Merge pull request #40 from Derpthemeus/improve-weather-provider-handling

Improve weather provider handling
This commit is contained in:
Samer Albahra
2019-05-27 01:13:52 -05:00
committed by GitHub
6 changed files with 104 additions and 111 deletions

View File

@@ -5,7 +5,6 @@ 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 * as local from "./local";
import { AdjustmentOptions, GeoCoordinates, TimeData, WateringData, WeatherData, WeatherProvider } from "../types"; import { AdjustmentOptions, GeoCoordinates, TimeData, WateringData, WeatherData, WeatherProvider } from "../types";
const weatherProvider: WeatherProvider = require("./weatherProviders/" + ( process.env.WEATHER_PROVIDER || "OWM" ) ).default; const weatherProvider: WeatherProvider = require("./weatherProviders/" + ( process.env.WEATHER_PROVIDER || "OWM" ) ).default;
@@ -18,6 +17,13 @@ const filters = {
timezone: /^()()()()()()([+-])(\d{2})(\d{2})/ timezone: /^()()()()()()([+-])(\d{2})(\d{2})/
}; };
// Enum of available watering scale adjustment methods.
const ADJUSTMENT_METHOD = {
MANUAL: 0,
ZIMMERMAN: 1,
RAIN_DELAY: 2
};
/** /**
* 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.
@@ -52,7 +58,7 @@ async function resolveCoordinates( location: string ): Promise< GeoCoordinates >
if ( typeof data.RESULTS === "object" && data.RESULTS.length && data.RESULTS[ 0 ].tz !== "MISSING" ) { if ( typeof data.RESULTS === "object" && data.RESULTS.length && data.RESULTS[ 0 ].tz !== "MISSING" ) {
// If it is, reply with an array containing the GPS coordinates // If it is, reply with an array containing the GPS coordinates
return [ data.RESULTS[ 0 ].lat, data.RESULTS[ 0 ].lon ]; return [ parseFloat( data.RESULTS[ 0 ].lat ), parseFloat( data.RESULTS[ 0 ].lon ) ];
} else { } else {
// Otherwise, indicate no data was found // Otherwise, indicate no data was found
@@ -77,16 +83,6 @@ export async function httpJSONRequest(url: string ): Promise< any > {
} }
} }
/**
* Retrieves weather data necessary for watering level calculations from the a local record.
* @param coordinates The coordinates to retrieve the watering data for.
* @return A Promise that will be resolved with WateringData.
*/
async function getLocalWateringData( coordinates: GeoCoordinates ): Promise< WateringData > {
// TODO is this type assertion safe?
return local.getLocalWeather() as WateringData;
}
/** /**
* Calculates timezone and sunrise/sunset for the specified coordinates. * Calculates timezone and sunrise/sunset for the specified coordinates.
* @param coordinates The coordinates to use to calculate time data. * @param coordinates The coordinates to use to calculate time data.
@@ -110,17 +106,13 @@ function getTimeData( coordinates: GeoCoordinates ): TimeData {
} }
/** /**
* Calculates how much watering should be scaled based on weather and adjustment options. * Calculates how much watering should be scaled based on weather and adjustment options using the Zimmerman method.
* @param adjustmentMethod The method to use to calculate the watering percentage. The only supported method is 1, which
* corresponds to the Zimmerman method. If an invalid adjustmentMethod is used, this method will return -1.
* @param adjustmentOptions Options to tweak the calculation, or undefined/null if no custom values are to be used. * @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. * @param wateringData The weather to use to calculate watering percentage.
* @return The percentage that watering should be scaled by, or -1 if an invalid adjustmentMethod was provided. * @return The percentage that watering should be scaled by.
*/ */
function calculateWeatherScale( adjustmentMethod: number, adjustmentOptions: AdjustmentOptions, wateringData: WateringData ): number { function calculateZimmermanWateringScale( adjustmentOptions: AdjustmentOptions, wateringData: WateringData ): number {
// Zimmerman method
if ( adjustmentMethod === 1 ) {
let humidityBase = 30, tempBase = 70, precipBase = 0; let humidityBase = 30, tempBase = 70, precipBase = 0;
// Check to make sure valid data exists for all factors // Check to make sure valid data exists for all factors
@@ -135,9 +127,8 @@ function calculateWeatherScale( adjustmentMethod: number, adjustmentOptions: Adj
precipBase = adjustmentOptions.hasOwnProperty( "br" ) ? adjustmentOptions.br : precipBase; precipBase = adjustmentOptions.hasOwnProperty( "br" ) ? adjustmentOptions.br : precipBase;
} }
let temp = wateringData.temp, let humidityFactor = ( humidityBase - wateringData.humidity ),
humidityFactor = ( humidityBase - wateringData.humidity ), tempFactor = ( ( wateringData.temp - tempBase ) * 4 ),
tempFactor = ( ( temp - tempBase ) * 4 ),
precipFactor = ( ( precipBase - wateringData.precip ) * 200 ); precipFactor = ( ( precipBase - wateringData.precip ) * 200 );
// Apply adjustment options, if provided, by multiplying the percentage against the factor // Apply adjustment options, if provided, by multiplying the percentage against the factor
@@ -157,9 +148,6 @@ function calculateWeatherScale( adjustmentMethod: number, adjustmentOptions: Adj
// Apply all of the weather modifying factors and clamp the result between 0 and 200%. // 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 ) ); return Math.floor( Math.min( Math.max( 0, 100 + humidityFactor + tempFactor + precipFactor ), 200 ) );
}
return -1;
} }
/** /**
@@ -193,6 +181,11 @@ 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 );
@@ -256,47 +249,36 @@ export const getWateringData = async function( req: express.Request, res: expres
res.send(`Error: Unable to resolve location (${err})`); res.send(`Error: Unable to resolve location (${err})`);
return; return;
} }
location = coordinates;
// Continue with the weather request // Continue with the weather request
let timeData: TimeData = getTimeData( coordinates ); let timeData: TimeData = getTimeData( coordinates );
let wateringData: WateringData; let wateringData: WateringData;
if ( local.useLocalWeather() ) { if ( adjustmentMethod !== ADJUSTMENT_METHOD.MANUAL ) {
wateringData = await getLocalWateringData( coordinates ); if ( !weatherProvider.getWateringData ) {
} else if ( adjustmentMethod !== 0 || checkRestrictions ) { res.send( "Error: selected WeatherProvider does not support getWateringData" );
wateringData = await weatherProvider.getWateringData(coordinates);
}
// Process data to retrieve the resulting scale, sunrise/sunset, timezone,
// and also calculate if a restriction is met to prevent watering.
// Use getTimeData as fallback if a PWS is used but time data is not provided.
// This will never occur, but it might become possible in the future when PWS support is re-added.
if ( !timeData ) {
if ( typeof location[ 0 ] === "number" && typeof location[ 1 ] === "number" ) {
timeData = getTimeData( location as GeoCoordinates );
} else {
res.send( "Error: No weather data found." );
return; return;
} }
wateringData = await weatherProvider.getWateringData( coordinates );
} }
let scale: number = calculateWeatherScale( adjustmentMethod, adjustmentOptions, wateringData ), let scale = -1, rainDelay = -1;
rainDelay: number = -1;
if ( adjustmentMethod === ADJUSTMENT_METHOD.ZIMMERMAN ) {
scale = calculateZimmermanWateringScale( adjustmentOptions, wateringData );
}
if (wateringData) { if (wateringData) {
// Check for any user-set restrictions and change the scale to 0 if the criteria is met // Check for any user-set restrictions and change the scale to 0 if the criteria is met
if (checkWeatherRestriction(req.params[0], wateringData)) { if (checkWeatherRestriction(req.params[0], wateringData)) {
scale = 0; scale = 0;
} }
}
// If any weather adjustment is being used, check the rain status // If any weather adjustment is being used, check the rain status
if ( adjustmentMethod > 0 && wateringData && wateringData.raining ) { 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 it is raining and the user has weather-based rain delay as the adjustment method then apply the specified delay
if ( adjustmentMethod === 2 ) { if ( adjustmentMethod === ADJUSTMENT_METHOD.RAIN_DELAY ) {
rainDelay = ( adjustmentOptions && adjustmentOptions.hasOwnProperty( "d" ) ) ? adjustmentOptions.d : 24; rainDelay = ( adjustmentOptions && adjustmentOptions.hasOwnProperty( "d" ) ) ? adjustmentOptions.d : 24;
} else { } else {
@@ -305,6 +287,7 @@ export const getWateringData = async function( req: express.Request, res: expres
scale = 0; scale = 0;
} }
} }
}
const data = { const data = {
scale: scale, scale: scale,
@@ -316,17 +299,13 @@ export const getWateringData = async function( req: express.Request, res: expres
rawData: undefined rawData: undefined
}; };
if ( adjustmentMethod > 0 ) { if ( adjustmentMethod > ADJUSTMENT_METHOD.MANUAL ) {
data.rawData = { data.rawData = {
h: wateringData ? Math.round( wateringData.humidity * 100) / 100 : null, h: wateringData ? Math.round( wateringData.humidity * 100) / 100 : null,
p: wateringData ? Math.round( wateringData.precip * 100 ) / 100 : null, p: wateringData ? Math.round( wateringData.precip * 100 ) / 100 : null,
t: wateringData ? Math.round( wateringData.temp * 10 ) / 10 : null, t: wateringData ? Math.round( wateringData.temp * 10 ) / 10 : null,
raining: wateringData ? ( wateringData.raining ? 1 : 0 ) : null raining: wateringData ? ( wateringData.raining ? 1 : 0 ) : null
} };
}
if ( local.useLocalWeather() ) {
console.log( "OpenSprinkler Weather Response: %s", JSON.stringify( data ) );
} }
// Return the response to the client in the requested format // Return the response to the client in the requested format
@@ -339,7 +318,7 @@ export const getWateringData = async function( req: express.Request, res: expres
"&sunrise=" + data.sunrise + "&sunrise=" + data.sunrise +
"&sunset=" + data.sunset + "&sunset=" + data.sunset +
"&eip=" + data.eip + "&eip=" + data.eip +
( adjustmentMethod > 0 ? "&rawData=" + JSON.stringify( data.rawData ) : "" ) ( data.rawData ? "&rawData=" + JSON.stringify( data.rawData ) : "" )
); );
} }
@@ -392,6 +371,11 @@ async function httpRequest( url: string ): Promise< string > {
function validateValues( keys: string[], obj: object ): boolean { function validateValues( keys: string[], obj: object ): boolean {
let key: string; let key: string;
// Return false if the object is null/undefined.
if ( !obj ) {
return false;
}
for ( key in keys ) { for ( key in keys ) {
if ( !keys.hasOwnProperty( key ) ) { if ( !keys.hasOwnProperty( key ) ) {
continue; continue;

View File

@@ -50,6 +50,7 @@ async function getDarkSkyWateringData( coordinates: GeoCoordinates ): Promise< W
} }
return { return {
weatherProvider: "DarkSky",
temp : totals.temp / 24, temp : totals.temp / 24,
humidity: totals.humidity / 24 * 100, humidity: totals.humidity / 24 * 100,
precip: totals.precip, precip: totals.precip,
@@ -74,6 +75,7 @@ async function getDarkSkyWeatherData( coordinates: GeoCoordinates ): Promise< We
} }
const weather: WeatherData = { const weather: WeatherData = {
weatherProvider: "DarkSky",
temp: Math.floor( forecast.currently.temperature ), temp: Math.floor( forecast.currently.temperature ),
humidity: Math.floor( forecast.currently.humidity * 100 ), humidity: Math.floor( forecast.currently.humidity * 100 ),
wind: Math.floor( forecast.currently.windSpeed ), wind: Math.floor( forecast.currently.windSpeed ),

View File

@@ -31,6 +31,7 @@ async function getOWMWateringData( coordinates: GeoCoordinates ): Promise< Water
} }
return { return {
weatherProvider: "OWM",
temp: totalTemp / periods, temp: totalTemp / periods,
humidity: totalHumidity / periods, humidity: totalHumidity / periods,
precip: totalPrecip / 25.4, precip: totalPrecip / 25.4,
@@ -58,6 +59,7 @@ async function getOWMWeatherData( coordinates: GeoCoordinates ): Promise< Weathe
} }
const weather: WeatherData = { const weather: WeatherData = {
weatherProvider: "OWM",
temp: parseInt( current.main.temp ), temp: parseInt( current.main.temp ),
humidity: parseInt( current.main.humidity ), humidity: parseInt( current.main.humidity ),
wind: parseInt( current.wind.speed ), wind: parseInt( current.wind.speed ),

View File

@@ -1,5 +1,6 @@
import * as express from "express"; import * as express from "express";
import { CronJob } from "cron"; import { CronJob } from "cron";
import { GeoCoordinates, WateringData, WeatherProvider } from "../../types";
const count = { temp: 0, humidity: 0 }; const count = { temp: 0, humidity: 0 };
@@ -42,25 +43,20 @@ export const captureWUStream = function( req: express.Request, res: express.Resp
res.send( "success\n" ); res.send( "success\n" );
}; };
export const useLocalWeather = function(): boolean { export const getLocalWateringData = function(): WateringData {
return process.env.PWS ? true : false; const result: WateringData = {
}; ...yesterday as WateringData,
export const getLocalWeather = function(): LocalWeather {
const result: LocalWeather = {};
// 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)
Object.assign( result, today, yesterday); ...today,
// 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 ) { if ( "precip" in yesterday && "precip" in today ) {
result.precip = yesterday.precip + today.precip; result.precip = yesterday.precip + today.precip;
} }
// PWS report "buckets" so consider it still raining if last bucket was less than an hour ago
if ( last_bucket !== undefined ) {
result.raining = ( ( Date.now() - +last_bucket ) / 1000 / 60 / 60 < 1 );
}
return result; return result;
}; };
@@ -79,6 +75,9 @@ interface PWSStatus {
precip?: number; precip?: number;
} }
export interface LocalWeather extends PWSStatus { const LocalWeatherProvider: WeatherProvider = {
raining?: boolean; getWateringData: async function ( coordinates: GeoCoordinates ) {
} return getLocalWateringData();
}
};
export default LocalWeatherProvider;

View File

@@ -5,7 +5,7 @@ import * as express from "express";
import * as cors from "cors"; import * as cors from "cors";
import * as weather from "./routes/weather"; import * as weather from "./routes/weather";
import * as local from "./routes/local"; import * as local from "./routes/weatherProviders/local";
let host = process.env.HOST || "127.0.0.1", let host = process.env.HOST || "127.0.0.1",
port = parseInt( process.env.PORT ) || 3000; port = parseInt( process.env.PORT ) || 3000;

View File

@@ -13,6 +13,8 @@ export interface TimeData {
} }
export interface WeatherData { export interface WeatherData {
/** The WeatherProvider that generated this data. */
weatherProvider: WeatherProviderId;
/** The current temperature (in Fahrenheit). */ /** The current temperature (in Fahrenheit). */
temp: number; temp: number;
/** The current humidity (as a percentage). */ /** The current humidity (as a percentage). */
@@ -54,6 +56,8 @@ export interface WeatherDataForecast {
* available. * available.
*/ */
export interface WateringData { export interface WateringData {
/** 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). */
@@ -88,7 +92,7 @@ export interface WeatherProvider {
* @return A Promise that will be resolved with the WateringData if it is successfully retrieved, * @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. * or resolved with undefined if an error occurs while retrieving the WateringData.
*/ */
getWateringData( coordinates : GeoCoordinates ): Promise< WateringData >; getWateringData?( coordinates : GeoCoordinates ): Promise< WateringData >;
/** /**
* Retrieves the current weather data for usage in the mobile app. * Retrieves the current weather data for usage in the mobile app.
@@ -96,5 +100,7 @@ export interface WeatherProvider {
* @return A Promise that will be resolved with the WeatherData if it is successfully retrieved, * @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. * or resolved with undefined if an error occurs while retrieving the WeatherData.
*/ */
getWeatherData( coordinates : GeoCoordinates ): Promise< WeatherData >; getWeatherData?( coordinates : GeoCoordinates ): Promise< WeatherData >;
} }
export type WeatherProviderId = "OWM" | "DarkSky" | "local";