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
JavaScript
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' }
}
})
}