From b44cd4502cac4a984e7a6594f701265454152875 Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Thu, 6 Jun 2019 10:46:24 -0400 Subject: [PATCH 01/24] 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 69d08ed1b941b32ea9b0a28287e445706ad22e82 Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Thu, 6 Jun 2019 12:25:49 -0400 Subject: [PATCH 02/24] Add mock WeatherProvider --- routes/weatherProviders/mock.ts | 38 +++++++++++++++++++++++++++++++++ types.ts | 2 +- 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 routes/weatherProviders/mock.ts diff --git a/routes/weatherProviders/mock.ts b/routes/weatherProviders/mock.ts new file mode 100644 index 0000000..6fda63a --- /dev/null +++ b/routes/weatherProviders/mock.ts @@ -0,0 +1,38 @@ +import { GeoCoordinates, WateringData, WeatherData, WeatherProvider } from "../../types"; + +/** + * A WeatherProvider for testing purposes that returns weather data that is provided in the constructor. + * This is a special WeatherProvider designed for testing purposes and should not be activated using the + * WEATHER_PROVIDER environment variable. + */ +export default class MockWeatherProvider implements WeatherProvider{ + + private readonly mockData: MockWeatherData; + + public constructor(mockData: MockWeatherData) { + this.mockData = mockData; + } + + public async getWateringData( coordinates: GeoCoordinates ): Promise< WateringData > { + const data = this.mockData.wateringData; + if ( !data.weatherProvider ) { + data.weatherProvider = "mock"; + } + + return data; + } + + public async getWeatherData( coordinates: GeoCoordinates ): Promise< WeatherData > { + const data = this.mockData.weatherData; + if ( !data.weatherProvider ) { + data.weatherProvider = "mock"; + } + + return data; + } +} + +interface MockWeatherData { + wateringData?: WateringData, + weatherData?: WeatherData +} diff --git a/types.ts b/types.ts index 6727935..8c111d4 100644 --- a/types.ts +++ b/types.ts @@ -103,4 +103,4 @@ export interface WeatherProvider { getWeatherData?( coordinates : GeoCoordinates ): Promise< WeatherData >; } -export type WeatherProviderId = "OWM" | "DarkSky" | "local"; +export type WeatherProviderId = "OWM" | "DarkSky" | "local" | "mock"; From 16b13c1e43b8aa90a9d90bc3a5bd278b871446d1 Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Thu, 6 Jun 2019 13:01:26 -0400 Subject: [PATCH 03/24] 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 04/24] 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 05/24] 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 06/24] 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 ) : "" ) ); } From 6648dbc20ef424999c779d68892174171d856d74 Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Thu, 6 Jun 2019 17:17:34 -0400 Subject: [PATCH 07/24] Move mock WeatherProvider to weather.spec.ts --- routes/weather.spec.ts | 41 ++++++++++++++++++++++++++++++++- routes/weatherProviders/mock.ts | 38 ------------------------------ 2 files changed, 40 insertions(+), 39 deletions(-) delete mode 100644 routes/weatherProviders/mock.ts diff --git a/routes/weather.spec.ts b/routes/weather.spec.ts index 87cede5..ecd328c 100644 --- a/routes/weather.spec.ts +++ b/routes/weather.spec.ts @@ -5,6 +5,7 @@ import * as MockExpressResponse from 'mock-express-response'; import * as MockDate from 'mockdate'; import { getWateringData } from './weather'; +import { GeoCoordinates, WateringData, WeatherData, WeatherProvider } from "../types"; const expected = require( '../test/expected.json' ); const replies = require( '../test/replies.json' ); @@ -58,4 +59,42 @@ function mockOWM() { .filteringPath( function() { return "/"; } ) .get( "/" ) .reply( 200, replies[location].OWMData ); -} \ No newline at end of file +} + + +/** + * A WeatherProvider for testing purposes that returns weather data that is provided in the constructor. + * This is a special WeatherProvider designed for testing purposes and should not be activated using the + * WEATHER_PROVIDER environment variable. + */ +export class MockWeatherProvider implements WeatherProvider { + + private readonly mockData: MockWeatherData; + + public constructor(mockData: MockWeatherData) { + this.mockData = mockData; + } + + public async getWateringData( coordinates: GeoCoordinates ): Promise< WateringData > { + const data = this.mockData.wateringData; + if ( !data.weatherProvider ) { + data.weatherProvider = "mock"; + } + + return data; + } + + public async getWeatherData( coordinates: GeoCoordinates ): Promise< WeatherData > { + const data = this.mockData.weatherData; + if ( !data.weatherProvider ) { + data.weatherProvider = "mock"; + } + + return data; + } +} + +interface MockWeatherData { + wateringData?: WateringData, + weatherData?: WeatherData +} diff --git a/routes/weatherProviders/mock.ts b/routes/weatherProviders/mock.ts deleted file mode 100644 index 6fda63a..0000000 --- a/routes/weatherProviders/mock.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { GeoCoordinates, WateringData, WeatherData, WeatherProvider } from "../../types"; - -/** - * A WeatherProvider for testing purposes that returns weather data that is provided in the constructor. - * This is a special WeatherProvider designed for testing purposes and should not be activated using the - * WEATHER_PROVIDER environment variable. - */ -export default class MockWeatherProvider implements WeatherProvider{ - - private readonly mockData: MockWeatherData; - - public constructor(mockData: MockWeatherData) { - this.mockData = mockData; - } - - public async getWateringData( coordinates: GeoCoordinates ): Promise< WateringData > { - const data = this.mockData.wateringData; - if ( !data.weatherProvider ) { - data.weatherProvider = "mock"; - } - - return data; - } - - public async getWeatherData( coordinates: GeoCoordinates ): Promise< WeatherData > { - const data = this.mockData.weatherData; - if ( !data.weatherProvider ) { - data.weatherProvider = "mock"; - } - - return data; - } -} - -interface MockWeatherData { - wateringData?: WateringData, - weatherData?: WeatherData -} From 0281a835e256d3de789175a1be0ad0a1e5ba741a Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Sun, 9 Jun 2019 11:25:21 -0400 Subject: [PATCH 08/24] Refactor adjustment method selection --- routes/adjustmentMethods/AdjustmentMethod.ts | 58 ++++++++ .../ManualAdjustmentMethod.ts | 17 +++ .../RainDelayAdjustmentMethod.ts | 27 ++++ .../ZimmermanAdjustmentMethod.ts | 94 ++++++++++++ routes/weather.ts | 137 +++++------------- types.ts | 17 --- 6 files changed, 233 insertions(+), 117 deletions(-) create mode 100644 routes/adjustmentMethods/AdjustmentMethod.ts create mode 100644 routes/adjustmentMethods/ManualAdjustmentMethod.ts create mode 100644 routes/adjustmentMethods/RainDelayAdjustmentMethod.ts create mode 100644 routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts diff --git a/routes/adjustmentMethods/AdjustmentMethod.ts b/routes/adjustmentMethods/AdjustmentMethod.ts new file mode 100644 index 0000000..374f260 --- /dev/null +++ b/routes/adjustmentMethods/AdjustmentMethod.ts @@ -0,0 +1,58 @@ +import { GeoCoordinates, WateringData } from "../../types"; +import { WeatherProvider } from "../weatherProviders/WeatherProvider"; + + +export interface AdjustmentMethod { + /** + * Calculates the percentage that should be used to scale watering time. + * @param adjustmentOptions The user-specified options for the calculation, or undefined/null if no custom values + * are to be used. No checks will be made to ensure the AdjustmentOptions are the correct type that the function + * is expecting or to ensure that any of its fields are valid. + * @param wateringData The basic weather information of the watering site. This may be undefined if an error occurred + * while retrieving the data. + * @param coordinates The coordinates of the watering site. + * @param weatherProvider The WeatherProvider that should be used if the adjustment method needs to obtain any more + * weather data. + * @return A Promise that will be resolved with the result of the calculation, or rejected with an error message if + * the watering scale cannot be calculated. + * @throws An error message can be thrown if an error occurs while calculating the watering scale. + */ + calculateWateringScale( + adjustmentOptions: AdjustmentOptions, + wateringData: WateringData | undefined, + coordinates: GeoCoordinates, + weatherProvider: WeatherProvider + ): Promise< AdjustmentMethodResponse >; +} + +export interface AdjustmentMethodResponse { + /** + * The percentage that should be used to scale the watering level. This should be an integer between 0-200 (inclusive), + * or undefined if the watering level should not be changed. + */ + scale: number | undefined; + /** + * The raw data that was used to calculate the watering scale. This will be sent directly to the OS controller, so + * each field should be formatted in a way that the controller understands and numbers should be rounded + * appropriately to remove excessive figures. If no data was used (e.g. an error occurred), this should be undefined. + */ + rawData?: object; + /** + * How long watering should be delayed for (in hours) due to rain, or undefined if watering should not be delayed + * for a specific amount of time (either it should be delayed indefinitely or it should not be delayed at all). This + * property will not stop watering on its own, and the `scale` property should be set to 0 to actually prevent + * watering. + */ + rainDelay?: number; + // TODO consider removing this field and breaking backwards compatibility to handle all errors consistently. + /** + * An message to send to the OS firmware to indicate that an error occurred while calculating the watering + * scale and the returned scale either defaulted to some reasonable value or was calculated with incomplete data. + * Older firmware versions will ignore this field (they will silently swallow the error and use the returned scale), + * but newer firmware versions may be able to alert the user that an error occurred and/or default to a + * user-configured watering scale instead of using the one returned by the AdjustmentMethod. + */ + errorMessage?: string; +} + +export interface AdjustmentOptions {} diff --git a/routes/adjustmentMethods/ManualAdjustmentMethod.ts b/routes/adjustmentMethods/ManualAdjustmentMethod.ts new file mode 100644 index 0000000..d584f83 --- /dev/null +++ b/routes/adjustmentMethods/ManualAdjustmentMethod.ts @@ -0,0 +1,17 @@ +import { AdjustmentMethod, AdjustmentMethodResponse } from "./AdjustmentMethod"; + + +/** + * Does not change the watering scale (only time data will be returned). + */ +async function calculateManualWateringScale( ): Promise< AdjustmentMethodResponse > { + return { + scale: undefined + } +} + + +const ManualAdjustmentMethod: AdjustmentMethod = { + calculateWateringScale: calculateManualWateringScale +}; +export default ManualAdjustmentMethod; diff --git a/routes/adjustmentMethods/RainDelayAdjustmentMethod.ts b/routes/adjustmentMethods/RainDelayAdjustmentMethod.ts new file mode 100644 index 0000000..56b1406 --- /dev/null +++ b/routes/adjustmentMethods/RainDelayAdjustmentMethod.ts @@ -0,0 +1,27 @@ +import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./AdjustmentMethod"; +import { WateringData } from "../../types"; + + +/** + * Only delays watering if it is currently raining and does not adjust the watering scale. + */ +async function calculateRainDelayWateringScale( adjustmentOptions: RainDelayAdjustmentOptions, wateringData: WateringData | undefined ): Promise< AdjustmentMethodResponse > { + const raining = wateringData && wateringData.raining; + const d = adjustmentOptions && adjustmentOptions.hasOwnProperty( "d" ) ? adjustmentOptions.d : 24; + return { + scale: undefined, + rawData: { raining: raining ? 1 : 0 }, + rainDelay: raining ? d : undefined + } +} + +export interface RainDelayAdjustmentOptions extends AdjustmentOptions { + /** The rain delay to use (in hours). */ + d?: number; +} + + +const RainDelayAdjustmentMethod: AdjustmentMethod = { + calculateWateringScale: calculateRainDelayWateringScale +}; +export default RainDelayAdjustmentMethod; diff --git a/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts b/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts new file mode 100644 index 0000000..e5268c0 --- /dev/null +++ b/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts @@ -0,0 +1,94 @@ +import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./AdjustmentMethod"; +import { WateringData } from "../../types"; +import { validateValues } from "../weather"; + + +/** + * Calculates how much watering should be scaled based on weather and adjustment options using the Zimmerman method. + * (https://github.com/rszimm/sprinklers_pi/wiki/Weather-adjustments#formula-for-setting-the-scale) + */ +async function calculateZimmermanWateringScale( adjustmentOptions: ZimmermanAdjustmentOptions, wateringData: WateringData | undefined ): Promise< AdjustmentMethodResponse > { + + // Temporarily disabled since OWM forecast data is checking if rain is forecasted for 3 hours in the future. + /* + // Don't water if it is currently raining. + if ( wateringData && wateringData.raining ) { + return { + scale: 0, + rawData: { raining: 1 } + } + } + */ + + const rawData = { + h: wateringData ? Math.round( wateringData.humidity * 100) / 100 : null, + p: wateringData ? Math.round( wateringData.precip * 100 ) / 100 : null, + t: wateringData ? Math.round( wateringData.temp * 10 ) / 10 : null, + raining: wateringData ? ( wateringData.raining ? 1 : 0 ) : null + }; + + // Check to make sure valid data exists for all factors + if ( !validateValues( [ "temp", "humidity", "precip" ], wateringData ) ) { + // Default to a scale of 100% if fields are missing. + return { + scale: 100, + rawData: rawData, + errorMessage: "Necessary field(s) were missing from WateringData." + }; + } + + let humidityBase = 30, tempBase = 70, precipBase = 0; + + // Get baseline conditions for 100% water level, if provided + if ( adjustmentOptions ) { + humidityBase = adjustmentOptions.hasOwnProperty( "bh" ) ? adjustmentOptions.bh : humidityBase; + tempBase = adjustmentOptions.hasOwnProperty( "bt" ) ? adjustmentOptions.bt : tempBase; + precipBase = adjustmentOptions.hasOwnProperty( "br" ) ? adjustmentOptions.br : precipBase; + } + + let humidityFactor = ( humidityBase - wateringData.humidity ), + tempFactor = ( ( wateringData.temp - tempBase ) * 4 ), + precipFactor = ( ( precipBase - wateringData.precip ) * 200 ); + + // Apply adjustment options, if provided, by multiplying the percentage against the factor + if ( adjustmentOptions ) { + if ( adjustmentOptions.hasOwnProperty( "h" ) ) { + humidityFactor = humidityFactor * ( adjustmentOptions.h / 100 ); + } + + if ( adjustmentOptions.hasOwnProperty( "t" ) ) { + tempFactor = tempFactor * ( adjustmentOptions.t / 100 ); + } + + if ( adjustmentOptions.hasOwnProperty( "r" ) ) { + precipFactor = precipFactor * ( adjustmentOptions.r / 100 ); + } + } + + return { + // Apply all of the weather modifying factors and clamp the result between 0 and 200%. + scale: Math.floor( Math.min( Math.max( 0, 100 + humidityFactor + tempFactor + precipFactor ), 200 ) ), + rawData: rawData + } +} + +export interface ZimmermanAdjustmentOptions extends AdjustmentOptions { + /** Base humidity (as a percentage). */ + bh?: number; + /** Base temperature (in Fahrenheit). */ + bt?: number; + /** Base precipitation (in inches). */ + br?: number; + /** The percentage to weight the humidity factor by. */ + h?: number; + /** The percentage to weight the temperature factor by. */ + t?: number; + /** The percentage to weight the precipitation factor by. */ + r?: number; +} + + +const ZimmermanAdjustmentMethod: AdjustmentMethod = { + calculateWateringScale: calculateZimmermanWateringScale +}; +export default ZimmermanAdjustmentMethod; diff --git a/routes/weather.ts b/routes/weather.ts index 3e93147..c511a7c 100644 --- a/routes/weather.ts +++ b/routes/weather.ts @@ -5,8 +5,12 @@ import * as SunCalc from "suncalc"; import * as moment from "moment-timezone"; import * as geoTZ from "geo-tz"; -import { AdjustmentOptions, GeoCoordinates, TimeData, WateringData, WeatherData } from "../types"; +import { GeoCoordinates, TimeData, WateringData, WeatherData } from "../types"; import { WeatherProvider } from "./weatherProviders/WeatherProvider"; +import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./adjustmentMethods/AdjustmentMethod"; +import ManualAdjustmentMethod from "./adjustmentMethods/ManualAdjustmentMethod"; +import ZimmermanAdjustmentMethod from "./adjustmentMethods/ZimmermanAdjustmentMethod"; +import RainDelayAdjustmentMethod from "./adjustmentMethods/RainDelayAdjustmentMethod"; const weatherProvider: WeatherProvider = new ( require("./weatherProviders/" + ( process.env.WEATHER_PROVIDER || "OWM" ) ).default )(); // Define regex filters to match against location @@ -18,11 +22,11 @@ const filters = { timezone: /^()()()()()()([+-])(\d{2})(\d{2})/ }; -// Enum of available watering scale adjustment methods. -const ADJUSTMENT_METHOD = { - MANUAL: 0, - ZIMMERMAN: 1, - RAIN_DELAY: 2 +/** AdjustmentMethods mapped to their numeric IDs. */ +const ADJUSTMENT_METHOD: { [ key: number ] : AdjustmentMethod } = { + 0: ManualAdjustmentMethod, + 1: ZimmermanAdjustmentMethod, + 2: RainDelayAdjustmentMethod }; /** @@ -107,52 +111,6 @@ function getTimeData( coordinates: GeoCoordinates ): TimeData { }; } -/** - * Calculates how much watering should be scaled based on weather and adjustment options using the Zimmerman method. - * @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 { - - let humidityBase = 30, tempBase = 70, precipBase = 0; - - // Check to make sure valid data exists for all factors - if ( !validateValues( [ "temp", "humidity", "precip" ], wateringData ) ) { - throw "Necessary field(s) were missing from WateringData."; - } - - // Get baseline conditions for 100% water level, if provided - if ( adjustmentOptions ) { - humidityBase = adjustmentOptions.hasOwnProperty( "bh" ) ? adjustmentOptions.bh : humidityBase; - tempBase = adjustmentOptions.hasOwnProperty( "bt" ) ? adjustmentOptions.bt : tempBase; - precipBase = adjustmentOptions.hasOwnProperty( "br" ) ? adjustmentOptions.br : precipBase; - } - - let humidityFactor = ( humidityBase - wateringData.humidity ), - tempFactor = ( ( wateringData.temp - tempBase ) * 4 ), - precipFactor = ( ( precipBase - wateringData.precip ) * 200 ); - - // Apply adjustment options, if provided, by multiplying the percentage against the factor - if ( adjustmentOptions ) { - if ( adjustmentOptions.hasOwnProperty( "h" ) ) { - humidityFactor = humidityFactor * ( adjustmentOptions.h / 100 ); - } - - if ( adjustmentOptions.hasOwnProperty( "t" ) ) { - tempFactor = tempFactor * ( adjustmentOptions.t / 100 ); - } - - if ( adjustmentOptions.hasOwnProperty( "r" ) ) { - precipFactor = precipFactor * ( adjustmentOptions.r / 100 ); - } - } - - // Apply all of the weather modifying factors and clamp the result between 0 and 200%. - return Math.floor( Math.min( Math.max( 0, 100 + humidityFactor + tempFactor + precipFactor ), 200 ) ); -} - /** * Checks if the weather data meets any of the restrictions set by OpenSprinkler. Restrictions prevent any watering * from occurring and are similar to 0% watering level. Known restrictions are: @@ -217,7 +175,7 @@ export const getWateringData = async function( req: express.Request, res: expres // The adjustment method is encoded by the OpenSprinkler firmware and must be // parsed. This allows the adjustment method and the restriction type to both // be saved in the same byte. - let adjustmentMethod: number = req.params[ 0 ] & ~( 1 << 7 ), + let adjustmentMethod: AdjustmentMethod = ADJUSTMENT_METHOD[ req.params[ 0 ] & ~( 1 << 7 ) ], checkRestrictions: boolean = ( ( req.params[ 0 ] >> 7 ) & 1 ) > 0, adjustmentOptionsString: string = getParameter(req.query.wto), location: string | GeoCoordinates = getParameter(req.query.loc), @@ -225,13 +183,6 @@ 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 remoteAddress = remoteAddress.split( "," )[ 0 ]; @@ -263,7 +214,7 @@ export const getWateringData = async function( req: express.Request, res: expres // Continue with the weather request let timeData: TimeData = getTimeData( coordinates ); let wateringData: WateringData; - if ( adjustmentMethod !== ADJUSTMENT_METHOD.MANUAL || checkRestrictions ) { + if ( adjustmentMethod !== ManualAdjustmentMethod || checkRestrictions ) { try { wateringData = await weatherProvider.getWateringData( coordinates ); } catch ( err ) { @@ -272,59 +223,45 @@ export const getWateringData = async function( req: express.Request, res: expres } } - let scale = -1, rainDelay = -1; - - if ( adjustmentMethod === ADJUSTMENT_METHOD.ZIMMERMAN ) { - try { - scale = calculateZimmermanWateringScale( adjustmentOptions, wateringData ); - } catch ( err ) { - // Default to a scale of 100% if the scale can't be calculated. - scale = 100; - errorMessage = err; + let adjustmentMethodResponse: AdjustmentMethodResponse; + try { + adjustmentMethodResponse = await adjustmentMethod.calculateWateringScale( + adjustmentOptions, wateringData, coordinates, weatherProvider + ); + } catch ( err ) { + if ( typeof err != "string" ) { + /* If an error occurs under expected circumstances (e.g. required optional fields from a weather API are + missing), an AdjustmentOption must throw a string. If a non-string error is caught, it is likely an Error + thrown by the JS engine due to unexpected circumstances. The user should not be shown the error message + since it may contain sensitive information. */ + res.send( "Error: an unexpected error occurred." ); + console.error( `An unexpected error occurred for ${ req.url }: `, err ); + } else { + res.send( "Error: " + err ); } + + return; } - if (wateringData) { + let scale = adjustmentMethodResponse.scale; + if ( wateringData ) { // Check for any user-set restrictions and change the scale to 0 if the criteria is met - if (checkWeatherRestriction(req.params[0], wateringData)) { + if ( checkWeatherRestriction( req.params[ 0 ], wateringData ) ) { scale = 0; } - - // If any weather adjustment is being used, check the rain status - if ( adjustmentMethod > ADJUSTMENT_METHOD.MANUAL && wateringData.raining ) { - - // If it is raining and the user has weather-based rain delay as the adjustment method then apply the specified delay - if ( adjustmentMethod === ADJUSTMENT_METHOD.RAIN_DELAY ) { - - rainDelay = ( adjustmentOptions && adjustmentOptions.hasOwnProperty( "d" ) ) ? adjustmentOptions.d : 24; - } else { - // Temporarily disabled since OWM forecast data is checking if rain is forecasted for 3 hours in the future. - // For any other adjustment method, apply a scale of 0 (as the scale will revert when the rain stops) - // scale = 0; - } - } } const data = { scale: scale, - rd: rainDelay, + rd: adjustmentMethodResponse.rainDelay, tz: getTimezone( timeData.timezone, undefined ), sunrise: timeData.sunrise, sunset: timeData.sunset, eip: ipToInt( remoteAddress ), - rawData: undefined, - error: errorMessage + rawData: adjustmentMethodResponse.rawData, + error: adjustmentMethodResponse.errorMessage }; - if ( adjustmentMethod > ADJUSTMENT_METHOD.MANUAL ) { - data.rawData = { - h: wateringData ? Math.round( wateringData.humidity * 100) / 100 : null, - p: wateringData ? Math.round( wateringData.precip * 100 ) / 100 : null, - t: wateringData ? Math.round( wateringData.temp * 10 ) / 10 : null, - raining: wateringData ? ( wateringData.raining ? 1 : 0 ) : null - }; - } - // Return the response to the client in the requested format if ( outputFormat === "json" ) { res.json( data ); @@ -336,7 +273,7 @@ export const getWateringData = async function( req: express.Request, res: expres "&sunset=" + data.sunset + "&eip=" + data.eip + ( data.rawData ? "&rawData=" + JSON.stringify( data.rawData ) : "" ) + - ( errorMessage ? "&error=" + encodeURIComponent( errorMessage ) : "" ) + ( data.error ? "&error=" + encodeURIComponent( data.error ) : "" ) ); } @@ -392,7 +329,7 @@ async function httpRequest( url: string ): Promise< string > { * @param obj The object to check. * @return A boolean indicating if the object has numeric values for all of the specified keys. */ -function validateValues( keys: string[], obj: object ): boolean { +export function validateValues( keys: string[], obj: object ): boolean { let key: string; // Return false if the object is null/undefined. diff --git a/types.ts b/types.ts index 07bbadb..c66cf8c 100644 --- a/types.ts +++ b/types.ts @@ -68,21 +68,4 @@ export interface WateringData { raining: boolean; } -export interface AdjustmentOptions { - /** Base humidity (as a percentage). */ - bh?: number; - /** Base temperature (in Fahrenheit). */ - bt?: number; - /** Base precipitation (in inches). */ - br?: number; - /** The percentage to weight the humidity factor by. */ - h?: number; - /** The percentage to weight the temperature factor by. */ - t?: number; - /** The percentage to weight the precipitation factor by. */ - r?: number; - /** The rain delay to use (in hours). */ - d?: number; -} - export type WeatherProviderId = "OWM" | "DarkSky" | "local"; From 3f748704e0e80daeccf073f7d5a13b1590bbe4b6 Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Sun, 9 Jun 2019 12:07:09 -0400 Subject: [PATCH 09/24] Automatically URL query format response --- routes/weather.ts | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/routes/weather.ts b/routes/weather.ts index c511a7c..1401c77 100644 --- a/routes/weather.ts +++ b/routes/weather.ts @@ -266,17 +266,34 @@ export const getWateringData = async function( req: express.Request, res: expres if ( outputFormat === "json" ) { res.json( data ); } else { - res.send( "&scale=" + data.scale + - "&rd=" + data.rd + - "&tz=" + data.tz + - "&sunrise=" + data.sunrise + - "&sunset=" + data.sunset + - "&eip=" + data.eip + - ( data.rawData ? "&rawData=" + JSON.stringify( data.rawData ) : "" ) + - ( data.error ? "&error=" + encodeURIComponent( data.error ) : "" ) - ); - } + // Return the data formatted as a URL query string. + let formatted = ""; + for ( const key in data ) { + // Skip inherited properties. + if ( !data.hasOwnProperty( key ) ) { + continue; + } + let value = data[ key ]; + switch ( typeof value ) { + case "undefined": + // Skip undefined properties. + continue; + case "object": + // Convert objects to JSON. + value = JSON.stringify( value ); + // Fallthrough. + case "string": + /* URL encode strings. Since the OS firmware uses a primitive version of query string parsing and + decoding, only some characters need to be escaped and only spaces ("+" or "%20") will be decoded. */ + value = value.replace( / /g, "+" ).replace( /\n/g, "\\n" ).replace( /&/g, "AMPERSAND" ); + break; + } + + formatted += `&${ key }=${ value }`; + } + res.send( formatted ); + } }; /** From 56f32b2d668b780b9a9a58187cfd9c97a18939d1 Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Mon, 10 Jun 2019 19:03:46 -0400 Subject: [PATCH 10/24] Fix tests --- test/expected.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/test/expected.json b/test/expected.json index 4b78b25..fdcdf8f 100644 --- a/test/expected.json +++ b/test/expected.json @@ -1,8 +1,6 @@ { "noWeather": { "01002": { - "scale": -1, - "rd": -1, "tz": 32, "sunrise": 332, "sunset": 1203, @@ -12,7 +10,6 @@ "adjustment1": { "01002": { "scale": 0, - "rd": -1, "tz": 32, "sunrise": 332, "sunset": 1203, From 8d9fb96ea992d9500761967724dae9532ca9b86e Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Fri, 21 Jun 2019 17:25:18 -0400 Subject: [PATCH 11/24] Error if WeatherProvider API key is not provided --- routes/weatherProviders/DarkSky.ts | 18 +++++++++++++----- routes/weatherProviders/OWM.ts | 18 +++++++++++++----- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/routes/weatherProviders/DarkSky.ts b/routes/weatherProviders/DarkSky.ts index 7098f39..0e69d55 100644 --- a/routes/weatherProviders/DarkSky.ts +++ b/routes/weatherProviders/DarkSky.ts @@ -6,14 +6,23 @@ import { WeatherProvider } from "./WeatherProvider"; export default class DarkSkyWeatherProvider extends WeatherProvider { + private readonly API_KEY: string; + + public constructor() { + super(); + this.API_KEY = process.env.DARKSKY_API_KEY; + if (!this.API_KEY) { + throw "DARKSKY_API_KEY environment variable is not defined."; + } + } + 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(); - 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`; + const yesterdayUrl = `https://api.darksky.net/forecast/${ this.API_KEY }/${ coordinates[ 0 ] },${ coordinates[ 1 ] },${ yesterdayTimestamp }?exclude=currently,minutely,daily,alerts,flags`, + todayUrl = `https://api.darksky.net/forecast/${ this.API_KEY }/${ coordinates[ 0 ] },${ coordinates[ 1 ] },${ todayTimestamp }?exclude=currently,minutely,daily,alerts,flags`; let yesterdayData, todayData; try { @@ -62,8 +71,7 @@ export default class DarkSkyWeatherProvider extends WeatherProvider { } 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`; + const forecastUrl = `https://api.darksky.net/forecast/${ this.API_KEY }/${ coordinates[ 0 ] },${ coordinates[ 1 ] }?exclude=minutely,alerts,flags`; let forecast; try { diff --git a/routes/weatherProviders/OWM.ts b/routes/weatherProviders/OWM.ts index fdb4023..0d32d8f 100644 --- a/routes/weatherProviders/OWM.ts +++ b/routes/weatherProviders/OWM.ts @@ -4,9 +4,18 @@ import { WeatherProvider } from "./WeatherProvider"; export default class OWMWeatherProvider extends WeatherProvider { + private readonly API_KEY: string; + + public constructor() { + super(); + this.API_KEY = process.env.OWM_API_KEY; + if (!this.API_KEY) { + throw "OWM_API_KEY environment variable is not defined."; + } + } + 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 ]; + const forecastUrl = `http://api.openweathermap.org/data/2.5/forecast?appid=${ this.API_KEY }&units=imperial&lat=${ coordinates[ 0 ] }&lon=${ coordinates[ 1 ] }`; // Perform the HTTP request to retrieve the weather data let forecast; @@ -43,9 +52,8 @@ export default class OWMWeatherProvider extends WeatherProvider { } 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 ]; + const currentUrl = `http://api.openweathermap.org/data/2.5/weather?appid=${ this.API_KEY }&units=imperial&lat=${ coordinates[ 0 ] }&lon=${ coordinates[ 1 ] }`, + forecastDailyUrl = `http://api.openweathermap.org/data/2.5/forecast/daily?appid=${ this.API_KEY }&units=imperial&lat=${ coordinates[ 0 ] }&lon=${ coordinates[ 1 ] }`; let current, forecast; try { From 930a0026def072aa6bbb6b1d4468055c5e409b66 Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Fri, 21 Jun 2019 19:53:28 -0400 Subject: [PATCH 12/24] Improve handling of missing data in Dark Sky --- routes/weatherProviders/DarkSky.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/routes/weatherProviders/DarkSky.ts b/routes/weatherProviders/DarkSky.ts index 0e69d55..843678c 100644 --- a/routes/weatherProviders/DarkSky.ts +++ b/routes/weatherProviders/DarkSky.ts @@ -56,9 +56,16 @@ export default class DarkSkyWeatherProvider extends WeatherProvider { const totals = { temp: 0, humidity: 0, precip: 0 }; for ( const sample of samples ) { + /* + * If temperature or humidity is missing from a sample, the total will become NaN. This is intended since + * calculateWateringScale will treat NaN as a missing value and temperature/humidity can't be accurately + * calculated when data is missing from some samples (since they follow diurnal cycles and will be + * significantly skewed if data is missing for several consecutive hours). + */ totals.temp += sample.temperature; totals.humidity += sample.humidity; - totals.precip += sample.precipIntensity + // This field may be missing from the response if it is snowing. + totals.precip += sample.precipIntensity || 0; } return { From 375cda9e11d08ca09274117d9610d579c332f1ca Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Tue, 25 Jun 2019 22:13:15 -0400 Subject: [PATCH 13/24] Fix tests --- routes/weather.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/routes/weather.spec.ts b/routes/weather.spec.ts index 36e92e5..a979773 100644 --- a/routes/weather.spec.ts +++ b/routes/weather.spec.ts @@ -4,6 +4,10 @@ import * as MockExpressRequest from 'mock-express-request'; import * as MockExpressResponse from 'mock-express-response'; import * as MockDate from 'mockdate'; +// The tests don't use OWM, but the WeatherProvider API key must be set to prevent an error from being thrown on startup. +process.env.WEATHER_PROVIDER = "OWM"; +process.env.OWM_API_KEY = "NO_KEY"; + import { getWateringData } from './weather'; import { GeoCoordinates, WateringData, WeatherData } from "../types"; import { WeatherProvider } from "./weatherProviders/WeatherProvider"; From 95dadf601d4210c37de8c4e10dc92cb9cfcfd978 Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Thu, 27 Jun 2019 22:58:33 -0400 Subject: [PATCH 14/24] Document that AdjustmentOptions are non-nullable --- routes/adjustmentMethods/AdjustmentMethod.ts | 5 ++-- .../RainDelayAdjustmentMethod.ts | 2 +- .../ZimmermanAdjustmentMethod.ts | 26 ++++++++----------- 3 files changed, 14 insertions(+), 19 deletions(-) diff --git a/routes/adjustmentMethods/AdjustmentMethod.ts b/routes/adjustmentMethods/AdjustmentMethod.ts index 374f260..66c1c3a 100644 --- a/routes/adjustmentMethods/AdjustmentMethod.ts +++ b/routes/adjustmentMethods/AdjustmentMethod.ts @@ -5,9 +5,8 @@ import { WeatherProvider } from "../weatherProviders/WeatherProvider"; export interface AdjustmentMethod { /** * Calculates the percentage that should be used to scale watering time. - * @param adjustmentOptions The user-specified options for the calculation, or undefined/null if no custom values - * are to be used. No checks will be made to ensure the AdjustmentOptions are the correct type that the function - * is expecting or to ensure that any of its fields are valid. + * @param adjustmentOptions The user-specified options for the calculation. No checks will be made to ensure the + * AdjustmentOptions are the correct type that the function is expecting or to ensure that any of its fields are valid. * @param wateringData The basic weather information of the watering site. This may be undefined if an error occurred * while retrieving the data. * @param coordinates The coordinates of the watering site. diff --git a/routes/adjustmentMethods/RainDelayAdjustmentMethod.ts b/routes/adjustmentMethods/RainDelayAdjustmentMethod.ts index 56b1406..4b6a0e0 100644 --- a/routes/adjustmentMethods/RainDelayAdjustmentMethod.ts +++ b/routes/adjustmentMethods/RainDelayAdjustmentMethod.ts @@ -7,7 +7,7 @@ import { WateringData } from "../../types"; */ async function calculateRainDelayWateringScale( adjustmentOptions: RainDelayAdjustmentOptions, wateringData: WateringData | undefined ): Promise< AdjustmentMethodResponse > { const raining = wateringData && wateringData.raining; - const d = adjustmentOptions && adjustmentOptions.hasOwnProperty( "d" ) ? adjustmentOptions.d : 24; + const d = adjustmentOptions.hasOwnProperty( "d" ) ? adjustmentOptions.d : 24; return { scale: undefined, rawData: { raining: raining ? 1 : 0 }, diff --git a/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts b/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts index e5268c0..c5ed019 100644 --- a/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts +++ b/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts @@ -40,29 +40,25 @@ async function calculateZimmermanWateringScale( adjustmentOptions: ZimmermanAdju let humidityBase = 30, tempBase = 70, precipBase = 0; // Get baseline conditions for 100% water level, if provided - if ( adjustmentOptions ) { - humidityBase = adjustmentOptions.hasOwnProperty( "bh" ) ? adjustmentOptions.bh : humidityBase; - tempBase = adjustmentOptions.hasOwnProperty( "bt" ) ? adjustmentOptions.bt : tempBase; - precipBase = adjustmentOptions.hasOwnProperty( "br" ) ? adjustmentOptions.br : precipBase; - } + humidityBase = adjustmentOptions.hasOwnProperty( "bh" ) ? adjustmentOptions.bh : humidityBase; + tempBase = adjustmentOptions.hasOwnProperty( "bt" ) ? adjustmentOptions.bt : tempBase; + precipBase = adjustmentOptions.hasOwnProperty( "br" ) ? adjustmentOptions.br : precipBase; let humidityFactor = ( humidityBase - wateringData.humidity ), tempFactor = ( ( wateringData.temp - tempBase ) * 4 ), precipFactor = ( ( precipBase - wateringData.precip ) * 200 ); // Apply adjustment options, if provided, by multiplying the percentage against the factor - if ( adjustmentOptions ) { - if ( adjustmentOptions.hasOwnProperty( "h" ) ) { - humidityFactor = humidityFactor * ( adjustmentOptions.h / 100 ); - } + if ( adjustmentOptions.hasOwnProperty( "h" ) ) { + humidityFactor = humidityFactor * ( adjustmentOptions.h / 100 ); + } - if ( adjustmentOptions.hasOwnProperty( "t" ) ) { - tempFactor = tempFactor * ( adjustmentOptions.t / 100 ); - } + if ( adjustmentOptions.hasOwnProperty( "t" ) ) { + tempFactor = tempFactor * ( adjustmentOptions.t / 100 ); + } - if ( adjustmentOptions.hasOwnProperty( "r" ) ) { - precipFactor = precipFactor * ( adjustmentOptions.r / 100 ); - } + if ( adjustmentOptions.hasOwnProperty( "r" ) ) { + precipFactor = precipFactor * ( adjustmentOptions.r / 100 ); } return { From dc171ebe6849942caeffa78f0b63859045474446 Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Fri, 28 Jun 2019 00:33:00 -0400 Subject: [PATCH 15/24] Refactor watering scale logic flow --- routes/adjustmentMethods/AdjustmentMethod.ts | 9 +++--- .../ManualAdjustmentMethod.ts | 3 +- .../RainDelayAdjustmentMethod.ts | 9 ++++-- .../ZimmermanAdjustmentMethod.ts | 15 ++++++---- routes/weather.spec.ts | 6 ++-- routes/weather.ts | 30 ++++++++++--------- routes/weatherProviders/DarkSky.ts | 13 ++++++-- routes/weatherProviders/OWM.ts | 4 +-- routes/weatherProviders/WeatherProvider.ts | 10 +++---- routes/weatherProviders/local.ts | 8 ++--- types.ts | 13 ++++---- 11 files changed, 70 insertions(+), 50 deletions(-) diff --git a/routes/adjustmentMethods/AdjustmentMethod.ts b/routes/adjustmentMethods/AdjustmentMethod.ts index 66c1c3a..209c0e7 100644 --- a/routes/adjustmentMethods/AdjustmentMethod.ts +++ b/routes/adjustmentMethods/AdjustmentMethod.ts @@ -1,4 +1,4 @@ -import { GeoCoordinates, WateringData } from "../../types"; +import { BaseWateringData, GeoCoordinates } from "../../types"; import { WeatherProvider } from "../weatherProviders/WeatherProvider"; @@ -7,10 +7,8 @@ export interface AdjustmentMethod { * Calculates the percentage that should be used to scale watering time. * @param adjustmentOptions The user-specified options for the calculation. No checks will be made to ensure the * AdjustmentOptions are the correct type that the function is expecting or to ensure that any of its fields are valid. - * @param wateringData The basic weather information of the watering site. This may be undefined if an error occurred - * while retrieving the data. * @param coordinates The coordinates of the watering site. - * @param weatherProvider The WeatherProvider that should be used if the adjustment method needs to obtain any more + * @param weatherProvider The WeatherProvider that should be used if the adjustment method needs to obtain any * weather data. * @return A Promise that will be resolved with the result of the calculation, or rejected with an error message if * the watering scale cannot be calculated. @@ -18,7 +16,6 @@ export interface AdjustmentMethod { */ calculateWateringScale( adjustmentOptions: AdjustmentOptions, - wateringData: WateringData | undefined, coordinates: GeoCoordinates, weatherProvider: WeatherProvider ): Promise< AdjustmentMethodResponse >; @@ -52,6 +49,8 @@ export interface AdjustmentMethodResponse { * user-configured watering scale instead of using the one returned by the AdjustmentMethod. */ errorMessage?: string; + /** The data that was used to calculate the watering scale, or undefined if no data was used. */ + wateringData: BaseWateringData; } export interface AdjustmentOptions {} diff --git a/routes/adjustmentMethods/ManualAdjustmentMethod.ts b/routes/adjustmentMethods/ManualAdjustmentMethod.ts index d584f83..7c9e8f7 100644 --- a/routes/adjustmentMethods/ManualAdjustmentMethod.ts +++ b/routes/adjustmentMethods/ManualAdjustmentMethod.ts @@ -6,7 +6,8 @@ import { AdjustmentMethod, AdjustmentMethodResponse } from "./AdjustmentMethod"; */ async function calculateManualWateringScale( ): Promise< AdjustmentMethodResponse > { return { - scale: undefined + scale: undefined, + wateringData: undefined } } diff --git a/routes/adjustmentMethods/RainDelayAdjustmentMethod.ts b/routes/adjustmentMethods/RainDelayAdjustmentMethod.ts index 4b6a0e0..bfa1348 100644 --- a/routes/adjustmentMethods/RainDelayAdjustmentMethod.ts +++ b/routes/adjustmentMethods/RainDelayAdjustmentMethod.ts @@ -1,17 +1,20 @@ import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./AdjustmentMethod"; -import { WateringData } from "../../types"; +import { GeoCoordinates, ZimmermanWateringData } from "../../types"; +import { WeatherProvider } from "../weatherProviders/WeatherProvider"; /** * Only delays watering if it is currently raining and does not adjust the watering scale. */ -async function calculateRainDelayWateringScale( adjustmentOptions: RainDelayAdjustmentOptions, wateringData: WateringData | undefined ): Promise< AdjustmentMethodResponse > { +async function calculateRainDelayWateringScale( adjustmentOptions: RainDelayAdjustmentOptions, coordinates: GeoCoordinates, weatherProvider: WeatherProvider ): Promise< AdjustmentMethodResponse > { + const wateringData: ZimmermanWateringData = await weatherProvider.getWateringData( coordinates ); const raining = wateringData && wateringData.raining; const d = adjustmentOptions.hasOwnProperty( "d" ) ? adjustmentOptions.d : 24; return { scale: undefined, rawData: { raining: raining ? 1 : 0 }, - rainDelay: raining ? d : undefined + rainDelay: raining ? d : undefined, + wateringData: wateringData } } diff --git a/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts b/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts index c5ed019..ec4a8ae 100644 --- a/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts +++ b/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts @@ -1,13 +1,15 @@ import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./AdjustmentMethod"; -import { WateringData } from "../../types"; +import { GeoCoordinates, ZimmermanWateringData } from "../../types"; import { validateValues } from "../weather"; +import { WeatherProvider } from "../weatherProviders/WeatherProvider"; /** * Calculates how much watering should be scaled based on weather and adjustment options using the Zimmerman method. * (https://github.com/rszimm/sprinklers_pi/wiki/Weather-adjustments#formula-for-setting-the-scale) */ -async function calculateZimmermanWateringScale( adjustmentOptions: ZimmermanAdjustmentOptions, wateringData: WateringData | undefined ): Promise< AdjustmentMethodResponse > { +async function calculateZimmermanWateringScale( adjustmentOptions: ZimmermanAdjustmentOptions, coordinates: GeoCoordinates, weatherProvider: WeatherProvider ): Promise< AdjustmentMethodResponse > { + const wateringData: ZimmermanWateringData = await weatherProvider.getWateringData( coordinates ); // Temporarily disabled since OWM forecast data is checking if rain is forecasted for 3 hours in the future. /* @@ -15,7 +17,8 @@ async function calculateZimmermanWateringScale( adjustmentOptions: ZimmermanAdju if ( wateringData && wateringData.raining ) { return { scale: 0, - rawData: { raining: 1 } + rawData: { raining: 1 }, + wateringData: wateringData } } */ @@ -33,7 +36,8 @@ async function calculateZimmermanWateringScale( adjustmentOptions: ZimmermanAdju return { scale: 100, rawData: rawData, - errorMessage: "Necessary field(s) were missing from WateringData." + errorMessage: "Necessary field(s) were missing from ZimmermanWateringData.", + wateringData: wateringData }; } @@ -64,7 +68,8 @@ async function calculateZimmermanWateringScale( adjustmentOptions: ZimmermanAdju return { // Apply all of the weather modifying factors and clamp the result between 0 and 200%. scale: Math.floor( Math.min( Math.max( 0, 100 + humidityFactor + tempFactor + precipFactor ), 200 ) ), - rawData: rawData + rawData: rawData, + wateringData: wateringData } } diff --git a/routes/weather.spec.ts b/routes/weather.spec.ts index 36e92e5..c566a70 100644 --- a/routes/weather.spec.ts +++ b/routes/weather.spec.ts @@ -5,7 +5,7 @@ import * as MockExpressResponse from 'mock-express-response'; import * as MockDate from 'mockdate'; import { getWateringData } from './weather'; -import { GeoCoordinates, WateringData, WeatherData } from "../types"; +import { GeoCoordinates, ZimmermanWateringData, WeatherData } from "../types"; import { WeatherProvider } from "./weatherProviders/WeatherProvider"; const expected = require( '../test/expected.json' ); @@ -77,7 +77,7 @@ export class MockWeatherProvider extends WeatherProvider { this.mockData = mockData; } - public async getWateringData( coordinates: GeoCoordinates ): Promise< WateringData > { + public async getWateringData( coordinates: GeoCoordinates ): Promise< ZimmermanWateringData > { const data = this.mockData.wateringData; if ( !data.weatherProvider ) { data.weatherProvider = "mock"; @@ -97,6 +97,6 @@ export class MockWeatherProvider extends WeatherProvider { } interface MockWeatherData { - wateringData?: WateringData, + wateringData?: ZimmermanWateringData, weatherData?: WeatherData } diff --git a/routes/weather.ts b/routes/weather.ts index 1401c77..544fe6f 100644 --- a/routes/weather.ts +++ b/routes/weather.ts @@ -5,7 +5,7 @@ import * as SunCalc from "suncalc"; import * as moment from "moment-timezone"; import * as geoTZ from "geo-tz"; -import { GeoCoordinates, TimeData, WateringData, WeatherData } from "../types"; +import { GeoCoordinates, TimeData, WeatherData, BaseWateringData } from "../types"; import { WeatherProvider } from "./weatherProviders/WeatherProvider"; import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./adjustmentMethods/AdjustmentMethod"; import ManualAdjustmentMethod from "./adjustmentMethods/ManualAdjustmentMethod"; @@ -121,7 +121,7 @@ function getTimeData( coordinates: GeoCoordinates ): TimeData { * @param weather Watering data to use to determine if any restrictions apply. * @return A boolean indicating if the watering level should be set to 0% due to a restriction. */ -function checkWeatherRestriction( adjustmentValue: number, weather: WateringData ): boolean { +function checkWeatherRestriction( adjustmentValue: number, weather: BaseWateringData ): boolean { const californiaRestriction = ( adjustmentValue >> 7 ) & 1; @@ -211,22 +211,12 @@ export const getWateringData = async function( req: express.Request, res: expres return; } - // Continue with the weather request let timeData: TimeData = getTimeData( coordinates ); - let wateringData: WateringData; - if ( adjustmentMethod !== ManualAdjustmentMethod || checkRestrictions ) { - try { - wateringData = await weatherProvider.getWateringData( coordinates ); - } catch ( err ) { - res.send( "Error: " + err ); - return; - } - } let adjustmentMethodResponse: AdjustmentMethodResponse; try { adjustmentMethodResponse = await adjustmentMethod.calculateWateringScale( - adjustmentOptions, wateringData, coordinates, weatherProvider + adjustmentOptions, coordinates, weatherProvider ); } catch ( err ) { if ( typeof err != "string" ) { @@ -244,7 +234,19 @@ export const getWateringData = async function( req: express.Request, res: expres } let scale = adjustmentMethodResponse.scale; - if ( wateringData ) { + + if ( checkRestrictions ) { + let wateringData: BaseWateringData = adjustmentMethodResponse.wateringData; + // Fetch the watering data if the AdjustmentMethod didn't fetch it and restrictions are being checked. + if ( checkRestrictions && !wateringData ) { + try { + wateringData = await weatherProvider.getWateringData( coordinates ); + } catch ( err ) { + res.send( "Error: " + err ); + return; + } + } + // Check for any user-set restrictions and change the scale to 0 if the criteria is met if ( checkWeatherRestriction( req.params[ 0 ], wateringData ) ) { scale = 0; diff --git a/routes/weatherProviders/DarkSky.ts b/routes/weatherProviders/DarkSky.ts index 7098f39..4ff6926 100644 --- a/routes/weatherProviders/DarkSky.ts +++ b/routes/weatherProviders/DarkSky.ts @@ -1,12 +1,12 @@ import * as moment from "moment-timezone"; -import { GeoCoordinates, WateringData, WeatherData } from "../../types"; +import { GeoCoordinates, WeatherData, ZimmermanWateringData } from "../../types"; import { httpJSONRequest } from "../weather"; import { WeatherProvider } from "./WeatherProvider"; export default class DarkSkyWeatherProvider extends WeatherProvider { - public async getWateringData( coordinates: GeoCoordinates ): Promise< WateringData > { + public async getWateringData( coordinates: GeoCoordinates ): Promise< ZimmermanWateringData > { // The Unix timestamp of 24 hours ago. const yesterdayTimestamp: number = moment().subtract( 1, "day" ).unix(); const todayTimestamp: number = moment().unix(); @@ -47,9 +47,16 @@ export default class DarkSkyWeatherProvider extends WeatherProvider { const totals = { temp: 0, humidity: 0, precip: 0 }; for ( const sample of samples ) { + /* + * If temperature or humidity is missing from a sample, the total will become NaN. This is intended since + * calculateWateringScale will treat NaN as a missing value and temperature/humidity can't be accurately + * calculated when data is missing from some samples (since they follow diurnal cycles and will be + * significantly skewed if data is missing for several consecutive hours). + */ totals.temp += sample.temperature; totals.humidity += sample.humidity; - totals.precip += sample.precipIntensity + // This field may be missing from the response if it is snowing. + totals.precip += sample.precipIntensity || 0; } return { diff --git a/routes/weatherProviders/OWM.ts b/routes/weatherProviders/OWM.ts index fdb4023..b35b4b8 100644 --- a/routes/weatherProviders/OWM.ts +++ b/routes/weatherProviders/OWM.ts @@ -1,10 +1,10 @@ -import { GeoCoordinates, WateringData, WeatherData } from "../../types"; +import { GeoCoordinates, WeatherData, ZimmermanWateringData } from "../../types"; import { httpJSONRequest } from "../weather"; import { WeatherProvider } from "./WeatherProvider"; export default class OWMWeatherProvider extends WeatherProvider { - public async getWateringData( coordinates: GeoCoordinates ): Promise< WateringData > { + public async getWateringData( coordinates: GeoCoordinates ): Promise< ZimmermanWateringData > { 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 ]; diff --git a/routes/weatherProviders/WeatherProvider.ts b/routes/weatherProviders/WeatherProvider.ts index 4826df2..79782f3 100644 --- a/routes/weatherProviders/WeatherProvider.ts +++ b/routes/weatherProviders/WeatherProvider.ts @@ -1,14 +1,14 @@ -import { GeoCoordinates, WateringData, WeatherData } from "../../types"; +import { GeoCoordinates, ZimmermanWateringData, WeatherData } from "../../types"; export class WeatherProvider { /** - * Retrieves weather data necessary for watering level calculations. + * Retrieves weather data necessary for Zimmerman 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 the WeatherProvider + * @return A Promise that will be resolved with the ZimmermanWateringData if it is successfully retrieved, + * or rejected with an error message if an error occurs while retrieving the ZimmermanWateringData or the WeatherProvider * does not support this method. */ - getWateringData( coordinates : GeoCoordinates ): Promise< WateringData > { + getWateringData( coordinates : GeoCoordinates ): Promise< ZimmermanWateringData > { throw "Selected WeatherProvider does not support getWateringData"; } diff --git a/routes/weatherProviders/local.ts b/routes/weatherProviders/local.ts index 71e4798..1a12eca 100644 --- a/routes/weatherProviders/local.ts +++ b/routes/weatherProviders/local.ts @@ -1,6 +1,6 @@ import * as express from "express"; import { CronJob } from "cron"; -import { GeoCoordinates, WateringData } from "../../types"; +import { GeoCoordinates, ZimmermanWateringData } from "../../types"; import { WeatherProvider } from "./WeatherProvider"; const count = { temp: 0, humidity: 0 }; @@ -46,9 +46,9 @@ export const captureWUStream = function( req: express.Request, res: express.Resp export default class LocalWeatherProvider extends WeatherProvider { - public async getWateringData( coordinates: GeoCoordinates ): Promise< WateringData > { - const result: WateringData = { - ...yesterday as WateringData, + public async getWateringData( coordinates: GeoCoordinates ): Promise< ZimmermanWateringData > { + const result: ZimmermanWateringData = { + ...yesterday as ZimmermanWateringData, // 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 diff --git a/types.ts b/types.ts index 8dbe9f5..cf66b0f 100644 --- a/types.ts +++ b/types.ts @@ -50,20 +50,23 @@ export interface WeatherDataForecast { description: string; } +export interface BaseWateringData { + /** The WeatherProvider that generated this data. */ + weatherProvider: WeatherProviderId; + /** The total precipitation over the window (in inches). */ + precip: number; +} + /** * Data from a 24 hour window that is used to calculate how watering levels should be scaled. This should ideally use * historic data from the past day, but may also use forecasted data for the next day if historical data is not * available. */ -export interface WateringData { - /** The WeatherProvider that generated this data. */ - weatherProvider: WeatherProviderId; +export interface ZimmermanWateringData extends BaseWateringData { /** The average temperature over the window (in Fahrenheit). */ temp: number; /** The average humidity over the window (as a percentage). */ humidity: number; - /** The total precipitation over the window (in inches). */ - precip: number; /** A boolean indicating if it is raining at the time that this data was retrieved. */ raining: boolean; } From 5f35b0410c42c1d980f18c4a91f64de1eebc8c40 Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Fri, 28 Jun 2019 16:02:35 -0400 Subject: [PATCH 16/24] Add WUnderground WeatherProvider with Zimmerman PWS support --- routes/adjustmentMethods/AdjustmentMethod.ts | 7 ++- .../ZimmermanAdjustmentMethod.ts | 11 +++-- routes/weather.ts | 39 +++++++++++++--- routes/weatherProviders/WUnderground.ts | 44 +++++++++++++++++++ routes/weatherProviders/WeatherProvider.ts | 6 ++- types.ts | 5 ++- 6 files changed, 99 insertions(+), 13 deletions(-) create mode 100644 routes/weatherProviders/WUnderground.ts diff --git a/routes/adjustmentMethods/AdjustmentMethod.ts b/routes/adjustmentMethods/AdjustmentMethod.ts index 209c0e7..3416a2a 100644 --- a/routes/adjustmentMethods/AdjustmentMethod.ts +++ b/routes/adjustmentMethods/AdjustmentMethod.ts @@ -1,4 +1,4 @@ -import { BaseWateringData, GeoCoordinates } from "../../types"; +import { BaseWateringData, GeoCoordinates, PWS } from "../../types"; import { WeatherProvider } from "../weatherProviders/WeatherProvider"; @@ -10,6 +10,8 @@ export interface AdjustmentMethod { * @param coordinates The coordinates of the watering site. * @param weatherProvider The WeatherProvider that should be used if the adjustment method needs to obtain any * weather data. + * @param pws The PWS to retrieve weather data from, or undefined if a PWS should not be used. If the implementation + * of this method does not have PWS support, this parameter may be ignored and coordinates may be used instead. * @return A Promise that will be resolved with the result of the calculation, or rejected with an error message if * the watering scale cannot be calculated. * @throws An error message can be thrown if an error occurs while calculating the watering scale. @@ -17,7 +19,8 @@ export interface AdjustmentMethod { calculateWateringScale( adjustmentOptions: AdjustmentOptions, coordinates: GeoCoordinates, - weatherProvider: WeatherProvider + weatherProvider: WeatherProvider, + pws?: PWS ): Promise< AdjustmentMethodResponse >; } diff --git a/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts b/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts index ec4a8ae..e8fea63 100644 --- a/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts +++ b/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts @@ -1,5 +1,5 @@ import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./AdjustmentMethod"; -import { GeoCoordinates, ZimmermanWateringData } from "../../types"; +import { GeoCoordinates, PWS, ZimmermanWateringData } from "../../types"; import { validateValues } from "../weather"; import { WeatherProvider } from "../weatherProviders/WeatherProvider"; @@ -8,8 +8,13 @@ import { WeatherProvider } from "../weatherProviders/WeatherProvider"; * Calculates how much watering should be scaled based on weather and adjustment options using the Zimmerman method. * (https://github.com/rszimm/sprinklers_pi/wiki/Weather-adjustments#formula-for-setting-the-scale) */ -async function calculateZimmermanWateringScale( adjustmentOptions: ZimmermanAdjustmentOptions, coordinates: GeoCoordinates, weatherProvider: WeatherProvider ): Promise< AdjustmentMethodResponse > { - const wateringData: ZimmermanWateringData = await weatherProvider.getWateringData( coordinates ); +async function calculateZimmermanWateringScale( + adjustmentOptions: ZimmermanAdjustmentOptions, + coordinates: GeoCoordinates, + weatherProvider: WeatherProvider, + pws?: PWS +): Promise< AdjustmentMethodResponse > { + const wateringData: ZimmermanWateringData = await weatherProvider.getWateringData( coordinates, pws ); // Temporarily disabled since OWM forecast data is checking if rain is forecasted for 3 hours in the future. /* diff --git a/routes/weather.ts b/routes/weather.ts index 544fe6f..eeeb0ee 100644 --- a/routes/weather.ts +++ b/routes/weather.ts @@ -5,13 +5,14 @@ import * as SunCalc from "suncalc"; import * as moment from "moment-timezone"; import * as geoTZ from "geo-tz"; -import { GeoCoordinates, TimeData, WeatherData, BaseWateringData } from "../types"; +import { BaseWateringData, GeoCoordinates, PWS, TimeData, WeatherData } from "../types"; import { WeatherProvider } from "./weatherProviders/WeatherProvider"; import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./adjustmentMethods/AdjustmentMethod"; import ManualAdjustmentMethod from "./adjustmentMethods/ManualAdjustmentMethod"; import ZimmermanAdjustmentMethod from "./adjustmentMethods/ZimmermanAdjustmentMethod"; import RainDelayAdjustmentMethod from "./adjustmentMethods/RainDelayAdjustmentMethod"; -const weatherProvider: WeatherProvider = new ( require("./weatherProviders/" + ( process.env.WEATHER_PROVIDER || "OWM" ) ).default )(); +const WEATHER_PROVIDER: WeatherProvider = new ( require("./weatherProviders/" + ( process.env.WEATHER_PROVIDER || "OWM" ) ).default )(); +const PWS_WEATHER_PROVIDER: WeatherProvider = new ( require("./weatherProviders/" + ( process.env.PWS_WEATHER_PROVIDER || "WUnderground" ) ).default )(); // Define regex filters to match against location const filters = { @@ -42,7 +43,7 @@ async function resolveCoordinates( location: string ): Promise< GeoCoordinates > } if ( filters.pws.test( location ) ) { - throw "Weather Underground is discontinued"; + throw "PWS ID must be specified in the pws parameter."; } else if ( filters.gps.test( location ) ) { const split: string[] = location.split( "," ); return [ parseFloat( split[ 0 ] ), parseFloat( split[ 1 ] ) ]; @@ -154,7 +155,7 @@ export const getWeatherData = async function( req: express.Request, res: express const timeData: TimeData = getTimeData( coordinates ); let weatherData: WeatherData; try { - weatherData = await weatherProvider.getWeatherData( coordinates ); + weatherData = await WEATHER_PROVIDER.getWeatherData( coordinates ); } catch ( err ) { res.send( "Error: " + err ); return; @@ -181,6 +182,7 @@ export const getWateringData = async function( req: express.Request, res: expres location: string | GeoCoordinates = getParameter(req.query.loc), outputFormat: string = getParameter(req.query.format), remoteAddress: string = getParameter(req.headers[ "x-forwarded-for" ]) || req.connection.remoteAddress, + pwsString: string = getParameter( req.query.pws ), adjustmentOptions: AdjustmentOptions; // X-Forwarded-For header may contain more than one IP address and therefore @@ -213,10 +215,22 @@ export const getWateringData = async function( req: express.Request, res: expres let timeData: TimeData = getTimeData( coordinates ); + // Parse the PWS information. + let pws: PWS | undefined = undefined; + if ( pwsString ) { + try { + pws = parsePWS( pwsString ); + } catch ( err ) { + res.send( `Error: ${ err }` ); + return; + } + } + + const weatherProvider = pws ? PWS_WEATHER_PROVIDER : WEATHER_PROVIDER; let adjustmentMethodResponse: AdjustmentMethodResponse; try { adjustmentMethodResponse = await adjustmentMethod.calculateWateringScale( - adjustmentOptions, coordinates, weatherProvider + adjustmentOptions, coordinates, weatherProvider, pws ); } catch ( err ) { if ( typeof err != "string" ) { @@ -431,3 +445,18 @@ function getParameter( parameter: string | string[] ): string { // Return an empty string if the parameter is undefined. return parameter || ""; } + +/** + * Creates a PWS object from a string. + * @param pwsString Information about the PWS in the format "pws:API_KEY@PWS_ID". + * @return The PWS specified by the string. + * @throws Throws an error message if the string is in an invalid format and cannot be parsed. + */ +function parsePWS( pwsString: string): PWS { + const match = pwsString.match( /^pws:(?[a-f\d]{32})@(?[a-zA-Z\d]+)$/ ); + if ( !match ) { + throw "Invalid PWS format."; + } + + return match.groups as PWS; +} diff --git a/routes/weatherProviders/WUnderground.ts b/routes/weatherProviders/WUnderground.ts new file mode 100644 index 0000000..854af36 --- /dev/null +++ b/routes/weatherProviders/WUnderground.ts @@ -0,0 +1,44 @@ +import { GeoCoordinates, PWS, WeatherData, ZimmermanWateringData } from "../../types"; +import { WeatherProvider } from "./WeatherProvider"; +import { httpJSONRequest } from "../weather"; + +export default class WUnderground extends WeatherProvider { + + async getWateringData( coordinates: GeoCoordinates, pws?: PWS ): Promise< ZimmermanWateringData > { + if ( !pws ) { + throw "WUnderground WeatherProvider requires a PWS to be specified."; + } + + const url = `https://api.weather.com/v2/pws/observations/hourly/7day?stationId=${ pws.id }&format=json&units=e&apiKey=${ pws.apiKey }`; + let data; + try { + data = await httpJSONRequest( url ); + } catch ( err ) { + console.error( "Error retrieving weather information from WUnderground:", err ); + throw "An error occurred while retrieving weather information from WUnderground." + } + + // Take the 24 most recent observations. + const samples = data.observations.slice( -24 ); + + // Fail if not enough data is available. + if ( samples.length !== 24 ) { + throw "Insufficient data was returned by WUnderground."; + } + + const totals = { temp: 0, humidity: 0, precip: 0 }; + for ( const sample of samples ) { + totals.temp += sample.imperial.tempAvg; + totals.humidity += sample.humidityAvg; + totals.precip += sample.imperial.precipRate; + } + + return { + weatherProvider: "WUnderground", + temp: totals.temp / samples.length, + humidity: totals.humidity / samples.length, + precip: totals.precip, + raining: samples[ samples.length - 1 ].imperial.precipRate > 0 + } + } +} diff --git a/routes/weatherProviders/WeatherProvider.ts b/routes/weatherProviders/WeatherProvider.ts index 79782f3..895bbab 100644 --- a/routes/weatherProviders/WeatherProvider.ts +++ b/routes/weatherProviders/WeatherProvider.ts @@ -1,14 +1,16 @@ -import { GeoCoordinates, ZimmermanWateringData, WeatherData } from "../../types"; +import { GeoCoordinates, PWS, WeatherData, ZimmermanWateringData } from "../../types"; export class WeatherProvider { /** * Retrieves weather data necessary for Zimmerman watering level calculations. * @param coordinates The coordinates to retrieve the watering data for. + * @param pws The PWS to retrieve the weather from, or undefined if a PWS should not be used. If the implementation + * of this method does not have PWS support, this parameter may be ignored and coordinates may be used instead. * @return A Promise that will be resolved with the ZimmermanWateringData if it is successfully retrieved, * or rejected with an error message if an error occurs while retrieving the ZimmermanWateringData or the WeatherProvider * does not support this method. */ - getWateringData( coordinates : GeoCoordinates ): Promise< ZimmermanWateringData > { + getWateringData( coordinates: GeoCoordinates, pws?: PWS ): Promise< ZimmermanWateringData > { throw "Selected WeatherProvider does not support getWateringData"; } diff --git a/types.ts b/types.ts index cf66b0f..72a480d 100644 --- a/types.ts +++ b/types.ts @@ -1,6 +1,9 @@ /** Geographic coordinates. The 1st element is the latitude, and the 2nd element is the longitude. */ export type GeoCoordinates = [number, number]; +/** A PWS ID and API key. */ +export type PWS = { id: string, apiKey: string }; + export interface TimeData { /** The UTC offset, in minutes. This uses POSIX offsets, which are the negation of typically used offsets * (https://github.com/eggert/tz/blob/2017b/etcetera#L36-L42). @@ -71,4 +74,4 @@ export interface ZimmermanWateringData extends BaseWateringData { raining: boolean; } -export type WeatherProviderId = "OWM" | "DarkSky" | "local" | "mock"; +export type WeatherProviderId = "OWM" | "DarkSky" | "local" | "mock" | "WUnderground"; From aa26698481a9c43054dca54bd01c3ce3bb6cd047 Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Sat, 29 Jun 2019 13:10:22 -0400 Subject: [PATCH 17/24] Fix compatibility with earlier Node.js versions Named regex match groups are not supported until Node.js 10.0.0 --- routes/weather.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/routes/weather.ts b/routes/weather.ts index eeeb0ee..fe313e5 100644 --- a/routes/weather.ts +++ b/routes/weather.ts @@ -453,10 +453,13 @@ function getParameter( parameter: string | string[] ): string { * @throws Throws an error message if the string is in an invalid format and cannot be parsed. */ function parsePWS( pwsString: string): PWS { - const match = pwsString.match( /^pws:(?[a-f\d]{32})@(?[a-zA-Z\d]+)$/ ); + const match = pwsString.match( /^pws:([a-f\d]{32})@([a-zA-Z\d]+)$/ ); if ( !match ) { throw "Invalid PWS format."; } - return match.groups as PWS; + return { + apiKey: match[ 1 ], + id: match[ 2 ] + }; } From dd92b8ecf878382cebed57df4b4f6cbff7302bc5 Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Sun, 30 Jun 2019 18:24:58 -0400 Subject: [PATCH 18/24] Make both Dark Sky API calls simultaneously --- routes/weatherProviders/DarkSky.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/routes/weatherProviders/DarkSky.ts b/routes/weatherProviders/DarkSky.ts index a5c3746..66a0692 100644 --- a/routes/weatherProviders/DarkSky.ts +++ b/routes/weatherProviders/DarkSky.ts @@ -26,8 +26,7 @@ export default class DarkSkyWeatherProvider extends WeatherProvider { let yesterdayData, todayData; try { - yesterdayData = await httpJSONRequest( yesterdayUrl ); - todayData = await httpJSONRequest( todayUrl ); + [ yesterdayData, todayData ] = await Promise.all( [ httpJSONRequest( yesterdayUrl ), 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." From a92f488ec335ad8f70a46126c163fbe507960c99 Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Tue, 2 Jul 2019 17:15:09 -0400 Subject: [PATCH 19/24] Use fixed calendar day window in Dark Sky WeatherProvider --- routes/weatherProviders/DarkSky.ts | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/routes/weatherProviders/DarkSky.ts b/routes/weatherProviders/DarkSky.ts index 66a0692..a7228aa 100644 --- a/routes/weatherProviders/DarkSky.ts +++ b/routes/weatherProviders/DarkSky.ts @@ -19,33 +19,23 @@ export default class DarkSkyWeatherProvider extends WeatherProvider { public async getWateringData( coordinates: GeoCoordinates ): Promise< ZimmermanWateringData > { // The Unix timestamp of 24 hours ago. const yesterdayTimestamp: number = moment().subtract( 1, "day" ).unix(); - const todayTimestamp: number = moment().unix(); - const yesterdayUrl = `https://api.darksky.net/forecast/${ this.API_KEY }/${ coordinates[ 0 ] },${ coordinates[ 1 ] },${ yesterdayTimestamp }?exclude=currently,minutely,daily,alerts,flags`, - todayUrl = `https://api.darksky.net/forecast/${ this.API_KEY }/${ coordinates[ 0 ] },${ coordinates[ 1 ] },${ todayTimestamp }?exclude=currently,minutely,daily,alerts,flags`; + const yesterdayUrl = `https://api.darksky.net/forecast/${ this.API_KEY }/${ coordinates[ 0 ] },${ coordinates[ 1 ] },${ yesterdayTimestamp }?exclude=currently,minutely,daily,alerts,flags`; - let yesterdayData, todayData; + let yesterdayData; try { - [ yesterdayData, todayData ] = await Promise.all( [ httpJSONRequest( yesterdayUrl ), httpJSONRequest( todayUrl ) ] ); + yesterdayData = await httpJSONRequest( yesterdayUrl ); } 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 ) { + if ( !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 ) + ...yesterdayData.hourly.data ]; // Fail if not enough data is available. From 5eb696c3e80c4d08eed61d609440bb2521ca584c Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Wed, 3 Jul 2019 16:26:14 -0400 Subject: [PATCH 20/24] Cache calculated watering scale when using data from Dark Sky --- WateringScaleCache.ts | 69 +++++++++++++ package-lock.json | 69 +++++++------ package.json | 2 + routes/weather.ts | 115 +++++++++++++-------- routes/weatherProviders/DarkSky.ts | 4 + routes/weatherProviders/WeatherProvider.ts | 9 ++ 6 files changed, 192 insertions(+), 76 deletions(-) create mode 100644 WateringScaleCache.ts diff --git a/WateringScaleCache.ts b/WateringScaleCache.ts new file mode 100644 index 0000000..5452505 --- /dev/null +++ b/WateringScaleCache.ts @@ -0,0 +1,69 @@ +import * as NodeCache from "node-cache"; +import { GeoCoordinates, PWS } from "./types"; +import { AdjustmentOptions } from "./routes/adjustmentMethods/AdjustmentMethod"; +import * as moment from "moment-timezone"; +import * as geoTZ from "geo-tz"; +import { Moment } from "moment-timezone/moment-timezone"; + +export default class WateringScaleCache { + private readonly cache: NodeCache = new NodeCache(); + + /** + * Stores the results of a watering scale calculation. The scale will be cached until the end of the day in the local + * timezone of the specified coordinates. If a scale has already been cached for the specified calculation parameters, + * this method will have no effect. + * @param adjustmentMethodId The ID of the AdjustmentMethod used to calculate this watering scale. This value should + * have the appropriate bits set for any restrictions that were used. + * @param coordinates The coordinates the watering scale was calculated for. + * @param pws The PWS used to calculate the watering scale, or undefined if one was not used. + * @param adjustmentOptions Any user-specified adjustment options that were used when calculating the watering scale. + * @param wateringScale The results of the watering scale calculation. + */ + public storeWateringScale( + adjustmentMethodId: number, + coordinates: GeoCoordinates, + pws: PWS, + adjustmentOptions: AdjustmentOptions, + wateringScale: CachedScale + ): void { + // The end of the day in the controller's timezone. + const expirationDate: Moment = moment().tz( geoTZ( coordinates[ 0 ], coordinates[ 1 ] )[ 0 ] ).endOf( "day" ); + const ttl: number = ( expirationDate.unix() - moment().unix() ); + const key = this.makeKey( adjustmentMethodId, coordinates, pws, adjustmentOptions ); + this.cache.set( key, wateringScale, ttl ); + } + + /** + * Retrieves a cached scale that was previously calculated with the given parameters. + * @param adjustmentMethodId The ID of the AdjustmentMethod used to calculate this watering scale. This value should + * have the appropriate bits set for any restrictions that were used. + * @param coordinates The coordinates the watering scale was calculated for. + * @param pws The PWS used to calculate the watering scale, or undefined if one was not used. + * @param adjustmentOptions Any user-specified adjustment options that were used when calculating the watering scale. + * @return The cached result of the watering scale calculation, or undefined if no values were cached. + */ + public getWateringScale( + adjustmentMethodId: number, + coordinates: GeoCoordinates, + pws: PWS, + adjustmentOptions: AdjustmentOptions + ): CachedScale | undefined { + const key = this.makeKey( adjustmentMethodId, coordinates, pws, adjustmentOptions ); + return this.cache.get( key ); + } + + private makeKey( + adjustmentMethodId: number, + coordinates: GeoCoordinates, + pws: PWS, + adjustmentOptions: AdjustmentOptions + ): string { + return `${ adjustmentMethodId }#${ coordinates.join( "," ) }#${ pws ? pws.id : "" }#${ JSON.stringify( adjustmentOptions ) }` + } +} + +export interface CachedScale { + scale: number; + rawData: object; + rainDelay: number; +} diff --git a/package-lock.json b/package-lock.json index 322f555..042b6ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "os-weather-service", - "version": "1.0.2", + "version": "1.0.4", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -127,6 +127,15 @@ "integrity": "sha512-Fvm24+u85lGmV4hT5G++aht2C5I4Z4dYlWZIh62FAfFO/TfzXtPpoLI6I7AuBWkIFqZCnhFOoTT7RjjaIL5Fjg==", "dev": true }, + "@types/node-cache": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@types/node-cache/-/node-cache-4.1.3.tgz", + "integrity": "sha512-3hsqnv3H1zkOhjygJaJUYmgz5+FcPO3vejBX7cE9/cnuINOJYrzkfOnUCvpwGe9kMZANIHJA7J5pOdeyv52OEw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/range-parser": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz", @@ -571,6 +580,11 @@ "integrity": "sha1-T6kXw+WclKAEzWH47lCdplFocUM=", "dev": true }, + "clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=" + }, "collection-visit": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", @@ -1126,8 +1140,7 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "aproba": { "version": "1.2.0", @@ -1148,14 +1161,12 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -1170,20 +1181,17 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "core-util-is": { "version": "1.0.2", @@ -1300,8 +1308,7 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "ini": { "version": "1.3.5", @@ -1313,7 +1320,6 @@ "version": "1.0.0", "bundled": true, "dev": true, - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -1328,7 +1334,6 @@ "version": "3.0.4", "bundled": true, "dev": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -1336,14 +1341,12 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "minipass": { "version": "2.3.5", "bundled": true, "dev": true, - "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -1362,7 +1365,6 @@ "version": "0.5.1", "bundled": true, "dev": true, - "optional": true, "requires": { "minimist": "0.0.8" } @@ -1443,8 +1445,7 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "object-assign": { "version": "4.1.1", @@ -1456,7 +1457,6 @@ "version": "1.4.0", "bundled": true, "dev": true, - "optional": true, "requires": { "wrappy": "1" } @@ -1542,8 +1542,7 @@ "safe-buffer": { "version": "5.1.2", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "safer-buffer": { "version": "2.1.2", @@ -1579,7 +1578,6 @@ "version": "1.0.2", "bundled": true, "dev": true, - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -1599,7 +1597,6 @@ "version": "3.0.1", "bundled": true, "dev": true, - "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -1643,14 +1640,12 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "yallist": { "version": "3.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true } } }, @@ -2091,8 +2086,7 @@ "lodash": { "version": "4.17.11", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", - "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==", - "dev": true + "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==" }, "lowercase-keys": { "version": "1.0.1", @@ -2465,6 +2459,15 @@ } } }, + "node-cache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/node-cache/-/node-cache-4.2.0.tgz", + "integrity": "sha512-obRu6/f7S024ysheAjoYFEEBqqDWv4LOMNJEuO8vMeEw2AT4z+NCzO4hlc2lhI4vATzbCQv6kke9FVdx0RbCOw==", + "requires": { + "clone": "2.x", + "lodash": "4.x" + } + }, "node-watch": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/node-watch/-/node-watch-0.6.2.tgz", diff --git a/package.json b/package.json index 0ce5cd2..fa2f69f 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "geo-tz": "^5.0.4", "mockdate": "^2.0.2", "moment-timezone": "^0.5.25", + "node-cache": "^4.2.0", "suncalc": "^1.8.0" }, "devDependencies": { @@ -30,6 +31,7 @@ "@types/mocha": "^5.2.6", "@types/moment-timezone": "^0.5.12", "@types/node": "^10.14.6", + "@types/node-cache": "^4.1.3", "@types/suncalc": "^1.8.0", "chai": "^4.2.0", "mocha": "^5.2.0", diff --git a/routes/weather.ts b/routes/weather.ts index fe313e5..b41e00e 100644 --- a/routes/weather.ts +++ b/routes/weather.ts @@ -11,6 +11,7 @@ import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from ". import ManualAdjustmentMethod from "./adjustmentMethods/ManualAdjustmentMethod"; import ZimmermanAdjustmentMethod from "./adjustmentMethods/ZimmermanAdjustmentMethod"; import RainDelayAdjustmentMethod from "./adjustmentMethods/RainDelayAdjustmentMethod"; +import WateringScaleCache, { CachedScale } from "../WateringScaleCache"; const WEATHER_PROVIDER: WeatherProvider = new ( require("./weatherProviders/" + ( process.env.WEATHER_PROVIDER || "OWM" ) ).default )(); const PWS_WEATHER_PROVIDER: WeatherProvider = new ( require("./weatherProviders/" + ( process.env.PWS_WEATHER_PROVIDER || "WUnderground" ) ).default )(); @@ -30,6 +31,8 @@ const ADJUSTMENT_METHOD: { [ key: number ] : AdjustmentMethod } = { 2: RainDelayAdjustmentMethod }; +const cache = new WateringScaleCache(); + /** * Resolves a location description to geographic coordinates. * @param location A partial zip/city/country or a coordinate pair. @@ -227,57 +230,83 @@ export const getWateringData = async function( req: express.Request, res: expres } const weatherProvider = pws ? PWS_WEATHER_PROVIDER : WEATHER_PROVIDER; - let adjustmentMethodResponse: AdjustmentMethodResponse; - try { - adjustmentMethodResponse = await adjustmentMethod.calculateWateringScale( - adjustmentOptions, coordinates, weatherProvider, pws - ); - } catch ( err ) { - if ( typeof err != "string" ) { - /* If an error occurs under expected circumstances (e.g. required optional fields from a weather API are - missing), an AdjustmentOption must throw a string. If a non-string error is caught, it is likely an Error - thrown by the JS engine due to unexpected circumstances. The user should not be shown the error message - since it may contain sensitive information. */ - res.send( "Error: an unexpected error occurred." ); - console.error( `An unexpected error occurred for ${ req.url }: `, err ); - } else { - res.send( "Error: " + err ); - } - - return; - } - - let scale = adjustmentMethodResponse.scale; - - if ( checkRestrictions ) { - let wateringData: BaseWateringData = adjustmentMethodResponse.wateringData; - // Fetch the watering data if the AdjustmentMethod didn't fetch it and restrictions are being checked. - if ( checkRestrictions && !wateringData ) { - try { - wateringData = await weatherProvider.getWateringData( coordinates ); - } catch ( err ) { - res.send( "Error: " + err ); - return; - } - } - - // Check for any user-set restrictions and change the scale to 0 if the criteria is met - if ( checkWeatherRestriction( req.params[ 0 ], wateringData ) ) { - scale = 0; - } - } const data = { - scale: scale, - rd: adjustmentMethodResponse.rainDelay, + scale: undefined, + rd: undefined, tz: getTimezone( timeData.timezone, undefined ), sunrise: timeData.sunrise, sunset: timeData.sunset, eip: ipToInt( remoteAddress ), - rawData: adjustmentMethodResponse.rawData, - error: adjustmentMethodResponse.errorMessage + rawData: undefined, + error: undefined }; + let cachedScale: CachedScale; + if ( weatherProvider.shouldCacheWateringScale() ) { + cachedScale = cache.getWateringScale( req.params[ 0 ], coordinates, pws, adjustmentOptions ); + } + + if ( cachedScale ) { + // Use the cached data if it exists. + data.scale = cachedScale.scale; + data.rawData = cachedScale.rawData; + data.rd = cachedScale.rainDelay; + } else { + // Calculate the watering scale if it wasn't found in the cache. + let adjustmentMethodResponse: AdjustmentMethodResponse; + try { + adjustmentMethodResponse = await adjustmentMethod.calculateWateringScale( + adjustmentOptions, coordinates, weatherProvider, pws + ); + } catch ( err ) { + if ( typeof err != "string" ) { + /* If an error occurs under expected circumstances (e.g. required optional fields from a weather API are + missing), an AdjustmentOption must throw a string. If a non-string error is caught, it is likely an Error + thrown by the JS engine due to unexpected circumstances. The user should not be shown the error message + since it may contain sensitive information. */ + res.send( "Error: an unexpected error occurred." ); + console.error( `An unexpected error occurred for ${ req.url }: `, err ); + } else { + res.send( "Error: " + err ); + } + + return; + } + + data.scale = adjustmentMethodResponse.scale; + data.error = adjustmentMethodResponse.errorMessage; + data.rd = adjustmentMethodResponse.rainDelay; + data.rawData = adjustmentMethodResponse.rawData; + + if ( checkRestrictions ) { + let wateringData: BaseWateringData = adjustmentMethodResponse.wateringData; + // Fetch the watering data if the AdjustmentMethod didn't fetch it and restrictions are being checked. + if ( checkRestrictions && !wateringData ) { + try { + wateringData = await weatherProvider.getWateringData( coordinates ); + } catch ( err ) { + res.send( "Error: " + err ); + return; + } + } + + // Check for any user-set restrictions and change the scale to 0 if the criteria is met + if ( checkWeatherRestriction( req.params[ 0 ], wateringData ) ) { + data.scale = 0; + } + } + + // Cache the watering scale if caching is enabled and no error occurred. + if ( weatherProvider.shouldCacheWateringScale() && !data.error ) { + cache.storeWateringScale( req.params[ 0 ], coordinates, pws, adjustmentOptions, { + scale: data.scale, + rawData: data.rawData, + rainDelay: data.rd + } ); + } + } + // Return the response to the client in the requested format if ( outputFormat === "json" ) { res.json( data ); diff --git a/routes/weatherProviders/DarkSky.ts b/routes/weatherProviders/DarkSky.ts index a7228aa..d064bca 100644 --- a/routes/weatherProviders/DarkSky.ts +++ b/routes/weatherProviders/DarkSky.ts @@ -111,4 +111,8 @@ export default class DarkSkyWeatherProvider extends WeatherProvider { return weather; } + + public shouldCacheWateringScale(): boolean { + return true; + } } diff --git a/routes/weatherProviders/WeatherProvider.ts b/routes/weatherProviders/WeatherProvider.ts index 895bbab..0667978 100644 --- a/routes/weatherProviders/WeatherProvider.ts +++ b/routes/weatherProviders/WeatherProvider.ts @@ -24,4 +24,13 @@ export class WeatherProvider { getWeatherData( coordinates : GeoCoordinates ): Promise< WeatherData > { throw "Selected WeatherProvider does not support getWeatherData"; } + + /** + * Returns a boolean indicating if watering scales calculated using data from this WeatherProvider should be cached + * until the end of the day in timezone the data was for. + * @return a boolean indicating if watering scales calculated using data from this WeatherProvider should be cached. + */ + shouldCacheWateringScale(): boolean { + return false; + } } From 7ea1824048e4b3292bbc0efe1ebf469bdd080c5b Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Fri, 5 Jul 2019 22:47:07 -0400 Subject: [PATCH 21/24] Improve PWS support The previous implementation would have required a firmware update --- routes/adjustmentMethods/AdjustmentMethod.ts | 5 ++++- routes/weather.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/routes/adjustmentMethods/AdjustmentMethod.ts b/routes/adjustmentMethods/AdjustmentMethod.ts index 3416a2a..2e5e051 100644 --- a/routes/adjustmentMethods/AdjustmentMethod.ts +++ b/routes/adjustmentMethods/AdjustmentMethod.ts @@ -56,4 +56,7 @@ export interface AdjustmentMethodResponse { wateringData: BaseWateringData; } -export interface AdjustmentOptions {} +export interface AdjustmentOptions { + /** Information about the PWS to use in the format "pws:API_KEY@PWS_ID". */ + pws?: string; +} diff --git a/routes/weather.ts b/routes/weather.ts index b41e00e..c0e7d21 100644 --- a/routes/weather.ts +++ b/routes/weather.ts @@ -185,7 +185,6 @@ export const getWateringData = async function( req: express.Request, res: expres location: string | GeoCoordinates = getParameter(req.query.loc), outputFormat: string = getParameter(req.query.format), remoteAddress: string = getParameter(req.headers[ "x-forwarded-for" ]) || req.connection.remoteAddress, - pwsString: string = getParameter( req.query.pws ), adjustmentOptions: AdjustmentOptions; // X-Forwarded-For header may contain more than one IP address and therefore @@ -219,6 +218,7 @@ export const getWateringData = async function( req: express.Request, res: expres let timeData: TimeData = getTimeData( coordinates ); // Parse the PWS information. + const pwsString: string | undefined = adjustmentOptions.pws; let pws: PWS | undefined = undefined; if ( pwsString ) { try { From c53e60e09099f445f7f483ea45bd19bf1a1670b9 Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Sat, 6 Jul 2019 09:30:32 -0400 Subject: [PATCH 22/24] Split PWS ID and API key into 2 parameters --- routes/adjustmentMethods/AdjustmentMethod.ts | 4 +- routes/weather.ts | 44 +++++++++----------- 2 files changed, 23 insertions(+), 25 deletions(-) diff --git a/routes/adjustmentMethods/AdjustmentMethod.ts b/routes/adjustmentMethods/AdjustmentMethod.ts index 2e5e051..514dabe 100644 --- a/routes/adjustmentMethods/AdjustmentMethod.ts +++ b/routes/adjustmentMethods/AdjustmentMethod.ts @@ -57,6 +57,8 @@ export interface AdjustmentMethodResponse { } export interface AdjustmentOptions { - /** Information about the PWS to use in the format "pws:API_KEY@PWS_ID". */ + /** The ID of the PWS to use, prefixed with "pws:". */ pws?: string; + /** The API key to use to access PWS data. */ + key?: string; } diff --git a/routes/weather.ts b/routes/weather.ts index c0e7d21..c8feb0c 100644 --- a/routes/weather.ts +++ b/routes/weather.ts @@ -218,15 +218,29 @@ export const getWateringData = async function( req: express.Request, res: expres let timeData: TimeData = getTimeData( coordinates ); // Parse the PWS information. - const pwsString: string | undefined = adjustmentOptions.pws; let pws: PWS | undefined = undefined; - if ( pwsString ) { - try { - pws = parsePWS( pwsString ); - } catch ( err ) { - res.send( `Error: ${ err }` ); + if ( adjustmentOptions.pws ) { + if ( !adjustmentOptions.key ) { + res.send("Error: An API key must be provided when using a PWS."); return; } + + const idMatch = adjustmentOptions.pws.match( /^pws:([a-zA-Z\d]+)$/ ); + const pwsId = idMatch ? idMatch[ 1 ] : undefined; + const keyMatch = adjustmentOptions.key.match( /^[a-f\d]{32}$/ ); + const apiKey = keyMatch ? keyMatch[ 0 ] : undefined; + + // Make sure that the PWS ID and API key look valid. + if ( !pwsId ) { + res.send("Error: PWS ID does not appear to be valid."); + return; + } + if ( !apiKey ) { + res.send("Error: PWS API key does not appear to be valid."); + return; + } + + pws = { id: pwsId, apiKey: apiKey }; } const weatherProvider = pws ? PWS_WEATHER_PROVIDER : WEATHER_PROVIDER; @@ -474,21 +488,3 @@ function getParameter( parameter: string | string[] ): string { // Return an empty string if the parameter is undefined. return parameter || ""; } - -/** - * Creates a PWS object from a string. - * @param pwsString Information about the PWS in the format "pws:API_KEY@PWS_ID". - * @return The PWS specified by the string. - * @throws Throws an error message if the string is in an invalid format and cannot be parsed. - */ -function parsePWS( pwsString: string): PWS { - const match = pwsString.match( /^pws:([a-f\d]{32})@([a-zA-Z\d]+)$/ ); - if ( !match ) { - throw "Invalid PWS format."; - } - - return { - apiKey: match[ 1 ], - id: match[ 2 ] - }; -} From 5787300b87ed5074642166b8dc0493266a787dea Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Fri, 12 Jul 2019 19:43:25 -0400 Subject: [PATCH 23/24] Silently fallback to default WeatherProvider if PWS key is omitted --- routes/weather.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/routes/weather.ts b/routes/weather.ts index c8feb0c..694438c 100644 --- a/routes/weather.ts +++ b/routes/weather.ts @@ -219,11 +219,7 @@ export const getWateringData = async function( req: express.Request, res: expres // Parse the PWS information. let pws: PWS | undefined = undefined; - if ( adjustmentOptions.pws ) { - if ( !adjustmentOptions.key ) { - res.send("Error: An API key must be provided when using a PWS."); - return; - } + if ( adjustmentOptions.pws && adjustmentOptions.key ) { const idMatch = adjustmentOptions.pws.match( /^pws:([a-zA-Z\d]+)$/ ); const pwsId = idMatch ? idMatch[ 1 ] : undefined; From 2992df9c338d6b3a22b24602a95b9dd68e81f72f Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Fri, 12 Jul 2019 20:04:25 -0400 Subject: [PATCH 24/24] Remove "pws:" prefix from PWS ID --- routes/adjustmentMethods/AdjustmentMethod.ts | 2 +- routes/weather.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/routes/adjustmentMethods/AdjustmentMethod.ts b/routes/adjustmentMethods/AdjustmentMethod.ts index 514dabe..9b37574 100644 --- a/routes/adjustmentMethods/AdjustmentMethod.ts +++ b/routes/adjustmentMethods/AdjustmentMethod.ts @@ -57,7 +57,7 @@ export interface AdjustmentMethodResponse { } export interface AdjustmentOptions { - /** The ID of the PWS to use, prefixed with "pws:". */ + /** The ID of the PWS to use. */ pws?: string; /** The API key to use to access PWS data. */ key?: string; diff --git a/routes/weather.ts b/routes/weather.ts index 694438c..3456cb0 100644 --- a/routes/weather.ts +++ b/routes/weather.ts @@ -221,8 +221,8 @@ export const getWateringData = async function( req: express.Request, res: expres let pws: PWS | undefined = undefined; if ( adjustmentOptions.pws && adjustmentOptions.key ) { - const idMatch = adjustmentOptions.pws.match( /^pws:([a-zA-Z\d]+)$/ ); - const pwsId = idMatch ? idMatch[ 1 ] : undefined; + const idMatch = adjustmentOptions.pws.match( /^[a-zA-Z\d]+$/ ); + const pwsId = idMatch ? idMatch[ 0 ] : undefined; const keyMatch = adjustmentOptions.key.match( /^[a-f\d]{32}$/ ); const apiKey = keyMatch ? keyMatch[ 0 ] : undefined;