Merge 'dev' into 'eto'
This commit is contained in:
@@ -56,4 +56,9 @@ export interface AdjustmentMethodResponse {
|
||||
wateringData: BaseWateringData;
|
||||
}
|
||||
|
||||
export interface AdjustmentOptions {}
|
||||
export interface AdjustmentOptions {
|
||||
/** The ID of the PWS to use, prefixed with "pws:". */
|
||||
pws?: string;
|
||||
/** The API key to use to access PWS data. */
|
||||
key?: string;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ import * as MockExpressRequest from 'mock-express-request';
|
||||
import * as MockExpressResponse from 'mock-express-response';
|
||||
import * as MockDate from 'mockdate';
|
||||
|
||||
// The tests don't use OWM, but the WeatherProvider API key must be set to prevent an error from being thrown on startup.
|
||||
process.env.WEATHER_PROVIDER = "OWM";
|
||||
process.env.OWM_API_KEY = "NO_KEY";
|
||||
|
||||
import { getWateringData } from './weather';
|
||||
import { GeoCoordinates, WeatherData, ZimmermanWateringData } from "../types";
|
||||
import { WeatherProvider } from "./weatherProviders/WeatherProvider";
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as geoTZ from "geo-tz";
|
||||
import { BaseWateringData, GeoCoordinates, PWS, TimeData, WeatherData } from "../types";
|
||||
import { WeatherProvider } from "./weatherProviders/WeatherProvider";
|
||||
import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./adjustmentMethods/AdjustmentMethod";
|
||||
import WateringScaleCache, { CachedScale } from "../WateringScaleCache";
|
||||
import ManualAdjustmentMethod from "./adjustmentMethods/ManualAdjustmentMethod";
|
||||
import ZimmermanAdjustmentMethod from "./adjustmentMethods/ZimmermanAdjustmentMethod";
|
||||
import RainDelayAdjustmentMethod from "./adjustmentMethods/RainDelayAdjustmentMethod";
|
||||
@@ -32,6 +33,8 @@ const ADJUSTMENT_METHOD: { [ key: number ] : AdjustmentMethod } = {
|
||||
3: EToAdjustmentMethod
|
||||
};
|
||||
|
||||
const cache = new WateringScaleCache();
|
||||
|
||||
/**
|
||||
* Resolves a location description to geographic coordinates.
|
||||
* @param location A partial zip/city/country or a coordinate pair.
|
||||
@@ -184,7 +187,6 @@ export const getWateringData = async function( req: express.Request, res: expres
|
||||
location: string | GeoCoordinates = getParameter(req.query.loc),
|
||||
outputFormat: string = getParameter(req.query.format),
|
||||
remoteAddress: string = getParameter(req.headers[ "x-forwarded-for" ]) || req.connection.remoteAddress,
|
||||
pwsString: string = getParameter( req.query.pws ),
|
||||
adjustmentOptions: AdjustmentOptions;
|
||||
|
||||
// X-Forwarded-For header may contain more than one IP address and therefore
|
||||
@@ -224,67 +226,108 @@ export const getWateringData = async function( req: express.Request, res: expres
|
||||
|
||||
// Parse the PWS information.
|
||||
let pws: PWS | undefined = undefined;
|
||||
if ( pwsString ) {
|
||||
try {
|
||||
pws = parsePWS( pwsString );
|
||||
} catch ( err ) {
|
||||
res.send( `Error: ${ err }` );
|
||||
if ( adjustmentOptions.pws ) {
|
||||
if ( !adjustmentOptions.key ) {
|
||||
res.send("Error: An API key must be provided when using a PWS.");
|
||||
return;
|
||||
}
|
||||
|
||||
const idMatch = adjustmentOptions.pws.match( /^pws:([a-zA-Z\d]+)$/ );
|
||||
const pwsId = idMatch ? idMatch[ 1 ] : 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;
|
||||
}
|
||||
|
||||
pws = { id: pwsId, apiKey: apiKey };
|
||||
}
|
||||
|
||||
const weatherProvider = pws ? PWS_WEATHER_PROVIDER : WEATHER_PROVIDER;
|
||||
let adjustmentMethodResponse: AdjustmentMethodResponse;
|
||||
try {
|
||||
adjustmentMethodResponse = await adjustmentMethod.calculateWateringScale(
|
||||
adjustmentOptions, coordinates, weatherProvider, pws
|
||||
);
|
||||
} catch ( err ) {
|
||||
if ( typeof err != "string" ) {
|
||||
/* If an error occurs under expected circumstances (e.g. required optional fields from a weather API are
|
||||
missing), an AdjustmentOption must throw a string. If a non-string error is caught, it is likely an Error
|
||||
thrown by the JS engine due to unexpected circumstances. The user should not be shown the error message
|
||||
since it may contain sensitive information. */
|
||||
res.send( "Error: an unexpected error occurred." );
|
||||
console.error( `An unexpected error occurred for ${ req.url }: `, err );
|
||||
} else {
|
||||
res.send( "Error: " + err );
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let scale = adjustmentMethodResponse.scale;
|
||||
|
||||
if ( checkRestrictions ) {
|
||||
let wateringData: BaseWateringData = adjustmentMethodResponse.wateringData;
|
||||
// Fetch the watering data if the AdjustmentMethod didn't fetch it and restrictions are being checked.
|
||||
if ( checkRestrictions && !wateringData ) {
|
||||
try {
|
||||
wateringData = await weatherProvider.getWateringData( coordinates );
|
||||
} catch ( err ) {
|
||||
res.send( "Error: " + err );
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for any user-set restrictions and change the scale to 0 if the criteria is met
|
||||
if ( checkWeatherRestriction( req.params[ 0 ], wateringData ) ) {
|
||||
scale = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const data = {
|
||||
scale: scale,
|
||||
rd: adjustmentMethodResponse.rainDelay,
|
||||
scale: undefined,
|
||||
rd: undefined,
|
||||
tz: getTimezone( timeData.timezone, undefined ),
|
||||
sunrise: timeData.sunrise,
|
||||
sunset: timeData.sunset,
|
||||
eip: ipToInt( remoteAddress ),
|
||||
rawData: adjustmentMethodResponse.rawData,
|
||||
error: adjustmentMethodResponse.errorMessage
|
||||
rawData: undefined,
|
||||
error: undefined
|
||||
};
|
||||
|
||||
let cachedScale: CachedScale;
|
||||
if ( weatherProvider.shouldCacheWateringScale() ) {
|
||||
cachedScale = cache.getWateringScale( req.params[ 0 ], coordinates, pws, adjustmentOptions );
|
||||
}
|
||||
|
||||
if ( cachedScale ) {
|
||||
// Use the cached data if it exists.
|
||||
data.scale = cachedScale.scale;
|
||||
data.rawData = cachedScale.rawData;
|
||||
data.rd = cachedScale.rainDelay;
|
||||
} else {
|
||||
// Calculate the watering scale if it wasn't found in the cache.
|
||||
let adjustmentMethodResponse: AdjustmentMethodResponse;
|
||||
try {
|
||||
adjustmentMethodResponse = await adjustmentMethod.calculateWateringScale(
|
||||
adjustmentOptions, coordinates, weatherProvider, pws
|
||||
);
|
||||
} catch ( err ) {
|
||||
if ( typeof err != "string" ) {
|
||||
/* If an error occurs under expected circumstances (e.g. required optional fields from a weather API are
|
||||
missing), an AdjustmentOption must throw a string. If a non-string error is caught, it is likely an Error
|
||||
thrown by the JS engine due to unexpected circumstances. The user should not be shown the error message
|
||||
since it may contain sensitive information. */
|
||||
res.send( "Error: an unexpected error occurred." );
|
||||
console.error( `An unexpected error occurred for ${ req.url }: `, err );
|
||||
} else {
|
||||
res.send( "Error: " + err );
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
data.scale = adjustmentMethodResponse.scale;
|
||||
data.error = adjustmentMethodResponse.errorMessage;
|
||||
data.rd = adjustmentMethodResponse.rainDelay;
|
||||
data.rawData = adjustmentMethodResponse.rawData;
|
||||
|
||||
if ( checkRestrictions ) {
|
||||
let wateringData: BaseWateringData = adjustmentMethodResponse.wateringData;
|
||||
// Fetch the watering data if the AdjustmentMethod didn't fetch it and restrictions are being checked.
|
||||
if ( checkRestrictions && !wateringData ) {
|
||||
try {
|
||||
wateringData = await weatherProvider.getWateringData( coordinates );
|
||||
} catch ( err ) {
|
||||
res.send( "Error: " + err );
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for any user-set restrictions and change the scale to 0 if the criteria is met
|
||||
if ( checkWeatherRestriction( req.params[ 0 ], wateringData ) ) {
|
||||
data.scale = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the watering scale if caching is enabled and no error occurred.
|
||||
if ( weatherProvider.shouldCacheWateringScale() && !data.error ) {
|
||||
cache.storeWateringScale( req.params[ 0 ], coordinates, pws, adjustmentOptions, {
|
||||
scale: data.scale,
|
||||
rawData: data.rawData,
|
||||
rainDelay: data.rd
|
||||
} );
|
||||
}
|
||||
}
|
||||
|
||||
// Return the response to the client in the requested format
|
||||
if ( outputFormat === "json" ) {
|
||||
res.json( data );
|
||||
@@ -452,21 +495,3 @@ export function getParameter( parameter: string | string[] ): string {
|
||||
// Return an empty string if the parameter is undefined.
|
||||
return parameter || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a PWS object from a string.
|
||||
* @param pwsString Information about the PWS in the format "pws:API_KEY@PWS_ID".
|
||||
* @return The PWS specified by the string.
|
||||
* @throws Throws an error message if the string is in an invalid format and cannot be parsed.
|
||||
*/
|
||||
function parsePWS( pwsString: string): PWS {
|
||||
const match = pwsString.match( /^pws:([a-f\d]{32})@([a-zA-Z\d]+)$/ );
|
||||
if ( !match ) {
|
||||
throw "Invalid PWS format.";
|
||||
}
|
||||
|
||||
return {
|
||||
apiKey: match[ 1 ],
|
||||
id: match[ 2 ]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,38 +7,36 @@ import { approximateSolarRadiation, CloudCoverInfo, EToData } from "../adjustmen
|
||||
|
||||
export default class DarkSkyWeatherProvider extends WeatherProvider {
|
||||
|
||||
private readonly API_KEY: string;
|
||||
|
||||
public constructor() {
|
||||
super();
|
||||
this.API_KEY = process.env.DARKSKY_API_KEY;
|
||||
if (!this.API_KEY) {
|
||||
throw "DARKSKY_API_KEY environment variable is not defined.";
|
||||
}
|
||||
}
|
||||
|
||||
public async getWateringData( coordinates: GeoCoordinates ): Promise< ZimmermanWateringData > {
|
||||
// The Unix timestamp of 24 hours ago.
|
||||
const yesterdayTimestamp: number = moment().subtract( 1, "day" ).unix();
|
||||
const todayTimestamp: number = moment().unix();
|
||||
|
||||
const DARKSKY_API_KEY = process.env.DARKSKY_API_KEY,
|
||||
yesterdayUrl = `https://api.darksky.net/forecast/${ DARKSKY_API_KEY }/${ coordinates[ 0 ] },${ coordinates[ 1 ] },${ yesterdayTimestamp }?exclude=currently,minutely,daily,alerts,flags`,
|
||||
todayUrl = `https://api.darksky.net/forecast/${ DARKSKY_API_KEY }/${ coordinates[ 0 ] },${ coordinates[ 1 ] },${ todayTimestamp }?exclude=currently,minutely,daily,alerts,flags`;
|
||||
const yesterdayUrl = `https://api.darksky.net/forecast/${ this.API_KEY }/${ coordinates[ 0 ] },${ coordinates[ 1 ] },${ yesterdayTimestamp }?exclude=currently,minutely,daily,alerts,flags`;
|
||||
|
||||
let yesterdayData, todayData;
|
||||
let yesterdayData;
|
||||
try {
|
||||
yesterdayData = await httpJSONRequest( yesterdayUrl );
|
||||
todayData = await httpJSONRequest( todayUrl );
|
||||
} catch ( err ) {
|
||||
console.error( "Error retrieving weather information from Dark Sky:", err );
|
||||
throw "An error occurred while retrieving weather information from Dark Sky."
|
||||
}
|
||||
|
||||
if ( !todayData.hourly || !todayData.hourly.data || !yesterdayData.hourly || !yesterdayData.hourly.data ) {
|
||||
if ( !yesterdayData.hourly || !yesterdayData.hourly.data ) {
|
||||
throw "Necessary field(s) were missing from weather information returned by Dark Sky.";
|
||||
}
|
||||
|
||||
/* The number of hourly forecasts to use from today's data. This will only include elements that contain historic
|
||||
data (not forecast data). */
|
||||
// Find the first element that contains forecast data.
|
||||
const todayElements = Math.min( 24, todayData.hourly.data.findIndex( ( data ) => data.time > todayTimestamp - 60 * 60 ) );
|
||||
|
||||
/* Take as much data as possible from the first elements of today's data and take the remaining required data from
|
||||
the remaining data from the last elements of yesterday's data. */
|
||||
const samples = [
|
||||
...yesterdayData.hourly.data.slice( todayElements - 24 ),
|
||||
...todayData.hourly.data.slice( 0, todayElements )
|
||||
...yesterdayData.hourly.data
|
||||
];
|
||||
|
||||
// Fail if not enough data is available.
|
||||
@@ -70,8 +68,7 @@ export default class DarkSkyWeatherProvider extends WeatherProvider {
|
||||
}
|
||||
|
||||
public async getWeatherData( 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`;
|
||||
const forecastUrl = `https://api.darksky.net/forecast/${ this.API_KEY }/${ coordinates[ 0 ] },${ coordinates[ 1 ] }?exclude=minutely,alerts,flags`;
|
||||
|
||||
let forecast;
|
||||
try {
|
||||
@@ -163,4 +160,8 @@ export default class DarkSkyWeatherProvider extends WeatherProvider {
|
||||
precip: ( historicData.daily.data[ 0 ].precipIntensity || 0 ) * 24
|
||||
};
|
||||
}
|
||||
|
||||
public shouldCacheWateringScale(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,18 @@ import * as moment from "moment";
|
||||
|
||||
export default class OWMWeatherProvider extends WeatherProvider {
|
||||
|
||||
private readonly API_KEY: string;
|
||||
|
||||
public constructor() {
|
||||
super();
|
||||
this.API_KEY = process.env.OWM_API_KEY;
|
||||
if (!this.API_KEY) {
|
||||
throw "OWM_API_KEY environment variable is not defined.";
|
||||
}
|
||||
}
|
||||
|
||||
public async getWateringData( coordinates: GeoCoordinates ): Promise< ZimmermanWateringData > {
|
||||
const 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 ];
|
||||
const forecastUrl = `http://api.openweathermap.org/data/2.5/forecast?appid=${ this.API_KEY }&units=imperial&lat=${ coordinates[ 0 ] }&lon=${ coordinates[ 1 ] }`;
|
||||
|
||||
// Perform the HTTP request to retrieve the weather data
|
||||
let forecast;
|
||||
@@ -45,9 +54,8 @@ export default class OWMWeatherProvider extends WeatherProvider {
|
||||
}
|
||||
|
||||
public async getWeatherData( 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 ];
|
||||
const currentUrl = `http://api.openweathermap.org/data/2.5/weather?appid=${ this.API_KEY }&units=imperial&lat=${ coordinates[ 0 ] }&lon=${ coordinates[ 1 ] }`,
|
||||
forecastDailyUrl = `http://api.openweathermap.org/data/2.5/forecast/daily?appid=${ this.API_KEY }&units=imperial&lat=${ coordinates[ 0 ] }&lon=${ coordinates[ 1 ] }`;
|
||||
|
||||
let current, forecast;
|
||||
try {
|
||||
|
||||
@@ -36,4 +36,13 @@ export class WeatherProvider {
|
||||
getEToData( coordinates: GeoCoordinates ): Promise< EToData > {
|
||||
throw "Selected WeatherProvider does not support getEToData";
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user