From 24b4a39fce61dd9bd8ab7757f3bbda0636adc8c9 Mon Sep 17 00:00:00 2001 From: James Barnett Date: Sat, 7 Apr 2018 18:49:48 +0100 Subject: Switch from http polling to data push via websockets --- app.js | 31 +++--- bin/www | 90 ----------------- package.json | 4 +- public/javascripts/dash.js | 134 ++++++++----------------- routes/energy-usage.js | 166 ------------------------------- routes/power-state.js | 23 ----- routes/ws.js | 25 +++++ services/data-broadcaster.js | 42 ++++++++ services/data-fetcher.js | 228 +++++++++++++++++++++++++++++++++++++++++++ views/index.hbs | 14 +-- 10 files changed, 357 insertions(+), 400 deletions(-) delete mode 100755 bin/www delete mode 100644 routes/energy-usage.js delete mode 100644 routes/power-state.js create mode 100644 routes/ws.js create mode 100644 services/data-broadcaster.js create mode 100644 services/data-fetcher.js diff --git a/app.js b/app.js index 5512f07..1d24419 100644 --- a/app.js +++ b/app.js @@ -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..13cd12e 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": { @@ -25,7 +25,7 @@ "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..72b0b5a 100644 --- a/public/javascripts/dash.js +++ b/public/javascripts/dash.js @@ -1,9 +1,4 @@ var dash = { - realtimeUsagePollRateMs: 1000, - powerStatePollRateMs: 60000, - historicalStatsPollRateMs: 300000, - pollingEnabled: true, - realtimeGauge: null, realtimeTrendChart: null, realtimeTrendLastSample: 0, @@ -17,7 +12,35 @@ var dash = { this.initDailyUsageChart(); this.initMonthlyUsageChart(); - this.startPolling(); + this.initWsConnection(); + }, + + initWsConnection: function() { + var ws = new WebSocket('ws://192.168.1.8:3000/ws'); + ws.onopen = function () { + console.log('Websocket connection established'); + ws.send('getCachedData'); + } + ws.onmessage = this.wsMessageHandler; + }, + + wsMessageHandler: function(messageEvent) { + let message = JSON.parse(messageEvent.data); + console.log(message); + + 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 +104,15 @@ var dash = { tooltips: { intersect: false }, + plugins: { + streaming: { + duration: 60000, + refresh: 1000, + delay: 1000, + frameRate: 30, + onRefresh: dash.realtimeTrendChartOnRefresh + } + } } }); }, @@ -156,22 +188,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 +203,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 +241,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 +277,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"); @@ -344,13 +295,4 @@ $(document).ready(function () { dash.init(); - $("#toggle-polling").change(function() { - if(this.checked) { - dash.startPolling(); - } - else { - dash.stopPolling(); - } - }); - }); 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/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..dc22df2 --- /dev/null +++ b/routes/ws.js @@ -0,0 +1,25 @@ +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 => { + + // Latest data is always pushed out to clients, but clients can also request cached data at any time. + if(msg === 'getCachedData') { + let cachedData = dataFetcher.getCachedData(); + + ws.send(dataBroadcaster.generatePayload('realtimeUsage', cachedData.realtimeUsage)); + ws.send(dataBroadcaster.generatePayload('dailyUsage', cachedData.dailyUsage)); + ws.send(dataBroadcaster.generatePayload('monthlyUsage', cachedData.monthlyUsage)); + ws.send(dataBroadcaster.generatePayload('powerState', cachedData.powerState)); + } + }); + +}); + +module.exports = router; diff --git a/services/data-broadcaster.js b/services/data-broadcaster.js new file mode 100644 index 0000000..a49f4a7 --- /dev/null +++ b/services/data-broadcaster.js @@ -0,0 +1,42 @@ +const app = require('../app'); + +function broadcastRealtimeUsageUpdate(data) { + broadcast(generatePayload('realtimeUsage', data)); +} + +function broadcastDailyUsageUpdate(data) { + broadcast(generatePayload('dailyUsage', data)); +} + +function broadcastMonthlyUsageUpdate(data) { + broadcast(generatePayload('monthlyUsage', data)); +} + +function broadcastPowerStateUpdate(data) { + broadcast(generatePayload('powersState', data)); +} + +function broadcast(payload) { + app.getWsClients().forEach(client => { + client.send(payload); + }) +} + +function generatePayload(dataType, data) { + + let payload = { + dataType: dataType, + 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..93f5020 --- /dev/null +++ b/services/data-fetcher.js @@ -0,0 +1,228 @@ +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(); +}, 5000) + +let cachedRealtimeUsageData = {}; +let cachedDailyUsageData = {}; +let cachedMonthlyUsageData = {}; +let cachedPowerState = {}; + +function fetchRealtimeUsage() { + + let deviceId = 1; // TODO + + console.log('connected clients', app.getWsClientCount()); + if(app.getWsClientCount() > 0) { + 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); + + cachedRealtimeUsageData = response; + + dataBroadcaster.broadcastRealtimeUsageUpdate(response); + + }); + } + + setTimeout(fetchRealtimeUsage, 1000); +} + +function fetchDailyUsage() { + + let deviceId = 1; + + // 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); + + let result = trimStatResults(combinedStats, totalDaysRequired); + + cachedDailyUsageData = result; + + dataBroadcaster.broadcastDailyUsageUpdate(result); + + }); + } + else { + let dayStats = fillMissingDays(currentPeriodStats, currentMoment); + + let result = trimStatResults(dayStats, totalDaysRequired); + cachedDailyUsageData = result; + + dataBroadcaster.broadcastDailyUsageUpdate(result); + } + + }); + + setTimeout(fetchDailyUsage, 300000); // 5 mins; +} + +function fetchMonthlyUsage() { + + let deviceId = 1; + + // 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); + + let result = trimStatResults(combinedStats, totalMonthsRequired); + + cachedMonthlyUsageData = result; + + dataBroadcaster.broadcastMonthlyUsageUpdate(result); + + }); + } + else { + let monthStats = fillMissingMonths(currentPeriodStats, currentMoment); + + let result = trimStatResults(monthStats, totalMonthsRequired); + + cachedMonthlyUsageData = result; + + dataBroadcaster.broadcastMonthlyUsageUpdate(result); + } + + }); + + setTimeout(fetchMonthlyUsage, 1800000); // 30 mins +} + +function fetchPowerState() { + + let deviceId = 1 + + deviceManager.getDevice(deviceId).getSysInfo().then(response => { + + let powerState = { + isOn: (response.relay_state === 1), + uptime: response.on_time + }; + + cachedPowerState = powerState; + dataBroadcaster.broadcastPowerStateUpdate(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); +} + +module.exports.getCachedData = function() { + return { + realtimeUsage: cachedRealtimeUsageData, + dailyUsage: cachedDailyUsageData, + monthlyUsage: cachedMonthlyUsageData, + powerState: cachedPowerState + } +} \ No newline at end of file diff --git a/views/index.hbs b/views/index.hbs index 7b2806d..ef2a514 100644 --- a/views/index.hbs +++ b/views/index.hbs @@ -33,16 +33,9 @@ @@ -243,7 +236,6 @@ - -- cgit v1.2.3 From 1301d90e93af799a9054f133847ebf3cbda15f9d Mon Sep 17 00:00:00 2001 From: James Barnett Date: Sat, 7 Apr 2018 21:43:17 +0100 Subject: Add support for switching between multiple plugs --- public/javascripts/dash.js | 51 +++++---- routes/index.js | 20 +++- routes/ws.js | 15 +-- services/data-broadcaster.js | 19 ++-- services/data-fetcher.js | 247 ++++++++++++++++++++++++++----------------- services/device-manager.js | 7 +- views/index.hbs | 9 +- 7 files changed, 224 insertions(+), 144 deletions(-) diff --git a/public/javascripts/dash.js b/public/javascripts/dash.js index 72b0b5a..7a8207c 100644 --- a/public/javascripts/dash.js +++ b/public/javascripts/dash.js @@ -1,4 +1,6 @@ var dash = { + deviceId: null, + realtimeGauge: null, realtimeTrendChart: null, realtimeTrendLastSample: 0, @@ -6,7 +8,11 @@ var dash = { dailyUsageChart: null, monthlyUsageChart: null, - init: function() { + init: function(deviceId) { + this.deviceId = deviceId; + + $('#' + deviceId).addClass('active'); + this.initRealtimeGauge(); this.initRealtimeTrendChart(); this.initDailyUsageChart(); @@ -16,29 +22,35 @@ var dash = { }, initWsConnection: function() { - var ws = new WebSocket('ws://192.168.1.8:3000/ws'); + var wsUri = 'ws://' + window.location.host + '/ws' + var ws = new WebSocket(wsUri); ws.onopen = function () { console.log('Websocket connection established'); - ws.send('getCachedData'); + ws.send(JSON.stringify( + { + requestType: 'getCachedData', + deviceId: dash.deviceId + } + )); } ws.onmessage = this.wsMessageHandler; }, wsMessageHandler: function(messageEvent) { let message = JSON.parse(messageEvent.data); - console.log(message); - - 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); + 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); + } } }, @@ -289,10 +301,3 @@ var dash = { }, }; - - -$(document).ready(function () { - - dash.init(); - -}); 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/ws.js b/routes/ws.js index dc22df2..08b0a18 100644 --- a/routes/ws.js +++ b/routes/ws.js @@ -9,14 +9,17 @@ 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(msg === 'getCachedData') { - let cachedData = dataFetcher.getCachedData(); + if(message.requestType === 'getCachedData') { + let deviceId = message.deviceId; + let cachedData = dataFetcher.getCachedData(deviceId); - ws.send(dataBroadcaster.generatePayload('realtimeUsage', cachedData.realtimeUsage)); - ws.send(dataBroadcaster.generatePayload('dailyUsage', cachedData.dailyUsage)); - ws.send(dataBroadcaster.generatePayload('monthlyUsage', cachedData.monthlyUsage)); - ws.send(dataBroadcaster.generatePayload('powerState', cachedData.powerState)); + 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)); } }); diff --git a/services/data-broadcaster.js b/services/data-broadcaster.js index a49f4a7..9e0c5d0 100644 --- a/services/data-broadcaster.js +++ b/services/data-broadcaster.js @@ -1,19 +1,19 @@ const app = require('../app'); -function broadcastRealtimeUsageUpdate(data) { - broadcast(generatePayload('realtimeUsage', data)); +function broadcastRealtimeUsageUpdate(deviceId, data) { + broadcast(generatePayload('realtimeUsage', deviceId, data)); } -function broadcastDailyUsageUpdate(data) { - broadcast(generatePayload('dailyUsage', data)); +function broadcastDailyUsageUpdate(deviceId, data) { + broadcast(generatePayload('dailyUsage', deviceId, data)); } -function broadcastMonthlyUsageUpdate(data) { - broadcast(generatePayload('monthlyUsage', data)); +function broadcastMonthlyUsageUpdate(deviceId, data) { + broadcast(generatePayload('monthlyUsage', deviceId, data)); } -function broadcastPowerStateUpdate(data) { - broadcast(generatePayload('powersState', data)); +function broadcastPowerStateUpdate(deviceId, data) { + broadcast(generatePayload('powersState', deviceId, data)); } function broadcast(payload) { @@ -22,10 +22,11 @@ function broadcast(payload) { }) } -function generatePayload(dataType, data) { +function generatePayload(dataType, deviceId, data) { let payload = { dataType: dataType, + deviceId: deviceId, data: data } diff --git a/services/data-fetcher.js b/services/data-fetcher.js index 93f5020..7ef3594 100644 --- a/services/data-fetcher.js +++ b/services/data-fetcher.js @@ -10,31 +10,34 @@ setTimeout(function() { fetchDailyUsage(); fetchMonthlyUsage(); fetchPowerState(); -}, 5000) +}, 2000) -let cachedRealtimeUsageData = {}; -let cachedDailyUsageData = {}; -let cachedMonthlyUsageData = {}; -let cachedPowerState = {}; +let cachedRealtimeUsageData = []; +let cachedDailyUsageData = []; +let cachedMonthlyUsageData = []; +let cachedPowerState = []; function fetchRealtimeUsage() { - let deviceId = 1; // TODO + if(app.getWsClientCount() > 0 || cachedRealtimeUsageData.length === 0) { - console.log('connected clients', app.getWsClientCount()); - if(app.getWsClientCount() > 0) { - deviceManager.getDevice(deviceId).emeter.getRealtime().then(response => { + deviceManager.getAllDevices().forEach(device => { - // 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); + let deviceId = device.deviceId; + device.emeter.getRealtime().then(response => { - cachedRealtimeUsageData = response; - - dataBroadcaster.broadcastRealtimeUsageUpdate(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); @@ -42,107 +45,127 @@ function fetchRealtimeUsage() { function fetchDailyUsage() { - let deviceId = 1; - - // 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()) { + 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); - // 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); - - let result = trimStatResults(combinedStats, totalDaysRequired); - - cachedDailyUsageData = result; - - dataBroadcaster.broadcastDailyUsageUpdate(result); - + let result = trimStatResults(dayStats, totalDaysRequired); + updateCache(cachedDailyUsageData, deviceId, result); + + dataBroadcaster.broadcastDailyUsageUpdate(deviceId, result); + } + }); - } - else { - let dayStats = fillMissingDays(currentPeriodStats, currentMoment); - - let result = trimStatResults(dayStats, totalDaysRequired); - cachedDailyUsageData = result; - dataBroadcaster.broadcastDailyUsageUpdate(result); - } + }); - }); + } setTimeout(fetchDailyUsage, 300000); // 5 mins; } function fetchMonthlyUsage() { - let deviceId = 1; - - // 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); - - let result = trimStatResults(combinedStats, totalMonthsRequired); - - cachedMonthlyUsageData = result; - - dataBroadcaster.broadcastMonthlyUsageUpdate(result); - + 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); + } + }); - } - else { - let monthStats = fillMissingMonths(currentPeriodStats, currentMoment); - - let result = trimStatResults(monthStats, totalMonthsRequired); - cachedMonthlyUsageData = result; - - dataBroadcaster.broadcastMonthlyUsageUpdate(result); - } + }); - }); + } setTimeout(fetchMonthlyUsage, 1800000); // 30 mins } function fetchPowerState() { - let deviceId = 1 + if(app.getWsClientCount() > 0 || cachedPowerState.length === 0) { - deviceManager.getDevice(deviceId).getSysInfo().then(response => { + deviceManager.getAllDevices().forEach(device => { - let powerState = { - isOn: (response.relay_state === 1), - uptime: response.on_time - }; + let deviceId = device.deviceId; + device.getSysInfo().then(response => { - cachedPowerState = powerState; - dataBroadcaster.broadcastPowerStateUpdate(powerState); - }); + let powerState = { + isOn: (response.relay_state === 1), + uptime: response.on_time + }; + + updateCache(cachedPowerState, deviceId, powerState); + dataBroadcaster.broadcastPowerStateUpdate(deviceId, powerState); + }); + }); + + } setTimeout(fetchPowerState, 60000); } @@ -218,11 +241,37 @@ function trimStatResults(stats, maxSamples) { return stats.splice(stats.length - maxSamples, stats.length); } -module.exports.getCachedData = function() { +function getCachedData(cache, deviceId) { + let cacheEntry = cache.find(d => d.deviceId == deviceId); + if(cacheEntry === undefined) { + return cacheEntry; + } + else { + return cacheEntry.data; + } +} + +function updateCache(cache, deviceId, data) { + + let cachedData = cache.find(d => d.deviceId == deviceId); + + if(cachedData === undefined) { + cache.push({ + deviceId: deviceId, + data: data + }); + } + else { + cachedData.data = data; + } +} + +module.exports.getCachedData = function(deviceId) { + return { - realtimeUsage: cachedRealtimeUsageData, - dailyUsage: cachedDailyUsageData, - monthlyUsage: cachedMonthlyUsageData, - powerState: cachedPowerState + realtimeUsage: getCachedData(cachedRealtimeUsageData, deviceId), + dailyUsage: getCachedData(cachedDailyUsageData, deviceId), + monthlyUsage: getCachedData(cachedMonthlyUsageData, deviceId), + powerState: getCachedData(cachedPowerState, deviceId) } } \ No newline at end of file diff --git a/services/device-manager.js b/services/device-manager.js index 3ed4a26..d1d81a1 100644 --- a/services/device-manager.js +++ b/services/device-manager.js @@ -6,11 +6,12 @@ var devices = []; client.startDiscovery({deviceTypes: ['plug']}).on('plug-new', plug => { console.log('Found device: ' + plug.alias + ' [' + plug.deviceId + ']'); devices.push(plug); -}) +}); module.exports.getDevice = function(deviceId) { - // TODO - get by id - return devices[0]; + + return devices.find(d => d.deviceId == deviceId); + } module.exports.getAllDevices = function() { diff --git a/views/index.hbs b/views/index.hbs index ef2a514..d0210a7 100644 --- a/views/index.hbs +++ b/views/index.hbs @@ -15,8 +15,8 @@

Devices