From 8d9fb96ea992d9500761967724dae9532ca9b86e Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Fri, 21 Jun 2019 17:25:18 -0400 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 dd92b8ecf878382cebed57df4b4f6cbff7302bc5 Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Sun, 30 Jun 2019 18:24:58 -0400 Subject: [PATCH 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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 ] - }; -}