Compare commits

..
201 Commits
Author SHA1 Message Date
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
57 changed files with 4400 additions and 1404 deletions
+3 -3
View File
@@ -22,13 +22,13 @@ jobs:
actions: read # for Nerivec/action-ci-bench
pull-requests: write # for Nerivec/action-ci-bench
steps:
- uses: actions/checkout@v5
- 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@v5
- 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
@@ -220,7 +220,7 @@ jobs:
runs-on: ${{ matrix.os }}
continue-on-error: true
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
+1 -1
View File
@@ -10,6 +10,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: 'Checkout repository'
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: 'Dependency review'
uses: actions/dependency-review-action@v4
+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@v5
- uses: actions/checkout@v6
- uses: devmasx/merge-branch@master
with:
type: now
@@ -1,148 +0,0 @@
name: New Device Support/External Converter Issue Bot
on:
issues:
types: [opened, edited]
permissions:
issues: write
contents: read
jobs:
comment:
runs-on: ubuntu-latest
steps:
- name: Comment on new device support/external converter issue
uses: actions/github-script@v8
with:
script: |
const issue = context.payload.issue;
const newDeviceSupport = issue.title && issue.title.startsWith("[New device support]");
const externalConverter = issue.title && issue.title.startsWith("[External Converter]");
// Only proceed for new device support or external converter issues
if (!newDeviceSupport && !externalConverter) {
return;
}
// 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
}
}
`);
}
}
// Checkout the repo and grep for the manufacturer name
const execSync = require('child_process').execSync;
execSync(`git clone https://github.com/Koenkk/zigbee-herdsman-converters.git zhc`, {stdio: 'ignore'});
// Check if Tuya manufacturer name is already supported.
const tuyaManufacturerNameRe = /['"](_T\w+_(\w+))['"]/g;
const tuyaManufacturerNames = Array.from(issue.body.matchAll(tuyaManufacturerNameRe), m => [m[1], m[2]]);
if (tuyaManufacturerNames.length > 0) {
for (const [fullName, partialName] of tuyaManufacturerNames) {
const fullMatch = (() => { try { return execSync(`grep -r --include="*.ts" "${fullName}" zhc`, {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 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."
].join('\n')
});
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}" zhc`, {encoding: 'utf8'}); } catch { return undefined; } })();
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!",
].join('\n')
});
return;
}
}
} else {
// Check if zigbee model is already supported.
const zigbeeModelRe = /zigbeeModel: \[['"](.+)['"]\]/g;
const zigbeeModels = Array.from(issue.body.matchAll(zigbeeModelRe), m => m[1]);
if (zigbeeModels.length > 0) {
for (const zigbeeModel of zigbeeModels) {
const fullMatch = (() => { try { return execSync(`grep -r --include="*.ts" '"${zigbeeModel}"' zhc`, {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."
].join('\n')
});
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."
].join('\n')
});
+4 -4
View File
@@ -31,18 +31,18 @@ jobs:
token: ${{secrets.GH_TOKEN}}
# Checkout repos
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
repository: koenkk/zigbee2mqtt
path: ./z2m
- uses: actions/checkout@v5
- 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
@@ -67,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
+2 -2
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
ref: dev
token: ${{ secrets.GH_TOKEN }}
@@ -25,7 +25,7 @@ jobs:
- 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 -1
View File
@@ -1,3 +1,3 @@
{
".": "2.6.3"
".": "2.9.1"
}
+216
View File
@@ -1,5 +1,221 @@
# Changelog
## [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)
+8 -8
View File
@@ -48,7 +48,7 @@ Zigbee2MQTT integrates well with (almost) every home automation solution because
### [Home Assistant](https://www.home-assistant.io/)
<img align="left" height="75px" width="75px" src="https://user-images.githubusercontent.com/7738048/40914297-49e6e560-6800-11e8-8904-36cce896e5a8.png">
<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 clear="right">
@@ -58,7 +58,7 @@ Zigbee2MQTT integrates well with (almost) every home automation solution because
### [Homey](https://homey.app/)
<img align="left" height="75px" width="75px" src="https://etc.athom.com/logo/white/256.png">
<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 clear="right">
@@ -68,7 +68,7 @@ Integration implemented in the [Homey App](https://homey.app/nl-nl/app/com.gruij
### [Domoticz](https://www.domoticz.com/)
<img align="left" height="75px" width="75px" src="https://user-images.githubusercontent.com/2734836/47615848-b8dd8700-dabd-11e8-9d77-175002dd8987.png">
<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 clear="right">
@@ -78,7 +78,7 @@ Integration implemented in Domoticz ([documentation](https://www.domoticz.com/wi
### [Gladys Assistant](https://gladysassistant.com/)
<img align="left" height="75px" width="75px" src="./images/gladys-assistant-logo.jpg">
<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 clear="right">
@@ -86,13 +86,13 @@ Integration implemented natively in Gladys Assistant ([documentation](https://gl
<br clear="both">
### [IoBroker](https://www.iobroker.net/)
### [ioBroker](https://www.iobroker.net/)
<img align="left" height="75px" width="75px" src="https://forum.iobroker.net/assets/uploads/system/site-logo.png">
<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)).
Integration implemented in ioBroker ([documentation](https://github.com/o0shojo0o/ioBroker.zigbee2mqtt)).
<br clear="both">
@@ -106,7 +106,7 @@ Zigbee2MQTT is made up of three modules, each developed in its own Github projec
### Developing
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.
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.2.5/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.1/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
+4 -1
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
@@ -52,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"]
+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);
+73 -24
View File
@@ -28,6 +28,8 @@ import utils from "./util/utils";
import Zigbee from "./zigbee";
export class Controller {
/** 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;
@@ -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,7 +332,10 @@ export class Controller {
}
}
async stop(restart = false, code = 0): 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 localCode = 0;
@@ -317,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(false, 2);
}
@bind async publishEntityState(entity: Group | Device, payload: KeyValue, stateChangeReason?: StateChangeReason): Promise<void> {
let message: Zigbee2MQTTAPI["{friendlyName}"] = {...payload};
+1 -1
View File
@@ -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);
}
+33 -5
View File
@@ -184,7 +184,7 @@ interface ParsedMQTTMessage {
}
export default class Bind extends Extension {
#topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/(bind|unbind)`);
#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
@@ -276,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) {
@@ -389,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,
@@ -488,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);
}
}
+78 -23
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";
@@ -31,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,
@@ -50,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> {
@@ -315,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"});
@@ -466,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");
}
@@ -518,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});
}
@@ -554,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,
@@ -812,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))) {
+31 -17
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> {
@@ -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) {
+3 -3
View File
@@ -202,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. (${(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`);
}
}
}
+117 -8
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"},
@@ -82,6 +86,7 @@ const BINARY_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
occupancy: {device_class: "occupancy"},
power_outage_memory: {entity_category: "config", icon: "mdi:memory"},
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: {device_class: "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: {device_class: "temperature", 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: {device_class: "wind_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: {device_class: "temperature", 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,6 +254,9 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
device_class: "water",
state_class: "total_increasing",
},
wind_chill: {device_class: "temperature", 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"},
@@ -283,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} = {
@@ -420,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);
@@ -689,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}),
},
};
@@ -752,6 +774,34 @@ export class HomeAssistant extends Extension {
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;
}
@@ -1184,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",
@@ -1675,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 => {
+30 -22
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";
@@ -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);
}
}
+7 -2
View File
@@ -37,6 +37,8 @@ export default class OnEvent extends Extension {
}
});
this.eventBus.onDeviceInterview(this, async (data) => {
// 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) => {
@@ -59,8 +61,11 @@ 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, {type: "stop", data: {ieeeAddr: device.ieeeAddr}});
// 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}});
}
}
}
+282 -197
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,54 +22,65 @@ export interface UpdatePayload {
state: UpdateState;
installed_version: number | null;
latest_version: number | null;
latest_source: string | null;
latest_release_notes: string | null;
};
}
/**
* 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 {
#topicRegex = new RegExp(
`^${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/(update|check|schedule|unschedule)/?(downgrade)?`,
"i",
);
private inProgress = new Set<string>();
private lastChecked = new Map<string, number>();
private scheduledUpgrades = new Set<string>();
private scheduledDowngrades = new Set<string>();
#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) {
@@ -79,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;
}
@@ -91,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},
@@ -207,47 +187,53 @@ 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> {
@@ -265,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";
@@ -274,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 || !device.definition.ota) {
error = `Device '${device.name}' does not support OTA updates`;
break;
}
} else if (!device.definition || !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;
}
@@ -309,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 || !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 || !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 || !device.definition.ota) {
error = `Device '${device.name}' does not support OTA updates`;
break;
}
} else if (!device.definition || !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));
@@ -404,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,
@@ -422,7 +452,7 @@ export default class OTAUpdate extends Extension {
}
}
this.inProgress.delete(device.ieeeAddr);
this.#inProgress.delete(device.ieeeAddr);
}
if (error) {
@@ -436,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];
}
}
+20 -2
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);
+6 -1
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();
@@ -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.`);
+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);
}
}
}
+136 -22
View File
@@ -1,5 +1,8 @@
import type * as zigbeeHerdsman from "zigbee-herdsman/dist";
import type {ZclPayload} from "zigbee-herdsman/dist/adapter/events";
import type {Eui64} from "zigbee-herdsman/dist/zspec/tstypes";
import type {ClusterDefinition, 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;
};
@@ -278,18 +310,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;
}[];
}
@@ -310,6 +345,7 @@ export interface Zigbee2MQTTAPI {
"bridge/definitions": {
clusters: Readonly<Record<ClusterName, Readonly<ClusterDefinition>>>;
custom_clusters: Record<string, CustomClusters>;
actions: string[];
};
"bridge/event":
@@ -558,6 +594,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;
@@ -582,51 +628,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": {
@@ -686,25 +774,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": {
@@ -821,6 +925,10 @@ export interface Zigbee2MQTTAPI {
channel: number;
};
"bridge/request/action": {action: string; params?: Record<string, unknown>};
"bridge/response/action": GenericZdoResponse | ZclPayload | undefined;
/**
* entity state response
*/
@@ -883,6 +991,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"
@@ -896,7 +1005,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"
@@ -907,6 +1017,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"
@@ -931,6 +1042,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"
@@ -941,7 +1053,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"
@@ -951,7 +1064,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;
-3
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 {
+24 -23
View File
@@ -9,27 +9,25 @@ 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 = new Date().toISOString().slice(0, 19).replace("T", ".").replace(/:/g, "-");
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;
@@ -144,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 {
@@ -240,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}`);
}
}
}
}
@@ -262,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();
}
});
}
}
+3 -3
View File
@@ -146,7 +146,7 @@ function generateHtmlForm(currentSettings: RecursivePartial<Settings>, devices:
id="serial_rtscts"
name="serial_rtscts"
${currentSettings.serial?.rtscts ? "checked" : ""}
${process.env.ZIGBEE2MQTT_CONFIG_SERIAL_RTSCTS ? "disabled" : ""}>
${process.env.ZIGBEE2MQTT_CONFIG_SERIAL_RTSCTS ? "disabled" : ""}
style="margin-bottom: 1rem;">
<small>Can be ignored for networked coordinators (TCP).</small>
</fieldset>
@@ -488,7 +488,7 @@ async function startOnboardingServer(): Promise<boolean> {
}
});
server.listen(Number.parseInt(serverUrl.port), serverUrl.hostname, () => {
server.listen(Number.parseInt(serverUrl.port, 10), serverUrl.hostname, () => {
console.log(`Onboarding page is available at ${serverUrl.href}`);
});
});
@@ -515,7 +515,7 @@ async function startFailureServer(errors: string): Promise<void> {
}
});
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}`);
});
});
+27 -4
View File
@@ -366,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",
@@ -380,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
}
}
},
@@ -930,6 +936,12 @@
"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"]
@@ -965,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"]
+3 -2
View File
@@ -10,7 +10,7 @@ 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";
@@ -82,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,
},
@@ -607,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) {
+92 -58
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,7 +14,7 @@ import utils from "./util/utils";
const entityIDRegex = /^(.+?)(?:\/([^/]+))?$/;
export default class Zigbee {
private herdsman!: Controller;
#herdsman!: Controller;
private eventBus: EventBus;
private groupLookup = new Map<number /* group ID */, Group>();
private deviceLookup = new Map<string /* IEEE address */, Device>();
@@ -25,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;
@@ -63,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();
@@ -101,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();
@@ -129,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 {
@@ -198,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> {
@@ -240,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,7 +300,7 @@ export default class Zigbee {
private resolveGroup(groupID: number): Group | undefined {
if (!this.groupLookup.has(groupID)) {
const group = this.herdsman.getGroupByID(groupID);
const group = this.#herdsman.getGroupByID(groupID);
if (group) {
this.groupLookup.set(groupID, new Group(group, this.resolveDevice));
@@ -339,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)!;
}
@@ -399,33 +433,33 @@ 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);
}
+17 -17
View File
@@ -1,6 +1,6 @@
{
"name": "zigbee2mqtt",
"version": "2.6.3",
"version": "2.9.1",
"description": "Zigbee to MQTT bridge using Zigbee-herdsman",
"main": "index.js",
"types": "dist/types/api.d.ts",
@@ -42,39 +42,39 @@
},
"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.33.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",
"mqtt": "^5.14.1",
"mqtt": "^5.15.0",
"object-assign-deep": "^0.4.0",
"rimraf": "^6.1.0",
"semver": "^7.7.3",
"rimraf": "^6.1.3",
"semver": "^7.7.4",
"source-map-support": "^0.5.21",
"throttleit": "^2.1.0",
"winston": "^3.18.3",
"winston": "^3.19.0",
"winston-syslog": "^2.7.1",
"winston-transport": "^4.9.0",
"ws": "^8.18.1",
"zigbee-herdsman": "6.3.2",
"zigbee-herdsman-converters": "25.59.0",
"ws": "^8.19.0",
"zigbee-herdsman": "9.0.9",
"zigbee-herdsman-converters": "26.12.0",
"zigbee2mqtt-frontend": "0.9.21",
"zigbee2mqtt-windfront": "2.2.3"
"zigbee2mqtt-windfront": "2.9.2"
},
"devDependencies": {
"@biomejs/biome": "^2.3.2",
"@biomejs/biome": "^2.4.4",
"@types/finalhandler": "^1.2.3",
"@types/humanize-duration": "^3.27.4",
"@types/js-yaml": "^4.0.9",
"@types/node": "^24.9.2",
"@types/node": "^24.11.0",
"@types/object-assign-deep": "^0.4.3",
"@types/readable-stream": "4.0.22",
"@types/readable-stream": "4.0.23",
"@types/serve-static": "^2.2.0",
"@types/ws": "8.18.1",
"@vitest/coverage-v8": "^3.1.1",
@@ -99,4 +99,4 @@
"optionalDependencies": {
"unix-dgram": "^2.0.7"
}
}
}
+275 -271
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -59,7 +59,13 @@ if (fs.existsSync(commitUserFile)) {
}
const definitions = definitionIndex.default.map((d) => zhc.prepareDefinition(d));
const whiteLabels = definitions.filter((d) => d.whiteLabel).flatMap((d) => d.whiteLabel);
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) {
+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.`,
});
}
+1 -1
View File
@@ -91,4 +91,4 @@ if (processArgs.length !== 1) {
const eraser = new ZStackNvMemEraser(processArgs[0]);
eraser.start();
void eraser.start();
+44 -41
View File
@@ -1,7 +1,7 @@
import {existsSync, mkdirSync} from "node:fs";
import stringify from "json-stable-stringify-without-jsonify";
import {bench, describe} from "vitest";
import {Zcl, Zdo, ZSpec} from "zigbee-herdsman";
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";
@@ -14,6 +14,48 @@ import type {RequestToResponseMap} from "zigbee-herdsman/dist/zspec/zdo/definiti
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();
@@ -365,45 +407,6 @@ const initController = async () => {
async () => {},
);
controller.zigbee.start = async () => {
await controller.zigbee.resolveDevicesDefinitions();
return "resumed";
};
controller.zigbee.stop = async () => {
await Promise.resolve();
};
// @ts-expect-error mocking private
controller.zigbee.herdsman = {
isStopping: () => false,
getCoordinatorVersion: async () =>
Promise.resolve({
type: "Dummy",
meta: {revision: "9.9.9"},
}),
getNetworkParameters: async () => Promise.resolve({...NETWORK_PARAMS}),
getPermitJoin: () => false,
getPermitJoinEnd: () => undefined,
getDeviceByIeeeAddr: (ieeeAddr) => ZH_DEVICES.find((device) => device.ieeeAddr === ieeeAddr),
getGroupByID: (id) => ZH_GROUPS.find((group) => group.groupID === id),
getDevicesByType: (type) => ZH_DEVICES.filter((device) => device.type === type),
getDeviceByNetworkAddress: (networkAddress) => ZH_DEVICES.find((device) => device.networkAddress === networkAddress),
*getDevicesIterator(predicate) {
for (const device of ZH_DEVICES) {
if (!predicate || predicate(device)) {
yield device;
}
}
},
*getGroupsIterator(predicate) {
for (const group of ZH_GROUPS) {
if (!predicate || predicate(group)) {
yield group;
}
}
},
};
// all dummies, can trigger `controller.mqtt.onMessage(topic, message)` as needed
// @ts-expect-error mocking private
controller.mqtt.client = {
+265 -2
View File
@@ -25,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";
@@ -54,6 +55,7 @@ const mocksClear = [
describe("Controller", () => {
let controller: Controller;
let mockExit: Mock;
let stopAfter = true;
const getZ2MDevice = (zhDevice: string | number | ZhDevice): Device => {
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();
});
@@ -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,6 +418,252 @@ 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 () => {
// 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"));
@@ -1168,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");
});
});
+81 -1
View File
@@ -358,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({
@@ -795,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"}),
{},
);
});
});
+378 -29
View File
@@ -1,5 +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";
@@ -17,6 +18,7 @@ import {Controller} from "../../lib/controller";
import Bridge from "../../lib/extension/bridge";
import * as settings from "../../lib/util/settings";
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);
@@ -162,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": {
@@ -319,6 +322,7 @@ describe("Extension: Bridge", () => {
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,
},
@@ -545,6 +549,7 @@ describe("Extension: Bridge", () => {
property: "transition",
type: "numeric",
value_min: 0,
value_step: 0.1,
},
{
access: 2,
@@ -771,6 +776,7 @@ describe("Extension: Bridge", () => {
property: "transition",
type: "numeric",
value_min: 0,
value_step: 0.1,
},
{
access: 2,
@@ -1883,6 +1889,7 @@ describe("Extension: Bridge", () => {
property: "transition",
type: "numeric",
value_min: 0,
value_step: 0.1,
},
{
access: 2,
@@ -2149,6 +2156,7 @@ describe("Extension: Bridge", () => {
property: "transition",
type: "numeric",
value_min: 0,
value_step: 0.1,
},
{
access: 2,
@@ -2583,6 +2591,7 @@ describe("Extension: Bridge", () => {
property: "transition",
type: "numeric",
value_min: 0,
value_step: 0.1,
},
{
access: 2,
@@ -3255,7 +3264,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"}),
{},
);
});
@@ -3532,11 +3541,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"}),
@@ -3546,12 +3555,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"}),
@@ -3602,11 +3611,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"}),
@@ -3616,10 +3625,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"}),
@@ -3629,11 +3638,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"}),
@@ -3643,14 +3652,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({
@@ -3673,7 +3682,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,
@@ -3694,7 +3703,7 @@ describe("Extension: Bridge", () => {
undefined,
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/configure_reporting",
"zigbee2mqtt/bridge/response/device/reporting/configure",
stringify({
data: {
id: "0x000b57fffec6a5b2",
@@ -3719,7 +3728,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",
@@ -3740,7 +3749,7 @@ describe("Extension: Bridge", () => {
undefined,
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/configure_reporting",
"zigbee2mqtt/bridge/response/device/reporting/configure",
stringify({
data: {
id: "0x000b57fffec6a5b2",
@@ -3764,7 +3773,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',
@@ -3778,7 +3787,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"}),
{},
);
@@ -3790,7 +3799,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",
@@ -3804,7 +3813,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"}),
{},
);
@@ -3816,7 +3825,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",
@@ -3830,7 +3839,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'"}),
{},
);
@@ -3841,6 +4086,8 @@ 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(
@@ -3885,6 +4132,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);
@@ -4106,4 +4362,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"}),
{},
);
});
});
+100 -14
View File
@@ -4,13 +4,15 @@ 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];
@@ -23,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();
}
};
@@ -42,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 => {
@@ -147,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();
@@ -227,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 */);
});
});
-4
View File
@@ -478,10 +478,6 @@ 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");
+122 -8
View File
@@ -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,
@@ -209,12 +211,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",
name: null,
payload_off: "OFF",
payload_on: "ON",
@@ -941,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",
@@ -1117,10 +1159,10 @@ describe("Extension: HomeAssistant", () => {
},
max_temp: "30",
min_temp: "5",
mode_command_template: `{% set values = { 'auto':'schedule', 'heat':'manual', 'off':'pause' } %}{"operating_mode": "{{ values[value] if value in values.keys() else 'pause' }}"}`,
mode_command_template: `{% set values = { 'auto':'schedule','heat':'manual','off':'pause'} %}{"operating_mode": "{{ values[value] if value in values.keys() else 'pause' }}"}`,
mode_command_topic: "zigbee2mqtt/bosch_radiator/set",
mode_state_template:
"{% set values = { 'schedule':'auto', 'manual':'heat', 'pause':'off' } %}{% set value = value_json.operating_mode %}{{ values[value] if value in values.keys() else 'off' }}",
"{% set values = {'schedule':'auto','manual':'heat','pause':'off'} %}{% set value = value_json.operating_mode %}{{ values[value] if value in values.keys() else 'off' }}",
mode_state_topic: "zigbee2mqtt/bosch_radiator",
modes: ["off", "heat", "auto"],
name: null,
@@ -1197,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;
@@ -2897,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 () => {
+140 -66
View File
@@ -1,5 +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";
@@ -25,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;
@@ -39,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");
};
@@ -143,6 +164,9 @@ describe("Extension: NetworkMap", () => {
value: {
links: [
{
deviceType: 0,
rxOnWhenIdle: 0,
permitJoining: 0,
depth: 1,
linkquality: 120,
lqi: 120,
@@ -155,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,
@@ -167,6 +194,9 @@ describe("Extension: NetworkMap", () => {
targetIeeeAddr: "0x00124b00120144ae",
},
{
deviceType: 0,
rxOnWhenIdle: 0,
permitJoining: 0,
depth: 1,
linkquality: 92,
lqi: 92,
@@ -179,6 +209,9 @@ describe("Extension: NetworkMap", () => {
targetIeeeAddr: "0x00124b00120144ae",
},
{
deviceType: 0,
rxOnWhenIdle: 0,
permitJoining: 0,
depth: 2,
linkquality: 110,
lqi: 110,
@@ -191,6 +224,9 @@ describe("Extension: NetworkMap", () => {
targetIeeeAddr: "0x000b57fffec6a5b2",
},
{
deviceType: 0,
rxOnWhenIdle: 0,
permitJoining: 0,
depth: 2,
linkquality: 100,
lqi: 100,
@@ -203,6 +239,9 @@ describe("Extension: NetworkMap", () => {
targetIeeeAddr: "0x000b57fffec6a5b2",
},
{
deviceType: 0,
rxOnWhenIdle: 0,
permitJoining: 0,
depth: 2,
linkquality: 130,
lqi: 130,
@@ -328,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}"];
@@ -492,6 +542,9 @@ describe("Extension: NetworkMap", () => {
value: {
links: [
{
deviceType: 0,
rxOnWhenIdle: 0,
permitJoining: 0,
depth: 1,
linkquality: 120,
lqi: 120,
@@ -504,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,
@@ -516,6 +572,9 @@ describe("Extension: NetworkMap", () => {
targetIeeeAddr: "0x00124b00120144ae",
},
{
deviceType: 0,
rxOnWhenIdle: 0,
permitJoining: 0,
depth: 1,
linkquality: 92,
lqi: 92,
@@ -528,6 +587,9 @@ describe("Extension: NetworkMap", () => {
targetIeeeAddr: "0x00124b00120144ae",
},
{
deviceType: 0,
rxOnWhenIdle: 0,
permitJoining: 0,
depth: 2,
linkquality: 130,
lqi: 130,
@@ -649,6 +711,9 @@ describe("Extension: NetworkMap", () => {
value: {
links: [
{
deviceType: 0,
rxOnWhenIdle: 0,
permitJoining: 0,
depth: 1,
linkquality: 120,
lqi: 120,
@@ -661,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,
@@ -673,6 +741,9 @@ describe("Extension: NetworkMap", () => {
targetIeeeAddr: "0x00124b00120144ae",
},
{
deviceType: 0,
rxOnWhenIdle: 0,
permitJoining: 0,
depth: 1,
linkquality: 92,
lqi: 92,
@@ -685,6 +756,9 @@ describe("Extension: NetworkMap", () => {
targetIeeeAddr: "0x00124b00120144ae",
},
{
deviceType: 0,
rxOnWhenIdle: 0,
permitJoining: 0,
depth: 2,
linkquality: 130,
lqi: 130,
File diff suppressed because it is too large Load Diff
+411 -71
View File
@@ -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,
@@ -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});
@@ -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",
{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",
@@ -1264,7 +1554,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: 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"});
@@ -1281,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",
@@ -1300,7 +1600,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", "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"});
@@ -1363,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 () => {
@@ -1371,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 () => {
@@ -1442,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});
@@ -1476,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,
@@ -1509,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 () => {
@@ -1527,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();
@@ -1537,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 () => {
@@ -1556,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", {}, {}]);
@@ -1601,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,
+27
View File
@@ -12,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();
@@ -43,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)", () => {
@@ -59,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> = {};
+73 -11
View File
@@ -1,9 +1,11 @@
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";
@@ -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();
@@ -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) => ({
+2 -2
View File
@@ -293,7 +293,7 @@ describe("Onboarding", () => {
}
const serverUrl = new URL(process.env.Z2M_ONBOARD_URL ?? "http://0.0.0.0:8080");
expect(mockHttpListen).toHaveBeenCalledWith(Number.parseInt(serverUrl.port), serverUrl.hostname, expect.any(Function));
expect(mockHttpListen).toHaveBeenCalledWith(Number.parseInt(serverUrl.port, 10), serverUrl.hostname, expect.any(Function));
return [resEnd.mock.calls[0][0], resEnd.mock.calls[1][0]];
};
@@ -351,7 +351,7 @@ describe("Onboarding", () => {
expect(resEnd).toHaveBeenCalledTimes(2);
const serverUrl = new URL(process.env.Z2M_ONBOARD_URL ?? "http://0.0.0.0:8080");
expect(mockHttpListen).toHaveBeenCalledWith(Number.parseInt(serverUrl.port), serverUrl.hostname, expect.any(Function));
expect(mockHttpListen).toHaveBeenCalledWith(Number.parseInt(serverUrl.port, 10), serverUrl.hostname, expect.any(Function));
return resEnd.mock.calls[0][0];
};
+78 -2
View File
@@ -1,12 +1,13 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from "vitest";
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(() => {});
@@ -916,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"));
});
});
});