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.
1,130 lines (1,059 loc) • 118 kB
JavaScript
/* eslint-disable prefer-template */
/* eslint-disable no-inner-declarations */
/* eslint-disable curly */
/* eslint-disable max-len */
/* eslint-disable prefer-arrow-callback */
const fs = require('fs')
const path = require('path')
const net = require('net')
const os = require('os')
const _ = require('lodash')
const knx = require('knxultimate')
// 2025-09: Use KNXUltimate built-in keyring for KNX Secure validation
let Keyring
try {
// Not exported by default; import from build path
({ Keyring } = require('knxultimate/build/secure/keyring'))
} catch (e) {
Keyring = null
}
// const dptlib = require('knxultimate').dptlib;
const dptlib = require('knxultimate').dptlib
const loggerClass = require('./utils/sysLogger')
// const { Server } = require('http')
const payloadRounder = require('./utils/payloadManipulation')
const utils = require('./utils/utils')
// DATAPONT MANIPULATION HELPERS
// ####################
const sortBy = (field) => (a, b) => {
if (a[field] > b[field]) {
return 1
} else {
return -1
}
}
const onlyDptKeys = (kv) => {
return kv[0].startsWith('DPT')
}
const extractBaseNo = (kv) => {
return {
subtypes: kv[1].subtypes,
base: parseInt(kv[1].id.replace('DPT', ''))
}
}
const convertSubtype = (baseType) => (kv) => {
const value = `${baseType.base}.${kv[0]}`
// let sRet = value + " " + kv[1].name + (kv[1].unit === undefined ? "" : " (" + kv[1].unit + ")");
const sRet = value + ' ' + kv[1].name
return {
value,
text: sRet
}
}
const toConcattedSubtypes = (acc, baseType) => {
const subtypes = Object.entries(baseType.subtypes).sort(sortBy(0)).map(convertSubtype(baseType))
return acc.concat(subtypes)
}
// ####################
const BIT_COUNT_TABLE = Array.from({ length: 256 }, (_, value) => {
let count = 0
let temp = value
while (temp) {
temp &= temp - 1
count++
}
return count
})
const parseIPv4Address = (str) => {
if (typeof str !== 'string') return null
const parts = str.trim().split('.')
if (parts.length !== 4) return null
const octets = []
for (let i = 0; i < parts.length; i++) {
const part = parts[i]
if (!/^\d+$/.test(part)) return null
const value = Number(part)
if (!Number.isInteger(value) || value < 0 || value > 255) return null
octets.push(value)
}
return octets
}
const countNetmaskBits = (maskOctets) => {
if (!Array.isArray(maskOctets) || maskOctets.length !== 4) return 0
let total = 0
for (let i = 0; i < 4; i++) {
const oct = maskOctets[i]
if (!Number.isInteger(oct) || oct < 0 || oct > 255) return 0
total += BIT_COUNT_TABLE[oct]
}
return total
}
const computeIPv4NetworkKey = (ipOctets, maskOctets) => {
if (!Array.isArray(ipOctets) || ipOctets.length !== 4 || !Array.isArray(maskOctets) || maskOctets.length !== 4) return null
const result = []
for (let i = 0; i < 4; i++) {
const ipVal = ipOctets[i]
const maskVal = maskOctets[i]
if (!Number.isInteger(ipVal) || ipVal < 0 || ipVal > 255 || !Number.isInteger(maskVal) || maskVal < 0 || maskVal > 255) return null
result.push(ipVal & maskVal)
}
return result.join('.')
}
const buildNetmaskOctetsFromPrefix = (prefix) => {
if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) return null
const octets = [0, 0, 0, 0]
let remaining = prefix
for (let i = 0; i < 4; i++) {
const bits = Math.max(0, Math.min(remaining, 8))
octets[i] = bits === 0 ? 0 : ((0xff << (8 - bits)) & 0xff)
remaining -= bits
}
return octets
}
const deriveNetmaskOctets = (iface) => {
if (!iface) return null
const netmask = typeof iface.netmask === 'string' && iface.netmask.trim() !== '' ? iface.netmask : null
if (netmask) {
const octets = parseIPv4Address(netmask)
if (octets) return octets
}
if (typeof iface.cidr === 'string' && iface.cidr.includes('/')) {
const parts = iface.cidr.split('/')
if (parts.length === 2) {
const prefix = Number(parts[1])
const octets = buildNetmaskOctetsFromPrefix(prefix)
if (octets) return octets
}
}
return null
}
const isMulticastIPv4 = (octets) => {
if (!Array.isArray(octets) || octets.length !== 4) return false
const first = octets[0]
return first >= 224 && first <= 239
}
const findAutoEthernetInterface = (targetIP) => {
const targetOctets = parseIPv4Address(targetIP)
if (!targetOctets || isMulticastIPv4(targetOctets)) return null
const interfaces = os.networkInterfaces()
if (!interfaces || typeof interfaces !== 'object') return null
let bestMatch = null
let bestMaskBits = -1
Object.keys(interfaces).forEach((ifname) => {
const entries = Array.isArray(interfaces[ifname]) ? interfaces[ifname] : []
entries.forEach((entry) => {
if (!entry || entry.internal) return
const family = entry.family === 'IPv4' || entry.family === 4
if (!family) return
const ifaceOctets = parseIPv4Address(entry.address)
if (!ifaceOctets) return
const maskOctets = deriveNetmaskOctets(entry)
if (!maskOctets) return
const ifaceNetwork = computeIPv4NetworkKey(ifaceOctets, maskOctets)
const targetNetwork = computeIPv4NetworkKey(targetOctets, maskOctets)
if (!ifaceNetwork || !targetNetwork || ifaceNetwork !== targetNetwork) return
const maskBits = countNetmaskBits(maskOctets)
if (maskBits > bestMaskBits) {
bestMaskBits = maskBits
bestMatch = {
name: ifname,
address: entry.address,
netmask: maskOctets.join('.'),
maskBits
}
}
})
})
return bestMatch
}
module.exports = (RED) => {
function knxUltimateConfigNode (config) {
RED.nodes.createNode(this, config)
const node = this
node.host = config.host
node.port = parseInt(config.port)
node.physAddr = config.physAddr // the KNX physical address we'd like to use
node.suppressACKRequest = typeof config.suppressACKRequest === 'undefined' ? true : config.suppressACKRequest // enable this option to suppress the acknowledge flag with outgoing L_Data.req requests. LoxOne needs this
node.linkStatus = 'disconnected' // Can be: connected or disconnected
node.nodeClients = [] // Stores the registered clients
node.KNXEthInterface = typeof config.KNXEthInterface === 'undefined' ? 'Auto' : config.KNXEthInterface
node.KNXEthInterfaceManuallyInput = typeof config.KNXEthInterfaceManuallyInput === 'undefined' ? '' : config.KNXEthInterfaceManuallyInput // If you manually set the interface name, it will be wrote here
node.timerDoInitialRead = null // 17/02/2020 Timer (timeout) to do initial read of all nodes requesting initial read, after all nodes have been registered to the sercer
node.stopETSImportIfNoDatapoint = typeof config.stopETSImportIfNoDatapoint === 'undefined' ? 'stop' : config.stopETSImportIfNoDatapoint // 09/01/2020 Stop, Import Fake or Skip the import if a group address has unset datapoint
node.userDir = path.join(RED.settings.userDir, 'knxultimatestorage') // 04/04/2021 Supergiovane: Storage for service files
node.exposedGAs = []
node.exposedGAsByGa = new Map()
node.loglevel = config.loglevel !== undefined ? config.loglevel : 'error' // 18/02/2020 Loglevel default error
if (node.loglevel === 'trace') node.loglevel = 'debug' // Backward compatibility
if (node.loglevel === 'silent') node.loglevel = 'disable' // Backward compatibility
node.sysLogger = null // 20/03/2022 Default
try {
node.sysLogger = new loggerClass({ loglevel: node.loglevel, setPrefix: node.type + ' <' + (node.name || node.id || '') + '>' })
} catch (error) { console.log(error.stack) }
node.csv = readCSV(config.csv) // Array from ETS CSV Group Addresses {ga:group address, dpt: datapoint, devicename: full device name with main and subgroups}
node.csvByGa = new Map()
if (Array.isArray(node.csv)) {
node.csv.forEach((entry) => {
if (entry && typeof entry.ga === 'string' && entry.ga !== '') node.csvByGa.set(entry.ga, entry)
})
}
node.rebuildExposedGAIndex = () => {
node.exposedGAsByGa = new Map()
if (!Array.isArray(node.exposedGAs)) return
node.exposedGAs.forEach((entry) => {
if (entry && typeof entry.ga === 'string' && entry.ga !== '') node.exposedGAsByGa.set(entry.ga, entry)
})
}
node.getExposedGAEntry = (ga) => {
if (typeof ga !== 'string' || ga === '') return undefined
return node.exposedGAsByGa.get(ga)
}
node.upsertExposedGAEntry = (entry) => {
if (!entry || typeof entry.ga !== 'string' || entry.ga === '') return undefined
const existing = node.exposedGAsByGa.get(entry.ga)
if (existing) {
Object.assign(existing, entry)
return existing
}
node.exposedGAs.push(entry)
node.exposedGAsByGa.set(entry.ga, entry)
return entry
}
node.removeExposedGAEntry = (ga) => {
if (typeof ga !== 'string' || ga === '') return
node.exposedGAsByGa.delete(ga)
const index = node.exposedGAs.findIndex((item) => item.ga === ga)
if (index > -1) node.exposedGAs.splice(index, 1)
}
// 12/11/2021 Connect at start delay
node.autoReconnect = true // 20/03/2022 Default
if (config.autoReconnect === 'no' || config.autoReconnect === false) {
node.autoReconnect = false
} else {
node.autoReconnect = true
}
node.enableFlowBubbles = config.enableFlowBubbles === true || config.enableFlowBubbles === 'true'
node.ignoreTelegramsWithRepeatedFlag = config.ignoreTelegramsWithRepeatedFlag === undefined ? false : config.ignoreTelegramsWithRepeatedFlag
const throttleSecondsRaw = Number(config.statusUpdateThrottle)
node.statusUpdateThrottleMs = Number.isFinite(throttleSecondsRaw) && throttleSecondsRaw > 0
? throttleSecondsRaw * 1000
: 0
node.statusDateTimeFormat = typeof config.statusDateTimeFormat === 'string' && config.statusDateTimeFormat !== ''
? config.statusDateTimeFormat
: 'legacy'
node.statusDateTimeCustom = typeof config.statusDateTimeCustom === 'string' && config.statusDateTimeCustom.trim() !== ''
? config.statusDateTimeCustom.trim()
: 'DD MMM HH:mm'
node.statusDateTimeLocale = typeof config.statusDateTimeLocale === 'string'
? config.statusDateTimeLocale.trim()
: ''
const resolveDateTimeLocale = () => {
const raw = node.statusDateTimeLocale
return raw && raw !== '' ? raw : undefined
}
const pad2 = (value) => String(value).padStart(2, '0')
const formatTimezoneOffset = (date) => {
const minutes = -date.getTimezoneOffset()
const sign = minutes >= 0 ? '+' : '-'
const abs = Math.abs(minutes)
return `${sign}${pad2(Math.floor(abs / 60))}:${pad2(abs % 60)}`
}
const safeMonthShortFormatter = () => {
try {
return new Intl.DateTimeFormat(resolveDateTimeLocale(), { month: 'short' })
} catch (error) {
try {
return new Intl.DateTimeFormat(undefined, { month: 'short' })
} catch (fallbackError) {
return null
}
}
}
let monthShortFormatter = null
const monthShort = (date) => {
if (!monthShortFormatter) monthShortFormatter = safeMonthShortFormatter()
if (monthShortFormatter) {
try {
return monthShortFormatter.format(date)
} catch (error) { /* empty */ }
}
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
return months[date.getMonth()]
}
const formatDateTimeTokens = (date, format) => {
const year = date.getFullYear()
const month = date.getMonth() + 1
const day = date.getDate()
const hour24 = date.getHours()
const minute = date.getMinutes()
const second = date.getSeconds()
const hour12 = (hour24 % 12) || 12
const ampm = hour24 < 12 ? 'AM' : 'PM'
const tokenValues = {
YYYY: String(year),
YY: pad2(year % 100),
MMM: monthShort(date),
MM: pad2(month),
M: String(month),
DD: pad2(day),
D: String(day),
HH: pad2(hour24),
H: String(hour24),
hh: pad2(hour12),
h: String(hour12),
mm: pad2(minute),
ss: pad2(second),
A: ampm,
a: ampm.toLowerCase(),
Z: formatTimezoneOffset(date)
}
const tokens = Object.keys(tokenValues).sort((a, b) => b.length - a.length)
let out = ''
let i = 0
while (i < format.length) {
const ch = format[i]
if (ch === '[') {
const end = format.indexOf(']', i + 1)
if (end === -1) {
out += ch
i += 1
} else {
out += format.slice(i + 1, end)
i = end + 1
}
continue
}
let matched = false
for (const token of tokens) {
if (format.startsWith(token, i)) {
out += tokenValues[token]
i += token.length
matched = true
break
}
}
if (!matched) {
out += ch
i += 1
}
}
return out
}
node.formatStatusTimestamp = (value, options = {}) => {
try {
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) return ''
const mode = node.statusDateTimeFormat
if (mode === 'iso') return formatDateTimeTokens(date, 'YYYY-MM-DD HH:mm:ss')
if (mode === 'isoNoSeconds') return formatDateTimeTokens(date, 'YYYY-MM-DD HH:mm')
if (mode === 'custom') return formatDateTimeTokens(date, node.statusDateTimeCustom || 'DD MMM HH:mm')
const legacy = `${date.getDate()}, ${date.toLocaleTimeString()}`
return options && options.legacyDayLabel ? `day ${legacy}` : legacy
} catch (error) {
try {
const date = new Date()
const legacy = `${date.getDate()}, ${date.toLocaleTimeString()}`
return options && options.legacyDayLabel ? `day ${legacy}` : legacy
} catch (fallbackError) {
return ''
}
}
}
node.applyStatusUpdate = (targetNode, status) => {
try {
if (!targetNode || typeof targetNode.status !== 'function') return
const throttle = node.statusUpdateThrottleMs
if (!throttle) {
targetNode.status(status)
return
}
if (!targetNode.__knxStatusThrottle) {
targetNode.__knxStatusThrottle = { pending: undefined, timer: null }
}
const tracker = targetNode.__knxStatusThrottle
tracker.pending = status
if (tracker.timer) return
tracker.timer = setTimeout(() => {
try {
if (tracker.pending !== undefined) {
targetNode.status(tracker.pending)
}
} catch (timerError) {
node.sysLogger?.warn('Unable to apply throttled status: ' + timerError.message)
} finally {
tracker.pending = undefined
tracker.timer = null
}
}, throttle)
} catch (error) {
node.sysLogger?.warn('applyStatusUpdate error: ' + error.message)
}
}
// 24/07/2021 KNX Secure checks...
node.keyringFileXML = typeof config.keyringFileXML === 'undefined' || config.keyringFileXML.trim() === '' ? '' : config.keyringFileXML
node.knxSecureSelected = typeof config.knxSecureSelected === 'undefined' ? false : config.knxSecureSelected
node.secureCredentialsMode = typeof config.secureCredentialsMode === 'undefined' ? 'keyring' : config.secureCredentialsMode
// 2025-09 Secure Tunnel Interface IA selection (Auto/Manual)
node.tunnelIASelection = typeof config.tunnelIASelection === 'undefined' ? 'Auto' : config.tunnelIASelection
node.tunnelIA = typeof config.tunnelIA === 'undefined' || config.tunnelIA === null ? '' : String(config.tunnelIA)
node.tunnelInterfaceIndividualAddress = typeof config.tunnelInterfaceIndividualAddress === 'undefined' || config.tunnelInterfaceIndividualAddress === null
? ''
: String(config.tunnelInterfaceIndividualAddress)
const normalizedTunnelIA = (value) => {
if (typeof value !== 'string') return ''
const trimmed = value.trim()
return trimmed === 'undefined' ? '' : trimmed
}
node.tunnelIA = normalizedTunnelIA(node.tunnelIA)
node.tunnelInterfaceIndividualAddress = normalizedTunnelIA(node.tunnelInterfaceIndividualAddress)
if (!node.tunnelInterfaceIndividualAddress && node.tunnelIA) {
node.tunnelInterfaceIndividualAddress = node.tunnelIA
} else if (!node.tunnelIA && node.tunnelInterfaceIndividualAddress) {
node.tunnelIA = node.tunnelInterfaceIndividualAddress
}
node.tunnelUserPassword = typeof config.tunnelUserPassword === 'undefined' ? '' : config.tunnelUserPassword
node.tunnelUserId = typeof config.tunnelUserId === 'undefined' ? '' : config.tunnelUserId
node.name = config.name === undefined || config.name === '' ? node.host : config.name // 12/08/2021
node.timerKNXUltimateCheckState = null // 08/10/2021 Check the state. If not connected and autoreconnect is true, retrig the connetion attempt.
node.knxConnectionProperties = null // Retains the connection properties
node.allowLauch_initKNXConnection = true // See the node.timerKNXUltimateCheckState function
// Serial FT1.2 configuration
const sanitizeSerialPath = (value) => {
if (typeof value !== 'string') return ''
const trimmed = value.trim()
if (trimmed === '' || trimmed === 'undefined') return ''
return trimmed
}
const parsePositiveNumber = (value, fallback) => {
const n = Number(value)
return Number.isFinite(n) && n > 0 ? n : fallback
}
const parseDataBits = (value) => {
const allowed = [5, 6, 7, 8]
const candidate = parsePositiveNumber(value, 8)
return allowed.includes(candidate) ? candidate : 8
}
const parseStopBits = (value) => {
const allowed = [1, 2]
const candidate = parsePositiveNumber(value, 1)
return allowed.includes(candidate) ? candidate : 1
}
const parseParity = (value) => {
const allowed = ['none', 'even', 'odd']
const val = typeof value === 'string' ? value.trim().toLowerCase() : ''
return allowed.includes(val) ? val : 'even'
}
const parseBoolean = (value, fallback) => {
if (typeof value === 'boolean') return value
if (typeof value === 'string') {
if (value.toLowerCase() === 'true') return true
if (value.toLowerCase() === 'false') return false
}
return fallback
}
const serialPathFromConfig = sanitizeSerialPath(config.serialPortPath)
const legacySerialPath = config.hostProtocol === 'SerialFT12' ? sanitizeSerialPath(config.host) : ''
node.serialPortPath = serialPathFromConfig || legacySerialPath || '/dev/ttyAMA0'
node.serialBaudRate = parsePositiveNumber(config.serialBaudRate, 19200)
node.serialDataBits = parseDataBits(config.serialDataBits)
node.serialStopBits = parseStopBits(config.serialStopBits)
node.serialParity = parseParity(config.serialParity)
node.serialRtscts = parseBoolean(config.serialRtscts, false)
node.serialDtr = parseBoolean(config.serialDtr, true)
node.serialTimeout = parsePositiveNumber(config.serialTimeout, 1200)
node.isKBERRY = parseBoolean(config.isKBERRY, true)
node.hostProtocol = config.hostProtocol === undefined ? 'Auto' : config.hostProtocol // 20/03/2022 Default
node.knxConnection = null // 20/03/2022 Default
node.serialDriverRef = null // Keep last FT1.2 driver to force-close on redeploy
node.delaybetweentelegrams = (config.delaybetweentelegrams === undefined || config.delaybetweentelegrams === null || config.delaybetweentelegrams === '') ? 25 : Number(config.delaybetweentelegrams)
if (node.delaybetweentelegrams < 25) node.delaybetweentelegrams = 25 // Protection avoiding handleKNXQueue hangs
if (node.delaybetweentelegrams > 100) node.delaybetweentelegrams = 100 // Protection avoiding handleKNXQueue hangs
node.timerSaveExposedGAs = null // Timer to save the exposed GA every once in a while
// 05/12/2021 Set the protocol (this is undefined if coming from ild versions
if (node.hostProtocol === 'Auto') {
// Auto set protocol based on IP
if (
node.host.startsWith('224.') ||
node.host.startsWith('225.') ||
node.host.startsWith('232.') ||
node.host.startsWith('233.') ||
node.host.startsWith('234.') ||
node.host.startsWith('235.') ||
node.host.startsWith('239.')
) {
node.hostProtocol = 'Multicast'
} else {
const isSecure = node.knxSecureSelected === true || node.knxSecureSelected === 'true'
node.hostProtocol = isSecure ? 'TunnelTCP' : 'TunnelUDP'
}
node.sysLogger?.info('IP Protocol AUTO SET to ' + node.hostProtocol + ', based on IP ' + node.host)
}
if (node.hostProtocol === 'SerialFT12') {
node.host = node.serialPortPath || node.host || '/dev/ttyAMA0'
}
node.setAllClientsStatus = (_status, _color, _text) => {
node.nodeClients.forEach((oClient) => {
try {
if (oClient.setNodeStatus !== undefined) oClient.setNodeStatus({
fill: _color,
shape: 'dot',
text: _status + ' ' + _text,
payload: '',
GA: oClient.topic,
dpt: '',
devicename: ''
})
} catch (error) {
node.sysLogger?.warn('Wow setAllClientsStatus error ' + error.message)
}
})
}
//
// KNX-SECURE
// Validate keyring (if available) and prepare secure configuration
//
node.secureTunnelConfig = undefined;
(async () => {
try {
if (node.knxSecureSelected) {
const secureMode = typeof node.secureCredentialsMode === 'string' ? node.secureCredentialsMode : 'keyring'
const useManual = secureMode === 'manual' || secureMode === 'combined'
const useKeyring = secureMode === 'keyring' || secureMode === 'combined'
const secureConfig = {}
const modeParts = []
if (useManual) {
const manualIA = (node.tunnelInterfaceIndividualAddress || '').trim()
const manualUserId = (node.tunnelUserId || '').trim()
const manualPwd = node.tunnelUserPassword || ''
if (manualIA) {
secureConfig.tunnelInterfaceIndividualAddress = manualIA
}
if (manualUserId) {
secureConfig.tunnelUserId = manualUserId
}
// Always include password property so KNX library receives the intended value (even if empty)
secureConfig.tunnelUserPassword = manualPwd
modeParts.push('manual tunnel credentials')
}
if (useKeyring) {
secureConfig.knxkeys_file_path = node.keyringFileXML || ''
secureConfig.knxkeys_password = node.credentials?.keyringFilePassword || ''
try {
const manualSelectionIA = normalizedTunnelIA(node.tunnelIA)
if (node.tunnelIASelection === 'Manual' && manualSelectionIA) {
if (!secureConfig.tunnelInterfaceIndividualAddress) {
secureConfig.tunnelInterfaceIndividualAddress = manualSelectionIA
}
} else if (!secureConfig.tunnelInterfaceIndividualAddress) {
secureConfig.tunnelInterfaceIndividualAddress = '' // Auto (let KNX stack select)
}
} catch (e) { /* empty */ }
// Optional early validation to give immediate feedback (non-fatal)
if (Keyring && node.keyringFileXML && (node.credentials?.keyringFilePassword || '') !== '') {
try {
const kr = new Keyring()
await kr.load(node.keyringFileXML, node.credentials.keyringFilePassword)
if (node.loglevel === 'debug') {
try {
const toIAString = (value) => {
if (!value) return ''
return typeof value.toString === 'function' ? value.toString() : String(value)
}
const toBufferString = (value) => {
if (!value) return ''
if (Buffer.isBuffer(value)) return value.toString('hex')
return String(value)
}
const interfaceMap = kr.getInterfaces?.()
const interfaces = Array.from(interfaceMap ? interfaceMap.values() : []).map((iface) => ({
type: iface.type || '',
individualAddress: toIAString(iface.individualAddress),
host: toIAString(iface.host),
userId: typeof iface.userId === 'number' ? iface.userId : '',
password: iface.password || '',
decryptedPassword: iface.decryptedPassword || '',
authentication: iface.authentication || '',
decryptedAuthentication: iface.decryptedAuthentication || '',
groupAddresses: Array.from(iface.groupAddresses ? iface.groupAddresses.entries() : []).map(([ga, senders]) => ({
address: ga,
senders: Array.isArray(senders) ? senders.map(toIAString) : []
}))
}))
const backbones = (kr.getBackbones?.() || []).map((backbone) => ({
multicastAddress: backbone.multicastAddress || '',
latency: typeof backbone.latency === 'number' ? backbone.latency : '',
key: backbone.key || '',
decryptedKey: toBufferString(backbone.decryptedKey)
}))
const groupAddressMap = kr.getGroupAddresses?.()
const groupAddresses = Array.from(groupAddressMap ? groupAddressMap.values() : []).map((group) => ({
address: toIAString(group.address),
key: group.key || '',
decryptedKey: toBufferString(group.decryptedKey)
}))
const deviceMap = kr.getDevices?.()
const devices = Array.from(deviceMap ? deviceMap.values() : []).map((device) => ({
individualAddress: toIAString(device.individualAddress),
toolKey: device.toolKey || '',
decryptedToolKey: toBufferString(device.decryptedToolKey),
managementPassword: device.managementPassword || '',
decryptedManagementPassword: device.decryptedManagementPassword || '',
authentication: device.authentication || '',
decryptedAuthentication: device.decryptedAuthentication || '',
sequenceNumber: typeof device.sequenceNumber === 'number' ? device.sequenceNumber : '',
serialNumber: device.serialNumber || ''
}))
const lines = []
lines.push('================ KNX Secure keyring debug dump ================')
lines.push(`Node: ${node.name || node.id || ''}`)
lines.push(`Created By: ${kr.getCreatedBy?.() || ''}`)
lines.push(`Created On: ${kr.getCreated?.() || ''}`)
lines.push(`Password (node credentials): ${node.credentials?.keyringFilePassword || ''}`)
lines.push('')
lines.push('Interfaces:')
if (interfaces.length === 0) {
lines.push(' (none)')
} else {
interfaces.forEach((iface, idx) => {
lines.push(` [${idx + 1}] ${iface.individualAddress || '(unknown)'} (${iface.type || ''})`)
lines.push(` Host: ${iface.host || ''}`)
lines.push(` User ID: ${iface.userId === '' ? '' : iface.userId}`)
lines.push(` Password (encoded): ${iface.password || ''}`)
lines.push(` Password (decoded): ${iface.decryptedPassword || ''}`)
lines.push(` Authentication (encoded): ${iface.authentication || ''}`)
lines.push(` Authentication (decoded): ${iface.decryptedAuthentication || ''}`)
if (!iface.groupAddresses || iface.groupAddresses.length === 0) {
lines.push(' Group Addresses: (none)')
} else {
lines.push(' Group Addresses:')
iface.groupAddresses.forEach((ga) => {
const senders = ga.senders && ga.senders.length > 0 ? ga.senders.join(', ') : '(none)'
lines.push(` - ${ga.address}: senders ${senders}`)
})
}
lines.push('')
})
}
lines.push('Backbones:')
if (backbones.length === 0) {
lines.push(' (none)')
} else {
backbones.forEach((backbone, idx) => {
lines.push(` [${idx + 1}] Multicast: ${backbone.multicastAddress || ''}`)
lines.push(` Latency: ${backbone.latency === '' ? '' : backbone.latency}`)
lines.push(` Key (encoded): ${backbone.key || ''}`)
lines.push(` Key (decoded hex): ${backbone.decryptedKey || ''}`)
lines.push('')
})
}
lines.push('Group Addresses:')
if (groupAddresses.length === 0) {
lines.push(' (none)')
} else {
groupAddresses.forEach((group, idx) => {
lines.push(` [${idx + 1}] ${group.address || ''}`)
lines.push(` Key (encoded): ${group.key || ''}`)
lines.push(` Key (decoded hex): ${group.decryptedKey || ''}`)
lines.push('')
})
}
lines.push('Devices:')
if (devices.length === 0) {
lines.push(' (none)')
} else {
devices.forEach((device, idx) => {
lines.push(` [${idx + 1}] ${device.individualAddress || ''}`)
lines.push(` Tool Key (encoded): ${device.toolKey || ''}`)
lines.push(` Tool Key (decoded hex): ${device.decryptedToolKey || ''}`)
lines.push(` Management Password (encoded): ${device.managementPassword || ''}`)
lines.push(` Management Password (decoded): ${device.decryptedManagementPassword || ''}`)
lines.push(` Authentication (encoded): ${device.authentication || ''}`)
lines.push(` Authentication (decoded): ${device.decryptedAuthentication || ''}`)
lines.push(` Sequence Number: ${device.sequenceNumber === '' ? '' : device.sequenceNumber}`)
lines.push(` Serial Number: ${device.serialNumber || ''}`)
lines.push('')
})
}
lines.push('Raw keyring (XML/base64 as provided):')
lines.push(node.keyringFileXML || '(empty)')
lines.push('================ End of keyring debug dump ================')
// 2025-10 Privacy: avoid logging full keyring contents to the console/file.
// node.sysLogger?.debug(lines.join('\n'))
} catch (dumpError) {
node.sysLogger?.error('KNX Secure: unable to log keyring details: ' + dumpError.message)
}
}
const createdBy = kr.getCreatedBy?.() || 'unknown'
const created = kr.getCreated?.() || 'unknown'
RED.log.info(`KNX-Secure: Keyring validated (Created by ${createdBy} on ${created}) using node ${node.name || node.id}`)
} catch (err) {
node.sysLogger?.error('KNX Secure: keyring validation failed: ' + err.message)
// Keep secure enabled: KNXClient will emit detailed errors on connect
}
}
modeParts.push('keyring file/password')
}
if (Object.keys(secureConfig).length > 0) {
node.secureTunnelConfig = secureConfig
} else {
node.secureTunnelConfig = undefined
}
if (modeParts.length > 0) {
RED.log.info(`KNX-Secure: secure mode selected (${modeParts.join(' + ')}). Node ${node.name || node.id}`)
}
} else {
RED.log.info('KNX-Unsecure: connection to insecure interface/router using node ' + (node.name || node.id))
}
} catch (error) {
node.sysLogger?.error('KNX Secure: error preparing secure configuration: ' + error.message)
node.secureTunnelConfig = undefined
node.knxSecureSelected = false
const t = setTimeout(() => node.setAllClientsStatus('Error', 'red', 'KNX Secure ' + error.message), 2000)
}
})()
// 04/04/2021 Supergiovane, creates the service paths where the persistent files are created.
// The values file is stored only upon disconnection/close
// ************************
function setupDirectory (_aPath) {
if (!fs.existsSync(_aPath)) {
// Create the path
try {
fs.mkdirSync(_aPath)
return true
} catch (error) {
return false
}
} else {
return true
}
}
if (!setupDirectory(node.userDir)) {
node.sysLogger?.error('Unable to set up MAIN directory: ' + node.userDir)
}
if (!setupDirectory(path.join(node.userDir, 'knxpersistvalues'))) {
node.sysLogger?.error('Unable to set up cache directory: ' + path.join(node.userDir, 'knxpersistvalues'))
} else {
node.sysLogger?.info('payload cache set to ' + path.join(node.userDir, 'knxpersistvalues'))
}
async function saveExposedGAs () {
const sFile = path.join(node.userDir, 'knxpersistvalues', 'knxpersist' + node.id + '.json')
try {
if (node.exposedGAs.length > 0) {
fs.writeFileSync(sFile, JSON.stringify(node.exposedGAs))
// node.sysLogger?.debug("wrote peristent values to the file " + sFile);
}
} catch (err) {
node.sysLogger?.error('unable to write peristent values to the file ' + sFile + ' ' + err.message)
}
}
function loadExposedGAs () {
const sFile = path.join(node.userDir, 'knxpersistvalues', 'knxpersist' + node.id + '.json')
try {
node.exposedGAs = JSON.parse(fs.readFileSync(sFile, 'utf8'))
if (!Array.isArray(node.exposedGAs)) node.exposedGAs = []
} catch (err) {
node.exposedGAs = []
node.sysLogger?.info('unable to read peristent file ' + sFile + ' ' + err.message)
}
node.rebuildExposedGAIndex()
}
// ************************
// 16/02/2020 KNX-Ultimate nodes calls this function, then this funcion calls the same function on the Watchdog
node.reportToWatchdogCalledByKNXUltimateNode = (_oError) => {
// _oError is = { nodeid: node.id, topic: node.outputtopic, devicename: devicename, GA: GA, text: text };
const readHistory = []
const delay = 0
node.nodeClients
.filter((_oClient) => _oClient.isWatchDog !== undefined && _oClient.isWatchDog === true)
.forEach((_oClient) => {
_oClient.signalNodeErrorCalledByConfigNode(_oError)
})
}
node.addClient = (_Node) => {
// Check if node already exists
if (node.nodeClients.filter((x) => x.id === _Node.id).length === 0) {
// Add _Node to the clients array
if (node.autoReconnect) {
_Node.setNodeStatus({
fill: 'grey',
shape: 'ring',
text: 'Node initialized.',
payload: '',
GA: '',
dpt: '',
devicename: ''
})
} else {
_Node.setNodeStatus({
fill: 'red',
shape: 'ring',
text: 'Autoconnect disabled. Please manually connect.',
payload: '',
GA: '',
dpt: '',
devicename: ''
})
}
node.nodeClients.push(_Node)
}
}
node.removeClient = async (_Node) => {
// Remove the client node from the clients array
try {
node.nodeClients = node.nodeClients.filter((x) => x.id !== _Node.id)
} catch (error) { /* empty */ }
// If no clien nodes, disconnect from bus.
if (node.nodeClients.length === 0) {
try {
await node.Disconnect()
} catch (error) { /* empty */ }
}
}
// 17/02/2020 Do initial read (called by node.timerDoInitialRead timer)
function DoInitialReadFromKNXBusOrFile () {
if (node.linkStatus !== 'connected') return // 29/08/2019 If not connected, exit
node.sysLogger?.info('Do DoInitialReadFromKNXBusOrFile')
loadExposedGAs() // 04/04/2021 load the current values of GA payload
node.sysLogger?.info('Loaded persist GA values', node.exposedGAs?.length)
if (node.timerSaveExposedGAs !== null) clearInterval(node.timerSaveExposedGAs)
node.timerSaveExposedGAs = setInterval(async () => {
await saveExposedGAs()
}, 5000)
node.sysLogger?.info('Started timerSaveExposedGAs with array lenght ', node.exposedGAs?.length)
try {
const readHistory = []
// First, read from file. This allow all virtual devices to get their values from file.
node.nodeClients
.filter((_oClient) => _oClient.initialread === 2 || _oClient.initialread === 3)
.filter((_oClient) => _oClient.hasOwnProperty('isWatchDog') === false)
.forEach((_oClient) => {
if (node.linkStatus !== 'connected') return // 16/08/2021 If not connected, exit
// 04/04/2020 selected READ FROM FILE 2 or from file then from bus 3
if (_oClient.listenallga === true) {
// 13/12/2021 DA FARE
} else {
try {
if (node.exposedGAs.length > 0) {
const oExposedGA = node.getExposedGAEntry(_oClient.topic)
if (oExposedGA !== undefined) {
// Retrieve the value from exposedGAs
const msg = buildInputMessage({
_srcGA: '',
_destGA: _oClient.topic,
_event: 'GroupValue_Response',
_Rawvalue: Buffer.from(oExposedGA.rawValue.data),
_inputDpt: _oClient.dpt,
_devicename: _oClient.name ? _oClient.name : '',
_outputtopic: _oClient.outputtopic,
_oNode: _oClient,
_echoed: false
})
_oClient.previouspayload = '' // 05/04/2021 Added previous payload
_oClient.currentPayload = msg.payload
_oClient.setNodeStatus({
fill: 'grey',
shape: 'dot',
text: 'Update value from persist file',
payload: _oClient.currentPayload,
GA: _oClient.topic,
dpt: _oClient.dpt,
devicename: _oClient.name || ''
})
// 06/05/2021 If, after the rawdata has been savad to file, the user changes the datapoint, the buildInputMessage returns payload null, because it's unable to convert the value
if (msg.payload === null) {
_oClient._hasCurrentPayload = false
// Delete the exposedGA
node.removeExposedGAEntry(_oClient.topic)
_oClient.setNodeStatus({
fill: 'yellow',
shape: 'dot',
text: 'Datapoint has been changed, remove the value from persist file',
payload: _oClient.currentPayload,
GA: _oClient.topic,
dpt: _oClient.dpt,
devicename: _oClient.devicename || ''
})
node.sysLogger?.error('DoInitialReadFromKNXBusOrFile: Datapoint may have been changed, remove the value from persist file of ' + _oClient.topic + ' Devicename ' + _oClient.name + ' Currend DPT ' + _oClient.dpt + ' Node.id ' + _oClient.id)
} else {
// Ensure optional features relying on "currentPayload" (e.g. periodic/cyclic send)
// work also when the value is restored from persist file after a Node-RED restart.
_oClient._hasCurrentPayload = true
if (_oClient.notifyresponse) _oClient.handleSend(msg)
}
} else {
if (_oClient.initialread === 3) {
// Not found, issue a READ to the bus
if (!readHistory.includes(_oClient.topic)) {
node.sysLogger?.debug('DoInitialReadFromKNXBusOrFile 3: sent read request to GA ' + _oClient.topic)
_oClient.setNodeStatus({
fill: 'grey',
shape: 'dot',
text: 'Persist value not found, issuing READ request to BUS',
payload: _oClient.currentPayload,
GA: _oClient.topic,
dpt: _oClient.dpt,
devicename: _oClient.devicename || ''
})
node.sendKNXTelegramToKNXEngine({
grpaddr: _oClient.topic,
payload: '',
dpt: '',
outputtype: 'read',
nodecallerid: _oClient.id
})
readHistory.push(_oClient.topic)
}
}
}
}
} catch (error) {
node.sysLogger?.error('DoInitialReadFromKNXBusOrFile: ' + error.stack)
}
}
})
// Then, after all values have been read from file, read from BUS
// This allow the virtual devices to get their values before this will be readed from bus
node.nodeClients
.filter((_oClient) => _oClient.initialread === 1)
.filter((_oClient) => _oClient.hasOwnProperty('isWatchDog') === false)
.forEach((_oClient) => {
if (node.linkStatus !== 'connected') return // 16/08/2021 If not connected, exit
// 04/04/2020 selected READ FROM BUS 1
if (_oClient.hasOwnProperty('isalertnode') && _oClient.isalertnode) {
_oClient.initialReadAllDevicesInRules()
} else if (_oClient.hasOwnProperty('isLoadControlNode') && _oClient.isLoadControlNode) {
_oClient.initialReadAllDevicesInRules()
} else if (_oClient.listenallga === true) {
for (let index = 0; index < node.csv.length; index++) {
const element = node.csv[index]
if (!readHistory.includes(element.ga)) {
node.sendKNXTelegramToKNXEngine({
grpaddr: element.ga,
payload: '',
dpt: '',
outputtype: 'read',
nodecallerid: element.id
})
readHistory.push(element.ga)
node.sysLogger?.debug('DoInitialReadFromKNXBusOrFile from Universal Node: sent read request to GA ' + element.ga)
}
}
} else {
if (!readHistory.includes(_oClient.topic)) {
node.sendKNXTelegramToKNXEngine({
grpaddr: _oClient.topic,
payload: '',
dpt: '',
outputtype: 'read',
nodecallerid: _oClient.id
})
readHistory.push(_oClient.topic)
node.sysLogger?.debug('DoInitialReadFromKNXBusOrFile: sent read request to GA ' + _oClient.topic)
}
}
})
} catch (error) { }
}
// 01/02/2020 Dinamic change of the KNX Gateway IP, Port and Physical Address
// This new thing has been requested by proServ RealKNX staff.
node.setGatewayConfig = async (
/** @type {string} */ _sIP,
/** @type {number} */ _iPort,
/** @type {string} */ _sPhysicalAddress,
/** @type {string} */ _sBindToEthernetInterface,
/** @type {string} */ _Protocol,
/** @type {string} */ _CSV
) => {
if (typeof _sIP !== 'undefined' && _sIP !== '') node.host = _sIP
if (typeof _iPort !== 'undefined' && _iPort !== 0) node.port = _iPort
if (typeof _sPhysicalAddress !== 'undefined' && _sPhysicalAddress !== '') node.physAddr = _sPhysicalAddress
if (typeof _sBindToEthernetInterface !== 'undefined') node.KNXEthInterface = _sBindToEthernetInterface
if (typeof _Protocol !== 'undefined') node.hostProtocol = _Protocol
if (typeof _CSV !== 'undefined' && _CSV !== '') {
try {
const sTemp = readCSV(_CSV) // 27/09/2022 Set the new CSV
node.csv = sTemp
} catch (error) {
node.sysLogger?.info("Node's main config setting error. " + error.message || '')
}
}
node.sysLogger?.info(
"Node's main config setting has been changed. New config: IP " +
node.host +
' Port ' +
node.port +
' PhysicalAddress ' +
node.physAddr +
' BindToInterface ' +
node.KNXEthInterface +
(typeof _CSV !== 'undefined' && _CSV !== '' ? '. A new group address CSV has been imported.' : '')
)
if (node.hostProtocol === 'SerialFT12') {
node.serialPortPath = node.host
}
try {
await node.Disconnect()
// node.setKnxConnectionProperties(); // 28/12/2021 Commented
node.setAllClientsStatus('CONFIG', 'yellow', 'KNXUltimage-config:setGatewayConfig: disconnected by new setting...')
node.sysLogger?.debug('KNXUltimage-config:setGatewayConfig: disconnected by setGatewayConfig.')
} catch (error) { }
}
// 05/05/2021 force connection or disconnection from the KNX BUS and disable the autoreconenctions attempts.
// This new thing has been requested by proServ RealKNX staff.
node.connectGateway = async (_bConnection) => {
if (_bConnection === undefined) return
node.sysLogger?.info(
(_bConnection === true ? 'Forced connection from watchdog' : 'Forced disconnection from watchdog') +
node.host +
' Port ' +
node.port +
' PhysicalAddress ' +
node.physAddr +
' BindToInterface ' +
node.KNXEthInterface
)
if (_bConnection === true) {
// CONNECT AND ENABLE RECONNECTION ATTEMPTS
try {
await node.Disconnect()
node.setAllClientsStatus('CONFIG', 'yellow', 'Forced GW connection from watchdog.')
node.autoReconnect = true
} catch (error) { }
} else {
// DISCONNECT AND DISABLE RECONNECTION ATTEMPTS
try {
node.autoReconnect = false
await node.Disconnect()
const t = setTimeout(() => {
// 21/03/2022 fixed possible memory leak. Previously was setTimeout without "let t = ".
node.setAllClientsStatus('CONFIG', 'yellow', 'Forced GW disconnection and stop reconnection attempts, from watchdog.')
}, 2000)
} catch (error) { }
}
}
node.setKnxConnectionProperties = () => {
// 25/08/2021 Moved out of node.initKNXConnection
node.knxConnectionProperties = {
ipAddr: node.host,
ipPort: node.port,
physAddr: node.physAddr, // the KNX physical address we'd like to use
suppress_ack_ldatareq: node.suppressACKRequest,
loglevel: node.loglevel,
hostProtocol: node.hostProtocol,
isSecureKNXEnabled: node.knxSecureSelected,
secureTunnelConfig: node.knxSecureSelected ? node.secureTunnelConfig : undefined,
localIPAddress: '', // Riempito da KNXEngine
KNXQueueSendIntervalMilliseconds: Number(node.delaybetweentelegrams),
connectionKeepAliveTimeout: 30 // Every 30 seconds, send a connectionstatus_request
}
const isSerialProtocol = node.hostProtocol === 'SerialFT12'
if (isSerialProtocol) {
const serialPath = node.serialPortPath || node.host || '/dev/ttyAMA0'
node.knxConnectionProperties.ipAddr = serialPath
node.knxConnectionProperties.serialInterface = {
path: serialPath,
baudRate: node.serialBaudRate,
dataBits: node.serialDataBits,
stopBits: node.serialStopBits,
parity: node.serialParity,
rtscts: !!node.serialRtscts,