Merge pull request #76 from OpenSprinkler/dev

Merge `dev` into `master`
This commit is contained in:
Matthew Oslan
2019-07-16 17:28:35 -04:00
committed by GitHub
16 changed files with 816 additions and 396 deletions

69
WateringScaleCache.ts Normal file
View 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
View File

@@ -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",

View File

@@ -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",

View 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;
}

View 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;

View 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;

View 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;

View File

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

View File

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

View File

@@ -1,113 +1,118 @@
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 {
// 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, private readonly API_KEY: string;
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; public constructor() {
try { super();
yesterdayData = await httpJSONRequest( yesterdayUrl ); this.API_KEY = process.env.DARKSKY_API_KEY;
todayData = await httpJSONRequest( todayUrl ); if (!this.API_KEY) {
} catch (err) { throw "DARKSKY_API_KEY environment variable is not defined.";
// Indicate watering data could not be retrieved if an API error occurs. }
return undefined;
} }
if ( !todayData.hourly || !todayData.hourly.data || !yesterdayData.hourly || !yesterdayData.hourly.data ) { public async getWateringData( coordinates: GeoCoordinates ): Promise< ZimmermanWateringData > {
return undefined; // The Unix timestamp of 24 hours ago.
const yesterdayTimestamp: number = moment().subtract( 1, "day" ).unix();
const yesterdayUrl = `https://api.darksky.net/forecast/${ this.API_KEY }/${ coordinates[ 0 ] },${ coordinates[ 1 ] },${ yesterdayTimestamp }?exclude=currently,minutely,daily,alerts,flags`;
let yesterdayData;
try {
yesterdayData = await httpJSONRequest( yesterdayUrl );
} catch ( err ) {
console.error( "Error retrieving weather information from Dark Sky:", err );
throw "An error occurred while retrieving weather information from Dark Sky."
}
if ( !yesterdayData.hourly || !yesterdayData.hourly.data ) {
throw "Necessary field(s) were missing from weather information returned by Dark Sky.";
}
const samples = [
...yesterdayData.hourly.data
];
// Fail if not enough data is available.
if ( samples.length !== 24 ) {
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;
// This field may be missing from the response if it is snowing.
totals.precip += sample.precipIntensity || 0;
}
return {
weatherProvider: "DarkSky",
temp: totals.temp / 24,
humidity: totals.humidity / 24 * 100,
precip: totals.precip,
raining: samples[ samples.length - 1 ].precipIntensity > 0
};
} }
/* The number of hourly forecasts to use from today's data. This will only include elements that contain historic public async getWeatherData( coordinates: GeoCoordinates ): Promise< WeatherData > {
data (not forecast data). */ const forecastUrl = `https://api.darksky.net/forecast/${ this.API_KEY }/${ coordinates[ 0 ] },${ coordinates[ 1 ] }?exclude=minutely,alerts,flags`;
// 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 let forecast;
the remaining data from the last elements of yesterday's data. */ try {
const samples = [ forecast = await httpJSONRequest( forecastUrl );
...yesterdayData.hourly.data.slice( todayElements - 24 ), } catch ( err ) {
...todayData.hourly.data.slice( 0, todayElements ) console.error( "Error retrieving weather information from Dark Sky:", err );
]; throw "An error occurred while retrieving weather information from Dark Sky."
}
// Fail if not enough data is available. if ( !forecast.currently || !forecast.daily || !forecast.daily.data ) {
if ( samples.length !== 24 ) { throw "Necessary field(s) were missing from weather information returned by Dark Sky.";
return undefined; }
}
const totals = { temp: 0, humidity: 0, precip: 0 }; const weather: WeatherData = {
for ( const sample of samples ) { weatherProvider: "DarkSky",
totals.temp += sample.temperature; temp: Math.floor( forecast.currently.temperature ),
totals.humidity += sample.humidity; humidity: Math.floor( forecast.currently.humidity * 100 ),
totals.precip += sample.precipIntensity wind: Math.floor( forecast.currently.windSpeed ),
} description: forecast.currently.summary,
return {
weatherProvider: "DarkSky",
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`;
let forecast;
try {
forecast = await httpJSONRequest( forecastUrl );
} catch (err) {
// Indicate weather data could not be retrieved if an API error occurs.
return undefined;
}
if ( !forecast.currently || !forecast.daily || !forecast.daily.data ) {
return undefined;
}
const weather: WeatherData = {
weatherProvider: "DarkSky",
temp: Math.floor( forecast.currently.temperature ),
humidity: Math.floor( forecast.currently.humidity * 100 ),
wind: Math.floor( forecast.currently.windSpeed ),
description: forecast.currently.summary,
// TODO set this
icon: "",
region: "",
city: "",
minTemp: Math.floor( forecast.daily.data[ 0 ].temperatureMin ),
maxTemp: Math.floor( forecast.daily.data[ 0 ].temperatureMax ),
precip: forecast.daily.data[ 0 ].precipIntensity * 24,
forecast: [ ]
};
for ( let index = 0; index < forecast.daily.data.length; index++ ) {
weather.forecast.push( {
temp_min: Math.floor( forecast.daily.data[ index ].temperatureMin ),
temp_max: Math.floor( forecast.daily.data[ index ].temperatureMax ),
date: forecast.daily.data[ index ].time,
// TODO set this // TODO set this
icon: "", icon: "",
description: forecast.daily.data[ index ].summary
} ); region: "",
city: "",
minTemp: Math.floor( forecast.daily.data[ 0 ].temperatureMin ),
maxTemp: Math.floor( forecast.daily.data[ 0 ].temperatureMax ),
precip: forecast.daily.data[ 0 ].precipIntensity * 24,
forecast: []
};
for ( let index = 0; index < forecast.daily.data.length; index++ ) {
weather.forecast.push( {
temp_min: Math.floor( forecast.daily.data[ index ].temperatureMin ),
temp_max: Math.floor( forecast.daily.data[ index ].temperatureMax ),
date: forecast.daily.data[ index ].time,
// TODO set this
icon: "",
description: forecast.daily.data[ index ].summary
} );
}
return weather;
} }
return weather; public shouldCacheWateringScale(): boolean {
return true;
}
} }
const DarkSkyWeatherProvider: WeatherProvider = {
getWateringData: getDarkSkyWateringData,
getWeatherData: getDarkSkyWeatherData
};
export default DarkSkyWeatherProvider;

View File

@@ -1,94 +1,100 @@
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 ];
// Perform the HTTP request to retrieve the weather data private readonly API_KEY: string;
let forecast;
try { public constructor() {
forecast = await httpJSONRequest( forecastUrl ); super();
} catch (err) { this.API_KEY = process.env.OWM_API_KEY;
// Indicate watering data could not be retrieved if an API error occurs. if (!this.API_KEY) {
return undefined; throw "OWM_API_KEY environment variable is not defined.";
}
} }
// Indicate watering data could not be retrieved if the forecast data is incomplete. public async getWateringData( coordinates: GeoCoordinates ): Promise< ZimmermanWateringData > {
if ( !forecast || !forecast.list ) { const forecastUrl = `http://api.openweathermap.org/data/2.5/forecast?appid=${ this.API_KEY }&units=imperial&lat=${ coordinates[ 0 ] }&lon=${ coordinates[ 1 ] }`;
return undefined;
// Perform the HTTP request to retrieve the weather data
let forecast;
try {
forecast = await httpJSONRequest( forecastUrl );
} 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 ) {
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 );
for ( let index = 0; index < periods; index++ ) {
totalTemp += parseFloat( forecast.list[ index ].main.temp );
totalHumidity += parseInt( forecast.list[ index ].main.humidity );
totalPrecip += ( forecast.list[ index ].rain ? parseFloat( forecast.list[ index ].rain[ "3h" ] || 0 ) : 0 );
}
return {
weatherProvider: "OWM",
temp: totalTemp / periods,
humidity: totalHumidity / periods,
precip: totalPrecip / 25.4,
raining: ( forecast.list[ 0 ].rain ? ( parseFloat( forecast.list[ 0 ].rain[ "3h" ] || 0 ) > 0 ) : false )
};
} }
let totalTemp = 0, public async getWeatherData( coordinates: GeoCoordinates ): Promise< WeatherData > {
totalHumidity = 0, const currentUrl = `http://api.openweathermap.org/data/2.5/weather?appid=${ this.API_KEY }&units=imperial&lat=${ coordinates[ 0 ] }&lon=${ coordinates[ 1 ] }`,
totalPrecip = 0; forecastDailyUrl = `http://api.openweathermap.org/data/2.5/forecast/daily?appid=${ this.API_KEY }&units=imperial&lat=${ coordinates[ 0 ] }&lon=${ coordinates[ 1 ] }`;
const periods = Math.min(forecast.list.length, 8); let current, forecast;
for ( let index = 0; index < periods; index++ ) { try {
totalTemp += parseFloat( forecast.list[ index ].main.temp ); current = await httpJSONRequest( currentUrl );
totalHumidity += parseInt( forecast.list[ index ].main.humidity ); forecast = await httpJSONRequest( forecastDailyUrl );
totalPrecip += ( forecast.list[ index ].rain ? parseFloat( forecast.list[ index ].rain[ "3h" ] || 0 ) : 0 ); } 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 ) {
throw "Necessary field(s) were missing from weather information returned by OWM.";
}
const weather: WeatherData = {
weatherProvider: "OWM",
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,
region: forecast.city.country,
city: forecast.city.name,
minTemp: parseInt( forecast.list[ 0 ].temp.min ),
maxTemp: parseInt( forecast.list[ 0 ].temp.max ),
precip: ( forecast.list[ 0 ].rain ? parseFloat( forecast.list[ 0 ].rain || 0 ) : 0 ) / 25.4,
forecast: []
};
for ( let index = 0; index < forecast.list.length; index++ ) {
weather.forecast.push( {
temp_min: parseInt( forecast.list[ index ].temp.min ),
temp_max: parseInt( forecast.list[ index ].temp.max ),
date: parseInt( forecast.list[ index ].dt ),
icon: forecast.list[ index ].weather[ 0 ].icon,
description: forecast.list[ index ].weather[ 0 ].description
} );
}
return weather;
} }
return {
weatherProvider: "OWM",
temp: totalTemp / periods,
humidity: totalHumidity / periods,
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 ];
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;
}
// 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;
}
const weather: WeatherData = {
weatherProvider: "OWM",
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,
region: forecast.city.country,
city: forecast.city.name,
minTemp: parseInt( forecast.list[ 0 ].temp.min ),
maxTemp: parseInt( forecast.list[ 0 ].temp.max ),
precip: ( forecast.list[ 0 ].rain ? parseFloat( forecast.list[ 0 ].rain || 0 ) : 0 ) / 25.4,
forecast: []
};
for ( let index = 0; index < forecast.list.length; index++ ) {
weather.forecast.push( {
temp_min: parseInt( forecast.list[ index ].temp.min ),
temp_max: parseInt( forecast.list[ index ].temp.max ),
date: parseInt( forecast.list[ index ].dt ),
icon: forecast.list[ index ].weather[ 0 ].icon,
description: forecast.list[ index ].weather[ 0 ].description
} );
}
return weather;
}
const OWMWeatherProvider: WeatherProvider = {
getWateringData: getOWMWateringData,
getWeatherData: getOWMWeatherData
};
export default OWMWeatherProvider;

