diff --git a/src/adapter/deconz/driver/driver.ts b/src/adapter/deconz/driver/driver.ts index 7aad713cfd..b837504b69 100644 --- a/src/adapter/deconz/driver/driver.ts +++ b/src/adapter/deconz/driver/driver.ts @@ -921,7 +921,7 @@ class Driver extends events.EventEmitter { }); // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` - this.socketPort!.once("close", this.onPortClose); + this.socketPort!.once("close", this.onPortClose.bind(this)); // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` this.socketPort!.on("error", (error) => { diff --git a/src/controller/controller.ts b/src/controller/controller.ts index c8803ad845..1ff9391159 100644 --- a/src/controller/controller.ts +++ b/src/controller/controller.ts @@ -23,7 +23,7 @@ import {Device, Entity} from "./model"; import {InterviewState} from "./model/device"; import Group from "./model/group"; import Touchlink from "./touchlink"; -import type {DeviceType, GreenPowerDeviceJoinedPayload, RawPayload} from "./tstype"; +import type {DeviceType, GreenPowerDeviceJoinedPayload, NetworkScanOptions, NetworkScanResult, RawPayload} from "./tstype"; const NS = "zh:controller"; @@ -412,6 +412,76 @@ export class Controller extends events.EventEmitter { return this.permitJoinEnd; } + /** + * Trigger a ZDO Mgmt_NWK_Update energy scan on the target node. + * + * This uses `NWK_UPDATE_REQUEST` with `duration` in range 0-5 and includes `count`, + * which requests an energy scan result in the corresponding `NWK_UPDATE_RESPONSE`. + */ + public async networkScan(options: NetworkScanOptions = {}): Promise { + const channels = options.channels ? [...new Set(options.channels)] : [11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26]; + assert(channels.length > 0, "At least one channel must be provided."); + + for (const channel of channels) { + assert(Number.isInteger(channel), `Channel must be an integer, got '${channel}'.`); + assert(channel >= 11 && channel <= 26, `Channel '${channel}' is invalid, use a channel between 11 - 26.`); + } + + channels.sort((a, b) => a - b); + + const duration = options.duration ?? 3; + assert(Number.isInteger(duration), `Duration must be an integer, got '${duration}'.`); + assert(duration >= 0 && duration <= 5, `Duration '${duration}' is invalid, use a value between 0 - 5.`); + + const count = options.count ?? 1; + assert(Number.isInteger(count), `Count must be an integer, got '${count}'.`); + assert(count >= 1 && count <= 8, `Count '${count}' is invalid, use a value between 1 - 8.`); + + const target = options.target ?? ZSpec.COORDINATOR_ADDRESS; + assert(Number.isInteger(target), `Target must be an integer, got '${target}'.`); + assert(target >= 0x0000 && target <= 0xffff, `Target '${target}' is invalid, use a value between 0x0000 - 0xFFFF.`); + + const clusterId = Zdo.ClusterId.NWK_UPDATE_REQUEST; + const zdoPayload = Zdo.Buffalo.buildRequest(this.adapter.hasZdoMessageOverhead, clusterId, channels, duration, count, undefined, undefined); + const response = await this.adapter.sendZdo(ZSpec.BLANK_EUI64, target, clusterId, zdoPayload, false); + + if (!Zdo.Buffalo.checkStatus(response)) { + throw new Zdo.StatusError(response[0]); + } + + assert(response[1], "NWK_UPDATE_RESPONSE payload is missing."); + const payload = response[1]; + const scannedChannels = ZSpec.Utils.uint32MaskToChannels(payload.scannedChannels).filter((channel) => channel >= 11 && channel <= 26); + + if (payload.entryList.length !== scannedChannels.length) { + logger.warning( + `Network scan entry count (${payload.entryList.length}) does not match scanned channel count (${scannedChannels.length}).`, + NS, + ); + } + + const energy: NetworkScanResult["energy"] = []; + for (let i = 0; i < scannedChannels.length; i++) { + const sample = payload.entryList[i]; + if (sample !== undefined) { + energy.push({channel: scannedChannels[i], energy: sample}); + } + } + + return { + target, + channels, + duration, + count, + scannedChannelsMask: payload.scannedChannels, + scannedChannels, + totalTransmissions: payload.totalTransmissions, + totalFailures: payload.totalFailures, + entryList: [...payload.entryList], + energy, + }; + } + public isStopping(): boolean { return this.stopping; } diff --git a/src/controller/tstype.ts b/src/controller/tstype.ts index 41d12d841e..147ab45935 100644 --- a/src/controller/tstype.ts +++ b/src/controller/tstype.ts @@ -163,6 +163,35 @@ export interface RawPayload { timeout?: number; } +export interface NetworkScanOptions { + /** Channels to scan, defaults to all channels 11-26. */ + channels?: number[]; + /** Zigbee scan duration exponent (0-5). */ + duration?: number; + /** Number of scans per channel (1-8). */ + count?: number; + /** Target network address, defaults to coordinator (0x0000). */ + target?: number; +} + +export interface NetworkScanEnergy { + channel: number; + energy: number; +} + +export interface NetworkScanResult { + target: number; + channels: number[]; + duration: number; + count: number; + scannedChannelsMask: number; + scannedChannels: number[]; + totalTransmissions: number; + totalFailures: number; + entryList: number[]; + energy: NetworkScanEnergy[]; +} + export interface TCustomCluster { attributes: Record | never; commands: Record> | never; diff --git a/test/controller.test.ts b/test/controller.test.ts index 3a768b34d4..b02434ff57 100755 --- a/test/controller.test.ts +++ b/test/controller.test.ts @@ -9692,6 +9692,79 @@ describe("Controller", () => { expect(device.genBasic.zclVersion).toStrictEqual(2); }); + it("Controller networkScan requests and parses NWK_UPDATE response", async () => { + await controller.start(); + mockAdapterSendZdo.mockClear(); + + mockAdapterSendZdo.mockImplementationOnce(() => { + return [ + Zdo.Status.SUCCESS, + { + scannedChannels: ZSpec.Utils.channelsToUInt32Mask([11, 15]), + totalTransmissions: 12, + totalFailures: 3, + entryList: [189, 141], + }, + ]; + }); + + const result = await controller.networkScan({channels: [15, 11], duration: 3, count: 2, target: 0x0000}); + + const expectedPayload = Zdo.Buffalo.buildRequest(false, Zdo.ClusterId.NWK_UPDATE_REQUEST, [11, 15], 3, 2, undefined, undefined); + expect(mockAdapterSendZdo).toHaveBeenCalledTimes(1); + expect(mockAdapterSendZdo).toHaveBeenCalledWith(ZSpec.BLANK_EUI64, 0x0000, Zdo.ClusterId.NWK_UPDATE_REQUEST, expectedPayload, false); + expect(result).toStrictEqual({ + target: 0x0000, + channels: [11, 15], + duration: 3, + count: 2, + scannedChannelsMask: ZSpec.Utils.channelsToUInt32Mask([11, 15]), + scannedChannels: [11, 15], + totalTransmissions: 12, + totalFailures: 3, + entryList: [189, 141], + energy: [ + {channel: 11, energy: 189}, + {channel: 15, energy: 141}, + ], + }); + }); + + it("Controller networkScan throws on failed NWK_UPDATE status", async () => { + await controller.start(); + mockAdapterSendZdo.mockClear(); + + mockAdapterSendZdo.mockImplementationOnce(() => { + return [Zdo.Status.NOT_SUPPORTED, undefined]; + }); + + await expect(controller.networkScan()).rejects.toThrow(); + expect(mockAdapterSendZdo).toHaveBeenCalledTimes(1); + }); + + it("Controller networkScan warns when scan entries do not match scanned channel count", async () => { + await controller.start(); + mockAdapterSendZdo.mockClear(); + mockLogger.warning.mockClear(); + + mockAdapterSendZdo.mockImplementationOnce(() => { + return [ + Zdo.Status.SUCCESS, + { + scannedChannels: ZSpec.Utils.channelsToUInt32Mask([11, 15]), + totalTransmissions: 12, + totalFailures: 3, + entryList: [189], + }, + ]; + }); + + const result = await controller.networkScan({channels: [11, 15], duration: 3, count: 2, target: 0x0000}); + + expect(mockLogger.warning).toHaveBeenCalledWith("Network scan entry count (1) does not match scanned channel count (2).", "zh:controller"); + expect(result.energy).toStrictEqual([{channel: 11, energy: 189}]); + }); + it("triggers sendZdo on sendRaw", async () => { await controller.start(); mockAdapterSendZdo.mockClear();