diff --git a/routes/adjustmentMethods/AdjustmentMethod.ts b/routes/adjustmentMethods/AdjustmentMethod.ts index 374f260..3416a2a 100644 --- a/routes/adjustmentMethods/AdjustmentMethod.ts +++ b/routes/adjustmentMethods/AdjustmentMethod.ts @@ -1,27 +1,26 @@ -import { GeoCoordinates, WateringData } from "../../types"; +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, 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 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 more + * @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, - wateringData: WateringData | undefined, coordinates: GeoCoordinates, - weatherProvider: WeatherProvider + weatherProvider: WeatherProvider, + pws?: PWS ): Promise< AdjustmentMethodResponse >; } @@ -53,6 +52,8 @@ export interface AdjustmentMethodResponse { * user-configured watering scale instead of using the one returned by the AdjustmentMethod. */ errorMessage?: string; + /** The data that was used to calculate the watering scale, or undefined if no data was used. */ + wateringData: BaseWateringData; } export interface AdjustmentOptions {} diff --git a/routes/adjustmentMethods/ManualAdjustmentMethod.ts b/routes/adjustmentMethods/ManualAdjustmentMethod.ts index d584f83..7c9e8f7 100644 --- a/routes/adjustmentMethods/ManualAdjustmentMethod.ts +++ b/routes/adjustmentMethods/ManualAdjustmentMethod.ts @@ -6,7 +6,8 @@ import { AdjustmentMethod, AdjustmentMethodResponse } from "./AdjustmentMethod"; */ async function calculateManualWateringScale( ): Promise< AdjustmentMethodResponse > { return { - scale: undefined + scale: undefined, + wateringData: undefined } } diff --git a/routes/adjustmentMethods/RainDelayAdjustmentMethod.ts b/routes/adjustmentMethods/RainDelayAdjustmentMethod.ts index 56b1406..bfa1348 100644 --- a/routes/adjustmentMethods/RainDelayAdjustmentMethod.ts +++ b/routes/adjustmentMethods/RainDelayAdjustmentMethod.ts @@ -1,17 +1,20 @@ import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./AdjustmentMethod"; -import { WateringData } from "../../types"; +import { GeoCoordinates, ZimmermanWateringData } from "../../types"; +import { WeatherProvider } from "../weatherProviders/WeatherProvider"; /** * Only delays watering if it is currently raining and does not adjust the watering scale. */ -async function calculateRainDelayWateringScale( adjustmentOptions: RainDelayAdjustmentOptions, wateringData: WateringData | undefined ): Promise< AdjustmentMethodResponse > { +async function calculateRainDelayWateringScale( adjustmentOptions: RainDelayAdjustmentOptions, coordinates: GeoCoordinates, weatherProvider: WeatherProvider ): Promise< AdjustmentMethodResponse > { + const wateringData: ZimmermanWateringData = await weatherProvider.getWateringData( coordinates ); const raining = wateringData && wateringData.raining; - const d = adjustmentOptions && adjustmentOptions.hasOwnProperty( "d" ) ? adjustmentOptions.d : 24; + const d = adjustmentOptions.hasOwnProperty( "d" ) ? adjustmentOptions.d : 24; return { scale: undefined, rawData: { raining: raining ? 1 : 0 }, - rainDelay: raining ? d : undefined + rainDelay: raining ? d : undefined, + wateringData: wateringData } } diff --git a/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts b/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts index e5268c0..e8fea63 100644 --- a/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts +++ b/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts @@ -1,13 +1,20 @@ import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./AdjustmentMethod"; -import { WateringData } from "../../types"; +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, wateringData: WateringData | undefined ): Promise< AdjustmentMethodResponse > { +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. /* @@ -15,7 +22,8 @@ async function calculateZimmermanWateringScale( adjustmentOptions: ZimmermanAdju if ( wateringData && wateringData.raining ) { return { scale: 0, - rawData: { raining: 1 } + rawData: { raining: 1 }, + wateringData: wateringData } } */ @@ -33,42 +41,40 @@ async function calculateZimmermanWateringScale( adjustmentOptions: ZimmermanAdju return { scale: 100, rawData: rawData, - errorMessage: "Necessary field(s) were missing from WateringData." + errorMessage: "Necessary field(s) were missing from ZimmermanWateringData.", + wateringData: wateringData }; } let humidityBase = 30, tempBase = 70, precipBase = 0; // Get baseline conditions for 100% water level, if provided - if ( adjustmentOptions ) { - humidityBase = adjustmentOptions.hasOwnProperty( "bh" ) ? adjustmentOptions.bh : humidityBase; - tempBase = adjustmentOptions.hasOwnProperty( "bt" ) ? adjustmentOptions.bt : tempBase; - precipBase = adjustmentOptions.hasOwnProperty( "br" ) ? adjustmentOptions.br : precipBase; - } + humidityBase = adjustmentOptions.hasOwnProperty( "bh" ) ? adjustmentOptions.bh : humidityBase; + tempBase = adjustmentOptions.hasOwnProperty( "bt" ) ? adjustmentOptions.bt : tempBase; + precipBase = adjustmentOptions.hasOwnProperty( "br" ) ? adjustmentOptions.br : precipBase; let humidityFactor = ( humidityBase - wateringData.humidity ), tempFactor = ( ( wateringData.temp - tempBase ) * 4 ), precipFactor = ( ( precipBase - wateringData.precip ) * 200 ); // Apply adjustment options, if provided, by multiplying the percentage against the factor - if ( adjustmentOptions ) { - if ( adjustmentOptions.hasOwnProperty( "h" ) ) { - humidityFactor = humidityFactor * ( adjustmentOptions.h / 100 ); - } + if ( adjustmentOptions.hasOwnProperty( "h" ) ) { + humidityFactor = humidityFactor * ( adjustmentOptions.h / 100 ); + } - if ( adjustmentOptions.hasOwnProperty( "t" ) ) { - tempFactor = tempFactor * ( adjustmentOptions.t / 100 ); - } + if ( adjustmentOptions.hasOwnProperty( "t" ) ) { + tempFactor = tempFactor * ( adjustmentOptions.t / 100 ); + } - if ( adjustmentOptions.hasOwnProperty( "r" ) ) { - precipFactor = precipFactor * ( adjustmentOptions.r / 100 ); - } + if ( adjustmentOptions.hasOwnProperty( "r" ) ) { + precipFactor = precipFactor * ( adjustmentOptions.r / 100 ); } return { // Apply all of the weather modifying factors and clamp the result between 0 and 200%. scale: Math.floor( Math.min( Math.max( 0, 100 + humidityFactor + tempFactor + precipFactor ), 200 ) ), - rawData: rawData + rawData: rawData, + wateringData: wateringData } } diff --git a/routes/weather.spec.ts b/routes/weather.spec.ts index a979773..3964910 100644 --- a/routes/weather.spec.ts +++ b/routes/weather.spec.ts @@ -9,7 +9,7 @@ process.env.WEATHER_PROVIDER = "OWM"; process.env.OWM_API_KEY = "NO_KEY"; import { getWateringData } from './weather'; -import { GeoCoordinates, WateringData, WeatherData } from "../types"; +import { GeoCoordinates, ZimmermanWateringData, WeatherData } from "../types"; import { WeatherProvider } from "./weatherProviders/WeatherProvider"; const expected = require( '../test/expected.json' ); @@ -81,7 +81,7 @@ export class MockWeatherProvider extends WeatherProvider { this.mockData = mockData; } - public async getWateringData( coordinates: GeoCoordinates ): Promise< WateringData > { + public async getWateringData( coordinates: GeoCoordinates ): Promise< ZimmermanWateringData > { const data = this.mockData.wateringData; if ( !data.weatherProvider ) { data.weatherProvider = "mock"; @@ -101,6 +101,6 @@ export class MockWeatherProvider extends WeatherProvider { } interface MockWeatherData { - wateringData?: WateringData, + wateringData?: ZimmermanWateringData, weatherData?: WeatherData } diff --git a/routes/weather.ts b/routes/weather.ts index 1401c77..fe313e5 100644 --- a/routes/weather.ts +++ b/routes/weather.ts @@ -5,13 +5,14 @@ import * as SunCalc from "suncalc"; import * as moment from "moment-timezone"; import * as geoTZ from "geo-tz"; -import { GeoCoordinates, TimeData, WateringData, WeatherData } from "../types"; +import { BaseWateringData, GeoCoordinates, PWS, TimeData, WeatherData } from "../types"; import { WeatherProvider } from "./weatherProviders/WeatherProvider"; import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./adjustmentMethods/AdjustmentMethod"; import ManualAdjustmentMethod from "./adjustmentMethods/ManualAdjustmentMethod"; import ZimmermanAdjustmentMethod from "./adjustmentMethods/ZimmermanAdjustmentMethod"; import RainDelayAdjustmentMethod from "./adjustmentMethods/RainDelayAdjustmentMethod"; -const weatherProvider: WeatherProvider = new ( require("./weatherProviders/" + ( process.env.WEATHER_PROVIDER || "OWM" ) ).default )(); +const WEATHER_PROVIDER: WeatherProvider = new ( require("./weatherProviders/" + ( process.env.WEATHER_PROVIDER || "OWM" ) ).default )(); +const PWS_WEATHER_PROVIDER: WeatherProvider = new ( require("./weatherProviders/" + ( process.env.PWS_WEATHER_PROVIDER || "WUnderground" ) ).default )(); // Define regex filters to match against location const filters = { @@ -42,7 +43,7 @@ async function resolveCoordinates( location: string ): Promise< GeoCoordinates > } if ( filters.pws.test( location ) ) { - throw "Weather Underground is discontinued"; + throw "PWS ID must be specified in the pws parameter."; } else if ( filters.gps.test( location ) ) { const split: string[] = location.split( "," ); return [ parseFloat( split[ 0 ] ), parseFloat( split[ 1 ] ) ]; @@ -121,7 +122,7 @@ function getTimeData( coordinates: GeoCoordinates ): TimeData { * @param weather Watering data to use to determine if any restrictions apply. * @return A boolean indicating if the watering level should be set to 0% due to a restriction. */ -function checkWeatherRestriction( adjustmentValue: number, weather: WateringData ): boolean { +function checkWeatherRestriction( adjustmentValue: number, weather: BaseWateringData ): boolean { const californiaRestriction = ( adjustmentValue >> 7 ) & 1; @@ -154,7 +155,7 @@ export const getWeatherData = async function( req: express.Request, res: express const timeData: TimeData = getTimeData( coordinates ); let weatherData: WeatherData; try { - weatherData = await weatherProvider.getWeatherData( coordinates ); + weatherData = await WEATHER_PROVIDER.getWeatherData( coordinates ); } catch ( err ) { res.send( "Error: " + err ); return; @@ -181,6 +182,7 @@ export const getWateringData = async function( req: express.Request, res: expres location: string | GeoCoordinates = getParameter(req.query.loc), outputFormat: string = getParameter(req.query.format), remoteAddress: string = getParameter(req.headers[ "x-forwarded-for" ]) || req.connection.remoteAddress, + pwsString: string = getParameter( req.query.pws ), adjustmentOptions: AdjustmentOptions; // X-Forwarded-For header may contain more than one IP address and therefore @@ -211,22 +213,24 @@ export const getWateringData = async function( req: express.Request, res: expres return; } - // Continue with the weather request let timeData: TimeData = getTimeData( coordinates ); - let wateringData: WateringData; - if ( adjustmentMethod !== ManualAdjustmentMethod || checkRestrictions ) { + + // Parse the PWS information. + let pws: PWS | undefined = undefined; + if ( pwsString ) { try { - wateringData = await weatherProvider.getWateringData( coordinates ); + pws = parsePWS( pwsString ); } catch ( err ) { - res.send( "Error: " + err ); + res.send( `Error: ${ err }` ); return; } } + const weatherProvider = pws ? PWS_WEATHER_PROVIDER : WEATHER_PROVIDER; let adjustmentMethodResponse: AdjustmentMethodResponse; try { adjustmentMethodResponse = await adjustmentMethod.calculateWateringScale( - adjustmentOptions, wateringData, coordinates, weatherProvider + adjustmentOptions, coordinates, weatherProvider, pws ); } catch ( err ) { if ( typeof err != "string" ) { @@ -244,7 +248,19 @@ export const getWateringData = async function( req: express.Request, res: expres } let scale = adjustmentMethodResponse.scale; - if ( wateringData ) { + + if ( checkRestrictions ) { + let wateringData: BaseWateringData = adjustmentMethodResponse.wateringData; + // Fetch the watering data if the AdjustmentMethod didn't fetch it and restrictions are being checked. + if ( checkRestrictions && !wateringData ) { + try { + wateringData = await weatherProvider.getWateringData( coordinates ); + } catch ( err ) { + res.send( "Error: " + err ); + return; + } + } + // Check for any user-set restrictions and change the scale to 0 if the criteria is met if ( checkWeatherRestriction( req.params[ 0 ], wateringData ) ) { scale = 0; @@ -429,3 +445,21 @@ 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 ] + }; +} diff --git a/routes/weatherProviders/DarkSky.ts b/routes/weatherProviders/DarkSky.ts index 843678c..a5c3746 100644 --- a/routes/weatherProviders/DarkSky.ts +++ b/routes/weatherProviders/DarkSky.ts @@ -1,6 +1,6 @@ import * as moment from "moment-timezone"; -import { GeoCoordinates, WateringData, WeatherData } from "../../types"; +import { GeoCoordinates, WeatherData, ZimmermanWateringData } from "../../types"; import { httpJSONRequest } from "../weather"; import { WeatherProvider } from "./WeatherProvider"; @@ -16,7 +16,7 @@ export default class DarkSkyWeatherProvider extends WeatherProvider { } } - public async getWateringData( coordinates: GeoCoordinates ): Promise< WateringData > { + public async getWateringData( coordinates: GeoCoordinates ): Promise< ZimmermanWateringData > { // The Unix timestamp of 24 hours ago. const yesterdayTimestamp: number = moment().subtract( 1, "day" ).unix(); const todayTimestamp: number = moment().unix(); diff --git a/routes/weatherProviders/OWM.ts b/routes/weatherProviders/OWM.ts index 0d32d8f..939cfa0 100644 --- a/routes/weatherProviders/OWM.ts +++ b/routes/weatherProviders/OWM.ts @@ -1,4 +1,4 @@ -import { GeoCoordinates, WateringData, WeatherData } from "../../types"; +import { GeoCoordinates, WeatherData, ZimmermanWateringData } from "../../types"; import { httpJSONRequest } from "../weather"; import { WeatherProvider } from "./WeatherProvider"; @@ -14,7 +14,7 @@ export default class OWMWeatherProvider extends WeatherProvider { } } - public async getWateringData( coordinates: GeoCoordinates ): Promise< WateringData > { + 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 diff --git a/routes/weatherProviders/WUnderground.ts b/routes/weatherProviders/WUnderground.ts new file mode 100644 index 0000000..854af36 --- /dev/null +++ b/routes/weatherProviders/WUnderground.ts @@ -0,0 +1,44 @@ +import { GeoCoordinates, PWS, WeatherData, ZimmermanWateringData } from "../../types"; +import { WeatherProvider } from "./WeatherProvider"; +import { httpJSONRequest } from "../weather"; + +export default class WUnderground extends WeatherProvider { + + async getWateringData( coordinates: GeoCoordinates, pws?: PWS ): Promise< ZimmermanWateringData > { + if ( !pws ) { + throw "WUnderground WeatherProvider requires a PWS to be specified."; + } + + const url = `https://api.weather.com/v2/pws/observations/hourly/7day?stationId=${ pws.id }&format=json&units=e&apiKey=${ pws.apiKey }`; + let data; + try { + data = await httpJSONRequest( url ); + } catch ( err ) { + console.error( "Error retrieving weather information from WUnderground:", err ); + throw "An error occurred while retrieving weather information from WUnderground." + } + + // Take the 24 most recent observations. + const samples = data.observations.slice( -24 ); + + // Fail if not enough data is available. + if ( samples.length !== 24 ) { + throw "Insufficient data was returned by WUnderground."; + } + + const totals = { temp: 0, humidity: 0, precip: 0 }; + for ( const sample of samples ) { + totals.temp += sample.imperial.tempAvg; + totals.humidity += sample.humidityAvg; + totals.precip += sample.imperial.precipRate; + } + + return { + weatherProvider: "WUnderground", + temp: totals.temp / samples.length, + humidity: totals.humidity / samples.length, + precip: totals.precip, + raining: samples[ samples.length - 1 ].imperial.precipRate > 0 + } + } +} diff --git a/routes/weatherProviders/WeatherProvider.ts b/routes/weatherProviders/WeatherProvider.ts index 4826df2..895bbab 100644 --- a/routes/weatherProviders/WeatherProvider.ts +++ b/routes/weatherProviders/WeatherProvider.ts @@ -1,14 +1,16 @@ -import { GeoCoordinates, WateringData, WeatherData } from "../../types"; +import { GeoCoordinates, PWS, WeatherData, ZimmermanWateringData } from "../../types"; export class WeatherProvider { /** - * Retrieves weather data necessary for watering level calculations. + * Retrieves weather data necessary for Zimmerman watering level calculations. * @param coordinates The coordinates to retrieve the watering data for. - * @return A Promise that will be resolved with the WateringData if it is successfully retrieved, - * or rejected with an error message if an error occurs while retrieving the WateringData or the WeatherProvider + * @param pws The PWS to retrieve the weather from, or undefined if a PWS should not be used. If the implementation + * of this method does not have PWS support, this parameter may be ignored and coordinates may be used instead. + * @return A Promise that will be resolved with the ZimmermanWateringData if it is successfully retrieved, + * or rejected with an error message if an error occurs while retrieving the ZimmermanWateringData or the WeatherProvider * does not support this method. */ - getWateringData( coordinates : GeoCoordinates ): Promise< WateringData > { + getWateringData( coordinates: GeoCoordinates, pws?: PWS ): Promise< ZimmermanWateringData > { throw "Selected WeatherProvider does not support getWateringData"; } diff --git a/routes/weatherProviders/local.ts b/routes/weatherProviders/local.ts index 71e4798..1a12eca 100644 --- a/routes/weatherProviders/local.ts +++ b/routes/weatherProviders/local.ts @@ -1,6 +1,6 @@ import * as express from "express"; import { CronJob } from "cron"; -import { GeoCoordinates, WateringData } from "../../types"; +import { GeoCoordinates, ZimmermanWateringData } from "../../types"; import { WeatherProvider } from "./WeatherProvider"; const count = { temp: 0, humidity: 0 }; @@ -46,9 +46,9 @@ export const captureWUStream = function( req: express.Request, res: express.Resp export default class LocalWeatherProvider extends WeatherProvider { - public async getWateringData( coordinates: GeoCoordinates ): Promise< WateringData > { - const result: WateringData = { - ...yesterday as WateringData, + public async getWateringData( coordinates: GeoCoordinates ): Promise< ZimmermanWateringData > { + const result: ZimmermanWateringData = { + ...yesterday as ZimmermanWateringData, // Use today's weather if we dont have information for yesterday yet (i.e. on startup) ...today, // PWS report "buckets" so consider it still raining if last bucket was less than an hour ago diff --git a/types.ts b/types.ts index 8dbe9f5..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,22 +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 type WeatherProviderId = "OWM" | "DarkSky" | "local" | "mock"; +export type WeatherProviderId = "OWM" | "DarkSky" | "local" | "mock" | "WUnderground";