View File

@@ -0,0 +1,44 @@
import { GeoCoordinates, PWS, WeatherData, ZimmermanWateringData } from "../../types";
import { WeatherProvider } from "./WeatherProvider";
import { httpJSONRequest } from "../weather";
export default class WUnderground extends WeatherProvider {
async getWateringData( coordinates: GeoCoordinates, pws?: PWS ): Promise< ZimmermanWateringData > {
if ( !pws ) {
throw "WUnderground WeatherProvider requires a PWS to be specified.";
}
const url = `https://api.weather.com/v2/pws/observations/hourly/7day?stationId=${ pws.id }&format=json&units=e&apiKey=${ pws.apiKey }`;
let data;
try {
data = await httpJSONRequest( url );
} catch ( err ) {
console.error( "Error retrieving weather information from WUnderground:", err );
throw "An error occurred while retrieving weather information from WUnderground."
}
// Take the 24 most recent observations.
const samples = data.observations.slice( -24 );
// Fail if not enough data is available.
if ( samples.length !== 24 ) {
throw "Insufficient data was returned by WUnderground.";
}
const totals = { temp: 0, humidity: 0, precip: 0 };
for ( const sample of samples ) {
totals.temp += sample.imperial.tempAvg;
totals.humidity += sample.humidityAvg;
totals.precip += sample.imperial.precipRate;
}
return {
weatherProvider: "WUnderground",
temp: totals.temp / samples.length,
humidity: totals.humidity / samples.length,
precip: totals.precip,
raining: samples[ samples.length - 1 ].imperial.precipRate > 0
}
}
}

View File

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

View File

@@ -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,22 +44,25 @@ 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 > {
// Use today's weather if we dont have information for yesterday yet (i.e. on startup) const result: ZimmermanWateringData = {
...today, ...yesterday as ZimmermanWateringData,
// PWS report "buckets" so consider it still raining if last bucket was less than an hour ago // Use today's weather if we dont have information for yesterday yet (i.e. on startup)
raining: last_bucket !== undefined ? ( ( Date.now() - +last_bucket ) / 1000 / 60 / 60 < 1 ) : undefined, ...today,
weatherProvider: "local" // PWS report "buckets" so consider it still raining if last bucket was less than an hour ago
raining: last_bucket !== undefined ? ( ( Date.now() - +last_bucket ) / 1000 / 60 / 60 < 1 ) : undefined,
weatherProvider: "local"
};
if ( "precip" in yesterday && "precip" in today ) {
result.precip = yesterday.precip + today.precip;
}
return result;
}; };
}
if ( "precip" in yesterday && "precip" in today ) {
result.precip = yesterday.precip + today.precip;
}
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;

View File

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

View File

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