aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJames Barnett <noreply@jamesbarnett.xyz>2018-04-07 18:49:48 +0100
committerJames Barnett <noreply@jamesbarnett.xyz>2018-04-07 18:49:48 +0100
commit24b4a39fce61dd9bd8ab7757f3bbda0636adc8c9 (patch)
treeb4ce61e16a66c2a43e40b8f78cc8431af7ef2d3a
parent954aff49707738e660e92e0418c31b1ec78a85a8 (diff)
downloadtplink-energy-monitor-24b4a39fce61dd9bd8ab7757f3bbda0636adc8c9.tar.xz
tplink-energy-monitor-24b4a39fce61dd9bd8ab7757f3bbda0636adc8c9.zip
Switch from http polling to data push via websockets
-rw-r--r--app.js31
-rwxr-xr-xbin/www90
-rw-r--r--package.json4
-rw-r--r--public/javascripts/dash.js134
-rw-r--r--routes/power-state.js23
-rw-r--r--routes/ws.js25
-rw-r--r--services/data-broadcaster.js42
-rw-r--r--services/data-fetcher.js (renamed from routes/energy-usage.js)116
-rw-r--r--views/index.hbs14
9 files changed, 218 insertions, 261 deletions
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/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/routes/energy-usage.js b/services/data-fetcher.js
index eb21eb9..93f5020 100644
--- a/routes/energy-usage.js
+++ b/services/data-fetcher.js
@@ -1,30 +1,48 @@
-const express = require('express');
-const router = express.Router();
-
-const deviceManager = require('../services/device-manager');
+const deviceManager = require('./device-manager');
+const dataBroadcaster = require('./data-broadcaster');
+const app = require('../app');
const moment = require('moment');
-router.get('/:deviceId/realtime', function(req, res, next) {
+// 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 deviceId = req.params.deviceId;
+let cachedRealtimeUsageData = {};
+let cachedDailyUsageData = {};
+let cachedMonthlyUsageData = {};
+let cachedPowerState = {};
- 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 => {
+function fetchRealtimeUsage() {
- // 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 = 1; // TODO
- res.json(response);
- });
+ 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);
-router.get('/:deviceId/day-stats', function(req, res, next) {
+ cachedRealtimeUsageData = response;
- let deviceId = req.params.deviceId;
+ 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
@@ -44,22 +62,31 @@ router.get('/:deviceId/day-stats', function(req, res, next) {
let previousMonthStats = fillMissingDays(previousPeriodStats, previousMoment);
let combinedStats = previousMonthStats.concat(currentMonthStats);
- res.json(trimStatResults(combinedStats, totalDaysRequired));
+ let result = trimStatResults(combinedStats, totalDaysRequired);
+
+ cachedDailyUsageData = result;
+
+ dataBroadcaster.broadcastDailyUsageUpdate(result);
});
}
else {
let dayStats = fillMissingDays(currentPeriodStats, currentMoment);
- res.json(trimStatResults(dayStats, totalDaysRequired));
+ let result = trimStatResults(dayStats, totalDaysRequired);
+ cachedDailyUsageData = result;
+
+ dataBroadcaster.broadcastDailyUsageUpdate(result);
}
});
-});
+ setTimeout(fetchDailyUsage, 300000); // 5 mins;
+}
+
+function fetchMonthlyUsage() {
-router.get('/:deviceId/month-stats', function(req, res, next) {
- let deviceId = req.params.deviceId;
+ let deviceId = 1;
// Get last x months
let totalMonthsRequired = 12; // TODO currently only works for up to 14 month (2 year) spans
@@ -78,19 +105,47 @@ router.get('/:deviceId/month-stats', function(req, res, next) {
let previousYearStats = fillMissingMonths(previousPeriodStats, previousMoment);
let combinedStats = previousYearStats.concat(currentYearStats);
- res.json(trimStatResults(combinedStats, totalMonthsRequired));
+ let result = trimStatResults(combinedStats, totalMonthsRequired);
+
+ cachedMonthlyUsageData = result;
+
+ dataBroadcaster.broadcastMonthlyUsageUpdate(result);
});
}
else {
let monthStats = fillMissingMonths(currentPeriodStats, currentMoment);
- res.json(trimStatResults(monthStats, totalMonthsRequired));
+ 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 = [];
@@ -163,4 +218,11 @@ function trimStatResults(stats, maxSamples) {
return stats.splice(stats.length - maxSamples, stats.length);
}
-module.exports = router; \ No newline at end of file
+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 @@
<div class="page-header">
<div class="row">
- <div class="col-sm-8">
- <h1>
- <i class="fa fa-plug"></i> {{device.alias}}
- </h1>
- </div>
- <div class="col-sm-4">
- <h2 class="pull-right">
- <input id="toggle-polling" class="form-control" type="checkbox" checked data-toggle="toggle" data-on="Auto refresh on" data-off="Auto refresh off" data-onstyle="success" data-offstyle="danger">
- </h2>
- </div>
+ <h1>
+ <i class="fa fa-plug"></i> {{device.alias}}
+ </h1>
</div>
</div>
@@ -243,7 +236,6 @@
<script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.0.0/js/bootstrap.min.js"></script>
<script src="https://bernii.github.io/gauge.js/dist/gauge.min.js"></script>
- <script src="https://gitcdn.github.io/bootstrap-toggle/2.2.2/js/bootstrap-toggle.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.21.0/moment.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment-duration-format/2.2.2/moment-duration-format.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.2/Chart.min.js"></script>