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 and ETS group address importer. Easy to use and highly configurable.

1,089 lines (1,023 loc) 99.8 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.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} // 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.ignoreTelegramsWithRepeatedFlag = config.ignoreTelegramsWithRepeatedFlag === undefined ? false : config.ignoreTelegramsWithRepeatedFlag const throttleSecondsRaw = Number(config.statusUpdateThrottle) node.statusUpdateThrottleMs = Number.isFinite(throttleSecondsRaw) && throttleSecondsRaw > 0 ? throttleSecondsRaw * 1000 : 0 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 node.hostProtocol = config.hostProtocol === undefined ? 'Auto' : config.hostProtocol // 20/03/2022 Default node.knxConnection = null // 20/03/2022 Default 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) } 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')) } catch (err) { node.exposedGAs = [] node.sysLogger?.info('unable to read peristent file ' + sFile + ' ' + err.message) } } // ************************ // 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.exposedGAs.find((a) => a.ga === _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) { // Delete the exposedGA node.exposedGAs = node.exposedGAs.filter((item) => item.ga !== _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 { 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.' : '') ) 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 } // 11/07/2022 Test if the IP is a valid one or is a DNS Name switch (net.isIP(node.host)) { case 0: // Invalid IP, resolve the DNS name. const dns = require('dns-sync') let resolvedIP = null try { resolvedIP = dns.resolve(node.host) } catch (error) { throw new Error('net.isIP: INVALID IP OR DNS NAME. Error checking the Gateway Host in Config node. ' + error.message) } if (resolvedIP === null || net.isIP(resolvedIP) === 0) { // Error in resolving DNS Name node.sysLogger?.error( 'net.isIP: INVALID IP OR DNS NAME. Check the Gateway Host in Config node ' + node.name + ' ' + node.host ) throw new Error('net.isIP: INVALID IP OR DNS NAME. Check the Gateway Host in Config node.') } node.sysLogger?.info( 'net.isIP: The gateway is not specified as IP. The DNS resolver pointed me to the IP ' + node.host + ', in Config node ' + node.name ) node.knxConnectionProperties.ipAddr = resolvedIP case 4: // It's an IPv4 break case 6: // It's an IPv6 break default: break } if (node.KNXEthInterface !== 'Auto') { let sIfaceName = '' if (node.KNXEthInterface === 'Manual') { sIfaceName = node.KNXEthInterfaceManuallyInput node.sysLogger?.info('Bind KNX Bus to interface : ' + sIfaceName + " (Interface's name entered by hand). Node " + node.name) } else { sIfaceName = node.KNXEthInterface node.sysLogger?.info( 'Bind KNX Bus to interface : ' + sIfaceName + " (Interface's name selected from dropdown list). Node " + node.name ) } node.knxConnectionProperties.interface = sIfaceName } else { // Remove any manual binding and try to auto-select based on subnet try { delete node.knxConnectionProperties.interface } catch (error) { } const targetIP = node.knxConnectionProperties?.ipAddr || node.host const autoInterface = typeof targetIP === 'string' ? findAutoEthernetInterface(targetIP) : null if (autoInterface && autoInterface.name) { node.knxConnectionProperties.interface = autoInterface.name const maskInfo = autoInterface.netmask ? autoInterface.netmask + (autoInterface.maskBits ? ' (' + autoInterface.maskBits + ')' : '') : (autoInterface.maskBits ? String(autoInterface.maskBits) : 'mask unknown') node.sysLogger?.info( 'Bind KNX Bus to interface (Auto) -> ' + autoInterface.name + ' (' + autoInterface.address + ' ' + maskInfo + ')' + '. Node ' + node.name ) } else { node.sysLogger?.info( 'Bind KNX Bus to interface (Auto). Node ' + node.name + (targetIP ? ' - no matching local interface found for ' + targetIP + '.' : '.') ) } } } // node.setKnxConnectionProperties(); 28/12/2021 Commented node.initKNXConnection = async () => { try { node.setKnxConnectionProperties() // 28/12/2021 Added } catch (error) { node.sysLogger?.error('setKnxConnectionProperties: ' + error.message) if (node.linkStatus !== 'disconnected') await node.Disconnect() return } // 12/08/2021 Avoid start connection if there are no knx-ultimate nodes linked to this gateway // At start, initKNXConnection is already called only if the gateway has clients, but in the successive calls from the error handler, this check is not done. if (node.nodeClients.length === 0) { try { node.sysLogger?.info('No nodes linked to this gateway ' + node.name) try { if (node.linkStatus !== 'disconnected') await node.Disconnect() } catch (error) { } return } catch (error) { } } try { // 02/01/2022 This is important to free the tunnel in case of hard disconnection. await node.Disconnect() } catch (error) { // node.sysLogger?.info(error) } try { // Unsetting handlers if node.knxConnection was existing try { if (node.knxConnection !== null && node.knxConnection !== undefined) { await node.knxConnection.Disconnect() node.sysLogger?.debug('removing old handlers. Node ' + node.name) node.knxConnection.removeAllListeners() } } catch (error) { node.sysLogger?.info('BANANA ERRORINO', error) } // node.knxConnectionProperties.localSocketAddress = { address: '192.168.2.2', port: 59000 } node.knxConnection = new knx.KNXClient(node.knxConnectionProperties) // Setting handlers // ###################################### node.knxConnection.on(knx.KNXClientEvents.indication, handleBusEvents) node.knxConnection.on(knx.KNXClientEvents.error, (err) => { try { node.sysLogger?.error('received KNXClientEvents.error: ' + (err.message === undefined ? err : err.message)) } catch (error) { } // 31/03/2022 Don't care about some errors if (err.message !== undefined && (err.message === 'ROUTING_LOST_MESSAGE' || err.message === 'ROUTING_BUSY')) { node.sysLogger?.error( 'KNXClientEvents.error: ' + (err.message === undefined ? err : err.message) + " consider DECREASING the transmission speed, by increasing the telegram's DELAY in the gateway configuration node!" ) return } node.Disconnect('Disconnected by error ' + (err.message === undefined ? err : err.message), 'red') node.sysLogger?.error('Disconnected by: ' + (err.message === undefined ? err : err.message)) }) node.knxConnection.on(knx.KNXClientEvents.disconnected, (info) => { if (node.linkStatus !== 'disconnected') { node.linkStatus = 'disconnected' node.sysLogger?.warn('Disconnected event %s', info) node.Disconnect('Disconnected by event: ' + info || '', 'red') // 11/03/2022 } }) node.knxConnection.on(knx.KNXClientEvents.close, (info) => { node.sysLogger?.debug('KNXClient socket closed.') node.linkStatus = 'disconnected' }) node.knxConnection.on(knx.KNXClientEvents.connected, (info) => { node.linkStatus = 'connected' // Start the timer to do initial read. if (node.timerDoInitialRead !== null) clearTimeout(node.timerDoInitialRead) node.timerDoInitialRead = setTimeout(() => { try { DoInitialReadFromKNXBusOrFile() } catch (error) { node.sysLogger?.error('DoInitialReadFromKNXBusOrFile ' + error.stack) } }, 1000) // 17/02/2020 Do initial read of all nodes requesting initial read const t = setTimeout(() => { // 21/03/2022 fixed possible memory leak. Previously was setTimeout without "let t = ". node.setAllClientsStatus('Connected.', 'green', 'On duty.') }, 500) node.sysLogger?.info('Connected to %o', info) }) node.knxConnection.on(knx.KNXClientEvents.connecting, (info) => { node.linkStatus = 'connecting' node.sysLogger?.debug('Connecting to' + info.ipAddr || '') node.setAllClientsStatus(info.ipAddr || '', 'grey', 'Connecting...') }) // ###################################### node.setAllClientsStatus('Connecting... ', 'grey', '') node.sysLogger?.info('perform websocket connection on ' + node.name) try { node.sysLogger?.info('Connecting... ' + node.name) node.knxConnection.Connect() } catch (error) { node.sysLogger?.error('node.knxConnection.Connect() ' + node.name + ': ' + error.message) node.linkStatus = 'disconnected' throw error } } catch (error) { if (node.sysLogger !== null) { node.sysLogger.error('Error in instantiating knxConnection ' + error.stack + ' Node ' + node.name) node.error('KNXUltimate-config: Error in instantiating knxConnection ' + error.message + ' Node ' + node.name) } node.linkStatus = 'disconnected' // 21/03/2022 fixed possible memory leak. Previously was setTimeout without "let t = ". const t = setTimeout(() => node.setAllClientsStatus('Error in instantiating knxConnection ' + error.message, 'red', 'Error'), 200) } } // Handle BUS events // --------------------------------------------------------------------------------------- function handleBusEvents (_datagram, _echoed) { // console.time('handleBusEven