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.
501 lines (442 loc) • 16.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 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
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)
}