Add WUnderground WeatherProvider with Zimmerman PWS support

This commit is contained in:
Matthew Oslan
2019-06-28 16:02:35 -04:00
parent dc171ebe68
commit 5f35b0410c
6 changed files with 99 additions and 13 deletions

View File

@@ -1,4 +1,4 @@
import { BaseWateringData, GeoCoordinates } from "../../types"; import { BaseWateringData, GeoCoordinates, PWS } from "../../types";
import { WeatherProvider } from "../weatherProviders/WeatherProvider"; import { WeatherProvider } from "../weatherProviders/WeatherProvider";
@@ -10,6 +10,8 @@ export interface AdjustmentMethod {
* @param coordinates The coordinates of the watering site. * @param coordinates The coordinates of the watering site.
* @param weatherProvider The WeatherProvider that should be used if the adjustment method needs to obtain any * @param weatherProvider The WeatherProvider that should be used if the adjustment method needs to obtain any
* weather data. * 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 * @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. * the watering scale cannot be calculated.
* @throws An error message can be thrown if an error occurs while calculating the watering scale. * @throws An error message can be thrown if an error occurs while calculating the watering scale.
@@ -17,7 +19,8 @@ export interface AdjustmentMethod {
calculateWateringScale( calculateWateringScale(
adjustmentOptions: AdjustmentOptions, adjustmentOptions: AdjustmentOptions,
coordinates: GeoCoordinates, coordinates: GeoCoordinates,
weatherProvider: WeatherProvider weatherProvider: WeatherProvider,
pws?: PWS
): Promise< AdjustmentMethodResponse >; ): Promise< AdjustmentMethodResponse >;
} }

View File

