Compare commits

..
466 Commits
Author SHA1 Message Date
Koen Kanters 2b485a98c5 chore(dev): release 2.9.2 (#31229) 2026-04-01 21:26:12 +02:00
Koen Kanters 62ab814d73 fix(ignore): update zigbee-herdsman-converters to 26.27.0 (#31563) 2026-03-31 19:00:42 +00:00
Koen Kanters fb3189b743 fix(ignore): update zigbee-herdsman to 10.0.5 (#31562) 2026-03-31 18:57:20 +00:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Nerivec
73d8ae8956 fix(ignore): bump the minor-patch group with 3 updates (#31559)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
2026-03-31 20:46:21 +02:00
Koen Kanters 969db9ae85 fix(ignore): update zigbee-herdsman-converters to 26.26.0 (#31510) 2026-03-25 19:20:04 +00:00
Koen Kanters 66985a8ce8 fix(ignore): update zigbee-herdsman-converters to 26.25.0 (#31497) 2026-03-24 21:22:34 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 52b063bb32 fix(ignore): bump the minor-patch group with 3 updates (#31489)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-24 21:48:41 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 3f2c00cdb7 chore(deps): bump pnpm/action-setup from 4 to 5 (#31488)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-24 21:48:28 +01:00
Koen Kanters dde637af2f fix(ignore): update zigbee-herdsman-converters to 26.24.0 (#31479) 2026-03-22 20:06:24 +00:00
Koen Kanters 867179c430 fix(ignore): update zigbee-herdsman to 10.0.4 (#31478) 2026-03-22 19:52:17 +00:00
Koen Kanters 2e5e362818 fix(ignore): update zigbee-herdsman-converters to 26.23.0 (#31462) 2026-03-20 20:26:14 +00:00
Koen Kanters 64286ebb30 fix(ignore): update zigbee-herdsman-converters to 26.22.0 (#31451) 2026-03-20 20:17:22 +00:00
Koen Kanters 6fb9d563e6 fix(ignore): update zigbee-herdsman to 10.0.3 (#31450) 2026-03-19 19:01:07 +00:00
Koen Kanters e054d7124b fix(ignore): update zigbee-herdsman-converters to 26.21.0 (#31427) 2026-03-17 19:18:59 +00:00
Koen Kanters 35567cb67f chore: change sorting to prevent merge conflict when both deps are updated at the same time 2026-03-17 20:14:34 +01:00
Koen Kanters 075f87c782 fix(ignore): update zigbee-herdsman to 10.0.2 (#31413) 2026-03-16 20:00:37 +00:00
Koen Kanters 9f49d0ec67 fix(ignore): update zigbee-herdsman-converters to 26.20.0 (#31415) 2026-03-16 19:57:51 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 67aa5099df fix(ignore): bump the minor-patch group with 2 updates (#31411)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-16 17:42:22 +01:00
Koen Kanters 69641d5467 fix(ignore): update zigbee-herdsman-converters to 26.19.1 (#31384) 2026-03-13 13:58:52 +00:00
Koen Kanters 6fca3744c9 fix(ignore): update zigbee-herdsman to 10.0.1 (#31383) 2026-03-13 13:11:23 +00:00
Koen Kanters f60db2d7a6 fix(ignore): update zigbee-herdsman-converters to 26.18.0 (#31362) 2026-03-12 06:47:49 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 08bf2cbc07 chore(deps): bump docker/build-push-action from 6 to 7 (#31325)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-12 07:14:47 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> c790e7b68f chore(deps): bump docker/metadata-action from 5 to 6 (#31327)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-12 07:14:14 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> a8633760bc chore(deps): bump docker/login-action from 3 to 4 (#31326)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-12 07:14:10 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> ff01c8718d chore(deps): bump docker/setup-qemu-action from 3 to 4 (#31324)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-12 07:13:55 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> cd6977bce6 chore(deps): bump docker/setup-buildx-action from 3 to 4 (#31323)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-12 07:13:52 +01:00
Koen Kanters 9143924482 fix(ignore): update zigbee-herdsman-converters to 26.17.0 (#31314) 2026-03-09 06:01:53 +00:00
NerivecandKoen Kanters 0f781ee9c0 fix: Match new cluster-related typing from ZH (#31298)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2026-03-09 06:55:11 +01:00
Andrei LAZAROVandNerivec 9f1eb764a6 fix: Export definition version (#31270)
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
2026-03-09 06:28:20 +01:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Nerivec
b7f1cecb49 fix(ignore): bump the minor-patch group with 3 updates (#31295)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
2026-03-07 12:24:25 +00:00
Koen Kanters 05946b5955 fix(ignore): update zigbee-herdsman-converters to 26.16.1 (#31268) 2026-03-07 06:14:12 +00:00
Nerivec 4a8369295c fix: Reintroduce onboarding improvements (#31273) 2026-03-07 06:21:11 +01:00
Koen Kanters 1747618afa fix(ignore): update zigbee-herdsman to 9.0.11 (#31257) 2026-03-03 19:19:31 +00:00
Koen Kanters 284d86e93b fix(ignore): update zigbee-herdsman-converters to 26.14.0 (#31246) 2026-03-03 06:06:45 +00:00
Koen Kanters d6323825e4 fix(ignore): update zigbee-herdsman to 9.0.10 (#31245) 2026-03-03 05:52:27 +00:00
DanielDeni b7b43030eb fix: Home Assistant: fix entity names for derived weather sensors by removing device_class (#31234) 2026-03-03 06:03:42 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> aefbb8be14 fix(ignore): bump @biomejs/biome from 2.4.4 to 2.4.5 in the minor-patch group (#31237)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-03 06:02:12 +01:00
Koen Kanters fc3a1968f6 fix(ignore): update zigbee-herdsman-converters to 26.13.0 (#31231) 2026-03-02 11:45:35 +00:00
github-actions[bot] c56fbca457 chore: promote to dev 2026-03-02 11:26:18 +00:00
Koen Kanters 85875aee27 chore(dev): release 2.9.1 (#31217) 2026-03-02 12:12:38 +01:00
Koen Kanters 7f0a9aa2a3 fix: Fix onboarding (#31228) 2026-03-02 12:10:18 +01:00
github-actions[bot] ff528567e6 chore: promote to dev 2026-03-01 14:08:34 +00:00
Koen Kanters 3473aa3f40 chore(dev): release 2.9.0 (#30896) 2026-03-01 14:57:03 +01:00
Koen Kanters 8ee5b1f03b fix(ignore): update zigbee-herdsman to 9.0.9 (#31215) 2026-03-01 13:46:14 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 89192f9dec fix(ignore): bump the minor-patch group with 2 updates (#31211)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-28 23:10:27 +00:00
Koen Kanters a9a6ebe007 fix(ignore): update zigbee-herdsman-converters to 26.12.0 (#31208) 2026-02-28 20:03:57 +00:00
Koen Kanters c7d54ec360 fix(ignore): update zigbee-herdsman to 9.0.8 (#31207) 2026-02-28 19:42:46 +00:00
Koen Kanters c880d36f29 fix(ignore): update zigbee-herdsman-converters to 26.11.0 (#31185) 2026-02-25 20:56:05 +00:00
Koen Kanters e1e856d7da fix(ignore): Fix enableDisableExtension (#31176) 2026-02-24 20:46:30 +01:00
Koen Kanters b9d9e5e75c fix(ignore): update zigbee-herdsman-converters to 26.10.1 (#31175) 2026-02-24 19:31:23 +00:00
Nerivec f8385b8899 fix: Improve startup signals behavior (#31130) 2026-02-24 20:23:52 +01:00
Koen Kanters a77f48f5c4 fix: Fix Home Assistant options cannot be updated while running (#31151) 2026-02-24 20:23:35 +01:00
Koen Kanters 79bcf276f5 fix(ignore): update zigbee-herdsman to 9.0.7 (#31174) 2026-02-24 19:06:50 +00:00
Nerivec 93dc9a3664 fix: update zigbee2mqtt-windfront to 2.9.0 (#31162) 2026-02-23 21:02:59 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 1d5184f2bd fix(ignore): bump @biomejs/biome from 2.4.1 to 2.4.4 in the minor-patch group (#31159)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-23 21:29:40 +01:00
Nerivec 8ecf353dd6 feat: Improve onboarding (#31152) 2026-02-23 21:29:31 +01:00
Koen Kanters 8560c11e06 fix(ignore): update zigbee-herdsman to 9.0.6 (#31149) 2026-02-22 20:29:08 +00:00
Koen Kanters 4d287eed0c fix(ignore): update zigbee-herdsman-converters to 26.9.0 (#31137) 2026-02-21 20:20:19 +00:00
DanielDeni 8d1240eba1 fix: Home Assistant: add weather station sensor discovery lookup entries (#31136) 2026-02-21 21:07:11 +01:00
NerivecandCopilot 0c43c98306 fix: Improve configure attempts lazy+cleanup (#31126)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-21 21:06:32 +01:00
Koen Kanters 396eb38e4b fix(ignore): update zigbee-herdsman-converters to 26.8.0 (#31118) 2026-02-19 19:43:14 +00:00
Koen Kanters 5957085fcc fix(ignore): update zigbee-herdsman to 9.0.5 (#31117) 2026-02-19 19:36:47 +00:00
Koen Kanters 02a2fd8fef fix: Home Assistant: add state_class: measurement for external_humidity and occupancy_level https://github.com/Koenkk/zigbee2mqtt/issues/31022 2026-02-19 20:29:43 +01:00
Koen Kanters 9d566f4c01 fix(ignore): update zigbee-herdsman-converters to 26.7.0 (#31090) 2026-02-17 20:15:20 +00:00
dependabot[bot] 42d88fe90d fix(ignore): bump the minor-patch group with 4 updates (#31081) 2026-02-16 19:54:17 +01:00
Koen Kanters 909d2b32b7 fix(ignore): update zigbee-herdsman-converters to 26.6.0 (#31076) 2026-02-15 19:34:36 +00:00
Koen Kanters 061262ff97 fix(ignore): update zigbee-herdsman-converters to 26.5.0 (#31058) 2026-02-13 20:49:19 +00:00
Koen Kanters 142754b71c fix(ignore): update zigbee-herdsman-converters to 26.4.0 (#31036) 2026-02-11 20:42:49 +00:00
Koen Kanters c599e19bf2 fix(ignore): update zigbee-herdsman-converters to 26.3.0 (#31028) 2026-02-10 19:47:25 +00:00
Koen Kanters 28a9f39dbc fix: Home Assistant: set state_class measurement for external_temperature https://github.com/Koenkk/zigbee2mqtt/issues/31022 2026-02-10 20:30:58 +01:00
Koen Kanters 4e360d0bf2 fix(ignore): update zigbee-herdsman-converters to 26.2.1 (#31015) 2026-02-09 21:06:38 +00:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Koen Kanters
15e385bac6 fix(ignore): bump the minor-patch group with 3 updates (#31011)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2026-02-09 20:57:32 +01:00
SnazzyBean c7dae39799 fix: Home Assistant: expose siren entity for IAS warning devices (#31000) 2026-02-09 20:42:03 +01:00
Koen Kanters b585e31304 fix(ignore): update zigbee-herdsman-converters to 26.2.0 (#30998) 2026-02-08 20:38:11 +00:00
Koen Kanters bf888de478 fix(ignore): update zigbee-herdsman to 9.0.4 (#30996) 2026-02-08 20:08:09 +00:00
Koen Kanters f1156db227 fix(ignore): update zigbee-herdsman-converters to 26.1.0 (#30981) 2026-02-06 21:16:11 +00:00
DataGhost d475aa5be5 fix: Home Assistant: add measurement state_class to _count entities (#30978) 2026-02-06 21:47:26 +01:00
+2
Koen Kantersdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>burmistrzakrhysfredrhysPatrick ZAJDAIgnacio Hernandez-RosRohan KapoorNerivecStephan GarlandAndrei LAZAROV
b71461e03c fix(ignore): Changes for new ZHC definition.version (#30402)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: burmistrzak <61958704+burmistrzak@users.noreply.github.com>
Co-authored-by: rhysfred <rhysfred@users.noreply.github.com>
Co-authored-by: rhys <rhys@frontleftspeaker.com>
Co-authored-by: Patrick ZAJDA <patrick@zajda.fr>
Co-authored-by: Ignacio Hernandez-Ros <ignacio@hernandez-ros.com>
Co-authored-by: Rohan Kapoor <rohan@rohankapoor.com>
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
Co-authored-by: Stephan Garland <stephan.garland@affirm.com>
Co-authored-by: Andrei LAZAROV <andrei_lazarov@yahoo.com>
2026-02-05 21:29:58 +01:00
Koenkk 6cce05a514 fix(ignore): update zigbee-herdsman-converters to 26.0.0 2026-02-05 21:29:58 +01:00
Koen Kanters 30d311e849 fix(ignore): update zigbee-herdsman to 9.0.3 (#30960) 2026-02-05 20:15:03 +00:00
Koen Kanters 402ed6094e fix(ignore): update zigbee-herdsman-converters to 25.122.0 (#30935) 2026-02-03 19:31:34 +00:00
Andrei LAZAROV e381d54f7e chore: readme: update integration logos and add links (#30926) 2026-02-03 20:05:11 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 4b039103c0 fix(ignore): bump mqtt from 5.14.1 to 5.15.0 in the minor-patch group (#30918)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-02 20:43:51 +01:00
Koen Kanters 8d8d37e388 fix: Home Assistant: discover temperature_probe as temperature device_class https://github.com/Koenkk/zigbee2mqtt/issues/30862 2026-02-02 20:12:34 +01:00
Koen Kanters 09d010ea06 fix(ignore): update zigbee-herdsman-converters to 25.121.0 (#30898) 2026-02-01 20:05:04 +00:00
github-actions[bot] ede1dc5951 chore: promote to dev 2026-02-01 19:37:17 +00:00
Koen Kanters b0b02c30b2 chore(dev): release 2.8.0 (#30420) 2026-02-01 20:19:58 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 9ddbb2a21a fix(ignore): bump zigbee2mqtt-windfront from 2.8.0 to 2.8.1 in the minor-patch group (#30879)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-01 20:17:41 +01:00
Koen Kanters 49b752da35 fix(ignore): update zigbee-herdsman-converters to 25.120.0 (#30876) 2026-01-31 13:35:35 +00:00
Koen Kanters 788b0d3993 fix(ignore): update zigbee-herdsman-converters to 25.119.0 (#30874) 2026-01-31 12:48:45 +00:00
Koen Kanters f68834049c fix(ignore): update zigbee-herdsman-converters to 25.118.0 (#30865) 2026-01-30 20:26:15 +00:00
Koen Kanters 4afbce62eb fix(ignore): update zigbee-herdsman-converters to 25.117.0 (#30849) 2026-01-29 19:07:37 +00:00
burmistrzak 4f412cb4da fix: Home Assistant: Change device_class of eco2 to volatile_organic_compounds_parts (#30840) 2026-01-29 19:48:41 +01:00
Koen Kanters 5a4d6b56ad fix(ignore): update zigbee-herdsman-converters to 25.116.0 (#30839) 2026-01-28 20:59:48 +00:00
burmistrzak 26a0fd4de0 fix: Home Assistant: discover temperature sensor for thermostats (#30804) 2026-01-28 21:47:03 +01:00
dependabot[bot] 8d980ae6ec fix(ignore): bump zigbee2mqtt-windfront in the minor-patch group
Bumps the minor-patch group with 1 update: [zigbee2mqtt-windfront](https://github.com/Nerivec/zigbee2mqtt-windfront).


Updates `zigbee2mqtt-windfront` from 2.7.0 to 2.8.0
- [Release notes](https://github.com/Nerivec/zigbee2mqtt-windfront/releases)
- [Commits](https://github.com/Nerivec/zigbee2mqtt-windfront/compare/v2.7.0...v2.8.0)

---
updated-dependencies:
- dependency-name: zigbee2mqtt-windfront
  dependency-version: 2.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-27 20:46:31 +01:00
Koen Kanters 99e06a8d3e fix(ignore): update zigbee-herdsman-converters to 25.115.0 (#30826) 2026-01-27 19:46:20 +00:00
NerivecandKoen Kanters 476efaa6e1 fix: OTA availability detection (#30815)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2026-01-27 20:37:18 +01:00
Koenkk 10478e797e fix(ignore): update zigbee-herdsman to 9.0.2 2026-01-27 20:37:18 +01:00
Stephan GarlandandKoen Kanters 5de47a0d93 fix: Network map: escape double-quotes and backslashes in device attributes (#30746)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2026-01-27 19:59:53 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 14bffd58b1 fix(ignore): bump @biomejs/biome from 2.3.12 to 2.3.13 in the minor-patch group (#30810)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-26 21:49:13 +01:00
Koen Kanters 999df2913f fix(ignore): update zigbee-herdsman-converters to 25.114.0 (#30803) 2026-01-25 20:55:24 +00:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Nerivec
da1f77ec6e fix(ignore): bump the minor-patch group with 2 updates (#30790)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
2026-01-24 20:59:45 +00:00
Koen Kanters 036ee471dd fix(ignore): update zigbee-herdsman-converters to 25.113.0 (#30789) 2026-01-24 20:31:04 +00:00
NerivecandKoen Kanters dd1c449796 feat: Improve OTA (#30566)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2026-01-24 21:27:44 +01:00
Koen Kanters 6d5e7e334a fix(ignore): update zigbee-herdsman to 9.0.1 (#30788) 2026-01-24 20:21:57 +00:00
burmistrzak 8e682956e5 fix: Home Assistant: Use temperature_delta for calibration (#30784) 2026-01-24 21:03:39 +01:00
Koen Kanters 971ddaa3cb fix(ignore): update zigbee-herdsman to 9.0.0 (#30787) 2026-01-24 19:45:15 +00:00
Koen Kanters ad51d12a2d fix(ignore): update zigbee-herdsman-converters to 25.112.0 (#30767) 2026-01-23 19:49:13 +00:00
Rohan Kapoor d9e4244f80 fix(ignore): Replace multiple area occupancy fields with camelCase instead of PascalCase (#30745) 2026-01-23 20:38:15 +01:00
Koen Kanters 2bb44f7a8b fix(ignore): update zigbee-herdsman-converters to 25.111.0 (#30739) 2026-01-21 20:53:41 +00:00
Koen Kanters 20af50201e fix(ignore): update zigbee-herdsman-converters to 25.110.0 (#30726) 2026-01-20 19:45:17 +00:00
Rohan Kapoor 2e36084852 fix: Home Assistant: treat Area1-4Occupancy as occupancy (#30712) 2026-01-20 19:34:39 +00:00
Koen Kanters 9d266bda9a fix(ignore): update zigbee-herdsman to 8.1.0 (#30725) 2026-01-20 19:29:58 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 043dd0ff1c fix(ignore): bump @types/node from 24.10.7 to 24.10.9 in the minor-patch group (#30713)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-20 20:09:54 +01:00
Koen Kanters ee6afaf1c6 fix(ignore): update zigbee-herdsman-converters to 25.109.0 (#30709) 2026-01-18 20:48:31 +00:00
Ignacio Hernandez-Ros 4815d5149d fix: Home Assistant: add gas device class (#30653) 2026-01-18 20:29:39 +01:00
Patrick ZAJDAandKoen Kanters 6f0b02e009 fix: Home Assistant: expose group settings override (#30627)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2026-01-17 15:25:36 +01:00
Koen Kanters 5e1b7d4830 fix(ignore): update zigbee-herdsman-converters to 25.108.0 (#30672) 2026-01-16 20:04:18 +00:00
Koen Kanters 535f75e90c fix(ignore): update zigbee-herdsman-converters to 25.107.0 (#30662) 2026-01-15 21:08:45 +00:00
Koen Kanters d44cdbde4f fix(ignore): update zigbee-herdsman to 8.0.3 (#30632) 2026-01-13 20:19:13 +00:00
Koen Kanters 9a41ec2a39 fix(ignore): update zigbee-herdsman-converters to 25.106.0 (#30631) 2026-01-13 20:03:22 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 1d87d2f234 fix(ignore): bump the minor-patch group with 2 updates (#30617)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-12 20:53:11 +01:00
Koen Kanters 29c757145d fix(ignore): update zigbee-herdsman-converters to 25.105.0 (#30605) 2026-01-11 20:47:06 +00:00
Koen Kanters e68403d8d3 fix(ignore): update zigbee-herdsman-converters to 25.104.0 (#30564) 2026-01-08 20:09:11 +00:00
Koen Kanters a32c8eec36 fix: Home Assistant: mark load_estimate as state_class measurement https://github.com/Koenkk/zigbee-herdsman-converters/issues/11240 2026-01-08 20:54:25 +01:00
Koen Kanters d4522c53ca fix(ignore): update zigbee-herdsman-converters to 25.103.0 (#30552) 2026-01-07 19:38:42 +00:00
Koen Kanters 57245e5234 fix(ignore): update zigbee-herdsman-converters to 25.102.0 (#30537) 2026-01-06 20:44:54 +00:00
Koen Kanters 4313baca09 fix(ignore): update zigbee-herdsman-converters to 25.101.0 (#30520) 2026-01-05 20:56:26 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> ed52c49e34 fix(ignore): bump @biomejs/biome from 2.3.10 to 2.3.11 in the minor-patch group (#30511)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-05 21:29:35 +01:00
Koen Kanters a2b81ab894 fix(ignore): update zigbee-herdsman-converters to 25.100.0 (#30499) 2026-01-04 20:27:22 +00:00
Koen Kanters 3e60f915ff fix: Home Assisant: set state class to measurement for illuminane_raw https://github.com/Koenkk/zigbee2mqtt/issues/30439 2026-01-04 20:02:44 +01:00
rhysfredandrhys 1eaa2f3e75 fix: Log error before renaming failed to load converters (#30436)
Co-authored-by: rhys <rhys@frontleftspeaker.com>
2026-01-04 19:51:46 +01:00
Koen Kantersandburmistrzak 3adf7d5d8e fix(ignore): update zigbee-herdsman-converters to 25.99.0 (#30481)
Co-authored-by: burmistrzak <61958704+burmistrzak@users.noreply.github.com>
2026-01-04 18:51:31 +00:00
Koen Kanters 1147cf16dd fix(ignore): update zigbee-herdsman to 8.0.2 (#30480) 2026-01-03 20:59:33 +00:00
github-actions[bot] 97a9b15240 chore: promote to dev 2026-01-01 13:53:53 +00:00
Koen Kanters 3a49c95786 chore(dev): release 2.7.2 (#30051) 2026-01-01 14:41:19 +01:00
Koen Kanters 0eba0c953d fix(ignore): update zigbee-herdsman-converters to 25.98.0 (#30417) 2026-01-01 13:23:35 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 4d6269e7e8 fix(ignore): bump zigbee2mqtt-windfront from 2.6.1 to 2.6.2 in the minor-patch group (#30412)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-31 19:56:27 +00:00
Koen Kanters 3727f078ec fix(ignore): update zigbee-herdsman-converters to 25.97.0 (#30396) 2025-12-30 14:29:46 +00:00
dependabot[bot] 93c9704fcd fix(ignore): bump @types/node from 24.10.2 to 24.10.4 in the minor-patch group (#30390) 2025-12-30 13:43:39 +01:00
Koen Kanters 2721b0ea78 fix(ignore): update zigbee-herdsman-converters to 25.96.0 (#30383) 2025-12-28 20:58:41 +00:00
Koen Kanters 4c48e9cfbc fix(ignore): update zigbee-herdsman-converters to 25.95.0 (#30371) 2025-12-27 20:42:04 +00:00
Koen Kanters 7cb5c58dc0 fix(ignore): update zigbee-herdsman-converters to 25.94.0 (#30355) 2025-12-26 14:37:38 +00:00
Koen Kanters c61f67f061 fix(ignore): update zigbee-herdsman-converters to 25.93.0 (#30331) 2025-12-23 20:58:54 +00:00
Nerivec 58d98c7263 fix: log dir tz format not working on some systems (#30324) 2025-12-23 17:45:04 +01:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Nerivec
b10fa93700 fix(ignore): bump the minor-patch group with 2 updates (#30309)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
2025-12-22 15:23:39 +00:00
Koen Kanters c28a024664 fix(ignore): update zigbee-herdsman-converters to 25.92.0 (#30300) 2025-12-21 21:28:27 +00:00
Nerivec 8549b37228 fix: Proper timezone in logs dir (#30297) 2025-12-21 20:44:05 +01:00
Koen Kanters 4c02e3da8f fix(ignore): update zigbee-herdsman-converters to 25.91.0 (#30279) 2025-12-19 21:02:06 +00:00
Koen Kanters ab0894715a fix(ignore): update zigbee-herdsman-converters to 25.90.0 (#30267) 2025-12-18 21:36:30 +00:00
Koen Kanters 1688a3b532 fix(ignore): update zigbee-herdsman to 8.0.1 (#30240) 2025-12-16 16:51:45 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 97694d2241 chore(deps): bump actions/cache from 4 to 5 (#30221)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-16 17:45:15 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> d6088c56db chore(deps): bump peter-evans/create-pull-request from 7 to 8 (#30222)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-16 17:45:06 +01:00
Koen Kanters b39b8d478e fix: Don't fail to start when removing old log directory fails (#30216) 2025-12-16 17:44:38 +01:00
Koen Kanters 01304cfc66 fix(ignore): update zigbee-herdsman-converters to 25.89.0 (#30215) 2025-12-14 20:40:29 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 1d15c2142f fix(ignore): bump the minor-patch group with 2 updates (#30207)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-14 16:40:27 +00:00
Koen Kanters 3818c50b8a fix(ignore): update zigbee-herdsman-converters to 25.88.0 (#30196) 2025-12-13 20:34:30 +00:00
Koen KantersandNerivec 7f5f6a5849 fix(ignore): update zigbee-herdsman to 8.0.0 and zigbee-herdsman-converters to 25.87.0 (#30186)
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
2025-12-13 20:40:09 +01:00
Nerivec 0025ef87c5 fix: Biome floating promises detection (#30137) 2025-12-13 20:38:03 +01:00
Koen Kanters 3460f157df fix(ignore): update zigbee-herdsman-converters to 25.86.0 (#30143) 2025-12-10 21:14:41 +00:00
Koen Kanters ecb0af4057 fix(ignore): update zigbee-herdsman to 7.0.6 (#30142) 2025-12-10 21:11:13 +00:00
Koen Kanters 5db6ae0a19 fix(ignore): update zigbee-herdsman-converters to 25.85.0 (#30120) 2025-12-09 20:15:54 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> ad3c09036b fix(ignore): bump the minor-patch group across 1 directory with 4 updates (#30117)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-09 20:33:03 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 0c89629526 chore(deps): bump alpine from 3.22 to 3.23 in /docker (#30094)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-09 20:04:22 +01:00
Koen Kanters 4574ffc71a fix(ignore): update zigbee-herdsman to 7.0.5 (#30116) 2025-12-09 18:46:55 +00:00
Koen Kanters 1707f3abf3 chore: merge master to dev 2025-12-07 21:26:17 +01:00
Koen Kanters 8916fb7b50 chore: lint issueBot 2025-12-07 21:25:58 +01:00
Koen Kanters 6e70ef24d0 chore: merge master to dev 2025-12-07 21:22:54 +01:00
Koen Kanters 8a3c4bd59f chore: issueBot: handle duplicates 2025-12-07 21:22:34 +01:00
Koen Kanters c18d1329c8 fix(ignore): update zigbee-herdsman-converters to 25.84.0 (#30081) 2025-12-07 19:56:11 +00:00
Yuxin Wang ed1e7b333f fix: Remove extra > in onboarding(#30065) 2025-12-07 20:22:04 +01:00
github-actions[bot] 95c38810a8 chore: promote to dev 2025-12-06 20:39:43 +00:00
Koen Kanters b70701f8b8 chore: fix changelog generator undefined whitelabel vendor/description 2025-12-06 21:23:09 +01:00
Koen Kanters 6d30fa156c chore(dev): release 2.7.1 (#29926) 2025-12-06 21:14:47 +01:00
Koen Kanters 9b6035387b fix(ignore): update zigbee-herdsman-converters to 25.83.1 (#30050) 2025-12-06 20:06:52 +00:00
burmistrzakandKoen Kanters 9ca85dfac8 fix: Home Assistant: Translate boolean to state topic for current_humidity_topic (#30014)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2025-12-06 20:46:27 +01:00
Koen Kanters 334fa2a60d fix(ignore): update zigbee-herdsman to 7.0.4 (#30041) 2025-12-06 13:22:31 +00:00
Koen Kanters a7f1345a91 fix(ignore): update zigbee-herdsman-converters to 25.83.0 (#30011) 2025-12-04 20:54:41 +00:00
Koen Kanters bae9bb8812 fix(ignore): update zigbee-herdsman to 7.0.3 (#30008) 2025-12-04 20:02:17 +00:00
Koen Kanters 740edd8b58 fix(ignore): update zigbee-herdsman-converters to 25.82.0 (#29990) 2025-12-03 19:29:19 +00:00
Koen Kanters 267fee3414 Revert "chore: retry 'Docker build and push' on failure"
This reverts commit 91f4b76970.
2025-12-03 20:10:50 +01:00
burmistrzak fa99c6dcca fix: Home Assistant: Accept multiple access levels for current_humidity source property (#29936) 2025-12-03 19:34:35 +01:00
Koen Kanters 91f4b76970 chore: retry 'Docker build and push' on failure 2025-12-02 21:57:12 +01:00
Koen Kanters fd4282b98d fix(ignore): update zigbee-herdsman-converters to 25.81.0 (#29959) 2025-12-02 20:48:36 +00:00
Koen Kanters 403bd4a725 fix(ignore): update zigbee-herdsman to 7.0.2 (#29956) 2025-12-02 20:16:21 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> ce27ff3cbf fix(ignore): bump finalhandler from 2.1.0 to 2.1.1 in the minor-patch group (#29920)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-02 21:00:30 +01:00
github-actions[bot] 5f474e03b8 chore: promote to dev 2025-12-01 20:35:56 +00:00
Koen Kanters 110149fa20 chore(dev): release 2.7.0 (#29396) 2025-12-01 21:22:06 +01:00
Koen Kanters 445890a071 fix(ignore): update zigbee-herdsman-converters to 25.80.0 (#29924) 2025-12-01 20:17:02 +00:00
Nerivec 3dce8548ce fix(ignore): bump zigbee2mqtt-windfront to 2.4.2 (#29919) 2025-12-01 16:02:29 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 4a020fd8de fix(ignore): bump zigbee2mqtt-windfront from 2.4.0 to 2.4.1 in the minor-patch group (#29905)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-30 15:36:04 +00:00
Koen Kanters d411fc5c69 fix(ignore): update zigbee-herdsman-converters to 25.79.0 (#29903) 2025-11-30 14:06:44 +00:00
burmistrzakandKoen Kanters 631c4f6a57 fix: Home Assistant: expose current_humidity for climate devices exposing humidity (#29842)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2025-11-29 13:32:28 +01:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Nerivec
c02ed09211 fix(ignore): bump the minor-patch group with 2 updates (#29859)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
2025-11-27 20:12:22 +00:00
Koen Kanters 49b4876768 fix(ignore): update zigbee-herdsman-converters to 25.78.0 (#29856) 2025-11-27 19:16:34 +00:00
Koen Kanters 802271ed37 fix(ignore): update zigbee-herdsman to 7.0.1 (#29855) 2025-11-27 19:00:08 +00:00
Nerivec 42fc6e03a7 fix: Reporting payload detection fixes (#29854) 2025-11-27 19:46:31 +01:00
Koen Kanters 35d8a5da7f fix(ignore): update zigbee-herdsman-converters to 25.77.0 (#29838) 2025-11-26 20:00:02 +00:00
Koen Kanters 72d862f4a0 fix(ignore): update zigbee-herdsman-converters to 25.76.0 (#29823) 2025-11-25 19:57:30 +00:00
Nerivec ad0696856a fix(ignore): prop name convention on new API endpoints (#29820) 2025-11-25 20:18:14 +01:00
NerivecandKoen Kanters a61646d584 feat: Add action bridge/request API (#29730)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2025-11-24 21:57:48 +01:00
Koen Kanters 854df498bd fix(ignore): update zigbee-herdsman-converters to 25.75.0 (#29811) 2025-11-24 20:54:10 +00:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Koen Kanters
e2232e9e91 fix(ignore): bump the minor-patch group with 3 updates (#29804)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2025-11-24 21:33:29 +01:00
dependabot[bot] 6775c4f17b chore(deps): bump actions/checkout from 5 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-24 21:31:41 +01:00
Nerivec 17520f7cfb fix(ignore): update zigbee2mqtt-windfront to 2.3.1 (#29788) 2025-11-23 22:27:04 +00:00
Koen Kanters 1707bcab88 chore: merge master to dev 2025-11-23 22:05:07 +01:00
Koen Kanters c577c8e5f0 chore: lint issueBot 2025-11-23 22:04:48 +01:00
Koen Kanters 7b8efbe2c4 chore: merge master to dev 2025-11-23 21:59:31 +01:00
Koen Kanters 459b695f60 chore: issueBot: add logging and search in title 2025-11-23 21:59:13 +01:00
NerivecandKoen Kanters f26ade4f93 feat: Add new bind/reporting/map features (#29750)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2025-11-23 21:50:11 +01:00
Koenkk 3e2aefbcf8 fix(ignore): update zigbee-herdsman to 7.0.0 2025-11-23 21:50:11 +01:00
Koen Kanters ba77ce7c8d fix(ignore): update zigbee-herdsman-converters to 25.74.0 (#29770) 2025-11-22 20:47:44 +00:00
Alex Freidel 4c21b66079 fix: Add port 8080 EXPOSE to container (#29754) 2025-11-22 13:24:24 +01:00
Koen Kanters be774a4d0d fix(ignore): update zigbee-herdsman-converters to 25.73.0 (#29752) 2025-11-21 19:48:11 +00:00
Koen Kanters 7b7b988960 fix(ignore): update zigbee-herdsman-converters to 25.72.0 (#29735) 2025-11-20 21:00:08 +00:00
Koen Kanters d6bf5174be fix(ignore): update zigbee-herdsman-converters to 25.71.0 (#29716) 2025-11-19 20:57:49 +00:00
Koen Kanters 73225e2436 fix(ignore): update zigbee-herdsman-converters to 25.70.0 (#29684) 2025-11-17 21:08:22 +00:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Koen Kanters
afedab9680 chore(deps): bump js-yaml from 4.1.0 to 4.1.1 (#29675)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2025-11-17 21:58:42 +01:00
Koen Kanters 53b7eaabf0 fix(ignore): update zigbee-herdsman to 6.4.2 (#29683) 2025-11-17 20:49:33 +00:00
Koen Kantersandburmistrzak 16d0e56968 fix(ignore): update zigbee-herdsman-converters to 25.69.0 (#29659)
Co-authored-by: burmistrzak <61958704+burmistrzak@users.noreply.github.com>
2025-11-17 20:11:05 +00:00
dependabot[bot] aea41b5c82 chore(deps): bump js-yaml from 4.1.0 to 4.1.1 (#29649) 2025-11-16 15:28:44 +01:00
Koen Kanters 564e9ab9eb fix(ignore): update zigbee-herdsman-converters to 25.68.0 (#29606) 2025-11-12 19:51:37 +00:00
Koen Kanters 61c6ec6111 fix(ignore): update zigbee-herdsman to 6.4.1 (#29595) 2025-11-11 21:09:25 +00:00
Koen Kanters 4794b3c81f fix(ignore): update zigbee-herdsman-converters to 25.67.0 (#29572) 2025-11-10 17:16:16 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 517b286583 fix(ignore): bump @biomejs/biome from 2.3.3 to 2.3.4 in the minor-patch group (#29571)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-10 18:09:42 +01:00
Louis Sautier aee173d10a chore: update build command in README (#29562) 2025-11-10 18:03:18 +01:00
Koen Kanters 940fff244c fix(ignore): update zigbee-herdsman-converters to 25.66.0 (#29558) 2025-11-09 20:50:48 +00:00
Koen Kanters 8822aedb5e fix(ignore): update zigbee-herdsman-converters to 25.65.0 (#29523) 2025-11-07 20:42:22 +00:00
Koen Kanters e788c520a9 fix(ignore): update zigbee-herdsman to 6.4.0 (#29522) 2025-11-07 20:37:54 +00:00
Koen Kanters 39c5bc8c04 fix(ignore): update zigbee-herdsman to 6.3.3 (#29500) 2025-11-06 21:53:56 +00:00
Koen Kanters 5c4e43fd10 fix(ignore): update zigbee-herdsman-converters to 25.64.0 (#29499) 2025-11-06 19:23:23 +00:00
Koen Kanters 5a6a9cf4d2 fix(ignore): update zigbee-herdsman-converters to 25.63.0 (#29482) 2025-11-05 20:18:35 +00:00
Koen Kanters ad0e91e61e chore: merge master to dev 2025-11-04 21:22:30 +01:00
Koen Kanters 5797b56871 chore: refactor issue bot 2025-11-04 21:22:09 +01:00
Koen Kanters ea1c5a8f4e fix(ignore): update zigbee-herdsman-converters to 25.62.0 (#29449) 2025-11-04 20:08:45 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 8099322cc2 fix(ignore): bump debounce from 2.2.0 to 3.0.0 (#29444)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-03 22:18:45 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 619b939b14 fix(ignore): bump the minor-patch group with 2 updates (#29443)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-03 22:18:32 +01:00
Koen Kanters eeed56ef2c fix(ignore): update zigbee-herdsman-converters to 25.60.0 (#29426) 2025-11-02 20:13:40 +00:00
github-actions[bot] e9b4a6221b chore: promote to dev 2025-11-01 21:04:00 +00:00
Koen Kanters 6e001b48ec chore(dev): release 2.6.3 (#28788) 2025-11-01 21:46:15 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> dc7749b57e fix(ignore): bump the minor-patch group across 1 directory with 4 updates (#29395)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-01 21:40:59 +01:00
Koen Kanters 40bf14b93c fix(ignore): update zigbee-herdsman-converters to 25.59.0 (#29382) 2025-11-01 20:26:36 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> b2c31f89c6 fix(ignore): bump @types/serve-static from 1.15.10 to 2.2.0 (#29394)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-01 20:50:11 +01:00
Koen Kanters faa65f78a2 fix(ignore): update zigbee-herdsman-converters to 25.57.0 (#29370) 2025-10-30 19:48:34 +00:00
Joseph Petersen c30630091d fix: Fix input onboarding environment variable disabled check (#29354) 2025-10-30 20:23:21 +01:00
Koen Kanters 61d99b7ece fix(ignore): update zigbee-herdsman-converters to 25.56.0 (#29338) 2025-10-28 20:23:32 +00:00
Koen Kanters e037a2c21c fix: Fix onEvent called twice first time when device options are changed (#29322) 2025-10-28 21:19:55 +01:00
Koen Kanters 592c256f96 fix(ignore): update zigbee-herdsman-converters to 25.55.0 (#29323) 2025-10-27 20:50:16 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 82f1092302 fix(ignore): bump the minor-patch group with 5 updates (#29314)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-27 20:58:01 +01:00
Koen Kanters 39e43d8f40 chore: merge master to dev 2025-10-27 20:43:36 +01:00
Koen Kanters 1ea7f650a2 chore: fix external converter/new device support bot 2025-10-27 20:43:11 +01:00
Koen Kanters 003c81b745 fix(ignore): update zigbee-herdsman-converters to 25.54.0 (#29306) 2025-10-26 21:28:39 +00:00
Koen Kanters 6c48498e0d fix: Require at least Node ^22.2.0 (#29285) 2025-10-25 20:39:14 +02:00
Koen Kanters e96630669a chore: merge master to dev 2025-10-25 20:33:06 +02:00
Koen Kanters 961265ad5e chore: extend external converter/new device bot to detected already support zigbee model 2025-10-25 20:32:39 +02:00
Koen Kanters 8ac2f55d87 fix: Require at least Node ^20.15.0 (#29284) 2025-10-25 20:14:58 +02:00
Koen Kanters 469d40c4aa chore: merge master to dev 2025-10-25 20:05:16 +02:00
Koen Kanters 5b844ea64e chore: fix external definition and new device support issue template render 2025-10-25 20:04:52 +02:00
Koen Kanters d719c1f368 fix(ignore): update zigbee-herdsman-converters to 25.53.0 (#29266) 2025-10-24 21:10:42 +00:00
Koen Kanters 0993652420 chore: merge master to dev 2025-10-23 21:01:31 +02:00
Koen Kanters 846b06ccab chore: update external converter template 2025-10-23 21:01:05 +02:00
Koen Kanters 3330d2aaed chore: merge master to dev 2025-10-23 20:52:40 +02:00
Koen Kanters bc650ccf82 chore: update external converter/new device support bot https://github.com/Nerivec/zigbee2mqtt-windfront/pull/298 2025-10-23 20:52:15 +02:00
Koen Kanters 968df11b16 chore: merge master to dev 2025-10-23 20:41:32 +02:00
Koen Kanters c4c129987d chore: Update and add new issue templates https://github.com/Nerivec/zigbee2mqtt-windfront/pull/298 2025-10-23 20:41:01 +02:00
Koen Kanters 05382a9692 fix(ignore): update zigbee-herdsman-converters to 25.52.0 (#29247) 2025-10-23 18:06:36 +00:00
Koen Kanters ef15924260 fix(ignore): update zigbee-herdsman to 6.3.2 (#29246) 2025-10-23 17:57:41 +00:00
Koen Kanters c0190c18ed fix: Home Assistant: fix device and group configuration URL (#29211) 2025-10-22 19:54:54 +02:00
Koen Kanters 238ea7c4c8 fix(ignore): update zigbee-herdsman-converters to 25.51.1 (#29212) 2025-10-21 19:28:14 +00:00
David Dreschner 174ba64449 fix: Home Assistant: make PI heating demand from writable (#29188) 2025-10-21 20:30:17 +02:00
Koen Kanters 408052282d fix(ignore): update zigbee-herdsman-converters to 25.51.0 (#29195) 2025-10-20 19:06:56 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> d8fc69dac3 fix(ignore): bump @types/node from 24.7.2 to 24.8.1 in the minor-patch group (#29191)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-20 20:48:52 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 0da5fe6837 chore(deps): bump actions/setup-node from 5 to 6 (#29190)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-20 20:48:40 +02:00
Koen Kanters df70f11a15 fix(ignore): update zigbee-herdsman-converters to 25.50.0 (#29180) 2025-10-19 19:18:25 +00:00
Koen Kanters 3ecb0c5e41 fix(ignore): update zigbee-herdsman-converters to 25.49.0 (#29154) 2025-10-18 12:46:47 +00:00
Koen Kanters 52d250b72f chore: add OIDC permission for tokenless publising 2025-10-18 14:38:34 +02:00
Koen Kanters feac6aa677 fix(ignore): update zigbee-herdsman to 6.3.1 (#29153) 2025-10-18 12:30:56 +00:00
Koen Kanters 1d69715a5a fix(ignore): update zigbee-herdsman-converters to 25.48.0 (#29141) 2025-10-17 18:09:35 +00:00
Koen Kanters eaaed1fec7 fix: Rename ZigBee -> Zigbee (#29131) 2025-10-16 22:40:39 +02:00
Koen Kanters b81dcdbf40 chore: merge master to dev 2025-10-16 22:19:39 +02:00
Koen Kanters 129a80d1bb chore: update device support bot 2025-10-16 22:19:23 +02:00
Koen Kanters 03a81c6ab1 fix(ignore): update zigbee-herdsman-converters to 25.47.0 (#29130) 2025-10-16 19:00:35 +00:00
Koen Kanters 799f71b9b4 chore: merge master to dev 2025-10-16 20:47:32 +02:00
Koen Kanters 38d305eee6 chore: add new device support bot 2025-10-16 20:47:04 +02:00
Koen Kanters 354d06aebc fix(ignore): update zigbee-herdsman to 6.3.0 (#29113) 2025-10-15 19:49:31 +00:00
Koen KantersandDavid Dreschner ad9b114b1a fix(ignore): update zigbee-herdsman-converters to 25.46.0 (#29096)
Co-authored-by: David Dreschner <34408442+DerDreschner@users.noreply.github.com>
2025-10-15 19:20:31 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> aa916ad604 fix(ignore): bump the minor-patch group with 3 updates (#29068)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-14 22:01:32 +02:00
Koen Kanters bafc8e4284 fix(ignore): update zigbee-herdsman-converters to 25.44.0 (#29050) 2025-10-12 19:26:32 +00:00
Koen Kanters 9c19aa9494 fix(ignore): update zigbee-herdsman-converters to 25.43.0 (#29014) 2025-10-11 12:48:52 +00:00
Koen Kanters 85c894dc4f chore: Update AGENTS.md and copilot-instructions.md (#28966) 2025-10-08 22:11:10 +02:00
Koen Kanters 5f893c5657 chore: merge master to dev 2025-10-08 21:32:27 +02:00
Koen Kanters 8865429d79 chore: update wrong device template 2025-10-08 21:32:08 +02:00
Koen Kanters 1a77675043 chore: merge master to dev 2025-10-08 21:30:43 +02:00
Koen Kanters 8018422823 chore: update wrong device template 2025-10-08 21:30:22 +02:00
Koen Kanters 8b1f5a555c fix(ignore): update zigbee-herdsman-converters to 25.42.0 (#28963) 2025-10-08 19:07:11 +00:00
Koen Kanters 79a27e3b06 fix(ignore): update zigbee-herdsman-converters to 25.41.0 (#28945) 2025-10-07 20:30:50 +00:00
Koen Kanters 5f08c3e83b fix(ignore): update zigbee-herdsman-converters to 25.40.0 (#28928) 2025-10-06 18:48:38 +00:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Nerivec
e24ec79980 fix(ignore): bump the minor-patch group with 4 updates (#28916)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
2025-10-06 20:10:21 +02:00
Nerivec d18dbb360d chore: add copilot prompts (#28919) 2025-10-06 20:09:58 +02:00
Koen Kanters 74335fbb68 fix: Remove Moment.js dependency (#28797) 2025-10-06 20:06:41 +02:00
Koen Kanters 8b4a4be2a4 fix(ignore): update zigbee-herdsman to 6.2.0 (#28900) 2025-10-05 19:56:17 +00:00
Koen Kanters 548f646f2f fix(ignore): update zigbee-herdsman-converters to 25.39.0 (#28899) 2025-10-05 19:28:13 +00:00
Koen Kanters a75a40b042 fix(ignore): update zigbee-herdsman-converters to 25.38.0 (#28872) 2025-10-04 12:42:17 +00:00
Dirk van Donkelaar 09fd675d0f fix: Add pi cooling demand to Home Assistant auto discovery (#28843) 2025-10-04 14:07:26 +02:00
Koen Kanters 1d38da6ac5 fix(ignore): update zigbee-herdsman-converters to 25.37.2 (#28859) 2025-10-03 19:18:52 +00:00
Koen Kanters c66008b36b fix(ignore): update zigbee-herdsman-converters to 25.37.1 (#28857) 2025-10-03 19:00:53 +00:00
Dirk van Donkelaar ab82f0e9a8 chore: Fix test command in CONTRIBUTING.md (#28844) 2025-10-03 19:47:43 +02:00
Koen Kanters d1ce146542 fix(ignore): update zigbee-herdsman-converters to 25.37.0 (#28837) 2025-10-02 19:34:49 +00:00
Koen Kanters cf93c649aa chore: vitest remove globals 2025-10-01 20:30:11 +02:00
Koen Kanters ca489daad8 fix(ignore): update zigbee-herdsman-converters to 25.36.0 (#28790) 2025-10-01 18:26:29 +00:00
github-actions[bot] 16f3f4d649 chore: promote to dev 2025-10-01 18:00:16 +00:00
Koen Kanters 4d247f3ae1 chore(dev): release 2.6.2 (#28441) 2025-10-01 19:47:06 +02:00
dependabot[bot] c9d01f4118 fix(ignore): bump the minor-patch group with 4 updates (#28778) 2025-10-01 19:36:13 +02:00
Koen Kanters 1283859a12 fix(ignore): update zigbee-herdsman-converters to 25.35.1 (#28774) 2025-09-30 19:36:38 +00:00
Koen Kanters a0d91cc063 fix(ignore): update zigbee-herdsman to 6.1.5 (#28773) 2025-09-30 18:53:15 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 116634193a fix(ignore): bump zigbee2mqtt-windfront from 2.1.0 to 2.2.0 in the minor-patch group (#28764)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-29 21:44:25 +02:00
Koen Kanters f6c0ce735f fix(ignore): update zigbee-herdsman-converters to 25.34.0 (#28763) 2025-09-29 19:30:13 +00:00
Koen Kanters 508318de57 fix(ignore): update zigbee-herdsman to 6.1.4 (#28762) 2025-09-29 19:06:13 +00:00
Koen Kanters 26c74b5778 fix(ignore): update zigbee-herdsman-converters to 25.33.0 (#28758) 2025-09-28 19:41:34 +00:00
Koen Kanters 87c474d2f5 fix(ignore): update zigbee-herdsman-converters to 25.32.0 (#28746) 2025-09-26 19:02:13 +00:00
Koen Kantersandnurikk 2c70deb411 fix(ignore): update zigbee2mqtt-frontend to 0.9.21 (#28745)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2025-09-26 18:45:24 +00:00
Koen Kanters a310627e19 fix(ignore): update zigbee-herdsman-converters to 25.31.0 (#28726) 2025-09-24 19:07:57 +00:00
Koen Kanters 77926bc1c3 fix(ignore): update zigbee-herdsman-converters to 25.30.0 (#28707) 2025-09-23 19:53:50 +00:00
Koen Kanters 892f92448e fix(ignore): update zigbee-herdsman to 6.1.3 (#28717) 2025-09-23 19:44:54 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> e8847d1a34 fix(ignore): bump @types/node from 24.4.0 to 24.5.2 in the minor-patch group (#28700)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-22 21:53:43 +02:00
Koen Kanters 79c0c0933a fix(ignore): update zigbee-herdsman to 6.1.2 (#28689) 2025-09-21 13:25:20 +00:00
Jori HuismanandNerivec a5e1e8c0c3 fix: Handle empty or null Home Assistant name in device settings (#28364)
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
2025-09-21 14:54:18 +02:00
Koen Kanters 382d37e839 fix(ignore): update zigbee-herdsman-converters to 25.29.0 (#28654) 2025-09-18 18:47:04 +00:00
dependabot[bot] c3e1525f60 fix(ignore): bump the minor-patch group with 3 updates (#28638) 2025-09-15 22:29:20 +02:00
Koen Kanters 2cb7dcc12a fix(ignore): update zigbee-herdsman-converters to 25.26.0 (#28629) 2025-09-14 18:30:00 +00:00
Koen Kanters b2f74607cc fix(ignore): update zigbee-herdsman-converters to 25.25.0 (#28621) 2025-09-13 19:56:35 +00:00
Koen Kanters 2edf85f6e1 fix(ignore): update zigbee-herdsman-converters to 25.24.0 (#28608) 2025-09-12 19:12:00 +00:00
Koen Kanters 09f33b31a9 fix: Fix DatabaseEntry with ID 'X' does not exist when adding device to delete group ID (#28583) 2025-09-11 20:53:58 +02:00
Koen Kanters 3f258342d8 fix(ignore): update zigbee-herdsman-converters to 25.23.0 (#28598) 2025-09-11 18:41:41 +00:00
Koen Kanters c28ca79d8a fix(ignore): update zigbee-herdsman to 6.1.1 (#28597) 2025-09-11 18:23:33 +00:00
Koen Kanters 5f6fae0df7 fix(ignore): update zigbee-herdsman-converters to 25.22.0 (#28580) 2025-09-10 18:58:53 +00:00
Koen Kanters 15e00ea8a4 fix: HomeAssistant: add default_entity_id to discovery message (#28560) 2025-09-09 21:09:42 +02:00
Koen Kanters 01d5863487 fix(ignore): update zigbee-herdsman-converters to 25.21.0 (#28561) 2025-09-09 18:57:23 +00:00
Koen Kanters fefae9957c fix: Make windfront the default frontend (#28546) 2025-09-09 20:05:41 +02:00
Koen Kanters a297b9255b fix(ignore): update zigbee-herdsman-converters to 25.20.0 (#28545) 2025-09-08 19:03:46 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 86b458111b fix(ignore): bump the minor-patch group with 3 updates (#28541)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-08 20:43:58 +02:00
dependabot[bot] f022f55be6 chore(deps): bump actions/stale from 9 to 10 (#28539) 2025-09-08 20:20:46 +02:00
dependabot[bot] dc991be28f chore(deps): bump actions/github-script from 7 to 8 (#28540) 2025-09-08 20:20:30 +02:00
dependabot[bot] 62b62d224f chore(deps): bump actions/setup-node from 4 to 5 (#28538) 2025-09-08 20:08:57 +02:00
Koen Kanters 5f2630c93b fix(ignore): update zigbee-herdsman-converters to 25.19.0 (#28533) 2025-09-07 20:19:09 +00:00
Koen Kanters e8e4977327 fix(ignore): update zigbee-herdsman-converters to 25.18.0 (#28518) 2025-09-06 18:45:43 +00:00
Koen Kanters 54d729e37a fix(ignore): update zigbee-herdsman-converters to 25.17.1 (#28511) 2025-09-05 18:33:51 +00:00
Koen Kanters 3018e40dae fix(ignore): update zigbee-herdsman-converters to 25.17.0 (#28480) 2025-09-04 18:52:23 +00:00
Koen Kanters 1df1f5d388 chore: merge master to dev 2025-09-03 19:58:04 +02:00
Koen Kanters d44463a330 chore: fix copilot-instructions 2025-09-03 19:57:41 +02:00
Nerivec e52203a1b8 fix(ignore): update zigbee2mqtt-windfront to 2.1.0 (#28471) 2025-09-03 10:55:48 +00:00
Koen Kanters 073a261738 chore: merge master to dev 2025-09-02 21:00:50 +02:00
Koen Kanters 64ebb98ad1 chore: add copilot instructions 2025-09-02 21:00:30 +02:00
Koen Kanters 3f27f79d7d fix(ignore): update zigbee-herdsman-converters to 25.15.0 (#28462) 2025-09-02 18:57:50 +00:00
Koen Kanters 06862c7259 fix(ignore): update zigbee-herdsman to 6.1.0 (#28460) 2025-09-02 18:00:00 +00:00
github-actions[bot] 0482d8b4e9 chore: promote to dev 2025-09-01 19:14:09 +00:00
Koen Kanters e9889e5d20 chore(dev): release 2.6.1 (#28144) 2025-09-01 20:57:13 +02:00
Koen Kanters 77886530fe fix(ignore): update zigbee-herdsman to 6.0.4 (#28438) 2025-09-01 18:52:39 +00:00
Koen Kanters bf22ddac46 fix(ignore): update zigbee-herdsman-converters to 25.14.0 (#28439) 2025-09-01 18:18:38 +00:00
Koen Kanters 203019aa26 fix(ignore): update zigbee-herdsman-converters to 25.13.0 (#28427) 2025-08-31 13:14:19 +00:00
Nerivec 08903f68d9 fix(ignore): update zigbee2mqtt-windfront to 2.0.1 (#28420) 2025-08-30 18:57:33 +00:00
Koen Kanters bdeab61001 fix(ignore): update zigbee-herdsman-converters to 25.12.0 (#28417) 2025-08-30 12:03:46 +00:00
Koen Kanters c07fce936f fix(ignore): update zigbee-herdsman-converters to 25.11.0 (#28407) 2025-08-28 18:31:42 +00:00
Koen Kanters 264440ebcc fix(ignore): update zigbee-herdsman to 6.0.2 (#28406) 2025-08-28 18:19:11 +00:00
Koen Kanters 32d82651e2 fix(ignore): update zigbee-herdsman-converters to 25.10.0 (#28399) 2025-08-27 18:29:45 +00:00
NerivecandKoen Kanters 9079c16d16 chore: switch benchmarking tool (#28396)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2025-08-27 20:12:30 +02:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Nerivec
a8fd23b6cc fix(ignore): bump the minor-patch group with 2 updates (#28383)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
2025-08-27 19:58:35 +02:00
Nerivec 2df363f3af fix: update zigbee2mqtt-windfront to 2.0.0 (#28389) 2025-08-26 15:47:44 +00:00
Koen Kanters 0147441c93 fix(ignore): update zigbee-herdsman-converters to 25.9.0 (#28380) 2025-08-25 20:49:30 +02:00
Koen Kanters 4d52fdd290 chore: Update changelog generation to include zigbee2mqtt-windfront (#28345) 2025-08-25 20:42:08 +02:00
Koen Kanters 8e6ba7975b fix(ignore): update zigbee-herdsman-converters to 25.8.0 (#28373) 2025-08-24 19:23:32 +00:00
Koen Kanters 7bd7bce69c fix(ignore): update zigbee-herdsman-converters to 25.7.0 (#28361) 2025-08-22 14:36:25 +00:00
Koen Kanters 4bd0c04bdc fix(ignore): update zigbee-herdsman-converters to 25.6.0 (#28344) 2025-08-20 18:50:40 +00:00
Joseph Spiros ccfab84e7f fix: Home Assistant: use "occupancy" device_class for human presence sensors (#28329) 2025-08-20 20:14:23 +02:00
Koen Kanters 23f831a1fe fix(ignore): update zigbee-herdsman-converters to 25.5.0 (#28328) 2025-08-19 18:35:14 +00:00
Koen KantersandNerivec 384bd309a6 fix(ignore): update zigbee-herdsman-converters to 25.4.0 (#28313)
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
2025-08-18 21:33:42 +02:00
Koen KantersandNerivec 5f7c00515a fix(ignore): update zigbee-herdsman-converters to 25.3.0 (#28301)
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
2025-08-18 21:33:42 +02:00
Koen Kanters bed8beccbc fix(ignore): update zigbee-herdsman to 6.0.1 (#28287) 2025-08-18 21:33:42 +02:00
Nerivec b21f926b3e fix: Support for new ZH typing (#28257) 2025-08-18 21:33:42 +02:00
Nerivec 6e7cc30da7 chore: skip unnecessary deps in dev-types branch (#28312) 2025-08-18 21:22:02 +02:00
Koen Kanters 4c9784255b fix(ignore): update zigbee-herdsman-converters to 25.2.0 (#28280) 2025-08-15 10:44:18 +00:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Nerivec
1dd6cc3da5 fix(ignore): bump the minor-patch group with 5 updates (#28237)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
2025-08-14 20:52:49 +02:00
Nerivec d04ab53f1d fix(ignore): update zigbee2mqtt-windfront to 1.8.1 (#28272) 2025-08-14 10:52:50 +00:00
Nerivec f4cfcbacde fix(ignore): update zigbee2mqtt-windfront to 1.8.0 (#28266) 2025-08-13 22:52:18 +00:00
Koen Kanters 2b1780879a fix(ignore): update zigbee-herdsman-converters to 25.1.0 (#28264) 2025-08-13 19:39:33 +00:00
Koen Kantersandnurikk 800b85c812 fix(ignore): update zigbee2mqtt-frontend to 0.9.20 (#28250)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2025-08-12 18:09:26 +00:00
Alexis "Horgix" Chotard c5fa13215c chore(github): fix link to "fail to start" FAQ in problem report template (#28244) 2025-08-12 19:42:19 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 6414ade05d chore(deps): bump actions/checkout from 4 to 5 (#28238)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-12 19:36:31 +02:00
Koen Kanters 5286570bd6 fix: Update to refactored zhc api and fix onEvent(stop) not being called when device is force removed (#27984) 2025-08-11 20:32:59 +02:00
Koen Kanters 3160219824 fix(ignore): update zigbee-herdsman-converters to 25.0.0 (#28236) 2025-08-11 20:26:49 +02:00
Koen Kanters bd1be88602 fix(ignore): update zigbee-herdsman to 6.0.0 (#28235) 2025-08-11 20:26:07 +02:00
Nerivec a102fac2c8 fix: Set minimum for some settings (#28209) 2025-08-09 14:40:27 +02:00
Nerivec 63a4207061 fix: Always unlink node_modules symlinks on stop (#28202) 2025-08-08 19:38:04 +02:00
Nerivec 092c95b716 fix(ignore): update zigbee2mqtt-windfront to 1.6.0 (#28207) 2025-08-08 10:55:58 +00:00
Koen Kanters 09f046c72d fix(ignore): update zigbee-herdsman-converters to 24.14.0 (#28199) 2025-08-07 18:28:28 +00:00
Kamil Breguła b7cbd9b5d8 fix: Home Assistant: set icon and state class for orientation sensors (#28192) 2025-08-07 19:29:22 +02:00
Koen Kanters e12add17be chore: merge master to dev 2025-08-06 20:04:47 +02:00
dependabot[bot] bda5bf9e96 chore(deps-dev): bump tmp from 0.2.3 to 0.2.4 (#28186) 2025-08-06 20:04:33 +02:00
Koen Kanters 2056b89bac fix(ignore): update zigbee-herdsman-converters to 24.13.0 (#28175) 2025-08-05 19:04:52 +00:00
Koen Kanters d698770169 Revert "Revert "chore: cleanup Dockerfile (#28166)""
This reverts commit b41e3e781e.
2025-08-05 19:44:31 +02:00
Koen Kanters b41e3e781e Revert "chore: cleanup Dockerfile (#28166)"
This reverts commit e8cec3542f.
2025-08-04 21:40:19 +02:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>NerivecKoen Kanters
b72abd675f fix(ignore): bump the minor-patch group with 4 updates (#28147)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2025-08-04 21:32:51 +02:00
Koen Kanters 9836f0e27a fix(ignore): update zigbee-herdsman-converters to 24.12.0 (#28170) 2025-08-04 19:27:39 +00:00
NerivecandKoen Kanters e8cec3542f chore: cleanup Dockerfile (#28166)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2025-08-04 20:53:07 +02:00
krkk 050ebfc9f5 fix: Remove 60 MB (28%) of dev dependencies from Docker image (#28160) 2025-08-04 20:36:44 +02:00
github-actions[bot] 449b99b0c0 chore: promote to dev 2025-08-01 15:52:58 +00:00
Koen Kanters 0bdc3fd1f7 chore(dev): release 2.6.0 (#27881) 2025-08-01 17:37:30 +02:00
Koen Kanters 376554943d fix(ignore): update zigbee-herdsman-converters to 24.11.0 (#28143) 2025-08-01 15:22:20 +00:00
Koen Kanters 9fcc41b199 fix(ignore): update zigbee-herdsman to 5.0.4 (#28141) 2025-08-01 15:04:45 +00:00
Nerivec fe5639ce40 fix(ignore): update zigbee2mqtt-windfront to 1.5.2 (#28140) 2025-08-01 14:56:52 +00:00
Rob van OostenrijkandNerivec 0182c4b32e fix: Ignore symbolic links in data during backup (#28132)
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
2025-07-31 20:26:36 +02:00
Koen Kanters c12f07c5a0 chore: don't run codspeed on release-please prs 2025-07-30 21:34:29 +02:00
Nerivec 31d3e19f38 fix(ignore): update zigbee2mqtt-windfront to 1.5.1 (#28123) 2025-07-30 19:06:35 +00:00
Koen Kanters ee1c884031 fix(ignore): update zigbee-herdsman-converters to 24.10.0 (#28122) 2025-07-30 18:55:55 +00:00
Koen Kanters e5f677bdd1 fix(ignore): update zigbee-herdsman to 5.0.3 (#28121) 2025-07-30 18:48:26 +00:00
Koen Kanters 6dd812f71c fix(ignore): fix 71cf2b4160 2025-07-29 20:47:51 +02:00
Koenkk 71cf2b4160 fix(ignore): update zigbee-herdsman-converters to 24.9.0 2025-07-29 18:34:40 +00:00
Koen Kanters e9f2a3f532 fix(ignore): update zigbee-herdsman to 5.0.2 (#28110) 2025-07-29 18:34:03 +00:00
Nerivec 0725374db7 chore: update readme (#28085) 2025-07-27 13:01:47 +02:00
Nerivec 6f7c6eec04 chore: import vitest instead of relying on globals (#28084) 2025-07-26 20:05:13 +02:00
Nerivec 29e56adc15 fix(ignore): update zigbee2mqtt-windfront to 1.5.0 (#28082) 2025-07-26 17:00:38 +00:00
Koen KantersandNerivec caf91cd7ff fix(ignore): update zigbee-herdsman-converters to 24.8.0 (#28081)
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
2025-07-26 16:37:46 +00:00
Koen Kanters 6432571c61 fix(ignore): update zigbee-herdsman to 5.0.1 (#28080) 2025-07-26 11:13:06 +00:00
Koen Kanters 713551b525 fix(ignore): fix breaking changes of https://github.com/Koenkk/zigbee2mqtt/pull/28077 2025-07-25 20:30:48 +02:00
Koen Kanters 9a215efe26 fix(ignore): update zigbee-herdsman to 5.0.0 (#28077) 2025-07-25 18:25:44 +00:00
Nerivec 65eef1b690 fix: Publish definition source in bridge/devices (#28076) 2025-07-25 19:22:17 +02:00
Koen Kanters 81bafa5520 fix(ignore): update zigbee-herdsman-converters to 24.7.0 (#28070) 2025-07-24 18:37:06 +00:00
Nerivec d322dc2047 fix(ignore): update zigbee2mqtt-windfront to 1.4.1 (#28062) 2025-07-23 19:37:34 +00:00
Nerivec 5f9019a95f fix: Publish endpoint names in bridge/devices (#28057) 2025-07-23 20:12:16 +02:00
Koen Kanters 4bc6a59ff3 fix(ignore): update zigbee-herdsman-converters to 24.6.0 (#28051) 2025-07-22 18:26:50 +00:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Nerivec
e505a93a40 fix(ignore): bump the minor-patch group with 4 updates (#28046)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
2025-07-22 19:58:18 +02:00
Koen Kanters bc74d0b11b fix(ignore): update zigbee-herdsman-converters to 24.5.0 (#28045) 2025-07-21 18:23:20 +00:00
Koen Kanters cc6f7b02bc fix(ignore): update zigbee-herdsman to 4.5.0 (#28044) 2025-07-21 18:18:32 +00:00
Koen Kanters 74e9357a59 fix(ignore): update zigbee-herdsman-converters to 24.4.0 (#28022) 2025-07-18 20:13:44 +00:00
Koen Kantersandnurikk 8b876823da fix(ignore): update zigbee2mqtt-frontend to 0.9.19 (#28021)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2025-07-18 22:05:48 +02:00
Nerivec db20369447 fix: Various fixes to align behaviors (+add benchmarks for dev) (#28011) 2025-07-18 21:34:51 +02:00
Koen Kanters 0e0a5618af fix: Exit with code 2 when adapters disconnects (#28013) 2025-07-18 20:32:39 +02:00
Koen Kanters 21fa5cbded fix(ignore): update zigbee-herdsman-converters to 24.3.0 (#28015) 2025-07-17 12:28:32 +00:00
Koen Kanters e2b7df9f84 fix(ignore): Remove retain/qos from mqtt publish logging https://github.com/Koenkk/zigbee2mqtt/pull/27983 2025-07-15 13:56:17 +02:00
Donald FlissingerandKoen Kanters ef07505b31 fix: Set MQTT retain and qos=1 on (re)connect (#27983)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2025-07-15 13:29:57 +02:00
Nerivec 435a6cceb9 fix: Improve startup settings validation (#27988) 2025-07-15 13:15:11 +02:00
Nerivec a51216e3c7 fix(ignore): update zigbee2mqtt-windfront to 1.3.1 (#27999) 2025-07-15 10:55:37 +00:00
Koen Kanters 6ede403688 fix(ignore): update zigbee-herdsman-converters to 24.2.0 (#27986) 2025-07-14 11:49:06 +00:00
NerivecandKoen Kanters ecadda45e7 fix: Fix settings schema defaults (#27961)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2025-07-12 18:36:56 +02:00
Koen Kanters a837702b2c fix(ignore): update zigbee-herdsman-converters to 24.1.0 (#27973) 2025-07-12 12:40:05 +00:00
Koen Kanters 57e04bb547 chore: merge master to dev 2025-07-12 13:36:44 +02:00
Koen Kanters ffdd353e16 chore: Add device database.db entry to problem report template 2025-07-12 13:36:24 +02:00
Nerivec 0215e76118 feat: Allow to disable internal frontend UI serving for standalone serving (#27851) 2025-07-12 09:33:26 +02:00
Nerivec 833605b71f fix(ignore): add test to specifically check HA envs (#27969) 2025-07-11 19:53:05 +02:00
Boris Conforty f4d821deca fix: log error message when external JS fails to load (#27837) 2025-07-11 17:36:06 +02:00
Koen Kanters c465ef8352 fix(ignore): update zigbee-herdsman-converters to 24.0.0 (#27946) 2025-07-10 13:06:50 +00:00
Koen Kanters 0dd4b6a991 chore: fix Docker Dependabot 2025-07-10 14:45:45 +02:00
Nerivec 1b37baefd3 fix(ignore): update zigbee2mqtt-windfront to 1.3.0 (#27953) 2025-07-09 22:37:02 +00:00
Koen Kanters b4a9424d60 fix(ignore): update zigbee-herdsman-converters to 23.72.0 (#27943) 2025-07-09 12:48:46 +00:00
Koen Kanters 93c1824b1d fix(ignore): update zigbee-herdsman-converters to 23.71.0 (#27931) 2025-07-08 11:27:01 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 8ceb08704f fix(ignore): bump the minor-patch group with 2 updates (#27924)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-07 20:47:07 +02:00
Koen Kanters eb82973067 fix(ignore): update zigbee-herdsman to 4.4.1 (#27918) 2025-07-06 19:21:12 +00:00
Koen Kantersandnurikk a3e7f69b3f fix(ignore): update zigbee2mqtt-frontend to 0.9.17 (#27917)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2025-07-06 21:15:40 +02:00
Koen Kanters 373fa43136 chore: change dependabot versioning strategy to increase 2025-07-06 21:13:57 +02:00
Koen Kanters e04732462c fix(ignore): update zigbee-herdsman-converters to 23.70.1 (#27916) 2025-07-06 19:01:12 +00:00
Koen Kanters e663e86e09 fix(ignore): update zigbee-herdsman to 4.4.0 (#27906) 2025-07-04 18:37:18 +00:00
Koen Kanters 163a15d664 fix(ignore): update zigbee-herdsman-converters to 23.70.0 (#27905) 2025-07-04 20:34:17 +02:00
Koen Kanters 4afdd4e892 fix(ignore): update zigbee-herdsman to 4.3.2 (#27900) 2025-07-04 11:47:05 +00:00
Nerivec 190cfe99ac fix: erroneous Health OS memory percent calculation (#27892) 2025-07-03 16:31:03 +02:00
github-actions[bot] c9284f6b33 chore: promote to dev 2025-07-02 19:48:07 +00:00
104 changed files with 9515 additions and 2691 deletions
@@ -0,0 +1,55 @@
name: External converter
description: Submit an external converter
title: '[External Converter]: '
labels: [external converter]
body:
- type: markdown
attributes:
value: |
**IMPORTANT:** In case the external converter fully works, please consider submitting a [pull request](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html#_4-create-a-pull-request) instead.
- type: input
id: link
attributes:
label: Link
description: Link of this device (product page)
placeholder: https://www.linktomydevice.org
validations:
required: true
- type: input
id: database
attributes:
label: Database entry
description: Entry of this device in `data/database.db` after pairing it
placeholder: '{"id":53,"type":"Router","ieeeAddr":"0x10458d00024284f69","nwkAddr":10148,"manufId":4151,"manufName":"LUMI","powerSource":"DC Source","modelId":"lumi.relay.c2acn01","epList":[1,2],"endpoints":{"1":{"profId":260,"epId":1,"devId":257,"inClusterList":[0,3,4,5,1,2,10,6,16,2820,12],"outClusterList":[25,10],"clusters":{"genBasic":{"attributes":{"modelId":"lumi.relay.c2acn01","appVersion":1,"manufacturerName":"LUMI","powerSource":4,"zclVersion":0,"stackVersion":2,"hwVersion":18,"dateCode":"8-6-2020"}},"genAnalogInput":{"attributes":{"presentValue":129.04425048828125}},"genOnOff":{"attributes":{"61440":117440715,"onOff":1}}},"binds":[],"configuredReportings":[],"meta":{}},"2":{"profId":260,"epId":2,"devId":257,"inClusterList":[6,16,4,5],"outClusterList":[],"clusters":{"genOnOff":{"attributes":{"61440":237478966,"onOff":0}}},"binds":[],"configuredReportings":[],"meta":{}}},"appVersion":1,"stackVersion":2,"hwVersion":18,"dateCode":"8-6-2020","zclVersion":0,"interviewCompleted":true,"meta":{},"lastSeen":1640285631405}'
validations:
required: true
- type: input
id: z2m_version
attributes:
label: Zigbee2MQTT version
description: Can be found in the frontend -> settings -> about -> Zigbee2MQTT version. Are you running Zigbee2MQTT 1.18.1? Then read [this](https://github.com/Koenkk/zigbee2mqtt/releases/tag/1.19.0).
placeholder: '1.22.1'
validations:
required: true
- type: textarea
id: external_definition
attributes:
label: External converter
description: Code of the external converter, to retrieve in the Z2M frontend go to Settings -> Dev Console -> External Converters
render: JavaScript
validations:
required: true
- type: textarea
attributes:
label: What does/doesn't work with the external definition?
description: Indicate what works and what doesn't work with the external definition.
validations:
required: true
- type: textarea
id: notes
attributes:
label: Notes
description: Any additional information or context that might be helpful
placeholder: Add any relevant notes or context here
validations:
required: false
+10 -8
View File
@@ -36,17 +36,11 @@ body:
validations:
required: true
- type: textarea
id: notes
attributes:
label: Comments
placeholder: I tried to follow the supporting new device page but got stuck at...
validations:
required: true
- type: textarea
id: external_definition
attributes:
label: External definition
description: See [Creating the external definition](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html#_2-creating-the-external-definition)
render: shell
render: JavaScript
validations:
required: true
- type: textarea
@@ -55,3 +49,11 @@ body:
description: Indicate what works and what doesn't work with the external definition.
validations:
required: true
- type: textarea
id: notes
attributes:
label: Notes
description: Any additional information or context that might be helpful
placeholder: Add any relevant notes or context here
validations:
required: false
+15 -1
View File
@@ -6,7 +6,7 @@ body:
attributes:
value: |
**IMPORTANT:** Before submitting:
- Zigbee2MQTT fails to start? Read [this](https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start.html)
- Zigbee2MQTT fails to start? Read [this](https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start_crashes-runtime.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)
@@ -69,6 +69,12 @@ body:
placeholder: Add-on on Home Assistant OS on Intel NUC, Plain on Docker container, ...
validations:
required: true
- type: input
id: device
attributes:
label: Device `database.db` entry
description: If the problem is related to a specific device, paste the `database.db` entry here
placeholder: '{"id":117,"type":"EndDevice","ieeeAddr":"0x00158d0001d8e1d2","nwkAddr":25887 ...'
- type: textarea
id: log
attributes:
@@ -77,3 +83,11 @@ body:
placeholder: Click here and drag the file into it or click on "Attach files by.." below
validations:
required: false
- type: textarea
id: notes
attributes:
label: Notes
description: Any additional information or context that might be helpful
placeholder: Add any relevant notes or context here
validations:
required: false
+4 -1
View File
@@ -6,7 +6,10 @@ body:
- type: markdown
attributes:
value: |
Only use this if the device is detected as **supported** and is **fully functional** but has a wrong picture, vendor, model or description.
Submit this issue only if your device is detected as **supported** and works **fully**, but displays an incorrect picture, vendor, model, or description.
**IMPORTANT**: Before opening this issue, please review the [guide](https://www.zigbee2mqtt.io/advanced/support-new-devices/02_support_new_tuya_devices.html#fixing-tuya-device-detection) for instructions on fixing the device detection yourself.
This usually only takes 5 minutes.
- type: input
id: link
attributes:
+459
View File
@@ -0,0 +1,459 @@
# GitHub Copilot Instructions
## Priority Guidelines
When generating code for this repository:
1. **Version Compatibility**: Always detect and respect the exact versions of languages, frameworks, and libraries used in this project
2. **Context Files**: Prioritize patterns and standards defined in the .github/copilot directory
3. **Codebase Patterns**: When context files don't provide specific guidance, scan the codebase for established patterns
4. **Architectural Consistency**: Maintain our layered architectural style with clear separation between controller, extensions, models, and utilities
5. **Code Quality**: Prioritize maintainability, performance, security, and testability in all generated code
## Technology Stack
### Core Technologies
- **Language**: TypeScript 5.9.3 with target `esnext` and module `NodeNext`
- **Runtime**: Node.js ^20 || ^22 || ^24
- **Package Manager**: pnpm 10.12.1
- **Testing**: Vitest 3.1.1 with @vitest/coverage-v8
- **Linting/Formatting**: Biome 2.2.5 (configured with 4-space indents, 150 line width, no bracket spacing)
### Key Dependencies
- **zigbee-herdsman**: 6.2.0 (exact version - critical for Zigbee protocol compatibility)
- **zigbee-herdsman-converters**: 25.42.0 (exact version - device definitions)
- **MQTT**: mqtt 5.14.1
- **Logging**: winston 3.18.3
- **YAML**: js-yaml 4.1.0
- **Decorators**: bind-decorator 1.0.11
- **WebSocket**: ws 8.18.1
### TypeScript Configuration
- **Strict Mode**: Enabled with `noImplicitAny` and `noImplicitThis`
- **Module System**: NodeNext with ESM interop
- **Decorators**: Experimental decorators enabled
- **Composite**: True (for project references)
- **Source Maps**: Inline source maps enabled
- **Output**: Compiled to `dist/` directory
## Project Architecture
### Directory Structure
```
lib/ # Source TypeScript files
├── controller.ts # Main controller orchestrating all components
├── mqtt.ts # MQTT client management
├── zigbee.ts # Zigbee network management
├── state.ts # State management
├── eventBus.ts # Event-driven communication
├── extension/ # Extension system (plugins)
│ ├── extension.ts # Abstract base class
│ ├── availability.ts
│ ├── bind.ts
│ ├── bridge.ts
│ ├── configure.ts
│ └── ...
├── model/ # Domain models
│ ├── device.ts
│ └── group.ts
├── util/ # Utility functions
│ ├── logger.ts
│ ├── settings.ts
│ ├── utils.ts
│ └── ...
└── types/ # TypeScript type definitions
└── api.ts
test/ # Vitest test files
data/ # Runtime configuration and data
```
### Architectural Patterns
#### Extension Pattern
All extensions inherit from the abstract `Extension` base class:
```typescript
abstract class Extension {
protected zigbee: Zigbee;
protected mqtt: Mqtt;
protected state: State;
protected publishEntityState: PublishEntityState;
protected eventBus: EventBus;
async start(): Promise<void> {}
async stop(): Promise<void> {}
}
```
#### Event-Driven Architecture
Use the `EventBus` for component communication. Events are strongly typed:
```typescript
interface EventBusMap {
deviceMessage: [data: eventdata.DeviceMessage];
mqttMessage: [data: eventdata.MQTTMessage];
publishEntityState: [data: eventdata.PublishEntityState];
// ... other events
}
```
#### Dependency Injection
The `Controller` class instantiates and injects dependencies into extensions. Follow this pattern when creating new extensions.
## Code Style and Conventions
### Naming Conventions
- **Classes**: PascalCase (e.g., `Extension`, `Device`, `EventBus`)
- **Interfaces/Types**: PascalCase (e.g., `MqttPublishOptions`, `DeviceOptions`)
- **Functions/Methods**: camelCase (e.g., `publishEntityState`, `enableDisableExtension`)
- **Constants**: SCREAMING_SNAKE_CASE for top-level constants (e.g., `CURRENT_VERSION`, `LOG_LEVELS`)
- **Private members**: Prefix with underscore for private class fields only when needed to distinguish from public properties (e.g., `_definitionModelID`)
- **Files**: camelCase for TypeScript files (e.g., `eventBus.ts`, `externalJS.ts`)
### Import Organization
Follow this import order (separated by blank lines):
1. Node.js built-in modules (use `node:` prefix: `import fs from "node:fs"`)
2. Third-party libraries (e.g., `bind-decorator`, `mqtt`)
3. Type-only imports from external packages (using `type` keyword)
4. Internal absolute imports from project root
5. Type-only imports from internal modules
Example:
```typescript
import fs from "node:fs";
import bind from "bind-decorator";
import type {IClientOptions} from "mqtt";
import {connectAsync} from "mqtt";
import type {Zigbee2MQTTAPI} from "./types/api";
import logger from "./util/logger";
import * as settings from "./util/settings";
```
### Type Annotations
- Use `type` imports for TypeScript types: `import type * as zhc from "zigbee-herdsman-converters"`
- Explicitly type function parameters and return types
- Use `KeyValue` type for generic object payloads: `type KeyValue = Record<string, any>`
- Prefer interfaces for object shapes, type aliases for unions/intersections
- Use namespace exports for related types: `export type * as ZSpec from "zigbee-herdsman/dist/zspec"`
### Async/Await Patterns
- Always use `async/await` for asynchronous operations
- Return types should be explicitly `Promise<Type>`
- Methods that don't return values should be `Promise<void>`
- Use `Awaited<ReturnType<typeof fn>>` for inferring async function return types
### Decorators
Use `@bind` decorator from `bind-decorator` for methods that need `this` binding:
```typescript
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
// Implementation
}
```
### Error Handling
- Use `throw new Error("message")` for explicit errors
- Include descriptive error messages
- Log errors using the logger: `logger.error("message")`
- For Zigbee-herdsman errors, log the stack trace: `logger.error((error as Error).stack!)`
- Catch and handle errors at appropriate boundaries (controller level)
### Logging
Use the centralized logger (winston-based):
```typescript
import logger from "./util/logger";
logger.info("message");
logger.warning("message");
logger.error("message");
logger.debug("message");
```
- Use namespaced loggers for specific modules (created internally by logger)
- Log levels: `error`, `warning`, `info`, `debug` (from most to least critical)
- Include relevant context in log messages (device names, IEEE addresses, etc.)
## Code Quality Standards
### Maintainability
- Write self-documenting code with clear, descriptive names
- Keep methods focused on single responsibilities
- Abstract classes should define clear contracts with protected members for subclasses
- Use constructor dependency injection for required dependencies
- Limit function complexity - methods should be concise and focused
- Use TypeScript's strict mode features (`noImplicitAny`, `noImplicitThis`)
### Performance
- Use `rimrafSync` for synchronous file deletion when appropriate
- Leverage async/await for I/O operations to avoid blocking
- Use JSON stable stringify for consistent object serialization: `json-stable-stringify-without-jsonify`
- Cache computed values when appropriate (see device model patterns)
- Use getter methods for computed properties that should be cached
### Security
- Validate input using Ajv JSON schema validation (see `settings.ts` pattern)
- Sanitize file paths using `path.join` from Node.js
- Use YAML safe loading: `yaml.safeLoad()`
- Handle sensitive data (credentials, tokens) through settings with proper defaults
- Never log sensitive information (passwords, tokens)
### Testability
- Write tests using Vitest with describe/it/expect patterns
- Mock external dependencies using Vitest's `vi.mock()`
- Use `beforeEach`, `afterEach`, `beforeAll`, `afterAll` for test setup/teardown
- Place test files in `test/` directory with `.test.ts` extension
- Mock constructors and modules in the pattern shown in `test/controller.test.ts`
- Use `flushPromises()` utility for async test synchronization
- Target 100% code coverage (configured in vitest.config.mts)
## Testing Standards
### Unit Testing Structure
```typescript
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
describe("ComponentName", () => {
beforeEach(() => {
// Setup
});
it("Should do something specific", async () => {
// Arrange
const input = {};
// Act
const result = await someFunction(input);
// Assert
expect(result).toBe(expected);
});
});
```
### Mocking Patterns
- Create mock modules in `test/mocks/` directory
- Use `vi.fn()` for function mocks
- Use `vi.mock()` for module mocks
- Clear mocks in `afterEach` or between tests
- Mock external libraries like `mqtt`, `zigbee-herdsman` consistently
### Test Coverage
- All code in `lib/**` should be covered
- Use coverage reports: `pnpm test:coverage`
- Thresholds set to 100% (can be adjusted per project needs)
- Tests should cover both success and failure paths
## Documentation Standards
### JSDoc Comments
Use JSDoc-style comments for classes and public methods:
```typescript
/**
* Besides initializing variables, the constructor should do nothing!
*
* @param {Zigbee} zigbee Zigbee 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
*/
constructor(zigbee: Zigbee, mqtt: Mqtt, state: State, ...) {
```
### Comment Style
- Use single-line comments (`//`) for implementation notes
- Use JSDoc (`/** */`) for public APIs and class/method documentation
- Include context for non-obvious logic
- Document parameters with their types and purposes
- Use `@param` tags with TypeScript types in braces
- Use biome-ignore comments when necessary: `// biome-ignore lint/rule: reason`
### Code Documentation
- Document complex algorithms or business logic
- Explain "why" not just "what" when logic is non-trivial
- Include links to relevant issues or documentation when applicable
- Document deprecations and breaking changes
## TypeScript-Specific Guidelines
### Module System
- Use ES modules with `import`/`export` syntax
- Default exports for main classes: `export default class Device {}`
- Named exports for utilities and types: `export const LOG_LEVELS = ...`
- Namespace exports for related types: `export type * as ZSpec from ...`
- Use `.js` extension in imports for local modules when using dynamic imports: `await import("./extension/frontend.js")`
### Type Safety
- Enable all strict type checking options
- Use type guards and assertions when necessary: `asserts expose is zhc.Numeric`
- Prefer `unknown` over `any` when type is truly unknown
- Use `// biome-ignore lint/suspicious/noExplicitAny: API` when `any` is necessary
- Define proper interfaces for external module types (e.g., `unix-dgram.d.ts`)
### Generic Types
- Use generics for reusable, type-safe abstractions
- Example: `abstract class ExternalJSExtension<M> extends Extension`
- Constrain generics when appropriate
- Document generic type parameters
### Utility Types
- Use built-in utility types: `Partial`, `Required`, `Pick`, `Omit`, `Record`
- Use `Awaited<ReturnType<typeof fn>>` for async function return types
- Define custom utility types when patterns emerge
- Use `type` for aliases, `interface` for object shapes
## Version Control and Releases
### Versioning Strategy
- Follow Semantic Versioning (MAJOR.MINOR.PATCH)
- Current version managed in `package.json`
- Use `-dev` suffix for development versions (e.g., `2.6.2-dev`)
- Configuration version tracked separately: `CURRENT_VERSION = 4`
### Changelog
- Maintain CHANGELOG.md with all changes
- Group changes by type: Bug Fixes, Features, Breaking Changes
- Include issue/PR references: `([#28583](url))`
- Include commit references: `([09f33b3](url))`
- Use conventional commits format
### Git Workflow
- Development on `dev` branch
- Production releases from `master` branch
- Use meaningful commit messages
- Reference issues in commits
## Project-Specific Patterns
### Settings Management
- All configuration loaded through `util/settings.ts`
- Validate settings using Ajv with JSON schema
- Schema defined in `settings.schema.json`
- Support runtime setting changes with restart detection
- Use `settings.get()` to access current configuration
- Use `settings.getDevice(ieeeAddr)` for device-specific config
### Device and Group Models
- Devices and groups are domain models wrapping `zigbee-herdsman` entities
- Access underlying entity via `.zh` property
- Expose computed properties as getters
- Include definition from `zigbee-herdsman-converters`
- Handle coordinator devices specially (type checking)
### MQTT Integration
- MQTT client wrapped in `Mqtt` class
- Publish options: `retain`, `qos` properties
- Topics follow pattern: `{base_topic}/{device}/{attribute}`
- Event-based message handling via EventBus
- Clean disconnect handling with retry logic
### Extension System
- Extensions are loosely coupled plugins
- Lifecycle: constructor → start() → stop()
- Constructor should only assign properties (no side effects)
- Use EventBus for inter-extension communication
- Extensions can be enabled/disabled at runtime
- External extensions loaded from `data/external_extensions/`
### State Management
- State persisted to `state.json`
- Cached in memory for performance
- Device states include all exposed attributes
- State changes trigger events via EventBus
## Best Practices Specific to This Project
1. **Never use language features beyond TypeScript 5.9.3 or ES2024**
2. **Always respect exact versions of zigbee-herdsman and zigbee-herdsman-converters** - these are critical for device compatibility
3. **Use the EventBus for all component communication** - avoid direct coupling
4. **Follow the Extension pattern for new features** - don't add logic directly to Controller
5. **Log appropriately** - info for user-relevant events, debug for developer info, error for failures
6. **Test with real Zigbee scenarios** - many edge cases exist with different device types
7. **Handle coordinator specially** - coordinator is a device but with unique behavior
8. **Validate all external input** - MQTT messages, configuration files, device data
9. **Use the bind decorator** for event handlers to preserve `this` context
10. **Match the exact code formatting** - Biome enforces 4 spaces, 150 line width, no bracket spacing
## Common Patterns to Follow
### Creating a New Extension
1. Extend `Extension` abstract class
2. Accept all dependencies in constructor
3. Implement `start()` method for initialization
4. Subscribe to EventBus events in `start()`
5. Implement `stop()` method for cleanup
6. Export as default: `export default class MyExtension extends Extension`
### Accessing Device Information
```typescript
const device: Device; // Our wrapper
device.ieeeAddr; // IEEE address
device.name; // Friendly name
device.zh; // Underlying zigbee-herdsman device
device.definition; // zigbee-herdsman-converters definition
device.options; // User configuration
```
### Publishing MQTT Messages
```typescript
await this.mqtt.publish(topic, message, {retain: true, qos: 0});
```
### Emitting Events
```typescript
this.eventBus.emit('deviceMessage', {device, message});
```
### Listening to Events
```typescript
this.eventBus.on('deviceMessage', this.onDeviceMessage, this);
```
## Integration Points
### Zigbee-Herdsman Integration
- Start controller: `await this.zigbee.start()`
- Access coordinator: `this.zigbee.coordinator()`
- Device operations through `zigbee-herdsman` API
- Event handling through EventBus wrappers
### MQTT Integration
- Connect: `await this.mqtt.connect()`
- Subscribe: `await this.mqtt.subscribe(topic)`
- Publish: `await this.mqtt.publish(topic, message, options)`
- Handle messages via EventBus `mqttMessage` event
### Frontend Integration
- Optional extension loaded dynamically
- Serves static files with compression
- WebSocket support for real-time updates
- Configurable port and base URL
### Home Assistant Integration
- Optional extension for discovery
- Publishes discovery messages to MQTT
- Supports entities, sensors, and devices
- Configurable discovery topic
## Critical Compatibility Notes
1. **Node.js**: Only versions 20, 22, and 24 are supported
2. **TypeScript**: Features must be compatible with 5.9.3
3. **Zigbee Libraries**: Exact versions are critical - do not suggest upgrades without testing
4. **MQTT Protocol**: Uses MQTT 3.1.1 and 5.0 features
5. **ES Modules**: Project uses ESM with NodeNext resolution
6. **Experimental Decorators**: Required for `@bind` decorator support
## When in Doubt
1. **Search for similar patterns** in the existing codebase
2. **Check existing extensions** for implementation examples
3. **Follow the controller and extension architecture** - don't bypass it
4. **Consult the test files** for usage examples
5. **Match the exact style** - run `pnpm check` to verify
6. **Prioritize consistency** over external best practices
7. **Test thoroughly** - this project controls real hardware
## Resources
- Repository: https://github.com/Koenkk/zigbee2mqtt
- Documentation: https://koenkk.github.io/zigbee2mqtt
- License: GPL-3.0
- Issue Tracker: https://github.com/Koenkk/zigbee2mqtt/issues
+2 -1
View File
@@ -1,6 +1,7 @@
version: 2
updates:
- package-ecosystem: npm
versioning-strategy: increase
directory: /
schedule:
interval: weekly
@@ -14,7 +15,7 @@ updates:
- minor
- patch
- package-ecosystem: docker
directory: /
directory: /docker
schedule:
interval: weekly
target-branch: dev
@@ -0,0 +1,294 @@
---
description: 'Technology-agnostic blueprint generator for creating comprehensive copilot-instructions.md files that guide GitHub Copilot to produce code consistent with project standards, architecture patterns, and exact technology versions by analyzing existing codebase patterns and avoiding assumptions.'
mode: 'agent'
---
# Copilot Instructions Blueprint Generator
## Configuration Variables
${PROJECT_TYPE="Auto-detect|.NET|Java|JavaScript|TypeScript|React|Angular|Python|Multiple|Other"} <!-- Primary technology -->
${ARCHITECTURE_STYLE="Layered|Microservices|Monolithic|Domain-Driven|Event-Driven|Serverless|Mixed"} <!-- Architectural approach -->
${CODE_QUALITY_FOCUS="Maintainability|Performance|Security|Accessibility|Testability|All"} <!-- Quality priorities -->
${DOCUMENTATION_LEVEL="Minimal|Standard|Comprehensive"} <!-- Documentation requirements -->
${TESTING_REQUIREMENTS="Unit|Integration|E2E|TDD|BDD|All"} <!-- Testing approach -->
${VERSIONING="Semantic|CalVer|Custom"} <!-- Versioning approach -->
## Generated Prompt
"Generate a comprehensive copilot-instructions.md file that will guide GitHub Copilot to produce code consistent with our project's standards, architecture, and technology versions. The instructions must be strictly based on actual code patterns in our codebase and avoid making any assumptions. Follow this approach:
### 1. Core Instruction Structure
```markdown
# GitHub Copilot Instructions
## Priority Guidelines
When generating code for this repository:
1. **Version Compatibility**: Always detect and respect the exact versions of languages, frameworks, and libraries used in this project
2. **Context Files**: Prioritize patterns and standards defined in the .github/copilot directory
3. **Codebase Patterns**: When context files don't provide specific guidance, scan the codebase for established patterns
4. **Architectural Consistency**: Maintain our ${ARCHITECTURE_STYLE} architectural style and established boundaries
5. **Code Quality**: Prioritize ${CODE_QUALITY_FOCUS == "All" ? "maintainability, performance, security, accessibility, and testability" : CODE_QUALITY_FOCUS} in all generated code
## Technology Version Detection
Before generating code, scan the codebase to identify:
1. **Language Versions**: Detect the exact versions of programming languages in use
- Examine project files, configuration files, and package managers
- Look for language-specific version indicators (e.g., <LangVersion> in .NET projects)
- Never use language features beyond the detected version
2. **Framework Versions**: Identify the exact versions of all frameworks
- Check package.json, .csproj, pom.xml, requirements.txt, etc.
- Respect version constraints when generating code
- Never suggest features not available in the detected framework versions
3. **Library Versions**: Note the exact versions of key libraries and dependencies
- Generate code compatible with these specific versions
- Never use APIs or features not available in the detected versions
## Context Files
Prioritize the following files in .github/copilot directory (if they exist):
- **architecture.md**: System architecture guidelines
- **tech-stack.md**: Technology versions and framework details
- **coding-standards.md**: Code style and formatting standards
- **folder-structure.md**: Project organization guidelines
- **exemplars.md**: Exemplary code patterns to follow
## Codebase Scanning Instructions
When context files don't provide specific guidance:
1. Identify similar files to the one being modified or created
2. Analyze patterns for:
- Naming conventions
- Code organization
- Error handling
- Logging approaches
- Documentation style
- Testing patterns
3. Follow the most consistent patterns found in the codebase
4. When conflicting patterns exist, prioritize patterns in newer files or files with higher test coverage
5. Never introduce patterns not found in the existing codebase
## Code Quality Standards
${CODE_QUALITY_FOCUS.includes("Maintainability") || CODE_QUALITY_FOCUS == "All" ? `### Maintainability
- Write self-documenting code with clear naming
- Follow the naming and organization conventions evident in the codebase
- Follow established patterns for consistency
- Keep functions focused on single responsibilities
- Limit function complexity and length to match existing patterns` : ""}
${CODE_QUALITY_FOCUS.includes("Performance") || CODE_QUALITY_FOCUS == "All" ? `### Performance
- Follow existing patterns for memory and resource management
- Match existing patterns for handling computationally expensive operations
- Follow established patterns for asynchronous operations
- Apply caching consistently with existing patterns
- Optimize according to patterns evident in the codebase` : ""}
${CODE_QUALITY_FOCUS.includes("Security") || CODE_QUALITY_FOCUS == "All" ? `### Security
- Follow existing patterns for input validation
- Apply the same sanitization techniques used in the codebase
- Use parameterized queries matching existing patterns
- Follow established authentication and authorization patterns
- Handle sensitive data according to existing patterns` : ""}
${CODE_QUALITY_FOCUS.includes("Accessibility") || CODE_QUALITY_FOCUS == "All" ? `### Accessibility
- Follow existing accessibility patterns in the codebase
- Match ARIA attribute usage with existing components
- Maintain keyboard navigation support consistent with existing code
- Follow established patterns for color and contrast
- Apply text alternative patterns consistent with the codebase` : ""}
${CODE_QUALITY_FOCUS.includes("Testability") || CODE_QUALITY_FOCUS == "All" ? `### Testability
- Follow established patterns for testable code
- Match dependency injection approaches used in the codebase
- Apply the same patterns for managing dependencies
- Follow established mocking and test double patterns
- Match the testing style used in existing tests` : ""}
## Documentation Requirements
${DOCUMENTATION_LEVEL == "Minimal" ?
`- Match the level and style of comments found in existing code
- Document according to patterns observed in the codebase
- Follow existing patterns for documenting non-obvious behavior
- Use the same format for parameter descriptions as existing code` : ""}
${DOCUMENTATION_LEVEL == "Standard" ?
`- Follow the exact documentation format found in the codebase
- Match the XML/JSDoc style and completeness of existing comments
- Document parameters, returns, and exceptions in the same style
- Follow existing patterns for usage examples
- Match class-level documentation style and content` : ""}
${DOCUMENTATION_LEVEL == "Comprehensive" ?
`- Follow the most detailed documentation patterns found in the codebase
- Match the style and completeness of the best-documented code
- Document exactly as the most thoroughly documented files do
- Follow existing patterns for linking documentation
- Match the level of detail in explanations of design decisions` : ""}
## Testing Approach
${TESTING_REQUIREMENTS.includes("Unit") || TESTING_REQUIREMENTS == "All" ?
`### Unit Testing
- Match the exact structure and style of existing unit tests
- Follow the same naming conventions for test classes and methods
- Use the same assertion patterns found in existing tests
- Apply the same mocking approach used in the codebase
- Follow existing patterns for test isolation` : ""}
${TESTING_REQUIREMENTS.includes("Integration") || TESTING_REQUIREMENTS == "All" ?
`### Integration Testing
- Follow the same integration test patterns found in the codebase
- Match existing patterns for test data setup and teardown
- Use the same approach for testing component interactions
- Follow existing patterns for verifying system behavior` : ""}
${TESTING_REQUIREMENTS.includes("E2E") || TESTING_REQUIREMENTS == "All" ?
`### End-to-End Testing
- Match the existing E2E test structure and patterns
- Follow established patterns for UI testing
- Apply the same approach for verifying user journeys` : ""}
${TESTING_REQUIREMENTS.includes("TDD") || TESTING_REQUIREMENTS == "All" ?
`### Test-Driven Development
- Follow TDD patterns evident in the codebase
- Match the progression of test cases seen in existing code
- Apply the same refactoring patterns after tests pass` : ""}
${TESTING_REQUIREMENTS.includes("BDD") || TESTING_REQUIREMENTS == "All" ?
`### Behavior-Driven Development
- Match the existing Given-When-Then structure in tests
- Follow the same patterns for behavior descriptions
- Apply the same level of business focus in test cases` : ""}
## Technology-Specific Guidelines
${PROJECT_TYPE == ".NET" || PROJECT_TYPE == "Auto-detect" || PROJECT_TYPE == "Multiple" ? `### .NET Guidelines
- Detect and strictly adhere to the specific .NET version in use
- Use only C# language features compatible with the detected version
- Follow LINQ usage patterns exactly as they appear in the codebase
- Match async/await usage patterns from existing code
- Apply the same dependency injection approach used in the codebase
- Use the same collection types and patterns found in existing code` : ""}
${PROJECT_TYPE == "Java" || PROJECT_TYPE == "Auto-detect" || PROJECT_TYPE == "Multiple" ? `### Java Guidelines
- Detect and adhere to the specific Java version in use
- Follow the exact same design patterns found in the codebase
- Match exception handling patterns from existing code
- Use the same collection types and approaches found in the codebase
- Apply the dependency injection patterns evident in existing code` : ""}
${PROJECT_TYPE == "JavaScript" || PROJECT_TYPE == "TypeScript" || PROJECT_TYPE == "Auto-detect" || PROJECT_TYPE == "Multiple" ? `### JavaScript/TypeScript Guidelines
- Detect and adhere to the specific ECMAScript/TypeScript version in use
- Follow the same module import/export patterns found in the codebase
- Match TypeScript type definitions with existing patterns
- Use the same async patterns (promises, async/await) as existing code
- Follow error handling patterns from similar files` : ""}
${PROJECT_TYPE == "React" || PROJECT_TYPE == "Auto-detect" || PROJECT_TYPE == "Multiple" ? `### React Guidelines
- Detect and adhere to the specific React version in use
- Match component structure patterns from existing components
- Follow the same hooks and lifecycle patterns found in the codebase
- Apply the same state management approach used in existing components
- Match prop typing and validation patterns from existing code` : ""}
${PROJECT_TYPE == "Angular" || PROJECT_TYPE == "Auto-detect" || PROJECT_TYPE == "Multiple" ? `### Angular Guidelines
- Detect and adhere to the specific Angular version in use
- Follow the same component and module patterns found in the codebase
- Match decorator usage exactly as seen in existing code
- Apply the same RxJS patterns found in the codebase
- Follow existing patterns for component communication` : ""}
${PROJECT_TYPE == "Python" || PROJECT_TYPE == "Auto-detect" || PROJECT_TYPE == "Multiple" ? `### Python Guidelines
- Detect and adhere to the specific Python version in use
- Follow the same import organization found in existing modules
- Match type hinting approaches if used in the codebase
- Apply the same error handling patterns found in existing code
- Follow the same module organization patterns` : ""}
## Version Control Guidelines
${VERSIONING == "Semantic" ?
`- Follow Semantic Versioning patterns as applied in the codebase
- Match existing patterns for documenting breaking changes
- Follow the same approach for deprecation notices` : ""}
${VERSIONING == "CalVer" ?
`- Follow Calendar Versioning patterns as applied in the codebase
- Match existing patterns for documenting changes
- Follow the same approach for highlighting significant changes` : ""}
${VERSIONING == "Custom" ?
`- Match the exact versioning pattern observed in the codebase
- Follow the same changelog format used in existing documentation
- Apply the same tagging conventions used in the project` : ""}
## General Best Practices
- Follow naming conventions exactly as they appear in existing code
- Match code organization patterns from similar files
- Apply error handling consistent with existing patterns
- Follow the same approach to testing as seen in the codebase
- Match logging patterns from existing code
- Use the same approach to configuration as seen in the codebase
## Project-Specific Guidance
- Scan the codebase thoroughly before generating any code
- Respect existing architectural boundaries without exception
- Match the style and patterns of surrounding code
- When in doubt, prioritize consistency with existing code over external best practices
```
### 2. Codebase Analysis Instructions
To create the copilot-instructions.md file, first analyze the codebase to:
1. **Identify Exact Technology Versions**:
- ${PROJECT_TYPE == "Auto-detect" ? "Detect all programming languages, frameworks, and libraries by scanning file extensions and configuration files" : `Focus on ${PROJECT_TYPE} technologies`}
- Extract precise version information from project files, package.json, .csproj, etc.
- Document version constraints and compatibility requirements
2. **Understand Architecture**:
- Analyze folder structure and module organization
- Identify clear layer boundaries and component relationships
- Document communication patterns between components
3. **Document Code Patterns**:
- Catalog naming conventions for different code elements
- Note documentation styles and completeness
- Document error handling patterns
- Map testing approaches and coverage
4. **Note Quality Standards**:
- Identify performance optimization techniques actually used
- Document security practices implemented in the code
- Note accessibility features present (if applicable)
- Document code quality patterns evident in the codebase
### 3. Implementation Notes
The final copilot-instructions.md should:
- Be placed in the .github directory
- Reference only patterns and standards that exist in the codebase
- Include explicit version compatibility requirements
- Avoid prescribing any practices not evident in the code
- Provide concrete examples from the codebase
- Be comprehensive yet concise enough for Copilot to effectively use
Important: Only include guidance based on patterns actually observed in the codebase. Explicitly instruct Copilot to prioritize consistency with existing code over external best practices or newer language features.
"
## Expected Output
A comprehensive copilot-instructions.md file that will guide GitHub Copilot to produce code that is perfectly compatible with your existing technology versions and follows your established patterns and architecture.
+249
View File
@@ -0,0 +1,249 @@
---
description: "Prompt for generating an AGENTS.md file for a repository"
mode: "agent"
---
# Create highquality AGENTS.md file
You are a code agent. Your task is to create a complete, accurate AGENTS.md at the root of this repository that follows the public guidance at https://agents.md/.
AGENTS.md is an open format designed to provide coding agents with the context and instructions they need to work effectively on a project.
## What is AGENTS.md?
AGENTS.md is a Markdown file that serves as a "README for agents" - a dedicated, predictable place to provide context and instructions to help AI coding agents work on your project. It complements README.md by containing detailed technical context that coding agents need but might clutter a human-focused README.
## Key Principles
- **Agent-focused**: Contains detailed technical instructions for automated tools
- **Complements README.md**: Doesn't replace human documentation but adds agent-specific context
- **Standardized location**: Placed at repository root (or subproject roots for monorepos)
- **Open format**: Uses standard Markdown with flexible structure
- **Ecosystem compatibility**: Works across 20+ different AI coding tools and agents
## File Structure and Content Guidelines
### 1. Required Setup
- Create the file as `AGENTS.md` in the repository root
- Use standard Markdown formatting
- No required fields - flexible structure based on project needs
### 2. Essential Sections to Include
#### Project Overview
- Brief description of what the project does
- Architecture overview if complex
- Key technologies and frameworks used
#### Setup Commands
- Installation instructions
- Environment setup steps
- Dependency management commands
- Database setup if applicable
#### Development Workflow
- How to start development server
- Build commands
- Watch/hot-reload setup
- Package manager specifics (npm, pnpm, yarn, etc.)
#### Testing Instructions
- How to run tests (unit, integration, e2e)
- Test file locations and naming conventions
- Coverage requirements
- Specific test patterns or frameworks used
- How to run subset of tests or focus on specific areas
#### Code Style Guidelines
- Language-specific conventions
- Linting and formatting rules
- File organization patterns
- Naming conventions
- Import/export patterns
#### Build and Deployment
- Build commands and outputs
- Environment configurations
- Deployment steps and requirements
- CI/CD pipeline information
### 3. Optional but Recommended Sections
#### Security Considerations
- Security testing requirements
- Secrets management
- Authentication patterns
- Permission models
#### Monorepo Instructions (if applicable)
- How to work with multiple packages
- Cross-package dependencies
- Selective building/testing
- Package-specific commands
#### Pull Request Guidelines
- Title format requirements
- Required checks before submission
- Review process
- Commit message conventions
#### Debugging and Troubleshooting
- Common issues and solutions
- Logging patterns
- Debug configuration
- Performance considerations
## Example Template
Use this as a starting template and customize based on the specific project:
```markdown
# AGENTS.md
## Project Overview
[Brief description of the project, its purpose, and key technologies]
## Setup Commands
- Install dependencies: `[package manager] install`
- Start development server: `[command]`
- Build for production: `[command]`
## Development Workflow
- [Development server startup instructions]
- [Hot reload/watch mode information]
- [Environment variable setup]
## Testing Instructions
- Run all tests: `[command]`
- Run unit tests: `[command]`
- Run integration tests: `[command]`
- Test coverage: `[command]`
- [Specific testing patterns or requirements]
## Code Style
- [Language and framework conventions]
- [Linting rules and commands]
- [Formatting requirements]
- [File organization patterns]
## Build and Deployment
- [Build process details]
- [Output directories]
- [Environment-specific builds]
- [Deployment commands]
## Pull Request Guidelines
- Title format: [component] Brief description
- Required checks: `[lint command]`, `[test command]`
- [Review requirements]
## Additional Notes
- [Any project-specific context]
- [Common gotchas or troubleshooting tips]
- [Performance considerations]
```
## Working Example from agents.md
Here's a real example from the agents.md website:
```markdown
# Sample AGENTS.md file
## Dev environment tips
- Use `pnpm dlx turbo run where <project_name>` to jump to a package instead of scanning with `ls`.
- Run `pnpm install --filter <project_name>` to add the package to your workspace so Vite, ESLint, and TypeScript can see it.
- Use `pnpm create vite@latest <project_name> -- --template react-ts` to spin up a new React + Vite package with TypeScript checks ready.
- Check the name field inside each package's package.json to confirm the right name—skip the top-level one.
## Testing instructions
- Find the CI plan in the .github/workflows folder.
- Run `pnpm turbo run test --filter <project_name>` to run every check defined for that package.
- From the package root you can just call `pnpm test`. The commit should pass all tests before you merge.
- To focus on one step, add the Vitest pattern: `pnpm vitest run -t "<test name>"`.
- Fix any test or type errors until the whole suite is green.
- After moving files or changing imports, run `pnpm lint --filter <project_name>` to be sure ESLint and TypeScript rules still pass.
- Add or update tests for the code you change, even if nobody asked.
## PR instructions
- Title format: [<project_name>] <Title>
- Always run `pnpm lint` and `pnpm test` before committing.
```
## Implementation Steps
1. **Analyze the project structure** to understand:
- Programming languages and frameworks used
- Package managers and build tools
- Testing frameworks
- Project architecture (monorepo, single package, etc.)
2. **Identify key workflows** by examining:
- package.json scripts
- Makefile or other build files
- CI/CD configuration files
- Documentation files
3. **Create comprehensive sections** covering:
- All essential setup and development commands
- Testing strategies and commands
- Code style and conventions
- Build and deployment processes
4. **Include specific, actionable commands** that agents can execute directly
5. **Test the instructions** by ensuring all commands work as documented
6. **Keep it focused** on what agents need to know, not general project information
## Best Practices
- **Be specific**: Include exact commands, not vague descriptions
- **Use code blocks**: Wrap commands in backticks for clarity
- **Include context**: Explain why certain steps are needed
- **Stay current**: Update as the project evolves
- **Test commands**: Ensure all listed commands actually work
- **Consider nested files**: For monorepos, create AGENTS.md files in subprojects as needed
## Monorepo Considerations
For large monorepos:
- Place a main AGENTS.md at the repository root
- Create additional AGENTS.md files in subproject directories
- The closest AGENTS.md file takes precedence for any given location
- Include navigation tips between packages/projects
## Final Notes
- AGENTS.md works with 20+ AI coding tools including Cursor, Aider, Gemini CLI, and many others
- The format is intentionally flexible - adapt it to your project's needs
- Focus on actionable instructions that help agents understand and work with your codebase
- This is living documentation - update it as your project evolves
When creating the AGENTS.md file, prioritize clarity, completeness, and actionability. The goal is to give any coding agent enough context to effectively contribute to the project without requiring additional human guidance.
@@ -0,0 +1,127 @@
---
mode: 'agent'
description: 'Create a new specification file for the solution, optimized for Generative AI consumption.'
tools: ['changes', 'codebase', 'editFiles', 'extensions', 'fetch', 'githubRepo', 'openSimpleBrowser', 'problems', 'runTasks', 'search', 'searchResults', 'terminalLastCommand', 'terminalSelection', 'testFailure', 'usages', 'vscodeAPI']
---
# Create Specification
Your goal is to create a new specification file for `${input:SpecPurpose}`.
The specification file must define the requirements, constraints, and interfaces for the solution components in a manner that is clear, unambiguous, and structured for effective use by Generative AIs. Follow established documentation standards and ensure the content is machine-readable and self-contained.
## Best Practices for AI-Ready Specifications
- Use precise, explicit, and unambiguous language.
- Clearly distinguish between requirements, constraints, and recommendations.
- Use structured formatting (headings, lists, tables) for easy parsing.
- Avoid idioms, metaphors, or context-dependent references.
- Define all acronyms and domain-specific terms.
- Include examples and edge cases where applicable.
- Ensure the document is self-contained and does not rely on external context.
The specification should be saved in the [/spec/](/spec/) directory and named according to the following convention: `spec-[a-z0-9-]+.md`, where the name should be descriptive of the specification's content and starting with the highlevel purpose, which is one of [schema, tool, data, infrastructure, process, architecture, or design].
The specification file must be formatted in well formed Markdown.
Specification files must follow the template below, ensuring that all sections are filled out appropriately. The front matter for the markdown should be structured correctly as per the example following:
```md
---
title: [Concise Title Describing the Specification's Focus]
version: [Optional: e.g., 1.0, Date]
date_created: [YYYY-MM-DD]
last_updated: [Optional: YYYY-MM-DD]
owner: [Optional: Team/Individual responsible for this spec]
tags: [Optional: List of relevant tags or categories, e.g., `infrastructure`, `process`, `design`, `app` etc]
---
# Introduction
[A short concise introduction to the specification and the goal it is intended to achieve.]
## 1. Purpose & Scope
[Provide a clear, concise description of the specification's purpose and the scope of its application. State the intended audience and any assumptions.]
## 2. Definitions
[List and define all acronyms, abbreviations, and domain-specific terms used in this specification.]
## 3. Requirements, Constraints & Guidelines
[Explicitly list all requirements, constraints, rules, and guidelines. Use bullet points or tables for clarity.]
- **REQ-001**: Requirement 1
- **SEC-001**: Security Requirement 1
- **[3 LETTERS]-001**: Other Requirement 1
- **CON-001**: Constraint 1
- **GUD-001**: Guideline 1
- **PAT-001**: Pattern to follow 1
## 4. Interfaces & Data Contracts
[Describe the interfaces, APIs, data contracts, or integration points. Use tables or code blocks for schemas and examples.]
## 5. Acceptance Criteria
[Define clear, testable acceptance criteria for each requirement using Given-When-Then format where appropriate.]
- **AC-001**: Given [context], When [action], Then [expected outcome]
- **AC-002**: The system shall [specific behavior] when [condition]
- **AC-003**: [Additional acceptance criteria as needed]
## 6. Test Automation Strategy
[Define the testing approach, frameworks, and automation requirements.]
- **Test Levels**: Unit, Integration, End-to-End
- **Frameworks**: MSTest, FluentAssertions, Moq (for .NET applications)
- **Test Data Management**: [approach for test data creation and cleanup]
- **CI/CD Integration**: [automated testing in GitHub Actions pipelines]
- **Coverage Requirements**: [minimum code coverage thresholds]
- **Performance Testing**: [approach for load and performance testing]
## 7. Rationale & Context
[Explain the reasoning behind the requirements, constraints, and guidelines. Provide context for design decisions.]
## 8. Dependencies & External Integrations
[Define the external systems, services, and architectural dependencies required for this specification. Focus on **what** is needed rather than **how** it's implemented. Avoid specific package or library versions unless they represent architectural constraints.]
### External Systems
- **EXT-001**: [External system name] - [Purpose and integration type]
### Third-Party Services
- **SVC-001**: [Service name] - [Required capabilities and SLA requirements]
### Infrastructure Dependencies
- **INF-001**: [Infrastructure component] - [Requirements and constraints]
### Data Dependencies
- **DAT-001**: [External data source] - [Format, frequency, and access requirements]
### Technology Platform Dependencies
- **PLT-001**: [Platform/runtime requirement] - [Version constraints and rationale]
### Compliance Dependencies
- **COM-001**: [Regulatory or compliance requirement] - [Impact on implementation]
**Note**: This section should focus on architectural and business dependencies, not specific package implementations. For example, specify "OAuth 2.0 authentication library" rather than "Microsoft.AspNetCore.Authentication.JwtBearer v6.0.1".
## 9. Examples & Edge Cases
```code
// Code snippet or data example demonstrating the correct application of the guidelines, including edge cases
```
## 10. Validation Criteria
[List the criteria or tests that must be satisfied for compliance with this specification.]
## 11. Related Specifications / Further Reading
[Link to related spec 1]
[Link to relevant external documentation]
```
@@ -0,0 +1,15 @@
---
mode: 'agent'
description: 'Review and refactor code in your project according to defined instructions'
---
## Role
You're a senior expert software engineer with extensive experience in maintaining projects over a long time and ensuring clean code and best practices.
## Task
1. Take a deep breath, and review all coding guidelines instructions in `.github/instructions/*.md` and `.github/copilot-instructions.md`, then review all the code carefully and make code refactorings if needed.
2. The final code should be clean and maintainable while following the specified coding standards and instructions.
3. Do not split up the code, keep the existing files intact.
4. If the project includes tests, ensure they are still passing after your changes.
@@ -0,0 +1,127 @@
---
mode: 'agent'
description: 'Update an existing specification file for the solution, optimized for Generative AI consumption based on new requirements or updates to any existing code.'
tools: ['changes', 'codebase', 'editFiles', 'extensions', 'fetch', 'githubRepo', 'openSimpleBrowser', 'problems', 'runTasks', 'search', 'searchResults', 'terminalLastCommand', 'terminalSelection', 'testFailure', 'usages', 'vscodeAPI']
---
# Update Specification
Your goal is to update the existing specification file `${file}` based on new requirements or updates to any existing code.
The specification file must define the requirements, constraints, and interfaces for the solution components in a manner that is clear, unambiguous, and structured for effective use by Generative AIs. Follow established documentation standards and ensure the content is machine-readable and self-contained.
## Best Practices for AI-Ready Specifications
- Use precise, explicit, and unambiguous language.
- Clearly distinguish between requirements, constraints, and recommendations.
- Use structured formatting (headings, lists, tables) for easy parsing.
- Avoid idioms, metaphors, or context-dependent references.
- Define all acronyms and domain-specific terms.
- Include examples and edge cases where applicable.
- Ensure the document is self-contained and does not rely on external context.
The specification should be saved in the [/spec/](/spec/) directory and named according to the following convention: `[a-z0-9-]+.md`, where the name should be descriptive of the specification's content and starting with the highlevel purpose, which is one of [schema, tool, data, infrastructure, process, architecture, or design].
The specification file must be formatted in well formed Markdown.
Specification files must follow the template below, ensuring that all sections are filled out appropriately. The front matter for the markdown should be structured correctly as per the example following:
```md
---
title: [Concise Title Describing the Specification's Focus]
version: [Optional: e.g., 1.0, Date]
date_created: [YYYY-MM-DD]
last_updated: [Optional: YYYY-MM-DD]
owner: [Optional: Team/Individual responsible for this spec]
tags: [Optional: List of relevant tags or categories, e.g., `infrastructure`, `process`, `design`, `app` etc]
---
# Introduction
[A short concise introduction to the specification and the goal it is intended to achieve.]
## 1. Purpose & Scope
[Provide a clear, concise description of the specification's purpose and the scope of its application. State the intended audience and any assumptions.]
## 2. Definitions
[List and define all acronyms, abbreviations, and domain-specific terms used in this specification.]
## 3. Requirements, Constraints & Guidelines
[Explicitly list all requirements, constraints, rules, and guidelines. Use bullet points or tables for clarity.]
- **REQ-001**: Requirement 1
- **SEC-001**: Security Requirement 1
- **[3 LETTERS]-001**: Other Requirement 1
- **CON-001**: Constraint 1
- **GUD-001**: Guideline 1
- **PAT-001**: Pattern to follow 1
## 4. Interfaces & Data Contracts
[Describe the interfaces, APIs, data contracts, or integration points. Use tables or code blocks for schemas and examples.]
## 5. Acceptance Criteria
[Define clear, testable acceptance criteria for each requirement using Given-When-Then format where appropriate.]
- **AC-001**: Given [context], When [action], Then [expected outcome]
- **AC-002**: The system shall [specific behavior] when [condition]
- **AC-003**: [Additional acceptance criteria as needed]
## 6. Test Automation Strategy
[Define the testing approach, frameworks, and automation requirements.]
- **Test Levels**: Unit, Integration, End-to-End
- **Frameworks**: MSTest, FluentAssertions, Moq (for .NET applications)
- **Test Data Management**: [approach for test data creation and cleanup]
- **CI/CD Integration**: [automated testing in GitHub Actions pipelines]
- **Coverage Requirements**: [minimum code coverage thresholds]
- **Performance Testing**: [approach for load and performance testing]
## 7. Rationale & Context
[Explain the reasoning behind the requirements, constraints, and guidelines. Provide context for design decisions.]
## 8. Dependencies & External Integrations
[Define the external systems, services, and architectural dependencies required for this specification. Focus on **what** is needed rather than **how** it's implemented. Avoid specific package or library versions unless they represent architectural constraints.]
### External Systems
- **EXT-001**: [External system name] - [Purpose and integration type]
### Third-Party Services
- **SVC-001**: [Service name] - [Required capabilities and SLA requirements]
### Infrastructure Dependencies
- **INF-001**: [Infrastructure component] - [Requirements and constraints]
### Data Dependencies
- **DAT-001**: [External data source] - [Format, frequency, and access requirements]
### Technology Platform Dependencies
- **PLT-001**: [Platform/runtime requirement] - [Version constraints and rationale]
### Compliance Dependencies
- **COM-001**: [Regulatory or compliance requirement] - [Impact on implementation]
**Note**: This section should focus on architectural and business dependencies, not specific package implementations. For example, specify "OAuth 2.0 authentication library" rather than "Microsoft.AspNetCore.Authentication.JwtBearer v6.0.1".
## 9. Examples & Edge Cases
```code
// Code snippet or data example demonstrating the correct application of the guidelines, including edge cases
```
## 10. Validation Criteria
[List the criteria or tests that must be satisfied for compliance with this specification.]
## 11. Related Specifications / Further Reading
[Link to related spec 1]
[Link to relevant external documentation]
```
+37 -18
View File
@@ -3,33 +3,37 @@ name: CI
on:
push:
tags:
- '**'
branches-ignore:
- dev-types
- '[0-9]+.[0-9]+.[0-9]+'
branches:
- dev
- master
pull_request:
permissions:
contents: write
pull-requests: write
id-token: write
jobs:
ci:
runs-on: ubuntu-latest
permissions:
id-token: write
id-token: write # for npm publish
actions: read # for Nerivec/action-ci-bench
pull-requests: write # for Nerivec/action-ci-bench
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
with:
# Required for `release: merge dev -> master and promote dev`
token: ${{ secrets.GH_TOKEN }}
- uses: actions/checkout@v4
- uses: actions/checkout@v6
if: ((github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push') == false
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: 24
registry-url: https://registry.npmjs.org/
@@ -47,16 +51,26 @@ jobs:
- name: Test
run: pnpm run test:coverage
- name: Bench
if: github.ref == 'refs/heads/dev' || (github.event_name == 'pull_request' && !startsWith(github.head_ref, 'release-please-'))
uses: Nerivec/action-ci-bench@main
with:
token: ${{ secrets.GITHUB_TOKEN }}
compare-against: dev
base-cmd: npx vitest bench --run --config ./test/vitest.config.mts --outputJson bench.json
compare-cmd: npx vitest bench --run --config ./test/vitest.config.mts --compare bench.json
base-result: bench.json
- name: Log in to the Docker container registry
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: koenkk
password: ${{ secrets.DOCKER_KEY }}
- name: Log in to the GitHub container registry
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: koenkk
@@ -64,20 +78,20 @@ jobs:
- name: Docker setup - QEMU
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
with:
platforms: all
- name: Docker setup - Buildx
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
id: buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
with:
version: latest
- name: dev - Docker build and push
if: github.ref == 'refs/heads/dev' && github.event_name == 'push'
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
file: docker/Dockerfile
@@ -99,6 +113,11 @@ jobs:
git checkout dev-types
rm -rf *
cp -R $temp_dir/* .
# remove unnecessary dependencies
KEEP_DEPS='["zigbee-herdsman","zigbee-herdsman-converters"]'
tmp_pkg=$(mktemp)
jq --indent 4 --argjson keep "$KEEP_DEPS" 'def filterDeps: (. // {}) | with_entries(select(.key as $k | $keep | index($k))); .dependencies |= filterDeps | .devDependencies |= filterDeps | .optionalDependencies |= filterDeps' package.json > "$tmp_pkg"
mv "$tmp_pkg" package.json
git add -A
git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
@@ -107,7 +126,7 @@ jobs:
- name: release - Docker meta
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
id: meta
with:
images: |
@@ -120,7 +139,7 @@ jobs:
- name: release - Docker build and push
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
file: docker/Dockerfile
@@ -201,11 +220,11 @@ jobs:
runs-on: ${{ matrix.os }}
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: pnpm
+1 -1
View File
@@ -10,6 +10,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: 'Checkout repository'
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: 'Dependency review'
uses: actions/dependency-review-action@v4
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
steps:
- name: Delete untagged images
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
github-token: ${{ secrets.GH_TOKEN }}
script: |
+38
View File
@@ -0,0 +1,38 @@
name: Issue Bot
on:
issues:
types: [opened, edited]
permissions:
issues: write
contents: read
jobs:
comment:
runs-on: ubuntu-latest
if: |
contains(github.event.issue.labels.*.name, 'new device support') ||
contains(github.event.issue.labels.*.name, 'external converter') ||
startsWith(github.event.issue.title, '[New device support]') ||
startsWith(github.event.issue.title, '[External Converter]')
steps:
- uses: actions/checkout@v6
with:
sparse-checkout: |
scripts
path: z2m
- uses: actions/checkout@v6
with:
repository: Koenkk/zigbee-herdsman-converters
ref: master
fetch-depth: 1
path: zhc
- name: Comment on new device support/external converter issue
uses: actions/github-script@v8
with:
script: |
const {newDeviceSupport} = await import("${{ github.workspace }}/z2m/scripts/issueBot.mjs")
await newDeviceSupport(github, core, context, "${{ github.workspace }}/zhc")
+1 -1
View File
@@ -9,7 +9,7 @@ jobs:
merge-master-to-dev:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: devmasx/merge-branch@master
with:
type: now
+8 -7
View File
@@ -16,11 +16,11 @@ jobs:
release_created: ${{ steps.release.outputs.release_created }}
version: '${{steps.release.outputs.major}}.${{steps.release.outputs.minor}}.${{steps.release.outputs.patch}}'
steps:
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v5
with:
version: 9
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: 24
@@ -31,18 +31,18 @@ jobs:
token: ${{secrets.GH_TOKEN}}
# Checkout repos
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
repository: koenkk/zigbee2mqtt
path: ./z2m
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
repository: koenkk/zigbee2mqtt
path: ./z2m-master
ref: master
- name: Restore cache commit-user-lookup.json
uses: actions/cache/restore@v4
uses: actions/cache/restore@v5
with:
path: z2m/scripts/commit-user-lookup.json
key: commit-user-lookup-dummy
@@ -54,10 +54,11 @@ jobs:
MASTER_ZHC_VERSION=$(cat z2m-master/package.json | jq -r '.dependencies."zigbee-herdsman-converters"')
MASTER_ZH_VERSION=$(cat z2m-master/package.json | jq -r '.dependencies."zigbee-herdsman"')
MASTER_FRONTEND_VERSION=$(cat z2m-master/package.json | jq -r '.dependencies."zigbee2mqtt-frontend"')
MASTER_WINDFRONT_VERSION=$(cat z2m-master/package.json | jq -r '.dependencies."zigbee2mqtt-windfront"')
wget -q -O - https://raw.githubusercontent.com/Koenkk/zigbee2mqtt/release-please--branches--dev--components--zigbee2mqtt/CHANGELOG.md > z2m/CHANGELOG.md
cd z2m
pnpm i --frozen-lockfile
node scripts/generateChangelog.js $MASTER_Z2M_VERSION $MASTER_ZHC_VERSION $MASTER_ZH_VERSION $MASTER_FRONTEND_VERSION >> ../changelog.md
node scripts/generateChangelog.mjs $MASTER_Z2M_VERSION $MASTER_ZHC_VERSION $MASTER_ZH_VERSION $MASTER_FRONTEND_VERSION $MASTER_WINDFRONT_VERSION >> ../changelog.md
env:
GH_TOKEN: ${{secrets.GH_TOKEN}}
- name: Update changelog gist
@@ -66,7 +67,7 @@ jobs:
env:
GH_TOKEN: ${{secrets.GH_TOKEN}}
- name: Save cache commit-user-lookup.json
uses: actions/cache/save@v4
uses: actions/cache/save@v5
if: always()
with:
path: z2m/scripts/commit-user-lookup.json
+1 -1
View File
@@ -9,7 +9,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
- uses: actions/stale@v10
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days'
+4 -4
View File
@@ -13,19 +13,19 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
ref: dev
token: ${{ secrets.GH_TOKEN }}
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- run: |
pnpm install ${{ github.event.client_payload.package }}@${{ github.event.client_payload.version }} --save-exact
pnpm install --no-frozen-lockfile
- uses: peter-evans/create-pull-request@v7
- uses: peter-evans/create-pull-request@v8
id: cpr
with:
commit-message: 'fix(ignore): update ${{ github.event.client_payload.package }} to ${{ github.event.client_payload.version }}'
+1
View File
@@ -77,6 +77,7 @@ tsconfig.types.tsbuildinfo
# Ignore config
data/*
!data/configuration.example.yaml
data-bench
# commit-user-lookup.json
scripts/commit-user-lookup.json
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "2.5.1"
".": "2.9.2"
}
+386
View File
@@ -0,0 +1,386 @@
# AGENTS.md
## Project Overview
Zigbee2MQTT is a Zigbee to MQTT bridge that allows you to use your Zigbee devices without the vendor's bridge or gateway. It bridges events and allows you to control Zigbee devices via MQTT, integrating them with any smart home infrastructure.
### Architecture
- **Language**: TypeScript 5.9.3 compiled to JavaScript (ES modules with NodeNext resolution)
- **Runtime**: Node.js (versions 20, 22, or 24)
- **Package Manager**: pnpm 10.12.1 (strictly enforced via `packageManager` field)
- **Core Dependencies**:
- `zigbee-herdsman` (6.2.0 - exact version, handles Zigbee adapter communication)
- `zigbee-herdsman-converters` (25.42.0 - exact version, device definitions)
- `mqtt` (5.14.1 - MQTT client)
- `winston` (3.18.3 - logging)
### Project Structure
```
lib/ # TypeScript source code
├── controller.ts # Main controller orchestrating components
├── mqtt.ts # MQTT client management
├── zigbee.ts # Zigbee network management
├── state.ts # State management
├── eventBus.ts # Event-driven communication
├── extension/ # Extension system (plugins)
│ └── extension.ts # Abstract base class
├── model/ # Domain models (Device, Group)
├── util/ # Utility functions
└── types/ # TypeScript type definitions
test/ # Vitest test files with mocks
data/ # Runtime configuration and database
dist/ # Compiled JavaScript output
```
## Setup Commands
### Prerequisites
- Node.js version 20, 22, or 24
- pnpm 10.12.1 (will be auto-installed via corepack if not present)
### Installation
```bash
# Install dependencies (uses pnpm lockfile)
pnpm install --frozen-lockfile
# For development without lockfile restrictions
pnpm install
```
### Initial Build
```bash
# Full build (TypeScript compilation + hash generation)
pnpm run build
# Build type definitions only
pnpm run build:types
```
## Development Workflow
### Starting Development
```bash
# Watch mode - recompile on file changes
pnpm run build:watch
# In another terminal, start Zigbee2MQTT
pnpm start
```
### Code Quality Checks
```bash
# Run Biome linter and formatter (check only)
pnpm run check
# Auto-fix linting and formatting issues
pnpm run check:w
# The check runs with --error-on-warnings flag
# Configuration: biome.json (4-space indent, 150 line width, no bracket spacing)
```
### Clean Build
```bash
# Remove build artifacts
pnpm run clean
# Removes: coverage/, dist/, tsconfig.tsbuildinfo
```
## Testing Instructions
### Running Tests
```bash
# Run all tests once
pnpm test
# Run tests with coverage report
pnpm run test:coverage
# Watch mode - re-run tests on changes
pnpm run test:watch
# Run benchmarks
pnpm run bench
```
### Test Requirements
- **Coverage**: 100% code coverage is enforced (configured in `test/vitest.config.mts`)
- **Framework**: Vitest 3.1.1 with @vitest/coverage-v8
- **Test Files**: Located in `test/` directory with `.test.ts` extension
- **Mocks**: Centralized in `test/mocks/` directory
- **Coverage Report**: Generated in `coverage/` directory (HTML report at `coverage/index.html`)
### Running Specific Tests
```bash
# Run tests matching a pattern
pnpm vitest run -t "test name pattern" --config ./test/vitest.config.mts
# Run specific test file
pnpm vitest run test/controller.test.ts --config ./test/vitest.config.mts
# Focus on one test area in watch mode
pnpm vitest watch -t "Extension" --config ./test/vitest.config.mts
```
## Code Style Guidelines
### TypeScript Conventions
- **Module System**: ES modules with NodeNext resolution
- **Target**: ESNext
- **Strict Mode**: Enabled (`noImplicitAny`, `noImplicitThis`)
- **Decorators**: Experimental decorators enabled (used for `@bind` from `bind-decorator`)
### Import Order
1. Node.js built-in modules (with `node:` prefix)
2. Third-party libraries
3. Type-only imports from external packages (using `type` keyword)
4. Internal absolute imports
5. Type-only imports from internal modules
Example:
```typescript
import fs from "node:fs";
import bind from "bind-decorator";
import type {IClientOptions} from "mqtt";
import {connectAsync} from "mqtt";
import type {Zigbee2MQTTAPI} from "./types/api";
import logger from "./util/logger";
```
### Naming Conventions
- **Classes**: PascalCase (e.g., `Extension`, `Device`)
- **Functions/Methods**: camelCase (e.g., `publishEntityState`)
- **Constants**: SCREAMING_SNAKE_CASE (e.g., `CURRENT_VERSION`)
- **Interfaces/Types**: PascalCase (e.g., `MqttPublishOptions`)
- **Files**: camelCase for TypeScript (e.g., `eventBus.ts`)
### Code Patterns
- **Async/Await**: Always use async/await, explicitly type return as `Promise<Type>`
- **Error Handling**: Use `throw new Error("message")`, log with winston logger
- **Event Handlers**: Use `@bind` decorator to preserve `this` context
- **Logging**: Use `logger.info()`, `logger.warning()`, `logger.error()`, `logger.debug()`
### Formatting Rules (Biome)
- 4-space indentation
- 150 character line width
- No bracket spacing in objects
- Run `pnpm run check:w` to auto-format
## Build and Deployment
### Build Process
```bash
# Production build
pnpm run build
# Outputs:
# - Compiled JavaScript in dist/
# - Type definitions in dist/types/
# - Includes hash generation for version tracking
```
### Pre-publish
```bash
# Automatically runs before publishing
pnpm run prepack
# Performs: clean + build
```
### Environment Setup
- Configuration stored in `data/configuration.yaml`
- Database in `data/database.db`
- Logs in `data/log/`
- External extensions in `data/external_extensions/`
- External converters in `data/external_converters/`
## Architecture Patterns
### Extension System
All features are implemented as extensions that inherit from the abstract `Extension` base class:
```typescript
abstract class Extension {
protected zigbee: Zigbee;
protected mqtt: Mqtt;
protected state: State;
protected publishEntityState: PublishEntityState;
protected eventBus: EventBus;
async start(): Promise<void> {} // Initialize extension
async stop(): Promise<void> {} // Cleanup extension
}
```
**Key Points**:
- Constructor should only assign properties (no side effects)
- Initialization happens in `start()` method
- Use EventBus for inter-component communication
- Extensions are loaded and managed by the Controller
### Event-Driven Communication
Components communicate via the strongly-typed EventBus:
```typescript
// Emit events
this.eventBus.emit('deviceMessage', {device, message});
// Listen to events
this.eventBus.on('deviceMessage', this.onDeviceMessage, this);
```
### Dependency Injection
The Controller instantiates and injects dependencies into all extensions. Follow this pattern when creating new extensions.
## Pull Request Guidelines
### Target Branch
- **Always create PRs against the `dev` branch**
- The `master` branch is for production releases only
### Before Submitting
```bash
# Run all checks
pnpm run check
pnpm test
# Ensure 100% code coverage
pnpm run test:coverage
# Build successfully
pnpm run build
```
### PR Requirements
- All CI checks must pass (linting, tests, build)
- 100% test coverage maintained
- Code follows Biome formatting rules
- Commit messages should be descriptive
- Reference related issues when applicable
### CI Pipeline
The GitHub Actions CI workflow (`.github/workflows/ci.yml`) runs:
1. Biome code quality checks (`pnpm run check`)
2. TypeScript compilation (`pnpm run build`)
3. Full test suite with coverage (`pnpm run test:coverage`)
4. Benchmarks (on dev branch and PRs)
5. Docker image builds (on dev branch and tags)
## Working with Device Support
### Adding New Devices
**Important**: Device support is NOT added to this repository. All device definitions live in `zigbee-herdsman-converters`.
- Follow the guide at: https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html
- No changes to zigbee2mqtt codebase are needed for new devices
- Device definitions are automatically picked up from `zigbee-herdsman-converters`
## Debugging and Troubleshooting
### Development Setup
For the easiest development experience, set up a bare-metal installation following:
https://www.zigbee2mqtt.io/guide/installation/01_linux.html
### Logging
- Winston logger is initialized in `lib/util/logger.ts`
- Log levels: `error`, `warning`, `info`, `debug`
- Logs are written to console and/or file based on configuration
- Use structured logging with context (device names, IEEE addresses)
### Common Issues
1. **Import errors after file moves**: Run `pnpm run check` to verify TypeScript and ESLint
2. **Test failures**: Check if mocks in `test/mocks/` need updates
3. **Build errors**: Ensure Node.js version is 20, 22, or 24
4. **Coverage issues**: View HTML report at `coverage/index.html` to identify uncovered code
### Performance Considerations
- Use `rimrafSync` for synchronous file operations
- Leverage async/await to avoid blocking
- Cache computed values in getters when appropriate
- EventBus provides loose coupling between components
## Critical Version Requirements
### Exact Versions
These dependencies use **exact versions** (no semver ranges) - do not upgrade without thorough testing:
- `zigbee-herdsman@6.2.0` - Critical for Zigbee protocol compatibility
- `zigbee-herdsman-converters@25.42.0` - Device definitions must match herdsman version
### Node.js Compatibility
Only these Node.js versions are supported:
- Node.js 20.x
- Node.js 22.x
- Node.js 24.x
Using other versions may cause runtime errors or incompatibilities.
## Additional Notes
### Package Manager
This project **requires pnpm 10.12.1**. The `packageManager` field in package.json enforces this via Corepack.
Do not use npm or yarn - they will not respect the pnpm-specific configuration.
### TypeScript Compilation
- Source files in `lib/` are compiled to `dist/`
- Type definitions exported from `dist/types/api.d.ts`
- Source maps are inlined for debugging
- Uses composite project references for faster incremental builds
### External Extensions
To load external extensions:
1. Place JavaScript files in `data/external_extensions/`
2. They will be automatically loaded on startup
3. No configuration changes needed
### Code Quality Tools
- **Linting/Formatting**: Biome 2.2.5 (replaces ESLint + Prettier)
- **Type Checking**: TypeScript 5.9.3
- **Testing**: Vitest 3.1.1
- **Coverage**: @vitest/coverage-v8
### Documentation
- Main documentation: https://www.zigbee2mqtt.io/
- Contributing guide: `CONTRIBUTING.md`
- Coding standards: `.github/copilot-instructions.md`
- Issue tracker: https://github.com/Koenkk/zigbee2mqtt/issues
+449 -1
View File
@@ -1,5 +1,453 @@
# Changelog
## [2.9.2](https://github.com/Koenkk/zigbee2mqtt/compare/2.9.1...2.9.2) (2026-03-31)
### Bug Fixes
* Export definition version ([#31270](https://github.com/Koenkk/zigbee2mqtt/issues/31270)) ([9f1eb76](https://github.com/Koenkk/zigbee2mqtt/commit/9f1eb764a62b041a7d9782066a8ffcaa3562eaad))
* Home Assistant: fix entity names for derived weather sensors by removing `device_class` ([#31234](https://github.com/Koenkk/zigbee2mqtt/issues/31234)) ([b7b4303](https://github.com/Koenkk/zigbee2mqtt/commit/b7b43030eb8aaa144dbb5477805638d860efdd38))
* **ignore:** bump @biomejs/biome from 2.4.4 to 2.4.5 in the minor-patch group ([#31237](https://github.com/Koenkk/zigbee2mqtt/issues/31237)) ([aefbb8b](https://github.com/Koenkk/zigbee2mqtt/commit/aefbb8be14ae5d84495278cbe9a6ca9d750553cb))
* **ignore:** bump the minor-patch group with 2 updates ([#31411](https://github.com/Koenkk/zigbee2mqtt/issues/31411)) ([67aa509](https://github.com/Koenkk/zigbee2mqtt/commit/67aa5099df2371eb9ee220be7a8821f0d4f8b38b))
* **ignore:** bump the minor-patch group with 3 updates ([#31295](https://github.com/Koenkk/zigbee2mqtt/issues/31295)) ([b7f1cec](https://github.com/Koenkk/zigbee2mqtt/commit/b7f1cecb49f088b7fc79dcef068034ca1d012c1d))
* **ignore:** bump the minor-patch group with 3 updates ([#31489](https://github.com/Koenkk/zigbee2mqtt/issues/31489)) ([52b063b](https://github.com/Koenkk/zigbee2mqtt/commit/52b063bb32429e14b30319c905e596b7632bd674))
* **ignore:** bump the minor-patch group with 3 updates ([#31559](https://github.com/Koenkk/zigbee2mqtt/issues/31559)) ([73d8ae8](https://github.com/Koenkk/zigbee2mqtt/commit/73d8ae895613af0d77f0a684f7305d688af11146))
* **ignore:** update zigbee-herdsman to 10.0.1 ([#31383](https://github.com/Koenkk/zigbee2mqtt/issues/31383)) ([6fca374](https://github.com/Koenkk/zigbee2mqtt/commit/6fca3744c9715dd4afb7fc777cf783c84bfb91fc))
* **ignore:** update zigbee-herdsman to 10.0.2 ([#31413](https://github.com/Koenkk/zigbee2mqtt/issues/31413)) ([075f87c](https://github.com/Koenkk/zigbee2mqtt/commit/075f87c7822c29c74fc1c4031866aca8c445c9be))
* **ignore:** update zigbee-herdsman to 10.0.3 ([#31450](https://github.com/Koenkk/zigbee2mqtt/issues/31450)) ([6fb9d56](https://github.com/Koenkk/zigbee2mqtt/commit/6fb9d563e69c2db3b31fd0f163e5f30924047e3f))
* **ignore:** update zigbee-herdsman to 10.0.4 ([#31478](https://github.com/Koenkk/zigbee2mqtt/issues/31478)) ([867179c](https://github.com/Koenkk/zigbee2mqtt/commit/867179c430eacf9bd66ef359b9a7435f2299d944))
* **ignore:** update zigbee-herdsman to 10.0.5 ([#31562](https://github.com/Koenkk/zigbee2mqtt/issues/31562)) ([fb3189b](https://github.com/Koenkk/zigbee2mqtt/commit/fb3189b7431cb203092bbe235bd35194e2b44a44))
* **ignore:** update zigbee-herdsman to 9.0.10 ([#31245](https://github.com/Koenkk/zigbee2mqtt/issues/31245)) ([d632382](https://github.com/Koenkk/zigbee2mqtt/commit/d6323825e4ae3168d52a096e7647bc7174d3cdb1))
* **ignore:** update zigbee-herdsman to 9.0.11 ([#31257](https://github.com/Koenkk/zigbee2mqtt/issues/31257)) ([1747618](https://github.com/Koenkk/zigbee2mqtt/commit/1747618afa3948ac9fb8899d94fba8456198e4b5))
* **ignore:** update zigbee-herdsman-converters to 26.13.0 ([#31231](https://github.com/Koenkk/zigbee2mqtt/issues/31231)) ([fc3a196](https://github.com/Koenkk/zigbee2mqtt/commit/fc3a1968f6635d246ad2d68e5c6b79d495fd96bb))
* **ignore:** update zigbee-herdsman-converters to 26.14.0 ([#31246](https://github.com/Koenkk/zigbee2mqtt/issues/31246)) ([284d86e](https://github.com/Koenkk/zigbee2mqtt/commit/284d86e93ba8cd83139d6dd0217519f67c01e565))
* **ignore:** update zigbee-herdsman-converters to 26.16.1 ([#31268](https://github.com/Koenkk/zigbee2mqtt/issues/31268)) ([05946b5](https://github.com/Koenkk/zigbee2mqtt/commit/05946b59552608c6ae8a8b02098552fe611f027a))
* **ignore:** update zigbee-herdsman-converters to 26.17.0 ([#31314](https://github.com/Koenkk/zigbee2mqtt/issues/31314)) ([9143924](https://github.com/Koenkk/zigbee2mqtt/commit/91439244823d9897998a57448321d2e8af9b083f))
* **ignore:** update zigbee-herdsman-converters to 26.18.0 ([#31362](https://github.com/Koenkk/zigbee2mqtt/issues/31362)) ([f60db2d](https://github.com/Koenkk/zigbee2mqtt/commit/f60db2d7a641ab5fccbb3a7b6a0e1e73983559ef))
* **ignore:** update zigbee-herdsman-converters to 26.19.1 ([#31384](https://github.com/Koenkk/zigbee2mqtt/issues/31384)) ([69641d5](https://github.com/Koenkk/zigbee2mqtt/commit/69641d5467ec5e3ddfebb4ff98142d0ff990c814))
* **ignore:** update zigbee-herdsman-converters to 26.20.0 ([#31415](https://github.com/Koenkk/zigbee2mqtt/issues/31415)) ([9f49d0e](https://github.com/Koenkk/zigbee2mqtt/commit/9f49d0ec6738ebd1ea65a3bf1aae553548217f2e))
* **ignore:** update zigbee-herdsman-converters to 26.21.0 ([#31427](https://github.com/Koenkk/zigbee2mqtt/issues/31427)) ([e054d71](https://github.com/Koenkk/zigbee2mqtt/commit/e054d7124bd4dc5bdb18c9de4a6923b06df6b929))
* **ignore:** update zigbee-herdsman-converters to 26.22.0 ([#31451](https://github.com/Koenkk/zigbee2mqtt/issues/31451)) ([64286eb](https://github.com/Koenkk/zigbee2mqtt/commit/64286ebb3054bc15e020aab431c1d3af76e2c914))
* **ignore:** update zigbee-herdsman-converters to 26.23.0 ([#31462](https://github.com/Koenkk/zigbee2mqtt/issues/31462)) ([2e5e362](https://github.com/Koenkk/zigbee2mqtt/commit/2e5e3628188765cddbd4116f41703eab491670df))
* **ignore:** update zigbee-herdsman-converters to 26.24.0 ([#31479](https://github.com/Koenkk/zigbee2mqtt/issues/31479)) ([dde637a](https://github.com/Koenkk/zigbee2mqtt/commit/dde637af2f8449ae24684fa34a32bec106c24913))
* **ignore:** update zigbee-herdsman-converters to 26.25.0 ([#31497](https://github.com/Koenkk/zigbee2mqtt/issues/31497)) ([66985a8](https://github.com/Koenkk/zigbee2mqtt/commit/66985a8ce8d4cefbc4ddacc754e182e0b5953cea))
* **ignore:** update zigbee-herdsman-converters to 26.26.0 ([#31510](https://github.com/Koenkk/zigbee2mqtt/issues/31510)) ([969db9a](https://github.com/Koenkk/zigbee2mqtt/commit/969db9ae85838b955e07a80f5f72f655f6beffcf))
* **ignore:** update zigbee-herdsman-converters to 26.27.0 ([#31563](https://github.com/Koenkk/zigbee2mqtt/issues/31563)) ([62ab814](https://github.com/Koenkk/zigbee2mqtt/commit/62ab814d73b7de957fb8b28679b9dea8ae087133))
* Match new cluster-related typing from ZH ([#31298](https://github.com/Koenkk/zigbee2mqtt/issues/31298)) ([0f781ee](https://github.com/Koenkk/zigbee2mqtt/commit/0f781ee9c03ae502745841f4dd7d6f3775c620d4))
* Reintroduce onboarding improvements ([#31273](https://github.com/Koenkk/zigbee2mqtt/issues/31273)) ([4a83692](https://github.com/Koenkk/zigbee2mqtt/commit/4a8369295c5d6c07ff1ac66f90a35b0e8b915296))
## [2.9.1](https://github.com/Koenkk/zigbee2mqtt/compare/2.9.0...2.9.1) (2026-03-02)
### Bug Fixes
* Fix onboarding ([#31228](https://github.com/Koenkk/zigbee2mqtt/issues/31228)) ([7f0a9aa](https://github.com/Koenkk/zigbee2mqtt/commit/7f0a9aa2a30ab79c182d8ea9e875339e9031fdbd))
## [2.9.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.8.0...2.9.0) (2026-03-01)
### Features
* Improve onboarding ([#31152](https://github.com/Koenkk/zigbee2mqtt/issues/31152)) ([8ecf353](https://github.com/Koenkk/zigbee2mqtt/commit/8ecf353dd634cc56f8cbbfbe21646945c262a11e))
### Bug Fixes
* Fix Home Assistant options cannot be updated while running ([#31151](https://github.com/Koenkk/zigbee2mqtt/issues/31151)) ([a77f48f](https://github.com/Koenkk/zigbee2mqtt/commit/a77f48f5c464903712f7143bb6a85ea701a39c5e))
* Home Assistant: add `measurement` `state_class` to `_count` entities ([#30978](https://github.com/Koenkk/zigbee2mqtt/issues/30978)) ([d475aa5](https://github.com/Koenkk/zigbee2mqtt/commit/d475aa5be5caa85a5dc3f0883dc40b2bcddc09b2))
* Home Assistant: add `state_class: measurement` for `external_humidity` and `occupancy_level` https://github.com/Koenkk/zigbee2mqtt/issues/31022 ([02a2fd8](https://github.com/Koenkk/zigbee2mqtt/commit/02a2fd8fef786be9488c1adc97093b1ec6bff9e1))
* Home Assistant: add weather station sensor discovery lookup entries ([#31136](https://github.com/Koenkk/zigbee2mqtt/issues/31136)) ([8d1240e](https://github.com/Koenkk/zigbee2mqtt/commit/8d1240eba161e8eb92ce6fdda757710a07c4456e))
* Home Assistant: discover `temperature_probe` as `temperature` `device_class` https://github.com/Koenkk/zigbee2mqtt/issues/30862 ([8d8d37e](https://github.com/Koenkk/zigbee2mqtt/commit/8d8d37e3888cbfa4899831874ee9df6e306ddb52))
* Home Assistant: expose siren entity for IAS warning devices ([#31000](https://github.com/Koenkk/zigbee2mqtt/issues/31000)) ([c7dae39](https://github.com/Koenkk/zigbee2mqtt/commit/c7dae397999102ec30c6681bc6a7577e97c8b707))
* Home Assistant: set `state_class` `measurement` for `external_temperature` https://github.com/Koenkk/zigbee2mqtt/issues/31022 ([28a9f39](https://github.com/Koenkk/zigbee2mqtt/commit/28a9f39dbcd5a04e5eb5630855de2b82ec676dac))
* **ignore:** bump @biomejs/biome from 2.4.1 to 2.4.4 in the minor-patch group ([#31159](https://github.com/Koenkk/zigbee2mqtt/issues/31159)) ([1d5184f](https://github.com/Koenkk/zigbee2mqtt/commit/1d5184f2bd46afa50faa470a97e807888ed81a78))
* **ignore:** bump mqtt from 5.14.1 to 5.15.0 in the minor-patch group ([#30918](https://github.com/Koenkk/zigbee2mqtt/issues/30918)) ([4b03910](https://github.com/Koenkk/zigbee2mqtt/commit/4b039103c0a9bd32458011dbc2e7bea0e609d0c7))
* **ignore:** bump the minor-patch group with 2 updates ([#31211](https://github.com/Koenkk/zigbee2mqtt/issues/31211)) ([89192f9](https://github.com/Koenkk/zigbee2mqtt/commit/89192f9decdf41338ec925c88860b560906b3ad4))
* **ignore:** bump the minor-patch group with 3 updates ([#31011](https://github.com/Koenkk/zigbee2mqtt/issues/31011)) ([15e385b](https://github.com/Koenkk/zigbee2mqtt/commit/15e385bac6dbfd87869df87f1be9a66cf4db76f1))
* **ignore:** bump the minor-patch group with 4 updates ([#31081](https://github.com/Koenkk/zigbee2mqtt/issues/31081)) ([42d88fe](https://github.com/Koenkk/zigbee2mqtt/commit/42d88fe90d6272f2c8241ac7a6e6b2c5ce5a55af))
* **ignore:** Changes for new ZHC `definition.version` ([#30402](https://github.com/Koenkk/zigbee2mqtt/issues/30402)) ([b71461e](https://github.com/Koenkk/zigbee2mqtt/commit/b71461e03cd8ceea5365f4c0b2e794e34341325e))
* **ignore:** Fix enableDisableExtension ([#31176](https://github.com/Koenkk/zigbee2mqtt/issues/31176)) ([e1e856d](https://github.com/Koenkk/zigbee2mqtt/commit/e1e856d7da9c92952c3de94853794337fea1945d))
* **ignore:** update zigbee-herdsman to 9.0.3 ([#30960](https://github.com/Koenkk/zigbee2mqtt/issues/30960)) ([30d311e](https://github.com/Koenkk/zigbee2mqtt/commit/30d311e8494c00e6ade7759a0a2bfdc9ab79022d))
* **ignore:** update zigbee-herdsman to 9.0.4 ([#30996](https://github.com/Koenkk/zigbee2mqtt/issues/30996)) ([bf888de](https://github.com/Koenkk/zigbee2mqtt/commit/bf888de4789b01e824ca6b5f82506691b4f689a9))
* **ignore:** update zigbee-herdsman to 9.0.5 ([#31117](https://github.com/Koenkk/zigbee2mqtt/issues/31117)) ([5957085](https://github.com/Koenkk/zigbee2mqtt/commit/5957085fcc8d6acdedf426a3f16632f1de55a061))
* **ignore:** update zigbee-herdsman to 9.0.6 ([#31149](https://github.com/Koenkk/zigbee2mqtt/issues/31149)) ([8560c11](https://github.com/Koenkk/zigbee2mqtt/commit/8560c11e0667304a643eac907d3c62e35bcc2e23))
* **ignore:** update zigbee-herdsman to 9.0.7 ([#31174](https://github.com/Koenkk/zigbee2mqtt/issues/31174)) ([79bcf27](https://github.com/Koenkk/zigbee2mqtt/commit/79bcf276f57454a77ea76b6d8286544abd4d7cca))
* **ignore:** update zigbee-herdsman to 9.0.8 ([#31207](https://github.com/Koenkk/zigbee2mqtt/issues/31207)) ([c7d54ec](https://github.com/Koenkk/zigbee2mqtt/commit/c7d54ec360ee34ba4cb062dcd474154fb70467a5))
* **ignore:** update zigbee-herdsman to 9.0.9 ([#31215](https://github.com/Koenkk/zigbee2mqtt/issues/31215)) ([8ee5b1f](https://github.com/Koenkk/zigbee2mqtt/commit/8ee5b1f03bdc06062d58913e827f086b6bbcb026))
* **ignore:** update zigbee-herdsman-converters to 25.121.0 ([#30898](https://github.com/Koenkk/zigbee2mqtt/issues/30898)) ([09d010e](https://github.com/Koenkk/zigbee2mqtt/commit/09d010ea067446d26b6c77411aac78d045dbc6ea))
* **ignore:** update zigbee-herdsman-converters to 25.122.0 ([#30935](https://github.com/Koenkk/zigbee2mqtt/issues/30935)) ([402ed60](https://github.com/Koenkk/zigbee2mqtt/commit/402ed6094eedb40ecdb841b4b1f7d29d59afc89c))
* **ignore:** update zigbee-herdsman-converters to 26.0.0 ([6cce05a](https://github.com/Koenkk/zigbee2mqtt/commit/6cce05a5141089e24211d85d1d77d09f8d2b36f0))
* **ignore:** update zigbee-herdsman-converters to 26.1.0 ([#30981](https://github.com/Koenkk/zigbee2mqtt/issues/30981)) ([f1156db](https://github.com/Koenkk/zigbee2mqtt/commit/f1156db227bf937354d6ab4e70d6da59307dedad))
* **ignore:** update zigbee-herdsman-converters to 26.10.1 ([#31175](https://github.com/Koenkk/zigbee2mqtt/issues/31175)) ([b9d9e5e](https://github.com/Koenkk/zigbee2mqtt/commit/b9d9e5e75c3f175ec0fc69f6315c5ca75a8e8128))
* **ignore:** update zigbee-herdsman-converters to 26.11.0 ([#31185](https://github.com/Koenkk/zigbee2mqtt/issues/31185)) ([c880d36](https://github.com/Koenkk/zigbee2mqtt/commit/c880d36f29a8f146170cae34bef15b86d01f5889))
* **ignore:** update zigbee-herdsman-converters to 26.12.0 ([#31208](https://github.com/Koenkk/zigbee2mqtt/issues/31208)) ([a9a6ebe](https://github.com/Koenkk/zigbee2mqtt/commit/a9a6ebe00743b47bd00667ae64d0f0fb25f5e38d))
* **ignore:** update zigbee-herdsman-converters to 26.2.0 ([#30998](https://github.com/Koenkk/zigbee2mqtt/issues/30998)) ([b585e31](https://github.com/Koenkk/zigbee2mqtt/commit/b585e3130431c7c27b18b9779ed038b9e6dd30c6))
* **ignore:** update zigbee-herdsman-converters to 26.2.1 ([#31015](https://github.com/Koenkk/zigbee2mqtt/issues/31015)) ([4e360d0](https://github.com/Koenkk/zigbee2mqtt/commit/4e360d0bf2fed651f7c801bcce8455dc8cf4d649))
* **ignore:** update zigbee-herdsman-converters to 26.3.0 ([#31028](https://github.com/Koenkk/zigbee2mqtt/issues/31028)) ([c599e19](https://github.com/Koenkk/zigbee2mqtt/commit/c599e19bf2252b90554ec3a68abd64487acd8882))
* **ignore:** update zigbee-herdsman-converters to 26.4.0 ([#31036](https://github.com/Koenkk/zigbee2mqtt/issues/31036)) ([142754b](https://github.com/Koenkk/zigbee2mqtt/commit/142754b71cf99ac97d5d25d6a124c90d17bf321d))
* **ignore:** update zigbee-herdsman-converters to 26.5.0 ([#31058](https://github.com/Koenkk/zigbee2mqtt/issues/31058)) ([061262f](https://github.com/Koenkk/zigbee2mqtt/commit/061262ff97c5f30571f77df5b06362023c282fa6))
* **ignore:** update zigbee-herdsman-converters to 26.6.0 ([#31076](https://github.com/Koenkk/zigbee2mqtt/issues/31076)) ([909d2b3](https://github.com/Koenkk/zigbee2mqtt/commit/909d2b32b7ee7fe9144351b82d174e31fd0bad40))
* **ignore:** update zigbee-herdsman-converters to 26.7.0 ([#31090](https://github.com/Koenkk/zigbee2mqtt/issues/31090)) ([9d566f4](https://github.com/Koenkk/zigbee2mqtt/commit/9d566f4c015e7a31adaeef3b04bcc3c2312d583f))
* **ignore:** update zigbee-herdsman-converters to 26.8.0 ([#31118](https://github.com/Koenkk/zigbee2mqtt/issues/31118)) ([396eb38](https://github.com/Koenkk/zigbee2mqtt/commit/396eb38e4b9a329a651dec46ec816be537a3c997))
* **ignore:** update zigbee-herdsman-converters to 26.9.0 ([#31137](https://github.com/Koenkk/zigbee2mqtt/issues/31137)) ([4d287ee](https://github.com/Koenkk/zigbee2mqtt/commit/4d287eed0c2b746e1cfbd18ad61ed7b5129a98d9))
* Improve configure attempts lazy+cleanup ([#31126](https://github.com/Koenkk/zigbee2mqtt/issues/31126)) ([0c43c98](https://github.com/Koenkk/zigbee2mqtt/commit/0c43c983063c1aa01b85318c85bf7101f0085430))
* Improve startup signals behavior ([#31130](https://github.com/Koenkk/zigbee2mqtt/issues/31130)) ([f8385b8](https://github.com/Koenkk/zigbee2mqtt/commit/f8385b88993cfcf3a27c315999c86b961fd8fbfa))
* update zigbee2mqtt-windfront to 2.9.0 ([#31162](https://github.com/Koenkk/zigbee2mqtt/issues/31162)) ([93dc9a3](https://github.com/Koenkk/zigbee2mqtt/commit/93dc9a3664f23a4a13a55e54c16714d721e596d7))
## [2.8.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.7.2...2.8.0) (2026-02-01)
### Features
* Improve OTA ([#30566](https://github.com/Koenkk/zigbee2mqtt/issues/30566)) ([dd1c449](https://github.com/Koenkk/zigbee2mqtt/commit/dd1c44979667360d77f05ac30729011f9f2d6cd6))
### Bug Fixes
* Home Assisant: set state class to `measurement` for `illuminane_raw` https://github.com/Koenkk/zigbee2mqtt/issues/30439 ([3e60f91](https://github.com/Koenkk/zigbee2mqtt/commit/3e60f915ffe1de640690695ccf6536ad235c336e))
* Home Assistant: add `gas` device class ([#30653](https://github.com/Koenkk/zigbee2mqtt/issues/30653)) ([4815d51](https://github.com/Koenkk/zigbee2mqtt/commit/4815d5149d3fbc70fe374fc4aa99213ac5f8a8c7))
* Home Assistant: Change `device_class` of `eco2` to `volatile_organic_compounds_parts` ([#30840](https://github.com/Koenkk/zigbee2mqtt/issues/30840)) ([4f412cb](https://github.com/Koenkk/zigbee2mqtt/commit/4f412cb4da6217fcca57b5ea1afc40d34d662e19))
* Home Assistant: discover temperature sensor for thermostats ([#30804](https://github.com/Koenkk/zigbee2mqtt/issues/30804)) ([26a0fd4](https://github.com/Koenkk/zigbee2mqtt/commit/26a0fd4de0f39d10a404517f42d4c37aff1eff8e))
* Home Assistant: expose group settings override ([#30627](https://github.com/Koenkk/zigbee2mqtt/issues/30627)) ([6f0b02e](https://github.com/Koenkk/zigbee2mqtt/commit/6f0b02e0093cc7fd61d30c00fb1cd61d0cfdf09f))
* Home Assistant: mark `load_estimate` as `state_class` `measurement` https://github.com/Koenkk/zigbee-herdsman-converters/issues/11240 ([a32c8ee](https://github.com/Koenkk/zigbee2mqtt/commit/a32c8eec3604a3ad0d16e87edf027581db7f6920))
* Home Assistant: treat `Area1-4Occupancy` as `occupancy` ([#30712](https://github.com/Koenkk/zigbee2mqtt/issues/30712)) ([2e36084](https://github.com/Koenkk/zigbee2mqtt/commit/2e36084852e84f26dd375820a87c0a8520d4c3e7))
* Home Assistant: Use `temperature_delta` for calibration ([#30784](https://github.com/Koenkk/zigbee2mqtt/issues/30784)) ([8e68295](https://github.com/Koenkk/zigbee2mqtt/commit/8e682956e5977101ed4ca80e795f413a5bfb4f09))
* **ignore:** bump @biomejs/biome from 2.3.10 to 2.3.11 in the minor-patch group ([#30511](https://github.com/Koenkk/zigbee2mqtt/issues/30511)) ([ed52c49](https://github.com/Koenkk/zigbee2mqtt/commit/ed52c49e34a200f9adf5deb2065c78fbe8df3189))
* **ignore:** bump @biomejs/biome from 2.3.12 to 2.3.13 in the minor-patch group ([#30810](https://github.com/Koenkk/zigbee2mqtt/issues/30810)) ([14bffd5](https://github.com/Koenkk/zigbee2mqtt/commit/14bffd58b1ae90cf5c32b654ddbb7a43d12a36cc))
* **ignore:** bump @types/node from 24.10.7 to 24.10.9 in the minor-patch group ([#30713](https://github.com/Koenkk/zigbee2mqtt/issues/30713)) ([043dd0f](https://github.com/Koenkk/zigbee2mqtt/commit/043dd0ff1cc94ffa8ea59f6de674e0363f9f6920))
* **ignore:** bump the minor-patch group with 2 updates ([#30617](https://github.com/Koenkk/zigbee2mqtt/issues/30617)) ([1d87d2f](https://github.com/Koenkk/zigbee2mqtt/commit/1d87d2f2349f9eed6351a032645eec075476f148))
* **ignore:** bump the minor-patch group with 2 updates ([#30790](https://github.com/Koenkk/zigbee2mqtt/issues/30790)) ([da1f77e](https://github.com/Koenkk/zigbee2mqtt/commit/da1f77ec6eb599a480f66cc82edb12acc6c22ca8))
* **ignore:** bump zigbee2mqtt-windfront from 2.8.0 to 2.8.1 in the minor-patch group ([#30879](https://github.com/Koenkk/zigbee2mqtt/issues/30879)) ([9ddbb2a](https://github.com/Koenkk/zigbee2mqtt/commit/9ddbb2a21a0d302ed39ac4c658587d9f33c48960))
* **ignore:** bump zigbee2mqtt-windfront in the minor-patch group ([8d980ae](https://github.com/Koenkk/zigbee2mqtt/commit/8d980ae6ecd759c6c6fb42fc7fbb1d701a3317fa))
* **ignore:** Replace multiple area occupancy fields with camelCase instead of PascalCase ([#30745](https://github.com/Koenkk/zigbee2mqtt/issues/30745)) ([d9e4244](https://github.com/Koenkk/zigbee2mqtt/commit/d9e4244f801aaa24b8347ab684521391df1ce0e2))
* **ignore:** update zigbee-herdsman to 8.0.2 ([#30480](https://github.com/Koenkk/zigbee2mqtt/issues/30480)) ([1147cf1](https://github.com/Koenkk/zigbee2mqtt/commit/1147cf16dd62dce5863a93983835c46e8e92e95d))
* **ignore:** update zigbee-herdsman to 8.0.3 ([#30632](https://github.com/Koenkk/zigbee2mqtt/issues/30632)) ([d44cdbd](https://github.com/Koenkk/zigbee2mqtt/commit/d44cdbde4f5247f69af4c79fd45a62c03037eca7))
* **ignore:** update zigbee-herdsman to 8.1.0 ([#30725](https://github.com/Koenkk/zigbee2mqtt/issues/30725)) ([9d266bd](https://github.com/Koenkk/zigbee2mqtt/commit/9d266bda9a34efbe726f8d984da48e5ba3c46a83))
* **ignore:** update zigbee-herdsman to 9.0.0 ([#30787](https://github.com/Koenkk/zigbee2mqtt/issues/30787)) ([971ddaa](https://github.com/Koenkk/zigbee2mqtt/commit/971ddaa3cbc2132150f7cde4cdbe0575349eabac))
* **ignore:** update zigbee-herdsman to 9.0.1 ([#30788](https://github.com/Koenkk/zigbee2mqtt/issues/30788)) ([6d5e7e3](https://github.com/Koenkk/zigbee2mqtt/commit/6d5e7e334a9a560b9f6293cf448ad84de8504b67))
* **ignore:** update zigbee-herdsman to 9.0.2 ([10478e7](https://github.com/Koenkk/zigbee2mqtt/commit/10478e797e1733f9bf66defce0e84b6cc1f23020))
* **ignore:** update zigbee-herdsman-converters to 25.100.0 ([#30499](https://github.com/Koenkk/zigbee2mqtt/issues/30499)) ([a2b81ab](https://github.com/Koenkk/zigbee2mqtt/commit/a2b81ab89491f8babf40001404969817f19c5b5e))
* **ignore:** update zigbee-herdsman-converters to 25.101.0 ([#30520](https://github.com/Koenkk/zigbee2mqtt/issues/30520)) ([4313bac](https://github.com/Koenkk/zigbee2mqtt/commit/4313baca09b8476466b8134c551b59f92996b53f))
* **ignore:** update zigbee-herdsman-converters to 25.102.0 ([#30537](https://github.com/Koenkk/zigbee2mqtt/issues/30537)) ([57245e5](https://github.com/Koenkk/zigbee2mqtt/commit/57245e52343568678a4dace9bf569c05f508decc))
* **ignore:** update zigbee-herdsman-converters to 25.103.0 ([#30552](https://github.com/Koenkk/zigbee2mqtt/issues/30552)) ([d4522c5](https://github.com/Koenkk/zigbee2mqtt/commit/d4522c53caca86ba3a087fa67839e2ebf5d9deca))
* **ignore:** update zigbee-herdsman-converters to 25.104.0 ([#30564](https://github.com/Koenkk/zigbee2mqtt/issues/30564)) ([e68403d](https://github.com/Koenkk/zigbee2mqtt/commit/e68403d8d3ac68f2d09df066d1be8db87f2e7977))
* **ignore:** update zigbee-herdsman-converters to 25.105.0 ([#30605](https://github.com/Koenkk/zigbee2mqtt/issues/30605)) ([29c7571](https://github.com/Koenkk/zigbee2mqtt/commit/29c757145d6ed16a8629b68379cd85fabe45a8f1))
* **ignore:** update zigbee-herdsman-converters to 25.106.0 ([#30631](https://github.com/Koenkk/zigbee2mqtt/issues/30631)) ([9a41ec2](https://github.com/Koenkk/zigbee2mqtt/commit/9a41ec2a39ca2373dbdcb15b09aa0eb09116c31e))
* **ignore:** update zigbee-herdsman-converters to 25.107.0 ([#30662](https://github.com/Koenkk/zigbee2mqtt/issues/30662)) ([535f75e](https://github.com/Koenkk/zigbee2mqtt/commit/535f75e90ca936afcac0c79e4c2452dce7b9b08d))
* **ignore:** update zigbee-herdsman-converters to 25.108.0 ([#30672](https://github.com/Koenkk/zigbee2mqtt/issues/30672)) ([5e1b7d4](https://github.com/Koenkk/zigbee2mqtt/commit/5e1b7d4830b5597eb993f32e3a6d1edd84b48e4c))
* **ignore:** update zigbee-herdsman-converters to 25.109.0 ([#30709](https://github.com/Koenkk/zigbee2mqtt/issues/30709)) ([ee6afaf](https://github.com/Koenkk/zigbee2mqtt/commit/ee6afaf1c608b89efef452d7cb337345b035d76a))
* **ignore:** update zigbee-herdsman-converters to 25.110.0 ([#30726](https://github.com/Koenkk/zigbee2mqtt/issues/30726)) ([20af502](https://github.com/Koenkk/zigbee2mqtt/commit/20af50201ef1e081223ee8b2f0b6304777c7b7f1))
* **ignore:** update zigbee-herdsman-converters to 25.111.0 ([#30739](https://github.com/Koenkk/zigbee2mqtt/issues/30739)) ([2bb44f7](https://github.com/Koenkk/zigbee2mqtt/commit/2bb44f7a8b8bd9fc1a21b127b764a5d697e65c3e))
* **ignore:** update zigbee-herdsman-converters to 25.112.0 ([#30767](https://github.com/Koenkk/zigbee2mqtt/issues/30767)) ([ad51d12](https://github.com/Koenkk/zigbee2mqtt/commit/ad51d12a2d1325e0b0c1b56a633b229f0f3d048b))
* **ignore:** update zigbee-herdsman-converters to 25.113.0 ([#30789](https://github.com/Koenkk/zigbee2mqtt/issues/30789)) ([036ee47](https://github.com/Koenkk/zigbee2mqtt/commit/036ee471ddfda094a3ae3f970dedb5600c9d8808))
* **ignore:** update zigbee-herdsman-converters to 25.114.0 ([#30803](https://github.com/Koenkk/zigbee2mqtt/issues/30803)) ([999df29](https://github.com/Koenkk/zigbee2mqtt/commit/999df2913ff7e47fe9438ed9398a0922c0b40916))
* **ignore:** update zigbee-herdsman-converters to 25.115.0 ([#30826](https://github.com/Koenkk/zigbee2mqtt/issues/30826)) ([99e06a8](https://github.com/Koenkk/zigbee2mqtt/commit/99e06a8d3ea66ed2a6acf6cddece2f2070cce6ae))
* **ignore:** update zigbee-herdsman-converters to 25.116.0 ([#30839](https://github.com/Koenkk/zigbee2mqtt/issues/30839)) ([5a4d6b5](https://github.com/Koenkk/zigbee2mqtt/commit/5a4d6b56ad2ea62378b7dd161eba31916f08c65b))
* **ignore:** update zigbee-herdsman-converters to 25.117.0 ([#30849](https://github.com/Koenkk/zigbee2mqtt/issues/30849)) ([4afbce6](https://github.com/Koenkk/zigbee2mqtt/commit/4afbce62ebc667fc7d94b8b23c0bb0125f065d79))
* **ignore:** update zigbee-herdsman-converters to 25.118.0 ([#30865](https://github.com/Koenkk/zigbee2mqtt/issues/30865)) ([f688340](https://github.com/Koenkk/zigbee2mqtt/commit/f68834049cde265f731ab271b50fcfa6f85fa971))
* **ignore:** update zigbee-herdsman-converters to 25.119.0 ([#30874](https://github.com/Koenkk/zigbee2mqtt/issues/30874)) ([788b0d3](https://github.com/Koenkk/zigbee2mqtt/commit/788b0d3993c877adc0361a4b2f9d2df79cea0b8d))
* **ignore:** update zigbee-herdsman-converters to 25.120.0 ([#30876](https://github.com/Koenkk/zigbee2mqtt/issues/30876)) ([49b752d](https://github.com/Koenkk/zigbee2mqtt/commit/49b752da3527920dcdfae6a617f4aa084f6a8901))
* **ignore:** update zigbee-herdsman-converters to 25.99.0 ([#30481](https://github.com/Koenkk/zigbee2mqtt/issues/30481)) ([3adf7d5](https://github.com/Koenkk/zigbee2mqtt/commit/3adf7d5d8ec6da3fabbd9f293d5d0bcaef3c9144))
* Log error before renaming failed to load converters ([#30436](https://github.com/Koenkk/zigbee2mqtt/issues/30436)) ([1eaa2f3](https://github.com/Koenkk/zigbee2mqtt/commit/1eaa2f3e759351bc746dec2ccc57b92cdcf1b5d7))
* Network map: escape double-quotes and backslashes in device attributes ([#30746](https://github.com/Koenkk/zigbee2mqtt/issues/30746)) ([5de47a0](https://github.com/Koenkk/zigbee2mqtt/commit/5de47a0d93f5fdc187e4f8a7489580a107a2fd49))
* OTA availability detection ([#30815](https://github.com/Koenkk/zigbee2mqtt/issues/30815)) ([476efaa](https://github.com/Koenkk/zigbee2mqtt/commit/476efaa6e18a1d2ea069ea9c128536356df547ac))
## [2.7.2](https://github.com/Koenkk/zigbee2mqtt/compare/2.7.1...2.7.2) (2026-01-01)
### Bug Fixes
* Biome floating promises detection ([#30137](https://github.com/Koenkk/zigbee2mqtt/issues/30137)) ([0025ef8](https://github.com/Koenkk/zigbee2mqtt/commit/0025ef87c502970b94e1b720516d42631e21323c))
* Don't fail to start when removing old log directory fails ([#30216](https://github.com/Koenkk/zigbee2mqtt/issues/30216)) ([b39b8d4](https://github.com/Koenkk/zigbee2mqtt/commit/b39b8d478e99c5a5ee5a3a610547168b3724ca91))
* **ignore:** bump @types/node from 24.10.2 to 24.10.4 in the minor-patch group ([#30390](https://github.com/Koenkk/zigbee2mqtt/issues/30390)) ([93c9704](https://github.com/Koenkk/zigbee2mqtt/commit/93c9704fcd5552e234dd03a7ae1cddc11403622b))
* **ignore:** bump the minor-patch group across 1 directory with 4 updates ([#30117](https://github.com/Koenkk/zigbee2mqtt/issues/30117)) ([ad3c090](https://github.com/Koenkk/zigbee2mqtt/commit/ad3c09036b804eb108238036f22776d83ea371b2))
* **ignore:** bump the minor-patch group with 2 updates ([#30207](https://github.com/Koenkk/zigbee2mqtt/issues/30207)) ([1d15c21](https://github.com/Koenkk/zigbee2mqtt/commit/1d15c2142fefb9c7ce96c947e5ac7ddc6101439f))
* **ignore:** bump the minor-patch group with 2 updates ([#30309](https://github.com/Koenkk/zigbee2mqtt/issues/30309)) ([b10fa93](https://github.com/Koenkk/zigbee2mqtt/commit/b10fa93700febe84634040dbdeb4857258cd2882))
* **ignore:** bump zigbee2mqtt-windfront from 2.6.1 to 2.6.2 in the minor-patch group ([#30412](https://github.com/Koenkk/zigbee2mqtt/issues/30412)) ([4d6269e](https://github.com/Koenkk/zigbee2mqtt/commit/4d6269e7e89af52df0b2576925abade2f6b60e09))
* **ignore:** update zigbee-herdsman to 7.0.5 ([#30116](https://github.com/Koenkk/zigbee2mqtt/issues/30116)) ([4574ffc](https://github.com/Koenkk/zigbee2mqtt/commit/4574ffc71a799846c6a04121c3b5908f968ff0be))
* **ignore:** update zigbee-herdsman to 7.0.6 ([#30142](https://github.com/Koenkk/zigbee2mqtt/issues/30142)) ([ecb0af4](https://github.com/Koenkk/zigbee2mqtt/commit/ecb0af40575ebaadf887685c50cd1a9968cc1c68))
* **ignore:** update zigbee-herdsman to 8.0.0 and zigbee-herdsman-converters to 25.87.0 ([#30186](https://github.com/Koenkk/zigbee2mqtt/issues/30186)) ([7f5f6a5](https://github.com/Koenkk/zigbee2mqtt/commit/7f5f6a5849d940a6e5288865edf7999c41af12e2))
* **ignore:** update zigbee-herdsman to 8.0.1 ([#30240](https://github.com/Koenkk/zigbee2mqtt/issues/30240)) ([1688a3b](https://github.com/Koenkk/zigbee2mqtt/commit/1688a3b5323a8774ad2c36775fdb99f48057f72a))
* **ignore:** update zigbee-herdsman-converters to 25.84.0 ([#30081](https://github.com/Koenkk/zigbee2mqtt/issues/30081)) ([c18d132](https://github.com/Koenkk/zigbee2mqtt/commit/c18d1329c801ce48cf5277c00682ad2f9098f383))
* **ignore:** update zigbee-herdsman-converters to 25.85.0 ([#30120](https://github.com/Koenkk/zigbee2mqtt/issues/30120)) ([5db6ae0](https://github.com/Koenkk/zigbee2mqtt/commit/5db6ae0a197a180abc5e497aebe6e59fcb9b6b3c))
* **ignore:** update zigbee-herdsman-converters to 25.86.0 ([#30143](https://github.com/Koenkk/zigbee2mqtt/issues/30143)) ([3460f15](https://github.com/Koenkk/zigbee2mqtt/commit/3460f157df84d10f8de87a37d6b0b0b243de6874))
* **ignore:** update zigbee-herdsman-converters to 25.88.0 ([#30196](https://github.com/Koenkk/zigbee2mqtt/issues/30196)) ([3818c50](https://github.com/Koenkk/zigbee2mqtt/commit/3818c50b8ae516e56ad07e8fce3c451f35e1b678))
* **ignore:** update zigbee-herdsman-converters to 25.89.0 ([#30215](https://github.com/Koenkk/zigbee2mqtt/issues/30215)) ([01304cf](https://github.com/Koenkk/zigbee2mqtt/commit/01304cfc6604b86acf181dc3f07688f56ecbe8a1))
* **ignore:** update zigbee-herdsman-converters to 25.90.0 ([#30267](https://github.com/Koenkk/zigbee2mqtt/issues/30267)) ([ab08947](https://github.com/Koenkk/zigbee2mqtt/commit/ab0894715a002bd5c14d8af45a67e5e546889590))
* **ignore:** update zigbee-herdsman-converters to 25.91.0 ([#30279](https://github.com/Koenkk/zigbee2mqtt/issues/30279)) ([4c02e3d](https://github.com/Koenkk/zigbee2mqtt/commit/4c02e3da8f66cafbdcde89303bb1aea6eaf73bcd))
* **ignore:** update zigbee-herdsman-converters to 25.92.0 ([#30300](https://github.com/Koenkk/zigbee2mqtt/issues/30300)) ([c28a024](https://github.com/Koenkk/zigbee2mqtt/commit/c28a024664989fe8a044babe3cdf43403b0ee2b5))
* **ignore:** update zigbee-herdsman-converters to 25.93.0 ([#30331](https://github.com/Koenkk/zigbee2mqtt/issues/30331)) ([c61f67f](https://github.com/Koenkk/zigbee2mqtt/commit/c61f67f061d2766e99c5cb9c99c02b172832271c))
* **ignore:** update zigbee-herdsman-converters to 25.94.0 ([#30355](https://github.com/Koenkk/zigbee2mqtt/issues/30355)) ([7cb5c58](https://github.com/Koenkk/zigbee2mqtt/commit/7cb5c58dc028f86c59935b64a738444950ef8e12))
* **ignore:** update zigbee-herdsman-converters to 25.95.0 ([#30371](https://github.com/Koenkk/zigbee2mqtt/issues/30371)) ([4c48e9c](https://github.com/Koenkk/zigbee2mqtt/commit/4c48e9cfbcedb9cd6aedee9f694db2993038eaee))
* **ignore:** update zigbee-herdsman-converters to 25.96.0 ([#30383](https://github.com/Koenkk/zigbee2mqtt/issues/30383)) ([2721b0e](https://github.com/Koenkk/zigbee2mqtt/commit/2721b0ea78f4e86e6fe3b5f4831c5787a28585a8))
* **ignore:** update zigbee-herdsman-converters to 25.97.0 ([#30396](https://github.com/Koenkk/zigbee2mqtt/issues/30396)) ([3727f07](https://github.com/Koenkk/zigbee2mqtt/commit/3727f078ecde73a5c444c42909f36b4a0f35d8c3))
* **ignore:** update zigbee-herdsman-converters to 25.98.0 ([#30417](https://github.com/Koenkk/zigbee2mqtt/issues/30417)) ([0eba0c9](https://github.com/Koenkk/zigbee2mqtt/commit/0eba0c953d7543b2850a9b9672ffbc3d87840fb3))
* log dir tz format not working on some systems ([#30324](https://github.com/Koenkk/zigbee2mqtt/issues/30324)) ([58d98c7](https://github.com/Koenkk/zigbee2mqtt/commit/58d98c7263dbe687eb677199eb760799ecff8e8f))
* Proper timezone in logs dir ([#30297](https://github.com/Koenkk/zigbee2mqtt/issues/30297)) ([8549b37](https://github.com/Koenkk/zigbee2mqtt/commit/8549b372283da8b55804c4049c7341294ced1538))
* Remove extra `>` in onboarding([#30065](https://github.com/Koenkk/zigbee2mqtt/issues/30065)) ([ed1e7b3](https://github.com/Koenkk/zigbee2mqtt/commit/ed1e7b333f481a4b3a37b021f2058331b86ebd84))
## [2.7.1](https://github.com/Koenkk/zigbee2mqtt/compare/2.7.0...2.7.1) (2025-12-06)
### Bug Fixes
* Home Assistant: Accept multiple access levels for `current_humidity` source property ([#29936](https://github.com/Koenkk/zigbee2mqtt/issues/29936)) ([fa99c6d](https://github.com/Koenkk/zigbee2mqtt/commit/fa99c6dcca97d2c8c90136249293dea3d42e0998))
* Home Assistant: Translate boolean to state topic for `current_humidity_topic` ([#30014](https://github.com/Koenkk/zigbee2mqtt/issues/30014)) ([9ca85df](https://github.com/Koenkk/zigbee2mqtt/commit/9ca85dfac8b0f0421ad0622dfdbe36c8766d22b4))
* **ignore:** bump finalhandler from 2.1.0 to 2.1.1 in the minor-patch group ([#29920](https://github.com/Koenkk/zigbee2mqtt/issues/29920)) ([ce27ff3](https://github.com/Koenkk/zigbee2mqtt/commit/ce27ff3cbfac204579b38d034e71733e4e4bb18f))
* **ignore:** update zigbee-herdsman to 7.0.2 ([#29956](https://github.com/Koenkk/zigbee2mqtt/issues/29956)) ([403bd4a](https://github.com/Koenkk/zigbee2mqtt/commit/403bd4a72541bf7342de246b033c5090b186df32))
* **ignore:** update zigbee-herdsman to 7.0.3 ([#30008](https://github.com/Koenkk/zigbee2mqtt/issues/30008)) ([bae9bb8](https://github.com/Koenkk/zigbee2mqtt/commit/bae9bb8812c81e46d4068b4284e0f9957821f88a))
* **ignore:** update zigbee-herdsman to 7.0.4 ([#30041](https://github.com/Koenkk/zigbee2mqtt/issues/30041)) ([334fa2a](https://github.com/Koenkk/zigbee2mqtt/commit/334fa2a60d04c66749978b8b8f4b9b5af6c7c7eb))
* **ignore:** update zigbee-herdsman-converters to 25.81.0 ([#29959](https://github.com/Koenkk/zigbee2mqtt/issues/29959)) ([fd4282b](https://github.com/Koenkk/zigbee2mqtt/commit/fd4282b98d9630f9a98e82f627c12c5920d810b4))
* **ignore:** update zigbee-herdsman-converters to 25.82.0 ([#29990](https://github.com/Koenkk/zigbee2mqtt/issues/29990)) ([740edd8](https://github.com/Koenkk/zigbee2mqtt/commit/740edd8b58b7d72b7a2a5247f7f6ac8ee99ff0fa))
* **ignore:** update zigbee-herdsman-converters to 25.83.0 ([#30011](https://github.com/Koenkk/zigbee2mqtt/issues/30011)) ([a7f1345](https://github.com/Koenkk/zigbee2mqtt/commit/a7f1345a9164f6c83cb81688c33511b14646fab8))
* **ignore:** update zigbee-herdsman-converters to 25.83.1 ([#30050](https://github.com/Koenkk/zigbee2mqtt/issues/30050)) ([9b60353](https://github.com/Koenkk/zigbee2mqtt/commit/9b6035387be6f285fab455f7845baa75f3f722be))
## [2.7.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.6.3...2.7.0) (2025-12-01)
### Features
* Add `action` bridge/request API ([#29730](https://github.com/Koenkk/zigbee2mqtt/issues/29730)) ([a61646d](https://github.com/Koenkk/zigbee2mqtt/commit/a61646d584c0a0de46abb3545cd964f65d84312d))
* Add new bind/reporting/map features ([#29750](https://github.com/Koenkk/zigbee2mqtt/issues/29750)) ([f26ade4](https://github.com/Koenkk/zigbee2mqtt/commit/f26ade4f938350d10ed22a32a2e30365f7514a72))
### Bug Fixes
* Add port 8080 `EXPOSE` to container ([#29754](https://github.com/Koenkk/zigbee2mqtt/issues/29754)) ([4c21b66](https://github.com/Koenkk/zigbee2mqtt/commit/4c21b660794ee3b85c9a9a6d01b0df5ec8bce639))
* Home Assistant: expose `current_humidity` for `climate` devices exposing `humidity` ([#29842](https://github.com/Koenkk/zigbee2mqtt/issues/29842)) ([631c4f6](https://github.com/Koenkk/zigbee2mqtt/commit/631c4f6a5729888d65a1125e0cca8f9ba2ff0e59))
* **ignore:** bump @biomejs/biome from 2.3.3 to 2.3.4 in the minor-patch group ([#29571](https://github.com/Koenkk/zigbee2mqtt/issues/29571)) ([517b286](https://github.com/Koenkk/zigbee2mqtt/commit/517b2865835a7c21b4c5ce68018fd30ae45d1a3f))
* **ignore:** bump debounce from 2.2.0 to 3.0.0 ([#29444](https://github.com/Koenkk/zigbee2mqtt/issues/29444)) ([8099322](https://github.com/Koenkk/zigbee2mqtt/commit/8099322cc2e93e5c1faf355d41485f2c673e0cea))
* **ignore:** bump the minor-patch group with 2 updates ([#29443](https://github.com/Koenkk/zigbee2mqtt/issues/29443)) ([619b939](https://github.com/Koenkk/zigbee2mqtt/commit/619b939b148394bd6a083c17cc5cf254c8eb3aea))
* **ignore:** bump the minor-patch group with 2 updates ([#29859](https://github.com/Koenkk/zigbee2mqtt/issues/29859)) ([c02ed09](https://github.com/Koenkk/zigbee2mqtt/commit/c02ed092116c7454220c0611bff5d078e12a8ecc))
* **ignore:** bump the minor-patch group with 3 updates ([#29804](https://github.com/Koenkk/zigbee2mqtt/issues/29804)) ([e2232e9](https://github.com/Koenkk/zigbee2mqtt/commit/e2232e9e9173ea9ec55152ac33bc0ee0141471bb))
* **ignore:** bump zigbee2mqtt-windfront from 2.4.0 to 2.4.1 in the minor-patch group ([#29905](https://github.com/Koenkk/zigbee2mqtt/issues/29905)) ([4a020fd](https://github.com/Koenkk/zigbee2mqtt/commit/4a020fd8de2dab2973b859c34b934416a15e82a5))
* **ignore:** bump zigbee2mqtt-windfront to 2.4.2 ([#29919](https://github.com/Koenkk/zigbee2mqtt/issues/29919)) ([3dce854](https://github.com/Koenkk/zigbee2mqtt/commit/3dce8548ceee111f49a5b3460fdb2cbb4f47da62))
* **ignore:** prop name convention on new API endpoints ([#29820](https://github.com/Koenkk/zigbee2mqtt/issues/29820)) ([ad06968](https://github.com/Koenkk/zigbee2mqtt/commit/ad0696856a9a63fc74b9c2115f08d7fd97850545))
* **ignore:** update zigbee-herdsman to 6.3.3 ([#29500](https://github.com/Koenkk/zigbee2mqtt/issues/29500)) ([39c5bc8](https://github.com/Koenkk/zigbee2mqtt/commit/39c5bc8c0446a7ccdffe6f82ae9e7f31f60f20ba))
* **ignore:** update zigbee-herdsman to 6.4.0 ([#29522](https://github.com/Koenkk/zigbee2mqtt/issues/29522)) ([e788c52](https://github.com/Koenkk/zigbee2mqtt/commit/e788c520a9268498daf0f88d7bedb289546d226e))
* **ignore:** update zigbee-herdsman to 6.4.1 ([#29595](https://github.com/Koenkk/zigbee2mqtt/issues/29595)) ([61c6ec6](https://github.com/Koenkk/zigbee2mqtt/commit/61c6ec611174529e4c041a56146ba8405cfe9ed8))
* **ignore:** update zigbee-herdsman to 6.4.2 ([#29683](https://github.com/Koenkk/zigbee2mqtt/issues/29683)) ([53b7eaa](https://github.com/Koenkk/zigbee2mqtt/commit/53b7eaabf0647c8d1edb0de24b89776991af576c))
* **ignore:** update zigbee-herdsman to 7.0.0 ([3e2aefb](https://github.com/Koenkk/zigbee2mqtt/commit/3e2aefbcf84f43d45c1d6ca33397c8bb76648822))
* **ignore:** update zigbee-herdsman to 7.0.1 ([#29855](https://github.com/Koenkk/zigbee2mqtt/issues/29855)) ([802271e](https://github.com/Koenkk/zigbee2mqtt/commit/802271ed37d404944d315f83f3b623ba9e424bb9))
* **ignore:** update zigbee-herdsman-converters to 25.60.0 ([#29426](https://github.com/Koenkk/zigbee2mqtt/issues/29426)) ([eeed56e](https://github.com/Koenkk/zigbee2mqtt/commit/eeed56ef2c01dfe67e7a0cd7cafd35ef1876f5b4))
* **ignore:** update zigbee-herdsman-converters to 25.62.0 ([#29449](https://github.com/Koenkk/zigbee2mqtt/issues/29449)) ([ea1c5a8](https://github.com/Koenkk/zigbee2mqtt/commit/ea1c5a8f4e8537a2f8c59988bb712f62a0adffd7))
* **ignore:** update zigbee-herdsman-converters to 25.63.0 ([#29482](https://github.com/Koenkk/zigbee2mqtt/issues/29482)) ([5a6a9cf](https://github.com/Koenkk/zigbee2mqtt/commit/5a6a9cf4d2de803a10cd38bc19f3ec256eff5929))
* **ignore:** update zigbee-herdsman-converters to 25.64.0 ([#29499](https://github.com/Koenkk/zigbee2mqtt/issues/29499)) ([5c4e43f](https://github.com/Koenkk/zigbee2mqtt/commit/5c4e43fd109321ddcfad3a67b60423f0197c51a6))
* **ignore:** update zigbee-herdsman-converters to 25.65.0 ([#29523](https://github.com/Koenkk/zigbee2mqtt/issues/29523)) ([8822aed](https://github.com/Koenkk/zigbee2mqtt/commit/8822aedb5e254f5be5f6cda0cfe18fc6f69c84d6))
* **ignore:** update zigbee-herdsman-converters to 25.66.0 ([#29558](https://github.com/Koenkk/zigbee2mqtt/issues/29558)) ([940fff2](https://github.com/Koenkk/zigbee2mqtt/commit/940fff244c39da04d0de5d7e4076359dc6245aed))
* **ignore:** update zigbee-herdsman-converters to 25.67.0 ([#29572](https://github.com/Koenkk/zigbee2mqtt/issues/29572)) ([4794b3c](https://github.com/Koenkk/zigbee2mqtt/commit/4794b3c81f9e38660f8549995b7f3ef8b4cd4318))
* **ignore:** update zigbee-herdsman-converters to 25.68.0 ([#29606](https://github.com/Koenkk/zigbee2mqtt/issues/29606)) ([564e9ab](https://github.com/Koenkk/zigbee2mqtt/commit/564e9ab9eb49c3517913917af8b23d4758c6b6c7))
* **ignore:** update zigbee-herdsman-converters to 25.69.0 ([#29659](https://github.com/Koenkk/zigbee2mqtt/issues/29659)) ([16d0e56](https://github.com/Koenkk/zigbee2mqtt/commit/16d0e5696859e79be4701154dd35f7780e47acae))
* **ignore:** update zigbee-herdsman-converters to 25.70.0 ([#29684](https://github.com/Koenkk/zigbee2mqtt/issues/29684)) ([73225e2](https://github.com/Koenkk/zigbee2mqtt/commit/73225e2436bb56ff4ed47d787e8a1793fd10998d))
* **ignore:** update zigbee-herdsman-converters to 25.71.0 ([#29716](https://github.com/Koenkk/zigbee2mqtt/issues/29716)) ([d6bf517](https://github.com/Koenkk/zigbee2mqtt/commit/d6bf5174be905128432e90d54b3c43c2a68e75ac))
* **ignore:** update zigbee-herdsman-converters to 25.72.0 ([#29735](https://github.com/Koenkk/zigbee2mqtt/issues/29735)) ([7b7b988](https://github.com/Koenkk/zigbee2mqtt/commit/7b7b9889607a6e48001ae3cda5950386c24f5fd6))
* **ignore:** update zigbee-herdsman-converters to 25.73.0 ([#29752](https://github.com/Koenkk/zigbee2mqtt/issues/29752)) ([be774a4](https://github.com/Koenkk/zigbee2mqtt/commit/be774a4d0d70122ced0f6ca79f49c060a7d2d2cb))
* **ignore:** update zigbee-herdsman-converters to 25.74.0 ([#29770](https://github.com/Koenkk/zigbee2mqtt/issues/29770)) ([ba77ce7](https://github.com/Koenkk/zigbee2mqtt/commit/ba77ce7c8df0b29845a090c9defb4ce8aaf0f924))
* **ignore:** update zigbee-herdsman-converters to 25.75.0 ([#29811](https://github.com/Koenkk/zigbee2mqtt/issues/29811)) ([854df49](https://github.com/Koenkk/zigbee2mqtt/commit/854df498bd3c8999393b683052dfba6b906ba669))
* **ignore:** update zigbee-herdsman-converters to 25.76.0 ([#29823](https://github.com/Koenkk/zigbee2mqtt/issues/29823)) ([72d862f](https://github.com/Koenkk/zigbee2mqtt/commit/72d862f4a080bfcd91bf99d4c13387e719dfdb8e))
* **ignore:** update zigbee-herdsman-converters to 25.77.0 ([#29838](https://github.com/Koenkk/zigbee2mqtt/issues/29838)) ([35d8a5d](https://github.com/Koenkk/zigbee2mqtt/commit/35d8a5da7f1d817727f8b029da1992901f5b4115))
* **ignore:** update zigbee-herdsman-converters to 25.78.0 ([#29856](https://github.com/Koenkk/zigbee2mqtt/issues/29856)) ([49b4876](https://github.com/Koenkk/zigbee2mqtt/commit/49b4876768c5d15fcff1ad0c9c2d02dda96543d0))
* **ignore:** update zigbee-herdsman-converters to 25.79.0 ([#29903](https://github.com/Koenkk/zigbee2mqtt/issues/29903)) ([d411fc5](https://github.com/Koenkk/zigbee2mqtt/commit/d411fc5c693300863a19c2798f0a7a05cfc7e55f))
* **ignore:** update zigbee-herdsman-converters to 25.80.0 ([#29924](https://github.com/Koenkk/zigbee2mqtt/issues/29924)) ([445890a](https://github.com/Koenkk/zigbee2mqtt/commit/445890a0710ba7f6b101b651bf379f10390f9e7e))
* **ignore:** update zigbee2mqtt-windfront to 2.3.1 ([#29788](https://github.com/Koenkk/zigbee2mqtt/issues/29788)) ([17520f7](https://github.com/Koenkk/zigbee2mqtt/commit/17520f7cfb69b577ebd986b86da80931a15a39db))
* Reporting payload detection fixes ([#29854](https://github.com/Koenkk/zigbee2mqtt/issues/29854)) ([42fc6e0](https://github.com/Koenkk/zigbee2mqtt/commit/42fc6e03a7a3092502e3c3d1defbcb3ed6438230))
## [2.6.3](https://github.com/Koenkk/zigbee2mqtt/compare/2.6.2...2.6.3) (2025-11-01)
### Bug Fixes
* Add pi cooling demand to Home Assistant auto discovery ([#28843](https://github.com/Koenkk/zigbee2mqtt/issues/28843)) ([09fd675](https://github.com/Koenkk/zigbee2mqtt/commit/09fd675d0f215af11e64515ea60b91ed28b5e683))
* Fix `onEvent` called twice first time when device options are changed ([#29322](https://github.com/Koenkk/zigbee2mqtt/issues/29322)) ([e037a2c](https://github.com/Koenkk/zigbee2mqtt/commit/e037a2c21cb985b6e71d9c1fa169f0886e8af832))
* Fix input onboarding environment variable disabled check ([#29354](https://github.com/Koenkk/zigbee2mqtt/issues/29354)) ([c306300](https://github.com/Koenkk/zigbee2mqtt/commit/c30630091d1ac14fed6df9c5ee43a275db877821))
* Home Assistant: fix device and group configuration URL ([#29211](https://github.com/Koenkk/zigbee2mqtt/issues/29211)) ([c0190c1](https://github.com/Koenkk/zigbee2mqtt/commit/c0190c18ede7accb407b805f6016c3242c11146a))
* Home Assistant: make PI heating demand from writable ([#29188](https://github.com/Koenkk/zigbee2mqtt/issues/29188)) ([174ba64](https://github.com/Koenkk/zigbee2mqtt/commit/174ba64449b04777d1d5743fbb7b498a29c40ae2))
* **ignore:** bump @types/node from 24.7.2 to 24.8.1 in the minor-patch group ([#29191](https://github.com/Koenkk/zigbee2mqtt/issues/29191)) ([d8fc69d](https://github.com/Koenkk/zigbee2mqtt/commit/d8fc69dac3cba33f3d69e377e338e03facbc19e7))
* **ignore:** bump @types/serve-static from 1.15.10 to 2.2.0 ([#29394](https://github.com/Koenkk/zigbee2mqtt/issues/29394)) ([b2c31f8](https://github.com/Koenkk/zigbee2mqtt/commit/b2c31f89c69cfe04d0d671235bd18eb3cfa7ed72))
* **ignore:** bump the minor-patch group across 1 directory with 4 updates ([#29395](https://github.com/Koenkk/zigbee2mqtt/issues/29395)) ([dc7749b](https://github.com/Koenkk/zigbee2mqtt/commit/dc7749b57e188835f1243fad4b1d9dcccd7dca7d))
* **ignore:** bump the minor-patch group with 3 updates ([#29068](https://github.com/Koenkk/zigbee2mqtt/issues/29068)) ([aa916ad](https://github.com/Koenkk/zigbee2mqtt/commit/aa916ad60451fea471217e65fbdbb38635879c8e))
* **ignore:** bump the minor-patch group with 4 updates ([#28916](https://github.com/Koenkk/zigbee2mqtt/issues/28916)) ([e24ec79](https://github.com/Koenkk/zigbee2mqtt/commit/e24ec79980535d62b02e849359e23a189f2255ed))
* **ignore:** bump the minor-patch group with 5 updates ([#29314](https://github.com/Koenkk/zigbee2mqtt/issues/29314)) ([82f1092](https://github.com/Koenkk/zigbee2mqtt/commit/82f10923027806f5cd3b31e27184883e07768c71))
* **ignore:** update zigbee-herdsman to 6.2.0 ([#28900](https://github.com/Koenkk/zigbee2mqtt/issues/28900)) ([8b4a4be](https://github.com/Koenkk/zigbee2mqtt/commit/8b4a4be2a4404a8b165bb62c43bca73894d6b13f))
* **ignore:** update zigbee-herdsman to 6.3.0 ([#29113](https://github.com/Koenkk/zigbee2mqtt/issues/29113)) ([354d06a](https://github.com/Koenkk/zigbee2mqtt/commit/354d06aebc8fc786309ce9d66403e335799d46ab))
* **ignore:** update zigbee-herdsman to 6.3.1 ([#29153](https://github.com/Koenkk/zigbee2mqtt/issues/29153)) ([feac6aa](https://github.com/Koenkk/zigbee2mqtt/commit/feac6aa677cb169bf3c9b47aa4e6687ea8e6432d))
* **ignore:** update zigbee-herdsman to 6.3.2 ([#29246](https://github.com/Koenkk/zigbee2mqtt/issues/29246)) ([ef15924](https://github.com/Koenkk/zigbee2mqtt/commit/ef15924260cf2aa0094f2f93db0ddd8fcfa0ac6f))
* **ignore:** update zigbee-herdsman-converters to 25.36.0 ([#28790](https://github.com/Koenkk/zigbee2mqtt/issues/28790)) ([ca489da](https://github.com/Koenkk/zigbee2mqtt/commit/ca489daad8def2683f4a5c248e8098d01cc7cc6f))
* **ignore:** update zigbee-herdsman-converters to 25.37.0 ([#28837](https://github.com/Koenkk/zigbee2mqtt/issues/28837)) ([d1ce146](https://github.com/Koenkk/zigbee2mqtt/commit/d1ce1465421f81a68e03ffe76e928e2ea33cdde0))
* **ignore:** update zigbee-herdsman-converters to 25.37.1 ([#28857](https://github.com/Koenkk/zigbee2mqtt/issues/28857)) ([c66008b](https://github.com/Koenkk/zigbee2mqtt/commit/c66008b36bccdc1626a7f095f1af498b100610e3))
* **ignore:** update zigbee-herdsman-converters to 25.37.2 ([#28859](https://github.com/Koenkk/zigbee2mqtt/issues/28859)) ([1d38da6](https://github.com/Koenkk/zigbee2mqtt/commit/1d38da6ac5ac6a54c1c13a09003545f9accbfa7a))
* **ignore:** update zigbee-herdsman-converters to 25.38.0 ([#28872](https://github.com/Koenkk/zigbee2mqtt/issues/28872)) ([a75a40b](https://github.com/Koenkk/zigbee2mqtt/commit/a75a40b042c8276e2b52c666b555fae4f656fa04))
* **ignore:** update zigbee-herdsman-converters to 25.39.0 ([#28899](https://github.com/Koenkk/zigbee2mqtt/issues/28899)) ([548f646](https://github.com/Koenkk/zigbee2mqtt/commit/548f646f2fc1f53e18f84aec817f845de8edb04d))
* **ignore:** update zigbee-herdsman-converters to 25.40.0 ([#28928](https://github.com/Koenkk/zigbee2mqtt/issues/28928)) ([5f08c3e](https://github.com/Koenkk/zigbee2mqtt/commit/5f08c3e83beaa98f6e5cf5fd32e106730e86f611))
* **ignore:** update zigbee-herdsman-converters to 25.41.0 ([#28945](https://github.com/Koenkk/zigbee2mqtt/issues/28945)) ([79a27e3](https://github.com/Koenkk/zigbee2mqtt/commit/79a27e3b060dd552110a1851252dd6a17524be9b))
* **ignore:** update zigbee-herdsman-converters to 25.42.0 ([#28963](https://github.com/Koenkk/zigbee2mqtt/issues/28963)) ([8b1f5a5](https://github.com/Koenkk/zigbee2mqtt/commit/8b1f5a555c571990e9e895b285060872ba27402f))
* **ignore:** update zigbee-herdsman-converters to 25.43.0 ([#29014](https://github.com/Koenkk/zigbee2mqtt/issues/29014)) ([9c19aa9](https://github.com/Koenkk/zigbee2mqtt/commit/9c19aa94948205489b50ac79063b2a065495a8a8))
* **ignore:** update zigbee-herdsman-converters to 25.44.0 ([#29050](https://github.com/Koenkk/zigbee2mqtt/issues/29050)) ([bafc8e4](https://github.com/Koenkk/zigbee2mqtt/commit/bafc8e4284d9493388c8ad8497bb55781238c547))
* **ignore:** update zigbee-herdsman-converters to 25.46.0 ([#29096](https://github.com/Koenkk/zigbee2mqtt/issues/29096)) ([ad9b114](https://github.com/Koenkk/zigbee2mqtt/commit/ad9b114b1af8225e8e1414ffa23b322271fb1694))
* **ignore:** update zigbee-herdsman-converters to 25.47.0 ([#29130](https://github.com/Koenkk/zigbee2mqtt/issues/29130)) ([03a81c6](https://github.com/Koenkk/zigbee2mqtt/commit/03a81c6ab14d9ae7b2a9fdd7513dcbbe55cdc16c))
* **ignore:** update zigbee-herdsman-converters to 25.48.0 ([#29141](https://github.com/Koenkk/zigbee2mqtt/issues/29141)) ([1d69715](https://github.com/Koenkk/zigbee2mqtt/commit/1d69715a5a75779509f81788a1541dd4c492ca9e))
* **ignore:** update zigbee-herdsman-converters to 25.49.0 ([#29154](https://github.com/Koenkk/zigbee2mqtt/issues/29154)) ([3ecb0c5](https://github.com/Koenkk/zigbee2mqtt/commit/3ecb0c5e41b514bfb824926fc9cf19636ec68ff5))
* **ignore:** update zigbee-herdsman-converters to 25.50.0 ([#29180](https://github.com/Koenkk/zigbee2mqtt/issues/29180)) ([df70f11](https://github.com/Koenkk/zigbee2mqtt/commit/df70f11a154e27208188432be453fac821f73597))
* **ignore:** update zigbee-herdsman-converters to 25.51.0 ([#29195](https://github.com/Koenkk/zigbee2mqtt/issues/29195)) ([4080522](https://github.com/Koenkk/zigbee2mqtt/commit/408052282d9a28f46df33266112cf52dbc0f98c0))
* **ignore:** update zigbee-herdsman-converters to 25.51.1 ([#29212](https://github.com/Koenkk/zigbee2mqtt/issues/29212)) ([238ea7c](https://github.com/Koenkk/zigbee2mqtt/commit/238ea7c4c8073cb0164281cc0cdec05a3212d6ad))
* **ignore:** update zigbee-herdsman-converters to 25.52.0 ([#29247](https://github.com/Koenkk/zigbee2mqtt/issues/29247)) ([05382a9](https://github.com/Koenkk/zigbee2mqtt/commit/05382a969232d5c1a286ba1a85c6422d0a81a39f))
* **ignore:** update zigbee-herdsman-converters to 25.53.0 ([#29266](https://github.com/Koenkk/zigbee2mqtt/issues/29266)) ([d719c1f](https://github.com/Koenkk/zigbee2mqtt/commit/d719c1f36848c08efaad3eda8e90fb5d61c4c657))
* **ignore:** update zigbee-herdsman-converters to 25.54.0 ([#29306](https://github.com/Koenkk/zigbee2mqtt/issues/29306)) ([003c81b](https://github.com/Koenkk/zigbee2mqtt/commit/003c81b745be7f3262d6e778957aec6a9cf3a404))
* **ignore:** update zigbee-herdsman-converters to 25.55.0 ([#29323](https://github.com/Koenkk/zigbee2mqtt/issues/29323)) ([592c256](https://github.com/Koenkk/zigbee2mqtt/commit/592c256f96cca5219eb8620422f3ebf73fba3c1e))
* **ignore:** update zigbee-herdsman-converters to 25.56.0 ([#29338](https://github.com/Koenkk/zigbee2mqtt/issues/29338)) ([61d99b7](https://github.com/Koenkk/zigbee2mqtt/commit/61d99b7ece63af35055fabbd9c07d43b3cbc80f9))
* **ignore:** update zigbee-herdsman-converters to 25.57.0 ([#29370](https://github.com/Koenkk/zigbee2mqtt/issues/29370)) ([faa65f7](https://github.com/Koenkk/zigbee2mqtt/commit/faa65f78a29f5738a83d6fd67dd749f7d02adb39))
* **ignore:** update zigbee-herdsman-converters to 25.59.0 ([#29382](https://github.com/Koenkk/zigbee2mqtt/issues/29382)) ([40bf14b](https://github.com/Koenkk/zigbee2mqtt/commit/40bf14b93cb94292cce954916eba30f48d3170e2))
* Remove Moment.js dependency ([#28797](https://github.com/Koenkk/zigbee2mqtt/issues/28797)) ([74335fb](https://github.com/Koenkk/zigbee2mqtt/commit/74335fbb686edd63e5965c50b6cfc853abd07717))
* Rename `ZigBee` -&gt; `Zigbee` ([#29131](https://github.com/Koenkk/zigbee2mqtt/issues/29131)) ([eaaed1f](https://github.com/Koenkk/zigbee2mqtt/commit/eaaed1fec79401cb0b15b6e4bb4ddfba7bcf3d39))
* Require at least Node ^20.15.0 ([#29284](https://github.com/Koenkk/zigbee2mqtt/issues/29284)) ([8ac2f55](https://github.com/Koenkk/zigbee2mqtt/commit/8ac2f55d87c3bebbda31cf1e9f1806e98b78c114))
* Require at least Node ^22.2.0 ([#29285](https://github.com/Koenkk/zigbee2mqtt/issues/29285)) ([6c48498](https://github.com/Koenkk/zigbee2mqtt/commit/6c48498e0d1cbf49ab5ccc4d7539c932ea92a22e))
## [2.6.2](https://github.com/Koenkk/zigbee2mqtt/compare/2.6.1...2.6.2) (2025-10-01)
### Bug Fixes
* Fix `DatabaseEntry with ID 'X' does not exist` when adding device to delete group ID ([#28583](https://github.com/Koenkk/zigbee2mqtt/issues/28583)) ([09f33b3](https://github.com/Koenkk/zigbee2mqtt/commit/09f33b31a940c72ecfd42586427f54b3567b097b))
* Handle empty or null Home Assistant name in device settings ([#28364](https://github.com/Koenkk/zigbee2mqtt/issues/28364)) ([a5e1e8c](https://github.com/Koenkk/zigbee2mqtt/commit/a5e1e8c0c316f500cdc19d7c47b5156ed0b1e0f0))
* HomeAssistant: add `default_entity_id` to discovery message ([#28560](https://github.com/Koenkk/zigbee2mqtt/issues/28560)) ([15e00ea](https://github.com/Koenkk/zigbee2mqtt/commit/15e00ea8a49cc26e6c359bb60fbec0e76499fbf1))
* **ignore:** bump @types/node from 24.4.0 to 24.5.2 in the minor-patch group ([#28700](https://github.com/Koenkk/zigbee2mqtt/issues/28700)) ([e8847d1](https://github.com/Koenkk/zigbee2mqtt/commit/e8847d1a342d04cd2e82704518cfb23745010dd1))
* **ignore:** bump the minor-patch group with 3 updates ([#28541](https://github.com/Koenkk/zigbee2mqtt/issues/28541)) ([86b4581](https://github.com/Koenkk/zigbee2mqtt/commit/86b458111b44a2e99f4abacaaadc4caf0d6b912c))
* **ignore:** bump the minor-patch group with 3 updates ([#28638](https://github.com/Koenkk/zigbee2mqtt/issues/28638)) ([c3e1525](https://github.com/Koenkk/zigbee2mqtt/commit/c3e1525f60d577ff2669761516fcfb23b4ee1bb7))
* **ignore:** bump the minor-patch group with 4 updates ([#28778](https://github.com/Koenkk/zigbee2mqtt/issues/28778)) ([c9d01f4](https://github.com/Koenkk/zigbee2mqtt/commit/c9d01f4118cdef2a54bae43b09edb5e54c4f3042))
* **ignore:** bump zigbee2mqtt-windfront from 2.1.0 to 2.2.0 in the minor-patch group ([#28764](https://github.com/Koenkk/zigbee2mqtt/issues/28764)) ([1166341](https://github.com/Koenkk/zigbee2mqtt/commit/116634193a0c7693c6431ceccb373cf5e3f99e40))
* **ignore:** update zigbee-herdsman to 6.1.0 ([#28460](https://github.com/Koenkk/zigbee2mqtt/issues/28460)) ([06862c7](https://github.com/Koenkk/zigbee2mqtt/commit/06862c72594abf083411cf517c8dfb3db12fd4a9))
* **ignore:** update zigbee-herdsman to 6.1.1 ([#28597](https://github.com/Koenkk/zigbee2mqtt/issues/28597)) ([c28ca79](https://github.com/Koenkk/zigbee2mqtt/commit/c28ca79d8a8d5264c3e53f1d1eb96ce5dce1f90e))
* **ignore:** update zigbee-herdsman to 6.1.2 ([#28689](https://github.com/Koenkk/zigbee2mqtt/issues/28689)) ([79c0c09](https://github.com/Koenkk/zigbee2mqtt/commit/79c0c0933ab2500ae4d5bd42dea57923729f8d86))
* **ignore:** update zigbee-herdsman to 6.1.3 ([#28717](https://github.com/Koenkk/zigbee2mqtt/issues/28717)) ([892f924](https://github.com/Koenkk/zigbee2mqtt/commit/892f92448e256acd59d329cde4b7519d18cd829a))
* **ignore:** update zigbee-herdsman to 6.1.4 ([#28762](https://github.com/Koenkk/zigbee2mqtt/issues/28762)) ([508318d](https://github.com/Koenkk/zigbee2mqtt/commit/508318de57267791422d731fcb9a423fa01c8062))
* **ignore:** update zigbee-herdsman to 6.1.5 ([#28773](https://github.com/Koenkk/zigbee2mqtt/issues/28773)) ([a0d91cc](https://github.com/Koenkk/zigbee2mqtt/commit/a0d91cc063437cc595dd693e33fe5b1f044cd6bf))
* **ignore:** update zigbee-herdsman-converters to 25.15.0 ([#28462](https://github.com/Koenkk/zigbee2mqtt/issues/28462)) ([3f27f79](https://github.com/Koenkk/zigbee2mqtt/commit/3f27f79d7dabd1951c9c1f2f3dfc24d6bc741edd))
* **ignore:** update zigbee-herdsman-converters to 25.17.0 ([#28480](https://github.com/Koenkk/zigbee2mqtt/issues/28480)) ([3018e40](https://github.com/Koenkk/zigbee2mqtt/commit/3018e40daef092d74de4eea9e2cb8f174899cd71))
* **ignore:** update zigbee-herdsman-converters to 25.17.1 ([#28511](https://github.com/Koenkk/zigbee2mqtt/issues/28511)) ([54d729e](https://github.com/Koenkk/zigbee2mqtt/commit/54d729e37a80b0d7a22b67b6223e28685cbf1974))
* **ignore:** update zigbee-herdsman-converters to 25.18.0 ([#28518](https://github.com/Koenkk/zigbee2mqtt/issues/28518)) ([e8e4977](https://github.com/Koenkk/zigbee2mqtt/commit/e8e4977327c147f6338d323662823dd3316d54a3))
* **ignore:** update zigbee-herdsman-converters to 25.19.0 ([#28533](https://github.com/Koenkk/zigbee2mqtt/issues/28533)) ([5f2630c](https://github.com/Koenkk/zigbee2mqtt/commit/5f2630c93b8541eafbb737c6eb134155ae2fa6f5))
* **ignore:** update zigbee-herdsman-converters to 25.20.0 ([#28545](https://github.com/Koenkk/zigbee2mqtt/issues/28545)) ([a297b92](https://github.com/Koenkk/zigbee2mqtt/commit/a297b9255b961d19c0164f6dc29fcaf3cc10aab6))
* **ignore:** update zigbee-herdsman-converters to 25.21.0 ([#28561](https://github.com/Koenkk/zigbee2mqtt/issues/28561)) ([01d5863](https://github.com/Koenkk/zigbee2mqtt/commit/01d5863487322729fed61da167b6754289d26919))
* **ignore:** update zigbee-herdsman-converters to 25.22.0 ([#28580](https://github.com/Koenkk/zigbee2mqtt/issues/28580)) ([5f6fae0](https://github.com/Koenkk/zigbee2mqtt/commit/5f6fae0df729b8fafa4d8835ba48dc5e25578330))
* **ignore:** update zigbee-herdsman-converters to 25.23.0 ([#28598](https://github.com/Koenkk/zigbee2mqtt/issues/28598)) ([3f25834](https://github.com/Koenkk/zigbee2mqtt/commit/3f258342d8acd2cd4f69902679735dee2a7e0e01))
* **ignore:** update zigbee-herdsman-converters to 25.24.0 ([#28608](https://github.com/Koenkk/zigbee2mqtt/issues/28608)) ([2edf85f](https://github.com/Koenkk/zigbee2mqtt/commit/2edf85f6e1c8b0a42eacfd38a4fd4a43875e86d1))
* **ignore:** update zigbee-herdsman-converters to 25.25.0 ([#28621](https://github.com/Koenkk/zigbee2mqtt/issues/28621)) ([b2f7460](https://github.com/Koenkk/zigbee2mqtt/commit/b2f74607cc32d17df12fc7361b9bab7eafebf5bd))
* **ignore:** update zigbee-herdsman-converters to 25.26.0 ([#28629](https://github.com/Koenkk/zigbee2mqtt/issues/28629)) ([2cb7dcc](https://github.com/Koenkk/zigbee2mqtt/commit/2cb7dcc12ab00dbc966717ab70b5f8ad8a86012e))
* **ignore:** update zigbee-herdsman-converters to 25.29.0 ([#28654](https://github.com/Koenkk/zigbee2mqtt/issues/28654)) ([382d37e](https://github.com/Koenkk/zigbee2mqtt/commit/382d37e839bad69c02c6a560b32964b03305ac67))
* **ignore:** update zigbee-herdsman-converters to 25.30.0 ([#28707](https://github.com/Koenkk/zigbee2mqtt/issues/28707)) ([77926bc](https://github.com/Koenkk/zigbee2mqtt/commit/77926bc1c31a86bf2ded7838216a59380f3bde1e))
* **ignore:** update zigbee-herdsman-converters to 25.31.0 ([#28726](https://github.com/Koenkk/zigbee2mqtt/issues/28726)) ([a310627](https://github.com/Koenkk/zigbee2mqtt/commit/a310627e19fb0f684be407d7d54bf90ff112041a))
* **ignore:** update zigbee-herdsman-converters to 25.32.0 ([#28746](https://github.com/Koenkk/zigbee2mqtt/issues/28746)) ([87c474d](https://github.com/Koenkk/zigbee2mqtt/commit/87c474d2f5433f74fda122a42d4ffa16c64b3a00))
* **ignore:** update zigbee-herdsman-converters to 25.33.0 ([#28758](https://github.com/Koenkk/zigbee2mqtt/issues/28758)) ([26c74b5](https://github.com/Koenkk/zigbee2mqtt/commit/26c74b57786e0feffe5d1bb3ae7406e25449a591))
* **ignore:** update zigbee-herdsman-converters to 25.34.0 ([#28763](https://github.com/Koenkk/zigbee2mqtt/issues/28763)) ([f6c0ce7](https://github.com/Koenkk/zigbee2mqtt/commit/f6c0ce735ff582e8d07a11f951a1faa9720367c1))
* **ignore:** update zigbee-herdsman-converters to 25.35.1 ([#28774](https://github.com/Koenkk/zigbee2mqtt/issues/28774)) ([1283859](https://github.com/Koenkk/zigbee2mqtt/commit/1283859a12d09ed1b7ea2605acbf2293adc80df0))
* **ignore:** update zigbee2mqtt-frontend to 0.9.21 ([#28745](https://github.com/Koenkk/zigbee2mqtt/issues/28745)) ([2c70deb](https://github.com/Koenkk/zigbee2mqtt/commit/2c70deb411fbf670f14b7c5457e96d21f7203822))
* **ignore:** update zigbee2mqtt-windfront to 2.1.0 ([#28471](https://github.com/Koenkk/zigbee2mqtt/issues/28471)) ([e52203a](https://github.com/Koenkk/zigbee2mqtt/commit/e52203a1b8b543820506780981de997e6e7edeff))
* Make windfront the default frontend ([#28546](https://github.com/Koenkk/zigbee2mqtt/issues/28546)) ([fefae99](https://github.com/Koenkk/zigbee2mqtt/commit/fefae9957c9d35bc2cfa1264cb23c4731d21a64a))
## [2.6.1](https://github.com/Koenkk/zigbee2mqtt/compare/2.6.0...2.6.1) (2025-09-01)
### Bug Fixes
* Always unlink `node_modules` symlinks on stop ([#28202](https://github.com/Koenkk/zigbee2mqtt/issues/28202)) ([63a4207](https://github.com/Koenkk/zigbee2mqtt/commit/63a4207061039e4dab50f5f999febe4d87397856))
* Home Assistant: set icon and state class for orientation sensors ([#28192](https://github.com/Koenkk/zigbee2mqtt/issues/28192)) ([b7cbd9b](https://github.com/Koenkk/zigbee2mqtt/commit/b7cbd9b5d830fc1b936208076c440bc561552220))
* Home Assistant: use "occupancy" device_class for human presence sensors ([#28329](https://github.com/Koenkk/zigbee2mqtt/issues/28329)) ([ccfab84](https://github.com/Koenkk/zigbee2mqtt/commit/ccfab84e7f93be151e841834943d8a65500fe5d9))
* **ignore:** bump the minor-patch group with 2 updates ([#28383](https://github.com/Koenkk/zigbee2mqtt/issues/28383)) ([a8fd23b](https://github.com/Koenkk/zigbee2mqtt/commit/a8fd23b6cc97468f345216913ebb6c8f27e83789))
* **ignore:** bump the minor-patch group with 4 updates ([#28147](https://github.com/Koenkk/zigbee2mqtt/issues/28147)) ([b72abd6](https://github.com/Koenkk/zigbee2mqtt/commit/b72abd675f409fca324b34936c1bfb6f3d8aefd4))
* **ignore:** bump the minor-patch group with 5 updates ([#28237](https://github.com/Koenkk/zigbee2mqtt/issues/28237)) ([1dd6cc3](https://github.com/Koenkk/zigbee2mqtt/commit/1dd6cc3da54527eb5560c7edec7e56324ef38ea1))
* **ignore:** update zigbee-herdsman to 6.0.0 ([#28235](https://github.com/Koenkk/zigbee2mqtt/issues/28235)) ([bd1be88](https://github.com/Koenkk/zigbee2mqtt/commit/bd1be886022ac13bb71828fc773df0f8f144ec8a))
* **ignore:** update zigbee-herdsman to 6.0.1 ([#28287](https://github.com/Koenkk/zigbee2mqtt/issues/28287)) ([bed8bec](https://github.com/Koenkk/zigbee2mqtt/commit/bed8beccbc54c06430bdd97bcfb8d14158756216))
* **ignore:** update zigbee-herdsman to 6.0.2 ([#28406](https://github.com/Koenkk/zigbee2mqtt/issues/28406)) ([264440e](https://github.com/Koenkk/zigbee2mqtt/commit/264440ebcc7c86fb6f2fbacd76a3b1bc451f01ad))
* **ignore:** update zigbee-herdsman to 6.0.4 ([#28438](https://github.com/Koenkk/zigbee2mqtt/issues/28438)) ([7788653](https://github.com/Koenkk/zigbee2mqtt/commit/77886530fe8c6599cfa1a1a797743fc07cdee75c))
* **ignore:** update zigbee-herdsman-converters to 24.12.0 ([#28170](https://github.com/Koenkk/zigbee2mqtt/issues/28170)) ([9836f0e](https://github.com/Koenkk/zigbee2mqtt/commit/9836f0e27a57863bdbd98f9770ea37036ccf243c))
* **ignore:** update zigbee-herdsman-converters to 24.13.0 ([#28175](https://github.com/Koenkk/zigbee2mqtt/issues/28175)) ([2056b89](https://github.com/Koenkk/zigbee2mqtt/commit/2056b89baca9018254e23b02fbf0a5250314159b))
* **ignore:** update zigbee-herdsman-converters to 24.14.0 ([#28199](https://github.com/Koenkk/zigbee2mqtt/issues/28199)) ([09f046c](https://github.com/Koenkk/zigbee2mqtt/commit/09f046c72d309ad4270e404376bcf009b3411b00))
* **ignore:** update zigbee-herdsman-converters to 25.0.0 ([#28236](https://github.com/Koenkk/zigbee2mqtt/issues/28236)) ([3160219](https://github.com/Koenkk/zigbee2mqtt/commit/3160219824bcb12c4cbff232d2b7ec5c5a73317b))
* **ignore:** update zigbee-herdsman-converters to 25.1.0 ([#28264](https://github.com/Koenkk/zigbee2mqtt/issues/28264)) ([2b17808](https://github.com/Koenkk/zigbee2mqtt/commit/2b1780879a62c8f1a044be7f2a525517f8af4082))
* **ignore:** update zigbee-herdsman-converters to 25.10.0 ([#28399](https://github.com/Koenkk/zigbee2mqtt/issues/28399)) ([32d8265](https://github.com/Koenkk/zigbee2mqtt/commit/32d82651e233665e1850d86d7f8a30a1cdead507))
* **ignore:** update zigbee-herdsman-converters to 25.11.0 ([#28407](https://github.com/Koenkk/zigbee2mqtt/issues/28407)) ([c07fce9](https://github.com/Koenkk/zigbee2mqtt/commit/c07fce936f8bbbfe6405938221c426ba8dd5eff2))
* **ignore:** update zigbee-herdsman-converters to 25.12.0 ([#28417](https://github.com/Koenkk/zigbee2mqtt/issues/28417)) ([bdeab61](https://github.com/Koenkk/zigbee2mqtt/commit/bdeab6100195a129980cf9d0151d6eb1b849a802))
* **ignore:** update zigbee-herdsman-converters to 25.13.0 ([#28427](https://github.com/Koenkk/zigbee2mqtt/issues/28427)) ([203019a](https://github.com/Koenkk/zigbee2mqtt/commit/203019aa26e3ee028e68d1ef9b4d032624fd7991))
* **ignore:** update zigbee-herdsman-converters to 25.14.0 ([#28439](https://github.com/Koenkk/zigbee2mqtt/issues/28439)) ([bf22dda](https://github.com/Koenkk/zigbee2mqtt/commit/bf22ddac466db6523b659b422a36c968700c385f))
* **ignore:** update zigbee-herdsman-converters to 25.2.0 ([#28280](https://github.com/Koenkk/zigbee2mqtt/issues/28280)) ([4c97842](https://github.com/Koenkk/zigbee2mqtt/commit/4c9784255bab79d7d8a394135bfd6f5b9e40ef9b))
* **ignore:** update zigbee-herdsman-converters to 25.3.0 ([#28301](https://github.com/Koenkk/zigbee2mqtt/issues/28301)) ([5f7c005](https://github.com/Koenkk/zigbee2mqtt/commit/5f7c00515adc8941c51b6ede219529719949a9bf))
* **ignore:** update zigbee-herdsman-converters to 25.4.0 ([#28313](https://github.com/Koenkk/zigbee2mqtt/issues/28313)) ([384bd30](https://github.com/Koenkk/zigbee2mqtt/commit/384bd309a6d980ba1555a7dcfe13539df67699a1))
* **ignore:** update zigbee-herdsman-converters to 25.5.0 ([#28328](https://github.com/Koenkk/zigbee2mqtt/issues/28328)) ([23f831a](https://github.com/Koenkk/zigbee2mqtt/commit/23f831a1feae67aacd5b76ac81bf3c857065f5d3))
* **ignore:** update zigbee-herdsman-converters to 25.6.0 ([#28344](https://github.com/Koenkk/zigbee2mqtt/issues/28344)) ([4bd0c04](https://github.com/Koenkk/zigbee2mqtt/commit/4bd0c04bdcdf22e08e152d52f3f5d9a565d8f5f7))
* **ignore:** update zigbee-herdsman-converters to 25.7.0 ([#28361](https://github.com/Koenkk/zigbee2mqtt/issues/28361)) ([7bd7bce](https://github.com/Koenkk/zigbee2mqtt/commit/7bd7bce69c4065d94a809baef80966ee07691efc))
* **ignore:** update zigbee-herdsman-converters to 25.8.0 ([#28373](https://github.com/Koenkk/zigbee2mqtt/issues/28373)) ([8e6ba79](https://github.com/Koenkk/zigbee2mqtt/commit/8e6ba7975b292b4ba0817de8b7877fb7b9892aed))
* **ignore:** update zigbee-herdsman-converters to 25.9.0 ([#28380](https://github.com/Koenkk/zigbee2mqtt/issues/28380)) ([0147441](https://github.com/Koenkk/zigbee2mqtt/commit/0147441c935a4a77f4fccdb59499672de64fabd5))
* **ignore:** update zigbee2mqtt-frontend to 0.9.20 ([#28250](https://github.com/Koenkk/zigbee2mqtt/issues/28250)) ([800b85c](https://github.com/Koenkk/zigbee2mqtt/commit/800b85c812e57e982a689554b64cbe73aee4f192))
* **ignore:** update zigbee2mqtt-windfront to 1.6.0 ([#28207](https://github.com/Koenkk/zigbee2mqtt/issues/28207)) ([092c95b](https://github.com/Koenkk/zigbee2mqtt/commit/092c95b7169d7e23c30db154988fb50d4099d750))
* **ignore:** update zigbee2mqtt-windfront to 1.8.0 ([#28266](https://github.com/Koenkk/zigbee2mqtt/issues/28266)) ([f4cfcba](https://github.com/Koenkk/zigbee2mqtt/commit/f4cfcbacde8cab83e0a7ea742f39f87c2bceb1e1))
* **ignore:** update zigbee2mqtt-windfront to 1.8.1 ([#28272](https://github.com/Koenkk/zigbee2mqtt/issues/28272)) ([d04ab53](https://github.com/Koenkk/zigbee2mqtt/commit/d04ab53f1d584aed400ab8fd0bcd985f78611cb5))
* **ignore:** update zigbee2mqtt-windfront to 2.0.1 ([#28420](https://github.com/Koenkk/zigbee2mqtt/issues/28420)) ([08903f6](https://github.com/Koenkk/zigbee2mqtt/commit/08903f68d9a9e20994a1da2eb4835ad28ca68877))
* Remove 60 MB (28%) of dev dependencies from Docker image ([#28160](https://github.com/Koenkk/zigbee2mqtt/issues/28160)) ([050ebfc](https://github.com/Koenkk/zigbee2mqtt/commit/050ebfc9f5dd5a2de07f5402d3c99ac8ab3b12b4))
* Set minimum for some settings ([#28209](https://github.com/Koenkk/zigbee2mqtt/issues/28209)) ([a102fac](https://github.com/Koenkk/zigbee2mqtt/commit/a102fac2c8ab09a126fddfb0cc7170d57bf114c0))
* Support for new ZH typing ([#28257](https://github.com/Koenkk/zigbee2mqtt/issues/28257)) ([b21f926](https://github.com/Koenkk/zigbee2mqtt/commit/b21f926b3e152176d09dc7f95fbc02b2b5ec13da))
* Update to refactored zhc api and fix `onEvent(stop)` not being called when device is force removed ([#27984](https://github.com/Koenkk/zigbee2mqtt/issues/27984)) ([5286570](https://github.com/Koenkk/zigbee2mqtt/commit/5286570bd625a714fad432dd693308caf59344de))
* update zigbee2mqtt-windfront to 2.0.0 ([#28389](https://github.com/Koenkk/zigbee2mqtt/issues/28389)) ([2df363f](https://github.com/Koenkk/zigbee2mqtt/commit/2df363f3af8f13c5fde91bab7ac276de3be7ee44))
## [2.6.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.5.1...2.6.0) (2025-08-01)
### Features
* Allow to disable internal frontend UI serving for standalone serving ([#27851](https://github.com/Koenkk/zigbee2mqtt/issues/27851)) ([0215e76](https://github.com/Koenkk/zigbee2mqtt/commit/0215e76118c8dcbe77c1ed4890846aaf7da85bae))
### Bug Fixes
* erroneous Health OS memory percent calculation ([#27892](https://github.com/Koenkk/zigbee2mqtt/issues/27892)) ([190cfe9](https://github.com/Koenkk/zigbee2mqtt/commit/190cfe99ac62b9dc6db4dc59a4389afda85e8177))
* Exit with code 2 when adapters disconnects ([#28013](https://github.com/Koenkk/zigbee2mqtt/issues/28013)) ([0e0a561](https://github.com/Koenkk/zigbee2mqtt/commit/0e0a5618af9a1b5707ddccbca2cde983cafb40b5))
* Fix settings schema defaults ([#27961](https://github.com/Koenkk/zigbee2mqtt/issues/27961)) ([ecadda4](https://github.com/Koenkk/zigbee2mqtt/commit/ecadda45e7c5665f9e242c956f5f140b7caa6eed))
* Ignore symbolic links in `data` during backup ([#28132](https://github.com/Koenkk/zigbee2mqtt/issues/28132)) ([0182c4b](https://github.com/Koenkk/zigbee2mqtt/commit/0182c4b32efafc85d1f4a38840345944501feaef))
* **ignore:** add test to specifically check HA envs ([#27969](https://github.com/Koenkk/zigbee2mqtt/issues/27969)) ([833605b](https://github.com/Koenkk/zigbee2mqtt/commit/833605b71f110b4e7aeb6632694820cdd1110e25))
* **ignore:** bump the minor-patch group with 2 updates ([#27924](https://github.com/Koenkk/zigbee2mqtt/issues/27924)) ([8ceb087](https://github.com/Koenkk/zigbee2mqtt/commit/8ceb08704f7e5d637b661c745c804beebf38eed9))
* **ignore:** bump the minor-patch group with 4 updates ([#28046](https://github.com/Koenkk/zigbee2mqtt/issues/28046)) ([e505a93](https://github.com/Koenkk/zigbee2mqtt/commit/e505a93a4036affe9e531b24e29ee543ed4cb0f5))
* **ignore:** fix 71cf2b41603a9923024c0472ffc1f7bfe3d14fc7 ([6dd812f](https://github.com/Koenkk/zigbee2mqtt/commit/6dd812f71cf9ca6737777c3d4acfab35c52a76b8))
* **ignore:** fix breaking changes of https://github.com/Koenkk/zigbee2mqtt/pull/28077 ([713551b](https://github.com/Koenkk/zigbee2mqtt/commit/713551b5257f8624ed03f7901f0addf6864faf1f))
* **ignore:** Remove retain/qos from mqtt publish logging https://github.com/Koenkk/zigbee2mqtt/pull/27983 ([e2b7df9](https://github.com/Koenkk/zigbee2mqtt/commit/e2b7df9f84550aa413f3453bb97a13ba2eee4187))
* **ignore:** update zigbee-herdsman to 4.3.2 ([#27900](https://github.com/Koenkk/zigbee2mqtt/issues/27900)) ([4afdd4e](https://github.com/Koenkk/zigbee2mqtt/commit/4afdd4e89241eddeb27e5f6c1c7b6604ce4a41cb))
* **ignore:** update zigbee-herdsman to 4.4.0 ([#27906](https://github.com/Koenkk/zigbee2mqtt/issues/27906)) ([e663e86](https://github.com/Koenkk/zigbee2mqtt/commit/e663e86e09cf86078877407e2233447468d66899))
* **ignore:** update zigbee-herdsman to 4.4.1 ([#27918](https://github.com/Koenkk/zigbee2mqtt/issues/27918)) ([eb82973](https://github.com/Koenkk/zigbee2mqtt/commit/eb829730678dad354d3feede2155f142d58a140f))
* **ignore:** update zigbee-herdsman to 4.5.0 ([#28044](https://github.com/Koenkk/zigbee2mqtt/issues/28044)) ([cc6f7b0](https://github.com/Koenkk/zigbee2mqtt/commit/cc6f7b02bc2d7548f142ec162696eae06027d6a3))
* **ignore:** update zigbee-herdsman to 5.0.0 ([#28077](https://github.com/Koenkk/zigbee2mqtt/issues/28077)) ([9a215ef](https://github.com/Koenkk/zigbee2mqtt/commit/9a215efe26be6fafde0f0a779b651ded860cd90c))
* **ignore:** update zigbee-herdsman to 5.0.1 ([#28080](https://github.com/Koenkk/zigbee2mqtt/issues/28080)) ([6432571](https://github.com/Koenkk/zigbee2mqtt/commit/6432571c61b6e2d4bcc17cc952eeacdaf72213f6))
* **ignore:** update zigbee-herdsman to 5.0.2 ([#28110](https://github.com/Koenkk/zigbee2mqtt/issues/28110)) ([e9f2a3f](https://github.com/Koenkk/zigbee2mqtt/commit/e9f2a3f5326954f361199bd4d73ed944f72f6ebe))
* **ignore:** update zigbee-herdsman to 5.0.3 ([#28121](https://github.com/Koenkk/zigbee2mqtt/issues/28121)) ([e5f677b](https://github.com/Koenkk/zigbee2mqtt/commit/e5f677bdd1cc3df4d05dd1f8164220ae2258f081))
* **ignore:** update zigbee-herdsman to 5.0.4 ([#28141](https://github.com/Koenkk/zigbee2mqtt/issues/28141)) ([9fcc41b](https://github.com/Koenkk/zigbee2mqtt/commit/9fcc41b199082e47d96de4cad996c55c39da83a5))
* **ignore:** update zigbee-herdsman-converters to 23.70.0 ([#27905](https://github.com/Koenkk/zigbee2mqtt/issues/27905)) ([163a15d](https://github.com/Koenkk/zigbee2mqtt/commit/163a15d664432631f100814927647b918563d85b))
* **ignore:** update zigbee-herdsman-converters to 23.70.1 ([#27916](https://github.com/Koenkk/zigbee2mqtt/issues/27916)) ([e047324](https://github.com/Koenkk/zigbee2mqtt/commit/e04732462c218cd42c2b617aefba37a5f61d1ce6))
* **ignore:** update zigbee-herdsman-converters to 23.71.0 ([#27931](https://github.com/Koenkk/zigbee2mqtt/issues/27931)) ([93c1824](https://github.com/Koenkk/zigbee2mqtt/commit/93c1824b1d81c997f0e5a445acac421300f55758))
* **ignore:** update zigbee-herdsman-converters to 23.72.0 ([#27943](https://github.com/Koenkk/zigbee2mqtt/issues/27943)) ([b4a9424](https://github.com/Koenkk/zigbee2mqtt/commit/b4a9424d604c47609071ef33e88ee55a55a6c8d5))
* **ignore:** update zigbee-herdsman-converters to 24.0.0 ([#27946](https://github.com/Koenkk/zigbee2mqtt/issues/27946)) ([c465ef8](https://github.com/Koenkk/zigbee2mqtt/commit/c465ef83526c434cac6555836d82e7707072b754))
* **ignore:** update zigbee-herdsman-converters to 24.1.0 ([#27973](https://github.com/Koenkk/zigbee2mqtt/issues/27973)) ([a837702](https://github.com/Koenkk/zigbee2mqtt/commit/a837702b2c1f517dcfa3a0f578d9b5b3700b171d))
* **ignore:** update zigbee-herdsman-converters to 24.10.0 ([#28122](https://github.com/Koenkk/zigbee2mqtt/issues/28122)) ([ee1c884](https://github.com/Koenkk/zigbee2mqtt/commit/ee1c884031fdef516393ce68754a9dbcc8ad093d))
* **ignore:** update zigbee-herdsman-converters to 24.11.0 ([#28143](https://github.com/Koenkk/zigbee2mqtt/issues/28143)) ([3765549](https://github.com/Koenkk/zigbee2mqtt/commit/376554943d15c45a15d30713002d825286204581))
* **ignore:** update zigbee-herdsman-converters to 24.2.0 ([#27986](https://github.com/Koenkk/zigbee2mqtt/issues/27986)) ([6ede403](https://github.com/Koenkk/zigbee2mqtt/commit/6ede4036886066bf91953b13714a16ba96217b6d))
* **ignore:** update zigbee-herdsman-converters to 24.3.0 ([#28015](https://github.com/Koenkk/zigbee2mqtt/issues/28015)) ([21fa5cb](https://github.com/Koenkk/zigbee2mqtt/commit/21fa5cbdeddb51d55364c4d28b887791384a951b))
* **ignore:** update zigbee-herdsman-converters to 24.4.0 ([#28022](https://github.com/Koenkk/zigbee2mqtt/issues/28022)) ([74e9357](https://github.com/Koenkk/zigbee2mqtt/commit/74e9357a5989c6b1eeb25aa91c0da27b7b54fa67))
* **ignore:** update zigbee-herdsman-converters to 24.5.0 ([#28045](https://github.com/Koenkk/zigbee2mqtt/issues/28045)) ([bc74d0b](https://github.com/Koenkk/zigbee2mqtt/commit/bc74d0b11b58bb2cb2bcc07f5ae9c47b1ca7a13e))
* **ignore:** update zigbee-herdsman-converters to 24.6.0 ([#28051](https://github.com/Koenkk/zigbee2mqtt/issues/28051)) ([4bc6a59](https://github.com/Koenkk/zigbee2mqtt/commit/4bc6a59ff38f9a102259afbff2606a75409042a3))
* **ignore:** update zigbee-herdsman-converters to 24.7.0 ([#28070](https://github.com/Koenkk/zigbee2mqtt/issues/28070)) ([81bafa5](https://github.com/Koenkk/zigbee2mqtt/commit/81bafa55201d6d7a34b364a85c19423a56eceeee))
* **ignore:** update zigbee-herdsman-converters to 24.8.0 ([#28081](https://github.com/Koenkk/zigbee2mqtt/issues/28081)) ([caf91cd](https://github.com/Koenkk/zigbee2mqtt/commit/caf91cd7ffb8ae4961c3f43875f051b182f1a379))
* **ignore:** update zigbee-herdsman-converters to 24.9.0 ([71cf2b4](https://github.com/Koenkk/zigbee2mqtt/commit/71cf2b41603a9923024c0472ffc1f7bfe3d14fc7))
* **ignore:** update zigbee2mqtt-frontend to 0.9.17 ([#27917](https://github.com/Koenkk/zigbee2mqtt/issues/27917)) ([a3e7f69](https://github.com/Koenkk/zigbee2mqtt/commit/a3e7f69b3ff6173a59b4d5e2ad1f09c2c414b416))
* **ignore:** update zigbee2mqtt-frontend to 0.9.19 ([#28021](https://github.com/Koenkk/zigbee2mqtt/issues/28021)) ([8b87682](https://github.com/Koenkk/zigbee2mqtt/commit/8b876823da7fbde4f980fefa54cec7c3fa52c423))
* **ignore:** update zigbee2mqtt-windfront to 1.3.0 ([#27953](https://github.com/Koenkk/zigbee2mqtt/issues/27953)) ([1b37bae](https://github.com/Koenkk/zigbee2mqtt/commit/1b37baefd3e6817fc9dbf36c9ec9e8aec4481643))
* **ignore:** update zigbee2mqtt-windfront to 1.3.1 ([#27999](https://github.com/Koenkk/zigbee2mqtt/issues/27999)) ([a51216e](https://github.com/Koenkk/zigbee2mqtt/commit/a51216e3c76335b66a9383b1734a213ad948d70a))
* **ignore:** update zigbee2mqtt-windfront to 1.4.1 ([#28062](https://github.com/Koenkk/zigbee2mqtt/issues/28062)) ([d322dc2](https://github.com/Koenkk/zigbee2mqtt/commit/d322dc20479faffa8f923e2190c1a5f82d17d5e4))
* **ignore:** update zigbee2mqtt-windfront to 1.5.0 ([#28082](https://github.com/Koenkk/zigbee2mqtt/issues/28082)) ([29e56ad](https://github.com/Koenkk/zigbee2mqtt/commit/29e56adc159268038727d955135d3edbedbaae8b))
* **ignore:** update zigbee2mqtt-windfront to 1.5.1 ([#28123](https://github.com/Koenkk/zigbee2mqtt/issues/28123)) ([31d3e19](https://github.com/Koenkk/zigbee2mqtt/commit/31d3e19f3862b067555bcbc9bbcd3a5ded6df211))
* **ignore:** update zigbee2mqtt-windfront to 1.5.2 ([#28140](https://github.com/Koenkk/zigbee2mqtt/issues/28140)) ([fe5639c](https://github.com/Koenkk/zigbee2mqtt/commit/fe5639ce40cf0a4035cd996a558c952dc065fcd0))
* Improve startup settings validation ([#27988](https://github.com/Koenkk/zigbee2mqtt/issues/27988)) ([435a6cc](https://github.com/Koenkk/zigbee2mqtt/commit/435a6cceb9aea1ac6941c99603d7785ef91e274a))
* log error message when external JS fails to load ([#27837](https://github.com/Koenkk/zigbee2mqtt/issues/27837)) ([f4d821d](https://github.com/Koenkk/zigbee2mqtt/commit/f4d821deca71540f830f38f631d1a3759c1d0f40))
* Publish definition source in `bridge/devices` ([#28076](https://github.com/Koenkk/zigbee2mqtt/issues/28076)) ([65eef1b](https://github.com/Koenkk/zigbee2mqtt/commit/65eef1b6900afdf831c809047ab68e93df59b4b0))
* Publish endpoint names in `bridge/devices` ([#28057](https://github.com/Koenkk/zigbee2mqtt/issues/28057)) ([5f9019a](https://github.com/Koenkk/zigbee2mqtt/commit/5f9019a95fb460f1dc4a31d09e2424ff56049c14))
* Set MQTT retain and qos=1 on (re)connect ([#27983](https://github.com/Koenkk/zigbee2mqtt/issues/27983)) ([ef07505](https://github.com/Koenkk/zigbee2mqtt/commit/ef07505b31782936a3ae517688c7e1eb9e541daf))
* Various fixes to align behaviors (+add benchmarks for dev) ([#28011](https://github.com/Koenkk/zigbee2mqtt/issues/28011)) ([db20369](https://github.com/Koenkk/zigbee2mqtt/commit/db203694477ff718386a3d97ca260dccd0dc0597))
## [2.5.1](https://github.com/Koenkk/zigbee2mqtt/compare/2.5.0...2.5.1) (2025-07-02)
@@ -183,7 +631,7 @@
* 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))
* 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))
+1 -1
View File
@@ -4,5 +4,5 @@ Everybody is invited and welcome to contribute to Zigbee2MQTT. Zigbee2MQTT is wr
- Pull requests are always created against the [**dev**](https://github.com/Koenkk/zigbee2mqtt/tree/dev) branch.
- Easiest way to start developing Zigbee2MQTT is by setting up a development environment (aka bare-metal installation). You can follow this [guide](https://www.zigbee2mqtt.io/guide/installation/01_linux.html) to do this.
- You can run the tests locally by executing `pnpm test`. Zigbee2MQTT enforces 100% code coverage, in case you add new code check if your code is covered by running `pnpm run test-with-coverage`. The coverage report can be found under `coverage/lcov-report/index.html`. Linting is also enforced and can be run with `pnpm run eslint`.
- You can run the tests locally by executing `pnpm test`. Zigbee2MQTT enforces 100% code coverage, in case you add new code check if your code is covered by running `pnpm run test:coverage`. The coverage report can be found under `coverage/lcov-report/index.html`. Linting is also enforced and can be run with `pnpm run eslint`.
- When you want to add support for a new device no changes to Zigbee2MQTT have to be made, only to zigbee-herdsman-converters. You can find a guide for it [here](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html).
+42 -40
View File
@@ -5,33 +5,27 @@
<br>
<br>
<div style="display: flex;">
<a href="https://github.com/Koenkk/zigbee2mqtt/actions?query=workflow%3Aci">
<img src="https://github.com/koenkk/zigbee2mqtt/workflows/ci/badge.svg">
</a>
<a href="https://github.com/Koenkk/zigbee2mqtt/releases">
<img src="https://img.shields.io/github/release/koenkk/zigbee2mqtt.svg">
</a>
<a href="https://www.npmjs.com/package/zigbee2mqtt">
<img src="https://img.shields.io/npm/v/zigbee2mqtt">
</a>
<a href="https://github.com/Koenkk/zigbee2mqtt/actions/workflows/ci.yml">
<img src="https://github.com/Koenkk/zigbee2mqtt/actions/workflows/ci.yml/badge.svg">
</a>
<a href="https://github.com/Koenkk/zigbee2mqtt/actions/workflows/github-code-scanning/codeql">
<img src="https://github.com/Koenkk/zigbee2mqtt/actions/workflows/github-code-scanning/codeql/badge.svg">
</a>
<a href="https://discord.gg/dadfWYE">
<img src="https://img.shields.io/discord/556563650429583360.svg">
</a>
<a href="https://github.com/Koenkk/zigbee2mqtt/stargazers">
<img src="https://img.shields.io/github/stars/koenkk/zigbee2mqtt.svg">
</a>
<a href="https://www.paypal.me/koenkk">
<img src="https://img.shields.io/badge/donate-PayPal-blue.svg">
</a>
<a href="https://discord.gg/dadfWYE">
<img src="https://img.shields.io/discord/556563650429583360.svg">
</a>
<a href="http://zigbee2mqtt.discourse.group/">
<img src="https://img.shields.io/discourse/https/zigbee2mqtt.discourse.group/status.svg">
</a>
<a>
<img src="https://img.shields.io/badge/Coverage-100%25-brightgreen.svg">
</a>
<a href="https://www.codacy.com/manual/Koenkk/zigbee2mqtt?utm_source=github.com&amp;utm_medium=referral&amp;utm_content=Koenkk/zigbee2mqtt&amp;utm_campaign=Badge_Grade">
<img src="https://api.codacy.com/project/badge/Grade/24f1e0fe39f04daa810e8a1416693d3f">
</a>
<a href="https://www.npmjs.com/package/zigbee2mqtt">
<img src="https://img.shields.io/npm/v/zigbee2mqtt">
</a>
</div>
<h1>Zigbee2MQTT 🌉 🐝</h1>
<p>
@@ -52,59 +46,67 @@ If you aren't familiar with **Zigbee** terminology make sure you [read this](htt
Zigbee2MQTT integrates well with (almost) every home automation solution because it uses MQTT. However the following integrations are worth mentioning:
<img align="left" height="100px" width="100px" src="https://user-images.githubusercontent.com/7738048/40914297-49e6e560-6800-11e8-8904-36cce896e5a8.png">
### [Home Assistant](https://www.home-assistant.io/)
- [Home Assistant OS](https://www.home-assistant.io/installation/): Using [the official addon](https://github.com/zigbee2mqtt/hassio-zigbee2mqtt)
- Other installation: using instructions [here](https://www.zigbee2mqtt.io/guide/usage/integrations/home_assistant.html)
<a href="https://www.home-assistant.io/"><img align="left" src="https://avatars.githubusercontent.com/u/13844975?s=75" style="padding-right: 20px;" alt="Home Assistant"></a>
<br>
<br clear="right">
<img align="left" height="100px" width="100px" src="https://etc.athom.com/logo/white/256.png">
[Home Assistant OS](https://www.home-assistant.io/installation/) using [the official addon](https://github.com/zigbee2mqtt/hassio-zigbee2mqtt) ([other installations](https://www.zigbee2mqtt.io/guide/usage/integrations/home_assistant.html))
<br clear="both">
### [Homey](https://homey.app/)
- Integration implemented in the [Homey App](https://homey.app/nl-nl/app/com.gruijter.zigbee2mqtt/)
- Documentation and support in the [Homey Forum](https://community.homey.app/t/83214)
<a href="https://homey.app/"><img align="left" src="https://avatars.githubusercontent.com/u/8502422?s=75" style="padding-right: 20px;" alt="Homey"></a>
<br>
<br clear="right">
<img align="left" height="100px" width="100px" src="https://user-images.githubusercontent.com/2734836/47615848-b8dd8700-dabd-11e8-9d77-175002dd8987.png">
Integration implemented in the [Homey App](https://homey.app/nl-nl/app/com.gruijter.zigbee2mqtt/) ([documentation & support](https://community.homey.app/t/83214))
<br clear="both">
### [Domoticz](https://www.domoticz.com/)
- Integration implemented in Domoticz ([documentation](https://www.domoticz.com/wiki/Zigbee2MQTT)).
<a href="https://www.domoticz.com/"><img align="left" src="https://avatars.githubusercontent.com/u/6067010?s=75" style="padding-right: 20px;" alt="Domoticz"></a>
<br>
<br clear="right">
<img align="left" height="100px" width="100px" src="./images/gladys-assistant-logo.jpg">
Integration implemented in Domoticz ([documentation](https://www.domoticz.com/wiki/Zigbee2MQTT)).
<br clear="both">
### [Gladys Assistant](https://gladysassistant.com/)
- Integration implemented natively in Gladys Assistant ([documentation](https://gladysassistant.com/docs/integrations/zigbee2mqtt/)).
<a href="https://gladysassistant.com/"><img align="left" src="https://avatars.githubusercontent.com/u/7365162?s=75" style="padding-right: 20px;" alt="Gladys Assistant"></a>
<br>
<br clear="right">
<img align="left" height="100px" width="100px" src="https://forum.iobroker.net/assets/uploads/system/site-logo.png">
Integration implemented natively in Gladys Assistant ([documentation](https://gladysassistant.com/docs/integrations/zigbee2mqtt/)).
### [IoBroker](https://www.iobroker.net/)
<br clear="both">
- Integration implemented in IoBroker ([documentation](https://github.com/o0shojo0o/ioBroker.zigbee2mqtt)).
### [ioBroker](https://www.iobroker.net/)
<br>
<a href="https://www.iobroker.net/"><img align="left" src="https://avatars.githubusercontent.com/u/7231148?s=75" style="padding-right: 20px;" alt="ioBroker"></a>
<br clear="right">
Integration implemented in ioBroker ([documentation](https://github.com/o0shojo0o/ioBroker.zigbee2mqtt)).
<br clear="both">
## Architecture
![Architecture](images/architecture.png)
![Architecture](images/architecture-new.png)
### Internal Architecture
Zigbee2MQTT is made up of three modules, each developed in its own Github project. Starting from the hardware (adapter) and moving up; [zigbee-herdsman](https://github.com/koenkk/zigbee-herdsman) connects to your Zigbee adapter and makes an API available to the higher levels of the stack. For e.g. Texas Instruments hardware, zigbee-herdsman uses the [TI zStack monitoring and test API](https://github.com/koenkk/zigbee-herdsman/raw/master/docs/Z-Stack%20Monitor%20and%20Test%20API.pdf) to communicate with the adapter. Zigbee-herdsman handles the core Zigbee communication. The module [zigbee-herdsman-converters](https://github.com/koenkk/zigbee-herdsman-converters) handles the mapping from individual device models to the Zigbee clusters they support. [Zigbee clusters](https://github.com/Koenkk/zigbee-herdsman/blob/master/docs/07-5123-08-Zigbee-Cluster-Library.pdf) are the layers of the Zigbee protocol on top of the base protocol that define things like how lights, sensors and switches talk to each other over the Zigbee network. Finally, the Zigbee2MQTT module drives zigbee-herdsman and maps the zigbee messages to MQTT messages. Zigbee2MQTT also keeps track of the state of the system. It uses a `database.db` file to store this state; a text file with a JSON database of connected devices and their capabilities. Zigbee2MQTT provides a [web-based interface](https://github.com/nurikk/zigbee2mqtt-frontend) that allows monitoring and configuration.
Zigbee2MQTT is made up of three modules, each developed in its own Github project. Starting from the hardware (adapter) and moving up; [zigbee-herdsman](https://github.com/koenkk/zigbee-herdsman) connects to your adapter to handle Zigbee communication and makes an API available to the higher levels of the stack. For e.g. Texas Instruments hardware, zigbee-herdsman uses the [TI zStack monitoring and test API](https://github.com/Koenkk/zigbee-herdsman/wiki/References#texas-instruments-zstack) to communicate with the adapter. The module [zigbee-herdsman-converters](https://github.com/koenkk/zigbee-herdsman-converters) handles the mapping from individual device models to the Zigbee clusters they support. [Zigbee clusters](https://github.com/Koenkk/zigbee-herdsman/wiki/References#csa-zigbee-alliance-spec) are the layers of the Zigbee protocol on top of the base protocol that define things like how lights, sensors and switches talk to each other over the Zigbee network. Finally, the Zigbee2MQTT module drives zigbee-herdsman and maps the zigbee messages to MQTT messages. Zigbee2MQTT also keeps track of the state of the system. It uses a `database.db` file to store this state; a text file with a JSON database of connected devices and their capabilities. Zigbee2MQTT provides several web-based interfaces ([zigbee2mqtt-frontend](https://github.com/nurikk/zigbee2mqtt-frontend), [zigbee2mqtt-windfront](https://github.com/Nerivec/zigbee2mqtt-windfront)) that allows monitoring and configuration.
### Developing
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.
Zigbee2MQTT uses TypeScript. 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 check:w` then `pnpm run test:coverage`.
+7 -2
View File
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.0.6/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.10/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
@@ -12,7 +12,7 @@
"bracketSpacing": false
},
"files": {
"includes": ["**", "!package.json"]
"includes": ["**", "!package.json", "!!**/dist", "!!**/coverage"]
},
"linter": {
"includes": ["**"],
@@ -72,6 +72,11 @@
"suspicious": {
"noConstEnum": "off",
"useAwait": "error"
},
"nursery": {
"useExhaustiveSwitchCases": "error",
"noFloatingPromises": "error",
"noMisusedPromises": "error"
}
}
},
+2 -2
View File
@@ -1,5 +1,5 @@
# Indicates the configuration version (used by configuration migrations)
version: 4
version: 5
# Home Assistant integration (MQTT discovery)
homeassistant:
@@ -15,7 +15,7 @@ mqtt:
# MQTT base topic for zigbee2mqtt MQTT messages
base_topic: zigbee2mqtt
# MQTT server URL
server: 'mqtt://localhost'
server: "mqtt://localhost"
# MQTT server authentication, uncomment if required:
# user: my_user
# password: my_password
+16 -6
View File
@@ -1,6 +1,6 @@
ARG TARGETPLATFORM
FROM alpine:3.22 AS base
FROM alpine:3.23 AS base
ENV NODE_ENV=production
WORKDIR /app
@@ -10,12 +10,19 @@ RUN apk add --no-cache tzdata eudev tini nodejs
FROM base AS deps
COPY package.json pnpm-lock.yaml ./
# Make and such are needed to compile serialport for riscv64
RUN apk add make gcc g++ python3 linux-headers npm && \
npm install -g pnpm@$(sed -n 's/.*"packageManager": "pnpm@\([0-9.]*\)".*/\1/p' package.json) && \
pnpm install --frozen-lockfile --no-optional && \
# serialport has outdated prebuilds that appear to fail on some archs, force build on target platform
rm -rf `find ./node_modules/.pnpm/ -wholename "*/@serialport/bindings-cpp/prebuilds" -type d` && \
# Install pnpm, configure a tmp-based store, then use it under a cache mount so each arch gets its own store
# NOTE: We do not install devDependencies here (the --prod argument in pnpm install) as we use an already
# built artifact (./dist directory) from previous GitHub runner stages. Omitting devDependencies here
# saves storage as this node_modules/ is being copied straight to the release stage later.
RUN --mount=type=cache,id=pnpm-store,target=/tmp/pnpm-store \
apk add --no-cache make gcc g++ python3 linux-headers npm jq && \
npm install -g $(jq -r '.packageManager' package.json) && \
pnpm config set store-dir /tmp/pnpm-store && \
pnpm install --frozen-lockfile --prod --no-optional && \
# force serialport to rebuild on current platform instead of using prebuilds
rm -rf $(find ./node_modules/.pnpm/ -wholename "*/@serialport/bindings-cpp/prebuilds" -type d) && \
pnpm rebuild @serialport/bindings-cpp
# Release
@@ -45,5 +52,8 @@ RUN mkdir /app/data
ARG COMMIT
RUN echo "$COMMIT" > dist/.hash
# Expose the default frontend port (see lib/util/settings.ts defaults.frontend.port)
EXPOSE 8080
ENTRYPOINT ["docker-entrypoint.sh"]
CMD [ "/sbin/tini", "--", "node", "index.js"]
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

+29 -20
View File
@@ -1,20 +1,17 @@
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();
/** @type {import("./dist/controller").Controller | undefined} */
let controller;
let stopping = false;
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 (process.env.Z2M_WATCHDOG != null && 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 {
@@ -25,6 +22,7 @@ if (watchdog && process.env.Z2M_WATCHDOG !== "default") {
const hashFile = path.join(__dirname, "dist", ".hash");
/** @type {(code: number) => Promise<void>} */
async function triggerWatchdog(code) {
const delay = watchdogDelays[watchdogCount];
watchdogCount += 1;
@@ -41,14 +39,16 @@ async function triggerWatchdog(code) {
}
}
/** @type {() => Promise<void>} */
async function restart() {
await stop(true);
await start();
}
/** @type {(code: number, restart?: boolean) => Promise<void>} */
async function exit(code, restart = false) {
if (!restart) {
if (watchdog && unsolicitedStop) {
if (process.env.Z2M_WATCHDOG != null && unsolicitedStop) {
await triggerWatchdog(code);
} else {
process.exit(code);
@@ -56,6 +56,7 @@ async function exit(code, restart = false) {
}
}
/** @type {() => Promise<string>} */
async function currentHash() {
return await new Promise((resolve) => {
exec("git rev-parse --short=8 HEAD", (error, stdout) => {
@@ -70,20 +71,23 @@ async function currentHash() {
});
}
/** @type {() => Promise<void>} */
async function writeHash() {
const hash = await currentHash();
fs.writeFileSync(hashFile, hash);
}
/** @type {(reason: "initial build" | "hash changed") => Promise<void>} */
async function build(reason) {
process.stdout.write(`Building Zigbee2MQTT... (${reason})`);
const {totalmem} = await import("node:os");
return await new Promise((resolve, reject) => {
const env = {...process.env};
const mb600 = 629145600;
if (mb600 > os.totalmem() && !env.NODE_OPTIONS) {
if (mb600 > 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";
@@ -107,6 +111,7 @@ async function build(reason) {
});
}
/** @type {() => Promise<void>} */
async function checkDist() {
if (!fs.existsSync(hashFile)) {
await build("initial build");
@@ -120,20 +125,22 @@ async function checkDist() {
}
}
/** @type {() => Promise<void>} */
async function start() {
console.log(`Starting Zigbee2MQTT ${watchdog ? `with watchdog (${watchdogDelays})` : "without watchdog"}.`);
console.log(`Starting Zigbee2MQTT ${process.env.Z2M_WATCHDOG != null ? `with watchdog (${watchdogDelays})` : "without watchdog"}.`);
await checkDist();
// gc
{
const version = engines.node;
const packageJSON = (await import("./package.json", {with: {type: "json"}})).default;
const version = packageJSON.engines.node;
const {satisfies} = await import("semver");
if (!semver.satisfies(process.version, version)) {
if (!satisfies(process.version, version)) {
console.log(`\t\tZigbee2MQTT requires node version ${version}, you are running ${process.version}!\n`);
}
const {onboard} = require("./dist/util/onboarding");
const {onboard} = await import("./dist/util/onboarding.js");
const success = await onboard();
if (!success) {
@@ -143,7 +150,7 @@ async function start() {
}
}
const {Controller} = require("./dist/controller");
const {Controller} = await import("./dist/controller.js");
controller = new Controller(restart, exit);
await controller.start();
@@ -153,32 +160,34 @@ async function start() {
watchdogCount = 0; // reset
}
async function stop(restart) {
/** @type {(restart: boolean, signal?: NodeJS.Signals) => Promise<void>} */
async function stop(restart, signal = undefined) {
// `handleQuit` or `restart` never unsolicited
unsolicitedStop = false;
await controller.stop(restart);
await controller?.stop(restart, undefined, signal);
}
async function handleQuit() {
/** @type {(signal: NodeJS.Signals) => Promise<void>} */
async function handleQuit(signal) {
if (!stopping) {
if (controller) {
stopping = true;
await stop(false);
await stop(false, signal);
} else {
process.exit(0);
}
}
}
if (require.main === module || require.main.filename.endsWith(`${path.sep}cli.js`)) {
if (require.main === module || require.main?.filename.endsWith(`${path.sep}cli.js`)) {
if (process.argv.length === 3 && process.argv[2] === "writehash") {
writeHash();
void writeHash();
} else {
process.on("SIGINT", handleQuit);
process.on("SIGTERM", handleQuit);
start();
void start();
}
} else {
process.on("SIGINT", handleQuit);
+81 -33
View File
@@ -28,10 +28,12 @@ import utils from "./util/utils";
import Zigbee from "./zigbee";
export class Controller {
private eventBus: EventBus;
private zigbee: Zigbee;
private state: State;
private mqtt: Mqtt;
/** Allows canceling in-progress startup sequence if necessary. Signal can be passed to long-running ops as necessary for finer control. */
#startAbortController: AbortController | undefined;
public readonly eventBus: EventBus;
public readonly zigbee: Zigbee;
public readonly state: State;
public readonly mqtt: Mqtt;
private restartCallback: () => Promise<void>;
private exitCallback: (code: number, restart: boolean) => Promise<void>;
public readonly extensions: Set<Extension>;
@@ -79,6 +81,9 @@ export class Controller {
}
async start(): Promise<void> {
this.#startAbortController = new AbortController();
const abortSignal = this.#startAbortController.signal;
if (settings.get().frontend.enabled) {
const {Frontend} = await import("./extension/frontend.js");
@@ -93,29 +98,48 @@ export class Controller {
this.state.start();
if (abortSignal.aborted) {
logger.info(`Zigbee2MQTT start aborted, reason=${abortSignal.reason}`);
return;
}
const info = await utils.getZigbee2MQTTVersion();
logger.info(`Starting Zigbee2MQTT version ${info.version} (commit #${info.commitHash})`);
// Start zigbee
try {
await this.zigbee.start();
this.eventBus.onAdapterDisconnected(this, this.onZigbeeAdapterDisconnected);
} catch (error) {
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",
);
logger.error("Exiting...");
// biome-ignore lint/style/noNonNullAssertion: always Error
logger.error((error as Error).stack!);
const started = await this.zigbee.start(abortSignal);
/* 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 (started) {
this.eventBus.onAdapterDisconnected(this, async () => {
logger.error("Adapter disconnected, stopping");
await this.stop(false, 2);
});
}
/* v8 ignore stop */
} catch (error) {
// skip if aborted by `stop`
if (error !== "SIGINT" && error !== "SIGTERM" && error !== "STOPABORT") {
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",
);
logger.error("Exiting...");
// biome-ignore lint/style/noNonNullAssertion: always Error
logger.error((error as Error).stack!);
return await this.exit(1);
/* 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");
}
/* v8 ignore stop */
return await this.exit(1);
}
}
if (abortSignal.aborted) {
logger.info(`Zigbee2MQTT start aborted, reason=${abortSignal.reason}`);
return;
}
// Log zigbee clients on startup
@@ -143,9 +167,19 @@ export class Controller {
return await this.exit(1);
}
if (abortSignal.aborted) {
logger.info(`Zigbee2MQTT start aborted, reason=${abortSignal.reason}`);
return;
}
// 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);
if (abortSignal.aborted) {
logger.info(`Zigbee2MQTT start aborted, reason=${abortSignal.reason}`);
return;
}
}
// Send all cached states.
@@ -153,21 +187,32 @@ export class Controller {
for (const entity of this.zigbee.devicesAndGroupsIterator()) {
if (this.state.exists(entity)) {
await this.publishEntityState(entity, this.state.get(entity), "publishCached");
if (abortSignal.aborted) {
logger.info(`Zigbee2MQTT start aborted, reason=${abortSignal.reason}`);
return;
}
}
}
}
this.eventBus.onLastSeenChanged(this, (data) => utils.publishLastSeen(data, settings.get(), false, this.publishEntityState));
this.eventBus.onLastSeenChanged(this, (data) => {
utils.publishLastSeen(data, settings.get(), false, this.publishEntityState).catch(() => {});
});
logger.info("Zigbee2MQTT started!");
this.sdNotify = await initSdNotify();
settings.setOnboarding(false);
this.#startAbortController = undefined; // gc
}
@bind async enableDisableExtension(enable: boolean, name: string): Promise<void> {
if (enable) {
let extension: Extension;
switch (name) {
case "Frontend": {
if (!settings.get().frontend.enabled) {
@@ -178,7 +223,7 @@ export class Controller {
/* v8 ignore start */
const {Frontend} = await import("./extension/frontend.js");
await this.addExtension(new Frontend(...this.extensionArgs));
extension = new Frontend(...this.extensionArgs);
break;
/* v8 ignore stop */
@@ -190,7 +235,7 @@ export class Controller {
const {HomeAssistant} = await import("./extension/homeassistant.js");
await this.addExtension(new HomeAssistant(...this.extensionArgs));
extension = new HomeAssistant(...this.extensionArgs);
break;
}
@@ -200,6 +245,12 @@ export class Controller {
);
}
}
const existingExtension = this.getExtension(name);
if (existingExtension) {
await this.removeExtension(existingExtension);
}
await this.extensions.add(extension);
} else {
switch (name) {
case "Frontend": {
@@ -281,17 +332,19 @@ export class Controller {
}
}
async stop(restart = false): Promise<void> {
async stop(restart = false, code = 0, signal: NodeJS.Signals | undefined = undefined): Promise<void> {
logger.info(`Stopping Zigbee2MQTT (restart=${restart}, code=${code}, signal=${signal})`);
this.#startAbortController?.abort(signal ?? "STOPABORT");
this.sdNotify?.notifyStopping();
let code = 0;
let localCode = 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;
localCode = 1;
}
}
@@ -306,11 +359,11 @@ export class Controller {
logger.info("Stopped Zigbee2MQTT");
} catch (error) {
logger.error(`Failed to stop Zigbee2MQTT (${(error as Error).stack})`);
code = 1;
localCode = 1;
}
this.sdNotify?.stop();
return await this.exit(code, restart);
return await this.exit(code !== 0 ? code : localCode, restart);
}
async exit(code: number, restart = false): Promise<void> {
@@ -318,11 +371,6 @@ export class Controller {
return await this.exitCallback(code, restart);
}
@bind async onZigbeeAdapterDisconnected(): Promise<void> {
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};
+7 -7
View File
@@ -8,8 +8,8 @@ interface EventBusMap {
adapterDisconnected: [];
permitJoinChanged: [data: eventdata.PermitJoinChanged];
publishAvailability: [];
deviceRenamed: [data: eventdata.EntityRenamed];
deviceRemoved: [data: eventdata.EntityRemoved];
entityRenamed: [data: eventdata.EntityRenamed];
entityRemoved: [data: eventdata.EntityRemoved];
lastSeenChanged: [data: eventdata.LastSeenChanged];
deviceNetworkAddressChanged: [data: eventdata.DeviceNetworkAddressChanged];
deviceAnnounce: [data: eventdata.DeviceAnnounce];
@@ -61,7 +61,7 @@ export default class EventBus {
public emitAdapterDisconnected(): void {
this.emitter.emit("adapterDisconnected");
}
public onAdapterDisconnected(key: ListenerKey, callback: () => void): void {
public onAdapterDisconnected(key: ListenerKey, callback: () => Promise<void>): void {
this.on("adapterDisconnected", callback, key);
}
@@ -73,17 +73,17 @@ export default class EventBus {
}
public emitEntityRenamed(data: eventdata.EntityRenamed): void {
this.emitter.emit("deviceRenamed", data);
this.emitter.emit("entityRenamed", data);
}
public onEntityRenamed(key: ListenerKey, callback: (data: eventdata.EntityRenamed) => void): void {
this.on("deviceRenamed", callback, key);
this.on("entityRenamed", callback, key);
}
public emitEntityRemoved(data: eventdata.EntityRemoved): void {
this.emitter.emit("deviceRemoved", data);
this.emitter.emit("entityRemoved", data);
}
public onEntityRemoved(key: ListenerKey, callback: (data: eventdata.EntityRemoved) => void): void {
this.on("deviceRemoved", callback, key);
this.on("entityRemoved", callback, key);
}
public emitLastSeenChanged(data: eventdata.LastSeenChanged): void {
+1 -1
View File
@@ -207,7 +207,7 @@ export default class Availability extends Extension {
await this.publishAvailability(data.entity, false, true);
}
});
this.eventBus.onEntityRemoved(this, (data) => data.type === "device" && this.clearTimer(data.id));
this.eventBus.onEntityRemoved(this, (data) => data.entity.isDevice() && this.clearTimer(data.entity.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);
+66 -59
View File
@@ -3,16 +3,16 @@ import bind from "bind-decorator";
import debounce from "debounce";
import stringify from "json-stable-stringify-without-jsonify";
import {Zcl} from "zigbee-herdsman";
import type {TClusterAttributeKeys} from "zigbee-herdsman/dist/zspec/zcl/definition/clusters-types";
import type {ClusterName} from "zigbee-herdsman/dist/zspec/zcl/definition/tstype";
import Device from "../model/device";
import Group from "../model/group";
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from "../types/api";
import logger from "../util/logger";
import * as settings from "../util/settings";
import utils from "../util/utils";
import utils, {DEFAULT_BIND_GROUP_ID} from "../util/utils";
import Extension from "./extension";
const TOPIC_REGEX = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/(bind|unbind)`);
const ALL_CLUSTER_CANDIDATES: readonly ClusterName[] = [
"genScenes",
"genOnOff",
@@ -28,7 +28,7 @@ const ALL_CLUSTER_CANDIDATES: readonly ClusterName[] = [
];
// See zigbee-herdsman-converters
const DEFAULT_BIND_GROUP = {type: "group_number", ID: 901, name: "default_bind_group"};
const DEFAULT_BIND_GROUP = {type: "group_number", ID: DEFAULT_BIND_GROUP_ID, name: "default_bind_group"};
const DEFAULT_REPORT_CONFIG = {minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1};
const getColorCapabilities = async (endpoint: zh.Endpoint): Promise<{colorTemperature: boolean; colorXY: boolean}> => {
@@ -44,53 +44,33 @@ const getColorCapabilities = async (endpoint: zh.Endpoint): Promise<{colorTemper
};
};
const REPORT_CLUSTERS: Readonly<
Partial<
Record<
ClusterName,
Readonly<{
attribute: string;
minimumReportInterval: number;
maximumReportInterval: number;
reportableChange: number;
condition?: (endpoint: zh.Endpoint) => Promise<boolean>;
}>[]
>
>
> = {
genOnOff: [{attribute: "onOff", ...DEFAULT_REPORT_CONFIG, minimumReportInterval: 0, reportableChange: 0}],
genLevelCtrl: [{attribute: "currentLevel", ...DEFAULT_REPORT_CONFIG}],
const REPORT_CLUSTERS = {
genOnOff: [{attribute: "onOff" as const, ...DEFAULT_REPORT_CONFIG, minimumReportInterval: 0, reportableChange: 0}],
genLevelCtrl: [{attribute: "currentLevel" as const, ...DEFAULT_REPORT_CONFIG}],
lightingColorCtrl: [
{
attribute: "colorTemperature",
attribute: "colorTemperature" as const,
...DEFAULT_REPORT_CONFIG,
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorTemperature,
condition: async (endpoint: zh.Endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorTemperature,
},
{
attribute: "currentX",
attribute: "currentX" as const,
...DEFAULT_REPORT_CONFIG,
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
condition: async (endpoint: zh.Endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
},
{
attribute: "currentY",
attribute: "currentY" as const,
...DEFAULT_REPORT_CONFIG,
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
condition: async (endpoint: zh.Endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
},
],
closuresWindowCovering: [
{attribute: "currentPositionLiftPercentage", ...DEFAULT_REPORT_CONFIG},
{attribute: "currentPositionTiltPercentage", ...DEFAULT_REPORT_CONFIG},
{attribute: "currentPositionLiftPercentage" as const, ...DEFAULT_REPORT_CONFIG},
{attribute: "currentPositionTiltPercentage" as const, ...DEFAULT_REPORT_CONFIG},
],
};
type PollOnMessage = {
cluster: Readonly<Partial<Record<ClusterName, {type: string; data: KeyValue}[]>>>;
read: Readonly<{cluster: string; attributes: string[]; attributesForEndpoint?: (endpoint: zh.Endpoint) => Promise<string[]>}>;
manufacturerIDs: readonly Zcl.ManufacturerCode[];
manufacturerNames: readonly string[];
}[];
const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
const POLL_ON_MESSAGE = [
{
// On messages that have the cluster and type of below
cluster: {
@@ -110,7 +90,7 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
genScenes: [{type: "commandRecall", data: {}}],
},
// Read the following attributes
read: {cluster: "genLevelCtrl", attributes: ["currentLevel"]},
read: {cluster: "genLevelCtrl" as const, attributes: ["currentLevel"] as TClusterAttributeKeys<"genLevelCtrl">},
// When the bound devices/members of group have the following manufacturerIDs
manufacturerIDs: [
Zcl.ManufacturerCode.SIGNIFY_NETHERLANDS_B_V,
@@ -142,7 +122,7 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
{type: "commandHueNotification", data: {button: 4}},
],
},
read: {cluster: "genOnOff", attributes: ["onOff"]},
read: {cluster: "genOnOff" as const, attributes: ["onOff"] as TClusterAttributeKeys<"genOnOff">},
manufacturerIDs: [
Zcl.ManufacturerCode.SIGNIFY_NETHERLANDS_B_V,
Zcl.ManufacturerCode.ATMEL,
@@ -158,13 +138,13 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
genScenes: [{type: "commandRecall", data: {}}],
},
read: {
cluster: "lightingColorCtrl",
attributes: [] as string[],
cluster: "lightingColorCtrl" as const,
attributes: [] as TClusterAttributeKeys<"lightingColorCtrl">,
// Since not all devices support the same attributes they need to be calculated dynamically
// depending on the capabilities of the endpoint.
attributesForEndpoint: async (endpoint): Promise<string[]> => {
attributesForEndpoint: async (endpoint: zh.Endpoint): Promise<TClusterAttributeKeys<"lightingColorCtrl">> => {
const supportedAttrs = await getColorCapabilities(endpoint);
const readAttrs: string[] = [];
const readAttrs: TClusterAttributeKeys<"lightingColorCtrl"> = [];
if (supportedAttrs.colorXY) {
readAttrs.push("currentX", "currentY");
@@ -204,6 +184,7 @@ interface ParsedMQTTMessage {
}
export default class Bind extends Extension {
#topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/(bind|unbind|binds/clear)`);
private pollDebouncers: {[s: string]: () => void} = {};
// biome-ignore lint/suspicious/useAwait: API
@@ -216,7 +197,7 @@ export default class Bind extends Extension {
private parseMQTTMessage(
data: eventdata.MQTTMessage,
): [raw: KeyValue | undefined, parsed: ParsedMQTTMessage | undefined, error: string | undefined] {
if (data.topic.match(TOPIC_REGEX)) {
if (data.topic.match(this.#topicRegex)) {
const type = data.topic.endsWith("unbind") ? "unbind" : "bind";
let skipDisableReporting = false;
const message = JSON.parse(data.message) as Zigbee2MQTTAPI["bridge/request/device/bind"];
@@ -295,6 +276,36 @@ export default class Bind extends Extension {
}
@bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
if (data.topic.endsWith("binds/clear")) {
const message = JSON.parse(data.message) as Zigbee2MQTTAPI["bridge/request/device/binds/clear"];
if (typeof message !== "object" || typeof message.target !== "string") {
await this.publishResponse("binds/clear", message, {}, "Invalid payload");
return;
}
const target = this.zigbee.resolveEntity(message.target);
if (!(target instanceof Device)) {
await this.publishResponse("binds/clear", message, {}, "Invalid target");
return;
}
// this list is raw (not resolved) to allow clearing any specific target (not only currently known)
const eui64List = message.ieee_list ?? ["0xffffffffffffffff"];
await target.zh.clearAllBindings(eui64List);
const responseData: Zigbee2MQTTAPI["bridge/response/device/binds/clear"] = {
target: message.target,
ieee_list: eui64List,
};
await this.publishResponse("binds/clear", message, responseData);
this.eventBus.emitDevicesChanged();
return;
}
const [raw, parsed, error] = this.parseMQTTMessage(data);
if (!raw || !parsed) {
@@ -408,7 +419,7 @@ export default class Bind extends Extension {
}
private async publishResponse<T extends Zigbee2MQTTResponseEndpoints>(
type: ParsedMQTTMessage["type"],
type: ParsedMQTTMessage["type"] | "binds/clear",
request: KeyValue,
data: Zigbee2MQTTAPI[T],
error?: string,
@@ -480,16 +491,15 @@ export default class Bind extends Extension {
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};
delete i.condition;
for (const c of REPORT_CLUSTERS[bind.cluster.name as keyof typeof REPORT_CLUSTERS]!) {
if (!("condition" in c) || !c.condition || (await c.condition(endpoint))) {
const {attribute, minimumReportInterval, maximumReportInterval, reportableChange} = c;
items.push(i);
items.push({attribute, minimumReportInterval, maximumReportInterval, reportableChange});
}
}
await endpoint.configureReporting(bind.cluster.name, items);
await endpoint.configureReporting(bind.cluster.name as keyof typeof REPORT_CLUSTERS, items);
logger.info(`Successfully setup reporting for '${entity}' cluster '${bind.cluster.name}'`);
} catch (error) {
logger.warning(`Failed to setup reporting for '${entity}' cluster '${bind.cluster.name}' (${(error as Error).message})`);
@@ -508,9 +518,7 @@ export default class Bind extends Extension {
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
for (const endpoint of device.zh.endpoints) {
for (const bind of endpoint.binds) {
allBinds.push(bind);
}
allBinds.push(...endpoint.binds);
}
}
@@ -539,16 +547,15 @@ 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};
delete i.condition;
for (const item of REPORT_CLUSTERS[cluster as keyof typeof REPORT_CLUSTERS]!) {
if (!("condition" in item) || !item.condition || (await item.condition(endpoint))) {
const {attribute, minimumReportInterval, reportableChange} = item;
items.push({...i, maximumReportInterval: 0xffff});
items.push({attribute, minimumReportInterval, maximumReportInterval: 0xffff, reportableChange});
}
}
await endpoint.configureReporting(cluster, items);
await endpoint.configureReporting(cluster as keyof typeof REPORT_CLUSTERS, items);
logger.info(`Successfully disabled reporting for '${entity}' cluster '${cluster}'`);
} catch (error) {
logger.warning(`Failed to disable reporting for '${entity}' cluster '${cluster}' (${(error as Error).message})`);
@@ -569,7 +576,7 @@ export default class Bind extends Extension {
* When we receive a message from a Hue dimmer we read the brightness from the bulb (if bound).
*/
const polls = POLL_ON_MESSAGE.filter((p) =>
p.cluster[data.cluster as ClusterName]?.some((c) => c.type === data.type && utils.equalsPartial(data.data, c.data)),
p.cluster[data.cluster as keyof (typeof p)["cluster"]]?.some((c) => c.type === data.type && utils.equalsPartial(data.data, c.data)),
);
if (polls.length) {
+88 -30
View File
@@ -1,4 +1,5 @@
import fs from "node:fs";
import path from "node:path";
import bind from "bind-decorator";
import stringify from "json-stable-stringify-without-jsonify";
import JSZip from "jszip";
@@ -14,12 +15,11 @@ import type {Zigbee2MQTTAPI, Zigbee2MQTTDevice, Zigbee2MQTTResponse, Zigbee2MQTT
import data from "../util/data";
import logger from "../util/logger";
import * as settings from "../util/settings";
import utils, {assertString} from "../util/utils";
import utils, {assertString, DEFAULT_BIND_GROUP_ID} from "../util/utils";
import Extension from "./extension";
const REQUEST_REGEX = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/(.*)`);
export default class Bridge extends Extension {
#requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/(.*)`);
// set on `start`
#osInfo!: Zigbee2MQTTAPI["bridge/info"]["os"];
private zigbee2mqttVersion!: {commitHash?: string; version: string};
@@ -32,7 +32,10 @@ export default class Bridge extends Extension {
private logTransport!: winston.transport;
private requestLookup: {[key: string]: (message: KeyValue | string) => Promise<Zigbee2MQTTResponse<Zigbee2MQTTResponseEndpoints>>} = {
"device/options": this.deviceOptions,
"device/configure_reporting": this.deviceConfigureReporting,
/** @deprecated 3.0 */
"device/configure_reporting": this.deviceReportingConfigure,
"device/reporting/configure": this.deviceReportingConfigure,
"device/reporting/read": this.deviceReportingRead,
"device/remove": this.deviceRemove,
"device/interview": this.deviceInterview,
"device/generate_external_definition": this.deviceGenerateExternalDefinition,
@@ -51,6 +54,7 @@ export default class Bridge extends Extension {
health_check: this.healthCheck,
coordinator_check: this.coordinatorCheck,
options: this.bridgeOptions,
action: this.action,
};
override async start(): Promise<void> {
@@ -197,7 +201,7 @@ export default class Bridge extends Extension {
}
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
const match = data.topic.match(REQUEST_REGEX);
const match = data.topic.match(this.#requestRegex);
if (!match) {
return;
@@ -316,14 +320,18 @@ export default class Bridge extends Extension {
@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"));
const files = utils.getAllFiles(dataPath);
const zip = new JSZip();
const logDir = `log${path.sep}`;
const otaDir = `ota${path.sep}`;
for (const f of files) {
zip.file(f[1], fs.readFileSync(f[0]));
const name = f.slice(dataPath.length + 1);
// XXX: `log` could technically be something else depending on `log_directory` setting
if (!name.startsWith(logDir) && !name.startsWith(otaDir)) {
zip.file(name, fs.readFileSync(f));
}
}
const base64Zip = await zip.generateAsync({type: "base64"});
@@ -467,16 +475,16 @@ 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 deviceReportingConfigure(message: string | KeyValue): Promise<Zigbee2MQTTResponse<"bridge/response/device/reporting/configure">> {
if (
typeof message !== "object" ||
message.id === undefined ||
message.endpoint === undefined ||
message.cluster === undefined ||
message.maximum_report_interval === undefined ||
message.minimum_report_interval === undefined ||
message.reportable_change === undefined ||
message.attribute === undefined
message.attribute === undefined ||
typeof message.maximum_report_interval !== "number" ||
typeof message.minimum_report_interval !== "number" ||
(message.reportable_change !== undefined && typeof message.reportable_change !== "number")
) {
throw new Error("Invalid payload");
}
@@ -519,25 +527,54 @@ export default class Bridge extends Extension {
});
}
@bind async deviceReportingRead(message: string | KeyValue): Promise<Zigbee2MQTTResponse<"bridge/response/device/reporting/read">> {
if (
typeof message !== "object" ||
message.id === undefined ||
message.endpoint === undefined ||
message.cluster === undefined ||
message.configs === undefined
) {
throw new Error("Invalid payload");
}
const device = this.getEntity("device", message.id);
const endpoint = device.endpoint(message.endpoint);
if (!endpoint) {
throw new Error(`Device '${device.ID}' does not have endpoint '${message.endpoint}'`);
}
const response = await endpoint.readReportingConfig(
message.cluster,
message.configs,
message.manufacturer_code ? {manufacturerCode: message.manufacturer_code} : {},
);
await this.publishDevices();
const responseData: Zigbee2MQTTAPI["bridge/response/device/reporting/read"] = {
id: message.id,
endpoint: message.endpoint,
cluster: message.cluster,
configs: response,
};
if (message.manufacturer_code) {
responseData.manufacturer_code = message.manufacturer_code;
}
return utils.getResponse(message, responseData);
}
@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);
logger.info(`Interviewing '${device.name}'`);
try {
await device.zh.interview(true);
logger.info(`Successfully interviewed '${device.name}'`);
} catch (error) {
throw new Error(`interview of '${device.name}' (${device.ieeeAddr}) failed: ${error}`, {cause: error});
}
// A re-interview can for example result in a different modelId, therefore reconsider the definition.
await device.resolveDefinition(true);
this.eventBus.emitDevicesChanged();
this.eventBus.emitExposesChanged({device});
await device.reInterview(this.eventBus);
return utils.getResponse(message, {id: message.id});
}
@@ -555,6 +592,22 @@ export default class Bridge extends Extension {
return utils.getResponse(message, {id: message.id, source});
}
@bind async action(message: string | KeyValue): Promise<Zigbee2MQTTResponse<"bridge/response/action">> {
if (typeof message !== "object" || !message.action) {
throw new Error("Invalid payload");
}
const action = zhc.ACTIONS[message.action];
if (action === undefined) {
throw new Error("Invalid action");
}
const response = await action(this.zigbee.zhController, message.params ?? {});
return utils.getResponse(message, response);
}
async renameEntity<T extends "device" | "group">(
entityType: T,
message: string | KeyValue,
@@ -631,7 +684,6 @@ export default class Bridge extends Extension {
await entity.zh.removeFromNetwork();
}
this.eventBus.emitEntityRemoved({id: entity.ID, name: friendlyName, type: "device"});
settings.removeDevice(entity.ID as string);
} else {
if (force) {
@@ -640,10 +692,12 @@ export default class Bridge extends Extension {
await entity.zh.removeFromNetwork();
}
this.eventBus.emitEntityRemoved({id: entity.ID, name: friendlyName, type: "group"});
this.zigbee.removeGroupFromLookup(entity.ID);
settings.removeGroup(entity.ID);
}
this.eventBus.emitEntityRemoved({entity, name: friendlyName});
// Remove from state
this.state.remove(entity.ID);
@@ -731,6 +785,7 @@ export default class Bridge extends Extension {
for (const endpoint of device.zh.endpoints) {
const data: (typeof endpoints)[keyof typeof endpoints] = {
name: device.endpointName(endpoint),
scenes: utils.getScenes(endpoint),
bindings: [],
configured_reportings: [],
@@ -797,7 +852,7 @@ export default class Bridge extends Extension {
groups.push({
id: group.ID,
friendly_name: group.ID === 901 ? "default_bind_group" : group.name,
friendly_name: group.ID === DEFAULT_BIND_GROUP_ID ? "default_bind_group" : group.name,
description: group.options.description,
scenes: utils.getScenes(group.zh),
members,
@@ -811,6 +866,7 @@ export default class Bridge extends Extension {
const data: Zigbee2MQTTAPI["bridge/definitions"] = {
clusters: Zcl.Clusters,
custom_clusters: {},
actions: Object.keys(zhc.ACTIONS),
};
for (const device of this.zigbee.devicesIterator((d) => !utils.objectIsEmpty(d.customClusters))) {
@@ -837,12 +893,14 @@ export default class Bridge extends Extension {
}
const payload: Zigbee2MQTTDevice["definition"] = {
source: device.definition.externalConverterName ? "external" : device.definition.generated ? "generated" : "native",
model: device.definition.model,
vendor: device.definition.vendor,
description: device.definition.description,
exposes: device.exposes(),
supports_ota: !!device.definition.ota,
options: device.definition.options ?? [],
version: device.definition.version,
icon,
};
+32 -18
View File
@@ -1,6 +1,5 @@
import bind from "bind-decorator";
import stringify from "json-stable-stringify-without-jsonify";
import * as zhc from "zigbee-herdsman-converters";
import Device from "../model/device";
import type {Zigbee2MQTTAPI} from "../types/api";
import logger from "../util/logger";
@@ -12,8 +11,8 @@ import Extension from "./extension";
* This extension calls the zigbee-herdsman-converters definition configure() method
*/
export default class Configure extends Extension {
private configuring = new Set();
private attempts: {[s: string]: number} = {};
private configuring = new Set<string>();
private attempts = new Map<string, number>();
private topic = `${settings.get().mqtt.base_topic}/bridge/request/device/configure`;
@bind private async onReconfigure(data: eventdata.Reconfigure): Promise<void> {
@@ -39,7 +38,7 @@ export default class Configure extends Extension {
if (!device || !(device instanceof Device)) {
error = `Device '${ID}' does not exist`;
} else if (!device.definition || !device.definition.configure) {
} else if (!device.definition?.configure) {
error = `Device '${device.name}' cannot be configured`;
} else {
try {
@@ -75,6 +74,7 @@ export default class Configure extends Extension {
await this.configure(data.device, "zigbee_event");
});
// TODO: this is triggering for any `data.status`, but should only for `successful`? (relies on `device.definition?` early-return?)
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);
@@ -93,42 +93,56 @@ export default class Configure extends Extension {
return;
}
const definitionVersion = device.definition.version;
if (!force) {
if (device.options.disabled || !device.interviewed) {
return;
}
if (device.zh.meta?.configured !== undefined) {
return;
}
// 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") {
return;
}
const shouldReconfigure =
// Should always reconfigure when not configured before
device.zh.meta?.configured === undefined ||
// Or should reconfigure when definition.version is not '0.0.0' and differs from last `meta.configured`.
// In older Z2M versions the stored `meta.configured` was the hash of the configure function.
// Since we don't want to reconfigure all devices, we don't re-configure when the definition has the default version of '0.0.0'.
(definitionVersion !== "0.0.0" && device.zh.meta?.configured !== definitionVersion);
if (!shouldReconfigure) {
return;
}
}
if (this.configuring.has(device.ieeeAddr) || (this.attempts[device.ieeeAddr] >= 3 && !force)) {
if (this.configuring.has(device.ieeeAddr)) {
return;
}
const attempts = this.attempts.get(device.ieeeAddr) ?? 0;
if (attempts >= 3 && !force) {
return;
}
this.configuring.add(device.ieeeAddr);
if (this.attempts[device.ieeeAddr] === undefined) {
this.attempts[device.ieeeAddr] = 0;
}
logger.info(`Configuring '${device.name}'`);
try {
await device.definition.configure(device.zh, this.zigbee.firstCoordinatorEndpoint(), device.definition);
logger.info(`Successfully configured '${device.name}'`);
device.zh.meta.configured = zhc.getConfigureKey(device.definition);
this.attempts.delete(device.ieeeAddr);
logger.info(`Successfully configured '${device.name}' (definition v${definitionVersion})`);
device.zh.meta.configured = definitionVersion;
device.zh.save();
this.eventBus.emitDevicesChanged();
} catch (error) {
this.attempts[device.ieeeAddr]++;
const attempt = this.attempts[device.ieeeAddr];
const msg = `Failed to configure '${device.name}', attempt ${attempt} (${(error as Error).stack})`;
const newAttempts = attempts + 1;
this.attempts.set(device.ieeeAddr, newAttempts);
const msg = `Failed to configure '${device.name}', attempt ${newAttempts} (${(error as Error).stack})`;
logger.error(msg);
if (throwError) {
+19 -6
View File
@@ -57,13 +57,15 @@ export default abstract class ExternalJSExtension<M> extends Extension {
if (!basePathInZ2mDir) {
logger.debug(`External JS folder '${this.folderName}' is outside the Z2M install dir, creating a symlink to 'node_modules'`);
const nodeModulesSymlink = path.join(this.basePath, "node_modules");
/* v8 ignore start */
if (fs.existsSync(nodeModulesSymlink)) {
fs.unlinkSync(nodeModulesSymlink);
}
/* v8 ignore stop */
// Type `junction` is required on Windows.
// https://github.com/nodejs/node/issues/18518#issuecomment-513866491
/* v8 ignore next */
fs.symlinkSync(nodeModulesPath, nodeModulesSymlink, os.platform() === "win32" ? "junction" : "dir");
fs.symlinkSync(nodeModulesPath, nodeModulesSymlink, /* v8 ignore next */ os.platform() === "win32" ? "junction" : "dir");
}
}
}
@@ -75,6 +77,17 @@ export default abstract class ExternalJSExtension<M> extends Extension {
await this.publishExternalJS();
}
override async stop(): Promise<void> {
await super.stop();
// ensure "node_modules" is never followed & included in 3rd-party backup systems
const nodeModulesSymlink = path.join(this.basePath, "node_modules");
if (fs.existsSync(nodeModulesSymlink)) {
fs.unlinkSync(nodeModulesSymlink);
}
}
private getFilePath(name: string, mkBasePath = false): string {
if (mkBasePath && !fs.existsSync(this.basePath)) {
fs.mkdirSync(this.basePath, {recursive: true});
@@ -189,14 +202,14 @@ export default abstract class ExternalJSExtension<M> extends Extension {
const mod = await this.importFile(filePath);
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`);
logger.error(
`Invalid external ${this.mqttTopic} '${extension.name}' was ignored and renamed to prevent interference with Zigbee2MQTT.`,
`Invalid external ${this.mqttTopic} '${extension.name}' was ignored and renamed to prevent interference with Zigbee2MQTT. (${(error as Error).message})`,
);
// biome-ignore lint/style/noNonNullAssertion: always Error
logger.debug((error as Error).stack!);
// change ext so Z2M doesn't try to load it again and again
fs.renameSync(filePath, `${filePath}.invalid`);
}
}
}
+86 -79
View File
@@ -7,7 +7,6 @@ import type {Socket} from "node:net";
import {posix} from "node:path";
import {parse} from "node:url";
import bind from "bind-decorator";
import type {RequestHandler} from "express-static-gzip";
import expressStaticGzip from "express-static-gzip";
import finalhandler from "finalhandler";
import stringify from "json-stable-stringify-without-jsonify";
@@ -24,9 +23,7 @@ import Extension from "./extension";
*/
export class Frontend extends Extension {
private mqttBaseTopic: string;
private server!: Server;
private fileServer!: RequestHandler;
private deviceIconsFileServer!: RequestHandler;
private server: Server | undefined;
private wss!: WebSocket.Server;
private baseUrl: string;
@@ -49,60 +46,97 @@ export class Frontend extends Extension {
}
override async start(): Promise<void> {
const hasSSL = (val: string | undefined, key: string): val is string => {
if (val) {
if (!existsSync(val)) {
logger.error(`Defined ${key} '${val}' file path does not exists, server won't be secured.`);
return false;
}
return true;
}
return false;
};
const {host, port, ssl_key: sslKey, ssl_cert: sslCert} = settings.get().frontend;
const options = {
enableBrotli: true,
// TODO: https://github.com/Koenkk/zigbee2mqtt/issues/24654 - enable compressed index serving when express-static-gzip is fixed.
index: false,
serveStatic: {
index: "index.html",
/* v8 ignore start */
setHeaders: (res: ServerResponse, path: string): void => {
if (path.endsWith("index.html")) {
res.setHeader("Cache-Control", "no-store");
if (settings.get().frontend.disable_ui_serving) {
const {host, port} = settings.get().frontend;
this.wss = new WebSocket.Server({port, host, path: posix.join(this.baseUrl, "api")});
logger.info(
/* v8 ignore next */
`Frontend UI serving is disabled. WebSocket at: ${this.wss.options.host ?? "0.0.0.0"}:${this.wss.options.port}${this.wss.options.path}`,
);
} else {
const {host, port, ssl_key: sslKey, ssl_cert: sslCert} = settings.get().frontend;
const hasSSL = (val: string | undefined, key: string): val is string => {
if (val) {
if (existsSync(val)) {
return true;
}
logger.error(`Defined ${key} '${val}' file path does not exists, server won't be secured.`);
}
return false;
};
const options: expressStaticGzip.ExpressStaticGzipOptions = {
enableBrotli: true,
serveStatic: {
/* v8 ignore start */
setHeaders: (res: ServerResponse, path: string): void => {
if (path.endsWith("index.html")) {
res.setHeader("Cache-Control", "no-store");
}
},
/* v8 ignore stop */
},
/* v8 ignore stop */
},
};
const frontend = (await import(settings.get().frontend.package)) as typeof import("zigbee2mqtt-frontend");
this.fileServer = expressStaticGzip(frontend.default.getPath(), options);
this.deviceIconsFileServer = expressStaticGzip(data.joinPath("device_icons"), options);
this.wss = new WebSocket.Server({noServer: true, path: posix.join(this.baseUrl, "api")});
};
const frontend = (await import(settings.get().frontend.package)) as typeof import("zigbee2mqtt-frontend");
const fileServer = expressStaticGzip(frontend.default.getPath(), options);
const deviceIconsFileServer = expressStaticGzip(data.joinPath("device_icons"), options);
const onRequest = (request: IncomingMessage, response: ServerResponse): void => {
const next = 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(".")) {
next();
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.path = request.url;
if (newUrl.startsWith("device_icons/")) {
request.path = request.path.replace("device_icons/", "");
request.url = request.url.replace("/device_icons", "");
deviceIconsFileServer(request, response, next);
} else {
fileServer(request, response, next);
}
};
if (hasSSL(sslKey, "ssl_key") && hasSSL(sslCert, "ssl_cert")) {
const serverOptions = {key: readFileSync(sslKey), cert: readFileSync(sslCert)};
this.server = createSecureServer(serverOptions, onRequest);
} else {
this.server = createServer(onRequest);
}
this.server.on("upgrade", this.onUpgrade);
if (!host) {
this.server.listen(port);
logger.info(`Started frontend on port ${port}`);
} else if (host.startsWith("/")) {
this.server.listen(host);
logger.info(`Started frontend on socket ${host}`);
} else {
this.server.listen(port, host);
logger.info(`Started frontend on port ${host}:${port}`);
}
this.wss = new WebSocket.Server({noServer: true, path: posix.join(this.baseUrl, "api")});
}
this.wss.on("connection", this.onWebSocketConnection);
if (hasSSL(sslKey, "ssl_key") && hasSSL(sslCert, "ssl_cert")) {
const serverOptions = {key: readFileSync(sslKey), cert: readFileSync(sslCert)};
this.server = createSecureServer(serverOptions, this.onRequest);
} else {
this.server = createServer(this.onRequest);
}
this.server.on("upgrade", this.onUpgrade);
this.eventBus.onMQTTMessagePublished(this, this.onMQTTPublishMessageOrEntityState);
this.eventBus.onPublishEntityState(this, this.onMQTTPublishMessageOrEntityState);
if (!host) {
this.server.listen(port);
logger.info(`Started frontend on port ${port}`);
} else if (host.startsWith("/")) {
this.server.listen(host);
logger.info(`Started frontend on socket ${host}`);
} else {
this.server.listen(port, host);
logger.info(`Started frontend on port ${host}:${port}`);
}
}
override async stop(): Promise<void> {
@@ -117,34 +151,7 @@ export class Frontend extends Extension {
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(".")) {
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.path = request.url;
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);
}
await new Promise((resolve) => (this.server ? this.server.close(resolve) : resolve(undefined)));
}
@bind private onUpgrade(request: IncomingMessage, socket: Socket, head: Buffer): void {
+7 -8
View File
@@ -11,8 +11,6 @@ import * as settings from "../util/settings";
import utils, {isLightExpose} from "../util/utils";
import Extension from "./extension";
const TOPIC_REGEX = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/group/members/(remove|add|remove_all)$`);
const STATE_PROPERTIES: Readonly<Record<string, (value: string, exposes: zhc.Expose[]) => boolean>> = {
state: () => true,
brightness: (_value, exposes) => exposes.some((e) => isLightExpose(e) && e.features.some((f) => f.name === "brightness")),
@@ -38,6 +36,7 @@ interface ParsedMQTTMessage {
}
export default class Groups extends Extension {
#topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/group/members/(remove|add|remove_all)$`);
private lastOptimisticState: {[s: string]: KeyValue} = {};
// biome-ignore lint/suspicious/useAwait: API
@@ -169,7 +168,7 @@ 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?.includes("state")
? `state_${endpointNames[member.ID - 1]}`
: "state";
@@ -185,7 +184,7 @@ export default class Groups extends Extension {
private parseMQTTMessage(
data: eventdata.MQTTMessage,
): [raw: KeyValue | undefined, parsed: ParsedMQTTMessage | undefined, error: string | undefined] {
const topicRegexMatch = data.topic.match(TOPIC_REGEX);
const topicRegexMatch = data.topic.match(this.#topicRegex);
if (topicRegexMatch) {
const type = topicRegexMatch[1] as "remove" | "add" | "remove_all";
@@ -273,7 +272,7 @@ export default class Groups extends Extension {
try {
if (type === "add") {
assert(resolvedGroup, "`resolvedGroup` is missing");
logger.info(`Adding '${resolvedDevice.name}' to '${resolvedGroup.name}'`);
logger.info(`Adding endpoint '${resolvedEndpoint.ID}' of device '${resolvedDevice.name}' to group '${resolvedGroup.name}'`);
await resolvedEndpoint.addToGroup(resolvedGroup.zh);
changedGroups.push(resolvedGroup);
// biome-ignore lint/style/noNonNullAssertion: valid from resolved asserts
@@ -281,7 +280,7 @@ export default class Groups extends Extension {
await this.publishResponse<"bridge/response/group/members/add">(parsed.type, raw, respPayload);
} else if (type === "remove") {
assert(resolvedGroup, "`resolvedGroup` is missing");
logger.info(`Removing '${resolvedDevice.name}' from '${resolvedGroup.name}'`);
logger.info(`Removing endpoint '${resolvedEndpoint.ID}' of device '${resolvedDevice.name}' from group '${resolvedGroup.name}'`);
await resolvedEndpoint.removeFromGroup(resolvedGroup.zh);
changedGroups.push(resolvedGroup);
// biome-ignore lint/style/noNonNullAssertion: valid from resolved asserts
@@ -289,7 +288,7 @@ export default class Groups extends Extension {
await this.publishResponse<"bridge/response/group/members/remove">(parsed.type, raw, respPayload);
} else {
// remove_all
logger.info(`Removing '${resolvedDevice.name}' from all groups`);
logger.info(`Removing endpoint '${resolvedEndpoint.ID}' of device '${resolvedDevice.name}' from all groups`);
for (const group of this.zigbee.groupsIterator((g) => g.members.includes(resolvedEndpoint))) {
changedGroups.push(group);
@@ -301,7 +300,7 @@ export default class Groups extends Extension {
await this.publishResponse<"bridge/response/group/members/remove_all">(parsed.type, raw, respPayload);
}
} catch (e) {
const errorMsg = `Failed to ${type} from group (${(e as Error).message})`;
const errorMsg = `Failed to ${type} ${type === "add" ? "to" : "from"} group (${(e as Error).message})`;
await this.publishResponse(parsed.type, raw, {}, errorMsg);
// biome-ignore lint/style/noNonNullAssertion: always Error
logger.debug((e as Error).stack!);
+3 -2
View File
@@ -33,13 +33,14 @@ export default class Health extends Extension {
async #checkHealth(): Promise<void> {
const sysMemTotalKb = os.totalmem() / 1024;
const sysMemFreeKb = os.freemem() / 1024;
const sysMemUsedKb = sysMemTotalKb - sysMemFreeKb;
const procMemUsedKb = process.memoryUsage().rss / 1024;
const healthcheck: Zigbee2MQTTAPI["bridge/health"] = {
response_time: Date.now(),
os: {
load_average: os.loadavg(), // will be [0,0,0] on Windows (not supported)
memory_used_mb: round2((sysMemTotalKb - sysMemFreeKb) / 1024),
memory_percent: round4((sysMemFreeKb / sysMemTotalKb) * 100.0),
memory_used_mb: round2(sysMemUsedKb / 1024),
memory_percent: round4((sysMemUsedKb / sysMemTotalKb) * 100.0),
},
process: {
uptime_sec: Math.floor(process.uptime()),
+174 -23
View File
@@ -50,6 +50,10 @@ const COVER_STOPPED_LOOKUP: ReadonlyArray<string> = ["stopped", "stop", "pause",
const SWITCH_DIFFERENT: ReadonlyArray<string> = ["valve_detection", "window_detection", "auto_lock", "away_mode"];
const BINARY_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
activity_led_indicator: {icon: "mdi:led-on"},
area1Occupancy: {device_class: "occupancy"},
area2Occupancy: {device_class: "occupancy"},
area3Occupancy: {device_class: "occupancy"},
area4Occupancy: {device_class: "occupancy"},
auto_off: {icon: "mdi:flash-auto"},
battery_low: {entity_category: "diagnostic", device_class: "battery"},
button_lock: {entity_category: "config", icon: "mdi:lock"},
@@ -81,7 +85,8 @@ const BINARY_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
noise_detected: {device_class: "sound"},
occupancy: {device_class: "occupancy"},
power_outage_memory: {entity_category: "config", icon: "mdi:memory"},
presence: {device_class: "presence"},
presence: {device_class: "occupancy"},
rain_status: {device_class: "moisture", icon: "mdi:weather-pouring"},
setup: {device_class: "running"},
smoke: {device_class: "smoke"},
sos: {device_class: "safety"},
@@ -114,6 +119,7 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
alarm_temperature_min: {device_class: "temperature", entity_category: "config", icon: "mdi:thermometer-low"},
angle: {icon: "angle-acute"},
angle_axis: {icon: "angle-acute"},
apparent_temperature: {icon: "mdi:thermometer-lines", state_class: "measurement"},
aqi: {device_class: "aqi", state_class: "measurement"},
auto_relock_time: {entity_category: "config", icon: "mdi:timer"},
away_preset_days: {entity_category: "config", icon: "mdi:timer"},
@@ -148,24 +154,30 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
entity_category: "diagnostic",
state_class: "measurement",
},
dew_point: {icon: "mdi:thermometer-water", state_class: "measurement"},
distance: {device_class: "distance", state_class: "measurement"},
duration: {entity_category: "config", icon: "mdi:timer"},
eco2: {device_class: "carbon_dioxide", state_class: "measurement"},
eco2: {device_class: "volatile_organic_compounds_parts", state_class: "measurement"},
eco_temperature: {entity_category: "config", icon: "mdi:thermometer"},
energy: {device_class: "energy", state_class: "total_increasing"},
external_temperature_input: {device_class: "temperature", icon: "mdi:thermometer"},
external_temperature: {device_class: "temperature", icon: "mdi:thermometer"},
external_humidity: {device_class: "humidity", icon: "mdi:water-percent"},
external_temperature: {device_class: "temperature", icon: "mdi:thermometer", state_class: "measurement"},
external_humidity: {device_class: "humidity", icon: "mdi:water-percent", state_class: "measurement"},
formaldehyd: {state_class: "measurement"},
flow: {device_class: "volume_flow_rate", state_class: "measurement"},
gas: {device_class: "gas", state_class: "total_increasing", icon: "mdi:meter-gas"},
gas_density: {icon: "mdi:google-circles-communities", state_class: "measurement"},
gust_speed: {icon: "mdi:weather-windy-variant", state_class: "measurement"},
hcho: {icon: "mdi:air-filter", state_class: "measurement"},
heat_stress: {icon: "mdi:weather-sunny-alert", state_class: "measurement"},
humidex: {icon: "mdi:thermometer-alert", state_class: "measurement"},
humidity: {device_class: "humidity", state_class: "measurement"},
humidity_calibration: {entity_category: "config", icon: "mdi:wrench-clock"},
humidity_max: {entity_category: "config", icon: "mdi:water-percent"},
humidity_min: {entity_category: "config", icon: "mdi:water-percent"},
illuminance_calibration: {entity_category: "config", icon: "mdi:wrench-clock"},
illuminance: {device_class: "illuminance", state_class: "measurement"},
illuminance_raw: {state_class: "measurement"},
internalTemperature: {
device_class: "temperature",
entity_category: "diagnostic",
@@ -177,6 +189,7 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
icon: "mdi:signal",
state_class: "measurement",
},
load_estimate: {state_class: "measurement"},
local_temperature: {device_class: "temperature", state_class: "measurement"},
max_range: {entity_category: "config", icon: "mdi:signal-distance-variant"},
max_temperature: {entity_category: "config", icon: "mdi:thermometer-high"},
@@ -189,7 +202,7 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
noise: {device_class: "sound_pressure", state_class: "measurement"},
noise_detect_level: {icon: "mdi:volume-equal"},
noise_timeout: {icon: "mdi:timer"},
occupancy_level: {icon: "mdi:motion-sensor"},
occupancy_level: {icon: "mdi:motion-sensor", state_class: "measurement"},
occupancy_sensitivity: {entity_category: "config", icon: "mdi:motion-sensor"},
occupancy_timeout: {entity_category: "config", icon: "mdi:timer"},
overload_protection: {icon: "mdi:flash"},
@@ -201,10 +214,13 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
power_phase_b: {device_class: "power", state_class: "measurement"},
power_phase_c: {device_class: "power", state_class: "measurement"},
power_factor: {device_class: "power_factor", enabled_by_default: false, entity_category: "diagnostic", state_class: "measurement"},
power_outage_count: {icon: "mdi:counter", enabled_by_default: false},
power_outage_count: {icon: "mdi:counter", enabled_by_default: false, state_class: "measurement"},
precipitation: {device_class: "precipitation", icon: "mdi:weather-rainy", state_class: "total_increasing"},
precision: {entity_category: "config", icon: "mdi:decimal-comma-increase"},
pressure: {device_class: "atmospheric_pressure", state_class: "measurement"},
pressure_trend: {icon: "mdi:trending-up", state_class: "measurement"},
presence_timeout: {entity_category: "config", icon: "mdi:timer"},
rain_rate: {device_class: "precipitation_intensity", icon: "mdi:weather-pouring", state_class: "measurement"},
reporting_time: {entity_category: "config", icon: "mdi:clock-time-one-outline"},
requested_brightness_level: {
enabled_by_default: false,
@@ -219,12 +235,14 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
smoke_density: {icon: "mdi:google-circles-communities", state_class: "measurement"},
soil_moisture: {device_class: "moisture", state_class: "measurement"},
temperature: {device_class: "temperature", state_class: "measurement"},
temperature_probe: {device_class: "temperature", state_class: "measurement"},
temperature_calibration: {entity_category: "config", icon: "mdi:wrench-clock"},
temperature_max: {entity_category: "config", icon: "mdi:thermometer-plus"},
temperature_min: {entity_category: "config", icon: "mdi:thermometer-minus"},
temperature_offset: {icon: "mdi:thermometer-lines"},
transition: {entity_category: "config", icon: "mdi:transition"},
trigger_count: {icon: "mdi:counter", enabled_by_default: false},
trigger_count: {icon: "mdi:counter", enabled_by_default: false, state_class: "measurement"},
uv_index: {icon: "mdi:white-balance-sunny", state_class: "measurement"},
voc: {device_class: "volatile_organic_compounds", state_class: "measurement"},
voc_index: {state_class: "measurement", icon: "mdi:molecule"},
voc_parts: {device_class: "volatile_organic_compounds_parts", state_class: "measurement"},
@@ -236,9 +254,15 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
device_class: "water",
state_class: "total_increasing",
},
x_axis: {icon: "mdi:axis-x-arrow"},
y_axis: {icon: "mdi:axis-y-arrow"},
z_axis: {icon: "mdi:axis-z-arrow"},
wind_chill: {icon: "mdi:snowflake-thermometer", state_class: "measurement"},
wind_direction: {icon: "mdi:compass-outline", state_class: "measurement"},
wind_speed: {device_class: "wind_speed", icon: "mdi:weather-windy", state_class: "measurement"},
x: {icon: "mdi:axis-x-arrow", state_class: "measurement"},
x_axis: {icon: "mdi:axis-x-arrow", state_class: "measurement"},
y: {icon: "mdi:axis-y-arrow", state_class: "measurement"},
y_axis: {icon: "mdi:axis-y-arrow", state_class: "measurement"},
z: {icon: "mdi:axis-z-arrow", state_class: "measurement"},
z_axis: {icon: "mdi:axis-z-arrow", state_class: "measurement"},
} as const;
const ENUM_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
action: {icon: "mdi:gesture-double-tap"},
@@ -280,6 +304,7 @@ const ENUM_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
thermostat_unit: {entity_category: "config", icon: "mdi:thermometer"},
update: {device_class: "update"},
volume: {entity_category: "config", icon: "mdi: volume-high"},
weather_condition: {icon: "mdi:weather-partly-cloudy"},
week: {entity_category: "config", icon: "mdi:calendar-clock"},
} as const;
const LIST_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
@@ -417,6 +442,7 @@ export class HomeAssistant extends Extension {
this.eventBus.onGroupMembersChanged(this, this.onGroupMembersChanged);
this.eventBus.onDeviceAnnounce(this, this.onZigbeeEvent);
this.eventBus.onDeviceJoined(this, this.onZigbeeEvent);
// TODO: this is triggering for any `data.status`?
this.eventBus.onDeviceInterview(this, this.onZigbeeEvent);
this.eventBus.onDeviceMessage(this, this.onZigbeeEvent);
this.eventBus.onScenesChanged(this, this.onScenesChanged);
@@ -686,9 +712,8 @@ export class HomeAssistant extends Extension {
command_topic: true,
command_topic_prefix: endpoint,
command_topic_postfix: tempCalibration.property,
device_class: "temperature",
device_class: "temperature_delta",
entity_category: "config",
icon: "mdi:math-compass",
...(tempCalibration.unit && {unit_of_measurement: tempCalibration.unit}),
},
};
@@ -703,22 +728,80 @@ export class HomeAssistant extends Extension {
const piHeatingDemand = (firstExpose as zhc.Climate).features.filter(isNumericExpose).find((f) => f.name === "pi_heating_demand");
if (piHeatingDemand) {
const discoveryEntry: DiscoveryEntry = {
type: "sensor",
object_id: endpoint ? /* v8 ignore next */ `${piHeatingDemand.name}_${endpoint}` : `${piHeatingDemand.name}`,
const discoveryEntry: Partial<DiscoveryEntry> = {
object_id: endpoint ? `${piHeatingDemand.name}_${endpoint}` : `${piHeatingDemand.name}`,
mockProperties: [{property: piHeatingDemand.property, value: null}],
discovery_payload: {
name: endpoint ? /* v8 ignore next */ `${piHeatingDemand.label} ${endpoint}` : piHeatingDemand.label,
name: endpoint ? `${piHeatingDemand.label} ${endpoint}` : piHeatingDemand.label,
value_template: `{{ value_json.${piHeatingDemand.property} }}`,
...(piHeatingDemand.unit && {unit_of_measurement: piHeatingDemand.unit}),
entity_category: "diagnostic",
icon: "mdi:radiator",
},
};
assert(discoveryEntry.discovery_payload);
if (piHeatingDemand.access & ACCESS_SET) {
discoveryEntry.type = "number";
discoveryEntry.discovery_payload.command_topic = true;
discoveryEntry.discovery_payload.command_topic_prefix = endpoint;
discoveryEntry.discovery_payload.command_topic_postfix = piHeatingDemand.property;
discoveryEntry.discovery_payload.min = piHeatingDemand.value_min;
discoveryEntry.discovery_payload.max = piHeatingDemand.value_max;
} else {
discoveryEntry.type = "sensor";
discoveryEntry.discovery_payload.entity_category = "diagnostic";
}
discoveryEntries.push(<DiscoveryEntry>discoveryEntry);
}
const piCoolingDemand = (firstExpose as zhc.Climate).features.filter(isNumericExpose).find((f) => f.name === "pi_cooling_demand");
if (piCoolingDemand) {
const discoveryEntry: DiscoveryEntry = {
type: "sensor",
object_id: endpoint ? /* v8 ignore next */ `${piCoolingDemand.name}_${endpoint}` : `${piCoolingDemand.name}`,
mockProperties: [{property: piCoolingDemand.property, value: null}],
discovery_payload: {
name: endpoint ? /* v8 ignore next */ `${piCoolingDemand.label} ${endpoint}` : piCoolingDemand.label,
value_template: `{{ value_json.${piCoolingDemand.property} }}`,
...(piCoolingDemand.unit && {unit_of_measurement: piCoolingDemand.unit}),
entity_category: "diagnostic",
icon: "mdi:air-conditioner",
},
};
discoveryEntries.push(discoveryEntry);
}
const localTemperature = (firstExpose as zhc.Climate).features.filter(isNumericExpose).find((f) => f.name === "local_temperature");
const temperatureSensor = allExposes?.filter(isNumericExpose).find((e) => e.name === "temperature" && e.access & ACCESS_STATE);
const localTemperatureSensor = allExposes
?.filter(isNumericExpose)
.find((e) => e.name === "local_temperature" && e.access & ACCESS_STATE);
if (localTemperature && !temperatureSensor && !localTemperatureSensor) {
const discoveryEntry: DiscoveryEntry = {
type: "sensor",
object_id: endpoint ? `${localTemperature.name}_${endpoint}` : `${localTemperature.name}`,
mockProperties: [{property: localTemperature.property, value: null}],
discovery_payload: {
name: endpoint ? `${localTemperature.label} ${endpoint}` : localTemperature.label,
value_template: `{{ value_json.${localTemperature.property} }}`,
...(localTemperature.unit && {unit_of_measurement: localTemperature.unit}),
device_class: "temperature",
state_class: "measurement",
},
};
discoveryEntries.push(discoveryEntry);
}
const currentHumidity = allExposes?.filter(isNumericExpose).find((e) => e.name === "humidity" && e.access & ACCESS_STATE);
if (currentHumidity) {
discoveryEntry.discovery_payload.current_humidity_template = `{{ value_json.${currentHumidity.property} }}`;
discoveryEntry.discovery_payload.current_humidity_topic = true;
}
discoveryEntries.push(discoveryEntry);
break;
}
@@ -1151,6 +1234,61 @@ export class HomeAssistant extends Extension {
case "composite":
case "list": {
const firstExposeTyped = firstExpose as zhc.Text | zhc.Composite | zhc.List;
// Warning composite → HA siren entity
if (firstExposeTyped.type === "composite" && firstExposeTyped.name === "warning" && firstExposeTyped.access & ACCESS_SET) {
const warningExpose = firstExpose as zhc.Composite;
const modeFeature = warningExpose.features.filter(isEnumExpose).find((f) => f.name === "mode");
const levelFeature = warningExpose.features.filter(isEnumExpose).find((f) => f.name === "level");
const durationFeature = warningExpose.features.filter(isNumericExpose).find((f) => f.name === "duration");
const discoveryEntry: DiscoveryEntry = {
type: "siren",
object_id: endpoint ? /* v8 ignore next */ `siren_${endpoint}` : "siren",
mockProperties: [{property: warningExpose.property, value: null}],
discovery_payload: {
name: endpoint ? /* v8 ignore next */ utils.capitalize(endpoint) : null,
command_topic: true,
command_topic_prefix: endpoint,
state_topic: false,
optimistic: true,
},
};
if (modeFeature) {
const tones = modeFeature.values.filter((v) => v !== "stop");
if (tones.length) {
discoveryEntry.discovery_payload.available_tones = tones;
}
}
if (levelFeature) {
discoveryEntry.discovery_payload.support_volume_set = true;
}
if (durationFeature) {
discoveryEntry.discovery_payload.support_duration = true;
}
const levelTemplate =
"{% if volume_level is defined %}" +
"{% if volume_level | float <= 0.25 %}low" +
"{% elif volume_level | float <= 0.5 %}medium" +
"{% elif volume_level | float <= 0.75 %}high" +
"{% else %}very_high{% endif %}" +
"{% else %}medium{% endif %}";
discoveryEntry.discovery_payload.command_template =
`{"warning": {"mode": "{{ tone | default('emergency') }}", ` +
`"level": "${levelTemplate}", ` +
`"duration": {{ duration | default(10) }}}}`;
discoveryEntry.discovery_payload.command_off_template = '{"warning": {"mode": "stop"}}';
discoveryEntries.push(discoveryEntry);
break;
}
if (firstExposeTyped.type === "text" && firstExposeTyped.access & ACCESS_SET) {
discoveryEntries.push({
type: "text",
@@ -1218,13 +1356,13 @@ export class HomeAssistant extends Extension {
@bind async onEntityRemoved(data: eventdata.EntityRemoved): Promise<void> {
logger.debug(`Clearing Home Assistant discovery for '${data.name}'`);
const discovered = this.getDiscovered(data.id);
const discovered = this.getDiscovered(data.entity.ID);
for (const topic of Object.keys(discovered.messages)) {
await this.mqtt.publish(topic, "", {clientOptions: {retain: true, qos: 1}, baseTopic: this.discoveryTopic, skipReceive: false});
}
delete this.discovered[data.id];
delete this.discovered[data.entity.ID];
}
@bind async onGroupMembersChanged(data: eventdata.GroupMembersChanged): Promise<void> {
@@ -1512,6 +1650,11 @@ export class HomeAssistant extends Extension {
payload.object_id = `${payload.object_id}${payload.object_id_postfix ?? ""}`;
delete payload.object_id_postfix;
// Set `default_entity_id`, as of HA 2025.10 this replaces the `object_id`.
// For migration purposes we set both for now.
// https://github.com/home-assistant/core/pull/151775
payload.default_entity_id = `${config.type}.${payload.object_id}`;
// Set unique_id
payload.unique_id = `${entity.options.ID}_${config.object_id}_${settings.get().mqtt.base_topic}`;
@@ -1637,6 +1780,10 @@ export class HomeAssistant extends Extension {
payload.action_topic = stateTopic;
}
if (payload.current_humidity_topic) {
payload.current_humidity_topic = stateTopic;
}
// Override configuration with user settings.
if (entity.options.homeassistant != null) {
const add = (obj: KeyValue, ignoreName: boolean): void => {
@@ -1720,11 +1867,11 @@ export class HomeAssistant extends Extension {
try {
message = JSON.parse(data.message);
const baseTopic = `${settings.get().mqtt.base_topic}/`;
if (isDeviceAutomation && (!message.topic || !message.topic.startsWith(baseTopic))) {
if (isDeviceAutomation && !message.topic?.startsWith(baseTopic)) {
return;
}
if (!isDeviceAutomation && (!message.availability || !message.availability[0].topic.startsWith(baseTopic))) {
if (!isDeviceAutomation && !message.availability?.[0].topic.startsWith(baseTopic)) {
return;
}
} catch {
@@ -1819,6 +1966,10 @@ export class HomeAssistant extends Extension {
};
const url = settings.get().frontend?.url ?? "";
// Since zigbee2mqtt-windfront support multiple instances the configuration URL contains the
// instance ID. Since we don't know which instance it is we always point to 0.
// https://github.com/Koenkk/zigbee2mqtt/issues/28936
const urlEntityPostfix = settings.get().frontend.package === "zigbee2mqtt-windfront" ? "0/" : "";
if (entity.isDevice()) {
assert(entity.definition, `Cannot 'getDevicePayload' for unsupported device`);
payload.model = entity.definition.description;
@@ -1826,11 +1977,11 @@ export class HomeAssistant extends Extension {
payload.manufacturer = entity.definition.vendor;
payload.sw_version = entity.zh.softwareBuildID;
payload.hw_version = entity.zh.hardwareVersion;
payload.configuration_url = `${url}/#/device/${entity.ieeeAddr}/info`;
payload.configuration_url = `${url}/#/device/${urlEntityPostfix}${entity.ieeeAddr}/info`;
} else if (entity.isGroup()) {
payload.model = "Group";
payload.manufacturer = "Zigbee2MQTT";
payload.configuration_url = `${url}/#/group/${entity.ID}`;
payload.configuration_url = `${url}/#/group/${urlEntityPostfix}${entity.ID}`;
} else {
payload.model = "Bridge";
payload.manufacturer = "Zigbee2MQTT";
+32 -24
View File
@@ -1,7 +1,8 @@
import bind from "bind-decorator";
import stringify from "json-stable-stringify-without-jsonify";
import type {Eui64} from "zigbee-herdsman/dist/zspec/tstypes";
import type {LQITableEntry, RoutingTableEntry} from "zigbee-herdsman/dist/zspec/zdo/definition/tstypes";
import type {Zigbee2MQTTAPI, Zigbee2MQTTNetworkMap} from "../types/api";
import logger from "../util/logger";
import * as settings from "../util/settings";
import utils from "../util/utils";
@@ -13,7 +14,7 @@ const SUPPORTED_FORMATS = ["raw", "graphviz", "plantuml"];
* This extension creates a network map
*/
export default class NetworkMap extends Extension {
private topic = `${settings.get().mqtt.base_topic}/bridge/request/networkmap`;
#topic = `${settings.get().mqtt.base_topic}/bridge/request/networkmap`;
// biome-ignore lint/suspicious/useAwait: API
override async start(): Promise<void> {
@@ -21,7 +22,7 @@ export default class NetworkMap extends Extension {
}
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
if (data.topic === this.topic) {
if (data.topic === this.#topic) {
const message = utils.parseJSON(data.message, data.message) as Zigbee2MQTTAPI["bridge/request/networkmap"];
try {
@@ -91,6 +92,8 @@ export default class NetworkMap extends Extension {
}
labels.push(lastSeen);
// Escape backslashes and double-quotes to avoid breaking Graphviz .dot files.
const escapedLabels = labels.map((label) => label.replace(/\\/g, "\\\\").replace(/"/g, '\\"'));
// Shape the record according to device type
if (node.type === "Coordinator") {
@@ -102,7 +105,7 @@ export default class NetworkMap extends Extension {
}
// 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="{${escapedLabels.join("|")}}"];\n`;
/**
* Add an edge between the device and its child to the graph
@@ -185,8 +188,8 @@ export default class NetworkMap extends Extension {
async networkScan(includeRoutes: boolean): Promise<Zigbee2MQTTNetworkMap> {
logger.info(`Starting network scan (includeRoutes '${includeRoutes}')`);
const lqis = new Map<Device, zh.LQI>();
const routingTables = new Map<Device, zh.RoutingTable>();
const lqis = new Map<Device, LQITableEntry[]>();
const routingTables = new Map<Device, RoutingTableEntry[]>();
const failed = new Map<Device, string[]>();
const requestWithRetry = async <T>(request: () => Promise<T>): Promise<T> => {
try {
@@ -210,7 +213,7 @@ export default class NetworkMap extends Extension {
await utils.sleep(1); // sleep 1 second between each scan to reduce stress on network.
try {
const result = await requestWithRetry<zh.LQI>(async () => await device.zh.lqi());
const result = await requestWithRetry<LQITableEntry[]>(async () => await device.zh.lqi());
lqis.set(device, result);
logger.debug(`LQI succeeded for '${device.name}'`);
} catch (error) {
@@ -222,7 +225,7 @@ export default class NetworkMap extends Extension {
if (includeRoutes) {
try {
const result = await requestWithRetry<zh.RoutingTable>(async () => await device.zh.routingTable());
const result = await requestWithRetry<RoutingTableEntry[]>(async () => await device.zh.routingTable());
routingTables.set(device, result);
logger.debug(`Routing table succeeded for '${device.name}'`);
} catch (error) {
@@ -274,42 +277,47 @@ export default class NetworkMap extends Extension {
}
// Add links
for (const [device, lqi] of lqis) {
for (const neighbor of lqi.neighbors) {
for (const [device, table] of lqis) {
for (const neighbor of table) {
if (neighbor.relationship > 3) {
// Relationship is not active, skip it
continue;
}
let neighborEui64 = neighbor.eui64;
// Some Xiaomi devices return 0x00 as the neighbor ieeeAddr (obviously not correct).
// Determine the correct ieeeAddr based on the networkAddress.
if (neighbor.ieeeAddr === "0x0000000000000000") {
const neighborDevice = this.zigbee.deviceByNetworkAddress(neighbor.networkAddress);
if (neighborEui64 === "0x0000000000000000") {
const neighborDevice = this.zigbee.deviceByNetworkAddress(neighbor.nwkAddress);
if (neighborDevice) {
neighbor.ieeeAddr = neighborDevice.ieeeAddr;
neighborEui64 = neighborDevice.ieeeAddr as Eui64;
}
}
const link: Zigbee2MQTTNetworkMap["links"][number] = {
source: {ieeeAddr: neighbor.ieeeAddr, networkAddress: neighbor.networkAddress},
source: {ieeeAddr: neighborEui64, networkAddress: neighbor.nwkAddress},
target: {ieeeAddr: device.ieeeAddr, networkAddress: device.zh.networkAddress},
linkquality: neighbor.linkquality,
depth: neighbor.depth,
routes: [],
// DEPRECATED:
sourceIeeeAddr: neighbor.ieeeAddr,
targetIeeeAddr: device.ieeeAddr,
sourceNwkAddr: neighbor.networkAddress,
lqi: neighbor.linkquality,
deviceType: neighbor.deviceType,
rxOnWhenIdle: neighbor.rxOnWhenIdle,
relationship: neighbor.relationship,
permitJoining: neighbor.permitJoining,
depth: neighbor.depth,
lqi: neighbor.lqi,
routes: [],
// below are @deprecated
sourceIeeeAddr: neighborEui64,
targetIeeeAddr: device.ieeeAddr,
sourceNwkAddr: neighbor.nwkAddress,
linkquality: neighbor.lqi,
};
const routingTable = routingTables.get(device);
if (routingTable) {
for (const entry of routingTable.table) {
if (entry.status === "ACTIVE" && entry.nextHop === neighbor.networkAddress) {
for (const entry of routingTable) {
if (entry.nextHopAddress === neighbor.nwkAddress) {
link.routes.push(entry);
}
}
+43 -26
View File
@@ -1,4 +1,4 @@
import {onEvent} from "zigbee-herdsman-converters";
import {onEvent, type OnEvent as ZhcOnEvent} from "zigbee-herdsman-converters";
import utils from "../util/utils";
import Extension from "./extension";
@@ -7,42 +7,52 @@ import Extension from "./extension";
* This extension calls the zigbee-herdsman-converters onEvent.
*/
export default class OnEvent extends Extension {
readonly #startCalled: Set<string> = new Set();
#getOnEventBaseData(device: Device): ZhcOnEvent.BaseData {
const deviceExposesChanged = (): void => this.eventBus.emitExposesAndDevicesChanged(device);
const state = this.state.get(device);
const options = device.options as KeyValue;
return {deviceExposesChanged, device: device.zh, state, options};
}
// biome-ignore lint/suspicious/useAwait: API
override async start(): Promise<void> {
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
// don't await, in case of repeated failures this would hold startup
this.callOnEvent(device, "start", {}).catch(utils.noop);
this.callOnEvent(device, {type: "start", data: this.#getOnEventBaseData(device)}).catch(utils.noop);
}
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", {});
await this.callOnEvent(data.device, {type: "deviceJoined", data: this.#getOnEventBaseData(data.device)});
});
this.eventBus.onDeviceLeave(this, async (data) => {
if (data.device) {
await this.callOnEvent(data.device, "stop", {});
await this.callOnEvent(data.device, {type: "stop", data: {ieeeAddr: data.device.ieeeAddr}});
}
});
this.eventBus.onEntityRemoved(this, async (data) => {
if (data.entity.isDevice()) {
await this.callOnEvent(data.entity, {type: "stop", data: {ieeeAddr: data.entity.ieeeAddr}});
}
});
this.eventBus.onDeviceInterview(this, async (data) => {
await this.callOnEvent(data.device, "deviceInterview", {});
// TODO: this is triggering for any `data.status`, any use outside `successful`?
// ZHC definition would skip from `device.definition?` but OnEvent from ZHC index wouldn't => triple triggering?
await this.callOnEvent(data.device, {type: "deviceInterview", data: {...this.#getOnEventBaseData(data.device), status: data.status}});
});
this.eventBus.onDeviceAnnounce(this, async (data) => {
await this.callOnEvent(data.device, "deviceAnnounce", {});
await this.callOnEvent(data.device, {type: "deviceAnnounce", data: this.#getOnEventBaseData(data.device)});
});
this.eventBus.onDeviceNetworkAddressChanged(this, async (data) => {
await this.callOnEvent(data.device, "deviceNetworkAddressChanged", {});
await this.callOnEvent(data.device, {type: "deviceNetworkAddressChanged", data: this.#getOnEventBaseData(data.device)});
});
this.eventBus.onEntityOptionsChanged(this, async (data) => {
if (data.entity.isDevice()) {
await this.callOnEvent(data.entity, "deviceOptionsChanged", {});
await this.callOnEvent(data.entity, {
type: "deviceOptionsChanged",
data: {...this.#getOnEventBaseData(data.entity), from: data.from, to: data.to},
});
this.eventBus.emitDevicesChanged();
}
});
@@ -51,24 +61,31 @@ export default class OnEvent extends Extension {
override async stop(): Promise<void> {
await super.stop();
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
await this.callOnEvent(device, "stop", {});
// ensure properly started, else this throws undesired errors (e.g. SIGINT during startup)
if (this.zigbee.zhController !== undefined) {
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
await this.callOnEvent(device, {type: "stop", data: {ieeeAddr: device.ieeeAddr}});
}
}
}
private async callOnEvent(device: Device, type: Parameters<typeof onEvent>[0], data: Parameters<typeof onEvent>[1]): Promise<void> {
private async callOnEvent(device: Device, event: ZhcOnEvent.Event): Promise<void> {
if (device.options.disabled) {
return;
}
const state = this.state.get(device);
const deviceExposesChanged = (): void => this.eventBus.emitExposesAndDevicesChanged(device);
if (event.type === "start") {
this.#startCalled.add(device.ieeeAddr);
} else if (event.type !== "stop" && !this.#startCalled.has(device.ieeeAddr) && device.definition?.onEvent) {
this.#startCalled.add(device.ieeeAddr);
await device.definition.onEvent({type: "start", data: this.#getOnEventBaseData(device)});
}
await onEvent(type, data, device.zh, {deviceExposesChanged});
await onEvent(event);
await device.definition?.onEvent?.(event);
if (device.definition?.onEvent) {
const options: KeyValue = device.options;
await device.definition.onEvent(type, data, device.zh, options, state, {deviceExposesChanged});
if (event.type === "stop") {
this.#startCalled.delete(device.ieeeAddr);
}
}
}
+286 -202
View File
@@ -1,10 +1,10 @@
import assert from "node:assert";
import path from "node:path";
import {existsSync, mkdirSync, rmSync, writeFileSync} from "node:fs";
import {join} from "node:path";
import bind from "bind-decorator";
import stringify from "json-stable-stringify-without-jsonify";
import {Zcl} from "zigbee-herdsman";
import type {Ota} from "zigbee-herdsman-converters";
import {ota} from "zigbee-herdsman-converters";
import {setOtaConfiguration, Zcl} from "zigbee-herdsman";
import type {OtaDataSettings, OtaSource, OtaUpdateAvailableResult} from "zigbee-herdsman/dist/controller/tstype";
import Device from "../model/device";
import type {Zigbee2MQTTAPI} from "../types/api";
import dataDir from "../util/data";
@@ -22,55 +22,65 @@ export interface UpdatePayload {
state: UpdateState;
installed_version: number | null;
latest_version: number | null;
latest_source: string | null;
latest_release_notes: string | null;
};
}
const topicRegex = new RegExp(
`^${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/(update|check|schedule|unschedule)/?(downgrade)?`,
"i",
);
/**
* Write to `dataDir` and return created path
*/
function writeFirmwareHexToDataDir(hex: string, fileName: string | undefined, deviceIeee: string): string {
if (!fileName) {
fileName = `${deviceIeee}_${Date.now()}`;
}
const baseDir = dataDir.joinPath("ota");
if (!existsSync(baseDir)) {
mkdirSync(baseDir, {recursive: true});
}
const filePath = join(baseDir, fileName);
writeFileSync(filePath, Buffer.from(hex, "hex"));
return filePath;
}
export default class OTAUpdate extends Extension {
private inProgress = new Set<string>();
private lastChecked = new Map<string, number>();
private scheduledUpgrades = new Set<string>();
private scheduledDowngrades = new Set<string>();
#topicRegex = new RegExp(
`^${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/(update|check|schedule|unschedule)/?(downgrade)?`,
"i",
);
#inProgress = new Set<string>();
#lastChecked = new Map<string, number>();
// biome-ignore lint/suspicious/useAwait: API
override async start(): Promise<void> {
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
this.eventBus.onDeviceMessage(this, this.onZigbeeEvent);
const otaSettings = settings.get().ota;
// Let OTA module know if the override index file is provided
let overrideIndexLocation = otaSettings.zigbee_ota_override_index_location;
// If the file name is not a full path, then treat it as a relative to the data directory
if (overrideIndexLocation && !ota.isValidUrl(overrideIndexLocation) && !path.isAbsolute(overrideIndexLocation)) {
overrideIndexLocation = dataDir.joinPath(overrideIndexLocation);
}
// In order to support local firmware files we need to let zigbeeOTA know where the data directory is
ota.setConfiguration({
dataDir: dataDir.getPath(),
overrideIndexLocation,
// TODO: implement me
imageBlockResponseDelay: otaSettings.image_block_response_delay,
defaultMaximumDataSize: otaSettings.default_maximum_data_size,
});
setOtaConfiguration(dataDir.getPath(), settings.get().ota.zigbee_ota_override_index_location);
// In case Zigbee2MQTT is restared during an update, progress and remaining values are still in state, remove them.
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
this.removeProgressAndRemainingFromState(device);
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";
this.state.get(device).update.state = "idle";
}
}
}
private removeProgressAndRemainingFromState(device: Device): void {
// mostly intended for testing
clearState(): void {
this.#inProgress.clear();
this.#lastChecked.clear();
}
#removeProgressAndRemainingFromState(device: Device): void {
const deviceState = this.state.get(device);
if (deviceState.update) {
@@ -80,7 +90,7 @@ 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)) {
if (data.type !== "commandQueryNextImageRequest" || !data.device.definition || this.#inProgress.has(data.device.ieeeAddr)) {
return;
}
@@ -92,115 +102,84 @@ export default class OTAUpdate extends Extension {
logger.debug(`Device '${data.device.name}' requested OTA`);
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.zh.scheduledOta) {
// allow custom source to override check for definition `ota`
if (data.device.zh.scheduledOta?.url !== undefined || data.device.definition.ota) {
this.#inProgress.add(data.device.ieeeAddr);
logger.info(`Updating '${data.device.name}' to latest firmware`);
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)}%`;
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(
const otaSettings = settings.get().ota;
const [, toVersion] = await this.#update(
undefined, // uses internally registered schedule
data.device,
this.getEntityPublishPayload(data.device, {available: false, currentFileVersion: fileVersion, otaFileVersion: fileVersion}),
data.data as Zcl.ClustersTypes.TClusterCommandPayload<"genOta", "queryNextImageRequest">,
data.meta.zclTransactionSequenceNumber,
{
// fallbacks are only to satisfy typing, should always be defined from settings defaults
requestTimeout: otaSettings.image_block_request_timeout ?? /* v8 ignore next */ 150000,
responseDelay: otaSettings.image_block_response_delay ?? /* v8 ignore next */ 250,
baseSize: otaSettings.default_maximum_data_size ?? /* v8 ignore next */ 50,
},
data.endpoint,
);
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();
if (toVersion === undefined) {
logger.info(`No OTA image currently available for '${data.device.name}'. Unscheduled.`);
}
} catch (e) {
logger.debug(`Update of '${data.device.name}' failed (${e}). Retry scheduled for next request.`);
logger.debug(`OTA 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.#removeProgressAndRemainingFromState(data.device);
await this.publishEntityState(data.device, this.#getEntityPublishPayload(data.device, "scheduled"));
}
this.inProgress.delete(data.device.ieeeAddr);
this.#inProgress.delete(data.device.ieeeAddr);
return; // we're done
}
}
if (!settings.get().ota.disable_automatic_update_check) {
if (data.device.definition.ota) {
if (!data.device.options.disable_automatic_update_check && !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 deviceLastChecked = this.#lastChecked.get(data.device.ieeeAddr);
const check = deviceLastChecked !== undefined ? Date.now() - deviceLastChecked > updateCheckInterval : true;
if (!check) {
return;
}
this.inProgress.add(data.device.ieeeAddr);
this.lastChecked.set(data.device.ieeeAddr, Date.now());
let availableResult: Ota.UpdateAvailableResult | undefined;
this.#inProgress.add(data.device.ieeeAddr);
this.#lastChecked.set(data.device.ieeeAddr, Date.now());
let availableResult: OtaUpdateAvailableResult | 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);
// auto-check defaults to zigbee-OTA + potential local index, and never `downgrade`
availableResult = await data.device.zh.checkOta(
{downgrade: false},
data.data as Zcl.ClustersTypes.TClusterCommandPayload<"genOta", "queryNextImageRequest">,
data.device.otaExtraMetas,
data.endpoint,
);
} catch (error) {
logger.debug(`Failed to check if update available for '${data.device.name}' (${error})`);
logger.debug(`Failed to check if OTA update available for '${data.device.name}' (${error})`);
}
await this.publishEntityState(data.device, this.getEntityPublishPayload(data.device, availableResult ?? "idle"));
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);
logger.info(`OTA update available for '${data.device.name}'`);
}
}
}
// Respond to stop the client from requesting OTAs
const endpoint = data.device.zh.endpoints.find((e) => e.supportsOutputCluster("genOta")) || data.endpoint;
await endpoint.commandResponse(
await data.endpoint.commandResponse(
"genOta",
"queryNextImageResponse",
{status: Zcl.Status.NO_IMAGE_AVAILABLE},
@@ -208,51 +187,57 @@ export default class OTAUpdate extends Extension {
data.meta.zclTransactionSequenceNumber,
);
logger.debug(`Responded to OTA request of '${data.device.name}' with 'NO_IMAGE_AVAILABLE'`);
this.inProgress.delete(data.device.ieeeAddr);
this.#inProgress.delete(data.device.ieeeAddr);
}
private async readSoftwareBuildIDAndDateCode(
async #readSoftwareBuildIDAndDateCode(
device: Device,
sendPolicy?: "immediate",
): Promise<{softwareBuildID: string; dateCode: string} | undefined> {
try {
const endpoint = device.zh.endpoints.find((e) => e.supportsInputCluster("genBasic"));
assert(endpoint);
const result = await endpoint.read("genBasic", ["dateCode", "swBuildId"], {sendPolicy});
return {softwareBuildID: result.swBuildId, dateCode: result.dateCode};
} catch {
return undefined;
}
}
private getEntityPublishPayload(
device: Device,
state: Ota.UpdateAvailableResult | UpdateState,
progress?: number,
remaining?: number,
): UpdatePayload {
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,
},
};
#getEntityPublishPayload(device: Device, state: OtaUpdateAvailableResult | UpdateState, progress?: number, remaining?: number): UpdatePayload {
const deviceUpdateState = this.state.get(device).update as UpdatePayload["update"];
const update: UpdatePayload["update"] =
typeof state === "string"
? {
state,
installed_version: deviceUpdateState?.installed_version,
latest_version: deviceUpdateState?.latest_version,
latest_source: deviceUpdateState?.latest_source,
latest_release_notes: deviceUpdateState?.latest_release_notes,
}
: {
state: state.available ? "available" : "idle",
installed_version: state.current.fileVersion,
latest_version: state.availableMeta?.fileVersion ?? state.current.fileVersion,
latest_source: state.availableMeta?.url || null,
latest_release_notes: state.availableMeta?.releaseNotes || null,
};
if (progress !== undefined) {
payload.update.progress = progress;
update.progress = progress;
}
if (remaining !== undefined) {
payload.update.remaining = Math.round(remaining);
update.remaining = Math.round(remaining);
}
return payload;
return {update};
}
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
const topicMatch = data.topic.match(topicRegex);
const topicMatch = data.topic.match(this.#topicRegex);
if (!topicMatch) {
return;
@@ -266,7 +251,14 @@ export default class OTAUpdate extends Extension {
| 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;
// TODO: deprecated 3.0 should remove string payload, enforce object
const messageObject = typeof message === "object";
if (messageObject) {
assert(message.id, "Invalid payload");
}
const ID = (messageObject ? message.id : message) as string;
const device = this.zigbee.resolveEntity(ID);
const type = topicMatch[1] as "check" | "update" | "schedule" | "unschedule";
const downgrade = topicMatch[2] === "downgrade";
@@ -275,34 +267,53 @@ export default class OTAUpdate extends Extension {
if (!(device instanceof Device)) {
error = `Device '${ID}' does not exist`;
} else if (!device.definition || !device.definition.ota) {
error = `Device '${device.name}' does not support OTA updates`;
} else if (this.inProgress.has(device.ieeeAddr)) {
} 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}'`;
error = `OTA update or check for update already in progress for '${device.name}'`;
} else {
switch (type) {
case "check": {
this.inProgress.add(device.ieeeAddr);
this.#inProgress.add(device.ieeeAddr);
logger.info(`Checking if update available for '${device.name}'`);
const source: OtaSource = {downgrade};
if (messageObject) {
const payload = message as
| Zigbee2MQTTAPI["bridge/request/device/ota_update/check"]
| Zigbee2MQTTAPI["bridge/request/device/ota_update/check/downgrade"];
if (payload.url) {
source.url = payload.url;
} else if (!device.definition?.ota) {
error = `Device '${device.name}' does not support OTA updates`;
break;
}
} else if (!device.definition?.ota) {
error = `Device '${device.name}' does not support OTA updates`;
break;
}
logger.info(`Checking if OTA update available for '${device.name}'`);
try {
const availableResult = await ota.isUpdateAvailable(device.zh, device.otaExtraMetas, undefined, downgrade);
const availableResult = await device.zh.checkOta(source, undefined, device.otaExtraMetas);
logger.info(`${availableResult.available ? "Update" : "No update"} available for '${device.name}'`);
logger.info(`${availableResult.available ? "" : "No "}OTA update available for '${device.name}'`);
await this.publishEntityState(device, this.getEntityPublishPayload(device, availableResult));
this.lastChecked.set(device.ieeeAddr, Date.now());
await this.publishEntityState(device, this.#getEntityPublishPayload(device, availableResult));
this.#lastChecked.set(device.ieeeAddr, Date.now());
const response = utils.getResponse<"bridge/response/device/ota_update/check">(message, {
id: ID,
update_available: availableResult.available,
downgrade: source.downgrade,
source: availableResult.availableMeta?.url,
release_notes: availableResult.availableMeta?.releaseNotes,
});
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})`;
error = `Failed to check if OTA update available for '${device.name}' (${(e as Error).message})`;
errorStack = (e as Error).stack;
}
@@ -310,94 +321,113 @@ export default class OTAUpdate extends Extension {
}
case "update": {
this.inProgress.add(device.ieeeAddr);
this.#inProgress.add(device.ieeeAddr);
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`);
}
const otaSettings = settings.get().ota;
const source: OtaSource = {downgrade};
const dataSettings: OtaDataSettings = {
// fallbacks are only to satisfy typing, should always be defined from settings defaults
requestTimeout: otaSettings.image_block_request_timeout ?? /* v8 ignore next */ 150000,
responseDelay: otaSettings.image_block_response_delay ?? /* v8 ignore next */ 250,
baseSize: otaSettings.default_maximum_data_size ?? /* v8 ignore next */ 50,
};
logger.info(`Updating '${device.name}' to ${downgrade ? "previous" : "latest"} firmware`);
if (messageObject) {
const payload = message as
| Zigbee2MQTTAPI["bridge/request/device/ota_update/update"]
| Zigbee2MQTTAPI["bridge/request/device/ota_update/update/downgrade"];
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 (payload.hex) {
assert(payload.hex.data);
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");
// write to `dataDir` and pass created path as source URL
source.url = writeFirmwareHexToDataDir(payload.hex.data, payload.hex.file_name, device.ieeeAddr);
} else if (payload.url) {
source.url = payload.url;
} else if (!device.definition?.ota) {
error = `Device '${device.name}' does not support OTA updates`;
break;
}
logger.info(`Finished update of '${device.name}'`);
this.removeProgressAndRemainingFromState(device);
await this.publishEntityState(
device,
this.getEntityPublishPayload(device, {available: false, currentFileVersion: fileVersion, otaFileVersion: fileVersion}),
);
if (payload.image_block_request_timeout) {
dataSettings.requestTimeout = payload.image_block_request_timeout;
}
const firmwareTo = await this.readSoftwareBuildIDAndDateCode(device);
if (payload.image_block_response_delay) {
dataSettings.responseDelay = payload.image_block_response_delay;
}
logger.info(() => `Device '${device.name}' was updated from '${stringify(firmwareFrom)}' to '${stringify(firmwareTo)}'`);
if (payload.default_maximum_data_size) {
dataSettings.baseSize = payload.default_maximum_data_size;
}
} else if (!device.definition?.ota) {
error = `Device '${device.name}' does not support OTA updates`;
break;
}
/**
* 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(`OTA updating '${device.name}' to ${downgrade ? "previous" : "latest"} firmware`);
try {
const firmwareFrom = await this.#readSoftwareBuildIDAndDateCode(device, "immediate");
const [fromVersion, toVersion] = await this.#update(source, device, undefined, undefined, dataSettings);
if (toVersion === undefined) {
error = `Update of '${device.name}' failed (No image currently available)`;
break;
}
const firmwareTo = await this.#readSoftwareBuildIDAndDateCode(device);
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,
from: {
file_version: fromVersion,
software_build_id: firmwareFrom?.softwareBuildID,
date_code: firmwareFrom?.dateCode,
},
to: {file_version: toVersion, software_build_id: firmwareTo?.softwareBuildID, date_code: firmwareTo?.dateCode},
});
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})`;
logger.debug(`OTA update of '${device.name}' failed (${e})`);
error = `OTA update of '${device.name}' failed (${(e as Error).message})`;
errorStack = (e as Error).stack;
this.removeProgressAndRemainingFromState(device);
await this.publishEntityState(device, this.getEntityPublishPayload(device, "available"));
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`);
}
const source: OtaSource = {downgrade};
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`);
}
if (messageObject) {
const payload = message as
| Zigbee2MQTTAPI["bridge/request/device/ota_update/schedule"]
| Zigbee2MQTTAPI["bridge/request/device/ota_update/schedule/downgrade"];
this.scheduledUpgrades.add(device.ieeeAddr);
if (payload.hex) {
assert(payload.hex.data);
// write to `dataDir` and pass created path as source URL
source.url = writeFirmwareHexToDataDir(payload.hex.data, payload.hex.file_name, device.ieeeAddr);
} else if (payload.url) {
source.url = payload.url;
} else if (!device.definition?.ota) {
error = `Device '${device.name}' does not support OTA updates`;
break;
}
} else if (!device.definition?.ota) {
error = `Device '${device.name}' does not support OTA updates`;
break;
}
logger.info(`Scheduled '${device.name}' to ${downgrade ? "downgrade" : "upgrade"} firmware on next request from device`);
device.zh.scheduleOta(source);
await this.publishEntityState(device, this.#getEntityPublishPayload(device, "scheduled"));
await this.publishEntityState(device, this.getEntityPublishPayload(device, "scheduled", undefined, undefined));
const response = utils.getResponse<"bridge/response/device/ota_update/schedule">(message, {
id: ID,
});
const response = utils.getResponse<"bridge/response/device/ota_update/schedule">(message, {id: ID, url: source.url});
await this.mqtt.publish("bridge/response/device/ota_update/schedule", stringify(response));
@@ -405,13 +435,12 @@ export default class OTAUpdate extends Extension {
}
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`);
if (device.zh.scheduledOta?.url?.startsWith(dataDir.joinPath("ota"))) {
rmSync(device.zh.scheduledOta.url, {force: true});
}
await this.publishEntityState(device, this.getEntityPublishPayload(device, "idle", undefined, undefined));
device.zh.unscheduleOta();
await this.publishEntityState(device, this.#getEntityPublishPayload(device, "idle"));
const response = utils.getResponse<"bridge/response/device/ota_update/unschedule">(message, {
id: ID,
@@ -423,7 +452,7 @@ export default class OTAUpdate extends Extension {
}
}
this.inProgress.delete(device.ieeeAddr);
this.#inProgress.delete(device.ieeeAddr);
}
if (error) {
@@ -437,4 +466,59 @@ export default class OTAUpdate extends Extension {
}
}
}
/**
* Do the bulk of the update work (hand over to zigbee-herdsman, then re-interview).
*
* `dataSettings` object may be mutated by zigbee-herdsman to fit request (e.g. known manuf quirk)
*/
async #update(
source: OtaSource | undefined,
device: Device,
requestPayload: Zcl.ClustersTypes.TClusterCommandPayload<"genOta", "queryNextImageRequest"> | undefined,
requestTsn: number | undefined,
dataSettings: OtaDataSettings,
endpoint?: zh.Endpoint,
): Promise<[from: number, to: number | undefined]> {
const [from, to] = await device.zh.updateOta(
source,
requestPayload,
requestTsn,
device.otaExtraMetas,
async (progress, remaining) => {
await this.publishEntityState(device, this.#getEntityPublishPayload(device, "updating", progress, remaining));
},
dataSettings,
endpoint,
);
if (to === undefined) {
this.#removeProgressAndRemainingFromState(device);
await this.publishEntityState(device, this.#getEntityPublishPayload(device, {available: false, current: from}));
return [from.fileVersion, undefined];
}
logger.info(`Finished update of '${device.name}'`);
this.#removeProgressAndRemainingFromState(device);
await this.publishEntityState(device, this.#getEntityPublishPayload(device, {available: false, current: to}));
logger.info(() => `Device '${device.name}' was OTA updated from '${from.fileVersion}' to '${to.fileVersion}'`);
// OTA update can bring new features & co, force full re-interview and re-configure, same as a "device joined"
if (device.zh.meta.configured !== undefined) {
delete device.zh.meta.configured;
device.zh.save();
}
setTimeout(() => {
device.reInterview(this.eventBus).catch((error) => {
logger.error(`${error.message}. Re-try manually after some time.`);
});
}, 5000);
return [from.fileVersion, to.fileVersion];
}
}
+3 -2
View File
@@ -9,12 +9,12 @@ import * as settings from "../util/settings";
import utils from "../util/utils";
import Extension from "./extension";
// TODO: get rid of this, use class member
let topicGetSetRegex: RegExp;
// Used by `publish.test.js` to reload regex when changing `mqtt.base_topic`.
// Used by `publish.test.ts` to reload regex when changing `mqtt.base_topic`.
export const loadTopicGetSetRegex = (): void => {
topicGetSetRegex = new RegExp(`^${settings.get().mqtt.base_topic}/(?!bridge)(.+?)/(get|set)(?:/(.+))?$`);
};
loadTopicGetSetRegex();
const STATE_VALUES: ReadonlyArray<string> = ["on", "off", "toggle", "open", "close", "stop", "lock", "unlock"];
const SCENE_CONVERTER_KEYS: ReadonlyArray<string> = ["scene_store", "scene_add", "scene_remove", "scene_remove_all", "scene_rename"];
@@ -29,6 +29,7 @@ interface ParsedTopic {
export default class Publish extends Extension {
// biome-ignore lint/suspicious/useAwait: API
override async start(): Promise<void> {
loadTopicGetSetRegex();
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
}
+1 -1
View File
@@ -136,7 +136,7 @@ export default class Receive extends Extension {
const publish = async (payload: KeyValue): Promise<void> => {
assert(data.device.definition);
const options: KeyValue = data.device.options;
zhc.postProcessConvertedFromZigbeeMessage(data.device.definition, payload, options);
zhc.postProcessConvertedFromZigbeeMessage(data.device.definition, payload, options, data.device.zh);
if (settings.get().advanced.elapsed) {
const now = Date.now();
+36 -26
View File
@@ -1,9 +1,10 @@
import assert from "node:assert";
import {InterviewState} from "zigbee-herdsman/dist/controller/model/device";
import type {OtaExtraMetas} from "zigbee-herdsman/dist/controller/tstype";
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 logger from "../util/logger";
import * as settings from "../util/settings";
const LINKQUALITY = new Numeric("linkquality", access.STATE)
@@ -38,7 +39,7 @@ export default class Device {
get customClusters(): CustomClusters {
return this.zh.customClusters;
}
get otaExtraMetas(): zhc.Ota.ExtraMetas {
get otaExtraMetas(): OtaExtraMetas {
return typeof this.definition?.ota === "object" ? this.definition.ota : {};
}
get interviewed(): boolean {
@@ -69,6 +70,23 @@ export default class Device {
}
}
async reInterview(eventBus: EventBus): Promise<void> {
// logic follows that of receiving `deviceInterview` event from ZH layer
logger.info(`Interviewing '${this.name}'`);
eventBus.emitDeviceInterview({status: "started", device: this});
try {
await this.zh.interview(true);
// A re-interview can for example result in a different modelId, therefore reconsider the definition.
await this.resolveDefinition(true);
logger.info(`Successfully interviewed '${this.name}'`);
eventBus.emitDeviceInterview({status: "successful", device: this});
} catch (error) {
eventBus.emitDeviceInterview({status: "failed", device: this});
throw new Error(`Interview of '${this.name}' (${this.ieeeAddr}) failed: ${error}`);
}
}
ensureInSettings(): void {
if (this.zh.type !== "Coordinator" && !settings.getDevice(this.zh.ieeeAddr)) {
settings.addDevice(this.zh.ieeeAddr);
@@ -76,43 +94,33 @@ export default class Device {
}
endpoint(key?: string | number): zh.Endpoint | undefined {
let endpoint: zh.Endpoint | undefined;
if (!key) {
key = "default";
} else if (!Number.isNaN(Number(key))) {
return this.zh.getEndpoint(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 (this.definition?.endpoint) {
const ID = this.definition.endpoint(this.zh)[key];
if (ID) {
endpoint = this.zh.getEndpoint(ID);
} else if (key === "default") {
endpoint = this.zh.endpoints[0];
} else {
return undefined;
return this.zh.getEndpoint(ID);
}
} else {
if (key !== "default") {
return undefined;
}
endpoint = this.zh.endpoints[0];
}
return endpoint;
return key === "default" ? this.zh.endpoints[0] : undefined;
}
endpointName(endpoint: zh.Endpoint): string | undefined {
let epName: string | undefined;
if (this.definition?.endpoint) {
const mapping = this.definition?.endpoint(this.zh);
for (const [name, id] of Object.entries(mapping)) {
if (id === endpoint.ID) {
const mapping = this.definition.endpoint(this.zh);
for (const name in mapping) {
if (mapping[name] === endpoint.ID) {
epName = name;
break;
}
}
}
@@ -124,9 +132,11 @@ export default class Device {
getEndpointNames(): string[] {
const names: string[] = [];
for (const name in this.definition?.endpoint?.(this.zh) ?? {}) {
if (name !== "default") {
names.push(name);
if (this.definition?.endpoint) {
for (const name in this.definition.endpoint(this.zh)) {
if (name !== "default") {
names.push(name);
}
}
}
+7 -1
View File
@@ -1,6 +1,6 @@
import type * as zhc from "zigbee-herdsman-converters";
import * as settings from "../util/settings";
import {DEFAULT_BIND_GROUP_ID} from "../util/utils";
export default class Group {
public zh: zh.Group;
@@ -23,6 +23,12 @@ export default class Group {
this.resolveDevice = resolveDevice;
}
ensureInSettings(): void {
if (this.ID !== DEFAULT_BIND_GROUP_ID && !settings.getGroup(this.ID)) {
settings.addGroup(this.name, this.ID.toString());
}
}
hasMember(device: Device): boolean {
return !!device.zh.endpoints.find((e) => this.zh.members.includes(e));
}
+11 -7
View File
@@ -152,7 +152,11 @@ export default class Mqtt {
const stateData: Zigbee2MQTTAPI["bridge/state"] = {state: "offline"};
await this.publish("bridge/state", JSON.stringify(stateData), {clientOptions: {retain: true}});
// prevent undesirable error when receiving SIGTERM/SIGINT during startup
if (this.client) {
await this.publish("bridge/state", JSON.stringify(stateData), {clientOptions: {retain: true}});
}
this.eventBus.removeListeners(this);
logger.info("Disconnecting from MQTT server");
await this.client?.endAsync();
@@ -171,7 +175,7 @@ export default class Mqtt {
const stateData: Zigbee2MQTTAPI["bridge/state"] = {state: "online"};
await this.publish("bridge/state", JSON.stringify(stateData), {clientOptions: {retain: true}});
await this.publish("bridge/state", JSON.stringify(stateData), {clientOptions: {retain: true, qos: 1}});
await this.subscribe(`${settings.get().mqtt.base_topic}/#`);
}
@@ -194,6 +198,7 @@ export default class Mqtt {
}
async publish(topic: string, payload: string, options: Partial<MqttPublishOptions> = {}): Promise<void> {
// TODO: add `options.validateTopic: boolean` to bypass these checks when topic is "controlled"
if (topic.includes("+") || topic.includes("#")) {
// https://github.com/Koenkk/zigbee2mqtt/issues/26939#issuecomment-2772309646
logger.error(`Topic '${topic}' includes wildcard characters, skipping publish.`);
@@ -222,19 +227,18 @@ export default class Mqtt {
logger.error("Not connected to MQTT server!");
logger.error(`Cannot send message: topic: '${topic}', payload: '${payload}`);
}
return;
}
if (!finalOptions.skipLog) {
logger.info(() => `MQTT publish: topic '${topic}', payload '${payload}'`, NS);
}
let clientOptions: IClientPublishOptions = finalOptions.clientOptions;
if (settings.get().mqtt.force_disable_retain) {
clientOptions = {...finalOptions.clientOptions, retain: false};
}
if (!finalOptions.skipLog) {
logger.info(() => `MQTT publish: topic '${topic}', payload '${payload}'`, NS);
}
try {
await this.client.publishAsync(topic, payload, clientOptions);
} catch (error) {
+8 -5
View File
@@ -51,11 +51,14 @@ class State {
}
stop(): void {
// Remove any invalid states (ie when the device has left the network) when the system is stopped
for (const [key] of this.state) {
if (typeof key === "string" && key.startsWith("0x") && !this.zigbee.resolveEntity(key)) {
// string key = ieeeAddr
this.state.delete(key);
// ensure properly started, else this throws undesired errors (e.g. SIGINT during startup)
if (this.zigbee.zhController !== undefined) {
// Remove any invalid states (ie when the device has left the network) when the system is stopped
for (const [key] of this.state) {
if (typeof key === "string" && key.startsWith("0x") && !this.zigbee.resolveEntity(key)) {
// string key = ieeeAddr
this.state.delete(key);
}
}
}
+142 -24
View File
@@ -1,5 +1,8 @@
import type * as zigbeeHerdsman from "zigbee-herdsman/dist";
import type {ClusterDefinition, ClusterName, CustomClusters} from "zigbee-herdsman/dist/zspec/zcl/definition/tstype";
import type {ZclPayload} from "zigbee-herdsman/dist/adapter/events";
import type {Eui64} from "zigbee-herdsman/dist/zspec/tstypes";
import type {Cluster, ClusterName, CustomClusters} from "zigbee-herdsman/dist/zspec/zcl/definition/tstype";
import type {GenericZdoResponse, RoutingTableEntry} from "zigbee-herdsman/dist/zspec/zdo/definition/tstypes";
import type * as zigbeeHerdsmanConverter from "zigbee-herdsman-converters";
import type {Base} from "zigbee-herdsman-converters/lib/exposes";
@@ -29,6 +32,33 @@ import type {LogLevel, schemaJson} from "../util/settings";
// biome-ignore lint/suspicious/noExplicitAny: API
type KeyValue = Record<string, any>;
export interface OnboardInitData {
page: "form";
settings: Zigbee2MQTTSettings;
settingsSchema: typeof schemaJson;
devices: {
name: string;
path: Zigbee2MQTTSettings["serial"]["port"];
adapter?: Zigbee2MQTTSettings["serial"]["adapter"];
baudRate?: Zigbee2MQTTSettings["serial"]["baudrate"];
rtscts?: Zigbee2MQTTSettings["serial"]["rtscts"];
}[];
}
export interface OnboardDoneData {
page: "done";
frontendUrl: string | null;
}
export interface OnboardFailureData {
page: "failure";
errors: string[];
}
export type OnboardData = OnboardInitData | OnboardDoneData | OnboardFailureData;
export type OnboardSubmitResponse = {success: true; frontendUrl: string | null} | {success: false; error: string};
export interface Zigbee2MQTTDeviceOptions {
disabled?: boolean;
retention?: number;
@@ -52,6 +82,7 @@ export interface Zigbee2MQTTDeviceOptions {
friendly_name: string;
description?: string;
qos?: 0 | 1 | 2;
disable_automatic_update_check?: boolean;
}
export interface Zigbee2MQTTGroupOptions {
@@ -137,6 +168,7 @@ export interface Zigbee2MQTTSettings {
update_check_interval: number;
disable_automatic_update_check: boolean;
zigbee_ota_override_index_location?: string;
image_block_request_timeout?: number;
image_block_response_delay?: number;
default_maximum_data_size?: number;
};
@@ -151,6 +183,7 @@ export interface Zigbee2MQTTSettings {
ssl_cert?: string;
ssl_key?: string;
notification_filter?: string[];
disable_ui_serving?: boolean;
};
devices: {[s: string]: Zigbee2MQTTDeviceOptions};
groups: {[s: string]: Omit<Zigbee2MQTTGroupOptions, "ID">};
@@ -196,6 +229,7 @@ export interface Zigbee2MQTTScene {
}
export interface Zigbee2MQTTDeviceEndpoint {
name?: string;
bindings: Zigbee2MQTTDeviceEndpointBinding[];
configured_reportings: Zigbee2MQTTDeviceEndpointConfiguredReporting[];
clusters: {input: string[]; output: string[]};
@@ -218,12 +252,14 @@ export interface Zigbee2MQTTDeviceEndpointConfiguredReporting {
}
export interface Zigbee2MQTTDeviceDefinition {
source: "native" | "generated" | "external";
model: string;
vendor: string;
description: string;
exposes: zigbeeHerdsmanConverter.Expose[];
supports_ota: boolean;
options: zigbeeHerdsmanConverter.Option[];
version: `0.0.${number}`;
icon: string;
}
@@ -275,18 +311,21 @@ export interface Zigbee2MQTTNetworkMap {
links: {
source: {ieeeAddr: string; networkAddress: number};
target: {ieeeAddr: string; networkAddress: number};
linkquality: number;
depth: number;
routes: {
destinationAddress: number;
status: string;
nextHop: number;
}[];
sourceIeeeAddr: string;
targetIeeeAddr: string;
sourceNwkAddr: number;
lqi: number;
deviceType: number;
rxOnWhenIdle: number;
relationship: number;
permitJoining: number;
depth: number;
lqi: number;
routes: RoutingTableEntry[];
/** @deprecated 3.0 */
linkquality: number;
/** @deprecated 3.0 */
sourceIeeeAddr: string;
/** @deprecated 3.0 */
targetIeeeAddr: string;
/** @deprecated 3.0 */
sourceNwkAddr: number;
}[];
}
@@ -305,8 +344,9 @@ export interface Zigbee2MQTTAPI {
};
"bridge/definitions": {
clusters: Readonly<Record<ClusterName, Readonly<ClusterDefinition>>>;
clusters: Readonly<Record<ClusterName, Cluster>>;
custom_clusters: Record<string, CustomClusters>;
actions: string[];
};
"bridge/event":
@@ -555,6 +595,16 @@ export interface Zigbee2MQTTAPI {
failed: string[];
};
"bridge/request/device/binds/clear": {
target: string;
ieee_list?: Eui64[];
};
"bridge/response/device/binds/clear": {
target: string;
ieee_list?: Eui64[];
};
"bridge/request/device/configure":
| {
id: string | number;
@@ -579,51 +629,93 @@ export interface Zigbee2MQTTAPI {
"bridge/request/device/ota_update/check": {
id: string;
/** expected to point to an index json if provided */
url?: string | null;
};
"bridge/request/device/ota_update/check/downgrade": {
id: string;
/** expected to point to an index json if provided */
url?: string | null;
};
"bridge/response/device/ota_update/check": {
id: string;
update_available: boolean;
downgrade?: boolean;
source?: string;
release_notes?: string;
};
"bridge/request/device/ota_update/update": {
id: string;
url?: string | null;
/**
* Full firmware file in hex form (expected compatible with `Buffer.from(hex, "hex")`)
* If file name supplied, will be used instead of <ieee_utc> to store firmware in data dir.
*/
hex?: {data: string; file_name?: string} | null;
image_block_request_timeout?: number | null;
image_block_response_delay?: number | null;
/** may be overridden internally to match specific device needs */
default_maximum_data_size?: number | null;
};
"bridge/request/device/ota_update/update/downgrade": {
id: string;
url?: string | null;
/**
* Full firmware file in hex form (expected compatible with `Buffer.from(hex, "hex")`)
* If file name supplied, will be used instead of <ieee_utc> to store firmware in data dir.
*/
hex?: {data: string; file_name?: string} | null;
image_block_request_timeout?: number | null;
image_block_response_delay?: number | null;
/** may be overridden internally to match specific device needs */
default_maximum_data_size?: number | null;
};
"bridge/response/device/ota_update/update": {
id: string;
from:
| {
software_build_id: string;
date_code: string;
file_version: number;
software_build_id?: string;
date_code?: string;
}
| undefined;
to:
| {
software_build_id: string;
date_code: string;
file_version: number;
software_build_id?: string;
date_code?: string;
}
| undefined;
};
"bridge/request/device/ota_update/schedule": {
id: string;
url?: string | null;
/**
* Full firmware file in hex form (expected compatible with `Buffer.from(hex, "hex")`)
* If file name supplied, will be used instead of <ieee_utc> to store firmware in data dir.
*/
hex?: {data: string; file_name?: string} | null;
};
"bridge/request/device/ota_update/schedule/downgrade": {
id: string;
url?: string | null;
/**
* Full firmware file in hex form (expected compatible with `Buffer.from(hex, "hex")`)
* If file name supplied, will be used instead of <ieee_utc> to store firmware in data dir.
*/
hex?: {data: string; file_name?: string} | null;
};
"bridge/response/device/ota_update/schedule": {
id: string;
url?: string;
};
"bridge/request/device/ota_update/unschedule": {
@@ -683,25 +775,41 @@ export interface Zigbee2MQTTAPI {
homeassistant_rename: boolean;
};
"bridge/request/device/configure_reporting": {
"bridge/request/device/reporting/configure": {
id: string;
endpoint: string | number;
cluster: string | number;
attribute: string | number | {ID: number; type: number};
minimum_report_interval: number;
maximum_report_interval: number;
reportable_change: number;
reportable_change?: number;
option: Record<string, unknown>;
};
"bridge/response/device/configure_reporting": {
"bridge/response/device/reporting/configure": {
id: string;
endpoint: string | number;
cluster: string | number;
attribute: string | number | {ID: number; type: number};
minimum_report_interval: number;
maximum_report_interval: number;
reportable_change: number;
reportable_change?: number;
};
"bridge/request/device/reporting/read": {
id: string;
endpoint: string | number;
cluster: string | number;
configs: {direction?: number; attribute: string | number | {ID: number; type: number}}[];
manufacturer_code?: number;
};
"bridge/response/device/reporting/read": {
id: string;
endpoint: string | number;
cluster: string | number;
configs: zigbeeHerdsman.Zcl.ClustersTypes.TFoundation["readReportConfigRsp"];
manufacturer_code?: number;
};
"bridge/request/group/remove": {
@@ -818,6 +926,10 @@ export interface Zigbee2MQTTAPI {
channel: number;
};
"bridge/request/action": {action: string; params?: Record<string, unknown>};
"bridge/response/action": GenericZdoResponse | ZclPayload | undefined;
/**
* entity state response
*/
@@ -880,6 +992,7 @@ export type Zigbee2MQTTRequestEndpoints =
| "bridge/request/options"
| "bridge/request/device/bind"
| "bridge/request/device/unbind"
| "bridge/request/device/binds/clear"
| "bridge/request/device/configure"
| "bridge/request/device/remove"
| "bridge/request/device/ota_update/check"
@@ -893,7 +1006,8 @@ export type Zigbee2MQTTRequestEndpoints =
| "bridge/request/device/generate_external_definition"
| "bridge/request/device/options"
| "bridge/request/device/rename"
| "bridge/request/device/configure_reporting"
| "bridge/request/device/reporting/configure"
| "bridge/request/device/reporting/read"
| "bridge/request/group/remove"
| "bridge/request/group/add"
| "bridge/request/group/rename"
@@ -904,6 +1018,7 @@ export type Zigbee2MQTTRequestEndpoints =
| "bridge/request/touchlink/factory_reset"
| "bridge/request/touchlink/scan"
| "bridge/request/touchlink/identify"
| "bridge/request/action"
| "{friendlyNameOrId}/set"
| "{friendlyNameOrId}/set/{attribute}"
| "{friendlyNameOrId}/{endpoint}/set"
@@ -928,6 +1043,7 @@ export type Zigbee2MQTTResponseEndpoints =
| "bridge/response/options"
| "bridge/response/device/bind"
| "bridge/response/device/unbind"
| "bridge/response/device/binds/clear"
| "bridge/response/device/configure"
| "bridge/response/device/remove"
| "bridge/response/device/ota_update/check"
@@ -938,7 +1054,8 @@ export type Zigbee2MQTTResponseEndpoints =
| "bridge/response/device/generate_external_definition"
| "bridge/response/device/options"
| "bridge/response/device/rename"
| "bridge/response/device/configure_reporting"
| "bridge/response/device/reporting/configure"
| "bridge/response/device/reporting/read"
| "bridge/response/group/remove"
| "bridge/response/group/add"
| "bridge/response/group/rename"
@@ -948,7 +1065,8 @@ export type Zigbee2MQTTResponseEndpoints =
| "bridge/response/group/members/remove_all"
| "bridge/response/touchlink/factory_reset"
| "bridge/response/touchlink/scan"
| "bridge/response/touchlink/identify";
| "bridge/response/touchlink/identify"
| "bridge/response/action";
export type Zigbee2MQTTRequest<T extends Zigbee2MQTTRequestEndpoints> = {
transaction?: string;
+18
View File
@@ -0,0 +1,18 @@
// minimal required because of sub-deps in mqtt >= 5.14.0 to avoid requiring `dom` type
declare global {
// map to node, doesn't really matter, just needs to be there, and the right "type vs value" to avoid lib check problems
/** @deprecated DOM SHIM, DO NOT USE */
type MessagePort = import("node:worker_threads").MessagePort;
/** @deprecated DOM SHIM, DO NOT USE */
type Worker = import("node:worker_threads").Worker;
/** @deprecated DOM SHIM, DO NOT USE */
type Transferable = import("node:worker_threads").Transferable;
/** @deprecated DOM SHIM, DO NOT USE */
const addEventListener: import("node:events").EventEmitter["addListener"];
/** @deprecated DOM SHIM, DO NOT USE */
const removeEventListener: import("node:events").EventEmitter["removeListener"];
/** @deprecated DOM SHIM, DO NOT USE */
const postMessage: import("node:worker_threads").MessagePort["postMessage"];
}
export {};
+2 -5
View File
@@ -36,9 +36,6 @@ declare global {
type Endpoint = ZHModels.Endpoint;
type Device = ZHModels.Device;
type Group = ZHModels.Group;
// biome-ignore lint/style/useNamingConvention: API
type LQI = ZHAdapterTypes.Lqi;
type RoutingTable = ZHAdapterTypes.RoutingTable;
type CoordinatorVersion = ZHAdapterTypes.CoordinatorVersion;
type NetworkParameters = ZHAdapterTypes.NetworkParameters;
interface Bind {
@@ -49,7 +46,7 @@ declare global {
namespace eventdata {
type EntityRenamed = {entity: Device | Group; homeAssisantRename: boolean; from: string; to: string};
type EntityRemoved = {id: string; name: string; type: "device"} | {id: number; name: string; type: "group"};
type EntityRemoved = {entity: Device | Group; name: string};
type MQTTMessage = {topic: string; message: string};
type MQTTMessagePublished = {topic: string; payload: string; options: MqttPublishOptions};
type StateChange = {
@@ -82,7 +79,7 @@ declare global {
groupID: number; // XXX: should this be `?`
cluster: string | number;
data: KeyValue | Array<string | number>;
meta: {zclTransactionSequenceNumber?: number; manufacturerCode?: number; frameControl?: ZHFrameControl};
meta: {zclTransactionSequenceNumber?: number; manufacturerCode?: number; frameControl?: ZHFrameControl; rawData: Buffer};
};
type ScenesChanged = {entity: Device | Group};
}
-7
View File
@@ -12,10 +12,3 @@ declare module "http" {
path?: string;
}
}
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;
}
+28 -28
View File
@@ -2,36 +2,32 @@ 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 * as settings from "./settings";
const NAMESPACE_SEPARATOR = ":";
class Logger {
// @ts-expect-error initalized in `init`
private level: settings.LogLevel;
// @ts-expect-error initalized in `init`
private output: string[];
// @ts-expect-error initalized in `init`
private directory: string;
// @ts-expect-error initalized in `init`
private logger: winston.Logger;
// @ts-expect-error initalized in `init`
private fileTransport: winston.transports.FileTransportInstance;
private level!: settings.LogLevel;
private output!: string[];
private directory!: string;
private logger!: winston.Logger;
private fileTransport: winston.transports.FileTransportInstance | undefined;
private debugNamespaceIgnoreRegex?: RegExp;
// @ts-expect-error initalized in `init`
private namespacedLevels: Record<string, settings.LogLevel>;
// @ts-expect-error initalized in `init`
private cachedNamespacedLevels: Record<string, settings.LogLevel>;
private namespacedLevels!: Record<string, settings.LogLevel>;
private cachedNamespacedLevels!: Record<string, settings.LogLevel>;
public init(): void {
// 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");
const date = new Date();
// offset UTC by current timezone, ISO keeps "Z" (UTC) which is then wrong but we strip it
const timestamp = new Date(date.getTime() - date.getTimezoneOffset() * 60000)
.toISOString()
.slice(0, 19)
.replace("T", ".")
.replaceAll(":", "-");
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;
@@ -40,11 +36,12 @@ class Logger {
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",
format: winston.format.combine(winston.format.errors({stack: true}), winston.format.timestamp({format: timestampFormat})),
format: winston.format.combine(
winston.format.errors({stack: true}),
winston.format.timestamp({format: settings.get().advanced.timestamp_format}),
),
levels: winston.config.syslog.levels,
});
@@ -145,11 +142,7 @@ 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 {
@@ -241,7 +234,11 @@ class Logger {
for (const dir of directories) {
this.debug(`Removing old log directory '${dir.path}'`);
rimrafSync(dir.path);
try {
rimrafSync(dir.path);
} catch (e) {
this.error(`Failed to remove old log directory '${dir.path}': ${e}`);
}
}
}
}
@@ -263,7 +260,10 @@ class Logger {
// @ts-expect-error workaround
this.fileTransport.on("open", () => this.fileTransport._dest.on("finish", resolve));
}
this.fileTransport.end();
if (this.fileTransport) {
this.fileTransport.end();
}
});
}
}
+233 -473
View File
@@ -1,393 +1,98 @@
import {existsSync, mkdirSync} from "node:fs";
import {existsSync, mkdirSync, writeFileSync} from "node:fs";
import type {ServerResponse} from "node:http";
import {createServer} from "node:http";
import {parse} from "node:querystring";
import path from "node:path";
import expressStaticGzip from "express-static-gzip";
import finalhandler from "finalhandler";
import stringify from "json-stable-stringify-without-jsonify";
import JSZip from "jszip";
import {findAllDevices} from "zigbee-herdsman/dist/adapter/adapterDiscovery";
import type {OnboardData, OnboardFailureData, OnboardSubmitResponse, Zigbee2MQTTSettings} from "../types/api";
import data from "./data";
import * as settings from "./settings";
import {YAMLFileException} from "./yaml";
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"];
/** same as extension/frontend */
const FILE_SERVER_OPTIONS: expressStaticGzip.ExpressStaticGzipOptions = {
enableBrotli: true,
serveStatic: {
/* v8 ignore start */
setHeaders: (res: ServerResponse, path: string): void => {
if (path.endsWith("index.html")) {
res.setHeader("Cache-Control", "no-store");
}
},
/* v8 ignore stop */
},
};
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");
}
function getZipEntryTargetPath(entryName: string): string {
const normalizedEntry = entryName.replace(/\\/g, "/");
if (!normalizedEntry || normalizedEntry.startsWith("/") || normalizedEntry.includes("\0")) {
throw new Error(`Invalid ZIP entry path '${entryName}'`);
}
const basePath = path.resolve(data.getPath());
const targetPath = path.resolve(basePath, normalizedEntry);
const relativePath = path.relative(basePath, targetPath);
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
throw new Error(`Unsafe ZIP entry path '${entryName}'`);
}
return targetPath;
}
async function extractZipDataToDataPath(zipContent: Buffer): Promise<void> {
const zip = await JSZip.loadAsync(zipContent);
for (const key in zip.files) {
const entry = zip.files[key];
const targetPath = getZipEntryTargetPath(entry.name);
if (entry.dir) {
mkdirSync(targetPath, {recursive: true});
continue;
}
mkdirSync(path.dirname(targetPath), {recursive: true});
writeFileSync(targetPath, await entry.async("nodebuffer"));
}
}
async function startOnboardingServer(): Promise<boolean> {
const currentSettings = settings.get();
const serverUrl = getServerUrl();
let server: ReturnType<typeof createServer> | undefined;
let failed = false;
const fileServer = expressStaticGzip((await import("zigbee2mqtt-windfront")).default.getOnboardingPath(), FILE_SERVER_OPTIONS);
const success = await new Promise<boolean>((resolve) => {
server = createServer(async (req, res) => {
const pathname = new URL(req.url /* v8 ignore next */ ?? "/", serverUrl).pathname;
if (req.method === "GET" && pathname === "/data") {
const payload: OnboardData = {
page: "form",
settings: currentSettings,
settingsSchema: settings.schemaJson,
devices: await findAllDevices(),
};
res.setHeader("Content-Type", "application/json");
res.writeHead(200);
res.end(stringify(payload));
return;
}
if (req.method === "POST") {
if (failed) {
res.end(() => {
resolve(false);
});
} else {
if (pathname === "/submit") {
let body = "";
req.on("data", (chunk) => {
@@ -395,95 +100,111 @@ async function startOnboardingServer(): Promise<boolean> {
});
req.on("end", () => {
const result = parse(body) as unknown as OnboardSettings;
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);
const result = (body ? JSON.parse(body) : {}) as RecursivePartial<Zigbee2MQTTSettings>;
// to redirect, make sure frontend "will be" enabled, and host isn't socket
settings.apply(result);
const appliedSettings = settings.get();
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";
appliedSettings.frontend.enabled &&
!appliedSettings.frontend.host?.startsWith("/");
const protocol = appliedSettings.frontend.ssl_cert && appliedSettings.frontend.ssl_key ? "https" : "http";
const frontendUrl = redirect
? `${protocol}://${appliedSettings.frontend.host ?? "localhost"}:${appliedSettings.frontend.port}${appliedSettings.frontend.base_url}`
: null;
const payload: OnboardSubmitResponse = {success: true, frontendUrl};
res.setHeader("Content-Type", "text/html");
res.setHeader("Content-Type", "application/json");
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);
},
);
res.end(stringify(payload), () => {
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>`));
}
const payload: OnboardSubmitResponse = {success: false, error: (error as Error).message};
res.setHeader("Content-Type", "application/json");
res.writeHead(406);
res.end(stringify(payload));
}
});
req.on("error", (error: Error) => {
console.error(`Failed to parse request body: ${error.message}`);
const payload: OnboardSubmitResponse = {success: false, error: error.message};
res.setHeader("Content-Type", "application/json");
res.writeHead(406);
res.end(stringify(payload));
});
return;
}
if (pathname === "/submit-zip") {
let body = "";
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", async () => {
try {
if (!body) {
throw new Error("Invalid ZIP payload: missing content");
}
const zipContent = Buffer.from(body, "base64");
await extractZipDataToDataPath(zipContent);
const payload: OnboardSubmitResponse = {success: true, frontendUrl: null};
res.setHeader("Content-Type", "application/json");
res.writeHead(200);
res.end(stringify(payload), () => {
resolve(true);
});
} catch (error) {
console.error(`Failed to apply ZIP data: ${(error as Error).message}`);
const payload: OnboardSubmitResponse = {success: false, error: (error as Error).message};
res.setHeader("Content-Type", "application/json");
res.writeHead(406);
res.end(stringify(payload));
}
});
req.on("error", (error: Error) => {
console.error(`Failed to parse ZIP request body: ${error.message}`);
const payload: OnboardSubmitResponse = {success: false, error: error.message};
res.setHeader("Content-Type", "application/json");
res.writeHead(406);
res.end(stringify(payload));
});
return;
}
} else {
res.setHeader("Content-Type", "text/html");
res.writeHead(200);
res.end(generateHtmlForm(currentSettings, await findAllDevices()));
}
const next = finalhandler(req, res);
fileServer(req, res, next);
});
server.listen(Number.parseInt(serverUrl.port), serverUrl.hostname, () => {
server.on("error", (error: Error) => {
console.error("Failed to start onboarding server", error);
resolve(false);
});
server.listen(Number.parseInt(serverUrl.port, 10), serverUrl.hostname, () => {
console.log(`Onboarding page is available at ${serverUrl.href}`);
});
});
@@ -493,24 +214,40 @@ async function startOnboardingServer(): Promise<boolean> {
return success;
}
async function startFailureServer(errors: string): Promise<void> {
async function startFailureServer(errors: string[]): Promise<void> {
const serverUrl = getServerUrl();
let server: ReturnType<typeof createServer> | undefined;
const fileServer = expressStaticGzip((await import("zigbee2mqtt-windfront")).default.getOnboardingPath(), FILE_SERVER_OPTIONS);
await new Promise<void>((resolve) => {
server = createServer((req, res) => {
if (req.method === "POST") {
const pathname = new URL(req.url /* v8 ignore next */ ?? "/", serverUrl).pathname;
if (req.method === "GET" && pathname === "/data") {
const payload: OnboardFailureData = {page: "failure", errors};
res.setHeader("Content-Type", "application/json");
res.writeHead(200);
res.end(stringify(payload));
return;
}
if (req.method === "POST" && pathname === "/submit") {
res.writeHead(200);
res.end(() => {
resolve();
});
} else {
res.setHeader("Content-Type", "text/html");
res.writeHead(406);
res.end(generateHtmlError(errors));
return;
}
const next = finalhandler(req, res);
fileServer(req, res, next);
});
server.listen(Number.parseInt(serverUrl.port), serverUrl.hostname, () => {
server.listen(Number.parseInt(serverUrl.port, 10), serverUrl.hostname, () => {
console.error(`Failure page is available at ${serverUrl.href}`);
});
});
@@ -518,6 +255,23 @@ async function startFailureServer(errors: string): Promise<void> {
await new Promise((resolve) => server?.close(resolve));
}
async function onSettingsErrors(errors: string[]): Promise<void> {
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}`);
}
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(errors);
}
}
export async function onboard(): Promise<boolean> {
if (!existsSync(data.getPath())) {
mkdirSync(data.getPath(), {recursive: true});
@@ -525,15 +279,38 @@ export async function onboard(): Promise<boolean> {
const confExists = existsSync(data.joinPath("configuration.yaml"));
if (!confExists) {
settings.writeMinimalDefaults();
} else {
if (confExists) {
// initial caching, ensure file is valid yaml first
try {
settings.getPersistedSettings();
} catch (error) {
await onSettingsErrors(
error instanceof YAMLFileException
? [`Your configuration file: '${error.file}' is invalid (use https://jsonformatter.org/yaml-validator to find and fix the issue)`]
: [`${error}`],
);
return false;
}
// migrate first
const {migrateIfNecessary} = await import("./settingsMigration.js");
migrateIfNecessary();
// make sure existing settings are valid before applying envs
const errors = settings.validateNonRequired();
if (errors.length > 0) {
await onSettingsErrors(errors);
return false;
}
// trigger initial writing of `ZIGBEE2MQTT_CONFIG_*` ENVs
settings.write();
} else {
settings.writeMinimalDefaults();
}
// use `configuration.yaml` file to detect "brand new install"
@@ -553,24 +330,7 @@ export async function onboard(): Promise<boolean> {
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);
}
await onSettingsErrors(errors);
return false;
}
+94 -32
View File
@@ -67,6 +67,7 @@
"title": "Timeout",
"requiresRestart": true,
"default": 10,
"minimum": 1,
"description": "Time after which an active device will be marked as offline in minutes"
},
"max_jitter": {
@@ -103,6 +104,7 @@
"title": "Timeout",
"requiresRestart": true,
"default": 1500,
"minimum": 1,
"description": "Time after which an passive device will be marked as offline in minutes"
}
},
@@ -248,8 +250,8 @@
"type": "number",
"title": "Baudrate",
"requiresRestart": true,
"description": "Baud rate speed for serial port, this can be anything firmware support but default is 115200 for Z-Stack and EZSP, 38400 for Deconz, however note that some EZSP firmware need 57600",
"examples": [38400, 57600, 115200]
"description": "Baud rate speed for the serial port. This must match what the firmware on your adapter supports (most commonly 115200).",
"examples": [115200, 921600, 460800, 230400, 57600, 38400]
},
"rtscts": {
"type": "boolean",
@@ -291,13 +293,16 @@
"type": "object",
"properties": {
"enddevice": {
"type": "string"
"type": "string",
"default": "#fff8ce"
},
"coordinator": {
"type": "string"
"type": "string",
"default": "#e04e5d"
},
"router": {
"type": "string"
"type": "string",
"default": "#4ea3e0"
}
}
},
@@ -305,13 +310,16 @@
"type": "object",
"properties": {
"enddevice": {
"type": "string"
"type": "string",
"default": "#000000"
},
"coordinator": {
"type": "string"
"type": "string",
"default": "#ffffff"
},
"router": {
"type": "string"
"type": "string",
"default": "#ffffff"
}
}
},
@@ -319,10 +327,12 @@
"type": "object",
"properties": {
"active": {
"type": "string"
"type": "string",
"default": "#009900"
},
"inactive": {
"type": "string"
"type": "string",
"default": "#994444"
}
}
}
@@ -340,7 +350,8 @@
"type": "number",
"title": "Update check interval",
"description": "Your device may request a check for a new firmware update. This value determines how frequently third party servers may actually be contacted to look for firmware updates. The value is set in minutes, and the default is 1 day.",
"default": 1440
"default": 1440,
"minimum": 1
},
"disable_automatic_update_check": {
"type": "boolean",
@@ -355,13 +366,20 @@
"description": "Location of override OTA index file",
"examples": ["index.json"]
},
"image_block_request_timeout": {
"type": "number",
"title": "Image block request timeout",
"description": "Timeout (in milliseconds) during OTA updates. You can increase this value if your device is requesting blocks too slowly.",
"default": 150000,
"minimum": 10000,
"maximum": 2147483647
},
"image_block_response_delay": {
"type": "number",
"title": "Image block response delay",
"description": "Limits the rate of requests (in milliseconds) during OTA updates to reduce network congestion. You can increase this value if your network appears unstable during OTA.",
"default": 250,
"minimum": 50,
"requiresRestart": true
"minimum": 50
},
"default_maximum_data_size": {
"type": "number",
@@ -369,8 +387,7 @@
"description": "The size of file chunks sent during an update (in bytes). Note: This value may get ignored for manufacturers that require specific values.",
"default": 50,
"minimum": 10,
"maximum": 100,
"requiresRestart": true
"maximum": 100
}
}
},
@@ -387,9 +404,9 @@
},
"package": {
"type": "string",
"enum": ["zigbee2mqtt-frontend", "zigbee2mqtt-windfront"],
"enum": ["zigbee2mqtt-windfront", "zigbee2mqtt-frontend"],
"title": "Package",
"default": "zigbee2mqtt-frontend",
"default": "zigbee2mqtt-windfront",
"requiresRestart": true,
"description": "Package used for the frontend"
},
@@ -446,6 +463,12 @@
"items": {
"type": "string"
}
},
"disable_ui_serving": {
"type": ["boolean", "null"],
"title": "Disable UI serving",
"description": "If true, the frontend UI is not served, only the WebSocket is maintained by Zigbee2MQTT (you are required to serve a standalone UI yourself as needed).",
"requiresRestart": true
}
},
"required": []
@@ -509,7 +532,8 @@
"enum": ["console", "file", "syslog"]
},
"title": "Log output",
"description": "Output location of the log, leave empty to suppress logging"
"description": "Output location of the log, leave empty to suppress logging",
"default": ["console", "file"]
},
"log_directory": {
"type": "string",
@@ -524,7 +548,7 @@
"requiresRestart": true,
"description": "Log file name, can also contain timestamp",
"examples": ["zigbee2mqtt_%TIMESTAMP%.log"],
"default": "log.txt"
"default": "log.log"
},
"log_level": {
"type": "string",
@@ -545,7 +569,14 @@
"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",
@@ -648,12 +679,15 @@
},
{
"type": "number",
"title": "Pan ID (number)"
"title": "Pan ID (number)",
"minimum": 1,
"maximum": 65534
}
],
"title": "Pan ID",
"requiresRestart": true,
"description": "ZigBee pan ID, changing requires re-pairing all devices!"
"description": "Zigbee pan ID, changing requires re-pairing all devices!",
"default": 6754
},
"ext_pan_id": {
"oneOf": [
@@ -671,14 +705,15 @@
],
"title": "Ext Pan ID",
"requiresRestart": true,
"description": "Zigbee extended pan ID, changing requires re-pairing all devices!"
"description": "Zigbee extended pan ID, changing requires re-pairing all devices!",
"default": [221, 221, 221, 221, 221, 221, 221, 221]
},
"channel": {
"type": "number",
"minimum": 11,
"maximum": 26,
"default": 11,
"title": "ZigBee channel",
"title": "Zigbee channel",
"requiresRestart": true,
"description": "Zigbee channel, changing might require re-pairing some devices! (Note: use a ZLL channel: 11, 15, 20, or 25 to avoid problems)",
"examples": [15, 20, 25]
@@ -689,6 +724,7 @@
"type": ["number", "null"],
"minimum": 1,
"maximum": 64,
"default": null,
"description": "Adapter concurrency (e.g. 2 for CC2531 or 16 for CC26X2R1) (default: null, uses recommended value)"
},
"adapter_delay": {
@@ -697,6 +733,7 @@
"title": "Adapter delay",
"minimum": 0,
"maximum": 1000,
"default": null,
"description": "Adapter delay"
},
"cache_state": {
@@ -746,14 +783,16 @@
],
"title": "Network key",
"requiresRestart": true,
"description": "Network encryption key, changing requires re-pairing all devices!"
"description": "Network encryption key, changing requires re-pairing all devices!",
"default": [1, 3, 5, 7, 9, 11, 13, 15, 0, 2, 4, 6, 8, 10, 12, 13]
},
"timestamp_format": {
"type": "string",
"title": "Timestamp format",
"requiresRestart": true,
"description": "Log timestamp format",
"examples": ["YYYY-MM-DD HH:mm:ss"]
"description": "Log timestamp format (see https://github.com/taylorhakes/fecha?tab=readme-ov-file#formatting-tokens for all supported tokens)",
"default": "YYYY-MM-DD HH:mm:ss",
"examples": ["YYYY-MM-DD HH:mm:ss.SSS"]
},
"transmit_power": {
"type": ["number", "null"],
@@ -767,7 +806,8 @@
"type": "string",
"enum": ["attribute_and_json", "attribute", "json"],
"title": "MQTT output type",
"description": "Examples when 'state' of a device is published json: topic: 'zigbee2mqtt/my_bulb' payload '{\"state\": \"ON\"}' attribute: topic 'zigbee2mqtt/my_bulb/state' payload 'ON' attribute_and_json: both json and attribute (see above)"
"description": "Examples when 'state' of a device is published json: topic: 'zigbee2mqtt/my_bulb' payload '{\"state\": \"ON\"}' attribute: topic 'zigbee2mqtt/my_bulb/state' payload 'ON' attribute_and_json: both json and attribute (see above)",
"default": "json"
}
}
},
@@ -781,6 +821,7 @@
"title": "Interval",
"description": "Interval between checks in minutes",
"default": 10,
"minimum": 1,
"requiresRestart": true
},
"reset_on_check": {
@@ -821,7 +862,8 @@
"description": "Sets the MQTT Message Expiry in seconds, Make sure to set mqtt.version to 5"
},
"qos": {
"type": "number",
"type": ["number"],
"enum": [0, 1, 2],
"title": "QoS",
"description": "QoS level for MQTT messages of this device"
},
@@ -889,11 +931,17 @@
"title": "Home Assistant",
"properties": {
"name": {
"type": "string",
"type": ["string", "null"],
"title": "Home Assistant name",
"description": "Name of the device in Home Assistant"
}
}
},
"disable_automatic_update_check": {
"type": "boolean",
"title": "Disable automatic update check",
"description": "Zigbee devices may request a firmware update, and do so frequently, causing Zigbee2MQTT to reach out to third party servers. If you disable these device initiated checks, you can still initiate a firmware update check manually.",
"default": false
}
},
"required": ["friendly_name"]
@@ -911,13 +959,16 @@
"type": "boolean"
},
"qos": {
"type": "number"
"type": ["number"],
"enum": [0, 1, 2],
"title": "QoS",
"description": "QoS level for MQTT messages of this group"
},
"off_state": {
"type": ["string"],
"enum": ["all_members_off", "last_member_state"],
"title": "Group off state",
"default": "auto",
"default": "all_members_off",
"requiresRestart": true,
"description": "Control when to publish state OFF or CLOSE for a group. 'all_members_off': only publish state OFF/CLOSE when all group members are in state OFF/CLOSE, 'last_member_state': publish state OFF whenever one of its members changes to OFF"
},
@@ -926,6 +977,17 @@
"items": {
"type": "string"
}
},
"homeassistant": {
"type": ["object", "null"],
"title": "Home Assistant",
"properties": {
"name": {
"type": ["string", "null"],
"title": "Home Assistant name",
"description": "Name of the group in Home Assistant"
}
}
}
},
"required": ["friendly_name"]
+26 -22
View File
@@ -1,18 +1,16 @@
import path from "node:path";
import type {ValidateFunction} from "ajv";
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";
import yaml from "./yaml";
export {schemaJson};
// When updating also update:
// - https://github.com/Koenkk/zigbee2mqtt/blob/dev/data/configuration.example.yaml#L2
export const CURRENT_VERSION = 4;
export const CURRENT_VERSION = 5;
/** 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";
@@ -42,7 +40,7 @@ export const defaults = {
},
frontend: {
enabled: false,
package: "zigbee2mqtt-frontend",
package: "zigbee2mqtt-windfront",
port: 8080,
base_url: "/",
},
@@ -52,6 +50,9 @@ export const defaults = {
force_disable_retain: false,
// 1MB = roughly 3.5KB per device * 300 devices for `/bridge/devices`
maximum_packet_size: 1048576,
keepalive: 60,
reject_unauthorized: true,
version: 4,
},
serial: {
disable_led: false,
@@ -81,6 +82,7 @@ export const defaults = {
ota: {
update_check_interval: 24 * 60,
disable_automatic_update_check: false,
image_block_request_timeout: 150000,
image_block_response_delay: 250,
default_maximum_data_size: 50,
},
@@ -257,15 +259,7 @@ export function write(): void {
}
export function validate(): string[] {
try {
getPersistedSettings();
} catch (error) {
if (error instanceof YAMLFileException) {
return [`Your YAML file: '${error.file}' is invalid (use https://jsonformatter.org/yaml-validator to find and fix the issue)`];
}
return [`${error}`];
}
getPersistedSettings();
if (!ajvSetting(_settings)) {
// biome-ignore lint/style/noNonNullAssertion: When `ajvSetting()` return false it always has `errors`
@@ -295,9 +289,6 @@ export function validate(): string[] {
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)) {
errors.push(`QOS for '${e.friendly_name}' not valid, should be 0, 1 or 2 got ${e.qos}`);
}
};
const settingsWithDefaults = get();
@@ -321,6 +312,19 @@ export function validate(): string[] {
return errors;
}
export function validateNonRequired(): string[] {
getPersistedSettings();
if (!ajvSetting(_settings)) {
// biome-ignore lint/style/noNonNullAssertion: When `ajvSetting()` return false it always has `errors`
const errors = ajvSetting.errors!.filter((e) => e.keyword !== "required");
return errors.map((v) => `${v.instancePath.substring(1)} ${v.message}`);
}
return [];
}
function read(): Partial<Settings> {
const s = yaml.read(CONFIG_FILE_PATH) as Partial<Settings>;
@@ -478,12 +482,12 @@ export function apply(settings: Record<string, unknown>, throwOnError = true): b
const newSettings = objectAssignDeep.noMutate(_settings, settings);
utils.removeNullPropertiesFromObject(newSettings, NULLABLE_SETTINGS);
ajvSetting(newSettings);
if (throwOnError) {
const errors = ajvSetting.errors?.filter((e) => e.keyword !== "required");
if (!ajvSetting(newSettings) && throwOnError) {
// biome-ignore lint/style/noNonNullAssertion: When `ajvSetting()` return false it always has `errors`
const errors = ajvSetting.errors!.filter((e) => e.keyword !== "required");
if (errors?.length) {
if (errors.length) {
const error = errors[0];
throw new Error(`${error.instancePath.substring(1)} ${error.message}`);
}
@@ -604,7 +608,7 @@ export function addGroup(name: string, id?: string): GroupOptions {
id = "1";
while (settings.groups[id]) {
id = (Number.parseInt(id) + 1).toString();
id = (Number.parseInt(id, 10) + 1).toString();
}
} else {
// ensure provided ID is not in use
+58 -3
View File
@@ -1,5 +1,4 @@
import {copyFileSync, writeFileSync} from "node:fs";
import {copyFileSync, existsSync, readFileSync, writeFileSync} from "node:fs";
import data from "./data";
import * as settings from "./settings";
import utils from "./utils";
@@ -29,7 +28,7 @@ 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, 4, settings.CURRENT_VERSION];
function backupSettings(version: number): void {
const filePath = data.joinPath("configuration.yaml");
@@ -481,6 +480,58 @@ function migrateToFour(
});
}
function migrateToFive(
_currentSettings: Partial<Settings>,
transfers: SettingsTransfer[],
changes: SettingsChange[],
additions: SettingsAdd[],
removals: SettingsRemove[],
customHandlers: SettingsCustomHandler[],
): void {
transfers.push();
changes.push({
path: ["version"],
note: "Migrated settings to version 5",
newValue: 5,
});
additions.push();
removals.push();
const resetOtaStates = (_currentSettings: Partial<Settings>): ReturnType<SettingsCustomHandler["execute"]> => {
const filePath = data.joinPath("state.json");
if (existsSync(filePath)) {
try {
const stateFile = readFileSync(filePath, "utf8");
const stateObj = JSON.parse(stateFile) as KeyValue;
for (const key in stateObj) {
const entry = stateObj[key];
if (entry.update) {
delete entry.update;
}
}
writeFileSync(filePath, JSON.stringify(stateObj), "utf8");
// hack, this does not actually affect settings, we just want to ensure the migration note triggers
return [true, undefined, true];
} catch (error) {
console.error(`Failed to write state to '${filePath}' (${error})`);
}
}
return [false, undefined, false];
};
customHandlers.push({
note: "Reset all cached OTA states due to internal changes.",
noteIf: () => true,
execute: (currentSettings) => resetOtaStates(currentSettings),
});
}
/**
* Order of execution:
* - Transfer
@@ -533,6 +584,10 @@ export function migrateIfNecessary(): void {
migrationNotesFileName = "migration-3-to-4.log";
migrateToFour(currentSettings, transfers, changes, additions, removals, customHandlers);
} else if (currentSettings.version === 4) {
migrationNotesFileName = "migration-4-to-5.log";
migrateToFive(currentSettings, transfers, changes, additions, removals, customHandlers);
}
for (const transfer of transfers) {
+14 -5
View File
@@ -12,6 +12,8 @@ import data from "./data";
const BASE64_IMAGE_REGEX = /data:image\/(?<extension>.+);base64,(?<data>.+)/;
export const DEFAULT_BIND_GROUP_ID = 901;
function pad(num: number): string {
const norm = Math.floor(Math.abs(num));
return (norm < 10 ? "0" : "") + norm;
@@ -200,14 +202,21 @@ function containsControlCharacter(str: string): boolean {
function getAllFiles(path_: string): string[] {
const result = [];
for (let item of fs.readdirSync(path_)) {
item = path.join(path_, item);
if (fs.lstatSync(item).isFile()) {
result.push(item);
for (const item of fs.readdirSync(path_, {withFileTypes: true})) {
if (item.isSymbolicLink()) {
continue;
}
const fileName = path.join(path_, item.name);
if (fs.lstatSync(fileName).isFile()) {
result.push(fileName);
} else {
result.push(...getAllFiles(item));
result.push(...getAllFiles(fileName));
}
}
return result;
}
+1 -1
View File
@@ -21,7 +21,7 @@ export class YAMLFileException extends YAMLException {
function read(file: string): KeyValue {
try {
const result = yaml.load(fs.readFileSync(file, "utf8"));
assert(result instanceof Object);
assert(result instanceof Object, `The content of ${file} is expected to be an object`);
return result as KeyValue;
} catch (error) {
if (error instanceof YAMLException) {
+107 -62
View File
@@ -4,7 +4,6 @@ import stringify from "json-stable-stringify-without-jsonify";
import type {Events as ZHEvents} from "zigbee-herdsman";
import {Controller} from "zigbee-herdsman";
import type {StartResult} from "zigbee-herdsman/dist/adapter/tstype";
import Device from "./model/device";
import Group from "./model/group";
import data from "./util/data";
@@ -15,8 +14,7 @@ import utils from "./util/utils";
const entityIDRegex = /^(.+?)(?:\/([^/]+))?$/;
export default class Zigbee {
// @ts-expect-error initialized in start
private herdsman: Controller;
#herdsman!: Controller;
private eventBus: EventBus;
private groupLookup = new Map<number /* group ID */, Group>();
private deviceLookup = new Map<string /* IEEE address */, Device>();
@@ -26,7 +24,11 @@ export default class Zigbee {
this.eventBus = eventBus;
}
async start(): Promise<StartResult> {
get zhController() {
return this.#herdsman;
}
async start(abortSignal: AbortSignal): Promise<boolean> {
const infoHerdsman = await utils.getDependencyVersion("zigbee-herdsman");
logger.info(`Starting zigbee-herdsman (${infoHerdsman.version})`);
const panId = settings.get().advanced.pan_id;
@@ -64,37 +66,41 @@ export default class Zigbee {
let startResult: StartResult;
try {
this.herdsman = new Controller(herdsmanSettings);
startResult = await this.herdsman.start();
this.#herdsman = new Controller(herdsmanSettings);
startResult = await this.#herdsman.start(abortSignal);
} catch (error) {
logger.error("Error while starting zigbee-herdsman");
throw error;
}
this.coordinatorIeeeAddr = this.herdsman.getDevicesByType("Coordinator")[0].ieeeAddr;
await this.resolveDevicesDefinitions();
this.coordinatorIeeeAddr = this.#herdsman.getDevicesByType("Coordinator")[0].ieeeAddr;
await this.resolveDevicesDefinitions(false, abortSignal);
this.herdsman.on("adapterDisconnected", () => this.eventBus.emitAdapterDisconnected());
this.herdsman.on("lastSeenChanged", (data: ZHEvents.LastSeenChangedPayload) => {
if (abortSignal.aborted) {
return false;
}
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();
@@ -102,19 +108,19 @@ export default class Zigbee {
this.logDeviceInterview(d);
this.eventBus.emitDeviceInterview(d);
});
this.herdsman.on("deviceJoined", async (data: ZHEvents.DeviceJoinedPayload) => {
this.#herdsman.on("deviceJoined", async (data: ZHEvents.DeviceJoinedPayload) => {
const device = this.resolveDevice(data.device.ieeeAddr);
/* v8 ignore next */ if (!device) return; // Prevent potential race
await device.resolveDefinition();
logger.info(`Device '${device.name}' joined`);
this.eventBus.emitDeviceJoined({device});
});
this.herdsman.on("deviceLeave", (data: ZHEvents.DeviceLeavePayload) => {
this.#herdsman.on("deviceLeave", (data: ZHEvents.DeviceLeavePayload) => {
const name = settings.getDevice(data.ieeeAddr)?.friendly_name || data.ieeeAddr;
logger.warning(`Device '${name}' left the network`);
this.eventBus.emitDeviceLeave({ieeeAddr: data.ieeeAddr, name, device: this.deviceLookup.get(data.ieeeAddr)});
});
this.herdsman.on("message", async (data: ZHEvents.MessagePayload) => {
this.#herdsman.on("message", async (data: ZHEvents.MessagePayload) => {
// biome-ignore lint/style/noNonNullAssertion: assumed valid
const device = this.resolveDevice(data.device.ieeeAddr)!;
await device.resolveDefinition();
@@ -130,32 +136,55 @@ export default class Zigbee {
logger.info(`zigbee-herdsman started (${startResult})`);
logger.info(`Coordinator firmware version: '${stringify(await this.getCoordinatorVersion())}'`);
logger.debug(`Zigbee network parameters: ${stringify(await this.herdsman.getNetworkParameters())}`);
logger.debug(`Zigbee network parameters: ${stringify(await this.#herdsman.getNetworkParameters())}`);
for (const device of this.devicesIterator(utils.deviceNotCoordinator)) {
// If a passlist is used, all other device will be removed from the network.
const passlist = settings.get().passlist;
const blocklist = settings.get().blocklist;
const remove = async (device: Device): Promise<void> => {
try {
await device.zh.removeFromNetwork();
} catch (error) {
logger.error(`Failed to remove '${device.ieeeAddr}' (${(error as Error).message})`);
}
};
if (abortSignal.aborted) {
return false;
}
if (passlist.length > 0) {
const remove = async (device: Device): Promise<void> => {
try {
await device.zh.removeFromNetwork();
} catch (error) {
logger.error(`Failed to remove '${device.ieeeAddr}' (${(error as Error).message})`);
}
};
// If a passlist is used, all other device will be removed from the network.
const passlist = settings.get().passlist;
if (passlist.length > 0) {
for (const device of this.devicesIterator(utils.deviceNotCoordinator)) {
if (!passlist.includes(device.ieeeAddr)) {
logger.warning(`Device not on passlist currently connected (${device.ieeeAddr}), removing...`);
logger.warning(`Device not on passlist currently on the network (${device.ieeeAddr}), removing...`);
await remove(device);
if (abortSignal.aborted) {
return false;
}
}
}
} else {
for (const ieee of settings.get().blocklist) {
const device = this.resolveDevice(ieee);
if (device) {
if (device.zh.type === "Coordinator") {
continue;
}
logger.warning(`Device on blocklist currently on the network (${device.ieeeAddr}), removing...`);
await remove(device);
if (abortSignal.aborted) {
return false;
}
} else {
logger.debug(`Ignoring blocklist device ${ieee}, not currently on the network`);
}
} else if (blocklist.includes(device.ieeeAddr)) {
logger.warning(`Device on blocklist currently connected (${device.ieeeAddr}), removing...`);
await remove(device);
}
}
return startResult;
return true;
}
private logDeviceInterview(data: eventdata.DeviceInterview): void {
@@ -199,39 +228,39 @@ export default class Zigbee {
}
async getCoordinatorVersion(): Promise<zh.CoordinatorVersion> {
return await this.herdsman.getCoordinatorVersion();
return await this.#herdsman.getCoordinatorVersion();
}
isStopping(): boolean {
return this.herdsman.isStopping();
return this.#herdsman.isStopping();
}
async backup(): Promise<void> {
return await this.herdsman.backup();
return await this.#herdsman.backup();
}
async coordinatorCheck(): Promise<{missingRouters: Device[]}> {
const check = await this.herdsman.coordinatorCheck();
const check = await this.#herdsman.coordinatorCheck();
// biome-ignore lint/style/noNonNullAssertion: assumed valid
return {missingRouters: check.missingRouters.map((d) => this.resolveDevice(d.ieeeAddr)!)};
}
async getNetworkParameters(): Promise<zh.NetworkParameters> {
return await this.herdsman.getNetworkParameters();
return await this.#herdsman.getNetworkParameters();
}
async stop(): Promise<void> {
logger.info("Stopping zigbee-herdsman...");
await this.herdsman.stop();
await this.#herdsman?.stop(); // could be undefined if this is called during startup (abort)
logger.info("Stopped zigbee-herdsman");
}
getPermitJoin(): boolean {
return this.herdsman.getPermitJoin();
return this.#herdsman.getPermitJoin();
}
getPermitJoinEnd(): number | undefined {
return this.herdsman.getPermitJoinEnd();
return this.#herdsman.getPermitJoinEnd();
}
async permitJoin(time: number, device?: Device): Promise<void> {
@@ -241,18 +270,22 @@ export default class Zigbee {
logger.info("Zigbee: disabling joining new devices.");
}
await this.herdsman.permitJoin(time, device?.zh);
await this.#herdsman.permitJoin(time, device?.zh);
}
async resolveDevicesDefinitions(ignoreCache = false): Promise<void> {
async resolveDevicesDefinitions(ignoreCache = false, abortSignal: AbortSignal | undefined = undefined): Promise<void> {
for (const device of this.devicesIterator(utils.deviceNotCoordinator)) {
await device.resolveDefinition(ignoreCache);
if (abortSignal?.aborted) {
return;
}
}
}
@bind private resolveDevice(ieeeAddr: string): Device | undefined {
if (!this.deviceLookup.has(ieeeAddr)) {
const device = this.herdsman.getDeviceByIeeeAddr(ieeeAddr);
const device = this.#herdsman.getDeviceByIeeeAddr(ieeeAddr);
if (device) {
this.deviceLookup.set(ieeeAddr, new Device(device));
}
@@ -266,12 +299,20 @@ export default class Zigbee {
}
private resolveGroup(groupID: number): Group | undefined {
const group = this.herdsman.getGroupByID(Number(groupID));
if (group && !this.groupLookup.has(groupID)) {
this.groupLookup.set(groupID, new Group(group, this.resolveDevice));
if (!this.groupLookup.has(groupID)) {
const group = this.#herdsman.getGroupByID(groupID);
if (group) {
this.groupLookup.set(groupID, new Group(group, this.resolveDevice));
}
}
return this.groupLookup.get(groupID);
const group = this.groupLookup.get(groupID);
if (group) {
group.ensureInSettings();
return group;
}
}
resolveEntity(key: string | number | zh.Device): Device | Group | undefined {
@@ -332,33 +373,33 @@ export default class Zigbee {
}
firstCoordinatorEndpoint(): zh.Endpoint {
return this.herdsman.getDevicesByType("Coordinator")[0].endpoints[0];
return this.#herdsman.getDevicesByType("Coordinator")[0].endpoints[0];
}
*devicesAndGroupsIterator(
devicePredicate?: (value: zh.Device) => boolean,
groupPredicate?: (value: zh.Group) => boolean,
): Generator<Device | Group> {
for (const device of this.herdsman.getDevicesIterator(devicePredicate)) {
for (const device of this.#herdsman.getDevicesIterator(devicePredicate)) {
// biome-ignore lint/style/noNonNullAssertion: assumed valid
yield this.resolveDevice(device.ieeeAddr)!;
}
for (const group of this.herdsman.getGroupsIterator(groupPredicate)) {
for (const group of this.#herdsman.getGroupsIterator(groupPredicate)) {
// biome-ignore lint/style/noNonNullAssertion: assumed valid
yield this.resolveGroup(group.groupID)!;
}
}
*groupsIterator(predicate?: (value: zh.Group) => boolean): Generator<Group> {
for (const group of this.herdsman.getGroupsIterator(predicate)) {
for (const group of this.#herdsman.getGroupsIterator(predicate)) {
// biome-ignore lint/style/noNonNullAssertion: assumed valid
yield this.resolveGroup(group.groupID)!;
}
}
*devicesIterator(predicate?: (value: zh.Device) => boolean): Generator<Device> {
for (const device of this.herdsman.getDevicesIterator(predicate)) {
for (const device of this.#herdsman.getDevicesIterator(predicate)) {
// biome-ignore lint/style/noNonNullAssertion: assumed valid
yield this.resolveDevice(device.ieeeAddr)!;
}
@@ -392,37 +433,41 @@ export default class Zigbee {
}
async touchlinkFactoryResetFirst(): Promise<boolean> {
return await this.herdsman.touchlinkFactoryResetFirst();
return await this.#herdsman.touchlink.factoryResetFirst();
}
async touchlinkFactoryReset(ieeeAddr: string, channel: number): Promise<boolean> {
return await this.herdsman.touchlinkFactoryReset(ieeeAddr, channel);
return await this.#herdsman.touchlink.factoryReset(ieeeAddr, channel);
}
async addInstallCode(installCode: string): Promise<void> {
await this.herdsman.addInstallCode(installCode);
await this.#herdsman.addInstallCode(installCode);
}
async touchlinkIdentify(ieeeAddr: string, channel: number): Promise<void> {
await this.herdsman.touchlinkIdentify(ieeeAddr, channel);
await this.#herdsman.touchlink.identify(ieeeAddr, channel);
}
async touchlinkScan(): Promise<{ieeeAddr: string; channel: number}[]> {
return await this.herdsman.touchlinkScan();
return await this.#herdsman.touchlink.scan();
}
createGroup(id: number): Group {
this.herdsman.createGroup(id);
this.#herdsman.createGroup(id);
// biome-ignore lint/style/noNonNullAssertion: just created
return this.resolveGroup(id)!;
}
deviceByNetworkAddress(networkAddress: number): Device | undefined {
const device = this.herdsman.getDeviceByNetworkAddress(networkAddress);
const device = this.#herdsman.getDeviceByNetworkAddress(networkAddress);
return device && this.resolveDevice(device.ieeeAddr);
}
groupByID(id: number): Group | undefined {
return this.resolveGroup(id);
}
removeGroupFromLookup(id: number): void {
this.groupLookup.delete(id);
}
}
+25 -25
View File
@@ -1,16 +1,16 @@
{
"name": "zigbee2mqtt",
"version": "2.5.1",
"version": "2.9.2",
"description": "Zigbee to MQTT bridge using Zigbee-herdsman",
"main": "index.js",
"types": "dist/types/api.d.ts",
"packageManager": "pnpm@10.12.1",
"packageManager": "pnpm@10.18.3",
"repository": {
"type": "git",
"url": "git+https://github.com/Koenkk/zigbee2mqtt.git"
},
"engines": {
"node": "^20 || ^22 || ^24"
"node": "^20.15.0 || ^22.2.0 || ^24"
},
"keywords": [
"xiaomi",
@@ -31,6 +31,7 @@
"test": "vitest run --config ./test/vitest.config.mts",
"test:coverage": "vitest run --config ./test/vitest.config.mts --coverage",
"test:watch": "vitest watch --config ./test/vitest.config.mts",
"bench": "vitest bench --run --config ./test/vitest.config.mts",
"prepack": "pnpm run clean && pnpm run build",
"clean": "rimraf coverage dist tsconfig.tsbuildinfo"
},
@@ -41,45 +42,44 @@
},
"homepage": "https://koenkk.github.io/zigbee2mqtt",
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.18.0",
"bind-decorator": "^1.0.11",
"debounce": "^2.2.0",
"debounce": "^3.0.0",
"express-static-gzip": "^3.0.0",
"fast-deep-equal": "^3.1.3",
"finalhandler": "^2.1.0",
"humanize-duration": "^3.32.1",
"finalhandler": "^2.1.1",
"humanize-duration": "^3.33.2",
"js-yaml": "^4.1.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
"jszip": "^3.10.1",
"moment": "^2.30.1",
"mqtt": "^5.11.0",
"mqtt": "^5.15.1",
"object-assign-deep": "^0.4.0",
"rimraf": "^6.0.1",
"semver": "^7.7.1",
"rimraf": "^6.1.3",
"semver": "^7.7.4",
"source-map-support": "^0.5.21",
"throttleit": "^2.1.0",
"winston": "^3.17.0",
"winston": "^3.19.0",
"winston-syslog": "^2.7.1",
"winston-transport": "^4.9.0",
"ws": "^8.18.1",
"zigbee-herdsman": "4.3.1",
"zigbee-herdsman-converters": "23.69.0",
"zigbee2mqtt-frontend": "0.9.16",
"zigbee2mqtt-windfront": "^1.2.1"
"ws": "^8.20.0",
"zigbee-herdsman": "10.0.5",
"zigbee-herdsman-converters": "26.27.0",
"zigbee2mqtt-frontend": "0.9.21",
"zigbee2mqtt-windfront": "2.11.1"
},
"devDependencies": {
"@biomejs/biome": "^2.0.5",
"@biomejs/biome": "^2.4.10",
"@types/finalhandler": "^1.2.3",
"@types/humanize-duration": "^3.27.4",
"@types/js-yaml": "^4.0.9",
"@types/node": "^24.0.3",
"@types/node": "^24.12.0",
"@types/object-assign-deep": "^0.4.3",
"@types/readable-stream": "4.0.21",
"@types/serve-static": "^1.15.7",
"@types/readable-stream": "4.0.23",
"@types/serve-static": "^2.2.0",
"@types/ws": "8.18.1",
"@vitest/coverage-v8": "^3.1.1",
"tmp": "^0.2.3",
"typescript": "^5.8.3",
"tmp": "^0.2.5",
"typescript": "^5.9.3",
"vitest": "^3.1.1"
},
"pnpm": {
@@ -97,6 +97,6 @@
"zigbee2mqtt": "cli.js"
},
"optionalDependencies": {
"unix-dgram": "^2.0.6"
"unix-dgram": "^2.0.7"
}
}
}
+387 -374
View File
File diff suppressed because it is too large Load Diff
-157
View File
@@ -1,157 +0,0 @@
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];
const zhTillVersion = process.argv[4];
const frontendTillVersion = process.argv[5];
const changelogs = [
{
tillVersion: z2mTillVersion,
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"),
},
{
tillVersion: zhTillVersion,
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",
isFrontend: true,
contents: fs.readFileSync(path.join(__dirname, "..", "node_modules", "zigbee2mqtt-frontend", "CHANGELOG.md"), "utf-8").split("\n"),
},
];
const releaseRe = /## \[(.+)\]/;
const changes = {features: [], fixes: [], detect: [], add: [], error: [], frontend: []};
let context = null;
const changeRe = [
/^\* (\*\*(.+):\*\*)?(.+)\((\[#\d+\]\(.+\))\) \(\[.+\]\(https:.+\/(.+)\)\)$/,
/^\* (\*\*(.+):\*\*)?(.+)(https:\/\/github\.com.+) \(\[.+\]\(https:.+\/(.+)\)\)$/,
/^\* (\*\*(.+):\*\*)?(.+)() \(\[.+\]\(https:.+\/(.+)\)\)$/,
];
const frontendChangeRe = /^\* (\*\*.+:\*\* )()?(.+) \(\[.+\]\(https:.+\/()(.+)\)\)(.+)?$/;
let commitUserLookup = {};
const commitUserFile = path.join(__dirname, "commit-user-lookup.json");
if (fs.existsSync(commitUserFile)) {
commitUserLookup = JSON.parse(fs.readFileSync(commitUserFile, "utf8"));
}
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);
for (const changelog of changelogs) {
for (const line of changelog.contents) {
const releaseMatch = line.match(releaseRe);
const changeMatch = changelog.isFrontend ? line.match(frontendChangeRe) : changeRe.map((re) => line.match(re)).find((e) => e);
if (releaseMatch) {
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 (changeMatch) {
let localContext = changelog.isFrontend ? "frontend" : changeMatch[2] ? changeMatch[2] : context;
if (!changes[localContext]) localContext = "error";
const commit = changeMatch[5];
const commitUserKey = `${changelog.project}-${commit} `;
let user =
commitUserKey in commitUserLookup
? commitUserLookup[commitUserKey]
: execSync(`curl -s https://api.github.com/repos/${changelog.project}/commits/${commit} | jq -r '.author.login'`)
.toString()
.trim();
if (user !== "null") commitUserLookup[commitUserKey] = user;
const messages = [];
let message = changeMatch[3].trim();
if (message.endsWith(".")) message = message.substring(0, message.length - 1);
if (changelog.isFrontend) {
changes[localContext].push(
`- [${commit.slice(0, 7)}](https://github.com/${changelog.project}/commit/${commit}) ${message} (@${user})`,
);
messages.push(capitalizeFirstChar(message));
} else {
const otherUser = message.match(/\[@(.+)\]\(https:\/\/github.com\/.+\)/) || message.match(/@(.+)/);
if (otherUser) {
user = otherUser[1];
message = message.replace(otherUser[0], "");
}
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)`);
}
}
} else {
messages.push(capitalizeFirstChar(message));
}
let issue = changeMatch[4].trim();
if (issue && !issue.startsWith("[#")) issue = `[#${issue.split("/").pop()}](${issue})`;
if (!issue) {
issue = "_NO_ISSUE_";
localContext = "error";
}
for (const message of messages) {
changes[localContext].push(`- ${issue} ${message} (@${user})`);
}
}
} else if (line === "# Changelog" || line === "### ⚠ BREAKING CHANGES" || !line) {
// continue;
} else {
changes.error.push(`- Unmatched line: ${line}`);
}
}
}
let result = "";
const names = [
["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`;
}
for (const change of changes[name[0]]) {
result += `${change}\n`;
}
result += "\n";
}
fs.writeFileSync(commitUserFile, JSON.stringify(commitUserLookup), "utf-8");
console.log(result.trim());
+191
View File
@@ -0,0 +1,191 @@
import {execSync} from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import zhc from "zigbee-herdsman-converters";
import definitionIndex from "zigbee-herdsman-converters/devices/index";
const z2mTillVersion = process.argv[2];
const zhcTillVersion = process.argv[3];
const zhTillVersion = process.argv[4];
const frontendTillVersion = process.argv[5];
const windfrontTillVersion = process.argv[6];
const changelogs = [
{
tillVersion: z2mTillVersion,
project: "koenkk/zigbee2mqtt",
contents: fs.readFileSync(path.join(import.meta.dirname, "..", "CHANGELOG.md"), "utf-8").split("\n"),
},
{
tillVersion: zhcTillVersion,
project: "koenkk/zigbee-herdsman-converters",
contents: fs
.readFileSync(path.join(import.meta.dirname, "..", "node_modules", "zigbee-herdsman-converters", "CHANGELOG.md"), "utf-8")
.split("\n"),
},
{
tillVersion: zhTillVersion,
project: "koenkk/zigbee-herdsman",
contents: fs.readFileSync(path.join(import.meta.dirname, "..", "node_modules", "zigbee-herdsman", "CHANGELOG.md"), "utf-8").split("\n"),
},
{
tillVersion: frontendTillVersion,
project: "nurikk/zigbee2mqtt-frontend",
isFrontend: true,
contents: fs.readFileSync(path.join(import.meta.dirname, "..", "node_modules", "zigbee2mqtt-frontend", "CHANGELOG.md"), "utf-8").split("\n"),
},
{
tillVersion: windfrontTillVersion,
project: "Nerivec/zigbee2mqtt-windfront",
},
];
const releaseRe = /## \[(.+)\]/;
const windfrontChangeRe = /^\* (feat|fix): (.+?)(?: by @([^\s]+) in (https:\/\/github\.com\/Nerivec\/zigbee2mqtt-windfront\/pull\/(\d+)))?$/gm;
const changes = {features: [], fixes: [], detect: [], add: [], error: [], frontend: [], windfront: []};
let context = null;
const changeRe = [
/^\* (\*\*(.+):\*\*)?(.+)\((\[#\d+\]\(.+\))\) \(\[.+\]\(https:.+\/(.+)\)\)$/,
/^\* (\*\*(.+):\*\*)?(.+)(https:\/\/github\.com.+) \(\[.+\]\(https:.+\/(.+)\)\)$/,
/^\* (\*\*(.+):\*\*)?(.+)() \(\[.+\]\(https:.+\/(.+)\)\)$/,
];
const frontendChangeRe = /^\* (\*\*.+:\*\* )()?(.+) \(\[.+\]\(https:.+\/()(.+)\)\)(.+)?$/;
let commitUserLookup = {};
const commitUserFile = path.join(import.meta.dirname, "commit-user-lookup.json");
if (fs.existsSync(commitUserFile)) {
commitUserLookup = JSON.parse(fs.readFileSync(commitUserFile, "utf8"));
}
const definitions = definitionIndex.default.map((d) => zhc.prepareDefinition(d));
const whiteLabels = definitions
.filter((d) => d.whiteLabel)
.flatMap((d) =>
d.whiteLabel.map((wl) => {
return {model: wl.model, vendor: wl.vendor ?? d.vendor, description: wl.description ?? d.description};
}),
);
const capitalizeFirstChar = (str) => str.charAt(0).toUpperCase() + str.slice(1);
for (const changelog of changelogs) {
if (changelog.project === "Nerivec/zigbee2mqtt-windfront") {
const releaseRsp = await fetch("https://api.github.com/repos/Nerivec/zigbee2mqtt-windfront/releases");
const releases = await releaseRsp.json();
for (const release of releases) {
if (release.name === `v${windfrontTillVersion}`) {
break;
}
let match = windfrontChangeRe.exec(release.body);
while (match !== null) {
const [, type, message, user, prLink, prId] = match;
const entry = `- ${prId && prLink ? `[#${prId}](${prLink}) ` : ""}${type}: ${message} ${user ? `(@${user.split("[")[0]})` : ""}`;
changes.windfront.push(entry);
match = windfrontChangeRe.exec(release.body);
}
}
} else {
for (const line of changelog.contents) {
const releaseMatch = line.match(releaseRe);
const changeMatch = changelog.isFrontend ? line.match(frontendChangeRe) : changeRe.map((re) => line.match(re)).find((e) => e);
if (releaseMatch) {
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 (changeMatch) {
let localContext = changelog.isFrontend ? "frontend" : changeMatch[2] ? changeMatch[2] : context;
if (!changes[localContext]) localContext = "error";
const commit = changeMatch[5];
const commitUserKey = `${changelog.project}-${commit} `;
let user =
commitUserKey in commitUserLookup
? commitUserLookup[commitUserKey]
: execSync(`curl -s https://api.github.com/repos/${changelog.project}/commits/${commit} | jq -r '.author.login'`)
.toString()
.trim();
if (user !== "null") commitUserLookup[commitUserKey] = user;
const messages = [];
let message = changeMatch[3].trim();
if (message.endsWith(".")) message = message.substring(0, message.length - 1);
if (changelog.isFrontend) {
changes[localContext].push(
`- [${commit.slice(0, 7)}](https://github.com/${changelog.project}/commit/${commit}) ${message} (@${user})`,
);
messages.push(capitalizeFirstChar(message));
} else {
const otherUser = message.match(/\[@(.+)\]\(https:\/\/github.com\/.+\)/) || message.match(/@(.+)/);
if (otherUser) {
user = otherUser[1];
message = message.replace(otherUser[0], "");
}
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)`);
}
}
} else {
messages.push(capitalizeFirstChar(message));
}
let issue = changeMatch[4].trim();
if (issue && !issue.startsWith("[#")) issue = `[#${issue.split("/").pop()}](${issue})`;
if (!issue) {
issue = "_NO_ISSUE_";
localContext = "error";
}
for (const message of messages) {
changes[localContext].push(`- ${issue} ${message} (@${user})`);
}
}
} else if (line === "# Changelog" || line === "### ⚠ BREAKING CHANGES" || !line) {
// continue;
} else {
changes.error.push(`- Unmatched line: ${line}`);
}
}
}
}
let result = "";
const names = [
["features", "Improvements"],
["fixes", "Fixes"],
["windfront", "Windfront"],
["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`;
}
for (const change of changes[name[0]]) {
result += `${change}\n`;
}
result += "\n";
}
fs.writeFileSync(commitUserFile, JSON.stringify(commitUserLookup), "utf-8");
console.log(result.trim());
+186
View File
@@ -0,0 +1,186 @@
import {execSync} from "node:child_process";
async function checkDuplicateIssue(github, context, name) {
// Search for existing issues with the same `name`
const searchQuery = `repo:${context.repo.owner}/${context.repo.repo} is:issue -is:pr "${name}" label:"new device support","external converter"`;
try {
const searchResults = await github.rest.search.issuesAndPullRequests({q: searchQuery, per_page: 100});
// Filter out the current issue and return the first duplicate found
const existingIssues = searchResults.data.items.filter((item) => item.number !== context.payload.issue.number).map((i) => `#${i.number}`);
if (existingIssues.length > 0) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
body: `👋 Hi there! This issue appears to be a duplicate of ${existingIssues.join(", ")}
This issue will be closed. Please follow the existing issue for updates.`,
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
state: "closed",
});
return true;
}
} catch (error) {
console.error(`Error searching for duplicate issues with ${name}:`, error);
}
return false;
}
export async function newDeviceSupport(github, _core, context, zhcDir) {
const issue = context.payload.issue;
// Hide previous bot comments
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
});
for (const comment of comments.data) {
if (comment.user.type === "Bot" && comment.user.login === "github-actions[bot]") {
await github.graphql(`mutation {
minimizeComment(input: {subjectId: "${comment.node_id}", classifier: OUTDATED}) {
clientMutationId
}
}`);
}
}
const titleAndBody = `${issue.title}\n\n${issue.body ?? ""}`;
// Check if Tuya manufacturer name is already supported.
const tuyaManufacturerNameRe = /['"](_T\w+_(\w+))['"]/g;
const tuyaManufacturerNames = Array.from(titleAndBody.matchAll(tuyaManufacturerNameRe), (m) => [m[1], m[2]]);
console.log("Found tuyaManufacturerNames", tuyaManufacturerNames);
if (tuyaManufacturerNames.length > 0) {
for (const [fullName, partialName] of tuyaManufacturerNames) {
if (await checkDuplicateIssue(github, context, fullName)) return;
const fullMatch = (() => {
try {
return execSync(`grep -r --include="*.ts" "${fullName}" "${zhcDir}"`, {encoding: "utf8"});
} catch {
return undefined;
}
})();
console.log(`Checking full match for '${fullName}', result: '${fullMatch}'`);
if (fullMatch) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: `👋 Hi there! The Tuya device with manufacturer name \`${fullName}\` is already supported in the latest dev branch.
See this [guide](https://www.zigbee2mqtt.io/advanced/more/switch-to-dev-branch.html) on how to update, after updating you can remove your external converter.
In case you created the external converter with the goal to extend or fix an issue with the out-of-the-box support, please submit a pull request.
For instructions on how to create a pull request see the [docs](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html#_4-create-a-pull-request).
If you need help with the process, feel free to ask here and we'll be happy to assist.`,
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: "closed",
});
return;
}
const partialMatch = (() => {
try {
return execSync(`grep -r --include="*.ts" "${partialName}" "${zhcDir}"`, {encoding: "utf8"});
} catch {
return undefined;
}
})();
console.log(`Checking partial match for '${partialName}', result: '${partialMatch}'`);
if (partialMatch) {
const candidates = Array.from(partialMatch.matchAll(tuyaManufacturerNameRe), (m) => m[1]);
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: `👋 Hi there! A similar Tuya device of which the manufacturer name also ends with \`_${partialName}\` is already supported.
This means the device can probably be easily be supported by re-using the existing converter.
I found the following matches: ${candidates.map((c) => `\`${c}\``).join(", ")}
Try to stop Z2M, change all occurrences of \`${fullName}\` in the \`data/database.db\` to one of the matches above and start Z2M.
Let us know if it works so we can support this device out-of-the-box!`,
});
return;
}
}
} else {
// Check if zigbee model is already supported.
const zigbeeModelRe = /zigbeeModel: \[['"](.+)['"]\]/g;
const zigbeeModels = Array.from(titleAndBody.matchAll(zigbeeModelRe), (m) => m[1]);
if (zigbeeModels.length > 0) {
for (const zigbeeModel of zigbeeModels) {
if (await checkDuplicateIssue(github, context, fullName)) return;
const fullMatch = (() => {
try {
return execSync(`grep -r --include="*.ts" '"${zigbeeModel}"' "${zhcDir}"`, {encoding: "utf8"});
} catch {
return undefined;
}
})();
if (fullMatch) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: `👋 Hi there! The device with zigbee model \`${zigbeeModel}\` is already supported in the latest dev branch.
See this [guide](https://www.zigbee2mqtt.io/advanced/more/switch-to-dev-branch.html) on how to update, after updating you can remove your external converter.
In case you created the external converter with the goal to extend or fix an issue with the out-of-the-box support, please submit a pull request.
For instructions on how to create a pull request see the [docs](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html#_4-create-a-pull-request).
If you need help with the process, feel free to ask here and we'll be happy to assist.`,
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: "closed",
});
return;
}
}
}
}
// Create a request to pull request comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: `🙏 Thank you for creating this issue and sharing your external converter!
In case all features work, please submit a pull request on this repository so the device can be supported out-of-the-box with the next release.
For instructions on how to create a pull request see the [docs](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html#_4-create-a-pull-request).
If **NOT** all features work, please follow the [How To Support new devices](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html).
${
tuyaManufacturerNames.length > 0
? "Since this is a Tuya also consider [How To Support new Tuya devices](https://www.zigbee2mqtt.io/advanced/support-new-devices/02_support_new_tuya_devices.html)."
: ""
}
If you need help with the process, feel free to ask here and we'll be happy to assist.`,
});
}
+2 -2
View File
@@ -18,5 +18,5 @@ const sandbox = {
};
vm.runInNewContext(moduleCode, sandbox, moduleFakePath);
const converter = sandbox.module.exports;
assert(!converter.toZigbee || !converter.toZigbee.includes(undefined));
assert(!converter.fromZigbee || !converter.fromZigbee.includes(undefined));
assert(!converter.toZigbee?.includes(undefined));
assert(!converter.fromZigbee?.includes(undefined));
+1 -1
View File
@@ -91,4 +91,4 @@ if (processArgs.length !== 1) {
const eraser = new ZStackNvMemEraser(processArgs[0]);
eraser.start();
void eraser.start();
+14
View File
@@ -0,0 +1,14 @@
import {hrtime} from "node:process";
import type {bench} from "vitest";
export const BENCH_OPTIONS: NonNullable<Parameters<typeof bench>[2]> = {
throws: true,
warmupTime: 1000,
now: () => Number(hrtime.bigint()) / 1e6,
setup: (_task, mode) => {
// Run the garbage collector before warmup at each cycle
if (mode === "warmup" && typeof globalThis.gc === "function") {
globalThis.gc();
}
},
};
+662
View File
@@ -0,0 +1,662 @@
import {existsSync, mkdirSync} from "node:fs";
import stringify from "json-stable-stringify-without-jsonify";
import {bench, describe, vi} from "vitest";
import {type Controller, Zcl, Zdo, ZSpec} from "zigbee-herdsman";
import type Adapter from "zigbee-herdsman/dist/adapter/adapter";
import type {ZclPayload} from "zigbee-herdsman/dist/adapter/events";
import {Device, InterviewState} from "zigbee-herdsman/dist/controller/model/device";
import {Endpoint} from "zigbee-herdsman/dist/controller/model/endpoint";
import Entity from "zigbee-herdsman/dist/controller/model/entity";
import {Group} from "zigbee-herdsman/dist/controller/model/group";
import type {DeviceType} from "zigbee-herdsman/dist/controller/tstype";
import {Foundation} from "zigbee-herdsman/dist/zspec/zcl/definition/foundation";
import type {RequestToResponseMap} from "zigbee-herdsman/dist/zspec/zdo/definition/tstypes";
import data from "../lib/util/data";
import {BENCH_OPTIONS} from "./benchOptions";
vi.doMock("zigbee-herdsman", async (importOriginal) => {
const actual = await importOriginal<typeof import("zigbee-herdsman")>();
class MockHerdsman {
on: Controller["on"] = vi.fn();
start: Controller["start"] = async () => "resumed" as const;
stop: Controller["stop"] = async () => {};
isStopping: Controller["isStopping"] = () => false;
getCoordinatorVersion: Controller["getCoordinatorVersion"] = async () =>
Promise.resolve({
type: "Dummy",
meta: {revision: "9.9.9"},
});
getNetworkParameters: Controller["getNetworkParameters"] = async () => Promise.resolve({...NETWORK_PARAMS});
getPermitJoin: Controller["getPermitJoin"] = () => false;
getPermitJoinEnd: Controller["getPermitJoinEnd"] = () => undefined;
getDeviceByIeeeAddr: Controller["getDeviceByIeeeAddr"] = (ieeeAddr) => ZH_DEVICES.find((device) => device.ieeeAddr === ieeeAddr);
getGroupByID: Controller["getGroupByID"] = (id) => ZH_GROUPS.find((group) => group.groupID === id);
getDevicesByType: Controller["getDevicesByType"] = (type) => ZH_DEVICES.filter((device) => device.type === type);
getDeviceByNetworkAddress: Controller["getDeviceByNetworkAddress"] = (networkAddress) =>
ZH_DEVICES.find((device) => device.networkAddress === networkAddress);
*getDevicesIterator(predicate: ((device: Device) => boolean) | undefined) {
for (const device of ZH_DEVICES) {
if (!predicate || predicate(device)) {
yield device;
}
}
}
*getGroupsIterator(predicate: ((group: Group) => boolean) | undefined) {
for (const group of ZH_GROUPS) {
if (!predicate || predicate(group)) {
yield group;
}
}
}
}
return {
...actual,
Controller: MockHerdsman,
};
});
process.env.ZIGBEE2MQTT_DATA = "data-bench";
data._testReload();
if (!existsSync(data.getPath())) {
mkdirSync(data.getPath(), {recursive: true});
}
const createEndpoint = (id: number, ieeeAddr: string, networkAddress: number) => {
const ep = Endpoint.create(
id,
id === ZSpec.GP_ENDPOINT ? ZSpec.GP_PROFILE_ID : ZSpec.HA_PROFILE_ID,
id === ZSpec.GP_ENDPOINT ? 0x66 : 0x65,
id === ZSpec.GP_ENDPOINT ? [Zcl.Clusters.greenPower.ID] : [Zcl.Clusters.genBasic.ID, Zcl.Clusters.genOnOff.ID],
id === ZSpec.GP_ENDPOINT ? [Zcl.Clusters.greenPower.ID] : [Zcl.Clusters.genBasic.ID],
networkAddress,
ieeeAddr,
);
ep.save = () => {};
return ep;
};
// `Device.create` requires `Database`, bypass it by using the private constructor directly
const createDevice = (
dbId: number,
type: DeviceType,
ieeeAddr: string,
networkAddress: number,
manufacturerID: number | undefined,
manufacturerName: string | undefined,
powerSource: string | undefined,
modelID: string | undefined,
): Device => {
const haEp = createEndpoint(ZSpec.HA_ENDPOINT, ieeeAddr, networkAddress);
const ep2 = createEndpoint(2, ieeeAddr, networkAddress);
const endpoints = [haEp, ep2];
if (type === "Coordinator" || type === "Router") {
const gpEp = createEndpoint(ZSpec.GP_ENDPOINT, ieeeAddr, networkAddress);
endpoints.push(gpEp);
}
// @ts-expect-error mocking private
const device = new Device(
dbId,
type,
ieeeAddr,
networkAddress,
manufacturerID,
endpoints,
manufacturerName,
powerSource,
modelID,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
InterviewState.Successful,
{},
undefined,
undefined,
0,
undefined,
);
// in-memory only
device.save = () => {};
return device;
};
// `Group.create` requires `Database`, bypass it by using the private constructor directly
const createGroup = (dbId: number, id: number): Group => {
// @ts-expect-error mocking private
const group = new Group(dbId, id, [], {});
// in-memory only
group.save = () => {};
return group;
};
const COORD_IEEE = "0x0101010101010101";
const EXT_PAN_ID = [0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd];
const NETWORK_PARAMS = {
panID: 0x1a62,
extendedPanID: `0x${Buffer.from(EXT_PAN_ID).toString("hex")}`,
channel: 11,
nwkUpdateID: 1,
};
const NETWORK_KEY = [1, 3, 5, 7, 9, 11, 13, 15, 0, 2, 4, 6, 8, 10, 12, 13];
const ZH_DEVICES: Device[] = [];
const ZH_GROUPS: Group[] = [];
const MANY_DEVICES = 100;
const initDevices = () => {
ZH_DEVICES.splice(0, ZH_DEVICES.length);
ZH_DEVICES.push(
createDevice(
0,
"Coordinator",
COORD_IEEE,
ZSpec.COORDINATOR_ADDRESS,
Zcl.ManufacturerCode.SILICON_LABORATORIES,
undefined,
undefined,
undefined,
),
);
ZH_DEVICES.push(
createDevice(1, "Router", "0xf1f1f1f1f1f1f1f1", 0x0001, Zcl.ManufacturerCode.INNR_LIGHTING_BV, "Innr", "Mains (single phase)", "AE 262"),
);
ZH_DEVICES.push(
createDevice(2, "EndDevice", "0xe2e2e2e2e2e2e2e2", 0x0002, Zcl.ManufacturerCode.TUYA_GLOBAL_INC, "_TYZB01_kvwjujy9", "Battery", "TS0222"),
);
ZH_DEVICES.push(createDevice(3, "GreenPower", "0x00000000015d3d3d", 0x0003, undefined, undefined, undefined, "GreenPower_7"));
// these have configure, without setTimeout, they hammer really badly (# of fn calls), so, only one of each
ZH_DEVICES.push(
createDevice(
4,
"Router",
"0xd3d3d3d3d3d3d3d3",
0x0004,
Zcl.ManufacturerCode.LEDVANCE_GMBH,
"LEDVANCE",
"Mains (single phase)",
"PLUG OUTDOOR EU T",
),
);
ZH_DEVICES.push(
createDevice(5, "Router", "0xc4c4c4c4c4c4c4c4", 0x0005, Zcl.ManufacturerCode.INOVELLI, "Inovelli", "Mains (single phase)", "VZM35-SN"),
);
ZH_DEVICES.push(
createDevice(
6,
"Router",
"0xb5b5b5b5b5b5b5b5",
0x0006,
Zcl.ManufacturerCode.SILICON_LABORATORIES,
"SMLIGHT",
"Mains (single phase)",
"SLZB-06Mg24",
),
);
ZH_DEVICES.push(
createDevice(
7,
"Router",
"0xa6a6a6a6a6a6a6a6",
0x0007,
Zcl.ManufacturerCode.TUYA_GLOBAL_INC,
"_TZE200_p0gzbqct",
"Mains (single phase)",
"TS0601",
),
);
};
const initGroups = () => {
ZH_GROUPS.splice(0, ZH_GROUPS.length);
ZH_GROUPS.push(createGroup(0, 1));
};
const addManyDevices = () => {
for (let i = 0; i < MANY_DEVICES; i++) {
const ieee = `0xf1f1f1f1f1f1f1${i.toString(16).padStart(2, "0")}`;
// device without `configure` (too many calls otherwise)
ZH_DEVICES.push(createDevice(1, "Router", ieee, 0x0008 + i, Zcl.ManufacturerCode.INNR_LIGHTING_BV, "Innr", "Mains (single phase)", "AE 262"));
}
};
const getMidDeviceIeee = () =>
`0xf1f1f1f1f1f1f1${Math.floor(MANY_DEVICES / 2)
.toString(16)
.padStart(2, "0")}`;
Device.byIeeeAddr = (ieeeAddr, _includeDeleted) => ZH_DEVICES.find((device) => device.ieeeAddr === ieeeAddr);
Device.byType = (type) => ZH_DEVICES.filter((device) => device.type === type);
const adapter = {
sendZclFrameToEndpoint: async (
_ieeeAddr: string,
networkAddress: number,
endpoint: number,
zclFrame: Zcl.Frame,
_timeout: number,
disableResponse: boolean,
_disableRecovery: boolean,
sourceEndpoint?: number,
): Promise<ZclPayload | undefined> => {
const payload: {[key: string]: unknown}[] = [];
if (!disableResponse) {
if (zclFrame.header.isGlobal) {
switch (zclFrame.command.ID) {
case Foundation.read.ID: {
for (const attr of zclFrame.payload) {
const attribute = zclFrame.cluster.getAttribute(attr.attrId);
if (attribute && attribute.type !== Zcl.DataType.NO_DATA && attribute.type < Zcl.DataType.OCTET_STR) {
payload.push({
attrId: attr.attrId,
dataType: attribute.type,
attrData: 1,
status: 0,
});
}
}
const messageContents = Zcl.Frame.create(
0,
Zcl.Direction.SERVER_TO_CLIENT,
true,
undefined,
10,
Foundation.readRsp.ID,
zclFrame.cluster.ID,
payload,
{},
).toBuffer();
return await Promise.resolve({
clusterID: zclFrame.cluster.ID,
header: Zcl.Header.fromBuffer(messageContents),
address: networkAddress,
data: messageContents,
endpoint: sourceEndpoint ?? 1,
linkquality: 200,
groupID: 0,
wasBroadcast: false,
destinationEndpoint: endpoint,
});
}
case Foundation.configReport.ID: {
for (const item of zclFrame.payload) {
payload.push({attrId: item.attrId, status: 0, direction: 1});
}
const messageContents = Zcl.Frame.create(
0,
Zcl.Direction.SERVER_TO_CLIENT,
true,
undefined,
10,
Foundation.configReportRsp.ID,
zclFrame.cluster.ID,
payload,
{},
).toBuffer();
return await Promise.resolve({
clusterID: zclFrame.cluster.ID,
header: Zcl.Header.fromBuffer(messageContents),
address: networkAddress,
data: messageContents,
endpoint: sourceEndpoint ?? 1,
linkquality: 200,
groupID: 0,
wasBroadcast: false,
destinationEndpoint: endpoint,
});
}
}
}
}
return await Promise.resolve(undefined);
},
sendZclFrameToGroup: async (_groupID: number, _zclFrame: Zcl.Frame, _sourceEndpoint?: number): Promise<void> => await Promise.resolve(),
sendZdo: async (
_ieeeAddress: string,
_networkAddress: number,
clusterId: Zdo.ClusterId,
_payload: Buffer,
_disableResponse: boolean,
): Promise<RequestToResponseMap[keyof RequestToResponseMap] | undefined> => {
switch (clusterId) {
case Zdo.ClusterId.BIND_REQUEST:
case Zdo.ClusterId.UNBIND_REQUEST: {
return await Promise.resolve([Zdo.Status.SUCCESS, undefined]);
}
}
return await Promise.resolve(undefined);
},
};
Entity.injectAdapter(adapter as Adapter);
// use plain type to avoid early import that otherwise messes with data path
let controller: import("../lib/controller.js").Controller;
const origSetImmediate = global.setImmediate;
const origSetTimeout = global.setTimeout;
const mockGlobalThis = () => {
const setImmediateProms: (void | Promise<void>)[] = [];
const setTimeoutProms: (void | Promise<void>)[] = [];
// @ts-expect-error mock
globalThis.setImmediate = (callback: () => void) => {
setImmediateProms.push(callback());
};
// @ts-expect-error mock
globalThis.setTimeout = (callback: () => void) => {
setTimeoutProms.push(callback());
};
return {setImmediateProms, setTimeoutProms};
};
const unmockGlobalThis = () => {
globalThis.setImmediate = origSetImmediate;
globalThis.setTimeout = origSetTimeout;
};
const settle = async (mockedGlobal: ReturnType<typeof mockGlobalThis>) => {
await Promise.allSettled(mockedGlobal.setImmediateProms);
await Promise.allSettled(mockedGlobal.setTimeoutProms);
await new Promise((resolve) => origSetImmediate(resolve));
};
const initSettings = async (pathValuePairs?: [string[], string | number | boolean][]) => {
const settings = await import("../lib/util/settings.js");
settings.writeMinimalDefaults();
// disable logging, too much influence on perf
settings.set(["advanced", "log_level"], "error");
settings.set(["advanced", "log_output"], []);
settings.set(["advanced", "pan_id"], NETWORK_PARAMS.panID);
settings.set(["advanced", "ext_pan_id"], EXT_PAN_ID);
settings.set(["advanced", "network_key"], NETWORK_KEY);
if (pathValuePairs) {
for (const [path, value] of pathValuePairs) {
settings.set(path, value);
}
}
};
const initController = async () => {
const {Controller} = await import("../lib/controller.js");
controller = new Controller(
async () => {},
async () => {},
);
// all dummies, can trigger `controller.mqtt.onMessage(topic, message)` as needed
// @ts-expect-error mocking private
controller.mqtt.client = {
options: {
protocolVersion: 5,
protocol: "mqtt",
host: "localhost",
port: 1883,
},
queue: [],
reconnecting: false,
disconnecting: false,
disconnected: false,
endAsync: async () => {},
// @ts-expect-error Z2M does not make use of return
publishAsync: async () => {},
};
controller.mqtt.connect = async () => {
// @ts-expect-error private
await controller.mqtt.onConnect();
};
controller.mqtt.subscribe = async () => {};
controller.mqtt.unsubscribe = async () => {};
// will be in-memory only
controller.state.start = () => {};
controller.state.stop = () => {};
};
describe("Controller with dummy zigbee/mqtt", () => {
bench(
"[defaults] start & stop controller",
async () => {
initDevices();
initGroups();
await initSettings();
await initController();
const mockedGlobal = mockGlobalThis();
await controller.start();
await settle(mockedGlobal);
if ((await controller.zigbee.getCoordinatorVersion()).type !== "Dummy") {
throw new Error("Invalid");
}
await controller.stop();
unmockGlobalThis();
},
BENCH_OPTIONS,
);
bench(
"[HA] start & stop controller",
async () => {
initDevices();
initGroups();
await initSettings([[["homeassistant", "enabled"], true]]);
await initController();
const mockedGlobal = mockGlobalThis();
await controller.start();
controller.mqtt.onMessage("homeassistant/status", Buffer.from("online", "utf8"));
await settle(mockedGlobal);
if ((await controller.zigbee.getCoordinatorVersion()).type !== "Dummy") {
throw new Error("Invalid");
}
await controller.stop();
unmockGlobalThis();
},
BENCH_OPTIONS,
);
describe("defaults runtime", () => {
const setup: NonNullable<Parameters<typeof bench>[2]>["setup"] = async (task, mode) => {
BENCH_OPTIONS.setup!(task, mode);
initDevices();
initGroups();
await initSettings();
await initController();
const mockedGlobal = mockGlobalThis();
await controller.start();
await settle(mockedGlobal);
};
const teardown = async () => {
await controller.stop();
unmockGlobalThis();
};
bench(
"[defaults] receive device message",
async () => {
const mockedGlobal = mockGlobalThis();
controller.eventBus.emitDeviceMessage({
type: "attributeReport",
device: controller.zigbee.resolveEntity("0xf1f1f1f1f1f1f1f1"),
endpoint: ZSpec.HA_ENDPOINT,
linkquality: 200,
groupID: 0,
cluster: "genOnOff",
data: {onOff: 1},
meta: {},
});
await settle(mockedGlobal);
},
{...BENCH_OPTIONS, setup, teardown},
);
bench(
"[defaults] receive MQTT message",
async () => {
const mockedGlobal = mockGlobalThis();
controller.mqtt.onMessage("zigbee2mqtt/0xf1f1f1f1f1f1f1f1/set", Buffer.from(`{"state": "OFF"}`, "utf8"));
await settle(mockedGlobal);
},
{...BENCH_OPTIONS, setup, teardown},
);
bench(
"[defaults] add group member",
async () => {
const mockedGlobal = mockGlobalThis();
controller.eventBus.emitMQTTMessage({
topic: "zigbee2mqtt/bridge/request/group/members/add",
message: stringify({
device: "0xf1f1f1f1f1f1f1f1",
group: `${ZH_GROUPS[0].groupID}`,
endpoint: ZSpec.HA_ENDPOINT,
}),
});
await settle(mockedGlobal);
if (ZH_GROUPS[0].members.length !== 1) {
throw new Error("Invalid state");
}
},
{...BENCH_OPTIONS, setup, teardown},
);
});
describe("defaults/stress runtime", () => {
const setup: NonNullable<Parameters<typeof bench>[2]>["setup"] = async (task, mode) => {
BENCH_OPTIONS.setup!(task, mode);
initDevices();
initGroups();
addManyDevices();
await initSettings();
await initController();
const mockedGlobal = mockGlobalThis();
await controller.start();
await settle(mockedGlobal);
};
const teardown = async () => {
await controller.stop();
unmockGlobalThis();
};
// this is mostly just to confirm the number of devices does not influence the processing (much)
bench(
"[defaults/stress] receive device message",
async () => {
const mockedGlobal = mockGlobalThis();
controller.eventBus.emitDeviceMessage({
type: "attributeReport",
device: controller.zigbee.resolveEntity(getMidDeviceIeee()),
endpoint: ZSpec.HA_ENDPOINT,
linkquality: 200,
groupID: 0,
cluster: "genOnOff",
data: {onOff: 1},
meta: {},
});
await settle(mockedGlobal);
},
{...BENCH_OPTIONS, setup, teardown},
);
});
describe("HA runtime", () => {
const setup: NonNullable<Parameters<typeof bench>[2]>["setup"] = async (task, mode) => {
BENCH_OPTIONS.setup!(task, mode);
initDevices();
initGroups();
await initSettings([[["homeassistant", "enabled"], true]]);
await initController();
const mockedGlobal = mockGlobalThis();
await controller.start();
controller.mqtt.onMessage("homeassistant/status", Buffer.from("online", "utf8"));
await settle(mockedGlobal);
};
const teardown = async () => {
await controller.stop();
unmockGlobalThis();
};
bench(
"[HA] receive device message",
async () => {
const mockedGlobal = mockGlobalThis();
controller.eventBus.emitDeviceMessage({
type: "attributeReport",
device: controller.zigbee.resolveEntity("0xf1f1f1f1f1f1f1f1"),
endpoint: ZSpec.HA_ENDPOINT,
linkquality: 200,
groupID: 0,
cluster: "genOnOff",
data: {onOff: 1},
meta: {},
});
await settle(mockedGlobal);
},
{...BENCH_OPTIONS, setup, teardown},
);
bench(
"[HA] receive MQTT message",
async () => {
const mockedGlobal = mockGlobalThis();
controller.mqtt.onMessage("zigbee2mqtt/0xf1f1f1f1f1f1f1f1/set", Buffer.from(`{"state": "OFF"}`, "utf8"));
await settle(mockedGlobal);
},
{...BENCH_OPTIONS, setup, teardown},
);
bench(
"[HA] receive MQTT discovery message",
async () => {
const mockedGlobal = mockGlobalThis();
controller.mqtt.onMessage(
"homeassistant/sensor/0xe2e2e2e2e2e2e2e2/update/config",
Buffer.from(stringify({availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}]}), "utf8"),
);
await settle(mockedGlobal);
},
{...BENCH_OPTIONS, setup, teardown},
);
});
});
+275 -23
View File
@@ -1,4 +1,5 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import * as data from "./mocks/data";
import {mockLogger} from "./mocks/logger";
import {
@@ -24,6 +25,7 @@ import {Controller as ZHController} from "zigbee-herdsman";
import {Controller} from "../lib/controller";
import type Device from "../lib/model/device";
import * as settings from "../lib/util/settings";
import Frontend from "../lib/extension/frontend";
const LOG_MQTT_NS = "z2m:mqtt";
@@ -53,9 +55,9 @@ const mocksClear = [
describe("Controller", () => {
let controller: Controller;
let mockExit: Mock;
let stopAfter = true;
const getZ2MDevice = (zhDevice: string | number | ZhDevice): Device => {
// @ts-expect-error private
return controller.zigbee.resolveEntity(zhDevice)! as Device;
};
@@ -64,6 +66,7 @@ describe("Controller", () => {
});
beforeEach(() => {
stopAfter = true;
returnDevices.splice(0);
mockExit = vi.fn();
data.writeDefaultConfiguration();
@@ -79,7 +82,10 @@ describe("Controller", () => {
});
afterEach(async () => {
await controller?.stop();
if (stopAfter) {
await controller?.stop();
}
await flushPromises();
});
@@ -106,12 +112,14 @@ describe("Controller", () => {
expect(mockLogger.info).toHaveBeenCalledWith(
"bulb (0x000b57fffec6a5b2): LED1545G12 - IKEA TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm (Router)",
);
expect(mockLogger.info).toHaveBeenCalledWith("remote (0x0017880104e45517): 324131092621 - Philips Hue dimmer switch (EndDevice)");
expect(mockLogger.info).toHaveBeenCalledWith("remote (0x0017880104e45517): 324131092621 - Philips Hue dimmer switch gen 1 (EndDevice)");
expect(mockLogger.info).toHaveBeenCalledWith("0x0017880104e45518 (0x0017880104e45518): Not supported (EndDevice)");
expect(mockMQTTConnectAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTConnectAsync).toHaveBeenCalledWith("mqtt://localhost", {
will: {payload: Buffer.from('{"state":"offline"}'), retain: true, topic: "zigbee2mqtt/bridge/state", qos: 1},
properties: {maximumPacketSize: 1048576},
keepalive: 60,
protocolVersion: 4,
});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bulb",
@@ -293,11 +301,9 @@ describe("Controller", () => {
await controller.start();
await flushPromises();
mockMQTTPublishAsync.mockClear();
// @ts-expect-error private
await controller.mqtt.publish("z2m/#/status", "empty");
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
expect(mockLogger.error).toHaveBeenCalledWith(`Topic 'z2m/#/status' includes wildcard characters, skipping publish.`);
// @ts-expect-error private
await controller.mqtt.publish("z2m/+/status", "empty");
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
expect(mockLogger.error).toHaveBeenCalledWith(`Topic 'z2m/+/status' includes wildcard characters, skipping publish.`);
@@ -323,11 +329,14 @@ describe("Controller", () => {
});
it("Should remove device on blocklist on startup", async () => {
settings.set(["blocklist"], [devices.bulb_color.ieeeAddr]);
settings.set(["blocklist"], [devices.bulb_color.ieeeAddr, "0x9998889998889990", "0x00124b00120144ae"]);
await controller.start();
await flushPromises();
expect(devices.bulb_color.removeFromNetwork).toHaveBeenCalledTimes(1);
expect(devices.bulb.removeFromNetwork).toHaveBeenCalledTimes(0);
expect(devices.coordinator.removeFromNetwork).toHaveBeenCalledTimes(0);
const debugCalls = mockLogger.debug.mock.calls.map((c) => (typeof c[0] === "string" ? c[0] : c[0]()));
expect(debugCalls.find((v) => v === "Ignoring blocklist device 0x9998889998889990, not currently on the network")).toBeDefined();
});
it("Start controller fails", async () => {
@@ -409,15 +418,262 @@ describe("Controller", () => {
delete process.env.NOTIFY_SOCKET;
});
describe("aborts during startup", () => {
let stateStartSpy: MockInstance<() => void>;
let zigbeeStartSpy: MockInstance<() => Promise<boolean>>;
let mqttConnectSpy: MockInstance<() => Promise<void>>;
let extensionStartSpy: MockInstance<() => Promise<void>>;
let publishEntityStateSpy: MockInstance<() => Promise<void>>;
let extensionStopSpy: MockInstance<() => Promise<void>>;
let mqttDisconnectSpy: MockInstance<() => Promise<void>>;
let mqttPublishSpy: MockInstance<() => Promise<void>>;
beforeEach(() => {
stateStartSpy = vi.spyOn(controller.state, "start");
zigbeeStartSpy = vi.spyOn(controller.zigbee, "start");
mqttConnectSpy = vi.spyOn(controller.mqtt, "connect");
const [firstExt] = controller.extensions;
extensionStartSpy = vi.spyOn(firstExt, "start");
publishEntityStateSpy = vi.spyOn(controller, "publishEntityState");
extensionStopSpy = vi.spyOn(firstExt, "stop");
mqttDisconnectSpy = vi.spyOn(controller.mqtt, "disconnect");
mqttPublishSpy = vi.spyOn(controller.mqtt, "publish");
});
afterEach(() => {
stopAfter = false;
stateStartSpy.mockRestore();
zigbeeStartSpy.mockRestore();
mqttConnectSpy.mockRestore();
extensionStartSpy.mockRestore();
publishEntityStateSpy.mockRestore();
extensionStopSpy.mockRestore();
mqttDisconnectSpy.mockRestore();
mqttPublishSpy.mockRestore();
});
it("during state start", async () => {
stateStartSpy.mockImplementationOnce(async () => {
await controller.stop(undefined, undefined, "SIGINT");
});
await controller.start();
await flushPromises();
expect(stateStartSpy).toHaveBeenCalledTimes(1);
expect(zigbeeStartSpy).toHaveBeenCalledTimes(0);
expect(mqttConnectSpy).toHaveBeenCalledTimes(0);
expect(extensionStartSpy).toHaveBeenCalledTimes(0);
expect(publishEntityStateSpy).toHaveBeenCalledTimes(0);
expect(extensionStopSpy).toHaveBeenCalledTimes(1);
expect(mqttDisconnectSpy).toHaveBeenCalledTimes(1);
expect(mqttPublishSpy).toHaveBeenCalledTimes(0);
});
it("during zigbee resolve definitions", async () => {
const resolveDevicesDefinitionsSpy = vi.spyOn(controller.zigbee, "resolveDevicesDefinitions");
resolveDevicesDefinitionsSpy.mockImplementationOnce(async (ignoreCache, abortSignal) => {
const device = controller.zigbee.resolveEntity(devices.bulb)! as Device;
device.resolveDefinition = vi.fn(async () => {
await controller.stop(undefined, undefined, "SIGINT");
});
return await controller.zigbee.resolveDevicesDefinitions(ignoreCache, abortSignal);
});
await controller.start();
await flushPromises();
expect(stateStartSpy).toHaveBeenCalledTimes(1);
expect(zigbeeStartSpy).toHaveBeenCalledTimes(1);
expect(mqttConnectSpy).toHaveBeenCalledTimes(0);
expect(extensionStartSpy).toHaveBeenCalledTimes(0);
expect(publishEntityStateSpy).toHaveBeenCalledTimes(0);
expect(extensionStopSpy).toHaveBeenCalledTimes(1);
expect(mqttDisconnectSpy).toHaveBeenCalledTimes(1);
expect(mqttPublishSpy).toHaveBeenCalledTimes(0);
});
it("during MQTT connect", async () => {
mqttConnectSpy.mockImplementationOnce(async () => {
await controller.stop(undefined, undefined, "SIGINT");
});
await controller.start();
await flushPromises();
expect(stateStartSpy).toHaveBeenCalledTimes(1);
expect(zigbeeStartSpy).toHaveBeenCalledTimes(1);
expect(mqttConnectSpy).toHaveBeenCalledTimes(1);
expect(extensionStartSpy).toHaveBeenCalledTimes(0);
expect(publishEntityStateSpy).toHaveBeenCalledTimes(0);
expect(extensionStopSpy).toHaveBeenCalledTimes(1);
expect(mqttDisconnectSpy).toHaveBeenCalledTimes(1);
expect(mqttPublishSpy).toHaveBeenCalledTimes(0);
});
it("during extension start", async () => {
extensionStartSpy.mockImplementationOnce(async () => {
await controller.stop(undefined, undefined, "SIGINT");
});
await controller.start();
await flushPromises();
expect(stateStartSpy).toHaveBeenCalledTimes(1);
expect(zigbeeStartSpy).toHaveBeenCalledTimes(1);
expect(mqttConnectSpy).toHaveBeenCalledTimes(1);
expect(extensionStartSpy).toHaveBeenCalledTimes(1);
expect(publishEntityStateSpy).toHaveBeenCalledTimes(0);
expect(extensionStopSpy).toHaveBeenCalledTimes(1);
expect(mqttDisconnectSpy).toHaveBeenCalledTimes(1);
expect(mqttPublishSpy).toHaveBeenCalledTimes(3);
});
it("during entity state publish", async () => {
publishEntityStateSpy.mockImplementationOnce(async () => {
await controller.stop(undefined, undefined, "SIGINT");
});
await controller.start();
await flushPromises();
expect(stateStartSpy).toHaveBeenCalledTimes(1);
expect(zigbeeStartSpy).toHaveBeenCalledTimes(1);
expect(mqttConnectSpy).toHaveBeenCalledTimes(1);
expect(extensionStartSpy).toHaveBeenCalledTimes(1);
expect(publishEntityStateSpy).toHaveBeenCalledTimes(1);
expect(extensionStopSpy).toHaveBeenCalledTimes(1);
expect(mqttDisconnectSpy).toHaveBeenCalledTimes(1);
expect(mqttPublishSpy).toHaveBeenCalledTimes(13);
});
it("without signal during state start", async () => {
stateStartSpy.mockImplementationOnce(async () => {
await controller.stop();
});
await controller.start();
await flushPromises();
expect(stateStartSpy).toHaveBeenCalledTimes(1);
expect(zigbeeStartSpy).toHaveBeenCalledTimes(0);
expect(mqttConnectSpy).toHaveBeenCalledTimes(0);
expect(extensionStartSpy).toHaveBeenCalledTimes(0);
expect(publishEntityStateSpy).toHaveBeenCalledTimes(0);
expect(extensionStopSpy).toHaveBeenCalledTimes(1);
expect(mqttDisconnectSpy).toHaveBeenCalledTimes(1);
expect(mqttPublishSpy).toHaveBeenCalledTimes(0);
});
it("without signal during MQTT connect", async () => {
mqttConnectSpy.mockImplementationOnce(async () => {
await controller.stop();
});
await controller.start();
await flushPromises();
expect(stateStartSpy).toHaveBeenCalledTimes(1);
expect(zigbeeStartSpy).toHaveBeenCalledTimes(1);
expect(mqttConnectSpy).toHaveBeenCalledTimes(1);
expect(extensionStartSpy).toHaveBeenCalledTimes(0);
expect(publishEntityStateSpy).toHaveBeenCalledTimes(0);
expect(extensionStopSpy).toHaveBeenCalledTimes(1);
expect(mqttDisconnectSpy).toHaveBeenCalledTimes(1);
expect(mqttPublishSpy).toHaveBeenCalledTimes(0);
});
it("during ZH Controller start", async () => {
mockZHController.start.mockImplementationOnce(async (abortSignal) => {
void controller.stop(undefined, undefined, "SIGINT");
abortSignal?.throwIfAborted();
return await Promise.resolve("resumed");
});
await controller.start();
await flushPromises();
expect(stateStartSpy).toHaveBeenCalledTimes(1);
expect(zigbeeStartSpy).toHaveBeenCalledTimes(1);
expect(mqttConnectSpy).toHaveBeenCalledTimes(0);
expect(extensionStartSpy).toHaveBeenCalledTimes(0);
expect(publishEntityStateSpy).toHaveBeenCalledTimes(0);
expect(extensionStopSpy).toHaveBeenCalledTimes(1);
expect(mqttDisconnectSpy).toHaveBeenCalledTimes(1);
expect(mqttPublishSpy).toHaveBeenCalledTimes(0);
});
it("during ZH Controller getNetworkParameters", async () => {
mockZHController.getNetworkParameters.mockImplementationOnce(async () => {
await controller.stop(undefined, undefined, "SIGINT");
return await Promise.resolve({panID: 0x162a, extendedPanID: "0x64c5fd698daf0c00", channel: 15, nwkUpdateID: 0});
});
await controller.start();
await flushPromises();
expect(stateStartSpy).toHaveBeenCalledTimes(1);
expect(zigbeeStartSpy).toHaveBeenCalledTimes(1);
expect(mqttConnectSpy).toHaveBeenCalledTimes(0);
expect(extensionStartSpy).toHaveBeenCalledTimes(0);
expect(publishEntityStateSpy).toHaveBeenCalledTimes(0);
expect(extensionStopSpy).toHaveBeenCalledTimes(1);
expect(mqttDisconnectSpy).toHaveBeenCalledTimes(1);
expect(mqttPublishSpy).toHaveBeenCalledTimes(0);
});
it("during ZH Controller passlist", async () => {
settings.set(["passlist"], [devices.bulb_color.ieeeAddr]);
devices.bulb.removeFromNetwork.mockImplementationOnce(async () => {
await controller.stop(undefined, undefined, "SIGINT");
});
await controller.start();
await flushPromises();
expect(stateStartSpy).toHaveBeenCalledTimes(1);
expect(zigbeeStartSpy).toHaveBeenCalledTimes(1);
expect(mqttConnectSpy).toHaveBeenCalledTimes(0);
expect(extensionStartSpy).toHaveBeenCalledTimes(0);
expect(publishEntityStateSpy).toHaveBeenCalledTimes(0);
expect(extensionStopSpy).toHaveBeenCalledTimes(1);
expect(mqttDisconnectSpy).toHaveBeenCalledTimes(1);
expect(mqttPublishSpy).toHaveBeenCalledTimes(0);
});
it("during ZH Controller blocklist", async () => {
settings.set(["blocklist"], [devices.bulb_color.ieeeAddr]);
devices.bulb_color.removeFromNetwork.mockImplementationOnce(async () => {
await controller.stop(undefined, undefined, "SIGINT");
});
await controller.start();
await flushPromises();
expect(stateStartSpy).toHaveBeenCalledTimes(1);
expect(zigbeeStartSpy).toHaveBeenCalledTimes(1);
expect(mqttConnectSpy).toHaveBeenCalledTimes(0);
expect(extensionStartSpy).toHaveBeenCalledTimes(0);
expect(publishEntityStateSpy).toHaveBeenCalledTimes(0);
expect(extensionStopSpy).toHaveBeenCalledTimes(1);
expect(mqttDisconnectSpy).toHaveBeenCalledTimes(1);
expect(mqttPublishSpy).toHaveBeenCalledTimes(0);
});
});
it("Start controller adapter disconnects", async () => {
mockZHController.stop.mockRejectedValueOnce("failed");
// Fail to stop extension exit code 1 should not override adapter disconnect exit code 2
vi.spyOn(Array.from(controller.extensions)[0], "stop").mockRejectedValueOnce(new Error("failed"));
await controller.start();
await mockZHEvents.adapterDisconnected();
await flushPromises();
expect(mockMQTTEndAsync).toHaveBeenCalledTimes(1);
expect(mockZHController.stop).toHaveBeenCalledTimes(1);
expect(mockExit).toHaveBeenCalledTimes(1);
expect(mockExit).toHaveBeenCalledWith(1, false);
expect(mockExit).toHaveBeenCalledWith(2, false);
});
it("does not throw when extension fails to stop on controller stop", async () => {
@@ -500,7 +756,6 @@ describe("Controller", () => {
});
it("Handle mqtt message", async () => {
// @ts-expect-error private
const spyEventbusEmitMQTTMessage = vi.spyOn(controller.eventBus, "emitMQTTMessage").mockImplementation(vi.fn());
await controller.start();
@@ -511,7 +766,6 @@ describe("Controller", () => {
});
it("Skip MQTT messages on topic we published to", async () => {
// @ts-expect-error private
const spyEventbusEmitMQTTMessage = vi.spyOn(controller.eventBus, "emitMQTTMessage").mockImplementation(vi.fn());
await controller.start();
@@ -519,7 +773,6 @@ describe("Controller", () => {
await mockMQTTEvents.message("zigbee2mqtt/skip-this-topic", "skipped");
expect(spyEventbusEmitMQTTMessage).toHaveBeenCalledWith({topic: "zigbee2mqtt/skip-this-topic", message: "skipped"});
mockLogger.debug.mockClear();
// @ts-expect-error private
await controller.mqtt.publish("skip-this-topic", "", {});
await mockMQTTEvents.message("zigbee2mqtt/skip-this-topic", "skipped");
expect(mockLogger.debug).toHaveBeenCalledTimes(0);
@@ -841,13 +1094,11 @@ describe("Controller", () => {
mockMQTTPublishAsync.mockClear();
const device = getZ2MDevice("bulb");
// @ts-expect-error private
expect(controller.state.get(device)).toStrictEqual({brightness: 50, color_temp: 370, linkquality: 99, state: "ON"});
await controller.publishEntityState(device, {state: "ON", brightness: 200, color_temp: 370, linkquality: 87});
await flushPromises();
// @ts-expect-error private
expect(controller.state.get(device)).toStrictEqual({brightness: 200, color_temp: 370, state: "ON"});
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(5);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb/state", "ON", {qos: 0, retain: true});
@@ -867,13 +1118,11 @@ describe("Controller", () => {
mockMQTTPublishAsync.mockClear();
const device = getZ2MDevice("bulb");
// @ts-expect-error private
expect(controller.state.get(device)).toStrictEqual({brightness: 50, color_temp: 370, linkquality: 99, state: "ON"});
await controller.publishEntityState(device, {state: "ON", brightness: 200, color_temp: 370, linkquality: 87});
await flushPromises();
// @ts-expect-error private
expect(controller.state.get(device)).toStrictEqual({brightness: 200, color_temp: 370, state: "ON"});
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(5);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb/state", "ON", {qos: 0, retain: true});
@@ -1045,6 +1294,8 @@ describe("Controller", () => {
expect(mockMQTTConnectAsync).toHaveBeenCalledTimes(1);
const expected = {
will: {payload: Buffer.from('{"state":"offline"}'), retain: false, topic: "zigbee2mqtt/bridge/state", qos: 1},
keepalive: 60,
protocolVersion: 4,
properties: {maximumPacketSize: 1048576},
};
expect(mockMQTTConnectAsync).toHaveBeenCalledWith("mqtt://localhost", expected);
@@ -1054,10 +1305,7 @@ describe("Controller", () => {
await controller.start();
await flushPromises();
const retainedMessages = Object.keys(
// @ts-expect-error private
controller.mqtt.retainedMessages,
).length;
const retainedMessages = Object.keys(controller.mqtt.retainedMessages).length;
mockMQTTPublishAsync.mockClear();
await vi.advanceTimersByTimeAsync(2500); // before any startup configure triggers
@@ -1078,7 +1326,6 @@ describe("Controller", () => {
it("Should prevent any message being published with retain flag when force_disable_retain is set", async () => {
settings.set(["mqtt", "force_disable_retain"], true);
// @ts-expect-error private
await controller.mqtt.connect();
mockMQTTPublishAsync.mockClear();
// @ts-expect-error private
@@ -1136,7 +1383,6 @@ describe("Controller", () => {
it("Should remove state of removed device when stopped", async () => {
await controller.start();
const device = getZ2MDevice("bulb");
// @ts-expect-error private
expect(controller.state.get(device)).toStrictEqual({brightness: 50, color_temp: 370, linkquality: 99, state: "ON"});
device.zh.isDeleted = true;
await controller.stop();
@@ -1145,7 +1391,6 @@ describe("Controller", () => {
});
it("EventBus should handle sync errors", async () => {
// @ts-expect-error private
const eventbus = controller.eventBus;
const callback = vi.fn().mockImplementation(() => {
throw new Error("Whoops!");
@@ -1158,7 +1403,6 @@ describe("Controller", () => {
});
it("EventBus should handle async errors", async () => {
// @ts-expect-error private
const eventbus = controller.eventBus;
const callback = vi.fn().mockRejectedValue(new Error("Whoops!"));
eventbus.onStateChange({constructor: {name: "Test"}}, callback);
@@ -1179,4 +1423,12 @@ describe("Controller", () => {
await controller.enableDisableExtension(false, "Availability");
}).rejects.toThrow("Built-in extension Availability cannot be disabled at runtime");
});
it("throws error when adding extension twice", async () => {
settings.set(["frontend", "enabled"], true);
await controller.start();
await expect(async () => {
await controller.addExtension(new Frontend(...controller.extensionArgs));
}).rejects.toThrow("Extension with name Frontend already present");
});
});
+1 -2
View File
@@ -1,7 +1,6 @@
import path from "node:path";
import tmp from "tmp";
import {describe, expect, it} from "vitest";
import data from "../lib/util/data";
describe("Data", () => {
+1 -2
View File
@@ -1,4 +1,5 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
@@ -281,9 +282,7 @@ describe("Extension: Availability", () => {
});
it("Should retrieve device state when it reconnects", async () => {
// @ts-expect-error private
const device = controller.zigbee.resolveEntity(devices.bulb_color.ieeeAddr);
// @ts-expect-error private
controller.state.set(device, {state: "OFF"});
const endpoint = devices.bulb_color.getEndpoint(1);
+86 -4
View File
@@ -1,4 +1,5 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import * as data from "../mocks/data";
import {mockDebounce} from "../mocks/debounce";
import {mockLogger} from "../mocks/logger";
@@ -10,6 +11,7 @@ import stringify from "json-stable-stringify-without-jsonify";
import {Controller} from "../../lib/controller";
import Bind from "../../lib/extension/bind";
import * as settings from "../../lib/util/settings";
import {DEFAULT_BIND_GROUP_ID} from "../../lib/util/utils";
const mocksClear = [
mockDebounce,
@@ -356,7 +358,7 @@ describe("Extension: Bind", () => {
{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(devices.bulb_color.meta.configured).toBe("0.0.0");
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/unbind",
stringify({
@@ -648,9 +650,9 @@ describe("Extension: Bind", () => {
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/unbind", stringify({from: "remote", to: target}));
await flushPromises();
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", 901);
expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", 901);
expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", 901);
expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", DEFAULT_BIND_GROUP_ID);
expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", DEFAULT_BIND_GROUP_ID);
expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", DEFAULT_BIND_GROUP_ID);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/unbind",
stringify({
@@ -793,4 +795,84 @@ describe("Extension: Bind", () => {
// Should only call Hue bulb, not e.g. tradfri
expect(devices.bulb_2.getEndpoint(1)!.read).toHaveBeenCalledTimes(0);
});
it("clears all bindings", async () => {
const device = devices.remote;
device.mockClear();
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/binds/clear", stringify({transaction: "1234", target: "remote"}));
await flushPromises();
expect(device.clearAllBindings).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/binds/clear",
stringify({
transaction: "1234",
data: {
target: "remote",
ieee_list: ["0xffffffffffffffff"],
},
status: "ok",
}),
{},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), {retain: true});
});
it("clears targeted bindings", async () => {
const device = devices.remote;
const target = devices.bulb_color;
device.mockClear();
mockMQTTEvents.message(
"zigbee2mqtt/bridge/request/device/binds/clear",
stringify({transaction: "1234", target: "remote", ieee_list: [target.ieeeAddr]}),
);
await flushPromises();
expect(device.clearAllBindings).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/binds/clear",
stringify({
transaction: "1234",
data: {
target: "remote",
ieee_list: [target.ieeeAddr],
},
status: "ok",
}),
{},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), {retain: true});
});
it("throw on invalid clears bindings payload", async () => {
const device = devices.remote;
device.mockClear();
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/binds/clear", stringify({targetz: "remote"}));
await flushPromises();
expect(device.clearAllBindings).toHaveBeenCalledTimes(0);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/binds/clear",
stringify({data: {}, status: "error", error: "Invalid payload"}),
{},
);
});
it("throw on invalid clears bindings target", async () => {
const device = devices.remote;
device.mockClear();
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/binds/clear", stringify({target: "remotez"}));
await flushPromises();
expect(device.clearAllBindings).toHaveBeenCalledTimes(0);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/binds/clear",
stringify({data: {}, status: "error", error: "Invalid target"}),
{},
);
});
});
+439 -37
View File
@@ -1,4 +1,6 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import {Zdo} from "zigbee-herdsman";
import * as data from "../mocks/data";
import {mockJSZipFile, mockJSZipGenerateAsync} from "../mocks/jszip";
import {mockLogger} from "../mocks/logger";
@@ -8,13 +10,15 @@ import {CUSTOM_CLUSTERS, devices, groups, mockController as mockZHController, ev
import assert from "node:assert";
import fs from "node:fs";
import {platform} from "node:os";
import path from "node:path";
import stringify from "json-stable-stringify-without-jsonify";
import type {Mock} from "vitest";
import {Controller} from "../../lib/controller";
import Bridge from "../../lib/extension/bridge";
import * as settings from "../../lib/util/settings";
import utils from "../../lib/util/utils";
import utils, {DEFAULT_BIND_GROUP_ID} from "../../lib/util/utils";
import {Zcl} from "zigbee-herdsman";
returnDevices.push(devices.coordinator.ieeeAddr);
returnDevices.push(devices.bulb.ieeeAddr);
@@ -160,6 +164,7 @@ describe("Extension: Bridge", () => {
"0x0017880104a44559": {friendly_name: "J1_cover"},
"0x0017880104e43559": {friendly_name: "U202DST600ZB"},
"0x0017880104e44559": {friendly_name: "3157100_thermostat"},
"0x18fc2600000d7ae3": {friendly_name: "bosch_rm230z"},
"0x0017880104e45517": {friendly_name: "remote", retain: true},
"0x0017880104e45520": {friendly_name: "button", retain: false},
"0x0017880104e45521": {
@@ -283,7 +288,7 @@ describe("Extension: Bridge", () => {
},
frontend: {
enabled: false,
package: "zigbee2mqtt-frontend",
package: "zigbee2mqtt-windfront",
port: 8080,
base_url: "/",
},
@@ -309,11 +314,15 @@ describe("Extension: Bridge", () => {
force_disable_retain: false,
include_device_information: false,
maximum_packet_size: 1048576,
keepalive: 60,
reject_unauthorized: true,
version: 4,
server: "mqtt://localhost",
},
ota: {
default_maximum_data_size: 50,
disable_automatic_update_check: false,
image_block_request_timeout: 150000,
image_block_response_delay: 250,
update_check_interval: 1440,
},
@@ -347,7 +356,7 @@ describe("Extension: Bridge", () => {
it("Should publish devices on startup", async () => {
await resetExtension();
// console.log(mockMQTTPublishAsync.mock.calls.find((c) => c[0] === 'zigbee2mqtt/bridge/devices')[1]);
// console.log(mockMQTTPublishAsync.mock.calls.find((c) => c[0] === "zigbee2mqtt/bridge/devices")[1]);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/devices",
stringify([
@@ -365,6 +374,7 @@ describe("Extension: Bridge", () => {
},
{
definition: {
source: "native",
description: "TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm",
exposes: [
{
@@ -539,6 +549,7 @@ describe("Extension: Bridge", () => {
property: "transition",
type: "numeric",
value_min: 0,
value_step: 0.1,
},
{
access: 2,
@@ -586,6 +597,7 @@ describe("Extension: Bridge", () => {
],
supports_ota: true,
vendor: "IKEA",
version: "0.0.0",
},
description: "this is my bulb",
disabled: false,
@@ -622,6 +634,7 @@ describe("Extension: Bridge", () => {
{
date_code: "2019.09",
definition: {
source: "native",
description: "Hue Go",
exposes: [
{
@@ -764,6 +777,7 @@ describe("Extension: Bridge", () => {
property: "transition",
type: "numeric",
value_min: 0,
value_step: 0.1,
},
{
access: 2,
@@ -789,6 +803,7 @@ describe("Extension: Bridge", () => {
],
supports_ota: true,
vendor: "Philips",
version: "0.0.0",
},
disabled: false,
endpoints: {
@@ -817,7 +832,8 @@ describe("Extension: Bridge", () => {
},
{
definition: {
description: "Hue dimmer switch",
source: "native",
description: "Hue dimmer switch gen 1",
exposes: [
{
access: 1,
@@ -916,10 +932,12 @@ describe("Extension: Bridge", () => {
],
supports_ota: true,
vendor: "Philips",
version: "0.0.0",
},
disabled: false,
endpoints: {
"1": {
name: "ep1",
bindings: [
{cluster: "genLevelCtrl", target: {endpoint: 1, ieee_address: "0x000b57fffec6a5b3", type: "endpoint"}},
{cluster: "genOnOff", target: {endpoint: 1, ieee_address: "0x000b57fffec6a5b3", type: "endpoint"}},
@@ -931,7 +949,13 @@ describe("Extension: Bridge", () => {
configured_reportings: [],
scenes: [],
},
"2": {bindings: [], clusters: {input: ["genBasic"], output: ["genOta", "genOnOff"]}, configured_reportings: [], scenes: []},
"2": {
name: "ep2",
bindings: [],
clusters: {input: ["genBasic"], output: ["genOta", "genOnOff"]},
configured_reportings: [],
scenes: [],
},
},
friendly_name: "remote",
ieee_address: "0x0017880104e45517",
@@ -946,6 +970,7 @@ describe("Extension: Bridge", () => {
},
{
definition: {
source: "generated",
description: "Automatically generated definition",
exposes: [
{
@@ -1016,6 +1041,7 @@ describe("Extension: Bridge", () => {
],
supports_ota: false,
vendor: "notSupportedMfg",
version: "0.0.0",
},
disabled: false,
endpoints: {
@@ -1040,6 +1066,7 @@ describe("Extension: Bridge", () => {
},
{
definition: {
source: "native",
description: "Wireless mini switch",
exposes: [
{
@@ -1115,10 +1142,12 @@ describe("Extension: Bridge", () => {
name: "device_temperature_calibration",
property: "device_temperature_calibration",
type: "numeric",
value_step: 0.1,
},
],
supports_ota: false,
vendor: "Aqara",
version: "0.0.0",
},
disabled: false,
endpoints: {
@@ -1150,6 +1179,7 @@ describe("Extension: Bridge", () => {
},
{
definition: {
source: "native",
description: "Temperature and humidity sensor",
exposes: [
{
@@ -1223,6 +1253,7 @@ describe("Extension: Bridge", () => {
name: "temperature_calibration",
property: "temperature_calibration",
type: "numeric",
value_step: 0.1,
},
{
access: 2,
@@ -1242,6 +1273,7 @@ describe("Extension: Bridge", () => {
name: "humidity_calibration",
property: "humidity_calibration",
type: "numeric",
value_step: 0.1,
},
{
access: 2,
@@ -1261,6 +1293,7 @@ describe("Extension: Bridge", () => {
name: "pressure_calibration",
property: "pressure_calibration",
type: "numeric",
value_step: 0.1,
},
{
access: 2,
@@ -1276,6 +1309,7 @@ describe("Extension: Bridge", () => {
],
supports_ota: false,
vendor: "Aqara",
version: "0.0.0",
},
disabled: false,
endpoints: {"1": {bindings: [], clusters: {input: ["genBasic"], output: []}, configured_reportings: [], scenes: []}},
@@ -1292,6 +1326,7 @@ describe("Extension: Bridge", () => {
},
{
definition: {
source: "native",
description: "Mi smart plug",
exposes: [
{
@@ -1371,6 +1406,7 @@ describe("Extension: Bridge", () => {
name: "power_calibration",
property: "power_calibration",
type: "numeric",
value_step: 0.1,
},
{
access: 2,
@@ -1390,6 +1426,7 @@ describe("Extension: Bridge", () => {
name: "energy_calibration",
property: "energy_calibration",
type: "numeric",
value_step: 0.1,
},
{
access: 2,
@@ -1409,6 +1446,7 @@ describe("Extension: Bridge", () => {
name: "device_temperature_calibration",
property: "device_temperature_calibration",
type: "numeric",
value_step: 0.1,
},
{
access: 2,
@@ -1423,6 +1461,7 @@ describe("Extension: Bridge", () => {
],
supports_ota: true,
vendor: "Xiaomi",
version: "0.0.0",
},
disabled: false,
endpoints: {"1": {bindings: [], clusters: {input: ["genBasic", "genOnOff"], output: []}, configured_reportings: [], scenes: []}},
@@ -1439,6 +1478,7 @@ describe("Extension: Bridge", () => {
},
{
definition: {
source: "native",
description: "zigfred plus smart in-wall switch",
exposes: [
{
@@ -1856,6 +1896,7 @@ describe("Extension: Bridge", () => {
property: "transition",
type: "numeric",
value_min: 0,
value_step: 0.1,
},
{
access: 2,
@@ -1880,46 +1921,54 @@ describe("Extension: Bridge", () => {
],
supports_ota: false,
vendor: "Siglis",
version: "0.0.0",
},
disabled: false,
endpoints: {
"10": {
name: "l5",
bindings: [],
clusters: {input: ["genBasic", "genScenes", "genOnOff", "genLevelCtrl"], output: []},
configured_reportings: [],
scenes: [],
},
"11": {
name: "l6",
bindings: [],
clusters: {input: ["genBasic", "genScenes", "closuresWindowCovering"], output: []},
configured_reportings: [],
scenes: [],
},
"12": {
name: "l7",
bindings: [],
clusters: {input: ["genBasic", "genScenes", "closuresWindowCovering"], output: []},
configured_reportings: [],
scenes: [],
},
"5": {
name: "l1",
bindings: [],
clusters: {input: ["genBasic", "genScenes", "genOnOff", "genLevelCtrl", "lightingColorCtrl"], output: []},
configured_reportings: [],
scenes: [],
},
"7": {
name: "l2",
bindings: [],
clusters: {input: ["genBasic", "genScenes", "genOnOff", "genLevelCtrl"], output: []},
configured_reportings: [],
scenes: [],
},
"8": {
name: "l3",
bindings: [],
clusters: {input: ["genBasic", "genScenes", "genOnOff", "genLevelCtrl"], output: []},
configured_reportings: [],
scenes: [],
},
"9": {
name: "l4",
bindings: [],
clusters: {input: ["genBasic", "genScenes", "genOnOff", "genLevelCtrl"], output: []},
configured_reportings: [],
@@ -1940,6 +1989,7 @@ describe("Extension: Bridge", () => {
},
{
definition: {
source: "native",
description: "TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm",
exposes: [
{
@@ -2114,6 +2164,7 @@ describe("Extension: Bridge", () => {
property: "transition",
type: "numeric",
value_min: 0,
value_step: 0.1,
},
{
access: 2,
@@ -2161,6 +2212,7 @@ describe("Extension: Bridge", () => {
],
supports_ota: true,
vendor: "IKEA",
version: "0.0.0",
},
disabled: false,
endpoints: {
@@ -2285,7 +2337,7 @@ describe("Extension: Bridge", () => {
scenes: [],
},
{friendly_name: "gledopto_group", id: 21, members: [{endpoint: 15, ieee_address: "0x0017880104e45724"}], scenes: []},
{friendly_name: "default_bind_group", id: 901, members: [], scenes: []},
{friendly_name: "default_bind_group", id: DEFAULT_BIND_GROUP_ID, members: [], scenes: []},
{
friendly_name: "ha_discovery_group",
id: 9,
@@ -2374,6 +2426,7 @@ describe("Extension: Bridge", () => {
stringify({
data: {
definition: {
source: "native",
description: "TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm",
exposes: [
{
@@ -2547,6 +2600,7 @@ describe("Extension: Bridge", () => {
property: "transition",
type: "numeric",
value_min: 0,
value_step: 0.1,
},
{
access: 2,
@@ -2594,6 +2648,7 @@ describe("Extension: Bridge", () => {
],
supports_ota: true,
vendor: "IKEA",
version: "0.0.0",
},
friendly_name: "bulb",
ieee_address: "0x000b57fffec6a5b2",
@@ -2609,6 +2664,7 @@ describe("Extension: Bridge", () => {
stringify({
data: {
definition: {
source: "generated",
description: "Automatically generated definition",
exposes: [
{
@@ -2679,6 +2735,7 @@ describe("Extension: Bridge", () => {
],
supports_ota: false,
vendor: "notSupportedMfg",
version: "0.0.0",
},
friendly_name: "0x0017880104e45518",
ieee_address: "0x0017880104e45518",
@@ -2934,11 +2991,13 @@ describe("Extension: Bridge", () => {
it("Should allow to remove group", async () => {
const group = groups.group_1;
const removeGroupFromLookup = vi.spyOn(controller.zigbee, "removeGroupFromLookup");
mockMQTTPublishAsync.mockClear();
mockMQTTEvents.message("zigbee2mqtt/bridge/request/group/remove", "group_1");
await flushPromises();
expect(group.removeFromNetwork).toHaveBeenCalledTimes(1);
expect(settings.getGroup("group_1")).toBeUndefined();
expect(removeGroupFromLookup).toHaveBeenCalledWith(1);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/groups", expect.any(String), expect.any(Object));
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/group/remove",
@@ -3144,7 +3203,6 @@ describe("Extension: Bridge", () => {
});
it("Should allow interviewing a device by ieeeAddr", async () => {
// @ts-expect-error private
const device = controller.zigbee.resolveEntity(devices.bulb)!;
assert("resolveDefinition" in device);
device.resolveDefinition = vi.fn();
@@ -3217,7 +3275,7 @@ describe("Extension: Bridge", () => {
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/interview",
stringify({data: {}, status: "error", error: "interview of 'bulb' (0x000b57fffec6a5b2) failed: Error: something went wrong"}),
stringify({data: {}, status: "error", error: "Interview of 'bulb' (0x000b57fffec6a5b2) failed: Error: something went wrong"}),
{},
);
});
@@ -3262,7 +3320,6 @@ describe("Extension: Bridge", () => {
" vendor: '',\n" +
" description: 'Automatically generated definition',\n" +
' extend: [m.onOff({"powerOnBehavior":false})],\n' +
" meta: {},\n" +
"};\n",
},
status: "ok",
@@ -3495,11 +3552,11 @@ describe("Extension: Bridge", () => {
it("Should allow to touchlink factory reset (succeeds)", async () => {
mockMQTTPublishAsync.mockClear();
mockZHController.touchlinkFactoryResetFirst.mockClear();
mockZHController.touchlinkFactoryResetFirst.mockReturnValueOnce(true);
mockZHController.touchlink.factoryResetFirst.mockClear();
mockZHController.touchlink.factoryResetFirst.mockReturnValueOnce(true);
mockMQTTEvents.message("zigbee2mqtt/bridge/request/touchlink/factory_reset", "");
await flushPromises();
expect(mockZHController.touchlinkFactoryResetFirst).toHaveBeenCalledTimes(1);
expect(mockZHController.touchlink.factoryResetFirst).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/touchlink/factory_reset",
stringify({data: {}, status: "ok"}),
@@ -3509,12 +3566,12 @@ describe("Extension: Bridge", () => {
it("Should allow to touchlink factory reset specific device", async () => {
mockMQTTPublishAsync.mockClear();
mockZHController.touchlinkFactoryReset.mockClear();
mockZHController.touchlinkFactoryReset.mockReturnValueOnce(true);
mockZHController.touchlink.factoryReset.mockClear();
mockZHController.touchlink.factoryReset.mockReturnValueOnce(true);
mockMQTTEvents.message("zigbee2mqtt/bridge/request/touchlink/factory_reset", stringify({ieee_address: "0x1239", channel: 12}));
await flushPromises();
expect(mockZHController.touchlinkFactoryReset).toHaveBeenCalledTimes(1);
expect(mockZHController.touchlinkFactoryReset).toHaveBeenCalledWith("0x1239", 12);
expect(mockZHController.touchlink.factoryReset).toHaveBeenCalledTimes(1);
expect(mockZHController.touchlink.factoryReset).toHaveBeenCalledWith("0x1239", 12);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/touchlink/factory_reset",
stringify({data: {ieee_address: "0x1239", channel: 12}, status: "ok"}),
@@ -3565,11 +3622,11 @@ describe("Extension: Bridge", () => {
it("Should allow to touchlink identify specific device", async () => {
mockMQTTPublishAsync.mockClear();
mockZHController.touchlinkIdentify.mockClear();
mockZHController.touchlink.identify.mockClear();
mockMQTTEvents.message("zigbee2mqtt/bridge/request/touchlink/identify", stringify({ieee_address: "0x1239", channel: 12}));
await flushPromises();
expect(mockZHController.touchlinkIdentify).toHaveBeenCalledTimes(1);
expect(mockZHController.touchlinkIdentify).toHaveBeenCalledWith("0x1239", 12);
expect(mockZHController.touchlink.identify).toHaveBeenCalledTimes(1);
expect(mockZHController.touchlink.identify).toHaveBeenCalledWith("0x1239", 12);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/touchlink/identify",
stringify({data: {ieee_address: "0x1239", channel: 12}, status: "ok"}),
@@ -3579,10 +3636,10 @@ describe("Extension: Bridge", () => {
it("Touchlink identify fails when payload is invalid", async () => {
mockMQTTPublishAsync.mockClear();
mockZHController.touchlinkIdentify.mockClear();
mockZHController.touchlink.identify.mockClear();
mockMQTTEvents.message("zigbee2mqtt/bridge/request/touchlink/identify", stringify({ieee_address: "0x1239"}));
await flushPromises();
expect(mockZHController.touchlinkIdentify).toHaveBeenCalledTimes(0);
expect(mockZHController.touchlink.identify).toHaveBeenCalledTimes(0);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/touchlink/identify",
stringify({data: {}, status: "error", error: "Invalid payload"}),
@@ -3592,11 +3649,11 @@ describe("Extension: Bridge", () => {
it("Should allow to touchlink factory reset (fails)", async () => {
mockMQTTPublishAsync.mockClear();
mockZHController.touchlinkFactoryResetFirst.mockClear();
mockZHController.touchlinkFactoryResetFirst.mockReturnValueOnce(false);
mockZHController.touchlink.factoryResetFirst.mockClear();
mockZHController.touchlink.factoryResetFirst.mockReturnValueOnce(false);
mockMQTTEvents.message("zigbee2mqtt/bridge/request/touchlink/factory_reset", "");
await flushPromises();
expect(mockZHController.touchlinkFactoryResetFirst).toHaveBeenCalledTimes(1);
expect(mockZHController.touchlink.factoryResetFirst).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/touchlink/factory_reset",
stringify({data: {}, status: "error", error: "Failed to factory reset device through Touchlink"}),
@@ -3606,14 +3663,14 @@ describe("Extension: Bridge", () => {
it("Should allow to touchlink scan", async () => {
mockMQTTPublishAsync.mockClear();
mockZHController.touchlinkScan.mockClear();
mockZHController.touchlinkScan.mockReturnValueOnce([
mockZHController.touchlink.scan.mockClear();
mockZHController.touchlink.scan.mockReturnValueOnce([
{ieeeAddr: "0x123", channel: 12},
{ieeeAddr: "0x124", channel: 24},
]);
mockMQTTEvents.message("zigbee2mqtt/bridge/request/touchlink/scan", "");
await flushPromises();
expect(mockZHController.touchlinkScan).toHaveBeenCalledTimes(1);
expect(mockZHController.touchlink.scan).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/touchlink/scan",
stringify({
@@ -3636,7 +3693,7 @@ describe("Extension: Bridge", () => {
endpoint.configureReporting.mockClear();
mockMQTTPublishAsync.mockClear();
mockMQTTEvents.message(
"zigbee2mqtt/bridge/request/device/configure_reporting",
"zigbee2mqtt/bridge/request/device/reporting/configure",
stringify({
id: "0x000b57fffec6a5b2",
endpoint: 1,
@@ -3657,7 +3714,7 @@ describe("Extension: Bridge", () => {
undefined,
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/configure_reporting",
"zigbee2mqtt/bridge/response/device/reporting/configure",
stringify({
data: {
id: "0x000b57fffec6a5b2",
@@ -3682,7 +3739,7 @@ describe("Extension: Bridge", () => {
endpoint.configureReporting.mockClear();
mockMQTTPublishAsync.mockClear();
mockMQTTEvents.message(
"zigbee2mqtt/bridge/request/device/configure_reporting",
"zigbee2mqtt/bridge/request/device/reporting/configure",
stringify({
id: "0x000b57fffec6a5b2",
endpoint: "1",
@@ -3703,7 +3760,7 @@ describe("Extension: Bridge", () => {
undefined,
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/configure_reporting",
"zigbee2mqtt/bridge/response/device/reporting/configure",
stringify({
data: {
id: "0x000b57fffec6a5b2",
@@ -3727,7 +3784,7 @@ describe("Extension: Bridge", () => {
endpoint.configureReporting.mockClear();
mockMQTTPublishAsync.mockClear();
mockMQTTEvents.message(
"zigbee2mqtt/bridge/request/device/configure_reporting",
"zigbee2mqtt/bridge/request/device/reporting/configure",
stringify({
id: "bulb",
// endpoint: '1',
@@ -3741,7 +3798,7 @@ describe("Extension: Bridge", () => {
await flushPromises();
expect(endpoint.configureReporting).toHaveBeenCalledTimes(0);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/configure_reporting",
"zigbee2mqtt/bridge/response/device/reporting/configure",
stringify({data: {}, status: "error", error: "Invalid payload"}),
{},
);
@@ -3753,7 +3810,7 @@ describe("Extension: Bridge", () => {
endpoint.configureReporting.mockClear();
mockMQTTPublishAsync.mockClear();
mockMQTTEvents.message(
"zigbee2mqtt/bridge/request/device/configure_reporting",
"zigbee2mqtt/bridge/request/device/reporting/configure",
stringify({
id: "non_existing_device",
endpoint: "1",
@@ -3767,7 +3824,7 @@ describe("Extension: Bridge", () => {
await flushPromises();
expect(endpoint.configureReporting).toHaveBeenCalledTimes(0);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/configure_reporting",
"zigbee2mqtt/bridge/response/device/reporting/configure",
stringify({data: {}, status: "error", error: "Device 'non_existing_device' does not exist"}),
{},
);
@@ -3779,7 +3836,7 @@ describe("Extension: Bridge", () => {
endpoint.configureReporting.mockClear();
mockMQTTPublishAsync.mockClear();
mockMQTTEvents.message(
"zigbee2mqtt/bridge/request/device/configure_reporting",
"zigbee2mqtt/bridge/request/device/reporting/configure",
stringify({
id: "0x000b57fffec6a5b2",
endpoint: "non_existing_endpoint",
@@ -3793,7 +3850,243 @@ describe("Extension: Bridge", () => {
await flushPromises();
expect(endpoint.configureReporting).toHaveBeenCalledTimes(0);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/configure_reporting",
"zigbee2mqtt/bridge/response/device/reporting/configure",
stringify({data: {}, status: "error", error: "Device '0x000b57fffec6a5b2' does not have endpoint 'non_existing_endpoint'"}),
{},
);
});
it("Should allow to read reporting config with endpoint as number", async () => {
const device = devices.bulb;
const endpoint = device.getEndpoint(1)!;
endpoint.bind.mockClear();
endpoint.readReportingConfig.mockClear();
endpoint.readReportingConfig.mockResolvedValueOnce([
{
status: Zcl.Status.SUCCESS,
direction: Zcl.Direction.CLIENT_TO_SERVER,
attrId: Zcl.Clusters.genLevelCtrl.attributes.currentLevel.ID,
dataType: Zcl.DataType.UINT8,
minRepIntval: 10,
maxRepIntval: 60,
repChange: 2,
},
]);
mockMQTTPublishAsync.mockClear();
mockMQTTEvents.message(
"zigbee2mqtt/bridge/request/device/reporting/read",
stringify({
id: "0x000b57fffec6a5b2",
endpoint: 1,
cluster: "genLevelCtrl",
configs: [{attribute: "currentLevel"}],
}),
);
await flushPromises();
expect(endpoint.readReportingConfig).toHaveBeenCalledTimes(1);
expect(endpoint.readReportingConfig).toHaveBeenCalledWith("genLevelCtrl", [{attribute: "currentLevel"}], {});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/reporting/read",
stringify({
data: {
id: "0x000b57fffec6a5b2",
endpoint: 1,
cluster: "genLevelCtrl",
configs: [
{
status: Zcl.Status.SUCCESS,
direction: Zcl.Direction.CLIENT_TO_SERVER,
attrId: Zcl.Clusters.genLevelCtrl.attributes.currentLevel.ID,
dataType: Zcl.DataType.UINT8,
minRepIntval: 10,
maxRepIntval: 60,
repChange: 2,
},
],
},
status: "ok",
}),
{},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), {retain: true});
});
it("Should allow to read reporting config with endpoint as string", async () => {
const device = devices.bulb;
const endpoint = device.getEndpoint(1)!;
endpoint.bind.mockClear();
endpoint.readReportingConfig.mockClear();
endpoint.readReportingConfig.mockResolvedValueOnce([
{
status: Zcl.Status.SUCCESS,
direction: Zcl.Direction.CLIENT_TO_SERVER,
attrId: Zcl.Clusters.genLevelCtrl.attributes.currentLevel.ID,
dataType: Zcl.DataType.UINT8,
minRepIntval: 10,
maxRepIntval: 60,
repChange: 2,
},
]);
mockMQTTPublishAsync.mockClear();
mockMQTTEvents.message(
"zigbee2mqtt/bridge/request/device/reporting/read",
stringify({
id: "0x000b57fffec6a5b2",
endpoint: "1",
cluster: "genLevelCtrl",
configs: [{attribute: "currentLevel"}],
}),
);
await flushPromises();
expect(endpoint.readReportingConfig).toHaveBeenCalledTimes(1);
expect(endpoint.readReportingConfig).toHaveBeenCalledWith("genLevelCtrl", [{attribute: "currentLevel"}], {});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/reporting/read",
stringify({
data: {
id: "0x000b57fffec6a5b2",
endpoint: "1",
cluster: "genLevelCtrl",
configs: [
{
status: Zcl.Status.SUCCESS,
direction: Zcl.Direction.CLIENT_TO_SERVER,
attrId: Zcl.Clusters.genLevelCtrl.attributes.currentLevel.ID,
dataType: Zcl.DataType.UINT8,
minRepIntval: 10,
maxRepIntval: 60,
repChange: 2,
},
],
},
status: "ok",
}),
{},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), {retain: true});
});
it("Should allow to read reporting config with manufacturer code", async () => {
const device = devices.bulb;
const endpoint = device.getEndpoint(1)!;
endpoint.bind.mockClear();
endpoint.readReportingConfig.mockClear();
endpoint.readReportingConfig.mockResolvedValueOnce([
{
status: Zcl.Status.SUCCESS,
direction: Zcl.Direction.CLIENT_TO_SERVER,
attrId: Zcl.Clusters.genLevelCtrl.attributes.currentLevel.ID,
dataType: Zcl.DataType.UINT8,
minRepIntval: 10,
maxRepIntval: 60,
repChange: 2,
},
]);
mockMQTTPublishAsync.mockClear();
mockMQTTEvents.message(
"zigbee2mqtt/bridge/request/device/reporting/read",
stringify({
id: "0x000b57fffec6a5b2",
endpoint: 1,
cluster: "genLevelCtrl",
configs: [{attribute: "currentLevel"}],
manufacturer_code: 0x1234,
}),
);
await flushPromises();
expect(endpoint.readReportingConfig).toHaveBeenCalledTimes(1);
expect(endpoint.readReportingConfig).toHaveBeenCalledWith("genLevelCtrl", [{attribute: "currentLevel"}], {manufacturerCode: 0x1234});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/reporting/read",
stringify({
data: {
id: "0x000b57fffec6a5b2",
endpoint: 1,
cluster: "genLevelCtrl",
configs: [
{
status: Zcl.Status.SUCCESS,
direction: Zcl.Direction.CLIENT_TO_SERVER,
attrId: Zcl.Clusters.genLevelCtrl.attributes.currentLevel.ID,
dataType: Zcl.DataType.UINT8,
minRepIntval: 10,
maxRepIntval: 60,
repChange: 2,
},
],
manufacturer_code: 0x1234,
},
status: "ok",
}),
{},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), {retain: true});
});
it("Should throw error when read reporting config is called with malformed payload", async () => {
const device = devices.bulb;
const endpoint = device.getEndpoint(1)!;
endpoint.readReportingConfig.mockClear();
mockMQTTPublishAsync.mockClear();
mockMQTTEvents.message(
"zigbee2mqtt/bridge/request/device/reporting/read",
stringify({
id: "bulb",
// endpoint: '1',
cluster: "genLevelCtrl",
configs: [{attribute: "currentLevel"}],
}),
);
await flushPromises();
expect(endpoint.readReportingConfig).toHaveBeenCalledTimes(0);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/reporting/read",
stringify({data: {}, status: "error", error: "Invalid payload"}),
{},
);
});
it("Should throw error when read reporting config is called for non-existing device", async () => {
const device = devices.bulb;
const endpoint = device.getEndpoint(1)!;
endpoint.readReportingConfig.mockClear();
mockMQTTPublishAsync.mockClear();
mockMQTTEvents.message(
"zigbee2mqtt/bridge/request/device/reporting/read",
stringify({
id: "non_existing_device",
endpoint: "1",
cluster: "genLevelCtrl",
configs: [{attribute: "currentLevel"}],
}),
);
await flushPromises();
expect(endpoint.readReportingConfig).toHaveBeenCalledTimes(0);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/reporting/read",
stringify({data: {}, status: "error", error: "Device 'non_existing_device' does not exist"}),
{},
);
});
it("Should throw error when read reporting config is called for non-existing endpoint", async () => {
const device = devices.bulb;
const endpoint = device.getEndpoint(1)!;
endpoint.readReportingConfig.mockClear();
mockMQTTPublishAsync.mockClear();
mockMQTTEvents.message(
"zigbee2mqtt/bridge/request/device/reporting/read",
stringify({
id: "0x000b57fffec6a5b2",
endpoint: "non_existing_endpoint",
cluster: "genLevelCtrl",
configs: [{attribute: "currentLevel"}],
}),
);
await flushPromises();
expect(endpoint.readReportingConfig).toHaveBeenCalledTimes(0);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/reporting/read",
stringify({data: {}, status: "error", error: "Device '0x000b57fffec6a5b2' does not have endpoint 'non_existing_endpoint'"}),
{},
);
@@ -3804,8 +4097,15 @@ describe("Extension: Bridge", () => {
fs.writeFileSync(path.join(data.mockDir, "ext_converters", "afile.js"), "test123");
fs.mkdirSync(path.join(data.mockDir, "log"));
fs.writeFileSync(path.join(data.mockDir, "log", "log.log"), "test123");
fs.mkdirSync(path.join(data.mockDir, "ota"));
fs.writeFileSync(path.join(data.mockDir, "ota", "my.ota"), "test123");
fs.mkdirSync(path.join(data.mockDir, "ext_converters", "123"));
fs.writeFileSync(path.join(data.mockDir, "ext_converters", "123", "myfile.js"), "test123");
fs.symlinkSync(
path.join(data.mockDir, "ext_converters"),
path.join(data.mockDir, "ext_converters_sym"),
platform() === "win32" ? "junction" : "dir",
);
mockMQTTPublishAsync.mockClear();
mockMQTTEvents.message("zigbee2mqtt/bridge/request/backup", "");
await flushPromises();
@@ -3843,6 +4143,15 @@ describe("Extension: Bridge", () => {
stringify({data: {restart_required: true}, status: "ok"}),
{},
);
// Change an option
await mockMQTTEvents.message(
"zigbee2mqtt/bridge/request/options",
stringify({options: {homeassistant: {enabled: true, experimental_event_entities: true}}}),
);
// @ts-expect-error - private property
await vi.waitUntil(() => controller.getExtension("HomeAssistant")?.experimentalEventEntities === true);
// revert
await mockMQTTEvents.message("zigbee2mqtt/bridge/request/options", stringify({options: {homeassistant: {enabled: false}}}));
await vi.waitUntil(() => controller.getExtension("HomeAssistant") === undefined);
@@ -4064,4 +4373,97 @@ describe("Extension: Bridge", () => {
});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/response/device/configure", expect.any(String), {});
});
it("triggers ZHC action by name with params", async () => {
mockMQTTPublishAsync.mockClear();
mockMQTTEvents.message(
"zigbee2mqtt/bridge/request/action",
JSON.stringify({
action: "raw",
params: {
profileId: Zdo.ZDO_PROFILE_ID,
ieeeAddress: "0xf1f2f3f4f5f6f7f8",
networkAddress: 0x1234,
clusterKey: Zdo.ClusterId.NETWORK_ADDRESS_REQUEST,
zdoParams: ["0xa1a2a3a4a5a6a7a8", false, 0],
},
}),
);
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/action",
JSON.stringify({data: [0x00, {assocDevList: [], eui64: "", nwkAddress: 0x1234, startIndex: 0}], status: "ok"}),
{},
);
});
it("triggers ZHC action by name without params", async () => {
mockMQTTPublishAsync.mockClear();
mockMQTTEvents.message(
"zigbee2mqtt/bridge/request/action",
JSON.stringify({
action: "raw",
}),
);
await flushPromises();
// we're mocking the response, so it's always this as long as the action & payload are valid
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/action",
JSON.stringify({data: [0x00, {assocDevList: [], eui64: "", nwkAddress: 0x1234, startIndex: 0}], status: "ok"}),
{},
);
});
it("throws on invalid action payload", async () => {
mockMQTTPublishAsync.mockClear();
mockMQTTEvents.message(
"zigbee2mqtt/bridge/request/action",
JSON.stringify({
params: {
profileId: Zdo.ZDO_PROFILE_ID,
ieeeAddress: "0xf1f2f3f4f5f6f7f8",
networkAddress: 0x1234,
clusterKey: Zdo.ClusterId.NETWORK_ADDRESS_REQUEST,
zdoParams: ["0xa1a2a3a4a5a6a7a8", false, 0],
},
}),
);
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/action",
stringify({data: {}, status: "error", error: "Invalid payload"}),
{},
);
});
it("throws on invalid action", async () => {
mockMQTTPublishAsync.mockClear();
mockMQTTEvents.message(
"zigbee2mqtt/bridge/request/action",
JSON.stringify({
action: "DOES_NOT_EXIST",
params: {
profileId: Zdo.ZDO_PROFILE_ID,
ieeeAddress: "0xf1f2f3f4f5f6f7f8",
networkAddress: 0x1234,
clusterKey: Zdo.ClusterId.NETWORK_ADDRESS_REQUEST,
zdoParams: ["0xa1a2a3a4a5a6a7a8", false, 0],
},
}),
);
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/action",
stringify({data: {}, status: "error", error: "Invalid action"}),
{},
);
});
});
+103 -18
View File
@@ -1,15 +1,18 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
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, devices, type Endpoint, events as mockZHEvents} from "../mocks/zigbeeHerdsman";
import {devices, type Endpoint, events as mockZHEvents, type Device as ZhDevice} from "../mocks/zigbeeHerdsman";
import stringify from "json-stable-stringify-without-jsonify";
import {InterviewState} from "zigbee-herdsman/dist/controller/model/device";
import {Controller} from "../../lib/controller";
import Device from "../../lib/model/device";
import Configure from "../../lib/extension/configure";
import * as settings from "../../lib/util/settings";
import assert from "node:assert";
const mocksClear = [mockMQTTPublishAsync, mockLogger.warning, mockLogger.debug];
@@ -22,12 +25,9 @@ describe("Extension: Configure", () => {
await controller.addExtension(new Configure(...controller.extensionArgs));
};
const mockClear = (device: Device): void => {
const mockClear = (device: ZhDevice): void => {
for (const endpoint of device.endpoints) {
endpoint.read.mockClear();
endpoint.write.mockClear();
endpoint.configureReporting.mockClear();
endpoint.bind.mockClear();
endpoint.mockClear();
}
};
@@ -41,7 +41,7 @@ describe("Extension: Configure", () => {
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(device.meta.configured).toBe(332242049);
expect(device.meta.configured).toBe("0.0.0");
};
const expectBulbConfigured = (): void => {
@@ -120,11 +120,9 @@ describe("Extension: Configure", () => {
it("Should reconfigure reporting on reconfigure event", async () => {
expectBulbConfigured();
// @ts-expect-error private
const device = controller.zigbee.resolveEntity(devices.bulb)!;
mockClear(device.zh);
expectBulbNotConfigured();
// @ts-expect-error private
controller.eventBus.emitReconfigure({device});
await flushPromises();
expectBulbConfigured();
@@ -148,6 +146,52 @@ describe("Extension: Configure", () => {
expectBulbConfigured();
});
it("Should re-configure when the version of the definition changes to a different value than the default (0.0.0)", async () => {
// Device is initially configured (definition has uses default version of 0.0.0)
const device = devices.bulb;
expectBulbConfigured();
// Nothing happens when receiving a Zigbee message
mockClear(device);
await mockZHEvents.lastSeenChanged({device});
await flushPromises();
expectBulbNotConfigured();
// Simulate that the definition version changes
const resolvedEntity = controller.zigbee.resolveEntity(device);
assert(resolvedEntity instanceof Device && resolvedEntity.definition);
assert(resolvedEntity.definition.version === "0.0.0");
try {
// Change the version to a different value
resolvedEntity.definition.version = "0.0.1";
// Now it should re-configure upon receiving a Zigbee message
mockClear(device);
await mockZHEvents.lastSeenChanged({device});
await flushPromises();
expectBulbConfigured();
} finally {
resolvedEntity.definition.version = "0.0.0";
}
});
it("Should NOT re-configure when version of the definition is 0.0.0", async () => {
// This test the migration from the old configureKey (hash of the configure function) to the new definition version system.
// See commment in `configure.ts` -> `shouldReconfigure` for more details.
// Device is initially configured (definition has uses default version of 0.0.0)
const device = devices.bulb;
expectBulbConfigured();
device.meta.configured = 1321;
// Nothing happens when receiving a Zigbee message
mockClear(device);
await mockZHEvents.lastSeenChanged({device});
await flushPromises();
expectBulbNotConfigured();
});
it("Should allow to configure via MQTT", async () => {
mockClear(devices.remote);
expectRemoteNotConfigured();
@@ -183,11 +227,11 @@ describe("Extension: Configure", () => {
});
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 mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/configure", stringify({id: "0x0017980134e45545", 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}),
stringify({data: {}, status: "error", error: "Device '0x0017980134e45545' cannot be configured", transaction: 20}),
{},
);
});
@@ -228,27 +272,68 @@ describe("Extension: Configure", () => {
});
it("Should configure max 3 times when fails", async () => {
// @ts-expect-error private
(controller.getExtension("Configure")! as Configure).attempts = {};
const device = devices.remote;
delete device.meta.configured;
const endpoint = device.getEndpoint(1)!;
mockClear(device);
endpoint.bind.mockRejectedValueOnce(new Error("BLA"));
endpoint.bind.mockRejectedValueOnce(new Error("BLA1"));
await mockZHEvents.lastSeenChanged({device});
await flushPromises();
endpoint.bind.mockRejectedValueOnce(new Error("BLA"));
endpoint.bind.mockRejectedValueOnce(new Error("BLA2"));
await mockZHEvents.lastSeenChanged({device});
await flushPromises();
endpoint.bind.mockRejectedValueOnce(new Error("BLA"));
endpoint.bind.mockRejectedValueOnce(new Error("BLA3"));
await mockZHEvents.lastSeenChanged({device});
await flushPromises();
endpoint.bind.mockRejectedValueOnce(new Error("BLA"));
await mockZHEvents.lastSeenChanged({device});
await flushPromises();
endpoint.bind.mockRejectedValueOnce(new Error("BLA"));
await mockZHEvents.lastSeenChanged({device});
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(3);
});
it("successfully configures after couple fails", async () => {
const device = devices.remote;
delete device.meta.configured;
const endpoint = device.getEndpoint(1)!;
mockClear(device);
endpoint.bind.mockRejectedValueOnce(new Error("BLA1"));
await mockZHEvents.lastSeenChanged({device});
await flushPromises();
endpoint.bind.mockRejectedValueOnce(new Error("BLA2"));
await mockZHEvents.lastSeenChanged({device});
await flushPromises();
await mockZHEvents.lastSeenChanged({device});
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(2 /* fails */ + 2 /* definition */);
});
it("successfully configures with force after fails", async () => {
const device = devices.remote;
delete device.meta.configured;
const endpoint = device.getEndpoint(1)!;
mockClear(device);
endpoint.bind.mockRejectedValueOnce(new Error("BLA1"));
await mockZHEvents.lastSeenChanged({device});
await flushPromises();
endpoint.bind.mockRejectedValueOnce(new Error("BLA2"));
await mockZHEvents.lastSeenChanged({device});
await flushPromises();
endpoint.bind.mockRejectedValueOnce(new Error("BLA3"));
await mockZHEvents.lastSeenChanged({device});
await flushPromises();
await mockZHEvents.lastSeenChanged({device});
await flushPromises();
await mockZHEvents.lastSeenChanged({device});
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(3);
endpoint.bind.mockRejectedValueOnce(new Error("BLA4"));
await mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/configure", stringify({id: device.ieeeAddr}));
await flushPromises();
await mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/configure", stringify({id: device.ieeeAddr}));
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(4 /* fails */ + 2 /* definition */);
});
});
+6 -4
View File
@@ -1,4 +1,5 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {mockMQTTEndAsync, mockMQTTPublishAsync} from "../mocks/mqtt";
@@ -49,7 +50,6 @@ describe("Extension: ExternalConverters", () => {
};
const getZ2MDevice = (zhDevice: string | number | ZhDevice): Device => {
// @ts-expect-error private
return controller.zigbee.resolveEntity(zhDevice)! as Device;
};
@@ -72,7 +72,6 @@ describe("Extension: ExternalConverters", () => {
beforeEach(async () => {
zhc.removeExternalDefinitions(); // remove all external converters
// @ts-expect-error private - clear cached
await controller.zigbee.resolveDevicesDefinitions(true);
for (const mock of mocksClear) mock.mockClear();
data.writeDefaultConfiguration();
@@ -82,10 +81,12 @@ describe("Extension: ExternalConverters", () => {
});
afterEach(async () => {
fs.rmSync(mockBasePath, {recursive: true, force: true});
await controller?.stop();
await flushPromises();
expect(fs.existsSync(path.join(mockBasePath, "node_modules"))).toStrictEqual(false);
fs.rmSync(mockBasePath, {recursive: true, force: true});
});
describe("from folder", () => {
@@ -348,6 +349,7 @@ describe("Extension: ExternalConverters", () => {
);
expect(fs.existsSync(filepath)).toStrictEqual(false);
expect(fs.existsSync(path.join(mockBasePath, "invalid.mjs.invalid"))).toStrictEqual(true);
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Parse failure: Expected ';', '}' or <eof>"));
});
});
+5 -2
View File
@@ -1,4 +1,5 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {mockMQTTEndAsync, mockMQTTPublishAsync} from "../mocks/mqtt";
@@ -67,10 +68,12 @@ describe("Extension: ExternalExtensions", () => {
});
afterEach(async () => {
fs.rmSync(mockBasePath, {recursive: true, force: true});
await controller?.stop();
await flushPromises();
expect(fs.existsSync(path.join(mockBasePath, "node_modules"))).toStrictEqual(false);
fs.rmSync(mockBasePath, {recursive: true, force: true});
});
describe("from folder", () => {
+15 -5
View File
@@ -1,4 +1,5 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
@@ -85,7 +86,7 @@ vi.mock("express-static-gzip", () => ({
}),
}));
vi.mock("zigbee2mqtt-frontend", () => ({
vi.mock("zigbee2mqtt-windfront", () => ({
default: {
getPath: (): string => frontendPath,
},
@@ -477,13 +478,22 @@ describe("Extension: Frontend", () => {
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);
});
it("disables serving", async () => {
settings.set(["frontend", "disable_ui_serving"], true);
controller = new Controller(vi.fn(), vi.fn());
await controller.start();
expect(mockHTTP.listen).toHaveBeenCalledTimes(0);
mockWS.clients.push(mockWSClient);
await controller.stop();
expect(mockWSClient.terminate).toHaveBeenCalledTimes(1);
expect(mockHTTP.close).toHaveBeenCalledTimes(0);
expect(mockWS.close).toHaveBeenCalledTimes(1);
});
});
+22 -3
View File
@@ -1,4 +1,5 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
@@ -44,7 +45,6 @@ describe("Extension: Groups", () => {
mockMQTTPublishAsync.mockClear();
groups.gledopto_group.command.mockClear();
zhcGlobalStore.clear();
// @ts-expect-error private
controller.state.clear();
});
@@ -459,7 +459,6 @@ describe("Extension: Groups", () => {
await mockMQTTEvents.message("zigbee2mqtt/group_1/set", stringify({state: "ON"}));
await flushPromises();
mockMQTTPublishAsync.mockClear();
// @ts-expect-error private
controller.state.clear();
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "OFF"}));
@@ -506,7 +505,7 @@ describe("Extension: Groups", () => {
expect(mockMQTTPublishAsync).not.toHaveBeenCalledWith("zigbee2mqtt/bridge/groups", expect.any(String), expect.any(Object));
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/group/members/add",
stringify({data: {}, status: "error", error: "Failed to add from group (timeout)"}),
stringify({data: {}, status: "error", error: "Failed to add to group (timeout)"}),
{},
);
});
@@ -591,6 +590,26 @@ describe("Extension: Groups", () => {
);
});
it("Remove from group via MQTT fails", async () => {
const device = devices.bulb_color;
const endpoint = device.getEndpoint(1)!;
const group = groups.group_1;
group.members.push(endpoint);
endpoint.removeFromGroup.mockImplementationOnce(() => {
throw new Error("timeout");
});
mockMQTTPublishAsync.mockClear();
mockMQTTEvents.message("zigbee2mqtt/bridge/request/group/members/remove", stringify({group: "group_1", device: "bulb_color"}));
await flushPromises();
expect(group.members.length).toStrictEqual(1);
expect(mockMQTTPublishAsync).not.toHaveBeenCalledWith("zigbee2mqtt/bridge/groups", expect.any(String), expect.any(Object));
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/group/members/remove",
stringify({data: {}, status: "error", error: "Failed to remove from group (timeout)"}),
{},
);
});
it("Remove from group via MQTT keeping device reporting", async () => {
const device = devices.bulb_color;
const endpoint = device.getEndpoint(1)!;
+1
View File
@@ -1,4 +1,5 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
+265 -21
View File
@@ -1,4 +1,5 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, beforeAll, beforeEach, describe, expect, it, test, vi} from "vitest";
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {events as mockMQTTEvents, mockMQTTPublishAsync, mockMQTTSubscribeAsync, mockMQTTUnsubscribeAsync} from "../mocks/mqtt";
@@ -55,7 +56,6 @@ describe("Extension: HomeAssistant", () => {
};
const getZ2MEntity = (zhDeviceOrGroup: string | number | ZhDevice): Device | Group => {
// @ts-expect-error private
return controller.zigbee.resolveEntity(zhDeviceOrGroup)!;
};
@@ -98,7 +98,7 @@ describe("Extension: HomeAssistant", () => {
for (const baseDefinition of await getZhcBaseDefinitions()) {
const d = zhc.prepareDefinition(baseDefinition);
const exposes = typeof d.exposes === "function" ? d.exposes(undefined, undefined) : d.exposes;
const exposes = typeof d.exposes === "function" ? d.exposes({isDummyDevice: true}, {}) : d.exposes;
const device = {
definition: d,
isDevice: (): boolean => true,
@@ -129,6 +129,7 @@ describe("Extension: HomeAssistant", () => {
it("Should discover devices and groups", async () => {
settings.set(["homeassistant", "experimental_event_entities"], true);
settings.set(["groups", "9", "homeassistant"], {name: "HA Discovery Group", icon: "mdi:lightbulb-group"});
await resetExtension();
let payload;
@@ -140,12 +141,13 @@ describe("Extension: HomeAssistant", () => {
command_topic: "zigbee2mqtt/ha_discovery_group/set",
device: {
identifiers: ["zigbee2mqtt_1221051039810110150109113116116_9"],
name: "ha_discovery_group",
name: "HA Discovery Group",
sw_version: version,
model: "Group",
manufacturer: "Zigbee2MQTT",
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
},
icon: "mdi:lightbulb-group",
max_mireds: 454,
min_mireds: 250,
name: null,
@@ -166,6 +168,7 @@ describe("Extension: HomeAssistant", () => {
"stop_hue_effect",
],
object_id: "ha_discovery_group",
default_entity_id: "light.ha_discovery_group",
unique_id: "9_light_zigbee2mqtt",
origin: origin,
};
@@ -190,6 +193,7 @@ describe("Extension: HomeAssistant", () => {
},
name: null,
object_id: "bulb_enddevice",
default_entity_id: "light.bulb_enddevice",
origin: origin,
schema: "json",
state_topic: "zigbee2mqtt/bulb_enddevice",
@@ -207,17 +211,19 @@ describe("Extension: HomeAssistant", () => {
command_topic: "zigbee2mqtt/ha_discovery_group/set",
device: {
identifiers: ["zigbee2mqtt_1221051039810110150109113116116_9"],
name: "ha_discovery_group",
name: "HA Discovery Group",
sw_version: version,
model: "Group",
manufacturer: "Zigbee2MQTT",
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
},
icon: "mdi:lightbulb-group",
name: null,
payload_off: "OFF",
payload_on: "ON",
state_topic: "zigbee2mqtt/ha_discovery_group",
object_id: "ha_discovery_group",
default_entity_id: "switch.ha_discovery_group",
unique_id: "9_switch_zigbee2mqtt",
origin: origin,
value_template: "{{ value_json.state }}",
@@ -236,6 +242,7 @@ describe("Extension: HomeAssistant", () => {
value_template: "{{ value_json.temperature }}",
state_topic: "zigbee2mqtt/weather_sensor",
object_id: "weather_sensor_temperature",
default_entity_id: "sensor.weather_sensor_temperature",
unique_id: "0x0017880104e45522_temperature_zigbee2mqtt",
origin: origin,
device: {
@@ -262,6 +269,7 @@ describe("Extension: HomeAssistant", () => {
value_template: "{{ value_json.humidity }}",
state_topic: "zigbee2mqtt/weather_sensor",
object_id: "weather_sensor_humidity",
default_entity_id: "sensor.weather_sensor_humidity",
unique_id: "0x0017880104e45522_humidity_zigbee2mqtt",
origin: origin,
enabled_by_default: true,
@@ -288,6 +296,7 @@ describe("Extension: HomeAssistant", () => {
value_template: "{{ value_json.pressure }}",
state_topic: "zigbee2mqtt/weather_sensor",
object_id: "weather_sensor_pressure",
default_entity_id: "sensor.weather_sensor_pressure",
unique_id: "0x0017880104e45522_pressure_zigbee2mqtt",
origin: origin,
enabled_by_default: true,
@@ -314,6 +323,7 @@ describe("Extension: HomeAssistant", () => {
value_template: "{{ value_json.battery }}",
state_topic: "zigbee2mqtt/weather_sensor",
object_id: "weather_sensor_battery",
default_entity_id: "sensor.weather_sensor_battery",
unique_id: "0x0017880104e45522_battery_zigbee2mqtt",
origin: origin,
enabled_by_default: true,
@@ -344,6 +354,7 @@ describe("Extension: HomeAssistant", () => {
state_topic: "zigbee2mqtt/weather_sensor",
name: "Linkquality",
object_id: "weather_sensor_linkquality",
default_entity_id: "sensor.weather_sensor_linkquality",
unique_id: "0x0017880104e45522_linkquality_zigbee2mqtt",
origin: origin,
device: {
@@ -378,6 +389,7 @@ describe("Extension: HomeAssistant", () => {
payload_on: "ON",
state_topic: "zigbee2mqtt/wall_switch_double",
object_id: "wall_switch_double_left",
default_entity_id: "switch.wall_switch_double_left",
unique_id: "0x0017880104e45542_switch_left_zigbee2mqtt",
origin: origin,
value_template: "{{ value_json.state_left }}",
@@ -404,6 +416,7 @@ describe("Extension: HomeAssistant", () => {
payload_on: "ON",
state_topic: "zigbee2mqtt/wall_switch_double",
object_id: "wall_switch_double_right",
default_entity_id: "switch.wall_switch_double_right",
unique_id: "0x0017880104e45542_switch_right_zigbee2mqtt",
origin: origin,
value_template: "{{ value_json.state_right }}",
@@ -436,6 +449,7 @@ describe("Extension: HomeAssistant", () => {
schema: "json",
state_topic: "zigbee2mqtt/bulb",
object_id: "bulb",
default_entity_id: "light.bulb",
unique_id: "0x000b57fffec6a5b2_light_zigbee2mqtt",
origin: origin,
};
@@ -459,6 +473,7 @@ describe("Extension: HomeAssistant", () => {
icon: "mdi:gesture-double-tap",
name: "Action",
object_id: "button_action",
default_entity_id: "event.button_action",
origin,
state_topic: "zigbee2mqtt/button",
unique_id: "0x0017880104e45520_action_zigbee2mqtt",
@@ -510,6 +525,7 @@ describe("Extension: HomeAssistant", () => {
value_template: "{{ value_json.humidity }}",
state_topic: "zigbee2mqtt/weather_sensor",
object_id: "weather_sensor_humidity",
default_entity_id: "sensor.weather_sensor_humidity",
unique_id: "0x0017880104e45522_humidity_zigbee2mqtt",
origin: origin,
enabled_by_default: true,
@@ -581,6 +597,7 @@ describe("Extension: HomeAssistant", () => {
value_template: "{{ value_json.temperature }}",
state_topic: "zigbee2mqtt/weather_sensor",
object_id: "weather_sensor_temperature",
default_entity_id: "sensor.weather_sensor_temperature",
unique_id: "0x0017880104e45522_temperature_zigbee2mqtt",
origin: origin,
device: {
@@ -606,6 +623,7 @@ describe("Extension: HomeAssistant", () => {
value_template: "{{ value_json.humidity }}",
state_topic: "zigbee2mqtt/weather_sensor",
object_id: "weather_sensor_humidity",
default_entity_id: "sensor.weather_sensor_humidity",
unique_id: "0x0017880104e45522_humidity_zigbee2mqtt",
origin: origin,
enabled_by_default: true,
@@ -633,6 +651,7 @@ describe("Extension: HomeAssistant", () => {
state_topic: "zigbee2mqtt/weather_sensor",
enabled_by_default: true,
object_id: "weather_sensor_pressure",
default_entity_id: "sensor.weather_sensor_pressure",
unique_id: "0x0017880104e45522_pressure_zigbee2mqtt",
origin: origin,
device: {
@@ -690,6 +709,7 @@ describe("Extension: HomeAssistant", () => {
state_topic: "zigbee2mqtt/weather_sensor",
enabled_by_default: true,
object_id: "weather_sensor_temperature",
default_entity_id: "sensor.weather_sensor_temperature",
unique_id: "0x0017880104e45522_temperature_zigbee2mqtt",
origin: origin,
device: {
@@ -731,6 +751,7 @@ describe("Extension: HomeAssistant", () => {
expire_after: 30,
icon: "mdi:test",
object_id: "weather_sensor_humidity",
default_entity_id: "sensor.weather_sensor_humidity",
};
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/sensor/0x0017880104e45522/humidity/config", stringify(payload), {
@@ -760,6 +781,7 @@ describe("Extension: HomeAssistant", () => {
value_template: "{{ value_json.temperature }}",
state_topic: "zigbee2mqtt/weather_sensor",
object_id: "weather_sensor_temperature",
default_entity_id: "sensor.weather_sensor_temperature",
unique_id: "0x0017880104e45522_temperature_zigbee2mqtt",
origin: origin,
device: {
@@ -786,6 +808,7 @@ describe("Extension: HomeAssistant", () => {
value_template: "{{ value_json.humidity }}",
state_topic: "zigbee2mqtt/weather_sensor",
object_id: "weather_sensor_humidity",
default_entity_id: "sensor.weather_sensor_humidity",
unique_id: "0x0017880104e45522_humidity_zigbee2mqtt",
origin: origin,
enabled_by_default: true,
@@ -813,6 +836,7 @@ describe("Extension: HomeAssistant", () => {
switch: {
type: "light",
object_id: "light",
default_entity_id: "light.light",
},
light: {
type: "this should be ignored",
@@ -841,6 +865,7 @@ describe("Extension: HomeAssistant", () => {
payload_on: "ON",
state_topic: "zigbee2mqtt/my_switch",
object_id: "my_switch",
default_entity_id: "light.my_switch",
unique_id: "0x0017880104e45541_light_zigbee2mqtt",
origin: origin,
value_template: "{{ value_json.state }}",
@@ -899,6 +924,7 @@ describe("Extension: HomeAssistant", () => {
speed_range_max: 4,
name: null,
object_id: "fan",
default_entity_id: "fan.fan",
unique_id: "0x0017880104e45548_fan_zigbee2mqtt",
origin: origin,
device: {
@@ -918,6 +944,45 @@ describe("Extension: HomeAssistant", () => {
});
});
it("Should discover siren (HS2WD-E)", () => {
const payload = {
available_tones: ["emergency"],
support_duration: true,
optimistic: true,
command_topic: "zigbee2mqtt/siren/set",
command_template:
'{"warning": {"mode": "{{ tone | default(\'emergency\') }}", ' +
'"level": "' +
"{% if volume_level is defined %}" +
"{% if volume_level | float <= 0.25 %}low" +
"{% elif volume_level | float <= 0.5 %}medium" +
"{% elif volume_level | float <= 0.75 %}high" +
"{% else %}very_high{% endif %}" +
'{% else %}medium{% endif %}", ' +
'"duration": {{ duration | default(10) }}}}',
command_off_template: '{"warning": {"mode": "stop"}}',
name: null,
object_id: "siren",
default_entity_id: "siren.siren",
unique_id: "0x0017880104e45549_siren_zigbee2mqtt",
origin: origin,
device: {
identifiers: ["zigbee2mqtt_0x0017880104e45549"],
name: "siren",
model: "Smart siren",
model_id: "HS2WD-E",
manufacturer: "Heiman",
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
},
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
};
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/siren/0x0017880104e45549/siren/config", stringify(payload), {
retain: true,
qos: 1,
});
});
it("Should discover devices with speed-controlled fan", () => {
const payload = {
state_topic: "zigbee2mqtt/fanbee",
@@ -931,6 +996,7 @@ describe("Extension: HomeAssistant", () => {
speed_range_max: 254,
name: null,
object_id: "fanbee",
default_entity_id: "fan.fanbee",
unique_id: "0x00124b00cfcf3298_fan_zigbee2mqtt",
origin: origin,
device: {
@@ -992,6 +1058,7 @@ describe("Extension: HomeAssistant", () => {
temperature_state_topic: "zigbee2mqtt/TS0601_thermostat",
temperature_unit: "C",
object_id: "ts0601_thermostat",
default_entity_id: "climate.ts0601_thermostat",
unique_id: "0x0017882104a44559_climate_zigbee2mqtt",
origin: origin,
};
@@ -1002,6 +1069,77 @@ describe("Extension: HomeAssistant", () => {
});
});
it("Should discover thermostat devices with read-only PI heating demand", () => {
const payload = {
availability: [
{
topic: "zigbee2mqtt/bridge/state",
value_template: "{{ value_json.state }}",
},
],
default_entity_id: "sensor.thermostat_pi_heating_demand",
device: {
identifiers: ["zigbee2mqtt_0x0017880104e45550"],
manufacturer: "eCozy",
model: "Smart heating thermostat",
model_id: "1TST-EU",
name: "thermostat",
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
},
entity_category: "diagnostic",
icon: "mdi:radiator",
name: "PI heating demand",
object_id: "thermostat_pi_heating_demand",
origin: origin,
state_topic: "zigbee2mqtt/thermostat",
unique_id: "0x0017880104e45550_pi_heating_demand_zigbee2mqtt",
unit_of_measurement: "%",
value_template: "{{ value_json.pi_heating_demand }}",
};
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/sensor/0x0017880104e45550/pi_heating_demand/config", stringify(payload), {
retain: true,
qos: 1,
});
});
it("Should discover thermostat devices with writable PI heating demand", () => {
const payload = {
availability: [
{
topic: "zigbee2mqtt/bridge/state",
value_template: "{{ value_json.state }}",
},
],
command_topic: "zigbee2mqtt/bosch_radiator/set/pi_heating_demand",
default_entity_id: "number.bosch_radiator_pi_heating_demand",
device: {
identifiers: ["zigbee2mqtt_0x18fc2600000d7ae2"],
manufacturer: "Bosch",
model: "Radiator thermostat II",
model_id: "BTH-RA",
name: "bosch_radiator",
sw_version: "3.05.09",
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
},
icon: "mdi:radiator",
max: 100,
min: 0,
name: "PI heating demand",
object_id: "bosch_radiator_pi_heating_demand",
origin: origin,
state_topic: "zigbee2mqtt/bosch_radiator",
unique_id: "0x18fc2600000d7ae2_pi_heating_demand_zigbee2mqtt",
unit_of_measurement: "%",
value_template: "{{ value_json.pi_heating_demand }}",
};
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/number/0x18fc2600000d7ae2/pi_heating_demand/config", stringify(payload), {
retain: true,
qos: 1,
});
});
it("Should discover Bosch BTH-RA with a compatibility mapping", () => {
const payload = {
action_template:
@@ -1029,6 +1167,7 @@ describe("Extension: HomeAssistant", () => {
modes: ["off", "heat", "auto"],
name: null,
object_id: "bosch_radiator",
default_entity_id: "climate.bosch_radiator",
origin: origin,
temp_step: 0.5,
temperature_command_topic: "zigbee2mqtt/bosch_radiator/set/occupied_heating_setpoint",
@@ -1081,6 +1220,7 @@ describe("Extension: HomeAssistant", () => {
modes: ["heat"],
name: null,
object_id: "bosch_radiator",
default_entity_id: "climate.bosch_radiator",
origin: origin,
temp_step: 0.5,
temperature_command_topic: "zigbee2mqtt/bosch_radiator/set/occupied_heating_setpoint",
@@ -1099,6 +1239,82 @@ describe("Extension: HomeAssistant", () => {
overrideSpy.mockRestore();
});
it("Should discover Bosch BTH-RM230Z with a current_humidity attribute", () => {
const payload = {
action_template:
"{% set values = {None:None,'idle':'idle','heat':'heating','cool':'cooling','fan_only':'fan'} %}{{ values[value_json.running_state] }}",
action_topic: "zigbee2mqtt/bosch_rm230z",
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
current_humidity_template: "{{ value_json.humidity }}",
current_humidity_topic: "zigbee2mqtt/bosch_rm230z",
current_temperature_template: "{{ value_json.local_temperature }}",
current_temperature_topic: "zigbee2mqtt/bosch_rm230z",
default_entity_id: "climate.bosch_rm230z",
device: {
identifiers: ["zigbee2mqtt_0x18fc2600000d7ae3"],
manufacturer: "Bosch",
model: "Room thermostat II 230V",
model_id: "BTH-RM230Z",
name: "bosch_rm230z",
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
},
max_temp: "30",
min_temp: "5",
mode_command_topic: "zigbee2mqtt/bosch_rm230z/set",
mode_state_template:
"{% set values = {'schedule':'auto','manual':'heat','pause':'off'} %}{% set value = value_json.operating_mode %}{% if value == \"manual\" %}{{ value_json.system_mode }}{% else %}{{ values[value] if value in values.keys() else 'off' }}{% endif %}",
mode_command_template:
"{% set values = { 'auto':'schedule','heat':'manual','cool':'manual','off':'pause'} %}{% if value == \"heat\" or value == \"cool\" %}{\"operating_mode\": \"manual\", \"system_mode\": \"{{ value }}\"}{% else %}{\"operating_mode\": \"{{ values[value] if value in values.keys() else 'pause' }}\"}{% endif %}",
mode_state_topic: "zigbee2mqtt/bosch_rm230z",
modes: ["off", "heat", "cool", "auto"],
name: null,
object_id: "bosch_rm230z",
origin,
temp_step: 0.5,
temperature_high_command_topic: "zigbee2mqtt/bosch_rm230z/set/occupied_cooling_setpoint",
temperature_high_state_template: "{{ value_json.occupied_cooling_setpoint }}",
temperature_high_state_topic: "zigbee2mqtt/bosch_rm230z",
temperature_low_command_topic: "zigbee2mqtt/bosch_rm230z/set/occupied_heating_setpoint",
temperature_low_state_template: "{{ value_json.occupied_heating_setpoint }}",
temperature_low_state_topic: "zigbee2mqtt/bosch_rm230z",
temperature_unit: "C",
unique_id: "0x18fc2600000d7ae3_climate_zigbee2mqtt",
};
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/climate/0x18fc2600000d7ae3/climate/config", stringify(payload), {
qos: 1,
retain: true,
});
});
it("Should discover seperate temperature sensor for thermostat", () => {
const payload = {
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
default_entity_id: "sensor.bosch_rm230z_local_temperature",
device: {
identifiers: ["zigbee2mqtt_0x18fc2600000d7ae3"],
manufacturer: "Bosch",
model: "Room thermostat II 230V",
model_id: "BTH-RM230Z",
name: "bosch_rm230z",
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
},
device_class: "temperature",
object_id: "bosch_rm230z_local_temperature",
origin,
state_class: "measurement",
state_topic: "zigbee2mqtt/bosch_rm230z",
unique_id: "0x18fc2600000d7ae3_local_temperature_zigbee2mqtt",
unit_of_measurement: "°C",
value_template: "{{ value_json.local_temperature }}",
};
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/sensor/0x18fc2600000d7ae3/local_temperature/config", stringify(payload), {
qos: 1,
retain: true,
});
});
it("Should discover devices with cover_position", () => {
let payload;
@@ -1115,6 +1331,7 @@ describe("Extension: HomeAssistant", () => {
state_stopped: "STOP",
name: null,
object_id: "smart_vent",
default_entity_id: "cover.smart_vent",
unique_id: "0x0017880104e45551_cover_zigbee2mqtt",
origin: origin,
device: {
@@ -1157,6 +1374,7 @@ describe("Extension: HomeAssistant", () => {
tilt_status_template: "{{ value_json.tilt }}",
tilt_status_topic: "zigbee2mqtt/zigfred_plus/l6",
object_id: "zigfred_plus_l6",
default_entity_id: "cover.zigfred_plus_l6",
unique_id: "0xf4ce368a38be56a1_cover_l6_zigbee2mqtt",
origin: origin,
value_template: "{{ value_json.state }}",
@@ -1179,14 +1397,15 @@ describe("Extension: HomeAssistant", () => {
command_topic: "zigbee2mqtt/0xa4c138018cf95021/left/set",
device: {
identifiers: ["zigbee2mqtt_0xa4c138018cf95021"],
manufacturer: "Lonsonho",
model: "Dual curtain/blind module",
model_id: "TS130F_dual",
manufacturer: "Girier",
model: "Dual smart curtain switch",
model_id: "TS130F_GIRIER_DUAL",
name: "0xa4c138018cf95021",
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
},
name: "Left",
object_id: "0xa4c138018cf95021_left",
default_entity_id: "cover.0xa4c138018cf95021_left",
origin: origin,
position_template: "{{ value_json.position }}",
position_topic: "zigbee2mqtt/0xa4c138018cf95021/left",
@@ -1209,14 +1428,15 @@ describe("Extension: HomeAssistant", () => {
command_topic: "zigbee2mqtt/0xa4c138018cf95021/right/set",
device: {
identifiers: ["zigbee2mqtt_0xa4c138018cf95021"],
manufacturer: "Lonsonho",
model: "Dual curtain/blind module",
model_id: "TS130F_dual",
manufacturer: "Girier",
model: "Dual smart curtain switch",
model_id: "TS130F_GIRIER_DUAL",
name: "0xa4c138018cf95021",
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
},
name: "Right",
object_id: "0xa4c138018cf95021_right",
default_entity_id: "cover.0xa4c138018cf95021_right",
origin: origin,
position_template: "{{ value_json.position }}",
position_topic: "zigbee2mqtt/0xa4c138018cf95021/right",
@@ -1230,6 +1450,8 @@ describe("Extension: HomeAssistant", () => {
value_template: '{% if "moving" in value_json and value_json.moving %} {{ value_json.moving }} {% else %} STOP {% endif %}',
};
console.log(mockMQTTPublishAsync.mock.calls.find((c) => c[0] === "homeassistant/cover/0xa4c138018cf95021/cover_left/config"));
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/cover/0xa4c138018cf95021/cover_left/config", stringify(payload_left), {
retain: true,
qos: 1,
@@ -1252,6 +1474,7 @@ describe("Extension: HomeAssistant", () => {
state_topic: "zigbee2mqtt/weather_sensor",
enabled_by_default: true,
object_id: "weather_sensor_temperature",
default_entity_id: "sensor.weather_sensor_temperature",
unique_id: "0x0017880104e45522_temperature_zigbee2mqtt",
origin: origin,
device: {
@@ -1430,6 +1653,7 @@ describe("Extension: HomeAssistant", () => {
value_template: "{{ value_json.temperature }}",
state_topic: "zigbee2mqtt/weather_sensor",
object_id: "weather_sensor_temperature",
default_entity_id: "sensor.weather_sensor_temperature",
unique_id: "0x0017880104e45522_temperature_zigbee2mqtt",
origin: origin,
device: {
@@ -1464,7 +1688,6 @@ describe("Extension: HomeAssistant", () => {
assert("ieeeAddr" in device);
resetDiscoveryPayloads(device.ieeeAddr);
mockMQTTPublishAsync.mockClear();
// @ts-expect-error private
controller.eventBus.emitEntityOptionsChanged({entity: device, from: {}, to: {test: 123}});
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(`homeassistant/light/${device.ID}/light/config`, expect.any(String), expect.any(Object));
@@ -1591,6 +1814,7 @@ describe("Extension: HomeAssistant", () => {
value_template: "{{ value_json.temperature }}",
state_topic: "zigbee2mqtt/weather_sensor",
object_id: "weather_sensor_temperature",
default_entity_id: "sensor.weather_sensor_temperature",
unique_id: "0x0017880104e45522_temperature_zigbee2mqtt",
origin: origin,
device: {
@@ -1660,6 +1884,7 @@ describe("Extension: HomeAssistant", () => {
value_template: "{{ value_json.temperature }}",
state_topic: "zigbee2mqtt/weather_sensor_renamed",
object_id: "weather_sensor_renamed_temperature",
default_entity_id: "sensor.weather_sensor_renamed_temperature",
origin: origin,
unique_id: "0x0017880104e45522_temperature_zigbee2mqtt",
device: {
@@ -1745,6 +1970,7 @@ describe("Extension: HomeAssistant", () => {
"stop_hue_effect",
],
object_id: "ha_discovery_group_new",
default_entity_id: "light.ha_discovery_group_new",
unique_id: "9_light_zigbee2mqtt",
origin: origin,
};
@@ -1781,6 +2007,7 @@ describe("Extension: HomeAssistant", () => {
value_template: "{{ value_json.temperature }}",
state_topic: "zigbee2mqtt/weather_sensor_renamed",
object_id: "weather_sensor_renamed_temperature",
default_entity_id: "sensor.weather_sensor_renamed_temperature",
unique_id: "0x0017880104e45522_temperature_zigbee2mqtt",
origin: origin,
device: {
@@ -1817,6 +2044,7 @@ describe("Extension: HomeAssistant", () => {
entity_picture: "https://github.com/Koenkk/zigbee2mqtt/raw/master/images/logo.png",
name: null,
object_id: "bulb",
default_entity_id: "update.bulb",
origin,
payload_install: `{"id": "0x000b57fffec6a5b2"}`,
state_topic: "zigbee2mqtt/bulb",
@@ -1980,6 +2208,7 @@ describe("Extension: HomeAssistant", () => {
icon: "mdi:gesture-double-tap",
name: "Action",
object_id: "button_action",
default_entity_id: "event.button_action",
origin: origin,
state_topic: "zigbee2mqtt/button",
unique_id: "0x0017880104e45520_action_zigbee2mqtt",
@@ -2210,6 +2439,7 @@ describe("Extension: HomeAssistant", () => {
"stop_hue_effect",
],
object_id: "ha_discovery_group",
default_entity_id: "light.ha_discovery_group",
unique_id: "9_light_zigbee2mqtt",
origin: origin,
};
@@ -2254,6 +2484,7 @@ describe("Extension: HomeAssistant", () => {
"stop_hue_effect",
],
object_id: "ha_discovery_group",
default_entity_id: "light.ha_discovery_group",
unique_id: "9_light_zigbee2mqtt",
origin: origin,
};
@@ -2296,6 +2527,7 @@ describe("Extension: HomeAssistant", () => {
state_topic: "zigbee2mqtt/bulb",
supported_color_modes: ["color_temp"],
object_id: "bulb",
default_entity_id: "light.bulb",
unique_id: "0x000b57fffec6a5b2_light_zigbee2mqtt",
origin: origin,
};
@@ -2330,6 +2562,7 @@ describe("Extension: HomeAssistant", () => {
name: "Last seen",
state_topic: "zigbee2mqtt/bulb",
object_id: "bulb_last_seen",
default_entity_id: "sensor.bulb_last_seen",
unique_id: "0x000b57fffec6a5b2_last_seen_zigbee2mqtt",
origin: origin,
value_template: "{{ value_json.last_seen }}",
@@ -2343,7 +2576,12 @@ describe("Extension: HomeAssistant", () => {
});
});
it("Should discover devices with configuration url", async () => {
it.each([
// Windfront includes the instance ID in the URL.
["zigbee2mqtt-windfront", "http://zigbee.mqtt/#/device/0/0x0017880104e45522/info"],
["zigbee2mqtt-frontend", "http://zigbee.mqtt/#/device/0x0017880104e45522/info"],
])("Should discover devices with configuration url (%s)", async (packageName: string, expectedUrl: string) => {
settings.set(["frontend", "package"], packageName);
settings.set(["frontend", "url"], "http://zigbee.mqtt");
await resetExtension();
@@ -2357,6 +2595,7 @@ describe("Extension: HomeAssistant", () => {
value_template: "{{ value_json.temperature }}",
state_topic: "zigbee2mqtt/weather_sensor",
object_id: "weather_sensor_temperature",
default_entity_id: "sensor.weather_sensor_temperature",
unique_id: "0x0017880104e45522_temperature_zigbee2mqtt",
origin: origin,
device: {
@@ -2365,7 +2604,7 @@ describe("Extension: HomeAssistant", () => {
model: "Temperature and humidity sensor",
model_id: "WSDCGQ11LM",
manufacturer: "Aqara",
configuration_url: "http://zigbee.mqtt/#/device/0x0017880104e45522/info",
configuration_url: expectedUrl,
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
},
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
@@ -2384,7 +2623,6 @@ describe("Extension: HomeAssistant", () => {
resetDiscoveryPayloads(device.ieeeAddr);
mockMQTTPublishAsync.mockClear();
// @ts-expect-error private
controller.eventBus.emitScenesChanged({entity: device});
await flushPromises();
@@ -2398,6 +2636,7 @@ describe("Extension: HomeAssistant", () => {
command_topic: "zigbee2mqtt/bulb_color_2/set",
payload_on: '{ "scene_recall": 1 }',
object_id: "bulb_color_2_1_chill_scene",
default_entity_id: "scene.bulb_color_2_1_chill_scene",
unique_id: "0x000b57fffec6a5b4_scene_1_zigbee2mqtt",
device: {
identifiers: ["zigbee2mqtt_0x000b57fffec6a5b4"],
@@ -2421,7 +2660,6 @@ describe("Extension: HomeAssistant", () => {
resetDiscoveryPayloads("9");
mockMQTTPublishAsync.mockClear();
// @ts-expect-error private
controller.eventBus.emitScenesChanged({entity: group});
await flushPromises();
@@ -2438,6 +2676,7 @@ describe("Extension: HomeAssistant", () => {
command_topic: "zigbee2mqtt/ha_discovery_group/set",
payload_on: '{ "scene_recall": 4 }',
object_id: "ha_discovery_group_4_scene_4",
default_entity_id: "scene.ha_discovery_group_4_scene_4",
unique_id: "9_scene_4_zigbee2mqtt",
device: {
identifiers: ["zigbee2mqtt_1221051039810110150109113116116_9"],
@@ -2466,6 +2705,7 @@ describe("Extension: HomeAssistant", () => {
const payload = {
name: "Restart",
object_id: "zigbee2mqtt_bridge_restart",
default_entity_id: "light.zigbee2mqtt_bridge_restart",
unique_id: "bridge_0x00124b00120144ae_restart_zigbee2mqtt",
device_class: "restart",
command_topic: "zigbee2mqtt/bridge/request/restart",
@@ -2483,7 +2723,6 @@ describe("Extension: HomeAssistant", () => {
availability_mode: "all",
};
// @ts-expect-error private
controller.eventBus.emitMQTTMessage({
topic: topic,
message: stringify(payload),
@@ -2508,6 +2747,7 @@ describe("Extension: HomeAssistant", () => {
payload = {
name: "Connection state",
object_id: "zigbee2mqtt_bridge_connection_state",
default_entity_id: "binary_sensor.zigbee2mqtt_bridge_connection_state",
entity_category: "diagnostic",
device_class: "connectivity",
unique_id: "bridge_0x00124b00120144ae_connection_state_zigbee2mqtt",
@@ -2527,6 +2767,7 @@ describe("Extension: HomeAssistant", () => {
payload = {
name: "Restart required",
object_id: "zigbee2mqtt_bridge_restart_required",
default_entity_id: "binary_sensor.zigbee2mqtt_bridge_restart_required",
entity_category: "diagnostic",
device_class: "problem",
enabled_by_default: false,
@@ -2550,6 +2791,7 @@ describe("Extension: HomeAssistant", () => {
payload = {
name: "Restart",
object_id: "zigbee2mqtt_bridge_restart",
default_entity_id: "button.zigbee2mqtt_bridge_restart",
unique_id: "bridge_0x00124b00120144ae_restart_zigbee2mqtt",
device_class: "restart",
command_topic: "zigbee2mqtt/bridge/request/restart",
@@ -2569,6 +2811,7 @@ describe("Extension: HomeAssistant", () => {
payload = {
name: "Log level",
object_id: "zigbee2mqtt_bridge_log_level",
default_entity_id: "select.zigbee2mqtt_bridge_log_level",
entity_category: "config",
unique_id: "bridge_0x00124b00120144ae_log_level_zigbee2mqtt",
state_topic: "zigbee2mqtt/bridge/info",
@@ -2591,6 +2834,7 @@ describe("Extension: HomeAssistant", () => {
payload = {
name: "Version",
object_id: "zigbee2mqtt_bridge_version",
default_entity_id: "sensor.zigbee2mqtt_bridge_version",
entity_category: "diagnostic",
icon: "mdi:zigbee",
unique_id: "bridge_0x00124b00120144ae_version_zigbee2mqtt",
@@ -2610,6 +2854,7 @@ describe("Extension: HomeAssistant", () => {
payload = {
name: "Coordinator version",
object_id: "zigbee2mqtt_bridge_coordinator_version",
default_entity_id: "sensor.zigbee2mqtt_bridge_coordinator_version",
entity_category: "diagnostic",
enabled_by_default: false,
icon: "mdi:chip",
@@ -2630,6 +2875,7 @@ describe("Extension: HomeAssistant", () => {
payload = {
name: "Network map",
object_id: "zigbee2mqtt_bridge_network_map",
default_entity_id: "sensor.zigbee2mqtt_bridge_network_map",
entity_category: "diagnostic",
enabled_by_default: false,
unique_id: "bridge_0x00124b00120144ae_network_map_zigbee2mqtt",
@@ -2652,6 +2898,7 @@ describe("Extension: HomeAssistant", () => {
payload = {
name: "Permit join",
object_id: "zigbee2mqtt_bridge_permit_join",
default_entity_id: "switch.zigbee2mqtt_bridge_permit_join",
icon: "mdi:human-greeting-proximity",
unique_id: "bridge_0x00124b00120144ae_permit_join_zigbee2mqtt",
state_topic: "zigbee2mqtt/bridge/info",
@@ -2688,7 +2935,7 @@ describe("Extension: HomeAssistant", () => {
mockMQTTPublishAsync.mockClear();
const device = devices["BMCT-SLZ"];
const data = {deviceMode: 0};
const msg = {data, cluster: "boschSpecific", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
const msg = {data, cluster: "boschEnergyDevice", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
resetDiscoveryPayloads("0x18fc26000000cafe");
await mockZHEvents.message(msg);
await flushPromises();
@@ -2707,6 +2954,7 @@ describe("Extension: HomeAssistant", () => {
icon: "mdi:tune",
name: "Device mode",
object_id: "0x18fc26000000cafe_device_mode",
default_entity_id: "select.0x18fc26000000cafe_device_mode",
options: ["light", "shutter", "disabled"],
origin: origin,
state_topic: "zigbee2mqtt/0x18fc26000000cafe",
@@ -2767,10 +3015,6 @@ describe("Extension: HomeAssistant", () => {
await controller.enableDisableExtension(false, "HomeAssistant");
}).rejects.toThrow("Tried to disable HomeAssistant extension enabled in settings");
await expect(async () => {
await controller.enableDisableExtension(true, "HomeAssistant");
}).rejects.toThrow("Extension with name HomeAssistant already present");
settings.set(["homeassistant", "enabled"], false);
await expect(async () => {
+141 -66
View File
@@ -1,4 +1,6 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import type {Eui64} from "zigbee-herdsman/dist/zspec/tstypes";
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
@@ -24,6 +26,22 @@ returnDevices.push(
const mocksClear = [mockMQTTPublishAsync, mockLogger.warning, mockLogger.debug];
const LQI_BASE_RSP = {
extendedPanId: [],
deviceType: 0,
rxOnWhenIdle: 0,
reserved1: 0,
permitJoining: 0,
reserved2: 0,
};
const ROUTING_BASE_RSP = {
memoryConstrained: 0,
manyToOne: 0,
routeRecordRequired: 0,
reserved1: 0,
};
describe("Extension: NetworkMap", () => {
let controller: Controller;
@@ -38,58 +56,62 @@ describe("Extension: NetworkMap", () => {
* | -> CC2530_ROUTER -> WXKG02LM_rev1
*
*/
devices.coordinator.lqi.mockResolvedValueOnce({
neighbors: [
{
ieeeAddr: devices.bulb_color.ieeeAddr,
networkAddress: devices.bulb_color.networkAddress,
relationship: 2,
depth: 1,
linkquality: 120,
},
{ieeeAddr: devices.bulb.ieeeAddr, networkAddress: devices.bulb.networkAddress, relationship: 2, depth: 1, linkquality: 92},
{
ieeeAddr: devices.external_converter_device.ieeeAddr,
networkAddress: devices.external_converter_device.networkAddress,
relationship: 2,
depth: 1,
linkquality: 92,
},
],
});
devices.coordinator.routingTable.mockResolvedValueOnce({
table: [{destinationAddress: devices.CC2530_ROUTER.networkAddress, status: "ACTIVE", nextHop: devices.bulb.networkAddress}],
});
devices.bulb.lqi.mockResolvedValueOnce({
neighbors: [
{
ieeeAddr: devices.bulb_color.ieeeAddr,
networkAddress: devices.bulb_color.networkAddress,
relationship: 1,
depth: 2,
linkquality: 110,
},
{
ieeeAddr: devices.CC2530_ROUTER.ieeeAddr,
networkAddress: devices.CC2530_ROUTER.networkAddress,
relationship: 1,
depth: 2,
linkquality: 100,
},
],
});
devices.CC2530_ROUTER.lqi.mockResolvedValueOnce({
neighbors: [
{ieeeAddr: "0x0000000000000000", networkAddress: devices.WXKG02LM_rev1.networkAddress, relationship: 1, depth: 2, linkquality: 130},
{
ieeeAddr: devices.bulb_color.ieeeAddr,
networkAddress: devices.bulb_color.networkAddress,
relationship: 4,
depth: 2,
linkquality: 130,
},
],
});
devices.coordinator.lqi.mockResolvedValueOnce([
{
eui64: devices.bulb_color.ieeeAddr as Eui64,
nwkAddress: devices.bulb_color.networkAddress,
relationship: 2,
depth: 1,
lqi: 120,
...LQI_BASE_RSP,
},
{eui64: devices.bulb.ieeeAddr as Eui64, nwkAddress: devices.bulb.networkAddress, relationship: 2, depth: 1, lqi: 92, ...LQI_BASE_RSP},
{
eui64: devices.external_converter_device.ieeeAddr as Eui64,
nwkAddress: devices.external_converter_device.networkAddress,
relationship: 2,
depth: 1,
lqi: 92,
...LQI_BASE_RSP,
},
]);
devices.coordinator.routingTable.mockResolvedValueOnce([
{
destinationAddress: devices.CC2530_ROUTER.networkAddress,
status: "ACTIVE",
nextHopAddress: devices.bulb.networkAddress,
...ROUTING_BASE_RSP,
},
]);
devices.bulb.lqi.mockResolvedValueOnce([
{
eui64: devices.bulb_color.ieeeAddr as Eui64,
nwkAddress: devices.bulb_color.networkAddress,
relationship: 1,
depth: 2,
lqi: 110,
...LQI_BASE_RSP,
},
{
eui64: devices.CC2530_ROUTER.ieeeAddr as Eui64,
nwkAddress: devices.CC2530_ROUTER.networkAddress,
relationship: 1,
depth: 2,
lqi: 100,
...LQI_BASE_RSP,
},
]);
devices.CC2530_ROUTER.lqi.mockResolvedValueOnce([
{eui64: "0x0000000000000000", nwkAddress: devices.WXKG02LM_rev1.networkAddress, relationship: 1, depth: 2, lqi: 130, ...LQI_BASE_RSP},
{
eui64: devices.bulb_color.ieeeAddr as Eui64,
nwkAddress: devices.bulb_color.networkAddress,
relationship: 4,
depth: 2,
lqi: 130,
...LQI_BASE_RSP,
},
]);
devices.unsupported_router.lqi.mockRejectedValueOnce("failed").mockRejectedValueOnce("failed");
devices.unsupported_router.routingTable.mockRejectedValueOnce("failed").mockRejectedValueOnce("failed");
};
@@ -142,6 +164,9 @@ describe("Extension: NetworkMap", () => {
value: {
links: [
{
deviceType: 0,
rxOnWhenIdle: 0,
permitJoining: 0,
depth: 1,
linkquality: 120,
lqi: 120,
@@ -154,11 +179,14 @@ describe("Extension: NetworkMap", () => {
targetIeeeAddr: "0x00124b00120144ae",
},
{
deviceType: 0,
rxOnWhenIdle: 0,
permitJoining: 0,
depth: 1,
linkquality: 92,
lqi: 92,
relationship: 2,
routes: [{destinationAddress: 6540, nextHop: 40369, status: "ACTIVE"}],
routes: [{destinationAddress: 6540, nextHopAddress: 40369, status: "ACTIVE", ...ROUTING_BASE_RSP}],
source: {ieeeAddr: "0x000b57fffec6a5b2", networkAddress: 40369},
sourceIeeeAddr: "0x000b57fffec6a5b2",
sourceNwkAddr: 40369,
@@ -166,6 +194,9 @@ describe("Extension: NetworkMap", () => {
targetIeeeAddr: "0x00124b00120144ae",
},
{
deviceType: 0,
rxOnWhenIdle: 0,
permitJoining: 0,
depth: 1,
linkquality: 92,
lqi: 92,
@@ -178,6 +209,9 @@ describe("Extension: NetworkMap", () => {
targetIeeeAddr: "0x00124b00120144ae",
},
{
deviceType: 0,
rxOnWhenIdle: 0,
permitJoining: 0,
depth: 2,
linkquality: 110,
lqi: 110,
@@ -190,6 +224,9 @@ describe("Extension: NetworkMap", () => {
targetIeeeAddr: "0x000b57fffec6a5b2",
},
{
deviceType: 0,
rxOnWhenIdle: 0,
permitJoining: 0,
depth: 2,
linkquality: 100,
lqi: 100,
@@ -202,6 +239,9 @@ describe("Extension: NetworkMap", () => {
targetIeeeAddr: "0x000b57fffec6a5b2",
},
{
deviceType: 0,
rxOnWhenIdle: 0,
permitJoining: 0,
depth: 2,
linkquality: 130,
lqi: 130,
@@ -327,23 +367,34 @@ describe("Extension: NetworkMap", () => {
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};
await mockZHEvents.message(payload);
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");
const z2mDevice = controller.zigbee.resolveEntity(devices.bulb_color.ieeeAddr);
const originalDef = z2mDevice.definition;
z2mDevice.definition = {
...originalDef,
description: `${originalDef.description} 6"`,
};
try {
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};
await mockZHEvents.message(payload);
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");
} finally {
z2mDevice.definition = originalDef;
}
const expected = `digraph G {
node[shape=record];
"0x00124b00120144ae" [style="bold, filled", fillcolor="#e04e5d", fontcolor="#ffffff", label="{Coordinator|0x00124b00120144ae (0x0000)|0 seconds ago}"];
"0x000b57fffec6a5b2" [style="rounded, filled", fillcolor="#4ea3e0", fontcolor="#ffffff", label="{bulb|0x000b57fffec6a5b2 (0x9db1)|IKEA TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm (LED1545G12)|9 seconds ago}"];
"0x000b57fffec6a5b2" -> "0x00124b00120144ae" [penwidth=2, weight=1, color="#009900", label="92 (routes: 0x198c)"]
"0x000b57fffec6a5b3" [style="rounded, filled", fillcolor="#4ea3e0", fontcolor="#ffffff", label="{bulb_color|0x000b57fffec6a5b3 (0x9dcf)|Philips Hue Go (7146060PH)|unknown}"];
"0x000b57fffec6a5b3" [style="rounded, filled", fillcolor="#4ea3e0", fontcolor="#ffffff", label="{bulb_color|0x000b57fffec6a5b3 (0x9dcf)|Philips Hue Go 6\\" (7146060PH)|unknown}"];
"0x000b57fffec6a5b3" -> "0x00124b00120144ae" [penwidth=0.5, weight=0, color="#994444", label="120"]
"0x000b57fffec6a5b3" -> "0x000b57fffec6a5b2" [penwidth=0.5, weight=0, color="#994444", label="110"]
"0x0017880104e45521" [style="rounded, dashed, filled", fillcolor="#fff8ce", fontcolor="#000000", label="{button_double_key|0x0017880104e45521 (0x198a)|Aqara Wireless remote switch (double rocker), 2016 model (WXKG02LM_rev1)|9 seconds ago}"];
@@ -491,6 +542,9 @@ describe("Extension: NetworkMap", () => {
value: {
links: [
{
deviceType: 0,
rxOnWhenIdle: 0,
permitJoining: 0,
depth: 1,
linkquality: 120,
lqi: 120,
@@ -503,11 +557,14 @@ describe("Extension: NetworkMap", () => {
targetIeeeAddr: "0x00124b00120144ae",
},
{
deviceType: 0,
rxOnWhenIdle: 0,
permitJoining: 0,
depth: 1,
linkquality: 92,
lqi: 92,
relationship: 2,
routes: [{destinationAddress: 6540, nextHop: 40369, status: "ACTIVE"}],
routes: [{destinationAddress: 6540, nextHopAddress: 40369, status: "ACTIVE", ...ROUTING_BASE_RSP}],
source: {ieeeAddr: "0x000b57fffec6a5b2", networkAddress: 40369},
sourceIeeeAddr: "0x000b57fffec6a5b2",
sourceNwkAddr: 40369,
@@ -515,6 +572,9 @@ describe("Extension: NetworkMap", () => {
targetIeeeAddr: "0x00124b00120144ae",
},
{
deviceType: 0,
rxOnWhenIdle: 0,
permitJoining: 0,
depth: 1,
linkquality: 92,
lqi: 92,
@@ -527,6 +587,9 @@ describe("Extension: NetworkMap", () => {
targetIeeeAddr: "0x00124b00120144ae",
},
{
deviceType: 0,
rxOnWhenIdle: 0,
permitJoining: 0,
depth: 2,
linkquality: 130,
lqi: 130,
@@ -648,6 +711,9 @@ describe("Extension: NetworkMap", () => {
value: {
links: [
{
deviceType: 0,
rxOnWhenIdle: 0,
permitJoining: 0,
depth: 1,
linkquality: 120,
lqi: 120,
@@ -660,11 +726,14 @@ describe("Extension: NetworkMap", () => {
targetIeeeAddr: "0x00124b00120144ae",
},
{
deviceType: 0,
rxOnWhenIdle: 0,
permitJoining: 0,
depth: 1,
linkquality: 92,
lqi: 92,
relationship: 2,
routes: [{destinationAddress: 6540, nextHop: 40369, status: "ACTIVE"}],
routes: [{destinationAddress: 6540, nextHopAddress: 40369, status: "ACTIVE", ...ROUTING_BASE_RSP}],
source: {ieeeAddr: "0x000b57fffec6a5b2", networkAddress: 40369},
sourceIeeeAddr: "0x000b57fffec6a5b2",
sourceNwkAddr: 40369,
@@ -672,6 +741,9 @@ describe("Extension: NetworkMap", () => {
targetIeeeAddr: "0x00124b00120144ae",
},
{
deviceType: 0,
rxOnWhenIdle: 0,
permitJoining: 0,
depth: 1,
linkquality: 92,
lqi: 92,
@@ -684,6 +756,9 @@ describe("Extension: NetworkMap", () => {
targetIeeeAddr: "0x00124b00120144ae",
},
{
deviceType: 0,
rxOnWhenIdle: 0,
permitJoining: 0,
depth: 2,
linkquality: 130,
lqi: 130,
+89 -87
View File
@@ -1,4 +1,5 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi, assert} from "vitest";
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {mockMQTTPublishAsync} from "../mocks/mqtt";
@@ -8,7 +9,7 @@ import {devices, events as mockZHEvents, returnDevices} from "../mocks/zigbeeHer
import type {MockInstance} from "vitest";
import * as zhc from "zigbee-herdsman-converters";
import type {OnEvent as DefinitionOnEvent} from "zigbee-herdsman-converters/lib/types";
import type {OnEvent as ZhcOnEvent} from "zigbee-herdsman-converters/lib/types";
import {Controller} from "../../lib/controller";
import OnEvent from "../../lib/extension/onEvent";
import type Device from "../../lib/model/device";
@@ -21,18 +22,12 @@ returnDevices.push(devices.bulb.ieeeAddr, devices.LIVOLO.ieeeAddr, devices.coord
describe("Extension: OnEvent", () => {
let controller: Controller;
let onEventSpy: MockInstance<typeof zhc.onEvent>;
let deviceOnEventSpy: MockInstance<DefinitionOnEvent>;
let deviceOnEventSpy: MockInstance<ZhcOnEvent.Handler>;
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 () => {
vi.useFakeTimers();
data.writeDefaultConfiguration();
@@ -52,7 +47,8 @@ describe("Extension: OnEvent", () => {
}
await controller.removeExtension(controller.getExtension("OnEvent")!);
clearOnEventSpies();
onEventSpy.mockClear();
deviceOnEventSpy.mockClear();
await controller.addExtension(new OnEvent(...controller.extensionArgs));
});
@@ -65,115 +61,121 @@ describe("Extension: OnEvent", () => {
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)},
);
expect(deviceOnEventSpy).toHaveBeenNthCalledWith(1, {
type: "start",
data: {
device: devices.LIVOLO,
options: settings.getDevice(devices.LIVOLO.ieeeAddr),
state: {},
deviceExposesChanged: expect.any(Function),
},
});
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)},
);
expect(deviceOnEventSpy).toHaveBeenNthCalledWith(2, {
type: "stop",
data: {
ieeeAddr: devices.LIVOLO.ieeeAddr,
},
});
});
it("calls on device events", async () => {
clearOnEventSpies();
await mockZHEvents.deviceAnnounce({device: devices.LIVOLO});
await flushPromises();
expect(deviceOnEventSpy).toHaveBeenCalledTimes(1);
expect(deviceOnEventSpy).toHaveBeenNthCalledWith(
1,
"deviceAnnounce",
{},
devices.LIVOLO,
settings.getDevice(devices.LIVOLO.ieeeAddr),
{},
{deviceExposesChanged: expect.any(Function)},
);
// Should always call with 'start' event first
expect(deviceOnEventSpy).toHaveBeenCalledTimes(2);
expect(deviceOnEventSpy).toHaveBeenNthCalledWith(1, {
type: "start",
data: {
device: devices.LIVOLO,
options: settings.getDevice(devices.LIVOLO.ieeeAddr),
state: {},
deviceExposesChanged: expect.any(Function),
},
});
// Device announce
expect(deviceOnEventSpy).toHaveBeenCalledTimes(2);
expect(deviceOnEventSpy).toHaveBeenNthCalledWith(2, {
type: "deviceAnnounce",
data: {
device: devices.LIVOLO,
options: settings.getDevice(devices.LIVOLO.ieeeAddr),
state: {},
deviceExposesChanged: expect.any(Function),
},
});
// Check deviceExposesChanged()
const emitExposesAndDevicesChangedSpy = vi.spyOn(
// @ts-expect-error protected
controller.getExtension("OnEvent")!.eventBus,
"emitExposesAndDevicesChanged",
);
deviceOnEventSpy.mock.calls[0][5]!.deviceExposesChanged();
assert(deviceOnEventSpy.mock.calls[0][0]!.type === "start");
deviceOnEventSpy.mock.calls[0][0]!.data.deviceExposesChanged();
expect(emitExposesAndDevicesChangedSpy).toHaveBeenCalledTimes(1);
expect(emitExposesAndDevicesChangedSpy).toHaveBeenCalledWith(getZ2MDevice(devices.LIVOLO));
// Call `stop` when device leaves
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},
expect(deviceOnEventSpy).toHaveBeenCalledTimes(3);
expect(deviceOnEventSpy).toHaveBeenNthCalledWith(3, {
type: "stop",
data: {
ieeeAddr: devices.LIVOLO.ieeeAddr,
},
devices.LIVOLO,
settings.getDevice(devices.LIVOLO.ieeeAddr),
{},
{deviceExposesChanged: expect.any(Function)},
);
});
// Call `stop` when device is removed
// @ts-expect-error private
controller.eventBus.emitEntityRemoved({entity: getZ2MDevice(devices.LIVOLO)});
await flushPromises();
expect(deviceOnEventSpy).toHaveBeenCalledTimes(4);
expect(deviceOnEventSpy).toHaveBeenNthCalledWith(4, {
type: "stop",
data: {
ieeeAddr: devices.LIVOLO.ieeeAddr,
},
});
// Device interview, should call with 'start' first as 'stop' was called
await mockZHEvents.deviceInterview({device: devices.LIVOLO, status: "started"});
await flushPromises();
expect(deviceOnEventSpy).toHaveBeenCalledTimes(6);
expect(deviceOnEventSpy).toHaveBeenNthCalledWith(5, {
type: "start",
data: {
device: devices.LIVOLO,
options: settings.getDevice(devices.LIVOLO.ieeeAddr),
state: {},
deviceExposesChanged: expect.any(Function),
},
});
expect(deviceOnEventSpy).toHaveBeenNthCalledWith(6, {
type: "deviceInterview",
data: {
device: devices.LIVOLO,
options: settings.getDevice(devices.LIVOLO.ieeeAddr),
state: {},
deviceExposesChanged: expect.any(Function),
status: "started",
},
});
});
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);
});
});
File diff suppressed because it is too large Load Diff
+418 -87
View File
@@ -1,4 +1,5 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
@@ -41,7 +42,6 @@ describe("Extension: Publish", () => {
beforeEach(() => {
data.writeDefaultConfiguration();
// @ts-expect-error private
controller.state.clear();
settings.reRead();
loadTopicGetSetRegex();
@@ -73,7 +73,12 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({brightness: "200"}));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(1);
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 200, transtime: 0}, {});
expect(endpoint.command).toHaveBeenCalledWith(
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 200, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({brightness: 200, state: "ON"});
@@ -119,7 +124,12 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({brightness_percent: "92"}));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(1);
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 235, transtime: 0}, {});
expect(endpoint.command).toHaveBeenCalledWith(
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 235, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({state: "ON", brightness: 235});
@@ -132,7 +142,12 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({brightness: 230}));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(1);
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 230, transtime: 0}, {});
expect(endpoint.command).toHaveBeenCalledWith(
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 230, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({state: "ON", brightness: 230});
@@ -145,7 +160,12 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({color_temp: "222"}));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(1);
expect(endpoint.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColorTemp", {colortemp: 222, transtime: 0}, {});
expect(endpoint.command).toHaveBeenCalledWith(
"lightingColorCtrl",
"moveToColorTemp",
{colortemp: 222, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({color_mode: "color_temp", color_temp: 222});
@@ -158,7 +178,12 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({color_temp_percent: 100}));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(1);
expect(endpoint.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColorTemp", {colortemp: 500, transtime: 0}, {});
expect(endpoint.command).toHaveBeenCalledWith(
"lightingColorCtrl",
"moveToColorTemp",
{colortemp: 500, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({color_mode: "color_temp", color_temp: 500});
@@ -223,7 +248,12 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/0x0017882194e45543/set", stringify({state_l2: "ON", brightness_l2: 50}));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(1);
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 50, transtime: 0}, {});
expect(endpoint.command).toHaveBeenCalledWith(
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 50, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/QS-Zigbee-D02-TRIAC-2C-LN", stringify({brightness_l2: 50, state_l2: "ON"}), {
retain: false,
qos: 0,
@@ -239,7 +269,7 @@ describe("Extension: Publish", () => {
expect(endpoint.command).toHaveBeenCalledWith(
"manuSpecificTuya",
"dataRequest",
{dpValues: [{data: [1], datatype: 1, dp: 2}], seq: expect.any(Number)},
{dpValues: [{data: Buffer.from([1]), datatype: 1, dp: 2}], seq: expect.any(Number)},
{disableDefaultResponse: true},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/TS0601_switch", stringify({state_l2: "ON"}), {retain: false, qos: 0});
@@ -256,19 +286,19 @@ describe("Extension: Publish", () => {
expect(endpoint.command).toHaveBeenCalledWith(
"manuSpecificTuya",
"dataRequest",
{dpValues: [{data: [0], datatype: 4, dp: 1}], seq: expect.any(Number)},
{dpValues: [{data: Buffer.from([0]), datatype: 4, dp: 1}], seq: expect.any(Number)},
{disableDefaultResponse: true},
);
expect(endpoint.command).toHaveBeenCalledWith(
"manuSpecificTuya",
"dataRequest",
{dpValues: [{data: [1], datatype: 1, dp: 102}], seq: expect.any(Number)},
{dpValues: [{data: Buffer.from([1]), datatype: 1, dp: 102}], seq: expect.any(Number)},
{disableDefaultResponse: true},
);
expect(endpoint.command).toHaveBeenCalledWith(
"manuSpecificTuya",
"dataRequest",
{dpValues: [{data: [0], datatype: 1, dp: 101}], seq: expect.any(Number)},
{dpValues: [{data: Buffer.from([0]), datatype: 1, dp: 101}], seq: expect.any(Number)},
{disableDefaultResponse: true},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
@@ -284,7 +314,12 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({color: {x: 100, y: 50}}));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(1);
expect(endpoint.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColor", {colorx: 6553500, colory: 3276750, transtime: 0}, {});
expect(endpoint.command).toHaveBeenCalledWith(
"lightingColorCtrl",
"moveToColor",
{colorx: 6553500, colory: 3276750, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({color_mode: "xy", color: {x: 100, y: 50}});
@@ -298,7 +333,12 @@ describe("Extension: Publish", () => {
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(2);
expect(endpoint.command).toHaveBeenCalledWith("genOnOff", "on", {}, {});
expect(endpoint.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColor", {colorx: 6553500, colory: 3276750, transtime: 0}, {});
expect(endpoint.command).toHaveBeenCalledWith(
"lightingColorCtrl",
"moveToColor",
{colorx: 6553500, colory: 3276750, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bulb_color",
@@ -313,8 +353,18 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({color: {x: 100, y: 50}, brightness: 20}));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(2);
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 20, transtime: 0}, {});
expect(endpoint.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColor", {colorx: 6553500, colory: 3276750, transtime: 0}, {});
expect(endpoint.command).toHaveBeenCalledWith(
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 20, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
);
expect(endpoint.command).toHaveBeenCalledWith(
"lightingColorCtrl",
"moveToColor",
{colorx: 6553500, colory: 3276750, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bulb_color",
@@ -329,8 +379,18 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({color: {x: 100, y: 50}, brightness: 20, state: "ON"}));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(2);
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 20, transtime: 0}, {});
expect(endpoint.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColor", {colorx: 6553500, colory: 3276750, transtime: 0}, {});
expect(endpoint.command).toHaveBeenCalledWith(
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 20, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
);
expect(endpoint.command).toHaveBeenCalledWith(
"lightingColorCtrl",
"moveToColor",
{colorx: 6553500, colory: 3276750, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bulb_color",
@@ -345,7 +405,13 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({color: {x: 100, y: 50}, brightness: 20, state: "OFF"}));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(2);
expect(endpoint.command).toHaveBeenNthCalledWith(1, "lightingColorCtrl", "moveToColor", {colorx: 6553500, colory: 3276750, transtime: 0}, {});
expect(endpoint.command).toHaveBeenNthCalledWith(
1,
"lightingColorCtrl",
"moveToColor",
{colorx: 6553500, colory: 3276750, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
);
expect(endpoint.command).toHaveBeenNthCalledWith(2, "genOnOff", "off", {}, {});
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
@@ -361,7 +427,12 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({color: {r: 100, g: 200, b: 10}}));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(1);
expect(endpoint.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColor", {colorx: 17806, colory: 43155, transtime: 0}, {});
expect(endpoint.command).toHaveBeenCalledWith(
"lightingColorCtrl",
"moveToColor",
{colorx: 17806, colory: 43155, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({color: {x: 0.2717, y: 0.6585}, color_mode: "xy"});
@@ -373,7 +444,12 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({color: {rgb: "100,200,10"}}));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(1);
expect(endpoint.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColor", {colorx: 17806, colory: 43155, transtime: 0}, {});
expect(endpoint.command).toHaveBeenCalledWith(
"lightingColorCtrl",
"moveToColor",
{colorx: 17806, colory: 43155, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({color: {x: 0.2717, y: 0.6585}, color_mode: "xy"});
@@ -385,7 +461,12 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "ON", brightness: "50"}));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(1);
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 50, transtime: 0}, {});
expect(endpoint.command).toHaveBeenCalledWith(
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 50, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({state: "ON", brightness: 50});
@@ -410,7 +491,12 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/group_1/set", stringify({brightness_percent: 50}));
await flushPromises();
expect(group.command).toHaveBeenCalledTimes(1);
expect(group.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 128, transtime: 0}, {});
expect(group.command).toHaveBeenCalledWith(
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 128, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2); // 'zigbee2mqtt/bulb_color' + below
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual("zigbee2mqtt/group_1");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toStrictEqual({state: "ON", brightness: 128});
@@ -425,7 +511,7 @@ describe("Extension: Publish", () => {
expect(group.command).toHaveBeenCalledWith(
"manuSpecificTuya",
"dataRequest",
{dpValues: [{data: [1], datatype: 1, dp: 7}], seq: expect.any(Number)},
{dpValues: [{data: Buffer.from([1]), datatype: 1, dp: 7}], seq: expect.any(Number)},
{disableDefaultResponse: true},
);
});
@@ -449,7 +535,12 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/group_1/set", stringify({state: "ON", brightness: 50}));
await flushPromises();
expect(group.command).toHaveBeenCalledTimes(1);
expect(group.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 50, transtime: 0}, {});
expect(group.command).toHaveBeenCalledWith(
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 50, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2); // 'zigbee2mqtt/bulb_color' + below
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual("zigbee2mqtt/group_1");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toStrictEqual({state: "ON", brightness: 50});
@@ -475,7 +566,12 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/group_1/set", stringify({color: {x: 0.37, y: 0.28}}));
await flushPromises();
expect(group.command).toHaveBeenCalledTimes(1);
expect(group.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColor", {colorx: 24248, colory: 18350, transtime: 0}, {});
expect(group.command).toHaveBeenCalledWith(
"lightingColorCtrl",
"moveToColor",
{colorx: 24248, colory: 18350, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2); // 'zigbee2mqtt/bulb_color' + below
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual("zigbee2mqtt/group_1");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toStrictEqual({color: {x: 0.37, y: 0.28}, color_mode: "xy"});
@@ -488,7 +584,12 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/group_1/set", stringify({color_temp: 100}));
await flushPromises();
expect(group.command).toHaveBeenCalledTimes(1);
expect(group.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColorTemp", {colortemp: 100, transtime: 0}, {});
expect(group.command).toHaveBeenCalledWith(
"lightingColorCtrl",
"moveToColorTemp",
{colortemp: 100, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2); // 'zigbee2mqtt/bulb_color' + below
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual("zigbee2mqtt/group_1");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toStrictEqual({color_temp: 100, color_mode: "color_temp"});
@@ -515,7 +616,12 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({brightness: "200"}));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(1);
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 200, transtime: 0}, {});
expect(endpoint.command).toHaveBeenCalledWith(
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 200, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
});
@@ -526,7 +632,12 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({brightness: "200"}));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(1);
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 200, transtime: 0}, {});
expect(endpoint.command).toHaveBeenCalledWith(
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 200, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({state: "ON"});
});
@@ -681,7 +792,12 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set/color", "#64C80A");
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(1);
expect(endpoint.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColor", {colorx: 17806, colory: 43155, transtime: 0}, {});
expect(endpoint.command).toHaveBeenCalledWith(
"lightingColorCtrl",
"moveToColor",
{colorx: 17806, colory: 43155, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({color: {x: 0.2717, y: 0.6585}, color_mode: "xy"});
@@ -798,7 +914,12 @@ describe("Extension: Publish", () => {
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(2);
expect(endpoint.command.mock.calls[0]).toEqual(["genOnOff", "on", {}, {}]);
expect(endpoint.command.mock.calls[1]).toEqual(["lightingColorCtrl", "moveToColor", {colorx: 45940, colory: 19595, transtime: 0}, {}]);
expect(endpoint.command.mock.calls[1]).toEqual([
"lightingColorCtrl",
"moveToColor",
{colorx: 45940, colory: 19595, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
]);
});
it("Should also use on/off cluster when controlling group with switch", async () => {
@@ -809,7 +930,12 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/switch_group/set", stringify({state: "ON", brightness: 100}));
await flushPromises();
expect(group.command).toHaveBeenCalledTimes(2);
expect(group.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 100, transtime: 0}, {});
expect(group.command).toHaveBeenCalledWith(
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 100, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
);
expect(group.command).toHaveBeenCalledWith("genOnOff", "on", {}, {});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/switch_group", stringify({state: "ON", brightness: 100}), {
retain: false,
@@ -835,7 +961,12 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/group_1/set", stringify({brightness: 100}));
await flushPromises();
expect(group.command).toHaveBeenCalledTimes(1);
expect(group.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 100, transtime: 200}, {});
expect(group.command).toHaveBeenCalledWith(
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 100, transtime: 200, optionsMask: 0, optionsOverride: 0},
{},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2); // zigbee2mqtt/bulb_color + below
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual("zigbee2mqtt/group_1");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toStrictEqual({state: "ON", brightness: 100});
@@ -850,7 +981,12 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify(payload));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(1);
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 20, transtime: 200}, {}]);
expect(endpoint.command.mock.calls[0]).toEqual([
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 20, transtime: 200, optionsMask: 0, optionsOverride: 0},
{},
]);
});
it("Should use transition from device_options on brightness command", async () => {
@@ -861,7 +997,12 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify(payload));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(1);
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 20, transtime: 200}, {}]);
expect(endpoint.command.mock.calls[0]).toEqual([
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 20, transtime: 200, optionsMask: 0, optionsOverride: 0},
{},
]);
});
it("Should turn bulb on with correct brightness when device is turned off twice and brightness is reported", async () => {
@@ -909,10 +1050,34 @@ describe("Extension: Publish", () => {
});
expect(endpoint.command).toHaveBeenCalledTimes(4);
expect(endpoint.command).toHaveBeenNthCalledWith(1, "genLevelCtrl", "moveToLevelWithOnOff", {level: 200, transtime: 0}, {});
expect(endpoint.command).toHaveBeenNthCalledWith(2, "genLevelCtrl", "moveToLevelWithOnOff", {level: 0, transtime: 0}, {});
expect(endpoint.command).toHaveBeenNthCalledWith(3, "genLevelCtrl", "moveToLevelWithOnOff", {level: 0, transtime: 0}, {});
expect(endpoint.command).toHaveBeenNthCalledWith(4, "genLevelCtrl", "moveToLevelWithOnOff", {level: 200, transtime: 0}, {});
expect(endpoint.command).toHaveBeenNthCalledWith(
1,
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 200, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
);
expect(endpoint.command).toHaveBeenNthCalledWith(
2,
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 0, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
);
expect(endpoint.command).toHaveBeenNthCalledWith(
3,
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 0, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
);
expect(endpoint.command).toHaveBeenNthCalledWith(
4,
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 200, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
);
});
it("Should turn bulb on with full brightness when transition is used and no brightness is known", async () => {
@@ -929,8 +1094,18 @@ describe("Extension: Publish", () => {
qos: 0,
});
expect(endpoint.command).toHaveBeenCalledTimes(2);
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 0, transtime: 5}, {}]);
expect(endpoint.command.mock.calls[1]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 254, transtime: 5}, {}]);
expect(endpoint.command.mock.calls[0]).toEqual([
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 0, transtime: 5, optionsMask: 0, optionsOverride: 0},
{},
]);
expect(endpoint.command.mock.calls[1]).toEqual([
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 254, transtime: 5, optionsMask: 0, optionsOverride: 0},
{},
]);
});
it("Transition parameter should not influence brightness on state ON", async () => {
@@ -944,9 +1119,19 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "ON", transition: 1}));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(3);
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 50, transtime: 0}, {}]);
expect(endpoint.command.mock.calls[0]).toEqual([
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 50, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
]);
expect(endpoint.command.mock.calls[1]).toEqual(["genOnOff", "on", {}, {}]);
expect(endpoint.command.mock.calls[2]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 50, transtime: 10}, {}]);
expect(endpoint.command.mock.calls[2]).toEqual([
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 50, transtime: 10, optionsMask: 0, optionsOverride: 0},
{},
]);
});
it("Should use transition when color temp", async () => {
@@ -957,7 +1142,12 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify(payload));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(1);
expect(endpoint.command.mock.calls[0]).toEqual(["lightingColorCtrl", "moveToColorTemp", {colortemp: 200, transtime: 200}, {}]);
expect(endpoint.command.mock.calls[0]).toEqual([
"lightingColorCtrl",
"moveToColorTemp",
{colortemp: 200, transtime: 200, optionsMask: 0, optionsOverride: 0},
{},
]);
});
it("Should use transition only once when setting brightness and color temperature for TRADFRI", async () => {
@@ -967,8 +1157,18 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb/set", stringify(payload));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(2);
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 20, transtime: 0}, {}]);
expect(endpoint.command.mock.calls[1]).toEqual(["lightingColorCtrl", "moveToColorTemp", {colortemp: 200, transtime: 200}, {}]);
expect(endpoint.command.mock.calls[0]).toEqual([
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 20, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
]);
expect(endpoint.command.mock.calls[1]).toEqual([
"lightingColorCtrl",
"moveToColorTemp",
{colortemp: 200, transtime: 200, optionsMask: 0, optionsOverride: 0},
{},
]);
});
it("Should use transition only once when setting brightness and color temperature for group which contains TRADFRI", async () => {
@@ -976,8 +1176,18 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/group_with_tradfri/set", stringify({state: "ON", transition: 60, brightness: 20, color_temp: 400}));
await flushPromises();
expect(group.command).toHaveBeenCalledTimes(2);
expect(group.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 20, transtime: 0}, {}]);
expect(group.command.mock.calls[1]).toEqual(["lightingColorCtrl", "moveToColorTemp", {colortemp: 400, transtime: 600}, {}]);
expect(group.command.mock.calls[0]).toEqual([
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 20, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
]);
expect(group.command.mock.calls[1]).toEqual([
"lightingColorCtrl",
"moveToColorTemp",
{colortemp: 400, transtime: 600, optionsMask: 0, optionsOverride: 0},
{},
]);
});
it("Message transition should overrule options transition", async () => {
@@ -988,7 +1198,12 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify(payload));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(1);
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 200, transtime: 100}, {}]);
expect(endpoint.command.mock.calls[0]).toEqual([
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 200, transtime: 100, optionsMask: 0, optionsOverride: 0},
{},
]);
});
it("Should set state with brightness before color", async () => {
@@ -998,8 +1213,18 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify(payload));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(2);
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 100, transtime: 30}, {}]);
expect(endpoint.command.mock.calls[1]).toEqual(["lightingColorCtrl", "moveToColor", {colorx: 45940, colory: 19595, transtime: 30}, {}]);
expect(endpoint.command.mock.calls[0]).toEqual([
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 100, transtime: 30, optionsMask: 0, optionsOverride: 0},
{},
]);
expect(endpoint.command.mock.calls[1]).toEqual([
"lightingColorCtrl",
"moveToColor",
{colorx: 45940, colory: 19595, transtime: 30, optionsMask: 0, optionsOverride: 0},
{},
]);
});
it("Should turn device off when brightness 0 is send", async () => {
@@ -1012,9 +1237,24 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "ON"}));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(3);
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 50, transtime: 0}, {}]);
expect(endpoint.command.mock.calls[1]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 0, transtime: 0}, {}]);
expect(endpoint.command.mock.calls[2]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 50, transtime: 0}, {}]);
expect(endpoint.command.mock.calls[0]).toEqual([
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 50, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
]);
expect(endpoint.command.mock.calls[1]).toEqual([
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 0, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
]);
expect(endpoint.command.mock.calls[2]).toEqual([
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 50, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
]);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(3);
expect(mockMQTTPublishAsync.mock.calls[0]).toEqual([
"zigbee2mqtt/bulb_color",
@@ -1043,9 +1283,24 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "ON"}));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(3);
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 50, transtime: 0}, {}]);
expect(endpoint.command.mock.calls[1]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 0, transtime: 30}, {}]);
expect(endpoint.command.mock.calls[2]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 50, transtime: 0}, {}]);
expect(endpoint.command.mock.calls[0]).toEqual([
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 50, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
]);
expect(endpoint.command.mock.calls[1]).toEqual([
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 0, transtime: 30, optionsMask: 0, optionsOverride: 0},
{},
]);
expect(endpoint.command.mock.calls[2]).toEqual([
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 50, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
]);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(3);
expect(mockMQTTPublishAsync.mock.calls[0]).toEqual([
"zigbee2mqtt/bulb_color",
@@ -1074,7 +1329,7 @@ describe("Extension: Publish", () => {
expect(endpoint.command.mock.calls[0]).toEqual([
"lightingColorCtrl",
"enhancedMoveToHueAndSaturation",
{direction: 0, enhancehue: 45510, saturation: 127, transtime: 0},
{enhancehue: 45510, saturation: 127, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
]);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
@@ -1129,7 +1384,12 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify(payload));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(1);
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 254, transtime: 30}, {}]);
expect(endpoint.command.mock.calls[0]).toEqual([
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 254, transtime: 30, optionsMask: 0, optionsOverride: 0},
{},
]);
});
it("Should turn device on with on/off with transition when transition 0 is provided", async () => {
@@ -1139,7 +1399,12 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify(payload));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(1);
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 254, transtime: 0}, {}]);
expect(endpoint.command.mock.calls[0]).toEqual([
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 254, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
]);
});
it("Should turn device off with onOff on off with transition", async () => {
@@ -1149,7 +1414,12 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify(payload));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(1);
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 0, transtime: 10}, {}]);
expect(endpoint.command.mock.calls[0]).toEqual([
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 0, transtime: 10, optionsMask: 0, optionsOverride: 0},
{},
]);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({state: "OFF"});
@@ -1168,7 +1438,12 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "OFF", transition: 3}));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(1);
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 0, transtime: 30}, {}]);
expect(endpoint.command.mock.calls[0]).toEqual([
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 0, transtime: 30, optionsMask: 0, optionsOverride: 0},
{},
]);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0]).toEqual([
"zigbee2mqtt/bulb_color",
@@ -1197,7 +1472,12 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "ON", transition: 3}));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(2);
expect(endpoint.command.mock.calls[1]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 200, transtime: 30}, {}]);
expect(endpoint.command.mock.calls[1]).toEqual([
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 200, transtime: 30, optionsMask: 0, optionsOverride: 0},
{},
]);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(3);
expect(mockMQTTPublishAsync.mock.calls[2]).toEqual([
"zigbee2mqtt/bulb_color",
@@ -1219,7 +1499,12 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "OFF", transition: 3}));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(1);
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 0, transtime: 30}, {}]);
expect(endpoint.command.mock.calls[0]).toEqual([
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 0, transtime: 30, optionsMask: 0, optionsOverride: 0},
{},
]);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0]).toEqual([
"zigbee2mqtt/bulb_color",
@@ -1231,7 +1516,12 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "ON"}));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(2);
expect(endpoint.command.mock.calls[1]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 200, transtime: 0}, {}]);
expect(endpoint.command.mock.calls[1]).toEqual([
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 200, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
]);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
expect(mockMQTTPublishAsync.mock.calls[1]).toEqual([
"zigbee2mqtt/bulb_color",
@@ -1256,18 +1546,20 @@ describe("Extension: Publish", () => {
it("Home Assistant: should not set state when color temperature is also set and device is already on", async () => {
settings.set(["homeassistant"], {enabled: true});
// @ts-expect-error private
const device = controller.zigbee.resolveEntity(devices.bulb_color.ieeeAddr)!;
// @ts-expect-error private
controller.state.remove(devices.bulb_color.ieeeAddr);
// @ts-expect-error private
controller.state.set(device, {state: "ON"});
const endpoint = device.zh.getEndpoint(1)!;
const payload = {state: "ON", color_temp: 100};
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify(payload));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(1);
expect(endpoint.command.mock.calls[0]).toEqual(["lightingColorCtrl", "moveToColorTemp", {colortemp: 100, transtime: 0}, {}]);
expect(endpoint.command.mock.calls[0]).toEqual([
"lightingColorCtrl",
"moveToColorTemp",
{colortemp: 100, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
]);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({state: "ON", color_temp: 100, color_mode: "color_temp"});
@@ -1275,11 +1567,8 @@ describe("Extension: Publish", () => {
it("Home Assistant: should set state when color temperature is also set and device is off", async () => {
settings.set(["homeassistant"], {enabled: true});
// @ts-expect-error private
const device = controller.zigbee.resolveEntity(devices.bulb_color.ieeeAddr)!;
// @ts-expect-error private
controller.state.remove(devices.bulb_color.ieeeAddr);
// @ts-expect-error private
controller.state.set(device, {state: "OFF"});
const endpoint = device.zh.getEndpoint(1)!;
const payload = {state: "ON", color_temp: 100};
@@ -1287,7 +1576,12 @@ describe("Extension: Publish", () => {
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(2);
expect(endpoint.command.mock.calls[0]).toEqual(["genOnOff", "on", {}, {}]);
expect(endpoint.command.mock.calls[1]).toEqual(["lightingColorCtrl", "moveToColorTemp", {colortemp: 100, transtime: 0}, {}]);
expect(endpoint.command.mock.calls[1]).toEqual([
"lightingColorCtrl",
"moveToColorTemp",
{colortemp: 100, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
]);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bulb_color",
@@ -1298,18 +1592,20 @@ describe("Extension: Publish", () => {
it("Home Assistant: should not set state when color is also set", async () => {
settings.set(["homeassistant"], {enabled: true});
// @ts-expect-error private
const device = controller.zigbee.resolveEntity(devices.bulb_color.ieeeAddr)!;
// @ts-expect-error private
controller.state.remove(devices.bulb_color.ieeeAddr);
// @ts-expect-error private
controller.state.set(device, {state: "ON"});
const endpoint = device.zh.getEndpoint(1)!;
const payload = {state: "ON", color: {x: 0.41, y: 0.25}};
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify(payload));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(1);
expect(endpoint.command.mock.calls[0]).toEqual(["lightingColorCtrl", "moveToColor", {colorx: 26869, colory: 16384, transtime: 0}, {}]);
expect(endpoint.command.mock.calls[0]).toEqual([
"lightingColorCtrl",
"moveToColor",
{colorx: 26869, colory: 16384, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
]);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({color: {x: 0.41, y: 0.25}, state: "ON", color_mode: "xy"});
@@ -1372,7 +1668,7 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({brightness_move: -40}));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(1);
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "move", {movemode: 1, rate: 40}, {});
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "move", {movemode: 1, rate: 40, optionsMask: 0, optionsOverride: 0}, {});
});
it("Should publish brightness_move down to zigbee devices", async () => {
@@ -1380,7 +1676,7 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({brightness_move: 30}));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(1);
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "move", {movemode: 0, rate: 30}, {});
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "move", {movemode: 0, rate: 30, optionsMask: 0, optionsOverride: 0}, {});
});
it("HS2WD-E burglar warning", async () => {
@@ -1451,7 +1747,12 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/roller_shutter/set", stringify({state: "OPEN"}));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(1);
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: "255", transtime: 0}, {});
expect(endpoint.command).toHaveBeenCalledWith(
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 255, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/roller_shutter");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({position: 100});
@@ -1467,7 +1768,7 @@ describe("Extension: Publish", () => {
expect(endpoint.command).toHaveBeenCalledWith(
"manuSpecificTuya",
"dataRequest",
{dpValues: [{data: [1], datatype: 1, dp: 3}], seq: expect.any(Number)},
{dpValues: [{data: Buffer.from([1]), datatype: 1, dp: 3}], seq: expect.any(Number)},
{disableDefaultResponse: true},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
@@ -1485,7 +1786,12 @@ describe("Extension: Publish", () => {
await vi.runOnlyPendingTimersAsync();
expect(endpoint.command).toHaveBeenCalledTimes(2);
expect(endpoint.command.mock.calls[0]).toEqual(["genOnOff", "on", {}, {}]);
expect(endpoint.command.mock.calls[1]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 20, transtime: 0}, {}]);
expect(endpoint.command.mock.calls[1]).toEqual([
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 20, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
]);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/GL-S-007ZS", stringify({state: "ON", brightness: 20}), {
qos: 0,
retain: false,
@@ -1518,8 +1824,18 @@ describe("Extension: Publish", () => {
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(2);
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 20, transtime: 0}, {});
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 20, transtime: 10}, {});
expect(endpoint.command).toHaveBeenCalledWith(
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 20, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
);
expect(endpoint.command).toHaveBeenCalledWith(
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 20, transtime: 10, optionsMask: 0, optionsOverride: 0},
{},
);
});
it("Should restore brightness when its turned off without transition and is turned on with", async () => {
@@ -1536,7 +1852,12 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "ON", brightness: 123}));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(2);
expect(endpoint.command.mock.calls[1]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 123, transtime: 0}, {}]);
expect(endpoint.command.mock.calls[1]).toEqual([
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 123, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
]);
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "OFF"}));
await flushPromises();
@@ -1546,7 +1867,12 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "ON", transition: 1.0}));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(4);
expect(endpoint.command.mock.calls[3]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 123, transtime: 10}, {}]);
expect(endpoint.command.mock.calls[3]).toEqual([
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 123, transtime: 10, optionsMask: 0, optionsOverride: 0},
{},
]);
});
it("Shouldnt use moveToLevelWithOnOff on turn on when no transition has been used as some devices do not turn on in that case", async () => {
@@ -1565,7 +1891,12 @@ describe("Extension: Publish", () => {
expect(endpoint.command).toHaveBeenCalledTimes(4);
expect(endpoint.command.mock.calls[0]).toEqual(["genOnOff", "on", {}, {}]);
expect(endpoint.command.mock.calls[1]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 150, transtime: 0}, {}]);
expect(endpoint.command.mock.calls[1]).toEqual([
"genLevelCtrl",
"moveToLevelWithOnOff",
{level: 150, transtime: 0, optionsMask: 0, optionsOverride: 0},
{},
]);
expect(endpoint.command.mock.calls[2]).toEqual(["genOnOff", "off", {}, {}]);
expect(endpoint.command.mock.calls[3]).toEqual(["genOnOff", "on", {}, {}]);
@@ -1610,7 +1941,7 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/group_tradfri_remote/set", stringify({scene_recall: 1}));
await flushPromises();
expect(group.command).toHaveBeenCalledTimes(1);
expect(group.command).toHaveBeenCalledWith("genScenes", "recall", {groupid: 15071, sceneid: 1}, {});
expect(group.command).toHaveBeenCalledWith("genScenes", "recall", {groupid: 15071, sceneid: 1, transitionTime: 0xffff}, {});
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(8);
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
1,
+2 -3
View File
@@ -1,4 +1,5 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
@@ -24,7 +25,6 @@ describe("Extension: Receive", () => {
});
beforeEach(() => {
// @ts-expect-error private
controller.state.clear();
data.writeDefaultConfiguration();
settings.reRead();
@@ -339,7 +339,6 @@ describe("Extension: Receive", () => {
type: "attributeReport",
linkquality: 10,
});
// @ts-expect-error private
const realDevice = controller.zigbee.resolveEntity(device);
// Trigger asynchronous event while device is "debouncing" to trigger Message to be sent out from State cache.
@@ -732,7 +731,7 @@ describe("Extension: Receive", () => {
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: "boschEnergyDevice", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
await mockZHEvents.message(payload);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bridge/devices");
});
+28
View File
@@ -1,4 +1,5 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import * as data from "./mocks/data";
import fs from "node:fs";
@@ -11,6 +12,14 @@ import Transport from "winston-transport";
import logger from "../lib/util/logger";
import * as settings from "../lib/util/settings";
vi.mock("rimraf", async (importOriginal) => {
const actual = await importOriginal<typeof import("rimraf")>();
return {
...actual,
rimrafSync: vi.fn(actual.rimrafSync),
};
});
describe("Logger", () => {
let consoleWriteSpy: MockInstance;
const dir = tmp.dirSync();
@@ -42,6 +51,7 @@ describe("Logger", () => {
it("Create log directory", () => {
const dirs = fs.readdirSync(dir.name);
expect(dirs.length).toBe(1);
expect(dirs[0]).toMatch(/^[0-9]{4}-[0-9]{2}-[0-9]{2}\.[0-9]{2}-[0-9]{2}-[0-9]{2}$/);
});
it("Should cleanup (default setting)", () => {
@@ -58,6 +68,24 @@ describe("Logger", () => {
expect(fs.readdirSync(dir.name).length).toBe(10);
});
it("Should handle cleanup error", () => {
for (const d of fs.readdirSync(dir.name)) {
rimrafSync(path.join(dir.name, d));
}
for (let i = 0; i < 20; i++) {
fs.mkdirSync(path.join(dir.name, `log_${i}`));
}
vi.mocked(rimrafSync).mockImplementationOnce(() => {
throw new Error("EACCES: permission denied");
});
const errorSpy = vi.spyOn(logger, "error");
logger.init();
expect(errorSpy).toHaveBeenCalledWith(expect.stringMatching(/Failed to remove old log directory '.*': Error: EACCES: permission denied/));
});
it("Should cleanup (15 folders setting)", () => {
for (const d of fs.readdirSync(dir.name)) {
rimrafSync(path.join(dir.name, d));
+10 -4
View File
@@ -1,9 +1,8 @@
import fs from "node:fs";
import path from "node:path";
import stringify from "json-stable-stringify-without-jsonify";
import tmp from "tmp";
import {vi} from "vitest";
import yaml from "../../lib/util/yaml";
export const mockDir: string = tmp.dirSync().name;
@@ -26,6 +25,9 @@ export const DEFAULT_CONFIGURATION = {
"0x18fc2600000d7ae2": {
friendly_name: "bosch_radiator",
},
"0x18fc2600000d7ae3": {
friendly_name: "bosch_rm230z",
},
"0x000b57fffec6a5b2": {
retain: true,
friendly_name: "bulb",
@@ -275,6 +277,10 @@ export function stateExists(): boolean {
return fs.existsSync(stateFile);
}
export function readState(): Record<string, unknown> {
return JSON.parse(fs.readFileSync(stateFile, "utf8"));
}
const defaultState = {
"0x000b57fffec6a5b2": {
state: "ON",
@@ -294,8 +300,8 @@ export function getDefaultState(): typeof defaultState {
return defaultState;
}
export function writeDefaultState(): void {
fs.writeFileSync(path.join(mockDir, "state.json"), stringify(defaultState));
export function writeDefaultState(data: Record<string, unknown> = defaultState): void {
fs.writeFileSync(stateFile, stringify(data));
}
export function writeEmptyDatabase(): void {
+1 -1
View File
@@ -1,5 +1,5 @@
import {vi} from "vitest";
import type Transport from "winston-transport";
import type {LogLevel} from "../../lib/util/settings";
let level: LogLevel = "info";
+1 -1
View File
@@ -1,5 +1,5 @@
import type {IClientPublishOptions} from "mqtt";
import {vi} from "vitest";
import type {EventHandler} from "./utils";
export const events: Record<string, EventHandler> = {};
+1
View File
@@ -1,3 +1,4 @@
import {vi} from "vitest";
import utils from "../../lib/util/utils";
const spy = vi.spyOn(utils, "sleep");
+1
View File
@@ -1,3 +1,4 @@
import {vi} from "vitest";
import type {DefinitionWithExtend} from "zigbee-herdsman-converters";
export type EventHandler = (...args: unknown[]) => unknown;
+75 -13
View File
@@ -1,10 +1,12 @@
import assert from "node:assert";
import type {Mock} from "vitest";
import {type Mock, vi} from "vitest";
import type {AdapterTypes} from "zigbee-herdsman";
import {Zcl} from "zigbee-herdsman";
import {InterviewState} from "zigbee-herdsman/dist/controller/model/device";
import type {OtaDataSettings, OtaExtraMetas, OtaSource, OtaUpdateAvailableResult} from "zigbee-herdsman/dist/controller/tstype";
import type {TClusterCommandPayload} from "zigbee-herdsman/dist/zspec/zcl/definition/clusters-types";
import type {BindingTableEntry, LQITableEntry, RoutingTableEntry} from "zigbee-herdsman/dist/zspec/zdo/definition/tstypes";
import {DEFAULT_BIND_GROUP_ID} from "../../lib/util/utils";
import type {EventHandler} from "./utils";
type ZHConfiguredReporting = {
@@ -104,6 +106,7 @@ export class Endpoint {
unbind: Mock;
save: Mock;
configureReporting: Mock;
readReportingConfig: Mock;
meta: Record<string, unknown>;
binds: ZHBind[];
profileID: number | undefined;
@@ -138,6 +141,7 @@ export class Endpoint {
this.unbind = vi.fn();
this.save = vi.fn();
this.configureReporting = vi.fn();
this.readReportingConfig = vi.fn();
this.meta = meta;
this.binds = binds;
this.profileID = profileID;
@@ -218,6 +222,7 @@ export class Endpoint {
this.unbind.mockClear();
this.save.mockClear();
this.configureReporting.mockClear();
this.readReportingConfig.mockClear();
this.addToGroup.mockClear();
this.removeFromGroup.mockClear();
this.getClusterAttributeValue.mockClear();
@@ -247,8 +252,38 @@ export class Device {
lastSeen: number | undefined;
isDeleted: boolean;
linkquality?: number;
lqi: Mock;
routingTable: Mock;
lqi: Mock<() => Promise<LQITableEntry[]>>;
routingTable: Mock<() => Promise<RoutingTableEntry[]>>;
bindingTable: Mock<() => Promise<BindingTableEntry[]>>;
clearAllBindings: Mock<() => Promise<void>>;
checkOta =
vi.fn<
(
source: OtaSource,
current: TClusterCommandPayload<"genOta", "queryNextImageRequest"> | undefined,
extraMetas: OtaExtraMetas,
endpoint: Endpoint,
) => Promise<OtaUpdateAvailableResult>
>();
updateOta =
vi.fn<
(
source: Readonly<OtaSource> | undefined,
requestPayload: TClusterCommandPayload<"genOta", "queryNextImageRequest"> | undefined,
requestTsn: number | undefined,
extraMetas: Readonly<OtaExtraMetas>,
onProgress: (progress: number, remaining: number) => void,
dataSettings: OtaDataSettings,
endpoint: Endpoint,
) => Promise<[from: OtaUpdateAvailableResult["current"], to: OtaUpdateAvailableResult["current"] | undefined]>
>();
scheduleOta = vi.fn((source) => {
this.scheduledOta = source;
});
unscheduleOta = vi.fn(() => {
this.scheduledOta = undefined;
});
scheduledOta: OtaSource | undefined = undefined;
constructor(
type: string,
@@ -285,8 +320,10 @@ export class Device {
this.manufacturerName = manufacturerName;
this.lastSeen = 1000;
this.isDeleted = false;
this.lqi = vi.fn(() => ({neighbors: []}));
this.routingTable = vi.fn(() => ({table: []}));
this.lqi = vi.fn(() => Promise.resolve([] as LQITableEntry[]));
this.routingTable = vi.fn(() => Promise.resolve([] as RoutingTableEntry[]));
this.bindingTable = vi.fn(() => Promise.resolve([] as BindingTableEntry[]));
this.clearAllBindings = vi.fn(() => {});
}
getEndpoint(ID: number): Endpoint | undefined {
@@ -302,7 +339,14 @@ export class Device {
this.save.mockClear();
this.lqi.mockClear();
this.routingTable.mockClear();
this.bindingTable.mockClear();
this.clearAllBindings.mockClear();
this.checkOta.mockClear();
this.updateOta.mockClear();
this.scheduleOta.mockClear();
this.unscheduleOta.mockClear();
this.meta = {};
this.scheduledOta = undefined;
for (const ep of this.endpoints) {
ep.mockClear();
@@ -505,7 +549,7 @@ export const groups = {
thermostat_group: new Group(12, [TS0601_thermostat.endpoints[0]]),
group_with_switch: new Group(14, [ZNCZ02LM.endpoints[0], bulb_2.endpoints[0]]),
gledopto_group: new Group(21, [GLEDOPTO_2ID.endpoints[3]]),
default_bind_group: new Group(901, []),
default_bind_group: new Group(DEFAULT_BIND_GROUP_ID, []),
ha_discovery_group: new Group(9, [bulb_color_2.endpoints[0], bulb_2.endpoints[0], QBKG03LM.endpoints[2]]),
hue_twilight_group: new Group(19, [hue_twilight.endpoints[1]]),
};
@@ -1128,18 +1172,33 @@ export const devices = {
"DC Source",
"FanBee1",
),
BTH_RM230Z: new Device(
"Router",
"0x18fc2600000d7ae3",
35902,
4617,
[new Endpoint(1, [0, 3, 513, 516, 1029, 2821], [10, 25], "0x18fc2600000d7ae3")],
InterviewState.Successful,
"Mains (single phase)",
"RBSH-RTH0-ZB-EU",
undefined,
undefined,
undefined,
CUSTOM_CLUSTER_BTHRA,
),
};
export const mockController = {
on: (type: string, handler: EventHandler): void => {
events[type] = handler;
},
start: vi.fn((): Promise<AdapterTypes.StartResult> => Promise.resolve("reset")),
start: vi.fn(async (abortSignal?: AbortSignal): Promise<AdapterTypes.StartResult> => {
abortSignal?.throwIfAborted();
return await Promise.resolve("reset");
}),
stop: vi.fn(),
touchlinkIdentify: vi.fn(),
touchlinkScan: vi.fn(),
touchlinkFactoryReset: vi.fn(),
touchlinkFactoryResetFirst: vi.fn(),
touchlink: {identify: vi.fn(), scan: vi.fn(), factoryReset: vi.fn(), factoryResetFirst: vi.fn()},
addInstallCode: vi.fn(),
permitJoin: vi.fn(),
getPermitJoin: vi.fn((): boolean => false),
@@ -1195,6 +1254,9 @@ export const mockController = {
groups[`group_${groupID}` as keyof typeof groups] = group;
return group;
}),
sendRaw: vi.fn(async (/*rawPayload: RawPayload, customClusters: CustomClusters = {}*/) =>
Promise.resolve([0x00, {nwkAddress: 0x1234, eui64: "", startIndex: 0, assocDevList: []}]),
),
};
vi.mock("zigbee-herdsman", async (importOriginal) => ({
+795 -142
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,4 +1,5 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import {mockLogger} from "./mocks/logger";
import {initSdNotify} from "../lib/util/sd-notify";
+49 -22
View File
@@ -1,8 +1,8 @@
// side-effect ensures using mock paths
import {beforeEach, describe, expect, it} from "vitest";
import "./mocks/data";
import fs from "node:fs";
import yaml from "js-yaml";
import objectAssignDeep from "object-assign-deep";
@@ -165,6 +165,43 @@ describe("Settings", () => {
writeAndCheck();
});
it("Should apply Home Assistant environment variables", () => {
// should be kept in sync with envs in https://github.com/zigbee2mqtt/hassio-zigbee2mqtt/blob/master/common/rootfs/docker-entrypoint.sh
process.env.ZIGBEE2MQTT_CONFIG_FRONTEND_ENABLED = "true";
process.env.ZIGBEE2MQTT_CONFIG_FRONTEND_PORT = "8099";
process.env.ZIGBEE2MQTT_CONFIG_HOMEASSISTANT_ENABLED = "true";
process.env.ZIGBEE2MQTT_CONFIG_SERIAL_PORT =
"/dev/serial/by-id/usb-ITead_Sonoff_Zigbee_3.0_USB_Dongle_Plus_48be7a7468d8ed11bfea786ff2c613ac-if00-port0";
process.env.ZIGBEE2MQTT_CONFIG_MQTT_SERVER = "mqtt://core-mosquitto:1883";
process.env.ZIGBEE2MQTT_CONFIG_MQTT_USER = "addons";
process.env.ZIGBEE2MQTT_CONFIG_MQTT_PASSWORD = "7RbR4NBSskS8TKG4ugzdRilXw6TPQ4NKFs259j2DxeultOFtl5JwciNZFd6feT5o";
write(configurationFile, {});
// @ts-expect-error workaround
const expected = objectAssignDeep.noMutate({}, settings.testing.defaults);
expected.frontend.enabled = true;
expected.frontend.port = 8099;
expected.homeassistant.enabled = true;
expected.serial.port = "/dev/serial/by-id/usb-ITead_Sonoff_Zigbee_3.0_USB_Dongle_Plus_48be7a7468d8ed11bfea786ff2c613ac-if00-port0";
expected.mqtt.server = "mqtt://core-mosquitto:1883";
expected.mqtt.user = "addons";
expected.mqtt.password = "7RbR4NBSskS8TKG4ugzdRilXw6TPQ4NKFs259j2DxeultOFtl5JwciNZFd6feT5o";
expected.devices = {};
expected.groups = {};
const writeAndCheck = (): void => {
expect(settings.write()); // trigger writing of ENVs
expect(settings.validate()).toStrictEqual([]);
expect(settings.get()).toStrictEqual(expected);
};
// Write trice to ensure there are no side effects.
writeAndCheck();
writeAndCheck();
writeAndCheck();
});
it("Should write environment variables as overrides to configuration.yaml, not in the ref file", () => {
write(secretFile, {password: "password-in-secret-file"}, false);
write(configurationFile, {mqtt: {password: "!secret password", server: "server"}});
@@ -275,6 +312,9 @@ describe("Settings", () => {
base_topic: "zigbee2mqtt",
include_device_information: false,
maximum_packet_size: 1048576,
keepalive: 60,
reject_unauthorized: true,
version: 4,
force_disable_retain: false,
password: "mysecretpassword",
server: "my.mqtt.server",
@@ -320,6 +360,9 @@ describe("Settings", () => {
base_topic: "zigbee2mqtt",
include_device_information: false,
maximum_packet_size: 1048576,
keepalive: 60,
reject_unauthorized: true,
version: 4,
force_disable_retain: false,
password: "mysecretpassword",
server: "my.mqtt.server",
@@ -638,6 +681,9 @@ describe("Settings", () => {
base_topic: "zigbee2mqtt",
include_device_information: false,
maximum_packet_size: 1048576,
keepalive: 60,
reject_unauthorized: true,
version: 4,
force_disable_retain: false,
server: "my.mqtt.server",
user: "myusername",
@@ -801,25 +847,6 @@ describe("Settings", () => {
expect(settings.get().blocklist).toStrictEqual(["0x123", "0x1234"]);
});
it("Should throw error when yaml file is invalid", () => {
fs.writeFileSync(
configurationFile,
`
good: 9
\t wrong
`,
);
settings.testing.clear();
const error = `Your YAML file: '${configurationFile}' is invalid (use https://jsonformatter.org/yaml-validator to find and fix the issue)`;
expect(settings.validate()).toEqual(expect.arrayContaining([error]));
});
it("Should throw error when yaml file does not exist", () => {
settings.testing.clear();
expect(settings.validate()[0]).toContain("ENOENT: no such file or directory, open ");
});
it("Configuration shouldnt be valid when invalid QOS value is used", () => {
write(configurationFile, {
...minimalConfig,
@@ -828,7 +855,7 @@ describe("Settings", () => {
settings.reRead();
const error = `QOS for 'myname' not valid, should be 0, 1 or 2 got 3`;
const error = "devices/0x0017880104e45519/qos must be equal to one of the allowed values";
expect(settings.validate()).toEqual(expect.arrayContaining([error]));
});
@@ -1020,6 +1047,6 @@ describe("Settings", () => {
write(configurationFile, {frontend: {enabled: true}});
settings.reRead();
expect(settings.get().frontend).toStrictEqual({enabled: true, package: "zigbee2mqtt-frontend", port: 8080, base_url: "/"});
expect(settings.get().frontend).toStrictEqual({enabled: true, package: "zigbee2mqtt-windfront", port: 8080, base_url: "/"});
});
});
+78 -1
View File
@@ -1,11 +1,13 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import * as data from "./mocks/data";
import {existsSync, readFileSync, rmSync} from "node:fs";
import {existsSync, readFileSync, rmSync, writeFileSync} from "node:fs";
import objectAssignDeep from "object-assign-deep";
import mockedData from "../lib/util/data";
import * as settings from "../lib/util/settings";
import * as settingsMigration from "../lib/util/settingsMigration";
import path from "node:path";
describe("Settings Migration", () => {
beforeAll(() => {});
@@ -915,4 +917,79 @@ describe("Settings Migration", () => {
expect(migrationNotesContent).toContain("[SPECIAL] Device icons are now saved as images.");
});
});
describe("Migrates v4 to v5", () => {
const BASE_CONFIG = {
version: 4,
mqtt: {
server: "mqtt://localhost",
},
};
const DEFAULT_STATE: Record<string, unknown> = {
"0x000b57fffec6a5b2": {
state: "ON",
brightness: 50,
color_temp: 370,
linkquality: 99,
},
"0x0017880104e45517": {
brightness: 255,
update: {state: "idle"},
},
1: {
state: "ON",
update: {state: "available", installed_version: 1, latest_version: 2},
},
};
beforeEach(() => {
settings.testing.CURRENT_VERSION = 5; // stop update after this version
data.writeDefaultConfiguration(BASE_CONFIG);
data.writeDefaultState(DEFAULT_STATE);
settings.reRead();
});
it("Update", () => {
// @ts-expect-error workaround
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
// @ts-expect-error workaround
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
afterSettings.version = 5;
expect(settings.getPersistedSettings()).toStrictEqual(beforeSettings);
expect(data.readState()).toStrictEqual(DEFAULT_STATE);
settingsMigration.migrateIfNecessary();
const migratedSettings = settings.getPersistedSettings();
expect(migratedSettings).toStrictEqual(afterSettings);
// @ts-expect-error workaround
const migratedState = objectAssignDeep.noMutate({}, DEFAULT_STATE);
delete (migratedState["0x0017880104e45517"] as Record<string, unknown>).update;
delete (migratedState[1] as Record<string, unknown>).update;
expect(data.readState()).toStrictEqual(migratedState);
});
it("handles errors gracefully", () => {
const consoleErrorSpy = vi.spyOn(console, "error");
writeFileSync(path.join(data.mockDir, "state.json"), "notjson", "utf8");
// @ts-expect-error workaround
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
// @ts-expect-error workaround
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
afterSettings.version = 5;
expect(settings.getPersistedSettings()).toStrictEqual(beforeSettings);
settingsMigration.migrateIfNecessary();
const migratedSettings = settings.getPersistedSettings();
expect(migratedSettings).toStrictEqual(afterSettings);
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to write state"));
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("is not valid JSON"));
});
});
});

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