From 5baa4ef0b1121ee2189be163d5bfb0b986f697bf Mon Sep 17 00:00:00 2001 From: Max Lyth Date: Tue, 20 Apr 2021 23:03:18 +0100 Subject: [PATCH] Add support for PW protected Shellies --- app.js | 28 +++- bin/www | 4 +- package.json | 6 +- public/javascripts/index_inc.js | 273 ++++++++++++++------------------ routes/api.js | 53 ++++--- shelly-coap.js | 257 ++++++++++++++++++++---------- views/details.pug | 32 ++-- 7 files changed, 368 insertions(+), 285 deletions(-) diff --git a/app.js b/app.js index b7225ab..266f2b4 100644 --- a/app.js +++ b/app.js @@ -6,18 +6,17 @@ const createError = require('http-errors'); const cookieParser = require('cookie-parser'); const compression = require('compression'); const cors = require('cors'); -//const helmet = require('helmet'); const proxy = require('express-http-proxy'); const express = require('express'); const SSE = require('express-sse'); const morgan = require('morgan'); const shellycoap = require('./shelly-coap.js') +const authHeader = require('basic-auth-header'); const indexRouter = require('./routes/index'); const apiRouter = require('./routes/api'); const app = express(); -const sse = new SSE(); -[app.locals.shellylist, app.locals.shellycoaplist] = shellycoap(sse); +const sse = new SSE({}, { isSerialized: false, initialEvent: 'shellysLoad' }); // view engine setup app.set('views', path.join(__dirname, 'views')); @@ -31,7 +30,6 @@ app.use(express.json()); app.use(express.urlencoded({ extended: false })); app.use(cookieParser()); app.use(process.env.PREFIX, express.static(path.join(__dirname, 'public'))); -//app.use(helmet()); // Add handler for client to be able to request no compression. This is required for express-sse app.use(compression({ filter: function (req, res) { @@ -40,17 +38,31 @@ app.use(compression({ })); app.use(path.join(process.env.PREFIX, '/'), indexRouter); app.use(path.join(process.env.PREFIX, '/api'), apiRouter); -app.use(path.join(process.env.PREFIX, '/proxy/:addr'), proxy(function (req, res) { - const addr = req.params.addr; - return 'http://' + addr; +app.use(path.join(process.env.PREFIX, '/proxy/:devicekey'), proxy(function (req, res) { + const devicekey = req.params.devicekey; + const shelly = app.locals.shellylist[devicekey]; + return 'http://' + shelly.ip; }, { userResDecorator: function (proxyRes, proxyResData, userReq, userRes) { let data = proxyResData.toString('utf8'); data = data.replace(',url:"/"+url,', ',url:""+url,'); return data; + }, + proxyReqOptDecorator: function (proxyReqOpts, srcReq) { + const devicekey = proxyReqOpts.params.devicekey; + const shelly = app.locals.shellylist[devicekey]; + if (shelly.auth) { + console.warn('Need to add auth headers for this device'); + proxyReqOpts.headers['Authorization'] = authHeader(process.env.SHELLYUSER, process.env.SHELLYPW); + } + return proxyReqOpts; } -})); +} +)); app.get(path.join(process.env.PREFIX, '/events'), sse.init); + +[app.locals.shellylist, app.locals.shellycoaplist] = shellycoap(sse); + // catch 404 and forward to error handler app.use(function (req, res, next) { next(createError(404)); diff --git a/bin/www b/bin/www index a34b1b8..474411c 100755 --- a/bin/www +++ b/bin/www @@ -11,6 +11,8 @@ var host = process.env.HOST = normalizeHost(process.env.HOST || 'localhost'); process.env.TRUSTPROXY = process.env.TRUSTPROXY || 'loopback'; process.env.UIMODE = process.env.UIMODE || 'light'; process.env.PREFIX = normalizePrefix(process.env.PREFIX || ''); +process.env.SHELLYUSER = process.env.SHELLYUSER || ''; +process.env.SHELLYPW = process.env.SHELLYPW || ''; /** * Module dependencies. @@ -21,8 +23,6 @@ var debug = require('debug')('shelly-admin:server'); var http = require('http'); - - /** * Create HTTP server. */ diff --git a/package.json b/package.json index 4042fa7..ff65293 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "shelly-admin", "description": "Shelly Admin", - "version": "0.2.3", + "version": "0.3.0", "author": "Max Lyth", "private": true, "main": "main.js", @@ -15,6 +15,8 @@ }, "dependencies": { "assert": "^2.0.0", + "basic-auth-header": "^1.0.1", + "coiot-coap": "^1.0.0", "compression": "^1.7.4", "cookie-parser": "~1.4.4", "cors": "^2.8.5", @@ -90,4 +92,4 @@ ] } } -} +} \ No newline at end of file diff --git a/public/javascripts/index_inc.js b/public/javascripts/index_inc.js index 0a3cc08..a331f59 100644 --- a/public/javascripts/index_inc.js +++ b/public/javascripts/index_inc.js @@ -6,9 +6,31 @@ let shellyTableObj = undefined; const shellylist = [{}]; const detailCardsState = JSON.parse(localStorage.getItem('ShellyAdmin_DetailCardsState_v1')) || { 'detail-general': true }; +const ssesource = new EventSource('events'); const deviceKey = (type, id) => `${type}-${id}`; +/** + * Deep diff between two object, using lodash + * @param {Object} object Object compared + * @param {Object} base Object to compare with + * @return {Object} Return a new object who represent the diff + */ +function difference(object, base) { + return _.transform(object, (result, value, key) => { + if (!_.isEqual(value, base[key])) { + result[key] = _.isObject(value) && _.isObject(base[key]) ? difference(value, base[key]) : value; + } + }); +} + +// eslint-disable-next-line no-unused-vars +function handleShellyDirect(deviceKey) { + console.info("Display shelly iFrame for " + deviceKey); + $('#shellyAccessModal iframe').attr('src', "proxy/" + deviceKey + "/"); + $('#shellyAccessModal').modal('show'); +} + $(document).ready(function () { $.fn.dataTable.ext.errMode = 'none'; shellyTableObj = $('#shellies').DataTable({ @@ -48,7 +70,23 @@ $(document).ready(function () { "responsivePriority": 1, "className": "text-nowrap text-truncate", "render": function (data, _type, _row) { - return `
 ${data}