@@ -1,5 +1,5 @@
import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./AdjustmentMethod"; import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./AdjustmentMethod";
import { GeoCoordinates, ZimmermanWateringData } from "../../types"; import { GeoCoordinates, PWS, ZimmermanWateringData } from "../../types";
import { validateValues } from "../weather"; import { validateValues } from "../weather";
import { WeatherProvider } from "../weatherProviders/WeatherProvider"; 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. * 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) * (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 > { async function calculateZimmermanWateringScale(
const wateringData: ZimmermanWateringData = await weatherProvider.getWateringData( coordinates ); 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. // Temporarily disabled since OWM forecast data is checking if rain is forecasted for 3 hours in the future.
/* /*

View File

@@ -5,13 +5,14 @@ import * as SunCalc from "suncalc";
import * as moment from "moment-timezone"; import * as moment from "moment-timezone";
import * as geoTZ from "geo-tz"; 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 { WeatherProvider } from "./weatherProviders/WeatherProvider";
import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./adjustmentMethods/AdjustmentMethod"; import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./adjustmentMethods/AdjustmentMethod";
import ManualAdjustmentMethod from "./adjustmentMethods/ManualAdjustmentMethod"; import ManualAdjustmentMethod from "./adjustmentMethods/ManualAdjustmentMethod";
import ZimmermanAdjustmentMethod from "./adjustmentMethods/ZimmermanAdjustmentMethod"; import ZimmermanAdjustmentMethod from "./adjustmentMethods/ZimmermanAdjustmentMethod";
import RainDelayAdjustmentMethod from "./adjustmentMethods/RainDelayAdjustmentMethod"; 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 // Define regex filters to match against location
const filters = { const filters = {
@@ -42,7 +43,7 @@ async function resolveCoordinates( location: string ): Promise< GeoCoordinates >
} }
if ( filters.pws.test( location ) ) { 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 ) ) { } else if ( filters.gps.test( location ) ) {
const split: string[] = location.split( "," ); const split: string[] = location.split( "," );
return [ parseFloat( split[ 0 ] ), parseFloat( split[ 1 ] ) ]; 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 ); const timeData: TimeData = getTimeData( coordinates );
let weatherData: WeatherData; let weatherData: WeatherData;
try { try {
weatherData = await weatherProvider.getWeatherData( coordinates ); weatherData = await WEATHER_PROVIDER.getWeatherData( coordinates );
} catch ( err ) { } catch ( err ) {
res.send( "Error: " + err ); res.send( "Error: " + err );
return; return;
@@ -181,6 +182,7 @@ export const getWateringData = async function( req: express.Request, res: expres
location: string | GeoCoordinates = getParameter(req.query.loc), location: string | GeoCoordinates = getParameter(req.query.loc),
outputFormat: string = getParameter(req.query.format), outputFormat: string = getParameter(req.query.format),
remoteAddress: string = getParameter(req.headers[ "x-forwarded-for" ]) || req.connection.remoteAddress, remoteAddress: string = getParameter(req.headers[ "x-forwarded-for" ]) || req.connection.remoteAddress,
pwsString: string = getParameter( req.query.pws ),
adjustmentOptions: AdjustmentOptions; adjustmentOptions: AdjustmentOptions;
// X-Forwarded-For header may contain more than one IP address and therefore // 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 ); 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; let adjustmentMethodResponse: AdjustmentMethodResponse;
try { try {
adjustmentMethodResponse = await adjustmentMethod.calculateWateringScale( adjustmentMethodResponse = await adjustmentMethod.calculateWateringScale(
adjustmentOptions, coordinates, weatherProvider adjustmentOptions, coordinates, weatherProvider, pws
); );
} catch ( err ) { } catch ( err ) {
if ( typeof err != "string" ) { if ( typeof err != "string" ) {
@@ -431,3 +445,18 @@ function getParameter( parameter: string | string[] ): string {
// Return an empty string if the parameter is undefined. // Return an empty string if the parameter is undefined.
return parameter || ""; 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:(?<apiKey>[a-f\d]{32})@(?<id>[a-zA-Z\d]+)$/ );
if ( !match ) {
throw "Invalid PWS format.";
}
return match.groups as PWS;
}

View File

@@ -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
}
}
}

View File

@@ -1,14 +1,16 @@
import { GeoCoordinates, ZimmermanWateringData, WeatherData } from "../../types"; import { GeoCoordinates, PWS, WeatherData, ZimmermanWateringData } from "../../types";
export class WeatherProvider { export class WeatherProvider {
/** /**
* Retrieves weather data necessary for Zimmerman watering level calculations. * Retrieves weather data necessary for Zimmerman watering level calculations.
* @param coordinates The coordinates to retrieve the watering data for. * @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, * @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 * or rejected with an error message if an error occurs while retrieving the ZimmermanWateringData or the WeatherProvider
* does not support this method. * does not support this method.
*/ */
getWateringData( coordinates : GeoCoordinates ): Promise< ZimmermanWateringData > { getWateringData( coordinates: GeoCoordinates, pws?: PWS ): Promise< ZimmermanWateringData > {
throw "Selected WeatherProvider does not support getWateringData"; throw "Selected WeatherProvider does not support getWateringData";
} }

View File

@@ -1,6 +1,9 @@
/** Geographic coordinates. The 1st element is the latitude, and the 2nd element is the longitude. */ /** Geographic coordinates. The 1st element is the latitude, and the 2nd element is the longitude. */
export type GeoCoordinates = [number, number]; export type GeoCoordinates = [number, number];
/** A PWS ID and API key. */
export type PWS = { id: string, apiKey: string };
export interface TimeData { export interface TimeData {
/** The UTC offset, in minutes. This uses POSIX offsets, which are the negation of typically used offsets /** 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). * (https://github.com/eggert/tz/blob/2017b/etcetera#L36-L42).
@@ -71,4 +74,4 @@ export interface ZimmermanWateringData extends BaseWateringData {
raining: boolean; raining: boolean;
} }
export type WeatherProviderId = "OWM" | "DarkSky" | "local" | "mock"; export type WeatherProviderId = "OWM" | "DarkSky" | "local" | "mock" | "WUnderground";