Files
opensprinkler-weather/routes/weather.ts
Matthew Oslan aa26698481 Fix compatibility with earlier Node.js versions
Named regex match groups are not supported until Node.js 10.0.0
2019-06-29 13:10:22 -04:00

466 lines
16 KiB
TypeScript

import * as express from "express";
import * as http from "http";
import * as https from "https";
import * as SunCalc from "suncalc";
import * as moment from "moment-timezone";
import * as geoTZ from "geo-tz";
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 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 = {
gps: /^[-+]?([1-8]?\d(\.\d+)?|90(\.0+)?),\s*[-+]?(180(\.0+)?|((1[0-7]\d)|([1-9]?\d))(\.\d+)?)$/,
pws: /^(?:pws|icao|zmw):/,
url: /^https?:\/\/([\w\.-]+)(:\d+)?(\/.*)?$/,
time: /(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})([+-])(\d{2})(\d{2})/,
timezone: /^()()()()()()([+-])(\d{2})(\d{2})/
};
/** AdjustmentMethods mapped to their numeric IDs. */
const ADJUSTMENT_METHOD: { [ key: number ] : AdjustmentMethod } = {
0: ManualAdjustmentMethod,
1: ZimmermanAdjustmentMethod,
2: RainDelayAdjustmentMethod
};
/**
* Resolves a location description to geographic coordinates.
* @param location A partial zip/city/country or a coordinate pair.
* @return A promise that will be resolved with the coordinates of the best match for the specified location, or
* rejected with an error message if unable to resolve the location.
*/
async function resolveCoordinates( location: string ): Promise< GeoCoordinates > {
if ( !location ) {
throw "No location specified";
}
if ( filters.pws.test( location ) ) {
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 ] ) ];
} else {
// Generate URL for autocomplete request
const url = "http://autocomplete.wunderground.com/aq?h=0&query=" +
encodeURIComponent( location );
let data;
try {
data = await httpJSONRequest( url );
} catch (err) {
// If the request fails, indicate no data was found.
throw "An API error occurred while attempting to resolve location";
}
// Check if the data is valid
if ( typeof data.RESULTS === "object" && data.RESULTS.length && data.RESULTS[ 0 ].tz !== "MISSING" ) {
// If it is, reply with an array containing the GPS coordinates
return [ parseFloat( data.RESULTS[ 0 ].lat ), parseFloat( data.RESULTS[ 0 ].lon ) ];
} else {
// Otherwise, indicate no data was found
throw "No match found for specified location";
}
}
}
/**
* Makes an HTTP/HTTPS GET request to the specified URL and parses the JSON response body.
* @param url The URL to fetch.
* @return A Promise that will be resolved the with parsed response body if the request succeeds, or will be rejected
* with an error if the request or JSON parsing fails. This error may contain information about the HTTP request or,
* response including API keys and other sensitive information.
*/
export async function httpJSONRequest(url: string ): Promise< any > {
try {
const data: string = await httpRequest(url);
return JSON.parse(data);
} catch (err) {
// Reject the promise if there was an error making the request or parsing the JSON.
throw err;
}
}
/**
* Calculates timezone and sunrise/sunset for the specified coordinates.
* @param coordinates The coordinates to use to calculate time data.
* @return The TimeData for the specified coordinates.
*/
function getTimeData( coordinates: GeoCoordinates ): TimeData {
const timezone = moment().tz( geoTZ( coordinates[ 0 ], coordinates[ 1 ] )[ 0 ] ).utcOffset();
const tzOffset: number = getTimezone( timezone, true );
// Calculate sunrise and sunset since Weather Underground does not provide it
const sunData = SunCalc.getTimes( new Date(), coordinates[ 0 ], coordinates[ 1 ] );
sunData.sunrise.setUTCMinutes( sunData.sunrise.getUTCMinutes() + tzOffset );
sunData.sunset.setUTCMinutes( sunData.sunset.getUTCMinutes() + tzOffset );
return {
timezone: timezone,
sunrise: ( sunData.sunrise.getUTCHours() * 60 + sunData.sunrise.getUTCMinutes() ),
sunset: ( sunData.sunset.getUTCHours() * 60 + sunData.sunset.getUTCMinutes() )
};
}
/**
* 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:
*
* - California watering restriction prevents watering if precipitation over two days is greater than 0.1" over the past
* 48 hours.
* @param adjustmentValue The adjustment value, which indicates which restrictions should be checked.
* @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: BaseWateringData ): boolean {
const californiaRestriction = ( adjustmentValue >> 7 ) & 1;
if ( californiaRestriction ) {
// TODO depending on which WeatherProvider is used, this might be checking if rain is forecasted in th next 24
// hours rather than checking if it has rained in the past 48 hours.
// If the California watering restriction is in use then prevent watering
// if more then 0.1" of rain has accumulated in the past 48 hours
if ( weather.precip > 0.1 ) {
return true;
}
}
return false;
}
export const getWeatherData = async function( req: express.Request, res: express.Response ) {
const location: string = getParameter(req.query.loc);
let coordinates: GeoCoordinates;
try {
coordinates = await resolveCoordinates( location );
} catch (err) {
res.send(`Error: Unable to resolve location (${err})`);
return;
}
// Continue with the weather request
const timeData: TimeData = getTimeData( coordinates );
let weatherData: WeatherData;
try {
weatherData = await WEATHER_PROVIDER.getWeatherData( coordinates );
} catch ( err ) {
res.send( "Error: " + err );
return;
}
res.json( {
...timeData,
...weatherData,
location: coordinates
} );
};
// API Handler when using the weatherX.py where X represents the
// adjustment method which is encoded to also carry the watering
// restriction and therefore must be decoded
export const getWateringData = async function( req: express.Request, res: express.Response ) {
// 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: 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),
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
// the string is split against a comma and the first value is selected
remoteAddress = remoteAddress.split( "," )[ 0 ];
// Parse weather adjustment options
try {
// Parse data that may be encoded
adjustmentOptionsString = decodeURIComponent( adjustmentOptionsString.replace( /\\x/g, "%" ) );
// Reconstruct JSON string from deformed controller output
adjustmentOptions = JSON.parse( "{" + adjustmentOptionsString + "}" );
} catch ( err ) {
// If the JSON is not valid then abort the claculation
res.send(`Error: Unable to parse options (${err})`);
return;
}
// Attempt to resolve provided location to GPS coordinates.
let coordinates: GeoCoordinates;
try {
coordinates = await resolveCoordinates( location );
} catch (err) {
res.send(`Error: Unable to resolve location (${err})`);
return;
}
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, pws
);
} 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;
}
let scale = adjustmentMethodResponse.scale;
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;
}
}
const data = {
scale: scale,
rd: adjustmentMethodResponse.rainDelay,
tz: getTimezone( timeData.timezone, undefined ),
sunrise: timeData.sunrise,
sunset: timeData.sunset,
eip: ipToInt( remoteAddress ),
rawData: adjustmentMethodResponse.rawData,
error: adjustmentMethodResponse.errorMessage
};
// Return the response to the client in the requested format
if ( outputFormat === "json" ) {
res.json( data );
} else {
// 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 );
}
};
/**
* Makes an HTTP/HTTPS GET request to the specified URL and returns the response body.
* @param url The URL to fetch.
* @return A Promise that will be resolved the with response body if the request succeeds, or will be rejected with an
* error if the request fails or returns a non-200 status code. This error may contain information about the HTTP
* request or, response including API keys and other sensitive information.
*/
async function httpRequest( url: string ): Promise< string > {
return new Promise< any >( ( resolve, reject ) => {
const splitUrl: string[] = url.match( filters.url );
const isHttps = url.startsWith("https");
const options = {
host: splitUrl[ 1 ],
port: splitUrl[ 2 ] || ( isHttps ? 443 : 80 ),
path: splitUrl[ 3 ]
};
( isHttps ? https : http ).get( options, ( response ) => {
if ( response.statusCode !== 200 ) {
reject( `Received ${ response.statusCode } status code for URL '${ url }'.` );
return;
}
let data = "";
// Reassemble the data as it comes in
response.on( "data", ( chunk ) => {
data += chunk;
} );
// Once the data is completely received, resolve the promise
response.on( "end", () => {
resolve( data );
} );
} ).on( "error", ( err ) => {
// If the HTTP request fails, reject the promise
reject( err );
} );
} );
}
/**
* Checks if the specified object contains numeric values for each of the specified keys.
* @param keys A list of keys to validate exist on the specified object.
* @param obj The object to check.
* @return A boolean indicating if the object has numeric values for all of the specified keys.
*/
export function validateValues( keys: string[], obj: object ): boolean {
let key: string;
// Return false if the object is null/undefined.
if ( !obj ) {
return false;
}
for ( key in keys ) {
if ( !keys.hasOwnProperty( key ) ) {
continue;
}
key = keys[ key ];
if ( !obj.hasOwnProperty( key ) || typeof obj[ key ] !== "number" || isNaN( obj[ key ] ) || obj[ key ] === null || obj[ key ] === -999 ) {
return false;
}
}
return true;
}
/**
* Converts a timezone to an offset in minutes or OpenSprinkler encoded format.
* @param time A time string formatted in ISO-8601 or just the timezone.
* @param useMinutes Indicates if the returned value should be in minutes of the OpenSprinkler encoded format.
* @return The offset of the specified timezone in either minutes or OpenSprinkler encoded format (depending on the
* value of useMinutes).
*/
function getTimezone( time: number | string, useMinutes: boolean = false ): number {
let hour, minute;
if ( typeof time === "number" ) {
hour = Math.floor( time / 60 );
minute = time % 60;
} else {
// Match the provided time string against a regex for parsing
let splitTime = time.match( filters.time ) || time.match( filters.timezone );
hour = parseInt( splitTime[ 7 ] + splitTime[ 8 ] );
minute = parseInt( splitTime[ 9 ] );
}
if ( useMinutes ) {
return ( hour * 60 ) + minute;
} else {
// Convert the timezone into the OpenSprinkler encoded format
minute = ( minute / 15 >> 0 ) / 4;
hour = hour + ( hour >= 0 ? minute : -minute );
return ( ( hour + 12 ) * 4 ) >> 0;
}
}
/**
* Converts an IP address string to an integer.
* @param ip The string representation of the IP address.
* @return The integer representation of the IP address.
*/
function ipToInt( ip: string ): number {
const split = ip.split( "." );
return ( ( ( ( ( ( +split[ 0 ] ) * 256 ) + ( +split[ 1 ] ) ) * 256 ) + ( +split[ 2 ] ) ) * 256 ) + ( +split[ 3 ] );
}
/**
* Returns a single value for a header/query parameter. If passed a single string, the same string will be returned. If
* an array of strings is passed, the first value will be returned. If this value is null/undefined, an empty string
* will be returned instead.
* @param parameter An array of parameters or a single parameter value.
* @return The first element in the array of parameter or the single parameter provided.
*/
function getParameter( parameter: string | string[] ): string {
if ( Array.isArray( parameter ) ) {
parameter = parameter[0];
}
// 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 ]
};
}