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.
668 lines (604 loc) • 25.1 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'
// Home Assistant bus wiring: 'standalone' (default) talks to the KNX gateway directly,
// 'flow' uses the node's input/output pins instead (wire a KNXUltimate universal node to
// both): the input pin feeds KNX bus telegrams in, the output pin emits telegrams to write.
node.haBusMode = config.haBusMode === 'flow' ? 'flow' : 'standalone'
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 : []
// Group addresses the user marked as read-only: they are still exposed (state is
// published to HA) but never accept commands back to the KNX bus.
node.mqttReadOnlyGAs = Array.isArray(config.mqttReadOnlyGAs) ? config.mqttReadOnlyGAs : []
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,
// GAs exposed as read-only (state only, no command topic back to KNX).
readOnlyGAs: node.mqttReadOnlyGAs,
onCommand: ({ ga, dpt, value }) => {
// A Home Assistant command arrived: write it to the KNX bus.
try {
if (node.haBusMode === 'flow') {
// Flow mode: emit a message on the (single) output pin for a downstream
// KNXUltimate universal node to write to the bus (destination + dpt + payload).
node.send({
topic: ga,
destination: ga,
dpt: dpt || '',
payload: value
})
return
}
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
}
}
// Home Assistant mode: mirror a decoded KNX telegram to MQTT. Works with a telegram coming
// from the gateway (standalone) or from the input pin (flow mode); both share the shape
// produced by the KNXUltimate universal node ({ payload, knx: { destination, event } }).
const publishKnxToMqtt = (msg) => {
if (!msg || !node.mqttBridge) return
const destination = msg.knx && msg.knx.destination ? msg.knx.destination : sanitizeString(msg.topic)
if (!destination) return
const event = msg.knx ? msg.knx.event : undefined
if (event === 'GroupValue_Read') return // read requests carry no value
node.mqttBridge.publishState(destination, msg.payload)
}
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') {
publishKnxToMqtt(msg)
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. The one
// exception is 'flow' bus mode, where the input pin carries KNX bus telegrams (from a
// KNXUltimate universal node) that must be mirrored to MQTT.
if (node.nodeMode === 'homeassistant') {
if (node.haBusMode === 'flow') publishKnxToMqtt(msg)
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}`)
}
}
}
// In HA 'flow' mode the KNX telegrams arrive on the input pin, so we must NOT subscribe to
// the gateway's client feed (that would double-publish and bypass the intended wiring).
const useFlowBus = node.nodeMode === 'homeassistant' && node.haBusMode === 'flow'
if (!useFlowBus) registerClient()
if (node.nodeMode === 'homeassistant') {
node.startMqttBridge()
} else {
updateIdleStatus()
issueInitialReads()
}
}
RED.nodes.registerType('knxUltimateIoTBridge', knxUltimateIoTBridge, {
credentials: {
mqttUsername: { type: 'text' },
mqttPassword: { type: 'password' }
}
})
}