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
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.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