Merge pull request #2 from OpenSprinkler/nodejs-rewrite

Application rewrite from Python to Node
This commit is contained in:
Samer Albahra
2015-07-03 16:49:31 -05:00
12 changed files with 704 additions and 451 deletions

View File

@@ -0,0 +1,37 @@
files:
"/opt/elasticbeanstalk/env.vars" :
mode: "000775"
owner: root
group: users
content: |
export NPM_CONFIG_LOGLEVEL=error
export NODE_PATH=`ls -td /opt/elasticbeanstalk/node-install/node-* | head -1`/bin
"/opt/elasticbeanstalk/hooks/appdeploy/pre/50npm.sh" :
mode: "000775"
owner: root
group: users
content: |
#!/bin/bash
. /opt/elasticbeanstalk/env.vars
function error_exit
{
eventHelper.py --msg "$1" --severity ERROR
exit $2
}
#install not-installed yet app node_modules
if [ ! -d "/var/node_modules" ]; then
mkdir /var/node_modules ;
fi
if [ -d /tmp/deployment/application ]; then
ln -s /var/node_modules /tmp/deployment/application/
fi
OUT=$([ -d "/tmp/deployment/application" ] && cd /tmp/deployment/application && $NODE_PATH/npm install 2>&1) || error_exit "Failed to run npm install. $OUT" $?
echo $OUT
"/opt/elasticbeanstalk/hooks/configdeploy/pre/50npm.sh" :
mode: "000666"
owner: root
group: users
content: |
#no need to run npm install during configdeploy

View File

