From 5669c216cfc0a4575c7d6713b7f4a710f7626913 Mon Sep 17 00:00:00 2001 From: Koen Kanters Date: Tue, 11 Mar 2025 21:50:01 +0100 Subject: [PATCH 1/4] fix(ignore): Move to `Node16` `module` (#1340) --- tsconfig.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tsconfig.json b/tsconfig.json index fe21df7596..b512944cd5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,14 +1,13 @@ { "compilerOptions": { "allowSyntheticDefaultImports": true, - "module": "commonjs", + "module": "Node16", "esModuleInterop": true, "target": "ES2022", "lib": ["ES2022"], "strict": true, "noImplicitAny": true, "noImplicitThis": true, - "moduleResolution": "node", "sourceMap": true, "declaration": true, "declarationMap": true, From 590851a1ea552974a6cdb4ae85a135a4456de5c5 Mon Sep 17 00:00:00 2001 From: Koen Kanters Date: Wed, 12 Mar 2025 21:37:51 +0100 Subject: [PATCH 2/4] fix(ignore): Use `module` `NodeNext` (#1343) --- src/adapter/adapter.ts | 2 +- tsconfig.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/adapter/adapter.ts b/src/adapter/adapter.ts index f0cbc0126b..ab1131f94b 100644 --- a/src/adapter/adapter.ts +++ b/src/adapter/adapter.ts @@ -72,7 +72,7 @@ export abstract class Adapter extends events.EventEmitter { serialPortOptions.adapter = adapter; serialPortOptions.path = path; - const adapterModule = await import(detectedAdapter[0]); + const adapterModule = await import(`${detectedAdapter[0]}.js`); const AdapterCtor = adapterModule[detectedAdapter[1]] as AdapterConstructor; return new AdapterCtor(networkOptions, serialPortOptions, backupPath, adapterOptions); diff --git a/tsconfig.json b/tsconfig.json index b512944cd5..1347f80651 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "allowSyntheticDefaultImports": true, - "module": "Node16", + "module": "NodeNext", "esModuleInterop": true, "target": "ES2022", "lib": ["ES2022"], From 038085fe9d9cb9644faf22d584711c339cfb2af3 Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Sat, 15 Mar 2025 08:22:05 +0100 Subject: [PATCH 3/4] feat: Initial support for ZigBee on Host adapter (#1308) --- package.json | 3 +- pnpm-lock.yaml | 9 + src/adapter/adapter.ts | 1 + src/adapter/adapterDiscovery.ts | 5 + src/adapter/socketPortUtils.ts | 4 +- src/adapter/tstype.ts | 2 +- src/adapter/zoh/adapter/utils.ts | 27 + src/adapter/zoh/adapter/zohAdapter.ts | 711 ++++++++++++++++++++++++++ test/adapter/adapter.test.ts | 9 +- test/adapter/zoh/utils.test.ts | 35 ++ test/adapter/zoh/zohAdapter.test.ts | 701 +++++++++++++++++++++++++ 11 files changed, 1502 insertions(+), 5 deletions(-) create mode 100644 src/adapter/zoh/adapter/utils.ts create mode 100644 src/adapter/zoh/adapter/zohAdapter.ts create mode 100644 test/adapter/zoh/utils.test.ts create mode 100644 test/adapter/zoh/zohAdapter.test.ts diff --git a/package.json b/package.json index 059d68e0b9..3952ced92c 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "debounce": "^2.2.0", "fast-deep-equal": "^3.1.3", "mixin-deep": "^2.0.1", - "slip": "^1.0.2" + "slip": "^1.0.2", + "zigbee-on-host": "^0.1.0" }, "deprecated": false, "description": "An open source ZigBee gateway solution with node.js.", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c659a48fa..8c5fd55282 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: slip: specifier: ^1.0.2 version: 1.0.2 + zigbee-on-host: + specifier: ^0.1.0 + version: 0.1.0 devDependencies: '@eslint/core': specifier: ^0.12.0 @@ -1398,6 +1401,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zigbee-on-host@0.1.0: + resolution: {integrity: sha512-0lUepIg+7Ikt8scRtcD4Y+NhQXydn+3J6caVc7aBz4XYA2ClSDNq8F3hJkFxWw3FD7ZPwpZnYfHa9tHb5pJWjw==} + engines: {node: '>=20.17.0'} + snapshots: '@ampproject/remapping@2.3.0': @@ -2654,3 +2661,5 @@ snapshots: yaml@2.7.0: {} yocto-queue@0.1.0: {} + + zigbee-on-host@0.1.0: {} diff --git a/src/adapter/adapter.ts b/src/adapter/adapter.ts index ab1131f94b..e3ac9aef9e 100644 --- a/src/adapter/adapter.ts +++ b/src/adapter/adapter.ts @@ -64,6 +64,7 @@ export abstract class Adapter extends events.EventEmitter { zstack: ['./z-stack/adapter/zStackAdapter', 'ZStackAdapter'], zboss: ['./zboss/adapter/zbossAdapter', 'ZBOSSAdapter'], zigate: ['./zigate/adapter/zigateAdapter', 'ZiGateAdapter'], + zoh: ['./zoh/adapter/zohAdapter', 'ZoHAdapter'], }; const [adapter, path] = await discoverAdapter(serialPortOptions.adapter, serialPortOptions.path); const detectedAdapter = adapterLookup[adapter]; diff --git a/src/adapter/adapterDiscovery.ts b/src/adapter/adapterDiscovery.ts index ffb4faee47..97a1526028 100644 --- a/src/adapter/adapterDiscovery.ts +++ b/src/adapter/adapterDiscovery.ts @@ -350,6 +350,11 @@ function matchUsbFingerprint( } export async function matchUsbAdapter(adapter: Adapter, path: string): Promise { + // no point in matching this + if (adapter === 'zoh') { + return false; + } + const isWindows = platform() === 'win32'; const portList = await getSerialPortList(); diff --git a/src/adapter/socketPortUtils.ts b/src/adapter/socketPortUtils.ts index 80a813c473..f95ab07933 100644 --- a/src/adapter/socketPortUtils.ts +++ b/src/adapter/socketPortUtils.ts @@ -1,11 +1,11 @@ -function isTcpPath(path: string): boolean { +export function isTcpPath(path: string): boolean { // tcp path must be: // tcp://: const regex = /^(?:tcp:\/\/)[\w.-]+[:][\d]+$/gm; return regex.test(path); } -function parseTcpPath(path: string): {host: string; port: number} { +export function parseTcpPath(path: string): {host: string; port: number} { const str = path.replace('tcp://', ''); return { host: str.substring(0, str.indexOf(':')), diff --git a/src/adapter/tstype.ts b/src/adapter/tstype.ts index 2dd1172270..0f1e5b7e34 100644 --- a/src/adapter/tstype.ts +++ b/src/adapter/tstype.ts @@ -1,4 +1,4 @@ -export type Adapter = 'deconz' | 'ember' | 'zstack' | 'zboss' | 'zigate' | 'ezsp'; +export type Adapter = 'deconz' | 'ember' | 'zstack' | 'zboss' | 'zigate' | 'ezsp' | 'zoh'; export type DiscoverableUsbAdapter = 'deconz' | 'ember' | 'zstack' | 'zboss' | 'zigate'; export type UsbAdapterFingerprint = { diff --git a/src/adapter/zoh/adapter/utils.ts b/src/adapter/zoh/adapter/utils.ts new file mode 100644 index 0000000000..0877158367 --- /dev/null +++ b/src/adapter/zoh/adapter/utils.ts @@ -0,0 +1,27 @@ +/** + * @param value 64-bit bigint + * @returns 16-length hex string in big-endian + */ +export function bigUInt64ToHexBE(value: bigint): string { + return value.toString(16).padStart(16, '0'); +} + +/** + * @param value 64-bit bigint + * @returns 8-bytelength buffer in little-endian + */ +export function bigUInt64ToBufferLE(value: bigint): Buffer { + const b = Buffer.alloc(8); + b.writeBigUInt64LE(value, 0); + return b; +} + +/** + * @param value 64-bit bigint + * @returns 8-bytelength buffer in big-endian + */ +export function bigUInt64ToBufferBE(value: bigint): Buffer { + const b = Buffer.alloc(8); + b.writeBigUInt64BE(value, 0); + return b; +} diff --git a/src/adapter/zoh/adapter/zohAdapter.ts b/src/adapter/zoh/adapter/zohAdapter.ts new file mode 100644 index 0000000000..f201a36c0f --- /dev/null +++ b/src/adapter/zoh/adapter/zohAdapter.ts @@ -0,0 +1,711 @@ +import {Socket} from 'node:net'; +import {dirname} from 'node:path'; + +import {OTRCPDriver} from 'zigbee-on-host'; +import {setLogger} from 'zigbee-on-host/dist/utils/logger'; +import {ZigbeeAPSHeader, ZigbeeAPSPayload} from 'zigbee-on-host/dist/zigbee/zigbee-aps'; + +import {Backup} from '../../../models/backup'; +import {logger} from '../../../utils/logger'; +import {Queue} from '../../../utils/queue'; +import {wait} from '../../../utils/wait'; +import {Waitress} from '../../../utils/waitress'; +import * as ZSpec from '../../../zspec'; +import * as Zcl from '../../../zspec/zcl'; +import * as Zdo from '../../../zspec/zdo'; +import * as ZdoTypes from '../../../zspec/zdo/definition/tstypes'; +import {Adapter} from '../../adapter'; +import {ZclPayload} from '../../events'; +import {SerialPort} from '../../serialPort'; +import {isTcpPath} from '../../socketPortUtils'; +import * as TsType from '../../tstype'; +import {bigUInt64ToHexBE} from './utils'; + +const NS = 'zh:zoh'; + +interface WaitressMatcher { + sender: number | string; + clusterId: number; + endpoint?: number; + commandId?: number; + transactionSequenceNumber?: number; +} + +type ZdoResponse = { + sender: number | string; + clusterId: number; + response: ZdoTypes.GenericZdoResponse; +}; + +const DEFAULT_REQUEST_TIMEOUT = 15000; + +export class ZoHAdapter extends Adapter { + private serialPort?: SerialPort; + private socketPort?: Socket; + /** True when adapter is currently closing */ + private closing: boolean; + + private interpanLock: boolean; + + public readonly driver: OTRCPDriver; + private readonly queue: Queue; + private readonly zclWaitress: Waitress; + private readonly zdoWaitress: Waitress; + + constructor( + networkOptions: TsType.NetworkOptions, + serialPortOptions: TsType.SerialPortOptions, + backupPath: string, + adapterOptions: TsType.AdapterOptions, + ) { + super(networkOptions, serialPortOptions, backupPath, adapterOptions); + + this.hasZdoMessageOverhead = true; + this.manufacturerID = Zcl.ManufacturerCode.CONNECTIVITY_STANDARDS_ALLIANCE; + this.closing = false; + + const channel = networkOptions.channelList[0]; + this.driver = new OTRCPDriver( + { + txChannel: channel, + ccaBackoffAttempts: 1, + ccaRetries: 4, + enableCSMACA: true, + headerUpdated: true, + reTx: false, + securityProcessed: true, + txDelay: 0, + txDelayBaseTime: 0, + rxChannelAfterTxDone: channel, + }, + // NOTE: this information is overwritten on `start` if a save exists + { + // TODO: make this configurable + eui64: Buffer.from([0x5a, 0x6f, 0x48, 0x6f, 0x6e, 0x5a, 0x32, 0x4d]).readBigUInt64LE(0), + panId: this.networkOptions.panID, + extendedPANId: Buffer.from(this.networkOptions.extendedPanID!).readBigUInt64LE(0), + channel, + nwkUpdateId: 0, + txPower: this.adapterOptions.transmitPower ?? /* v8 ignore next */ 5, + // ZigBeeAlliance09 + tcKey: Buffer.from([0x5a, 0x69, 0x67, 0x42, 0x65, 0x65, 0x41, 0x6c, 0x6c, 0x69, 0x61, 0x6e, 0x63, 0x65, 0x30, 0x39]), + tcKeyFrameCounter: 0, + networkKey: Buffer.from(this.networkOptions.networkKey!), + networkKeyFrameCounter: 0, + networkKeySequenceNumber: 0, + }, + dirname(this.backupPath), + ); + this.queue = new Queue(this.adapterOptions.concurrent || /* v8 ignore next */ 8); // ORed to avoid 0 (not checked in settings/queue constructor) + this.zclWaitress = new Waitress(this.zclWaitressValidator, this.waitressTimeoutFormatter); + this.zdoWaitress = new Waitress(this.zdoWaitressValidator, this.waitressTimeoutFormatter); + + this.interpanLock = false; + } + + /** + * Init the serial or socket port and hook parser/writer. + */ + /* v8 ignore start */ + public async initPort(): Promise { + await this.closePort(); // will do nothing if nothing's open + + if (isTcpPath(this.serialPortOptions.path!)) { + const pathUrl = new URL(this.serialPortOptions.path!); + const hostname = pathUrl.hostname; + const port = Number.parseInt(pathUrl.port, 10); + + logger.debug(`Opening TCP socket with ${hostname}:${port}`, NS); + + this.socketPort = new Socket(); + + this.socketPort.setNoDelay(true); + this.socketPort.setKeepAlive(true, 15000); + this.driver.writer.pipe(this.socketPort); + this.socketPort.pipe(this.driver.parser); + this.driver.parser.on('data', this.driver.onFrame.bind(this.driver)); + + return await new Promise((resolve, reject): void => { + const openError = async (err: Error): Promise => { + await this.stop(); + + reject(err); + }; + + this.socketPort!.on('connect', () => { + logger.debug('Socket connected', NS); + }); + this.socketPort!.on('ready', (): void => { + logger.info('Socket ready', NS); + this.socketPort!.removeListener('error', openError); + this.socketPort!.once('close', this.onPortClose.bind(this)); + this.socketPort!.on('error', this.onPortError.bind(this)); + + resolve(); + }); + this.socketPort!.once('error', openError); + + this.socketPort!.connect(port, hostname); + }); + } + + const serialOpts = { + path: this.serialPortOptions.path!, + baudRate: typeof this.serialPortOptions.baudRate === 'number' ? this.serialPortOptions.baudRate : 115200, + rtscts: typeof this.serialPortOptions.rtscts === 'boolean' ? this.serialPortOptions.rtscts : false, + autoOpen: false, + parity: 'none' as const, + stopBits: 1 as const, + xon: false, + xoff: false, + }; + + // enable software flow control if RTS/CTS not enabled in config + if (!serialOpts.rtscts) { + logger.info('RTS/CTS config is off, enabling software flow control.', NS); + serialOpts.xon = true; + serialOpts.xoff = true; + } + + logger.debug(() => `Opening serial port with [path=${serialOpts.path} baudRate=${serialOpts.baudRate} rtscts=${serialOpts.rtscts}]`, NS); + this.serialPort = new SerialPort(serialOpts); + + this.driver.writer.pipe(this.serialPort); + this.serialPort.pipe(this.driver.parser); + this.driver.parser.on('data', this.driver.onFrame.bind(this.driver)); + + try { + await this.serialPort!.asyncOpen(); + await this.serialPort!.asyncFlush(); + + logger.info('Serial port opened', NS); + + this.serialPort.once('close', this.onPortClose.bind(this)); + this.serialPort.on('error', this.onPortError.bind(this)); + } catch (error) { + await this.stop(); + + throw error; + } + } + /* v8 ignore stop */ + + /** + * Handle port closing + * @param err A boolean for Socket, an Error for serialport + */ + /* v8 ignore start */ + private async onPortClose(error: boolean | Error): Promise { + if (error) { + logger.error('Port closed unexpectedly.', NS); + } else { + logger.info('Port closed.', NS); + } + } + /* v8 ignore stop */ + + /** + * Handle port error + * @param error + */ + /* v8 ignore start */ + private async onPortError(error: Error): Promise { + logger.error(`Port ${error}`, NS); + + this.emit('disconnected'); + } + /* v8 ignore stop */ + + /* v8 ignore start */ + public async closePort(): Promise { + if (this.serialPort?.isOpen) { + try { + await this.serialPort!.asyncFlushAndClose(); + } catch (err) { + logger.error(`Failed to close serial port ${err}.`, NS); + } + + this.serialPort.removeAllListeners(); + + this.serialPort = undefined; + } else if (this.socketPort != null && !this.socketPort.closed) { + this.socketPort.destroy(); + this.socketPort.removeAllListeners(); + + this.socketPort = undefined; + } + } + /* v8 ignore stop */ + + public async start(): Promise { + setLogger(logger); // pass the logger to ZoH + await this.initPort(); + await this.driver.start(); + await this.driver.formNetwork(); + + this.driver.on('frame', this.onFrame.bind(this)); + this.driver.on('deviceJoined', this.onDeviceJoined.bind(this)); + this.driver.on('deviceRejoined', this.onDeviceRejoined.bind(this)); + this.driver.on('deviceLeft', this.onDeviceLeft.bind(this)); + this.driver.on('deviceAuthorized', this.onDeviceAuthorized.bind(this)); + + return 'resumed'; + } + + public async stop(): Promise { + this.closing = true; + + this.driver.removeAllListeners(); + this.queue.clear(); + this.zclWaitress.clear(); + this.zdoWaitress.clear(); + await this.driver.stop(); + } + + public async getCoordinatorIEEE(): Promise { + return `0x${bigUInt64ToHexBE(this.driver.netParams.eui64)}`; + } + + public async getCoordinatorVersion(): Promise { + return { + type: 'ZigBee on Host', + meta: {revision: 'https://github.com/Nerivec/zigbee-on-host'}, + }; + } + + /* v8 ignore start */ + public async reset(type: 'soft' | 'hard'): Promise { + throw new Error(`Reset ${type} not support`); + } + /* v8 ignore stop */ + + /* v8 ignore start */ + public async supportsBackup(): Promise { + return false; + } + /* v8 ignore stop */ + + /* v8 ignore start */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async backup(ieeeAddressesInDatabase: string[]): Promise { + throw new Error('ZigBee on Host handles backup internally'); + } + /* v8 ignore stop */ + + public async getNetworkParameters(): Promise { + return { + panID: this.driver.netParams.panId, + extendedPanID: `0x${bigUInt64ToHexBE(this.driver.netParams.extendedPANId)}`, + channel: this.driver.netParams.channel, + nwkUpdateID: this.driver.netParams.nwkUpdateId, + }; + } + + /* v8 ignore start */ + public async addInstallCode(ieeeAddress: string, key: Buffer): Promise { + throw new Error(`not supported ${ieeeAddress}, ${key.toString('hex')}`); + } + /* v8 ignore stop */ + + /* v8 ignore start */ + public waitFor( + networkAddress: number, + endpoint: number, + frameType: Zcl.FrameType, + direction: Zcl.Direction, + transactionSequenceNumber: number | undefined, + clusterID: number, + commandIdentifier: number, + timeout: number, + ): {promise: Promise; cancel: () => void} { + const waiter = this.zclWaitress.waitFor( + { + sender: networkAddress, + endpoint, + clusterId: clusterID, + commandId: commandIdentifier, + transactionSequenceNumber, + }, + timeout, + ); + const cancel = (): void => this.zclWaitress.remove(waiter.ID); + + return {cancel, promise: waiter.start().promise}; + } + /* v8 ignore stop */ + + // #region ZDO + + public async sendZdo( + ieeeAddress: string, + networkAddress: number, + clusterId: Zdo.ClusterId, + payload: Buffer, + disableResponse: true, + ): Promise; + public async sendZdo( + ieeeAddress: string, + networkAddress: number, + clusterId: K, + payload: Buffer, + disableResponse: false, + ): Promise; + public async sendZdo( + ieeeAddress: string, + networkAddress: number, + clusterId: K, + payload: Buffer, + disableResponse: boolean, + ): Promise { + if (networkAddress === ZSpec.COORDINATOR_ADDRESS) { + // mock ZDO response using driver layer data for coordinator + // seqNum doesn't matter since waitress bypassed, so don't bother doing any logic for it + switch (clusterId) { + case Zdo.ClusterId.NODE_DESCRIPTOR_REQUEST: { + const respClusterId = Zdo.ClusterId.NODE_DESCRIPTOR_RESPONSE; + const result = Zdo.Buffalo.readResponse( + this.hasZdoMessageOverhead, + respClusterId, + Buffer.from(this.driver.configAttributes.nodeDescriptor), + ) as ZdoTypes.RequestToResponseMap[K]; + + this.emit('zdoResponse', respClusterId, result); + return result; + } + case Zdo.ClusterId.POWER_DESCRIPTOR_REQUEST: { + const respClusterId = Zdo.ClusterId.POWER_DESCRIPTOR_RESPONSE; + const result = Zdo.Buffalo.readResponse( + this.hasZdoMessageOverhead, + respClusterId, + Buffer.from(this.driver.configAttributes.powerDescriptor), + ) as ZdoTypes.RequestToResponseMap[K]; + + this.emit('zdoResponse', respClusterId, result); + return result; + } + case Zdo.ClusterId.SIMPLE_DESCRIPTOR_REQUEST: { + const respClusterId = Zdo.ClusterId.SIMPLE_DESCRIPTOR_RESPONSE; + const result = Zdo.Buffalo.readResponse( + this.hasZdoMessageOverhead, + respClusterId, + Buffer.from(this.driver.configAttributes.simpleDescriptors), + ) as ZdoTypes.RequestToResponseMap[K]; + + this.emit('zdoResponse', respClusterId, result); + return result; + } + case Zdo.ClusterId.ACTIVE_ENDPOINTS_REQUEST: { + const respClusterId = Zdo.ClusterId.ACTIVE_ENDPOINTS_RESPONSE; + const result = Zdo.Buffalo.readResponse( + this.hasZdoMessageOverhead, + respClusterId, + Buffer.from(this.driver.configAttributes.activeEndpoints), + ) as ZdoTypes.RequestToResponseMap[K]; + + this.emit('zdoResponse', respClusterId, result); + return result; + } + // TODO: + // case Zdo.ClusterId.LQI_TABLE_REQUEST: { + // break; + // } + // case Zdo.ClusterId.ROUTING_TABLE_REQUEST: { + // break; + // } + /* v8 ignore start */ + default: { + throw new Error(`ZDO cluster ${clusterId} not supported for ${networkAddress}:${ieeeAddress}`); + } + /* v8 ignore stop */ + } + } + + return await this.queue.execute(async () => { + this.checkInterpanLock(); + + logger.debug(() => `~~~> [ZDO to=${ieeeAddress}:${networkAddress} clusterId=${clusterId} disableResponse=${disableResponse}]`, NS); + + await this.driver.sendZDO( + payload, + networkAddress, // nwkDest16 + undefined, // nwkDest64 XXX: avoid passing EUI64 whenever not absolutely necessary + clusterId, // clusterId + ); + + if (!disableResponse) { + const responseClusterId = Zdo.Utils.getResponseClusterId(clusterId); + + if (responseClusterId) { + const resp = await this.zdoWaitress + .waitFor( + { + sender: responseClusterId === Zdo.ClusterId.NETWORK_ADDRESS_RESPONSE ? ieeeAddress : networkAddress, + clusterId: responseClusterId, + }, + DEFAULT_REQUEST_TIMEOUT, + ) + .start().promise; + + return resp.response as ZdoTypes.RequestToResponseMap[K]; + } + } + }, networkAddress); + } + + public async permitJoin(seconds: number, networkAddress?: number): Promise { + if (networkAddress === undefined) { + // send ZDO BCAST + this.driver.allowJoins(seconds, true); + + const clusterId = Zdo.ClusterId.PERMIT_JOINING_REQUEST; + // `authentication`: TC significance always 1 (zb specs) + const zdoPayload = Zdo.Buffalo.buildRequest(this.hasZdoMessageOverhead, clusterId, seconds, 1, []); + + await this.sendZdo(ZSpec.BLANK_EUI64, ZSpec.BroadcastAddress.DEFAULT, clusterId, zdoPayload, true); + } else if (networkAddress === ZSpec.COORDINATOR_ADDRESS) { + this.driver.allowJoins(seconds, true); + } else { + // send ZDO to networkAddress + this.driver.allowJoins(seconds, false); + + const clusterId = Zdo.ClusterId.PERMIT_JOINING_REQUEST; + // `authentication`: TC significance always 1 (zb specs) + const zdoPayload = Zdo.Buffalo.buildRequest(this.hasZdoMessageOverhead, clusterId, seconds, 1, []); + const result = await this.sendZdo(ZSpec.BLANK_EUI64, networkAddress, clusterId, zdoPayload, false); + + /* v8 ignore start */ + if (!Zdo.Buffalo.checkStatus(result)) { + throw new Zdo.StatusError(result[0]); + } + /* v8 ignore stop */ + } + } + + // #endregion + + // #region ZCL + + public async sendZclFrameToEndpoint( + ieeeAddr: string, + networkAddress: number, + endpoint: number, + zclFrame: Zcl.Frame, + timeout: number, + disableResponse: boolean, + disableRecovery: boolean, + sourceEndpoint?: number, + ): Promise { + let commandResponseId: number | undefined; + + if (zclFrame.command.response !== undefined && disableResponse === false) { + commandResponseId = zclFrame.command.response; + } else if (!zclFrame.header.frameControl.disableDefaultResponse) { + commandResponseId = Zcl.Foundation.defaultRsp.ID; + } + + return await this.queue.execute(async () => { + this.checkInterpanLock(); + + logger.debug( + () => `~~~> [ZCL to=${ieeeAddr}:${networkAddress} clusterId=${zclFrame.cluster.ID} destEp=${endpoint} sourceEp=${sourceEndpoint}]`, + NS, + ); + + for (let i = 0; i < 2; i++) { + try { + await this.driver.sendUnicast( + zclFrame.toBuffer(), + ZSpec.HA_PROFILE_ID, + zclFrame.cluster.ID, + networkAddress, // nwkDest16 + undefined, // nwkDest64 XXX: avoid passing EUI64 whenever not absolutely necessary + endpoint, // destEp + sourceEndpoint ?? 1, // sourceEp + ); + + if (commandResponseId !== undefined) { + const resp = await this.zclWaitress + .waitFor( + { + sender: networkAddress, + clusterId: zclFrame.cluster.ID, + endpoint, + commandId: commandResponseId, + transactionSequenceNumber: zclFrame.header.transactionSequenceNumber, + }, + timeout, + ) + .start().promise; + + return resp; + } + + return; + } catch (error) { + if (disableRecovery || i == 1) { + throw error; + } // else retry + } + /* v8 ignore start */ + } // coverage detection failure + /* v8 ignore stop */ + }); + } + + public async sendZclFrameToGroup(groupID: number, zclFrame: Zcl.Frame, sourceEndpoint?: number): Promise { + return await this.queue.execute(async () => { + this.checkInterpanLock(); + + logger.debug(() => `~~~> [ZCL GROUP to=${groupID} clusterId=${zclFrame.cluster.ID} sourceEp=${sourceEndpoint}]`, NS); + + await this.driver.sendMulticast(zclFrame.toBuffer(), ZSpec.HA_PROFILE_ID, zclFrame.cluster.ID, groupID, 0xff, sourceEndpoint ?? 1); + // settle + await wait(500); + }); + } + + public async sendZclFrameToAll( + endpoint: number, + zclFrame: Zcl.Frame, + sourceEndpoint: number, + destination: ZSpec.BroadcastAddress, + ): Promise { + return await this.queue.execute(async () => { + this.checkInterpanLock(); + + logger.debug(() => `~~~> [ZCL BROADCAST to=${destination} destEp=${endpoint} sourceEp=${sourceEndpoint}]`, NS); + + await this.driver.sendBroadcast(zclFrame.toBuffer(), ZSpec.HA_PROFILE_ID, zclFrame.cluster.ID, destination, endpoint, sourceEndpoint); + // settle + await wait(500); + }); + } + + // #endregion + + // #region InterPAN + + /* v8 ignore start */ + public async setChannelInterPAN(channel: number): Promise { + throw new Error(`not supported ${channel}`); + } + /* v8 ignore stop */ + + /* v8 ignore start */ + public async sendZclFrameInterPANToIeeeAddr(zclFrame: Zcl.Frame, ieeeAddress: string): Promise { + throw new Error(`not supported ${JSON.stringify(zclFrame)}, ${ieeeAddress}`); + } + /* v8 ignore stop */ + + /* v8 ignore start */ + public async sendZclFrameInterPANBroadcast(zclFrame: Zcl.Frame, timeout: number): Promise { + throw new Error(`not supported ${JSON.stringify(zclFrame)}, ${timeout}`); + } + /* v8 ignore stop */ + + /* v8 ignore start */ + public async restoreChannelInterPAN(): Promise { + throw new Error(`not supported`); + } + /* v8 ignore stop */ + + // #endregion + + // #region Implementation-Specific + + /* v8 ignore start */ + private checkInterpanLock(): void { + if (this.interpanLock) { + throw new Error(`[INTERPAN MODE] Cannot execute non-InterPAN commands.`); + } + } + /* v8 ignore stop */ + + /** + * @param sender16 If undefined, sender64 is expected defined + * @param sender64 If undefined, sender16 is expected defined + * @param apsHeader + * @param apsPayload + */ + private onFrame( + sender16: number | undefined, + sender64: bigint | undefined, + apsHeader: ZigbeeAPSHeader, + apsPayload: ZigbeeAPSPayload, + rssi: number, + ): void { + const data = Buffer.from(apsPayload); + + if (apsHeader.profileId === Zdo.ZDO_PROFILE_ID) { + logger.debug(() => `<~~~ APS ZDO[sender=${sender16}:${sender64} clusterId=${apsHeader.clusterId}]`, NS); + + const result = Zdo.Buffalo.readResponse(this.hasZdoMessageOverhead, apsHeader.clusterId!, data); + + if (apsHeader.clusterId! === Zdo.ClusterId.NETWORK_ADDRESS_RESPONSE) { + // special case to properly resolve a NETWORK_ADDRESS_RESPONSE following a NETWORK_ADDRESS_REQUEST (based on EUI64 from ZDO payload) + // NOTE: if response has invalid status (no EUI64 available), response waiter will eventually time out + if (Zdo.Buffalo.checkStatus(result)) { + this.zdoWaitress.resolve({sender: result[1].eui64, clusterId: apsHeader.clusterId, response: result}); + } + } else { + this.zdoWaitress.resolve({sender: sender16!, clusterId: apsHeader.clusterId!, response: result}); + } + + this.emit('zdoResponse', apsHeader.clusterId!, result); + } else { + logger.debug(() => `<~~~ APS[sender=${sender16}:${sender64} profileId=${apsHeader.profileId} clusterId=${apsHeader.clusterId}]`, NS); + + const payload: ZclPayload = { + clusterID: apsHeader.clusterId!, + header: Zcl.Header.fromBuffer(data), + address: sender64 !== undefined ? `0x${bigUInt64ToHexBE(sender64)}` : sender16!, + data, + endpoint: apsHeader.sourceEndpoint!, + linkquality: rssi, // TODO: convert RSSI to LQA + groupID: apsHeader.group!, + wasBroadcast: apsHeader.frameControl.deliveryMode === 2 /* BCAST */, + destinationEndpoint: apsHeader.destEndpoint!, + }; + + this.zclWaitress.resolve(payload); + this.emit('zclPayload', payload); + } + } + + private onDeviceJoined(source16: number, source64: bigint): void { + this.emit('deviceJoined', {networkAddress: source16, ieeeAddr: `0x${bigUInt64ToHexBE(source64)}`}); + } + + private onDeviceRejoined(source16: number, source64: bigint): void { + this.emit('deviceJoined', {networkAddress: source16, ieeeAddr: `0x${bigUInt64ToHexBE(source64)}`}); + } + + private onDeviceLeft(source16: number, source64: bigint): void { + this.emit('deviceLeave', {networkAddress: source16, ieeeAddr: `0x${bigUInt64ToHexBE(source64)}`}); + } + + /* v8 ignore start */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + private onDeviceAuthorized(source16: number, source64: bigint): void {} + /* v8 ignore stop */ + + private waitressTimeoutFormatter(matcher: WaitressMatcher, timeout: number): string { + return `Timeout after ${timeout}ms [sender=${matcher.sender} clusterId=${matcher.clusterId} cmdId=${matcher.commandId}]`; + } + + private zclWaitressValidator(payload: ZclPayload, matcher: WaitressMatcher): boolean { + return ( + // no sender in Touchlink + (matcher.sender === undefined || payload.address === matcher.sender) && + payload.clusterID === matcher.clusterId && + payload.endpoint === matcher.endpoint && + payload.header!.commandIdentifier === matcher.commandId && + (matcher.transactionSequenceNumber === undefined || payload.header!.transactionSequenceNumber === matcher.transactionSequenceNumber) + ); + } + + private zdoWaitressValidator(payload: ZdoResponse, matcher: WaitressMatcher): boolean { + return payload.sender === matcher.sender && payload.clusterId === matcher.clusterId; + } + // #endregion +} diff --git a/test/adapter/adapter.test.ts b/test/adapter/adapter.test.ts index edfbd5606a..24a179d9a4 100644 --- a/test/adapter/adapter.test.ts +++ b/test/adapter/adapter.test.ts @@ -11,6 +11,7 @@ import {SerialPort} from '../../src/adapter/serialPort'; import {ZStackAdapter} from '../../src/adapter/z-stack/adapter/zStackAdapter'; import {ZBOSSAdapter} from '../../src/adapter/zboss/adapter/zbossAdapter'; import {ZiGateAdapter} from '../../src/adapter/zigate/adapter/zigateAdapter'; +import {ZoHAdapter} from '../../src/adapter/zoh/adapter/zohAdapter'; import { DECONZ_CONBEE_II, EMBER_SKYCONNECT, @@ -87,9 +88,15 @@ describe('Adapter', () => { ['zstack', ZStackAdapter], ['zboss', ZBOSSAdapter], ['zigate', ZiGateAdapter], + ['zoh', ZoHAdapter], ])('Calls adapter contructor for %s', async (name, cls) => { const adapter = await Adapter.create( - {panID: 0x1a62, channelList: [11]}, + { + panID: 0x1a62, + channelList: [11], + extendedPanID: [0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd], + networkKey: [1, 3, 5, 7, 9, 11, 13, 15, 0, 2, 4, 6, 8, 10, 12, 13], + }, {path: '/dev/ttyUSB0', adapter: name as TsType.Adapter}, 'test.db.backup', {disableLED: false}, diff --git a/test/adapter/zoh/utils.test.ts b/test/adapter/zoh/utils.test.ts new file mode 100644 index 0000000000..5fe9e8cfa5 --- /dev/null +++ b/test/adapter/zoh/utils.test.ts @@ -0,0 +1,35 @@ +import {bigUInt64ToBufferBE, bigUInt64ToBufferLE, bigUInt64ToHexBE} from '../../../src/adapter/zoh/adapter/utils'; + +describe('ZoH Utils', () => { + it('handles bigint conversions', () => { + const v10x = '0x90395efffec7fd21'; + const v1Buf = Buffer.from([0x21, 0xfd, 0xc7, 0xfe, 0xff, 0x5e, 0x39, 0x90]); + const v1BigInt = 10392442068718320929n; + + const v20x = '0x9986ffbb4523acef'; + const v2Buf = Buffer.from([0xef, 0xac, 0x23, 0x45, 0xbb, 0xff, 0x86, 0x99]); + const v2BigInt = 11062810714466135279n; + + const v30x = '0x0322334455667788'; + const v3Buf = Buffer.from([0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x03]); + const v3BigInt = 225799299905517448n; + + expect(bigUInt64ToHexBE(v1BigInt)).toStrictEqual(v10x.slice(2)); + expect(bigUInt64ToBufferLE(v1BigInt)).toStrictEqual(v1Buf); + expect(bigUInt64ToBufferBE(v1BigInt)).toStrictEqual(Buffer.from(v1Buf).reverse()); + expect(BigInt(v10x)).toStrictEqual(v1BigInt); + expect(v1Buf.readBigUInt64LE(0)).toStrictEqual(v1BigInt); + + expect(bigUInt64ToHexBE(v2BigInt)).toStrictEqual(v20x.slice(2)); + expect(bigUInt64ToBufferLE(v2BigInt)).toStrictEqual(v2Buf); + expect(bigUInt64ToBufferBE(v2BigInt)).toStrictEqual(Buffer.from(v2Buf).reverse()); + expect(BigInt(v20x)).toStrictEqual(v2BigInt); + expect(v2Buf.readBigUInt64LE(0)).toStrictEqual(v2BigInt); + + expect(bigUInt64ToHexBE(v3BigInt)).toStrictEqual(v30x.slice(2)); + expect(bigUInt64ToBufferLE(v3BigInt)).toStrictEqual(v3Buf); + expect(bigUInt64ToBufferBE(v3BigInt)).toStrictEqual(Buffer.from(v3Buf).reverse()); + expect(BigInt(v30x)).toStrictEqual(v3BigInt); + expect(v3Buf.readBigUInt64LE(0)).toStrictEqual(v3BigInt); + }); +}); diff --git a/test/adapter/zoh/zohAdapter.test.ts b/test/adapter/zoh/zohAdapter.test.ts new file mode 100644 index 0000000000..5a4fe0f39b --- /dev/null +++ b/test/adapter/zoh/zohAdapter.test.ts @@ -0,0 +1,701 @@ +import {mkdirSync, rmSync} from 'node:fs'; +import {join} from 'node:path'; + +import {encodeSpinelFrame, SPINEL_HEADER_FLG_SPINEL, SpinelFrame} from 'zigbee-on-host/dist/spinel/spinel'; + +import {bigUInt64ToHexBE} from '../../../src/adapter/zoh/adapter/utils'; +import {ZoHAdapter} from '../../../src/adapter/zoh/adapter/zohAdapter'; +import * as Zcl from '../../../src/zspec/zcl'; +import * as Zdo from '../../../src/zspec/zdo'; + +const TEMP_PATH = 'zoh-tmp'; +const TEMP_PATH_SAVE = join(TEMP_PATH, 'zoh.save'); + +describe('ZigBee on Host', () => { + let adapter: ZoHAdapter; + + const deleteZoHSave = () => { + rmSync(TEMP_PATH_SAVE, {force: true}); + }; + + const makeSpinelStreamRawFrame = (tid: number, macFrame: Buffer): SpinelFrame => { + return { + header: { + tid, + nli: 0, + flg: SPINEL_HEADER_FLG_SPINEL, + }, + commandId: 6 /* PROP_VALUE_IS */, + payload: Buffer.from([113 /* STREAM_RAW */, macFrame.byteLength & 0xff, (macFrame.byteLength >> 8) & 0xff, ...macFrame]), + }; + }; + + beforeAll(() => { + vi.useFakeTimers(); + + rmSync(TEMP_PATH, {force: true, recursive: true}); + mkdirSync(TEMP_PATH, {recursive: true}); + }); + + afterAll(() => { + vi.useRealTimers(); + + rmSync(TEMP_PATH, {force: true, recursive: true}); + }); + + beforeEach(async () => { + deleteZoHSave(); + + adapter = new ZoHAdapter( + { + panID: 0x1a62, + extendedPanID: [0xdd, 0x11, 0x22, 0xdd, 0xdd, 0x33, 0x44, 0xdd], + channelList: [11], + networkKey: [0x11, 0x03, 0x15, 0x07, 0x09, 0x0b, 0x0d, 0x0f, 0x00, 0x02, 0x04, 0x06, 0x08, 0x1a, 0x1c, 0x1d], + networkKeyDistribute: false, + }, + { + baudRate: 460800, + rtscts: true, + path: '/dev/serial/by-id/mock-adapter', + adapter: 'zoh', + }, + join(TEMP_PATH, `ember_coordinator_backup.json`), + { + concurrent: 8, + disableLED: false, + transmitPower: 19, + }, + ); + + vi.spyOn(adapter, 'initPort').mockImplementation(() => Promise.resolve()); + + vi.spyOn(adapter.driver, 'start').mockImplementation(async () => { + await adapter.driver.loadState(); + }); + vi.spyOn(adapter.driver, 'getProperty').mockImplementation(() => + Promise.resolve({ + header: {tid: 1, nli: 0, flg: SPINEL_HEADER_FLG_SPINEL}, + commandId: 2 /* PROP_VALUE_GET */, + payload: Buffer.alloc(254), // more than enough to not fail various reads + }), + ); + vi.spyOn(adapter.driver, 'setProperty').mockImplementation(() => Promise.resolve([0, Buffer.alloc(0)])); + vi.spyOn(adapter.driver, 'formNetwork').mockImplementation(() => Promise.resolve()); + + vi.spyOn(adapter.driver.writer, 'pipe').mockImplementation( + // @ts-expect-error mock noop + () => {}, + ); + vi.spyOn(adapter.driver.writer, 'writeBuffer').mockImplementation((b) => {}); + + adapter.driver.parser.on('data', adapter.driver.onFrame.bind(adapter.driver)); + }); + + afterEach(async () => { + await adapter.stop(); + }); + + it('Adapter impl: gets state', async () => { + await expect(adapter.start()).resolves.toStrictEqual('resumed'); + await expect(adapter.getCoordinatorIEEE()).resolves.toStrictEqual('0x4d325a6e6f486f5a'); + await expect(adapter.getCoordinatorVersion()).resolves.toStrictEqual({ + type: 'ZigBee on Host', + meta: {revision: 'https://github.com/Nerivec/zigbee-on-host'}, + }); + await expect(adapter.getNetworkParameters()).resolves.toStrictEqual({ + panID: 0x1a62, + extendedPanID: '0xdd4433dddd2211dd', + channel: 11, + nwkUpdateID: 0, + }); + }); + + it('Adapter impl: sendZdo to device', async () => { + await adapter.start(); + + const waitForTIDSpy = vi.spyOn(adapter.driver, 'waitForTID'); + + waitForTIDSpy.mockImplementationOnce(() => Promise.resolve(makeSpinelStreamRawFrame(1, Buffer.alloc(1)))); + + const p1 = adapter.sendZdo( + '0x0807060504030201', + 0x2211, + Zdo.ClusterId.IEEE_ADDRESS_REQUEST, + Zdo.Buffalo.buildRequest(true, Zdo.ClusterId.IEEE_ADDRESS_REQUEST, 0x2211, false, 0), + false, + ); + + await vi.advanceTimersByTimeAsync(100); + adapter.driver.emit( + 'frame', + 0x2211, + 578437695752307201n, + { + frameControl: { + frameType: 0 /* DATA */, + deliveryMode: 0 /* UNICAST */, + ackFormat: false, + security: false, + ackRequest: false, + extendedHeader: false, + }, + profileId: 0x0, + clusterId: Zdo.ClusterId.IEEE_ADDRESS_RESPONSE, + sourceEndpoint: 0x0, + destEndpoint: 0x0, + }, + Buffer.from([1, 0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x11, 0x22]), + -50, + ); + + await expect(p1).resolves.toStrictEqual([ + 0, + { + eui64: '0x0807060504030201', + nwkAddress: 0x2211, + startIndex: 0, + assocDevList: [], + }, + ]); + + waitForTIDSpy.mockImplementationOnce(() => Promise.resolve(makeSpinelStreamRawFrame(1, Buffer.alloc(1)))); + + const p2 = adapter.sendZdo( + '0x0807060504030201', + 0x2211, + Zdo.ClusterId.NETWORK_ADDRESS_REQUEST, + Zdo.Buffalo.buildRequest(true, Zdo.ClusterId.NETWORK_ADDRESS_REQUEST, '0x0807060504030201', false, 0), + false, + ); + + await vi.advanceTimersByTimeAsync(100); + adapter.driver.emit( + 'frame', + 0x2211, + 578437695752307201n, + { + frameControl: { + frameType: 0 /* DATA */, + deliveryMode: 0 /* UNICAST */, + ackFormat: false, + security: false, + ackRequest: false, + extendedHeader: false, + }, + profileId: 0x0, + clusterId: Zdo.ClusterId.NETWORK_ADDRESS_RESPONSE, + sourceEndpoint: 0x0, + destEndpoint: 0x0, + }, + Buffer.from([1, 0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x11, 0x22]), + -50, + ); + + await expect(p2).resolves.toStrictEqual([ + 0, + { + eui64: '0x0807060504030201', + nwkAddress: 0x2211, + startIndex: 0, + assocDevList: [], + }, + ]); + }); + + it('Adapter impl: sendZdo to coordinator', async () => { + await adapter.start(); + + const emitSpy = vi.spyOn(adapter, 'emit'); + + await adapter.sendZdo( + `0x${bigUInt64ToHexBE(adapter.driver.netParams.eui64)}`, + 0x0000, + Zdo.ClusterId.NODE_DESCRIPTOR_REQUEST, + Zdo.Buffalo.buildRequest(true, Zdo.ClusterId.NODE_DESCRIPTOR_REQUEST, 0x0000), + false, + ); + + expect(emitSpy).toHaveBeenLastCalledWith('zdoResponse', Zdo.ClusterId.NODE_DESCRIPTOR_RESPONSE, [ + 0, + expect.objectContaining({ + nwkAddress: 0x0000, + logicalType: 0x00, + manufacturerCode: Zcl.ManufacturerCode.CONNECTIVITY_STANDARDS_ALLIANCE, + serverMask: expect.objectContaining({primaryTrustCenter: 1, stackComplianceRevision: 22}), + }), + ]); + + await adapter.sendZdo( + `0x${bigUInt64ToHexBE(adapter.driver.netParams.eui64)}`, + 0x0000, + Zdo.ClusterId.POWER_DESCRIPTOR_REQUEST, + Zdo.Buffalo.buildRequest(true, Zdo.ClusterId.POWER_DESCRIPTOR_REQUEST, 0x0000), + false, + ); + + expect(emitSpy).toHaveBeenLastCalledWith('zdoResponse', Zdo.ClusterId.POWER_DESCRIPTOR_RESPONSE, [ + 0, + expect.objectContaining({nwkAddress: 0x0000}), + ]); + + await adapter.sendZdo( + `0x${bigUInt64ToHexBE(adapter.driver.netParams.eui64)}`, + 0x0000, + Zdo.ClusterId.SIMPLE_DESCRIPTOR_REQUEST, + Zdo.Buffalo.buildRequest(true, Zdo.ClusterId.SIMPLE_DESCRIPTOR_REQUEST, 0x0000, 1), + false, + ); + + expect(emitSpy).toHaveBeenLastCalledWith('zdoResponse', Zdo.ClusterId.SIMPLE_DESCRIPTOR_RESPONSE, [ + 0, + expect.objectContaining({endpoint: 1, profileId: 0x0104}), + ]); + + await adapter.sendZdo( + `0x${bigUInt64ToHexBE(adapter.driver.netParams.eui64)}`, + 0x0000, + Zdo.ClusterId.ACTIVE_ENDPOINTS_REQUEST, + Zdo.Buffalo.buildRequest(true, Zdo.ClusterId.ACTIVE_ENDPOINTS_REQUEST, 0x0000), + false, + ); + + expect(emitSpy).toHaveBeenLastCalledWith('zdoResponse', Zdo.ClusterId.ACTIVE_ENDPOINTS_RESPONSE, [ + 0, + {nwkAddress: 0, endpointList: [1, 242]}, + ]); + }); + + it('Adapter impl: permitJoin', async () => { + await adapter.start(); + + const sendZdoSpy = vi.spyOn(adapter, 'sendZdo'); + const allowJoinsSpy = vi.spyOn(adapter.driver, 'allowJoins'); + + sendZdoSpy.mockImplementationOnce(() => Promise.resolve([0, undefined])); + await adapter.permitJoin(254); + expect(allowJoinsSpy).toHaveBeenLastCalledWith(254, true); + + await adapter.permitJoin(0); + expect(allowJoinsSpy).toHaveBeenLastCalledWith(0, true); + + await adapter.permitJoin(200, 0x0000); + expect(allowJoinsSpy).toHaveBeenLastCalledWith(200, true); + + await adapter.permitJoin(0); + expect(allowJoinsSpy).toHaveBeenLastCalledWith(0, true); + + sendZdoSpy.mockImplementationOnce(() => Promise.resolve([0, undefined])); + await adapter.permitJoin(150, 0x1234); + expect(allowJoinsSpy).toHaveBeenLastCalledWith(150, false); + + await adapter.permitJoin(0); + expect(allowJoinsSpy).toHaveBeenLastCalledWith(0, true); + }); + + it('Adapter impl: sendZclFrameToEndpoint', async () => { + await adapter.start(); + + const waitForTIDSpy = vi.spyOn(adapter.driver, 'waitForTID'); + const sendUnicastSpy = vi.spyOn(adapter.driver, 'sendUnicast'); + + waitForTIDSpy.mockImplementationOnce(() => Promise.resolve(makeSpinelStreamRawFrame(1, Buffer.alloc(1)))); + sendUnicastSpy.mockImplementationOnce(() => Promise.resolve(1)); + + const zclPayload = Buffer.from([16, 123, Zcl.Foundation.read.ID]); + const zclFrame = Zcl.Frame.fromBuffer(Zcl.Clusters.genGroups.ID, Zcl.Header.fromBuffer(zclPayload), zclPayload, {}); + + const p1 = adapter.sendZclFrameToEndpoint('0x00000000000004d2', 0x9876, 1, zclFrame, 10000, false, false, 2); + + await vi.advanceTimersByTimeAsync(100); + adapter.driver.emit( + 'frame', + 0x9876, + undefined, + { + frameControl: { + frameType: 0 /* DATA */, + deliveryMode: 0 /* UNICAST */, + ackFormat: false, + security: false, + ackRequest: false, + extendedHeader: false, + }, + profileId: 0x0104, + clusterId: Zcl.Clusters.genGroups.ID, + sourceEndpoint: 0x1, + destEndpoint: 0x2, + }, + Buffer.from([0, 123, Zcl.Foundation.read.response!, 0x01, 0xff]), + -25, + ); + await expect(p1).resolves.toStrictEqual({ + address: 0x9876, + clusterID: Zcl.Clusters.genGroups.ID, + data: Buffer.from([0, 123, Zcl.Foundation.read.response!, 0x01, 0xff]), + destinationEndpoint: 2, + endpoint: 1, + groupID: undefined, + header: expect.objectContaining({ + commandIdentifier: 1, + frameControl: { + direction: 0, + disableDefaultResponse: false, + frameType: 0, + manufacturerSpecific: false, + reservedBits: 0, + }, + manufacturerCode: undefined, + transactionSequenceNumber: 123, + }), + linkquality: -25, + wasBroadcast: false, + }); + expect(sendUnicastSpy).toHaveBeenLastCalledWith(zclFrame.toBuffer(), 0x0104, Zcl.Clusters.genGroups.ID, 0x9876, undefined, 1, 2); + + waitForTIDSpy.mockImplementationOnce(() => Promise.resolve(makeSpinelStreamRawFrame(2, Buffer.alloc(1)))); + sendUnicastSpy.mockImplementationOnce(() => Promise.resolve(2)); + + const p2 = adapter.sendZclFrameToEndpoint('0x00000000000004d2', 0x9876, 1, zclFrame, 10000, true, false); + + await vi.advanceTimersByTimeAsync(100); + await expect(p2).resolves.toStrictEqual(undefined); + expect(sendUnicastSpy).toHaveBeenLastCalledWith(zclFrame.toBuffer(), 0x0104, Zcl.Clusters.genGroups.ID, 0x9876, undefined, 1, 1); + + const zclPayloadDefRsp = Buffer.from([0, 123, Zcl.Foundation.read.ID]); + const zclFrameDefRsp = Zcl.Frame.fromBuffer(Zcl.Clusters.genGroups.ID, Zcl.Header.fromBuffer(zclPayloadDefRsp), zclPayloadDefRsp, {}); + + waitForTIDSpy.mockImplementationOnce(() => Promise.resolve(makeSpinelStreamRawFrame(3, Buffer.alloc(1)))); + sendUnicastSpy.mockImplementationOnce(() => Promise.resolve(3)); + + const p3 = adapter.sendZclFrameToEndpoint('0x00000000000004d2', 0x9876, 1, zclFrameDefRsp, 10000, true, false, 2); + + await vi.advanceTimersByTimeAsync(100); + adapter.driver.emit( + 'frame', + 0x9876, + undefined, + { + frameControl: { + frameType: 0 /* DATA */, + deliveryMode: 0 /* UNICAST */, + ackFormat: false, + security: false, + ackRequest: false, + extendedHeader: false, + }, + profileId: 0x0104, + clusterId: Zcl.Clusters.genGroups.ID, + sourceEndpoint: 0x1, + destEndpoint: 0x2, + }, + Buffer.from([0, 123, Zcl.Foundation.defaultRsp.ID, 0x01, 0xff]), + -25, + ); + await expect(p3).resolves.toStrictEqual({ + address: 0x9876, + clusterID: Zcl.Clusters.genGroups.ID, + data: Buffer.from([0, 123, Zcl.Foundation.defaultRsp.ID, 0x01, 0xff]), + destinationEndpoint: 2, + endpoint: 1, + groupID: undefined, + header: expect.objectContaining({ + commandIdentifier: Zcl.Foundation.defaultRsp.ID, + frameControl: { + direction: 0, + disableDefaultResponse: false, + frameType: 0, + manufacturerSpecific: false, + reservedBits: 0, + }, + manufacturerCode: undefined, + transactionSequenceNumber: 123, + }), + linkquality: -25, + wasBroadcast: false, + }); + expect(sendUnicastSpy).toHaveBeenLastCalledWith(zclFrameDefRsp.toBuffer(), 0x0104, Zcl.Clusters.genGroups.ID, 0x9876, undefined, 1, 2); + + sendUnicastSpy.mockClear(); + waitForTIDSpy.mockImplementationOnce(() => Promise.resolve(makeSpinelStreamRawFrame(2, Buffer.alloc(1)))); + sendUnicastSpy.mockImplementationOnce(() => Promise.reject(new Error('Failed'))).mockImplementationOnce(() => Promise.resolve(2)); + + const p4 = adapter.sendZclFrameToEndpoint('0x00000000000004d2', 0x9876, 1, zclFrame, 10000, false, false, 2); + + await vi.advanceTimersByTimeAsync(100); + adapter.driver.emit( + 'frame', + 0x9876, + undefined, + { + frameControl: { + frameType: 0 /* DATA */, + deliveryMode: 0 /* UNICAST */, + ackFormat: false, + security: false, + ackRequest: false, + extendedHeader: false, + }, + profileId: 0x0104, + clusterId: Zcl.Clusters.genGroups.ID, + sourceEndpoint: 0x1, + destEndpoint: 0x2, + }, + Buffer.from([0, 123, Zcl.Foundation.read.response!, 0x01, 0xff]), + -25, + ); + await expect(p4).resolves.toStrictEqual({ + address: 0x9876, + clusterID: Zcl.Clusters.genGroups.ID, + data: Buffer.from([0, 123, Zcl.Foundation.read.response!, 0x01, 0xff]), + destinationEndpoint: 2, + endpoint: 1, + groupID: undefined, + header: expect.objectContaining({ + commandIdentifier: 1, + frameControl: { + direction: 0, + disableDefaultResponse: false, + frameType: 0, + manufacturerSpecific: false, + reservedBits: 0, + }, + manufacturerCode: undefined, + transactionSequenceNumber: 123, + }), + linkquality: -25, + wasBroadcast: false, + }); + expect(sendUnicastSpy).toHaveBeenLastCalledWith(zclFrame.toBuffer(), 0x0104, Zcl.Clusters.genGroups.ID, 0x9876, undefined, 1, 2); + expect(sendUnicastSpy).toHaveBeenCalledTimes(2); + + sendUnicastSpy.mockClear(); + waitForTIDSpy.mockImplementationOnce(() => Promise.resolve(makeSpinelStreamRawFrame(2, Buffer.alloc(1)))); + sendUnicastSpy + .mockImplementationOnce(() => Promise.reject(new Error('Failed'))) + .mockImplementationOnce(() => Promise.reject(new Error('Failed'))); + + await expect(adapter.sendZclFrameToEndpoint('0x00000000000004d2', 0x9876, 1, zclFrame, 10000, false, false, 2)).rejects.toThrow('Failed'); + expect(sendUnicastSpy).toHaveBeenCalledTimes(2); + }); + + it('Adapter impl: sendZclFrameToGroup', async () => { + await adapter.start(); + + const sendMulticastSpy = vi.spyOn(adapter.driver, 'sendMulticast').mockImplementationOnce(() => Promise.resolve(1)); + + const zclPayload = Buffer.from([0, 123, Zcl.Foundation.read.ID]); + const zclFrame = Zcl.Frame.fromBuffer(Zcl.Clusters.genGroups.ID, Zcl.Header.fromBuffer(zclPayload), zclPayload, {}); + + const p1 = adapter.sendZclFrameToGroup(123, zclFrame, 5); + + await vi.advanceTimersByTimeAsync(1000); + await expect(p1).resolves.toStrictEqual(undefined); + expect(sendMulticastSpy).toHaveBeenLastCalledWith(zclFrame.toBuffer(), 0x0104, Zcl.Clusters.genGroups.ID, 123, 0xff, 5); + + const p2 = adapter.sendZclFrameToGroup(123, zclFrame); + + await vi.advanceTimersByTimeAsync(1000); + await expect(p2).resolves.toStrictEqual(undefined); + expect(sendMulticastSpy).toHaveBeenLastCalledWith(zclFrame.toBuffer(), 0x0104, Zcl.Clusters.genGroups.ID, 123, 0xff, 1); + }); + + it('Adapter impl: sendZclFrameToAll', async () => { + await adapter.start(); + + const sendBroadcastSpy = vi.spyOn(adapter.driver, 'sendBroadcast').mockImplementationOnce(() => Promise.resolve(1)); + + const zclPayload = Buffer.from([0, 123, Zcl.Foundation.read.ID]); + const zclFrame = Zcl.Frame.fromBuffer(Zcl.Clusters.genAlarms.ID, Zcl.Header.fromBuffer(zclPayload), zclPayload, {}); + + const p = adapter.sendZclFrameToAll(3, zclFrame, 1, 0xfffc); + + await vi.advanceTimersByTimeAsync(1000); + await expect(p).resolves.toStrictEqual(undefined); + expect(sendBroadcastSpy).toHaveBeenLastCalledWith(zclFrame.toBuffer(), 0x0104, Zcl.Clusters.genAlarms.ID, 0xfffc, 3, 1); + }); + + it('receives ZDO frame', async () => { + await adapter.start(); + + const emitSpy = vi.spyOn(adapter, 'emit'); + + adapter.driver.emit( + 'frame', + 0x2211, + 578437695752307201n, + { + frameControl: { + frameType: 0 /* DATA */, + deliveryMode: 0 /* UNICAST */, + ackFormat: false, + security: false, + ackRequest: false, + extendedHeader: false, + }, + profileId: 0x0, + clusterId: Zdo.ClusterId.IEEE_ADDRESS_RESPONSE, + sourceEndpoint: 0x0, + destEndpoint: 0x0, + }, + Buffer.from([1, 0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x11, 0x22]), + -50, + ); + + expect(emitSpy).toHaveBeenLastCalledWith('zdoResponse', Zdo.ClusterId.IEEE_ADDRESS_RESPONSE, [ + 0, + { + eui64: '0x0807060504030201', + nwkAddress: 0x2211, + startIndex: 0, + assocDevList: [], + }, + ]); + + // NETWORK_ADDRESS_RESPONSE codepath + adapter.driver.emit( + 'frame', + 0x2211, + 578437695752307201n, + { + frameControl: { + frameType: 0 /* DATA */, + deliveryMode: 0 /* UNICAST */, + ackFormat: false, + security: false, + ackRequest: false, + extendedHeader: false, + }, + profileId: 0x0, + clusterId: Zdo.ClusterId.NETWORK_ADDRESS_RESPONSE, + sourceEndpoint: 0x0, + destEndpoint: 0x0, + }, + Buffer.from([1, 0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x11, 0x22]), + -50, + ); + + expect(emitSpy).toHaveBeenLastCalledWith('zdoResponse', Zdo.ClusterId.NETWORK_ADDRESS_RESPONSE, [ + 0, + { + eui64: '0x0807060504030201', + nwkAddress: 0x2211, + startIndex: 0, + assocDevList: [], + }, + ]); + }); + + it('receives ZCL frame', async () => { + await adapter.start(); + + const emitSpy = vi.spyOn(adapter, 'emit'); + + adapter.driver.emit( + 'frame', + 0x9876, + undefined, + { + frameControl: { + frameType: 0 /* DATA */, + deliveryMode: 0 /* UNICAST */, + ackFormat: false, + security: false, + ackRequest: false, + extendedHeader: false, + }, + profileId: 0x0104, + clusterId: Zcl.Clusters.genAlarms.ID, + sourceEndpoint: 0x1, + destEndpoint: 0x1, + }, + Buffer.from([0, 123, Zcl.Foundation.read.ID, 0x01, 0xff]), + -25, + ); + + expect(emitSpy).toHaveBeenLastCalledWith('zclPayload', { + address: 0x9876, + clusterID: Zcl.Clusters.genAlarms.ID, + data: Buffer.from([0, 123, Zcl.Foundation.read.ID, 0x01, 0xff]), + destinationEndpoint: 1, + endpoint: 1, + groupID: undefined, + header: { + commandIdentifier: Zcl.Foundation.read.ID, + frameControl: { + direction: 0, + disableDefaultResponse: false, + frameType: 0, + manufacturerSpecific: false, + reservedBits: 0, + }, + manufacturerCode: undefined, + transactionSequenceNumber: 123, + }, + linkquality: -25, + wasBroadcast: false, + }); + + adapter.driver.emit( + 'frame', + 0x9876, + 1234n, + { + frameControl: { + frameType: 0 /* DATA */, + deliveryMode: 0 /* UNICAST */, + ackFormat: false, + security: false, + ackRequest: false, + extendedHeader: false, + }, + profileId: 0x0104, + clusterId: Zcl.Clusters.genIdentify.ID, + sourceEndpoint: 0x1, + destEndpoint: 0x1, + }, + Buffer.from([0, 123, 0x00, 0x01, 0xff]), + -25, + ); + + expect(emitSpy).toHaveBeenLastCalledWith('zclPayload', { + address: '0x00000000000004d2', + clusterID: Zcl.Clusters.genIdentify.ID, + data: Buffer.from([0, 123, 0x00, 0x01, 0xff]), + destinationEndpoint: 1, + endpoint: 1, + groupID: undefined, + header: { + commandIdentifier: 0, + frameControl: { + direction: 0, + disableDefaultResponse: false, + frameType: 0, + manufacturerSpecific: false, + reservedBits: 0, + }, + manufacturerCode: undefined, + transactionSequenceNumber: 123, + }, + linkquality: -25, + wasBroadcast: false, + }); + }); + + it('receives device events', async () => { + await adapter.start(); + + const emitSpy = vi.spyOn(adapter, 'emit'); + + adapter.driver.emit('deviceJoined', 0x123, 4321n); + expect(emitSpy).toHaveBeenLastCalledWith('deviceJoined', {networkAddress: 0x123, ieeeAddr: `0x00000000000010e1`}); + + adapter.driver.emit('deviceRejoined', 0x987, 4321n); + expect(emitSpy).toHaveBeenLastCalledWith('deviceJoined', {networkAddress: 0x987, ieeeAddr: `0x00000000000010e1`}); + + adapter.driver.emit('deviceLeft', 0x123, 4321n); + expect(emitSpy).toHaveBeenLastCalledWith('deviceLeave', {networkAddress: 0x123, ieeeAddr: `0x00000000000010e1`}); + + // adapter.driver.emit('deviceAuthorized', 0x123, 4321n); + }); +}); From a5c4fe767b02b2a937f70a0459520e7e72ee92f5 Mon Sep 17 00:00:00 2001 From: Koen Kanters Date: Sat, 15 Mar 2025 08:22:51 +0100 Subject: [PATCH 4/4] chore(master): release 3.4.0 (#1341) --- .release-please-manifest.json | 2 +- CHANGELOG.md | 13 +++++++++++++ package.json | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index f1507a00bb..395791d451 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "3.3.2" + ".": "3.4.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index ef197169b4..958820f7c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [3.4.0](https://github.com/Koenkk/zigbee-herdsman/compare/v3.3.2...v3.4.0) (2025-03-15) + + +### Features + +* Initial support for ZigBee on Host adapter ([#1308](https://github.com/Koenkk/zigbee-herdsman/issues/1308)) ([038085f](https://github.com/Koenkk/zigbee-herdsman/commit/038085fe9d9cb9644faf22d584711c339cfb2af3)) + + +### Bug Fixes + +* **ignore:** Move to `Node16` `module` ([#1340](https://github.com/Koenkk/zigbee-herdsman/issues/1340)) ([5669c21](https://github.com/Koenkk/zigbee-herdsman/commit/5669c216cfc0a4575c7d6713b7f4a710f7626913)) +* **ignore:** Use `module` `NodeNext` ([#1343](https://github.com/Koenkk/zigbee-herdsman/issues/1343)) ([590851a](https://github.com/Koenkk/zigbee-herdsman/commit/590851a1ea552974a6cdb4ae85a135a4456de5c5)) + ## [3.3.2](https://github.com/Koenkk/zigbee-herdsman/compare/v3.3.1...v3.3.2) (2025-03-11) diff --git a/package.json b/package.json index 3952ced92c..a660c319e6 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,7 @@ "clean": "rimraf temp coverage dist tsconfig.tsbuildinfo", "prepack": "pnpm run clean && pnpm run build" }, - "version": "3.3.2", + "version": "3.4.0", "pnpm": { "onlyBuiltDependencies": [ "@serialport/bindings-cpp",