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",
|
"name": "os-weather-service",
|
||||||
"version": "1.0.2",
|
"version": "1.0.4",
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -127,6 +127,15 @@
|
|||||||
"integrity": "sha512-Fvm24+u85lGmV4hT5G++aht2C5I4Z4dYlWZIh62FAfFO/TfzXtPpoLI6I7AuBWkIFqZCnhFOoTT7RjjaIL5Fjg==",
|
"integrity": "sha512-Fvm24+u85lGmV4hT5G++aht2C5I4Z4dYlWZIh62FAfFO/TfzXtPpoLI6I7AuBWkIFqZCnhFOoTT7RjjaIL5Fjg==",
|
||||||
"dev": true
|
"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": {
|
"@types/range-parser": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz",
|
||||||
@@ -571,6 +580,11 @@
|
|||||||
"integrity": "sha1-T6kXw+WclKAEzWH47lCdplFocUM=",
|
"integrity": "sha1-T6kXw+WclKAEzWH47lCdplFocUM=",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"clone": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||||
|
"integrity": "sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18="
|
||||||
|
},
|
||||||
"collection-visit": {
|
"collection-visit": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz",
|
||||||
@@ -1126,8 +1140,7 @@
|
|||||||
"ansi-regex": {
|
"ansi-regex": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
"dev": true,
|
"dev": true
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"aproba": {
|
"aproba": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
@@ -1148,14 +1161,12 @@
|
|||||||
"balanced-match": {
|
"balanced-match": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
"dev": true,
|
"dev": true
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"brace-expansion": {
|
"brace-expansion": {
|
||||||
"version": "1.1.11",
|
"version": "1.1.11",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"optional": true,
|
|
||||||
"requires": {
|
"requires": {
|
||||||
"balanced-match": "^1.0.0",
|
"balanced-match": "^1.0.0",
|
||||||
"concat-map": "0.0.1"
|
"concat-map": "0.0.1"
|
||||||
@@ -1170,20 +1181,17 @@
|
|||||||
"code-point-at": {
|
"code-point-at": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
"dev": true,
|
"dev": true
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"concat-map": {
|
"concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
"dev": true,
|
"dev": true
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"console-control-strings": {
|
"console-control-strings": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
"dev": true,
|
"dev": true
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"core-util-is": {
|
"core-util-is": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
@@ -1300,8 +1308,7 @@
|
|||||||
"inherits": {
|
"inherits": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
"dev": true,
|
"dev": true
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"ini": {
|
"ini": {
|
||||||
"version": "1.3.5",
|
"version": "1.3.5",
|
||||||
@@ -1313,7 +1320,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"optional": true,
|
|
||||||
"requires": {
|
"requires": {
|
||||||
"number-is-nan": "^1.0.0"
|
"number-is-nan": "^1.0.0"
|
||||||
}
|
}
|
||||||
@@ -1328,7 +1334,6 @@
|
|||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"optional": true,
|
|
||||||
"requires": {
|
"requires": {
|
||||||
"brace-expansion": "^1.1.7"
|
"brace-expansion": "^1.1.7"
|
||||||
}
|
}
|
||||||
@@ -1336,14 +1341,12 @@
|
|||||||
"minimist": {
|
"minimist": {
|
||||||
"version": "0.0.8",
|
"version": "0.0.8",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
"dev": true,
|
"dev": true
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"minipass": {
|
"minipass": {
|
||||||
"version": "2.3.5",
|
"version": "2.3.5",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"optional": true,
|
|
||||||
"requires": {
|
"requires": {
|
||||||
"safe-buffer": "^5.1.2",
|
"safe-buffer": "^5.1.2",
|
||||||
"yallist": "^3.0.0"
|
"yallist": "^3.0.0"
|
||||||
@@ -1362,7 +1365,6 @@
|
|||||||
"version": "0.5.1",
|
"version": "0.5.1",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"optional": true,
|
|
||||||
"requires": {
|
"requires": {
|
||||||
"minimist": "0.0.8"
|
"minimist": "0.0.8"
|
||||||
}
|
}
|
||||||
@@ -1443,8 +1445,7 @@
|
|||||||
"number-is-nan": {
|
"number-is-nan": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
"dev": true,
|
"dev": true
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"object-assign": {
|
"object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
@@ -1456,7 +1457,6 @@
|
|||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"optional": true,
|
|
||||||
"requires": {
|
"requires": {
|
||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
@@ -1542,8 +1542,7 @@
|
|||||||
"safe-buffer": {
|
"safe-buffer": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
"dev": true,
|
"dev": true
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"safer-buffer": {
|
"safer-buffer": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
@@ -1579,7 +1578,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"optional": true,
|
|
||||||
"requires": {
|
"requires": {
|
||||||
"code-point-at": "^1.0.0",
|
"code-point-at": "^1.0.0",
|
||||||
"is-fullwidth-code-point": "^1.0.0",
|
"is-fullwidth-code-point": "^1.0.0",
|
||||||
@@ -1599,7 +1597,6 @@
|
|||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"optional": true,
|
|
||||||
"requires": {
|
"requires": {
|
||||||
"ansi-regex": "^2.0.0"
|
"ansi-regex": "^2.0.0"
|
||||||
}
|
}
|
||||||
@@ -1643,14 +1640,12 @@
|
|||||||
"wrappy": {
|
"wrappy": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
"dev": true,
|
"dev": true
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"yallist": {
|
"yallist": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
"dev": true,
|
"dev": true
|
||||||
"optional": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2091,8 +2086,7 @@
|
|||||||
"lodash": {
|
"lodash": {
|
||||||
"version": "4.17.11",
|
"version": "4.17.11",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz",
|
||||||
"integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==",
|
"integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"lowercase-keys": {
|
"lowercase-keys": {
|
||||||
"version": "1.0.1",
|
"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": {
|
"node-watch": {
|
||||||
"version": "0.6.2",
|
"version": "0.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/node-watch/-/node-watch-0.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/node-watch/-/node-watch-0.6.2.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"geo-tz": "^5.0.4",
|
"geo-tz": "^5.0.4",
|
||||||
"mockdate": "^2.0.2",
|
"mockdate": "^2.0.2",
|
||||||
"moment-timezone": "^0.5.25",
|
"moment-timezone": "^0.5.25",
|
||||||
|
"node-cache": "^4.2.0",
|
||||||
"suncalc": "^1.8.0"
|
"suncalc": "^1.8.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
"@types/mocha": "^5.2.6",
|
"@types/mocha": "^5.2.6",
|
||||||
"@types/moment-timezone": "^0.5.12",
|
"@types/moment-timezone": "^0.5.12",
|
||||||
"@types/node": "^10.14.6",
|
"@types/node": "^10.14.6",
|
||||||
|
"@types/node-cache": "^4.1.3",
|
||||||
"@types/suncalc": "^1.8.0",
|
"@types/suncalc": "^1.8.0",
|
||||||
"chai": "^4.2.0",
|
"chai": "^4.2.0",
|
||||||
"mocha": "^5.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 MockExpressResponse from 'mock-express-response';
|
||||||
import * as MockDate from 'mockdate';
|
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 { getWateringData } from './weather';
|
||||||
|
import { GeoCoordinates, ZimmermanWateringData, WeatherData } from "../types";
|
||||||
|
import { WeatherProvider } from "./weatherProviders/WeatherProvider";
|
||||||
|
|
||||||
const expected = require( '../test/expected.json' );
|
const expected = require( '../test/expected.json' );
|
||||||
const replies = require( '../test/replies.json' );
|
const replies = require( '../test/replies.json' );
|
||||||
@@ -59,3 +65,42 @@ function mockOWM() {
|
|||||||
.get( "/" )
|
.get( "/" )
|
||||||
.reply( 200, replies[location].OWMData );
|
.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 moment from "moment-timezone";
|
||||||
import * as geoTZ from "geo-tz";
|
import * as geoTZ from "geo-tz";
|
||||||
|
|
||||||
import { AdjustmentOptions, GeoCoordinates, TimeData, WateringData, WeatherData, WeatherProvider } from "../types";
|
import { BaseWateringData, GeoCoordinates, PWS, TimeData, WeatherData } from "../types";
|
||||||
const weatherProvider: WeatherProvider = require("./weatherProviders/" + ( process.env.WEATHER_PROVIDER || "OWM" ) ).default;
|
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
|
// Define regex filters to match against location
|
||||||
const filters = {
|
const filters = {
|
||||||
@@ -17,13 +24,15 @@ const filters = {
|
|||||||
timezone: /^()()()()()()([+-])(\d{2})(\d{2})/
|
timezone: /^()()()()()()([+-])(\d{2})(\d{2})/
|
||||||
};
|
};
|
||||||
|
|
||||||
// Enum of available watering scale adjustment methods.
|
/** AdjustmentMethods mapped to their numeric IDs. */
|
||||||
const ADJUSTMENT_METHOD = {
|
const ADJUSTMENT_METHOD: { [ key: number ] : AdjustmentMethod } = {
|
||||||
MANUAL: 0,
|
0: ManualAdjustmentMethod,
|
||||||
ZIMMERMAN: 1,
|
1: ZimmermanAdjustmentMethod,
|
||||||
RAIN_DELAY: 2
|
2: RainDelayAdjustmentMethod
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cache = new WateringScaleCache();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a location description to geographic coordinates.
|
* Resolves a location description to geographic coordinates.
|
||||||
* @param location A partial zip/city/country or a coordinate pair.
|
* @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 ) ) {
|
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 ] ) ];
|
||||||
@@ -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.
|
* Makes an HTTP/HTTPS GET request to the specified URL and parses the JSON response body.
|
||||||
* @param url The URL to fetch.
|
* @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
|
* @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 > {
|
export async function httpJSONRequest(url: string ): Promise< any > {
|
||||||
try {
|
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
|
* 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:
|
* 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.
|
* @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.
|
* @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;
|
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 ) {
|
export const getWeatherData = async function( req: express.Request, res: express.Response ) {
|
||||||
const location: string = getParameter(req.query.loc);
|
const location: string = getParameter(req.query.loc);
|
||||||
|
|
||||||
if ( !weatherProvider.getWeatherData ) {
|
|
||||||
res.send( "Error: selected WeatherProvider does not support getWeatherData" );
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let coordinates: GeoCoordinates;
|
let coordinates: GeoCoordinates;
|
||||||
try {
|
try {
|
||||||
coordinates = await resolveCoordinates( location );
|
coordinates = await resolveCoordinates( location );
|
||||||
@@ -196,7 +156,13 @@ export const getWeatherData = async function( req: express.Request, res: express
|
|||||||
|
|
||||||
// Continue with the weather request
|
// Continue with the weather request
|
||||||
const timeData: TimeData = getTimeData( coordinates );
|
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( {
|
res.json( {
|
||||||
...timeData,
|
...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
|
// The adjustment method is encoded by the OpenSprinkler firmware and must be
|
||||||
// parsed. This allows the adjustment method and the restriction type to both
|
// parsed. This allows the adjustment method and the restriction type to both
|
||||||
// be saved in the same byte.
|
// 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,
|
checkRestrictions: boolean = ( ( req.params[ 0 ] >> 7 ) & 1 ) > 0,
|
||||||
adjustmentOptionsString: string = getParameter(req.query.wto),
|
adjustmentOptionsString: string = getParameter(req.query.wto),
|
||||||
location: string | GeoCoordinates = getParameter(req.query.loc),
|
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,
|
remoteAddress: string = getParameter(req.headers[ "x-forwarded-for" ]) || req.connection.remoteAddress,
|
||||||
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
|
||||||
// the string is split against a comma and the first value is selected
|
// the string is split against a comma and the first value is selected
|
||||||
remoteAddress = remoteAddress.split( "," )[ 0 ];
|
remoteAddress = remoteAddress.split( "," )[ 0 ];
|
||||||
@@ -250,85 +215,148 @@ export const getWateringData = async function( req: express.Request, res: expres
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Continue with the weather request
|
|
||||||
let timeData: TimeData = getTimeData( coordinates );
|
let timeData: TimeData = getTimeData( coordinates );
|
||||||
let wateringData: WateringData;
|
|
||||||
if ( adjustmentMethod !== ADJUSTMENT_METHOD.MANUAL || checkRestrictions ) {
|
// Parse the PWS information.
|
||||||
if ( !weatherProvider.getWateringData ) {
|
let pws: PWS | undefined = undefined;
|
||||||
res.send( "Error: selected WeatherProvider does not support getWateringData" );
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
wateringData = await weatherProvider.getWateringData( coordinates );
|
pws = { id: pwsId, apiKey: apiKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
let scale = -1, rainDelay = -1;
|
const weatherProvider = pws ? PWS_WEATHER_PROVIDER : WEATHER_PROVIDER;
|
||||||
|
|
||||||
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 data = {
|
const data = {
|
||||||
scale: scale,
|
scale: undefined,
|
||||||
rd: rainDelay,
|
rd: undefined,
|
||||||
tz: getTimezone( timeData.timezone, undefined ),
|
tz: getTimezone( timeData.timezone, undefined ),
|
||||||
sunrise: timeData.sunrise,
|
sunrise: timeData.sunrise,
|
||||||
sunset: timeData.sunset,
|
sunset: timeData.sunset,
|
||||||
eip: ipToInt( remoteAddress ),
|
eip: ipToInt( remoteAddress ),
|
||||||
rawData: undefined
|
rawData: undefined,
|
||||||
|
error: undefined
|
||||||
};
|
};
|
||||||
|
|
||||||
if ( adjustmentMethod > ADJUSTMENT_METHOD.MANUAL ) {
|
let cachedScale: CachedScale;
|
||||||
data.rawData = {
|
if ( weatherProvider.shouldCacheWateringScale() ) {
|
||||||
h: wateringData ? Math.round( wateringData.humidity * 100) / 100 : null,
|
cachedScale = cache.getWateringScale( req.params[ 0 ], coordinates, pws, adjustmentOptions );
|
||||||
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
|
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
|
// Return the response to the client in the requested format
|
||||||
if ( outputFormat === "json" ) {
|
if ( outputFormat === "json" ) {
|
||||||
res.json( data );
|
res.json( data );
|
||||||
} else {
|
} else {
|
||||||
res.send( "&scale=" + data.scale +
|
// Return the data formatted as a URL query string.
|
||||||
"&rd=" + data.rd +
|
let formatted = "";
|
||||||
"&tz=" + data.tz +
|
for ( const key in data ) {
|
||||||
"&sunrise=" + data.sunrise +
|
// Skip inherited properties.
|
||||||
"&sunset=" + data.sunset +
|
if ( !data.hasOwnProperty( key ) ) {
|
||||||
"&eip=" + data.eip +
|
continue;
|
||||||
( data.rawData ? "&rawData=" + JSON.stringify( data.rawData ) : "" )
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
* Makes an HTTP/HTTPS GET request to the specified URL and returns the response body.
|
||||||
* @param url The URL to fetch.
|
* @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
|
* @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 > {
|
async function httpRequest( url: string ): Promise< string > {
|
||||||
return new Promise< any >( ( resolve, reject ) => {
|
return new Promise< any >( ( resolve, reject ) => {
|
||||||
@@ -343,6 +371,11 @@ async function httpRequest( url: string ): Promise< string > {
|
|||||||
};
|
};
|
||||||
|
|
||||||
( isHttps ? https : http ).get( options, ( response ) => {
|
( isHttps ? https : http ).get( options, ( response ) => {
|
||||||
|
if ( response.statusCode !== 200 ) {
|
||||||
|
reject( `Received ${ response.statusCode } status code for URL '${ url }'.` );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let data = "";
|
let data = "";
|
||||||
|
|
||||||
// Reassemble the data as it comes in
|
// Reassemble the data as it comes in
|
||||||
@@ -368,7 +401,7 @@ async function httpRequest( url: string ): Promise< string > {
|
|||||||
* @param obj The object to check.
|
* @param obj The object to check.
|
||||||
* @return A boolean indicating if the object has numeric values for all of the specified keys.
|
* @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;
|
let key: string;
|
||||||
|
|
||||||
// Return false if the object is null/undefined.
|
// Return false if the object is null/undefined.
|
||||||
|
|||||||
@@ -1,52 +1,60 @@
|
|||||||
import * as moment from "moment-timezone";
|
import * as moment from "moment-timezone";
|
||||||
|
|
||||||
import { GeoCoordinates, WateringData, WeatherData, WeatherProvider } from "../../types";
|
import { GeoCoordinates, WeatherData, ZimmermanWateringData } from "../../types";
|
||||||
import { httpJSONRequest } from "../weather";
|
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.
|
// The Unix timestamp of 24 hours ago.
|
||||||
const yesterdayTimestamp: number = moment().subtract( 1, "day" ).unix();
|
const yesterdayTimestamp: number = moment().subtract( 1, "day" ).unix();
|
||||||
const todayTimestamp: number = moment().unix();
|
|
||||||
|
|
||||||
const DARKSKY_API_KEY = process.env.DARKSKY_API_KEY,
|
const yesterdayUrl = `https://api.darksky.net/forecast/${ this.API_KEY }/${ coordinates[ 0 ] },${ coordinates[ 1 ] },${ yesterdayTimestamp }?exclude=currently,minutely,daily,alerts,flags`;
|
||||||
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`;
|
|
||||||
|
|
||||||
let yesterdayData, todayData;
|
let yesterdayData;
|
||||||
try {
|
try {
|
||||||
yesterdayData = await httpJSONRequest( yesterdayUrl );
|
yesterdayData = await httpJSONRequest( yesterdayUrl );
|
||||||
todayData = await httpJSONRequest( todayUrl );
|
|
||||||
} catch ( err ) {
|
} catch ( err ) {
|
||||||
// Indicate watering data could not be retrieved if an API error occurs.
|
console.error( "Error retrieving weather information from Dark Sky:", err );
|
||||||
return undefined;
|
throw "An error occurred while retrieving weather information from Dark Sky."
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( !todayData.hourly || !todayData.hourly.data || !yesterdayData.hourly || !yesterdayData.hourly.data ) {
|
if ( !yesterdayData.hourly || !yesterdayData.hourly.data ) {
|
||||||
return undefined;
|
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 = [
|
const samples = [
|
||||||
...yesterdayData.hourly.data.slice( todayElements - 24 ),
|
...yesterdayData.hourly.data
|
||||||
...todayData.hourly.data.slice( 0, todayElements )
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Fail if not enough data is available.
|
// Fail if not enough data is available.
|
||||||
if ( samples.length !== 24 ) {
|
if ( samples.length !== 24 ) {
|
||||||
return undefined;
|
throw "Insufficient data was returned by Dark Sky.";
|
||||||
}
|
}
|
||||||
|
|
||||||
const totals = { temp: 0, humidity: 0, precip: 0 };
|
const totals = { temp: 0, humidity: 0, precip: 0 };
|
||||||
for ( const sample of samples ) {
|
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.temp += sample.temperature;
|
||||||
totals.humidity += sample.humidity;
|
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 {
|
return {
|
||||||
@@ -58,20 +66,19 @@ async function getDarkSkyWateringData( coordinates: GeoCoordinates ): Promise< W
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getDarkSkyWeatherData( coordinates: GeoCoordinates ): Promise< WeatherData > {
|
public async getWeatherData( coordinates: GeoCoordinates ): Promise< WeatherData > {
|
||||||
const DARKSKY_API_KEY = process.env.DARKSKY_API_KEY,
|
const forecastUrl = `https://api.darksky.net/forecast/${ this.API_KEY }/${ coordinates[ 0 ] },${ coordinates[ 1 ] }?exclude=minutely,alerts,flags`;
|
||||||
forecastUrl = `https://api.darksky.net/forecast/${DARKSKY_API_KEY}/${coordinates[0]},${coordinates[1]}?exclude=minutely,alerts,flags`;
|
|
||||||
|
|
||||||
let forecast;
|
let forecast;
|
||||||
try {
|
try {
|
||||||
forecast = await httpJSONRequest( forecastUrl );
|
forecast = await httpJSONRequest( forecastUrl );
|
||||||
} catch ( err ) {
|
} catch ( err ) {
|
||||||
// Indicate weather data could not be retrieved if an API error occurs.
|
console.error( "Error retrieving weather information from Dark Sky:", err );
|
||||||
return undefined;
|
throw "An error occurred while retrieving weather information from Dark Sky."
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( !forecast.currently || !forecast.daily || !forecast.daily.data ) {
|
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 = {
|
const weather: WeatherData = {
|
||||||
@@ -105,9 +112,7 @@ async function getDarkSkyWeatherData( coordinates: GeoCoordinates ): Promise< We
|
|||||||
return weather;
|
return weather;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public shouldCacheWateringScale(): boolean {
|
||||||
const DarkSkyWeatherProvider: WeatherProvider = {
|
return true;
|
||||||
getWateringData: getDarkSkyWateringData,
|
}
|
||||||
getWeatherData: getDarkSkyWeatherData
|
}
|
||||||
};
|
|
||||||
export default DarkSkyWeatherProvider;
|
|
||||||
|
|||||||
@@ -1,22 +1,34 @@
|
|||||||
import { GeoCoordinates, WateringData, WeatherData, WeatherProvider } from "../../types";
|
import { GeoCoordinates, WeatherData, ZimmermanWateringData } from "../../types";
|
||||||
import { httpJSONRequest } from "../weather";
|
import { httpJSONRequest } from "../weather";
|
||||||
|
import { WeatherProvider } from "./WeatherProvider";
|
||||||
|
|
||||||
async function getOWMWateringData( coordinates: GeoCoordinates ): Promise< WateringData > {
|
export default class OWMWeatherProvider extends WeatherProvider {
|
||||||
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 ];
|
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
|
// Perform the HTTP request to retrieve the weather data
|
||||||
let forecast;
|
let forecast;
|
||||||
try {
|
try {
|
||||||
forecast = await httpJSONRequest( forecastUrl );
|
forecast = await httpJSONRequest( forecastUrl );
|
||||||
} catch ( err ) {
|
} catch ( err ) {
|
||||||
// Indicate watering data could not be retrieved if an API error occurs.
|
console.error( "Error retrieving weather information from OWM:", err );
|
||||||
return undefined;
|
throw "An error occurred while retrieving weather information from OWM."
|
||||||
}
|
}
|
||||||
|
|
||||||
// Indicate watering data could not be retrieved if the forecast data is incomplete.
|
// Indicate watering data could not be retrieved if the forecast data is incomplete.
|
||||||
if ( !forecast || !forecast.list ) {
|
if ( !forecast || !forecast.list ) {
|
||||||
return undefined;
|
throw "Necessary field(s) were missing from weather information returned by OWM.";
|
||||||
}
|
}
|
||||||
|
|
||||||
let totalTemp = 0,
|
let totalTemp = 0,
|
||||||
@@ -39,23 +51,22 @@ async function getOWMWateringData( coordinates: GeoCoordinates ): Promise< Water
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getOWMWeatherData( coordinates: GeoCoordinates ): Promise< WeatherData > {
|
public async getWeatherData( coordinates: GeoCoordinates ): Promise< WeatherData > {
|
||||||
const OWM_API_KEY = process.env.OWM_API_KEY,
|
const currentUrl = `http://api.openweathermap.org/data/2.5/weather?appid=${ this.API_KEY }&units=imperial&lat=${ coordinates[ 0 ] }&lon=${ coordinates[ 1 ] }`,
|
||||||
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=${ this.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 ];
|
|
||||||
|
|
||||||
let current, forecast;
|
let current, forecast;
|
||||||
try {
|
try {
|
||||||
current = await httpJSONRequest( currentUrl );
|
current = await httpJSONRequest( currentUrl );
|
||||||
forecast = await httpJSONRequest( forecastDailyUrl );
|
forecast = await httpJSONRequest( forecastDailyUrl );
|
||||||
} catch ( err ) {
|
} catch ( err ) {
|
||||||
// Indicate watering data could not be retrieved if an API error occurs.
|
console.error( "Error retrieving weather information from OWM:", err );
|
||||||
return undefined;
|
throw "An error occurred while retrieving weather information from OWM."
|
||||||
}
|
}
|
||||||
|
|
||||||
// Indicate watering data could not be retrieved if the forecast data is incomplete.
|
// Indicate watering data could not be retrieved if the forecast data is incomplete.
|
||||||
if ( !current || !current.main || !current.wind || !current.weather || !forecast || !forecast.list ) {
|
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 = {
|
const weather: WeatherData = {
|
||||||
@@ -86,9 +97,4 @@ async function getOWMWeatherData( coordinates: GeoCoordinates ): Promise< Weathe
|
|||||||
|
|
||||||
return weather;
|
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 * as express from "express";
|
||||||
import { CronJob } from "cron";
|
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 };
|
const count = { temp: 0, humidity: 0 };
|
||||||
|
|
||||||
@@ -43,9 +44,11 @@ export const captureWUStream = function( req: express.Request, res: express.Resp
|
|||||||
res.send( "success\n" );
|
res.send( "success\n" );
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getLocalWateringData = function(): WateringData {
|
export default class LocalWeatherProvider extends WeatherProvider {
|
||||||
const result: WateringData = {
|
|
||||||
...yesterday as WateringData,
|
public async getWateringData( coordinates: GeoCoordinates ): Promise< ZimmermanWateringData > {
|
||||||
|
const result: ZimmermanWateringData = {
|
||||||
|
...yesterday as ZimmermanWateringData,
|
||||||
// Use today's weather if we dont have information for yesterday yet (i.e. on startup)
|
// Use today's weather if we dont have information for yesterday yet (i.e. on startup)
|
||||||
...today,
|
...today,
|
||||||
// PWS report "buckets" so consider it still raining if last bucket was less than an hour ago
|
// PWS report "buckets" so consider it still raining if last bucket was less than an hour ago
|
||||||
@@ -59,6 +62,7 @@ export const getLocalWateringData = function(): WateringData {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
new CronJob( "0 0 0 * * *", function() {
|
new CronJob( "0 0 0 * * *", function() {
|
||||||
|
|
||||||
@@ -74,10 +78,3 @@ interface PWSStatus {
|
|||||||
humidity?: number;
|
humidity?: number;
|
||||||
precip?: number;
|
precip?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LocalWeatherProvider: WeatherProvider = {
|
|
||||||
getWateringData: async function ( coordinates: GeoCoordinates ) {
|
|
||||||
return getLocalWateringData();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
export default LocalWeatherProvider;
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
{
|
{
|
||||||
"noWeather": {
|
"noWeather": {
|
||||||
"01002": {
|
"01002": {
|
||||||
"scale": -1,
|
|
||||||
"rd": -1,
|
|
||||||
"tz": 32,
|
"tz": 32,
|
||||||
"sunrise": 332,
|
"sunrise": 332,
|
||||||
"sunset": 1203,
|
"sunset": 1203,
|
||||||
@@ -12,7 +10,6 @@
|
|||||||
"adjustment1": {
|
"adjustment1": {
|
||||||
"01002": {
|
"01002": {
|
||||||
"scale": 0,
|
"scale": 0,
|
||||||
"rd": -1,
|
|
||||||
"tz": 32,
|
"tz": 32,
|
||||||
"sunrise": 332,
|
"sunrise": 332,
|
||||||
"sunset": 1203,
|
"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. */
|
/** 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).
|
||||||
@@ -50,57 +53,25 @@ export interface WeatherDataForecast {
|
|||||||
description: string;
|
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
|
* 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
|
* historic data from the past day, but may also use forecasted data for the next day if historical data is not
|
||||||
* available.
|
* available.
|
||||||
*/
|
*/
|
||||||
export interface WateringData {
|
export interface ZimmermanWateringData extends BaseWateringData {
|
||||||
/** The WeatherProvider that generated this data. */
|
|
||||||
weatherProvider: WeatherProviderId;
|
|
||||||
/** The average temperature over the window (in Fahrenheit). */
|
/** The average temperature over the window (in Fahrenheit). */
|
||||||
temp: number;
|
temp: number;
|
||||||
/** The average humidity over the window (as a percentage). */
|
/** The average humidity over the window (as a percentage). */
|
||||||
humidity: number;
|
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. */
|
/** A boolean indicating if it is raining at the time that this data was retrieved. */
|
||||||
raining: boolean;
|
raining: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdjustmentOptions {
|
export type WeatherProviderId = "OWM" | "DarkSky" | "local" | "mock" | "WUnderground";
|
||||||
/** 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";
|
|
||||||
|
|||||||
Reference in New Issue
Block a user