Compare commits

..
219 Commits
Author SHA1 Message Date
Koen Kanters e9889e5d20 chore(dev): release 2.6.1 (#28144) 2025-09-01 20:57:13 +02:00
Koen Kanters 77886530fe fix(ignore): update zigbee-herdsman to 6.0.4 (#28438) 2025-09-01 18:52:39 +00:00
Koen Kanters bf22ddac46 fix(ignore): update zigbee-herdsman-converters to 25.14.0 (#28439) 2025-09-01 18:18:38 +00:00
Koen Kanters 203019aa26 fix(ignore): update zigbee-herdsman-converters to 25.13.0 (#28427) 2025-08-31 13:14:19 +00:00
Nerivec 08903f68d9 fix(ignore): update zigbee2mqtt-windfront to 2.0.1 (#28420) 2025-08-30 18:57:33 +00:00
Koen Kanters bdeab61001 fix(ignore): update zigbee-herdsman-converters to 25.12.0 (#28417) 2025-08-30 12:03:46 +00:00
Koen Kanters c07fce936f fix(ignore): update zigbee-herdsman-converters to 25.11.0 (#28407) 2025-08-28 18:31:42 +00:00
Koen Kanters 264440ebcc fix(ignore): update zigbee-herdsman to 6.0.2 (#28406) 2025-08-28 18:19:11 +00:00
Koen Kanters 32d82651e2 fix(ignore): update zigbee-herdsman-converters to 25.10.0 (#28399) 2025-08-27 18:29:45 +00:00
NerivecandKoen Kanters 9079c16d16 chore: switch benchmarking tool (#28396)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2025-08-27 20:12:30 +02:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Nerivec
a8fd23b6cc fix(ignore): bump the minor-patch group with 2 updates (#28383)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
2025-08-27 19:58:35 +02:00
Nerivec 2df363f3af fix: update zigbee2mqtt-windfront to 2.0.0 (#28389) 2025-08-26 15:47:44 +00:00
Koen Kanters 0147441c93 fix(ignore): update zigbee-herdsman-converters to 25.9.0 (#28380) 2025-08-25 20:49:30 +02:00
Koen Kanters 4d52fdd290 chore: Update changelog generation to include zigbee2mqtt-windfront (#28345) 2025-08-25 20:42:08 +02:00
Koen Kanters 8e6ba7975b fix(ignore): update zigbee-herdsman-converters to 25.8.0 (#28373) 2025-08-24 19:23:32 +00:00
Koen Kanters 7bd7bce69c fix(ignore): update zigbee-herdsman-converters to 25.7.0 (#28361) 2025-08-22 14:36:25 +00:00
Koen Kanters 4bd0c04bdc fix(ignore): update zigbee-herdsman-converters to 25.6.0 (#28344) 2025-08-20 18:50:40 +00:00
Joseph Spiros ccfab84e7f fix: Home Assistant: use "occupancy" device_class for human presence sensors (#28329) 2025-08-20 20:14:23 +02:00
Koen Kanters 23f831a1fe fix(ignore): update zigbee-herdsman-converters to 25.5.0 (#28328) 2025-08-19 18:35:14 +00:00
Koen KantersandNerivec 384bd309a6 fix(ignore): update zigbee-herdsman-converters to 25.4.0 (#28313)
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
2025-08-18 21:33:42 +02:00
Koen KantersandNerivec 5f7c00515a fix(ignore): update zigbee-herdsman-converters to 25.3.0 (#28301)
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
2025-08-18 21:33:42 +02:00
Koen Kanters bed8beccbc fix(ignore): update zigbee-herdsman to 6.0.1 (#28287) 2025-08-18 21:33:42 +02:00
Nerivec b21f926b3e fix: Support for new ZH typing (#28257) 2025-08-18 21:33:42 +02:00
Nerivec 6e7cc30da7 chore: skip unnecessary deps in dev-types branch (#28312) 2025-08-18 21:22:02 +02:00
Koen Kanters 4c9784255b fix(ignore): update zigbee-herdsman-converters to 25.2.0 (#28280) 2025-08-15 10:44:18 +00:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Nerivec
1dd6cc3da5 fix(ignore): bump the minor-patch group with 5 updates (#28237)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
2025-08-14 20:52:49 +02:00
Nerivec d04ab53f1d fix(ignore): update zigbee2mqtt-windfront to 1.8.1 (#28272) 2025-08-14 10:52:50 +00:00
Nerivec f4cfcbacde fix(ignore): update zigbee2mqtt-windfront to 1.8.0 (#28266) 2025-08-13 22:52:18 +00:00
Koen Kanters 2b1780879a fix(ignore): update zigbee-herdsman-converters to 25.1.0 (#28264) 2025-08-13 19:39:33 +00:00
Koen Kantersandnurikk 800b85c812 fix(ignore): update zigbee2mqtt-frontend to 0.9.20 (#28250)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2025-08-12 18:09:26 +00:00
Alexis "Horgix" Chotard c5fa13215c chore(github): fix link to "fail to start" FAQ in problem report template (#28244) 2025-08-12 19:42:19 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 6414ade05d chore(deps): bump actions/checkout from 4 to 5 (#28238)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-12 19:36:31 +02:00
Koen Kanters 5286570bd6 fix: Update to refactored zhc api and fix onEvent(stop) not being called when device is force removed (#27984) 2025-08-11 20:32:59 +02:00
Koen Kanters 3160219824 fix(ignore): update zigbee-herdsman-converters to 25.0.0 (#28236) 2025-08-11 20:26:49 +02:00
Koen Kanters bd1be88602 fix(ignore): update zigbee-herdsman to 6.0.0 (#28235) 2025-08-11 20:26:07 +02:00
Nerivec a102fac2c8 fix: Set minimum for some settings (#28209) 2025-08-09 14:40:27 +02:00
Nerivec 63a4207061 fix: Always unlink node_modules symlinks on stop (#28202) 2025-08-08 19:38:04 +02:00
Nerivec 092c95b716 fix(ignore): update zigbee2mqtt-windfront to 1.6.0 (#28207) 2025-08-08 10:55:58 +00:00
Koen Kanters 09f046c72d fix(ignore): update zigbee-herdsman-converters to 24.14.0 (#28199) 2025-08-07 18:28:28 +00:00
Kamil Breguła b7cbd9b5d8 fix: Home Assistant: set icon and state class for orientation sensors (#28192) 2025-08-07 19:29:22 +02:00
Koen Kanters e12add17be chore: merge master to dev 2025-08-06 20:04:47 +02:00
dependabot[bot] bda5bf9e96 chore(deps-dev): bump tmp from 0.2.3 to 0.2.4 (#28186) 2025-08-06 20:04:33 +02:00
Koen Kanters 2056b89bac fix(ignore): update zigbee-herdsman-converters to 24.13.0 (#28175) 2025-08-05 19:04:52 +00:00
Koen Kanters d698770169 Revert "Revert "chore: cleanup Dockerfile (#28166)""
This reverts commit b41e3e781e.
2025-08-05 19:44:31 +02:00
Koen Kanters b41e3e781e Revert "chore: cleanup Dockerfile (#28166)"
This reverts commit e8cec3542f.
2025-08-04 21:40:19 +02:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>NerivecKoen Kanters
b72abd675f fix(ignore): bump the minor-patch group with 4 updates (#28147)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2025-08-04 21:32:51 +02:00
Koen Kanters 9836f0e27a fix(ignore): update zigbee-herdsman-converters to 24.12.0 (#28170) 2025-08-04 19:27:39 +00:00
NerivecandKoen Kanters e8cec3542f chore: cleanup Dockerfile (#28166)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2025-08-04 20:53:07 +02:00
krkk 050ebfc9f5 fix: Remove 60 MB (28%) of dev dependencies from Docker image (#28160) 2025-08-04 20:36:44 +02:00
github-actions[bot] 449b99b0c0 chore: promote to dev 2025-08-01 15:52:58 +00:00
Koen Kanters 0bdc3fd1f7 chore(dev): release 2.6.0 (#27881) 2025-08-01 17:37:30 +02:00
Koen Kanters 376554943d fix(ignore): update zigbee-herdsman-converters to 24.11.0 (#28143) 2025-08-01 15:22:20 +00:00
Koen Kanters 9fcc41b199 fix(ignore): update zigbee-herdsman to 5.0.4 (#28141) 2025-08-01 15:04:45 +00:00
Nerivec fe5639ce40 fix(ignore): update zigbee2mqtt-windfront to 1.5.2 (#28140) 2025-08-01 14:56:52 +00:00
Rob van OostenrijkandNerivec 0182c4b32e fix: Ignore symbolic links in data during backup (#28132)
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
2025-07-31 20:26:36 +02:00
Koen Kanters c12f07c5a0 chore: don't run codspeed on release-please prs 2025-07-30 21:34:29 +02:00
Nerivec 31d3e19f38 fix(ignore): update zigbee2mqtt-windfront to 1.5.1 (#28123) 2025-07-30 19:06:35 +00:00
Koen Kanters ee1c884031 fix(ignore): update zigbee-herdsman-converters to 24.10.0 (#28122) 2025-07-30 18:55:55 +00:00
Koen Kanters e5f677bdd1 fix(ignore): update zigbee-herdsman to 5.0.3 (#28121) 2025-07-30 18:48:26 +00:00
Koen Kanters 6dd812f71c fix(ignore): fix 71cf2b4160 2025-07-29 20:47:51 +02:00
Koenkk 71cf2b4160 fix(ignore): update zigbee-herdsman-converters to 24.9.0 2025-07-29 18:34:40 +00:00
Koen Kanters e9f2a3f532 fix(ignore): update zigbee-herdsman to 5.0.2 (#28110) 2025-07-29 18:34:03 +00:00
Nerivec 0725374db7 chore: update readme (#28085) 2025-07-27 13:01:47 +02:00
Nerivec 6f7c6eec04 chore: import vitest instead of relying on globals (#28084) 2025-07-26 20:05:13 +02:00
Nerivec 29e56adc15 fix(ignore): update zigbee2mqtt-windfront to 1.5.0 (#28082) 2025-07-26 17:00:38 +00:00
Koen KantersandNerivec caf91cd7ff fix(ignore): update zigbee-herdsman-converters to 24.8.0 (#28081)
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
2025-07-26 16:37:46 +00:00
Koen Kanters 6432571c61 fix(ignore): update zigbee-herdsman to 5.0.1 (#28080) 2025-07-26 11:13:06 +00:00
Koen Kanters 713551b525 fix(ignore): fix breaking changes of https://github.com/Koenkk/zigbee2mqtt/pull/28077 2025-07-25 20:30:48 +02:00
Koen Kanters 9a215efe26 fix(ignore): update zigbee-herdsman to 5.0.0 (#28077) 2025-07-25 18:25:44 +00:00
Nerivec 65eef1b690 fix: Publish definition source in bridge/devices (#28076) 2025-07-25 19:22:17 +02:00
Koen Kanters 81bafa5520 fix(ignore): update zigbee-herdsman-converters to 24.7.0 (#28070) 2025-07-24 18:37:06 +00:00
Nerivec d322dc2047 fix(ignore): update zigbee2mqtt-windfront to 1.4.1 (#28062) 2025-07-23 19:37:34 +00:00
Nerivec 5f9019a95f fix: Publish endpoint names in bridge/devices (#28057) 2025-07-23 20:12:16 +02:00
Koen Kanters 4bc6a59ff3 fix(ignore): update zigbee-herdsman-converters to 24.6.0 (#28051) 2025-07-22 18:26:50 +00:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Nerivec
e505a93a40 fix(ignore): bump the minor-patch group with 4 updates (#28046)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
2025-07-22 19:58:18 +02:00
Koen Kanters bc74d0b11b fix(ignore): update zigbee-herdsman-converters to 24.5.0 (#28045) 2025-07-21 18:23:20 +00:00
Koen Kanters cc6f7b02bc fix(ignore): update zigbee-herdsman to 4.5.0 (#28044) 2025-07-21 18:18:32 +00:00
Koen Kanters 74e9357a59 fix(ignore): update zigbee-herdsman-converters to 24.4.0 (#28022) 2025-07-18 20:13:44 +00:00
Koen Kantersandnurikk 8b876823da fix(ignore): update zigbee2mqtt-frontend to 0.9.19 (#28021)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2025-07-18 22:05:48 +02:00
Nerivec db20369447 fix: Various fixes to align behaviors (+add benchmarks for dev) (#28011) 2025-07-18 21:34:51 +02:00
Koen Kanters 0e0a5618af fix: Exit with code 2 when adapters disconnects (#28013) 2025-07-18 20:32:39 +02:00
Koen Kanters 21fa5cbded fix(ignore): update zigbee-herdsman-converters to 24.3.0 (#28015) 2025-07-17 12:28:32 +00:00
Koen Kanters e2b7df9f84 fix(ignore): Remove retain/qos from mqtt publish logging https://github.com/Koenkk/zigbee2mqtt/pull/27983 2025-07-15 13:56:17 +02:00
Donald FlissingerandKoen Kanters ef07505b31 fix: Set MQTT retain and qos=1 on (re)connect (#27983)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2025-07-15 13:29:57 +02:00
Nerivec 435a6cceb9 fix: Improve startup settings validation (#27988) 2025-07-15 13:15:11 +02:00
Nerivec a51216e3c7 fix(ignore): update zigbee2mqtt-windfront to 1.3.1 (#27999) 2025-07-15 10:55:37 +00:00
Koen Kanters 6ede403688 fix(ignore): update zigbee-herdsman-converters to 24.2.0 (#27986) 2025-07-14 11:49:06 +00:00
NerivecandKoen Kanters ecadda45e7 fix: Fix settings schema defaults (#27961)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2025-07-12 18:36:56 +02:00
Koen Kanters a837702b2c fix(ignore): update zigbee-herdsman-converters to 24.1.0 (#27973) 2025-07-12 12:40:05 +00:00
Koen Kanters 57e04bb547 chore: merge master to dev 2025-07-12 13:36:44 +02:00
Koen Kanters ffdd353e16 chore: Add device database.db entry to problem report template 2025-07-12 13:36:24 +02:00
Nerivec 0215e76118 feat: Allow to disable internal frontend UI serving for standalone serving (#27851) 2025-07-12 09:33:26 +02:00
Nerivec 833605b71f fix(ignore): add test to specifically check HA envs (#27969) 2025-07-11 19:53:05 +02:00
Boris Conforty f4d821deca fix: log error message when external JS fails to load (#27837) 2025-07-11 17:36:06 +02:00
Koen Kanters c465ef8352 fix(ignore): update zigbee-herdsman-converters to 24.0.0 (#27946) 2025-07-10 13:06:50 +00:00
Koen Kanters 0dd4b6a991 chore: fix Docker Dependabot 2025-07-10 14:45:45 +02:00
Nerivec 1b37baefd3 fix(ignore): update zigbee2mqtt-windfront to 1.3.0 (#27953) 2025-07-09 22:37:02 +00:00
Koen Kanters b4a9424d60 fix(ignore): update zigbee-herdsman-converters to 23.72.0 (#27943) 2025-07-09 12:48:46 +00:00
Koen Kanters 93c1824b1d fix(ignore): update zigbee-herdsman-converters to 23.71.0 (#27931) 2025-07-08 11:27:01 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 8ceb08704f fix(ignore): bump the minor-patch group with 2 updates (#27924)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-07 20:47:07 +02:00
Koen Kanters eb82973067 fix(ignore): update zigbee-herdsman to 4.4.1 (#27918) 2025-07-06 19:21:12 +00:00
Koen Kantersandnurikk a3e7f69b3f fix(ignore): update zigbee2mqtt-frontend to 0.9.17 (#27917)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2025-07-06 21:15:40 +02:00
Koen Kanters 373fa43136 chore: change dependabot versioning strategy to increase 2025-07-06 21:13:57 +02:00
Koen Kanters e04732462c fix(ignore): update zigbee-herdsman-converters to 23.70.1 (#27916) 2025-07-06 19:01:12 +00:00
Koen Kanters e663e86e09 fix(ignore): update zigbee-herdsman to 4.4.0 (#27906) 2025-07-04 18:37:18 +00:00
Koen Kanters 163a15d664 fix(ignore): update zigbee-herdsman-converters to 23.70.0 (#27905) 2025-07-04 20:34:17 +02:00
Koen Kanters 4afdd4e892 fix(ignore): update zigbee-herdsman to 4.3.2 (#27900) 2025-07-04 11:47:05 +00:00
Nerivec 190cfe99ac fix: erroneous Health OS memory percent calculation (#27892) 2025-07-03 16:31:03 +02:00
github-actions[bot] c9284f6b33 chore: promote to dev 2025-07-02 19:48:07 +00:00
Koen Kanters 5063c7d3ad chore(dev): release 2.5.1 (#27879) 2025-07-02 21:36:29 +02:00
Koen Kanters 4dd7dabf08 fix: Various Windfront frontend improvements @nerivec (#27880) 2025-07-02 21:35:07 +02:00
Koen Kanters 917d8527a5 fix(ignore): update zigbee-herdsman to 4.3.1 (#27878) 2025-07-02 19:15:36 +00:00
github-actions[bot] f307467e37 chore: promote to dev 2025-07-01 18:38:36 +00:00
Koen Kanters c0cd2c6b96 chore(dev): release 2.5.0 (#27573) 2025-07-01 20:27:23 +02:00
Koen Kanters 4a4ee8888d chore: fix ci not triggering on tag 2025-07-01 20:27:16 +02:00
Koen Kanters 5285b7b95e fix(ignore): update zigbee-herdsman-converters to 23.69.0 (#27860) 2025-07-01 17:53:36 +00:00
dependabot[bot] c8dc26b241 fix(ignore): bump the minor-patch group across 1 directory with 4 updates (#27858) 2025-07-01 17:59:28 +02:00
Koen Kanters 25ea77e038 chore: fix ci not running for pull request 2025-06-30 21:05:47 +02:00
Koen Kanters 50c7d2d6ad fix(ignore): update zigbee-herdsman to 4.3.0 (#27853) 2025-06-30 18:40:20 +00:00
Koen Kanters 3ccbaf21d2 fix(ignore): update zigbee-herdsman-converters to 23.68.0 (#27852) 2025-06-30 18:35:49 +00:00
Koen Kanters 6930fa6f3f fix(ignore): update zigbee-herdsman-converters to 23.67.0 (#27842) 2025-06-29 19:06:51 +00:00
Koen Kanters 16faef1511 fix(ignore): update zigbee-herdsman to 4.2.1 (#27835) 2025-06-28 08:24:18 +00:00
Koen Kanters c87d4524d5 fix(ignore): Migrate to Biome 2 (#27785) 2025-06-28 10:20:41 +02:00
Koen Kanters 33234d9ce4 fix(ignore): update zigbee-herdsman-converters to 23.66.0 (#27831) 2025-06-27 18:49:45 +00:00
Koen Kanters 4190ba43b4 fix(ignore): update zigbee-herdsman-converters to 23.65.0 (#27804) 2025-06-24 20:02:56 +00:00
Koen Kanters b7d9c78309 fix(ignore): update zigbee-herdsman-converters to 23.64.0 (#27799) 2025-06-23 18:51:22 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 50b0ffd7e7 fix(ignore): bump the minor-patch group with 2 updates (#27797)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-23 20:35:51 +02:00
Koen Kanters 15f3eaf966 fix(ignore): update zigbee-herdsman to 4.2.0 (#27786) 2025-06-22 20:14:19 +00:00
Koen Kanters 0d5de750ff fix(ignore): update zigbee-herdsman-converters to 23.63.0 (#27784) 2025-06-22 19:32:39 +00:00
Koen Kantersandnurikk 7109900bcf fix(ignore): update zigbee2mqtt-frontend to 0.9.16 (#27783)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2025-06-22 19:26:13 +00:00
Koen Kanters 281e10c15f fix(ignore): update zigbee-herdsman-converters to 23.62.0 (#27767) 2025-06-19 18:23:04 +00:00
Nerivec cdad6ccd75 fix(ignore): update zigbee2mqtt-windfront to 1.1.0 (#27763) 2025-06-19 19:49:25 +02:00
Koen Kanters b537f9a93a fix(ignore): update zigbee-herdsman-converters to 23.61.0 (#27759) 2025-06-18 19:57:08 +00:00
Koen Kanters 1b56e63aab chore: Use commit SHA instead of Update in dev-types commit message https://github.com/Koenkk/zigbee2mqtt/pull/27746 2025-06-18 20:00:38 +02:00
Koen Kanters f33d3606be fix(ignore): update zigbee-herdsman-converters to 23.60.0 (#27752) 2025-06-17 18:25:09 +00:00
Koen Kanters fb60cfde38 chore: Publish types (#27746) 2025-06-17 20:20:34 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> de800450ad fix(ignore): bump @types/node from 22.15.30 to 24.0.3 (#27745)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-16 19:52:03 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 977672976f fix(ignore): bump zigbee2mqtt-windfront from 1.0.5 to 1.0.6 in the minor-patch group (#27744)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-16 19:51:52 +02:00
NerivecandKoen Kanters cd9b752ede feat: New health extension & extras in bridge/info (#27164)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2025-06-16 19:45:36 +02:00
Koen Kanters 242815e139 fix(ignore): update zigbee-herdsman to 4.1.2 (#27729) 2025-06-15 11:47:16 +00:00
Koen Kanters 87ff0f3711 fix(ignore): update zigbee-herdsman-converters to 23.59.0 (#27712) 2025-06-13 18:45:44 +00:00
Koen Kantersandnurikk 8ecb9be98e fix(ignore): update zigbee2mqtt-frontend to 0.9.15 (#27711)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2025-06-13 18:23:04 +00:00
NerivecandKoen Kanters 0e66b29983 fix: Generate group ID if empty string given (#27702)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2025-06-12 20:28:55 +02:00
Koen Kanters 6a8d20858e fix(ignore): update zigbee-herdsman-converters to 23.58.0 (#27695) 2025-06-11 18:14:46 +00:00
Koen Kanters e4b253e08b fix: Fix devices not immediately marked as offline when timeout expires (#27691) 2025-06-11 19:48:52 +02:00
Koen Kanters 56d3a8f9d8 fix(ignore): update zigbee-herdsman-converters to 23.57.1 (#27689) 2025-06-10 19:10:57 +00:00
Koen Kanters f6c3887c81 fix: Bump Alpine to 3.22 (#27690) 2025-06-10 21:05:39 +02:00
Koen Kanters 2affe188c5 chore: bump pnpm 2025-06-10 20:25:01 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 587a02f663 fix(ignore): bump the minor-patch group with 6 updates (#27678)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-10 20:09:49 +02:00
Koen Kanters 0181263d32 fix: Rename fix typo in backoff settings https://github.com/Koenkk/zigbee2mqtt/issues/27642 2025-06-08 19:34:05 +02:00
Nerivec eaa3e458b5 fix(ignore): update zigbee2mqtt-windfront to 1.0.5 (#27656) 2025-06-08 18:33:34 +02:00
Koen Kanters 252bfbaa20 fix(ignore): update zigbee-herdsman-converters to 23.57.0 (#27646) 2025-06-07 17:27:45 +00:00
Koen Kantersandnurikk 66e1318a79 fix(ignore): update zigbee2mqtt-frontend to 0.9.14 (#27645)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2025-06-07 17:23:03 +00:00
Nerivec 9a4ce6fcd0 fix: update zigbee2mqtt-windfront to 1.0.4 (#27641) 2025-06-06 23:57:37 +02:00
Koen Kanters 4b95b54be7 fix(ignore): update zigbee-herdsman to 4.1.1 (#27640) 2025-06-06 20:13:33 +00:00
Koen Kanters f637e7d59c fix(ignore): update zigbee-herdsman-converters to 23.56.0 (#27639) 2025-06-06 19:41:31 +00:00
Nerivec 018fad27af fix(ignore): API correctness (#27637) 2025-06-06 20:01:07 +02:00
Koen Kanters 4594142c09 fix(ignore): update zigbee-herdsman-converters to 23.55.0 (#27618) 2025-06-04 18:16:29 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> ac80bb6430 fix(ignore): bump the minor-patch group with 4 updates (#27610)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-03 21:15:11 +02:00
Koen Kanters 1765fe8925 fix(ignore): update zigbee-herdsman-converters to 23.54.0 (#27600) 2025-06-02 18:11:51 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 7e82a3d1e9 fix(ignore): bump the minor-patch group with 3 updates (#27598)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-02 19:51:26 +02:00
github-actions[bot] 9df6cccd31 chore: promote to dev 2025-06-01 18:18:38 +00:00
Koen Kanters bdb94da46e chore(dev): release 2.4.0 (#27277) 2025-06-01 20:06:39 +02:00
Koen Kanters e0b0248172 fix(ignore): update zigbee-herdsman-converters to 23.53.0 (#27572) 2025-06-01 18:00:44 +00:00
Nerivec 5e44cf77b3 fix(ignore): update zigbee2mqtt-windfront to 1.0.2 (#27567) 2025-06-01 19:43:23 +02:00
Nerivec 621cae2a63 fix(ignore): networkmap typing (#27566) 2025-06-01 19:42:24 +02:00
Koen Kanters 711b456b06 fix(ignore): update zigbee-herdsman-converters to 23.52.1 (#27563) 2025-05-31 11:48:01 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 278e3fb40e fix(ignore): bump the minor-patch group across 1 directory with 2 updates (#27562)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-31 13:42:59 +02:00
Koen Kanters 6ed18c757d fix(ignore): update zigbee-herdsman-converters to 23.52.0 (#27559) 2025-05-31 11:38:28 +00:00
Koen Kanters 1ca24700f2 chore: merge master to dev 2025-05-31 12:34:46 +02:00
Koen Kanters cefc1dbf8d chore: update dependabot config 2025-05-31 12:34:21 +02:00
Nerivec a5e4e9ef61 fix: API missing a few request endpoints (#27551) 2025-05-30 19:18:28 +02:00
Koen Kanters f5920fd53b fix(ignore): update zigbee-herdsman-converters to 23.51.0 (#27540) 2025-05-28 18:43:03 +00:00
Koen Kanters a289f981bf fix(ignore): Fix passive states not updating in frontend (#27532) 2025-05-28 19:28:52 +02:00
Koen Kanters 00dda245e4 fix(ignore): update zigbee-herdsman-converters to 23.50.1 (#27531) 2025-05-27 19:09:21 +00:00
Koen Kanters 1960e026d2 fix(ignore): update zigbee-herdsman-converters to 23.50.0 (#27521) 2025-05-26 19:15:36 +00:00
Ilya Kirov 4bf45630e2 fix: HA integration for external sensors Aqara W100 (#27520) 2025-05-26 21:09:43 +02:00
Ilya Kirov ed03c40dad fix: Fix import error on Windows (ERR_UNSUPPORTED_ESM_URL_SCHEME) (#27519) 2025-05-26 20:32:45 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 02b08834e4 chore(deps-dev): bump the minor-patch group with 4 updates (#27518)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-26 20:31:28 +02:00
Nerivec 1b85968e5e feat: WindFront new frontend support (#27489) 2025-05-26 20:14:34 +02:00
Koen Kanters ea49d1ae44 fix(ignore): update zigbee-herdsman-converters to 23.49.0 (#27479) 2025-05-22 18:40:25 +00:00
Dustin Utecht a085b92c68 chore: Fixed brokenzStackEraseAllNvMem script (#27473) 2025-05-22 20:18:52 +02:00
Koen Kanters 1303340aaf fix(ignore): update zigbee-herdsman-converters to 23.48.0 (#27470) 2025-05-21 20:00:09 +00:00
Koen Kanters 693f0d0a35 fix(ignore): update zigbee-herdsman-converters to 23.47.0 (#27463) 2025-05-20 20:01:26 +00:00
Koen Kanters 3edcfd7ff6 fix(ignore): update zigbee-herdsman to 4.1.0 (#27462) 2025-05-20 19:32:54 +00:00
Koen Kanters 0ead302c0a fix: Don't copy external JS to dist folder (#27397) 2025-05-19 21:01:51 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 418991386e chore(deps): bump the minor-patch group with 2 updates (#27448)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-19 20:32:14 +02:00
Koen Kanters 7449c2357c fix(ignore): update zigbee-herdsman-converters to 23.46.0 (#27444) 2025-05-18 18:17:55 +00:00
Koen Kanters 8c3252c213 fix(ignore): update zigbee-herdsman-converters to 23.45.0 (#27422) 2025-05-16 20:19:33 +00:00
Koen Kanters 21c9faffaf fix(ignore): update zigbee-herdsman-converters to 23.44.0 (#27404) 2025-05-14 19:04:59 +00:00
Koen Kanters e0611269f3 fix(ignore): update zigbee-herdsman-converters to 23.43.0 (#27394) 2025-05-13 18:26:20 +00:00
Koen Kanters edcfe32572 fix(ignore): update zigbee-herdsman-converters to 23.42.0 (#27392) 2025-05-12 19:46:59 +00:00
Nerivec d697c29c7b fix: Rerun onboarding if Z2M start failed after previous onboarding (#27386) 2025-05-12 21:33:55 +02:00
dependabot[bot] ba41985667 chore(deps): bump humanize-duration from 3.32.1 to 3.32.2 in the minor-patch group (#27388) 2025-05-12 19:32:50 +02:00
Koen Kantersandnurikk f656d9629c fix(ignore): update zigbee2mqtt-frontend to 0.9.13 (#27382)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2025-05-11 20:00:25 +00:00
Koen Kanters cc3521c877 fix(ignore): update zigbee-herdsman to 4.0.2 (#27380) 2025-05-11 19:31:37 +00:00
Koen Kantersandnurikk 615c53c93c fix(ignore): update zigbee2mqtt-frontend to 0.9.12 (#27379)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2025-05-11 13:11:10 +00:00
Koen Kanters f7e50519e3 fix(ignore): update zigbee-herdsman-converters to 23.41.0 (#27371) 2025-05-10 12:37:58 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> e09d5fd526 chore(deps): bump express-static-gzip from 2.2.0 to 3.0.0 (#27369)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-10 14:27:11 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 8ef01fd9b1 chore(deps): bump the minor-patch group with 2 updates (#27368)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-10 14:26:52 +02:00
Koen Kanters 19116995bb chore: merge master to dev 2025-05-10 14:18:22 +02:00
Koen Kanters 9d1427c104 chore: Add target-branch for all Dependabot entries 2025-05-10 14:17:59 +02:00
Koen Kanters 317c9b4046 fix(ignore): update zigbee-herdsman-converters to 23.40.1 (#27358) 2025-05-08 18:46:32 +00:00
Koen Kanters aad87cc3ec fix: Drop Node 18 support, add 24 (#27348) 2025-05-07 21:26:13 +02:00
Koen Kantersandbo0tzz d8855b8e6f fix: Fix bind/unbind by coordinator ieeeAddr not working (#27333)
Co-authored-by: bo0tzz <git@bo0tzz.me>
2025-05-07 21:15:05 +02:00
Koen Kanters 7a4f2869b9 fix(ignore): update zigbee-herdsman-converters to 23.40.0 (#27347) 2025-05-07 19:01:10 +00:00
bo0tzzandKoen Kanters 40652d8a8f chore: feature requests don't go stale (#27336)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2025-05-07 20:42:51 +02:00
Koen Kanters 1d6ba5393a fix(ignore): update zigbee-herdsman-converters to 23.39.0 (#27340) 2025-05-06 20:10:28 +00:00
Koen Kanters 69364c958f fix: Trim name on rename (#27332) 2025-05-06 21:49:24 +02:00
Koen Kanters 164c3222b9 fix(ignore): update zigbee-herdsman-converters to 23.38.0 (#27329) 2025-05-05 18:03:22 +00:00
Koen Kanters a8ba7eb552 chore: merge master to dev 2025-05-05 19:51:41 +02:00
Koen Kanters 5ff2148016 chore: fully switch to Dependabot 2025-05-05 19:51:23 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> bca4bfa074 chore(deps): bump the minor-patch group with 3 updates (#27327)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-05 19:36:30 +02:00
0xdefec71f 3cbcc4a0ab fix: Improve update script (#27308) 2025-05-04 14:43:22 +02:00
Koen Kanters ff823dbaf0 fix(ignore): update zigbee-herdsman-converters to 23.37.0 (#27300) 2025-05-03 06:56:48 +00:00
Koen Kantersandnurikk 3cb63aa922 fix(ignore): update zigbee2mqtt-frontend to 0.9.11 (#27301)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2025-05-03 06:50:40 +00:00
Koen Kanters b7b92da8ff fix(ignore): Cleanup publish options (#27226) 2025-05-03 08:45:45 +02:00
Koen Kanters 39e4468308 fix: Only send JSON state over WebSocket (#27225) 2025-05-03 08:42:07 +02:00
github-actions[bot] 908ecb3e2a chore: promote to dev 2025-05-01 18:34:36 +00:00
87 changed files with 4281 additions and 2080 deletions
+15 -9
View File
@@ -2,19 +2,25 @@ blank_issues_enabled: false
contact_links:
- name: 'IMPORTANT: Check development branch changelog first!!'
url: https://gist.github.com/Koenkk/bfd4c3d1725a2cccacc11d6ba51008ba
about: "Before submitting an issue, check that the issue hasn't already been solved in the development branch. Click 'Open' to see the release notes of the development branch. In case it is, you can read here how to switch to the development branch: https://www.zigbee2mqtt.io/advanced/more/switch-to-dev-branch.html"
- name: Questions/discussion
about: Before submitting an issue, check that it has not already been solved in the development branch. Click here to see the release notes of the development branch.
- name: Switch to the development branch
url: https://www.zigbee2mqtt.io/advanced/more/switch-to-dev-branch.html
about: If the development branch solves an issue you are facing, click here to see the instructions to switch to the development branch.
- name: Questions/discussions
url: https://github.com/Koenkk/zigbee2mqtt/discussions/new
about: Ask questions, discuss about devices or show things you made
- name: Frontend issues
url: https://github.com/nurikk/z2m-frontend/issues
about: Issues related to the frontend.
about: Ask questions, discuss devices, show things you made...
- name: Original frontend issues
url: https://github.com/nurikk/zigbee2mqtt-frontend/issues
about: Issues with the frontend package zigbee2mqtt-frontend
- name: WindFront frontend issues
url: https://github.com/Nerivec/zigbee2mqtt-windfront/issues
about: Issues with the frontend package zigbee2mqtt-windfront
- name: Home Assistant addon issues
url: https://github.com/zigbee2mqtt/hassio-zigbee2mqtt/issues
about: Issues related to the Home Assistant addon.
about: Issues with the Home Assistant addon
- name: FAQ
url: https://www.zigbee2mqtt.io/guide/faq
about: Frequently asked questions.
about: Frequently asked questions
- name: Support Chat
url: https://discord.gg/NyseBeK
about: Chat for feedback, questions and troubleshooting.
about: Chat for feedback, questions and troubleshooting
+7 -1
View File
@@ -6,7 +6,7 @@ body:
attributes:
value: |
**IMPORTANT:** Before submitting:
- Zigbee2MQTT fails to start? Read [this](https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start.html)
- Zigbee2MQTT fails to start? Read [this](https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start_crashes-runtime.html)
- Raspberry Pi users? Make sure to check [this](https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start_crashes-runtime.html#raspberry-pi-users-use-a-good-power-supply)
- You read the [FAQ](https://www.zigbee2mqtt.io/guide/faq/)
- Are you using an EZSP adapter (e.g. Dongle-E/SkyConnect)? Try the [new driver](https://github.com/Koenkk/zigbee2mqtt/discussions/21462)
@@ -69,6 +69,12 @@ body:
placeholder: Add-on on Home Assistant OS on Intel NUC, Plain on Docker container, ...
validations:
required: true
- type: input
id: device
attributes:
label: Device `database.db` entry
description: If the problem is related to a specific device, paste the `database.db` entry here
placeholder: '{"id":117,"type":"EndDevice","ieeeAddr":"0x00158d0001d8e1d2","nwkAddr":25887 ...'
- type: textarea
id: log
attributes:
+13
View File
@@ -1,13 +1,26 @@
version: 2
updates:
- package-ecosystem: npm
versioning-strategy: increase
directory: /
schedule:
interval: weekly
target-branch: dev
commit-message:
prefix: fix(ignore)
groups:
minor-patch:
applies-to: version-updates
update-types:
- minor
- patch
- package-ecosystem: docker
directory: /docker
schedule:
interval: weekly
target-branch: dev
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
target-branch: dev
+47 -7
View File
@@ -1,6 +1,13 @@
name: CI
on: [pull_request, push]
on:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
branches:
- dev
- master
pull_request:
permissions:
contents: write
@@ -10,22 +17,24 @@ jobs:
ci:
runs-on: ubuntu-latest
permissions:
id-token: write
id-token: write # for npm publish
actions: read # for Nerivec/action-ci-bench
pull-requests: write # for Nerivec/action-ci-bench
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
with:
# Required for `release: merge dev -> master and promote dev`
token: ${{ secrets.GH_TOKEN }}
- uses: actions/checkout@v4
- uses: actions/checkout@v5
if: ((github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push') == false
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
node-version: 24
registry-url: https://registry.npmjs.org/
cache: pnpm
@@ -41,6 +50,16 @@ jobs:
- name: Test
run: pnpm run test:coverage
- name: Bench
if: github.ref == 'refs/heads/dev' || (github.event_name == 'pull_request' && !startsWith(github.head_ref, 'release-please-'))
uses: Nerivec/action-ci-bench@main
with:
token: ${{ secrets.GITHUB_TOKEN }}
compare-against: dev
base-cmd: npx vitest bench --run --config ./test/vitest.config.mts --outputJson bench.json
compare-cmd: npx vitest bench --run --config ./test/vitest.config.mts --compare bench.json
base-result: bench.json
- name: Log in to the Docker container registry
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
uses: docker/login-action@v3
@@ -83,6 +102,27 @@ jobs:
VERSION=dev
DATE=${{ github.event.repository.updated_at }}
- name: dev - Publish types
if: github.ref == 'refs/heads/dev' && github.event_name == 'push'
run: |
pnpm run build:types
temp_dir=$(mktemp -d)
cp -R package.json LICENSE dist $temp_dir
git fetch --unshallow
git checkout dev-types
rm -rf *
cp -R $temp_dir/* .
# remove unnecessary dependencies
KEEP_DEPS='["zigbee-herdsman","zigbee-herdsman-converters"]'
tmp_pkg=$(mktemp)
jq --indent 4 --argjson keep "$KEEP_DEPS" 'def filterDeps: (. // {}) | with_entries(select(.key as $k | $keep | index($k))); .dependencies |= filterDeps | .devDependencies |= filterDeps | .optionalDependencies |= filterDeps' package.json > "$tmp_pkg"
mv "$tmp_pkg" package.json
git add -A
git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git commit -m "$GITHUB_SHA" || echo 'Nothing to commit'
git push --force --set-upstream origin dev-types
- name: release - Docker meta
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
uses: docker/metadata-action@v5
@@ -175,11 +215,11 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: [18, 20, 22]
node: [20, 22, 24]
runs-on: ${{ matrix.os }}
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- 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@v4
uses: actions/checkout@v5
- name: 'Dependency review'
uses: actions/dependency-review-action@v4
+1 -1
View File
@@ -9,7 +9,7 @@ jobs:
merge-master-to-dev:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: devmasx/merge-branch@master
with:
type: now
+5 -4
View File
@@ -22,7 +22,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
node-version: 24
- uses: googleapis/release-please-action@v4
id: release
@@ -31,11 +31,11 @@ jobs:
token: ${{secrets.GH_TOKEN}}
# Checkout repos
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
repository: koenkk/zigbee2mqtt
path: ./z2m
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
repository: koenkk/zigbee2mqtt
path: ./z2m-master
@@ -54,10 +54,11 @@ jobs:
MASTER_ZHC_VERSION=$(cat z2m-master/package.json | jq -r '.dependencies."zigbee-herdsman-converters"')
MASTER_ZH_VERSION=$(cat z2m-master/package.json | jq -r '.dependencies."zigbee-herdsman"')
MASTER_FRONTEND_VERSION=$(cat z2m-master/package.json | jq -r '.dependencies."zigbee2mqtt-frontend"')
MASTER_WINDFRONT_VERSION=$(cat z2m-master/package.json | jq -r '.dependencies."zigbee2mqtt-windfront"')
wget -q -O - https://raw.githubusercontent.com/Koenkk/zigbee2mqtt/release-please--branches--dev--components--zigbee2mqtt/CHANGELOG.md > z2m/CHANGELOG.md
cd z2m
pnpm i --frozen-lockfile
node scripts/generateChangelog.js $MASTER_Z2M_VERSION $MASTER_ZHC_VERSION $MASTER_ZH_VERSION $MASTER_FRONTEND_VERSION >> ../changelog.md
node scripts/generateChangelog.mjs $MASTER_Z2M_VERSION $MASTER_ZHC_VERSION $MASTER_ZH_VERSION $MASTER_FRONTEND_VERSION $MASTER_WINDFRONT_VERSION >> ../changelog.md
env:
GH_TOKEN: ${{secrets.GH_TOKEN}}
- name: Update changelog gist
+1 -1
View File
@@ -16,5 +16,5 @@ jobs:
stale-pr-message: 'This pull request is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days'
days-before-stale: 60
days-before-close: 7
exempt-issue-labels: dont-stale
exempt-issue-labels: dont-stale,feature-request
operations-per-run: 500
-42
View File
@@ -1,42 +0,0 @@
name: Update dependencies
on:
workflow_dispatch:
schedule:
- cron: '0 0 * * 0'
permissions:
contents: write
pull-requests: write
jobs:
update-dependencies:
if: github.repository == 'Koenkk/zigbee2mqtt'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: dev
token: ${{ secrets.GH_TOKEN }}
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: |
# Disable for now as pnpm updates through corepack
# don't work well yet for bare-metal installations.
# pnpm self-update
pnpm up --latest
- uses: peter-evans/create-pull-request@v7
id: cpr
with:
commit-message: 'fix(ignore): update dependencies'
branch: 'deps/all'
title: 'fix(ignore): update dependencies'
token: ${{ secrets.GH_TOKEN }}
- uses: peter-evans/create-or-update-comment@v4
if: ${{ steps.cpr.outputs.pull-request-number }}
with:
issue-number: ${{ steps.cpr.outputs.pull-request-number }}
body: 'CC: @Koenkk'
+2 -2
View File
@@ -13,14 +13,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
ref: dev
token: ${{ secrets.GH_TOKEN }}
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
node-version: 24
cache: pnpm
- run: |
pnpm install ${{ github.event.client_payload.package }}@${{ github.event.client_payload.version }} --save-exact
+2
View File
@@ -53,6 +53,7 @@ typings/
# Build cache
tsconfig.tsbuildinfo
tsconfig.types.tsbuildinfo
# Optional REPL history
.node_repl_history
@@ -76,6 +77,7 @@ tsconfig.tsbuildinfo
# Ignore config
data/*
!data/configuration.example.yaml
data-bench
# commit-user-lookup.json
scripts/commit-user-lookup.json
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "2.3.0"
".": "2.6.1"
}
+215
View File
@@ -1,5 +1,220 @@
# Changelog
## [2.6.1](https://github.com/Koenkk/zigbee2mqtt/compare/2.6.0...2.6.1) (2025-09-01)
### Bug Fixes
* Always unlink `node_modules` symlinks on stop ([#28202](https://github.com/Koenkk/zigbee2mqtt/issues/28202)) ([63a4207](https://github.com/Koenkk/zigbee2mqtt/commit/63a4207061039e4dab50f5f999febe4d87397856))
* Home Assistant: set icon and state class for orientation sensors ([#28192](https://github.com/Koenkk/zigbee2mqtt/issues/28192)) ([b7cbd9b](https://github.com/Koenkk/zigbee2mqtt/commit/b7cbd9b5d830fc1b936208076c440bc561552220))
* Home Assistant: use "occupancy" device_class for human presence sensors ([#28329](https://github.com/Koenkk/zigbee2mqtt/issues/28329)) ([ccfab84](https://github.com/Koenkk/zigbee2mqtt/commit/ccfab84e7f93be151e841834943d8a65500fe5d9))
* **ignore:** bump the minor-patch group with 2 updates ([#28383](https://github.com/Koenkk/zigbee2mqtt/issues/28383)) ([a8fd23b](https://github.com/Koenkk/zigbee2mqtt/commit/a8fd23b6cc97468f345216913ebb6c8f27e83789))
* **ignore:** bump the minor-patch group with 4 updates ([#28147](https://github.com/Koenkk/zigbee2mqtt/issues/28147)) ([b72abd6](https://github.com/Koenkk/zigbee2mqtt/commit/b72abd675f409fca324b34936c1bfb6f3d8aefd4))
* **ignore:** bump the minor-patch group with 5 updates ([#28237](https://github.com/Koenkk/zigbee2mqtt/issues/28237)) ([1dd6cc3](https://github.com/Koenkk/zigbee2mqtt/commit/1dd6cc3da54527eb5560c7edec7e56324ef38ea1))
* **ignore:** update zigbee-herdsman to 6.0.0 ([#28235](https://github.com/Koenkk/zigbee2mqtt/issues/28235)) ([bd1be88](https://github.com/Koenkk/zigbee2mqtt/commit/bd1be886022ac13bb71828fc773df0f8f144ec8a))
* **ignore:** update zigbee-herdsman to 6.0.1 ([#28287](https://github.com/Koenkk/zigbee2mqtt/issues/28287)) ([bed8bec](https://github.com/Koenkk/zigbee2mqtt/commit/bed8beccbc54c06430bdd97bcfb8d14158756216))
* **ignore:** update zigbee-herdsman to 6.0.2 ([#28406](https://github.com/Koenkk/zigbee2mqtt/issues/28406)) ([264440e](https://github.com/Koenkk/zigbee2mqtt/commit/264440ebcc7c86fb6f2fbacd76a3b1bc451f01ad))
* **ignore:** update zigbee-herdsman to 6.0.4 ([#28438](https://github.com/Koenkk/zigbee2mqtt/issues/28438)) ([7788653](https://github.com/Koenkk/zigbee2mqtt/commit/77886530fe8c6599cfa1a1a797743fc07cdee75c))
* **ignore:** update zigbee-herdsman-converters to 24.12.0 ([#28170](https://github.com/Koenkk/zigbee2mqtt/issues/28170)) ([9836f0e](https://github.com/Koenkk/zigbee2mqtt/commit/9836f0e27a57863bdbd98f9770ea37036ccf243c))
* **ignore:** update zigbee-herdsman-converters to 24.13.0 ([#28175](https://github.com/Koenkk/zigbee2mqtt/issues/28175)) ([2056b89](https://github.com/Koenkk/zigbee2mqtt/commit/2056b89baca9018254e23b02fbf0a5250314159b))
* **ignore:** update zigbee-herdsman-converters to 24.14.0 ([#28199](https://github.com/Koenkk/zigbee2mqtt/issues/28199)) ([09f046c](https://github.com/Koenkk/zigbee2mqtt/commit/09f046c72d309ad4270e404376bcf009b3411b00))
* **ignore:** update zigbee-herdsman-converters to 25.0.0 ([#28236](https://github.com/Koenkk/zigbee2mqtt/issues/28236)) ([3160219](https://github.com/Koenkk/zigbee2mqtt/commit/3160219824bcb12c4cbff232d2b7ec5c5a73317b))
* **ignore:** update zigbee-herdsman-converters to 25.1.0 ([#28264](https://github.com/Koenkk/zigbee2mqtt/issues/28264)) ([2b17808](https://github.com/Koenkk/zigbee2mqtt/commit/2b1780879a62c8f1a044be7f2a525517f8af4082))
* **ignore:** update zigbee-herdsman-converters to 25.10.0 ([#28399](https://github.com/Koenkk/zigbee2mqtt/issues/28399)) ([32d8265](https://github.com/Koenkk/zigbee2mqtt/commit/32d82651e233665e1850d86d7f8a30a1cdead507))
* **ignore:** update zigbee-herdsman-converters to 25.11.0 ([#28407](https://github.com/Koenkk/zigbee2mqtt/issues/28407)) ([c07fce9](https://github.com/Koenkk/zigbee2mqtt/commit/c07fce936f8bbbfe6405938221c426ba8dd5eff2))
* **ignore:** update zigbee-herdsman-converters to 25.12.0 ([#28417](https://github.com/Koenkk/zigbee2mqtt/issues/28417)) ([bdeab61](https://github.com/Koenkk/zigbee2mqtt/commit/bdeab6100195a129980cf9d0151d6eb1b849a802))
* **ignore:** update zigbee-herdsman-converters to 25.13.0 ([#28427](https://github.com/Koenkk/zigbee2mqtt/issues/28427)) ([203019a](https://github.com/Koenkk/zigbee2mqtt/commit/203019aa26e3ee028e68d1ef9b4d032624fd7991))
* **ignore:** update zigbee-herdsman-converters to 25.14.0 ([#28439](https://github.com/Koenkk/zigbee2mqtt/issues/28439)) ([bf22dda](https://github.com/Koenkk/zigbee2mqtt/commit/bf22ddac466db6523b659b422a36c968700c385f))
* **ignore:** update zigbee-herdsman-converters to 25.2.0 ([#28280](https://github.com/Koenkk/zigbee2mqtt/issues/28280)) ([4c97842](https://github.com/Koenkk/zigbee2mqtt/commit/4c9784255bab79d7d8a394135bfd6f5b9e40ef9b))
* **ignore:** update zigbee-herdsman-converters to 25.3.0 ([#28301](https://github.com/Koenkk/zigbee2mqtt/issues/28301)) ([5f7c005](https://github.com/Koenkk/zigbee2mqtt/commit/5f7c00515adc8941c51b6ede219529719949a9bf))
* **ignore:** update zigbee-herdsman-converters to 25.4.0 ([#28313](https://github.com/Koenkk/zigbee2mqtt/issues/28313)) ([384bd30](https://github.com/Koenkk/zigbee2mqtt/commit/384bd309a6d980ba1555a7dcfe13539df67699a1))
* **ignore:** update zigbee-herdsman-converters to 25.5.0 ([#28328](https://github.com/Koenkk/zigbee2mqtt/issues/28328)) ([23f831a](https://github.com/Koenkk/zigbee2mqtt/commit/23f831a1feae67aacd5b76ac81bf3c857065f5d3))
* **ignore:** update zigbee-herdsman-converters to 25.6.0 ([#28344](https://github.com/Koenkk/zigbee2mqtt/issues/28344)) ([4bd0c04](https://github.com/Koenkk/zigbee2mqtt/commit/4bd0c04bdcdf22e08e152d52f3f5d9a565d8f5f7))
* **ignore:** update zigbee-herdsman-converters to 25.7.0 ([#28361](https://github.com/Koenkk/zigbee2mqtt/issues/28361)) ([7bd7bce](https://github.com/Koenkk/zigbee2mqtt/commit/7bd7bce69c4065d94a809baef80966ee07691efc))
* **ignore:** update zigbee-herdsman-converters to 25.8.0 ([#28373](https://github.com/Koenkk/zigbee2mqtt/issues/28373)) ([8e6ba79](https://github.com/Koenkk/zigbee2mqtt/commit/8e6ba7975b292b4ba0817de8b7877fb7b9892aed))
* **ignore:** update zigbee-herdsman-converters to 25.9.0 ([#28380](https://github.com/Koenkk/zigbee2mqtt/issues/28380)) ([0147441](https://github.com/Koenkk/zigbee2mqtt/commit/0147441c935a4a77f4fccdb59499672de64fabd5))
* **ignore:** update zigbee2mqtt-frontend to 0.9.20 ([#28250](https://github.com/Koenkk/zigbee2mqtt/issues/28250)) ([800b85c](https://github.com/Koenkk/zigbee2mqtt/commit/800b85c812e57e982a689554b64cbe73aee4f192))
* **ignore:** update zigbee2mqtt-windfront to 1.6.0 ([#28207](https://github.com/Koenkk/zigbee2mqtt/issues/28207)) ([092c95b](https://github.com/Koenkk/zigbee2mqtt/commit/092c95b7169d7e23c30db154988fb50d4099d750))
* **ignore:** update zigbee2mqtt-windfront to 1.8.0 ([#28266](https://github.com/Koenkk/zigbee2mqtt/issues/28266)) ([f4cfcba](https://github.com/Koenkk/zigbee2mqtt/commit/f4cfcbacde8cab83e0a7ea742f39f87c2bceb1e1))
* **ignore:** update zigbee2mqtt-windfront to 1.8.1 ([#28272](https://github.com/Koenkk/zigbee2mqtt/issues/28272)) ([d04ab53](https://github.com/Koenkk/zigbee2mqtt/commit/d04ab53f1d584aed400ab8fd0bcd985f78611cb5))
* **ignore:** update zigbee2mqtt-windfront to 2.0.1 ([#28420](https://github.com/Koenkk/zigbee2mqtt/issues/28420)) ([08903f6](https://github.com/Koenkk/zigbee2mqtt/commit/08903f68d9a9e20994a1da2eb4835ad28ca68877))
* Remove 60 MB (28%) of dev dependencies from Docker image ([#28160](https://github.com/Koenkk/zigbee2mqtt/issues/28160)) ([050ebfc](https://github.com/Koenkk/zigbee2mqtt/commit/050ebfc9f5dd5a2de07f5402d3c99ac8ab3b12b4))
* Set minimum for some settings ([#28209](https://github.com/Koenkk/zigbee2mqtt/issues/28209)) ([a102fac](https://github.com/Koenkk/zigbee2mqtt/commit/a102fac2c8ab09a126fddfb0cc7170d57bf114c0))
* Support for new ZH typing ([#28257](https://github.com/Koenkk/zigbee2mqtt/issues/28257)) ([b21f926](https://github.com/Koenkk/zigbee2mqtt/commit/b21f926b3e152176d09dc7f95fbc02b2b5ec13da))
* Update to refactored zhc api and fix `onEvent(stop)` not being called when device is force removed ([#27984](https://github.com/Koenkk/zigbee2mqtt/issues/27984)) ([5286570](https://github.com/Koenkk/zigbee2mqtt/commit/5286570bd625a714fad432dd693308caf59344de))
* update zigbee2mqtt-windfront to 2.0.0 ([#28389](https://github.com/Koenkk/zigbee2mqtt/issues/28389)) ([2df363f](https://github.com/Koenkk/zigbee2mqtt/commit/2df363f3af8f13c5fde91bab7ac276de3be7ee44))
## [2.6.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.5.1...2.6.0) (2025-08-01)
### Features
* Allow to disable internal frontend UI serving for standalone serving ([#27851](https://github.com/Koenkk/zigbee2mqtt/issues/27851)) ([0215e76](https://github.com/Koenkk/zigbee2mqtt/commit/0215e76118c8dcbe77c1ed4890846aaf7da85bae))
### Bug Fixes
* erroneous Health OS memory percent calculation ([#27892](https://github.com/Koenkk/zigbee2mqtt/issues/27892)) ([190cfe9](https://github.com/Koenkk/zigbee2mqtt/commit/190cfe99ac62b9dc6db4dc59a4389afda85e8177))
* Exit with code 2 when adapters disconnects ([#28013](https://github.com/Koenkk/zigbee2mqtt/issues/28013)) ([0e0a561](https://github.com/Koenkk/zigbee2mqtt/commit/0e0a5618af9a1b5707ddccbca2cde983cafb40b5))
* Fix settings schema defaults ([#27961](https://github.com/Koenkk/zigbee2mqtt/issues/27961)) ([ecadda4](https://github.com/Koenkk/zigbee2mqtt/commit/ecadda45e7c5665f9e242c956f5f140b7caa6eed))
* Ignore symbolic links in `data` during backup ([#28132](https://github.com/Koenkk/zigbee2mqtt/issues/28132)) ([0182c4b](https://github.com/Koenkk/zigbee2mqtt/commit/0182c4b32efafc85d1f4a38840345944501feaef))
* **ignore:** add test to specifically check HA envs ([#27969](https://github.com/Koenkk/zigbee2mqtt/issues/27969)) ([833605b](https://github.com/Koenkk/zigbee2mqtt/commit/833605b71f110b4e7aeb6632694820cdd1110e25))
* **ignore:** bump the minor-patch group with 2 updates ([#27924](https://github.com/Koenkk/zigbee2mqtt/issues/27924)) ([8ceb087](https://github.com/Koenkk/zigbee2mqtt/commit/8ceb08704f7e5d637b661c745c804beebf38eed9))
* **ignore:** bump the minor-patch group with 4 updates ([#28046](https://github.com/Koenkk/zigbee2mqtt/issues/28046)) ([e505a93](https://github.com/Koenkk/zigbee2mqtt/commit/e505a93a4036affe9e531b24e29ee543ed4cb0f5))
* **ignore:** fix 71cf2b41603a9923024c0472ffc1f7bfe3d14fc7 ([6dd812f](https://github.com/Koenkk/zigbee2mqtt/commit/6dd812f71cf9ca6737777c3d4acfab35c52a76b8))
* **ignore:** fix breaking changes of https://github.com/Koenkk/zigbee2mqtt/pull/28077 ([713551b](https://github.com/Koenkk/zigbee2mqtt/commit/713551b5257f8624ed03f7901f0addf6864faf1f))
* **ignore:** Remove retain/qos from mqtt publish logging https://github.com/Koenkk/zigbee2mqtt/pull/27983 ([e2b7df9](https://github.com/Koenkk/zigbee2mqtt/commit/e2b7df9f84550aa413f3453bb97a13ba2eee4187))
* **ignore:** update zigbee-herdsman to 4.3.2 ([#27900](https://github.com/Koenkk/zigbee2mqtt/issues/27900)) ([4afdd4e](https://github.com/Koenkk/zigbee2mqtt/commit/4afdd4e89241eddeb27e5f6c1c7b6604ce4a41cb))
* **ignore:** update zigbee-herdsman to 4.4.0 ([#27906](https://github.com/Koenkk/zigbee2mqtt/issues/27906)) ([e663e86](https://github.com/Koenkk/zigbee2mqtt/commit/e663e86e09cf86078877407e2233447468d66899))
* **ignore:** update zigbee-herdsman to 4.4.1 ([#27918](https://github.com/Koenkk/zigbee2mqtt/issues/27918)) ([eb82973](https://github.com/Koenkk/zigbee2mqtt/commit/eb829730678dad354d3feede2155f142d58a140f))
* **ignore:** update zigbee-herdsman to 4.5.0 ([#28044](https://github.com/Koenkk/zigbee2mqtt/issues/28044)) ([cc6f7b0](https://github.com/Koenkk/zigbee2mqtt/commit/cc6f7b02bc2d7548f142ec162696eae06027d6a3))
* **ignore:** update zigbee-herdsman to 5.0.0 ([#28077](https://github.com/Koenkk/zigbee2mqtt/issues/28077)) ([9a215ef](https://github.com/Koenkk/zigbee2mqtt/commit/9a215efe26be6fafde0f0a779b651ded860cd90c))
* **ignore:** update zigbee-herdsman to 5.0.1 ([#28080](https://github.com/Koenkk/zigbee2mqtt/issues/28080)) ([6432571](https://github.com/Koenkk/zigbee2mqtt/commit/6432571c61b6e2d4bcc17cc952eeacdaf72213f6))
* **ignore:** update zigbee-herdsman to 5.0.2 ([#28110](https://github.com/Koenkk/zigbee2mqtt/issues/28110)) ([e9f2a3f](https://github.com/Koenkk/zigbee2mqtt/commit/e9f2a3f5326954f361199bd4d73ed944f72f6ebe))
* **ignore:** update zigbee-herdsman to 5.0.3 ([#28121](https://github.com/Koenkk/zigbee2mqtt/issues/28121)) ([e5f677b](https://github.com/Koenkk/zigbee2mqtt/commit/e5f677bdd1cc3df4d05dd1f8164220ae2258f081))
* **ignore:** update zigbee-herdsman to 5.0.4 ([#28141](https://github.com/Koenkk/zigbee2mqtt/issues/28141)) ([9fcc41b](https://github.com/Koenkk/zigbee2mqtt/commit/9fcc41b199082e47d96de4cad996c55c39da83a5))
* **ignore:** update zigbee-herdsman-converters to 23.70.0 ([#27905](https://github.com/Koenkk/zigbee2mqtt/issues/27905)) ([163a15d](https://github.com/Koenkk/zigbee2mqtt/commit/163a15d664432631f100814927647b918563d85b))
* **ignore:** update zigbee-herdsman-converters to 23.70.1 ([#27916](https://github.com/Koenkk/zigbee2mqtt/issues/27916)) ([e047324](https://github.com/Koenkk/zigbee2mqtt/commit/e04732462c218cd42c2b617aefba37a5f61d1ce6))
* **ignore:** update zigbee-herdsman-converters to 23.71.0 ([#27931](https://github.com/Koenkk/zigbee2mqtt/issues/27931)) ([93c1824](https://github.com/Koenkk/zigbee2mqtt/commit/93c1824b1d81c997f0e5a445acac421300f55758))
* **ignore:** update zigbee-herdsman-converters to 23.72.0 ([#27943](https://github.com/Koenkk/zigbee2mqtt/issues/27943)) ([b4a9424](https://github.com/Koenkk/zigbee2mqtt/commit/b4a9424d604c47609071ef33e88ee55a55a6c8d5))
* **ignore:** update zigbee-herdsman-converters to 24.0.0 ([#27946](https://github.com/Koenkk/zigbee2mqtt/issues/27946)) ([c465ef8](https://github.com/Koenkk/zigbee2mqtt/commit/c465ef83526c434cac6555836d82e7707072b754))
* **ignore:** update zigbee-herdsman-converters to 24.1.0 ([#27973](https://github.com/Koenkk/zigbee2mqtt/issues/27973)) ([a837702](https://github.com/Koenkk/zigbee2mqtt/commit/a837702b2c1f517dcfa3a0f578d9b5b3700b171d))
* **ignore:** update zigbee-herdsman-converters to 24.10.0 ([#28122](https://github.com/Koenkk/zigbee2mqtt/issues/28122)) ([ee1c884](https://github.com/Koenkk/zigbee2mqtt/commit/ee1c884031fdef516393ce68754a9dbcc8ad093d))
* **ignore:** update zigbee-herdsman-converters to 24.11.0 ([#28143](https://github.com/Koenkk/zigbee2mqtt/issues/28143)) ([3765549](https://github.com/Koenkk/zigbee2mqtt/commit/376554943d15c45a15d30713002d825286204581))
* **ignore:** update zigbee-herdsman-converters to 24.2.0 ([#27986](https://github.com/Koenkk/zigbee2mqtt/issues/27986)) ([6ede403](https://github.com/Koenkk/zigbee2mqtt/commit/6ede4036886066bf91953b13714a16ba96217b6d))
* **ignore:** update zigbee-herdsman-converters to 24.3.0 ([#28015](https://github.com/Koenkk/zigbee2mqtt/issues/28015)) ([21fa5cb](https://github.com/Koenkk/zigbee2mqtt/commit/21fa5cbdeddb51d55364c4d28b887791384a951b))
* **ignore:** update zigbee-herdsman-converters to 24.4.0 ([#28022](https://github.com/Koenkk/zigbee2mqtt/issues/28022)) ([74e9357](https://github.com/Koenkk/zigbee2mqtt/commit/74e9357a5989c6b1eeb25aa91c0da27b7b54fa67))
* **ignore:** update zigbee-herdsman-converters to 24.5.0 ([#28045](https://github.com/Koenkk/zigbee2mqtt/issues/28045)) ([bc74d0b](https://github.com/Koenkk/zigbee2mqtt/commit/bc74d0b11b58bb2cb2bcc07f5ae9c47b1ca7a13e))
* **ignore:** update zigbee-herdsman-converters to 24.6.0 ([#28051](https://github.com/Koenkk/zigbee2mqtt/issues/28051)) ([4bc6a59](https://github.com/Koenkk/zigbee2mqtt/commit/4bc6a59ff38f9a102259afbff2606a75409042a3))
* **ignore:** update zigbee-herdsman-converters to 24.7.0 ([#28070](https://github.com/Koenkk/zigbee2mqtt/issues/28070)) ([81bafa5](https://github.com/Koenkk/zigbee2mqtt/commit/81bafa55201d6d7a34b364a85c19423a56eceeee))
* **ignore:** update zigbee-herdsman-converters to 24.8.0 ([#28081](https://github.com/Koenkk/zigbee2mqtt/issues/28081)) ([caf91cd](https://github.com/Koenkk/zigbee2mqtt/commit/caf91cd7ffb8ae4961c3f43875f051b182f1a379))
* **ignore:** update zigbee-herdsman-converters to 24.9.0 ([71cf2b4](https://github.com/Koenkk/zigbee2mqtt/commit/71cf2b41603a9923024c0472ffc1f7bfe3d14fc7))
* **ignore:** update zigbee2mqtt-frontend to 0.9.17 ([#27917](https://github.com/Koenkk/zigbee2mqtt/issues/27917)) ([a3e7f69](https://github.com/Koenkk/zigbee2mqtt/commit/a3e7f69b3ff6173a59b4d5e2ad1f09c2c414b416))
* **ignore:** update zigbee2mqtt-frontend to 0.9.19 ([#28021](https://github.com/Koenkk/zigbee2mqtt/issues/28021)) ([8b87682](https://github.com/Koenkk/zigbee2mqtt/commit/8b876823da7fbde4f980fefa54cec7c3fa52c423))
* **ignore:** update zigbee2mqtt-windfront to 1.3.0 ([#27953](https://github.com/Koenkk/zigbee2mqtt/issues/27953)) ([1b37bae](https://github.com/Koenkk/zigbee2mqtt/commit/1b37baefd3e6817fc9dbf36c9ec9e8aec4481643))
* **ignore:** update zigbee2mqtt-windfront to 1.3.1 ([#27999](https://github.com/Koenkk/zigbee2mqtt/issues/27999)) ([a51216e](https://github.com/Koenkk/zigbee2mqtt/commit/a51216e3c76335b66a9383b1734a213ad948d70a))
* **ignore:** update zigbee2mqtt-windfront to 1.4.1 ([#28062](https://github.com/Koenkk/zigbee2mqtt/issues/28062)) ([d322dc2](https://github.com/Koenkk/zigbee2mqtt/commit/d322dc20479faffa8f923e2190c1a5f82d17d5e4))
* **ignore:** update zigbee2mqtt-windfront to 1.5.0 ([#28082](https://github.com/Koenkk/zigbee2mqtt/issues/28082)) ([29e56ad](https://github.com/Koenkk/zigbee2mqtt/commit/29e56adc159268038727d955135d3edbedbaae8b))
* **ignore:** update zigbee2mqtt-windfront to 1.5.1 ([#28123](https://github.com/Koenkk/zigbee2mqtt/issues/28123)) ([31d3e19](https://github.com/Koenkk/zigbee2mqtt/commit/31d3e19f3862b067555bcbc9bbcd3a5ded6df211))
* **ignore:** update zigbee2mqtt-windfront to 1.5.2 ([#28140](https://github.com/Koenkk/zigbee2mqtt/issues/28140)) ([fe5639c](https://github.com/Koenkk/zigbee2mqtt/commit/fe5639ce40cf0a4035cd996a558c952dc065fcd0))
* Improve startup settings validation ([#27988](https://github.com/Koenkk/zigbee2mqtt/issues/27988)) ([435a6cc](https://github.com/Koenkk/zigbee2mqtt/commit/435a6cceb9aea1ac6941c99603d7785ef91e274a))
* log error message when external JS fails to load ([#27837](https://github.com/Koenkk/zigbee2mqtt/issues/27837)) ([f4d821d](https://github.com/Koenkk/zigbee2mqtt/commit/f4d821deca71540f830f38f631d1a3759c1d0f40))
* Publish definition source in `bridge/devices` ([#28076](https://github.com/Koenkk/zigbee2mqtt/issues/28076)) ([65eef1b](https://github.com/Koenkk/zigbee2mqtt/commit/65eef1b6900afdf831c809047ab68e93df59b4b0))
* Publish endpoint names in `bridge/devices` ([#28057](https://github.com/Koenkk/zigbee2mqtt/issues/28057)) ([5f9019a](https://github.com/Koenkk/zigbee2mqtt/commit/5f9019a95fb460f1dc4a31d09e2424ff56049c14))
* Set MQTT retain and qos=1 on (re)connect ([#27983](https://github.com/Koenkk/zigbee2mqtt/issues/27983)) ([ef07505](https://github.com/Koenkk/zigbee2mqtt/commit/ef07505b31782936a3ae517688c7e1eb9e541daf))
* Various fixes to align behaviors (+add benchmarks for dev) ([#28011](https://github.com/Koenkk/zigbee2mqtt/issues/28011)) ([db20369](https://github.com/Koenkk/zigbee2mqtt/commit/db203694477ff718386a3d97ca260dccd0dc0597))
## [2.5.1](https://github.com/Koenkk/zigbee2mqtt/compare/2.5.0...2.5.1) (2025-07-02)
### Bug Fixes
* **ignore:** update zigbee-herdsman to 4.3.1 ([#27878](https://github.com/Koenkk/zigbee2mqtt/issues/27878)) ([917d852](https://github.com/Koenkk/zigbee2mqtt/commit/917d8527a5efcf349153ed71e8a44b6d6c87b398))
* Various Windfront frontend improvements [@nerivec](https://github.com/nerivec) ([#27880](https://github.com/Koenkk/zigbee2mqtt/issues/27880)) ([4dd7dab](https://github.com/Koenkk/zigbee2mqtt/commit/4dd7dabf0803e3cad6e9b6aa1683a2ea8984fb0e))
## [2.5.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.4.0...2.5.0) (2025-07-01)
### Features
* New health extension & extras in `bridge/info` ([#27164](https://github.com/Koenkk/zigbee2mqtt/issues/27164)) ([cd9b752](https://github.com/Koenkk/zigbee2mqtt/commit/cd9b752ede91db1d02c567dded92c4e52c05b88d))
### Bug Fixes
* Bump Alpine to 3.22 ([#27690](https://github.com/Koenkk/zigbee2mqtt/issues/27690)) ([f6c3887](https://github.com/Koenkk/zigbee2mqtt/commit/f6c3887c812ea6e15e950e6d55e0274356f2eea5))
* Fix devices not immediately marked as offline when timeout expires ([#27691](https://github.com/Koenkk/zigbee2mqtt/issues/27691)) ([e4b253e](https://github.com/Koenkk/zigbee2mqtt/commit/e4b253e08beb147367872aa4517df93a03ef1a4f))
* Generate group ID if empty string given ([#27702](https://github.com/Koenkk/zigbee2mqtt/issues/27702)) ([0e66b29](https://github.com/Koenkk/zigbee2mqtt/commit/0e66b29983ae691a2dab44f6649e20a9e00a43cd))
* **ignore:** API correctness ([#27637](https://github.com/Koenkk/zigbee2mqtt/issues/27637)) ([018fad2](https://github.com/Koenkk/zigbee2mqtt/commit/018fad27afb5861b1d9c7adff3161c926ccbbaa4))
* **ignore:** bump @types/node from 22.15.30 to 24.0.3 ([#27745](https://github.com/Koenkk/zigbee2mqtt/issues/27745)) ([de80045](https://github.com/Koenkk/zigbee2mqtt/commit/de800450ad31e5d8ad5c342b061dc3c59f0d565e))
* **ignore:** bump the minor-patch group across 1 directory with 4 updates ([#27858](https://github.com/Koenkk/zigbee2mqtt/issues/27858)) ([c8dc26b](https://github.com/Koenkk/zigbee2mqtt/commit/c8dc26b241ae6562271d56fe59a13f05927b2bb4))
* **ignore:** bump the minor-patch group with 2 updates ([#27797](https://github.com/Koenkk/zigbee2mqtt/issues/27797)) ([50b0ffd](https://github.com/Koenkk/zigbee2mqtt/commit/50b0ffd7e7ef343fec983a62d2268feda28b01ba))
* **ignore:** bump the minor-patch group with 3 updates ([#27598](https://github.com/Koenkk/zigbee2mqtt/issues/27598)) ([7e82a3d](https://github.com/Koenkk/zigbee2mqtt/commit/7e82a3d1e960d4fa2d89558b6d6a20534e7d6ecf))
* **ignore:** bump the minor-patch group with 4 updates ([#27610](https://github.com/Koenkk/zigbee2mqtt/issues/27610)) ([ac80bb6](https://github.com/Koenkk/zigbee2mqtt/commit/ac80bb6430007e494c544089af323c3fb99c3541))
* **ignore:** bump the minor-patch group with 6 updates ([#27678](https://github.com/Koenkk/zigbee2mqtt/issues/27678)) ([587a02f](https://github.com/Koenkk/zigbee2mqtt/commit/587a02f66319688cb93df82fde02539bce0263ea))
* **ignore:** bump zigbee2mqtt-windfront from 1.0.5 to 1.0.6 in the minor-patch group ([#27744](https://github.com/Koenkk/zigbee2mqtt/issues/27744)) ([9776729](https://github.com/Koenkk/zigbee2mqtt/commit/977672976f06f644ed6e02e4a17c07b63873c8da))
* **ignore:** Migrate to Biome 2 ([#27785](https://github.com/Koenkk/zigbee2mqtt/issues/27785)) ([c87d452](https://github.com/Koenkk/zigbee2mqtt/commit/c87d4524d599302a0728b85799c9bf7c0784a8f4))
* **ignore:** update zigbee-herdsman to 4.1.1 ([#27640](https://github.com/Koenkk/zigbee2mqtt/issues/27640)) ([4b95b54](https://github.com/Koenkk/zigbee2mqtt/commit/4b95b54be7abc53a7f7a83814994f9cdde90134e))
* **ignore:** update zigbee-herdsman to 4.1.2 ([#27729](https://github.com/Koenkk/zigbee2mqtt/issues/27729)) ([242815e](https://github.com/Koenkk/zigbee2mqtt/commit/242815e1390e9e3a5ded929e8bfc4a9a4329a526))
* **ignore:** update zigbee-herdsman to 4.2.0 ([#27786](https://github.com/Koenkk/zigbee2mqtt/issues/27786)) ([15f3eaf](https://github.com/Koenkk/zigbee2mqtt/commit/15f3eaf96629caa5fbd7e02ae002883becc1b49f))
* **ignore:** update zigbee-herdsman to 4.2.1 ([#27835](https://github.com/Koenkk/zigbee2mqtt/issues/27835)) ([16faef1](https://github.com/Koenkk/zigbee2mqtt/commit/16faef1511e9bfb1cf76c5faaa1008e195ef67fc))
* **ignore:** update zigbee-herdsman to 4.3.0 ([#27853](https://github.com/Koenkk/zigbee2mqtt/issues/27853)) ([50c7d2d](https://github.com/Koenkk/zigbee2mqtt/commit/50c7d2d6adb9c9366d5c7f829003c84a140f7a19))
* **ignore:** update zigbee-herdsman-converters to 23.54.0 ([#27600](https://github.com/Koenkk/zigbee2mqtt/issues/27600)) ([1765fe8](https://github.com/Koenkk/zigbee2mqtt/commit/1765fe8925e359b917c1e2e08d3691d4c8bdcf3f))
* **ignore:** update zigbee-herdsman-converters to 23.55.0 ([#27618](https://github.com/Koenkk/zigbee2mqtt/issues/27618)) ([4594142](https://github.com/Koenkk/zigbee2mqtt/commit/4594142c09ee8d19af2d32238ffa58512694d2ed))
* **ignore:** update zigbee-herdsman-converters to 23.56.0 ([#27639](https://github.com/Koenkk/zigbee2mqtt/issues/27639)) ([f637e7d](https://github.com/Koenkk/zigbee2mqtt/commit/f637e7d59cc71f32b8d04e8f4bfc414027e7d23a))
* **ignore:** update zigbee-herdsman-converters to 23.57.0 ([#27646](https://github.com/Koenkk/zigbee2mqtt/issues/27646)) ([252bfba](https://github.com/Koenkk/zigbee2mqtt/commit/252bfbaa20beccd315592dac811cb23309cdee01))
* **ignore:** update zigbee-herdsman-converters to 23.57.1 ([#27689](https://github.com/Koenkk/zigbee2mqtt/issues/27689)) ([56d3a8f](https://github.com/Koenkk/zigbee2mqtt/commit/56d3a8f9d8bc01e30a6e469b517545d3406941e2))
* **ignore:** update zigbee-herdsman-converters to 23.58.0 ([#27695](https://github.com/Koenkk/zigbee2mqtt/issues/27695)) ([6a8d208](https://github.com/Koenkk/zigbee2mqtt/commit/6a8d20858ede85de72c34ce58e9df41fa2e91d0d))
* **ignore:** update zigbee-herdsman-converters to 23.59.0 ([#27712](https://github.com/Koenkk/zigbee2mqtt/issues/27712)) ([87ff0f3](https://github.com/Koenkk/zigbee2mqtt/commit/87ff0f3711e043eb47319e9fb8abcdfa878d9868))
* **ignore:** update zigbee-herdsman-converters to 23.60.0 ([#27752](https://github.com/Koenkk/zigbee2mqtt/issues/27752)) ([f33d360](https://github.com/Koenkk/zigbee2mqtt/commit/f33d3606be9e916989d08c7d418c4505dcbfe4ea))
* **ignore:** update zigbee-herdsman-converters to 23.61.0 ([#27759](https://github.com/Koenkk/zigbee2mqtt/issues/27759)) ([b537f9a](https://github.com/Koenkk/zigbee2mqtt/commit/b537f9a93a61095b6b0b82d04d85f7e264aec27c))
* **ignore:** update zigbee-herdsman-converters to 23.62.0 ([#27767](https://github.com/Koenkk/zigbee2mqtt/issues/27767)) ([281e10c](https://github.com/Koenkk/zigbee2mqtt/commit/281e10c15fa09358e999f1f6b506fe9b08295608))
* **ignore:** update zigbee-herdsman-converters to 23.63.0 ([#27784](https://github.com/Koenkk/zigbee2mqtt/issues/27784)) ([0d5de75](https://github.com/Koenkk/zigbee2mqtt/commit/0d5de750ffed0674c5a23c2c4d8d1e954c602197))
* **ignore:** update zigbee-herdsman-converters to 23.64.0 ([#27799](https://github.com/Koenkk/zigbee2mqtt/issues/27799)) ([b7d9c78](https://github.com/Koenkk/zigbee2mqtt/commit/b7d9c78309a8c3e3cf551948b6542d2b71b70697))
* **ignore:** update zigbee-herdsman-converters to 23.65.0 ([#27804](https://github.com/Koenkk/zigbee2mqtt/issues/27804)) ([4190ba4](https://github.com/Koenkk/zigbee2mqtt/commit/4190ba43b43e1f688e246f9236bfe7c60230a927))
* **ignore:** update zigbee-herdsman-converters to 23.66.0 ([#27831](https://github.com/Koenkk/zigbee2mqtt/issues/27831)) ([33234d9](https://github.com/Koenkk/zigbee2mqtt/commit/33234d9ce45323a2a10b7839c5f7aebc84a9cc32))
* **ignore:** update zigbee-herdsman-converters to 23.67.0 ([#27842](https://github.com/Koenkk/zigbee2mqtt/issues/27842)) ([6930fa6](https://github.com/Koenkk/zigbee2mqtt/commit/6930fa6f3fce8f415302c8db32294b1a9d0ea333))
* **ignore:** update zigbee-herdsman-converters to 23.68.0 ([#27852](https://github.com/Koenkk/zigbee2mqtt/issues/27852)) ([3ccbaf2](https://github.com/Koenkk/zigbee2mqtt/commit/3ccbaf21d227b7c4ad7952522a980a9083f06463))
* **ignore:** update zigbee-herdsman-converters to 23.69.0 ([#27860](https://github.com/Koenkk/zigbee2mqtt/issues/27860)) ([5285b7b](https://github.com/Koenkk/zigbee2mqtt/commit/5285b7b95e3c977cca14d28444b9f4f2a9d8996c))
* **ignore:** update zigbee2mqtt-frontend to 0.9.14 ([#27645](https://github.com/Koenkk/zigbee2mqtt/issues/27645)) ([66e1318](https://github.com/Koenkk/zigbee2mqtt/commit/66e1318a79502cb723cf88d888c9ac23daeb7fd8))
* **ignore:** update zigbee2mqtt-frontend to 0.9.15 ([#27711](https://github.com/Koenkk/zigbee2mqtt/issues/27711)) ([8ecb9be](https://github.com/Koenkk/zigbee2mqtt/commit/8ecb9be98efb44db23f386e05509389c7d681025))
* **ignore:** update zigbee2mqtt-frontend to 0.9.16 ([#27783](https://github.com/Koenkk/zigbee2mqtt/issues/27783)) ([7109900](https://github.com/Koenkk/zigbee2mqtt/commit/7109900bcfeff48fdea48456455cd1c6cebf380a))
* **ignore:** update zigbee2mqtt-windfront to 1.0.5 ([#27656](https://github.com/Koenkk/zigbee2mqtt/issues/27656)) ([eaa3e45](https://github.com/Koenkk/zigbee2mqtt/commit/eaa3e458b5a8a9c5ebf31e12ae7e68fcf60f762f))
* **ignore:** update zigbee2mqtt-windfront to 1.1.0 ([#27763](https://github.com/Koenkk/zigbee2mqtt/issues/27763)) ([cdad6cc](https://github.com/Koenkk/zigbee2mqtt/commit/cdad6ccd75200061ded795af032f0fed0bb21484))
* Rename fix typo in backoff settings https://github.com/Koenkk/zigbee2mqtt/issues/27642 ([0181263](https://github.com/Koenkk/zigbee2mqtt/commit/0181263d32dd1099676ed92226a3fc2ee6ac701e))
* update zigbee2mqtt-windfront to 1.0.4 ([#27641](https://github.com/Koenkk/zigbee2mqtt/issues/27641)) ([9a4ce6f](https://github.com/Koenkk/zigbee2mqtt/commit/9a4ce6fcd0fce6fea9c195ff48a41096b738dccb))
## [2.4.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.3.0...2.4.0) (2025-06-01)
### Features
* WindFront new frontend support ([#27489](https://github.com/Koenkk/zigbee2mqtt/issues/27489)) ([1b85968](https://github.com/Koenkk/zigbee2mqtt/commit/1b85968e5e691182305cf334836c069e70a79775))
### Bug Fixes
* API missing a few request endpoints ([#27551](https://github.com/Koenkk/zigbee2mqtt/issues/27551)) ([a5e4e9e](https://github.com/Koenkk/zigbee2mqtt/commit/a5e4e9ef61e6f9e3251de43f2c9525d94c13249d))
* Don't copy external JS to `dist` folder ([#27397](https://github.com/Koenkk/zigbee2mqtt/issues/27397)) ([0ead302](https://github.com/Koenkk/zigbee2mqtt/commit/0ead302c0a01f8fd2356e70c853f7795cb55b1c8))
* Drop Node 18 support, add 24 ([#27348](https://github.com/Koenkk/zigbee2mqtt/issues/27348)) ([aad87cc](https://github.com/Koenkk/zigbee2mqtt/commit/aad87cc3ec48c664ec58a02fa39c5c23a73db492))
* Fix bind/unbind by coordinator ieeeAddr not working ([#27333](https://github.com/Koenkk/zigbee2mqtt/issues/27333)) ([d8855b8](https://github.com/Koenkk/zigbee2mqtt/commit/d8855b8e6f1abc63daf6771a9ec07279ebc168d9))
* Fix import error on Windows (ERR_UNSUPPORTED_ESM_URL_SCHEME) ([#27519](https://github.com/Koenkk/zigbee2mqtt/issues/27519)) ([ed03c40](https://github.com/Koenkk/zigbee2mqtt/commit/ed03c40dad1a8722b24e84f4ed8613b0e37a99ea))
* HA integration for external sensors Aqara W100 ([#27520](https://github.com/Koenkk/zigbee2mqtt/issues/27520)) ([4bf4563](https://github.com/Koenkk/zigbee2mqtt/commit/4bf45630e28e5534fe68bb4bac6b020701c0e209))
* **ignore:** bump the minor-patch group across 1 directory with 2 updates ([#27562](https://github.com/Koenkk/zigbee2mqtt/issues/27562)) ([278e3fb](https://github.com/Koenkk/zigbee2mqtt/commit/278e3fb40e84c97ab5dce8147fab7029f5bca168))
* **ignore:** Cleanup publish options ([#27226](https://github.com/Koenkk/zigbee2mqtt/issues/27226)) ([b7b92da](https://github.com/Koenkk/zigbee2mqtt/commit/b7b92da8ffbaa25ce5927cb6dbf12f9da593a2e0))
* **ignore:** Fix passive states not updating in frontend ([#27532](https://github.com/Koenkk/zigbee2mqtt/issues/27532)) ([a289f98](https://github.com/Koenkk/zigbee2mqtt/commit/a289f981bf81ef0734561317628f3de2e3b38f40))
* **ignore:** networkmap typing ([#27566](https://github.com/Koenkk/zigbee2mqtt/issues/27566)) ([621cae2](https://github.com/Koenkk/zigbee2mqtt/commit/621cae2a6398d9eeced00d499bf542a8143de456))
* **ignore:** update zigbee-herdsman to 4.0.2 ([#27380](https://github.com/Koenkk/zigbee2mqtt/issues/27380)) ([cc3521c](https://github.com/Koenkk/zigbee2mqtt/commit/cc3521c877a2c5048666e27c05f21b5852dceb5c))
* **ignore:** update zigbee-herdsman to 4.1.0 ([#27462](https://github.com/Koenkk/zigbee2mqtt/issues/27462)) ([3edcfd7](https://github.com/Koenkk/zigbee2mqtt/commit/3edcfd7ff6b5e19ee0ad8780655d911b37b9bcab))
* **ignore:** update zigbee-herdsman-converters to 23.37.0 ([#27300](https://github.com/Koenkk/zigbee2mqtt/issues/27300)) ([ff823db](https://github.com/Koenkk/zigbee2mqtt/commit/ff823dbaf0b9818be83b0e3ba02073b1afcd439d))
* **ignore:** update zigbee-herdsman-converters to 23.38.0 ([#27329](https://github.com/Koenkk/zigbee2mqtt/issues/27329)) ([164c322](https://github.com/Koenkk/zigbee2mqtt/commit/164c3222b9dd6cec12ac407145bc8e38985fa7cb))
* **ignore:** update zigbee-herdsman-converters to 23.39.0 ([#27340](https://github.com/Koenkk/zigbee2mqtt/issues/27340)) ([1d6ba53](https://github.com/Koenkk/zigbee2mqtt/commit/1d6ba5393a1dc5626b6ca539e3ec245cc3d61a56))
* **ignore:** update zigbee-herdsman-converters to 23.40.0 ([#27347](https://github.com/Koenkk/zigbee2mqtt/issues/27347)) ([7a4f286](https://github.com/Koenkk/zigbee2mqtt/commit/7a4f2869b9836cd9f0e037c324585bdf453e8e8c))
* **ignore:** update zigbee-herdsman-converters to 23.40.1 ([#27358](https://github.com/Koenkk/zigbee2mqtt/issues/27358)) ([317c9b4](https://github.com/Koenkk/zigbee2mqtt/commit/317c9b4046c4e02a6bad65b1e2cb9f5e59803f24))
* **ignore:** update zigbee-herdsman-converters to 23.41.0 ([#27371](https://github.com/Koenkk/zigbee2mqtt/issues/27371)) ([f7e5051](https://github.com/Koenkk/zigbee2mqtt/commit/f7e50519e30ca65a396b07d89c3a81b922916ac9))
* **ignore:** update zigbee-herdsman-converters to 23.42.0 ([#27392](https://github.com/Koenkk/zigbee2mqtt/issues/27392)) ([edcfe32](https://github.com/Koenkk/zigbee2mqtt/commit/edcfe325725aab283753b10f72521cb3f4b0dca4))
* **ignore:** update zigbee-herdsman-converters to 23.43.0 ([#27394](https://github.com/Koenkk/zigbee2mqtt/issues/27394)) ([e061126](https://github.com/Koenkk/zigbee2mqtt/commit/e0611269f37aedec70ebfe17f75e44cbd2ce619d))
* **ignore:** update zigbee-herdsman-converters to 23.44.0 ([#27404](https://github.com/Koenkk/zigbee2mqtt/issues/27404)) ([21c9faf](https://github.com/Koenkk/zigbee2mqtt/commit/21c9faffafafbbf6f52f12ecd20bead82bf20777))
* **ignore:** update zigbee-herdsman-converters to 23.45.0 ([#27422](https://github.com/Koenkk/zigbee2mqtt/issues/27422)) ([8c3252c](https://github.com/Koenkk/zigbee2mqtt/commit/8c3252c2136b6c7e92c5c63ccfbb3af7a943f818))
* **ignore:** update zigbee-herdsman-converters to 23.46.0 ([#27444](https://github.com/Koenkk/zigbee2mqtt/issues/27444)) ([7449c23](https://github.com/Koenkk/zigbee2mqtt/commit/7449c2357c2b19fa94f6bb111783e723e0a746f5))
* **ignore:** update zigbee-herdsman-converters to 23.47.0 ([#27463](https://github.com/Koenkk/zigbee2mqtt/issues/27463)) ([693f0d0](https://github.com/Koenkk/zigbee2mqtt/commit/693f0d0a35231b529261fe1652bb6e355d5854fe))
* **ignore:** update zigbee-herdsman-converters to 23.48.0 ([#27470](https://github.com/Koenkk/zigbee2mqtt/issues/27470)) ([1303340](https://github.com/Koenkk/zigbee2mqtt/commit/1303340aaf1aede9b6f4042d7a3780407a7d42e5))
* **ignore:** update zigbee-herdsman-converters to 23.49.0 ([#27479](https://github.com/Koenkk/zigbee2mqtt/issues/27479)) ([ea49d1a](https://github.com/Koenkk/zigbee2mqtt/commit/ea49d1ae446a422ebd480b630a51b2e6f8921276))
* **ignore:** update zigbee-herdsman-converters to 23.50.0 ([#27521](https://github.com/Koenkk/zigbee2mqtt/issues/27521)) ([1960e02](https://github.com/Koenkk/zigbee2mqtt/commit/1960e026d20cefc8c64d0a47e32f0bea075f24f6))
* **ignore:** update zigbee-herdsman-converters to 23.50.1 ([#27531](https://github.com/Koenkk/zigbee2mqtt/issues/27531)) ([00dda24](https://github.com/Koenkk/zigbee2mqtt/commit/00dda245e49761afe155d0920059893448af8506))
* **ignore:** update zigbee-herdsman-converters to 23.51.0 ([#27540](https://github.com/Koenkk/zigbee2mqtt/issues/27540)) ([f5920fd](https://github.com/Koenkk/zigbee2mqtt/commit/f5920fd53be777a289fa480cf1ef62bd1701aba3))
* **ignore:** update zigbee-herdsman-converters to 23.52.0 ([#27559](https://github.com/Koenkk/zigbee2mqtt/issues/27559)) ([6ed18c7](https://github.com/Koenkk/zigbee2mqtt/commit/6ed18c757dd81899d5a8dcf9f912927dfbdde95d))
* **ignore:** update zigbee-herdsman-converters to 23.52.1 ([#27563](https://github.com/Koenkk/zigbee2mqtt/issues/27563)) ([711b456](https://github.com/Koenkk/zigbee2mqtt/commit/711b456b068ef86c1add5d9cc16770f20a495622))
* **ignore:** update zigbee-herdsman-converters to 23.53.0 ([#27572](https://github.com/Koenkk/zigbee2mqtt/issues/27572)) ([e0b0248](https://github.com/Koenkk/zigbee2mqtt/commit/e0b02481729916daf8a7ff02ddc61dbb717cc271))
* **ignore:** update zigbee2mqtt-frontend to 0.9.11 ([#27301](https://github.com/Koenkk/zigbee2mqtt/issues/27301)) ([3cb63aa](https://github.com/Koenkk/zigbee2mqtt/commit/3cb63aa9229a3b4a0f099ce3f8481badf241e202))
* **ignore:** update zigbee2mqtt-frontend to 0.9.12 ([#27379](https://github.com/Koenkk/zigbee2mqtt/issues/27379)) ([615c53c](https://github.com/Koenkk/zigbee2mqtt/commit/615c53c93c8cda90836d9edf98f54d5e589610b8))
* **ignore:** update zigbee2mqtt-frontend to 0.9.13 ([#27382](https://github.com/Koenkk/zigbee2mqtt/issues/27382)) ([f656d96](https://github.com/Koenkk/zigbee2mqtt/commit/f656d9629c405d32067ccbbf411b1c5d6255e4ff))
* **ignore:** update zigbee2mqtt-windfront to 1.0.2 ([#27567](https://github.com/Koenkk/zigbee2mqtt/issues/27567)) ([5e44cf7](https://github.com/Koenkk/zigbee2mqtt/commit/5e44cf77b3a4c664be4b97a95455a39a18bd8632))
* Improve update script ([#27308](https://github.com/Koenkk/zigbee2mqtt/issues/27308)) ([3cbcc4a](https://github.com/Koenkk/zigbee2mqtt/commit/3cbcc4a0abbe1cb09096d0b989abba48acea8772))
* Only send JSON state over WebSocket ([#27225](https://github.com/Koenkk/zigbee2mqtt/issues/27225)) ([39e4468](https://github.com/Koenkk/zigbee2mqtt/commit/39e4468308c1909f03ecd068f5c7da23b040f80b))
* Rerun onboarding if Z2M start failed after previous onboarding ([#27386](https://github.com/Koenkk/zigbee2mqtt/issues/27386)) ([d697c29](https://github.com/Koenkk/zigbee2mqtt/commit/d697c29c7be5e7e49d82d25f355a26875ac383a3))
* Trim name on rename ([#27332](https://github.com/Koenkk/zigbee2mqtt/issues/27332)) ([69364c9](https://github.com/Koenkk/zigbee2mqtt/commit/69364c958f9f2b7430bfb5a072ffe5267bfcc378))
## [2.3.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.2.1...2.3.0) (2025-05-01)
+41 -39
View File
@@ -5,33 +5,27 @@
<br>
<br>
<div style="display: flex;">
<a href="https://github.com/Koenkk/zigbee2mqtt/actions?query=workflow%3Aci">
<img src="https://github.com/koenkk/zigbee2mqtt/workflows/ci/badge.svg">
</a>
<a href="https://github.com/Koenkk/zigbee2mqtt/releases">
<img src="https://img.shields.io/github/release/koenkk/zigbee2mqtt.svg">
</a>
<a href="https://www.npmjs.com/package/zigbee2mqtt">
<img src="https://img.shields.io/npm/v/zigbee2mqtt">
</a>
<a href="https://github.com/Koenkk/zigbee2mqtt/actions/workflows/ci.yml">
<img src="https://github.com/Koenkk/zigbee2mqtt/actions/workflows/ci.yml/badge.svg">
</a>
<a href="https://github.com/Koenkk/zigbee2mqtt/actions/workflows/github-code-scanning/codeql">
<img src="https://github.com/Koenkk/zigbee2mqtt/actions/workflows/github-code-scanning/codeql/badge.svg">
</a>
<a href="https://discord.gg/dadfWYE">
<img src="https://img.shields.io/discord/556563650429583360.svg">
</a>
<a href="https://github.com/Koenkk/zigbee2mqtt/stargazers">
<img src="https://img.shields.io/github/stars/koenkk/zigbee2mqtt.svg">
</a>
<a href="https://www.paypal.me/koenkk">
<img src="https://img.shields.io/badge/donate-PayPal-blue.svg">
</a>
<a href="https://discord.gg/dadfWYE">
<img src="https://img.shields.io/discord/556563650429583360.svg">
</a>
<a href="http://zigbee2mqtt.discourse.group/">
<img src="https://img.shields.io/discourse/https/zigbee2mqtt.discourse.group/status.svg">
</a>
<a>
<img src="https://img.shields.io/badge/Coverage-100%25-brightgreen.svg">
</a>
<a href="https://www.codacy.com/manual/Koenkk/zigbee2mqtt?utm_source=github.com&amp;utm_medium=referral&amp;utm_content=Koenkk/zigbee2mqtt&amp;utm_campaign=Badge_Grade">
<img src="https://api.codacy.com/project/badge/Grade/24f1e0fe39f04daa810e8a1416693d3f">
</a>
<a href="https://www.npmjs.com/package/zigbee2mqtt">
<img src="https://img.shields.io/npm/v/zigbee2mqtt">
</a>
</div>
<h1>Zigbee2MQTT 🌉 🐝</h1>
<p>
@@ -52,59 +46,67 @@ If you aren't familiar with **Zigbee** terminology make sure you [read this](htt
Zigbee2MQTT integrates well with (almost) every home automation solution because it uses MQTT. However the following integrations are worth mentioning:
<img align="left" height="100px" width="100px" src="https://user-images.githubusercontent.com/7738048/40914297-49e6e560-6800-11e8-8904-36cce896e5a8.png">
### [Home Assistant](https://www.home-assistant.io/)
- [Home Assistant OS](https://www.home-assistant.io/installation/): Using [the official addon](https://github.com/zigbee2mqtt/hassio-zigbee2mqtt)
- Other installation: using instructions [here](https://www.zigbee2mqtt.io/guide/usage/integrations/home_assistant.html)
<img align="left" height="75px" width="75px" src="https://user-images.githubusercontent.com/7738048/40914297-49e6e560-6800-11e8-8904-36cce896e5a8.png">
<br>
<br clear="right">
<img align="left" height="100px" width="100px" src="https://etc.athom.com/logo/white/256.png">
[Home Assistant OS](https://www.home-assistant.io/installation/) using [the official addon](https://github.com/zigbee2mqtt/hassio-zigbee2mqtt) ([other installations](https://www.zigbee2mqtt.io/guide/usage/integrations/home_assistant.html))
<br clear="both">
### [Homey](https://homey.app/)
- Integration implemented in the [Homey App](https://homey.app/nl-nl/app/com.gruijter.zigbee2mqtt/)
- Documentation and support in the [Homey Forum](https://community.homey.app/t/83214)
<img align="left" height="75px" width="75px" src="https://etc.athom.com/logo/white/256.png">
<br>
<br clear="right">
<img align="left" height="100px" width="100px" src="https://user-images.githubusercontent.com/2734836/47615848-b8dd8700-dabd-11e8-9d77-175002dd8987.png">
Integration implemented in the [Homey App](https://homey.app/nl-nl/app/com.gruijter.zigbee2mqtt/) ([documentation & support](https://community.homey.app/t/83214))
<br clear="both">
### [Domoticz](https://www.domoticz.com/)
- Integration implemented in Domoticz ([documentation](https://www.domoticz.com/wiki/Zigbee2MQTT)).
<img align="left" height="75px" width="75px" src="https://user-images.githubusercontent.com/2734836/47615848-b8dd8700-dabd-11e8-9d77-175002dd8987.png">
<br>
<br clear="right">
<img align="left" height="100px" width="100px" src="./images/gladys-assistant-logo.jpg">
Integration implemented in Domoticz ([documentation](https://www.domoticz.com/wiki/Zigbee2MQTT)).
<br clear="both">
### [Gladys Assistant](https://gladysassistant.com/)
- Integration implemented natively in Gladys Assistant ([documentation](https://gladysassistant.com/docs/integrations/zigbee2mqtt/)).
<img align="left" height="75px" width="75px" src="./images/gladys-assistant-logo.jpg">
<br>
<br clear="right">
<img align="left" height="100px" width="100px" src="https://forum.iobroker.net/assets/uploads/system/site-logo.png">
Integration implemented natively in Gladys Assistant ([documentation](https://gladysassistant.com/docs/integrations/zigbee2mqtt/)).
<br clear="both">
### [IoBroker](https://www.iobroker.net/)
- Integration implemented in IoBroker ([documentation](https://github.com/o0shojo0o/ioBroker.zigbee2mqtt)).
<img align="left" height="75px" width="75px" src="https://forum.iobroker.net/assets/uploads/system/site-logo.png">
<br>
<br clear="right">
Integration implemented in IoBroker ([documentation](https://github.com/o0shojo0o/ioBroker.zigbee2mqtt)).
<br clear="both">
## Architecture
![Architecture](images/architecture.png)
![Architecture](images/architecture-new.png)
### Internal Architecture
Zigbee2MQTT is made up of three modules, each developed in its own Github project. Starting from the hardware (adapter) and moving up; [zigbee-herdsman](https://github.com/koenkk/zigbee-herdsman) connects to your Zigbee adapter and makes an API available to the higher levels of the stack. For e.g. Texas Instruments hardware, zigbee-herdsman uses the [TI zStack monitoring and test API](https://github.com/koenkk/zigbee-herdsman/raw/master/docs/Z-Stack%20Monitor%20and%20Test%20API.pdf) to communicate with the adapter. Zigbee-herdsman handles the core Zigbee communication. The module [zigbee-herdsman-converters](https://github.com/koenkk/zigbee-herdsman-converters) handles the mapping from individual device models to the Zigbee clusters they support. [Zigbee clusters](https://github.com/Koenkk/zigbee-herdsman/blob/master/docs/07-5123-08-Zigbee-Cluster-Library.pdf) are the layers of the Zigbee protocol on top of the base protocol that define things like how lights, sensors and switches talk to each other over the Zigbee network. Finally, the Zigbee2MQTT module drives zigbee-herdsman and maps the zigbee messages to MQTT messages. Zigbee2MQTT also keeps track of the state of the system. It uses a `database.db` file to store this state; a text file with a JSON database of connected devices and their capabilities. Zigbee2MQTT provides a [web-based interface](https://github.com/nurikk/zigbee2mqtt-frontend) that allows monitoring and configuration.
Zigbee2MQTT is made up of three modules, each developed in its own Github project. Starting from the hardware (adapter) and moving up; [zigbee-herdsman](https://github.com/koenkk/zigbee-herdsman) connects to your adapter to handle Zigbee communication and makes an API available to the higher levels of the stack. For e.g. Texas Instruments hardware, zigbee-herdsman uses the [TI zStack monitoring and test API](https://github.com/Koenkk/zigbee-herdsman/wiki/References#texas-instruments-zstack) to communicate with the adapter. The module [zigbee-herdsman-converters](https://github.com/koenkk/zigbee-herdsman-converters) handles the mapping from individual device models to the Zigbee clusters they support. [Zigbee clusters](https://github.com/Koenkk/zigbee-herdsman/wiki/References#csa-zigbee-alliance-spec) are the layers of the Zigbee protocol on top of the base protocol that define things like how lights, sensors and switches talk to each other over the Zigbee network. Finally, the Zigbee2MQTT module drives zigbee-herdsman and maps the zigbee messages to MQTT messages. Zigbee2MQTT also keeps track of the state of the system. It uses a `database.db` file to store this state; a text file with a JSON database of connected devices and their capabilities. Zigbee2MQTT provides several web-based interfaces ([zigbee2mqtt-frontend](https://github.com/nurikk/zigbee2mqtt-frontend), [zigbee2mqtt-windfront](https://github.com/Nerivec/zigbee2mqtt-windfront)) that allows monitoring and configuration.
### Developing
Zigbee2MQTT uses TypeScript (partially for now). Therefore after making changes to files in the `lib/` directory you need to recompile Zigbee2MQTT. This can be done by executing `pnpm run build`. For faster development instead of running `pnpm run build` you can run `pnpm run build-watch` in another terminal session, this will recompile as you change files.
Zigbee2MQTT uses TypeScript. Therefore after making changes to files in the `lib/` directory you need to recompile Zigbee2MQTT. This can be done by executing `pnpm run build`. For faster development instead of running `pnpm run build` you can run `pnpm run build-watch` in another terminal session, this will recompile as you change files.
Before running any of the commands, you'll first need to run `pnpm install --include=dev`.
Before submitting changes run `pnpm run check:w` then `pnpm run test:coverage`.
+14 -5
View File
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"$schema": "https://biomejs.dev/schemas/2.2.2/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
@@ -12,10 +12,10 @@
"bracketSpacing": false
},
"files": {
"ignore": ["package.json"]
"includes": ["**", "!package.json"]
},
"linter": {
"ignore": [],
"includes": ["**"],
"rules": {
"correctness": {
"noUnusedImports": "error"
@@ -55,7 +55,16 @@
}
]
}
}
},
"useAsConstAssertion": "error",
"useDefaultParameterLast": "error",
"useEnumInitializers": "error",
"useSelfClosingElements": "error",
"useSingleVarDeclarator": "error",
"noUnusedTemplateLiteral": "error",
"useNumberNamespace": "error",
"noInferrableTypes": "error",
"noUselessElse": "error"
},
"performance": {
"noDelete": "off"
@@ -68,7 +77,7 @@
},
"overrides": [
{
"include": ["test/**"],
"includes": ["test/**"],
"linter": {
"rules": {
"style": {
+13 -9
View File
@@ -1,6 +1,6 @@
ARG TARGETPLATFORM
FROM alpine:3.21 AS base
FROM alpine:3.22 AS base
ENV NODE_ENV=production
WORKDIR /app
@@ -10,12 +10,19 @@ RUN apk add --no-cache tzdata eudev tini nodejs
FROM base AS deps
COPY package.json pnpm-lock.yaml ./
# Make and such are needed to compile serialport for riscv64
RUN apk add make gcc g++ python3 linux-headers npm && \
npm install -g pnpm@10.4.1 && \
pnpm install --frozen-lockfile --no-optional && \
# serialport has outdated prebuilds that appear to fail on some archs, force build on target platform
rm -rf `find ./node_modules/.pnpm/ -wholename "*/@serialport/bindings-cpp/prebuilds" -type d` && \
# Install pnpm, configure a tmp-based store, then use it under a cache mount so each arch gets its own store
# NOTE: We do not install devDependencies here (the --prod argument in pnpm install) as we use an already
# built artifact (./dist directory) from previous GitHub runner stages. Omitting devDependencies here
# saves storage as this node_modules/ is being copied straight to the release stage later.
RUN --mount=type=cache,id=pnpm-store,target=/tmp/pnpm-store \
apk add --no-cache make gcc g++ python3 linux-headers npm jq && \
npm install -g $(jq -r '.packageManager' package.json) && \
pnpm config set store-dir /tmp/pnpm-store && \
pnpm install --frozen-lockfile --prod --no-optional && \
# force serialport to rebuild on current platform instead of using prebuilds
rm -rf $(find ./node_modules/.pnpm/ -wholename "*/@serialport/bindings-cpp/prebuilds" -type d) && \
pnpm rebuild @serialport/bindings-cpp
# Release
@@ -35,9 +42,6 @@ LABEL org.opencontainers.image.version=${VERSION}
COPY --from=deps /app/node_modules ./node_modules
COPY dist ./dist
# To prevent `Error: EACCES: permission denied, mkdir '/app/dist/external_converters'`
# when running rootless.
RUN chmod -R 777 ./dist
COPY package.json LICENSE index.js data/configuration.example.yaml ./
COPY docker/docker-entrypoint.sh /usr/local/bin/
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

+27 -24
View File
@@ -1,30 +1,26 @@
import type {IClientPublishOptions} from "mqtt";
import type Extension from "./extension/extension";
import type {Zigbee2MQTTAPI} from "./types/api";
import bind from "bind-decorator";
import stringify from "json-stable-stringify-without-jsonify";
import {setLogger as zhSetLogger} from "zigbee-herdsman";
import {setLogger as zhcSetLogger} from "zigbee-herdsman-converters";
import EventBus from "./eventBus";
// Extensions
import ExtensionAvailability from "./extension/availability";
import ExtensionBind from "./extension/bind";
import ExtensionBridge from "./extension/bridge";
import ExtensionConfigure from "./extension/configure";
import type Extension from "./extension/extension";
import ExtensionExternalConverters from "./extension/externalConverters";
import ExtensionExternalExtensions from "./extension/externalExtensions";
import ExtensionGroups from "./extension/groups";
import ExtensionHealth from "./extension/health";
import ExtensionNetworkMap from "./extension/networkMap";
import ExtensionOnEvent from "./extension/onEvent";
import ExtensionOTAUpdate from "./extension/otaUpdate";
import ExtensionPublish from "./extension/publish";
import ExtensionReceive from "./extension/receive";
import Mqtt from "./mqtt";
import Mqtt, {type MqttPublishOptions} from "./mqtt";
import State from "./state";
import type {Zigbee2MQTTAPI} from "./types/api";
import logger from "./util/logger";
import {initSdNotify} from "./util/sd-notify";
import * as settings from "./util/settings";
@@ -32,10 +28,10 @@ import utils from "./util/utils";
import Zigbee from "./zigbee";
export class Controller {
private eventBus: EventBus;
private zigbee: Zigbee;
private state: State;
private mqtt: Mqtt;
public readonly eventBus: EventBus;
public readonly zigbee: Zigbee;
public readonly state: State;
public readonly mqtt: Mqtt;
private restartCallback: () => Promise<void>;
private exitCallback: (code: number, restart: boolean) => Promise<void>;
public readonly extensions: Set<Extension>;
@@ -78,6 +74,7 @@ export class Controller {
new ExtensionOTAUpdate(...this.extensionArgs),
new ExtensionExternalExtensions(...this.extensionArgs),
new ExtensionAvailability(...this.extensionArgs),
new ExtensionHealth(...this.extensionArgs),
]);
}
@@ -165,6 +162,8 @@ export class Controller {
logger.info("Zigbee2MQTT started!");
this.sdNotify = await initSdNotify();
settings.setOnboarding(false);
}
@bind async enableDisableExtension(enable: boolean, name: string): Promise<void> {
@@ -282,17 +281,16 @@ export class Controller {
}
}
async stop(restart = false): Promise<void> {
async stop(restart = false, code = 0): Promise<void> {
this.sdNotify?.notifyStopping();
let code = 0;
let localCode = 0;
for (const extension of this.extensions) {
try {
await extension.stop();
} catch (error) {
logger.error(`Failed to stop '${extension.constructor.name}' (${(error as Error).stack})`);
code = 1;
localCode = 1;
}
}
@@ -307,11 +305,11 @@ export class Controller {
logger.info("Stopped Zigbee2MQTT");
} catch (error) {
logger.error(`Failed to stop Zigbee2MQTT (${(error as Error).stack})`);
code = 1;
localCode = 1;
}
this.sdNotify?.stop();
return await this.exit(code, restart);
return await this.exit(code !== 0 ? code : localCode, restart);
}
async exit(code: number, restart = false): Promise<void> {
@@ -321,7 +319,7 @@ export class Controller {
@bind async onZigbeeAdapterDisconnected(): Promise<void> {
logger.error("Adapter disconnected, stopping");
await this.stop();
await this.stop(false, 2);
}
@bind async publishEntityState(entity: Group | Device, payload: KeyValue, stateChangeReason?: StateChangeReason): Promise<void> {
@@ -335,14 +333,19 @@ export class Controller {
message = newState;
}
const options: IClientPublishOptions = {
retain: utils.getObjectProperty(entity.options, "retain", false),
qos: utils.getObjectProperty(entity.options, "qos", 0),
const options: MakePartialExcept<MqttPublishOptions, "clientOptions" | "meta"> = {
clientOptions: {
retain: utils.getObjectProperty(entity.options, "retain", false),
qos: utils.getObjectProperty(entity.options, "qos", 0),
},
meta: {
isEntityState: true,
},
};
const retention = utils.getObjectProperty<number | false>(entity.options, "retention", false);
if (retention !== false) {
options.properties = {messageExpiryInterval: retention};
options.clientOptions.properties = {messageExpiryInterval: retention};
}
if (entity.isDevice() && settings.get().mqtt.include_device_information) {
@@ -399,7 +402,7 @@ export class Controller {
this.eventBus.emitPublishEntityState({entity, message, stateChangeReason, payload});
}
async iteratePayloadAttributeOutput(topicRoot: string, payload: KeyValue, options: IClientPublishOptions): Promise<void> {
async iteratePayloadAttributeOutput(topicRoot: string, payload: KeyValue, options: Partial<MqttPublishOptions>): Promise<void> {
for (const [key, value] of Object.entries(payload)) {
let subPayload = value;
let message = null;
+54 -6
View File
@@ -8,8 +8,8 @@ interface EventBusMap {
adapterDisconnected: [];
permitJoinChanged: [data: eventdata.PermitJoinChanged];
publishAvailability: [];
deviceRenamed: [data: eventdata.EntityRenamed];
deviceRemoved: [data: eventdata.EntityRemoved];
entityRenamed: [data: eventdata.EntityRenamed];
entityRemoved: [data: eventdata.EntityRemoved];
lastSeenChanged: [data: eventdata.LastSeenChanged];
deviceNetworkAddressChanged: [data: eventdata.DeviceNetworkAddressChanged];
deviceAnnounce: [data: eventdata.DeviceAnnounce];
@@ -34,9 +34,25 @@ type EventBusListener<K> = K extends keyof EventBusMap
: never
: never;
type Stats = {
devices: Map<
string, // IEEE address
{
lastSeenChanges?: {messages: number; first: number};
leaveCounts: number;
networkAddressChanges: number;
}
>;
mqtt: {
published: number;
received: number;
};
};
export default class EventBus {
private callbacksByExtension = new Map<string, {event: keyof EventBusMap; callback: EventBusListener<keyof EventBusMap>}[]>();
private emitter = new events.EventEmitter<EventBusMap>();
readonly stats: Stats = {devices: new Map(), mqtt: {published: 0, received: 0}};
constructor() {
this.emitter.setMaxListeners(100);
@@ -57,21 +73,33 @@ export default class EventBus {
}
public emitEntityRenamed(data: eventdata.EntityRenamed): void {
this.emitter.emit("deviceRenamed", data);
this.emitter.emit("entityRenamed", data);
}
public onEntityRenamed(key: ListenerKey, callback: (data: eventdata.EntityRenamed) => void): void {
this.on("deviceRenamed", callback, key);
this.on("entityRenamed", callback, key);
}
public emitEntityRemoved(data: eventdata.EntityRemoved): void {
this.emitter.emit("deviceRemoved", data);
this.emitter.emit("entityRemoved", data);
}
public onEntityRemoved(key: ListenerKey, callback: (data: eventdata.EntityRemoved) => void): void {
this.on("deviceRemoved", callback, key);
this.on("entityRemoved", callback, key);
}
public emitLastSeenChanged(data: eventdata.LastSeenChanged): void {
this.emitter.emit("lastSeenChanged", data);
const device = this.stats.devices.get(data.device.ieeeAddr);
if (device?.lastSeenChanges) {
device.lastSeenChanges.messages += 1;
} else {
this.stats.devices.set(data.device.ieeeAddr, {
lastSeenChanges: {messages: 1, first: Date.now()},
leaveCounts: 0,
networkAddressChanges: 0,
});
}
}
public onLastSeenChanged(key: ListenerKey, callback: (data: eventdata.LastSeenChanged) => void): void {
this.on("lastSeenChanged", callback, key);
@@ -79,6 +107,14 @@ export default class EventBus {
public emitDeviceNetworkAddressChanged(data: eventdata.DeviceNetworkAddressChanged): void {
this.emitter.emit("deviceNetworkAddressChanged", data);
const device = this.stats.devices.get(data.device.ieeeAddr);
if (device) {
device.networkAddressChanges += 1;
} else {
this.stats.devices.set(data.device.ieeeAddr, {leaveCounts: 0, networkAddressChanges: 1});
}
}
public onDeviceNetworkAddressChanged(key: ListenerKey, callback: (data: eventdata.DeviceNetworkAddressChanged) => void): void {
this.on("deviceNetworkAddressChanged", callback, key);
@@ -121,6 +157,14 @@ export default class EventBus {
public emitDeviceLeave(data: eventdata.DeviceLeave): void {
this.emitter.emit("deviceLeave", data);
const device = this.stats.devices.get(data.ieeeAddr);
if (device) {
device.leaveCounts += 1;
} else {
this.stats.devices.set(data.ieeeAddr, {leaveCounts: 1, networkAddressChanges: 0});
}
}
public onDeviceLeave(key: ListenerKey, callback: (data: eventdata.DeviceLeave) => void): void {
this.on("deviceLeave", callback, key);
@@ -135,6 +179,8 @@ export default class EventBus {
public emitMQTTMessage(data: eventdata.MQTTMessage): void {
this.emitter.emit("mqttMessage", data);
this.stats.mqtt.received += 1;
}
public onMQTTMessage(key: ListenerKey, callback: (data: eventdata.MQTTMessage) => void): void {
this.on("mqttMessage", callback, key);
@@ -142,6 +188,8 @@ export default class EventBus {
public emitMQTTMessagePublished(data: eventdata.MQTTMessagePublished): void {
this.emitter.emit("mqttMessagePublished", data);
this.stats.mqtt.published += 1;
}
public onMQTTMessagePublished(key: ListenerKey, callback: (data: eventdata.MQTTMessagePublished) => void): void {
this.on("mqttMessagePublished", callback, key);
+6 -9
View File
@@ -1,11 +1,8 @@
import type * as zhc from "zigbee-herdsman-converters";
import type {Zigbee2MQTTAPI} from "../types/api";
import assert from "node:assert";
import bind from "bind-decorator";
import debounce from "debounce";
import type * as zhc from "zigbee-herdsman-converters";
import type {Zigbee2MQTTAPI} from "../types/api";
import logger from "../util/logger";
import * as settings from "../util/settings";
@@ -76,7 +73,7 @@ export default class Availability extends Extension {
if (entity.isDevice()) {
const lastSeen = entity.zh.lastSeen ?? /* v8 ignore next */ 0;
return Date.now() - lastSeen < this.getTimeout(entity) + this.getMaxJitter(entity);
return Date.now() - lastSeen < this.getTimeout(entity);
}
for (const memberDevice of entity.membersDevices()) {
@@ -206,11 +203,11 @@ export default class Availability extends Extension {
this.eventBus.onEntityRenamed(this, async (data) => {
if (utils.isAvailabilityEnabledForEntity(data.entity, settings.get())) {
await this.mqtt.publish(`${data.from}/availability`, "", {retain: true, qos: 1});
await this.mqtt.publish(`${data.from}/availability`, "", {clientOptions: {retain: true, qos: 1}});
await this.publishAvailability(data.entity, false, true);
}
});
this.eventBus.onEntityRemoved(this, (data) => data.type === "device" && this.clearTimer(data.id));
this.eventBus.onEntityRemoved(this, (data) => data.entity.isDevice() && this.clearTimer(data.entity.ID));
this.eventBus.onDeviceLeave(this, (data) => this.clearTimer(data.ieeeAddr));
this.eventBus.onDeviceAnnounce(this, (data) => this.retrieveState(data.device));
this.eventBus.onLastSeenChanged(this, this.onLastSeenChanged);
@@ -265,7 +262,7 @@ export default class Availability extends Extension {
const topic = `${entity.name}/availability`;
const payload: Zigbee2MQTTAPI["{friendlyName}/availability"] = {state: available ? "online" : "offline"};
this.lastPublishedAvailabilities.set(entity.ID, available);
await this.mqtt.publish(topic, JSON.stringify(payload), {retain: true, qos: 1});
await this.mqtt.publish(topic, JSON.stringify(payload), {clientOptions: {retain: true, qos: 1}});
if (!skipGroups && entity.isDevice()) {
for (const group of this.zigbee.groupsIterator()) {
+41 -64
View File
@@ -1,23 +1,18 @@
import type {ClusterName} from "zigbee-herdsman/dist/zspec/zcl/definition/tstype";
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from "../types/api";
import assert from "node:assert";
import bind from "bind-decorator";
import debounce from "debounce";
import stringify from "json-stable-stringify-without-jsonify";
import {Zcl} from "zigbee-herdsman";
import type {TClusterAttributeKeys} from "zigbee-herdsman/dist/zspec/zcl/definition/clusters-types";
import type {ClusterName} from "zigbee-herdsman/dist/zspec/zcl/definition/tstype";
import Device from "../model/device";
import Group from "../model/group";
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from "../types/api";
import logger from "../util/logger";
import * as settings from "../util/settings";
import utils from "../util/utils";
import utils, {DEFAULT_BIND_GROUP_ID} from "../util/utils";
import Extension from "./extension";
const TOPIC_REGEX = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/(bind|unbind)`);
const ALL_CLUSTER_CANDIDATES: readonly ClusterName[] = [
"genScenes",
"genOnOff",
@@ -33,7 +28,7 @@ const ALL_CLUSTER_CANDIDATES: readonly ClusterName[] = [
];
// See zigbee-herdsman-converters
const DEFAULT_BIND_GROUP = {type: "group_number", ID: 901, name: "default_bind_group"};
const DEFAULT_BIND_GROUP = {type: "group_number", ID: DEFAULT_BIND_GROUP_ID, name: "default_bind_group"};
const DEFAULT_REPORT_CONFIG = {minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1};
const getColorCapabilities = async (endpoint: zh.Endpoint): Promise<{colorTemperature: boolean; colorXY: boolean}> => {
@@ -49,53 +44,33 @@ const getColorCapabilities = async (endpoint: zh.Endpoint): Promise<{colorTemper
};
};
const REPORT_CLUSTERS: Readonly<
Partial<
Record<
ClusterName,
Readonly<{
attribute: string;
minimumReportInterval: number;
maximumReportInterval: number;
reportableChange: number;
condition?: (endpoint: zh.Endpoint) => Promise<boolean>;
}>[]
>
>
> = {
genOnOff: [{attribute: "onOff", ...DEFAULT_REPORT_CONFIG, minimumReportInterval: 0, reportableChange: 0}],
genLevelCtrl: [{attribute: "currentLevel", ...DEFAULT_REPORT_CONFIG}],
const REPORT_CLUSTERS = {
genOnOff: [{attribute: "onOff" as const, ...DEFAULT_REPORT_CONFIG, minimumReportInterval: 0, reportableChange: 0}],
genLevelCtrl: [{attribute: "currentLevel" as const, ...DEFAULT_REPORT_CONFIG}],
lightingColorCtrl: [
{
attribute: "colorTemperature",
attribute: "colorTemperature" as const,
...DEFAULT_REPORT_CONFIG,
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorTemperature,
condition: async (endpoint: zh.Endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorTemperature,
},
{
attribute: "currentX",
attribute: "currentX" as const,
...DEFAULT_REPORT_CONFIG,
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
condition: async (endpoint: zh.Endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
},
{
attribute: "currentY",
attribute: "currentY" as const,
...DEFAULT_REPORT_CONFIG,
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
condition: async (endpoint: zh.Endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
},
],
closuresWindowCovering: [
{attribute: "currentPositionLiftPercentage", ...DEFAULT_REPORT_CONFIG},
{attribute: "currentPositionTiltPercentage", ...DEFAULT_REPORT_CONFIG},
{attribute: "currentPositionLiftPercentage" as const, ...DEFAULT_REPORT_CONFIG},
{attribute: "currentPositionTiltPercentage" as const, ...DEFAULT_REPORT_CONFIG},
],
};
type PollOnMessage = {
cluster: Readonly<Partial<Record<ClusterName, {type: string; data: KeyValue}[]>>>;
read: Readonly<{cluster: string; attributes: string[]; attributesForEndpoint?: (endpoint: zh.Endpoint) => Promise<string[]>}>;
manufacturerIDs: readonly Zcl.ManufacturerCode[];
manufacturerNames: readonly string[];
}[];
const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
const POLL_ON_MESSAGE = [
{
// On messages that have the cluster and type of below
cluster: {
@@ -115,7 +90,7 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
genScenes: [{type: "commandRecall", data: {}}],
},
// Read the following attributes
read: {cluster: "genLevelCtrl", attributes: ["currentLevel"]},
read: {cluster: "genLevelCtrl" as const, attributes: ["currentLevel"] as TClusterAttributeKeys<"genLevelCtrl">},
// When the bound devices/members of group have the following manufacturerIDs
manufacturerIDs: [
Zcl.ManufacturerCode.SIGNIFY_NETHERLANDS_B_V,
@@ -147,7 +122,7 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
{type: "commandHueNotification", data: {button: 4}},
],
},
read: {cluster: "genOnOff", attributes: ["onOff"]},
read: {cluster: "genOnOff" as const, attributes: ["onOff"] as TClusterAttributeKeys<"genOnOff">},
manufacturerIDs: [
Zcl.ManufacturerCode.SIGNIFY_NETHERLANDS_B_V,
Zcl.ManufacturerCode.ATMEL,
@@ -163,13 +138,13 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
genScenes: [{type: "commandRecall", data: {}}],
},
read: {
cluster: "lightingColorCtrl",
attributes: [] as string[],
cluster: "lightingColorCtrl" as const,
attributes: [] as TClusterAttributeKeys<"lightingColorCtrl">,
// Since not all devices support the same attributes they need to be calculated dynamically
// depending on the capabilities of the endpoint.
attributesForEndpoint: async (endpoint): Promise<string[]> => {
attributesForEndpoint: async (endpoint: zh.Endpoint): Promise<TClusterAttributeKeys<"lightingColorCtrl">> => {
const supportedAttrs = await getColorCapabilities(endpoint);
const readAttrs: string[] = [];
const readAttrs: TClusterAttributeKeys<"lightingColorCtrl"> = [];
if (supportedAttrs.colorXY) {
readAttrs.push("currentX", "currentY");
@@ -198,7 +173,7 @@ interface ParsedMQTTMessage {
type: "bind" | "unbind";
sourceKey?: string;
sourceEndpointKey?: string | number;
targetKey?: string;
targetKey?: string | number;
targetEndpointKey?: string | number;
clusters?: string[];
skipDisableReporting: boolean;
@@ -209,6 +184,7 @@ interface ParsedMQTTMessage {
}
export default class Bind extends Extension {
#topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/(bind|unbind)`);
private pollDebouncers: {[s: string]: () => void} = {};
// biome-ignore lint/suspicious/useAwait: API
@@ -221,7 +197,7 @@ export default class Bind extends Extension {
private parseMQTTMessage(
data: eventdata.MQTTMessage,
): [raw: KeyValue | undefined, parsed: ParsedMQTTMessage | undefined, error: string | undefined] {
if (data.topic.match(TOPIC_REGEX)) {
if (data.topic.match(this.#topicRegex)) {
const type = data.topic.endsWith("unbind") ? "unbind" : "bind";
let skipDisableReporting = false;
const message = JSON.parse(data.message) as Zigbee2MQTTAPI["bridge/request/device/bind"];
@@ -242,7 +218,10 @@ export default class Bind extends Extension {
return [message, {type, skipDisableReporting}, `Source device '${message.from}' does not exist`];
}
const resolvedTarget = message.to === DEFAULT_BIND_GROUP.name ? DEFAULT_BIND_GROUP : this.zigbee.resolveEntity(message.to);
const resolvedTarget =
message.to === DEFAULT_BIND_GROUP.name || message.to === DEFAULT_BIND_GROUP.ID
? DEFAULT_BIND_GROUP
: this.zigbee.resolveEntity(message.to);
if (!resolvedTarget) {
return [message, {type, skipDisableReporting}, `Target device or group '${message.to}' does not exist`];
@@ -482,16 +461,15 @@ export default class Bind extends Extension {
const items = [];
// biome-ignore lint/style/noNonNullAssertion: valid from outer `if`
for (const c of REPORT_CLUSTERS[bind.cluster.name as ClusterName]!) {
if (!c.condition || (await c.condition(endpoint))) {
const i = {...c};
delete i.condition;
for (const c of REPORT_CLUSTERS[bind.cluster.name as keyof typeof REPORT_CLUSTERS]!) {
if (!("condition" in c) || !c.condition || (await c.condition(endpoint))) {
const {attribute, minimumReportInterval, maximumReportInterval, reportableChange} = c;
items.push(i);
items.push({attribute, minimumReportInterval, maximumReportInterval, reportableChange});
}
}
await endpoint.configureReporting(bind.cluster.name, items);
await endpoint.configureReporting(bind.cluster.name as keyof typeof REPORT_CLUSTERS, items);
logger.info(`Successfully setup reporting for '${entity}' cluster '${bind.cluster.name}'`);
} catch (error) {
logger.warning(`Failed to setup reporting for '${entity}' cluster '${bind.cluster.name}' (${(error as Error).message})`);
@@ -541,16 +519,15 @@ export default class Bind extends Extension {
const items = [];
// biome-ignore lint/style/noNonNullAssertion: valid from loop (pushed to array only if in)
for (const item of REPORT_CLUSTERS[cluster as ClusterName]!) {
if (!item.condition || (await item.condition(endpoint))) {
const i = {...item};
delete i.condition;
for (const item of REPORT_CLUSTERS[cluster as keyof typeof REPORT_CLUSTERS]!) {
if (!("condition" in item) || !item.condition || (await item.condition(endpoint))) {
const {attribute, minimumReportInterval, reportableChange} = item;
items.push({...i, maximumReportInterval: 0xffff});
items.push({attribute, minimumReportInterval, maximumReportInterval: 0xffff, reportableChange});
}
}
await endpoint.configureReporting(cluster, items);
await endpoint.configureReporting(cluster as keyof typeof REPORT_CLUSTERS, items);
logger.info(`Successfully disabled reporting for '${entity}' cluster '${cluster}'`);
} catch (error) {
logger.warning(`Failed to disable reporting for '${entity}' cluster '${cluster}' (${(error as Error).message})`);
@@ -571,7 +548,7 @@ export default class Bind extends Extension {
* When we receive a message from a Hue dimmer we read the brightness from the bulb (if bound).
*/
const polls = POLL_ON_MESSAGE.filter((p) =>
p.cluster[data.cluster as ClusterName]?.some((c) => c.type === data.type && utils.equalsPartial(data.data, c.data)),
p.cluster[data.cluster as keyof (typeof p)["cluster"]]?.some((c) => c.type === data.type && utils.equalsPartial(data.data, c.data)),
);
if (polls.length) {
+40 -31
View File
@@ -1,30 +1,26 @@
import type winston from "winston";
import type Group from "../model/group";
import type {Zigbee2MQTTAPI, Zigbee2MQTTDevice, Zigbee2MQTTResponse, Zigbee2MQTTResponseEndpoints} from "../types/api";
import fs from "node:fs";
import bind from "bind-decorator";
import stringify from "json-stable-stringify-without-jsonify";
import JSZip from "jszip";
import objectAssignDeep from "object-assign-deep";
import type winston from "winston";
import Transport from "winston-transport";
import {Zcl} from "zigbee-herdsman";
import * as zhc from "zigbee-herdsman-converters";
import {InterviewState} from "zigbee-herdsman/dist/controller/model/device";
import * as zhc from "zigbee-herdsman-converters";
import Device from "../model/device";
import type Group from "../model/group";
import type {Zigbee2MQTTAPI, Zigbee2MQTTDevice, Zigbee2MQTTResponse, Zigbee2MQTTResponseEndpoints} from "../types/api";
import data from "../util/data";
import logger from "../util/logger";
import * as settings from "../util/settings";
import utils from "../util/utils";
import utils, {assertString, DEFAULT_BIND_GROUP_ID} from "../util/utils";
import Extension from "./extension";
const REQUEST_REGEX = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/(.*)`);
export default class Bridge extends Extension {
#requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/(.*)`);
// set on `start`
#osInfo!: Zigbee2MQTTAPI["bridge/info"]["os"];
private zigbee2mqttVersion!: {commitHash?: string; version: string};
private zigbeeHerdsmanVersion!: {version: string};
private zigbeeHerdsmanConvertersVersion!: {version: string};
@@ -58,14 +54,13 @@ export default class Bridge extends Extension {
override async start(): Promise<void> {
const debugToMQTTFrontend = settings.get().advanced.log_debug_to_mqtt_frontend;
const baseTopic = settings.get().mqtt.base_topic;
const bridgeLogging = (message: string, level: string, namespace: string): void => {
const payload = stringify({message, level, namespace});
if (payload !== this.lastBridgeLoggingPayload) {
this.lastBridgeLoggingPayload = payload;
void this.mqtt.publish("bridge/logging", payload, {}, baseTopic, true);
void this.mqtt.publish("bridge/logging", payload, {skipLog: true});
}
};
@@ -93,6 +88,15 @@ export default class Bridge extends Extension {
logger.addTransport(this.logTransport);
const os = await import("node:os");
const process = await import("node:process");
const logicalCpuCores = os.cpus();
this.#osInfo = {
version: `${os.version()} - ${os.release()} - ${os.arch()}`,
node_version: process.version,
cpus: `${[...new Set(logicalCpuCores.map((cpu) => cpu.model))].join(" | ")} (x${logicalCpuCores.length})`,
memory_mb: Math.round(os.totalmem() / 1024 / 1024),
};
this.zigbee2mqttVersion = await utils.getZigbee2MQTTVersion();
this.zigbeeHerdsmanVersion = await utils.getDependencyVersion("zigbee-herdsman");
this.zigbeeHerdsmanConvertersVersion = await utils.getDependencyVersion("zigbee-herdsman-converters");
@@ -129,7 +133,7 @@ export default class Bridge extends Extension {
data: {friendly_name: data.device.name, ieee_address: data.device.ieeeAddr},
};
await this.mqtt.publish("bridge/event", stringify(payload), {retain: false, qos: 0});
await this.mqtt.publish("bridge/event", stringify(payload));
});
this.eventBus.onDeviceLeave(this, async (data) => {
await this.publishDevices();
@@ -137,7 +141,7 @@ export default class Bridge extends Extension {
const payload: Zigbee2MQTTAPI["bridge/event"] = {type: "device_leave", data: {ieee_address: data.ieeeAddr, friendly_name: data.name}};
await this.mqtt.publish("bridge/event", stringify(payload), {retain: false, qos: 0});
await this.mqtt.publish("bridge/event", stringify(payload));
});
this.eventBus.onDeviceNetworkAddressChanged(this, async () => {
await this.publishDevices();
@@ -165,7 +169,7 @@ export default class Bridge extends Extension {
};
}
await this.mqtt.publish("bridge/event", stringify(payload), {retain: false, qos: 0});
await this.mqtt.publish("bridge/event", stringify(payload));
});
this.eventBus.onDeviceAnnounce(this, async (data) => {
await this.publishDevices();
@@ -175,7 +179,7 @@ export default class Bridge extends Extension {
data: {friendly_name: data.device.name, ieee_address: data.device.ieeeAddr},
};
await this.mqtt.publish("bridge/event", stringify(payload), {retain: false, qos: 0});
await this.mqtt.publish("bridge/event", stringify(payload));
});
await this.publishInfo();
@@ -192,7 +196,7 @@ export default class Bridge extends Extension {
}
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
const match = data.topic.match(REQUEST_REGEX);
const match = data.topic.match(this.#requestRegex);
if (!match) {
return;
@@ -565,7 +569,8 @@ export default class Bridge extends Extension {
}
const from = deviceAndHasLast ? this.lastJoinedDeviceIeeeAddr : message.from;
const to = message.to;
assertString(message.to, "to");
const to = message.to.trim();
const homeAssisantRename = message.homeassistant_rename !== undefined ? message.homeassistant_rename : false;
const entity = this.getEntity(entityType, from);
const oldFriendlyName = entity.options.friendly_name;
@@ -573,7 +578,7 @@ export default class Bridge extends Extension {
settings.changeFriendlyName(from, to);
// Clear retained messages
await this.mqtt.publish(oldFriendlyName, "", {retain: true});
await this.mqtt.publish(oldFriendlyName, "", {clientOptions: {retain: true}});
this.eventBus.emitEntityRenamed({entity: entity, homeAssisantRename, from: oldFriendlyName, to});
@@ -625,7 +630,6 @@ export default class Bridge extends Extension {
await entity.zh.removeFromNetwork();
}
this.eventBus.emitEntityRemoved({id: entity.ID, name: friendlyName, type: "device"});
settings.removeDevice(entity.ID as string);
} else {
if (force) {
@@ -634,15 +638,16 @@ export default class Bridge extends Extension {
await entity.zh.removeFromNetwork();
}
this.eventBus.emitEntityRemoved({id: entity.ID, name: friendlyName, type: "group"});
settings.removeGroup(entity.ID);
}
this.eventBus.emitEntityRemoved({entity, name: friendlyName});
// Remove from state
this.state.remove(entity.ID);
// Clear any retained messages
await this.mqtt.publish(friendlyName, "", {retain: true});
await this.mqtt.publish(friendlyName, "", {clientOptions: {retain: true}});
logger.info(`Successfully removed ${entityType} '${friendlyName}'${blockForceLog}`);
@@ -691,6 +696,8 @@ export default class Bridge extends Extension {
const networkParams = await this.zigbee.getNetworkParameters();
const payload: Zigbee2MQTTAPI["bridge/info"] = {
os: this.#osInfo,
mqtt: this.mqtt.info,
version: this.zigbee2mqttVersion.version,
commit: this.zigbee2mqttVersion.commitHash,
zigbee_herdsman_converters: this.zigbeeHerdsmanConvertersVersion,
@@ -712,7 +719,7 @@ export default class Bridge extends Extension {
config_schema: settings.schemaJson,
};
await this.mqtt.publish("bridge/info", stringify(payload), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
await this.mqtt.publish("bridge/info", stringify(payload), {clientOptions: {retain: true}, skipLog: true});
}
async publishDevices(): Promise<void> {
@@ -723,6 +730,7 @@ export default class Bridge extends Extension {
for (const endpoint of device.zh.endpoints) {
const data: (typeof endpoints)[keyof typeof endpoints] = {
name: device.endpointName(endpoint),
scenes: utils.getScenes(endpoint),
bindings: [],
configured_reportings: [],
@@ -774,7 +782,7 @@ export default class Bridge extends Extension {
});
}
await this.mqtt.publish("bridge/devices", stringify(devices), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
await this.mqtt.publish("bridge/devices", stringify(devices), {clientOptions: {retain: true}, skipLog: true});
}
async publishGroups(): Promise<void> {
@@ -789,14 +797,14 @@ export default class Bridge extends Extension {
groups.push({
id: group.ID,
friendly_name: group.ID === 901 ? "default_bind_group" : group.name,
friendly_name: group.ID === DEFAULT_BIND_GROUP_ID ? "default_bind_group" : group.name,
description: group.options.description,
scenes: utils.getScenes(group.zh),
members,
});
}
await this.mqtt.publish("bridge/groups", stringify(groups), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
await this.mqtt.publish("bridge/groups", stringify(groups), {clientOptions: {retain: true}, skipLog: true});
}
async publishDefinitions(): Promise<void> {
@@ -809,7 +817,7 @@ export default class Bridge extends Extension {
data.custom_clusters[device.ieeeAddr] = device.customClusters;
}
await this.mqtt.publish("bridge/definitions", stringify(data), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
await this.mqtt.publish("bridge/definitions", stringify(data), {clientOptions: {retain: true}, skipLog: true});
}
getDefinitionPayload(device: Device): Zigbee2MQTTDevice["definition"] | undefined {
@@ -824,11 +832,12 @@ export default class Bridge extends Extension {
if (icon) {
/* v8 ignore next */
icon = icon.replace("${zigbeeModel}", utils.sanitizeImageParameter(device.zh.modelID ?? ""));
icon = icon.replace("${model}", utils.sanitizeImageParameter(device.definition.model));
icon = icon.replace("$zigbeeModel", utils.sanitizeImageParameter(device.zh.modelID ?? ""));
icon = icon.replace("$model", utils.sanitizeImageParameter(device.definition.model));
}
const payload: Zigbee2MQTTDevice["definition"] = {
source: device.definition.externalConverterName ? "external" : device.definition.generated ? "generated" : "native",
model: device.definition.model,
vendor: device.definition.vendor,
description: device.definition.description,
+4 -5
View File
@@ -1,11 +1,8 @@
import type {Zigbee2MQTTAPI} from "../types/api";
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";
import * as settings from "../util/settings";
import utils from "../util/utils";
@@ -59,7 +56,7 @@ export default class Configure extends Extension {
}
}
override async start(): Promise<void> {
override start(): Promise<void> {
setImmediate(async () => {
// Only configure routers on startup, end devices are likely sleeping and
// will reconfigure once they send a message
@@ -82,6 +79,8 @@ export default class Configure extends Extension {
this.eventBus.onLastSeenChanged(this, (data) => this.configure(data.device, "zigbee_event"));
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
this.eventBus.onReconfigure(this, this.onReconfigure);
return Promise.resolve();
}
private async configure(
+1 -2
View File
@@ -1,7 +1,6 @@
import type Extension from "./extension";
import logger from "../util/logger";
import * as settings from "../util/settings";
import type Extension from "./extension";
import ExternalJSExtension from "./externalJS";
type TModule = new (...args: ConstructorParameters<typeof Extension>) => Extension;
+81 -86
View File
@@ -1,10 +1,9 @@
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponse} from "../types/api";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import bind from "bind-decorator";
import stringify from "json-stable-stringify-without-jsonify";
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponse} from "../types/api";
import data from "../util/data";
import logger from "../util/logger";
@@ -13,13 +12,14 @@ import utils from "../util/utils";
import Extension from "./extension";
const SUPPORTED_OPERATIONS = ["save", "remove"];
const TMP_PREFIX = ".tmp-ed42d4f2-";
export default abstract class ExternalJSExtension<M> extends Extension {
protected folderName: string;
protected mqttTopic: string;
protected requestRegex: RegExp;
protected basePath: string;
protected srcBasePath: string;
protected nodeModulesSymlinked = false;
constructor(
zigbee: Zigbee,
@@ -39,13 +39,35 @@ export default abstract class ExternalJSExtension<M> extends Extension {
this.mqttTopic = mqttTopic;
this.requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/${mqttTopic}/(save|remove)`);
this.basePath = data.joinPath(folderName);
// 1-up from this file
this.srcBasePath = path.join(
__dirname,
"..",
// prevent race in vitest with files being manipulated from same location
process.env.VITEST_WORKER_ID ? /* v8 ignore next */ `${folderName}_${Math.floor(Math.random() * 10000)}` : folderName,
);
}
/**
* In case the external JS is not in the Z2M install dir (e.g. when `ZIGBEE2MQTT_DATA` is used), the external
* JS cannot import from `node_modules`.
* To workaround this create a symlink to `node_modules` in the external JS dir.
* https://nodejs.org/api/esm.html#no-node_path
*/
private symlinkNodeModulesIfNecessary() {
if (!this.nodeModulesSymlinked) {
this.nodeModulesSymlinked = true;
const nodeModulesPath = path.join(__dirname, "..", "..", "node_modules");
const z2mDirNormalized = `${path.resolve(path.join(nodeModulesPath, ".."))}${path.sep}`;
const basePathNormalized = `${path.resolve(this.basePath)}${path.sep}`;
const basePathInZ2mDir = basePathNormalized.startsWith(z2mDirNormalized);
if (!basePathInZ2mDir) {
logger.debug(`External JS folder '${this.folderName}' is outside the Z2M install dir, creating a symlink to 'node_modules'`);
const nodeModulesSymlink = path.join(this.basePath, "node_modules");
/* v8 ignore start */
if (fs.existsSync(nodeModulesSymlink)) {
fs.unlinkSync(nodeModulesSymlink);
}
/* v8 ignore stop */
// Type `junction` is required on Windows.
// https://github.com/nodejs/node/issues/18518#issuecomment-513866491
fs.symlinkSync(nodeModulesPath, nodeModulesSymlink, /* v8 ignore next */ os.platform() === "win32" ? "junction" : "dir");
}
}
}
override async start(): Promise<void> {
@@ -56,35 +78,34 @@ export default abstract class ExternalJSExtension<M> extends Extension {
}
override async stop(): Promise<void> {
// remove src base path on stop to ensure always back to default
fs.rmSync(this.srcBasePath, {force: true, recursive: true});
await super.stop();
// ensure "node_modules" is never followed & included in 3rd-party backup systems
const nodeModulesSymlink = path.join(this.basePath, "node_modules");
if (fs.existsSync(nodeModulesSymlink)) {
fs.unlinkSync(nodeModulesSymlink);
}
}
private getFilePath(name: string, mkBasePath = false, inSource = false): string {
const basePath = inSource ? this.srcBasePath : this.basePath;
if (mkBasePath && !fs.existsSync(basePath)) {
fs.mkdirSync(basePath, {recursive: true});
private getFilePath(name: string, mkBasePath = false): string {
if (mkBasePath && !fs.existsSync(this.basePath)) {
fs.mkdirSync(this.basePath, {recursive: true});
}
return path.join(basePath, name);
return path.join(this.basePath, name);
}
protected getFileCode(name: string): string {
return fs.readFileSync(this.getFilePath(name), "utf8");
}
protected *getFiles(inSource = false): Generator<{name: string; code: string}> {
const basePath = inSource ? this.srcBasePath : this.basePath;
if (!fs.existsSync(basePath)) {
return;
}
for (const fileName of fs.readdirSync(basePath)) {
if (fileName.endsWith(".js") || fileName.endsWith(".cjs") || fileName.endsWith(".mjs")) {
yield {name: fileName, code: this.getFileCode(fileName)};
protected *getFiles(): Generator<{name: string; code: string}> {
if (fs.existsSync(this.basePath)) {
for (const fileName of fs.readdirSync(this.basePath)) {
if (!fileName.startsWith(TMP_PREFIX) && (fileName.endsWith(".js") || fileName.endsWith(".cjs") || fileName.endsWith(".mjs"))) {
yield {name: fileName, code: this.getFileCode(fileName)};
}
}
}
}
@@ -131,14 +152,12 @@ export default abstract class ExternalJSExtension<M> extends Extension {
}
const {name} = message;
const srcToBeRemoved = this.getFilePath(name, false, true);
const toBeRemoved = this.getFilePath(name);
if (fs.existsSync(srcToBeRemoved)) {
const mod = await import(this.getImportPath(srcToBeRemoved));
if (fs.existsSync(toBeRemoved)) {
const mod = await this.importFile(toBeRemoved);
await this.removeJS(name, mod.default);
fs.rmSync(srcToBeRemoved, {force: true});
fs.rmSync(toBeRemoved, {force: true});
logger.info(`${name} (${toBeRemoved}) removed.`);
await this.publishExternalJS();
@@ -146,7 +165,7 @@ export default abstract class ExternalJSExtension<M> extends Extension {
return utils.getResponse(message, {});
}
return utils.getResponse(message, {}, `${name} (${srcToBeRemoved}) doesn't exists`);
return utils.getResponse(message, {}, `${name} (${toBeRemoved}) doesn't exists`);
}
@bind private async save(
@@ -157,66 +176,37 @@ export default abstract class ExternalJSExtension<M> extends Extension {
}
const {name, code} = message;
const srcFilePath = this.getFilePath(name, true, true);
let newName = name;
if (fs.existsSync(srcFilePath)) {
// if file already exist, version it to bypass node module caching
const versionMatch = name.match(/\.(\d+)\.(c|m)?js$/);
if (versionMatch) {
const version = Number.parseInt(versionMatch[1], 10);
newName = name.replace(`.${version}.`, `.${version + 1}.`);
} else {
const ext = path.extname(name);
newName = name.replace(ext, `.1${ext}`);
}
// remove previous version
fs.rmSync(srcFilePath, {force: true});
fs.rmSync(this.getFilePath(name, true, false), {force: true});
}
const newSrcFilePath = this.getFilePath(newName, false /* already created above if needed */, true);
const filePath = this.getFilePath(name, true);
try {
fs.writeFileSync(newSrcFilePath, code, "utf8");
fs.writeFileSync(filePath, code, "utf8");
this.symlinkNodeModulesIfNecessary();
const mod = await import(this.getImportPath(newSrcFilePath));
const mod = await this.importFile(filePath);
await this.loadJS(name, mod.default, newName);
logger.info(`${newName} loaded. Contents written to '${newSrcFilePath}'.`);
// keep original in data folder synced
fs.writeFileSync(this.getFilePath(newName, true, false), code, "utf8");
await this.loadJS(name, mod.default, name);
logger.info(`${name} loaded. Contents written to '${filePath}'.`);
await this.publishExternalJS();
return utils.getResponse(message, {});
} catch (error) {
fs.rmSync(newSrcFilePath, {force: true});
// NOTE: original in data folder doesn't get written if invalid
return utils.getResponse(message, {}, `${newName} contains invalid code: ${(error as Error).message}`);
return utils.getResponse(message, {}, `${name} contains invalid code: ${(error as Error).message}`);
}
}
private async loadFiles(): Promise<void> {
for (const extension of this.getFiles()) {
const srcFilePath = this.getFilePath(extension.name, true, true);
this.symlinkNodeModulesIfNecessary();
const filePath = this.getFilePath(extension.name);
try {
fs.copyFileSync(filePath, srcFilePath);
const mod = await import(this.getImportPath(srcFilePath));
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`);
fs.rmSync(srcFilePath, {force: true});
logger.error(
`Invalid external ${this.mqttTopic} '${extension.name}' was ignored and renamed to prevent interference with Zigbee2MQTT.`,
`Invalid external ${this.mqttTopic} '${extension.name}' was ignored and renamed to prevent interference with Zigbee2MQTT. (${(error as Error).message})`,
);
// biome-ignore lint/style/noNonNullAssertion: always Error
logger.debug((error as Error).stack!);
@@ -225,20 +215,25 @@ export default abstract class ExternalJSExtension<M> extends Extension {
}
private async publishExternalJS(): Promise<void> {
await this.mqtt.publish(
`bridge/${this.mqttTopic}s`,
stringify(Array.from(this.getFiles(true))),
{
retain: true,
qos: 0,
},
settings.get().mqtt.base_topic,
true,
);
await this.mqtt.publish(`bridge/${this.mqttTopic}s`, stringify(Array.from(this.getFiles())), {
clientOptions: {retain: true},
skipLog: true,
});
}
private getImportPath(filePath: string): string {
// prevent issues on Windows
return path.relative(__dirname, filePath).replaceAll("\\", "/");
// biome-ignore lint/suspicious/noExplicitAny: dynamic module
private async importFile(file: string): Promise<any> {
const ext = path.extname(file);
// Create the file in a temp path to bypass node module cache when importing multiple times.
const tmpFile = path.join(this.basePath, `${TMP_PREFIX}${path.basename(file, ext)}-${crypto.randomUUID()}${ext}`);
fs.copyFileSync(file, tmpFile);
try {
// Do `replaceAll("\\", "/")` to prevent issues on Windows
/* v8 ignore next */
const mod = await import(os.platform() === "win32" ? `file:///${tmpFile.replaceAll("\\", "/")}` : tmpFile);
return mod;
} finally {
fs.rmSync(tmpFile);
}
}
}
+123 -119
View File
@@ -1,23 +1,17 @@
import type {IncomingMessage, Server, ServerResponse} from "node:http";
import type {Socket} from "node:net";
import type {RequestHandler} from "express-static-gzip";
import assert from "node:assert";
import {existsSync, readFileSync} from "node:fs";
import type {IncomingMessage, Server, ServerResponse} from "node:http";
import {createServer} from "node:http";
import {createServer as createSecureServer} from "node:https";
import type {Socket} from "node:net";
import {posix} from "node:path";
import {parse} from "node:url";
import bind from "bind-decorator";
import expressStaticGzip from "express-static-gzip";
import finalhandler from "finalhandler";
import stringify from "json-stable-stringify-without-jsonify";
import WebSocket from "ws";
import frontend from "zigbee2mqtt-frontend";
import data from "../util/data";
import logger from "../util/logger";
import * as settings from "../util/settings";
@@ -29,14 +23,7 @@ import Extension from "./extension";
*/
export class Frontend extends Extension {
private mqttBaseTopic: string;
private host: string | undefined;
private port: number;
private sslCert: string | undefined;
private sslKey: string | undefined;
private authToken: string | undefined;
private server!: Server;
private fileServer!: RequestHandler;
private deviceIconsFileServer!: RequestHandler;
private server: Server | undefined;
private wss!: WebSocket.Server;
private baseUrl: string;
@@ -54,69 +41,102 @@ export class Frontend extends Extension {
const frontendSettings = settings.get().frontend;
assert(frontendSettings.enabled, `Frontend extension created with setting 'enabled: false'`);
this.host = frontendSettings.host;
this.port = frontendSettings.port;
this.sslCert = frontendSettings.ssl_cert;
this.sslKey = frontendSettings.ssl_key;
this.authToken = frontendSettings.auth_token;
this.baseUrl = frontendSettings.base_url;
this.mqttBaseTopic = settings.get().mqtt.base_topic;
}
private isHttpsConfigured(): boolean {
if (this.sslCert && this.sslKey) {
if (!existsSync(this.sslCert) || !existsSync(this.sslKey)) {
logger.error(`defined ssl_cert '${this.sslCert}' or ssl_key '${this.sslKey}' file path does not exists, server won't be secured.`);
return false;
}
return true;
}
return false;
}
override async start(): Promise<void> {
const options = {
enableBrotli: true,
// TODO: https://github.com/Koenkk/zigbee2mqtt/issues/24654 - enable compressed index serving when express-static-gzip is fixed.
index: false,
serveStatic: {
index: "index.html",
/* v8 ignore start */
setHeaders: (res: ServerResponse, path: string): void => {
if (path.endsWith("index.html")) {
res.setHeader("Cache-Control", "no-store");
if (settings.get().frontend.disable_ui_serving) {
const {host, port} = settings.get().frontend;
this.wss = new WebSocket.Server({port, host, path: posix.join(this.baseUrl, "api")});
logger.info(
/* v8 ignore next */
`Frontend UI serving is disabled. WebSocket at: ${this.wss.options.host ?? "0.0.0.0"}:${this.wss.options.port}${this.wss.options.path}`,
);
} else {
const {host, port, ssl_key: sslKey, ssl_cert: sslCert} = settings.get().frontend;
const hasSSL = (val: string | undefined, key: string): val is string => {
if (val) {
if (existsSync(val)) {
return true;
}
logger.error(`Defined ${key} '${val}' file path does not exists, server won't be secured.`);
}
return false;
};
const options: expressStaticGzip.ExpressStaticGzipOptions = {
enableBrotli: true,
serveStatic: {
/* v8 ignore start */
setHeaders: (res: ServerResponse, path: string): void => {
if (path.endsWith("index.html")) {
res.setHeader("Cache-Control", "no-store");
}
},
/* v8 ignore stop */
},
/* v8 ignore stop */
},
};
this.fileServer = expressStaticGzip(frontend.getPath(), options);
this.deviceIconsFileServer = expressStaticGzip(data.joinPath("device_icons"), options);
this.wss = new WebSocket.Server({noServer: true, path: posix.join(this.baseUrl, "api")});
};
const frontend = (await import(settings.get().frontend.package)) as typeof import("zigbee2mqtt-frontend");
const fileServer = expressStaticGzip(frontend.default.getPath(), options);
const deviceIconsFileServer = expressStaticGzip(data.joinPath("device_icons"), options);
const onRequest = (request: IncomingMessage, response: ServerResponse): void => {
const next = finalhandler(request, response);
// biome-ignore lint/style/noNonNullAssertion: `Only valid for request obtained from Server`
const newUrl = posix.relative(this.baseUrl, request.url!);
// The request url is not within the frontend base url, so the relative path starts with '..'
if (newUrl.startsWith(".")) {
next();
return;
}
// Attach originalUrl so that static-server can perform a redirect to '/' when serving the root directory.
// This is necessary for the browser to resolve relative assets paths correctly.
request.originalUrl = request.url;
request.url = `/${newUrl}`;
request.path = request.url;
if (newUrl.startsWith("device_icons/")) {
request.path = request.path.replace("device_icons/", "");
request.url = request.url.replace("/device_icons", "");
deviceIconsFileServer(request, response, next);
} else {
fileServer(request, response, next);
}
};
if (hasSSL(sslKey, "ssl_key") && hasSSL(sslCert, "ssl_cert")) {
const serverOptions = {key: readFileSync(sslKey), cert: readFileSync(sslCert)};
this.server = createSecureServer(serverOptions, onRequest);
} else {
this.server = createServer(onRequest);
}
this.server.on("upgrade", this.onUpgrade);
if (!host) {
this.server.listen(port);
logger.info(`Started frontend on port ${port}`);
} else if (host.startsWith("/")) {
this.server.listen(host);
logger.info(`Started frontend on socket ${host}`);
} else {
this.server.listen(port, host);
logger.info(`Started frontend on port ${host}:${port}`);
}
this.wss = new WebSocket.Server({noServer: true, path: posix.join(this.baseUrl, "api")});
}
this.wss.on("connection", this.onWebSocketConnection);
if (this.isHttpsConfigured()) {
// biome-ignore lint/style/noNonNullAssertion: valid from `isHttpsConfigured`
const serverOptions = {key: readFileSync(this.sslKey!), cert: readFileSync(this.sslCert!)};
this.server = createSecureServer(serverOptions, this.onRequest);
} else {
this.server = createServer(this.onRequest);
}
this.server.on("upgrade", this.onUpgrade);
this.eventBus.onMQTTMessagePublished(this, this.onMQTTPublishMessage);
if (!this.host) {
this.server.listen(this.port);
logger.info(`Started frontend on port ${this.port}`);
} else if (this.host.startsWith("/")) {
this.server.listen(this.host);
logger.info(`Started frontend on socket ${this.host}`);
} else {
this.server.listen(this.port, this.host);
logger.info(`Started frontend on port ${this.host}:${this.port}`);
}
this.eventBus.onMQTTMessagePublished(this, this.onMQTTPublishMessageOrEntityState);
this.eventBus.onPublishEntityState(this, this.onMQTTPublishMessageOrEntityState);
}
override async stop(): Promise<void> {
@@ -131,51 +151,20 @@ export class Frontend extends Extension {
this.wss.close();
}
await new Promise((resolve) => this.server?.close(resolve));
}
@bind private onRequest(request: IncomingMessage, response: ServerResponse): void {
const fin = finalhandler(request, response);
// biome-ignore lint/style/noNonNullAssertion: `Only valid for request obtained from Server`
const newUrl = posix.relative(this.baseUrl, request.url!);
// The request url is not within the frontend base url, so the relative path starts with '..'
if (newUrl.startsWith(".")) {
fin();
return;
}
// Attach originalUrl so that static-server can perform a redirect to '/' when serving the root directory.
// This is necessary for the browser to resolve relative assets paths correctly.
request.originalUrl = request.url;
request.url = `/${newUrl}`;
request.path = request.url;
if (newUrl.startsWith("device_icons/")) {
request.path = request.path.replace("device_icons/", "");
request.url = request.url.replace("/device_icons", "");
this.deviceIconsFileServer(request, response, fin);
} else {
this.fileServer(request, response, fin);
}
}
private authenticate(request: IncomingMessage, cb: (authenticate: boolean) => void): void {
// biome-ignore lint/style/noNonNullAssertion: `Only valid for request obtained from Server`
const {query} = parse(request.url!, true);
cb(!this.authToken || this.authToken === query.token);
await new Promise((resolve) => (this.server ? this.server.close(resolve) : resolve(undefined)));
}
@bind private onUpgrade(request: IncomingMessage, socket: Socket, head: Buffer): void {
this.wss.handleUpgrade(request, socket, head, (ws) => {
this.authenticate(request, (isAuthenticated) => {
if (isAuthenticated) {
this.wss.emit("connection", ws, request);
} else {
ws.close(4401, "Unauthorized");
}
});
// biome-ignore lint/style/noNonNullAssertion: `Only valid for request obtained from Server`
const {query} = parse(request.url!, true);
const authToken = settings.get().frontend.auth_token;
if (!authToken || authToken === query.token) {
this.wss.emit("connection", ws, request);
} else {
ws.close(4401, "Unauthorized");
}
});
}
@@ -217,16 +206,31 @@ export class Frontend extends Extension {
}
}
@bind private onMQTTPublishMessage(data: eventdata.MQTTMessagePublished): void {
if (data.topic.startsWith(`${this.mqttBaseTopic}/`)) {
// Send topic without base_topic
const topic = data.topic.substring(this.mqttBaseTopic.length + 1);
const payload = utils.parseJSON(data.payload, data.payload);
@bind private onMQTTPublishMessageOrEntityState(data: eventdata.MQTTMessagePublished | eventdata.PublishEntityState): void {
let topic: string;
let payload: KeyValue | string;
for (const client of this.wss.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(stringify({topic, payload}));
}
if ("topic" in data) {
// MQTTMessagePublished
if (data.options.meta.isEntityState || !data.topic.startsWith(`${this.mqttBaseTopic}/`)) {
// Don't send entity state to frontend on `MQTTMessagePublished` event, this is handled by
// `PublishEntityState` instead. Reason for this is to skip attribute messages when `output` is
// set to `attribute` or `attribute_and_json`, we only want to send JSON entity states to the
// frontend.
return;
}
// Send topic without base_topic
topic = data.topic.substring(this.mqttBaseTopic.length + 1);
payload = utils.parseJSON(data.payload, data.payload);
} else {
// PublishEntityState
topic = data.entity.name;
payload = data.message;
}
for (const client of this.wss.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(stringify({topic, payload}));
}
}
}
+8 -13
View File
@@ -1,22 +1,16 @@
import type * as zhc from "zigbee-herdsman-converters";
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from "../types/api";
import assert from "node:assert";
import bind from "bind-decorator";
import equals from "fast-deep-equal/es6";
import stringify from "json-stable-stringify-without-jsonify";
import type * as zhc from "zigbee-herdsman-converters";
import Device from "../model/device";
import Group from "../model/group";
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from "../types/api";
import logger from "../util/logger";
import * as settings from "../util/settings";
import utils, {isLightExpose} from "../util/utils";
import Extension from "./extension";
const TOPIC_REGEX = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/group/members/(remove|add|remove_all)$`);
const STATE_PROPERTIES: Readonly<Record<string, (value: string, exposes: zhc.Expose[]) => boolean>> = {
state: () => true,
brightness: (_value, exposes) => exposes.some((e) => isLightExpose(e) && e.features.some((f) => f.name === "brightness")),
@@ -42,6 +36,7 @@ interface ParsedMQTTMessage {
}
export default class Groups extends Extension {
#topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/group/members/(remove|add|remove_all)$`);
private lastOptimisticState: {[s: string]: KeyValue} = {};
// biome-ignore lint/suspicious/useAwait: API
@@ -189,7 +184,7 @@ export default class Groups extends Extension {
private parseMQTTMessage(
data: eventdata.MQTTMessage,
): [raw: KeyValue | undefined, parsed: ParsedMQTTMessage | undefined, error: string | undefined] {
const topicRegexMatch = data.topic.match(TOPIC_REGEX);
const topicRegexMatch = data.topic.match(this.#topicRegex);
if (topicRegexMatch) {
const type = topicRegexMatch[1] as "remove" | "add" | "remove_all";
@@ -277,7 +272,7 @@ export default class Groups extends Extension {
try {
if (type === "add") {
assert(resolvedGroup, "`resolvedGroup` is missing");
logger.info(`Adding '${resolvedDevice.name}' to '${resolvedGroup.name}'`);
logger.info(`Adding endpoint '${resolvedEndpoint.ID}' of device '${resolvedDevice.name}' to group '${resolvedGroup.name}'`);
await resolvedEndpoint.addToGroup(resolvedGroup.zh);
changedGroups.push(resolvedGroup);
// biome-ignore lint/style/noNonNullAssertion: valid from resolved asserts
@@ -285,7 +280,7 @@ export default class Groups extends Extension {
await this.publishResponse<"bridge/response/group/members/add">(parsed.type, raw, respPayload);
} else if (type === "remove") {
assert(resolvedGroup, "`resolvedGroup` is missing");
logger.info(`Removing '${resolvedDevice.name}' from '${resolvedGroup.name}'`);
logger.info(`Removing endpoint '${resolvedEndpoint.ID}' of device '${resolvedDevice.name}' from group '${resolvedGroup.name}'`);
await resolvedEndpoint.removeFromGroup(resolvedGroup.zh);
changedGroups.push(resolvedGroup);
// biome-ignore lint/style/noNonNullAssertion: valid from resolved asserts
@@ -293,7 +288,7 @@ export default class Groups extends Extension {
await this.publishResponse<"bridge/response/group/members/remove">(parsed.type, raw, respPayload);
} else {
// remove_all
logger.info(`Removing '${resolvedDevice.name}' from all groups`);
logger.info(`Removing endpoint '${resolvedEndpoint.ID}' of device '${resolvedDevice.name}' from all groups`);
for (const group of this.zigbee.groupsIterator((g) => g.members.includes(resolvedEndpoint))) {
changedGroups.push(group);
@@ -305,7 +300,7 @@ export default class Groups extends Extension {
await this.publishResponse<"bridge/response/group/members/remove_all">(parsed.type, raw, respPayload);
}
} catch (e) {
const errorMsg = `Failed to ${type} from group (${(e as Error).message})`;
const errorMsg = `Failed to ${type} ${type === "add" ? "to" : "from"} group (${(e as Error).message})`;
await this.publishResponse(parsed.type, raw, {}, errorMsg);
// biome-ignore lint/style/noNonNullAssertion: always Error
logger.debug((e as Error).stack!);
+78
View File
@@ -0,0 +1,78 @@
import * as os from "node:os";
import * as process from "node:process";
import type {Zigbee2MQTTAPI} from "../types/api";
import * as settings from "../util/settings";
import utils from "../util/utils";
import Extension from "./extension";
/** Round with 2 decimals */
const round2 = (n: number): number => Math.round(n * 100.0) / 100.0;
/** Round with 4 decimals */
const round4 = (n: number): number => Math.round(n * 10000.0) / 10000.0;
export default class Health extends Extension {
#checkTimer: NodeJS.Timeout | undefined;
override async start(): Promise<void> {
await super.start();
this.#checkTimer = setInterval(this.#checkHealth.bind(this), utils.minutes(settings.get().health.interval));
}
override async stop(): Promise<void> {
clearInterval(this.#checkTimer);
await super.stop();
}
clearStats(): void {
this.eventBus.stats.devices.clear();
this.eventBus.stats.mqtt.published = 0;
this.eventBus.stats.mqtt.received = 0;
}
async #checkHealth(): Promise<void> {
const sysMemTotalKb = os.totalmem() / 1024;
const sysMemFreeKb = os.freemem() / 1024;
const sysMemUsedKb = sysMemTotalKb - sysMemFreeKb;
const procMemUsedKb = process.memoryUsage().rss / 1024;
const healthcheck: Zigbee2MQTTAPI["bridge/health"] = {
response_time: Date.now(),
os: {
load_average: os.loadavg(), // will be [0,0,0] on Windows (not supported)
memory_used_mb: round2(sysMemUsedKb / 1024),
memory_percent: round4((sysMemUsedKb / sysMemTotalKb) * 100.0),
},
process: {
uptime_sec: Math.floor(process.uptime()),
memory_used_mb: round2(procMemUsedKb / 1024),
memory_percent: round4((procMemUsedKb / sysMemTotalKb) * 100.0),
},
mqtt: {...this.mqtt.stats, ...this.eventBus.stats.mqtt},
devices: {},
};
for (const [ieeeAddr, device] of this.eventBus.stats.devices) {
let messages = 0;
let mps = 0;
if (device.lastSeenChanges) {
const timeDiff = Date.now() - device.lastSeenChanges.first;
messages = device.lastSeenChanges.messages;
mps = timeDiff > 0 ? round4(messages / (timeDiff / 1000.0)) : 0;
}
healthcheck.devices[ieeeAddr] = {
messages,
messages_per_sec: mps,
leave_count: device.leaveCounts,
network_address_changes: device.networkAddressChanges,
};
}
if (settings.get().health.reset_on_check) {
this.clearStats();
}
await this.mqtt.publish("bridge/health", JSON.stringify(healthcheck), {clientOptions: {retain: true, qos: 1}});
}
}
+27 -16
View File
@@ -1,9 +1,7 @@
import type * as zhc from "zigbee-herdsman-converters";
import assert from "node:assert";
import bind from "bind-decorator";
import stringify from "json-stable-stringify-without-jsonify";
import type * as zhc from "zigbee-herdsman-converters";
import logger from "../util/logger";
import * as settings from "../util/settings";
@@ -83,7 +81,7 @@ const BINARY_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
noise_detected: {device_class: "sound"},
occupancy: {device_class: "occupancy"},
power_outage_memory: {entity_category: "config", icon: "mdi:memory"},
presence: {device_class: "presence"},
presence: {device_class: "occupancy"},
setup: {device_class: "running"},
smoke: {device_class: "smoke"},
sos: {device_class: "safety"},
@@ -156,6 +154,8 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
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"},
formaldehyd: {state_class: "measurement"},
flow: {device_class: "volume_flow_rate", state_class: "measurement"},
gas_density: {icon: "mdi:google-circles-communities", state_class: "measurement"},
@@ -236,9 +236,12 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
device_class: "water",
state_class: "total_increasing",
},
x_axis: {icon: "mdi:axis-x-arrow"},
y_axis: {icon: "mdi:axis-y-arrow"},
z_axis: {icon: "mdi:axis-z-arrow"},
x: {icon: "mdi:axis-x-arrow", state_class: "measurement"},
x_axis: {icon: "mdi:axis-x-arrow", state_class: "measurement"},
y: {icon: "mdi:axis-y-arrow", state_class: "measurement"},
y_axis: {icon: "mdi:axis-y-arrow", state_class: "measurement"},
z: {icon: "mdi:axis-z-arrow", state_class: "measurement"},
z_axis: {icon: "mdi:axis-z-arrow", state_class: "measurement"},
} as const;
const ENUM_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
action: {icon: "mdi:gesture-double-tap"},
@@ -1218,13 +1221,13 @@ export class HomeAssistant extends Extension {
@bind async onEntityRemoved(data: eventdata.EntityRemoved): Promise<void> {
logger.debug(`Clearing Home Assistant discovery for '${data.name}'`);
const discovered = this.getDiscovered(data.id);
const discovered = this.getDiscovered(data.entity.ID);
for (const topic of Object.keys(discovered.messages)) {
await this.mqtt.publish(topic, "", {retain: true, qos: 1}, this.discoveryTopic, false, false);
await this.mqtt.publish(topic, "", {clientOptions: {retain: true, qos: 1}, baseTopic: this.discoveryTopic, skipReceive: false});
}
delete this.discovered[data.id];
delete this.discovered[data.entity.ID];
}
@bind async onGroupMembersChanged(data: eventdata.GroupMembersChanged): Promise<void> {
@@ -1305,7 +1308,7 @@ export class HomeAssistant extends Extension {
if (data.homeAssisantRename) {
const discovered = this.getDiscovered(data.entity);
for (const topic of Object.keys(discovered.messages)) {
await this.mqtt.publish(topic, "", {retain: true, qos: 1}, this.discoveryTopic, false, false);
await this.mqtt.publish(topic, "", {clientOptions: {retain: true, qos: 1}, baseTopic: this.discoveryTopic, skipReceive: false});
}
discovered.messages = {};
@@ -1685,7 +1688,11 @@ export class HomeAssistant extends Extension {
if (!discoveredMessage || discoveredMessage.payload !== payloadStr || !discoveredMessage.published) {
discovered.messages[topic] = {payload: payloadStr, published: publish};
if (publish) {
await this.mqtt.publish(topic, payloadStr, {retain: true, qos: 1}, this.discoveryTopic, false, false);
await this.mqtt.publish(topic, payloadStr, {
clientOptions: {retain: true, qos: 1},
baseTopic: this.discoveryTopic,
skipReceive: false,
});
}
} else {
logger.debug(`Skipping discovery of '${topic}', already discovered`);
@@ -1701,7 +1708,7 @@ export class HomeAssistant extends Extension {
for (const topic of lastDiscoveredTopics) {
const isDeviceAutomation = topic.match(this.discoveryRegexWoTopic)?.[1] === "device_automation";
if (!newDiscoveredTopics.has(topic) && !isDeviceAutomation) {
await this.mqtt.publish(topic, "", {retain: true, qos: 1}, this.discoveryTopic, false, false);
await this.mqtt.publish(topic, "", {clientOptions: {retain: true, qos: 1}, baseTopic: this.discoveryTopic, skipReceive: false});
}
}
}
@@ -1751,7 +1758,7 @@ export class HomeAssistant extends Extension {
if (clear) {
logger.debug(`Clearing outdated Home Assistant config '${data.topic}'`);
await this.mqtt.publish(topic, "", {retain: true, qos: 1}, this.discoveryTopic, false, false);
await this.mqtt.publish(topic, "", {clientOptions: {retain: true, qos: 1}, baseTopic: this.discoveryTopic, skipReceive: false});
} else if (entity) {
this.getDiscovered(entity).messages[topic] = {payload: stringify(message), published: true};
}
@@ -1784,7 +1791,7 @@ export class HomeAssistant extends Extension {
for (const topic of Object.keys(discovered.messages)) {
if (topic.startsWith("scene")) {
await this.mqtt.publish(topic, "", {retain: true, qos: 1}, this.discoveryTopic, false, false);
await this.mqtt.publish(topic, "", {clientOptions: {retain: true, qos: 1}, baseTopic: this.discoveryTopic, skipReceive: false});
delete discovered.messages[topic];
}
}
@@ -1917,7 +1924,11 @@ export class HomeAssistant extends Extension {
origin: this.discoveryOrigin,
};
await this.mqtt.publish(topic, stringify(payload), {retain: true, qos: 1}, this.discoveryTopic, false, false);
await this.mqtt.publish(topic, stringify(payload), {
clientOptions: {retain: true, qos: 1},
baseTopic: this.discoveryTopic,
skipReceive: false,
});
discovered.triggers.add(discoveredKey);
}
+4 -6
View File
@@ -1,7 +1,6 @@
import type {Zigbee2MQTTAPI, Zigbee2MQTTNetworkMap} from "../types/api";
import bind from "bind-decorator";
import stringify from "json-stable-stringify-without-jsonify";
import type {Zigbee2MQTTAPI, Zigbee2MQTTNetworkMap} from "../types/api";
import logger from "../util/logger";
import * as settings from "../util/settings";
@@ -14,7 +13,7 @@ const SUPPORTED_FORMATS = ["raw", "graphviz", "plantuml"];
* This extension creates a network map
*/
export default class NetworkMap extends Extension {
private topic = `${settings.get().mqtt.base_topic}/bridge/request/networkmap`;
#topic = `${settings.get().mqtt.base_topic}/bridge/request/networkmap`;
// biome-ignore lint/suspicious/useAwait: API
override async start(): Promise<void> {
@@ -22,7 +21,7 @@ export default class NetworkMap extends Extension {
}
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
if (data.topic === this.topic) {
if (data.topic === this.#topic) {
const message = utils.parseJSON(data.message, data.message) as Zigbee2MQTTAPI["bridge/request/networkmap"];
try {
@@ -268,8 +267,7 @@ export default class NetworkMap extends Extension {
networkAddress: device.zh.networkAddress,
manufacturerName: device.zh.manufacturerName,
modelID: device.zh.modelID,
// biome-ignore lint/style/noNonNullAssertion: TODO: biome migration: wrong typing? will be undefined if type=EndDevice?
failed: failed.get(device)!,
failed: failed.get(device),
lastSeen: device.zh.lastSeen,
definition,
});
+35 -25
View File
@@ -1,4 +1,4 @@
import {onEvent} from "zigbee-herdsman-converters";
import {onEvent, type OnEvent as ZhcOnEvent} from "zigbee-herdsman-converters";
import utils from "../util/utils";
import Extension from "./extension";
@@ -7,42 +7,50 @@ import Extension from "./extension";
* This extension calls the zigbee-herdsman-converters onEvent.
*/
export default class OnEvent extends Extension {
readonly #startCalled: Set<string> = new Set();
#getOnEventBaseData(device: Device): ZhcOnEvent.BaseData {
const deviceExposesChanged = (): void => this.eventBus.emitExposesAndDevicesChanged(device);
const state = this.state.get(device);
const options = device.options as KeyValue;
return {deviceExposesChanged, device: device.zh, state, options};
}
// biome-ignore lint/suspicious/useAwait: API
override async start(): Promise<void> {
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
// don't await, in case of repeated failures this would hold startup
this.callOnEvent(device, "start", {}).catch(utils.noop);
this.callOnEvent(device, {type: "start", data: this.#getOnEventBaseData(device)}).catch(utils.noop);
}
this.eventBus.onDeviceMessage(this, async (data) => {
await this.callOnEvent(data.device, "message", {
endpoint: data.endpoint,
meta: data.meta,
cluster: typeof data.cluster === "string" ? data.cluster : /* v8 ignore next */ undefined, // XXX: ZH typing is wrong?
type: data.type,
data: data.data, // XXX: typing is a bit convoluted: ZHC has `KeyValueAny` here while Z2M has `KeyValue | Array<string | number>`
});
});
this.eventBus.onDeviceJoined(this, async (data) => {
await this.callOnEvent(data.device, "deviceJoined", {});
await this.callOnEvent(data.device, {type: "deviceJoined", data: this.#getOnEventBaseData(data.device)});
});
this.eventBus.onDeviceLeave(this, async (data) => {
if (data.device) {
await this.callOnEvent(data.device, "stop", {});
await this.callOnEvent(data.device, {type: "stop", data: {ieeeAddr: data.device.ieeeAddr}});
}
});
this.eventBus.onEntityRemoved(this, async (data) => {
if (data.entity.isDevice()) {
await this.callOnEvent(data.entity, {type: "stop", data: {ieeeAddr: data.entity.ieeeAddr}});
}
});
this.eventBus.onDeviceInterview(this, async (data) => {
await this.callOnEvent(data.device, "deviceInterview", {});
await this.callOnEvent(data.device, {type: "deviceInterview", data: {...this.#getOnEventBaseData(data.device), status: data.status}});
});
this.eventBus.onDeviceAnnounce(this, async (data) => {
await this.callOnEvent(data.device, "deviceAnnounce", {});
await this.callOnEvent(data.device, {type: "deviceAnnounce", data: this.#getOnEventBaseData(data.device)});
});
this.eventBus.onDeviceNetworkAddressChanged(this, async (data) => {
await this.callOnEvent(data.device, "deviceNetworkAddressChanged", {});
await this.callOnEvent(data.device, {type: "deviceNetworkAddressChanged", data: this.#getOnEventBaseData(data.device)});
});
this.eventBus.onEntityOptionsChanged(this, async (data) => {
if (data.entity.isDevice()) {
await this.callOnEvent(data.entity, "deviceOptionsChanged", {});
await this.callOnEvent(data.entity, {
type: "deviceOptionsChanged",
data: {...this.#getOnEventBaseData(data.entity), from: data.from, to: data.to},
});
this.eventBus.emitDevicesChanged();
}
});
@@ -52,23 +60,25 @@ export default class OnEvent extends Extension {
await super.stop();
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
await this.callOnEvent(device, "stop", {});
await this.callOnEvent(device, {type: "stop", data: {ieeeAddr: device.ieeeAddr}});
}
}
private async callOnEvent(device: Device, type: Parameters<typeof onEvent>[0], data: Parameters<typeof onEvent>[1]): Promise<void> {
private async callOnEvent(device: Device, event: ZhcOnEvent.Event): Promise<void> {
if (device.options.disabled) {
return;
}
const state = this.state.get(device);
const deviceExposesChanged = (): void => this.eventBus.emitExposesAndDevicesChanged(device);
if (event.type !== "start" && event.type !== "stop" && !this.#startCalled.has(device.ieeeAddr) && device.definition?.onEvent) {
this.#startCalled.add(device.ieeeAddr);
await device.definition.onEvent({type: "start", data: this.#getOnEventBaseData(device)});
}
await onEvent(type, data, device.zh, {deviceExposesChanged});
await onEvent(event);
await device.definition?.onEvent?.(event);
if (device.definition?.onEvent) {
const options: KeyValue = device.options;
await device.definition.onEvent(type, data, device.zh, options, state, {deviceExposesChanged});
if (event.type === "stop") {
this.#startCalled.delete(device.ieeeAddr);
}
}
}
+7 -13
View File
@@ -1,17 +1,12 @@
import type {Ota} from "zigbee-herdsman-converters";
import type {Zigbee2MQTTAPI} from "../types/api";
import assert from "node:assert";
import path 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 Device from "../model/device";
import type {Zigbee2MQTTAPI} from "../types/api";
import dataDir from "../util/data";
import logger from "../util/logger";
import * as settings from "../util/settings";
@@ -30,12 +25,11 @@ export interface UpdatePayload {
};
}
const topicRegex = new RegExp(
`^${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/(update|check|schedule|unschedule)/?(downgrade)?`,
"i",
);
export default class OTAUpdate extends Extension {
#topicRegex = new RegExp(
`^${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/(update|check|schedule|unschedule)/?(downgrade)?`,
"i",
);
private inProgress = new Set<string>();
private lastChecked = new Map<string, number>();
private scheduledUpgrades = new Set<string>();
@@ -257,7 +251,7 @@ export default class OTAUpdate extends Extension {
}
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
const topicMatch = data.topic.match(topicRegex);
const topicMatch = data.topic.match(this.#topicRegex);
if (!topicMatch) {
return;
+4 -4
View File
@@ -1,7 +1,6 @@
import type * as zhc from "zigbee-herdsman-converters";
import bind from "bind-decorator";
import stringify from "json-stable-stringify-without-jsonify";
import type * as zhc from "zigbee-herdsman-converters";
import Device from "../model/device";
import Group from "../model/group";
@@ -10,12 +9,12 @@ import * as settings from "../util/settings";
import utils from "../util/utils";
import Extension from "./extension";
// TODO: get rid of this, use class member
let topicGetSetRegex: RegExp;
// Used by `publish.test.js` to reload regex when changing `mqtt.base_topic`.
// Used by `publish.test.ts` to reload regex when changing `mqtt.base_topic`.
export const loadTopicGetSetRegex = (): void => {
topicGetSetRegex = new RegExp(`^${settings.get().mqtt.base_topic}/(?!bridge)(.+?)/(get|set)(?:/(.+))?$`);
};
loadTopicGetSetRegex();
const STATE_VALUES: ReadonlyArray<string> = ["on", "off", "toggle", "open", "close", "stop", "lock", "unlock"];
const SCENE_CONVERTER_KEYS: ReadonlyArray<string> = ["scene_store", "scene_add", "scene_remove", "scene_remove_all", "scene_rename"];
@@ -30,6 +29,7 @@ interface ParsedTopic {
export default class Publish extends Extension {
// biome-ignore lint/suspicious/useAwait: API
override async start(): Promise<void> {
loadTopicGetSetRegex();
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
}
+1 -1
View File
@@ -136,7 +136,7 @@ export default class Receive extends Extension {
const publish = async (payload: KeyValue): Promise<void> => {
assert(data.device.definition);
const options: KeyValue = data.device.options;
zhc.postProcessConvertedFromZigbeeMessage(data.device.definition, payload, options);
zhc.postProcessConvertedFromZigbeeMessage(data.device.definition, payload, options, data.device.zh);
if (settings.get().advanced.elapsed) {
const now = Date.now();
+20 -30
View File
@@ -1,10 +1,8 @@
import type {CustomClusters} from "zigbee-herdsman/dist/zspec/zcl/definition/tstype";
import assert from "node:assert";
import * as zhc from "zigbee-herdsman-converters";
import {Numeric, access} from "zigbee-herdsman-converters";
import {InterviewState} from "zigbee-herdsman/dist/controller/model/device";
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 * as settings from "../util/settings";
@@ -78,43 +76,33 @@ export default class Device {
}
endpoint(key?: string | number): zh.Endpoint | undefined {
let endpoint: zh.Endpoint | undefined;
if (!key) {
key = "default";
} else if (!Number.isNaN(Number(key))) {
return this.zh.getEndpoint(Number(key));
}
if (!Number.isNaN(Number(key))) {
endpoint = this.zh.getEndpoint(Number(key));
} else if (this.definition?.endpoint) {
const ID = this.definition?.endpoint?.(this.zh)[key];
if (this.definition?.endpoint) {
const ID = this.definition.endpoint(this.zh)[key];
if (ID) {
endpoint = this.zh.getEndpoint(ID);
} else if (key === "default") {
endpoint = this.zh.endpoints[0];
} else {
return undefined;
return this.zh.getEndpoint(ID);
}
} else {
if (key !== "default") {
return undefined;
}
endpoint = this.zh.endpoints[0];
}
return endpoint;
return key === "default" ? this.zh.endpoints[0] : undefined;
}
endpointName(endpoint: zh.Endpoint): string | undefined {
let epName = undefined;
let epName: string | undefined;
if (this.definition?.endpoint) {
const mapping = this.definition?.endpoint(this.zh);
for (const [name, id] of Object.entries(mapping)) {
if (id === endpoint.ID) {
const mapping = this.definition.endpoint(this.zh);
for (const name in mapping) {
if (mapping[name] === endpoint.ID) {
epName = name;
break;
}
}
}
@@ -126,9 +114,11 @@ export default class Device {
getEndpointNames(): string[] {
const names: string[] = [];
for (const name in this.definition?.endpoint?.(this.zh) ?? {}) {
if (name !== "default") {
names.push(name);
if (this.definition?.endpoint) {
for (const name in this.definition.endpoint(this.zh)) {
if (name !== "default") {
names.push(name);
}
}
}
+7 -1
View File
@@ -1,6 +1,6 @@
import type * as zhc from "zigbee-herdsman-converters";
import * as settings from "../util/settings";
import {DEFAULT_BIND_GROUP_ID} from "../util/utils";
export default class Group {
public zh: zh.Group;
@@ -23,6 +23,12 @@ export default class Group {
this.resolveDevice = resolveDevice;
}
ensureInSettings(): void {
if (this.ID !== DEFAULT_BIND_GROUP_ID && !settings.getGroup(this.ID)) {
settings.addGroup(this.name, this.ID.toString());
}
}
hasMember(device: Device): boolean {
return !!device.zh.endpoints.find((e) => this.zh.members.includes(e));
}
+52 -36
View File
@@ -1,11 +1,8 @@
import type {IClientOptions, IClientPublishOptions, MqttClient} from "mqtt";
import type {Zigbee2MQTTAPI} from "./types/api";
import fs from "node:fs";
import bind from "bind-decorator";
import type {IClientOptions, IClientPublishOptions, MqttClient} from "mqtt";
import {connectAsync} from "mqtt";
import type {Zigbee2MQTTAPI} from "./types/api";
import logger from "./util/logger";
import * as settings from "./util/settings";
@@ -13,18 +10,46 @@ import utils from "./util/utils";
const NS = "z2m:mqtt";
export interface MqttPublishOptions {
clientOptions: IClientPublishOptions;
baseTopic: string;
skipLog: boolean;
skipReceive: boolean;
meta: {isEntityState?: boolean};
}
export default class Mqtt {
private publishedTopics = new Set<string>();
private connectionTimer?: NodeJS.Timeout;
private client!: MqttClient;
private eventBus: EventBus;
private republishRetainedTimer?: NodeJS.Timeout;
public retainedMessages: {
[s: string]: {payload: string; options: IClientPublishOptions; skipLog: boolean; skipReceive: boolean; topic: string; base: string};
} = {};
private defaultPublishOptions: MqttPublishOptions;
public retainedMessages: {[s: string]: {topic: string; payload: string; options: MqttPublishOptions}} = {};
get info() {
return {
version: this.client.options.protocolVersion,
server: `${this.client.options.protocol}://${this.client.options.host}:${this.client.options.port}`,
};
}
get stats() {
return {
connected: this.isConnected(),
queued: this.client.queue.length,
};
}
constructor(eventBus: EventBus) {
this.eventBus = eventBus;
this.defaultPublishOptions = {
clientOptions: {},
baseTopic: settings.get().mqtt.base_topic,
skipLog: false,
skipReceive: true,
meta: {},
};
}
async connect(): Promise<void> {
@@ -109,7 +134,7 @@ export default class Mqtt {
// Republish retained messages in case MQTT broker does not persist them.
// https://github.com/Koenkk/zigbee2mqtt/issues/9629
for (const msg of Object.values(this.retainedMessages)) {
await this.publish(msg.topic, msg.payload, msg.options, msg.base, msg.skipLog, msg.skipReceive);
await this.publish(msg.topic, msg.payload, msg.options);
}
}, 2000);
@@ -127,7 +152,7 @@ export default class Mqtt {
const stateData: Zigbee2MQTTAPI["bridge/state"] = {state: "offline"};
await this.publish("bridge/state", JSON.stringify(stateData), {retain: true, qos: 0});
await this.publish("bridge/state", JSON.stringify(stateData), {clientOptions: {retain: true}});
this.eventBus.removeListeners(this);
logger.info("Disconnecting from MQTT server");
await this.client?.endAsync();
@@ -146,7 +171,7 @@ export default class Mqtt {
const stateData: Zigbee2MQTTAPI["bridge/state"] = {state: "online"};
await this.publish("bridge/state", JSON.stringify(stateData), {retain: true, qos: 0});
await this.publish("bridge/state", JSON.stringify(stateData), {clientOptions: {retain: true, qos: 1}});
await this.subscribe(`${settings.get().mqtt.base_topic}/#`);
}
@@ -168,60 +193,51 @@ export default class Mqtt {
return this.client && !this.client.reconnecting && !this.client.disconnecting && !this.client.disconnected;
}
async publish(
topic: string,
payload: string,
options: IClientPublishOptions = {},
base = settings.get().mqtt.base_topic,
skipLog = false,
skipReceive = true,
): Promise<void> {
async publish(topic: string, payload: string, options: Partial<MqttPublishOptions> = {}): Promise<void> {
if (topic.includes("+") || topic.includes("#")) {
// https://github.com/Koenkk/zigbee2mqtt/issues/26939#issuecomment-2772309646
logger.error(`Topic '${topic}' includes wildcard characters, skipping publish.`);
return;
}
const defaultOptions = {qos: 0 as const, retain: false};
topic = `${base}/${topic}`;
const finalOptions = {...this.defaultPublishOptions, ...options};
topic = `${finalOptions.baseTopic}/${topic}`;
if (skipReceive) {
if (finalOptions.skipReceive) {
this.publishedTopics.add(topic);
}
if (options.retain) {
if (finalOptions.clientOptions.retain) {
if (payload) {
this.retainedMessages[topic] = {payload, options, skipReceive, skipLog, topic: topic.substring(base.length + 1), base};
this.retainedMessages[topic] = {payload, options: finalOptions, topic: topic.substring(finalOptions.baseTopic.length + 1)};
} else {
delete this.retainedMessages[topic];
}
}
this.eventBus.emitMQTTMessagePublished({topic, payload, options: {...defaultOptions, ...options}});
this.eventBus.emitMQTTMessagePublished({topic, payload, options: finalOptions});
if (!this.isConnected()) {
if (!skipLog) {
if (!finalOptions.skipLog) {
logger.error("Not connected to MQTT server!");
logger.error(`Cannot send message: topic: '${topic}', payload: '${payload}`);
}
return;
}
if (!skipLog) {
let clientOptions: IClientPublishOptions = finalOptions.clientOptions;
if (settings.get().mqtt.force_disable_retain) {
clientOptions = {...finalOptions.clientOptions, retain: false};
}
if (!finalOptions.skipLog) {
logger.info(() => `MQTT publish: topic '${topic}', payload '${payload}'`, NS);
}
const actualOptions: IClientPublishOptions = {...defaultOptions, ...options};
if (settings.get().mqtt.force_disable_retain) {
actualOptions.retain = false;
}
try {
await this.client.publishAsync(topic, payload, actualOptions);
await this.client.publishAsync(topic, payload, clientOptions);
} catch (error) {
if (!skipLog) {
if (!finalOptions.skipLog) {
logger.error(`MQTT server error: ${(error as Error).message}`);
logger.error(`Could not send message: topic: '${topic}', payload: '${payload}`);
}
+73 -14
View File
@@ -1,7 +1,7 @@
import type * as zigbeeHerdsmanConverter from "zigbee-herdsman-converters";
import type {Base} from "zigbee-herdsman-converters/lib/exposes";
import type * as zigbeeHerdsman from "zigbee-herdsman/dist";
import type {ClusterDefinition, ClusterName, CustomClusters} from "zigbee-herdsman/dist/zspec/zcl/definition/tstype";
import type * as zigbeeHerdsmanConverter from "zigbee-herdsman-converters";
import type {Base} from "zigbee-herdsman-converters/lib/exposes";
export type * as ZSpec from "zigbee-herdsman/dist/zspec";
export type * as Zcl from "zigbee-herdsman/dist/zspec/zcl";
@@ -69,6 +69,8 @@ export interface Zigbee2MQTTGroupOptions {
export interface Zigbee2MQTTSettings {
version?: number;
/** only used internally during startup, removed on successful Z2M start */
onboarding?: true;
homeassistant: {
enabled: boolean;
discovery_topic: string;
@@ -140,6 +142,7 @@ export interface Zigbee2MQTTSettings {
};
frontend: {
enabled: boolean;
package: "zigbee2mqtt-frontend" | "zigbee2mqtt-windfront";
auth_token?: string;
host?: string;
port: number;
@@ -148,6 +151,7 @@ export interface Zigbee2MQTTSettings {
ssl_cert?: string;
ssl_key?: string;
notification_filter?: string[];
disable_ui_serving?: boolean;
};
devices: {[s: string]: Zigbee2MQTTDeviceOptions};
groups: {[s: string]: Omit<Zigbee2MQTTGroupOptions, "ID">};
@@ -180,6 +184,11 @@ export interface Zigbee2MQTTSettings {
output: "json" | "attribute" | "attribute_and_json";
transmit_power?: number;
};
health: {
/** in minutes */
interval: number;
reset_on_check: boolean;
};
}
export interface Zigbee2MQTTScene {
@@ -188,6 +197,7 @@ export interface Zigbee2MQTTScene {
}
export interface Zigbee2MQTTDeviceEndpoint {
name?: string;
bindings: Zigbee2MQTTDeviceEndpointBinding[];
configured_reportings: Zigbee2MQTTDeviceEndpointConfiguredReporting[];
clusters: {input: string[]; output: string[]};
@@ -210,6 +220,7 @@ export interface Zigbee2MQTTDeviceEndpointConfiguredReporting {
}
export interface Zigbee2MQTTDeviceDefinition {
source: "native" | "generated" | "external";
model: string;
vendor: string;
description: string;
@@ -226,8 +237,8 @@ export interface Zigbee2MQTTDevice {
supported: boolean;
friendly_name: string;
disabled: boolean;
description: string | undefined;
definition: Zigbee2MQTTDeviceDefinition | undefined;
description?: string;
definition?: Zigbee2MQTTDeviceDefinition;
power_source: zigbeeHerdsman.Models.Device["powerSource"];
software_build_id: zigbeeHerdsman.Models.Device["softwareBuildID"];
date_code: zigbeeHerdsman.Models.Device["dateCode"];
@@ -247,7 +258,7 @@ export interface Zigbee2MQTTGroupMember {
export interface Zigbee2MQTTGroup {
id: number;
friendly_name: "default_bind_group" | string;
description: string | undefined;
description?: string;
scenes: Zigbee2MQTTScene[];
members: Zigbee2MQTTGroupMember[];
}
@@ -258,10 +269,10 @@ export interface Zigbee2MQTTNetworkMap {
friendlyName: string;
type: string;
networkAddress: number;
manufacturerName: string | undefined;
modelID: string | undefined;
failed: string[];
lastSeen: number | undefined;
manufacturerName?: string;
modelID?: string;
failed?: string[];
lastSeen?: number;
definition?: {model: string; vendor: string; supports: string; description: string};
}[];
links: {
@@ -327,6 +338,16 @@ export interface Zigbee2MQTTAPI {
};
"bridge/info": {
os: {
version: string;
node_version: string;
cpus: string;
memory_mb: number;
};
mqtt: {
version: number | undefined;
server: string;
};
version: string;
commit: string | undefined;
zigbee_herdsman_converters: {version: string};
@@ -352,6 +373,36 @@ export interface Zigbee2MQTTAPI {
config_schema: typeof schemaJson;
};
"bridge/health": {
/** time of message, msec from epoch, UTC */
response_time: number;
os: {
load_average: number[];
memory_used_mb: number;
memory_percent: number;
};
process: {
uptime_sec: number;
memory_used_mb: number;
memory_percent: number;
};
mqtt: {
connected: boolean;
queued: number;
received: number;
published: number;
};
devices: Record<
string /* ieee */,
{
messages: number;
messages_per_sec: number;
leave_count: number;
network_address_changes: number;
}
>;
};
"bridge/devices": Zigbee2MQTTDevice[];
"bridge/groups": Zigbee2MQTTGroup[];
@@ -474,7 +525,7 @@ export interface Zigbee2MQTTAPI {
"bridge/request/device/bind": {
from: string;
from_endpoint: string | number | "default";
to: string;
to: string | number;
to_endpoint?: string | number;
clusters?: string[];
skip_disable_reporting?: boolean;
@@ -483,7 +534,7 @@ export interface Zigbee2MQTTAPI {
"bridge/response/device/bind": {
from: string;
from_endpoint: string | number;
to: string;
to: string | number;
to_endpoint: string | number | undefined;
clusters: string[];
failed: string[];
@@ -492,7 +543,7 @@ export interface Zigbee2MQTTAPI {
"bridge/request/device/unbind": {
from: string;
from_endpoint: string | number | "default";
to: string;
to: string | number;
to_endpoint?: string | number;
clusters?: string[];
skip_disable_reporting?: boolean;
@@ -501,7 +552,7 @@ export interface Zigbee2MQTTAPI {
"bridge/response/device/unbind": {
from: string;
from_endpoint: string | number;
to: string;
to: string | number;
to_endpoint: string | number | undefined;
clusters: string[];
failed: string[];
@@ -855,7 +906,15 @@ export type Zigbee2MQTTRequestEndpoints =
| "bridge/request/group/members/remove_all"
| "bridge/request/touchlink/factory_reset"
| "bridge/request/touchlink/scan"
| "bridge/request/touchlink/identify";
| "bridge/request/touchlink/identify"
| "{friendlyNameOrId}/set"
| "{friendlyNameOrId}/set/{attribute}"
| "{friendlyNameOrId}/{endpoint}/set"
| "{friendlyNameOrId}/{endpoint}/set/{attribute}"
| "{friendlyNameOrId}/get"
| "{friendlyNameOrId}/get/{attribute}"
| "{friendlyNameOrId}/{endpoint}/get"
| "{friendlyNameOrId}/{endpoint}/get/{attribute}";
export type Zigbee2MQTTResponseEndpoints =
| "bridge/response/permit_join"
+18
View File
@@ -0,0 +1,18 @@
// minimal required because of sub-deps in mqtt >= 5.14.0 to avoid requiring `dom` type
declare global {
// map to node, doesn't really matter, just needs to be there, and the right "type vs value" to avoid lib check problems
/** @deprecated DOM SHIM, DO NOT USE */
type MessagePort = import("node:worker_threads").MessagePort;
/** @deprecated DOM SHIM, DO NOT USE */
type Worker = import("node:worker_threads").Worker;
/** @deprecated DOM SHIM, DO NOT USE */
type Transferable = import("node:worker_threads").Transferable;
/** @deprecated DOM SHIM, DO NOT USE */
const addEventListener: import("node:events").EventEmitter["addListener"];
/** @deprecated DOM SHIM, DO NOT USE */
const removeEventListener: import("node:events").EventEmitter["removeListener"];
/** @deprecated DOM SHIM, DO NOT USE */
const postMessage: import("node:worker_threads").MessagePort["postMessage"];
}
export {};
+5 -3
View File
@@ -6,6 +6,7 @@ import type TypeExtension from "../extension/extension";
import type TypeDevice from "../model/device";
import type TypeGroup from "../model/group";
import type TypeMqtt from "../mqtt";
import type {MqttPublishOptions} from "../mqtt";
import type TypeState from "../state";
import type TypeZigbee from "../zigbee";
import type {Zigbee2MQTTDeviceOptions, Zigbee2MQTTGroupOptions, Zigbee2MQTTSettings} from "./api";
@@ -24,6 +25,7 @@ declare global {
type StateChangeReason = "publishDebounce" | "groupOptimistic" | "lastSeenChanged" | "publishCached" | "publishThrottle";
type PublishEntityState = (entity: Device | Group, payload: KeyValue, stateChangeReason?: StateChangeReason) => Promise<void>;
type RecursivePartial<T> = {[P in keyof T]?: RecursivePartial<T[P]>};
type MakePartialExcept<T, K extends keyof T> = Partial<Omit<T, K>> & Pick<T, K>;
interface KeyValue {
// biome-ignore lint/suspicious/noExplicitAny: API
[s: string]: any;
@@ -47,9 +49,9 @@ declare global {
namespace eventdata {
type EntityRenamed = {entity: Device | Group; homeAssisantRename: boolean; from: string; to: string};
type EntityRemoved = {id: string; name: string; type: "device"} | {id: number; name: string; type: "group"};
type EntityRemoved = {entity: Device | Group; name: string};
type MQTTMessage = {topic: string; message: string};
type MQTTMessagePublished = {topic: string; payload: string; options: {retain: boolean; qos: number}};
type MQTTMessagePublished = {topic: string; payload: string; options: MqttPublishOptions};
type StateChange = {
entity: Device | Group;
from: KeyValue;
@@ -80,7 +82,7 @@ declare global {
groupID: number; // XXX: should this be `?`
cluster: string | number;
data: KeyValue | Array<string | number>;
meta: {zclTransactionSequenceNumber?: number; manufacturerCode?: number; frameControl?: ZHFrameControl};
meta: {zclTransactionSequenceNumber?: number; manufacturerCode?: number; frameControl?: ZHFrameControl; rawData: Buffer};
};
type ScenesChanged = {entity: Device | Group};
}
+5 -8
View File
@@ -1,5 +1,9 @@
declare module "zigbee2mqtt-frontend" {
export function getPath(): string;
const frontend: {
getPath: () => string;
};
export default frontend;
}
declare module "http" {
@@ -8,10 +12,3 @@ declare module "http" {
path?: string;
}
}
declare module "express-static-gzip" {
import type {IncomingMessage, ServerResponse} from "node:http";
export type RequestHandler = (req: IncomingMessage, res: ServerResponse, finalhandler: (err: unknown) => void) => void;
export default function expressStaticGzip(root: string, options?: Record<string, unknown>): RequestHandler;
}
+1
View File
@@ -18,4 +18,5 @@ function _testReload(): void {
dataPath = setPath();
}
// biome-ignore lint/style/useNamingConvention: test
export default {joinPath, getPath, _testReload};
+53 -27
View File
@@ -1,11 +1,10 @@
import {existsSync, mkdirSync} from "node:fs";
import {createServer} from "node:http";
import {parse} from "node:querystring";
import {findAllDevices} from "zigbee-herdsman/dist/adapter/adapterDiscovery";
import data from "./data";
import * as settings from "./settings";
import {YAMLFileException} from "./yaml";
type OnboardSettings = {
mqtt_base_topic?: string;
@@ -396,8 +395,6 @@ async function startOnboardingServer(): Promise<boolean> {
req.on("end", () => {
const result = parse(body) as unknown as OnboardSettings;
console.log(JSON.stringify(currentSettings));
console.log(JSON.stringify(result));
const frontendEnabled = result.frontend_enabled === "on";
const updatedSettings: RecursivePartial<Settings> = {
mqtt: {
@@ -500,7 +497,7 @@ async function startFailureServer(errors: string): Promise<void> {
let server: ReturnType<typeof createServer> | undefined;
await new Promise<void>((resolve) => {
server = createServer(async (req, res) => {
server = createServer((req, res) => {
if (req.method === "POST") {
res.end(() => {
resolve();
@@ -520,6 +517,27 @@ async function startFailureServer(errors: string): Promise<void> {
await new Promise((resolve) => server?.close(resolve));
}
async function onSettingsErrors(errors: string[]): Promise<void> {
let pErrors = "";
console.error("\n\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
console.error(" READ THIS CAREFULLY\n");
console.error("Refusing to start because configuration is not valid, found the following errors:");
for (const error of errors) {
console.error(`- ${error}`);
pErrors += `<p>- ${escapeHtml(error)}</p>`;
}
console.error("\nIf you don't know how to solve this, read https://www.zigbee2mqtt.io/guide/configuration");
console.error("\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n\n");
if (!process.env.Z2M_ONBOARD_NO_SERVER && !process.env.Z2M_ONBOARD_NO_FAILURE_PAGE) {
await startFailureServer(pErrors);
}
}
export async function onboard(): Promise<boolean> {
if (!existsSync(data.getPath())) {
mkdirSync(data.getPath(), {recursive: true});
@@ -527,20 +545,45 @@ export async function onboard(): Promise<boolean> {
const confExists = existsSync(data.joinPath("configuration.yaml"));
if (!confExists) {
settings.writeMinimalDefaults();
} else {
if (confExists) {
// initial caching, ensure file is valid yaml first
try {
settings.getPersistedSettings();
} catch (error) {
await onSettingsErrors(
error instanceof YAMLFileException
? [`Your configuration file: '${error.file}' is invalid (use https://jsonformatter.org/yaml-validator to find and fix the issue)`]
: [`${error}`],
);
return false;
}
// migrate first
const {migrateIfNecessary} = await import("./settingsMigration.js");
migrateIfNecessary();
// make sure existing settings are valid before applying envs
const errors = settings.validateNonRequired();
if (errors.length > 0) {
await onSettingsErrors(errors);
return false;
}
// trigger initial writing of `ZIGBEE2MQTT_CONFIG_*` ENVs
settings.write();
} else {
settings.writeMinimalDefaults();
}
// use `configuration.yaml` file to detect "brand new install"
// env allows to re-run onboard even with existing install
if (!process.env.Z2M_ONBOARD_NO_SERVER && (process.env.Z2M_ONBOARD_FORCE_RUN || !confExists)) {
if (!process.env.Z2M_ONBOARD_NO_SERVER && (process.env.Z2M_ONBOARD_FORCE_RUN || !confExists || settings.get().onboarding)) {
settings.setOnboarding(true);
const success = await startOnboardingServer();
if (!success) {
@@ -553,24 +596,7 @@ export async function onboard(): Promise<boolean> {
const errors = settings.validate();
if (errors.length > 0) {
let pErrors = "";
console.error("\n\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
console.error(" READ THIS CAREFULLY\n");
console.error("Refusing to start because configuration is not valid, found the following errors:");
for (const error of errors) {
console.error(`- ${error}`);
pErrors += `<p>- ${escapeHtml(error)}</p>`;
}
console.error("\nIf you don't know how to solve this, read https://www.zigbee2mqtt.io/guide/configuration");
console.error("\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n\n");
if (!process.env.Z2M_ONBOARD_NO_SERVER && !process.env.Z2M_ONBOARD_NO_FAILURE_PAGE) {
await startFailureServer(pErrors);
}
await onSettingsErrors(errors);
return false;
}
+1 -2
View File
@@ -1,6 +1,5 @@
import type {UnixDgramSocket} from "unix-dgram";
import {platform} from "node:os";
import type {UnixDgramSocket} from "unix-dgram";
import logger from "./logger";
+93 -25
View File
@@ -67,6 +67,7 @@
"title": "Timeout",
"requiresRestart": true,
"default": 10,
"minimum": 1,
"description": "Time after which an active device will be marked as offline in minutes"
},
"max_jitter": {
@@ -78,7 +79,7 @@
},
"backoff": {
"type": "boolean",
"title": "Enabled",
"title": "Backoff",
"description": "Enable timeout backoff on failed availability pings (x1.5, x3, x6, x12...)",
"default": true
},
@@ -103,6 +104,7 @@
"title": "Timeout",
"requiresRestart": true,
"default": 1500,
"minimum": 1,
"description": "Time after which an passive device will be marked as offline in minutes"
}
},
@@ -238,7 +240,7 @@
"default": false
},
"adapter": {
"type": ["string"],
"type": "string",
"enum": ["deconz", "zstack", "zigate", "ezsp", "ember", "zboss", "zoh"],
"title": "Adapter",
"requiresRestart": true,
@@ -248,8 +250,8 @@
"type": "number",
"title": "Baudrate",
"requiresRestart": true,
"description": "Baud rate speed for serial port, this can be anything firmware support but default is 115200 for Z-Stack and EZSP, 38400 for Deconz, however note that some EZSP firmware need 57600",
"examples": [38400, 57600, 115200]
"description": "Baud rate speed for the serial port. This must match what the firmware on your adapter supports (most commonly 115200).",
"examples": [115200, 921600, 460800, 230400, 57600, 38400]
},
"rtscts": {
"type": "boolean",
@@ -291,13 +293,16 @@
"type": "object",
"properties": {
"enddevice": {
"type": "string"
"type": "string",
"default": "#fff8ce"
},
"coordinator": {
"type": "string"
"type": "string",
"default": "#e04e5d"
},
"router": {
"type": "string"
"type": "string",
"default": "#4ea3e0"
}
}
},
@@ -305,13 +310,16 @@
"type": "object",
"properties": {
"enddevice": {
"type": "string"
"type": "string",
"default": "#000000"
},
"coordinator": {
"type": "string"
"type": "string",
"default": "#ffffff"
},
"router": {
"type": "string"
"type": "string",
"default": "#ffffff"
}
}
},
@@ -319,10 +327,12 @@
"type": "object",
"properties": {
"active": {
"type": "string"
"type": "string",
"default": "#009900"
},
"inactive": {
"type": "string"
"type": "string",
"default": "#994444"
}
}
}
@@ -340,7 +350,8 @@
"type": "number",
"title": "Update check interval",
"description": "Your device may request a check for a new firmware update. This value determines how frequently third party servers may actually be contacted to look for firmware updates. The value is set in minutes, and the default is 1 day.",
"default": 1440
"default": 1440,
"minimum": 1
},
"disable_automatic_update_check": {
"type": "boolean",
@@ -385,6 +396,14 @@
"default": false,
"requiresRestart": true
},
"package": {
"type": "string",
"enum": ["zigbee2mqtt-frontend", "zigbee2mqtt-windfront"],
"title": "Package",
"default": "zigbee2mqtt-frontend",
"requiresRestart": true,
"description": "Package used for the frontend"
},
"port": {
"type": "number",
"title": "Port",
@@ -438,6 +457,12 @@
"items": {
"type": "string"
}
},
"disable_ui_serving": {
"type": ["boolean", "null"],
"title": "Disable UI serving",
"description": "If true, the frontend UI is not served, only the WebSocket is maintained by Zigbee2MQTT (you are required to serve a standalone UI yourself as needed).",
"requiresRestart": true
}
},
"required": []
@@ -501,7 +526,8 @@
"enum": ["console", "file", "syslog"]
},
"title": "Log output",
"description": "Output location of the log, leave empty to suppress logging"
"description": "Output location of the log, leave empty to suppress logging",
"default": ["console", "file"]
},
"log_directory": {
"type": "string",
@@ -516,7 +542,7 @@
"requiresRestart": true,
"description": "Log file name, can also contain timestamp",
"examples": ["zigbee2mqtt_%TIMESTAMP%.log"],
"default": "log.txt"
"default": "log.log"
},
"log_level": {
"type": "string",
@@ -537,7 +563,14 @@
"title": "Log Namespaced Levels",
"description": "Set individual log levels for certain namespaces",
"default": {},
"examples": [{ "z2m:mqtt": "warning" }, { "zh:ember:uart:ash": "info" }]
"examples": [
{
"z2m:mqtt": "warning"
},
{
"zh:ember:uart:ash": "info"
}
]
},
"log_debug_to_mqtt_frontend": {
"type": "boolean",
@@ -640,12 +673,15 @@
},
{
"type": "number",
"title": "Pan ID (number)"
"title": "Pan ID (number)",
"minimum": 1,
"maximum": 65534
}
],
"title": "Pan ID",
"requiresRestart": true,
"description": "ZigBee pan ID, changing requires re-pairing all devices!"
"description": "ZigBee pan ID, changing requires re-pairing all devices!",
"default": 6754
},
"ext_pan_id": {
"oneOf": [
@@ -663,7 +699,8 @@
],
"title": "Ext Pan ID",
"requiresRestart": true,
"description": "Zigbee extended pan ID, changing requires re-pairing all devices!"
"description": "Zigbee extended pan ID, changing requires re-pairing all devices!",
"default": [221, 221, 221, 221, 221, 221, 221, 221]
},
"channel": {
"type": "number",
@@ -681,6 +718,7 @@
"type": ["number", "null"],
"minimum": 1,
"maximum": 64,
"default": null,
"description": "Adapter concurrency (e.g. 2 for CC2531 or 16 for CC26X2R1) (default: null, uses recommended value)"
},
"adapter_delay": {
@@ -689,6 +727,7 @@
"title": "Adapter delay",
"minimum": 0,
"maximum": 1000,
"default": null,
"description": "Adapter delay"
},
"cache_state": {
@@ -738,14 +777,16 @@
],
"title": "Network key",
"requiresRestart": true,
"description": "Network encryption key, changing requires re-pairing all devices!"
"description": "Network encryption key, changing requires re-pairing all devices!",
"default": [1, 3, 5, 7, 9, 11, 13, 15, 0, 2, 4, 6, 8, 10, 12, 13]
},
"timestamp_format": {
"type": "string",
"title": "Timestamp format",
"requiresRestart": true,
"description": "Log timestamp format",
"examples": ["YYYY-MM-DD HH:mm:ss"]
"default": "YYYY-MM-DD HH:mm:ss",
"examples": ["YYYY-MM-DD HH:mm:ss.SSS"]
},
"transmit_power": {
"type": ["number", "null"],
@@ -759,9 +800,32 @@
"type": "string",
"enum": ["attribute_and_json", "attribute", "json"],
"title": "MQTT output type",
"description": "Examples when 'state' of a device is published json: topic: 'zigbee2mqtt/my_bulb' payload '{\"state\": \"ON\"}' attribute: topic 'zigbee2mqtt/my_bulb/state' payload 'ON' attribute_and_json: both json and attribute (see above)"
"description": "Examples when 'state' of a device is published json: topic: 'zigbee2mqtt/my_bulb' payload '{\"state\": \"ON\"}' attribute: topic 'zigbee2mqtt/my_bulb/state' payload 'ON' attribute_and_json: both json and attribute (see above)",
"default": "json"
}
}
},
"health": {
"title": "Health",
"description": "Periodically check the health of Zigbee2MQTT",
"type": "object",
"properties": {
"interval": {
"type": "number",
"title": "Interval",
"description": "Interval between checks in minutes",
"default": 10,
"minimum": 1,
"requiresRestart": true
},
"reset_on_check": {
"type": "boolean",
"title": "Reset on check",
"description": "If true, will reset stats every time the health check is executed (only applicable to stats that can be reset).",
"default": false
}
},
"required": []
}
},
"required": ["mqtt"],
@@ -792,7 +856,8 @@
"description": "Sets the MQTT Message Expiry in seconds, Make sure to set mqtt.version to 5"
},
"qos": {
"type": "number",
"type": ["number"],
"enum": [0, 1, 2],
"title": "QoS",
"description": "QoS level for MQTT messages of this device"
},
@@ -882,13 +947,16 @@
"type": "boolean"
},
"qos": {
"type": "number"
"type": ["number"],
"enum": [0, 1, 2],
"title": "QoS",
"description": "QoS level for MQTT messages of this group"
},
"off_state": {
"type": ["string"],
"enum": ["all_members_off", "last_member_state"],
"title": "Group off state",
"default": "auto",
"default": "all_members_off",
"requiresRestart": true,
"description": "Control when to publish state OFF or CLOSE for a group. 'all_members_off': only publish state OFF/CLOSE when all group members are in state OFF/CLOSE, 'last_member_state': publish state OFF whenever one of its members changes to OFF"
},
+45 -20
View File
@@ -1,6 +1,5 @@
import type {ValidateFunction} from "ajv";
import path from "node:path";
import type {ValidateFunction} from "ajv";
import Ajv from "ajv";
import objectAssignDeep from "object-assign-deep";
@@ -8,7 +7,7 @@ import objectAssignDeep from "object-assign-deep";
import data from "./data";
import schemaJson from "./settings.schema.json";
import utils from "./utils";
import yaml, {YAMLFileException} from "./yaml";
import yaml from "./yaml";
export {schemaJson};
// When updating also update:
@@ -43,6 +42,7 @@ export const defaults = {
},
frontend: {
enabled: false,
package: "zigbee2mqtt-frontend",
port: 8080,
base_url: "/",
},
@@ -52,6 +52,9 @@ export const defaults = {
force_disable_retain: false,
// 1MB = roughly 3.5KB per device * 300 devices for `/bridge/devices`
maximum_packet_size: 1048576,
keepalive: 60,
reject_unauthorized: true,
version: 4,
},
serial: {
disable_led: false,
@@ -112,6 +115,10 @@ export const defaults = {
timestamp_format: "YYYY-MM-DD HH:mm:ss",
output: "json",
},
health: {
interval: 10,
reset_on_check: false,
},
} satisfies RecursivePartial<Settings>;
let _settings: Partial<Settings> | undefined;
@@ -179,6 +186,22 @@ export function writeMinimalDefaults(): void {
loadSettingsWithDefaults();
}
export function setOnboarding(value: boolean): void {
const settings = getPersistedSettings();
if (value) {
if (!settings.onboarding) {
settings.onboarding = value;
write();
}
} else if (settings.onboarding) {
delete settings.onboarding;
write();
}
}
export function write(): void {
const settings = getPersistedSettings();
const toWrite: KeyValue = objectAssignDeep({}, settings);
@@ -237,15 +260,7 @@ export function write(): void {
}
export function validate(): string[] {
try {
getPersistedSettings();
} catch (error) {
if (error instanceof YAMLFileException) {
return [`Your YAML file: '${error.file}' is invalid (use https://jsonformatter.org/yaml-validator to find and fix the issue)`];
}
return [`${error}`];
}
getPersistedSettings();
if (!ajvSetting(_settings)) {
// biome-ignore lint/style/noNonNullAssertion: When `ajvSetting()` return false it always has `errors`
@@ -275,9 +290,6 @@ export function validate(): string[] {
if ("icon" in e && e.icon && !e.icon.startsWith("http://") && !e.icon.startsWith("https://") && !e.icon.startsWith("device_icons/")) {
errors.push(`Device icon of '${e.friendly_name}' should start with 'device_icons/', got '${e.icon}'`);
}
if (e.qos != null && ![0, 1, 2].includes(e.qos)) {
errors.push(`QOS for '${e.friendly_name}' not valid, should be 0, 1 or 2 got ${e.qos}`);
}
};
const settingsWithDefaults = get();
@@ -301,6 +313,19 @@ export function validate(): string[] {
return errors;
}
export function validateNonRequired(): string[] {
getPersistedSettings();
if (!ajvSetting(_settings)) {
// biome-ignore lint/style/noNonNullAssertion: When `ajvSetting()` return false it always has `errors`
const errors = ajvSetting.errors!.filter((e) => e.keyword !== "required");
return errors.map((v) => `${v.instancePath.substring(1)} ${v.message}`);
}
return [];
}
function read(): Partial<Settings> {
const s = yaml.read(CONFIG_FILE_PATH) as Partial<Settings>;
@@ -458,12 +483,12 @@ export function apply(settings: Record<string, unknown>, throwOnError = true): b
const newSettings = objectAssignDeep.noMutate(_settings, settings);
utils.removeNullPropertiesFromObject(newSettings, NULLABLE_SETTINGS);
ajvSetting(newSettings);
if (throwOnError) {
const errors = ajvSetting.errors?.filter((e) => e.keyword !== "required");
if (!ajvSetting(newSettings) && throwOnError) {
// biome-ignore lint/style/noNonNullAssertion: When `ajvSetting()` return false it always has `errors`
const errors = ajvSetting.errors!.filter((e) => e.keyword !== "required");
if (errors?.length) {
if (errors.length) {
const error = errors[0];
throw new Error(`${error.instancePath.substring(1)} ${error.message}`);
}
@@ -579,7 +604,7 @@ export function addGroup(name: string, id?: string): GroupOptions {
settings.groups = {};
}
if (id == null) {
if (id == null || (typeof id === "string" && id.trim() === "")) {
// look for free ID
id = "1";
+25 -14
View File
@@ -1,20 +1,19 @@
import type * as zhc from "zigbee-herdsman-converters";
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponse, Zigbee2MQTTResponseEndpoints, Zigbee2MQTTScene} from "../types/api";
import assert from "node:assert";
import {exec} from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import equals from "fast-deep-equal/es6";
import humanizeDuration from "humanize-duration";
import type * as zhc from "zigbee-herdsman-converters";
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponse, Zigbee2MQTTResponseEndpoints, Zigbee2MQTTScene} from "../types/api";
import data from "./data";
const BASE64_IMAGE_REGEX = /data:image\/(?<extension>.+);base64,(?<data>.+)/;
export const DEFAULT_BIND_GROUP_ID = 901;
function pad(num: number): string {
const norm = Math.floor(Math.abs(num));
return (norm < 10 ? "0" : "") + norm;
@@ -87,7 +86,6 @@ function formatDate(time: number, type: "ISO_8601" | "ISO_8601_local" | "epoch"
function objectIsEmpty(object: object): boolean {
// much faster than checking `Object.keys(object).length`
// biome-ignore lint/style/useNamingConvention: bad detection
for (const _k in object) return false;
return true;
}
@@ -204,14 +202,21 @@ function containsControlCharacter(str: string): boolean {
function getAllFiles(path_: string): string[] {
const result = [];
for (let item of fs.readdirSync(path_)) {
item = path.join(path_, item);
if (fs.lstatSync(item).isFile()) {
result.push(item);
for (const item of fs.readdirSync(path_, {withFileTypes: true})) {
if (item.isSymbolicLink()) {
continue;
}
const fileName = path.join(path_, item.name);
if (fs.lstatSync(fileName).isFile()) {
result.push(fileName);
} else {
result.push(...getAllFiles(item));
result.push(...getAllFiles(fileName));
}
}
return result;
}
@@ -279,9 +284,9 @@ function isZHGroup(obj: unknown): obj is zh.Group {
return obj?.constructor.name.toLowerCase() === "group";
}
const hours = (hours: number): number => 1000 * 60 * 60 * hours;
const minutes = (minutes: number): number => 1000 * 60 * minutes;
const seconds = (seconds: number): number => 1000 * seconds;
export const hours = (hours: number): number => 1000 * 60 * 60 * hours;
export const minutes = (minutes: number): number => 1000 * 60 * minutes;
export const seconds = (seconds: number): number => 1000 * seconds;
async function publishLastSeen(
data: eventdata.LastSeenChanged,
@@ -340,6 +345,12 @@ export function isLightExpose(expose: zhc.Expose): expose is zhc.Light {
return expose.type === "light";
}
export function assertString(value: unknown, property: string): asserts value is string {
if (typeof value !== "string") {
throw new Error(`${property} is not a string, got ${typeof value} (${value})`);
}
}
function getScenes(entity: zh.Endpoint | zh.Group): Zigbee2MQTTScene[] {
const scenes: {[id: number]: Zigbee2MQTTScene} = {};
const endpoints = isZHEndpoint(entity) ? [entity] : entity.members;
+1 -1
View File
@@ -21,7 +21,7 @@ export class YAMLFileException extends YAMLException {
function read(file: string): KeyValue {
try {
const result = yaml.load(fs.readFileSync(file, "utf8"));
assert(result instanceof Object);
assert(result instanceof Object, `The content of ${file} is expected to be an object`);
return result as KeyValue;
} catch (error) {
if (error instanceof YAMLException) {
+19 -13
View File
@@ -1,12 +1,9 @@
import type {Events as ZHEvents} from "zigbee-herdsman";
import type {StartResult} from "zigbee-herdsman/dist/adapter/tstype";
import {randomInt} from "node:crypto";
import bind from "bind-decorator";
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";
@@ -18,11 +15,11 @@ import utils from "./util/utils";
const entityIDRegex = /^(.+?)(?:\/([^/]+))?$/;
export default class Zigbee {
// @ts-expect-error initialized in start
private herdsman: Controller;
private herdsman!: Controller;
private eventBus: EventBus;
private groupLookup = new Map<number /* group ID */, Group>();
private deviceLookup = new Map<string /* IEEE address */, Device>();
private coordinatorIeeeAddr!: string;
constructor(eventBus: EventBus) {
this.eventBus = eventBus;
@@ -73,6 +70,7 @@ export default class Zigbee {
throw error;
}
this.coordinatorIeeeAddr = this.herdsman.getDevicesByType("Coordinator")[0].ieeeAddr;
await this.resolveDevicesDefinitions();
this.herdsman.on("adapterDisconnected", () => this.eventBus.emitAdapterDisconnected());
@@ -267,12 +265,20 @@ export default class Zigbee {
}
private resolveGroup(groupID: number): Group | undefined {
const group = this.herdsman.getGroupByID(Number(groupID));
if (group && !this.groupLookup.has(groupID)) {
this.groupLookup.set(groupID, new Group(group, this.resolveDevice));
if (!this.groupLookup.has(groupID)) {
const group = this.herdsman.getGroupByID(groupID);
if (group) {
this.groupLookup.set(groupID, new Group(group, this.resolveDevice));
}
}
return this.groupLookup.get(groupID);
const group = this.groupLookup.get(groupID);
if (group) {
group.ensureInSettings();
return group;
}
}
resolveEntity(key: string | number | zh.Device): Device | Group | undefined {
@@ -280,8 +286,8 @@ export default class Zigbee {
return this.resolveDevice(key.ieeeAddr);
}
if (typeof key === "string" && key.toLowerCase() === "coordinator") {
return this.resolveDevice(this.herdsman.getDevicesByType("Coordinator")[0].ieeeAddr);
if (typeof key === "string" && (key.toLowerCase() === "coordinator" || key === this.coordinatorIeeeAddr)) {
return this.resolveDevice(this.coordinatorIeeeAddr);
}
const settingsDevice = settings.getDevice(key.toString());
+19 -16
View File
@@ -1,16 +1,16 @@
{
"name": "zigbee2mqtt",
"version": "2.3.0",
"version": "2.6.1",
"description": "Zigbee to MQTT bridge using Zigbee-herdsman",
"main": "index.js",
"types": "dist/types/api.d.ts",
"packageManager": "pnpm@10.4.1",
"packageManager": "pnpm@10.12.1",
"repository": {
"type": "git",
"url": "git+https://github.com/Koenkk/zigbee2mqtt.git"
},
"engines": {
"node": "^18 || ^20 || ^22 || ^23"
"node": "^20 || ^22 || ^24"
},
"keywords": [
"xiaomi",
@@ -23,13 +23,15 @@
],
"scripts": {
"build": "tsc && node index.js writehash",
"build:types": "pnpm run clean && tsc --project tsconfig.types.json && cp lib/util/settings.schema.json dist/util",
"build:watch": "tsc --watch",
"check": "biome check",
"check": "biome check --error-on-warnings",
"check:w": "biome check --write",
"start": "node index.js",
"test": "vitest run --config ./test/vitest.config.mts",
"test:coverage": "vitest run --config ./test/vitest.config.mts --coverage",
"test:watch": "vitest watch --config ./test/vitest.config.mts",
"bench": "vitest bench --run --config ./test/vitest.config.mts",
"prepack": "pnpm run clean && pnpm run build",
"clean": "rimraf coverage dist tsconfig.tsbuildinfo"
},
@@ -43,7 +45,7 @@
"ajv": "^8.17.1",
"bind-decorator": "^1.0.11",
"debounce": "^2.2.0",
"express-static-gzip": "^2.2.0",
"express-static-gzip": "^3.0.0",
"fast-deep-equal": "^3.1.3",
"finalhandler": "^2.1.0",
"humanize-duration": "^3.32.1",
@@ -51,7 +53,7 @@
"json-stable-stringify-without-jsonify": "^1.0.1",
"jszip": "^3.10.1",
"moment": "^2.30.1",
"mqtt": "^5.11.0",
"mqtt": "^5.14.0",
"object-assign-deep": "^0.4.0",
"rimraf": "^6.0.1",
"semver": "^7.7.1",
@@ -61,23 +63,24 @@
"winston-syslog": "^2.7.1",
"winston-transport": "^4.9.0",
"ws": "^8.18.1",
"zigbee-herdsman": "4.0.0",
"zigbee-herdsman-converters": "23.36.0",
"zigbee2mqtt-frontend": "0.9.10"
"zigbee-herdsman": "6.0.4",
"zigbee-herdsman-converters": "25.14.0",
"zigbee2mqtt-frontend": "0.9.20",
"zigbee2mqtt-windfront": "2.0.1"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@biomejs/biome": "^2.2.2",
"@types/finalhandler": "^1.2.3",
"@types/humanize-duration": "^3.27.4",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.14.1",
"@types/node": "^24.3.0",
"@types/object-assign-deep": "^0.4.3",
"@types/readable-stream": "4.0.18",
"@types/readable-stream": "4.0.21",
"@types/serve-static": "^1.15.7",
"@types/ws": "8.18.1",
"@vitest/coverage-v8": "^3.1.1",
"tmp": "^0.2.3",
"typescript": "^5.8.3",
"tmp": "^0.2.5",
"typescript": "^5.9.2",
"vitest": "^3.1.1"
},
"pnpm": {
@@ -95,6 +98,6 @@
"zigbee2mqtt": "cli.js"
},
"optionalDependencies": {
"unix-dgram": "^2.0.6"
"unix-dgram": "^2.0.7"
}
}
}
+613 -544
View File
File diff suppressed because it is too large Load Diff
-157
View File
@@ -1,157 +0,0 @@
const path = require("node:path");
const fs = require("node:fs");
const process = require("node:process");
const {execSync} = require("node:child_process");
const zhc = require("zigbee-herdsman-converters");
const z2mTillVersion = process.argv[2];
const zhcTillVersion = process.argv[3];
const zhTillVersion = process.argv[4];
const frontendTillVersion = process.argv[5];
const changelogs = [
{
tillVersion: z2mTillVersion,
project: "koenkk/zigbee2mqtt",
contents: fs.readFileSync(path.join(__dirname, "..", "CHANGELOG.md"), "utf-8").split("\n"),
},
{
tillVersion: zhcTillVersion,
project: "koenkk/zigbee-herdsman-converters",
contents: fs.readFileSync(path.join(__dirname, "..", "node_modules", "zigbee-herdsman-converters", "CHANGELOG.md"), "utf-8").split("\n"),
},
{
tillVersion: zhTillVersion,
project: "koenkk/zigbee-herdsman",
contents: fs.readFileSync(path.join(__dirname, "..", "node_modules", "zigbee-herdsman", "CHANGELOG.md"), "utf-8").split("\n"),
},
{
tillVersion: frontendTillVersion,
project: "nurikk/zigbee2mqtt-frontend",
isFrontend: true,
contents: fs.readFileSync(path.join(__dirname, "..", "node_modules", "zigbee2mqtt-frontend", "CHANGELOG.md"), "utf-8").split("\n"),
},
];
const releaseRe = /## \[(.+)\]/;
const changes = {features: [], fixes: [], detect: [], add: [], error: [], frontend: []};
let context = null;
const changeRe = [
/^\* (\*\*(.+):\*\*)?(.+)\((\[#\d+\]\(.+\))\) \(\[.+\]\(https:.+\/(.+)\)\)$/,
/^\* (\*\*(.+):\*\*)?(.+)(https:\/\/github\.com.+) \(\[.+\]\(https:.+\/(.+)\)\)$/,
/^\* (\*\*(.+):\*\*)?(.+)() \(\[.+\]\(https:.+\/(.+)\)\)$/,
];
const frontendChangeRe = /^\* (\*\*.+:\*\* )()?(.+) \(\[.+\]\(https:.+\/()(.+)\)\)(.+)?$/;
let commitUserLookup = {};
const commitUserFile = path.join(__dirname, "commit-user-lookup.json");
if (fs.existsSync(commitUserFile)) {
commitUserLookup = JSON.parse(fs.readFileSync(commitUserFile, "utf8"));
}
const definitions = require("zigbee-herdsman-converters/devices/index").default.map((d) => zhc.prepareDefinition(d));
const whiteLabels = definitions.filter((d) => d.whiteLabel).flatMap((d) => d.whiteLabel);
const capitalizeFirstChar = (str) => str.charAt(0).toUpperCase() + str.slice(1);
for (const changelog of changelogs) {
for (const line of changelog.contents) {
const releaseMatch = line.match(releaseRe);
const changeMatch = changelog.isFrontend ? line.match(frontendChangeRe) : changeRe.map((re) => line.match(re)).find((e) => e);
if (releaseMatch) {
if (releaseMatch[1] === changelog.tillVersion) {
break;
}
} else if (line === "### Features") {
context = "features";
} else if (line === "### Bug Fixes") {
context = "fixes";
} else if (line.startsWith("* **ignore:**")) {
// continue;
} else if (changeMatch) {
let localContext = changelog.isFrontend ? "frontend" : changeMatch[2] ? changeMatch[2] : context;
if (!changes[localContext]) localContext = "error";
const commit = changeMatch[5];
const commitUserKey = `${changelog.project}-${commit} `;
let user =
commitUserKey in commitUserLookup
? commitUserLookup[commitUserKey]
: execSync(`curl -s https://api.github.com/repos/${changelog.project}/commits/${commit} | jq -r '.author.login'`)
.toString()
.trim();
if (user !== "null") commitUserLookup[commitUserKey] = user;
const messages = [];
let message = changeMatch[3].trim();
if (message.endsWith(".")) message = message.substring(0, message.length - 1);
if (changelog.isFrontend) {
changes[localContext].push(
`- [${commit.slice(0, 7)}](https://github.com/${changelog.project}/commit/${commit}) ${message} (@${user})`,
);
messages.push(capitalizeFirstChar(message));
} else {
const otherUser = message.match(/\[@(.+)\]\(https:\/\/github.com\/.+\)/) || message.match(/@(.+)/);
if (otherUser) {
user = otherUser[1];
message = message.replace(otherUser[0], "");
}
if (localContext === "add") {
for (const model of message.split(",")) {
const definition = definitions.find((d) => d.model === model.trim());
const whiteLabel = whiteLabels.find((d) => d.model === model.trim());
const match = definition || whiteLabel;
if (match) {
messages.push(`\`${match.model}\` ${match.vendor} ${match.description}`);
} else {
changes.error.push(`${line} (model '${model}' does not exist)`);
}
}
} else {
messages.push(capitalizeFirstChar(message));
}
let issue = changeMatch[4].trim();
if (issue && !issue.startsWith("[#")) issue = `[#${issue.split("/").pop()}](${issue})`;
if (!issue) {
issue = "_NO_ISSUE_";
localContext = "error";
}
for (const message of messages) {
changes[localContext].push(`- ${issue} ${message} (@${user})`);
}
}
} else if (line === "# Changelog" || line === "### ⚠ BREAKING CHANGES" || !line) {
// continue;
} else {
changes.error.push(`- Unmatched line: ${line}`);
}
}
}
let result = "";
const names = [
["features", "Improvements"],
["fixes", "Fixes"],
["frontend", "Frontend"],
["add", "New supported devices"],
["detect", "Fixed device detections"],
["error", "Changelog generator error"],
];
for (const name of names) {
result += `# ${name[1]}\n`;
if (name[0] === "add") {
result += `This release adds support for ${changes.add.length} devices: \n`;
}
for (const change of changes[name[0]]) {
result += `${change}\n`;
}
result += "\n";
}
fs.writeFileSync(commitUserFile, JSON.stringify(commitUserLookup), "utf-8");
console.log(result.trim());
+185
View File
@@ -0,0 +1,185 @@
import {execSync} from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import zhc from "zigbee-herdsman-converters";
import definitionIndex from "zigbee-herdsman-converters/devices/index";
const z2mTillVersion = process.argv[2];
const zhcTillVersion = process.argv[3];
const zhTillVersion = process.argv[4];
const frontendTillVersion = process.argv[5];
const windfrontTillVersion = process.argv[6];
const changelogs = [
{
tillVersion: z2mTillVersion,
project: "koenkk/zigbee2mqtt",
contents: fs.readFileSync(path.join(import.meta.dirname, "..", "CHANGELOG.md"), "utf-8").split("\n"),
},
{
tillVersion: zhcTillVersion,
project: "koenkk/zigbee-herdsman-converters",
contents: fs
.readFileSync(path.join(import.meta.dirname, "..", "node_modules", "zigbee-herdsman-converters", "CHANGELOG.md"), "utf-8")
.split("\n"),
},
{
tillVersion: zhTillVersion,
project: "koenkk/zigbee-herdsman",
contents: fs.readFileSync(path.join(import.meta.dirname, "..", "node_modules", "zigbee-herdsman", "CHANGELOG.md"), "utf-8").split("\n"),
},
{
tillVersion: frontendTillVersion,
project: "nurikk/zigbee2mqtt-frontend",
isFrontend: true,
contents: fs.readFileSync(path.join(import.meta.dirname, "..", "node_modules", "zigbee2mqtt-frontend", "CHANGELOG.md"), "utf-8").split("\n"),
},
{
tillVersion: windfrontTillVersion,
project: "Nerivec/zigbee2mqtt-windfront",
},
];
const releaseRe = /## \[(.+)\]/;
const windfrontChangeRe = /^\* (feat|fix): (.+?)(?: by @([^\s]+) in (https:\/\/github\.com\/Nerivec\/zigbee2mqtt-windfront\/pull\/(\d+)))?$/gm;
const changes = {features: [], fixes: [], detect: [], add: [], error: [], frontend: [], windfront: []};
let context = null;
const changeRe = [
/^\* (\*\*(.+):\*\*)?(.+)\((\[#\d+\]\(.+\))\) \(\[.+\]\(https:.+\/(.+)\)\)$/,
/^\* (\*\*(.+):\*\*)?(.+)(https:\/\/github\.com.+) \(\[.+\]\(https:.+\/(.+)\)\)$/,
/^\* (\*\*(.+):\*\*)?(.+)() \(\[.+\]\(https:.+\/(.+)\)\)$/,
];
const frontendChangeRe = /^\* (\*\*.+:\*\* )()?(.+) \(\[.+\]\(https:.+\/()(.+)\)\)(.+)?$/;
let commitUserLookup = {};
const commitUserFile = path.join(import.meta.dirname, "commit-user-lookup.json");
if (fs.existsSync(commitUserFile)) {
commitUserLookup = JSON.parse(fs.readFileSync(commitUserFile, "utf8"));
}
const definitions = definitionIndex.default.map((d) => zhc.prepareDefinition(d));
const whiteLabels = definitions.filter((d) => d.whiteLabel).flatMap((d) => d.whiteLabel);
const capitalizeFirstChar = (str) => str.charAt(0).toUpperCase() + str.slice(1);
for (const changelog of changelogs) {
if (changelog.project === "Nerivec/zigbee2mqtt-windfront") {
const releaseRsp = await fetch("https://api.github.com/repos/Nerivec/zigbee2mqtt-windfront/releases");
const releases = await releaseRsp.json();
for (const release of releases) {
if (release.name === `v${windfrontTillVersion}`) {
break;
}
let match = windfrontChangeRe.exec(release.body);
while (match !== null) {
const [, type, message, user, prLink, prId] = match;
const entry = `- ${prId && prLink ? `[#${prId}](${prLink}) ` : ""}${type}: ${message} ${user ? `(@${user.split("[")[0]})` : ""}`;
changes.windfront.push(entry);
match = windfrontChangeRe.exec(release.body);
}
}
} else {
for (const line of changelog.contents) {
const releaseMatch = line.match(releaseRe);
const changeMatch = changelog.isFrontend ? line.match(frontendChangeRe) : changeRe.map((re) => line.match(re)).find((e) => e);
if (releaseMatch) {
if (releaseMatch[1] === changelog.tillVersion) {
break;
}
} else if (line === "### Features") {
context = "features";
} else if (line === "### Bug Fixes") {
context = "fixes";
} else if (line.startsWith("* **ignore:**")) {
// continue;
} else if (changeMatch) {
let localContext = changelog.isFrontend ? "frontend" : changeMatch[2] ? changeMatch[2] : context;
if (!changes[localContext]) localContext = "error";
const commit = changeMatch[5];
const commitUserKey = `${changelog.project}-${commit} `;
let user =
commitUserKey in commitUserLookup
? commitUserLookup[commitUserKey]
: execSync(`curl -s https://api.github.com/repos/${changelog.project}/commits/${commit} | jq -r '.author.login'`)
.toString()
.trim();
if (user !== "null") commitUserLookup[commitUserKey] = user;
const messages = [];
let message = changeMatch[3].trim();
if (message.endsWith(".")) message = message.substring(0, message.length - 1);
if (changelog.isFrontend) {
changes[localContext].push(
`- [${commit.slice(0, 7)}](https://github.com/${changelog.project}/commit/${commit}) ${message} (@${user})`,
);
messages.push(capitalizeFirstChar(message));
} else {
const otherUser = message.match(/\[@(.+)\]\(https:\/\/github.com\/.+\)/) || message.match(/@(.+)/);
if (otherUser) {
user = otherUser[1];
message = message.replace(otherUser[0], "");
}
if (localContext === "add") {
for (const model of message.split(",")) {
const definition = definitions.find((d) => d.model === model.trim());
const whiteLabel = whiteLabels.find((d) => d.model === model.trim());
const match = definition || whiteLabel;
if (match) {
messages.push(`\`${match.model}\` ${match.vendor} ${match.description}`);
} else {
changes.error.push(`${line} (model '${model}' does not exist)`);
}
}
} else {
messages.push(capitalizeFirstChar(message));
}
let issue = changeMatch[4].trim();
if (issue && !issue.startsWith("[#")) issue = `[#${issue.split("/").pop()}](${issue})`;
if (!issue) {
issue = "_NO_ISSUE_";
localContext = "error";
}
for (const message of messages) {
changes[localContext].push(`- ${issue} ${message} (@${user})`);
}
}
} else if (line === "# Changelog" || line === "### ⚠ BREAKING CHANGES" || !line) {
// continue;
} else {
changes.error.push(`- Unmatched line: ${line}`);
}
}
}
}
let result = "";
const names = [
["features", "Improvements"],
["fixes", "Fixes"],
["windfront", "Windfront"],
["frontend", "Frontend"],
["add", "New supported devices"],
["detect", "Fixed device detections"],
["error", "Changelog generator error"],
];
for (const name of names) {
result += `# ${name[1]}\n`;
if (name[0] === "add") {
result += `This release adds support for ${changes.add.length} devices: \n`;
}
for (const change of changes[name[0]]) {
result += `${change}\n`;
}
result += "\n";
}
fs.writeFileSync(commitUserFile, JSON.stringify(commitUserLookup), "utf-8");
console.log(result.trim());
+3 -3
View File
@@ -39,13 +39,13 @@ class ZStackNvMemEraser {
async clearAllNvMemItems() {
let maxNvMemId;
switch (this.version.product) {
case ZnpVersion.zStack12:
case ZnpVersion.ZStack12:
maxNvMemId = 0x0302;
break;
case ZnpVersion.zStack30x:
case ZnpVersion.ZStack30x:
maxNvMemId = 0x033f;
break;
case ZnpVersion.zStack3x0:
case ZnpVersion.ZStack3x0:
maxNvMemId = 0x032f;
break;
}
+14
View File
@@ -0,0 +1,14 @@
import {hrtime} from "node:process";
import type {bench} from "vitest";
export const BENCH_OPTIONS: NonNullable<Parameters<typeof bench>[2]> = {
throws: true,
warmupTime: 1000,
now: () => Number(hrtime.bigint()) / 1e6,
setup: (_task, mode) => {
// Run the garbage collector before warmup at each cycle
if (mode === "warmup" && typeof globalThis.gc === "function") {
globalThis.gc();
}
},
};
+659
View File
@@ -0,0 +1,659 @@
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 type Adapter from "zigbee-herdsman/dist/adapter/adapter";
import type {ZclPayload} from "zigbee-herdsman/dist/adapter/events";
import {Device, InterviewState} from "zigbee-herdsman/dist/controller/model/device";
import {Endpoint} from "zigbee-herdsman/dist/controller/model/endpoint";
import Entity from "zigbee-herdsman/dist/controller/model/entity";
import {Group} from "zigbee-herdsman/dist/controller/model/group";
import type {DeviceType} from "zigbee-herdsman/dist/controller/tstype";
import {Foundation} from "zigbee-herdsman/dist/zspec/zcl/definition/foundation";
import type {RequestToResponseMap} from "zigbee-herdsman/dist/zspec/zdo/definition/tstypes";
import data from "../lib/util/data";
import {BENCH_OPTIONS} from "./benchOptions";
process.env.ZIGBEE2MQTT_DATA = "data-bench";
data._testReload();
if (!existsSync(data.getPath())) {
mkdirSync(data.getPath(), {recursive: true});
}
const createEndpoint = (id: number, ieeeAddr: string, networkAddress: number) => {
const ep = Endpoint.create(
id,
id === ZSpec.GP_ENDPOINT ? ZSpec.GP_PROFILE_ID : ZSpec.HA_PROFILE_ID,
id === ZSpec.GP_ENDPOINT ? 0x66 : 0x65,
id === ZSpec.GP_ENDPOINT ? [Zcl.Clusters.greenPower.ID] : [Zcl.Clusters.genBasic.ID, Zcl.Clusters.genOnOff.ID],
id === ZSpec.GP_ENDPOINT ? [Zcl.Clusters.greenPower.ID] : [Zcl.Clusters.genBasic.ID],
networkAddress,
ieeeAddr,
);
ep.save = () => {};
return ep;
};
// `Device.create` requires `Database`, bypass it by using the private constructor directly
const createDevice = (
dbId: number,
type: DeviceType,
ieeeAddr: string,
networkAddress: number,
manufacturerID: number | undefined,
manufacturerName: string | undefined,
powerSource: string | undefined,
modelID: string | undefined,
): Device => {
const haEp = createEndpoint(ZSpec.HA_ENDPOINT, ieeeAddr, networkAddress);
const ep2 = createEndpoint(2, ieeeAddr, networkAddress);
const endpoints = [haEp, ep2];
if (type === "Coordinator" || type === "Router") {
const gpEp = createEndpoint(ZSpec.GP_ENDPOINT, ieeeAddr, networkAddress);
endpoints.push(gpEp);
}
// @ts-expect-error mocking private
const device = new Device(
dbId,
type,
ieeeAddr,
networkAddress,
manufacturerID,
endpoints,
manufacturerName,
powerSource,
modelID,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
InterviewState.Successful,
{},
undefined,
undefined,
0,
undefined,
);
// in-memory only
device.save = () => {};
return device;
};
// `Group.create` requires `Database`, bypass it by using the private constructor directly
const createGroup = (dbId: number, id: number): Group => {
// @ts-expect-error mocking private
const group = new Group(dbId, id, [], {});
// in-memory only
group.save = () => {};
return group;
};
const COORD_IEEE = "0x0101010101010101";
const EXT_PAN_ID = [0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd];
const NETWORK_PARAMS = {
panID: 0x1a62,
extendedPanID: `0x${Buffer.from(EXT_PAN_ID).toString("hex")}`,
channel: 11,
nwkUpdateID: 1,
};
const NETWORK_KEY = [1, 3, 5, 7, 9, 11, 13, 15, 0, 2, 4, 6, 8, 10, 12, 13];
const ZH_DEVICES: Device[] = [];
const ZH_GROUPS: Group[] = [];
const MANY_DEVICES = 100;
const initDevices = () => {
ZH_DEVICES.splice(0, ZH_DEVICES.length);
ZH_DEVICES.push(
createDevice(
0,
"Coordinator",
COORD_IEEE,
ZSpec.COORDINATOR_ADDRESS,
Zcl.ManufacturerCode.SILICON_LABORATORIES,
undefined,
undefined,
undefined,
),
);
ZH_DEVICES.push(
createDevice(1, "Router", "0xf1f1f1f1f1f1f1f1", 0x0001, Zcl.ManufacturerCode.INNR_LIGHTING_BV, "Innr", "Mains (single phase)", "AE 262"),
);
ZH_DEVICES.push(
createDevice(2, "EndDevice", "0xe2e2e2e2e2e2e2e2", 0x0002, Zcl.ManufacturerCode.TUYA_GLOBAL_INC, "_TYZB01_kvwjujy9", "Battery", "TS0222"),
);
ZH_DEVICES.push(createDevice(3, "GreenPower", "0x00000000015d3d3d", 0x0003, undefined, undefined, undefined, "GreenPower_7"));
// these have configure, without setTimeout, they hammer really badly (# of fn calls), so, only one of each
ZH_DEVICES.push(
createDevice(
4,
"Router",
"0xd3d3d3d3d3d3d3d3",
0x0004,
Zcl.ManufacturerCode.LEDVANCE_GMBH,
"LEDVANCE",
"Mains (single phase)",
"PLUG OUTDOOR EU T",
),
);
ZH_DEVICES.push(
createDevice(5, "Router", "0xc4c4c4c4c4c4c4c4", 0x0005, Zcl.ManufacturerCode.INOVELLI, "Inovelli", "Mains (single phase)", "VZM35-SN"),
);
ZH_DEVICES.push(
createDevice(
6,
"Router",
"0xb5b5b5b5b5b5b5b5",
0x0006,
Zcl.ManufacturerCode.SILICON_LABORATORIES,
"SMLIGHT",
"Mains (single phase)",
"SLZB-06Mg24",
),
);
ZH_DEVICES.push(
createDevice(
7,
"Router",
"0xa6a6a6a6a6a6a6a6",
0x0007,
Zcl.ManufacturerCode.TUYA_GLOBAL_INC,
"_TZE200_p0gzbqct",
"Mains (single phase)",
"TS0601",
),
);
};
const initGroups = () => {
ZH_GROUPS.splice(0, ZH_GROUPS.length);
ZH_GROUPS.push(createGroup(0, 1));
};
const addManyDevices = () => {
for (let i = 0; i < MANY_DEVICES; i++) {
const ieee = `0xf1f1f1f1f1f1f1${i.toString(16).padStart(2, "0")}`;
// device without `configure` (too many calls otherwise)
ZH_DEVICES.push(createDevice(1, "Router", ieee, 0x0008 + i, Zcl.ManufacturerCode.INNR_LIGHTING_BV, "Innr", "Mains (single phase)", "AE 262"));
}
};
const getMidDeviceIeee = () =>
`0xf1f1f1f1f1f1f1${Math.floor(MANY_DEVICES / 2)
.toString(16)
.padStart(2, "0")}`;
Device.byIeeeAddr = (ieeeAddr, _includeDeleted) => ZH_DEVICES.find((device) => device.ieeeAddr === ieeeAddr);
Device.byType = (type) => ZH_DEVICES.filter((device) => device.type === type);
const adapter = {
sendZclFrameToEndpoint: async (
_ieeeAddr: string,
networkAddress: number,
endpoint: number,
zclFrame: Zcl.Frame,
_timeout: number,
disableResponse: boolean,
_disableRecovery: boolean,
sourceEndpoint?: number,
): Promise<ZclPayload | undefined> => {
const payload: {[key: string]: unknown}[] = [];
if (!disableResponse) {
if (zclFrame.header.isGlobal) {
switch (zclFrame.command.ID) {
case Foundation.read.ID: {
for (const attr of zclFrame.payload) {
const attribute = zclFrame.cluster.getAttribute(attr.attrId);
if (attribute && attribute.type !== Zcl.DataType.NO_DATA && attribute.type < Zcl.DataType.OCTET_STR) {
payload.push({
attrId: attr.attrId,
dataType: attribute.type,
attrData: 1,
status: 0,
});
}
}
const messageContents = Zcl.Frame.create(
0,
Zcl.Direction.SERVER_TO_CLIENT,
true,
undefined,
10,
Foundation.readRsp.ID,
zclFrame.cluster.ID,
payload,
{},
).toBuffer();
return await Promise.resolve({
clusterID: zclFrame.cluster.ID,
header: Zcl.Header.fromBuffer(messageContents),
address: networkAddress,
data: messageContents,
endpoint: sourceEndpoint ?? 1,
linkquality: 200,
groupID: 0,
wasBroadcast: false,
destinationEndpoint: endpoint,
});
}
case Foundation.configReport.ID: {
for (const item of zclFrame.payload) {
payload.push({attrId: item.attrId, status: 0, direction: 1});
}
const messageContents = Zcl.Frame.create(
0,
Zcl.Direction.SERVER_TO_CLIENT,
true,
undefined,
10,
Foundation.configReportRsp.ID,
zclFrame.cluster.ID,
payload,
{},
).toBuffer();
return await Promise.resolve({
clusterID: zclFrame.cluster.ID,
header: Zcl.Header.fromBuffer(messageContents),
address: networkAddress,
data: messageContents,
endpoint: sourceEndpoint ?? 1,
linkquality: 200,
groupID: 0,
wasBroadcast: false,
destinationEndpoint: endpoint,
});
}
}
}
}
return await Promise.resolve(undefined);
},
sendZclFrameToGroup: async (_groupID: number, _zclFrame: Zcl.Frame, _sourceEndpoint?: number): Promise<void> => await Promise.resolve(),
sendZdo: async (
_ieeeAddress: string,
_networkAddress: number,
clusterId: Zdo.ClusterId,
_payload: Buffer,
_disableResponse: boolean,
): Promise<RequestToResponseMap[keyof RequestToResponseMap] | undefined> => {
switch (clusterId) {
case Zdo.ClusterId.BIND_REQUEST:
case Zdo.ClusterId.UNBIND_REQUEST: {
return await Promise.resolve([Zdo.Status.SUCCESS, undefined]);
}
}
return await Promise.resolve(undefined);
},
};
Entity.injectAdapter(adapter as Adapter);
// use plain type to avoid early import that otherwise messes with data path
let controller: import("../lib/controller.js").Controller;
const origSetImmediate = global.setImmediate;
const origSetTimeout = global.setTimeout;
const mockGlobalThis = () => {
const setImmediateProms: (void | Promise<void>)[] = [];
const setTimeoutProms: (void | Promise<void>)[] = [];
// @ts-expect-error mock
globalThis.setImmediate = (callback: () => void) => {
setImmediateProms.push(callback());
};
// @ts-expect-error mock
globalThis.setTimeout = (callback: () => void) => {
setTimeoutProms.push(callback());
};
return {setImmediateProms, setTimeoutProms};
};
const unmockGlobalThis = () => {
globalThis.setImmediate = origSetImmediate;
globalThis.setTimeout = origSetTimeout;
};
const settle = async (mockedGlobal: ReturnType<typeof mockGlobalThis>) => {
await Promise.allSettled(mockedGlobal.setImmediateProms);
await Promise.allSettled(mockedGlobal.setTimeoutProms);
await new Promise((resolve) => origSetImmediate(resolve));
};
const initSettings = async (pathValuePairs?: [string[], string | number | boolean][]) => {
const settings = await import("../lib/util/settings.js");
settings.writeMinimalDefaults();
// disable logging, too much influence on perf
settings.set(["advanced", "log_level"], "error");
settings.set(["advanced", "log_output"], []);
settings.set(["advanced", "pan_id"], NETWORK_PARAMS.panID);
settings.set(["advanced", "ext_pan_id"], EXT_PAN_ID);
settings.set(["advanced", "network_key"], NETWORK_KEY);
if (pathValuePairs) {
for (const [path, value] of pathValuePairs) {
settings.set(path, value);
}
}
};
const initController = async () => {
const {Controller} = await import("../lib/controller.js");
controller = new Controller(
async () => {},
async () => {},
);
controller.zigbee.start = async () => {
await controller.zigbee.resolveDevicesDefinitions();
return "resumed";
};
controller.zigbee.stop = async () => {
await Promise.resolve();
};
// @ts-expect-error mocking private
controller.zigbee.herdsman = {
isStopping: () => false,
getCoordinatorVersion: async () =>
Promise.resolve({
type: "Dummy",
meta: {revision: "9.9.9"},
}),
getNetworkParameters: async () => Promise.resolve({...NETWORK_PARAMS}),
getPermitJoin: () => false,
getPermitJoinEnd: () => undefined,
getDeviceByIeeeAddr: (ieeeAddr) => ZH_DEVICES.find((device) => device.ieeeAddr === ieeeAddr),
getGroupByID: (id) => ZH_GROUPS.find((group) => group.groupID === id),
getDevicesByType: (type) => ZH_DEVICES.filter((device) => device.type === type),
getDeviceByNetworkAddress: (networkAddress) => ZH_DEVICES.find((device) => device.networkAddress === networkAddress),
*getDevicesIterator(predicate) {
for (const device of ZH_DEVICES) {
if (!predicate || predicate(device)) {
yield device;
}
}
},
*getGroupsIterator(predicate) {
for (const group of ZH_GROUPS) {
if (!predicate || predicate(group)) {
yield group;
}
}
},
};
// all dummies, can trigger `controller.mqtt.onMessage(topic, message)` as needed
// @ts-expect-error mocking private
controller.mqtt.client = {
options: {
protocolVersion: 5,
protocol: "mqtt",
host: "localhost",
port: 1883,
},
queue: [],
reconnecting: false,
disconnecting: false,
disconnected: false,
endAsync: async () => {},
// @ts-expect-error Z2M does not make use of return
publishAsync: async () => {},
};
controller.mqtt.connect = async () => {
// @ts-expect-error private
await controller.mqtt.onConnect();
};
controller.mqtt.subscribe = async () => {};
controller.mqtt.unsubscribe = async () => {};
// will be in-memory only
controller.state.start = () => {};
controller.state.stop = () => {};
};
describe("Controller with dummy zigbee/mqtt", () => {
bench(
"[defaults] start & stop controller",
async () => {
initDevices();
initGroups();
await initSettings();
await initController();
const mockedGlobal = mockGlobalThis();
await controller.start();
await settle(mockedGlobal);
if ((await controller.zigbee.getCoordinatorVersion()).type !== "Dummy") {
throw new Error("Invalid");
}
await controller.stop();
unmockGlobalThis();
},
BENCH_OPTIONS,
);
bench(
"[HA] start & stop controller",
async () => {
initDevices();
initGroups();
await initSettings([[["homeassistant", "enabled"], true]]);
await initController();
const mockedGlobal = mockGlobalThis();
await controller.start();
controller.mqtt.onMessage("homeassistant/status", Buffer.from("online", "utf8"));
await settle(mockedGlobal);
if ((await controller.zigbee.getCoordinatorVersion()).type !== "Dummy") {
throw new Error("Invalid");
}
await controller.stop();
unmockGlobalThis();
},
BENCH_OPTIONS,
);
describe("defaults runtime", () => {
const setup: NonNullable<Parameters<typeof bench>[2]>["setup"] = async (task, mode) => {
BENCH_OPTIONS.setup!(task, mode);
initDevices();
initGroups();
await initSettings();
await initController();
const mockedGlobal = mockGlobalThis();
await controller.start();
await settle(mockedGlobal);
};
const teardown = async () => {
await controller.stop();
unmockGlobalThis();
};
bench(
"[defaults] receive device message",
async () => {
const mockedGlobal = mockGlobalThis();
controller.eventBus.emitDeviceMessage({
type: "attributeReport",
device: controller.zigbee.resolveEntity("0xf1f1f1f1f1f1f1f1"),
endpoint: ZSpec.HA_ENDPOINT,
linkquality: 200,
groupID: 0,
cluster: "genOnOff",
data: {onOff: 1},
meta: {},
});
await settle(mockedGlobal);
},
{...BENCH_OPTIONS, setup, teardown},
);
bench(
"[defaults] receive MQTT message",
async () => {
const mockedGlobal = mockGlobalThis();
controller.mqtt.onMessage("zigbee2mqtt/0xf1f1f1f1f1f1f1f1/set", Buffer.from(`{"state": "OFF"}`, "utf8"));
await settle(mockedGlobal);
},
{...BENCH_OPTIONS, setup, teardown},
);
bench(
"[defaults] add group member",
async () => {
const mockedGlobal = mockGlobalThis();
controller.eventBus.emitMQTTMessage({
topic: "zigbee2mqtt/bridge/request/group/members/add",
message: stringify({
device: "0xf1f1f1f1f1f1f1f1",
group: `${ZH_GROUPS[0].groupID}`,
endpoint: ZSpec.HA_ENDPOINT,
}),
});
await settle(mockedGlobal);
if (ZH_GROUPS[0].members.length !== 1) {
throw new Error("Invalid state");
}
},
{...BENCH_OPTIONS, setup, teardown},
);
});
describe("defaults/stress runtime", () => {
const setup: NonNullable<Parameters<typeof bench>[2]>["setup"] = async (task, mode) => {
BENCH_OPTIONS.setup!(task, mode);
initDevices();
initGroups();
addManyDevices();
await initSettings();
await initController();
const mockedGlobal = mockGlobalThis();
await controller.start();
await settle(mockedGlobal);
};
const teardown = async () => {
await controller.stop();
unmockGlobalThis();
};
// this is mostly just to confirm the number of devices does not influence the processing (much)
bench(
"[defaults/stress] receive device message",
async () => {
const mockedGlobal = mockGlobalThis();
controller.eventBus.emitDeviceMessage({
type: "attributeReport",
device: controller.zigbee.resolveEntity(getMidDeviceIeee()),
endpoint: ZSpec.HA_ENDPOINT,
linkquality: 200,
groupID: 0,
cluster: "genOnOff",
data: {onOff: 1},
meta: {},
});
await settle(mockedGlobal);
},
{...BENCH_OPTIONS, setup, teardown},
);
});
describe("HA runtime", () => {
const setup: NonNullable<Parameters<typeof bench>[2]>["setup"] = async (task, mode) => {
BENCH_OPTIONS.setup!(task, mode);
initDevices();
initGroups();
await initSettings([[["homeassistant", "enabled"], true]]);
await initController();
const mockedGlobal = mockGlobalThis();
await controller.start();
controller.mqtt.onMessage("homeassistant/status", Buffer.from("online", "utf8"));
await settle(mockedGlobal);
};
const teardown = async () => {
await controller.stop();
unmockGlobalThis();
};
bench(
"[HA] receive device message",
async () => {
const mockedGlobal = mockGlobalThis();
controller.eventBus.emitDeviceMessage({
type: "attributeReport",
device: controller.zigbee.resolveEntity("0xf1f1f1f1f1f1f1f1"),
endpoint: ZSpec.HA_ENDPOINT,
linkquality: 200,
groupID: 0,
cluster: "genOnOff",
data: {onOff: 1},
meta: {},
});
await settle(mockedGlobal);
},
{...BENCH_OPTIONS, setup, teardown},
);
bench(
"[HA] receive MQTT message",
async () => {
const mockedGlobal = mockGlobalThis();
controller.mqtt.onMessage("zigbee2mqtt/0xf1f1f1f1f1f1f1f1/set", Buffer.from(`{"state": "OFF"}`, "utf8"));
await settle(mockedGlobal);
},
{...BENCH_OPTIONS, setup, teardown},
);
bench(
"[HA] receive MQTT discovery message",
async () => {
const mockedGlobal = mockGlobalThis();
controller.mqtt.onMessage(
"homeassistant/sensor/0xe2e2e2e2e2e2e2e2/update/config",
Buffer.from(stringify({availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}]}), "utf8"),
);
await settle(mockedGlobal);
},
{...BENCH_OPTIONS, setup, teardown},
);
});
});
+49 -38
View File
@@ -1,3 +1,5 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import * as data from "./mocks/data";
import {mockLogger} from "./mocks/logger";
import {
@@ -9,23 +11,19 @@ import {
mockMQTTUnsubscribeAsync,
} from "./mocks/mqtt";
import {flushPromises} from "./mocks/utils";
import {devices, mockController as mockZHController, events as mockZHEvents, returnDevices} from "./mocks/zigbeeHerdsman";
import type {Mock, MockInstance} from "vitest";
import type Device from "../lib/model/device";
import type {Device as ZhDevice} from "./mocks/zigbeeHerdsman";
import {devices, mockController as mockZHController, events as mockZHEvents, returnDevices} from "./mocks/zigbeeHerdsman";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import stringify from "json-stable-stringify-without-jsonify";
import tmp from "tmp";
import type {Mock, MockInstance} from "vitest";
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";
const LOG_MQTT_NS = "z2m:mqtt";
@@ -58,7 +56,6 @@ describe("Controller", () => {
let mockExit: Mock;
const getZ2MDevice = (zhDevice: string | number | ZhDevice): Device => {
// @ts-expect-error private
return controller.zigbee.resolveEntity(zhDevice)! as Device;
};
@@ -87,6 +84,7 @@ describe("Controller", () => {
});
it("Start controller", async () => {
settings.setOnboarding(true);
settings.set(["advanced", "transmit_power"], 14);
await controller.start();
expect(ZHController).toHaveBeenCalledWith({
@@ -114,6 +112,8 @@ describe("Controller", () => {
expect(mockMQTTConnectAsync).toHaveBeenCalledWith("mqtt://localhost", {
will: {payload: Buffer.from('{"state":"offline"}'), retain: true, topic: "zigbee2mqtt/bridge/state", qos: 1},
properties: {maximumPacketSize: 1048576},
keepalive: 60,
protocolVersion: 4,
});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bulb",
@@ -121,6 +121,7 @@ describe("Controller", () => {
{retain: true, qos: 0},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/remote", stringify({brightness: 255}), {retain: true, qos: 0});
expect(settings.get().onboarding).toBeUndefined();
});
it("Start controller with specific MQTT settings", async () => {
@@ -294,11 +295,9 @@ describe("Controller", () => {
await controller.start();
await flushPromises();
mockMQTTPublishAsync.mockClear();
// @ts-expect-error private
await controller.mqtt.publish("z2m/#/status", "empty");
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
expect(mockLogger.error).toHaveBeenCalledWith(`Topic 'z2m/#/status' includes wildcard characters, skipping publish.`);
// @ts-expect-error private
await controller.mqtt.publish("z2m/+/status", "empty");
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
expect(mockLogger.error).toHaveBeenCalledWith(`Topic 'z2m/+/status' includes wildcard characters, skipping publish.`);
@@ -339,6 +338,16 @@ describe("Controller", () => {
expect(mockExit).toHaveBeenCalledTimes(1);
});
it("Start controller fails after onboarding", async () => {
settings.setOnboarding(true);
mockZHController.start.mockImplementationOnce(() => {
throw new Error("failed");
});
await controller.start();
expect(mockExit).toHaveBeenCalledTimes(1);
expect(settings.get().onboarding).toStrictEqual(true);
});
it("Start controller fails due to MQTT connect error", async () => {
mockMQTTConnectAsync.mockImplementationOnce(() => {
throw new Error("addr not found");
@@ -350,6 +359,19 @@ describe("Controller", () => {
expect(mockExit).toHaveBeenCalledWith(1, false);
});
it("Start controller fails due to MQTT connect error after onboarding", async () => {
settings.setOnboarding(true);
mockMQTTConnectAsync.mockImplementationOnce(() => {
throw new Error("addr not found");
});
await controller.start();
await flushPromises();
expect(mockLogger.error).toHaveBeenCalledWith("MQTT failed to connect, exiting... (addr not found)");
expect(mockExit).toHaveBeenCalledTimes(1);
expect(mockExit).toHaveBeenCalledWith(1, false);
expect(settings.get().onboarding).toStrictEqual(true);
});
it("Start controller and stop with restart", async () => {
await controller.start();
await controller.stop(true);
@@ -388,14 +410,15 @@ describe("Controller", () => {
});
it("Start controller adapter disconnects", async () => {
mockZHController.stop.mockRejectedValueOnce("failed");
// Fail to stop extension exit code 1 should not override adapter disconnect exit code 2
vi.spyOn(Array.from(controller.extensions)[0], "stop").mockRejectedValueOnce(new Error("failed"));
await controller.start();
await mockZHEvents.adapterDisconnected();
await flushPromises();
expect(mockMQTTEndAsync).toHaveBeenCalledTimes(1);
expect(mockZHController.stop).toHaveBeenCalledTimes(1);
expect(mockExit).toHaveBeenCalledTimes(1);
expect(mockExit).toHaveBeenCalledWith(1, false);
expect(mockExit).toHaveBeenCalledWith(2, false);
});
it("does not throw when extension fails to stop on controller stop", async () => {
@@ -478,7 +501,6 @@ describe("Controller", () => {
});
it("Handle mqtt message", async () => {
// @ts-expect-error private
const spyEventbusEmitMQTTMessage = vi.spyOn(controller.eventBus, "emitMQTTMessage").mockImplementation(vi.fn());
await controller.start();
@@ -489,7 +511,6 @@ describe("Controller", () => {
});
it("Skip MQTT messages on topic we published to", async () => {
// @ts-expect-error private
const spyEventbusEmitMQTTMessage = vi.spyOn(controller.eventBus, "emitMQTTMessage").mockImplementation(vi.fn());
await controller.start();
@@ -497,7 +518,6 @@ describe("Controller", () => {
await mockMQTTEvents.message("zigbee2mqtt/skip-this-topic", "skipped");
expect(spyEventbusEmitMQTTMessage).toHaveBeenCalledWith({topic: "zigbee2mqtt/skip-this-topic", message: "skipped"});
mockLogger.debug.mockClear();
// @ts-expect-error private
await controller.mqtt.publish("skip-this-topic", "", {});
await mockMQTTEvents.message("zigbee2mqtt/skip-this-topic", "skipped");
expect(mockLogger.debug).toHaveBeenCalledTimes(0);
@@ -559,7 +579,7 @@ describe("Controller", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/event",
stringify({type: "device_joined", data: {friendly_name: "bulb", ieee_address: device.ieeeAddr}}),
{retain: false, qos: 0},
{},
);
});
@@ -621,7 +641,7 @@ describe("Controller", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/event",
stringify({type: "device_joined", data: {friendly_name: "bulb", ieee_address: device.ieeeAddr}}),
{retain: false, qos: 0},
{},
);
});
@@ -634,7 +654,7 @@ describe("Controller", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/event",
stringify({type: "device_interview", data: {friendly_name: "bulb", status: "started", ieee_address: device.ieeeAddr}}),
{retain: false, qos: 0},
{},
);
});
@@ -647,7 +667,7 @@ describe("Controller", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/event",
stringify({type: "device_interview", data: {friendly_name: "bulb", status: "failed", ieee_address: device.ieeeAddr}}),
{retain: false, qos: 0},
{},
);
});
@@ -670,7 +690,7 @@ describe("Controller", () => {
expect(parsedMessage.data.definition.description).toStrictEqual("TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm");
expect(parsedMessage.data.definition.exposes).toStrictEqual(expect.any(Array));
expect(parsedMessage.data.definition.options).toStrictEqual(expect.any(Array));
expect(mockMQTTPublishAsync.mock.calls[1][2]).toStrictEqual({retain: false, qos: 0});
expect(mockMQTTPublishAsync.mock.calls[1][2]).toStrictEqual({});
});
it("On zigbee deviceInterview successful not supported", async () => {
@@ -692,7 +712,7 @@ describe("Controller", () => {
expect(parsedMessage.data.definition.description).toStrictEqual("Automatically generated definition");
expect(parsedMessage.data.definition.exposes).toStrictEqual(expect.any(Array));
expect(parsedMessage.data.definition.options).toStrictEqual(expect.any(Array));
expect(mockMQTTPublishAsync.mock.calls[1][2]).toStrictEqual({retain: false, qos: 0});
expect(mockMQTTPublishAsync.mock.calls[1][2]).toStrictEqual({});
});
it("On zigbee event device announce", async () => {
@@ -705,7 +725,7 @@ describe("Controller", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/event",
stringify({type: "device_announce", data: {friendly_name: "bulb", ieee_address: device.ieeeAddr}}),
{retain: false, qos: 0},
{},
);
});
@@ -721,7 +741,7 @@ describe("Controller", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/event",
stringify({type: "device_leave", data: {ieee_address: device.ieeeAddr, friendly_name: device.ieeeAddr}}),
{retain: false, qos: 0},
{},
);
});
@@ -736,7 +756,7 @@ describe("Controller", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/event",
stringify({type: "device_leave", data: {ieee_address: device.ieeeAddr, friendly_name: "bulb"}}),
{retain: false, qos: 0},
{},
);
});
@@ -819,13 +839,11 @@ describe("Controller", () => {
mockMQTTPublishAsync.mockClear();
const device = getZ2MDevice("bulb");
// @ts-expect-error private
expect(controller.state.get(device)).toStrictEqual({brightness: 50, color_temp: 370, linkquality: 99, state: "ON"});
await controller.publishEntityState(device, {state: "ON", brightness: 200, color_temp: 370, linkquality: 87});
await flushPromises();
// @ts-expect-error private
expect(controller.state.get(device)).toStrictEqual({brightness: 200, color_temp: 370, state: "ON"});
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(5);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb/state", "ON", {qos: 0, retain: true});
@@ -845,13 +863,11 @@ describe("Controller", () => {
mockMQTTPublishAsync.mockClear();
const device = getZ2MDevice("bulb");
// @ts-expect-error private
expect(controller.state.get(device)).toStrictEqual({brightness: 50, color_temp: 370, linkquality: 99, state: "ON"});
await controller.publishEntityState(device, {state: "ON", brightness: 200, color_temp: 370, linkquality: 87});
await flushPromises();
// @ts-expect-error private
expect(controller.state.get(device)).toStrictEqual({brightness: 200, color_temp: 370, state: "ON"});
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(5);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb/state", "ON", {qos: 0, retain: true});
@@ -1023,6 +1039,8 @@ describe("Controller", () => {
expect(mockMQTTConnectAsync).toHaveBeenCalledTimes(1);
const expected = {
will: {payload: Buffer.from('{"state":"offline"}'), retain: false, topic: "zigbee2mqtt/bridge/state", qos: 1},
keepalive: 60,
protocolVersion: 4,
properties: {maximumPacketSize: 1048576},
};
expect(mockMQTTConnectAsync).toHaveBeenCalledWith("mqtt://localhost", expected);
@@ -1032,16 +1050,13 @@ describe("Controller", () => {
await controller.start();
await flushPromises();
const retainedMessages = Object.keys(
// @ts-expect-error private
controller.mqtt.retainedMessages,
).length;
const retainedMessages = Object.keys(controller.mqtt.retainedMessages).length;
mockMQTTPublishAsync.mockClear();
await vi.advanceTimersByTimeAsync(2500); // before any startup configure triggers
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(retainedMessages);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/info", expect.any(String), {retain: true, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/info", expect.any(String), {retain: true});
});
it("Should not republish retained messages on MQTT initial connect when retained message are sent", async () => {
@@ -1056,14 +1071,13 @@ describe("Controller", () => {
it("Should prevent any message being published with retain flag when force_disable_retain is set", async () => {
settings.set(["mqtt", "force_disable_retain"], true);
// @ts-expect-error private
await controller.mqtt.connect();
mockMQTTPublishAsync.mockClear();
// @ts-expect-error private
await controller.mqtt.publish("fo", "bar", {retain: true});
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/fo", "bar", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/fo", "bar", {retain: false});
});
it("Should publish last seen changes", async () => {
@@ -1114,7 +1128,6 @@ describe("Controller", () => {
it("Should remove state of removed device when stopped", async () => {
await controller.start();
const device = getZ2MDevice("bulb");
// @ts-expect-error private
expect(controller.state.get(device)).toStrictEqual({brightness: 50, color_temp: 370, linkquality: 99, state: "ON"});
device.zh.isDeleted = true;
await controller.stop();
@@ -1123,7 +1136,6 @@ describe("Controller", () => {
});
it("EventBus should handle sync errors", async () => {
// @ts-expect-error private
const eventbus = controller.eventBus;
const callback = vi.fn().mockImplementation(() => {
throw new Error("Whoops!");
@@ -1136,7 +1148,6 @@ describe("Controller", () => {
});
it("EventBus should handle async errors", async () => {
// @ts-expect-error private
const eventbus = controller.eventBus;
const callback = vi.fn().mockRejectedValue(new Error("Whoops!"));
eventbus.onStateChange({constructor: {name: "Test"}}, callback);
+1 -2
View File
@@ -1,7 +1,6 @@
import path from "node:path";
import tmp from "tmp";
import {describe, expect, it} from "vitest";
import data from "../lib/util/data";
describe("Data", () => {
+2 -4
View File
@@ -1,3 +1,5 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
@@ -5,9 +7,7 @@ import {flushPromises} from "../mocks/utils";
import {devices, events as mockZHEvents, returnDevices} from "../mocks/zigbeeHerdsman";
import assert from "node:assert";
import stringify from "json-stable-stringify-without-jsonify";
import {Controller} from "../../lib/controller";
import Availability from "../../lib/extension/availability";
import * as settings from "../../lib/util/settings";
@@ -282,9 +282,7 @@ describe("Extension: Availability", () => {
});
it("Should retrieve device state when it reconnects", async () => {
// @ts-expect-error private
const device = controller.zigbee.resolveEntity(devices.bulb_color.ieeeAddr);
// @ts-expect-error private
controller.state.set(device, {state: "OFF"});
const endpoint = devices.bulb_color.getEndpoint(1);
+49 -28
View File
@@ -1,3 +1,5 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import * as data from "../mocks/data";
import {mockDebounce} from "../mocks/debounce";
import {mockLogger} from "../mocks/logger";
@@ -6,10 +8,10 @@ import {flushPromises} from "../mocks/utils";
import {type Device, devices, groups, events as mockZHEvents} from "../mocks/zigbeeHerdsman";
import stringify from "json-stable-stringify-without-jsonify";
import {Controller} from "../../lib/controller";
import Bind from "../../lib/extension/bind";
import * as settings from "../../lib/util/settings";
import {DEFAULT_BIND_GROUP_ID} from "../../lib/util/utils";
const mocksClear = [
mockDebounce,
@@ -108,10 +110,10 @@ describe("Extension: Bind", () => {
},
status: "ok",
}),
{retain: false, qos: 0},
{},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), {retain: true, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), {retain: true});
// Teardown
target.binds = originalTargetBinds;
@@ -125,7 +127,7 @@ describe("Extension: Bind", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/bind",
stringify({data: {}, status: "error", error: "Invalid payload"}),
{retain: false, qos: 0},
{},
);
});
@@ -184,10 +186,10 @@ describe("Extension: Bind", () => {
},
status: "ok",
}),
{retain: false, qos: 0},
{},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), {retain: true, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), {retain: true});
// Teardown
target.binds = originalTargetBinds;
@@ -254,10 +256,10 @@ describe("Extension: Bind", () => {
},
status: "ok",
}),
{retain: false, qos: 0},
{},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), {retain: true, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), {retain: true});
// Teardown
target.configuredReportings = originalTargetCR;
@@ -277,7 +279,26 @@ describe("Extension: Bind", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/bind",
stringify({data: {from: "remote", from_endpoint: "default", to: "bulb_color", clusters: ["genOnOff"], failed: []}, status: "ok"}),
{retain: false, qos: 0},
{},
);
});
it("Should allow to bind to coordinator by ieeeAddr", async () => {
const device = devices.remote;
const target = devices.coordinator.getEndpoint(1)!;
const endpoint = device.getEndpoint(1)!;
mockClear(device);
mockMQTTEvents.message(
"zigbee2mqtt/bridge/request/device/bind",
stringify({from: "remote", to: devices.coordinator.ieeeAddr, clusters: ["genOnOff"]}),
);
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(1);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/bind",
stringify({data: {from: "remote", from_endpoint: "default", to: "0x00124b00120144ae", clusters: ["genOnOff"], failed: []}, status: "ok"}),
{},
);
});
@@ -292,7 +313,7 @@ describe("Extension: Bind", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/bind",
stringify({data: {}, status: "error", error: "Nothing to bind"}),
{retain: false, qos: 0},
{},
);
});
@@ -344,7 +365,7 @@ describe("Extension: Bind", () => {
data: {from: "remote", from_endpoint: "default", to: "bulb_color", clusters: ["genScenes", "genOnOff", "genLevelCtrl"], failed: []},
status: "ok",
}),
{retain: false, qos: 0},
{},
);
// Teardown
@@ -370,7 +391,7 @@ describe("Extension: Bind", () => {
data: {from: "remote", from_endpoint: "default", to: "Coordinator", clusters: ["genScenes", "genOnOff", "genLevelCtrl"], failed: []},
status: "ok",
}),
{retain: false, qos: 0},
{},
);
});
@@ -401,7 +422,7 @@ describe("Extension: Bind", () => {
data: {from: "remote", from_endpoint: "default", to: "group_1", clusters: ["genScenes", "genOnOff", "genLevelCtrl"], failed: []},
status: "ok",
}),
{retain: false, qos: 0},
{},
);
// Should configure reporting for device added to group
@@ -437,7 +458,7 @@ describe("Extension: Bind", () => {
data: {from: "remote", from_endpoint: "default", to: "group_1", clusters: ["genScenes", "genOnOff", "genLevelCtrl"], failed: []},
status: "ok",
}),
{retain: false, qos: 0},
{},
);
});
@@ -514,7 +535,7 @@ describe("Extension: Bind", () => {
data: {from: "remote", from_endpoint: "default", to: "1", clusters: ["genScenes", "genOnOff", "genLevelCtrl"], failed: []},
status: "ok",
}),
{retain: false, qos: 0},
{},
);
});
@@ -532,7 +553,7 @@ describe("Extension: Bind", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/bind",
stringify({data: {}, status: "error", error: "Failed to bind"}),
{retain: false, qos: 0},
{},
);
});
@@ -554,7 +575,7 @@ describe("Extension: Bind", () => {
data: {from: "remote", from_endpoint: "ep2", to: "wall_switch_double", to_endpoint: "right", clusters: ["genOnOff"], failed: []},
status: "ok",
}),
{retain: false, qos: 0},
{},
);
});
@@ -576,7 +597,7 @@ describe("Extension: Bind", () => {
data: {from: "remote", from_endpoint: 2, to: "wall_switch_double", to_endpoint: 3, clusters: ["genOnOff"], failed: []},
status: "ok",
}),
{retain: false, qos: 0},
{},
);
});
@@ -601,7 +622,7 @@ describe("Extension: Bind", () => {
},
status: "ok",
}),
{retain: false, qos: 0},
{},
);
});
@@ -617,7 +638,7 @@ describe("Extension: Bind", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/bind",
stringify({data: {from: "remote", from_endpoint: "ep2", to: "wall_switch", clusters: ["genOnOff"], failed: []}, status: "ok"}),
{retain: false, qos: 0},
{},
);
});
@@ -629,9 +650,9 @@ describe("Extension: Bind", () => {
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/unbind", stringify({from: "remote", to: target}));
await flushPromises();
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", 901);
expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", 901);
expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", 901);
expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", DEFAULT_BIND_GROUP_ID);
expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", DEFAULT_BIND_GROUP_ID);
expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", DEFAULT_BIND_GROUP_ID);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/unbind",
stringify({
@@ -644,7 +665,7 @@ describe("Extension: Bind", () => {
},
status: "ok",
}),
{retain: false, qos: 0},
{},
);
});
@@ -656,7 +677,7 @@ describe("Extension: Bind", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/bind",
stringify({data: {}, status: "error", error: "Source device 'remote_not_existing' does not exist"}),
{retain: false, qos: 0},
{},
);
});
@@ -671,7 +692,7 @@ describe("Extension: Bind", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/bind",
stringify({data: {}, status: "error", error: "Source device 'remote' does not have endpoint 'not_existing_endpoint'"}),
{retain: false, qos: 0},
{},
);
});
@@ -683,7 +704,7 @@ describe("Extension: Bind", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/bind",
stringify({data: {}, status: "error", error: "Target device or group 'bulb_color_not_existing' does not exist"}),
{retain: false, qos: 0},
{},
);
});
@@ -698,7 +719,7 @@ describe("Extension: Bind", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/bind",
stringify({data: {}, status: "error", error: "Target device 'bulb_color' does not have endpoint 'not_existing_endpoint'"}),
{retain: false, qos: 0},
{},
);
});
File diff suppressed because it is too large Load Diff
+10 -12
View File
@@ -1,13 +1,13 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
import {flushPromises} from "../mocks/utils";
import {type Device, type Endpoint, devices, events as mockZHEvents} from "../mocks/zigbeeHerdsman";
import {type Device, devices, type Endpoint, events as mockZHEvents} 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 Configure from "../../lib/extension/configure";
import * as settings from "../../lib/util/settings";
@@ -121,11 +121,9 @@ describe("Extension: Configure", () => {
it("Should reconfigure reporting on reconfigure event", async () => {
expectBulbConfigured();
// @ts-expect-error private
const device = controller.zigbee.resolveEntity(devices.bulb)!;
mockClear(device.zh);
expectBulbNotConfigured();
// @ts-expect-error private
controller.eventBus.emitReconfigure({device});
await flushPromises();
expectBulbConfigured();
@@ -158,7 +156,7 @@ describe("Extension: Configure", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/configure",
stringify({data: {id: "remote"}, status: "ok"}),
{retain: false, qos: 0},
{},
);
});
@@ -168,7 +166,7 @@ describe("Extension: Configure", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/configure",
stringify({data: {}, status: "error", error: "Device 'not_existing_device' does not exist"}),
{retain: false, qos: 0},
{},
);
});
@@ -179,17 +177,17 @@ describe("Extension: Configure", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/configure",
stringify({data: {}, status: "error", error: "Failed to configure (Bind timeout after 10s)"}),
{retain: false, qos: 0},
{},
);
});
it("Fail to configure via MQTT when device has no configure", async () => {
await mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/configure", stringify({id: "0x0017882104a44559", transaction: 20}));
await mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/configure", stringify({id: "0x0017882104a44562", transaction: 20}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/configure",
stringify({data: {}, status: "error", error: "Device 'TS0601_thermostat' cannot be configured", transaction: 20}),
{retain: false, qos: 0},
stringify({data: {}, status: "error", error: "Device 'TS0601_cover_switch' cannot be configured", transaction: 20}),
{},
);
});
@@ -199,7 +197,7 @@ describe("Extension: Configure", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/configure",
stringify({data: {}, status: "error", error: "Invalid payload"}),
{retain: false, qos: 0},
{},
);
});
+35 -37
View File
@@ -1,21 +1,19 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {mockMQTTEndAsync, mockMQTTPublishAsync} from "../mocks/mqtt";
import {flushPromises} from "../mocks/utils";
import {devices, mockController as mockZHController, returnDevices} from "../mocks/zigbeeHerdsman";
import type Device from "../../lib/model/device";
import type {Device as ZhDevice} from "../mocks/zigbeeHerdsman";
import {devices, mockController as mockZHController, returnDevices} from "../mocks/zigbeeHerdsman";
import fs from "node:fs";
import path from "node:path";
import stringify from "json-stable-stringify-without-jsonify";
import * as zhc from "zigbee-herdsman-converters";
import {Controller} from "../../lib/controller";
import ExternalConverters from "../../lib/extension/externalConverters";
import type Device from "../../lib/model/device";
import * as settings from "../../lib/util/settings";
const BASE_DIR = "external_converters";
@@ -52,7 +50,6 @@ describe("Extension: ExternalConverters", () => {
};
const getZ2MDevice = (zhDevice: string | number | ZhDevice): Device => {
// @ts-expect-error private
return controller.zigbee.resolveEntity(zhDevice)! as Device;
};
@@ -75,7 +72,6 @@ describe("Extension: ExternalConverters", () => {
beforeEach(async () => {
zhc.removeExternalDefinitions(); // remove all external converters
// @ts-expect-error private - clear cached
await controller.zigbee.resolveDevicesDefinitions(true);
for (const mock of mocksClear) mock.mockClear();
data.writeDefaultConfiguration();
@@ -85,10 +81,12 @@ describe("Extension: ExternalConverters", () => {
});
afterEach(async () => {
fs.rmSync(mockBasePath, {recursive: true, force: true});
await controller?.stop();
await flushPromises();
expect(fs.existsSync(path.join(mockBasePath, "node_modules"))).toStrictEqual(false);
fs.rmSync(mockBasePath, {recursive: true, force: true});
});
describe("from folder", () => {
@@ -100,7 +98,7 @@ describe("Extension: ExternalConverters", () => {
await controller.start();
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/converters", stringify([]), {retain: true, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/converters", stringify([]), {retain: true});
});
it("CJS: loads converters", async () => {
@@ -121,7 +119,7 @@ describe("Extension: ExternalConverters", () => {
{name: "mock-external-converter-multiple.js", code: getFileCode("cjs", "mock-external-converter-multiple.js")},
{name: "mock-external-converter.js", code: getFileCode("cjs", "mock-external-converter.js")},
]),
{retain: true, qos: 0},
{retain: true},
);
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenCalledTimes(2);
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(1, "mock-external-converter-multiple.js");
@@ -191,7 +189,7 @@ describe("Extension: ExternalConverters", () => {
{name: "mock-external-converter-multiple.mjs", code: getFileCode("mjs", "mock-external-converter-multiple.mjs")},
{name: "mock-external-converter.mjs", code: getFileCode("mjs", "mock-external-converter.mjs")},
]),
{retain: true, qos: 0},
{retain: true},
);
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenCalledTimes(2);
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(1, "mock-external-converter-multiple.mjs");
@@ -263,7 +261,7 @@ describe("Extension: ExternalConverters", () => {
{name: "mock-external-converter-multiple.js", code: getFileCode("cjs", "mock-external-converter-multiple.js")},
{name: converterName, code: converterCode},
]),
{retain: true, qos: 0},
{retain: true},
);
converterCode = converterCode.replace('posix.join("external", "converter")', 'posix.join("external", "converter", "edited")');
@@ -283,9 +281,9 @@ describe("Extension: ExternalConverters", () => {
"zigbee2mqtt/bridge/converters",
stringify([
{name: "mock-external-converter-multiple.js", code: getFileCode("cjs", "mock-external-converter-multiple.js")},
{name: "mock-external-converter.1.js", code: converterCode},
{name: "mock-external-converter.js", code: converterCode},
]),
{retain: true, qos: 0},
{retain: true},
);
expect(zhcAddExternalDefinitionSpy).toHaveBeenLastCalledWith(
expect.objectContaining({
@@ -294,7 +292,7 @@ describe("Extension: ExternalConverters", () => {
vendor: "external",
model: "external_converter_device",
description: "external/converter/edited",
externalConverterName: "mock-external-converter.1.js",
externalConverterName: "mock-external-converter.js",
}),
);
@@ -302,7 +300,7 @@ describe("Extension: ExternalConverters", () => {
await (controller.getExtension("ExternalConverters")! as ExternalConverters).onMQTTMessage({
topic: "zigbee2mqtt/bridge/request/converter/save",
message: {name: "mock-external-converter.1.js", code: converterCode},
message: {name: "mock-external-converter.js", code: converterCode},
});
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
@@ -315,9 +313,9 @@ describe("Extension: ExternalConverters", () => {
"zigbee2mqtt/bridge/converters",
stringify([
{name: "mock-external-converter-multiple.js", code: getFileCode("cjs", "mock-external-converter-multiple.js")},
{name: "mock-external-converter.2.js", code: converterCode},
{name: "mock-external-converter.js", code: converterCode},
]),
{retain: true, qos: 0},
{retain: true},
);
expect(zhcAddExternalDefinitionSpy).toHaveBeenLastCalledWith(
expect.objectContaining({
@@ -326,7 +324,7 @@ describe("Extension: ExternalConverters", () => {
vendor: "external",
model: "external_converter_device",
description: "external/converter",
externalConverterName: "mock-external-converter.2.js",
externalConverterName: "mock-external-converter.js",
}),
);
});
@@ -347,15 +345,20 @@ describe("Extension: ExternalConverters", () => {
{name: "mock-external-converter-multiple.mjs", code: getFileCode("mjs", "mock-external-converter-multiple.mjs")},
{name: "mock-external-converter.mjs", code: getFileCode("mjs", "mock-external-converter.mjs")},
]),
{retain: true, qos: 0},
{retain: true},
);
expect(fs.existsSync(filepath)).toStrictEqual(false);
expect(fs.existsSync(path.join(mockBasePath, "invalid.mjs.invalid"))).toStrictEqual(true);
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Parse failure: Expected ';', '}' or <eof>"));
});
});
describe("from MQTT", () => {
it("CJS: saves and removes", async () => {
// Create a dummy 'node_modules' file to test to externalJS.ts recreates the symlink.
fs.mkdirSync(mockBasePath);
fs.writeFileSync(path.join(mockBasePath, "node_modules"), "");
const converterName = "foo.js";
const converterCode = getFileCode("cjs", "mock-external-converter.js");
@@ -398,9 +401,10 @@ describe("Extension: ExternalConverters", () => {
stringify([{name: converterName, code: converterCode}]),
{
retain: true,
qos: 0,
},
);
// Ensure that the .tmp import file is deleted.
expect(fs.readdirSync(mockBasePath)).toStrictEqual(["foo.js", "node_modules"]);
//-- REMOVE
await (controller.getExtension("ExternalConverters")! as ExternalConverters).onMQTTMessage({
@@ -417,7 +421,7 @@ describe("Extension: ExternalConverters", () => {
expect(rmSyncSpy).toHaveBeenCalledWith(expect.stringContaining(converterName), {force: true});
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenCalledTimes(2);
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(2, converterName);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/converters", stringify([]), {retain: true, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/converters", stringify([]), {retain: true});
});
it("MJS: saves and removes", async () => {
@@ -463,7 +467,6 @@ describe("Extension: ExternalConverters", () => {
stringify([{name: converterName, code: converterCode}]),
{
retain: true,
qos: 0,
},
);
@@ -482,7 +485,7 @@ describe("Extension: ExternalConverters", () => {
expect(rmSyncSpy).toHaveBeenCalledWith(expect.stringContaining(converterName), {force: true});
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenCalledTimes(2);
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(2, converterName);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/converters", stringify([]), {retain: true, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/converters", stringify([]), {retain: true});
});
it("returns error on invalid code", async () => {
@@ -500,10 +503,9 @@ describe("Extension: ExternalConverters", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/converter/save",
expect.stringContaining(`"error":"${converterName} contains invalid code`),
{retain: false, qos: 0},
{},
);
expect(writeFileSyncSpy).toHaveBeenCalledWith(expect.stringContaining(converterName), converterCode, "utf8");
expect(rmSyncSpy).toHaveBeenCalledWith(expect.stringContaining(converterName), {force: true});
});
it("returns error on invalid removal", async () => {
@@ -520,7 +522,7 @@ describe("Extension: ExternalConverters", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/converter/remove",
expect.stringContaining("doesn't exists"),
{retain: false, qos: 0},
{},
);
expect(rmSyncSpy).not.toHaveBeenCalledWith(expect.stringContaining(converterName), {force: true});
});
@@ -543,12 +545,8 @@ describe("Extension: ExternalConverters", () => {
message: {name: converterName, code: converterCode},
});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/response/converter/save", expect.stringContaining(errorMsg), {
retain: false,
qos: 0,
});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/response/converter/save", expect.stringContaining(errorMsg), {});
expect(writeFileSyncSpy).toHaveBeenCalledWith(expect.stringContaining(converterName), converterCode, "utf8");
expect(rmSyncSpy).toHaveBeenCalledWith(expect.stringContaining(converterName), {force: true});
});
it("returns error on failed removal", async () => {
@@ -579,7 +577,7 @@ describe("Extension: ExternalConverters", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/converter/remove",
stringify({data: {}, status: "error", error: errorMsg}),
{retain: false, qos: 0},
{},
);
expect(rmSyncSpy).not.toHaveBeenCalledWith(expect.stringContaining(converterName), {force: true});
});
@@ -596,7 +594,7 @@ describe("Extension: ExternalConverters", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/converter/save",
stringify({data: {}, status: "error", error: "Invalid payload", transaction: 1}),
{retain: false, qos: 0},
{},
);
await (controller.getExtension("ExternalConverters")! as ExternalConverters).onMQTTMessage({
@@ -607,7 +605,7 @@ describe("Extension: ExternalConverters", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/converter/remove",
stringify({data: {}, status: "error", error: "Invalid payload", transaction: 2}),
{retain: false, qos: 0},
{},
);
});
});
+49 -47
View File
@@ -1,3 +1,5 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {mockMQTTEndAsync, mockMQTTPublishAsync} from "../mocks/mqtt";
@@ -6,9 +8,7 @@ import {devices, mockController as mockZHController, returnDevices} from "../moc
import fs from "node:fs";
import path from "node:path";
import stringify from "json-stable-stringify-without-jsonify";
import {Controller} from "../../lib/controller";
import ExternalExtensions from "../../lib/extension/externalExtensions";
import * as settings from "../../lib/util/settings";
@@ -68,10 +68,12 @@ describe("Extension: ExternalExtensions", () => {
});
afterEach(async () => {
fs.rmSync(mockBasePath, {recursive: true, force: true});
await controller?.stop();
await flushPromises();
expect(fs.existsSync(path.join(mockBasePath, "node_modules"))).toStrictEqual(false);
fs.rmSync(mockBasePath, {recursive: true, force: true});
});
describe("from folder", () => {
@@ -83,7 +85,7 @@ describe("Extension: ExternalExtensions", () => {
await controller.start();
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/extensions", stringify([]), {retain: true, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/extensions", stringify([]), {retain: true});
});
it("CJS: loads extensions", async () => {
@@ -92,17 +94,17 @@ describe("Extension: ExternalExtensions", () => {
await controller.start();
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example2/extension", "call2 from constructor", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example2/extension", "call2 from start", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from constructor", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example2/extension", "call2 from constructor", {});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example2/extension", "call2 from start", {});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from constructor", {});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start", {});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/extensions",
stringify([
{name: "example2Extension.js", code: getFileCode("cjs", "example2Extension.js")},
{name: "exampleExtension.js", code: getFileCode("cjs", "exampleExtension.js")},
]),
{retain: true, qos: 0},
{retain: true},
);
});
@@ -112,17 +114,17 @@ describe("Extension: ExternalExtensions", () => {
await controller.start();
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example2/extension", "call2 from constructor", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example2/extension", "call2 from start", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from constructor", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example2/extension", "call2 from constructor", {});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example2/extension", "call2 from start", {});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from constructor", {});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start", {});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/extensions",
stringify([
{name: "example2Extension.mjs", code: getFileCode("mjs", "example2Extension.mjs")},
{name: "exampleExtension.mjs", code: getFileCode("mjs", "exampleExtension.mjs")},
]),
{retain: true, qos: 0},
{retain: true},
);
});
@@ -132,8 +134,8 @@ describe("Extension: ExternalExtensions", () => {
await controller.start();
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension/counter", "start 0", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).not.toHaveBeenCalledWith("zigbee2mqtt/example/extension/counter", "start 1", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension/counter", "start 0", {});
expect(mockMQTTPublishAsync).not.toHaveBeenCalledWith("zigbee2mqtt/example/extension/counter", "start 1", {});
});
it("loads all valid extensions, relocates & skips ones with errors", async () => {
@@ -152,7 +154,7 @@ describe("Extension: ExternalExtensions", () => {
{name: "example2Extension.mjs", code: getFileCode("mjs", "example2Extension.mjs")},
{name: "exampleExtension.mjs", code: getFileCode("mjs", "exampleExtension.mjs")},
]),
{retain: true, qos: 0},
{retain: true},
);
expect(fs.existsSync(filepath)).toStrictEqual(false);
expect(fs.existsSync(path.join(mockBasePath, "invalid.mjs.invalid"))).toStrictEqual(true);
@@ -166,17 +168,17 @@ describe("Extension: ExternalExtensions", () => {
await controller.start();
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example2/extension", "call2 from constructor", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example2/extension", "call2 from start", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from constructor", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example2/extension", "call2 from constructor", {});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example2/extension", "call2 from start", {});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from constructor", {});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start", {});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/extensions",
stringify([
{name: "example2Extension.js", code: getFileCode("cjs", "example2Extension.js")},
{name: extensionName, code: extensionCode},
]),
{retain: true, qos: 0},
{retain: true},
);
extensionCode = extensionCode
@@ -189,15 +191,15 @@ describe("Extension: ExternalExtensions", () => {
message: {name: extensionName, code: extensionCode},
});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from stop", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start - edited", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from stop", {});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start - edited", {});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/extensions",
stringify([
{name: "example2Extension.js", code: getFileCode("cjs", "example2Extension.js")},
{name: "exampleExtension.1.js", code: extensionCode},
{name: "exampleExtension.js", code: extensionCode},
]),
{retain: true, qos: 0},
{retain: true},
);
extensionCode = extensionCode
@@ -207,18 +209,19 @@ describe("Extension: ExternalExtensions", () => {
mockMQTTPublishAsync.mockClear();
await (controller.getExtension("ExternalExtensions")! as ExternalExtensions).onMQTTMessage({
topic: "zigbee2mqtt/bridge/request/extension/save",
message: {name: "exampleExtension.1.js", code: extensionCode},
message: {name: "exampleExtension.js", code: extensionCode},
});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from stop - edited", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from stop - edited", {});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start", {});
expect(mockMQTTPublishAsync).not.toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start - edited", {});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/extensions",
stringify([
{name: "example2Extension.js", code: getFileCode("cjs", "example2Extension.js")},
{name: "exampleExtension.2.js", code: extensionCode},
{name: "exampleExtension.js", code: extensionCode},
]),
{retain: true, qos: 0},
{retain: true},
);
});
});
@@ -238,16 +241,17 @@ describe("Extension: ExternalExtensions", () => {
});
expect(writeFileSyncSpy).toHaveBeenCalledWith(expect.stringContaining(extensionName), extensionCode, "utf8");
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from constructor", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from constructor", {});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start", {});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/extensions",
stringify([{name: extensionName, code: extensionCode}]),
{
retain: true,
qos: 0,
},
);
// Ensure that the .tmp import file is deleted.
expect(fs.readdirSync(mockBasePath)).toStrictEqual(["foo.js", "node_modules"]);
//-- REMOVE
await (controller.getExtension("ExternalExtensions")! as ExternalExtensions).onMQTTMessage({
@@ -256,8 +260,8 @@ describe("Extension: ExternalExtensions", () => {
});
expect(rmSyncSpy).toHaveBeenCalledWith(expect.stringContaining(extensionName), {force: true});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from stop", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/extensions", stringify([]), {retain: true, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from stop", {});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/extensions", stringify([]), {retain: true});
});
it("MJS: saves and removes", async () => {
@@ -274,14 +278,13 @@ describe("Extension: ExternalExtensions", () => {
});
expect(writeFileSyncSpy).toHaveBeenCalledWith(expect.stringContaining(extensionName), extensionCode, "utf8");
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from constructor", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from constructor", {});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start", {});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/extensions",
stringify([{name: extensionName, code: extensionCode}]),
{
retain: true,
qos: 0,
},
);
@@ -292,8 +295,8 @@ describe("Extension: ExternalExtensions", () => {
});
expect(rmSyncSpy).toHaveBeenCalledWith(expect.stringContaining(extensionName), {force: true});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from stop", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/extensions", stringify([]), {retain: true, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from stop", {});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/extensions", stringify([]), {retain: true});
});
it("returns error on invalid code", async () => {
@@ -311,10 +314,9 @@ describe("Extension: ExternalExtensions", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/extension/save",
expect.stringContaining(`"error":"${extensionName} contains invalid code`),
{retain: false, qos: 0},
{},
);
expect(writeFileSyncSpy).toHaveBeenCalledWith(expect.stringContaining(extensionName), extensionCode, "utf8");
expect(rmSyncSpy).toHaveBeenCalledWith(expect.stringContaining(extensionName), {force: true});
});
it("returns error on invalid removal", async () => {
@@ -331,7 +333,7 @@ describe("Extension: ExternalExtensions", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/extension/remove",
expect.stringContaining("doesn't exists"),
{retain: false, qos: 0},
{},
);
expect(rmSyncSpy).not.toHaveBeenCalledWith(expect.stringContaining(extensionName), {force: true});
});
@@ -348,7 +350,7 @@ describe("Extension: ExternalExtensions", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/extension/save",
stringify({data: {}, status: "error", error: "Invalid payload", transaction: 1}),
{retain: false, qos: 0},
{},
);
await (controller.getExtension("ExternalExtensions")! as ExternalExtensions).onMQTTMessage({
@@ -359,7 +361,7 @@ describe("Extension: ExternalExtensions", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/extension/remove",
stringify({data: {}, status: "error", error: "Invalid payload", transaction: 2}),
{retain: false, qos: 0},
{},
);
});
});
+51 -14
View File
@@ -1,15 +1,15 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {mockMQTTPublishAsync} from "../mocks/mqtt";
import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
import {type EventHandler, flushPromises} from "../mocks/utils";
import {devices} from "../mocks/zigbeeHerdsman";
import {devices, events as mockZHEvents} from "../mocks/zigbeeHerdsman";
import path from "node:path";
import stringify from "json-stable-stringify-without-jsonify";
import type {Mock} from "vitest";
import ws from "ws";
import {Controller} from "../../lib/controller";
import * as settings from "../../lib/util/settings";
@@ -23,7 +23,6 @@ const mockHTTP = {
close: vi.fn<(cb: (err?: Error) => void) => void>((cb) => cb()),
};
let mockHTTPSOnRequest: (request: {url: string}, response: number) => void;
const mockHTTPSEvents: Record<string, EventHandler> = {};
const mockHTTPS = {
listen: vi.fn(),
@@ -56,7 +55,7 @@ const mockWS = {
handleUpgrade: vi.fn().mockImplementation((_request, _socket, _head, cb) => {
cb(mockWSocket);
}),
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
// biome-ignore lint/suspicious/noExplicitAny: ignore
emit: vi.fn<(eventName: string, ...args: any[]) => void>(),
close: vi.fn<(code?: number, data?: string | Buffer) => void>(),
};
@@ -76,10 +75,7 @@ vi.mock("node:http", () => ({
}));
vi.mock("node:https", () => ({
createServer: vi.fn().mockImplementation((onRequest) => {
mockHTTPSOnRequest = onRequest;
return mockHTTPS;
}),
createServer: vi.fn().mockImplementation(() => mockHTTPS),
Agent: vi.fn(),
}));
@@ -230,6 +226,7 @@ describe("Extension: Frontend", () => {
mockWSClient.readyState = "open";
mockWS.clients.push(mockWSClient);
await mockWSEvents.connection(mockWSClient);
devices.bulb_color.linkquality = 20;
const allTopics = mockWSClient.send.mock.calls.map(([m]) => JSON.parse(m).topic);
expect(allTopics).toContain("bridge/devices");
@@ -249,7 +246,7 @@ describe("Extension: Frontend", () => {
state: "ON",
effect: null,
power_on_behavior: null,
linkquality: null,
linkquality: 20,
update: {state: null, installed_version: -1, latest_version: -1},
}),
{retain: false, qos: 0},
@@ -270,9 +267,36 @@ describe("Extension: Frontend", () => {
topic: "bulb_color",
payload: {
state: "ON",
effect: null,
power_on_behavior: null,
linkquality: null,
effect: null,
linkquality: 20,
update: {state: null, installed_version: -1, latest_version: -1},
},
}),
);
// Should publish bridge messages
await mockZHEvents.deviceJoined({device: devices.bulb});
await flushPromises();
expect(mockWSClient.send).toHaveBeenCalledWith(
stringify({payload: {data: {friendly_name: "bulb", ieee_address: "0x000b57fffec6a5b2"}, type: "device_joined"}, topic: "bridge/event"}),
);
// Should send JSON state event when `output: attribute`
mockWSClient.send.mockClear();
settings.set(["advanced", "output"], "attribute");
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({brightness: 90}));
await flushPromises();
expect(mockWSClient.send).toHaveBeenCalledTimes(1);
expect(mockWSClient.send).toHaveBeenCalledWith(
stringify({
topic: "bulb_color",
payload: {
state: "ON",
brightness: 90,
power_on_behavior: null,
effect: null,
linkquality: 20,
update: {state: null, installed_version: -1, latest_version: -1},
},
}),
@@ -338,7 +362,7 @@ describe("Extension: Frontend", () => {
expect(mockHTTP.listen).toHaveBeenCalledWith(8081, "127.0.0.1");
});
it("Authentification", async () => {
it("Authentication", async () => {
const authToken = "sample-secure-token";
settings.set(["frontend", "auth_token"], authToken);
controller = new Controller(vi.fn(), vi.fn());
@@ -463,4 +487,17 @@ describe("Extension: Frontend", () => {
await vi.waitFor(() => controller.getExtension("Frontend") === undefined);
});
it("disables serving", async () => {
settings.set(["frontend", "disable_ui_serving"], true);
controller = new Controller(vi.fn(), vi.fn());
await controller.start();
expect(mockHTTP.listen).toHaveBeenCalledTimes(0);
mockWS.clients.push(mockWSClient);
await controller.stop();
expect(mockWSClient.terminate).toHaveBeenCalledTimes(1);
expect(mockHTTP.close).toHaveBeenCalledTimes(0);
expect(mockWS.close).toHaveBeenCalledTimes(1);
});
});
+39 -21
View File
@@ -1,3 +1,5 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
@@ -5,9 +7,7 @@ import {flushPromises} from "../mocks/utils";
import {devices, groups, events as mockZHEvents, resetGroupMembers, returnDevices} from "../mocks/zigbeeHerdsman";
import stringify from "json-stable-stringify-without-jsonify";
import * as zhcGlobalStore from "zigbee-herdsman-converters/lib/store";
import {Controller} from "../../lib/controller";
import * as settings from "../../lib/util/settings";
@@ -45,7 +45,6 @@ describe("Extension: Groups", () => {
mockMQTTPublishAsync.mockClear();
groups.gledopto_group.command.mockClear();
zhcGlobalStore.clear();
// @ts-expect-error private
controller.state.clear();
});
@@ -460,7 +459,6 @@ describe("Extension: Groups", () => {
await mockMQTTEvents.message("zigbee2mqtt/group_1/set", stringify({state: "ON"}));
await flushPromises();
mockMQTTPublishAsync.mockClear();
// @ts-expect-error private
controller.state.clear();
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "OFF"}));
@@ -487,7 +485,7 @@ describe("Extension: Groups", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/group/members/add",
stringify({data: {device: "bulb_color", endpoint: "default", group: "group_1"}, transaction: "123", status: "ok"}),
{retain: false, qos: 0},
{},
);
});
@@ -507,8 +505,8 @@ describe("Extension: Groups", () => {
expect(mockMQTTPublishAsync).not.toHaveBeenCalledWith("zigbee2mqtt/bridge/groups", expect.any(String), expect.any(Object));
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/group/members/add",
stringify({data: {}, status: "error", error: "Failed to add from group (timeout)"}),
{retain: false, qos: 0},
stringify({data: {}, status: "error", error: "Failed to add to group (timeout)"}),
{},
);
});
@@ -526,7 +524,7 @@ describe("Extension: Groups", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/group/members/add",
stringify({data: {device: "bulb_color", endpoint: "default", group: "group/with/slashes"}, status: "ok"}),
{retain: false, qos: 0},
{},
);
});
@@ -546,7 +544,7 @@ describe("Extension: Groups", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/group/members/add",
stringify({data: {device: "wall_switch_double", endpoint: "right", group: "group_1"}, status: "ok"}),
{retain: false, qos: 0},
{},
);
});
@@ -571,7 +569,7 @@ describe("Extension: Groups", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/group/members/add",
stringify({data: {device: "wall_switch_double", endpoint: "right", group: "group_1"}, status: "ok"}),
{retain: false, qos: 0},
{},
);
});
@@ -588,7 +586,27 @@ describe("Extension: Groups", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/group/members/remove",
stringify({data: {device: "bulb_color", endpoint: "default", group: "group_1"}, status: "ok"}),
{retain: false, qos: 0},
{},
);
});
it("Remove from group via MQTT fails", async () => {
const device = devices.bulb_color;
const endpoint = device.getEndpoint(1)!;
const group = groups.group_1;
group.members.push(endpoint);
endpoint.removeFromGroup.mockImplementationOnce(() => {
throw new Error("timeout");
});
mockMQTTPublishAsync.mockClear();
mockMQTTEvents.message("zigbee2mqtt/bridge/request/group/members/remove", stringify({group: "group_1", device: "bulb_color"}));
await flushPromises();
expect(group.members.length).toStrictEqual(1);
expect(mockMQTTPublishAsync).not.toHaveBeenCalledWith("zigbee2mqtt/bridge/groups", expect.any(String), expect.any(Object));
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/group/members/remove",
stringify({data: {}, status: "error", error: "Failed to remove from group (timeout)"}),
{},
);
});
@@ -608,7 +626,7 @@ describe("Extension: Groups", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/group/members/remove",
stringify({data: {device: "bulb_color", endpoint: "default", group: "group_1"}, status: "ok"}),
{retain: false, qos: 0},
{},
);
});
@@ -628,7 +646,7 @@ describe("Extension: Groups", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/group/members/remove",
stringify({data: {device: "0x0017880104e45542", endpoint: "3", group: "group_1"}, status: "ok"}),
{retain: false, qos: 0},
{},
);
});
@@ -648,7 +666,7 @@ describe("Extension: Groups", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/group/members/remove",
stringify({data: {device: "wall_switch_double", endpoint: "3", group: "group_1"}, status: "ok"}),
{retain: false, qos: 0},
{},
);
});
@@ -668,7 +686,7 @@ describe("Extension: Groups", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/group/members/remove",
stringify({data: {device: "0x0017880104e45542", endpoint: "right", group: "group_1"}, status: "ok"}),
{retain: false, qos: 0},
{},
);
});
@@ -683,7 +701,7 @@ describe("Extension: Groups", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/group/members/remove_all",
stringify({data: {device: "0x0017880104e45542", endpoint: "right"}, status: "ok"}),
{retain: false, qos: 0},
{},
);
});
@@ -696,7 +714,7 @@ describe("Extension: Groups", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/group/members/remove",
stringify({data: {}, status: "error", error: "Group 'group_1_not_existing' does not exist"}),
{retain: false, qos: 0},
{},
);
});
@@ -709,7 +727,7 @@ describe("Extension: Groups", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/group/members/add",
stringify({data: {}, status: "error", error: "Device 'bulb_color_not_existing' does not exist"}),
{retain: false, qos: 0},
{},
);
});
@@ -725,7 +743,7 @@ describe("Extension: Groups", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/group/members/add",
stringify({data: {}, status: "error", error: "Device 'bulb_color' does not have endpoint 'not_existing_endpoint'"}),
{retain: false, qos: 0},
{},
);
});
@@ -738,7 +756,7 @@ describe("Extension: Groups", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/group/members/add",
stringify({data: {}, status: "error", error: "Invalid payload"}),
{retain: false, qos: 0},
{},
);
});
@@ -751,7 +769,7 @@ describe("Extension: Groups", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/group/members/add",
stringify({data: {}, status: "error", error: "Invalid payload"}),
{retain: false, qos: 0},
{},
);
});
+315
View File
@@ -0,0 +1,315 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
import {flushPromises} from "../mocks/utils";
import {devices, events as mockZHEvents, returnDevices} from "../mocks/zigbeeHerdsman";
import {Controller} from "../../lib/controller";
import Health from "../../lib/extension/health";
import * as settings from "../../lib/util/settings";
import {minutes, seconds} from "../../lib/util/utils";
const mocksClear = [mockMQTTPublishAsync, mockLogger.warning, mockLogger.info];
returnDevices.push(devices.bulb_color.ieeeAddr, devices.bulb_color_2.ieeeAddr, devices.coordinator.ieeeAddr);
describe("Extension: Health", () => {
let controller: Controller;
const getExtension = (): Health => controller.getExtension("Health") as Health;
const resetExtension = async (): Promise<void> => {
await controller.removeExtension(getExtension());
await controller.addExtension(new Health(...controller.extensionArgs));
};
beforeAll(async () => {
vi.useFakeTimers();
settings.reRead();
controller = new Controller(vi.fn(), vi.fn());
await controller.start();
await flushPromises();
});
beforeEach(() => {
data.writeDefaultConfiguration();
settings.reRead();
settings.set(["devices", devices.bulb_color_2.ieeeAddr, "health"], false);
for (const mock of mocksClear) {
mock.mockClear();
}
getExtension().clearStats();
});
afterEach(async () => {});
afterAll(async () => {
await controller?.stop();
await flushPromises();
vi.useRealTimers();
});
it("checks health at default interval", async () => {
await resetExtension();
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
await mockZHEvents.lastSeenChanged({device: devices.bulb_color_2});
await mockZHEvents.deviceLeave({ieeeAddr: devices.bulb_color.ieeeAddr});
await mockZHEvents.deviceJoined({device: devices.bulb_color});
await mockZHEvents.deviceNetworkAddressChanged({device: devices.bulb_color});
await vi.advanceTimersByTimeAsync(seconds(1));
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
await vi.advanceTimersByTimeAsync(seconds(1));
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
await vi.advanceTimersByTimeAsync(seconds(1));
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
await mockMQTTEvents.message("zigbee2mqtt/mock", "mocked");
await vi.advanceTimersByTimeAsync(minutes(11));
let calls = mockMQTTPublishAsync.mock.calls.filter((call) => call[0] === "zigbee2mqtt/bridge/health");
expect(calls.length).toStrictEqual(1);
expect(JSON.parse(calls[0][1])).toStrictEqual({
response_time: expect.any(Number),
os: {
load_average: [expect.any(Number), expect.any(Number), expect.any(Number)],
memory_used_mb: expect.any(Number),
memory_percent: expect.any(Number),
},
process: {uptime_sec: expect.any(Number), memory_used_mb: expect.any(Number), memory_percent: expect.any(Number)},
mqtt: {
connected: true,
queued: 0,
published: expect.any(Number),
received: 1,
},
devices: {
[devices.bulb_color.ieeeAddr]: {
leave_count: 1,
messages: 4,
messages_per_sec: 0.0067,
network_address_changes: 1,
},
[devices.bulb_color_2.ieeeAddr]: {
leave_count: 0,
messages: 1,
messages_per_sec: 0.0017,
network_address_changes: 0,
},
},
});
expect(calls[0][2]).toStrictEqual({retain: true, qos: 1});
mockMQTTPublishAsync.mockClear();
await mockMQTTEvents.message("zigbee2mqtt/mock2", "mocked2");
await vi.advanceTimersByTimeAsync(minutes(11));
calls = mockMQTTPublishAsync.mock.calls.filter((call) => call[0] === "zigbee2mqtt/bridge/health");
expect(calls.length).toStrictEqual(1);
expect(JSON.parse(calls[0][1])).toStrictEqual({
response_time: expect.any(Number),
os: {
load_average: [expect.any(Number), expect.any(Number), expect.any(Number)],
memory_used_mb: expect.any(Number),
memory_percent: expect.any(Number),
},
process: {uptime_sec: expect.any(Number), memory_used_mb: expect.any(Number), memory_percent: expect.any(Number)},
mqtt: {
connected: true,
queued: 0,
published: expect.any(Number),
received: 2,
},
devices: {
[devices.bulb_color.ieeeAddr]: {
leave_count: 1,
messages: 4,
messages_per_sec: 0.0033,
network_address_changes: 1,
},
[devices.bulb_color_2.ieeeAddr]: {
leave_count: 0,
messages: 1,
messages_per_sec: 0.0008,
network_address_changes: 0,
},
},
});
expect(calls[0][2]).toStrictEqual({retain: true, qos: 1});
});
it("checks health at given interval", async () => {
settings.set(["health", "interval"], 20);
await resetExtension();
await vi.advanceTimersByTimeAsync(minutes(11));
let calls = mockMQTTPublishAsync.mock.calls.filter((call) => call[0] === "zigbee2mqtt/bridge/health");
expect(calls.length).toStrictEqual(0);
await vi.advanceTimersByTimeAsync(minutes(10));
calls = mockMQTTPublishAsync.mock.calls.filter((call) => call[0] === "zigbee2mqtt/bridge/health");
expect(calls.length).toStrictEqual(1);
expect(JSON.parse(calls[0][1])).toStrictEqual({
response_time: expect.any(Number),
os: {
load_average: [expect.any(Number), expect.any(Number), expect.any(Number)],
memory_used_mb: expect.any(Number),
memory_percent: expect.any(Number),
},
process: {uptime_sec: expect.any(Number), memory_used_mb: expect.any(Number), memory_percent: expect.any(Number)},
mqtt: {
connected: true,
queued: 0,
published: expect.any(Number),
received: 0,
},
devices: {},
});
expect(calls[0][2]).toStrictEqual({retain: true, qos: 1});
});
it("init device health from leave", async () => {
await resetExtension();
await mockZHEvents.deviceLeave({ieeeAddr: devices.bulb_color.ieeeAddr});
await mockZHEvents.deviceJoined({device: devices.bulb_color});
await vi.advanceTimersByTimeAsync(minutes(11));
const calls = mockMQTTPublishAsync.mock.calls.filter((call) => call[0] === "zigbee2mqtt/bridge/health");
expect(calls.length).toStrictEqual(1);
expect(JSON.parse(calls[0][1])).toStrictEqual({
response_time: expect.any(Number),
os: {
load_average: [expect.any(Number), expect.any(Number), expect.any(Number)],
memory_used_mb: expect.any(Number),
memory_percent: expect.any(Number),
},
process: {uptime_sec: expect.any(Number), memory_used_mb: expect.any(Number), memory_percent: expect.any(Number)},
mqtt: {
connected: true,
queued: 0,
published: expect.any(Number),
received: 0,
},
devices: {
[devices.bulb_color.ieeeAddr]: {
leave_count: 1,
messages: 0,
messages_per_sec: 0,
network_address_changes: 0,
},
},
});
expect(calls[0][2]).toStrictEqual({retain: true, qos: 1});
});
it("init device health from network address change", async () => {
await resetExtension();
await mockZHEvents.deviceNetworkAddressChanged({device: devices.bulb_color});
await vi.advanceTimersByTimeAsync(minutes(11));
const calls = mockMQTTPublishAsync.mock.calls.filter((call) => call[0] === "zigbee2mqtt/bridge/health");
expect(calls.length).toStrictEqual(1);
expect(JSON.parse(calls[0][1])).toStrictEqual({
response_time: expect.any(Number),
os: {
load_average: [expect.any(Number), expect.any(Number), expect.any(Number)],
memory_used_mb: expect.any(Number),
memory_percent: expect.any(Number),
},
process: {uptime_sec: expect.any(Number), memory_used_mb: expect.any(Number), memory_percent: expect.any(Number)},
mqtt: {
connected: true,
queued: 0,
published: expect.any(Number),
received: 0,
},
devices: {
[devices.bulb_color.ieeeAddr]: {
leave_count: 0,
messages: 0,
messages_per_sec: 0,
network_address_changes: 1,
},
},
});
expect(calls[0][2]).toStrictEqual({retain: true, qos: 1});
});
it("checks health then resets possible stats", async () => {
settings.set(["health", "reset_on_check"], true);
await resetExtension();
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
await mockZHEvents.lastSeenChanged({device: devices.bulb_color}); // coverage no time diff first/last
await mockZHEvents.lastSeenChanged({device: devices.bulb_color_2});
await vi.advanceTimersByTimeAsync(minutes(11));
let calls = mockMQTTPublishAsync.mock.calls.filter((call) => call[0] === "zigbee2mqtt/bridge/health");
expect(calls.length).toStrictEqual(1);
expect(JSON.parse(calls[0][1])).toStrictEqual({
response_time: expect.any(Number),
os: {
load_average: [expect.any(Number), expect.any(Number), expect.any(Number)],
memory_used_mb: expect.any(Number),
memory_percent: expect.any(Number),
},
process: {uptime_sec: expect.any(Number), memory_used_mb: expect.any(Number), memory_percent: expect.any(Number)},
mqtt: {
connected: true,
queued: 0,
published: expect.any(Number),
received: 0,
},
devices: {
[devices.bulb_color.ieeeAddr]: {
leave_count: 0,
messages: 2,
messages_per_sec: 0.0033,
network_address_changes: 0,
},
[devices.bulb_color_2.ieeeAddr]: {
leave_count: 0,
messages: 1,
messages_per_sec: 0.0017,
network_address_changes: 0,
},
},
});
expect(calls[0][2]).toStrictEqual({retain: true, qos: 1});
mockMQTTPublishAsync.mockClear();
await vi.advanceTimersByTimeAsync(minutes(11));
calls = mockMQTTPublishAsync.mock.calls.filter((call) => call[0] === "zigbee2mqtt/bridge/health");
expect(calls.length).toStrictEqual(1);
expect(JSON.parse(calls[0][1])).toStrictEqual({
response_time: expect.any(Number),
os: {
load_average: [expect.any(Number), expect.any(Number), expect.any(Number)],
memory_used_mb: expect.any(Number),
memory_percent: expect.any(Number),
},
process: {uptime_sec: expect.any(Number), memory_used_mb: expect.any(Number), memory_percent: expect.any(Number)},
mqtt: {
connected: true,
queued: 0,
published: expect.any(Number),
received: 0,
},
devices: {},
});
expect(calls[0][2]).toStrictEqual({retain: true, qos: 1});
});
});
+28 -33
View File
@@ -1,25 +1,23 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, beforeAll, beforeEach, describe, expect, it, test, vi} from "vitest";
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {events as mockMQTTEvents, mockMQTTPublishAsync, mockMQTTSubscribeAsync, mockMQTTUnsubscribeAsync} from "../mocks/mqtt";
import * as mockSleep from "../mocks/sleep";
import {flushPromises, getZhcBaseDefinitions} from "../mocks/utils";
import type {Device as ZhDevice} from "../mocks/zigbeeHerdsman";
import {devices, groups, events as mockZHEvents} from "../mocks/zigbeeHerdsman";
import assert from "node:assert";
import stringify from "json-stable-stringify-without-jsonify";
import type {MockInstance} from "vitest";
import * as zhc from "zigbee-herdsman-converters";
import type {KeyValueAny} from "zigbee-herdsman-converters/lib/types";
import {Controller} from "../../lib/controller";
import HomeAssistant from "../../lib/extension/homeassistant";
import type Device from "../../lib/model/device";
import type Group from "../../lib/model/group";
import type {Device as ZhDevice} from "../mocks/zigbeeHerdsman";
import assert from "node:assert";
import stringify from "json-stable-stringify-without-jsonify";
import * as zhc from "zigbee-herdsman-converters";
import type {KeyValueAny} from "zigbee-herdsman-converters/lib/types";
import {Controller} from "../../lib/controller";
import HomeAssistant from "../../lib/extension/homeassistant";
import * as settings from "../../lib/util/settings";
const mocksClear = [mockMQTTPublishAsync, mockLogger.debug, mockLogger.warning, mockLogger.error];
@@ -58,7 +56,6 @@ describe("Extension: HomeAssistant", () => {
};
const getZ2MEntity = (zhDeviceOrGroup: string | number | ZhDevice): Device | Group => {
// @ts-expect-error private
return controller.zigbee.resolveEntity(zhDeviceOrGroup)!;
};
@@ -101,7 +98,7 @@ describe("Extension: HomeAssistant", () => {
for (const baseDefinition of await getZhcBaseDefinitions()) {
const d = zhc.prepareDefinition(baseDefinition);
const exposes = typeof d.exposes === "function" ? d.exposes(undefined, undefined) : d.exposes;
const exposes = typeof d.exposes === "function" ? d.exposes({isDummyDevice: true}, {}) : d.exposes;
const device = {
definition: d,
isDevice: (): boolean => true,
@@ -921,7 +918,7 @@ describe("Extension: HomeAssistant", () => {
});
});
it("Should discover devices with speed-controlled fan", async () => {
it("Should discover devices with speed-controlled fan", () => {
const payload = {
state_topic: "zigbee2mqtt/fanbee",
state_value_template: "{{ value_json.state }}",
@@ -1182,9 +1179,9 @@ describe("Extension: HomeAssistant", () => {
command_topic: "zigbee2mqtt/0xa4c138018cf95021/left/set",
device: {
identifiers: ["zigbee2mqtt_0xa4c138018cf95021"],
manufacturer: "Lonsonho",
model: "Dual curtain/blind module",
model_id: "TS130F_dual",
manufacturer: "Girier",
model: "Dual smart curtain switch",
model_id: "TS130F_GIRIER_DUAL",
name: "0xa4c138018cf95021",
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
},
@@ -1212,9 +1209,9 @@ describe("Extension: HomeAssistant", () => {
command_topic: "zigbee2mqtt/0xa4c138018cf95021/right/set",
device: {
identifiers: ["zigbee2mqtt_0xa4c138018cf95021"],
manufacturer: "Lonsonho",
model: "Dual curtain/blind module",
model_id: "TS130F_dual",
manufacturer: "Girier",
model: "Dual smart curtain switch",
model_id: "TS130F_GIRIER_DUAL",
name: "0xa4c138018cf95021",
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
},
@@ -1233,6 +1230,8 @@ describe("Extension: HomeAssistant", () => {
value_template: '{% if "moving" in value_json and value_json.moving %} {{ value_json.moving }} {% else %} STOP {% endif %}',
};
console.log(mockMQTTPublishAsync.mock.calls.find((c) => c[0] === "homeassistant/cover/0xa4c138018cf95021/cover_left/config"));
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/cover/0xa4c138018cf95021/cover_left/config", stringify(payload_left), {
retain: true,
qos: 1,
@@ -1467,7 +1466,6 @@ describe("Extension: HomeAssistant", () => {
assert("ieeeAddr" in device);
resetDiscoveryPayloads(device.ieeeAddr);
mockMQTTPublishAsync.mockClear();
// @ts-expect-error private
controller.eventBus.emitEntityOptionsChanged({entity: device, from: {}, to: {test: 123}});
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(`homeassistant/light/${device.ID}/light/config`, expect.any(String), expect.any(Object));
@@ -1564,7 +1562,8 @@ describe("Extension: HomeAssistant", () => {
await flushPromises();
await vi.runOnlyPendingTimersAsync();
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/health", expect.any(String), expect.any(Object));
});
it("Shouldnt send all status when home assistant comes online with different topic", async () => {
@@ -1577,7 +1576,8 @@ describe("Extension: HomeAssistant", () => {
await flushPromises();
await vi.runOnlyPendingTimersAsync();
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/health", expect.any(String), expect.any(Object));
});
it("Should discover devices with availability", async () => {
@@ -1936,7 +1936,7 @@ describe("Extension: HomeAssistant", () => {
await mockZHEvents.message(payload1);
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/button/action", "single", {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/button/action", "single", expect.any(Object));
expect(mockMQTTPublishAsync.mock.calls.filter((c) => c[1] === "single")).toHaveLength(1);
});
@@ -2017,15 +2017,12 @@ describe("Extension: HomeAssistant", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/U202DST600ZB/l2",
stringify({state: "ON", brightness: 20, effect: null, power_on_behavior: null}),
{qos: 0, retain: false},
{},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/U202DST600ZB/l1",
stringify({state: null, effect: null, power_on_behavior: null}),
{
qos: 0,
retain: false,
},
{},
);
});
@@ -2388,7 +2385,6 @@ describe("Extension: HomeAssistant", () => {
resetDiscoveryPayloads(device.ieeeAddr);
mockMQTTPublishAsync.mockClear();
// @ts-expect-error private
controller.eventBus.emitScenesChanged({entity: device});
await flushPromises();
@@ -2425,7 +2421,6 @@ describe("Extension: HomeAssistant", () => {
resetDiscoveryPayloads("9");
mockMQTTPublishAsync.mockClear();
// @ts-expect-error private
controller.eventBus.emitScenesChanged({entity: group});
await flushPromises();
@@ -2459,7 +2454,8 @@ describe("Extension: HomeAssistant", () => {
stringify(payload),
{retain: true, qos: 1},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(6);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(7);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/health", expect.any(String), expect.any(Object));
});
it("Should not clear bridge entities unnecessarily", async () => {
@@ -2486,7 +2482,6 @@ describe("Extension: HomeAssistant", () => {
availability_mode: "all",
};
// @ts-expect-error private
controller.eventBus.emitMQTTMessage({
topic: topic,
message: stringify(payload),
+3 -3
View File
@@ -1,3 +1,5 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
@@ -7,9 +9,7 @@ import {devices, events as mockZHEvents, returnDevices} from "../mocks/zigbeeHer
import fs from "node:fs";
import path from "node:path";
import stringify from "json-stable-stringify-without-jsonify";
import {Controller} from "../../lib/controller";
import * as settings from "../../lib/util/settings";
@@ -473,7 +473,7 @@ describe("Extension: NetworkMap", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/networkmap",
stringify({data: {}, status: "error", error: "Type 'not_existing' not supported, allowed are: raw,graphviz,plantuml"}),
{retain: false, qos: 0},
{},
);
});
+91 -82
View File
@@ -1,32 +1,30 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {mockMQTTPublishAsync} from "../mocks/mqtt";
import {flushPromises} from "../mocks/utils";
import type {Device as ZhDevice} from "../mocks/zigbeeHerdsman";
import {devices, events as mockZHEvents, returnDevices} from "../mocks/zigbeeHerdsman";
import type {MockInstance} from "vitest";
import type {OnEvent as DefinitionOnEvent} from "zigbee-herdsman-converters/lib/types";
import type Device from "../../lib/model/device";
import type {Device as ZhDevice} from "../mocks/zigbeeHerdsman";
import * as zhc from "zigbee-herdsman-converters";
import type {OnEvent as ZhcOnEvent} from "zigbee-herdsman-converters/lib/types";
import {Controller} from "../../lib/controller";
import OnEvent from "../../lib/extension/onEvent";
import type Device from "../../lib/model/device";
import * as settings from "../../lib/util/settings";
const mocksClear = [mockMQTTPublishAsync, mockLogger.warning, mockLogger.debug];
returnDevices.push(devices.bulb.ieeeAddr, devices.LIVOLO.ieeeAddr);
returnDevices.push(devices.bulb.ieeeAddr, devices.LIVOLO.ieeeAddr, devices.coordinator.ieeeAddr);
describe("Extension: OnEvent", () => {
let controller: Controller;
let onEventSpy: MockInstance<typeof zhc.onEvent>;
let deviceOnEventSpy: MockInstance<DefinitionOnEvent>;
let deviceOnEventSpy: MockInstance<ZhcOnEvent.Handler>;
const getZ2MDevice = (zhDevice: string | number | ZhDevice): Device => {
// @ts-expect-error private
return controller.zigbee.resolveEntity(zhDevice)! as Device;
};
@@ -67,29 +65,26 @@ describe("Extension: OnEvent", () => {
it("starts & stops", async () => {
expect(onEventSpy).toHaveBeenCalledTimes(2);
expect(deviceOnEventSpy).toHaveBeenCalledTimes(1);
expect(deviceOnEventSpy).toHaveBeenNthCalledWith(
1,
"start",
{},
devices.LIVOLO,
settings.getDevice(devices.LIVOLO.ieeeAddr),
{},
{deviceExposesChanged: expect.any(Function)},
);
expect(deviceOnEventSpy).toHaveBeenNthCalledWith(1, {
type: "start",
data: {
device: devices.LIVOLO,
options: settings.getDevice(devices.LIVOLO.ieeeAddr),
state: {},
deviceExposesChanged: expect.any(Function),
},
});
await controller.stop();
expect(onEventSpy).toHaveBeenCalledTimes(4);
expect(deviceOnEventSpy).toHaveBeenCalledTimes(2);
expect(deviceOnEventSpy).toHaveBeenNthCalledWith(
2,
"stop",
{},
devices.LIVOLO,
settings.getDevice(devices.LIVOLO.ieeeAddr),
{},
{deviceExposesChanged: expect.any(Function)},
);
expect(deviceOnEventSpy).toHaveBeenNthCalledWith(2, {
type: "stop",
data: {
ieeeAddr: devices.LIVOLO.ieeeAddr,
},
});
});
it("calls on device events", async () => {
@@ -97,73 +92,87 @@ describe("Extension: OnEvent", () => {
await mockZHEvents.deviceAnnounce({device: devices.LIVOLO});
await flushPromises();
expect(deviceOnEventSpy).toHaveBeenCalledTimes(1);
expect(deviceOnEventSpy).toHaveBeenNthCalledWith(
1,
"deviceAnnounce",
{},
devices.LIVOLO,
settings.getDevice(devices.LIVOLO.ieeeAddr),
{},
{deviceExposesChanged: expect.any(Function)},
);
// Should always call with 'start' event first
expect(deviceOnEventSpy).toHaveBeenCalledTimes(2);
expect(deviceOnEventSpy).toHaveBeenNthCalledWith(1, {
type: "start",
data: {
device: devices.LIVOLO,
options: settings.getDevice(devices.LIVOLO.ieeeAddr),
state: {},
deviceExposesChanged: expect.any(Function),
},
});
// Device announce
expect(deviceOnEventSpy).toHaveBeenCalledTimes(2);
expect(deviceOnEventSpy).toHaveBeenNthCalledWith(2, {
type: "deviceAnnounce",
data: {
device: devices.LIVOLO,
options: settings.getDevice(devices.LIVOLO.ieeeAddr),
state: {},
deviceExposesChanged: expect.any(Function),
},
});
// Check deviceExposesChanged()
const emitExposesAndDevicesChangedSpy = vi.spyOn(
// @ts-expect-error protected
controller.getExtension("OnEvent")!.eventBus,
"emitExposesAndDevicesChanged",
);
deviceOnEventSpy.mock.calls[0][5]!.deviceExposesChanged();
assert(deviceOnEventSpy.mock.calls[0][0]!.type === "start");
deviceOnEventSpy.mock.calls[0][0]!.data.deviceExposesChanged();
expect(emitExposesAndDevicesChangedSpy).toHaveBeenCalledTimes(1);
expect(emitExposesAndDevicesChangedSpy).toHaveBeenCalledWith(getZ2MDevice(devices.LIVOLO));
// Call `stop` when device leaves
await mockZHEvents.deviceLeave({ieeeAddr: devices.LIVOLO.ieeeAddr});
await flushPromises();
expect(deviceOnEventSpy).toHaveBeenCalledTimes(2);
expect(deviceOnEventSpy).toHaveBeenNthCalledWith(
2,
"stop",
{},
devices.LIVOLO,
settings.getDevice(devices.LIVOLO.ieeeAddr),
{},
{deviceExposesChanged: expect.any(Function)},
);
});
it("calls on device message", async () => {
clearOnEventSpies();
await mockZHEvents.message({
type: "attributeReport",
device: devices.LIVOLO,
endpoint: devices.LIVOLO.endpoints[0],
linkquality: 213,
groupID: 0,
cluster: "genBasic",
data: {zclVersion: 8},
meta: {zclTransactionSequenceNumber: 1, manufacturerCode: devices.LIVOLO.manufacturerID},
});
await flushPromises();
expect(deviceOnEventSpy).toHaveBeenCalledTimes(1);
expect(deviceOnEventSpy).toHaveBeenCalledWith(
"message",
{
type: "attributeReport",
endpoint: devices.LIVOLO.endpoints[0],
cluster: "genBasic",
data: {zclVersion: 8},
meta: {zclTransactionSequenceNumber: 1, manufacturerCode: devices.LIVOLO.manufacturerID},
expect(deviceOnEventSpy).toHaveBeenCalledTimes(3);
expect(deviceOnEventSpy).toHaveBeenNthCalledWith(3, {
type: "stop",
data: {
ieeeAddr: devices.LIVOLO.ieeeAddr,
},
devices.LIVOLO,
settings.getDevice(devices.LIVOLO.ieeeAddr),
{},
{deviceExposesChanged: expect.any(Function)},
);
});
// Call `stop` when device is removed
// @ts-expect-error private
controller.eventBus.emitEntityRemoved({entity: getZ2MDevice(devices.LIVOLO)});
await flushPromises();
expect(deviceOnEventSpy).toHaveBeenCalledTimes(4);
expect(deviceOnEventSpy).toHaveBeenNthCalledWith(4, {
type: "stop",
data: {
ieeeAddr: devices.LIVOLO.ieeeAddr,
},
});
// Device interview, should call with 'start' first as 'stop' was called
await mockZHEvents.deviceInterview({device: devices.LIVOLO, status: "started"});
await flushPromises();
expect(deviceOnEventSpy).toHaveBeenCalledTimes(6);
expect(deviceOnEventSpy).toHaveBeenNthCalledWith(5, {
type: "start",
data: {
device: devices.LIVOLO,
options: settings.getDevice(devices.LIVOLO.ieeeAddr),
state: {},
deviceExposesChanged: expect.any(Function),
},
});
expect(deviceOnEventSpy).toHaveBeenNthCalledWith(6, {
type: "deviceInterview",
data: {
device: devices.LIVOLO,
options: settings.getDevice(devices.LIVOLO.ieeeAddr),
state: {},
deviceExposesChanged: expect.any(Function),
status: "started",
},
});
});
it("does not block startup on failure", async () => {
+24 -29
View File
@@ -1,3 +1,5 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
@@ -6,11 +8,8 @@ import {flushPromises} from "../mocks/utils";
import {devices, events as mockZHEvents} from "../mocks/zigbeeHerdsman";
import path from "node:path";
import stringify from "json-stable-stringify-without-jsonify";
import * as zhc from "zigbee-herdsman-converters";
import {Controller} from "../../lib/controller";
import OTAUpdate from "../../lib/extension/otaUpdate";
import * as settings from "../../lib/util/settings";
@@ -69,7 +68,6 @@ describe("Extension: OTAUpdate", () => {
devices.bulb.mockClear();
updateSpy.mockClear();
isUpdateAvailableSpy.mockClear();
// @ts-expect-error private
controller.state.clear();
});
@@ -143,9 +141,9 @@ describe("Extension: OTAUpdate", () => {
},
status: "ok",
}),
{retain: false, qos: 0},
{},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), {retain: true, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), {retain: true});
});
it("handles when OTA update fails", async () => {
@@ -161,7 +159,7 @@ describe("Extension: OTAUpdate", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/ota_update/update",
stringify({data: {}, status: "error", error: "Update of 'bulb' failed (Update failed)"}),
{retain: false, qos: 0},
{},
);
});
@@ -178,7 +176,7 @@ describe("Extension: OTAUpdate", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/ota_update/update",
stringify({data: {}, status: "error", error: "Update of 'bulb' failed (No image currently available)"}),
{retain: false, qos: 0},
{},
);
});
@@ -192,7 +190,7 @@ describe("Extension: OTAUpdate", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/ota_update/check",
stringify({data: {id: "bulb", update_available: false}, status: "ok"}),
{retain: false, qos: 0},
{},
);
mockMQTTPublishAsync.mockClear();
@@ -205,7 +203,7 @@ describe("Extension: OTAUpdate", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/ota_update/check",
stringify({data: {id: "bulb", update_available: true}, status: "ok"}),
{retain: false, qos: 0},
{},
);
isUpdateAvailableSpy.mockResolvedValueOnce({available: false, currentFileVersion: 10, otaFileVersion: 10});
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/check/downgrade", "bulb");
@@ -216,7 +214,7 @@ describe("Extension: OTAUpdate", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/ota_update/check",
stringify({data: {id: "bulb", update_available: false}, status: "ok"}),
{retain: false, qos: 0},
{},
);
// @ts-expect-error private
@@ -234,7 +232,7 @@ describe("Extension: OTAUpdate", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/ota_update/check",
stringify({data: {id: "bulb", update_available: true}, status: "ok"}),
{retain: false, qos: 0},
{},
);
device.definition = originalDefinition;
@@ -254,7 +252,7 @@ describe("Extension: OTAUpdate", () => {
status: "error",
error: `Failed to check if update available for 'bulb' (RF signals disturbed because of dogs barking)`,
}),
{retain: false, qos: 0},
{},
);
});
@@ -264,7 +262,7 @@ describe("Extension: OTAUpdate", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/ota_update/check",
stringify({data: {}, status: "error", error: `Device 'not_existing_deviceooo' does not exist`}),
{retain: false, qos: 0},
{},
);
});
@@ -274,7 +272,7 @@ describe("Extension: OTAUpdate", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/ota_update/check",
stringify({data: {}, status: "error", error: `Device 'dimmer_wall_switch' does not support OTA updates`}),
{retain: false, qos: 0},
{},
);
});
@@ -318,7 +316,7 @@ describe("Extension: OTAUpdate", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
`zigbee2mqtt/bridge/response/device/ota_update/${type.replace("/downgrade", "")}`,
stringify({data: {}, status: "error", error: `Update or check for update already in progress for 'bulb'`}),
{retain: false, qos: 0},
{},
);
},
);
@@ -332,7 +330,7 @@ describe("Extension: OTAUpdate", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/response/device/ota_update/update",
stringify({data: {id: "bulb", from: undefined, to: undefined}, status: "ok"}),
{retain: false, qos: 0},
{},
);
});
@@ -485,7 +483,7 @@ describe("Extension: OTAUpdate", () => {
2,
"zigbee2mqtt/bridge/response/device/ota_update/schedule",
stringify({data: {id: "bulb"}, status: "ok"}),
{retain: false, qos: 0},
{},
);
const data = {imageType: 12382};
@@ -519,7 +517,7 @@ describe("Extension: OTAUpdate", () => {
stringify({update: {state: "idle", installed_version: 2, latest_version: 2}}),
{retain: true, qos: 0},
);
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(6, "zigbee2mqtt/bridge/devices", expect.any(String), {retain: true, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(6, "zigbee2mqtt/bridge/devices", expect.any(String), {retain: true});
});
it("schedules and cancels an update when no image available", async () => {
@@ -536,7 +534,7 @@ describe("Extension: OTAUpdate", () => {
2,
"zigbee2mqtt/bridge/response/device/ota_update/schedule",
stringify({data: {id: "bulb"}, status: "ok"}),
{retain: false, qos: 0},
{},
);
const data = {imageType: 12382};
@@ -575,7 +573,7 @@ describe("Extension: OTAUpdate", () => {
2,
"zigbee2mqtt/bridge/response/device/ota_update/schedule",
stringify({data: {id: "bulb"}, status: "ok"}),
{retain: false, qos: 0},
{},
);
const data = {imageType: 12382};
@@ -619,7 +617,7 @@ describe("Extension: OTAUpdate", () => {
stringify({update: {state: "idle", installed_version: 2, latest_version: 2}}),
{retain: true, qos: 0},
);
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(7, "zigbee2mqtt/bridge/devices", expect.any(String), {retain: true, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(7, "zigbee2mqtt/bridge/devices", expect.any(String), {retain: true});
});
it("overwrites current schedule on re-schedule", async () => {
@@ -638,7 +636,7 @@ describe("Extension: OTAUpdate", () => {
2,
"zigbee2mqtt/bridge/response/device/ota_update/schedule",
stringify({data: {id: "bulb"}, status: "ok"}),
{retain: false, qos: 0},
{},
);
mockMQTTEvents.message(`zigbee2mqtt/bridge/request/device/ota_update/${overwriteType}`, "bulb");
@@ -652,7 +650,7 @@ describe("Extension: OTAUpdate", () => {
4,
"zigbee2mqtt/bridge/response/device/ota_update/schedule",
stringify({data: {id: "bulb"}, status: "ok"}),
{retain: false, qos: 0},
{},
);
}
});
@@ -669,7 +667,7 @@ describe("Extension: OTAUpdate", () => {
2,
"zigbee2mqtt/bridge/response/device/ota_update/schedule",
stringify({data: {id: "bulb"}, status: "ok"}),
{retain: false, qos: 0},
{},
);
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/unschedule", "bulb");
@@ -681,7 +679,7 @@ describe("Extension: OTAUpdate", () => {
4,
"zigbee2mqtt/bridge/response/device/ota_update/unschedule",
stringify({data: {id: "bulb"}, status: "ok"}),
{retain: false, qos: 0},
{},
);
});
@@ -749,12 +747,9 @@ describe("Extension: OTAUpdate", () => {
});
it("clear update state on startup", async () => {
// @ts-expect-error private
const device = controller.zigbee.resolveEntity(devices.bulb_color.ieeeAddr);
// @ts-expect-error private
controller.state.set(device, {update: {progress: 100, remaining: 10, state: "updating"}});
await resetExtension();
// @ts-expect-error private
expect(controller.state.get(device)).toStrictEqual({update: {state: "available"}});
});
});
+16 -28
View File
@@ -1,3 +1,5 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
@@ -6,9 +8,7 @@ import {flushPromises} from "../mocks/utils";
import {devices, groups, events as mockZHEvents} from "../mocks/zigbeeHerdsman";
import stringify from "json-stable-stringify-without-jsonify";
import {clearGlobalStore} from "zigbee-herdsman-converters";
import {Controller} from "../../lib/controller";
import {loadTopicGetSetRegex} from "../../lib/extension/publish";
import * as settings from "../../lib/util/settings";
@@ -42,7 +42,6 @@ describe("Extension: Publish", () => {
beforeEach(() => {
data.writeDefaultConfiguration();
// @ts-expect-error private
controller.state.clear();
settings.reRead();
loadTopicGetSetRegex();
@@ -240,7 +239,7 @@ describe("Extension: Publish", () => {
expect(endpoint.command).toHaveBeenCalledWith(
"manuSpecificTuya",
"dataRequest",
{dpValues: [{data: [1], datatype: 1, dp: 2}], seq: expect.any(Number)},
{dpValues: [{data: Buffer.from([1]), datatype: 1, dp: 2}], seq: expect.any(Number)},
{disableDefaultResponse: true},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/TS0601_switch", stringify({state_l2: "ON"}), {retain: false, qos: 0});
@@ -257,19 +256,19 @@ describe("Extension: Publish", () => {
expect(endpoint.command).toHaveBeenCalledWith(
"manuSpecificTuya",
"dataRequest",
{dpValues: [{data: [0], datatype: 4, dp: 1}], seq: expect.any(Number)},
{dpValues: [{data: Buffer.from([0]), datatype: 4, dp: 1}], seq: expect.any(Number)},
{disableDefaultResponse: true},
);
expect(endpoint.command).toHaveBeenCalledWith(
"manuSpecificTuya",
"dataRequest",
{dpValues: [{data: [1], datatype: 1, dp: 102}], seq: expect.any(Number)},
{dpValues: [{data: Buffer.from([1]), datatype: 1, dp: 102}], seq: expect.any(Number)},
{disableDefaultResponse: true},
);
expect(endpoint.command).toHaveBeenCalledWith(
"manuSpecificTuya",
"dataRequest",
{dpValues: [{data: [0], datatype: 1, dp: 101}], seq: expect.any(Number)},
{dpValues: [{data: Buffer.from([0]), datatype: 1, dp: 101}], seq: expect.any(Number)},
{disableDefaultResponse: true},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
@@ -426,7 +425,7 @@ describe("Extension: Publish", () => {
expect(group.command).toHaveBeenCalledWith(
"manuSpecificTuya",
"dataRequest",
{dpValues: [{data: [1], datatype: 1, dp: 7}], seq: expect.any(Number)},
{dpValues: [{data: Buffer.from([1]), datatype: 1, dp: 7}], seq: expect.any(Number)},
{disableDefaultResponse: true},
);
});
@@ -967,20 +966,18 @@ describe("Extension: Publish", () => {
const payload = {state: "ON", brightness: 20, color_temp: 200, transition: 20};
await mockMQTTEvents.message("zigbee2mqtt/bulb/set", stringify(payload));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(3);
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(["genLevelCtrl", "stop", {}, {}]); // unfreeze
expect(endpoint.command.mock.calls[2]).toEqual(["lightingColorCtrl", "moveToColorTemp", {colortemp: 200, transtime: 200}, {}]);
expect(endpoint.command.mock.calls[1]).toEqual(["lightingColorCtrl", "moveToColorTemp", {colortemp: 200, transtime: 200}, {}]);
});
it("Should use transition only once when setting brightness and color temperature for group which contains TRADFRI", async () => {
const group = groups.group_with_tradfri;
await mockMQTTEvents.message("zigbee2mqtt/group_with_tradfri/set", stringify({state: "ON", transition: 60, brightness: 20, color_temp: 400}));
await flushPromises();
expect(group.command).toHaveBeenCalledTimes(3);
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(["genLevelCtrl", "stop", {}, {}]); // unfreeze
expect(group.command.mock.calls[2]).toEqual(["lightingColorCtrl", "moveToColorTemp", {colortemp: 400, transtime: 600}, {}]);
expect(group.command.mock.calls[1]).toEqual(["lightingColorCtrl", "moveToColorTemp", {colortemp: 400, transtime: 600}, {}]);
});
it("Message transition should overrule options transition", async () => {
@@ -1077,7 +1074,7 @@ describe("Extension: Publish", () => {
expect(endpoint.command.mock.calls[0]).toEqual([
"lightingColorCtrl",
"enhancedMoveToHueAndSaturation",
{direction: 0, enhancehue: 45510, saturation: 127, transtime: 0},
{enhancehue: 45510, saturation: 127, transtime: 0},
{},
]);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
@@ -1259,11 +1256,8 @@ describe("Extension: Publish", () => {
it("Home Assistant: should not set state when color temperature is also set and device is already on", async () => {
settings.set(["homeassistant"], {enabled: true});
// @ts-expect-error private
const device = controller.zigbee.resolveEntity(devices.bulb_color.ieeeAddr)!;
// @ts-expect-error private
controller.state.remove(devices.bulb_color.ieeeAddr);
// @ts-expect-error private
controller.state.set(device, {state: "ON"});
const endpoint = device.zh.getEndpoint(1)!;
const payload = {state: "ON", color_temp: 100};
@@ -1278,11 +1272,8 @@ describe("Extension: Publish", () => {
it("Home Assistant: should set state when color temperature is also set and device is off", async () => {
settings.set(["homeassistant"], {enabled: true});
// @ts-expect-error private
const device = controller.zigbee.resolveEntity(devices.bulb_color.ieeeAddr)!;
// @ts-expect-error private
controller.state.remove(devices.bulb_color.ieeeAddr);
// @ts-expect-error private
controller.state.set(device, {state: "OFF"});
const endpoint = device.zh.getEndpoint(1)!;
const payload = {state: "ON", color_temp: 100};
@@ -1301,11 +1292,8 @@ describe("Extension: Publish", () => {
it("Home Assistant: should not set state when color is also set", async () => {
settings.set(["homeassistant"], {enabled: true});
// @ts-expect-error private
const device = controller.zigbee.resolveEntity(devices.bulb_color.ieeeAddr)!;
// @ts-expect-error private
controller.state.remove(devices.bulb_color.ieeeAddr);
// @ts-expect-error private
controller.state.set(device, {state: "ON"});
const endpoint = device.zh.getEndpoint(1)!;
const payload = {state: "ON", color: {x: 0.41, y: 0.25}};
@@ -1454,7 +1442,7 @@ 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}, {});
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});
@@ -1470,7 +1458,7 @@ describe("Extension: Publish", () => {
expect(endpoint.command).toHaveBeenCalledWith(
"manuSpecificTuya",
"dataRequest",
{dpValues: [{data: [1], datatype: 1, dp: 3}], seq: expect.any(Number)},
{dpValues: [{data: Buffer.from([1]), datatype: 1, dp: 3}], seq: expect.any(Number)},
{disableDefaultResponse: true},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
@@ -1601,8 +1589,8 @@ describe("Extension: Publish", () => {
await flushPromises();
expect(group.command).toHaveBeenCalledTimes(1);
expect(group.command).toHaveBeenCalledWith("genScenes", "store", {groupid: 15071, sceneid: 1}, {});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), {retain: true, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/groups", expect.any(String), {retain: true, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), {retain: true});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/groups", expect.any(String), {retain: true});
await mockMQTTEvents.message("zigbee2mqtt/bulb_color_2/set", stringify({state: "ON", brightness: 250, color_temp: 20}));
await mockMQTTEvents.message("zigbee2mqtt/bulb_2/set", stringify({state: "ON", brightness: 110}));
+17 -11
View File
@@ -1,3 +1,5 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
@@ -6,7 +8,6 @@ import {flushPromises} from "../mocks/utils";
import {devices, events as mockZHEvents} from "../mocks/zigbeeHerdsman";
import stringify from "json-stable-stringify-without-jsonify";
import {Controller} from "../../lib/controller";
import * as settings from "../../lib/util/settings";
@@ -24,7 +25,6 @@ describe("Extension: Receive", () => {
});
beforeEach(() => {
// @ts-expect-error private
controller.state.clear();
data.writeDefaultConfiguration();
settings.reRead();
@@ -183,10 +183,11 @@ describe("Extension: Receive", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
vi.runOnlyPendingTimers();
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/weather_sensor");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({temperature: 0.08, humidity: 0.01, pressure: 2});
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 1, retain: false});
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual("zigbee2mqtt/bridge/health");
});
it("Should debounce and retain messages when set via device_options", async () => {
@@ -229,10 +230,11 @@ describe("Extension: Receive", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
vi.runOnlyPendingTimers();
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/weather_sensor");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({temperature: 0.08, humidity: 0.01, pressure: 2});
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 1, retain: true});
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual("zigbee2mqtt/bridge/health");
});
it("Should debounce messages only with the same payload values for provided debounce_ignore keys", async () => {
@@ -281,8 +283,9 @@ describe("Extension: Receive", () => {
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({temperature: 0.08, pressure: 2});
vi.runOnlyPendingTimers();
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(3);
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toStrictEqual({temperature: 0.07, pressure: 2, humidity: 0.03});
expect(mockMQTTPublishAsync.mock.calls[2][0]).toStrictEqual("zigbee2mqtt/bridge/health");
});
it("Should NOT publish old messages from State cache during debouncing", async () => {
@@ -322,9 +325,10 @@ describe("Extension: Receive", () => {
vi.runOnlyPendingTimers();
// Test that only one MQTT is sent out and test its values.
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1 + 1 /* "bridge/health" */);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/weather_sensor");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({temperature: 0.08, humidity: 0.01, pressure: 2});
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual("zigbee2mqtt/bridge/health");
// Send another Zigbee message...
await mockZHEvents.message({
@@ -335,7 +339,6 @@ describe("Extension: Receive", () => {
type: "attributeReport",
linkquality: 10,
});
// @ts-expect-error private
const realDevice = controller.zigbee.resolveEntity(device);
// Trigger asynchronous event while device is "debouncing" to trigger Message to be sent out from State cache.
@@ -343,12 +346,14 @@ describe("Extension: Receive", () => {
vi.runOnlyPendingTimers();
// Total of 3 messages should have triggered.
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(3);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(5);
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual("zigbee2mqtt/bridge/health");
// Test that message pushed by asynchronous message contains NEW measurement and not old.
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toStrictEqual({temperature: 0.09, humidity: 0.01, pressure: 2});
// Test that messages after debouncing contains NEW measurement and not old.
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[2][1])).toStrictEqual({temperature: 0.09, humidity: 0.01, pressure: 2});
// Test that messages after debouncing contains NEW measurement and not old.
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[3][1])).toStrictEqual({temperature: 0.09, humidity: 0.01, pressure: 2});
expect(mockMQTTPublishAsync.mock.calls[4][0]).toStrictEqual("zigbee2mqtt/bridge/health");
});
it("Should throttle multiple messages from spamming devices", async () => {
@@ -439,9 +444,10 @@ describe("Extension: Receive", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb/set", stringify({state: "ON"}));
await flushPromises();
vi.runOnlyPendingTimers();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2 + 1);
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({state: "ON"});
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toStrictEqual({state: "ON"});
expect(mockMQTTPublishAsync.mock.calls[2][0]).toStrictEqual("zigbee2mqtt/bridge/health");
});
it("Should handle a zigbee message with 1 precision", async () => {
+3 -4
View File
@@ -1,15 +1,14 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import * as data from "./mocks/data";
import type {MockInstance} from "vitest";
import fs from "node:fs";
import {platform} from "node:os";
import path from "node:path";
import {rimrafSync} from "rimraf";
import tmp from "tmp";
import type {MockInstance} from "vitest";
import Transport from "winston-transport";
import logger from "../lib/util/logger";
import * as settings from "../lib/util/settings";
+7
View File
@@ -25,6 +25,13 @@ export const mockMQTTConnectAsync = vi.fn(() => ({
events[type] = handler;
}),
stream: {setMaxListeners: vi.fn()},
options: {
protocolVersion: 5,
protocol: "mqtt",
host: "localhost",
port: 1883,
},
queue: [],
}));
vi.mock("mqtt", () => ({
+3 -4
View File
@@ -1,11 +1,10 @@
import assert from "node:assert";
import type {Mock} from "vitest";
import type {AdapterTypes} from "zigbee-herdsman";
import assert from "node:assert";
import {Zcl} from "zigbee-herdsman";
import {InterviewState} from "zigbee-herdsman/dist/controller/model/device";
import {DEFAULT_BIND_GROUP_ID} from "../../lib/util/utils";
import type {EventHandler} from "./utils";
type ZHConfiguredReporting = {
@@ -506,7 +505,7 @@ export const groups = {
thermostat_group: new Group(12, [TS0601_thermostat.endpoints[0]]),
group_with_switch: new Group(14, [ZNCZ02LM.endpoints[0], bulb_2.endpoints[0]]),
gledopto_group: new Group(21, [GLEDOPTO_2ID.endpoints[3]]),
default_bind_group: new Group(901, []),
default_bind_group: new Group(DEFAULT_BIND_GROUP_ID, []),
ha_discovery_group: new Group(9, [bulb_color_2.endpoints[0], bulb_2.endpoints[0], QBKG03LM.endpoints[2]]),
hue_twilight_group: new Group(19, [hue_twilight.endpoints[1]]),
};
+132 -15
View File
@@ -1,11 +1,11 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import * as data from "./mocks/data";
import {rmSync, writeFileSync} from "node:fs";
import {join} from "node:path";
import type {IncomingMessage, OutgoingHttpHeader, OutgoingHttpHeaders, RequestListener, Server, ServerResponse} from "node:http";
import {rmSync} from "node:fs";
import type {findAllDevices} from "zigbee-herdsman/dist/adapter/adapterDiscovery";
import {onboard} from "../lib/util/onboarding";
import * as settings from "../lib/util/settings";
@@ -67,6 +67,7 @@ const SETTINGS_MINIMAL_DEFAULTS = {
homeassistant: {
enabled: settings.defaults.homeassistant!.enabled,
},
onboarding: true,
};
const SAMPLE_SETTINGS_INIT = {
@@ -123,6 +124,7 @@ const SAMPLE_SETTINGS_SAVE = {
homeassistant: {
enabled: true,
},
onboarding: true,
};
const SAMPLE_SETTINGS_SAVE_PARAMS = {
@@ -186,10 +188,10 @@ describe("Onboarding", () => {
expectWriteMinimal: boolean,
expectFailure: boolean,
): Promise<[getHtml: string, postHtml: string]> => {
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
// biome-ignore lint/suspicious/noExplicitAny: ignore
const reqDataListener = vi.fn<(chunk: any) => void>();
const reqEndListener = vi.fn<() => void>();
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
// biome-ignore lint/suspicious/noExplicitAny: ignore
const resEnd = vi.fn<(chunk: any | (() => void), cb?: () => void) => ServerResponse<IncomingMessage>>(
// @ts-expect-error return not used
(chunk, cb) => {
@@ -297,7 +299,7 @@ describe("Onboarding", () => {
};
const runFailure = async (): Promise<string> => {
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
// biome-ignore lint/suspicious/noExplicitAny: ignore
const resEnd = vi.fn<(chunk: any | (() => void), cb?: () => void) => ServerResponse<IncomingMessage>>(
// @ts-expect-error return not used
(chunk, cb) => {
@@ -441,7 +443,7 @@ describe("Onboarding", () => {
expect(postHtml).toContain("You can close this page");
});
it("rerun onboard via ENV and sets given settings", async () => {
it("reruns onboard via ENV and sets given settings", async () => {
// data.removeConfiguration();
process.env.Z2M_ONBOARD_FORCE_RUN = "1";
@@ -466,6 +468,30 @@ describe("Onboarding", () => {
expect(postHtml).toContain('<a href="http://localhost:8080/">');
});
it("reruns onboard on failed start", async () => {
// data.removeConfiguration();
settings.setOnboarding(true);
let p;
const [getHtml, postHtml] = await new Promise<[string, string]>((resolve, reject) => {
mockHttpOnListen.mockImplementationOnce(async () => {
try {
resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE_PARAMS, false, false));
} catch (error) {
reject(error);
}
});
p = onboard();
});
await expect(p).resolves.toStrictEqual(true);
expect(data.read()).toStrictEqual(SAMPLE_SETTINGS_SAVE);
expect(getHtml).toContain("No device found");
expect(getHtml).toContain("generate_network");
expect(postHtml).toContain('<a href="http://localhost:8080/">');
});
it("sets given settings - no frontend redirect", async () => {
data.removeConfiguration();
@@ -579,7 +605,7 @@ describe("Onboarding", () => {
});
await expect(p).resolves.toStrictEqual(false);
expect(data.read()).toStrictEqual(SAMPLE_SETTINGS_INIT);
expect(data.read()).toStrictEqual(Object.assign({}, SAMPLE_SETTINGS_INIT, {onboarding: true}));
expect(getHtml).toContain("No device found");
expect(postHtml).toContain("adapter must be equal to one of the allowed values");
});
@@ -622,11 +648,14 @@ describe("Onboarding", () => {
const p = onboard();
await expect(p).resolves.toStrictEqual(true);
expect(data.read()).toStrictEqual(
Object.assign({}, SETTINGS_MINIMAL_DEFAULTS, {
mqtt: {server: process.env.ZIGBEE2MQTT_CONFIG_MQTT_SERVER, base_topic: SETTINGS_MINIMAL_DEFAULTS.mqtt.base_topic},
}),
);
const expected = Object.assign({}, SETTINGS_MINIMAL_DEFAULTS, {
mqtt: {server: process.env.ZIGBEE2MQTT_CONFIG_MQTT_SERVER, base_topic: SETTINGS_MINIMAL_DEFAULTS.mqtt.base_topic},
});
// @ts-expect-error mock
delete expected.onboarding;
expect(data.read()).toStrictEqual(expected);
});
it("handles configuring onboarding with config ENV overrides", async () => {
@@ -724,7 +753,13 @@ describe("Onboarding", () => {
});
it("handles validation failure", async () => {
settings.set(["serial", "adapter"], "emberz");
const reReadSpy = vi.spyOn(settings, "reRead");
// set after onboarding server is done to reach bottom code path
reReadSpy.mockImplementationOnce(() => {
settings.set(["serial", "adapter"], "emberz");
settings.reRead();
});
let p;
const getHtml = await new Promise<string>((resolve, reject) => {
@@ -741,6 +776,88 @@ describe("Onboarding", () => {
await expect(p).resolves.toStrictEqual(false);
expect(getHtml).toContain("adapter must be equal to one of the allowed values");
reReadSpy.mockRestore();
});
it("handles non-required validation failure before applying envs", async () => {
settings.set(["serial"], "/dev/ttyUSB0");
let p;
const getHtml = await new Promise<string>((resolve, reject) => {
mockHttpOnListen.mockImplementationOnce(async () => {
try {
resolve(await runFailure());
} catch (error) {
reject(error);
}
});
p = onboard();
});
await expect(p).resolves.toStrictEqual(false);
expect(getHtml).toContain("serial must be object");
});
it("handles invalid yaml file", async () => {
settings.testing.clear();
const configFile = join(data.mockDir, "configuration.yaml");
writeFileSync(
configFile,
`
good: 9
\t wrong
`,
);
let p;
const getHtml = await new Promise<string>((resolve, reject) => {
mockHttpOnListen.mockImplementationOnce(async () => {
try {
resolve(await runFailure());
} catch (error) {
reject(error);
}
});
p = onboard();
});
await expect(p).resolves.toStrictEqual(false);
expect(getHtml).toContain("Your configuration file");
expect(getHtml).toContain("is invalid");
data.removeConfiguration();
});
it("handles error while loading yaml file", async () => {
settings.testing.clear();
const configFile = join(data.mockDir, "configuration.yaml");
writeFileSync(configFile, "badfile");
let p;
const getHtml = await new Promise<string>((resolve, reject) => {
mockHttpOnListen.mockImplementationOnce(async () => {
try {
resolve(await runFailure());
} catch (error) {
reject(error);
}
});
p = onboard();
});
await expect(p).resolves.toStrictEqual(false);
expect(getHtml).toContain("AssertionError");
expect(getHtml).toContain("expected to be an object");
data.removeConfiguration();
});
it("handles creating data path", async () => {
+2
View File
@@ -1,3 +1,5 @@
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import {mockLogger} from "./mocks/logger";
import {initSdNotify} from "../lib/util/sd-notify";
+86 -58
View File
@@ -1,8 +1,8 @@
// side-effect ensures using mock paths
import {beforeEach, describe, expect, it} from "vitest";
import "./mocks/data";
import fs from "node:fs";
import yaml from "js-yaml";
import objectAssignDeep from "object-assign-deep";
@@ -165,6 +165,43 @@ describe("Settings", () => {
writeAndCheck();
});
it("Should apply Home Assistant environment variables", () => {
// should be kept in sync with envs in https://github.com/zigbee2mqtt/hassio-zigbee2mqtt/blob/master/common/rootfs/docker-entrypoint.sh
process.env.ZIGBEE2MQTT_CONFIG_FRONTEND_ENABLED = "true";
process.env.ZIGBEE2MQTT_CONFIG_FRONTEND_PORT = "8099";
process.env.ZIGBEE2MQTT_CONFIG_HOMEASSISTANT_ENABLED = "true";
process.env.ZIGBEE2MQTT_CONFIG_SERIAL_PORT =
"/dev/serial/by-id/usb-ITead_Sonoff_Zigbee_3.0_USB_Dongle_Plus_48be7a7468d8ed11bfea786ff2c613ac-if00-port0";
process.env.ZIGBEE2MQTT_CONFIG_MQTT_SERVER = "mqtt://core-mosquitto:1883";
process.env.ZIGBEE2MQTT_CONFIG_MQTT_USER = "addons";
process.env.ZIGBEE2MQTT_CONFIG_MQTT_PASSWORD = "7RbR4NBSskS8TKG4ugzdRilXw6TPQ4NKFs259j2DxeultOFtl5JwciNZFd6feT5o";
write(configurationFile, {});
// @ts-expect-error workaround
const expected = objectAssignDeep.noMutate({}, settings.testing.defaults);
expected.frontend.enabled = true;
expected.frontend.port = 8099;
expected.homeassistant.enabled = true;
expected.serial.port = "/dev/serial/by-id/usb-ITead_Sonoff_Zigbee_3.0_USB_Dongle_Plus_48be7a7468d8ed11bfea786ff2c613ac-if00-port0";
expected.mqtt.server = "mqtt://core-mosquitto:1883";
expected.mqtt.user = "addons";
expected.mqtt.password = "7RbR4NBSskS8TKG4ugzdRilXw6TPQ4NKFs259j2DxeultOFtl5JwciNZFd6feT5o";
expected.devices = {};
expected.groups = {};
const writeAndCheck = (): void => {
expect(settings.write()); // trigger writing of ENVs
expect(settings.validate()).toStrictEqual([]);
expect(settings.get()).toStrictEqual(expected);
};
// Write trice to ensure there are no side effects.
writeAndCheck();
writeAndCheck();
writeAndCheck();
});
it("Should write environment variables as overrides to configuration.yaml, not in the ref file", () => {
write(secretFile, {password: "password-in-secret-file"}, false);
write(configurationFile, {mqtt: {password: "!secret password", server: "server"}});
@@ -275,6 +312,9 @@ describe("Settings", () => {
base_topic: "zigbee2mqtt",
include_device_information: false,
maximum_packet_size: 1048576,
keepalive: 60,
reject_unauthorized: true,
version: 4,
force_disable_retain: false,
password: "mysecretpassword",
server: "my.mqtt.server",
@@ -320,6 +360,9 @@ describe("Settings", () => {
base_topic: "zigbee2mqtt",
include_device_information: false,
maximum_packet_size: 1048576,
keepalive: 60,
reject_unauthorized: true,
version: 4,
force_disable_retain: false,
password: "mysecretpassword",
server: "my.mqtt.server",
@@ -506,7 +549,7 @@ describe("Settings", () => {
it("Should read groups", () => {
const content = {
groups: {
1: {
"1": {
friendly_name: "123",
},
},
@@ -515,18 +558,17 @@ describe("Settings", () => {
write(configurationFile, content);
const group = settings.getGroup("1");
const expected = {
expect(group).toStrictEqual({
ID: 1,
friendly_name: "123",
};
expect(group).toStrictEqual(expected);
});
});
it("Throw if removing non-existing group", () => {
const content = {
groups: {
1: {
"1": {
friendly_name: "123",
},
},
@@ -543,7 +585,7 @@ describe("Settings", () => {
};
const contentGroups = {
1: {
"1": {
friendly_name: "123",
},
};
@@ -552,12 +594,11 @@ describe("Settings", () => {
write(groupsFile, contentGroups);
const group = settings.getGroup("1");
const expected = {
expect(group).toStrictEqual({
ID: 1,
friendly_name: "123",
};
expect(group).toStrictEqual(expected);
});
});
it("Combine everything! groups and devices from separate file :)", () => {
@@ -567,9 +608,8 @@ describe("Settings", () => {
};
const contentGroups = {
1: {
"1": {
friendly_name: "123",
devices: [],
},
};
write(configurationFile, contentConfiguration);
@@ -586,44 +626,48 @@ describe("Settings", () => {
expect(read(configurationFile)).toStrictEqual(expectedConfiguration);
const expectedDevice = {
expect(read(devicesFile)).toStrictEqual({
"0x1234": {
friendly_name: "0x1234",
},
};
expect(read(devicesFile)).toStrictEqual(expectedDevice);
});
const group = settings.getGroup("1");
const expectedGroup = {
expect(group).toStrictEqual({
ID: 1,
friendly_name: "123",
devices: [],
};
expect(group).toStrictEqual(expectedGroup);
});
expect(read(configurationFile)).toStrictEqual(expectedConfiguration);
const expectedDevice2 = {
expect(settings.getDevice("0x1234")).toStrictEqual({
ID: "0x1234",
friendly_name: "0x1234",
};
expect(settings.getDevice("0x1234")).toStrictEqual(expectedDevice2);
});
});
it("Should add groups", () => {
write(configurationFile, {});
settings.addGroup("test123");
const expected = {
1: {
expect(settings.get().groups).toStrictEqual({
"1": {
friendly_name: "test123",
},
};
});
expect(settings.get().groups).toStrictEqual(expected);
settings.addGroup("test456", "");
expect(settings.get().groups).toStrictEqual({
"1": {
friendly_name: "test123",
},
"2": {
friendly_name: "test456",
},
});
});
it("Should allow username without password", () => {
@@ -637,6 +681,9 @@ describe("Settings", () => {
base_topic: "zigbee2mqtt",
include_device_information: false,
maximum_packet_size: 1048576,
keepalive: 60,
reject_unauthorized: true,
version: 4,
force_disable_retain: false,
server: "my.mqtt.server",
user: "myusername",
@@ -650,7 +697,7 @@ describe("Settings", () => {
settings.addGroup("test123", "123");
const expected = {
123: {
"123": {
friendly_name: "test123",
},
};
@@ -682,7 +729,7 @@ describe("Settings", () => {
settings.addGroup("test123");
}).toThrow(`friendly_name 'test123' is already in use`);
const expected = {
1: {
"1": {
friendly_name: "test123",
},
};
@@ -698,7 +745,7 @@ describe("Settings", () => {
settings.addGroup("test_id_123", "123");
}).toThrow(new Error("Group ID '123' is already in use"));
const expected = {
123: {
"123": {
friendly_name: "test123",
},
};
@@ -800,25 +847,6 @@ describe("Settings", () => {
expect(settings.get().blocklist).toStrictEqual(["0x123", "0x1234"]);
});
it("Should throw error when yaml file is invalid", () => {
fs.writeFileSync(
configurationFile,
`
good: 9
\t wrong
`,
);
settings.testing.clear();
const error = `Your YAML file: '${configurationFile}' is invalid (use https://jsonformatter.org/yaml-validator to find and fix the issue)`;
expect(settings.validate()).toEqual(expect.arrayContaining([error]));
});
it("Should throw error when yaml file does not exist", () => {
settings.testing.clear();
expect(settings.validate()[0]).toContain("ENOENT: no such file or directory, open ");
});
it("Configuration shouldnt be valid when invalid QOS value is used", () => {
write(configurationFile, {
...minimalConfig,
@@ -827,7 +855,7 @@ describe("Settings", () => {
settings.reRead();
const error = `QOS for 'myname' not valid, should be 0, 1 or 2 got 3`;
const error = "devices/0x0017880104e45519/qos must be equal to one of the allowed values";
expect(settings.validate()).toEqual(expect.arrayContaining([error]));
});
@@ -835,7 +863,7 @@ describe("Settings", () => {
write(configurationFile, {
...minimalConfig,
devices: {"0x0017880104e45519": {friendly_name: "myname", retain: false}},
groups: {1: {friendly_name: "myname", retain: false}},
groups: {"1": {friendly_name: "myname", retain: false}},
});
settings.reRead();
@@ -924,7 +952,7 @@ describe("Settings", () => {
expect(settings.validate()).toEqual(expect.arrayContaining([error]));
});
it("Configuration shouldnt be valid when duplicate friendly_name are used", async () => {
it("Configuration shouldnt be valid when duplicate friendly_name are used", () => {
write(configurationFile, {
devices: {
"0x0017880104e45519": {friendly_name: "myname", retain: false},
@@ -939,7 +967,7 @@ describe("Settings", () => {
}).toThrowError(`friendly_name 'myname' is already in use`);
});
it("Should throw when removing device which doesnt exist", async () => {
it("Should throw when removing device which doesnt exist", () => {
write(configurationFile, {
devices: {
"0x0017880104e45519": {friendly_name: "myname", retain: false},
@@ -1019,6 +1047,6 @@ describe("Settings", () => {
write(configurationFile, {frontend: {enabled: true}});
settings.reRead();
expect(settings.get().frontend).toStrictEqual({enabled: true, port: 8080, base_url: "/"});
expect(settings.get().frontend).toStrictEqual({enabled: true, package: "zigbee2mqtt-frontend", port: 8080, base_url: "/"});
});
});
+2 -3
View File
@@ -1,10 +1,9 @@
// side-effect ensures using mock paths
// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from "vitest";
import * as data from "./mocks/data";
import {existsSync, readFileSync, rmSync} 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";
+1 -2
View File
@@ -2,9 +2,8 @@
"extends": "../tsconfig",
"include": ["./**/*", "vitest.config.mts"],
"compilerOptions": {
"types": ["vitest/globals"],
"rootDir": "..",
"noEmit": true
},
"references": [{ "path": ".." }]
"references": [{"path": ".."}]
}
+7 -2
View File
@@ -1,8 +1,8 @@
import {exec} from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import utils from "../lib/util/utils";
import {describe, expect, it, vi} from "vitest";
import utils, {assertString} from "../lib/util/utils";
// keep the implementations, just spy
vi.mock("node:child_process", {spy: true});
@@ -78,6 +78,11 @@ describe("Utils", () => {
expect(utils.formatDate(date, "ISO_8601_local").toString().endsWith("+01:00")).toBeTruthy();
});
it("Assert string", () => {
assertString("test", "property");
expect(() => assertString(1, "property")).toThrow("property is not a string, got number (1)");
});
it("Removes null properties from object", () => {
const obj1 = {
ab: 0,
+1
View File
@@ -1,6 +1,7 @@
import {defineConfig} from "vitest/config";
export default defineConfig({
plugins: [],
test: {
globals: true,
onConsoleLog(_log: string, _type: "stdout" | "stderr"): boolean | undefined {
+2 -2
View File
@@ -3,8 +3,8 @@
"allowSyntheticDefaultImports": true,
"module": "NodeNext",
"esModuleInterop": true,
"target": "ES2022",
"lib": ["ES2022"],
"target": "esnext",
"lib": ["esnext"],
"strict": true,
"noImplicitAny": true,
"noImplicitThis": true,
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig",
"compilerOptions": {
"emitDeclarationOnly": true
}
}
+8
View File
@@ -36,6 +36,14 @@ else
fi
fi
echo "Resetting local changes to package.json and pnpm-lock.yaml..."
git checkout --quiet -- package.json pnpm-lock.yaml || true
if ! command -v pnpm >/dev/null 2>&1; then
echo "pnpm not found, preparing with Corepack..."
corepack prepare pnpm@latest --activate
fi
echo "Updating..."
git pull --no-rebase