Merge pull request #22 from Derpthemeus/ts-refactor
Refactor codebase to TypeScript
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,6 +4,8 @@ node_modules
|
|||||||
coverage/*
|
coverage/*
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
.vscode
|
.vscode
|
||||||
|
.idea
|
||||||
|
js
|
||||||
|
|
||||||
# Elastic Beanstalk Files
|
# Elastic Beanstalk Files
|
||||||
.elasticbeanstalk/*
|
.elasticbeanstalk/*
|
||||||
|
|||||||
119
package-lock.json
generated
119
package-lock.json
generated
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "os-weather-service",
|
"name": "os-weather-service",
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -26,6 +26,117 @@
|
|||||||
"@turf/helpers": "6.x"
|
"@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": {
|
"abbrev": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||||
"integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c="
|
"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": {
|
"underscore.string": {
|
||||||
"version": "3.3.5",
|
"version": "3.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/underscore.string/-/underscore.string-3.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/underscore.string/-/underscore.string-3.3.5.tgz",
|
||||||
|
|||||||
13
package.json
13
package.json
@@ -5,7 +5,8 @@
|
|||||||
"repository": "https://github.com/OpenSprinkler/Weather-Weather",
|
"repository": "https://github.com/OpenSprinkler/Weather-Weather",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "mocha --exit test",
|
"test": "mocha --exit test",
|
||||||
"start": "node server"
|
"start": "node js/server",
|
||||||
|
"compile": "tsc"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
@@ -18,11 +19,19 @@
|
|||||||
"suncalc": "^1.8.0"
|
"suncalc": "^1.8.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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",
|
"chai": "^4.2.0",
|
||||||
"grunt-contrib-jshint": "^1.1.0",
|
"grunt-contrib-jshint": "^1.1.0",
|
||||||
"hippie": "^0.5.2",
|
"hippie": "^0.5.2",
|
||||||
"istanbul": "^0.4.5",
|
"istanbul": "^0.4.5",
|
||||||
"mocha": "^5.2.0",
|
"mocha": "^5.2.0",
|
||||||
"nock": "^9.6.1"
|
"nock": "^9.6.1",
|
||||||
|
"typescript": "^3.4.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
routes/local.js → routes/local.ts
Executable file → Normal file
41
routes/local.js → routes/local.ts
Executable file → Normal file
@@ -1,18 +1,22 @@
|
|||||||
var CronJob = require( "cron" ).CronJob,
|
import * as express from "express";
|
||||||
server = require( "../server.js" ),
|
|
||||||
today = {}, yesterday = {},
|
|
||||||
count = { temp: 0, humidity: 0 },
|
|
||||||
current_date = new Date(),
|
|
||||||
last_bucket;
|
|
||||||
|
|
||||||
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() &&
|
return d1.getFullYear() === d2.getFullYear() &&
|
||||||
d1.getMonth() === d2.getMonth() &&
|
d1.getMonth() === d2.getMonth() &&
|
||||||
d1.getDate() === d2.getDate();
|
d1.getDate() === d2.getDate();
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.captureWUStream = function( req, res ) {
|
exports.captureWUStream = function( req: express.Request, res: express.Response ) {
|
||||||
var prev, curr;
|
let prev: number, curr: number;
|
||||||
|
|
||||||
if ( !( "dateutc" in req.query ) || !sameDay( current_date, new Date( req.query.dateutc + "Z") )) {
|
if ( !( "dateutc" in req.query ) || !sameDay( current_date, new Date( req.query.dateutc + "Z") )) {
|
||||||
res.send( "Error: Bad date range\n" );
|
res.send( "Error: Bad date range\n" );
|
||||||
@@ -39,12 +43,12 @@ exports.captureWUStream = function( req, res ) {
|
|||||||
res.send( "success\n" );
|
res.send( "success\n" );
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.useLocalWeather = function() {
|
exports.useLocalWeather = function(): boolean {
|
||||||
return server.pws !== "none" ? true : false;
|
return server.pws !== "none" ? true : false;
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.getLocalWeather = function() {
|
exports.getLocalWeather = function(): LocalWeather {
|
||||||
var result = {};
|
const result: LocalWeather = {};
|
||||||
|
|
||||||
// Use today's weather if we dont have information for yesterday yet (i.e. on startup)
|
// Use today's weather if we dont have information for yesterday yet (i.e. on startup)
|
||||||
Object.assign( result, today, yesterday);
|
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
|
// PWS report "buckets" so consider it still raining if last bucket was less than an hour ago
|
||||||
if ( last_bucket !== undefined ) {
|
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;
|
return result;
|
||||||
@@ -68,3 +72,14 @@ new CronJob( "0 0 0 * * *", function() {
|
|||||||
count.temp = 0; count.humidity = 0;
|
count.temp = 0; count.humidity = 0;
|
||||||
current_date = new Date();
|
current_date = new Date();
|
||||||
}, null, true );
|
}, null, true );
|
||||||
|
|
||||||
|
|
||||||
|
interface PWSStatus {
|
||||||
|
temp?: number;
|
||||||
|
humidity?: number;
|
||||||
|
precip?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalWeather extends PWSStatus {
|
||||||
|
raining?: boolean;
|
||||||
|
}
|
||||||
@@ -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 ] );
|
|
||||||
}
|
|
||||||
580
routes/weather.ts
Normal file
580
routes/weather.ts
Normal file
@@ -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 || "";
|
||||||
|
}
|
||||||
13
server.js → server.ts
Executable file → Normal file
13
server.js → server.ts
Executable file → Normal file
@@ -1,9 +1,10 @@
|
|||||||
var package = require( "./package.json" ),
|
const packageJson = require( "../package.json" ),
|
||||||
express = require( "express" ),
|
express = require( "express" ),
|
||||||
weather = require( "./routes/weather.js" ),
|
weather = require( "./routes/weather.js" ),
|
||||||
local = require( "./routes/local.js" ),
|
local = require( "./routes/local.js" ),
|
||||||
cors = require( "cors" ),
|
cors = require( "cors" );
|
||||||
host = process.env.HOST || "127.0.0.1",
|
|
||||||
|
let host = process.env.HOST || "127.0.0.1",
|
||||||
port = process.env.PORT || 3000,
|
port = process.env.PORT || 3000,
|
||||||
pws = process.env.PWS || "none",
|
pws = process.env.PWS || "none",
|
||||||
app = express();
|
app = express();
|
||||||
@@ -31,7 +32,7 @@ if ( pws === "WU" ) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.get( "/", function( req, res ) {
|
app.get( "/", function( req, res ) {
|
||||||
res.send( package.description + " v" + package.version );
|
res.send( packageJson.description + " v" + packageJson.version );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
// Handle 404 error
|
// Handle 404 error
|
||||||
@@ -42,10 +43,10 @@ app.use( function( req, res ) {
|
|||||||
|
|
||||||
// Start listening on the service port
|
// Start listening on the service port
|
||||||
app.listen( port, host, function() {
|
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" ) {
|
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 );
|
||||||
}
|
}
|
||||||
} );
|
} );
|
||||||
|
|
||||||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es6",
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noEmitOnError": true,
|
||||||
|
"outDir": "js/",
|
||||||
|
"sourceMap": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"server.ts",
|
||||||
|
"types.ts",
|
||||||
|
"routes/**/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
65
types.ts
Normal file
65
types.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user