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.

633 lines (570 loc) 22.9 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 MQTT - IoT' 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 : [] // Operation mode: 'iot' (classic IoT mappings, default) or 'homeassistant' (native // MQTT bridge with Home Assistant discovery for every group address + cover/climate). node.nodeMode = config.nodeMode === 'homeassistant' ? 'homeassistant' : 'iot' node.mqttUrl = typeof config.mqttUrl === 'string' ? config.mqttUrl.trim() : '' node.mqttBaseTopic = typeof config.mqttBaseTopic === 'string' && config.mqttBaseTopic.trim() !== '' ? config.mqttBaseTopic.trim() : 'knx-ultimate' node.mqttDiscovery = config.mqttDiscovery !== false && config.mqttDiscovery !== 'false' node.mqttDiscoveryPrefix = typeof config.mqttDiscoveryPrefix === 'string' && config.mqttDiscoveryPrefix.trim() !== '' ? config.mqttDiscoveryPrefix.trim() : 'homeassistant' node.mqttCustomEntities = Array.isArray(config.mqttCustomEntities) ? config.mqttCustomEntities : [] // Group addresses to expose as simple entities. Once the user curates the list // (mqttExposeConfigured), only the listed GAs are exposed; otherwise all imported GAs are. node.mqttExposeConfigured = config.mqttExposeConfigured === true node.mqttExposedGAs = Array.isArray(config.mqttExposedGAs) ? config.mqttExposedGAs : [] node.mqttBridge = null 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 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}`) } } // HOME ASSISTANT (MQTT) BRIDGE ----------------------------------------------------------- node.startMqttBridge = () => { if (node.mqttBridge !== null) return // already running if (!node.mqttUrl) { pushStatus({ fill: 'red', shape: 'dot', text: 'MQTT broker URL missing' }) return } try { // Lazy-require so the node still loads if the optional mqtt dependency is missing. const { createMqttBridge } = require('./lib/mqtt-bridge.js') node.mqttBridge = createMqttBridge({ node, url: node.mqttUrl, baseTopic: node.mqttBaseTopic, discovery: node.mqttDiscovery, discoveryPrefix: node.mqttDiscoveryPrefix, username: node.credentials ? node.credentials.mqttUsername : undefined, password: node.credentials ? node.credentials.mqttPassword : undefined, groupAddresses: (node.serverKNX && Array.isArray(node.serverKNX.csv)) ? node.serverKNX.csv : [], customEntities: node.mqttCustomEntities, // null => expose all imported GAs (until the user curates the list). exposedGAs: node.mqttExposeConfigured ? node.mqttExposedGAs : null, onCommand: ({ ga, dpt, value }) => { // A Home Assistant command arrived: write it to the KNX bus. try { node.serverKNX.sendKNXTelegramToKNXEngine({ grpaddr: ga, payload: value, dpt, outputtype: 'write', nodecallerid: node.id }) } catch (error) { if (node.sysLogger) node.sysLogger.error('HA bridge write failed (' + ga + '): ' + error.message) } }, onStatus: (status) => { if (!status) return if (status.state === 'connected') { pushStatus({ fill: 'green', shape: 'dot', text: 'HA connected (' + (status.detail || '0') + ' entities)' }) } else if (status.state === 'error') { pushStatus({ fill: 'red', shape: 'dot', text: 'MQTT ' + (status.detail || 'error') }) } else if (status.state === 'reconnect' || status.state === 'offline') { pushStatus({ fill: 'yellow', shape: 'ring', text: 'MQTT ' + status.state }) } } }) node.mqttBridge.connect() pushStatus({ fill: 'grey', shape: 'ring', text: 'HA mode: connecting (' + node.mqttBridge.entityCount + ' entities)' }) } catch (error) { node.mqttBridge = null if (node.sysLogger) node.sysLogger.error('startMqttBridge failed: ' + error.message) pushStatus({ fill: 'red', shape: 'dot', text: 'MQTT bridge: ' + error.message }) } } node.stopMqttBridge = (done) => { const bridge = node.mqttBridge node.mqttBridge = null let called = false const cb = () => { if (called) return called = true if (typeof done === 'function') { try { done() } catch (error) { /* ignore */ } } } if (!bridge) { cb() return } try { bridge.close(cb) } catch (error) { if (node.sysLogger) node.sysLogger.error('stopMqttBridge error: ' + (error && error.message)) cb() } } 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 // Home Assistant mode: mirror the decoded value to MQTT and stop (no IoT mappings). if (node.nodeMode === 'homeassistant') { const event = msg.knx ? msg.knx.event : undefined if (node.mqttBridge && event !== 'GroupValue_Read') { node.mqttBridge.publishState(destination, msg.payload) } 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) => { // In Home Assistant mode, commands flow in over MQTT, not via the flow input. if (node.nodeMode === 'homeassistant') { if (done) done() return } 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) => { // Always call done() exactly once, even if something throws, so a deploy / Node-RED exit // is never blocked by the bridge teardown. let finished = false const finish = () => { if (finished) return finished = true if (typeof done === 'function') { try { done() } catch (error) { /* ignore */ } } } try { if (node.serverKNX && typeof node.serverKNX.removeClient === 'function') { try { node.serverKNX.removeClient(node) } catch (error) { /* empty */ } } // Stop the MQTT bridge (best-effort, hard-capped so redeploy never blocks on the broker). node.stopMqttBridge(finish) } catch (error) { if (node.sysLogger) node.sysLogger.error('close handler error: ' + (error && error.message)) finish() } }) 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() if (node.nodeMode === 'homeassistant') { node.startMqttBridge() } else { updateIdleStatus() issueInitialReads() } } RED.nodes.registerType('knxUltimateIoTBridge', knxUltimateIoTBridge, { credentials: { mqttUsername: { type: 'text' }, mqttPassword: { type: 'password' } } }) }