From 0281a835e256d3de789175a1be0ad0a1e5ba741a Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Sun, 9 Jun 2019 11:25:21 -0400 Subject: [PATCH 1/3] Refactor adjustment method selection --- routes/adjustmentMethods/AdjustmentMethod.ts | 58 ++++++++ .../ManualAdjustmentMethod.ts | 17 +++ .../RainDelayAdjustmentMethod.ts | 27 ++++ .../ZimmermanAdjustmentMethod.ts | 94 ++++++++++++ routes/weather.ts | 137 +++++------------- types.ts | 17 --- 6 files changed, 233 insertions(+), 117 deletions(-) create mode 100644 routes/adjustmentMethods/AdjustmentMethod.ts create mode 100644 routes/adjustmentMethods/ManualAdjustmentMethod.ts create mode 100644 routes/adjustmentMethods/RainDelayAdjustmentMethod.ts create mode 100644 routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts diff --git a/routes/adjustmentMethods/AdjustmentMethod.ts b/routes/adjustmentMethods/AdjustmentMethod.ts new file mode 100644 index 0000000..374f260 --- /dev/null +++ b/routes/adjustmentMethods/AdjustmentMethod.ts @@ -0,0 +1,58 @@ +import { GeoCoordinates, WateringData } from "../../types"; +import { WeatherProvider } from "../weatherProviders/WeatherProvider"; + + +export interface AdjustmentMethod { + /** + * Calculates the percentage that should be used to scale watering time. + * @param adjustmentOptions The user-specified options for the calculation, or undefined/null if no custom values + * are to be used. No checks will be made to ensure the AdjustmentOptions are the correct type that the function + * is expecting or to ensure that any of its fields are valid. + * @param wateringData The basic weather information of the watering site. This may be undefined if an error occurred + * while retrieving the data. + * @param coordinates The coordinates of the watering site. + * @param weatherProvider The WeatherProvider that should be used if the adjustment method needs to obtain any more + * weather data. + * @return A Promise that will be resolved with the result of the calculation, or rejected with an error message if + * the watering scale cannot be calculated. + * @throws An error message can be thrown if an error occurs while calculating the watering scale. + */ + calculateWateringScale( + adjustmentOptions: AdjustmentOptions, + wateringData: WateringData | undefined, + coordinates: GeoCoordinates, + weatherProvider: WeatherProvider + ): Promise< AdjustmentMethodResponse >; +} + +export interface AdjustmentMethodResponse { + /** + * The percentage that should be used to scale the watering level. This should be an integer between 0-200 (inclusive), + * or undefined if the watering level should not be changed. + */ + scale: number | undefined; + /** + * The raw data that was used to calculate the watering scale. This will be sent directly to the OS controller, so + * each field should be formatted in a way that the controller understands and numbers should be rounded + * appropriately to remove excessive figures. If no data was used (e.g. an error occurred), this should be undefined. + */ + rawData?: object; + /** + * How long watering should be delayed for (in hours) due to rain, or undefined if watering should not be delayed + * for a specific amount of time (either it should be delayed indefinitely or it should not be delayed at all). This + * property will not stop watering on its own, and the `scale` property should be set to 0 to actually prevent + * watering. + */ + rainDelay?: number; + // TODO consider removing this field and breaking backwards compatibility to handle all errors consistently. + /** + * An message to send to the OS firmware to indicate that an error occurred while calculating the watering + * scale and the returned scale either defaulted to some reasonable value or was calculated with incomplete data. + * Older firmware versions will ignore this field (they will silently swallow the error and use the returned scale), + * but newer firmware versions may be able to alert the user that an error occurred and/or default to a + * user-configured watering scale instead of using the one returned by the AdjustmentMethod. + */ + errorMessage?: string; +} + +export interface AdjustmentOptions {} diff --git a/routes/adjustmentMethods/ManualAdjustmentMethod.ts b/routes/adjustmentMethods/ManualAdjustmentMethod.ts new file mode 100644 index 0000000..d584f83 --- /dev/null +++ b/routes/adjustmentMethods/ManualAdjustmentMethod.ts @@ -0,0 +1,17 @@ +import { AdjustmentMethod, AdjustmentMethodResponse } from "./AdjustmentMethod"; + + +/** + * Does not change the watering scale (only time data will be returned). + */ +async function calculateManualWateringScale( ): Promise< AdjustmentMethodResponse > { + return { + scale: undefined + } +} + + +const ManualAdjustmentMethod: AdjustmentMethod = { + calculateWateringScale: calculateManualWateringScale +}; +export default ManualAdjustmentMethod; diff --git a/routes/adjustmentMethods/RainDelayAdjustmentMethod.ts b/routes/adjustmentMethods/RainDelayAdjustmentMethod.ts new file mode 100644 index 0000000..56b1406 --- /dev/null +++ b/routes/adjustmentMethods/RainDelayAdjustmentMethod.ts @@ -0,0 +1,27 @@ +import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./AdjustmentMethod"; +import { WateringData } from "../../types"; + + +/** + * Only delays watering if it is currently raining and does not adjust the watering scale. + */ +async function calculateRainDelayWateringScale( adjustmentOptions: RainDelayAdjustmentOptions, wateringData: WateringData | undefined ): Promise< AdjustmentMethodResponse > { + const raining = wateringData && wateringData.raining; + const d = adjustmentOptions && adjustmentOptions.hasOwnProperty( "d" ) ? adjustmentOptions.d : 24; + return { + scale: undefined, + rawData: { raining: raining ? 1 : 0 }, + rainDelay: raining ? d : undefined + } +} + +export interface RainDelayAdjustmentOptions extends AdjustmentOptions { + /** The rain delay to use (in hours). */ + d?: number; +} + + +const RainDelayAdjustmentMethod: AdjustmentMethod = { + calculateWateringScale: calculateRainDelayWateringScale +}; +export default RainDelayAdjustmentMethod; diff --git a/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts b/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts new file mode 100644 index 0000000..e5268c0 --- /dev/null +++ b/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts @@ -0,0 +1,94 @@ +import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./AdjustmentMethod"; +import { WateringData } from "../../types"; +import { validateValues } from "../weather"; + + +/** + * Calculates how much watering should be scaled based on weather and adjustment options using the Zimmerman method. + * (https://github.com/rszimm/sprinklers_pi/wiki/Weather-adjustments#formula-for-setting-the-scale) + */ +async function calculateZimmermanWateringScale( adjustmentOptions: ZimmermanAdjustmentOptions, wateringData: WateringData | undefined ): Promise< AdjustmentMethodResponse > { + + // Temporarily disabled since OWM forecast data is checking if rain is forecasted for 3 hours in the future. + /* + // Don't water if it is currently raining. + if ( wateringData && wateringData.raining ) { + return { + scale: 0, + rawData: { raining: 1 } + } + } + */ + + const rawData = { + h: wateringData ? Math.round( wateringData.humidity * 100) / 100 : null, + p: wateringData ? Math.round( wateringData.precip * 100 ) / 100 : null, + t: wateringData ? Math.round( wateringData.temp * 10 ) / 10 : null, + raining: wateringData ? ( wateringData.raining ? 1 : 0 ) : null + }; + + // Check to make sure valid data exists for all factors + if ( !validateValues( [ "temp", "humidity", "precip" ], wateringData ) ) { + // Default to a scale of 100% if fields are missing. + return { + scale: 100, + rawData: rawData, + errorMessage: "Necessary field(s) were missing from WateringData." + }; + } + + let humidityBase = 30, tempBase = 70, precipBase = 0; + + // Get baseline conditions for 100% water level, if provided + if ( adjustmentOptions ) { + humidityBase = adjustmentOptions.hasOwnProperty( "bh" ) ? adjustmentOptions.bh : humidityBase; + tempBase = adjustmentOptions.hasOwnProperty( "bt" ) ? adjustmentOptions.bt : tempBase; + precipBase = adjustmentOptions.hasOwnProperty( "br" ) ? adjustmentOptions.br : precipBase; + } + + let humidityFactor = ( humidityBase - wateringData.humidity ), + tempFactor = ( ( wateringData.temp - tempBase ) * 4 ), + precipFactor = ( ( precipBase - wateringData.precip ) * 200 ); + + // Apply adjustment options, if provided, by multiplying the percentage against the factor + if ( adjustmentOptions ) { + if ( adjustmentOptions.hasOwnProperty( "h" ) ) { + humidityFactor = humidityFactor * ( adjustmentOptions.h / 100 ); + } + + if ( adjustmentOptions.hasOwnProperty( "t" ) ) { + tempFactor = tempFactor * ( adjustmentOptions.t / 100 ); + } + + if ( adjustmentOptions.hasOwnProperty( "r" ) ) { + precipFactor = precipFactor * ( adjustmentOptions.r / 100 ); + } + } + + return { + // Apply all of the weather modifying factors and clamp the result between 0 and 200%. + scale: Math.floor( Math.min( Math.max( 0, 100 + humidityFactor + tempFactor + precipFactor ), 200 ) ), + rawData: rawData + } +} + +export interface ZimmermanAdjustmentOptions extends AdjustmentOptions { + /** Base humidity (as a percentage). */ + bh?: number; + /** Base temperature (in Fahrenheit). */ + bt?: number; + /** Base precipitation (in inches). */ + br?: number; + /** The percentage to weight the humidity factor by. */ + h?: number; + /** The percentage to weight the temperature factor by. */ + t?: number; + /** The percentage to weight the precipitation factor by. */ + r?: number; +} + + +const ZimmermanAdjustmentMethod: AdjustmentMethod = { + calculateWateringScale: calculateZimmermanWateringScale +}; +export default ZimmermanAdjustmentMethod; diff --git a/routes/weather.ts b/routes/weather.ts index 3e93147..c511a7c 100644 --- a/routes/weather.ts +++ b/routes/weather.ts @@ -5,8 +5,12 @@ import * as SunCalc from "suncalc"; import * as moment from "moment-timezone"; import * as geoTZ from "geo-tz"; -import { AdjustmentOptions, GeoCoordinates, TimeData, WateringData, WeatherData } from "../types"; +import { GeoCoordinates, TimeData, WateringData, WeatherData } from "../types"; import { WeatherProvider } from "./weatherProviders/WeatherProvider"; +import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./adjustmentMethods/AdjustmentMethod"; +import ManualAdjustmentMethod from "./adjustmentMethods/ManualAdjustmentMethod"; +import ZimmermanAdjustmentMethod from "./adjustmentMethods/ZimmermanAdjustmentMethod"; +import RainDelayAdjustmentMethod from "./adjustmentMethods/RainDelayAdjustmentMethod"; const weatherProvider: WeatherProvider = new ( require("./weatherProviders/" + ( process.env.WEATHER_PROVIDER || "OWM" ) ).default )(); // Define regex filters to match against location @@ -18,11 +22,11 @@ const filters = { timezone: /^()()()()()()([+-])(\d{2})(\d{2})/ }; -// Enum of available watering scale adjustment methods. -const ADJUSTMENT_METHOD = { - MANUAL: 0, - ZIMMERMAN: 1, - RAIN_DELAY: 2 +/** AdjustmentMethods mapped to their numeric IDs. */ +const ADJUSTMENT_METHOD: { [ key: number ] : AdjustmentMethod } = { + 0: ManualAdjustmentMethod, + 1: ZimmermanAdjustmentMethod, + 2: RainDelayAdjustmentMethod }; /** @@ -107,52 +111,6 @@ function getTimeData( coordinates: GeoCoordinates ): TimeData { }; } -/** - * Calculates how much watering should be scaled based on weather and adjustment options using the Zimmerman method. - * @param adjustmentOptions Options to tweak the calculation, or undefined/null if no custom values are to be used. - * @param wateringData The weather to use to calculate watering percentage. - * @return The percentage that watering should be scaled by. - * @throws An error message will be thrown if the watering scale cannot be calculated. - */ -function calculateZimmermanWateringScale( adjustmentOptions: AdjustmentOptions, wateringData: WateringData ): number { - - let humidityBase = 30, tempBase = 70, precipBase = 0; - - // Check to make sure valid data exists for all factors - if ( !validateValues( [ "temp", "humidity", "precip" ], wateringData ) ) { - throw "Necessary field(s) were missing from WateringData."; - } - - // Get baseline conditions for 100% water level, if provided - if ( adjustmentOptions ) { - humidityBase = adjustmentOptions.hasOwnProperty( "bh" ) ? adjustmentOptions.bh : humidityBase; - tempBase = adjustmentOptions.hasOwnProperty( "bt" ) ? adjustmentOptions.bt : tempBase; - precipBase = adjustmentOptions.hasOwnProperty( "br" ) ? adjustmentOptions.br : precipBase; - } - - let humidityFactor = ( humidityBase - wateringData.humidity ), - tempFactor = ( ( wateringData.temp - tempBase ) * 4 ), - precipFactor = ( ( precipBase - wateringData.precip ) * 200 ); - - // Apply adjustment options, if provided, by multiplying the percentage against the factor - if ( adjustmentOptions ) { - if ( adjustmentOptions.hasOwnProperty( "h" ) ) { - humidityFactor = humidityFactor * ( adjustmentOptions.h / 100 ); - } - - if ( adjustmentOptions.hasOwnProperty( "t" ) ) { - tempFactor = tempFactor * ( adjustmentOptions.t / 100 ); - } - - if ( adjustmentOptions.hasOwnProperty( "r" ) ) { - precipFactor = precipFactor * ( adjustmentOptions.r / 100 ); - } - } - - // Apply all of the weather modifying factors and clamp the result between 0 and 200%. - return Math.floor( Math.min( Math.max( 0, 100 + humidityFactor + tempFactor + precipFactor ), 200 ) ); -} - /** * Checks if the weather data meets any of the restrictions set by OpenSprinkler. Restrictions prevent any watering * from occurring and are similar to 0% watering level. Known restrictions are: @@ -217,7 +175,7 @@ export const getWateringData = async function( req: express.Request, res: expres // The adjustment method is encoded by the OpenSprinkler firmware and must be // parsed. This allows the adjustment method and the restriction type to both // be saved in the same byte. - let adjustmentMethod: number = req.params[ 0 ] & ~( 1 << 7 ), + let adjustmentMethod: AdjustmentMethod = ADJUSTMENT_METHOD[ req.params[ 0 ] & ~( 1 << 7 ) ], checkRestrictions: boolean = ( ( req.params[ 0 ] >> 7 ) & 1 ) > 0, adjustmentOptionsString: string = getParameter(req.query.wto), location: string | GeoCoordinates = getParameter(req.query.loc), @@ -225,13 +183,6 @@ export const getWateringData = async function( req: express.Request, res: expres remoteAddress: string = getParameter(req.headers[ "x-forwarded-for" ]) || req.connection.remoteAddress, adjustmentOptions: AdjustmentOptions; - /* A message to include in the response to indicate that the watering scale was not calculated correctly and - defaulted to 100%. This approach is used for backwards compatibility because older OS firmware versions were - hardcoded to keep the previous watering scale if the response did not include a watering scale, but newer versions - might allow for different behaviors. */ - let errorMessage: string = undefined; - - // X-Forwarded-For header may contain more than one IP address and therefore // the string is split against a comma and the first value is selected remoteAddress = remoteAddress.split( "," )[ 0 ]; @@ -263,7 +214,7 @@ export const getWateringData = async function( req: express.Request, res: expres // Continue with the weather request let timeData: TimeData = getTimeData( coordinates ); let wateringData: WateringData; - if ( adjustmentMethod !== ADJUSTMENT_METHOD.MANUAL || checkRestrictions ) { + if ( adjustmentMethod !== ManualAdjustmentMethod || checkRestrictions ) { try { wateringData = await weatherProvider.getWateringData( coordinates ); } catch ( err ) { @@ -272,59 +223,45 @@ export const getWateringData = async function( req: express.Request, res: expres } } - let scale = -1, rainDelay = -1; - - if ( adjustmentMethod === ADJUSTMENT_METHOD.ZIMMERMAN ) { - try { - scale = calculateZimmermanWateringScale( adjustmentOptions, wateringData ); - } catch ( err ) { - // Default to a scale of 100% if the scale can't be calculated. - scale = 100; - errorMessage = err; + let adjustmentMethodResponse: AdjustmentMethodResponse; + try { + adjustmentMethodResponse = await adjustmentMethod.calculateWateringScale( + adjustmentOptions, wateringData, coordinates, weatherProvider + ); + } catch ( err ) { + if ( typeof err != "string" ) { + /* If an error occurs under expected circumstances (e.g. required optional fields from a weather API are + missing), an AdjustmentOption must throw a string. If a non-string error is caught, it is likely an Error + thrown by the JS engine due to unexpected circumstances. The user should not be shown the error message + since it may contain sensitive information. */ + res.send( "Error: an unexpected error occurred." ); + console.error( `An unexpected error occurred for ${ req.url }: `, err ); + } else { + res.send( "Error: " + err ); } + + return; } - if (wateringData) { + let scale = adjustmentMethodResponse.scale; + if ( wateringData ) { // Check for any user-set restrictions and change the scale to 0 if the criteria is met - if (checkWeatherRestriction(req.params[0], wateringData)) { + if ( checkWeatherRestriction( req.params[ 0 ], wateringData ) ) { scale = 0; } - - // If any weather adjustment is being used, check the rain status - if ( adjustmentMethod > ADJUSTMENT_METHOD.MANUAL && wateringData.raining ) { - - // If it is raining and the user has weather-based rain delay as the adjustment method then apply the specified delay - if ( adjustmentMethod === ADJUSTMENT_METHOD.RAIN_DELAY ) { - - rainDelay = ( adjustmentOptions && adjustmentOptions.hasOwnProperty( "d" ) ) ? adjustmentOptions.d : 24; - } else { - // Temporarily disabled since OWM forecast data is checking if rain is forecasted for 3 hours in the future. - // For any other adjustment method, apply a scale of 0 (as the scale will revert when the rain stops) - // scale = 0; - } - } } const data = { scale: scale, - rd: rainDelay, + rd: adjustmentMethodResponse.rainDelay, tz: getTimezone( timeData.timezone, undefined ), sunrise: timeData.sunrise, sunset: timeData.sunset, eip: ipToInt( remoteAddress ), - rawData: undefined, - error: errorMessage + rawData: adjustmentMethodResponse.rawData, + error: adjustmentMethodResponse.errorMessage }; - if ( adjustmentMethod > ADJUSTMENT_METHOD.MANUAL ) { - data.rawData = { - h: wateringData ? Math.round( wateringData.humidity * 100) / 100 : null, - p: wateringData ? Math.round( wateringData.precip * 100 ) / 100 : null, - t: wateringData ? Math.round( wateringData.temp * 10 ) / 10 : null, - raining: wateringData ? ( wateringData.raining ? 1 : 0 ) : null - }; - } - // Return the response to the client in the requested format if ( outputFormat === "json" ) { res.json( data ); @@ -336,7 +273,7 @@ export const getWateringData = async function( req: express.Request, res: expres "&sunset=" + data.sunset + "&eip=" + data.eip + ( data.rawData ? "&rawData=" + JSON.stringify( data.rawData ) : "" ) + - ( errorMessage ? "&error=" + encodeURIComponent( errorMessage ) : "" ) + ( data.error ? "&error=" + encodeURIComponent( data.error ) : "" ) ); } @@ -392,7 +329,7 @@ async function httpRequest( url: string ): Promise< string > { * @param obj The object to check. * @return A boolean indicating if the object has numeric values for all of the specified keys. */ -function validateValues( keys: string[], obj: object ): boolean { +export function validateValues( keys: string[], obj: object ): boolean { let key: string; // Return false if the object is null/undefined. diff --git a/types.ts b/types.ts index 07bbadb..c66cf8c 100644 --- a/types.ts +++ b/types.ts @@ -68,21 +68,4 @@ export interface WateringData { raining: boolean; } -export interface AdjustmentOptions { - /** Base humidity (as a percentage). */ - bh?: number; - /** Base temperature (in Fahrenheit). */ - bt?: number; - /** Base precipitation (in inches). */ - br?: number; - /** The percentage to weight the humidity factor by. */ - h?: number; - /** The percentage to weight the temperature factor by. */ - t?: number; - /** The percentage to weight the precipitation factor by. */ - r?: number; - /** The rain delay to use (in hours). */ - d?: number; -} - export type WeatherProviderId = "OWM" | "DarkSky" | "local"; From 3f748704e0e80daeccf073f7d5a13b1590bbe4b6 Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Sun, 9 Jun 2019 12:07:09 -0400 Subject: [PATCH 2/3] Automatically URL query format response --- routes/weather.ts | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/routes/weather.ts b/routes/weather.ts index c511a7c..1401c77 100644 --- a/routes/weather.ts +++ b/routes/weather.ts @@ -266,17 +266,34 @@ export const getWateringData = async function( req: express.Request, res: expres if ( outputFormat === "json" ) { res.json( data ); } else { - res.send( "&scale=" + data.scale + - "&rd=" + data.rd + - "&tz=" + data.tz + - "&sunrise=" + data.sunrise + - "&sunset=" + data.sunset + - "&eip=" + data.eip + - ( data.rawData ? "&rawData=" + JSON.stringify( data.rawData ) : "" ) + - ( data.error ? "&error=" + encodeURIComponent( data.error ) : "" ) - ); - } + // Return the data formatted as a URL query string. + let formatted = ""; + for ( const key in data ) { + // Skip inherited properties. + if ( !data.hasOwnProperty( key ) ) { + continue; + } + let value = data[ key ]; + switch ( typeof value ) { + case "undefined": + // Skip undefined properties. + continue; + case "object": + // Convert objects to JSON. + value = JSON.stringify( value ); + // Fallthrough. + case "string": + /* URL encode strings. Since the OS firmware uses a primitive version of query string parsing and + decoding, only some characters need to be escaped and only spaces ("+" or "%20") will be decoded. */ + value = value.replace( / /g, "+" ).replace( /\n/g, "\\n" ).replace( /&/g, "AMPERSAND" ); + break; + } + + formatted += `&${ key }=${ value }`; + } + res.send( formatted ); + } }; /** From 56f32b2d668b780b9a9a58187cfd9c97a18939d1 Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Mon, 10 Jun 2019 19:03:46 -0400 Subject: [PATCH 3/3] Fix tests --- test/expected.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/test/expected.json b/test/expected.json index 4b78b25..fdcdf8f 100644 --- a/test/expected.json +++ b/test/expected.json @@ -1,8 +1,6 @@ { "noWeather": { "01002": { - "scale": -1, - "rd": -1, "tz": 32, "sunrise": 332, "sunset": 1203, @@ -12,7 +10,6 @@ "adjustment1": { "01002": { "scale": 0, - "rd": -1, "tz": 32, "sunrise": 332, "sunset": 1203,