Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/adapter/deconz/driver/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
72 changes: 71 additions & 1 deletion src/controller/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -412,6 +412,76 @@ export class Controller extends events.EventEmitter<ControllerEventMap> {
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<NetworkScanResult> {
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<Zdo.ClusterId.NWK_UPDATE_RESPONSE>(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;
}
Expand Down
29 changes: 29 additions & 0 deletions src/controller/tstype.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | never;
commands: Record<string, Record<string, unknown | never>> | never;
Expand Down
73 changes: 73 additions & 0 deletions test/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down