Merge pull request #76 from OpenSprinkler/dev
Merge `dev` into `master`
This commit is contained in:
69
WateringScaleCache.ts
Normal file
69
WateringScaleCache.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import * as NodeCache from "node-cache";
|
||||
import { GeoCoordinates, PWS } from "./types";
|
||||
import { AdjustmentOptions } from "./routes/adjustmentMethods/AdjustmentMethod";
|
||||
import * as moment from "moment-timezone";
|
||||
import * as geoTZ from "geo-tz";
|
||||
import { Moment } from "moment-timezone/moment-timezone";
|
||||
|
||||
export default class WateringScaleCache {
|
||||
private readonly cache: NodeCache = new NodeCache();
|
||||
|
||||
/**
|
||||
* Stores the results of a watering scale calculation. The scale will be cached until the end of the day in the local
|
||||
* timezone of the specified coordinates. If a scale has already been cached for the specified calculation parameters,
|
||||
* this method will have no effect.
|
||||
* @param adjustmentMethodId The ID of the AdjustmentMethod used to calculate this watering scale. This value should
|
||||
* have the appropriate bits set for any restrictions that were used.
|
||||
* @param coordinates The coordinates the watering scale was calculated for.
|
||||
* @param pws The PWS used to calculate the watering scale, or undefined if one was not used.
|
||||
* @param adjustmentOptions Any user-specified adjustment options that were used when calculating the watering scale.
|
||||
* @param wateringScale The results of the watering scale calculation.
|
||||
*/
|
||||
public storeWateringScale(
|
||||
adjustmentMethodId: number,
|
||||
coordinates: GeoCoordinates,
|
||||
pws: PWS,
|
||||
adjustmentOptions: AdjustmentOptions,
|
||||
wateringScale: CachedScale
|
||||
): void {
|
||||
// The end of the day in the controller's timezone.
|
||||
const expirationDate: Moment = moment().tz( geoTZ( coordinates[ 0 ], coordinates[ 1 ] )[ 0 ] ).endOf( "day" );
|
||||
const ttl: number = ( expirationDate.unix() - moment().unix() );
|
||||
const key = this.makeKey( adjustmentMethodId, coordinates, pws, adjustmentOptions );
|
||||
this.cache.set( key, wateringScale, ttl );
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a cached scale that was previously calculated with the given parameters.
|
||||
* @param adjustmentMethodId The ID of the AdjustmentMethod used to calculate this watering scale. This value should
|
||||
* have the appropriate bits set for any restrictions that were used.
|
||||
* @param coordinates The coordinates the watering scale was calculated for.
|
||||
* @param pws The PWS used to calculate the watering scale, or undefined if one was not used.
|
||||
* @param adjustmentOptions Any user-specified adjustment options that were used when calculating the watering scale.
|
||||
* @return The cached result of the watering scale calculation, or undefined if no values were cached.
|
||||
*/
|
||||
public getWateringScale(
|
||||
adjustmentMethodId: number,
|
||||
coordinates: GeoCoordinates,
|
||||
pws: PWS,
|
||||
adjustmentOptions: AdjustmentOptions
|
||||
): CachedScale | undefined {
|
||||
const key = this.makeKey( adjustmentMethodId, coordinates, pws, adjustmentOptions );
|
||||
return this.cache.get( key );
|
||||
}
|
||||
|
||||
private makeKey(
|
||||
adjustmentMethodId: number,
|
||||
coordinates: GeoCoordinates,
|
||||
pws: PWS,
|
||||
adjustmentOptions: AdjustmentOptions
|
||||
): string {
|
||||
return `${ adjustmentMethodId }#${ coordinates.join( "," ) }#${ pws ? pws.id : "" }#${ JSON.stringify( adjustmentOptions ) }`
|
||||
}
|
||||
}
|
||||
|
||||
export interface CachedScale {
|
||||
scale: number;
|
||||
rawData: object;
|
||||
rainDelay: number;
|
||||
}
|
||||
69
package-lock.json
generated
69
package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "os-weather-service",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.4",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
@@ -127,6 +127,15 @@
|
||||
"integrity": "sha512-Fvm24+u85lGmV4hT5G++aht2C5I4Z4dYlWZIh62FAfFO/TfzXtPpoLI6I7AuBWkIFqZCnhFOoTT7RjjaIL5Fjg==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/node-cache": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node-cache/-/node-cache-4.1.3.tgz",
|
||||
"integrity": "sha512-3hsqnv3H1zkOhjygJaJUYmgz5+FcPO3vejBX7cE9/cnuINOJYrzkfOnUCvpwGe9kMZANIHJA7J5pOdeyv52OEw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/range-parser": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz",
|
||||
@@ -571,6 +580,11 @@
|
||||
"integrity": "sha1-T6kXw+WclKAEzWH47lCdplFocUM=",
|
||||
"dev": true
|
||||
},
|
||||
"clone": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||
"integrity": "sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18="
|
||||
},
|
||||
"collection-visit": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz",
|
||||
@@ -1126,8 +1140,7 @@
|
||||
"ansi-regex": {
|
||||
"version": "2.1.1",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true
|
||||
"dev": true
|
||||
},
|
||||
"aproba": {
|
||||
"version": "1.2.0",
|
||||
@@ -1148,14 +1161,12 @@
|
||||
"balanced-match": {
|
||||
"version": "1.0.0",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true
|
||||
"dev": true
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
@@ -1170,20 +1181,17 @@
|
||||
"code-point-at": {
|
||||
"version": "1.1.0",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true
|
||||
"dev": true
|
||||
},
|
||||
"concat-map": {
|
||||
"version": "0.0.1",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true
|
||||
"dev": true
|
||||
},
|
||||
"console-control-strings": {
|
||||
"version": "1.1.0",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true
|
||||
"dev": true
|
||||
},
|
||||
"core-util-is": {
|
||||
"version": "1.0.2",
|
||||
@@ -1300,8 +1308,7 @@
|
||||
"inherits": {
|
||||
"version": "2.0.3",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true
|
||||
"dev": true
|
||||
},
|
||||
"ini": {
|
||||
"version": "1.3.5",
|
||||
@@ -1313,7 +1320,6 @@
|
||||
"version": "1.0.0",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"number-is-nan": "^1.0.0"
|
||||
}
|
||||
@@ -1328,7 +1334,6 @@
|
||||
"version": "3.0.4",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
}
|
||||
@@ -1336,14 +1341,12 @@
|
||||
"minimist": {
|
||||
"version": "0.0.8",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true
|
||||
"dev": true
|
||||
},
|
||||
"minipass": {
|
||||
"version": "2.3.5",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"safe-buffer": "^5.1.2",
|
||||
"yallist": "^3.0.0"
|
||||
@@ -1362,7 +1365,6 @@
|
||||
"version": "0.5.1",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"minimist": "0.0.8"
|
||||
}
|
||||
@@ -1443,8 +1445,7 @@
|
||||
"number-is-nan": {
|
||||
"version": "1.0.1",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true
|
||||
"dev": true
|
||||
},
|
||||
"object-assign": {
|
||||
"version": "4.1.1",
|
||||
@@ -1456,7 +1457,6 @@
|
||||
"version": "1.4.0",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
@@ -1542,8 +1542,7 @@
|
||||
"safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true
|
||||
"dev": true
|
||||
},
|
||||
"safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
@@ -1579,7 +1578,6 @@
|
||||
"version": "1.0.2",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"code-point-at": "^1.0.0",
|
||||
"is-fullwidth-code-point": "^1.0.0",
|
||||
@@ -1599,7 +1597,6 @@
|
||||
"version": "3.0.1",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"ansi-regex": "^2.0.0"
|
||||
}
|
||||
@@ -1643,14 +1640,12 @@
|
||||
"wrappy": {
|
||||
"version": "1.0.2",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true
|
||||
"dev": true
|
||||
},
|
||||
"yallist": {
|
||||
"version": "3.0.3",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2091,8 +2086,7 @@
|
||||
"lodash": {
|
||||
"version": "4.17.11",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz",
|
||||
"integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==",
|
||||
"dev": true
|
||||
"integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg=="
|
||||
},
|
||||
"lowercase-keys": {
|
||||
"version": "1.0.1",
|
||||
@@ -2465,6 +2459,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node-cache": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/node-cache/-/node-cache-4.2.0.tgz",
|
||||
"integrity": "sha512-obRu6/f7S024ysheAjoYFEEBqqDWv4LOMNJEuO8vMeEw2AT4z+NCzO4hlc2lhI4vATzbCQv6kke9FVdx0RbCOw==",
|
||||
"requires": {
|
||||
"clone": "2.x",
|
||||
"lodash": "4.x"
|
||||
}
|
||||
},
|
||||
"node-watch": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/node-watch/-/node-watch-0.6.2.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"geo-tz": "^5.0.4",
|
||||
"mockdate": "^2.0.2",
|
||||
"moment-timezone": "^0.5.25",
|
||||
"node-cache": "^4.2.0",
|
||||
"suncalc": "^1.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -30,6 +31,7 @@
|
||||
"@types/mocha": "^5.2.6",
|
||||
"@types/moment-timezone": "^0.5.12",
|
||||
"@types/node": "^10.14.6",
|
||||
"@types/node-cache": "^4.1.3",
|
||||
"@types/suncalc": "^1.8.0",
|
||||
"chai": "^4.2.0",
|
||||
"mocha": "^5.2.0",
|
||||
|
||||
64
routes/adjustmentMethods/AdjustmentMethod.ts
Normal file
64
routes/adjustmentMethods/AdjustmentMethod.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
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. 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
|
||||
* 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,
|
||||
coordinates: GeoCoordinates,
|
||||
weatherProvider: WeatherProvider,
|
||||
pws?: PWS
|
||||
): Promise< AdjustmentMethodResponse >;
|
||||
}
|
||||
|
||||
export interface AdjustmentMethodResponse {
|
||||
/**
|
||||
* The percentage that should be used to scale the watering level. This should be an integer between 0-200 (inclusive),
|
||||
* or undefined if the watering level should not be changed.
|
||||
*/
|
||||
scale: number | undefined;
|
||||
/**
|
||||
* The raw data that was used to calculate the watering scale. This will be sent directly to the OS controller, so
|
||||
* each field should be formatted in a way that the controller understands and numbers should be rounded
|
||||
* appropriately to remove excessive figures. If no data was used (e.g. an error occurred), this should be undefined.
|
||||
*/
|
||||
rawData?: object;
|
||||
/**
|
||||
* How long watering should be delayed for (in hours) due to rain, or undefined if watering should not be delayed
|
||||
* for a specific amount of time (either it should be delayed indefinitely or it should not be delayed at all). This
|
||||
* property will not stop watering on its own, and the `scale` property should be set to 0 to actually prevent
|
||||
* watering.
|
||||
*/
|
||||
rainDelay?: number;
|
||||
// TODO consider removing this field and breaking backwards compatibility to handle all errors consistently.
|
||||
/**
|
||||
* An message to send to the OS firmware to indicate that an error occurred while calculating the watering
|
||||
* scale and the returned scale either defaulted to some reasonable value or was calculated with incomplete data.
|
||||
* Older firmware versions will ignore this field (they will silently swallow the error and use the returned scale),
|
||||
* but newer firmware versions may be able to alert the user that an error occurred and/or default to a
|
||||
* 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 {
|
||||
/** The ID of the PWS to use. */
|
||||
pws?: string;
|
||||
/** The API key to use to access PWS data. */
|
||||
key?: string;
|
||||
}
|
||||
18
routes/adjustmentMethods/ManualAdjustmentMethod.ts
Normal file
18
routes/adjustmentMethods/ManualAdjustmentMethod.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { AdjustmentMethod, AdjustmentMethodResponse } from "./AdjustmentMethod";
|
||||
|
||||
|
||||
/**
|
||||
* Does not change the watering scale (only time data will be returned).
|
||||
*/
|
||||
async function calculateManualWateringScale( ): Promise< AdjustmentMethodResponse > {
|
||||
return {
|
||||
scale: undefined,
|
||||
wateringData: undefined
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const ManualAdjustmentMethod: AdjustmentMethod = {
|
||||
calculateWateringScale: calculateManualWateringScale
|
||||
};
|
||||
export default ManualAdjustmentMethod;
|
||||
30
routes/adjustmentMethods/RainDelayAdjustmentMethod.ts
Normal file
30
routes/adjustmentMethods/RainDelayAdjustmentMethod.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./AdjustmentMethod";
|
||||
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, 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,
|
||||
wateringData: wateringData
|
||||
}
|
||||
}
|
||||
|
||||
export interface RainDelayAdjustmentOptions extends AdjustmentOptions {
|
||||
/** The rain delay to use (in hours). */
|
||||
d?: number;
|
||||
}
|
||||
|
||||
|
||||
const RainDelayAdjustmentMethod: AdjustmentMethod = {
|
||||
calculateWateringScale: calculateRainDelayWateringScale
|
||||
};
|
||||
export default RainDelayAdjustmentMethod;
|
||||
100
routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts
Normal file
100
routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./AdjustmentMethod";
|
||||
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,
|
||||
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.
|
||||
/*
|
||||
// Don't water if it is currently raining.
|
||||
if ( wateringData && wateringData.raining ) {
|
||||
return {
|
||||
scale: 0,
|
||||
rawData: { raining: 1 },
|
||||
wateringData: wateringData
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
const rawData = {
|
||||
h: wateringData ? Math.round( wateringData.humidity * 100) / 100 : null,
|
||||
p: wateringData ? Math.round( wateringData.precip * 100 ) / 100 : null,
|
||||
t: wateringData ? Math.round( wateringData.temp * 10 ) / 10 : null,
|
||||
raining: wateringData ? ( wateringData.raining ? 1 : 0 ) : null
|
||||
};
|
||||
|
||||
// Check to make sure valid data exists for all factors
|
||||
if ( !validateValues( [ "temp", "humidity", "precip" ], wateringData ) ) {
|
||||
// Default to a scale of 100% if fields are missing.
|
||||
return {
|
||||
scale: 100,
|
||||
rawData: rawData,
|
||||
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
|
||||
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.hasOwnProperty( "h" ) ) {
|
||||
humidityFactor = humidityFactor * ( adjustmentOptions.h / 100 );
|
||||
}
|
||||
|
||||
if ( adjustmentOptions.hasOwnProperty( "t" ) ) {
|
||||
tempFactor = tempFactor * ( adjustmentOptions.t / 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,
|
||||
wateringData: wateringData
|
||||
}
|
||||
}
|
||||
|
||||
export interface ZimmermanAdjustmentOptions extends AdjustmentOptions {
|
||||
/** Base humidity (as a percentage). */
|
||||
bh?: number;
|
||||
/** Base temperature (in Fahrenheit). */
|
||||
bt?: number;
|
||||
/** Base precipitation (in inches). */
|
||||
br?: number;
|
||||
/** The percentage to weight the humidity factor by. */
|
||||
h?: number;
|
||||
/** The percentage to weight the temperature factor by. */
|
||||
t?: number;
|
||||
/** The percentage to weight the precipitation factor by. */
|
||||
r?: number;
|
||||
}
|
||||
|
||||
|
||||
const ZimmermanAdjustmentMethod: AdjustmentMethod = {
|
||||
calculateWateringScale: calculateZimmermanWateringScale
|
||||
};
|
||||
export default ZimmermanAdjustmentMethod;
|
||||
@@ -4,7 +4,13 @@ import * as MockExpressRequest from 'mock-express-request';
|
||||
import * as MockExpressResponse from 'mock-express-response';
|
||||
import * as MockDate from 'mockdate';
|
||||
|
||||
// The tests don't use OWM, but the WeatherProvider API key must be set to prevent an error from being thrown on startup.
|
||||
process.env.WEATHER_PROVIDER = "OWM";
|
||||
process.env.OWM_API_KEY = "NO_KEY";
|
||||
|
||||
import { getWateringData } from './weather';
|
||||
import { GeoCoordinates, ZimmermanWateringData, WeatherData } from "../types";
|
||||
import { WeatherProvider } from "./weatherProviders/WeatherProvider";
|
||||
|
||||
const expected = require( '../test/expected.json' );
|
||||
const replies = require( '../test/replies.json' );
|
||||
@@ -59,3 +65,42 @@ function mockOWM() {
|
||||
.get( "/" )
|
||||
.reply( 200, replies[location].OWMData );
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* A WeatherProvider for testing purposes that returns weather data that is provided in the constructor.
|
||||
* This is a special WeatherProvider designed for testing purposes and should not be activated using the
|
||||
* WEATHER_PROVIDER environment variable.
|
||||
*/
|
||||
export class MockWeatherProvider extends WeatherProvider {
|
||||
|
||||
private readonly mockData: MockWeatherData;
|
||||
|
||||
public constructor(mockData: MockWeatherData) {
|
||||
super();
|
||||
this.mockData = mockData;
|
||||
}
|
||||
|
||||
public async getWateringData( coordinates: GeoCoordinates ): Promise< ZimmermanWateringData > {
|
||||
const data = this.mockData.wateringData;
|
||||
if ( !data.weatherProvider ) {
|
||||
data.weatherProvider = "mock";
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
public async getWeatherData( coordinates: GeoCoordinates ): Promise< WeatherData > {
|
||||
const data = this.mockData.weatherData;
|
||||
if ( !data.weatherProvider ) {
|
||||
data.weatherProvider = "mock";
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
interface MockWeatherData {
|
||||
wateringData?: ZimmermanWateringData,
|
||||
weatherData?: WeatherData
|
||||
}
|
||||
|
||||
@@ -5,8 +5,15 @@ import * as SunCalc from "suncalc";
|
||||
import * as moment from "moment-timezone";
|
||||
import * as geoTZ from "geo-tz";
|
||||
|
||||
import { AdjustmentOptions, GeoCoordinates, TimeData, WateringData, WeatherData, WeatherProvider } from "../types";
|
||||
const weatherProvider: WeatherProvider = require("./weatherProviders/" + ( process.env.WEATHER_PROVIDER || "OWM" ) ).default;
|
||||
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";
|
||||
import WateringScaleCache, { CachedScale } from "../WateringScaleCache";
|
||||
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 = {
|
||||
@@ -17,13 +24,15 @@ const filters = {
|
||||
timezone: /^()()()()()()([+-])(\d{2})(\d{2})/
|
||||
};
|
||||
|
||||
// Enum of available watering scale adjustment methods.
|
||||
const ADJUSTMENT_METHOD = {
|
||||
MANUAL: 0,
|
||||
ZIMMERMAN: 1,
|
||||
RAIN_DELAY: 2
|
||||
/** AdjustmentMethods mapped to their numeric IDs. */
|
||||
const ADJUSTMENT_METHOD: { [ key: number ] : AdjustmentMethod } = {
|
||||
0: ManualAdjustmentMethod,
|
||||
1: ZimmermanAdjustmentMethod,
|
||||
2: RainDelayAdjustmentMethod
|
||||
};
|
||||
|
||||
const cache = new WateringScaleCache();
|
||||
|
||||
/**
|
||||
* Resolves a location description to geographic coordinates.
|
||||
* @param location A partial zip/city/country or a coordinate pair.
|
||||
@@ -37,7 +46,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 ] ) ];
|
||||
@@ -71,7 +80,8 @@ async function resolveCoordinates( location: string ): Promise< GeoCoordinates >
|
||||
* 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.
|
||||
* 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 {
|
||||
@@ -105,51 +115,6 @@ function getTimeData( coordinates: GeoCoordinates ): TimeData {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates how much watering should be scaled based on weather and adjustment options using the Zimmerman method.
|
||||
* @param adjustmentOptions Options to tweak the calculation, or undefined/null if no custom values are to be used.
|
||||
* @param wateringData The weather to use to calculate watering percentage.
|
||||
* @return The percentage that watering should be scaled by.
|
||||
*/
|
||||
function calculateZimmermanWateringScale( adjustmentOptions: AdjustmentOptions, wateringData: WateringData ): number {
|
||||
|
||||
let humidityBase = 30, tempBase = 70, precipBase = 0;
|
||||
|
||||
// Check to make sure valid data exists for all factors
|
||||
if ( !validateValues( [ "temp", "humidity", "precip" ], wateringData ) ) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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( "t" ) ) {
|
||||
tempFactor = tempFactor * ( adjustmentOptions.t / 100 );
|
||||
}
|
||||
|
||||
if ( adjustmentOptions.hasOwnProperty( "r" ) ) {
|
||||
precipFactor = precipFactor * ( adjustmentOptions.r / 100 );
|
||||
}
|
||||
}
|
||||
|
||||
// Apply all of the weather modifying factors and clamp the result between 0 and 200%.
|
||||
return Math.floor( Math.min( Math.max( 0, 100 + humidityFactor + tempFactor + precipFactor ), 200 ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* 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:
|
||||
@@ -160,7 +125,7 @@ function calculateZimmermanWateringScale( adjustmentOptions: AdjustmentOptions,
|
||||
* @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;
|
||||
|
||||
@@ -181,11 +146,6 @@ function checkWeatherRestriction( adjustmentValue: number, weather: WateringData
|
||||
export const getWeatherData = async function( req: express.Request, res: express.Response ) {
|
||||
const location: string = getParameter(req.query.loc);
|
||||
|
||||
if ( !weatherProvider.getWeatherData ) {
|
||||
res.send( "Error: selected WeatherProvider does not support getWeatherData" );
|
||||
return;
|
||||
}
|
||||
|
||||
let coordinates: GeoCoordinates;
|
||||
try {
|
||||
coordinates = await resolveCoordinates( location );
|
||||
@@ -196,7 +156,13 @@ export const getWeatherData = async function( req: express.Request, res: express
|
||||
|
||||
// Continue with the weather request
|
||||
const timeData: TimeData = getTimeData( coordinates );
|
||||
const weatherData: WeatherData = await weatherProvider.getWeatherData( coordinates );
|
||||
let weatherData: WeatherData;
|
||||
try {
|
||||
weatherData = await WEATHER_PROVIDER.getWeatherData( coordinates );
|
||||
} catch ( err ) {
|
||||
res.send( "Error: " + err );
|
||||
return;
|
||||
}
|
||||
|
||||
res.json( {
|
||||
...timeData,
|
||||
@@ -213,7 +179,7 @@ export const getWateringData = async function( req: express.Request, res: expres
|
||||
// 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: number = req.params[ 0 ] & ~( 1 << 7 ),
|
||||
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),
|
||||
@@ -221,7 +187,6 @@ export const getWateringData = async function( req: express.Request, res: expres
|
||||
remoteAddress: string = getParameter(req.headers[ "x-forwarded-for" ]) || req.connection.remoteAddress,
|
||||
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 ];
|
||||
@@ -250,85 +215,148 @@ 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 !== ADJUSTMENT_METHOD.MANUAL || checkRestrictions ) {
|
||||
if ( !weatherProvider.getWateringData ) {
|
||||
res.send( "Error: selected WeatherProvider does not support getWateringData" );
|
||||
|
||||
// Parse the PWS information.
|
||||
let pws: PWS | undefined = undefined;
|
||||
if ( adjustmentOptions.pws && adjustmentOptions.key ) {
|
||||
|
||||
const idMatch = adjustmentOptions.pws.match( /^[a-zA-Z\d]+$/ );
|
||||
const pwsId = idMatch ? idMatch[ 0 ] : undefined;
|
||||
const keyMatch = adjustmentOptions.key.match( /^[a-f\d]{32}$/ );
|
||||
const apiKey = keyMatch ? keyMatch[ 0 ] : undefined;
|
||||
|
||||
// Make sure that the PWS ID and API key look valid.
|
||||
if ( !pwsId ) {
|
||||
res.send("Error: PWS ID does not appear to be valid.");
|
||||
return;
|
||||
}
|
||||
if ( !apiKey ) {
|
||||
res.send("Error: PWS API key does not appear to be valid.");
|
||||
return;
|
||||
}
|
||||
|
||||
wateringData = await weatherProvider.getWateringData( coordinates );
|
||||
pws = { id: pwsId, apiKey: apiKey };
|
||||
}
|
||||
|
||||
let scale = -1, rainDelay = -1;
|
||||
|
||||
if ( adjustmentMethod === ADJUSTMENT_METHOD.ZIMMERMAN ) {
|
||||
scale = calculateZimmermanWateringScale( adjustmentOptions, wateringData );
|
||||
}
|
||||
|
||||
if (wateringData) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
// If any weather adjustment is being used, check the rain status
|
||||
if ( adjustmentMethod > ADJUSTMENT_METHOD.MANUAL && wateringData.raining ) {
|
||||
|
||||
// If it is raining and the user has weather-based rain delay as the adjustment method then apply the specified delay
|
||||
if ( adjustmentMethod === ADJUSTMENT_METHOD.RAIN_DELAY ) {
|
||||
|
||||
rainDelay = ( adjustmentOptions && adjustmentOptions.hasOwnProperty( "d" ) ) ? adjustmentOptions.d : 24;
|
||||
} else {
|
||||
// Temporarily disabled since OWM forecast data is checking if rain is forecasted for 3 hours in the future.
|
||||
// For any other adjustment method, apply a scale of 0 (as the scale will revert when the rain stops)
|
||||
// scale = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
const weatherProvider = pws ? PWS_WEATHER_PROVIDER : WEATHER_PROVIDER;
|
||||
|
||||
const data = {
|
||||
scale: scale,
|
||||
rd: rainDelay,
|
||||
scale: undefined,
|
||||
rd: undefined,
|
||||
tz: getTimezone( timeData.timezone, undefined ),
|
||||
sunrise: timeData.sunrise,
|
||||
sunset: timeData.sunset,
|
||||
eip: ipToInt( remoteAddress ),
|
||||
rawData: undefined
|
||||
rawData: undefined,
|
||||
error: undefined
|
||||
};
|
||||
|
||||
if ( adjustmentMethod > ADJUSTMENT_METHOD.MANUAL ) {
|
||||
data.rawData = {
|
||||
h: wateringData ? Math.round( wateringData.humidity * 100) / 100 : null,
|
||||
p: wateringData ? Math.round( wateringData.precip * 100 ) / 100 : null,
|
||||
t: wateringData ? Math.round( wateringData.temp * 10 ) / 10 : null,
|
||||
raining: wateringData ? ( wateringData.raining ? 1 : 0 ) : null
|
||||
};
|
||||
let cachedScale: CachedScale;
|
||||
if ( weatherProvider.shouldCacheWateringScale() ) {
|
||||
cachedScale = cache.getWateringScale( req.params[ 0 ], coordinates, pws, adjustmentOptions );
|
||||
}
|
||||
|
||||
if ( cachedScale ) {
|
||||
// Use the cached data if it exists.
|
||||
data.scale = cachedScale.scale;
|
||||
data.rawData = cachedScale.rawData;
|
||||
data.rd = cachedScale.rainDelay;
|
||||
} else {
|
||||
// Calculate the watering scale if it wasn't found in the cache.
|
||||
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;
|
||||
}
|
||||
|
||||
data.scale = adjustmentMethodResponse.scale;
|
||||
data.error = adjustmentMethodResponse.errorMessage;
|
||||
data.rd = adjustmentMethodResponse.rainDelay;
|
||||
data.rawData = adjustmentMethodResponse.rawData;
|
||||
|
||||
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 ) ) {
|
||||
data.scale = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the watering scale if caching is enabled and no error occurred.
|
||||
if ( weatherProvider.shouldCacheWateringScale() && !data.error ) {
|
||||
cache.storeWateringScale( req.params[ 0 ], coordinates, pws, adjustmentOptions, {
|
||||
scale: data.scale,
|
||||
rawData: data.rawData,
|
||||
rainDelay: data.rd
|
||||
} );
|
||||
}
|
||||
}
|
||||
|
||||
// Return the response to the client in the requested format
|
||||
if ( outputFormat === "json" ) {
|
||||
res.json( data );
|
||||
} else {
|
||||
res.send( "&scale=" + data.scale +
|
||||
"&rd=" + data.rd +
|
||||
"&tz=" + data.tz +
|
||||
"&sunrise=" + data.sunrise +
|
||||
"&sunset=" + data.sunset +
|
||||
"&eip=" + data.eip +
|
||||
( data.rawData ? "&rawData=" + JSON.stringify( data.rawData ) : "" )
|
||||
);
|
||||
// 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.
|
||||
* 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 ) => {
|
||||
@@ -343,6 +371,11 @@ async function httpRequest( url: string ): Promise< string > {
|
||||
};
|
||||
|
||||
( 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
|
||||
@@ -368,7 +401,7 @@ async function httpRequest( url: string ): Promise< string > {
|
||||
* @param obj The object to check.
|
||||
* @return A boolean indicating if the object has numeric values for all of the specified keys.
|
||||
*/
|
||||
function validateValues( keys: string[], obj: object ): boolean {
|
||||
export function validateValues( keys: string[], obj: object ): boolean {
|
||||
let key: string;
|
||||
|
||||
// Return false if the object is null/undefined.
|
||||
|
||||
@@ -1,77 +1,84 @@
|
||||
import * as moment from "moment-timezone";
|
||||
|
||||
import { GeoCoordinates, WateringData, WeatherData, WeatherProvider } from "../../types";
|
||||
import { GeoCoordinates, WeatherData, ZimmermanWateringData } from "../../types";
|
||||
import { httpJSONRequest } from "../weather";
|
||||
import { WeatherProvider } from "./WeatherProvider";
|
||||
|
||||
async function getDarkSkyWateringData( coordinates: GeoCoordinates ): Promise< WateringData > {
|
||||
export default class DarkSkyWeatherProvider extends WeatherProvider {
|
||||
|
||||
private readonly API_KEY: string;
|
||||
|
||||
public constructor() {
|
||||
super();
|
||||
this.API_KEY = process.env.DARKSKY_API_KEY;
|
||||
if (!this.API_KEY) {
|
||||
throw "DARKSKY_API_KEY environment variable is not defined.";
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
const DARKSKY_API_KEY = process.env.DARKSKY_API_KEY,
|
||||
yesterdayUrl = `https://api.darksky.net/forecast/${DARKSKY_API_KEY}/${coordinates[0]},${coordinates[1]},${yesterdayTimestamp}?exclude=currently,minutely,daily,alerts,flags`,
|
||||
todayUrl = `https://api.darksky.net/forecast/${DARKSKY_API_KEY}/${coordinates[0]},${coordinates[1]},${todayTimestamp}?exclude=currently,minutely,daily,alerts,flags`;
|
||||
const yesterdayUrl = `https://api.darksky.net/forecast/${ this.API_KEY }/${ coordinates[ 0 ] },${ coordinates[ 1 ] },${ yesterdayTimestamp }?exclude=currently,minutely,daily,alerts,flags`;
|
||||
|
||||
let yesterdayData, todayData;
|
||||
let yesterdayData;
|
||||
try {
|
||||
yesterdayData = await httpJSONRequest( yesterdayUrl );
|
||||
todayData = await httpJSONRequest( todayUrl );
|
||||
} catch (err) {
|
||||
// Indicate watering data could not be retrieved if an API error occurs.
|
||||
return undefined;
|
||||
} catch ( err ) {
|
||||
console.error( "Error retrieving weather information from Dark Sky:", err );
|
||||
throw "An error occurred while retrieving weather information from Dark Sky."
|
||||
}
|
||||
|
||||
if ( !todayData.hourly || !todayData.hourly.data || !yesterdayData.hourly || !yesterdayData.hourly.data ) {
|
||||
return undefined;
|
||||
if ( !yesterdayData.hourly || !yesterdayData.hourly.data ) {
|
||||
throw "Necessary field(s) were missing from weather information returned by Dark Sky.";
|
||||
}
|
||||
|
||||
/* The number of hourly forecasts to use from today's data. This will only include elements that contain historic
|
||||
data (not forecast data). */
|
||||
// Find the first element that contains forecast data.
|
||||
const todayElements = Math.min( 24, todayData.hourly.data.findIndex( ( data ) => data.time > todayTimestamp - 60 * 60 ) );
|
||||
|
||||
/* Take as much data as possible from the first elements of today's data and take the remaining required data from
|
||||
the remaining data from the last elements of yesterday's data. */
|
||||
const samples = [
|
||||
...yesterdayData.hourly.data.slice( todayElements - 24 ),
|
||||
...todayData.hourly.data.slice( 0, todayElements )
|
||||
...yesterdayData.hourly.data
|
||||
];
|
||||
|
||||
// Fail if not enough data is available.
|
||||
if ( samples.length !== 24 ) {
|
||||
return undefined;
|
||||
throw "Insufficient data was returned by Dark Sky.";
|
||||
}
|
||||
|
||||
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 {
|
||||
weatherProvider: "DarkSky",
|
||||
temp : totals.temp / 24,
|
||||
temp: totals.temp / 24,
|
||||
humidity: totals.humidity / 24 * 100,
|
||||
precip: totals.precip,
|
||||
raining: samples[ samples.length - 1 ].precipIntensity > 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function getDarkSkyWeatherData( coordinates: GeoCoordinates ): Promise< WeatherData > {
|
||||
const DARKSKY_API_KEY = process.env.DARKSKY_API_KEY,
|
||||
forecastUrl = `https://api.darksky.net/forecast/${DARKSKY_API_KEY}/${coordinates[0]},${coordinates[1]}?exclude=minutely,alerts,flags`;
|
||||
public async getWeatherData( coordinates: GeoCoordinates ): Promise< WeatherData > {
|
||||
const forecastUrl = `https://api.darksky.net/forecast/${ this.API_KEY }/${ coordinates[ 0 ] },${ coordinates[ 1 ] }?exclude=minutely,alerts,flags`;
|
||||
|
||||
let forecast;
|
||||
try {
|
||||
forecast = await httpJSONRequest( forecastUrl );
|
||||
} catch (err) {
|
||||
// Indicate weather data could not be retrieved if an API error occurs.
|
||||
return undefined;
|
||||
} catch ( err ) {
|
||||
console.error( "Error retrieving weather information from Dark Sky:", err );
|
||||
throw "An error occurred while retrieving weather information from Dark Sky."
|
||||
}
|
||||
|
||||
if ( !forecast.currently || !forecast.daily || !forecast.daily.data ) {
|
||||
return undefined;
|
||||
throw "Necessary field(s) were missing from weather information returned by Dark Sky.";
|
||||
}
|
||||
|
||||
const weather: WeatherData = {
|
||||
@@ -88,7 +95,7 @@ async function getDarkSkyWeatherData( coordinates: GeoCoordinates ): Promise< We
|
||||
minTemp: Math.floor( forecast.daily.data[ 0 ].temperatureMin ),
|
||||
maxTemp: Math.floor( forecast.daily.data[ 0 ].temperatureMax ),
|
||||
precip: forecast.daily.data[ 0 ].precipIntensity * 24,
|
||||
forecast: [ ]
|
||||
forecast: []
|
||||
};
|
||||
|
||||
for ( let index = 0; index < forecast.daily.data.length; index++ ) {
|
||||
@@ -103,11 +110,9 @@ async function getDarkSkyWeatherData( coordinates: GeoCoordinates ): Promise< We
|
||||
}
|
||||
|
||||
return weather;
|
||||
}
|
||||
|
||||
public shouldCacheWateringScale(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const DarkSkyWeatherProvider: WeatherProvider = {
|
||||
getWateringData: getDarkSkyWateringData,
|
||||
getWeatherData: getDarkSkyWeatherData
|
||||
};
|
||||
export default DarkSkyWeatherProvider;
|
||||
|
||||
@@ -1,29 +1,41 @@
|
||||
import { GeoCoordinates, WateringData, WeatherData, WeatherProvider } from "../../types";
|
||||
import { GeoCoordinates, WeatherData, ZimmermanWateringData } from "../../types";
|
||||
import { httpJSONRequest } from "../weather";
|
||||
import { WeatherProvider } from "./WeatherProvider";
|
||||
|
||||
async function getOWMWateringData( coordinates: GeoCoordinates ): Promise< WateringData > {
|
||||
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 ];
|
||||
export default class OWMWeatherProvider extends WeatherProvider {
|
||||
|
||||
private readonly API_KEY: string;
|
||||
|
||||
public constructor() {
|
||||
super();
|
||||
this.API_KEY = process.env.OWM_API_KEY;
|
||||
if (!this.API_KEY) {
|
||||
throw "OWM_API_KEY environment variable is not defined.";
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
let forecast;
|
||||
try {
|
||||
forecast = await httpJSONRequest( forecastUrl );
|
||||
} catch (err) {
|
||||
// Indicate watering data could not be retrieved if an API error occurs.
|
||||
return undefined;
|
||||
} catch ( err ) {
|
||||
console.error( "Error retrieving weather information from OWM:", err );
|
||||
throw "An error occurred while retrieving weather information from OWM."
|
||||
}
|
||||
|
||||
// Indicate watering data could not be retrieved if the forecast data is incomplete.
|
||||
if ( !forecast || !forecast.list ) {
|
||||
return undefined;
|
||||
throw "Necessary field(s) were missing from weather information returned by OWM.";
|
||||
}
|
||||
|
||||
let totalTemp = 0,
|
||||
totalHumidity = 0,
|
||||
totalPrecip = 0;
|
||||
|
||||
const periods = Math.min(forecast.list.length, 8);
|
||||
const periods = Math.min( forecast.list.length, 8 );
|
||||
for ( let index = 0; index < periods; index++ ) {
|
||||
totalTemp += parseFloat( forecast.list[ index ].main.temp );
|
||||
totalHumidity += parseInt( forecast.list[ index ].main.humidity );
|
||||
@@ -37,25 +49,24 @@ async function getOWMWateringData( coordinates: GeoCoordinates ): Promise< Water
|
||||
precip: totalPrecip / 25.4,
|
||||
raining: ( forecast.list[ 0 ].rain ? ( parseFloat( forecast.list[ 0 ].rain[ "3h" ] || 0 ) > 0 ) : false )
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function getOWMWeatherData( coordinates: GeoCoordinates ): Promise< WeatherData > {
|
||||
const OWM_API_KEY = process.env.OWM_API_KEY,
|
||||
currentUrl = "http://api.openweathermap.org/data/2.5/weather?appid=" + OWM_API_KEY + "&units=imperial&lat=" + coordinates[ 0 ] + "&lon=" + coordinates[ 1 ],
|
||||
forecastDailyUrl = "http://api.openweathermap.org/data/2.5/forecast/daily?appid=" + OWM_API_KEY + "&units=imperial&lat=" + coordinates[ 0 ] + "&lon=" + coordinates[ 1 ];
|
||||
public async getWeatherData( coordinates: GeoCoordinates ): Promise< WeatherData > {
|
||||
const currentUrl = `http://api.openweathermap.org/data/2.5/weather?appid=${ this.API_KEY }&units=imperial&lat=${ coordinates[ 0 ] }&lon=${ coordinates[ 1 ] }`,
|
||||
forecastDailyUrl = `http://api.openweathermap.org/data/2.5/forecast/daily?appid=${ this.API_KEY }&units=imperial&lat=${ coordinates[ 0 ] }&lon=${ coordinates[ 1 ] }`;
|
||||
|
||||
let current, forecast;
|
||||
try {
|
||||
current = await httpJSONRequest( currentUrl );
|
||||
forecast = await httpJSONRequest( forecastDailyUrl );
|
||||
} catch (err) {
|
||||
// Indicate watering data could not be retrieved if an API error occurs.
|
||||
return undefined;
|
||||
} catch ( err ) {
|
||||
console.error( "Error retrieving weather information from OWM:", err );
|
||||
throw "An error occurred while retrieving weather information from OWM."
|
||||
}
|
||||
|
||||
// Indicate watering data could not be retrieved if the forecast data is incomplete.
|
||||
if ( !current || !current.main || !current.wind || !current.weather || !forecast || !forecast.list ) {
|
||||
return undefined;
|
||||
throw "Necessary field(s) were missing from weather information returned by OWM.";
|
||||
}
|
||||
|
||||
const weather: WeatherData = {
|
||||
@@ -63,8 +74,8 @@ async function getOWMWeatherData( coordinates: GeoCoordinates ): Promise< Weathe
|
||||
temp: parseInt( current.main.temp ),
|
||||
humidity: parseInt( current.main.humidity ),
|
||||
wind: parseInt( current.wind.speed ),
|
||||
description: current.weather[0].description,
|
||||
icon: current.weather[0].icon,
|
||||
description: current.weather[ 0 ].description,
|
||||
icon: current.weather[ 0 ].icon,
|
||||
|
||||
region: forecast.city.country,
|
||||
city: forecast.city.name,
|
||||
@@ -85,10 +96,5 @@ async function getOWMWeatherData( coordinates: GeoCoordinates ): Promise< Weathe
|
||||
}
|
||||
|
||||
return weather;
|
||||
}
|
||||
}
|
||||
|
||||
const OWMWeatherProvider: WeatherProvider = {
|
||||
getWateringData: getOWMWateringData,
|
||||
getWeatherData: getOWMWeatherData
|
||||
};
|
||||
export default OWMWeatherProvider;
|
||||
|
||||
44
routes/weatherProviders/WUnderground.ts
Normal file
44
routes/weatherProviders/WUnderground.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
36
routes/weatherProviders/WeatherProvider.ts
Normal file
36
routes/weatherProviders/WeatherProvider.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
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, pws?: PWS ): Promise< ZimmermanWateringData > {
|
||||
throw "Selected WeatherProvider does not support getWateringData";
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the current weather data for usage in the mobile app.
|
||||
* @param coordinates The coordinates to retrieve the weather for
|
||||
* @return A Promise that will be resolved with the WeatherData if it is successfully retrieved,
|
||||
* or rejected with an error message if an error occurs while retrieving the WeatherData or the WeatherProvider does
|
||||
* not support this method.
|
||||
*/
|
||||
getWeatherData( coordinates : GeoCoordinates ): Promise< WeatherData > {
|
||||
throw "Selected WeatherProvider does not support getWeatherData";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a boolean indicating if watering scales calculated using data from this WeatherProvider should be cached
|
||||
* until the end of the day in timezone the data was for.
|
||||
* @return a boolean indicating if watering scales calculated using data from this WeatherProvider should be cached.
|
||||
*/
|
||||
shouldCacheWateringScale(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as express from "express";
|
||||
import { CronJob } from "cron";
|
||||
import { GeoCoordinates, WateringData, WeatherProvider } from "../../types";
|
||||
import { GeoCoordinates, ZimmermanWateringData } from "../../types";
|
||||
import { WeatherProvider } from "./WeatherProvider";
|
||||
|
||||
const count = { temp: 0, humidity: 0 };
|
||||
|
||||
@@ -43,9 +44,11 @@ export const captureWUStream = function( req: express.Request, res: express.Resp
|
||||
res.send( "success\n" );
|
||||
};
|
||||
|
||||
export const getLocalWateringData = function(): WateringData {
|
||||
const result: WateringData = {
|
||||
...yesterday as WateringData,
|
||||
export default class LocalWeatherProvider extends WeatherProvider {
|
||||
|
||||
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
|
||||
@@ -58,7 +61,8 @@ export const getLocalWateringData = function(): WateringData {
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
new CronJob( "0 0 0 * * *", function() {
|
||||
|
||||
@@ -74,10 +78,3 @@ interface PWSStatus {
|
||||
humidity?: number;
|
||||
precip?: number;
|
||||
}
|
||||
|
||||
const LocalWeatherProvider: WeatherProvider = {
|
||||
getWateringData: async function ( coordinates: GeoCoordinates ) {
|
||||
return getLocalWateringData();
|
||||
}
|
||||
};
|
||||
export default LocalWeatherProvider;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
{
|
||||
"noWeather": {
|
||||
"01002": {
|
||||
"scale": -1,
|
||||
"rd": -1,
|
||||
"tz": 32,
|
||||
"sunrise": 332,
|
||||
"sunset": 1203,
|
||||
@@ -12,7 +10,6 @@
|
||||
"adjustment1": {
|
||||
"01002": {
|
||||
"scale": 0,
|
||||
"rd": -1,
|
||||
"tz": 32,
|
||||
"sunrise": 332,
|
||||
"sunset": 1203,
|
||||
|
||||
53
types.ts
53
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,57 +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 interface AdjustmentOptions {
|
||||
/** Base humidity (as a percentage). */
|
||||
bh?: number;
|
||||
/** Base temperature (in Fahrenheit). */
|
||||
bt?: number;
|
||||
/** Base precipitation (in inches). */
|
||||
br?: number;
|
||||
/** The percentage to weight the humidity factor by. */
|
||||
h?: number;
|
||||
/** The percentage to weight the temperature factor by. */
|
||||
t?: number;
|
||||
/** The percentage to weight the precipitation factor by. */
|
||||
r?: number;
|
||||
/** The rain delay to use (in hours). */
|
||||
d?: number;
|
||||
}
|
||||
|
||||
export interface WeatherProvider {
|
||||
/**
|
||||
* Retrieves weather data necessary for 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 resolved with undefined if an error occurs while retrieving the WateringData.
|
||||
*/
|
||||
getWateringData?( coordinates : GeoCoordinates ): Promise< WateringData >;
|
||||
|
||||
/**
|
||||
* Retrieves the current weather data for usage in the mobile app.
|
||||
* @param coordinates The coordinates to retrieve the weather for
|
||||
* @return A Promise that will be resolved with the WeatherData if it is successfully retrieved,
|
||||
* or resolved with undefined if an error occurs while retrieving the WeatherData.
|
||||
*/
|
||||
getWeatherData?( coordinates : GeoCoordinates ): Promise< WeatherData >;
|
||||
}
|
||||
|
||||
export type WeatherProviderId = "OWM" | "DarkSky" | "local";
|
||||
export type WeatherProviderId = "OWM" | "DarkSky" | "local" | "mock" | "WUnderground";
|
||||
|
||||
Reference in New Issue
Block a user