UNPKG

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
/* 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,