diff --git a/package-lock.json b/package-lock.json index dbc1fef..693d67d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 1b484f0..d8c408a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/routes/local.ts b/routes/local.ts new file mode 100644 index 0000000..833c473 --- /dev/null +++ b/routes/local.ts @@ -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; +} diff --git a/routes/weather.ts b/routes/weather.ts index d8770f1..13afc35 100644 --- a/routes/weather.ts +++ b/routes/weather.ts @@ -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,45 +17,51 @@ 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 > { - // Generate URL for autocomplete request - const url = "http://autocomplete.wunderground.com/aq?h=0&query=" + - encodeURIComponent( location ); - - let data; - try { - data = await getData( url ); - } catch (err) { - // If the request fails, indicate no data was found. - throw "An API error occurred while attempting to resolve location"; - } - - // Check if the data is valid - if ( typeof data.RESULTS === "object" && data.RESULTS.length && data.RESULTS[ 0 ].tz !== "MISSING" ) { - - // If it is, reply with an array containing the GPS coordinates - return [ data.RESULTS[ 0 ].lat, data.RESULTS[ 0 ].lon ]; - } else { - - // Otherwise, indicate no data was found + 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 httpJSONRequest( url ); + } catch (err) { + // If the request fails, indicate no data was found. + throw "An API error occurred while attempting to resolve location"; + } + + // Check if the data is valid + if ( typeof data.RESULTS === "object" && data.RESULTS.length && data.RESULTS[ 0 ].tz !== "MISSING" ) { + + // If it is, reply with an array containing the GPS coordinates + return [ data.RESULTS[ 0 ].lat, data.RESULTS[ 0 ].lon ]; + } else { + + // 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; - } - } + 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, @@ -359,11 +371,16 @@ exports.getWateringData = async function( req: express.Request, res: express.Res } location = coordinates; - } + } // 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; -} diff --git a/server.ts b/server.ts index 46b3c7a..2716c85 100644 --- a/server.ts +++ b/server.ts @@ -1,14 +1,19 @@ -var express = require( "express" ), - weather = require( "./routes/weather.js" ), - cors = require( "cors" ), - host = process.env.HOST || "127.0.0.1", - port = process.env.PORT || 3000, - app = express(); +const packageJson = require( "../package.json" ), + express = require( "express" ), + weather = require( "./routes/weather.js" ), + local = require( "./routes/local.js" ), + cors = require( "cors" ); -if ( !process.env.HOST || !process.env.PORT ) { +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 || !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; diff --git a/tsconfig.json b/tsconfig.json index f850c6d..98a056c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ }, "include": [ "server.ts", + "types.ts", "routes/**/*" ] } diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..c5ae0d4 --- /dev/null +++ b/types.ts @@ -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; +}