mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-08-06 19:00:13 +00:00
feat: Add action bridge/request API (#29730)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
This commit is contained in:
+1
-1
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
@@ -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<Zigbee2MQTTResponse<"bridge/response/action">> {
|
||||
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<T extends "device" | "group">(
|
||||
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))) {
|
||||
|
||||
+10
-2
@@ -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<Record<ClusterName, Readonly<ClusterDefinition>>>;
|
||||
custom_clusters: Record<string, CustomClusters>;
|
||||
actions: string[];
|
||||
};
|
||||
|
||||
"bridge/event":
|
||||
@@ -852,6 +854,10 @@ export interface Zigbee2MQTTAPI {
|
||||
channel: number;
|
||||
};
|
||||
|
||||
"bridge/request/action": {action: string; params?: Record<string, unknown>};
|
||||
|
||||
"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<T extends Zigbee2MQTTRequestEndpoints> = {
|
||||
transaction?: string;
|
||||
|
||||
@@ -488,7 +488,7 @@ async function startOnboardingServer(): Promise<boolean> {
|
||||
}
|
||||
});
|
||||
|
||||
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<void> {
|
||||
}
|
||||
});
|
||||
|
||||
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}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
+41
-38
@@ -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<number /* group ID */, Group>();
|
||||
private deviceLookup = new Map<string /* IEEE address */, Device>();
|
||||
@@ -25,6 +24,10 @@ export default class Zigbee {
|
||||
this.eventBus = eventBus;
|
||||
}
|
||||
|
||||
get zhController() {
|
||||
return this.#herdsman;
|
||||
}
|
||||
|
||||
async start(): Promise<StartResult> {
|
||||
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<zh.CoordinatorVersion> {
|
||||
return await this.herdsman.getCoordinatorVersion();
|
||||
return await this.#herdsman.getCoordinatorVersion();
|
||||
}
|
||||
|
||||
isStopping(): boolean {
|
||||
return this.herdsman.isStopping();
|
||||
return this.#herdsman.isStopping();
|
||||
}
|
||||
|
||||
async backup(): Promise<void> {
|
||||
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<zh.NetworkParameters> {
|
||||
return await this.herdsman.getNetworkParameters();
|
||||
return await this.#herdsman.getNetworkParameters();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
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<void> {
|
||||
@@ -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<void> {
|
||||
@@ -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<Device | Group> {
|
||||
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<Group> {
|
||||
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<Device> {
|
||||
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<boolean> {
|
||||
return await this.herdsman.touchlink.factoryResetFirst();
|
||||
return await this.#herdsman.touchlink.factoryResetFirst();
|
||||
}
|
||||
|
||||
async touchlinkFactoryReset(ieeeAddr: string, channel: number): Promise<boolean> {
|
||||
return await this.herdsman.touchlink.factoryReset(ieeeAddr, channel);
|
||||
return await this.#herdsman.touchlink.factoryReset(ieeeAddr, channel);
|
||||
}
|
||||
|
||||
async addInstallCode(installCode: string): Promise<void> {
|
||||
await this.herdsman.addInstallCode(installCode);
|
||||
await this.#herdsman.addInstallCode(installCode);
|
||||
}
|
||||
|
||||
async touchlinkIdentify(ieeeAddr: string, channel: number): Promise<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
+44
-41
@@ -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<typeof import("zigbee-herdsman")>();
|
||||
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 = {
|
||||
|
||||
@@ -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"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1145,12 +1145,7 @@ export const mockController = {
|
||||
},
|
||||
start: vi.fn((): Promise<AdapterTypes.StartResult> => 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) => ({
|
||||
|
||||
@@ -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];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user