diff --git a/.gitignore b/.gitignore index 554c800..00170b7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ node_modules coverage/* npm-debug.log .vscode +.idea +js # Elastic Beanstalk Files .elasticbeanstalk/* diff --git a/package-lock.json b/package-lock.json index 489f193..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": { @@ -26,6 +26,117 @@ "@turf/helpers": "6.x" } }, + "@types/body-parser": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.17.0.tgz", + "integrity": "sha512-a2+YeUjPkztKJu5aIF2yArYFQQp8d51wZ7DavSHjFuY1mqVgidGyzEQ41JIVNy82fXj8yPgy2vJmfIywgESW6w==", + "dev": true, + "requires": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "@types/connect": { + "version": "3.4.32", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.32.tgz", + "integrity": "sha512-4r8qa0quOvh7lGD0pre62CAb1oni1OO6ecJLGCezTmhQ8Fz50Arx9RUszryR8KlgK6avuSXvviL6yWyViQABOg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-GmK8AKu8i+s+EChK/uZ5IbrXPcPaQKWaNSGevDT/7o3gFObwSUQwqb1jMqxuo+YPvj0ckGzINI+EO7EHcmJjKg==", + "dev": true, + "requires": { + "@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", + "integrity": "sha512-ftQl3DtBvqHl9L16tpqqzA4YzCSXZfi7g8cQceTz5rOlYtk/IZbFjAv3mLOQlNIgOaylCQWQoBdDQHPgEBJPHg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/express": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.16.1.tgz", + "integrity": "sha512-V0clmJow23WeyblmACoxbHBu2JKlE5TiIme6Lem14FnPW9gsttyHtk6wq7njcdIWH1njAaFgR8gW09lgY98gQg==", + "dev": true, + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "*", + "@types/serve-static": "*" + } + }, + "@types/express-serve-static-core": { + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.16.4.tgz", + "integrity": "sha512-x/8h6FHm14rPWnW2HP5likD/rsqJ3t/77OWx2PLxym0hXbeBWQmcPyHmwX+CtCQpjIfgrUdEoDFcLPwPZWiqzQ==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/range-parser": "*" + } + }, + "@types/mime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.1.tgz", + "integrity": "sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw==", + "dev": true + }, + "@types/moment-timezone": { + "version": "0.5.12", + "resolved": "https://registry.npmjs.org/@types/moment-timezone/-/moment-timezone-0.5.12.tgz", + "integrity": "sha512-hnHH2+Efg2vExr/dSz+IX860nSiyk9Sk4pJF2EmS11lRpMcNXeB4KBW5xcgw2QPsb9amTXdsVNEe5IoJXiT0uw==", + "dev": true, + "requires": { + "moment": ">=2.14.0" + } + }, + "@types/node": { + "version": "10.14.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.14.6.tgz", + "integrity": "sha512-Fvm24+u85lGmV4hT5G++aht2C5I4Z4dYlWZIh62FAfFO/TfzXtPpoLI6I7AuBWkIFqZCnhFOoTT7RjjaIL5Fjg==", + "dev": true + }, + "@types/range-parser": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz", + "integrity": "sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==", + "dev": true + }, + "@types/serve-static": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.2.tgz", + "integrity": "sha512-/BZ4QRLpH/bNYgZgwhKEh+5AsboDBcUdlBYgzoLX0fpj3Y2gp6EApyOlM3bK53wQS/OE1SrdSYBAbux2D1528Q==", + "dev": true, + "requires": { + "@types/express-serve-static-core": "*", + "@types/mime": "*" + } + }, + "@types/suncalc": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@types/suncalc/-/suncalc-1.8.0.tgz", + "integrity": "sha512-1Bx7KgoCLP8LuKaY9whWiX0Y8JMEB9gmZHNJigainwFuv3gEkZvTx0AGNvnA5nSu1daQcJDKScm9tNpW/ZjpjA==", + "dev": true + }, "abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -5763,6 +5874,12 @@ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" }, + "typescript": { + "version": "3.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.4.5.tgz", + "integrity": "sha512-YycBxUb49UUhdNMU5aJ7z5Ej2XGmaIBL0x34vZ82fn3hGvD+bgrMrVDpatgz2f7YxUMJxMkbWxJZeAvDxVe7Vw==", + "dev": true + }, "underscore.string": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/underscore.string/-/underscore.string-3.3.5.tgz", diff --git a/package.json b/package.json index f6f6116..d8c408a 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "repository": "https://github.com/OpenSprinkler/Weather-Weather", "scripts": { "test": "mocha --exit test", - "start": "node server" + "start": "node js/server", + "compile": "tsc" }, "dependencies": { "cors": "^2.8.5", @@ -18,11 +19,19 @@ "suncalc": "^1.8.0" }, "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", + "@types/node": "^10.14.6", + "@types/suncalc": "^1.8.0", "chai": "^4.2.0", "grunt-contrib-jshint": "^1.1.0", "hippie": "^0.5.2", "istanbul": "^0.4.5", "mocha": "^5.2.0", - "nock": "^9.6.1" + "nock": "^9.6.1", + "typescript": "^3.4.5" } } diff --git a/routes/local.js b/routes/local.ts old mode 100755 new mode 100644 similarity index 70% rename from routes/local.js rename to routes/local.ts index 5bb7542..833c473 --- a/routes/local.js +++ b/routes/local.ts @@ -1,18 +1,22 @@ -var CronJob = require( "cron" ).CronJob, - server = require( "../server.js" ), - today = {}, yesterday = {}, - count = { temp: 0, humidity: 0 }, - current_date = new Date(), - last_bucket; +import * as express from "express"; -function sameDay(d1, d2) { +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, res ) { - var prev, curr; +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" ); @@ -39,12 +43,12 @@ exports.captureWUStream = function( req, res ) { res.send( "success\n" ); }; -exports.useLocalWeather = function() { +exports.useLocalWeather = function(): boolean { return server.pws !== "none" ? true : false; }; -exports.getLocalWeather = function() { - var result = {}; +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); @@ -55,7 +59,7 @@ exports.getLocalWeather = function() { // 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 ); + result.raining = ( ( Date.now() - +last_bucket ) / 1000 / 60 / 60 < 1 ); } return result; @@ -68,3 +72,14 @@ new CronJob( "0 0 0 * * *", function() { 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.js b/routes/weather.js deleted file mode 100755 index 59d2396..0000000 --- a/routes/weather.js +++ /dev/null @@ -1,453 +0,0 @@ -var http = require( "http" ), - local = require( "../routes/local.js" ), - SunCalc = require( "suncalc" ), - moment = require( "moment-timezone" ), - geoTZ = require( "geo-tz" ), - - // Define regex filters to match against location - filters = { - gps: /^[-+]?([1-8]?\d(\.\d+)?|90(\.0+)?),\s*[-+]?(180(\.0+)?|((1[0-7]\d)|([1-9]?\d))(\.\d+)?)$/, - pws: /^(?:pws|icao|zmw):/, - url: /^https?:\/\/([\w\.-]+)(:\d+)?(\/.*)?$/, - time: /(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})([+-])(\d{2})(\d{2})/, - timezone: /^()()()()()()([+-])(\d{2})(\d{2})/ - }; - -// If location does not match GPS or PWS/ICAO, then attempt to resolve -// location using Weather Underground autocomplete API -function resolveCoordinates( location, callback ) { - - if ( filters.pws.test( location ) ) { - callback( false ); - } else if ( filters.gps.test( location ) ) { - location = location.split( "," ); - location = [ parseFloat( location[ 0 ] ), parseFloat( location[ 1 ] ) ]; - callback( location ); - } else { - // Generate URL for autocomplete request - var url = "http://autocomplete.wunderground.com/aq?h=0&query=" + - encodeURIComponent( location ); - - httpJSONRequest( url, function( data ) { - if ( typeof data.RESULTS === "object" && data.RESULTS.length ) { - callback( [ data.RESULTS[ 0 ].lat, data.RESULTS[ 0 ].lon ] ); - } else { - callback( false ); - } - } ); - } -} - -// Retrieve data from Open Weather Map for water level calculations -function getOWMWateringData( location, callback ) { - var OWM_API_KEY = process.env.OWM_API_KEY, - forecastUrl = "http://api.openweathermap.org/data/2.5/forecast?appid=" + OWM_API_KEY + "&units=imperial&lat=" + location[ 0 ] + "&lon=" + location[ 1 ]; - - getTimeData( location, function( weather ) { - - // Perform the HTTP request to retrieve the weather data - httpJSONRequest( forecastUrl, function( forecast ) { - - if ( !forecast || !forecast.list ) { - callback( weather ); - return; - } - - weather.temp = 0; - weather.humidity = 0; - weather.precip = 0; - - var periods = Math.min(forecast.list.length, 10); - for ( var index = 0; index < periods; index++ ) { - weather.temp += parseFloat( forecast.list[ index ].main.temp ); - weather.humidity += parseInt( forecast.list[ index ].main.humidity ); - weather.precip += ( forecast.list[ index ].rain ? parseFloat( forecast.list[ index ].rain[ "3h" ] || 0 ) : 0 ); - } - - weather.temp = weather.temp / periods; - weather.humidity = weather.humidity / periods; - weather.precip = weather.precip / 25.4; - weather.raining = ( forecast.list[ 0 ].rain ? ( parseFloat( forecast.list[ 0 ].rain[ "3h" ] || 0 ) > 0 ) : false ); - - callback( weather ); - } ); - } ); -} - -// Retrieve weather data from Open Weather Map for App -function getOWMWeatherData( location, callback ) { - var OWM_API_KEY = process.env.OWM_API_KEY, - currentUrl = "http://api.openweathermap.org/data/2.5/weather?appid=" + OWM_API_KEY + "&units=imperial&lat=" + location[ 0 ] + "&lon=" + location[ 1 ], - forecastDailyUrl = "http://api.openweathermap.org/data/2.5/forecast/daily?appid=" + OWM_API_KEY + "&units=imperial&lat=" + location[ 0 ] + "&lon=" + location[ 1 ]; - - getTimeData( location, function( weather ) { - - httpJSONRequest( currentUrl, function( current ) { - - httpJSONRequest( forecastDailyUrl, function( forecast ) { - - if ( !current || !current.main || !current.wind || !current.weather || !forecast || !forecast.list ) { - callback( weather ); - return; - } - - weather.temp = parseInt( current.main.temp ); - weather.humidity = parseInt( current.main.humidity ); - weather.wind = parseInt( current.wind.speed ); - weather.description = current.weather[0].description; - weather.icon = current.weather[0].icon; - - weather.region = forecast.city.country; - weather.city = forecast.city.name; - weather.minTemp = parseInt( forecast.list[ 0 ].temp.min ); - weather.maxTemp = parseInt( forecast.list[ 0 ].temp.max ); - weather.precip = ( forecast.list[ 0 ].rain ? parseFloat( forecast.list[ 0 ].rain || 0 ) : 0 ) / 25.4; - weather.forecast = []; - - for ( var 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 - } ); - } - - callback( weather ); - } ); - } ); - } ); -} - -// Retrieve weather data from Local record -function getLocalWateringData( location, callback ) { - - getTimeData( location, function( weather ) { - Object.assign( weather, local.getLocalWeather() ); - location = location.join( "," ); - - callback( weather ); - } ); -} - -// Calculate timezone and sun rise/set information -function getTimeData( location, callback ) { - var timezone = moment().tz( geoTZ( location[ 0 ], location[ 1 ] ) ).utcOffset(); - var tzOffset = getTimezone( timezone, "minutes" ); - - // Calculate sunrise and sunset since Weather Underground does not provide it - var sunData = SunCalc.getTimes( new Date(), location[ 0 ], location[ 1 ] ); - - sunData.sunrise.setUTCMinutes( sunData.sunrise.getUTCMinutes() + tzOffset ); - sunData.sunset.setUTCMinutes( sunData.sunset.getUTCMinutes() + tzOffset ); - - callback( { - timezone: timezone, - sunrise: ( sunData.sunrise.getUTCHours() * 60 + sunData.sunrise.getUTCMinutes() ), - sunset: ( sunData.sunset.getUTCHours() * 60 + sunData.sunset.getUTCMinutes() ) - } ); -} - -// Calculates the resulting water scale using the provided weather data, adjustment method and options -function calculateWeatherScale( adjustmentMethod, adjustmentOptions, weather ) { - - // Zimmerman method - if ( adjustmentMethod === 1 ) { - var humidityBase = 30, tempBase = 70, precipBase = 0; - - // Check to make sure valid data exists for all factors - if ( !validateValues( [ "temp", "humidity", "precip" ], weather ) ) { - 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; - } - - var humidityFactor = ( humidityBase - weather.humidity ), - tempFactor = ( ( weather.temp - tempBase ) * 4 ), - precipFactor = ( ( precipBase - weather.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 parseInt( Math.min( Math.max( 0, 100 + humidityFactor + tempFactor + precipFactor ), 200 ) ); - } - - return -1; -} - -// 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. -// -// California watering restriction prevents watering if precipitation over two days is greater -// than 0.01" over the past 48 hours. -function checkWeatherRestriction( adjustmentValue, weather ) { - - var californiaRestriction = ( adjustmentValue >> 7 ) & 1; - - if ( californiaRestriction ) { - - // If the California watering restriction is in use then prevent watering - // if more then 0.1" of rain has accumulated in the past 48 hours - if ( weather.precip > 0.1 ) { - return true; - } - } - - return false; -} - -exports.getWeatherData = function( req, res ) { - var location = req.query.loc; - - // Attempt to resolve provided location to GPS coordinates when it does not match - // a GPS coordinate or Weather Underground location using Weather Underground autocomplete - resolveCoordinates( location, function( result ) { - if ( result === false ) { - res.send( "Error: Unable to resolve location" ); - return; - } - - location = result; - getOWMWeatherData( location, function( data ) { - data.location = location; - res.json( data ); - } ); - } ); -}; - -// API Handler when using the weatherX.py where X represents the -// adjustment method which is encoded to also carry the watering -// restriction and therefore must be decoded -exports.getWateringData = function( req, res ) { - - // The adjustment method is encoded by the OpenSprinkler firmware and must be - // parsed. This allows the adjustment method and the restriction type to both - // be saved in the same byte. - var adjustmentMethod = req.params[ 0 ] & ~( 1 << 7 ), - adjustmentOptions = req.query.wto, - location = req.query.loc, - outputFormat = req.query.format, - remoteAddress = req.headers[ "x-forwarded-for" ] || req.connection.remoteAddress, - - // Function that will accept the weather after it is received from the API - // Data will be processed to retrieve the resulting scale, sunrise/sunset, timezone, - // and also calculate if a restriction is met to prevent watering. - finishRequest = function( weather ) { - if ( !weather ) { - if ( typeof location[ 0 ] === "number" && typeof location[ 1 ] === "number" ) { - getTimeData( location, finishRequest ); - } else { - res.send( "Error: No weather data found." ); - } - - return; - } - - var scale = calculateWeatherScale( adjustmentMethod, adjustmentOptions, weather ), - rainDelay = -1; - - // Check for any user-set restrictions and change the scale to 0 if the criteria is met - if ( checkWeatherRestriction( req.params[ 0 ], weather ) ) { - scale = 0; - } - - // If any weather adjustment is being used, check the rain status - if ( adjustmentMethod > 0 && weather.hasOwnProperty( "raining" ) && weather.raining ) { - - // If it is raining and the user has weather-based rain delay as the adjustment method then apply the specified delay - if ( adjustmentMethod === 2 ) { - - rainDelay = ( adjustmentOptions && adjustmentOptions.hasOwnProperty( "d" ) ) ? adjustmentOptions.d : 24; - } else { - - // For any other adjustment method, apply a scale of 0 (as the scale will revert when the rain stops) - scale = 0; - } - } - - var data = { - scale: scale, - rd: rainDelay, - tz: getTimezone( weather.timezone ), - sunrise: weather.sunrise, - sunset: weather.sunset, - eip: ipToInt( remoteAddress ), - rawData: { - h: Math.round( weather.humidity ), - p: Math.round( weather.precip * 100 ) / 100, - t: Math.round( weather.temp * 10 ) / 10, - raining: weather.raining ? 1 : 0 - } - }; - - 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 ); - } else { - res.send( "&scale=" + data.scale + - "&rd=" + data.rd + - "&tz=" + data.tz + - "&sunrise=" + data.sunrise + - "&sunset=" + data.sunset + - "&eip=" + data.eip + - "&rawData=" + JSON.stringify( data.rawData ) - ); - } - }; - - if ( local.useLocalWeather() ) { - console.log( "OpenSprinkler Weather Query: %s", JSON.stringify( req.query ) ); - } - - // Exit if no location is provided - if ( !location ) { - res.send( "Error: No location provided." ); - return; - } - - // 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 - remoteAddress = remoteAddress.split( "," )[ 0 ]; - - // Parse weather adjustment options - try { - - // Parse data that may be encoded - adjustmentOptions = decodeURIComponent( adjustmentOptions.replace( /\\x/g, "%" ) ); - - // Reconstruct JSON string from deformed controller output - adjustmentOptions = JSON.parse( "{" + adjustmentOptions + "}" ); - } catch ( err ) { - - // If the JSON is not valid, do not incorporate weather adjustment options - adjustmentOptions = false; - } - - // Attempt to resolve provided location to GPS coordinates when it does not match - // a GPS coordinate or Weather Underground location using Weather Underground autocomplete - resolveCoordinates( location, function( result ) { - if ( result === false ) { - res.send( "Error: Unable to resolve location" ); - return; - } - - location = result; - if ( local.useLocalWeather() ) { - getLocalWateringData( location, finishRequest ); - } else { - getOWMWateringData( location, finishRequest ); - } - } ); -}; - -// Generic HTTP request handler that parses the URL and uses the -// native Node.js http module to perform the request. Returns a Json object -function httpJSONRequest( url, callback ) { - url = url.match( filters.url ); - - var options = { - host: url[ 1 ], - port: url[ 2 ] || 80, - path: url[ 3 ] - }; - - http.get( options, function( response ) { - var data = ""; - - // Reassemble the data as it comes in - response.on( "data", function( chunk ) { - data += chunk; - } ); - - // Once the data is completely received, return it to the callback - response.on( "end", function() { - data = JSON.parse( data ); - callback( data ); - } ); - } ).on( "error", function() { - - // If the HTTP request fails, return false - callback( false ); - } ); -} - -// Checks to make sure an array contains the keys provided and returns true or false -function validateValues( keys, array ) { - var key; - - for ( key in keys ) { - if ( !keys.hasOwnProperty( key ) ) { - continue; - } - - key = keys[ key ]; - - if ( !array.hasOwnProperty( key ) || typeof array[ key ] !== "number" || isNaN( array[ key ] ) || array[ key ] === null || array[ key ] === -999 ) { - return false; - } - } - - return true; -} - -// Accepts a time string formatted in ISO-8601 or just the timezone -// offset and returns the timezone. -// The timezone output is formatted for OpenSprinkler Unified firmware. -function getTimezone( time, format ) { - - var hour, minute, tz; - - if ( typeof time === "number" ) { - hour = Math.floor( time / 60 ); - minute = time % 60; - } else { - - // Match the provided time string against a regex for parsing - time = time.match( filters.time ) || time.match( filters.timezone ); - - hour = parseInt( time[ 7 ] + time[ 8 ] ); - minute = parseInt( time[ 9 ] ); - } - - if ( format === "minutes" ) { - tz = ( hour * 60 ) + minute; - } else { - - // Convert the timezone into the OpenSprinkler encoded format - minute = ( minute / 15 >> 0 ) / 4; - hour = hour + ( hour >= 0 ? minute : -minute ); - - tz = ( ( hour + 12 ) * 4 ) >> 0; - } - - return tz; -} - -// Converts IP string to integer -function ipToInt( ip ) { - ip = ip.split( "." ); - return ( ( ( ( ( ( +ip[ 0 ] ) * 256 ) + ( +ip[ 1 ] ) ) * 256 ) + ( +ip[ 2 ] ) ) * 256 ) + ( +ip[ 3 ] ); -} diff --git a/routes/weather.ts b/routes/weather.ts new file mode 100644 index 0000000..13afc35 --- /dev/null +++ b/routes/weather.ts @@ -0,0 +1,580 @@ +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" ), + + // Define regex filters to match against location + filters = { + gps: /^[-+]?([1-8]?\d(\.\d+)?|90(\.0+)?),\s*[-+]?(180(\.0+)?|((1[0-7]\d)|([1-9]?\d))(\.\d+)?)$/, + pws: /^(?:pws|icao|zmw):/, + url: /^https?:\/\/([\w\.-]+)(:\d+)?(\/.*)?$/, + time: /(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})([+-])(\d{2})(\d{2})/, + timezone: /^()()()()()()([+-])(\d{2})(\d{2})/ + }; + +/** + * 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 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/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 httpJSONRequest(url: string ): Promise< any > { + try { + const data: string = await httpRequest(url); + return JSON.parse(data); + } catch (err) { + // Reject the promise if there was an error making the request or parsing the JSON. + throw err; + } +} + +/** + * 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 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< 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 httpJSONRequest( forecastUrl ); + } 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 ( !forecast || !forecast.list ) { + return undefined; + } + + let totalTemp = 0, + totalHumidity = 0, + totalPrecip = 0; + + const periods = Math.min(forecast.list.length, 10); + 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 { + temp: totalTemp / periods, + humidity: totalHumidity / periods, + precip: totalPrecip / 25.4, + raining: ( forecast.list[ 0 ].rain ? ( parseFloat( forecast.list[ 0 ].rain[ "3h" ] || 0 ) > 0 ) : false ) + }; +} + +/** + * 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 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< 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 = { + 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; +} + +/** + * 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. + * @return The TimeData for the specified coordinates. + */ +function getTimeData( coordinates: GeoCoordinates ): TimeData { + const timezone = moment().tz( geoTZ( coordinates[ 0 ], coordinates[ 1 ] ) ).utcOffset(); + const tzOffset: number = getTimezone( timezone, true ); + + // Calculate sunrise and sunset since Weather Underground does not provide it + const sunData = SunCalc.getTimes( new Date(), coordinates[ 0 ], coordinates[ 1 ] ); + + sunData.sunrise.setUTCMinutes( sunData.sunrise.getUTCMinutes() + tzOffset ); + sunData.sunset.setUTCMinutes( sunData.sunset.getUTCMinutes() + tzOffset ); + + return { + timezone: timezone, + sunrise: ( sunData.sunrise.getUTCHours() * 60 + sunData.sunrise.getUTCMinutes() ), + sunset: ( sunData.sunset.getUTCHours() * 60 + sunData.sunset.getUTCMinutes() ) + }; +} + +/** + * Calculates how much watering should be scaled based on weather and adjustment options. + * @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 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: WateringData ): number { + + // Zimmerman method + if ( adjustmentMethod === 1 ) { + 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 temp = wateringData.temp, + humidityFactor = ( humidityBase - wateringData.humidity ), + tempFactor = ( ( 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 ) ); + } + + return -1; +} + +/** + * 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: + * + * - California watering restriction prevents watering if precipitation over two days is greater than 0.1" over the past + * 48 hours. + * @param adjustmentValue The adjustment value, which indicates which restrictions should be checked. + * @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: WateringData ): boolean { + + const californiaRestriction = ( adjustmentValue >> 7 ) & 1; + + if ( californiaRestriction ) { + + // TODO this is currently checking if the forecasted precipitation over the next 30 hours is >0.1 inches + // If the California watering restriction is in use then prevent watering + // if more then 0.1" of rain has accumulated in the past 48 hours + if ( weather.precip > 0.1 ) { + return true; + } + } + + return false; +} + +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; + 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: WeatherData = await getOWMWeatherData( coordinates ); + + res.json( { + ...timeData, + ...weatherData, + location: coordinates + } ); +}; + +// API Handler when using the weatherX.py where X represents the +// adjustment method which is encoded to also carry the watering +// restriction and therefore must be decoded +exports.getWateringData = async function( req: express.Request, res: express.Response ) { + + // The adjustment method is encoded by the OpenSprinkler firmware and must be + // parsed. This allows the adjustment method and the restriction type to both + // be saved in the same byte. + let adjustmentMethod: number = req.params[ 0 ] & ~( 1 << 7 ), + adjustmentOptionsString: string = getParameter(req.query.wto), + location: string | GeoCoordinates = getParameter(req.query.loc), + outputFormat: string = getParameter(req.query.format), + remoteAddress: string = getParameter(req.headers[ "x-forwarded-for" ]) || req.connection.remoteAddress, + adjustmentOptions: AdjustmentOptions; + + + // Exit if no location is provided + if ( !location ) { + res.send( "Error: No location provided." ); + return; + } + + // 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 + remoteAddress = remoteAddress.split( "," )[ 0 ]; + + // Parse weather adjustment options + try { + + // Parse data that may be encoded + adjustmentOptionsString = decodeURIComponent( adjustmentOptionsString.replace( /\\x/g, "%" ) ); + + // Reconstruct JSON string from deformed controller output + adjustmentOptions = JSON.parse( "{" + adjustmentOptionsString + "}" ); + } catch ( err ) { + + // If the JSON is not valid, do not incorporate weather adjustment options + adjustmentOptions = undefined; + } + + let coordinates: GeoCoordinates; + // Parse location string + if ( filters.pws.test( location ) ) { + + // Weather Underground is discontinued and PWS or ICAO cannot be resolved + res.send( "Error: Weather Underground is discontinued." ); + return; + } else if ( filters.gps.test( location ) ) { + + // Handle GPS coordinates by storing each coordinate in an array + const splitLocation: string[] = location.split( "," ); + coordinates = [ parseFloat( splitLocation[ 0 ] ), parseFloat( splitLocation[ 1 ] ) ]; + location = coordinates; + + } 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; + } + + location = coordinates; + } + + // Continue with the weather request + let timeData: TimeData = getTimeData( 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, + // 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; + } + } + + let scale: number = calculateWeatherScale( adjustmentMethod, adjustmentOptions, wateringData ), + rainDelay: number = -1; + + 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 > 0 && wateringData && 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 === 2 ) { + + rainDelay = ( adjustmentOptions && adjustmentOptions.hasOwnProperty( "d" ) ) ? adjustmentOptions.d : 24; + } else { + + // For any other adjustment method, apply a scale of 0 (as the scale will revert when the rain stops) + scale = 0; + } + } + + const data = { + scale: scale, + rd: rainDelay, + tz: getTimezone( timeData.timezone, undefined ), + sunrise: timeData.sunrise, + sunset: timeData.sunset, + eip: ipToInt( remoteAddress ), + // TODO this may need to be changed (https://github.com/OpenSprinkler/OpenSprinkler-Weather/pull/11#issuecomment-491037948) + rawData: { + h: wateringData ? wateringData.humidity : 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 + } + }; + + 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 ); + } else { + res.send( "&scale=" + data.scale + + "&rd=" + data.rd + + "&tz=" + data.tz + + "&sunrise=" + data.sunrise + + "&sunset=" + data.sunset + + "&eip=" + data.eip + + "&rawData=" + JSON.stringify( data.rawData ) + ); + } + +}; + +/** + * Makes an HTTP GET request to the specified URL and returns the response body. + * @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 + * Error if the request fails. + */ +async function httpRequest( url: string ): Promise< string > { + return new Promise< any >( ( resolve, reject ) => { + + const splitUrl: string[] = url.match( filters.url ); + + const options = { + host: splitUrl[ 1 ], + port: splitUrl[ 2 ] || 80, + path: splitUrl[ 3 ] + }; + + http.get( options, ( response ) => { + let data = ""; + + // Reassemble the data as it comes in + response.on( "data", ( chunk ) => { + data += chunk; + } ); + + // Once the data is completely received, resolve the promise + response.on( "end", () => { + resolve( data ); + } ); + } ).on( "error", ( err ) => { + + // If the HTTP request fails, reject the promise + reject( err ); + } ); + } ); +} + +/** + * Checks if the specified object contains numeric values for each of the specified keys. + * @param keys A list of keys to validate exist on the specified object. + * @param obj The object to check. + * @return A boolean indicating if the object has numeric values for all of the specified keys. + */ +function validateValues( keys: string[], obj: object ): boolean { + let key: string; + + for ( key in keys ) { + if ( !keys.hasOwnProperty( key ) ) { + continue; + } + + key = keys[ key ]; + + if ( !obj.hasOwnProperty( key ) || typeof obj[ key ] !== "number" || isNaN( obj[ key ] ) || obj[ key ] === null || obj[ key ] === -999 ) { + return false; + } + } + + return true; +} + +/** + * Converts a timezone to an offset in minutes or OpenSprinkler encoded format. + * @param time A time string formatted in ISO-8601 or just the timezone. + * @param useMinutes Indicates if the returned value should be in minutes of the OpenSprinkler encoded format. + * @return The offset of the specified timezone in either minutes or OpenSprinkler encoded format (depending on the + * value of useMinutes). + */ +function getTimezone( time: number | string, useMinutes: boolean = false ): number { + + let hour, minute; + + if ( typeof time === "number" ) { + hour = Math.floor( time / 60 ); + minute = time % 60; + } else { + + // Match the provided time string against a regex for parsing + let splitTime = time.match( filters.time ) || time.match( filters.timezone ); + + hour = parseInt( splitTime[ 7 ] + splitTime[ 8 ] ); + minute = parseInt( splitTime[ 9 ] ); + } + + if ( useMinutes ) { + return ( hour * 60 ) + minute; + } else { + + // Convert the timezone into the OpenSprinkler encoded format + minute = ( minute / 15 >> 0 ) / 4; + hour = hour + ( hour >= 0 ? minute : -minute ); + + return ( ( hour + 12 ) * 4 ) >> 0; + } +} + +/** + * Converts an IP address string to an integer. + * @param ip The string representation of the IP address. + * @return The integer representation of the IP address. + */ +function ipToInt( ip: string ): number { + const split = ip.split( "." ); + return ( ( ( ( ( ( +split[ 0 ] ) * 256 ) + ( +split[ 1 ] ) ) * 256 ) + ( +split[ 2 ] ) ) * 256 ) + ( +split[ 3 ] ); +} + +/** + * Returns a single value for a header/query parameter. If passed a single string, the same string will be returned. If + * an array of strings is passed, the first value will be returned. If this value is null/undefined, an empty string + * will be returned instead. + * @param parameter An array of parameters or a single parameter value. + * @return The first element in the array of parameter or the single parameter provided. + */ +function getParameter( parameter: string | string[] ): string { + if ( Array.isArray( parameter ) ) { + parameter = parameter[0]; + } + + // Return an empty string if the parameter is undefined. + return parameter || ""; +} diff --git a/server.js b/server.ts old mode 100755 new mode 100644 similarity index 69% rename from server.js rename to server.ts index 4383799..2716c85 --- a/server.js +++ b/server.ts @@ -1,12 +1,13 @@ -var package = require( "./package.json" ), - express = require( "express" ), - weather = require( "./routes/weather.js" ), - local = require( "./routes/local.js" ), - cors = require( "cors" ), - host = process.env.HOST || "127.0.0.1", - port = process.env.PORT || 3000, - pws = process.env.PWS || "none", - app = express(); +const packageJson = require( "../package.json" ), + express = require( "express" ), + weather = require( "./routes/weather.js" ), + 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 || !process.env.LOCAL_PWS ) { require( "dotenv" ).load(); @@ -31,7 +32,7 @@ if ( pws === "WU" ) { } app.get( "/", function( req, res ) { - res.send( package.description + " v" + package.version ); + res.send( packageJson.description + " v" + packageJson.version ); } ); // Handle 404 error @@ -42,10 +43,10 @@ app.use( function( req, res ) { // Start listening on the service port app.listen( port, host, function() { - console.log( "%s now listening on %s:%s", package.description, 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", package.description ); + console.log( "%s now listening for local weather stream", packageJson.description ); } } ); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..98a056c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es6", + "noImplicitReturns": true, + "noEmitOnError": true, + "outDir": "js/", + "sourceMap": true, + "skipLibCheck": true + }, + "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; +}