UNPKG

zigbee-herdsman

Version:

An open source ZigBee gateway solution with node.js.

1,266 lines (1,155 loc) 451 kB
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