diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 240c4c4d..70464f3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index a722fdc2..1d66c5dd 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/lib/controller.ts b/lib/controller.ts index 79e65dd0..bf392830 100644 --- a/lib/controller.ts +++ b/lib/controller.ts @@ -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; private exitCallback: (code: number, restart: boolean) => Promise; public readonly extensions: Set; diff --git a/lib/extension/bind.ts b/lib/extension/bind.ts index 0d585696..f5764389 100755 --- a/lib/extension/bind.ts +++ b/lib/extension/bind.ts @@ -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"]; diff --git a/lib/extension/bridge.ts b/lib/extension/bridge.ts index ca7cf8d4..e58d1b65 100644 --- a/lib/extension/bridge.ts +++ b/lib/extension/bridge.ts @@ -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 { - 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, diff --git a/lib/extension/groups.ts b/lib/extension/groups.ts index 0883897f..673a9bb2 100644 --- a/lib/extension/groups.ts +++ b/lib/extension/groups.ts @@ -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 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!); diff --git a/lib/extension/networkMap.ts b/lib/extension/networkMap.ts index e69e7592..fbee79e5 100644 --- a/lib/extension/networkMap.ts +++ b/lib/extension/networkMap.ts @@ -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 { @@ -21,7 +21,7 @@ export default class NetworkMap extends Extension { } @bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise { - if (data.topic === this.topic) { + if (data.topic === this.#topic) { const message = utils.parseJSON(data.message, data.message) as Zigbee2MQTTAPI["bridge/request/networkmap"]; try { diff --git a/lib/extension/otaUpdate.ts b/lib/extension/otaUpdate.ts index d5d7c5e3..25153449 100644 --- a/lib/extension/otaUpdate.ts +++ b/lib/extension/otaUpdate.ts @@ -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(); private lastChecked = new Map(); private scheduledUpgrades = new Set(); @@ -252,7 +251,7 @@ export default class OTAUpdate extends Extension { } @bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise { - const topicMatch = data.topic.match(topicRegex); + const topicMatch = data.topic.match(this.#topicRegex); if (!topicMatch) { return; diff --git a/lib/extension/publish.ts b/lib/extension/publish.ts index 09e88433..d0998292 100644 --- a/lib/extension/publish.ts +++ b/lib/extension/publish.ts @@ -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 = ["on", "off", "toggle", "open", "close", "stop", "lock", "unlock"]; const SCENE_CONVERTER_KEYS: ReadonlyArray = ["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 { + loadTopicGetSetRegex(); this.eventBus.onMQTTMessage(this, this.onMQTTMessage); } diff --git a/lib/model/group.ts b/lib/model/group.ts index 13f5f296..d0a773ef 100644 --- a/lib/model/group.ts +++ b/lib/model/group.ts @@ -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)); } diff --git a/lib/util/utils.ts b/lib/util/utils.ts index 7f5528d8..34319abf 100644 --- a/lib/util/utils.ts +++ b/lib/util/utils.ts @@ -12,6 +12,8 @@ import data from "./data"; const BASE64_IMAGE_REGEX = /data:image\/(?.+);base64,(?.+)/; +export const DEFAULT_BIND_GROUP_ID = 901; + function pad(num: number): string { const norm = Math.floor(Math.abs(num)); return (norm < 10 ? "0" : "") + norm; diff --git a/lib/zigbee.ts b/lib/zigbee.ts index 7c2edcdf..180e1a49 100644 --- a/lib/zigbee.ts +++ b/lib/zigbee.ts @@ -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(); private deviceLookup = new Map(); @@ -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 { diff --git a/package.json b/package.json index 2f47207a..fbfea973 100644 --- a/package.json +++ b/package.json @@ -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" } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a767fbc..240b24a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/test/controller.bench.ts b/test/controller.bench.ts new file mode 100644 index 00000000..b42b2221 --- /dev/null +++ b/test/controller.bench.ts @@ -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 => { + 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 => await Promise.resolve(), + sendZdo: async ( + _ieeeAddress: string, + _networkAddress: number, + clusterId: Zdo.ClusterId, + _payload: Buffer, + _disableResponse: boolean, + ): Promise => { + 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)[] = []; + const setTimeoutProms: (void | Promise)[] = []; + + // @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) => { + 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}, + ); + }); +}); diff --git a/test/controller.test.ts b/test/controller.test.ts index 02052cf1..757e369e 100644 --- a/test/controller.test.ts +++ b/test/controller.test.ts @@ -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); diff --git a/test/extensions/availability.test.ts b/test/extensions/availability.test.ts index 8f8f779d..6146a324 100644 --- a/test/extensions/availability.test.ts +++ b/test/extensions/availability.test.ts @@ -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); diff --git a/test/extensions/bind.test.ts b/test/extensions/bind.test.ts index 85121cfe..64bf3588 100644 --- a/test/extensions/bind.test.ts +++ b/test/extensions/bind.test.ts @@ -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({ diff --git a/test/extensions/bridge.test.ts b/test/extensions/bridge.test.ts index 2c8638ed..2dab0abf 100644 --- a/test/extensions/bridge.test.ts +++ b/test/extensions/bridge.test.ts @@ -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(); diff --git a/test/extensions/configure.test.ts b/test/extensions/configure.test.ts index 038a9891..71d2452b 100644 --- a/test/extensions/configure.test.ts +++ b/test/extensions/configure.test.ts @@ -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(); diff --git a/test/extensions/externalConverters.test.ts b/test/extensions/externalConverters.test.ts index f767ebff..ace7b723 100644 --- a/test/extensions/externalConverters.test.ts +++ b/test/extensions/externalConverters.test.ts @@ -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(); diff --git a/test/extensions/groups.test.ts b/test/extensions/groups.test.ts index bf9ae845..85a21594 100644 --- a/test/extensions/groups.test.ts +++ b/test/extensions/groups.test.ts @@ -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)!; diff --git a/test/extensions/homeassistant.test.ts b/test/extensions/homeassistant.test.ts index 2247dab7..7b7500d5 100644 --- a/test/extensions/homeassistant.test.ts +++ b/test/extensions/homeassistant.test.ts @@ -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), diff --git a/test/extensions/onEvent.test.ts b/test/extensions/onEvent.test.ts index 0f8e6ad4..69c9ea3d 100644 --- a/test/extensions/onEvent.test.ts +++ b/test/extensions/onEvent.test.ts @@ -24,7 +24,6 @@ describe("Extension: OnEvent", () => { let deviceOnEventSpy: MockInstance; const getZ2MDevice = (zhDevice: string | number | ZhDevice): Device => { - // @ts-expect-error private return controller.zigbee.resolveEntity(zhDevice)! as Device; }; diff --git a/test/extensions/otaUpdate.test.ts b/test/extensions/otaUpdate.test.ts index fd14846a..83759643 100644 --- a/test/extensions/otaUpdate.test.ts +++ b/test/extensions/otaUpdate.test.ts @@ -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"}}); }); }); diff --git a/test/extensions/publish.test.ts b/test/extensions/publish.test.ts index 997b4659..ab8901a5 100644 --- a/test/extensions/publish.test.ts +++ b/test/extensions/publish.test.ts @@ -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}}; diff --git a/test/extensions/receive.test.ts b/test/extensions/receive.test.ts index 6e2c4742..0e5e1d36 100644 --- a/test/extensions/receive.test.ts +++ b/test/extensions/receive.test.ts @@ -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. diff --git a/test/mocks/zigbeeHerdsman.ts b/test/mocks/zigbeeHerdsman.ts index 9105941b..1c6750da 100644 --- a/test/mocks/zigbeeHerdsman.ts +++ b/test/mocks/zigbeeHerdsman.ts @@ -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]]), }; diff --git a/test/vitest.config.mts b/test/vitest.config.mts index 28938861..45e8fae4 100644 --- a/test/vitest.config.mts +++ b/test/vitest.config.mts @@ -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 {