From 95dadf601d4210c37de8c4e10dc92cb9cfcfd978 Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Thu, 27 Jun 2019 22:58:33 -0400 Subject: [PATCH 1/4] Document that AdjustmentOptions are non-nullable --- routes/adjustmentMethods/AdjustmentMethod.ts | 5 ++-- .../RainDelayAdjustmentMethod.ts | 2 +- .../ZimmermanAdjustmentMethod.ts | 26 ++++++++----------- 3 files changed, 14 insertions(+), 19 deletions(-) diff --git a/routes/adjustmentMethods/AdjustmentMethod.ts b/routes/adjustmentMethods/AdjustmentMethod.ts index 374f260..66c1c3a 100644 --- a/routes/adjustmentMethods/AdjustmentMethod.ts +++ b/routes/adjustmentMethods/AdjustmentMethod.ts @@ -5,9 +5,8 @@ import { WeatherProvider } from "../weatherProviders/WeatherProvider"; export interface AdjustmentMethod { /** * Calculates the percentage that should be used to scale watering time. - * @param adjustmentOptions The user-specified options for the calculation, or undefined/null if no custom values - * are to be used. No checks will be made to ensure the AdjustmentOptions are the correct type that the function - * is expecting or to ensure that any of its fields are valid. + * @param adjustmentOptions The user-specified options for the calculation. No checks will be made to ensure the + * AdjustmentOptions are the correct type that the function is expecting or to ensure that any of its fields are valid. * @param wateringData The basic weather information of the watering site. This may be undefined if an error occurred * while retrieving the data. * @param coordinates The coordinates of the watering site. diff --git a/routes/adjustmentMethods/RainDelayAdjustmentMethod.ts b/routes/adjustmentMethods/RainDelayAdjustmentMethod.ts index 56b1406..4b6a0e0 100644 --- a/routes/adjustmentMethods/RainDelayAdjustmentMethod.ts +++ b/routes/adjustmentMethods/RainDelayAdjustmentMethod.ts @@ -7,7 +7,7 @@ import { WateringData } from "../../types"; */ async function calculateRainDelayWateringScale( adjustmentOptions: RainDelayAdjustmentOptions, wateringData: WateringData | undefined ): Promise< AdjustmentMethodResponse > { const raining = wateringData && wateringData.raining; - const d = adjustmentOptions && adjustmentOptions.hasOwnProperty( "d" ) ? adjustmentOptions.d : 24; + const d = adjustmentOptions.hasOwnProperty( "d" ) ? adjustmentOptions.d : 24; return { scale: undefined, rawData: { raining: raining ? 1 : 0 }, diff --git a/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts b/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts index e5268c0..c5ed019 100644 --- a/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts +++ b/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts @@ -40,29 +40,25 @@ async function calculateZimmermanWateringScale( adjustmentOptions: ZimmermanAdju let humidityBase = 30, tempBase = 70, precipBase = 0; // Get baseline conditions for 100% water level, if provided - if ( adjustmentOptions ) { - humidityBase = adjustmentOptions.hasOwnProperty( "bh" ) ? adjustmentOptions.bh : humidityBase; - tempBase = adjustmentOptions.hasOwnProperty( "bt" ) ? adjustmentOptions.bt : tempBase; - precipBase = adjustmentOptions.hasOwnProperty( "br" ) ? adjustmentOptions.br : precipBase; - } + humidityBase = adjustmentOptions.hasOwnProperty( "bh" ) ? adjustmentOptions.bh : humidityBase; + tempBase = adjustmentOptions.hasOwnProperty( "bt" ) ? adjustmentOptions.bt : tempBase; + precipBase = adjustmentOptions.hasOwnProperty( "br" ) ? adjustmentOptions.br : precipBase; let humidityFactor = ( humidityBase - wateringData.humidity ), tempFactor = ( ( wateringData.temp - tempBase ) * 4 ), precipFactor = ( ( precipBase - wateringData.precip ) * 200 ); // Apply adjustment options, if provided, by multiplying the percentage against the factor - if ( adjustmentOptions ) { - if ( adjustmentOptions.hasOwnProperty( "h" ) ) { - humidityFactor = humidityFactor * ( adjustmentOptions.h / 100 ); - } + if ( adjustmentOptions.hasOwnProperty( "h" ) ) { + humidityFactor = humidityFactor * ( adjustmentOptions.h / 100 ); + } - if ( adjustmentOptions.hasOwnProperty( "t" ) ) { - tempFactor = tempFactor * ( adjustmentOptions.t / 100 ); - } + if ( adjustmentOptions.hasOwnProperty( "t" ) ) { + tempFactor = tempFactor * ( adjustmentOptions.t / 100 ); + } - if ( adjustmentOptions.hasOwnProperty( "r" ) ) { - precipFactor = precipFactor * ( adjustmentOptions.r / 100 ); - } + if ( adjustmentOptions.hasOwnProperty( "r" ) ) { + precipFactor = precipFactor * ( adjustmentOptions.r / 100 ); } return { From dc171ebe6849942caeffa78f0b63859045474446 Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Fri, 28 Jun 2019 00:33:00 -0400 Subject: [PATCH 2/4] Refactor watering scale logic flow --- routes/adjustmentMethods/AdjustmentMethod.ts | 9 +++--- .../ManualAdjustmentMethod.ts | 3 +- .../RainDelayAdjustmentMethod.ts | 9 ++++-- .../ZimmermanAdjustmentMethod.ts | 15 ++++++---- routes/weather.spec.ts | 6 ++-- routes/weather.ts | 30 ++++++++++--------- routes/weatherProviders/DarkSky.ts | 13 ++++++-- routes/weatherProviders/OWM.ts | 4 +-- routes/weatherProviders/WeatherProvider.ts | 10 +++---- routes/weatherProviders/local.ts | 8 ++--- types.ts | 13 ++++---- 11 files changed, 70 insertions(+), 50 deletions(-) diff --git a/routes/adjustmentMethods/AdjustmentMethod.ts b/routes/adjustmentMethods/AdjustmentMethod.ts index 66c1c3a..209c0e7 100644 --- a/routes/adjustmentMethods/AdjustmentMethod.ts +++ b/routes/adjustmentMethods/AdjustmentMethod.ts @@ -1,4 +1,4 @@ -import { GeoCoordinates, WateringData } from "../../types"; +import { BaseWateringData, GeoCoordinates } from "../../types"; import { WeatherProvider } from "../weatherProviders/WeatherProvider"; @@ -7,10 +7,8 @@ export interface AdjustmentMethod { * Calculates the percentage that should be used to scale watering time. * @param adjustmentOptions The user-specified options for the calculation. No checks will be made to ensure the * AdjustmentOptions are the correct type that the function is expecting or to ensure that any of its fields are valid. - * @param wateringData The basic weather information of the watering site. This may be undefined if an error occurred - * while retrieving the data. * @param coordinates The coordinates of the watering site. - * @param weatherProvider The WeatherProvider that should be used if the adjustment method needs to obtain any more + * @param weatherProvider The WeatherProvider that should be used if the adjustment method needs to obtain any * weather data. * @return A Promise that will be resolved with the result of the calculation, or rejected with an error message if * the watering scale cannot be calculated. @@ -18,7 +16,6 @@ export interface AdjustmentMethod { */ calculateWateringScale( adjustmentOptions: AdjustmentOptions, - wateringData: WateringData | undefined, coordinates: GeoCoordinates, weatherProvider: WeatherProvider ): Promise< AdjustmentMethodResponse >; @@ -52,6 +49,8 @@ export interface AdjustmentMethodResponse { * user-configured watering scale instead of using the one returned by the AdjustmentMethod. */ errorMessage?: string; + /** The data that was used to calculate the watering scale, or undefined if no data was used. */ + wateringData: BaseWateringData; } export interface AdjustmentOptions {} diff --git a/routes/adjustmentMethods/ManualAdjustmentMethod.ts b/routes/adjustmentMethods/ManualAdjustmentMethod.ts index d584f83..7c9e8f7 100644 --- a/routes/adjustmentMethods/ManualAdjustmentMethod.ts +++ b/routes/adjustmentMethods/ManualAdjustmentMethod.ts @@ -6,7 +6,8 @@ import { AdjustmentMethod, AdjustmentMethodResponse } from "./AdjustmentMethod"; */ async function calculateManualWateringScale( ): Promise< AdjustmentMethodResponse > { return { - scale: undefined + scale: undefined, + wateringData: undefined } } diff --git a/routes/adjustmentMethods/RainDelayAdjustmentMethod.ts b/routes/adjustmentMethods/RainDelayAdjustmentMethod.ts index 4b6a0e0..bfa1348 100644 --- a/routes/adjustmentMethods/RainDelayAdjustmentMethod.ts +++ b/routes/adjustmentMethods/RainDelayAdjustmentMethod.ts @@ -1,17 +1,20 @@ import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./AdjustmentMethod"; -import { WateringData } from "../../types"; +import { GeoCoordinates, ZimmermanWateringData } from "../../types"; +import { WeatherProvider } from "../weatherProviders/WeatherProvider"; /** * Only delays watering if it is currently raining and does not adjust the watering scale. */ -async function calculateRainDelayWateringScale( adjustmentOptions: RainDelayAdjustmentOptions, wateringData: WateringData | undefined ): Promise< AdjustmentMethodResponse > { +async function calculateRainDelayWateringScale( adjustmentOptions: RainDelayAdjustmentOptions, coordinates: GeoCoordinates, weatherProvider: WeatherProvider ): Promise< AdjustmentMethodResponse > { + const wateringData: ZimmermanWateringData = await weatherProvider.getWateringData( coordinates ); const raining = wateringData && wateringData.raining; const d = adjustmentOptions.hasOwnProperty( "d" ) ? adjustmentOptions.d : 24; return { scale: undefined, rawData: { raining: raining ? 1 : 0 }, - rainDelay: raining ? d : undefined + rainDelay: raining ? d : undefined, + wateringData: wateringData } } diff --git a/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts b/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts index c5ed019..ec4a8ae 100644 --- a/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts +++ b/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts @@ -1,13 +1,15 @@ import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./AdjustmentMethod"; -import { WateringData } from "../../types"; +import { GeoCoordinates, ZimmermanWateringData } from "../../types"; import { validateValues } from "../weather"; +import { WeatherProvider } from "../weatherProviders/WeatherProvider"; /** * Calculates how much watering should be scaled based on weather and adjustment options using the Zimmerman method. * (https://github.com/rszimm/sprinklers_pi/wiki/Weather-adjustments#formula-for-setting-the-scale) */ -async function calculateZimmermanWateringScale( adjustmentOptions: ZimmermanAdjustmentOptions, wateringData: WateringData | undefined ): Promise< AdjustmentMethodResponse > { +async function calculateZimmermanWateringScale( adjustmentOptions: ZimmermanAdjustmentOptions, coordinates: GeoCoordinates, weatherProvider: WeatherProvider ): Promise< AdjustmentMethodResponse > { + const wateringData: ZimmermanWateringData = await weatherProvider.getWateringData( coordinates ); // Temporarily disabled since OWM forecast data is checking if rain is forecasted for 3 hours in the future. /* @@ -15,7 +17,8 @@ async function calculateZimmermanWateringScale( adjustmentOptions: ZimmermanAdju if ( wateringData && wateringData.raining ) { return { scale: 0, - rawData: { raining: 1 } + rawData: { raining: 1 }, + wateringData: wateringData } } */ @@ -33,7 +36,8 @@ async function calculateZimmermanWateringScale( adjustmentOptions: ZimmermanAdju return { scale: 100, rawData: rawData, - errorMessage: "Necessary field(s) were missing from WateringData." + errorMessage: "Necessary field(s) were missing from ZimmermanWateringData.", + wateringData: wateringData }; } @@ -64,7 +68,8 @@ async function calculateZimmermanWateringScale( adjustmentOptions: ZimmermanAdju return { // Apply all of the weather modifying factors and clamp the result between 0 and 200%. scale: Math.floor( Math.min( Math.max( 0, 100 + humidityFactor + tempFactor + precipFactor ), 200 ) ), - rawData: rawData + rawData: rawData, + wateringData: wateringData } } diff --git a/routes/weather.spec.ts b/routes/weather.spec.ts index 36e92e5..c566a70 100644 --- a/routes/weather.spec.ts +++ b/routes/weather.spec.ts @@ -5,7 +5,7 @@ import * as MockExpressResponse from 'mock-express-response'; import * as MockDate from 'mockdate'; import { getWateringData } from './weather'; -import { GeoCoordinates, WateringData, WeatherData } from "../types"; +import { GeoCoordinates, ZimmermanWateringData, WeatherData } from "../types"; import { WeatherProvider } from "./weatherProviders/WeatherProvider"; const expected = require( '../test/expected.json' ); @@ -77,7 +77,7 @@ export class MockWeatherProvider extends WeatherProvider { this.mockData = mockData; } - public async getWateringData( coordinates: GeoCoordinates ): Promise< WateringData > { + public async getWateringData( coordinates: GeoCoordinates ): Promise< ZimmermanWateringData > { const data = this.mockData.wateringData; if ( !data.weatherProvider ) { data.weatherProvider = "mock"; @@ -97,6 +97,6 @@ export class MockWeatherProvider extends WeatherProvider { } interface MockWeatherData { - wateringData?: WateringData, + wateringData?: ZimmermanWateringData, weatherData?: WeatherData } diff --git a/routes/weather.ts b/routes/weather.ts index 1401c77..544fe6f 100644 --- a/routes/weather.ts +++ b/routes/weather.ts @@ -5,7 +5,7 @@ import * as SunCalc from "suncalc"; import * as moment from "moment-timezone"; import * as geoTZ from "geo-tz"; -import { GeoCoordinates, TimeData, WateringData, WeatherData } from "../types"; +import { GeoCoordinates, TimeData, WeatherData, BaseWateringData } from "../types"; import { WeatherProvider } from "./weatherProviders/WeatherProvider"; import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./adjustmentMethods/AdjustmentMethod"; import ManualAdjustmentMethod from "./adjustmentMethods/ManualAdjustmentMethod"; @@ -121,7 +121,7 @@ function getTimeData( coordinates: GeoCoordinates ): TimeData { * @param weather Watering data to use to determine if any restrictions apply. * @return A boolean indicating if the watering level should be set to 0% due to a restriction. */ -function checkWeatherRestriction( adjustmentValue: number, weather: WateringData ): boolean { +function checkWeatherRestriction( adjustmentValue: number, weather: BaseWateringData ): boolean { const californiaRestriction = ( adjustmentValue >> 7 ) & 1; @@ -211,22 +211,12 @@ export const getWateringData = async function( req: express.Request, res: expres return; } - // Continue with the weather request let timeData: TimeData = getTimeData( coordinates ); - let wateringData: WateringData; - if ( adjustmentMethod !== ManualAdjustmentMethod || checkRestrictions ) { - try { - wateringData = await weatherProvider.getWateringData( coordinates ); - } catch ( err ) { - res.send( "Error: " + err ); - return; - } - } let adjustmentMethodResponse: AdjustmentMethodResponse; try { adjustmentMethodResponse = await adjustmentMethod.calculateWateringScale( - adjustmentOptions, wateringData, coordinates, weatherProvider + adjustmentOptions, coordinates, weatherProvider ); } catch ( err ) { if ( typeof err != "string" ) { @@ -244,7 +234,19 @@ export const getWateringData = async function( req: express.Request, res: expres } let scale = adjustmentMethodResponse.scale; - if ( wateringData ) { + + if ( checkRestrictions ) { + let wateringData: BaseWateringData = adjustmentMethodResponse.wateringData; + // Fetch the watering data if the AdjustmentMethod didn't fetch it and restrictions are being checked. + if ( checkRestrictions && !wateringData ) { + try { + wateringData = await weatherProvider.getWateringData( coordinates ); + } catch ( err ) { + res.send( "Error: " + err ); + return; + } + } + // Check for any user-set restrictions and change the scale to 0 if the criteria is met if ( checkWeatherRestriction( req.params[ 0 ], wateringData ) ) { scale = 0; diff --git a/routes/weatherProviders/DarkSky.ts b/routes/weatherProviders/DarkSky.ts index 7098f39..4ff6926 100644 --- a/routes/weatherProviders/DarkSky.ts +++ b/routes/weatherProviders/DarkSky.ts @@ -1,12 +1,12 @@ import * as moment from "moment-timezone"; -import { GeoCoordinates, WateringData, WeatherData } from "../../types"; +import { GeoCoordinates, WeatherData, ZimmermanWateringData } from "../../types"; import { httpJSONRequest } from "../weather"; import { WeatherProvider } from "./WeatherProvider"; export default class DarkSkyWeatherProvider extends WeatherProvider { - public async getWateringData( coordinates: GeoCoordinates ): Promise< WateringData > { + public async getWateringData( coordinates: GeoCoordinates ): Promise< ZimmermanWateringData > { // The Unix timestamp of 24 hours ago. const yesterdayTimestamp: number = moment().subtract( 1, "day" ).unix(); const todayTimestamp: number = moment().unix(); @@ -47,9 +47,16 @@ export default class DarkSkyWeatherProvider extends WeatherProvider { const totals = { temp: 0, humidity: 0, precip: 0 }; for ( const sample of samples ) { + /* + * If temperature or humidity is missing from a sample, the total will become NaN. This is intended since + * calculateWateringScale will treat NaN as a missing value and temperature/humidity can't be accurately + * calculated when data is missing from some samples (since they follow diurnal cycles and will be + * significantly skewed if data is missing for several consecutive hours). + */ totals.temp += sample.temperature; totals.humidity += sample.humidity; - totals.precip += sample.precipIntensity + // This field may be missing from the response if it is snowing. + totals.precip += sample.precipIntensity || 0; } return { diff --git a/routes/weatherProviders/OWM.ts b/routes/weatherProviders/OWM.ts index fdb4023..b35b4b8 100644 --- a/routes/weatherProviders/OWM.ts +++ b/routes/weatherProviders/OWM.ts @@ -1,10 +1,10 @@ -import { GeoCoordinates, WateringData, WeatherData } from "../../types"; +import { GeoCoordinates, WeatherData, ZimmermanWateringData } from "../../types"; import { httpJSONRequest } from "../weather"; import { WeatherProvider } from "./WeatherProvider"; export default class OWMWeatherProvider extends WeatherProvider { - public async getWateringData( coordinates: GeoCoordinates ): Promise< WateringData > { + public async getWateringData( coordinates: GeoCoordinates ): Promise< ZimmermanWateringData > { const OWM_API_KEY = process.env.OWM_API_KEY, forecastUrl = "http://api.openweathermap.org/data/2.5/forecast?appid=" + OWM_API_KEY + "&units=imperial&lat=" + coordinates[ 0 ] + "&lon=" + coordinates[ 1 ]; diff --git a/routes/weatherProviders/WeatherProvider.ts b/routes/weatherProviders/WeatherProvider.ts index 4826df2..79782f3 100644 --- a/routes/weatherProviders/WeatherProvider.ts +++ b/routes/weatherProviders/WeatherProvider.ts @@ -1,14 +1,14 @@ -import { GeoCoordinates, WateringData, WeatherData } from "../../types"; +import { GeoCoordinates, ZimmermanWateringData, WeatherData } from "../../types"; export class WeatherProvider { /** - * Retrieves weather data necessary for watering level calculations. + * Retrieves weather data necessary for Zimmerman watering level calculations. * @param coordinates The coordinates to retrieve the watering data for. - * @return A Promise that will be resolved with the WateringData if it is successfully retrieved, - * or rejected with an error message if an error occurs while retrieving the WateringData or the WeatherProvider + * @return A Promise that will be resolved with the ZimmermanWateringData if it is successfully retrieved, + * or rejected with an error message if an error occurs while retrieving the ZimmermanWateringData or the WeatherProvider * does not support this method. */ - getWateringData( coordinates : GeoCoordinates ): Promise< WateringData > { + getWateringData( coordinates : GeoCoordinates ): Promise< ZimmermanWateringData > { throw "Selected WeatherProvider does not support getWateringData"; } diff --git a/routes/weatherProviders/local.ts b/routes/weatherProviders/local.ts index 71e4798..1a12eca 100644 --- a/routes/weatherProviders/local.ts +++ b/routes/weatherProviders/local.ts @@ -1,6 +1,6 @@ import * as express from "express"; import { CronJob } from "cron"; -import { GeoCoordinates, WateringData } from "../../types"; +import { GeoCoordinates, ZimmermanWateringData } from "../../types"; import { WeatherProvider } from "./WeatherProvider"; const count = { temp: 0, humidity: 0 }; @@ -46,9 +46,9 @@ export const captureWUStream = function( req: express.Request, res: express.Resp export default class LocalWeatherProvider extends WeatherProvider { - public async getWateringData( coordinates: GeoCoordinates ): Promise< WateringData > { - const result: WateringData = { - ...yesterday as WateringData, + public async getWateringData( coordinates: GeoCoordinates ): Promise< ZimmermanWateringData > { + const result: ZimmermanWateringData = { + ...yesterday as ZimmermanWateringData, // Use today's weather if we dont have information for yesterday yet (i.e. on startup) ...today, // PWS report "buckets" so consider it still raining if last bucket was less than an hour ago diff --git a/types.ts b/types.ts index 8dbe9f5..cf66b0f 100644 --- a/types.ts +++ b/types.ts @@ -50,20 +50,23 @@ export interface WeatherDataForecast { description: string; } +export interface BaseWateringData { + /** The WeatherProvider that generated this data. */ + weatherProvider: WeatherProviderId; + /** The total precipitation over the window (in inches). */ + precip: number; +} + /** * Data from a 24 hour window that is used to calculate how watering levels should be scaled. This should ideally use * historic data from the past day, but may also use forecasted data for the next day if historical data is not * available. */ -export interface WateringData { - /** The WeatherProvider that generated this data. */ - weatherProvider: WeatherProviderId; +export interface ZimmermanWateringData extends BaseWateringData { /** The average temperature over the window (in Fahrenheit). */ temp: number; /** The average humidity over the window (as a percentage). */ humidity: number; - /** The total precipitation over the window (in inches). */ - precip: number; /** A boolean indicating if it is raining at the time that this data was retrieved. */ raining: boolean; } From 5f35b0410c42c1d980f18c4a91f64de1eebc8c40 Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Fri, 28 Jun 2019 16:02:35 -0400 Subject: [PATCH 3/4] Add WUnderground WeatherProvider with Zimmerman PWS support --- routes/adjustmentMethods/AdjustmentMethod.ts | 7 ++- .../ZimmermanAdjustmentMethod.ts | 11 +++-- routes/weather.ts | 39 +++++++++++++--- routes/weatherProviders/WUnderground.ts | 44 +++++++++++++++++++ routes/weatherProviders/WeatherProvider.ts | 6 ++- types.ts | 5 ++- 6 files changed, 99 insertions(+), 13 deletions(-) create mode 100644 routes/weatherProviders/WUnderground.ts diff --git a/routes/adjustmentMethods/AdjustmentMethod.ts b/routes/adjustmentMethods/AdjustmentMethod.ts index 209c0e7..3416a2a 100644 --- a/routes/adjustmentMethods/AdjustmentMethod.ts +++ b/routes/adjustmentMethods/AdjustmentMethod.ts @@ -1,4 +1,4 @@ -import { BaseWateringData, GeoCoordinates } from "../../types"; +import { BaseWateringData, GeoCoordinates, PWS } from "../../types"; import { WeatherProvider } from "../weatherProviders/WeatherProvider"; @@ -10,6 +10,8 @@ export interface AdjustmentMethod { * @param coordinates The coordinates of the watering site. * @param weatherProvider The WeatherProvider that should be used if the adjustment method needs to obtain any * weather data. + * @param pws The PWS to retrieve weather data from, or undefined if a PWS should not be used. If the implementation + * of this method does not have PWS support, this parameter may be ignored and coordinates may be used instead. * @return A Promise that will be resolved with the result of the calculation, or rejected with an error message if * the watering scale cannot be calculated. * @throws An error message can be thrown if an error occurs while calculating the watering scale. @@ -17,7 +19,8 @@ export interface AdjustmentMethod { calculateWateringScale( adjustmentOptions: AdjustmentOptions, coordinates: GeoCoordinates, - weatherProvider: WeatherProvider + weatherProvider: WeatherProvider, + pws?: PWS ): Promise< AdjustmentMethodResponse >; } diff --git a/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts b/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts index ec4a8ae..e8fea63 100644 --- a/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts +++ b/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts @@ -1,5 +1,5 @@ import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./AdjustmentMethod"; -import { GeoCoordinates, ZimmermanWateringData } from "../../types"; +import { GeoCoordinates, PWS, ZimmermanWateringData } from "../../types"; import { validateValues } from "../weather"; import { WeatherProvider } from "../weatherProviders/WeatherProvider"; @@ -8,8 +8,13 @@ import { WeatherProvider } from "../weatherProviders/WeatherProvider"; * Calculates how much watering should be scaled based on weather and adjustment options using the Zimmerman method. * (https://github.com/rszimm/sprinklers_pi/wiki/Weather-adjustments#formula-for-setting-the-scale) */ -async function calculateZimmermanWateringScale( adjustmentOptions: ZimmermanAdjustmentOptions, coordinates: GeoCoordinates, weatherProvider: WeatherProvider ): Promise< AdjustmentMethodResponse > { - const wateringData: ZimmermanWateringData = await weatherProvider.getWateringData( coordinates ); +async function calculateZimmermanWateringScale( + adjustmentOptions: ZimmermanAdjustmentOptions, + coordinates: GeoCoordinates, + weatherProvider: WeatherProvider, + pws?: PWS +): Promise< AdjustmentMethodResponse > { + const wateringData: ZimmermanWateringData = await weatherProvider.getWateringData( coordinates, pws ); // Temporarily disabled since OWM forecast data is checking if rain is forecasted for 3 hours in the future. /* diff --git a/routes/weather.ts b/routes/weather.ts index 544fe6f..eeeb0ee 100644 --- a/routes/weather.ts +++ b/routes/weather.ts @@ -5,13 +5,14 @@ import * as SunCalc from "suncalc"; import * as moment from "moment-timezone"; import * as geoTZ from "geo-tz"; -import { GeoCoordinates, TimeData, WeatherData, BaseWateringData } from "../types"; +import { BaseWateringData, GeoCoordinates, PWS, TimeData, WeatherData } from "../types"; import { WeatherProvider } from "./weatherProviders/WeatherProvider"; import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./adjustmentMethods/AdjustmentMethod"; import ManualAdjustmentMethod from "./adjustmentMethods/ManualAdjustmentMethod"; import ZimmermanAdjustmentMethod from "./adjustmentMethods/ZimmermanAdjustmentMethod"; import RainDelayAdjustmentMethod from "./adjustmentMethods/RainDelayAdjustmentMethod"; -const weatherProvider: WeatherProvider = new ( require("./weatherProviders/" + ( process.env.WEATHER_PROVIDER || "OWM" ) ).default )(); +const WEATHER_PROVIDER: WeatherProvider = new ( require("./weatherProviders/" + ( process.env.WEATHER_PROVIDER || "OWM" ) ).default )(); +const PWS_WEATHER_PROVIDER: WeatherProvider = new ( require("./weatherProviders/" + ( process.env.PWS_WEATHER_PROVIDER || "WUnderground" ) ).default )(); // Define regex filters to match against location const filters = { @@ -42,7 +43,7 @@ async function resolveCoordinates( location: string ): Promise< GeoCoordinates > } if ( filters.pws.test( location ) ) { - throw "Weather Underground is discontinued"; + throw "PWS ID must be specified in the pws parameter."; } else if ( filters.gps.test( location ) ) { const split: string[] = location.split( "," ); return [ parseFloat( split[ 0 ] ), parseFloat( split[ 1 ] ) ]; @@ -154,7 +155,7 @@ export const getWeatherData = async function( req: express.Request, res: express const timeData: TimeData = getTimeData( coordinates ); let weatherData: WeatherData; try { - weatherData = await weatherProvider.getWeatherData( coordinates ); + weatherData = await WEATHER_PROVIDER.getWeatherData( coordinates ); } catch ( err ) { res.send( "Error: " + err ); return; @@ -181,6 +182,7 @@ export const getWateringData = async function( req: express.Request, res: expres location: string | GeoCoordinates = getParameter(req.query.loc), outputFormat: string = getParameter(req.query.format), remoteAddress: string = getParameter(req.headers[ "x-forwarded-for" ]) || req.connection.remoteAddress, + pwsString: string = getParameter( req.query.pws ), adjustmentOptions: AdjustmentOptions; // X-Forwarded-For header may contain more than one IP address and therefore @@ -213,10 +215,22 @@ export const getWateringData = async function( req: express.Request, res: expres let timeData: TimeData = getTimeData( coordinates ); + // Parse the PWS information. + let pws: PWS | undefined = undefined; + if ( pwsString ) { + try { + pws = parsePWS( pwsString ); + } catch ( err ) { + res.send( `Error: ${ err }` ); + return; + } + } + + const weatherProvider = pws ? PWS_WEATHER_PROVIDER : WEATHER_PROVIDER; let adjustmentMethodResponse: AdjustmentMethodResponse; try { adjustmentMethodResponse = await adjustmentMethod.calculateWateringScale( - adjustmentOptions, coordinates, weatherProvider + adjustmentOptions, coordinates, weatherProvider, pws ); } catch ( err ) { if ( typeof err != "string" ) { @@ -431,3 +445,18 @@ function getParameter( parameter: string | string[] ): string { // Return an empty string if the parameter is undefined. return parameter || ""; } + +/** + * Creates a PWS object from a string. + * @param pwsString Information about the PWS in the format "pws:API_KEY@PWS_ID". + * @return The PWS specified by the string. + * @throws Throws an error message if the string is in an invalid format and cannot be parsed. + */ +function parsePWS( pwsString: string): PWS { + const match = pwsString.match( /^pws:(?[a-f\d]{32})@(?[a-zA-Z\d]+)$/ ); + if ( !match ) { + throw "Invalid PWS format."; + } + + return match.groups as PWS; +} diff --git a/routes/weatherProviders/WUnderground.ts b/routes/weatherProviders/WUnderground.ts new file mode 100644 index 0000000..854af36 --- /dev/null +++ b/routes/weatherProviders/WUnderground.ts @@ -0,0 +1,44 @@ +import { GeoCoordinates, PWS, WeatherData, ZimmermanWateringData } from "../../types"; +import { WeatherProvider } from "./WeatherProvider"; +import { httpJSONRequest } from "../weather"; + +export default class WUnderground extends WeatherProvider { + + async getWateringData( coordinates: GeoCoordinates, pws?: PWS ): Promise< ZimmermanWateringData > { + if ( !pws ) { + throw "WUnderground WeatherProvider requires a PWS to be specified."; + } + + const url = `https://api.weather.com/v2/pws/observations/hourly/7day?stationId=${ pws.id }&format=json&units=e&apiKey=${ pws.apiKey }`; + let data; + try { + data = await httpJSONRequest( url ); + } catch ( err ) { + console.error( "Error retrieving weather information from WUnderground:", err ); + throw "An error occurred while retrieving weather information from WUnderground." + } + + // Take the 24 most recent observations. + const samples = data.observations.slice( -24 ); + + // Fail if not enough data is available. + if ( samples.length !== 24 ) { + throw "Insufficient data was returned by WUnderground."; + } + + const totals = { temp: 0, humidity: 0, precip: 0 }; + for ( const sample of samples ) { + totals.temp += sample.imperial.tempAvg; + totals.humidity += sample.humidityAvg; + totals.precip += sample.imperial.precipRate; + } + + return { + weatherProvider: "WUnderground", + temp: totals.temp / samples.length, + humidity: totals.humidity / samples.length, + precip: totals.precip, + raining: samples[ samples.length - 1 ].imperial.precipRate > 0 + } + } +} diff --git a/routes/weatherProviders/WeatherProvider.ts b/routes/weatherProviders/WeatherProvider.ts index 79782f3..895bbab 100644 --- a/routes/weatherProviders/WeatherProvider.ts +++ b/routes/weatherProviders/WeatherProvider.ts @@ -1,14 +1,16 @@ -import { GeoCoordinates, ZimmermanWateringData, WeatherData } from "../../types"; +import { GeoCoordinates, PWS, WeatherData, ZimmermanWateringData } from "../../types"; export class WeatherProvider { /** * Retrieves weather data necessary for Zimmerman watering level calculations. * @param coordinates The coordinates to retrieve the watering data for. + * @param pws The PWS to retrieve the weather from, or undefined if a PWS should not be used. If the implementation + * of this method does not have PWS support, this parameter may be ignored and coordinates may be used instead. * @return A Promise that will be resolved with the ZimmermanWateringData if it is successfully retrieved, * or rejected with an error message if an error occurs while retrieving the ZimmermanWateringData or the WeatherProvider * does not support this method. */ - getWateringData( coordinates : GeoCoordinates ): Promise< ZimmermanWateringData > { + getWateringData( coordinates: GeoCoordinates, pws?: PWS ): Promise< ZimmermanWateringData > { throw "Selected WeatherProvider does not support getWateringData"; } diff --git a/types.ts b/types.ts index cf66b0f..72a480d 100644 --- a/types.ts +++ b/types.ts @@ -1,6 +1,9 @@ /** Geographic coordinates. The 1st element is the latitude, and the 2nd element is the longitude. */ export type GeoCoordinates = [number, number]; +/** A PWS ID and API key. */ +export type PWS = { id: string, apiKey: string }; + export interface TimeData { /** The UTC offset, in minutes. This uses POSIX offsets, which are the negation of typically used offsets * (https://github.com/eggert/tz/blob/2017b/etcetera#L36-L42). @@ -71,4 +74,4 @@ export interface ZimmermanWateringData extends BaseWateringData { raining: boolean; } -export type WeatherProviderId = "OWM" | "DarkSky" | "local" | "mock"; +export type WeatherProviderId = "OWM" | "DarkSky" | "local" | "mock" | "WUnderground"; From aa26698481a9c43054dca54bd01c3ce3bb6cd047 Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Sat, 29 Jun 2019 13:10:22 -0400 Subject: [PATCH 4/4] Fix compatibility with earlier Node.js versions Named regex match groups are not supported until Node.js 10.0.0 --- routes/weather.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/routes/weather.ts b/routes/weather.ts index eeeb0ee..fe313e5 100644 --- a/routes/weather.ts +++ b/routes/weather.ts @@ -453,10 +453,13 @@ function getParameter( parameter: string | string[] ): string { * @throws Throws an error message if the string is in an invalid format and cannot be parsed. */ function parsePWS( pwsString: string): PWS { - const match = pwsString.match( /^pws:(?[a-f\d]{32})@(?[a-zA-Z\d]+)$/ ); + const match = pwsString.match( /^pws:([a-f\d]{32})@([a-zA-Z\d]+)$/ ); if ( !match ) { throw "Invalid PWS format."; } - return match.groups as PWS; + return { + apiKey: match[ 1 ], + id: match[ 2 ] + }; }