zigbee-herdsman
Version:
An open source ZigBee gateway solution with node.js.
1,266 lines (1,155 loc) • 451 kB
text/typescript
import fs from "node:fs";
import path from "node:path";
import equals from "fast-deep-equal/es6";
import {ZStackAdapter} from "../src/adapter/z-stack/adapter/zStackAdapter";
import {Controller} from "../src/controller";
import type * as Events from "../src/controller/events";
import GreenPower from "../src/controller/greenPower";
import Request from "../src/controller/helpers/request";
import zclTransactionSequenceNumber from "../src/controller/helpers/zclTransactionSequenceNumber";
import {Device, Endpoint, Group} from "../src/controller/model";
import {InterviewState} from "../src/controller/model/device";
import type * as Models from "../src/models";
import * as Utils from "../src/utils";
import {setLogger} from "../src/utils/logger";
import * as ZSpec from "../src/zspec";
import {BroadcastAddress} from "../src/zspec/enums";
import * as Zcl from "../src/zspec/zcl";
import * as Zdo from "../src/zspec/zdo";
import type {IEEEAddressResponse, NetworkAddressResponse} from "../src/zspec/zdo/definition/tstypes";
import {DEFAULT_184_CHECKIN_INTERVAL, LQI_TABLE_ENTRY_DEFAULTS, MOCK_DEVICES, ROUTING_TABLE_ENTRY_DEFAULTS} from "./mockDevices";
const globalSetImmediate = setImmediate;
const flushPromises = () => new Promise(globalSetImmediate);
const mockLogger = {
debug: vi.fn((messageOrLambda) => {
if (typeof messageOrLambda === "function") messageOrLambda();
}),
info: vi.fn(),
warning: vi.fn(),
error: vi.fn(),
};
const mockDummyBackup: Models.Backup = {
networkOptions: {
panId: 6755,
extendedPanId: Buffer.from("deadbeef01020304", "hex"),
channelList: [11],
networkKey: Buffer.from("a1a2a3a4a5a6a7a8b1b2b3b4b5b6b7b8", "hex"),
networkKeyDistribute: false,
},
coordinatorIeeeAddress: Buffer.from("0102030405060708", "hex"),
logicalChannel: 11,
networkUpdateId: 0,
securityLevel: 5,
znp: {
version: 1,
},
networkKeyInfo: {
sequenceNumber: 0,
frameCounter: 10000,
},
devices: [
{
networkAddress: 1001,
ieeeAddress: Buffer.from("c1c2c3c4c5c6c7c8", "hex"),
isDirectChild: false,
},
{
networkAddress: 1002,
ieeeAddress: Buffer.from("d1d2d3d4d5d6d7d8", "hex"),
isDirectChild: false,
linkKey: {
key: Buffer.from("f8f7f6f5f4f3f2f1e1e2e3e4e5e6e7e8", "hex"),
rxCounter: 10000,
txCounter: 5000,
},
},
],
};
type AdapterEvent = "zclPayload" | "deviceJoined" | "deviceLeave" | "zdoResponse" | "disconnected";
const mockAdapterEvents: Record<AdapterEvent, (...args: unknown[]) => void> = {
zclPayload: () => {},
deviceJoined: () => {},
deviceLeave: () => {},
zdoResponse: () => {},
disconnected: () => {},
};
const mockAdapterWaitFor = vi.fn();
const mockAdapterSupportsDiscoverRoute = vi.fn();
const mockSetChannelInterPAN = vi.fn();
const mocksendZclFrameInterPANToIeeeAddr = vi.fn();
const mocksendZclFrameInterPANBroadcast = vi.fn();
const mockRestoreChannelInterPAN = vi.fn();
const mockAdapterPermitJoin = vi.fn();
const mockDiscoverRoute = vi.fn();
const mockAdapterSupportsBackup = vi.fn().mockReturnValue(true);
const mockAdapterReset = vi.fn();
const mockAdapterStop = vi.fn();
const mockAdapterStart = vi.fn().mockReturnValue("resumed");
const mockAdapterGetCoordinatorIEEE = vi.fn().mockReturnValue("0x0000012300000000");
const mockAdapterGetNetworkParameters = vi.fn().mockReturnValue({panID: 1, extendedPanID: "0x64c5fd698daf0c00", channel: 15, nwkUpdateID: 0});
const mocksendZclFrameToGroup = vi.fn();
const mocksendZclFrameToAll = vi.fn();
const mockAddInstallCode = vi.fn();
const mocksendZclFrameToEndpoint = vi.fn();
const mockApaterBackup = vi.fn(() => Promise.resolve(mockDummyBackup));
let sendZdoResponseStatus = Zdo.Status.SUCCESS;
const mockAdapterSendZdo = vi
.fn()
.mockImplementation(async (_ieeeAddress: string, networkAddress: number, clusterId: Zdo.ClusterId, payload: Buffer, _disableResponse: true) => {
if (sendZdoResponseStatus !== Zdo.Status.SUCCESS) {
return [sendZdoResponseStatus, undefined];
}
if (ZSpec.BroadcastAddress[networkAddress]) {
// TODO
} else {
const device = MOCK_DEVICES[networkAddress];
if (!device) {
throw new Error(`Mock device ${networkAddress} not found`);
}
switch (clusterId) {
case Zdo.ClusterId.NODE_DESCRIPTOR_REQUEST: {
if (device.key === "xiaomi") {
const frame = Zcl.Frame.create(
0,
1,
true,
undefined,
10,
"readRsp",
0,
[{attrId: 5, status: 0, dataType: 66, attrData: "lumi.occupancy"}],
{},
);
await mockAdapterEvents.zclPayload({
wasBroadcast: false,
address: networkAddress,
clusterID: frame.cluster.ID,
data: frame.toBuffer(),
header: frame.header,
endpoint: 1,
linkquality: 50,
groupID: 1,
});
}
if (!device.nodeDescriptor) {
throw new Error("NODE_DESCRIPTOR_REQUEST timeout");
}
return device.nodeDescriptor;
}
case Zdo.ClusterId.ACTIVE_ENDPOINTS_REQUEST: {
if (!device.activeEndpoints) {
throw new Error("ACTIVE_ENDPOINTS_REQUEST timeout");
}
return device.activeEndpoints;
}
case Zdo.ClusterId.SIMPLE_DESCRIPTOR_REQUEST: {
if (!device.simpleDescriptor) {
throw new Error("SIMPLE_DESCRIPTOR_REQUEST timeout");
}
// XXX: only valid if hasZdoMessageOverhead === false
const endpoint = payload[2];
if (device.simpleDescriptor[endpoint] === undefined) {
throw new Error(`SIMPLE_DESCRIPTOR_REQUEST(${endpoint}) timeout`);
}
return device.simpleDescriptor[endpoint];
}
case Zdo.ClusterId.LQI_TABLE_REQUEST: {
if (!device.lqiTable) {
throw new Error("LQI_TABLE_REQUEST timeout");
}
return device.lqiTable;
}
case Zdo.ClusterId.ROUTING_TABLE_REQUEST: {
if (!device.routingTable) {
throw new Error("ROUTING_TABLE_REQUEST timeout");
}
return device.routingTable;
}
default: {
// Zdo.ClusterId.LEAVE_REQUEST, Zdo.ClusterId.BIND_REQUEST, Zdo.ClusterId.UNBIND_REQUEST
return [Zdo.Status.SUCCESS, undefined];
}
}
}
});
let iasZoneReadState170Count = 0;
let enroll170 = true;
let configureReportStatus = 0;
let configureReportDefaultRsp = false;
const restoreMocksendZclFrameToEndpoint = () => {
mocksendZclFrameToEndpoint.mockImplementation((_ieeeAddr, networkAddress, endpoint, frame: Zcl.Frame) => {
if (
frame.header.isGlobal &&
frame.isCommand("read") &&
(frame.isCluster("genBasic") || frame.isCluster("ssIasZone") || frame.isCluster("genPollCtrl") || frame.isCluster("hvacThermostat"))
) {
const payload: {[key: string]: unknown}[] = [];
const cluster = frame.cluster;
for (const item of frame.payload) {
if (item.attrId !== 65314) {
const attribute = cluster.getAttribute(item.attrId);
if (frame.isCluster("ssIasZone") && item.attrId === 0) {
iasZoneReadState170Count++;
payload.push({
attrId: item.attrId,
dataType: attribute.type,
attrData: iasZoneReadState170Count === 2 && enroll170 ? 1 : 0,
status: 0,
});
} else {
payload.push({
attrId: item.attrId,
dataType: attribute.type,
attrData: MOCK_DEVICES[networkAddress]!.attributes![endpoint][attribute.name],
status: 0,
});
}
}
}
const responseFrame = Zcl.Frame.create(0, 1, true, undefined, 10, "readRsp", frame.cluster.ID, payload, {});
return {clusterID: responseFrame.cluster.ID, header: responseFrame.header, data: responseFrame.toBuffer()};
}
if (frame.header.isSpecific && (frame.isCommand("add") || frame.isCommand("remove")) && frame.isCluster("genGroups")) {
const responseFrame = Zcl.Frame.create(
1,
1,
true,
undefined,
10,
`${frame.command.name}Rsp`,
frame.cluster.ID,
{status: 0, groupid: 1},
{},
);
return {clusterID: frame.cluster.ID, header: responseFrame.header, data: responseFrame.toBuffer()};
}
if (
networkAddress === 170 &&
frame.header.isGlobal &&
frame.isCluster("ssIasZone") &&
frame.isCommand("write") &&
frame.payload[0].attrId === 16
) {
// Write of ias cie address
const response = Zcl.Frame.create(
Zcl.FrameType.SPECIFIC,
Zcl.Direction.SERVER_TO_CLIENT,
false,
undefined,
1,
"enrollReq",
Zcl.Utils.getCluster("ssIasZone", undefined, {}).ID,
{zonetype: 0, manucode: 1},
{},
);
mockAdapterEvents.zclPayload({
wasBroadcast: false,
address: 170,
clusterID: response.cluster.ID,
data: response.toBuffer(),
header: response.header,
endpoint: 1,
linkquality: 50,
groupID: 1,
});
}
if (frame.header.isGlobal && frame.isCommand("write")) {
const payload: {[key: string]: unknown}[] = [];
for (const item of frame.payload) {
payload.push({attrId: item.attrId, status: 0});
}
const responseFrame = Zcl.Frame.create(0, 1, true, undefined, 10, "writeRsp", 0, payload, {});
return {clusterID: responseFrame.cluster.ID, header: responseFrame.header, data: responseFrame.toBuffer()};
}
if (frame.header.isGlobal && frame.isCommand("configReport")) {
let payload;
let cmd;
if (configureReportDefaultRsp) {
payload = {cmdId: 1, statusCode: configureReportStatus};
cmd = "defaultRsp";
} else {
payload = [];
cmd = "configReportRsp";
for (const item of frame.payload) {
payload.push({attrId: item.attrId, status: configureReportStatus, direction: 1});
}
}
const responseFrame = Zcl.Frame.create(0, 1, true, undefined, 10, cmd, 0, payload, {});
return {clusterID: responseFrame.cluster.ID, header: responseFrame.header, data: responseFrame.toBuffer()};
}
});
};
const mocksClear = [
mockAdapterStart,
mocksendZclFrameToEndpoint,
mockAdapterReset,
mocksendZclFrameToGroup,
mockSetChannelInterPAN,
mocksendZclFrameInterPANToIeeeAddr,
mocksendZclFrameInterPANBroadcast,
mockRestoreChannelInterPAN,
mockAddInstallCode,
mockAdapterGetNetworkParameters,
mockAdapterSendZdo,
mockLogger.debug,
mockLogger.info,
mockLogger.warning,
mockLogger.error,
];
const deepClone = (obj: object | undefined) => JSON.parse(JSON.stringify(obj));
const equalsPartial = (objA: object, objB: object) => {
for (const [key, value] of Object.entries(objB)) {
if (!equals(objA[key as keyof typeof objA], value)) {
return false;
}
}
return true;
};
vi.mock("../src/utils/wait", () => ({
wait: vi.fn(() => {
return new Promise<void>((resolve) => resolve());
}),
}));
let dummyBackup: Models.UnifiedBackupStorage | undefined;
vi.mock("../src/adapter/z-stack/adapter/zStackAdapter", () => ({
ZStackAdapter: vi.fn(() => ({
hasZdoMessageOverhead: false,
manufacturerID: 0x0007,
on: (event: AdapterEvent, handler: (...args: unknown[]) => void) => {
mockAdapterEvents[event] = handler;
},
removeAllListeners: (event: AdapterEvent) => delete mockAdapterEvents[event],
start: mockAdapterStart,
getCoordinatorIEEE: mockAdapterGetCoordinatorIEEE,
reset: mockAdapterReset,
supportsBackup: mockAdapterSupportsBackup,
backup: mockApaterBackup,
getCoordinatorVersion: () => {
return {type: "zStack", meta: {version: 1}};
},
getNetworkParameters: mockAdapterGetNetworkParameters,
waitFor: mockAdapterWaitFor,
sendZclFrameToEndpoint: mocksendZclFrameToEndpoint,
sendZclFrameToGroup: mocksendZclFrameToGroup,
sendZclFrameToAll: mocksendZclFrameToAll,
addInstallCode: mockAddInstallCode,
permitJoin: mockAdapterPermitJoin,
supportsDiscoverRoute: mockAdapterSupportsDiscoverRoute,
discoverRoute: mockDiscoverRoute,
stop: mockAdapterStop,
setChannelInterPAN: mockSetChannelInterPAN,
sendZclFrameInterPANToIeeeAddr: mocksendZclFrameInterPANToIeeeAddr,
sendZclFrameInterPANBroadcast: mocksendZclFrameInterPANBroadcast,
restoreChannelInterPAN: mockRestoreChannelInterPAN,
sendZdo: mockAdapterSendZdo,
})),
}));
const TEMP_PATH = path.resolve("temp");
const getTempFile = (filename: string): string => {
if (!fs.existsSync(TEMP_PATH)) {
fs.mkdirSync(TEMP_PATH);
}
return path.join(TEMP_PATH, filename);
};
const mocksRestore = [mockAdapterPermitJoin, mockAdapterStop, mocksendZclFrameToAll];
const events: {
deviceJoined: Events.DeviceJoinedPayload[];
deviceInterview: Events.DeviceInterviewPayload[];
adapterDisconnected: number[];
deviceAnnounce: Events.DeviceAnnouncePayload[];
deviceLeave: Events.DeviceLeavePayload[];
message: Events.MessagePayload[];
permitJoinChanged: Events.PermitJoinChangedPayload[];
lastSeenChanged: Events.LastSeenChangedPayload[];
deviceNetworkAddressChanged: Events.DeviceNetworkAddressChangedPayload[];
} = {
deviceJoined: [],
deviceInterview: [],
adapterDisconnected: [],
deviceAnnounce: [],
deviceLeave: [],
message: [],
permitJoinChanged: [],
lastSeenChanged: [],
deviceNetworkAddressChanged: [],
};
const backupPath = getTempFile("backup");
const mockAcceptJoiningDeviceHandler = vi.fn((_ieeeAddr: string): Promise<boolean> => Promise.resolve(true));
const options = {
network: {
panID: 0x1a63,
channelList: [15],
},
serialPort: {
baudRate: 115200,
rtscts: true,
path: "/dev/ttyUSB0",
adapter: "zstack" as const,
},
adapter: {
disableLED: false,
},
databasePath: getTempFile("database.db"),
databaseBackupPath: getTempFile("database.db.backup"),
backupPath,
acceptJoiningDeviceHandler: mockAcceptJoiningDeviceHandler,
};
const databaseContents = () => fs.readFileSync(options.databasePath).toString();
describe("Controller", () => {
let controller: Controller;
let mockedDate: Date;
beforeAll(async () => {
mockedDate = new Date();
vi.useFakeTimers();
vi.setSystemTime(mockedDate);
setLogger(mockLogger);
dummyBackup = await Utils.BackupUtils.toUnifiedBackup(mockDummyBackup);
});
afterAll(() => {
vi.useRealTimers();
fs.rmSync(TEMP_PATH, {recursive: true, force: true});
});
beforeEach(async () => {
vi.setSystemTime(mockedDate);
sendZdoResponseStatus = Zdo.Status.SUCCESS;
for (const m of mocksRestore) m.mockRestore();
for (const m of mocksClear) m.mockClear();
MOCK_DEVICES[174]!.attributes![1].checkinInterval = DEFAULT_184_CHECKIN_INTERVAL;
// @ts-expect-error mock
zclTransactionSequenceNumber.number = 1;
iasZoneReadState170Count = 0;
configureReportStatus = 0;
configureReportDefaultRsp = false;
enroll170 = true;
options.network.channelList = [15];
for (const event in events) {
events[event as keyof typeof events] = [];
}
Device.resetCache();
Group.resetCache();
if (fs.existsSync(options.databasePath)) {
fs.unlinkSync(options.databasePath);
}
controller = new Controller(options);
controller.on("permitJoinChanged", (data) => events.permitJoinChanged.push(data));
controller.on("deviceJoined", (data) => events.deviceJoined.push(data));
controller.on("deviceInterview", (data) => events.deviceInterview.push(deepClone(data)));
controller.on("adapterDisconnected", () => events.adapterDisconnected.push(1));
controller.on("deviceAnnounce", (data) => events.deviceAnnounce.push(data));
controller.on("deviceLeave", (data) => events.deviceLeave.push(data));
controller.on("message", (data) => events.message.push(data));
controller.on("lastSeenChanged", (data) => events.lastSeenChanged.push(data));
controller.on("deviceNetworkAddressChanged", (data) => events.deviceNetworkAddressChanged.push(data));
restoreMocksendZclFrameToEndpoint();
});
it("Call controller constructor options mixed with default options", async () => {
await controller.start();
expect(ZStackAdapter).toHaveBeenCalledWith(
{
networkKeyDistribute: false,
networkKey: [1, 3, 5, 7, 9, 11, 13, 15, 0, 2, 4, 6, 8, 10, 12, 13],
panID: 6755,
extendedPanID: [221, 221, 221, 221, 221, 221, 221, 221],
channelList: [15],
},
{baudRate: 115200, path: "/dev/ttyUSB0", rtscts: true, adapter: "zstack"},
backupPath,
{disableLED: false},
);
}, 10000); // randomly times out for some reason
it("Call controller constructor error on invalid channel", async () => {
options.network.channelList = [10];
expect(() => {
new Controller(options);
}).toThrowError("'10' is an invalid channel, use a channel between 11 - 26.");
});
it("Call controller constructor error when network key too small", async () => {
const newOptions = deepClone(options);
newOptions.network.networkKey = [1, 2, 3];
expect(() => {
new Controller(newOptions);
}).toThrowError(`Network key must be a 16 digits long array, got ${newOptions.network.networkKey}.`);
});
it("Call controller constructor error when extendedPanID is too long", async () => {
const newOptions = deepClone(options);
newOptions.network.extendedPanID = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16];
expect(() => {
new Controller(newOptions);
}).toThrowError(`ExtendedPanID must be an 8 digits long array, got ${newOptions.network.extendedPanID}.`);
});
it("Call controller constructor error with invalid panID", async () => {
const newOptions = deepClone(options);
newOptions.network.panID = 0xffff;
expect(() => {
new Controller(newOptions);
}).toThrowError("PanID must have a value of 0x0001 (1) - 0xFFFE (65534), got 65535.");
newOptions.network.panID = 0;
expect(() => {
new Controller(newOptions);
}).toThrowError("PanID must have a value of 0x0001 (1) - 0xFFFE (65534), got 0.");
});
it("Controller stop, should create backup", async () => {
// @ts-expect-error private
const databaseSaveSpy = vi.spyOn(controller, "databaseSave");
await controller.start();
databaseSaveSpy.mockClear();
if (fs.existsSync(options.backupPath)) fs.unlinkSync(options.backupPath);
expect(controller.isStopping()).toBeFalsy();
expect(controller.isAdapterDisconnected()).toBeFalsy();
await controller.stop();
expect(controller.isStopping()).toBeTruthy();
expect(controller.isAdapterDisconnected()).toBeTruthy();
expect(mockAdapterPermitJoin).toHaveBeenCalledWith(0);
expect(JSON.parse(fs.readFileSync(options.backupPath).toString())).toStrictEqual(JSON.parse(JSON.stringify(dummyBackup)));
expect(mockAdapterStop).toHaveBeenCalledTimes(1);
expect(databaseSaveSpy).toHaveBeenCalledTimes(1);
});
it("Syncs runtime lookups", async () => {
await controller.start();
// @ts-expect-error private
Device.devices.clear();
// @ts-expect-error private
Device.deletedDevices.clear();
// @ts-expect-error private
Group.groups.clear();
await mockAdapterEvents.deviceJoined({networkAddress: 129, ieeeAddr: "0x129"});
// @ts-expect-error private
expect(Device.devices.size).toStrictEqual(1);
// @ts-expect-error private
expect(Device.deletedDevices.size).toStrictEqual(0);
expect(Device.byIeeeAddr("0x129", false)).toBeInstanceOf(Device);
expect(Device.byIeeeAddr("0x128", false)).toBeUndefined();
await mockAdapterEvents.deviceJoined({networkAddress: 128, ieeeAddr: "0x128"});
await mockAdapterEvents.deviceLeave({networkAddress: 128, ieeeAddr: "0x128"});
// @ts-expect-error private
expect(Device.devices.size).toStrictEqual(1);
// @ts-expect-error private
expect(Device.deletedDevices.size).toStrictEqual(1);
expect(Device.byIeeeAddr("0x128", false)).toBeUndefined();
expect(Device.byIeeeAddr("0x128", true)).toBeInstanceOf(Device);
await mockAdapterEvents.deviceJoined({networkAddress: 128, ieeeAddr: "0x128"});
// @ts-expect-error private
expect(Device.devices.size).toStrictEqual(2);
// @ts-expect-error private
expect(Device.deletedDevices.size).toStrictEqual(0);
const device2 = Device.byIeeeAddr("0x128", false);
expect(device2).toBeInstanceOf(Device);
expect(() => {
device2!.undelete();
}).toThrow(`Device '0x128' is not deleted`);
controller.createGroup(1);
// @ts-expect-error private
expect(Group.groups.size).toStrictEqual(1);
expect(Group.byGroupID(1)).toBeInstanceOf(Group);
expect(Group.byGroupID(2)).toBeUndefined();
const group2 = controller.createGroup(2);
group2.removeFromNetwork();
// @ts-expect-error private
expect(Group.groups.size).toStrictEqual(1);
expect(Group.byGroupID(1)).toBeInstanceOf(Group);
expect(Group.byGroupID(2)).toBeUndefined();
await controller.stop();
// @ts-expect-error private
expect(Device.devices.size).toStrictEqual(0);
// @ts-expect-error private
expect(Device.deletedDevices.size).toStrictEqual(0);
// @ts-expect-error private
expect(Group.groups.size).toStrictEqual(0);
});
it("Controller start", async () => {
await controller.start();
expect(mockAdapterStart).toHaveBeenCalledTimes(1);
expect(deepClone(controller.getDevicesByType("Coordinator")[0])).toStrictEqual({
ID: 1,
_events: {},
_eventsCount: 0,
_pendingRequestTimeout: 0,
_customClusters: {},
_endpoints: [
{
deviceID: 3,
_events: {},
_eventsCount: 0,
inputClusters: [10],
outputClusters: [11],
pendingRequests: {
id: 1,
deviceIeeeAddress: "0x0000012300000000",
sendInProgress: false,
},
profileID: 2,
ID: 1,
meta: {},
clusters: {},
deviceIeeeAddress: "0x0000012300000000",
deviceNetworkAddress: 0,
_binds: [],
_configuredReportings: [],
},
{
deviceID: 5,
_events: {},
_eventsCount: 0,
inputClusters: [1],
outputClusters: [0],
pendingRequests: {
id: 2,
deviceIeeeAddress: "0x0000012300000000",
sendInProgress: false,
},
profileID: 3,
meta: {},
ID: 2,
clusters: {},
deviceIeeeAddress: "0x0000012300000000",
deviceNetworkAddress: 0,
_binds: [],
_configuredReportings: [],
},
],
_ieeeAddr: "0x0000012300000000",
_interviewState: InterviewState.Successful,
_skipDefaultResponse: false,
_manufacturerID: 0x0007,
_networkAddress: 0,
_type: "Coordinator",
meta: {},
});
expect(JSON.parse(fs.readFileSync(options.backupPath).toString())).toStrictEqual(JSON.parse(JSON.stringify(dummyBackup)));
vi.advanceTimersByTime(86500000);
});
it("Controller update ieeeAddr if changed", async () => {
await controller.start();
expect(controller.getDevicesByType("Coordinator")[0].ieeeAddr).toStrictEqual("0x0000012300000000");
await controller.stop();
mockAdapterGetCoordinatorIEEE.mockReturnValueOnce("0x123444");
await controller.start();
expect(controller.getDevicesByType("Coordinator")[0].ieeeAddr).toStrictEqual("0x123444");
});
it("Touchlink factory reset first", async () => {
await controller.start();
let counter = 0;
mocksendZclFrameInterPANBroadcast.mockImplementation(() => {
counter++;
if (counter === 1) {
throw new Error("no response");
}
if (counter === 2) {
return {address: "0x0000012300000000"};
}
});
const result = await controller.touchlinkFactoryResetFirst();
expect(result).toBeTruthy();
expect(mockSetChannelInterPAN).toHaveBeenCalledTimes(2);
expect(mockSetChannelInterPAN).toHaveBeenCalledWith(11);
expect(mockSetChannelInterPAN).toHaveBeenCalledWith(15);
expect(mocksendZclFrameInterPANBroadcast).toHaveBeenCalledTimes(2);
expect(deepClone(mocksendZclFrameInterPANBroadcast.mock.calls[0][0])).toStrictEqual({
header: {
frameControl: {reservedBits: 0, frameType: 1, direction: 0, disableDefaultResponse: true, manufacturerSpecific: false},
transactionSequenceNumber: 0,
commandIdentifier: 0,
},
payload: {transactionID: expect.any(Number), zigbeeInformation: 4, touchlinkInformation: 18},
cluster: {
ID: 4096,
attributes: {},
name: "touchlink",
commands: expect.any(Object),
commandsResponse: expect.any(Object),
},
command: {
ID: 0,
response: 1,
parameters: [
{name: "transactionID", type: 35},
{name: "zigbeeInformation", type: 24},
{name: "touchlinkInformation", type: 24},
],
name: "scanRequest",
},
});
expect(deepClone(mocksendZclFrameInterPANBroadcast.mock.calls[1][0])).toStrictEqual({
header: {
frameControl: {reservedBits: 0, frameType: 1, direction: 0, disableDefaultResponse: true, manufacturerSpecific: false},
transactionSequenceNumber: 0,
commandIdentifier: 0,
},
payload: {transactionID: expect.any(Number), zigbeeInformation: 4, touchlinkInformation: 18},
cluster: {
ID: 4096,
attributes: {},
name: "touchlink",
commands: expect.any(Object),
commandsResponse: expect.any(Object),
},
command: {
ID: 0,
response: 1,
parameters: [
{name: "transactionID", type: 35},
{name: "zigbeeInformation", type: 24},
{name: "touchlinkInformation", type: 24},
],
name: "scanRequest",
},
});
expect(mockRestoreChannelInterPAN).toHaveBeenCalledTimes(1);
expect(mocksendZclFrameInterPANToIeeeAddr).toHaveBeenCalledTimes(2);
expect(deepClone(mocksendZclFrameInterPANToIeeeAddr.mock.calls[0][0])).toStrictEqual({
header: {
frameControl: {reservedBits: 0, frameType: 1, direction: 0, disableDefaultResponse: true, manufacturerSpecific: false},
transactionSequenceNumber: 0,
commandIdentifier: 6,
},
payload: {transactionID: expect.any(Number), duration: 65535},
cluster: {
ID: 4096,
attributes: {},
name: "touchlink",
commands: expect.any(Object),
commandsResponse: expect.any(Object),
},
command: {
ID: 6,
parameters: [
{name: "transactionID", type: 35},
{name: "duration", type: 33},
],
name: "identifyRequest",
},
});
expect(deepClone(mocksendZclFrameInterPANToIeeeAddr.mock.calls[1][0])).toStrictEqual({
header: {
frameControl: {reservedBits: 0, frameType: 1, direction: 0, disableDefaultResponse: true, manufacturerSpecific: false},
transactionSequenceNumber: 0,
commandIdentifier: 7,
},
payload: {transactionID: expect.any(Number)},
cluster: {
ID: 4096,
attributes: {},
name: "touchlink",
commands: expect.any(Object),
commandsResponse: expect.any(Object),
},
command: {ID: 7, parameters: [{name: "transactionID", type: 35}], name: "resetToFactoryNew"},
});
});
it("Touchlink scan", async () => {
await controller.start();
let counter = 0;
mocksendZclFrameInterPANBroadcast.mockImplementation(() => {
counter++;
if (counter === 1) {
throw new Error("no response");
}
if (counter === 2) {
return {address: "0x0000012300000000"};
}
});
const result = await controller.touchlinkScan();
expect(result).toStrictEqual([{ieeeAddr: "0x0000012300000000", channel: 15}]);
expect(mockSetChannelInterPAN).toHaveBeenCalledTimes(16);
expect(mockSetChannelInterPAN).toHaveBeenCalledWith(11);
expect(mockSetChannelInterPAN).toHaveBeenCalledWith(15);
expect(mocksendZclFrameInterPANBroadcast).toHaveBeenCalledTimes(16);
expect(deepClone(mocksendZclFrameInterPANBroadcast.mock.calls[0][0])).toStrictEqual({
header: {
frameControl: {reservedBits: 0, frameType: 1, direction: 0, disableDefaultResponse: true, manufacturerSpecific: false},
transactionSequenceNumber: 0,
commandIdentifier: 0,
},
payload: {transactionID: expect.any(Number), zigbeeInformation: 4, touchlinkInformation: 18},
cluster: {
ID: 4096,
attributes: {},
name: "touchlink",
commands: expect.any(Object),
commandsResponse: expect.any(Object),
},
command: {
ID: 0,
response: 1,
parameters: [
{name: "transactionID", type: 35},
{name: "zigbeeInformation", type: 24},
{name: "touchlinkInformation", type: 24},
],
name: "scanRequest",
},
});
expect(deepClone(mocksendZclFrameInterPANBroadcast.mock.calls[1][0])).toStrictEqual({
header: {
frameControl: {reservedBits: 0, frameType: 1, direction: 0, disableDefaultResponse: true, manufacturerSpecific: false},
transactionSequenceNumber: 0,
commandIdentifier: 0,
},
payload: {transactionID: expect.any(Number), zigbeeInformation: 4, touchlinkInformation: 18},
cluster: {
ID: 4096,
attributes: {},
name: "touchlink",
commands: expect.any(Object),
commandsResponse: expect.any(Object),
},
command: {
ID: 0,
response: 1,
parameters: [
{name: "transactionID", type: 35},
{name: "zigbeeInformation", type: 24},
{name: "touchlinkInformation", type: 24},
],
name: "scanRequest",
},
});
expect(mockRestoreChannelInterPAN).toHaveBeenCalledTimes(1);
expect(mocksendZclFrameInterPANToIeeeAddr).toHaveBeenCalledTimes(0);
});
it("Touchlink lock", async () => {
await controller.start();
let resolve: (() => void) | undefined;
mockSetChannelInterPAN.mockImplementationOnce(() => {
return new Promise<void>((r) => {
resolve = r;
});
});
const r1 = controller.touchlinkScan();
let error;
try {
await controller.touchlinkScan();
} catch (e) {
error = e;
}
expect(error).toStrictEqual(new Error("Touchlink operation already in progress"));
resolve?.();
await r1;
});
it("Touchlink factory reset", async () => {
await controller.start();
mocksendZclFrameInterPANBroadcast.mockImplementation(() => {
return {address: "0x0000012300000000"};
});
await controller.touchlinkFactoryReset("0x0000012300000000", 15);
expect(mockSetChannelInterPAN).toHaveBeenCalledTimes(1);
expect(mockSetChannelInterPAN).toHaveBeenCalledWith(15);
expect(mocksendZclFrameInterPANBroadcast).toHaveBeenCalledTimes(1);
expect(deepClone(mocksendZclFrameInterPANBroadcast.mock.calls[0][0])).toStrictEqual({
header: {
frameControl: {reservedBits: 0, frameType: 1, direction: 0, disableDefaultResponse: true, manufacturerSpecific: false},
transactionSequenceNumber: 0,
commandIdentifier: 0,
},
payload: {transactionID: expect.any(Number), zigbeeInformation: 4, touchlinkInformation: 18},
cluster: {
ID: 4096,
attributes: {},
name: "touchlink",
commands: expect.any(Object),
commandsResponse: expect.any(Object),
},
command: {
ID: 0,
response: 1,
parameters: [
{name: "transactionID", type: 35},
{name: "zigbeeInformation", type: 24},
{name: "touchlinkInformation", type: 24},
],
name: "scanRequest",
},
});
expect(mockRestoreChannelInterPAN).toHaveBeenCalledTimes(1);
expect(mocksendZclFrameInterPANToIeeeAddr).toHaveBeenCalledTimes(2);
expect(deepClone(mocksendZclFrameInterPANToIeeeAddr.mock.calls[0][0])).toStrictEqual({
header: {
frameControl: {reservedBits: 0, frameType: 1, direction: 0, disableDefaultResponse: true, manufacturerSpecific: false},
transactionSequenceNumber: 0,
commandIdentifier: 6,
},
payload: {transactionID: expect.any(Number), duration: 65535},
cluster: {
ID: 4096,
attributes: {},
name: "touchlink",
commands: expect.any(Object),
commandsResponse: expect.any(Object),
},
command: {
ID: 6,
parameters: [
{name: "transactionID", type: 35},
{name: "duration", type: 33},
],
name: "identifyRequest",
},
});
expect(deepClone(mocksendZclFrameInterPANToIeeeAddr.mock.calls[1][0])).toStrictEqual({
header: {
frameControl: {reservedBits: 0, frameType: 1, direction: 0, disableDefaultResponse: true, manufacturerSpecific: false},
transactionSequenceNumber: 0,
commandIdentifier: 7,
},
payload: {transactionID: expect.any(Number)},
cluster: {
ID: 4096,
attributes: {},
name: "touchlink",
commands: expect.any(Object),
commandsResponse: expect.any(Object),
},
command: {ID: 7, parameters: [{name: "transactionID", type: 35}], name: "resetToFactoryNew"},
});
});
it("Touchlink identify", async () => {
await controller.start();
mocksendZclFrameInterPANBroadcast.mockImplementation(() => {
return {address: "0x0000012300000000"};
});
await controller.touchlinkIdentify("0x0000012300000000", 15);
expect(mockSetChannelInterPAN).toHaveBeenCalledTimes(1);
expect(mockSetChannelInterPAN).toHaveBeenCalledWith(15);
expect(mocksendZclFrameInterPANBroadcast).toHaveBeenCalledTimes(1);
expect(deepClone(mocksendZclFrameInterPANBroadcast.mock.calls[0][0])).toStrictEqual({
header: {
frameControl: {reservedBits: 0, frameType: 1, direction: 0, disableDefaultResponse: true, manufacturerSpecific: false},
transactionSequenceNumber: 0,
commandIdentifier: 0,
},
payload: {transactionID: expect.any(Number), zigbeeInformation: 4, touchlinkInformation: 18},
cluster: {
ID: 4096,
attributes: {},
name: "touchlink",
commands: expect.any(Object),
commandsResponse: expect.any(Object),
},
command: {
ID: 0,
response: 1,
parameters: [
{name: "transactionID", type: 35},
{name: "zigbeeInformation", type: 24},
{name: "touchlinkInformation", type: 24},
],
name: "scanRequest",
},
});
expect(mockRestoreChannelInterPAN).toHaveBeenCalledTimes(1);
expect(mocksendZclFrameInterPANToIeeeAddr).toHaveBeenCalledTimes(1);
expect(deepClone(mocksendZclFrameInterPANToIeeeAddr.mock.calls[0][0])).toStrictEqual({
header: {
frameControl: {reservedBits: 0, frameType: 1, direction: 0, disableDefaultResponse: true, manufacturerSpecific: false},
transactionSequenceNumber: 0,
commandIdentifier: 6,
},
payload: {transactionID: expect.any(Number), duration: 65535},
cluster: {
ID: 4096,
attributes: {},
name: "touchlink",
commands: expect.any(Object),
commandsResponse: expect.any(Object),
},
command: {
ID: 6,
parameters: [
{name: "transactionID", type: 35},
{name: "duration", type: 33},
],
name: "identifyRequest",
},
});
});
it("Controller should ignore touchlink messages", async () => {
const frame = Zcl.Frame.create(
Zcl.FrameType.SPECIFIC,
Zcl.Direction.SERVER_TO_CLIENT,
false,
undefined,
1,
"scanResponse",
Zcl.Utils.getCluster("touchlink", undefined, {}).ID,
{
transactionID: 1,
rssiCorrection: 1,
zigbeeInformation: 1,
touchlinkInformation: 1,
keyBitmask: 1,
responseID: 1,
extendedPanID: "0x001788010de23e6e",
networkUpdateID: 1,
logicalChannel: 1,
panID: 1,
networkAddress: 1,
numberOfSubDevices: 0,
totalGroupIdentifiers: 1,
},
{},
);
await controller.start();
await mockAdapterEvents.deviceJoined({networkAddress: 129, ieeeAddr: "0x129"});
await mockAdapterEvents.zclPayload({
wasBroadcast: false,
networkAddress: 129,
clusterID: frame.cluster.ID,
data: frame.toBuffer(),
header: frame.header,
endpoint: 1,
linkquality: 50,
groupID: 1,
});
expect(events.message.length).toBe(0);
});
it("Device should update properties when reported", async () => {
const frame = Zcl.Frame.create(0, 1, true, undefined, 10, "readRsp", 0, [{attrId: 5, status: 0, dataType: 66, attrData: "new.model.id"}], {});
await controller.start();
await mockAdapterEvents.deviceJoined({networkAddress: 129, ieeeAddr: "0x129"});
expect(Device.byIeeeAddr("0x129")!.modelID).toBe("myModelID");
await mockAdapterEvents.zclPayload({
wasBroadcast: false,
address: 129,
clusterID: frame.cluster.ID,
data: frame.toBuffer(),
header: frame.header,
endpoint: 1,
linkquality: 50,
groupID: 1,
});
expect(Device.byIeeeAddr("0x129")!.modelID).toBe("new.model.id");
});
it("Change channel on start", async () => {
mockAdapterStart.mockReturnValueOnce("resumed");
mockAdapterGetNetworkParameters.mockReturnValueOnce({panID: 1, extendedPanID: "0x64c5fd698daf0c00", channel: 25, nwkUpdateID: 0});
// @ts-expect-error private
const changeChannelSpy = vi.spyOn(controller, "changeChannel");
await controller.start();
expect(mockAdapterGetNetworkParameters).toHaveBeenCalledTimes(1);
const zdoPayload = Zdo.Buffalo.buildRequest(false, Zdo.ClusterId.NWK_UPDATE_REQUEST, [15], 0xfe, undefined, 1, undefined);
expect(mockAdapterSendZdo).toHaveBeenCalledWith(
ZSpec.BLANK_EUI64,
ZSpec.BroadcastAddress.SLEEPY,
Zdo.ClusterId.NWK_UPDATE_REQUEST,
zdoPayload,
true,
);
mockAdapterGetNetworkParameters.mockReturnValueOnce({panID: 1, extendedPanID: "0x64c5fd698daf0c00", channel: 15, nwkUpdateID: 1});
expect(await controller.getNetworkParameters()).toEqual({panID: 1, channel: 15, extendedPanID: "0x64c5fd698daf0c00", nwkUpdateID: 1});
expect(changeChannelSpy).toHaveBeenCalledTimes(1);
});
it("Change channel on start when nwkUpdateID is 0xff", async () => {
mockAdapterStart.mockReturnValueOnce("resumed");
mockAdapterGetNetworkParameters.mockReturnValueOnce({panID: 1, extendedPanID: "0x64c5fd698daf0c00", channel: 25, nwkUpdateID: 0xff});
// @ts-expect-error private
const changeChannelSpy = vi.spyOn(controller, "changeChannel");
await controller.start();
expect(mockAdapterGetNetworkParameters).toHaveBeenCalledTimes(1);
const zdoPayload = Zdo.Buffalo.buildRequest(false, Zdo.ClusterId.NWK_UPDATE_REQUEST, [15], 0xfe, undefined, 0, undefined);
expect(mockAdapterSendZdo).toHaveBeenCalledWith(
ZSpec.BLANK_EUI64,
ZSpec.BroadcastAddress.SLEEPY,
Zdo.ClusterId.NWK_UPDATE_REQUEST,
zdoPayload,
true,
);
expect(await controller.getNetworkParameters()).toEqual({panID: 1, channel: 15, extendedPanID: "0x64c5fd698daf0c00", nwkUpdateID: 0});
expect(changeChannelSpy).toHaveBeenCalledTimes(1);
});
it("Does not change channel on start if not changed", async () => {
mockAdapterStart.mockReturnValueOnce("resumed");
// @ts-expect-error private
const changeChannelSpy = vi.spyOn(controller, "changeChannel");
await controller.start();
expect(mockAdapterGetNetworkParameters).toHaveBeenCalledTimes(1);
expect(changeChannelSpy).toHaveBeenCalledTimes(0);
});
it("Get coordinator version", async () => {
await controller.start();
expect(await controller.getCoordinatorVersion()).toEqual({type: "zStack", meta: {version: 1}});
});
it("Get network parameters", async () => {
await controller.start();
expect(await controller.getNetworkParameters()).toEqual({panID: 1, channel: 15, extendedPanID: "0x64c5fd698daf0c00", nwkUpdateID: 0});
// cached
expect(await controller.getNetworkParameters()).toEqual({panID: 1, channel: 15, extendedPanID: "0x64c5fd698daf0c00", nwkUpdateID: 0});
expect(mockAdapterGetNetworkParameters).toHaveBeenCalledTimes(1);
});
it("Iterates over all devices", async () => {
await controller.start();
await mockAdapterEvents.deviceJoined({networkAddress: 129, ieeeAddr: "0x129"});
let devices = 0;
for (const device of controller.getDevicesIterator()) {
expect(device).toBeInstanceOf(Device);
devices += 1;
}
expect(devices).toStrictEqual(2); // + coordinator
});
it("Iterates over devices with predicate", async () => {
await controller.start();
await mockAdapterEvents.deviceJoined({networkAddress: 129, ieeeAddr: "0x129"});
let devices = 0;
for (const device of controller.getDevicesIterator((d) => d.networkAddress === 129)) {
expect(device).toBeInstanceOf(Device);
devices += 1;
}
expect(devices).toStrictEqual(1);
});
it("Iterates over all groups", async () => {
await controller.start();
controller.createGroup(1);
controller.createGroup(2);
let groups = 0;
for (const group of controller.getGroupsIterator()) {
expect(group).toBeInstanceOf(Group);
groups += 1;
}
expect(groups).toStrictEqual(2);
});
it("Iterates over groups with predicate", async () => {
await controller.start();
controller.createGroup(1);
controller.createGroup(2);
let groups = 0;
for (const group of controller.getGroupsIterator((d) => d.groupID === 1)) {
expect(group).toBeInstanceOf(Group);
groups += 1;
}
expect(groups).toStrictEqual(1);
});
it("Join a device", async () => {
await controller.start();
expect(databaseContents().includes("0x129")).toBeFalsy();
await mockAdapterEvents.deviceJoined({networkAddress: 129, ieeeAddr: "0x129"});
expect(equalsPartial(events.deviceJoined[0].device, {ID: 2, networkAddress: 129, ieeeAddr: "0x129"})).toBeTruthy();
expect(events.deviceInterview[0]).toStrictEqual({
device: {
_events: {},
_eventsCount: 0,
meta: {},
_skipDefaultResponse: false,
_lastSeen: Date.now(),
ID: 2,
_pendingRequestTimeout: 0,
_customClusters: {},
_endpoints: [],
_type: "Unknown",
_ieeeAddr: "0x129",
_interviewState: InterviewState.Pending,
_networkAddress: 129,
},
status: "started",
});
const device = {
ID: 2,
_events: {},
_eventsCount: 0,
_pendingRequestTimeout: 0,
_skipDefaultResponse: false,
_lastSeen: Date.now(),
_type: "Router",
_ieeeAddr: "0x129",
_networkAddress: 129,
meta: {},
_customClusters: {},
_endpoi