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) • 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)
}