From 5f35b0410c42c1d980f18c4a91f64de1eebc8c40 Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Fri, 28 Jun 2019 16:02:35 -0400 Subject: [PATCH] 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";