Compare commits

...
140 Commits
Author SHA1 Message Date
Koen Kanters aec59cb774 chore(dev): release 2.3.0 (#26991) 2025-05-01 20:16:51 +02:00
Koen Kanters 961a82e213 fix(ignore): update zigbee-herdsman-converters to 23.36.0 (#27276) 2025-05-01 17:39:38 +00:00
Nerivec c36a177916 fix(ignore): API typing fixes. (#27266) 2025-04-30 21:47:09 +02:00
Koen Kanters 97dbe1e8af fix(ignore): update zigbee-herdsman-converters to 23.35.0 (#27253) 2025-04-28 19:26:46 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 60808b4d36 chore(deps): bump the minor-patch group with 2 updates (#27251)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-28 21:05:05 +02:00
Nerivec 7cbf31a129 fix(ignore): Group API proper typing (#27243) 2025-04-28 21:01:14 +02:00
Nerivec 5304b0ceb7 fix(ignore): Add missing API request endpoints (#27242) 2025-04-27 15:24:58 +00:00
Nerivec 9a9f8d1438 fix(ignore): API exporting issues (#27211) 2025-04-26 21:40:17 +02:00
Koen Kanters a5135168c8 fix(ignore): update zigbee-herdsman-converters to 23.34.0 (#27224) 2025-04-26 19:39:54 +00:00
Koen Kanters c1b62e83ef fix(ignore): update zigbee-herdsman-converters to 23.33.0 (#27202) 2025-04-23 19:21:24 +00:00
andy-vdg 4acbdd7685 fix: Add icon example to settings schema (#27194) 2025-04-23 21:05:59 +02:00
Koen Kantersandnurikk 3ad20ddcdc fix(ignore): update zigbee2mqtt-frontend to 0.9.10 (#27191)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2025-04-22 19:32:43 +00:00
Koen Kanters 69f9002e31 fix: Add interview state (#27163) 2025-04-22 21:27:07 +02:00
Koenkk dfeaf22f70 fix(ignore): update zigbee-herdsman to 4.0.0 2025-04-22 21:27:07 +02:00
Koen Kanters faa6ad9ce8 fix(ignore): update zigbee-herdsman-converters to 23.32.0 (#27178) 2025-04-21 19:47:22 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 3e4bda0fda chore(deps-dev): bump the minor-patch group with 2 updates (#27175)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-21 21:20:52 +02:00
Koen Kanters 8195b2dd4a fix(ignore): update zigbee-herdsman-converters to 23.31.0 (#27162) 2025-04-19 18:56:05 +00:00
Koen Kanters ecc4696144 fix(ignore): update zigbee-herdsman-converters to 23.30.0 (#27154) 2025-04-19 18:30:03 +00:00
Koen Kanters 5e5bb18cf9 chore: Biome ignore package.json 2025-04-19 20:27:19 +02:00
Nerivec 6fbb8b5ca2 fix: Migrate to Biome (#27150) 2025-04-18 20:34:47 +02:00
Koen Kanters 130d041f75 fix(ignore): update zigbee-herdsman to 3.5.2 (#27147) 2025-04-18 07:54:12 +00:00
Koen Kanters 5a97643211 chore: merge master to dev 2025-04-17 20:24:45 +02:00
Koen Kanters 3a474187c6 chore: fix problem_report.yaml 2025-04-17 20:24:27 +02:00
Koen Kanters 8be1ae16ab fix(ignore): update zigbee-herdsman-converters to 23.29.0 (#27137) 2025-04-16 18:43:26 +00:00
Koen KantersandNerivec 1e2ad28267 fix: Use Alpine 3.21 for arm v6 and v7 (#25417)
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
2025-04-15 21:05:17 +02:00
Koen Kanters 8a9f631973 fix(ignore): update zigbee-herdsman-converters to 23.28.0 (#27130) 2025-04-15 19:01:12 +00:00
Koen Kanters b694dc1525 fix(ignore): update zigbee-herdsman-converters to 23.27.0 (#27121) 2025-04-14 20:30:22 +00:00
Koen Kantersandnurikk 0c0e5a3794 fix(ignore): update zigbee2mqtt-frontend to 0.9.9 (#27120)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2025-04-14 19:53:03 +00:00
Nerivec 36e2baf081 fix: Use Map for State (#27105) 2025-04-14 21:51:01 +02:00
NerivecandKoen Kanters 6e346073e7 feat: Allow scheduling OTA on device request (#26823)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2025-04-13 21:50:45 +02:00
Koenkk 06b53be5d8 fix(ignore): update zigbee-herdsman-converters to 23.26.0 2025-04-13 21:50:45 +02:00
Koen Kanters 12f858fbfe chore: merge master to dev 2025-04-13 21:34:42 +02:00
Koen Kanters 690d214caa chore: lint 2025-04-13 21:34:25 +02:00
Koen Kanters ead72c11f5 chore: merge master to dev 2025-04-13 21:14:52 +02:00
Koen Kanters d1c9f88c1d chore: enable dependabot 2025-04-13 21:14:35 +02:00
Koen Kanters b4ce65bb41 fix(ignore): update dependencies (#27094) 2025-04-13 20:53:09 +02:00
Nerivec e3d214ba9c fix: Guard against ZHC throwing (#27093) 2025-04-13 20:52:52 +02:00
Nerivec 8df5a484fc feat: Availability improvements (#26811) 2025-04-13 20:49:28 +02:00
Nerivec fc31e0af0a fix(ignore): partial cleanup for biome move (#27076) 2025-04-12 14:02:47 +02:00
Koen Kanters b8cc120da3 fix(ignore): update zigbee-herdsman-converters to 23.25.0 (#27075) 2025-04-10 19:52:04 +00:00
Nerivec 80502da80f fix: OnEvent fixes (#27063) 2025-04-10 21:08:04 +02:00
Koen Kanters 8e547d06ec fix(ignore): update zigbee-herdsman-converters to 23.24.0 (#27061) 2025-04-09 20:32:20 +00:00
[pʲɵs] 7fb10b1dcb fix: Do not call start() on extensions twice when starting (#27032) 2025-04-09 21:31:33 +02:00
Koen Kanters 850f0820ac fix: Don't allow publishing wildcards in MQTT topic (#27025) 2025-04-08 20:26:22 +02:00
Koen Kanters 17adc44a63 fix(ignore): update zigbee-herdsman-converters to 23.23.0 (#27036) 2025-04-07 19:50:54 +00:00
Koen Kanters 7d8232dc66 chore: merge master to dev 2025-04-06 21:22:59 +02:00
Koen Kanters a2debc08b4 chore: update problem report template 2025-04-06 21:22:43 +02:00
Koen Kanters 6949bbb883 fix(ignore): update zigbee-herdsman to 3.5.1 (#27024) 2025-04-06 19:20:39 +00:00
Koen Kanters 2297b81640 fix(ignore): update zigbee-herdsman-converters to 23.22.0 (#27023) 2025-04-06 19:13:07 +00:00
Koen Kanters d3bf771de0 fix(ignore): update dependencies (#27022) 2025-04-06 21:03:14 +02:00
Koen Kanters a426bf01ea chore: merge master to dev 2025-04-06 20:52:55 +02:00
Koen Kanters f8221ec2da chore: fix update-dependencies 2025-04-06 20:52:37 +02:00
Koen Kantersandnurikk d27ff76921 fix(ignore): update zigbee2mqtt-frontend to 0.9.8 (#27018)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2025-04-06 14:01:41 +00:00
Koen Kanters 20be4054c1 fix(ignore): update zigbee-herdsman-converters to 23.21.0 (#27001) 2025-04-05 12:06:03 +00:00
Koen Kanters 23e71aab14 fix(ignore): update zigbee-herdsman to 3.5.0 (#27000) 2025-04-05 12:00:44 +00:00
github-actions[bot] a34f547fa5 chore: promote to dev 2025-04-04 20:25:30 +00:00
Koen Kanters a14feef8c1 chore(dev): release 2.2.1 (#26934) 2025-04-04 22:12:32 +02:00
Koen Kanters d6a5128aa8 fix: Fix settings being overwriting when env var is set to a ref (#26988) 2025-04-04 21:57:11 +02:00
Koen Kanters 9d0ee03f38 fix(ignore): update zigbee-herdsman-converters to 23.20.1 (#26989) 2025-04-04 19:37:12 +00:00
Koen Kanters 37927e698a fix(ignore): update zigbee-herdsman-converters to 23.20.0 (#26978) 2025-04-03 20:27:14 +00:00
Koen Kantersandnurikk 89cdb1f180 fix(ignore): update zigbee2mqtt-frontend to 0.9.7 (#26976)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2025-04-03 18:41:06 +00:00
Koen Kanters e90afb286a fix(ignore): update zigbee-herdsman-converters to 23.19.1 (#26961) 2025-04-02 20:07:16 +00:00
Koen Kantersandnurikk fb4d01ebce fix(ignore): update zigbee2mqtt-frontend to 0.9.6 (#26959)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2025-04-02 21:29:10 +02:00
Nerivec 9e481fe5e5 fix: Fix issue with some settings when migrating from 1.x.x to 2.2.x (#26947) 2025-04-02 19:54:00 +02:00
Koen Kanters 0b7d0039f7 fix(ignore): update zigbee-herdsman-converters to 23.19.0 (#26938) 2025-04-01 20:13:05 +00:00
Koen Kanters 5257c7eae1 chore: prettier ignore release-notes.md 2025-04-01 21:06:04 +02:00
github-actions[bot] 952116d327 chore: promote to dev 2025-04-01 19:00:47 +00:00
Koen Kanters c5c07e7dfe chore(dev): release 2.2.0 (#26565) 2025-04-01 20:43:39 +02:00
Koen Kanters e02bbdcf0b fix(ignore): update dependencies (#26911) 2025-04-01 20:37:25 +02:00
Koen Kanters 69f0840feb fix(ignore): update zigbee-herdsman-converters to 23.18.0 (#26933) 2025-04-01 18:30:07 +00:00
Koen Kanters ea04f19709 fix(ignore): update zigbee-herdsman-converters to 23.17.0 (#26925) 2025-03-31 19:14:49 +00:00
Koen Kanters 9fe38b1677 fix(ignore): update zigbee-herdsman to 3.4.11 (#26924) 2025-03-31 18:50:39 +00:00
Koen Kanters 6e6a989159 fix(ignore): update zigbee-herdsman-converters to 23.16.0 (#26918) 2025-03-30 19:59:11 +00:00
Koen Kanters b52cfd87c3 fix(ignore): update zigbee-herdsman to 3.4.10 (#26908) 2025-03-29 17:23:23 +00:00
Koen Kanters 516425b44b fix(ignore): update zigbee-herdsman-converters to 23.15.0 (#26900) 2025-03-28 19:39:14 +00:00
Koen Kanters 6a65769060 fix(ignore): update zigbee-herdsman-converters to 23.14.0 (#26886) 2025-03-27 19:27:28 +00:00
Koen Kanters 0a41af5dd2 fix(ignore): update zigbee-herdsman to 3.4.9 (#26877) 2025-03-26 22:18:27 +00:00
clockbrain 267c399047 fix: Add device name to no converter available error message (#26867) 2025-03-26 21:45:33 +01:00
Koen Kanters f824295b7e fix(ignore): update zigbee-herdsman to 3.4.8 (#26866) 2025-03-25 22:50:21 +00:00
Koen Kanters 341c453657 fix(ignore): Fix HA addon onboarding (#26862) 2025-03-25 22:06:00 +01:00
Koen Kanters c5c7f70ad6 fix(ignore): update zigbee-herdsman-converters to 23.13.0 (#26861) 2025-03-25 19:41:18 +00:00
Koen Kanters 440e3a0094 fix(ignore): update zigbee-herdsman to 3.4.7 (#26859) 2025-03-25 18:50:46 +00:00
Koen Kanters 53e8226f0a fix(ignore): update zigbee-herdsman-converters to 23.12.0 (#26835) 2025-03-23 21:06:27 +00:00
Koen Kanters 7a1621a906 fix(ignore): fix EACCES: permission denied, mkdir '/app/dist/external_converters' 2025-03-23 22:02:17 +01:00
Koen Kanters 9e0c84beed fix(ignore): update zigbee-herdsman to 3.4.6 (#26834) 2025-03-23 20:59:26 +00:00
Koen Kanters 5f9b49d66f Merge remote-tracking branch 'origin/master' into dev 2025-03-23 21:54:59 +01:00
Koen Kanters 93d0ee44ba fix(ignore): update dependencies (#26833) 2025-03-23 21:46:52 +01:00
Koen Kanters fc35ac5737 chore: fix update-dependencies 2025-03-23 21:44:40 +01:00
Koen Kanters 2b90d7b3b9 fix(ignore): update zigbee-herdsman to 3.4.5 (#26826) 2025-03-22 19:24:20 +00:00
Nerivec f2a1c9a2be fix(gnore): Onboarding fixes (#26824) 2025-03-22 16:11:06 +01:00
Koen Kanters 0714582eb9 fix(ignore): update zigbee-herdsman to 3.4.4 (#26821) 2025-03-21 21:46:26 +00:00
Koen Kanters 5288549744 fix(ignore): update zigbee-herdsman-converters to 23.11.0 (#26820) 2025-03-21 20:36:17 +00:00
Nerivec 05d6f08a0d feat: Onboarding (#26608) 2025-03-21 21:30:19 +01:00
Koen Kanters 6f1266fd0e chore: remove docker-compose.yml 2025-03-21 21:10:30 +01:00
Koen Kantersandnurikk 9792466aac fix(ignore): update zigbee2mqtt-frontend to 0.9.5 (#26814)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2025-03-21 10:52:52 +00:00
Koen Kanters 52c255bcc2 fix(ignore): update zigbee-herdsman-converters to 23.10.0 (#26792) 2025-03-20 20:35:53 +00:00
Nerivec badf107991 feat: Use dynamic import for external JS (#26522) 2025-03-20 21:08:32 +01:00
Simon f872a5180c feat: Frontend: support filtering notifications (#26805) 2025-03-20 20:59:58 +01:00
Koen Kanters 4f2b052016 fix(ignore): update zigbee-herdsman to 3.4.3 (#26802) 2025-03-19 20:30:37 +00:00
Koen Kanters ef5b3de5e2 chore: enable provenance for release Docker image 2025-03-18 21:20:07 +01:00
Koen Kanters ff7b09eb99 chore: fix provenance 2025-03-18 21:16:36 +01:00
Koen Kanters 1baafbb6cf chore: enable provenance for NPM package 2025-03-18 20:54:49 +01:00
Koen Kanters 17ce9f4c52 chore: enable provenance for NPM package 2025-03-18 20:52:05 +01:00
Koen Kanters e6ce7ecdc1 chore: disable automatic pnpm updates 2025-03-18 20:50:24 +01:00
Koen Kanters 36ba1350a9 chore: remove codeql (enable basic) 2025-03-18 20:45:50 +01:00
Koen Kanters 582bf7d2c6 fix(ignore): update zigbee-herdsman to 3.4.2 (#26789) 2025-03-18 19:18:19 +00:00
Koen Kanters 46fd04364d fix(ignore): update zigbee-herdsman to 3.4.1 (#26779) 2025-03-17 20:22:38 +00:00
Alexander D. Kanevskiy 747f6ac476 fix: Home Assistant: fix electrical measurements sensors category (#26769) 2025-03-17 21:03:05 +01:00
Ömer Güven 6e781eafae fix(ignore): Make update check less verbose (#26774) 2025-03-17 20:43:56 +01:00
Koen Kanters 325199d445 fix(ignore): update zigbee-herdsman-converters to 23.8.0 (#26761) 2025-03-16 22:16:34 +01:00
Koen Kanters e602dc849a fix(ignore): update dependencies (#26751) 2025-03-16 22:09:37 +01:00
Koen Kanters ad0a222df1 Merge branch 'master' into dev 2025-03-16 21:58:34 +01:00
Koen Kanters 6d6372ccf0 chore: Skip updating dependencies on forks 2025-03-16 21:27:40 +01:00
NerivecandKoen Kanters 92955c02a4 feat: Support ZigBee on Host adapter (#26742)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2025-03-15 08:36:52 +01:00
Koen Kanters e5a80e3b1b fix(ignore): update zigbee-herdsman to 3.4.0 (#26744) 2025-03-15 07:26:13 +00:00
Ömer Güven fb48bd0cb9 fix(ignore): Fix update check (#26723) 2025-03-15 08:09:55 +01:00
Nerivec db578d9d11 fix: Use dynamic import for optional extensions (#26735) 2025-03-15 08:07:56 +01:00
Koen Kanters 8d2ef6df06 fix(ignore): update zigbee-herdsman-converters to 23.7.0 (#26733) 2025-03-13 19:13:26 +00:00
Koen Kanters 5db115057e fix(ignore): Module NodeNext (#26719) 2025-03-12 21:37:32 +01:00
Koen Kanters be722a0500 fix(ignore): Move to Node16 module (#26717) 2025-03-12 20:01:11 +00:00
BlueMöhre e3985002c2 fix: Fix bridge offline message format for frontend (#26707) 2025-03-12 20:23:40 +01:00
Koen Kanters fcbb355f47 Revert "fix(ignore): Move to Node16 module (#26704)"
This reverts commit ea5e0d47a5.
2025-03-12 07:25:37 +01:00
Koen Kanters 06ba62b833 fix(ignore): update zigbee-herdsman-converters to 23.6.0 (#26691) 2025-03-11 21:55:32 +01:00
Koen Kanters ea5e0d47a5 fix(ignore): Move to Node16 module (#26704) 2025-03-11 21:51:04 +01:00
Koen Kanters 8378949e1d fix(ignore): update zigbee-herdsman to 3.3.2 (#26702) 2025-03-11 19:52:35 +00:00
Koen Kanters 78cde99469 chore: Small improvements to CI pipelines (#26690) 2025-03-10 21:09:20 +01:00
Koen Kanters a5195a6eee fix(ignore): update zigbee-herdsman to 3.3.1 (#26674) 2025-03-09 19:32:28 +00:00
Koen Kanters fb9816659b fix(ignore): update dependencies (#26662) 2025-03-09 20:22:16 +01:00
Nerivec f6f7ba68c9 fix: Write ZIGBEE2MQTT_CONFIG_* envs to yaml as overrides (#26653) 2025-03-08 14:32:52 +01:00
Ömer Güven 1b6ad46462 fix: Check if new commits are available in update.sh (#26631) 2025-03-08 14:14:25 +01:00
Koen Kanters ad32fec1d0 fix(ignore): Fix tests failing when zhc is linked via pnpm link (#26630) 2025-03-06 20:27:17 +00:00
Koen Kanters c603f42c88 fix(ignore): update zigbee-herdsman-converters to 23.4.0 (#26629) 2025-03-06 20:20:30 +00:00
Koen Kanters ed7e7c7e53 fix(ignore): update zigbee-herdsman to 3.3.0 (#26628) 2025-03-06 19:20:29 +00:00
Koen Kanters 3941692665 fix(ignore): update zigbee-herdsman-converters to 23.3.0 (#26598) 2025-03-03 20:39:41 +00:00
Nerivec 8775ceb0c1 fix: General cleanup (#26595) 2025-03-03 21:12:57 +01:00
11fb858ff2 fix(ignore): update dependencies (#26554)
Co-authored-by: Simon Rettberg <srett@users.noreply.github.com>
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
2025-03-02 22:21:46 +01:00
Koen Kanters 4fa794a639 fix(ignore): Reduce chaining in homeassistant.ts (#26582) 2025-03-02 20:47:23 +00:00
Koen Kanters 1fe2d8dae6 fix(ignore): Remove uncessary extensions spread (#26581) 2025-03-02 20:30:36 +00:00
bfeac07c4c fix: Implement systemd-notify directly (#26456)
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2025-03-02 08:59:28 +01:00
github-actions[bot] b6c0f4e5db chore: promote to dev 2025-03-02 07:58:15 +00:00
113 changed files with 14670 additions and 11730 deletions
+6 -3
View File
@@ -6,9 +6,10 @@ body:
attributes:
value: |
**IMPORTANT:** Before submitting:
- Zigbee2MQTT fails to start? Read [this](https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start.html)
- Raspberry Pi users? Make sure to check [this](https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start_crashes-runtime.html#raspberry-pi-users-use-a-good-power-supply)
- You read the [FAQ](https://www.zigbee2mqtt.io/guide/faq/)
- Are you using an EZSP adapter (e.g. Dongle-E/SkyConnect)? Try the [new driver](https://github.com/Koenkk/zigbee2mqtt/discussions/21462)
- Zigbee2MQTT fails to start? Read [this](https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start.html)
- Make sure the bug also occurs in the [dev branch](https://www.zigbee2mqtt.io/advanced/more/switch-to-dev-branch.html)
- Make sure you are using the [latest firmware](https://www.zigbee2mqtt.io/guide/adapters/#recommended) on your adapter
- The issue has not been [reported already](https://github.com/Koenkk/zigbee2mqtt/issues)
@@ -58,11 +59,13 @@ body:
placeholder: Electrolama zig-a-zig-ah! (zzh!), Slaesh's CC2652RB stick, ...
validations:
required: true
- type: input
- type: textarea
id: setup
attributes:
label: Setup
description: How do you run Z2M (plain, add-on...) and on what machine (Pi, x86-64, containerized...)?
description: |-
How do you run Z2M (plain, add-on...) and on what machine (Pi, x86-64, containerized...)?
When running on Linux also include output of: `uname -a && cat /etc/issue.net`
placeholder: Add-on on Home Assistant OS on Intel NUC, Plain on Docker container, ...
validations:
required: true
+9 -9
View File
@@ -1,13 +1,13 @@
version: 2
updates:
- package-ecosystem: 'github-actions'
directory: '/'
- package-ecosystem: npm
directory: /
schedule:
interval: 'daily'
target-branch: dev
- package-ecosystem: 'docker'
directory: '/docker'
schedule:
interval: 'daily'
interval: weekly
target-branch: dev
groups:
minor-patch:
applies-to: version-updates
update-types:
- minor
- patch
+10 -9
View File
@@ -1,4 +1,4 @@
name: ci
name: CI
on: [pull_request, push]
@@ -9,6 +9,8 @@ permissions:
jobs:
ci:
runs-on: ubuntu-latest
permissions:
id-token: write
steps:
- uses: actions/checkout@v4
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
@@ -30,14 +32,12 @@ jobs:
- name: Install dependencies
run: pnpm i --frozen-lockfile
- name: Check
run: pnpm run check
- name: Build
run: pnpm run build
- name: Lint
run: |
pnpm run pretty:check
pnpm run eslint
- name: Test
run: pnpm run test:coverage
@@ -102,7 +102,6 @@ jobs:
with:
context: .
file: docker/Dockerfile
provenance: false
platforms: linux/arm64/v8,linux/amd64,linux/arm/v6,linux/arm/v7,linux/riscv64,linux/386
tags: ${{ steps.meta.outputs.tags }}
push: true
@@ -116,6 +115,7 @@ jobs:
run: pnpm publish --no-git-checks
env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN }}
NPM_CONFIG_PROVENANCE: true
- name: 'dev: Trigger zigbee2mqtt/hassio-zigbee2mqtt build'
if: github.ref == 'refs/heads/dev' && github.event_name == 'push'
@@ -180,6 +180,7 @@ jobs:
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
@@ -188,8 +189,8 @@ jobs:
cache: pnpm
- name: Install dependencies
# --ignore-scripts prevents the serialport build which often fails on Windows
run: pnpm i --frozen-lockfile --ignore-scripts
# --ignore-scripts prevents build on Windows (only for unix-dgram, so doesn't matter, others have pre-builds)
run: pnpm i --frozen-lockfile ${{ matrix.os == 'windows-latest' && '--ignore-scripts' || '' }}
- name: Build
run: pnpm run build
-92
View File
@@ -1,92 +0,0 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: 'CodeQL'
on:
push:
branches: ['master', 'dev']
pull_request:
branches: ['master', 'dev']
schedule:
- cron: '15 2 * * 2'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
# Runner size impacts CodeQL analysis time. To learn more, please see:
# - https://gh.io/recommended-hardware-resources-for-running-codeql
# - https://gh.io/supported-runners-and-hardware-resources
# - https://gh.io/using-larger-runners (GitHub.com only)
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
permissions:
# required for all workflows
security-events: write
# required to fetch internal or private CodeQL packs
packages: read
# only required for workflows in private repositories
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: javascript-typescript
build-mode: none
# CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
# Use `c-cpp` to analyze code written in C, C++ or both
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# If the analyze step fails for one of the languages you are analyzing with
# "We were unable to automatically build your code", modify the matrix above
# to set the build mode to "manual" for that language. Then modify this step
# to build your code.
# ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- if: matrix.build-mode == 'manual'
shell: bash
run: |
echo 'If you are using a "manual" build mode for one or more of the' \
'languages you are analyzing, replace this with the commands to build' \
'your code, for example:'
echo ' make bootstrap'
echo ' make release'
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: '/language:${{matrix.language}}'
@@ -1,4 +1,5 @@
name: deps-review
name: Dependency review
on: [pull_request]
permissions:
@@ -6,7 +6,7 @@ on:
- master
jobs:
fail_pr:
fail-pr-to-master:
runs-on: ubuntu-latest
steps:
- name: Fail PR to master
@@ -1,7 +1,7 @@
on:
workflow_dispatch:
name: ghcr-cleanup
name: GHCR cleanup
permissions: {}
jobs:
@@ -1,4 +1,4 @@
name: merge-master-to-dev
name: Merge master to dev
on:
push:
@@ -6,7 +6,7 @@ on:
- master
jobs:
merge_master_to_dev:
merge-master-to-dev:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -7,7 +7,7 @@ permissions:
contents: write
pull-requests: write
name: release-please
name: Release Please
jobs:
release-please:
+1 -1
View File
@@ -1,4 +1,4 @@
name: stale
name: Stale
on:
schedule:
@@ -1,17 +1,17 @@
name: Update dependencies
on:
workflow_dispatch:
schedule:
- cron: '0 0 * * 0'
permissions:
contents: read
contents: write
pull-requests: write
jobs:
update_deps:
permissions:
contents: write # for peter-evans/create-pull-request to create branch
pull-requests: write # for peter-evans/create-pull-request to create a PR
update-dependencies:
if: github.repository == 'Koenkk/zigbee2mqtt'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -24,10 +24,19 @@ jobs:
node-version: 22
cache: pnpm
- run: |
# Disable for now as pnpm updates through corepack
# don't work well yet for bare-metal installations.
# pnpm self-update
pnpm up --latest
- uses: peter-evans/create-pull-request@v7
id: cpr
with:
commit-message: 'fix(ignore): update dependencies'
branch: 'deps/all'
title: 'fix(ignore): update dependencies'
token: ${{ secrets.GH_TOKEN }}
- uses: peter-evans/create-or-update-comment@v4
if: ${{ steps.cpr.outputs.pull-request-number }}
with:
issue-number: ${{ steps.cpr.outputs.pull-request-number }}
body: 'CC: @Koenkk'
@@ -2,11 +2,11 @@ on:
repository_dispatch:
types: update_dep
name: update-dep
name: Update dependency
permissions: {}
jobs:
update_dep:
update-dependency:
permissions:
contents: write
pull-requests: write
-2
View File
@@ -1,2 +0,0 @@
pnpm-lock.yaml
CHANGELOG.md
-28
View File
@@ -1,28 +0,0 @@
{
"semi": true,
"trailingComma": "all",
"singleQuote": true,
"printWidth": 150,
"bracketSpacing": false,
"endOfLine": "lf",
"tabWidth": 4,
"importOrder": [
"^[./]*/mocks",
"",
"<TYPES>^(node:)",
"",
"<TYPES>",
"",
"<TYPES>^[.]",
"",
"<BUILTIN_MODULES>",
"",
"<THIRD_PARTY_MODULES>",
"",
"^zigbee",
"",
"^[.]"
],
"importOrderParserPlugins": ["typescript", "decorators"],
"plugins": ["@ianvs/prettier-plugin-sort-imports"]
}
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "2.1.3"
".": "2.3.0"
}
+133
View File
@@ -1,5 +1,138 @@
# Changelog
## [2.3.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.2.1...2.3.0) (2025-05-01)
### Features
* Allow scheduling OTA on device request ([#26823](https://github.com/Koenkk/zigbee2mqtt/issues/26823)) ([6e34607](https://github.com/Koenkk/zigbee2mqtt/commit/6e346073e75685eed52da0d2e229ef68e073c76d))
* Availability improvements ([#26811](https://github.com/Koenkk/zigbee2mqtt/issues/26811)) ([8df5a48](https://github.com/Koenkk/zigbee2mqtt/commit/8df5a484fc3dd3881da217605426e80e017ff467))
### Bug Fixes
* Add icon example to settings schema ([#27194](https://github.com/Koenkk/zigbee2mqtt/issues/27194)) ([4acbdd7](https://github.com/Koenkk/zigbee2mqtt/commit/4acbdd7685a6cd3b6db99859b2ccd220aa60c3a2))
* Add interview state ([#27163](https://github.com/Koenkk/zigbee2mqtt/issues/27163)) ([69f9002](https://github.com/Koenkk/zigbee2mqtt/commit/69f9002e316bdb3d1489d1434d5e53d0f9efaf84))
* Do not call start() on extensions twice when starting ([#27032](https://github.com/Koenkk/zigbee2mqtt/issues/27032)) ([7fb10b1](https://github.com/Koenkk/zigbee2mqtt/commit/7fb10b1dcb5bce3485c53c331d6671dd81082209))
* Don't allow publishing wildcards in MQTT topic ([#27025](https://github.com/Koenkk/zigbee2mqtt/issues/27025)) ([850f082](https://github.com/Koenkk/zigbee2mqtt/commit/850f0820ac2eb8666b7fa91e0129815e1a67c27c))
* Guard against ZHC throwing ([#27093](https://github.com/Koenkk/zigbee2mqtt/issues/27093)) ([e3d214b](https://github.com/Koenkk/zigbee2mqtt/commit/e3d214ba9c66503ab023cce219e462b3da2f10ab))
* **ignore:** Add missing API request endpoints ([#27242](https://github.com/Koenkk/zigbee2mqtt/issues/27242)) ([5304b0c](https://github.com/Koenkk/zigbee2mqtt/commit/5304b0ceb713c534c8700d12e33d1f8dd8242eaf))
* **ignore:** API exporting issues ([#27211](https://github.com/Koenkk/zigbee2mqtt/issues/27211)) ([9a9f8d1](https://github.com/Koenkk/zigbee2mqtt/commit/9a9f8d143839e039fe50594848d658f630396752))
* **ignore:** API typing fixes. ([#27266](https://github.com/Koenkk/zigbee2mqtt/issues/27266)) ([c36a177](https://github.com/Koenkk/zigbee2mqtt/commit/c36a1779169ecf75d37d933f32bae75799630365))
* **ignore:** Group API proper typing ([#27243](https://github.com/Koenkk/zigbee2mqtt/issues/27243)) ([7cbf31a](https://github.com/Koenkk/zigbee2mqtt/commit/7cbf31a1296722a25b20fea561c2fedf04f22861))
* **ignore:** partial cleanup for biome move ([#27076](https://github.com/Koenkk/zigbee2mqtt/issues/27076)) ([fc31e0a](https://github.com/Koenkk/zigbee2mqtt/commit/fc31e0af0a3c1592c2be9057a66287c60fa28a75))
* **ignore:** update dependencies ([#27022](https://github.com/Koenkk/zigbee2mqtt/issues/27022)) ([d3bf771](https://github.com/Koenkk/zigbee2mqtt/commit/d3bf771de0365a2fb060d81ef07daccc6151abbf))
* **ignore:** update dependencies ([#27094](https://github.com/Koenkk/zigbee2mqtt/issues/27094)) ([b4ce65b](https://github.com/Koenkk/zigbee2mqtt/commit/b4ce65bb41853ef177d910f7cb03131e861a0475))
* **ignore:** update zigbee-herdsman to 3.5.0 ([#27000](https://github.com/Koenkk/zigbee2mqtt/issues/27000)) ([23e71aa](https://github.com/Koenkk/zigbee2mqtt/commit/23e71aab14536f4f38f12c3c8cb96eb1cd7ccb60))
* **ignore:** update zigbee-herdsman to 3.5.1 ([#27024](https://github.com/Koenkk/zigbee2mqtt/issues/27024)) ([6949bbb](https://github.com/Koenkk/zigbee2mqtt/commit/6949bbb8838802573d2d1fd573fdba67132be446))
* **ignore:** update zigbee-herdsman to 3.5.2 ([#27147](https://github.com/Koenkk/zigbee2mqtt/issues/27147)) ([130d041](https://github.com/Koenkk/zigbee2mqtt/commit/130d041f75121918dfd605aa0959e20718c392ab))
* **ignore:** update zigbee-herdsman to 4.0.0 ([dfeaf22](https://github.com/Koenkk/zigbee2mqtt/commit/dfeaf22f703712e32dd1e27c96de7a374bc620c5))
* **ignore:** update zigbee-herdsman-converters to 23.21.0 ([#27001](https://github.com/Koenkk/zigbee2mqtt/issues/27001)) ([20be405](https://github.com/Koenkk/zigbee2mqtt/commit/20be4054c163c0b8fe420de606c163076be8c926))
* **ignore:** update zigbee-herdsman-converters to 23.22.0 ([#27023](https://github.com/Koenkk/zigbee2mqtt/issues/27023)) ([2297b81](https://github.com/Koenkk/zigbee2mqtt/commit/2297b8164094b648678c52884b7e20b62ccbe93c))
* **ignore:** update zigbee-herdsman-converters to 23.23.0 ([#27036](https://github.com/Koenkk/zigbee2mqtt/issues/27036)) ([17adc44](https://github.com/Koenkk/zigbee2mqtt/commit/17adc44a63c1bc237101c02b6f780b37f578903c))
* **ignore:** update zigbee-herdsman-converters to 23.24.0 ([#27061](https://github.com/Koenkk/zigbee2mqtt/issues/27061)) ([8e547d0](https://github.com/Koenkk/zigbee2mqtt/commit/8e547d06ec9981d9bfa95a51e12620a9e4a88c39))
* **ignore:** update zigbee-herdsman-converters to 23.25.0 ([#27075](https://github.com/Koenkk/zigbee2mqtt/issues/27075)) ([b8cc120](https://github.com/Koenkk/zigbee2mqtt/commit/b8cc120da3f29d8d4d4393d565540f2d24a3db8e))
* **ignore:** update zigbee-herdsman-converters to 23.26.0 ([06b53be](https://github.com/Koenkk/zigbee2mqtt/commit/06b53be5d891e01b498342e01502296dabeffa02))
* **ignore:** update zigbee-herdsman-converters to 23.27.0 ([#27121](https://github.com/Koenkk/zigbee2mqtt/issues/27121)) ([b694dc1](https://github.com/Koenkk/zigbee2mqtt/commit/b694dc1525608d1d9394a405193aae88f34d2ada))
* **ignore:** update zigbee-herdsman-converters to 23.28.0 ([#27130](https://github.com/Koenkk/zigbee2mqtt/issues/27130)) ([8a9f631](https://github.com/Koenkk/zigbee2mqtt/commit/8a9f63197319aad59e3028c86e4bb3da6b3e6a01))
* **ignore:** update zigbee-herdsman-converters to 23.29.0 ([#27137](https://github.com/Koenkk/zigbee2mqtt/issues/27137)) ([8be1ae1](https://github.com/Koenkk/zigbee2mqtt/commit/8be1ae16ab5c47f5aaec28a6dc328c3d3d80d409))
* **ignore:** update zigbee-herdsman-converters to 23.30.0 ([#27154](https://github.com/Koenkk/zigbee2mqtt/issues/27154)) ([ecc4696](https://github.com/Koenkk/zigbee2mqtt/commit/ecc46961446d17de540e48c76ab5b7c26f6c8c18))
* **ignore:** update zigbee-herdsman-converters to 23.31.0 ([#27162](https://github.com/Koenkk/zigbee2mqtt/issues/27162)) ([8195b2d](https://github.com/Koenkk/zigbee2mqtt/commit/8195b2dd4ab514ba4ba944499ce71e66c41d6855))
* **ignore:** update zigbee-herdsman-converters to 23.32.0 ([#27178](https://github.com/Koenkk/zigbee2mqtt/issues/27178)) ([faa6ad9](https://github.com/Koenkk/zigbee2mqtt/commit/faa6ad9ce883644eada82c04a9c11d41e62b4799))
* **ignore:** update zigbee-herdsman-converters to 23.33.0 ([#27202](https://github.com/Koenkk/zigbee2mqtt/issues/27202)) ([c1b62e8](https://github.com/Koenkk/zigbee2mqtt/commit/c1b62e83ef22ae5880e833b645586a19bba661af))
* **ignore:** update zigbee-herdsman-converters to 23.34.0 ([#27224](https://github.com/Koenkk/zigbee2mqtt/issues/27224)) ([a513516](https://github.com/Koenkk/zigbee2mqtt/commit/a5135168c81ae9e78eb84e66f2c4556a3e426442))
* **ignore:** update zigbee-herdsman-converters to 23.35.0 ([#27253](https://github.com/Koenkk/zigbee2mqtt/issues/27253)) ([97dbe1e](https://github.com/Koenkk/zigbee2mqtt/commit/97dbe1e8af826c115d591712447031321f68cb60))
* **ignore:** update zigbee-herdsman-converters to 23.36.0 ([#27276](https://github.com/Koenkk/zigbee2mqtt/issues/27276)) ([961a82e](https://github.com/Koenkk/zigbee2mqtt/commit/961a82e213889bee221abec0015ed45e171c6227))
* **ignore:** update zigbee2mqtt-frontend to 0.9.10 ([#27191](https://github.com/Koenkk/zigbee2mqtt/issues/27191)) ([3ad20dd](https://github.com/Koenkk/zigbee2mqtt/commit/3ad20ddcdc2995b4a0522b55adeaa25701e279fc))
* **ignore:** update zigbee2mqtt-frontend to 0.9.8 ([#27018](https://github.com/Koenkk/zigbee2mqtt/issues/27018)) ([d27ff76](https://github.com/Koenkk/zigbee2mqtt/commit/d27ff76921471ba6d88bfc14fa828665efaad5dd))
* **ignore:** update zigbee2mqtt-frontend to 0.9.9 ([#27120](https://github.com/Koenkk/zigbee2mqtt/issues/27120)) ([0c0e5a3](https://github.com/Koenkk/zigbee2mqtt/commit/0c0e5a3794260782ccee759d131762c8f148c0f5))
* Migrate to Biome ([#27150](https://github.com/Koenkk/zigbee2mqtt/issues/27150)) ([6fbb8b5](https://github.com/Koenkk/zigbee2mqtt/commit/6fbb8b5ca2d3d98240f23e0208edb2fd05826daa))
* OnEvent fixes ([#27063](https://github.com/Koenkk/zigbee2mqtt/issues/27063)) ([80502da](https://github.com/Koenkk/zigbee2mqtt/commit/80502da80f38bcbcd5ad9e0c83a0d1d77a269394))
* Use Alpine 3.21 for arm v6 and v7 ([#25417](https://github.com/Koenkk/zigbee2mqtt/issues/25417)) ([1e2ad28](https://github.com/Koenkk/zigbee2mqtt/commit/1e2ad28267030097cc9e2d53edc0cea17d328220))
* Use Map for State ([#27105](https://github.com/Koenkk/zigbee2mqtt/issues/27105)) ([36e2baf](https://github.com/Koenkk/zigbee2mqtt/commit/36e2baf081242c609907d616b2945a9ead9d6fdd))
## [2.2.1](https://github.com/Koenkk/zigbee2mqtt/compare/2.2.0...2.2.1) (2025-04-04)
### Bug Fixes
* Fix issue with some settings when migrating from 1.x.x to 2.2.x ([#26947](https://github.com/Koenkk/zigbee2mqtt/issues/26947)) ([9e481fe](https://github.com/Koenkk/zigbee2mqtt/commit/9e481fe5e57a8e664dd58cff7765b5365630c7c1))
* Fix settings being overwriting when env var is set to a ref ([#26988](https://github.com/Koenkk/zigbee2mqtt/issues/26988)) ([d6a5128](https://github.com/Koenkk/zigbee2mqtt/commit/d6a5128aa819a85e2a06e57307329d2b35938397))
* **ignore:** update zigbee-herdsman-converters to 23.19.0 ([#26938](https://github.com/Koenkk/zigbee2mqtt/issues/26938)) ([0b7d003](https://github.com/Koenkk/zigbee2mqtt/commit/0b7d0039f709215ac1a4895f15f06db41b4d4998))
* **ignore:** update zigbee-herdsman-converters to 23.19.1 ([#26961](https://github.com/Koenkk/zigbee2mqtt/issues/26961)) ([e90afb2](https://github.com/Koenkk/zigbee2mqtt/commit/e90afb286a15c5416fd1126864acdfc4c03c3a64))
* **ignore:** update zigbee-herdsman-converters to 23.20.0 ([#26978](https://github.com/Koenkk/zigbee2mqtt/issues/26978)) ([37927e6](https://github.com/Koenkk/zigbee2mqtt/commit/37927e698abcb242965423e9ee37623255feadab))
* **ignore:** update zigbee-herdsman-converters to 23.20.1 ([#26989](https://github.com/Koenkk/zigbee2mqtt/issues/26989)) ([9d0ee03](https://github.com/Koenkk/zigbee2mqtt/commit/9d0ee03f38ee6881dce3b17c219a6580303607ef))
* **ignore:** update zigbee2mqtt-frontend to 0.9.6 ([#26959](https://github.com/Koenkk/zigbee2mqtt/issues/26959)) ([fb4d01e](https://github.com/Koenkk/zigbee2mqtt/commit/fb4d01ebced7046919d6657c7609a196d39a1a6c))
* **ignore:** update zigbee2mqtt-frontend to 0.9.7 ([#26976](https://github.com/Koenkk/zigbee2mqtt/issues/26976)) ([89cdb1f](https://github.com/Koenkk/zigbee2mqtt/commit/89cdb1f180bb343b56a50c94dc6a23dda1345582))
## [2.2.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.1.3...2.2.0) (2025-04-01)
### Features
* Frontend: support filtering notifications ([#26805](https://github.com/Koenkk/zigbee2mqtt/issues/26805)) ([f872a51](https://github.com/Koenkk/zigbee2mqtt/commit/f872a5180c5618b7d3e7c0d11f1283a454dcbc6b))
* Onboarding ([#26608](https://github.com/Koenkk/zigbee2mqtt/issues/26608)) ([05d6f08](https://github.com/Koenkk/zigbee2mqtt/commit/05d6f08a0dd6a5097b7d19b8de3b39e37e9a8533))
* Support ZigBee on Host adapter ([#26742](https://github.com/Koenkk/zigbee2mqtt/issues/26742)) ([92955c0](https://github.com/Koenkk/zigbee2mqtt/commit/92955c02a4de09cbed79249db8bf0e6529423b93))
* Use dynamic import for external JS ([#26522](https://github.com/Koenkk/zigbee2mqtt/issues/26522)) ([badf107](https://github.com/Koenkk/zigbee2mqtt/commit/badf1079919baa06f160a42c34e9f742a9b584c9))
### Bug Fixes
* Add device name to no converter available error message ([#26867](https://github.com/Koenkk/zigbee2mqtt/issues/26867)) ([267c399](https://github.com/Koenkk/zigbee2mqtt/commit/267c3990474dcc646e6987a108867a54b8b940db))
* Check if new commits are available in update.sh ([#26631](https://github.com/Koenkk/zigbee2mqtt/issues/26631)) ([1b6ad46](https://github.com/Koenkk/zigbee2mqtt/commit/1b6ad46462681b8f223a6de54c5996d0e8f66f64))
* Fix bridge offline message format for frontend ([#26707](https://github.com/Koenkk/zigbee2mqtt/issues/26707)) ([e398500](https://github.com/Koenkk/zigbee2mqtt/commit/e3985002c2bf374c2edd82e95aaa2da7c920b464))
* General cleanup ([#26595](https://github.com/Koenkk/zigbee2mqtt/issues/26595)) ([8775ceb](https://github.com/Koenkk/zigbee2mqtt/commit/8775ceb0c1d2bac552a345751935d0191410bfa3))
* **gnore:** Onboarding fixes ([#26824](https://github.com/Koenkk/zigbee2mqtt/issues/26824)) ([f2a1c9a](https://github.com/Koenkk/zigbee2mqtt/commit/f2a1c9a2be052ec46d845299d73ff1495d4ea4e1))
* Home Assistant: fix electrical measurements sensors category ([#26769](https://github.com/Koenkk/zigbee2mqtt/issues/26769)) ([747f6ac](https://github.com/Koenkk/zigbee2mqtt/commit/747f6ac4767522a51fb5e5cc0b796b3a92243476))
* **ignore:** fix `EACCES: permission denied, mkdir '/app/dist/external_converters'` ([7a1621a](https://github.com/Koenkk/zigbee2mqtt/commit/7a1621a9060cd0109643655df2b2833e046158fa))
* **ignore:** Fix HA addon onboarding ([#26862](https://github.com/Koenkk/zigbee2mqtt/issues/26862)) ([341c453](https://github.com/Koenkk/zigbee2mqtt/commit/341c453657d74aef61e4a44b24d081fab9671d39))
* **ignore:** Fix tests failing when zhc is linked via `pnpm link` ([#26630](https://github.com/Koenkk/zigbee2mqtt/issues/26630)) ([ad32fec](https://github.com/Koenkk/zigbee2mqtt/commit/ad32fec1d02f2d43b7dc18bf38bbd14f7c97ffde))
* **ignore:** Fix update check ([#26723](https://github.com/Koenkk/zigbee2mqtt/issues/26723)) ([fb48bd0](https://github.com/Koenkk/zigbee2mqtt/commit/fb48bd0cb9a0311302d82a7f1b9cec21a585799e))
* **ignore:** Make update check less verbose ([#26774](https://github.com/Koenkk/zigbee2mqtt/issues/26774)) ([6e781ea](https://github.com/Koenkk/zigbee2mqtt/commit/6e781eafaefa54fad24ed857e1f96261ff3bebb7))
* **ignore:** Module `NodeNext` ([#26719](https://github.com/Koenkk/zigbee2mqtt/issues/26719)) ([5db1150](https://github.com/Koenkk/zigbee2mqtt/commit/5db115057ef733a33575c889e981696d2b9e74b9))
* **ignore:** Move to `Node16` `module` ([#26704](https://github.com/Koenkk/zigbee2mqtt/issues/26704)) ([ea5e0d4](https://github.com/Koenkk/zigbee2mqtt/commit/ea5e0d47a599ac66dcf4671e7a8b0687d8a08b85))
* **ignore:** Move to Node16 module ([#26717](https://github.com/Koenkk/zigbee2mqtt/issues/26717)) ([be722a0](https://github.com/Koenkk/zigbee2mqtt/commit/be722a0500892e6787d669ceb10c7d0bfc1c7e4c))
* **ignore:** Reduce chaining in `homeassistant.ts` ([#26582](https://github.com/Koenkk/zigbee2mqtt/issues/26582)) ([4fa794a](https://github.com/Koenkk/zigbee2mqtt/commit/4fa794a6392917d0140b94257bb7cd155fc25936))
* **ignore:** Remove uncessary extensions spread ([#26581](https://github.com/Koenkk/zigbee2mqtt/issues/26581)) ([1fe2d8d](https://github.com/Koenkk/zigbee2mqtt/commit/1fe2d8dae6ace27c4b297b7943ac5501c096e8ab))
* **ignore:** update dependencies ([#26554](https://github.com/Koenkk/zigbee2mqtt/issues/26554)) ([11fb858](https://github.com/Koenkk/zigbee2mqtt/commit/11fb858ff2819319f8aa4bd8a6b2f25944619a8e))
* **ignore:** update dependencies ([#26662](https://github.com/Koenkk/zigbee2mqtt/issues/26662)) ([fb98166](https://github.com/Koenkk/zigbee2mqtt/commit/fb9816659bec15d0a9a39f73d4900fc7586a378e))
* **ignore:** update dependencies ([#26751](https://github.com/Koenkk/zigbee2mqtt/issues/26751)) ([e602dc8](https://github.com/Koenkk/zigbee2mqtt/commit/e602dc849a1d17b9097ce06015ba587c59ba5006))
* **ignore:** update dependencies ([#26833](https://github.com/Koenkk/zigbee2mqtt/issues/26833)) ([93d0ee4](https://github.com/Koenkk/zigbee2mqtt/commit/93d0ee44ba4445711b7e43ab4bfa06e8ac266d68))
* **ignore:** update dependencies ([#26911](https://github.com/Koenkk/zigbee2mqtt/issues/26911)) ([e02bbdc](https://github.com/Koenkk/zigbee2mqtt/commit/e02bbdcf0bbf8577ab6e138c915f7e2ef1a32482))
* **ignore:** update zigbee-herdsman to 3.3.0 ([#26628](https://github.com/Koenkk/zigbee2mqtt/issues/26628)) ([ed7e7c7](https://github.com/Koenkk/zigbee2mqtt/commit/ed7e7c7e530010aeb44eb71e50f31145c7285c26))
* **ignore:** update zigbee-herdsman to 3.3.1 ([#26674](https://github.com/Koenkk/zigbee2mqtt/issues/26674)) ([a5195a6](https://github.com/Koenkk/zigbee2mqtt/commit/a5195a6eee9a3e852709c4b979c965dee33f2b54))
* **ignore:** update zigbee-herdsman to 3.3.2 ([#26702](https://github.com/Koenkk/zigbee2mqtt/issues/26702)) ([8378949](https://github.com/Koenkk/zigbee2mqtt/commit/8378949e1d9cdfb3d33bc771dc61594ca7ef3c05))
* **ignore:** update zigbee-herdsman to 3.4.0 ([#26744](https://github.com/Koenkk/zigbee2mqtt/issues/26744)) ([e5a80e3](https://github.com/Koenkk/zigbee2mqtt/commit/e5a80e3b1bf8ab5f693d248f2b6a90481bdb10fc))
* **ignore:** update zigbee-herdsman to 3.4.1 ([#26779](https://github.com/Koenkk/zigbee2mqtt/issues/26779)) ([46fd043](https://github.com/Koenkk/zigbee2mqtt/commit/46fd04364ded0b6f7c9fcea6f33a2b523971b389))
* **ignore:** update zigbee-herdsman to 3.4.10 ([#26908](https://github.com/Koenkk/zigbee2mqtt/issues/26908)) ([b52cfd8](https://github.com/Koenkk/zigbee2mqtt/commit/b52cfd87c3deedfa0d1d391ce4f6f64083389559))
* **ignore:** update zigbee-herdsman to 3.4.11 ([#26924](https://github.com/Koenkk/zigbee2mqtt/issues/26924)) ([9fe38b1](https://github.com/Koenkk/zigbee2mqtt/commit/9fe38b1677681b1f3b47ac6d9876fa7305572244))
* **ignore:** update zigbee-herdsman to 3.4.2 ([#26789](https://github.com/Koenkk/zigbee2mqtt/issues/26789)) ([582bf7d](https://github.com/Koenkk/zigbee2mqtt/commit/582bf7d2c6a21003e09892b569ef5fb115f5d1ab))
* **ignore:** update zigbee-herdsman to 3.4.3 ([#26802](https://github.com/Koenkk/zigbee2mqtt/issues/26802)) ([4f2b052](https://github.com/Koenkk/zigbee2mqtt/commit/4f2b052016f4d313dfc43d35aca986b682c173b0))
* **ignore:** update zigbee-herdsman to 3.4.4 ([#26821](https://github.com/Koenkk/zigbee2mqtt/issues/26821)) ([0714582](https://github.com/Koenkk/zigbee2mqtt/commit/0714582eb975c39ef0fb5205d7f2c76ce7de2a05))
* **ignore:** update zigbee-herdsman to 3.4.5 ([#26826](https://github.com/Koenkk/zigbee2mqtt/issues/26826)) ([2b90d7b](https://github.com/Koenkk/zigbee2mqtt/commit/2b90d7b3b974353c312dc00c26b6223be74e1272))
* **ignore:** update zigbee-herdsman to 3.4.6 ([#26834](https://github.com/Koenkk/zigbee2mqtt/issues/26834)) ([9e0c84b](https://github.com/Koenkk/zigbee2mqtt/commit/9e0c84beed18d13ec310d91b89e35eba46fe3817))
* **ignore:** update zigbee-herdsman to 3.4.7 ([#26859](https://github.com/Koenkk/zigbee2mqtt/issues/26859)) ([440e3a0](https://github.com/Koenkk/zigbee2mqtt/commit/440e3a00942dc8bb7a7b9b66efd825e15dccad6c))
* **ignore:** update zigbee-herdsman to 3.4.8 ([#26866](https://github.com/Koenkk/zigbee2mqtt/issues/26866)) ([f824295](https://github.com/Koenkk/zigbee2mqtt/commit/f824295b7efefb78d6f803a4291fb2a485694c96))
* **ignore:** update zigbee-herdsman to 3.4.9 ([#26877](https://github.com/Koenkk/zigbee2mqtt/issues/26877)) ([0a41af5](https://github.com/Koenkk/zigbee2mqtt/commit/0a41af5dd23d1806d703b878feaba25d90c2d016))
* **ignore:** update zigbee-herdsman-converters to 23.10.0 ([#26792](https://github.com/Koenkk/zigbee2mqtt/issues/26792)) ([52c255b](https://github.com/Koenkk/zigbee2mqtt/commit/52c255bcc2a37a7238afe675f7c521c0834e2d9d))
* **ignore:** update zigbee-herdsman-converters to 23.11.0 ([#26820](https://github.com/Koenkk/zigbee2mqtt/issues/26820)) ([5288549](https://github.com/Koenkk/zigbee2mqtt/commit/5288549744795296a9919828b1a85b1223edd755))
* **ignore:** update zigbee-herdsman-converters to 23.12.0 ([#26835](https://github.com/Koenkk/zigbee2mqtt/issues/26835)) ([53e8226](https://github.com/Koenkk/zigbee2mqtt/commit/53e8226f0aff0cdfba76f6f30672c0bd3d72d52d))
* **ignore:** update zigbee-herdsman-converters to 23.13.0 ([#26861](https://github.com/Koenkk/zigbee2mqtt/issues/26861)) ([c5c7f70](https://github.com/Koenkk/zigbee2mqtt/commit/c5c7f70ad6aaca083c4edb4dba783e60f8ec17a5))
* **ignore:** update zigbee-herdsman-converters to 23.14.0 ([#26886](https://github.com/Koenkk/zigbee2mqtt/issues/26886)) ([6a65769](https://github.com/Koenkk/zigbee2mqtt/commit/6a657690604daacc4a2cc1afa8407fd0d4dc9eaf))
* **ignore:** update zigbee-herdsman-converters to 23.15.0 ([#26900](https://github.com/Koenkk/zigbee2mqtt/issues/26900)) ([516425b](https://github.com/Koenkk/zigbee2mqtt/commit/516425b44b7f395db7bbed93699b989d6a8276be))
* **ignore:** update zigbee-herdsman-converters to 23.16.0 ([#26918](https://github.com/Koenkk/zigbee2mqtt/issues/26918)) ([6e6a989](https://github.com/Koenkk/zigbee2mqtt/commit/6e6a989159383a3534ce6d12ea8c9bdc9092d2bc))
* **ignore:** update zigbee-herdsman-converters to 23.17.0 ([#26925](https://github.com/Koenkk/zigbee2mqtt/issues/26925)) ([ea04f19](https://github.com/Koenkk/zigbee2mqtt/commit/ea04f197090e3a3bc52dc4eb4ecde65146484bfb))
* **ignore:** update zigbee-herdsman-converters to 23.18.0 ([#26933](https://github.com/Koenkk/zigbee2mqtt/issues/26933)) ([69f0840](https://github.com/Koenkk/zigbee2mqtt/commit/69f0840feb2f8a5332148cfe271d16e413a02ebf))
* **ignore:** update zigbee-herdsman-converters to 23.3.0 ([#26598](https://github.com/Koenkk/zigbee2mqtt/issues/26598)) ([3941692](https://github.com/Koenkk/zigbee2mqtt/commit/3941692665d96fee575995bb6a372bfb92e0a466))
* **ignore:** update zigbee-herdsman-converters to 23.4.0 ([#26629](https://github.com/Koenkk/zigbee2mqtt/issues/26629)) ([c603f42](https://github.com/Koenkk/zigbee2mqtt/commit/c603f42c889b5508a56f0f43df7d09dba909b0cd))
* **ignore:** update zigbee-herdsman-converters to 23.6.0 ([#26691](https://github.com/Koenkk/zigbee2mqtt/issues/26691)) ([06ba62b](https://github.com/Koenkk/zigbee2mqtt/commit/06ba62b8331d8d5db1696c47bd3b643449584b16))
* **ignore:** update zigbee-herdsman-converters to 23.7.0 ([#26733](https://github.com/Koenkk/zigbee2mqtt/issues/26733)) ([8d2ef6d](https://github.com/Koenkk/zigbee2mqtt/commit/8d2ef6df06bbfcc856a91ef592e8ac54f46a705c))
* **ignore:** update zigbee-herdsman-converters to 23.8.0 ([#26761](https://github.com/Koenkk/zigbee2mqtt/issues/26761)) ([325199d](https://github.com/Koenkk/zigbee2mqtt/commit/325199d4455adb32f912687827ed01d89e009bc1))
* **ignore:** update zigbee2mqtt-frontend to 0.9.5 ([#26814](https://github.com/Koenkk/zigbee2mqtt/issues/26814)) ([9792466](https://github.com/Koenkk/zigbee2mqtt/commit/9792466aac8b94747fe41fe47db766b7a7e93367))
* Implement systemd-notify directly ([#26456](https://github.com/Koenkk/zigbee2mqtt/issues/26456)) ([bfeac07](https://github.com/Koenkk/zigbee2mqtt/commit/bfeac07c4c38fb55db99d5f8204e85ec54552564))
* Use dynamic import for optional extensions ([#26735](https://github.com/Koenkk/zigbee2mqtt/issues/26735)) ([db578d9](https://github.com/Koenkk/zigbee2mqtt/commit/db578d9d1180c01c14f1b4707e779c28961eb5b8))
* Write `ZIGBEE2MQTT_CONFIG_*` envs to yaml as overrides ([#26653](https://github.com/Koenkk/zigbee2mqtt/issues/26653)) ([f6f7ba6](https://github.com/Koenkk/zigbee2mqtt/commit/f6f7ba68c9dd84ccdd82df0769b197756e42bbf7))
## [2.1.3](https://github.com/Koenkk/zigbee2mqtt/compare/2.1.2...2.1.3) (2025-03-02)
+1 -1
View File
@@ -107,7 +107,7 @@ Zigbee2MQTT is made up of three modules, each developed in its own Github projec
Zigbee2MQTT uses TypeScript (partially for now). Therefore after making changes to files in the `lib/` directory you need to recompile Zigbee2MQTT. This can be done by executing `pnpm run build`. For faster development instead of running `pnpm run build` you can run `pnpm run build-watch` in another terminal session, this will recompile as you change files.
Before running any of the commands, you'll first need to run `pnpm install --include=dev`.
Before submitting changes run `pnpm run test:coverage`, `pnpm run pretty:check` and `pnpm run eslint`
Before submitting changes run `pnpm run check:w` then `pnpm run test:coverage`.
## Supported devices
+85
View File
@@ -0,0 +1,85 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"formatter": {
"indentStyle": "space",
"indentWidth": 4,
"lineWidth": 150,
"bracketSpacing": false
},
"files": {
"ignore": ["package.json"]
},
"linter": {
"ignore": [],
"rules": {
"correctness": {
"noUnusedImports": "error"
},
"style": {
"noParameterAssign": "off",
"useThrowNewError": "error",
"useThrowOnlyError": "error",
"useNamingConvention": {
"level": "error",
"options": {
"strictCase": false,
"conventions": [
{
"selector": {
"kind": "objectLiteralProperty"
},
"formats": ["snake_case", "camelCase", "CONSTANT_CASE", "PascalCase"]
},
{
"selector": {
"kind": "const"
},
"formats": ["snake_case", "camelCase", "CONSTANT_CASE", "PascalCase"]
},
{
"selector": {
"kind": "typeProperty"
},
"formats": ["snake_case", "camelCase", "CONSTANT_CASE", "PascalCase"]
},
{
"selector": {
"kind": "enumMember"
},
"formats": ["CONSTANT_CASE", "PascalCase"]
}
]
}
}
},
"performance": {
"noDelete": "off"
},
"suspicious": {
"noConstEnum": "off",
"useAwait": "error"
}
}
},
"overrides": [
{
"include": ["test/**"],
"linter": {
"rules": {
"style": {
"noNonNullAssertion": "off",
"useNamingConvention": "off"
},
"suspicious": {
"noImplicitAnyLet": "off"
}
}
}
}
]
}
+3 -3
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env node
const path = require('path');
process.env['ZIGBEE2MQTT_DATA'] = process.env['ZIGBEE2MQTT_DATA'] || path.join(process.env['HOME'], '.z2m');
require('./index');
const path = require("node:path");
process.env.ZIGBEE2MQTT_DATA = process.env.ZIGBEE2MQTT_DATA || path.join(process.env.HOME, ".z2m");
require("./index");
+4 -9
View File
@@ -1,14 +1,6 @@
ARG TARGETPLATFORM
# Need to use Alpine 3.18.4 which uses Node 18 for arm/v6 and arm/v7, otherwise the build hangs.
# See https://github.com/nodejs/docker-node/issues/2077
FROM alpine:3.18.4 AS linux-arm-alpine
FROM alpine:3.21 AS linux-arm64-alpine
FROM alpine:3.21 AS linux-amd64-alpine
FROM alpine:3.21 AS linux-riscv64-alpine
FROM alpine:3.21 AS linux-386-alpine
FROM linux-${TARGETARCH}-alpine AS base
FROM alpine:3.21 AS base
ENV NODE_ENV=production
WORKDIR /app
@@ -43,6 +35,9 @@ LABEL org.opencontainers.image.version=${VERSION}
COPY --from=deps /app/node_modules ./node_modules
COPY dist ./dist
# To prevent `Error: EACCES: permission denied, mkdir '/app/dist/external_converters'`
# when running rootless.
RUN chmod -R 777 ./dist
COPY package.json LICENSE index.js data/configuration.example.yaml ./
COPY docker/docker-entrypoint.sh /usr/local/bin/
-14
View File
@@ -1,14 +0,0 @@
version: '3'
services:
zigbee2mqtt:
container_name: zigbee2mqtt
image: koenkk/zigbee2mqtt
volumes:
- ./data:/app/data
devices:
# CC251
#- /dev/ttyUSB_cc2531:/dev/ttyACM0
# CC2530 / GBAN GB2530S
#- /dev/ttyUSB_cc2530:/dev/ttyACM0
restart: always
network_mode: host
-5
View File
@@ -9,9 +9,4 @@ fi
echo "Using '$DATA' as data directory"
if [ ! -f "$DATA/configuration.yaml" ]; then
echo "Creating configuration file..."
cp /app/configuration.example.yaml "$DATA/configuration.yaml"
fi
exec "$@"
-32
View File
@@ -1,32 +0,0 @@
// @ts-check
import eslint from '@eslint/js';
import eslintConfigPrettier from 'eslint-config-prettier';
import tseslint from 'typescript-eslint';
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
{
languageOptions: {
parserOptions: {
project: true,
},
},
rules: {
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/ban-ts-comment': 'error',
'@typescript-eslint/explicit-function-return-type': 'error',
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unused-vars': 'error',
'array-bracket-spacing': ['error', 'never'],
'@typescript-eslint/return-await': ['error', 'always'],
'object-curly-spacing': ['error', 'never'],
'@typescript-eslint/no-floating-promises': 'error',
},
},
{
ignores: ['dist/', '**/*.js', '**/*.mjs'],
},
eslintConfigPrettier,
);
+55 -67
View File
@@ -1,30 +1,29 @@
const semver = require('semver');
const engines = require('./package.json').engines;
const fs = require('fs');
const os = require('os');
const path = require('path');
const {exec} = require('child_process');
const {rimrafSync} = require('rimraf');
require('source-map-support').install();
const semver = require("semver");
const engines = require("./package.json").engines;
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const {exec} = require("node:child_process");
require("source-map-support").install();
let controller;
let stopping = false;
let watchdog = process.env.Z2M_WATCHDOG != undefined;
const watchdog = process.env.Z2M_WATCHDOG != null;
let watchdogCount = 0;
let unsolicitedStop = false;
// csv in minutes, default: 1min, 5min, 15min, 30min, 60min
let watchdogDelays = [2000, 60000, 300000, 900000, 1800000, 3600000];
if (watchdog && process.env.Z2M_WATCHDOG !== 'default') {
if (/^(?:(?:[0-9]*[.])?[0-9]+)+(?:,?(?:[0-9]*[.])?[0-9]+)*$/.test(process.env.Z2M_WATCHDOG)) {
watchdogDelays = process.env.Z2M_WATCHDOG.split(',').map((v) => parseFloat(v) * 60000);
if (watchdog && process.env.Z2M_WATCHDOG !== "default") {
if (/^\d+(.\d+)?(,\d+(.\d+)?)*$/.test(process.env.Z2M_WATCHDOG)) {
watchdogDelays = process.env.Z2M_WATCHDOG.split(",").map((v) => Number.parseFloat(v) * 60000);
} else {
console.log(`Invalid watchdog delays (must use number-only CSV format representing minutes, example: 'Z2M_WATCHDOG=1,5,15,30,60'.`);
process.exit(1);
}
}
const hashFile = path.join(__dirname, 'dist', '.hash');
const hashFile = path.join(__dirname, "dist", ".hash");
async function triggerWatchdog(code) {
const delay = watchdogDelays[watchdogCount];
@@ -58,10 +57,16 @@ async function exit(code, restart = false) {
}
async function currentHash() {
const git = require('git-last-commit');
return await new Promise((resolve) => {
exec("git rev-parse --short=8 HEAD", (error, stdout) => {
const commitHash = stdout.trim();
return new Promise((resolve) => {
git.getLastCommit((err, commit) => (err ? resolve('unknown') : resolve(commit.shortHash)));
if (error || commitHash === "") {
resolve("unknown");
} else {
resolve(commitHash);
}
});
});
}
@@ -72,30 +77,30 @@ async function writeHash() {
}
async function build(reason) {
return new Promise((resolve, reject) => {
process.stdout.write(`Building Zigbee2MQTT... (${reason})`);
rimrafSync('dist');
process.stdout.write(`Building Zigbee2MQTT... (${reason})`);
return await new Promise((resolve, reject) => {
const env = {...process.env};
const _600mb = 629145600;
const mb600 = 629145600;
if (_600mb > os.totalmem() && !env.NODE_OPTIONS) {
if (mb600 > os.totalmem() && !env.NODE_OPTIONS) {
// Prevent OOM on tsc compile for system with low memory
// https://github.com/Koenkk/zigbee2mqtt/issues/12034
env.NODE_OPTIONS = '--max_old_space_size=256';
env.NODE_OPTIONS = "--max_old_space_size=256";
}
exec('pnpm run build', {env, cwd: __dirname}, async (err, stdout, stderr) => {
// clean build, prevent failures due to tsc incremental building
exec("pnpm run prepack", {env, cwd: __dirname}, (err) => {
if (err) {
process.stdout.write(', failed\n');
process.stdout.write(", failed\n");
if (err.code === 134) {
process.stderr.write('\n\nBuild failed; ran out-of-memory, free some memory (RAM) and start again\n\n');
process.stderr.write("\n\nBuild failed; ran out-of-memory, free some memory (RAM) and start again\n\n");
}
reject(err);
} else {
process.stdout.write(', finished\n');
process.stdout.write(", finished\n");
resolve();
}
});
@@ -104,58 +109,41 @@ async function build(reason) {
async function checkDist() {
if (!fs.existsSync(hashFile)) {
await build('initial build');
await build("initial build");
}
const distHash = fs.readFileSync(hashFile, 'utf-8');
const distHash = fs.readFileSync(hashFile, "utf8");
const hash = await currentHash();
if (hash !== 'unknown' && distHash !== hash) {
await build('hash changed');
if (hash !== "unknown" && distHash !== hash) {
await build("hash changed");
}
}
async function start() {
console.log(`Starting Zigbee2MQTT ${watchdog ? `with watchdog (${watchdogDelays})` : `without watchdog`}.`);
console.log(`Starting Zigbee2MQTT ${watchdog ? `with watchdog (${watchdogDelays})` : "without watchdog"}.`);
await checkDist();
const version = engines.node;
if (!semver.satisfies(process.version, version)) {
console.log(`\t\tZigbee2MQTT requires node version ${version}, you are running ${process.version}!\n`);
}
// Validate settings
const settings = require('./dist/util/settings');
settings.reRead();
// gc
{
const settingsMigration = require('./dist/util/settingsMigration');
const version = engines.node;
settingsMigration.migrateIfNecessary();
}
const errors = settings.validate();
if (errors.length > 0) {
unsolicitedStop = false;
console.log(`\n\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!`);
console.log(' READ THIS CAREFULLY\n');
console.log(`Refusing to start because configuration is not valid, found the following errors:`);
for (const error of errors) {
console.log(`- ${error}`);
if (!semver.satisfies(process.version, version)) {
console.log(`\t\tZigbee2MQTT requires node version ${version}, you are running ${process.version}!\n`);
}
console.log(`\nIf you don't know how to solve this, read https://www.zigbee2mqtt.io/guide/configuration`);
console.log(`\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n\n`);
const {onboard} = require("./dist/util/onboarding");
return exit(1);
const success = await onboard();
if (!success) {
unsolicitedStop = false;
return await exit(1);
}
}
const {Controller} = require('./dist/controller');
const {Controller} = require("./dist/controller");
controller = new Controller(restart, exit);
await controller.start();
@@ -184,17 +172,17 @@ async function handleQuit() {
}
}
if (require.main === module || require.main.filename.endsWith(path.sep + 'cli.js')) {
if (process.argv.length === 3 && process.argv[2] === 'writehash') {
if (require.main === module || require.main.filename.endsWith(`${path.sep}cli.js`)) {
if (process.argv.length === 3 && process.argv[2] === "writehash") {
writeHash();
} else {
process.on('SIGINT', handleQuit);
process.on('SIGTERM', handleQuit);
process.on("SIGINT", handleQuit);
process.on("SIGTERM", handleQuit);
start();
}
} else {
process.on('SIGINT', handleQuit);
process.on('SIGTERM', handleQuit);
process.on("SIGINT", handleQuit);
process.on("SIGTERM", handleQuit);
module.exports = {start};
}
+197 -143
View File
@@ -1,79 +1,46 @@
import type {IClientPublishOptions} from 'mqtt';
import type * as SdNotify from 'sd-notify';
import type {IClientPublishOptions} from "mqtt";
import type {Zigbee2MQTTAPI} from './types/api';
import type Extension from "./extension/extension";
import type {Zigbee2MQTTAPI} from "./types/api";
import assert from 'node:assert';
import bind from "bind-decorator";
import stringify from "json-stable-stringify-without-jsonify";
import bind from 'bind-decorator';
import stringify from 'json-stable-stringify-without-jsonify';
import {setLogger as zhSetLogger} from "zigbee-herdsman";
import {setLogger as zhcSetLogger} from "zigbee-herdsman-converters";
import {setLogger as zhSetLogger} from 'zigbee-herdsman';
import {setLogger as zhcSetLogger} from 'zigbee-herdsman-converters';
import EventBus from './eventBus';
import ExtensionAvailability from './extension/availability';
import ExtensionBind from './extension/bind';
import ExtensionBridge from './extension/bridge';
import ExtensionConfigure from './extension/configure';
import ExtensionExternalConverters from './extension/externalConverters';
import ExtensionExternalExtensions from './extension/externalExtensions';
import EventBus from "./eventBus";
// Extensions
import ExtensionFrontend from './extension/frontend';
import ExtensionGroups from './extension/groups';
import ExtensionHomeAssistant from './extension/homeassistant';
import ExtensionNetworkMap from './extension/networkMap';
import ExtensionOnEvent from './extension/onEvent';
import ExtensionOTAUpdate from './extension/otaUpdate';
import ExtensionPublish from './extension/publish';
import ExtensionReceive from './extension/receive';
import MQTT from './mqtt';
import State from './state';
import logger from './util/logger';
import * as settings from './util/settings';
import utils from './util/utils';
import Zigbee from './zigbee';
type SdNotifyType = typeof SdNotify;
const AllExtensions = [
ExtensionPublish,
ExtensionReceive,
ExtensionNetworkMap,
ExtensionHomeAssistant,
ExtensionConfigure,
ExtensionBridge,
ExtensionGroups,
ExtensionBind,
ExtensionOnEvent,
ExtensionOTAUpdate,
ExtensionExternalConverters,
ExtensionFrontend,
ExtensionExternalExtensions,
ExtensionAvailability,
];
type ExtensionArgs = [
Zigbee,
MQTT,
State,
PublishEntityState,
EventBus,
enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
restartCallback: () => Promise<void>,
addExtension: (extension: Extension) => Promise<void>,
];
import ExtensionAvailability from "./extension/availability";
import ExtensionBind from "./extension/bind";
import ExtensionBridge from "./extension/bridge";
import ExtensionConfigure from "./extension/configure";
import ExtensionExternalConverters from "./extension/externalConverters";
import ExtensionExternalExtensions from "./extension/externalExtensions";
import ExtensionGroups from "./extension/groups";
import ExtensionNetworkMap from "./extension/networkMap";
import ExtensionOnEvent from "./extension/onEvent";
import ExtensionOTAUpdate from "./extension/otaUpdate";
import ExtensionPublish from "./extension/publish";
import ExtensionReceive from "./extension/receive";
import Mqtt from "./mqtt";
import State from "./state";
import logger from "./util/logger";
import {initSdNotify} from "./util/sd-notify";
import * as settings from "./util/settings";
import utils from "./util/utils";
import Zigbee from "./zigbee";
export class Controller {
private eventBus: EventBus;
private zigbee: Zigbee;
private state: State;
private mqtt: MQTT;
private mqtt: Mqtt;
private restartCallback: () => Promise<void>;
private exitCallback: (code: number, restart: boolean) => Promise<void>;
private extensions: Extension[];
private extensionArgs: ExtensionArgs;
private sdNotify: SdNotifyType | undefined;
public readonly extensions: Set<Extension>;
public readonly extensionArgs: ConstructorParameters<typeof Extension>;
private sdNotify: Awaited<ReturnType<typeof initSdNotify>>;
constructor(restartCallback: () => Promise<void>, exitCallback: (code: number, restart: boolean) => Promise<void>) {
logger.init();
@@ -81,7 +48,7 @@ export class Controller {
zhcSetLogger(logger);
this.eventBus = new EventBus();
this.zigbee = new Zigbee(this.eventBus);
this.mqtt = new MQTT(this.eventBus);
this.mqtt = new Mqtt(this.eventBus);
this.state = new State(this.eventBus, this.zigbee);
this.restartCallback = restartCallback;
this.exitCallback = exitCallback;
@@ -98,7 +65,7 @@ export class Controller {
this.addExtension,
];
this.extensions = [
this.extensions = new Set([
new ExtensionExternalConverters(...this.extensionArgs),
new ExtensionOnEvent(...this.extensionArgs),
new ExtensionBridge(...this.extensionArgs),
@@ -111,47 +78,43 @@ export class Controller {
new ExtensionOTAUpdate(...this.extensionArgs),
new ExtensionExternalExtensions(...this.extensionArgs),
new ExtensionAvailability(...this.extensionArgs),
];
if (settings.get().frontend.enabled) {
this.extensions.push(new ExtensionFrontend(...this.extensionArgs));
}
if (settings.get().homeassistant.enabled) {
this.extensions.push(new ExtensionHomeAssistant(...this.extensionArgs));
}
]);
}
async start(): Promise<void> {
if (settings.get().frontend.enabled) {
const {Frontend} = await import("./extension/frontend.js");
this.extensions.add(new Frontend(...this.extensionArgs));
}
if (settings.get().homeassistant.enabled) {
const {HomeAssistant} = await import("./extension/homeassistant.js");
this.extensions.add(new HomeAssistant(...this.extensionArgs));
}
this.state.start();
const info = await utils.getZigbee2MQTTVersion();
logger.info(`Starting Zigbee2MQTT version ${info.version} (commit #${info.commitHash})`);
try {
this.sdNotify = process.env.NOTIFY_SOCKET ? await import('sd-notify') : undefined;
logger.debug('sd-notify loaded');
/* v8 ignore start */
} catch {
logger.debug('sd-notify is not installed');
}
/* v8 ignore stop */
// Start zigbee
try {
await this.zigbee.start();
this.eventBus.onAdapterDisconnected(this, this.onZigbeeAdapterDisconnected);
} catch (error) {
logger.error('Failed to start zigbee-herdsman');
logger.error("Failed to start zigbee-herdsman");
logger.error(
'Check https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start_crashes-runtime.html for possible solutions',
"Check https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start_crashes-runtime.html for possible solutions",
);
logger.error('Exiting...');
logger.error("Exiting...");
// biome-ignore lint/style/noNonNullAssertion: always Error
logger.error((error as Error).stack!);
/* v8 ignore start */
if ((error as Error).message.includes('USB adapter discovery error (No valid USB adapter found)')) {
logger.error('If this happens after updating to Zigbee2MQTT 2.0.0, see https://github.com/Koenkk/zigbee2mqtt/discussions/24364');
if ((error as Error).message.includes("USB adapter discovery error (No valid USB adapter found)")) {
logger.error("If this happens after updating to Zigbee2MQTT 2.0.0, see https://github.com/Koenkk/zigbee2mqtt/discussions/24364");
}
/* v8 ignore stop */
@@ -164,8 +127,9 @@ export class Controller {
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
// `definition` validated by `isSupported`
const model = device.isSupported
? `${device.definition!.model} - ${device.definition!.vendor} ${device.definition!.description}`
: 'Not supported';
? // biome-ignore lint/style/noNonNullAssertion: valid from `isSupported`
`${device.definition!.model} - ${device.definition!.vendor} ${device.definition!.description}`
: "Not supported";
logger.info(`${device.name} (${device.ieeeAddr}): ${model} (${device.zh.type})`);
deviceCount++;
@@ -182,71 +146,171 @@ export class Controller {
return await this.exit(1);
}
// Call extensions
await this.callExtensions('start', [...this.extensions]);
// copy current Set of extensions to ignore possible external extensions added while looping
for (const extension of new Set(this.extensions)) {
await this.startExtension(extension);
}
// Send all cached states.
if (settings.get().advanced.cache_state_send_on_startup && settings.get().advanced.cache_state) {
for (const entity of this.zigbee.devicesAndGroupsIterator()) {
if (this.state.exists(entity)) {
await this.publishEntityState(entity, this.state.get(entity), 'publishCached');
await this.publishEntityState(entity, this.state.get(entity), "publishCached");
}
}
}
this.eventBus.onLastSeenChanged(this, (data) => utils.publishLastSeen(data, settings.get(), false, this.publishEntityState));
logger.info(`Zigbee2MQTT started!`);
logger.info("Zigbee2MQTT started!");
const watchdogInterval = this.sdNotify?.watchdogInterval() || 0;
if (watchdogInterval > 0) {
this.sdNotify?.startWatchdogMode(Math.floor(watchdogInterval / 2));
}
this.sdNotify?.ready();
this.sdNotify = await initSdNotify();
}
@bind async enableDisableExtension(enable: boolean, name: string): Promise<void> {
if (!enable) {
const extension = this.extensions.find((e) => e.constructor.name === name);
if (extension) {
await this.callExtensions('stop', [extension]);
this.extensions.splice(this.extensions.indexOf(extension), 1);
if (enable) {
switch (name) {
case "Frontend": {
if (!settings.get().frontend.enabled) {
throw new Error("Tried to enable Frontend extension disabled in settings");
}
// this is not actually used, not tested either
/* v8 ignore start */
const {Frontend} = await import("./extension/frontend.js");
await this.addExtension(new Frontend(...this.extensionArgs));
break;
/* v8 ignore stop */
}
case "HomeAssistant": {
if (!settings.get().homeassistant.enabled) {
throw new Error("Tried to enable HomeAssistant extension disabled in settings");
}
const {HomeAssistant} = await import("./extension/homeassistant.js");
await this.addExtension(new HomeAssistant(...this.extensionArgs));
break;
}
default: {
throw new Error(
`Extension ${name} does not exist (should be added with 'addExtension') or is built-in that cannot be enabled at runtime`,
);
}
}
} else {
const Extension = AllExtensions.find((e) => e.name === name);
assert(Extension, `Extension '${name}' does not exist`);
const extension = new Extension(...this.extensionArgs);
this.extensions.push(extension);
await this.callExtensions('start', [extension]);
switch (name) {
case "Frontend": {
if (settings.get().frontend.enabled) {
throw new Error("Tried to disable Frontend extension enabled in settings");
}
break;
}
case "HomeAssistant": {
if (settings.get().homeassistant.enabled) {
throw new Error("Tried to disable HomeAssistant extension enabled in settings");
}
break;
}
case "Availability":
case "Bind":
case "Bridge":
case "Configure":
case "ExternalConverters":
case "ExternalExtensions":
case "Groups":
case "NetworkMap":
case "OnEvent":
case "OTAUpdate":
case "Publish":
case "Receive": {
throw new Error(`Built-in extension ${name} cannot be disabled at runtime`);
}
}
const extension = this.getExtension(name);
if (extension) {
await this.removeExtension(extension);
}
}
}
public getExtension(name: string): Extension | undefined {
for (const extension of this.extensions) {
if (extension.constructor.name === name) {
return extension;
}
}
}
@bind async addExtension(extension: Extension): Promise<void> {
this.extensions.push(extension);
await this.callExtensions('start', [extension]);
for (const ext of this.extensions) {
if (ext.constructor.name === extension.constructor.name) {
throw new Error(`Extension with name ${ext.constructor.name} already present`);
}
}
this.extensions.add(extension);
await this.startExtension(extension);
}
async removeExtension(extension: Extension): Promise<void> {
if (this.extensions.delete(extension)) {
await this.stopExtension(extension);
}
}
private async startExtension(extension: Extension): Promise<void> {
try {
await extension.start();
} catch (error) {
logger.error(`Failed to start '${extension.constructor.name}' (${(error as Error).stack})`);
}
}
private async stopExtension(extension: Extension): Promise<void> {
try {
await extension.stop();
} catch (error) {
logger.error(`Failed to stop '${extension.constructor.name}' (${(error as Error).stack})`);
}
}
async stop(restart = false): Promise<void> {
this.sdNotify?.stopping(process.pid);
this.sdNotify?.notifyStopping();
let code = 0;
for (const extension of this.extensions) {
try {
await extension.stop();
} catch (error) {
logger.error(`Failed to stop '${extension.constructor.name}' (${(error as Error).stack})`);
code = 1;
}
}
// Call extensions
await this.callExtensions('stop', this.extensions);
this.eventBus.removeListeners(this);
// Wrap-up
this.state.stop();
await this.mqtt.disconnect();
let code = 0;
try {
await this.zigbee.stop();
logger.info('Stopped Zigbee2MQTT');
logger.info("Stopped Zigbee2MQTT");
} catch (error) {
logger.error(`Failed to stop Zigbee2MQTT (${(error as Error).message})`);
logger.error(`Failed to stop Zigbee2MQTT (${(error as Error).stack})`);
code = 1;
}
this.sdNotify?.stopWatchdogMode();
this.sdNotify?.stop();
return await this.exit(code, restart);
}
@@ -256,12 +320,12 @@ export class Controller {
}
@bind async onZigbeeAdapterDisconnected(): Promise<void> {
logger.error('Adapter disconnected, stopping');
logger.error("Adapter disconnected, stopping");
await this.stop();
}
@bind async publishEntityState(entity: Group | Device, payload: KeyValue, stateChangeReason?: StateChangeReason): Promise<void> {
let message: Zigbee2MQTTAPI['{friendlyName}'] = {...payload};
let message: Zigbee2MQTTAPI["{friendlyName}"] = {...payload};
// Update state cache with new state.
const newState = this.state.set(entity, payload, stateChangeReason);
@@ -272,10 +336,10 @@ export class Controller {
}
const options: IClientPublishOptions = {
retain: utils.getObjectProperty(entity.options, 'retain', false),
qos: utils.getObjectProperty(entity.options, 'qos', 0),
retain: utils.getObjectProperty(entity.options, "retain", false),
qos: utils.getObjectProperty(entity.options, "qos", 0),
};
const retention = utils.getObjectProperty<number | false>(entity.options, 'retention', false);
const retention = utils.getObjectProperty<number | false>(entity.options, "retention", false);
if (retention !== false) {
options.properties = {messageExpiryInterval: retention};
@@ -299,13 +363,13 @@ export class Controller {
// Manufacturer name can contain \u0000, remove this.
// https://github.com/home-assistant/core/issues/85691
/* v8 ignore next */
manufacturerName: entity.zh.manufacturerName?.split('\u0000')[0],
manufacturerName: entity.zh.manufacturerName?.split("\u0000")[0],
};
}
// Add lastseen
const lastSeen = settings.get().advanced.last_seen;
if (entity.isDevice() && lastSeen !== 'disable' && entity.zh.lastSeen) {
if (entity.isDevice() && lastSeen !== "disable" && entity.zh.lastSeen) {
message.last_seen = utils.formatDate(entity.zh.lastSeen, lastSeen);
}
@@ -323,11 +387,11 @@ export class Controller {
if (!utils.objectIsEmpty(message)) {
const output = settings.get().advanced.output;
if (output === 'attribute_and_json' || output === 'json') {
if (output === "attribute_and_json" || output === "json") {
await this.mqtt.publish(entity.name, stringify(message), options);
}
if (output === 'attribute_and_json' || output === 'attribute') {
if (output === "attribute_and_json" || output === "attribute") {
await this.iteratePayloadAttributeOutput(`${entity.name}/`, message, options);
}
}
@@ -341,19 +405,19 @@ export class Controller {
let message = null;
// Special cases
if (key === 'color' && utils.objectHasProperties(subPayload, ['r', 'g', 'b'])) {
if (key === "color" && utils.objectHasProperties(subPayload, ["r", "g", "b"])) {
subPayload = [subPayload.r, subPayload.g, subPayload.b];
}
// Check Array first, since it is also an Object
if (subPayload === null || subPayload === undefined) {
message = '';
message = "";
} else if (Array.isArray(subPayload)) {
message = subPayload.map((x) => `${x}`).join(',');
} else if (typeof subPayload === 'object') {
message = subPayload.map((x) => `${x}`).join(",");
} else if (typeof subPayload === "object") {
await this.iteratePayloadAttributeOutput(`${topicRoot}${key}-`, subPayload, options);
} else {
message = typeof subPayload === 'string' ? subPayload : stringify(subPayload);
message = typeof subPayload === "string" ? subPayload : stringify(subPayload);
}
if (message !== null) {
@@ -361,14 +425,4 @@ export class Controller {
}
}
}
private async callExtensions(method: 'start' | 'stop', extensions: Extension[]): Promise<void> {
for (const extension of extensions) {
try {
await extension[method]?.();
} catch (error) {
logger.error(`Failed to call '${extension.constructor.name}' '${method}' (${(error as Error).stack})`);
}
}
}
}
+57 -49
View File
@@ -1,6 +1,6 @@
import events from 'node:events';
import events from "node:events";
import logger from './util/logger';
import logger from "./util/logger";
type ListenerKey = object;
@@ -35,7 +35,7 @@ type EventBusListener<K> = K extends keyof EventBusMap
: never;
export default class EventBus {
private callbacksByExtension: {[s: string]: {event: keyof EventBusMap; callback: EventBusListener<keyof EventBusMap>}[]} = {};
private callbacksByExtension = new Map<string, {event: keyof EventBusMap; callback: EventBusListener<keyof EventBusMap>}[]>();
private emitter = new events.EventEmitter<EventBusMap>();
constructor() {
@@ -43,150 +43,150 @@ export default class EventBus {
}
public emitAdapterDisconnected(): void {
this.emitter.emit('adapterDisconnected');
this.emitter.emit("adapterDisconnected");
}
public onAdapterDisconnected(key: ListenerKey, callback: () => void): void {
this.on('adapterDisconnected', callback, key);
this.on("adapterDisconnected", callback, key);
}
public emitPermitJoinChanged(data: eventdata.PermitJoinChanged): void {
this.emitter.emit('permitJoinChanged', data);
this.emitter.emit("permitJoinChanged", data);
}
public onPermitJoinChanged(key: ListenerKey, callback: (data: eventdata.PermitJoinChanged) => void): void {
this.on('permitJoinChanged', callback, key);
this.on("permitJoinChanged", callback, key);
}
public emitEntityRenamed(data: eventdata.EntityRenamed): void {
this.emitter.emit('deviceRenamed', data);
this.emitter.emit("deviceRenamed", data);
}
public onEntityRenamed(key: ListenerKey, callback: (data: eventdata.EntityRenamed) => void): void {
this.on('deviceRenamed', callback, key);
this.on("deviceRenamed", callback, key);
}
public emitEntityRemoved(data: eventdata.EntityRemoved): void {
this.emitter.emit('deviceRemoved', data);
this.emitter.emit("deviceRemoved", data);
}
public onEntityRemoved(key: ListenerKey, callback: (data: eventdata.EntityRemoved) => void): void {
this.on('deviceRemoved', callback, key);
this.on("deviceRemoved", callback, key);
}
public emitLastSeenChanged(data: eventdata.LastSeenChanged): void {
this.emitter.emit('lastSeenChanged', data);
this.emitter.emit("lastSeenChanged", data);
}
public onLastSeenChanged(key: ListenerKey, callback: (data: eventdata.LastSeenChanged) => void): void {
this.on('lastSeenChanged', callback, key);
this.on("lastSeenChanged", callback, key);
}
public emitDeviceNetworkAddressChanged(data: eventdata.DeviceNetworkAddressChanged): void {
this.emitter.emit('deviceNetworkAddressChanged', data);
this.emitter.emit("deviceNetworkAddressChanged", data);
}
public onDeviceNetworkAddressChanged(key: ListenerKey, callback: (data: eventdata.DeviceNetworkAddressChanged) => void): void {
this.on('deviceNetworkAddressChanged', callback, key);
this.on("deviceNetworkAddressChanged", callback, key);
}
public emitDeviceAnnounce(data: eventdata.DeviceAnnounce): void {
this.emitter.emit('deviceAnnounce', data);
this.emitter.emit("deviceAnnounce", data);
}
public onDeviceAnnounce(key: ListenerKey, callback: (data: eventdata.DeviceAnnounce) => void): void {
this.on('deviceAnnounce', callback, key);
this.on("deviceAnnounce", callback, key);
}
public emitDeviceInterview(data: eventdata.DeviceInterview): void {
this.emitter.emit('deviceInterview', data);
this.emitter.emit("deviceInterview", data);
}
public onDeviceInterview(key: ListenerKey, callback: (data: eventdata.DeviceInterview) => void): void {
this.on('deviceInterview', callback, key);
this.on("deviceInterview", callback, key);
}
public emitDeviceJoined(data: eventdata.DeviceJoined): void {
this.emitter.emit('deviceJoined', data);
this.emitter.emit("deviceJoined", data);
}
public onDeviceJoined(key: ListenerKey, callback: (data: eventdata.DeviceJoined) => void): void {
this.on('deviceJoined', callback, key);
this.on("deviceJoined", callback, key);
}
public emitEntityOptionsChanged(data: eventdata.EntityOptionsChanged): void {
this.emitter.emit('entityOptionsChanged', data);
this.emitter.emit("entityOptionsChanged", data);
}
public onEntityOptionsChanged(key: ListenerKey, callback: (data: eventdata.EntityOptionsChanged) => void): void {
this.on('entityOptionsChanged', callback, key);
this.on("entityOptionsChanged", callback, key);
}
public emitExposesChanged(data: eventdata.ExposesChanged): void {
this.emitter.emit('exposesChanged', data);
this.emitter.emit("exposesChanged", data);
}
public onExposesChanged(key: ListenerKey, callback: (data: eventdata.ExposesChanged) => void): void {
this.on('exposesChanged', callback, key);
this.on("exposesChanged", callback, key);
}
public emitDeviceLeave(data: eventdata.DeviceLeave): void {
this.emitter.emit('deviceLeave', data);
this.emitter.emit("deviceLeave", data);
}
public onDeviceLeave(key: ListenerKey, callback: (data: eventdata.DeviceLeave) => void): void {
this.on('deviceLeave', callback, key);
this.on("deviceLeave", callback, key);
}
public emitDeviceMessage(data: eventdata.DeviceMessage): void {
this.emitter.emit('deviceMessage', data);
this.emitter.emit("deviceMessage", data);
}
public onDeviceMessage(key: ListenerKey, callback: (data: eventdata.DeviceMessage) => void): void {
this.on('deviceMessage', callback, key);
this.on("deviceMessage", callback, key);
}
public emitMQTTMessage(data: eventdata.MQTTMessage): void {
this.emitter.emit('mqttMessage', data);
this.emitter.emit("mqttMessage", data);
}
public onMQTTMessage(key: ListenerKey, callback: (data: eventdata.MQTTMessage) => void): void {
this.on('mqttMessage', callback, key);
this.on("mqttMessage", callback, key);
}
public emitMQTTMessagePublished(data: eventdata.MQTTMessagePublished): void {
this.emitter.emit('mqttMessagePublished', data);
this.emitter.emit("mqttMessagePublished", data);
}
public onMQTTMessagePublished(key: ListenerKey, callback: (data: eventdata.MQTTMessagePublished) => void): void {
this.on('mqttMessagePublished', callback, key);
this.on("mqttMessagePublished", callback, key);
}
public emitPublishEntityState(data: eventdata.PublishEntityState): void {
this.emitter.emit('publishEntityState', data);
this.emitter.emit("publishEntityState", data);
}
public onPublishEntityState(key: ListenerKey, callback: (data: eventdata.PublishEntityState) => void): void {
this.on('publishEntityState', callback, key);
this.on("publishEntityState", callback, key);
}
public emitGroupMembersChanged(data: eventdata.GroupMembersChanged): void {
this.emitter.emit('groupMembersChanged', data);
this.emitter.emit("groupMembersChanged", data);
}
public onGroupMembersChanged(key: ListenerKey, callback: (data: eventdata.GroupMembersChanged) => void): void {
this.on('groupMembersChanged', callback, key);
this.on("groupMembersChanged", callback, key);
}
public emitDevicesChanged(): void {
this.emitter.emit('devicesChanged');
this.emitter.emit("devicesChanged");
}
public onDevicesChanged(key: ListenerKey, callback: () => void): void {
this.on('devicesChanged', callback, key);
this.on("devicesChanged", callback, key);
}
public emitScenesChanged(data: eventdata.ScenesChanged): void {
this.emitter.emit('scenesChanged', data);
this.emitter.emit("scenesChanged", data);
}
public onScenesChanged(key: ListenerKey, callback: (data: eventdata.ScenesChanged) => void): void {
this.on('scenesChanged', callback, key);
this.on("scenesChanged", callback, key);
}
public emitReconfigure(data: eventdata.Reconfigure): void {
this.emitter.emit('reconfigure', data);
this.emitter.emit("reconfigure", data);
}
public onReconfigure(key: ListenerKey, callback: (data: eventdata.Reconfigure) => void): void {
this.on('reconfigure', callback, key);
this.on("reconfigure", callback, key);
}
public emitStateChange(data: eventdata.StateChange): void {
this.emitter.emit('stateChange', data);
this.emitter.emit("stateChange", data);
}
public onStateChange(key: ListenerKey, callback: (data: eventdata.StateChange) => void): void {
this.on('stateChange', callback, key);
this.on("stateChange", callback, key);
}
public emitExposesAndDevicesChanged(device: Device): void {
@@ -195,8 +195,8 @@ export default class EventBus {
}
private on<K extends keyof EventBusMap>(event: K, callback: EventBusListener<K>, key: ListenerKey): void {
if (!this.callbacksByExtension[key.constructor.name]) {
this.callbacksByExtension[key.constructor.name] = [];
if (!this.callbacksByExtension.has(key.constructor.name)) {
this.callbacksByExtension.set(key.constructor.name, []);
}
const wrappedCallback = async (...args: never[]): Promise<void> => {
@@ -204,15 +204,23 @@ export default class EventBus {
await callback(...args);
} catch (error) {
logger.error(`EventBus error '${key.constructor.name}/${event}': ${(error as Error).message}`);
// biome-ignore lint/style/noNonNullAssertion: always Error
logger.debug((error as Error).stack!);
}
};
this.callbacksByExtension[key.constructor.name].push({event, callback: wrappedCallback});
// biome-ignore lint/style/noNonNullAssertion: just created if wasn't valid
this.callbacksByExtension.get(key.constructor.name)!.push({event, callback: wrappedCallback});
this.emitter.on(event, wrappedCallback as EventBusListener<K>);
}
public removeListeners(key: ListenerKey): void {
this.callbacksByExtension[key.constructor.name]?.forEach((e) => this.emitter.removeListener(e.event, e.callback));
const callbacks = this.callbacksByExtension.get(key.constructor.name);
if (callbacks) {
for (const cb of callbacks) {
this.emitter.removeListener(cb.event, cb.callback);
}
}
}
}
+163 -80
View File
@@ -1,67 +1,132 @@
import type {Zigbee2MQTTAPI} from 'lib/types/api';
import type * as zhc from "zigbee-herdsman-converters";
import assert from 'node:assert';
import type {Zigbee2MQTTAPI} from "../types/api";
import bind from 'bind-decorator';
import debounce from 'debounce';
import assert from "node:assert";
import * as zhc from 'zigbee-herdsman-converters';
import bind from "bind-decorator";
import debounce from "debounce";
import logger from '../util/logger';
import * as settings from '../util/settings';
import utils from '../util/utils';
import Extension from './extension';
import logger from "../util/logger";
import * as settings from "../util/settings";
import utils from "../util/utils";
import Extension from "./extension";
const RETRIEVE_ON_RECONNECT: readonly {keys: string[]; condition?: (state: KeyValue) => boolean}[] = [
{keys: ['state']},
{keys: ['brightness'], condition: (state: KeyValue): boolean => state.state === 'ON'},
{keys: ['color', 'color_temp'], condition: (state: KeyValue): boolean => state.state === 'ON'},
{keys: ["state"]},
{keys: ["brightness"], condition: (state: KeyValue): boolean => state.state === "ON"},
{keys: ["color", "color_temp"], condition: (state: KeyValue): boolean => state.state === "ON"},
];
export default class Availability extends Extension {
private timers: {[s: string]: NodeJS.Timeout} = {};
private availabilityCache: {[s: string]: boolean} = {};
private retrieveStateDebouncers: {[s: string]: () => void} = {};
/** Mapped by IEEE address */
private readonly timers = new Map<string, NodeJS.Timeout>();
/** Mapped by IEEE address or Group ID */
private readonly lastPublishedAvailabilities = new Map<string | number, boolean>();
/** Mapped by IEEE address */
private readonly pingBackoffs = new Map<string, number>();
/** IEEE addresses, waiting for last seen changes to take them out of "availability sleep" */
private readonly backoffPausedDevices = new Set<string>();
/** Mapped by IEEE address */
private readonly retrieveStateDebouncers = new Map<string, () => void>();
private pingQueue: Device[] = [];
private pingQueueExecuting = false;
private stopped = false;
private getTimeout(device: Device): number {
if (typeof device.options.availability === 'object' && device.options.availability?.timeout != null) {
if (typeof device.options.availability === "object" && device.options.availability?.timeout != null) {
return utils.minutes(device.options.availability.timeout);
}
const type = this.isActiveDevice(device) ? 'active' : 'passive';
return utils.minutes(this.isActiveDevice(device) ? settings.get().availability.active.timeout : settings.get().availability.passive.timeout);
}
return utils.minutes(settings.get().availability[type].timeout);
private getMaxJitter(device: Device): number {
if (typeof device.options.availability === "object" && device.options.availability?.max_jitter != null) {
return device.options.availability.max_jitter;
}
return settings.get().availability.active.max_jitter;
}
private getBackoff(device: Device): boolean {
if (typeof device.options.availability === "object" && device.options.availability?.backoff != null) {
return device.options.availability.backoff;
}
return settings.get().availability.active.backoff;
}
private getPauseOnBackoffGt(device: Device): number {
if (typeof device.options.availability === "object" && device.options.availability?.pause_on_backoff_gt != null) {
return device.options.availability.pause_on_backoff_gt;
}
return settings.get().availability.active.pause_on_backoff_gt;
}
private isActiveDevice(device: Device): boolean {
return (device.zh.type === 'Router' && device.zh.powerSource !== 'Battery') || device.zh.powerSource === 'Mains (single phase)';
return (
(device.zh.type === "Router" && device.zh.powerSource !== "Battery") ||
(device.zh.powerSource !== undefined && device.zh.powerSource !== "Unknown" && device.zh.powerSource !== "Battery")
);
}
private isAvailable(entity: Device | Group): boolean {
if (entity.isDevice()) {
return Date.now() - (entity.zh.lastSeen ?? /* v8 ignore next */ 0) < this.getTimeout(entity);
} else {
const membersDevices = entity.membersDevices();
return membersDevices.length === 0 || membersDevices.some((d) => this.availabilityCache[d.ieeeAddr]);
const lastSeen = entity.zh.lastSeen ?? /* v8 ignore next */ 0;
return Date.now() - lastSeen < this.getTimeout(entity) + this.getMaxJitter(entity);
}
for (const memberDevice of entity.membersDevices()) {
if (this.lastPublishedAvailabilities.get(memberDevice.ieeeAddr) === true) {
return true;
}
}
return false;
}
private resetTimer(device: Device): void {
clearTimeout(this.timers[device.ieeeAddr]);
private resetTimer(device: Device, resetBackoff = false): void {
clearTimeout(this.timers.get(device.ieeeAddr));
this.removeFromPingQueue(device);
// If the timer triggers, the device is not available anymore otherwise resetTimer already has been called
if (this.isActiveDevice(device)) {
// If device did not check in, ping it, if that fails it will be marked as offline
this.timers[device.ieeeAddr] = setTimeout(() => this.addToPingQueue(device), this.getTimeout(device) + utils.seconds(1));
const backoffEnabled = this.getBackoff(device);
const jitter = Math.random() * this.getMaxJitter(device);
let backoff = 1;
if (resetBackoff) {
// always cleanup even if backoff disabled (ensures proper state if changed at runtime)
this.backoffPausedDevices.delete(device.ieeeAddr);
this.pingBackoffs.delete(device.ieeeAddr);
} else if (backoffEnabled) {
backoff = this.pingBackoffs.get(device.ieeeAddr) ?? 1;
}
// never paused if was reset (just deleted) or backoff disabled, might as well skip the Set lookup
if (!backoffEnabled || resetBackoff || !this.backoffPausedDevices.has(device.ieeeAddr)) {
// If device did not check in, ping it, if that fails it will be marked as offline
this.timers.set(
device.ieeeAddr,
setTimeout(this.addToPingQueue.bind(this, device), (this.getTimeout(device) + utils.seconds(1) + jitter) * backoff),
);
}
} else {
this.timers[device.ieeeAddr] = setTimeout(() => this.publishAvailability(device, true), this.getTimeout(device) + utils.seconds(1));
this.timers.set(
device.ieeeAddr,
setTimeout(this.publishAvailability.bind(this, device, true), this.getTimeout(device) + utils.seconds(1)),
);
}
}
private clearTimer(ieeeAddress: string): void {
clearTimeout(this.timers.get(ieeeAddress));
this.timers.delete(ieeeAddress);
}
private addToPingQueue(device: Device): void {
this.pingQueue.push(device);
this.pingQueueExecuteNext().catch(utils.noop);
@@ -69,7 +134,7 @@ export default class Availability extends Extension {
private removeFromPingQueue(device: Device): void {
const index = this.pingQueue.findIndex((d) => d.ieeeAddr === device.ieeeAddr);
if (index != -1) {
if (index !== -1) {
this.pingQueue.splice(index, 1);
}
}
@@ -81,8 +146,8 @@ export default class Availability extends Extension {
this.pingQueueExecuting = true;
const device = this.pingQueue[0];
let pingedSuccessfully = false;
const available = this.availabilityCache[device.ieeeAddr] || this.isAvailable(device);
let pingSuccess = false;
const available = this.lastPublishedAvailabilities.get(device.ieeeAddr) || this.isAvailable(device);
const attempts = available ? 2 : 1;
for (let i = 1; i <= attempts; i++) {
@@ -90,7 +155,7 @@ export default class Availability extends Extension {
// Enable recovery if device is marked as available and first ping fails.
await device.zh.ping(!available || i !== 2);
pingedSuccessfully = true;
pingSuccess = true;
logger.debug(`Successfully pinged '${device.name}' (attempt ${i}/${attempts})`);
break;
@@ -109,8 +174,21 @@ export default class Availability extends Extension {
return;
}
await this.publishAvailability(device, !pingedSuccessfully);
this.resetTimer(device);
if (!pingSuccess && this.getBackoff(device)) {
const currentBackoff = this.pingBackoffs.get(device.ieeeAddr) ?? 1;
// setting is "greater than" but since we already did the ping, we use ">=" for comparison below (pause next)
const pauseOnBackoff = this.getPauseOnBackoffGt(device);
if (pauseOnBackoff > 0 && currentBackoff >= pauseOnBackoff) {
this.backoffPausedDevices.add(device.ieeeAddr);
} else {
// results in backoffs: *1.5, *3, *6, *12... (with default timeout: 10, 15, 30, 60, 120)
this.pingBackoffs.set(device.ieeeAddr, currentBackoff * (available ? 1.5 : 2));
}
}
await this.publishAvailability(device, !pingSuccess);
this.resetTimer(device, pingSuccess);
this.removeFromPingQueue(device);
// Sleep 2 seconds before executing next ping
@@ -123,21 +201,21 @@ export default class Availability extends Extension {
override async start(): Promise<void> {
if (this.stopped) {
throw new Error('This extension cannot be restarted.');
throw new Error("This extension cannot be restarted.");
}
this.eventBus.onEntityRenamed(this, async (data) => {
if (utils.isAvailabilityEnabledForEntity(data.entity, settings.get())) {
await this.mqtt.publish(`${data.from}/availability`, '', {retain: true, qos: 1});
await this.mqtt.publish(`${data.from}/availability`, "", {retain: true, qos: 1});
await this.publishAvailability(data.entity, false, true);
}
});
this.eventBus.onEntityRemoved(this, (data) => data.type == 'device' && clearTimeout(this.timers[data.id]));
this.eventBus.onDeviceLeave(this, (data) => clearTimeout(this.timers[data.ieeeAddr]));
this.eventBus.onEntityRemoved(this, (data) => data.type === "device" && this.clearTimer(data.id));
this.eventBus.onDeviceLeave(this, (data) => this.clearTimer(data.ieeeAddr));
this.eventBus.onDeviceAnnounce(this, (data) => this.retrieveState(data.device));
this.eventBus.onLastSeenChanged(this, this.onLastSeenChanged);
this.eventBus.onGroupMembersChanged(this, (data) => this.publishAvailability(data.group, false));
// Publish initial availability
await this.publishAvailabilityForAllEntities();
@@ -154,7 +232,7 @@ export default class Availability extends Extension {
}
}
@bind private async publishAvailabilityForAllEntities(): Promise<void> {
private async publishAvailabilityForAllEntities(): Promise<void> {
for (const entity of this.zigbee.devicesAndGroupsIterator(utils.deviceNotCoordinator)) {
if (utils.isAvailabilityEnabledForEntity(entity, settings.get())) {
await this.publishAvailability(entity, true, false, true);
@@ -175,18 +253,18 @@ export default class Availability extends Extension {
const available = this.isAvailable(entity);
if (!forcePublish && this.availabilityCache[entity.ID] == available) {
if (!forcePublish && this.lastPublishedAvailabilities.get(entity.ID) === available) {
return;
}
if (entity.isDevice() && entity.ieeeAddr in this.availabilityCache && available && this.availabilityCache[entity.ieeeAddr] === false) {
if (entity.isDevice() && available && this.lastPublishedAvailabilities.get(entity.ieeeAddr) === false) {
logger.debug(`Device '${entity.name}' reconnected`);
this.retrieveState(entity);
}
const topic = `${entity.name}/availability`;
const payload: Zigbee2MQTTAPI['{friendlyName}/availability'] = {state: available ? 'online' : 'offline'};
this.availabilityCache[entity.ID] = available;
const payload: Zigbee2MQTTAPI["{friendlyName}/availability"] = {state: available ? "online" : "offline"};
this.lastPublishedAvailabilities.set(entity.ID, available);
await this.mqtt.publish(topic, JSON.stringify(payload), {retain: true, qos: 1});
if (!skipGroups && entity.isDevice()) {
@@ -202,7 +280,7 @@ export default class Availability extends Extension {
if (utils.isAvailabilityEnabledForEntity(data.device, settings.get())) {
// Remove from ping queue, not necessary anymore since we know the device is online.
this.removeFromPingQueue(data.device);
this.resetTimer(data.device);
this.resetTimer(data.device, true);
await this.publishAvailability(data.device, false);
}
}
@@ -211,7 +289,7 @@ export default class Availability extends Extension {
this.stopped = true;
this.pingQueue = [];
for (const t of Object.values(this.timers)) {
for (const [, t] of this.timers) {
clearTimeout(t);
}
@@ -223,43 +301,48 @@ export default class Availability extends Extension {
* Retrieve state of a device in a debounced manner, this function is called on a 'deviceAnnounce' which a
* device can send multiple times after each other.
*/
if (device.definition && !device.zh.interviewing && !this.retrieveStateDebouncers[device.ieeeAddr]) {
this.retrieveStateDebouncers[device.ieeeAddr] = debounce(async () => {
logger.debug(`Retrieving state of '${device.name}' after reconnect`);
if (device.definition && device.interviewed && !this.retrieveStateDebouncers.get(device.ieeeAddr)) {
this.retrieveStateDebouncers.set(
device.ieeeAddr,
debounce(async () => {
logger.debug(`Retrieving state of '${device.name}' after reconnect`);
// Color and color temperature converters do both, only needs to be called once.
for (const item of RETRIEVE_ON_RECONNECT) {
if (item.condition && this.state.get(device) && !item.condition(this.state.get(device))) {
continue;
// Color and color temperature converters do both, only needs to be called once.
for (const item of RETRIEVE_ON_RECONNECT) {
if (item.condition && this.state.get(device) && !item.condition(this.state.get(device))) {
continue;
}
// biome-ignore lint/style/noNonNullAssertion: doesn't change once valid
const converter = device.definition!.toZigbee.find((c) => !c.key || c.key.find((k) => item.keys.includes(k)));
const options: KeyValue = device.options;
const state = this.state.get(device);
const meta: zhc.Tz.Meta = {
message: this.state.get(device),
// biome-ignore lint/style/noNonNullAssertion: doesn't change once valid
mapped: device.definition!,
endpoint_name: undefined,
options,
state,
device: device.zh,
/* v8 ignore next */
publish: (payload: KeyValue) => this.publishEntityState(device, payload),
};
try {
const endpoint = device.endpoint();
assert(endpoint);
await converter?.convertGet?.(endpoint, item.keys[0], meta);
} catch (error) {
logger.error(`Failed to read state of '${device.name}' after reconnect (${(error as Error).message})`);
}
await utils.sleep(500);
}
const converter = device.definition!.toZigbee.find((c) => !c.key || c.key.find((k) => item.keys.includes(k)));
const options: KeyValue = device.options;
const state = this.state.get(device);
const meta: zhc.Tz.Meta = {
message: this.state.get(device),
mapped: device.definition!,
endpoint_name: undefined,
options,
state,
device: device.zh,
/* v8 ignore next */
publish: (payload: KeyValue) => this.publishEntityState(device, payload),
};
try {
const endpoint = device.endpoint();
assert(endpoint);
await converter?.convertGet?.(endpoint, item.keys[0], meta);
} catch (error) {
logger.error(`Failed to read state of '${device.name}' after reconnect (${(error as Error).message})`);
}
await utils.sleep(500);
}
}, utils.seconds(2));
}, utils.seconds(2)),
);
}
this.retrieveStateDebouncers[device.ieeeAddr]?.();
this.retrieveStateDebouncers.get(device.ieeeAddr)?.();
}
}
+116 -103
View File
@@ -1,46 +1,47 @@
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from 'lib/types/api';
import type {ClusterName} from "zigbee-herdsman/dist/zspec/zcl/definition/tstype";
import assert from 'node:assert';
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from "../types/api";
import bind from 'bind-decorator';
import debounce from 'debounce';
import stringify from 'json-stable-stringify-without-jsonify';
import assert from "node:assert";
import {Zcl} from 'zigbee-herdsman';
import {ClusterName} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype';
import bind from "bind-decorator";
import debounce from "debounce";
import stringify from "json-stable-stringify-without-jsonify";
import Device from '../model/device';
import Group from '../model/group';
import logger from '../util/logger';
import * as settings from '../util/settings';
import utils from '../util/utils';
import Extension from './extension';
import {Zcl} from "zigbee-herdsman";
import Device from "../model/device";
import Group from "../model/group";
import logger from "../util/logger";
import * as settings from "../util/settings";
import utils 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',
'genLevelCtrl',
'lightingColorCtrl',
'closuresWindowCovering',
'hvacThermostat',
'msIlluminanceMeasurement',
'msTemperatureMeasurement',
'msRelativeHumidity',
'msSoilMoisture',
'msCO2',
"genScenes",
"genOnOff",
"genLevelCtrl",
"lightingColorCtrl",
"closuresWindowCovering",
"hvacThermostat",
"msIlluminanceMeasurement",
"msTemperatureMeasurement",
"msRelativeHumidity",
"msSoilMoisture",
"msCO2",
];
// 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: 901, 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}> => {
if (endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') == null) {
await endpoint.read('lightingColorCtrl', ['colorCapabilities']);
if (endpoint.getClusterAttributeValue("lightingColorCtrl", "colorCapabilities") == null) {
await endpoint.read("lightingColorCtrl", ["colorCapabilities"]);
}
const value = endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') as number;
const value = endpoint.getClusterAttributeValue("lightingColorCtrl", "colorCapabilities") as number;
return {
colorTemperature: (value & (1 << 4)) > 0,
@@ -62,28 +63,28 @@ const REPORT_CLUSTERS: Readonly<
>
>
> = {
genOnOff: [{attribute: 'onOff', ...DEFAULT_REPORT_CONFIG, minimumReportInterval: 0, reportableChange: 0}],
genLevelCtrl: [{attribute: 'currentLevel', ...DEFAULT_REPORT_CONFIG}],
genOnOff: [{attribute: "onOff", ...DEFAULT_REPORT_CONFIG, minimumReportInterval: 0, reportableChange: 0}],
genLevelCtrl: [{attribute: "currentLevel", ...DEFAULT_REPORT_CONFIG}],
lightingColorCtrl: [
{
attribute: 'colorTemperature',
attribute: "colorTemperature",
...DEFAULT_REPORT_CONFIG,
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorTemperature,
},
{
attribute: 'currentX',
attribute: "currentX",
...DEFAULT_REPORT_CONFIG,
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
},
{
attribute: 'currentY',
attribute: "currentY",
...DEFAULT_REPORT_CONFIG,
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
},
],
closuresWindowCovering: [
{attribute: 'currentPositionLiftPercentage', ...DEFAULT_REPORT_CONFIG},
{attribute: 'currentPositionTiltPercentage', ...DEFAULT_REPORT_CONFIG},
{attribute: "currentPositionLiftPercentage", ...DEFAULT_REPORT_CONFIG},
{attribute: "currentPositionTiltPercentage", ...DEFAULT_REPORT_CONFIG},
],
};
@@ -99,22 +100,22 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
// On messages that have the cluster and type of below
cluster: {
manuSpecificPhilips: [
{type: 'commandHueNotification', data: {button: 2}},
{type: 'commandHueNotification', data: {button: 3}},
{type: "commandHueNotification", data: {button: 2}},
{type: "commandHueNotification", data: {button: 3}},
],
genLevelCtrl: [
{type: 'commandStep', data: {}},
{type: 'commandStepWithOnOff', data: {}},
{type: 'commandStop', data: {}},
{type: 'commandMoveWithOnOff', data: {}},
{type: 'commandStopWithOnOff', data: {}},
{type: 'commandMove', data: {}},
{type: 'commandMoveToLevelWithOnOff', data: {}},
{type: "commandStep", data: {}},
{type: "commandStepWithOnOff", data: {}},
{type: "commandStop", data: {}},
{type: "commandMoveWithOnOff", data: {}},
{type: "commandStopWithOnOff", data: {}},
{type: "commandMove", data: {}},
{type: "commandMoveToLevelWithOnOff", data: {}},
],
genScenes: [{type: 'commandRecall', data: {}}],
genScenes: [{type: "commandRecall", data: {}}],
},
// Read the following attributes
read: {cluster: 'genLevelCtrl', attributes: ['currentLevel']},
read: {cluster: "genLevelCtrl", attributes: ["currentLevel"]},
// When the bound devices/members of group have the following manufacturerIDs
manufacturerIDs: [
Zcl.ManufacturerCode.SIGNIFY_NETHERLANDS_B_V,
@@ -124,29 +125,29 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
Zcl.ManufacturerCode.TELINK_MICRO,
Zcl.ManufacturerCode.BUSCH_JAEGER_ELEKTRO,
],
manufacturerNames: ['GLEDOPTO', 'Trust International B.V.\u0000'],
manufacturerNames: ["GLEDOPTO", "Trust International B.V.\u0000"],
},
{
cluster: {
genLevelCtrl: [
{type: 'commandStepWithOnOff', data: {}},
{type: 'commandMoveWithOnOff', data: {}},
{type: 'commandStopWithOnOff', data: {}},
{type: 'commandMoveToLevelWithOnOff', data: {}},
{type: "commandStepWithOnOff", data: {}},
{type: "commandMoveWithOnOff", data: {}},
{type: "commandStopWithOnOff", data: {}},
{type: "commandMoveToLevelWithOnOff", data: {}},
],
genOnOff: [
{type: 'commandOn', data: {}},
{type: 'commandOff', data: {}},
{type: 'commandOffWithEffect', data: {}},
{type: 'commandToggle', data: {}},
{type: "commandOn", data: {}},
{type: "commandOff", data: {}},
{type: "commandOffWithEffect", data: {}},
{type: "commandToggle", data: {}},
],
genScenes: [{type: 'commandRecall', data: {}}],
genScenes: [{type: "commandRecall", data: {}}],
manuSpecificPhilips: [
{type: 'commandHueNotification', data: {button: 1}},
{type: 'commandHueNotification', data: {button: 4}},
{type: "commandHueNotification", data: {button: 1}},
{type: "commandHueNotification", data: {button: 4}},
],
},
read: {cluster: 'genOnOff', attributes: ['onOff']},
read: {cluster: "genOnOff", attributes: ["onOff"]},
manufacturerIDs: [
Zcl.ManufacturerCode.SIGNIFY_NETHERLANDS_B_V,
Zcl.ManufacturerCode.ATMEL,
@@ -155,14 +156,14 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
Zcl.ManufacturerCode.TELINK_MICRO,
Zcl.ManufacturerCode.BUSCH_JAEGER_ELEKTRO,
],
manufacturerNames: ['GLEDOPTO', 'Trust International B.V.\u0000'],
manufacturerNames: ["GLEDOPTO", "Trust International B.V.\u0000"],
},
{
cluster: {
genScenes: [{type: 'commandRecall', data: {}}],
genScenes: [{type: "commandRecall", data: {}}],
},
read: {
cluster: 'lightingColorCtrl',
cluster: "lightingColorCtrl",
attributes: [] as string[],
// Since not all devices support the same attributes they need to be calculated dynamically
// depending on the capabilities of the endpoint.
@@ -171,11 +172,11 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
const readAttrs: string[] = [];
if (supportedAttrs.colorXY) {
readAttrs.push('currentX', 'currentY');
readAttrs.push("currentX", "currentY");
}
if (supportedAttrs.colorTemperature) {
readAttrs.push('colorTemperature');
readAttrs.push("colorTemperature");
}
return readAttrs;
@@ -189,12 +190,12 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
Zcl.ManufacturerCode.TELINK_MICRO,
// Note: ManufacturerCode.BUSCH_JAEGER is left out intentionally here as their devices don't support colors
],
manufacturerNames: ['GLEDOPTO', 'Trust International B.V.\u0000'],
manufacturerNames: ["GLEDOPTO", "Trust International B.V.\u0000"],
},
];
interface ParsedMQTTMessage {
type: 'bind' | 'unbind';
type: "bind" | "unbind";
sourceKey?: string;
sourceEndpointKey?: string | number;
targetKey?: string;
@@ -210,6 +211,7 @@ interface ParsedMQTTMessage {
export default class Bind extends Extension {
private pollDebouncers: {[s: string]: () => void} = {};
// biome-ignore lint/suspicious/useAwait: API
override async start(): Promise<void> {
this.eventBus.onDeviceMessage(this, this.poll);
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
@@ -220,20 +222,20 @@ export default class Bind extends Extension {
data: eventdata.MQTTMessage,
): [raw: KeyValue | undefined, parsed: ParsedMQTTMessage | undefined, error: string | undefined] {
if (data.topic.match(TOPIC_REGEX)) {
const type = data.topic.endsWith('unbind') ? 'unbind' : 'bind';
const type = data.topic.endsWith("unbind") ? "unbind" : "bind";
let skipDisableReporting = false;
const message = JSON.parse(data.message) as Zigbee2MQTTAPI['bridge/request/device/bind'];
const message = JSON.parse(data.message) as Zigbee2MQTTAPI["bridge/request/device/bind"];
if (typeof message !== 'object' || message.from == undefined || message.to == undefined) {
return [message, {type, skipDisableReporting}, `Invalid payload`];
if (typeof message !== "object" || message.from == null || message.to == null) {
return [message, {type, skipDisableReporting}, "Invalid payload"];
}
const sourceKey = message.from;
const sourceEndpointKey = message.from_endpoint ?? 'default';
const sourceEndpointKey = message.from_endpoint ?? "default";
const targetKey = message.to;
const targetEndpointKey = message.to_endpoint;
const clusters = message.clusters;
skipDisableReporting = message.skip_disable_reporting != undefined ? message.skip_disable_reporting : false;
skipDisableReporting = message.skip_disable_reporting != null ? message.skip_disable_reporting : false;
const resolvedSource = this.zigbee.resolveEntity(message.from) as Device;
if (!resolvedSource || !(resolvedSource instanceof Device)) {
@@ -289,9 +291,9 @@ export default class Bind extends Extension {
},
undefined,
];
} else {
return [undefined, undefined, undefined];
}
return [undefined, undefined, undefined];
}
@bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
@@ -320,10 +322,10 @@ export default class Bind extends Extension {
resolvedBindTarget,
} = parsed;
assert(resolvedSource, '`resolvedSource` is missing');
assert(resolvedTarget, '`resolvedTarget` is missing');
assert(resolvedSourceEndpoint, '`resolvedSourceEndpoint` is missing');
assert(resolvedBindTarget != undefined, '`resolvedBindTarget` is missing');
assert(resolvedSource, "`resolvedSource` is missing");
assert(resolvedTarget, "`resolvedTarget` is missing");
assert(resolvedSourceEndpoint, "`resolvedSourceEndpoint` is missing");
assert(resolvedBindTarget !== undefined, "`resolvedBindTarget` is missing");
const successfulClusters: string[] = [];
const failedClusters = [];
@@ -337,8 +339,8 @@ export default class Bind extends Extension {
const anyClusterValid =
utils.isZHGroup(resolvedBindTarget) ||
typeof resolvedBindTarget === 'number' ||
(resolvedTarget instanceof Device && resolvedTarget.zh.type === 'Coordinator');
typeof resolvedBindTarget === "number" ||
(resolvedTarget instanceof Device && resolvedTarget.zh.type === "Coordinator");
if (!anyClusterValid && utils.isZHEndpoint(resolvedBindTarget)) {
matchingClusters =
@@ -353,7 +355,7 @@ export default class Bind extends Extension {
attemptedClusters.push(cluster);
try {
if (type === 'bind') {
if (type === "bind") {
await resolvedSourceEndpoint.bind(cluster, resolvedBindTarget);
} else {
await resolvedSourceEndpoint.unbind(cluster, resolvedBindTarget);
@@ -361,7 +363,7 @@ export default class Bind extends Extension {
successfulClusters.push(cluster);
logger.info(
`Successfully ${type === 'bind' ? 'bound' : 'unbound'} cluster '${cluster}' from '${resolvedSource.name}' to '${resolvedTarget.name}'`,
`Successfully ${type === "bind" ? "bound" : "unbound"} cluster '${cluster}' from '${resolvedSource.name}' to '${resolvedTarget.name}'`,
);
} catch (error) {
failedClusters.push(cluster);
@@ -374,26 +376,31 @@ export default class Bind extends Extension {
logger.error(`Nothing to ${type} from '${resolvedSource.name}' to '${resolvedTarget.name}'`);
await this.publishResponse(parsed.type, raw, {}, `Nothing to ${type}`);
return;
} else if (failedClusters.length === attemptedClusters.length) {
}
if (failedClusters.length === attemptedClusters.length) {
await this.publishResponse(parsed.type, raw, {}, `Failed to ${type}`);
return;
}
const responseData: Zigbee2MQTTAPI['bridge/response/device/bind'] | Zigbee2MQTTAPI['bridge/response/device/unbind'] = {
from: sourceKey!, // valid with assert above on `resolvedSource`
from_endpoint: sourceEndpointKey!, // valid with assert above on `resolvedSourceEndpoint`
to: targetKey!, // valid with assert above on `resolvedTarget`
const responseData: Zigbee2MQTTAPI["bridge/response/device/bind"] | Zigbee2MQTTAPI["bridge/response/device/unbind"] = {
// biome-ignore lint/style/noNonNullAssertion: valid with assert above on `resolvedSource`
from: sourceKey!,
// biome-ignore lint/style/noNonNullAssertion: valid with assert above on `resolvedSourceEndpoint`
from_endpoint: sourceEndpointKey!,
// biome-ignore lint/style/noNonNullAssertion: valid with assert above on `resolvedTarget`
to: targetKey!,
to_endpoint: targetEndpointKey,
clusters: successfulClusters,
failed: failedClusters,
};
if (successfulClusters.length !== 0) {
if (type === 'bind') {
if (type === "bind") {
await this.setupReporting(
resolvedSourceEndpoint.binds.filter((b) => successfulClusters.includes(b.cluster.name) && b.target === resolvedBindTarget),
);
} else if (typeof resolvedBindTarget !== 'number' && !skipDisableReporting) {
} else if (typeof resolvedBindTarget !== "number" && !skipDisableReporting) {
await this.disableUnnecessaryReportings(resolvedBindTarget);
}
}
@@ -403,7 +410,7 @@ export default class Bind extends Extension {
}
private async publishResponse<T extends Zigbee2MQTTResponseEndpoints>(
type: ParsedMQTTMessage['type'],
type: ParsedMQTTMessage["type"],
request: KeyValue,
data: Zigbee2MQTTAPI[T],
error?: string,
@@ -417,7 +424,7 @@ export default class Bind extends Extension {
}
@bind async onGroupMembersChanged(data: eventdata.GroupMembersChanged): Promise<void> {
if (data.action === 'add') {
if (data.action === "add") {
const bindsToGroup: zh.Bind[] = [];
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
@@ -465,13 +472,16 @@ export default class Bind extends Extension {
for (const bind of binds) {
if (bind.cluster.name in REPORT_CLUSTERS) {
for (const endpoint of this.getSetupReportingEndpoints(bind, coordinatorEndpoint)) {
const entity = `${this.zigbee.resolveEntity(endpoint.getDevice())!.name}/${endpoint.ID}`;
// biome-ignore lint/style/noNonNullAssertion: TODO: biome migration: ???
const resolvedDevice = this.zigbee.resolveEntity(endpoint.getDevice())!;
const entity = `${resolvedDevice.name}/${endpoint.ID}`;
try {
await endpoint.bind(bind.cluster.name, coordinatorEndpoint);
const items = [];
// biome-ignore lint/style/noNonNullAssertion: valid from outer `if`
for (const c of REPORT_CLUSTERS[bind.cluster.name as ClusterName]!) {
if (!c.condition || (await c.condition(endpoint))) {
const i = {...c};
@@ -530,6 +540,7 @@ export default class Bind extends Extension {
const items = [];
// biome-ignore lint/style/noNonNullAssertion: valid from loop (pushed to array only if in)
for (const item of REPORT_CLUSTERS[cluster as ClusterName]!) {
if (!item.condition || (await item.condition(endpoint))) {
const i = {...item};
@@ -564,31 +575,33 @@ export default class Bind extends Extension {
);
if (polls.length) {
const toPoll: Set<zh.Endpoint> = new Set();
const toPoll = new Set<zh.Endpoint>();
// Add bound devices
for (const endpoint of data.device.zh.endpoints) {
for (const bind of endpoint.binds) {
if (utils.isZHEndpoint(bind.target) && bind.target.getDevice().type !== 'Coordinator') {
if (utils.isZHEndpoint(bind.target) && bind.target.getDevice().type !== "Coordinator") {
toPoll.add(bind.target);
}
}
}
// If message is published to a group, add members of the group
const group = data.groupID && data.groupID !== 0 && this.zigbee.groupByID(data.groupID);
if (data.groupID && data.groupID !== 0) {
// If message is published to a group, add members of the group
const group = this.zigbee.groupByID(data.groupID);
if (group) {
for (const member of group.zh.members) {
toPoll.add(member);
if (group) {
for (const member of group.zh.members) {
toPoll.add(member);
}
}
}
for (const endpoint of toPoll) {
const device = endpoint.getDevice();
for (const poll of polls) {
// XXX: manufacturerID/manufacturerName can be undefined and won't match `includes`, but TS enforces same-type
if (
// biome-ignore lint/style/noNonNullAssertion: manufacturerID/manufacturerName can be undefined and won't match `includes`, but TS enforces same-type
(!poll.manufacturerIDs.includes(device.manufacturerID!) && !poll.manufacturerNames.includes(device.manufacturerName!)) ||
!endpoint.supportsInputCluster(poll.read.cluster)
) {
@@ -609,9 +622,9 @@ export default class Bind extends Extension {
try {
await endpoint.read(poll.read.cluster, readAttrs);
} catch (error) {
logger.error(
`Failed to poll ${readAttrs} from ${this.zigbee.resolveEntity(device)!.name} (${(error as Error).message})`,
);
// biome-ignore lint/style/noNonNullAssertion: TODO: biome migration: ???
const resolvedDevice = this.zigbee.resolveEntity(device)!;
logger.error(`Failed to poll ${readAttrs} from ${resolvedDevice.name} (${(error as Error).message})`);
}
}, 1000);
}
+188 -179
View File
@@ -1,24 +1,26 @@
import type {Zigbee2MQTTAPI, Zigbee2MQTTDevice, Zigbee2MQTTResponse, Zigbee2MQTTResponseEndpoints} from 'lib/types/api';
import type winston from "winston";
import fs from 'node:fs';
import type Group from "../model/group";
import type {Zigbee2MQTTAPI, Zigbee2MQTTDevice, Zigbee2MQTTResponse, Zigbee2MQTTResponseEndpoints} from "../types/api";
import bind from 'bind-decorator';
import stringify from 'json-stable-stringify-without-jsonify';
import JSZip from 'jszip';
import objectAssignDeep from 'object-assign-deep';
import winston from 'winston';
import Transport from 'winston-transport';
import fs from "node:fs";
import {Zcl} from 'zigbee-herdsman';
import * as zhc from 'zigbee-herdsman-converters';
import bind from "bind-decorator";
import stringify from "json-stable-stringify-without-jsonify";
import JSZip from "jszip";
import objectAssignDeep from "object-assign-deep";
import Transport from "winston-transport";
import Device from '../model/device';
import Group from '../model/group';
import data from '../util/data';
import logger from '../util/logger';
import * as settings from '../util/settings';
import utils from '../util/utils';
import Extension from './extension';
import {Zcl} from "zigbee-herdsman";
import * as zhc from "zigbee-herdsman-converters";
import {InterviewState} from "zigbee-herdsman/dist/controller/model/device";
import Device from "../model/device";
import data from "../util/data";
import logger from "../util/logger";
import * as settings from "../util/settings";
import utils from "../util/utils";
import Extension from "./extension";
const REQUEST_REGEX = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/(.*)`);
@@ -32,23 +34,23 @@ export default class Bridge extends Extension {
private lastBridgeLoggingPayload?: string;
private logTransport!: winston.transport;
private requestLookup: {[key: string]: (message: KeyValue | string) => Promise<Zigbee2MQTTResponse<Zigbee2MQTTResponseEndpoints>>} = {
'device/options': this.deviceOptions,
'device/configure_reporting': this.deviceConfigureReporting,
'device/remove': this.deviceRemove,
'device/interview': this.deviceInterview,
'device/generate_external_definition': this.deviceGenerateExternalDefinition,
'device/rename': this.deviceRename,
'group/add': this.groupAdd,
'group/options': this.groupOptions,
'group/remove': this.groupRemove,
'group/rename': this.groupRename,
"device/options": this.deviceOptions,
"device/configure_reporting": this.deviceConfigureReporting,
"device/remove": this.deviceRemove,
"device/interview": this.deviceInterview,
"device/generate_external_definition": this.deviceGenerateExternalDefinition,
"device/rename": this.deviceRename,
"group/add": this.groupAdd,
"group/options": this.groupOptions,
"group/remove": this.groupRemove,
"group/rename": this.groupRename,
permit_join: this.permitJoin,
restart: this.restart,
backup: this.backup,
'touchlink/factory_reset': this.touchlinkFactoryReset,
'touchlink/identify': this.touchlinkIdentify,
'install_code/add': this.installCodeAdd,
'touchlink/scan': this.touchlinkScan,
"touchlink/factory_reset": this.touchlinkFactoryReset,
"touchlink/identify": this.touchlinkIdentify,
"install_code/add": this.installCodeAdd,
"touchlink/scan": this.touchlinkScan,
health_check: this.healthCheck,
coordinator_check: this.coordinatorCheck,
options: this.bridgeOptions,
@@ -63,7 +65,7 @@ export default class Bridge extends Extension {
if (payload !== this.lastBridgeLoggingPayload) {
this.lastBridgeLoggingPayload = payload;
void this.mqtt.publish(`bridge/logging`, payload, {}, baseTopic, true);
void this.mqtt.publish("bridge/logging", payload, {}, baseTopic, true);
}
};
@@ -79,7 +81,7 @@ export default class Bridge extends Extension {
} else {
class EventTransport extends Transport {
override log(info: {message: string; level: string; namespace: string}, next: () => void): void {
if (info.level !== 'debug') {
if (info.level !== "debug") {
bridgeLogging(info.message, info.level, info.namespace);
}
next();
@@ -92,8 +94,8 @@ export default class Bridge extends Extension {
logger.addTransport(this.logTransport);
this.zigbee2mqttVersion = await utils.getZigbee2MQTTVersion();
this.zigbeeHerdsmanVersion = await utils.getDependencyVersion('zigbee-herdsman');
this.zigbeeHerdsmanConvertersVersion = await utils.getDependencyVersion('zigbee-herdsman-converters');
this.zigbeeHerdsmanVersion = await utils.getDependencyVersion("zigbee-herdsman");
this.zigbeeHerdsmanConvertersVersion = await utils.getDependencyVersion("zigbee-herdsman-converters");
this.coordinatorVersion = await this.zigbee.getCoordinatorVersion();
this.eventBus.onEntityRenamed(this, async () => {
@@ -122,20 +124,20 @@ export default class Bridge extends Extension {
this.lastJoinedDeviceIeeeAddr = data.device.ieeeAddr;
await this.publishDevices();
const payload: Zigbee2MQTTAPI['bridge/event'] = {
type: 'device_joined',
const payload: Zigbee2MQTTAPI["bridge/event"] = {
type: "device_joined",
data: {friendly_name: data.device.name, ieee_address: data.device.ieeeAddr},
};
await this.mqtt.publish('bridge/event', stringify(payload), {retain: false, qos: 0});
await this.mqtt.publish("bridge/event", stringify(payload), {retain: false, qos: 0});
});
this.eventBus.onDeviceLeave(this, async (data) => {
await this.publishDevices();
await this.publishDefinitions();
const payload: Zigbee2MQTTAPI['bridge/event'] = {type: 'device_leave', data: {ieee_address: data.ieeeAddr, friendly_name: data.name}};
const payload: Zigbee2MQTTAPI["bridge/event"] = {type: "device_leave", data: {ieee_address: data.ieeeAddr, friendly_name: data.name}};
await this.mqtt.publish('bridge/event', stringify(payload), {retain: false, qos: 0});
await this.mqtt.publish("bridge/event", stringify(payload), {retain: false, qos: 0});
});
this.eventBus.onDeviceNetworkAddressChanged(this, async () => {
await this.publishDevices();
@@ -143,11 +145,11 @@ export default class Bridge extends Extension {
this.eventBus.onDeviceInterview(this, async (data) => {
await this.publishDevices();
let payload: Zigbee2MQTTAPI['bridge/event'];
let payload: Zigbee2MQTTAPI["bridge/event"];
if (data.status === 'successful') {
if (data.status === "successful") {
payload = {
type: 'device_interview',
type: "device_interview",
data: {
friendly_name: data.device.name,
status: data.status,
@@ -158,22 +160,22 @@ export default class Bridge extends Extension {
};
} else {
payload = {
type: 'device_interview',
type: "device_interview",
data: {friendly_name: data.device.name, status: data.status, ieee_address: data.device.ieeeAddr},
};
}
await this.mqtt.publish('bridge/event', stringify(payload), {retain: false, qos: 0});
await this.mqtt.publish("bridge/event", stringify(payload), {retain: false, qos: 0});
});
this.eventBus.onDeviceAnnounce(this, async (data) => {
await this.publishDevices();
const payload: Zigbee2MQTTAPI['bridge/event'] = {
type: 'device_announce',
const payload: Zigbee2MQTTAPI["bridge/event"] = {
type: "device_announce",
data: {friendly_name: data.device.name, ieee_address: data.device.ieeeAddr},
};
await this.mqtt.publish('bridge/event', stringify(payload), {retain: false, qos: 0});
await this.mqtt.publish("bridge/event", stringify(payload), {retain: false, qos: 0});
});
await this.publishInfo();
@@ -206,6 +208,7 @@ export default class Bridge extends Extension {
await this.mqtt.publish(`bridge/response/${match[1]}`, stringify(response));
} catch (error) {
logger.error(`Request '${data.topic}' failed with error: '${(error as Error).message}'`);
// biome-ignore lint/style/noNonNullAssertion: always using Error
logger.debug((error as Error).stack!);
const response = utils.getResponse(message, {}, (error as Error).message);
await this.mqtt.publish(`bridge/response/${match[1]}`, stringify(response));
@@ -217,17 +220,17 @@ export default class Bridge extends Extension {
* Requests
*/
@bind async deviceOptions(message: KeyValue | string): Promise<Zigbee2MQTTResponse<'bridge/response/device/options'>> {
return await this.changeEntityOptions('device', message);
@bind async deviceOptions(message: KeyValue | string): Promise<Zigbee2MQTTResponse<"bridge/response/device/options">> {
return await this.changeEntityOptions("device", message);
}
@bind async groupOptions(message: KeyValue | string): Promise<Zigbee2MQTTResponse<'bridge/response/group/options'>> {
return await this.changeEntityOptions('group', message);
@bind async groupOptions(message: KeyValue | string): Promise<Zigbee2MQTTResponse<"bridge/response/group/options">> {
return await this.changeEntityOptions("group", message);
}
@bind async bridgeOptions(message: KeyValue | string): Promise<Zigbee2MQTTResponse<'bridge/response/options'>> {
if (typeof message !== 'object' || typeof message.options !== 'object') {
throw new Error(`Invalid payload`);
@bind async bridgeOptions(message: KeyValue | string): Promise<Zigbee2MQTTResponse<"bridge/response/options">> {
if (typeof message !== "object" || typeof message.options !== "object") {
throw new Error("Invalid payload");
}
const newSettings = message.options as Partial<Settings>;
@@ -235,39 +238,40 @@ export default class Bridge extends Extension {
// Apply some settings on-the-fly.
if (newSettings.homeassistant) {
await this.enableDisableExtension(settings.get().homeassistant.enabled, 'HomeAssistant');
await this.enableDisableExtension(settings.get().homeassistant.enabled, "HomeAssistant");
}
if (newSettings.advanced?.log_level != undefined) {
if (newSettings.advanced?.log_level != null) {
logger.setLevel(settings.get().advanced.log_level);
}
if (newSettings.advanced?.log_namespaced_levels != undefined) {
if (newSettings.advanced?.log_namespaced_levels != null) {
logger.setNamespacedLevels(settings.get().advanced.log_namespaced_levels);
}
if (newSettings.advanced?.log_debug_namespace_ignore != undefined) {
if (newSettings.advanced?.log_debug_namespace_ignore != null) {
logger.setDebugNamespaceIgnore(settings.get().advanced.log_debug_namespace_ignore);
}
logger.info('Successfully changed options');
logger.info("Successfully changed options");
await this.publishInfo();
return utils.getResponse(message, {restart_required: this.restartRequired});
}
@bind async deviceRemove(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/device/remove'>> {
return await this.removeEntity('device', message);
@bind async deviceRemove(message: string | KeyValue): Promise<Zigbee2MQTTResponse<"bridge/response/device/remove">> {
return await this.removeEntity("device", message);
}
@bind async groupRemove(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/group/remove'>> {
return await this.removeEntity('group', message);
@bind async groupRemove(message: string | KeyValue): Promise<Zigbee2MQTTResponse<"bridge/response/group/remove">> {
return await this.removeEntity("group", message);
}
@bind async healthCheck(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/health_check'>> {
// biome-ignore lint/suspicious/useAwait: API
@bind async healthCheck(message: string | KeyValue): Promise<Zigbee2MQTTResponse<"bridge/response/health_check">> {
return utils.getResponse(message, {healthy: true});
}
@bind async coordinatorCheck(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/coordinator_check'>> {
@bind async coordinatorCheck(message: string | KeyValue): Promise<Zigbee2MQTTResponse<"bridge/response/coordinator_check">> {
const result = await this.zigbee.coordinatorCheck();
const missingRouters = result.missingRouters.map((d) => {
return {ieee_address: d.ieeeAddr, friendly_name: d.name};
@@ -275,65 +279,70 @@ export default class Bridge extends Extension {
return utils.getResponse(message, {missing_routers: missingRouters});
}
@bind async groupAdd(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/group/add'>> {
if (typeof message === 'object' && message.friendly_name === undefined) {
throw new Error(`Invalid payload`);
@bind async groupAdd(message: string | KeyValue): Promise<Zigbee2MQTTResponse<"bridge/response/group/add">> {
if (typeof message === "object" && message.friendly_name === undefined) {
throw new Error("Invalid payload");
}
const friendlyName = typeof message === 'object' ? message.friendly_name : message;
const ID = typeof message === 'object' && message.id !== undefined ? message.id : null;
const friendlyName = typeof message === "object" ? message.friendly_name : message;
const ID = typeof message === "object" && message.id !== undefined ? message.id : null;
const group = settings.addGroup(friendlyName, ID);
this.zigbee.createGroup(group.ID);
await this.publishGroups();
return utils.getResponse(message, {friendly_name: group.friendly_name, id: group.ID});
}
@bind async deviceRename(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/device/rename'>> {
return await this.renameEntity('device', message);
@bind async deviceRename(message: string | KeyValue): Promise<Zigbee2MQTTResponse<"bridge/response/device/rename">> {
return await this.renameEntity("device", message);
}
@bind async groupRename(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/group/rename'>> {
return await this.renameEntity('group', message);
@bind async groupRename(message: string | KeyValue): Promise<Zigbee2MQTTResponse<"bridge/response/group/rename">> {
return await this.renameEntity("group", message);
}
@bind async restart(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/restart'>> {
// biome-ignore lint/suspicious/useAwait: API
@bind async restart(message: string | KeyValue): Promise<Zigbee2MQTTResponse<"bridge/response/restart">> {
// Wait 500 ms before restarting so response can be send.
setTimeout(this.restartCallback, 500);
logger.info('Restarting Zigbee2MQTT');
logger.info("Restarting Zigbee2MQTT");
return utils.getResponse(message, {});
}
@bind async backup(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/backup'>> {
@bind async backup(message: string | KeyValue): Promise<Zigbee2MQTTResponse<"bridge/response/backup">> {
await this.zigbee.backup();
const dataPath = data.getPath();
const files = utils
.getAllFiles(dataPath)
.map((f) => [f, f.substring(dataPath.length + 1)])
.filter((f) => !f[1].startsWith('log'));
.filter((f) => !f[1].startsWith("log"));
const zip = new JSZip();
files.forEach((f) => zip.file(f[1], fs.readFileSync(f[0])));
const base64Zip = await zip.generateAsync({type: 'base64'});
for (const f of files) {
zip.file(f[1], fs.readFileSync(f[0]));
}
const base64Zip = await zip.generateAsync({type: "base64"});
return utils.getResponse(message, {zip: base64Zip});
}
@bind async installCodeAdd(message: KeyValue | string): Promise<Zigbee2MQTTResponse<'bridge/response/install_code/add'>> {
if (typeof message === 'object' && message.value === undefined) {
throw new Error('Invalid payload');
@bind async installCodeAdd(message: KeyValue | string): Promise<Zigbee2MQTTResponse<"bridge/response/install_code/add">> {
if (typeof message === "object" && message.value === undefined) {
throw new Error("Invalid payload");
}
const value = typeof message === 'object' ? message.value : message;
const value = typeof message === "object" ? message.value : message;
await this.zigbee.addInstallCode(value);
logger.info('Successfully added new install code');
logger.info("Successfully added new install code");
return utils.getResponse(message, {value});
}
@bind async permitJoin(message: KeyValue | string): Promise<Zigbee2MQTTResponse<'bridge/response/permit_join'>> {
@bind async permitJoin(message: KeyValue | string): Promise<Zigbee2MQTTResponse<"bridge/response/permit_join">> {
let time: number | undefined;
let device: Device | undefined;
if (typeof message === 'object') {
if (typeof message === "object") {
if (message.time === undefined) {
throw new Error('Invalid payload');
throw new Error("Invalid payload");
}
time = Number.parseInt(message.time, 10);
@@ -362,9 +371,9 @@ export default class Bridge extends Extension {
return utils.getResponse(message, response);
}
@bind async touchlinkIdentify(message: KeyValue | string): Promise<Zigbee2MQTTResponse<'bridge/response/touchlink/identify'>> {
if (typeof message !== 'object' || message.ieee_address === undefined || message.channel === undefined) {
throw new Error('Invalid payload');
@bind async touchlinkIdentify(message: KeyValue | string): Promise<Zigbee2MQTTResponse<"bridge/response/touchlink/identify">> {
if (typeof message !== "object" || message.ieee_address === undefined || message.channel === undefined) {
throw new Error("Invalid payload");
}
logger.info(`Start Touchlink identify of '${message.ieee_address}' on channel ${message.channel}`);
@@ -372,11 +381,11 @@ export default class Bridge extends Extension {
return utils.getResponse(message, {ieee_address: message.ieee_address, channel: message.channel});
}
@bind async touchlinkFactoryReset(message: KeyValue | string): Promise<Zigbee2MQTTResponse<'bridge/response/touchlink/factory_reset'>> {
@bind async touchlinkFactoryReset(message: KeyValue | string): Promise<Zigbee2MQTTResponse<"bridge/response/touchlink/factory_reset">> {
let result = false;
let payload: Zigbee2MQTTAPI['bridge/response/touchlink/factory_reset'] = {};
let payload: Zigbee2MQTTAPI["bridge/response/touchlink/factory_reset"] = {};
if (typeof message === 'object' && message.ieee_address !== undefined && message.channel !== undefined) {
if (typeof message === "object" && message.ieee_address !== undefined && message.channel !== undefined) {
logger.info(`Start Touchlink factory reset of '${message.ieee_address}' on channel ${message.channel}`);
result = await this.zigbee.touchlinkFactoryReset(message.ieee_address, message.channel);
@@ -385,26 +394,26 @@ export default class Bridge extends Extension {
channel: message.channel,
};
} else {
logger.info('Start Touchlink factory reset of first found device');
logger.info("Start Touchlink factory reset of first found device");
result = await this.zigbee.touchlinkFactoryResetFirst();
}
if (result) {
logger.info('Successfully factory reset device through Touchlink');
logger.info("Successfully factory reset device through Touchlink");
return utils.getResponse(message, payload);
} else {
logger.error('Failed to factory reset device through Touchlink');
throw new Error('Failed to factory reset device through Touchlink');
}
logger.error("Failed to factory reset device through Touchlink");
throw new Error("Failed to factory reset device through Touchlink");
}
@bind async touchlinkScan(message: KeyValue | string): Promise<Zigbee2MQTTResponse<'bridge/response/touchlink/scan'>> {
logger.info('Start Touchlink scan');
@bind async touchlinkScan(message: KeyValue | string): Promise<Zigbee2MQTTResponse<"bridge/response/touchlink/scan">> {
logger.info("Start Touchlink scan");
const result = await this.zigbee.touchlinkScan();
const found = result.map((r) => {
return {ieee_address: r.ieeeAddr, channel: r.channel};
});
logger.info('Finished Touchlink scan');
logger.info("Finished Touchlink scan");
return utils.getResponse(message, {found});
}
@@ -412,12 +421,12 @@ export default class Bridge extends Extension {
* Utils
*/
async changeEntityOptions<T extends 'device' | 'group'>(
async changeEntityOptions<T extends "device" | "group">(
entityType: T,
message: KeyValue | string,
): Promise<Zigbee2MQTTResponse<T extends 'device' ? 'bridge/response/device/options' : 'bridge/response/group/options'>> {
if (typeof message !== 'object' || message.id === undefined || message.options === undefined) {
throw new Error(`Invalid payload`);
): Promise<Zigbee2MQTTResponse<T extends "device" ? "bridge/response/device/options" : "bridge/response/group/options">> {
if (typeof message !== "object" || message.id === undefined || message.options === undefined) {
throw new Error("Invalid payload");
}
const cleanup = (o: KeyValue): KeyValue => {
@@ -453,9 +462,9 @@ export default class Bridge extends Extension {
return utils.getResponse(message, {from: oldOptions, to: newOptions, id: ID, restart_required: this.restartRequired});
}
@bind async deviceConfigureReporting(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/device/configure_reporting'>> {
@bind async deviceConfigureReporting(message: string | KeyValue): Promise<Zigbee2MQTTResponse<"bridge/response/device/configure_reporting">> {
if (
typeof message !== 'object' ||
typeof message !== "object" ||
message.id === undefined ||
message.endpoint === undefined ||
message.cluster === undefined ||
@@ -464,10 +473,10 @@ export default class Bridge extends Extension {
message.reportable_change === undefined ||
message.attribute === undefined
) {
throw new Error(`Invalid payload`);
throw new Error("Invalid payload");
}
const device = this.getEntity('device', message.id);
const device = this.getEntity("device", message.id);
const endpoint = device.endpoint(message.endpoint);
if (!endpoint) {
@@ -505,12 +514,12 @@ export default class Bridge extends Extension {
});
}
@bind async deviceInterview(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/device/interview'>> {
if (typeof message !== 'object' || message.id === undefined) {
throw new Error(`Invalid payload`);
@bind async deviceInterview(message: string | KeyValue): Promise<Zigbee2MQTTResponse<"bridge/response/device/interview">> {
if (typeof message !== "object" || message.id === undefined) {
throw new Error("Invalid payload");
}
const device = this.getEntity('device', message.id);
const device = this.getEntity("device", message.id);
logger.info(`Interviewing '${device.name}'`);
try {
@@ -530,29 +539,29 @@ export default class Bridge extends Extension {
@bind async deviceGenerateExternalDefinition(
message: string | KeyValue,
): Promise<Zigbee2MQTTResponse<'bridge/response/device/generate_external_definition'>> {
if (typeof message !== 'object' || message.id === undefined) {
throw new Error(`Invalid payload`);
): Promise<Zigbee2MQTTResponse<"bridge/response/device/generate_external_definition">> {
if (typeof message !== "object" || message.id === undefined) {
throw new Error("Invalid payload");
}
const device = this.getEntity('device', message.id);
const device = this.getEntity("device", message.id);
const source = await zhc.generateExternalDefinitionSource(device.zh);
return utils.getResponse(message, {id: message.id, source});
}
async renameEntity<T extends 'device' | 'group'>(
async renameEntity<T extends "device" | "group">(
entityType: T,
message: string | KeyValue,
): Promise<Zigbee2MQTTResponse<T extends 'device' ? 'bridge/response/device/rename' : 'bridge/response/group/rename'>> {
const deviceAndHasLast = entityType === 'device' && typeof message === 'object' && message.last === true;
): Promise<Zigbee2MQTTResponse<T extends "device" ? "bridge/response/device/rename" : "bridge/response/group/rename">> {
const deviceAndHasLast = entityType === "device" && typeof message === "object" && message.last === true;
if (typeof message !== 'object' || (message.from === undefined && !deviceAndHasLast) || message.to === undefined) {
throw new Error(`Invalid payload`);
if (typeof message !== "object" || (message.from === undefined && !deviceAndHasLast) || message.to === undefined) {
throw new Error("Invalid payload");
}
if (deviceAndHasLast && !this.lastJoinedDeviceIeeeAddr) {
throw new Error('No device has joined since start');
throw new Error("No device has joined since start");
}
const from = deviceAndHasLast ? this.lastJoinedDeviceIeeeAddr : message.from;
@@ -564,7 +573,7 @@ export default class Bridge extends Extension {
settings.changeFriendlyName(from, to);
// Clear retained messages
await this.mqtt.publish(oldFriendlyName, '', {retain: true});
await this.mqtt.publish(oldFriendlyName, "", {retain: true});
this.eventBus.emitEntityRenamed({entity: entity, homeAssisantRename, from: oldFriendlyName, to});
@@ -581,31 +590,29 @@ export default class Bridge extends Extension {
return utils.getResponse(message, {from: oldFriendlyName, to, homeassistant_rename: homeAssisantRename});
}
async removeEntity<T extends 'device' | 'group'>(
async removeEntity<T extends "device" | "group">(
entityType: T,
message: string | KeyValue,
): Promise<Zigbee2MQTTResponse<T extends 'device' ? 'bridge/response/device/remove' : 'bridge/response/group/remove'>> {
const ID = typeof message === 'object' ? message.id : message.trim();
): Promise<Zigbee2MQTTResponse<T extends "device" ? "bridge/response/device/remove" : "bridge/response/group/remove">> {
const ID = typeof message === "object" ? message.id : message.trim();
const entity = this.getEntity(entityType, ID);
// note: entity.name is dynamically retrieved, will change once device is removed (friendly => ieee)
const friendlyName = entity.name;
const entityID = entity.ID;
let block = false;
let force = false;
let blockForceLog = '';
let blockForceLog = "";
if (entityType === 'device' && typeof message === 'object') {
if (entityType === "device" && typeof message === "object") {
block = !!message.block;
force = !!message.force;
blockForceLog = ` (block: ${block}, force: ${force})`;
} else if (entityType === 'group' && typeof message === 'object') {
} else if (entityType === "group" && typeof message === "object") {
force = !!message.force;
blockForceLog = ` (force: ${force})`;
}
try {
logger.info(`Removing ${entityType} '${entity.name}'${blockForceLog}`);
const name = entity.name;
logger.info(`Removing ${entityType} '${friendlyName}'${blockForceLog}`);
if (entity instanceof Device) {
if (block) {
@@ -618,8 +625,8 @@ export default class Bridge extends Extension {
await entity.zh.removeFromNetwork();
}
this.eventBus.emitEntityRemoved({id: entityID, name, type: 'device'});
settings.removeDevice(entityID as string);
this.eventBus.emitEntityRemoved({id: entity.ID, name: friendlyName, type: "device"});
settings.removeDevice(entity.ID as string);
} else {
if (force) {
entity.zh.removeFromDatabase();
@@ -627,15 +634,15 @@ export default class Bridge extends Extension {
await entity.zh.removeFromNetwork();
}
this.eventBus.emitEntityRemoved({id: entityID, name, type: 'group'});
settings.removeGroup(entityID);
this.eventBus.emitEntityRemoved({id: entity.ID, name: friendlyName, type: "group"});
settings.removeGroup(entity.ID);
}
// Remove from state
this.state.remove(entityID);
this.state.remove(entity.ID);
// Clear any retained messages
await this.mqtt.publish(friendlyName, '', {retain: true});
await this.mqtt.publish(friendlyName, "", {retain: true});
logger.info(`Successfully removed ${entityType} '${friendlyName}'${blockForceLog}`);
@@ -645,32 +652,32 @@ export default class Bridge extends Extension {
// Refresh Cluster definition
await this.publishDefinitions();
const responseData: Zigbee2MQTTAPI['bridge/response/device/remove'] = {id: ID, block, force};
const responseData: Zigbee2MQTTAPI["bridge/response/device/remove"] = {id: ID, block, force};
return utils.getResponse(message, responseData);
} else {
await this.publishGroups();
const responseData: Zigbee2MQTTAPI['bridge/response/group/remove'] = {id: ID, force};
return utils.getResponse(
message,
// @ts-expect-error typing infer does not work here
responseData,
);
}
await this.publishGroups();
const responseData: Zigbee2MQTTAPI["bridge/response/group/remove"] = {id: ID, force};
return utils.getResponse(
message,
// @ts-expect-error typing infer does not work here
responseData,
);
} catch (error) {
throw new Error(`Failed to remove ${entityType} '${friendlyName}'${blockForceLog} (${error})`);
}
}
getEntity(type: 'group', ID: string): Group;
getEntity(type: 'device', ID: string): Device;
getEntity(type: 'group' | 'device', ID: string): Device | Group;
getEntity(type: 'group' | 'device', ID: string): Device | Group {
const entity = this.zigbee.resolveEntity(ID);
getEntity(type: "group", id: string): Group;
getEntity(type: "device", id: string): Device;
getEntity(type: "group" | "device", id: string): Device | Group;
getEntity(type: "group" | "device", id: string): Device | Group {
const entity = this.zigbee.resolveEntity(id);
if (!entity || entity.constructor.name.toLowerCase() !== type) {
throw new Error(`${utils.capitalize(type)} '${ID}' does not exist`);
throw new Error(`${utils.capitalize(type)} '${id}' does not exist`);
}
return entity;
}
@@ -683,13 +690,13 @@ export default class Bridge extends Extension {
delete config.frontend.auth_token;
const networkParams = await this.zigbee.getNetworkParameters();
const payload: Zigbee2MQTTAPI['bridge/info'] = {
const payload: Zigbee2MQTTAPI["bridge/info"] = {
version: this.zigbee2mqttVersion.version,
commit: this.zigbee2mqttVersion.commitHash,
zigbee_herdsman_converters: this.zigbeeHerdsmanConvertersVersion,
zigbee_herdsman: this.zigbeeHerdsmanVersion,
coordinator: {
ieee_address: this.zigbee.firstCoordinatorEndpoint().getDevice().ieeeAddr,
ieee_address: this.zigbee.firstCoordinatorEndpoint().deviceIeeeAddress,
...this.coordinatorVersion,
},
network: {
@@ -705,14 +712,14 @@ export default class Bridge extends Extension {
config_schema: settings.schemaJson,
};
await this.mqtt.publish('bridge/info', stringify(payload), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
await this.mqtt.publish("bridge/info", stringify(payload), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
}
async publishDevices(): Promise<void> {
const devices: Zigbee2MQTTAPI['bridge/devices'] = [];
const devices: Zigbee2MQTTAPI["bridge/devices"] = [];
for (const device of this.zigbee.devicesIterator()) {
const endpoints: (typeof devices)[number]['endpoints'] = {};
const endpoints: (typeof devices)[number]["endpoints"] = {};
for (const endpoint of device.zh.endpoints) {
const data: (typeof endpoints)[keyof typeof endpoints] = {
@@ -727,8 +734,8 @@ export default class Bridge extends Extension {
for (const bind of endpoint.binds) {
const target = utils.isZHEndpoint(bind.target)
? {type: 'endpoint', ieee_address: bind.target.getDevice().ieeeAddr, endpoint: bind.target.ID}
: {type: 'group', id: bind.target.groupID};
? {type: "endpoint" as const, ieee_address: bind.target.deviceIeeeAddress, endpoint: bind.target.ID}
: {type: "group" as const, id: bind.target.groupID};
data.bindings.push({cluster: bind.cluster.name, target});
}
@@ -758,40 +765,42 @@ export default class Bridge extends Extension {
software_build_id: device.zh.softwareBuildID,
date_code: device.zh.dateCode,
model_id: device.zh.modelID,
interviewing: device.zh.interviewing,
interview_completed: device.zh.interviewCompleted,
/** @deprecated interviewing and interview_completed are superceded by interview_state */
interviewing: device.zh.interviewState === InterviewState.InProgress,
interview_completed: device.zh.interviewState === InterviewState.Successful,
interview_state: device.zh.interviewState,
manufacturer: device.zh.manufacturerName,
endpoints,
});
}
await this.mqtt.publish('bridge/devices', stringify(devices), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
await this.mqtt.publish("bridge/devices", stringify(devices), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
}
async publishGroups(): Promise<void> {
const groups: Zigbee2MQTTAPI['bridge/groups'] = [];
const groups: Zigbee2MQTTAPI["bridge/groups"] = [];
for (const group of this.zigbee.groupsIterator()) {
const members = [];
for (const member of group.zh.members) {
members.push({ieee_address: member.getDevice().ieeeAddr, endpoint: member.ID});
members.push({ieee_address: member.deviceIeeeAddress, endpoint: member.ID});
}
groups.push({
id: group.ID,
friendly_name: group.ID === 901 ? 'default_bind_group' : group.name,
friendly_name: group.ID === 901 ? "default_bind_group" : group.name,
description: group.options.description,
scenes: utils.getScenes(group.zh),
members,
});
}
await this.mqtt.publish('bridge/groups', stringify(groups), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
await this.mqtt.publish("bridge/groups", stringify(groups), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
}
async publishDefinitions(): Promise<void> {
const data: Zigbee2MQTTAPI['bridge/definition'] = {
const data: Zigbee2MQTTAPI["bridge/definitions"] = {
clusters: Zcl.Clusters,
custom_clusters: {},
};
@@ -800,10 +809,10 @@ export default class Bridge extends Extension {
data.custom_clusters[device.ieeeAddr] = device.customClusters;
}
await this.mqtt.publish('bridge/definitions', stringify(data), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
await this.mqtt.publish("bridge/definitions", stringify(data), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
}
getDefinitionPayload(device: Device): Zigbee2MQTTDevice['definition'] | undefined {
getDefinitionPayload(device: Device): Zigbee2MQTTDevice["definition"] | undefined {
if (!device.definition) {
return undefined;
}
@@ -815,11 +824,11 @@ export default class Bridge extends Extension {
if (icon) {
/* v8 ignore next */
icon = icon.replace('${zigbeeModel}', utils.sanitizeImageParameter(device.zh.modelID ?? ''));
icon = icon.replace('${model}', utils.sanitizeImageParameter(device.definition.model));
icon = icon.replace("${zigbeeModel}", utils.sanitizeImageParameter(device.zh.modelID ?? ""));
icon = icon.replace("${model}", utils.sanitizeImageParameter(device.definition.model));
}
const payload: Zigbee2MQTTDevice['definition'] = {
const payload: Zigbee2MQTTDevice["definition"] = {
model: device.definition.model,
vendor: device.definition.vendor,
description: device.definition.description,
+24 -24
View File
@@ -1,15 +1,15 @@
import type {Zigbee2MQTTAPI} from 'lib/types/api';
import type {Zigbee2MQTTAPI} from "../types/api";
import bind from 'bind-decorator';
import stringify from 'json-stable-stringify-without-jsonify';
import bind from "bind-decorator";
import stringify from "json-stable-stringify-without-jsonify";
import * as zhc from 'zigbee-herdsman-converters';
import * as zhc from "zigbee-herdsman-converters";
import Device from '../model/device';
import logger from '../util/logger';
import * as settings from '../util/settings';
import utils from '../util/utils';
import Extension from './extension';
import Device from "../model/device";
import logger from "../util/logger";
import * as settings from "../util/settings";
import utils from "../util/utils";
import Extension from "./extension";
/**
* This extension calls the zigbee-herdsman-converters definition configure() method
@@ -26,17 +26,17 @@ export default class Configure extends Extension {
data.device.zh.save();
}
await this.configure(data.device, 'reporting_disabled');
await this.configure(data.device, "reporting_disabled");
}
@bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
if (data.topic === this.topic) {
const message = utils.parseJSON(data.message, data.message) as Zigbee2MQTTAPI['bridge/request/device/configure'];
const ID = typeof message === 'object' ? message.id : message;
const message = utils.parseJSON(data.message, data.message) as Zigbee2MQTTAPI["bridge/request/device/configure"];
const ID = typeof message === "object" ? message.id : message;
let error: string | undefined;
if (ID === undefined) {
error = `Invalid payload`;
error = "Invalid payload";
} else {
const device = this.zigbee.resolveEntity(ID);
@@ -46,16 +46,16 @@ export default class Configure extends Extension {
error = `Device '${device.name}' cannot be configured`;
} else {
try {
await this.configure(device, 'mqtt_message', true, true);
await this.configure(device, "mqtt_message", true, true);
} catch (e) {
error = `Failed to configure (${(e as Error).message})`;
}
}
}
const response = utils.getResponse<'bridge/response/device/configure'>(message, {id: ID}, error);
const response = utils.getResponse<"bridge/response/device/configure">(message, {id: ID}, error);
await this.mqtt.publish(`bridge/response/device/configure`, stringify(response));
await this.mqtt.publish("bridge/response/device/configure", stringify(response));
}
}
@@ -63,10 +63,10 @@ export default class Configure extends Extension {
setImmediate(async () => {
// Only configure routers on startup, end devices are likely sleeping and
// will reconfigure once they send a message
for (const device of this.zigbee.devicesIterator((d) => d.type === 'Router')) {
for (const device of this.zigbee.devicesIterator((d) => d.type === "Router")) {
// Sleep 10 seconds between configuring on startup to not DDoS the coordinator when many devices have to be configured.
await utils.sleep(10);
await this.configure(device, 'started');
await this.configure(device, "started");
}
});
@@ -76,17 +76,17 @@ export default class Configure extends Extension {
data.device.zh.save();
}
await this.configure(data.device, 'zigbee_event');
await this.configure(data.device, "zigbee_event");
});
this.eventBus.onDeviceInterview(this, (data) => this.configure(data.device, 'zigbee_event'));
this.eventBus.onLastSeenChanged(this, (data) => this.configure(data.device, 'zigbee_event'));
this.eventBus.onDeviceInterview(this, (data) => this.configure(data.device, "zigbee_event"));
this.eventBus.onLastSeenChanged(this, (data) => this.configure(data.device, "zigbee_event"));
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
this.eventBus.onReconfigure(this, this.onReconfigure);
}
private async configure(
device: Device,
event: 'started' | 'zigbee_event' | 'reporting_disabled' | 'mqtt_message',
event: "started" | "zigbee_event" | "reporting_disabled" | "mqtt_message",
force = false,
throwError = false,
): Promise<void> {
@@ -95,7 +95,7 @@ export default class Configure extends Extension {
}
if (!force) {
if (device.options.disabled || !device.zh.interviewCompleted) {
if (device.options.disabled || !device.interviewed) {
return;
}
@@ -104,7 +104,7 @@ export default class Configure extends Extension {
}
// Only configure end devices when it is active, otherwise it will likely fails as they are sleeping.
if (device.zh.type === 'EndDevice' && event !== 'zigbee_event') {
if (device.zh.type === "EndDevice" && event !== "zigbee_event") {
return;
}
}
+6 -5
View File
@@ -1,6 +1,6 @@
abstract class Extension {
protected zigbee: Zigbee;
protected mqtt: MQTT;
protected mqtt: Mqtt;
protected state: State;
protected publishEntityState: PublishEntityState;
protected eventBus: EventBus;
@@ -12,7 +12,7 @@ abstract class Extension {
* Besides initializing variables, the constructor should do nothing!
*
* @param {Zigbee} zigbee Zigbee controller
* @param {MQTT} mqtt MQTT controller
* @param {Mqtt} mqtt MQTT controller
* @param {State} state State controller
* @param {Function} publishEntityState Method to publish device state to MQTT.
* @param {EventBus} eventBus The event bus
@@ -22,7 +22,7 @@ abstract class Extension {
*/
constructor(
zigbee: Zigbee,
mqtt: MQTT,
mqtt: Mqtt,
state: State,
publishEntityState: PublishEntityState,
eventBus: EventBus,
@@ -48,12 +48,13 @@ abstract class Extension {
/**
* Is called once the extension has to stop
*/
// biome-ignore lint/suspicious/useAwait: API
async stop(): Promise<void> {
this.eventBus.removeListeners(this);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public adjustMessageBeforePublish(entity: Group | Device, message: KeyValue): void {}
public adjustMessageBeforePublish(_entity: Group | Device, _message: KeyValue): void {}
}
export default Extension;
+20 -18
View File
@@ -1,16 +1,16 @@
import type * as zhc from 'zigbee-herdsman-converters';
import type {ExternalDefinitionWithExtend} from "zigbee-herdsman-converters";
import {addExternalDefinition, removeExternalDefinitions} from 'zigbee-herdsman-converters';
import {addExternalDefinition, removeExternalDefinitions} from "zigbee-herdsman-converters";
import logger from '../util/logger';
import ExternalJSExtension from './externalJS';
import logger from "../util/logger";
import ExternalJSExtension from "./externalJS";
type ModuleExports = zhc.ExternalDefinitionWithExtend | zhc.ExternalDefinitionWithExtend[];
type TModule = ExternalDefinitionWithExtend | ExternalDefinitionWithExtend[];
export default class ExternalConverters extends ExternalJSExtension<ModuleExports> {
export default class ExternalConverters extends ExternalJSExtension<TModule> {
constructor(
zigbee: Zigbee,
mqtt: MQTT,
mqtt: Mqtt,
state: State,
publishEntityState: PublishEntityState,
eventBus: EventBus,
@@ -27,36 +27,38 @@ export default class ExternalConverters extends ExternalJSExtension<ModuleExport
enableDisableExtension,
restartCallback,
addExtension,
'converter',
'external_converters',
"converter",
"external_converters",
);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected async removeJS(name: string, module: ModuleExports): Promise<void> {
protected async removeJS(name: string, _mod: TModule): Promise<void> {
removeExternalDefinitions(name);
await this.zigbee.resolveDevicesDefinitions(true);
}
protected async loadJS(name: string, module: ModuleExports): Promise<void> {
protected async loadJS(name: string, mod: TModule, newName?: string): Promise<void> {
try {
removeExternalDefinitions(name);
const definitions = Array.isArray(module) ? module : [module];
const definitions = Array.isArray(mod) ? mod : [mod];
for (const definition of definitions) {
definition.externalConverterName = name;
definition.externalConverterName = newName ?? name;
addExternalDefinition(definition);
logger.info(`Loaded external converter '${name}'.`);
logger.info(`Loaded external converter '${newName ?? name}'.`);
}
await this.zigbee.resolveDevicesDefinitions(true);
} catch (error) {
logger.error(`Failed to load external converter '${name}'`);
logger.error(`Check the code for syntax error and make sure it is up to date with the current Zigbee2MQTT version.`);
logger.error(
`External converters are not meant for long term usage, but for local testing after which a pull request should be created to add out-of-the-box support for the device`,
/* v8 ignore next */
`Failed to load external converter '${newName ?? name}'. Check the code for syntax error and make sure it is up to date with the current Zigbee2MQTT version.`,
);
logger.warning(
"External converters are not meant for long term usage, but for local testing after which a pull request should be created to add out-of-the-box support for the device",
);
throw error;
+41 -30
View File
@@ -1,15 +1,15 @@
import type Extension from './extension';
import type Extension from "./extension";
import logger from '../util/logger';
import * as settings from '../util/settings';
import ExternalJSExtension from './externalJS';
import logger from "../util/logger";
import * as settings from "../util/settings";
import ExternalJSExtension from "./externalJS";
type ModuleExports = typeof Extension;
type TModule = new (...args: ConstructorParameters<typeof Extension>) => Extension;
export default class ExternalExtensions extends ExternalJSExtension<ModuleExports> {
export default class ExternalExtensions extends ExternalJSExtension<TModule> {
constructor(
zigbee: Zigbee,
mqtt: MQTT,
mqtt: Mqtt,
state: State,
publishEntityState: PublishEntityState,
eventBus: EventBus,
@@ -26,34 +26,45 @@ export default class ExternalExtensions extends ExternalJSExtension<ModuleExport
enableDisableExtension,
restartCallback,
addExtension,
'extension',
'external_extensions',
"extension",
"external_extensions",
);
}
protected async removeJS(name: string, module: ModuleExports): Promise<void> {
await this.enableDisableExtension(false, module.name);
protected async removeJS(_name: string, mod: TModule): Promise<void> {
await this.enableDisableExtension(false, mod.name);
}
protected async loadJS(name: string, module: ModuleExports): Promise<void> {
// stop if already started
await this.enableDisableExtension(false, module.name);
await this.addExtension(
// @ts-expect-error `module` is the interface, not the actual passed class
new module(
this.zigbee,
this.mqtt,
this.state,
this.publishEntityState,
this.eventBus,
this.enableDisableExtension,
this.restartCallback,
this.addExtension,
settings,
logger,
),
);
protected async loadJS(name: string, mod: TModule, newName?: string): Promise<void> {
try {
// stop if already started
await this.enableDisableExtension(false, mod.name);
await this.addExtension(
new mod(
this.zigbee,
this.mqtt,
this.state,
this.publishEntityState,
this.eventBus,
this.enableDisableExtension,
this.restartCallback,
this.addExtension,
// @ts-expect-error additional params that don't fit the internal `Extension` type
settings,
logger,
),
);
logger.info(`Loaded external extension '${name}'.`);
/* v8 ignore start */
logger.info(`Loaded external extension '${newName ?? name}'.`);
} catch (error) {
logger.error(
/* v8 ignore next */
`Failed to load external extension '${newName ?? name}'. Check the code for syntax error and make sure it is up to date with the current Zigbee2MQTT version.`,
);
throw error;
}
/* v8 ignore stop */
}
}
+115 -62
View File
@@ -1,28 +1,29 @@
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponse} from 'lib/types/api';
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponse} from "../types/api";
import fs from 'node:fs';
import path from 'node:path';
import {Context, runInNewContext} from 'node:vm';
import fs from "node:fs";
import path from "node:path";
import bind from 'bind-decorator';
import stringify from 'json-stable-stringify-without-jsonify';
import bind from "bind-decorator";
import stringify from "json-stable-stringify-without-jsonify";
import data from '../util/data';
import logger from '../util/logger';
import * as settings from '../util/settings';
import utils from '../util/utils';
import Extension from './extension';
import data from "../util/data";
import logger from "../util/logger";
import * as settings from "../util/settings";
import utils from "../util/utils";
import Extension from "./extension";
const SUPPORTED_OPERATIONS = ['save', 'remove'];
const SUPPORTED_OPERATIONS = ["save", "remove"];
export default abstract class ExternalJSExtension<M> extends Extension {
protected folderName: string;
protected mqttTopic: string;
protected requestRegex: RegExp;
protected basePath: string;
protected srcBasePath: string;
constructor(
zigbee: Zigbee,
mqtt: MQTT,
mqtt: Mqtt,
state: State,
publishEntityState: PublishEntityState,
eventBus: EventBus,
@@ -34,9 +35,17 @@ export default abstract class ExternalJSExtension<M> extends Extension {
) {
super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension);
this.folderName = folderName;
this.mqttTopic = mqttTopic;
this.requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/${mqttTopic}/(save|remove)`);
this.basePath = data.joinPath(folderName);
// 1-up from this file
this.srcBasePath = path.join(
__dirname,
"..",
// prevent race in vitest with files being manipulated from same location
process.env.VITEST_WORKER_ID ? /* v8 ignore next */ `${folderName}_${Math.floor(Math.random() * 10000)}` : folderName,
);
}
override async start(): Promise<void> {
@@ -46,25 +55,35 @@ export default abstract class ExternalJSExtension<M> extends Extension {
await this.publishExternalJS();
}
private getFilePath(name: string, mkBasePath: boolean = false): string {
if (mkBasePath && !fs.existsSync(this.basePath)) {
fs.mkdirSync(this.basePath, {recursive: true});
override async stop(): Promise<void> {
// remove src base path on stop to ensure always back to default
fs.rmSync(this.srcBasePath, {force: true, recursive: true});
await super.stop();
}
private getFilePath(name: string, mkBasePath = false, inSource = false): string {
const basePath = inSource ? this.srcBasePath : this.basePath;
if (mkBasePath && !fs.existsSync(basePath)) {
fs.mkdirSync(basePath, {recursive: true});
}
return path.join(this.basePath, name);
return path.join(basePath, name);
}
protected getFileCode(name: string): string {
return fs.readFileSync(path.join(this.basePath, name), 'utf8');
return fs.readFileSync(this.getFilePath(name), "utf8");
}
protected *getFiles(): Generator<{name: string; code: string}> {
if (!fs.existsSync(this.basePath)) {
protected *getFiles(inSource = false): Generator<{name: string; code: string}> {
const basePath = inSource ? this.srcBasePath : this.basePath;
if (!fs.existsSync(basePath)) {
return;
}
for (const fileName of fs.readdirSync(this.basePath)) {
if (fileName.endsWith('.js')) {
for (const fileName of fs.readdirSync(basePath)) {
if (fileName.endsWith(".js") || fileName.endsWith(".cjs") || fileName.endsWith(".mjs")) {
yield {name: fileName, code: this.getFileCode(fileName)};
}
}
@@ -77,15 +96,15 @@ export default abstract class ExternalJSExtension<M> extends Extension {
const message = utils.parseJSON(data.message, data.message);
try {
let response;
let response: Awaited<ReturnType<typeof this.save | typeof this.remove>>;
if (match[1].toLowerCase() === 'save') {
if (match[1].toLowerCase() === "save") {
response = await this.save(
message as Zigbee2MQTTAPI['bridge/request/converter/save'] | Zigbee2MQTTAPI['bridge/request/extension/save'],
message as Zigbee2MQTTAPI["bridge/request/converter/save"] | Zigbee2MQTTAPI["bridge/request/extension/save"],
);
} else {
response = await this.remove(
message as Zigbee2MQTTAPI['bridge/request/converter/remove'] | Zigbee2MQTTAPI['bridge/request/extension/remove'],
message as Zigbee2MQTTAPI["bridge/request/converter/remove"] | Zigbee2MQTTAPI["bridge/request/extension/remove"],
);
}
@@ -100,67 +119,115 @@ export default abstract class ExternalJSExtension<M> extends Extension {
}
}
protected abstract removeJS(name: string, module: M): Promise<void>;
protected abstract removeJS(name: string, mod: M): Promise<void>;
protected abstract loadJS(name: string, module: M): Promise<void>;
protected abstract loadJS(name: string, mod: M, newName?: string): Promise<void>;
@bind private async remove(
message: Zigbee2MQTTAPI['bridge/request/converter/remove'] | Zigbee2MQTTAPI['bridge/request/extension/remove'],
): Promise<Zigbee2MQTTResponse<'bridge/response/converter/remove' | 'bridge/response/extension/remove'>> {
message: Zigbee2MQTTAPI["bridge/request/converter/remove"] | Zigbee2MQTTAPI["bridge/request/extension/remove"],
): Promise<Zigbee2MQTTResponse<"bridge/response/converter/remove" | "bridge/response/extension/remove">> {
if (!message.name) {
return utils.getResponse(message, {}, `Invalid payload`);
return utils.getResponse(message, {}, "Invalid payload");
}
const {name} = message;
const srcToBeRemoved = this.getFilePath(name, false, true);
const toBeRemoved = this.getFilePath(name);
if (fs.existsSync(toBeRemoved)) {
await this.removeJS(name, this.loadModuleFromText(this.getFileCode(name), name));
if (fs.existsSync(srcToBeRemoved)) {
const mod = await import(this.getImportPath(srcToBeRemoved));
await this.removeJS(name, mod.default);
fs.rmSync(srcToBeRemoved, {force: true});
fs.rmSync(toBeRemoved, {force: true});
logger.info(`${name} (${toBeRemoved}) removed.`);
await this.publishExternalJS();
return utils.getResponse(message, {});
} else {
return utils.getResponse(message, {}, `${name} (${toBeRemoved}) doesn't exists`);
}
return utils.getResponse(message, {}, `${name} (${srcToBeRemoved}) doesn't exists`);
}
@bind private async save(
message: Zigbee2MQTTAPI['bridge/request/converter/save'] | Zigbee2MQTTAPI['bridge/request/extension/save'],
): Promise<Zigbee2MQTTResponse<'bridge/response/converter/save' | 'bridge/response/extension/save'>> {
message: Zigbee2MQTTAPI["bridge/request/converter/save"] | Zigbee2MQTTAPI["bridge/request/extension/save"],
): Promise<Zigbee2MQTTResponse<"bridge/response/converter/save" | "bridge/response/extension/save">> {
if (!message.name || !message.code) {
return utils.getResponse(message, {}, `Invalid payload`);
return utils.getResponse(message, {}, "Invalid payload");
}
const {name, code} = message;
const srcFilePath = this.getFilePath(name, true, true);
let newName = name;
if (fs.existsSync(srcFilePath)) {
// if file already exist, version it to bypass node module caching
const versionMatch = name.match(/\.(\d+)\.(c|m)?js$/);
if (versionMatch) {
const version = Number.parseInt(versionMatch[1], 10);
newName = name.replace(`.${version}.`, `.${version + 1}.`);
} else {
const ext = path.extname(name);
newName = name.replace(ext, `.1${ext}`);
}
// remove previous version
fs.rmSync(srcFilePath, {force: true});
fs.rmSync(this.getFilePath(name, true, false), {force: true});
}
const newSrcFilePath = this.getFilePath(newName, false /* already created above if needed */, true);
try {
await this.loadJS(name, this.loadModuleFromText(code, name));
fs.writeFileSync(newSrcFilePath, code, "utf8");
const filePath = this.getFilePath(name, true);
const mod = await import(this.getImportPath(newSrcFilePath));
fs.writeFileSync(filePath, code, 'utf8');
logger.info(`${name} loaded. Contents written to '${filePath}'.`);
await this.loadJS(name, mod.default, newName);
logger.info(`${newName} loaded. Contents written to '${newSrcFilePath}'.`);
// keep original in data folder synced
fs.writeFileSync(this.getFilePath(newName, true, false), code, "utf8");
await this.publishExternalJS();
return utils.getResponse(message, {});
} catch (error) {
return utils.getResponse(message, {}, `${name} contains invalid code: ${(error as Error).message}`);
fs.rmSync(newSrcFilePath, {force: true});
// NOTE: original in data folder doesn't get written if invalid
return utils.getResponse(message, {}, `${newName} contains invalid code: ${(error as Error).message}`);
}
}
private async loadFiles(): Promise<void> {
for (const extension of this.getFiles()) {
await this.loadJS(extension.name, this.loadModuleFromText(extension.code, extension.name));
const srcFilePath = this.getFilePath(extension.name, true, true);
const filePath = this.getFilePath(extension.name);
try {
fs.copyFileSync(filePath, srcFilePath);
const mod = await import(this.getImportPath(srcFilePath));
await this.loadJS(extension.name, mod.default);
} catch (error) {
// change ext so Z2M doesn't try to load it again and again
fs.renameSync(filePath, `${filePath}.invalid`);
fs.rmSync(srcFilePath, {force: true});
logger.error(
`Invalid external ${this.mqttTopic} '${extension.name}' was ignored and renamed to prevent interference with Zigbee2MQTT.`,
);
// biome-ignore lint/style/noNonNullAssertion: always Error
logger.debug((error as Error).stack!);
}
}
}
private async publishExternalJS(): Promise<void> {
await this.mqtt.publish(
`bridge/${this.mqttTopic}s`,
stringify(Array.from(this.getFiles())),
stringify(Array.from(this.getFiles(true))),
{
retain: true,
qos: 0,
@@ -170,22 +237,8 @@ export default abstract class ExternalJSExtension<M> extends Extension {
);
}
private loadModuleFromText(moduleCode: string, name: string): M {
const moduleFakePath = path.join(__dirname, '..', '..', 'data', 'extension', name);
const sandbox: Context = {
require: require,
module: {},
console,
setTimeout,
clearTimeout,
setInterval,
clearInterval,
setImmediate,
clearImmediate,
};
runInNewContext(moduleCode, sandbox, moduleFakePath);
return sandbox.module.exports;
private getImportPath(filePath: string): string {
// prevent issues on Windows
return path.relative(__dirname, filePath).replaceAll("\\", "/");
}
}
+58 -50
View File
@@ -1,33 +1,33 @@
import type {IncomingMessage, Server, ServerResponse} from 'node:http';
import type {Socket} from 'node:net';
import type {IncomingMessage, Server, ServerResponse} from "node:http";
import type {Socket} from "node:net";
import type {RequestHandler} from 'express-static-gzip';
import type {RequestHandler} from "express-static-gzip";
import assert from 'node:assert';
import {existsSync, readFileSync} from 'node:fs';
import {createServer} from 'node:http';
import {createServer as createSecureServer} from 'node:https';
import {posix} from 'node:path';
import {parse} from 'node:url';
import assert from "node:assert";
import {existsSync, readFileSync} from "node:fs";
import {createServer} from "node:http";
import {createServer as createSecureServer} from "node:https";
import {posix} from "node:path";
import {parse} from "node:url";
import bind from 'bind-decorator';
import expressStaticGzip from 'express-static-gzip';
import finalhandler from 'finalhandler';
import stringify from 'json-stable-stringify-without-jsonify';
import WebSocket from 'ws';
import bind from "bind-decorator";
import expressStaticGzip from "express-static-gzip";
import finalhandler from "finalhandler";
import stringify from "json-stable-stringify-without-jsonify";
import WebSocket from "ws";
import frontend from 'zigbee2mqtt-frontend';
import frontend from "zigbee2mqtt-frontend";
import data from '../util/data';
import logger from '../util/logger';
import * as settings from '../util/settings';
import utils from '../util/utils';
import Extension from './extension';
import data from "../util/data";
import logger from "../util/logger";
import * as settings from "../util/settings";
import utils from "../util/utils";
import Extension from "./extension";
/**
* This extension servers the frontend
*/
export default class Frontend extends Extension {
export class Frontend extends Extension {
private mqttBaseTopic: string;
private host: string | undefined;
private port: number;
@@ -42,7 +42,7 @@ export default class Frontend extends Extension {
constructor(
zigbee: Zigbee,
mqtt: MQTT,
mqtt: Mqtt,
state: State,
publishEntityState: PublishEntityState,
eventBus: EventBus,
@@ -80,39 +80,37 @@ export default class Frontend extends Extension {
// TODO: https://github.com/Koenkk/zigbee2mqtt/issues/24654 - enable compressed index serving when express-static-gzip is fixed.
index: false,
serveStatic: {
index: 'index.html',
index: "index.html",
/* v8 ignore start */
setHeaders: (res: ServerResponse, path: string): void => {
if (path.endsWith('index.html')) {
res.setHeader('Cache-Control', 'no-store');
if (path.endsWith("index.html")) {
res.setHeader("Cache-Control", "no-store");
}
},
/* v8 ignore stop */
},
};
this.fileServer = expressStaticGzip(frontend.getPath(), options);
this.deviceIconsFileServer = expressStaticGzip(data.joinPath('device_icons'), options);
this.wss = new WebSocket.Server({noServer: true, path: posix.join(this.baseUrl, 'api')});
this.deviceIconsFileServer = expressStaticGzip(data.joinPath("device_icons"), options);
this.wss = new WebSocket.Server({noServer: true, path: posix.join(this.baseUrl, "api")});
this.wss.on('connection', this.onWebSocketConnection);
this.wss.on("connection", this.onWebSocketConnection);
if (this.isHttpsConfigured()) {
const serverOptions = {
key: readFileSync(this.sslKey!), // valid from `isHttpsConfigured`
cert: readFileSync(this.sslCert!), // valid from `isHttpsConfigured`
};
// biome-ignore lint/style/noNonNullAssertion: valid from `isHttpsConfigured`
const serverOptions = {key: readFileSync(this.sslKey!), cert: readFileSync(this.sslCert!)};
this.server = createSecureServer(serverOptions, this.onRequest);
} else {
this.server = createServer(this.onRequest);
}
this.server.on('upgrade', this.onUpgrade);
this.server.on("upgrade", this.onUpgrade);
this.eventBus.onMQTTMessagePublished(this, this.onMQTTPublishMessage);
if (!this.host) {
this.server.listen(this.port);
logger.info(`Started frontend on port ${this.port}`);
} else if (this.host.startsWith('/')) {
} else if (this.host.startsWith("/")) {
this.server.listen(this.host);
logger.info(`Started frontend on socket ${this.host}`);
} else {
@@ -123,33 +121,40 @@ export default class Frontend extends Extension {
override async stop(): Promise<void> {
await super.stop();
this.wss?.clients.forEach((client) => {
client.send(stringify({topic: 'bridge/state', payload: 'offline'}));
client.terminate();
});
this.wss?.close();
if (this.wss) {
for (const client of this.wss.clients) {
client.send(stringify({topic: "bridge/state", payload: {state: "offline"}}));
client.terminate();
}
this.wss.close();
}
await new Promise((resolve) => this.server?.close(resolve));
}
@bind private onRequest(request: IncomingMessage, response: ServerResponse): void {
const fin = finalhandler(request, response);
// biome-ignore lint/style/noNonNullAssertion: `Only valid for request obtained from Server`
const newUrl = posix.relative(this.baseUrl, request.url!);
// The request url is not within the frontend base url, so the relative path starts with '..'
if (newUrl.startsWith('.')) {
return fin();
if (newUrl.startsWith(".")) {
fin();
return;
}
// Attach originalUrl so that static-server can perform a redirect to '/' when serving the root directory.
// This is necessary for the browser to resolve relative assets paths correctly.
request.originalUrl = request.url;
request.url = '/' + newUrl;
request.url = `/${newUrl}`;
request.path = request.url;
if (newUrl.startsWith('device_icons/')) {
request.path = request.path.replace('device_icons/', '');
request.url = request.url.replace('/device_icons', '');
if (newUrl.startsWith("device_icons/")) {
request.path = request.path.replace("device_icons/", "");
request.url = request.url.replace("/device_icons", "");
this.deviceIconsFileServer(request, response, fin);
} else {
this.fileServer(request, response, fin);
@@ -157,6 +162,7 @@ export default class Frontend extends Extension {
}
private authenticate(request: IncomingMessage, cb: (authenticate: boolean) => void): void {
// biome-ignore lint/style/noNonNullAssertion: `Only valid for request obtained from Server`
const {query} = parse(request.url!, true);
cb(!this.authToken || this.authToken === query.token);
}
@@ -165,17 +171,17 @@ export default class Frontend extends Extension {
this.wss.handleUpgrade(request, socket, head, (ws) => {
this.authenticate(request, (isAuthenticated) => {
if (isAuthenticated) {
this.wss.emit('connection', ws, request);
this.wss.emit("connection", ws, request);
} else {
ws.close(4401, 'Unauthorized');
ws.close(4401, "Unauthorized");
}
});
});
}
@bind private onWebSocketConnection(ws: WebSocket): void {
ws.on('error', (msg) => logger.error(`WebSocket error: ${msg.message}`));
ws.on('message', (data: Buffer, isBinary: boolean) => {
ws.on("error", (msg) => logger.error(`WebSocket error: ${msg.message}`));
ws.on("message", (data: Buffer, isBinary: boolean) => {
if (!isBinary && data) {
const message = data.toString();
const {topic, payload} = JSON.parse(message);
@@ -199,7 +205,7 @@ export default class Frontend extends Extension {
const payload = this.state.get(device);
const lastSeen = settings.get().advanced.last_seen;
if (lastSeen !== 'disable') {
if (lastSeen !== "disable") {
payload.last_seen = utils.formatDate(device.zh.lastSeen ?? /* v8 ignore next */ 0, lastSeen);
}
@@ -225,3 +231,5 @@ export default class Frontend extends Extension {
}
}
}
export default Frontend;
+67 -64
View File
@@ -1,37 +1,37 @@
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from 'lib/types/api';
import type * as zhc from "zigbee-herdsman-converters";
import assert from 'node:assert';
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from "../types/api";
import bind from 'bind-decorator';
import equals from 'fast-deep-equal/es6';
import stringify from 'json-stable-stringify-without-jsonify';
import assert from "node:assert";
import * as zhc from 'zigbee-herdsman-converters';
import bind from "bind-decorator";
import equals from "fast-deep-equal/es6";
import stringify from "json-stable-stringify-without-jsonify";
import Device from '../model/device';
import Group from '../model/group';
import logger from '../util/logger';
import * as settings from '../util/settings';
import utils, {isLightExpose} from '../util/utils';
import Extension from './extension';
import Device from "../model/device";
import Group from "../model/group";
import logger from "../util/logger";
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')),
color_temp: (value, exposes) => exposes.some((e) => isLightExpose(e) && e.features.some((f) => f.name === 'color_temp')),
color: (value, exposes) => exposes.some((e) => isLightExpose(e) && e.features.some((f) => f.name === 'color_xy' || f.name === 'color_hs')),
brightness: (_value, exposes) => exposes.some((e) => isLightExpose(e) && e.features.some((f) => f.name === "brightness")),
color_temp: (_value, exposes) => exposes.some((e) => isLightExpose(e) && e.features.some((f) => f.name === "color_temp")),
color: (_value, exposes) => exposes.some((e) => isLightExpose(e) && e.features.some((f) => f.name === "color_xy" || f.name === "color_hs")),
color_mode: (value, exposes) =>
exposes.some(
(e) =>
isLightExpose(e) &&
(e.features.some((f) => f.name === `color_${value}`) || (value === 'color_temp' && e.features.some((f) => f.name === 'color_temp'))),
(e.features.some((f) => f.name === `color_${value}`) || (value === "color_temp" && e.features.some((f) => f.name === "color_temp"))),
),
};
interface ParsedMQTTMessage {
type: 'remove' | 'add' | 'remove_all';
type: "remove" | "add" | "remove_all";
resolvedGroup?: Group;
resolvedDevice?: Device;
resolvedEndpoint?: zh.Endpoint;
@@ -44,15 +44,16 @@ interface ParsedMQTTMessage {
export default class Groups extends Extension {
private lastOptimisticState: {[s: string]: KeyValue} = {};
// biome-ignore lint/suspicious/useAwait: API
override async start(): Promise<void> {
this.eventBus.onStateChange(this, this.onStateChange);
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
}
@bind async onStateChange(data: eventdata.StateChange): Promise<void> {
const reason = 'groupOptimistic';
const reason = "groupOptimistic";
if (data.reason === reason || data.reason === 'publishCached') {
if (data.reason === reason || data.reason === "publishCached") {
return;
}
@@ -81,7 +82,7 @@ export default class Groups extends Extension {
const groups = [];
for (const group of this.zigbee.groupsIterator()) {
if (group.options && (group.options.optimistic == undefined || group.options.optimistic)) {
if (group.options && (group.options.optimistic == null || group.options.optimistic)) {
groups.push(group);
}
}
@@ -106,7 +107,7 @@ export default class Groups extends Extension {
// Invalidate the last optimistic group state when group state is changed directly.
delete this.lastOptimisticState[entity.ID];
const groupsToPublish: Set<Group> = new Set();
const groupsToPublish = new Set<Group>();
for (const member of entity.zh.members) {
const device = this.zigbee.resolveEntity(member.getDevice()) as Device;
@@ -153,15 +154,16 @@ export default class Groups extends Extension {
private shouldPublishPayloadForGroup(group: Group, payload: KeyValue): boolean {
return (
group.options.off_state === 'last_member_state' ||
group.options.off_state === "last_member_state" ||
!payload ||
(payload.state !== 'OFF' && payload.state !== 'CLOSE') ||
(payload.state !== "OFF" && payload.state !== "CLOSE") ||
this.areAllMembersOffOrClosed(group)
);
}
private areAllMembersOffOrClosed(group: Group): boolean {
for (const member of group.zh.members) {
// biome-ignore lint/style/noNonNullAssertion: TODO: biome migration: valid from loop?
const device = this.zigbee.resolveEntity(member.getDevice())!;
if (this.state.exists(device)) {
@@ -171,11 +173,11 @@ export default class Groups extends Extension {
endpointNames &&
endpointNames.length >= member.ID &&
device.definition?.meta?.multiEndpoint &&
(!device.definition.meta.multiEndpointSkip || !device.definition.meta.multiEndpointSkip.includes('state'))
(!device.definition.meta.multiEndpointSkip || !device.definition.meta.multiEndpointSkip.includes("state"))
? `state_${endpointNames[member.ID - 1]}`
: 'state';
: "state";
if (state[stateKey] === 'ON' || state[stateKey] === 'OPEN') {
if (state[stateKey] === "ON" || state[stateKey] === "OPEN") {
return false;
}
}
@@ -190,31 +192,36 @@ export default class Groups extends Extension {
const topicRegexMatch = data.topic.match(TOPIC_REGEX);
if (topicRegexMatch) {
const type = topicRegexMatch[1] as 'remove' | 'add' | 'remove_all';
let resolvedGroup;
let groupKey;
const type = topicRegexMatch[1] as "remove" | "add" | "remove_all";
let resolvedGroup: Group | undefined;
let groupKey: string | undefined;
let skipDisableReporting = false;
const message = JSON.parse(data.message) as Zigbee2MQTTAPI['bridge/request/group/members/add'];
const message = JSON.parse(data.message) as
| Zigbee2MQTTAPI["bridge/request/group/members/add"]
| Zigbee2MQTTAPI["bridge/request/group/members/remove"]
| Zigbee2MQTTAPI["bridge/request/group/members/remove_all"];
if (typeof message !== 'object' || message.device == undefined) {
return [message, {type, skipDisableReporting}, 'Invalid payload'];
if (typeof message !== "object" || message.device == null) {
return [message, {type, skipDisableReporting}, "Invalid payload"];
}
const deviceKey = message.device;
skipDisableReporting = message.skip_disable_reporting != undefined ? message.skip_disable_reporting : false;
skipDisableReporting = message.skip_disable_reporting != null ? message.skip_disable_reporting : false;
if (type !== "remove_all") {
if (!("group" in message) || message.group == null) {
return [message, {type, skipDisableReporting}, "Invalid payload"];
}
if (type !== 'remove_all') {
groupKey = message.group;
if (message.group == undefined) {
return [message, {type, skipDisableReporting}, `Invalid payload`];
}
const group = this.zigbee.resolveEntity(message.group);
resolvedGroup = this.zigbee.resolveEntity(message.group);
if (!resolvedGroup || !(resolvedGroup instanceof Group)) {
if (!group || !(group instanceof Group)) {
return [message, {type, skipDisableReporting}, `Group '${message.group}' does not exist`];
}
resolvedGroup = group;
}
const resolvedDevice = this.zigbee.resolveEntity(message.device);
@@ -223,7 +230,7 @@ export default class Groups extends Extension {
return [message, {type, skipDisableReporting}, `Device '${message.device}' does not exist`];
}
const endpointKey = message.endpoint ?? 'default';
const endpointKey = message.endpoint ?? "default";
const resolvedEndpoint = resolvedDevice.endpoint(message.endpoint);
if (!resolvedEndpoint) {
@@ -244,9 +251,9 @@ export default class Groups extends Extension {
},
undefined,
];
} else {
return [undefined, undefined, undefined];
}
return [undefined, undefined, undefined];
}
@bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
@@ -264,30 +271,26 @@ export default class Groups extends Extension {
const {resolvedGroup, resolvedDevice, resolvedEndpoint, type, groupKey, deviceKey, endpointKey, skipDisableReporting} = parsed;
const changedGroups: Group[] = [];
assert(resolvedDevice, '`resolvedDevice` is missing');
assert(resolvedEndpoint, '`resolvedEndpoint` is missing');
assert(resolvedDevice, "`resolvedDevice` is missing");
assert(resolvedEndpoint, "`resolvedEndpoint` is missing");
try {
if (type === 'add') {
assert(resolvedGroup, '`resolvedGroup` is missing');
if (type === "add") {
assert(resolvedGroup, "`resolvedGroup` is missing");
logger.info(`Adding '${resolvedDevice.name}' to '${resolvedGroup.name}'`);
await resolvedEndpoint.addToGroup(resolvedGroup.zh);
changedGroups.push(resolvedGroup);
await this.publishResponse<'bridge/response/group/members/add'>(parsed.type, raw, {
device: deviceKey!, // valid from resolved asserts
endpoint: endpointKey!, // valid from resolved asserts
group: groupKey!, // valid from resolved asserts
});
} else if (type === 'remove') {
assert(resolvedGroup, '`resolvedGroup` is missing');
// biome-ignore lint/style/noNonNullAssertion: valid from resolved asserts
const respPayload = {device: deviceKey!, endpoint: endpointKey!, group: groupKey!};
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}'`);
await resolvedEndpoint.removeFromGroup(resolvedGroup.zh);
changedGroups.push(resolvedGroup);
await this.publishResponse<'bridge/response/group/members/remove'>(parsed.type, raw, {
device: deviceKey!, // valid from resolved asserts
endpoint: endpointKey!, // valid from resolved asserts
group: groupKey!, // valid from resolved asserts
});
// biome-ignore lint/style/noNonNullAssertion: valid from resolved asserts
const respPayload = {device: deviceKey!, endpoint: endpointKey!, group: groupKey!};
await this.publishResponse<"bridge/response/group/members/remove">(parsed.type, raw, respPayload);
} else {
// remove_all
logger.info(`Removing '${resolvedDevice.name}' from all groups`);
@@ -297,14 +300,14 @@ export default class Groups extends Extension {
}
await resolvedEndpoint.removeFromAllGroups();
await this.publishResponse<'bridge/response/group/members/remove_all'>(parsed.type, raw, {
device: deviceKey!, // valid from resolved asserts
endpoint: endpointKey!, // valid from resolved asserts
});
// biome-ignore lint/style/noNonNullAssertion: valid from resolved asserts
const respPayload = {device: deviceKey!, endpoint: endpointKey!};
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})`;
await this.publishResponse(parsed.type, raw, {}, errorMsg);
// biome-ignore lint/style/noNonNullAssertion: always Error
logger.debug((e as Error).stack!);
return;
}
@@ -315,7 +318,7 @@ export default class Groups extends Extension {
}
private async publishResponse<T extends Zigbee2MQTTResponseEndpoints>(
type: ParsedMQTTMessage['type'],
type: ParsedMQTTMessage["type"],
request: KeyValue,
data: Zigbee2MQTTAPI[T],
error?: string,
File diff suppressed because it is too large Load Diff
+98 -92
View File
@@ -1,14 +1,14 @@
import type {Zigbee2MQTTAPI, Zigbee2MQTTNetworkMap} from 'lib/types/api';
import type {Zigbee2MQTTAPI, Zigbee2MQTTNetworkMap} from "../types/api";
import bind from 'bind-decorator';
import stringify from 'json-stable-stringify-without-jsonify';
import bind from "bind-decorator";
import stringify from "json-stable-stringify-without-jsonify";
import logger from '../util/logger';
import * as settings from '../util/settings';
import utils from '../util/utils';
import Extension from './extension';
import logger from "../util/logger";
import * as settings from "../util/settings";
import utils from "../util/utils";
import Extension from "./extension";
const SUPPORTED_FORMATS = ['raw', 'graphviz', 'plantuml'];
const SUPPORTED_FORMATS = ["raw", "graphviz", "plantuml"];
/**
* This extension creates a network map
@@ -16,43 +16,44 @@ const SUPPORTED_FORMATS = ['raw', 'graphviz', 'plantuml'];
export default class NetworkMap extends Extension {
private topic = `${settings.get().mqtt.base_topic}/bridge/request/networkmap`;
// biome-ignore lint/suspicious/useAwait: API
override async start(): Promise<void> {
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
}
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
if (data.topic === this.topic) {
const message = utils.parseJSON(data.message, data.message) as Zigbee2MQTTAPI['bridge/request/networkmap'];
const message = utils.parseJSON(data.message, data.message) as Zigbee2MQTTAPI["bridge/request/networkmap"];
try {
const type = typeof message === 'object' ? message.type : message;
const type = typeof message === "object" ? message.type : message;
if (!SUPPORTED_FORMATS.includes(type)) {
throw new Error(`Type '${type}' not supported, allowed are: ${SUPPORTED_FORMATS.join(',')}`);
throw new Error(`Type '${type}' not supported, allowed are: ${SUPPORTED_FORMATS.join(",")}`);
}
const routes = typeof message === 'object' && message.routes;
const routes = typeof message === "object" && message.routes;
const topology = await this.networkScan(routes);
let responseData: Zigbee2MQTTAPI['bridge/response/networkmap'];
let responseData: Zigbee2MQTTAPI["bridge/response/networkmap"];
switch (type) {
case 'raw': {
case "raw": {
responseData = {type, routes, value: this.raw(topology)};
break;
}
case 'graphviz': {
case "graphviz": {
responseData = {type, routes, value: this.graphviz(topology)};
break;
}
case 'plantuml': {
case "plantuml": {
responseData = {type, routes, value: this.plantuml(topology)};
break;
}
}
await this.mqtt.publish('bridge/response/networkmap', stringify(utils.getResponse(message, responseData)));
await this.mqtt.publish("bridge/response/networkmap", stringify(utils.getResponse(message, responseData)));
} catch (error) {
await this.mqtt.publish('bridge/response/networkmap', stringify(utils.getResponse(message, {}, (error as Error).message)));
await this.mqtt.publish("bridge/response/networkmap", stringify(utils.getResponse(message, {}, (error as Error).message)));
}
}
}
@@ -64,10 +65,10 @@ export default class NetworkMap extends Extension {
graphviz(topology: Zigbee2MQTTNetworkMap): string {
const colors = settings.get().map_options.graphviz.colors;
let text = 'digraph G {\nnode[shape=record];\n';
let style = '';
let text = "digraph G {\nnode[shape=record];\n";
let style = "";
topology.nodes.forEach((node) => {
for (const node of topology.nodes) {
const labels = [];
// Add friendly name
@@ -75,118 +76,119 @@ export default class NetworkMap extends Extension {
// Add the device short network address, ieeaddr and scan note (if any)
labels.push(
`${node.ieeeAddr} (${utils.toNetworkAddressHex(node.networkAddress)})` +
(node.failed && node.failed.length ? `failed: ${node.failed.join(',')}` : ''),
`${node.ieeeAddr} (${utils.toNetworkAddressHex(node.networkAddress)})${node.failed?.length ? `failed: ${node.failed.join(",")}` : ""}`,
);
// Add the device model
if (node.type !== 'Coordinator') {
if (node.type !== "Coordinator") {
labels.push(`${node.definition?.vendor} ${node.definition?.description} (${node.definition?.model})`);
}
// Add the device last_seen timestamp
let lastSeen = 'unknown';
const date = node.type === 'Coordinator' ? Date.now() : node.lastSeen;
let lastSeen = "unknown";
const date = node.type === "Coordinator" ? Date.now() : node.lastSeen;
if (date) {
lastSeen = utils.formatDate(date, 'relative') as string;
lastSeen = utils.formatDate(date, "relative") as string;
}
labels.push(lastSeen);
// Shape the record according to device type
if (node.type == 'Coordinator') {
if (node.type === "Coordinator") {
style = `style="bold, filled", fillcolor="${colors.fill.coordinator}", fontcolor="${colors.font.coordinator}"`;
} else if (node.type == 'Router') {
} else if (node.type === "Router") {
style = `style="rounded, filled", fillcolor="${colors.fill.router}", fontcolor="${colors.font.router}"`;
} else {
style = `style="rounded, dashed, filled", fillcolor="${colors.fill.enddevice}", fontcolor="${colors.font.enddevice}"`;
}
// Add the device with its labels to the graph as a node.
text += ` "${node.ieeeAddr}" [${style}, label="{${labels.join('|')}}"];\n`;
text += ` "${node.ieeeAddr}" [${style}, label="{${labels.join("|")}}"];\n`;
/**
* Add an edge between the device and its child to the graph
* NOTE: There are situations where a device is NOT in the topology, this can be e.g.
* due to not responded to the lqi scan. In that case we do not add an edge for this device.
*/
topology.links
.filter((e) => e.source.ieeeAddr === node.ieeeAddr)
.forEach((e) => {
const lineStyle = node.type == 'EndDevice' ? 'penwidth=1, ' : !e.routes.length ? 'penwidth=0.5, ' : 'penwidth=2, ';
const lineWeight = !e.routes.length ? `weight=0, color="${colors.line.inactive}", ` : `weight=1, color="${colors.line.active}", `;
const textRoutes = e.routes.map((r) => utils.toNetworkAddressHex(r.destinationAddress));
const lineLabels = !e.routes.length ? `label="${e.linkquality}"` : `label="${e.linkquality} (routes: ${textRoutes.join(',')})"`;
text += ` "${node.ieeeAddr}" -> "${e.target.ieeeAddr}"`;
for (const link of topology.links) {
if (link.source.ieeeAddr === node.ieeeAddr) {
const lineStyle = node.type === "EndDevice" ? "penwidth=1, " : !link.routes.length ? "penwidth=0.5, " : "penwidth=2, ";
const lineWeight = !link.routes.length
? `weight=0, color="${colors.line.inactive}", `
: `weight=1, color="${colors.line.active}", `;
const textRoutes = link.routes.map((r) => utils.toNetworkAddressHex(r.destinationAddress));
const lineLabels = !link.routes.length
? `label="${link.linkquality}"`
: `label="${link.linkquality} (routes: ${textRoutes.join(",")})"`;
text += ` "${node.ieeeAddr}" -> "${link.target.ieeeAddr}"`;
text += ` [${lineStyle}${lineWeight}${lineLabels}]\n`;
});
});
}
}
}
text += '}';
text += "}";
return text.replace(/\0/g, '');
return text.replace(/\0/g, "");
}
plantuml(topology: Zigbee2MQTTNetworkMap): string {
const text = [];
text.push(`' paste into: https://www.planttext.com/`);
text.push(``);
text.push('@startuml');
text.push("");
text.push("@startuml");
topology.nodes
.sort((a, b) => a.friendlyName.localeCompare(b.friendlyName))
.forEach((node) => {
// Add friendly name
text.push(`card ${node.ieeeAddr} [`);
text.push(`${node.friendlyName}`);
text.push(`---`);
for (const node of topology.nodes.sort((a, b) => a.friendlyName.localeCompare(b.friendlyName))) {
// Add friendly name
text.push(`card ${node.ieeeAddr} [`);
text.push(`${node.friendlyName}`);
text.push("---");
// Add the device short network address, ieeaddr and scan note (if any)
text.push(
`${node.ieeeAddr} (${utils.toNetworkAddressHex(node.networkAddress)})` +
(node.failed && node.failed.length ? ` failed: ${node.failed.join(',')}` : ''),
);
// Add the device short network address, ieeaddr and scan note (if any)
text.push(
`${node.ieeeAddr} (${utils.toNetworkAddressHex(node.networkAddress)})${node.failed?.length ? ` failed: ${node.failed.join(",")}` : ""}`,
);
// Add the device model
if (node.type !== 'Coordinator') {
text.push(`---`);
const definition = (this.zigbee.resolveEntity(node.ieeeAddr) as Device).definition;
text.push(`${definition?.vendor} ${definition?.description} (${definition?.model})`);
}
// Add the device model
if (node.type !== "Coordinator") {
text.push("---");
const definition = (this.zigbee.resolveEntity(node.ieeeAddr) as Device).definition;
text.push(`${definition?.vendor} ${definition?.description} (${definition?.model})`);
}
// Add the device last_seen timestamp
let lastSeen = 'unknown';
const date = node.type === 'Coordinator' ? Date.now() : node.lastSeen;
if (date) {
lastSeen = utils.formatDate(date, 'relative') as string;
}
text.push(`---`);
text.push(lastSeen);
text.push(`]`);
text.push(``);
});
// Add the device last_seen timestamp
let lastSeen = "unknown";
const date = node.type === "Coordinator" ? Date.now() : node.lastSeen;
if (date) {
lastSeen = utils.formatDate(date, "relative") as string;
}
text.push("---");
text.push(lastSeen);
text.push("]");
text.push("");
}
/**
* Add edges between the devices
* NOTE: There are situations where a device is NOT in the topology, this can be e.g.
* due to not responded to the lqi scan. In that case we do not add an edge for this device.
*/
topology.links.forEach((link) => {
for (const link of topology.links) {
text.push(`${link.sourceIeeeAddr} --> ${link.targetIeeeAddr}: ${link.lqi}`);
});
text.push('');
}
text.push(`@enduml`);
text.push("");
return text.join(`\n`);
text.push("@enduml");
return text.join("\n");
}
async networkScan(includeRoutes: boolean): Promise<Zigbee2MQTTNetworkMap> {
logger.info(`Starting network scan (includeRoutes '${includeRoutes}')`);
const lqis: Map<Device, zh.LQI> = new Map();
const routingTables: Map<Device, zh.RoutingTable> = new Map();
const failed: Map<Device, string[]> = new Map();
const lqis = new Map<Device, zh.LQI>();
const routingTables = new Map<Device, zh.RoutingTable>();
const failed = new Map<Device, string[]>();
const requestWithRetry = async <T>(request: () => Promise<T>): Promise<T> => {
try {
const result = await request();
@@ -199,12 +201,13 @@ export default class NetworkMap extends Extension {
}
};
for (const device of this.zigbee.devicesIterator((d) => d.type !== 'GreenPower' && d.type !== 'EndDevice')) {
for (const device of this.zigbee.devicesIterator((d) => d.type !== "GreenPower" && d.type !== "EndDevice")) {
if (device.options.disabled) {
continue;
}
failed.set(device, []);
const deviceFailures: string[] = [];
failed.set(device, deviceFailures);
await utils.sleep(1); // sleep 1 second between each scan to reduce stress on network.
try {
@@ -212,8 +215,9 @@ export default class NetworkMap extends Extension {
lqis.set(device, result);
logger.debug(`LQI succeeded for '${device.name}'`);
} catch (error) {
failed.get(device)!.push('lqi'); // set above
deviceFailures.push("lqi"); // set above
logger.error(`Failed to execute LQI for '${device.name}'`);
// biome-ignore lint/style/noNonNullAssertion: always Error
logger.debug((error as Error).stack!);
}
@@ -223,19 +227,20 @@ export default class NetworkMap extends Extension {
routingTables.set(device, result);
logger.debug(`Routing table succeeded for '${device.name}'`);
} catch (error) {
failed.get(device)!.push('routingTable'); // set above
deviceFailures.push("routingTable"); // set above
logger.error(`Failed to execute routing table for '${device.name}'`);
// biome-ignore lint/style/noNonNullAssertion: always Error
logger.debug((error as Error).stack!);
}
}
}
logger.info(`Network scan finished`);
logger.info("Network scan finished");
const topology: Zigbee2MQTTNetworkMap = {nodes: [], links: []};
// XXX: display GP/disabled devices in the map, better feedback than just hiding them?
for (const device of this.zigbee.devicesIterator((d) => d.type !== 'GreenPower')) {
for (const device of this.zigbee.devicesIterator((d) => d.type !== "GreenPower")) {
if (device.options.disabled) {
continue;
}
@@ -249,10 +254,10 @@ export default class NetworkMap extends Extension {
supports: Array.from(
new Set(
device.exposes().map((e) => {
return e.name ?? `${e.type} (${e.features?.map((f) => f.name).join(', ')})`;
return e.name ?? `${e.type} (${e.features?.map((f) => f.name).join(", ")})`;
}),
),
).join(', '),
).join(", "),
}
: undefined;
@@ -263,6 +268,7 @@ export default class NetworkMap extends Extension {
networkAddress: device.zh.networkAddress,
manufacturerName: device.zh.manufacturerName,
modelID: device.zh.modelID,
// biome-ignore lint/style/noNonNullAssertion: TODO: biome migration: wrong typing? will be undefined if type=EndDevice?
failed: failed.get(device)!,
lastSeen: device.zh.lastSeen,
definition,
@@ -279,7 +285,7 @@ export default class NetworkMap extends Extension {
// Some Xiaomi devices return 0x00 as the neighbor ieeeAddr (obviously not correct).
// Determine the correct ieeeAddr based on the networkAddress.
if (neighbor.ieeeAddr === '0x0000000000000000') {
if (neighbor.ieeeAddr === "0x0000000000000000") {
const neighborDevice = this.zigbee.deviceByNetworkAddress(neighbor.networkAddress);
if (neighborDevice) {
@@ -287,7 +293,7 @@ export default class NetworkMap extends Extension {
}
}
const link: Zigbee2MQTTNetworkMap['links'][number] = {
const link: Zigbee2MQTTNetworkMap["links"][number] = {
source: {ieeeAddr: neighbor.ieeeAddr, networkAddress: neighbor.networkAddress},
target: {ieeeAddr: device.ieeeAddr, networkAddress: device.zh.networkAddress},
linkquality: neighbor.linkquality,
@@ -305,7 +311,7 @@ export default class NetworkMap extends Extension {
if (routingTable) {
for (const entry of routingTable.table) {
if (entry.status === 'ACTIVE' && entry.nextHop === neighbor.networkAddress) {
if (entry.status === "ACTIVE" && entry.nextHop === neighbor.networkAddress) {
link.routes.push(entry);
}
}
+45 -24
View File
@@ -1,49 +1,70 @@
import * as zhc from 'zigbee-herdsman-converters';
import {onEvent} from "zigbee-herdsman-converters";
import utils from '../util/utils';
import Extension from './extension';
import utils from "../util/utils";
import Extension from "./extension";
/**
* This extension calls the zigbee-herdsman-converters onEvent.
*/
export default class OnEvent extends Extension {
// biome-ignore lint/suspicious/useAwait: API
override async start(): Promise<void> {
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
this.callOnEvent(device, 'start', {}).catch(utils.noop);
// don't await, in case of repeated failures this would hold startup
this.callOnEvent(device, "start", {}).catch(utils.noop);
}
this.eventBus.onDeviceMessage(this, (data) => this.callOnEvent(data.device, 'message', this.convertData(data)));
this.eventBus.onDeviceJoined(this, (data) => this.callOnEvent(data.device, 'deviceJoined', this.convertData(data)));
this.eventBus.onDeviceInterview(this, (data) => this.callOnEvent(data.device, 'deviceInterview', this.convertData(data)));
this.eventBus.onDeviceAnnounce(this, (data) => this.callOnEvent(data.device, 'deviceAnnounce', this.convertData(data)));
this.eventBus.onDeviceNetworkAddressChanged(this, (data) =>
this.callOnEvent(data.device, 'deviceNetworkAddressChanged', this.convertData(data)),
);
this.eventBus.onEntityOptionsChanged(this, async (data) => {
if (data.entity.isDevice()) {
await this.callOnEvent(data.entity, 'deviceOptionsChanged', data).then(() => this.eventBus.emitDevicesChanged());
this.eventBus.onDeviceMessage(this, async (data) => {
await this.callOnEvent(data.device, "message", {
endpoint: data.endpoint,
meta: data.meta,
cluster: typeof data.cluster === "string" ? data.cluster : /* v8 ignore next */ undefined, // XXX: ZH typing is wrong?
type: data.type,
data: data.data, // XXX: typing is a bit convoluted: ZHC has `KeyValueAny` here while Z2M has `KeyValue | Array<string | number>`
});
});
this.eventBus.onDeviceJoined(this, async (data) => {
await this.callOnEvent(data.device, "deviceJoined", {});
});
this.eventBus.onDeviceLeave(this, async (data) => {
if (data.device) {
await this.callOnEvent(data.device, "stop", {});
}
});
this.eventBus.onDeviceInterview(this, async (data) => {
await this.callOnEvent(data.device, "deviceInterview", {});
});
this.eventBus.onDeviceAnnounce(this, async (data) => {
await this.callOnEvent(data.device, "deviceAnnounce", {});
});
this.eventBus.onDeviceNetworkAddressChanged(this, async (data) => {
await this.callOnEvent(data.device, "deviceNetworkAddressChanged", {});
});
this.eventBus.onEntityOptionsChanged(this, async (data) => {
if (data.entity.isDevice()) {
await this.callOnEvent(data.entity, "deviceOptionsChanged", {});
this.eventBus.emitDevicesChanged();
}
});
}
private convertData(data: KeyValue): KeyValue {
return {...data, device: data.device.zh};
}
override async stop(): Promise<void> {
await super.stop();
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
await this.callOnEvent(device, 'stop', {});
await this.callOnEvent(device, "stop", {});
}
}
private async callOnEvent(device: Device, type: zhc.OnEventType, data: KeyValue): Promise<void> {
if (device.options.disabled) return;
const state = this.state.get(device);
const deviceExposesChanged = (): void => this.eventBus.emitExposesAndDevicesChanged(data.device);
private async callOnEvent(device: Device, type: Parameters<typeof onEvent>[0], data: Parameters<typeof onEvent>[1]): Promise<void> {
if (device.options.disabled) {
return;
}
await zhc.onEvent(type, data, device.zh, {deviceExposesChanged});
const state = this.state.get(device);
const deviceExposesChanged = (): void => this.eventBus.emitExposesAndDevicesChanged(device);
await onEvent(type, data, device.zh, {deviceExposesChanged});
if (device.definition?.onEvent) {
const options: KeyValue = device.options;
+276 -115
View File
@@ -1,24 +1,26 @@
import type {Zigbee2MQTTAPI} from 'lib/types/api';
import type {Ota} from 'zigbee-herdsman-converters';
import type {Ota} from "zigbee-herdsman-converters";
import assert from 'node:assert';
import path from 'node:path';
import type {Zigbee2MQTTAPI} from "../types/api";
import bind from 'bind-decorator';
import stringify from 'json-stable-stringify-without-jsonify';
import assert from "node:assert";
import path from "node:path";
import {Zcl} from 'zigbee-herdsman';
import {ota} from 'zigbee-herdsman-converters';
import bind from "bind-decorator";
import stringify from "json-stable-stringify-without-jsonify";
import Device from '../model/device';
import dataDir from '../util/data';
import logger from '../util/logger';
import * as settings from '../util/settings';
import utils from '../util/utils';
import Extension from './extension';
import {Zcl} from "zigbee-herdsman";
import {ota} from "zigbee-herdsman-converters";
type UpdateState = 'updating' | 'idle' | 'available';
interface UpdatePayload {
import Device from "../model/device";
import dataDir from "../util/data";
import logger from "../util/logger";
import * as settings from "../util/settings";
import utils from "../util/utils";
import Extension from "./extension";
type UpdateState = "updating" | "idle" | "available" | "scheduled";
export interface UpdatePayload {
update: {
progress?: number;
remaining?: number;
@@ -28,12 +30,18 @@ interface UpdatePayload {
};
}
const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/(update|check)/?(downgrade)?`, 'i');
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 {
private inProgress = new Set();
private lastChecked: {[s: string]: number} = {};
private inProgress = new Set<string>();
private lastChecked = new Map<string, number>();
private scheduledUpgrades = new Set<string>();
private scheduledDowngrades = new Set<string>();
// biome-ignore lint/suspicious/useAwait: API
override async start(): Promise<void> {
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
this.eventBus.onDeviceMessage(this, this.onZigbeeEvent);
@@ -61,8 +69,8 @@ export default class OTAUpdate extends Extension {
this.removeProgressAndRemainingFromState(device);
// Reset update state, e.g. when Z2M restarted during update.
if (this.state.get(device).update?.state === 'updating') {
this.state.get(device).update.state = 'available';
if (this.state.get(device).update?.state === "updating") {
this.state.get(device).update.state = "available";
}
}
}
@@ -77,60 +85,145 @@ export default class OTAUpdate extends Extension {
}
@bind private async onZigbeeEvent(data: eventdata.DeviceMessage): Promise<void> {
if (data.type !== 'commandQueryNextImageRequest' || !data.device.definition || this.inProgress.has(data.device.ieeeAddr)) return;
if (data.type !== "commandQueryNextImageRequest" || !data.device.definition || this.inProgress.has(data.device.ieeeAddr)) {
return;
}
// `commandQueryNextImageRequest` check above should ensures this is valid but...
assert(
data.meta.zclTransactionSequenceNumber !== undefined,
"Missing 'queryNextImageRequest' transaction sequence number (cannot match reply)",
);
logger.debug(`Device '${data.device.name}' requested OTA`);
const automaticOTACheckDisabled = settings.get().ota.disable_automatic_update_check;
if (data.device.definition.ota) {
if (this.scheduledUpgrades.has(data.device.ieeeAddr) || this.scheduledDowngrades.has(data.device.ieeeAddr)) {
this.inProgress.add(data.device.ieeeAddr);
if (data.device.definition.ota && !automaticOTACheckDisabled) {
// When a device does a next image request, it will usually do it a few times after each other
// with only 10 - 60 seconds inbetween. It doesn't make sense to check for a new update
// each time, so this interval can be set by the user. The default is 1,440 minutes (one day).
const updateCheckInterval = settings.get().ota.update_check_interval * 1000 * 60;
const check =
this.lastChecked[data.device.ieeeAddr] !== undefined
? Date.now() - this.lastChecked[data.device.ieeeAddr] > updateCheckInterval
: true;
if (!check) return;
logger.info(`Updating '${data.device.name}' to latest firmware`);
this.lastChecked[data.device.ieeeAddr] = Date.now();
let availableResult: Ota.UpdateAvailableResult | undefined;
try {
const fileVersion = await ota.update(
data.device.zh,
data.device.otaExtraMetas,
this.scheduledDowngrades.has(data.device.ieeeAddr),
async (progress, remaining) => {
let msg = `Update of '${data.device.name}' at ${progress.toFixed(2)}%`;
try {
// never use 'previous' when responding to device request
availableResult = await ota.isUpdateAvailable(data.device.zh, data.device.otaExtraMetas, data.data as Ota.ImageInfo, false);
} catch (error) {
logger.debug(`Failed to check if update available for '${data.device.name}' (${error})`);
if (remaining) {
msg += `, ≈ ${Math.round(remaining / 60)} minutes remaining`;
}
logger.info(msg);
await this.publishEntityState(
data.device,
this.getEntityPublishPayload(data.device, "updating", progress, remaining ?? undefined),
);
},
data.data as Ota.ImageInfo,
data.meta.zclTransactionSequenceNumber,
);
// remove right away on update success or no image in case any of the below calls fail
this.scheduledUpgrades.delete(data.device.ieeeAddr);
this.scheduledDowngrades.delete(data.device.ieeeAddr);
if (fileVersion === undefined) {
logger.info(`No image currently available for '${data.device.name}'. Unscheduling.`);
// XXX: superfluous?
this.removeProgressAndRemainingFromState(data.device);
await this.publishEntityState(data.device, this.getEntityPublishPayload(data.device, "idle"));
this.inProgress.delete(data.device.ieeeAddr);
return;
}
logger.info(`Finished update of '${data.device.name}'`);
this.removeProgressAndRemainingFromState(data.device);
await this.publishEntityState(
data.device,
this.getEntityPublishPayload(data.device, {available: false, currentFileVersion: fileVersion, otaFileVersion: fileVersion}),
);
const firmwareTo = await this.readSoftwareBuildIDAndDateCode(data.device);
logger.info(() => `Device '${data.device.name}' was updated to '${stringify(firmwareTo)}'`);
/**
* Re-configure after reading software build ID and date code, some devices use a
* custom attribute for this (e.g. Develco SMSZB-120)
*/
this.eventBus.emitReconfigure({device: data.device});
this.eventBus.emitDevicesChanged();
} catch (e) {
logger.debug(`Update of '${data.device.name}' failed (${e}). Retry scheduled for next request.`);
this.removeProgressAndRemainingFromState(data.device);
await this.publishEntityState(data.device, this.getEntityPublishPayload(data.device, "scheduled"));
}
this.inProgress.delete(data.device.ieeeAddr);
return; // we're done
}
await this.publishEntityState(data.device, this.getEntityPublishPayload(data.device, availableResult ?? 'idle'));
if (!settings.get().ota.disable_automatic_update_check) {
// When a device does a next image request, it will usually do it a few times after each other
// with only 10 - 60 seconds inbetween. It doesn't make sense to check for a new update
// each time, so this interval can be set by the user. The default is 1,440 minutes (one day).
const updateCheckInterval = settings.get().ota.update_check_interval * 1000 * 60;
const deviceLastChecked = this.lastChecked.get(data.device.ieeeAddr);
const check = deviceLastChecked !== undefined ? Date.now() - deviceLastChecked > updateCheckInterval : true;
if (availableResult?.available) {
const message = `Update available for '${data.device.name}'`;
logger.info(message);
if (!check) {
return;
}
this.inProgress.add(data.device.ieeeAddr);
this.lastChecked.set(data.device.ieeeAddr, Date.now());
let availableResult: Ota.UpdateAvailableResult | undefined;
try {
// never use 'previous' when responding to device request
availableResult = await ota.isUpdateAvailable(data.device.zh, data.device.otaExtraMetas, data.data as Ota.ImageInfo, false);
} catch (error) {
logger.debug(`Failed to check if update available for '${data.device.name}' (${error})`);
}
await this.publishEntityState(data.device, this.getEntityPublishPayload(data.device, availableResult ?? "idle"));
if (availableResult?.available) {
const message = `Update available for '${data.device.name}'`;
logger.info(message);
}
}
}
// Respond to stop the client from requesting OTAs
const endpoint = data.device.zh.endpoints.find((e) => e.supportsOutputCluster('genOta')) || data.endpoint;
const endpoint = data.device.zh.endpoints.find((e) => e.supportsOutputCluster("genOta")) || data.endpoint;
await endpoint.commandResponse(
'genOta',
'queryNextImageResponse',
"genOta",
"queryNextImageResponse",
{status: Zcl.Status.NO_IMAGE_AVAILABLE},
undefined,
data.meta.zclTransactionSequenceNumber,
);
logger.debug(`Responded to OTA request of '${data.device.name}' with 'NO_IMAGE_AVAILABLE'`);
this.inProgress.delete(data.device.ieeeAddr);
}
private async readSoftwareBuildIDAndDateCode(
device: Device,
sendPolicy?: 'immediate',
sendPolicy?: "immediate",
): Promise<{softwareBuildID: string; dateCode: string} | undefined> {
try {
const endpoint = device.zh.endpoints.find((e) => e.supportsInputCluster('genBasic'));
const endpoint = device.zh.endpoints.find((e) => e.supportsInputCluster("genBasic"));
assert(endpoint);
const result = await endpoint.read('genBasic', ['dateCode', 'swBuildId'], {sendPolicy});
const result = await endpoint.read("genBasic", ["dateCode", "swBuildId"], {sendPolicy});
return {softwareBuildID: result.swBuildId, dateCode: result.dateCode};
} catch {
return undefined;
@@ -146,9 +239,9 @@ export default class OTAUpdate extends Extension {
const deviceUpdateState = this.state.get(device).update;
const payload: UpdatePayload = {
update: {
state: typeof state === 'string' ? state : state.available ? 'available' : 'idle',
installed_version: typeof state === 'string' ? deviceUpdateState?.installed_version : state.currentFileVersion,
latest_version: typeof state === 'string' ? deviceUpdateState?.latest_version : state.otaFileVersion,
state: typeof state === "string" ? state : state.available ? "available" : "idle",
installed_version: typeof state === "string" ? deviceUpdateState?.installed_version : state.currentFileVersion,
latest_version: typeof state === "string" ? deviceUpdateState?.latest_version : state.otaFileVersion,
},
};
@@ -171,14 +264,17 @@ export default class OTAUpdate extends Extension {
}
const message = utils.parseJSON(data.message, data.message) as
| Zigbee2MQTTAPI['bridge/request/device/ota_update/check']
| Zigbee2MQTTAPI['bridge/request/device/ota_update/check/downgrade']
| Zigbee2MQTTAPI['bridge/request/device/ota_update/update']
| Zigbee2MQTTAPI['bridge/request/device/ota_update/update/downgrade'];
const ID = (typeof message === 'object' && message['id'] !== undefined ? message.id : message) as string;
| Zigbee2MQTTAPI["bridge/request/device/ota_update/check"]
| Zigbee2MQTTAPI["bridge/request/device/ota_update/check/downgrade"]
| Zigbee2MQTTAPI["bridge/request/device/ota_update/update"]
| Zigbee2MQTTAPI["bridge/request/device/ota_update/update/downgrade"]
| Zigbee2MQTTAPI["bridge/request/device/ota_update/schedule"]
| Zigbee2MQTTAPI["bridge/request/device/ota_update/schedule/downgrade"]
| Zigbee2MQTTAPI["bridge/request/device/ota_update/unschedule"];
const ID = (typeof message === "object" && message.id !== undefined ? message.id : message) as string;
const device = this.zigbee.resolveEntity(ID);
const type = topicMatch[1];
const downgrade = Boolean(topicMatch[2]);
const type = topicMatch[1] as "check" | "update" | "schedule" | "unschedule";
const downgrade = topicMatch[2] === "downgrade";
let error: string | undefined;
let errorStack: string | undefined;
@@ -187,83 +283,148 @@ export default class OTAUpdate extends Extension {
} else if (!device.definition || !device.definition.ota) {
error = `Device '${device.name}' does not support OTA updates`;
} else if (this.inProgress.has(device.ieeeAddr)) {
// also guards against scheduling while check/update op in progress that could result in undesired OTA state
error = `Update or check for update already in progress for '${device.name}'`;
} else {
this.inProgress.add(device.ieeeAddr);
switch (type) {
case "check": {
this.inProgress.add(device.ieeeAddr);
if (type === 'check') {
const msg = `Checking if update available for '${device.name}'`;
logger.info(msg);
logger.info(`Checking if update available for '${device.name}'`);
try {
const availableResult = await ota.isUpdateAvailable(device.zh, device.otaExtraMetas, undefined, downgrade);
const msg = `${availableResult.available ? 'Update' : 'No update'} available for '${device.name}'`;
logger.info(msg);
try {
const availableResult = await ota.isUpdateAvailable(device.zh, device.otaExtraMetas, undefined, downgrade);
await this.publishEntityState(device, this.getEntityPublishPayload(device, availableResult));
logger.info(`${availableResult.available ? "Update" : "No update"} available for '${device.name}'`);
this.lastChecked[device.ieeeAddr] = Date.now();
const response = utils.getResponse<'bridge/response/device/ota_update/check'>(message, {
id: ID,
update_available: availableResult.available,
});
await this.publishEntityState(device, this.getEntityPublishPayload(device, availableResult));
this.lastChecked.set(device.ieeeAddr, Date.now());
await this.mqtt.publish(`bridge/response/device/ota_update/check`, stringify(response));
} catch (e) {
error = `Failed to check if update available for '${device.name}' (${(e as Error).message})`;
errorStack = (e as Error).stack;
const response = utils.getResponse<"bridge/response/device/ota_update/check">(message, {
id: ID,
update_available: availableResult.available,
});
await this.mqtt.publish("bridge/response/device/ota_update/check", stringify(response));
} catch (e) {
error = `Failed to check if update available for '${device.name}' (${(e as Error).message})`;
errorStack = (e as Error).stack;
}
break;
}
} else {
// type === 'update'
const msg = `Updating '${device.name}' to ${downgrade ? 'previous' : 'latest'} firmware`;
logger.info(msg);
try {
const firmwareFrom = await this.readSoftwareBuildIDAndDateCode(device, 'immediate');
const fileVersion = await ota.update(device.zh, device.otaExtraMetas, downgrade, async (progress, remaining) => {
let msg = `Update of '${device.name}' at ${progress.toFixed(2)}%`;
case "update": {
this.inProgress.add(device.ieeeAddr);
if (remaining) {
msg += `, ≈ ${Math.round(remaining / 60)} minutes remaining`;
if (this.scheduledUpgrades.delete(device.ieeeAddr)) {
logger.info(`Previously scheduled '${device.name}' upgrade was cancelled by manual update`);
} else if (this.scheduledDowngrades.delete(device.ieeeAddr)) {
logger.info(`Previously scheduled '${device.name}' downgrade was cancelled by manual update`);
}
logger.info(`Updating '${device.name}' to ${downgrade ? "previous" : "latest"} firmware`);
try {
const firmwareFrom = await this.readSoftwareBuildIDAndDateCode(device, "immediate");
const fileVersion = await ota.update(device.zh, device.otaExtraMetas, downgrade, async (progress, remaining) => {
let msg = `Update of '${device.name}' at ${progress.toFixed(2)}%`;
if (remaining) {
msg += `, ≈ ${Math.round(remaining / 60)} minutes remaining`;
}
logger.info(msg);
await this.publishEntityState(device, this.getEntityPublishPayload(device, "updating", progress, remaining ?? undefined));
});
if (fileVersion === undefined) {
throw new Error("No image currently available");
}
logger.info(msg);
logger.info(`Finished update of '${device.name}'`);
this.removeProgressAndRemainingFromState(device);
await this.publishEntityState(
device,
this.getEntityPublishPayload(device, {available: false, currentFileVersion: fileVersion, otaFileVersion: fileVersion}),
);
await this.publishEntityState(device, this.getEntityPublishPayload(device, 'updating', progress, remaining ?? undefined));
});
const firmwareTo = await this.readSoftwareBuildIDAndDateCode(device);
logger.info(`Finished update of '${device.name}'`);
this.removeProgressAndRemainingFromState(device);
await this.publishEntityState(
device,
this.getEntityPublishPayload(device, {available: false, currentFileVersion: fileVersion, otaFileVersion: fileVersion}),
);
logger.info(() => `Device '${device.name}' was updated from '${stringify(firmwareFrom)}' to '${stringify(firmwareTo)}'`);
const firmwareTo = await this.readSoftwareBuildIDAndDateCode(device);
/**
* Re-configure after reading software build ID and date code, some devices use a
* custom attribute for this (e.g. Develco SMSZB-120)
*/
this.eventBus.emitReconfigure({device});
this.eventBus.emitDevicesChanged();
logger.info(() => `Device '${device.name}' was updated from '${stringify(firmwareFrom)}' to '${stringify(firmwareTo)}'`);
const response = utils.getResponse<"bridge/response/device/ota_update/update">(message, {
id: ID,
from: firmwareFrom ? {software_build_id: firmwareFrom.softwareBuildID, date_code: firmwareFrom.dateCode} : undefined,
to: firmwareTo ? {software_build_id: firmwareTo.softwareBuildID, date_code: firmwareTo.dateCode} : undefined,
});
/**
* Re-configure after reading software build ID and date code, some devices use a
* custom attribute for this (e.g. Develco SMSZB-120)
*/
this.eventBus.emitReconfigure({device});
this.eventBus.emitDevicesChanged();
await this.mqtt.publish("bridge/response/device/ota_update/update", stringify(response));
} catch (e) {
logger.debug(`Update of '${device.name}' failed (${e})`);
error = `Update of '${device.name}' failed (${(e as Error).message})`;
errorStack = (e as Error).stack;
const response = utils.getResponse<'bridge/response/device/ota_update/update'>(message, {
this.removeProgressAndRemainingFromState(device);
await this.publishEntityState(device, this.getEntityPublishPayload(device, "available"));
}
break;
}
case "schedule": {
// ensure only one type scheduled by deleting from the other if necessary
if (downgrade) {
if (this.scheduledUpgrades.delete(device.ieeeAddr)) {
logger.info(`Previously scheduled '${device.name}' upgrade was cancelled in favor of new downgrade request`);
}
this.scheduledDowngrades.add(device.ieeeAddr);
} else {
if (this.scheduledDowngrades.delete(device.ieeeAddr)) {
logger.info(`Previously scheduled '${device.name}' downgrade was cancelled in favor of new upgrade request`);
}
this.scheduledUpgrades.add(device.ieeeAddr);
}
logger.info(`Scheduled '${device.name}' to ${downgrade ? "downgrade" : "upgrade"} firmware on next request from device`);
await this.publishEntityState(device, this.getEntityPublishPayload(device, "scheduled", undefined, undefined));
const response = utils.getResponse<"bridge/response/device/ota_update/schedule">(message, {
id: ID,
from: firmwareFrom ? {software_build_id: firmwareFrom.softwareBuildID, date_code: firmwareFrom.dateCode} : undefined,
to: firmwareTo ? {software_build_id: firmwareTo.softwareBuildID, date_code: firmwareTo.dateCode} : undefined,
});
await this.mqtt.publish(`bridge/response/device/ota_update/update`, stringify(response));
} catch (e) {
logger.debug(`Update of '${device.name}' failed (${e})`);
error = `Update of '${device.name}' failed (${(e as Error).message})`;
errorStack = (e as Error).stack;
await this.mqtt.publish("bridge/response/device/ota_update/schedule", stringify(response));
this.removeProgressAndRemainingFromState(device);
await this.publishEntityState(device, this.getEntityPublishPayload(device, 'available'));
break;
}
case "unschedule": {
if (this.scheduledUpgrades.delete(device.ieeeAddr)) {
logger.info(`Previously scheduled '${device.name}' upgrade was cancelled`);
} else if (this.scheduledDowngrades.delete(device.ieeeAddr)) {
logger.info(`Previously scheduled '${device.name}' downgrade was cancelled`);
}
await this.publishEntityState(device, this.getEntityPublishPayload(device, "idle", undefined, undefined));
const response = utils.getResponse<"bridge/response/device/ota_update/unschedule">(message, {
id: ID,
});
await this.mqtt.publish("bridge/response/device/ota_update/unschedule", stringify(response));
break;
}
}
+36 -40
View File
@@ -1,14 +1,14 @@
import type * as zhc from 'zigbee-herdsman-converters';
import type * as zhc from "zigbee-herdsman-converters";
import bind from 'bind-decorator';
import stringify from 'json-stable-stringify-without-jsonify';
import bind from "bind-decorator";
import stringify from "json-stable-stringify-without-jsonify";
import Device from '../model/device';
import Group from '../model/group';
import logger from '../util/logger';
import * as settings from '../util/settings';
import utils from '../util/utils';
import Extension from './extension';
import Device from "../model/device";
import Group from "../model/group";
import logger from "../util/logger";
import * as settings from "../util/settings";
import utils from "../util/utils";
import Extension from "./extension";
let topicGetSetRegex: RegExp;
// Used by `publish.test.js` to reload regex when changing `mqtt.base_topic`.
@@ -17,24 +17,25 @@ export const loadTopicGetSetRegex = (): void => {
};
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'];
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"];
interface ParsedTopic {
ID: string;
endpoint: string | undefined;
attribute: string;
type: 'get' | 'set';
type: "get" | "set";
}
export default class Publish extends Extension {
// biome-ignore lint/suspicious/useAwait: API
override async start(): Promise<void> {
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
}
parseTopic(topic: string): ParsedTopic | undefined {
// The function supports the following topic formats (below are for 'set'. 'get' will look the same):
// - <base_topic>/device_name/set (endpoint and attribute is defined in the payload)
// - <base_topic>/device_name/set (auto-matches endpoint and attribute is defined in the payload)
// - <base_topic>/device_name/set/attribute (default endpoint used)
// - <base_topic>/device_name/endpoint/set (attribute is defined in the payload)
// - <base_topic>/device_name/endpoint/set/attribute (payload is the value)
@@ -53,7 +54,7 @@ export default class Publish extends Extension {
// Now parse the device/group name, and endpoint name
const entity = this.zigbee.resolveEntityAndEndpoint(deviceNameAndEndpoint);
return {ID: entity.ID, endpoint: entity.endpointID, type: match[2] as 'get' | 'set', attribute: attribute};
return {ID: entity.ID, endpoint: entity.endpointID, type: match[2] as "get" | "set", attribute: attribute};
}
parseMessage(parsedTopic: ParsedTopic, data: eventdata.MQTTMessage): KeyValue | undefined {
@@ -67,11 +68,7 @@ export default class Publish extends Extension {
try {
return JSON.parse(data.message);
} catch {
if (STATE_VALUES.includes(data.message.toLowerCase())) {
return {state: data.message};
} else {
return undefined;
}
return STATE_VALUES.includes(data.message.toLowerCase()) ? {state: data.message} : undefined;
}
}
}
@@ -86,10 +83,9 @@ export default class Publish extends Extension {
const hasColorTemp = message.color_temp !== undefined;
const hasColor = message.color !== undefined;
const hasBrightness = message.brightness !== undefined;
const isOn = entityState.state === 'ON' ? true : false;
if (isOn && (hasColorTemp || hasColor) && !hasBrightness) {
if (entityState.state === "ON" && (hasColorTemp || hasColor) && !hasBrightness) {
delete message.state;
logger.debug('Skipping state because of Home Assistant');
logger.debug("Skipping state because of Home Assistant");
}
}
}
@@ -139,9 +135,8 @@ export default class Publish extends Extension {
const entityState = this.state.get(re);
const membersState =
re instanceof Group
? Object.fromEntries(
re.zh.members.map((e) => [e.getDevice().ieeeAddr, this.state.get(this.zigbee.resolveEntity(e.getDevice().ieeeAddr)!)]),
)
? // biome-ignore lint/style/noNonNullAssertion: TODO: biome migration: might be a bit much assumed here?
Object.fromEntries(re.zh.members.map((e) => [e.deviceIeeeAddress, this.state.get(this.zigbee.resolveEntity(e.deviceIeeeAddress)!)]))
: undefined;
const converters = this.getDefinitionConverters(definition);
@@ -157,8 +152,8 @@ export default class Publish extends Extension {
* bulb off => move state & brightness to the front
*/
const entries = Object.entries(message);
const sorter = typeof message.state === 'string' && message.state.toLowerCase() === 'off' ? 1 : -1;
entries.sort((a) => (['state', 'brightness', 'brightness_percent'].includes(a[0]) ? sorter : sorter * -1));
const sorter = typeof message.state === "string" && message.state.toLowerCase() === "off" ? 1 : -1;
entries.sort((a) => (["state", "brightness", "brightness_percent"].includes(a[0]) ? sorter : sorter * -1));
// For each attribute call the corresponding converter
const usedConverters: {[s: number]: zhc.Tz.Converter[]} = {};
@@ -176,7 +171,7 @@ export default class Publish extends Extension {
};
const endpointNames = re instanceof Device ? re.getEndpointNames() : [];
const propertyEndpointRegex = new RegExp(`^(.*?)_(${endpointNames.join('|')})$`);
const propertyEndpointRegex = new RegExp(`^(.*?)_(${endpointNames.join("|")})$`);
let scenesChanged = false;
for (const entry of entries) {
@@ -192,8 +187,7 @@ export default class Publish extends Extension {
if (re instanceof Device && propertyEndpointMatch) {
endpointName = propertyEndpointMatch[2];
key = propertyEndpointMatch[1];
// endpointName is always matched to an existing endpoint of the device
// since `propertyEndpointRegex` only contains valid endpoints for this device.
// biome-ignore lint/style/noNonNullAssertion: endpointName is always matched to an existing endpoint of the device since `propertyEndpointRegex` only contains valid endpoints for this device
localTarget = re.endpoint(endpointName)!;
endpointOrGroupID = localTarget.ID;
}
@@ -205,19 +199,19 @@ export default class Publish extends Extension {
(!c.key || c.key.includes(key)) && (re instanceof Group || !c.endpoints || (endpointName && c.endpoints.includes(endpointName))),
);
if (parsedTopic.type === 'set' && converter && usedConverters[endpointOrGroupID].includes(converter)) {
if (parsedTopic.type === "set" && converter && usedConverters[endpointOrGroupID].includes(converter)) {
// Use a converter for set only once
// (e.g. light_onoff_brightness converters can convert state and brightness)
continue;
}
if (!converter) {
logger.error(`No converter available for '${key}' (${stringify(message[key])})`);
logger.error(`No converter available for '${key}' on '${re.name}': (${stringify(message[key])})`);
continue;
}
// If the endpoint_name name is a number, try to map it to a friendlyName
if (!isNaN(Number(endpointName)) && re.isDevice() && utils.isZHEndpoint(localTarget) && re.endpointName(localTarget)) {
if (!Number.isNaN(Number(endpointName)) && re.isDevice() && utils.isZHEndpoint(localTarget) && re.endpointName(localTarget)) {
endpointName = re.endpointName(localTarget);
}
@@ -247,12 +241,12 @@ export default class Publish extends Extension {
}
try {
if (parsedTopic.type === 'set' && converter.convertSet) {
if (parsedTopic.type === "set" && converter.convertSet) {
logger.debug(`Publishing '${parsedTopic.type}' '${key}' to '${re.name}'`);
const result = await converter.convertSet(localTarget, key, value, meta);
const optimistic = entitySettings.optimistic === undefined || entitySettings.optimistic;
if (result && result.state && optimistic) {
if (result?.state && optimistic) {
const msg = result.state;
if (endpointName) {
@@ -268,12 +262,13 @@ export default class Publish extends Extension {
addToToPublish(re, msg);
}
if (result && result.membersState && optimistic) {
if (result?.membersState && optimistic) {
for (const [ieeeAddr, state] of Object.entries(result.membersState)) {
// biome-ignore lint/style/noNonNullAssertion: might be a bit much assumed here?
addToToPublish(this.zigbee.resolveEntity(ieeeAddr)!, state);
}
}
} else if (parsedTopic.type === 'get' && converter.convertGet) {
} else if (parsedTopic.type === "get" && converter.convertGet) {
logger.debug(`Publishing get '${parsedTopic.type}' '${key}' to '${re.name}'`);
await converter.convertGet(localTarget, key, meta);
} else {
@@ -283,6 +278,7 @@ export default class Publish extends Extension {
} catch (error) {
const message = `Publish '${parsedTopic.type}' '${key}' to '${re.name}' failed: '${error}'`;
logger.error(message);
// biome-ignore lint/style/noNonNullAssertion: always Error
logger.debug((error as Error).stack!);
}
@@ -306,9 +302,9 @@ export default class Publish extends Extension {
private getDefinitionConverters(definition: zhc.Definition | zhc.Definition[]): ReadonlyArray<zhc.Tz.Converter> {
if (Array.isArray(definition)) {
return definition.length ? Array.from(new Set(definition.map((d) => d.toZigbee).flat())) : [];
} else {
return definition?.toZigbee;
return definition.length ? Array.from(new Set(definition.flatMap((d) => d.toZigbee))) : [];
}
return definition?.toZigbee;
}
}
+31 -29
View File
@@ -1,30 +1,32 @@
import assert from 'node:assert';
import assert from "node:assert";
import bind from 'bind-decorator';
import debounce from 'debounce';
import stringify from 'json-stable-stringify-without-jsonify';
import throttle from 'throttleit';
import bind from "bind-decorator";
import debounce from "debounce";
import stringify from "json-stable-stringify-without-jsonify";
import throttle from "throttleit";
import * as zhc from 'zigbee-herdsman-converters';
import * as zhc from "zigbee-herdsman-converters";
import logger from '../util/logger';
import * as settings from '../util/settings';
import utils from '../util/utils';
import Extension from './extension';
import logger from "../util/logger";
import * as settings from "../util/settings";
import utils from "../util/utils";
import Extension from "./extension";
type DebounceFunction = (() => void) & {clear(): void} & {flush(): void};
export default class Receive extends Extension {
// TODO: move all to `Map`
private elapsed: {[s: string]: number} = {};
private debouncers: {[s: string]: {payload: KeyValue; publish: DebounceFunction}} = {};
private throttlers: {[s: string]: {publish: PublishEntityState}} = {};
// biome-ignore lint/suspicious/useAwait: API
override async start(): Promise<void> {
this.eventBus.onPublishEntityState(this, this.onPublishEntityState);
this.eventBus.onDeviceMessage(this, this.onDeviceMessage);
}
@bind async onPublishEntityState(data: eventdata.PublishEntityState): Promise<void> {
@bind onPublishEntityState(data: eventdata.PublishEntityState): void {
/**
* Prevent that outdated properties are being published.
* In case that e.g. the state is currently held back by a debounce and a new state is published
@@ -33,8 +35,8 @@ export default class Receive extends Extension {
if (
data.entity.isDevice() &&
this.debouncers[data.entity.ieeeAddr] &&
data.stateChangeReason !== 'publishDebounce' &&
data.stateChangeReason !== 'lastSeenChanged'
data.stateChangeReason !== "publishDebounce" &&
data.stateChangeReason !== "lastSeenChanged"
) {
for (const key of Object.keys(data.payload)) {
delete this.debouncers[data.entity.ieeeAddr].payload[key];
@@ -47,7 +49,7 @@ export default class Receive extends Extension {
this.debouncers[device.ieeeAddr] = {
payload: {},
publish: debounce(async () => {
await this.publishEntityState(device, this.debouncers[device.ieeeAddr].payload, 'publishDebounce');
await this.publishEntityState(device, this.debouncers[device.ieeeAddr].payload, "publishDebounce");
this.debouncers[device.ieeeAddr].payload = {};
}, time * 1000),
};
@@ -81,7 +83,7 @@ export default class Receive extends Extension {
// By updating cache we make sure that state cache is always up-to-date.
this.state.set(device, payload);
await this.throttlers[device.ieeeAddr].publish(device, payload, 'publishThrottle');
await this.throttlers[device.ieeeAddr].publish(device, payload, "publishThrottle");
}
// if debounce_ignore are specified (Array of strings)
@@ -90,13 +92,12 @@ export default class Receive extends Extension {
// otherwise payload is conflicted
isPayloadConflicted(newPayload: KeyValue, oldPayload: KeyValue, debounceIgnore: string[] | undefined): boolean {
let result = false;
Object.keys(oldPayload)
.filter((key) => (debounceIgnore || []).includes(key))
.forEach((key) => {
if (typeof newPayload[key] !== 'undefined' && newPayload[key] !== oldPayload[key]) {
result = true;
}
});
for (const key in oldPayload) {
if (debounceIgnore?.includes(key) && typeof newPayload[key] !== "undefined" && newPayload[key] !== oldPayload[key]) {
result = true;
}
}
return result;
}
@@ -105,9 +106,9 @@ export default class Receive extends Extension {
/* v8 ignore next */
if (!data.device) return;
if (!data.device.definition || data.device.zh.interviewing) {
logger.debug(`Skipping message, still interviewing`);
await utils.publishLastSeen({device: data.device, reason: 'messageEmitted'}, settings.get(), true, this.publishEntityState);
if (!data.device.definition || !data.device.interviewed) {
logger.debug("Skipping message, still interviewing");
await utils.publishLastSeen({device: data.device, reason: "messageEmitted"}, settings.get(), true, this.publishEntityState);
return;
}
@@ -117,13 +118,13 @@ export default class Receive extends Extension {
});
// Check if there is an available converter, genOta messages are not interesting.
const ignoreClusters: (string | number)[] = ['genOta', 'genTime', 'genBasic', 'genPollCtrl'];
if (converters.length == 0 && !ignoreClusters.includes(data.cluster)) {
const ignoreClusters: (string | number)[] = ["genOta", "genTime", "genBasic", "genPollCtrl"];
if (converters.length === 0 && !ignoreClusters.includes(data.cluster)) {
logger.debug(
`No converter available for '${data.device.definition.model}' with ` +
`cluster '${data.cluster}' and type '${data.type}' and data '${stringify(data.data)}'`,
);
await utils.publishLastSeen({device: data.device, reason: 'messageEmitted'}, settings.get(), true, this.publishEntityState);
await utils.publishLastSeen({device: data.device, reason: "messageEmitted"}, settings.get(), true, this.publishEntityState);
return;
}
@@ -174,6 +175,7 @@ export default class Receive extends Extension {
/* v8 ignore start */
} catch (error) {
logger.error(`Exception while calling fromZigbee converter: ${(error as Error).message}}`);
// biome-ignore lint/style/noNonNullAssertion: always Error
logger.debug((error as Error).stack!);
}
/* v8 ignore stop */
@@ -182,7 +184,7 @@ export default class Receive extends Extension {
if (!utils.objectIsEmpty(payload)) {
await publish(payload);
} else {
await utils.publishLastSeen({device: data.device, reason: 'messageEmitted'}, settings.get(), true, this.publishEntityState);
await utils.publishLastSeen({device: data.device, reason: "messageEmitted"}, settings.get(), true, this.publishEntityState);
}
}
}
+31 -25
View File
@@ -1,17 +1,19 @@
import assert from 'node:assert';
import type {CustomClusters} from "zigbee-herdsman/dist/zspec/zcl/definition/tstype";
import * as zhc from 'zigbee-herdsman-converters';
import {access, Numeric} from 'zigbee-herdsman-converters';
import {CustomClusters} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype';
import assert from "node:assert";
import * as settings from '../util/settings';
import * as zhc from "zigbee-herdsman-converters";
import {Numeric, access} from "zigbee-herdsman-converters";
import {InterviewState} from "zigbee-herdsman/dist/controller/model/device";
const LINKQUALITY = new Numeric('linkquality', access.STATE)
.withUnit('lqi')
.withDescription('Link quality (signal strength)')
import * as settings from "../util/settings";
const LINKQUALITY = new Numeric("linkquality", access.STATE)
.withUnit("lqi")
.withDescription("Link quality (signal strength)")
.withValueMin(0)
.withValueMax(255)
.withCategory('diagnostic');
.withCategory("diagnostic");
export default class Device {
public zh: zh.Device;
@@ -21,6 +23,7 @@ export default class Device {
get ieeeAddr(): string {
return this.zh.ieeeAddr;
}
// biome-ignore lint/style/useNamingConvention: API
get ID(): string {
return this.zh.ieeeAddr;
}
@@ -29,16 +32,19 @@ export default class Device {
return {...settings.get().device_options, ...deviceOptions};
}
get name(): string {
return this.zh.type === 'Coordinator' ? 'Coordinator' : this.options?.friendly_name;
return this.zh.type === "Coordinator" ? "Coordinator" : this.options?.friendly_name;
}
get isSupported(): boolean {
return this.zh.type === 'Coordinator' || Boolean(this.definition && !this.definition.generated);
return this.zh.type === "Coordinator" || Boolean(this.definition && !this.definition.generated);
}
get customClusters(): CustomClusters {
return this.zh.customClusters;
}
get otaExtraMetas(): zhc.Ota.ExtraMetas {
return typeof this.definition?.ota === 'object' ? this.definition.ota : {};
return typeof this.definition?.ota === "object" ? this.definition.ota : {};
}
get interviewed(): boolean {
return this.zh.interviewState === InterviewState.Successful || this.zh.interviewState === InterviewState.Failed;
}
constructor(device: zh.Device) {
@@ -47,8 +53,8 @@ export default class Device {
exposes(): zhc.Expose[] {
const exposes: zhc.Expose[] = [];
assert(this.definition, 'Cannot retreive exposes before definition is resolved');
if (typeof this.definition.exposes == 'function') {
assert(this.definition, "Cannot retreive exposes before definition is resolved");
if (typeof this.definition.exposes === "function") {
const options: KeyValue = this.options;
exposes.push(...this.definition.exposes(this.zh, options));
} else {
@@ -58,15 +64,15 @@ export default class Device {
return exposes;
}
async resolveDefinition(ignoreCache: boolean = false): Promise<void> {
if (!this.zh.interviewing && (!this.definition || this._definitionModelID !== this.zh.modelID || ignoreCache)) {
async resolveDefinition(ignoreCache = false): Promise<void> {
if (this.interviewed && (!this.definition || this._definitionModelID !== this.zh.modelID || ignoreCache)) {
this.definition = await zhc.findByDevice(this.zh, true);
this._definitionModelID = this.zh.modelID;
}
}
ensureInSettings(): void {
if (this.zh.type !== 'Coordinator' && !settings.getDevice(this.zh.ieeeAddr)) {
if (this.zh.type !== "Coordinator" && !settings.getDevice(this.zh.ieeeAddr)) {
settings.addDevice(this.zh.ieeeAddr);
}
}
@@ -74,24 +80,24 @@ export default class Device {
endpoint(key?: string | number): zh.Endpoint | undefined {
let endpoint: zh.Endpoint | undefined;
if (key == null || key == '') {
key = 'default';
if (!key) {
key = "default";
}
if (!isNaN(Number(key))) {
if (!Number.isNaN(Number(key))) {
endpoint = this.zh.getEndpoint(Number(key));
} else if (this.definition?.endpoint) {
const ID = this.definition?.endpoint?.(this.zh)[key];
if (ID) {
endpoint = this.zh.getEndpoint(ID);
} else if (key === 'default') {
} else if (key === "default") {
endpoint = this.zh.endpoints[0];
} else {
return undefined;
}
} else {
if (key !== 'default') {
if (key !== "default") {
return undefined;
}
@@ -107,21 +113,21 @@ export default class Device {
if (this.definition?.endpoint) {
const mapping = this.definition?.endpoint(this.zh);
for (const [name, id] of Object.entries(mapping)) {
if (id == endpoint.ID) {
if (id === endpoint.ID) {
epName = name;
}
}
}
/* v8 ignore next */
return epName === 'default' ? undefined : epName;
return epName === "default" ? undefined : epName;
}
getEndpointNames(): string[] {
const names: string[] = [];
for (const name in this.definition?.endpoint?.(this.zh) ?? {}) {
if (name !== 'default') {
if (name !== "default") {
names.push(name);
}
}
+12 -5
View File
@@ -1,16 +1,17 @@
import * as zhc from 'zigbee-herdsman-converters';
import type * as zhc from "zigbee-herdsman-converters";
import * as settings from '../util/settings';
import * as settings from "../util/settings";
export default class Group {
public zh: zh.Group;
private resolveDevice: (ieeeAddr: string) => Device | undefined;
// biome-ignore lint/style/useNamingConvention: API
get ID(): number {
return this.zh.groupID;
}
get options(): GroupOptions {
// XXX: Group always exists in settings
// biome-ignore lint/style/noNonNullAssertion: Group always exists in settings
return {...settings.getGroup(this.ID)!};
}
get name(): string {
@@ -26,8 +27,14 @@ export default class Group {
return !!device.zh.endpoints.find((e) => this.zh.members.includes(e));
}
membersDevices(): Device[] {
return this.zh.members.map((d) => this.resolveDevice(d.getDevice().ieeeAddr)!);
*membersDevices(): Generator<Device> {
for (const member of this.zh.members) {
const resolvedDevice = this.resolveDevice(member.deviceIeeeAddress);
if (resolvedDevice) {
yield resolvedDevice;
}
}
}
membersDefinitions(): zhc.Definition[] {
+34 -28
View File
@@ -1,20 +1,20 @@
import type {IClientOptions, IClientPublishOptions, MqttClient} from 'mqtt';
import type {IClientOptions, IClientPublishOptions, MqttClient} from "mqtt";
import type {Zigbee2MQTTAPI} from './types/api';
import type {Zigbee2MQTTAPI} from "./types/api";
import fs from 'node:fs';
import fs from "node:fs";
import bind from 'bind-decorator';
import {connectAsync} from 'mqtt';
import bind from "bind-decorator";
import {connectAsync} from "mqtt";
import logger from './util/logger';
import * as settings from './util/settings';
import utils from './util/utils';
import logger from "./util/logger";
import * as settings from "./util/settings";
import utils from "./util/utils";
const NS = 'z2m:mqtt';
const NS = "z2m:mqtt";
export default class MQTT {
private publishedTopics: Set<string> = new Set();
export default class Mqtt {
private publishedTopics = new Set<string>();
private connectionTimer?: NodeJS.Timeout;
private client!: MqttClient;
private eventBus: EventBus;
@@ -35,8 +35,8 @@ export default class MQTT {
const options: IClientOptions = {
will: {
topic: `${settings.get().mqtt.base_topic}/bridge/state`,
payload: Buffer.from(JSON.stringify({state: 'offline'})),
retain: settings.get().mqtt.force_disable_retain ? false : true,
payload: Buffer.from(JSON.stringify({state: "offline"})),
retain: !settings.get().mqtt.force_disable_retain,
qos: 1,
},
properties: {maximumPacketSize: mqttSettings.maximum_packet_size},
@@ -71,7 +71,7 @@ export default class MQTT {
logger.debug(`Using MQTT login with username only: ${mqttSettings.user}`);
options.username = mqttSettings.user;
} else {
logger.debug(`Using MQTT anonymous login`);
logger.debug("Using MQTT anonymous login");
}
if (mqttSettings.client_id) {
@@ -80,7 +80,7 @@ export default class MQTT {
}
if (mqttSettings.reject_unauthorized !== undefined && !mqttSettings.reject_unauthorized) {
logger.debug(`MQTT reject_unauthorized set false, ignoring certificate warnings.`);
logger.debug("MQTT reject_unauthorized set false, ignoring certificate warnings.");
options.rejectUnauthorized = false;
}
@@ -89,21 +89,21 @@ export default class MQTT {
// https://github.com/Koenkk/zigbee2mqtt/issues/9822
this.client.stream.setMaxListeners(0);
this.client.on('error', (err) => {
this.client.on("error", (err) => {
logger.error(`MQTT error: ${err.message}`);
});
if (mqttSettings.version != undefined && mqttSettings.version >= 5) {
this.client.on('disconnect', (packet) => {
if (mqttSettings.version != null && mqttSettings.version >= 5) {
this.client.on("disconnect", (packet) => {
logger.error(`MQTT disconnect: reason ${packet.reasonCode} (${packet.properties?.reasonString})`);
});
}
this.client.on('message', this.onMessage);
this.client.on("message", this.onMessage);
await this.onConnect();
this.client.on('connect', this.onConnect);
this.client.on("connect", this.onConnect);
this.republishRetainedTimer = setTimeout(async () => {
// Republish retained messages in case MQTT broker does not persist them.
@@ -116,7 +116,7 @@ export default class MQTT {
// Set timer at interval to check if connected to MQTT server.
this.connectionTimer = setInterval(() => {
if (!this.isConnected()) {
logger.error('Not connected to MQTT server!');
logger.error("Not connected to MQTT server!");
}
}, utils.seconds(10));
}
@@ -125,11 +125,11 @@ export default class MQTT {
clearTimeout(this.connectionTimer);
clearTimeout(this.republishRetainedTimer);
const stateData: Zigbee2MQTTAPI['bridge/state'] = {state: 'offline'};
const stateData: Zigbee2MQTTAPI["bridge/state"] = {state: "offline"};
await this.publish('bridge/state', JSON.stringify(stateData), {retain: true, qos: 0});
await this.publish("bridge/state", JSON.stringify(stateData), {retain: true, qos: 0});
this.eventBus.removeListeners(this);
logger.info('Disconnecting from MQTT server');
logger.info("Disconnecting from MQTT server");
await this.client?.endAsync();
}
@@ -142,11 +142,11 @@ export default class MQTT {
}
@bind private async onConnect(): Promise<void> {
logger.info('Connected to MQTT server');
logger.info("Connected to MQTT server");
const stateData: Zigbee2MQTTAPI['bridge/state'] = {state: 'online'};
const stateData: Zigbee2MQTTAPI["bridge/state"] = {state: "online"};
await this.publish('bridge/state', JSON.stringify(stateData), {retain: true, qos: 0});
await this.publish("bridge/state", JSON.stringify(stateData), {retain: true, qos: 0});
await this.subscribe(`${settings.get().mqtt.base_topic}/#`);
}
@@ -176,6 +176,12 @@ export default class MQTT {
skipLog = false,
skipReceive = true,
): Promise<void> {
if (topic.includes("+") || topic.includes("#")) {
// https://github.com/Koenkk/zigbee2mqtt/issues/26939#issuecomment-2772309646
logger.error(`Topic '${topic}' includes wildcard characters, skipping publish.`);
return;
}
const defaultOptions = {qos: 0 as const, retain: false};
topic = `${base}/${topic}`;
@@ -195,7 +201,7 @@ export default class MQTT {
if (!this.isConnected()) {
if (!skipLog) {
logger.error(`Not connected to MQTT server!`);
logger.error("Not connected to MQTT server!");
logger.error(`Cannot send message: topic: '${topic}', payload: '${payload}`);
}
+61 -46
View File
@@ -1,39 +1,38 @@
import fs from 'node:fs';
import {existsSync, readFileSync, writeFileSync} from "node:fs";
import objectAssignDeep from 'object-assign-deep';
import objectAssignDeep from "object-assign-deep";
import data from './util/data';
import logger from './util/logger';
import * as settings from './util/settings';
import utils from './util/utils';
import data from "./util/data";
import logger from "./util/logger";
import * as settings from "./util/settings";
import utils from "./util/utils";
const saveInterval = 1000 * 60 * 5; // 5 minutes
const dontCacheProperties = [
'action',
'action_.*',
'button',
'button_left',
'button_right',
'forgotten',
'keyerror',
'step_size',
'transition_time',
'group_list',
'group_capacity',
'no_occupancy_since',
'step_mode',
'transition_time',
'duration',
'elapsed',
'from_side',
'to_side',
'illuminance_lux', // removed in z2m 2.0.0
const SAVE_INTERVAL = 1000 * 60 * 5; // 5 minutes
const CACHE_IGNORE_PROPERTIES = [
"action",
"action_.*",
"button",
"button_left",
"button_right",
"forgotten",
"keyerror",
"step_size",
"transition_time",
"group_list",
"group_capacity",
"no_occupancy_since",
"step_mode",
"transition_time",
"duration",
"elapsed",
"from_side",
"to_side",
"illuminance_lux", // removed in z2m 2.0.0
];
class State {
private state: {[s: string | number]: KeyValue} = {};
private file = data.joinPath('state.json');
private readonly state = new Map<string | number, KeyValue>();
private readonly file = data.joinPath("state.json");
private timer?: NodeJS.Timeout;
constructor(
@@ -48,23 +47,37 @@ class State {
this.load();
// Save the state on every interval
this.timer = setInterval(() => this.save(), saveInterval);
this.timer = setInterval(() => this.save(), SAVE_INTERVAL);
}
stop(): void {
// Remove any invalid states (ie when the device has left the network) when the system is stopped
Object.keys(this.state)
.filter((k) => typeof k === 'string' && !this.zigbee.resolveEntity(k)) // string key = ieeeAddr
.forEach((k) => delete this.state[k]);
for (const [key] of this.state) {
if (typeof key === "string" && key.startsWith("0x") && !this.zigbee.resolveEntity(key)) {
// string key = ieeeAddr
this.state.delete(key);
}
}
clearTimeout(this.timer);
this.save();
}
clear(): void {
this.state.clear();
}
private load(): void {
if (fs.existsSync(this.file)) {
this.state.clear();
if (existsSync(this.file)) {
try {
this.state = JSON.parse(fs.readFileSync(this.file, 'utf8'));
const stateObj = JSON.parse(readFileSync(this.file, "utf8")) as KeyValue;
for (const key in stateObj) {
this.state.set(key.startsWith("0x") ? key : Number.parseInt(key, 10), stateObj[key]);
}
logger.debug(`Loaded state from file ${this.file}`);
} catch (error) {
logger.debug(`Failed to load state from file ${this.file} (corrupt file?) (${(error as Error).message})`);
@@ -77,40 +90,42 @@ class State {
private save(): void {
if (settings.get().advanced.cache_state_persistent) {
logger.debug(`Saving state to file ${this.file}`);
const json = JSON.stringify(this.state, null, 4);
const json = JSON.stringify(Object.fromEntries(this.state), null, 4);
try {
fs.writeFileSync(this.file, json, 'utf8');
writeFileSync(this.file, json, "utf8");
} catch (error) {
logger.error(`Failed to write state to '${this.file}' (${error})`);
}
} else {
logger.debug(`Not saving state`);
logger.debug("Not saving state");
}
}
exists(entity: Device | Group): boolean {
return this.state[entity.ID] !== undefined;
return this.state.has(entity.ID);
}
get(entity: Group | Device): KeyValue {
return this.state[entity.ID] || {};
return this.state.get(entity.ID) || {};
}
set(entity: Group | Device, update: KeyValue, reason?: string): KeyValue {
const fromState = this.state[entity.ID] || {};
const fromState = this.state.get(entity.ID) || {};
const toState = objectAssignDeep({}, fromState, update);
const newCache = {...toState};
const entityDontCacheProperties = entity.options.filtered_cache || [];
utils.filterProperties(dontCacheProperties.concat(entityDontCacheProperties), newCache);
utils.filterProperties(CACHE_IGNORE_PROPERTIES.concat(entityDontCacheProperties), newCache);
this.state[entity.ID] = newCache;
this.state.set(entity.ID, newCache);
this.eventBus.emitStateChange({entity, from: fromState, to: toState, reason, update});
return toState;
}
remove(ID: string | number): void {
delete this.state[ID];
remove(id: string | number): boolean {
return this.state.delete(id);
}
}
+432 -205
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,3 +1,3 @@
declare module 'json-stable-stringify-without-jsonify' {
declare module "json-stable-stringify-without-jsonify" {
export default function (obj: unknown): string;
}
+24 -159
View File
@@ -1,21 +1,19 @@
import type TypeEventBus from 'lib/eventBus';
import type TypeExtension from 'lib/extension/extension';
import type TypeDevice from 'lib/model/device';
import type TypeGroup from 'lib/model/group';
import type TypeMQTT from 'lib/mqtt';
import type TypeState from 'lib/state';
import type TypeZigbee from 'lib/zigbee';
import type {AdapterTypes as ZHAdapterTypes, Events as ZHEvents, Models as ZHModels} from 'zigbee-herdsman';
import type {Cluster as ZHCluster, FrameControl as ZHFrameControl} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype';
import type {AdapterTypes as ZHAdapterTypes, Events as ZHEvents, Models as ZHModels} from "zigbee-herdsman";
import type {Cluster as ZHCluster, FrameControl as ZHFrameControl} from "zigbee-herdsman/dist/zspec/zcl/definition/tstype";
import {LogLevel} from 'lib/util/settings';
type OptionalProps<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
import type TypeEventBus from "../eventBus";
import type TypeExtension from "../extension/extension";
import type TypeDevice from "../model/device";
import type TypeGroup from "../model/group";
import type TypeMqtt from "../mqtt";
import type TypeState from "../state";
import type TypeZigbee from "../zigbee";
import type {Zigbee2MQTTDeviceOptions, Zigbee2MQTTGroupOptions, Zigbee2MQTTSettings} from "./api";
declare global {
// Define some class types as global
type EventBus = TypeEventBus;
type MQTT = TypeMQTT;
type Mqtt = TypeMqtt;
type Zigbee = TypeZigbee;
type Group = TypeGroup;
type Device = TypeDevice;
@@ -23,11 +21,11 @@ declare global {
type Extension = TypeExtension;
// Types
type StateChangeReason = 'publishDebounce' | 'groupOptimistic' | 'lastSeenChanged' | 'publishCached' | 'publishThrottle';
type StateChangeReason = "publishDebounce" | "groupOptimistic" | "lastSeenChanged" | "publishCached" | "publishThrottle";
type PublishEntityState = (entity: Device | Group, payload: KeyValue, stateChangeReason?: StateChangeReason) => Promise<void>;
type RecursivePartial<T> = {[P in keyof T]?: RecursivePartial<T[P]>};
interface KeyValue {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// biome-ignore lint/suspicious/noExplicitAny: API
[s: string]: any;
}
@@ -36,7 +34,8 @@ declare global {
type Endpoint = ZHModels.Endpoint;
type Device = ZHModels.Device;
type Group = ZHModels.Group;
type LQI = ZHAdapterTypes.LQI;
// biome-ignore lint/style/useNamingConvention: API
type LQI = ZHAdapterTypes.Lqi;
type RoutingTable = ZHAdapterTypes.RoutingTable;
type CoordinatorVersion = ZHAdapterTypes.CoordinatorVersion;
type NetworkParameters = ZHAdapterTypes.NetworkParameters;
@@ -48,7 +47,7 @@ declare global {
namespace eventdata {
type EntityRenamed = {entity: Device | Group; homeAssisantRename: boolean; from: string; to: string};
type EntityRemoved = {id: number | string; name: string; type: 'device' | 'group'};
type EntityRemoved = {id: string; name: string; type: "device"} | {id: number; name: string; type: "group"};
type MQTTMessage = {topic: string; message: string};
type MQTTMessagePublished = {topic: string; payload: string; options: {retain: boolean; qos: number}};
type StateChange = {
@@ -61,24 +60,24 @@ declare global {
type PermitJoinChanged = ZHEvents.PermitJoinChangedPayload;
type LastSeenChanged = {
device: Device;
reason: 'deviceAnnounce' | 'networkAddress' | 'deviceJoined' | 'messageEmitted' | 'messageNonEmitted';
reason: "deviceAnnounce" | "networkAddress" | "deviceJoined" | "messageEmitted" | "messageNonEmitted";
};
type DeviceNetworkAddressChanged = {device: Device};
type DeviceAnnounce = {device: Device};
type DeviceInterview = {device: Device; status: 'started' | 'successful' | 'failed'};
type DeviceInterview = {device: Device; status: "started" | "successful" | "failed"};
type DeviceJoined = {device: Device};
type EntityOptionsChanged = {entity: Device | Group; from: KeyValue; to: KeyValue};
type ExposesChanged = {device: Device};
type Reconfigure = {device: Device};
type DeviceLeave = {ieeeAddr: string; name: string};
type GroupMembersChanged = {group: Group; action: 'remove' | 'add' | 'remove_all'; endpoint: zh.Endpoint; skipDisableReporting: boolean};
type DeviceLeave = {ieeeAddr: string; name: string; device?: Device};
type GroupMembersChanged = {group: Group; action: "remove" | "add" | "remove_all"; endpoint: zh.Endpoint; skipDisableReporting: boolean};
type PublishEntityState = {entity: Group | Device; message: KeyValue; stateChangeReason?: StateChangeReason; payload: KeyValue};
type DeviceMessage = {
type: ZHEvents.MessagePayloadType;
device: Device;
endpoint: zh.Endpoint;
linkquality: number;
groupID: number;
groupID: number; // XXX: should this be `?`
cluster: string | number;
data: KeyValue | Array<string | number>;
meta: {zclTransactionSequenceNumber?: number; manufacturerCode?: number; frameControl?: ZHFrameControl};
@@ -87,147 +86,13 @@ declare global {
}
// Settings
interface Settings {
version?: number;
homeassistant: {
enabled: boolean;
discovery_topic: string;
status_topic: string;
experimental_event_entities: boolean;
legacy_action_sensor: boolean;
};
availability: {
enabled: boolean;
active: {timeout: number};
passive: {timeout: number};
};
mqtt: {
base_topic: string;
include_device_information: boolean;
force_disable_retain: boolean;
version?: 3 | 4 | 5;
user?: string;
password?: string;
server: string;
ca?: string;
keepalive?: number;
key?: string;
cert?: string;
client_id?: string;
reject_unauthorized?: boolean;
maximum_packet_size: number;
};
serial: {
disable_led: boolean;
port?: string;
adapter?: 'deconz' | 'zstack' | 'ezsp' | 'zigate' | 'ember';
baudrate?: number;
rtscts?: boolean;
};
passlist: string[];
blocklist: string[];
map_options: {
graphviz: {
colors: {
fill: {
enddevice: string;
coordinator: string;
router: string;
};
font: {
coordinator: string;
router: string;
enddevice: string;
};
line: {
active: string;
inactive: string;
};
};
};
};
ota: {
update_check_interval: number;
disable_automatic_update_check: boolean;
zigbee_ota_override_index_location?: string;
image_block_response_delay?: number;
default_maximum_data_size?: number;
};
frontend: {
enabled: boolean;
auth_token?: string;
host?: string;
port: number;
base_url: string;
url?: string;
ssl_cert?: string;
ssl_key?: string;
};
devices: {[s: string]: DeviceOptions};
groups: {[s: string]: Omit<GroupOptions, 'ID'>};
device_options: KeyValue;
advanced: {
log_rotation: boolean;
log_console_json: boolean;
log_symlink_current: boolean;
log_output: ('console' | 'file' | 'syslog')[];
log_directory: string;
log_file: string;
log_level: LogLevel;
log_namespaced_levels: Record<string, LogLevel>;
log_syslog: KeyValue;
log_debug_to_mqtt_frontend: boolean;
log_debug_namespace_ignore: string;
log_directories_to_keep: number;
pan_id: number | 'GENERATE';
ext_pan_id: number[] | 'GENERATE';
channel: number;
adapter_concurrent?: number;
adapter_delay?: number;
cache_state: boolean;
cache_state_persistent: boolean;
cache_state_send_on_startup: boolean;
last_seen: 'disable' | 'ISO_8601' | 'ISO_8601_local' | 'epoch';
elapsed: boolean;
network_key: number[] | 'GENERATE';
timestamp_format: string;
output: 'json' | 'attribute' | 'attribute_and_json';
transmit_power?: number;
};
}
type Settings = Zigbee2MQTTSettings;
interface DeviceOptions {
disabled?: boolean;
retention?: number;
availability?: boolean | {timeout: number};
optimistic?: boolean;
debounce?: number;
debounce_ignore?: string[];
throttle?: number;
filtered_attributes?: string[];
filtered_cache?: string[];
filtered_optimistic?: string[];
icon?: string;
homeassistant?: KeyValue;
friendly_name: string;
description?: string;
qos?: 0 | 1 | 2;
}
type DeviceOptions = Zigbee2MQTTDeviceOptions;
interface DeviceOptionsWithId extends DeviceOptions {
ID: string;
}
interface GroupOptions {
ID: number;
optimistic?: boolean;
off_state?: 'all_members_off' | 'last_member_state';
filtered_attributes?: string[];
filtered_cache?: string[];
filtered_optimistic?: string[];
homeassistant?: KeyValue;
friendly_name: string;
description?: string;
qos?: 0 | 1 | 2;
}
type GroupOptions = Zigbee2MQTTGroupOptions;
}
+13
View File
@@ -0,0 +1,13 @@
declare module "unix-dgram" {
import {EventEmitter} from "node:events";
export class UnixDgramSocket extends EventEmitter {
send(buf: Buffer, callback?: (err?: Error) => void): void;
send(buf: Buffer, offset: number, length: number, path: string, callback?: (err?: Error) => void): void;
bind(path: string): void;
connect(remotePath: string): void;
close(): void;
}
export function createSocket(type: "unix_dgram", listener?: (msg: Buffer) => void): UnixDgramSocket;
}
+5 -4
View File
@@ -1,16 +1,17 @@
declare module 'zigbee2mqtt-frontend' {
declare module "zigbee2mqtt-frontend" {
export function getPath(): string;
}
declare module 'http' {
declare module "http" {
interface IncomingMessage {
originalUrl?: string;
path?: string;
}
}
declare module 'express-static-gzip' {
import {IncomingMessage, ServerResponse} from 'node:http';
declare module "express-static-gzip" {
import type {IncomingMessage, ServerResponse} from "node:http";
export type RequestHandler = (req: IncomingMessage, res: ServerResponse, finalhandler: (err: unknown) => void) => void;
export default function expressStaticGzip(root: string, options?: Record<string, unknown>): RequestHandler;
}
+2 -2
View File
@@ -1,7 +1,7 @@
import path from 'node:path';
import path from "node:path";
function setPath(): string {
return process.env.ZIGBEE2MQTT_DATA ? process.env.ZIGBEE2MQTT_DATA : path.normalize(path.join(__dirname, '..', '..', 'data'));
return process.env.ZIGBEE2MQTT_DATA ? process.env.ZIGBEE2MQTT_DATA : path.normalize(path.join(__dirname, "..", "..", "data"));
}
let dataPath = setPath();
+49 -43
View File
@@ -1,14 +1,14 @@
import assert from 'node:assert';
import fs from 'node:fs';
import path from 'node:path';
import assert from "node:assert";
import fs from "node:fs";
import path from "node:path";
import moment from 'moment';
import {rimrafSync} from 'rimraf';
import winston from 'winston';
import moment from "moment";
import {rimrafSync} from "rimraf";
import winston from "winston";
import * as settings from './settings';
import * as settings from "./settings";
const NAMESPACE_SEPARATOR = ':';
const NAMESPACE_SEPARATOR = ":";
class Logger {
// @ts-expect-error initalized in `init`
@@ -31,26 +31,26 @@ class Logger {
// What transports to enable
this.output = settings.get().advanced.log_output;
// Directory to log to
const timestamp = moment(Date.now()).format('YYYY-MM-DD.HH-mm-ss');
this.directory = settings.get().advanced.log_directory.replace('%TIMESTAMP%', timestamp);
const logFilename = settings.get().advanced.log_file.replace('%TIMESTAMP%', timestamp);
const timestamp = moment(Date.now()).format("YYYY-MM-DD.HH-mm-ss");
this.directory = settings.get().advanced.log_directory.replace("%TIMESTAMP%", timestamp);
const logFilename = settings.get().advanced.log_file.replace("%TIMESTAMP%", timestamp);
this.level = settings.get().advanced.log_level;
this.namespacedLevels = settings.get().advanced.log_namespaced_levels;
this.cachedNamespacedLevels = Object.assign({}, this.namespacedLevels);
assert(settings.LOG_LEVELS.includes(this.level), `'${this.level}' is not valid log_level, use one of '${settings.LOG_LEVELS.join(', ')}'`);
assert(settings.LOG_LEVELS.includes(this.level), `'${this.level}' is not valid log_level, use one of '${settings.LOG_LEVELS.join(", ")}'`);
const timestampFormat = (): string => moment().format(settings.get().advanced.timestamp_format);
this.logger = winston.createLogger({
level: 'debug',
level: "debug",
format: winston.format.combine(winston.format.errors({stack: true}), winston.format.timestamp({format: timestampFormat})),
levels: winston.config.syslog.levels,
});
const consoleSilenced = !this.output.includes('console');
const consoleSilenced = !this.output.includes("console");
// Print to user what logging is active
let logging = `Logging to console${consoleSilenced ? ' (silenced)' : ''}`;
let logging = `Logging to console${consoleSilenced ? " (silenced)" : ""}`;
// Setup default console logger
this.logger.add(
@@ -60,7 +60,7 @@ class Logger {
? winston.format.json()
: winston.format.combine(
// winston.config.syslog.levels sets 'warning' as 'red'
winston.format.colorize({colors: {debug: 'blue', info: 'green', warning: 'yellow', error: 'red'}}),
winston.format.colorize({colors: {debug: "blue", info: "green", warning: "yellow", error: "red"}}),
winston.format.printf((info) => {
return `[${info.timestamp}] ${info.level}: \t${info.message}`;
}),
@@ -68,15 +68,15 @@ class Logger {
}),
);
if (this.output.includes('file')) {
if (this.output.includes("file")) {
logging += `, file (filename: ${logFilename})`;
// Make sure that log directory exists when not logging to stdout only
fs.mkdirSync(this.directory, {recursive: true});
if (settings.get().advanced.log_symlink_current) {
const current = settings.get().advanced.log_directory.replace('%TIMESTAMP%', 'current');
const actual = './' + timestamp;
const current = settings.get().advanced.log_directory.replace("%TIMESTAMP%", "current");
const actual = `./${timestamp}`;
/* v8 ignore start */
if (fs.existsSync(current)) {
@@ -108,18 +108,17 @@ class Logger {
}
/* v8 ignore start */
if (this.output.includes('syslog')) {
logging += `, syslog`;
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unused-expressions
require('winston-syslog').Syslog;
if (this.output.includes("syslog")) {
logging += ", syslog";
require("winston-syslog").Syslog;
const options: KeyValue = {
app_name: 'Zigbee2MQTT',
app_name: "Zigbee2MQTT",
format: winston.format.printf((info) => info.message as string),
...settings.get().advanced.log_syslog,
};
if (options['type'] !== undefined) {
if (options.type !== undefined) {
options.type = options.type.toString();
}
@@ -146,11 +145,15 @@ class Logger {
}
public getDebugNamespaceIgnore(): string {
return this.debugNamespaceIgnoreRegex?.toString().slice(1, -1) /* remove slashes */ ?? '';
return (
this.debugNamespaceIgnoreRegex
?.toString()
.slice(1, -1) /* remove slashes */ ?? ""
);
}
public setDebugNamespaceIgnore(value: string): void {
this.debugNamespaceIgnoreRegex = value != '' ? new RegExp(value) : undefined;
this.debugNamespaceIgnoreRegex = value !== "" ? new RegExp(value) : undefined;
}
public getLevel(): settings.LogLevel {
@@ -178,11 +181,13 @@ class Logger {
private cacheNamespacedLevel(namespace: string): string {
let cached = namespace;
while (this.cachedNamespacedLevels[namespace] == undefined) {
while (this.cachedNamespacedLevels[namespace] === undefined) {
const sep = cached.lastIndexOf(NAMESPACE_SEPARATOR);
if (sep === -1) {
return (this.cachedNamespacedLevels[namespace] = this.level);
this.cachedNamespacedLevels[namespace] = this.level;
return this.level;
}
cached = cached.slice(0, sep);
@@ -201,30 +206,30 @@ class Logger {
}
}
public error(messageOrLambda: string | (() => string), namespace: string = 'z2m'): void {
this.log('error', messageOrLambda, namespace);
public error(messageOrLambda: string | (() => string), namespace = "z2m"): void {
this.log("error", messageOrLambda, namespace);
}
public warning(messageOrLambda: string | (() => string), namespace: string = 'z2m'): void {
this.log('warning', messageOrLambda, namespace);
public warning(messageOrLambda: string | (() => string), namespace = "z2m"): void {
this.log("warning", messageOrLambda, namespace);
}
public info(messageOrLambda: string | (() => string), namespace: string = 'z2m'): void {
this.log('info', messageOrLambda, namespace);
public info(messageOrLambda: string | (() => string), namespace = "z2m"): void {
this.log("info", messageOrLambda, namespace);
}
public debug(messageOrLambda: string | (() => string), namespace: string = 'z2m'): void {
public debug(messageOrLambda: string | (() => string), namespace = "z2m"): void {
if (this.debugNamespaceIgnoreRegex?.test(namespace)) {
return;
}
this.log('debug', messageOrLambda, namespace);
this.log("debug", messageOrLambda, namespace);
}
// Cleanup any old log directory.
private cleanup(): void {
if (settings.get().advanced.log_directory.includes('%TIMESTAMP%')) {
const rootDirectory = path.join(this.directory, '..');
if (settings.get().advanced.log_directory.includes("%TIMESTAMP%")) {
const rootDirectory = path.join(this.directory, "..");
let directories = fs.readdirSync(rootDirectory).map((d) => {
d = path.join(rootDirectory, d);
@@ -233,10 +238,11 @@ class Logger {
directories.sort((a: KeyValue, b: KeyValue) => b.birth - a.birth);
directories = directories.slice(settings.get().advanced.log_directories_to_keep, directories.length);
directories.forEach((dir) => {
for (const dir of directories) {
this.debug(`Removing old log directory '${dir.path}'`);
rimrafSync(dir.path);
});
}
}
}
@@ -252,10 +258,10 @@ class Logger {
// @ts-expect-error workaround
if (this.fileTransport._dest) {
// @ts-expect-error workaround
this.fileTransport._dest.on('finish', resolve);
this.fileTransport._dest.on("finish", resolve);
} else {
// @ts-expect-error workaround
this.fileTransport.on('open', () => this.fileTransport._dest.on('finish', resolve));
this.fileTransport.on("open", () => this.fileTransport._dest.on("finish", resolve));
}
this.fileTransport.end();
});
+579
View File
@@ -0,0 +1,579 @@
import {existsSync, mkdirSync} from "node:fs";
import {createServer} from "node:http";
import {parse} from "node:querystring";
import {findAllDevices} from "zigbee-herdsman/dist/adapter/adapterDiscovery";
import data from "./data";
import * as settings from "./settings";
type OnboardSettings = {
mqtt_base_topic?: string;
mqtt_server?: string;
mqtt_user?: string;
mqtt_password?: string;
serial_port?: string;
serial_adapter?: Settings["serial"]["adapter"];
serial_baudrate?: string;
serial_rtscts?: "on";
network_channel?: string;
network_key?: string;
network_pan_id?: string;
network_ext_pan_id?: string;
frontend_enabled?: "on";
frontend_port?: string;
homeassistant_enabled?: "on";
log_level?: Settings["advanced"]["log_level"];
};
function escapeHtml(s: string): string {
return s.replace(/[^0-9A-Za-z \-_.]/g, (c) => `&#${c.charCodeAt(0)};`);
}
function generateHtmlDone(frontendUrl: string | undefined): string {
return `
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Zigbee2MQTT Onboarding</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.min.css">
</head>
<body>
<main>
<h1>Zigbee2MQTT Onboarding</h1>
<p>Settings saved.</p>
<p>Zigbee2MQTT is now starting...</p>
<small>${frontendUrl ? `Redirecting to Zigbee2MQTT frontend at <a href="${frontendUrl}">${frontendUrl}</a> in 30 seconds.` : "You can close this page."}</small>
</main>
${frontendUrl ? `<script>setTimeout(() => { window.location.replace("${frontendUrl}"); }, 30000);</script>` : ""}
</body>
</html>
`;
}
function generateHtmlForm(currentSettings: RecursivePartial<Settings>, devices: Awaited<ReturnType<typeof findAllDevices>>): string {
let devicesSelect = "";
if (devices.length > 0) {
devicesSelect += '<select id="found_device" onchange="setFoundDevice(this)">';
devicesSelect += '<option value="">Select a device</option>';
for (const device of devices) {
// just in case name has commas, remove them to not mess with `split` logic
const deviceStr = `${device.name.replaceAll(",", "")}, ${device.path}, ${device.adapter ?? "unknown"}`;
devicesSelect += `<option value="${deviceStr}">${deviceStr}</option>`;
}
devicesSelect += "</select>";
devicesSelect += "<small>Optionally allows to configure coordinator port and type (if known) automatically.</small>";
} else {
devicesSelect = "<small>No device found</small>";
}
let generateCheckbox = "";
if (
Array.isArray(currentSettings.advanced?.network_key) ||
typeof currentSettings.advanced?.pan_id === "number" ||
Array.isArray(currentSettings.advanced?.ext_pan_id)
) {
generateCheckbox = `
<label for="generate_network">
<input
type="checkbox"
id="generate_network"
onclick="setGenerate(this)"
${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_NETWORK_KEY || process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_PAN_ID || process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_EXT_PAN_ID ? "disabled" : ""}>
Generate network?
</label>
`;
}
/* v8 ignore start */
return `
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Zigbee2MQTT Onboarding</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.min.css">
</head>
<body>
<main>
<h1>Zigbee2MQTT Onboarding</h1>
<p>Set the base configuration to start Zigbee2MQTT.</p>
<p>Optional fields will either be ignored or fallback to defaults if not set (see appropriate documentation page for more details).</p>
<p>If a field is disabled, it means <a href="https://www.zigbee2mqtt.io/guide/configuration/#environment-variables" target="_blank">environment variables</a> are being used to override specific values (for example, through the Home Assistant add-on configuration page).</p>
<hr>
<form method="post">
<fieldset ${process.env.ZIGBEE2MQTT_CONFIG_SERIAL || process.env.ZIGBEE2MQTT_CONFIG_SERIAL_PORT || process.env.ZIGBEE2MQTT_CONFIG_SERIAL_ADAPTER ? "disabled" : ""}>
<label for="found_device">Found Devices</label>
${devicesSelect}
</fieldset>
<fieldset ${process.env.ZIGBEE2MQTT_CONFIG_SERIAL ? "disabled" : ""}>
<label for="serial_port">Coordinator/Adapter Port/Path</label>
<input
type="text"
id="serial_port"
name="serial_port"
value="${currentSettings.serial?.port ?? ""}"
required
${process.env.ZIGBEE2MQTT_CONFIG_SERIAL_PORT ? "disabled" : ""}>
<label for="serial_adapter">Coordinator/Adapter Type/Stack/Driver</label>
<select id="serial_adapter" name="serial_adapter" required ${process.env.ZIGBEE2MQTT_CONFIG_SERIAL_ADAPTER ? "disabled" : ""}>
<option value="zstack" ${currentSettings.serial?.adapter === "zstack" ? "selected" : ""}>zstack</option>
<option value="ember" ${currentSettings.serial?.adapter === "ember" ? "selected" : ""}>ember</option>
<option value="deconz" ${currentSettings.serial?.adapter === "deconz" ? "selected" : ""}>deconz</option>
<option value="zigate" ${currentSettings.serial?.adapter === "zigate" ? "selected" : ""}>zigate</option>
<option value="zboss" ${currentSettings.serial?.adapter === "zboss" ? "selected" : ""}>zboss</option>
</select>
<label for="serial_baudrate">Coordinator/Adapter Baudrate</label>
<select id="serial_baudrate" name="serial_baudrate" ${process.env.ZIGBEE2MQTT_CONFIG_SERIAL_BAUDRATE ? "disabled" : ""}>
<option value="38400" ${currentSettings.serial?.baudrate === 38400 ? "selected" : ""}>38400</option>
<option value="57600" ${currentSettings.serial?.baudrate === 57600 ? "selected" : ""}>57600</option>
<option value="115200" ${!currentSettings.serial?.baudrate || currentSettings.serial?.baudrate === 115200 ? "selected" : ""}>115200</option>
<option value="230400" ${currentSettings.serial?.baudrate === 230400 ? "selected" : ""}>230400</option>
<option value="460800" ${currentSettings.serial?.baudrate === 460800 ? "selected" : ""}>460800</option>
<option value="921600" ${currentSettings.serial?.baudrate === 921600 ? "selected" : ""}>921600</option>
</select>
<small>Can be ignored for networked coordinators (TCP).</small>
<label for="serial_rtscts">Coordinator/Adapter Hardware Flow Control ("rtscts: true")</label>
<input
type="checkbox"
id="serial_rtscts"
name="serial_rtscts"
${currentSettings.serial?.rtscts ? "checked" : ""}
style="margin-bottom: 1rem;">
<small>Can be ignored for networked coordinators (TCP).</small>
</fieldset>
<small>
<a href="https://www.zigbee2mqtt.io/guide/configuration/adapter-settings.html" target="_blank">https://www.zigbee2mqtt.io/guide/configuration/adapter-settings.html</a>
</small>
<hr>
<fieldset ${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED ? "disabled" : ""}>
<label for="closest_wifi_channel">Closest WiFi Channel</label>
<input
type="number"
min="0"
max="14"
id="closest_wifi_channel"
value="0"
onclick="setBestZigbeeChannel(this)"
${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_CHANNEL ? "disabled" : ""}>
<small>Optionally set to your closest WiFi channel to pick the best value for "Network channel" below.</small>
<label for="network_channel">Network Channel</label>
<input
type="number"
min="11"
max="26"
id="network_channel"
name="network_channel"
value="${currentSettings.advanced?.channel ?? "25"}"
required
${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_CHANNEL ? "disabled" : ""}>
</fieldset>
<fieldset ${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED ? "disabled" : ""}>
${generateCheckbox}
<label for="network_key">Network Key</label>
<input
type="text"
id="network_key"
name="network_key"
value="${currentSettings.advanced?.network_key ?? "GENERATE"}"
pattern="^([0-9]+(,[0-9]+){15})|GENERATE$"
required
${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_NETWORK_KEY ? "disabled" : ""}>
<label for="network_pan_id">Network PAN ID</label>
<input
type="text"
id="network_pan_id"
name="network_pan_id"
value="${currentSettings.advanced?.pan_id ?? "GENERATE"}"
pattern="^([0-9]{1,5})|GENERATE$"
required
${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_PAN_ID ? "disabled" : ""}>
<label for="network_ext_pan_id">Network Extended PAN ID</label>
<input
type="text"
id="network_ext_pan_id"
name="network_ext_pan_id"
value="${currentSettings.advanced?.ext_pan_id ?? "GENERATE"}"
pattern="^([0-9]+(,[0-9]+){7})|GENERATE$"
required
${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_EXT_PAN_ID ? "disabled" : ""}>
</fieldset>
<small>
<a href="https://www.zigbee2mqtt.io/guide/configuration/zigbee-network.html" target="_blank">https://www.zigbee2mqtt.io/guide/configuration/zigbee-network.html</a>
</small>
<hr>
<fieldset ${process.env.ZIGBEE2MQTT_CONFIG_MQTT ? "disabled" : ""}>
<label for="mqtt_base_topic">MQTT Base Topic</label>
<input
type="text"
id="mqtt_base_topic"
name="mqtt_base_topic"
value="${currentSettings.mqtt?.base_topic ?? "zigbee2mqtt"}"
required
${process.env.ZIGBEE2MQTT_CONFIG_MQTT_BASE_TOPIC ? "disabled" : ""}>
<label for="mqtt_server">MQTT Server</label>
<input
type="text"
id="mqtt_server"
name="mqtt_server"
value="${currentSettings.mqtt?.server ?? "mqtt://localhost:1883"}"
required
${process.env.ZIGBEE2MQTT_CONFIG_MQTT_SERVER ? "disabled" : ""}>
<label for="mqtt_user">MQTT User</label>
<input
type="text"
id="mqtt_user"
name="mqtt_user"
value="${currentSettings.mqtt?.user ?? ""}"
${process.env.ZIGBEE2MQTT_CONFIG_MQTT_USER ? "disabled" : ""}>
<small>Optional. Set only if using authentication.</small>
<label for="mqtt_password">MQTT Password</label>
<input
type="password"
id="mqtt_password"
name="mqtt_password"
value="${currentSettings.mqtt?.password ?? ""}"
${process.env.ZIGBEE2MQTT_CONFIG_MQTT_PASSWORD ? "disabled" : ""}>
<small>Optional. Set only if using authentication.</small>
</fieldset>
<small>
<a href="https://www.zigbee2mqtt.io/guide/configuration/mqtt.html" target="_blank">https://www.zigbee2mqtt.io/guide/configuration/mqtt.html</a>
</small>
<hr>
<fieldset ${process.env.ZIGBEE2MQTT_CONFIG_FRONTEND ? "disabled" : ""}>
<label for="frontend_enabled">
<input
type="checkbox"
id="frontend_enabled"
name="frontend_enabled"
${currentSettings.frontend?.enabled ? "checked" : ""}
${process.env.ZIGBEE2MQTT_CONFIG_FRONTEND_ENABLED ? "disabled" : ""}>
Frontend enabled?
</label>
<label for="frontend_port">Frontend Port</label>
<input
type="number"
min="0"
max="65535"
id="frontend_port"
name="frontend_port"
value="${currentSettings.frontend?.port ?? "8080"}"
required
${process.env.ZIGBEE2MQTT_CONFIG_FRONTEND_PORT ? "disabled" : ""}>
</fieldset>
<small>
<a href="https://www.zigbee2mqtt.io/guide/configuration/frontend.html" target="_blank">https://www.zigbee2mqtt.io/guide/configuration/frontend.html</a>
</small>
<fieldset ${process.env.ZIGBEE2MQTT_CONFIG_HOMEASSISTANT ? "disabled" : ""}>
<label for="homeassistant_enabled" ${process.env.ZIGBEE2MQTT_CONFIG_HOMEASSISTANT_ENABLED ? "disabled" : ""}>
<input type="checkbox" id="homeassistant_enabled" name="homeassistant_enabled" ${currentSettings.homeassistant?.enabled ? "checked" : ""}>
Home Assistant enabled?
</label>
</fieldset>
<small>
<a href="https://www.zigbee2mqtt.io/guide/configuration/homeassistant.html" target="_blank">https://www.zigbee2mqtt.io/guide/configuration/homeassistant.html</a>
</small>
<hr>
<fieldset ${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED ? "disabled" : ""}>
<label for="log_level">Log Level</label>
<select id="log_level" name="log_level" ${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_LOG_LEVEL ? "disabled" : ""}>
<option value="error" ${currentSettings.advanced?.log_level === "error" ? "selected" : ""}>error</option>
<option value="warning" ${currentSettings.advanced?.log_level === "warning" ? "selected" : ""}>warning</option>
<option value="info" ${!currentSettings.advanced?.log_level || currentSettings.advanced?.log_level === "info" ? "selected" : ""}>info</option>
<option value="debug" ${currentSettings.advanced?.log_level === "debug" ? "selected" : ""}>debug</option>
</select>
</fieldset>
<small>
<a href="https://www.zigbee2mqtt.io/guide/configuration/logging.html" target="_blank">https://www.zigbee2mqtt.io/guide/configuration/logging.html</a>
</small>
<hr>
<input type="submit" value="Submit">
</form>
</main>
<script>
function setFoundDevice(e) {
if (!e.value) {
return;
}
const [, path, adapter] = e.value.split(", ");
const serialPortEl = document.querySelector("#serial_port");
serialPortEl.value = path;
const serialAdapterEl = document.querySelector("#serial_adapter");
if (['zstack', 'ember', 'deconz', 'zigate', 'zboss'].includes(adapter)) {
serialAdapterEl.value = adapter;
} else {
serialAdapterEl.value = '';
}
}
function setBestZigbeeChannel(e) {
const wifiChannel = parseInt(e.value, 10);
const networkChannelEl = document.querySelector("#network_channel");
if (wifiChannel >= 11) {
// WiFi 11-14
networkChannelEl.value = 15;
} else if (wifiChannel >= 6) {
// WiFi 6-10
networkChannelEl.value = 11;
} else {
// WiFi 1-5
networkChannelEl.value = 25;
}
}
function setGenerate(e) {
document.querySelector("#network_key").value = e.checked ? "GENERATE" : "${currentSettings.advanced?.network_key ?? "GENERATE"}";
document.querySelector("#network_pan_id").value = e.checked ? "GENERATE" : "${currentSettings.advanced?.pan_id ?? "GENERATE"}";
document.querySelector("#network_ext_pan_id").value = e.checked ? "GENERATE" : "${currentSettings.advanced?.ext_pan_id ?? "GENERATE"}";
}
</script>
</body>
</html>
`;
/* v8 ignore stop */
}
function generateHtmlError(errors: string): string {
return `
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Zigbee2MQTT Onboarding</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.min.css">
</head>
<body>
<main>
<h1>Zigbee2MQTT configuration is not valid</h1>
<p style="color: #F00;">Found the following errors:</p>
${errors}
<hr>
<p>If you don't know how to solve this, read <a href="https://www.zigbee2mqtt.io/guide/configuration" target="_blank">https://www.zigbee2mqtt.io/guide/configuration</a></p>
<form method="post" action="/">
<input type="submit" value="Close">
</form>
</main>
</body>
</html>
`;
}
function getServerUrl(): URL {
return new URL(process.env.Z2M_ONBOARD_URL ?? "http://0.0.0.0:8080");
}
async function startOnboardingServer(): Promise<boolean> {
const currentSettings = settings.get();
const serverUrl = getServerUrl();
let server: ReturnType<typeof createServer> | undefined;
let failed = false;
const success = await new Promise<boolean>((resolve) => {
server = createServer(async (req, res) => {
if (req.method === "POST") {
if (failed) {
res.end(() => {
resolve(false);
});
} else {
let body = "";
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
const result = parse(body) as unknown as OnboardSettings;
console.log(JSON.stringify(currentSettings));
console.log(JSON.stringify(result));
const frontendEnabled = result.frontend_enabled === "on";
const updatedSettings: RecursivePartial<Settings> = {
mqtt: {
base_topic: result.mqtt_base_topic,
server: result.mqtt_server,
user: result.mqtt_user || undefined, // empty string => removed
password: result.mqtt_password || undefined, // empty string => removed
},
serial: {
port: result.serial_port,
adapter: result.serial_adapter,
baudrate: result.serial_baudrate ? Number.parseInt(result.serial_baudrate, 10) : undefined,
rtscts: result.serial_rtscts === "on",
},
advanced: {
log_level: result.log_level,
channel: result.network_channel ? Number.parseInt(result.network_channel, 10) : undefined,
network_key: result.network_key
? result.network_key === "GENERATE"
? result.network_key
: result.network_key.split(",").map((v) => Number.parseInt(v, 10))
: undefined,
pan_id: result.network_pan_id
? result.network_pan_id === "GENERATE"
? result.network_pan_id
: Number.parseInt(result.network_pan_id, 10)
: undefined,
ext_pan_id: result.network_ext_pan_id
? result.network_ext_pan_id === "GENERATE"
? result.network_ext_pan_id
: result.network_ext_pan_id.split(",").map((v) => Number.parseInt(v, 10))
: undefined,
},
frontend: {
enabled: frontendEnabled,
port: result.frontend_port ? Number.parseInt(result.frontend_port, 10) : undefined,
},
homeassistant: {
enabled: result.homeassistant_enabled === "on",
},
};
try {
settings.apply(updatedSettings);
// to redirect, make sure frontend "will be" enabled, and host isn't socket
const redirect =
!process.env.Z2M_ONBOARD_NO_REDIRECT &&
frontendEnabled &&
(!currentSettings.frontend?.host || !currentSettings.frontend.host.startsWith("/"));
const protocol = currentSettings.frontend?.ssl_cert && currentSettings.frontend.ssl_key ? "https" : "http";
res.setHeader("Content-Type", "text/html");
res.writeHead(200);
res.end(
generateHtmlDone(
redirect
? /* v8 ignore next */ `${protocol}://${currentSettings.frontend?.host ?? "localhost"}:${currentSettings.frontend?.port ?? "8080"}${currentSettings.frontend?.base_url ?? "/"}`
: undefined,
),
() => {
resolve(true);
},
);
} catch (error) {
console.error(`Failed to apply configuration: ${(error as Error).message}`);
failed = true;
if (process.env.Z2M_ONBOARD_NO_FAILURE_PAGE) {
res.end(() => {
resolve(false);
});
} else {
res.setHeader("Content-Type", "text/html");
res.writeHead(406);
res.end(generateHtmlError(`<p>${escapeHtml((error as Error).message)}</p>`));
}
}
});
}
} else {
res.setHeader("Content-Type", "text/html");
res.writeHead(200);
res.end(generateHtmlForm(currentSettings, await findAllDevices()));
}
});
server.listen(Number.parseInt(serverUrl.port), serverUrl.hostname, () => {
console.log(`Onboarding page is available at ${serverUrl.href}`);
});
});
await new Promise((resolve) => server?.close(resolve));
return success;
}
async function startFailureServer(errors: string): Promise<void> {
const serverUrl = getServerUrl();
let server: ReturnType<typeof createServer> | undefined;
await new Promise<void>((resolve) => {
server = createServer(async (req, res) => {
if (req.method === "POST") {
res.end(() => {
resolve();
});
} else {
res.setHeader("Content-Type", "text/html");
res.writeHead(406);
res.end(generateHtmlError(errors));
}
});
server.listen(Number.parseInt(serverUrl.port), serverUrl.hostname, () => {
console.error(`Failure page is available at ${serverUrl.href}`);
});
});
await new Promise((resolve) => server?.close(resolve));
}
export async function onboard(): Promise<boolean> {
if (!existsSync(data.getPath())) {
mkdirSync(data.getPath(), {recursive: true});
}
const confExists = existsSync(data.joinPath("configuration.yaml"));
if (!confExists) {
settings.writeMinimalDefaults();
} else {
// migrate first
const {migrateIfNecessary} = await import("./settingsMigration.js");
migrateIfNecessary();
// trigger initial writing of `ZIGBEE2MQTT_CONFIG_*` ENVs
settings.write();
}
// use `configuration.yaml` file to detect "brand new install"
// env allows to re-run onboard even with existing install
if (!process.env.Z2M_ONBOARD_NO_SERVER && (process.env.Z2M_ONBOARD_FORCE_RUN || !confExists)) {
const success = await startOnboardingServer();
if (!success) {
return false;
}
}
settings.reRead();
const errors = settings.validate();
if (errors.length > 0) {
let pErrors = "";
console.error("\n\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
console.error(" READ THIS CAREFULLY\n");
console.error("Refusing to start because configuration is not valid, found the following errors:");
for (const error of errors) {
console.error(`- ${error}`);
pErrors += `<p>- ${escapeHtml(error)}</p>`;
}
console.error("\nIf you don't know how to solve this, read https://www.zigbee2mqtt.io/guide/configuration");
console.error("\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n\n");
if (!process.env.Z2M_ONBOARD_NO_SERVER && !process.env.Z2M_ONBOARD_NO_FAILURE_PAGE) {
await startFailureServer(pErrors);
}
return false;
}
return true;
}
+70
View File
@@ -0,0 +1,70 @@
import type {UnixDgramSocket} from "unix-dgram";
import {platform} from "node:os";
import logger from "./logger";
/**
* Handle sd_notify protocol, @see https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html
* No-op if running on unsupported platforms or without Type=notify
* Soft-fails if improperly setup (this is not necessary for Zigbee2MQTT to function properly)
*/
export async function initSdNotify(): Promise<{notifyStopping: () => void; stop: () => void} | undefined> {
if (!process.env.NOTIFY_SOCKET) {
return;
}
let socket: UnixDgramSocket | undefined;
try {
const {createSocket} = await import("unix-dgram");
socket = createSocket("unix_dgram");
} catch (error) {
if (platform() !== "win32" || process.env.WSL_DISTRO_NAME) {
// not on plain Windows
logger.error(`Could not init sd_notify: ${(error as Error).message}`);
// biome-ignore lint/style/noNonNullAssertion: always Error
logger.debug((error as Error).stack!);
} else {
// this should not happen
logger.warning(`NOTIFY_SOCKET env is set: ${(error as Error).message}`);
}
return;
}
const sendToSystemd = (msg: string): void => {
const buffer = Buffer.from(msg);
// biome-ignore lint/style/noNonNullAssertion: valid from start of function
socket.send(buffer, 0, buffer.byteLength, process.env.NOTIFY_SOCKET!, (err) => {
if (err) {
logger.warning(`Failed to send "${msg}" to systemd: ${err.message}`);
}
});
};
const notifyStopping = (): void => sendToSystemd("STOPPING=1");
sendToSystemd("READY=1");
const wdUSec = process.env.WATCHDOG_USEC !== undefined ? Math.max(0, Number.parseInt(process.env.WATCHDOG_USEC, 10)) : -1;
if (wdUSec > 0) {
// Convert us to ms, send twice as frequently as the timeout
const watchdogInterval = setInterval(() => sendToSystemd("WATCHDOG=1"), wdUSec / 1000 / 2);
return {
notifyStopping,
stop: (): void => clearInterval(watchdogInterval),
};
}
if (wdUSec !== -1) {
logger.warning(`WATCHDOG_USEC invalid: "${process.env.WATCHDOG_USEC}", parsed to "${wdUSec}"`);
}
return {
notifyStopping,
stop: (): void => {},
};
}
+31 -3
View File
@@ -68,6 +68,26 @@
"requiresRestart": true,
"default": 10,
"description": "Time after which an active device will be marked as offline in minutes"
},
"max_jitter": {
"type": "number",
"title": "Max jitter",
"default": 30000,
"minimum": 1000,
"description": "Maximum jitter (in msec) allowed on timeout to avoid availability pings trying to trigger around the same time"
},
"backoff": {
"type": "boolean",
"title": "Enabled",
"description": "Enable timeout backoff on failed availability pings (x1.5, x3, x6, x12...)",
"default": true
},
"pause_on_backoff_gt": {
"type": "number",
"title": "Pause on backoff greater than",
"default": 0,
"minimum": 0,
"description": "Pause availability pings when backoff reaches over this limit until a new Zigbee message is received from the device. A value of zero disables pausing."
}
},
"required": ["timeout"]
@@ -219,7 +239,7 @@
},
"adapter": {
"type": ["string"],
"enum": ["deconz", "zstack", "zigate", "ezsp", "ember", "zboss"],
"enum": ["deconz", "zstack", "zigate", "ezsp", "ember", "zboss", "zoh"],
"title": "Adapter",
"requiresRestart": true,
"description": "Adapter type, not needed unless you are experiencing problems"
@@ -410,6 +430,14 @@
"description": "Base URL for the frontend. If hosted under a subpath, e.g. 'http://localhost:8080/z2m', set this to '/z2m'",
"default": "/",
"requiresRestart": true
},
"notification_filter": {
"title": "Notification Filter",
"description": "Hide frontend notifications matching specified regex strings. Example: 'z2m: Failed to ping.*'",
"type": "array",
"items": {
"type": "string"
}
}
},
"required": []
@@ -509,7 +537,7 @@
"title": "Log Namespaced Levels",
"description": "Set individual log levels for certain namespaces",
"default": {},
"examples": [{"z2m:mqtt": "warning"}, {"zh:ember:uart:ash": "info"}]
"examples": [{ "z2m:mqtt": "warning" }, { "zh:ember:uart:ash": "info" }]
},
"log_debug_to_mqtt_frontend": {
"type": "boolean",
@@ -825,7 +853,7 @@
"icon": {
"type": "string",
"title": "Icon",
"description": "The user-defined device icon for the frontend. It can be a full URL link to an image (e.g. https://SOME.SITE/MODEL123.jpg) or a path to a local file inside the `device_icons` directory."
"description": "The user-defined device icon for the frontend. It can be a full URL link to an image (e.g. https://SOME.SITE/MODEL123.jpg) or a path to a local file inside the `device_icons` directory (e.g. device_icons/MODEL123.png)."
},
"homeassistant": {
"type": ["object", "null"],
+164 -129
View File
@@ -1,52 +1,53 @@
import path from 'node:path';
import type {ValidateFunction} from "ajv";
import Ajv, {ValidateFunction} from 'ajv';
import objectAssignDeep from 'object-assign-deep';
import path from "node:path";
import data from './data';
import schemaJson from './settings.schema.json';
import utils from './utils';
import yaml, {YAMLFileException} from './yaml';
import Ajv from "ajv";
import objectAssignDeep from "object-assign-deep";
import data from "./data";
import schemaJson from "./settings.schema.json";
import utils from "./utils";
import yaml, {YAMLFileException} from "./yaml";
export {schemaJson};
// When updating also update:
// - https://github.com/Koenkk/zigbee2mqtt/blob/dev/data/configuration.example.yaml#L2
// - https://github.com/zigbee2mqtt/hassio-zigbee2mqtt/blob/master/common/rootfs/docker-entrypoint.sh#L54
export const CURRENT_VERSION = 4;
/** NOTE: by order of priority, lower index is lower level (more important) */
export const LOG_LEVELS: readonly string[] = ['error', 'warning', 'info', 'debug'] as const;
export type LogLevel = 'error' | 'warning' | 'info' | 'debug';
export const LOG_LEVELS: readonly string[] = ["error", "warning", "info", "debug"] as const;
export type LogLevel = "error" | "warning" | "info" | "debug";
const CONFIG_FILE_PATH = data.joinPath('configuration.yaml');
const NULLABLE_SETTINGS = ['homeassistant'];
const ajvSetting = new Ajv({allErrors: true}).addKeyword('requiresRestart').compile(schemaJson);
const ajvRestartRequired = new Ajv({allErrors: true}).addKeyword({keyword: 'requiresRestart', validate: (s: unknown) => !s}).compile(schemaJson);
const CONFIG_FILE_PATH = data.joinPath("configuration.yaml");
const NULLABLE_SETTINGS = ["homeassistant"];
const ajvSetting = new Ajv({allErrors: true}).addKeyword("requiresRestart").compile(schemaJson);
const ajvRestartRequired = new Ajv({allErrors: true}).addKeyword({keyword: "requiresRestart", validate: (s: unknown) => !s}).compile(schemaJson);
const ajvRestartRequiredDeviceOptions = new Ajv({allErrors: true})
.addKeyword({keyword: 'requiresRestart', validate: (s: unknown) => !s})
.addKeyword({keyword: "requiresRestart", validate: (s: unknown) => !s})
.compile(schemaJson.definitions.device);
const ajvRestartRequiredGroupOptions = new Ajv({allErrors: true})
.addKeyword({keyword: 'requiresRestart', validate: (s: unknown) => !s})
.addKeyword({keyword: "requiresRestart", validate: (s: unknown) => !s})
.compile(schemaJson.definitions.group);
export const defaults: RecursivePartial<Settings> = {
export const defaults = {
homeassistant: {
enabled: false,
discovery_topic: 'homeassistant',
status_topic: 'homeassistant/status',
discovery_topic: "homeassistant",
status_topic: "homeassistant/status",
legacy_action_sensor: false,
experimental_event_entities: false,
},
availability: {
enabled: false,
active: {timeout: 10},
active: {timeout: 10, max_jitter: 30000, backoff: true, pause_on_backoff_gt: 0},
passive: {timeout: 1500},
},
frontend: {
enabled: false,
port: 8080,
base_url: '/',
base_url: "/",
},
mqtt: {
base_topic: 'zigbee2mqtt',
base_topic: "zigbee2mqtt",
include_device_information: false,
force_disable_retain: false,
// 1MB = roughly 3.5KB per device * 300 devices for `/bridge/devices`
@@ -61,18 +62,18 @@ export const defaults: RecursivePartial<Settings> = {
graphviz: {
colors: {
fill: {
enddevice: '#fff8ce',
coordinator: '#e04e5d',
router: '#4ea3e0',
enddevice: "#fff8ce",
coordinator: "#e04e5d",
router: "#4ea3e0",
},
font: {
coordinator: '#ffffff',
router: '#ffffff',
enddevice: '#000000',
coordinator: "#ffffff",
router: "#ffffff",
enddevice: "#000000",
},
line: {
active: '#009900',
inactive: '#994444',
active: "#009900",
inactive: "#994444",
},
},
},
@@ -88,14 +89,14 @@ export const defaults: RecursivePartial<Settings> = {
log_rotation: true,
log_console_json: false,
log_symlink_current: false,
log_output: ['console', 'file'],
log_directory: path.join(data.getPath(), 'log', '%TIMESTAMP%'),
log_file: 'log.log',
log_level: /* v8 ignore next */ process.env.DEBUG ? 'debug' : 'info',
log_output: ["console", "file"],
log_directory: path.join(data.getPath(), "log", "%TIMESTAMP%"),
log_file: "log.log",
log_level: /* v8 ignore next */ process.env.DEBUG ? "debug" : "info",
log_namespaced_levels: {},
log_syslog: {},
log_debug_to_mqtt_frontend: false,
log_debug_namespace_ignore: '',
log_debug_namespace_ignore: "",
log_directories_to_keep: 10,
pan_id: 0x1a62,
ext_pan_id: [0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd],
@@ -105,13 +106,13 @@ export const defaults: RecursivePartial<Settings> = {
cache_state: true,
cache_state_persistent: true,
cache_state_send_on_startup: true,
last_seen: 'disable',
last_seen: "disable",
elapsed: false,
network_key: [1, 3, 5, 7, 9, 11, 13, 15, 0, 2, 4, 6, 8, 10, 12, 13],
timestamp_format: 'YYYY-MM-DD HH:mm:ss',
output: 'json',
timestamp_format: "YYYY-MM-DD HH:mm:ss",
output: "json",
},
};
} satisfies RecursivePartial<Settings>;
let _settings: Partial<Settings> | undefined;
let _settingsWithDefaults: Settings | undefined;
@@ -137,16 +138,48 @@ function parseValueRef(text: string): {filename: string; key: string} | null {
if (match) {
let filename = match[1];
// This is mainly for backward compatibility.
if (!filename.endsWith('.yaml') && !filename.endsWith('.yml')) {
filename += '.yaml';
if (!filename.endsWith(".yaml") && !filename.endsWith(".yml")) {
filename += ".yaml";
}
return {filename, key: match[2]};
} else {
return null;
}
return null;
}
function write(): void {
export function writeMinimalDefaults(): void {
const minimal = {
version: CURRENT_VERSION,
mqtt: {
base_topic: defaults.mqtt.base_topic,
server: "mqtt://localhost:1883",
},
serial: {},
advanced: {
log_level: defaults.advanced.log_level,
channel: defaults.advanced.channel,
network_key: "GENERATE",
pan_id: "GENERATE",
ext_pan_id: "GENERATE",
},
frontend: {
enabled: defaults.frontend.enabled,
port: defaults.frontend.port,
},
homeassistant: {
enabled: defaults.homeassistant.enabled,
},
} as Partial<Settings>;
applyEnvironmentVariables(minimal);
yaml.writeIfChanged(CONFIG_FILE_PATH, minimal);
_settings = read();
loadSettingsWithDefaults();
}
export function write(): void {
const settings = getPersistedSettings();
const toWrite: KeyValue = objectAssignDeep({}, settings);
@@ -154,25 +187,25 @@ function write(): void {
const actual = yaml.read(CONFIG_FILE_PATH);
// In case the setting is defined in a separate file (e.g. !secret network_key) update it there.
for (const path of [
['mqtt', 'server'],
['mqtt', 'user'],
['mqtt', 'password'],
['advanced', 'network_key'],
['frontend', 'auth_token'],
for (const [ns, key] of [
["mqtt", "server"],
["mqtt", "user"],
["mqtt", "password"],
["advanced", "network_key"],
["frontend", "auth_token"],
]) {
if (actual[path[0]] && actual[path[0]][path[1]]) {
const ref = parseValueRef(actual[path[0]][path[1]]);
if (actual[ns]?.[key]) {
const ref = parseValueRef(actual[ns][key]);
if (ref) {
yaml.updateIfChanged(data.joinPath(ref.filename), ref.key, toWrite[path[0]][path[1]]);
toWrite[path[0]][path[1]] = actual[path[0]][path[1]];
yaml.updateIfChanged(data.joinPath(ref.filename), ref.key, toWrite[ns][key]);
toWrite[ns][key] = actual[ns][key];
}
}
}
// Write devices/groups to separate file if required.
const writeDevicesOrGroups = (type: 'devices' | 'groups'): void => {
if (typeof actual[type] === 'string' || (Array.isArray(actual[type]) && actual[type].length > 0)) {
const writeDevicesOrGroups = (type: "devices" | "groups"): void => {
if (typeof actual[type] === "string" || (Array.isArray(actual[type]) && actual[type].length > 0)) {
const fileToWrite = Array.isArray(actual[type]) ? actual[type][0] : actual[type];
const content = objectAssignDeep({}, settings[type]);
@@ -191,8 +224,11 @@ function write(): void {
}
};
writeDevicesOrGroups('devices');
writeDevicesOrGroups('groups');
writeDevicesOrGroups("devices");
writeDevicesOrGroups("groups");
applyEnvironmentVariables(toWrite);
yaml.writeIfChanged(CONFIG_FILE_PATH, toWrite);
_settings = read();
@@ -212,36 +248,21 @@ export function validate(): string[] {
}
if (!ajvSetting(_settings)) {
// When `ajvSetting()` return false it always has `errors`.
// biome-ignore lint/style/noNonNullAssertion: When `ajvSetting()` return false it always has `errors`
return ajvSetting.errors!.map((v) => `${v.instancePath.substring(1)} ${v.message}`);
}
const errors = [];
if (
_settings.advanced &&
_settings.advanced.network_key &&
typeof _settings.advanced.network_key === 'string' &&
_settings.advanced.network_key !== 'GENERATE'
) {
if (_settings.advanced?.network_key && typeof _settings.advanced.network_key === "string" && _settings.advanced.network_key !== "GENERATE") {
errors.push(`advanced.network_key: should be array or 'GENERATE' (is '${_settings.advanced.network_key}')`);
}
if (
_settings.advanced &&
_settings.advanced.pan_id &&
typeof _settings.advanced.pan_id === 'string' &&
_settings.advanced.pan_id !== 'GENERATE'
) {
if (_settings.advanced?.pan_id && typeof _settings.advanced.pan_id === "string" && _settings.advanced.pan_id !== "GENERATE") {
errors.push(`advanced.pan_id: should be number or 'GENERATE' (is '${_settings.advanced.pan_id}')`);
}
if (
_settings.advanced &&
_settings.advanced.ext_pan_id &&
typeof _settings.advanced.ext_pan_id === 'string' &&
_settings.advanced.ext_pan_id !== 'GENERATE'
) {
if (_settings.advanced?.ext_pan_id && typeof _settings.advanced.ext_pan_id === "string" && _settings.advanced.ext_pan_id !== "GENERATE") {
errors.push(`advanced.ext_pan_id: should be array or 'GENERATE' (is '${_settings.advanced.ext_pan_id}')`);
}
@@ -251,7 +272,7 @@ export function validate(): string[] {
if (names.includes(e.friendly_name)) errors.push(`Duplicate friendly_name '${e.friendly_name}' found`);
errors.push(...utils.validateFriendlyName(e.friendly_name));
names.push(e.friendly_name);
if ('icon' in e && e.icon && !e.icon.startsWith('http://') && !e.icon.startsWith('https://') && !e.icon.startsWith('device_icons/')) {
if ("icon" in e && e.icon && !e.icon.startsWith("http://") && !e.icon.startsWith("https://") && !e.icon.startsWith("device_icons/")) {
errors.push(`Device icon of '${e.friendly_name}' should start with 'device_icons/', got '${e.icon}'`);
}
if (e.qos != null && ![0, 1, 2].includes(e.qos)) {
@@ -261,13 +282,18 @@ export function validate(): string[] {
const settingsWithDefaults = get();
Object.values(settingsWithDefaults.devices).forEach((d) => check(d));
Object.values(settingsWithDefaults.groups).forEach((g) => check(g));
for (const key in settingsWithDefaults.devices) {
check(settingsWithDefaults.devices[key]);
}
for (const key in settingsWithDefaults.groups) {
check(settingsWithDefaults.groups[key]);
}
if (settingsWithDefaults.mqtt.version !== 5) {
for (const device of Object.values(settingsWithDefaults.devices)) {
if (device.retention) {
errors.push('MQTT retention requires protocol version 5');
errors.push("MQTT retention requires protocol version 5");
}
}
}
@@ -277,11 +303,10 @@ export function validate(): string[] {
function read(): Partial<Settings> {
const s = yaml.read(CONFIG_FILE_PATH) as Partial<Settings>;
applyEnvironmentVariables(s);
// Read !secret MQTT username and password if set
const interpretValue = <T>(value: T): T => {
if (typeof value === 'string') {
if (typeof value === "string") {
const ref = parseValueRef(value);
if (ref) {
return yaml.read(data.joinPath(ref.filename))[ref.key];
@@ -311,8 +336,8 @@ function read(): Partial<Settings> {
}
// Read devices/groups configuration from separate file if specified.
const readDevicesOrGroups = (type: 'devices' | 'groups'): void => {
if (typeof s[type] === 'string' || (Array.isArray(s[type]) && Array(s[type]).length > 0)) {
const readDevicesOrGroups = (type: "devices" | "groups"): void => {
if (typeof s[type] === "string" || (Array.isArray(s[type]) && Array(s[type]).length > 0)) {
const files: string[] = Array.isArray(s[type]) ? s[type] : [s[type]];
s[type] = {};
for (const file of files) {
@@ -323,8 +348,8 @@ function read(): Partial<Settings> {
}
};
readDevicesOrGroups('devices');
readDevicesOrGroups('groups');
readDevicesOrGroups("devices");
readDevicesOrGroups("groups");
return s;
}
@@ -332,10 +357,10 @@ function read(): Partial<Settings> {
function applyEnvironmentVariables(settings: Partial<Settings>): void {
const iterate = (obj: KeyValue, path: string[]): void => {
for (const key in obj) {
if (key !== 'type') {
if (key !== 'properties' && obj[key]) {
const type = (obj[key].type || 'object').toString();
const envPart = path.reduce((acc, val) => `${acc}${val}_`, '');
if (key !== "type") {
if (key !== "properties" && obj[key]) {
const type = (obj[key].type || "object").toString();
const envPart = path.reduce((acc, val) => `${acc}${val}_`, "");
const envVariableName = `ZIGBEE2MQTT_CONFIG_${envPart}${key}`.toUpperCase();
const envVariable = process.env[envVariableName];
@@ -347,32 +372,32 @@ function applyEnvironmentVariables(settings: Partial<Settings>): void {
return acc[val];
}, settings);
if (type.indexOf('object') >= 0 || type.indexOf('array') >= 0) {
if (type.indexOf("object") >= 0 || type.indexOf("array") >= 0) {
try {
setting[key as keyof Settings] = JSON.parse(envVariable);
} catch {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// biome-ignore lint/suspicious/noExplicitAny: auto-parsing
setting[key as keyof Settings] = envVariable as any;
}
} else if (type.indexOf('number') >= 0) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} else if (type.indexOf("number") >= 0) {
// biome-ignore lint/suspicious/noExplicitAny: auto-parsing
setting[key as keyof Settings] = ((envVariable as unknown as number) * 1) as any;
} else if (type.indexOf('boolean') >= 0) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setting[key as keyof Settings] = (envVariable.toLowerCase() === 'true') as any;
} else if (type.indexOf("boolean") >= 0) {
// biome-ignore lint/suspicious/noExplicitAny: auto-parsing
setting[key as keyof Settings] = (envVariable.toLowerCase() === "true") as any;
} else {
if (type.indexOf('string') >= 0) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (type.indexOf("string") >= 0) {
// biome-ignore lint/suspicious/noExplicitAny: auto-parsing
setting[key as keyof Settings] = envVariable as any;
}
}
}
}
if (typeof obj[key] === 'object' && obj[key]) {
if (typeof obj[key] === "object" && obj[key]) {
const newPath = [...path];
if (key !== 'properties' && key !== 'oneOf' && !Number.isInteger(Number(key))) {
if (key !== "properties" && key !== "oneOf" && !Number.isInteger(Number(key))) {
newPath.push(key);
}
@@ -403,11 +428,12 @@ export function get(): Settings {
loadSettingsWithDefaults();
}
// biome-ignore lint/style/noNonNullAssertion: just loaded
return _settingsWithDefaults!;
}
export function set(path: string[], value: string | number | boolean | KeyValue): void {
/* eslint-disable-next-line */
// biome-ignore lint/suspicious/noExplicitAny: auto-parsing
let settings: any = getPersistedSettings();
for (let i = 0; i < path.length; i++) {
@@ -426,7 +452,7 @@ export function set(path: string[], value: string | number | boolean | KeyValue)
write();
}
export function apply(settings: Record<string, unknown>, throwOnError: boolean = true): boolean {
export function apply(settings: Record<string, unknown>, throwOnError = true): boolean {
getPersistedSettings(); // Ensure _settings is initialized.
// @ts-expect-error noMutate not typed properly
const newSettings = objectAssignDeep.noMutate(_settings, settings);
@@ -435,7 +461,7 @@ export function apply(settings: Record<string, unknown>, throwOnError: boolean =
ajvSetting(newSettings);
if (throwOnError) {
const errors = ajvSetting.errors && ajvSetting.errors.filter((e) => e.keyword !== 'required');
const errors = ajvSetting.errors?.filter((e) => e.keyword !== "required");
if (errors?.length) {
const error = errors[0];
@@ -448,7 +474,7 @@ export function apply(settings: Record<string, unknown>, throwOnError: boolean =
ajvRestartRequired(settings);
const restartRequired = Boolean(ajvRestartRequired.errors && !!ajvRestartRequired.errors.find((e) => e.keyword === 'requiresRestart'));
const restartRequired = Boolean(ajvRestartRequired.errors && !!ajvRestartRequired.errors.find((e) => e.keyword === "requiresRestart"));
return restartRequired;
}
@@ -506,9 +532,9 @@ function getDeviceThrowIfNotExists(IDorName: string): DeviceOptionsWithId {
return device;
}
export function addDevice(ID: string): DeviceOptionsWithId {
if (getDevice(ID)) {
throw new Error(`Device '${ID}' already exists`);
export function addDevice(id: string): DeviceOptionsWithId {
if (getDevice(id)) {
throw new Error(`Device '${id}' already exists`);
}
const settings = getPersistedSettings();
@@ -517,19 +543,20 @@ export function addDevice(ID: string): DeviceOptionsWithId {
settings.devices = {};
}
settings.devices[ID] = {friendly_name: ID};
settings.devices[id] = {friendly_name: id};
write();
return getDevice(ID)!; // valid from creation above
// biome-ignore lint/style/noNonNullAssertion: valid from creation above
return getDevice(id)!;
}
export function blockDevice(ID: string): void {
export function blockDevice(id: string): void {
const settings = getPersistedSettings();
if (!settings.blocklist) {
settings.blocklist = [];
}
settings.blocklist.push(ID);
settings.blocklist.push(id);
write();
}
@@ -540,7 +567,7 @@ export function removeDevice(IDorName: string): void {
write();
}
export function addGroup(name: string, ID?: string): GroupOptions {
export function addGroup(name: string, id?: string): GroupOptions {
utils.validateFriendlyName(name, true);
if (getGroup(name) || getDevice(name)) {
@@ -552,32 +579,34 @@ export function addGroup(name: string, ID?: string): GroupOptions {
settings.groups = {};
}
if (ID == undefined) {
if (id == null) {
// look for free ID
ID = '1';
id = "1";
while (settings.groups[ID]) {
ID = (Number.parseInt(ID) + 1).toString();
while (settings.groups[id]) {
id = (Number.parseInt(id) + 1).toString();
}
} else {
// ensure provided ID is not in use
ID = ID.toString();
id = id.toString();
if (settings.groups[ID]) {
throw new Error(`Group ID '${ID}' is already in use`);
if (settings.groups[id]) {
throw new Error(`Group ID '${id}' is already in use`);
}
}
settings.groups[ID] = {friendly_name: name};
settings.groups[id] = {friendly_name: name};
write();
return getGroup(ID)!; // valid from creation above
// biome-ignore lint/style/noNonNullAssertion: valid from creation above
return getGroup(id)!;
}
export function removeGroup(IDorName: string | number): void {
const groupID = getGroupThrowIfNotExists(IDorName.toString()).ID!;
const groupID = getGroupThrowIfNotExists(IDorName.toString()).ID;
const settings = getPersistedSettings();
// biome-ignore lint/style/noNonNullAssertion: throwing above if not valid
delete settings.groups![groupID];
write();
}
@@ -590,15 +619,19 @@ export function changeEntityOptions(IDorName: string, newOptions: KeyValue): boo
const device = getDevice(IDorName);
if (device) {
objectAssignDeep(settings.devices![device.ID], newOptions);
utils.removeNullPropertiesFromObject(settings.devices![device.ID], NULLABLE_SETTINGS);
// biome-ignore lint/style/noNonNullAssertion: valid from above
const settingsDevice = settings.devices![device.ID];
objectAssignDeep(settingsDevice, newOptions);
utils.removeNullPropertiesFromObject(settingsDevice, NULLABLE_SETTINGS);
validator = ajvRestartRequiredDeviceOptions;
} else {
const group = getGroup(IDorName);
if (group) {
objectAssignDeep(settings.groups![group.ID], newOptions);
utils.removeNullPropertiesFromObject(settings.groups![group.ID], NULLABLE_SETTINGS);
// biome-ignore lint/style/noNonNullAssertion: valid from above
const settingsGroup = settings.groups![group.ID];
objectAssignDeep(settingsGroup, newOptions);
utils.removeNullPropertiesFromObject(settingsGroup, NULLABLE_SETTINGS);
validator = ajvRestartRequiredGroupOptions;
} else {
throw new Error(`Device or group '${IDorName}' does not exist`);
@@ -608,7 +641,7 @@ export function changeEntityOptions(IDorName: string, newOptions: KeyValue): boo
write();
validator(newOptions);
const restartRequired = Boolean(validator.errors && !!validator.errors.find((e) => e.keyword === 'requiresRestart'));
const restartRequired = Boolean(validator.errors && !!validator.errors.find((e) => e.keyword === "requiresRestart"));
return restartRequired;
}
@@ -623,11 +656,13 @@ export function changeFriendlyName(IDorName: string, newName: string): void {
const device = getDevice(IDorName);
if (device) {
// biome-ignore lint/style/noNonNullAssertion: valid from above
settings.devices![device.ID].friendly_name = newName;
} else {
const group = getGroup(IDorName);
if (group) {
// biome-ignore lint/style/noNonNullAssertion: valid from above
settings.groups![group.ID].friendly_name = newName;
} else {
throw new Error(`Device or group '${IDorName}' does not exist`);
+127 -124
View File
@@ -1,8 +1,8 @@
import {copyFileSync, writeFileSync} from 'node:fs';
import {copyFileSync, writeFileSync} from "node:fs";
import data from './data';
import * as settings from './settings';
import utils from './utils';
import data from "./data";
import * as settings from "./settings";
import utils from "./utils";
interface SettingsMigration {
path: string[];
@@ -10,7 +10,7 @@ interface SettingsMigration {
noteIf?: (previousValue: unknown) => boolean;
}
interface SettingsAdd extends Omit<SettingsMigration, 'noteIf'> {
interface SettingsAdd extends Omit<SettingsMigration, "noteIf"> {
value: unknown;
}
@@ -25,16 +25,16 @@ interface SettingsTransfer extends SettingsMigration {
newPath: string[];
}
interface SettingsCustomHandler extends Omit<SettingsMigration, 'path'> {
interface SettingsCustomHandler extends Omit<SettingsMigration, "path"> {
execute: (currentSettings: Partial<Settings>) => [validPath: boolean, previousValue: unknown, changed: boolean];
}
const SUPPORTED_VERSIONS: Settings['version'][] = [undefined, 2, 3, settings.CURRENT_VERSION];
const SUPPORTED_VERSIONS: Settings["version"][] = [undefined, 2, 3, settings.CURRENT_VERSION];
function backupSettings(version: number): void {
const filePath = data.joinPath('configuration.yaml');
const filePath = data.joinPath("configuration.yaml");
copyFileSync(filePath, filePath.replace('.yaml', `_backup_v${version}.yaml`));
copyFileSync(filePath, filePath.replace(".yaml", `_backup_v${version}.yaml`));
}
/**
@@ -46,8 +46,8 @@ function backupSettings(version: number): void {
* @param createPathIfNotExist
* @returns Returns true if value was set, false if not.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function setValue(currentSettings: any, path: string[], value: unknown, createPathIfNotExist: boolean = false): boolean {
// biome-ignore lint/suspicious/noExplicitAny: auto-parsing
function setValue(currentSettings: any, path: string[], value: unknown, createPathIfNotExist = false): boolean {
for (let i = 0; i < path.length; i++) {
const key = path[i];
@@ -82,7 +82,7 @@ function setValue(currentSettings: any, path: string[], value: unknown, createPa
* - true if path was valid
* - the value at path
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// biome-ignore lint/suspicious/noExplicitAny: auto-parsing
function getValue(currentSettings: any, path: string[]): [validPath: boolean, value: unknown] {
for (let i = 0; i < path.length; i++) {
const key = path[i];
@@ -90,14 +90,14 @@ function getValue(currentSettings: any, path: string[]): [validPath: boolean, va
if (i === path.length - 1) {
return [value !== undefined, value];
} else {
if (!value) {
// invalid path
break;
}
currentSettings = value;
}
if (!value) {
// invalid path
break;
}
currentSettings = value;
}
return [false, undefined];
@@ -122,7 +122,7 @@ function addValue(currentSettings: Partial<Settings>, addition: SettingsAdd): vo
function removeValue(currentSettings: Partial<Settings>, removal: SettingsRemove): [validPath: boolean, previousValue: unknown] {
const [validPath, previousValue] = getValue(currentSettings, removal.path);
if (validPath && previousValue != undefined) {
if (validPath && previousValue != null) {
setValue(currentSettings, removal.path, undefined);
}
@@ -137,7 +137,7 @@ function removeValue(currentSettings: Partial<Settings>, removal: SettingsRemove
*/
function changeValue(currentSettings: Partial<Settings>, change: SettingsChange): [validPath: boolean, previousValue: unknown, changed: boolean] {
const [validPath, previousValue] = getValue(currentSettings, change.path);
let changed: boolean = false;
let changed = false;
if (validPath && previousValue !== change.newValue) {
if (!change.previousValueAnyOf || change.previousValueAnyOf.includes(previousValue)) {
@@ -166,10 +166,10 @@ function transferValue(
): [validPath: boolean, previousValue: unknown, transfered: boolean] {
const [validPath, previousValue] = getValue(currentSettings, transfer.path);
const [destValidPath, destValue] = getValue(currentSettings, transfer.newPath);
const transfered = validPath && previousValue != undefined && (!destValidPath || destValue == undefined || Array.isArray(destValue));
const transfered = validPath && previousValue != null && (!destValidPath || destValue == null || Array.isArray(destValue));
// no point in set if already undefined
if (validPath && previousValue != undefined) {
if (validPath && previousValue != null) {
setValue(currentSettings, transfer.path, undefined);
}
@@ -185,7 +185,7 @@ function transferValue(
}
const noteIfWasTrue = (previousValue: unknown): boolean => previousValue === true;
const noteIfWasDefined = (previousValue: unknown): boolean => previousValue != undefined;
const noteIfWasDefined = (previousValue: unknown): boolean => previousValue != null;
const noteIfWasNonEmptyArray = (previousValue: unknown): boolean => Array.isArray(previousValue) && previousValue.length > 0;
function migrateToTwo(
@@ -198,82 +198,82 @@ function migrateToTwo(
): void {
transfers.push(
{
path: ['advanced', 'homeassistant_discovery_topic'],
note: `HA discovery_topic was moved from advanced.homeassistant_discovery_topic to homeassistant.discovery_topic.`,
path: ["advanced", "homeassistant_discovery_topic"],
note: "HA discovery_topic was moved from advanced.homeassistant_discovery_topic to homeassistant.discovery_topic.",
noteIf: noteIfWasDefined,
newPath: ['homeassistant', 'discovery_topic'],
newPath: ["homeassistant", "discovery_topic"],
},
{
path: ['advanced', 'homeassistant_status_topic'],
note: `HA status_topic was moved from advanced.homeassistant_status_topic to homeassistant.status_topic.`,
path: ["advanced", "homeassistant_status_topic"],
note: "HA status_topic was moved from advanced.homeassistant_status_topic to homeassistant.status_topic.",
noteIf: noteIfWasDefined,
newPath: ['homeassistant', 'status_topic'],
newPath: ["homeassistant", "status_topic"],
},
{
path: ['advanced', 'baudrate'],
note: `Baudrate was moved from advanced.baudrate to serial.baudrate.`,
path: ["advanced", "baudrate"],
note: "Baudrate was moved from advanced.baudrate to serial.baudrate.",
noteIf: noteIfWasDefined,
newPath: ['serial', 'baudrate'],
newPath: ["serial", "baudrate"],
},
{
path: ['advanced', 'rtscts'],
note: `RTSCTS was moved from advanced.rtscts to serial.rtscts.`,
path: ["advanced", "rtscts"],
note: "RTSCTS was moved from advanced.rtscts to serial.rtscts.",
noteIf: noteIfWasDefined,
newPath: ['serial', 'rtscts'],
newPath: ["serial", "rtscts"],
},
{
path: ['experimental', 'transmit_power'],
note: `Transmit power was moved from experimental.transmit_power to advanced.transmit_power.`,
path: ["experimental", "transmit_power"],
note: "Transmit power was moved from experimental.transmit_power to advanced.transmit_power.",
noteIf: noteIfWasDefined,
newPath: ['advanced', 'transmit_power'],
newPath: ["advanced", "transmit_power"],
},
{
path: ['experimental', 'output'],
note: `Output was moved from experimental.output to advanced.output.`,
path: ["experimental", "output"],
note: "Output was moved from experimental.output to advanced.output.",
noteIf: noteIfWasDefined,
newPath: ['advanced', 'output'],
newPath: ["advanced", "output"],
},
{
path: ['ban'],
note: `ban was renamed to passlist.`,
path: ["ban"],
note: "ban was renamed to passlist.",
noteIf: noteIfWasDefined,
newPath: ['blocklist'],
newPath: ["blocklist"],
},
{
path: ['whitelist'],
note: `whitelist was renamed to passlist.`,
path: ["whitelist"],
note: "whitelist was renamed to passlist.",
noteIf: noteIfWasDefined,
newPath: ['passlist'],
newPath: ["passlist"],
},
);
changes.push({
path: ['advanced', 'log_level'],
path: ["advanced", "log_level"],
note: `Log level 'warn' has been renamed to 'warning'.`,
noteIf: (previousValue): boolean => previousValue === 'warn',
previousValueAnyOf: ['warn'],
newValue: 'warning',
noteIf: (previousValue): boolean => previousValue === "warn",
previousValueAnyOf: ["warn"],
newValue: "warning",
});
additions.push({
path: ['version'],
note: `Migrated settings to version 2`,
path: ["version"],
note: "Migrated settings to version 2",
value: 2,
});
const haLegacyTriggers: SettingsRemove = {
path: ['homeassistant', 'legacy_triggers'],
note: `Action and click sensors have been removed (homeassistant.legacy_triggers setting). This means all sensor.*_action and sensor.*_click entities are removed. Use the MQTT device trigger instead.`,
path: ["homeassistant", "legacy_triggers"],
note: "Action and click sensors have been removed (homeassistant.legacy_triggers setting). This means all sensor.*_action and sensor.*_click entities are removed. Use the MQTT device trigger instead.",
noteIf: noteIfWasTrue,
};
const haLegacyEntityAttrs: SettingsRemove = {
path: ['homeassistant', 'legacy_entity_attributes'],
note: `Entity attributes (homeassistant.legacy_entity_attributes setting) has been removed. This means that entities discovered by Zigbee2MQTT will no longer have entity attributes (Home Assistant entity attributes are accessed via e.g. states.binary_sensor.my_sensor.attributes).`,
path: ["homeassistant", "legacy_entity_attributes"],
note: "Entity attributes (homeassistant.legacy_entity_attributes setting) has been removed. This means that entities discovered by Zigbee2MQTT will no longer have entity attributes (Home Assistant entity attributes are accessed via e.g. states.binary_sensor.my_sensor.attributes).",
noteIf: noteIfWasTrue,
};
const otaIkeaUseTestUrl: SettingsRemove = {
path: ['ota', 'ikea_ota_use_test_url'],
note: `Due to the OTA rework, the ota.ikea_ota_use_test_url option has been removed.`,
path: ["ota", "ikea_ota_use_test_url"],
note: "Due to the OTA rework, the ota.ikea_ota_use_test_url option has been removed.",
noteIf: noteIfWasTrue,
};
@@ -281,94 +281,94 @@ function migrateToTwo(
haLegacyTriggers,
haLegacyEntityAttrs,
{
path: ['advanced', 'homeassistant_legacy_triggers'],
path: ["advanced", "homeassistant_legacy_triggers"],
note: haLegacyTriggers.note,
noteIf: haLegacyTriggers.noteIf,
},
{
path: ['advanced', 'homeassistant_legacy_entity_attributes'],
path: ["advanced", "homeassistant_legacy_entity_attributes"],
note: haLegacyEntityAttrs.note,
noteIf: haLegacyEntityAttrs.noteIf,
},
{
path: ['permit_join'],
note: `The permit_join setting has been removed, use the frontend or MQTT to permit joining.`,
path: ["permit_join"],
note: "The permit_join setting has been removed, use the frontend or MQTT to permit joining.",
noteIf: noteIfWasTrue,
},
otaIkeaUseTestUrl,
{
path: ['advanced', 'ikea_ota_use_test_url'],
path: ["advanced", "ikea_ota_use_test_url"],
note: otaIkeaUseTestUrl.note,
noteIf: otaIkeaUseTestUrl.noteIf,
},
{
path: ['advanced', 'legacy_api'],
note: `The MQTT legacy API has been removed (advanced.legacy_api setting). See link below for affected topics.`,
path: ["advanced", "legacy_api"],
note: "The MQTT legacy API has been removed (advanced.legacy_api setting). See link below for affected topics.",
noteIf: noteIfWasTrue,
},
{
path: ['advanced', 'legacy_availability_payload'],
note: `Due to the removal of advanced.legacy_availability_payload, zigbee2mqtt/bridge/state will now always be a JSON object ({"state":"online"} or {"state":"offline"})`,
path: ["advanced", "legacy_availability_payload"],
note: 'Due to the removal of advanced.legacy_availability_payload, zigbee2mqtt/bridge/state will now always be a JSON object ({"state":"online"} or {"state":"offline"})',
noteIf: noteIfWasTrue,
},
{
path: ['advanced', 'soft_reset_timeout'],
note: `Removed deprecated: Soft reset feature (advanced.soft_reset_timeout setting)`,
path: ["advanced", "soft_reset_timeout"],
note: "Removed deprecated: Soft reset feature (advanced.soft_reset_timeout setting)",
noteIf: noteIfWasDefined,
},
{
path: ['advanced', 'report'],
note: `Removed deprecated: Report feature (advanced.report setting)`,
path: ["advanced", "report"],
note: "Removed deprecated: Report feature (advanced.report setting)",
noteIf: noteIfWasTrue,
},
{
path: ['advanced', 'availability_timeout'],
note: `Removed deprecated: advanced.availability_timeout availability settings`,
path: ["advanced", "availability_timeout"],
note: "Removed deprecated: advanced.availability_timeout availability settings",
noteIf: noteIfWasDefined,
},
{
path: ['advanced', 'availability_blocklist'],
note: `Removed deprecated: advanced.availability_blocklist availability settings`,
path: ["advanced", "availability_blocklist"],
note: "Removed deprecated: advanced.availability_blocklist availability settings",
noteIf: noteIfWasNonEmptyArray,
},
{
path: ['advanced', 'availability_passlist'],
note: `Removed deprecated: advanced.availability_passlist availability settings`,
path: ["advanced", "availability_passlist"],
note: "Removed deprecated: advanced.availability_passlist availability settings",
noteIf: noteIfWasNonEmptyArray,
},
{
path: ['advanced', 'availability_blacklist'],
note: `Removed deprecated: advanced.availability_blacklist availability settings`,
path: ["advanced", "availability_blacklist"],
note: "Removed deprecated: advanced.availability_blacklist availability settings",
noteIf: noteIfWasNonEmptyArray,
},
{
path: ['advanced', 'availability_whitelist'],
note: `Removed deprecated: advanced.availability_whitelist availability settings`,
path: ["advanced", "availability_whitelist"],
note: "Removed deprecated: advanced.availability_whitelist availability settings",
noteIf: noteIfWasNonEmptyArray,
},
{
path: ['device_options', 'legacy'],
note: `Removed everything that was enabled through device_options.legacy. See link below for affected devices.`,
path: ["device_options", "legacy"],
note: "Removed everything that was enabled through device_options.legacy. See link below for affected devices.",
noteIf: noteIfWasTrue,
},
{
path: ['experimental'],
note: `The entire experimental section was removed.`,
path: ["experimental"],
note: "The entire experimental section was removed.",
noteIf: noteIfWasDefined,
},
{
path: ['external_converters'],
note: `External converters are now automatically loaded from the 'data/external_converters' directory without requiring settings to be set. Make sure your external converters are still needed (might be supported out-of-the-box now), and if so, move them to that directory.`,
path: ["external_converters"],
note: "External converters are now automatically loaded from the 'data/external_converters' directory without requiring settings to be set. Make sure your external converters are still needed (might be supported out-of-the-box now), and if so, move them to that directory.",
noteIf: noteIfWasNonEmptyArray,
},
);
// note only once
const noteEntityOptionsRetrieveState = `Retrieve state option ((devices|groups).xyz.retrieve_state setting)`;
const noteEntityOptionsRetrieveState = "Retrieve state option ((devices|groups).xyz.retrieve_state setting)";
for (const deviceKey in currentSettings.devices) {
removals.push({
path: ['devices', deviceKey, 'retrieve_state'],
path: ["devices", deviceKey, "retrieve_state"],
note: noteEntityOptionsRetrieveState,
noteIf: noteIfWasTrue,
});
@@ -376,13 +376,13 @@ function migrateToTwo(
for (const groupKey in currentSettings.groups) {
removals.push({
path: ['groups', groupKey, 'retrieve_state'],
path: ["groups", groupKey, "retrieve_state"],
note: noteEntityOptionsRetrieveState,
noteIf: noteIfWasTrue,
});
removals.push({
path: ['groups', groupKey, 'devices'],
note: `Removed configuring group members through configuration.yaml (groups.xyz.devices setting). This will not impact current group members; however, you will no longer be able to add or remove devices from a group through the configuration.yaml.`,
path: ["groups", groupKey, "devices"],
note: "Removed configuring group members through configuration.yaml (groups.xyz.devices setting). This will not impact current group members; however, you will no longer be able to add or remove devices from a group through the configuration.yaml.",
noteIf: noteIfWasDefined,
});
}
@@ -391,7 +391,7 @@ function migrateToTwo(
}
function migrateToThree(
currentSettings: Partial<Settings>,
_currentSettings: Partial<Settings>,
transfers: SettingsTransfer[],
changes: SettingsChange[],
additions: SettingsAdd[],
@@ -400,18 +400,18 @@ function migrateToThree(
): void {
transfers.push();
changes.push({
path: ['version'],
note: `Migrated settings to version 3`,
path: ["version"],
note: "Migrated settings to version 3",
newValue: 3,
});
additions.push();
removals.push();
const changeToObject = (currentSettings: Partial<Settings>, path: string[]): ReturnType<SettingsCustomHandler['execute']> => {
const changeToObject = (currentSettings: Partial<Settings>, path: string[]): ReturnType<SettingsCustomHandler["execute"]> => {
const [validPath, previousValue] = getValue(currentSettings, path);
if (validPath) {
if (typeof previousValue === 'boolean') {
if (typeof previousValue === "boolean") {
setValue(currentSettings, path, {enabled: previousValue});
} else {
setValue(currentSettings, path, {enabled: true, ...(previousValue as object)});
@@ -425,23 +425,23 @@ function migrateToThree(
{
note: `Property 'homeassistant' is now always an object.`,
noteIf: () => true,
execute: (currentSettings) => changeToObject(currentSettings, ['homeassistant']),
execute: (currentSettings) => changeToObject(currentSettings, ["homeassistant"]),
},
{
note: `Property 'frontend' is now always an object.`,
noteIf: () => true,
execute: (currentSettings) => changeToObject(currentSettings, ['frontend']),
execute: (currentSettings) => changeToObject(currentSettings, ["frontend"]),
},
{
note: `Property 'availability' is now always an object.`,
noteIf: () => true,
execute: (currentSettings) => changeToObject(currentSettings, ['availability']),
execute: (currentSettings) => changeToObject(currentSettings, ["availability"]),
},
);
}
function migrateToFour(
currentSettings: Partial<Settings>,
_currentSettings: Partial<Settings>,
transfers: SettingsTransfer[],
changes: SettingsChange[],
additions: SettingsAdd[],
@@ -450,15 +450,15 @@ function migrateToFour(
): void {
transfers.push();
changes.push({
path: ['version'],
note: `Migrated settings to version 4`,
path: ["version"],
note: "Migrated settings to version 4",
newValue: 4,
});
additions.push();
removals.push();
const saveBase64DeviceIconsAsImage = (currentSettings: Partial<Settings>): ReturnType<SettingsCustomHandler['execute']> => {
const [validPath, previousValue] = getValue(currentSettings, ['devices']);
const saveBase64DeviceIconsAsImage = (currentSettings: Partial<Settings>): ReturnType<SettingsCustomHandler["execute"]> => {
const [validPath, previousValue] = getValue(currentSettings, ["devices"]);
let changed = false;
if (validPath) {
@@ -475,7 +475,7 @@ function migrateToFour(
};
customHandlers.push({
note: `Device icons are now saved as images.`,
note: "Device icons are now saved as images.",
noteIf: () => true,
execute: (currentSettings) => saveBase64DeviceIconsAsImage(currentSettings),
});
@@ -491,22 +491,26 @@ function migrateToFour(
* Should allow the most flexibility whenever combination of migrations is necessary (e.g. Transfer + Change)
*/
export function migrateIfNecessary(): void {
let currentSettings = settings.getPersistedSettings();
const currentSettings = settings.getPersistedSettings();
if (!SUPPORTED_VERSIONS.includes(currentSettings.version)) {
throw new Error(
`Your configuration.yaml has an unsupported version ${currentSettings.version}, expected one of ${SUPPORTED_VERSIONS.map((v) => String(v)).join(',')}.`,
`Your configuration.yaml has an unsupported version ${currentSettings.version}, expected one of ${SUPPORTED_VERSIONS.map((v) => String(v)).join(",")}.`,
);
}
/* v8 ignore next */
const finalVersion = process.env.VITEST_WORKER_ID ? settings.testing.CURRENT_VERSION : settings.CURRENT_VERSION;
// when same version as current, nothing left to do
if (currentSettings.version === finalVersion) {
// when same version as current, nothing to do
return;
}
while (currentSettings.version !== finalVersion) {
let migrationNotesFileName: string | undefined;
// don't duplicate outputs
const migrationNotes: Set<string> = new Set();
const migrationNotes = new Set<string>();
const transfers: SettingsTransfer[] = [];
const changes: SettingsChange[] = [];
const additions: SettingsAdd[] = [];
@@ -516,17 +520,17 @@ export function migrateIfNecessary(): void {
backupSettings(currentSettings.version || 1);
// each version should only bump to the next version so as to gradually migrate if necessary
if (currentSettings.version == undefined) {
if (currentSettings.version == null) {
// migrating from 1 (`version` did not exist) to 2
migrationNotesFileName = 'migration-1-to-2.log';
migrationNotesFileName = "migration-1-to-2.log";
migrateToTwo(currentSettings, transfers, changes, additions, removals, customHandlers);
} else if (currentSettings.version === 2) {
migrationNotesFileName = 'migration-2-to-3.log';
migrationNotesFileName = "migration-2-to-3.log";
migrateToThree(currentSettings, transfers, changes, additions, removals, customHandlers);
} else if (currentSettings.version === 3) {
migrationNotesFileName = 'migration-3-to-4.log';
migrationNotesFileName = "migration-3-to-4.log";
migrateToFour(currentSettings, transfers, changes, additions, removals, customHandlers);
}
@@ -535,7 +539,7 @@ export function migrateIfNecessary(): void {
const [validPath, previousValue, transfered] = transferValue(currentSettings, transfer);
if (validPath && (!transfer.noteIf || transfer.noteIf(previousValue))) {
migrationNotes.add(`[${transfered ? 'TRANSFER' : 'REMOVAL'}] ${transfer.note}`);
migrationNotes.add(`[${transfered ? "TRANSFER" : "REMOVAL"}] ${transfer.note}`);
}
}
@@ -570,17 +574,16 @@ export function migrateIfNecessary(): void {
}
if (migrationNotesFileName && migrationNotes.size > 0) {
migrationNotes.add(`For more details, see https://github.com/Koenkk/zigbee2mqtt/discussions/24198`);
migrationNotes.add("For more details, see https://github.com/Koenkk/zigbee2mqtt/discussions/24198");
const migrationNotesFilePath = data.joinPath(migrationNotesFileName);
writeFileSync(migrationNotesFilePath, Array.from(migrationNotes).join(`\r\n\r\n`), 'utf8');
writeFileSync(migrationNotesFilePath, Array.from(migrationNotes).join("\r\n\r\n"), "utf8");
console.log(`Migration notes written in ${migrationNotesFilePath}`);
}
// don't throw to allow stepping through versions (validates against current schema)
settings.apply(currentSettings as unknown as Record<string, unknown>, false);
settings.reRead();
currentSettings = settings.getPersistedSettings();
}
// don't throw, onboarding will validate at end of process
settings.apply(currentSettings as unknown as Record<string, unknown>, false);
settings.reRead();
}
+92 -104
View File
@@ -1,21 +1,23 @@
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponse, Zigbee2MQTTResponseEndpoints, Zigbee2MQTTScene} from 'lib/types/api';
import type * as zhc from 'zigbee-herdsman-converters';
import type * as zhc from "zigbee-herdsman-converters";
import assert from 'node:assert';
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponse, Zigbee2MQTTResponseEndpoints, Zigbee2MQTTScene} from "../types/api";
import equals from 'fast-deep-equal/es6';
import humanizeDuration from 'humanize-duration';
import assert from "node:assert";
import {exec} from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import data from './data';
import equals from "fast-deep-equal/es6";
import humanizeDuration from "humanize-duration";
const BASE64_IMAGE_REGEX = new RegExp(`data:image/(?<extension>.+);base64,(?<data>.+)`);
import data from "./data";
const BASE64_IMAGE_REGEX = /data:image\/(?<extension>.+);base64,(?<data>.+)/;
function pad(num: number): string {
const norm = Math.floor(Math.abs(num));
return (norm < 10 ? '0' : '') + norm;
return (norm < 10 ? "0" : "") + norm;
}
// construct a local ISO8601 string (instead of UTC-based)
@@ -24,82 +26,69 @@ function pad(num: number): string {
// - ISO8601 (local) = 2019-03-01T16:32:45.941+0100 (for timezone GMT+1)
function toLocalISOString(date: Date): string {
const tzOffset = -date.getTimezoneOffset();
const plusOrMinus = tzOffset >= 0 ? '+' : '-';
const plusOrMinus = tzOffset >= 0 ? "+" : "-";
return (
date.getFullYear() +
'-' +
pad(date.getMonth() + 1) +
'-' +
pad(date.getDate()) +
'T' +
pad(date.getHours()) +
':' +
pad(date.getMinutes()) +
':' +
pad(date.getSeconds()) +
plusOrMinus +
pad(tzOffset / 60) +
':' +
pad(tzOffset % 60)
);
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}${plusOrMinus}${pad(tzOffset / 60)}:${pad(tzOffset % 60)}`;
}
function capitalize(s: string): string {
return s[0].toUpperCase() + s.slice(1);
}
async function getZigbee2MQTTVersion(includeCommitHash = true): Promise<{commitHash?: string; version: string}> {
const git = await import('git-last-commit');
const packageJSON = await import('../..' + '/package.json');
export async function getZigbee2MQTTVersion(includeCommitHash = true): Promise<{commitHash?: string; version: string}> {
const packageJSON = (await import("../../package.json", {with: {type: "json"}})).default;
const version = packageJSON.version;
let commitHash: string | undefined;
if (!includeCommitHash) {
return {version: packageJSON.version, commitHash: undefined};
return {version, commitHash};
}
return await new Promise((resolve) => {
const version = packageJSON.version;
exec("git rev-parse --short=8 HEAD", (error, stdout) => {
commitHash = stdout.trim();
git.getLastCommit((err: Error, commit: {shortHash: string}) => {
let commitHash = undefined;
if (err) {
if (error || commitHash === "") {
try {
commitHash = fs.readFileSync(path.join(__dirname, '..', '..', 'dist', '.hash'), 'utf-8');
/* v8 ignore start */
commitHash = fs.readFileSync(path.join(__dirname, "..", "..", "dist", ".hash"), "utf-8");
} catch {
commitHash = 'unknown';
commitHash = "unknown";
}
/* v8 ignore stop */
} else {
commitHash = commit.shortHash;
}
commitHash = commitHash.trim();
resolve({commitHash, version});
});
});
}
async function getDependencyVersion(depend: string): Promise<{version: string}> {
const packageJsonPath = require.resolve(`${depend}/package.json`);
const version = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')).version;
return {version};
const packageJSON = (await import(`${depend}/package.json`, {with: {type: "json"}})).default;
return {version: packageJSON.version};
}
function formatDate(time: number, type: 'ISO_8601' | 'ISO_8601_local' | 'epoch' | 'relative'): string | number {
if (type === 'ISO_8601') return new Date(time).toISOString();
else if (type === 'ISO_8601_local') return toLocalISOString(new Date(time));
else if (type === 'epoch') return time;
else {
// relative
return humanizeDuration(Date.now() - time, {language: 'en', largest: 2, round: true}) + ' ago';
function formatDate(time: number, type: "ISO_8601" | "ISO_8601_local" | "epoch" | "relative"): string | number {
switch (type) {
case "ISO_8601":
// ISO8601 (UTC) = 2019-03-01T15:32:45.941Z
return new Date(time).toISOString();
case "ISO_8601_local":
// ISO8601 (local) = 2019-03-01T16:32:45.941+01:00 (for timezone GMT+1)
return toLocalISOString(new Date(time));
case "epoch":
return time;
default:
// relative
return `${humanizeDuration(Date.now() - time, {language: "en", largest: 2, round: true})} ago`;
}
}
function objectIsEmpty(object: object): boolean {
// much faster than checking `Object.keys(object).length`
for (const k in object) return false;
// biome-ignore lint/style/useNamingConvention: bad detection
for (const _k in object) return false;
return true;
}
@@ -135,27 +124,27 @@ function getResponse<T extends Zigbee2MQTTResponseEndpoints>(
if (error !== undefined) {
const response: Zigbee2MQTTResponse<T> = {
data: {}, // always return an empty `data` payload on error
status: 'error',
status: "error",
error: error,
};
if (typeof request === 'object' && request.transaction !== undefined) {
response.transaction = request.transaction;
}
return response;
} else {
const response: Zigbee2MQTTResponse<T> = {
data, // valid from error check
status: 'ok',
};
if (typeof request === 'object' && request.transaction !== undefined) {
if (typeof request === "object" && request.transaction !== undefined) {
response.transaction = request.transaction;
}
return response;
}
const response: Zigbee2MQTTResponse<T> = {
data, // valid from error check
status: "ok",
};
if (typeof request === "object" && request.transaction !== undefined) {
response.transaction = request.transaction;
}
return response;
}
function parseJSON(value: string, fallback: string): KeyValue | string {
@@ -182,7 +171,7 @@ function removeNullPropertiesFromObject(obj: KeyValue, ignoreKeys: string[] = []
if (value == null) {
delete obj[key];
} else if (typeof value === 'object') {
} else if (typeof value === "object") {
removeNullPropertiesFromObject(value, ignoreKeys);
}
}
@@ -190,7 +179,7 @@ function removeNullPropertiesFromObject(obj: KeyValue, ignoreKeys: string[] = []
function toNetworkAddressHex(value: number): string {
const hex = value.toString(16);
return `0x${'0'.repeat(4 - hex.length)}${hex}`;
return `0x${"0".repeat(4 - hex.length)}${hex}`;
}
function charRange(start: string, stop: string): number[] {
@@ -201,7 +190,7 @@ function charRange(start: string, stop: string): number[] {
return result;
}
const controlCharacters = [...charRange('\u0000', '\u001F'), ...charRange('\u007f', '\u009F'), ...charRange('\ufdd0', '\ufdef')];
const controlCharacters = [...charRange("\u0000", "\u001F"), ...charRange("\u007f", "\u009F"), ...charRange("\ufdd0", "\ufdef")];
function containsControlCharacter(str: string): boolean {
for (let i = 0; i < str.length; i++) {
@@ -229,11 +218,11 @@ function getAllFiles(path_: string): string[] {
function validateFriendlyName(name: string, throwFirstError = false): string[] {
const errors = [];
if (name.length === 0) errors.push(`friendly_name must be at least 1 char long`);
if (name.endsWith('/') || name.startsWith('/')) errors.push(`friendly_name is not allowed to end or start with /`);
if (containsControlCharacter(name)) errors.push(`friendly_name is not allowed to contain control char`);
if (name.length === 0) errors.push("friendly_name must be at least 1 char long");
if (name.endsWith("/") || name.startsWith("/")) errors.push("friendly_name is not allowed to end or start with /");
if (containsControlCharacter(name)) errors.push("friendly_name is not allowed to contain control char");
if (name.match(/.*\/\d*$/)) errors.push(`Friendly name cannot end with a "/DIGIT" ('${name}')`);
if (name.includes('#') || name.includes('+')) {
if (name.includes("#") || name.includes("+")) {
errors.push(`MQTT wildcard (+ and #) not allowed in friendly_name ('${name}')`);
}
@@ -249,10 +238,7 @@ function sleep(seconds: number): Promise<void> {
}
function sanitizeImageParameter(parameter: string): string {
const replaceByDash = [/\?/g, /&/g, /[^a-z\d\- _./:]/gi];
let sanitized = parameter;
replaceByDash.forEach((r) => (sanitized = sanitized.replace(r, '-')));
return sanitized;
return parameter.replace(/\?|&|[^a-z\d\- _./:]/gi, "-");
}
function isAvailabilityEnabledForEntity(entity: Device | Group, settings: Settings): boolean {
@@ -261,22 +247,24 @@ function isAvailabilityEnabledForEntity(entity: Device | Group, settings: Settin
}
if (entity.isGroup()) {
return !entity.membersDevices().some((d) => !isAvailabilityEnabledForEntity(d, settings));
for (const memberDevice of entity.membersDevices()) {
if (!isAvailabilityEnabledForEntity(memberDevice, settings)) {
return false;
}
}
return true;
}
if (entity.options.availability != null) {
return !!entity.options.availability;
}
if (!settings.availability.enabled) {
return false;
}
return true;
return settings.availability.enabled;
}
function isZHEndpoint(obj: unknown): obj is zh.Endpoint {
return obj?.constructor.name.toLowerCase() === 'endpoint';
return obj?.constructor.name.toLowerCase() === "endpoint";
}
function flatten<Type>(arr: Type[][]): Type[] {
@@ -288,7 +276,7 @@ function arrayUnique<Type>(arr: Type[]): Type[] {
}
function isZHGroup(obj: unknown): obj is zh.Group {
return obj?.constructor.name.toLowerCase() === 'group';
return obj?.constructor.name.toLowerCase() === "group";
}
const hours = (hours: number): number => 1000 * 60 * 60 * hours;
@@ -308,9 +296,9 @@ async function publishLastSeen(
* - In case reason !== messageEmitted, controller.ts will call this based on the zigbee-herdsman
* lastSeenChanged event.
*/
const allow = data.reason !== 'messageEmitted' || (data.reason === 'messageEmitted' && allowMessageEmitted);
if (settings.advanced.last_seen && settings.advanced.last_seen !== 'disable' && allow) {
await publishEntityState(data.device, {}, 'lastSeenChanged');
const allow = data.reason !== "messageEmitted" || (data.reason === "messageEmitted" && allowMessageEmitted);
if (settings.advanced.last_seen && settings.advanced.last_seen !== "disable" && allow) {
await publishEntityState(data.device, {}, "lastSeenChanged");
}
}
@@ -325,31 +313,31 @@ function filterProperties(filter: string[] | undefined, data: KeyValue): void {
}
export function isNumericExpose(expose: zhc.Expose): expose is zhc.Numeric {
return expose?.type === 'numeric';
return expose?.type === "numeric";
}
export function assertEnumExpose(expose: zhc.Expose): asserts expose is zhc.Enum {
assert(expose?.type === 'enum');
assert(expose?.type === "enum");
}
export function assertNumericExpose(expose: zhc.Expose): asserts expose is zhc.Numeric {
assert(expose?.type === 'numeric');
assert(expose?.type === "numeric");
}
export function assertBinaryExpose(expose: zhc.Expose): asserts expose is zhc.Binary {
assert(expose?.type === 'binary');
assert(expose?.type === "binary");
}
export function isEnumExpose(expose: zhc.Expose): expose is zhc.Enum {
return expose?.type === 'enum';
return expose?.type === "enum";
}
export function isBinaryExpose(expose: zhc.Expose): expose is zhc.Binary {
return expose?.type === 'binary';
return expose?.type === "binary";
}
export function isLightExpose(expose: zhc.Expose): expose is zhc.Light {
return expose.type === 'light';
return expose.type === "light";
}
function getScenes(entity: zh.Endpoint | zh.Group): Zigbee2MQTTScene[] {
@@ -359,9 +347,9 @@ function getScenes(entity: zh.Endpoint | zh.Group): Zigbee2MQTTScene[] {
for (const endpoint of endpoints) {
for (const [key, data] of Object.entries(endpoint.meta?.scenes || {})) {
const split = key.split('_');
const sceneID = parseInt(split[0], 10);
const sceneGroupID = parseInt(split[1], 10);
const split = key.split("_");
const sceneID = Number.parseInt(split[0], 10);
const sceneGroupID = Number.parseInt(split[1], 10);
if (sceneGroupID === groupID) {
scenes[sceneID] = {id: sceneID, name: (data as KeyValue).name || `Scene ${sceneID}`};
}
@@ -372,7 +360,7 @@ function getScenes(entity: zh.Endpoint | zh.Group): Zigbee2MQTTScene[] {
}
function deviceNotCoordinator(device: zh.Device): boolean {
return device.type !== 'Coordinator';
return device.type !== "Coordinator";
}
function matchBase64File(value: string | undefined): {extension: string; data: string} | false {
@@ -387,11 +375,11 @@ function matchBase64File(value: string | undefined): {extension: string; data: s
}
function saveBase64DeviceIcon(base64Match: {extension: string; data: string}): string {
const md5Hash = crypto.createHash('md5').update(base64Match.data).digest('hex');
const md5Hash = crypto.createHash("md5").update(base64Match.data).digest("hex");
const fileSettings = `device_icons/${md5Hash}.${base64Match.extension}`;
const file = path.join(data.getPath(), fileSettings);
fs.mkdirSync(path.dirname(file), {recursive: true});
fs.writeFileSync(file, base64Match.data, {encoding: 'base64'});
fs.writeFileSync(file, base64Match.data, {encoding: "base64"});
return fileSettings;
}
+6 -6
View File
@@ -1,8 +1,8 @@
import assert from 'node:assert';
import fs from 'node:fs';
import assert from "node:assert";
import fs from "node:fs";
import equals from 'fast-deep-equal/es6';
import yaml, {YAMLException} from 'js-yaml';
import equals from "fast-deep-equal/es6";
import yaml, {YAMLException} from "js-yaml";
export class YAMLFileException extends YAMLException {
file: string;
@@ -10,7 +10,7 @@ export class YAMLFileException extends YAMLException {
constructor(error: YAMLException, file: string) {
super(error.reason, error.mark);
this.name = 'YAMLFileException';
this.name = "YAMLFileException";
this.cause = error.cause;
this.message = error.message;
this.stack = error.stack;
@@ -20,7 +20,7 @@ export class YAMLFileException extends YAMLException {
function read(file: string): KeyValue {
try {
const result = yaml.load(fs.readFileSync(file, 'utf8'));
const result = yaml.load(fs.readFileSync(file, "utf8"));
assert(result instanceof Object);
return result as KeyValue;
} catch (error) {
+110 -97
View File
@@ -1,48 +1,49 @@
import type {Events as ZHEvents} from 'zigbee-herdsman';
import type {Events as ZHEvents} from "zigbee-herdsman";
import type {StartResult} from "zigbee-herdsman/dist/adapter/tstype";
import {randomInt} from 'node:crypto';
import {randomInt} from "node:crypto";
import bind from 'bind-decorator';
import stringify from 'json-stable-stringify-without-jsonify';
import bind from "bind-decorator";
import stringify from "json-stable-stringify-without-jsonify";
import {Controller} from 'zigbee-herdsman';
import {Controller} from "zigbee-herdsman";
import Device from './model/device';
import Group from './model/group';
import data from './util/data';
import logger from './util/logger';
import * as settings from './util/settings';
import utils from './util/utils';
import Device from "./model/device";
import Group from "./model/group";
import data from "./util/data";
import logger from "./util/logger";
import * as settings from "./util/settings";
import utils from "./util/utils";
const entityIDRegex = new RegExp(`^(.+?)(?:/([^/]+))?$`);
const entityIDRegex = /^(.+?)(?:\/([^/]+))?$/;
export default class Zigbee {
// @ts-expect-error initialized in start
private herdsman: Controller;
private eventBus: EventBus;
private groupLookup: {[s: number]: Group} = {};
private deviceLookup: {[s: string]: Device} = {};
private groupLookup = new Map<number /* group ID */, Group>();
private deviceLookup = new Map<string /* IEEE address */, Device>();
constructor(eventBus: EventBus) {
this.eventBus = eventBus;
}
async start(): Promise<'reset' | 'resumed' | 'restored'> {
const infoHerdsman = await utils.getDependencyVersion('zigbee-herdsman');
async start(): Promise<StartResult> {
const infoHerdsman = await utils.getDependencyVersion("zigbee-herdsman");
logger.info(`Starting zigbee-herdsman (${infoHerdsman.version})`);
const panId = settings.get().advanced.pan_id;
const extPanId = settings.get().advanced.ext_pan_id;
const networkKey = settings.get().advanced.network_key;
const herdsmanSettings = {
network: {
panID: panId === 'GENERATE' ? this.generatePanID() : panId,
extendedPanID: extPanId === 'GENERATE' ? this.generateExtPanID() : extPanId,
panID: panId === "GENERATE" ? this.generatePanID() : panId,
extendedPanID: extPanId === "GENERATE" ? this.generateExtPanID() : extPanId,
channelList: [settings.get().advanced.channel],
networkKey: networkKey === 'GENERATE' ? this.generateNetworkKey() : networkKey,
networkKey: networkKey === "GENERATE" ? this.generateNetworkKey() : networkKey,
},
databasePath: data.joinPath('database.db'),
databaseBackupPath: data.joinPath('database.db.backup'),
backupPath: data.joinPath('coordinator_backup.json'),
databasePath: data.joinPath("database.db"),
databaseBackupPath: data.joinPath("database.db.backup"),
backupPath: data.joinPath("coordinator_backup.json"),
serialPort: {
baudRate: settings.get().serial.baudrate,
rtscts: settings.get().serial.rtscts,
@@ -63,35 +64,38 @@ export default class Zigbee {
`Using zigbee-herdsman with settings: '${stringify(JSON.stringify(herdsmanSettings).replaceAll(JSON.stringify(herdsmanSettings.network.networkKey), '"HIDDEN"'))}'`,
);
let startResult;
let startResult: StartResult;
try {
this.herdsman = new Controller(herdsmanSettings);
startResult = await this.herdsman.start();
} catch (error) {
logger.error(`Error while starting zigbee-herdsman`);
logger.error("Error while starting zigbee-herdsman");
throw error;
}
await this.resolveDevicesDefinitions();
this.herdsman.on('adapterDisconnected', () => this.eventBus.emitAdapterDisconnected());
this.herdsman.on('lastSeenChanged', (data: ZHEvents.LastSeenChangedPayload) => {
this.herdsman.on("adapterDisconnected", () => this.eventBus.emitAdapterDisconnected());
this.herdsman.on("lastSeenChanged", (data: ZHEvents.LastSeenChangedPayload) => {
// biome-ignore lint/style/noNonNullAssertion: assumed valid
this.eventBus.emitLastSeenChanged({device: this.resolveDevice(data.device.ieeeAddr)!, reason: data.reason});
});
this.herdsman.on('permitJoinChanged', (data: ZHEvents.PermitJoinChangedPayload) => {
this.herdsman.on("permitJoinChanged", (data: ZHEvents.PermitJoinChangedPayload) => {
this.eventBus.emitPermitJoinChanged(data);
});
this.herdsman.on('deviceNetworkAddressChanged', (data: ZHEvents.DeviceNetworkAddressChangedPayload) => {
this.herdsman.on("deviceNetworkAddressChanged", (data: ZHEvents.DeviceNetworkAddressChangedPayload) => {
// biome-ignore lint/style/noNonNullAssertion: assumed valid
const device = this.resolveDevice(data.device.ieeeAddr)!;
logger.debug(`Device '${device.name}' changed network address`);
this.eventBus.emitDeviceNetworkAddressChanged({device});
});
this.herdsman.on('deviceAnnounce', (data: ZHEvents.DeviceAnnouncePayload) => {
this.herdsman.on("deviceAnnounce", (data: ZHEvents.DeviceAnnouncePayload) => {
// biome-ignore lint/style/noNonNullAssertion: assumed valid
const device = this.resolveDevice(data.device.ieeeAddr)!;
logger.debug(`Device '${device.name}' announced itself`);
this.eventBus.emitDeviceAnnounce({device});
});
this.herdsman.on('deviceInterview', async (data: ZHEvents.DeviceInterviewPayload) => {
this.herdsman.on("deviceInterview", async (data: ZHEvents.DeviceInterviewPayload) => {
const device = this.resolveDevice(data.device.ieeeAddr);
/* v8 ignore next */ if (!device) return; // Prevent potential race
await device.resolveDefinition();
@@ -99,29 +103,29 @@ export default class Zigbee {
this.logDeviceInterview(d);
this.eventBus.emitDeviceInterview(d);
});
this.herdsman.on('deviceJoined', async (data: ZHEvents.DeviceJoinedPayload) => {
this.herdsman.on("deviceJoined", async (data: ZHEvents.DeviceJoinedPayload) => {
const device = this.resolveDevice(data.device.ieeeAddr);
/* v8 ignore next */ if (!device) return; // Prevent potential race
await device.resolveDefinition();
logger.info(`Device '${device.name}' joined`);
this.eventBus.emitDeviceJoined({device});
});
this.herdsman.on('deviceLeave', (data: ZHEvents.DeviceLeavePayload) => {
this.herdsman.on("deviceLeave", (data: ZHEvents.DeviceLeavePayload) => {
const name = settings.getDevice(data.ieeeAddr)?.friendly_name || data.ieeeAddr;
logger.warning(`Device '${name}' left the network`);
this.eventBus.emitDeviceLeave({ieeeAddr: data.ieeeAddr, name});
this.eventBus.emitDeviceLeave({ieeeAddr: data.ieeeAddr, name, device: this.deviceLookup.get(data.ieeeAddr)});
});
this.herdsman.on('message', async (data: ZHEvents.MessagePayload) => {
this.herdsman.on("message", async (data: ZHEvents.MessagePayload) => {
// biome-ignore lint/style/noNonNullAssertion: assumed valid
const device = this.resolveDevice(data.device.ieeeAddr)!;
await device.resolveDefinition();
logger.debug(
() =>
`Received Zigbee message from '${device.name}', type '${data.type}', ` +
`cluster '${data.cluster}', data '${stringify(data.data)}' from endpoint ${data.endpoint.ID}` +
(data['groupID'] !== undefined ? ` with groupID ${data.groupID}` : ``) +
(device.zh.type === 'Coordinator' ? `, ignoring since it is from coordinator` : ``),
);
if (device.zh.type === 'Coordinator') return;
logger.debug(() => {
const groupId = data.groupID !== undefined ? ` with groupID ${data.groupID}` : "";
const fromCoord = device.zh.type === "Coordinator" ? ", ignoring since it is from coordinator" : "";
return `Received Zigbee message from '${device.name}', type '${data.type}', cluster '${data.cluster}', data '${stringify(data.data)}' from endpoint ${data.endpoint.ID}${groupId}${fromCoord}`;
});
if (device.zh.type === "Coordinator") return;
this.eventBus.emitDeviceMessage({...data, device});
});
@@ -157,20 +161,19 @@ export default class Zigbee {
private logDeviceInterview(data: eventdata.DeviceInterview): void {
const name = data.device.name;
if (data.status === 'successful') {
if (data.status === "successful") {
logger.info(`Successfully interviewed '${name}', device has successfully been paired`);
if (data.device.isSupported) {
// biome-ignore lint/style/noNonNullAssertion: valid from `isSupported`
const {vendor, description, model} = data.device.definition!;
logger.info(`Device '${name}' is supported, identified as: ${vendor} ${description} (${model})`);
} else {
logger.warning(
`Device '${name}' with Zigbee model '${data.device.zh.modelID}' and manufacturer name ` +
`'${data.device.zh.manufacturerName}' is NOT supported, ` +
`please follow https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html`,
`Device '${name}' with Zigbee model '${data.device.zh.modelID}' and manufacturer name '${data.device.zh.manufacturerName}' is NOT supported, please follow https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html`,
);
}
} else if (data.status === 'failed') {
} else if (data.status === "failed") {
logger.error(`Failed to interview '${name}', device has not successfully been paired`);
} else {
// data.status === 'started'
@@ -180,19 +183,19 @@ export default class Zigbee {
private generateNetworkKey(): number[] {
const key = Array.from({length: 16}, () => randomInt(256));
settings.set(['advanced', 'network_key'], key);
settings.set(["advanced", "network_key"], key);
return key;
}
private generateExtPanID(): number[] {
const key = Array.from({length: 8}, () => randomInt(256));
settings.set(['advanced', 'ext_pan_id'], key);
settings.set(["advanced", "ext_pan_id"], key);
return key;
}
private generatePanID(): number {
const panID = randomInt(1, 0xffff - 1);
settings.set(['advanced', 'pan_id'], panID);
settings.set(["advanced", "pan_id"], panID);
return panID;
}
@@ -210,6 +213,7 @@ export default class Zigbee {
async coordinatorCheck(): Promise<{missingRouters: Device[]}> {
const check = await this.herdsman.coordinatorCheck();
// biome-ignore lint/style/noNonNullAssertion: assumed valid
return {missingRouters: check.missingRouters.map((d) => this.resolveDevice(d.ieeeAddr)!)};
}
@@ -218,9 +222,9 @@ export default class Zigbee {
}
async stop(): Promise<void> {
logger.info('Stopping zigbee-herdsman...');
logger.info("Stopping zigbee-herdsman...");
await this.herdsman.stop();
logger.info('Stopped zigbee-herdsman');
logger.info("Stopped zigbee-herdsman");
}
getPermitJoin(): boolean {
@@ -233,67 +237,69 @@ export default class Zigbee {
async permitJoin(time: number, device?: Device): Promise<void> {
if (time > 0) {
logger.info(`Zigbee: allowing new devices to join${device ? ` via ${device.name}` : ''}.`);
logger.info(`Zigbee: allowing new devices to join${device ? ` via ${device.name}` : ""}.`);
} else {
logger.info('Zigbee: disabling joining new devices.');
logger.info("Zigbee: disabling joining new devices.");
}
await this.herdsman.permitJoin(time, device?.zh);
}
async resolveDevicesDefinitions(ignoreCache: boolean = false): Promise<void> {
async resolveDevicesDefinitions(ignoreCache = false): Promise<void> {
for (const device of this.devicesIterator(utils.deviceNotCoordinator)) {
await device.resolveDefinition(ignoreCache);
}
}
@bind private resolveDevice(ieeeAddr: string): Device | undefined {
if (!this.deviceLookup[ieeeAddr]) {
if (!this.deviceLookup.has(ieeeAddr)) {
const device = this.herdsman.getDeviceByIeeeAddr(ieeeAddr);
if (device) {
this.deviceLookup[ieeeAddr] = new Device(device);
this.deviceLookup.set(ieeeAddr, new Device(device));
}
}
const device = this.deviceLookup[ieeeAddr];
const device = this.deviceLookup.get(ieeeAddr);
if (device && !device.zh.isDeleted) {
device.ensureInSettings();
return device;
}
}
private resolveGroup(groupID: number): Group {
private resolveGroup(groupID: number): Group | undefined {
const group = this.herdsman.getGroupByID(Number(groupID));
if (group && !this.groupLookup[groupID]) {
this.groupLookup[groupID] = new Group(group, this.resolveDevice);
if (group && !this.groupLookup.has(groupID)) {
this.groupLookup.set(groupID, new Group(group, this.resolveDevice));
}
return this.groupLookup[groupID];
return this.groupLookup.get(groupID);
}
resolveEntity(key: string | number | zh.Device): Device | Group | undefined {
if (typeof key === 'object') {
if (typeof key === "object") {
return this.resolveDevice(key.ieeeAddr);
} else if (typeof key === 'string' && key.toLowerCase() === 'coordinator') {
return this.resolveDevice(this.herdsman.getDevicesByType('Coordinator')[0].ieeeAddr);
} else {
const settingsDevice = settings.getDevice(key.toString());
}
if (settingsDevice) {
return this.resolveDevice(settingsDevice.ID);
}
if (typeof key === "string" && key.toLowerCase() === "coordinator") {
return this.resolveDevice(this.herdsman.getDevicesByType("Coordinator")[0].ieeeAddr);
}
const groupSettings = settings.getGroup(key);
const settingsDevice = settings.getDevice(key.toString());
if (groupSettings) {
const group = this.resolveGroup(groupSettings.ID);
// If group does not exist, create it (since it's already in configuration.yaml)
return group ? group : this.createGroup(groupSettings.ID);
}
if (settingsDevice) {
return this.resolveDevice(settingsDevice.ID);
}
const groupSettings = settings.getGroup(key);
if (groupSettings) {
const group = this.resolveGroup(groupSettings.ID);
// If group does not exist, create it (since it's already in configuration.yaml)
return group ? group : this.createGroup(groupSettings.ID);
}
}
resolveEntityAndEndpoint(ID: string): {ID: string; entity: Device | Group | undefined; endpointID?: string; endpoint?: zh.Endpoint} {
resolveEntityAndEndpoint(id: string): {ID: string; entity: Device | Group | undefined; endpointID?: string; endpoint?: zh.Endpoint} {
// This function matches the following entity formats:
// device_name (just device name)
// device_name/ep_name (device name and endpoint numeric ID or name)
@@ -301,14 +307,14 @@ export default class Zigbee {
// device/name/ep_name (device name with slashes, and endpoint numeric ID or name)
// The function tries to find an exact match first
let entityName = ID;
let deviceOrGroup = this.resolveEntity(ID);
let entityName = id;
let deviceOrGroup = this.resolveEntity(id);
let endpointNameOrID: string | undefined;
// If exact match did not happen, try matching a device_name/endpoint pattern
if (!deviceOrGroup) {
// First split the input token by the latest slash
const match = ID.match(entityIDRegex);
const match = id.match(entityIDRegex);
if (match) {
// Get the resulting IDs from the match
@@ -327,7 +333,7 @@ export default class Zigbee {
}
firstCoordinatorEndpoint(): zh.Endpoint {
return this.herdsman.getDevicesByType('Coordinator')[0].endpoints[0];
return this.herdsman.getDevicesByType("Coordinator")[0].endpoints[0];
}
*devicesAndGroupsIterator(
@@ -335,26 +341,31 @@ export default class Zigbee {
groupPredicate?: (value: zh.Group) => boolean,
): Generator<Device | Group> {
for (const device of this.herdsman.getDevicesIterator(devicePredicate)) {
// biome-ignore lint/style/noNonNullAssertion: assumed valid
yield this.resolveDevice(device.ieeeAddr)!;
}
for (const group of this.herdsman.getGroupsIterator(groupPredicate)) {
yield this.resolveGroup(group.groupID);
// biome-ignore lint/style/noNonNullAssertion: assumed valid
yield this.resolveGroup(group.groupID)!;
}
}
*groupsIterator(predicate?: (value: zh.Group) => boolean): Generator<Group> {
for (const group of this.herdsman.getGroupsIterator(predicate)) {
yield this.resolveGroup(group.groupID);
// biome-ignore lint/style/noNonNullAssertion: assumed valid
yield this.resolveGroup(group.groupID)!;
}
}
*devicesIterator(predicate?: (value: zh.Device) => boolean): Generator<Device> {
for (const device of this.herdsman.getDevicesIterator(predicate)) {
// biome-ignore lint/style/noNonNullAssertion: assumed valid
yield this.resolveDevice(device.ieeeAddr)!;
}
}
// biome-ignore lint/suspicious/useAwait: API
@bind private async acceptJoiningDeviceHandler(ieeeAddr: string): Promise<boolean> {
// If passlist is set, all devices not on passlist will be rejected to join the network
const passlist = settings.get().passlist;
@@ -363,21 +374,22 @@ export default class Zigbee {
if (passlist.includes(ieeeAddr)) {
logger.info(`Accepting joining device which is on passlist '${ieeeAddr}'`);
return true;
} else {
logger.info(`Rejecting joining not in passlist device '${ieeeAddr}'`);
return false;
}
} else if (blocklist.length > 0) {
logger.info(`Rejecting joining not in passlist device '${ieeeAddr}'`);
return false;
}
if (blocklist.length > 0) {
if (blocklist.includes(ieeeAddr)) {
logger.info(`Rejecting joining device which is on blocklist '${ieeeAddr}'`);
return false;
} else {
logger.info(`Accepting joining not in blocklist device '${ieeeAddr}'`);
return true;
}
} else {
return true;
logger.info(`Accepting joining not in blocklist device '${ieeeAddr}'`);
}
return true;
}
async touchlinkFactoryResetFirst(): Promise<boolean> {
@@ -400,9 +412,10 @@ export default class Zigbee {
return await this.herdsman.touchlinkScan();
}
createGroup(ID: number): Group {
this.herdsman.createGroup(ID);
return this.resolveGroup(ID);
createGroup(id: number): Group {
this.herdsman.createGroup(id);
// biome-ignore lint/style/noNonNullAssertion: just created
return this.resolveGroup(id)!;
}
deviceByNetworkAddress(networkAddress: number): Device | undefined {
@@ -410,7 +423,7 @@ export default class Zigbee {
return device && this.resolveDevice(device.ieeeAddr);
}
groupByID(ID: number): Group {
return this.resolveGroup(ID);
groupByID(id: number): Group | undefined {
return this.resolveGroup(id);
}
}
+18 -26
View File
@@ -1,8 +1,9 @@
{
"name": "zigbee2mqtt",
"version": "2.1.3",
"version": "2.3.0",
"description": "Zigbee to MQTT bridge using Zigbee-herdsman",
"main": "index.js",
"types": "dist/types/api.d.ts",
"packageManager": "pnpm@10.4.1",
"repository": {
"type": "git",
@@ -22,10 +23,9 @@
],
"scripts": {
"build": "tsc && node index.js writehash",
"build-watch": "tsc --watch",
"eslint": "eslint --max-warnings=0",
"pretty:write": "prettier --write .",
"pretty:check": "prettier --check .",
"build:watch": "tsc --watch",
"check": "biome check",
"check:w": "biome check --write",
"start": "node index.js",
"test": "vitest run --config ./test/vitest.config.mts",
"test:coverage": "vitest run --config ./test/vitest.config.mts --coverage",
@@ -45,14 +45,13 @@
"debounce": "^2.2.0",
"express-static-gzip": "^2.2.0",
"fast-deep-equal": "^3.1.3",
"finalhandler": "^1.3.1",
"git-last-commit": "^1.0.1",
"finalhandler": "^2.1.0",
"humanize-duration": "^3.32.1",
"js-yaml": "^4.1.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
"jszip": "^3.10.1",
"moment": "^2.30.1",
"mqtt": "^5.10.3",
"mqtt": "^5.11.0",
"object-assign-deep": "^0.4.0",
"rimraf": "^6.0.1",
"semver": "^7.7.1",
@@ -62,40 +61,33 @@
"winston-syslog": "^2.7.1",
"winston-transport": "^4.9.0",
"ws": "^8.18.1",
"zigbee-herdsman": "3.2.7",
"zigbee-herdsman-converters": "23.2.0",
"zigbee2mqtt-frontend": "0.9.4"
"zigbee-herdsman": "4.0.0",
"zigbee-herdsman-converters": "23.36.0",
"zigbee2mqtt-frontend": "0.9.10"
},
"devDependencies": {
"@eslint/core": "^0.12.0",
"@eslint/js": "^9.21.0",
"@ianvs/prettier-plugin-sort-imports": "^4.4.1",
"@biomejs/biome": "^1.9.4",
"@types/finalhandler": "^1.2.3",
"@types/humanize-duration": "^3.27.4",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.13.5",
"@types/node": "^22.14.1",
"@types/object-assign-deep": "^0.4.3",
"@types/readable-stream": "4.0.18",
"@types/sd-notify": "^2.8.2",
"@types/serve-static": "^1.15.7",
"@types/ws": "8.5.14",
"@vitest/coverage-v8": "^3.0.7",
"eslint": "^9.21.0",
"eslint-config-prettier": "^10.0.1",
"prettier": "^3.5.2",
"@types/ws": "8.18.1",
"@vitest/coverage-v8": "^3.1.1",
"tmp": "^0.2.3",
"typescript": "^5.7.3",
"typescript-eslint": "^8.25.0",
"vitest": "^3.0.7"
"typescript": "^5.8.3",
"vitest": "^3.1.1"
},
"pnpm": {
"overrides": {
"zigbee-herdsman": "$zigbee-herdsman"
},
"onlyBuiltDependencies": [
"@biomejs/biome",
"@serialport/bindings-cpp",
"esbuild",
"sd-notify",
"unix-dgram"
]
},
@@ -103,6 +95,6 @@
"zigbee2mqtt": "cli.js"
},
"optionalDependencies": {
"sd-notify": "^2.8.0"
"unix-dgram": "^2.0.6"
}
}
+517 -1307
View File
File diff suppressed because it is too large Load Diff
+55 -49
View File
@@ -1,8 +1,8 @@
const path = require('path');
const fs = require('fs');
const process = require('process');
const {execSync} = require('child_process');
const zhc = require('zigbee-herdsman-converters');
const path = require("node:path");
const fs = require("node:fs");
const process = require("node:process");
const {execSync} = require("node:child_process");
const zhc = require("zigbee-herdsman-converters");
const z2mTillVersion = process.argv[2];
const zhcTillVersion = process.argv[3];
@@ -12,24 +12,24 @@ const frontendTillVersion = process.argv[5];
const changelogs = [
{
tillVersion: z2mTillVersion,
project: 'koenkk/zigbee2mqtt',
contents: fs.readFileSync(path.join(__dirname, '..', 'CHANGELOG.md'), 'utf-8').split('\n'),
project: "koenkk/zigbee2mqtt",
contents: fs.readFileSync(path.join(__dirname, "..", "CHANGELOG.md"), "utf-8").split("\n"),
},
{
tillVersion: zhcTillVersion,
project: 'koenkk/zigbee-herdsman-converters',
contents: fs.readFileSync(path.join(__dirname, '..', 'node_modules', 'zigbee-herdsman-converters', 'CHANGELOG.md'), 'utf-8').split('\n'),
project: "koenkk/zigbee-herdsman-converters",
contents: fs.readFileSync(path.join(__dirname, "..", "node_modules", "zigbee-herdsman-converters", "CHANGELOG.md"), "utf-8").split("\n"),
},
{
tillVersion: zhTillVersion,
project: 'koenkk/zigbee-herdsman',
contents: fs.readFileSync(path.join(__dirname, '..', 'node_modules', 'zigbee-herdsman', 'CHANGELOG.md'), 'utf-8').split('\n'),
project: "koenkk/zigbee-herdsman",
contents: fs.readFileSync(path.join(__dirname, "..", "node_modules", "zigbee-herdsman", "CHANGELOG.md"), "utf-8").split("\n"),
},
{
tillVersion: frontendTillVersion,
project: 'nurikk/zigbee2mqtt-frontend',
project: "nurikk/zigbee2mqtt-frontend",
isFrontend: true,
contents: fs.readFileSync(path.join(__dirname, '..', 'node_modules', 'zigbee2mqtt-frontend', 'CHANGELOG.md'), 'utf-8').split('\n'),
contents: fs.readFileSync(path.join(__dirname, "..", "node_modules", "zigbee2mqtt-frontend", "CHANGELOG.md"), "utf-8").split("\n"),
},
];
@@ -44,12 +44,12 @@ const changeRe = [
const frontendChangeRe = /^\* (\*\*.+:\*\* )()?(.+) \(\[.+\]\(https:.+\/()(.+)\)\)(.+)?$/;
let commitUserLookup = {};
const commitUserFile = path.join(__dirname, 'commit-user-lookup.json');
const commitUserFile = path.join(__dirname, "commit-user-lookup.json");
if (fs.existsSync(commitUserFile)) {
commitUserLookup = JSON.parse(fs.readFileSync(commitUserFile, 'utf8'));
commitUserLookup = JSON.parse(fs.readFileSync(commitUserFile, "utf8"));
}
const definitions = require('zigbee-herdsman-converters/devices/index').default.map((d) => zhc.prepareDefinition(d));
const definitions = require("zigbee-herdsman-converters/devices/index").default.map((d) => zhc.prepareDefinition(d));
const whiteLabels = definitions.filter((d) => d.whiteLabel).flatMap((d) => d.whiteLabel);
const capitalizeFirstChar = (str) => str.charAt(0).toUpperCase() + str.slice(1);
@@ -61,15 +61,15 @@ for (const changelog of changelogs) {
if (releaseMatch[1] === changelog.tillVersion) {
break;
}
} else if (line === '### Features') {
context = 'features';
} else if (line === '### Bug Fixes') {
context = 'fixes';
} else if (line.startsWith('* **ignore:**')) {
continue;
} else if (line === "### Features") {
context = "features";
} else if (line === "### Bug Fixes") {
context = "fixes";
} else if (line.startsWith("* **ignore:**")) {
// continue;
} else if (changeMatch) {
let localContext = changelog.isFrontend ? 'frontend' : changeMatch[2] ? changeMatch[2] : context;
if (!changes[localContext]) localContext = 'error';
let localContext = changelog.isFrontend ? "frontend" : changeMatch[2] ? changeMatch[2] : context;
if (!changes[localContext]) localContext = "error";
const commit = changeMatch[5];
const commitUserKey = `${changelog.project}-${commit} `;
@@ -79,10 +79,10 @@ for (const changelog of changelogs) {
: execSync(`curl -s https://api.github.com/repos/${changelog.project}/commits/${commit} | jq -r '.author.login'`)
.toString()
.trim();
if (user !== 'null') commitUserLookup[commitUserKey] = user;
if (user !== "null") commitUserLookup[commitUserKey] = user;
const messages = [];
let message = changeMatch[3].trim();
if (message.endsWith('.')) message = message.substring(0, message.length - 1);
if (message.endsWith(".")) message = message.substring(0, message.length - 1);
if (changelog.isFrontend) {
changes[localContext].push(
@@ -93,18 +93,18 @@ for (const changelog of changelogs) {
const otherUser = message.match(/\[@(.+)\]\(https:\/\/github.com\/.+\)/) || message.match(/@(.+)/);
if (otherUser) {
user = otherUser[1];
message = message.replace(otherUser[0], '');
message = message.replace(otherUser[0], "");
}
if (localContext === 'add') {
for (const model of message.split(',')) {
if (localContext === "add") {
for (const model of message.split(",")) {
const definition = definitions.find((d) => d.model === model.trim());
const whiteLabel = whiteLabels.find((d) => d.model === model.trim());
const match = definition || whiteLabel;
if (match) {
messages.push(`\`${match.model}\` ${match.vendor} ${match.description}`);
} else {
changes['error'].push(`${line} (model '${model}' does not exist)`);
changes.error.push(`${line} (model '${model}' does not exist)`);
}
}
} else {
@@ -112,40 +112,46 @@ for (const changelog of changelogs) {
}
let issue = changeMatch[4].trim();
if (issue && !issue.startsWith('[#')) issue = `[#${issue.split('/').pop()}](${issue})`;
if (issue && !issue.startsWith("[#")) issue = `[#${issue.split("/").pop()}](${issue})`;
if (!issue) {
issue = '_NO_ISSUE_';
localContext = 'error';
issue = "_NO_ISSUE_";
localContext = "error";
}
messages.forEach((m) => changes[localContext].push(`- ${issue} ${m} (@${user})`));
for (const message of messages) {
changes[localContext].push(`- ${issue} ${message} (@${user})`);
}
}
} else if (line === '# Changelog' || line === '### ⚠ BREAKING CHANGES' || !line) {
continue;
} else if (line === "# Changelog" || line === "### ⚠ BREAKING CHANGES" || !line) {
// continue;
} else {
changes['error'].push(`- Unmatched line: ${line}`);
changes.error.push(`- Unmatched line: ${line}`);
}
}
}
let result = '';
let result = "";
const names = [
['features', 'Improvements'],
['fixes', 'Fixes'],
['frontend', 'Frontend'],
['add', 'New supported devices'],
['detect', 'Fixed device detections'],
['error', 'Changelog generator error'],
["features", "Improvements"],
["fixes", "Fixes"],
["frontend", "Frontend"],
["add", "New supported devices"],
["detect", "Fixed device detections"],
["error", "Changelog generator error"],
];
for (const name of names) {
result += `# ${name[1]}\n`;
if (name[0] === 'add') {
result += `This release adds support for ${changes['add'].length} devices: \n`;
if (name[0] === "add") {
result += `This release adds support for ${changes.add.length} devices: \n`;
}
changes[name[0]].forEach((e) => (result += `${e}\n`));
result += '\n';
for (const change of changes[name[0]]) {
result += `${change}\n`;
}
result += "\n";
}
fs.writeFileSync(commitUserFile, JSON.stringify(commitUserLookup), 'utf-8');
fs.writeFileSync(commitUserFile, JSON.stringify(commitUserLookup), "utf-8");
console.log(result.trim());
+5 -5
View File
@@ -1,10 +1,10 @@
const assert = require('assert');
const vm = require('vm');
const fs = require('fs');
const path = require('path');
const assert = require("node:assert");
const vm = require("node:vm");
const fs = require("node:fs");
const path = require("node:path");
const filename = process.argv[2];
const moduleCode = fs.readFileSync(filename);
const moduleFakePath = path.join(__dirname, 'externally-loaded.js');
const moduleFakePath = path.join(__dirname, "externally-loaded.js");
const sandbox = {
require: require,
module: {},
+21 -21
View File
@@ -1,7 +1,7 @@
const {ZnpCommandStatus, NvSystemIds} = require('zigbee-herdsman/dist/adapter/z-stack/constants/common');
const {ZnpVersion} = require('zigbee-herdsman/dist/adapter/z-stack/adapter/tstype');
const {Subsystem} = require('zigbee-herdsman/dist/adapter/z-stack/unpi/constants');
const {Znp} = require('zigbee-herdsman/dist/adapter/z-stack/znp');
const {ZnpCommandStatus, NvSystemIds} = require("zigbee-herdsman/dist/adapter/z-stack/constants/common");
const {ZnpVersion} = require("zigbee-herdsman/dist/adapter/z-stack/adapter/tstype");
const {Subsystem} = require("zigbee-herdsman/dist/adapter/z-stack/unpi/constants");
const {Znp} = require("zigbee-herdsman/dist/adapter/z-stack/znp");
class ZStackNvMemEraser {
constructor(device) {
@@ -13,7 +13,7 @@ class ZStackNvMemEraser {
const attempts = 3;
for (let i = 0; i < attempts; i++) {
try {
await this.znp.request(Subsystem.SYS, 'ping', {capabilities: 1});
await this.znp.request(Subsystem.SYS, "ping", {capabilities: 1});
break;
} catch (e) {
if (attempts - 1 === i) {
@@ -23,10 +23,10 @@ class ZStackNvMemEraser {
}
// Old firmware did not support version, assume it's Z-Stack 1.2 for now.
try {
this.version = (await this.znp.request(Subsystem.SYS, 'version', {})).payload;
} catch (e) {
console.log(`Failed to get zStack version, assuming 1.2`);
this.version = {transportrev: 2, product: 0, majorrel: 2, minorrel: 0, maintrel: 0, revision: ''};
this.version = (await this.znp.request(Subsystem.SYS, "version", {})).payload;
} catch {
console.log("Failed to get zStack version, assuming 1.2");
this.version = {transportrev: 2, product: 0, majorrel: 2, minorrel: 0, maintrel: 0, revision: ""};
}
console.log(`Detected znp version '${ZnpVersion[this.version.product]}' (${JSON.stringify(this.version)})`);
@@ -54,23 +54,23 @@ class ZStackNvMemEraser {
console.log(`Clearing all NVMEM items, from 0 to ${maxNvMemId}`);
for (let id = 0; id <= maxNvMemId; id++) {
let len;
const needOsal = !(this.version.product == ZnpVersion.zStack3x0 && id <= 7);
const needOsal = !(this.version.product === ZnpVersion.zStack3x0 && id <= 7);
if (needOsal) {
const lengthRes = await this.znp.request(Subsystem.SYS, 'osalNvLength', {id: id});
len = lengthRes.payload['length'];
const lengthRes = await this.znp.request(Subsystem.SYS, "osalNvLength", {id: id});
len = lengthRes.payload.length;
} else {
const lengthRes = await this.znp.request(Subsystem.SYS, 'nvLength', {sysid: NvSystemIds.ZSTACK, itemid: id, subid: 0});
len = lengthRes.payload['len'];
const lengthRes = await this.znp.request(Subsystem.SYS, "nvLength", {sysid: NvSystemIds.ZSTACK, itemid: id, subid: 0});
len = lengthRes.payload.len;
}
if (len != 0) {
if (len !== 0) {
console.log(`NVMEM item #${id} - deleting, size: ${len}`);
if (needOsal) {
await this.znp.request(Subsystem.SYS, 'osalNvDelete', {id: id, len: len}, undefined, undefined, [
await this.znp.request(Subsystem.SYS, "osalNvDelete", {id: id, len: len}, undefined, undefined, [
ZnpCommandStatus.SUCCESS,
ZnpCommandStatus.NV_ITEM_INITIALIZED,
]);
} else {
await this.znp.request(Subsystem.SYS, 'nvDelete', {sysid: NvSystemIds.ZSTACK, itemid: id, subid: 0}, undefined, undefined, [
await this.znp.request(Subsystem.SYS, "nvDelete", {sysid: NvSystemIds.ZSTACK, itemid: id, subid: 0}, undefined, undefined, [
ZnpCommandStatus.SUCCESS,
ZnpCommandStatus.NV_ITEM_INITIALIZED,
]);
@@ -82,10 +82,10 @@ class ZStackNvMemEraser {
}
}
const processArgs = process.argv.slice(2);
if (processArgs.length != 1) {
console.log('ZStack NVMEM eraser.');
console.log('Usage:');
console.log(' node zStackEraseAllNvMem.js <SERIAL_PORT>');
if (processArgs.length !== 1) {
console.log("ZStack NVMEM eraser.");
console.log("Usage:");
console.log(" node zStackEraseAllNvMem.js <SERIAL_PORT>");
process.exit(1);
}
@@ -0,0 +1,24 @@
const mockDevices = [
{
mock: 1,
model: "external_converters_device_1",
zigbeeModel: ["external_converter_device_1"],
vendor: "external_1",
description: "external_1",
fromZigbee: [],
toZigbee: [],
exposes: [],
},
{
mock: 2,
model: "external_converters_device_2",
zigbeeModel: ["external_converter_device_2"],
vendor: "external_2",
description: "external_2",
fromZigbee: [],
toZigbee: [],
exposes: [],
},
];
module.exports = mockDevices;
@@ -0,0 +1,14 @@
const {posix} = require("node:path");
const mockDevice = {
mock: true,
zigbeeModel: ["external_converter_device"],
vendor: "external",
model: "external_converter_device",
description: posix.join("external", "converter"),
fromZigbee: [],
toZigbee: [],
exposes: [],
};
module.exports = mockDevice;
@@ -0,0 +1,22 @@
export default [
{
mock: 1,
model: "external_converters_device_1",
zigbeeModel: ["external_converter_device_1"],
vendor: "external_1",
description: "external_1",
fromZigbee: [],
toZigbee: [],
exposes: [],
},
{
mock: 2,
model: "external_converters_device_2",
zigbeeModel: ["external_converter_device_2"],
vendor: "external_2",
description: "external_2",
fromZigbee: [],
toZigbee: [],
exposes: [],
},
];
@@ -0,0 +1,12 @@
import {posix} from "node:path";
import {identify} from "zigbee-herdsman-converters/lib/modernExtend";
export default {
mock: true,
zigbeeModel: ["external_converter_device"],
vendor: "external",
model: "external_converter_device",
description: posix.join("external", "converter"),
extend: [identify()],
};
@@ -1,24 +0,0 @@
const mockDevices = [
{
mock: 1,
model: 'external_converters_device_1',
zigbeeModel: ['external_converter_device_1'],
vendor: 'external_1',
description: 'external_1',
fromZigbee: [],
toZigbee: [],
exposes: [],
},
{
mock: 2,
model: 'external_converters_device_2',
zigbeeModel: ['external_converter_device_2'],
vendor: 'external_2',
description: 'external_2',
fromZigbee: [],
toZigbee: [],
exposes: [],
},
];
module.exports = mockDevices;
@@ -1,12 +0,0 @@
const mockDevice = {
mock: true,
zigbeeModel: ['external_converter_device'],
vendor: 'external',
model: 'external_converter_device',
description: 'external',
fromZigbee: [],
toZigbee: [],
exposes: [],
};
module.exports = mockDevice;
@@ -0,0 +1,16 @@
class Example2 {
constructor(_zigbee, mqtt, _state, _publishEntityState, _eventBus) {
this.mqtt = mqtt;
this.mqtt.publish("example2/extension", "call2 from constructor");
}
start() {
this.mqtt.publish("example2/extension", "call2 from start");
}
stop() {
this.mqtt.publish("example/extension", "call2 from stop");
}
}
module.exports = Example2;
@@ -0,0 +1,16 @@
class Example {
constructor(_zigbee, mqtt, _state, _publishEntityState, _eventBus) {
this.mqtt = mqtt;
this.mqtt.publish("example/extension", "call from constructor");
}
start() {
this.mqtt.publish("example/extension", "call from start");
}
stop() {
this.mqtt.publish("example/extension", "call from stop");
}
}
module.exports = Example;
@@ -1,16 +0,0 @@
class Example2 {
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
this.mqtt = mqtt;
this.mqtt.publish('example2/extension', 'call2 from constructor');
}
start() {
this.mqtt.publish('example2/extension', 'call2 from start');
}
stop() {
this.mqtt.publish('example/extension', 'call2 from stop');
}
}
module.exports = Example2;
@@ -1,16 +0,0 @@
class Example {
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
this.mqtt = mqtt;
this.mqtt.publish('example/extension', 'call from constructor');
}
start() {
this.mqtt.publish('example/extension', 'call from start');
}
stop() {
this.mqtt.publish('example/extension', 'call from stop');
}
}
module.exports = Example;
@@ -0,0 +1,14 @@
export default class Example2 {
constructor(_zigbee, mqtt, _state, _publishEntityState, _eventBus) {
this.mqtt = mqtt;
this.mqtt.publish("example2/extension", "call2 from constructor");
}
start() {
this.mqtt.publish("example2/extension", "call2 from start");
}
stop() {
this.mqtt.publish("example/extension", "call2 from stop");
}
}
@@ -0,0 +1,17 @@
export default class Example {
constructor(_zigbee, mqtt, _state, _publishEntityState, _eventBus) {
this.mqtt = mqtt;
this.mqtt.publish("example/extension", "call from constructor");
this.counter = 0;
}
start() {
this.mqtt.publish("example/extension", "call from start");
this.mqtt.publish("example/extension/counter", `start ${this.counter++}`);
}
stop() {
this.mqtt.publish("example/extension", "call from stop");
this.mqtt.publish("example/extension/counter", `stop ${--this.counter}`);
}
}
+435 -368
View File
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -1,25 +1,25 @@
import path from 'node:path';
import path from "node:path";
import tmp from 'tmp';
import tmp from "tmp";
import data from '../lib/util/data';
import data from "../lib/util/data";
describe('Data', () => {
describe('Get path', () => {
it('Should return correct path', () => {
const expected = path.normalize(path.join(__dirname, '..', 'data'));
describe("Data", () => {
describe("Get path", () => {
it("Should return correct path", () => {
const expected = path.normalize(path.join(__dirname, "..", "data"));
const actual = data.getPath();
expect(actual).toBe(expected);
});
it('Should return correct path when ZIGBEE2MQTT_DATA set', () => {
it("Should return correct path when ZIGBEE2MQTT_DATA set", () => {
const expected = tmp.dirSync().name;
process.env.ZIGBEE2MQTT_DATA = expected;
data._testReload();
const actual = data.getPath();
expect(actual).toBe(expected);
expect(data.joinPath('test')).toStrictEqual(path.join(expected, 'test'));
expect(data.joinPath('/test')).toStrictEqual(path.resolve(expected, '/test'));
expect(data.joinPath("test")).toStrictEqual(path.join(expected, "test"));
expect(data.joinPath("/test")).toStrictEqual(path.resolve(expected, "/test"));
delete process.env.ZIGBEE2MQTT_DATA;
data._testReload();
});
+370 -83
View File
@@ -1,17 +1,17 @@
import * as data from '../mocks/data';
import {mockLogger} from '../mocks/logger';
import {events as mockMQTTEvents, mockMQTTPublishAsync} from '../mocks/mqtt';
import {flushPromises} from '../mocks/utils';
import {devices, events as mockZHEvents, returnDevices} from '../mocks/zigbeeHerdsman';
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
import {flushPromises} from "../mocks/utils";
import {devices, events as mockZHEvents, returnDevices} from "../mocks/zigbeeHerdsman";
import assert from 'node:assert';
import assert from "node:assert";
import stringify from 'json-stable-stringify-without-jsonify';
import stringify from "json-stable-stringify-without-jsonify";
import {Controller} from '../../lib/controller';
import Availability from '../../lib/extension/availability';
import * as settings from '../../lib/util/settings';
import utils from '../../lib/util/utils';
import {Controller} from "../../lib/controller";
import Availability from "../../lib/extension/availability";
import * as settings from "../../lib/util/settings";
import utils from "../../lib/util/utils";
const mocksClear = [mockMQTTPublishAsync, mockLogger.warning, mockLogger.info];
@@ -28,12 +28,12 @@ returnDevices.push(
devices.hue_twilight.ieeeAddr,
);
describe('Extension: Availability', () => {
describe("Extension: Availability", () => {
let controller: Controller;
const resetExtension = async (): Promise<void> => {
await controller.enableDisableExtension(false, 'Availability');
await controller.enableDisableExtension(true, 'Availability');
await controller.removeExtension(controller.getExtension("Availability")!);
await controller.addExtension(new Availability(...controller.extensionArgs));
};
const setTimeAndAdvanceTimers = async (value: number): Promise<void> => {
@@ -42,10 +42,10 @@ describe('Extension: Availability', () => {
};
beforeAll(async () => {
vi.spyOn(utils, 'sleep').mockImplementation(vi.fn());
vi.spyOn(utils, "sleep").mockImplementation(vi.fn());
vi.useFakeTimers();
settings.reRead();
settings.set(['availability'], {enabled: true});
settings.set(["availability"], {enabled: true});
controller = new Controller(vi.fn(), vi.fn());
await controller.start();
await flushPromises();
@@ -55,35 +55,39 @@ describe('Extension: Availability', () => {
vi.setSystemTime(utils.minutes(1));
data.writeDefaultConfiguration();
settings.reRead();
settings.set(['availability'], {enabled: true});
settings.set(['devices', devices.bulb_color_2.ieeeAddr, 'availability'], false);
Object.values(devices).forEach((d) => (d.lastSeen = utils.minutes(1)));
mocksClear.forEach((m) => m.mockClear());
settings.set(["availability"], {enabled: true});
settings.set(["devices", devices.bulb_color_2.ieeeAddr, "availability"], false);
settings.set(["devices", devices.hue_twilight.ieeeAddr, "availability"], {max_jitter: 1000});
settings.set(["devices", devices.GLEDOPTO_2ID.ieeeAddr, "availability"], {backoff: false});
settings.set(["devices", devices.QBKG03LM.ieeeAddr, "availability"], {pause_on_backoff_gt: 1.5});
for (const key in devices) devices[key as keyof typeof devices].lastSeen = utils.minutes(1);
for (const mock of mocksClear) mock.mockClear();
await resetExtension();
Object.values(devices).forEach((d) => d.ping.mockClear());
for (const key in devices) devices[key as keyof typeof devices].ping.mockClear();
});
afterEach(async () => {});
afterAll(async () => {
await controller.stop();
await controller?.stop();
await flushPromises();
vi.useRealTimers();
});
it('Should publish availability on startup for device where it is enabled for', async () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', stringify({state: 'online'}), {
it("Should publish availability on startup for device where it is enabled for", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb_color/availability", stringify({state: "online"}), {
retain: true,
qos: 1,
});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/remote/availability', stringify({state: 'online'}), {retain: true, qos: 1});
expect(mockMQTTPublishAsync).not.toHaveBeenCalledWith('zigbee2mqtt/bulb_color_2/availability', stringify({state: 'online'}), {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/remote/availability", stringify({state: "online"}), {retain: true, qos: 1});
expect(mockMQTTPublishAsync).not.toHaveBeenCalledWith("zigbee2mqtt/bulb_color_2/availability", stringify({state: "online"}), {
retain: true,
qos: 1,
});
});
it('Should ping on startup for enabled and unavailable devices', async () => {
settings.set(['devices', devices.bulb_color_2.ieeeAddr, 'availability'], true);
it("Should ping on startup for enabled and unavailable devices", async () => {
settings.set(["devices", devices.bulb_color_2.ieeeAddr, "availability"], true);
devices.bulb_color.lastSeen = Date.now() - utils.minutes(20);
devices.bulb_color_2.lastSeen = Date.now();
await resetExtension();
@@ -93,9 +97,9 @@ describe('Extension: Availability', () => {
expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(0); // enabled/available
});
it('Should not ping on startup for available or disabled devices', async () => {
settings.set(['devices', devices.bulb_color_2.ieeeAddr, 'availability'], true);
settings.set(['devices', devices.bulb_color_2.ieeeAddr, 'disabled'], true);
it("Should not ping on startup for available or disabled devices", async () => {
settings.set(["devices", devices.bulb_color_2.ieeeAddr, "availability"], true);
settings.set(["devices", devices.bulb_color_2.ieeeAddr, "disabled"], true);
devices.bulb_color.lastSeen = Date.now();
devices.bulb_color_2.lastSeen = Date.now() - utils.minutes(20);
await resetExtension();
@@ -105,7 +109,7 @@ describe('Extension: Availability', () => {
expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(0); // disabled/unavailable
});
it('Should publish offline for active device when not seen for 10 minutes', async () => {
it("Should publish offline for active device when not seen for 10 minutes", async () => {
mockMQTTPublishAsync.mockClear();
await setTimeAndAdvanceTimers(utils.minutes(5));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
@@ -113,34 +117,34 @@ describe('Extension: Availability', () => {
await setTimeAndAdvanceTimers(utils.minutes(7));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', stringify({state: 'offline'}), {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb_color/availability", stringify({state: "offline"}), {
retain: true,
qos: 1,
});
});
it('Shouldnt do anything for a device when availability: false is set for device', async () => {
it("Shouldnt do anything for a device when availability: false is set for device", async () => {
await mockZHEvents.lastSeenChanged({device: devices.bulb_color_2}); // Coverage satisfaction
await setTimeAndAdvanceTimers(utils.minutes(12));
expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(0);
});
it('Should publish offline for passive device when not seen for 25 hours', async () => {
it("Should publish offline for passive device when not seen for 25 hours", async () => {
mockMQTTPublishAsync.mockClear();
await setTimeAndAdvanceTimers(utils.hours(26));
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/remote/availability', stringify({state: 'offline'}), {retain: true, qos: 1});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/remote/availability", stringify({state: "offline"}), {retain: true, qos: 1});
});
it('Should reset ping timer when device last seen changes for active device', async () => {
it("Should reset ping timer when device last seen changes for active device", async () => {
mockMQTTPublishAsync.mockClear();
await setTimeAndAdvanceTimers(utils.minutes(5));
await setTimeAndAdvanceTimers(utils.minutes(6));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', stringify({state: 'offline'}), {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb_color/availability", stringify({state: "offline"}), {
retain: true,
qos: 1,
});
@@ -153,13 +157,13 @@ describe('Extension: Availability', () => {
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
});
it('Should ping again when first ping fails', async () => {
it("Should ping again when first ping fails", async () => {
mockMQTTPublishAsync.mockClear();
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
devices.bulb_color.ping.mockImplementationOnce(() => {
throw new Error('failed');
throw new Error("failed");
});
await setTimeAndAdvanceTimers(utils.minutes(15));
@@ -168,14 +172,14 @@ describe('Extension: Availability', () => {
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(2, false);
});
it('Should reset ping timer when device last seen changes for passive device', async () => {
it("Should reset ping timer when device last seen changes for passive device", async () => {
mockMQTTPublishAsync.mockClear();
await setTimeAndAdvanceTimers(utils.hours(24));
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
await mockZHEvents.lastSeenChanged({device: devices.remote});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/remote/availability', stringify({state: 'offline'}), {retain: true, qos: 1});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/remote/availability", stringify({state: "offline"}), {retain: true, qos: 1});
await setTimeAndAdvanceTimers(utils.hours(25));
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
@@ -184,11 +188,11 @@ describe('Extension: Availability', () => {
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
});
it('Should immediately mark device as online when it lastSeen changes', async () => {
it("Should immediately mark device as online when it lastSeen changes", async () => {
mockMQTTPublishAsync.mockClear();
await setTimeAndAdvanceTimers(utils.minutes(15));
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', stringify({state: 'offline'}), {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb_color/availability", stringify({state: "offline"}), {
retain: true,
qos: 1,
});
@@ -196,14 +200,14 @@ describe('Extension: Availability', () => {
devices.bulb_color.lastSeen = Date.now();
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', stringify({state: 'online'}), {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb_color/availability", stringify({state: "online"}), {
retain: true,
qos: 1,
});
});
it('Should allow to change availability timeout via device options', async () => {
settings.set(['devices', '0x000b57fffec6a5b3', 'availability'], {timeout: 40});
it("Should allow to change availability timeout via device options", async () => {
settings.set(["devices", "0x000b57fffec6a5b3", "availability"], {timeout: 40});
await resetExtension();
devices.bulb_color.ping.mockClear();
@@ -214,8 +218,8 @@ describe('Extension: Availability', () => {
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
});
it('Should not ping disabled devices', async () => {
settings.set(['devices', devices.bulb_color.ieeeAddr, 'disabled'], true);
it("Should not ping disabled devices", async () => {
settings.set(["devices", devices.bulb_color.ieeeAddr, "disabled"], true);
await resetExtension();
devices.bulb_color.ping.mockClear();
@@ -223,8 +227,8 @@ describe('Extension: Availability', () => {
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
});
it('Should allow to change availability timeout via avaiability options', async () => {
settings.set(['availability', 'active', 'timeout'], 30);
it("Should allow to change availability timeout via avaiability options", async () => {
settings.set(["availability", "active", "timeout"], 30);
await resetExtension();
devices.bulb_color.ping.mockClear();
@@ -235,7 +239,7 @@ describe('Extension: Availability', () => {
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
});
it('Should stop pinging device when it leaves', async () => {
it("Should stop pinging device when it leaves", async () => {
await setTimeAndAdvanceTimers(utils.minutes(9));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
@@ -246,19 +250,19 @@ describe('Extension: Availability', () => {
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
});
it('Should stop pinging device when it is removed', async () => {
it("Should stop pinging device when it is removed", async () => {
await setTimeAndAdvanceTimers(utils.minutes(9));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/remove', stringify({id: 'bulb_color'}));
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/remove", stringify({id: "bulb_color"}));
await flushPromises();
await setTimeAndAdvanceTimers(utils.minutes(3));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
});
it('Should allow to be disabled', async () => {
settings.set(['availability'], {enabled: false});
it("Should allow to be disabled", async () => {
settings.set(["availability"], {enabled: false});
await resetExtension();
devices.bulb_color.ping.mockClear();
@@ -266,9 +270,9 @@ describe('Extension: Availability', () => {
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
});
it('Should allow to enable availability for just one device', async () => {
settings.set(['availability'], {enabled: false});
settings.set(['devices', devices.bulb_color.ieeeAddr, 'availability'], true);
it("Should allow to enable availability for just one device", async () => {
settings.set(["availability"], {enabled: false});
settings.set(["devices", devices.bulb_color.ieeeAddr, "availability"], true);
await resetExtension();
devices.bulb_color.ping.mockClear();
@@ -277,11 +281,11 @@ describe('Extension: Availability', () => {
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
});
it('Should retrieve device state when it reconnects', async () => {
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'});
controller.state.set(device, {state: "OFF"});
const endpoint = devices.bulb_color.getEndpoint(1);
assert(endpoint);
@@ -297,57 +301,57 @@ describe('Extension: Availability', () => {
await setTimeAndAdvanceTimers(utils.seconds(2));
expect(endpoint.read).toHaveBeenCalledTimes(1);
expect(endpoint.read).toHaveBeenCalledWith('genOnOff', ['onOff']);
expect(endpoint.read).toHaveBeenCalledWith("genOnOff", ["onOff"]);
endpoint.read.mockClear();
await mockZHEvents.deviceAnnounce({device: devices.bulb_color});
await flushPromises();
endpoint.read.mockImplementationOnce(() => {
throw new Error('');
throw new Error("");
});
await setTimeAndAdvanceTimers(utils.seconds(3));
expect(endpoint.read).toHaveBeenCalledTimes(1);
});
it('Should republish availability when device is renamed', async () => {
it("Should republish availability when device is renamed", async () => {
mockMQTTPublishAsync.mockClear();
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/rename', stringify({from: 'bulb_color', to: 'bulb_new_name'}));
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/rename", stringify({from: "bulb_color", to: "bulb_new_name"}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', '', {retain: true, qos: 1});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_new_name/availability', stringify({state: 'online'}), {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb_color/availability", "", {retain: true, qos: 1});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb_new_name/availability", stringify({state: "online"}), {
retain: true,
qos: 1,
});
await setTimeAndAdvanceTimers(utils.hours(12));
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_new_name/availability', stringify({state: 'offline'}), {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb_new_name/availability", stringify({state: "offline"}), {
retain: true,
qos: 1,
});
});
it('Should publish availability payload in JSON format', async () => {
it("Should publish availability payload in JSON format", async () => {
await resetExtension();
devices.remote.ping.mockClear();
mockMQTTPublishAsync.mockClear();
await setTimeAndAdvanceTimers(utils.hours(26));
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/remote/availability', stringify({state: 'offline'}), {retain: true, qos: 1});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/remote/availability", stringify({state: "offline"}), {retain: true, qos: 1});
});
it('Should publish availability for groups', async () => {
settings.set(['devices', devices.bulb_color_2.ieeeAddr, 'availability'], true);
it("Should publish availability for groups", async () => {
settings.set(["devices", devices.bulb_color_2.ieeeAddr, "availability"], true);
await resetExtension();
devices.bulb_color_2.ping.mockClear();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/group_tradfri_remote/availability', stringify({state: 'online'}), {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/group_tradfri_remote/availability", stringify({state: "online"}), {
retain: true,
qos: 1,
});
mockMQTTPublishAsync.mockClear();
await setTimeAndAdvanceTimers(utils.minutes(12));
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/group_tradfri_remote/availability', stringify({state: 'offline'}), {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/group_tradfri_remote/availability", stringify({state: "offline"}), {
retain: true,
qos: 1,
});
@@ -355,17 +359,16 @@ describe('Extension: Availability', () => {
devices.bulb_color_2.lastSeen = Date.now();
await mockZHEvents.lastSeenChanged({device: devices.bulb_color_2});
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/group_tradfri_remote/availability', stringify({state: 'online'}), {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/group_tradfri_remote/availability", stringify({state: "online"}), {
retain: true,
qos: 1,
});
});
it('Should clear the ping queue on stop', async () => {
it("Should clear the ping queue on stop", async () => {
const availability = controller.getExtension("Availability")! as Availability;
// @ts-expect-error private
const availability = controller.extensions.find((extension) => extension instanceof Availability)!;
// @ts-expect-error private
const publishAvailabilitySpy = vi.spyOn(availability, 'publishAvailability');
const publishAvailabilitySpy = vi.spyOn(availability, "publishAvailability");
devices.bulb_color.ping.mockImplementationOnce(() => new Promise((resolve) => setTimeout(resolve, 1000)));
// @ts-expect-error private
@@ -383,12 +386,296 @@ describe('Extension: Availability', () => {
devices.bulb_color.ping = vi.fn(); // ensure reset
});
it('Should prevent instance restart', async () => {
// @ts-expect-error private
const availability = controller.extensions.find((extension) => extension instanceof Availability)!;
it("Should prevent instance restart", async () => {
const availability = controller.getExtension("Availability")! as Availability;
await availability.stop();
await expect(() => availability.start()).rejects.toThrow();
});
it("jitters pings", async () => {
const devicesPings = [
devices.bulb_color.ping,
devices.TS0601_thermostat.ping,
devices.bulb_2.ping,
devices.ZNCZ02LM.ping,
devices.GLEDOPTO_2ID.ping,
devices.QBKG03LM.ping,
devices.hue_twilight.ping,
];
let called = 0;
let lastCalled = 0;
await setTimeAndAdvanceTimers(utils.minutes(10));
for (const p of devicesPings) {
called += p.mock.calls.length;
}
expect(called).toStrictEqual(0);
lastCalled = called;
called = 0;
await setTimeAndAdvanceTimers(utils.seconds(2)); // 10:02
expect(devices.hue_twilight.ping).toHaveBeenCalledTimes(1); // max_jitter: 1000
await setTimeAndAdvanceTimers(utils.seconds(8)); // 10:10
for (const p of devicesPings) {
called += p.mock.calls.length;
}
expect(called).toBeGreaterThanOrEqual(0);
expect(called).toBeLessThan(7);
expect(called).toBeGreaterThanOrEqual(lastCalled);
lastCalled = called;
called = 0;
await setTimeAndAdvanceTimers(utils.seconds(20)); // 10:20
for (const p of devicesPings) {
called += p.mock.calls.length;
}
expect(called).toBeGreaterThanOrEqual(lastCalled);
lastCalled = called;
called = 0;
await setTimeAndAdvanceTimers(utils.seconds(35)); // 10:35
for (const p of devicesPings) {
called += p.mock.calls.length;
}
expect(called).toStrictEqual(7);
for (const p of devicesPings) {
expect(p).toHaveBeenCalledTimes(1);
}
});
it("does not trigger backoff on ping success", async () => {
settings.set(["availability", "active", "max_jitter"], 0); // easier testing
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
await setTimeAndAdvanceTimers(utils.minutes(10.5)); // 10:30
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
await setTimeAndAdvanceTimers(utils.minutes(10.5)); // 21:00
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
});
it("triggers backoff on successive ping failures", async () => {
settings.set(["availability", "active", "max_jitter"], 0); // easier testing
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
await mockZHEvents.lastSeenChanged({device: devices.GLEDOPTO_2ID}); // backoff: false
devices.bulb_color.ping
.mockImplementationOnce(() => {
throw new Error("failed");
})
.mockImplementationOnce(() => {
throw new Error("failed");
})
.mockImplementationOnce(() => {
throw new Error("failed");
})
.mockImplementationOnce(() => {
throw new Error("failed");
});
devices.GLEDOPTO_2ID.ping
.mockImplementationOnce(() => {
throw new Error("failed");
})
.mockImplementationOnce(() => {
throw new Error("failed");
});
await setTimeAndAdvanceTimers(utils.minutes(10.5)); // 10:30
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(2, false);
expect(devices.GLEDOPTO_2ID.ping).toHaveBeenCalledTimes(2);
expect(devices.GLEDOPTO_2ID.ping).toHaveBeenNthCalledWith(1, true);
expect(devices.GLEDOPTO_2ID.ping).toHaveBeenNthCalledWith(2, false);
await setTimeAndAdvanceTimers(utils.minutes(14.5)); // 25:00
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
expect(devices.GLEDOPTO_2ID.ping).toHaveBeenCalledTimes(3);
await setTimeAndAdvanceTimers(utils.minutes(0.5)); // 25:30
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(3);
expect(devices.GLEDOPTO_2ID.ping).toHaveBeenCalledTimes(3);
await setTimeAndAdvanceTimers(utils.minutes(29.5)); // 55:00
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(3);
expect(devices.GLEDOPTO_2ID.ping).toHaveBeenCalledTimes(6);
await setTimeAndAdvanceTimers(utils.minutes(0.5)); // 55:30
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(4);
expect(devices.GLEDOPTO_2ID.ping).toHaveBeenCalledTimes(6);
await setTimeAndAdvanceTimers(utils.minutes(59.5)); // 115:00
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(4);
expect(devices.GLEDOPTO_2ID.ping).toHaveBeenCalledTimes(12);
await setTimeAndAdvanceTimers(utils.minutes(0.5)); // 115:30
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(5);
expect(devices.GLEDOPTO_2ID.ping).toHaveBeenCalledTimes(12);
// backoff was reset (4 mocks done)
await setTimeAndAdvanceTimers(utils.minutes(10.5)); // 126:00
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(6);
expect(devices.GLEDOPTO_2ID.ping).toHaveBeenCalledTimes(13);
});
it("resets backoff on last seen", async () => {
settings.set(["availability", "active", "max_jitter"], 0); // easier testing
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
devices.bulb_color.ping
.mockImplementationOnce(() => {
throw new Error("failed");
})
.mockImplementationOnce(() => {
throw new Error("failed");
});
await setTimeAndAdvanceTimers(utils.minutes(10.5)); // 10:30
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(2, false);
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
await setTimeAndAdvanceTimers(utils.minutes(10.5)); // 21:00
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(3);
});
it("pauses when backoff reaches configured value and unpauses on last seen", async () => {
settings.set(["availability", "active", "max_jitter"], 0); // easier testing
settings.set(["availability", "active", "pause_on_backoff_gt"], 3);
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
await mockZHEvents.lastSeenChanged({device: devices.QBKG03LM}); // pause_on_backoff_gt: 1.5
devices.bulb_color.ping
.mockImplementationOnce(() => {
throw new Error("failed");
})
.mockImplementationOnce(() => {
throw new Error("failed");
})
.mockImplementationOnce(() => {
throw new Error("failed");
})
.mockImplementationOnce(() => {
throw new Error("failed");
});
devices.QBKG03LM.ping
.mockImplementationOnce(() => {
throw new Error("failed");
})
.mockImplementationOnce(() => {
throw new Error("failed");
})
.mockImplementationOnce(() => {
throw new Error("failed");
})
.mockImplementationOnce(() => {
throw new Error("failed");
});
await setTimeAndAdvanceTimers(utils.minutes(10.5)); // 10:30
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(2, false);
expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(2);
expect(devices.QBKG03LM.ping).toHaveBeenNthCalledWith(1, true);
expect(devices.QBKG03LM.ping).toHaveBeenNthCalledWith(2, false);
await setTimeAndAdvanceTimers(utils.minutes(14.5)); // 25:00
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(2);
await setTimeAndAdvanceTimers(utils.minutes(0.5)); // 25:30
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(3);
expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(3);
await setTimeAndAdvanceTimers(utils.minutes(29.5)); // 55:00
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(3);
expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(3);
await setTimeAndAdvanceTimers(utils.minutes(0.5)); // 55:30
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(4);
expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(3);
await setTimeAndAdvanceTimers(utils.minutes(100));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(4);
expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(3);
await setTimeAndAdvanceTimers(utils.minutes(100));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(4);
expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(3);
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
await mockZHEvents.lastSeenChanged({device: devices.QBKG03LM});
await setTimeAndAdvanceTimers(utils.minutes(10.5));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(5);
expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(4);
});
it("allows to disable backoff", async () => {
settings.set(["availability", "active", "max_jitter"], 0); // easier testing
settings.set(["availability", "active", "backoff"], false);
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
devices.bulb_color.ping
.mockImplementationOnce(() => {
throw new Error("failed");
})
.mockImplementationOnce(() => {
throw new Error("failed");
})
.mockImplementationOnce(() => {
throw new Error("failed");
})
.mockImplementationOnce(() => {
throw new Error("failed");
});
await setTimeAndAdvanceTimers(utils.minutes(10.5)); // 10:30
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(2, false);
await setTimeAndAdvanceTimers(utils.minutes(9.5)); // 20:00
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
await setTimeAndAdvanceTimers(utils.minutes(0.5)); // 20:30
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(3);
await setTimeAndAdvanceTimers(utils.minutes(9.5)); // 30:00
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(3);
await setTimeAndAdvanceTimers(utils.minutes(0.5)); // 30:30
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(4);
await setTimeAndAdvanceTimers(utils.minutes(9.5)); // 40:00
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(4);
await setTimeAndAdvanceTimers(utils.minutes(0.5)); // 40:30
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(5);
});
});
+253 -250
View File
@@ -1,14 +1,15 @@
import * as data from '../mocks/data';
import {mockDebounce} from '../mocks/debounce';
import {mockLogger} from '../mocks/logger';
import {events as mockMQTTEvents, mockMQTTPublishAsync} from '../mocks/mqtt';
import {flushPromises} from '../mocks/utils';
import {Device, devices, groups, events as mockZHEvents} from '../mocks/zigbeeHerdsman';
import * as data from "../mocks/data";
import {mockDebounce} from "../mocks/debounce";
import {mockLogger} from "../mocks/logger";
import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
import {flushPromises} from "../mocks/utils";
import {type Device, devices, groups, events as mockZHEvents} from "../mocks/zigbeeHerdsman";
import stringify from 'json-stable-stringify-without-jsonify';
import stringify from "json-stable-stringify-without-jsonify";
import {Controller} from '../../lib/controller';
import * as settings from '../../lib/util/settings';
import {Controller} from "../../lib/controller";
import Bind from "../../lib/extension/bind";
import * as settings from "../../lib/util/settings";
const mocksClear = [
mockDebounce,
@@ -18,12 +19,12 @@ const mocksClear = [
devices.bulb_color_2.getEndpoint(1)!.read,
];
describe('Extension: Bind', () => {
describe("Extension: Bind", () => {
let controller: Controller;
const resetExtension = async (): Promise<void> => {
await controller.enableDisableExtension(false, 'Bind');
await controller.enableDisableExtension(true, 'Bind');
await controller.removeExtension(controller.getExtension("Bind")!);
await controller.addExtension(new Bind(...controller.extensionArgs));
};
const mockClear = (device: Device): void => {
@@ -49,14 +50,16 @@ describe('Extension: Bind', () => {
settings.reRead();
groups.group_1.members = [];
await resetExtension();
mocksClear.forEach((m) => m.mockClear());
for (const mock of mocksClear) mock.mockClear();
});
afterAll(async () => {
await controller?.stop();
await flushPromises();
vi.useRealTimers();
});
it('Should bind to device and configure reporting', async () => {
it("Should bind to device and configure reporting", async () => {
const device = devices.remote;
const target = devices.bulb_color.getEndpoint(1)!;
const endpoint = device.getEndpoint(1)!;
@@ -65,68 +68,68 @@ describe('Extension: Bind', () => {
const originalDeviceOutputClusters = device.getEndpoint(1)!.outputClusters;
device.getEndpoint(1)!.outputClusters = [...device.getEndpoint(1)!.outputClusters, 768];
const originalTargetBinds = target.binds;
target.binds = [{cluster: {name: 'genLevelCtrl'}, target: devices.coordinator.getEndpoint(1)!}];
target.binds = [{cluster: {name: "genLevelCtrl"}, target: devices.coordinator.getEndpoint(1)!}];
target.getClusterAttributeValue.mockReturnValueOnce(undefined);
mockClear(device);
target.configureReporting.mockImplementationOnce(() => {
throw new Error('timeout');
throw new Error("timeout");
});
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({transaction: '1234', from: 'remote', to: 'bulb_color'}));
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/bind", stringify({transaction: "1234", from: "remote", to: "bulb_color"}));
await flushPromises();
expect(target.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities']);
expect(target.read).toHaveBeenCalledWith("lightingColorCtrl", ["colorCapabilities"]);
expect(endpoint.bind).toHaveBeenCalledTimes(4);
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
expect(endpoint.bind).toHaveBeenCalledWith('lightingColorCtrl', target);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target);
expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target);
expect(endpoint.bind).toHaveBeenCalledWith("lightingColorCtrl", target);
expect(target.configureReporting).toHaveBeenCalledTimes(3);
expect(target.configureReporting).toHaveBeenCalledWith('genOnOff', [
{attribute: 'onOff', maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
expect(target.configureReporting).toHaveBeenCalledWith("genOnOff", [
{attribute: "onOff", maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
]);
expect(target.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
{attribute: 'currentLevel', maximumReportInterval: 3600, minimumReportInterval: 5, reportableChange: 1},
expect(target.configureReporting).toHaveBeenCalledWith("genLevelCtrl", [
{attribute: "currentLevel", maximumReportInterval: 3600, minimumReportInterval: 5, reportableChange: 1},
]);
expect(target.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
{attribute: 'colorTemperature', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
{attribute: 'currentX', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
{attribute: 'currentY', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
expect(target.configureReporting).toHaveBeenCalledWith("lightingColorCtrl", [
{attribute: "colorTemperature", minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
{attribute: "currentX", minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
{attribute: "currentY", minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
]);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
"zigbee2mqtt/bridge/response/device/bind",
stringify({
transaction: '1234',
transaction: "1234",
data: {
from: 'remote',
from_endpoint: 'default',
to: 'bulb_color',
clusters: ['genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl'],
from: "remote",
from_endpoint: "default",
to: "bulb_color",
clusters: ["genScenes", "genOnOff", "genLevelCtrl", "lightingColorCtrl"],
failed: [],
},
status: 'ok',
status: "ok",
}),
{retain: false, qos: 0},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), {retain: true, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), {retain: true, qos: 0});
// Teardown
target.binds = originalTargetBinds;
device.getEndpoint(1)!.outputClusters = originalDeviceOutputClusters;
});
it('Should throw error on invalid payload', async () => {
it("Should throw error on invalid payload", async () => {
mockMQTTPublishAsync.mockClear();
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({fromz: 'remote', to: 'bulb_color'}));
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/bind", stringify({fromz: "remote", to: "bulb_color"}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({data: {}, status: 'error', error: 'Invalid payload'}),
"zigbee2mqtt/bridge/response/device/bind",
stringify({data: {}, status: "error", error: "Invalid payload"}),
{retain: false, qos: 0},
);
});
it('Filters out unsupported clusters for reporting setup', async () => {
it("Filters out unsupported clusters for reporting setup", async () => {
const device = devices.remote;
const target = devices.bulb_color.getEndpoint(1)!;
const endpoint = device.getEndpoint(1)!;
@@ -142,49 +145,49 @@ describe('Extension: Bind', () => {
const originalTargetOutputClusters = target.outputClusters;
target.outputClusters = [...target.outputClusters, 8];
const originalTargetBinds = target.binds;
target.binds = [{cluster: {name: 'genLevelCtrl'}, target: devices.coordinator.getEndpoint(1)!}];
target.binds = [{cluster: {name: "genLevelCtrl"}, target: devices.coordinator.getEndpoint(1)!}];
target.getClusterAttributeValue.mockReturnValueOnce(undefined);
mockClear(device);
target.configureReporting.mockImplementationOnce(() => {
throw new Error('timeout');
throw new Error("timeout");
});
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({transaction: '1234', from: 'remote', to: 'bulb_color'}));
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/bind", stringify({transaction: "1234", from: "remote", to: "bulb_color"}));
await flushPromises();
expect(target.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities']);
expect(target.read).toHaveBeenCalledWith("lightingColorCtrl", ["colorCapabilities"]);
expect(endpoint.bind).toHaveBeenCalledTimes(4);
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
expect(endpoint.bind).toHaveBeenCalledWith('lightingColorCtrl', target);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target);
expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target);
expect(endpoint.bind).toHaveBeenCalledWith("lightingColorCtrl", target);
expect(target.configureReporting).toHaveBeenCalledTimes(2);
expect(target.configureReporting).toHaveBeenCalledWith('genOnOff', [
{attribute: 'onOff', maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
expect(target.configureReporting).toHaveBeenCalledWith("genOnOff", [
{attribute: "onOff", maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
]);
// expect(target.configureReporting).toHaveBeenCalledWith("genLevelCtrl",[{"attribute": "currentLevel", "maximumReportInterval": 3600, "minimumReportInterval": 5, "reportableChange": 1}]);
expect(target.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
{attribute: 'colorTemperature', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
{attribute: 'currentX', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
{attribute: 'currentY', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
expect(target.configureReporting).toHaveBeenCalledWith("lightingColorCtrl", [
{attribute: "colorTemperature", minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
{attribute: "currentX", minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
{attribute: "currentY", minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
]);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
"zigbee2mqtt/bridge/response/device/bind",
stringify({
transaction: '1234',
transaction: "1234",
data: {
from: 'remote',
from_endpoint: 'default',
to: 'bulb_color',
clusters: ['genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl'],
from: "remote",
from_endpoint: "default",
to: "bulb_color",
clusters: ["genScenes", "genOnOff", "genLevelCtrl", "lightingColorCtrl"],
failed: [],
},
status: 'ok',
status: "ok",
}),
{retain: false, qos: 0},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), {retain: true, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), {retain: true, qos: 0});
// Teardown
target.binds = originalTargetBinds;
@@ -194,7 +197,7 @@ describe('Extension: Bind', () => {
device.getEndpoint(1)!.outputClusters = originalDeviceOutputClusters;
});
it('Filters out reporting setup based on bind status', async () => {
it("Filters out reporting setup based on bind status", async () => {
const device = devices.remote;
const target = devices.bulb_color.getEndpoint(1)!;
const endpoint = device.getEndpoint(1)!;
@@ -203,58 +206,58 @@ describe('Extension: Bind', () => {
const originalDeviceOutputClusters = device.getEndpoint(1)!.outputClusters;
device.getEndpoint(1)!.outputClusters = [...device.getEndpoint(1)!.outputClusters, 768];
const originalTargetBinds = target.binds;
target.binds = [{cluster: {name: 'genLevelCtrl'}, target: devices.coordinator.getEndpoint(1)!}];
target.binds = [{cluster: {name: "genLevelCtrl"}, target: devices.coordinator.getEndpoint(1)!}];
target.getClusterAttributeValue.mockReturnValueOnce(undefined);
mockClear(device);
target.configureReporting.mockImplementationOnce(() => {
throw new Error('timeout');
throw new Error("timeout");
});
const originalTargetCR = target.configuredReportings;
target.configuredReportings = [
{
cluster: {name: 'genLevelCtrl'},
attribute: {name: 'currentLevel', ID: 0},
cluster: {name: "genLevelCtrl"},
attribute: {name: "currentLevel", ID: 0},
minimumReportInterval: 0,
maximumReportInterval: 3600,
reportableChange: 0,
},
];
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({transaction: '1234', from: 'remote', to: 'bulb_color'}));
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/bind", stringify({transaction: "1234", from: "remote", to: "bulb_color"}));
await flushPromises();
expect(target.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities']);
expect(target.read).toHaveBeenCalledWith("lightingColorCtrl", ["colorCapabilities"]);
expect(endpoint.bind).toHaveBeenCalledTimes(4);
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
expect(endpoint.bind).toHaveBeenCalledWith('lightingColorCtrl', target);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target);
expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target);
expect(endpoint.bind).toHaveBeenCalledWith("lightingColorCtrl", target);
expect(target.configureReporting).toHaveBeenCalledTimes(2);
expect(target.configureReporting).toHaveBeenCalledWith('genOnOff', [
{attribute: 'onOff', maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
expect(target.configureReporting).toHaveBeenCalledWith("genOnOff", [
{attribute: "onOff", maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
]);
// expect(target.configureReporting).toHaveBeenCalledWith("genLevelCtrl",[{"attribute": "currentLevel", "maximumReportInterval": 3600, "minimumReportInterval": 5, "reportableChange": 1}]);
expect(target.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
{attribute: 'colorTemperature', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
{attribute: 'currentX', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
{attribute: 'currentY', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
expect(target.configureReporting).toHaveBeenCalledWith("lightingColorCtrl", [
{attribute: "colorTemperature", minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
{attribute: "currentX", minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
{attribute: "currentY", minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
]);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
"zigbee2mqtt/bridge/response/device/bind",
stringify({
transaction: '1234',
transaction: "1234",
data: {
from: 'remote',
from_endpoint: 'default',
to: 'bulb_color',
clusters: ['genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl'],
from: "remote",
from_endpoint: "default",
to: "bulb_color",
clusters: ["genScenes", "genOnOff", "genLevelCtrl", "lightingColorCtrl"],
failed: [],
},
status: 'ok',
status: "ok",
}),
{retain: false, qos: 0},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), {retain: true, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), {retain: true, qos: 0});
// Teardown
target.configuredReportings = originalTargetCR;
@@ -262,84 +265,84 @@ describe('Extension: Bind', () => {
device.getEndpoint(1)!.outputClusters = originalDeviceOutputClusters;
});
it('Should bind only specified clusters', async () => {
it("Should bind only specified clusters", async () => {
const device = devices.remote;
const target = devices.bulb_color.getEndpoint(1)!;
const endpoint = device.getEndpoint(1)!;
mockClear(device);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'bulb_color', clusters: ['genOnOff']}));
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/bind", stringify({from: "remote", to: "bulb_color", clusters: ["genOnOff"]}));
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(1);
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({data: {from: 'remote', from_endpoint: 'default', to: 'bulb_color', clusters: ['genOnOff'], failed: []}, status: 'ok'}),
"zigbee2mqtt/bridge/response/device/bind",
stringify({data: {from: "remote", from_endpoint: "default", to: "bulb_color", clusters: ["genOnOff"], failed: []}, status: "ok"}),
{retain: false, qos: 0},
);
});
it('Should log error when there is nothing to bind', async () => {
it("Should log error when there is nothing to bind", async () => {
const device = devices.bulb_color;
const endpoint = device.getEndpoint(1)!;
mockClear(device);
mockLogger.error.mockClear();
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'button'}));
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/bind", stringify({from: "remote", to: "button"}));
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(0);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({data: {}, status: 'error', error: 'Nothing to bind'}),
"zigbee2mqtt/bridge/response/device/bind",
stringify({data: {}, status: "error", error: "Nothing to bind"}),
{retain: false, qos: 0},
);
});
it('Should unbind', async () => {
it("Should unbind", async () => {
const device = devices.remote;
const target = devices.bulb_color.getEndpoint(1)!;
// setup
target.configureReporting.mockImplementationOnce(() => {
throw new Error('timeout');
throw new Error("timeout");
});
const originalRemoteBinds = device.getEndpoint(1)!.binds;
device.getEndpoint(1)!.binds = [];
const originalTargetBinds = target.binds;
target.binds = [
{cluster: {name: 'genOnOff'}, target: devices.coordinator.getEndpoint(1)!},
{cluster: {name: 'genLevelCtrl'}, target: devices.coordinator.getEndpoint(1)!},
{cluster: {name: 'lightingColorCtrl'}, target: devices.coordinator.getEndpoint(1)!},
{cluster: {name: "genOnOff"}, target: devices.coordinator.getEndpoint(1)!},
{cluster: {name: "genLevelCtrl"}, target: devices.coordinator.getEndpoint(1)!},
{cluster: {name: "lightingColorCtrl"}, target: devices.coordinator.getEndpoint(1)!},
];
const endpoint = device.getEndpoint(1)!;
mockClear(device);
delete devices.bulb_color.meta.configured;
expect(devices.bulb_color.meta.configured).toBe(undefined);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'bulb_color'}));
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/unbind", stringify({from: "remote", to: "bulb_color"}));
await flushPromises();
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', target);
expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", target);
expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", target);
// Disable reporting
expect(target.configureReporting).toHaveBeenCalledTimes(3);
expect(target.configureReporting).toHaveBeenCalledWith('genOnOff', [
{attribute: 'onOff', maximumReportInterval: 0xffff, minimumReportInterval: 0, reportableChange: 0},
expect(target.configureReporting).toHaveBeenCalledWith("genOnOff", [
{attribute: "onOff", maximumReportInterval: 0xffff, minimumReportInterval: 0, reportableChange: 0},
]);
expect(target.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
{attribute: 'currentLevel', maximumReportInterval: 0xffff, minimumReportInterval: 5, reportableChange: 1},
expect(target.configureReporting).toHaveBeenCalledWith("genLevelCtrl", [
{attribute: "currentLevel", maximumReportInterval: 0xffff, minimumReportInterval: 5, reportableChange: 1},
]);
expect(target.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
{attribute: 'colorTemperature', minimumReportInterval: 5, maximumReportInterval: 0xffff, reportableChange: 1},
{attribute: 'currentX', minimumReportInterval: 5, maximumReportInterval: 0xffff, reportableChange: 1},
{attribute: 'currentY', minimumReportInterval: 5, maximumReportInterval: 0xffff, reportableChange: 1},
expect(target.configureReporting).toHaveBeenCalledWith("lightingColorCtrl", [
{attribute: "colorTemperature", minimumReportInterval: 5, maximumReportInterval: 0xffff, reportableChange: 1},
{attribute: "currentX", minimumReportInterval: 5, maximumReportInterval: 0xffff, reportableChange: 1},
{attribute: "currentY", minimumReportInterval: 5, maximumReportInterval: 0xffff, reportableChange: 1},
]);
expect(devices.bulb_color.meta.configured).toBe(332242049);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/unbind',
"zigbee2mqtt/bridge/response/device/unbind",
stringify({
data: {from: 'remote', from_endpoint: 'default', to: 'bulb_color', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []},
status: 'ok',
data: {from: "remote", from_endpoint: "default", to: "bulb_color", clusters: ["genScenes", "genOnOff", "genLevelCtrl"], failed: []},
status: "ok",
}),
{retain: false, qos: 0},
);
@@ -349,29 +352,29 @@ describe('Extension: Bind', () => {
device.getEndpoint(1)!.binds = originalRemoteBinds;
});
it('Should unbind coordinator', async () => {
it("Should unbind coordinator", async () => {
const device = devices.remote;
const target = devices.coordinator.getEndpoint(1)!;
const endpoint = device.getEndpoint(1)!;
mockClear(device);
endpoint.unbind.mockClear();
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'Coordinator'}));
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/unbind", stringify({from: "remote", to: "Coordinator"}));
await flushPromises();
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', target);
expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", target);
expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", target);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/unbind',
"zigbee2mqtt/bridge/response/device/unbind",
stringify({
data: {from: 'remote', from_endpoint: 'default', to: 'Coordinator', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []},
status: 'ok',
data: {from: "remote", from_endpoint: "default", to: "Coordinator", clusters: ["genScenes", "genOnOff", "genLevelCtrl"], failed: []},
status: "ok",
}),
{retain: false, qos: 0},
);
});
it('Should bind to groups', async () => {
it("Should bind to groups", async () => {
const device = devices.remote;
const target = groups.group_1;
const target1Member = devices.bulb.getEndpoint(1)!;
@@ -379,42 +382,42 @@ describe('Extension: Bind', () => {
target.members.push(target1Member);
target1Member.configureReporting.mockClear();
mockClear(device);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'group_1'}));
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/bind", stringify({from: "remote", to: "group_1"}));
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(3);
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target);
expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target);
expect(target1Member.configureReporting).toHaveBeenCalledTimes(2);
expect(target1Member.configureReporting).toHaveBeenCalledWith('genOnOff', [
{attribute: 'onOff', maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
expect(target1Member.configureReporting).toHaveBeenCalledWith("genOnOff", [
{attribute: "onOff", maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
]);
expect(target1Member.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
{attribute: 'currentLevel', maximumReportInterval: 3600, minimumReportInterval: 5, reportableChange: 1},
expect(target1Member.configureReporting).toHaveBeenCalledWith("genLevelCtrl", [
{attribute: "currentLevel", maximumReportInterval: 3600, minimumReportInterval: 5, reportableChange: 1},
]);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
"zigbee2mqtt/bridge/response/device/bind",
stringify({
data: {from: 'remote', from_endpoint: 'default', to: 'group_1', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []},
status: 'ok',
data: {from: "remote", from_endpoint: "default", to: "group_1", clusters: ["genScenes", "genOnOff", "genLevelCtrl"], failed: []},
status: "ok",
}),
{retain: false, qos: 0},
);
// Should configure reporting for device added to group
target1Member.configureReporting.mockClear();
await mockMQTTEvents.message('zigbee2mqtt/bridge/request/group/members/add', stringify({group: 'group_1', device: 'bulb'}));
await mockMQTTEvents.message("zigbee2mqtt/bridge/request/group/members/add", stringify({group: "group_1", device: "bulb"}));
await flushPromises();
expect(target1Member.configureReporting).toHaveBeenCalledTimes(2);
expect(target1Member.configureReporting).toHaveBeenCalledWith('genOnOff', [
{attribute: 'onOff', maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
expect(target1Member.configureReporting).toHaveBeenCalledWith("genOnOff", [
{attribute: "onOff", maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
]);
expect(target1Member.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
{attribute: 'currentLevel', maximumReportInterval: 3600, minimumReportInterval: 5, reportableChange: 1},
expect(target1Member.configureReporting).toHaveBeenCalledWith("genLevelCtrl", [
{attribute: "currentLevel", maximumReportInterval: 3600, minimumReportInterval: 5, reportableChange: 1},
]);
});
it('Should unbind from group', async () => {
it("Should unbind from group", async () => {
const device = devices.remote;
const target = groups.group_1;
const target1Member = devices.bulb.getEndpoint(1)!;
@@ -422,23 +425,23 @@ describe('Extension: Bind', () => {
target.members.push(target1Member);
target1Member.configureReporting.mockClear();
mockClear(device);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'group_1'}));
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/unbind", stringify({from: "remote", to: "group_1"}));
await flushPromises();
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', target);
expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", target);
expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", target);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/unbind',
"zigbee2mqtt/bridge/response/device/unbind",
stringify({
data: {from: 'remote', from_endpoint: 'default', to: 'group_1', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []},
status: 'ok',
data: {from: "remote", from_endpoint: "default", to: "group_1", clusters: ["genScenes", "genOnOff", "genLevelCtrl"], failed: []},
status: "ok",
}),
{retain: false, qos: 0},
);
});
it('Should unbind from group with skip_disable_reporting=true', async () => {
it("Should unbind from group with skip_disable_reporting=true", async () => {
const device = devices.remote;
const target = groups.group_1;
const target1Member = devices.bulb_2.getEndpoint(1)!;
@@ -450,12 +453,12 @@ describe('Extension: Bind', () => {
endpoint.binds = [];
target1Member.binds = [
{cluster: {name: 'genLevelCtrl'}, target: devices.coordinator.getEndpoint(1)!},
{cluster: {name: 'genOnOff'}, target: devices.coordinator.getEndpoint(1)!},
{cluster: {name: "genLevelCtrl"}, target: devices.coordinator.getEndpoint(1)!},
{cluster: {name: "genOnOff"}, target: devices.coordinator.getEndpoint(1)!},
];
target1Member.configureReporting.mockClear();
mockClear(device);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'group_1', skip_disable_reporting: true}));
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/unbind", stringify({from: "remote", to: "group_1", skip_disable_reporting: true}));
await flushPromises();
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
// with skip_disable_reporting set to false, we don't expect it to reconfigure reporting
@@ -463,7 +466,7 @@ describe('Extension: Bind', () => {
endpoint.binds = originalBinds;
});
it('Should unbind from group with skip_disable_reporting=false', async () => {
it("Should unbind from group with skip_disable_reporting=false", async () => {
const device = devices.remote;
const target = groups.group_1;
const target1Member = devices.bulb_2.getEndpoint(1)!;
@@ -475,184 +478,184 @@ describe('Extension: Bind', () => {
endpoint.binds = [];
target1Member.binds = [
{cluster: {name: 'genLevelCtrl'}, target: devices.coordinator.getEndpoint(1)!},
{cluster: {name: 'genOnOff'}, target: devices.coordinator.getEndpoint(1)!},
{cluster: {name: "genLevelCtrl"}, target: devices.coordinator.getEndpoint(1)!},
{cluster: {name: "genOnOff"}, target: devices.coordinator.getEndpoint(1)!},
];
target1Member.configureReporting.mockClear();
mockClear(device);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'group_1', skip_disable_reporting: false}));
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/unbind", stringify({from: "remote", to: "group_1", skip_disable_reporting: false}));
await flushPromises();
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
// with skip_disable_reporting set, we expect it to reconfigure reporting
expect(target1Member.configureReporting).toHaveBeenCalledTimes(2);
expect(target1Member.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
{attribute: 'currentLevel', maximumReportInterval: 65535, minimumReportInterval: 5, reportableChange: 1},
expect(target1Member.configureReporting).toHaveBeenCalledWith("genLevelCtrl", [
{attribute: "currentLevel", maximumReportInterval: 65535, minimumReportInterval: 5, reportableChange: 1},
]);
expect(target1Member.configureReporting).toHaveBeenCalledWith('genOnOff', [
{attribute: 'onOff', maximumReportInterval: 65535, minimumReportInterval: 0, reportableChange: 0},
expect(target1Member.configureReporting).toHaveBeenCalledWith("genOnOff", [
{attribute: "onOff", maximumReportInterval: 65535, minimumReportInterval: 0, reportableChange: 0},
]);
endpoint.binds = originalBinds;
});
it('Should bind to group by number', async () => {
it("Should bind to group by number", async () => {
const device = devices.remote;
const target = groups.group_1;
const endpoint = device.getEndpoint(1)!;
mockClear(device);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: '1'}));
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/bind", stringify({from: "remote", to: "1"}));
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(3);
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target);
expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
"zigbee2mqtt/bridge/response/device/bind",
stringify({
data: {from: 'remote', from_endpoint: 'default', to: '1', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []},
status: 'ok',
data: {from: "remote", from_endpoint: "default", to: "1", clusters: ["genScenes", "genOnOff", "genLevelCtrl"], failed: []},
status: "ok",
}),
{retain: false, qos: 0},
);
});
it('Should log when bind fails', async () => {
it("Should log when bind fails", async () => {
mockLogger.error.mockClear();
const device = devices.remote;
const endpoint = device.getEndpoint(1)!;
mockClear(device);
endpoint.bind.mockImplementation(() => {
throw new Error('failed');
throw new Error("failed");
});
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'bulb_color'}));
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/bind", stringify({from: "remote", to: "bulb_color"}));
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(3);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({data: {}, status: 'error', error: 'Failed to bind'}),
"zigbee2mqtt/bridge/response/device/bind",
stringify({data: {}, status: "error", error: "Failed to bind"}),
{retain: false, qos: 0},
);
});
it('Should bind from non default endpoint names', async () => {
it("Should bind from non default endpoint names", async () => {
const device = devices.remote;
const target = devices.QBKG03LM.getEndpoint(3)!;
const endpoint = device.getEndpoint(2)!;
mockClear(device);
mockMQTTEvents.message(
'zigbee2mqtt/bridge/request/device/bind',
stringify({from: 'remote', from_endpoint: 'ep2', to: 'wall_switch_double', to_endpoint: 'right'}),
"zigbee2mqtt/bridge/request/device/bind",
stringify({from: "remote", from_endpoint: "ep2", to: "wall_switch_double", to_endpoint: "right"}),
);
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(1);
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
"zigbee2mqtt/bridge/response/device/bind",
stringify({
data: {from: 'remote', from_endpoint: 'ep2', to: 'wall_switch_double', to_endpoint: 'right', clusters: ['genOnOff'], failed: []},
status: 'ok',
data: {from: "remote", from_endpoint: "ep2", to: "wall_switch_double", to_endpoint: "right", clusters: ["genOnOff"], failed: []},
status: "ok",
}),
{retain: false, qos: 0},
);
});
it('Should bind from non default endpoint IDs', async () => {
it("Should bind from non default endpoint IDs", async () => {
const device = devices.remote;
const target = devices.QBKG03LM.getEndpoint(3)!;
const endpoint = device.getEndpoint(2)!;
mockClear(device);
mockMQTTEvents.message(
'zigbee2mqtt/bridge/request/device/bind',
stringify({from: 'remote', from_endpoint: 2, to: 'wall_switch_double', to_endpoint: 3}),
"zigbee2mqtt/bridge/request/device/bind",
stringify({from: "remote", from_endpoint: 2, to: "wall_switch_double", to_endpoint: 3}),
);
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(1);
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
"zigbee2mqtt/bridge/response/device/bind",
stringify({
data: {from: 'remote', from_endpoint: 2, to: 'wall_switch_double', to_endpoint: 3, clusters: ['genOnOff'], failed: []},
status: 'ok',
data: {from: "remote", from_endpoint: 2, to: "wall_switch_double", to_endpoint: 3, clusters: ["genOnOff"], failed: []},
status: "ok",
}),
{retain: false, qos: 0},
);
});
it('Should bind server clusters to client clusters', async () => {
it("Should bind server clusters to client clusters", async () => {
const device = devices.temperature_sensor;
const target = devices.heating_actuator.getEndpoint(1)!;
const endpoint = device.getEndpoint(1)!;
mockClear(device);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'temperature_sensor', to: 'heating_actuator'}));
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/bind", stringify({from: "temperature_sensor", to: "heating_actuator"}));
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(1);
expect(endpoint.bind).toHaveBeenCalledWith('msTemperatureMeasurement', target);
expect(endpoint.bind).toHaveBeenCalledWith("msTemperatureMeasurement", target);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
"zigbee2mqtt/bridge/response/device/bind",
stringify({
data: {
from: 'temperature_sensor',
from_endpoint: 'default',
to: 'heating_actuator',
clusters: ['msTemperatureMeasurement'],
from: "temperature_sensor",
from_endpoint: "default",
to: "heating_actuator",
clusters: ["msTemperatureMeasurement"],
failed: [],
},
status: 'ok',
status: "ok",
}),
{retain: false, qos: 0},
);
});
it('Should bind to default endpoint returned by endpoints()', async () => {
it("Should bind to default endpoint returned by endpoints()", async () => {
const device = devices.remote;
const target = devices.QBKG04LM.getEndpoint(2)!;
const endpoint = device.getEndpoint(2)!;
mockClear(device);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', from_endpoint: 'ep2', to: 'wall_switch'}));
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/bind", stringify({from: "remote", from_endpoint: "ep2", to: "wall_switch"}));
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(1);
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({data: {from: 'remote', from_endpoint: 'ep2', to: 'wall_switch', clusters: ['genOnOff'], failed: []}, status: 'ok'}),
"zigbee2mqtt/bridge/response/device/bind",
stringify({data: {from: "remote", from_endpoint: "ep2", to: "wall_switch", clusters: ["genOnOff"], failed: []}, status: "ok"}),
{retain: false, qos: 0},
);
});
it('Should unbind from default_bind_group', async () => {
it("Should unbind from default_bind_group", async () => {
const device = devices.remote;
const target = 'default_bind_group';
const target = "default_bind_group";
const endpoint = device.getEndpoint(1)!;
mockClear(device);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: target}));
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", 901);
expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", 901);
expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", 901);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/unbind',
"zigbee2mqtt/bridge/response/device/unbind",
stringify({
data: {
from: 'remote',
from_endpoint: 'default',
to: 'default_bind_group',
clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'],
from: "remote",
from_endpoint: "default",
to: "default_bind_group",
clusters: ["genScenes", "genOnOff", "genLevelCtrl"],
failed: [],
},
status: 'ok',
status: "ok",
}),
{retain: false, qos: 0},
);
});
it('Error bind fails when source device does not exist', async () => {
it("Error bind fails when source device does not exist", async () => {
const device = devices.remote;
mockClear(device);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote_not_existing', to: 'bulb_color'}));
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/bind", stringify({from: "remote_not_existing", to: "bulb_color"}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({data: {}, status: 'error', error: "Source device 'remote_not_existing' does not exist"}),
"zigbee2mqtt/bridge/response/device/bind",
stringify({data: {}, status: "error", error: "Source device 'remote_not_existing' does not exist"}),
{retain: false, qos: 0},
);
});
@@ -661,25 +664,25 @@ describe('Extension: Bind', () => {
const device = devices.remote;
mockClear(device);
mockMQTTEvents.message(
'zigbee2mqtt/bridge/request/device/bind',
stringify({from: 'remote', from_endpoint: 'not_existing_endpoint', to: 'bulb_color'}),
"zigbee2mqtt/bridge/request/device/bind",
stringify({from: "remote", from_endpoint: "not_existing_endpoint", to: "bulb_color"}),
);
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({data: {}, status: 'error', error: "Source device 'remote' does not have endpoint 'not_existing_endpoint'"}),
"zigbee2mqtt/bridge/response/device/bind",
stringify({data: {}, status: "error", error: "Source device 'remote' does not have endpoint 'not_existing_endpoint'"}),
{retain: false, qos: 0},
);
});
it('Error bind fails when target device or group does not exist', async () => {
it("Error bind fails when target device or group does not exist", async () => {
const device = devices.remote;
mockClear(device);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'bulb_color_not_existing'}));
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/bind", stringify({from: "remote", to: "bulb_color_not_existing"}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({data: {}, status: 'error', error: "Target device or group 'bulb_color_not_existing' does not exist"}),
"zigbee2mqtt/bridge/response/device/bind",
stringify({data: {}, status: "error", error: "Target device or group 'bulb_color_not_existing' does not exist"}),
{retain: false, qos: 0},
);
});
@@ -688,47 +691,47 @@ describe('Extension: Bind', () => {
const device = devices.remote;
mockClear(device);
mockMQTTEvents.message(
'zigbee2mqtt/bridge/request/device/bind',
stringify({from: 'remote', to: 'bulb_color', to_endpoint: 'not_existing_endpoint'}),
"zigbee2mqtt/bridge/request/device/bind",
stringify({from: "remote", to: "bulb_color", to_endpoint: "not_existing_endpoint"}),
);
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({data: {}, status: 'error', error: "Target device 'bulb_color' does not have endpoint 'not_existing_endpoint'"}),
"zigbee2mqtt/bridge/response/device/bind",
stringify({data: {}, status: "error", error: "Target device 'bulb_color' does not have endpoint 'not_existing_endpoint'"}),
{retain: false, qos: 0},
);
});
it('Should poll bounded Hue bulb when receiving message from Hue dimmer', async () => {
it("Should poll bounded Hue bulb when receiving message from Hue dimmer", async () => {
const remote = devices.remote;
const data = {button: 3, unknown1: 3145728, type: 2, unknown2: 0, time: 1};
const payload = {
data,
cluster: 'manuSpecificPhilips',
cluster: "manuSpecificPhilips",
device: remote,
endpoint: remote.getEndpoint(2)!,
type: 'commandHueNotification',
type: "commandHueNotification",
linkquality: 10,
groupID: 0,
};
await mockZHEvents.message(payload);
await flushPromises();
expect(mockDebounce).toHaveBeenCalledTimes(1);
expect(devices.bulb_color.getEndpoint(1)!.read).toHaveBeenCalledWith('genLevelCtrl', ['currentLevel']);
expect(devices.bulb_color.getEndpoint(1)!.read).toHaveBeenCalledWith("genLevelCtrl", ["currentLevel"]);
});
it('Should poll bounded Hue bulb when receiving message from scene controller', async () => {
it("Should poll bounded Hue bulb when receiving message from scene controller", async () => {
const remote = devices.bj_scene_switch;
const data = {action: 'recall_2_row_1'};
const data = {action: "recall_2_row_1"};
devices.bulb_color_2.getEndpoint(1)!.read.mockImplementationOnce(() => {
throw new Error('failed');
throw new Error("failed");
});
const payload = {
data,
cluster: 'genScenes',
cluster: "genScenes",
device: remote,
endpoint: remote.getEndpoint(10)!,
type: 'commandRecall',
type: "commandRecall",
linkquality: 10,
groupID: 0,
};
@@ -736,22 +739,22 @@ describe('Extension: Bind', () => {
await flushPromises();
// Calls to three clusters are expected in this case
expect(mockDebounce).toHaveBeenCalledTimes(3);
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledWith('genOnOff', ['onOff']);
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledWith('genLevelCtrl', ['currentLevel']);
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledWith('lightingColorCtrl', ['currentX', 'currentY', 'colorTemperature']);
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledWith("genOnOff", ["onOff"]);
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledWith("genLevelCtrl", ["currentLevel"]);
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledWith("lightingColorCtrl", ["currentX", "currentY", "colorTemperature"]);
});
it('Should poll grouped Hue bulb when receiving message from TRADFRI remote', async () => {
it("Should poll grouped Hue bulb when receiving message from TRADFRI remote", async () => {
devices.bulb_color_2.getEndpoint(1)!.read.mockClear();
devices.bulb_2.getEndpoint(1)!.read.mockClear();
const remote = devices.tradfri_remote;
const data = {stepmode: 0, stepsize: 43, transtime: 5};
const payload = {
data,
cluster: 'genLevelCtrl',
cluster: "genLevelCtrl",
device: remote,
endpoint: remote.getEndpoint(1)!,
type: 'commandStepWithOnOff',
type: "commandStepWithOnOff",
linkquality: 10,
groupID: 15071,
};
@@ -759,8 +762,8 @@ describe('Extension: Bind', () => {
await flushPromises();
expect(mockDebounce).toHaveBeenCalledTimes(2);
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledTimes(2);
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledWith('genLevelCtrl', ['currentLevel']);
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledWith('genOnOff', ['onOff']);
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledWith("genLevelCtrl", ["currentLevel"]);
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledWith("genOnOff", ["onOff"]);
// Should also only debounce once
await mockZHEvents.message(payload);
File diff suppressed because it is too large Load Diff
+62 -69
View File
@@ -1,23 +1,26 @@
import * as data from '../mocks/data';
import {mockLogger} from '../mocks/logger';
import {events as mockMQTTEvents, mockMQTTPublishAsync} from '../mocks/mqtt';
import {flushPromises} from '../mocks/utils';
import {Device, devices, Endpoint, events as mockZHEvents} from '../mocks/zigbeeHerdsman';
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
import {flushPromises} from "../mocks/utils";
import {type Device, type Endpoint, devices, events as mockZHEvents} from "../mocks/zigbeeHerdsman";
import stringify from 'json-stable-stringify-without-jsonify';
import stringify from "json-stable-stringify-without-jsonify";
import {Controller} from '../../lib/controller';
import * as settings from '../../lib/util/settings';
import {InterviewState} from "zigbee-herdsman/dist/controller/model/device";
import {Controller} from "../../lib/controller";
import Configure from "../../lib/extension/configure";
import * as settings from "../../lib/util/settings";
const mocksClear = [mockMQTTPublishAsync, mockLogger.warning, mockLogger.debug];
describe('Extension: Configure', () => {
describe("Extension: Configure", () => {
let controller: Controller;
let coordinatorEndpoint: Endpoint;
const resetExtension = async (): Promise<void> => {
await controller.enableDisableExtension(false, 'Configure');
await controller.enableDisableExtension(true, 'Configure');
await controller.removeExtension(controller.getExtension("Configure")!);
await controller.addExtension(new Configure(...controller.extensionArgs));
};
const mockClear = (device: Device): void => {
@@ -33,12 +36,12 @@ describe('Extension: Configure', () => {
const device = devices.remote;
const endpoint1 = device.getEndpoint(1)!;
expect(endpoint1.bind).toHaveBeenCalledTimes(2);
expect(endpoint1.bind).toHaveBeenCalledWith('genOnOff', coordinatorEndpoint);
expect(endpoint1.bind).toHaveBeenCalledWith('genLevelCtrl', coordinatorEndpoint);
expect(endpoint1.bind).toHaveBeenCalledWith("genOnOff", coordinatorEndpoint);
expect(endpoint1.bind).toHaveBeenCalledWith("genLevelCtrl", coordinatorEndpoint);
const endpoint2 = device.getEndpoint(2)!;
expect(endpoint2.write).toHaveBeenCalledTimes(1);
expect(endpoint2.write).toHaveBeenCalledWith('genBasic', {49: {type: 25, value: 11}}, {disableDefaultResponse: true, manufacturerCode: 4107});
expect(endpoint2.write).toHaveBeenCalledWith("genBasic", {49: {type: 25, value: 11}}, {disableDefaultResponse: true, manufacturerCode: 4107});
expect(device.meta.configured).toBe(332242049);
};
@@ -46,8 +49,8 @@ describe('Extension: Configure', () => {
const device = devices.bulb;
const endpoint1 = device.getEndpoint(1)!;
expect(endpoint1.read).toHaveBeenCalledTimes(2);
expect(endpoint1.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities']);
expect(endpoint1.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorTempPhysicalMin', 'colorTempPhysicalMax']);
expect(endpoint1.read).toHaveBeenCalledWith("lightingColorCtrl", ["colorCapabilities"]);
expect(endpoint1.read).toHaveBeenCalledWith("lightingColorCtrl", ["colorTempPhysicalMin", "colorTempPhysicalMax"]);
};
const expectBulbNotConfigured = (): void => {
@@ -74,25 +77,27 @@ describe('Extension: Configure', () => {
beforeEach(async () => {
data.writeDefaultConfiguration();
settings.reRead();
mocksClear.forEach((m) => m.mockClear());
for (const mock of mocksClear) mock.mockClear();
coordinatorEndpoint = devices.coordinator.getEndpoint(1)!;
await resetExtension();
await vi.runOnlyPendingTimersAsync();
});
afterAll(async () => {
await controller?.stop();
await flushPromises();
vi.useRealTimers();
});
it('Should configure Router on startup', async () => {
it("Should configure Router on startup", () => {
expectBulbConfigured();
});
it('Should not configure EndDevice on startup', async () => {
it("Should not configure EndDevice on startup", () => {
expectRemoteNotConfigured();
});
it('Should re-configure when device rejoins', async () => {
it("Should re-configure when device rejoins", async () => {
expectBulbConfigured();
const device = devices.bulb;
await flushPromises();
@@ -103,18 +108,18 @@ describe('Extension: Configure', () => {
expectBulbConfigured();
});
it('Should not re-configure disabled devices', async () => {
it("Should not re-configure disabled devices", async () => {
expectBulbConfigured();
const device = devices.bulb;
await flushPromises();
mockClear(device);
settings.set(['devices', device.ieeeAddr, 'disabled'], true);
settings.set(["devices", device.ieeeAddr, "disabled"], true);
mockZHEvents.deviceJoined({device});
await flushPromises();
expectBulbNotConfigured();
});
it('Should reconfigure reporting on reconfigure event', async () => {
it("Should reconfigure reporting on reconfigure event", async () => {
expectBulbConfigured();
// @ts-expect-error private
const device = controller.zigbee.resolveEntity(devices.bulb)!;
@@ -126,7 +131,7 @@ describe('Extension: Configure', () => {
expectBulbConfigured();
});
it('Should not configure twice', async () => {
it("Should not configure twice", async () => {
expectBulbConfigured();
const device = devices.bulb;
mockClear(device);
@@ -135,7 +140,7 @@ describe('Extension: Configure', () => {
expectBulbNotConfigured();
});
it('Should configure on zigbee message when not configured yet', async () => {
it("Should configure on zigbee message when not configured yet", async () => {
const device = devices.bulb;
delete device.meta.configured;
mockClear(device);
@@ -144,74 +149,72 @@ describe('Extension: Configure', () => {
expectBulbConfigured();
});
it('Should allow to configure via MQTT', async () => {
it("Should allow to configure via MQTT", async () => {
mockClear(devices.remote);
expectRemoteNotConfigured();
await mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/configure', 'remote');
await mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/configure", "remote");
await flushPromises();
expectRemoteConfigured();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/configure',
stringify({data: {id: 'remote'}, status: 'ok'}),
"zigbee2mqtt/bridge/response/device/configure",
stringify({data: {id: "remote"}, status: "ok"}),
{retain: false, qos: 0},
);
});
it('Fail to configure via MQTT when device does not exist', async () => {
await mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/configure', stringify({id: 'not_existing_device'}));
it("Fail to configure via MQTT when device does not exist", async () => {
await mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/configure", stringify({id: "not_existing_device"}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/configure',
stringify({data: {}, status: 'error', error: "Device 'not_existing_device' does not exist"}),
"zigbee2mqtt/bridge/response/device/configure",
stringify({data: {}, status: "error", error: "Device 'not_existing_device' does not exist"}),
{retain: false, qos: 0},
);
});
it('Fail to configure via MQTT when configure fails', async () => {
devices.remote.getEndpoint(1)!.bind.mockImplementationOnce(async () => {
throw new Error('Bind timeout after 10s');
});
await mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/configure', stringify({id: 'remote'}));
it("Fail to configure via MQTT when configure fails", async () => {
devices.remote.getEndpoint(1)!.bind.mockRejectedValueOnce(new Error("Bind timeout after 10s"));
await mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/configure", stringify({id: "remote"}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/configure',
stringify({data: {}, status: 'error', error: 'Failed to configure (Bind timeout after 10s)'}),
"zigbee2mqtt/bridge/response/device/configure",
stringify({data: {}, status: "error", error: "Failed to configure (Bind timeout after 10s)"}),
{retain: false, qos: 0},
);
});
it('Fail to configure via MQTT when device has no configure', async () => {
await mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/configure', stringify({id: '0x0017882104a44559', transaction: 20}));
it("Fail to configure via MQTT when device has no configure", async () => {
await mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/configure", stringify({id: "0x0017882104a44559", transaction: 20}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/configure',
stringify({data: {}, status: 'error', error: "Device 'TS0601_thermostat' cannot be configured", transaction: 20}),
"zigbee2mqtt/bridge/response/device/configure",
stringify({data: {}, status: "error", error: "Device 'TS0601_thermostat' cannot be configured", transaction: 20}),
{retain: false, qos: 0},
);
});
it('Handles invalid payload for configure via MQTT', async () => {
await mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/configure', stringify({idx: '0x0017882104a44559'}));
it("Handles invalid payload for configure via MQTT", async () => {
await mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/configure", stringify({idx: "0x0017882104a44559"}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/configure',
stringify({data: {}, status: 'error', error: 'Invalid payload'}),
"zigbee2mqtt/bridge/response/device/configure",
stringify({data: {}, status: "error", error: "Invalid payload"}),
{retain: false, qos: 0},
);
});
it('Should not configure when interview not completed', async () => {
it("Should not configure when interview not completed", async () => {
const device = devices.remote;
delete device.meta.configured;
device.interviewCompleted = false;
device.interviewState = InterviewState.Pending;
mockClear(device);
await mockZHEvents.lastSeenChanged({device});
await flushPromises();
expectRemoteNotConfigured();
device.interviewCompleted = true;
device.interviewState = InterviewState.Successful;
});
it('Should not configure when already configuring', async () => {
it("Should not configure when already configuring", async () => {
const device = devices.remote;
delete device.meta.configured;
const endpoint = device.getEndpoint(1)!;
@@ -225,36 +228,26 @@ describe('Extension: Configure', () => {
expect(endpoint.bind).toHaveBeenCalledTimes(1);
});
it('Should configure max 3 times when fails', async () => {
it("Should configure max 3 times when fails", async () => {
// @ts-expect-error private
controller.extensions.find((e) => e.constructor.name === 'Configure').attempts = {};
(controller.getExtension("Configure")! as Configure).attempts = {};
const device = devices.remote;
delete device.meta.configured;
const endpoint = device.getEndpoint(1)!;
mockClear(device);
endpoint.bind.mockImplementationOnce(async () => {
throw new Error('BLA');
});
endpoint.bind.mockRejectedValueOnce(new Error("BLA"));
await mockZHEvents.lastSeenChanged({device});
await flushPromises();
endpoint.bind.mockImplementationOnce(async () => {
throw new Error('BLA');
});
endpoint.bind.mockRejectedValueOnce(new Error("BLA"));
await mockZHEvents.lastSeenChanged({device});
await flushPromises();
endpoint.bind.mockImplementationOnce(async () => {
throw new Error('BLA');
});
endpoint.bind.mockRejectedValueOnce(new Error("BLA"));
await mockZHEvents.lastSeenChanged({device});
await flushPromises();
endpoint.bind.mockImplementationOnce(async () => {
throw new Error('BLA');
});
endpoint.bind.mockRejectedValueOnce(new Error("BLA"));
await mockZHEvents.lastSeenChanged({device});
await flushPromises();
endpoint.bind.mockImplementationOnce(async () => {
throw new Error('BLA');
});
endpoint.bind.mockRejectedValueOnce(new Error("BLA"));
await mockZHEvents.lastSeenChanged({device});
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(3);
+452 -195
View File
@@ -1,35 +1,34 @@
import * as data from '../mocks/data';
import {mockLogger} from '../mocks/logger';
import {mockMQTTEndAsync, events as mockMQTTEvents, mockMQTTPublishAsync} from '../mocks/mqtt';
import {flushPromises} from '../mocks/utils';
import {devices, mockController as mockZHController, returnDevices} from '../mocks/zigbeeHerdsman';
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {mockMQTTEndAsync, mockMQTTPublishAsync} from "../mocks/mqtt";
import {flushPromises} from "../mocks/utils";
import {devices, mockController as mockZHController, returnDevices} from "../mocks/zigbeeHerdsman";
import type Device from '../../lib/model/device';
import type Device from "../../lib/model/device";
import type {Device as ZhDevice} from "../mocks/zigbeeHerdsman";
import fs from 'node:fs';
import path from 'node:path';
import fs from "node:fs";
import path from "node:path";
import stringify from 'json-stable-stringify-without-jsonify';
import stringify from "json-stable-stringify-without-jsonify";
import * as zhc from 'zigbee-herdsman-converters';
import * as zhc from "zigbee-herdsman-converters";
import {Controller} from '../../lib/controller';
import * as settings from '../../lib/util/settings';
import {Controller} from "../../lib/controller";
import ExternalConverters from "../../lib/extension/externalConverters";
import * as settings from "../../lib/util/settings";
const BASE_DIR = 'external_converters';
const BASE_DIR = "external_converters";
describe('Extension: ExternalConverters', () => {
describe("Extension: ExternalConverters", () => {
const mockBasePath = path.join(data.mockDir, BASE_DIR);
let controller: Controller;
const existsSyncSpy = vi.spyOn(fs, 'existsSync');
const readdirSyncSpy = vi.spyOn(fs, 'readdirSync');
const mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync');
const rmSyncSpy = vi.spyOn(fs, 'rmSync');
const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync');
const rmSyncSpy = vi.spyOn(fs, "rmSync");
const writeFileSyncSpy = vi.spyOn(fs, "writeFileSync");
const zhcAddExternalDefinitionSpy = vi.spyOn(zhc, 'addExternalDefinition');
const zhcRemoveExternalDefinitionsSpy = vi.spyOn(zhc, 'removeExternalDefinitions');
const zhcAddExternalDefinitionSpy = vi.spyOn(zhc, "addExternalDefinition");
const zhcRemoveExternalDefinitionsSpy = vi.spyOn(zhc, "removeExternalDefinitions");
const mocksClear = [
mockMQTTEndAsync,
@@ -38,31 +37,28 @@ describe('Extension: ExternalConverters', () => {
mockLogger.error,
mockZHController.stop,
devices.bulb.save,
existsSyncSpy,
readdirSyncSpy,
mkdirSyncSpy,
rmSyncSpy,
writeFileSyncSpy,
zhcAddExternalDefinitionSpy,
zhcRemoveExternalDefinitionsSpy,
];
const useAssets = (): void => {
fs.cpSync(path.join(__dirname, '..', 'assets', BASE_DIR), mockBasePath, {recursive: true});
const useAssets = (mtype: "cjs" | "mjs"): void => {
fs.cpSync(path.join(__dirname, "..", "assets", BASE_DIR, mtype), mockBasePath, {recursive: true});
};
const getFileCode = (fileName: string): string => {
return fs.readFileSync(path.join(__dirname, '..', 'assets', BASE_DIR, fileName), 'utf8');
const getFileCode = (mtype: "cjs" | "mjs", fileName: string): string => {
return fs.readFileSync(path.join(__dirname, "..", "assets", BASE_DIR, mtype, fileName), "utf8");
};
const getZ2MDevice = (zhDevice: unknown): Device => {
const getZ2MDevice = (zhDevice: string | number | ZhDevice): Device => {
// @ts-expect-error private
return controller.zigbee.resolveEntity(zhDevice)! as Device;
};
const resetExtension = async (): Promise<void> => {
await controller.enableDisableExtension(false, 'ExternalConverters');
await controller.enableDisableExtension(true, 'ExternalConverters');
await controller.removeExtension(controller.getExtension("ExternalConverters")!);
await controller.addExtension(new ExternalConverters(...controller.extensionArgs));
};
beforeAll(async () => {
@@ -73,13 +69,15 @@ describe('Extension: ExternalConverters', () => {
await flushPromises();
});
afterAll(async () => {
afterAll(() => {
vi.useRealTimers();
});
beforeEach(async () => {
zhc.removeExternalDefinitions(); // remove all external converters
mocksClear.forEach((m) => m.mockClear());
// @ts-expect-error private - clear cached
await controller.zigbee.resolveDevicesDefinitions(true);
for (const mock of mocksClear) mock.mockClear();
data.writeDefaultConfiguration();
data.writeDefaultState();
settings.reRead();
@@ -90,133 +88,313 @@ describe('Extension: ExternalConverters', () => {
fs.rmSync(mockBasePath, {recursive: true, force: true});
await controller?.stop();
await flushPromises();
});
describe('from folder', () => {
beforeEach(async () => {
describe("from folder", () => {
beforeEach(() => {
controller = new Controller(vi.fn(), vi.fn());
});
it('loads nothing', async () => {
it("loads nothing", async () => {
await controller.start();
await flushPromises();
expect(existsSyncSpy).toHaveBeenCalledWith(mockBasePath);
expect(readdirSyncSpy).not.toHaveBeenCalledWith(mockBasePath);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/converters', stringify([]), {retain: true, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/converters", stringify([]), {retain: true, qos: 0});
});
it('loads converters', async () => {
useAssets();
it("CJS: loads converters", async () => {
useAssets("cjs");
await controller.start();
await flushPromises();
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
description: 'external',
model: 'external_converter_device',
vendor: 'external',
zigbeeModel: ['external_converter_device'],
description: "external/converter",
model: "external_converter_device",
vendor: "external",
zigbeeModel: ["external_converter_device"],
});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/converters',
"zigbee2mqtt/bridge/converters",
stringify([
{name: 'mock-external-converter-multiple.js', code: getFileCode('mock-external-converter-multiple.js')},
{name: 'mock-external-converter.js', code: getFileCode('mock-external-converter.js')},
{name: "mock-external-converter-multiple.js", code: getFileCode("cjs", "mock-external-converter-multiple.js")},
{name: "mock-external-converter.js", code: getFileCode("cjs", "mock-external-converter.js")},
]),
{retain: true, qos: 0},
);
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenCalledTimes(2);
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(1, 'mock-external-converter-multiple.js');
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(2, 'mock-external-converter.js');
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(1, "mock-external-converter-multiple.js");
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(2, "mock-external-converter.js");
expect(zhcAddExternalDefinitionSpy).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
mock: 1,
model: 'external_converters_device_1',
zigbeeModel: ['external_converter_device_1'],
vendor: 'external_1',
description: 'external_1',
model: "external_converters_device_1",
zigbeeModel: ["external_converter_device_1"],
vendor: "external_1",
description: "external_1",
}),
);
expect(zhcAddExternalDefinitionSpy).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
mock: 2,
model: 'external_converters_device_2',
zigbeeModel: ['external_converter_device_2'],
vendor: 'external_2',
description: 'external_2',
model: "external_converters_device_2",
zigbeeModel: ["external_converter_device_2"],
vendor: "external_2",
description: "external_2",
}),
);
expect(zhcAddExternalDefinitionSpy).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
mock: true,
zigbeeModel: ['external_converter_device'],
vendor: 'external',
model: 'external_converter_device',
description: 'external',
zigbeeModel: ["external_converter_device"],
vendor: "external",
model: "external_converter_device",
description: "external/converter",
}),
);
const bridgeDevices = mockMQTTPublishAsync.mock.calls.filter((c) => c[0] === 'zigbee2mqtt/bridge/devices');
const bridgeDevices = mockMQTTPublishAsync.mock.calls.filter((c) => c[0] === "zigbee2mqtt/bridge/devices");
expect(bridgeDevices.length).toBe(1);
expect(JSON.parse(bridgeDevices[0][1])).toEqual(
expect.arrayContaining([
expect.objectContaining({
model_id: 'external_converter_device',
model_id: "external_converter_device",
supported: true,
definition: expect.objectContaining({
description: 'external',
model: 'external_converter_device',
description: "external/converter",
model: "external_converter_device",
}),
}),
]),
);
});
it('saves and removes from MQTT', async () => {
const converterName = 'foo.js';
const converterCode = getFileCode('mock-external-converter.js');
const converterFilePath = path.join(mockBasePath, converterName);
it("MJS: loads converters", async () => {
useAssets("mjs");
await controller.start();
await flushPromises();
mocksClear.forEach((m) => m.mockClear());
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
description: 'Automatically generated definition',
model: 'external_converter_device',
vendor: '',
zigbeeModel: ['external_converter_device'],
description: "external/converter",
model: "external_converter_device",
vendor: "external",
zigbeeModel: ["external_converter_device"],
});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/converters",
stringify([
{name: "mock-external-converter-multiple.mjs", code: getFileCode("mjs", "mock-external-converter-multiple.mjs")},
{name: "mock-external-converter.mjs", code: getFileCode("mjs", "mock-external-converter.mjs")},
]),
{retain: true, qos: 0},
);
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenCalledTimes(2);
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(1, "mock-external-converter-multiple.mjs");
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(2, "mock-external-converter.mjs");
expect(zhcAddExternalDefinitionSpy).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
mock: 1,
model: "external_converters_device_1",
zigbeeModel: ["external_converter_device_1"],
vendor: "external_1",
description: "external_1",
}),
);
expect(zhcAddExternalDefinitionSpy).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
mock: 2,
model: "external_converters_device_2",
zigbeeModel: ["external_converter_device_2"],
vendor: "external_2",
description: "external_2",
}),
);
expect(zhcAddExternalDefinitionSpy).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
mock: true,
zigbeeModel: ["external_converter_device"],
vendor: "external",
model: "external_converter_device",
description: "external/converter",
}),
);
//-- SAVE
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode}));
const bridgeDevices = mockMQTTPublishAsync.mock.calls.filter((c) => c[0] === "zigbee2mqtt/bridge/devices");
expect(bridgeDevices.length).toBe(1);
expect(JSON.parse(bridgeDevices[0][1])).toEqual(
expect.arrayContaining([
expect.objectContaining({
model_id: "external_converter_device",
supported: true,
definition: expect.objectContaining({
description: "external/converter",
model: "external_converter_device",
}),
}),
]),
);
});
it("updates after edit from MQTT", async () => {
const converterName = "mock-external-converter.js";
let converterCode = getFileCode("cjs", converterName);
useAssets("cjs");
await controller.start();
await flushPromises();
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
description: 'external',
model: 'external_converter_device',
vendor: 'external',
zigbeeModel: ['external_converter_device'],
description: "external/converter",
model: "external_converter_device",
vendor: "external",
zigbeeModel: ["external_converter_device"],
});
expect(mkdirSyncSpy).toHaveBeenCalledWith(mockBasePath, {recursive: true});
expect(writeFileSyncSpy).toHaveBeenCalledWith(converterFilePath, converterCode, 'utf8');
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/converters",
stringify([
{name: "mock-external-converter-multiple.js", code: getFileCode("cjs", "mock-external-converter-multiple.js")},
{name: converterName, code: converterCode},
]),
{retain: true, qos: 0},
);
converterCode = converterCode.replace('posix.join("external", "converter")', 'posix.join("external", "converter", "edited")');
await (controller.getExtension("ExternalConverters")! as ExternalConverters).onMQTTMessage({
topic: "zigbee2mqtt/bridge/request/converter/save",
message: {name: converterName, code: converterCode},
});
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
description: "external/converter/edited",
model: "external_converter_device",
vendor: "external",
zigbeeModel: ["external_converter_device"],
});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/converters",
stringify([
{name: "mock-external-converter-multiple.js", code: getFileCode("cjs", "mock-external-converter-multiple.js")},
{name: "mock-external-converter.1.js", code: converterCode},
]),
{retain: true, qos: 0},
);
expect(zhcAddExternalDefinitionSpy).toHaveBeenLastCalledWith(
expect.objectContaining({
mock: true,
zigbeeModel: ["external_converter_device"],
vendor: "external",
model: "external_converter_device",
description: "external/converter/edited",
externalConverterName: "mock-external-converter.1.js",
}),
);
converterCode = converterCode.replace('posix.join("external", "converter", "edited")', 'posix.join("external", "converter")');
await (controller.getExtension("ExternalConverters")! as ExternalConverters).onMQTTMessage({
topic: "zigbee2mqtt/bridge/request/converter/save",
message: {name: "mock-external-converter.1.js", code: converterCode},
});
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
description: "external/converter",
model: "external_converter_device",
vendor: "external",
zigbeeModel: ["external_converter_device"],
});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/converters",
stringify([
{name: "mock-external-converter-multiple.js", code: getFileCode("cjs", "mock-external-converter-multiple.js")},
{name: "mock-external-converter.2.js", code: converterCode},
]),
{retain: true, qos: 0},
);
expect(zhcAddExternalDefinitionSpy).toHaveBeenLastCalledWith(
expect.objectContaining({
mock: true,
zigbeeModel: ["external_converter_device"],
vendor: "external",
model: "external_converter_device",
description: "external/converter",
externalConverterName: "mock-external-converter.2.js",
}),
);
});
it("loads all valid converters, relocates & skips ones with errors", async () => {
useAssets("mjs");
const filepath = path.join(mockBasePath, "invalid.mjs");
fs.writeFileSync(filepath, "invalid js", "utf8");
await controller.start();
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/converters",
stringify([
{name: "mock-external-converter-multiple.mjs", code: getFileCode("mjs", "mock-external-converter-multiple.mjs")},
{name: "mock-external-converter.mjs", code: getFileCode("mjs", "mock-external-converter.mjs")},
]),
{retain: true, qos: 0},
);
expect(fs.existsSync(filepath)).toStrictEqual(false);
expect(fs.existsSync(path.join(mockBasePath, "invalid.mjs.invalid"))).toStrictEqual(true);
});
});
describe("from MQTT", () => {
it("CJS: saves and removes", async () => {
const converterName = "foo.js";
const converterCode = getFileCode("cjs", "mock-external-converter.js");
await resetExtension();
for (const mock of mocksClear) mock.mockClear();
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
description: "Automatically generated definition",
model: "external_converter_device",
vendor: "",
zigbeeModel: ["external_converter_device"],
});
//-- SAVE
await (controller.getExtension("ExternalConverters")! as ExternalConverters).onMQTTMessage({
topic: "zigbee2mqtt/bridge/request/converter/save",
message: {name: converterName, code: converterCode},
});
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
description: "external/converter",
model: "external_converter_device",
vendor: "external",
zigbeeModel: ["external_converter_device"],
});
expect(writeFileSyncSpy).toHaveBeenCalledWith(expect.stringContaining(converterName), converterCode, "utf8");
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenCalledTimes(1);
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(1, converterName);
expect(zhcAddExternalDefinitionSpy).toHaveBeenCalledWith(
expect.objectContaining({
mock: true,
zigbeeModel: ['external_converter_device'],
vendor: 'external',
model: 'external_converter_device',
description: 'external',
zigbeeModel: ["external_converter_device"],
vendor: "external",
model: "external_converter_device",
description: "external/converter",
}),
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/converters',
"zigbee2mqtt/bridge/converters",
stringify([{name: converterName, code: converterCode}]),
{
retain: true,
@@ -225,133 +403,212 @@ describe('Extension: ExternalConverters', () => {
);
//-- REMOVE
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/remove', stringify({name: converterName}));
await flushPromises();
await (controller.getExtension("ExternalConverters")! as ExternalConverters).onMQTTMessage({
topic: "zigbee2mqtt/bridge/request/converter/remove",
message: {name: converterName},
});
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
description: 'Automatically generated definition',
model: 'external_converter_device',
vendor: '',
zigbeeModel: ['external_converter_device'],
description: "Automatically generated definition",
model: "external_converter_device",
vendor: "",
zigbeeModel: ["external_converter_device"],
});
expect(rmSyncSpy).toHaveBeenCalledWith(converterFilePath, {force: true});
expect(rmSyncSpy).toHaveBeenCalledWith(expect.stringContaining(converterName), {force: true});
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenCalledTimes(2);
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(2, converterName);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/converters', stringify([]), {retain: true, qos: 0});
});
});
it('returns error on invalid code', async () => {
const converterName = 'foo.js';
const converterCode = 'definetly not a correct javascript code';
const converterFilePath = path.join(mockBasePath, converterName);
await resetExtension();
mocksClear.forEach((m) => m.mockClear());
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/converter/save',
expect.stringContaining(`"error":"foo.js contains invalid code`),
{retain: false, qos: 0},
);
expect(writeFileSyncSpy).not.toHaveBeenCalledWith(converterFilePath, converterCode, 'utf8');
});
it('returns error on invalid removal', async () => {
const converterName = 'invalid.js';
const converterFilePath = path.join(mockBasePath, converterName);
await resetExtension();
mocksClear.forEach((m) => m.mockClear());
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/remove', stringify({name: converterName}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/converter/remove',
stringify({data: {}, status: 'error', error: `${converterName} (${converterFilePath}) doesn't exists`}),
{retain: false, qos: 0},
);
expect(rmSyncSpy).not.toHaveBeenCalledWith(converterFilePath, {force: true});
});
it('returns error on invalid definition', async () => {
const converterName = 'foo.js';
const converterCode = getFileCode('mock-external-converter.js');
const converterFilePath = path.join(mockBasePath, converterName);
await resetExtension();
mocksClear.forEach((m) => m.mockClear());
const errorMsg = `Invalid definition`;
zhcAddExternalDefinitionSpy.mockImplementationOnce(() => {
throw new Error(errorMsg);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/converters", stringify([]), {retain: true, qos: 0});
});
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode}));
await flushPromises();
it("MJS: saves and removes", async () => {
const converterName = "foo.mjs";
const converterCode = getFileCode("mjs", "mock-external-converter.mjs");
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/response/converter/save', expect.stringContaining(errorMsg), {
retain: false,
qos: 0,
});
expect(writeFileSyncSpy).not.toHaveBeenCalledWith(converterFilePath, converterCode, 'utf8');
});
await resetExtension();
for (const mock of mocksClear) mock.mockClear();
it('returns error on failed removal', async () => {
const converterName = 'foo.js';
const converterCode = getFileCode('mock-external-converter.js');
const converterFilePath = path.join(mockBasePath, converterName);
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
description: "Automatically generated definition",
model: "external_converter_device",
vendor: "",
zigbeeModel: ["external_converter_device"],
});
await resetExtension();
mocksClear.forEach((m) => m.mockClear());
//-- SAVE
await (controller.getExtension("ExternalConverters")! as ExternalConverters).onMQTTMessage({
topic: "zigbee2mqtt/bridge/request/converter/save",
message: {name: converterName, code: converterCode},
});
//-- SAVE
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode}));
await flushPromises();
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
description: "external/converter",
model: "external_converter_device",
vendor: "external",
zigbeeModel: ["external_converter_device"],
});
expect(writeFileSyncSpy).toHaveBeenCalledWith(expect.stringContaining(converterName), converterCode, "utf8");
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenCalledTimes(1);
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(1, converterName);
expect(zhcAddExternalDefinitionSpy).toHaveBeenCalledWith(
expect.objectContaining({
mock: true,
zigbeeModel: ["external_converter_device"],
vendor: "external",
model: "external_converter_device",
description: "external/converter",
}),
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/converters",
stringify([{name: converterName, code: converterCode}]),
{
retain: true,
qos: 0,
},
);
const errorMsg = `Failed to remove definition`;
//-- REMOVE
await (controller.getExtension("ExternalConverters")! as ExternalConverters).onMQTTMessage({
topic: "zigbee2mqtt/bridge/request/converter/remove",
message: {name: converterName},
});
zhcRemoveExternalDefinitionsSpy.mockImplementationOnce(() => {
throw new Error(errorMsg);
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
description: "Automatically generated definition",
model: "external_converter_device",
vendor: "",
zigbeeModel: ["external_converter_device"],
});
expect(rmSyncSpy).toHaveBeenCalledWith(expect.stringContaining(converterName), {force: true});
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenCalledTimes(2);
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(2, converterName);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/converters", stringify([]), {retain: true, qos: 0});
});
//-- REMOVE
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/remove', stringify({name: converterName}));
await flushPromises();
it("returns error on invalid code", async () => {
const converterName = "foo1.js";
const converterCode = "definetly not a correct javascript code";
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/converter/remove',
stringify({data: {}, status: 'error', error: errorMsg}),
{retain: false, qos: 0},
);
expect(rmSyncSpy).not.toHaveBeenCalledWith(converterFilePath, {force: true});
});
await resetExtension();
for (const mock of mocksClear) mock.mockClear();
it('handles invalid payloads', async () => {
await resetExtension();
mocksClear.forEach((m) => m.mockClear());
await (controller.getExtension("ExternalConverters")! as ExternalConverters).onMQTTMessage({
topic: "zigbee2mqtt/bridge/request/converter/save",
message: {name: converterName, code: converterCode},
});
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: 'test.js', transaction: 1 /* code */}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/converter/save",
expect.stringContaining(`"error":"${converterName} contains invalid code`),
{retain: false, qos: 0},
);
expect(writeFileSyncSpy).toHaveBeenCalledWith(expect.stringContaining(converterName), converterCode, "utf8");
expect(rmSyncSpy).toHaveBeenCalledWith(expect.stringContaining(converterName), {force: true});
});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/converter/save',
stringify({data: {}, status: 'error', error: `Invalid payload`, transaction: 1}),
{retain: false, qos: 0},
);
it("returns error on invalid removal", async () => {
const converterName = "foo2.js";
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/remove', stringify({namex: 'test.js', transaction: 2}));
await flushPromises();
await resetExtension();
for (const mock of mocksClear) mock.mockClear();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/converter/remove',
stringify({data: {}, status: 'error', error: `Invalid payload`, transaction: 2}),
{retain: false, qos: 0},
);
await (controller.getExtension("ExternalConverters")! as ExternalConverters).onMQTTMessage({
topic: "zigbee2mqtt/bridge/request/converter/remove",
message: {name: converterName},
});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/converter/remove",
expect.stringContaining("doesn't exists"),
{retain: false, qos: 0},
);
expect(rmSyncSpy).not.toHaveBeenCalledWith(expect.stringContaining(converterName), {force: true});
});
it("returns error on invalid definition", async () => {
const converterName = "foo3.js";
const converterCode = getFileCode("cjs", "mock-external-converter.js");
await resetExtension();
for (const mock of mocksClear) mock.mockClear();
const errorMsg = "Invalid definition";
zhcAddExternalDefinitionSpy.mockImplementationOnce(() => {
throw new Error(errorMsg);
});
await (controller.getExtension("ExternalConverters")! as ExternalConverters).onMQTTMessage({
topic: "zigbee2mqtt/bridge/request/converter/save",
message: {name: converterName, code: converterCode},
});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/response/converter/save", expect.stringContaining(errorMsg), {
retain: false,
qos: 0,
});
expect(writeFileSyncSpy).toHaveBeenCalledWith(expect.stringContaining(converterName), converterCode, "utf8");
expect(rmSyncSpy).toHaveBeenCalledWith(expect.stringContaining(converterName), {force: true});
});
it("returns error on failed removal", async () => {
const converterName = "foo4.js";
const converterCode = getFileCode("cjs", "mock-external-converter.js");
await resetExtension();
for (const mock of mocksClear) mock.mockClear();
//-- SAVE
await (controller.getExtension("ExternalConverters")! as ExternalConverters).onMQTTMessage({
topic: "zigbee2mqtt/bridge/request/converter/save",
message: {name: converterName, code: converterCode},
});
const errorMsg = "Failed to remove definition";
zhcRemoveExternalDefinitionsSpy.mockImplementationOnce(() => {
throw new Error(errorMsg);
});
//-- REMOVE
await (controller.getExtension("ExternalConverters")! as ExternalConverters).onMQTTMessage({
topic: "zigbee2mqtt/bridge/request/converter/remove",
message: {name: converterName},
});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/converter/remove",
stringify({data: {}, status: "error", error: errorMsg}),
{retain: false, qos: 0},
);
expect(rmSyncSpy).not.toHaveBeenCalledWith(expect.stringContaining(converterName), {force: true});
});
it("handles invalid payloads", async () => {
await resetExtension();
for (const mock of mocksClear) mock.mockClear();
await (controller.getExtension("ExternalConverters")! as ExternalConverters).onMQTTMessage({
topic: "zigbee2mqtt/bridge/request/converter/save",
message: {name: "foo5.js", transaction: 1 /* code */},
});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/converter/save",
stringify({data: {}, status: "error", error: "Invalid payload", transaction: 1}),
{retain: false, qos: 0},
);
await (controller.getExtension("ExternalConverters")! as ExternalConverters).onMQTTMessage({
topic: "zigbee2mqtt/bridge/request/converter/remove",
message: {namex: "foo5.js", transaction: 2},
});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/converter/remove",
stringify({data: {}, status: "error", error: "Invalid payload", transaction: 2}),
{retain: false, qos: 0},
);
});
});
});
+281 -122
View File
@@ -1,28 +1,26 @@
import * as data from '../mocks/data';
import {mockLogger} from '../mocks/logger';
import {mockMQTTEndAsync, events as mockMQTTEvents, mockMQTTPublishAsync} from '../mocks/mqtt';
import {flushPromises} from '../mocks/utils';
import {devices, mockController as mockZHController, returnDevices} from '../mocks/zigbeeHerdsman';
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {mockMQTTEndAsync, mockMQTTPublishAsync} from "../mocks/mqtt";
import {flushPromises} from "../mocks/utils";
import {devices, mockController as mockZHController, returnDevices} from "../mocks/zigbeeHerdsman";
import fs from 'node:fs';
import path from 'node:path';
import fs from "node:fs";
import path from "node:path";
import stringify from 'json-stable-stringify-without-jsonify';
import stringify from "json-stable-stringify-without-jsonify";
import {Controller} from '../../lib/controller';
import * as settings from '../../lib/util/settings';
import {Controller} from "../../lib/controller";
import ExternalExtensions from "../../lib/extension/externalExtensions";
import * as settings from "../../lib/util/settings";
const BASE_DIR = 'external_extensions';
const BASE_DIR = "external_extensions";
describe('Extension: ExternalExtensions', () => {
describe("Extension: ExternalExtensions", () => {
let controller: Controller;
const mockBasePath = path.join(data.mockDir, BASE_DIR);
const existsSyncSpy = vi.spyOn(fs, 'existsSync');
const readdirSyncSpy = vi.spyOn(fs, 'readdirSync');
const mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync');
const rmSyncSpy = vi.spyOn(fs, 'rmSync');
const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync');
const rmSyncSpy = vi.spyOn(fs, "rmSync");
const writeFileSyncSpy = vi.spyOn(fs, "writeFileSync");
const mocksClear = [
mockMQTTEndAsync,
@@ -31,103 +29,219 @@ describe('Extension: ExternalExtensions', () => {
mockLogger.error,
mockZHController.stop,
devices.bulb.save,
existsSyncSpy,
readdirSyncSpy,
mkdirSyncSpy,
rmSyncSpy,
writeFileSyncSpy,
];
const useAssets = (): void => {
fs.cpSync(path.join(__dirname, '..', 'assets', BASE_DIR), mockBasePath, {recursive: true});
const useAssets = (mtype: "cjs" | "mjs"): void => {
fs.cpSync(path.join(__dirname, "..", "assets", BASE_DIR, mtype), mockBasePath, {recursive: true});
};
const getFileCode = (fileName: string): string => {
return fs.readFileSync(path.join(__dirname, '..', 'assets', BASE_DIR, fileName), 'utf8');
const getFileCode = (mtype: "cjs" | "mjs", fileName: string): string => {
return fs.readFileSync(path.join(__dirname, "..", "assets", BASE_DIR, mtype, fileName), "utf8");
};
const resetExtension = async (): Promise<void> => {
await controller.enableDisableExtension(false, 'ExternalExtensions');
await controller.enableDisableExtension(true, 'ExternalExtensions');
await controller.removeExtension(controller.getExtension("ExternalExtensions")!);
await controller.addExtension(new ExternalExtensions(...controller.extensionArgs));
};
beforeAll(async () => {
vi.useFakeTimers();
controller = new Controller(vi.fn(), vi.fn());
await controller.start();
await flushPromises();
});
afterAll(async () => {
afterAll(() => {
vi.useRealTimers();
});
beforeEach(async () => {
mocksClear.forEach((m) => m.mockClear());
beforeEach(() => {
for (const mock of mocksClear) mock.mockClear();
data.writeDefaultConfiguration();
data.writeDefaultState();
settings.reRead();
returnDevices.splice(0);
});
afterEach(() => {
afterEach(async () => {
fs.rmSync(mockBasePath, {recursive: true, force: true});
await controller?.stop();
await flushPromises();
});
describe('from folder', () => {
beforeEach(async () => {
describe("from folder", () => {
beforeEach(() => {
controller = new Controller(vi.fn(), vi.fn());
});
it('loads nothing', async () => {
it("loads nothing", async () => {
await controller.start();
await flushPromises();
expect(existsSyncSpy).toHaveBeenCalledWith(mockBasePath);
expect(readdirSyncSpy).not.toHaveBeenCalledWith(mockBasePath);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/extensions', stringify([]), {retain: true, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/extensions", stringify([]), {retain: true, qos: 0});
});
it('loads extensions', async () => {
useAssets();
it("CJS: loads extensions", async () => {
useAssets("cjs");
await controller.start();
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example2/extension', 'call2 from constructor', {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example2/extension', 'call2 from start', {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from constructor', {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from start', {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example2/extension", "call2 from constructor", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example2/extension", "call2 from start", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from constructor", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/extensions',
"zigbee2mqtt/bridge/extensions",
stringify([
{name: 'example2Extension.js', code: getFileCode('example2Extension.js')},
{name: 'exampleExtension.js', code: getFileCode('exampleExtension.js')},
{name: "example2Extension.js", code: getFileCode("cjs", "example2Extension.js")},
{name: "exampleExtension.js", code: getFileCode("cjs", "exampleExtension.js")},
]),
{retain: true, qos: 0},
);
});
it('saves and removes from MQTT', async () => {
const extensionName = 'foo.js';
const extensionCode = getFileCode('exampleExtension.js');
const extensionFilePath = path.join(mockBasePath, extensionName);
it("MJS: loads extensions", async () => {
useAssets("mjs");
await controller.start();
await flushPromises();
mocksClear.forEach((m) => m.mockClear());
//-- SAVE
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: extensionName, code: extensionCode}));
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example2/extension", "call2 from constructor", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example2/extension", "call2 from start", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from constructor", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/extensions",
stringify([
{name: "example2Extension.mjs", code: getFileCode("mjs", "example2Extension.mjs")},
{name: "exampleExtension.mjs", code: getFileCode("mjs", "exampleExtension.mjs")},
]),
{retain: true, qos: 0},
);
});
it("starts extensions only once", async () => {
useAssets("mjs");
await controller.start();
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension/counter", "start 0", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).not.toHaveBeenCalledWith("zigbee2mqtt/example/extension/counter", "start 1", {retain: false, qos: 0});
});
it("loads all valid extensions, relocates & skips ones with errors", async () => {
useAssets("mjs");
const filepath = path.join(mockBasePath, "invalid.mjs");
fs.writeFileSync(filepath, "invalid js", "utf8");
await controller.start();
await flushPromises();
expect(mkdirSyncSpy).toHaveBeenCalledWith(mockBasePath, {recursive: true});
expect(writeFileSyncSpy).toHaveBeenCalledWith(extensionFilePath, extensionCode, 'utf8');
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from constructor', {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from start', {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/extensions',
"zigbee2mqtt/bridge/extensions",
stringify([
{name: "example2Extension.mjs", code: getFileCode("mjs", "example2Extension.mjs")},
{name: "exampleExtension.mjs", code: getFileCode("mjs", "exampleExtension.mjs")},
]),
{retain: true, qos: 0},
);
expect(fs.existsSync(filepath)).toStrictEqual(false);
expect(fs.existsSync(path.join(mockBasePath, "invalid.mjs.invalid"))).toStrictEqual(true);
});
it("updates after edit from MQTT", async () => {
const extensionName = "exampleExtension.js";
let extensionCode = getFileCode("cjs", extensionName);
useAssets("cjs");
await controller.start();
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example2/extension", "call2 from constructor", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example2/extension", "call2 from start", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from constructor", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/extensions",
stringify([
{name: "example2Extension.js", code: getFileCode("cjs", "example2Extension.js")},
{name: extensionName, code: extensionCode},
]),
{retain: true, qos: 0},
);
extensionCode = extensionCode
.replace('"call from start"', '"call from start - edited"')
.replace('"call from stop"', '"call from stop - edited"');
mockMQTTPublishAsync.mockClear();
await (controller.getExtension("ExternalExtensions")! as ExternalExtensions).onMQTTMessage({
topic: "zigbee2mqtt/bridge/request/extension/save",
message: {name: extensionName, code: extensionCode},
});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from stop", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start - edited", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/extensions",
stringify([
{name: "example2Extension.js", code: getFileCode("cjs", "example2Extension.js")},
{name: "exampleExtension.1.js", code: extensionCode},
]),
{retain: true, qos: 0},
);
extensionCode = extensionCode
.replace('"call from start - edited"', '"call from start"')
.replace('"call from stop - edited"', '"call from stop"');
mockMQTTPublishAsync.mockClear();
await (controller.getExtension("ExternalExtensions")! as ExternalExtensions).onMQTTMessage({
topic: "zigbee2mqtt/bridge/request/extension/save",
message: {name: "exampleExtension.1.js", code: extensionCode},
});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from stop - edited", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/extensions",
stringify([
{name: "example2Extension.js", code: getFileCode("cjs", "example2Extension.js")},
{name: "exampleExtension.2.js", code: extensionCode},
]),
{retain: true, qos: 0},
);
});
});
describe("from MQTT", () => {
it("CJS: saves and removes", async () => {
const extensionName = "foo.js";
const extensionCode = getFileCode("cjs", "exampleExtension.js");
await resetExtension();
for (const mock of mocksClear) mock.mockClear();
//-- SAVE
await (controller.getExtension("ExternalExtensions")! as ExternalExtensions).onMQTTMessage({
topic: "zigbee2mqtt/bridge/request/extension/save",
message: {name: extensionName, code: extensionCode},
});
expect(writeFileSyncSpy).toHaveBeenCalledWith(expect.stringContaining(extensionName), extensionCode, "utf8");
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from constructor", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/extensions",
stringify([{name: extensionName, code: extensionCode}]),
{
retain: true,
@@ -136,72 +250,117 @@ describe('Extension: ExternalExtensions', () => {
);
//-- REMOVE
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/remove', stringify({name: extensionName}));
await flushPromises();
await (controller.getExtension("ExternalExtensions")! as ExternalExtensions).onMQTTMessage({
topic: "zigbee2mqtt/bridge/request/extension/remove",
message: {name: extensionName},
});
expect(rmSyncSpy).toHaveBeenCalledWith(extensionFilePath, {force: true});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from stop', {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/extensions', stringify([]), {retain: true, qos: 0});
expect(rmSyncSpy).toHaveBeenCalledWith(expect.stringContaining(extensionName), {force: true});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from stop", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/extensions", stringify([]), {retain: true, qos: 0});
});
it("MJS: saves and removes", async () => {
const extensionName = "foo.mjs";
const extensionCode = getFileCode("mjs", "exampleExtension.mjs");
await resetExtension();
for (const mock of mocksClear) mock.mockClear();
//-- SAVE
await (controller.getExtension("ExternalExtensions")! as ExternalExtensions).onMQTTMessage({
topic: "zigbee2mqtt/bridge/request/extension/save",
message: {name: extensionName, code: extensionCode},
});
expect(writeFileSyncSpy).toHaveBeenCalledWith(expect.stringContaining(extensionName), extensionCode, "utf8");
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from constructor", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/extensions",
stringify([{name: extensionName, code: extensionCode}]),
{
retain: true,
qos: 0,
},
);
//-- REMOVE
await (controller.getExtension("ExternalExtensions")! as ExternalExtensions).onMQTTMessage({
topic: "zigbee2mqtt/bridge/request/extension/remove",
message: {name: extensionName},
});
expect(rmSyncSpy).toHaveBeenCalledWith(expect.stringContaining(extensionName), {force: true});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from stop", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/extensions", stringify([]), {retain: true, qos: 0});
});
it("returns error on invalid code", async () => {
const extensionName = "foo1.js";
const extensionCode = "definetly not a correct javascript code";
await resetExtension();
for (const mock of mocksClear) mock.mockClear();
await (controller.getExtension("ExternalExtensions")! as ExternalExtensions).onMQTTMessage({
topic: "zigbee2mqtt/bridge/request/extension/save",
message: {name: extensionName, code: extensionCode},
});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/extension/save",
expect.stringContaining(`"error":"${extensionName} contains invalid code`),
{retain: false, qos: 0},
);
expect(writeFileSyncSpy).toHaveBeenCalledWith(expect.stringContaining(extensionName), extensionCode, "utf8");
expect(rmSyncSpy).toHaveBeenCalledWith(expect.stringContaining(extensionName), {force: true});
});
it("returns error on invalid removal", async () => {
const extensionName = "foo2.js";
await resetExtension();
for (const mock of mocksClear) mock.mockClear();
await (controller.getExtension("ExternalExtensions")! as ExternalExtensions).onMQTTMessage({
topic: "zigbee2mqtt/bridge/request/extension/remove",
message: {name: extensionName},
});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/extension/remove",
expect.stringContaining("doesn't exists"),
{retain: false, qos: 0},
);
expect(rmSyncSpy).not.toHaveBeenCalledWith(expect.stringContaining(extensionName), {force: true});
});
it("handles invalid payloads", async () => {
await resetExtension();
for (const mock of mocksClear) mock.mockClear();
await (controller.getExtension("ExternalExtensions")! as ExternalExtensions).onMQTTMessage({
topic: "zigbee2mqtt/bridge/request/extension/save",
message: {name: "foo3.js", transaction: 1 /* code */},
});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/extension/save",
stringify({data: {}, status: "error", error: "Invalid payload", transaction: 1}),
{retain: false, qos: 0},
);
await (controller.getExtension("ExternalExtensions")! as ExternalExtensions).onMQTTMessage({
topic: "zigbee2mqtt/bridge/request/extension/remove",
message: {namex: "foo3.js", transaction: 2},
});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/extension/remove",
stringify({data: {}, status: "error", error: "Invalid payload", transaction: 2}),
{retain: false, qos: 0},
);
});
});
it('returns error on invalid code', async () => {
const extensionName = 'foo.js';
const extensionCode = 'definetly not a correct javascript code';
const extensionFilePath = path.join(mockBasePath, extensionName);
await resetExtension();
mocksClear.forEach((m) => m.mockClear());
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: extensionName, code: extensionCode}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/extension/save',
expect.stringContaining(`"error":"${extensionName} contains invalid code`),
{retain: false, qos: 0},
);
expect(writeFileSyncSpy).not.toHaveBeenCalledWith(extensionFilePath, extensionCode, 'utf8');
});
it('returns error on invalid removal', async () => {
const converterName = 'invalid.js';
const converterFilePath = path.join(mockBasePath, converterName);
await resetExtension();
mocksClear.forEach((m) => m.mockClear());
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/remove', stringify({name: converterName}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/extension/remove',
stringify({data: {}, status: 'error', error: `${converterName} (${converterFilePath}) doesn't exists`}),
{retain: false, qos: 0},
);
expect(rmSyncSpy).not.toHaveBeenCalledWith(converterFilePath, {force: true});
});
it('handles invalid payloads', async () => {
await resetExtension();
mocksClear.forEach((m) => m.mockClear());
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: 'test.js', transaction: 1 /* code */}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/extension/save',
stringify({data: {}, status: 'error', error: `Invalid payload`, transaction: 1}),
{retain: false, qos: 0},
);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/remove', stringify({namex: 'test.js', transaction: 2}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/extension/remove',
stringify({data: {}, status: 'error', error: `Invalid payload`, transaction: 2}),
{retain: false, qos: 0},
);
});
});
+138 -109
View File
@@ -1,17 +1,17 @@
import * as data from '../mocks/data';
import {mockLogger} from '../mocks/logger';
import {mockMQTTPublishAsync} from '../mocks/mqtt';
import {EventHandler, flushPromises} from '../mocks/utils';
import {devices} from '../mocks/zigbeeHerdsman';
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {mockMQTTPublishAsync} from "../mocks/mqtt";
import {type EventHandler, flushPromises} from "../mocks/utils";
import {devices} from "../mocks/zigbeeHerdsman";
import path from 'node:path';
import path from "node:path";
import stringify from 'json-stable-stringify-without-jsonify';
import {Mock} from 'vitest';
import ws from 'ws';
import stringify from "json-stable-stringify-without-jsonify";
import type {Mock} from "vitest";
import ws from "ws";
import {Controller} from '../../lib/controller';
import * as settings from '../../lib/util/settings';
import {Controller} from "../../lib/controller";
import * as settings from "../../lib/util/settings";
let mockHTTPOnRequest: (request: {url: string}, response: number) => void;
const mockHTTPEvents: Record<string, EventHandler> = {};
@@ -23,7 +23,6 @@ const mockHTTP = {
close: vi.fn<(cb: (err?: Error) => void) => void>((cb) => cb()),
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let mockHTTPSOnRequest: (request: {url: string}, response: number) => void;
const mockHTTPSEvents: Record<string, EventHandler> = {};
const mockHTTPS = {
@@ -45,7 +44,7 @@ const mockWSClient = {
},
send: vi.fn<(data: string) => void>(),
terminate: vi.fn<() => void>(),
readyState: 'close',
readyState: "close",
};
const mockWSEvents: Record<string, EventHandler> = {};
const mockWSClients: (typeof mockWSClient)[] = [];
@@ -54,21 +53,21 @@ const mockWS = {
on: (event: string, handler: EventHandler): void => {
mockWSEvents[event] = handler;
},
handleUpgrade: vi.fn().mockImplementation((request, socket, head, cb) => {
handleUpgrade: vi.fn().mockImplementation((_request, _socket, _head, cb) => {
cb(mockWSocket);
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
emit: vi.fn<(eventName: string, ...args: any[]) => void>(),
close: vi.fn<(code?: number, data?: string | Buffer) => void>(),
};
const frontendPath = 'frontend-path';
const deviceIconsPath = path.join(data.mockDir, 'device_icons');
const frontendPath = "frontend-path";
const deviceIconsPath = path.join(data.mockDir, "device_icons");
let mockNodeStatic: {[s: string]: Mock} = {};
const mockFinalHandler = vi.fn();
vi.mock('node:http', () => ({
vi.mock("node:http", () => ({
createServer: vi.fn().mockImplementation((onRequest) => {
mockHTTPOnRequest = onRequest;
return mockHTTP;
@@ -76,7 +75,7 @@ vi.mock('node:http', () => ({
Agent: vi.fn(),
}));
vi.mock('node:https', () => ({
vi.mock("node:https", () => ({
createServer: vi.fn().mockImplementation((onRequest) => {
mockHTTPSOnRequest = onRequest;
return mockHTTPS;
@@ -84,29 +83,29 @@ vi.mock('node:https', () => ({
Agent: vi.fn(),
}));
vi.mock('express-static-gzip', () => ({
vi.mock("express-static-gzip", () => ({
default: vi.fn().mockImplementation((path: string) => {
mockNodeStatic[path] = vi.fn();
return mockNodeStatic[path];
}),
}));
vi.mock('zigbee2mqtt-frontend', () => ({
vi.mock("zigbee2mqtt-frontend", () => ({
default: {
getPath: (): string => frontendPath,
},
}));
vi.mock('ws', () => ({
vi.mock("ws", () => ({
default: {
OPEN: 'open',
OPEN: "open",
Server: vi.fn().mockImplementation(() => {
return mockWS;
}),
},
}));
vi.mock('finalhandler', () => ({
vi.mock("finalhandler", () => ({
default: vi.fn().mockImplementation(() => {
return mockFinalHandler;
}),
@@ -128,39 +127,41 @@ const mocksClear = [
mockLogger.error,
];
describe('Extension: Frontend', () => {
describe("Extension: Frontend", () => {
let controller: Controller;
beforeAll(async () => {
beforeAll(() => {
vi.useFakeTimers();
});
beforeEach(async () => {
beforeEach(() => {
mockNodeStatic = {};
mockWS.clients = [];
data.writeDefaultConfiguration();
data.writeDefaultState();
settings.reRead();
settings.set(['frontend'], {enabled: true, port: 8081, host: '127.0.0.1'});
settings.set(['homeassistant'], {enabled: true});
settings.set(["frontend"], {enabled: true, port: 8081, host: "127.0.0.1"});
settings.set(["homeassistant"], {enabled: true});
devices.bulb.linkquality = 10;
mocksClear.forEach((m) => m.mockClear());
mockWSClient.readyState = 'close';
for (const mock of mocksClear) mock.mockClear();
mockWSClient.readyState = "close";
});
afterAll(async () => {
afterAll(() => {
vi.useRealTimers();
});
afterEach(async () => {
delete devices.bulb.linkquality;
await controller?.stop();
await flushPromises();
});
it('Start/stop with defaults', async () => {
it("Start/stop with defaults", async () => {
controller = new Controller(vi.fn(), vi.fn());
await controller.start();
expect(Object.keys(mockNodeStatic)).toStrictEqual([frontendPath, deviceIconsPath]);
expect(mockHTTP.listen).toHaveBeenCalledWith(8081, '127.0.0.1');
expect(mockHTTP.listen).toHaveBeenCalledWith(8081, "127.0.0.1");
mockWS.clients.push(mockWSClient);
await controller.stop();
expect(mockWSClient.terminate).toHaveBeenCalledTimes(1);
@@ -168,8 +169,8 @@ describe('Extension: Frontend', () => {
expect(mockWS.close).toHaveBeenCalledTimes(1);
});
it('Start/stop without host', async () => {
settings.set(['frontend'], {enabled: true, port: 8081});
it("Start/stop without host", async () => {
settings.set(["frontend"], {enabled: true, port: 8081});
controller = new Controller(vi.fn(), vi.fn());
await controller.start();
expect(Object.keys(mockNodeStatic)).toStrictEqual([frontendPath, deviceIconsPath]);
@@ -181,12 +182,12 @@ describe('Extension: Frontend', () => {
expect(mockWS.close).toHaveBeenCalledTimes(1);
});
it('Start/stop unix socket', async () => {
settings.set(['frontend', 'host'], '/tmp/zigbee2mqtt.sock');
it("Start/stop unix socket", async () => {
settings.set(["frontend", "host"], "/tmp/zigbee2mqtt.sock");
controller = new Controller(vi.fn(), vi.fn());
await controller.start();
expect(Object.keys(mockNodeStatic)).toStrictEqual([frontendPath, deviceIconsPath]);
expect(mockHTTP.listen).toHaveBeenCalledWith('/tmp/zigbee2mqtt.sock');
expect(mockHTTP.listen).toHaveBeenCalledWith("/tmp/zigbee2mqtt.sock");
mockWS.clients.push(mockWSClient);
await controller.stop();
expect(mockWSClient.terminate).toHaveBeenCalledTimes(1);
@@ -194,58 +195,58 @@ describe('Extension: Frontend', () => {
expect(mockWS.close).toHaveBeenCalledTimes(1);
});
it('Start/stop HTTPS valid', async () => {
settings.set(['frontend', 'ssl_cert'], path.join(__dirname, '..', 'assets', 'certs', 'dummy.crt'));
settings.set(['frontend', 'ssl_key'], path.join(__dirname, '..', 'assets', 'certs', 'dummy.key'));
it("Start/stop HTTPS valid", async () => {
settings.set(["frontend", "ssl_cert"], path.join(__dirname, "..", "assets", "certs", "dummy.crt"));
settings.set(["frontend", "ssl_key"], path.join(__dirname, "..", "assets", "certs", "dummy.key"));
controller = new Controller(vi.fn(), vi.fn());
await controller.start();
expect(mockHTTP.listen).not.toHaveBeenCalledWith(8081, '127.0.0.1');
expect(mockHTTPS.listen).toHaveBeenCalledWith(8081, '127.0.0.1');
expect(mockHTTP.listen).not.toHaveBeenCalledWith(8081, "127.0.0.1");
expect(mockHTTPS.listen).toHaveBeenCalledWith(8081, "127.0.0.1");
await controller.stop();
});
it('Start/stop HTTPS invalid : missing config', async () => {
settings.set(['frontend', 'ssl_cert'], path.join(__dirname, '..', 'assets', 'certs', 'dummy.crt'));
it("Start/stop HTTPS invalid : missing config", async () => {
settings.set(["frontend", "ssl_cert"], path.join(__dirname, "..", "assets", "certs", "dummy.crt"));
controller = new Controller(vi.fn(), vi.fn());
await controller.start();
expect(mockHTTP.listen).toHaveBeenCalledWith(8081, '127.0.0.1');
expect(mockHTTPS.listen).not.toHaveBeenCalledWith(8081, '127.0.0.1');
expect(mockHTTP.listen).toHaveBeenCalledWith(8081, "127.0.0.1");
expect(mockHTTPS.listen).not.toHaveBeenCalledWith(8081, "127.0.0.1");
await controller.stop();
});
it('Start/stop HTTPS invalid : missing file', async () => {
settings.set(['frontend', 'ssl_cert'], 'filesNotExists.crt');
settings.set(['frontend', 'ssl_key'], path.join(__dirname, '..', 'assets', 'certs', 'dummy.key'));
it("Start/stop HTTPS invalid : missing file", async () => {
settings.set(["frontend", "ssl_cert"], "filesNotExists.crt");
settings.set(["frontend", "ssl_key"], path.join(__dirname, "..", "assets", "certs", "dummy.key"));
controller = new Controller(vi.fn(), vi.fn());
await controller.start();
expect(mockHTTP.listen).toHaveBeenCalledWith(8081, '127.0.0.1');
expect(mockHTTPS.listen).not.toHaveBeenCalledWith(8081, '127.0.0.1');
expect(mockHTTP.listen).toHaveBeenCalledWith(8081, "127.0.0.1");
expect(mockHTTPS.listen).not.toHaveBeenCalledWith(8081, "127.0.0.1");
await controller.stop();
});
it('Websocket interaction', async () => {
it("Websocket interaction", async () => {
controller = new Controller(vi.fn(), vi.fn());
await controller.start();
mockWSClient.readyState = 'open';
mockWSClient.readyState = "open";
mockWS.clients.push(mockWSClient);
await mockWSEvents.connection(mockWSClient);
const allTopics = mockWSClient.send.mock.calls.map(([m]) => JSON.parse(m).topic);
expect(allTopics).toContain('bridge/devices');
expect(allTopics).toContain('bridge/info');
expect(mockWSClient.send).toHaveBeenCalledWith(stringify({topic: 'bridge/state', payload: {state: 'online'}}));
expect(mockWSClient.send).toHaveBeenCalledWith(stringify({topic: 'remote', payload: {brightness: 255}}));
expect(allTopics).toContain("bridge/devices");
expect(allTopics).toContain("bridge/info");
expect(mockWSClient.send).toHaveBeenCalledWith(stringify({topic: "bridge/state", payload: {state: "online"}}));
expect(mockWSClient.send).toHaveBeenCalledWith(stringify({topic: "remote", payload: {brightness: 255}}));
// Message
mockMQTTPublishAsync.mockClear();
mockWSClient.send.mockClear();
mockWSClientEvents.message(stringify({topic: 'bulb_color/set', payload: {state: 'ON'}}), false);
mockWSClientEvents.message(stringify({topic: "bulb_color/set", payload: {state: "ON"}}), false);
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bulb_color',
"zigbee2mqtt/bulb_color",
stringify({
state: 'ON',
state: "ON",
effect: null,
power_on_behavior: null,
linkquality: null,
@@ -254,21 +255,21 @@ describe('Extension: Frontend', () => {
{retain: false, qos: 0},
);
mockWSClientEvents.message(undefined, false);
mockWSClientEvents.message('', false);
mockWSClientEvents.message("", false);
mockWSClientEvents.message(null, false);
await flushPromises();
// Error
mockWSClientEvents.error(new Error('This is an error'));
expect(mockLogger.error).toHaveBeenCalledWith('WebSocket error: This is an error');
mockWSClientEvents.error(new Error("This is an error"));
expect(mockLogger.error).toHaveBeenCalledWith("WebSocket error: This is an error");
// Received message on socket
expect(mockWSClient.send).toHaveBeenCalledTimes(1);
expect(mockWSClient.send).toHaveBeenCalledWith(
stringify({
topic: 'bulb_color',
topic: "bulb_color",
payload: {
state: 'ON',
state: "ON",
effect: null,
power_on_behavior: null,
linkquality: null,
@@ -279,76 +280,76 @@ describe('Extension: Frontend', () => {
// Shouldnt set when not ready
mockWSClient.send.mockClear();
mockWSClient.readyState = 'close';
mockWSClientEvents.message(stringify({topic: 'bulb_color/set', payload: {state: 'ON'}}), false);
mockWSClient.readyState = "close";
mockWSClientEvents.message(stringify({topic: "bulb_color/set", payload: {state: "ON"}}), false);
expect(mockWSClient.send).toHaveBeenCalledTimes(0);
// Send last seen on connect
mockWSClient.send.mockClear();
mockWSClient.readyState = 'open';
settings.set(['advanced'], {last_seen: 'ISO_8601'});
mockWSClient.readyState = "open";
settings.set(["advanced"], {last_seen: "ISO_8601"});
mockWS.clients.push(mockWSClient);
await mockWSEvents.connection(mockWSClient);
expect(mockWSClient.send).toHaveBeenCalledWith(
stringify({topic: 'remote', payload: {brightness: 255, last_seen: '1970-01-01T00:00:01.000Z'}}),
stringify({topic: "remote", payload: {brightness: 255, last_seen: "1970-01-01T00:00:01.000Z"}}),
);
});
it('onRequest/onUpgrade', async () => {
it("onRequest/onUpgrade", async () => {
controller = new Controller(vi.fn(), vi.fn());
await controller.start();
const mockSocket = {destroy: vi.fn()};
mockHTTPEvents.upgrade({url: 'http://localhost:8080/api'}, mockSocket, 3);
mockHTTPEvents.upgrade({url: "http://localhost:8080/api"}, mockSocket, 3);
expect(mockWS.handleUpgrade).toHaveBeenCalledTimes(1);
expect(mockSocket.destroy).toHaveBeenCalledTimes(0);
expect(mockWS.handleUpgrade).toHaveBeenCalledWith({url: 'http://localhost:8080/api'}, mockSocket, 3, expect.any(Function));
expect(mockWS.handleUpgrade).toHaveBeenCalledWith({url: "http://localhost:8080/api"}, mockSocket, 3, expect.any(Function));
mockWS.handleUpgrade.mock.calls[0][3](99);
expect(mockWS.emit).toHaveBeenCalledWith('connection', 99, {url: 'http://localhost:8080/api'});
expect(mockWS.emit).toHaveBeenCalledWith("connection", 99, {url: "http://localhost:8080/api"});
mockHTTPOnRequest({url: '/file.txt'}, 2);
mockHTTPOnRequest({url: "/file.txt"}, 2);
expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledTimes(0);
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1);
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith(
{originalUrl: '/file.txt', path: '/file.txt', url: '/file.txt'},
{originalUrl: "/file.txt", path: "/file.txt", url: "/file.txt"},
2,
expect.any(Function),
);
});
it('Should serve device icons', async () => {
it("Should serve device icons", async () => {
controller = new Controller(vi.fn(), vi.fn());
await controller.start();
mockHTTPOnRequest({url: '/device_icons/my_device.png'}, 2);
mockHTTPOnRequest({url: "/device_icons/my_device.png"}, 2);
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(0);
expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledTimes(1);
expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledWith(
{originalUrl: '/device_icons/my_device.png', path: '/my_device.png', url: '/my_device.png'},
{originalUrl: "/device_icons/my_device.png", path: "/my_device.png", url: "/my_device.png"},
2,
expect.any(Function),
);
});
it('Static server', async () => {
it("Static server", async () => {
controller = new Controller(vi.fn(), vi.fn());
await controller.start();
expect(mockHTTP.listen).toHaveBeenCalledWith(8081, '127.0.0.1');
expect(mockHTTP.listen).toHaveBeenCalledWith(8081, "127.0.0.1");
});
it('Authentification', async () => {
const authToken = 'sample-secure-token';
settings.set(['frontend', 'auth_token'], authToken);
it("Authentification", async () => {
const authToken = "sample-secure-token";
settings.set(["frontend", "auth_token"], authToken);
controller = new Controller(vi.fn(), vi.fn());
await controller.start();
const mockSocket = {destroy: vi.fn()};
mockHTTPEvents.upgrade({url: '/api'}, mockSocket, mockWSocket);
mockHTTPEvents.upgrade({url: "/api"}, mockSocket, mockWSocket);
expect(mockWS.handleUpgrade).toHaveBeenCalledTimes(1);
expect(mockSocket.destroy).toHaveBeenCalledTimes(0);
expect(mockWS.handleUpgrade).toHaveBeenCalledWith({url: '/api'}, mockSocket, mockWSocket, expect.any(Function));
expect(mockWSocket.close).toHaveBeenCalledWith(4401, 'Unauthorized');
expect(mockWS.handleUpgrade).toHaveBeenCalledWith({url: "/api"}, mockSocket, mockWSocket, expect.any(Function));
expect(mockWSocket.close).toHaveBeenCalledWith(4401, "Unauthorized");
mockWSocket.close.mockClear();
mockWS.emit.mockClear();
@@ -361,58 +362,58 @@ describe('Extension: Frontend', () => {
expect(mockWS.handleUpgrade).toHaveBeenCalledWith({url}, mockSocket, 3, expect.any(Function));
expect(mockWSocket.close).toHaveBeenCalledTimes(0);
mockWS.handleUpgrade.mock.calls[0][3](mockWSocket);
expect(mockWS.emit).toHaveBeenCalledWith('connection', mockWSocket, {url});
expect(mockWS.emit).toHaveBeenCalledWith("connection", mockWSocket, {url});
});
it.each(['/z2m/', '/z2m'])('Works with non-default base url %s', async (baseUrl) => {
settings.set(['frontend', 'base_url'], baseUrl);
it.each(["/z2m/", "/z2m"])("Works with non-default base url %s", async (baseUrl) => {
settings.set(["frontend", "base_url"], baseUrl);
controller = new Controller(vi.fn(), vi.fn());
await controller.start();
expect(ws.Server).toHaveBeenCalledWith({noServer: true, path: '/z2m/api'});
expect(ws.Server).toHaveBeenCalledWith({noServer: true, path: "/z2m/api"});
mockHTTPOnRequest({url: '/z2m'}, 2);
mockHTTPOnRequest({url: "/z2m"}, 2);
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1);
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith({originalUrl: '/z2m', path: '/', url: '/'}, 2, expect.any(Function));
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith({originalUrl: "/z2m", path: "/", url: "/"}, 2, expect.any(Function));
expect(mockFinalHandler).not.toHaveBeenCalledWith();
mockNodeStatic[frontendPath].mockReset();
expect(mockFinalHandler).not.toHaveBeenCalledWith();
mockHTTPOnRequest({url: '/z2m/file.txt'}, 2);
mockHTTPOnRequest({url: "/z2m/file.txt"}, 2);
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1);
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith(
{originalUrl: '/z2m/file.txt', path: '/file.txt', url: '/file.txt'},
{originalUrl: "/z2m/file.txt", path: "/file.txt", url: "/file.txt"},
2,
expect.any(Function),
);
expect(mockFinalHandler).not.toHaveBeenCalledWith();
mockNodeStatic[frontendPath].mockReset();
mockHTTPOnRequest({url: '/z/file.txt'}, 2);
mockHTTPOnRequest({url: "/z/file.txt"}, 2);
expect(mockNodeStatic[frontendPath]).not.toHaveBeenCalled();
expect(mockFinalHandler).toHaveBeenCalled();
mockHTTPOnRequest({url: '/z2m/device_icons/my-device.png'}, 2);
mockHTTPOnRequest({url: "/z2m/device_icons/my-device.png"}, 2);
expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledTimes(1);
expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledWith(
{originalUrl: '/z2m/device_icons/my-device.png', path: '/my-device.png', url: '/my-device.png'},
{originalUrl: "/z2m/device_icons/my-device.png", path: "/my-device.png", url: "/my-device.png"},
2,
expect.any(Function),
);
});
it('Works with non-default complex base url', async () => {
const baseUrl = '/z2m-more++/c0mplex.url/';
settings.set(['frontend', 'base_url'], baseUrl);
it("Works with non-default complex base url", async () => {
const baseUrl = "/z2m-more++/c0mplex.url/";
settings.set(["frontend", "base_url"], baseUrl);
controller = new Controller(vi.fn(), vi.fn());
await controller.start();
expect(ws.Server).toHaveBeenCalledWith({noServer: true, path: '/z2m-more++/c0mplex.url/api'});
expect(ws.Server).toHaveBeenCalledWith({noServer: true, path: "/z2m-more++/c0mplex.url/api"});
mockHTTPOnRequest({url: '/z2m-more++/c0mplex.url'}, 2);
mockHTTPOnRequest({url: "/z2m-more++/c0mplex.url"}, 2);
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1);
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith(
{originalUrl: '/z2m-more++/c0mplex.url', path: '/', url: '/'},
{originalUrl: "/z2m-more++/c0mplex.url", path: "/", url: "/"},
2,
expect.any(Function),
);
@@ -420,18 +421,46 @@ describe('Extension: Frontend', () => {
mockNodeStatic[frontendPath].mockReset();
expect(mockFinalHandler).not.toHaveBeenCalledWith();
mockHTTPOnRequest({url: '/z2m-more++/c0mplex.url/file.txt'}, 2);
mockHTTPOnRequest({url: "/z2m-more++/c0mplex.url/file.txt"}, 2);
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1);
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith(
{originalUrl: '/z2m-more++/c0mplex.url/file.txt', path: '/file.txt', url: '/file.txt'},
{originalUrl: "/z2m-more++/c0mplex.url/file.txt", path: "/file.txt", url: "/file.txt"},
2,
expect.any(Function),
);
expect(mockFinalHandler).not.toHaveBeenCalledWith();
mockNodeStatic[frontendPath].mockReset();
mockHTTPOnRequest({url: '/z/file.txt'}, 2);
mockHTTPOnRequest({url: "/z/file.txt"}, 2);
expect(mockNodeStatic[frontendPath]).not.toHaveBeenCalled();
expect(mockFinalHandler).toHaveBeenCalled();
});
it("prevents mismatching setting/extension state", async () => {
settings.set(["frontend", "enabled"], false);
controller = new Controller(vi.fn(), vi.fn());
await controller.start();
await expect(async () => {
await controller.enableDisableExtension(true, "Frontend");
}).rejects.toThrow("Tried to enable Frontend extension disabled in settings");
settings.set(["frontend", "enabled"], true);
await expect(async () => {
await controller.enableDisableExtension(false, "Frontend");
}).rejects.toThrow("Tried to disable Frontend extension enabled in settings");
await controller.enableDisableExtension(true, "Frontend");
await expect(async () => {
await controller.enableDisableExtension(true, "Frontend");
}).rejects.toThrow("Extension with name Frontend already present");
settings.set(["frontend", "enabled"], false);
await controller.enableDisableExtension(false, "Frontend");
await vi.waitFor(() => controller.getExtension("Frontend") === undefined);
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+270 -253
View File
@@ -1,17 +1,17 @@
import * as data from '../mocks/data';
import {mockLogger} from '../mocks/logger';
import {events as mockMQTTEvents, mockMQTTPublishAsync} from '../mocks/mqtt';
import * as mockSleep from '../mocks/sleep';
import {flushPromises} from '../mocks/utils';
import {devices, events as mockZHEvents, returnDevices} from '../mocks/zigbeeHerdsman';
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
import * as mockSleep from "../mocks/sleep";
import {flushPromises} from "../mocks/utils";
import {devices, events as mockZHEvents, returnDevices} from "../mocks/zigbeeHerdsman";
import fs from 'node:fs';
import path from 'node:path';
import fs from "node:fs";
import path from "node:path";
import stringify from 'json-stable-stringify-without-jsonify';
import stringify from "json-stable-stringify-without-jsonify";
import {Controller} from '../../lib/controller';
import * as settings from '../../lib/util/settings';
import {Controller} from "../../lib/controller";
import * as settings from "../../lib/util/settings";
returnDevices.push(
devices.coordinator.ieeeAddr,
@@ -25,7 +25,7 @@ returnDevices.push(
const mocksClear = [mockMQTTPublishAsync, mockLogger.warning, mockLogger.debug];
describe('Extension: NetworkMap', () => {
describe("Extension: NetworkMap", () => {
let controller: Controller;
const mock = (): void => {
@@ -59,7 +59,7 @@ describe('Extension: NetworkMap', () => {
],
});
devices.coordinator.routingTable.mockResolvedValueOnce({
table: [{destinationAddress: devices.CC2530_ROUTER.networkAddress, status: 'ACTIVE', nextHop: devices.bulb.networkAddress}],
table: [{destinationAddress: devices.CC2530_ROUTER.networkAddress, status: "ACTIVE", nextHop: devices.bulb.networkAddress}],
});
devices.bulb.lqi.mockResolvedValueOnce({
neighbors: [
@@ -81,7 +81,7 @@ describe('Extension: NetworkMap', () => {
});
devices.CC2530_ROUTER.lqi.mockResolvedValueOnce({
neighbors: [
{ieeeAddr: '0x0000000000000000', networkAddress: devices.WXKG02LM_rev1.networkAddress, relationship: 1, depth: 2, linkquality: 130},
{ieeeAddr: "0x0000000000000000", networkAddress: devices.WXKG02LM_rev1.networkAddress, relationship: 1, depth: 2, linkquality: 130},
{
ieeeAddr: devices.bulb_color.ieeeAddr,
networkAddress: devices.bulb_color.networkAddress,
@@ -91,28 +91,28 @@ describe('Extension: NetworkMap', () => {
},
],
});
devices.unsupported_router.lqi.mockRejectedValueOnce('failed').mockRejectedValueOnce('failed');
devices.unsupported_router.routingTable.mockRejectedValueOnce('failed').mockRejectedValueOnce('failed');
devices.unsupported_router.lqi.mockRejectedValueOnce("failed").mockRejectedValueOnce("failed");
devices.unsupported_router.routingTable.mockRejectedValueOnce("failed").mockRejectedValueOnce("failed");
};
beforeAll(async () => {
vi.useFakeTimers();
mockSleep.mock();
vi.spyOn(Date, 'now').mockReturnValue(10000);
vi.spyOn(Date, "now").mockReturnValue(10000);
data.writeDefaultConfiguration();
settings.reRead();
data.writeEmptyState();
fs.mkdirSync(path.join(data.mockDir, 'external_converters'));
fs.mkdirSync(path.join(data.mockDir, "external_converters"));
fs.copyFileSync(
path.join(__dirname, '..', 'assets', 'external_converters', 'mock-external-converter.js'),
path.join(data.mockDir, 'external_converters', 'mock-external-converter.js'),
path.join(__dirname, "..", "assets", "external_converters", "cjs", "mock-external-converter.js"),
path.join(data.mockDir, "external_converters", "mock-external-converter.js"),
);
controller = new Controller(vi.fn(), vi.fn());
await controller.start();
});
beforeEach(async () => {
mocksClear.forEach((m) => m.mockClear());
for (const mock of mocksClear) mock.mockClear();
await flushPromises();
const device = devices.bulb_color;
device.lastSeen = 1000;
@@ -123,21 +123,23 @@ describe('Extension: NetworkMap', () => {
afterAll(async () => {
mockSleep.restore();
fs.rmSync(path.join(data.mockDir, 'external_converters'), {recursive: true});
fs.rmSync(path.join(data.mockDir, "external_converters"), {recursive: true});
await controller?.stop();
await flushPromises();
vi.useRealTimers();
});
it('Output raw networkmap', async () => {
it("Output raw networkmap", async () => {
mock();
mockMQTTEvents.message('zigbee2mqtt/bridge/request/networkmap', stringify({type: 'raw', routes: true}));
mockMQTTEvents.message("zigbee2mqtt/bridge/request/networkmap", stringify({type: "raw", routes: true}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/response/networkmap');
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bridge/response/networkmap");
const expected = {
data: {
routes: true,
type: 'raw',
type: "raw",
value: {
links: [
{
@@ -146,23 +148,23 @@ describe('Extension: NetworkMap', () => {
lqi: 120,
relationship: 2,
routes: [],
source: {ieeeAddr: '0x000b57fffec6a5b3', networkAddress: 40399},
sourceIeeeAddr: '0x000b57fffec6a5b3',
source: {ieeeAddr: "0x000b57fffec6a5b3", networkAddress: 40399},
sourceIeeeAddr: "0x000b57fffec6a5b3",
sourceNwkAddr: 40399,
target: {ieeeAddr: '0x00124b00120144ae', networkAddress: 0},
targetIeeeAddr: '0x00124b00120144ae',
target: {ieeeAddr: "0x00124b00120144ae", networkAddress: 0},
targetIeeeAddr: "0x00124b00120144ae",
},
{
depth: 1,
linkquality: 92,
lqi: 92,
relationship: 2,
routes: [{destinationAddress: 6540, nextHop: 40369, status: 'ACTIVE'}],
source: {ieeeAddr: '0x000b57fffec6a5b2', networkAddress: 40369},
sourceIeeeAddr: '0x000b57fffec6a5b2',
routes: [{destinationAddress: 6540, nextHop: 40369, status: "ACTIVE"}],
source: {ieeeAddr: "0x000b57fffec6a5b2", networkAddress: 40369},
sourceIeeeAddr: "0x000b57fffec6a5b2",
sourceNwkAddr: 40369,
target: {ieeeAddr: '0x00124b00120144ae', networkAddress: 0},
targetIeeeAddr: '0x00124b00120144ae',
target: {ieeeAddr: "0x00124b00120144ae", networkAddress: 0},
targetIeeeAddr: "0x00124b00120144ae",
},
{
depth: 1,
@@ -170,11 +172,11 @@ describe('Extension: NetworkMap', () => {
lqi: 92,
relationship: 2,
routes: [],
source: {ieeeAddr: '0x0017880104e45511', networkAddress: 1114},
sourceIeeeAddr: '0x0017880104e45511',
source: {ieeeAddr: "0x0017880104e45511", networkAddress: 1114},
sourceIeeeAddr: "0x0017880104e45511",
sourceNwkAddr: 1114,
target: {ieeeAddr: '0x00124b00120144ae', networkAddress: 0},
targetIeeeAddr: '0x00124b00120144ae',
target: {ieeeAddr: "0x00124b00120144ae", networkAddress: 0},
targetIeeeAddr: "0x00124b00120144ae",
},
{
depth: 2,
@@ -182,11 +184,11 @@ describe('Extension: NetworkMap', () => {
lqi: 110,
relationship: 1,
routes: [],
source: {ieeeAddr: '0x000b57fffec6a5b3', networkAddress: 40399},
sourceIeeeAddr: '0x000b57fffec6a5b3',
source: {ieeeAddr: "0x000b57fffec6a5b3", networkAddress: 40399},
sourceIeeeAddr: "0x000b57fffec6a5b3",
sourceNwkAddr: 40399,
target: {ieeeAddr: '0x000b57fffec6a5b2', networkAddress: 40369},
targetIeeeAddr: '0x000b57fffec6a5b2',
target: {ieeeAddr: "0x000b57fffec6a5b2", networkAddress: 40369},
targetIeeeAddr: "0x000b57fffec6a5b2",
},
{
depth: 2,
@@ -194,11 +196,11 @@ describe('Extension: NetworkMap', () => {
lqi: 100,
relationship: 1,
routes: [],
source: {ieeeAddr: '0x0017880104e45559', networkAddress: 6540},
sourceIeeeAddr: '0x0017880104e45559',
source: {ieeeAddr: "0x0017880104e45559", networkAddress: 6540},
sourceIeeeAddr: "0x0017880104e45559",
sourceNwkAddr: 6540,
target: {ieeeAddr: '0x000b57fffec6a5b2', networkAddress: 40369},
targetIeeeAddr: '0x000b57fffec6a5b2',
target: {ieeeAddr: "0x000b57fffec6a5b2", networkAddress: 40369},
targetIeeeAddr: "0x000b57fffec6a5b2",
},
{
depth: 2,
@@ -206,131 +208,136 @@ describe('Extension: NetworkMap', () => {
lqi: 130,
relationship: 1,
routes: [],
source: {ieeeAddr: '0x0017880104e45521', networkAddress: 6538},
sourceIeeeAddr: '0x0017880104e45521',
source: {ieeeAddr: "0x0017880104e45521", networkAddress: 6538},
sourceIeeeAddr: "0x0017880104e45521",
sourceNwkAddr: 6538,
target: {ieeeAddr: '0x0017880104e45559', networkAddress: 6540},
targetIeeeAddr: '0x0017880104e45559',
target: {ieeeAddr: "0x0017880104e45559", networkAddress: 6540},
targetIeeeAddr: "0x0017880104e45559",
},
],
nodes: [
{
// definition: null,
failed: [],
friendlyName: 'Coordinator',
ieeeAddr: '0x00124b00120144ae',
friendlyName: "Coordinator",
ieeeAddr: "0x00124b00120144ae",
lastSeen: 1000,
// modelID: null,
networkAddress: 0,
type: 'Coordinator',
type: "Coordinator",
},
{
definition: {
description: 'TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm',
model: 'LED1545G12',
description: "TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm",
model: "LED1545G12",
supports:
'light (state, brightness, color_temp, color_temp_startup, level_config), effect, power_on_behavior, color_options, identify, linkquality',
vendor: 'IKEA',
"light (state, brightness, color_temp, color_temp_startup, level_config), effect, power_on_behavior, color_options, identify, linkquality",
vendor: "IKEA",
},
failed: [],
friendlyName: 'bulb',
ieeeAddr: '0x000b57fffec6a5b2',
friendlyName: "bulb",
ieeeAddr: "0x000b57fffec6a5b2",
lastSeen: 1000,
modelID: 'TRADFRI bulb E27 WS opal 980lm',
modelID: "TRADFRI bulb E27 WS opal 980lm",
networkAddress: 40369,
type: 'Router',
type: "Router",
},
{
definition: {
description: 'Hue Go',
model: '7146060PH',
description: "Hue Go",
model: "7146060PH",
supports:
'light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), power_on_behavior, effect, linkquality',
vendor: 'Philips',
"light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), power_on_behavior, effect, linkquality",
vendor: "Philips",
},
failed: [],
friendlyName: 'bulb_color',
ieeeAddr: '0x000b57fffec6a5b3',
friendlyName: "bulb_color",
ieeeAddr: "0x000b57fffec6a5b3",
lastSeen: 1000,
modelID: 'LLC020',
modelID: "LLC020",
networkAddress: 40399,
type: 'Router',
type: "Router",
},
{
definition: {
description: 'Wireless remote switch (double rocker), 2016 model',
model: 'WXKG02LM_rev1',
supports: 'battery, voltage, power_outage_count, action, linkquality',
vendor: 'Aqara',
description: "Wireless remote switch (double rocker), 2016 model",
model: "WXKG02LM_rev1",
supports: "battery, voltage, power_outage_count, action, linkquality",
vendor: "Aqara",
},
friendlyName: 'button_double_key',
ieeeAddr: '0x0017880104e45521',
friendlyName: "button_double_key",
ieeeAddr: "0x0017880104e45521",
lastSeen: 1000,
modelID: 'lumi.sensor_86sw2.es1',
modelID: "lumi.sensor_86sw2.es1",
networkAddress: 6538,
type: 'EndDevice',
type: "EndDevice",
},
{
definition: {
description: 'Automatically generated definition',
model: 'notSupportedModelID',
supports: 'action, linkquality',
vendor: 'Boef',
description: "Automatically generated definition",
model: "notSupportedModelID",
supports: "action, linkquality",
vendor: "Boef",
},
failed: ['lqi', 'routingTable'],
friendlyName: '0x0017880104e45525',
ieeeAddr: '0x0017880104e45525',
failed: ["lqi", "routingTable"],
friendlyName: "0x0017880104e45525",
ieeeAddr: "0x0017880104e45525",
lastSeen: 1000,
manufacturerName: 'Boef',
modelID: 'notSupportedModelID',
manufacturerName: "Boef",
modelID: "notSupportedModelID",
networkAddress: 6536,
type: 'Router',
type: "Router",
},
{
definition: {
description: 'CC2530 router',
model: 'CC2530.ROUTER',
supports: 'led, linkquality',
vendor: 'Custom devices (DiY)',
description: "CC2530 router",
model: "CC2530.ROUTER",
supports: "led, linkquality",
vendor: "Custom devices (DiY)",
},
failed: [],
friendlyName: 'cc2530_router',
ieeeAddr: '0x0017880104e45559',
friendlyName: "cc2530_router",
ieeeAddr: "0x0017880104e45559",
lastSeen: 1000,
modelID: 'lumi.router',
modelID: "lumi.router",
networkAddress: 6540,
type: 'Router',
type: "Router",
},
{
definition: {description: 'external', model: 'external_converter_device', supports: 'linkquality', vendor: 'external'},
friendlyName: '0x0017880104e45511',
ieeeAddr: '0x0017880104e45511',
definition: {
description: "external/converter",
model: "external_converter_device",
supports: "linkquality",
vendor: "external",
},
friendlyName: "0x0017880104e45511",
ieeeAddr: "0x0017880104e45511",
lastSeen: 1000,
modelID: 'external_converter_device',
modelID: "external_converter_device",
networkAddress: 1114,
type: 'EndDevice',
type: "EndDevice",
},
],
},
},
status: 'ok',
status: "ok",
};
const actual = JSON.parse(mockMQTTPublishAsync.mock.calls[0][1]);
expect(actual).toStrictEqual(expected);
});
it('Output graphviz networkmap', async () => {
it("Output graphviz networkmap", async () => {
mock();
const device = devices.bulb_color;
device.lastSeen = undefined;
const endpoint = device.getEndpoint(1);
const data = {modelID: 'test'};
const payload = {data, cluster: 'genOnOff', device, endpoint, type: 'readResponse', linkquality: 10};
const data = {modelID: "test"};
const payload = {data, cluster: "genOnOff", device, endpoint, type: "readResponse", linkquality: 10};
await mockZHEvents.message(payload);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/networkmap', stringify({type: 'graphviz', routes: true}));
mockMQTTEvents.message("zigbee2mqtt/bridge/request/networkmap", stringify({type: "graphviz", routes: true}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/response/networkmap');
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bridge/response/networkmap");
const expected = `digraph G {
node[shape=record];
@@ -345,30 +352,30 @@ describe('Extension: NetworkMap', () => {
"0x0017880104e45525" [style="rounded, filled", fillcolor="#4ea3e0", fontcolor="#ffffff", label="{0x0017880104e45525|0x0017880104e45525 (0x1988)failed: lqi,routingTable|Boef Automatically generated definition (notSupportedModelID)|9 seconds ago}"];
"0x0017880104e45559" [style="rounded, filled", fillcolor="#4ea3e0", fontcolor="#ffffff", label="{cc2530_router|0x0017880104e45559 (0x198c)|Custom devices (DiY) CC2530 router (CC2530.ROUTER)|9 seconds ago}"];
"0x0017880104e45559" -> "0x000b57fffec6a5b2" [penwidth=0.5, weight=0, color="#994444", label="100"]
"0x0017880104e45511" [style="rounded, dashed, filled", fillcolor="#fff8ce", fontcolor="#000000", label="{0x0017880104e45511|0x0017880104e45511 (0x045a)|external external (external_converter_device)|9 seconds ago}"];
"0x0017880104e45511" [style="rounded, dashed, filled", fillcolor="#fff8ce", fontcolor="#000000", label="{0x0017880104e45511|0x0017880104e45511 (0x045a)|external external/converter (external_converter_device)|9 seconds ago}"];
"0x0017880104e45511" -> "0x00124b00120144ae" [penwidth=1, weight=0, color="#994444", label="92"]
}`;
const expectedLines = expected.split('\n');
const actualLines = JSON.parse(mockMQTTPublishAsync.mock.calls[0][1]).data.value.split('\n');
const expectedLines = expected.split("\n");
const actualLines = JSON.parse(mockMQTTPublishAsync.mock.calls[0][1]).data.value.split("\n");
for (let i = 0; i < expectedLines.length; i++) {
expect(actualLines[i].trim()).toStrictEqual(expectedLines[i].trim());
}
});
it('Output plantuml networkmap', async () => {
it("Output plantuml networkmap", async () => {
mock();
const device = devices.bulb_color;
device.lastSeen = undefined;
const endpoint = device.getEndpoint(1);
const data = {modelID: 'test'};
const payload = {data, cluster: 'genOnOff', device, endpoint, type: 'readResponse', linkquality: 10};
const data = {modelID: "test"};
const payload = {data, cluster: "genOnOff", device, endpoint, type: "readResponse", linkquality: 10};
await mockZHEvents.message(payload);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/networkmap', stringify({type: 'plantuml', routes: true}));
mockMQTTEvents.message("zigbee2mqtt/bridge/request/networkmap", stringify({type: "plantuml", routes: true}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/response/networkmap');
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bridge/response/networkmap");
const expected = `' paste into: https://www.planttext.com/
@@ -378,7 +385,7 @@ describe('Extension: NetworkMap', () => {
---
0x0017880104e45511 (0x045a)
---
external external (external_converter_device)
external external/converter (external_converter_device)
---
9 seconds ago
]
@@ -450,38 +457,38 @@ describe('Extension: NetworkMap', () => {
@enduml`;
const expectedLines = expected.split('\n');
const actualLines = JSON.parse(mockMQTTPublishAsync.mock.calls[0][1]).data.value.split('\n');
const expectedLines = expected.split("\n");
const actualLines = JSON.parse(mockMQTTPublishAsync.mock.calls[0][1]).data.value.split("\n");
for (let i = 0; i < expectedLines.length; i++) {
expect(actualLines[i].trim()).toStrictEqual(expectedLines[i].trim());
}
});
it('Should throw error when requesting invalid type', async () => {
it("Should throw error when requesting invalid type", async () => {
mock();
mockMQTTEvents.message('zigbee2mqtt/bridge/request/networkmap', 'not_existing');
mockMQTTEvents.message("zigbee2mqtt/bridge/request/networkmap", "not_existing");
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/networkmap',
stringify({data: {}, status: 'error', error: "Type 'not_existing' not supported, allowed are: raw,graphviz,plantuml"}),
"zigbee2mqtt/bridge/response/networkmap",
stringify({data: {}, status: "error", error: "Type 'not_existing' not supported, allowed are: raw,graphviz,plantuml"}),
{retain: false, qos: 0},
);
});
it('Should exclude disabled devices from networkmap', async () => {
settings.set(['devices', '0x000b57fffec6a5b2', 'disabled'], true);
it("Should exclude disabled devices from networkmap", async () => {
settings.set(["devices", "0x000b57fffec6a5b2", "disabled"], true);
mock();
mockMQTTEvents.message('zigbee2mqtt/bridge/request/networkmap', stringify({type: 'raw', routes: true}));
mockMQTTEvents.message("zigbee2mqtt/bridge/request/networkmap", stringify({type: "raw", routes: true}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/response/networkmap');
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bridge/response/networkmap");
const expected = {
data: {
routes: true,
type: 'raw',
type: "raw",
value: {
links: [
{
@@ -490,23 +497,23 @@ describe('Extension: NetworkMap', () => {
lqi: 120,
relationship: 2,
routes: [],
source: {ieeeAddr: '0x000b57fffec6a5b3', networkAddress: 40399},
sourceIeeeAddr: '0x000b57fffec6a5b3',
source: {ieeeAddr: "0x000b57fffec6a5b3", networkAddress: 40399},
sourceIeeeAddr: "0x000b57fffec6a5b3",
sourceNwkAddr: 40399,
target: {ieeeAddr: '0x00124b00120144ae', networkAddress: 0},
targetIeeeAddr: '0x00124b00120144ae',
target: {ieeeAddr: "0x00124b00120144ae", networkAddress: 0},
targetIeeeAddr: "0x00124b00120144ae",
},
{
depth: 1,
linkquality: 92,
lqi: 92,
relationship: 2,
routes: [{destinationAddress: 6540, nextHop: 40369, status: 'ACTIVE'}],
source: {ieeeAddr: '0x000b57fffec6a5b2', networkAddress: 40369},
sourceIeeeAddr: '0x000b57fffec6a5b2',
routes: [{destinationAddress: 6540, nextHop: 40369, status: "ACTIVE"}],
source: {ieeeAddr: "0x000b57fffec6a5b2", networkAddress: 40369},
sourceIeeeAddr: "0x000b57fffec6a5b2",
sourceNwkAddr: 40369,
target: {ieeeAddr: '0x00124b00120144ae', networkAddress: 0},
targetIeeeAddr: '0x00124b00120144ae',
target: {ieeeAddr: "0x00124b00120144ae", networkAddress: 0},
targetIeeeAddr: "0x00124b00120144ae",
},
{
depth: 1,
@@ -514,11 +521,11 @@ describe('Extension: NetworkMap', () => {
lqi: 92,
relationship: 2,
routes: [],
source: {ieeeAddr: '0x0017880104e45511', networkAddress: 1114},
sourceIeeeAddr: '0x0017880104e45511',
source: {ieeeAddr: "0x0017880104e45511", networkAddress: 1114},
sourceIeeeAddr: "0x0017880104e45511",
sourceNwkAddr: 1114,
target: {ieeeAddr: '0x00124b00120144ae', networkAddress: 0},
targetIeeeAddr: '0x00124b00120144ae',
target: {ieeeAddr: "0x00124b00120144ae", networkAddress: 0},
targetIeeeAddr: "0x00124b00120144ae",
},
{
depth: 2,
@@ -526,114 +533,119 @@ describe('Extension: NetworkMap', () => {
lqi: 130,
relationship: 1,
routes: [],
source: {ieeeAddr: '0x0017880104e45521', networkAddress: 6538},
sourceIeeeAddr: '0x0017880104e45521',
source: {ieeeAddr: "0x0017880104e45521", networkAddress: 6538},
sourceIeeeAddr: "0x0017880104e45521",
sourceNwkAddr: 6538,
target: {ieeeAddr: '0x0017880104e45559', networkAddress: 6540},
targetIeeeAddr: '0x0017880104e45559',
target: {ieeeAddr: "0x0017880104e45559", networkAddress: 6540},
targetIeeeAddr: "0x0017880104e45559",
},
],
nodes: [
{
failed: [],
friendlyName: 'Coordinator',
ieeeAddr: '0x00124b00120144ae',
friendlyName: "Coordinator",
ieeeAddr: "0x00124b00120144ae",
lastSeen: 1000,
networkAddress: 0,
type: 'Coordinator',
type: "Coordinator",
},
{
definition: {
description: 'Hue Go',
model: '7146060PH',
description: "Hue Go",
model: "7146060PH",
supports:
'light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), power_on_behavior, effect, linkquality',
vendor: 'Philips',
"light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), power_on_behavior, effect, linkquality",
vendor: "Philips",
},
failed: [],
friendlyName: 'bulb_color',
ieeeAddr: '0x000b57fffec6a5b3',
friendlyName: "bulb_color",
ieeeAddr: "0x000b57fffec6a5b3",
lastSeen: 1000,
modelID: 'LLC020',
modelID: "LLC020",
networkAddress: 40399,
type: 'Router',
type: "Router",
},
{
definition: {
description: 'Wireless remote switch (double rocker), 2016 model',
model: 'WXKG02LM_rev1',
supports: 'battery, voltage, power_outage_count, action, linkquality',
vendor: 'Aqara',
description: "Wireless remote switch (double rocker), 2016 model",
model: "WXKG02LM_rev1",
supports: "battery, voltage, power_outage_count, action, linkquality",
vendor: "Aqara",
},
friendlyName: 'button_double_key',
ieeeAddr: '0x0017880104e45521',
friendlyName: "button_double_key",
ieeeAddr: "0x0017880104e45521",
lastSeen: 1000,
modelID: 'lumi.sensor_86sw2.es1',
modelID: "lumi.sensor_86sw2.es1",
networkAddress: 6538,
type: 'EndDevice',
type: "EndDevice",
},
{
definition: {
description: 'Automatically generated definition',
model: 'notSupportedModelID',
supports: 'action, linkquality',
vendor: 'Boef',
description: "Automatically generated definition",
model: "notSupportedModelID",
supports: "action, linkquality",
vendor: "Boef",
},
failed: ['lqi', 'routingTable'],
friendlyName: '0x0017880104e45525',
ieeeAddr: '0x0017880104e45525',
failed: ["lqi", "routingTable"],
friendlyName: "0x0017880104e45525",
ieeeAddr: "0x0017880104e45525",
lastSeen: 1000,
manufacturerName: 'Boef',
modelID: 'notSupportedModelID',
manufacturerName: "Boef",
modelID: "notSupportedModelID",
networkAddress: 6536,
type: 'Router',
type: "Router",
},
{
definition: {
description: 'CC2530 router',
model: 'CC2530.ROUTER',
supports: 'led, linkquality',
vendor: 'Custom devices (DiY)',
description: "CC2530 router",
model: "CC2530.ROUTER",
supports: "led, linkquality",
vendor: "Custom devices (DiY)",
},
failed: [],
friendlyName: 'cc2530_router',
ieeeAddr: '0x0017880104e45559',
friendlyName: "cc2530_router",
ieeeAddr: "0x0017880104e45559",
lastSeen: 1000,
modelID: 'lumi.router',
modelID: "lumi.router",
networkAddress: 6540,
type: 'Router',
type: "Router",
},
{
definition: {description: 'external', model: 'external_converter_device', supports: 'linkquality', vendor: 'external'},
friendlyName: '0x0017880104e45511',
ieeeAddr: '0x0017880104e45511',
definition: {
description: "external/converter",
model: "external_converter_device",
supports: "linkquality",
vendor: "external",
},
friendlyName: "0x0017880104e45511",
ieeeAddr: "0x0017880104e45511",
lastSeen: 1000,
modelID: 'external_converter_device',
modelID: "external_converter_device",
networkAddress: 1114,
type: 'EndDevice',
type: "EndDevice",
},
],
},
},
status: 'ok',
status: "ok",
};
const actual = JSON.parse(mockMQTTPublishAsync.mock.calls[0][1]);
expect(actual).toStrictEqual(expected);
});
it('Handles retrying request when first attempt fails', async () => {
settings.set(['devices', '0x000b57fffec6a5b2', 'disabled'], true);
it("Handles retrying request when first attempt fails", async () => {
settings.set(["devices", "0x000b57fffec6a5b2", "disabled"], true);
mock();
devices.bulb.lqi.mockRejectedValueOnce('failed');
mockMQTTEvents.message('zigbee2mqtt/bridge/request/networkmap', stringify({type: 'raw', routes: true}));
devices.bulb.lqi.mockRejectedValueOnce("failed");
mockMQTTEvents.message("zigbee2mqtt/bridge/request/networkmap", stringify({type: "raw", routes: true}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/response/networkmap');
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bridge/response/networkmap");
const expected = {
data: {
routes: true,
type: 'raw',
type: "raw",
value: {
links: [
{
@@ -642,23 +654,23 @@ describe('Extension: NetworkMap', () => {
lqi: 120,
relationship: 2,
routes: [],
source: {ieeeAddr: '0x000b57fffec6a5b3', networkAddress: 40399},
sourceIeeeAddr: '0x000b57fffec6a5b3',
source: {ieeeAddr: "0x000b57fffec6a5b3", networkAddress: 40399},
sourceIeeeAddr: "0x000b57fffec6a5b3",
sourceNwkAddr: 40399,
target: {ieeeAddr: '0x00124b00120144ae', networkAddress: 0},
targetIeeeAddr: '0x00124b00120144ae',
target: {ieeeAddr: "0x00124b00120144ae", networkAddress: 0},
targetIeeeAddr: "0x00124b00120144ae",
},
{
depth: 1,
linkquality: 92,
lqi: 92,
relationship: 2,
routes: [{destinationAddress: 6540, nextHop: 40369, status: 'ACTIVE'}],
source: {ieeeAddr: '0x000b57fffec6a5b2', networkAddress: 40369},
sourceIeeeAddr: '0x000b57fffec6a5b2',
routes: [{destinationAddress: 6540, nextHop: 40369, status: "ACTIVE"}],
source: {ieeeAddr: "0x000b57fffec6a5b2", networkAddress: 40369},
sourceIeeeAddr: "0x000b57fffec6a5b2",
sourceNwkAddr: 40369,
target: {ieeeAddr: '0x00124b00120144ae', networkAddress: 0},
targetIeeeAddr: '0x00124b00120144ae',
target: {ieeeAddr: "0x00124b00120144ae", networkAddress: 0},
targetIeeeAddr: "0x00124b00120144ae",
},
{
depth: 1,
@@ -666,11 +678,11 @@ describe('Extension: NetworkMap', () => {
lqi: 92,
relationship: 2,
routes: [],
source: {ieeeAddr: '0x0017880104e45511', networkAddress: 1114},
sourceIeeeAddr: '0x0017880104e45511',
source: {ieeeAddr: "0x0017880104e45511", networkAddress: 1114},
sourceIeeeAddr: "0x0017880104e45511",
sourceNwkAddr: 1114,
target: {ieeeAddr: '0x00124b00120144ae', networkAddress: 0},
targetIeeeAddr: '0x00124b00120144ae',
target: {ieeeAddr: "0x00124b00120144ae", networkAddress: 0},
targetIeeeAddr: "0x00124b00120144ae",
},
{
depth: 2,
@@ -678,96 +690,101 @@ describe('Extension: NetworkMap', () => {
lqi: 130,
relationship: 1,
routes: [],
source: {ieeeAddr: '0x0017880104e45521', networkAddress: 6538},
sourceIeeeAddr: '0x0017880104e45521',
source: {ieeeAddr: "0x0017880104e45521", networkAddress: 6538},
sourceIeeeAddr: "0x0017880104e45521",
sourceNwkAddr: 6538,
target: {ieeeAddr: '0x0017880104e45559', networkAddress: 6540},
targetIeeeAddr: '0x0017880104e45559',
target: {ieeeAddr: "0x0017880104e45559", networkAddress: 6540},
targetIeeeAddr: "0x0017880104e45559",
},
],
nodes: [
{
failed: [],
friendlyName: 'Coordinator',
ieeeAddr: '0x00124b00120144ae',
friendlyName: "Coordinator",
ieeeAddr: "0x00124b00120144ae",
lastSeen: 1000,
networkAddress: 0,
type: 'Coordinator',
type: "Coordinator",
},
{
definition: {
description: 'Hue Go',
model: '7146060PH',
description: "Hue Go",
model: "7146060PH",
supports:
'light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), power_on_behavior, effect, linkquality',
vendor: 'Philips',
"light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), power_on_behavior, effect, linkquality",
vendor: "Philips",
},
failed: [],
friendlyName: 'bulb_color',
ieeeAddr: '0x000b57fffec6a5b3',
friendlyName: "bulb_color",
ieeeAddr: "0x000b57fffec6a5b3",
lastSeen: 1000,
modelID: 'LLC020',
modelID: "LLC020",
networkAddress: 40399,
type: 'Router',
type: "Router",
},
{
definition: {
description: 'Wireless remote switch (double rocker), 2016 model',
model: 'WXKG02LM_rev1',
supports: 'battery, voltage, power_outage_count, action, linkquality',
vendor: 'Aqara',
description: "Wireless remote switch (double rocker), 2016 model",
model: "WXKG02LM_rev1",
supports: "battery, voltage, power_outage_count, action, linkquality",
vendor: "Aqara",
},
friendlyName: 'button_double_key',
ieeeAddr: '0x0017880104e45521',
friendlyName: "button_double_key",
ieeeAddr: "0x0017880104e45521",
lastSeen: 1000,
modelID: 'lumi.sensor_86sw2.es1',
modelID: "lumi.sensor_86sw2.es1",
networkAddress: 6538,
type: 'EndDevice',
type: "EndDevice",
},
{
definition: {
description: 'Automatically generated definition',
model: 'notSupportedModelID',
supports: 'action, linkquality',
vendor: 'Boef',
description: "Automatically generated definition",
model: "notSupportedModelID",
supports: "action, linkquality",
vendor: "Boef",
},
failed: ['lqi', 'routingTable'],
friendlyName: '0x0017880104e45525',
ieeeAddr: '0x0017880104e45525',
failed: ["lqi", "routingTable"],
friendlyName: "0x0017880104e45525",
ieeeAddr: "0x0017880104e45525",
lastSeen: 1000,
manufacturerName: 'Boef',
modelID: 'notSupportedModelID',
manufacturerName: "Boef",
modelID: "notSupportedModelID",
networkAddress: 6536,
type: 'Router',
type: "Router",
},
{
definition: {
description: 'CC2530 router',
model: 'CC2530.ROUTER',
supports: 'led, linkquality',
vendor: 'Custom devices (DiY)',
description: "CC2530 router",
model: "CC2530.ROUTER",
supports: "led, linkquality",
vendor: "Custom devices (DiY)",
},
failed: [],
friendlyName: 'cc2530_router',
ieeeAddr: '0x0017880104e45559',
friendlyName: "cc2530_router",
ieeeAddr: "0x0017880104e45559",
lastSeen: 1000,
modelID: 'lumi.router',
modelID: "lumi.router",
networkAddress: 6540,
type: 'Router',
type: "Router",
},
{
definition: {description: 'external', model: 'external_converter_device', supports: 'linkquality', vendor: 'external'},
friendlyName: '0x0017880104e45511',
ieeeAddr: '0x0017880104e45511',
definition: {
description: "external/converter",
model: "external_converter_device",
supports: "linkquality",
vendor: "external",
},
friendlyName: "0x0017880104e45511",
ieeeAddr: "0x0017880104e45511",
lastSeen: 1000,
modelID: 'external_converter_device',
modelID: "external_converter_device",
networkAddress: 1114,
type: 'EndDevice',
type: "EndDevice",
},
],
},
},
status: 'ok',
status: "ok",
};
const actual = JSON.parse(mockMQTTPublishAsync.mock.calls[0][1]);
expect(actual).toStrictEqual(expected);
+145 -65
View File
@@ -1,101 +1,181 @@
import * as data from '../mocks/data';
import {mockLogger} from '../mocks/logger';
import {mockMQTTPublishAsync} from '../mocks/mqtt';
import {flushPromises} from '../mocks/utils';
import {devices, events as mockZHEvents} from '../mocks/zigbeeHerdsman';
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {mockMQTTPublishAsync} from "../mocks/mqtt";
import {flushPromises} from "../mocks/utils";
import {devices, events as mockZHEvents, returnDevices} from "../mocks/zigbeeHerdsman";
import * as zhc from 'zigbee-herdsman-converters';
import type {MockInstance} from "vitest";
import type {OnEvent as DefinitionOnEvent} from "zigbee-herdsman-converters/lib/types";
import {Controller} from '../../lib/controller';
import * as settings from '../../lib/util/settings';
import type Device from "../../lib/model/device";
import type {Device as ZhDevice} from "../mocks/zigbeeHerdsman";
const mockOnEvent = vi.fn();
const mockLivoloOnEvent = vi.fn();
// @ts-expect-error mock
zhc.onEvent = mockOnEvent;
import * as zhc from "zigbee-herdsman-converters";
import {Controller} from "../../lib/controller";
import OnEvent from "../../lib/extension/onEvent";
import * as settings from "../../lib/util/settings";
const mocksClear = [mockMQTTPublishAsync, mockLogger.warning, mockLogger.debug];
describe('Extension: OnEvent', () => {
returnDevices.push(devices.bulb.ieeeAddr, devices.LIVOLO.ieeeAddr);
describe("Extension: OnEvent", () => {
let controller: Controller;
let onEventSpy: MockInstance<typeof zhc.onEvent>;
let deviceOnEventSpy: MockInstance<DefinitionOnEvent>;
const getZ2MDevice = (zhDevice: string | number | ZhDevice): Device => {
// @ts-expect-error private
return controller.zigbee.resolveEntity(zhDevice)! as Device;
};
const clearOnEventSpies = (): void => {
onEventSpy.mockClear();
deviceOnEventSpy.mockClear();
};
beforeAll(async () => {
const mappedLivolo = (await zhc.findByDevice(devices.LIVOLO))!;
mappedLivolo.onEvent = mockLivoloOnEvent;
});
beforeEach(async () => {
vi.useFakeTimers();
data.writeDefaultConfiguration();
settings.reRead();
controller = new Controller(vi.fn(), vi.fn());
await controller.start();
await flushPromises();
onEventSpy = vi.spyOn(zhc, "onEvent");
deviceOnEventSpy = vi.spyOn(getZ2MDevice(devices.LIVOLO).definition!, "onEvent");
});
beforeEach(async () => {
// @ts-expect-error private
controller.state.state = {};
data.writeDefaultConfiguration();
settings.reRead();
mocksClear.forEach((m) => m.mockClear());
mockOnEvent.mockClear();
for (const mock of mocksClear) {
mock.mockClear();
}
await controller.removeExtension(controller.getExtension("OnEvent")!);
clearOnEventSpies();
await controller.addExtension(new OnEvent(...controller.extensionArgs));
});
afterAll(async () => {
await controller.stop();
await flushPromises();
vi.useRealTimers();
});
it('Should call with start event', async () => {
expect(mockLivoloOnEvent).toHaveBeenCalledTimes(1);
const call = mockLivoloOnEvent.mock.calls[0];
expect(call[0]).toBe('start');
expect(call[1]).toStrictEqual({});
expect(call[2]).toBe(devices.LIVOLO);
expect(call[3]).toStrictEqual(settings.getDevice(devices.LIVOLO.ieeeAddr));
expect(call[4]).toStrictEqual({});
});
it('Should call with stop event', async () => {
mockLivoloOnEvent.mockClear();
await controller.stop();
await flushPromises();
expect(mockLivoloOnEvent).toHaveBeenCalledTimes(1);
const call = mockLivoloOnEvent.mock.calls[0];
expect(call[0]).toBe('stop');
expect(call[1]).toStrictEqual({});
expect(call[2]).toBe(devices.LIVOLO);
});
it('Should call with zigbee event', async () => {
mockLivoloOnEvent.mockClear();
await mockZHEvents.deviceAnnounce({device: devices.LIVOLO});
await flushPromises();
expect(mockLivoloOnEvent).toHaveBeenCalledTimes(1);
expect(mockLivoloOnEvent).toHaveBeenCalledWith(
'deviceAnnounce',
{device: devices.LIVOLO},
it("starts & stops", async () => {
expect(onEventSpy).toHaveBeenCalledTimes(2);
expect(deviceOnEventSpy).toHaveBeenCalledTimes(1);
expect(deviceOnEventSpy).toHaveBeenNthCalledWith(
1,
"start",
{},
devices.LIVOLO,
settings.getDevice(devices.LIVOLO.ieeeAddr),
{},
{
deviceExposesChanged: expect.any(Function),
},
{deviceExposesChanged: expect.any(Function)},
);
// Test deviceExposesChanged
mockMQTTPublishAsync.mockClear();
console.log(mockLivoloOnEvent.mock.calls[0][5].deviceExposesChanged());
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/devices');
await controller.stop();
expect(onEventSpy).toHaveBeenCalledTimes(4);
expect(deviceOnEventSpy).toHaveBeenCalledTimes(2);
expect(deviceOnEventSpy).toHaveBeenNthCalledWith(
2,
"stop",
{},
devices.LIVOLO,
settings.getDevice(devices.LIVOLO.ieeeAddr),
{},
{deviceExposesChanged: expect.any(Function)},
);
});
it('Should call index onEvent with zigbee event', async () => {
mockOnEvent.mockClear();
it("calls on device events", async () => {
clearOnEventSpies();
await mockZHEvents.deviceAnnounce({device: devices.LIVOLO});
await flushPromises();
expect(mockOnEvent).toHaveBeenCalledTimes(1);
expect(zhc.onEvent).toHaveBeenCalledWith('deviceAnnounce', {device: devices.LIVOLO}, devices.LIVOLO, {
deviceExposesChanged: expect.any(Function),
expect(deviceOnEventSpy).toHaveBeenCalledTimes(1);
expect(deviceOnEventSpy).toHaveBeenNthCalledWith(
1,
"deviceAnnounce",
{},
devices.LIVOLO,
settings.getDevice(devices.LIVOLO.ieeeAddr),
{},
{deviceExposesChanged: expect.any(Function)},
);
const emitExposesAndDevicesChangedSpy = vi.spyOn(
// @ts-expect-error protected
controller.getExtension("OnEvent")!.eventBus,
"emitExposesAndDevicesChanged",
);
deviceOnEventSpy.mock.calls[0][5]!.deviceExposesChanged();
expect(emitExposesAndDevicesChangedSpy).toHaveBeenCalledTimes(1);
expect(emitExposesAndDevicesChangedSpy).toHaveBeenCalledWith(getZ2MDevice(devices.LIVOLO));
await mockZHEvents.deviceLeave({ieeeAddr: devices.LIVOLO.ieeeAddr});
await flushPromises();
expect(deviceOnEventSpy).toHaveBeenCalledTimes(2);
expect(deviceOnEventSpy).toHaveBeenNthCalledWith(
2,
"stop",
{},
devices.LIVOLO,
settings.getDevice(devices.LIVOLO.ieeeAddr),
{},
{deviceExposesChanged: expect.any(Function)},
);
});
it("calls on device message", async () => {
clearOnEventSpies();
await mockZHEvents.message({
type: "attributeReport",
device: devices.LIVOLO,
endpoint: devices.LIVOLO.endpoints[0],
linkquality: 213,
groupID: 0,
cluster: "genBasic",
data: {zclVersion: 8},
meta: {zclTransactionSequenceNumber: 1, manufacturerCode: devices.LIVOLO.manufacturerID},
});
await flushPromises();
expect(deviceOnEventSpy).toHaveBeenCalledTimes(1);
expect(deviceOnEventSpy).toHaveBeenCalledWith(
"message",
{
type: "attributeReport",
endpoint: devices.LIVOLO.endpoints[0],
cluster: "genBasic",
data: {zclVersion: 8},
meta: {zclTransactionSequenceNumber: 1, manufacturerCode: devices.LIVOLO.manufacturerID},
},
devices.LIVOLO,
settings.getDevice(devices.LIVOLO.ieeeAddr),
{},
{deviceExposesChanged: expect.any(Function)},
);
});
it("does not block startup on failure", async () => {
await controller.removeExtension(controller.getExtension("OnEvent")!);
clearOnEventSpies();
deviceOnEventSpy.mockImplementationOnce(async () => {
await new Promise((resolve) => setTimeout(resolve, 10000));
throw new Error("Failed");
});
await controller.addExtension(new OnEvent(...controller.extensionArgs));
expect(onEventSpy).toHaveBeenCalledTimes(2);
expect(deviceOnEventSpy).toHaveBeenCalledTimes(1);
});
});
+441 -146
View File
@@ -1,19 +1,19 @@
import * as data from '../mocks/data';
import {mockLogger} from '../mocks/logger';
import {events as mockMQTTEvents, mockMQTTPublishAsync} from '../mocks/mqtt';
import * as mockSleep from '../mocks/sleep';
import {flushPromises} from '../mocks/utils';
import {devices, events as mockZHEvents} from '../mocks/zigbeeHerdsman';
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
import * as mockSleep from "../mocks/sleep";
import {flushPromises} from "../mocks/utils";
import {devices, events as mockZHEvents} from "../mocks/zigbeeHerdsman";
import path from 'node:path';
import path from "node:path";
import stringify from 'json-stable-stringify-without-jsonify';
import OTAUpdate from 'lib/extension/otaUpdate';
import stringify from "json-stable-stringify-without-jsonify";
import * as zhc from 'zigbee-herdsman-converters';
import * as zhc from "zigbee-herdsman-converters";
import {Controller} from '../../lib/controller';
import * as settings from '../../lib/util/settings';
import {Controller} from "../../lib/controller";
import OTAUpdate from "../../lib/extension/otaUpdate";
import * as settings from "../../lib/util/settings";
const mocksClear = [mockMQTTPublishAsync, devices.bulb.save, mockLogger.info];
@@ -23,14 +23,14 @@ const DEFAULT_CONFIG: zhc.Ota.Settings = {
defaultMaximumDataSize: 50,
};
describe('Extension: OTAUpdate', () => {
describe("Extension: OTAUpdate", () => {
let controller: Controller;
const updateSpy = vi.spyOn(zhc.ota, 'update');
const isUpdateAvailableSpy = vi.spyOn(zhc.ota, 'isUpdateAvailable');
const updateSpy = vi.spyOn(zhc.ota, "update");
const isUpdateAvailableSpy = vi.spyOn(zhc.ota, "isUpdateAvailable");
const resetExtension = async (): Promise<void> => {
await controller.enableDisableExtension(false, 'OTAUpdate');
await controller.enableDisableExtension(true, 'OTAUpdate');
await controller.removeExtension(controller.getExtension("OTAUpdate")!);
await controller.addExtension(new OTAUpdate(...controller.extensionArgs));
};
beforeAll(async () => {
@@ -38,7 +38,6 @@ describe('Extension: OTAUpdate', () => {
mockSleep.mock();
data.writeDefaultConfiguration();
settings.reRead();
settings.reRead();
controller = new Controller(vi.fn(), vi.fn());
await controller.start();
await flushPromises();
@@ -46,31 +45,40 @@ describe('Extension: OTAUpdate', () => {
afterAll(async () => {
mockSleep.restore();
await controller?.stop();
await flushPromises();
vi.useRealTimers();
});
beforeEach(async () => {
beforeEach(() => {
zhc.ota.setConfiguration(DEFAULT_CONFIG);
const extension = controller.getExtension("OTAUpdate")! as OTAUpdate;
// @ts-expect-error private
const extension: OTAUpdate = controller.extensions.find((e) => e.constructor.name === 'OTAUpdate');
// @ts-expect-error private
extension.lastChecked = {};
extension.lastChecked = new Map();
// @ts-expect-error private
extension.inProgress = new Set();
mocksClear.forEach((m) => m.mockClear());
// @ts-expect-error private
extension.scheduledUpgrades = new Set();
// @ts-expect-error private
extension.scheduledDowngrades = new Set();
for (const mock of mocksClear) {
mock.mockClear();
}
devices.bulb.mockClear();
updateSpy.mockClear();
isUpdateAvailableSpy.mockClear();
// @ts-expect-error private
controller.state.state = {};
controller.state.clear();
});
afterEach(async () => {
settings.set(['ota', 'disable_automatic_update_check'], false);
afterEach(() => {
settings.set(["ota", "disable_automatic_update_check"], false);
});
it.each(['update', 'update/downgrade'])('Should OTA update a device with topic %s', async (type) => {
const downgrade = type === 'update/downgrade';
it.each(["update", "update/downgrade"])("updates a device with topic %s", async (type) => {
const downgrade = type === "update/downgrade";
let count = 10;
devices.bulb.endpoints[0].read.mockImplementation(() => {
if (downgrade) {
@@ -81,21 +89,21 @@ describe('Extension: OTAUpdate', () => {
return {swBuildId: count, dateCode: `201901${count}`};
});
updateSpy.mockImplementationOnce(async (device, extraMetas, previous, onProgress) => {
updateSpy.mockImplementationOnce(async (_device, _extraMetas, previous, onProgress) => {
expect(previous).toStrictEqual(downgrade);
onProgress(0, undefined);
onProgress(10, 3600.2123);
return 90;
return await Promise.resolve(90);
});
mockMQTTEvents.message(`zigbee2mqtt/bridge/request/device/ota_update/${type}`, 'bulb');
mockMQTTEvents.message(`zigbee2mqtt/bridge/request/device/ota_update/${type}`, "bulb");
await flushPromises();
const fromSwBuildId = 10 + (downgrade ? -1 : +1);
const toSwBuildId = 10 + (downgrade ? -2 : +2);
const fromDateCode = `201901${fromSwBuildId}`;
const toDateCode = `201901${toSwBuildId}`;
expect(mockLogger.info).toHaveBeenCalledWith(`Updating 'bulb' to ${downgrade ? 'previous' : 'latest'} firmware`);
expect(mockLogger.info).toHaveBeenCalledWith(`Updating 'bulb' to ${downgrade ? "previous" : "latest"} firmware`);
expect(isUpdateAvailableSpy).toHaveBeenCalledTimes(0);
expect(updateSpy).toHaveBeenCalledTimes(1);
expect(updateSpy).toHaveBeenCalledWith(devices.bulb, {}, downgrade, expect.any(Function));
@@ -104,93 +112,110 @@ describe('Extension: OTAUpdate', () => {
expect(mockLogger.info).toHaveBeenCalledWith(`Finished update of 'bulb'`);
// note this is a lambda for `info`, so go down to `log` call to get actual message
expect(mockLogger.log).toHaveBeenCalledWith(
'info',
"info",
`Device 'bulb' was updated from '{"dateCode":"${fromDateCode}","softwareBuildID":${fromSwBuildId}}' to '{"dateCode":"${toDateCode}","softwareBuildID":${toSwBuildId}}'`,
'z2m',
"z2m",
);
expect(devices.bulb.save).toHaveBeenCalledTimes(1);
expect(devices.bulb.endpoints[0].read).toHaveBeenCalledWith('genBasic', ['dateCode', 'swBuildId'], {sendPolicy: 'immediate'});
expect(devices.bulb.endpoints[0].read).toHaveBeenCalledWith('genBasic', ['dateCode', 'swBuildId'], {sendPolicy: undefined});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({update: {state: 'updating', progress: 0}}), {
expect(devices.bulb.endpoints[0].read).toHaveBeenCalledWith("genBasic", ["dateCode", "swBuildId"], {sendPolicy: "immediate"});
expect(devices.bulb.endpoints[0].read).toHaveBeenCalledWith("genBasic", ["dateCode", "swBuildId"], {sendPolicy: undefined});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({update: {state: "updating", progress: 0}}), {
retain: true,
qos: 0,
});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bulb',
stringify({update: {state: 'updating', progress: 10, remaining: 3600}}),
"zigbee2mqtt/bulb",
stringify({update: {state: "updating", progress: 10, remaining: 3600}}),
{retain: true, qos: 0},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bulb',
stringify({update: {state: 'idle', installed_version: 90, latest_version: 90}}),
"zigbee2mqtt/bulb",
stringify({update: {state: "idle", installed_version: 90, latest_version: 90}}),
{retain: true, qos: 0},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/ota_update/update',
"zigbee2mqtt/bridge/response/device/ota_update/update",
stringify({
data: {
from: {date_code: fromDateCode, software_build_id: fromSwBuildId},
id: 'bulb',
id: "bulb",
to: {date_code: toDateCode, software_build_id: toSwBuildId},
},
status: 'ok',
status: "ok",
}),
{retain: false, qos: 0},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), {retain: true, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), {retain: true, qos: 0});
});
it('Should handle when OTA update fails', async () => {
it("handles when OTA update fails", async () => {
devices.bulb.endpoints[0].read.mockImplementation(() => {
return {swBuildId: 1, dateCode: '2019010'};
return {swBuildId: 1, dateCode: "2019010"};
});
devices.bulb.save.mockClear();
updateSpy.mockRejectedValueOnce(new Error('Update failed'));
updateSpy.mockRejectedValueOnce(new Error("Update failed"));
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/ota_update/update', stringify({id: 'bulb'}));
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/update", stringify({id: "bulb"}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({update: {state: 'available'}}), {retain: true, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({update: {state: "available"}}), {retain: true, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/ota_update/update',
stringify({data: {}, status: 'error', error: "Update of 'bulb' failed (Update failed)"}),
"zigbee2mqtt/bridge/response/device/ota_update/update",
stringify({data: {}, status: "error", error: "Update of 'bulb' failed (Update failed)"}),
{retain: false, qos: 0},
);
});
it('Should be able to check if OTA update is available', async () => {
it("handles when OTA update returns no image available", async () => {
devices.bulb.endpoints[0].read.mockImplementation(() => {
return {swBuildId: 1, dateCode: "2019010"};
});
devices.bulb.save.mockClear();
updateSpy.mockResolvedValueOnce(undefined);
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/update", stringify({id: "bulb"}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({update: {state: "available"}}), {retain: true, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/ota_update/update",
stringify({data: {}, status: "error", error: "Update of 'bulb' failed (No image currently available)"}),
{retain: false, qos: 0},
);
});
it("is able to check if OTA update is available", async () => {
isUpdateAvailableSpy.mockResolvedValueOnce({available: false, currentFileVersion: 10, otaFileVersion: 10});
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/ota_update/check', 'bulb');
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/check", "bulb");
await flushPromises();
expect(isUpdateAvailableSpy).toHaveBeenCalledTimes(1);
expect(isUpdateAvailableSpy).toHaveBeenNthCalledWith(1, devices.bulb, {}, undefined, false);
expect(updateSpy).toHaveBeenCalledTimes(0);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/ota_update/check',
stringify({data: {id: 'bulb', update_available: false}, status: 'ok'}),
"zigbee2mqtt/bridge/response/device/ota_update/check",
stringify({data: {id: "bulb", update_available: false}, status: "ok"}),
{retain: false, qos: 0},
);
mockMQTTPublishAsync.mockClear();
isUpdateAvailableSpy.mockResolvedValueOnce({available: true, currentFileVersion: 10, otaFileVersion: 12});
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/ota_update/check', 'bulb');
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/check", "bulb");
await flushPromises();
expect(isUpdateAvailableSpy).toHaveBeenCalledTimes(2);
expect(isUpdateAvailableSpy).toHaveBeenNthCalledWith(2, devices.bulb, {}, undefined, false);
expect(updateSpy).toHaveBeenCalledTimes(0);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/ota_update/check',
stringify({data: {id: 'bulb', update_available: true}, status: 'ok'}),
"zigbee2mqtt/bridge/response/device/ota_update/check",
stringify({data: {id: "bulb", update_available: true}, status: "ok"}),
{retain: false, qos: 0},
);
isUpdateAvailableSpy.mockResolvedValueOnce({available: false, currentFileVersion: 10, otaFileVersion: 10});
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/ota_update/check/downgrade', 'bulb');
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/check/downgrade", "bulb");
await flushPromises();
expect(isUpdateAvailableSpy).toHaveBeenCalledTimes(3);
expect(isUpdateAvailableSpy).toHaveBeenNthCalledWith(3, devices.bulb, {}, undefined, true);
expect(updateSpy).toHaveBeenCalledTimes(0);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/ota_update/check',
stringify({data: {id: 'bulb', update_available: false}, status: 'ok'}),
"zigbee2mqtt/bridge/response/device/ota_update/check",
stringify({data: {id: "bulb", update_available: false}, status: "ok"}),
{retain: false, qos: 0},
);
@@ -201,103 +226,147 @@ describe('Extension: OTAUpdate', () => {
mockMQTTPublishAsync.mockClear();
isUpdateAvailableSpy.mockResolvedValueOnce({available: true, currentFileVersion: 10, otaFileVersion: 12});
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/ota_update/check/downgrade', 'bulb');
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/check/downgrade", "bulb");
await flushPromises();
expect(isUpdateAvailableSpy).toHaveBeenCalledTimes(4);
expect(isUpdateAvailableSpy).toHaveBeenNthCalledWith(4, devices.bulb, {suppressElementImageParseFailure: true}, undefined, true);
expect(updateSpy).toHaveBeenCalledTimes(0);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/ota_update/check',
stringify({data: {id: 'bulb', update_available: true}, status: 'ok'}),
"zigbee2mqtt/bridge/response/device/ota_update/check",
stringify({data: {id: "bulb", update_available: true}, status: "ok"}),
{retain: false, qos: 0},
);
device.definition = originalDefinition;
});
it('Should handle if OTA update check fails', async () => {
isUpdateAvailableSpy.mockRejectedValueOnce(new Error('RF signals disturbed because of dogs barking'));
it("handles if OTA update check fails", async () => {
isUpdateAvailableSpy.mockRejectedValueOnce(new Error("RF signals disturbed because of dogs barking"));
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/ota_update/check', 'bulb');
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/check", "bulb");
await flushPromises();
expect(isUpdateAvailableSpy).toHaveBeenCalledTimes(1);
expect(updateSpy).toHaveBeenCalledTimes(0);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/ota_update/check',
"zigbee2mqtt/bridge/response/device/ota_update/check",
stringify({
data: {},
status: 'error',
status: "error",
error: `Failed to check if update available for 'bulb' (RF signals disturbed because of dogs barking)`,
}),
{retain: false, qos: 0},
);
});
it('Should fail when device does not exist', async () => {
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/ota_update/check', 'not_existing_deviceooo');
it("fails when device does not exist", async () => {
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/check", "not_existing_deviceooo");
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/ota_update/check',
stringify({data: {}, status: 'error', error: `Device 'not_existing_deviceooo' does not exist`}),
"zigbee2mqtt/bridge/response/device/ota_update/check",
stringify({data: {}, status: "error", error: `Device 'not_existing_deviceooo' does not exist`}),
{retain: false, qos: 0},
);
});
it('Should not check for OTA when device does not support it', async () => {
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/ota_update/check', 'dimmer_wall_switch');
it("does not check for OTA when device does not support it", async () => {
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/check", "dimmer_wall_switch");
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/ota_update/check',
stringify({data: {}, status: 'error', error: `Device 'dimmer_wall_switch' does not support OTA updates`}),
"zigbee2mqtt/bridge/response/device/ota_update/check",
stringify({data: {}, status: "error", error: `Device 'dimmer_wall_switch' does not support OTA updates`}),
{retain: false, qos: 0},
);
});
it('Should refuse to check/update when already in progress', async () => {
isUpdateAvailableSpy.mockImplementationOnce(
// @ts-expect-error mocked as needed
async () => {
await new Promise<void>((resolve) => {
setTimeout(() => resolve(), 99999);
it.each(["check", "check/downgrade", "update", "update/downgrade", "schedule", "schedule/downgrade", "unschedule"])(
"refuses to %s when already in progress",
async (type) => {
if (type.includes("schedule") || type.includes("check")) {
isUpdateAvailableSpy.mockImplementationOnce(async () => {
return await new Promise((resolve) => {
setTimeout(
() =>
resolve({
available: false,
currentFileVersion: 1,
otaFileVersion: 1,
}),
99999,
);
});
});
},
);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/ota_update/check', 'bulb');
await flushPromises();
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/ota_update/check', 'bulb');
await flushPromises();
expect(isUpdateAvailableSpy).toHaveBeenCalledTimes(1);
vi.runOnlyPendingTimers();
} else {
updateSpy.mockImplementationOnce(async () => {
return await new Promise((resolve) => {
setTimeout(() => resolve(1), 99999);
});
});
}
mockMQTTEvents.message(`zigbee2mqtt/bridge/request/device/ota_update/${type.includes("schedule") ? "check" : type}`, "bulb");
await flushPromises();
mockMQTTEvents.message(`zigbee2mqtt/bridge/request/device/ota_update/${type}`, "bulb");
await flushPromises();
if (type.includes("schedule") || type.includes("check")) {
expect(isUpdateAvailableSpy).toHaveBeenCalledTimes(1);
} else {
expect(updateSpy).toHaveBeenCalledTimes(1);
}
await vi.runOnlyPendingTimersAsync();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
`zigbee2mqtt/bridge/response/device/ota_update/${type.replace("/downgrade", "")}`,
stringify({data: {}, status: "error", error: `Update or check for update already in progress for 'bulb'`}),
{retain: false, qos: 0},
);
},
);
it("does not crash when read modelID before/after OTA update fails", async () => {
devices.bulb.endpoints[0].read.mockRejectedValueOnce("Failed from").mockRejectedValueOnce("Failed to");
updateSpy.mockResolvedValueOnce(1);
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/update", "bulb");
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/ota_update/check',
stringify({data: {}, status: 'error', error: `Update or check for update already in progress for 'bulb'`}),
"zigbee2mqtt/bridge/response/device/ota_update/update",
stringify({data: {id: "bulb", from: undefined, to: undefined}, status: "ok"}),
{retain: false, qos: 0},
);
});
it('Shouldnt crash when read modelID before/after OTA update fails', async () => {
devices.bulb.endpoints[0].read.mockRejectedValueOnce('Failed from').mockRejectedValueOnce('Failed to');
updateSpy.mockImplementationOnce(vi.fn());
it("cancels scheduled when direct update requested", async () => {
updateSpy.mockResolvedValueOnce(1);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/ota_update/update', 'bulb');
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/schedule", "bulb");
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/ota_update/update',
stringify({data: {id: 'bulb', from: undefined, to: undefined}, status: 'ok'}),
{retain: false, qos: 0},
);
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/update", "bulb");
await flushPromises();
expect(mockLogger.info).toHaveBeenCalledWith("Previously scheduled 'bulb' upgrade was cancelled by manual update");
updateSpy.mockResolvedValueOnce(1);
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/schedule/downgrade", "bulb");
await flushPromises();
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/update", "bulb");
await flushPromises();
expect(mockLogger.info).toHaveBeenCalledWith("Previously scheduled 'bulb' downgrade was cancelled by manual update");
});
it('Should check for update when device requests it', async () => {
it("checks for update when device requests it", async () => {
const data = {imageType: 12382};
isUpdateAvailableSpy.mockResolvedValueOnce({available: true, currentFileVersion: 10, otaFileVersion: 12});
const payload = {
data,
cluster: 'genOta',
cluster: "genOta",
device: devices.bulb,
endpoint: devices.bulb.getEndpoint(1)!,
type: 'commandQueryNextImageRequest',
type: "commandQueryNextImageRequest",
linkquality: 10,
meta: {zclTransactionSequenceNumber: 10},
};
@@ -308,7 +377,7 @@ describe('Extension: OTAUpdate', () => {
expect(isUpdateAvailableSpy).toHaveBeenCalledWith(devices.bulb, {}, {imageType: 12382}, false);
expect(mockLogger.info).toHaveBeenCalledWith(`Update available for 'bulb'`);
expect(devices.bulb.endpoints[0].commandResponse).toHaveBeenCalledTimes(1);
expect(devices.bulb.endpoints[0].commandResponse).toHaveBeenCalledWith('genOta', 'queryNextImageResponse', {status: 0x98}, undefined, 10);
expect(devices.bulb.endpoints[0].commandResponse).toHaveBeenCalledWith("genOta", "queryNextImageResponse", {status: 0x98}, undefined, 10);
// Should not request again when device asks again after a short time
await mockZHEvents.message(payload);
@@ -320,21 +389,21 @@ describe('Extension: OTAUpdate', () => {
await flushPromises();
expect(mockLogger.info).not.toHaveBeenCalledWith(`Update available for 'bulb'`);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bulb',
stringify({update: {state: 'available', installed_version: 10, latest_version: 12}}),
"zigbee2mqtt/bulb",
stringify({update: {state: "available", installed_version: 10, latest_version: 12}}),
{retain: true, qos: 0},
);
});
it('Should respond with NO_IMAGE_AVAILABLE when update available request fails', async () => {
it("responds with NO_IMAGE_AVAILABLE when update available request fails", async () => {
const data = {imageType: 12382};
isUpdateAvailableSpy.mockRejectedValueOnce('Nothing to find here');
isUpdateAvailableSpy.mockRejectedValueOnce("Nothing to find here");
const payload = {
data,
cluster: 'genOta',
cluster: "genOta",
device: devices.bulb,
endpoint: devices.bulb.getEndpoint(1)!,
type: 'commandQueryNextImageRequest',
type: "commandQueryNextImageRequest",
linkquality: 10,
meta: {zclTransactionSequenceNumber: 10},
};
@@ -343,19 +412,19 @@ describe('Extension: OTAUpdate', () => {
expect(isUpdateAvailableSpy).toHaveBeenCalledTimes(1);
expect(isUpdateAvailableSpy).toHaveBeenCalledWith(devices.bulb, {}, {imageType: 12382}, false);
expect(devices.bulb.endpoints[0].commandResponse).toHaveBeenCalledTimes(1);
expect(devices.bulb.endpoints[0].commandResponse).toHaveBeenCalledWith('genOta', 'queryNextImageResponse', {status: 0x98}, undefined, 10);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({update: {state: 'idle'}}), {retain: true, qos: 0});
expect(devices.bulb.endpoints[0].commandResponse).toHaveBeenCalledWith("genOta", "queryNextImageResponse", {status: 0x98}, undefined, 10);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({update: {state: "idle"}}), {retain: true, qos: 0});
});
it('Should check for update when device requests it and it is not available', async () => {
it("checks for update when device requests it and it is not available", async () => {
const data = {imageType: 12382};
isUpdateAvailableSpy.mockResolvedValueOnce({available: false, currentFileVersion: 13, otaFileVersion: 13});
const payload = {
data,
cluster: 'genOta',
cluster: "genOta",
device: devices.bulb,
endpoint: devices.bulb.getEndpoint(1)!,
type: 'commandQueryNextImageRequest',
type: "commandQueryNextImageRequest",
linkquality: 10,
meta: {zclTransactionSequenceNumber: 10},
};
@@ -364,24 +433,24 @@ describe('Extension: OTAUpdate', () => {
expect(isUpdateAvailableSpy).toHaveBeenCalledTimes(1);
expect(isUpdateAvailableSpy).toHaveBeenCalledWith(devices.bulb, {}, {imageType: 12382}, false);
expect(devices.bulb.endpoints[0].commandResponse).toHaveBeenCalledTimes(1);
expect(devices.bulb.endpoints[0].commandResponse).toHaveBeenCalledWith('genOta', 'queryNextImageResponse', {status: 0x98}, undefined, 10);
expect(devices.bulb.endpoints[0].commandResponse).toHaveBeenCalledWith("genOta", "queryNextImageResponse", {status: 0x98}, undefined, 10);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bulb',
stringify({update: {state: 'idle', installed_version: 13, latest_version: 13}}),
"zigbee2mqtt/bulb",
stringify({update: {state: "idle", installed_version: 13, latest_version: 13}}),
{retain: true, qos: 0},
);
});
it('Should not check for update when device requests it and disable_automatic_update_check is set to true', async () => {
settings.set(['ota', 'disable_automatic_update_check'], true);
it("does not check for update when device requests it and disable_automatic_update_check is set to true", async () => {
settings.set(["ota", "disable_automatic_update_check"], true);
const data = {imageType: 12382};
isUpdateAvailableSpy.mockResolvedValueOnce({available: true, currentFileVersion: 10, otaFileVersion: 13});
const payload = {
data,
cluster: 'genOta',
cluster: "genOta",
device: devices.bulb,
endpoint: devices.bulb.getEndpoint(1)!,
type: 'commandQueryNextImageRequest',
type: "commandQueryNextImageRequest",
linkquality: 10,
meta: {zclTransactionSequenceNumber: 10},
};
@@ -390,76 +459,302 @@ describe('Extension: OTAUpdate', () => {
expect(isUpdateAvailableSpy).toHaveBeenCalledTimes(0);
});
it('Should respond with NO_IMAGE_AVAILABLE when not supporting OTA', async () => {
it.each(["schedule", "schedule/downgrade"])("schedules and performs an update with topic %s", async (type) => {
const downgrade = type === "schedule/downgrade";
if (downgrade) {
settings.set(["ota", "disable_automatic_update_check"], true); // coverage, scheduling not affected by this
}
updateSpy.mockImplementationOnce(async (_device, _extraMetas, previous, onProgress) => {
expect(previous).toStrictEqual(downgrade);
onProgress(0, undefined);
onProgress(10, 3600.2123);
return await Promise.resolve(2);
});
mockMQTTEvents.message(`zigbee2mqtt/bridge/request/device/ota_update/${type}`, "bulb");
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(1, "zigbee2mqtt/bulb", stringify({update: {state: "scheduled"}}), {
retain: true,
qos: 0,
});
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
2,
"zigbee2mqtt/bridge/response/device/ota_update/schedule",
stringify({data: {id: "bulb"}, status: "ok"}),
{retain: false, qos: 0},
);
const data = {imageType: 12382};
const payload = {
data,
cluster: "genOta",
device: devices.bulb,
endpoint: devices.bulb.getEndpoint(1)!,
type: "commandQueryNextImageRequest",
linkquality: 10,
meta: {zclTransactionSequenceNumber: 10},
};
await mockZHEvents.message(payload);
await flushPromises();
expect(updateSpy).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(3, "zigbee2mqtt/bulb", stringify({update: {state: "updating", progress: 0}}), {
retain: true,
qos: 0,
});
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
4,
"zigbee2mqtt/bulb",
stringify({update: {state: "updating", progress: 10, remaining: 3600}}),
{retain: true, qos: 0},
);
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
5,
"zigbee2mqtt/bulb",
stringify({update: {state: "idle", installed_version: 2, latest_version: 2}}),
{retain: true, qos: 0},
);
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(6, "zigbee2mqtt/bridge/devices", expect.any(String), {retain: true, qos: 0});
});
it("schedules and cancels an update when no image available", async () => {
updateSpy.mockResolvedValueOnce(undefined);
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/schedule", "bulb");
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(1, "zigbee2mqtt/bulb", stringify({update: {state: "scheduled"}}), {
retain: true,
qos: 0,
});
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
2,
"zigbee2mqtt/bridge/response/device/ota_update/schedule",
stringify({data: {id: "bulb"}, status: "ok"}),
{retain: false, qos: 0},
);
const data = {imageType: 12382};
const payload = {
data,
cluster: "genOta",
device: devices.bulb,
endpoint: devices.bulb.getEndpoint(1)!,
type: "commandQueryNextImageRequest",
linkquality: 10,
meta: {zclTransactionSequenceNumber: 10},
};
await mockZHEvents.message(payload);
await flushPromises();
expect(updateSpy).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(3, "zigbee2mqtt/bulb", stringify({update: {state: "idle"}}), {retain: true, qos: 0});
});
it("schedules and re-schedules an update when failed", async () => {
updateSpy.mockRejectedValueOnce("Update failed").mockImplementationOnce(async (_device, _extraMetas, _previous, onProgress) => {
onProgress(0, undefined);
onProgress(10, 3600.2123);
return await Promise.resolve(2);
});
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/schedule", "bulb");
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(1, "zigbee2mqtt/bulb", stringify({update: {state: "scheduled"}}), {
retain: true,
qos: 0,
});
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
2,
"zigbee2mqtt/bridge/response/device/ota_update/schedule",
stringify({data: {id: "bulb"}, status: "ok"}),
{retain: false, qos: 0},
);
const data = {imageType: 12382};
const payload = {
data,
cluster: "genOta",
device: devices.bulb,
endpoint: devices.bulb.getEndpoint(1)!,
type: "commandQueryNextImageRequest",
linkquality: 10,
meta: {zclTransactionSequenceNumber: 10},
};
await mockZHEvents.message(payload);
await flushPromises();
expect(updateSpy).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(3, "zigbee2mqtt/bulb", stringify({update: {state: "scheduled"}}), {
retain: true,
qos: 0,
});
await mockZHEvents.message(payload);
await flushPromises();
console.log(mockMQTTPublishAsync.mock.calls.map((c) => c[0]));
expect(updateSpy).toHaveBeenCalledTimes(2);
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(4, "zigbee2mqtt/bulb", stringify({update: {state: "updating", progress: 0}}), {
retain: true,
qos: 0,
});
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
5,
"zigbee2mqtt/bulb",
stringify({update: {state: "updating", progress: 10, remaining: 3600}}),
{retain: true, qos: 0},
);
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
6,
"zigbee2mqtt/bulb",
stringify({update: {state: "idle", installed_version: 2, latest_version: 2}}),
{retain: true, qos: 0},
);
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(7, "zigbee2mqtt/bridge/devices", expect.any(String), {retain: true, qos: 0});
});
it("overwrites current schedule on re-schedule", async () => {
for (const [type, overwriteType] of [
["schedule", "schedule/downgrade"],
["schedule/downgrade", "schedule"],
]) {
mockMQTTEvents.message(`zigbee2mqtt/bridge/request/device/ota_update/${type}`, "bulb");
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(1, "zigbee2mqtt/bulb", stringify({update: {state: "scheduled"}}), {
retain: true,
qos: 0,
});
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
2,
"zigbee2mqtt/bridge/response/device/ota_update/schedule",
stringify({data: {id: "bulb"}, status: "ok"}),
{retain: false, qos: 0},
);
mockMQTTEvents.message(`zigbee2mqtt/bridge/request/device/ota_update/${overwriteType}`, "bulb");
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(3, "zigbee2mqtt/bulb", stringify({update: {state: "scheduled"}}), {
retain: true,
qos: 0,
});
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
4,
"zigbee2mqtt/bridge/response/device/ota_update/schedule",
stringify({data: {id: "bulb"}, status: "ok"}),
{retain: false, qos: 0},
);
}
});
it.each(["schedule", "schedule/downgrade"])("unschedules", async (type) => {
mockMQTTEvents.message(`zigbee2mqtt/bridge/request/device/ota_update/${type}`, "bulb");
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(1, "zigbee2mqtt/bulb", stringify({update: {state: "scheduled"}}), {
retain: true,
qos: 0,
});
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
2,
"zigbee2mqtt/bridge/response/device/ota_update/schedule",
stringify({data: {id: "bulb"}, status: "ok"}),
{retain: false, qos: 0},
);
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/unschedule", "bulb");
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(3, "zigbee2mqtt/bulb", stringify({update: {state: "idle"}}), {retain: true, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
4,
"zigbee2mqtt/bridge/response/device/ota_update/unschedule",
stringify({data: {id: "bulb"}, status: "ok"}),
{retain: false, qos: 0},
);
});
it("responds with NO_IMAGE_AVAILABLE when not supporting OTA", async () => {
const device = devices.HGZB04D;
const data = {imageType: 12382};
const payload = {
data,
cluster: 'genOta',
cluster: "genOta",
device,
endpoint: device.getEndpoint(1)!,
type: 'commandQueryNextImageRequest',
type: "commandQueryNextImageRequest",
linkquality: 10,
meta: {zclTransactionSequenceNumber: 10},
};
await mockZHEvents.message(payload);
await flushPromises();
expect(device.endpoints[0].commandResponse).toHaveBeenCalledTimes(1);
expect(device.endpoints[0].commandResponse).toHaveBeenCalledWith('genOta', 'queryNextImageResponse', {status: 152}, undefined, 10);
expect(device.endpoints[0].commandResponse).toHaveBeenCalledWith("genOta", "queryNextImageResponse", {status: 152}, undefined, 10);
});
it('Should respond with NO_IMAGE_AVAILABLE when not supporting OTA and device has no OTA endpoint to standard endpoint', async () => {
it("responds with NO_IMAGE_AVAILABLE when not supporting OTA and device has no OTA endpoint to standard endpoint", async () => {
const device = devices.SV01;
const data = {imageType: 12382};
const payload = {
data,
cluster: 'genOta',
cluster: "genOta",
device,
endpoint: device.getEndpoint(1)!,
type: 'commandQueryNextImageRequest',
type: "commandQueryNextImageRequest",
linkquality: 10,
meta: {zclTransactionSequenceNumber: 10},
};
await mockZHEvents.message(payload);
await flushPromises();
expect(device.endpoints[0].commandResponse).toHaveBeenCalledTimes(1);
expect(device.endpoints[0].commandResponse).toHaveBeenCalledWith('genOta', 'queryNextImageResponse', {status: 152}, undefined, 10);
expect(device.endpoints[0].commandResponse).toHaveBeenCalledWith("genOta", "queryNextImageResponse", {status: 152}, undefined, 10);
});
it('Sets given configuration', async () => {
const setConfiguration = vi.spyOn(zhc.ota, 'setConfiguration');
settings.set(['ota', 'zigbee_ota_override_index_location'], 'local.index.json');
settings.set(['ota', 'image_block_response_delay'], 10000);
settings.set(['ota', 'default_maximum_data_size'], 10);
it("sets given configuration", async () => {
const setConfiguration = vi.spyOn(zhc.ota, "setConfiguration");
settings.set(["ota", "zigbee_ota_override_index_location"], "local.index.json");
settings.set(["ota", "image_block_response_delay"], 10000);
settings.set(["ota", "default_maximum_data_size"], 10);
await resetExtension();
expect(setConfiguration).toHaveBeenCalledWith({
...DEFAULT_CONFIG,
overrideIndexLocation: path.join(data.mockDir, 'local.index.json'),
overrideIndexLocation: path.join(data.mockDir, "local.index.json"),
imageBlockResponseDelay: 10000,
defaultMaximumDataSize: 10,
});
setConfiguration.mockClear();
settings.set(['ota', 'zigbee_ota_override_index_location'], 'http://my.site/index.json');
settings.set(['ota', 'image_block_response_delay'], 50);
settings.set(['ota', 'default_maximum_data_size'], 100);
settings.set(["ota", "zigbee_ota_override_index_location"], "http://my.site/index.json");
settings.set(["ota", "image_block_response_delay"], 50);
settings.set(["ota", "default_maximum_data_size"], 100);
await resetExtension();
expect(setConfiguration).toHaveBeenCalledWith({
...DEFAULT_CONFIG,
overrideIndexLocation: 'http://my.site/index.json',
overrideIndexLocation: "http://my.site/index.json",
imageBlockResponseDelay: 50,
defaultMaximumDataSize: 100,
});
setConfiguration.mockClear();
});
it('Clear update state on startup', async () => {
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'}});
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'}});
expect(controller.state.get(device)).toStrictEqual({update: {state: "available"}});
});
});
File diff suppressed because it is too large Load Diff
+187 -185
View File
@@ -1,18 +1,18 @@
import * as data from '../mocks/data';
import {mockLogger} from '../mocks/logger';
import {events as mockMQTTEvents, mockMQTTPublishAsync} from '../mocks/mqtt';
import * as mockSleep from '../mocks/sleep';
import {flushPromises} from '../mocks/utils';
import {devices, events as mockZHEvents} from '../mocks/zigbeeHerdsman';
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
import * as mockSleep from "../mocks/sleep";
import {flushPromises} from "../mocks/utils";
import {devices, events as mockZHEvents} from "../mocks/zigbeeHerdsman";
import stringify from 'json-stable-stringify-without-jsonify';
import stringify from "json-stable-stringify-without-jsonify";
import {Controller} from '../../lib/controller';
import * as settings from '../../lib/util/settings';
import {Controller} from "../../lib/controller";
import * as settings from "../../lib/util/settings";
const mocksClear = [mockMQTTPublishAsync, mockLogger.warning, mockLogger.debug];
describe('Extension: Receive', () => {
describe("Extension: Receive", () => {
let controller: Controller;
beforeAll(async () => {
@@ -23,156 +23,158 @@ describe('Extension: Receive', () => {
await vi.runOnlyPendingTimersAsync();
});
beforeEach(async () => {
beforeEach(() => {
// @ts-expect-error private
controller.state.state = {};
controller.state.clear();
data.writeDefaultConfiguration();
settings.reRead();
mocksClear.forEach((m) => m.mockClear());
for (const mock of mocksClear) mock.mockClear();
delete devices.WXKG11LM.linkquality;
});
afterAll(async () => {
vi.useRealTimers();
mockSleep.restore();
await controller?.stop();
await flushPromises();
vi.useRealTimers();
});
it('Should handle a zigbee message', async () => {
it("Should handle a zigbee message", async () => {
const device = devices.WXKG11LM;
device.linkquality = 10;
const data = {onOff: 1};
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
const payload = {data, cluster: "genOnOff", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
await mockZHEvents.message(payload);
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/button', stringify({action: 'single', linkquality: 10}), {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/button", stringify({action: "single", linkquality: 10}), {
retain: false,
qos: 0,
});
});
it('Should handle a zigbee message which uses ep (left)', async () => {
it("Should handle a zigbee message which uses ep (left)", async () => {
const device = devices.WXKG02LM_rev1;
const data = {onOff: 1};
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
const payload = {data, cluster: "genOnOff", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
await mockZHEvents.message(payload);
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button_double_key');
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({action: 'single_left'});
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/button_double_key");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({action: "single_left"});
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
});
it('Should handle a zigbee message which uses ep (right)', async () => {
it("Should handle a zigbee message which uses ep (right)", async () => {
const device = devices.WXKG02LM_rev1;
const data = {onOff: 1};
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(2), type: 'attributeReport', linkquality: 10};
const payload = {data, cluster: "genOnOff", device, endpoint: device.getEndpoint(2), type: "attributeReport", linkquality: 10};
await mockZHEvents.message(payload);
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button_double_key');
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({action: 'single_right'});
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/button_double_key");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({action: "single_right"});
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
});
it('Should handle a zigbee message with default precision', async () => {
it("Should handle a zigbee message with default precision", async () => {
const device = devices.WSDCGQ11LM;
const data = {measuredValue: -85};
const payload = {
data,
cluster: 'msTemperatureMeasurement',
cluster: "msTemperatureMeasurement",
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
type: "attributeReport",
linkquality: 10,
};
await mockZHEvents.message(payload);
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/weather_sensor");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({temperature: -0.85});
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 1, retain: false});
});
it('Should allow to invert cover', async () => {
it("Should allow to invert cover", async () => {
const device = devices.J1_cover;
// Non-inverted (open = 100, close = 0)
await mockZHEvents.message({
data: {currentPositionLiftPercentage: 90, currentPositionTiltPercentage: 80},
cluster: 'closuresWindowCovering',
cluster: "closuresWindowCovering",
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
type: "attributeReport",
linkquality: 10,
});
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/J1_cover', stringify({position: 10, tilt: 20, state: 'OPEN'}), {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/J1_cover", stringify({position: 10, tilt: 20, state: "OPEN"}), {
retain: false,
qos: 0,
});
// Inverted
mockMQTTPublishAsync.mockClear();
settings.set(['devices', device.ieeeAddr, 'invert_cover'], true);
settings.set(["devices", device.ieeeAddr, "invert_cover"], true);
await mockZHEvents.message({
data: {currentPositionLiftPercentage: 90, currentPositionTiltPercentage: 80},
cluster: 'closuresWindowCovering',
cluster: "closuresWindowCovering",
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
type: "attributeReport",
linkquality: 10,
});
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/J1_cover', stringify({position: 90, tilt: 80, state: 'OPEN'}), {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/J1_cover", stringify({position: 90, tilt: 80, state: "OPEN"}), {
retain: false,
qos: 0,
});
});
it('Should allow to disable the legacy integration', async () => {
it("Should allow to disable the legacy integration", async () => {
const device = devices.WXKG11LM;
settings.set(['devices', device.ieeeAddr, 'legacy'], false);
settings.set(["devices", device.ieeeAddr, "legacy"], false);
const data = {onOff: 1};
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
const payload = {data, cluster: "genOnOff", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
await mockZHEvents.message(payload);
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/button', stringify({action: 'single'}), {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/button", stringify({action: "single"}), {retain: false, qos: 0});
});
it('Should debounce messages', async () => {
it("Should debounce messages", async () => {
const device = devices.WSDCGQ11LM;
settings.set(['devices', device.ieeeAddr, 'debounce'], 0.1);
settings.set(["devices", device.ieeeAddr, "debounce"], 0.1);
const data1 = {measuredValue: 8};
const payload1 = {
data: data1,
cluster: 'msTemperatureMeasurement',
cluster: "msTemperatureMeasurement",
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
type: "attributeReport",
linkquality: 10,
};
await mockZHEvents.message(payload1);
const data2 = {measuredValue: 1};
const payload2 = {
data: data2,
cluster: 'msRelativeHumidity',
cluster: "msRelativeHumidity",
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
type: "attributeReport",
linkquality: 10,
};
await mockZHEvents.message(payload2);
const data3 = {measuredValue: 2};
const payload3 = {
data: data3,
cluster: 'msPressureMeasurement',
cluster: "msPressureMeasurement",
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
type: "attributeReport",
linkquality: 10,
};
await mockZHEvents.message(payload3);
@@ -182,43 +184,43 @@ describe('Extension: Receive', () => {
vi.runOnlyPendingTimers();
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/weather_sensor");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({temperature: 0.08, humidity: 0.01, pressure: 2});
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 1, retain: false});
});
it('Should debounce and retain messages when set via device_options', async () => {
it("Should debounce and retain messages when set via device_options", async () => {
const device = devices.WSDCGQ11LM;
settings.set(['device_options', 'debounce'], 0.1);
settings.set(['device_options', 'retain'], true);
delete settings.get().devices['0x0017880104e45522']['retain'];
settings.set(["device_options", "debounce"], 0.1);
settings.set(["device_options", "retain"], true);
delete settings.get().devices["0x0017880104e45522"].retain;
const data1 = {measuredValue: 8};
const payload1 = {
data: data1,
cluster: 'msTemperatureMeasurement',
cluster: "msTemperatureMeasurement",
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
type: "attributeReport",
linkquality: 10,
};
await mockZHEvents.message(payload1);
const data2 = {measuredValue: 1};
const payload2 = {
data: data2,
cluster: 'msRelativeHumidity',
cluster: "msRelativeHumidity",
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
type: "attributeReport",
linkquality: 10,
};
await mockZHEvents.message(payload2);
const data3 = {measuredValue: 2};
const payload3 = {
data: data3,
cluster: 'msPressureMeasurement',
cluster: "msPressureMeasurement",
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
type: "attributeReport",
linkquality: 10,
};
await mockZHEvents.message(payload3);
@@ -228,48 +230,48 @@ describe('Extension: Receive', () => {
vi.runOnlyPendingTimers();
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/weather_sensor");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({temperature: 0.08, humidity: 0.01, pressure: 2});
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 1, retain: true});
});
it('Should debounce messages only with the same payload values for provided debounce_ignore keys', async () => {
it("Should debounce messages only with the same payload values for provided debounce_ignore keys", async () => {
const device = devices.WSDCGQ11LM;
settings.set(['devices', device.ieeeAddr, 'debounce'], 0.1);
settings.set(['devices', device.ieeeAddr, 'debounce_ignore'], ['temperature']);
settings.set(["devices", device.ieeeAddr, "debounce"], 0.1);
settings.set(["devices", device.ieeeAddr, "debounce_ignore"], ["temperature"]);
const tempMsg = {
data: {measuredValue: 8},
cluster: 'msTemperatureMeasurement',
cluster: "msTemperatureMeasurement",
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
type: "attributeReport",
linkquality: 13,
};
await mockZHEvents.message(tempMsg);
const pressureMsg = {
data: {measuredValue: 2},
cluster: 'msPressureMeasurement',
cluster: "msPressureMeasurement",
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
type: "attributeReport",
linkquality: 13,
};
await mockZHEvents.message(pressureMsg);
const tempMsg2 = {
data: {measuredValue: 7},
cluster: 'msTemperatureMeasurement',
cluster: "msTemperatureMeasurement",
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
type: "attributeReport",
linkquality: 13,
};
await mockZHEvents.message(tempMsg2);
const humidityMsg = {
data: {measuredValue: 3},
cluster: 'msRelativeHumidity',
cluster: "msRelativeHumidity",
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
type: "attributeReport",
linkquality: 13,
};
await mockZHEvents.message(humidityMsg);
@@ -283,34 +285,34 @@ describe('Extension: Receive', () => {
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toStrictEqual({temperature: 0.07, pressure: 2, humidity: 0.03});
});
it('Should NOT publish old messages from State cache during debouncing', async () => {
it("Should NOT publish old messages from State cache during debouncing", async () => {
// Summary:
// First send multiple measurements to device that is debouncing. Make sure only one message is sent out to mockMQTT. This also ensures first message is cached to "State".
// Then send another measurement to that same device and trigger asynchronous event to push data from Cache. Newest value should be sent out.
const device = devices.WSDCGQ11LM;
settings.set(['devices', device.ieeeAddr, 'debounce'], 0.1);
settings.set(["devices", device.ieeeAddr, "debounce"], 0.1);
await mockZHEvents.message({
data: {measuredValue: 8},
cluster: 'msTemperatureMeasurement',
cluster: "msTemperatureMeasurement",
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
type: "attributeReport",
linkquality: 10,
});
await mockZHEvents.message({
data: {measuredValue: 1},
cluster: 'msRelativeHumidity',
cluster: "msRelativeHumidity",
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
type: "attributeReport",
linkquality: 10,
});
await mockZHEvents.message({
data: {measuredValue: 2},
cluster: 'msPressureMeasurement',
cluster: "msPressureMeasurement",
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
type: "attributeReport",
linkquality: 10,
});
await flushPromises();
@@ -321,16 +323,16 @@ describe('Extension: Receive', () => {
// Test that only one MQTT is sent out and test its values.
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/weather_sensor");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({temperature: 0.08, humidity: 0.01, pressure: 2});
// Send another Zigbee message...
await mockZHEvents.message({
data: {measuredValue: 9},
cluster: 'msTemperatureMeasurement',
cluster: "msTemperatureMeasurement",
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
type: "attributeReport",
linkquality: 10,
});
// @ts-expect-error private
@@ -349,39 +351,39 @@ describe('Extension: Receive', () => {
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[2][1])).toStrictEqual({temperature: 0.09, humidity: 0.01, pressure: 2});
});
it('Should throttle multiple messages from spamming devices', async () => {
it("Should throttle multiple messages from spamming devices", async () => {
const device = devices.SPAMMER;
const throttle_for_testing = 1;
settings.set(['device_options', 'throttle'], throttle_for_testing);
settings.set(['device_options', 'retain'], true);
settings.set(['devices', device.ieeeAddr, 'friendly_name'], 'spammer1');
settings.set(["device_options", "throttle"], throttle_for_testing);
settings.set(["device_options", "retain"], true);
settings.set(["devices", device.ieeeAddr, "friendly_name"], "spammer1");
const data1 = {measuredValue: 1};
const payload1 = {
data: data1,
cluster: 'msTemperatureMeasurement',
cluster: "msTemperatureMeasurement",
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
type: "attributeReport",
linkquality: 10,
};
await mockZHEvents.message(payload1);
const data2 = {measuredValue: 2};
const payload2 = {
data: data2,
cluster: 'msTemperatureMeasurement',
cluster: "msTemperatureMeasurement",
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
type: "attributeReport",
linkquality: 10,
};
await mockZHEvents.message(payload2);
const data3 = {measuredValue: 3};
const payload3 = {
data: data3,
cluster: 'msTemperatureMeasurement',
cluster: "msTemperatureMeasurement",
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
type: "attributeReport",
linkquality: 10,
};
await mockZHEvents.message(payload3);
@@ -390,7 +392,7 @@ describe('Extension: Receive', () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/spammer1');
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/spammer1");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({temperature: 0.01});
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 0, retain: true});
@@ -400,149 +402,149 @@ describe('Extension: Receive', () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
await flushPromises();
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/spammer1');
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual("zigbee2mqtt/spammer1");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toStrictEqual({temperature: 0.03});
expect(mockMQTTPublishAsync.mock.calls[1][2]).toStrictEqual({qos: 0, retain: true});
const data4 = {measuredValue: 4};
const payload4 = {
data: data4,
cluster: 'msTemperatureMeasurement',
cluster: "msTemperatureMeasurement",
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
type: "attributeReport",
linkquality: 10,
};
await mockZHEvents.message(payload4);
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(3);
expect(mockMQTTPublishAsync.mock.calls[2][0]).toStrictEqual('zigbee2mqtt/spammer1');
expect(mockMQTTPublishAsync.mock.calls[2][0]).toStrictEqual("zigbee2mqtt/spammer1");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[2][1])).toStrictEqual({temperature: 0.04});
expect(mockMQTTPublishAsync.mock.calls[2][2]).toStrictEqual({qos: 0, retain: true});
});
it('Shouldnt republish old state', async () => {
it("Shouldnt republish old state", async () => {
// https://github.com/Koenkk/zigbee2mqtt/issues/3572
const device = devices.bulb;
settings.set(['devices', device.ieeeAddr, 'debounce'], 0.1);
settings.set(["devices", device.ieeeAddr, "debounce"], 0.1);
await mockZHEvents.message({
data: {onOff: 0},
cluster: 'genOnOff',
cluster: "genOnOff",
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
type: "attributeReport",
linkquality: 10,
});
await mockMQTTEvents.message('zigbee2mqtt/bulb/set', stringify({state: 'ON'}));
await mockMQTTEvents.message("zigbee2mqtt/bulb/set", stringify({state: "ON"}));
await flushPromises();
vi.runOnlyPendingTimers();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({state: 'ON'});
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toStrictEqual({state: 'ON'});
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({state: "ON"});
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toStrictEqual({state: "ON"});
});
it('Should handle a zigbee message with 1 precision', async () => {
it("Should handle a zigbee message with 1 precision", async () => {
const device = devices.WSDCGQ11LM;
settings.set(['devices', device.ieeeAddr, 'temperature_precision'], 1);
settings.set(["devices", device.ieeeAddr, "temperature_precision"], 1);
const data = {measuredValue: -85};
const payload = {
data,
cluster: 'msTemperatureMeasurement',
cluster: "msTemperatureMeasurement",
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
type: "attributeReport",
linkquality: 10,
};
await mockZHEvents.message(payload);
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/weather_sensor");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({temperature: -0.8});
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 1, retain: false});
});
it('Should handle a zigbee message with 0 precision', async () => {
it("Should handle a zigbee message with 0 precision", async () => {
const device = devices.WSDCGQ11LM;
settings.set(['devices', device.ieeeAddr, 'temperature_precision'], 0);
settings.set(["devices", device.ieeeAddr, "temperature_precision"], 0);
const data = {measuredValue: -85};
const payload = {
data,
cluster: 'msTemperatureMeasurement',
cluster: "msTemperatureMeasurement",
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
type: "attributeReport",
linkquality: 10,
};
await mockZHEvents.message(payload);
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/weather_sensor");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({temperature: -1});
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 1, retain: false});
});
it('Should handle a zigbee message with 1 precision when set via device_options', async () => {
it("Should handle a zigbee message with 1 precision when set via device_options", async () => {
const device = devices.WSDCGQ11LM;
settings.set(['device_options', 'temperature_precision'], 1);
settings.set(["device_options", "temperature_precision"], 1);
const data = {measuredValue: -85};
const payload = {
data,
cluster: 'msTemperatureMeasurement',
cluster: "msTemperatureMeasurement",
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
type: "attributeReport",
linkquality: 10,
};
await mockZHEvents.message(payload);
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/weather_sensor");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({temperature: -0.8});
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 1, retain: false});
});
it('Should handle a zigbee message with 2 precision when overrides device_options', async () => {
it("Should handle a zigbee message with 2 precision when overrides device_options", async () => {
const device = devices.WSDCGQ11LM;
settings.set(['device_options', 'temperature_precision'], 1);
settings.set(['devices', device.ieeeAddr, 'temperature_precision'], 0);
settings.set(["device_options", "temperature_precision"], 1);
settings.set(["devices", device.ieeeAddr, "temperature_precision"], 0);
const data = {measuredValue: -85};
const payload = {
data,
cluster: 'msTemperatureMeasurement',
cluster: "msTemperatureMeasurement",
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
type: "attributeReport",
linkquality: 10,
};
await mockZHEvents.message(payload);
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/weather_sensor");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({temperature: -1});
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 1, retain: false});
});
it('Should handle a zigbee message with voltage 2990', async () => {
it("Should handle a zigbee message with voltage 2990", async () => {
const device = devices.WXKG02LM_rev1;
const data = {65281: {1: 2990}};
const payload = {data, cluster: 'genBasic', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
const payload = {data, cluster: "genBasic", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
await mockZHEvents.message(payload);
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button_double_key');
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/button_double_key");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({battery: 93, voltage: 2990});
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
});
it('Should publish 1 message when converted twice', async () => {
it("Should publish 1 message when converted twice", async () => {
const device = devices.RTCGQ11LM;
const data = {65281: {1: 3045, 3: 19, 5: 35, 6: [0, 3], 11: 381, 100: 0}};
const payload = {data, cluster: 'genBasic', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
const payload = {data, cluster: "genBasic", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
await mockZHEvents.message(payload);
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/occupancy_sensor');
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/occupancy_sensor");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({
battery: 100,
illuminance: 381,
@@ -553,128 +555,128 @@ describe('Extension: Receive', () => {
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
});
it('Should publish 1 message when converted twice', async () => {
it("Should publish 1 message when converted twice", async () => {
const device = devices.RTCGQ11LM;
const data = {9999: {1: 3045}};
const payload = {data, cluster: 'genBasic', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
const payload = {data, cluster: "genBasic", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
await mockZHEvents.message(payload);
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
});
it('Should publish last_seen epoch', async () => {
it("Should publish last_seen epoch", async () => {
const device = devices.WXKG02LM_rev1;
settings.set(['advanced', 'last_seen'], 'epoch');
settings.set(["advanced", "last_seen"], "epoch");
const data = {onOff: 1};
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
const payload = {data, cluster: "genOnOff", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
await mockZHEvents.message(payload);
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button_double_key');
expect(typeof JSON.parse(mockMQTTPublishAsync.mock.calls[0][1]).last_seen).toBe('number');
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/button_double_key");
expect(typeof JSON.parse(mockMQTTPublishAsync.mock.calls[0][1]).last_seen).toBe("number");
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
});
it('Should publish last_seen ISO_8601', async () => {
it("Should publish last_seen ISO_8601", async () => {
const device = devices.WXKG02LM_rev1;
settings.set(['advanced', 'last_seen'], 'ISO_8601');
settings.set(["advanced", "last_seen"], "ISO_8601");
const data = {onOff: 1};
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
const payload = {data, cluster: "genOnOff", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
await mockZHEvents.message(payload);
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button_double_key');
expect(typeof JSON.parse(mockMQTTPublishAsync.mock.calls[0][1]).last_seen).toBe('string');
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/button_double_key");
expect(typeof JSON.parse(mockMQTTPublishAsync.mock.calls[0][1]).last_seen).toBe("string");
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
});
it('Should publish last_seen ISO_8601_local', async () => {
it("Should publish last_seen ISO_8601_local", async () => {
const device = devices.WXKG02LM_rev1;
settings.set(['advanced', 'last_seen'], 'ISO_8601_local');
settings.set(["advanced", "last_seen"], "ISO_8601_local");
const data = {onOff: 1};
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
const payload = {data, cluster: "genOnOff", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
await mockZHEvents.message(payload);
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button_double_key');
expect(typeof JSON.parse(mockMQTTPublishAsync.mock.calls[0][1]).last_seen).toBe('string');
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/button_double_key");
expect(typeof JSON.parse(mockMQTTPublishAsync.mock.calls[0][1]).last_seen).toBe("string");
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
});
it('Should handle messages from Xiaomi router devices', async () => {
it("Should handle messages from Xiaomi router devices", async () => {
const device = devices.ZNCZ02LM;
const data = {onOff: 1};
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 20};
const payload = {data, cluster: "genOnOff", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 20};
await mockZHEvents.message(payload);
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/power_plug', stringify({state: 'ON'}), {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/switch_group', stringify({state: 'ON'}), {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/power_plug", stringify({state: "ON"}), {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/switch_group", stringify({state: "ON"}), {retain: false, qos: 0});
});
it('Should not handle messages from coordinator', async () => {
it("Should not handle messages from coordinator", async () => {
const device = devices.coordinator;
const data = {onOff: 1};
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
const payload = {data, cluster: "genOnOff", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
await mockZHEvents.message(payload);
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
});
it('Should not handle messages from still interviewing devices with unknown definition', async () => {
it("Should not handle messages from still interviewing devices with unknown definition", async () => {
const device = devices.interviewing;
const data = {onOff: 1};
mockLogger.debug.mockClear();
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
const payload = {data, cluster: "genOnOff", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
await mockZHEvents.message(payload);
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
expect(mockLogger.debug).toHaveBeenCalledWith(`Skipping message, still interviewing`);
expect(mockLogger.debug).toHaveBeenCalledWith("Skipping message, still interviewing");
});
it('Should handle a command', async () => {
it("Should handle a command", async () => {
const device = devices.E1743;
const data = {};
const payload = {
data,
cluster: 'genLevelCtrl',
cluster: "genLevelCtrl",
device,
endpoint: device.getEndpoint(1),
type: 'commandStopWithOnOff',
type: "commandStopWithOnOff",
linkquality: 10,
meta: {zclTransactionSequenceNumber: 1},
};
await mockZHEvents.message(payload);
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/ikea_onoff');
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({action: 'brightness_stop'});
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/ikea_onoff");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({action: "brightness_stop"});
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
});
it('Should add elapsed', async () => {
settings.set(['advanced', 'elapsed'], true);
it("Should add elapsed", async () => {
settings.set(["advanced", "elapsed"], true);
const device = devices.E1743;
const payload = {data: {}, cluster: 'genLevelCtrl', device, endpoint: device.getEndpoint(1), type: 'commandStopWithOnOff'};
vi.spyOn(Date, 'now').mockReturnValueOnce(150).mockReturnValueOnce(200);
const payload = {data: {}, cluster: "genLevelCtrl", device, endpoint: device.getEndpoint(1), type: "commandStopWithOnOff"};
vi.spyOn(Date, "now").mockReturnValueOnce(150).mockReturnValueOnce(200);
await mockZHEvents.message({...payload, meta: {zclTransactionSequenceNumber: 2}});
await flushPromises();
await mockZHEvents.message({...payload, meta: {zclTransactionSequenceNumber: 3}});
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/ikea_onoff');
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({action: 'brightness_stop'});
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/ikea_onoff");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({action: "brightness_stop"});
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/ikea_onoff');
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toMatchObject({action: 'brightness_stop'});
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual("zigbee2mqtt/ikea_onoff");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toMatchObject({action: "brightness_stop"});
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1]).elapsed).toBe(50);
expect(mockMQTTPublishAsync.mock.calls[1][2]).toStrictEqual({qos: 0, retain: false});
});
it('Should log when message is from supported device but has no converters', async () => {
it("Should log when message is from supported device but has no converters", async () => {
const device = devices.ZNCZ02LM;
const data = {inactiveText: 'hello'};
const payload = {data, cluster: 'genBinaryOutput', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 20};
const data = {inactiveText: "hello"};
const payload = {data, cluster: "genBinaryOutput", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 20};
await mockZHEvents.message(payload);
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
@@ -683,7 +685,7 @@ describe('Extension: Receive', () => {
);
});
it('Should report correct energy and power values for different versions of SP600', async () => {
it("Should report correct energy and power values for different versions of SP600", async () => {
// https://github.com/Koenkk/zigbee-herdsman-converters/issues/915, OLD and NEW use different date code
// divisor of OLD is not correct and therefore underreports by factor 10.
const data = {instantaneousDemand: 496, currentSummDelivered: 6648};
@@ -691,40 +693,40 @@ describe('Extension: Receive', () => {
const SP600_NEW = devices.SP600_NEW;
await mockZHEvents.message({
data,
cluster: 'seMetering',
cluster: "seMetering",
device: SP600_NEW,
endpoint: SP600_NEW.getEndpoint(1),
type: 'attributeReport',
type: "attributeReport",
linkquality: 10,
meta: {zclTransactionSequenceNumber: 1},
});
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/SP600_NEW');
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/SP600_NEW");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({energy: 0.66, power: 49.6});
mockMQTTPublishAsync.mockClear();
const SP600_OLD = devices.SP600_OLD;
await mockZHEvents.message({
data,
cluster: 'seMetering',
cluster: "seMetering",
device: SP600_OLD,
endpoint: SP600_OLD.getEndpoint(1),
type: 'attributeReport',
type: "attributeReport",
linkquality: 10,
meta: {zclTransactionSequenceNumber: 2},
});
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/SP600_OLD');
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/SP600_OLD");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({energy: 6.65, power: 496});
});
it('Should emit DevicesChanged event when a converter announces changed exposes', async () => {
const device = devices['BMCT-SLZ'];
it("Should emit DevicesChanged event when a converter announces changed exposes", async () => {
const device = devices["BMCT-SLZ"];
const data = {deviceMode: 0};
const payload = {data, cluster: 'boschSpecific', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
const payload = {data, cluster: "boschSpecific", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
await mockZHEvents.message(payload);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/devices');
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bridge/devices");
});
});
+167 -165
View File
@@ -1,19 +1,19 @@
import * as data from './mocks/data';
import * as data from "./mocks/data";
import type {MockInstance} from 'vitest';
import type {MockInstance} from "vitest";
import fs from 'node:fs';
import {platform} from 'node:os';
import path from 'node:path';
import fs from "node:fs";
import {platform} from "node:os";
import path from "node:path";
import {rimrafSync} from 'rimraf';
import tmp from 'tmp';
import Transport from 'winston-transport';
import {rimrafSync} from "rimraf";
import tmp from "tmp";
import Transport from "winston-transport";
import logger from '../lib/util/logger';
import * as settings from '../lib/util/settings';
import logger from "../lib/util/logger";
import * as settings from "../lib/util/settings";
describe('Logger', () => {
describe("Logger", () => {
let consoleWriteSpy: MockInstance;
const dir = tmp.dirSync();
@@ -24,29 +24,29 @@ describe('Logger', () => {
beforeAll(() => {
// @ts-expect-error private
consoleWriteSpy = vi.spyOn(console._stdout, 'write').mockImplementation(() => {});
consoleWriteSpy = vi.spyOn(console._stdout, "write").mockImplementation(() => {});
});
afterAll(() => {
consoleWriteSpy.mockRestore();
});
beforeEach(async () => {
beforeEach(() => {
data.writeDefaultConfiguration();
settings.reRead();
settings.set(['advanced', 'log_directory'], dir.name + '/%TIMESTAMP%');
settings.set(["advanced", "log_directory"], `${dir.name}/%TIMESTAMP%`);
logger.init();
consoleWriteSpy.mockClear();
});
afterEach(async () => {});
it('Create log directory', () => {
it("Create log directory", () => {
const dirs = fs.readdirSync(dir.name);
expect(dirs.length).toBe(1);
});
it('Should cleanup (default setting)', () => {
it("Should cleanup (default setting)", () => {
for (const d of fs.readdirSync(dir.name)) {
rimrafSync(path.join(dir.name, d));
}
@@ -60,7 +60,7 @@ describe('Logger', () => {
expect(fs.readdirSync(dir.name).length).toBe(10);
});
it('Should cleanup (15 folders setting)', () => {
it("Should cleanup (15 folders setting)", () => {
for (const d of fs.readdirSync(dir.name)) {
rimrafSync(path.join(dir.name, d));
}
@@ -69,13 +69,13 @@ describe('Logger', () => {
fs.mkdirSync(path.join(dir.name, `log_${i}`));
}
settings.set(['advanced', 'log_directories_to_keep'], 15);
settings.set(["advanced", "log_directories_to_keep"], 15);
expect(fs.readdirSync(dir.name).length).toBe(20);
logger.init();
expect(fs.readdirSync(dir.name).length).toBe(15);
});
it('Should not cleanup when there is no timestamp set', () => {
it("Should not cleanup when there is no timestamp set", () => {
for (const d of fs.readdirSync(dir.name)) {
rimrafSync(path.join(dir.name, d));
}
@@ -84,30 +84,30 @@ describe('Logger', () => {
fs.mkdirSync(path.join(dir.name, `log_${i}`));
}
settings.set(['advanced', 'log_directory'], dir.name + '/bla');
settings.set(["advanced", "log_directory"], `${dir.name}/bla`);
expect(fs.readdirSync(dir.name).length).toBe(20);
logger.init();
expect(fs.readdirSync(dir.name).length).toBe(21);
});
it('Set and get log level', () => {
logger.setLevel('debug');
expect(logger.getLevel()).toBe('debug');
logger.setLevel('info');
expect(logger.getLevel()).toBe('info');
logger.setLevel('warning');
expect(logger.getLevel()).toBe('warning');
logger.setLevel('error');
expect(logger.getLevel()).toBe('error');
it("Set and get log level", () => {
logger.setLevel("debug");
expect(logger.getLevel()).toBe("debug");
logger.setLevel("info");
expect(logger.getLevel()).toBe("info");
logger.setLevel("warning");
expect(logger.getLevel()).toBe("warning");
logger.setLevel("error");
expect(logger.getLevel()).toBe("error");
// winston level always stays at 'debug', logic handled by custom logger
expect(logger.winston.level).toStrictEqual('debug');
expect(logger.winston.level).toStrictEqual("debug");
for (const transport of logger.winston.transports) {
expect(transport.level).toStrictEqual(undefined);
}
});
it('Add/remove transport', () => {
it("Add/remove transport", () => {
class DummyTransport extends Transport {
log(): void {}
}
@@ -120,64 +120,64 @@ describe('Logger', () => {
expect(logger.winston.transports.length).toBe(2);
});
it('Logger should be console and file by default', () => {
it("Logger should be console and file by default", () => {
// @ts-expect-error private
const pipes = logger.winston._readableState.pipes;
expect(pipes.length).toBe(2);
expect(pipes[0].constructor.name).toBe('Console');
expect(pipes[0].constructor.name).toBe("Console");
expect(pipes[0].silent).toBe(false);
expect(pipes[1].constructor.name).toBe('File');
expect(pipes[1].constructor.name).toBe("File");
expect(pipes[1].dirname.startsWith(dir.name)).toBeTruthy();
});
it('Logger can be file only', () => {
settings.set(['advanced', 'log_output'], ['file']);
it("Logger can be file only", () => {
settings.set(["advanced", "log_output"], ["file"]);
logger.init();
// @ts-expect-error private
const pipes = logger.winston._readableState.pipes;
expect(pipes.length).toBe(2);
expect(pipes[0].constructor.name).toBe('Console');
expect(pipes[0].constructor.name).toBe("Console");
expect(pipes[0].silent).toBe(true);
expect(pipes[1].constructor.name).toBe('File');
expect(pipes[1].constructor.name).toBe("File");
expect(pipes[1].dirname.startsWith(dir.name)).toBeTruthy();
});
it('Logger can be console only', () => {
settings.set(['advanced', 'log_output'], ['console']);
it("Logger can be console only", () => {
settings.set(["advanced", "log_output"], ["console"]);
logger.init();
// @ts-expect-error private
const pipes = logger.winston._readableState.pipes;
expect(pipes.constructor.name).toBe('Console');
expect(pipes.constructor.name).toBe("Console");
expect(pipes.silent).toBe(false);
});
it('Logger can be nothing', () => {
settings.set(['advanced', 'log_output'], []);
it("Logger can be nothing", () => {
settings.set(["advanced", "log_output"], []);
logger.init();
// @ts-expect-error private
const pipes = logger.winston._readableState.pipes;
expect(pipes.constructor.name).toBe('Console');
expect(pipes.constructor.name).toBe("Console");
expect(pipes.silent).toBe(true);
});
it('Should allow to disable log rotation', () => {
settings.set(['advanced', 'log_rotation'], false);
it("Should allow to disable log rotation", () => {
settings.set(["advanced", "log_rotation"], false);
logger.init();
// @ts-expect-error private
const pipes = logger.winston._readableState.pipes;
expect(pipes[1].constructor.name).toBe('File');
expect(pipes[1].constructor.name).toBe("File");
expect(pipes[1].maxFiles).toBeNull();
expect(pipes[1].tailable).toBeFalsy();
expect(pipes[1].maxsize).toBeNull();
});
it('Should allow to symlink logs to current directory', () => {
it("Should allow to symlink logs to current directory", () => {
try {
settings.set(['advanced', 'log_symlink_current'], true);
settings.set(["advanced", "log_symlink_current"], true);
logger.init();
expect(fs.readdirSync(dir.name).includes('current')).toBeTruthy();
expect(fs.readdirSync(dir.name).includes("current")).toBeTruthy();
} catch (error) {
if (platform() !== 'win32' || !(error as Error).message.startsWith('EPERM')) {
if (platform() !== "win32" || !(error as Error).message.startsWith("EPERM")) {
throw error;
}
@@ -186,38 +186,38 @@ describe('Logger', () => {
});
it.each([
['debug', {higher: ['info', 'warning', 'error'], lower: []}],
['info', {higher: ['warning', 'error'], lower: ['debug']}],
['warning', {higher: ['error'], lower: ['debug', 'info']}],
['error', {higher: [], lower: ['debug', 'info', 'warning']}],
])('Logs relevant levels for %s', (level, otherLevels) => {
["debug", {higher: ["info", "warning", "error"], lower: []}],
["info", {higher: ["warning", "error"], lower: ["debug"]}],
["warning", {higher: ["error"], lower: ["debug", "info"]}],
["error", {higher: [], lower: ["debug", "info", "warning"]}],
])("Logs relevant levels for %s", (level, otherLevels) => {
logger.setLevel(level as settings.LogLevel);
const logSpy = vi.spyOn(logger.winston, 'log');
const logSpy = vi.spyOn(logger.winston, "log");
consoleWriteSpy.mockClear();
let i = 1;
// @ts-expect-error dynamic
logger[level]('msg');
expect(logSpy).toHaveBeenLastCalledWith(level, 'z2m: msg');
logger[level]("msg");
expect(logSpy).toHaveBeenLastCalledWith(level, "z2m: msg");
expect(consoleWriteSpy).toHaveBeenCalledTimes(i++);
// @ts-expect-error dynamic
logger[level]('msg', 'abcd');
expect(logSpy).toHaveBeenLastCalledWith(level, 'abcd: msg');
logger[level]("msg", "abcd");
expect(logSpy).toHaveBeenLastCalledWith(level, "abcd: msg");
expect(consoleWriteSpy).toHaveBeenCalledTimes(i++);
// @ts-expect-error dynamic
logger[level](() => 'func msg', 'abcd');
expect(logSpy).toHaveBeenLastCalledWith(level, 'abcd: func msg');
logger[level](() => "func msg", "abcd");
expect(logSpy).toHaveBeenLastCalledWith(level, "abcd: func msg");
expect(consoleWriteSpy).toHaveBeenCalledTimes(i++);
for (const higherLevel of otherLevels.higher) {
// @ts-expect-error dynamic
logger[higherLevel]('higher msg');
expect(logSpy).toHaveBeenLastCalledWith(higherLevel, 'z2m: higher msg');
logger[higherLevel]("higher msg");
expect(logSpy).toHaveBeenLastCalledWith(higherLevel, "z2m: higher msg");
expect(consoleWriteSpy).toHaveBeenCalledTimes(i++);
// @ts-expect-error dynamic
logger[higherLevel]('higher msg', 'abcd');
expect(logSpy).toHaveBeenLastCalledWith(higherLevel, 'abcd: higher msg');
logger[higherLevel]("higher msg", "abcd");
expect(logSpy).toHaveBeenLastCalledWith(higherLevel, "abcd: higher msg");
expect(consoleWriteSpy).toHaveBeenCalledTimes(i++);
}
@@ -226,11 +226,11 @@ describe('Logger', () => {
for (const lowerLevel of otherLevels.lower) {
// @ts-expect-error dynamic
logger[lowerLevel]('lower msg');
logger[lowerLevel]("lower msg");
expect(logSpy).not.toHaveBeenCalled();
expect(consoleWriteSpy).not.toHaveBeenCalled();
// @ts-expect-error dynamic
logger[lowerLevel]('lower msg', 'abcd');
logger[lowerLevel]("lower msg", "abcd");
expect(logSpy).not.toHaveBeenCalled();
expect(consoleWriteSpy).not.toHaveBeenCalled();
}
@@ -238,202 +238,204 @@ describe('Logger', () => {
it.each([
[
'^zhc:legacy:fz:(tuya|moes)',
"^zhc:legacy:fz:(tuya|moes)",
new RegExp(/^zhc:legacy:fz:(tuya|moes)/),
[
{ns: 'zhc:legacy:fz:tuya_device12', match: true},
{ns: 'zhc:legacy:fz:moes_dimmer', match: true},
{ns: 'zhc:legacy:fz:not_moes', match: false},
{ns: 'zhc:legacy:fz', match: false},
{ns: 'zhc:legacy:fz:', match: false},
{ns: '1zhc:legacy:fz:tuya_device12', match: false},
{ns: "zhc:legacy:fz:tuya_device12", match: true},
{ns: "zhc:legacy:fz:moes_dimmer", match: true},
{ns: "zhc:legacy:fz:not_moes", match: false},
{ns: "zhc:legacy:fz", match: false},
{ns: "zhc:legacy:fz:", match: false},
{ns: "1zhc:legacy:fz:tuya_device12", match: false},
],
],
[
'^zhc:legacy:fz:(tuya|moes)|^zh:ember:uart:|^zh:controller',
"^zhc:legacy:fz:(tuya|moes)|^zh:ember:uart:|^zh:controller",
new RegExp(/^zhc:legacy:fz:(tuya|moes)|^zh:ember:uart:|^zh:controller/),
[
{ns: 'zh:ember:uart:ash', match: true},
{ns: 'zh:ember:uart', match: false},
{ns: 'zh:controller', match: true},
{ns: 'zh:controller:', match: true},
{ns: 'azh:controller:', match: false},
{ns: "zh:ember:uart:ash", match: true},
{ns: "zh:ember:uart", match: false},
{ns: "zh:controller", match: true},
{ns: "zh:controller:", match: true},
{ns: "azh:controller:", match: false},
],
],
[
'',
"",
undefined,
[
{ns: 'zhc:legacy:fz:tuya_device12', match: false},
{ns: 'zhc:legacy:fz:moes_dimmer', match: false},
{ns: 'zhc:legacy:fz:not_moes', match: false},
{ns: 'zhc:legacy:fz', match: false},
{ns: 'zhc:legacy:fz:', match: false},
{ns: '1zhc:legacy:fz:tuya_device12', match: false},
{ns: 'zh:ember:uart:ash', match: false},
{ns: 'zh:ember:uart', match: false},
{ns: 'zh:controller', match: false},
{ns: 'zh:controller:', match: false},
{ns: 'azh:controller:', match: false},
{ns: "zhc:legacy:fz:tuya_device12", match: false},
{ns: "zhc:legacy:fz:moes_dimmer", match: false},
{ns: "zhc:legacy:fz:not_moes", match: false},
{ns: "zhc:legacy:fz", match: false},
{ns: "zhc:legacy:fz:", match: false},
{ns: "1zhc:legacy:fz:tuya_device12", match: false},
{ns: "zh:ember:uart:ash", match: false},
{ns: "zh:ember:uart", match: false},
{ns: "zh:controller", match: false},
{ns: "zh:controller:", match: false},
{ns: "azh:controller:", match: false},
],
],
])('Sets namespace ignore for debug level %s', (ignore, expected, tests) => {
logger.setLevel('debug');
const logSpy = vi.spyOn(logger.winston, 'log');
])("Sets namespace ignore for debug level %s", (ignore, expected, tests) => {
logger.setLevel("debug");
const logSpy = vi.spyOn(logger.winston, "log");
logger.setDebugNamespaceIgnore(ignore);
// @ts-expect-error private
expect(logger.debugNamespaceIgnoreRegex).toStrictEqual(expected);
expect(logger.getDebugNamespaceIgnore()).toStrictEqual(ignore);
for (const test of tests) {
logger.debug('Test message', test.ns);
logger.debug("Test message", test.ns);
if (test.match) {
expect(logSpy).not.toHaveBeenCalled();
} else {
expect(logSpy).toHaveBeenLastCalledWith('debug', `${test.ns}: Test message`);
expect(logSpy).toHaveBeenLastCalledWith("debug", `${test.ns}: Test message`);
}
logSpy.mockClear();
}
});
it('Logs with namespaced levels or default - higher', () => {
settings.set(['advanced', 'log_namespaced_levels'], {
'z2m:mqtt': 'warning',
it("Logs with namespaced levels or default - higher", () => {
settings.set(["advanced", "log_namespaced_levels"], {
"z2m:mqtt": "warning",
});
logger.init();
logger.setLevel('debug');
expect(logger.getNamespacedLevels()).toStrictEqual({'z2m:mqtt': 'warning'});
expect(logger.getLevel()).toStrictEqual('debug');
logger.setLevel("debug");
expect(logger.getNamespacedLevels()).toStrictEqual({"z2m:mqtt": "warning"});
expect(logger.getLevel()).toStrictEqual("debug");
const logSpy = vi.spyOn(logger.winston, 'log');
const logSpy = vi.spyOn(logger.winston, "log");
consoleWriteSpy.mockClear();
logger.info(`MQTT publish: topic 'abcd/efgh', payload '{"my": {"payload": "injson"}}'`, 'z2m:mqtt');
logger.info(`MQTT publish: topic 'abcd/efgh', payload '{"my": {"payload": "injson"}}'`, "z2m:mqtt");
expect(logSpy).toHaveBeenCalledTimes(0);
expect(consoleWriteSpy).toHaveBeenCalledTimes(0);
logger.error(`Not connected to MQTT server!`, 'z2m:mqtt');
logger.error("Not connected to MQTT server!", "z2m:mqtt");
expect(logSpy).toHaveBeenCalledTimes(1);
expect(consoleWriteSpy).toHaveBeenCalledTimes(1);
logger.info(`Just another info message`, 'z2m:notmqtt');
logger.info("Just another info message", "z2m:notmqtt");
expect(logSpy).toHaveBeenCalledTimes(2);
expect(consoleWriteSpy).toHaveBeenCalledTimes(2);
});
it('Logs with namespaced levels or default - lower', () => {
it("Logs with namespaced levels or default - lower", () => {
expect(logger.getNamespacedLevels()).toStrictEqual({});
logger.setNamespacedLevels({'z2m:mqtt': 'info'});
logger.setLevel('warning');
expect(logger.getNamespacedLevels()).toStrictEqual({'z2m:mqtt': 'info'});
expect(logger.getLevel()).toStrictEqual('warning');
logger.setNamespacedLevels({"z2m:mqtt": "info"});
logger.setLevel("warning");
expect(logger.getNamespacedLevels()).toStrictEqual({"z2m:mqtt": "info"});
expect(logger.getLevel()).toStrictEqual("warning");
const logSpy = vi.spyOn(logger.winston, 'log');
const logSpy = vi.spyOn(logger.winston, "log");
consoleWriteSpy.mockClear();
logger.info(`MQTT publish: topic 'abcd/efgh', payload '{"my": {"payload": "injson"}}'`, 'z2m:mqtt');
logger.info(`MQTT publish: topic 'abcd/efgh', payload '{"my": {"payload": "injson"}}'`, "z2m:mqtt");
expect(logSpy).toHaveBeenCalledTimes(1);
expect(consoleWriteSpy).toHaveBeenCalledTimes(1);
logger.error(`Not connected to MQTT server!`, 'z2m:mqtt');
logger.error("Not connected to MQTT server!", "z2m:mqtt");
expect(logSpy).toHaveBeenCalledTimes(2);
expect(consoleWriteSpy).toHaveBeenCalledTimes(2);
logger.info(`Just another info message`, 'z2m:notmqtt');
logger.info("Just another info message", "z2m:notmqtt");
expect(logSpy).toHaveBeenCalledTimes(2);
expect(consoleWriteSpy).toHaveBeenCalledTimes(2);
logger.warning(`Just another warning message`, 'z2m:notmqtt');
logger.warning("Just another warning message", "z2m:notmqtt");
expect(logSpy).toHaveBeenCalledTimes(3);
expect(consoleWriteSpy).toHaveBeenCalledTimes(3);
});
it('Logs with namespaced levels hierarchy', () => {
const nsLevels = {'zh:zstack': 'debug' as const, 'zh:zstack:unpi:writer': 'error' as const};
it("Logs with namespaced levels hierarchy", () => {
const nsLevels = {"zh:zstack": "debug" as const, "zh:zstack:unpi:writer": "error" as const};
let cachedNSLevels;
cachedNSLevels = Object.assign({}, nsLevels);
logger.setNamespacedLevels(nsLevels);
logger.setLevel('warning');
logger.setLevel("warning");
consoleWriteSpy.mockClear();
logger.debug(`--- parseNext [] debug picked from hierarchy`, 'zh:zstack:unpi:parser');
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {'zh:zstack:unpi:parser': 'debug'}));
logger.debug("--- parseNext [] debug picked from hierarchy", "zh:zstack:unpi:parser");
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {"zh:zstack:unpi:parser": "debug"}));
expect(consoleWriteSpy).toHaveBeenCalledTimes(1);
logger.warning(`--> frame [36,15] warning explicitely supressed`, 'zh:zstack:unpi:writer');
logger.warning("--> frame [36,15] warning explicitely supressed", "zh:zstack:unpi:writer");
expect(getCachedNamespacedLevels()).toStrictEqual(cachedNSLevels);
expect(consoleWriteSpy).toHaveBeenCalledTimes(1);
logger.warning(`Another supressed warning message in a sub namespace`, 'zh:zstack:unpi:writer:sub:ns');
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {'zh:zstack:unpi:writer:sub:ns': 'error'}));
logger.warning("Another supressed warning message in a sub namespace", "zh:zstack:unpi:writer:sub:ns");
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {"zh:zstack:unpi:writer:sub:ns": "error"}));
expect(consoleWriteSpy).toHaveBeenCalledTimes(1);
logger.error(`but error should go through`, 'zh:zstack:unpi:writer:another:sub:ns');
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {'zh:zstack:unpi:writer:another:sub:ns': 'error'}));
logger.error("but error should go through", "zh:zstack:unpi:writer:another:sub:ns");
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {"zh:zstack:unpi:writer:another:sub:ns": "error"}));
expect(consoleWriteSpy).toHaveBeenCalledTimes(2);
logger.warning(`new unconfigured namespace warning`, 'z2m:mqtt');
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {'z2m:mqtt': 'warning'}));
logger.warning("new unconfigured namespace warning", "z2m:mqtt");
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {"z2m:mqtt": "warning"}));
expect(consoleWriteSpy).toHaveBeenCalledTimes(3);
logger.info(`cached unconfigured namespace info should be supressed`, 'z2m:mqtt');
logger.info("cached unconfigured namespace info should be supressed", "z2m:mqtt");
expect(getCachedNamespacedLevels()).toStrictEqual(cachedNSLevels);
expect(consoleWriteSpy).toHaveBeenCalledTimes(3);
logger.setLevel('info');
expect(getCachedNamespacedLevels()).toStrictEqual((cachedNSLevels = Object.assign({}, nsLevels)));
logger.info(`unconfigured namespace info should now pass after default level change and cache reset`, 'z2m:mqtt');
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {'z2m:mqtt': 'info'}));
logger.setLevel("info");
cachedNSLevels = Object.assign({}, nsLevels);
expect(getCachedNamespacedLevels()).toStrictEqual(cachedNSLevels);
logger.info("unconfigured namespace info should now pass after default level change and cache reset", "z2m:mqtt");
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {"z2m:mqtt": "info"}));
expect(consoleWriteSpy).toHaveBeenCalledTimes(4);
logger.error(`configured namespace hierachy should still work after the cache reset`, 'zh:zstack:unpi:writer:another:sub:ns');
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {'zh:zstack:unpi:writer:another:sub:ns': 'error'}));
logger.error("configured namespace hierachy should still work after the cache reset", "zh:zstack:unpi:writer:another:sub:ns");
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {"zh:zstack:unpi:writer:another:sub:ns": "error"}));
expect(consoleWriteSpy).toHaveBeenCalledTimes(5);
logger.setNamespacedLevels({'zh:zstack': 'warning'});
expect(getCachedNamespacedLevels()).toStrictEqual((cachedNSLevels = {'zh:zstack': 'warning'}));
logger.error(`error logged`, 'zh:zstack:unpi:writer');
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {'zh:zstack:unpi:writer': 'warning'}));
logger.setNamespacedLevels({"zh:zstack": "warning"});
cachedNSLevels = {"zh:zstack": "warning"};
expect(getCachedNamespacedLevels()).toStrictEqual(cachedNSLevels);
logger.error("error logged", "zh:zstack:unpi:writer");
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {"zh:zstack:unpi:writer": "warning"}));
expect(consoleWriteSpy).toHaveBeenCalledTimes(6);
logger.debug(`debug suppressed`, 'zh:zstack:unpi');
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {'zh:zstack:unpi': 'warning'}));
logger.debug("debug suppressed", "zh:zstack:unpi");
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {"zh:zstack:unpi": "warning"}));
expect(consoleWriteSpy).toHaveBeenCalledTimes(6);
logger.warning(`warning logged`, 'zh:zstack');
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {'zh:zstack': 'warning'}));
logger.warning("warning logged", "zh:zstack");
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {"zh:zstack": "warning"}));
expect(consoleWriteSpy).toHaveBeenCalledTimes(7);
logger.info(`unconfigured namespace`, 'z2m:mqtt');
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {'z2m:mqtt': 'info'}));
logger.info("unconfigured namespace", "z2m:mqtt");
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {"z2m:mqtt": "info"}));
expect(consoleWriteSpy).toHaveBeenCalledTimes(8);
});
it('Ignores SPLAT chars', () => {
logger.setLevel('debug');
it("Ignores SPLAT chars", () => {
logger.setLevel("debug");
const logSpy = vi.spyOn(logger.winston, 'log');
const logSpy = vi.spyOn(logger.winston, "log");
consoleWriteSpy.mockClear();
let splatChars = '%d';
logger.debug(splatChars, 'z2m:mqtt');
expect(logSpy).toHaveBeenLastCalledWith('debug', `z2m:mqtt: ${splatChars}`);
let splatChars = "%d";
logger.debug(splatChars, "z2m:mqtt");
expect(logSpy).toHaveBeenLastCalledWith("debug", `z2m:mqtt: ${splatChars}`);
expect(consoleWriteSpy.mock.calls[0][0]).toMatch(new RegExp(`^.*\tz2m:mqtt: ${splatChars}`));
splatChars = 'anything %s goes here';
logger.debug(splatChars, 'z2m:test');
expect(logSpy).toHaveBeenLastCalledWith('debug', `z2m:test: ${splatChars}`);
splatChars = "anything %s goes here";
logger.debug(splatChars, "z2m:test");
expect(logSpy).toHaveBeenLastCalledWith("debug", `z2m:test: ${splatChars}`);
expect(consoleWriteSpy.mock.calls[1][0]).toMatch(new RegExp(`^.*\tz2m:test: ${splatChars}`));
});
it('Logs to console in JSON when configured', () => {
settings.set(['advanced', 'log_console_json'], true);
it("Logs to console in JSON when configured", () => {
settings.set(["advanced", "log_console_json"], true);
logger.init();
consoleWriteSpy.mockClear();
logger.info(`Test JSON message`, 'z2m');
logger.info("Test JSON message", "z2m");
const outputJSON = JSON.parse(consoleWriteSpy.mock.calls[0][0]);
expect(outputJSON).toStrictEqual({
level: 'info',
message: 'z2m: Test JSON message',
level: "info",
message: "z2m: Test JSON message",
timestamp: expect.any(String),
});
settings.set(['advanced', 'log_console_json'], false);
settings.set(["advanced", "log_console_json"], false);
logger.init();
consoleWriteSpy.mockClear();
logger.info(`Test JSON message`, 'z2m');
logger.info("Test JSON message", "z2m");
const outputStr: string = consoleWriteSpy.mock.calls[0][0];
expect(outputStr.trim().endsWith('\u001b[32minfo\u001b[39m: \tz2m: Test JSON message')).toStrictEqual(true);
expect(outputStr.trim().endsWith("\u001b[32minfo\u001b[39m: \tz2m: Test JSON message")).toStrictEqual(true);
});
});
+153 -141
View File
@@ -1,248 +1,250 @@
import fs from 'node:fs';
import path from 'node:path';
import fs from "node:fs";
import path from "node:path";
import stringify from 'json-stable-stringify-without-jsonify';
import tmp from 'tmp';
import stringify from "json-stable-stringify-without-jsonify";
import tmp from "tmp";
import yaml from '../../lib/util/yaml';
import yaml from "../../lib/util/yaml";
export const mockDir: string = tmp.dirSync().name;
const stateFile = path.join(mockDir, 'state.json');
const configFile = path.join(mockDir, "configuration.yaml");
const stateFile = path.join(mockDir, "state.json");
const databaseFile = path.join(mockDir, "database.db");
export const DEFAULT_CONFIGURATION = {
homeassistant: {enabled: false},
frontend: {enabled: false},
availability: {enabled: false},
mqtt: {
base_topic: 'zigbee2mqtt',
server: 'mqtt://localhost',
base_topic: "zigbee2mqtt",
server: "mqtt://localhost",
},
serial: {
port: '/dev/dummy',
port: "/dev/dummy",
},
devices: {
'0x18fc2600000d7ae2': {
friendly_name: 'bosch_radiator',
"0x18fc2600000d7ae2": {
friendly_name: "bosch_radiator",
},
'0x000b57fffec6a5b2': {
"0x000b57fffec6a5b2": {
retain: true,
friendly_name: 'bulb',
description: 'this is my bulb',
friendly_name: "bulb",
description: "this is my bulb",
},
'0x0017880104e45517': {
"0x0017880104e45517": {
retain: true,
friendly_name: 'remote',
friendly_name: "remote",
},
'0x0017880104e45520': {
"0x0017880104e45520": {
retain: false,
friendly_name: 'button',
friendly_name: "button",
},
'0x0017880104e45521': {
"0x0017880104e45521": {
retain: false,
friendly_name: 'button_double_key',
friendly_name: "button_double_key",
},
'0x0017880104e45522': {
"0x0017880104e45522": {
qos: 1,
retain: false,
friendly_name: 'weather_sensor',
friendly_name: "weather_sensor",
},
'0x0017880104e45523': {
"0x0017880104e45523": {
retain: false,
friendly_name: 'occupancy_sensor',
friendly_name: "occupancy_sensor",
},
'0x0017880104e45524': {
"0x0017880104e45524": {
retain: false,
friendly_name: 'power_plug',
friendly_name: "power_plug",
},
'0x0017880104e45530': {
"0x0017880104e45530": {
retain: false,
friendly_name: 'button_double_key_interviewing',
friendly_name: "button_double_key_interviewing",
},
'0x0017880104e45540': {
friendly_name: 'ikea_onoff',
"0x0017880104e45540": {
friendly_name: "ikea_onoff",
},
'0x000b57fffec6a5b7': {
"0x000b57fffec6a5b7": {
retain: false,
friendly_name: 'bulb_2',
friendly_name: "bulb_2",
},
'0x000b57fffec6a5b3': {
"0x000b57fffec6a5b3": {
retain: false,
friendly_name: 'bulb_color',
friendly_name: "bulb_color",
},
'0x000b57fffec6a5b4': {
"0x000b57fffec6a5b4": {
retain: false,
friendly_name: 'bulb_color_2',
friendly_name: "bulb_color_2",
},
'0x0017880104e45541': {
"0x0017880104e45541": {
retain: false,
friendly_name: 'wall_switch',
friendly_name: "wall_switch",
},
'0x0017880104e45542': {
"0x0017880104e45542": {
retain: false,
friendly_name: 'wall_switch_double',
friendly_name: "wall_switch_double",
},
'0x0017880104e45543': {
"0x0017880104e45543": {
retain: false,
friendly_name: 'led_controller_1',
friendly_name: "led_controller_1",
},
'0x0017880104e45544': {
"0x0017880104e45544": {
retain: false,
friendly_name: 'led_controller_2',
friendly_name: "led_controller_2",
},
'0x0017880104e45545': {
"0x0017880104e45545": {
retain: false,
friendly_name: 'dimmer_wall_switch',
friendly_name: "dimmer_wall_switch",
},
'0x0017880104e45547': {
"0x0017880104e45547": {
retain: false,
friendly_name: 'curtain',
friendly_name: "curtain",
},
'0x0017880104e45548': {
"0x0017880104e45548": {
retain: false,
friendly_name: 'fan',
friendly_name: "fan",
},
'0x0017880104e45549': {
"0x0017880104e45549": {
retain: false,
friendly_name: 'siren',
friendly_name: "siren",
},
'0x0017880104e45529': {
"0x0017880104e45529": {
retain: false,
friendly_name: 'unsupported2',
friendly_name: "unsupported2",
},
'0x0017880104e45550': {
"0x0017880104e45550": {
retain: false,
friendly_name: 'thermostat',
friendly_name: "thermostat",
},
'0x0017880104e45551': {
"0x0017880104e45551": {
retain: false,
friendly_name: 'smart vent',
friendly_name: "smart vent",
},
'0x0017880104e45552': {
"0x0017880104e45552": {
retain: false,
friendly_name: 'j1',
friendly_name: "j1",
},
'0x0017880104e45553': {
"0x0017880104e45553": {
retain: false,
friendly_name: 'bulb_enddevice',
friendly_name: "bulb_enddevice",
},
'0x0017880104e45559': {
"0x0017880104e45559": {
retain: false,
friendly_name: 'cc2530_router',
friendly_name: "cc2530_router",
},
'0x0017880104e45560': {
"0x0017880104e45560": {
retain: false,
friendly_name: 'livolo',
friendly_name: "livolo",
},
'0x90fd9ffffe4b64ae': {
"0x90fd9ffffe4b64ae": {
retain: false,
friendly_name: 'tradfri_remote',
friendly_name: "tradfri_remote",
},
'0x90fd9ffffe4b64af': {
friendly_name: 'roller_shutter',
"0x90fd9ffffe4b64af": {
friendly_name: "roller_shutter",
},
'0x90fd9ffffe4b64ax': {
friendly_name: 'ZNLDP12LM',
"0x90fd9ffffe4b64ax": {
friendly_name: "ZNLDP12LM",
},
'0x90fd9ffffe4b64aa': {
friendly_name: 'SP600_OLD',
"0x90fd9ffffe4b64aa": {
friendly_name: "SP600_OLD",
},
'0x90fd9ffffe4b64ab': {
friendly_name: 'SP600_NEW',
"0x90fd9ffffe4b64ab": {
friendly_name: "SP600_NEW",
},
'0x90fd9ffffe4b64ac': {
friendly_name: 'MKS-CM-W5',
"0x90fd9ffffe4b64ac": {
friendly_name: "MKS-CM-W5",
},
'0x0017880104e45526': {
friendly_name: 'GL-S-007ZS',
"0x0017880104e45526": {
friendly_name: "GL-S-007ZS",
},
'0x0017880104e43559': {
friendly_name: 'U202DST600ZB',
"0x0017880104e43559": {
friendly_name: "U202DST600ZB",
},
'0xf4ce368a38be56a1': {
"0xf4ce368a38be56a1": {
retain: false,
friendly_name: 'zigfred_plus',
front_surface_enabled: 'true',
dimmer_1_enabled: 'true',
dimmer_1_dimming_enabled: 'true',
dimmer_2_enabled: 'true',
dimmer_2_dimming_enabled: 'true',
dimmer_3_enabled: 'true',
dimmer_3_dimming_enabled: 'true',
dimmer_4_enabled: 'true',
dimmer_4_dimming_enabled: 'true',
cover_1_enabled: 'true',
cover_1_tilt_enabled: 'true',
cover_2_enabled: 'true',
cover_2_tilt_enabled: 'true',
friendly_name: "zigfred_plus",
front_surface_enabled: "true",
dimmer_1_enabled: "true",
dimmer_1_dimming_enabled: "true",
dimmer_2_enabled: "true",
dimmer_2_dimming_enabled: "true",
dimmer_3_enabled: "true",
dimmer_3_dimming_enabled: "true",
dimmer_4_enabled: "true",
dimmer_4_dimming_enabled: "true",
cover_1_enabled: "true",
cover_1_tilt_enabled: "true",
cover_2_enabled: "true",
cover_2_tilt_enabled: "true",
},
'0x0017880104e44559': {
friendly_name: '3157100_thermostat',
"0x0017880104e44559": {
friendly_name: "3157100_thermostat",
},
'0x0017880104a44559': {
friendly_name: 'J1_cover',
"0x0017880104a44559": {
friendly_name: "J1_cover",
},
'0x0017882104a44559': {
friendly_name: 'TS0601_thermostat',
"0x0017882104a44559": {
friendly_name: "TS0601_thermostat",
},
'0x0017882104a44560': {
friendly_name: 'TS0601_switch',
"0x0017882104a44560": {
friendly_name: "TS0601_switch",
},
'0x0017882104a44562': {
friendly_name: 'TS0601_cover_switch',
"0x0017882104a44562": {
friendly_name: "TS0601_cover_switch",
},
'0x0017882194e45543': {
friendly_name: 'QS-Zigbee-D02-TRIAC-2C-LN',
"0x0017882194e45543": {
friendly_name: "QS-Zigbee-D02-TRIAC-2C-LN",
},
'0x0017880104e45724': {
friendly_name: 'GLEDOPTO_2ID',
"0x0017880104e45724": {
friendly_name: "GLEDOPTO_2ID",
},
'0x0017880104e45561': {
friendly_name: 'temperature_sensor',
"0x0017880104e45561": {
friendly_name: "temperature_sensor",
},
'0x0017880104e45562': {
friendly_name: 'heating_actuator',
"0x0017880104e45562": {
friendly_name: "heating_actuator",
},
'0x000b57cdfec6a5b3': {
friendly_name: 'hue_twilight',
"0x000b57cdfec6a5b3": {
friendly_name: "hue_twilight",
},
'0x00124b00cfcf3298': {
friendly_name: 'fanbee',
"0x00124b00cfcf3298": {
friendly_name: "fanbee",
retain: true,
},
},
groups: {
1: {
friendly_name: 'group_1',
friendly_name: "group_1",
retain: false,
},
2: {
friendly_name: 'group_2',
friendly_name: "group_2",
retain: false,
},
15071: {
friendly_name: 'group_tradfri_remote',
friendly_name: "group_tradfri_remote",
retain: false,
},
11: {
friendly_name: 'group_with_tradfri',
friendly_name: "group_with_tradfri",
retain: false,
},
12: {
friendly_name: 'thermostat_group',
friendly_name: "thermostat_group",
retain: false,
},
14: {
friendly_name: 'switch_group',
friendly_name: "switch_group",
retain: false,
},
21: {
friendly_name: 'gledopto_group',
friendly_name: "gledopto_group",
},
9: {
friendly_name: 'ha_discovery_group',
friendly_name: "ha_discovery_group",
},
19: {
friendly_name: 'hue_twilight_group',
friendly_name: "hue_twilight_group",
},
},
};
@@ -250,7 +252,15 @@ export const DEFAULT_CONFIGURATION = {
export function writeDefaultConfiguration(config: unknown = undefined): void {
config = config || DEFAULT_CONFIGURATION;
yaml.writeIfChanged(path.join(mockDir, 'configuration.yaml'), config);
yaml.writeIfChanged(configFile, config);
}
export function read(): ReturnType<typeof yaml.read> {
return yaml.read(configFile);
}
export function removeConfiguration(): void {
fs.rmSync(configFile, {force: true});
}
export function writeEmptyState(): void {
@@ -258,9 +268,7 @@ export function writeEmptyState(): void {
}
export function removeState(): void {
if (stateExists()) {
fs.unlinkSync(stateFile);
}
fs.rmSync(stateFile, {force: true});
}
export function stateExists(): boolean {
@@ -268,17 +276,17 @@ export function stateExists(): boolean {
}
const defaultState = {
'0x000b57fffec6a5b2': {
state: 'ON',
"0x000b57fffec6a5b2": {
state: "ON",
brightness: 50,
color_temp: 370,
linkquality: 99,
},
'0x0017880104e45517': {
"0x0017880104e45517": {
brightness: 255,
},
1: {
state: 'ON',
state: "ON",
},
};
@@ -287,15 +295,19 @@ export function getDefaultState(): typeof defaultState {
}
export function writeDefaultState(): void {
fs.writeFileSync(path.join(mockDir, 'state.json'), stringify(defaultState));
fs.writeFileSync(path.join(mockDir, "state.json"), stringify(defaultState));
}
export function read(): ReturnType<typeof yaml.read> {
return yaml.read(path.join(mockDir, 'configuration.yaml'));
export function writeEmptyDatabase(): void {
fs.writeFileSync(databaseFile, "");
}
vi.mock('../../lib/util/data', async () => {
const nodePath = await vi.importActual<typeof import('node:path')>('node:path');
export function removeDatabase(): void {
fs.rmSync(databaseFile, {force: true});
}
vi.mock("../../lib/util/data", async () => {
const nodePath = await vi.importActual<typeof import("node:path")>("node:path");
return {
default: {
+1 -1
View File
@@ -1,5 +1,5 @@
export const mockDebounce = vi.fn((fn) => fn);
vi.mock('debounce', () => ({
vi.mock("debounce", () => ({
default: mockDebounce,
}));
+2 -2
View File
@@ -1,7 +1,7 @@
export const mockJSZipFile = vi.fn();
export const mockJSZipGenerateAsync = vi.fn().mockReturnValue('THISISBASE64');
export const mockJSZipGenerateAsync = vi.fn().mockReturnValue("THISISBASE64");
vi.mock('jszip', () => ({
vi.mock("jszip", () => ({
default: vi.fn().mockImplementation(() => {
return {
file: mockJSZipFile,
+12 -11
View File
@@ -1,15 +1,16 @@
import type {LogLevel} from 'lib/util/settings';
import type Transport from 'winston-transport';
import type Transport from "winston-transport";
let level: LogLevel = 'info';
let debugNamespaceIgnore: string = '';
import type {LogLevel} from "../../lib/util/settings";
let level: LogLevel = "info";
let debugNamespaceIgnore = "";
let namespacedLevels: Record<string, LogLevel> = {};
let transports: Transport[] = [];
let transportsEnabled: boolean = false;
let transportsEnabled = false;
const getMessage = (messageOrLambda: string | (() => string)): string => (messageOrLambda instanceof Function ? messageOrLambda() : messageOrLambda);
export const mockLogger = {
log: vi.fn().mockImplementation((level, message, namespace = 'z2m') => {
log: vi.fn().mockImplementation((level, message, namespace = "z2m") => {
if (transportsEnabled) {
for (const transport of transports) {
transport.log!({level, message, namespace}, () => {});
@@ -17,10 +18,10 @@ export const mockLogger = {
}
}),
init: vi.fn(),
info: vi.fn().mockImplementation((messageOrLambda, namespace = 'z2m') => mockLogger.log('info', getMessage(messageOrLambda), namespace)),
warning: vi.fn().mockImplementation((messageOrLambda, namespace = 'z2m') => mockLogger.log('warning', getMessage(messageOrLambda), namespace)),
error: vi.fn().mockImplementation((messageOrLambda, namespace = 'z2m') => mockLogger.log('error', getMessage(messageOrLambda), namespace)),
debug: vi.fn().mockImplementation((messageOrLambda, namespace = 'z2m') => mockLogger.log('debug', getMessage(messageOrLambda), namespace)),
info: vi.fn().mockImplementation((messageOrLambda, namespace = "z2m") => mockLogger.log("info", getMessage(messageOrLambda), namespace)),
warning: vi.fn().mockImplementation((messageOrLambda, namespace = "z2m") => mockLogger.log("warning", getMessage(messageOrLambda), namespace)),
error: vi.fn().mockImplementation((messageOrLambda, namespace = "z2m") => mockLogger.log("error", getMessage(messageOrLambda), namespace)),
debug: vi.fn().mockImplementation((messageOrLambda, namespace = "z2m") => mockLogger.log("debug", getMessage(messageOrLambda), namespace)),
cleanup: vi.fn(),
logOutput: vi.fn(),
add: (transport: Transport): void => {
@@ -50,6 +51,6 @@ export const mockLogger = {
end: vi.fn(),
};
vi.mock('../../lib/util/logger', () => ({
vi.mock("../../lib/util/logger", () => ({
default: mockLogger,
}));
+9 -12
View File
@@ -1,16 +1,13 @@
import type {IClientPublishOptions} from 'mqtt';
import type {IClientPublishOptions} from "mqtt";
import {EventHandler} from './utils';
import type {EventHandler} from "./utils";
export const events: Record<string, EventHandler> = {};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const mockMQTTPublishAsync = vi.fn(async (topic: string, message: string, opts?: IClientPublishOptions): Promise<void> => {});
export const mockMQTTPublishAsync = vi.fn(async (_topic: string, _message: string, _opts?: IClientPublishOptions): Promise<void> => {});
export const mockMQTTEndAsync = vi.fn(async (): Promise<void> => {});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const mockMQTTSubscribeAsync = vi.fn(async (topicObject: string): Promise<void> => {});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const mockMQTTUnsubscribeAsync = vi.fn(async (topic: string): Promise<void> => {});
export const mockMQTTSubscribeAsync = vi.fn(async (_topicObject: string): Promise<void> => {});
export const mockMQTTUnsubscribeAsync = vi.fn(async (_topic: string): Promise<void> => {});
export const mockMQTTConnectAsync = vi.fn(() => ({
reconnecting: false,
@@ -20,9 +17,9 @@ export const mockMQTTConnectAsync = vi.fn(() => ({
endAsync: mockMQTTEndAsync,
subscribeAsync: mockMQTTSubscribeAsync,
unsubscribeAsync: mockMQTTUnsubscribeAsync,
on: vi.fn((type, handler) => {
if (type === 'connect') {
handler();
on: vi.fn(async (type, handler) => {
if (type === "connect") {
await handler();
}
events[type] = handler;
@@ -30,6 +27,6 @@ export const mockMQTTConnectAsync = vi.fn(() => ({
stream: {setMaxListeners: vi.fn()},
}));
vi.mock('mqtt', () => ({
vi.mock("mqtt", () => ({
connectAsync: mockMQTTConnectAsync,
}));

Some files were not shown because too many files have changed in this diff Show More