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, and KNX routing between interfaces. Easy to use and highly configurable.
574 lines (511 loc) • 20.4 kB
JavaScript
// KNX Router Filter - filters RAW telegram objects between KNX Multi Routing nodes
const normalizeText = (value) => String(value || '').trim()
const normalizeHex = (value) => {
if (value === undefined || value === null) return ''
const s = String(value).trim()
if (!s) return ''
return s.replace(/^0x/i, '').replace(/[^0-9a-fA-F]/g, '')
}
const parseBoolean = (value, fallback) => {
if (value === undefined || value === null) return fallback
if (typeof value === 'boolean') return value
if (typeof value === 'number') return value !== 0
const s = String(value).trim().toLowerCase()
if (s === 'true' || s === '1' || s === 'yes' || s === 'on') return true
if (s === 'false' || s === '0' || s === 'no' || s === 'off') return false
return fallback
}
const normalizeMultiline = (value) => {
if (value === undefined || value === null) return ''
if (Array.isArray(value)) return value.map(v => String(v)).join('\n')
return String(value)
}
let _knxultimateCache = null
const getKnxultimate = () => {
if (_knxultimateCache) return _knxultimateCache
_knxultimateCache = require('knxultimate')
return _knxultimateCache
}
const splitPatterns = (raw) => {
const s = normalizeText(raw)
if (!s) return []
return s
.split(/[\r\n,;]+/)
.map(t => t.trim())
.filter(Boolean)
}
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const compileAddressPatterns = ({ raw, kind }) => {
const tokens = splitPatterns(raw)
const regexes = []
for (const token of tokens) {
if (token === '*') {
regexes.push(/^.*$/)
continue
}
if (token.toLowerCase().startsWith('re:')) {
const reSrc = token.slice(3).trim()
if (!reSrc) continue
try {
regexes.push(new RegExp(reSrc))
} catch (error) {
// ignore invalid regex
}
continue
}
if (kind === 'ga') {
const parts = token.split('/').map(p => p.trim()).filter(p => p !== '')
if (parts.length === 0) continue
while (parts.length < 3) parts.push('*')
const segs = parts.slice(0, 3).map((p) => {
if (p === '*' || p === 'x' || p === 'X') return '\\d+'
if (/^\\d+$/.test(p)) return escapeRegExp(p)
// fallback: allow exact match for weird inputs
return escapeRegExp(p)
})
regexes.push(new RegExp(`^${segs[0]}/${segs[1]}/${segs[2]}$`))
continue
}
// kind === 'src' (physical address)
const parts = token.split('.').map(p => p.trim()).filter(p => p !== '')
if (parts.length === 0) continue
while (parts.length < 3) parts.push('*')
const segs = parts.slice(0, 3).map((p) => {
if (p === '*' || p === 'x' || p === 'X') return '\\d+'
if (/^\\d+$/.test(p)) return escapeRegExp(p)
return escapeRegExp(p)
})
regexes.push(new RegExp(`^${segs[0]}\\.${segs[1]}\\.${segs[2]}$`))
}
return regexes
}
const matchesAny = (value, regexes) => {
if (!regexes || regexes.length === 0) return false
const s = String(value || '')
if (!s) return false
for (const re of regexes) {
try {
if (re.test(s)) return true
} catch (e) { /* ignore */ }
}
return false
}
const compileRewriteRules = ({ raw, kind }) => {
const lines = splitPatterns(raw)
const rules = []
for (const line of lines) {
const trimmed = String(line || '').trim()
if (!trimmed) continue
if (trimmed.startsWith('#')) continue
const sep = trimmed.includes('=>') ? '=>' : (trimmed.includes('->') ? '->' : null)
if (!sep) continue
const parts = trimmed.split(sep)
if (parts.length < 2) continue
const left = parts[0].trim()
const right = parts.slice(1).join(sep).trim()
if (!left || !right) continue
// Regex rule
if (left.toLowerCase().startsWith('re:')) {
const reSrc = left.slice(3).trim()
if (!reSrc) continue
try {
rules.push({
type: 'regex',
re: new RegExp(reSrc),
replacement: right,
source: trimmed
})
} catch (error) { /* ignore invalid */ }
continue
}
// Wildcard rule (ga/src)
const delimiter = kind === 'ga' ? '/' : '.'
const leftParts = left.split(delimiter).map(p => p.trim()).filter(p => p !== '')
if (leftParts.length === 0) continue
while (leftParts.length < 3) leftParts.push('*')
const leftSegs = leftParts.slice(0, 3).map((p) => {
if (p === '*' || p === 'x' || p === 'X') return '(\\d+)'
if (/^\\d+$/.test(p)) return escapeRegExp(p)
return escapeRegExp(p)
})
const re = new RegExp(`^${leftSegs.join(kind === 'ga' ? '\\/' : '\\.')}$`)
// replacement: allow '*' placeholders to map capture groups in order
let replacement = right
let captureIndex = 1
replacement = replacement.replace(/\*/g, () => `$${captureIndex++}`)
rules.push({ type: 'wildcard', re, replacement, source: trimmed })
}
return rules
}
const applyRewrite = (value, rules) => {
const s = String(value || '')
if (!s || !rules || rules.length === 0) return s
for (const rule of rules) {
try {
if (rule.re && rule.re.test(s)) {
const out = s.replace(rule.re, rule.replacement)
return out
}
} catch (e) { /* ignore */ }
}
return s
}
const setNestedIfExists = (obj, path, value) => {
try {
if (!obj || typeof obj !== 'object') return false
const parts = path.split('.')
let cur = obj
for (let i = 0; i < parts.length - 1; i++) {
if (!cur || typeof cur !== 'object') return false
if (!Object.prototype.hasOwnProperty.call(cur, parts[i])) return false
cur = cur[parts[i]]
}
const last = parts[parts.length - 1]
if (!Object.prototype.hasOwnProperty.call(cur, last)) return false
cur[last] = value
return true
} catch (e) {
return false
}
}
const getPayloadObject = (msg) => {
if (!msg || typeof msg !== 'object') return null
if (!msg.payload || typeof msg.payload !== 'object' || Buffer.isBuffer(msg.payload)) return null
return msg.payload
}
const getKnxObject = (msg) => {
const payload = getPayloadObject(msg)
if (payload && payload.knx && typeof payload.knx === 'object' && !Buffer.isBuffer(payload.knx)) return payload.knx
if (msg && msg.knx && typeof msg.knx === 'object' && !Buffer.isBuffer(msg.knx)) return msg.knx
return null
}
const getCemiHexFromMsg = (msg) => {
const knx = getKnxObject(msg)
if (!knx) return ''
const cemi = knx.cemi
if (!cemi) return ''
if (typeof cemi === 'string') return cemi
if (typeof cemi === 'object' && !Buffer.isBuffer(cemi) && cemi.hex) return cemi.hex
return ''
}
const setCemiHexOnMsg = (msg, cemiHex) => {
const knx = getKnxObject(msg)
if (!knx) return false
if (knx.cemi && typeof knx.cemi === 'object' && !Buffer.isBuffer(knx.cemi) && 'hex' in knx.cemi) {
knx.cemi.hex = cemiHex
return true
}
knx.cemi = { hex: cemiHex }
return true
}
const isIndividualAddressString = (value) => /^\d{1,2}\.\d{1,2}\.\d{1,3}$/.test(String(value || '').trim())
const isGroupAddressString = (value) => /^\d{1,2}\/\d{1,2}\/\d{1,3}$/.test(String(value || '').trim())
const syncCemiWithKnxFields = (msg) => {
const knx = getKnxObject(msg)
if (!knx) return false
const cemiHex = getCemiHexFromMsg(msg)
const clean = normalizeHex(cemiHex)
if (!clean || clean.length % 2 !== 0) return false
let cemi
try {
const { KNXTunnelingRequest } = getKnxultimate()
cemi = KNXTunnelingRequest.parseCEMIMessage(Buffer.from(clean, 'hex'), 0)
} catch (e) {
return false
}
if (!cemi || !cemi.control) return false
let updated = false
try {
if (isIndividualAddressString(knx.source)) {
const { KNXAddress } = getKnxultimate()
cemi.srcAddress = KNXAddress.createFromString(String(knx.source).trim(), KNXAddress.TYPE_INDIVIDUAL)
updated = true
}
} catch (e) { /* ignore */ }
try {
if (isGroupAddressString(knx.destination)) {
const { KNXAddress } = getKnxultimate()
cemi.dstAddress = KNXAddress.createFromString(String(knx.destination).trim(), KNXAddress.TYPE_GROUP)
try { cemi.control.addressType = 1 } catch (e2) { /* ignore */ }
updated = true
}
} catch (e) { /* ignore */ }
if (!updated) return false
try {
const outHex = cemi.toBuffer().toString('hex')
setCemiHexOnMsg(msg, outHex)
try { knx.cemi.hopCount = knx.cemi.hopCount } catch (e) { /* ignore */ }
return true
} catch (e) {
return false
}
}
const attachFilterMeta = (msg, meta) => {
try {
const payload = getPayloadObject(msg)
if (!payload) return
payload.knxRouterFilter = meta
} catch (e) { /* ignore */ }
}
const getOrCreateMeta = (msg) => {
const payload = getPayloadObject(msg)
if (!payload) return null
if (!payload.knxRouterFilter || typeof payload.knxRouterFilter !== 'object' || Buffer.isBuffer(payload.knxRouterFilter)) {
payload.knxRouterFilter = {}
}
return payload.knxRouterFilter
}
module.exports = function (RED) {
function knxUltimateRouterFilter (config) {
RED.nodes.createNode(this, config)
const node = this
node.name = config.name || 'KNX Router Filter'
node.allowWrite = parseBoolean(config.allowWrite, true)
node.allowResponse = parseBoolean(config.allowResponse, true)
node.allowRead = parseBoolean(config.allowRead, true)
node.gaMode = config.gaMode || 'off' // off|allow|block
node.gaPatterns = config.gaPatterns || ''
node.srcMode = config.srcMode || 'off' // off|allow|block
node.srcPatterns = config.srcPatterns || ''
node.rewriteGA = parseBoolean(config.rewriteGA, false)
node.gaRewriteRules = config.gaRewriteRules || ''
node.rewriteSource = parseBoolean(config.rewriteSource, false)
node.srcRewriteRules = config.srcRewriteRules || ''
let gaRegexes = compileAddressPatterns({ raw: node.gaPatterns, kind: 'ga' })
let srcRegexes = compileAddressPatterns({ raw: node.srcPatterns, kind: 'src' })
let gaRewrite = compileRewriteRules({ raw: node.gaRewriteRules, kind: 'ga' })
let srcRewrite = compileRewriteRules({ raw: node.srcRewriteRules, kind: 'src' })
let passed = 0
let dropped = 0
const providerCache = new Map()
const getGatewayIdFromMsg = (msg) => {
try {
const payload = msg && msg.payload && typeof msg.payload === 'object' && !Buffer.isBuffer(msg.payload) ? msg.payload : null
const routing = (payload && payload.knxMultiRouting) ? payload.knxMultiRouting : (msg && msg.knxMultiRouting ? msg.knxMultiRouting : null)
const id = routing && routing.gateway && routing.gateway.id ? String(routing.gateway.id) : ''
return id
} catch (e) {
return ''
}
}
const resolveProvider = (gatewayId) => {
if (!gatewayId) return null
if (providerCache.has(gatewayId)) return providerCache.get(gatewayId) || null
try {
const p = RED.nodes.getNode(gatewayId) || null
providerCache.set(gatewayId, p)
return p
} catch (e) {
providerCache.set(gatewayId, null)
return null
}
}
const applyStatus = (msg, status) => {
try {
const gatewayId = getGatewayIdFromMsg(msg)
const provider = resolveProvider(gatewayId)
if (provider && typeof provider.applyStatusUpdate === 'function') {
provider.applyStatusUpdate(node, status)
} else {
node.status(status)
}
} catch (e) {
try { node.status(status) } catch (e2) { /* ignore */ }
}
}
const setCountersStatus = (msg) => {
applyStatus(msg, { fill: 'grey', shape: 'dot', text: `pass ${passed} / drop ${dropped}` })
}
let configStatusTimer = null
const setConfigStatus = (msg, text) => {
try {
if (configStatusTimer) clearTimeout(configStatusTimer)
applyStatus(msg, { fill: 'blue', shape: 'ring', text: text || 'Config changed' })
configStatusTimer = setTimeout(() => setCountersStatus(null), 2000)
} catch (e) { /* ignore */ }
}
const rebuildCompiledRules = () => {
gaRegexes = compileAddressPatterns({ raw: node.gaPatterns, kind: 'ga' })
srcRegexes = compileAddressPatterns({ raw: node.srcPatterns, kind: 'src' })
gaRewrite = compileRewriteRules({ raw: node.gaRewriteRules, kind: 'ga' })
srcRewrite = compileRewriteRules({ raw: node.srcRewriteRules, kind: 'src' })
}
const applySetConfig = (msg) => {
const sc = msg && msg.setConfig && typeof msg.setConfig === 'object' && !Buffer.isBuffer(msg.setConfig) ? msg.setConfig : null
if (!sc) return false
const changedKeys = []
const markChanged = (key) => { if (!changedKeys.includes(key)) changedKeys.push(key) }
if (Object.prototype.hasOwnProperty.call(sc, 'name')) {
node.name = normalizeText(sc.name) || node.name
markChanged('name')
}
if (Object.prototype.hasOwnProperty.call(sc, 'allowWrite')) {
node.allowWrite = parseBoolean(sc.allowWrite, node.allowWrite)
markChanged('allowWrite')
}
if (Object.prototype.hasOwnProperty.call(sc, 'allowResponse')) {
node.allowResponse = parseBoolean(sc.allowResponse, node.allowResponse)
markChanged('allowResponse')
}
if (Object.prototype.hasOwnProperty.call(sc, 'allowRead')) {
node.allowRead = parseBoolean(sc.allowRead, node.allowRead)
markChanged('allowRead')
}
if (Object.prototype.hasOwnProperty.call(sc, 'gaMode')) {
const m = normalizeText(sc.gaMode).toLowerCase()
if (m === 'off' || m === 'allow' || m === 'block') {
node.gaMode = m
markChanged('gaMode')
}
}
if (Object.prototype.hasOwnProperty.call(sc, 'gaPatterns')) {
node.gaPatterns = normalizeMultiline(sc.gaPatterns)
markChanged('gaPatterns')
}
if (Object.prototype.hasOwnProperty.call(sc, 'srcMode')) {
const m = normalizeText(sc.srcMode).toLowerCase()
if (m === 'off' || m === 'allow' || m === 'block') {
node.srcMode = m
markChanged('srcMode')
}
}
if (Object.prototype.hasOwnProperty.call(sc, 'srcPatterns')) {
node.srcPatterns = normalizeMultiline(sc.srcPatterns)
markChanged('srcPatterns')
}
if (Object.prototype.hasOwnProperty.call(sc, 'rewriteGA')) {
node.rewriteGA = parseBoolean(sc.rewriteGA, node.rewriteGA)
markChanged('rewriteGA')
}
if (Object.prototype.hasOwnProperty.call(sc, 'gaRewriteRules')) {
node.gaRewriteRules = normalizeMultiline(sc.gaRewriteRules)
markChanged('gaRewriteRules')
}
if (Object.prototype.hasOwnProperty.call(sc, 'rewriteSource')) {
node.rewriteSource = parseBoolean(sc.rewriteSource, node.rewriteSource)
markChanged('rewriteSource')
}
if (Object.prototype.hasOwnProperty.call(sc, 'srcRewriteRules')) {
node.srcRewriteRules = normalizeMultiline(sc.srcRewriteRules)
markChanged('srcRewriteRules')
}
if (changedKeys.length > 0) {
rebuildCompiledRules()
setConfigStatus(msg, `Config changed: ${changedKeys.join(', ')}`)
}
return true
}
const extractFields = (msg) => {
const payload = msg && msg.payload !== undefined ? msg.payload : null
const knx = (payload && payload.knx) ? payload.knx : (msg && msg.knx ? msg.knx : null)
const event = knx && knx.event ? String(knx.event) : ''
const destination = knx && (knx.destination || knx.grpaddr || knx.ga) ? String(knx.destination || knx.grpaddr || knx.ga) : ''
const source = knx && knx.source ? String(knx.source) : ''
return { event, destination, source }
}
const shouldPassEvent = (event) => {
if (!event) return true
if (event === 'GroupValue_Write') return node.allowWrite
if (event === 'GroupValue_Response') return node.allowResponse
if (event === 'GroupValue_Read') return node.allowRead
return true
}
const shouldPassAddress = ({ mode, value, regexes }) => {
if (!mode || mode === 'off') return true
if (!regexes || regexes.length === 0) return true
const isMatch = matchesAny(value, regexes)
if (mode === 'allow') return isMatch
if (mode === 'block') return !isMatch
return true
}
node.on('input', function (msg) {
try {
if (msg && Object.prototype.hasOwnProperty.call(msg, 'setConfig')) {
applySetConfig(msg)
return
}
const fields = extractFields(msg)
// If message doesn't look like a KNX raw telegram, pass it through unchanged.
if (!fields.event && !fields.destination && !fields.source) {
passed += 1
node.send([msg, null])
setCountersStatus(msg)
return
}
if (!shouldPassEvent(fields.event)) {
dropped += 1
attachFilterMeta(msg, { dropped: true, reason: 'event', event: fields.event })
node.send([null, msg])
setCountersStatus(msg)
return
}
if (!shouldPassAddress({ mode: node.gaMode, value: fields.destination, regexes: gaRegexes })) {
dropped += 1
attachFilterMeta(msg, { dropped: true, reason: 'ga', ga: fields.destination })
node.send([null, msg])
setCountersStatus(msg)
return
}
if (!shouldPassAddress({ mode: node.srcMode, value: fields.source, regexes: srcRegexes })) {
dropped += 1
attachFilterMeta(msg, { dropped: true, reason: 'source', source: fields.source })
node.send([null, msg])
setCountersStatus(msg)
return
}
passed += 1
let meta = getOrCreateMeta(msg) || {}
// Rewrite fields only for passed messages
if (fields.event || fields.destination || fields.source) {
const beforeGA = fields.destination
const beforeSrc = fields.source
let afterGA = beforeGA
let afterSrc = beforeSrc
if (node.rewriteGA) afterGA = applyRewrite(beforeGA, gaRewrite)
if (node.rewriteSource) afterSrc = applyRewrite(beforeSrc, srcRewrite)
let anyRewrite = false
if (afterGA && afterGA !== beforeGA) {
// Prefer payload.knx.destination, fallback to msg.knx.destination
const changed = setNestedIfExists(msg, 'payload.knx.destination', afterGA) || setNestedIfExists(msg, 'knx.destination', afterGA)
meta = Object.assign({}, meta, { rewrite: Object.assign({}, meta.rewrite || {}, { destination: { from: beforeGA, to: afterGA } }) })
if (!changed && msg.payload && msg.payload.knx) msg.payload.knx.destination = afterGA
anyRewrite = true
}
if (afterSrc && afterSrc !== beforeSrc) {
const changed = setNestedIfExists(msg, 'payload.knx.source', afterSrc) || setNestedIfExists(msg, 'knx.source', afterSrc)
meta = Object.assign({}, meta, { rewrite: Object.assign({}, meta.rewrite || {}, { source: { from: beforeSrc, to: afterSrc } }) })
if (!changed && msg.payload && msg.payload.knx) msg.payload.knx.source = afterSrc
anyRewrite = true
}
if (anyRewrite) {
let cemiSynced = false
try { cemiSynced = syncCemiWithKnxFields(msg) } catch (e) { cemiSynced = false }
meta = Object.assign({}, meta, {
rewritten: true,
cemiSynced,
original: Object.assign({}, meta.original || {}, {
destination: beforeGA,
source: beforeSrc
})
})
}
}
const finalMeta = Object.assign({}, meta || {}, { dropped: false, rewritten: !!(meta && meta.rewritten) })
attachFilterMeta(msg, finalMeta)
node.send([msg, null])
setCountersStatus(msg)
} catch (error) {
node.error(error)
applyStatus(msg, { fill: 'red', shape: 'dot', text: error.message || String(error) })
}
})
node.on('close', function () {
try {
if (configStatusTimer) clearTimeout(configStatusTimer)
configStatusTimer = null
} catch (e) { /* ignore */ }
})
setCountersStatus(null)
}
RED.nodes.registerType('knxUltimateRouterFilter', knxUltimateRouterFilter)
}