Rebase on master

This commit is contained in:
Matthew Oslan
2019-05-11 16:21:30 -04:00
7 changed files with 275 additions and 135 deletions

20
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{
"name": "os-weather-service",
"version": "1.0.1",
"version": "1.0.2",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -54,6 +54,16 @@
"@types/express": "*"
}
},
"@types/cron": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@types/cron/-/cron-1.7.0.tgz",
"integrity": "sha512-LRu/XiiOExELholyEwEuSTPAEiO+sVR1nXmWEjezneGgYpDyMNVIsjiaHYBoCEUJo4F1hCOlAzQAh80iEUVbKw==",
"dev": true,
"requires": {
"@types/node": "*",
"moment": ">=2.14.0"
}
},
"@types/dotenv": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@types/dotenv/-/dotenv-6.1.1.tgz",
@@ -498,6 +508,14 @@
"vary": "^1"
}
},
"cron": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/cron/-/cron-1.7.1.tgz",
"integrity": "sha512-gmMB/pJcqUVs/NklR1sCGlNYM7TizEw+1gebz20BMc/8bTm/r7QUp3ZPSPlG8Z5XRlvb7qhjEjq/+bdIfUCL2A==",
"requires": {
"moment-timezone": "^0.5.x"
}
},
"currently-unhandled": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "os-weather-service",
"description": "OpenSprinkler Weather Service",
"version": "1.0.1",
"version": "1.0.2",
"repository": "https://github.com/OpenSprinkler/Weather-Weather",
"scripts": {
"test": "mocha --exit test",
@@ -10,6 +10,7 @@
},
"dependencies": {
"cors": "^2.8.5",
"cron": "^1.3.0",
"dotenv": "^6.2.0",
"express": "^4.16.4",
"geo-tz": "^4.0.2",
@@ -19,6 +20,7 @@
},
"devDependencies": {
"@types/cors": "^2.8.5",
"@types/cron": "^1.3.0",
"@types/dotenv": "^6.1.1",
"@types/express": "^4.16.1",
"@types/moment-timezone": "^0.5.12",

85
routes/local.ts Normal file
View File

@@ -0,0 +1,85 @@
import * as express from "express";
const CronJob = require( "cron" ).CronJob,
server = require( "../server.js" ),
count = { temp: 0, humidity: 0 };
let today: PWSStatus = {},
yesterday: PWSStatus = {},
last_bucket: Date,
current_date: Date = new Date();
function sameDay(d1: Date, d2: Date): boolean {
return d1.getFullYear() === d2.getFullYear() &&
d1.getMonth() === d2.getMonth() &&
d1.getDate() === d2.getDate();
}
exports.captureWUStream = function( req: express.Request, res: express.Response ) {
let prev: number, curr: number;
if ( !( "dateutc" in req.query ) || !sameDay( current_date, new Date( req.query.dateutc + "Z") )) {
res.send( "Error: Bad date range\n" );
return;
}
if ( ( "tempf" in req.query ) && !isNaN( curr = parseFloat( req.query.tempf ) ) && curr !== -9999.0 ) {
prev = ( "temp" in today ) ? today.temp : 0;
today.temp = ( prev * count.temp + curr ) / ( ++count.temp );
}
if ( ( "humidity" in req.query ) && !isNaN( curr = parseFloat( req.query.humidity ) ) && curr !== -9999.0 ) {
prev = ( "humidity" in today ) ? today.humidity : 0;
today.humidity = ( prev * count.humidity + curr ) / ( ++count.humidity );
}
if ( ( "dailyrainin" in req.query ) && !isNaN( curr = parseFloat( req.query.dailyrainin ) ) && curr !== -9999.0 ) {
today.precip = curr;
}
if ( ( "rainin" in req.query ) && !isNaN( curr = parseFloat( req.query.rainin ) ) && curr > 0 ) {
last_bucket = new Date();
}
console.log( "OpenSprinkler Weather Observation: %s", JSON.stringify( req.query ) );
res.send( "success\n" );
};
exports.useLocalWeather = function(): boolean {
return server.pws !== "none" ? true : false;
};
exports.getLocalWeather = function(): LocalWeather {
const result: LocalWeather = {};
// Use today's weather if we dont have information for yesterday yet (i.e. on startup)
Object.assign( result, today, yesterday);
if ( "precip" in yesterday && "precip" in today ) {
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;
};
new CronJob( "0 0 0 * * *", function() {
yesterday = Object.assign( {}, today );
today = Object.assign( {} );
count.temp = 0; count.humidity = 0;
current_date = new Date();
}, null, true );
interface PWSStatus {
temp?: number;
humidity?: number;
precip?: number;
}
export interface LocalWeather extends PWSStatus {
raining?: boolean;
}

View File

@@ -1,6 +1,8 @@
import * as express from "express";
import { AdjustmentOptions, GeoCoordinates, TimeData, WateringData, WeatherData } from "../types";
const http = require( "http" ),
local = require( "./local"),
SunCalc = require( "suncalc" ),
moment = require( "moment-timezone" ),
geoTZ = require( "geo-tz" ),
@@ -15,21 +17,26 @@ const http = require( "http" ),
};
/**
* Uses the Weather Underground API to resolve a location name (ZIP code, city name, country name, etc.) to geographic
* coordinates.
* @param location A zip code or partial city/country name.
* Resolves a location description to geographic coordinates.
* @param location A partial zip/city/country or a coordinate pair.
* @return A promise that will be resolved with the coordinates of the best match for the specified location, or
* rejected with an error message if unable to resolve the location.
*/
async function resolveCoordinates( location: string ): Promise< GeoCoordinates > {
if ( filters.pws.test( location ) ) {
throw "Unable to resolve location";
} else if ( filters.gps.test( location ) ) {
const split: string[] = location.split( "," );
return [ parseFloat( split[ 0 ] ), parseFloat( split[ 1 ] ) ];
} else {
// Generate URL for autocomplete request
const url = "http://autocomplete.wunderground.com/aq?h=0&query=" +
encodeURIComponent( location );
let data;
try {
data = await getData( url );
data = await httpJSONRequest( url );
} catch (err) {
// If the request fails, indicate no data was found.
throw "An API error occurred while attempting to resolve location";
@@ -45,15 +52,16 @@ async function resolveCoordinates( location: string ): Promise< GeoCoordinates >
// Otherwise, indicate no data was found
throw "Unable to resolve location";
}
}
}
/**
* Makes an HTTP 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.
* @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.
*/
async function getData( url: string ): Promise< any > {
async function httpJSONRequest(url: string ): Promise< any > {
try {
const data: string = await httpRequest(url);
return JSON.parse(data);
@@ -66,17 +74,17 @@ async function getData( url: string ): Promise< any > {
/**
* Retrieves weather data necessary for watering level calculations from the OWM API.
* @param coordinates The coordinates to retrieve the watering data for.
* @return A Promise that will be resolved with OWMWateringData if the API calls succeed, or undefined if an
* error occurs while retrieving the weather data.
* @return A Promise that will be resolved with WateringData if the API calls succeed, or resolved with undefined
* if an error occurs while retrieving the weather data.
*/
async function getOWMWateringData( coordinates: GeoCoordinates ): Promise< OWMWateringData > {
async function getOWMWateringData( coordinates: GeoCoordinates ): Promise< WateringData > {
const OWM_API_KEY = process.env.OWM_API_KEY,
forecastUrl = "http://api.openweathermap.org/data/2.5/forecast?appid=" + OWM_API_KEY + "&units=imperial&lat=" + coordinates[ 0 ] + "&lon=" + coordinates[ 1 ];
// Perform the HTTP request to retrieve the weather data
let forecast;
try {
forecast = await getData( forecastUrl );
forecast = await httpJSONRequest( forecastUrl );
} catch (err) {
// Indicate watering data could not be retrieved if an API error occurs.
return undefined;
@@ -109,18 +117,18 @@ async function getOWMWateringData( coordinates: GeoCoordinates ): Promise< OWMWa
/**
* Retrieves the current weather data from OWM for usage in the mobile app.
* @param coordinates The coordinates to retrieve the weather for
* @return A Promise that will be resolved with the OWMWeatherData if the API calls succeed, or undefined if
* an error occurs while retrieving the weather data.
* @return A Promise that will be resolved with the WeatherData if the API calls succeed, or resolved with undefined
* if an error occurs while retrieving the weather data.
*/
async function getOWMWeatherData( coordinates: GeoCoordinates ): Promise< OWMWeatherData > {
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 getData( currentUrl );
forecast = await getData( forecastDailyUrl );
current = await httpJSONRequest( currentUrl );
forecast = await httpJSONRequest( forecastDailyUrl );
} catch (err) {
// Indicate watering data could not be retrieved if an API error occurs.
return undefined;
@@ -131,7 +139,7 @@ async function getOWMWeatherData( coordinates: GeoCoordinates ): Promise< OWMWea
return undefined;
}
const weather: OWMWeatherData = {
const weather: WeatherData = {
temp: parseInt( current.main.temp ),
humidity: parseInt( current.main.humidity ),
wind: parseInt( current.wind.speed ),
@@ -159,6 +167,15 @@ async function getOWMWeatherData( coordinates: GeoCoordinates ): Promise< OWMWea
return weather;
}
/**
* 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 > {
return local.getLocalWeather();
}
/**
* Calculates timezone and sunrise/sunset for the specified coordinates.
* @param coordinates The coordinates to use to calculate time data.
@@ -189,7 +206,7 @@ function getTimeData( coordinates: GeoCoordinates ): TimeData {
* @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.
*/
function calculateWeatherScale( adjustmentMethod: number, adjustmentOptions: AdjustmentOptions, wateringData: OWMWateringData ): number {
function calculateWeatherScale( adjustmentMethod: number, adjustmentOptions: AdjustmentOptions, wateringData: WateringData ): number {
// Zimmerman method
if ( adjustmentMethod === 1 ) {
@@ -244,7 +261,7 @@ function calculateWeatherScale( adjustmentMethod: number, adjustmentOptions: Adj
* @param weather Watering data to use to determine if any restrictions apply.
* @return A boolean indicating if the watering level should be set to 0% due to a restriction.
*/
function checkWeatherRestriction( adjustmentValue: number, weather: OWMWateringData ): boolean {
function checkWeatherRestriction( adjustmentValue: number, weather: WateringData ): boolean {
const californiaRestriction = ( adjustmentValue >> 7 ) & 1;
@@ -263,28 +280,23 @@ function checkWeatherRestriction( adjustmentValue: number, weather: OWMWateringD
exports.getWeatherData = async function( req: express.Request, res: express.Response ) {
const location: string = getParameter(req.query.loc);
if ( !location ) {
res.send( "Error: Unable to resolve location" );
return;
}
let coordinates: GeoCoordinates;
if ( filters.gps.test( location ) ) {
// Handle GPS coordinates by storing each coordinate in an array
const split: string[] = location.split( "," );
coordinates = [ parseFloat( split[ 0 ] ), parseFloat( split[ 1 ] ) ];
} else {
// Attempt to resolve provided location to GPS coordinates when it does not match
// a GPS coordinate or Weather Underground location using Weather Underground autocomplete
try {
coordinates = await resolveCoordinates( location );
} catch (err) {
res.send( "Error: Unable to resolve location" );
return;
}
}
// Continue with the weather request
const timeData: TimeData = getTimeData( coordinates );
const weatherData: OWMWeatherData = await getOWMWeatherData( coordinates );
const weatherData: WeatherData = await getOWMWeatherData( coordinates );
res.json( {
...timeData,
@@ -363,7 +375,12 @@ exports.getWateringData = async function( req: express.Request, res: express.Res
// Continue with the weather request
let timeData: TimeData = getTimeData( coordinates );
const wateringData: OWMWateringData = await getOWMWateringData( coordinates );
let wateringData: WateringData;
if ( local.useLocalWeather() ) {
wateringData = await getLocalWateringData( coordinates );
} else {
wateringData = await getOWMWateringData(coordinates);
}
// Process data to retrieve the resulting scale, sunrise/sunset, timezone,
@@ -420,6 +437,10 @@ exports.getWateringData = async function( req: express.Request, res: express.Res
}
};
if ( local.useLocalWeather() ) {
console.log( "OpenSprinkler Weather Response: %s", JSON.stringify( data ) );
}
// Return the response to the client in the requested format
if ( outputFormat === "json" ) {
res.json( data );
@@ -557,70 +578,3 @@ function getParameter( parameter: string | string[] ): string {
// Return an empty string if the parameter is undefined.
return parameter || "";
}
/** Geographic coordinates. The 1st element is the latitude, and the 2nd element is the longitude. */
type GeoCoordinates = [number, number];
interface TimeData {
/** The UTC offset, in minutes. This uses POSIX offsets, which are the negation of typically used offsets
* (https://github.com/eggert/tz/blob/2017b/etcetera#L36-L42).
*/
timezone: number;
/** The time of sunrise, in minutes from UTC midnight. */
sunrise: number;
/** The time of sunset, in minutes from UTC midnight. */
sunset: number;
}
interface OWMWeatherData {
/** The current temperature (in Fahrenheit). */
temp: number;
/** The current humidity (as a percentage). */
humidity: number;
wind: number;
description: string;
icon: string;
region: string;
city: string;
minTemp: number;
maxTemp: number;
precip: number;
forecast: OWMWeatherDataForecast[]
}
interface OWMWeatherDataForecast {
temp_min: number;
temp_max: number;
date: number;
icon: string;
description: string;
}
interface OWMWateringData {
/** The average forecasted temperature over the next 30 hours (in Fahrenheit). */
temp: number;
/** The average forecasted humidity over the next 30 hours (as a percentage). */
humidity: number;
/** The forecasted total precipitation over the next 30 hours (in inches). */
precip: number;
/** A boolean indicating if it is currently raining. */
raining: boolean;
}
interface AdjustmentOptions {
/** Base humidity (as a percentage). */
bh?: number;
/** Base temperature (in Fahrenheit). */
bt?: number;
/** Base precipitation (in inches). */
br?: number;
/** The percentage to weight the humidity factor by. */
h?: number;
/** The percentage to weight the temperature factor by. */
t?: number;
/** The percentage to weight the precipitation factor by. */
r?: number;
/** The rain delay to use (in hours). */
d?: number;
}

View File

@@ -1,14 +1,19 @@
var express = require( "express" ),
const packageJson = require( "../package.json" ),
express = require( "express" ),
weather = require( "./routes/weather.js" ),
cors = require( "cors" ),
host = process.env.HOST || "127.0.0.1",
local = require( "./routes/local.js" ),
cors = require( "cors" );
let host = process.env.HOST || "127.0.0.1",
port = process.env.PORT || 3000,
pws = process.env.PWS || "none",
app = express();
if ( !process.env.HOST || !process.env.PORT ) {
if ( !process.env.HOST || !process.env.PORT || !process.env.LOCAL_PWS ) {
require( "dotenv" ).load();
host = process.env.HOST || host;
port = process.env.PORT || port;
pws = process.env.PWS || pws;
}
// Handle requests matching /weatherID.py where ID corresponds to the
@@ -21,8 +26,13 @@ app.get( /(\d+)/, weather.getWateringData );
app.options( /weatherData/, cors() );
app.get( /weatherData/, cors(), weather.getWeatherData );
// Endpoint to stream Weather Underground data from local PWS
if ( pws === "WU" ) {
app.get( "/weatherstation/updateweatherstation.php", local.captureWUStream );
}
app.get( "/", function( req, res ) {
res.send( "OpenSprinkler Weather Service" );
res.send( packageJson.description + " v" + packageJson.version );
} );
// Handle 404 error
@@ -33,7 +43,12 @@ app.use( function( req, res ) {
// Start listening on the service port
app.listen( port, host, function() {
console.log( "OpenSprinkler Weather Service now listening on %s:%s", host, port );
console.log( "%s now listening on %s:%s", packageJson.description, host, port );
if (pws !== "none" ) {
console.log( "%s now listening for local weather stream", packageJson.description );
}
} );
exports.app = app;
exports.pws = pws;

View File

@@ -9,6 +9,7 @@
},
"include": [
"server.ts",
"types.ts",
"routes/**/*"
]
}

65
types.ts Normal file
View File

@@ -0,0 +1,65 @@
/** Geographic coordinates. The 1st element is the latitude, and the 2nd element is the longitude. */
export type GeoCoordinates = [number, number];
export interface TimeData {
/** The UTC offset, in minutes. This uses POSIX offsets, which are the negation of typically used offsets
* (https://github.com/eggert/tz/blob/2017b/etcetera#L36-L42).
*/
timezone: number;
/** The time of sunrise, in minutes from UTC midnight. */
sunrise: number;
/** The time of sunset, in minutes from UTC midnight. */
sunset: number;
}
export interface WeatherData {
/** The current temperature (in Fahrenheit). */
temp: number;
/** The current humidity (as a percentage). */
humidity: number;
wind: number;
description: string;
icon: string;
region: string;
city: string;
minTemp: number;
maxTemp: number;
precip: number;
forecast: WeatherDataForecast[]
}
export interface WeatherDataForecast {
temp_min: number;
temp_max: number;
date: number;
icon: string;
description: string;
}
export interface WateringData {
/** The average forecasted temperature over the next 30 hours (in Fahrenheit). */
temp: number;
/** The average forecasted humidity over the next 30 hours (as a percentage). */
humidity: number;
/** The forecasted total precipitation over the next 30 hours (in inches). */
precip: number;
/** A boolean indicating if it is currently raining. */
raining: boolean;
}
export interface AdjustmentOptions {
/** Base humidity (as a percentage). */
bh?: number;
/** Base temperature (in Fahrenheit). */
bt?: number;
/** Base precipitation (in inches). */
br?: number;
/** The percentage to weight the humidity factor by. */
h?: number;
/** The percentage to weight the temperature factor by. */
t?: number;
/** The percentage to weight the precipitation factor by. */
r?: number;
/** The rain delay to use (in hours). */
d?: number;
}