diff --git a/biome.json b/biome.json index 952e171a4b..dcb1d642db 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.2.5/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.4/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/lib/extension/bind.ts b/lib/extension/bind.ts index 69e78a46eb..884c134f05 100755 --- a/lib/extension/bind.ts +++ b/lib/extension/bind.ts @@ -518,9 +518,7 @@ export default class Bind extends Extension { for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) { for (const endpoint of device.zh.endpoints) { - for (const bind of endpoint.binds) { - allBinds.push(bind); - } + allBinds.push(...endpoint.binds); } } diff --git a/lib/extension/bridge.ts b/lib/extension/bridge.ts index 09339bb245..c0b8a1dbb8 100644 --- a/lib/extension/bridge.ts +++ b/lib/extension/bridge.ts @@ -53,6 +53,7 @@ export default class Bridge extends Extension { health_check: this.healthCheck, coordinator_check: this.coordinatorCheck, options: this.bridgeOptions, + action: this.action, }; override async start(): Promise { @@ -597,6 +598,22 @@ export default class Bridge extends Extension { return utils.getResponse(message, {id: message.id, source}); } + @bind async action(message: string | KeyValue): Promise> { + if (typeof message !== "object" || !message.action) { + throw new Error("Invalid payload"); + } + + const action = zhc.ACTIONS[message.action]; + + if (action === undefined) { + throw new Error("Invalid action"); + } + + const response = await action(this.zigbee.zhController, message.params ?? {}); + + return utils.getResponse(message, response); + } + async renameEntity( entityType: T, message: string | KeyValue, @@ -855,6 +872,7 @@ export default class Bridge extends Extension { const data: Zigbee2MQTTAPI["bridge/definitions"] = { clusters: Zcl.Clusters, custom_clusters: {}, + actions: Object.keys(zhc.ACTIONS), }; for (const device of this.zigbee.devicesIterator((d) => !utils.objectIsEmpty(d.customClusters))) { diff --git a/lib/types/api.ts b/lib/types/api.ts index c46c956af7..fe6265c54c 100644 --- a/lib/types/api.ts +++ b/lib/types/api.ts @@ -1,7 +1,8 @@ import type * as zigbeeHerdsman from "zigbee-herdsman/dist"; +import type {ZclPayload} from "zigbee-herdsman/dist/adapter/events"; import type {Eui64} from "zigbee-herdsman/dist/zspec/tstypes"; import type {ClusterDefinition, ClusterName, CustomClusters} from "zigbee-herdsman/dist/zspec/zcl/definition/tstype"; -import type {RoutingTableEntry} from "zigbee-herdsman/dist/zspec/zdo/definition/tstypes"; +import type {GenericZdoResponse, RoutingTableEntry} from "zigbee-herdsman/dist/zspec/zdo/definition/tstypes"; import type * as zigbeeHerdsmanConverter from "zigbee-herdsman-converters"; import type {Base} from "zigbee-herdsman-converters/lib/exposes"; @@ -315,6 +316,7 @@ export interface Zigbee2MQTTAPI { "bridge/definitions": { clusters: Readonly>>; custom_clusters: Record; + actions: string[]; }; "bridge/event": @@ -852,6 +854,10 @@ export interface Zigbee2MQTTAPI { channel: number; }; + "bridge/request/action": {action: string; params?: Record}; + + "bridge/response/action": GenericZdoResponse | ZclPayload | undefined; + /** * entity state response */ @@ -940,6 +946,7 @@ export type Zigbee2MQTTRequestEndpoints = | "bridge/request/touchlink/factory_reset" | "bridge/request/touchlink/scan" | "bridge/request/touchlink/identify" + | "bridge/request/action" | "{friendlyNameOrId}/set" | "{friendlyNameOrId}/set/{attribute}" | "{friendlyNameOrId}/{endpoint}/set" @@ -986,7 +993,8 @@ export type Zigbee2MQTTResponseEndpoints = | "bridge/response/group/members/remove_all" | "bridge/response/touchlink/factory_reset" | "bridge/response/touchlink/scan" - | "bridge/response/touchlink/identify"; + | "bridge/response/touchlink/identify" + | "bridge/response/action"; export type Zigbee2MQTTRequest = { transaction?: string; diff --git a/lib/util/onboarding.ts b/lib/util/onboarding.ts index 3b03794805..7fcbc87901 100644 --- a/lib/util/onboarding.ts +++ b/lib/util/onboarding.ts @@ -488,7 +488,7 @@ async function startOnboardingServer(): Promise { } }); - server.listen(Number.parseInt(serverUrl.port), serverUrl.hostname, () => { + server.listen(Number.parseInt(serverUrl.port, 10), serverUrl.hostname, () => { console.log(`Onboarding page is available at ${serverUrl.href}`); }); }); @@ -515,7 +515,7 @@ async function startFailureServer(errors: string): Promise { } }); - server.listen(Number.parseInt(serverUrl.port), serverUrl.hostname, () => { + server.listen(Number.parseInt(serverUrl.port, 10), serverUrl.hostname, () => { console.error(`Failure page is available at ${serverUrl.href}`); }); }); diff --git a/lib/util/settings.ts b/lib/util/settings.ts index 713ce347b0..be704109a6 100644 --- a/lib/util/settings.ts +++ b/lib/util/settings.ts @@ -607,7 +607,7 @@ export function addGroup(name: string, id?: string): GroupOptions { id = "1"; while (settings.groups[id]) { - id = (Number.parseInt(id) + 1).toString(); + id = (Number.parseInt(id, 10) + 1).toString(); } } else { // ensure provided ID is not in use diff --git a/lib/zigbee.ts b/lib/zigbee.ts index d22014664f..862e80dceb 100644 --- a/lib/zigbee.ts +++ b/lib/zigbee.ts @@ -4,7 +4,6 @@ import stringify from "json-stable-stringify-without-jsonify"; import type {Events as ZHEvents} from "zigbee-herdsman"; import {Controller} from "zigbee-herdsman"; import type {StartResult} from "zigbee-herdsman/dist/adapter/tstype"; - import Device from "./model/device"; import Group from "./model/group"; import data from "./util/data"; @@ -15,7 +14,7 @@ import utils from "./util/utils"; const entityIDRegex = /^(.+?)(?:\/([^/]+))?$/; export default class Zigbee { - private herdsman!: Controller; + #herdsman!: Controller; private eventBus: EventBus; private groupLookup = new Map(); private deviceLookup = new Map(); @@ -25,6 +24,10 @@ export default class Zigbee { this.eventBus = eventBus; } + get zhController() { + return this.#herdsman; + } + async start(): Promise { const infoHerdsman = await utils.getDependencyVersion("zigbee-herdsman"); logger.info(`Starting zigbee-herdsman (${infoHerdsman.version})`); @@ -63,37 +66,37 @@ export default class Zigbee { let startResult: StartResult; try { - this.herdsman = new Controller(herdsmanSettings); - startResult = await this.herdsman.start(); + this.#herdsman = new Controller(herdsmanSettings); + startResult = await this.#herdsman.start(); } catch (error) { logger.error("Error while starting zigbee-herdsman"); throw error; } - this.coordinatorIeeeAddr = this.herdsman.getDevicesByType("Coordinator")[0].ieeeAddr; + this.coordinatorIeeeAddr = this.#herdsman.getDevicesByType("Coordinator")[0].ieeeAddr; await this.resolveDevicesDefinitions(); - this.herdsman.on("adapterDisconnected", () => this.eventBus.emitAdapterDisconnected()); - this.herdsman.on("lastSeenChanged", (data: ZHEvents.LastSeenChangedPayload) => { + this.#herdsman.on("adapterDisconnected", () => this.eventBus.emitAdapterDisconnected()); + this.#herdsman.on("lastSeenChanged", (data: ZHEvents.LastSeenChangedPayload) => { // biome-ignore lint/style/noNonNullAssertion: assumed valid this.eventBus.emitLastSeenChanged({device: this.resolveDevice(data.device.ieeeAddr)!, reason: data.reason}); }); - this.herdsman.on("permitJoinChanged", (data: ZHEvents.PermitJoinChangedPayload) => { + this.#herdsman.on("permitJoinChanged", (data: ZHEvents.PermitJoinChangedPayload) => { this.eventBus.emitPermitJoinChanged(data); }); - this.herdsman.on("deviceNetworkAddressChanged", (data: ZHEvents.DeviceNetworkAddressChangedPayload) => { + this.#herdsman.on("deviceNetworkAddressChanged", (data: ZHEvents.DeviceNetworkAddressChangedPayload) => { // biome-ignore lint/style/noNonNullAssertion: assumed valid const device = this.resolveDevice(data.device.ieeeAddr)!; logger.debug(`Device '${device.name}' changed network address`); this.eventBus.emitDeviceNetworkAddressChanged({device}); }); - this.herdsman.on("deviceAnnounce", (data: ZHEvents.DeviceAnnouncePayload) => { + this.#herdsman.on("deviceAnnounce", (data: ZHEvents.DeviceAnnouncePayload) => { // biome-ignore lint/style/noNonNullAssertion: assumed valid const device = this.resolveDevice(data.device.ieeeAddr)!; logger.debug(`Device '${device.name}' announced itself`); this.eventBus.emitDeviceAnnounce({device}); }); - this.herdsman.on("deviceInterview", async (data: ZHEvents.DeviceInterviewPayload) => { + this.#herdsman.on("deviceInterview", async (data: ZHEvents.DeviceInterviewPayload) => { const device = this.resolveDevice(data.device.ieeeAddr); /* v8 ignore next */ if (!device) return; // Prevent potential race await device.resolveDefinition(); @@ -101,19 +104,19 @@ export default class Zigbee { this.logDeviceInterview(d); this.eventBus.emitDeviceInterview(d); }); - this.herdsman.on("deviceJoined", async (data: ZHEvents.DeviceJoinedPayload) => { + this.#herdsman.on("deviceJoined", async (data: ZHEvents.DeviceJoinedPayload) => { const device = this.resolveDevice(data.device.ieeeAddr); /* v8 ignore next */ if (!device) return; // Prevent potential race await device.resolveDefinition(); logger.info(`Device '${device.name}' joined`); this.eventBus.emitDeviceJoined({device}); }); - this.herdsman.on("deviceLeave", (data: ZHEvents.DeviceLeavePayload) => { + this.#herdsman.on("deviceLeave", (data: ZHEvents.DeviceLeavePayload) => { const name = settings.getDevice(data.ieeeAddr)?.friendly_name || data.ieeeAddr; logger.warning(`Device '${name}' left the network`); this.eventBus.emitDeviceLeave({ieeeAddr: data.ieeeAddr, name, device: this.deviceLookup.get(data.ieeeAddr)}); }); - this.herdsman.on("message", async (data: ZHEvents.MessagePayload) => { + this.#herdsman.on("message", async (data: ZHEvents.MessagePayload) => { // biome-ignore lint/style/noNonNullAssertion: assumed valid const device = this.resolveDevice(data.device.ieeeAddr)!; await device.resolveDefinition(); @@ -129,7 +132,7 @@ export default class Zigbee { logger.info(`zigbee-herdsman started (${startResult})`); logger.info(`Coordinator firmware version: '${stringify(await this.getCoordinatorVersion())}'`); - logger.debug(`Zigbee network parameters: ${stringify(await this.herdsman.getNetworkParameters())}`); + logger.debug(`Zigbee network parameters: ${stringify(await this.#herdsman.getNetworkParameters())}`); for (const device of this.devicesIterator(utils.deviceNotCoordinator)) { // If a passlist is used, all other device will be removed from the network. @@ -198,39 +201,39 @@ export default class Zigbee { } async getCoordinatorVersion(): Promise { - return await this.herdsman.getCoordinatorVersion(); + return await this.#herdsman.getCoordinatorVersion(); } isStopping(): boolean { - return this.herdsman.isStopping(); + return this.#herdsman.isStopping(); } async backup(): Promise { - return await this.herdsman.backup(); + return await this.#herdsman.backup(); } async coordinatorCheck(): Promise<{missingRouters: Device[]}> { - const check = await this.herdsman.coordinatorCheck(); + const check = await this.#herdsman.coordinatorCheck(); // biome-ignore lint/style/noNonNullAssertion: assumed valid return {missingRouters: check.missingRouters.map((d) => this.resolveDevice(d.ieeeAddr)!)}; } async getNetworkParameters(): Promise { - return await this.herdsman.getNetworkParameters(); + return await this.#herdsman.getNetworkParameters(); } async stop(): Promise { logger.info("Stopping zigbee-herdsman..."); - await this.herdsman.stop(); + await this.#herdsman.stop(); logger.info("Stopped zigbee-herdsman"); } getPermitJoin(): boolean { - return this.herdsman.getPermitJoin(); + return this.#herdsman.getPermitJoin(); } getPermitJoinEnd(): number | undefined { - return this.herdsman.getPermitJoinEnd(); + return this.#herdsman.getPermitJoinEnd(); } async permitJoin(time: number, device?: Device): Promise { @@ -240,7 +243,7 @@ export default class Zigbee { logger.info("Zigbee: disabling joining new devices."); } - await this.herdsman.permitJoin(time, device?.zh); + await this.#herdsman.permitJoin(time, device?.zh); } async resolveDevicesDefinitions(ignoreCache = false): Promise { @@ -251,7 +254,7 @@ export default class Zigbee { @bind private resolveDevice(ieeeAddr: string): Device | undefined { if (!this.deviceLookup.has(ieeeAddr)) { - const device = this.herdsman.getDeviceByIeeeAddr(ieeeAddr); + const device = this.#herdsman.getDeviceByIeeeAddr(ieeeAddr); if (device) { this.deviceLookup.set(ieeeAddr, new Device(device)); } @@ -266,7 +269,7 @@ export default class Zigbee { private resolveGroup(groupID: number): Group | undefined { if (!this.groupLookup.has(groupID)) { - const group = this.herdsman.getGroupByID(groupID); + const group = this.#herdsman.getGroupByID(groupID); if (group) { this.groupLookup.set(groupID, new Group(group, this.resolveDevice)); @@ -339,33 +342,33 @@ export default class Zigbee { } firstCoordinatorEndpoint(): zh.Endpoint { - return this.herdsman.getDevicesByType("Coordinator")[0].endpoints[0]; + return this.#herdsman.getDevicesByType("Coordinator")[0].endpoints[0]; } *devicesAndGroupsIterator( devicePredicate?: (value: zh.Device) => boolean, groupPredicate?: (value: zh.Group) => boolean, ): Generator { - for (const device of this.herdsman.getDevicesIterator(devicePredicate)) { + for (const device of this.#herdsman.getDevicesIterator(devicePredicate)) { // biome-ignore lint/style/noNonNullAssertion: assumed valid yield this.resolveDevice(device.ieeeAddr)!; } - for (const group of this.herdsman.getGroupsIterator(groupPredicate)) { + for (const group of this.#herdsman.getGroupsIterator(groupPredicate)) { // biome-ignore lint/style/noNonNullAssertion: assumed valid yield this.resolveGroup(group.groupID)!; } } *groupsIterator(predicate?: (value: zh.Group) => boolean): Generator { - for (const group of this.herdsman.getGroupsIterator(predicate)) { + for (const group of this.#herdsman.getGroupsIterator(predicate)) { // biome-ignore lint/style/noNonNullAssertion: assumed valid yield this.resolveGroup(group.groupID)!; } } *devicesIterator(predicate?: (value: zh.Device) => boolean): Generator { - for (const device of this.herdsman.getDevicesIterator(predicate)) { + for (const device of this.#herdsman.getDevicesIterator(predicate)) { // biome-ignore lint/style/noNonNullAssertion: assumed valid yield this.resolveDevice(device.ieeeAddr)!; } @@ -399,33 +402,33 @@ export default class Zigbee { } async touchlinkFactoryResetFirst(): Promise { - return await this.herdsman.touchlink.factoryResetFirst(); + return await this.#herdsman.touchlink.factoryResetFirst(); } async touchlinkFactoryReset(ieeeAddr: string, channel: number): Promise { - return await this.herdsman.touchlink.factoryReset(ieeeAddr, channel); + return await this.#herdsman.touchlink.factoryReset(ieeeAddr, channel); } async addInstallCode(installCode: string): Promise { - await this.herdsman.addInstallCode(installCode); + await this.#herdsman.addInstallCode(installCode); } async touchlinkIdentify(ieeeAddr: string, channel: number): Promise { - await this.herdsman.touchlink.identify(ieeeAddr, channel); + await this.#herdsman.touchlink.identify(ieeeAddr, channel); } async touchlinkScan(): Promise<{ieeeAddr: string; channel: number}[]> { - return await this.herdsman.touchlink.scan(); + return await this.#herdsman.touchlink.scan(); } createGroup(id: number): Group { - this.herdsman.createGroup(id); + this.#herdsman.createGroup(id); // biome-ignore lint/style/noNonNullAssertion: just created return this.resolveGroup(id)!; } deviceByNetworkAddress(networkAddress: number): Device | undefined { - const device = this.herdsman.getDeviceByNetworkAddress(networkAddress); + const device = this.#herdsman.getDeviceByNetworkAddress(networkAddress); return device && this.resolveDevice(device.ieeeAddr); } diff --git a/test/controller.bench.ts b/test/controller.bench.ts index 31d72fee15..1f79b3ebd6 100644 --- a/test/controller.bench.ts +++ b/test/controller.bench.ts @@ -1,7 +1,7 @@ import {existsSync, mkdirSync} from "node:fs"; import stringify from "json-stable-stringify-without-jsonify"; -import {bench, describe} from "vitest"; -import {Zcl, Zdo, ZSpec} from "zigbee-herdsman"; +import {bench, describe, vi} from "vitest"; +import {type Controller, Zcl, Zdo, ZSpec} from "zigbee-herdsman"; import type Adapter from "zigbee-herdsman/dist/adapter/adapter"; import type {ZclPayload} from "zigbee-herdsman/dist/adapter/events"; import {Device, InterviewState} from "zigbee-herdsman/dist/controller/model/device"; @@ -14,6 +14,48 @@ import type {RequestToResponseMap} from "zigbee-herdsman/dist/zspec/zdo/definiti import data from "../lib/util/data"; import {BENCH_OPTIONS} from "./benchOptions"; +vi.doMock("zigbee-herdsman", async (importOriginal) => { + const actual = await importOriginal(); + class MockHerdsman { + on: Controller["on"] = vi.fn(); + start: Controller["start"] = async () => "resumed" as const; + stop: Controller["stop"] = async () => {}; + isStopping: Controller["isStopping"] = () => false; + getCoordinatorVersion: Controller["getCoordinatorVersion"] = async () => + Promise.resolve({ + type: "Dummy", + meta: {revision: "9.9.9"}, + }); + getNetworkParameters: Controller["getNetworkParameters"] = async () => Promise.resolve({...NETWORK_PARAMS}); + getPermitJoin: Controller["getPermitJoin"] = () => false; + getPermitJoinEnd: Controller["getPermitJoinEnd"] = () => undefined; + getDeviceByIeeeAddr: Controller["getDeviceByIeeeAddr"] = (ieeeAddr) => ZH_DEVICES.find((device) => device.ieeeAddr === ieeeAddr); + getGroupByID: Controller["getGroupByID"] = (id) => ZH_GROUPS.find((group) => group.groupID === id); + getDevicesByType: Controller["getDevicesByType"] = (type) => ZH_DEVICES.filter((device) => device.type === type); + getDeviceByNetworkAddress: Controller["getDeviceByNetworkAddress"] = (networkAddress) => + ZH_DEVICES.find((device) => device.networkAddress === networkAddress); + *getDevicesIterator(predicate: ((device: Device) => boolean) | undefined) { + for (const device of ZH_DEVICES) { + if (!predicate || predicate(device)) { + yield device; + } + } + } + *getGroupsIterator(predicate: ((group: Group) => boolean) | undefined) { + for (const group of ZH_GROUPS) { + if (!predicate || predicate(group)) { + yield group; + } + } + } + } + + return { + ...actual, + Controller: MockHerdsman, + }; +}); + process.env.ZIGBEE2MQTT_DATA = "data-bench"; data._testReload(); @@ -365,45 +407,6 @@ const initController = async () => { async () => {}, ); - controller.zigbee.start = async () => { - await controller.zigbee.resolveDevicesDefinitions(); - - return "resumed"; - }; - controller.zigbee.stop = async () => { - await Promise.resolve(); - }; - // @ts-expect-error mocking private - controller.zigbee.herdsman = { - isStopping: () => false, - getCoordinatorVersion: async () => - Promise.resolve({ - type: "Dummy", - meta: {revision: "9.9.9"}, - }), - getNetworkParameters: async () => Promise.resolve({...NETWORK_PARAMS}), - getPermitJoin: () => false, - getPermitJoinEnd: () => undefined, - getDeviceByIeeeAddr: (ieeeAddr) => ZH_DEVICES.find((device) => device.ieeeAddr === ieeeAddr), - getGroupByID: (id) => ZH_GROUPS.find((group) => group.groupID === id), - getDevicesByType: (type) => ZH_DEVICES.filter((device) => device.type === type), - getDeviceByNetworkAddress: (networkAddress) => ZH_DEVICES.find((device) => device.networkAddress === networkAddress), - *getDevicesIterator(predicate) { - for (const device of ZH_DEVICES) { - if (!predicate || predicate(device)) { - yield device; - } - } - }, - *getGroupsIterator(predicate) { - for (const group of ZH_GROUPS) { - if (!predicate || predicate(group)) { - yield group; - } - } - }, - }; - // all dummies, can trigger `controller.mqtt.onMessage(topic, message)` as needed // @ts-expect-error mocking private controller.mqtt.client = { diff --git a/test/extensions/bridge.test.ts b/test/extensions/bridge.test.ts index b50c8859cd..b959fb83d3 100644 --- a/test/extensions/bridge.test.ts +++ b/test/extensions/bridge.test.ts @@ -1,5 +1,6 @@ // biome-ignore assist/source/organizeImports: import mocks first import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from "vitest"; +import {Zdo} from "zigbee-herdsman"; import * as data from "../mocks/data"; import {mockJSZipFile, mockJSZipGenerateAsync} from "../mocks/jszip"; import {mockLogger} from "../mocks/logger"; @@ -4348,4 +4349,97 @@ describe("Extension: Bridge", () => { }); expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/response/device/configure", expect.any(String), {}); }); + + it("triggers ZHC action by name with params", async () => { + mockMQTTPublishAsync.mockClear(); + + mockMQTTEvents.message( + "zigbee2mqtt/bridge/request/action", + JSON.stringify({ + action: "raw", + params: { + profileId: Zdo.ZDO_PROFILE_ID, + ieeeAddress: "0xf1f2f3f4f5f6f7f8", + networkAddress: 0x1234, + clusterKey: Zdo.ClusterId.NETWORK_ADDRESS_REQUEST, + zdoParams: ["0xa1a2a3a4a5a6a7a8", false, 0], + }, + }), + ); + await flushPromises(); + + expect(mockMQTTPublishAsync).toHaveBeenCalledWith( + "zigbee2mqtt/bridge/response/action", + JSON.stringify({data: [0x00, {assocDevList: [], eui64: "", nwkAddress: 0x1234, startIndex: 0}], status: "ok"}), + {}, + ); + }); + + it("triggers ZHC action by name without params", async () => { + mockMQTTPublishAsync.mockClear(); + + mockMQTTEvents.message( + "zigbee2mqtt/bridge/request/action", + JSON.stringify({ + action: "raw", + }), + ); + await flushPromises(); + + // we're mocking the response, so it's always this as long as the action & payload are valid + expect(mockMQTTPublishAsync).toHaveBeenCalledWith( + "zigbee2mqtt/bridge/response/action", + JSON.stringify({data: [0x00, {assocDevList: [], eui64: "", nwkAddress: 0x1234, startIndex: 0}], status: "ok"}), + {}, + ); + }); + + it("throws on invalid action payload", async () => { + mockMQTTPublishAsync.mockClear(); + + mockMQTTEvents.message( + "zigbee2mqtt/bridge/request/action", + JSON.stringify({ + params: { + profileId: Zdo.ZDO_PROFILE_ID, + ieeeAddress: "0xf1f2f3f4f5f6f7f8", + networkAddress: 0x1234, + clusterKey: Zdo.ClusterId.NETWORK_ADDRESS_REQUEST, + zdoParams: ["0xa1a2a3a4a5a6a7a8", false, 0], + }, + }), + ); + await flushPromises(); + + expect(mockMQTTPublishAsync).toHaveBeenCalledWith( + "zigbee2mqtt/bridge/response/action", + stringify({data: {}, status: "error", error: "Invalid payload"}), + {}, + ); + }); + + it("throws on invalid action", async () => { + mockMQTTPublishAsync.mockClear(); + + mockMQTTEvents.message( + "zigbee2mqtt/bridge/request/action", + JSON.stringify({ + action: "DOES_NOT_EXIST", + params: { + profileId: Zdo.ZDO_PROFILE_ID, + ieeeAddress: "0xf1f2f3f4f5f6f7f8", + networkAddress: 0x1234, + clusterKey: Zdo.ClusterId.NETWORK_ADDRESS_REQUEST, + zdoParams: ["0xa1a2a3a4a5a6a7a8", false, 0], + }, + }), + ); + await flushPromises(); + + expect(mockMQTTPublishAsync).toHaveBeenCalledWith( + "zigbee2mqtt/bridge/response/action", + stringify({data: {}, status: "error", error: "Invalid action"}), + {}, + ); + }); }); diff --git a/test/mocks/zigbeeHerdsman.ts b/test/mocks/zigbeeHerdsman.ts index 5eb882e8fc..6b5ac3f312 100644 --- a/test/mocks/zigbeeHerdsman.ts +++ b/test/mocks/zigbeeHerdsman.ts @@ -1145,12 +1145,7 @@ export const mockController = { }, start: vi.fn((): Promise => Promise.resolve("reset")), stop: vi.fn(), - touchlink: { - identify: vi.fn(), - scan: vi.fn(), - factoryReset: vi.fn(), - factoryResetFirst: vi.fn(), - }, + touchlink: {identify: vi.fn(), scan: vi.fn(), factoryReset: vi.fn(), factoryResetFirst: vi.fn()}, addInstallCode: vi.fn(), permitJoin: vi.fn(), getPermitJoin: vi.fn((): boolean => false), @@ -1206,6 +1201,9 @@ export const mockController = { groups[`group_${groupID}` as keyof typeof groups] = group; return group; }), + sendRaw: vi.fn(async (/*rawPayload: RawPayload, customClusters: CustomClusters = {}*/) => + Promise.resolve([0x00, {nwkAddress: 0x1234, eui64: "", startIndex: 0, assocDevList: []}]), + ), }; vi.mock("zigbee-herdsman", async (importOriginal) => ({ diff --git a/test/onboarding.test.ts b/test/onboarding.test.ts index 4923c2e582..3c366cadd8 100644 --- a/test/onboarding.test.ts +++ b/test/onboarding.test.ts @@ -293,7 +293,7 @@ describe("Onboarding", () => { } const serverUrl = new URL(process.env.Z2M_ONBOARD_URL ?? "http://0.0.0.0:8080"); - expect(mockHttpListen).toHaveBeenCalledWith(Number.parseInt(serverUrl.port), serverUrl.hostname, expect.any(Function)); + expect(mockHttpListen).toHaveBeenCalledWith(Number.parseInt(serverUrl.port, 10), serverUrl.hostname, expect.any(Function)); return [resEnd.mock.calls[0][0], resEnd.mock.calls[1][0]]; }; @@ -351,7 +351,7 @@ describe("Onboarding", () => { expect(resEnd).toHaveBeenCalledTimes(2); const serverUrl = new URL(process.env.Z2M_ONBOARD_URL ?? "http://0.0.0.0:8080"); - expect(mockHttpListen).toHaveBeenCalledWith(Number.parseInt(serverUrl.port), serverUrl.hostname, expect.any(Function)); + expect(mockHttpListen).toHaveBeenCalledWith(Number.parseInt(serverUrl.port, 10), serverUrl.hostname, expect.any(Function)); return resEnd.mock.calls[0][0]; };