fix: Various fixes to align behaviors (+add benchmarks for dev) (#28011)

This commit is contained in:
Nerivec
2025-07-18 21:34:51 +02:00
committed by GitHub
parent 0e0a5618af
commit db20369447
29 changed files with 1032 additions and 98 deletions
+11 -3
View File
@@ -3,9 +3,10 @@ name: CI
on:
push:
tags:
- '**'
branches-ignore:
- dev-types
- '[0-9]+.[0-9]+.[0-9]+'
branches:
- dev
- master
pull_request:
permissions:
@@ -47,6 +48,13 @@ jobs:
- name: Test
run: pnpm run test:coverage
- name: Bench
if: github.ref == 'refs/heads/dev' || github.event_name == 'pull_request'
uses: CodSpeedHQ/action@v3
with:
run: pnpm run bench
token: ${{ secrets.CODSPEED_TOKEN }}
- name: Log in to the Docker container registry
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
uses: docker/login-action@v3
+1
View File
@@ -77,6 +77,7 @@ tsconfig.types.tsbuildinfo
# Ignore config
data/*
!data/configuration.example.yaml
data-bench
# commit-user-lookup.json
scripts/commit-user-lookup.json
+4 -4
View File
@@ -28,10 +28,10 @@ import utils from "./util/utils";
import Zigbee from "./zigbee";
export class Controller {
private eventBus: EventBus;
private zigbee: Zigbee;
private state: State;
private mqtt: Mqtt;
public readonly eventBus: EventBus;
public readonly zigbee: Zigbee;
public readonly state: State;
public readonly mqtt: Mqtt;
private restartCallback: () => Promise<void>;
private exitCallback: (code: number, restart: boolean) => Promise<void>;
public readonly extensions: Set<Extension>;
+4 -4
View File
@@ -9,10 +9,9 @@ import Group from "../model/group";
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from "../types/api";
import logger from "../util/logger";
import * as settings from "../util/settings";
import utils from "../util/utils";
import utils, {DEFAULT_BIND_GROUP_ID} from "../util/utils";
import Extension from "./extension";
const TOPIC_REGEX = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/(bind|unbind)`);
const ALL_CLUSTER_CANDIDATES: readonly ClusterName[] = [
"genScenes",
"genOnOff",
@@ -28,7 +27,7 @@ const ALL_CLUSTER_CANDIDATES: readonly ClusterName[] = [
];
// See zigbee-herdsman-converters
const DEFAULT_BIND_GROUP = {type: "group_number", ID: 901, name: "default_bind_group"};
const DEFAULT_BIND_GROUP = {type: "group_number", ID: DEFAULT_BIND_GROUP_ID, name: "default_bind_group"};
const DEFAULT_REPORT_CONFIG = {minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1};
const getColorCapabilities = async (endpoint: zh.Endpoint): Promise<{colorTemperature: boolean; colorXY: boolean}> => {
@@ -204,6 +203,7 @@ interface ParsedMQTTMessage {
}
export default class Bind extends Extension {
#topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/(bind|unbind)`);
private pollDebouncers: {[s: string]: () => void} = {};
// biome-ignore lint/suspicious/useAwait: API
@@ -216,7 +216,7 @@ export default class Bind extends Extension {
private parseMQTTMessage(
data: eventdata.MQTTMessage,
): [raw: KeyValue | undefined, parsed: ParsedMQTTMessage | undefined, error: string | undefined] {
if (data.topic.match(TOPIC_REGEX)) {
if (data.topic.match(this.#topicRegex)) {
const type = data.topic.endsWith("unbind") ? "unbind" : "bind";
let skipDisableReporting = false;
const message = JSON.parse(data.message) as Zigbee2MQTTAPI["bridge/request/device/bind"];
+4 -5
View File
@@ -14,12 +14,11 @@ import type {Zigbee2MQTTAPI, Zigbee2MQTTDevice, Zigbee2MQTTResponse, Zigbee2MQTT
import data from "../util/data";
import logger from "../util/logger";
import * as settings from "../util/settings";
import utils, {assertString} from "../util/utils";
import utils, {assertString, DEFAULT_BIND_GROUP_ID} from "../util/utils";
import Extension from "./extension";
const REQUEST_REGEX = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/(.*)`);
export default class Bridge extends Extension {
#requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/(.*)`);
// set on `start`
#osInfo!: Zigbee2MQTTAPI["bridge/info"]["os"];
private zigbee2mqttVersion!: {commitHash?: string; version: string};
@@ -197,7 +196,7 @@ export default class Bridge extends Extension {
}
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
const match = data.topic.match(REQUEST_REGEX);
const match = data.topic.match(this.#requestRegex);
if (!match) {
return;
@@ -797,7 +796,7 @@ export default class Bridge extends Extension {
groups.push({
id: group.ID,
friendly_name: group.ID === 901 ? "default_bind_group" : group.name,
friendly_name: group.ID === DEFAULT_BIND_GROUP_ID ? "default_bind_group" : group.name,
description: group.options.description,
scenes: utils.getScenes(group.zh),
members,
+6 -7
View File
@@ -11,8 +11,6 @@ import * as settings from "../util/settings";
import utils, {isLightExpose} from "../util/utils";
import Extension from "./extension";
const TOPIC_REGEX = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/group/members/(remove|add|remove_all)$`);
const STATE_PROPERTIES: Readonly<Record<string, (value: string, exposes: zhc.Expose[]) => boolean>> = {
state: () => true,
brightness: (_value, exposes) => exposes.some((e) => isLightExpose(e) && e.features.some((f) => f.name === "brightness")),
@@ -38,6 +36,7 @@ interface ParsedMQTTMessage {
}
export default class Groups extends Extension {
#topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/group/members/(remove|add|remove_all)$`);
private lastOptimisticState: {[s: string]: KeyValue} = {};
// biome-ignore lint/suspicious/useAwait: API
@@ -185,7 +184,7 @@ export default class Groups extends Extension {
private parseMQTTMessage(
data: eventdata.MQTTMessage,
): [raw: KeyValue | undefined, parsed: ParsedMQTTMessage | undefined, error: string | undefined] {
const topicRegexMatch = data.topic.match(TOPIC_REGEX);
const topicRegexMatch = data.topic.match(this.#topicRegex);
if (topicRegexMatch) {
const type = topicRegexMatch[1] as "remove" | "add" | "remove_all";
@@ -273,7 +272,7 @@ export default class Groups extends Extension {
try {
if (type === "add") {
assert(resolvedGroup, "`resolvedGroup` is missing");
logger.info(`Adding '${resolvedDevice.name}' to '${resolvedGroup.name}'`);
logger.info(`Adding endpoint '${resolvedEndpoint.ID}' of device '${resolvedDevice.name}' to group '${resolvedGroup.name}'`);
await resolvedEndpoint.addToGroup(resolvedGroup.zh);
changedGroups.push(resolvedGroup);
// biome-ignore lint/style/noNonNullAssertion: valid from resolved asserts
@@ -281,7 +280,7 @@ export default class Groups extends Extension {
await this.publishResponse<"bridge/response/group/members/add">(parsed.type, raw, respPayload);
} else if (type === "remove") {
assert(resolvedGroup, "`resolvedGroup` is missing");
logger.info(`Removing '${resolvedDevice.name}' from '${resolvedGroup.name}'`);
logger.info(`Removing endpoint '${resolvedEndpoint.ID}' of device '${resolvedDevice.name}' from group '${resolvedGroup.name}'`);
await resolvedEndpoint.removeFromGroup(resolvedGroup.zh);
changedGroups.push(resolvedGroup);
// biome-ignore lint/style/noNonNullAssertion: valid from resolved asserts
@@ -289,7 +288,7 @@ export default class Groups extends Extension {
await this.publishResponse<"bridge/response/group/members/remove">(parsed.type, raw, respPayload);
} else {
// remove_all
logger.info(`Removing '${resolvedDevice.name}' from all groups`);
logger.info(`Removing endpoint '${resolvedEndpoint.ID}' of device '${resolvedDevice.name}' from all groups`);
for (const group of this.zigbee.groupsIterator((g) => g.members.includes(resolvedEndpoint))) {
changedGroups.push(group);
@@ -301,7 +300,7 @@ export default class Groups extends Extension {
await this.publishResponse<"bridge/response/group/members/remove_all">(parsed.type, raw, respPayload);
}
} catch (e) {
const errorMsg = `Failed to ${type} from group (${(e as Error).message})`;
const errorMsg = `Failed to ${type} ${type === "add" ? "to" : "from"} group (${(e as Error).message})`;
await this.publishResponse(parsed.type, raw, {}, errorMsg);
// biome-ignore lint/style/noNonNullAssertion: always Error
logger.debug((e as Error).stack!);
+2 -2
View File
@@ -13,7 +13,7 @@ const SUPPORTED_FORMATS = ["raw", "graphviz", "plantuml"];
* This extension creates a network map
*/
export default class NetworkMap extends Extension {
private topic = `${settings.get().mqtt.base_topic}/bridge/request/networkmap`;
#topic = `${settings.get().mqtt.base_topic}/bridge/request/networkmap`;
// biome-ignore lint/suspicious/useAwait: API
override async start(): Promise<void> {
@@ -21,7 +21,7 @@ export default class NetworkMap extends Extension {
}
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
if (data.topic === this.topic) {
if (data.topic === this.#topic) {
const message = utils.parseJSON(data.message, data.message) as Zigbee2MQTTAPI["bridge/request/networkmap"];
try {
+5 -6
View File
@@ -25,12 +25,11 @@ export interface UpdatePayload {
};
}
const topicRegex = new RegExp(
`^${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/(update|check|schedule|unschedule)/?(downgrade)?`,
"i",
);
export default class OTAUpdate extends Extension {
#topicRegex = new RegExp(
`^${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/(update|check|schedule|unschedule)/?(downgrade)?`,
"i",
);
private inProgress = new Set<string>();
private lastChecked = new Map<string, number>();
private scheduledUpgrades = new Set<string>();
@@ -252,7 +251,7 @@ export default class OTAUpdate extends Extension {
}
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
const topicMatch = data.topic.match(topicRegex);
const topicMatch = data.topic.match(this.#topicRegex);
if (!topicMatch) {
return;
+3 -2
View File
@@ -9,12 +9,12 @@ import * as settings from "../util/settings";
import utils from "../util/utils";
import Extension from "./extension";
// TODO: get rid of this, use class member
let topicGetSetRegex: RegExp;
// Used by `publish.test.js` to reload regex when changing `mqtt.base_topic`.
// Used by `publish.test.ts` to reload regex when changing `mqtt.base_topic`.
export const loadTopicGetSetRegex = (): void => {
topicGetSetRegex = new RegExp(`^${settings.get().mqtt.base_topic}/(?!bridge)(.+?)/(get|set)(?:/(.+))?$`);
};
loadTopicGetSetRegex();
const STATE_VALUES: ReadonlyArray<string> = ["on", "off", "toggle", "open", "close", "stop", "lock", "unlock"];
const SCENE_CONVERTER_KEYS: ReadonlyArray<string> = ["scene_store", "scene_add", "scene_remove", "scene_remove_all", "scene_rename"];
@@ -29,6 +29,7 @@ interface ParsedTopic {
export default class Publish extends Extension {
// biome-ignore lint/suspicious/useAwait: API
override async start(): Promise<void> {
loadTopicGetSetRegex();
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
}
+7 -1
View File
@@ -1,6 +1,6 @@
import type * as zhc from "zigbee-herdsman-converters";
import * as settings from "../util/settings";
import {DEFAULT_BIND_GROUP_ID} from "../util/utils";
export default class Group {
public zh: zh.Group;
@@ -23,6 +23,12 @@ export default class Group {
this.resolveDevice = resolveDevice;
}
ensureInSettings(): void {
if (this.ID !== DEFAULT_BIND_GROUP_ID && !settings.getGroup(this.ID)) {
settings.addGroup(this.name, this.ID.toString());
}
}
hasMember(device: Device): boolean {
return !!device.zh.endpoints.find((e) => this.zh.members.includes(e));
}
+2
View File
@@ -12,6 +12,8 @@ import data from "./data";
const BASE64_IMAGE_REGEX = /data:image\/(?<extension>.+);base64,(?<data>.+)/;
export const DEFAULT_BIND_GROUP_ID = 901;
function pad(num: number): string {
const norm = Math.floor(Math.abs(num));
return (norm < 10 ? "0" : "") + norm;
+13 -6
View File
@@ -15,8 +15,7 @@ import utils from "./util/utils";
const entityIDRegex = /^(.+?)(?:\/([^/]+))?$/;
export default class Zigbee {
// @ts-expect-error initialized in start
private herdsman: Controller;
private herdsman!: Controller;
private eventBus: EventBus;
private groupLookup = new Map<number /* group ID */, Group>();
private deviceLookup = new Map<string /* IEEE address */, Device>();
@@ -266,12 +265,20 @@ export default class Zigbee {
}
private resolveGroup(groupID: number): Group | undefined {
const group = this.herdsman.getGroupByID(Number(groupID));
if (group && !this.groupLookup.has(groupID)) {
this.groupLookup.set(groupID, new Group(group, this.resolveDevice));
if (!this.groupLookup.has(groupID)) {
const group = this.herdsman.getGroupByID(groupID);
if (group) {
this.groupLookup.set(groupID, new Group(group, this.resolveDevice));
}
}
return this.groupLookup.get(groupID);
const group = this.groupLookup.get(groupID);
if (group) {
group.ensureInSettings();
return group;
}
}
resolveEntity(key: string | number | zh.Device): Device | Group | undefined {
+3 -1
View File
@@ -31,6 +31,7 @@
"test": "vitest run --config ./test/vitest.config.mts",
"test:coverage": "vitest run --config ./test/vitest.config.mts --coverage",
"test:watch": "vitest watch --config ./test/vitest.config.mts",
"bench": "vitest bench --run --config ./test/vitest.config.mts",
"prepack": "pnpm run clean && pnpm run build",
"clean": "rimraf coverage dist tsconfig.tsbuildinfo"
},
@@ -69,6 +70,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.0.5",
"@codspeed/vitest-plugin": "^4.0.1",
"@types/finalhandler": "^1.2.3",
"@types/humanize-duration": "^3.27.4",
"@types/js-yaml": "^4.0.9",
@@ -99,4 +101,4 @@
"optionalDependencies": {
"unix-dgram": "^2.0.6"
}
}
}
+261
View File
@@ -90,6 +90,9 @@ importers:
'@biomejs/biome':
specifier: ^2.0.5
version: 2.0.6
'@codspeed/vitest-plugin':
specifier: ^4.0.1
version: 4.0.1(vite@6.3.5(@types/node@24.0.10))(vitest@3.2.4(@types/node@24.0.10))
'@types/finalhandler':
specifier: ^1.2.3
version: 1.2.4
@@ -215,6 +218,15 @@ packages:
cpu: [x64]
os: [win32]
'@codspeed/core@4.0.1':
resolution: {integrity: sha512-fJ53arfgtzCDZa8DuGJhpTZ3Ll9A1uW5nQ2jSJnfO4Hl5MRD2cP8P4vPvIUAGbdbjwCxR1jat6cW8OloMJkJXw==}
'@codspeed/vitest-plugin@4.0.1':
resolution: {integrity: sha512-aqmrPJzX9cD50UWDsOyih5L5WcEYlNQg3u84sJJ9ZuuLApA51w+LGxk6Xbyb8LJF9n/CwM94HKHV/qArfnvDoQ==}
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0
vitest: '>=1.2.2'
'@colors/colors@1.6.0':
resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==}
engines: {node: '>=0.1.90'}
@@ -644,6 +656,12 @@ packages:
async@3.2.6:
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
axios@1.10.0:
resolution: {integrity: sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -682,6 +700,10 @@ packages:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
chai@5.2.0:
resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==}
engines: {node: '>=12'}
@@ -712,6 +734,10 @@ packages:
colorspace@1.1.4:
resolution: {integrity: sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
commist@3.2.0:
resolution: {integrity: sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==}
@@ -752,6 +778,10 @@ packages:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
@@ -760,6 +790,10 @@ packages:
resolution: {integrity: sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==}
engines: {node: '>=6'}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
@@ -779,9 +813,25 @@ packages:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'}
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
es-errors@1.3.0:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
es-set-tostringtag@2.1.0:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
esbuild@0.25.5:
resolution: {integrity: sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==}
engines: {node: '>=18'}
@@ -840,13 +890,30 @@ packages:
resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==}
engines: {node: '>= 0.8'}
find-up@6.3.0:
resolution: {integrity: sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
fn.name@1.1.0:
resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==}
follow-redirects@1.15.9:
resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
form-data@4.0.3:
resolution: {integrity: sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==}
engines: {node: '>= 6'}
fresh@2.0.0:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
@@ -856,6 +923,17 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
glob@10.4.5:
resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==}
hasBin: true
@@ -869,10 +947,26 @@ packages:
resolution: {integrity: sha512-mTCC51QFadK75MvAhrL5nPVIP291NjML1guo10Sa7Yj04tJU4V++Vgm780NIddg9etQD9D8FM67hFGqM8EE2HQ==}
engines: {node: '>= 0.2.5'}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
has-tostringtag@1.0.2:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
help-me@5.0.0:
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
@@ -971,6 +1065,10 @@ packages:
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
locate-path@7.2.0:
resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
logform@2.7.0:
resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==}
engines: {node: '>= 12.0.0'}
@@ -995,10 +1093,22 @@ packages:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-db@1.54.0:
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
mime-types@3.0.1:
resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==}
engines: {node: '>= 0.6'}
@@ -1070,6 +1180,14 @@ packages:
one-time@1.0.0:
resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==}
p-limit@4.0.0:
resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
p-locate@6.0.0:
resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
@@ -1080,6 +1198,10 @@ packages:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
path-exists@5.0.0:
resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
@@ -1117,6 +1239,9 @@ packages:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
range-parser@1.2.1:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
@@ -1465,6 +1590,10 @@ packages:
utf-8-validate:
optional: true
yocto-queue@1.2.1:
resolution: {integrity: sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==}
engines: {node: '>=12.20'}
zigbee-herdsman-converters@24.3.0:
resolution: {integrity: sha512-gGqA7A6/ru6lcNPPKmOreJzGl0JayXgcOPfwSxkw0LUtn9Wtct/rAQj5BMa/Rz1Zzwa++Amn8jEJSdx5eCT3sg==}
@@ -1542,6 +1671,23 @@ snapshots:
'@biomejs/cli-win32-x64@2.0.6':
optional: true
'@codspeed/core@4.0.1':
dependencies:
axios: 1.10.0
find-up: 6.3.0
form-data: 4.0.3
node-gyp-build: 4.8.4
transitivePeerDependencies:
- debug
'@codspeed/vitest-plugin@4.0.1(vite@6.3.5(@types/node@24.0.10))(vitest@3.2.4(@types/node@24.0.10))':
dependencies:
'@codspeed/core': 4.0.1
vite: 6.3.5(@types/node@24.0.10)
vitest: 3.2.4(@types/node@24.0.10)
transitivePeerDependencies:
- debug
'@colors/colors@1.6.0': {}
'@dabh/diagnostics@2.0.3':
@@ -1884,6 +2030,16 @@ snapshots:
async@3.2.6: {}
asynckit@0.4.0: {}
axios@1.10.0:
dependencies:
follow-redirects: 1.15.9
form-data: 4.0.3
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
balanced-match@1.0.2: {}
base64-js@1.5.1: {}
@@ -1926,6 +2082,11 @@ snapshots:
cac@6.7.14: {}
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
chai@5.2.0:
dependencies:
assertion-error: 2.0.1
@@ -1963,6 +2124,10 @@ snapshots:
color: 3.2.1
text-hex: 1.0.0
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
commist@3.2.0: {}
concat-stream@2.0.0:
@@ -1992,12 +2157,20 @@ snapshots:
deep-eql@5.0.2: {}
delayed-stream@1.0.0: {}
depd@2.0.0: {}
dns-packet@5.6.1:
dependencies:
'@leichtgewicht/ip-codec': 2.0.5
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
es-errors: 1.3.0
gopd: 1.2.0
eastasianwidth@0.2.0: {}
ee-first@1.1.1: {}
@@ -2010,8 +2183,23 @@ snapshots:
encodeurl@2.0.0: {}
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
es-module-lexer@1.7.0: {}
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
es-set-tostringtag@2.1.0:
dependencies:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.2
esbuild@0.25.5:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.5
@@ -2091,18 +2279,53 @@ snapshots:
transitivePeerDependencies:
- supports-color
find-up@6.3.0:
dependencies:
locate-path: 7.2.0
path-exists: 5.0.0
fn.name@1.1.0: {}
follow-redirects@1.15.9: {}
foreground-child@3.3.1:
dependencies:
cross-spawn: 7.0.6
signal-exit: 4.1.0
form-data@4.0.3:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.2
mime-types: 2.1.35
fresh@2.0.0: {}
fsevents@2.3.3:
optional: true
function-bind@1.1.2: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.1.1
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.2
math-intrinsics: 1.1.0
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
es-object-atoms: 1.1.1
glob@10.4.5:
dependencies:
foreground-child: 3.3.1
@@ -2123,8 +2346,20 @@ snapshots:
glossy@0.1.7: {}
gopd@1.2.0: {}
has-flag@4.0.0: {}
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.1.0
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
help-me@5.0.0: {}
html-escaper@2.0.2: {}
@@ -2222,6 +2457,10 @@ snapshots:
dependencies:
immediate: 3.0.6
locate-path@7.2.0:
dependencies:
p-locate: 6.0.0
logform@2.7.0:
dependencies:
'@colors/colors': 1.6.0
@@ -2251,8 +2490,16 @@ snapshots:
dependencies:
semver: 7.7.2
math-intrinsics@1.1.0: {}
mime-db@1.52.0: {}
mime-db@1.54.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
mime-types@3.0.1:
dependencies:
mime-db: 1.54.0
@@ -2337,12 +2584,22 @@ snapshots:
dependencies:
fn.name: 1.1.0
p-limit@4.0.0:
dependencies:
yocto-queue: 1.2.1
p-locate@6.0.0:
dependencies:
p-limit: 4.0.0
package-json-from-dist@1.0.1: {}
pako@1.0.11: {}
parseurl@1.3.3: {}
path-exists@5.0.0: {}
path-key@3.1.1: {}
path-scurry@1.11.1:
@@ -2373,6 +2630,8 @@ snapshots:
process@0.11.10: {}
proxy-from-env@1.1.0: {}
range-parser@1.2.1: {}
readable-stream@2.3.8:
@@ -2747,6 +3006,8 @@ snapshots:
ws@8.18.3: {}
yocto-queue@1.2.1: {}
zigbee-herdsman-converters@24.3.0:
dependencies:
buffer-crc32: 1.0.0
+674
View File
@@ -0,0 +1,674 @@
import {existsSync, mkdirSync} from "node:fs";
import stringify from "json-stable-stringify-without-jsonify";
import {afterEach, beforeEach, bench, describe} from "vitest";
import {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";
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) {
try {
const attribute = zclFrame.cluster.getAttribute(attr.attrId);
if (attribute.type !== Zcl.DataType.NO_DATA && attribute.type < Zcl.DataType.OCTET_STR) {
payload.push({
attrId: attr.attrId,
dataType: attribute.type,
attrData: 1,
status: 0,
});
}
} catch {}
}
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 () => {},
);
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 = {
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", () => {
describe("defaults start & stop", () => {
beforeEach(async () => {
initDevices();
initGroups();
await initSettings();
await initController();
});
afterEach(() => {
unmockGlobalThis();
});
bench(
"[defaults] start & stop controller",
async () => {
const mockedGlobal = mockGlobalThis();
await controller.start();
await settle(mockedGlobal);
if ((await controller.zigbee.getCoordinatorVersion()).type !== "Dummy") {
throw new Error("Invalid");
}
await controller.stop();
},
{throws: true},
);
});
describe("HA start & stop", () => {
beforeEach(async () => {
initDevices();
initGroups();
await initSettings([[["homeassistant", "enabled"], true]]);
await initController();
});
afterEach(() => {
unmockGlobalThis();
});
bench(
"[HA] start & stop controller",
async () => {
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();
},
{throws: true},
);
});
describe("defaults runtime", () => {
beforeAll(async () => {
initDevices();
initGroups();
await initSettings();
await initController();
const mockedGlobal = mockGlobalThis();
await controller.start();
await settle(mockedGlobal);
});
afterAll(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);
},
{throws: true},
);
bench(
"[defaults] receive MQTT message",
async () => {
const mockedGlobal = mockGlobalThis();
controller.mqtt.onMessage("zigbee2mqtt/0xf1f1f1f1f1f1f1f1/set", Buffer.from(`{"state": "OFF"}`, "utf8"));
await settle(mockedGlobal);
},
{throws: true},
);
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");
}
},
{throws: true},
);
});
describe("defaults/stress runtime", () => {
beforeAll(async () => {
initDevices();
initGroups();
addManyDevices();
await initSettings();
await initController();
const mockedGlobal = mockGlobalThis();
await controller.start();
await settle(mockedGlobal);
}, 60000);
afterAll(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);
},
{throws: true},
);
});
describe("HA runtime", () => {
beforeAll(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);
});
afterAll(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);
},
{throws: true},
);
bench(
"[HA] receive MQTT message",
async () => {
const mockedGlobal = mockGlobalThis();
controller.mqtt.onMessage("zigbee2mqtt/0xf1f1f1f1f1f1f1f1/set", Buffer.from(`{"state": "OFF"}`, "utf8"));
await settle(mockedGlobal);
},
{throws: true},
);
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);
},
{throws: true},
);
});
});
+1 -18
View File
@@ -55,7 +55,6 @@ describe("Controller", () => {
let mockExit: Mock;
const getZ2MDevice = (zhDevice: string | number | ZhDevice): Device => {
// @ts-expect-error private
return controller.zigbee.resolveEntity(zhDevice)! as Device;
};
@@ -295,11 +294,9 @@ describe("Controller", () => {
await controller.start();
await flushPromises();
mockMQTTPublishAsync.mockClear();
// @ts-expect-error private
await controller.mqtt.publish("z2m/#/status", "empty");
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
expect(mockLogger.error).toHaveBeenCalledWith(`Topic 'z2m/#/status' includes wildcard characters, skipping publish.`);
// @ts-expect-error private
await controller.mqtt.publish("z2m/+/status", "empty");
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
expect(mockLogger.error).toHaveBeenCalledWith(`Topic 'z2m/+/status' includes wildcard characters, skipping publish.`);
@@ -503,7 +500,6 @@ describe("Controller", () => {
});
it("Handle mqtt message", async () => {
// @ts-expect-error private
const spyEventbusEmitMQTTMessage = vi.spyOn(controller.eventBus, "emitMQTTMessage").mockImplementation(vi.fn());
await controller.start();
@@ -514,7 +510,6 @@ describe("Controller", () => {
});
it("Skip MQTT messages on topic we published to", async () => {
// @ts-expect-error private
const spyEventbusEmitMQTTMessage = vi.spyOn(controller.eventBus, "emitMQTTMessage").mockImplementation(vi.fn());
await controller.start();
@@ -522,7 +517,6 @@ describe("Controller", () => {
await mockMQTTEvents.message("zigbee2mqtt/skip-this-topic", "skipped");
expect(spyEventbusEmitMQTTMessage).toHaveBeenCalledWith({topic: "zigbee2mqtt/skip-this-topic", message: "skipped"});
mockLogger.debug.mockClear();
// @ts-expect-error private
await controller.mqtt.publish("skip-this-topic", "", {});
await mockMQTTEvents.message("zigbee2mqtt/skip-this-topic", "skipped");
expect(mockLogger.debug).toHaveBeenCalledTimes(0);
@@ -844,13 +838,11 @@ describe("Controller", () => {
mockMQTTPublishAsync.mockClear();
const device = getZ2MDevice("bulb");
// @ts-expect-error private
expect(controller.state.get(device)).toStrictEqual({brightness: 50, color_temp: 370, linkquality: 99, state: "ON"});
await controller.publishEntityState(device, {state: "ON", brightness: 200, color_temp: 370, linkquality: 87});
await flushPromises();
// @ts-expect-error private
expect(controller.state.get(device)).toStrictEqual({brightness: 200, color_temp: 370, state: "ON"});
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(5);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb/state", "ON", {qos: 0, retain: true});
@@ -870,13 +862,11 @@ describe("Controller", () => {
mockMQTTPublishAsync.mockClear();
const device = getZ2MDevice("bulb");
// @ts-expect-error private
expect(controller.state.get(device)).toStrictEqual({brightness: 50, color_temp: 370, linkquality: 99, state: "ON"});
await controller.publishEntityState(device, {state: "ON", brightness: 200, color_temp: 370, linkquality: 87});
await flushPromises();
// @ts-expect-error private
expect(controller.state.get(device)).toStrictEqual({brightness: 200, color_temp: 370, state: "ON"});
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(5);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb/state", "ON", {qos: 0, retain: true});
@@ -1059,10 +1049,7 @@ describe("Controller", () => {
await controller.start();
await flushPromises();
const retainedMessages = Object.keys(
// @ts-expect-error private
controller.mqtt.retainedMessages,
).length;
const retainedMessages = Object.keys(controller.mqtt.retainedMessages).length;
mockMQTTPublishAsync.mockClear();
await vi.advanceTimersByTimeAsync(2500); // before any startup configure triggers
@@ -1083,7 +1070,6 @@ describe("Controller", () => {
it("Should prevent any message being published with retain flag when force_disable_retain is set", async () => {
settings.set(["mqtt", "force_disable_retain"], true);
// @ts-expect-error private
await controller.mqtt.connect();
mockMQTTPublishAsync.mockClear();
// @ts-expect-error private
@@ -1141,7 +1127,6 @@ describe("Controller", () => {
it("Should remove state of removed device when stopped", async () => {
await controller.start();
const device = getZ2MDevice("bulb");
// @ts-expect-error private
expect(controller.state.get(device)).toStrictEqual({brightness: 50, color_temp: 370, linkquality: 99, state: "ON"});
device.zh.isDeleted = true;
await controller.stop();
@@ -1150,7 +1135,6 @@ describe("Controller", () => {
});
it("EventBus should handle sync errors", async () => {
// @ts-expect-error private
const eventbus = controller.eventBus;
const callback = vi.fn().mockImplementation(() => {
throw new Error("Whoops!");
@@ -1163,7 +1147,6 @@ describe("Controller", () => {
});
it("EventBus should handle async errors", async () => {
// @ts-expect-error private
const eventbus = controller.eventBus;
const callback = vi.fn().mockRejectedValue(new Error("Whoops!"));
eventbus.onStateChange({constructor: {name: "Test"}}, callback);
-2
View File
@@ -281,9 +281,7 @@ describe("Extension: Availability", () => {
});
it("Should retrieve device state when it reconnects", async () => {
// @ts-expect-error private
const device = controller.zigbee.resolveEntity(devices.bulb_color.ieeeAddr);
// @ts-expect-error private
controller.state.set(device, {state: "OFF"});
const endpoint = devices.bulb_color.getEndpoint(1);
+4 -3
View File
@@ -10,6 +10,7 @@ import stringify from "json-stable-stringify-without-jsonify";
import {Controller} from "../../lib/controller";
import Bind from "../../lib/extension/bind";
import * as settings from "../../lib/util/settings";
import {DEFAULT_BIND_GROUP_ID} from "../../lib/util/utils";
const mocksClear = [
mockDebounce,
@@ -648,9 +649,9 @@ describe("Extension: Bind", () => {
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/unbind", stringify({from: "remote", to: target}));
await flushPromises();
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", 901);
expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", 901);
expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", 901);
expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", DEFAULT_BIND_GROUP_ID);
expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", DEFAULT_BIND_GROUP_ID);
expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", DEFAULT_BIND_GROUP_ID);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/unbind",
stringify({
+2 -3
View File
@@ -14,7 +14,7 @@ import type {Mock} from "vitest";
import {Controller} from "../../lib/controller";
import Bridge from "../../lib/extension/bridge";
import * as settings from "../../lib/util/settings";
import utils from "../../lib/util/utils";
import utils, {DEFAULT_BIND_GROUP_ID} from "../../lib/util/utils";
returnDevices.push(devices.coordinator.ieeeAddr);
returnDevices.push(devices.bulb.ieeeAddr);
@@ -2288,7 +2288,7 @@ describe("Extension: Bridge", () => {
scenes: [],
},
{friendly_name: "gledopto_group", id: 21, members: [{endpoint: 15, ieee_address: "0x0017880104e45724"}], scenes: []},
{friendly_name: "default_bind_group", id: 901, members: [], scenes: []},
{friendly_name: "default_bind_group", id: DEFAULT_BIND_GROUP_ID, members: [], scenes: []},
{
friendly_name: "ha_discovery_group",
id: 9,
@@ -3147,7 +3147,6 @@ describe("Extension: Bridge", () => {
});
it("Should allow interviewing a device by ieeeAddr", async () => {
// @ts-expect-error private
const device = controller.zigbee.resolveEntity(devices.bulb)!;
assert("resolveDefinition" in device);
device.resolveDefinition = vi.fn();
-2
View File
@@ -120,11 +120,9 @@ describe("Extension: Configure", () => {
it("Should reconfigure reporting on reconfigure event", async () => {
expectBulbConfigured();
// @ts-expect-error private
const device = controller.zigbee.resolveEntity(devices.bulb)!;
mockClear(device.zh);
expectBulbNotConfigured();
// @ts-expect-error private
controller.eventBus.emitReconfigure({device});
await flushPromises();
expectBulbConfigured();
@@ -49,7 +49,6 @@ describe("Extension: ExternalConverters", () => {
};
const getZ2MDevice = (zhDevice: string | number | ZhDevice): Device => {
// @ts-expect-error private
return controller.zigbee.resolveEntity(zhDevice)! as Device;
};
@@ -72,7 +71,6 @@ describe("Extension: ExternalConverters", () => {
beforeEach(async () => {
zhc.removeExternalDefinitions(); // remove all external converters
// @ts-expect-error private - clear cached
await controller.zigbee.resolveDevicesDefinitions(true);
for (const mock of mocksClear) mock.mockClear();
data.writeDefaultConfiguration();
+21 -3
View File
@@ -44,7 +44,6 @@ describe("Extension: Groups", () => {
mockMQTTPublishAsync.mockClear();
groups.gledopto_group.command.mockClear();
zhcGlobalStore.clear();
// @ts-expect-error private
controller.state.clear();
});
@@ -459,7 +458,6 @@ describe("Extension: Groups", () => {
await mockMQTTEvents.message("zigbee2mqtt/group_1/set", stringify({state: "ON"}));
await flushPromises();
mockMQTTPublishAsync.mockClear();
// @ts-expect-error private
controller.state.clear();
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "OFF"}));
@@ -506,7 +504,7 @@ describe("Extension: Groups", () => {
expect(mockMQTTPublishAsync).not.toHaveBeenCalledWith("zigbee2mqtt/bridge/groups", expect.any(String), expect.any(Object));
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/group/members/add",
stringify({data: {}, status: "error", error: "Failed to add from group (timeout)"}),
stringify({data: {}, status: "error", error: "Failed to add to group (timeout)"}),
{},
);
});
@@ -591,6 +589,26 @@ describe("Extension: Groups", () => {
);
});
it("Remove from group via MQTT fails", async () => {
const device = devices.bulb_color;
const endpoint = device.getEndpoint(1)!;
const group = groups.group_1;
group.members.push(endpoint);
endpoint.removeFromGroup.mockImplementationOnce(() => {
throw new Error("timeout");
});
mockMQTTPublishAsync.mockClear();
mockMQTTEvents.message("zigbee2mqtt/bridge/request/group/members/remove", stringify({group: "group_1", device: "bulb_color"}));
await flushPromises();
expect(group.members.length).toStrictEqual(1);
expect(mockMQTTPublishAsync).not.toHaveBeenCalledWith("zigbee2mqtt/bridge/groups", expect.any(String), expect.any(Object));
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/group/members/remove",
stringify({data: {}, status: "error", error: "Failed to remove from group (timeout)"}),
{},
);
});
it("Remove from group via MQTT keeping device reporting", async () => {
const device = devices.bulb_color;
const endpoint = device.getEndpoint(1)!;
-5
View File
@@ -55,7 +55,6 @@ describe("Extension: HomeAssistant", () => {
};
const getZ2MEntity = (zhDeviceOrGroup: string | number | ZhDevice): Device | Group => {
// @ts-expect-error private
return controller.zigbee.resolveEntity(zhDeviceOrGroup)!;
};
@@ -1464,7 +1463,6 @@ describe("Extension: HomeAssistant", () => {
assert("ieeeAddr" in device);
resetDiscoveryPayloads(device.ieeeAddr);
mockMQTTPublishAsync.mockClear();
// @ts-expect-error private
controller.eventBus.emitEntityOptionsChanged({entity: device, from: {}, to: {test: 123}});
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(`homeassistant/light/${device.ID}/light/config`, expect.any(String), expect.any(Object));
@@ -2384,7 +2382,6 @@ describe("Extension: HomeAssistant", () => {
resetDiscoveryPayloads(device.ieeeAddr);
mockMQTTPublishAsync.mockClear();
// @ts-expect-error private
controller.eventBus.emitScenesChanged({entity: device});
await flushPromises();
@@ -2421,7 +2418,6 @@ describe("Extension: HomeAssistant", () => {
resetDiscoveryPayloads("9");
mockMQTTPublishAsync.mockClear();
// @ts-expect-error private
controller.eventBus.emitScenesChanged({entity: group});
await flushPromises();
@@ -2483,7 +2479,6 @@ describe("Extension: HomeAssistant", () => {
availability_mode: "all",
};
// @ts-expect-error private
controller.eventBus.emitMQTTMessage({
topic: topic,
message: stringify(payload),
-1
View File
@@ -24,7 +24,6 @@ describe("Extension: OnEvent", () => {
let deviceOnEventSpy: MockInstance<DefinitionOnEvent>;
const getZ2MDevice = (zhDevice: string | number | ZhDevice): Device => {
// @ts-expect-error private
return controller.zigbee.resolveEntity(zhDevice)! as Device;
};
-4
View File
@@ -67,7 +67,6 @@ describe("Extension: OTAUpdate", () => {
devices.bulb.mockClear();
updateSpy.mockClear();
isUpdateAvailableSpy.mockClear();
// @ts-expect-error private
controller.state.clear();
});
@@ -747,12 +746,9 @@ describe("Extension: OTAUpdate", () => {
});
it("clear update state on startup", async () => {
// @ts-expect-error private
const device = controller.zigbee.resolveEntity(devices.bulb_color.ieeeAddr);
// @ts-expect-error private
controller.state.set(device, {update: {progress: 100, remaining: 10, state: "updating"}});
await resetExtension();
// @ts-expect-error private
expect(controller.state.get(device)).toStrictEqual({update: {state: "available"}});
});
});
-10
View File
@@ -41,7 +41,6 @@ describe("Extension: Publish", () => {
beforeEach(() => {
data.writeDefaultConfiguration();
// @ts-expect-error private
controller.state.clear();
settings.reRead();
loadTopicGetSetRegex();
@@ -1256,11 +1255,8 @@ describe("Extension: Publish", () => {
it("Home Assistant: should not set state when color temperature is also set and device is already on", async () => {
settings.set(["homeassistant"], {enabled: true});
// @ts-expect-error private
const device = controller.zigbee.resolveEntity(devices.bulb_color.ieeeAddr)!;
// @ts-expect-error private
controller.state.remove(devices.bulb_color.ieeeAddr);
// @ts-expect-error private
controller.state.set(device, {state: "ON"});
const endpoint = device.zh.getEndpoint(1)!;
const payload = {state: "ON", color_temp: 100};
@@ -1275,11 +1271,8 @@ describe("Extension: Publish", () => {
it("Home Assistant: should set state when color temperature is also set and device is off", async () => {
settings.set(["homeassistant"], {enabled: true});
// @ts-expect-error private
const device = controller.zigbee.resolveEntity(devices.bulb_color.ieeeAddr)!;
// @ts-expect-error private
controller.state.remove(devices.bulb_color.ieeeAddr);
// @ts-expect-error private
controller.state.set(device, {state: "OFF"});
const endpoint = device.zh.getEndpoint(1)!;
const payload = {state: "ON", color_temp: 100};
@@ -1298,11 +1291,8 @@ describe("Extension: Publish", () => {
it("Home Assistant: should not set state when color is also set", async () => {
settings.set(["homeassistant"], {enabled: true});
// @ts-expect-error private
const device = controller.zigbee.resolveEntity(devices.bulb_color.ieeeAddr)!;
// @ts-expect-error private
controller.state.remove(devices.bulb_color.ieeeAddr);
// @ts-expect-error private
controller.state.set(device, {state: "ON"});
const endpoint = device.zh.getEndpoint(1)!;
const payload = {state: "ON", color: {x: 0.41, y: 0.25}};
-2
View File
@@ -24,7 +24,6 @@ describe("Extension: Receive", () => {
});
beforeEach(() => {
// @ts-expect-error private
controller.state.clear();
data.writeDefaultConfiguration();
settings.reRead();
@@ -339,7 +338,6 @@ describe("Extension: Receive", () => {
type: "attributeReport",
linkquality: 10,
});
// @ts-expect-error private
const realDevice = controller.zigbee.resolveEntity(device);
// Trigger asynchronous event while device is "debouncing" to trigger Message to be sent out from State cache.
+2 -2
View File
@@ -4,7 +4,7 @@ import type {AdapterTypes} from "zigbee-herdsman";
import {Zcl} from "zigbee-herdsman";
import {InterviewState} from "zigbee-herdsman/dist/controller/model/device";
import {DEFAULT_BIND_GROUP_ID} from "../../lib/util/utils";
import type {EventHandler} from "./utils";
type ZHConfiguredReporting = {
@@ -505,7 +505,7 @@ export const groups = {
thermostat_group: new Group(12, [TS0601_thermostat.endpoints[0]]),
group_with_switch: new Group(14, [ZNCZ02LM.endpoints[0], bulb_2.endpoints[0]]),
gledopto_group: new Group(21, [GLEDOPTO_2ID.endpoints[3]]),
default_bind_group: new Group(901, []),
default_bind_group: new Group(DEFAULT_BIND_GROUP_ID, []),
ha_discovery_group: new Group(9, [bulb_color_2.endpoints[0], bulb_2.endpoints[0], QBKG03LM.endpoints[2]]),
hue_twilight_group: new Group(19, [hue_twilight.endpoints[1]]),
};
+2
View File
@@ -1,6 +1,8 @@
import codspeedPlugin from "@codspeed/vitest-plugin";
import {defineConfig} from "vitest/config";
export default defineConfig({
plugins: [codspeedPlugin()],
test: {
globals: true,
onConsoleLog(_log: string, _type: "stdout" | "stderr"): boolean | undefined {