UNPKG

knxultimate

Version:

KNX IP protocol implementation for Node. This is the ENGINE of Node-Red KNX-Ultimate node.

1,704 lines (1,559 loc) 141 kB
/** * Implements the main KNX client managing tunnelling and routing. * * Written in Italy with love, sun and passion, by Massimo Saccani. * * Released under the MIT License. * Use at your own risk; the author assumes no liability for damages. */ import dgram, { RemoteInfo, Socket as UDPSocket } from 'dgram' import net, { Socket as TCPSocket } from 'net' import * as crypto from 'crypto' import { ConnectionStatus, KNX_CONSTANTS } from './protocol/KNXConstants' import CEMIConstants from './protocol/cEMI/CEMIConstants' import CEMIFactory from './protocol/cEMI/CEMIFactory' import CEMIMessage from './protocol/cEMI/CEMIMessage' import KNXProtocol, { KnxMessage, KnxResponse } from './protocol/KNXProtocol' import KNXConnectResponse from './protocol/KNXConnectResponse' import HPAI, { KnxProtocol } from './protocol/HPAI' import TunnelCRI, { TunnelTypes } from './protocol/TunnelCRI' import KNXConnectionStateResponse from './protocol/KNXConnectionStateResponse' import * as errors from './errors' import * as ipAddressHelper from './util/ipAddressHelper' import KNXAddress from './protocol/KNXAddress' import KNXDataBuffer, { IDataPoint } from './protocol/KNXDataBuffer' import * as DPTLib from './dptlib' import KnxLog, { KNXLogger, LogLevel, module as createLogger, setLogLevel, KNXLoggerOptions, } from './KnxLog' import { KNXDescriptionResponse, KNXPacket } from './protocol' import KNXRoutingIndication from './protocol/KNXRoutingIndication' import KNXConnectRequest from './protocol/KNXConnectRequest' import KNXTunnelingRequest from './protocol/KNXTunnelingRequest' import { TypedEventEmitter } from './TypedEmitter' import KNXHeader from './protocol/KNXHeader' import KNXTunnelingAck from './protocol/KNXTunnelingAck' import KNXSearchResponse from './protocol/KNXSearchResponse' import KNXDisconnectResponse from './protocol/KNXDisconnectResponse' import { wait, getTimestamp } from './utils' import SerialFT12, { SerialFT12Options, SerialPortSummary, } from './transports/SerialFT12' import { performance } from 'perf_hooks' // KNX Secure helpers (moved inlined usage from SecureTunnelTCP) import { Keyring } from './secure/keyring' import { calculateMessageAuthenticationCodeCBC, encryptDataCtr, decryptCtr, } from './secure/security_primitives' import { SCF_ENCRYPTION_S_A_DATA, KNXIP, CEMI as SEC_CEMI, APCI, APCI_SEC, TPCI_DATA, SECURE_WRAPPER_TAG, SECURE_WRAPPER_CTR_SUFFIX, SECURE_WRAPPER_MAC_SUFFIX, SECURE_WRAPPER_OVERHEAD, KNXIP_HDR_SECURE_WRAPPER, KNXIP_HDR_TUNNELING_REQUEST, KNXIP_HDR_TUNNELING_ACK, KNXIP_HDR_TUNNELING_CONNECT_REQUEST, KNXIP_HDR_SECURE_SESSION_REQUEST, KNXIP_HDR_SECURE_SESSION_AUTHENTICATE, DATA_SECURE_CTR_SUFFIX, AUTH_CTR_IV, CONNECT_SEND_DELAY_MS, DEFAULT_STATUS_TIMEOUT_MS, SECURE_SESSION_TIMEOUT_MS, SECURE_AUTH_TIMEOUT_MS, SECURE_CONNECT_TIMEOUT_MS, HPAI_CONTROL_ENDPOINT_EMPTY, HPAI_DATA_ENDPOINT_EMPTY, CRD_TUNNEL_LINKLAYER, TUNNEL_CONN_HEADER_LEN, TUNNELING_ACK_TOTAL_LEN, WAIT_FOR_STATUS_DEFAULT_MS, KNXIP_HEADER_LEN, DEFAULT_SRC_IA_FALLBACK, PUBLIC_KEY_LEN, SECURE_SEQ_LEN, AES_BLOCK_LEN, MAC_LEN_FULL, MAC_LEN_SHORT, } from './secure/secure_knx_constants' export type DiscoveryInterface = { ip: string port: number name: string ia: string services: string[] type: 'tunnelling' | 'routing' transport?: 'UDP' | 'TCP' | 'Multicast' } // Secure config moved here to avoid dependency on separate class file export interface SecureConfig { tunnelInterfaceIndividualAddress?: string knxkeys_file_path?: string knxkeys_password?: string tunnelUserPassword?: string tunnelUserId?: number } export enum ConncetionState { STARTED = 'STARTED', CONNECTING = 'CONNECTING', CONNECTED = 'CONNECTED', DISCONNECTING = 'DISCONNECTING', DISCONNECTED = 'DISCONNECTED', } export enum SocketEvents { error = 'error', message = 'message', listening = 'listening', data = 'data', close = 'close', connect = 'connect', } export type KNXClientProtocol = | 'TunnelUDP' | 'Multicast' | 'TunnelTCP' | 'SerialFT12' export enum KNXClientEvents { error = 'error', disconnected = 'disconnected', discover = 'discover', indication = 'indication', connected = 'connected', ready = 'ready', response = 'response', connecting = 'connecting', ackReceived = 'ackReceived', close = 'close', descriptionResponse = 'descriptionResponse', } export interface KNXClientEventCallbacks { error: (error: Error) => void disconnected: (reason: string) => void discover: ( host: string, header: KNXHeader, message: KNXSearchResponse, ) => void getGatewayDescription: (searchResponse: KNXDescriptionResponse) => void indication: (packet: KNXRoutingIndication, echoed: boolean) => void connected: (options: KNXClientOptions) => void ready: () => void response: (host: string, header: KNXHeader, message: KnxResponse) => void connecting: (options: KNXClientOptions) => void ackReceived: ( packet: KNXTunnelingAck | KNXTunnelingRequest, ack: boolean, ) => void close: () => void descriptionResponse: (packet: KNXDescriptionResponse) => void } export type KNXClientOptions = { /** The physical address to be identified in the KNX bus */ physAddr?: string /** Connection keep alive timeout. Time after which the connection is closed if no ping received */ connectionKeepAliveTimeout?: number /** The IP of your KNX router/interface (for Routers, use "224.0.23.12") */ ipAddr?: string /** The port, default is "3671" */ ipPort?: number | string /** Default: "TunnelUDP". "Multicast" if you're connecting to a KNX Router. "TunnelUDP" for KNX Interfaces, or "TunnelTCP" for secure KNX Interfaces (not yet implemented) */ hostProtocol?: KNXClientProtocol /** True: Enables the secure connection. Leave false until KNX-Secure has been released. */ isSecureKNXEnabled?: boolean /** Avoid sending/receive the ACK telegram. Leave false. If you encounter issues with old interface, set it to true */ suppress_ack_ldatareq?: boolean /** The local IP address to be used to connect to the KNX/IP Bus. Leave blank, will be automatically filled by KNXUltimate */ localIPAddress?: string /** Specifies the local eth interface to be used to connect to the KNX Bus. */ interface?: string /** Local socket address. Automatically filled by KNXClient */ localSocketAddress?: string // ** Local queue interval between each KNX telegram. Default is 1 telegram each 25ms KNXQueueSendIntervalMilliseconds?: number /** Enables sniffing mode to monitor KNX */ sniffingMode?: boolean /** Sets the tunnel_endpoint with the localIPAddress instead of the standard 0.0.0.0 */ theGatewayIsKNXVirtual?: boolean /** Optional configuration for KNX/IP Secure over TCP (handshake + Data Secure helpers). */ secureTunnelConfig?: SecureConfig /** Secure multicast: wait to send until timer is authenticated (default: true) */ secureRoutingWaitForTimer?: boolean /** Serial FT1.2 configuration (used when hostProtocol === 'SerialFT12') */ serialInterface?: SerialFT12Options } & KNXLoggerOptions const optionsDefaults: KNXClientOptions = { physAddr: '', connectionKeepAliveTimeout: KNX_CONSTANTS.CONNECTION_ALIVE_TIME, ipAddr: '224.0.23.12', ipPort: 3671, hostProtocol: 'Multicast', isSecureKNXEnabled: false, suppress_ack_ldatareq: false, loglevel: 'info', localIPAddress: '', interface: '', KNXQueueSendIntervalMilliseconds: 25, theGatewayIsKNXVirtual: false, secureRoutingWaitForTimer: true, } export enum KNXTimer { /** Triggers when an ACK is not received in time */ ACK = 'ack', /** Delay between heartbeats */ HEARTBEAT = 'heartbeat', /** Triggers when no connection state response is received */ CONNECTION_STATE = 'connection_state', /** Waiting for a connect response */ CONNECTION = 'connection', /** Delay before sending the connect request */ CONNECT_REQUEST = 'connect_request', /** Delay after receiving a disconnect request */ DISCONNECT = 'disconnect', /** Waits for discovery responses */ DISCOVERY = 'discovery', /** Waits for the gateway description gather responses */ GATEWAYDESCRIPTION = 'GatewayDescription', } export type SnifferPacket = { reqType?: string request?: string response?: string resType?: string /** Time in ms between this request and the previous */ deltaReq: number /** Time in ms between the request and the response */ deltaRes?: number } interface KNXQueueItem { knxPacket: KNXPacket ACK: KNXTunnelingRequest expectedSeqNumberForACK: number } export default class KNXClient extends TypedEventEmitter<KNXClientEventCallbacks> { private _channelID: number private _connectionState: string private _numFailedTelegramACK: number private _clientTunnelSeqNumber: number private _options: KNXClientOptions private _peerHost: string private _peerPort: number private _heartbeatFailures: number private _heartbeatRunning: boolean private max_HeartbeatFailures: number private _awaitingResponseType: number private _clientSocket: UDPSocket | TCPSocket private _serialDriver?: SerialFT12 private sysLogger: KNXLogger private _clearToSend = false private socketReady = false private timers: Map<KNXTimer, NodeJS.Timeout> public physAddr: KNXAddress public theGatewayIsKNXVirtual: boolean private commandQueue: Array<KNXQueueItem> = [] private exitProcessingKNXQueueLoop: boolean private currentItemHandledByTheQueue: KNXQueueItem private queueLock = false private sniffingPackets: SnifferPacket[] private lastSnifferRequest: number // ==== KNX/IP Secure (migrated from SecureTunnelTCP) ==== private _tcpRxBuffer: Buffer private _secureSessionKey?: Buffer private _secureSessionId: number = 0 private _secureWrapperSeq: number = 0 // 6-byte counter (we store as number increment) private _secureTunnelSeq: number = 0 // 1-byte seq in tunneling connection header private _securePrivateKey?: crypto.KeyObject private _securePublicKey?: Buffer // 32 bytes raw X25519 public key private _secureUserId: number = 2 private _secureUserPasswordKey?: Buffer private _secureGroupKeys: Map<number, Buffer> = new Map() private _secureSendSeq48: bigint = 0n private _secureSerial: Buffer = Buffer.from('000000000000', 'hex') private _secureAssignedIa: number = 0 private _secureKeyring?: Keyring // Track candidate secure interface IAs for fallback selection private _secureCandidateIAs: string[] = [] private _secureCandidateIndex: number = 0 // Track hosts we have already probed with SECURE_SEARCH_REQUEST (unicast) private _secureSearchProbed: Set<string> = new Set() // ==== KNX/IP Secure Group (routing over multicast) ==== private _secureBackboneKey?: Buffer private _secureRoutingTimerOffsetMs: number = 0 private _secureRoutingTimerAuthenticated: boolean = false private _secureRoutingLatencyMs: number = 1000 // Logging helpers use KNXClient loglevel; no separate boolean private _secureHandshakeSessionTimer?: NodeJS.Timeout private _secureHandshakeAuthTimer?: NodeJS.Timeout private _secureHandshakeConnectTimer?: NodeJS.Timeout // Secure routing (multicast) initial timer sync helper private _secureRoutingSyncTimer?: NodeJS.Timeout private _secureHandshakeState?: | 'connecting' | 'session' | 'auth' | 'connect' get udpSocket() { if (this._clientSocket instanceof UDPSocket) { return this._clientSocket } return null } get tcpSocket() { if (this._clientSocket instanceof TCPSocket) { return this._clientSocket } return null } private isSerialTransport(): boolean { return this._options.hostProtocol === 'SerialFT12' } constructor( options: KNXClientOptions, createSocket?: (client: KNXClient) => void, ) { super() this.timers = new Map() // This is the KNX telegram's queue list this.commandQueue = [] this.exitProcessingKNXQueueLoop = false if (options === undefined) { options = optionsDefaults } else { options = { ...optionsDefaults, ...options, } } this._options = options this.sniffingPackets = [] this.sysLogger = createLogger(this._options.setPrefix || 'KNXEngine') if (this._options.loglevel) { setLogLevel(this._options.loglevel) } this._channelID = null this._connectionState = ConncetionState.DISCONNECTED this._numFailedTelegramACK = 0 this._clientTunnelSeqNumber = -1 this._options.connectionKeepAliveTimeout = KNX_CONSTANTS.CONNECTION_ALIVE_TIME this._peerHost = this._options.ipAddr this._peerPort = parseInt(this._options.ipPort as string, 10) this._options.localSocketAddress = options.localSocketAddress this._heartbeatFailures = 0 this.max_HeartbeatFailures = 3 this._awaitingResponseType = null this._clientSocket = null // Configure the limiter try { if (Number(this._options.KNXQueueSendIntervalMilliseconds) < 20) { this._options.KNXQueueSendIntervalMilliseconds = 20 // Protection avoiding handleKNXQueue hangs } } catch (error) { this._options.KNXQueueSendIntervalMilliseconds = 25 this.sysLogger.error( `KNXQueueSendIntervalMilliseconds:${error.message}. Defaulting to 25`, ) } // add an empty error listener, without this // every "error" emitted throws an unhandled exception this.on('error', (error) => { this.sysLogger.error(error.stack) }) if (this._options.physAddr !== '') { this.physAddr = KNXAddress.createFromString(this._options.physAddr) } try { this._options.localIPAddress = ipAddressHelper.getLocalAddress( this._options.interface, ) } catch (error) { this.sysLogger.error( `ipAddressHelper.getLocalAddress:${error.message}`, ) throw error } if (createSocket) { createSocket(this) } else { this.createSocket() } } private createSocket() { if (this.isSerialTransport()) { this.socketReady = false return } if (this._options.hostProtocol === 'TunnelUDP') { this._clientSocket = dgram.createSocket({ type: 'udp4', reuseAddr: true, }) as UDPSocket this.udpSocket.on( SocketEvents.message, (msg: Buffer, rinfo: RemoteInfo) => { try { // TunnelUDP never uses IP Secure wrapper; pass through this.processInboundMessage(msg, rinfo) } catch (e) { this.emit( KNXClientEvents.error, e instanceof Error ? e : new Error('UDP data error'), ) } }, ) this.udpSocket.on(SocketEvents.error, (error) => { this.socketReady = false this.emit(KNXClientEvents.error, error) }) this.udpSocket.on(SocketEvents.close, () => { this.socketReady = false this.exitProcessingKNXQueueLoop = true // For unexpected closes, emit a disconnected event if ( this._connectionState !== ConncetionState.DISCONNECTING && this._connectionState !== ConncetionState.DISCONNECTED ) { try { this.setDisconnected('Socket closed by peer').catch( () => {}, ) } catch {} } this.emit(KNXClientEvents.close) }) this.udpSocket.on(SocketEvents.listening, () => { this.socketReady = true this.handleKNXQueue() }) this.udpSocket.bind( { // port: this._peerPort, // Local port shall be assigned by the socket. address: this._options.localIPAddress, // Force UDP to be heard trough this interface }, () => { try { // For multicast SEARCH_REQUEST sending, ensure correct iface and TTL try { this.udpSocket.setMulticastInterface( this._options.localIPAddress, ) this.udpSocket.setMulticastTTL(55) } catch {} this.udpSocket.setTTL(55) if (this._options.localSocketAddress === undefined) { this._options.localSocketAddress = this.udpSocket.address().address } } catch (error) { this.sysLogger.error( `UDP: Error setting SetTTL ${error.message}` || '', ) } }, ) } else if (this._options.hostProtocol === 'TunnelTCP') { // KNX/IP Secure over TCP handled inline this.initTcpSocket() } else if (this._options.hostProtocol === 'Multicast') { this._clientSocket = dgram.createSocket({ type: 'udp4', reuseAddr: true, }) as UDPSocket // this._clientSocket.removeAllListeners() this.udpSocket.on(SocketEvents.listening, () => { this.socketReady = true this.handleKNXQueue() // For plain multicast, emit connected at listening; for secure multicast wait for timer auth (0955/0950) if ( this._connectionState === ConncetionState.CONNECTING && !this._options.isSecureKNXEnabled ) { this._connectionState = ConncetionState.CONNECTED this._numFailedTelegramACK = 0 this.clearToSend = true this._clientTunnelSeqNumber = -1 this.emit(KNXClientEvents.connected, this._options) } // If secure routing, proactively send a TimerNotify once to authenticate timer if ( this._options.hostProtocol === 'Multicast' && this._options.isSecureKNXEnabled ) { // small delay to ensure keyring/backbone key are loaded try { clearTimeout(this._secureRoutingSyncTimer as any) } catch {} this._secureRoutingSyncTimer = setTimeout(() => { try { // Ensure backbone key available if (!this._secureBackboneKey) this.secureEnsureKeyring() this.secureSendTimerNotify() } catch {} }, 120) } }) this.udpSocket.on( SocketEvents.message, (msg: Buffer, rinfo: RemoteInfo) => { try { if (this._options.isSecureKNXEnabled) { this.secureOnUdpData(msg, rinfo) return } this.processInboundMessage(msg, rinfo) } catch (e) { this.emit( KNXClientEvents.error, e instanceof Error ? e : new Error('UDP data error'), ) } }, ) this.udpSocket.on(SocketEvents.error, (error) => { this.socketReady = false this.emit(KNXClientEvents.error, error) }) this.udpSocket.on(SocketEvents.close, () => { this.socketReady = false this.exitProcessingKNXQueueLoop = true if ( this._connectionState !== ConncetionState.DISCONNECTING && this._connectionState !== ConncetionState.DISCONNECTED ) { try { this.setDisconnected('Socket closed by peer').catch( () => {}, ) } catch {} } this.emit(KNXClientEvents.close) }) // The multicast traffic is not sent to a specific local IP, so we cannot set the this._options.localIPAddress in the bind // otherwise the socket will never ever receive a packet. this.udpSocket.bind( this._peerPort, this._options.theGatewayIsKNXVirtual ? this._options.localIPAddress || '0.0.0.0' : '0.0.0.0', () => { try { this.udpSocket.setMulticastTTL(55) this.udpSocket.setMulticastInterface( this._options.localIPAddress, ) // Ensure we receive our own multicast (useful for local echo/diagnostics) try { this.udpSocket.setMulticastLoopback(true) } catch {} this.sysLogger.debug( `[${getTimestamp()}] Multicast socket bound on ${this._options.localIPAddress || '0.0.0.0'}:${this._peerPort}`, ) } catch (error) { this.sysLogger.error( `Multicast: Error setting SetTTL ${error.message}` || '', ) } try { this.udpSocket.addMembership( this._peerHost, this._options.localIPAddress, ) this.sysLogger.debug( `[${getTimestamp()}] Joined multicast group ${this._peerHost} on ${this._options.localIPAddress}`, ) } catch (err) { this.sysLogger.error( 'Multicast: cannot add membership (%s)', err, ) this.emit(KNXClientEvents.error, err) } }, ) } } // Initialize/Reinitialize TCP socket and its listeners for KNX/IP Secure over TCP private initTcpSocket() { this._clientSocket = new net.Socket() // Buffer incoming TCP to complete frames this._tcpRxBuffer = Buffer.alloc(0) this.tcpSocket.on('connect', () => { // TCP connected, start secure session handshake this.socketReady = true // Reset queue exit flag on fresh TCP connect this.exitProcessingKNXQueueLoop = false this.secureStartSession().catch((err) => { this.emit(KNXClientEvents.error, err) }) }) this.tcpSocket.on('data', (data: Buffer) => { try { this.secureOnTcpData(data) } catch (e) { this.emit( KNXClientEvents.error, e instanceof Error ? e : new Error('TCP data error'), ) } }) this.tcpSocket.on('error', (error) => { this.socketReady = false this.emit(KNXClientEvents.error, error) }) this.tcpSocket.on('close', () => { this.socketReady = false this.exitProcessingKNXQueueLoop = true try { this.sysLogger.debug( `[${getTimestamp()}] TCP close: set exitProcessingKNXQueueLoop=true`, ) } catch {} // If the socket closed unexpectedly, propagate a disconnected event if ( this._connectionState !== ConncetionState.DISCONNECTING && this._connectionState !== ConncetionState.DISCONNECTED ) { try { this.setDisconnected('Socket closed by peer').catch( () => {}, ) } catch {} } this.emit(KNXClientEvents.close) }) } private async connectSerialTransport() { const options: SerialFT12Options = { ...(this._options.serialInterface || {}), } this._peerHost = options.path || '/dev/ttyAMA0' this._peerPort = 0 // Prepare KNX Data Secure for serial mode (KBerry): // if a .knxkeys file is configured, load group keys so that // maybeApplyDataSecure / maybeDecryptDataSecure can work on cEMI frames. if (this._options.isSecureKNXEnabled) { try { await this.secureEnsureKeyring() } catch (err) { try { this.sysLogger.error( `[${getTimestamp()}] Serial FT1.2: secure keyring error: ${(err as Error).message}`, ) } catch {} } } this._serialDriver = new SerialFT12(options) this._serialDriver.on('cemi', (payload) => this.handleSerialCemi(payload), ) this._serialDriver.on('error', (err) => { this.emit( KNXClientEvents.error, err instanceof Error ? err : new Error(String(err)), ) }) this._serialDriver.on('close', () => { if (this.isSerialTransport()) { this.socketReady = false if ( this._connectionState !== ConncetionState.DISCONNECTED && this._connectionState !== ConncetionState.DISCONNECTING ) { this.setDisconnected('Serial FT1.2 port closed').catch( () => {}, ) } } }) await this._serialDriver.open() } private async closeSerialTransport() { if (!this._serialDriver) return try { await this._serialDriver.close() } catch (error) { this.sysLogger.warn( `[${getTimestamp()}] Serial FT1.2 close error: ${(error as Error).message}`, ) } finally { this._serialDriver = undefined this.socketReady = false } } private handleSerialCemi(payload: Buffer) { try { if (!payload || payload.length < 2) return const msgCode = payload.readUInt8(0) // eslint-disable-next-line default-case switch (msgCode) { case CEMIConstants.L_DATA_IND: { const cemi = CEMIFactory.createFromBuffer( msgCode, payload, 1, ) this.ensurePlainCEMI(cemi) this.emit( KNXClientEvents.indication, new KNXRoutingIndication(cemi), false, ) break } case CEMIConstants.L_DATA_CON: { break } } } catch (error) { this.sysLogger.error( `[${getTimestamp()}] Serial FT1.2 parse error: ${ (error as Error).message }`, ) } } private extractCemiMessage(packet: KNXPacket): CEMIMessage { const cemi = (packet as any)?.cEMIMessage if (!cemi) { throw new Error('KNX packet does not contain a cEMI message') } return cemi } /** * The channel ID of the connection. Only defined after a successful connection */ get channelID() { return this._channelID } /** * Handle the busy state, for example while waiting for ACK. When true means we can send new telegrams to bus */ get clearToSend(): boolean { return this._clearToSend } set clearToSend(val: boolean) { this._clearToSend = val if (val) { this.handleKNXQueue() } } private getKNXDataBuffer(data: Buffer, dptid: string | number) { if (typeof dptid === 'number') { dptid = dptid.toString() } const adpu = {} as DPTLib.APDU DPTLib.populateAPDU(data, adpu, dptid) const iDatapointType: number = parseInt( dptid.substring(0, dptid.indexOf('.')), ) const isSixBits: boolean = adpu.bitlength <= 6 this.sysLogger.debug( `[${getTimestamp()}] ` + `isSixBits:${isSixBits} Includes (should be = isSixBits):${[ 1, 2, 3, 5, 9, 10, 11, 14, 18, ].includes(iDatapointType)} ADPU BitLength:${adpu.bitlength}`, ) const datapoint: IDataPoint = { id: '', value: 'any', type: { type: isSixBits }, bind: null, read: () => null, write: null, } return new KNXDataBuffer(adpu.data, datapoint) } /** Waits till providden event occurs for at most the providden timeout */ private async waitForEvent(event: KNXClientEvents, timeout: number) { let resolveRef: () => void return Promise.race<void>([ new Promise<void>((resolve) => { resolveRef = resolve this.once(event, resolve) }), wait(timeout), ]).then(() => { this.off(event, resolveRef) }) } private setTimer(type: KNXTimer, cb: () => void, delay: number) { if (this.timers.has(type)) { clearTimeout(this.timers.get(type)) this.timers.delete(type) // TODO: should we throw error? this.sysLogger.warn(`Timer "${type}" was already running`) } this.timers.set( type, setTimeout(() => { this.timers.delete(type) cb() }, delay), ) } private clearTimer(type: KNXTimer) { if (this.timers.has(type)) { clearTimeout(this.timers.get(type)) this.timers.delete(type) } } private clearAllTimers() { // use dedicated methods where possible this.stopDiscovery() this.stopHeartBeat() this.stopGatewayDescription() // clear all other timers for (const timer of this.timers.keys()) { this.clearTimer(timer) } } private processKnxPacketQueueItem(_knxPacket: KNXPacket): Promise<boolean> { return new Promise((resolve) => { // Prepare the debug log ************************ if (this.sysLogger.level === 'debug') { if ( _knxPacket instanceof KNXTunnelingRequest || _knxPacket instanceof KNXRoutingIndication ) { // Composing debug string let sTPCI = '' if (_knxPacket.cEMIMessage.npdu.isGroupRead) { sTPCI = 'Read' } if (_knxPacket.cEMIMessage.npdu.isGroupResponse) { sTPCI = 'Response' } if (_knxPacket.cEMIMessage.npdu.isGroupWrite) { sTPCI = 'Write' } let sDebugString = '' sDebugString = `peerHost:${this._peerHost}:${this._peerPort}` sDebugString += ` dstAddress: ${_knxPacket.cEMIMessage.dstAddress.toString()}` sDebugString += ` channelID:${this._channelID === null || this._channelID === undefined ? 'None' : this._channelID}` sDebugString += ` npdu: ${sTPCI}` sDebugString += ` knxHeader: ${_knxPacket.constructor.name}` sDebugString += ` raw: ${JSON.stringify(_knxPacket)}` this.sysLogger.debug( `[${getTimestamp()}] ` + `KNXEngine: <outgoing telegram>: ${sDebugString} `, ) } else if (_knxPacket instanceof KNXTunnelingAck) { this.sysLogger.debug( `[${getTimestamp()}] ` + `KNXEngine: <outgoing telegram ACK>:${this.getKNXConstantName(_knxPacket.status)} channelID:${_knxPacket.channelID} seqCounter:${_knxPacket.seqCounter}`, ) } } // End Prepare the debug log ************************ if (this.isSerialTransport()) { try { const cemi = this.extractCemiMessage(_knxPacket) if ( this._options.isSecureKNXEnabled && _knxPacket instanceof KNXRoutingIndication ) { this.maybeApplyDataSecure(cemi as any) try { _knxPacket.length = cemi.length ?? _knxPacket.length } catch {} } this._serialDriver ?.sendCemiPayload(cemi.toBuffer()) .then(() => resolve(true)) .catch((error) => { this.emit( KNXClientEvents.error, error instanceof Error ? error : new Error(String(error)), ) resolve(false) }) } catch (error) { this.emit( KNXClientEvents.error, error instanceof Error ? error : new Error(String(error)), ) resolve(false) } return } if ( this._options.hostProtocol === 'Multicast' || this._options.hostProtocol === 'TunnelUDP' ) { try { // If Multicast+Secure, apply Data Secure (if GA has key) before wrapping try { if ( this._options.hostProtocol === 'Multicast' && this._options.isSecureKNXEnabled && _knxPacket instanceof KNXRoutingIndication ) { const kri = _knxPacket as KNXRoutingIndication & { header: any length: number } this.maybeApplyDataSecure(kri.cEMIMessage as any) // Update KNX/IP header length to include updated cEMI length try { kri.length = kri.cEMIMessage?.length ?? kri.length kri.header.length = KNX_CONSTANTS.HEADER_SIZE_10 + kri.length } catch {} } } catch {} let outBuf = _knxPacket.toBuffer() if ( this._options.hostProtocol === 'Multicast' && this._options.isSecureKNXEnabled && (_knxPacket instanceof KNXRoutingIndication || (_knxPacket as any)?.header?.service_type === KNX_CONSTANTS.ROUTING_INDICATION) ) { try { outBuf = this.secureWrapRouting(outBuf) if (this.isLevelEnabled('debug')) { this.sysLogger.debug( `[${getTimestamp()}] TX 0950 SecureWrapper (routing) len=${outBuf.length}`, ) } } catch (e) { this.sysLogger.error( `Secure multicast wrap error: ${(e as Error).message}`, ) } } this.udpSocket.send( outBuf, this._peerPort, this._peerHost, (error) => { if (error) { this.sysLogger.error( `Sending KNX packet: Send UDP sending error: ${error.message}`, ) this.emit(KNXClientEvents.error, error) } resolve(!error) }, ) } catch (error) { this.sysLogger.error( `Sending KNX packet: Send UDP Catch error: ${ (error as Error).message } ${typeof _knxPacket} seqCounter:${ (_knxPacket as any)?.seqCounter }`, ) this.emit(KNXClientEvents.error, error as Error) resolve(false) } } else if (this._options.hostProtocol === 'TunnelTCP') { // KNX Secure over TCP: wrap KNX/IP frame in SecureWrapper and send via TCP try { // Ensure Data Secure is applied at send time (after leaving the queue) if ( this._options.isSecureKNXEnabled && _knxPacket instanceof KNXTunnelingRequest && (_knxPacket as any).cEMIMessage?.msgCode === CEMIConstants.L_DATA_REQ ) { // Apply Data Secure right before sending this.maybeApplyDataSecure( (_knxPacket as any).cEMIMessage, ) // IMPORTANT: update KNX/IP header length to include new cEMI length try { const ktr = _knxPacket as KNXTunnelingRequest const cemiLen = ktr.cEMIMessage?.length ?? 0 // Header.length includes header size (10) + body length ktr.header.length = KNX_CONSTANTS.HEADER_SIZE_10 + (4 + cemiLen) } catch {} } // Debug before wrapping: show if APDU is secure/plain, GA, src, flags and seq48 try { if (_knxPacket instanceof KNXTunnelingRequest) { const ktr: any = _knxPacket const cemi: any = ktr?.cEMIMessage const dstStr = cemi?.dstAddress?.toString?.() const srcStr = cemi?.srcAddress?.toString?.() const ctrlBuf: Buffer = cemi?.control?.toBuffer?.() const flags16 = Buffer.isBuffer(ctrlBuf) ? (ctrlBuf[0] << 8) | ctrlBuf[1] : undefined const isSecApdu = !!( cemi?.npdu && (cemi.npdu.tpci & 0xff) === APCI_SEC.HIGH && (cemi.npdu.apci & 0xff) === APCI_SEC.LOW ) let scf: number | undefined let seq48Hex: string | undefined if (isSecApdu) { const dbuf: Buffer = cemi.npdu.dataBuffer?.value if ( Buffer.isBuffer(dbuf) && dbuf.length >= 1 + SECURE_SEQ_LEN ) { scf = dbuf[0] const seq = dbuf.subarray( 1, 1 + SECURE_SEQ_LEN, ) seq48Hex = seq.toString('hex') } } this.sysLogger.debug( `[${getTimestamp()}] ` + `TX TunnelTCP: dst=${dstStr} src=${srcStr} flags=0x${( flags16 ?? 0 ).toString( 16, )} dataSecure=${isSecApdu} scf=${ typeof scf === 'number' ? scf : 'n/a' } seq48=${seq48Hex ?? 'n/a'}`, ) try { if (this.isLevelEnabled('debug')) { const innerHex = ktr .toBuffer() .toString('hex') this.sysLogger.debug( `[${getTimestamp()}] TX inner (KNX/IP TunnelReq): ${innerHex}`, ) } } catch {} } } catch {} const inner = _knxPacket.toBuffer() const payload = this._options.isSecureKNXEnabled ? this.secureWrap(inner) : inner this.tcpSocket.write(payload, (error) => { if (error) { this.sysLogger.error( `Sending KNX packet: Send TCP sending error: ${error.message}` || 'Undef error', ) this.emit(KNXClientEvents.error, error) } resolve(!error) }) } catch (error) { this.sysLogger.error( `Sending KNX packet: Send TCP Catch error: ${(error as Error).message}` || 'Undef error', ) this.emit(KNXClientEvents.error, error as Error) resolve(false) } } }) } private async handleKNXQueue() { if (this.queueLock) { this.sysLogger.debug( `[${getTimestamp()}] ` + `KNXClient: handleKNXQueue: HandleQueue has called, but the queue loop is already running. Exit.`, ) return } this.sysLogger.debug( `[${getTimestamp()}] ` + `KNXClient: handleKNXQueue: Start Processing queued KNX. Found ${this.commandQueue.length} telegrams in queue.`, ) // lock the queue this.queueLock = true // Limiter: limits max telegrams per second while (this.commandQueue.length > 0) { if (!this.clearToSend) { this.sysLogger.debug( `[${getTimestamp()}] ` + `KNXClient: handleKNXQueue: Clear to send is false. Pause processing queue.`, ) break } if (this.exitProcessingKNXQueueLoop) { this.sysLogger.debug( `[${getTimestamp()}] ` + `KNXClient: handleKNXQueue: exitProcessingKNXQueueLoop is true. Exit processing queue loop`, ) break } if (this.socketReady === false) { this.sysLogger.debug( `[${getTimestamp()}] ` + `KNXClient: handleKNXQueue: Socket is not ready. Stop processing queue.`, ) break } const item = this.commandQueue.pop() // Secure multicast gating: wait for timer authentication before sending RoutingIndication if ( this._options.hostProtocol === 'Multicast' && this._options.isSecureKNXEnabled && (this._options.secureRoutingWaitForTimer ?? true) && !this._secureRoutingTimerAuthenticated && item.knxPacket instanceof KNXRoutingIndication ) { try { this.sysLogger.debug( `[${getTimestamp()}] Secure multicast: waiting timer auth, deferring 0950 send`, ) } catch {} // push back item and wait briefly this.commandQueue.push(item) await wait(200) continue } this.currentItemHandledByTheQueue = item // Associa il sequence number di tunneling al momento dell'invio try { if (this._options.hostProtocol === 'TunnelTCP') { // Solo per KNXTunnelingRequest: il seq dell'ACK deve eguagliare quello ricevuto, non va incrementato if (item.knxPacket instanceof KNXTunnelingRequest) { const ktr = item.knxPacket as any const seq = this.secureIncTunnelSeq() ktr.seqCounter = seq if (item.ACK) { item.expectedSeqNumberForACK = seq } try { this.sysLogger.debug( `[${getTimestamp()}] Assign tunnel seq=${seq} ch=${ktr?.channelID} dst=${ktr?.cEMIMessage?.dstAddress?.toString?.()}`, ) } catch {} } } } catch {} if ( item.ACK !== undefined && this._options.hostProtocol !== 'TunnelTCP' ) { this.setTimerWaitingForACK(item.ACK) } if (!(await this.processKnxPacketQueueItem(item.knxPacket))) { this.sysLogger.error( `KNXClient: handleKNXQueue: returning from processKnxPacketQueueItem ${JSON.stringify(item)}`, ) // Clear the queue this.commandQueue = [] break } await wait(this._options.KNXQueueSendIntervalMilliseconds) } this.queueLock = false this.sysLogger.debug( `[${getTimestamp()}] ` + `KNXClient: handleKNXQueue: End Processing queued KNX.`, ) } /** * Write knxPacket to socket */ send( _knxPacket: KNXPacket, _ACK: KNXTunnelingRequest, _priority: boolean, _expectedSeqNumberForACK: number, ): void { const toBeAdded: KNXQueueItem = { knxPacket: _knxPacket, ACK: _ACK, expectedSeqNumberForACK: _expectedSeqNumberForACK, } if (this._options.sniffingMode) { const buffer = _knxPacket.toBuffer() this.sniffingPackets.push({ reqType: _knxPacket.constructor.name, request: buffer.toString('hex'), deltaReq: this.lastSnifferRequest ? Date.now() - this.lastSnifferRequest : 0, }) this.lastSnifferRequest = Date.now() } if (_priority) { this.commandQueue.push(toBeAdded) // Put the item as first to be sent. this.clearToSend = true } else { this.commandQueue.unshift(toBeAdded) // Put the item as last to be sent. } this.handleKNXQueue() this.sysLogger.debug( `[${getTimestamp()}] ` + `KNXClient: <added telegram to queue> queueLength:${this.commandQueue.length} priority:${_priority} type:${this.getKNXConstantName(toBeAdded.knxPacket.type)} channelID:${toBeAdded.ACK?.channelID || 'filled later'} seqCounter:${toBeAdded.ACK?.seqCounter || 'filled later'}`, ) } /** Sends a WRITE telegram to the BUS. * `dstAddress` is the group address (for example "0/0/1"), * `data` is the value you want to send (for example true), * `dptid` is a string/number representing the datapoint (for example "5.001") */ write( dstAddress: KNXAddress | string, data: any, dptid: string | number, ): void { if (this._connectionState !== ConncetionState.CONNECTED) throw new Error( 'The socket is not connected. Unable to access the KNX BUS', ) // Get the Data Buffer from the plain value const knxBuffer = this.getKNXDataBuffer(data, dptid) if (typeof dstAddress === 'string') dstAddress = KNXAddress.createFromString( dstAddress, KNXAddress.TYPE_GROUP, ) const srcAddress = this.physAddr if (this._options.hostProtocol === 'Multicast') { // Multicast: per KNX Routing spec, inject as L_DATA_IND const cEMIMessage = CEMIFactory.newLDataIndicationMessage( 'write', srcAddress, dstAddress, knxBuffer, ) cEMIMessage.control.ack = 0 cEMIMessage.control.broadcast = 1 cEMIMessage.control.priority = 3 cEMIMessage.control.addressType = 1 cEMIMessage.control.hopCount = 6 const knxPacketRequest = KNXProtocol.newKNXRoutingIndication(cEMIMessage) this.send(knxPacketRequest, undefined, false, this.getSeqNumber()) // 06/12/2021 Multicast automatically echoes telegrams } else if (this.isSerialTransport()) { // Serial FT1.2 (KBerry): send as L_DATA_REQ over cEMI/FT1.2 const cEMIMessage = CEMIFactory.newLDataRequestMessage( 'write', srcAddress, dstAddress, knxBuffer, ) // Request bus ACK unless suppressed cEMIMessage.control.ack = this._options.suppress_ack_ldatareq ? 0 : 1 cEMIMessage.control.broadcast = 1 cEMIMessage.control.priority = 3 cEMIMessage.control.addressType = 1 cEMIMessage.control.hopCount = 6 const knxPacketRequest = KNXProtocol.newKNXRoutingIndication(cEMIMessage) this.send(knxPacketRequest, undefined, false, this.getSeqNumber()) // Echo on local client as indication this.ensurePlainCEMI(cEMIMessage) this.emit(KNXClientEvents.indication, knxPacketRequest as any, true) } else { // Tunneling const cEMIMessage = CEMIFactory.newLDataRequestMessage( 'write', srcAddress, dstAddress, knxBuffer, ) // Tunnelling UDP: request bus ACK unless suppressed; TunnelTCP: no bus ACK cEMIMessage.control.ack = // eslint-disable-next-line no-nested-ternary this._options.hostProtocol === 'TunnelTCP' ? 0 : this._options.suppress_ack_ldatareq ? 0 : 1 cEMIMessage.control.broadcast = 1 cEMIMessage.control.priority = 3 cEMIMessage.control.addressType = 1 cEMIMessage.control.hopCount = 6 // Data Secure si applica solo in TunnelTCP // Nota: per TunnelTCP, il seq di tunneling viene assegnato al momento dell'invio in handleKNXQueue const seqNum: number = this._options.hostProtocol === 'TunnelTCP' ? 0 : this.incSeqNumber() const knxPacketRequest = KNXProtocol.newKNXTunnelingRequest( this._channelID, seqNum, cEMIMessage, ) if (!this._options.suppress_ack_ldatareq) { this.send(knxPacketRequest, knxPacketRequest, false, seqNum) } else { this.send(knxPacketRequest, undefined, false, seqNum) } // 06/12/2021 Echo the sent telegram. Emit entire telegram with plain cEMI this.ensurePlainCEMI(knxPacketRequest.cEMIMessage) this.emit(KNXClientEvents.indication, knxPacketRequest as any, true) } } /** * Sends a RESPONSE telegram to the BUS. * `dstAddress` is the group address (for example "0/0/1"), * `data` is the value you want to send (for example true), * `dptid` is a string/number representing the datapoint (for example "5.001") */ respond( dstAddress: KNXAddress | string, data: Buffer, dptid: string | number, ): void { if (this._connectionState !== ConncetionState.CONNECTED) throw new Error( 'The socket is not connected. Unable to access the KNX BUS', ) // Get the Data Buffer from the plain value const knxBuffer = this.getKNXDataBuffer(data, dptid) if (typeof dstAddress === 'string') dstAddress = KNXAddress.createFromString( dstAddress, KNXAddress.TYPE_GROUP, ) const srcAddress = this.physAddr if (this._options.hostProtocol === 'Multicast') { // Multicast: per KNX Routing spec, inject as L_DATA_IND const cEMIMessage = CEMIFactory.newLDataIndicationMessage( 'response', srcAddress, dstAddress, knxBuffer, ) cEMIMessage.control.ack = 0 // No ack like telegram sent from ETS (0 means don't care) cEMIMessage.control.broadcast = 1 cEMIMessage.control.priority = 3 cEMIMessage.control.addressType = 1 cEMIMessage.control.hopCount = 6 const knxPacketRequest = KNXProtocol.newKNXRoutingIndication(cEMIMessage) this.send(knxPacketRequest, undefined, false, this.getSeqNumber()) // 06/12/2021 Multicast automatically echoes telegrams } else if (this.isSerialTransport()) { // Serial FT1.2 (KBerry): send as L_DATA_REQ over cEMI/FT1.2 const cEMIMessage = CEMIFactory.newLDataRequestMessage( 'response', srcAddress, dstAddress, knxBuffer, ) // No ACK request on bus for responses cEMIMessage.control.ack = 0 cEMIMessage.control.broadcast = 1 cEMIMessage.control.priority = 3 cEMIMessage.control.addressType = 1 cEMIMessage.control.hopCount = 6 const knxPacketRequest = KNXProtocol.newKNXRoutingIndication(cEMIMessage) this.send(knxPacketRequest, undefined, false, this.getSeqNumber()) this.ensurePlainCEMI(cEMIMessage) this.emit(KNXClientEvents.indication, knxPacketRequest as any, true) } else { // Tunneling const cEMIMessage = CEMIFactory.newLDataRequestMessage( 'response', srcAddress, dstAddress, knxBuffer, ) // No ACK request on bus cEMIMessage.control.ack = 0 cEMIMessage.control.broadcast = 1 cEMIMessage.control.priority = 3 cEMIMessage.control.addressType = 1 cEMIMessage.control.hopCount = 6 // Data Secure si applica solo in TunnelTCP const seqNum: number = this._options.hostProtocol === 'TunnelTCP' ? 0 : this.incSeqNumber() const knxPacketRequest = KNXProtocol.newKNXTunnelingRequest( this._channelID, seqNum, cEMIMessage, ) if (!this._options.suppress_ack_ldatareq) { this.send(knxPacketRequest, knxPacketRequest, false, seqNum) } else { this.send(knxPacketRequest, undefined, false, seqNum) } // 06/12/2021 Echo the sent telegram. Emit entire telegram with plain cEMI this.ensurePlainCEMI(knxPacketRequest.cEMIMessage) this.emit(KNXClientEvents.indication, knxPacketRequest as any, true) } } /** * Sends a READ telegram to the BUS. GA is the group address (for example "0/0/1"). */ read(dstAddress: KNXAddress | string): void { if (this._connectionState !== ConncetionState.CONNECTED) throw new Error( 'The socket is not connected. Unable to access the KNX BUS', ) if (typeof dstAddress === 'string') dstAddress = KNXAddress.createFromString( dstAddress, KNXAddress.TYPE_GROUP, ) const srcAddress = this.physAddr if (this._options.hostProtocol === 'Multicast') { // Multicast: per KNX Routing spec, inject as L_DATA_IND const cEMIMessage = CEMIFactory.newLDataIndicationMessage( 'read', srcAddress, dstAddress, null, ) cEMIMessage.control.ack = 0 cEMIMessage.control.broadcast = 1 cEMIMessage.control.priority = 3 cEMIMessage.control.addressType = 1 cEMIMessage.control.hopCount = 6 const knxPacketRequest = KNXProtocol.newKNXRoutingIndication(cEMIMessage) this.send(knxPacketRequest, undefined, false, this.getSeqNumber()) // 06/12/2021 Multicast automatically echoes telegrams } else if (this.isSerialTransport()) { // Serial FT1.2 (KBerry): send as L_DATA_REQ over cEMI/FT1.2 const cEMIMessage = CEMIFactory.newLDataRequestMessage( 'read', srcAddress, dstAddress, null, ) // Request bus ACK unless suppressed cEMIMessage.control.ack = this._options.suppress_ack_ldatareq ? 0 : 1 cEMIMessage.control.broadcast = 1 cEMIMessage.control.priority = 3 cEMIMessage.control.addressType = 1 cEMIMessage.control.hopCount = 6 const knxPacketRequest = KNXProtocol.newKNXRoutingIndication(cEMIMessage) this.send(knxPacketRequest, undefined, false, this.getSeqNumber()) this.ensurePlainCEMI(cEMIMessage) this.emit(KNXClientEvents.indication, knxPacketRequest as any, true) } else { // Tunneling const cEMIMessage = CEMIFactory.newLDataRequestMessage( 'read', srcAddress, dstAddress, null, ) // Tunnelling UDP: request bus ACK unless suppressed; TunnelTCP: no bus ACK cEMIMessage.control.ack = // eslint-disable-next-line no-nested-ternary this._options.hostProtocol === 'TunnelTCP' ? 0 : this._options.suppress_ack_ldatareq ? 0 : 1 cEMIMessage.control.broadcast = 1 cEMIMessage.control.priority = 3 cEMIMessage.control.addressType = 1 cEMIMessage.control.hopCount = 6 const seqNum: number = this._options.hostProtocol === 'TunnelTCP' ? 0 : this.incSeqNumber() const knxPacketRequest = KNXProtocol.newKNXTunnelingRequest( this._channelID, seqNum, cEMIMessage, ) if (!this._options.suppress_ack_ldatareq) { this.send(knxPacketRequest, knxPacketRequest, false, seqNum) } else { this.send(knxPacketRequest, undefined, false, seqNum) } // 06/12/2021 Echo the sent telegram. Emit entire telegram with plain cEMI this.ensurePlainCEMI(knxPacketRequest.cEMIMessage) this.emit(KNXClientEvents.indication, knxPacketRequest as any, true) } } /** * Sends a WRITE telegram to the BUS. * `dstAddress` is the group address (for example "0/0/1"), * `rawDataBuffer` is the buffer you want to send, * `dptid` is a string/number representing the datapoint (for example "5.001") */ writeRaw( dstAddress: KNXAddress | string, rawDataBuffer: Buffer, bitlength: number, ): void { // bitlength is unused and only for backward compatibility if (this._connectionState !== ConncetionState.CONNECTED) throw new Error( 'The socket is not connected. Unable to access the KNX BUS', ) if (!Buffer.isBuffer(rawDataBuffer)) { this.sysLogger.error( 'KNXClient: writeRaw: Value must be a buffer! ', ) return } const isSixBits: boolean = bitlength <= 6 const datapoint: IDataPoint = { id: '', value: 'any', type: { type: isSixBits }, bind: null, read: () => null, write: null, } // Get the KNDDataBuffer const baseBufferFromBitLength: Buffer = Buffer.alloc( Math.ceil(bitlength / 8), ) // The buffer length must be like specified by bitlength rawDataBuffer.copy(baseBufferFromBitLength, 0) const data: KNXDataBuffer = new KNXDataBuffer( baseBufferFromBitLength, datapoint, ) if (typeof dstAddress === 'string') dstAddress = KNXAddress.createFromString( dstAddress, KNXAddress.TYPE_GROUP, ) const srcAddress = this.physAddr if (this._options.hostProtocol === 'Multicast') { // Multicast: per KNX Routing spec, inject as L_DATA_IND const cEMIMessage = CEMIFactory.newLDataIndicationMessage( 'write', srcAddress, dstAddress, data, ) cEMIMessage.control.ack = 0 cEMIMessage.control.broadcast = 1 cEMIMessage.control.priority = 3 cEMIMessage.control.addressType = 1 cEMIMessage.control.hopCount = 6 const knxPacketRequest = KNXProtocol.newKNXRoutingIndication(cEMIMessage) this.send(knxPacketRequest, undefined,