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/adjustmentMethods/AdjustmentMethod.ts b/routes/adjustmentMethods/AdjustmentMethod.ts new file mode 100644 index 0000000..9b37574 --- /dev/null +++ b/routes/adjustmentMethods/AdjustmentMethod.ts @@ -0,0 +1,64 @@ +import { BaseWateringData, GeoCoordinates, PWS } 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. 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 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. + */ + calculateWateringScale( + adjustmentOptions: AdjustmentOptions, + coordinates: GeoCoordinates, + weatherProvider: WeatherProvider, + pws?: PWS + ): 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; + /** The data that was used to calculate the watering scale, or undefined if no data was used. */ + wateringData: BaseWateringData; +} + +export interface AdjustmentOptions { + /** The ID of the PWS to use. */ + pws?: string; + /** The API key to use to access PWS data. */ + key?: string; +} diff --git a/routes/adjustmentMethods/ManualAdjustmentMethod.ts b/routes/adjustmentMethods/ManualAdjustmentMethod.ts new file mode 100644 index 0000000..7c9e8f7 --- /dev/null +++ b/routes/adjustmentMethods/ManualAdjustmentMethod.ts @@ -0,0 +1,18 @@ +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, + wateringData: 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..bfa1348 --- /dev/null +++ b/routes/adjustmentMethods/RainDelayAdjustmentMethod.ts @@ -0,0 +1,30 @@ +import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./AdjustmentMethod"; +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, 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, + wateringData: wateringData + } +} + +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..e8fea63 --- /dev/null +++ b/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts @@ -0,0 +1,100 @@ +import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./AdjustmentMethod"; +import { GeoCoordinates, PWS, 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, + 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. + /* + // Don't water if it is currently raining. + if ( wateringData && wateringData.raining ) { + return { + scale: 0, + rawData: { raining: 1 }, + wateringData: wateringData + } + } + */ + + 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 ZimmermanWateringData.", + wateringData: wateringData + }; + } + + let humidityBase = 30, tempBase = 70, precipBase = 0; + + // Get baseline conditions for 100% water level, if provided + 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.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, + wateringData: wateringData + } +} + +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.spec.ts b/routes/weather.spec.ts index 87cede5..3964910 100644 --- a/routes/weather.spec.ts +++ b/routes/weather.spec.ts @@ -4,7 +4,13 @@ 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, ZimmermanWateringData, WeatherData } from "../types"; +import { WeatherProvider } from "./weatherProviders/WeatherProvider"; const expected = require( '../test/expected.json' ); const replies = require( '../test/replies.json' ); @@ -58,4 +64,43 @@ 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 extends WeatherProvider { + + private readonly mockData: MockWeatherData; + + public constructor(mockData: MockWeatherData) { + super(); + this.mockData = mockData; + } + + public async getWateringData( coordinates: GeoCoordinates ): Promise< ZimmermanWateringData > { + 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?: ZimmermanWateringData, + weatherData?: WeatherData +} diff --git a/routes/weather.ts b/routes/weather.ts index 70450aa..3456cb0 100644 --- a/routes/weather.ts +++ b/routes/weather.ts @@ -5,8 +5,15 @@ 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 { 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"; +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 )(); // Define regex filters to match against location const filters = { @@ -17,13 +24,15 @@ 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 }; +const cache = new WateringScaleCache(); + /** * Resolves a location description to geographic coordinates. * @param location A partial zip/city/country or a coordinate pair. @@ -37,7 +46,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 ] ) ]; @@ -71,7 +80,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 { @@ -105,51 +115,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. - */ -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 ) ) { - return 100; - } - - // 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: @@ -160,7 +125,7 @@ function calculateZimmermanWateringScale( adjustmentOptions: AdjustmentOptions, * @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; @@ -181,11 +146,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 ); @@ -196,7 +156,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 WEATHER_PROVIDER.getWeatherData( coordinates ); + } catch ( err ) { + res.send( "Error: " + err ); + return; + } res.json( { ...timeData, @@ -213,7 +179,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), @@ -221,7 +187,6 @@ export const getWateringData = async function( req: express.Request, res: expres remoteAddress: string = getParameter(req.headers[ "x-forwarded-for" ]) || req.connection.remoteAddress, adjustmentOptions: AdjustmentOptions; - // 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 ]; @@ -250,85 +215,148 @@ 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 !== ADJUSTMENT_METHOD.MANUAL || checkRestrictions ) { - if ( !weatherProvider.getWateringData ) { - res.send( "Error: selected WeatherProvider does not support getWateringData" ); + + // Parse the PWS information. + let pws: PWS | undefined = undefined; + if ( adjustmentOptions.pws && adjustmentOptions.key ) { + + 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; + + // 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; } - wateringData = await weatherProvider.getWateringData( coordinates ); + pws = { id: pwsId, apiKey: apiKey }; } - let scale = -1, rainDelay = -1; - - if ( adjustmentMethod === ADJUSTMENT_METHOD.ZIMMERMAN ) { - scale = calculateZimmermanWateringScale( adjustmentOptions, wateringData ); - } - - 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)) { - 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 weatherProvider = pws ? PWS_WEATHER_PROVIDER : WEATHER_PROVIDER; const data = { - scale: scale, - rd: rainDelay, + scale: undefined, + rd: undefined, tz: getTimezone( timeData.timezone, undefined ), sunrise: timeData.sunrise, sunset: timeData.sunset, eip: ipToInt( remoteAddress ), - rawData: undefined + rawData: undefined, + error: undefined }; - 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 - }; + 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 ); } 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 ) : "" ) - ); - } + // 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 ); + } }; /** * 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 +371,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 @@ -368,7 +401,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/routes/weatherProviders/DarkSky.ts b/routes/weatherProviders/DarkSky.ts index 9655acf..d064bca 100644 --- a/routes/weatherProviders/DarkSky.ts +++ b/routes/weatherProviders/DarkSky.ts @@ -1,113 +1,118 @@ import * as moment from "moment-timezone"; -import { GeoCoordinates, WateringData, WeatherData, WeatherProvider } from "../../types"; +import { GeoCoordinates, WeatherData, ZimmermanWateringData } 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`; + private readonly API_KEY: string; - let yesterdayData, todayData; - try { - yesterdayData = await httpJSONRequest( yesterdayUrl ); - todayData = await httpJSONRequest( todayUrl ); - } catch (err) { - // Indicate watering data could not be retrieved if an API error occurs. - return undefined; + public constructor() { + super(); + this.API_KEY = process.env.DARKSKY_API_KEY; + if (!this.API_KEY) { + throw "DARKSKY_API_KEY environment variable is not defined."; + } } - if ( !todayData.hourly || !todayData.hourly.data || !yesterdayData.hourly || !yesterdayData.hourly.data ) { - return undefined; + public async getWateringData( coordinates: GeoCoordinates ): Promise< ZimmermanWateringData > { + // The Unix timestamp of 24 hours ago. + const yesterdayTimestamp: number = moment().subtract( 1, "day" ).unix(); + + const yesterdayUrl = `https://api.darksky.net/forecast/${ this.API_KEY }/${ coordinates[ 0 ] },${ coordinates[ 1 ] },${ yesterdayTimestamp }?exclude=currently,minutely,daily,alerts,flags`; + + let yesterdayData; + try { + 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 ( !yesterdayData.hourly || !yesterdayData.hourly.data ) { + throw "Necessary field(s) were missing from weather information returned by Dark Sky."; + } + + const samples = [ + ...yesterdayData.hourly.data + ]; + + // 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 ) { + /* + * 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; + // This field may be missing from the response if it is snowing. + totals.precip += sample.precipIntensity || 0; + } + + return { + weatherProvider: "DarkSky", + temp: totals.temp / 24, + humidity: totals.humidity / 24 * 100, + precip: totals.precip, + raining: samples[ samples.length - 1 ].precipIntensity > 0 + }; } - /* 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 ) ); + public async getWeatherData( coordinates: GeoCoordinates ): Promise< WeatherData > { + const forecastUrl = `https://api.darksky.net/forecast/${ this.API_KEY }/${ coordinates[ 0 ] },${ coordinates[ 1 ] }?exclude=minutely,alerts,flags`; - /* 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 ) - ]; + 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." + } - // Fail if not enough data is available. - if ( samples.length !== 24 ) { - return undefined; - } + if ( !forecast.currently || !forecast.daily || !forecast.daily.data ) { + throw "Necessary field(s) were missing from weather information 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) { - // Indicate weather data could not be retrieved if an API error occurs. - return undefined; - } - - if ( !forecast.currently || !forecast.daily || !forecast.daily.data ) { - return undefined; - } - - 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; + public shouldCacheWateringScale(): boolean { + return true; + } } - - -const DarkSkyWeatherProvider: WeatherProvider = { - getWateringData: getDarkSkyWateringData, - getWeatherData: getDarkSkyWeatherData -}; -export default DarkSkyWeatherProvider; diff --git a/routes/weatherProviders/OWM.ts b/routes/weatherProviders/OWM.ts index 096fae2..939cfa0 100644 --- a/routes/weatherProviders/OWM.ts +++ b/routes/weatherProviders/OWM.ts @@ -1,94 +1,100 @@ -import { GeoCoordinates, WateringData, WeatherData, WeatherProvider } from "../../types"; +import { GeoCoordinates, WeatherData, ZimmermanWateringData } 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) { - // Indicate watering data could not be retrieved if an API error occurs. - return undefined; + 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."; + } } - // Indicate watering data could not be retrieved if the forecast data is incomplete. - if ( !forecast || !forecast.list ) { - return undefined; + public async getWateringData( coordinates: GeoCoordinates ): Promise< ZimmermanWateringData > { + 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; + 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 ) + }; } - let totalTemp = 0, - totalHumidity = 0, - totalPrecip = 0; + public async getWeatherData( coordinates: GeoCoordinates ): Promise< WeatherData > { + 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 ] }`; - 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 ); + 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; } - - 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) { - // Indicate watering data could not be retrieved if an API error occurs. - return undefined; - } - - // 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; - } - - 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/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 new file mode 100644 index 0000000..0667978 --- /dev/null +++ b/routes/weatherProviders/WeatherProvider.ts @@ -0,0 +1,36 @@ +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, pws?: PWS ): Promise< ZimmermanWateringData > { + 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 the WeatherProvider does + * not support this method. + */ + 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; + } +} diff --git a/routes/weatherProviders/local.ts b/routes/weatherProviders/local.ts index 314df1c..1a12eca 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, ZimmermanWateringData } 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< 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 + 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/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, diff --git a/types.ts b/types.ts index 6727935..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). @@ -50,57 +53,25 @@ 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; } -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 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. - */ - 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 resolved with undefined if an error occurs while retrieving the WeatherData. - */ - getWeatherData?( coordinates : GeoCoordinates ): Promise< WeatherData >; -} - -export type WeatherProviderId = "OWM" | "DarkSky" | "local"; +export type WeatherProviderId = "OWM" | "DarkSky" | "local" | "mock" | "WUnderground";