UNPKG

node-red-contrib-knx-ultimate

Version:

Control your KNX and KNX Secure intallation via Node-Red! A bunch of KNX nodes, with integrated Philips HUE control, ETS group address importer, and KNX routing between interfaces. Easy to use and highly configurable.

435 lines (393 loc) 16.3 kB
const fs = require('fs') const path = require('path') const KNXAddress = require('knxultimate').KNXAddress const _ = require('lodash') const { getRequestAccessToken, normalizeAuthFromAccessTokenQuery } = require('./utils/httpAdminAccessToken') let viewerAdminEndpointsRegistered = false const viewerRuntimeNodes = new Map() const knxUltimateViewerVueDistDir = path.join(__dirname, 'plugins', 'knxUltimateViewer-vue') const sendKnxUltimateViewerVueIndex = (req, res) => { const entryPath = path.join(knxUltimateViewerVueDistDir, 'index.html') fs.readFile(entryPath, 'utf8', (error, html) => { if (error || typeof html !== 'string') { res.status(503).type('text/plain').send('KNX Viewer Vue build not found. Run "npm run knx-viewer:build" in the module root.') return } const rawToken = getRequestAccessToken(req) if (!rawToken) { res.type('text/html').send(html) return } const encodedToken = encodeURIComponent(rawToken) const htmlWithToken = html .replace('./assets/app.js', `./assets/app.js?access_token=${encodedToken}`) .replace('./assets/app.css', `./assets/app.css?access_token=${encodedToken}`) res.type('text/html').send(htmlWithToken) }) } const sendStaticFileSafe = ({ rootDir, relativePath, res }) => { const rootPath = path.resolve(rootDir) const requestedPath = String(relativePath || '').replace(/^\/+/, '') const fullPath = path.resolve(rootPath, requestedPath) if (!fullPath.startsWith(rootPath + path.sep) && fullPath !== rootPath) { res.status(403).type('text/plain').send('Forbidden') return } fs.stat(fullPath, (statError, stats) => { if (statError || !stats || !stats.isFile()) { res.status(404).type('text/plain').send('File not found') return } res.sendFile(fullPath, (sendError) => { if (!sendError || res.headersSent) return res.status(sendError.statusCode || 500).type('text/plain').send(sendError.message || String(sendError)) }) }) } const normalizeDpt = (value) => String(value || '').trim().toUpperCase() const isBooleanLikeDpt = (value) => { const dpt = normalizeDpt(value) return dpt === '1' || dpt.startsWith('1.') || dpt.includes('DPT-1') || dpt.includes('DPST-1-') } const isDimmerLikeDpt = (value) => { const dpt = normalizeDpt(value) return dpt === '5.001' || dpt.includes('5.001') || dpt.includes('DPST-5-1') || dpt.includes('DPT-5') } const clamp = (value, min, max) => Math.max(min, Math.min(max, value)) const normalizePayloadText = (value) => { if (value === undefined || value === null) return '' if (Buffer.isBuffer(value)) return value.toString('hex') if (typeof value === 'object') { try { return JSON.stringify(value) } catch (error) { return String(value) } } return String(value) } const sortViewerEntries = (a, b) => { if (a.addressRAW !== undefined && b.addressRAW !== undefined) { return a.addressRAW > b.addressRAW ? 1 : -1 } return a.addressRAW !== undefined ? 1 : -1 } const classifyViewerEntry = (entry) => { const payloadNumber = Number(entry && entry.payload) const numericPayload = Number.isFinite(payloadNumber) ? clamp(payloadNumber, 0, 100) : null const dpt = String(entry && entry.dpt ? entry.dpt : '').trim() const isBooleanPayload = typeof (entry && entry.payload) === 'boolean' if (isBooleanPayload || isBooleanLikeDpt(dpt)) { return { kind: 'light', isOn: entry && entry.payload === true, level: entry && entry.payload === true ? 100 : 0 } } if (numericPayload !== null && isDimmerLikeDpt(dpt)) { return { kind: 'dimmer', isOn: numericPayload > 0, level: Math.round(numericPayload) } } return { kind: 'other', isOn: false, level: null } } const buildViewerWebState = (node) => { const entries = Array.isArray(node && node.exposedGAs) ? node.exposedGAs.slice().sort(sortViewerEntries) : [] const items = entries.map((entry) => { const classification = classifyViewerEntry(entry) const lastUpdateMs = entry && entry.lastupdate ? new Date(entry.lastupdate).getTime() : 0 return { address: String(entry && entry.address ? entry.address : '').trim(), addressRAW: Number(entry && entry.addressRAW ? entry.addressRAW : 0), dpt: String(entry && entry.dpt ? entry.dpt : '').trim(), payload: entry ? entry.payload : undefined, payloadText: normalizePayloadText(entry ? entry.payload : ''), devicename: String(entry && entry.devicename ? entry.devicename : '').trim(), lastUpdate: entry && entry.lastupdate ? new Date(entry.lastupdate).toISOString() : '', lastUpdateMs: Number.isFinite(lastUpdateMs) ? lastUpdateMs : 0, rawPayload: String(entry && entry.rawPayload ? entry.rawPayload : '').trim(), payloadmeasureunit: String(entry && entry.payloadmeasureunit ? entry.payloadmeasureunit : '').trim(), kind: classification.kind, isOn: classification.isOn, level: classification.level } }) const lights = items.filter(item => item.kind === 'light') const dimmers = items.filter(item => item.kind === 'dimmer') const others = items.filter(item => item.kind === 'other') const lastUpdateMs = Math.max(...items.map(item => Number(item.lastUpdateMs || 0)), 0) return { node: { id: node.id, name: node.name || 'KNXViewer', gatewayId: node.serverKNX ? node.serverKNX.id : '', gatewayName: (node.serverKNX && node.serverKNX.name) ? node.serverKNX.name : '' }, summary: { totalItems: items.length, lightCount: lights.length, dimmerCount: dimmers.length, otherCount: others.length, lightOnCount: lights.filter(item => item.isOn).length, lightOffCount: lights.filter(item => !item.isOn).length, dimmerActiveCount: dimmers.filter(item => Number(item.level || 0) > 0).length, averageDimmerLevel: dimmers.length ? Math.round(dimmers.reduce((acc, item) => acc + Number(item.level || 0), 0) / dimmers.length) : 0, lastUpdate: lastUpdateMs > 0 ? new Date(lastUpdateMs).toISOString() : '' }, items } } module.exports = function (RED) { if (!viewerAdminEndpointsRegistered) { RED.httpAdmin.use('/knxUltimateViewer', normalizeAuthFromAccessTokenQuery) RED.httpAdmin.get('/knxUltimateViewer/page', RED.auth.needsPermission('knxUltimate-config.read'), (req, res) => { sendKnxUltimateViewerVueIndex(req, res) }) RED.httpAdmin.get('/knxUltimateViewer/page/assets/:file', RED.auth.needsPermission('knxUltimate-config.read'), (req, res) => { sendStaticFileSafe({ rootDir: path.join(knxUltimateViewerVueDistDir, 'assets'), relativePath: req.params.file, res }) }) // Alias for relative asset URLs resolved from ".../page?nodeId=..." // which become ".../assets/<file>" in browsers. RED.httpAdmin.get('/knxUltimateViewer/assets/:file', RED.auth.needsPermission('knxUltimate-config.read'), (req, res) => { sendStaticFileSafe({ rootDir: path.join(knxUltimateViewerVueDistDir, 'assets'), relativePath: req.params.file, res }) }) RED.httpAdmin.get('/knxUltimateViewer/nodes', RED.auth.needsPermission('knxUltimate-config.read'), (req, res) => { try { const nodes = Array.from(viewerRuntimeNodes.values()) .map((node) => ({ id: node.id, name: node.name || 'KNXViewer', gatewayId: node.serverKNX ? node.serverKNX.id : '', gatewayName: (node.serverKNX && node.serverKNX.name) ? node.serverKNX.name : '' })) .sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''))) res.json({ nodes }) } catch (error) { res.status(500).json({ error: error.message || String(error) }) } }) RED.httpAdmin.get('/knxUltimateViewer/state', RED.auth.needsPermission('knxUltimate-config.read'), (req, res) => { try { const nodeId = String(req.query.nodeId || '').trim() let viewerNode = nodeId ? viewerRuntimeNodes.get(nodeId) : null if (!viewerNode) { viewerNode = Array.from(viewerRuntimeNodes.values())[0] } if (!viewerNode) { res.status(404).json({ error: 'KNX Viewer node not found' }) return } res.json(buildViewerWebState(viewerNode)) } catch (error) { res.status(500).json({ error: error.message || String(error) }) } }) viewerAdminEndpointsRegistered = true } function knxUltimateViewer (config) { RED.nodes.createNode(this, config) const node = this node.serverKNX = RED.nodes.getNode(config.server) || undefined const pushStatus = (status) => { if (!status) return const provider = node.serverKNX if (provider && typeof provider.applyStatusUpdate === 'function') { provider.applyStatusUpdate(node, status) } else { node.status(status) } } const updateStatus = (status) => { if (!status) return pushStatus(status) } if (node.serverKNX === undefined) { updateStatus({ fill: 'red', shape: 'dot', text: '[THE GATEWAY NODE HAS BEEN DISABLED]' }) return } node.topic = node.name node.name = config.name === undefined ? 'KNXViewer' : config.name node.outputtopic = node.name node.dpt = '' node.notifyreadrequest = false node.notifyreadrequestalsorespondtobus = 'false' node.notifyreadrequestalsorespondtobusdefaultvalueifnotinitialized = '' node.notifyresponse = true node.notifywrite = true node.initialread = false node.listenallga = true node.outputtype = 'write' node.outputRBE = 'false' node.inputRBE = 'false' node.currentPayload = '' node.passthrough = 'no' node.formatmultiplyvalue = 1 node.formatnegativevalue = 'leave' node.formatdecimalsvalue = 2 node.timerPIN3 = null node.exposedGAs = [] viewerRuntimeNodes.set(node.id, node) node.setNodeStatus = ({ fill, shape, text, payload, GA }) => { try { if (node.serverKNX === null) { updateStatus({ fill: 'red', shape: 'dot', text: '[NO GATEWAY SELECTED]' }); return } const gaValue = GA === undefined ? '' : GA let payloadValue = payload === undefined ? '' : payload payloadValue = typeof payloadValue === 'object' ? JSON.stringify(payloadValue) : payloadValue const dDate = new Date() const ts = (node.serverKNX && typeof node.serverKNX.formatStatusTimestamp === 'function') ? node.serverKNX.formatStatusTimestamp(dDate) : `${dDate.getDate()}, ${dDate.toLocaleTimeString()}` updateStatus({ fill, shape, text: gaValue + ' ' + payloadValue + ' ' + text + ' (' + ts + ')' }) } catch (error) { /* empty */ } } node.handleSend = (msg) => { let gaEntry try { gaEntry = node.exposedGAs.find(ga => ga.address === msg.knx.destination) } catch (error) { } const deviceName = msg.devicename === node.name ? 'Import ETS file to view the group address name' : msg.devicename const addressRAW = KNXAddress.createFromString(msg.knx.destination, KNXAddress.TYPE_GROUP).get() if (gaEntry === undefined) { node.exposedGAs.push({ address: msg.knx.destination, addressRAW, dpt: msg.knx.dpt, payload: msg.payload, devicename: deviceName, lastupdate: new Date(), rawPayload: 'HEX Raw: ' + msg.knx.rawValue.toString('hex') || '?', payloadmeasureunit: (msg.payloadmeasureunit !== 'unknown' ? ' ' + msg.payloadmeasureunit : '') }) } else { gaEntry.dpt = msg.knx.dpt gaEntry.payload = msg.payload gaEntry.devicename = deviceName gaEntry.lastupdate = new Date() gaEntry.rawPayload = 'HEX Raw: ' + msg.knx.rawValue.toString('hex') || '?' gaEntry.payloadmeasureunit = (msg.payloadmeasureunit !== 'unknown' ? ' ' + msg.payloadmeasureunit : '') } const pin1 = node.createPayloadPIN1() const pin2 = node.createPayloadPIN2() const pin3 = node.createPayloadPIN3() node.send([pin1, pin2, pin3]) } node.createPayloadPIN1 = () => { const sorted = node.exposedGAs.sort(sortViewerEntries) let payload = '' const head = `<div class="main"><table><caption>Current received KNX Group address values</caption> <thead> <tr> <th> GA </th> <th> Value </th> <th> DPT </th> <th> Last updated </th> <th> Group Address Name </th> </tr> </thead> <tbody>` const footer = `</tbody><tfoot> <tr> <th scope="row">Count</th> <td>` + sorted.length + `</td> </tr> </tfoot> </table></div>` try { for (let index = 0; index < sorted.length; index++) { const element = sorted[index] payload += '<tr><td>' + element.address + '</td>' if (typeof element.payload === 'boolean' && element.payload === true) { payload += '<td><b><font color=green>True</font></b></td>' } else if (typeof element.payload === 'boolean' && element.payload === false) { payload += '<td><font color=red>False</font></td>' } else if (typeof element.payload === 'object' && !isNaN(Date.parse(element.payload))) { payload += '<td>' + element.payload.toLocaleString() + '</td>' } else if (typeof element.payload === 'object') { try { payload += '<td><i>' + element.rawPayload + '</i></td>' } catch (error) { payload += '<td>' + element.payload + '</td>' } } else { payload += '<td>' + element.payload + element.payloadmeasureunit + '</td>' } payload += '<td>' + element.dpt + '</td>' payload += '<td>' + element.lastupdate.toLocaleString() + '</td>' payload += '<td><font style="font-size: smaller;">' + element.devicename + '</font></td></tr>' } } catch (error) { } return { topic: node.name, payload: head + payload + footer } } node.createPayloadPIN2 = () => { return { topic: node.name, payload: node.exposedGAs } } node.createPayloadPIN3 = () => { let head = '' let footer = '' let payload = '' try { const items = _.clone(node.serverKNX.knxConnection.commandQueue) if (items === undefined) return head = `<div class="main"><table><caption>Queue of outgoing telegrams to the KNX BUS. The more the count,</br>the more congested is the KNX BUS.</caption> <thead> <tr> <th> Channel ID </th> <th> Sequence counter </th> <th> Type of packet</th> <th> Status </th> </tr> </thead> <tbody>` footer = `</tbody><tfoot> <tr> <th scope="row">Count</th> <td>` + items.length + `</td> </tr> </tfoot> </table></div>` for (let index = 0; index < items.length; index++) { const element = items[index] payload += '<tr><td>' + element.knxPacket.channelID + '</td>' payload += '<td><b><font color=green>' + element.knxPacket.seqCounter + '</font></b></td>' payload += '<td>' + element.knxPacket.type + '</td>' payload += '<td>' + element.knxPacket.status + '</td></tr>' } } catch (error) { } return { topic: node.name, payload: head + payload + footer } } node.on('input', function () { }) node.on('close', function (done) { if (node.timerPIN3 !== null) clearInterval(node.timerPIN3) viewerRuntimeNodes.delete(node.id) if (node.serverKNX) { node.serverKNX.removeClient(node) } done() }) if (node.serverKNX) { node.serverKNX.removeClient(node) node.serverKNX.addClient(node) } } RED.nodes.registerType('knxUltimateViewer', knxUltimateViewer) }