From b44cd4502cac4a984e7a6594f701265454152875 Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Thu, 6 Jun 2019 10:46:24 -0400 Subject: [PATCH 1/5] Overhaul error handling --- routes/weather.ts | 29 ++++++++++++++++++++++++----- routes/weatherProviders/DarkSky.ts | 14 +++++++------- routes/weatherProviders/OWM.ts | 12 ++++++------ types.ts | 4 ++-- 4 files changed, 39 insertions(+), 20 deletions(-) diff --git a/routes/weather.ts b/routes/weather.ts index 70450aa..48d62b1 100644 --- a/routes/weather.ts +++ b/routes/weather.ts @@ -71,7 +71,8 @@ async function resolveCoordinates( location: string ): Promise< GeoCoordinates > * Makes an HTTP/HTTPS GET request to the specified URL and parses the JSON response body. * @param url The URL to fetch. * @return A Promise that will be resolved the with parsed response body if the request succeeds, or will be rejected - * with an Error if the request or JSON parsing fails. + * with an error if the request or JSON parsing fails. This error may contain information about the HTTP request or, + * response including API keys and other sensitive information. */ export async function httpJSONRequest(url: string ): Promise< any > { try { @@ -110,6 +111,7 @@ function getTimeData( coordinates: GeoCoordinates ): TimeData { * @param adjustmentOptions Options to tweak the calculation, or undefined/null if no custom values are to be used. * @param wateringData The weather to use to calculate watering percentage. * @return The percentage that watering should be scaled by. + * @throws An error message will be thrown if the watering scale cannot be calculated. */ function calculateZimmermanWateringScale( adjustmentOptions: AdjustmentOptions, wateringData: WateringData ): number { @@ -117,7 +119,7 @@ function calculateZimmermanWateringScale( adjustmentOptions: AdjustmentOptions, // Check to make sure valid data exists for all factors if ( !validateValues( [ "temp", "humidity", "precip" ], wateringData ) ) { - return 100; + throw "Necessary field(s) were missing from WateringData."; } // Get baseline conditions for 100% water level, if provided @@ -196,7 +198,13 @@ export const getWeatherData = async function( req: express.Request, res: express // Continue with the weather request const timeData: TimeData = getTimeData( coordinates ); - const weatherData: WeatherData = await weatherProvider.getWeatherData( coordinates ); + let weatherData: WeatherData; + try { + weatherData = await weatherProvider.getWeatherData( coordinates ); + } catch ( err ) { + res.send( "Error: " + err ); + return; + } res.json( { ...timeData, @@ -259,7 +267,12 @@ export const getWateringData = async function( req: express.Request, res: expres return; } - wateringData = await weatherProvider.getWateringData( coordinates ); + try { + wateringData = await weatherProvider.getWateringData( coordinates ); + } catch ( err ) { + res.send( "Error: " + err ); + return; + } } let scale = -1, rainDelay = -1; @@ -328,7 +341,8 @@ export const getWateringData = async function( req: express.Request, res: expres * Makes an HTTP/HTTPS GET request to the specified URL and returns the response body. * @param url The URL to fetch. * @return A Promise that will be resolved the with response body if the request succeeds, or will be rejected with an - * Error if the request fails. + * error if the request fails or returns a non-200 status code. This error may contain information about the HTTP + * request or, response including API keys and other sensitive information. */ async function httpRequest( url: string ): Promise< string > { return new Promise< any >( ( resolve, reject ) => { @@ -343,6 +357,11 @@ async function httpRequest( url: string ): Promise< string > { }; ( isHttps ? https : http ).get( options, ( response ) => { + if ( response.statusCode !== 200 ) { + reject( `Received ${ response.statusCode } status code for URL '${ url }'.` ); + return; + } + let data = ""; // Reassemble the data as it comes in diff --git a/routes/weatherProviders/DarkSky.ts b/routes/weatherProviders/DarkSky.ts index 9655acf..8bbe377 100644 --- a/routes/weatherProviders/DarkSky.ts +++ b/routes/weatherProviders/DarkSky.ts @@ -17,12 +17,12 @@ async function getDarkSkyWateringData( coordinates: GeoCoordinates ): Promise< W yesterdayData = await httpJSONRequest( yesterdayUrl ); todayData = await httpJSONRequest( todayUrl ); } catch (err) { - // Indicate watering data could not be retrieved if an API error occurs. - return undefined; + console.error( "Error retrieving weather information from Dark Sky:", err ); + throw "An error occurred while retrieving weather information from Dark Sky." } if ( !todayData.hourly || !todayData.hourly.data || !yesterdayData.hourly || !yesterdayData.hourly.data ) { - return undefined; + throw "Necessary field(s) were missing from weather information returned by Dark Sky."; } /* The number of hourly forecasts to use from today's data. This will only include elements that contain historic @@ -39,7 +39,7 @@ async function getDarkSkyWateringData( coordinates: GeoCoordinates ): Promise< W // Fail if not enough data is available. if ( samples.length !== 24 ) { - return undefined; + throw "Insufficient data was returned by Dark Sky."; } const totals = { temp: 0, humidity: 0, precip: 0 }; @@ -66,12 +66,12 @@ async function getDarkSkyWeatherData( coordinates: GeoCoordinates ): Promise< We try { forecast = await httpJSONRequest( forecastUrl ); } catch (err) { - // Indicate weather data could not be retrieved if an API error occurs. - return undefined; + console.error( "Error retrieving weather information from Dark Sky:", err ); + throw "An error occurred while retrieving weather information from Dark Sky." } if ( !forecast.currently || !forecast.daily || !forecast.daily.data ) { - return undefined; + throw "Necessary field(s) were missing from weather information returned by Dark Sky."; } const weather: WeatherData = { diff --git a/routes/weatherProviders/OWM.ts b/routes/weatherProviders/OWM.ts index 096fae2..af86c48 100644 --- a/routes/weatherProviders/OWM.ts +++ b/routes/weatherProviders/OWM.ts @@ -10,13 +10,13 @@ async function getOWMWateringData( coordinates: GeoCoordinates ): Promise< Water try { forecast = await httpJSONRequest( forecastUrl ); } catch (err) { - // Indicate watering data could not be retrieved if an API error occurs. - return undefined; + console.error( "Error retrieving weather information from OWM:", err ); + throw "An error occurred while retrieving weather information from OWM." } // Indicate watering data could not be retrieved if the forecast data is incomplete. if ( !forecast || !forecast.list ) { - return undefined; + throw "Necessary field(s) were missing from weather information returned by OWM."; } let totalTemp = 0, @@ -49,13 +49,13 @@ async function getOWMWeatherData( coordinates: GeoCoordinates ): Promise< Weathe current = await httpJSONRequest( currentUrl ); forecast = await httpJSONRequest( forecastDailyUrl ); } catch (err) { - // Indicate watering data could not be retrieved if an API error occurs. - return undefined; + console.error( "Error retrieving weather information from OWM:", err ); + throw "An error occurred while retrieving weather information from OWM." } // Indicate watering data could not be retrieved if the forecast data is incomplete. if ( !current || !current.main || !current.wind || !current.weather || !forecast || !forecast.list ) { - return undefined; + throw "Necessary field(s) were missing from weather information returned by OWM."; } const weather: WeatherData = { diff --git a/types.ts b/types.ts index 6727935..8fcdf97 100644 --- a/types.ts +++ b/types.ts @@ -90,7 +90,7 @@ export interface WeatherProvider { * Retrieves weather data necessary for watering level calculations. * @param coordinates The coordinates to retrieve the watering data for. * @return A Promise that will be resolved with the WateringData if it is successfully retrieved, - * or resolved with undefined if an error occurs while retrieving the WateringData. + * or rejected with an error message if an error occurs while retrieving the WateringData. */ getWateringData?( coordinates : GeoCoordinates ): Promise< WateringData >; @@ -98,7 +98,7 @@ export interface WeatherProvider { * Retrieves the current weather data for usage in the mobile app. * @param coordinates The coordinates to retrieve the weather for * @return A Promise that will be resolved with the WeatherData if it is successfully retrieved, - * or resolved with undefined if an error occurs while retrieving the WeatherData. + * or rejected with an error message if an error occurs while retrieving the WeatherData. */ getWeatherData?( coordinates : GeoCoordinates ): Promise< WeatherData >; } From 16b13c1e43b8aa90a9d90bc3a5bd278b871446d1 Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Thu, 6 Jun 2019 13:01:26 -0400 Subject: [PATCH 2/5] Handle errors thrown by calculateZimmermanWateringScale --- routes/weather.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/routes/weather.ts b/routes/weather.ts index 48d62b1..c34cf17 100644 --- a/routes/weather.ts +++ b/routes/weather.ts @@ -278,7 +278,12 @@ export const getWateringData = async function( req: express.Request, res: expres let scale = -1, rainDelay = -1; if ( adjustmentMethod === ADJUSTMENT_METHOD.ZIMMERMAN ) { - scale = calculateZimmermanWateringScale( adjustmentOptions, wateringData ); + try { + scale = calculateZimmermanWateringScale( adjustmentOptions, wateringData ); + } catch ( err ) { + res.send( "Error: " + err ); + return; + } } if (wateringData) { From 8368991c67be80c421d8ebbd50a55013686fe1af Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Thu, 6 Jun 2019 16:11:59 -0400 Subject: [PATCH 3/5] Convert WeatherProvider from interface to class --- routes/weather.ts | 5 +- routes/weatherProviders/DarkSky.ts | 193 ++++++++++----------- routes/weatherProviders/OWM.ts | 172 +++++++++--------- routes/weatherProviders/WeatherProvider.ts | 19 ++ routes/weatherProviders/local.ts | 43 +++-- types.ts | 18 -- 6 files changed, 222 insertions(+), 228 deletions(-) create mode 100644 routes/weatherProviders/WeatherProvider.ts diff --git a/routes/weather.ts b/routes/weather.ts index c34cf17..257dff8 100644 --- a/routes/weather.ts +++ b/routes/weather.ts @@ -5,8 +5,9 @@ import * as SunCalc from "suncalc"; import * as moment from "moment-timezone"; import * as geoTZ from "geo-tz"; -import { AdjustmentOptions, GeoCoordinates, TimeData, WateringData, WeatherData, WeatherProvider } from "../types"; -const weatherProvider: WeatherProvider = require("./weatherProviders/" + ( process.env.WEATHER_PROVIDER || "OWM" ) ).default; +import { AdjustmentOptions, GeoCoordinates, TimeData, WateringData, WeatherData } from "../types"; +import { WeatherProvider } from "./weatherProviders/WeatherProvider"; +const weatherProvider: WeatherProvider = new ( require("./weatherProviders/" + ( process.env.WEATHER_PROVIDER || "OWM" ) ).default )(); // Define regex filters to match against location const filters = { diff --git a/routes/weatherProviders/DarkSky.ts b/routes/weatherProviders/DarkSky.ts index 8bbe377..7098f39 100644 --- a/routes/weatherProviders/DarkSky.ts +++ b/routes/weatherProviders/DarkSky.ts @@ -1,113 +1,110 @@ import * as moment from "moment-timezone"; -import { GeoCoordinates, WateringData, WeatherData, WeatherProvider } from "../../types"; +import { GeoCoordinates, WateringData, WeatherData } from "../../types"; import { httpJSONRequest } from "../weather"; +import { WeatherProvider } from "./WeatherProvider"; -async function getDarkSkyWateringData( coordinates: GeoCoordinates ): Promise< WateringData > { - // The Unix timestamp of 24 hours ago. - const yesterdayTimestamp: number = moment().subtract( 1, "day" ).unix(); - const todayTimestamp: number = moment().unix(); +export default class DarkSkyWeatherProvider extends WeatherProvider { - const DARKSKY_API_KEY = process.env.DARKSKY_API_KEY, - yesterdayUrl = `https://api.darksky.net/forecast/${DARKSKY_API_KEY}/${coordinates[0]},${coordinates[1]},${yesterdayTimestamp}?exclude=currently,minutely,daily,alerts,flags`, - todayUrl = `https://api.darksky.net/forecast/${DARKSKY_API_KEY}/${coordinates[0]},${coordinates[1]},${todayTimestamp}?exclude=currently,minutely,daily,alerts,flags`; + public async getWateringData( coordinates: GeoCoordinates ): Promise< WateringData > { + // The Unix timestamp of 24 hours ago. + const yesterdayTimestamp: number = moment().subtract( 1, "day" ).unix(); + const todayTimestamp: number = moment().unix(); - let yesterdayData, todayData; - try { - yesterdayData = await httpJSONRequest( yesterdayUrl ); - todayData = await httpJSONRequest( todayUrl ); - } catch (err) { - console.error( "Error retrieving weather information from Dark Sky:", err ); - throw "An error occurred while retrieving weather information from Dark Sky." + const DARKSKY_API_KEY = process.env.DARKSKY_API_KEY, + yesterdayUrl = `https://api.darksky.net/forecast/${ DARKSKY_API_KEY }/${ coordinates[ 0 ] },${ coordinates[ 1 ] },${ yesterdayTimestamp }?exclude=currently,minutely,daily,alerts,flags`, + todayUrl = `https://api.darksky.net/forecast/${ DARKSKY_API_KEY }/${ coordinates[ 0 ] },${ coordinates[ 1 ] },${ todayTimestamp }?exclude=currently,minutely,daily,alerts,flags`; + + let yesterdayData, todayData; + try { + yesterdayData = await httpJSONRequest( yesterdayUrl ); + todayData = await httpJSONRequest( todayUrl ); + } catch ( err ) { + console.error( "Error retrieving weather information from Dark Sky:", err ); + throw "An error occurred while retrieving weather information from Dark Sky." + } + + if ( !todayData.hourly || !todayData.hourly.data || !yesterdayData.hourly || !yesterdayData.hourly.data ) { + throw "Necessary field(s) were missing from weather information returned by Dark Sky."; + } + + /* The number of hourly forecasts to use from today's data. This will only include elements that contain historic + data (not forecast data). */ + // Find the first element that contains forecast data. + const todayElements = Math.min( 24, todayData.hourly.data.findIndex( ( data ) => data.time > todayTimestamp - 60 * 60 ) ); + + /* Take as much data as possible from the first elements of today's data and take the remaining required data from + the remaining data from the last elements of yesterday's data. */ + const samples = [ + ...yesterdayData.hourly.data.slice( todayElements - 24 ), + ...todayData.hourly.data.slice( 0, todayElements ) + ]; + + // Fail if not enough data is available. + if ( samples.length !== 24 ) { + throw "Insufficient data was returned by Dark Sky."; + } + + const totals = { temp: 0, humidity: 0, precip: 0 }; + for ( const sample of samples ) { + totals.temp += sample.temperature; + totals.humidity += sample.humidity; + totals.precip += sample.precipIntensity + } + + return { + weatherProvider: "DarkSky", + temp: totals.temp / 24, + humidity: totals.humidity / 24 * 100, + precip: totals.precip, + raining: samples[ samples.length - 1 ].precipIntensity > 0 + }; } - if ( !todayData.hourly || !todayData.hourly.data || !yesterdayData.hourly || !yesterdayData.hourly.data ) { - throw "Necessary field(s) were missing from weather information returned by Dark Sky."; - } + public async getWeatherData( coordinates: GeoCoordinates ): Promise< WeatherData > { + const DARKSKY_API_KEY = process.env.DARKSKY_API_KEY, + forecastUrl = `https://api.darksky.net/forecast/${ DARKSKY_API_KEY }/${ coordinates[ 0 ] },${ coordinates[ 1 ] }?exclude=minutely,alerts,flags`; - /* The number of hourly forecasts to use from today's data. This will only include elements that contain historic - data (not forecast data). */ - // Find the first element that contains forecast data. - const todayElements = Math.min( 24, todayData.hourly.data.findIndex( ( data ) => data.time > todayTimestamp - 60 * 60 ) ); + let forecast; + try { + forecast = await httpJSONRequest( forecastUrl ); + } catch ( err ) { + console.error( "Error retrieving weather information from Dark Sky:", err ); + throw "An error occurred while retrieving weather information from Dark Sky." + } - /* Take as much data as possible from the first elements of today's data and take the remaining required data from - the remaining data from the last elements of yesterday's data. */ - const samples = [ - ...yesterdayData.hourly.data.slice( todayElements - 24 ), - ...todayData.hourly.data.slice( 0, todayElements ) - ]; + if ( !forecast.currently || !forecast.daily || !forecast.daily.data ) { + throw "Necessary field(s) were missing from weather information returned by Dark Sky."; + } - // Fail if not enough data is available. - if ( samples.length !== 24 ) { - throw "Insufficient data was returned by Dark Sky."; - } - - const totals = { temp: 0, humidity: 0, precip: 0 }; - for ( const sample of samples ) { - totals.temp += sample.temperature; - totals.humidity += sample.humidity; - totals.precip += sample.precipIntensity - } - - return { - weatherProvider: "DarkSky", - temp : totals.temp / 24, - humidity: totals.humidity / 24 * 100, - precip: totals.precip, - raining: samples[ samples.length - 1 ].precipIntensity > 0 - }; -} - -async function getDarkSkyWeatherData( coordinates: GeoCoordinates ): Promise< WeatherData > { - const DARKSKY_API_KEY = process.env.DARKSKY_API_KEY, - forecastUrl = `https://api.darksky.net/forecast/${DARKSKY_API_KEY}/${coordinates[0]},${coordinates[1]}?exclude=minutely,alerts,flags`; - - let forecast; - try { - forecast = await httpJSONRequest( forecastUrl ); - } catch (err) { - console.error( "Error retrieving weather information from Dark Sky:", err ); - throw "An error occurred while retrieving weather information from Dark Sky." - } - - if ( !forecast.currently || !forecast.daily || !forecast.daily.data ) { - throw "Necessary field(s) were missing from weather information returned by Dark Sky."; - } - - const weather: WeatherData = { - weatherProvider: "DarkSky", - temp: Math.floor( forecast.currently.temperature ), - humidity: Math.floor( forecast.currently.humidity * 100 ), - wind: Math.floor( forecast.currently.windSpeed ), - description: forecast.currently.summary, - // TODO set this - icon: "", - - region: "", - city: "", - minTemp: Math.floor( forecast.daily.data[ 0 ].temperatureMin ), - maxTemp: Math.floor( forecast.daily.data[ 0 ].temperatureMax ), - precip: forecast.daily.data[ 0 ].precipIntensity * 24, - forecast: [ ] - }; - - for ( let index = 0; index < forecast.daily.data.length; index++ ) { - weather.forecast.push( { - temp_min: Math.floor( forecast.daily.data[ index ].temperatureMin ), - temp_max: Math.floor( forecast.daily.data[ index ].temperatureMax ), - date: forecast.daily.data[ index ].time, + const weather: WeatherData = { + weatherProvider: "DarkSky", + temp: Math.floor( forecast.currently.temperature ), + humidity: Math.floor( forecast.currently.humidity * 100 ), + wind: Math.floor( forecast.currently.windSpeed ), + description: forecast.currently.summary, // TODO set this icon: "", - description: forecast.daily.data[ index ].summary - } ); + + region: "", + city: "", + minTemp: Math.floor( forecast.daily.data[ 0 ].temperatureMin ), + maxTemp: Math.floor( forecast.daily.data[ 0 ].temperatureMax ), + precip: forecast.daily.data[ 0 ].precipIntensity * 24, + forecast: [] + }; + + for ( let index = 0; index < forecast.daily.data.length; index++ ) { + weather.forecast.push( { + temp_min: Math.floor( forecast.daily.data[ index ].temperatureMin ), + temp_max: Math.floor( forecast.daily.data[ index ].temperatureMax ), + date: forecast.daily.data[ index ].time, + // TODO set this + icon: "", + description: forecast.daily.data[ index ].summary + } ); + } + + return weather; } - - return weather; } - - -const DarkSkyWeatherProvider: WeatherProvider = { - getWateringData: getDarkSkyWateringData, - getWeatherData: getDarkSkyWeatherData -}; -export default DarkSkyWeatherProvider; diff --git a/routes/weatherProviders/OWM.ts b/routes/weatherProviders/OWM.ts index af86c48..fdb4023 100644 --- a/routes/weatherProviders/OWM.ts +++ b/routes/weatherProviders/OWM.ts @@ -1,94 +1,92 @@ -import { GeoCoordinates, WateringData, WeatherData, WeatherProvider } from "../../types"; +import { GeoCoordinates, WateringData, WeatherData } from "../../types"; import { httpJSONRequest } from "../weather"; +import { WeatherProvider } from "./WeatherProvider"; -async function getOWMWateringData( coordinates: GeoCoordinates ): Promise< WateringData > { - const OWM_API_KEY = process.env.OWM_API_KEY, - forecastUrl = "http://api.openweathermap.org/data/2.5/forecast?appid=" + OWM_API_KEY + "&units=imperial&lat=" + coordinates[ 0 ] + "&lon=" + coordinates[ 1 ]; +export default class OWMWeatherProvider extends WeatherProvider { - // Perform the HTTP request to retrieve the weather data - let forecast; - try { - forecast = await httpJSONRequest( forecastUrl ); - } catch (err) { - console.error( "Error retrieving weather information from OWM:", err ); - throw "An error occurred while retrieving weather information from OWM." + public async getWateringData( coordinates: GeoCoordinates ): Promise< WateringData > { + const OWM_API_KEY = process.env.OWM_API_KEY, + forecastUrl = "http://api.openweathermap.org/data/2.5/forecast?appid=" + OWM_API_KEY + "&units=imperial&lat=" + coordinates[ 0 ] + "&lon=" + coordinates[ 1 ]; + + // Perform the HTTP request to retrieve the weather data + let forecast; + try { + forecast = await httpJSONRequest( forecastUrl ); + } catch ( err ) { + console.error( "Error retrieving weather information from OWM:", err ); + throw "An error occurred while retrieving weather information from OWM." + } + + // Indicate watering data could not be retrieved if the forecast data is incomplete. + if ( !forecast || !forecast.list ) { + throw "Necessary field(s) were missing from weather information returned by OWM."; + } + + let totalTemp = 0, + totalHumidity = 0, + totalPrecip = 0; + + const periods = Math.min( forecast.list.length, 8 ); + for ( let index = 0; index < periods; index++ ) { + totalTemp += parseFloat( forecast.list[ index ].main.temp ); + totalHumidity += parseInt( forecast.list[ index ].main.humidity ); + totalPrecip += ( forecast.list[ index ].rain ? parseFloat( forecast.list[ index ].rain[ "3h" ] || 0 ) : 0 ); + } + + return { + weatherProvider: "OWM", + temp: totalTemp / periods, + humidity: totalHumidity / periods, + precip: totalPrecip / 25.4, + raining: ( forecast.list[ 0 ].rain ? ( parseFloat( forecast.list[ 0 ].rain[ "3h" ] || 0 ) > 0 ) : false ) + }; } - // Indicate watering data could not be retrieved if the forecast data is incomplete. - if ( !forecast || !forecast.list ) { - throw "Necessary field(s) were missing from weather information returned by OWM."; + public async getWeatherData( coordinates: GeoCoordinates ): Promise< WeatherData > { + const OWM_API_KEY = process.env.OWM_API_KEY, + currentUrl = "http://api.openweathermap.org/data/2.5/weather?appid=" + OWM_API_KEY + "&units=imperial&lat=" + coordinates[ 0 ] + "&lon=" + coordinates[ 1 ], + forecastDailyUrl = "http://api.openweathermap.org/data/2.5/forecast/daily?appid=" + OWM_API_KEY + "&units=imperial&lat=" + coordinates[ 0 ] + "&lon=" + coordinates[ 1 ]; + + let current, forecast; + try { + current = await httpJSONRequest( currentUrl ); + forecast = await httpJSONRequest( forecastDailyUrl ); + } catch ( err ) { + console.error( "Error retrieving weather information from OWM:", err ); + throw "An error occurred while retrieving weather information from OWM." + } + + // Indicate watering data could not be retrieved if the forecast data is incomplete. + if ( !current || !current.main || !current.wind || !current.weather || !forecast || !forecast.list ) { + throw "Necessary field(s) were missing from weather information returned by OWM."; + } + + const weather: WeatherData = { + weatherProvider: "OWM", + temp: parseInt( current.main.temp ), + humidity: parseInt( current.main.humidity ), + wind: parseInt( current.wind.speed ), + description: current.weather[ 0 ].description, + icon: current.weather[ 0 ].icon, + + region: forecast.city.country, + city: forecast.city.name, + minTemp: parseInt( forecast.list[ 0 ].temp.min ), + maxTemp: parseInt( forecast.list[ 0 ].temp.max ), + precip: ( forecast.list[ 0 ].rain ? parseFloat( forecast.list[ 0 ].rain || 0 ) : 0 ) / 25.4, + forecast: [] + }; + + for ( let index = 0; index < forecast.list.length; index++ ) { + weather.forecast.push( { + temp_min: parseInt( forecast.list[ index ].temp.min ), + temp_max: parseInt( forecast.list[ index ].temp.max ), + date: parseInt( forecast.list[ index ].dt ), + icon: forecast.list[ index ].weather[ 0 ].icon, + description: forecast.list[ index ].weather[ 0 ].description + } ); + } + + return weather; } - - let totalTemp = 0, - totalHumidity = 0, - totalPrecip = 0; - - const periods = Math.min(forecast.list.length, 8); - for ( let index = 0; index < periods; index++ ) { - totalTemp += parseFloat( forecast.list[ index ].main.temp ); - totalHumidity += parseInt( forecast.list[ index ].main.humidity ); - totalPrecip += ( forecast.list[ index ].rain ? parseFloat( forecast.list[ index ].rain[ "3h" ] || 0 ) : 0 ); - } - - return { - weatherProvider: "OWM", - temp: totalTemp / periods, - humidity: totalHumidity / periods, - precip: totalPrecip / 25.4, - raining: ( forecast.list[ 0 ].rain ? ( parseFloat( forecast.list[ 0 ].rain[ "3h" ] || 0 ) > 0 ) : false ) - }; } - -async function getOWMWeatherData( coordinates: GeoCoordinates ): Promise< WeatherData > { - const OWM_API_KEY = process.env.OWM_API_KEY, - currentUrl = "http://api.openweathermap.org/data/2.5/weather?appid=" + OWM_API_KEY + "&units=imperial&lat=" + coordinates[ 0 ] + "&lon=" + coordinates[ 1 ], - forecastDailyUrl = "http://api.openweathermap.org/data/2.5/forecast/daily?appid=" + OWM_API_KEY + "&units=imperial&lat=" + coordinates[ 0 ] + "&lon=" + coordinates[ 1 ]; - - let current, forecast; - try { - current = await httpJSONRequest( currentUrl ); - forecast = await httpJSONRequest( forecastDailyUrl ); - } catch (err) { - console.error( "Error retrieving weather information from OWM:", err ); - throw "An error occurred while retrieving weather information from OWM." - } - - // Indicate watering data could not be retrieved if the forecast data is incomplete. - if ( !current || !current.main || !current.wind || !current.weather || !forecast || !forecast.list ) { - throw "Necessary field(s) were missing from weather information returned by OWM."; - } - - const weather: WeatherData = { - weatherProvider: "OWM", - temp: parseInt( current.main.temp ), - humidity: parseInt( current.main.humidity ), - wind: parseInt( current.wind.speed ), - description: current.weather[0].description, - icon: current.weather[0].icon, - - region: forecast.city.country, - city: forecast.city.name, - minTemp: parseInt( forecast.list[ 0 ].temp.min ), - maxTemp: parseInt( forecast.list[ 0 ].temp.max ), - precip: ( forecast.list[ 0 ].rain ? parseFloat( forecast.list[ 0 ].rain || 0 ) : 0 ) / 25.4, - forecast: [] - }; - - for ( let index = 0; index < forecast.list.length; index++ ) { - weather.forecast.push( { - temp_min: parseInt( forecast.list[ index ].temp.min ), - temp_max: parseInt( forecast.list[ index ].temp.max ), - date: parseInt( forecast.list[ index ].dt ), - icon: forecast.list[ index ].weather[ 0 ].icon, - description: forecast.list[ index ].weather[ 0 ].description - } ); - } - - return weather; -} - -const OWMWeatherProvider: WeatherProvider = { - getWateringData: getOWMWateringData, - getWeatherData: getOWMWeatherData -}; -export default OWMWeatherProvider; diff --git a/routes/weatherProviders/WeatherProvider.ts b/routes/weatherProviders/WeatherProvider.ts new file mode 100644 index 0000000..5581860 --- /dev/null +++ b/routes/weatherProviders/WeatherProvider.ts @@ -0,0 +1,19 @@ +import { GeoCoordinates, WateringData, WeatherData } from "../../types"; + +export class WeatherProvider { + /** + * Retrieves weather data necessary for watering level calculations. + * @param coordinates The coordinates to retrieve the watering data for. + * @return A Promise that will be resolved with the WateringData if it is successfully retrieved, + * or rejected with an error message if an error occurs while retrieving the WateringData. + */ + getWateringData?( coordinates : GeoCoordinates ): Promise< WateringData >; + + /** + * Retrieves the current weather data for usage in the mobile app. + * @param coordinates The coordinates to retrieve the weather for + * @return A Promise that will be resolved with the WeatherData if it is successfully retrieved, + * or rejected with an error message if an error occurs while retrieving the WeatherData. + */ + getWeatherData?( coordinates : GeoCoordinates ): Promise< WeatherData >; +} diff --git a/routes/weatherProviders/local.ts b/routes/weatherProviders/local.ts index 314df1c..71e4798 100644 --- a/routes/weatherProviders/local.ts +++ b/routes/weatherProviders/local.ts @@ -1,6 +1,7 @@ import * as express from "express"; import { CronJob } from "cron"; -import { GeoCoordinates, WateringData, WeatherProvider } from "../../types"; +import { GeoCoordinates, WateringData } from "../../types"; +import { WeatherProvider } from "./WeatherProvider"; const count = { temp: 0, humidity: 0 }; @@ -43,22 +44,25 @@ export const captureWUStream = function( req: express.Request, res: express.Resp res.send( "success\n" ); }; -export const getLocalWateringData = function(): WateringData { - const result: WateringData = { - ...yesterday as WateringData, - // Use today's weather if we dont have information for yesterday yet (i.e. on startup) - ...today, - // PWS report "buckets" so consider it still raining if last bucket was less than an hour ago - raining: last_bucket !== undefined ? ( ( Date.now() - +last_bucket ) / 1000 / 60 / 60 < 1 ) : undefined, - weatherProvider: "local" +export default class LocalWeatherProvider extends WeatherProvider { + + public async getWateringData( coordinates: GeoCoordinates ): Promise< WateringData > { + const result: WateringData = { + ...yesterday as WateringData, + // Use today's weather if we dont have information for yesterday yet (i.e. on startup) + ...today, + // PWS report "buckets" so consider it still raining if last bucket was less than an hour ago + raining: last_bucket !== undefined ? ( ( Date.now() - +last_bucket ) / 1000 / 60 / 60 < 1 ) : undefined, + weatherProvider: "local" + }; + + if ( "precip" in yesterday && "precip" in today ) { + result.precip = yesterday.precip + today.precip; + } + + return result; }; - - if ( "precip" in yesterday && "precip" in today ) { - result.precip = yesterday.precip + today.precip; - } - - return result; -}; +} new CronJob( "0 0 0 * * *", function() { @@ -74,10 +78,3 @@ interface PWSStatus { humidity?: number; precip?: number; } - -const LocalWeatherProvider: WeatherProvider = { - getWateringData: async function ( coordinates: GeoCoordinates ) { - return getLocalWateringData(); - } -}; -export default LocalWeatherProvider; diff --git a/types.ts b/types.ts index 8fcdf97..07bbadb 100644 --- a/types.ts +++ b/types.ts @@ -85,22 +85,4 @@ export interface AdjustmentOptions { d?: number; } -export interface WeatherProvider { - /** - * Retrieves weather data necessary for watering level calculations. - * @param coordinates The coordinates to retrieve the watering data for. - * @return A Promise that will be resolved with the WateringData if it is successfully retrieved, - * or rejected with an error message if an error occurs while retrieving the WateringData. - */ - getWateringData?( coordinates : GeoCoordinates ): Promise< WateringData >; - - /** - * Retrieves the current weather data for usage in the mobile app. - * @param coordinates The coordinates to retrieve the weather for - * @return A Promise that will be resolved with the WeatherData if it is successfully retrieved, - * or rejected with an error message if an error occurs while retrieving the WeatherData. - */ - getWeatherData?( coordinates : GeoCoordinates ): Promise< WeatherData >; -} - export type WeatherProviderId = "OWM" | "DarkSky" | "local"; From b57b019295270031bc4c9d093e44443ff2429571 Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Thu, 6 Jun 2019 16:17:06 -0400 Subject: [PATCH 4/5] Add default implementations to WeatherProvider --- routes/weather.ts | 10 ---------- routes/weatherProviders/WeatherProvider.ts | 14 ++++++++++---- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/routes/weather.ts b/routes/weather.ts index 257dff8..4b7856f 100644 --- a/routes/weather.ts +++ b/routes/weather.ts @@ -184,11 +184,6 @@ function checkWeatherRestriction( adjustmentValue: number, weather: WateringData export const getWeatherData = async function( req: express.Request, res: express.Response ) { const location: string = getParameter(req.query.loc); - if ( !weatherProvider.getWeatherData ) { - res.send( "Error: selected WeatherProvider does not support getWeatherData" ); - return; - } - let coordinates: GeoCoordinates; try { coordinates = await resolveCoordinates( location ); @@ -263,11 +258,6 @@ export const getWateringData = async function( req: express.Request, res: expres let timeData: TimeData = getTimeData( coordinates ); let wateringData: WateringData; if ( adjustmentMethod !== ADJUSTMENT_METHOD.MANUAL || checkRestrictions ) { - if ( !weatherProvider.getWateringData ) { - res.send( "Error: selected WeatherProvider does not support getWateringData" ); - return; - } - try { wateringData = await weatherProvider.getWateringData( coordinates ); } catch ( err ) { diff --git a/routes/weatherProviders/WeatherProvider.ts b/routes/weatherProviders/WeatherProvider.ts index 5581860..4826df2 100644 --- a/routes/weatherProviders/WeatherProvider.ts +++ b/routes/weatherProviders/WeatherProvider.ts @@ -5,15 +5,21 @@ export class WeatherProvider { * Retrieves weather data necessary for watering level calculations. * @param coordinates The coordinates to retrieve the watering data for. * @return A Promise that will be resolved with the WateringData if it is successfully retrieved, - * or rejected with an error message if an error occurs while retrieving the WateringData. + * or rejected with an error message if an error occurs while retrieving the WateringData or the WeatherProvider + * does not support this method. */ - getWateringData?( coordinates : GeoCoordinates ): Promise< WateringData >; + getWateringData( coordinates : GeoCoordinates ): Promise< WateringData > { + throw "Selected WeatherProvider does not support getWateringData"; + } /** * Retrieves the current weather data for usage in the mobile app. * @param coordinates The coordinates to retrieve the weather for * @return A Promise that will be resolved with the WeatherData if it is successfully retrieved, - * or rejected with an error message if an error occurs while retrieving the WeatherData. + * or rejected with an error message if an error occurs while retrieving the WeatherData or the WeatherProvider does + * not support this method. */ - getWeatherData?( coordinates : GeoCoordinates ): Promise< WeatherData >; + getWeatherData( coordinates : GeoCoordinates ): Promise< WeatherData > { + throw "Selected WeatherProvider does not support getWeatherData"; + } } From f489993d8c98c161c921acda5797cc3ba9bfbd51 Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Thu, 6 Jun 2019 17:14:03 -0400 Subject: [PATCH 5/5] Make error handling backwards compatible --- routes/weather.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/routes/weather.ts b/routes/weather.ts index 4b7856f..3e93147 100644 --- a/routes/weather.ts +++ b/routes/weather.ts @@ -225,6 +225,12 @@ export const getWateringData = async function( req: express.Request, res: expres remoteAddress: string = getParameter(req.headers[ "x-forwarded-for" ]) || req.connection.remoteAddress, adjustmentOptions: AdjustmentOptions; + /* A message to include in the response to indicate that the watering scale was not calculated correctly and + defaulted to 100%. This approach is used for backwards compatibility because older OS firmware versions were + hardcoded to keep the previous watering scale if the response did not include a watering scale, but newer versions + might allow for different behaviors. */ + let errorMessage: string = undefined; + // X-Forwarded-For header may contain more than one IP address and therefore // the string is split against a comma and the first value is selected @@ -272,8 +278,9 @@ export const getWateringData = async function( req: express.Request, res: expres try { scale = calculateZimmermanWateringScale( adjustmentOptions, wateringData ); } catch ( err ) { - res.send( "Error: " + err ); - return; + // Default to a scale of 100% if the scale can't be calculated. + scale = 100; + errorMessage = err; } } @@ -305,7 +312,8 @@ export const getWateringData = async function( req: express.Request, res: expres sunrise: timeData.sunrise, sunset: timeData.sunset, eip: ipToInt( remoteAddress ), - rawData: undefined + rawData: undefined, + error: errorMessage }; if ( adjustmentMethod > ADJUSTMENT_METHOD.MANUAL ) { @@ -327,7 +335,8 @@ export const getWateringData = async function( req: express.Request, res: expres "&sunrise=" + data.sunrise + "&sunset=" + data.sunset + "&eip=" + data.eip + - ( data.rawData ? "&rawData=" + JSON.stringify( data.rawData ) : "" ) + ( data.rawData ? "&rawData=" + JSON.stringify( data.rawData ) : "" ) + + ( errorMessage ? "&error=" + encodeURIComponent( errorMessage ) : "" ) ); }