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, KNX AI for diagnosticsand KNX routing between interfaces. Easy to use and highly configurable.

543 lines (478 loc) 24.4 kB
// KNX Multi Routing - interconnect multiple KNX Ultimate gateways via Node-RED flows const loggerClass = require('./utils/sysLogger') const os = require('os') const toBoolean = (value, fallback) => { if (value === undefined || value === null) return fallback if (typeof value === 'boolean') return value if (typeof value === 'number') return value !== 0 const s = String(value).trim().toLowerCase() if (s === 'true' || s === '1' || s === 'yes' || s === 'on') return true if (s === 'false' || s === '0' || s === 'no' || s === 'off') return false return fallback } const safeNumber = (value, fallback) => { const n = Number(value) return Number.isFinite(n) ? n : fallback } const normalizeHex = (value) => { if (value === undefined || value === null) return '' const s = String(value).trim() if (s === '') return '' return s.replace(/^0x/i, '').replace(/[^0-9a-fA-F]/g, '') } const bufferFromMaybe = (value) => { if (value === undefined || value === null) return null if (Buffer.isBuffer(value)) return value if (Array.isArray(value)) return Buffer.from(value) if (typeof value === 'object' && value.type === 'Buffer' && Array.isArray(value.data)) return Buffer.from(value.data) if (typeof value === 'string') { const trimmed = value.trim() if (trimmed === '') return null // Heuristic: if hex-like, decode as hex, else base64 if (/^[0-9a-fA-F]+$/.test(trimmed) && trimmed.length % 2 === 0) return Buffer.from(trimmed, 'hex') try { return Buffer.from(trimmed, 'base64') } catch (e) { return null } } return null } const CEMI_L_DATA_REQ = 0x11 const CEMI_L_DATA_IND = 0x29 const isWildcardHost = (host) => { const h = host === undefined || host === null ? '' : String(host).trim() return h === '' || h === '0.0.0.0' || h === '::' || h === '::0' } const guessAdvertiseHost = (listenHost) => { if (listenHost && !isWildcardHost(listenHost)) return String(listenHost) try { const ifaces = os.networkInterfaces() for (const entries of Object.values(ifaces)) { for (const entry of entries || []) { if (entry.family === 'IPv4' && !entry.internal) return entry.address } } } catch (e) { /* ignore */ } return '127.0.0.1' } let _knxultimateCache = null const getKnxultimate = () => { if (_knxultimateCache) return _knxultimateCache _knxultimateCache = require('knxultimate') return _knxultimateCache } module.exports = function (RED) { function knxUltimateMultiRouting (config) { RED.nodes.createNode(this, config) const node = this node.mode = config.mode || 'gateway' // 'gateway' | 'server' node.serverKNX = (config.server && RED.nodes.getNode(config.server)) || undefined if (node.mode !== 'server' && node.serverKNX === undefined) { node.status({ fill: 'red', shape: 'dot', text: '[THE GATEWAY NODE HAS BEEN DISABLED]' }) return } node.name = config.name || 'KNX Multi Routing' node.outputtopic = config.outputtopic || node.name node.topic = node.outputtopic node.dpt = '' // Capture all bus events node.listenallga = true node.notifyreadrequest = true node.notifyreadrequestalsorespondtobus = 'false' node.notifyreadrequestalsorespondtobusdefaultvalueifnotinitialized = '' node.notifyresponse = true node.notifywrite = true node.initialread = false node.outputtype = 'write' node.outputRBE = 'false' node.inputRBE = 'false' node.isMultiRouting = true // Forwarding controls (input -> KNX bus) // Basic loop protection: drop messages already tagged as originating from this same gateway. node.dropIfSameGateway = config.dropIfSameGateway !== undefined ? (config.dropIfSameGateway === true || config.dropIfSameGateway === 'true') : true node.respectRoutingCounter = toBoolean(config.respectRoutingCounter, true) node.decrementRoutingCounter = toBoolean(config.decrementRoutingCounter, false) // KNX/IP tunneling server (optional) node.tunnelServer = null node.tunnelSessions = new Set() node.tunnelGatewayId = '' node.tunnelAssignedIndividualAddress = '' node.tunnelAdvertiseHostTimer = null node.tunnelStatusRefreshTimer = null node.tunnelRxCount = 0 node.tunnelLastRxAt = 0 const pushStatus = (status) => { if (!status) return const provider = node.serverKNX try { if (provider && typeof provider.applyStatusUpdate === 'function') { provider.applyStatusUpdate(node, status) } else { node.status(status) } } catch (error) { try { node.status(status) } catch (e2) { /* ignore */ } } } const updateStatus = (status) => { if (!status) return pushStatus(status) } // Used to call the status update from the config node. node.setNodeStatus = ({ fill, shape, text, payload, GA, dpt, devicename }) => { try { if ((node.mode !== 'server') && (node.serverKNX === null || node.serverKNX === undefined)) { updateStatus({ fill: 'red', shape: 'dot', text: '[NO GATEWAY SELECTED]' }); return } const dDate = new Date() const ts = (node.serverKNX && typeof node.serverKNX.formatStatusTimestamp === 'function') ? node.serverKNX.formatStatusTimestamp(dDate) : `${dDate.getDate()}, ${dDate.toLocaleTimeString()}` GA = (typeof GA === 'undefined' || GA === '') ? '' : '(' + GA + ') ' devicename = devicename || '' dpt = (typeof dpt === 'undefined' || dpt === '') ? '' : ' DPT' + dpt payload = typeof payload === 'object' ? JSON.stringify(payload) : payload updateStatus({ fill, shape, text: GA + payload + (node.listenallga === true ? ' ' + devicename : '') + ' (' + ts + ') ' + (text || '') }) } catch (error) { /* empty */ } } try { const baseLogLevel = (node.serverKNX && node.serverKNX.loglevel) ? node.serverKNX.loglevel : 'error' node.sysLogger = new loggerClass({ loglevel: baseLogLevel, setPrefix: node.type + ' <' + (node.name || node.id || '') + '>' }) } catch (error) { /* empty */ } const localGatewayIds = () => { const ids = new Set() if (node.serverKNX && node.serverKNX.id) ids.add(String(node.serverKNX.id)) if (node.tunnelGatewayId) ids.add(String(node.tunnelGatewayId)) return ids } // Called by knxUltimate-config.js to deliver bus telegrams (raw APDU + addresses) node.handleSend = (msg) => { try { const processed = applyRoutingCounterOnOutboundMsg(msg) if (!processed) return node.send(processed) } catch (error) { node.sysLogger?.error(`knxUltimateMultiRouting: output error: ${error.message}`) } } const tryParseCemiHex = (cemiHex) => { const clean = normalizeHex(cemiHex) if (!clean || clean.length % 2 !== 0) return null let KNXTunnelingRequest try { ({ KNXTunnelingRequest } = getKnxultimate()) } catch (e) { return null } try { return KNXTunnelingRequest.parseCEMIMessage(Buffer.from(clean, 'hex'), 0) } catch (e) { return null } } const getHopCountFromCemiHex = (cemiHex) => { const cemi = tryParseCemiHex(cemiHex) const hop = cemi && cemi.control ? Number(cemi.control.hopCount) : NaN return Number.isFinite(hop) ? hop : null } const decrementHopCountInCemiHex = (cemiHex) => { const cemi = tryParseCemiHex(cemiHex) if (!cemi || !cemi.control) return null const hop = Number(cemi.control.hopCount) if (!Number.isFinite(hop)) return null if (hop <= 0) return { oldHopCount: hop, newHopCount: hop, cemiHex: cemi.toBuffer().toString('hex') } const newHop = hop - 1 cemi.control.hopCount = newHop return { oldHopCount: hop, newHopCount: newHop, cemiHex: cemi.toBuffer().toString('hex') } } const applyRoutingCounterOnOutboundMsg = (msg) => { if (!msg) return msg const p = msg.payload !== undefined ? msg.payload : msg const k = (p && p.knx) ? p.knx : (msg && msg.knx ? msg.knx : null) if (!k || typeof k !== 'object') return msg const cemiHex = (k.cemi && (k.cemi.hex || k.cemi)) ? (k.cemi.hex || k.cemi) : '' if (!cemiHex) return msg const hopCount = getHopCountFromCemiHex(cemiHex) if (hopCount !== null) { try { k.routingCounter = hopCount } catch (e) { /* ignore */ } try { if (k.cemi && typeof k.cemi === 'object') k.cemi.hopCount = hopCount } catch (e) { /* ignore */ } } if (node.respectRoutingCounter && hopCount === 0) { node.setNodeStatus({ fill: 'grey', shape: 'ring', text: 'Dropped (routing counter 0)', payload: k.event || '', GA: k.destination || '', dpt: '', devicename: k.source || '' }) return null } if (!node.decrementRoutingCounter) return msg if (hopCount === null) return msg const dec = decrementHopCountInCemiHex(cemiHex) if (!dec) return msg if (node.respectRoutingCounter && dec.newHopCount === 0) { node.setNodeStatus({ fill: 'grey', shape: 'ring', text: 'Dropped (routing counter 0)', payload: k.event || '', GA: k.destination || '', dpt: '', devicename: k.source || '' }) return null } try { if (k.cemi && typeof k.cemi === 'object' && 'hex' in k.cemi) k.cemi.hex = dec.cemiHex else k.cemi = dec.cemiHex } catch (e) { /* ignore */ } try { k.routingCounter = dec.newHopCount } catch (e) { /* ignore */ } try { if (k.cemi && typeof k.cemi === 'object') k.cemi.hopCount = dec.newHopCount } catch (e) { /* ignore */ } return msg } const parseIncoming = (msg) => { const p = (msg && msg.payload !== undefined) ? msg.payload : msg const k = (p && p.knx) ? p.knx : (msg && msg.knx ? msg.knx : p) if (!k || typeof k !== 'object') return null const destination = k.destination || k.grpaddr || k.ga || (k.address && k.address.destination) || '' const source = k.source || (k.address && k.address.source) || '' const event = k.event || '' const apdu = k.apdu || {} const apduData = bufferFromMaybe(apdu.data !== undefined ? apdu.data : (k.rawValue !== undefined ? k.rawValue : k.apduData)) const bitlength = Number(apdu.bitlength !== undefined ? apdu.bitlength : (k.bitlength !== undefined ? k.bitlength : (apduData ? apduData.length * 8 : 0))) const cemiHex = (k.cemi && (k.cemi.hex || k.cemi)) ? (k.cemi.hex || k.cemi) : '' const hopCount = cemiHex ? getHopCountFromCemiHex(cemiHex) : null const routing = (p && p.knxMultiRouting) ? p.knxMultiRouting : (msg && msg.knxMultiRouting ? msg.knxMultiRouting : null) const originGatewayId = routing && routing.gateway && routing.gateway.id ? String(routing.gateway.id) : '' return { event, destination, source, apduData, bitlength, originGatewayId, cemiHex, hopCount } } const canForwardToGateway = (parsed) => { if (!parsed) return false if (!parsed.destination || typeof parsed.destination !== 'string') return false if (!node.serverKNX || node.serverKNX.linkStatus !== 'connected' || !node.serverKNX.knxConnection) return false if (node.dropIfSameGateway && parsed.originGatewayId && localGatewayIds().has(String(parsed.originGatewayId))) return false if (node.respectRoutingCounter && parsed.hopCount === 0) return false return true } const forwardToBus = (parsed) => { const client = node.serverKNX.knxConnection if (parsed.cemiHex) { const cemi = tryParseCemiHex(parsed.cemiHex) if (cemi && cemi.control) { // Apply routing-counter checks while forwarding to the BUS. const hop = Number(cemi.control.hopCount) if (Number.isFinite(hop)) { if (node.respectRoutingCounter && hop === 0) return } const isSerial = typeof client.isSerialTransport === 'function' ? client.isSerialTransport() : false const hostProtocol = client && client._options && client._options.hostProtocol ? String(client._options.hostProtocol) : '' let KNXProtocol try { ({ KNXProtocol } = getKnxultimate()) } catch (e) { KNXProtocol = null } if (!KNXProtocol || typeof client.send !== 'function') { // fall back to legacy methods below } else if (hostProtocol === 'Multicast' || isSerial) { cemi.msgCode = hostProtocol === 'Multicast' ? CEMI_L_DATA_IND : CEMI_L_DATA_REQ const knxPacketRequest = KNXProtocol.newKNXRoutingIndication(cemi) const expected = (typeof client.getSeqNumber === 'function') ? client.getSeqNumber() : 0 client.send(knxPacketRequest, undefined, false, expected) return } else { // Tunneling cemi.msgCode = CEMI_L_DATA_REQ try { if (hostProtocol === 'TunnelTCP') cemi.control.ack = 0 else cemi.control.ack = (client._options && client._options.suppress_ack_ldatareq) ? 0 : 1 } catch (e) { /* ignore */ } const seqNum = (hostProtocol === 'TunnelTCP' && typeof client.secureIncTunnelSeq === 'function') ? client.secureIncTunnelSeq() : (typeof client.incSeqNumber === 'function' ? client.incSeqNumber() : 0) const ch = (client.channelID !== undefined && client.channelID !== null) ? client.channelID : (client._channelID || 0) const knxPacketRequest = KNXProtocol.newKNXTunnelingRequest(ch, seqNum, cemi) const wantsAck = !(client._options && client._options.suppress_ack_ldatareq) client.send(knxPacketRequest, wantsAck ? knxPacketRequest : undefined, false, seqNum) return } } } // Legacy: re-create telegram from event+APDU (routing counter will be reset by the library). const ga = parsed.destination if (parsed.event === 'GroupValue_Write') { if (!parsed.apduData) return if (typeof client.writeRaw !== 'function') throw new Error('KNX client does not support writeRaw') client.writeRaw(ga, parsed.apduData, parsed.bitlength) return } if (parsed.event === 'GroupValue_Response') { if (!parsed.apduData) return if (typeof client.respondRaw !== 'function') throw new Error('KNX client does not support respondRaw') client.respondRaw(ga, parsed.apduData, parsed.bitlength) return } if (parsed.event === 'GroupValue_Read') { if (typeof client.read !== 'function') throw new Error('KNX client does not support read') client.read(ga) } } const canForwardToTunnel = (parsed) => { if (!parsed) return false if (!node.tunnelServer) return false if (!parsed.cemiHex) return false if (node.dropIfSameGateway && parsed.originGatewayId && localGatewayIds().has(String(parsed.originGatewayId))) return false if (node.respectRoutingCounter && parsed.hopCount === 0) return false return true } const forwardToTunnel = (parsed) => { const clean = normalizeHex(parsed.cemiHex) if (!clean || clean.length % 2 !== 0) return let KNXTunnelingRequest try { ({ KNXTunnelingRequest } = getKnxultimate()) } catch (e) { throw new Error('knxultimate KNXTunnelingRequest not available') } const cemi = KNXTunnelingRequest.parseCEMIMessage(Buffer.from(clean, 'hex'), 0) node.tunnelServer.injectCemi(cemi) } const updateTunnelStatus = (extraText) => { if (node.mode !== 'server') return const addr = node.tunnelServer && typeof node.tunnelServer.address === 'object' ? node.tunnelServer.address : null const host = addr ? addr.host : (config.tunnelListenHost || '0.0.0.0') const port = addr ? addr.port : safeNumber(config.tunnelListenPort, 3671) const sessionsFromServer = node.tunnelServer && node.tunnelServer.sessions && typeof node.tunnelServer.sessions.size === 'number' ? node.tunnelServer.sessions.size : null const sessionsFromEvents = node.tunnelSessions ? node.tunnelSessions.size : 0 const sessions = sessionsFromServer !== null ? sessionsFromServer : sessionsFromEvents const adv = (node.tunnelServer && node.tunnelServer.options && node.tunnelServer.options.advertiseHost) ? node.tunnelServer.options.advertiseHost : '' const tail = extraText ? ` ${extraText}` : '' const advText = adv ? ` adv:${adv}` : '' const rxText = node.tunnelRxCount ? ` rx:${node.tunnelRxCount}` : '' updateStatus({ fill: 'green', shape: 'dot', text: `Tunnel ${host}:${port}${advText} sessions:${sessions}${rxText}${tail}` }) } const startTunnelServerIfNeeded = () => { if (node.mode !== 'server') return let KNXIPTunnelServer try { ({ KNXIPTunnelServer } = require('knxultimate')) } catch (e) { updateStatus({ fill: 'red', shape: 'dot', text: 'KNX/IP Server: knxultimate missing KNXIPTunnelServer' }) node.error('KNX/IP Server mode requires knxultimate with KNXIPTunnelServer exported.') return } node.tunnelGatewayId = (config.tunnelGatewayId && String(config.tunnelGatewayId).trim()) || String(node.id) node.tunnelAssignedIndividualAddress = (config.tunnelAssignedIndividualAddress && String(config.tunnelAssignedIndividualAddress).trim()) || '15.15.255' const listenHost = (config.tunnelListenHost && String(config.tunnelListenHost).trim()) || '0.0.0.0' const listenPort = safeNumber(config.tunnelListenPort, 3671) const advertiseHostConfigured = (config.tunnelAdvertiseHost && String(config.tunnelAdvertiseHost).trim()) || '' const advertiseHost = advertiseHostConfigured || guessAdvertiseHost(listenHost) const maxSessions = Math.max(1, safeNumber(config.tunnelMaxSessions, 1)) const loglevel = (node.serverKNX && node.serverKNX.loglevel) ? node.serverKNX.loglevel : 'error' node.tunnelServer = new KNXIPTunnelServer({ listenHost, listenPort, advertiseHost, assignedIndividualAddress: node.tunnelAssignedIndividualAddress, maxSessions, loglevel }) // If the OS network was not ready at boot, periodically refresh auto-advertise host. // This affects only new client connections (CONNECT_RESPONSE payload). if (!advertiseHostConfigured) { const refreshAdvertiseHost = () => { try { if (!node.tunnelServer || !node.tunnelServer.options) return const next = guessAdvertiseHost(listenHost) const cur = node.tunnelServer.options.advertiseHost if (next && cur !== next) { node.tunnelServer.options.advertiseHost = next updateTunnelStatus('(adv host updated)') } } catch (e) { /* ignore */ } } try { node.tunnelAdvertiseHostTimer = setInterval(refreshAdvertiseHost, 10000) if (node.tunnelAdvertiseHostTimer && typeof node.tunnelAdvertiseHostTimer.unref === 'function') node.tunnelAdvertiseHostTimer.unref() } catch (e) { /* ignore */ } } node.tunnelServer.on('error', (err) => { updateStatus({ fill: 'red', shape: 'dot', text: `Tunnel error: ${err.message}` }) node.error(err) }) node.tunnelServer.on('listening', () => { updateTunnelStatus('') }) node.tunnelServer.on('sessionUp', (s) => { try { node.tunnelSessions.add(s.channelId) } catch (e) { /* ignore */ } updateTunnelStatus('client connected') }) node.tunnelServer.on('sessionDown', (s) => { try { if (s && s.channelId) node.tunnelSessions.delete(s.channelId) } catch (e) { /* ignore */ } updateTunnelStatus('client disconnected') }) node.tunnelServer.on('rawTelegram', (knx, info) => { try { node.tunnelRxCount = (node.tunnelRxCount || 0) + 1 node.tunnelLastRxAt = Date.now() const msg = { topic: node.outputtopic || knx.destination, payload: { knx, knxMultiRouting: { gateway: { id: node.tunnelGatewayId, name: node.name || '', physAddr: node.tunnelAssignedIndividualAddress || '' }, receivedAt: Date.now(), tunnel: { channelId: info && info.channelId ? info.channelId : undefined } } } } const processed = applyRoutingCounterOnOutboundMsg(msg) if (!processed) return node.send(processed) } catch (error) { node.sysLogger?.error(`knxUltimateMultiRouting: tunnel rawTelegram output error: ${error.message}`) } }) // Periodic refresh to surface session count even if Node-RED doesn't show transient updates. try { node.tunnelStatusRefreshTimer = setInterval(() => updateTunnelStatus(''), 5000) if (node.tunnelStatusRefreshTimer && typeof node.tunnelStatusRefreshTimer.unref === 'function') node.tunnelStatusRefreshTimer.unref() } catch (e) { /* ignore */ } updateStatus({ fill: 'grey', shape: 'dot', text: 'Starting KNX/IP Server...' }) Promise.resolve() .then(() => node.tunnelServer.start()) .then(() => updateTunnelStatus('')) .catch((err) => { updateStatus({ fill: 'red', shape: 'dot', text: `Tunnel start failed: ${err.message}` }) node.error(err) }) } node.on('input', function (msg) { try { const parsed = parseIncoming(msg) if (node.respectRoutingCounter && parsed && parsed.hopCount === 0) { node.setNodeStatus({ fill: 'grey', shape: 'ring', text: 'Dropped (routing counter 0)', payload: parsed.event || '', GA: parsed.destination, dpt: '', devicename: parsed.source || '' }) return } if (node.decrementRoutingCounter && parsed && parsed.cemiHex && parsed.hopCount !== null && parsed.hopCount > 0) { const dec = decrementHopCountInCemiHex(parsed.cemiHex) if (dec) { if (node.respectRoutingCounter && dec.newHopCount === 0) { node.setNodeStatus({ fill: 'grey', shape: 'ring', text: 'Dropped (routing counter 0)', payload: parsed.event || '', GA: parsed.destination, dpt: '', devicename: parsed.source || '' }) return } parsed.cemiHex = dec.cemiHex parsed.hopCount = dec.newHopCount } } if (node.mode === 'server') { if (!canForwardToTunnel(parsed)) return forwardToTunnel(parsed) } else { if (!canForwardToGateway(parsed)) return forwardToBus(parsed) } node.setNodeStatus({ fill: 'green', shape: 'dot', text: 'Forwarded', payload: parsed.event || '', GA: parsed.destination, dpt: '', devicename: parsed.source || '' }) } catch (error) { node.setNodeStatus({ fill: 'red', shape: 'dot', text: `Forward error: ${error.message || error}`, payload: '', GA: '', dpt: '', devicename: '' }) node.error(error) } }) node.on('close', function (done) { const shutdown = async () => { if (node.serverKNX) { try { node.serverKNX.removeClient(node) } catch (e) { /* ignore */ } } if (node.tunnelAdvertiseHostTimer) { try { clearInterval(node.tunnelAdvertiseHostTimer) } catch (e) { /* ignore */ } node.tunnelAdvertiseHostTimer = null } if (node.tunnelStatusRefreshTimer) { try { clearInterval(node.tunnelStatusRefreshTimer) } catch (e) { /* ignore */ } node.tunnelStatusRefreshTimer = null } if (node.tunnelServer) { try { await node.tunnelServer.stop() } catch (e) { /* ignore */ } node.tunnelServer = null } } Promise.resolve(shutdown()).then(() => done()).catch(() => done()) }) // On each deploy, unsubscribe+resubscribe if (node.serverKNX) { try { node.serverKNX.removeClient(node) } catch (e) { /* ignore */ } try { node.serverKNX.addClient(node) } catch (e) { /* ignore */ } } startTunnelServerIfNeeded() if (node.mode !== 'server') updateStatus({ fill: 'grey', shape: 'dot', text: 'Routing ready' }) } RED.nodes.registerType('knxUltimateMultiRouting', knxUltimateMultiRouting) }