diff options
| -rw-r--r-- | README.md | 6 | ||||
| -rw-r--r-- | app.js | 31 | ||||
| -rwxr-xr-x | bin/www | 90 | ||||
| -rw-r--r-- | package.json | 7 | ||||
| -rw-r--r-- | public/javascripts/dash.js | 167 | ||||
| -rw-r--r-- | public/stylesheets/style.css | 63 | ||||
| -rw-r--r-- | routes/energy-usage.js | 166 | ||||
| -rw-r--r-- | routes/index.js | 20 | ||||
| -rw-r--r-- | routes/power-state.js | 23 | ||||
| -rw-r--r-- | routes/ws.js | 28 | ||||
| -rw-r--r-- | services/data-broadcaster.js | 43 | ||||
| -rw-r--r-- | services/data-fetcher.js | 277 | ||||
| -rw-r--r-- | services/device-manager.js | 12 | ||||
| -rw-r--r-- | views/index.hbs | 38 |
14 files changed, 552 insertions, 419 deletions
@@ -51,10 +51,10 @@ If you hit this issue you can try disabling the VirtualBox adapter in `Control P - [x] Show historical data - [x] Build dists - [x] Docker image -- [ ] Support switching between multiple plugs (currently only works for the fist plug discovered) +- [x] Support switching between multiple plugs +- [x] Switch to websockets - [ ] Rescan for devices on the fly - [ ] Add daily cost metrics -- [ ] Configurable poll rates -- [ ] Switch to websockets + @@ -1,14 +1,14 @@ -var createError = require('http-errors'); -var express = require('express'); -var path = require('path'); -var cookieParser = require('cookie-parser'); -var logger = require('morgan'); +const createError = require('http-errors'); +const express = require('express'); +const path = require('path'); +const cookieParser = require('cookie-parser'); +const logger = require('morgan'); -var indexRouter = require('./routes/index'); -var energyUsageRouter = require('./routes/energy-usage'); -var powerStateRouter = require('./routes/power-state'); +const app = express(); +const expressWs = require('express-ws')(app); -var app = express(); +const indexRouter = require('./routes/index'); +const wsRouter = require('./routes/ws'); // view engine setup app.set('views', path.join(__dirname, 'views')); @@ -21,8 +21,7 @@ app.use(cookieParser()); app.use(express.static(path.join(__dirname, 'public'))); app.use('/', indexRouter); -app.use('/energy-usage', energyUsageRouter); -app.use('/power-state', powerStateRouter); +app.use('/ws', wsRouter); // catch 404 and forward to error handler app.use(function(req, res, next) { @@ -40,4 +39,12 @@ app.use(function(err, req, res, next) { res.render('error'); }); -module.exports = app; +app.listen(process.env.PORT || '3000'); + +module.exports.getWsClientCount = function() { + return expressWs.getWss().clients.size; +}; + +module.exports.getWsClients = function() { + return expressWs.getWss().clients; +}; diff --git a/bin/www b/bin/www deleted file mode 100755 index 7356b2b..0000000 --- a/bin/www +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env node - -/** - * Module dependencies. - */ - -var app = require('../app'); -var debug = require('debug')('tplink-monitor:server'); -var http = require('http'); - -/** - * Get port from environment and store in Express. - */ - -var port = normalizePort(process.env.PORT || '3000'); -app.set('port', port); - -/** - * Create HTTP server. - */ - -var server = http.createServer(app); - -/** - * Listen on provided port, on all network interfaces. - */ - -server.listen(port); -server.on('error', onError); -server.on('listening', onListening); - -/** - * Normalize a port into a number, string, or false. - */ - -function normalizePort(val) { - var port = parseInt(val, 10); - - if (isNaN(port)) { - // named pipe - return val; - } - - if (port >= 0) { - // port number - return port; - } - - return false; -} - -/** - * Event listener for HTTP server "error" event. - */ - -function onError(error) { - if (error.syscall !== 'listen') { - throw error; - } - - var bind = typeof port === 'string' - ? 'Pipe ' + port - : 'Port ' + port; - - // handle specific listen errors with friendly messages - switch (error.code) { - case 'EACCES': - console.error(bind + ' requires elevated privileges'); - process.exit(1); - break; - case 'EADDRINUSE': - console.error(bind + ' is already in use'); - process.exit(1); - break; - default: - throw error; - } -} - -/** - * Event listener for HTTP server "listening" event. - */ - -function onListening() { - var addr = server.address(); - var bind = typeof addr === 'string' - ? 'pipe ' + addr - : 'port ' + addr.port; - debug('Listening on ' + bind); -} diff --git a/package.json b/package.json index 41b6569..b97fe2e 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "name": "James Barnett" }, "scripts": { - "start": "node ./bin/www", + "start": "node ./app.js", "dist": "pkg . --out-path ./dist" }, "dependencies": { @@ -19,13 +19,14 @@ "http-errors": "~1.6.2", "morgan": "~1.9.0", "tplink-smarthome-api": "0.22.0", - "moment": "2.22.0" + "moment": "2.22.0", + "express-ws": "3.0.0" }, "devDependencies": { "pkg": "4.3.1" }, "bin" : { - "tplink-monitor": "./bin/www" + "tplink-monitor": "./app.js" }, "pkg": { "scripts": "node_modules/hbs/lib/hbs.js", diff --git a/public/javascripts/dash.js b/public/javascripts/dash.js index 43524ad..821e9c1 100644 --- a/public/javascripts/dash.js +++ b/public/javascripts/dash.js @@ -1,8 +1,5 @@ var dash = { - realtimeUsagePollRateMs: 1000, - powerStatePollRateMs: 60000, - historicalStatsPollRateMs: 300000, - pollingEnabled: true, + deviceId: null, realtimeGauge: null, realtimeTrendChart: null, @@ -11,13 +8,59 @@ var dash = { dailyUsageChart: null, monthlyUsageChart: null, - init: function() { + init: function(deviceId) { + this.deviceId = deviceId; + + $('.' + deviceId).addClass('active'); + this.initRealtimeGauge(); this.initRealtimeTrendChart(); this.initDailyUsageChart(); this.initMonthlyUsageChart(); - this.startPolling(); + this.initWsConnection(); + }, + + initWsConnection: function() { + var wsUri = 'ws://' + window.location.host + '/ws' + var ws = new WebSocket(wsUri); + ws.onopen = function () { + console.log('Websocket connection established'); + $('#connection-error').hide(200); + ws.send(JSON.stringify( + { + requestType: 'getCachedData', + deviceId: dash.deviceId + } + )); + } + ws.onmessage = dash.wsMessageHandler; + + ws.onclose = function() { + // Usually caused by mobile devices going to sleep or the user minimising the browser app. + // The setTimeout will begin once the device wakes from sleep or the browser regains focus. + $('#connection-error').show(); + setTimeout(dash.initWsConnection, 2000); + } + }, + + wsMessageHandler: function(messageEvent) { + let message = JSON.parse(messageEvent.data); + if(message.deviceId === dash.deviceId) { + if(message.dataType === 'realtimeUsage') { + dash.refreshRealtimeDisplay(message.data); + } + else if(message.dataType === 'dailyUsage') { + dash.parseDailyUsageData(message.data); + } + else if(message.dataType === 'monthlyUsage') { + dash.parseMonthlyUsageData(message.data); + } + else if(message.dataType === 'powerState') { + dash.refreshPowerState(message.data); + } + } + }, initRealtimeGauge: function() { @@ -81,6 +124,15 @@ var dash = { tooltips: { intersect: false }, + plugins: { + streaming: { + duration: 60000, + refresh: 1000, + delay: 1000, + frameRate: 30, + onRefresh: dash.realtimeTrendChartOnRefresh + } + } } }); }, @@ -156,22 +208,6 @@ var dash = { }); }, - // TODO - should probably use websockets - pollUsage: function() { - if(this.pollingEnabled) { - $.ajax({ - url: "/energy-usage/1/realtime", - type: "GET", - success: function(data) { - dash.refreshRealtimeDisplay(data); - }, - dataType: "json", - complete: setTimeout(function() {dash.pollUsage()}, dash.realtimeUsagePollRateMs), - timeout: 2000 - }); - } - }, - refreshRealtimeDisplay: function(realtime) { var power = Math.round(realtime.power); @@ -187,41 +223,6 @@ var dash = { this.realtimeTrendLastSample = power; }, - startPolling: function() { - this.pollingEnabled = true; - this.realtimeTrendChart.options.plugins.streaming = { - duration: 60000, - refresh: 1000, - delay: 1000, - frameRate: 30, - onRefresh: dash.realtimeTrendChartOnRefresh - }; - - this.pollUsage(); - this.pollPowerStatus(); - this.pollDayStats(); - this.pollMonthStats(); - }, - - stopPolling: function() { - this.pollingEnabled = false; - this.realtimeTrendChart.options.plugins.streaming = false; - }, - - pollDayStats: function() { - if(this.pollingEnabled) { - $.ajax({ - url: "/energy-usage/1/day-stats", - type: "GET", - success: function(data) { - dash.parseDailyUsageData(data); - }, - dataType: "json", - complete: setTimeout(function() {dash.pollDayStats()}, dash.historicalStatsPollRateMs), - timeout: 4000 - }); - } - }, parseDailyUsageData: function(usageData) { @@ -260,21 +261,6 @@ var dash = { }, - pollMonthStats: function() { - if(this.pollingEnabled) { - $.ajax({ - url: "/energy-usage/1/month-stats", - type: "GET", - success: function(data) { - dash.parseMonthlyUsageData(data); - }, - dataType: "json", - complete: setTimeout(function() {dash.pollMonthStats()}, dash.historicalStatsPollRateMs), - timeout: 4000 - }); - } - }, - parseMonthlyUsageData: function(usageData) { // Clear previous data @@ -311,21 +297,6 @@ var dash = { $("#avg-month").text(avg.toFixed(2)); }, - pollPowerStatus: function() { - if(this.pollingEnabled) { - $.ajax({ - url: "/power-state/1", - type: "GET", - success: function(data) { - dash.refreshPowerState(data); - }, - dataType: "json", - complete: setTimeout(function() {dash.pollPowerStatus()}, dash.powerStatePollRateMs), - timeout: 2000 - }); - } - }, - refreshPowerState: function(powerState) { if(powerState.isOn) { $("#power-state").text("ON").attr("class", "label label-success"); @@ -334,23 +305,13 @@ var dash = { $("#power-state").text("OFF").attr("class", "label label-danger"); } - $("#uptime").text(moment.duration(powerState.uptime, "seconds").format("d[d] h[h] m[m]")); - }, - -}; - - -$(document).ready(function () { - - dash.init(); - - $("#toggle-polling").change(function() { - if(this.checked) { - dash.startPolling(); + if(powerState.uptime === 0) { + $("#uptime").text("-"); } else { - dash.stopPolling(); + $("#uptime").text(moment.duration(powerState.uptime, "seconds").format("d[d] h[h] m[m]", {largest: 2})); } - }); + + }, -}); +}; diff --git a/public/stylesheets/style.css b/public/stylesheets/style.css index 3f4c1ca..2057c4d 100644 --- a/public/stylesheets/style.css +++ b/public/stylesheets/style.css @@ -25,4 +25,65 @@ footer ul { color: #fff; background-color: #5cb85c; border-color: #4cae4c; -}
\ No newline at end of file +} + +.alert-danger { + color: #a94442; + background-color: #f2dede; + border-color: #ebccd1; +} + +.list-inline > .active { + font-weight: bold; +} + +.nav_title { + width: 170px; +} + +.nav-md .container.body .col-md-3.left_col { + width: 170px; +} + +.nav-md .container.body .right_col { + margin-left: 170px; +} + +@media (min-width: 992px) { + footer { + margin-left: 170px; + } +} + +@media (max-width: 991px) { + .nav-md .container.body .right_col { + padding-right: 0; + margin-left: 0; + } +} + +@media (max-width: 1100px) { + .col-md-2, .col-md-3, .col-md-4, .col-md-6, .col-md-8, .col-md-9, .col-xs-12, .col-lg-2 { + padding-left: 5px; + padding-right: 5px; + } +} + +@media (min-width: 1635px) { + .col-xl-2,{ + float: left; + } + .col-xl-2 { + width: 16.66666667%; + } +} + +.device-list-small{ + display: none; +} + +@media (max-width: 991px) { + .device-list-small{ + display: inline; + } +} diff --git a/routes/energy-usage.js b/routes/energy-usage.js deleted file mode 100644 index eb21eb9..0000000 --- a/routes/energy-usage.js +++ /dev/null @@ -1,166 +0,0 @@ -const express = require('express'); -const router = express.Router(); - -const deviceManager = require('../services/device-manager'); -const moment = require('moment'); - -router.get('/:deviceId/realtime', function(req, res, next) { - - let deviceId = req.params.deviceId; - - let realtimeUsage = {}; - // TODO - cache results with a short TTL so we don't hammer the plug if multiple clients are requesting data - deviceManager.getDevice(deviceId).emeter.getRealtime().then(response => { - - // Voltage seems to be reported as its peak to peak value, not RMS. - // Show the RMS value since thats what would you expect to see. - // i.e. 220v not 310v (in the U.K) - response.voltage = response.voltage / Math.sqrt(2); - - res.json(response); - }); - -}); - -router.get('/:deviceId/day-stats', function(req, res, next) { - - let deviceId = req.params.deviceId; - - // Get last x days - let totalDaysRequired = 30; // TODO currently only works for up to 2 months spans - let currentMoment = moment(); - let previousMoment = moment().subtract(totalDaysRequired, 'days'); - - // Month + 1 as the API months are index 1 based. - deviceManager.getDevice(deviceId).emeter.getDayStats(currentMoment.year(), currentMoment.month() +1).then(currentPeriodStats => { - - // Check if we also need the previous month to meet the required total number of samples - if(currentMoment.month() !== previousMoment.month()) { - - // Get previous month. This currently wont work if the previousMoment is more than 1 month before the currentMoment (see above) - deviceManager.getDevice(deviceId).emeter.getDayStats(previousMoment.year(), previousMoment.month() +1).then(previousPeriodStats => { - - let currentMonthStats = fillMissingDays(currentPeriodStats, currentMoment); - let previousMonthStats = fillMissingDays(previousPeriodStats, previousMoment); - let combinedStats = previousMonthStats.concat(currentMonthStats); - - res.json(trimStatResults(combinedStats, totalDaysRequired)); - - }); - } - else { - let dayStats = fillMissingDays(currentPeriodStats, currentMoment); - - res.json(trimStatResults(dayStats, totalDaysRequired)); - } - - }); - -}); - -router.get('/:deviceId/month-stats', function(req, res, next) { - let deviceId = req.params.deviceId; - - // Get last x months - let totalMonthsRequired = 12; // TODO currently only works for up to 14 month (2 year) spans - let currentMoment = moment(); - let previousMoment = moment().subtract(totalMonthsRequired, 'months'); - - deviceManager.getDevice(deviceId).emeter.getMonthStats(currentMoment.year()).then(currentPeriodStats => { - - // Check if we also need the previous year to meet the required total number of samples - if(currentMoment.month() + 1 < totalMonthsRequired) { - - // Get previous year (assuming the totalMonthsRequired limit described above). - deviceManager.getDevice(deviceId).emeter.getMonthStats(previousMoment.year()).then(previousPeriodStats => { - - let currentYearStats = fillMissingMonths(currentPeriodStats, currentMoment); - let previousYearStats = fillMissingMonths(previousPeriodStats, previousMoment); - let combinedStats = previousYearStats.concat(currentYearStats); - - res.json(trimStatResults(combinedStats, totalMonthsRequired)); - - }); - } - else { - let monthStats = fillMissingMonths(currentPeriodStats, currentMoment); - - res.json(trimStatResults(monthStats, totalMonthsRequired)); - } - - }); - -}); - -function fillMissingDays(sparseDayStats, statsMoment) { - let denseDayStats = []; - - let totalDays; - // If these stats are for the current month, fill up to the current day of the month - // Otherwise fill the whole month - if(moment().month() === statsMoment.month()) { - totalDays = statsMoment.date(); - } - else { - totalDays = statsMoment.daysInMonth(); - } - - Array.from({length: totalDays}, (x,i) => i + 1).forEach(d => { - - let stat = sparseDayStats.day_list.find(i => i.day === d); - - if(stat === undefined) { - denseDayStats.push({ - year: statsMoment.year(), - month: statsMoment.month() +1, - day: d, - energy: 0 - }) - } - else { - denseDayStats.push(stat); - } - - }); - - return denseDayStats; -} - -function fillMissingMonths(sparseMonthStats, statsMoment) { - let denseMonthStats = []; - - let maxMonths; - // Dont fill in months which exist in the future - if(statsMoment.year() === moment().year()) { - maxMonths = moment().month() + 1; // API months are 1 based - } - else { - maxMonths = 12; - } - - // Fill in any missing months up to the max amount - Array.from({length: maxMonths}, (x,i) => i + 1).forEach(m => { - - let stat = sparseMonthStats.month_list.find(i => i.month === m); - - if(stat === undefined) { - denseMonthStats.push({ - year: statsMoment.year(), - month: m, - energy: 0 - }) - } - else { - denseMonthStats.push(stat); - } - - }); - - return denseMonthStats; -} - -function trimStatResults(stats, maxSamples) { - return stats.splice(stats.length - maxSamples, stats.length); -} - -module.exports = router;
\ No newline at end of file diff --git a/routes/index.js b/routes/index.js index 4909ff8..12c62cd 100644 --- a/routes/index.js +++ b/routes/index.js @@ -5,11 +5,27 @@ const deviceManager = require('../services/device-manager'); router.get('/', function(req, res, next) { + let deviceId = sortDevices(deviceManager.getAllDevices())[0].deviceId; + + res.redirect('/' + deviceId); + +}); + +router.get('/:deviceId', function(req, res, next) { + + let deviceId = req.params.deviceId; + res.render('index', { - device: deviceManager.getDevice(), - devices: deviceManager.getAllDevices() + device: deviceManager.getDevice(deviceId), + devices: sortDevices(deviceManager.getAllDevices()) }); }); +function sortDevices(devices) { + return devices.slice().sort((a, b) => { + return a.alias.toLowerCase().localeCompare(b.alias.toLowerCase()) + }) +} + module.exports = router; diff --git a/routes/power-state.js b/routes/power-state.js deleted file mode 100644 index 6333863..0000000 --- a/routes/power-state.js +++ /dev/null @@ -1,23 +0,0 @@ -const express = require('express'); -const router = express.Router(); - -const deviceManager = require('../services/device-manager'); -const moment = require('moment'); - -router.get('/:deviceId', function(req, res, next) { - - let deviceId = req.params.deviceId; - - deviceManager.getDevice(deviceId).getSysInfo().then(response => { - - let powerState = { - isOn: (response.relay_state === 1), - uptime: response.on_time - }; - - res.json(powerState); - }); - -}); - -module.exports = router;
\ No newline at end of file diff --git a/routes/ws.js b/routes/ws.js new file mode 100644 index 0000000..08b0a18 --- /dev/null +++ b/routes/ws.js @@ -0,0 +1,28 @@ +const express = require('express'); +const router = express.Router(); + +const deviceManager = require('../services/device-manager'); +const dataFetcher = require('../services/data-fetcher'); +const dataBroadcaster = require('../services/data-broadcaster'); + +router.ws('/', function(ws, req) { + + ws.on('message', msg => { + + let message = JSON.parse(msg); + + // Latest data is always pushed out to clients, but clients can also request cached data at any time. + if(message.requestType === 'getCachedData') { + let deviceId = message.deviceId; + let cachedData = dataFetcher.getCachedData(deviceId); + + ws.send(dataBroadcaster.generatePayload('realtimeUsage', deviceId, cachedData.realtimeUsage)); + ws.send(dataBroadcaster.generatePayload('dailyUsage', deviceId, cachedData.dailyUsage)); + ws.send(dataBroadcaster.generatePayload('monthlyUsage', deviceId, cachedData.monthlyUsage)); + ws.send(dataBroadcaster.generatePayload('powerState', deviceId, cachedData.powerState)); + } + }); + +}); + +module.exports = router; diff --git a/services/data-broadcaster.js b/services/data-broadcaster.js new file mode 100644 index 0000000..9e0c5d0 --- /dev/null +++ b/services/data-broadcaster.js @@ -0,0 +1,43 @@ +const app = require('../app'); + +function broadcastRealtimeUsageUpdate(deviceId, data) { + broadcast(generatePayload('realtimeUsage', deviceId, data)); +} + +function broadcastDailyUsageUpdate(deviceId, data) { + broadcast(generatePayload('dailyUsage', deviceId, data)); +} + +function broadcastMonthlyUsageUpdate(deviceId, data) { + broadcast(generatePayload('monthlyUsage', deviceId, data)); +} + +function broadcastPowerStateUpdate(deviceId, data) { + broadcast(generatePayload('powersState', deviceId, data)); +} + +function broadcast(payload) { + app.getWsClients().forEach(client => { + client.send(payload); + }) +} + +function generatePayload(dataType, deviceId, data) { + + let payload = { + dataType: dataType, + deviceId: deviceId, + data: data + } + + return JSON.stringify(payload); +} + + +module.exports = { + broadcastRealtimeUsageUpdate: broadcastRealtimeUsageUpdate, + broadcastDailyUsageUpdate: broadcastDailyUsageUpdate, + broadcastMonthlyUsageUpdate: broadcastMonthlyUsageUpdate, + broadcastPowerStateUpdate: broadcastPowerStateUpdate, + generatePayload: generatePayload +}
\ No newline at end of file diff --git a/services/data-fetcher.js b/services/data-fetcher.js new file mode 100644 index 0000000..7ef3594 --- /dev/null +++ b/services/data-fetcher.js @@ -0,0 +1,277 @@ +const deviceManager = require('./device-manager'); +const dataBroadcaster = require('./data-broadcaster'); +const app = require('../app'); +const moment = require('moment'); + +// Get initial data after a short delay to allow the device manager to find devices +// TODO run once device manager notifies its complete instead +setTimeout(function() { + fetchRealtimeUsage(); + fetchDailyUsage(); + fetchMonthlyUsage(); + fetchPowerState(); +}, 2000) + +let cachedRealtimeUsageData = []; +let cachedDailyUsageData = []; +let cachedMonthlyUsageData = []; +let cachedPowerState = []; + +function fetchRealtimeUsage() { + + if(app.getWsClientCount() > 0 || cachedRealtimeUsageData.length === 0) { + + deviceManager.getAllDevices().forEach(device => { + + let deviceId = device.deviceId; + device.emeter.getRealtime().then(response => { + + // Voltage seems to be reported as its peak to peak value, not RMS. + // Show the RMS value since thats what would you expect to see. + // i.e. 220v not 310v (in the U.K) + response.voltage = response.voltage / Math.sqrt(2); + + updateCache(cachedRealtimeUsageData, deviceId, response); + + dataBroadcaster.broadcastRealtimeUsageUpdate(deviceId, response); + }); + + }); + + } + + setTimeout(fetchRealtimeUsage, 1000); +} + +function fetchDailyUsage() { + + if(app.getWsClientCount() > 0 || cachedDailyUsageData.length === 0) { + + // Get last x days + let totalDaysRequired = 30; // TODO currently only works for up to 2 months spans + let currentMoment = moment(); + let previousMoment = moment().subtract(totalDaysRequired, 'days'); + + // Month + 1 as the API months are index 1 based. + deviceManager.getAllDevices().forEach(device => { + + let deviceId = device.deviceId; + device.emeter.getDayStats(currentMoment.year(), currentMoment.month() +1).then(currentPeriodStats => { + + // Check if we also need the previous month to meet the required total number of samples + if(currentMoment.month() !== previousMoment.month()) { + + // Get previous month. This currently wont work if the previousMoment is more than 1 month before the currentMoment (see above) + device.emeter.getDayStats(previousMoment.year(), previousMoment.month() +1).then(previousPeriodStats => { + + let currentMonthStats = fillMissingDays(currentPeriodStats, currentMoment); + let previousMonthStats = fillMissingDays(previousPeriodStats, previousMoment); + let combinedStats = previousMonthStats.concat(currentMonthStats); + + let result = trimStatResults(combinedStats, totalDaysRequired); + + updateCache(cachedDailyUsageData, deviceId, result); + + dataBroadcaster.broadcastDailyUsageUpdate(deviceId, result); + + }); + } + else { + let dayStats = fillMissingDays(currentPeriodStats, currentMoment); + + let result = trimStatResults(dayStats, totalDaysRequired); + updateCache(cachedDailyUsageData, deviceId, result); + + dataBroadcaster.broadcastDailyUsageUpdate(deviceId, result); + } + + }); + + }); + + } + + setTimeout(fetchDailyUsage, 300000); // 5 mins; +} + +function fetchMonthlyUsage() { + + if(app.getWsClientCount() > 0 || cachedMonthlyUsageData.length === 0) { + + // Get last x months + let totalMonthsRequired = 12; // TODO currently only works for up to 14 month (2 year) spans + let currentMoment = moment(); + let previousMoment = moment().subtract(totalMonthsRequired, 'months'); + + deviceManager.getAllDevices().forEach(device => { + + let deviceId = device.deviceId; + device.emeter.getMonthStats(currentMoment.year()).then(currentPeriodStats => { + + // Check if we also need the previous year to meet the required total number of samples + if(currentMoment.month() + 1 < totalMonthsRequired) { + + // Get previous year (assuming the totalMonthsRequired limit described above). + device.emeter.getMonthStats(previousMoment.year()).then(previousPeriodStats => { + + let currentYearStats = fillMissingMonths(currentPeriodStats, currentMoment); + let previousYearStats = fillMissingMonths(previousPeriodStats, previousMoment); + let combinedStats = previousYearStats.concat(currentYearStats); + + let result = trimStatResults(combinedStats, totalMonthsRequired); + + updateCache(cachedMonthlyUsageData, deviceId, result); + + dataBroadcaster.broadcastMonthlyUsageUpdate(deviceId, result); + + }); + } + else { + let monthStats = fillMissingMonths(currentPeriodStats, currentMoment); + + let result = trimStatResults(monthStats, totalMonthsRequired); + + updateCache(cachedMonthlyUsageData, deviceId, result); + + dataBroadcaster.broadcastMonthlyUsageUpdate(deviceId, result); + } + + }); + + }); + + } + + setTimeout(fetchMonthlyUsage, 1800000); // 30 mins +} + +function fetchPowerState() { + + if(app.getWsClientCount() > 0 || cachedPowerState.length === 0) { + + deviceManager.getAllDevices().forEach(device => { + + let deviceId = device.deviceId; + device.getSysInfo().then(response => { + + let powerState = { + isOn: (response.relay_state === 1), + uptime: response.on_time + }; + + updateCache(cachedPowerState, deviceId, powerState); + + dataBroadcaster.broadcastPowerStateUpdate(deviceId, powerState); + }); + }); + + } + setTimeout(fetchPowerState, 60000); +} + + +function fillMissingDays(sparseDayStats, statsMoment) { + let denseDayStats = []; + + let totalDays; + // If these stats are for the current month, fill up to the current day of the month + // Otherwise fill the whole month + if(moment().month() === statsMoment.month()) { + totalDays = statsMoment.date(); + } + else { + totalDays = statsMoment.daysInMonth(); + } + + Array.from({length: totalDays}, (x,i) => i + 1).forEach(d => { + + let stat = sparseDayStats.day_list.find(i => i.day === d); + + if(stat === undefined) { + denseDayStats.push({ + year: statsMoment.year(), + month: statsMoment.month() +1, + day: d, + energy: 0 + }) + } + else { + denseDayStats.push(stat); + } + + }); + + return denseDayStats; +} + +function fillMissingMonths(sparseMonthStats, statsMoment) { + let denseMonthStats = []; + + let maxMonths; + // Dont fill in months which exist in the future + if(statsMoment.year() === moment().year()) { + maxMonths = moment().month() + 1; // API months are 1 based + } + else { + maxMonths = 12; + } + + // Fill in any missing months up to the max amount + Array.from({length: maxMonths}, (x,i) => i + 1).forEach(m => { + + let stat = sparseMonthStats.month_list.find(i => i.month === m); + + if(stat === undefined) { + denseMonthStats.push({ + year: statsMoment.year(), + month: m, + energy: 0 + }) + } + else { + denseMonthStats.push(stat); + } + + }); + + return denseMonthStats; +} + +function trimStatResults(stats, maxSamples) { + return stats.splice(stats.length - maxSamples, stats.length) |