From 3d6c7d6da32880c64b592d797c46bd74fd015997 Mon Sep 17 00:00:00 2001 From: Samer Albahra Date: Wed, 1 Jul 2015 00:12:02 -0500 Subject: [PATCH 01/28] Initial version of Node.js rewrite --- .gitignore | 3 + .jscsrc | 3 + Gruntfile.js | 45 +++++ application.py | 446 ---------------------------------------------- package.json | 13 ++ requirements.txt | 2 - routes/weather.js | 254 ++++++++++++++++++++++++++ server.js | 21 +++ 8 files changed, 339 insertions(+), 448 deletions(-) create mode 100644 .gitignore create mode 100644 .jscsrc create mode 100644 Gruntfile.js delete mode 100644 application.py create mode 100644 package.json delete mode 100644 requirements.txt create mode 100644 routes/weather.js create mode 100644 server.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d725b83 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +.env +WeatherService.zip diff --git a/.jscsrc b/.jscsrc new file mode 100644 index 0000000..a87f8f0 --- /dev/null +++ b/.jscsrc @@ -0,0 +1,3 @@ +{ + "maximumLineLength": 150 +} diff --git a/Gruntfile.js b/Gruntfile.js new file mode 100644 index 0000000..bce0537 --- /dev/null +++ b/Gruntfile.js @@ -0,0 +1,45 @@ +module.exports = function( grunt ) { + + // Load node-modules; + grunt.loadNpmTasks( "grunt-contrib-jshint" ); + grunt.loadNpmTasks( "grunt-contrib-compress" ); + grunt.loadNpmTasks( "grunt-jscs" ); + + // Project configuration. + grunt.initConfig( { + pkg: grunt.file.readJSON( "package.json" ), + + jshint: { + main: [ "server.js", "routes/**" ], + options: { + jshintrc: true + } + }, + + jscs: { + main: [ "server.js", "routes/**" ], + options: { + config: true, + fix: true + } + }, + + compress: { + build: { + options: { + archive: "WeatherService.zip" + }, + files: [ { + src: [ ".ebextensions/*", "routes/*", "server.js", "package.json" ], + expand: true + } ] + } + } + + } ); + + // Default task(s). + grunt.registerTask( "default", [ "jshint", "jscs" ] ); + grunt.registerTask( "build", [ "jshint", "jscs", "compress:build" ] ); + +}; diff --git a/application.py b/application.py deleted file mode 100644 index a8355bb..0000000 --- a/application.py +++ /dev/null @@ -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] diff --git a/package.json b/package.json new file mode 100644 index 0000000..b415d89 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "os-weather-service", + "description": "OpenSprinkler Weather Service", + "version": "0.0.1", + "dependencies": { + "dotenv": "^1.2.0", + "express": "^4.13.0", + "grunt": "^0.4.5", + "grunt-contrib-compress": "^0.13.0", + "grunt-contrib-jshint": "^0.11.2", + "grunt-jscs": "^1.8.0" + } +} diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 92cb6af..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -pytz==2014.10 -ephem==3.7.5.3 \ No newline at end of file diff --git a/routes/weather.js b/routes/weather.js new file mode 100644 index 0000000..f84b754 --- /dev/null +++ b/routes/weather.js @@ -0,0 +1,254 @@ +// Define regex filters to match against location +var http = require( "http" ), + 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})/ + }; + +// 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 ); + } ); +} + +// Converts IP string to integer +function ipToInt( ip ) { + ip = ip.split( "." ); + return ( ( ( ( ( ( +ip[0] ) * 256 ) + ( +ip[1] ) ) * 256 ) + ( +ip[2] ) ) * 256 ) + ( +ip[3] ); +} + +// 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 ) { + var url = "http://autocomplete.wunderground.com/aq?h=0&query=" + + encodeURIComponent( location ); + + httpRequest( url, function( data ) { + data = JSON.parse( data ); + if ( data.hasOwnProperty( "RESULTS" ) ) { + callback( [ data.RESULTS[0].lat, data.RESULTS[0].lon ] ); + } + } ); +} + +// Accepts a time string formatted in ISO-8601 and returns the timezone. +// The timezone output is formatted for OpenSprinkler Unified firmware. +function getTimezone( time ) { + var time = time.match( filters.time ), + hour = parseInt( time[7] + time[8] ), + minute = parseInt( time[9] ); + + minute = ( minute / 15 >> 0 ) / 4.0; + hour = hour + ( hour >=0 ? minute : -minute ); + + return ( ( hour + 12 ) * 4 ) >> 0; +} + +// 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 ) { + 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" ].split(",")[0] || req.connection.remoteAddress, + weather = {}, + + // After the location is resolved, this function will run to complete the weather request + getWeatherData = function() { + + // 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 ) { + weather = JSON.parse( data ); + + var scale = calculateWeatherScale(), + restrict = checkWeatherRestriction() ? 1 : 0, + sunData = getSunData(), + timezone = getTimezone( weather.observation.obs_time_local ); + + // Return the response to the client + if ( outputFormat === "json" ) { + res.json( { + scale: scale, + restrict: restrict, + tz: timezone, + sunrise: sunData[0], + sunset: sunData[1], + eip: ipToInt( remoteAddress ) + } ); + } else { + res.send( "&scale=" + scale + + "&restrict=" + restrict + + "&tz=" + timezone + + "&sunrise=" + sunData[0] + + "&sunset=" + sunData[1] + + "&eip=" + ipToInt( remoteAddress ) + ); + } + } ); + }, + getSunData = function() { + + // Sun times must be converted from strings into date objects and processed into minutes from midnight + // TODO: Need to use the timezone to adjust sun times + var sunrise = weather.observation.sunrise.match( filters.time ), + sunset = weather.observation.sunset.match( filters.time ); + + return [ + parseInt( sunrise[4] ) * 60 + parseInt( sunrise[5] ), + parseInt( sunset[4] ) * 60 + parseInt( sunset[5] ) + ]; + }, + calculateWeatherScale = function() { + + // Calculate the average temperature + var temp = ( weather.observation.imperial.temp_max_24hour + weather.observation.imperial.temp_min_24hour ) / 2, + + // Relative humidity and if unavailable default to 0 + rh = weather.observation.imperial.rh || 0, + + // The absolute precipitation in the past 48 hours + precip = weather.observation.imperial.precip_2day; + + if ( typeof temp !== "number" ) { + + // If the maximum and minimum temperatures are not available then use the current temperature + temp = weather.observation.imperial.temp; + } + + console.log( { + temp: temp, + humidity: rh, + precip_48hr: precip + } ); + + if ( adjustmentMethod == 1 ) { + + // Zimmerman method + + var humidityFactor = ( 30 - rh ), + tempFactor = ( ( temp - 70 ) * 4 ), + precipFactor = ( precip * -2 ); + + // Apply adjustment options if available + 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 ); + } + } + + return parseInt( Math.min( Math.max( 0, 100 + humidityFactor + tempFactor + precipFactor ), 200 ) ); + } + + return -1; + }, + checkWeatherRestriction = function() { + var californiaRestriction = ( req.params[0] >> 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.observation.imperial.precip_2day > 0.01 ) { + return true; + } + } + + return false; + }; + + // Parse weather adjustment options + try { + + // Reconstruct JSON string from deformed controller output + adjustmentOptions = JSON.parse( "{" + adjustmentOptions + "}" ); + } catch (err) { + adjustmentOptions = false; + } + + // Parse location string + if ( filters.gps.test( location ) ) { + + // Handle GPS coordinates + location = location.split( "," ); + location = [ parseFloat( location[0] ), parseFloat( location[1] ) ]; + getWeatherData(); + + } else if ( filters.pws.test( location ) ) { + + // Handle Weather Underground specific location + getPWSCoordinates( location, weatherUndergroundKey, function( result ) { + location = result; + getWeatherData(); + } ); + } else { + + // Attempt to resolve provided location to GPS coordinates + resolveCoordinates( location, function( result ) { + location = result; + getWeatherData(); + } ); + } +}; + diff --git a/server.js b/server.js new file mode 100644 index 0000000..630b117 --- /dev/null +++ b/server.js @@ -0,0 +1,21 @@ +var express = require( "express" ), + weather = require( "./routes/weather.js" ), + port = process.env.PORT || 3000; + app = express(); + +// 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( "Not found" ); +} ); + +// Start listening on the service port +var server = app.listen( port, "127.0.0.1", function() { + + console.log( "OpenSprinkler Weather Service now listening on port %s", port ); +} ); From 91b429c1ddd0994dde9d6f40c08e73523b39d960 Mon Sep 17 00:00:00 2001 From: Samer Albahra Date: Wed, 1 Jul 2015 00:26:07 -0500 Subject: [PATCH 02/28] Small bug fixes --- routes/weather.js | 6 ++++-- server.js | 4 ++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/routes/weather.js b/routes/weather.js index f84b754..0f89b9b 100644 --- a/routes/weather.js +++ b/routes/weather.js @@ -97,7 +97,7 @@ exports.getWeather = function( req, res ) { weatherUndergroundKey = req.query.key, outputFormat = req.query.format, firmwareVersion = req.query.fwv, - remoteAddress = req.headers[ "x-forwarded-for" ].split(",")[0] || req.connection.remoteAddress, + remoteAddress = req.headers[ "x-forwarded-for" ] || req.connection.remoteAddress, weather = {}, // After the location is resolved, this function will run to complete the weather request @@ -161,7 +161,7 @@ exports.getWeather = function( req, res ) { rh = weather.observation.imperial.rh || 0, // The absolute precipitation in the past 48 hours - precip = weather.observation.imperial.precip_2day; + precip = weather.observation.imperial.precip_2day || weather.observation.imperial.precip_24hour; if ( typeof temp !== "number" ) { @@ -218,6 +218,8 @@ exports.getWeather = function( req, res ) { return false; }; + remoteAddress = remoteAddress.split(",")[0]; + // Parse weather adjustment options try { diff --git a/server.js b/server.js index 630b117..737e350 100644 --- a/server.js +++ b/server.js @@ -3,6 +3,10 @@ var express = require( "express" ), port = process.env.PORT || 3000; app = express(); +if ( !process.env.PORT ) { + require( "dotenv" ).load(); +} + // Handle requests matching /weatherID.py where ID corresponds to the // weather adjustment method selector. // This endpoint is considered deprecated and supported for prior firmware From da4a540e92a8c49e99090f8f187ab243ab900297 Mon Sep 17 00:00:00 2001 From: Samer Albahra Date: Wed, 1 Jul 2015 17:57:43 -0500 Subject: [PATCH 03/28] Refactor to remove nested function definition --- routes/weather.js | 255 ++++++++++++++++++++++++---------------------- server.js | 2 +- 2 files changed, 136 insertions(+), 121 deletions(-) diff --git a/routes/weather.js b/routes/weather.js index 0f89b9b..ce6f769 100644 --- a/routes/weather.js +++ b/routes/weather.js @@ -68,7 +68,7 @@ function resolveCoordinates( location, callback ) { httpRequest( url, function( data ) { data = JSON.parse( data ); - if ( data.hasOwnProperty( "RESULTS" ) ) { + if ( typeof data.RESULTS === "object" && data.RESULTS.length ) { callback( [ data.RESULTS[0].lat, data.RESULTS[0].lon ] ); } } ); @@ -77,8 +77,9 @@ function resolveCoordinates( location, callback ) { // Accepts a time string formatted in ISO-8601 and returns the timezone. // The timezone output is formatted for OpenSprinkler Unified firmware. function getTimezone( time ) { - var time = time.match( filters.time ), - hour = parseInt( time[7] + time[8] ), + time = time.match( filters.time ); + + var hour = parseInt( time[7] + time[8] ), minute = parseInt( time[9] ); minute = ( minute / 15 >> 0 ) / 4.0; @@ -87,6 +88,100 @@ function getTimezone( time ) { return ( ( hour + 12 ) * 4 ) >> 0; } +// Retrieve weather data to complete the weather request +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 ) { + callback( JSON.parse( data ) ); + } ); +} + +// Calculates the resulting water scale using the provided weather data, adjustment method and options +function calculateWeatherScale( adjustmentMethod, adjustmentOptions, weather ) { + + // Calculate the average temperature + var temp = ( weather.observation.imperial.temp_max_24hour + weather.observation.imperial.temp_min_24hour ) / 2, + + // Relative humidity and if unavailable default to 0 + rh = weather.observation.imperial.rh || 0, + + // The absolute precipitation in the past 48 hours + precip = weather.observation.imperial.precip_2day || weather.observation.imperial.precip_24hour; + + if ( typeof temp !== "number" ) { + + // If the maximum and minimum temperatures are not available then use the current temperature + temp = weather.observation.imperial.temp; + } + + // Zimmerman method + if ( adjustmentMethod == 1 ) { + + var humidityFactor = ( 30 - rh ), + tempFactor = ( ( temp - 70 ) * 4 ), + precipFactor = ( precip * -2 ); + + // Apply adjustment options if available + 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 ); + } + } + + return parseInt( Math.min( Math.max( 0, 100 + humidityFactor + tempFactor + precipFactor ), 200 ) ); + } + + return -1; +} + +// Function to return the sunrise and sunset times from the weather reply +function getSunData( weather ) { + + // Sun times must be converted from strings into date objects and processed into minutes from midnight + var sunrise = weather.observation.sunrise.match( filters.time ), + sunset = weather.observation.sunset.match( filters.time ); + + return [ + parseInt( sunrise[4] ) * 60 + parseInt( sunrise[5] ), + parseInt( sunset[4] ) * 60 + parseInt( sunset[5] ) + ]; +} + +// 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.01" of rain has accumulated in the past 48 hours + if ( weather.observation.imperial.precip_2day > 0.01 || weather.observation.imperial.precip_24hour > 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 @@ -98,126 +193,41 @@ exports.getWeather = function( req, res ) { outputFormat = req.query.format, firmwareVersion = req.query.fwv, remoteAddress = req.headers[ "x-forwarded-for" ] || req.connection.remoteAddress, - weather = {}, - - // After the location is resolved, this function will run to complete the weather request - getWeatherData = function() { - - // 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 ) { - weather = JSON.parse( data ); - - var scale = calculateWeatherScale(), - restrict = checkWeatherRestriction() ? 1 : 0, - sunData = getSunData(), - timezone = getTimezone( weather.observation.obs_time_local ); - - // Return the response to the client - if ( outputFormat === "json" ) { - res.json( { - scale: scale, - restrict: restrict, - tz: timezone, - sunrise: sunData[0], - sunset: sunData[1], - eip: ipToInt( remoteAddress ) - } ); - } else { - res.send( "&scale=" + scale + - "&restrict=" + restrict + - "&tz=" + timezone + - "&sunrise=" + sunData[0] + - "&sunset=" + sunData[1] + - "&eip=" + ipToInt( remoteAddress ) - ); - } - } ); - }, - getSunData = function() { - - // Sun times must be converted from strings into date objects and processed into minutes from midnight - // TODO: Need to use the timezone to adjust sun times - var sunrise = weather.observation.sunrise.match( filters.time ), - sunset = weather.observation.sunset.match( filters.time ); - - return [ - parseInt( sunrise[4] ) * 60 + parseInt( sunrise[5] ), - parseInt( sunset[4] ) * 60 + parseInt( sunset[5] ) - ]; - }, - calculateWeatherScale = function() { - - // Calculate the average temperature - var temp = ( weather.observation.imperial.temp_max_24hour + weather.observation.imperial.temp_min_24hour ) / 2, - - // Relative humidity and if unavailable default to 0 - rh = weather.observation.imperial.rh || 0, - - // The absolute precipitation in the past 48 hours - precip = weather.observation.imperial.precip_2day || weather.observation.imperial.precip_24hour; - - if ( typeof temp !== "number" ) { - - // If the maximum and minimum temperatures are not available then use the current temperature - temp = weather.observation.imperial.temp; + finishRequest = function( weather ) { + if ( !weather || typeof weather.observation !== "object" || typeof weather.observation.imperial !== "object" ) { + res.send( "Error: No weather data found." ); + return; } - console.log( { - temp: temp, - humidity: rh, - precip_48hr: precip - } ); + var data = { + scale: calculateWeatherScale( adjustmentMethod, adjustmentOptions, weather ), + restrict: checkWeatherRestriction( req.params[0], weather ) ? 1 : 0, + tz: getTimezone( weather.observation.obs_time_local ), + sunrise: getSunData( weather )[0], + sunset: getSunData( weather )[1], + eip: ipToInt( remoteAddress ) + }; - if ( adjustmentMethod == 1 ) { - - // Zimmerman method - - var humidityFactor = ( 30 - rh ), - tempFactor = ( ( temp - 70 ) * 4 ), - precipFactor = ( precip * -2 ); - - // Apply adjustment options if available - 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 ); - } - } - - return parseInt( Math.min( Math.max( 0, 100 + humidityFactor + tempFactor + precipFactor ), 200 ) ); + // Return the response to the client + 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 + ); } - - return -1; - }, - checkWeatherRestriction = function() { - var californiaRestriction = ( req.params[0] >> 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.observation.imperial.precip_2day > 0.01 ) { - return true; - } - } - - return false; }; + // Exit if no location is provided + if ( !location ) { + res.send( "Error: No location provided." ); + return; + } + remoteAddress = remoteAddress.split(",")[0]; // Parse weather adjustment options @@ -235,21 +245,26 @@ exports.getWeather = function( req, res ) { // Handle GPS coordinates location = location.split( "," ); location = [ parseFloat( location[0] ), parseFloat( location[1] ) ]; - getWeatherData(); + getWeatherData( location, finishRequest ); } else if ( filters.pws.test( location ) ) { + if ( !weatherUndergroundKey ) { + res.send( "Error: Weather Underground key required when using PWS or ICAO location." ); + return; + } + // Handle Weather Underground specific location getPWSCoordinates( location, weatherUndergroundKey, function( result ) { location = result; - getWeatherData(); + getWeatherData( location, finishRequest ); } ); } else { // Attempt to resolve provided location to GPS coordinates resolveCoordinates( location, function( result ) { location = result; - getWeatherData(); + getWeatherData( location, finishRequest ); } ); } }; diff --git a/server.js b/server.js index 737e350..4a47ad0 100644 --- a/server.js +++ b/server.js @@ -15,7 +15,7 @@ app.get( /weather(\d+)\.py/, weather.getWeather ); // Handle 404 error app.use( function( req, res ) { res.status( 404 ); - res.send( "Not found" ); + res.send( "Error: Request not found" ); } ); // Start listening on the service port From 8c7d5b5bc7b3a1810fe0b3d124cd7310f0e46b43 Mon Sep 17 00:00:00 2001 From: Samer Albahra Date: Wed, 1 Jul 2015 18:16:34 -0500 Subject: [PATCH 04/28] Add Amazon Elastic Beanstalk exceptions to gitignore --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index d725b83..9c959ef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ node_modules .env WeatherService.zip + +# Elastic Beanstalk Files +.elasticbeanstalk/* +!.elasticbeanstalk/*.cfg.yml +!.elasticbeanstalk/*.global.yml From 664cc3666a39cbdbda365a01a166e55af86c773d Mon Sep 17 00:00:00 2001 From: Samer Albahra Date: Wed, 1 Jul 2015 18:45:27 -0500 Subject: [PATCH 05/28] Wrap code within a closure function --- routes/weather.js | 519 +++++++++++++++++++++++----------------------- 1 file changed, 263 insertions(+), 256 deletions(-) diff --git a/routes/weather.js b/routes/weather.js index ce6f769..0dd0aa6 100644 --- a/routes/weather.js +++ b/routes/weather.js @@ -1,271 +1,278 @@ -// Define regex filters to match against location -var http = require( "http" ), - 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})/ - }; +( function() { -// 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 ); - } ); -} - -// Converts IP string to integer -function ipToInt( ip ) { - ip = ip.split( "." ); - return ( ( ( ( ( ( +ip[0] ) * 256 ) + ( +ip[1] ) ) * 256 ) + ( +ip[2] ) ) * 256 ) + ( +ip[3] ); -} - -// 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 ) { - var url = "http://autocomplete.wunderground.com/aq?h=0&query=" + - encodeURIComponent( location ); - - httpRequest( url, function( data ) { - data = JSON.parse( data ); - if ( typeof data.RESULTS === "object" && data.RESULTS.length ) { - callback( [ data.RESULTS[0].lat, data.RESULTS[0].lon ] ); - } - } ); -} - -// Accepts a time string formatted in ISO-8601 and returns the timezone. -// The timezone output is formatted for OpenSprinkler Unified firmware. -function getTimezone( time ) { - time = time.match( filters.time ); - - var hour = parseInt( time[7] + time[8] ), - minute = parseInt( time[9] ); - - minute = ( minute / 15 >> 0 ) / 4.0; - hour = hour + ( hour >=0 ? minute : -minute ); - - return ( ( hour + 12 ) * 4 ) >> 0; -} - -// Retrieve weather data to complete the weather request -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 ) { - callback( JSON.parse( data ) ); - } ); -} - -// Calculates the resulting water scale using the provided weather data, adjustment method and options -function calculateWeatherScale( adjustmentMethod, adjustmentOptions, weather ) { - - // Calculate the average temperature - var temp = ( weather.observation.imperial.temp_max_24hour + weather.observation.imperial.temp_min_24hour ) / 2, - - // Relative humidity and if unavailable default to 0 - rh = weather.observation.imperial.rh || 0, - - // The absolute precipitation in the past 48 hours - precip = weather.observation.imperial.precip_2day || weather.observation.imperial.precip_24hour; - - if ( typeof temp !== "number" ) { - - // If the maximum and minimum temperatures are not available then use the current temperature - temp = weather.observation.imperial.temp; - } - - // Zimmerman method - if ( adjustmentMethod == 1 ) { - - var humidityFactor = ( 30 - rh ), - tempFactor = ( ( temp - 70 ) * 4 ), - precipFactor = ( precip * -2 ); - - // Apply adjustment options if available - 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 ); - } - } - - return parseInt( Math.min( Math.max( 0, 100 + humidityFactor + tempFactor + precipFactor ), 200 ) ); - } - - return -1; -} - -// Function to return the sunrise and sunset times from the weather reply -function getSunData( weather ) { - - // Sun times must be converted from strings into date objects and processed into minutes from midnight - var sunrise = weather.observation.sunrise.match( filters.time ), - sunset = weather.observation.sunset.match( filters.time ); - - return [ - parseInt( sunrise[4] ) * 60 + parseInt( sunrise[5] ), - parseInt( sunset[4] ) * 60 + parseInt( sunset[5] ) - ]; -} - -// 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.01" of rain has accumulated in the past 48 hours - if ( weather.observation.imperial.precip_2day > 0.01 || weather.observation.imperial.precip_24hour > 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 ) { - 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, - finishRequest = function( weather ) { - if ( !weather || typeof weather.observation !== "object" || typeof weather.observation.imperial !== "object" ) { - 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.observation.obs_time_local ), - sunrise: getSunData( weather )[0], - sunset: getSunData( weather )[1], - eip: ipToInt( remoteAddress ) - }; - - // Return the response to the client - 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 - ); - } + // Define regex filters to match against location + var http = require( "http" ), + 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})/ }; - // Exit if no location is provided - if ( !location ) { - res.send( "Error: No location provided." ); - return; + // 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 ); + } ); } - remoteAddress = remoteAddress.split(",")[0]; - - // Parse weather adjustment options - try { - - // Reconstruct JSON string from deformed controller output - adjustmentOptions = JSON.parse( "{" + adjustmentOptions + "}" ); - } catch (err) { - adjustmentOptions = false; + // Converts IP string to integer + function ipToInt( ip ) { + ip = ip.split( "." ); + return ( ( ( ( ( ( +ip[0] ) * 256 ) + ( +ip[1] ) ) * 256 ) + ( +ip[2] ) ) * 256 ) + ( +ip[3] ); } - // Parse location string - if ( filters.gps.test( location ) ) { + // 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"; - // Handle GPS coordinates - location = location.split( "," ); - location = [ parseFloat( location[0] ), parseFloat( location[1] ) ]; - getWeatherData( location, finishRequest ); + httpRequest( url, function( data ) { + data = JSON.parse( data ); - } else if ( filters.pws.test( location ) ) { + 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 ( !weatherUndergroundKey ) { - res.send( "Error: Weather Underground key required when using PWS or ICAO location." ); + // If location does not match GPS or PWS/ICAO, then attempt to resolve + // location using Weather Underground autocomplete API + function resolveCoordinates( location, callback ) { + var url = "http://autocomplete.wunderground.com/aq?h=0&query=" + + encodeURIComponent( location ); + + httpRequest( url, function( data ) { + data = JSON.parse( data ); + if ( typeof data.RESULTS === "object" && data.RESULTS.length ) { + callback( [ data.RESULTS[0].lat, data.RESULTS[0].lon ] ); + } + } ); + } + + // Accepts a time string formatted in ISO-8601 and returns the timezone. + // The timezone output is formatted for OpenSprinkler Unified firmware. + function getTimezone( time ) { + time = time.match( filters.time ); + + var hour = parseInt( time[7] + time[8] ), + minute = parseInt( time[9] ); + + minute = ( minute / 15 >> 0 ) / 4.0; + hour = hour + ( hour >=0 ? minute : -minute ); + + return ( ( hour + 12 ) * 4 ) >> 0; + } + + // Retrieve weather data to complete the weather request + 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 ) { + callback( JSON.parse( data ) ); + } ); + } + + // Calculates the resulting water scale using the provided weather data, adjustment method and options + function calculateWeatherScale( adjustmentMethod, adjustmentOptions, weather ) { + + // Calculate the average temperature + var temp = ( weather.observation.imperial.temp_max_24hour + weather.observation.imperial.temp_min_24hour ) / 2, + + // Relative humidity and if unavailable default to 0 + rh = weather.observation.imperial.rh || 0, + + // The absolute precipitation in the past 48 hours + precip = weather.observation.imperial.precip_2day || weather.observation.imperial.precip_24hour; + + if ( typeof temp !== "number" ) { + + // If the maximum and minimum temperatures are not available then use the current temperature + temp = weather.observation.imperial.temp; + } + + // Zimmerman method + if ( adjustmentMethod == 1 ) { + + var humidityFactor = ( 30 - rh ), + tempFactor = ( ( temp - 70 ) * 4 ), + precipFactor = ( precip * -2 ); + + // Apply adjustment options if available + 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 ); + } + } + + return parseInt( Math.min( Math.max( 0, 100 + humidityFactor + tempFactor + precipFactor ), 200 ) ); + } + + return -1; + } + + // Function to return the sunrise and sunset times from the weather reply + function getSunData( weather ) { + + // Sun times must be converted from strings into date objects and processed into minutes from midnight + var sunrise = weather.observation.sunrise.match( filters.time ), + sunset = weather.observation.sunset.match( filters.time ); + + return [ + parseInt( sunrise[4] ) * 60 + parseInt( sunrise[5] ), + parseInt( sunset[4] ) * 60 + parseInt( sunset[5] ) + ]; + } + + // 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.01" of rain has accumulated in the past 48 hours + if ( weather.observation.imperial.precip_2day > 0.01 || weather.observation.imperial.precip_24hour > 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, + finishRequest = function( weather ) { + if ( !weather || typeof weather.observation !== "object" || typeof weather.observation.imperial !== "object" ) { + 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.observation.obs_time_local ), + sunrise: getSunData( weather )[0], + sunset: getSunData( weather )[1], + eip: ipToInt( remoteAddress ) + }; + + // Return the response to the client + 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; } - // Handle Weather Underground specific location - getPWSCoordinates( location, weatherUndergroundKey, function( result ) { - location = result; - getWeatherData( location, finishRequest ); - } ); - } else { + remoteAddress = remoteAddress.split(",")[0]; - // Attempt to resolve provided location to GPS coordinates - resolveCoordinates( location, function( result ) { - location = result; - getWeatherData( location, finishRequest ); - } ); - } -}; + // Parse weather adjustment options + try { + + // Reconstruct JSON string from deformed controller output + adjustmentOptions = JSON.parse( "{" + adjustmentOptions + "}" ); + } catch (err) { + adjustmentOptions = false; + } + + // Parse location string + if ( filters.gps.test( location ) ) { + + // Handle GPS coordinates + location = location.split( "," ); + location = [ parseFloat( location[0] ), parseFloat( location[1] ) ]; + getWeatherData( location, finishRequest ); + + } else if ( filters.pws.test( location ) ) { + + if ( !weatherUndergroundKey ) { + res.send( "Error: Weather Underground key required when using PWS or ICAO location." ); + return; + } + + // Handle Weather Underground specific location + getPWSCoordinates( location, weatherUndergroundKey, function( result ) { + location = result; + getWeatherData( location, finishRequest ); + } ); + } else { + + // Attempt to resolve provided location to GPS coordinates + resolveCoordinates( location, function( result ) { + location = result; + getWeatherData( location, finishRequest ); + } ); + } + }; +} )(); From 712792679acb5872efc5735a47e18fa92fee6b2c Mon Sep 17 00:00:00 2001 From: Samer Albahra Date: Wed, 1 Jul 2015 19:57:01 -0500 Subject: [PATCH 06/28] Add the ability to parse XML from WSI history API --- package.json | 4 +++- routes/weather.js | 29 ++++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index b415d89..be73cc4 100644 --- a/package.json +++ b/package.json @@ -2,12 +2,14 @@ "name": "os-weather-service", "description": "OpenSprinkler Weather Service", "version": "0.0.1", + "repository": "https://github.com/OpenSprinkler/Weather-Adjustments/", "dependencies": { "dotenv": "^1.2.0", "express": "^4.13.0", "grunt": "^0.4.5", "grunt-contrib-compress": "^0.13.0", "grunt-contrib-jshint": "^0.11.2", - "grunt-jscs": "^1.8.0" + "grunt-jscs": "^1.8.0", + "xml2js": "^0.4.9" } } diff --git a/routes/weather.js b/routes/weather.js index 0dd0aa6..256f2db 100644 --- a/routes/weather.js +++ b/routes/weather.js @@ -1,7 +1,8 @@ ( function() { // Define regex filters to match against location - var http = require( "http" ), + var http = require( "http" ), + parseXML = require( "xml2js" ).parseString, filters = { gps: /^[-+]?([1-8]?\d(\.\d+)?|90(\.0+)?),\s*[-+]?(180(\.0+)?|((1[0-7]\d)|([1-9]?\d))(\.\d+)?)$/, pws: /^(?:pws|icao):/, @@ -45,6 +46,11 @@ return ( ( ( ( ( ( +ip[0] ) * 256 ) + ( +ip[1] ) ) * 256 ) + ( +ip[2] ) ) * 256 ) + ( +ip[3] ); } + // Resolves the Month / Day / Year of a Date object + Date.prototype.toUSDate = function(){ + return ( this.getMonth() + 1 ) + "/" + this.getDate() + "/" + this.getFullYear(); + }; + // Takes a PWS or ICAO location and resolves the GPS coordinates function getPWSCoordinates( location, weatherUndergroundKey, callback ) { var url = "http://api.wunderground.com/api/" + weatherUndergroundKey + @@ -106,6 +112,27 @@ } ); } + // 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, + today = new Date(), + yesterday = new Date( today.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.toUSDate() + "&enddate=" + today.toUSDate() + "&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].$ ); + }); + } ); + } + // Calculates the resulting water scale using the provided weather data, adjustment method and options function calculateWeatherScale( adjustmentMethod, adjustmentOptions, weather ) { From 237fe2a483c74ecc9aac966f6d831dd578d92dd6 Mon Sep 17 00:00:00 2001 From: Samer Albahra Date: Wed, 1 Jul 2015 20:03:27 -0500 Subject: [PATCH 07/28] Restrict yesterday weather result to only one day --- routes/weather.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/weather.js b/routes/weather.js index 256f2db..c70dfc9 100644 --- a/routes/weather.js +++ b/routes/weather.js @@ -123,7 +123,7 @@ // 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.toUSDate() + "&enddate=" + today.toUSDate() + "&TS=LST"; + "&Req=davg&startdate=" + yesterday.toUSDate() + "&enddate=" + yesterday.toUSDate() + "&TS=LST"; // Perform the HTTP request to retrieve the weather data httpRequest( url, function( xml ) { From 3091fecb434bccf2b81bca94de87cc343dc761cb Mon Sep 17 00:00:00 2001 From: Samer Albahra Date: Wed, 1 Jul 2015 21:41:20 -0500 Subject: [PATCH 08/28] Add comments to the code --- routes/weather.js | 88 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 68 insertions(+), 20 deletions(-) diff --git a/routes/weather.js b/routes/weather.js index c70dfc9..519ad48 100644 --- a/routes/weather.js +++ b/routes/weather.js @@ -1,9 +1,10 @@ ( function() { - // Define regex filters to match against location var http = require( "http" ), parseXML = require( "xml2js" ).parseString, - filters = { + + // 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+)?(\/.*)?$/, @@ -71,12 +72,20 @@ // 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 ] ); } } ); @@ -85,12 +94,15 @@ // Accepts a time string formatted in ISO-8601 and returns the timezone. // The timezone output is formatted for OpenSprinkler Unified firmware. function getTimezone( time ) { + + // Match the provided time string against a regex for parsing time = time.match( filters.time ); var hour = parseInt( time[7] + time[8] ), minute = parseInt( time[9] ); - minute = ( minute / 15 >> 0 ) / 4.0; + // Convert the timezone into the OpenSprinkler encoded format + minute = ( minute / 15 >> 0 ) / 4; hour = hour + ( hour >=0 ? minute : -minute ); return ( ( hour + 12 ) * 4 ) >> 0; @@ -108,6 +120,8 @@ // Perform the HTTP request to retrieve the weather data httpRequest( url, function( data ) { + + // Return the data to the callback function callback( JSON.parse( data ) ); } ); } @@ -117,7 +131,11 @@ // Get the API key from the environment variables var WSI_HISTORY_KEY = process.env.WSI_HISTORY_KEY, + + // Generate a Date object for the current time today = new Date(), + + // Generate a Date object for the previous day by subtracting a day (in milliseconds) from today yesterday = new Date( today.getTime() - 1000 * 60 * 60 * 24 ), // Generate URL using WSI Cleaned History API in Imperial units showing daily average values @@ -158,7 +176,7 @@ tempFactor = ( ( temp - 70 ) * 4 ), precipFactor = ( precip * -2 ); - // Apply adjustment options if available + // Apply adjustment options, if provided, by multiplying the percentage against the factor if ( adjustmentOptions ) { if ( adjustmentOptions.hasOwnProperty( "h" ) ) { humidityFactor = humidityFactor * ( adjustmentOptions.h / 100 ); @@ -173,6 +191,7 @@ } } + // 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 ) ); } @@ -182,11 +201,13 @@ // Function to return the sunrise and sunset times from the weather reply function getSunData( weather ) { - // Sun times must be converted from strings into date objects and processed into minutes from midnight + // Sun times are parsed from string against a regex to identify the timezone var sunrise = weather.observation.sunrise.match( filters.time ), sunset = weather.observation.sunset.match( filters.time ); return [ + + // Values are converted to minutes from midnight for the controller parseInt( sunrise[4] ) * 60 + parseInt( sunrise[5] ), parseInt( sunset[4] ) * 60 + parseInt( sunset[5] ) ]; @@ -194,9 +215,23 @@ // 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]; + + if ( adverseCodes.indexOf( weather.observation.icon_code ) !== -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 ) { @@ -226,6 +261,10 @@ 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 || typeof weather.observation !== "object" || typeof weather.observation.imperial !== "object" ) { res.send( "Error: No weather data found." ); @@ -233,24 +272,24 @@ } var data = { - scale: calculateWeatherScale( adjustmentMethod, adjustmentOptions, weather ), - restrict: checkWeatherRestriction( req.params[0], weather ) ? 1 : 0, - tz: getTimezone( weather.observation.obs_time_local ), - sunrise: getSunData( weather )[0], - sunset: getSunData( weather )[1], - eip: ipToInt( remoteAddress ) + scale: calculateWeatherScale( adjustmentMethod, adjustmentOptions, weather ), + restrict: checkWeatherRestriction( req.params[0], weather ) ? 1 : 0, + tz: getTimezone( weather.observation.obs_time_local ), + sunrise: getSunData( weather )[0], + sunset: getSunData( weather )[1], + eip: ipToInt( remoteAddress ) }; // Return the response to the client 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 + res.send( "&scale=" + data.scale + + "&restrict=" + data.restrict + + "&tz=" + data.tz + + "&sunrise=" + data.sunrise + + "&sunset=" + data.sunset + + "&eip=" + data.eip ); } }; @@ -261,6 +300,8 @@ 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 @@ -269,32 +310,39 @@ // 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.gps.test( location ) ) { - // Handle GPS coordinates + // 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 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; } - // Handle Weather Underground specific location getPWSCoordinates( location, weatherUndergroundKey, function( result ) { location = result; getWeatherData( location, finishRequest ); } ); } else { - // Attempt to resolve provided location to GPS coordinates + // 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 ) { location = result; getWeatherData( location, finishRequest ); From 199399ff5eed4575865f876544c85883947850ef Mon Sep 17 00:00:00 2001 From: Samer Albahra Date: Wed, 1 Jul 2015 21:48:32 -0500 Subject: [PATCH 09/28] Add more error handling --- routes/weather.js | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/routes/weather.js b/routes/weather.js index 519ad48..4f64e07 100644 --- a/routes/weather.js +++ b/routes/weather.js @@ -87,6 +87,10 @@ // 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 ); } } ); } @@ -121,8 +125,16 @@ // Perform the HTTP request to retrieve the weather data httpRequest( url, function( data ) { - // Return the data to the callback function - callback( JSON.parse( data ) ); + try { + + // Return the data to the callback function if successful + callback( JSON.parse( data ) ); + } catch (err) { + + // Otherwise indicate the request failed + callback( false ); + } + } ); } @@ -280,7 +292,7 @@ eip: ipToInt( remoteAddress ) }; - // Return the response to the client + // Return the response to the client in the requested format if ( outputFormat === "json" ) { res.json( data ); } else { @@ -336,6 +348,11 @@ } getPWSCoordinates( location, weatherUndergroundKey, function( result ) { + if ( result === false ) { + res.send( "Error: Unable to resolve location" ); + return; + } + location = result; getWeatherData( location, finishRequest ); } ); @@ -344,6 +361,11 @@ // 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 ); } ); From 66b4d1ccbdfecf495a790f439b6d5ac4af0589b7 Mon Sep 17 00:00:00 2001 From: Samer Albahra Date: Thu, 2 Jul 2015 19:19:56 -0500 Subject: [PATCH 10/28] Add local weather caching mechanism --- .ebextensions/mongodb.config | 27 ++++++++++++++++++++++ Gruntfile.js | 19 ++------------- models/Cache.js | 14 +++++++++++ package.json | 3 ++- routes/weather.js | 38 ++++++++++++++++++++++++++++-- server.js | 45 ++++++++++++++++++++++++++++++++---- 6 files changed, 122 insertions(+), 24 deletions(-) create mode 100644 .ebextensions/mongodb.config create mode 100644 models/Cache.js diff --git a/.ebextensions/mongodb.config b/.ebextensions/mongodb.config new file mode 100644 index 0000000..9d0ff8b --- /dev/null +++ b/.ebextensions/mongodb.config @@ -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'] diff --git a/Gruntfile.js b/Gruntfile.js index bce0537..bdc3b44 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -2,7 +2,6 @@ module.exports = function( grunt ) { // Load node-modules; grunt.loadNpmTasks( "grunt-contrib-jshint" ); - grunt.loadNpmTasks( "grunt-contrib-compress" ); grunt.loadNpmTasks( "grunt-jscs" ); // Project configuration. @@ -10,36 +9,22 @@ module.exports = function( grunt ) { pkg: grunt.file.readJSON( "package.json" ), jshint: { - main: [ "server.js", "routes/**" ], + main: [ "Gruntfile.js", "server.js", "routes/**", "models/**" ], options: { jshintrc: true } }, jscs: { - main: [ "server.js", "routes/**" ], + main: [ "Gruntfile.js", "server.js", "routes/**", "models/**" ], options: { config: true, fix: true } - }, - - compress: { - build: { - options: { - archive: "WeatherService.zip" - }, - files: [ { - src: [ ".ebextensions/*", "routes/*", "server.js", "package.json" ], - expand: true - } ] - } } - } ); // Default task(s). grunt.registerTask( "default", [ "jshint", "jscs" ] ); - grunt.registerTask( "build", [ "jshint", "jscs", "compress:build" ] ); }; diff --git a/models/Cache.js b/models/Cache.js new file mode 100644 index 0000000..ef5e107 --- /dev/null +++ b/models/Cache.js @@ -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 ); diff --git a/package.json b/package.json index be73cc4..5ad1ce4 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,13 @@ "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-compress": "^0.13.0", "grunt-contrib-jshint": "^0.11.2", "grunt-jscs": "^1.8.0", + "mongoose": "^4.0.6", "xml2js": "^0.4.9" } } diff --git a/routes/weather.js b/routes/weather.js index 4f64e07..ecfabd2 100644 --- a/routes/weather.js +++ b/routes/weather.js @@ -2,6 +2,7 @@ var http = require( "http" ), parseXML = require( "xml2js" ).parseString, + Cache = require( "../models/Cache" ), // Define regex filters to match against location filters = { @@ -126,9 +127,20 @@ httpRequest( url, function( data ) { try { + var weather = JSON.parse( data ); - // Return the data to the callback function if successful - callback( JSON.parse( data ) ); + location = location.join( "," ); + + Cache.findOne( { location: location }, function( err, record ) { + if ( record && record.hasOwnProperty( "yesterdayHumidity" ) ) { + 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 @@ -163,6 +175,28 @@ } ); } + // 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.observation.imperial.rh; + record.currentHumidityCount++; + record.save(); + } else { + + // If no cache record is found, generate a new one and save it + new Cache( { + currentHumidityTotal: weather.observation.imperial.rh, + currentHumidityCount: 1 + } ).save(); + } + } ); + } + // Calculates the resulting water scale using the provided weather data, adjustment method and options function calculateWeatherScale( adjustmentMethod, adjustmentOptions, weather ) { diff --git a/server.js b/server.js index 4a47ad0..e96b010 100644 --- a/server.js +++ b/server.js @@ -1,12 +1,23 @@ -var express = require( "express" ), - weather = require( "./routes/weather.js" ), - port = process.env.PORT || 3000; - app = express(); +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 @@ -23,3 +34,29 @@ var server = 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 * * * * *", 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" ); From 057480fb7f0fac4253855d6619d4b3c37b34784e Mon Sep 17 00:00:00 2001 From: Samer Albahra Date: Thu, 2 Jul 2015 19:30:39 -0500 Subject: [PATCH 11/28] Fix bugs incorporating cached value of yesterday's humidity --- routes/weather.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/routes/weather.js b/routes/weather.js index ecfabd2..500dba1 100644 --- a/routes/weather.js +++ b/routes/weather.js @@ -132,7 +132,8 @@ location = location.join( "," ); Cache.findOne( { location: location }, function( err, record ) { - if ( record && record.hasOwnProperty( "yesterdayHumidity" ) ) { + + if ( record && record.yesterdayHumidity !== null ) { weather.yesterdayHumidity = record.yesterdayHumidity; } @@ -183,16 +184,20 @@ // If a record is found update the data and save it if ( record ) { + record.currentHumidityTotal += weather.observation.imperial.rh; record.currentHumidityCount++; record.save(); + } else { // If no cache record is found, generate a new one and save it new Cache( { + location: location, currentHumidityTotal: weather.observation.imperial.rh, currentHumidityCount: 1 } ).save(); + } } ); } @@ -204,7 +209,7 @@ var temp = ( weather.observation.imperial.temp_max_24hour + weather.observation.imperial.temp_min_24hour ) / 2, // Relative humidity and if unavailable default to 0 - rh = weather.observation.imperial.rh || 0, + rh = weather.yesterdayHumidity || weather.observation.imperial.rh || 0, // The absolute precipitation in the past 48 hours precip = weather.observation.imperial.precip_2day || weather.observation.imperial.precip_24hour; From 90faf0f2fbdd650e3fda3192d88f106548d3b0d5 Mon Sep 17 00:00:00 2001 From: Samer Albahra Date: Thu, 2 Jul 2015 19:43:57 -0500 Subject: [PATCH 12/28] Speed up Elastic Beanstalk update times --- .ebextensions/deploy_npm.config | 37 +++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .ebextensions/deploy_npm.config diff --git a/.ebextensions/deploy_npm.config b/.ebextensions/deploy_npm.config new file mode 100644 index 0000000..901cd2e --- /dev/null +++ b/.ebextensions/deploy_npm.config @@ -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 From e82d72067f828c34be17e7d2e427bfb23ddbe450 Mon Sep 17 00:00:00 2001 From: Samer Albahra Date: Thu, 2 Jul 2015 19:45:12 -0500 Subject: [PATCH 13/28] Change cronjob for weather cache processor to daily --- server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server.js b/server.js index e96b010..d1ca424 100644 --- a/server.js +++ b/server.js @@ -36,7 +36,7 @@ var server = app.listen( port, "127.0.0.1", function() { } ); // Schedule a cronjob daily to consildate the weather cache data, runs daily -new CronJob( "0 * * * * *", function() { +new CronJob( "0 0 0 * * *", function() { // Find all records in the weather cache Cache.find( {}, function( err, records ) { From 8bfe799a01dbf9e297c1e1ce8080a7d8bc3bd9c2 Mon Sep 17 00:00:00 2001 From: Samer Albahra Date: Thu, 2 Jul 2015 20:28:16 -0500 Subject: [PATCH 14/28] Small changes to read and weather route --- README.md | 8 ++- routes/weather.js | 160 +++++++++++++++++++++++++--------------------- 2 files changed, 91 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index cc83b12..e391c7c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/routes/weather.js b/routes/weather.js index 500dba1..b000b50 100644 --- a/routes/weather.js +++ b/routes/weather.js @@ -12,47 +12,6 @@ time: /(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})([+-])(\d{2})(\d{2})/ }; - // 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 ); - } ); - } - - // 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 - Date.prototype.toUSDate = function(){ - return ( this.getMonth() + 1 ) + "/" + this.getDate() + "/" + this.getFullYear(); - }; - // Takes a PWS or ICAO location and resolves the GPS coordinates function getPWSCoordinates( location, weatherUndergroundKey, callback ) { var url = "http://api.wunderground.com/api/" + weatherUndergroundKey + @@ -96,23 +55,6 @@ } ); } - // Accepts a time string formatted in ISO-8601 and returns the timezone. - // The timezone output is formatted for OpenSprinkler Unified firmware. - function getTimezone( time ) { - - // Match the provided time string against a regex for parsing - time = time.match( filters.time ); - - var hour = parseInt( time[7] + time[8] ), - minute = parseInt( time[9] ); - - // Convert the timezone into the OpenSprinkler encoded format - minute = ( minute / 15 >> 0 ) / 4; - hour = hour + ( hour >=0 ? minute : -minute ); - - return ( ( hour + 12 ) * 4 ) >> 0; - } - // Retrieve weather data to complete the weather request function getWeatherData( location, callback ) { @@ -249,21 +191,6 @@ return -1; } - // Function to return the sunrise and sunset times from the weather reply - function getSunData( weather ) { - - // Sun times are parsed from string against a regex to identify the timezone - var sunrise = weather.observation.sunrise.match( filters.time ), - sunset = weather.observation.sunset.match( filters.time ); - - return [ - - // Values are converted to minutes from midnight for the controller - parseInt( sunrise[4] ) * 60 + parseInt( sunrise[5] ), - parseInt( sunset[4] ) * 60 + parseInt( sunset[5] ) - ]; - } - // 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. // @@ -410,5 +337,90 @@ } ); } }; -} )(); + // 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 ); + } ); + } + + // Accepts a time string formatted in ISO-8601 and returns the timezone. + // The timezone output is formatted for OpenSprinkler Unified firmware. + function getTimezone( time ) { + + // Match the provided time string against a regex for parsing + time = time.match( filters.time ); + + var hour = parseInt( time[7] + time[8] ), + minute = parseInt( time[9] ); + + // Convert the timezone into the OpenSprinkler encoded format + minute = ( minute / 15 >> 0 ) / 4; + hour = hour + ( hour >=0 ? minute : -minute ); + + return ( ( hour + 12 ) * 4 ) >> 0; + } + + // Function to return the sunrise and sunset times from the weather reply + function getSunData( weather ) { + + // Sun times are parsed from string against a regex to identify the timezone + var sunrise = weather.observation.sunrise.match( filters.time ), + sunset = weather.observation.sunset.match( filters.time ); + + return [ + + // Values are converted to minutes from midnight for the controller + parseInt( sunrise[4] ) * 60 + parseInt( sunrise[5] ), + parseInt( sunset[4] ) * 60 + parseInt( sunset[5] ) + ]; + } + + // Converts IP string to integer + function ipToInt( ip ) { + ip = ip.split( "." ); + return ( ( ( ( ( ( +ip[0] ) * 256 ) + ( +ip[1] ) ) * 256 ) + ( +ip[2] ) ) * 256 ) + ( +ip[3] ); + } + + function F2C( temp ) { + return ( temp - 32 ) * 5 / 9; + } + + function mm2in( x ) { + return x * 0.03937008; + } + + function ft2m( x ) { + return x * 0.3048; + } + + // Resolves the Month / Day / Year of a Date object + Date.prototype.toUSDate = function(){ + return ( this.getMonth() + 1 ) + "/" + this.getDate() + "/" + this.getFullYear(); + }; + +} )(); From 69d4c9ca4d011bae90b966d975560cae9ef755e4 Mon Sep 17 00:00:00 2001 From: Samer Albahra Date: Thu, 2 Jul 2015 20:53:18 -0500 Subject: [PATCH 15/28] Many improvements and big fixes --- .gitignore | 1 + .jscsrc | 3 --- .jshintrc | 21 +++++++++++++++++++++ Gruntfile.js | 13 ++----------- package.json | 1 - routes/weather.js | 23 ++++++++++++----------- server.js | 4 ++-- 7 files changed, 38 insertions(+), 28 deletions(-) delete mode 100644 .jscsrc create mode 100644 .jshintrc diff --git a/.gitignore b/.gitignore index 9c959ef..f38038a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.DS_Store node_modules .env WeatherService.zip diff --git a/.jscsrc b/.jscsrc deleted file mode 100644 index a87f8f0..0000000 --- a/.jscsrc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "maximumLineLength": 150 -} diff --git a/.jshintrc b/.jshintrc new file mode 100644 index 0000000..94a6a96 --- /dev/null +++ b/.jshintrc @@ -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 + } +} diff --git a/Gruntfile.js b/Gruntfile.js index bdc3b44..7b0f6dc 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -2,29 +2,20 @@ module.exports = function( grunt ) { // Load node-modules; grunt.loadNpmTasks( "grunt-contrib-jshint" ); - grunt.loadNpmTasks( "grunt-jscs" ); // Project configuration. grunt.initConfig( { pkg: grunt.file.readJSON( "package.json" ), jshint: { - main: [ "Gruntfile.js", "server.js", "routes/**", "models/**" ], + main: [ "Gruntfile.js", "server.js", "routes/*.js", "models/*.js" ], options: { jshintrc: true } - }, - - jscs: { - main: [ "Gruntfile.js", "server.js", "routes/**", "models/**" ], - options: { - config: true, - fix: true - } } } ); // Default task(s). - grunt.registerTask( "default", [ "jshint", "jscs" ] ); + grunt.registerTask( "default", [ "jshint" ] ); }; diff --git a/package.json b/package.json index 5ad1ce4..f03d4c2 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,6 @@ "express": "^4.13.0", "grunt": "^0.4.5", "grunt-contrib-jshint": "^0.11.2", - "grunt-jscs": "^1.8.0", "mongoose": "^4.0.6", "xml2js": "^0.4.9" } diff --git a/routes/weather.js b/routes/weather.js index b000b50..8d6843c 100644 --- a/routes/weather.js +++ b/routes/weather.js @@ -1,7 +1,7 @@ ( function() { var http = require( "http" ), - parseXML = require( "xml2js" ).parseString, +// parseXML = require( "xml2js" ).parseString, Cache = require( "../models/Cache" ), // Define regex filters to match against location @@ -92,7 +92,7 @@ } ); } - +/* // Retrieve the historical weather data for the provided location function getYesterdayWeatherData( location, callback ) { @@ -108,7 +108,7 @@ // 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.toUSDate() + "&enddate=" + yesterday.toUSDate() + "&TS=LST"; + "&Req=davg&startdate=" + toUSDate( yesterday ) + "&enddate=" + toUSDate( yesterday ) + "&TS=LST"; // Perform the HTTP request to retrieve the weather data httpRequest( url, function( xml ) { @@ -117,7 +117,7 @@ }); } ); } - +*/ // Update weather cache record in the local database function updateCache( location, weather ) { @@ -163,7 +163,7 @@ } // Zimmerman method - if ( adjustmentMethod == 1 ) { + if ( adjustmentMethod === 1 ) { var humidityFactor = ( 30 - rh ), tempFactor = ( ( temp - 70 ) * 4 ), @@ -237,7 +237,7 @@ location = req.query.loc, weatherUndergroundKey = req.query.key, outputFormat = req.query.format, - firmwareVersion = req.query.fwv, +// 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 @@ -406,7 +406,8 @@ return ( ( ( ( ( ( +ip[0] ) * 256 ) + ( +ip[1] ) ) * 256 ) + ( +ip[2] ) ) * 256 ) + ( +ip[3] ); } - function F2C( temp ) { +/* + function f2c( temp ) { return ( temp - 32 ) * 5 / 9; } @@ -419,8 +420,8 @@ } // Resolves the Month / Day / Year of a Date object - Date.prototype.toUSDate = function(){ - return ( this.getMonth() + 1 ) + "/" + this.getDate() + "/" + this.getFullYear(); - }; - + function toUSDate( date ){ + return ( date.getMonth() + 1 ) + "/" + date.getDate() + "/" + date.getFullYear(); + } +*/ } )(); diff --git a/server.js b/server.js index d1ca424..b64adfd 100644 --- a/server.js +++ b/server.js @@ -3,7 +3,7 @@ var express = require( "express" ), mongoose = require( "mongoose" ), Cache = require( "./models/Cache" ), CronJob = require( "cron" ).CronJob, - port = process.env.PORT || 3000; + port = process.env.PORT || 3000, app = express(); if ( !process.env.PORT ) { @@ -30,7 +30,7 @@ app.use( function( req, res ) { } ); // Start listening on the service port -var server = app.listen( port, "127.0.0.1", function() { +app.listen( port, "127.0.0.1", function() { console.log( "OpenSprinkler Weather Service now listening on port %s", port ); } ); From 687e3c6ca7b2a60426b57550bf1c85f0554b5cd6 Mon Sep 17 00:00:00 2001 From: Samer Albahra Date: Fri, 3 Jul 2015 01:10:59 -0500 Subject: [PATCH 16/28] Remove unnecessary IIFE https://stackoverflow.com/questions/21531329/are-node-js-modules-need-to -be-wrapped-inside-the-module-pattern --- routes/weather.js | 688 +++++++++++++++++++++++----------------------- 1 file changed, 343 insertions(+), 345 deletions(-) diff --git a/routes/weather.js b/routes/weather.js index 8d6843c..86f5f73 100644 --- a/routes/weather.js +++ b/routes/weather.js @@ -1,427 +1,425 @@ -( function() { +var http = require( "http" ), +// parseXML = require( "xml2js" ).parseString, + Cache = require( "../models/Cache" ), - var http = require( "http" ), -// 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})/ + }; - // 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})/ - }; +// 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"; - // 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 ); - 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 ( 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 ) { - // 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 ); - // Generate URL for autocomplete request - var url = "http://autocomplete.wunderground.com/aq?h=0&query=" + - encodeURIComponent( location ); + httpRequest( url, function( data ) { - httpRequest( url, function( data ) { + // Parse the reply for JSON data + data = JSON.parse( 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 ) { - // 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 { - // 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 ); + } + } ); +} - // Otherwise, indicate no data was found - callback( false ); - } - } ); - } +// Retrieve weather data to complete the weather request +function getWeatherData( location, callback ) { - // Retrieve weather data to complete the weather request - function getWeatherData( location, callback ) { + // Get the API key from the environment variables + var WSI_API_KEY = process.env.WSI_API_KEY, - // 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"; - // 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 ) { - // Perform the HTTP request to retrieve the weather data - httpRequest( url, function( data ) { + try { + var weather = JSON.parse( data ); - try { - var weather = JSON.parse( data ); + location = location.join( "," ); - location = location.join( "," ); + Cache.findOne( { location: location }, function( err, record ) { - Cache.findOne( { location: location }, function( err, record ) { + if ( record && record.yesterdayHumidity !== null ) { + weather.yesterdayHumidity = record.yesterdayHumidity; + } - if ( record && record.yesterdayHumidity !== null ) { - weather.yesterdayHumidity = record.yesterdayHumidity; - } + // Return the data to the callback function if successful + callback( weather ); + } ); - // Return the data to the callback function if successful - callback( weather ); - } ); + updateCache( location, weather ); + } catch ( err ) { - updateCache( location, weather ); - } catch (err) { + // Otherwise indicate the request failed + callback( false ); + } - // Otherwise indicate the request failed - callback( false ); - } - - } ); - } + } ); +} /* - // Retrieve the historical weather data for the provided location - function getYesterdayWeatherData( location, callback ) { +// 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, + // Get the API key from the environment variables + var WSI_HISTORY_KEY = process.env.WSI_HISTORY_KEY, - // Generate a Date object for the current time - today = new Date(), + // Generate a Date object for the current time + today = new Date(), - // Generate a Date object for the previous day by subtracting a day (in milliseconds) from today - yesterday = new Date( today.getTime() - 1000 * 60 * 60 * 24 ), + // Generate a Date object for the previous day by subtracting a day (in milliseconds) from today + yesterday = new Date( today.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=" + toUSDate( yesterday ) + "&enddate=" + toUSDate( yesterday ) + "&TS=LST"; + // 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=" + toUSDate( yesterday ) + "&enddate=" + toUSDate( 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].$ ); - }); - } ); - } + // 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 ) { +// Update weather cache record in the local database +function updateCache( location, weather ) { - // If a record is found update the data and save it - if ( record ) { + // Search for a cache record for the provided location + Cache.findOne( { location: location }, function( err, record ) { - record.currentHumidityTotal += weather.observation.imperial.rh; - record.currentHumidityCount++; - record.save(); + // If a record is found update the data and save it + if ( record ) { - } else { + record.currentHumidityTotal += weather.observation.imperial.rh; + record.currentHumidityCount++; + record.save(); - // If no cache record is found, generate a new one and save it - new Cache( { - location: location, - currentHumidityTotal: weather.observation.imperial.rh, - currentHumidityCount: 1 - } ).save(); + } else { - } - } ); + // If no cache record is found, generate a new one and save it + new Cache( { + location: location, + currentHumidityTotal: weather.observation.imperial.rh, + currentHumidityCount: 1 + } ).save(); + + } + } ); +} + +// Calculates the resulting water scale using the provided weather data, adjustment method and options +function calculateWeatherScale( adjustmentMethod, adjustmentOptions, weather ) { + + // Calculate the average temperature + var temp = ( weather.observation.imperial.temp_max_24hour + weather.observation.imperial.temp_min_24hour ) / 2, + + // Relative humidity and if unavailable default to 0 + rh = weather.yesterdayHumidity || weather.observation.imperial.rh || 0, + + // The absolute precipitation in the past 48 hours + precip = weather.observation.imperial.precip_2day || weather.observation.imperial.precip_24hour; + + if ( typeof temp !== "number" ) { + + // If the maximum and minimum temperatures are not available then use the current temperature + temp = weather.observation.imperial.temp; } - // Calculates the resulting water scale using the provided weather data, adjustment method and options - function calculateWeatherScale( adjustmentMethod, adjustmentOptions, weather ) { + // Zimmerman method + if ( adjustmentMethod === 1 ) { - // Calculate the average temperature - var temp = ( weather.observation.imperial.temp_max_24hour + weather.observation.imperial.temp_min_24hour ) / 2, + var humidityFactor = ( 30 - rh ), + tempFactor = ( ( temp - 70 ) * 4 ), + precipFactor = ( precip * -2 ); - // Relative humidity and if unavailable default to 0 - rh = weather.yesterdayHumidity || weather.observation.imperial.rh || 0, - - // The absolute precipitation in the past 48 hours - precip = weather.observation.imperial.precip_2day || weather.observation.imperial.precip_24hour; - - if ( typeof temp !== "number" ) { - - // If the maximum and minimum temperatures are not available then use the current temperature - temp = weather.observation.imperial.temp; - } - - // Zimmerman method - if ( adjustmentMethod === 1 ) { - - var humidityFactor = ( 30 - rh ), - tempFactor = ( ( temp - 70 ) * 4 ), - precipFactor = ( precip * -2 ); - - // 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 adjustment options, if provided, by multiplying the percentage against the factor + if ( adjustmentOptions ) { + if ( adjustmentOptions.hasOwnProperty( "h" ) ) { + humidityFactor = humidityFactor * ( adjustmentOptions.h / 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 ) ); + if ( adjustmentOptions.hasOwnProperty( "t" ) ) { + tempFactor = tempFactor * ( adjustmentOptions.t / 100 ); + } + + if ( adjustmentOptions.hasOwnProperty( "r" ) ) { + precipFactor = precipFactor * ( adjustmentOptions.r / 100 ); + } } - return -1; + // 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 ) ); } - // 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 ) { + return -1; +} - // 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]; +// 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 ) { - if ( adverseCodes.indexOf( weather.observation.icon_code ) !== -1 ) { + // 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 ]; - // If the current weather indicates rain, add a restrict flag to the weather script indicating - // the controller should not water. + if ( adverseCodes.indexOf( weather.observation.icon_code ) !== -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.observation.imperial.precip_2day > 0.01 || weather.observation.imperial.precip_24hour > 0.01 ) { 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.observation.imperial.precip_2day > 0.01 || weather.observation.imperial.precip_24hour > 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 ) { + return false; +} - // 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, +// 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, + 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 || typeof weather.observation !== "object" || typeof weather.observation.imperial !== "object" ) { - 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.observation.obs_time_local ), - sunrise: getSunData( weather )[0], - sunset: getSunData( weather )[1], - 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.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 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." ); + // 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 || typeof weather.observation !== "object" || typeof weather.observation.imperial !== "object" ) { + res.send( "Error: No weather data found." ); return; } - getPWSCoordinates( location, weatherUndergroundKey, function( result ) { - if ( result === false ) { - res.send( "Error: Unable to resolve location" ); - return; - } + var data = { + scale: calculateWeatherScale( adjustmentMethod, adjustmentOptions, weather ), + restrict: checkWeatherRestriction( req.params[0], weather ) ? 1 : 0, + tz: getTimezone( weather.observation.obs_time_local ), + sunrise: getSunData( weather )[0], + sunset: getSunData( weather )[1], + eip: ipToInt( remoteAddress ) + }; - location = result; - 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] + // 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 + ); + } }; - http.get( options, function( response ) { - var data = ""; + // Exit if no location is provided + if ( !location ) { + res.send( "Error: No location provided." ); + return; + } - // Reassemble the data as it comes in - response.on( "data", function( chunk ) { - data += chunk; - } ); + // 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]; - // Once the data is completely received, return it to the callback - response.on( "end", function() { - callback( data ); - } ); - } ).on( "error", function() { + // Parse weather adjustment options + try { - // If the HTTP request fails, return false - callback( false ); + // 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.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 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 { - // Accepts a time string formatted in ISO-8601 and returns the timezone. - // The timezone output is formatted for OpenSprinkler Unified firmware. - function getTimezone( time ) { + // 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; + } - // Match the provided time string against a regex for parsing - time = time.match( filters.time ); + location = result; + getWeatherData( location, finishRequest ); + } ); + } +}; - var hour = parseInt( time[7] + time[8] ), - minute = parseInt( time[9] ); +// 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 ); - // Convert the timezone into the OpenSprinkler encoded format - minute = ( minute / 15 >> 0 ) / 4; - hour = hour + ( hour >=0 ? minute : -minute ); + var options = { + host: url[1], + port: url[2] || 80, + path: url[3] + }; - return ( ( hour + 12 ) * 4 ) >> 0; - } + http.get( options, function( response ) { + var data = ""; - // Function to return the sunrise and sunset times from the weather reply - function getSunData( weather ) { + // Reassemble the data as it comes in + response.on( "data", function( chunk ) { + data += chunk; + } ); - // Sun times are parsed from string against a regex to identify the timezone - var sunrise = weather.observation.sunrise.match( filters.time ), - sunset = weather.observation.sunset.match( filters.time ); + // Once the data is completely received, return it to the callback + response.on( "end", function() { + callback( data ); + } ); + } ).on( "error", function() { - return [ + // If the HTTP request fails, return false + callback( false ); + } ); +} - // Values are converted to minutes from midnight for the controller - parseInt( sunrise[4] ) * 60 + parseInt( sunrise[5] ), - parseInt( sunset[4] ) * 60 + parseInt( sunset[5] ) - ]; - } +// Accepts a time string formatted in ISO-8601 and returns the timezone. +// The timezone output is formatted for OpenSprinkler Unified firmware. +function getTimezone( time ) { - // Converts IP string to integer - function ipToInt( ip ) { - ip = ip.split( "." ); - return ( ( ( ( ( ( +ip[0] ) * 256 ) + ( +ip[1] ) ) * 256 ) + ( +ip[2] ) ) * 256 ) + ( +ip[3] ); - } + // Match the provided time string against a regex for parsing + time = time.match( filters.time ); + + var hour = parseInt( time[7] + time[8] ), + minute = parseInt( time[9] ); + + // Convert the timezone into the OpenSprinkler encoded format + minute = ( minute / 15 >> 0 ) / 4; + hour = hour + ( hour >= 0 ? minute : -minute ); + + return ( ( hour + 12 ) * 4 ) >> 0; +} + +// Function to return the sunrise and sunset times from the weather reply +function getSunData( weather ) { + + // Sun times are parsed from string against a regex to identify the timezone + var sunrise = weather.observation.sunrise.match( filters.time ), + sunset = weather.observation.sunset.match( filters.time ); + + return [ + + // Values are converted to minutes from midnight for the controller + parseInt( sunrise[4] ) * 60 + parseInt( sunrise[5] ), + parseInt( sunset[4] ) * 60 + parseInt( sunset[5] ) + ]; +} + +// Converts IP string to integer +function ipToInt( ip ) { + ip = ip.split( "." ); + return ( ( ( ( ( ( +ip[0] ) * 256 ) + ( +ip[1] ) ) * 256 ) + ( +ip[2] ) ) * 256 ) + ( +ip[3] ); +} /* - function f2c( temp ) { - return ( temp - 32 ) * 5 / 9; - } +function f2c( temp ) { + return ( temp - 32 ) * 5 / 9; +} - function mm2in( x ) { - return x * 0.03937008; - } +function mm2in( x ) { + return x * 0.03937008; +} - function ft2m( x ) { - return x * 0.3048; - } +function ft2m( x ) { + return x * 0.3048; +} - // Resolves the Month / Day / Year of a Date object - function toUSDate( date ){ - return ( date.getMonth() + 1 ) + "/" + date.getDate() + "/" + date.getFullYear(); - } +// Resolves the Month / Day / Year of a Date object +function toUSDate( date ){ + return ( date.getMonth() + 1 ) + "/" + date.getDate() + "/" + date.getFullYear(); +} */ -} )(); From cd67a6f997af1c7b52a3fe96608dfd5eb7d2ca83 Mon Sep 17 00:00:00 2001 From: Samer Albahra Date: Fri, 3 Jul 2015 01:27:40 -0500 Subject: [PATCH 17/28] Minor fixes --- routes/weather.js | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/routes/weather.js b/routes/weather.js index 86f5f73..ff7acf6 100644 --- a/routes/weather.js +++ b/routes/weather.js @@ -90,32 +90,28 @@ function getWeatherData( location, callback ) { } ); } -/* + // 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 current time - today = new Date(), - // Generate a Date object for the previous day by subtracting a day (in milliseconds) from today - yesterday = new Date( today.getTime() - 1000 * 60 * 60 * 24 ), + 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=" + toUSDate( yesterday ) + "&enddate=" + toUSDate( yesterday ) + "&TS=LST"; + "&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 ) { + parseXML( xml, function( err, result ) { callback( result.WeatherResponse.WeatherRecords[0].WeatherData[0].$ ); - }); + } ); } ); } -*/ // Update weather cache record in the local database function updateCache( location, weather ) { @@ -236,7 +232,7 @@ exports.getWeather = function( req, res ) { location = req.query.loc, weatherUndergroundKey = req.query.key, outputFormat = req.query.format, -// firmwareVersion = req.query.fwv, + 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 @@ -405,7 +401,6 @@ function ipToInt( ip ) { return ( ( ( ( ( ( +ip[0] ) * 256 ) + ( +ip[1] ) ) * 256 ) + ( +ip[2] ) ) * 256 ) + ( +ip[3] ); } -/* function f2c( temp ) { return ( temp - 32 ) * 5 / 9; } @@ -419,7 +414,6 @@ function ft2m( x ) { } // Resolves the Month / Day / Year of a Date object -function toUSDate( date ){ +function toUSDate( date ) { return ( date.getMonth() + 1 ) + "/" + date.getDate() + "/" + date.getFullYear(); } -*/ From 288733a1907ee7b34369c0855448a4442b3ec838 Mon Sep 17 00:00:00 2001 From: Samer Albahra Date: Fri, 3 Jul 2015 02:53:51 -0500 Subject: [PATCH 18/28] Add the ability to retrieve weather data from Weather Underground --- package.json | 1 + routes/weather.js | 149 ++++++++++++++++++++++++++++++---------------- 2 files changed, 98 insertions(+), 52 deletions(-) diff --git a/package.json b/package.json index f03d4c2..271502f 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "grunt": "^0.4.5", "grunt-contrib-jshint": "^0.11.2", "mongoose": "^4.0.6", + "suncalc": "^1.6.0", "xml2js": "^0.4.9" } } diff --git a/routes/weather.js b/routes/weather.js index ff7acf6..321aabb 100644 --- a/routes/weather.js +++ b/routes/weather.js @@ -1,4 +1,5 @@ var http = require( "http" ), + SunCalc = require( "suncalc" ), // parseXML = require( "xml2js" ).parseString, Cache = require( "../models/Cache" ), @@ -7,7 +8,8 @@ var http = require( "http" ), 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})/ + 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 @@ -53,7 +55,47 @@ function resolveCoordinates( location, callback ) { } ); } -// Retrieve weather data to complete the weather request +// 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/" + location + ".json"; + + // Perform the HTTP request to retrieve the weather data + httpRequest( url, function( data ) { + try { + var data = JSON.parse( data ); + + // Calculate sunrise and sunset since Weather Underground does not provide it + var sunData = SunCalc.getTimes( new Date(), data.current_observation.observation_location.latitude, data.current_observation.observation_location.longitude ), + weather = { + icon: data.current_observation.icon, + timezone: data.current_observation.local_tz_offset, + sunrise: ( sunData.sunrise.getHours() * 60 + sunData.sunrise.getMinutes() ), + sunset: ( sunData.sunset.getHours() * 60 + sunData.sunset.getMinutes() ), + maxTemp: parseInt( data.history.dailysummary[0].maxtempi ), + minTemp: parseInt( data.history.dailysummary[0].mintempi ), + temp: 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: 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 @@ -67,7 +109,20 @@ function getWeatherData( location, callback ) { httpRequest( url, function( data ) { try { - var weather = JSON.parse( data ); + var data = JSON.parse( data ), + 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( "," ); @@ -122,7 +177,7 @@ function updateCache( location, weather ) { // If a record is found update the data and save it if ( record ) { - record.currentHumidityTotal += weather.observation.imperial.rh; + record.currentHumidityTotal += weather.humidity; record.currentHumidityCount++; record.save(); @@ -131,7 +186,7 @@ function updateCache( location, weather ) { // If no cache record is found, generate a new one and save it new Cache( { location: location, - currentHumidityTotal: weather.observation.imperial.rh, + currentHumidityTotal: weather.humidity, currentHumidityCount: 1 } ).save(); @@ -142,28 +197,14 @@ function updateCache( location, weather ) { // Calculates the resulting water scale using the provided weather data, adjustment method and options function calculateWeatherScale( adjustmentMethod, adjustmentOptions, weather ) { - // Calculate the average temperature - var temp = ( weather.observation.imperial.temp_max_24hour + weather.observation.imperial.temp_min_24hour ) / 2, - - // Relative humidity and if unavailable default to 0 - rh = weather.yesterdayHumidity || weather.observation.imperial.rh || 0, - - // The absolute precipitation in the past 48 hours - precip = weather.observation.imperial.precip_2day || weather.observation.imperial.precip_24hour; - - if ( typeof temp !== "number" ) { - - // If the maximum and minimum temperatures are not available then use the current temperature - temp = weather.observation.imperial.temp; - } - // Zimmerman method if ( adjustmentMethod === 1 ) { - var humidityFactor = ( 30 - rh ), + var temp = ( ( weather.maxTemp + weather.minTemp ) / 2 ) || weather.temp, + humidityFactor = ( 30 - weather.humidity ), tempFactor = ( ( temp - 70 ) * 4 ), - precipFactor = ( precip * -2 ); - + precipFactor = ( weather.precip * -200 ); +console.log(temp, humidityFactor, tempFactor, precipFactor); // Apply adjustment options, if provided, by multiplying the percentage against the factor if ( adjustmentOptions ) { if ( adjustmentOptions.hasOwnProperty( "h" ) ) { @@ -196,9 +237,10 @@ function calculateWeatherScale( adjustmentMethod, adjustmentOptions, weather ) { 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 ]; + 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 ( adverseCodes.indexOf( weather.observation.icon_code ) !== -1 ) { + 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. @@ -211,7 +253,7 @@ function checkWeatherRestriction( adjustmentValue, weather ) { // 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.observation.imperial.precip_2day > 0.01 || weather.observation.imperial.precip_24hour > 0.01 ) { + if ( weather.precip > 0.01 ) { return true; } } @@ -239,7 +281,7 @@ exports.getWeather = function( req, res ) { // 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 || typeof weather.observation !== "object" || typeof weather.observation.imperial !== "object" ) { + if ( !weather ) { res.send( "Error: No weather data found." ); return; } @@ -247,9 +289,9 @@ exports.getWeather = function( req, res ) { var data = { scale: calculateWeatherScale( adjustmentMethod, adjustmentOptions, weather ), restrict: checkWeatherRestriction( req.params[0], weather ) ? 1 : 0, - tz: getTimezone( weather.observation.obs_time_local ), - sunrise: getSunData( weather )[0], - sunset: getSunData( weather )[1], + tz: getTimezone( weather.timezone ), + sunrise: weather.sunrise, + sunset: weather.sunset, eip: ipToInt( remoteAddress ) }; @@ -289,16 +331,7 @@ exports.getWeather = function( req, res ) { } // Parse location string - 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 if ( filters.pws.test( location ) ) { + if ( filters.pws.test( location ) ) { // Handle locations using PWS or ICAO (Weather Underground) if ( !weatherUndergroundKey ) { @@ -317,6 +350,22 @@ exports.getWeather = function( req, res ) { 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 @@ -363,12 +412,13 @@ function httpRequest( url, callback ) { } ); } -// Accepts a time string formatted in ISO-8601 and returns the timezone. +// 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 ) { // Match the provided time string against a regex for parsing - time = time.match( filters.time ); + time = time.match( filters.time ) || time.match( filters.timezone ); var hour = parseInt( time[7] + time[8] ), minute = parseInt( time[9] ); @@ -381,18 +431,13 @@ function getTimezone( time ) { } // Function to return the sunrise and sunset times from the weather reply -function getSunData( weather ) { +function parseDayTime( time ) { - // Sun times are parsed from string against a regex to identify the timezone - var sunrise = weather.observation.sunrise.match( filters.time ), - sunset = weather.observation.sunset.match( filters.time ); + // Time is parsed from string against a regex + time = time.match( filters.time ); - return [ - - // Values are converted to minutes from midnight for the controller - parseInt( sunrise[4] ) * 60 + parseInt( sunrise[5] ), - parseInt( sunset[4] ) * 60 + parseInt( sunset[5] ) - ]; + // Values are converted to minutes from midnight for the controller + return parseInt( time[4] ) * 60 + parseInt( time[5] ); } // Converts IP string to integer From 859bce73dd992c6966319c98a6563133f0e7cd2f Mon Sep 17 00:00:00 2001 From: Samer Albahra Date: Fri, 3 Jul 2015 02:54:58 -0500 Subject: [PATCH 19/28] Remove debug code --- routes/weather.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/weather.js b/routes/weather.js index 321aabb..c32d0ea 100644 --- a/routes/weather.js +++ b/routes/weather.js @@ -204,7 +204,7 @@ function calculateWeatherScale( adjustmentMethod, adjustmentOptions, weather ) { humidityFactor = ( 30 - weather.humidity ), tempFactor = ( ( temp - 70 ) * 4 ), precipFactor = ( weather.precip * -200 ); -console.log(temp, humidityFactor, tempFactor, precipFactor); + // Apply adjustment options, if provided, by multiplying the percentage against the factor if ( adjustmentOptions ) { if ( adjustmentOptions.hasOwnProperty( "h" ) ) { From c096106738ced36a731b65fc489539c0fec12e80 Mon Sep 17 00:00:00 2001 From: Samer Albahra Date: Fri, 3 Jul 2015 10:29:05 -0500 Subject: [PATCH 20/28] Generate date using the local observation time instead of server --- routes/weather.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/routes/weather.js b/routes/weather.js index c32d0ea..33a9ac1 100644 --- a/routes/weather.js +++ b/routes/weather.js @@ -68,7 +68,9 @@ function getWeatherUndergroundData( location, weatherUndergroundKey, callback ) var data = JSON.parse( data ); // Calculate sunrise and sunset since Weather Underground does not provide it - var sunData = SunCalc.getTimes( new Date(), data.current_observation.observation_location.latitude, data.current_observation.observation_location.longitude ), + var sunData = SunCalc.getTimes( new Date( data.current_observation.local_epoch * 1000 ), + data.current_observation.observation_location.latitude, + data.current_observation.observation_location.longitude ), weather = { icon: data.current_observation.icon, timezone: data.current_observation.local_tz_offset, @@ -84,6 +86,10 @@ function getWeatherUndergroundData( location, weatherUndergroundKey, callback ) elevation: data.current_observation.observation_location.elevation }; + if ( weather.sunrise > weather.sunset ) { + weather.sunset += 1440; + } + callback( weather ); } catch ( err ) { From 261ffb4d3529ee705f381cb922a334816125cba2 Mon Sep 17 00:00:00 2001 From: Samer Albahra Date: Fri, 3 Jul 2015 10:50:02 -0500 Subject: [PATCH 21/28] Attempt to remove timezone offset from sun time calculations --- routes/weather.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/routes/weather.js b/routes/weather.js index 33a9ac1..74ebc3b 100644 --- a/routes/weather.js +++ b/routes/weather.js @@ -65,12 +65,16 @@ function getWeatherUndergroundData( location, weatherUndergroundKey, callback ) // Perform the HTTP request to retrieve the weather data httpRequest( url, function( data ) { try { - var data = JSON.parse( data ); + var data = JSON.parse( data ), + date = new Date( data.current_observation.local_epoch * 1000 ); + + date.setMinutes( date.getMinutes() + date.getTimezoneOffset() ); // Calculate sunrise and sunset since Weather Underground does not provide it - var sunData = SunCalc.getTimes( new Date( data.current_observation.local_epoch * 1000 ), + var sunData = SunCalc.getTimes( date, data.current_observation.observation_location.latitude, data.current_observation.observation_location.longitude ), + weather = { icon: data.current_observation.icon, timezone: data.current_observation.local_tz_offset, From 7541c21bb42ad165f293b556d18f2d9d39ce8c38 Mon Sep 17 00:00:00 2001 From: Samer Albahra Date: Fri, 3 Jul 2015 11:08:39 -0500 Subject: [PATCH 22/28] Attempt to generate UTC timestamp for sun times --- routes/weather.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/routes/weather.js b/routes/weather.js index 74ebc3b..0642bc6 100644 --- a/routes/weather.js +++ b/routes/weather.js @@ -66,15 +66,13 @@ function getWeatherUndergroundData( location, weatherUndergroundKey, callback ) httpRequest( url, function( data ) { try { var data = JSON.parse( data ), - date = new Date( data.current_observation.local_epoch * 1000 ); - date.setMinutes( date.getMinutes() + date.getTimezoneOffset() ); + date = new Date( Date.UTC( 1970, 0, 1, 0, 0, data.current_observation.local_epoch, 0 ) ), - // Calculate sunrise and sunset since Weather Underground does not provide it - var sunData = SunCalc.getTimes( date, + // Calculate sunrise and sunset since Weather Underground does not provide it + sunData = SunCalc.getTimes( date, data.current_observation.observation_location.latitude, data.current_observation.observation_location.longitude ), - weather = { icon: data.current_observation.icon, timezone: data.current_observation.local_tz_offset, @@ -90,6 +88,8 @@ function getWeatherUndergroundData( location, weatherUndergroundKey, callback ) elevation: data.current_observation.observation_location.elevation }; + console.log( date, date.getTimezoneOffset() ); + if ( weather.sunrise > weather.sunset ) { weather.sunset += 1440; } From d07e861722ee269b1c020d74e44c3d569f114170 Mon Sep 17 00:00:00 2001 From: Samer Albahra Date: Fri, 3 Jul 2015 11:21:44 -0500 Subject: [PATCH 23/28] Fix timezone issues with sunrise/sunset times --- routes/weather.js | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/routes/weather.js b/routes/weather.js index 0642bc6..d63da20 100644 --- a/routes/weather.js +++ b/routes/weather.js @@ -67,17 +67,17 @@ function getWeatherUndergroundData( location, weatherUndergroundKey, callback ) try { var data = JSON.parse( data ), - date = new Date( Date.UTC( 1970, 0, 1, 0, 0, data.current_observation.local_epoch, 0 ) ), + tzOffset = getTimezone( data.current_observation.local_tz_offset, "minutes" ), // Calculate sunrise and sunset since Weather Underground does not provide it - sunData = SunCalc.getTimes( date, + sunData = SunCalc.getTimes( data.current_observation.local_epoch * 1000, data.current_observation.observation_location.latitude, data.current_observation.observation_location.longitude ), weather = { icon: data.current_observation.icon, timezone: data.current_observation.local_tz_offset, - sunrise: ( sunData.sunrise.getHours() * 60 + sunData.sunrise.getMinutes() ), - sunset: ( sunData.sunset.getHours() * 60 + sunData.sunset.getMinutes() ), + sunrise: ( sunData.sunrise.getUTCHours() * 60 + sunData.sunrise.getUTCMinutes() ) + tzOffset, + sunset: ( sunData.sunset.getUTCHours() * 60 + sunData.sunset.getUTCMinutes() ) + tzOffset, maxTemp: parseInt( data.history.dailysummary[0].maxtempi ), minTemp: parseInt( data.history.dailysummary[0].mintempi ), temp: data.current_observation.temp_f, @@ -88,8 +88,6 @@ function getWeatherUndergroundData( location, weatherUndergroundKey, callback ) elevation: data.current_observation.observation_location.elevation }; - console.log( date, date.getTimezoneOffset() ); - if ( weather.sunrise > weather.sunset ) { weather.sunset += 1440; } @@ -425,19 +423,27 @@ function httpRequest( url, callback ) { // 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 ) { +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] ); + minute = parseInt( time[9] ), + tz; - // Convert the timezone into the OpenSprinkler encoded format - minute = ( minute / 15 >> 0 ) / 4; - hour = hour + ( hour >= 0 ? minute : -minute ); + if ( format === "minutes" ) { + tz = ( hour * 60 ) + minute; + } else { - return ( ( hour + 12 ) * 4 ) >> 0; + // 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 From 6ad0f0ff331d690c69691831c95a34fdd5ca9c37 Mon Sep 17 00:00:00 2001 From: Samer Albahra Date: Fri, 3 Jul 2015 13:13:30 -0500 Subject: [PATCH 24/28] Add data validation before running water calculation function --- routes/weather.js | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/routes/weather.js b/routes/weather.js index d63da20..507b1a9 100644 --- a/routes/weather.js +++ b/routes/weather.js @@ -80,12 +80,12 @@ function getWeatherUndergroundData( location, weatherUndergroundKey, callback ) sunset: ( sunData.sunset.getUTCHours() * 60 + sunData.sunset.getUTCMinutes() ) + tzOffset, maxTemp: parseInt( data.history.dailysummary[0].maxtempi ), minTemp: parseInt( data.history.dailysummary[0].mintempi ), - temp: data.current_observation.temp_f, + 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: data.current_observation.observation_location.elevation + elevation: parseInt( data.current_observation.observation_location.elevation ) }; if ( weather.sunrise > weather.sunset ) { @@ -208,6 +208,11 @@ 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 ), @@ -420,6 +425,25 @@ function httpRequest( url, callback ) { } ); } +// 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. From 1a1a744dd7191741768878fb9ee1f38897555b7e Mon Sep 17 00:00:00 2001 From: Samer Albahra Date: Fri, 3 Jul 2015 13:22:45 -0500 Subject: [PATCH 25/28] Add timezone offset to Date object so overflow is handled --- routes/weather.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/routes/weather.js b/routes/weather.js index 507b1a9..5c2f4ec 100644 --- a/routes/weather.js +++ b/routes/weather.js @@ -72,12 +72,16 @@ function getWeatherUndergroundData( location, weatherUndergroundKey, callback ) // 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.observation_location.longitude ), - weather = { + data.current_observation.observation_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() ) + tzOffset, - sunset: ( sunData.sunset.getUTCHours() * 60 + sunData.sunset.getUTCMinutes() ) + tzOffset, + 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 ), @@ -88,10 +92,6 @@ function getWeatherUndergroundData( location, weatherUndergroundKey, callback ) elevation: parseInt( data.current_observation.observation_location.elevation ) }; - if ( weather.sunrise > weather.sunset ) { - weather.sunset += 1440; - } - callback( weather ); } catch ( err ) { From b7bf17ee9dfb2b9260bc12821ba55d18b58955bd Mon Sep 17 00:00:00 2001 From: Samer Albahra Date: Fri, 3 Jul 2015 13:56:06 -0500 Subject: [PATCH 26/28] Add fallback coordinates if none are provided by observation location --- routes/weather.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/routes/weather.js b/routes/weather.js index 5c2f4ec..ed1b6b8 100644 --- a/routes/weather.js +++ b/routes/weather.js @@ -71,8 +71,8 @@ function getWeatherUndergroundData( location, weatherUndergroundKey, callback ) // 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.observation_location.longitude ); + 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 ); From 2bdc99582537d6cd1fdedc0ea0a10073e27f4f17 Mon Sep 17 00:00:00 2001 From: Samer Albahra Date: Fri, 3 Jul 2015 14:17:40 -0500 Subject: [PATCH 27/28] Fix incorrect variable definitions --- routes/weather.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/routes/weather.js b/routes/weather.js index ed1b6b8..cf2e0e2 100644 --- a/routes/weather.js +++ b/routes/weather.js @@ -65,9 +65,9 @@ function getWeatherUndergroundData( location, weatherUndergroundKey, callback ) // Perform the HTTP request to retrieve the weather data httpRequest( url, function( data ) { try { - var data = JSON.parse( data ), + data = JSON.parse( data ); - tzOffset = getTimezone( data.current_observation.local_tz_offset, "minutes" ), + 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, @@ -117,8 +117,9 @@ function getWeatherData( location, callback ) { httpRequest( url, function( data ) { try { - var data = JSON.parse( data ), - weather = { + data = JSON.parse( data ); + + var weather = { iconCode: data.observation.icon_code, timezone: data.observation.obs_time_local, sunrise: parseDayTime( data.observation.sunrise ), From bc713d038034fde4152fdbefb9b12be8243cd10e Mon Sep 17 00:00:00 2001 From: Samer Albahra Date: Fri, 3 Jul 2015 16:05:03 -0500 Subject: [PATCH 28/28] Fix bug with Weather Underground queries --- routes/weather.js | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/routes/weather.js b/routes/weather.js index cf2e0e2..b295327 100644 --- a/routes/weather.js +++ b/routes/weather.js @@ -1,6 +1,6 @@ var http = require( "http" ), SunCalc = require( "suncalc" ), -// parseXML = require( "xml2js" ).parseString, + parseXML = require( "xml2js" ).parseString, Cache = require( "../models/Cache" ), // Define regex filters to match against location @@ -19,7 +19,6 @@ function getPWSCoordinates( location, weatherUndergroundKey, callback ) { 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 ] ); @@ -60,7 +59,7 @@ 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/" + location + ".json"; + "/yesterday/conditions/q/" + encodeURIComponent( location ) + ".json"; // Perform the HTTP request to retrieve the weather data httpRequest( url, function( data ) { @@ -487,18 +486,6 @@ function ipToInt( ip ) { return ( ( ( ( ( ( +ip[0] ) * 256 ) + ( +ip[1] ) ) * 256 ) + ( +ip[2] ) ) * 256 ) + ( +ip[3] ); } -function f2c( temp ) { - return ( temp - 32 ) * 5 / 9; -} - -function mm2in( x ) { - return x * 0.03937008; -} - -function ft2m( x ) { - return x * 0.3048; -} - // Resolves the Month / Day / Year of a Date object function toUSDate( date ) { return ( date.getMonth() + 1 ) + "/" + date.getDate() + "/" + date.getFullYear();