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 and ETS group address importer. Easy to use and highly configurable.

501 lines (442 loc) 17.1 kB
const loggerClass = require('./utils/sysLogger'); module.exports = function (RED) { function knxUltimateIoTBridge(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); } }; if (!node.serverKNX) { pushStatus({ fill: 'red', shape: 'dot', text: '[THE GATEWAY NODE HAS BEEN DISABLED]' }); return; } node.name = config.name || 'KNX IoT Bridge'; node.outputtopic = config.outputtopic || ''; node.listenallga = true; node.notifyreadrequest = true; node.notifyresponse = true; node.notifywrite = true; node.initialread = false; node.outputtype = 'write'; node.outputRBE = 'false'; node.inputRBE = 'false'; node.emitOnChangeOnly = config.emitOnChangeOnly === true; node.readOnDeploy = config.readOnDeploy === true; node.acceptFlowInput = config.acceptFlowInput !== false; // default true node.mappings = Array.isArray(config.mappings) ? config.mappings : []; const safeNumber = (value, fallback = 0) => { if (value === null || value === undefined || value === '') return fallback; const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; }; const sanitizeString = (value) => { if (typeof value === 'string') return value.trim(); if (value === undefined || value === null) return ''; return String(value).trim(); }; const normaliseDirection = (value) => { switch (value) { case 'knx-to-iot': case 'iot-to-knx': case 'bidirectional': return value; default: return 'bidirectional'; } }; const normaliseType = (value) => { switch (value) { case 'mqtt': case 'rest': case 'modbus': return value; default: return 'mqtt'; } }; const ensureId = (value) => { const id = sanitizeString(value); return id !== '' ? id : (RED.util && typeof RED.util.generateId === 'function' ? RED.util.generateId() : Math.random().toString(16).slice(2)); }; const cleanMapping = (raw) => { const mapping = { ...raw }; mapping.id = ensureId(mapping.id); mapping.label = sanitizeString(mapping.label) || mapping.id; mapping.ga = sanitizeString(mapping.ga); mapping.dpt = sanitizeString(mapping.dpt); mapping.direction = normaliseDirection(mapping.direction); mapping.iotType = normaliseType(mapping.iotType); mapping.target = sanitizeString(mapping.target); mapping.method = sanitizeString(mapping.method) || 'POST'; mapping.modbusFunction = sanitizeString(mapping.modbusFunction) || 'writeHoldingRegister'; mapping.scale = safeNumber(mapping.scale, 1); mapping.offset = safeNumber(mapping.offset, 0); mapping.template = sanitizeString(mapping.template); mapping.property = sanitizeString(mapping.property); mapping.enabled = mapping.enabled === false ? false : true; mapping.timeout = safeNumber(mapping.timeout, 0); mapping.retry = safeNumber(mapping.retry, 0); return mapping; }; node.mappings = node.mappings.map(cleanMapping).filter((m) => m.ga !== '' && m.enabled); node.stateById = new Map(); node.gaIndex = new Map(); node.targetIndex = new Map(); const registerMapping = (mapping) => { const existing = node.gaIndex.get(mapping.ga) || []; existing.push(mapping); node.gaIndex.set(mapping.ga, existing); const key = mapping.iotType + '::' + (mapping.target || mapping.label); const targetList = node.targetIndex.get(key) || []; targetList.push(mapping); node.targetIndex.set(key, targetList); }; node.mappings.forEach(registerMapping); const buildStatusText = (baseText) => { const total = node.mappings.length; return `${total} map(s) ${baseText || ''}`.trim(); }; const updateIdleStatus = () => { pushStatus({ fill: 'grey', shape: 'ring', text: buildStatusText('ready') }); }; 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 */ } node.setNodeStatus = ({ fill = 'grey', shape = 'ring', text = '', mapping, payload }) => { try { const extra = mapping ? ` ${mapping.ga}${mapping.target || mapping.iotType}` : ''; const valueStr = payload === undefined ? '' : ` ${JSON.stringify(payload)}`; pushStatus({ fill, shape, text: buildStatusText(`${text}${extra}${valueStr}`) }); } catch (error) { if (node.sysLogger) node.sysLogger.error(`Status update failed: ${error.message}`); } }; const isBooleanDpt = (dpt) => typeof dpt === 'string' && dpt.startsWith('1.'); const toBoolean = (value) => { if (typeof value === 'boolean') return value; if (typeof value === 'number') return value !== 0; if (typeof value === 'string') { const lowered = value.trim().toLowerCase(); if (['true', '1', 'on', 'yes', 'open'].includes(lowered)) return true; if (['false', '0', 'off', 'no', 'close', 'closed'].includes(lowered)) return false; } if (value && typeof value === 'object') { if (Object.prototype.hasOwnProperty.call(value, 'value')) return toBoolean(value.value); if (Object.prototype.hasOwnProperty.call(value, 'state')) return toBoolean(value.state); } return Boolean(value); }; const applyScale = (value, mapping) => { if (typeof value === 'number' && Number.isFinite(value)) { return (value * mapping.scale) + mapping.offset; } return value; }; const revertScale = (value, mapping) => { if (typeof value === 'number' && Number.isFinite(value)) { const scaled = value - mapping.offset; if (mapping.scale === 0) return scaled; return scaled / mapping.scale; } return value; }; const valuesAreEqual = (a, b) => { if (a === b) return true; if (typeof a === 'number' && typeof b === 'number') { if (Number.isNaN(a) && Number.isNaN(b)) return true; return a === b; } if (typeof a === 'boolean' && typeof b === 'boolean') return a === b; try { return JSON.stringify(a) === JSON.stringify(b); } catch (error) { return false; } }; const renderTemplate = (template, context) => { if (!template) return context.value; return template .replace(/{{\s*value\s*}}/g, String(context.value)) .replace(/{{\s*ga\s*}}/g, context.ga) .replace(/{{\s*target\s*}}/g, context.target) .replace(/{{\s*type\s*}}/g, context.type) .replace(/{{\s*label\s*}}/g, context.label) .replace(/{{\s*isoTimestamp\s*}}/g, new Date().toISOString()); }; const buildOutMessage = (mapping, value, meta) => { const context = { value, ga: mapping.ga, target: mapping.target, type: mapping.iotType, label: mapping.label, isoTimestamp: new Date().toISOString() }; const payload = renderTemplate(mapping.template, context); const topic = mapping.iotType === 'mqtt' ? (mapping.target || node.outputtopic || mapping.ga) : (node.outputtopic || mapping.target || mapping.ga); const out = { topic, payload, bridge: { id: mapping.id, label: mapping.label, type: mapping.iotType, direction: 'knx-to-iot', target: mapping.target, method: mapping.method, modbusFunction: mapping.modbusFunction, property: mapping.property, timeout: mapping.timeout, retry: mapping.retry, scale: mapping.scale, offset: mapping.offset }, knx: { ga: mapping.ga, dpt: mapping.dpt, event: meta.event, source: meta.source, ts: meta.ts, raw: meta.raw } }; if (mapping.iotType === 'rest') { out.url = mapping.target || node.outputtopic || ''; out.method = mapping.method || 'POST'; if (mapping.property) out.property = mapping.property; out.timeout = mapping.timeout; out.retry = mapping.retry; out.headers = meta.headers || {}; } if (mapping.iotType === 'modbus') { out.modbusFunction = mapping.modbusFunction; out.address = mapping.target; if (mapping.property) out.property = mapping.property; out.timeout = mapping.timeout; out.retry = mapping.retry; } if (mapping.iotType === 'mqtt' && mapping.property) { out.property = mapping.property; } return out; }; const rememberKnxValue = (mapping, value) => { const current = node.stateById.get(mapping.id) || {}; current.lastKnxValue = value; current.updatedAt = Date.now(); node.stateById.set(mapping.id, current); }; const rememberIoTValue = (mapping, value) => { const current = node.stateById.get(mapping.id) || {}; current.lastIoTValue = value; current.updatedAt = Date.now(); node.stateById.set(mapping.id, current); }; const shouldEmitKnxValue = (mapping, value) => { if (!node.emitOnChangeOnly) return true; const current = node.stateById.get(mapping.id); if (!current || current.lastKnxValue === undefined) return true; return !valuesAreEqual(current.lastKnxValue, value); }; const findMappingsByGA = (ga) => node.gaIndex.get(ga) || []; const matchMappingForIoT = (msg) => { const bridge = msg.bridge || {}; const type = bridge.type || (msg.iotType) || 'mqtt'; const target = bridge.target || msg.topic || ''; const id = bridge.id || bridge.mappingId; if (id) { const mapping = node.mappings.find((m) => m.id === id); if (mapping) return mapping; } const key = type + '::' + target; const list = node.targetIndex.get(key); if (list && list.length > 0) return list[0]; if (target && !target.includes('::')) { for (const m of node.mappings) { if (m.target === target) return m; } } return null; }; const sendToKNX = (mapping, payload, meta = {}) => { try { if (!node.serverKNX || typeof node.serverKNX.sendKNXTelegramToKNXEngine !== 'function') { throw new Error('KNX gateway not available'); } const telegram = { grpaddr: mapping.ga, payload, dpt: mapping.dpt || '', outputtype: meta.outputtype || 'write', nodecallerid: node.id }; node.serverKNX.sendKNXTelegramToKNXEngine(telegram); } catch (error) { if (node.sysLogger) { node.sysLogger.error(`sendToKNX failed (${mapping.ga}): ${error.message}`); } else { RED.log.error(`knxUltimateIoTBridge sendToKNX failed (${mapping.ga}): ${error.message}`); } throw error; } }; const handleKnxTelegram = (msg) => { try { if (!msg) return; const destination = msg.knx && msg.knx.destination ? msg.knx.destination : sanitizeString(msg.topic); if (!destination) return; const meta = { event: msg.knx ? msg.knx.event : undefined, source: msg.knx ? msg.knx.source : undefined, ts: Date.now(), raw: msg.knx || {} }; if (meta.event === 'GroupValue_Read') { // Skip read indications; we only emit when value is provided. return; } const mappings = findMappingsByGA(destination); if (!mappings.length) return; for (const mapping of mappings) { if (mapping.direction === 'iot-to-knx') continue; let value = msg.payload; if (isBooleanDpt(mapping.dpt)) { value = toBoolean(value); } if (typeof value === 'number') { value = applyScale(value, mapping); } if (!shouldEmitKnxValue(mapping, value)) continue; const outMsg = buildOutMessage(mapping, value, meta); rememberKnxValue(mapping, value); node.setNodeStatus({ fill: 'green', shape: 'dot', text: 'KNX→IoT', mapping, payload: value }); node.send([outMsg, null]); } } catch (error) { if (node.sysLogger) { node.sysLogger.error(`handleKnxTelegram error: ${error.message}`); } else { RED.log.error(`knxUltimateIoTBridge handleKnxTelegram error: ${error.message}`); } } }; node.handleSend = handleKnxTelegram; node.on('input', (msg, send, done) => { if (!node.acceptFlowInput) { if (done) done(); return; } const bridgeMapping = matchMappingForIoT(msg); if (!bridgeMapping) { node.setNodeStatus({ fill: 'yellow', shape: 'ring', text: 'No mapping for input', payload: msg.topic }); if (done) done(); return; } if (bridgeMapping.direction === 'knx-to-iot') { node.setNodeStatus({ fill: 'yellow', shape: 'ring', text: 'Mapping is KNX→IoT only', mapping: bridgeMapping }); if (done) done(); return; } let value = msg.payload; if (isBooleanDpt(bridgeMapping.dpt)) { value = toBoolean(value); } else if (typeof value === 'string' && value.trim() !== '' && !Number.isNaN(Number(value))) { value = Number(value); } if (typeof value === 'number') { value = revertScale(value, bridgeMapping); } try { sendToKNX(bridgeMapping, value); rememberIoTValue(bridgeMapping, msg.payload); const ack = { topic: bridgeMapping.ga, payload: value, bridge: { id: bridgeMapping.id, label: bridgeMapping.label, type: bridgeMapping.iotType, direction: 'iot-to-knx', target: bridgeMapping.target, method: bridgeMapping.method, modbusFunction: bridgeMapping.modbusFunction, property: bridgeMapping.property, timeout: bridgeMapping.timeout, retry: bridgeMapping.retry } }; if (bridgeMapping.iotType === 'rest') { ack.url = bridgeMapping.target || ''; ack.method = bridgeMapping.method || 'POST'; } if (bridgeMapping.iotType === 'modbus') { ack.address = bridgeMapping.target; ack.modbusFunction = bridgeMapping.modbusFunction; } node.setNodeStatus({ fill: 'blue', shape: 'dot', text: 'IoT→KNX', mapping: bridgeMapping, payload: msg.payload }); if (send) send([null, ack]); else node.send([null, ack]); if (done) done(); } catch (error) { node.setNodeStatus({ fill: 'red', shape: 'dot', text: error.message, mapping: bridgeMapping }); if (done) done(error); } }); node.on('close', (done) => { if (node.serverKNX && typeof node.serverKNX.removeClient === 'function') { try { node.serverKNX.removeClient(node); } catch (error) { /* empty */ } } if (done) done(); }); const registerClient = () => { if (node.serverKNX) { try { if (typeof node.serverKNX.removeClient === 'function') { node.serverKNX.removeClient(node); } if (typeof node.serverKNX.addClient === 'function') { node.serverKNX.addClient(node); } } catch (error) { if (node.sysLogger) node.sysLogger.error(`registerClient failed: ${error.message}`); } } }; const issueInitialReads = () => { if (!node.readOnDeploy) return; if (!node.serverKNX || typeof node.serverKNX.sendKNXTelegramToKNXEngine !== 'function') return; for (const mapping of node.mappings) { if (mapping.direction === 'iot-to-knx') continue; if (!mapping.ga) continue; try { node.serverKNX.sendKNXTelegramToKNXEngine({ grpaddr: mapping.ga, payload: '', dpt: '', outputtype: 'read', nodecallerid: node.id }); } catch (error) { if (node.sysLogger) node.sysLogger.error(`Initial read failed (${mapping.ga}): ${error.message}`); } } }; registerClient(); updateIdleStatus(); issueInitialReads(); } RED.nodes.registerType('knxUltimateIoTBridge', knxUltimateIoTBridge); };