@@ -0,0 +1,27 @@
files:
"/home/ec2-user/install_mongo.sh" :
mode: "0007555"
owner: root
group: root
content: |
#!/bin/bash
echo "[MongoDB]
name=MongoDB Repository
baseurl=http://downloads-distro.mongodb.org/repo/redhat/os/x86_64
gpgcheck=0
enabled=1" | tee -a /etc/yum.repos.d/mongodb.repo
yum -y update
yum -y install mongodb-org-server mongodb-org-shell mongodb-org-tools
commands:
01install_mongo:
command: ./install_mongo.sh
cwd: /home/ec2-user
test: '[ ! -f /usr/bin/mongo ] && echo "MongoDB not installed"'
services:
sysvinit:
mongod:
enabled: true
ensureRunning: true
commands: ['01install_mongo']

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
.DS_Store
node_modules
.env
WeatherService.zip
# Elastic Beanstalk Files
.elasticbeanstalk/*
!.elasticbeanstalk/*.cfg.yml
!.elasticbeanstalk/*.global.yml

21
.jshintrc Normal file
View File

@@ -0,0 +1,21 @@
{
"boss": true,
"curly": true,
"eqeqeq": true,
"eqnull": true,
"expr": true,
"immed": true,
"noarg": true,
"onevar": true,
"quotmark": "double",
"smarttabs": true,
"trailing": true,
"undef": true,
"unused": true,
"browser": true,
"node": true,
"sub": true,
"globals": {
"module": true
}
}

21
Gruntfile.js Normal file
View File

@@ -0,0 +1,21 @@
module.exports = function( grunt ) {
// Load node-modules;
grunt.loadNpmTasks( "grunt-contrib-jshint" );
// Project configuration.
grunt.initConfig( {
pkg: grunt.file.readJSON( "package.json" ),
jshint: {
main: [ "Gruntfile.js", "server.js", "routes/*.js", "models/*.js" ],
options: {
jshintrc: true
}
}
} );
// Default task(s).
grunt.registerTask( "default", [ "jshint" ] );
};

View File

@@ -3,12 +3,14 @@
## Description
This script is used by OpenSprinkler Unified Firmware to update the water level of the device. It also provides timezone information based on user location along with other local information (sunrise, sunset, daylights saving time, etc).
The production version runs on Amazon Elastic Beanstalk (AWS EB) and therefore this package is tailored to be zipped and uploaded to AWS EB. The script is written in Python.
The production version runs on Amazon Elastic Beanstalk (AWS EB) and therefore this package is tailored to be zipped and uploaded to AWS EB. The script is written in Javascript for Node.JS.
## File Detail
**Requirements.txt** is used to define the required Python modules needed to run the script.
**server.js** is the primary file launching the API daemon.
**Application.py** parses the incoming URL and returns the appropriate values. The script defaults to URL format return however a 'format' parameter can be passed with the value 'json' in order to output JSON.
**routes/*.js** contains all the endpoints for the API service. Currently, only one exists for weather adjustment.
**models/*.js** contains all the database models used by the routes. Currently, only one exists to manage weather cache data.
## Privacy

View File

@@ -1,446 +0,0 @@
#!/usr/bin/python
import urllib
import urllib2
import cgi
import re
import math
import json
import datetime
import time
import sys
import calendar
import pytz
import ephem
from datetime import datetime, timedelta, date
def safe_float(s, dv):
r = dv
try:
r = float(s)
except:
return dv
return r
def safe_int(s, dv):
r = dv
try:
r = int(s)
except:
return dv
return r
def isInt(s):
try:
_v = int(s)
except:
return 0
return 1
def isFloat(s):
try:
_f = float(s)
except:
return 0
return 1
def F2C(temp):
return (temp - 32) * 5 / 9
def C2F(temp):
return temp * 9 / 5 + 32
def mm2in(x):
return x * 0.03937008
def ft2m(x):
return x * 0.3048
def IP2Int(ip):
o = map(int, ip.split('.'))
res = (16777216 * o[0]) + (65536 * o[1]) + (256 * o[2]) + o[3]
return res
def getClientAddress(environ):
try:
return environ['HTTP_X_FORWARDED_FOR'].split(',')[-1].strip()
except KeyError:
return environ['REMOTE_ADDR']
def computeETs(latitude, longitude, elevation, temp_high, temp_low, temp_avg, hum_high, hum_low, hum_avg, wind, solar):
tm = time.gmtime()
dayofyear = tm.tm_yday
latitude = safe_float(latitude, 0)
longitude = safe_float(longitude, 0)
# Converted values
El = ft2m(elevation)
Rs = float(solar) * 0.0864 # W/m2 to MJ/d /m2
Tx = F2C(float(temp_high))
Tn = F2C(float(temp_low))
Tm = F2C(float(temp_avg))
RHx = float(hum_high)
RHn = float(hum_low)
RHm = float(hum_avg)
Td = Tm - (100 - RHm) / 5 # approx. dewpoint (daily mean)
U2 = float(wind) * 0.44704 # wind speed in m/s
# Step 1: Extraterrestrial radiation
Gsc = 0.082
sigma = 4.90e-9
phi = math.pi * latitude / 180
dr = 1 + 0.033 * math.cos(2 * math.pi * dayofyear / 365)
delta = 0.409 * math.sin(2 * math.pi * dayofyear / 365 - 1.39)
omegas = math.acos(-math.tan(phi) * math.tan(delta))
Ra = (24 * 60 / math.pi) * Gsc * dr * (omegas * math.sin(delta)
* math.sin(phi) + math.cos(phi) * math.cos(delta) * math.sin(omegas))
# Step 2: Daily net radiation
Rso = Ra * (0.75 + 2.0e-5 * El) # 5
Rns = (1 - 0.23) * Rs
f = 1.35 * Rs / Rso - 0.35 # 7
esTx = 0.6108 * math.exp(17.27 * Tx / (Tx + 237.3)) # 8
esTn = 0.6108 * math.exp(17.27 * Tn / (Tn + 237.3))
ed = (esTx * RHn / 100 + esTn * RHx / 100) / 2 # 10
ea = (esTx + esTn) / 2 # 22
epsilonp = 0.34 - 0.14 * math.sqrt(ea) # 12
Rnl = -f * epsilonp * sigma * \
((Tx + 273.14) ** 4 + (Tn + 273.15) ** 4) / 2 # 13
Rn = Rns + Rnl
# Step 3: variables needed to compute ET
beta = 101.3 * ((293 - 0.0065 * El) / 293) ** 5.26 # 15
lam = 2.45
gamma = 0.00163 * beta / lam
e0 = 0.6108 * math.exp(17.27 * Tm / (Tm + 237.3)) # 19
Delta = 4099 * e0 / (Tm + 237.3) ** 2 # 20
G = 0
ea = (esTx + esTn) / 2
# Step 4: calculate ETh
ETh = 0.408 * (0.0023 * Ra * (Tm + 17.8) * math.sqrt(Tx - Tn)) # 23
# Step 5: calculate ET0
R0 = 0.408 * Delta * (Rn - G) / (Delta + gamma * (1 + 0.34 * U2)) # 24
A0 = (900 * gamma / (Tm + 273)) * U2 * (ea - ed) / \
(Delta + gamma * (1 + 0.34 * U2)) # 25
ET0 = R0 + A0
# Step 6: calculate ETr
Rr = 0.408 * Delta * (Rn - G) / (Delta + gamma * (1 + 0.38 * U2)) # 27
Ar = (1600 * gamma / (Tm + 273)) * U2 * (ea - ed) / \
(Delta + gamma * (1 + 0.38 * U2)) # 28
ETr = Rr + Ar
return (mm2in(ETh), mm2in(ET0), mm2in(ETr))
def not_found(environ, start_response):
"""Called if no URL matches."""
start_response('404 NOT FOUND', [('Content-Type', 'text/plain')])
return ['Not Found']
def application(environ, start_response):
path = environ.get('PATH_INFO')
uwt = re.match('/weather(\d+)\.py', path)
parameters = cgi.parse_qs(environ.get('QUERY_STRING', ''))
status = '200 OK'
wto = {}
if uwt is not None:
uwt = safe_int(uwt.group(1), 0)
else:
return not_found(environ, start_response)
if 'loc' in parameters:
loc = parameters['loc'][0]
else:
loc = ''
if 'key' in parameters:
key = parameters['key'][0]
else:
key = ''
if 'format' in parameters:
of = parameters['format'][0]
else:
of = ''
if 'fwv' in parameters:
fwv = parameters['fwv'][0]
else:
fwv = ''
if 'wto' in parameters:
wto = json.loads('{' + parameters['wto'][0] + '}')
solar, wind, avehumidity, minhumidity, maxhumidity, maxt, mint, elevation, restrict, maxh, minh, meant, pre, pre_today, h_today, sunrise, sunset, scale, toffset = [
0, 0, 0, 0, 0, 0, 0, 0, 0, -1, -1, -500, -1, -1, -1, -1, -1, -1, -1]
ET = [0, 0, 0]
eip = IP2Int(getClientAddress(environ))
# if loc is GPS coordinate itself
sp = loc.split(',', 1)
if len(sp) == 2 and isFloat(sp[0]) and isFloat(sp[1]):
lat = sp[0]
lon = sp[1]
else:
lat = None
lon = None
# if loc is US 5+4 zip code, strip the last 4
sp = loc.split('-', 1)
if len(sp) == 2 and isInt(sp[0]) and len(sp[0]) == 5 and isInt(sp[1]) and len(sp[1]) == 4:
loc = sp[0]
tzone = None
# if loc is pws, query wunderground geolookup to get GPS coordinates
if loc.startswith('pws:') or loc.startswith('icao:'):
try:
req = urllib2.urlopen('http://api.wunderground.com/api/' +
key + '/conditions/forecast/q/' + urllib.quote(loc) + '.json')
dat = json.load(req)
if 'current_observation' in dat:
v = dat['current_observation'][
'observation_location']['latitude']
if v and isFloat(v):
lat = v
v = dat['current_observation'][
'observation_location']['longitude']
if v and isFloat(v):
lon = v
v = dat['current_observation'][
'observation_location']['elevation']
if v:
elevation = safe_int(int(v.split()[0]), 0)
v = dat['current_observation']['solarradiation']
if v:
solar = safe_int(v, 0)
v = dat['current_observation']['local_tz_long']
if v:
tzone = v
else:
v = dat['current_observation']['local_tz_short']
if v:
tzone = v
forecast = dat['forecast']['simpleforecast']['forecastday'][0]
v = forecast['high']['fahrenheit']
if v:
maxt = safe_int(v, 0)
v = forecast['low']['fahrenheit']
if v:
mint = safe_int(v, 0)
v = forecast['avehumidity']
if v:
avehumidity = safe_int(v, 0)
v = forecast['maxhumidity']
if v:
maxhumidity = safe_int(v, 0)
v = forecast['minhumidity']
if v:
minhumidity = safe_int(v, 0)
v = forecast['avewind']['mph']
if v:
wind = safe_int(v, 0)
except:
lat = None
lon = None
tzone = None
# now do autocomplete lookup to get GPS coordinates
if lat == None or lon == None:
try:
req = urllib2.urlopen(
'http://autocomplete.wunderground.com/aq?h=0&query=' + urllib.quote(loc))
dat = json.load(req)
if dat['RESULTS']:
v = dat['RESULTS'][0]['lat']
if v and isFloat(v):
lat = v
v = dat['RESULTS'][0]['lon']
if v and isFloat(v):
lon = v
v = dat['RESULTS'][0]['tz']
if v:
tzone = v
else:
v = dat['RESULTS'][0]['tz_long']
if v:
tzone = v
except:
lat = None
lon = None
tzone = None
if (lat) and (lon):
if not loc.startswith('pws:') and not loc.startswith('icao:'):
loc = '' + lat + ',' + lon
home = ephem.Observer()
home.lat = lat
home.long = lon
sun = ephem.Sun()
sun.compute(home)
sunrise = calendar.timegm(
home.next_rising(sun).datetime().utctimetuple())
sunset = calendar.timegm(
home.next_setting(sun).datetime().utctimetuple())
if tzone:
try:
tnow = pytz.utc.localize(datetime.utcnow())
tdelta = tnow.astimezone(pytz.timezone(tzone)).utcoffset()
toffset = tdelta.days * 96 + tdelta.seconds / 900 + 48
except:
toffset = -1
if (key != ''):
try:
req = urllib2.urlopen('http://api.wunderground.com/api/' +
key + '/yesterday/conditions/q/' + urllib.quote(loc) + '.json')
dat = json.load(req)
if dat['history'] and dat['history']['dailysummary']:
info = dat['history']['dailysummary'][0]
if info:
v = info['maxhumidity']
if v:
maxh = safe_float(v, maxh)
v = info['minhumidity']
if v:
minh = safe_float(v, minh)
v = info['meantempi']
if v:
meant = safe_float(v, meant)
v = info['precipi']
if v:
pre = safe_float(v, pre)
info = dat['current_observation']
if info:
v = info['precip_today_in']
if v:
pre_today = safe_float(v, pre_today)
v = info['relative_humidity'].replace('%', '')
if v:
h_today = safe_float(v, h_today)
# Check which weather method is being used
if ((uwt & ~(1 << 7)) == 1):
# calculate water time scale, per
# https://github.com/rszimm/sprinklers_pi/blob/master/Weather.cpp
hf = 0
if (maxh >= 0) and (minh >= 0):
hf = 30 - (maxh + minh) / 2
# elif (h_today>=0):
# hf = 30 - h_today
tf = 0
if (meant > -500):
tf = (meant - 70) * 4
rf = 0
if (pre >= 0):
rf -= pre * 200
if (pre_today >= 0):
rf -= pre_today * 200
if 't' in wto:
tf = tf * (wto['t'] / 100.0)
if 'h' in wto:
hf = hf * (wto['h'] / 100.0)
if 'r' in wto:
rf = rf * (wto['r'] / 100.0)
scale = (int)(100 + hf + tf + rf)
if (scale < 0):
scale = 0
if (scale > 200):
scale = 200
elif ((uwt & ~(1 << 7)) == 2):
ET = computeETs(lat, lon, elevation, maxt, mint, meant,
maxhumidity, minhumidity, avehumidity, wind, solar)
# TODO: Actually generate correct scale using ET (ET[1] is ET0
# for short canopy)
scale = safe_int(ET[1] * 100, -1)
# Check weather modifier bits and apply scale modification
if ((uwt >> 7) & 1):
# California modification to prevent watering when rain has
# occured within 48 hours
# Get before yesterday's weather data
beforeYesterday = date.today() - timedelta(2)
req = urllib2.urlopen('http://api.wunderground.com/api/' + key + '/history_' +
beforeYesterday.strftime('%Y%m%d') + '/q/' + urllib.quote(loc) + '.json')
dat = json.load(req)
if dat['history'] and dat['history']['dailysummary']:
info = dat['history']['dailysummary'][0]
if info:
v = info['precipi']
if v:
pre_beforeYesterday = safe_float(v, -1)
preTotal = pre_today + pre + pre_beforeYesterday
if (preTotal > 0.01):
restrict = 1
except:
pass
urllib2.urlopen('https://ssl.google-analytics.com/collect?v=1&t=event&ec=weather&ea=lookup&el=results&ev=' + str(scale) +
'&cd1=' + str(fwv) + '&cd2=' + urllib.quote(loc) + '&cd3=' + str(toffset) + '&cid=555&tid=UA-57507808-1&z=' + str(time.time()))
else:
urllib2.urlopen('https://ssl.google-analytics.com/collect?v=1&t=event&ec=timezone&ea=lookup&el=results&ev=' + str(
toffset) + '&cd1=' + str(fwv) + '&cd2=' + urllib.quote(loc) + '&cid=555&tid=UA-57507808-1&z=' + str(time.time()))
# prepare sunrise sunset time
delta = 3600 / 4 * (toffset - 48)
if (sunrise >= 0):
sunrise = int(((sunrise + delta) % 86400) / 60)
if (sunset >= 0):
sunset = int(((sunset + delta) % 86400) / 60)
if of == 'json' or of == 'JSON':
output = '{"scale":%d, "restrict":%d, "tz":%d, "sunrise":%d, "sunset":%d, "maxh":%d, "minh":%d, "meant":%d, "pre":%f, "prec":%f, "hc":%d, "eip":%d}' % (
scale, restrict, toffset, sunrise, sunset, int(maxh), int(minh), int(meant), pre, pre_today, int(h_today), eip)
else:
output = '&scale=%d&restrict=%d&tz=%d&sunrise=%d&sunset=%d&maxh=%d&minh=%d&meant=%d&pre=%f&prec=%f&hc=%d&eip=%d' % (
scale, restrict, toffset, sunrise, sunset, int(maxh), int(minh), int(meant), pre, pre_today, int(h_today), eip)
response_headers = [
('Content-type', 'text/plain'), ('Content-Length', str(len(output)))]
start_response(status, response_headers)
return [output]

14
models/Cache.js Normal file
View File

@@ -0,0 +1,14 @@
var mongoose = require( "mongoose" );
var cacheSchema = new mongoose.Schema( {
// Stores the current GPS location as unique for weather data cache
location: { type: String, unique: true },
// This is the end of day value for the humidity yesterday
yesterdayHumidity: Number,
currentHumidityTotal: Number,
currentHumidityCount: Number
} );
module.exports = mongoose.model( "Cache", cacheSchema );

16
package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "os-weather-service",
"description": "OpenSprinkler Weather Service",
"version": "0.0.1",
"repository": "https://github.com/OpenSprinkler/Weather-Adjustments/",
"dependencies": {
"cron": "^1.0.9",
"dotenv": "^1.2.0",
"express": "^4.13.0",
"grunt": "^0.4.5",
"grunt-contrib-jshint": "^0.11.2",
"mongoose": "^4.0.6",
"suncalc": "^1.6.0",
"xml2js": "^0.4.9"
}
}

View File

@@ -1,2 +0,0 @@
pytz==2014.10
ephem==3.7.5.3

492
routes/weather.js Normal file
View File

@@ -0,0 +1,492 @@
var http = require( "http" ),
SunCalc = require( "suncalc" ),
parseXML = require( "xml2js" ).parseString,
Cache = require( "../models/Cache" ),
// 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):/,
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})/
};
// Takes a PWS or ICAO location and resolves the GPS coordinates
function getPWSCoordinates( location, weatherUndergroundKey, callback ) {
var url = "http://api.wunderground.com/api/" + weatherUndergroundKey +
"/conditions/forecast/q/" + encodeURIComponent( location ) + ".json";
httpRequest( url, function( data ) {
data = JSON.parse( data );
if ( typeof data === "object" && data.current_observation && data.current_observation.observation_location ) {
callback( [ data.current_observation.observation_location.latitude,
data.current_observation.observation_location.longitude ] );
} else {
callback( false );
}
} );
}
// If location does not match GPS or PWS/ICAO, then attempt to resolve
// location using Weather Underground autocomplete API
function resolveCoordinates( location, callback ) {
// Generate URL for autocomplete request
var url = "http://autocomplete.wunderground.com/aq?h=0&query=" +
encodeURIComponent( location );
httpRequest( url, function( data ) {
// Parse the reply for JSON data
data = JSON.parse( data );
// Check if the data is valid
if ( typeof data.RESULTS === "object" && data.RESULTS.length ) {
// If it is, reply with an array containing the GPS coordinates
callback( [ data.RESULTS[0].lat, data.RESULTS[0].lon ] );
} else {
// Otherwise, indicate no data was found
callback( false );
}
} );
}
// Retrieve weather data to complete the weather request using Weather Underground
function getWeatherUndergroundData( location, weatherUndergroundKey, callback ) {
// Generate URL using The Weather Company API v1 in Imperial units
var url = "http://api.wunderground.com/api/" + weatherUndergroundKey +
"/yesterday/conditions/q/" + encodeURIComponent( location ) + ".json";
// Perform the HTTP request to retrieve the weather data
httpRequest( url, function( data ) {
try {
data = JSON.parse( data );
var tzOffset = getTimezone( data.current_observation.local_tz_offset, "minutes" ),
// Calculate sunrise and sunset since Weather Underground does not provide it
sunData = SunCalc.getTimes( data.current_observation.local_epoch * 1000,
data.current_observation.observation_location.latitude || data.current_observation.display_location.latitude,
data.current_observation.observation_location.longitude || data.current_observation.display_location.longitude );
sunData.sunrise.setUTCMinutes( sunData.sunrise.getUTCMinutes() + tzOffset );
sunData.sunset.setUTCMinutes( sunData.sunset.getUTCMinutes() + tzOffset );
var weather = {
icon: data.current_observation.icon,
timezone: data.current_observation.local_tz_offset,
sunrise: ( sunData.sunrise.getUTCHours() * 60 + sunData.sunrise.getUTCMinutes() ),
sunset: ( sunData.sunset.getUTCHours() * 60 + sunData.sunset.getUTCMinutes() ),
maxTemp: parseInt( data.history.dailysummary[0].maxtempi ),
minTemp: parseInt( data.history.dailysummary[0].mintempi ),
temp: parseInt( data.current_observation.temp_f ),
humidity: ( parseInt( data.history.dailysummary[0].maxhumidity ) + parseInt( data.history.dailysummary[0].minhumidity ) ) / 2,
precip: parseInt( data.current_observation.precip_today_in ) + parseInt( data.history.dailysummary[0].precipi ),
solar: parseInt( data.current_observation.UV ),
wind: parseInt( data.history.dailysummary[0].meanwindspdi ),
elevation: parseInt( data.current_observation.observation_location.elevation )
};
callback( weather );
} catch ( err ) {
// Otherwise indicate the request failed
callback( false );
}
} );
}
// Retrieve weather data to complete the weather request using The Weather Channel
function getWeatherData( location, callback ) {
// Get the API key from the environment variables
var WSI_API_KEY = process.env.WSI_API_KEY,
// Generate URL using The Weather Company API v1 in Imperial units
url = "http://api.weather.com/v1/geocode/" + location[0] + "/" + location[1] +
"/observations/current.json?apiKey=" + WSI_API_KEY + "&language=en-US&units=e";
// Perform the HTTP request to retrieve the weather data
httpRequest( url, function( data ) {
try {
data = JSON.parse( data );
var weather = {
iconCode: data.observation.icon_code,
timezone: data.observation.obs_time_local,
sunrise: parseDayTime( data.observation.sunrise ),
sunset: parseDayTime( data.observation.sunset ),
maxTemp: data.observation.imperial.temp_max_24hour,
minTemp: data.observation.imperial.temp_min_24hour,
temp: data.observation.imperial.temp,
humidity: data.observation.imperial.rh || 0,
precip: data.observation.imperial.precip_2day || data.observation.imperial.precip_24hour,
solar: data.observation.imperial.uv_index,
wind: data.observation.imperial.wspd
};
location = location.join( "," );
Cache.findOne( { location: location }, function( err, record ) {
if ( record && record.yesterdayHumidity !== null ) {
weather.yesterdayHumidity = record.yesterdayHumidity;
}
// Return the data to the callback function if successful
callback( weather );
} );
updateCache( location, weather );
} catch ( err ) {
// Otherwise indicate the request failed
callback( false );
}
} );
}
// Retrieve the historical weather data for the provided location
function getYesterdayWeatherData( location, callback ) {
// Get the API key from the environment variables
var WSI_HISTORY_KEY = process.env.WSI_HISTORY_KEY,
// Generate a Date object for the previous day by subtracting a day (in milliseconds) from today
yesterday = toUSDate( new Date( new Date().getTime() - 1000 * 60 * 60 * 24 ) ),
// Generate URL using WSI Cleaned History API in Imperial units showing daily average values
url = "http://cleanedobservations.wsi.com/CleanedObs.svc/GetObs?ID=" + WSI_HISTORY_KEY +
"&Lat=" + location[0] + "&Long=" + location[1] +
"&Req=davg&startdate=" + yesterday + "&enddate=" + yesterday + "&TS=LST";
// Perform the HTTP request to retrieve the weather data
httpRequest( url, function( xml ) {
parseXML( xml, function( err, result ) {
callback( result.WeatherResponse.WeatherRecords[0].WeatherData[0].$ );
} );
} );
}
// Update weather cache record in the local database
function updateCache( location, weather ) {
// Search for a cache record for the provided location
Cache.findOne( { location: location }, function( err, record ) {
// If a record is found update the data and save it
if ( record ) {
record.currentHumidityTotal += weather.humidity;
record.currentHumidityCount++;
record.save();
} else {
// If no cache record is found, generate a new one and save it
new Cache( {
location: location,
currentHumidityTotal: weather.humidity,
currentHumidityCount: 1
} ).save();
}
} );
}
// Calculates the resulting water scale using the provided weather data, adjustment method and options
function calculateWeatherScale( adjustmentMethod, adjustmentOptions, weather ) {
// Zimmerman method
if ( adjustmentMethod === 1 ) {
// Check to make sure valid data exists for all factors
if ( !validateValues( [ "temp", "humidity", "precip" ], weather ) ) {
return -1;
}
var temp = ( ( weather.maxTemp + weather.minTemp ) / 2 ) || weather.temp,
humidityFactor = ( 30 - weather.humidity ),
tempFactor = ( ( temp - 70 ) * 4 ),
precipFactor = ( 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.
//
// All queries will return a restrict flag if the current weather indicates rain.
//
// California watering restriction prevents watering if precipitation over two days is greater
// than 0.01" over the past 48 hours.
function checkWeatherRestriction( adjustmentValue, weather ) {
// Define all the weather codes that indicate rain
var adverseCodes = [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 35, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47 ],
adverseWords = [ "flurries", "sleet", "rain", "sleet", "snow", "tstorms" ];
if ( ( weather.iconCode && adverseCodes.indexOf( weather.iconCode ) !== -1 ) || ( weather.icon && adverseWords.indexOf( weather.icon ) !== -1 ) ) {
// If the current weather indicates rain, add a restrict flag to the weather script indicating
// the controller should not water.
return true;
}
var californiaRestriction = ( adjustmentValue >> 7 ) & 1;
if ( californiaRestriction ) {
// If the California watering restriction is in use then prevent watering
// if more then 0.01" of rain has accumulated in the past 48 hours
if ( weather.precip > 0.01 ) {
return true;
}
}
return false;
}
// 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.getWeather = 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,
weatherUndergroundKey = req.query.key,
outputFormat = req.query.format,
firmwareVersion = req.query.fwv,
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 ) {
res.send( "Error: No weather data found." );
return;
}
var data = {
scale: calculateWeatherScale( adjustmentMethod, adjustmentOptions, weather ),
restrict: checkWeatherRestriction( req.params[0], weather ) ? 1 : 0,
tz: getTimezone( weather.timezone ),
sunrise: weather.sunrise,
sunset: weather.sunset,
eip: ipToInt( remoteAddress )
};
// Return the response to the client in the requested format
if ( outputFormat === "json" ) {
res.json( data );
} else {
res.send( "&scale=" + data.scale +
"&restrict=" + data.restrict +
"&tz=" + data.tz +
"&sunrise=" + data.sunrise +
"&sunset=" + data.sunset +
"&eip=" + data.eip
);
}
};
// 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 {
// 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;
}
// Parse location string
if ( filters.pws.test( location ) ) {
// Handle locations using PWS or ICAO (Weather Underground)
if ( !weatherUndergroundKey ) {
// If no key is provided for Weather Underground then the PWS or ICAO cannot be resolved
res.send( "Error: Weather Underground key required when using PWS or ICAO location." );
return;
}
getPWSCoordinates( location, weatherUndergroundKey, function( result ) {
if ( result === false ) {
res.send( "Error: Unable to resolve location" );
return;
}
location = result;
getWeatherData( location, finishRequest );
} );
} else if ( weatherUndergroundKey ) {
// The current weather script uses Weather Underground and during the transition period
// both will be supported and users who provide a Weather Underground API key will continue
// using Weather Underground until The Weather Service becomes the default API
getWeatherUndergroundData( location, weatherUndergroundKey, finishRequest );
} else if ( filters.gps.test( location ) ) {
// Handle GPS coordinates by storing each coordinate in an array
location = location.split( "," );
location = [ parseFloat( location[0] ), parseFloat( location[1] ) ];
// Continue with the weather request
getWeatherData( location, finishRequest );
} 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
resolveCoordinates( location, function( result ) {
if ( result === false ) {
res.send( "Error: Unable to resolve location" );
return;
}
location = result;
getWeatherData( location, finishRequest );
} );
}
};
// Generic HTTP request handler that parses the URL and uses the
// native Node.js http module to perform the request
function httpRequest( 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() {
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 ) {
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 ) {
// Match the provided time string against a regex for parsing
time = time.match( filters.time ) || time.match( filters.timezone );
var hour = parseInt( time[7] + time[8] ),
minute = parseInt( time[9] ),
tz;
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;
}
// Function to return the sunrise and sunset times from the weather reply
function parseDayTime( time ) {
// Time is parsed from string against a regex
time = time.match( filters.time );
// Values are converted to minutes from midnight for the controller
return parseInt( time[4] ) * 60 + parseInt( time[5] );
}
// Converts IP string to integer
function ipToInt( ip ) {
ip = ip.split( "." );
return ( ( ( ( ( ( +ip[0] ) * 256 ) + ( +ip[1] ) ) * 256 ) + ( +ip[2] ) ) * 256 ) + ( +ip[3] );
}
// Resolves the Month / Day / Year of a Date object
function toUSDate( date ) {
return ( date.getMonth() + 1 ) + "/" + date.getDate() + "/" + date.getFullYear();
}

62
server.js Normal file
View File

@@ -0,0 +1,62 @@
var express = require( "express" ),
weather = require( "./routes/weather.js" ),
mongoose = require( "mongoose" ),
Cache = require( "./models/Cache" ),
CronJob = require( "cron" ).CronJob,
port = process.env.PORT || 3000,
app = express();
if ( !process.env.PORT ) {
require( "dotenv" ).load();
}
// Connect to local MongoDB instance
mongoose.connect( "localhost" );
// If the database connection cannot be established, throw an error
mongoose.connection.on( "error", function() {
console.error( "MongoDB Connection Error. Please make sure that MongoDB is running." );
} );
// Handle requests matching /weatherID.py where ID corresponds to the
// weather adjustment method selector.
// This endpoint is considered deprecated and supported for prior firmware
app.get( /weather(\d+)\.py/, weather.getWeather );
// Handle 404 error
app.use( function( req, res ) {
res.status( 404 );
res.send( "Error: Request not found" );
} );
// Start listening on the service port
app.listen( port, "127.0.0.1", function() {
console.log( "OpenSprinkler Weather Service now listening on port %s", port );
} );
// Schedule a cronjob daily to consildate the weather cache data, runs daily
new CronJob( "0 0 0 * * *", function() {
// Find all records in the weather cache
Cache.find( {}, function( err, records ) {
// Cycle through each record
records.forEach( function( record ){
// If the record contains any unaveraged data, then process the record
if ( record.currentHumidityCount > 0 ) {
// Average the humidity by dividing the total over the total data points collected
record.yesterdayHumidity = record.currentHumidityTotal / record.currentHumidityCount;
// Reset the current humidity data for the new day
record.currentHumidityTotal = 0;
record.currentHumidityCount = 0;
// Save the record in the database
record.save();
}
} );
} );
}, null, true, "UTC" );