Files
zigbee2mqtt/test/controller.bench.ts
2025-11-24 21:57:48 +01:00

663 lines
23 KiB
TypeScript

import {existsSync, mkdirSync} from "node:fs";
import stringify from "json-stable-stringify-without-jsonify";
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";
import {Endpoint} from "zigbee-herdsman/dist/controller/model/endpoint";
import Entity from "zigbee-herdsman/dist/controller/model/entity";
import {Group} from "zigbee-herdsman/dist/controller/model/group";
import type {DeviceType} from "zigbee-herdsman/dist/controller/tstype";
import {Foundation} from "zigbee-herdsman/dist/zspec/zcl/definition/foundation";
import type {RequestToResponseMap} from "zigbee-herdsman/dist/zspec/zdo/definition/tstypes";
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();
if (!existsSync(data.getPath())) {
mkdirSync(data.getPath(), {recursive: true});
}
const createEndpoint = (id: number, ieeeAddr: string, networkAddress: number) => {
const ep = Endpoint.create(
id,
id === ZSpec.GP_ENDPOINT ? ZSpec.GP_PROFILE_ID : ZSpec.HA_PROFILE_ID,
id === ZSpec.GP_ENDPOINT ? 0x66 : 0x65,
id === ZSpec.GP_ENDPOINT ? [Zcl.Clusters.greenPower.ID] : [Zcl.Clusters.genBasic.ID, Zcl.Clusters.genOnOff.ID],
id === ZSpec.GP_ENDPOINT ? [Zcl.Clusters.greenPower.ID] : [Zcl.Clusters.genBasic.ID],
networkAddress,
ieeeAddr,
);
ep.save = () => {};
return ep;
};
// `Device.create` requires `Database`, bypass it by using the private constructor directly
const createDevice = (
dbId: number,
type: DeviceType,
ieeeAddr: string,
networkAddress: number,
manufacturerID: number | undefined,
manufacturerName: string | undefined,
powerSource: string | undefined,
modelID: string | undefined,
): Device => {
const haEp = createEndpoint(ZSpec.HA_ENDPOINT, ieeeAddr, networkAddress);
const ep2 = createEndpoint(2, ieeeAddr, networkAddress);
const endpoints = [haEp, ep2];
if (type === "Coordinator" || type === "Router") {
const gpEp = createEndpoint(ZSpec.GP_ENDPOINT, ieeeAddr, networkAddress);
endpoints.push(gpEp);
}
// @ts-expect-error mocking private
const device = new Device(
dbId,
type,
ieeeAddr,
networkAddress,
manufacturerID,
endpoints,
manufacturerName,
powerSource,
modelID,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
InterviewState.Successful,
{},
undefined,
undefined,
0,
undefined,
);
// in-memory only
device.save = () => {};
return device;
};
// `Group.create` requires `Database`, bypass it by using the private constructor directly
const createGroup = (dbId: number, id: number): Group => {
// @ts-expect-error mocking private
const group = new Group(dbId, id, [], {});
// in-memory only
group.save = () => {};
return group;
};
const COORD_IEEE = "0x0101010101010101";
const EXT_PAN_ID = [0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd];
const NETWORK_PARAMS = {
panID: 0x1a62,
extendedPanID: `0x${Buffer.from(EXT_PAN_ID).toString("hex")}`,
channel: 11,
nwkUpdateID: 1,
};
const NETWORK_KEY = [1, 3, 5, 7, 9, 11, 13, 15, 0, 2, 4, 6, 8, 10, 12, 13];
const ZH_DEVICES: Device[] = [];
const ZH_GROUPS: Group[] = [];
const MANY_DEVICES = 100;
const initDevices = () => {
ZH_DEVICES.splice(0, ZH_DEVICES.length);
ZH_DEVICES.push(
createDevice(
0,
"Coordinator",
COORD_IEEE,
ZSpec.COORDINATOR_ADDRESS,
Zcl.ManufacturerCode.SILICON_LABORATORIES,
undefined,
undefined,
undefined,
),
);
ZH_DEVICES.push(
createDevice(1, "Router", "0xf1f1f1f1f1f1f1f1", 0x0001, Zcl.ManufacturerCode.INNR_LIGHTING_BV, "Innr", "Mains (single phase)", "AE 262"),
);
ZH_DEVICES.push(
createDevice(2, "EndDevice", "0xe2e2e2e2e2e2e2e2", 0x0002, Zcl.ManufacturerCode.TUYA_GLOBAL_INC, "_TYZB01_kvwjujy9", "Battery", "TS0222"),
);
ZH_DEVICES.push(createDevice(3, "GreenPower", "0x00000000015d3d3d", 0x0003, undefined, undefined, undefined, "GreenPower_7"));
// these have configure, without setTimeout, they hammer really badly (# of fn calls), so, only one of each
ZH_DEVICES.push(
createDevice(
4,
"Router",
"0xd3d3d3d3d3d3d3d3",
0x0004,
Zcl.ManufacturerCode.LEDVANCE_GMBH,
"LEDVANCE",
"Mains (single phase)",
"PLUG OUTDOOR EU T",
),
);
ZH_DEVICES.push(
createDevice(5, "Router", "0xc4c4c4c4c4c4c4c4", 0x0005, Zcl.ManufacturerCode.INOVELLI, "Inovelli", "Mains (single phase)", "VZM35-SN"),
);
ZH_DEVICES.push(
createDevice(
6,
"Router",
"0xb5b5b5b5b5b5b5b5",
0x0006,
Zcl.ManufacturerCode.SILICON_LABORATORIES,
"SMLIGHT",
"Mains (single phase)",
"SLZB-06Mg24",
),
);
ZH_DEVICES.push(
createDevice(
7,
"Router",
"0xa6a6a6a6a6a6a6a6",
0x0007,
Zcl.ManufacturerCode.TUYA_GLOBAL_INC,
"_TZE200_p0gzbqct",
"Mains (single phase)",
"TS0601",
),
);
};
const initGroups = () => {
ZH_GROUPS.splice(0, ZH_GROUPS.length);
ZH_GROUPS.push(createGroup(0, 1));
};
const addManyDevices = () => {
for (let i = 0; i < MANY_DEVICES; i++) {
const ieee = `0xf1f1f1f1f1f1f1${i.toString(16).padStart(2, "0")}`;
// device without `configure` (too many calls otherwise)
ZH_DEVICES.push(createDevice(1, "Router", ieee, 0x0008 + i, Zcl.ManufacturerCode.INNR_LIGHTING_BV, "Innr", "Mains (single phase)", "AE 262"));
}
};
const getMidDeviceIeee = () =>
`0xf1f1f1f1f1f1f1${Math.floor(MANY_DEVICES / 2)
.toString(16)
.padStart(2, "0")}`;
Device.byIeeeAddr = (ieeeAddr, _includeDeleted) => ZH_DEVICES.find((device) => device.ieeeAddr === ieeeAddr);
Device.byType = (type) => ZH_DEVICES.filter((device) => device.type === type);
const adapter = {
sendZclFrameToEndpoint: async (
_ieeeAddr: string,
networkAddress: number,
endpoint: number,
zclFrame: Zcl.Frame,
_timeout: number,
disableResponse: boolean,
_disableRecovery: boolean,
sourceEndpoint?: number,
): Promise<ZclPayload | undefined> => {
const payload: {[key: string]: unknown}[] = [];
if (!disableResponse) {
if (zclFrame.header.isGlobal) {
switch (zclFrame.command.ID) {
case Foundation.read.ID: {
for (const attr of zclFrame.payload) {
const attribute = zclFrame.cluster.getAttribute(attr.attrId);
if (attribute && attribute.type !== Zcl.DataType.NO_DATA && attribute.type < Zcl.DataType.OCTET_STR) {
payload.push({
attrId: attr.attrId,
dataType: attribute.type,
attrData: 1,
status: 0,
});
}
}
const messageContents = Zcl.Frame.create(
0,
Zcl.Direction.SERVER_TO_CLIENT,
true,
undefined,
10,
Foundation.readRsp.ID,
zclFrame.cluster.ID,
payload,
{},
).toBuffer();
return await Promise.resolve({
clusterID: zclFrame.cluster.ID,
header: Zcl.Header.fromBuffer(messageContents),
address: networkAddress,
data: messageContents,
endpoint: sourceEndpoint ?? 1,
linkquality: 200,
groupID: 0,
wasBroadcast: false,
destinationEndpoint: endpoint,
});
}
case Foundation.configReport.ID: {
for (const item of zclFrame.payload) {
payload.push({attrId: item.attrId, status: 0, direction: 1});
}
const messageContents = Zcl.Frame.create(
0,
Zcl.Direction.SERVER_TO_CLIENT,
true,
undefined,
10,
Foundation.configReportRsp.ID,
zclFrame.cluster.ID,
payload,
{},
).toBuffer();
return await Promise.resolve({
clusterID: zclFrame.cluster.ID,
header: Zcl.Header.fromBuffer(messageContents),
address: networkAddress,
data: messageContents,
endpoint: sourceEndpoint ?? 1,
linkquality: 200,
groupID: 0,
wasBroadcast: false,
destinationEndpoint: endpoint,
});
}
}
}
}
return await Promise.resolve(undefined);
},
sendZclFrameToGroup: async (_groupID: number, _zclFrame: Zcl.Frame, _sourceEndpoint?: number): Promise<void> => await Promise.resolve(),
sendZdo: async (
_ieeeAddress: string,
_networkAddress: number,
clusterId: Zdo.ClusterId,
_payload: Buffer,
_disableResponse: boolean,
): Promise<RequestToResponseMap[keyof RequestToResponseMap] | undefined> => {
switch (clusterId) {
case Zdo.ClusterId.BIND_REQUEST:
case Zdo.ClusterId.UNBIND_REQUEST: {
return await Promise.resolve([Zdo.Status.SUCCESS, undefined]);
}
}
return await Promise.resolve(undefined);
},
};
Entity.injectAdapter(adapter as Adapter);
// use plain type to avoid early import that otherwise messes with data path
let controller: import("../lib/controller.js").Controller;
const origSetImmediate = global.setImmediate;
const origSetTimeout = global.setTimeout;
const mockGlobalThis = () => {
const setImmediateProms: (void | Promise<void>)[] = [];
const setTimeoutProms: (void | Promise<void>)[] = [];
// @ts-expect-error mock
globalThis.setImmediate = (callback: () => void) => {
setImmediateProms.push(callback());
};
// @ts-expect-error mock
globalThis.setTimeout = (callback: () => void) => {
setTimeoutProms.push(callback());
};
return {setImmediateProms, setTimeoutProms};
};
const unmockGlobalThis = () => {
globalThis.setImmediate = origSetImmediate;
globalThis.setTimeout = origSetTimeout;
};
const settle = async (mockedGlobal: ReturnType<typeof mockGlobalThis>) => {
await Promise.allSettled(mockedGlobal.setImmediateProms);
await Promise.allSettled(mockedGlobal.setTimeoutProms);
await new Promise((resolve) => origSetImmediate(resolve));
};
const initSettings = async (pathValuePairs?: [string[], string | number | boolean][]) => {
const settings = await import("../lib/util/settings.js");
settings.writeMinimalDefaults();
// disable logging, too much influence on perf
settings.set(["advanced", "log_level"], "error");
settings.set(["advanced", "log_output"], []);
settings.set(["advanced", "pan_id"], NETWORK_PARAMS.panID);
settings.set(["advanced", "ext_pan_id"], EXT_PAN_ID);
settings.set(["advanced", "network_key"], NETWORK_KEY);
if (pathValuePairs) {
for (const [path, value] of pathValuePairs) {
settings.set(path, value);
}
}
};
const initController = async () => {
const {Controller} = await import("../lib/controller.js");
controller = new Controller(
async () => {},
async () => {},
);
// all dummies, can trigger `controller.mqtt.onMessage(topic, message)` as needed
// @ts-expect-error mocking private
controller.mqtt.client = {
options: {
protocolVersion: 5,
protocol: "mqtt",
host: "localhost",
port: 1883,
},
queue: [],
reconnecting: false,
disconnecting: false,
disconnected: false,
endAsync: async () => {},
// @ts-expect-error Z2M does not make use of return
publishAsync: async () => {},
};
controller.mqtt.connect = async () => {
// @ts-expect-error private
await controller.mqtt.onConnect();
};
controller.mqtt.subscribe = async () => {};
controller.mqtt.unsubscribe = async () => {};
// will be in-memory only
controller.state.start = () => {};
controller.state.stop = () => {};
};
describe("Controller with dummy zigbee/mqtt", () => {
bench(
"[defaults] start & stop controller",
async () => {
initDevices();
initGroups();
await initSettings();
await initController();
const mockedGlobal = mockGlobalThis();
await controller.start();
await settle(mockedGlobal);
if ((await controller.zigbee.getCoordinatorVersion()).type !== "Dummy") {
throw new Error("Invalid");
}
await controller.stop();
unmockGlobalThis();
},
BENCH_OPTIONS,
);
bench(
"[HA] start & stop controller",
async () => {
initDevices();
initGroups();
await initSettings([[["homeassistant", "enabled"], true]]);
await initController();
const mockedGlobal = mockGlobalThis();
await controller.start();
controller.mqtt.onMessage("homeassistant/status", Buffer.from("online", "utf8"));
await settle(mockedGlobal);
if ((await controller.zigbee.getCoordinatorVersion()).type !== "Dummy") {
throw new Error("Invalid");
}
await controller.stop();
unmockGlobalThis();
},
BENCH_OPTIONS,
);
describe("defaults runtime", () => {
const setup: NonNullable<Parameters<typeof bench>[2]>["setup"] = async (task, mode) => {
BENCH_OPTIONS.setup!(task, mode);
initDevices();
initGroups();
await initSettings();
await initController();
const mockedGlobal = mockGlobalThis();
await controller.start();
await settle(mockedGlobal);
};
const teardown = async () => {
await controller.stop();
unmockGlobalThis();
};
bench(
"[defaults] receive device message",
async () => {
const mockedGlobal = mockGlobalThis();
controller.eventBus.emitDeviceMessage({
type: "attributeReport",
device: controller.zigbee.resolveEntity("0xf1f1f1f1f1f1f1f1"),
endpoint: ZSpec.HA_ENDPOINT,
linkquality: 200,
groupID: 0,
cluster: "genOnOff",
data: {onOff: 1},
meta: {},
});
await settle(mockedGlobal);
},
{...BENCH_OPTIONS, setup, teardown},
);
bench(
"[defaults] receive MQTT message",
async () => {
const mockedGlobal = mockGlobalThis();
controller.mqtt.onMessage("zigbee2mqtt/0xf1f1f1f1f1f1f1f1/set", Buffer.from(`{"state": "OFF"}`, "utf8"));
await settle(mockedGlobal);
},
{...BENCH_OPTIONS, setup, teardown},
);
bench(
"[defaults] add group member",
async () => {
const mockedGlobal = mockGlobalThis();
controller.eventBus.emitMQTTMessage({
topic: "zigbee2mqtt/bridge/request/group/members/add",
message: stringify({
device: "0xf1f1f1f1f1f1f1f1",
group: `${ZH_GROUPS[0].groupID}`,
endpoint: ZSpec.HA_ENDPOINT,
}),
});
await settle(mockedGlobal);
if (ZH_GROUPS[0].members.length !== 1) {
throw new Error("Invalid state");
}
},
{...BENCH_OPTIONS, setup, teardown},
);
});
describe("defaults/stress runtime", () => {
const setup: NonNullable<Parameters<typeof bench>[2]>["setup"] = async (task, mode) => {
BENCH_OPTIONS.setup!(task, mode);
initDevices();
initGroups();
addManyDevices();
await initSettings();
await initController();
const mockedGlobal = mockGlobalThis();
await controller.start();
await settle(mockedGlobal);
};
const teardown = async () => {
await controller.stop();
unmockGlobalThis();
};
// this is mostly just to confirm the number of devices does not influence the processing (much)
bench(
"[defaults/stress] receive device message",
async () => {
const mockedGlobal = mockGlobalThis();
controller.eventBus.emitDeviceMessage({
type: "attributeReport",
device: controller.zigbee.resolveEntity(getMidDeviceIeee()),
endpoint: ZSpec.HA_ENDPOINT,
linkquality: 200,
groupID: 0,
cluster: "genOnOff",
data: {onOff: 1},
meta: {},
});
await settle(mockedGlobal);
},
{...BENCH_OPTIONS, setup, teardown},
);
});
describe("HA runtime", () => {
const setup: NonNullable<Parameters<typeof bench>[2]>["setup"] = async (task, mode) => {
BENCH_OPTIONS.setup!(task, mode);
initDevices();
initGroups();
await initSettings([[["homeassistant", "enabled"], true]]);
await initController();
const mockedGlobal = mockGlobalThis();
await controller.start();
controller.mqtt.onMessage("homeassistant/status", Buffer.from("online", "utf8"));
await settle(mockedGlobal);
};
const teardown = async () => {
await controller.stop();
unmockGlobalThis();
};
bench(
"[HA] receive device message",
async () => {
const mockedGlobal = mockGlobalThis();
controller.eventBus.emitDeviceMessage({
type: "attributeReport",
device: controller.zigbee.resolveEntity("0xf1f1f1f1f1f1f1f1"),
endpoint: ZSpec.HA_ENDPOINT,
linkquality: 200,
groupID: 0,
cluster: "genOnOff",
data: {onOff: 1},
meta: {},
});
await settle(mockedGlobal);
},
{...BENCH_OPTIONS, setup, teardown},
);
bench(
"[HA] receive MQTT message",
async () => {
const mockedGlobal = mockGlobalThis();
controller.mqtt.onMessage("zigbee2mqtt/0xf1f1f1f1f1f1f1f1/set", Buffer.from(`{"state": "OFF"}`, "utf8"));
await settle(mockedGlobal);
},
{...BENCH_OPTIONS, setup, teardown},
);
bench(
"[HA] receive MQTT discovery message",
async () => {
const mockedGlobal = mockGlobalThis();
controller.mqtt.onMessage(
"homeassistant/sensor/0xe2e2e2e2e2e2e2e2/update/config",
Buffer.from(stringify({availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}]}), "utf8"),
);
await settle(mockedGlobal);
},
{...BENCH_OPTIONS, setup, teardown},
);
});
});