`; + let result = data; + if (_type == 'display') { + let auth = _row['auth'] || false; + if (auth) { + console.info(`Device need authentication`); + } + let locked = _row['locked'] || false; + result = ''; + result += auth ? `` : ``; + result += ' '; + if (_row.locked === false) { + result += `${data} `; + } else { + result += `${data} `; + } + } + return result; }, "type": "ip-address" }, @@ -109,7 +147,7 @@ $(document).ready(function () { "data": "lastSeenHuman", "name": "lastseen-human", "title": "LastSeen", - "width": 100, + "width": 20, "className": "text-nowrap text-truncate", "responsivePriority": 8040, "type": "natural-time-delta" @@ -132,8 +170,7 @@ $(document).ready(function () { "render": function (data, _type, _row, _meta) { let result = ''; data ??= {}; - data.current ??= "/-"; - let currentName = data.current.split('/')[1].split('-')[0]; + let currentName = (/([^/]*\/)([^-]*)(-.*)/g.exec(data?.current + "/-"))[2]; result = currentName; if (_type == 'display') { result = '' + currentName + ''; @@ -142,11 +179,11 @@ $(document).ready(function () { if (currentCell) currentContent = currentCell.node(); if (currentContent) currentContent = $(currentContent); if (currentContent) { - if ($('span[updating]', currentContent).length > 0) { + if ($('span[upgrading]', currentContent).length > 0) { result = currentContent.html(); return result; } - if ($('span[updated]', currentContent).length > 0) { + if ($('span[upgraded]', currentContent).length > 0) { result = currentContent.html(); return result; } @@ -154,7 +191,7 @@ $(document).ready(function () { if (data.hasupdate || false) { data.new ??= ''; const devicekey = _row['devicekey']; - result = `${currentName}  `; + result = `${currentName}  `; result += ``; } } @@ -295,27 +332,76 @@ $(document).ready(function () { } ) - // Empty the Datable of all rows and load a fresh set of devices from server - $.ajax({ - url: "api/shellys" - }).done(function (data) { + ssesource.addEventListener('open', message => { + console.log(`SSE: open`); + }, false); + ssesource.addEventListener('shellysLoad', message => { + console.log('SSE: shellysLoad'); + const shellys = JSON.parse(message.data)[0]; shellyTableObj.clear(); - data.forEach(element => { - shellyTableObj.row.add(element) - }); - shellyTableObj.draw(); - }); - + for (const [, shelly] of Object.entries(shellys)) { + shellyTableObj.row.add(shelly); + } + shellyTableObj.columns.adjust().draw(); + }, false); + ssesource.addEventListener('shellyUpdate', message => { + const shelly = JSON.parse(message.data); + const devKey = deviceKey(shelly.type, shelly.id); + let existingRow = shellyTableObj.row(function (_idx, data, _node) { return data.devicekey === devKey ? true : false; }); + if (existingRow.length > 0) { + const existingObj = existingRow.data(); + const differences = difference(existingObj, shelly); + if (differences.length === 0) { + console.log(`SSE: shellyUpdate no changes for ${devKey}`); + } else { + let noVisibleCols = true; + for (let col in differences) { + if (existingRow.columns(col + ':name')[0].length > 0) { + // eslint-disable-next-line no-unused-vars + noVisibleCols = false; + break; + } + } + if (noVisibleCols == false) { + //console.log(`SSE: shellyUpdate including visible columns for ${devKey}`); + existingRow.data(shelly).draw(); + } else { + //console.log(`SSE: shellyUpdate only for invisible columns for ${devKey}`); + existingRow.data(shelly); + } + } + } else { + console.log(`SSE: shellyUpdate that was new for ${devKey}`); + shellyTableObj.row.add(shelly).draw(); + } + }, false); + ssesource.addEventListener('shellyCreate', message => { + console.log('SSE: shellyCreate'); + const shelly = JSON.parse(message.data); + const devKey = deviceKey(shelly.type, shelly.id); + let existingRow = shellyTableObj.row(function (_idx, data, _node) { return data.devicekey === devKey ? true : false; }); + if (existingRow.length > 0) { + console.log('Got Create that was a merge'); + existingRow.data(shelly).draw(); + } else { + console.log(`Got Create that was new for row ${devKey}`); + shellyTableObj.row.add(shelly).draw(); + } + }, false); + ssesource.addEventListener('shellyRemove', message => { + console.log('SSE: shellyRemove'); + const shelly = JSON.parse(message.data); + let existingRow = shellyTableObj.row(function (_idx, data, _node) { return data.devicekey === shelly.devicekey ? true : false; }); + if (existingRow) { + existingRow.remove().draw(); + } + }, false); + ssesource.addEventListener('error', message => { + console.log('SSE: error'); + }, false); }); -// eslint-disable-next-line no-unused-vars -function handleShellyDirect(shellyIP) { - console.info("Display shelly iFrame for " + shellyIP); - $('#shellyAccessModal iframe').attr('src', "proxy/" + shellyIP + "/"); - $('#shellyAccessModal').modal('show'); -} - -function pollUpdateTimer() { +function pollUpgradeTimer() { var element = this.element; var devicekey = this.devicekey; var tableCell = this.tableCell; @@ -326,9 +412,9 @@ function pollUpdateTimer() { .done(function (data) { console.info(`Got update status of ${data} for ${devicekey}`); if (data == 'idle') { - tableCell.html(` Success!`); + tableCell.html(` Success!`); setTimeout(function () { - $('[updated]', tableCell).removeAttr('updated'); + $('[upgraded]', tableCell).removeAttr('upgraded'); $.ajax({ url: "api/shelly/" + devicekey }).done(function (data) { @@ -343,151 +429,36 @@ function pollUpdateTimer() { } if (data != curStatus) { curStatus = data; - tableCell.html(` ${data}`); + tableCell.html(` ${data}`); } - setTimeout(pollUpdateTimer.bind({ element, devicekey, tableCell, originalContent, startTime, curStatus }), 1500); + setTimeout(pollUpgradeTimer.bind({ element, devicekey, tableCell, originalContent, startTime, curStatus }), 1500); }) .fail(function (data) { console.warning(`Updatestatus failed with ${data} for ${devicekey}`); if ((Date.now() - startTime) > 60000) { tableCell.html(originalContent); } else { - setTimeout(pollUpdateTimer.bind({ element, devicekey, tableCell, originalContent, startTime, curStatus }), 1500); + setTimeout(pollUpgradeTimer.bind({ element, devicekey, tableCell, originalContent, startTime, curStatus }), 1500); } }); } // eslint-disable-next-line no-unused-vars -function handleShellyUpdate(element, devicekey) { - console.info("Start firmware update for " + devicekey); +function handleShellyUpgrade(element, devicekey) { + console.info("Start firmware upgrade for " + devicekey); let tableCell = $(element).parent(); let originalContent = tableCell.html(); let startTime = Date.now(); let curStatus = 'Requesting…'; $('[data-toggle="tooltip"]', tableCell).tooltip('hide'); - tableCell.html(`${curStatus}`); + tableCell.html(`${curStatus}`); $.ajax({ url: "api/update/" + devicekey }) .done(function (data) { - console.info("Requested firmware update for " + devicekey); - setTimeout(pollUpdateTimer.bind({ element, devicekey, tableCell, originalContent, startTime, curStatus }), 1500); + console.info("Requested firmware upgrade for " + devicekey); + setTimeout(pollUpgradeTimer.bind({ element, devicekey, tableCell, originalContent, startTime, curStatus }), 1500); }) .fail(function (data) { tableCell.html(originalContent); - console.error("Failed to request firmware update for " + devicekey); + console.error("Failed to request firmware upgrade for " + devicekey); }); } - -/** - * Deep diff between two object, using lodash - * @param {Object} object Object compared - * @param {Object} base Object to compare with - * @return {Object} Return a new object who represent the diff - */ -function difference(object, base) { - return _.transform(object, (result, value, key) => { - if (!_.isEqual(value, base[key])) { - result[key] = _.isObject(value) && _.isObject(base[key]) ? difference(value, base[key]) : value; - } - }); -} - -const ssesource = new EventSource('events'); -ssesource.addEventListener('shellyRefresh', message => { - console.log('Got Refresh'); - const shelly = JSON.parse(message.data); - const devKey = deviceKey(shelly.type, shelly.id); - if (_.isNil(shellyTableObj)) return; - let existingRow = shellyTableObj.rows(function (_idx, data, _node) { return data.devicekey == devKey ? true : false; }); - if (Array.isArray(existingRow)) existingRow = existingRow[0]; - const existingObj = existingRow.data(); - const differences = difference(existingObj, shelly); - if (differences.length === 0) { - console.log("no differnces in refresh event"); - } else { - // _.find(shellylist, function (o) { return o.devicekey === devKey; }); - let noVisibleCols = true; - for (let col in differences) { - if (existingRow.columns(col + ':name')[0].length > 0) { - // eslint-disable-next-line no-unused-vars - noVisibleCols = false; - break; - } - } - if (existingRow) { - existingRow.data(shelly).draw(); - } else { - let newRow = shellyTableObj.row.add(shelly); - newRow = newRow.rows(function (_idx, data, _node) { return data.devicekey === devKey ? true : false; }); - if (Array.isArray(newRow)) newRow = newRow[0]; - console.log(`Got Refresh that was new for row ${newRow.id}`); - $.ajax({ - url: "api/shelly/" + devKey - }).done(function (data) { - newRow.data(data).draw(); - }) - } - } -}, false); -ssesource.addEventListener('shellyUpdate', message => { - const shelly = JSON.parse(message.data); - const devKey = deviceKey(shelly.type, shelly.id); - let existingRow = shellyTableObj.rows(function (_idx, data, _node) { return data.devicekey === devKey ? true : false; }); - if (Array.isArray(existingRow)) existingRow = existingRow[0]; - const existingObj = existingRow.data(); - - // _.find(shellylist, function (o) { return o.devicekey === devKey; }); - if (existingRow.columns(shelly.prop + ':name')) { - if (existingObj) { - console.log('Got Update that was a merge'); - _.merge(existingObj, shelly); - if (shelly.prop) { - existingObj[shelly.prop] = shelly.newValue; - } - } else { - let newRow = shellyTableObj.row.add(shelly); - newRow = newRow.rows(function (_idx, data, _node) { return data.devicekey === devKey ? true : false; }); - if (Array.isArray(newRow)) newRow = newRow[0]; - console.log(`Got Update that was new for row ${newRow.id}`); - $.ajax({ - url: "api/shelly/" + devKey - }).done(function (data) { - newRow.data(data).draw(); - }) - } - //document.querySelector('#events').innerHTML = message.data; - } -}, false); -ssesource.addEventListener('shellyCreate', message => { - const shelly = JSON.parse(message.data); - const devKey = deviceKey(shelly.type, shelly.id); - let existingRow = shellyTableObj.rows(function (_idx, data, _node) { return data.devicekey === devKey ? true : false; }); - if (Array.isArray(existingRow)) existingRow = existingRow[0]; - if (existingRow) { - console.log('Got Create that was a merge'); - const existingObj = existingRow.data(); - _.merge(existingObj, shelly); - existingRow.draw(); - } else { - let newRow = shellyTableObj.row.add(shelly); - newRow = newRow.rows(function (_idx, data, _node) { return data.devicekey === devKey ? true : false; }); - if (Array.isArray(newRow)) newRow = newRow[0]; - console.log(`Got Create that was new for row ${newRow.id}`); - $.ajax({ - url: "api/shelly/" + devKey - }).done(function (data) { - newRow.data(data).draw(); - }) - } -}, false); -ssesource.addEventListener('shellyRemove', message => { - console.log('Got Remove'); - const shelly = JSON.parse(message.data); - let existingRow = shellyTableObj.rows(function (_idx, data, _node) { return data.devicekey === shelly.devicekey ? true : false; }); - if (Array.isArray(existingRow)) existingRow = existingRow[0]; - if (existingRow) { - existingRow.remove(); - } -}, false); -ssesource.addEventListener('error', message => { - console.log('Got SSE error'); -}, false); \ No newline at end of file diff --git a/routes/api.js b/routes/api.js index a667371..9dbbeaf 100644 --- a/routes/api.js +++ b/routes/api.js @@ -51,35 +51,49 @@ api.get('/shelly/:devicekey', async function (req, res) { }); api.get('/details/:devicekey', function (req, res) { - function getShellyDetail(shelly, key) { - let result = _.get(shelly, key, null); + function getShellyDetail(device, key) { + let result = _.get(device, key, null); switch (key) { - case 'status.ram_free': - case 'status.ram_total': - case 'status.fs_size': - case 'status.fs_free': + case 'statusCache?.ram_free': + case 'statusCache?.ram_total': + case 'statusCache?.fs_size': + case 'statusCache?.fs_free': result = prettyBytes(result); break; - case 'settings.mqtt.keep_alive': - case 'settings.mqtt.update_period': - case 'settings.mqtt.reconnect_timeout_min': - case 'settings.mqtt.reconnect_timeout_max': - case 'status.uptime': + case 'settingsCache?.mqtt.keep_alive': + case 'settingsCache?.mqtt.update_period': + case 'settingsCache?.mqtt.reconnect_timeout_min': + case 'settingsCache?.mqtt.reconnect_timeout_max': + case 'statusCache.uptime': result = humanizeDuration(result * 1000, { largest: 2 }); break; case 'lastSeen': - result = humanizeDuration((Date.now() - Date.parse(result)), { maxDecimalPoints: 1, largest: 2 }); + result = humanizeDuration((Date.now() - result), { maxDecimalPoints: 1, largest: 2 }); break; - case 'settings.device.mac': + case 'settingsCache?.device.mac': + case 'shelly?.mac': result = result .match(/.{1,2}/g) // ["4a", "89", "26", "c4", "45", "78"] .join(':') break; default: - if (result === true) result = 'True'; - if (result === false) result = 'False'; - result = (result === null) ? 'n/a' : encode(result); - if (result == '') result = ' '; + switch (result) { + case true: + result = ' True'; + break; + case false: + result = ' False'; + break; + case '': + result = ' '; + break; + case null: + result = 'n/a'; + break; + default: + result = encode(result); + break; + } break; } return result; @@ -88,13 +102,14 @@ api.get('/details/:devicekey', function (req, res) { try { req.app.locals._ = _; req.app.locals.getShellyDetail = getShellyDetail; + const shellycoaplist = req.app.locals.shellycoaplist; const shellylist = req.app.locals.shellylist; - //const shelly = shellylist.find(c => c.devicekey == req.params.devicekey); const shelly = shellylist[req.params.devicekey]; + const device = shellycoaplist[req.params.devicekey]; assert(_.isObject(shelly)); const imagePath = path.join(__dirname, '..', 'public', 'images', 'shelly-devices', shelly.type + '.png'); const imageName = (fs.existsSync(imagePath)) ? shelly.type + '.png' : 'Unknown.png'; - res.render('details', { 'title': 'Shelly Details', 'shelly': shelly, 'imageName': imageName }); + res.render('details', { 'title': 'Shelly Details', 'device': device, 'imageName': imageName }); } catch (err) { const response = `Get details failed with error ${err.message}... Can not find Shelly matching key:${req.params.devicekey}`; console.error(response); diff --git a/shelly-coap.js b/shelly-coap.js index 90a80c9..6b09ad0 100644 --- a/shelly-coap.js +++ b/shelly-coap.js @@ -8,101 +8,110 @@ const _ = require('lodash'); const TimeAgo = require('javascript-time-ago'); const en = require('javascript-time-ago/locale/en'); const shellies = require('shellies') +const { CoIoTServer, } = require('coiot-coap'); const deviceKey = (type, id) => `${type}-${id}`; var shellycoaplist = {}; var shellylist = {}; let sse; - -// Setup some Express globals we will need in templates and view handlers -/* -Consider switching to middleware locals rather than app globals. eg: -app.use(function(req, res, next){ - res.locals._ = require('underscore'); - next(); -}); -*/ +// Listen to ALL messages in your network +const coIoTserver = new CoIoTServer(); TimeAgo.addDefaultLocale(en); const timeAgo = new TimeAgo('en-GB') +function shellyExtractBasic(device) { + let shellyObj = new Object; + shellyObj.devicekey = deviceKey(device.type, device.id); + shellyObj.id = device.id; + shellyObj.type = device.type; + shellyObj.ip = device.host; + shellyObj.auth = device.shelly?.auth ?? false; + shellyObj.locked = device.shelly?.locked ?? false; + return shellyObj; +} function shellyExtract(device) { - return { - devicekey: deviceKey(device.type, device.id), - id: device.id, - type: device.type, - devicename: device.name, - ip: device.host, - fw: {}, - online: device.online, - lastSeen: device.lastSeen, - lastSeenHuman: timeAgo.format(Date.parse(device.lastSeen)), - modelName: device.modelName + let shellyObj = shellyExtractBasic(device); + if (device.settings?.name) shellyObj.givenname = device.settings?.name + if (_.isObject(device.status?.update)) { + shellyObj.fw = new Object; + shellyObj.fw.current = device.status.update.old_version; + shellyObj.fw.new = device.status.update.new_version; + shellyObj.fw.hasupdate = device.status.update.has_update; + } else if (_.isObject(device.fw)) { + shellyObj.fw = device.fw; + } else if (_.isObject(device.shelly)) { + shellyObj.fw = new Object; + shellyObj.fw.current = device.shelly.fw; } + if (_.isDate(device.lastSeen)) shellyObj.lastSeen = device.lastSeen; + if (_.isDate(shellyObj.lastSeen)) shellyObj.lastSeenHuman = timeAgo.format(shellyObj.lastSeen); + if (device.modelName) shellyObj.modelName = device.modelName; + return shellyObj; } -function processStatus(device, newStatus) { +function processStatus(device, status) { const devicekey = deviceKey(device.type, device.id); //console.log('Received new polled status for ', devicekey); - const existingCoAPDevice = shellycoaplist[devicekey]; if (_.isNil(existingCoAPDevice)) { console.error('SHOULD NOT BE HERE! Device ', devicekey, ' did not exist in CoAP list when updating status'); - return; + return false; } const existingDevice = shellylist[devicekey]; if (_.isNil(existingDevice)) { console.error('SHOULD NOT BE HERE! Device ', devicekey, ' did not exist in shellylist when updating status'); - return; + return false; } + shellycoaplist[devicekey].statusCache = status; let newExtraction = shellyExtract(device); - newExtraction.status = newStatus; + let newStatus = status; newExtraction.mqtt_connected = newStatus.mqtt.connected; newExtraction.rssi = newStatus.wifi_sta.rssi; newExtraction.fw['hasupdate'] = newStatus.update.has_update; newExtraction.fw['new'] = newStatus.update.new_version; - newExtraction = _.merge(existingDevice, newExtraction); + shellylist[devicekey] = _.merge(shellylist[devicekey], newExtraction); const differences = _.difference(shellylist[devicekey], newExtraction); if (differences.length === 0) { //console.log('Found ZERO differences when updating status. Ignoring SSE event'); - return; + return false; } - console.log('Found DIFFERENCES', differences, 'when updating status so sending SSE event'); - shellylist[devicekey] = newExtraction; - sse.send(newExtraction, 'shellyRefresh'); + console.warn('Found DIFFERENCES', differences, 'when updating status so sending SSE event'); + sse.send(shellylist[devicekey], 'shellyUpdate'); + return true; } -function pollStatus(device) { +async function pollStatus(device) { + let statusChanged = false; try { - device.getStatus?.().then((newStatus) => { - try { - processStatus(device, newStatus); - } catch (err) { console.error('*********ERROR*********: ', err.message, ' while processStatus'); } - }); + //console.info(`About to request status for ${device.id}`); + let newStatus = await device.getStatus(); + statusChanged = processStatus(device, newStatus); } catch (err) { console.error('*********ERROR*********: ', err.message, ' uncaught in pollStatus'); } - return Math.round(Math.random() * (20000)) + 50000; + let interval = Math.round(Math.random() * (10000)) + 25000; + if (!statusChanged) interval = interval * 2; + return interval; } -function pollStatusTimer() { - const nextPollInterval = pollStatus(this.device); +async function pollStatusTimer() { + const nextPollInterval = await pollStatus(this.device); setTimeout(pollStatusTimer.bind({ device: this.device }), nextPollInterval); } - -function processSettings(device, newSettings) { +function processSettings(device, settings) { const devicekey = deviceKey(device.type, device.id); //console.log('Received new polled settings for ', devicekey); const existingCoAPDevice = shellycoaplist[devicekey]; if (_.isNil(existingCoAPDevice)) { console.error('SHOULD NOT BE HERE! Device ', devicekey, ' did not exist in CoAP list when updating settings'); - return; + return false; } - // var existingDevice = _.find(shellylist, function (o) { return o.devicekey === devicekey; }); const existingDevice = shellylist[devicekey]; if (_.isNil(existingDevice)) { console.error('SHOULD NOT BE HERE! Device ', devicekey, ' did not exist in shellylist when updating settings'); - return; + return false; } + shellycoaplist[devicekey].settingsCache = settings; let newExtraction = shellyExtract(device); - newExtraction.settings = newSettings; + let newSettings = settings; newExtraction.fw['current'] = newSettings.fw; newExtraction.givenname = newSettings.name; newExtraction.ssid = newSettings.wifi_sta.ssid; @@ -111,76 +120,150 @@ function processSettings(device, newSettings) { const differences = _.difference(shellylist[devicekey], newExtraction); if (differences.length === 0) { //console.log('Found ZERO differences when updating settings. Ignoring SSE event'); - return; + return false; } - console.log('Found DIFFERENCES', differences, 'when updating settings so sendding SSE event'); + console.warn('Found DIFFERENCES', differences, 'when updating settings so sendding SSE event'); shellylist[devicekey] = newExtraction; - sse.send(newExtraction, 'shellyRefresh'); + sse.send(newExtraction, 'shellyUpdate'); + return true; } -function pollSettings(device) { +async function pollSettings(device) { + let settingsChanged = false; try { - device.getSettings?.().then((newSettings) => { - try { - processSettings(device, newSettings); - } catch (err) { console.error('*********ERROR*********: ', err.message, ' while processSettings'); } - }); + //console.info(`About to request settings for ${device.id}`); + let newSettings = await device.getSettings(); + settingsChanged = processSettings(device, newSettings); } catch (err) { console.error('*********ERROR*********: ', err.message, ' uncaught in pollSettings'); } - return Math.round(Math.random() * (20000)) + 50000; + let interval = Math.round(Math.random() * (10000)) + 25000; + if (!settingsChanged) interval = interval * 2; + return interval; } -function pollSettingsTimer() { - const nextPollInterval = pollSettings(this.device); +async function pollSettingsTimer() { + const nextPollInterval = await pollSettings(this.device); setTimeout(pollSettingsTimer.bind({ device: this.device }), nextPollInterval); } -shellies.on('discover', device => { - // a new device has been discovered - console.log('Discovered device with ID', device.id, 'and type', device.type); + +async function checkShellyAuth(device) { const devicekey = deviceKey(device.type, device.id); + if (device.coiot) { + console.info(`This is a coIoT generated record`); + } + if (device.shelly === undefined) { + //console.info(`We must request general data to see if auth is required before proceeding`); + const res = await device.request.get(`${device.host}/shelly`) + let shellyObj = res.body; + device.shelly = shellyObj; + } + if (device.shelly?.auth == true) { + console.warn(`Device ${device.id} is password protected`); + device.setAuthCredentials(process.env.SHELLYUSER, process.env.SHELLYPW); + try { + await device.request.get(`${device.host}/status`) + console.info(`Password for user '${process.env.SHELLYUSER}' on device ${device.id} was correct`); + device.shelly.locked = false; + } catch (err) { + console.error(`*********ERROR*********: ${err.message} Provided password for user '${process.env.SHELLYUSER}' on device ${device.id} was incorrect`); + device.shelly.locked = true; + device.name = "Incorrect password provided"; + const extractedData = shellyExtractBasic(device); + shellylist[devicekey] = extractedData; + sse.send(extractedData, 'shellyCreate'); + return false; + } + } device.forceUpdate = async function () { - const statusResponse = await this.getStatus(); - processStatus(this, statusResponse); - const settingsResponse = await this.getSettings(); - processSettings(this, settingsResponse); + try { + const statusResponse = await this.getStatus(); + processStatus(this, statusResponse); + } catch (err) { console.error('Error: ', err.message, ' while forcing status update'); } + try { + const settingsResponse = await this.getSettings(); + processSettings(this, settingsResponse); + } catch (err) { console.error('Error: ', err.message, ' while forcing settings update'); } return this; } - shellycoaplist[devicekey] = device; - const extractedData = shellyExtract(device); + const extractedData = shellyExtractBasic(device); shellylist[devicekey] = extractedData; + sse.send(extractedData, 'shellyCreate'); setTimeout(pollSettingsTimer.bind({ device: device }), Math.round(Math.random() * (2500)) + 500); setTimeout(pollStatusTimer.bind({ device: device }), Math.round(Math.random() * (2500)) + 500); - sse.send(extractedData, 'shellyCreate'); + return true; +} - device.on('change', (prop, newValue, oldValue) => { - // a property on the device has changed +async function pollSetupTimer() { + if (await checkShellyAuth(this.device) == false) { + console.warn(`Could not connect to Shelly ${this.device.id} so set a timer to try again soon.`); + setTimeout(pollSetupTimer.bind({ device: this.device }), 60000); + } +} + + +shellies.on('discover', device => { + // a new device has been discovered + try { + console.log('Discovered device with ID', device.id, 'and type', device.type); const devicekey = deviceKey(device.type, device.id); - var extractedData = { ...shellylist[devicekey] }; + device.devicekey = devicekey; + shellycoaplist[devicekey] = device; + const extractedData = shellyExtract(device); + shellylist[devicekey] = extractedData; + sse.send(extractedData, 'shellyCreate'); + setTimeout(pollSetupTimer.bind({ device: device }), Math.round(Math.random() * (100)) + 25); + } catch (err) { console.error('Error: ', err.message, ' while processing discovered Shelly'); } + + device.on('change', (prop, newValue, oldValue) => { try { - delete extractedData?.settings; - delete extractedData?.status; - extractedData.prop = prop; - extractedData.oldValue = oldValue; - extractedData.newValue = newValue; - sse.send(extractedData, 'shellyUpdate'); - //console.log('Shellies(change) Events:', devicekey, 'property:', prop, 'changed from:', oldValue, 'to:', newValue, 'sent //to', sse.listenerCount('data'), 'listeners'); - } catch (err) { console.error('Error: ', err.message, ' while sending update'); } + // a property on the device has changed + const devicekey = deviceKey(device.type, device.id); + try { + let newExtraction = shellyExtract(device); + shellylist[devicekey] = _.merge(shellylist[devicekey], newExtraction); + sse.send(shellylist[devicekey], 'shellyUpdate'); + } catch (err) { console.error('Error: ', err.message, ' while sending update'); } + } catch (err) { console.error('Error: ', err.message, ' while handling change event'); } }) device.on('offline', () => { - const devicekey = deviceKey(device.type, device.id); - console.log('Device with deviceKey', devicekey, 'went offline') - const extractedData = shellylist[devicekey]; try { - sse.listenerCount('shellyRemove'); - sse.send(extractedData, 'shellyRemove'); - } catch (err) { console.log('Error: ', err.message, ' while sending remove'); } - delete shellycoaplist[devicekey]; - delete shellylist[devicekey]; + const devicekey = deviceKey(device.type, device.id); + console.log(`Device with deviceKey ${devicekey} went offline`) + try { + sse.send(shellylist[devicekey], 'shellyRemove'); + sse.listenerCount('shellyRemove'); + } catch (err) { console.error('Error: ', err.message, ' while sending remove'); } + delete shellycoaplist[devicekey]; + delete shellylist[devicekey]; + } catch (err) { console.error('Error: ', err.message, ' while handling offline event'); } }) }) +coIoTserver.on('status', (status) => { + const devicekey = deviceKey(status.deviceType, status.deviceId); + if (_.isObject(shellycoaplist[devicekey])) { + //console.info('CoIoT already exists device with ID', status.deviceId, 'and type', status.deviceType); + return; + } + console.info('CoIoT Discovered device with ID', status.deviceId, 'and type', status.deviceType); + const device = shellies.createDevice(status.deviceType, status.deviceId, status.location.host); + device.coiotDiscovered = true; + device.online = true; + device.lastSeen = new Date(); + device.devicekey = devicekey; + shellycoaplist[devicekey] = device; + shellies.addDevice(device); + checkShellyAuth(device); + console.log(status); +}); + function start(SSE) { sse = SSE; - // start discovering devices and listening for status updates + + // Set the initial data for sse to ShellyList + sse.updateInit(shellylist); + // start coIoT Discovery + coIoTserver.listen(); + // start CoAP Discovery shellies.start(); return [shellylist, shellycoaplist]; } diff --git a/views/details.pug b/views/details.pug index d12aa7e..2f9972b 100644 --- a/views/details.pug +++ b/views/details.pug @@ -14,9 +14,9 @@ block content #detail-general.collapse.show(aria-labelledby='heading-detail-general') .card-body.px-0.pt-2.pb-1 div.row.mx-0.shellydetail.small - each value, key in { 'settings.name': 'Device name:', 'id': 'Shelly ID:', 'type': 'Shelly Type:', 'modelName': 'Shelly Model:', 'settings.device.mac': 'MAC address:', 'lastSeen': 'Last seen:', 'settings.wifirecovery_reboot_enabled': 'No WiFi reboot:', 'settings.factory_reset_from_switch': 'Reset from switch:', 'settings.debug_enable': 'Debug enabled:'} + each value, key in { 'settingsCache.name': 'Device name:', 'id': 'Shelly ID:', 'type': 'Shelly Type:', 'modelName': 'Shelly Model:', 'shelly.mac': 'MAC address:', 'settingsCache.wifirecovery_reboot_enabled': 'No WiFi reboot:', 'settingsCache.factory_reset_from_switch': 'Reset from switch:', 'settingsCache.debug_enable': 'Debug enabled:'} dt.col-5= value - dd.col-7!= getShellyDetail(shelly, key) + dd.col-7!= getShellyDetail(device, key) div.card.mb-3.bg-transparent.shellydetailcard .card-header.px-3.pb-1.pt-2 @@ -26,9 +26,9 @@ block content #detail-network.collapse.show(aria-labelledby='heading-detail-network') .card-body.px-0.pt-2.pb-1 div.row.mx-0.shellydetail.small - each value, key in { 'status.wifi_sta.ip': 'IP address:', 'settings.device.hostname': 'Hostname:', 'settings.discoverableip': 'Is discoverable?:'} + each value, key in { 'statusCache.wifi_sta.ip': 'IP address:', 'settingsCache.device.hostname': 'Hostname:', 'settingsCache.discoverableip': 'Is discoverable?:'} dt.col-5= value - dd.col-7!= getShellyDetail(shelly, key) + dd.col-7!= getShellyDetail(device, key) div.card.mb-3.bg-transparent.shellydetailcard .card-header.px-3.pb-1.pt-2 @@ -38,9 +38,9 @@ block content #detail-firmware.collapse.show(aria-labelledby='heading-detail-firmware') .card-body.px-0.pt-2.pb-1 div.row.mx-0.shellydetail.small - each value, key in { 'settings.fw': 'Current Firmware:', 'settings.build_info.build_timestamp': 'Build time:', 'settings.build_info.build_version': 'Build version:', 'status.update.has_update': 'Update available:', 'status.update.new_version': 'Version available:', 'status.update.status': 'Update status:'} + each value, key in { 'settingsCache.fw': 'Current Firmware:', 'settingsCache.build_info.build_timestamp': 'Build time:', 'settingsCache.build_info.build_version': 'Build version:', 'statusCache.update.has_update': 'Update available:', 'statusCache.update.new_version': 'Version available:', 'statusCache.update.statusCache': 'Update status:'} dt.col-5= value - dd.col-7!= getShellyDetail(shelly, key) + dd.col-7!= getShellyDetail(device, key) div.card.mb-3.bg-transparent.shellydetailcard .card-header.px-3.pb-1.pt-2 @@ -50,9 +50,9 @@ block content #detail-hardware.collapse.show(aria-labelledby='heading-detail-hardware') .card-body.px-0.pt-2.pb-1 div.row.mx-0.shellydetail.small - each value, key in { 'status.ram_total': 'Total RAM:', 'status.ram_free': 'Free RAM:', 'status.fs_size': 'File system size:', 'status.fs_free': 'File system free:', 'status.uptime': 'Hardware uptime:', 'settings.hwinfo.hw_revision': 'Hardware revision:', 'settings.hwinfo.batch_id': 'Hardware batch ID:'} + each value, key in { 'statusCache.ram_total': 'Total RAM:', 'statusCache.ram_free': 'Free RAM:', 'statusCache.fs_size': 'File system size:', 'statusCache.fs_free': 'File system free:', 'statusCache.uptime': 'Hardware uptime:', 'settingsCache.hwinfo.hw_revision': 'Hardware revision:', 'settingsCache.hwinfo.batch_id': 'Hardware batch ID:'} dt.col-5= value - dd.col-7!= getShellyDetail(shelly, key) + dd.col-7!= getShellyDetail(device, key) div.card.mb-3.bg-transparent.shellydetailcard .card-header.px-3.pb-1.pt-2 @@ -62,9 +62,9 @@ block content #detail-security.collapse.show(aria-labelledby='heading-detail-security') .card-body.px-0.pt-2.pb-1 div.row.mx-0.shellydetail.small - each value, key in { 'settings.login.enabled': 'Login enabled:', 'settings.login.unprotected': 'Password protected:', 'settings.login.username': 'User name:'} + each value, key in { 'settingsCache.login.enabled': 'Login enabled:', 'settingsCache.login.unprotected': 'Password protected:', 'settingsCache.login.username': 'User name:'} dt.col-5= value - dd.col-7!= getShellyDetail(shelly, key) + dd.col-7!= getShellyDetail(device, key) div.card.mb-3.bg-transparent.shellydetailcard .card-header.px-3.pb-1.pt-2 @@ -74,9 +74,9 @@ block content #detail-wireless.collapse.show(aria-labelledby='heading-detail-wireless') .card-body.px-0.pt-2.pb-1 div.row.mx-0.shellydetail.small - each value, key in { 'settings.wifi_ap.enabled': 'Access point enabled:', 'settings.wifi_ap.ssid': 'Access point SSID:', 'settings.wifi_ap.key': 'Access point key:', 'settings.wifi_sta.enabled': 'Wifi network 1 enabled:', 'settings.wifi_sta.ssid': 'Wifi network 1 SSID:', 'settings.wifi_sta.ipv4_method': 'Wifi network 1 IP:', 'settings.wifi_sta1.enabled': 'Wifi network 2 enabled:', 'settings.wifi_sta1.ssid': 'Wifi network 2 SSID:', 'settings.wifi_sta1.ipv4_method': 'Wifi network 2 IP:', 'settings.ap_roaming.enabled': 'Wifi roaming enabled:', 'settings.ap_roaming.threshold': 'Wifi roaming threshhold:'} + each value, key in { 'settingsCache.wifi_ap.enabled': 'Access point enabled:', 'settingsCache.wifi_ap.ssid': 'Access point SSID:', 'settingsCache.wifi_ap.key': 'Access point key:', 'settingsCache.wifi_sta.enabled': 'Wifi network 1 enabled:', 'settingsCache.wifi_sta.ssid': 'Wifi network 1 SSID:', 'settingsCache.wifi_sta.ipv4_method': 'Wifi network 1 IP:', 'settingsCache.wifi_sta1.enabled': 'Wifi network 2 enabled:', 'settingsCache.wifi_sta1.ssid': 'Wifi network 2 SSID:', 'settingsCache.wifi_sta1.ipv4_method': 'Wifi network 2 IP:', 'settingsCache.ap_roaming.enabled': 'Wifi roaming enabled:', 'settingsCache.ap_roaming.threshold': 'Wifi roaming threshhold:'} dt.col-5= value - dd.col-7!= getShellyDetail(shelly, key) + dd.col-7!= getShellyDetail(device, key) div.card.mb-3.bg-transparent.shellydetailcard .card-header.px-3.pb-1.pt-2 @@ -86,9 +86,9 @@ block content #detail-mqtt.collapse.show(aria-labelledby='heading-detail-mqtt') .card-body.px-0.pt-2.pb-1 div.row.mx-0.shellydetail.small - each value, key in { 'status.mqtt.connected': 'MQTT Connected', 'settings.mqtt.enable': 'MQTT Enabled', 'settings.mqtt.server': 'MQTT Server', 'settings.mqtt.user': 'MQTT User', 'settings.mqtt.clean_session': 'MQTT Clean session', 'settings.mqtt.keep_alive': 'MQTT Keep alive', 'settings.mqtt.max_qos': 'MQTT QoS', 'settings.mqtt.retain': 'MQTT Retain', 'settings.mqtt.update_period': 'MQTT Update period', 'settings.mqtt.reconnect_timeout_min': 'MQTT Min timeout', 'settings.mqtt.reconnect_timeout_max': 'MQTT Max timeout'} + each value, key in { 'statusCache.mqtt.connected': 'MQTT Connected', 'settingsCache.mqtt.enable': 'MQTT Enabled', 'settingsCache.mqtt.server': 'MQTT Server', 'settingsCache.mqtt.user': 'MQTT User', 'settingsCache.mqtt.clean_session': 'MQTT Clean session', 'settingsCache.mqtt.keep_alive': 'MQTT Keep alive', 'settingsCache.mqtt.max_qos': 'MQTT QoS', 'settingsCache.mqtt.retain': 'MQTT Retain', 'settingsCache.mqtt.update_period': 'MQTT Update period', 'settingsCache.mqtt.reconnect_timeout_min': 'MQTT Min timeout', 'settingsCache.mqtt.reconnect_timeout_max': 'MQTT Max timeout'} dt.col-5= value - dd.col-7!= getShellyDetail(shelly, key) + dd.col-7!= getShellyDetail(device, key) div.card.mb-3.bg-transparent.shellydetailcard .card-header.px-3.pb-1.pt-2 @@ -98,9 +98,9 @@ block content #detail-power.collapse.show(aria-labelledby='heading-detail-power') .card-body.px-0.pt-2.pb-1 div.row.mx-0.shellydetail.small - each value, key in { 'settings.device.sleep_mode': 'Sleep mode:', 'settings.sleep_mode.period': 'Sleep period:', 'settings.sleep_mode.unit': 'Sleep period units:', 'settings.remain_awake': 'Remain awake:', 'status.bat.value': 'Battery remaining:', 'status.bat.voltage': 'Battery voltage:', 'status.charger': 'Is charging:'} + each value, key in { 'settingsCache.device.sleep_mode': 'Sleep mode:', 'settingsCache.sleep_mode.period': 'Sleep period:', 'settingsCache.sleep_mode.unit': 'Sleep period units:', 'settingsCache.remain_awake': 'Remain awake:', 'statusCache.bat.value': 'Battery remaining:', 'statusCache.bat.voltage': 'Battery voltage:', 'statusCache.charger': 'Is charging:'} dt.col-5= value - dd.col-7!= getShellyDetail(shelly, key) + dd.col-7!= getShellyDetail(device, key) div.card.mb-3.bg-transparent.shellydetailcard .card-header.px-3.pb-1.pt-2