UNPKG

knxultimate

Version:

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

575 lines (513 loc) 14.2 kB
import { test, describe } from 'node:test' import assert from 'node:assert' import sinon from 'sinon' import { dptlib, KNXClient, KNXClientEvents, SnifferPacket } from '../../src' import { KNX_CONSTANTS } from '../../src/protocol/KNXConstants' import MockKNXServer from 'test/utils/MockKNXServer' import { networkInterfaces } from 'node:os' const ciIP = '192.168.1.58' function getDefaultIpLocal() { if (process.env.CI) { return ciIP } const interfaces = networkInterfaces() for (const iface in interfaces) { for (const intf of interfaces[iface]) { if ( intf.family === 'IPv4' && !intf.internal && intf.address !== '::1' ) { return intf.address } } } return null } function ipToHex(ip: string) { return Buffer.from(ip.split('.').map(Number)).toString('hex') } const getMockResponses = (): SnifferPacket[] => { const localIp = getDefaultIpLocal() const knxGwIp = ipToHex(MockKNXServer.host) if (!localIp) { throw new Error('No local IP found') } // convert real IP to hex const reqIPHex = ipToHex(localIp) return [ { request: `06100201000e0801${reqIPHex}0e57`, response: `06100202004e0801${knxGwIp}0e5736010200af010000006c00769395e000170c006c007693954b4e582049502053656375726520427269646765000000000000000000000a020201030104010501`, deltaReq: 0, deltaRes: 10, }, ] } const mockToggleResponses: SnifferPacket[] = [ { reqType: 'KNXConnectRequest', request: '06100205001a0801000000000000080100000000000004040200', deltaReq: 0, response: '06100206001451000801c0a801740e570404affb', resType: 'KNXConnectResponse', deltaRes: 9, }, { reqType: 'KNXConnectionStateRequest', request: '06100207001051000801000000000000', deltaReq: 11, response: '0610020800085100', resType: 'KNXConnectionStateResponse', deltaRes: 20, }, // Toggle ON { reqType: 'KNXTunnelingRequest', request: '061004200015045100001100bce0ffc80001010081', deltaReq: 1581, response: '06100421000a04510000', resType: 'KNXTunnelingAck', deltaRes: 9, }, { reqType: 'KNXTunnelingRequest', response: '061004200015045100002e00bce0affb0001010081', deltaReq: 44, }, { reqType: 'KNXTunnelingAck', request: '06100421000a04510000', deltaReq: 44, response: '061004200015045101002900bce0af020101010081', resType: 'KNXTunnelingRequest', deltaRes: 670, }, { reqType: 'KNXTunnelingAck', request: '06100421000a04510100', deltaReq: 671, }, // Toggle OFF { reqType: 'KNXTunnelingRequest', request: '061004200015045101001100bce0ffc80001010080', deltaReq: 516, response: '06100421000a04510100', resType: 'KNXTunnelingAck', deltaRes: 7, }, { reqType: 'KNXTunnelingRequest', response: '061004200015045102002e00bce0affb0001010080', deltaReq: 43, }, { reqType: 'KNXTunnelingAck', request: '06100421000a04510200', deltaReq: 43, response: '061004200015045103002900bce0af020101010080', resType: 'KNXTunnelingRequest', deltaRes: 563, }, { reqType: 'KNXTunnelingAck', request: '06100421000a04510300', deltaReq: 563, }, // Disconnect { reqType: 'KNXDisconnectRequest', request: '06100209001051000801000000000000', deltaReq: 1250, response: '0610020a00085100', resType: 'KNXDisconnectResponse', deltaRes: 4, }, ] const getMockPacketsForDisconnectTest: SnifferPacket[] = [ // Initial connection { reqType: 'KNXConnectRequest', request: '06100205001a0801000000000000080100000000000004040200', response: '06100206001424000801c0a801740e570404affc', deltaReq: 0, deltaRes: 8, resType: 'KNXConnectResponse', }, // First successful heartbeat { reqType: 'KNXConnectionStateRequest', request: '06100207001024000801000000000000', response: '0610020800082400', deltaReq: 9, deltaRes: 21, resType: 'KNXConnectionStateResponse', }, // First failed heartbeat during disconnection { reqType: 'KNXConnectionStateRequest', request: '06100207001024000801000000000000', deltaReq: 10005, }, // Second failed heartbeat { reqType: 'KNXConnectionStateRequest', request: '06100207001024000801000000000000', deltaReq: 10001, }, // Third failed heartbeat - should trigger disconnection { reqType: 'KNXConnectionStateRequest', request: '06100207001024000801000000000000', deltaReq: 10006, }, ] const getMockPacketsForDisconnectRequestTest: SnifferPacket[] = [ // Initial connection { reqType: 'KNXConnectRequest', request: '06100205001a0801000000000000080100000000000004040200', response: '06100206001424000801c0a801740e570404affc', deltaReq: 0, deltaRes: 8, resType: 'KNXConnectResponse', }, // Disconnect { reqType: 'KNXDisconnectRequest', request: '06100209001024000801000000000000', deltaReq: 500, response: '0610020a00085100', resType: 'KNXDisconnectResponse', deltaRes: 4, }, ] describe('KNXClient Tests', () => { test('should discover KNX interfaces', async () => { const clock = sinon.useFakeTimers({ shouldAdvanceTime: true }) try { const client = new KNXClient( { loglevel: 'trace', hostProtocol: 'Multicast', }, (c: KNXClient) => { const server = new MockKNXServer(getMockResponses(), c) server.createFakeSocket() c.startDiscovery() }, ) const discovered: string[] = [] // Handle client errors client.on(KNXClientEvents.error, (error) => { // ignore ENODEV errors, it happens on CI if ((error as any).code !== 'ENODEV') { throw error } }) // Handle successful discoveries client.on( KNXClientEvents.discover, (host, header, searchResponse) => { discovered.push( `${host}:${searchResponse.deviceInfo?.name.replace(/:/g, ' ') ?? ''}:${searchResponse.deviceInfo?.formattedAddress ?? ''}`, ) }, ) // Advance virtual time instead of waiting await clock.tickAsync(50) // Verify discovery results const expectedHost = `${MockKNXServer.host}:${MockKNXServer.port}:KNX IP Secure Bridge:10.15.1` assert.equal(discovered[0], expectedHost, 'Discovery should work') await client.Disconnect() } finally { // Restore real timers clock.restore() } }) test('should perform toggle operation', async () => { const clock = sinon.useFakeTimers({ shouldAdvanceTime: true }) const groupId = '0/0/1' const dpt = '1.001' let client: KNXClient const waitForIndication = async (expectedValue: any): Promise<void> => { return new Promise<void>((resolve) => { client.once('indication', (packet) => { const { npdu } = packet.cEMIMessage const dest = packet.cEMIMessage.dstAddress.toString() const src = packet.cEMIMessage.srcAddress.toString() const data = npdu.dataValue assert.equal(dest, '0/1/1') assert.equal(src, MockKNXServer.physicalAddress) if (npdu.isGroupRead || npdu.isGroupWrite) { const value = dptlib.resolve(dpt).fromBuffer(data) assert.equal(value, expectedValue) resolve() } }) }) } try { client = new KNXClient( { loglevel: 'trace', hostProtocol: 'TunnelUDP', sniffingMode: true, }, (c: KNXClient) => { const server = new MockKNXServer(mockToggleResponses, c) server.on('error', (error) => { if ( !error.message.includes( 'No matching response found', ) ) { throw error } }) server.createFakeSocket() }, ) return await new Promise<void>((resolve, reject) => { client.on(KNXClientEvents.error, (error) => { reject(error) }) client.on(KNXClientEvents.connected, async () => { try { // First toggle - ON client.write(groupId, true, dpt) // Advance by exact deltas from the mock await clock.tickAsync(9) // First response delta await clock.tickAsync(44) // Second response delta await clock.tickAsync(670) // Third response delta await waitForIndication(true) // Second toggle - OFF client.write(groupId, false, dpt) await clock.tickAsync(7) // First response delta await clock.tickAsync(43) // Second response delta await clock.tickAsync(563) // Third response delta await waitForIndication(false) await client.Disconnect() resolve() } catch (error) { reject(error) } }) client.Connect() // Initial connection timing clock.tickAsync(9) // Connect response clock.tickAsync(20) // First heartbeat response }) } finally { clock.restore() } }) test('should handle long network disconnection leading to auto-disconnect', async () => { const clock = sinon.useFakeTimers({ shouldAdvanceTime: true, toFake: [ 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date', ], }) try { const events: string[] = [] let server: MockKNXServer let disconnectReason = '' // Store original constants const originalStateTimeout = KNX_CONSTANTS.CONNECTIONSTATE_REQUEST_TIMEOUT const originalAliveTime = KNX_CONSTANTS.CONNECTION_ALIVE_TIME // Override timeouts for test KNX_CONSTANTS.CONNECTIONSTATE_REQUEST_TIMEOUT = 0.1 // 100ms instead of 10s KNX_CONSTANTS.CONNECTION_ALIVE_TIME = 0.1 // 100ms instead of 60s const client = new KNXClient( { loglevel: 'trace', hostProtocol: 'TunnelUDP', ipAddr: MockKNXServer.host, ipPort: MockKNXServer.port, connectionKeepAliveTimeout: 0.1, // This needs to match CONNECTION_ALIVE_TIME localIPAddress: getDefaultIpLocal(), }, (c: KNXClient) => { server = new MockKNXServer( getMockPacketsForDisconnectTest, c, ) server.on('error', (error) => { if ( error.message.includes('No matching response found') ) { throw new Error( `MockKNXServer error: ${error.message}`, ) } }) server.createFakeSocket() }, ) // Track connection const connectionPromise = new Promise<void>((resolve) => { client.once(KNXClientEvents.connected, () => { events.push('connected') resolve() }) }) // Track disconnection const disconnectionPromise = new Promise<void>((resolve) => { client.on(KNXClientEvents.error, (error) => { events.push('error') }) client.once(KNXClientEvents.disconnected, (reason) => { disconnectReason = reason events.push('disconnected') resolve() }) }) try { // Connect and wait for connection client.Connect() await clock.tickAsync(10) // Wait for connection await connectionPromise // Initial state verification assert.strictEqual( client.isConnected(), true, 'Should be connected initially', ) assert.deepStrictEqual( events, ['connected'], 'Should have connected event', ) assert.strictEqual( client['_heartbeatFailures'], 0, 'Should have no heartbeat failures', ) // Wait for first successful heartbeat await clock.tickAsync(20) assert.strictEqual( client.isConnected(), true, 'Should still be connected after first heartbeat', ) assert.strictEqual( client['_heartbeatFailures'], 0, 'Should still have no failures', ) // Simulate disconnection server.setPaused(true) // Wait for three heartbeat failures await clock.tickAsync(200) // First failure await clock.tickAsync(200) // Second failure await clock.tickAsync(200) // Third failure await disconnectionPromise // Final state verification assert.strictEqual( client['_heartbeatFailures'], 0, 'Heartbeat failures should reset after disconnection', ) assert.strictEqual( client.isConnected(), false, 'Should be disconnected', ) assert.ok( disconnectReason.includes('Connection dead'), `Should disconnect due to dead connection, got: ${disconnectReason}`, ) assert.deepStrictEqual( events, ['connected', 'error', 'disconnected'], 'Events should occur in correct order', ) } finally { // Restore original constants KNX_CONSTANTS.CONNECTIONSTATE_REQUEST_TIMEOUT = originalStateTimeout KNX_CONSTANTS.CONNECTION_ALIVE_TIME = originalAliveTime if (client.isConnected()) { await client.Disconnect() } } } finally { clock.restore() } }) test('should send a disconnect request', async () => { const events: string[] = [] let server: MockKNXServer let disconnectReason = '' const client = new KNXClient( { loglevel: 'trace', hostProtocol: 'TunnelUDP', ipAddr: MockKNXServer.host, ipPort: MockKNXServer.port, localIPAddress: getDefaultIpLocal(), }, (c: KNXClient) => { server = new MockKNXServer( getMockPacketsForDisconnectRequestTest, c, ) server.on('error', (error) => { if (error.message.includes('No matching response found')) { throw new Error(`MockKNXServer error: ${error.message}`) } }) server.createFakeSocket() }, ) // Track connection const connectionPromise = new Promise<void>((resolve) => { client.once(KNXClientEvents.connected, () => { events.push('connected') resolve() }) }) // Track disconnection const disconnectionPromise = new Promise<void>((resolve) => { client.on(KNXClientEvents.error, (error) => { events.push('error') }) client.once(KNXClientEvents.disconnected, (reason) => { disconnectReason = reason events.push('disconnected') resolve() }) }) // Connect and wait for connection client.Connect() await connectionPromise // Connected state verification assert.strictEqual(client.isConnected(), true, 'Should be connected') assert.deepStrictEqual( events, ['connected'], 'Should have connected event', ) // Disconnect and wait for disconnection client.Disconnect() await disconnectionPromise assert.strictEqual( client.isConnected(), false, 'Should be disconnected', ) assert.ok( disconnectReason.includes( 'Received DISCONNECT_RESPONSE from the KNX interface', ), `Should receive disconnect response: ${disconnectReason}`, ) }) })