Compare commits

...
724 Commits
Author SHA1 Message Date
Koen Kanters 1accb8b66b chore(dev): release 2.1.1 (#26135) 2025-02-06 20:33:58 +01:00
Koen Kanters a3682b5fc7 chore: fix update_dep 2025-02-06 20:31:47 +01:00
Koen Kanters cfea8b76e2 Update zigbee-herdsman-converters to 21.31.0 (#26216) 2025-02-06 19:23:29 +00:00
Koen Kanters 9e28818c53 Update zigbee-herdsman-converters to 21.30.0 (#26203) 2025-02-05 20:38:05 +00:00
Koen Kanters bfbbfcda37 Update zigbee-herdsman-converters to 21.29.0 (#26183) 2025-02-04 20:25:50 +00:00
Rohan KapoorandKoen Kanters e4e4f16ec8 fix: Rename internal_temperature to internalTemperature to correct typo (#26114)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2025-02-03 20:09:24 +01:00
Koen Kanters 75c3c5e57f chore: Update release_please.yml 2025-02-02 21:11:51 +01:00
Koen Kanters 6a15328001 Update zigbee-herdsman-converters to 21.28.0 (#26134) 2025-02-02 20:08:59 +00:00
Koen Kanters b960775160 fix(ignore): update dependencies (#26126) 2025-02-02 15:06:40 +01:00
Koen Kanters 41ba32db49 chore: merge master to dev 2025-02-02 15:03:46 +01:00
Koen Kanters 83ff4a7684 chore: Add packageManager to package.json (#26106) 2025-02-02 15:03:26 +01:00
Koen Kanters 98b5a60886 chore: Add packageManager to package.json (#26106) 2025-02-01 22:14:03 +01:00
github-actions[bot] b34723db36 chore: promote to dev 2025-02-01 20:34:39 +00:00
Koen Kanters 5e9d53f0aa chore(dev): release 2.1.0 (#25439) 2025-02-01 21:23:35 +01:00
Koen Kanters 8c7961de1f Update zigbee-herdsman-converters to 21.27.1 (#26102) 2025-02-01 20:01:44 +00:00
Koen Kanters ff466f817f chore: merge master to dev 2025-02-01 20:57:03 +01:00
Koen Kanters c7a7c3d8c2 chore: lint codeql.yaml 2025-02-01 20:56:47 +01:00
Koen Kanters 7540a0d02f chore: merge master to dev 2025-02-01 20:54:57 +01:00
Koen Kanters 33d5f0595d chore: Create codeql.yaml 2025-02-01 20:54:42 +01:00
Koen Kanters c4510d7382 chore: merge master to dev 2025-02-01 20:52:04 +01:00
Koen Kanters e670524241 chore: remove codeql.yaml 2025-02-01 20:51:44 +01:00
Koen Kanters 75f8ed07ed Update zigbee-herdsman to 3.2.5 (#26101) 2025-02-01 19:47:49 +00:00
Koen Kanters 0706a5e039 Update zigbee-herdsman-converters to 21.27.0 (#26071) 2025-01-30 20:15:53 +00:00
Koen Kanters 32329695d0 Update zigbee-herdsman-converters to 21.26.0 (#26043) 2025-01-28 18:37:44 +00:00
Tatham Oddie cdbd55320a fix: Add Home Assistant device class for flow rates (e.g., Sonoff SWV) (#26035) 2025-01-28 19:12:00 +01:00
Koen Kanters 7b199f898e Update zigbee-herdsman-converters to 21.25.0 (#26032) 2025-01-27 20:00:24 +00:00
Rob Coleman 0462b94f5a fix: Only use endpoint-specific states if the device definition uses them (#26019) 2025-01-27 20:54:01 +01:00
Koen Kanters 85460cd5ce fix(ignore): update dependencies (#26005) 2025-01-27 19:44:50 +01:00
Koen Kanters 420a9f2ff6 Update zigbee-herdsman-converters to 21.24.0 (#26000) 2025-01-25 19:04:47 +00:00
gracianodias3 5cf4547fe0 fix: Allow to set throttle via frontend (#25997) 2025-01-25 20:00:09 +01:00
Koen Kanters 2af65cf28e fix: Fix duplicate action publish when advanced.output has attribute (#25963) 2025-01-23 21:01:56 +01:00
Koen Kanters e9f74f0958 Update zigbee-herdsman-converters to 21.23.0 (#25951) 2025-01-22 19:28:32 +00:00
Koen Kanters 2567b4c39e Update zigbee-herdsman to 3.2.4 (#25919) 2025-01-20 21:01:53 +00:00
Koen Kanters ac1671609c Update zigbee-herdsman-converters to 21.22.0 (#25918) 2025-01-20 20:58:50 +00:00
Koen Kanters 8da5fa663d fix(ignore): update dependencies (#25890) 2025-01-19 21:23:36 +01:00
Koen Kanters 3d3c68b54a Update zigbee-herdsman-converters to 21.21.1 (#25872) 2025-01-19 13:04:23 +00:00
Koen Kanters 5e60f1dc8d fix(ignore): Remove adapter auto from schema (#25896) 2025-01-19 12:58:35 +00:00
Koen Kanters a1f74a312a Update zigbee-herdsman to 3.2.3 (#25895) 2025-01-19 12:32:15 +00:00
Koen Kanters b21167b326 Update zigbee-herdsman-converters to 21.20.0 (#25837) 2025-01-15 19:54:49 +00:00
Koen Kanters be17e698a1 Update zigbee-herdsman-converters to 21.19.0 (#25797) 2025-01-13 20:07:23 +00:00
Koen Kanters 7392557af2 Update zigbee-herdsman-converters to 21.18.0 (#25779) 2025-01-12 21:18:15 +00:00
Koen Kanters 360be57079 Update zigbee-herdsman-converters to 21.17.0 (#25770) 2025-01-12 13:43:05 +00:00
Koen Kanters 361f9eef8b Update zigbee-herdsman to 3.2.2 (#25769) 2025-01-12 13:17:20 +00:00
Koen Kanters 5b82d7e4df fix(ignore): update dependencies (#25760) 2025-01-12 14:12:38 +01:00
Koen Kanters 9daeba1f0a fix: Fix Failed to call 'Frontend' 'stop' (TypeError: Cannot read properties of undefined (reading 'close') https://github.com/Koenkk/zigbee2mqtt/issues/25715 2025-01-11 14:38:21 +01:00
Koen Kanters 2d5e7bf9ff fix: Fix Error: write after end error (#25737) 2025-01-11 14:11:26 +01:00
Koen Kanters b5bef45cb2 Update zigbee-herdsman-converters to 21.16.0 (#25735) 2025-01-10 20:15:20 +00:00
NerivecandKoen Kanters f1f73bd464 fix: Long startup time when OnEvent 'start' times out (repeatedly) (#25693)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2025-01-09 22:22:19 +01:00
Nerivec 23b709cee2 fix(ignore): Ensure config example is never out of sync with settings (#25707) 2025-01-09 20:37:01 +01:00
Koen Kanters 31bdf02a72 Update zigbee-herdsman-converters to 21.15.0 (#25690) 2025-01-08 21:27:05 +00:00
Jens-Uwe MagerandNerivec 26ef565c8a feat: Add a settings option to log to console in json format (#25649)
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
2025-01-08 19:22:26 +00:00
Koen Kanters 6f3524b251 fix(ignore): update dependencies (#25535) 2025-01-07 19:08:30 +00:00
Jan BouwhuisandKoen Kanters 318dc04132 feat: Allow Home Assistant OTA Update entity to show progress while updating (#25632)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2025-01-07 20:04:45 +01:00
Koen Kanters bf354f2121 Update zigbee-herdsman-converters to 21.14.0 (#25635) 2025-01-06 21:15:58 +00:00
Koen Kanters 1667ba64ac fix: Remove invalid warn log_level https://github.com/nurikk/zigbee2mqtt-frontend/issues/2369 (#25634) 2025-01-06 21:45:55 +01:00
SteffenBeermann a28a5baf17 fix: Enable use of MQTT username without password (#25611) 2025-01-06 20:19:18 +01:00
Koen Kanters c33946ffc3 Update zigbee-herdsman-converters to 21.13.0 (#25563) 2025-01-05 13:59:54 +00:00
RoboMagus 0d30eb553b fix: Add semver tags to Docker releases (#25530) 2025-01-05 13:03:38 +00:00
Devin Buhl 43a5b24dcc fix: Container: remove modifying capabilities of /usr/bin/node (#25456) 2025-01-05 13:36:57 +01:00
Koen Kanters 3c46cfb2e9 chore: promote to dev 2025-01-03 22:00:11 +01:00
Koen Kanters 7972950c84 chore: fix ci.yaml 2025-01-03 21:59:40 +01:00
Koen Kanters 060ae99cff chore(dev): release 2.0.0 (#24988) 2025-01-03 20:45:37 +00:00
Koen Kanters b7847d8203 Update zigbee-herdsman-converters to 21.12.0 (#25437) 2025-01-03 20:06:03 +00:00
Zitrium31andKoen Kanters c586df8242 feat: Home Assistant: support endpoints for locks (#25359)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2025-01-03 19:58:39 +00:00
Ric Klaren 7cf01e9882 fix(ignore): add cap_net_bind_service=+ep to /usr/bin/node (#25385) (#25424) 2025-01-03 20:55:34 +01:00
Koen Kanters 2d6b00bc69 Update zigbee-herdsman to 3.2.1 (#25416) 2025-01-02 13:22:14 +00:00
Koen Kanters 9055eccae9 Revert "fix: add cap_net_bind_service=+ep to /usr/bin/node (#25385)" (#25415) 2025-01-02 14:15:39 +01:00
Koen Kanters 2046c05fd4 chore: merge master to dev 2025-01-02 13:55:12 +01:00
Koen Kanters 902db5ea5b chore: update stale comment 2025-01-02 13:54:56 +01:00
Kenny Levinsen 60c37c11d0 fix: Home Assistant: use extended device identifiers (#25389) 2025-01-02 12:48:26 +00:00
Ric Klaren 61864f0fb2 fix: Add cap_net_bind_service=+ep to /usr/bin/node in Docker container (#25385) (#25387) 2025-01-02 13:39:38 +01:00
Koen Kanters 240ab43f2d Update zigbee-herdsman-converters to 21.11.0 (#25386) 2024-12-30 20:49:56 +00:00
Koen Kanters 2713125768 fix(ignore): remove illuminance_lux from cached payload https://github.com/Koenkk/zigbee2mqtt/issues/25379 2024-12-30 21:34:42 +01:00
Koen Kanters ed75553603 fix(ignore): Fix availabilty, frontend and homeassistant cannot be disabled via the frontend https://github.com/nurikk/zigbee2mqtt-frontend/issues/2339 2024-12-30 21:23:27 +01:00
Koen Kantersandnurikk e48cd22ab7 Update zigbee2mqtt-frontend to 0.9.4 (#25381)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2024-12-30 16:21:27 +00:00
Koen Kanters 1e7e00e8dd fix(ignore): Improvements for #25299 (#25364) 2024-12-30 14:30:54 +01:00
Koen Kanters 02666e9139 fix(ignore): Fix 2 to 3 migration enabling frontend/availabilty/homeassistant when not set (#25349) 2024-12-30 14:22:18 +01:00
Koen Kanters 47d73eb23a fix(ignore): update dependencies (#25360) 2024-12-29 15:07:18 +01:00
NerivecandKoen Kanters 120a5b43e9 fix(ignore): Use new exports from ZH (#25350)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2024-12-28 14:34:12 +00:00
Koen Kanters e8a5521a4c chore: remove onlythis 2024-12-28 15:25:12 +01:00
Koen Kanters 329b8c9c27 fix: Improve device icon serving (#25299) 2024-12-28 15:07:02 +01:00
Koen Kanters 0e19c227fc Update zigbee-herdsman-converters to 21.10.0 (#25348) 2024-12-28 13:35:19 +00:00
Koen Kantersandnurikk e868a38c09 Update zigbee2mqtt-frontend to 0.9.3 (#25347)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2024-12-28 13:30:31 +00:00
Koen Kanters 301cb331d5 Update zigbee-herdsman to 3.2.0 (#25346) 2024-12-28 13:19:26 +00:00
Ricc68andRicc68 bcec40f84a fix: Fix URL in startup failed bailout message (#25339)
Co-authored-by: Ricc68 <Ricc68@local>
2024-12-28 13:59:50 +01:00
Koen Kanters 62e18a65f3 Update zigbee-herdsman-converters to 21.9.2 (#25316) 2024-12-25 09:38:01 +00:00
Koen Kanters b8e6385e05 chore: add prepack 2024-12-24 22:10:04 +01:00
Koen Kantersandnurikk b65cc0ee74 Update zigbee2mqtt-frontend to 0.9.2 (#25313)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2024-12-24 13:41:24 +00:00
Koen Kanters eaafc7c28a Update zigbee-herdsman-converters to 21.8.0 (#25296) 2024-12-22 20:14:21 +00:00
Koen Kanters 2128e8323c fix(ignore): update dependencies (#25288) 2024-12-22 20:24:36 +01:00
NerivecandKoen Kanters 06f44533af feat: Use Vitest for testing (#25284)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2024-12-22 20:19:45 +01:00
Rohan Kapoor 5d059b7bcb fix: Home Assistant: expose the internal_temperature sensor with the correct device class (#25289) 2024-12-22 19:15:34 +00:00
Koen Kanters 6eae15fb5e Update zigbee-herdsman to 3.1.1 (#25287) 2024-12-22 09:55:36 +00:00
Koen Kanters 2abf55ccf8 fix!: Change default homeassistant.status_topic to homeassistant/status (#25286) 2024-12-21 20:33:44 +00:00
Koen Kanters 803e9060ea Update zigbee-herdsman-converters to 21.7.0 (#25285) 2024-12-21 20:27:27 +00:00
Koen Kanters 7170215e96 fix: Fix log directories not being deleted (#25282) 2024-12-21 16:54:19 +01:00
Nerivec 758ab34747 fix(ignore): import using node: prefix for built-in (#25248) 2024-12-18 22:19:33 +01:00
Koen KantersandNerivec c3b4628a1d fix: Remove oneOf bool/obj from settings schema (#25195)
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
2024-12-17 22:27:55 +01:00
Koen Kanters e7f8de1e94 Update zigbee-herdsman-converters to 21.6.0 (#25253) 2024-12-17 20:57:56 +00:00
NerivecandKoen Kanters 1c75eef16d fix(ignore): remove dep in favor of built-in (#25250)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2024-12-17 21:31:21 +01:00
NerivecandKoen Kanters c88f630299 fix(ignore): overrides (#25246)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2024-12-17 21:30:00 +01:00
Tobias G ef6524b93f fix: Fix dual cover Home Assistant discovery (#25240) 2024-12-17 21:25:30 +01:00
Koen Kanters 9822c75497 Update zigbee-herdsman-converters to 21.5.0 (#25221) 2024-12-15 20:22:53 +00:00
Koen Kanters 56746ded19 fix(ignore): update dependencies (#25210) 2024-12-15 19:44:39 +00:00
Nerivec 0b62aefbb2 fix(ignore): Force rebuild of @serialport/bindings-cpp in Docker (#25202) 2024-12-15 20:38:41 +01:00
Koen Kanters e234f42e1f Update zigbee-herdsman to 3.0.4 (#25196) 2024-12-14 13:36:56 +00:00
Koen Kanters 72428897a7 fix: Fix group with only multi endpoints devices not being controllable (#25156) 2024-12-14 14:24:34 +01:00
Koen Kanters 39c007d211 fix(ignore): Add back Home Assistant legacy action sensor (#25192) 2024-12-14 13:30:33 +01:00
Koen Kanters 5e71cf2cc8 fix(ignore): Update zigbee-herdsman-converters to 21.4.0 (#25191) 2024-12-13 21:28:39 +01:00
Koen Kanters 146b6c3860 fix(ignore): Rebuild serialport, attempt to fix #25092 2024-12-13 21:25:50 +01:00
NerivecandKoen Kanters 2b08c688d3 fix: Proper extended PAN ID from getNetworkParameters (#25174)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2024-12-13 20:58:21 +01:00
Koenkk 0a20e4a455 fix(ignore): update zigbee-herdsman to 3.0.3 2024-12-13 20:58:21 +01:00
Cody Cutrer 08ac795bb3 fix: OpenHAB: Fix rejectattr argument in event value_template (#25179) 2024-12-13 19:41:47 +00:00
Koen Kanters a2027d79e8 fix(ignore): Add back linux/386 (#25187) 2024-12-13 20:35:11 +01:00
Koen Kanters 1dde41f7ea chore: Lint Dockerfile 2024-12-11 22:45:38 +01:00
e23d300fd9 feat: Docker: RISC-V support (#22824)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2024-12-11 22:42:37 +01:00
Koen Kanters 32c341c7f5 fix!: Docker: update Alpine to 3.21, drop x86 support (#25171) 2024-12-11 22:31:24 +01:00
Koen Kanters 9eea58eba4 fix: Fix Cannot read properties of null (reading 'toString') when Home Assistant event entities are enabled https://github.com/Koenkk/zigbee2mqtt/issues/25133 2024-12-11 22:25:54 +01:00
Nerivec ec13ebd3ad fix(ignore): Minor cleanup (#25166) 2024-12-11 21:09:41 +01:00
Koen Kanters 9fad883ae7 chore: merge master to dev 2024-12-10 21:28:20 +01:00
Koen Kanters bc6eccf615 chore: update stale 2024-12-10 21:28:01 +01:00
Koen Kanters 6905c10a05 Update zigbee-herdsman-converters to 21.3.0 (#25143) 2024-12-09 21:36:21 +00:00
Koen Kantersandnurikk 1d176f4358 Update zigbee2mqtt-frontend to 0.9.1 (#25142)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2024-12-09 21:12:11 +00:00
Cody Cutrer 226945cae0 fix: Fix Home Assistant event entities for openHAB (#25140) 2024-12-09 20:34:19 +01:00
7d63a87aad fix!: Remove permit_join_timeout (#25082)
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
Co-authored-by: IIIEII <al@iiieii.ru>
Co-authored-by: Artem Draft <Drafteed@users.noreply.github.com>
2024-12-08 15:49:42 +01:00
Koen Kantersandnurikk f44ea32bca Update zigbee2mqtt-frontend to 0.9.0 (#25128)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2024-12-08 14:46:00 +00:00
Koen Kanters 7803a166d2 fix(ignore): update dependencies (#25120) 2024-12-08 15:02:27 +01:00
Artem DraftandKoen Kanters ba45445544 fix!: Home Assistant: remove duplicate sensor/select for select/number/button entities (#25026)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2024-12-07 21:43:47 +01:00
Nerivec 1a9c79bb2f feat: Typing for MQTT API (#25098) 2024-12-07 20:21:30 +00:00
IIIEII d891320314 fix(ignore): Fixed additional case of calculation of group state when including no… (#25104) 2024-12-07 21:05:55 +01:00
Koen Kanters 639e3bb6fd chore: merge master to dev 2024-12-05 21:11:58 +01:00
Koen Kanters 7dc92d4b6c chore: fix update_dep.yml 2024-12-05 21:11:31 +01:00
Koen Kanters 0687195aca Update zigbee-herdsman to 3.0.1 (#25081) 2024-12-05 20:11:14 +00:00
Koen Kanters 0affca4fe4 chore: merge master to dev 2024-12-05 21:09:05 +01:00
Koen Kanters f8ff89d442 Revert "chore: fix update_dep.yml"
This reverts commit d943b31372.
2024-12-05 21:07:30 +01:00
Koen Kanters 66404cc9f4 chore: merge master to dev 2024-12-05 21:07:00 +01:00
Koen Kanters d943b31372 chore: fix update_dep.yml 2024-12-05 21:06:37 +01:00
alorenteandKoen Kanters b370b80dfb fix: Add more OCI Image Labels (#25074)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2024-12-05 20:12:23 +01:00
Koen Kanters 51f364a1d5 fix(ignore): Fix external converter loading (#25063) 2024-12-05 19:58:55 +01:00
Koen Kanters 53208ae06f Merge remote-tracking branch 'origin/master' into dev 2024-12-04 21:25:27 +01:00
IIIEII 9227e139de fix: Fixed calculation of group state when including non-default endpoints (#25059) 2024-12-04 21:20:47 +01:00
Koen Kanters 71e163fcc3 Update zigbee-herdsman-converters to 21.2.1 (#25054) 2024-12-03 21:18:23 +00:00
Koen Kanters 5b907f2f77 chore: fix update_dep(s) 2024-12-03 22:05:55 +01:00
Koen Kanters acba533397 chore: fix ci commit 2024-12-01 21:34:34 +01:00
Koen Kanters 9bd20afb9c fix(ignore): Update usb discovery discussion link 2024-12-01 20:54:46 +01:00
Koen Kanters 32e10d89f4 fix(ignore): update dependencies (#24999) 2024-12-01 20:48:04 +01:00
Koen Kanters 80b00b6254 chore: fix ci.yml 2024-12-01 20:47:39 +01:00
Koen Kanters c2c6b1991c chore: fix release_please 2024-12-01 20:44:34 +01:00
Koen Kanters 1f56496869 Merge pull request #24492 from Koenkk/feat/2.0.0
Zigbee2MQTT 2.0.0
2024-12-01 20:39:46 +01:00
Koen Kanters b581dc6994 fix(ignore): fix tests 2024-12-01 20:37:13 +01:00
Koen Kanters 1ce45c8c41 fix(ignore): update deps 2024-12-01 20:12:46 +01:00
Koen Kanters a6b36051f0 fix!: Ensure data in response is always empty on error (#24971) 2024-12-01 20:11:34 +01:00
Nerivec d78c6facab fix: Cleanup leftover legacy settings logic (#24947) 2024-12-01 20:11:34 +01:00
Koen Kanters 8d58048f34 chore: improve ci 2024-12-01 20:11:34 +01:00
Koen Kanters f54719fb89 chore: fix provenance 2024-12-01 20:11:34 +01:00
NerivecandKoen Kanters 9907005256 feat: Live loading of external JS (converters/extensions) (#24764)
* feat: Live loading of external JS (converters/extensions)

* Fix imports

* Improve error message on MQTT save

* Handle non-existing base path

* Throw on bad converter

* Add tests

* Fix use of ext conv in network map tests.

* More coverage.

* Dont mock zhc for basics, tests actual live loading

* Update

* feat: Live loading of external JS (converters/extensions)

* Fix imports

* Improve error message on MQTT save

* Handle non-existing base path

* Throw on bad converter

* Add tests

* Fix use of ext conv in network map tests.

* More coverage.

* Dont mock zhc for basics, tests actual live loading

* Update

* Fix rebase

* Fix

* Bump zhc

* pretty

* fix typing

* Cleanup `external_converters` setting remnants.

---------

Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2024-12-01 20:11:34 +01:00
Koen Kanters cc61f67215 fix(ignore): some fixes 2024-12-01 20:11:34 +01:00
Koen Kanters 23d8cea4e2 fix: Cleanup Dockerfile 2024-12-01 20:11:34 +01:00
Koen Kanters 8aef77e57e fix(ignore): Remove external definition type 2024-12-01 20:11:34 +01:00
Nerivec c6958c4991 feat: Automatic settings migration (#24871)
* feat: Automatic settings migration

* Handle failing on unsupported version.

* Handle change not needed.

* Test change not wanted.

* Cleanup.

* Add `external_converters` removal.

* Fix schema export.
2024-12-01 20:11:34 +01:00
Nerivec e052988663 fix: Use built-in async for mqtt (#24786)
* fix: Use async for mqtt.

* Update mocks, fix expects

* Prettier.

* Use appropriate error for publish

* Set max listeners immediately after connect

* Add mqtt v5+ `disconnect` event handler

* Set maximum packet size in `CONNECT` packet

* Fix tests.

* Add setting for maximum packet size.

* Add `reasonString` to disconnect logs

* Fix tests

* Fix

* Prettier
2024-12-01 20:11:34 +01:00
NerivecandKoen Kanters 563514c446 fix!: Rework OTA (#24634)
* fix(ignore): Update zh and zhc

* update

* Update

* fix

* fix!: Rework OTA

* Import only required from zhc.

* Remove uri-js

* Update settings.schema.json

* fix save

---------

Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2024-12-01 20:11:33 +01:00
Koen Kanters 95279b02af fix(ignore): Small cleanup (#24696) 2024-12-01 20:11:08 +01:00
Koen Kanters e290057e21 fix!: Remove deprecated Home Assistant integration features (#24494)
* Remove `legacy_trigger`

* Remove legacy_entity_attributes

* Remove `LEGACY_MAPPING`

* Finish cleanup

* Updates

* revert some entities removal

* Update

* Updates
2024-12-01 20:11:06 +01:00
Koen Kanters 61cf5094c7 fix 236b084396 2024-12-01 20:10:35 +01:00
Nerivec 9537ef05d5 fix!: Improve bind/bridge/groups parsing and resolving consistency (#24432)
* Improve bind/bridge/groups parsing and resolving consistency

* Fix mqtt payload casing. Improve typing.

* Cleanup tests

* Add test for bind endpoint by ID.

* Match behavior for group response 'default' endpoint.
2024-12-01 20:09:28 +01:00
Koen Kanters 3f76586a50 fix!: Remove configuring group members through configuration.yaml (#24338)
* fix!: Remove configuring group members through `configuration.yaml`

* Updates

* Updates

* fix

* Updates

* Feedback
2024-12-01 20:09:28 +01:00
Nerivec 6f6e7c3ee0 fix: Refactor tests to TS (#24357) 2024-12-01 20:09:27 +01:00
NerivecandKoen Kanters 2124d3450c fix!: Improve permit join (#24257)
* fix: Improve permit join

* Update Home Assistant permit join switch

* Remove `permit_join` from `settings.schema.json`

* Update zigbee-herdsman version to pre-release.

* Fix pnpm overrides

* Update test/homeassistant.test.js

---------

Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2024-12-01 20:07:06 +01:00
Koen KantersandNerivec b6186623f0 feat!: The big cleanup (#24200)
* Remove `deviceGroupMembership`

* Fix controller.ts

* Remove `legacy_api` logic from non-legacy extensions.

* Fix network map coverage.

* Remove all `legacy` extensions.

* Remove `legacy_availability_payload`, `legacy_api`. Remove legacy from configure.

* Fix prettier.

* Remove some leftovers

* Renamed `updateAvailable` to `update_available`

* Remove emitPublishAvailability

* Remove `configuration.yaml`

* Switch to pnpm

* Remove direct mqtt-packet dependency.

* fix pretty

* fix `getDependencyVersion`

* fix pnpm publish

* fix(ignore): fix pnpm publish

* Remove deprecated bridge `config/*`

* Improve update script

* Improve update.sh

---------

Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
2024-12-01 20:06:49 +01:00
Koen Kanters 3220b27c8a fix!: HA permit join switch: disable automatically after 254 seconds (#24245) 2024-12-01 20:06:37 +01:00
Koen Kanters 98b977030e fix(ignore): Changes for improved adapter discovery (#24147)
* fix: Comment serial config in example configuration

* Link to issue

* Update link

* Update example config

* Feedback
2024-12-01 20:06:37 +01:00
Koen Kanters 1fdc427358 chore: update update_deps.yml 2024-12-01 20:04:54 +01:00
github-actions[bot] 9a74b015dc chore: promote to dev 2024-12-01 13:44:52 +00:00
Koen Kanters 861cba63b3 chore(dev): release 1.42.0 (#24569) 2024-12-01 14:33:44 +01:00
Koen Kanters 59d0eb4534 Update zigbee-herdsman-converters to 20.58.0 (#24987) 2024-12-01 13:30:48 +00:00
Koen Kanters 57ee303cda Update zigbee-herdsman-converters to 20.57.0 (#24977) 2024-11-30 20:21:19 +00:00
Der Mundschenk & Compagnie 4690fe1a55 fix(ignore): Ignore None actions for HA events (#24972) 2024-11-30 21:09:09 +01:00
Der Mundschenk & CompagnieandKoen Kanters 826547a8e1 fix(ignore): Prevent empty events from being published to HA (#24969)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2024-11-30 13:46:31 +01:00
Koen Kanters 7047aef7f7 fix(ignore): Fix Cannot read properties of undefined (reading 'clients') https://github.com/Koenkk/zigbee2mqtt/issues/24957 2024-11-29 20:43:30 +01:00
Der Mundschenk & Compagnie d82d782bac fix(ignore): Prevent incorrect event_type due to duplicate keys (#24954) 2024-11-29 20:26:33 +01:00
Koen Kantersandnurikk 6351f4618e Update zigbee2mqtt-frontend to 0.7.6 (#24955)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2024-11-28 20:44:30 +00:00
Koen Kanters 2c52c9884c Update zigbee-herdsman-converters to 20.56.0 (#24952) 2024-11-28 19:56:10 +00:00
Koen Kanters 5e47a21673 Update zigbee-herdsman-converters to 20.55.0 (#24939) 2024-11-27 19:41:38 +00:00
Der Mundschenk & Compagnie 5ca4cf4acd fix: Home Assistant: template errors when action key is missing or empty in state payload (#24931) 2024-11-27 08:11:07 +01:00
Koen Kanters 267d800122 Update zigbee-herdsman-converters to 20.54.0 (#24920) 2024-11-25 19:26:00 +00:00
Koen KantersandIlya Kirov 790373b896 fix(ignore): Update zigbee-herdsman-converters to 20.53.0 (#24906)
Co-authored-by: Ilya Kirov <kirovilya@gmail.com>
2024-11-24 21:29:37 +00:00
Koen Kanters ca6493528d fix(ignore): Update dependencies (#24894) 2024-11-24 22:01:37 +01:00
Koen Kanters 1e77893f75 fix(ignore): update zigbee-herdsman-converters to 20.52.0 (#24874) 2024-11-22 20:55:41 +00:00
Koen Kanters 82a026e02e fix(ignore): update zigbee-herdsman-converters to 20.51.0 (#24856) 2024-11-21 20:58:55 +00:00
Koen Kanters 988984a1ae fix(ignore): update zigbee-herdsman-converters to 20.50.0 (#24843) 2024-11-20 20:27:28 +00:00
Koen Kanters 5859947207 fix(ignore): update zigbee-herdsman-converters to 20.49.1 (#24830) 2024-11-19 19:43:54 +00:00
Koen Kanters b1508ae52f Update zigbee-herdsman-converters to 20.48.0 (#24820)
* fix(ignore): update zigbee-herdsman-converters to 20.48.0

* u
2024-11-18 21:06:11 +00:00
Koen Kanters 152ad12508 fix(ignore): update zigbee-herdsman to 2.1.9 (#24819) 2024-11-18 20:05:50 +00:00
Koen Kanters 36e452b0f3 fix(ignore): update zigbee-herdsman-converters to 20.47.0 (#24772) 2024-11-14 20:36:53 +00:00
Koen Kanters 2a9bbb21ba fix(ignore): update zigbee-herdsman to 2.1.8 (#24763) 2024-11-13 20:56:59 +00:00
Koen Kanters f7e7115f79 chore: attempt to fix codeql.yaml 2024-11-13 21:53:01 +01:00
Koen Kanters 6e04f7b03e fix(ignore): update zigbee-herdsman-converters to 20.46.0 (#24762) 2024-11-13 20:49:13 +00:00
Nerivec 2a1ab78f7a chore: Update codeql config (#24751)
* Update codeql config

* Fix prettier
2024-11-13 21:01:37 +01:00
Koen Kanters e5d02c4b95 chore: merge master to dev 2024-11-12 21:30:24 +01:00
Koen Kanters 69f7282952 chore: fix README.md badge 2024-11-12 21:30:05 +01:00
Koen Kanters b8c3104aa1 fix(ignore): update zigbee-herdsman-converters to 20.45.0 (#24733) 2024-11-12 20:23:45 +00:00
Der Mundschenk & Compagnie 22e13c52b9 fix: Home Assistant event entities, part 2 (#24717)
* Add Philips Hue Tap dial rotation

* Add support for Hue Tap buttons

* Refactor action parsing
2024-11-11 21:10:20 +01:00
Koen Kanters 32e2637594 fix(ignore): update zigbee-herdsman-converters to 20.44.0 (#24715) 2024-11-10 19:39:18 +00:00
Koen Kanters 928635f1da fix(ignore): update dependencies (#24702) 2024-11-10 15:11:17 +01:00
Koen Kanters a353acc8e3 fix(ignore): update zigbee-herdsman-converters to 20.43.0 (#24680) 2024-11-07 19:53:05 +00:00
Koen Kanters b5d4a253b4 fix(ignore): update zigbee-herdsman-converters to 20.42.0 (#24666) 2024-11-06 20:47:04 +00:00
Boris Dolgov 7267d27779 fix: Temporarily disable serving compressed index file. (#24657)
express-static-gzip does not handle redirects with custom base URLs
properly. As index.html file is only 5KB, compression is not critical
for it.
2024-11-06 21:11:02 +01:00
Koen Kanters f2e5d59c35 fix(ignore): update zigbee-herdsman-converters to 20.41.0 (#24656) 2024-11-05 21:16:36 +00:00
Koen Kanters 1558a5d8ac fix(ignore): update zigbee-herdsman-converters to 20.40.0 (#24644) 2024-11-04 21:07:13 +00:00
Boris Dolgov 87cee1aea6 fix: Replace connect-gzip-static with express-static-gzip to become compatible with Node 23 (#24619)
Old versions of connect-gzip-static are not compatible with node 23,
new versions of connect-gzip-static are not compatible with node 18,
but we want to support both. express-static-gzip is compatible with
all versions.
2024-11-04 20:57:25 +01:00
Koen Kantersandnurikk 0c2cc10ad8 fix(ignore): update zigbee2mqtt-frontend to 0.7.5 (#24631)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2024-11-04 10:56:25 +00:00
Koen Kanters 84cc92f91a fix(ignore): update dependencies (#24614) 2024-11-03 16:00:30 +01:00
Koen Kanters a6bfab7934 Update zigbee-herdsman-converters to 20.39.2 (#24609)
* fix(ignore): update zigbee-herdsman-converters to 20.39.2

* u
2024-11-03 14:34:04 +00:00
Der Mundschenk & CompagnieandKoen Kanters 848f250dbb feat: Add experimental support for Home Assistant event entities (#24233)
* Expose new event entity for actions

* Unify exposed actions for HA

* Fix event tests

* Only add event entities wen homeassistant/experimental_event_entities is true

---------

Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2024-11-03 14:54:42 +01:00
Magnus Ihse BursieandKoen Kanters c078ccb136 fix: Home Assistant: remove the diagnostic category classification for power (#24575)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2024-11-02 18:56:57 +00:00
github-actions[bot] 67d3f05fd7 chore: promote to dev 2024-11-01 07:57:31 +00:00
Koen Kanters cc31ceabd1 chore(dev): release 1.41.0 (#24173) 2024-11-01 08:46:52 +01:00
Koen Kanters c5d30b585b fix(ignore): update zigbee-herdsman-converters to 20.38.0 (#24567) 2024-10-31 20:23:53 +00:00
Koen Kanters f1615a255c fix(ignore): update zigbee-herdsman to 2.1.7 (#24566) 2024-10-31 20:17:43 +00:00
Magnus Ihse Bursie a2ce3230ac fix: Add additional discovery lookups for Home Assistant (#24555) 2024-10-31 20:36:52 +01:00
Koen Kanters bd1f6d24a1 fix(ignore): update zigbee-herdsman-converters to 20.37.0 (#24553) 2024-10-30 19:47:11 +00:00
Koen Kanters 9837488f44 fix(ignore): update zigbee-herdsman to 2.1.6 (#24545) 2024-10-29 20:08:04 +00:00
Koen Kanters b620a21d46 fix(ignore): update zigbee-herdsman-converters to 20.36.0 (#24542) 2024-10-29 19:31:53 +00:00
Koen Kanters 2139e7a794 fix(ignore): update dependencies (#24498) 2024-10-29 20:26:52 +01:00
Bacchus777 e0a0f868c0 fix: Improve power_phase_b and power_phase_c Home Assistant discovery (#24538)
Added power_phase_b and power_phase_c
2024-10-29 20:07:07 +01:00
Cody Cutrer ab726061b7 fix: Remove extraneous whitespace in template for binary attributes (#24520)
I assume Home Assistant strips the whitespace at some point before
comparing it to payload_on/payload_off, but I haven't quite found where yet.
I have confirmed Python preserves the whitespace when simply evaluating
the Jinja.

For openHAB, it does not strip the whitespace, causing errors because
it doesn't match the payload_on or payload_off values. At some point I'd
like to bring openHAB inline with Home Assistant, but I need to do so
carefully without breaking something unexpectedly (especially with non-
Home Assistant MQTT integrations). In the meantime, this should fix the
issue in openHAB, without causing issues for Home Assistant or any other
software that follows Home Assistant's MQTT discovery process.
2024-10-28 21:16:40 +01:00
Koen Kanters 074b2e20e0 fix(ignore): update zigbee-herdsman-converters to 20.35.0 (#24514) 2024-10-27 20:54:05 +00:00
Koen Kanters 2b460d3a5f fix(ignore): update zigbee-herdsman-converters to 20.34.0 (#24493) 2024-10-26 13:20:20 +00:00
Koen Kanters c09aeecd95 fix(ignore): update zigbee-herdsman-converters to 20.33.0 (#24457) 2024-10-23 20:27:22 +00:00
Koen Kanters 9ad7c70f24 fix(ignore): update zigbee-herdsman-converters to 20.32.0 (#24434) 2024-10-21 20:24:51 +00:00
Koen Kanters 64778650d3 fix(ignore): update zigbee-herdsman to 2.1.5 (#24433) 2024-10-21 19:51:21 +00:00
Koen Kanters eb606a5b98 fix(ignore): update dependencies (#24411) 2024-10-20 20:22:33 +02:00
Koen Kanters 669a16f13a fix(ignore): update zigbee-herdsman-converters to 20.31.0 (#24408) 2024-10-19 18:48:46 +00:00
Koen Kanters b2d3bbce3c Update zigbee-herdsman-converters to 20.30.0 (#24398)
* fix(ignore): update zigbee-herdsman-converters to 20.30.0

* u
2024-10-19 12:00:50 +00:00
Koen Kanters 12cb6e135d fix(ignore): update zigbee-herdsman to 2.1.4 (#24397) 2024-10-18 18:11:34 +00:00
Koen Kanters d79e71b13e fix(ignore): update zigbee-herdsman-converters to 20.29.0 (#24363) 2024-10-16 20:07:32 +00:00
Koen Kanters dbda1481ec chore: improve testExternalConverter.js 2024-10-16 21:59:24 +02:00
Koen Kanters d12b117b43 fix(ignore): update zigbee-herdsman-converters to 20.28.0 (#24337) 2024-10-14 20:10:31 +00:00
Koen KantersandNerivec e2f19f19b4 fix: Frontend code cleanup @Nerivec (#24322)
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
2024-10-14 19:56:00 +02:00
Koen Kanters 618b318214 fix(ignore): update zigbee-herdsman-converters to 20.27.2 (#24321) 2024-10-13 20:32:13 +00:00
Koen Kanters 9f12ebb54e fix(ignore): update dependencies (#24309) 2024-10-13 20:10:19 +02:00
Koen Kanters db00759a03 fix(ignore): update zigbee-herdsman-converters to 20.26.0 (#24307) 2024-10-12 20:44:31 +00:00
Koen Kanters 5f601873fe fix(ignore): update zigbee-herdsman-converters to 20.25.0 (#24285) 2024-10-10 20:35:52 +00:00
Vladimir Kotikov 1fdf0a0a47 feat: Allow serving frontend under subpath (#24244)
* Allow app running in the server subdirectory

* Use path.posix to manage urls

* Add base_utl to config schema
2024-10-10 14:22:48 +02:00
Koen Kanters 3bb4af253a fix(ignore): update zigbee-herdsman-converters to 20.24.0 (#24246) 2024-10-06 21:07:58 +00:00
Massimiliano 076ada2325 fix: Fix group state change for open/close devices (#24226)
* Fix group state change if OPEN

* Runned Prettier
2024-10-06 21:35:46 +02:00
Koen Kanters 5c3f71292d fix(ignore): update dependencies (#24236) 2024-10-06 21:33:07 +02:00
Koen Kanters 02292590ee fix(ignore): update zigbee-herdsman-converters to 20.23.1 (#24225) 2024-10-05 13:40:41 +00:00
Koen Kanters b09ae10588 fix(ignore): update zigbee-herdsman-converters to 20.23.0 (#24201) 2024-10-03 19:34:26 +00:00
Koen Kanters e5fac76060 fix(ignore): update zigbee-herdsman-converters to 20.22.0 (#24186) 2024-10-02 19:48:01 +00:00
Ivan F. MartinezandKoen Kanters 17ac9cf8e7 feat: Add throttle option for devices (#24122)
* basic spam control

* used npm run pretty:write

* add test for SPAMMER description to comply with 100% coverage test

* define friendly name to spammer test devices

* Update README.md

Co-authored-by: Koen Kanters <koenkanters94@gmail.com>

* trying now with throttleit library

* lint corrections

* last lint request

* correct await / async definiction

* remove description support

* change first command to be executed

---------

Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2024-10-02 21:15:15 +02:00
github-actions[bot] 063d4620d2 chore: promote to dev 2024-10-01 19:41:45 +00:00
Koen Kanters e06848db8c chore(dev): release 1.40.2 (#23885) 2024-10-01 21:30:57 +02:00
Koen Kanters c82f93230d fix(ignore): update zigbee-herdsman-converters to 20.21.0 (#24172)
* fix(ignore): update zigbee-herdsman-converters to 20.21.0

* fix
2024-10-01 21:27:13 +02:00
Koen Kanters 13e4937db2 fix(ignore): update zigbee-herdsman to 2.1.3 (#24171) 2024-10-01 18:45:21 +00:00
Koen Kanters 58c856bbd0 fix(ignore): update zigbee-herdsman-converters to 20.20.0 (#24144) 2024-09-29 18:25:56 +00:00
Koen Kanters df1913bc56 fix(ignore): update zigbee-herdsman to 2.1.2 (#24138) 2024-09-29 11:47:51 +00:00
Koen Kanters 37f80dfb69 fix(ignore): update dependencies (#24132) 2024-09-29 13:20:17 +02:00
Koen Kanters 8fe76e090f fix(ignore): update zigbee-herdsman-converters to 20.19.0 (#24130)
* fix(ignore): update zigbee-herdsman-converters to 20.19.0

* fix tests
2024-09-28 18:47:32 +00:00
Koen Kanters 15d845bf92 fix(ignore): update zigbee-herdsman-converters to 20.18.0 (#24105) 2024-09-25 19:33:27 +00:00
Koen Kanters 22e1480f74 Update zigbee-herdsman-converters to 20.17.1 (#24092)
* fix(ignore): update zigbee-herdsman-converters to 20.17.1

* Update
2024-09-24 20:29:25 +00:00
Koen Kanters 5dd1bfb8a6 fix(ignore): update zigbee-herdsman to 2.1.1 (#24093) 2024-09-24 20:01:58 +00:00
Koen Kanters b1c7fd0033 fix(ignore): update zigbee-herdsman-converters to 20.17.0 (#24077) 2024-09-23 18:46:59 +00:00
Koen Kanters 2314147d0e fix(ignore): update zigbee-herdsman to 2.1.0 (#24076) 2024-09-23 18:35:57 +00:00
Koen Kanters ee4becb64e fix(ignore): update zigbee-herdsman to 2.0.0 (#24064) 2024-09-22 19:36:21 +00:00
Koen Kanters a5bb49aa71 fix(ignore): update dependencies (#24057)
* fix(ignore): update dependencies

* u
2024-09-22 20:08:50 +02:00
Koen Kanters d289194a71 fix(ignore): update zigbee-herdsman to 1.1.0 (#24054) 2024-09-21 19:06:54 +00:00
Koen Kanters 7ad51ce662 fix: Fix Home Assistant Invalid state message error when state is too long (#24045) 2024-09-21 00:06:44 +02:00
Koen Kanters 951bbd3576 fix(ignore): update zigbee-herdsman-converters to 20.16.3 (#24033) 2024-09-19 20:17:21 +00:00
Koen Kanters 8a54a01583 fix(ignore): update zigbee-herdsman to 1.0.1 (#24032) 2024-09-19 19:58:47 +00:00
Koen Kanters e7a3f0da0a fix(ignore): update zigbee-herdsman-converters to 20.16.2 (#24022) 2024-09-18 20:09:35 +00:00
Koen Kanters 2ae19a7c42 fix(ignore): update zigbee-herdsman-converters to 20.16.1 (#24009) 2024-09-17 20:30:55 +00:00
Koen Kanters da2a6974e0 fix(ignore): update zigbee-herdsman to 1.0.0 (#24008) 2024-09-17 20:24:12 +00:00
Koen Kanters d3de77da13 fix(ignore): update zigbee-herdsman-converters to 20.16.0 (#23990) 2024-09-16 20:49:03 +00:00
Koen Kanters 28650daf83 chore: fix cedc0623f7 2024-09-16 22:46:37 +02:00
cedc0623f7 chore: Fix CI - trigger helm update (#23977)
* Fix CI - trigger helm update

* Update .github/workflows/ci.yml

Co-authored-by: Koen Kanters <koenkanters94@gmail.com>

---------

Co-authored-by: jose <jose.pedrosa@reddit.com>
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2024-09-16 20:23:13 +02:00
Koen Kanters 3efc2b237b fix(ignore): update dependencies (#23973) 2024-09-15 20:46:51 +02:00
Koen Kanters 03b5b0043a fix(ignore): update zigbee-herdsman-converters to 20.15.0 (#23969) 2024-09-14 19:01:07 +00:00
Koen Kanters 70a65c7dcf fix(ignore): update zigbee-herdsman to 0.57.4 (#23968) 2024-09-14 18:46:56 +00:00
Koen Kanters eb8685682f fix(ignore): update zigbee-herdsman-converters to 20.14.1 (#23952) 2024-09-12 21:02:39 +00:00
Koen Kanters e642f7c86a fix(ignore): Sync eslint settings from zhc (#23951)
* fix(ignore): Sync eslint settings from zhc

* u
2024-09-12 19:36:57 +00:00
Tomer c6ca472ca1 fix: Improve performance when logging is disabled (#23893)
* Improve log performance

* Bug fix

* Revert sample configuration

* Align package versions

* Revert dependecies version to allow testing

* Update package-lock.json

* Fix prettier issue

* Coverage to 100%

* Fix CR comments

* remove uneeded changes

* Make the same perf optimization to the MQTT messages

* Fix MQTT tests

* prettier fix

* Allow easy mocking of stub logger callTransports function to receive the actual message

* cleanup leftover

* Fix prettier issues

* Finalize logger mock structure and add few lambda log calls

* fix prettier issue

* Run prettier

* Revert "fix prettier issue"

This reverts commit b409d538e3.

* Update zigbee.ts

Fix prettier issue

* Fix test issues

* Revert await removal
2024-09-12 20:33:05 +02:00
Koen Kanters 0540e4fbb4 fix(ignore): update zigbee-herdsman-converters to 20.13.0 (#23927) 2024-09-09 20:13:08 +00:00
Koen Kanters 9c72a768f2 fix(ignore): Improvement for afd80449b3 2024-09-08 21:33:56 +02:00
Koen Kanters 8c0476612f fix(ignore): update dependencies (#23904) 2024-09-08 14:28:03 +02:00
Koen Kanters afd80449b3 fix(ignore): Migrate to eslint 9 (#23800)
* Eslint 9

* Automatic changes

* Manual changes

* Process feedback

* u
2024-09-08 14:26:18 +02:00
Koen Kanters d989061122 fix(ignore): update dependencies (#23898) 2024-09-08 13:08:03 +02:00
NerivecandKoen Kanters 48d77e4b5e fix: Enforce TS strict type checking (#23601)
* Enforce TS `strict` type checking.

* updates

* updates

* updates

* Updates

* Updates

* pretty

* u

* u

* u

* Updates

* updates

* Updates

* Updates

* `ReadonlyArray`

* scenesChanged

* objectID

* Improve coverage

* u

* u

* process feedback

---------

Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2024-09-07 11:40:50 +00:00
github-actions[bot] 63c3bb4166 chore: promote to dev 2024-09-07 11:33:51 +00:00
Koen Kanters 403d3c090d chore(dev): release 1.40.1 (#23797) 2024-09-07 13:25:22 +02:00
Koen Kanters 9283c4b497 fix(ignore): update zigbee-herdsman-converters to 20.12.1 (#23884) 2024-09-07 11:17:24 +00:00
Koen Kanters 0d46354c48 fix(ignore): update zigbee-herdsman to 0.57.3 (#23877) 2024-09-06 20:26:15 +00:00
Koen Kanters 13a108ddbc fix: Log stack trace when update check fails https://github.com/Koenkk/zigbee2mqtt/discussions/23798 2024-09-06 14:37:20 +02:00
Koen Kanters 5524e881e6 fix(ignore): update zigbee-herdsman to 0.57.2 (#23872) 2024-09-06 12:19:21 +00:00
Koen Kanters a33c29f10a fix(ignore): update zigbee-herdsman-converters to 20.11.0 (#23837) 2024-09-05 12:39:50 +00:00
Koen Kanters fc02071c8f chore: fix e2da2fa8d3 2024-09-05 14:37:30 +02:00
Koen Kanters 06556d44f3 chore: merge master to dev 2024-09-05 13:35:31 +02:00
Koen Kanters 13084d22a7 chore: Update new device support template 2024-09-05 13:35:15 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 14787d3a6a chore(deps): bump peter-evans/create-pull-request from 6 to 7 (#23832)
Bumps [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request) from 6 to 7.
- [Release notes](https://github.com/peter-evans/create-pull-request/releases)
- [Commits](https://github.com/peter-evans/create-pull-request/compare/v6...v7)

---
updated-dependencies:
- dependency-name: peter-evans/create-pull-request
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-03 20:21:00 +02:00
Jose Luis Pedrosaandjose e2da2fa8d3 chore: Invoke chart CI (#23819)
The workflow name on the chart repository is OnZigbee2MQTTRelease, this event means that a new version of zigbe2mqtt is has been released, it should probably trigger too the release, but that is a chart repo concern

Co-authored-by: jose <jose.pedrosa@reddit.com>
2024-09-03 20:20:40 +02:00
Koen Kanters 6dade8b11f fix(ignore): update zigbee-herdsman-converters to 20.9.0 (#23822) 2024-09-02 20:48:09 +00:00
Koen Kanters 557b17ad5b fix: Fix Cannot read properties of undefined (reading 'some') https://github.com/Koenkk/zigbee2mqtt/issues/23821 2024-09-02 22:22:19 +02:00
Koen Kanters 64222d231f Update zigbee-herdsman-converters to 20.8.5 (#23799)
* fix(ignore): update zigbee-herdsman-converters to 20.8.5

* Update utils.ts
2024-09-01 19:23:14 +00:00
Koen Kanters b7fa722109 fix(ignore): update dependencies (#23790) 2024-09-01 20:23:49 +02:00
github-actions[bot] 4a1d983168 chore: promote to dev 2024-09-01 18:22:59 +00:00
Koen Kanters 6fa1dabecb chore(dev): release 1.40.0 (#23473) 2024-09-01 20:12:13 +02:00
Koen Kanters 3b2ba0950e fix(ignore): update zigbee-herdsman-converters to 20.8.4 (#23791) 2024-09-01 18:10:07 +00:00
Koen Kanters 9f40936452 fix(ignore): update zigbee-herdsman-converters to 20.8.1 (#23783) 2024-08-30 18:35:59 +00:00
Koen Kanters b2bae6ba94 fix: Add logging when loading external converters https://github.com/Koenkk/zigbee2mqtt/issues/23703 2024-08-30 20:23:55 +02:00
Koen Kanters 406deaa9da fix(ignore): update zigbee-herdsman to 0.57.1 (#23781) 2024-08-30 18:14:13 +00:00
Koen Kanters 65b1e60a3d fix(ignore): update zigbee-herdsman-converters to 20.8.0 (#23777) 2024-08-29 19:28:15 +00:00
Koen Kanters 6ed9b26657 fix(ignore): update zigbee-herdsman-converters to 20.7.0 (#23759) 2024-08-27 18:57:31 +00:00
Koen Kanters cf3298bf6f fix: Update watchdog delays (#23757) 2024-08-27 20:50:24 +02:00
Koen Kanters 00948c0be1 chore: merge master to dev 2024-08-27 20:48:05 +02:00
Koen Kanters ed9de74f91 chore: Update New device support request template 2024-08-27 20:47:45 +02:00
Koen Kanters 41587211e0 fix(ignore): update zigbee-herdsman to 0.57.0 (#23758) 2024-08-27 18:43:23 +00:00
Ilya Kirov b4176d6fee feat: Add support for zboss adapter (#23747)
https://github.com/Koenkk/zigbee-herdsman/pull/1165
2024-08-27 20:37:00 +02:00
Koen Kanters ccdf6b3c6d fix(ignore): update zigbee-herdsman-converters to 20.6.0 (#23750) 2024-08-26 19:06:20 +00:00
Koen Kanters 8a05077792 fix(ignore): update dependencies (#23731) 2024-08-26 20:12:08 +02:00
Koen Kanters df7772bfe0 fix(ignore): update zigbee-herdsman-converters to 20.5.0 (#23723) 2024-08-24 13:07:53 +00:00
Koen Kanters 6ae9970efd fix(ignore): update zigbee-herdsman-converters to 20.4.0 (#23692) 2024-08-21 19:45:53 +00:00
Koen Kanters b92072fc6a Merge remote-tracking branch 'origin/master' into dev 2024-08-20 21:38:19 +02:00
Koen Kanters 5bdf90df9e chore: Trigger zigbee2mqtt-chart release on release https://github.com/Koenkk/zigbee2mqtt-chart/issues/7 2024-08-20 21:36:01 +02:00
Koen Kanters 22980177b6 fix(ignore): Fix crash when retrieving state (#23679) 2024-08-20 20:57:09 +02:00
Koen Kanters 837cbe5f26 fix(ignore): update zigbee-herdsman-converters to 20.3.0 (#23666) 2024-08-19 19:13:00 +00:00
Koen Kanters e95116c773 fix(ignore): update zigbee-herdsman to 0.56.2 (#23658) 2024-08-18 12:38:04 +00:00
Koen Kanters 27300ba8da fix(ignore): update dependencies (#23653) 2024-08-18 14:03:00 +02:00
Koen Kanters 801ab9066f fix(ignore): update zigbee-herdsman to 0.56.0 (#23643) 2024-08-16 20:57:46 +00:00
Koen Kanters 2a631f3551 chore: merge master to dev 2024-08-16 22:31:18 +02:00
Koen Kanters 66fefaa827 chore: Add fail_pr_to_master.yaml 2024-08-16 22:30:56 +02:00
Koen Kanters c986fd6c66 fix(ignore): update zigbee-herdsman-converters to 20.2.0 (#23628) 2024-08-15 19:32:00 +00:00
Koen Kanters 29f436ab95 fix(ignore): update zigbee-herdsman-converters to 20.1.1 (#23612) 2024-08-13 19:40:53 +00:00
Koen KantersandKoenkk d827d954ed fix(ignore): update zigbee-herdsman-converters to 20.0.0 (#23595)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-08-12 11:39:30 +00:00
Koen Kanters 8ae2c4d42d chore: merge master to dev 2024-08-12 13:15:23 +02:00
Koen Kanters 9027c41fe8 Revert "Added pictures for _TZE204_ncti2pro (ZSVIOT PN6) (#23552)"
This reverts commit 00b5e983d9.
2024-08-12 13:15:02 +02:00
Andre Klärner a5748d620b fix: Add OCI labels to image (#23578)
this adds Docker Labels according to the OCI Image Format Specification
which means that tools like renovate can automatically find the
changelogs from a docker image, helping the people like me judge the
impact of upgrading their z2m instance from the pull request renovate
generates.
2024-08-11 21:59:44 +02:00
Koen Kanters d1734feb21 chore: merge master to dev 2024-08-09 14:37:08 +02:00
Vitaliy 00b5e983d9 Added pictures for _TZE204_ncti2pro (ZSVIOT PN6) (#23552)
* Added pictures for _TZE204_ncti2pro (ZSVIOT PN6)

* Removed jpg file and renamed TS0601_pn6_way_controller.png to PN6.png
2024-08-09 14:36:52 +02:00
Koen Kanters efd3d3dfb0 fix: Set zigbee-herdsman transmit power through settings on startup (#23566)
* fix: Set zigbee-herdsman transmit power through settings on startup

* use advanced
2024-08-09 12:33:59 +00:00
Koen KantersandKoenkk aeba93d0a3 fix(ignore): update zigbee-herdsman-converters to 19.75.0 (#23565)
* fix(ignore): update zigbee-herdsman-converters to 19.75.0

* fix

---------

Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-08-08 21:14:32 +02:00
Koen KantersandKoenkk 7ca456d20e fix(ignore): update zigbee-herdsman to 0.55.5 (#23564)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-08-08 19:06:42 +00:00
Nerivec 13ac8a0f53 fix: Improve looping performance (#23541)
* Use herdsman improved looping.

* Fix lint.

* Fix tests.

* Feedback
2024-08-08 20:21:40 +02:00
Koen KantersandKoenkk fe6cacd87e fix(ignore): update zigbee-herdsman-converters to 19.74.0 (#23539)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-08-06 19:42:34 +00:00
Koen KantersandKoenkk 00923685d2 fix(ignore): update zigbee-herdsman-converters to 19.73.1 (#23527)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-08-05 21:14:09 +00:00
Koen KantersandKoenkk bd7dec0f11 fix(ignore): update dependencies (#23508)
* fix(ignore): update dependencies

* update

---------

Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-08-05 21:08:40 +00:00
Koen Kanters 9bb21f486c chore: merge master to dev 2024-08-04 22:14:52 +02:00
LaurentandKoen Kanters d0f5733db3 fix: Remove group from Home Assistant when removed (#23492)
* Fire event when group gets removed

* Add a unit test for the new behavior

* Make prettier happy

* Add missing return type

* Merge device and group removal events

* Unbreak bridgeLegacy.ts

* Update homeassistant.ts

* Update bridge.ts

* Update bridge.ts

---------

Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2024-08-04 22:14:36 +02:00
Koen Kanters 5b610afa8f fix: Add min/max for some adapter options (#23503)
* fix: Add min/max for some adapter options

* update
2024-08-04 19:53:29 +00:00
Koen KantersandKoenkk 2cb8a8f5a5 fix(ignore): update zigbee-herdsman-converters to 19.73.0 (#23495)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-08-03 11:05:54 +00:00
Koen Kanters 3217332e46 fix: Fix expose category ignored by HA discovery (#23494) 2024-08-03 13:00:29 +02:00
Koen KantersandKoenkk a25ef81cf4 fix(ignore): update zigbee-herdsman to 0.55.4 (#23493)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-08-03 10:31:41 +00:00
Koen Kanters eac11a6439 chore: fix package.json indentation 2024-08-01 21:11:44 +02:00
github-actions[bot] a330830cde chore: promote to dev 2024-08-01 18:57:20 +00:00
Koen Kanters e132316ac4 chore(dev): release 1.39.1 (#23221) 2024-08-01 20:42:11 +02:00
Koen KantersandKoenkk 3dc9a05b0c fix(ignore): update zigbee-herdsman-converters to 19.72.0 (#23472)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-08-01 18:17:31 +00:00
Koen Kanters 7cc5209af3 chore: apply prettier to whole repo 2024-07-29 21:26:33 +02:00
Koen Kanters 02dc8a7b87 chore: apply prettier to whole repo 2024-07-29 21:21:24 +02:00
Koen KantersandKoenkk 3a573da83c fix(ignore): update dependencies (#23440)
* fix(ignore): update dependencies

* fix

---------

Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-07-28 14:55:34 +02:00
Koen Kantersandnurikk 26718eefea fix(ignore): update zigbee2mqtt-frontend to 0.7.4 (#23432)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2024-07-26 21:09:02 +00:00
Koen KantersandKoenkk 115b187c1a fix(ignore): update zigbee-herdsman to 0.55.3 (#23431)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-07-26 20:57:00 +00:00
Koen KantersandKoenkk fd65b9a108 fix(ignore): update zigbee-herdsman to 0.55.2 (#23422)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-07-25 20:59:34 +00:00
Koen Kanters d9ba4524e4 fix(ignore): fix 5fcf295ab8 2024-07-25 21:59:40 +02:00
Koen Kanters 5fcf295ab8 fix: Fix race condition in frontend extension (#23412) 2024-07-24 20:20:24 +00:00
Nerivec b7d9d9e142 fix: Improve looping when retrieving all devices from herdsman (#23405) 2024-07-24 21:58:48 +02:00
Koen KantersandKoenkk 1bf3032b98 fix(ignore): update zigbee-herdsman-converters to 19.71.1 (#23404)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-07-23 20:26:33 +00:00
Koen Kantersandnurikk 85453091e7 fix(ignore): update zigbee2mqtt-frontend to 0.7.3 (#23394)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2024-07-22 19:45:24 +00:00
Koen Kanters 26b1613948 fix(ignore): Remove some unecessary string concatenations 2024-07-22 21:33:24 +02:00
Koen KantersandKoenkk c32c411f88 fix(ignore): update zigbee-herdsman to 0.55.1 (#23392)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-07-22 18:33:07 +00:00
Koen KantersandKoenkk cda8927dc7 fix(ignore): update zigbee-herdsman to 0.55.0 (#23370)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-07-18 18:42:17 +00:00
Koen KantersandKoenkk 8c71afd93c fix(ignore): update zigbee-herdsman-converters to 19.70.0 (#23359)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-07-17 20:03:44 +00:00
Koen KantersandKoenkk 712b920abb fix(ignore): update zigbee-herdsman to 0.54.1 (#23358)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-07-17 19:13:50 +00:00
Koen KantersandKoenkk f795399e10 fix(ignore): update zigbee-herdsman to 0.54.0 (#23351)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-07-16 20:07:55 +00:00
Koen Kanters 541221bbbd fix(ignore): Update channel change description (#23333)
* fix(ignore): Update channel change description

* Update settings.schema.json
2024-07-16 22:02:41 +02:00
Koen KantersandKoenkk 24c9159e19 fix(ignore): update zigbee-herdsman-converters to 19.69.0 (#23346)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-07-15 20:45:09 +00:00
Koen KantersandKoenkk 5d05ebbfd2 fix(ignore): update dependencies (#23276)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-07-15 21:53:56 +02:00
Koen KantersandKoenkk bd78f56d5f fix(ignore): update zigbee-herdsman-converters to 19.68.0 (#23338)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-07-13 20:05:52 +00:00
Koen Kanters af7467bf62 fix(ignore): Update test cases for https://github.com/Koenkk/zigbee2mqtt/pull/23328 2024-07-13 21:20:33 +02:00
Koen Kanters e3fa0bfb65 fix: Ignore cache when re-interviewing device (#23328)
* fix: Ignore cache when re-interviewing device

* updates
2024-07-12 10:15:33 +00:00
Koen KantersandKoenkk 00661fac4b fix(ignore): update zigbee-herdsman to 0.53.1 (#23331)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-07-12 12:12:30 +02:00
Koen KantersandKoenkk 2fbb73a009 fix(ignore): update zigbee-herdsman-converters to 19.67.0 (#23327)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-07-11 19:33:03 +00:00
Koen Kanters 8397f47649 fix: Fix availability blocking other extensions startup (#23316)
* fix: Fix availability blocking frontend startup

* Process feedback

* fix
2024-07-11 21:07:01 +02:00
Koen KantersandKoenkk fe87b5dec7 fix(ignore): update zigbee-herdsman to 0.53.0 (#23317)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-07-10 19:54:18 +00:00
Koen KantersandKoenkk 2916d7ee52 fix(ignore): update zigbee-herdsman-converters to 19.66.0 (#23306)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-07-09 19:16:08 +00:00
Koen KantersandKoenkk eee1da620f fix(ignore): update zigbee-herdsman to 0.52.0 (#23299)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-07-08 20:00:37 +00:00
Koen KantersandKoenkk 5663afb27e fix(ignore): update zigbee-herdsman-converters to 19.65.0 (#23272)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-07-06 09:59:51 +00:00
Koen Kanters 1bf385c5ff fix(ignore): fix 6b9247c84d 2024-07-06 11:56:16 +02:00
Koen Kanters 6b9247c84d fix: Truncate text of Home Assistant text sensor https://github.com/Koenkk/zigbee2mqtt/issues/23199 2024-07-06 11:22:26 +02:00
Nerivec 6cb62e53cf fix: Fix syslog output (#23269) 2024-07-06 10:57:24 +02:00
Koen Kanters f97ad2e951 fix(ignore): fix tests 2024-07-03 22:37:12 +02:00
Koen KantersandKoenkk e917802f5f fix(ignore): update zigbee-herdsman-converters to 19.64.0 (#23252)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-07-03 22:23:38 +02:00
Gernot MessowandKoen Kanters 4210edf60e fix: Allow definition to override HA discovery payload (#23075)
* Added function to override payloads for non-conforming devices

* override implementation for Bosch BTH-RA
* unit test including mockup for this device

* Experimental and broken integration of overrideHaConfig

* Not working experiments; mqtt publish debug output

* fixes

* Updates

* fix

---------

Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2024-07-03 21:27:45 +02:00
Koen KantersandKoenkk addc2385b6 fix(ignore): update zigbee-herdsman-converters to 19.63.0 (#23244)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-07-02 20:05:00 +00:00
Koen KantersandKoenkk 2dbbed14f0 fix(ignore): update zigbee-herdsman to 0.51.0 (#23223)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-07-01 18:52:01 +00:00
github-actions[bot] 5c42883779 chore: promote to dev 2024-07-01 18:32:21 +00:00
Koen Kanters 032692673b chore(dev): release 1.39.0 (#22868) 2024-07-01 20:25:54 +02:00
Koen Kanters 2d2b78e9b5 chore: fix 24c43d9400 2024-06-30 22:14:26 +02:00
Koen Kanters 24c43d9400 chore: fix changelog generator 2024-06-30 22:12:49 +02:00
Koen Kanters 2885eee795 chore: improve changelog generator 2024-06-30 21:50:33 +02:00
Koen KantersandKoenkk 8196460de0 fix(ignore): update zigbee-herdsman-converters to 19.62.0 (#23214)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-06-30 19:10:26 +00:00
Ikko Eltociear Ashimine 5dde063119 chore: update homeassistant.ts (#23213)
unecessary -> unnecessary
2024-06-30 20:59:53 +02:00
Koen Kanters dc7a06cf56 fix: Don't allow homeassistant.discovery_topic to be equal to mqtt.base_topic https://github.com/Koenkk/zigbee2mqtt/issues/23109 (#23209) 2024-06-30 10:42:24 +00:00
Koen KantersandKoenkk 6ba42c6a12 fix(ignore): update zigbee-herdsman to 0.50.1 (#23208)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-06-30 10:38:00 +00:00
Koen KantersandKoenkk 940422a9d8 fix(ignore): update dependencies (#23207)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-06-30 12:32:07 +02:00
Koen KantersandKoenkk fa24d2d2ba fix(ignore): update zigbee-herdsman-converters to 19.61.0 (#23203)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-06-29 11:55:30 +00:00
Koen KantersandKoenkk c48db3f5b9 fix(ignore): update zigbee-herdsman-converters to 19.60.1 (#23163)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-06-25 19:14:58 +00:00
Koen Kanters 30227a13ae chore: Implement prettier (#23153)
* chore: Implement prettier

* Run prettier

* fix lint

* process feedback

* process feedback
2024-06-24 18:58:47 +00:00
Koen KantersandKoenkk 8780ab2792 fix(ignore): update dependencies (#23137)
* fix(ignore): update dependencies

* fix

---------

Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-06-24 12:01:44 +02:00
Koen KantersandKoenkk dec9f10133 fix(ignore): update zigbee-herdsman-converters to 19.59.2 (#23152)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-06-24 09:25:15 +00:00
Koen KantersandKoenkk ee42d68472 fix(ignore): update zigbee-herdsman-converters to 19.59.1 (#23141)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-06-23 09:32:59 +00:00
Jorge SchrauwenandKoen Kanters ad84374046 fix: Fix OTA overriding custom software version attribute (#23129)
* fix: OTA loses version info for develco/frient

This fix is not perfect, as it will still print that it got updated from null to null.

We just moved the reconfigure later, which will trigger this https://github.com/Koenkk/zigbee-herdsman-converters/blob/000041cba216822dab151151c493b560678da91a/src/devices/develco.ts#L36
for develco/frient devices that have it in their configure. (Which should be all of them)

Of note though, ZCL marks both dateCode and swBuildId as optional attributes, so Develco/Frient returning UNSUPPORTED_ATTRIBUTE here is not outside of spec. Although I'm not sure there is a cleaner way to do this.

* Update otaUpdate.test.js

* Update otaUpdate.ts

---------

Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2024-06-23 11:24:03 +02:00
Nerivec 3d772b5db3 fix: Bind code optimisation/formatting (#23110)
* Bind code optimization/formatting

* Feedback

* Fix coverage
2024-06-23 10:38:00 +02:00
Koen KantersandKoenkk 8877c9e864 fix(ignore): update zigbee-herdsman-converters to 19.59.0 (#23130)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-06-22 10:54:46 +00:00
Koen KantersandKoenkk 889e41ca4e fix(ignore): update zigbee-herdsman-converters to 19.58.0 (#23114)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-06-20 11:13:59 +00:00
Nerivec 2b36f7492b fix: Optional watchdog for "adapter disconnected"-type events (non-node-crash) (#23043)
* Watchdog for "adapter disconnected"-type events (non-node-crash)

* Reset watchdog count on successful start.

* Cleanup

* Switch from argv to env.
2024-06-19 20:51:04 +02:00
Koen Kanters bcc36811a9 chore: merge master to dev 2024-06-19 12:53:13 +02:00
dependabot[bot] 6c7d52a3ce chore(deps-dev): bump braces from 3.0.2 to 3.0.3 (#23105) 2024-06-19 12:52:44 +02:00
Koen Kanters 9f1d6f7d94 chore: merge master to dev 2024-06-19 12:48:53 +02:00
dependabot[bot] 89d3acf1d4 chore(deps): bump ws from 8.17.0 to 8.17.1 (#23100) 2024-06-19 12:48:36 +02:00
Koen KantersandKoenkk 70e84ddc90 fix(ignore): update zigbee-herdsman-converters to 19.57.1 (#23099)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-06-18 19:10:38 +00:00
Nerivec 9c2264e86e fix: MQTT code optimization/formatting (#23072)
* MQTT code optimization/formatting

* Feedback.

* Move `onConnect` logic inside `connect` event.
2024-06-18 13:59:07 +02:00
Koen KantersandKoenkk a85d3e4331 fix(ignore): update zigbee-herdsman-converters to 19.57.0 (#23077)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-06-16 15:47:07 +00:00
Koen Kanters d45e254f50 fix(ignore): update dependencies (#23073) 2024-06-16 17:42:28 +02:00
Nerivec f57465324a fix: Availability code optimization/formatting (#23065)
* Availability code optimization/formatting

* Fix mock.

* Fix map => some
2024-06-16 17:25:40 +02:00
Koen KantersandKoenkk 83ac5649d3 fix(ignore): update zigbee-herdsman-converters to 19.56.0 (#23068)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-06-15 13:26:21 +00:00
ghoz d41cf43a7c fix: Exclude homeassistant entries from null cleanup (#22995)
* exclude homeassistant entries from null cleanup

* Add a test

* Don't hardcode the exclude list, add one more test

* implement suggested changes as per review
2024-06-15 10:27:29 +02:00
Rotzbua fcdae971e5 fix: Remove outdated @types/debounce (#23063)
`debounce` now includes types.
https://github.com/sindresorhus/debounce/releases/tag/v2.0.0
2024-06-15 10:26:38 +02:00
ghoz ef2c939482 fix: Don't call onEvent on disabled devices (#23058)
As z2m requires a restart for  enable/disable to be effective,
we don't need to handle changes at runtime (stop events etc)
2024-06-15 10:26:06 +02:00
Koen KantersandKoenkk 85356a2932 fix(ignore): update zigbee-herdsman to 0.50.0 (#23064)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-06-14 21:29:13 +00:00
Koen KantersandKoenkk a371347ceb fix(ignore): update zigbee-herdsman-converters to 19.55.0 (#23039)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-06-13 19:48:53 +00:00
Koen Kanters 0754fa8552 fix(ignore): Improve network key hiding https://github.com/Koenkk/zigbee-herdsman/pull/1086 2024-06-13 21:19:09 +02:00
Koen KantersandKoenkk 47bba42357 fix(ignore): update zigbee-herdsman-converters to 19.54.0 (#23027)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-06-12 19:23:25 +00:00
ghoz eff341bd14 fix: Differentiate the MQTT publish/ received from regular MQTT logs (#23026)
* Differentiate the spammy "MQTT publish"/"received MQTT message" from regular logs

Rather than creating subnamespaces, keep the z2m:mqtt logger namespace only for those 2 , move back to z2m default for the rest.

* fix tests
2024-06-12 21:01:16 +02:00
Nerivec 6064194e52 fix: Groups code optimization/formatting (#23015) 2024-06-11 21:08:01 +02:00
Koen Kanters 69541c6348 chore: merge master to dev 2024-06-10 22:18:32 +02:00
Koen Kanters f18473017e chore: Fix problem_report template debugging link 2024-06-10 22:18:15 +02:00
Koen KantersandKoenkk aa53303c0f fix(ignore): update zigbee-herdsman-converters to 19.53.0 (#23007)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-06-10 20:14:26 +00:00
Koen Kanters dbcae00316 fix(ignore): update dependencies (#22986) 2024-06-09 15:48:05 +02:00
Koen KantersandKoenkk a4a0c6f3b9 fix(ignore): update zigbee-herdsman-converters to 19.52.0 (#22982)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-06-08 16:29:39 +00:00
Koen KantersandKoenkk fd3d9a318d fix(ignore): update zigbee-herdsman-converters to 19.51.0 (#22953)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-06-05 18:45:20 +00:00
Koen KantersandKoenkk 76959067e2 fix(ignore): update zigbee-herdsman to 0.49.3 (#22952)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-06-05 18:24:11 +00:00
Nerivec 282dfd5b01 fix: Fix floating promises originating from index.js (#22943) 2024-06-05 20:19:53 +02:00
Koen Kantersandnurikk cdfbb17b4a fix(ignore): update zigbee2mqtt-frontend to 0.7.2 (#22938)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2024-06-04 19:08:43 +00:00
Koen KantersandKoenkk 9d95d61e32 fix(ignore): update zigbee-herdsman-converters to 19.50.1 (#22936)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-06-04 18:36:16 +00:00
ghozandNerivec 881037c371 feat: Allow hierarchical filtering of logger namespaces (#22859)
* POC: Allow hierarchical filtering of logger namespaces

matches the closes defined namespace log level:

advanced:
  log_namespaced_levels:
    'zh:zstack:unpi': info

would also apply to zh:zstack:unpi:write and zh:zstack:unpi:parse

To limit runtime penalty uses the namespacedLevels as a lazy cache

* Move namespace levels to separate cache

Avoid poluting the namespacedLevel in the config / UI
Add cache reset on config time log level change

* Actually use the cache

* Optimize/Cleanup

* Add some namespace hierarchy filter tests

* More coverage.

* Fix #22935

---------

Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
2024-06-04 20:17:35 +02:00
Nerivec 2b8eaa196a fix: Enforce no floating promises (#22880)
* Enforce no floating promises.

* forEach => for/of. Fix tests.
2024-06-04 20:05:39 +02:00
Adam Goode dbf0a3358d fix: Set voc_index to mdi:molecule to match other VOC icons in Home Assistant (#22920) 2024-06-04 20:00:30 +02:00
Koen KantersandKoenkk 6fbf14cced fix(ignore): update zigbee-herdsman-converters to 19.50.0 (#22918)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-06-03 18:55:10 +00:00
Adam Goode 40ad896114 fix: Support ppb and ppm for VOC reporting in Home Assistant (#22895)
See https://github.com/home-assistant/core/pull/92973.
2024-06-03 20:11:40 +02:00
Koen KantersandKoenkk 6c123c28b3 fix(ignore): update zigbee-herdsman-converters to 19.49.0 (#22902)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-06-02 19:25:26 +00:00
Adam Goode 519e123b69 fix: Use occupancy device_class instead of motion for occupancy sensors (#22896)
'occupancy' matches the zigbee terminology more closely and is more
consistent with other Home Assistant entities of this type.

https://www.home-assistant.io/integrations/binary_sensor/

In general, it looks like 'motion' is more for alarms, and 'occupancy'
for non-alarm situations. Z2M currently makes no distinction between
these concepts, but they could be separated (with a fair amount of work).
2024-06-02 21:11:00 +02:00
Koen Kanters 8b4f753c5d chore: merge master to dev 2024-06-02 15:59:59 +02:00
Koen Kanters 0e6da85d19 chore: fix problem_report.yaml logging link 2024-06-02 15:59:38 +02:00
Koen KantersandKoenkk 8f195f67e6 fix(ignore): update dependencies (#22883)
* fix(ignore): update dependencies

* Update homeassistant.test.js

---------

Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-06-02 11:33:34 +02:00
Koen Kanters 457ed86ef8 fix(ignore): TuYa -> Tuya https://github.com/Koenkk/zigbee2mqtt/discussions/22876 2024-06-01 16:44:22 +02:00
Koen Kanters 6924db79e8 fix: Add logging for interview https://github.com/nurikk/zigbee2mqtt-frontend/pull/2027 2024-06-01 16:30:38 +02:00
github-actions[bot] 137087d8ac chore: promote to dev 2024-06-01 09:29:38 +00:00
Koen Kanters fe048e6e66 chore(dev): release 1.38.0 (#22867) 2024-06-01 11:18:35 +02:00
Koen Kanters aa439d6687 chore: Move to googleapis/release-please-action@v4 2024-06-01 11:16:56 +02:00
Koen Kanters b5fc1689c4 chore: remove Helm chart, moved to Koenkk/zigbee2mqtt-chart 2024-06-01 11:13:21 +02:00
Koen KantersandKoenkk 33d8223e52 fix(ignore): update zigbee-herdsman-converters to 19.47.1 (#22865)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-06-01 09:05:00 +00:00
Koen KantersandKoenkk 2cc68fd862 fix(ignore): update zigbee-herdsman to 0.49.2 (#22864)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-06-01 08:49:39 +00:00
Koen KantersandKoenkk a02fd3cee6 fix(ignore): update zigbee-herdsman-converters to 19.47.0 (#22855)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-05-31 18:50:52 +00:00
Koen Kantersandnurikk a3ea91deb9 fix(ignore): update zigbee2mqtt-frontend to 0.7.1 (#22854)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2024-05-31 18:45:28 +00:00
Koen KantersandKoenkk 1a12c2f15e fix(ignore): update zigbee-herdsman-converters to 19.46.0 (#22842)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-05-30 19:24:20 +00:00
Koen KantersandKoenkk f9b0fff185 Update zigbee-herdsman-converters to 19.45.0 (#22814)
* fix(ignore): update zigbee-herdsman-converters to 19.45.0

* fix tests

---------

Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-05-30 19:20:02 +00:00
Mike Ryan b6ad6418a1 feat: Add api for device re-interview (#22788)
* feat: add api for device re-interview

- Adds an API that allows for the re-interview of a device. This can be
  useful a device firmware upgrade adds new device endpoints (as is the case
  when upgrading an Inovelli VZM31-SN to 2.18). Without the ability to
  re-interview, one must remove and re-add the device.

* rename from reinterview to interview

* publish devices after interview.

* only allow device ids or names, not endpoints
2024-05-30 20:48:34 +02:00
Koen KantersandKoenkk 9f5b5d1a76 fix(ignore): update zigbee-herdsman-converters to 19.44.0 (#22801)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-05-27 20:29:08 +00:00
Koen KantersandKoenkk 0b3f4eb0a7 fix(ignore): update zigbee-herdsman to 0.49.1 (#22800)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-05-27 20:02:04 +00:00
Koen Kanters ae8a59dd51 fix(ignore): Fix Home Assistant device_automation being cleared due to ef68cc328e 2024-05-27 16:59:56 +02:00
Koen Kantersandnurikk b984a2d092 fix(ignore): update zigbee2mqtt-frontend to 0.7.0 (#22761)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2024-05-24 08:58:34 +00:00
Koen KantersandKoenkk 9b3a39415f fix(ignore): update zigbee-herdsman to 0.49.0 (#22754)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-05-23 18:51:17 +00:00
Koen Kanters 3b7392ead2 fix(ignore): Revert "Update dependencies" (#22753)
This reverts commit 5ad7e1541f.
2024-05-23 20:39:30 +02:00
Koen KantersandKoenkk 5ad7e1541f fix(ignore): update dependencies (#22752)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-05-23 20:34:36 +02:00
Koen Kanters b2d32657f3 fix(ignore): Allow deprecated warn log_level https://github.com/Koenkk/zigbee2mqtt/issues/22706 2024-05-22 20:12:15 +02:00
Koen Kanters ef68cc328e fix: Optimize Home Assistant discovery (#22701)
* fix: Optimize Home Assistant discovery

* update

* u

* u

* u

* fix
2024-05-21 20:53:37 +02:00
Koen KantersandKoenkk 30f6c8e60c fix(ignore): update zigbee-herdsman-converters to 19.43.0 (#22730)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-05-21 18:22:36 +00:00
Laurent ChardinandKoen Kanters d484405cf0 fix: Expose custom clusters to MQTT (#22583)
* (feat) Expose Custom Clusters in MQTT

- Introducing bridge/definitions
- Updating test case for custom clusters
- Updating new path after ZCL revamp with 0.47.0

This change is needed for nurikk/zigbee2mqtt-frontend#2001

* Fixing test case

* Update lib/model/device.ts

Co-authored-by: Koen Kanters <koenkanters94@gmail.com>

* Update test/bridge.test.js

Co-authored-by: Koen Kanters <koenkanters94@gmail.com>

* Removing the publishDefinition call from some events. updating tests

---------

Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2024-05-21 19:54:18 +02:00
Koen KantersandKoenkk 75608c79c8 fix(ignore): update zigbee-herdsman-converters to 19.42.0 (#22704)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-05-20 06:45:16 +00:00
Koen Kanters 63de2c5ccc fix: Fix Entity 'homeassistant/sensor' is unknown https://github.com/Koenkk/zigbee2mqtt/issues/22258 2024-05-19 21:32:09 +02:00
Koen KantersandKoenkk 91887c1877 fix(ignore): update zigbee-herdsman to 0.48.1 (#22699)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-05-19 14:12:19 +00:00
Koen Kantersandnurikk 8ab3c464dc fix(ignore): update zigbee2mqtt-frontend to 0.6.168 (#22698)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2024-05-19 14:06:41 +00:00
Koen KantersandNerivec ce6043acb0 fix: Improve settings changing (#22674)
* Add namespaced levels for logger. Add NS to mqtt. Deprecate 'warn'.

* Improve setting validation.

* Fix setting through frontend

* Support reload + frontend improvements

* update description

* remove requiresRestart

* Fix tests.

* Fix namespaced logging at lower levels. Add better tests.

* fix: Fix settings changing

* remove `removeNullPropertiesFromObject` from bridge

* Add extra test

* update

---------

Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
2024-05-19 16:01:28 +02:00
NerivecandKoen Kanters 2eec6a4d26 fix: Add namespace-specific levels support to logger (#22619)
* Add namespaced levels for logger. Add NS to mqtt. Deprecate 'warn'.

* Improve setting validation.

* Fix setting through frontend

* Support reload + frontend improvements

* update description

* remove requiresRestart

* Fix tests.

* Fix namespaced logging at lower levels. Add better tests.

---------

Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2024-05-19 15:47:40 +02:00
Koen KantersandKoenkk e9b7a847b0 fix(ignore): update zigbee-herdsman-converters to 19.41.0 (#22690)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-05-18 19:04:07 +00:00
Koen KantersandKoenkk e01662b3a2 fix(ignore): update zigbee-herdsman to 0.48.0 (#22689)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-05-18 18:54:35 +00:00
Koen KantersandKoenkk 2963397b60 fix(ignore): update zigbee-herdsman-converters to 19.40.0 (#22655)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-05-15 20:01:38 +00:00
Koen KantersandKoenkk 2be947b29d fix(ignore): update zigbee-herdsman-converters to 19.39.0 (#22641)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-05-14 20:20:10 +00:00
Koen KantersandKoenkk 4f9658132b fix(ignore): update zigbee-herdsman to 0.47.2 (#22640)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-05-14 22:13:00 +02:00
Koen Kanters d39baa6f1d chore: merge master to dev 2024-05-13 22:35:47 +02:00
Koen Kanters cda867a387 fix: Add Node 22 support, remove Node 21 https://github.com/Koenkk/zigbee2mqtt/issues/22595 2024-05-13 22:35:26 +02:00
Koen KantersandKoenkk 0ba02d1f40 fix(ignore): update zigbee-herdsman to 0.47.1 (#22620)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-05-13 20:02:50 +00:00
Koen KantersandKoenkk 09e51e064b Update zigbee-herdsman to 0.47.0 (#22602)
* fix(ignore): update zigbee-herdsman to 0.47.0

* fix imports

* u

---------

Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-05-12 19:34:16 +00:00
Koen Kanters 04e88baf60 fix: Fix custom cluster not available in frontend https://github.com/Koenkk/zigbee2mqtt/issues/22425 2024-05-10 22:15:38 +02:00
Koen Kanters ecf75ba4ce chore: Set mark_as_latest: false for Helm release 2024-05-10 22:08:09 +02:00
Koen Kanters 14d035007d chore: merge master to dev 2024-05-10 21:46:03 +02:00
Koen Kanters c02c61dd56 chore: Trigger Helm chart release 2024-05-10 21:45:41 +02:00
Koen Kanters 6b60c4ea41 chore: merge master to dev 2024-05-09 22:44:46 +02:00
Koen Kanters d043fd56f9 chore: update problem_report.yaml 2024-05-09 22:44:27 +02:00
Koen Kanters 297e904c2f chore: merge master to dev 2024-05-09 22:38:56 +02:00
Koen Kanters 34f0332564 chore: fix helm_release.yaml branch 2024-05-09 22:38:39 +02:00
github-actions[bot] 9e83b7bba2 chore: promote to dev 2024-05-09 20:33:49 +00:00
Koen Kantersandgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> ea39d86e6b chore: release 1.37.1 (#22569)
* chore: release dev

* chore(release): update helm-chart docs

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-05-09 22:23:15 +02:00
Koen KantersandKoenkk 06fd8a4eb1 fix(ignore): update zigbee-herdsman-converters to 19.37.2 (#22570)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-05-09 20:14:10 +00:00
Nerivec d26ccaed65 fix: Fix logger crashing on restart (#22565) 2024-05-09 21:42:28 +02:00
Koen KantersandKoenkk 989f667c81 fix(ignore): update zigbee-herdsman to 0.46.6 (#22558)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-05-09 06:17:53 +00:00
Koen Kanters cce41c20fb chore: set release-please pull-request-title-pattern 2024-05-08 23:43:55 +02:00
Koen Kanters 350c682e8f chore: make tests faster 2024-05-08 23:32:46 +02:00
Koen KantersandKoenkk 6bcb4b66b1 fix(ignore): update zigbee-herdsman to 0.46.5 (#22552)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-05-08 21:16:13 +00:00
Koen Kanters 58d37b0f92 chore: improve test speed 2024-05-08 23:09:23 +02:00
Koen Kanters aecdfe638b chore: another attempt to fix release_please 2024-05-08 22:55:46 +02:00
Koen KantersandKoenkk cc9a95c74e fix(ignore): update zigbee-herdsman-converters to 19.37.1 (#22549)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-05-08 20:49:51 +00:00
Koen Kanters 834626d2e9 chore: fix release_please.yml 2024-05-08 22:44:30 +02:00
Koen KantersandKoenkk 6b886f6268 fix(ignore): update zigbee-herdsman-converters to 19.37.0 (#22531)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-05-07 20:49:28 +00:00
Koen Kanters a0c9a1769f fix: Fix MQTT ECONNREFUSED error https://github.com/zigbee2mqtt/hassio-zigbee2mqtt/issues/605 2024-05-07 21:25:01 +02:00
Koen KantersandKoenkk 3544d5172e fix(ignore): update zigbee-herdsman-converters to 19.36.1 (#22503)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-05-06 20:00:06 +00:00
Koen KantersandKoenkk e44608a46d fix(ignore): update zigbee-herdsman to 0.46.4 (#22501)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-05-06 19:55:04 +00:00
Jose Luis Pedrosaandjose 6a69c16170 chore: Release please no longer overwrites .release-please-manifest.json (#22499)
Co-authored-by: jose <jose.pedrosa@reddit.com>
2024-05-06 21:43:55 +02:00
Jose Luis Pedrosaandjose b2e8b3e00c fix: Add Helm chart (#21852)
* Initial draft for helm chart

* Add CI using helm releaser

Add helm chart docummentation

Add configuration.yaml values

* Set maintainer

set right defaults on values.yaml

* Add ci to update values on chart

* Add chart version on CI

* Remove manual update of the chart

* Trigger update of Chart.yaml with release_please

* Add chart to release-please

* Add version to release-please manifest for chart

* remove the second release-please

* Update appVersion in chart.yaml too

* Add make target to generate helm-docs

* Generate docs and manage versions in release-please

* Add persistent static volumes

* Small fixes, ensure no volume works

* Fixes for static provisioned volume

* Delete yq dependency in CI

* Delete Makefile, update release-please to run doc generation directly

---------

Co-authored-by: jose <jose.pedrosa@reddit.com>
2024-05-06 20:30:09 +02:00
Koen KantersandKoenkk 358e2eee45 fix(ignore): update zigbee-herdsman-converters to 19.36.0 (#22472)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-05-05 20:45:29 +00:00
Koen KantersandKoenkk 316654b5d3 fix(ignore): update zigbee-herdsman to 0.46.3 (#22471)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-05-05 20:32:55 +00:00
Nerivec eb49f523bf fix: Add settings for finer control over debug log level (#22426)
* Add settings for finer control over debug log level.

* Updates from feedback.
2024-05-05 21:52:26 +02:00
Koen KantersandKoenkk b754b9d463 fix(ignore): update dependencies (#22456)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-05-05 21:44:20 +02:00
Koen KantersandKoenkk e4c9bac348 fix(ignore): update zigbee-herdsman to 0.46.2 (#22470)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-05-05 19:05:33 +00:00
Koen KantersandKoenkk beedfaefac fix(ignore): update zigbee-herdsman-converters to 19.35.0 (#22442)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-05-04 08:34:32 +00:00
Koen KantersandKoenkk 329d832337 fix(ignore): update zigbee-herdsman to 0.46.1 (#22439)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-05-04 07:00:44 +00:00
Koen Kantersandnurikk 23e97d0001 fix(ignore): update zigbee2mqtt-frontend to 0.6.167 (#22419)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2024-05-03 08:44:34 +00:00
Koen KantersandKoenkk 3a61f0acfa Update zigbee-herdsman-converters to 19.34.0 (#22411)
* fix(ignore): update zigbee-herdsman-converters to 19.34.0

* fix tests

---------

Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-05-02 20:20:06 +00:00
Koen Kantersandnurikk 5899436d74 fix(ignore): update zigbee2mqtt-frontend to 0.6.166 (#22410)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2024-05-02 20:00:06 +00:00
Koen Kanters 97d5c5c97a chore: merge master to dev 2024-05-02 21:18:48 +02:00
rbswift ee5cf5a901 chore: Improve update.sh (#22395)
Pulling without specifying how to reconcile divergent branches is discouraged and produces an ugly "hint" that can be a little disconcerting when running update.sh
2024-05-02 21:18:25 +02:00
Koen KantersandKoenkk 907591c467 fix(ignore): update zigbee-herdsman-converters to 19.33.0 (#22381)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-05-01 20:28:30 +00:00
Koen KantersandKoenkk 55b5f91a96 fix(ignore): update zigbee-herdsman to 0.46.0 (#22380)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-05-01 20:20:29 +00:00
github-actions[bot] ce915a6797 chore: promote to dev 2024-05-01 19:45:20 +00:00
Koen Kanters 46f34c8cc7 chore(dev): release 1.37.0 (#22022) 2024-05-01 21:23:10 +02:00
Koen KantersandKoenkk e03013c581 fix(ignore): update zigbee-herdsman-converters to 19.32.0 (#22377)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-05-01 19:17:13 +00:00
Koen Kantersandnurikk 8a288a9119 fix(ignore): update zigbee2mqtt-frontend to 0.6.165 (#22366)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2024-04-30 19:47:10 +00:00
Koen KantersandKoenkk 8e27ec24f6 fix(ignore): update zigbee-herdsman to 0.45.0 (#22365)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-30 19:38:26 +00:00
Koen KantersandKoenkk 4b291bf012 Update zigbee-herdsman-converters to 19.31.1 (#22341)
* fix(ignore): update zigbee-herdsman-converters to 19.31.1

* fix tests

---------

Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-29 20:01:56 +00:00
Koen Kantersandnurikk 10e6ad0e67 fix(ignore): update zigbee2mqtt-frontend to 0.6.164 (#22340)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2024-04-28 18:23:36 +00:00
Koen KantersandKoenkk 1b089ef6ae fix(ignore): update zigbee-herdsman to 0.44.0 (#22339)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-28 18:11:30 +00:00
Koen KantersandKoenkk d7e2d98a5a fix(ignore): update dependencies (#22331)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-28 08:56:01 +02:00
Koen Kanters eb69319674 chore: merge master to dev 2024-04-28 08:53:39 +02:00
Koen Kanters 8c8a6a3ecf chore: don't autoupdate eslint 2024-04-28 08:53:17 +02:00
Koen Kanters baa1d703d4 chore: don't autoupdate eslint 2024-04-28 08:50:56 +02:00
Koen KantersandKoenkk 37551265a2 fix(ignore): update zigbee-herdsman-converters to 19.29.0 (#22325)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-27 07:19:33 +00:00
Koen KantersandKoenkk 245afb1126 fix(ignore): update zigbee-herdsman-converters to 19.28.0 (#22318)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-25 20:31:11 +00:00
Koen KantersandKoenkk 389779d4e3 fix(ignore): update zigbee-herdsman to 0.43.1 (#22317)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-25 19:50:10 +00:00
Koen KantersandKoenkk df23240683 fix(ignore): update zigbee-herdsman-converters to 19.27.0 (#22301)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-23 20:46:01 +00:00
Koen KantersandKoenkk 75f9743528 fix(ignore): update zigbee-herdsman to 0.43.0 (#22300)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-23 20:25:41 +00:00
Koen Kantersandnurikk e5d632bc91 fix(ignore): update zigbee2mqtt-frontend to 0.6.163 (#22288)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2024-04-22 11:31:56 +00:00
Koen KantersandKoenkk 4f97a7ad2c fix(ignore): update zigbee-herdsman to 0.42.5 (#22285)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-21 20:47:08 +00:00
Koen KantersandKoenkk 4bf2ffa7dd fix(ignore): update zigbee-herdsman-converters to 19.26.0 (#22284)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-21 19:09:52 +00:00
Koen KantersandKoenkk a8f6bec673 fix(ignore): update zigbee-herdsman to 0.42.4 (#22283)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-21 19:01:53 +00:00
Koen KantersandKoenkk 80504b943d fix(ignore): update zigbee-herdsman-converters to 19.25.0 (#22266)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-20 10:04:27 +00:00
Koen KantersandKoenkk d9628df17b fix(ignore): update zigbee-herdsman-converters to 19.24.0 (#22254)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-18 20:29:12 +00:00
Koen KantersandKoenkk f7abbd5f5a fix(ignore): update zigbee-herdsman to 0.42.3 (#22253)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-18 20:15:59 +00:00
Koen KantersandKoenkk 1544317fce fix(ignore): update zigbee-herdsman-converters to 19.23.0 (#22232)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-16 19:49:38 +00:00
Koen KantersandKoenkk 172aa7461a fix(ignore): update zigbee-herdsman-converters to 19.22.0 (#22221)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-15 20:13:35 +00:00
Koen KantersandKoenkk 76f5425452 fix(ignore): update zigbee-herdsman to 0.42.2 (#22220)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-15 19:55:40 +00:00
Koen KantersandKoenkk 87aa18d14c fix(ignore): update zigbee-herdsman-converters to 19.21.0 (#22199)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-13 08:07:00 +00:00
Koen KantersandKoenkk e10ab5ccb2 fix(ignore): update zigbee-herdsman-converters to 19.20.0 (#22187)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-11 19:46:11 +00:00
f774753213 fix(ignore): update zigbee-herdsman-converters to 19.19.1 (#22171)
* fix(ignore): update zigbee-herdsman-converters to 19.19.1

* fix(configure): pass definition to function call (#22154)

* Pass definition to configure

* fix test

---------

Co-authored-by: Koen Kanters <koenkanters94@gmail.com>

---------

Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
Co-authored-by: Sergei Salimov <32013287+mrskycriper@users.noreply.github.com>
2024-04-10 22:02:12 +02:00
Koen KantersandKoenkk a210c238f1 fix(ignore): update zigbee-herdsman to 0.42.1 (#22143)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-10 19:56:41 +00:00
Koen KantersandKoenkk cdbbe0d49f fix(ignore): update zigbee-herdsman-converters to 19.19.0 (#22153)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-09 19:21:28 +00:00
Koen Kanters c98e5a74de fix: Remove deprecated Home Assistant color_mode (#22142) 2024-04-08 21:19:17 +02:00
Koen KantersandKoenkk 8b8d660b06 fix(ignore): update zigbee-herdsman-converters to 19.18.0 (#22141)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-08 19:14:41 +00:00
Nerivec 3c1a4afcb7 fix: Convert logger into a class (#22129) 2024-04-07 20:52:55 +02:00
Koen KantersandKoenkk cabc9f5e2f fix(ignore): update zigbee-herdsman-converters to 19.17.0 (#22122)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-07 08:46:33 +00:00
Koen KantersandKoenkk 28b8aff1ae fix(ignore): update zigbee-herdsman to 0.42.0 (#22121)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-07 10:42:45 +02:00
Koen KantersandKoenkk e7487189cf fix(ignore): update dependencies (#22007)
* fix(ignore): update dependencies

* fix

---------

Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-06 13:02:02 +02:00
Koen Kanters 92f53235f3 fix: Fix crash when stopping in case MQTT client did not connect yet (#22110) 2024-04-06 10:49:22 +00:00
Koen Kanters 002050f35b fix: Reduce MQTT publishing by excluding debug logging from bridge/logging (#22066)
* fix: Reduce MQTT publishing by excluding debug logging from `bridge/logging`

* fix tests

* fix
2024-04-06 12:44:43 +02:00
Koen KantersandKoenkk 394a3fbd8a fix(ignore): update zigbee-herdsman-converters to 19.16.0 (#22107)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-06 09:46:26 +00:00
Koen Kanters af3a0332f9 chore: Update stale.yml 2024-04-06 08:33:30 +02:00
Koen Kantersandnurikk 0c80129466 fix(ignore): update zigbee2mqtt-frontend to 0.6.162 (#22094)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2024-04-05 11:51:31 +00:00
Koen KantersandKoenkk ae35aaed5e fix(ignore): update zigbee-herdsman-converters to 19.15.0 (#22089)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-04 20:06:53 +00:00
Koen Kanters 9d251dbad5 fix: Disable automatic reconfigure when configureKey changes (#22088) 2024-04-04 21:47:44 +02:00
Koen KantersandKoenkk a020138f6b fix(ignore): update zigbee-herdsman to 0.41.2 (#22087)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-04 19:36:33 +00:00
Koen KantersandKoenkk 701cdda38c Update zigbee-herdsman-converters to 19.14.0 (#22065)
* fix(ignore): update zigbee-herdsman-converters to 19.14.0

* fix tests

---------

Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-04 19:33:39 +00:00
Nerivec 919c4eee73 fix: Cleanup old logger leftovers (#22069) 2024-04-04 20:24:39 +02:00
Koen KantersandKoenkk 3e913495b1 fix(ignore): update zigbee-herdsman to 0.41.1 (#22064)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-03 18:26:22 +00:00
Koen Kanters bbb6f9e042 fix: Change MQTT publish log to debug (#22063) 2024-04-03 18:13:46 +00:00
Koen Kanters cdd6fb0864 fix(ignore): fix 73566df 2024-04-02 22:39:33 +02:00
Koen Kanters 780c065fa1 fix(ignore): fix 73566dff98 2024-04-02 22:38:02 +02:00
Koen KantersandKoenkk db7d9e1120 fix(ignore): update zigbee-herdsman-converters to 19.13.0 (#22046)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-02 22:37:10 +02:00
NerivecandKoen Kanters 73566dff98 feat: Unified logging across z2m/zh/zhc (#21984)
* Unified logging across z2m/zh/zhc

* Updates

---------

Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2024-04-02 22:33:51 +02:00
Koen KantersandKoenkk bd0a41107a fix(ignore): update zigbee-herdsman to 0.41.0 (#22045)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-02 22:33:26 +02:00
Koen KantersandKoenkk 62844ff637 Update zigbee-herdsman-converters to 19.12.1 (#22044)
* fix(ignore): update zigbee-herdsman-converters to 19.12.1

* fix tests

---------

Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-02 20:17:43 +00:00
github-actions[bot] 5077b03a02 chore: promote to dev 2024-04-01 18:06:23 +00:00
Koen Kanters ffc2ff1d0e chore(dev): release 1.36.1 (#21638) 2024-04-01 19:52:17 +02:00
Koen KantersandKoenkk 3aadab0925 fix(ignore): update zigbee-herdsman-converters to 19.11.2 (#22021)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-04-01 17:49:24 +00:00
Koen KantersandKoenkk 85c00b8b43 fix(ignore): update zigbee-herdsman-converters to 19.11.1 (#22014)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-31 19:12:44 +00:00
Koen KantersandKoenkk e51792c65a fix(ignore): update zigbee-herdsman-converters to 19.11.0 (#22013)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-31 18:44:03 +00:00
Koen KantersandKoenkk 1e74b2f53f fix(ignore): update zigbee-herdsman to 0.40.3 (#22010)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-31 08:01:06 +00:00
Koen KantersandKoenkk 886b5345d7 fix(ignore): update zigbee-herdsman to 0.40.2 (#22000)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-30 10:14:12 +00:00
Koen KantersandKoenkk 7d8b5374ad fix(ignore): update zigbee-herdsman-converters to 19.10.0 (#21996)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-29 19:48:10 +00:00
Koen KantersandKoenkk 0c7730f4c5 fix(ignore): update zigbee-herdsman to 0.40.1 (#21987)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-28 20:08:42 +00:00
Koen KantersandKoenkk a7aab714ba fix(ignore): update zigbee-herdsman-converters to 19.9.0 (#21970)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-27 19:57:56 +00:00
Koen KantersandKoenkk 032b9a096e fix(ignore): update zigbee-herdsman-converters to 19.8.0 (#21957)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-26 19:47:40 +00:00
Koen KantersandKoenkk 1f3d61dc7f Update zigbee-herdsman-converters to 19.7.0 (#21947)
* fix(ignore): update zigbee-herdsman-converters to 19.7.0

* fix tests

---------

Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-25 21:24:00 +00:00
Koen KantersandKoenkk 32b173c653 fix(ignore): update zigbee-herdsman-converters to 19.6.0 (#21927)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-24 13:48:20 +00:00
Koen KantersandKoenkk 8c4cd8c9aa fix(ignore): update dependencies (#21923)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-24 08:06:46 +01:00
Koen KantersandKoenkk 73423ebd60 fix(ignore): update zigbee-herdsman-converters to 19.5.0 (#21902)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-21 19:35:31 +00:00
Koen KantersandKoenkk 334e260b85 fix(ignore): update zigbee-herdsman to 0.40.0 (#21901)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-21 19:32:32 +00:00
Koen Kanters 731957dab9 chore: merge master to dev 2024-03-21 20:10:51 +01:00
wexmanandJens Weiermann 9c505fd75f chore: optimized svg logo (#21874)
* optimized svg image

* preserved original border

---------

Co-authored-by: Jens Weiermann <j.weiermann@wilms.com>
2024-03-21 20:10:33 +01:00
Koen KantersandKoenkk 25cec20f0d fix(ignore): update zigbee-herdsman-converters to 19.4.2 (#21892)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-20 20:40:45 +00:00
Koen KantersandKoenkk f05d501380 fix(ignore): update zigbee-herdsman to 0.39.1 (#21891)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-20 20:27:30 +00:00
Koen KantersandKoenkk b02c34c6e5 fix(ignore): update zigbee-herdsman-converters to 19.4.1 (#21872)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-19 20:57:38 +00:00
Koen KantersandKoenkk 801134b630 fix(ignore): update zigbee-herdsman to 0.39.0 (#21871)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-19 20:50:21 +00:00
Koen KantersandKoenkk 73d43007d4 fix(ignore): update zigbee-herdsman-converters to 19.4.0 (#21870)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-19 20:30:52 +00:00
Koen KantersandKoenkk 1a9e05e6ab fix(ignore): update zigbee-herdsman-converters to 19.3.0 (#21851)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-17 20:21:30 +00:00
Koen KantersandKoenkk 1006725b09 fix(ignore): update dependencies (#21832)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-17 10:18:56 +01:00
Koen KantersandKoenkk a4f9c49ab5 fix(ignore): update zigbee-herdsman-converters to 19.2.1 (#21801)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-14 19:58:50 +00:00
Koen KantersandKoenkk 658622880f fix(ignore): update zigbee-herdsman to 0.38.1 (#21802)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-14 20:55:17 +01:00
Koen KantersandKoenkk 720fc491ce fix(ignore): update zigbee-herdsman-converters to 19.1.1 (#21789)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-13 19:45:48 +00:00
Koen KantersandKoenkk 56feb77f2d fix(ignore): update zigbee-herdsman-converters to 19.1.0 (#21778)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-12 20:42:14 +00:00
Koen KantersandKoenkk 233d263e13 fix(ignore): update zigbee-herdsman to 0.38.0 (#21777)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-12 20:32:24 +00:00
Koen KantersandKoenkk dbd616fbe5 fix(ignore): update zigbee-herdsman-converters to 19.0.0 (#21768)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-11 20:51:22 +00:00
Koen KantersandKoenkk 86c2e90e06 Update zigbee-herdsman-converters to 18.49.0 (#21767)
* fix(ignore): update zigbee-herdsman-converters to 18.49.0

* fix tests

---------

Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-11 20:40:55 +00:00
Koen KantersandKoenkk 43610bbff4 fix(ignore): Update dependencies (#21740)
* fix(ignore): update dependencies

* fix

* fix

---------

Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-10 19:08:48 +00:00
Koen Kanters 29543bad4b fix(ignore): fix tests 2024-03-10 19:54:35 +01:00
Koen KantersandKoenkk 2a047a469c fix(ignore): update zigbee-herdsman-converters to 18.48.0 (#21753)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-10 19:45:41 +01:00
Koen Kanters 83502f2d30 fix(ignore): Update manufacturer codes (#21729) 2024-03-10 19:45:22 +01:00
Koen KantersandKoenkk b60f679814 fix(ignore): update zigbee-herdsman to 0.37.0 (#21743)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-10 19:41:50 +01:00
Sergei Salimov 14e044093c fix: Home Assistant: remove button name if device_class is present (#21726)
* Remove preset name if button has device class

* fix device_class for consumer_connected

* combine device_class checks
2024-03-08 22:39:15 +01:00
Koen KantersandKoenkk 3188038a7b fix(ignore): update zigbee-herdsman-converters to 18.47.0 (#21728)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-08 21:36:44 +00:00
Koen KantersandKoenkk ebf996acd8 fix(ignore): update zigbee-herdsman to 0.36.2 (#21727)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-08 21:26:15 +00:00
Koen KantersandKoenkk 0e0eccbd13 fix(ignore): update zigbee-herdsman-converters to 18.46.0 (#21714)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-07 21:03:26 +00:00
Koen KantersandKoenkk e834e93deb fix(ignore): update zigbee-herdsman to 0.36.1 (#21713)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-07 20:05:21 +00:00
Koen KantersandKoenkk d37c164993 fix(ignore): update zigbee-herdsman-converters to 18.45.0 (#21699)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-06 19:36:32 +00:00
Koen KantersandKoenkk 6daa7aa61d fix(ignore): update zigbee-herdsman-converters to 18.44.0 (#21690)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-05 21:36:10 +00:00
koenkk 1110d3f8ef fix: Fix incorrect update state when Z2M is restarted during OTA update https://github.com/Koenkk/zigbee2mqtt/issues/12794 2024-03-05 22:33:33 +01:00
Koen KantersandKoenkk 1d73d9d476 fix(ignore): update zigbee-herdsman to 0.36.0 (#21689)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-05 20:51:14 +00:00
Simon RettbergandKoen Kanters 2da80954d6 fix: Fix crash due to race condition during interview (#21680)
Fixes #21118
Mentioned in #21036 and #20688

Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2024-03-05 21:28:35 +01:00
koenkk e2fa66a38c chore: fix ci 2024-03-05 21:23:07 +01:00
koenkk 94382ee418 chore: fix ci 2024-03-05 21:19:56 +01:00
Koen KantersandKoenkk 94c92ef021 Update zigbee-herdsman-converters to 18.43.0 (#21678)
* fix(ignore): update zigbee-herdsman-converters to 18.43.0

* Update configure.test.js

---------

Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-04 21:44:31 +00:00
Koen KantersandKoenkk 8c86849bb9 fix(ignore): update dependencies (#21657)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-04 22:32:11 +01:00
Koen KantersandKoenkk 30909d57fb fix(ignore): update zigbee-herdsman to 0.35.3 (#21677)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-04 21:23:13 +00:00
Koen Kantersandnurikk aaed8ab95e fix(ignore): update zigbee2mqtt-frontend to 0.6.161 (#21660)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2024-03-03 09:39:49 +00:00
Koen Kanters f24b20b133 chore: merge master to dev 2024-03-02 22:19:13 +01:00
koenkk 2a53b8e59b chore: fix release ci 2024-03-02 22:18:48 +01:00
Koen KantersandKoenkk ccaa1281a3 fix(ignore): update zigbee-herdsman to 0.35.2 (#21654)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-02 21:16:37 +00:00
Sergei Salimov 01843fe6c4 fix: Add more Home Assistant entity icons (#21652) 2024-03-02 21:46:46 +01:00
Koen Kanters 66fb672492 fix: Fix set endpoint name conversion (#21653)
* fix: Fix `set` endpoint name conversion https://github.com/Koenkk/zigbee2mqtt/issues/21646

* fix
2024-03-02 21:45:19 +01:00
koenkk 38ed953599 chore: promote to dev 2024-03-01 21:17:51 +01:00
Koen Kanters 86ed71cb1f chore(dev): release 1.36.0 (#21266) 2024-03-01 20:58:11 +01:00
Koen KantersandKoenkk ccc31670ec fix(ignore): update zigbee-herdsman-converters to 18.42.0 (#21637)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-01 19:50:46 +00:00
Koen KantersandKoenkk 0d8f5780dd fix(ignore): update zigbee-herdsman to 0.35.1 (#21636)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-03-01 19:45:01 +00:00
Koen Kantersandnurikk a8a262640f fix(ignore): update zigbee2mqtt-frontend to 0.6.159 (#21623)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2024-02-29 20:45:12 +00:00
Koen KantersandKoenkk 72333f0888 fix(ignore): update zigbee-herdsman-converters to 18.41.0 (#21622)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-29 20:20:07 +00:00
Koen KantersandKoenkk a84083be40 fix(ignore): update zigbee-herdsman to 0.35.0 (#21621)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-29 16:57:19 +00:00
Sergei Salimov 9b8583e778 fix: Add noise and noise_detected to HA lookup (#21610) 2024-02-29 17:42:48 +01:00
Koen KantersandKoenkk aeaa040920 fix(ignore): update zigbee-herdsman-converters to 18.40.0 (#21603)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-27 20:04:02 +00:00
Koen KantersandKoenkk 222b67b1b0 fix(ignore): update zigbee-herdsman to 0.34.9 (#21602)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-27 19:04:44 +00:00
Koen KantersandKoenkk 1ee1710a96 Update zigbee-herdsman-converters to 18.39.1 (#21590)
* fix(ignore): update zigbee-herdsman-converters to 18.39.1

* fix

---------

Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-26 21:32:08 +00:00
Koen Kanters a1e2ee7837 chore: merge master to dev 2024-02-26 21:45:09 +01:00
koenkk f04ade6d76 chore: Update problem_report template 2024-02-26 21:44:00 +01:00
Koen KantersandKoenkk fee0811893 fix(ignore): update zigbee-herdsman to 0.34.8 (#21588)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-26 19:21:45 +00:00
Koen Kanters 956003aa0a fix: Fix endpoint matching for TuYa dummy endpoints (#21576) 2024-02-26 20:02:05 +01:00
Koen KantersandKoenkk 8fb28f9e2f Update zigbee-herdsman-converters to 18.38.0 (#21568)
* fix(ignore): update zigbee-herdsman-converters to 18.38.0

* fix tests

---------

Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-25 21:09:35 +00:00
Koen KantersandKoenkk a06a0ea059 fix(ignore): update zigbee-herdsman to 0.34.7 (#21575)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-25 21:04:49 +00:00
Koen KantersandKoenkk 4f71c1e8bf fix(ignore): update zigbee-herdsman to 0.34.6 (#21567)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-25 13:57:37 +00:00
Koen KantersandKoenkk c56470c83f fix(ignore): update dependencies (#21561)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-25 14:12:46 +01:00
Koen KantersandKoenkk cc27032752 fix(ignore): update zigbee-herdsman to 0.34.5 (#21563)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-25 09:03:54 +00:00
Koen KantersandKoenkk 7c97902d4a fix(ignore): update zigbee-herdsman-converters to 18.37.1 (#21549)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-24 08:31:39 +00:00
Koen KantersandKoenkk 23959c5b0e fix(ignore): update zigbee-herdsman to 0.34.4 (#21545)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-23 20:40:51 +00:00
Koen KantersandKoenkk e726d5907e fix(ignore): update zigbee-herdsman to 0.34.3 (#21530)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-22 19:37:33 +00:00
Koen KantersandKoenkk 1337f54e4a fix(ignore): update zigbee-herdsman-converters to 18.37.0 (#21533)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-22 19:32:59 +00:00
Oleksandr Masliuchenko 471902e531 fix: Improve nested group and device name match (#21521) 2024-02-22 19:54:09 +01:00
Koen KantersandKoenkk 22d33360f6 fix(ignore): update zigbee-herdsman-converters to 18.36.0 (#21504)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-20 20:04:54 +00:00
koenkk db1e3b73d1 fix: Only configure routers on startup https://github.com/Koenkk/zigbee2mqtt/issues/21446 2024-02-20 20:24:03 +01:00
Artem Draft fe73db4ff6 fix: trigger_count & power_outage_count adjustments in HA (#21492) 2024-02-20 19:47:45 +01:00
Koen Kanters 9dd6eeee7b chore: merge master to dev 2024-02-20 19:47:25 +01:00
Oleksandr Masliuchenko 3c609658fd fix: Fix parsing entity name that contains slash in the name and endpoint specification (#21493) 2024-02-20 19:47:22 +01:00
Anthony Bourguignon 8b694432aa fix: Check if Zigbee2MQTT is running in update.sh (#21494) 2024-02-20 19:46:44 +01:00
Koen KantersandKoenkk b32bd309ca fix(ignore): update zigbee-herdsman-converters to 18.35.0 (#21487)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-19 21:08:45 +00:00
Koen KantersandKoenkk 78b5e6fd4b fix(ignore): update zigbee-herdsman to 0.34.2 (#21478)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-19 20:46:48 +00:00
Koen Kanters c5c0a8b193 feat: Add experimental Ember adapter @Nerivec https://github.com/Koenkk/zigbee-herdsman/pull/918 (#21459) 2024-02-18 22:11:09 +01:00
Koen KantersandKoenkk b51eef2319 fix(ignore): update zigbee-herdsman to 0.34.1 (#21461)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-18 21:01:03 +00:00
Koen KantersandKoenkk ebed9d31d7 fix(ignore): update zigbee-herdsman-converters to 18.34.0 (#21456)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-18 16:39:07 +00:00
Koen KantersandKoenkk 16b251d3c9 fix(ignore): update dependencies (#21444)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-18 16:23:36 +01:00
Koen KantersandKoenkk 5766bf479b fix(ignore): update zigbee-herdsman-converters to 18.33.2 (#21416)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-15 19:01:26 +00:00
Koen KantersandKoenkk 8a9477f2bb fix(ignore): update zigbee-herdsman-converters to 18.33.1 (#21400)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-14 18:49:57 +00:00
Koen KantersandKoenkk 2aaf878889 fix(ignore): update zigbee-herdsman-converters to 18.33.0 (#21387)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-13 19:28:16 +00:00
Koen KantersandKoenkk dfa499659e fix(ignore): update zigbee-herdsman to 0.33.9 (#21386)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-13 19:05:11 +00:00
Sergei SalimovandKoen Kanters 24b5f9b43a feat: Add support for button entity in Home Assistant (#21372)
* add button entity support

* add enabled_by_default to select

---------

Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2024-02-13 19:43:21 +01:00
Koen KantersandKoenkk 0ab7390a64 fix(ignore): update zigbee-herdsman-converters to 18.32.0 (#21373)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-12 20:31:38 +00:00
Koen KantersandKoenkk c7b820c376 fix(ignore): update zigbee-herdsman-converters to 18.31.0 (#21359)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-11 20:46:36 +00:00
Koen KantersandKoenkk ce41f2f8ba fix(ignore): update dependencies (#21347)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-11 21:07:38 +01:00
Koen Kantersandnurikk 8bc3c46c34 fix(ignore): update zigbee2mqtt-frontend to 0.6.158 (#21356)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2024-02-11 18:17:21 +00:00
Oleksandr Masliuchenko b4473d6e24 fix: Remove predefined endpoint list, remove limits on device friendly name (#21325)
* Get rid of predefined list of endpoints, correct friend name validations

* Fix minor bug while parsing a topic for an unknown device
2024-02-10 16:50:44 +01:00
Koen KantersandKoenkk 910f3a6860 fix(ignore): update zigbee-herdsman-converters to 18.30.0 (#21315)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-08 19:58:46 +00:00
Oleksandr MasliuchenkoandKoen Kanters 81335e5b4b fix: Remove dependency on predefined list of endpoints (parseEntityID() function) (#21264)
* Add function resolveEntityByID() function as an improved alternative for parseEntityID()

* Port deviceGroupMembership.ts from parseEntityID() to resolveEntityByID()

* Migrade groups.ts from parseEntityID() to resolveEntityByID()

* Improve test coverage on groups functionality

* Migrate from parseEntityID() to resolveEntityByID() for bridge functionality

* Migrate from parseEntityID() to resolveEntityByID() for bind functionality

* Finally get rid of parseEntityID() function

* Fix linter issues

* Move resolveEntityAndEndpoint() function to zigbee.ts

---------

Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2024-02-08 20:54:05 +01:00
Koen KantersandKoenkk 55653c974e fix(ignore): update zigbee-herdsman-converters to 18.29.1 (#21290)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-07 20:32:47 +00:00
Oleksandr MasliuchenkoandKoen Kanters a5a87a79a8 fix: Match endpoint name in the MQTT topic based on endpoints listed in the definition (#21214)
* Add endpoint matching based on the endpoint names list in the device definition

* Cleanup debug prints

* Correct creating endpoint names list, filter out null elements

* Correct test due to slightly changed behavior

* Fix endpoint name lookup when converter does not provide full name-to-id mapping

* Simplify topic name matching

* Remove obsolete branches, improve code coverage

* Make linter happy

* Make linter happy

* Remove dependency on endpoint names while removing device from all groups

* Get rid of predefined list of endpoints when processing group state update

---------

Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2024-02-07 20:12:48 +01:00
Koen Kantersandnurikk a479bf9039 fix(ignore): update zigbee2mqtt-frontend to 0.6.157 (#21269)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2024-02-06 21:32:15 +00:00
koenkk b7e19cc86a chore: promote to dev 2024-02-06 21:06:28 +01:00
Koen Kanters fe0742a628 chore(dev): release 1.35.3 (#21139) 2024-02-06 20:48:14 +01:00
Koen KantersandKoenkk 29bae3a734 fix(ignore): update zigbee-herdsman-converters to 18.28.3 (#21265)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-06 19:44:44 +00:00
Koen Kanters 6aa6f04059 chore: merge master to dev 2024-02-06 17:46:32 +01:00
Issy Szemeti fc6d74e2b7 chore: Fix spelling/grammar (#21260) 2024-02-06 17:46:13 +01:00
Koen KantersandKoenkk 92e0d7895b Update zigbee-herdsman to 0.33.8 (#21246)
* fix(ignore): update zigbee-herdsman to 0.33.8

* fix

---------

Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-05 19:57:03 +00:00
Koen KantersandKoenkk 250d5bfc24 fix(ignore): update zigbee-herdsman-converters to 18.28.2 (#21245)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-05 19:38:54 +00:00
Koen Kantersandnurikk dfda63138f fix(ignore): update zigbee2mqtt-frontend to 0.6.156 (#21229)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2024-02-04 22:35:57 +00:00
Koen Kantersandnurikk 8ab6c16f6e fix(ignore): update zigbee2mqtt-frontend to 0.6.155 (#21228)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2024-02-04 22:20:13 +00:00
Koen KantersandKoenkk a6d51a60a8 fix(ignore): update zigbee-herdsman-converters to 18.28.1 (#21222)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-04 20:05:53 +00:00
Koen KantersandKoenkk 3d37892296 fix(ignore): update zigbee-herdsman-converters to 18.28.0 (#21207)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-04 08:28:03 +00:00
Koen KantersandKoenkk 1df54f071f fix(ignore): update dependencies (#21204)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-04 09:16:48 +01:00
koenkk b3253bacc4 chore: remove @types/rimraf 2024-02-04 09:13:03 +01:00
Koen KantersandKoenkk 4b1721721b fix(ignore): update zigbee-herdsman-converters to 18.27.1 (#21189)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-03 13:57:30 +00:00
Koen Kantersandnurikk 2db07236db fix(ignore): update zigbee2mqtt-frontend to 0.6.154 (#21188)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2024-02-03 12:14:17 +00:00
Koen Kantersandnurikk 7295955397 fix(ignore): update zigbee2mqtt-frontend to 0.6.153 (#21187)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2024-02-03 12:10:41 +00:00
koenkk f83709d96c fix(ignore): set zhc logger 2024-02-03 12:27:04 +01:00
Koen KantersandKoenkk a678a1c652 fix(ignore): update zigbee-herdsman-converters to 18.27.0 (#21184)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-03 11:24:11 +00:00
Koen KantersandKoenkk 3d1336dc6c fix(ignore): update zigbee-herdsman to 0.33.7 (#21178)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-02 20:58:09 +00:00
koenkk da1d0d81c2 chore: fix release-please 2024-02-01 22:03:28 +01:00
koenkk 335e18fe98 chore: fix release-please 2024-02-01 22:02:01 +01:00
Koen Kanters 6e2539af09 chore: merge master to dev 2024-02-01 21:58:18 +01:00
koenkk fdc9f7d4d4 chore: fix dev -> master on release 2024-02-01 21:57:56 +01:00
Koen KantersandKoenkk 08c03e48cc fix(ignore): update zigbee-herdsman-converters to 18.26.0 (#21145)
Co-authored-by: Koenkk <2892853+Koenkk@users.noreply.github.com>
2024-02-01 20:35:49 +00:00
koenkk 6621f6fd91 chore: promote to dev 2024-02-01 17:45:05 +01:00
145 changed files with 31205 additions and 29347 deletions
-2
View File
@@ -1,2 +0,0 @@
node_modules/*
test/
-46
View File
@@ -1,46 +0,0 @@
module.exports = {
'env': {
'jest/globals': true,
'es6': true,
'node': true,
},
'extends': ['eslint:recommended', 'google', 'plugin:jest/recommended', 'plugin:jest/style'],
'parserOptions': {
'ecmaVersion': 2018,
'sourceType': 'module',
},
'rules': {
'require-jsdoc': 'off',
'indent': ['error', 4],
'max-len': ['error', {'code': 120}],
'no-prototype-builtins': 'off',
'linebreak-style': ['error', (process.platform === 'win32' ? 'windows' : 'unix')], // https://stackoverflow.com/q/39114446/2771889
},
'plugins': [
'jest',
],
'overrides': [{
files: ['*.ts'],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
extends: ['plugin:@typescript-eslint/recommended'],
parserOptions: {
project: './tsconfig.json',
},
rules: {
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/explicit-function-return-type': 'error',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unused-vars': 'error',
'@typescript-eslint/semi': ['error'],
'array-bracket-spacing': ['error', 'never'],
'indent': ['error', 4],
'max-len': ['error', {'code': 120}],
'no-return-await': 'error',
'object-curly-spacing': ['error', 'never'],
},
}],
};
+2 -2
View File
@@ -1,4 +1,4 @@
github: [koenkk]
custom:
- https://www.paypal.me/koenkk
- https://www.buymeacoffee.com/koenkk
- https://www.paypal.me/koenkk
- https://www.buymeacoffee.com/koenkk
+18 -18
View File
@@ -1,20 +1,20 @@
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 if 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
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.
- name: Home Assistant addon issues
url: https://github.com/zigbee2mqtt/hassio-zigbee2mqtt/issues
about: Issues related to the Home Assistant addon.
- name: FAQ
url: https://www.zigbee2mqtt.io/guide/faq
about: Frequently asked questions.
- name: Support Chat
url: https://discord.gg/NyseBeK
about: Chat for feedback, questions and troubleshooting.
- 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
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.
- name: Home Assistant addon issues
url: https://github.com/zigbee2mqtt/hassio-zigbee2mqtt/issues
about: Issues related to the Home Assistant addon.
- name: FAQ
url: https://www.zigbee2mqtt.io/guide/faq
about: Frequently asked questions.
- name: Support Chat
url: https://discord.gg/NyseBeK
about: Chat for feedback, questions and troubleshooting.
+34 -34
View File
@@ -1,38 +1,38 @@
name: Feature request
description: Suggest an idea for this project
title: "[Feature request]: "
title: '[Feature request]: '
labels: [feature request]
body:
- type: markdown
attributes:
value: |
**IMPORTANT:** Before submitting:
- Is your feature request related to the fronted? Then click [here](https://github.com/nurikk/zigbee2mqtt-frontend/issues/new?assignees=&labels=&template=feature_request.md&title=)
- type: textarea
id: textarea1
attributes:
label: Is your feature request related to a problem? Please describe
placeholder: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
validations:
required: true
- type: textarea
id: textarea2
attributes:
label: Describe the solution you'd like
placeholder: A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
id: textarea3
attributes:
label: Describe alternatives you've considered
placeholder: A clear and concise description of any alternative solutions or features you've considered.
validations:
required: true
- type: textarea
id: textarea4
attributes:
label: Additional context
placeholder: Add any other context or screenshots about the feature request here.
validations:
required: true
- type: markdown
attributes:
value: |
**IMPORTANT:** Before submitting:
- Is your feature request related to the frontend? Then click [here](https://github.com/nurikk/zigbee2mqtt-frontend/issues/new?assignees=&labels=&template=feature_request.md&title=)
- type: textarea
id: textarea1
attributes:
label: Is your feature request related to a problem? Please describe
placeholder: A clear and concise description of what the problem is. Eg. I'm always frustrated when [...]
validations:
required: true
- type: textarea
id: textarea2
attributes:
label: Describe the solution you'd like
placeholder: A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
id: textarea3
attributes:
label: Describe alternatives you've considered
placeholder: A clear and concise description of any alternative solutions or features you've considered.
validations:
required: true
- type: textarea
id: textarea4
attributes:
label: Additional context
placeholder: Add any other context or screenshots about the feature request here.
validations:
required: true
+53 -39
View File
@@ -1,43 +1,57 @@
name: New device support request
description: Request support for a new device
title: "[New device support]: "
title: '[New device support]: '
labels: [new device support]
body:
- type: markdown
attributes:
value: |
**IMPORTANT:** Before submitting:
- Make sure this device is not already supported in the dev branch by checking the [dev branch changelog](https://gist.github.com/Koenkk/bfd4c3d1725a2cccacc11d6ba51008ba#new-supported-devices)
- Make sure there is no existing issue or PR for this device already, search for your device here: https://github.com/Koenkk/zigbee2mqtt/issues
- Follow this [guide](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html)
- If you are using the Home Assistant addon and are still on 1.18.1, check the first point of [this](https://github.com/Koenkk/zigbee2mqtt/releases/tag/1.19.0)
- type: input
id: link
attributes:
label: Link
description: Link of this device (product page)
placeholder: https://www.linktomydevice.org
validations:
required: true
- type: input
id: database
attributes:
label: Database entry
description: Entry of this device in `data/database.db` after pairing it
placeholder: '{"id":53,"type":"Router","ieeeAddr":"0x10458d00024284f69","nwkAddr":10148,"manufId":4151,"manufName":"LUMI","powerSource":"DC Source","modelId":"lumi.relay.c2acn01","epList":[1,2],"endpoints":{"1":{"profId":260,"epId":1,"devId":257,"inClusterList":[0,3,4,5,1,2,10,6,16,2820,12],"outClusterList":[25,10],"clusters":{"genBasic":{"attributes":{"modelId":"lumi.relay.c2acn01","appVersion":1,"manufacturerName":"LUMI","powerSource":4,"zclVersion":0,"stackVersion":2,"hwVersion":18,"dateCode":"8-6-2020"}},"genAnalogInput":{"attributes":{"presentValue":129.04425048828125}},"genOnOff":{"attributes":{"61440":117440715,"onOff":1}}},"binds":[],"configuredReportings":[],"meta":{}},"2":{"profId":260,"epId":2,"devId":257,"inClusterList":[6,16,4,5],"outClusterList":[],"clusters":{"genOnOff":{"attributes":{"61440":237478966,"onOff":0}}},"binds":[],"configuredReportings":[],"meta":{}}},"appVersion":1,"stackVersion":2,"hwVersion":18,"dateCode":"8-6-2020","zclVersion":0,"interviewCompleted":true,"meta":{},"lastSeen":1640285631405}'
validations:
required: true
- type: textarea
id: notes
attributes:
label: Comments
placeholder: I tried to follow the supporting new device page but got stuck at...
validations:
required: true
- type: textarea
attributes:
label: External defintion
description: See [Creating the external definition](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html#_2-creating-the-external-definition)
render: shell
validations:
required: true
- type: markdown
attributes:
value: |
**IMPORTANT:** Before submitting:
- Make sure this device is not already supported in the dev branch by checking the [dev branch changelog](https://gist.github.com/Koenkk/bfd4c3d1725a2cccacc11d6ba51008ba#new-supported-devices)
- Make sure there is no existing issue or PR for this device already, search for your device here: https://github.com/Koenkk/zigbee2mqtt/issues
- Follow this [guide](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html)
- If you are using the Home Assistant addon and are still on 1.18.1, check the first point of [this](https://github.com/Koenkk/zigbee2mqtt/releases/tag/1.19.0)
- type: input
id: link
attributes:
label: Link
description: Link of this device (product page)
placeholder: https://www.linktomydevice.org
validations:
required: true
- type: input
id: database
attributes:
label: Database entry
description: Entry of this device in `data/database.db` after pairing it
placeholder: '{"id":53,"type":"Router","ieeeAddr":"0x10458d00024284f69","nwkAddr":10148,"manufId":4151,"manufName":"LUMI","powerSource":"DC Source","modelId":"lumi.relay.c2acn01","epList":[1,2],"endpoints":{"1":{"profId":260,"epId":1,"devId":257,"inClusterList":[0,3,4,5,1,2,10,6,16,2820,12],"outClusterList":[25,10],"clusters":{"genBasic":{"attributes":{"modelId":"lumi.relay.c2acn01","appVersion":1,"manufacturerName":"LUMI","powerSource":4,"zclVersion":0,"stackVersion":2,"hwVersion":18,"dateCode":"8-6-2020"}},"genAnalogInput":{"attributes":{"presentValue":129.04425048828125}},"genOnOff":{"attributes":{"61440":117440715,"onOff":1}}},"binds":[],"configuredReportings":[],"meta":{}},"2":{"profId":260,"epId":2,"devId":257,"inClusterList":[6,16,4,5],"outClusterList":[],"clusters":{"genOnOff":{"attributes":{"61440":237478966,"onOff":0}}},"binds":[],"configuredReportings":[],"meta":{}}},"appVersion":1,"stackVersion":2,"hwVersion":18,"dateCode":"8-6-2020","zclVersion":0,"interviewCompleted":true,"meta":{},"lastSeen":1640285631405}'
validations:
required: true
- type: input
id: z2m_version
attributes:
label: Zigbee2MQTT version
description: Can be found in the frontend -> settings -> about -> Zigbee2MQTT version. Are you running Zigbee2MQTT 1.18.1? Then read [this](https://github.com/Koenkk/zigbee2mqtt/releases/tag/1.19.0).
placeholder: '1.22.1'
validations:
required: true
- type: textarea
id: notes
attributes:
label: Comments
placeholder: I tried to follow the supporting new device page but got stuck at...
validations:
required: true
- type: textarea
attributes:
label: External definition
description: See [Creating the external definition](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html#_2-creating-the-external-definition)
render: shell
validations:
required: true
- type: textarea
attributes:
label: What does/doesn't work with the external definition?
description: Indicate what works and what doesn't work with the external definition.
validations:
required: true
+72 -71
View File
@@ -2,74 +2,75 @@ name: Problem report
description: Create a report to help us improve
labels: [problem]
body:
- type: markdown
attributes:
value: |
**IMPORTANT:** Before submitting:
- You read the [FAQ](https://www.zigbee2mqtt.io/guide/faq/)
- Zigbee2MQTT fails to start? Read [this](https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start.html)
- Make sure the bug also occurs in the [dev branch](https://www.zigbee2mqtt.io/advanced/more/switch-to-dev-branch.html)
- Make sure you are using the [latest firmware](https://www.zigbee2mqtt.io/guide/adapters/#recommended) on your adapter
- The issue has not been [reported already](https://github.com/Koenkk/zigbee2mqtt/issues)
- Is your issue related to the fronted? Then click [here](https://github.com/nurikk/zigbee2mqtt-frontend/issues/new?assignees=&labels=bug%2Ctriage&template=bug_report.yaml&title=%5BBug%5D%3A+)
- If you are using the Home Assistant addon and are still on 1.18.1, check the first point of [this](https://github.com/Koenkk/zigbee2mqtt/releases/tag/1.19.0)
- type: textarea
id: what_happend
attributes:
label: What happened?
validations:
required: true
- type: textarea
id: expect_to_happen
attributes:
label: What did you expect to happen?
placeholder: I expected that ...
validations:
required: false
- type: textarea
id: reproduce
attributes:
label: How to reproduce it (minimal and precise)
placeholder: First do this, than this..
validations:
required: false
- type: input
id: z2m_version
attributes:
label: Zigbee2MQTT version
description: Can be found in the frontend -> settings -> about -> Zigbee2MQTT version. Are you running Zigbee2MQTT 1.18.1? Then read [this](https://github.com/Koenkk/zigbee2mqtt/releases/tag/1.19.0).
placeholder: '1.22.1'
validations:
required: true
- type: input
id: adapter_fwversion
attributes:
label: Adapter firmware version
description: Can be found in the frontend -> settings -> about -> coordinator revision
placeholder: '20211210'
validations:
required: true
- type: input
id: adapter
attributes:
label: Adapter
description: The adapter you are using
placeholder: Electrolama zig-a-zig-ah! (zzh!), Slaesh's CC2652RB stick, ...
validations:
required: true
- type: input
id: setup
attributes:
label: Setup
description: How do you run Z2M (plain, add-on...) and on what machine (Pi, x86-64, containerized...)?
placeholder: Add-on on Home Assistant OS on Intel NUC, Plain on Docker container, ...
validations:
required: true
- type: textarea
id: log
attributes:
label: Debug log
description: After enabling [debug logging](https://www.zigbee2mqtt.io/guide/usage/debug.html) the log can be found under `data/log`. Attach the file below
placeholder: Click here and drag the file into it or click on "Attach files by.." below
validations:
required: false
- type: markdown
attributes:
value: |
**IMPORTANT:** Before submitting:
- You read the [FAQ](https://www.zigbee2mqtt.io/guide/faq/)
- Are you using an EZSP adapter (e.g. Dongle-E/SkyConnect)? Try the [new driver](https://github.com/Koenkk/zigbee2mqtt/discussions/21462)
- Zigbee2MQTT fails to start? Read [this](https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start.html)
- Make sure the bug also occurs in the [dev branch](https://www.zigbee2mqtt.io/advanced/more/switch-to-dev-branch.html)
- Make sure you are using the [latest firmware](https://www.zigbee2mqtt.io/guide/adapters/#recommended) on your adapter
- The issue has not been [reported already](https://github.com/Koenkk/zigbee2mqtt/issues)
- Is your issue related to the frontend? Then click [here](https://github.com/nurikk/zigbee2mqtt-frontend/issues/new?assignees=&labels=bug%2Ctriage&template=bug_report.yaml&title=%5BBug%5D%3A+)
- If you are using the Home Assistant addon and are still on 1.18.1, check the first point of [this](https://github.com/Koenkk/zigbee2mqtt/releases/tag/1.19.0)
- type: textarea
id: what_happend
attributes:
label: What happened?
validations:
required: true
- type: textarea
id: expect_to_happen
attributes:
label: What did you expect to happen?
placeholder: I expected that ...
validations:
required: false
- type: textarea
id: reproduce
attributes:
label: How to reproduce it (minimal and precise)
placeholder: First do this, then this..
validations:
required: false
- type: input
id: z2m_version
attributes:
label: Zigbee2MQTT version
description: Can be found in the frontend -> settings -> about -> Zigbee2MQTT version. Are you running Zigbee2MQTT 1.18.1? Then read [this](https://github.com/Koenkk/zigbee2mqtt/releases/tag/1.19.0).
placeholder: '1.22.1'
validations:
required: true
- type: input
id: adapter_fwversion
attributes:
label: Adapter firmware version
description: Can be found in the frontend -> settings -> about -> coordinator revision
placeholder: '20211210'
validations:
required: true
- type: input
id: adapter
attributes:
label: Adapter
description: The adapter you are using. In case of EZSP, try the [new `ember` driver](https://github.com/Koenkk/zigbee2mqtt/discussions/21462) first.
placeholder: Electrolama zig-a-zig-ah! (zzh!), Slaesh's CC2652RB stick, ...
validations:
required: true
- type: input
id: setup
attributes:
label: Setup
description: How do you run Z2M (plain, add-on...) and on what machine (Pi, x86-64, containerized...)?
placeholder: Add-on on Home Assistant OS on Intel NUC, Plain on Docker container, ...
validations:
required: true
- type: textarea
id: log
attributes:
label: Debug log
description: After enabling [debug logging](https://www.zigbee2mqtt.io/guide/configuration/logging.html#debugging) the log can be found under `data/log`. Attach the file below
placeholder: Click here and drag the file into it or click on "Attach files by.." below
validations:
required: false
+60 -60
View File
@@ -1,64 +1,64 @@
name: Wrong device picture/vendor/model/description
description: Use if device is detected as supported and is fully functional but has a wrong picture, vendor, model or description
title: "[Wrong device]: "
title: '[Wrong device]: '
labels: [wrong device]
body:
- type: markdown
attributes:
value: |
Only use this if the device is detected as **supported** and is **fully functional** but has a wrong picture, vendor, model or description.
- type: input
id: link
attributes:
label: Link
description: Link of this device (product page)
placeholder: https://www.linktomydevice.org
validations:
required: true
- type: input
id: model
attributes:
label: Model
description: Expected model, model that is printed on the device, for TuYa device this is NOT something like TS0601 or _TZE200_cf1sl3tj
placeholder: RTCGQ01LM
validations:
required: true
- type: input
id: description
attributes:
label: Description
description: Expected description
placeholder: Motion sensor
validations:
required: true
- type: input
id: vendor
attributes:
label: Vendor
description: Expected vendor
placeholder: Xiaomi
validations:
required: true
- type: input
id: picture
attributes:
label: Picture (link)
description: Expected picture
placeholder: https://www.linktomydevice.org/RTCGQ01LM.jpg
validations:
required: true
- type: input
id: database
attributes:
label: Database entry
description: Entry of this device in `data/database.db` after pairing it
placeholder: '{"id":53,"type":"Router","ieeeAddr":"0x10458d00024284f69","nwkAddr":10148,"manufId":4151,"manufName":"LUMI","powerSource":"DC Source","modelId":"lumi.relay.c2acn01","epList":[1,2],"endpoints":{"1":{"profId":260,"epId":1,"devId":257,"inClusterList":[0,3,4,5,1,2,10,6,16,2820,12],"outClusterList":[25,10],"clusters":{"genBasic":{"attributes":{"modelId":"lumi.relay.c2acn01","appVersion":1,"manufacturerName":"LUMI","powerSource":4,"zclVersion":0,"stackVersion":2,"hwVersion":18,"dateCode":"8-6-2020"}},"genAnalogInput":{"attributes":{"presentValue":129.04425048828125}},"genOnOff":{"attributes":{"61440":117440715,"onOff":1}}},"binds":[],"configuredReportings":[],"meta":{}},"2":{"profId":260,"epId":2,"devId":257,"inClusterList":[6,16,4,5],"outClusterList":[],"clusters":{"genOnOff":{"attributes":{"61440":237478966,"onOff":0}}},"binds":[],"configuredReportings":[],"meta":{}}},"appVersion":1,"stackVersion":2,"hwVersion":18,"dateCode":"8-6-2020","zclVersion":0,"interviewCompleted":true,"meta":{},"lastSeen":1640285631405}'
validations:
required: true
- type: textarea
id: notes
attributes:
label: Notes
placeholder: Some additional notes...
validations:
required: false
- type: markdown
attributes:
value: |
Only use this if the device is detected as **supported** and is **fully functional** but has a wrong picture, vendor, model or description.
- type: input
id: link
attributes:
label: Link
description: Link of this device (product page)
placeholder: https://www.linktomydevice.org
validations:
required: true
- type: input
id: model
attributes:
label: Model
description: Expected model, model that is printed on the device, for Tuya device this is NOT something like TS0601 or _TZE200_cf1sl3tj
placeholder: RTCGQ01LM
validations:
required: true
- type: input
id: description
attributes:
label: Description
description: Expected description
placeholder: Motion sensor
validations:
required: true
- type: input
id: vendor
attributes:
label: Vendor
description: Expected vendor
placeholder: Xiaomi
validations:
required: true
- type: input
id: picture
attributes:
label: Picture (link)
description: Expected picture
placeholder: https://www.linktomydevice.org/RTCGQ01LM.jpg
validations:
required: true
- type: input
id: database
attributes:
label: Database entry
description: Entry of this device in `data/database.db` after pairing it
placeholder: '{"id":53,"type":"Router","ieeeAddr":"0x10458d00024284f69","nwkAddr":10148,"manufId":4151,"manufName":"LUMI","powerSource":"DC Source","modelId":"lumi.relay.c2acn01","epList":[1,2],"endpoints":{"1":{"profId":260,"epId":1,"devId":257,"inClusterList":[0,3,4,5,1,2,10,6,16,2820,12],"outClusterList":[25,10],"clusters":{"genBasic":{"attributes":{"modelId":"lumi.relay.c2acn01","appVersion":1,"manufacturerName":"LUMI","powerSource":4,"zclVersion":0,"stackVersion":2,"hwVersion":18,"dateCode":"8-6-2020"}},"genAnalogInput":{"attributes":{"presentValue":129.04425048828125}},"genOnOff":{"attributes":{"61440":117440715,"onOff":1}}},"binds":[],"configuredReportings":[],"meta":{}},"2":{"profId":260,"epId":2,"devId":257,"inClusterList":[6,16,4,5],"outClusterList":[],"clusters":{"genOnOff":{"attributes":{"61440":237478966,"onOff":0}}},"binds":[],"configuredReportings":[],"meta":{}}},"appVersion":1,"stackVersion":2,"hwVersion":18,"dateCode":"8-6-2020","zclVersion":0,"interviewCompleted":true,"meta":{},"lastSeen":1640285631405}'
validations:
required: true
- type: textarea
id: notes
attributes:
label: Notes
placeholder: Some additional notes...
validations:
required: false
+10 -10
View File
@@ -1,13 +1,13 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
target-branch: dev
- package-ecosystem: 'github-actions'
directory: '/'
schedule:
interval: 'daily'
target-branch: dev
- package-ecosystem: "docker"
directory: "/docker"
schedule:
interval: "daily"
target-branch: dev
- package-ecosystem: 'docker'
directory: '/docker'
schedule:
interval: 'daily'
target-branch: dev
+190 -124
View File
@@ -3,130 +3,196 @@ name: ci
on: [pull_request, push]
permissions:
contents: write
pull-requests: write
contents: write
pull-requests: write
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: https://registry.npmjs.org/
cache: npm
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Test
run: npm run test-with-coverage
- name: Lint
run: npm run eslint
- name: Docker login
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
run: echo ${{ secrets.DOCKER_KEY }} | docker login -u koenkk --password-stdin
- name: Docker login ghcr.io
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
run: echo ${{ secrets.GH_TOKEN }} | docker login ghcr.io -u koenkk --password-stdin
- name: Docker setup - QEMU
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
uses: docker/setup-qemu-action@v3
with:
platforms: all
- name: Docker setup - Buildx
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
id: buildx
uses: docker/setup-buildx-action@v3
with:
version: latest
- name: "dev: Docker build"
if: github.ref == 'refs/heads/dev' && github.event_name == 'push'
run: |
docker buildx build \
--build-arg COMMIT=$(git rev-parse --short HEAD) \
--platform linux/arm64/v8,linux/386,linux/amd64,linux/arm/v6,linux/arm/v7 \
-f docker/Dockerfile \
--provenance=false \
--push \
-t koenkk/zigbee2mqtt:latest-dev -t ghcr.io/koenkk/zigbee2mqtt:latest-dev \
.
- name: "release: Docker build"
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
run: |
TAG="$(git describe --tags)"
docker buildx build \
--build-arg COMMIT=$(git rev-parse --short HEAD) \
--platform linux/arm64/v8,linux/386,linux/amd64,linux/arm/v6,linux/arm/v7 \
-f docker/Dockerfile \
--provenance=false \
--push \
-t koenkk/zigbee2mqtt:latest -t "koenkk/zigbee2mqtt:$TAG" -t ghcr.io/koenkk/zigbee2mqtt:latest -t "ghcr.io/koenkk/zigbee2mqtt:$TAG" \
.
- name: "release: Publish to npm"
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
run: npm publish
env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN }}
- name: "dev: Trigger zigbee2mqtt/hassio-zigbee2mqtt build"
if: github.ref == 'refs/heads/dev' && github.event_name == 'push'
run: |
curl \
-X POST \
-H "Authorization: token ${{ secrets.GH_TOKEN }}" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/zigbee2mqtt/hassio-zigbee2mqtt/actions/workflows/ci.yml/dispatches \
-d '{"ref":"master","inputs":{}}'
- name: "release: Trigger zigbee2mqtt/hassio-zigbee2mqtt build"
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
run: |
TAG=${GITHUB_REF#refs/*/}
echo "Triggering with tag '$TAG'"
curl \
-X POST \
-H "Authorization: token ${{ secrets.GH_TOKEN }}" \
-H "Accept: application/vnd.github.everest-preview+json" \
-H "Content-Type: application/json" \
https://api.github.com/repos/zigbee2mqtt/hassio-zigbee2mqtt/dispatches \
--data "{\"event_type\": \"release\", \"client_payload\": { \"version\": \"$TAG-1\"}}"
- name: "release: merge dev -> master and promote dev"
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
run: |
TAG=${GITHUB_REF#refs/*/}
git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git fetch origin
git checkout master
git merge -m 'chore: merge dev to master' origin/dev
git push origin master
git checkout dev
jq ".version = \"$TAG-dev\"" package.json > package.json.tmp
jq ".version = \"$TAG-dev\"" package-lock.json > package-lock.json.tmp
mv package.json.tmp package.json
mv package-lock.json.tmp package-lock.json
git add -A
git commit -m "chore: promote to dev"
git push origin dev
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
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 }}
tests:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: [18, 20]
runs-on: ${{ matrix.os }}
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
registry-url: https://registry.npmjs.org/
cache: 'npm'
- name: Install dependencies
# --ignore-scripts prevents the serialport build which often fails on Windows
run: npm ci --ignore-scripts
- name: Build
run: npm run build
- name: Test
run: npm run test-with-coverage
- uses: actions/checkout@v4
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: 20
registry-url: https://registry.npmjs.org/
cache: pnpm
- name: Install dependencies
run: pnpm i --frozen-lockfile
- name: Build
run: pnpm run build
- name: Lint
run: |
pnpm run pretty:check
pnpm run eslint
- name: Test
run: pnpm run test:coverage
- 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
with:
username: koenkk
password: ${{ secrets.DOCKER_KEY }}
- name: Log in to the GitHub container registry
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: koenkk
password: ${{ secrets.GH_TOKEN }}
- name: Docker setup - QEMU
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
uses: docker/setup-qemu-action@v3
with:
platforms: all
- name: Docker setup - Buildx
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
id: buildx
uses: docker/setup-buildx-action@v3
with:
version: latest
- name: dev - Docker build and push
if: github.ref == 'refs/heads/dev' && github.event_name == 'push'
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile
platforms: linux/arm64/v8,linux/amd64,linux/arm/v6,linux/arm/v7,linux/riscv64,linux/386
tags: koenkk/zigbee2mqtt:latest-dev,ghcr.io/koenkk/zigbee2mqtt:latest-dev
push: true
build-args: |
COMMIT=${{ github.sha }}
VERSION=dev
DATE=${{ github.event.repository.updated_at }}
- name: release - Docker meta
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
uses: docker/metadata-action@v5
id: meta
with:
images: |
koenkk/zigbee2mqtt
ghcr.io/koenkk/zigbee2mqtt
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: release - Docker build and push
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile
provenance: false
platforms: linux/arm64/v8,linux/amd64,linux/arm/v6,linux/arm/v7,linux/riscv64,linux/386
tags: ${{ steps.meta.outputs.tags }}
push: true
build-args: |
COMMIT=${{ github.sha }}
VERSION=${{ github.ref_name }}
DATE=${{ github.event.repository.updated_at }}
- name: 'release: Publish to npm'
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
run: pnpm publish --no-git-checks
env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN }}
- name: 'dev: Trigger zigbee2mqtt/hassio-zigbee2mqtt build'
if: github.ref == 'refs/heads/dev' && github.event_name == 'push'
run: |
curl \
-X POST \
-H "Authorization: token ${{ secrets.GH_TOKEN }}" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/zigbee2mqtt/hassio-zigbee2mqtt/actions/workflows/ci.yml/dispatches \
-d '{"ref":"master","inputs":{}}'
- name: 'release: Trigger zigbee2mqtt/hassio-zigbee2mqtt build'
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
run: |
TAG=${GITHUB_REF#refs/*/}
echo "Triggering with tag '$TAG'"
curl \
-X POST \
-H "Authorization: token ${{ secrets.GH_TOKEN }}" \
-H "Accept: application/vnd.github.everest-preview+json" \
-H "Content-Type: application/json" \
https://api.github.com/repos/zigbee2mqtt/hassio-zigbee2mqtt/dispatches \
--data "{\"event_type\": \"release\", \"client_payload\": { \"version\": \"$TAG-1\"}}"
- name: 'release: Trigger zigbee2mqtt-chart image update'
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
run: |
TAG=${GITHUB_REF#refs/*/}
echo "Triggering with tag '$TAG'"
curl -L \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ secrets.GH_TOKEN }}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/Koenkk/zigbee2mqtt-chart/actions/workflows/on_zigbee2mqtt_release.yaml/dispatches \
--data "{\"ref\": \"main\", \"inputs\": { \"zigbee2mqtt_version\": \"$TAG\"}}"
- name: 'release: merge dev -> master and promote dev'
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
run: |
TAG=${GITHUB_REF#refs/*/}
git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git fetch --unshallow
git fetch origin
git checkout master
git merge --ff-only origin/dev
git push origin master
git checkout dev
jq --indent 4 ".version = \"$TAG-dev\"" package.json > package.json.tmp
mv package.json.tmp package.json
git add -A
git commit -m "chore: promote to dev"
git push origin dev
tests:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: [18, 20, 22]
runs-on: ${{ matrix.os }}
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: pnpm
- name: Install dependencies
# --ignore-scripts prevents the serialport build which often fails on Windows
run: pnpm i --frozen-lockfile --ignore-scripts
- name: Build
run: pnpm run build
- name: Test
run: pnpm run test:coverage
+86 -17
View File
@@ -1,23 +1,92 @@
name: codeql
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: 'CodeQL'
on:
push:
branches:
- dev
push:
branches: ['master', 'dev']
pull_request:
branches: ['master', 'dev']
schedule:
- cron: '15 2 * * 2'
jobs:
CodeQL-Build:
runs-on: ubuntu-latest
analyze:
name: Analyze (${{ matrix.language }})
# Runner size impacts CodeQL analysis time. To learn more, please see:
# - https://gh.io/recommended-hardware-resources-for-running-codeql
# - https://gh.io/supported-runners-and-hardware-resources
# - https://gh.io/using-larger-runners (GitHub.com only)
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
permissions:
# required for all workflows
security-events: write
permissions:
security-events: write
# required to fetch internal or private CodeQL packs
packages: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
# only required for workflows in private repositories
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: javascript-typescript
build-mode: none
# CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
# Use `c-cpp` to analyze code written in C, C++ or both
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# If the analyze step fails for one of the languages you are analyzing with
# "We were unable to automatically build your code", modify the matrix above
# to set the build mode to "manual" for that language. Then modify this step
# to build your code.
# ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- if: matrix.build-mode == 'manual'
shell: bash
run: |
echo 'If you are using a "manual" build mode for one or more of the' \
'languages you are analyzing, replace this with the commands to build' \
'your code, for example:'
echo ' make bootstrap'
echo ' make release'
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: '/language:${{matrix.language}}'
+8 -8
View File
@@ -2,13 +2,13 @@ name: deps-review
on: [pull_request]
permissions:
contents: read
contents: read
jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- name: 'Checkout repository'
uses: actions/checkout@v4
- name: 'Dependency review'
uses: actions/dependency-review-action@v4
dependency-review:
runs-on: ubuntu-latest
steps:
- name: 'Checkout repository'
uses: actions/checkout@v4
- name: 'Dependency review'
uses: actions/dependency-review-action@v4
+19
View File
@@ -0,0 +1,19 @@
name: Fail PR to master
on:
pull_request:
branches:
- master
jobs:
fail_pr:
runs-on: ubuntu-latest
steps:
- name: Fail PR to master
run: |
if [[ "${{ github.event.pull_request.title }}" == "chore(dev): release"* ]]; then
echo "PR title starts with 'chore(dev): release', allowing PR"
else
echo "Pull requests to the master branch are not allowed, target dev branch"
exit 1
fi
+27 -27
View File
@@ -1,34 +1,34 @@
on:
workflow_dispatch:
workflow_dispatch:
name: ghcr-cleanup
permissions: {}
jobs:
build:
runs-on: ubuntu-latest
build:
runs-on: ubuntu-latest
steps:
- name: Delete untagged images
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GH_TOKEN }}
script: |
const response = await github.request("GET /${{ env.OWNER }}/packages/container/${{ env.PACKAGE_NAME }}/versions",
{ per_page: ${{ env.PER_PAGE }}
});
for(version of response.data) {
if (version.metadata.container.tags.length == 0) {
try {
console.log("delete " + version.id)
const deleteResponse = await github.request("DELETE /${{ env.OWNER }}/packages/container/${{ env.PACKAGE_NAME }}/versions/" + version.id, { });
console.log("status " + deleteResponse.status)
} catch (e) {
console.log("failed")
}
}
}
env:
OWNER: user
PACKAGE_NAME: zigbee2mqtt
PER_PAGE: 2000
steps:
- name: Delete untagged images
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GH_TOKEN }}
script: |
const response = await github.request("GET /${{ env.OWNER }}/packages/container/${{ env.PACKAGE_NAME }}/versions",
{ per_page: ${{ env.PER_PAGE }}
});
for(version of response.data) {
if (version.metadata.container.tags.length == 0) {
try {
console.log("delete " + version.id)
const deleteResponse = await github.request("DELETE /${{ env.OWNER }}/packages/container/${{ env.PACKAGE_NAME }}/versions/" + version.id, { });
console.log("status " + deleteResponse.status)
} catch (e) {
console.log("failed")
}
}
}
env:
OWNER: user
PACKAGE_NAME: zigbee2mqtt
PER_PAGE: 2000
+14 -14
View File
@@ -1,19 +1,19 @@
name: merge-master-to-dev
on:
push:
branches:
- master
push:
branches:
- master
jobs:
merge_master_to_dev:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: devmasx/merge-branch@master
with:
type: now
head_to_merge: master
target_branch: dev
message: "chore: merge master to dev"
github_token: ${{ secrets.GH_TOKEN }}
merge_master_to_dev:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: devmasx/merge-branch@master
with:
type: now
head_to_merge: master
target_branch: dev
message: 'chore: merge master to dev'
github_token: ${{ secrets.GH_TOKEN }}
+63 -59
View File
@@ -1,69 +1,73 @@
on:
push:
branches:
- dev
push:
branches:
- dev
permissions:
contents: write
pull-requests: write
contents: write
pull-requests: write
name: release-please
jobs:
release-please:
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
version: "${{steps.release.outputs.major}}.${{steps.release.outputs.minor}}.${{steps.release.outputs.patch}}"
steps:
- uses: actions/setup-node@v4
with:
node-version: 20
release-please:
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
version: '${{steps.release.outputs.major}}.${{steps.release.outputs.minor}}.${{steps.release.outputs.patch}}'
steps:
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: google-github-actions/release-please-action@v4
id: release
with:
target-branch: dev
token: ${{secrets.GH_TOKEN}}
- uses: actions/setup-node@v4
with:
node-version: 20
# Checkout repos
- uses: actions/checkout@v4
with:
repository: koenkk/zigbee2mqtt
path: ./z2m
- uses: actions/checkout@v4
with:
repository: koenkk/zigbee2mqtt
path: ./z2m-master
ref: master
- uses: googleapis/release-please-action@v4
id: release
with:
target-branch: dev
token: ${{secrets.GH_TOKEN}}
- name: Restore cache commit-user-lookup.json
uses: actions/cache/restore@v4
with:
path: z2m/scripts/commit-user-lookup.json
key: commit-user-lookup-dummy
restore-keys: |
commit-user-lookup-
- name: Generate changelog
run: |
MASTER_Z2M_VERSION=$(cat z2m-master/package.json | jq -r '.version')
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"')
wget -q -O - https://raw.githubusercontent.com/Koenkk/zigbee2mqtt/release-please--branches--dev--components--zigbee2mqtt/CHANGELOG.md > z2m/CHANGELOG.md
cd z2m
npm ci
node scripts/generateChangelog.js $MASTER_Z2M_VERSION $MASTER_ZHC_VERSION $MASTER_ZH_VERSION $MASTER_FRONTEND_VERSION >> ../changelog.md
env:
GH_TOKEN: ${{secrets.GH_TOKEN}}
- name: Update changelog gist
run: |
gh gist edit bfd4c3d1725a2cccacc11d6ba51008ba -a changelog.md
env:
GH_TOKEN: ${{secrets.GH_TOKEN}}
- name: Save cache commit-user-lookup.json
uses: actions/cache/save@v4
if: always()
with:
path: z2m/scripts/commit-user-lookup.json
key: commit-user-lookup-${{ hashFiles('z2m/scripts/commit-user-lookup.json') }}
# Checkout repos
- uses: actions/checkout@v4
with:
repository: koenkk/zigbee2mqtt
path: ./z2m
- uses: actions/checkout@v4
with:
repository: koenkk/zigbee2mqtt
path: ./z2m-master
ref: master
- name: Restore cache commit-user-lookup.json
uses: actions/cache/restore@v4
with:
path: z2m/scripts/commit-user-lookup.json
key: commit-user-lookup-dummy
restore-keys: |
commit-user-lookup-
- name: Generate changelog
run: |
MASTER_Z2M_VERSION=$(cat z2m-master/package.json | jq -r '.version')
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"')
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
env:
GH_TOKEN: ${{secrets.GH_TOKEN}}
- name: Update changelog gist
run: |
gh gist edit bfd4c3d1725a2cccacc11d6ba51008ba -a changelog.md
env:
GH_TOKEN: ${{secrets.GH_TOKEN}}
- name: Save cache commit-user-lookup.json
uses: actions/cache/save@v4
if: always()
with:
path: z2m/scripts/commit-user-lookup.json
key: commit-user-lookup-${{ hashFiles('z2m/scripts/commit-user-lookup.json') }}
+15 -15
View File
@@ -1,20 +1,20 @@
name: stale
on:
schedule:
- cron: "0 0 * * *"
workflow_dispatch:
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: 'This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 7 days'
stale-pr-message: 'This pull request is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 7 days'
days-before-stale: 180
days-before-close: 30
exempt-issue-labels: dont-stale
operations-per-run: 500
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days'
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
operations-per-run: 500
+32 -29
View File
@@ -1,36 +1,39 @@
on:
repository_dispatch:
types: update_dep
repository_dispatch:
types: update_dep
name: update-dep
permissions: {}
jobs:
update_dep:
permissions:
contents: write
pull-requests: write
update_dep:
permissions:
contents: write
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: dev
token: ${{ secrets.GH_TOKEN }}
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm install ${{ github.event.client_payload.package }}@${{ github.event.client_payload.version }} --save-exact
- uses: peter-evans/create-pull-request@v6
id: cpr
with:
commit-message: "fix(ignore): update ${{ github.event.client_payload.package }} to ${{ github.event.client_payload.version }}"
branch: "deps/${{ github.event.client_payload.package }}"
title: Update ${{ github.event.client_payload.package }} to ${{ github.event.client_payload.version }}
token: ${{ secrets.GH_TOKEN }}
- run: sleep 5 # Otherwise pull request may not exist yet causing automerge to fail
- run: gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}"
if: steps.cpr.outputs.pull-request-operation == 'created'
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
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: 20
cache: pnpm
- run: |
pnpm install ${{ github.event.client_payload.package }}@${{ github.event.client_payload.version }} --save-exact
pnpm install --no-frozen-lockfile
- uses: peter-evans/create-pull-request@v7
id: cpr
with:
commit-message: 'fix(ignore): update ${{ github.event.client_payload.package }} to ${{ github.event.client_payload.version }}'
branch: 'deps/${{ github.event.client_payload.package }}'
title: 'fix(ignore): update ${{ github.event.client_payload.package }} to ${{ github.event.client_payload.version }}'
token: ${{ secrets.GH_TOKEN }}
- run: sleep 5 # Otherwise pull request may not exist yet causing automerge to fail
- run: gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}"
if: steps.cpr.outputs.pull-request-operation == 'created'
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
+27 -28
View File
@@ -1,34 +1,33 @@
name: Update dependencies
on:
workflow_dispatch:
schedule:
- cron: "0 0 * * 0"
workflow_dispatch:
schedule:
- cron: '0 0 * * 0'
permissions:
contents: read
contents: read
jobs:
update_deps:
permissions:
contents: write # for peter-evans/create-pull-request to create branch
pull-requests: write # for peter-evans/create-pull-request to create a PR
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: dev
token: ${{ secrets.GH_TOKEN }}
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
# connect-gzip-static@4.0.0 requires Node 20 >=
- run: npx npm-check-updates -u -x connect-gzip-static
- run: rm -f package-lock.json
- run: npm install
- uses: peter-evans/create-pull-request@v6
with:
commit-message: "fix(ignore): update dependencies"
branch: "deps/all"
title: Update dependencies
token: ${{ secrets.GH_TOKEN }}
update_deps:
permissions:
contents: write # for peter-evans/create-pull-request to create branch
pull-requests: write # for peter-evans/create-pull-request to create a PR
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: 20
cache: pnpm
- run: |
pnpm up --latest
- uses: peter-evans/create-pull-request@v7
with:
commit-message: 'fix(ignore): update dependencies'
branch: 'deps/all'
title: 'fix(ignore): update dependencies'
token: ${{ secrets.GH_TOKEN }}
+1 -13
View File
@@ -63,18 +63,6 @@ tsconfig.tsbuildinfo
# dotenv environment variables file
.env
# data
data/database.db
data/config.json
data/log*.txt
data/state.json
data/log
data-backup/
data/coordinator_backup.json
data/.storage
data/database.db.backup
data/extension
# MacOS indexing file
.DS_Store
@@ -82,7 +70,7 @@ data/extension
.idea
# Ignore config
data/*.yaml
data/*
!data/configuration.example.yaml
# commit-user-lookup.json
+2
View File
@@ -0,0 +1,2 @@
pnpm-lock.yaml
CHANGELOG.md
+28
View File
@@ -0,0 +1,28 @@
{
"semi": true,
"trailingComma": "all",
"singleQuote": true,
"printWidth": 150,
"bracketSpacing": false,
"endOfLine": "lf",
"tabWidth": 4,
"importOrder": [
"^[./]*/mocks",
"",
"<TYPES>^(node:)",
"",
"<TYPES>",
"",
"<TYPES>^[.]",
"",
"<BUILTIN_MODULES>",
"",
"<THIRD_PARTY_MODULES>",
"",
"^zigbee",
"",
"^[.]"
],
"importOrderParserPlugins": ["typescript", "decorators"],
"plugins": ["@ianvs/prettier-plugin-sort-imports"]
}
+2 -2
View File
@@ -1,3 +1,3 @@
{
".": "1.35.2"
}
".": "2.1.1"
}
+1018 -385
View File
File diff suppressed because it is too large Load Diff
+11 -12
View File
@@ -1,4 +1,3 @@
# Contributor Covenant Code of Conduct
## Our Pledge
@@ -18,23 +17,23 @@ diverse, inclusive, and healthy community.
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
- Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
- The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
@@ -106,7 +105,7 @@ Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
+1 -1
View File
@@ -4,5 +4,5 @@ Everybody is invited and welcome to contribute to Zigbee2MQTT. Zigbee2MQTT is wr
- Pull requests are always created against the [**dev**](https://github.com/Koenkk/zigbee2mqtt/tree/dev) branch.
- Easiest way to start developing Zigbee2MQTT is by setting up a development environment (aka bare-metal installation). You can follow this [guide](https://www.zigbee2mqtt.io/guide/installation/01_linux.html) to do this.
- You can run the tests locally by executing `npm test`. Zigbee2MQTT enforces 100% code coverage, in case you add new code check if your code is covered by running `npm run test-with-coverage`. The coverage report can be found under `coverage/lcov-report/index.html`. Linting is also enforced and can be run with `npm run eslint`.
- You can run the tests locally by executing `pnpm test`. Zigbee2MQTT enforces 100% code coverage, in case you add new code check if your code is covered by running `pnpm run test-with-coverage`. The coverage report can be found under `coverage/lcov-report/index.html`. Linting is also enforced and can be run with `pnpm run eslint`.
- When you want to add support for a new device no changes to Zigbee2MQTT have to be made, only to zigbee-herdsman-converters. You can find a guide for it [here](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html).
+20 -5
View File
@@ -5,8 +5,8 @@
<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 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">
@@ -43,16 +43,19 @@
</div>
## [Getting started](https://www.zigbee2mqtt.io/guide/getting-started)
The [documentation](https://www.zigbee2mqtt.io/) provides you all the information needed to get up and running! Make sure you don't skip sections if this is your first visit, as there might be important details in there for you.
If you aren't familiar with **Zigbee** terminology make sure you [read this](https://www.zigbee2mqtt.io/advanced/zigbee/01_zigbee_network.html) to help you out.
## [Integrations](https://www.zigbee2mqtt.io/guide/usage/integrations.html)
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)
@@ -61,6 +64,7 @@ Zigbee2MQTT integrates well with (almost) every home automation solution because
<img align="left" height="100px" width="100px" src="https://etc.athom.com/logo/white/256.png">
### [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)
@@ -69,6 +73,7 @@ Zigbee2MQTT integrates well with (almost) every home automation solution because
<img align="left" height="100px" width="100px" src="https://user-images.githubusercontent.com/2734836/47615848-b8dd8700-dabd-11e8-9d77-175002dd8987.png">
### [Domoticz](https://www.domoticz.com/)
- Integration implemented in Domoticz ([documentation](https://www.domoticz.com/wiki/Zigbee2MQTT)).
<br>
@@ -76,6 +81,7 @@ Zigbee2MQTT integrates well with (almost) every home automation solution because
<img align="left" height="100px" width="100px" src="./images/gladys-assistant-logo.jpg">
### [Gladys Assistant](https://gladysassistant.com/)
- Integration implemented natively in Gladys Assistant ([documentation](https://gladysassistant.com/docs/integrations/zigbee2mqtt/)).
<br>
@@ -83,23 +89,32 @@ Zigbee2MQTT integrates well with (almost) every home automation solution because
<img align="left" height="100px" width="100px" src="https://forum.iobroker.net/assets/uploads/system/site-logo.png">
### [IoBroker](https://www.iobroker.net/)
- Integration implemented in IoBroker ([documentation](https://github.com/o0shojo0o/ioBroker.zigbee2mqtt)).
<br>
## Architecture
![Architecture](images/architecture.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 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.
### 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 `npm run build`. For faster development instead of running `npm run build` you can run `npm run build-watch` in another terminal session, this will recompile as you change files.
Zigbee2MQTT uses TypeScript (partially for now). Therefore after making changes to files in the `lib/` directory you need to recompile Zigbee2MQTT. This can be done by executing `pnpm run build`. For faster development instead of running `pnpm run build` you can run `pnpm run build-watch` in another terminal session, this will recompile as you change files.
Before running any of the commands, you'll first need to run `pnpm install --include=dev`.
Before submitting changes run `pnpm run test:coverage`, `pnpm run pretty:check` and `pnpm run eslint`
## Supported devices
See [Supported devices](https://www.zigbee2mqtt.io/supported-devices) to check whether your device is supported. There is quite an extensive list, including devices from vendors like [Xiaomi](https://www.zigbee2mqtt.io/supported-devices/#v=Xiaomi), [Ikea](https://www.zigbee2mqtt.io/supported-devices/#v=IKEA), [Philips](https://www.zigbee2mqtt.io/supported-devices/#v=Philips), [OSRAM](https://www.zigbee2mqtt.io/supported-devices/#v=OSRAM) and more.
If it's not listed in [Supported devices](https://www.zigbee2mqtt.io/supported-devices), support can be added (fairly) easily, see [How to support new devices](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html).
## Support & help
If you need assistance you can check [opened issues](https://github.com/Koenkk/zigbee2mqtt/issues). Feel free to help with Pull Requests when you were able to fix things or add new devices or just share the love on social media.
-10
View File
@@ -1,10 +0,0 @@
module.exports = {
presets: [
['@babel/preset-env', {targets: {node: 'current'}}],
'@babel/preset-typescript',
],
assumptions: {setPublicClassFields: true},
plugins: [
['@babel/plugin-proposal-decorators', {'legacy': true}],
],
};
+36 -19
View File
@@ -1,29 +1,46 @@
# Indicates the configuration version (used by configuration migrations)
version: 4
# Home Assistant integration (MQTT discovery)
homeassistant: false
homeassistant:
enabled: false
# Enable the frontend, runs on port 8080 by default
frontend: true
frontend:
enabled: true
# port: 8080
# MQTT settings
mqtt:
# MQTT base topic for zigbee2mqtt MQTT messages
base_topic: zigbee2mqtt
# MQTT server URL
server: 'mqtt://localhost'
# MQTT server authentication, uncomment if required:
# user: my_user
# password: my_password
# MQTT base topic for zigbee2mqtt MQTT messages
base_topic: zigbee2mqtt
# MQTT server URL
server: 'mqtt://localhost'
# MQTT server authentication, uncomment if required:
# user: my_user
# password: my_password
# Serial settings
serial:
# Location of CC2531 USB sniffer
port: /dev/ttyACM0
# Serial settings, only required when Zigbee2MQTT fails to start with:
# USB adapter discovery error (No valid USB adapter found).
# Specify valid 'adapter' and 'port' in your configuration.
# serial:
# # Location of the adapter
# # USB adapters - use format "port: /dev/serial/by-id/XXX"
# # Ethernet adapters - use format "port: tcp://192.168.1.12:6638"
# port: /dev/serial/by-id/usb-Texas_Instruments_TI_CC2531_USB_CDC___0X00124B0018ED3DDF-if00
# # Adapter type, allowed values: `zstack`, `ember`, `deconz`, `zigate` or `zboss`
# adapter: zstack
# Periodically check whether devices are online/offline
# availability:
# enabled: false
# Advanced settings
advanced:
# Let Zigbee2MQTT generate a network key on first start
network_key: GENERATE
# Let Zigbee2MQTT generate a pan_id on first start
pan_id: GENERATE
# Let Zigbee2MQTT generate a ext_pan_id on first start
ext_pan_id: GENERATE
# channel: 11
# Let Zigbee2MQTT generate a network key on first start
network_key: GENERATE
# Let Zigbee2MQTT generate a pan_id on first start
pan_id: GENERATE
# Let Zigbee2MQTT generate a ext_pan_id on first start
ext_pan_id: GENERATE
-20
View File
@@ -1,20 +0,0 @@
# Home Assistant integration (MQTT discovery)
homeassistant: false
# allow new devices to join
permit_join: true
# MQTT settings
mqtt:
# MQTT base topic for zigbee2mqtt MQTT messages
base_topic: zigbee2mqtt
# MQTT server URL
server: 'mqtt://localhost'
# MQTT server authentication, uncomment if required:
# user: my_user
# password: my_password
# Serial settings
serial:
# Location of CC2531 USB sniffer
port: /dev/ttyACM0
+36 -15
View File
@@ -1,26 +1,49 @@
FROM alpine:3.18.4 as base
ARG TARGETPLATFORM
# Need to use Alpine 3.18.4 which uses Node 18 for arm/v6 and arm/v7, otherwise the build hangs.
# See https://github.com/nodejs/docker-node/issues/2077
FROM alpine:3.18.4 AS linux-arm-alpine
FROM alpine:3.21 AS linux-arm64-alpine
FROM alpine:3.21 AS linux-amd64-alpine
FROM alpine:3.21 AS linux-riscv64-alpine
FROM alpine:3.21 AS linux-386-alpine
FROM linux-${TARGETARCH}-alpine AS base
ENV NODE_ENV=production
WORKDIR /app
RUN apk add --no-cache tzdata eudev tini nodejs
# Dependencies and build
FROM base as dependencies_and_build
FROM base AS deps
COPY package*.json tsconfig.json index.js ./
COPY lib ./lib
RUN apk add --no-cache --virtual .buildtools make gcc g++ python3 linux-headers git npm && \
npm ci --production --no-audit --no-optional --no-update-notifier && \
# Serialport needs to be rebuild for Alpine https://serialport.io/docs/9.x.x/guide-installation#alpine-linux
npm rebuild --build-from-source && \
apk del .buildtools
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 && \
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` && \
pnpm rebuild @serialport/bindings-cpp
# Release
FROM base as release
FROM base AS release
COPY --from=dependencies_and_build /app/node_modules ./node_modules
ARG DATE
ARG VERSION
LABEL org.opencontainers.image.authors="Koen Kanters"
LABEL org.opencontainers.image.title="Zigbee2MQTT"
LABEL org.opencontainers.image.description="Zigbee to MQTT bridge using Zigbee-herdsman"
LABEL org.opencontainers.image.url="https://github.com/Koenkk/zigbee2mqtt"
LABEL org.opencontainers.image.documentation="https://www.zigbee2mqtt.io/"
LABEL org.opencontainers.image.source="https://github.com/Koenkk/zigbee2mqtt"
LABEL org.opencontainers.image.licenses="GPL-3.0"
LABEL org.opencontainers.image.created=${DATE}
LABEL org.opencontainers.image.version=${VERSION}
COPY --from=deps /app/node_modules ./node_modules
COPY dist ./dist
COPY package.json LICENSE index.js data/configuration.yaml data/configuration.example.yaml ./
COPY package.json LICENSE index.js data/configuration.example.yaml ./
COPY docker/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
@@ -30,7 +53,5 @@ RUN mkdir /app/data
ARG COMMIT
RUN echo "$COMMIT" > dist/.hash
ENV NODE_ENV production
ENTRYPOINT ["docker-entrypoint.sh"]
CMD [ "/sbin/tini", "--", "node", "index.js"]
+13 -13
View File
@@ -1,14 +1,14 @@
version: '3'
services:
version: '3'
services:
zigbee2mqtt:
container_name: zigbee2mqtt
image: koenkk/zigbee2mqtt
volumes:
- ./data:/app/data
devices:
# CC251
#- /dev/ttyUSB_cc2531:/dev/ttyACM0
# CC2530 / GBAN GB2530S
#- /dev/ttyUSB_cc2530:/dev/ttyACM0
restart: always
network_mode: host
container_name: zigbee2mqtt
image: koenkk/zigbee2mqtt
volumes:
- ./data:/app/data
devices:
# CC251
#- /dev/ttyUSB_cc2531:/dev/ttyACM0
# CC2530 / GBAN GB2530S
#- /dev/ttyUSB_cc2530:/dev/ttyACM0
restart: always
network_mode: host
+32
View File
@@ -0,0 +1,32 @@
// @ts-check
import eslint from '@eslint/js';
import eslintConfigPrettier from 'eslint-config-prettier';
import tseslint from 'typescript-eslint';
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
{
languageOptions: {
parserOptions: {
project: true,
},
},
rules: {
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/ban-ts-comment': 'error',
'@typescript-eslint/explicit-function-return-type': 'error',
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unused-vars': 'error',
'array-bracket-spacing': ['error', 'never'],
'@typescript-eslint/return-await': ['error', 'always'],
'object-curly-spacing': ['error', 'never'],
'@typescript-eslint/no-floating-promises': 'error',
},
},
{
ignores: ['dist/', '**/*.js', '**/*.mjs'],
},
eslintConfigPrettier,
);
Executable → Regular
+120 -1
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+83 -16
View File
@@ -9,32 +9,65 @@ require('source-map-support').install();
let controller;
let stopping = false;
let watchdog = process.env.Z2M_WATCHDOG != undefined;
let watchdogCount = 0;
let unsolicitedStop = false;
// csv in minutes, default: 1min, 5min, 15min, 30min, 60min
let watchdogDelays = [2000, 60000, 300000, 900000, 1800000, 3600000];
if (watchdog && process.env.Z2M_WATCHDOG !== 'default') {
if (/^(?:(?:[0-9]*[.])?[0-9]+)+(?:,?(?:[0-9]*[.])?[0-9]+)*$/.test(process.env.Z2M_WATCHDOG)) {
watchdogDelays = process.env.Z2M_WATCHDOG.split(',').map((v) => parseFloat(v) * 60000);
} else {
console.log(`Invalid watchdog delays (must use number-only CSV format representing minutes, example: 'Z2M_WATCHDOG=1,5,15,30,60'.`);
process.exit(1);
}
}
const hashFile = path.join(__dirname, 'dist', '.hash');
async function triggerWatchdog(code) {
const delay = watchdogDelays[watchdogCount];
watchdogCount += 1;
if (delay) {
// garbage collector
controller = undefined;
console.log(`WATCHDOG: Waiting ${delay / 60000}min before next start try.`);
await new Promise((resolve) => setTimeout(resolve, delay));
await start();
} else {
process.exit(code);
}
}
async function restart() {
await stop(true);
await start();
}
async function exit(code, restart) {
async function exit(code, restart = false) {
if (!restart) {
process.exit(code);
if (watchdog && unsolicitedStop) {
await triggerWatchdog(code);
} else {
process.exit(code);
}
}
}
async function currentHash() {
const git = require('git-last-commit');
return new Promise((resolve) => {
git.getLastCommit((err, commit) => {
if (err) resolve('unknown');
else resolve(commit.shortHash);
});
git.getLastCommit((err, commit) => (err ? resolve('unknown') : resolve(commit.shortHash)));
});
}
async function writeHash() {
const hash = await currentHash();
fs.writeFileSync(hashFile, hash);
}
@@ -42,21 +75,24 @@ async function build(reason) {
return new Promise((resolve, reject) => {
process.stdout.write(`Building Zigbee2MQTT... (${reason})`);
rimrafSync('dist');
const env = {...process.env};
const _600mb = 629145600;
if (_600mb > os.totalmem() && !env.NODE_OPTIONS) {
// Prevent OOM on tsc compile for system with low memory
// https://github.com/Koenkk/zigbee2mqtt/issues/12034
env.NODE_OPTIONS = '--max_old_space_size=256';
}
exec('npm run build', {env, cwd: __dirname}, async (err, stdout, stderr) => {
exec('pnpm run build', {env, cwd: __dirname}, async (err, stdout, stderr) => {
if (err) {
process.stdout.write(', failed\n');
if (err.code === 134) {
process.stderr.write(
'\n\nBuild failed; ran out-of-memory, free some memory (RAM) and start again\n\n');
process.stderr.write('\n\nBuild failed; ran out-of-memory, free some memory (RAM) and start again\n\n');
}
reject(err);
} else {
process.stdout.write(', finished\n');
@@ -79,42 +115,72 @@ async function checkDist() {
}
async function start() {
console.log(`Starting Zigbee2MQTT ${watchdog ? `with watchdog (${watchdogDelays})` : `without watchdog`}.`);
await checkDist();
const version = engines.node;
if (!semver.satisfies(process.version, version)) {
console.log(`\t\tZigbee2MQTT requires node version ${version}, you are running ${process.version}!\n`); // eslint-disable-line
console.log(`\t\tZigbee2MQTT requires node version ${version}, you are running ${process.version}!\n`);
}
// Validate settings
const settings = require('./dist/util/settings');
settings.reRead();
// gc
{
const settingsMigration = require('./dist/util/settingsMigration');
settingsMigration.migrateIfNecessary();
}
const errors = settings.validate();
if (errors.length > 0) {
unsolicitedStop = false;
console.log(`\n\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!`);
console.log(' READ THIS CAREFULLY\n');
console.log(`Refusing to start because configuration is not valid, found the following errors:`);
for (const error of errors) {
console.log(`- ${error}`);
}
console.log(`\nIf you don't know how to solve this, read https://www.zigbee2mqtt.io/guide/configuration`); // eslint-disable-line
console.log(`\nIf you don't know how to solve this, read https://www.zigbee2mqtt.io/guide/configuration`);
console.log(`\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n\n`);
exit(1);
return exit(1);
}
const Controller = require('./dist/controller');
const {Controller} = require('./dist/controller');
controller = new Controller(restart, exit);
await controller.start();
// consider next controller.stop() call as unsolicited, only after successful first start
unsolicitedStop = true;
watchdogCount = 0; // reset
}
async function stop(restart) {
// `handleQuit` or `restart` never unsolicited
unsolicitedStop = false;
await controller.stop(restart);
}
async function handleQuit() {
if (!stopping && controller) {
stopping = true;
await stop(false);
if (!stopping) {
if (controller) {
stopping = true;
await stop(false);
} else {
process.exit(0);
}
}
}
@@ -129,5 +195,6 @@ if (require.main === module || require.main.filename.endsWith(path.sep + 'cli.js
} else {
process.on('SIGINT', handleQuit);
process.on('SIGTERM', handleQuit);
module.exports = {start};
}
+153 -122
View File
@@ -1,64 +1,84 @@
import MQTT from './mqtt';
import Zigbee from './zigbee';
import type {IClientPublishOptions} from 'mqtt';
import type * as SdNotify from 'sd-notify';
import type {Zigbee2MQTTAPI} from './types/api';
import assert from 'node:assert';
import bind from 'bind-decorator';
import stringify from 'json-stable-stringify-without-jsonify';
import {setLogger as zhSetLogger} from 'zigbee-herdsman';
import {setLogger as zhcSetLogger} from 'zigbee-herdsman-converters';
import EventBus from './eventBus';
import ExtensionAvailability from './extension/availability';
import ExtensionBind from './extension/bind';
import ExtensionBridge from './extension/bridge';
import ExtensionConfigure from './extension/configure';
import ExtensionExternalConverters from './extension/externalConverters';
import ExtensionExternalExtensions from './extension/externalExtensions';
// Extensions
import ExtensionFrontend from './extension/frontend';
import ExtensionGroups from './extension/groups';
import ExtensionHomeAssistant from './extension/homeassistant';
import ExtensionNetworkMap from './extension/networkMap';
import ExtensionOnEvent from './extension/onEvent';
import ExtensionOTAUpdate from './extension/otaUpdate';
import ExtensionPublish from './extension/publish';
import ExtensionReceive from './extension/receive';
import MQTT from './mqtt';
import State from './state';
import logger from './util/logger';
import * as settings from './util/settings';
import utils from './util/utils';
import stringify from 'json-stable-stringify-without-jsonify';
import assert from 'assert';
import bind from 'bind-decorator';
import Zigbee from './zigbee';
// Extensions
import ExtensionFrontend from './extension/frontend';
import ExtensionPublish from './extension/publish';
import ExtensionReceive from './extension/receive';
import ExtensionNetworkMap from './extension/networkMap';
import ExtensionSoftReset from './extension/legacy/softReset';
import ExtensionHomeAssistant from './extension/homeassistant';
import ExtensionConfigure from './extension/configure';
import ExtensionDeviceGroupMembership from './extension/legacy/deviceGroupMembership';
import ExtensionBridgeLegacy from './extension/legacy/bridgeLegacy';
import ExtensionBridge from './extension/bridge';
import ExtensionGroups from './extension/groups';
import ExtensionAvailability from './extension/availability';
import ExtensionBind from './extension/bind';
import ExtensionReport from './extension/legacy/report';
import ExtensionOnEvent from './extension/onEvent';
import ExtensionOTAUpdate from './extension/otaUpdate';
import ExtensionExternalConverters from './extension/externalConverters';
import ExtensionExternalExtension from './extension/externalExtension';
type SdNotifyType = typeof SdNotify;
const AllExtensions = [
ExtensionPublish, ExtensionReceive, ExtensionNetworkMap, ExtensionSoftReset, ExtensionHomeAssistant,
ExtensionConfigure, ExtensionDeviceGroupMembership, ExtensionBridgeLegacy, ExtensionBridge, ExtensionGroups,
ExtensionBind, ExtensionReport, ExtensionOnEvent, ExtensionOTAUpdate,
ExtensionExternalConverters, ExtensionFrontend, ExtensionExternalExtension, ExtensionAvailability,
ExtensionPublish,
ExtensionReceive,
ExtensionNetworkMap,
ExtensionHomeAssistant,
ExtensionConfigure,
ExtensionBridge,
ExtensionGroups,
ExtensionBind,
ExtensionOnEvent,
ExtensionOTAUpdate,
ExtensionExternalConverters,
ExtensionFrontend,
ExtensionExternalExtensions,
ExtensionAvailability,
];
type ExtensionArgs = [Zigbee, MQTT, State, PublishEntityState, EventBus,
(enable: boolean, name: string) => Promise<void>, () => void, (extension: Extension) => Promise<void>];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let sdNotify: any = null;
try {
sdNotify = process.env.NOTIFY_SOCKET ? require('sd-notify') : null;
} catch {
// sd-notify is optional
}
type ExtensionArgs = [
Zigbee,
MQTT,
State,
PublishEntityState,
EventBus,
enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
restartCallback: () => Promise<void>,
addExtension: (extension: Extension) => Promise<void>,
];
export class Controller {
private eventBus: EventBus;
private zigbee: Zigbee;
private state: State;
private mqtt: MQTT;
private restartCallback: () => void;
private exitCallback: (code: number, restart: boolean) => void;
private restartCallback: () => Promise<void>;
private exitCallback: (code: number, restart: boolean) => Promise<void>;
private extensions: Extension[];
private extensionArgs: ExtensionArgs;
private sdNotify: SdNotifyType | undefined;
constructor(restartCallback: () => void, exitCallback: (code: number, restart: boolean) => void) {
constructor(restartCallback: () => Promise<void>, exitCallback: (code: number, restart: boolean) => Promise<void>) {
logger.init();
zhSetLogger(logger);
zhcSetLogger(logger);
this.eventBus = new EventBus();
this.zigbee = new Zigbee(this.eventBus);
this.mqtt = new MQTT(this.eventBus);
@@ -67,91 +87,99 @@ export class Controller {
this.exitCallback = exitCallback;
// Initialize extensions.
this.extensionArgs = [this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus,
this.enableDisableExtension, this.restartCallback, this.addExtension];
this.extensionArgs = [
this.zigbee,
this.mqtt,
this.state,
this.publishEntityState,
this.eventBus,
this.enableDisableExtension,
this.restartCallback,
this.addExtension,
];
this.extensions = [
new ExtensionExternalConverters(...this.extensionArgs),
new ExtensionOnEvent(...this.extensionArgs),
new ExtensionBridge(...this.extensionArgs),
new ExtensionPublish(...this.extensionArgs),
new ExtensionReceive(...this.extensionArgs),
new ExtensionDeviceGroupMembership(...this.extensionArgs),
new ExtensionConfigure(...this.extensionArgs),
new ExtensionNetworkMap(...this.extensionArgs),
new ExtensionGroups(...this.extensionArgs),
new ExtensionBind(...this.extensionArgs),
new ExtensionOnEvent(...this.extensionArgs),
new ExtensionOTAUpdate(...this.extensionArgs),
new ExtensionReport(...this.extensionArgs),
new ExtensionExternalExtension(...this.extensionArgs),
new ExtensionExternalExtensions(...this.extensionArgs),
new ExtensionAvailability(...this.extensionArgs),
settings.get().frontend && new ExtensionFrontend(...this.extensionArgs),
settings.get().advanced.legacy_api && new ExtensionBridgeLegacy(...this.extensionArgs),
settings.get().external_converters.length && new ExtensionExternalConverters(...this.extensionArgs),
settings.get().homeassistant && new ExtensionHomeAssistant(...this.extensionArgs),
/* istanbul ignore next */
settings.get().advanced.soft_reset_timeout !== 0 && new ExtensionSoftReset(...this.extensionArgs),
].filter((n) => n);
];
if (settings.get().frontend.enabled) {
this.extensions.push(new ExtensionFrontend(...this.extensionArgs));
}
if (settings.get().homeassistant.enabled) {
this.extensions.push(new ExtensionHomeAssistant(...this.extensionArgs));
}
}
async start(): Promise<void> {
this.state.start();
logger.logOutput();
const info = await utils.getZigbee2MQTTVersion();
logger.info(`Starting Zigbee2MQTT version ${info.version} (commit #${info.commitHash})`);
// Start zigbee
let startResult;
try {
startResult = await this.zigbee.start();
this.sdNotify = process.env.NOTIFY_SOCKET ? await import('sd-notify') : undefined;
logger.debug('sd-notify loaded');
/* v8 ignore start */
} catch {
logger.debug('sd-notify is not installed');
}
/* v8 ignore stop */
// Start zigbee
try {
await this.zigbee.start();
this.eventBus.onAdapterDisconnected(this, this.onZigbeeAdapterDisconnected);
} catch (error) {
logger.error('Failed to start zigbee');
logger.error('Check https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start.html for possible solutions'); /* eslint-disable-line max-len */
logger.error('Failed to start zigbee-herdsman');
logger.error(
'Check https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start_crashes-runtime.html for possible solutions',
);
logger.error('Exiting...');
logger.error(error.stack);
await this.exit(1);
}
logger.error((error as Error).stack!);
// Disable some legacy options on new network creation
if (startResult === 'reset') {
settings.set(['advanced', 'homeassistant_legacy_entity_attributes'], false);
settings.set(['advanced', 'legacy_api'], false);
settings.set(['advanced', 'legacy_availability_payload'], false);
settings.set(['device_options', 'legacy'], false);
this.enableDisableExtension(false, 'BridgeLegacy');
/* v8 ignore start */
if ((error as Error).message.includes('USB adapter discovery error (No valid USB adapter found)')) {
logger.error('If this happens after updating to Zigbee2MQTT 2.0.0, see https://github.com/Koenkk/zigbee2mqtt/discussions/24364');
}
/* v8 ignore stop */
return await this.exit(1);
}
// Log zigbee clients on startup
const devices = this.zigbee.devices(false);
logger.info(`Currently ${devices.length} devices are joined:`);
for (const device of devices) {
const model = device.isSupported ?
`${device.definition.model} - ${device.definition.vendor} ${device.definition.description}` :
'Not supported';
let deviceCount = 0;
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
// `definition` validated by `isSupported`
const model = device.isSupported
? `${device.definition!.model} - ${device.definition!.vendor} ${device.definition!.description}`
: 'Not supported';
logger.info(`${device.name} (${device.ieeeAddr}): ${model} (${device.zh.type})`);
deviceCount++;
}
// Enable zigbee join
try {
if (settings.get().permit_join) {
logger.warn('`permit_join` set to `true` in configuration.yaml.');
logger.warn('Allowing new devices to join.');
logger.warn('Set `permit_join` to `false` once you joined all devices.');
}
await this.zigbee.permitJoin(settings.get().permit_join);
} catch (error) {
logger.error(`Failed to set permit join to ${settings.get().permit_join}`);
}
logger.info(`Currently ${deviceCount} devices are joined.`);
// MQTT
try {
await this.mqtt.connect();
} catch (error) {
logger.error(`MQTT failed to connect, exiting...`);
logger.error(`MQTT failed to connect, exiting... (${(error as Error).message})`);
await this.zigbee.stop();
await this.exit(1);
return await this.exit(1);
}
// Call extensions
@@ -159,23 +187,22 @@ export class Controller {
// Send all cached states.
if (settings.get().advanced.cache_state_send_on_startup && settings.get().advanced.cache_state) {
for (const entity of [...devices, ...this.zigbee.groups()]) {
for (const entity of this.zigbee.devicesAndGroupsIterator()) {
if (this.state.exists(entity)) {
this.publishEntityState(entity, this.state.get(entity), 'publishCached');
await this.publishEntityState(entity, this.state.get(entity), 'publishCached');
}
}
}
this.eventBus.onLastSeenChanged(this,
(data) => utils.publishLastSeen(data, settings.get(), false, this.publishEntityState));
this.eventBus.onLastSeenChanged(this, (data) => utils.publishLastSeen(data, settings.get(), false, this.publishEntityState));
logger.info(`Zigbee2MQTT started!`);
const watchdogInterval = sdNotify?.watchdogInterval() || 0;
const watchdogInterval = this.sdNotify?.watchdogInterval() || 0;
if (watchdogInterval > 0) {
sdNotify.startWatchdogMode(Math.floor(watchdogInterval / 2));
this.sdNotify?.startWatchdogMode(Math.floor(watchdogInterval / 2));
}
sdNotify?.ready();
this.sdNotify?.ready();
}
@bind async enableDisableExtension(enable: boolean, name: string): Promise<void> {
@@ -200,7 +227,7 @@ export class Controller {
}
async stop(restart = false): Promise<void> {
sdNotify?.stopping();
this.sdNotify?.stopping(process.pid);
// Call extensions
await this.callExtensions('stop', this.extensions);
@@ -209,22 +236,23 @@ export class Controller {
// Wrap-up
this.state.stop();
await this.mqtt.disconnect();
let code = 0;
try {
await this.zigbee.stop();
logger.info('Stopped Zigbee2MQTT');
await this.exit(0, restart);
} catch (error) {
logger.error('Failed to stop Zigbee2MQTT');
await this.exit(1, restart);
logger.error(`Failed to stop Zigbee2MQTT (${(error as Error).message})`);
code = 1;
}
sdNotify?.stopWatchdogMode();
this.sdNotify?.stopWatchdogMode();
return await this.exit(code, restart);
}
async exit(code: number, restart = false): Promise<void> {
await logger.end();
this.exitCallback(code, restart);
return await this.exitCallback(code, restart);
}
@bind async onZigbeeAdapterDisconnected(): Promise<void> {
@@ -232,9 +260,8 @@ export class Controller {
await this.stop();
}
@bind async publishEntityState(entity: Group | Device, payload: KeyValue,
stateChangeReason?: StateChangeReason): Promise<void> {
let message = {...payload};
@bind async publishEntityState(entity: Group | Device, payload: KeyValue, stateChangeReason?: StateChangeReason): Promise<void> {
let message: Zigbee2MQTTAPI['{friendlyName}'] = {...payload};
// Update state cache with new state.
const newState = this.state.set(entity, payload, stateChangeReason);
@@ -244,27 +271,34 @@ export class Controller {
message = newState;
}
const options: MQTTOptions = {
retain: utils.getObjectProperty(entity.options, 'retain', false) as boolean,
qos: utils.getObjectProperty(entity.options, 'qos', 0) as 0 | 1 | 2,
const options: IClientPublishOptions = {
retain: utils.getObjectProperty(entity.options, 'retain', false),
qos: utils.getObjectProperty(entity.options, 'qos', 0),
};
const retention = utils.getObjectProperty<number | false>(entity.options, 'retention', false);
const retention = utils.getObjectProperty(entity.options, 'retention', false);
if (retention !== false) {
options.properties = {messageExpiryInterval: retention as number};
options.properties = {messageExpiryInterval: retention};
}
if (entity.isDevice() && settings.get().mqtt.include_device_information) {
message.device = {
friendlyName: entity.name, model: entity.definition?.model,
ieeeAddr: entity.ieeeAddr, networkAddress: entity.zh.networkAddress, type: entity.zh.type,
friendlyName: entity.name,
model: entity.definition?.model,
ieeeAddr: entity.ieeeAddr,
networkAddress: entity.zh.networkAddress,
type: entity.zh.type,
manufacturerID: entity.zh.manufacturerID,
powerSource: entity.zh.powerSource, applicationVersion: entity.zh.applicationVersion,
stackVersion: entity.zh.stackVersion, zclVersion: entity.zh.zclVersion,
hardwareVersion: entity.zh.hardwareVersion, dateCode: entity.zh.dateCode,
powerSource: entity.zh.powerSource,
applicationVersion: entity.zh.applicationVersion,
stackVersion: entity.zh.stackVersion,
zclVersion: entity.zh.zclVersion,
hardwareVersion: entity.zh.hardwareVersion,
dateCode: entity.zh.dateCode,
softwareBuildID: entity.zh.softwareBuildID,
// Manufacturer name can contain \u0000, remove this.
// https://github.com/home-assistant/core/issues/85691
/* v8 ignore next */
manufacturerName: entity.zh.manufacturerName?.split('\u0000')[0],
};
}
@@ -287,7 +321,7 @@ export class Controller {
// Filter mqtt message attributes
utils.filterProperties(entity.options.filtered_attributes, message);
if (Object.entries(message).length) {
if (!utils.objectIsEmpty(message)) {
const output = settings.get().advanced.output;
if (output === 'attribute_and_json' || output === 'json') {
await this.mqtt.publish(entity.name, stringify(message), options);
@@ -301,7 +335,7 @@ export class Controller {
this.eventBus.emitPublishEntityState({entity, message, stateChangeReason, payload});
}
async iteratePayloadAttributeOutput(topicRoot: string, payload: KeyValue, options: MQTTOptions): Promise<void> {
async iteratePayloadAttributeOutput(topicRoot: string, payload: KeyValue, options: IClientPublishOptions): Promise<void> {
for (const [key, value] of Object.entries(payload)) {
let subPayload = value;
let message = null;
@@ -333,11 +367,8 @@ export class Controller {
try {
await extension[method]?.();
} catch (error) {
/* istanbul ignore next */
logger.error(`Failed to call '${extension.constructor.name}' '${method}' (${error.stack})`);
logger.error(`Failed to call '${extension.constructor.name}' '${method}' (${(error as Error).stack})`);
}
}
}
}
module.exports = Controller;
+53 -23
View File
@@ -1,12 +1,42 @@
import events from 'events';
import events from 'node:events';
import logger from './util/logger';
// eslint-disable-next-line
type ListenerKey = object;
interface EventBusMap {
adapterDisconnected: [];
permitJoinChanged: [data: eventdata.PermitJoinChanged];
publishAvailability: [];
deviceRenamed: [data: eventdata.EntityRenamed];
deviceRemoved: [data: eventdata.EntityRemoved];
lastSeenChanged: [data: eventdata.LastSeenChanged];
deviceNetworkAddressChanged: [data: eventdata.DeviceNetworkAddressChanged];
deviceAnnounce: [data: eventdata.DeviceAnnounce];
deviceInterview: [data: eventdata.DeviceInterview];
deviceJoined: [data: eventdata.DeviceJoined];
entityOptionsChanged: [data: eventdata.EntityOptionsChanged];
exposesChanged: [data: eventdata.ExposesChanged];
deviceLeave: [data: eventdata.DeviceLeave];
deviceMessage: [data: eventdata.DeviceMessage];
mqttMessage: [data: eventdata.MQTTMessage];
mqttMessagePublished: [data: eventdata.MQTTMessagePublished];
publishEntityState: [data: eventdata.PublishEntityState];
groupMembersChanged: [data: eventdata.GroupMembersChanged];
devicesChanged: [];
scenesChanged: [data: eventdata.ScenesChanged];
reconfigure: [data: eventdata.Reconfigure];
stateChange: [data: eventdata.StateChange];
}
type EventBusListener<K> = K extends keyof EventBusMap
? EventBusMap[K] extends unknown[]
? (...args: EventBusMap[K]) => Promise<void> | void
: never
: never;
export default class EventBus {
private callbacksByExtension: { [s: string]: { event: string, callback: (...args: unknown[]) => void }[] } = {};
private emitter = new events.EventEmitter();
private callbacksByExtension: {[s: string]: {event: keyof EventBusMap; callback: EventBusListener<keyof EventBusMap>}[]} = {};
private emitter = new events.EventEmitter<EventBusMap>();
constructor() {
this.emitter.setMaxListeners(100);
@@ -26,13 +56,6 @@ export default class EventBus {
this.on('permitJoinChanged', callback, key);
}
public emitPublishAvailability(): void {
this.emitter.emit('publishAvailability');
}
public onPublishAvailability(key: ListenerKey, callback: () => void): void {
this.on('publishAvailability', callback, key);
}
public emitEntityRenamed(data: eventdata.EntityRenamed): void {
this.emitter.emit('deviceRenamed', data);
}
@@ -40,10 +63,10 @@ export default class EventBus {
this.on('deviceRenamed', callback, key);
}
public emitDeviceRemoved(data: eventdata.DeviceRemoved): void {
public emitEntityRemoved(data: eventdata.EntityRemoved): void {
this.emitter.emit('deviceRemoved', data);
}
public onDeviceRemoved(key: ListenerKey, callback: (data: eventdata.DeviceRemoved) => void): void {
public onEntityRemoved(key: ListenerKey, callback: (data: eventdata.EntityRemoved) => void): void {
this.on('deviceRemoved', callback, key);
}
@@ -57,8 +80,7 @@ export default class EventBus {
public emitDeviceNetworkAddressChanged(data: eventdata.DeviceNetworkAddressChanged): void {
this.emitter.emit('deviceNetworkAddressChanged', data);
}
public onDeviceNetworkAddressChanged(
key: ListenerKey, callback: (data: eventdata.DeviceNetworkAddressChanged) => void): void {
public onDeviceNetworkAddressChanged(key: ListenerKey, callback: (data: eventdata.DeviceNetworkAddressChanged) => void): void {
this.on('deviceNetworkAddressChanged', callback, key);
}
@@ -167,22 +189,30 @@ export default class EventBus {
this.on('stateChange', callback, key);
}
private on(event: string, callback: (...args: unknown[]) => (Promise<void> | void), key: ListenerKey): void {
if (!this.callbacksByExtension[key.constructor.name]) this.callbacksByExtension[key.constructor.name] = [];
const wrappedCallback = async (...args: unknown[]): Promise<void> => {
public emitExposesAndDevicesChanged(device: Device): void {
this.emitDevicesChanged();
this.emitExposesChanged({device});
}
private on<K extends keyof EventBusMap>(event: K, callback: EventBusListener<K>, key: ListenerKey): void {
if (!this.callbacksByExtension[key.constructor.name]) {
this.callbacksByExtension[key.constructor.name] = [];
}
const wrappedCallback = async (...args: never[]): Promise<void> => {
try {
await callback(...args);
} catch (error) {
logger.error(`EventBus error '${key.constructor.name}/${event}': ${error.message}`);
logger.debug(error.stack);
logger.error(`EventBus error '${key.constructor.name}/${event}': ${(error as Error).message}`);
logger.debug((error as Error).stack!);
}
};
this.callbacksByExtension[key.constructor.name].push({event, callback: wrappedCallback});
this.emitter.on(event, wrappedCallback);
this.emitter.on(event, wrappedCallback as EventBusListener<K>);
}
public removeListeners(key: ListenerKey): void {
this.callbacksByExtension[key.constructor.name]?.forEach(
(e) => this.emitter.removeListener(e.event, e.callback));
this.callbacksByExtension[key.constructor.name]?.forEach((e) => this.emitter.removeListener(e.event, e.callback));
}
}
+110 -79
View File
@@ -1,12 +1,18 @@
import Extension from './extension';
import logger from '../util/logger';
import utils from '../util/utils';
import * as settings from '../util/settings';
import debounce from 'debounce';
import type {Zigbee2MQTTAPI} from 'lib/types/api';
import assert from 'node:assert';
import bind from 'bind-decorator';
import debounce from 'debounce';
import * as zhc from 'zigbee-herdsman-converters';
const retrieveOnReconnect = [
import logger from '../util/logger';
import * as settings from '../util/settings';
import utils from '../util/utils';
import Extension from './extension';
const RETRIEVE_ON_RECONNECT: readonly {keys: string[]; condition?: (state: KeyValue) => boolean}[] = [
{keys: ['state']},
{keys: ['brightness'], condition: (state: KeyValue): boolean => state.state === 'ON'},
{keys: ['color', 'color_temp'], condition: (state: KeyValue): boolean => state.state === 'ON'},
@@ -25,72 +31,76 @@ export default class Availability extends Extension {
return utils.minutes(device.options.availability.timeout);
}
const key = this.isActiveDevice(device) ? 'active' : 'passive';
let value = settings.get().availability?.[key]?.timeout;
if (value == null) value = key == 'active' ? 10 : 1500;
return utils.minutes(value);
const type = this.isActiveDevice(device) ? 'active' : 'passive';
return utils.minutes(settings.get().availability[type].timeout);
}
private isActiveDevice(device: Device): boolean {
return (device.zh.type === 'Router' && device.zh.powerSource !== 'Battery') ||
device.zh.powerSource === 'Mains (single phase)';
return (device.zh.type === 'Router' && device.zh.powerSource !== 'Battery') || device.zh.powerSource === 'Mains (single phase)';
}
private isAvailable(entity: Device | Group): boolean {
if (entity.isDevice()) {
const ago = Date.now() - entity.zh.lastSeen;
return ago < this.getTimeout(entity);
return Date.now() - (entity.zh.lastSeen ?? /* v8 ignore next */ 0) < this.getTimeout(entity);
} else {
return entity.membersDevices().length === 0 ||
entity.membersDevices().map((d) => this.availabilityCache[d.ieeeAddr]).includes(true);
const membersDevices = entity.membersDevices();
return membersDevices.length === 0 || membersDevices.some((d) => this.availabilityCache[d.ieeeAddr]);
}
}
private resetTimer(device: Device): void {
clearTimeout(this.timers[device.ieeeAddr]);
this.removeFromPingQueue(device);
// If the timer triggers, the device is not available anymore otherwise resetTimer already have been called
// If the timer triggers, the device is not available anymore otherwise resetTimer already has been called
if (this.isActiveDevice(device)) {
// If device did not check in, ping it, if that fails it will be marked as offline
this.timers[device.ieeeAddr] = setTimeout(
() => this.addToPingQueue(device), this.getTimeout(device) + utils.seconds(1));
this.timers[device.ieeeAddr] = setTimeout(() => this.addToPingQueue(device), this.getTimeout(device) + utils.seconds(1));
} else {
this.timers[device.ieeeAddr] = setTimeout(
() => this.publishAvailability(device, true), this.getTimeout(device) + utils.seconds(1));
this.timers[device.ieeeAddr] = setTimeout(() => this.publishAvailability(device, true), this.getTimeout(device) + utils.seconds(1));
}
}
private addToPingQueue(device: Device): void {
this.pingQueue.push(device);
this.pingQueueExecuteNext();
this.pingQueueExecuteNext().catch(utils.noop);
}
private removeFromPingQueue(device: Device): void {
const index = this.pingQueue.findIndex((d) => d.ieeeAddr === device.ieeeAddr);
index != -1 && this.pingQueue.splice(index, 1);
if (index != -1) {
this.pingQueue.splice(index, 1);
}
}
private async pingQueueExecuteNext(): Promise<void> {
if (this.pingQueue.length === 0 || this.pingQueueExecuting) return;
this.pingQueueExecuting = true;
if (this.pingQueue.length === 0 || this.pingQueueExecuting) {
return;
}
this.pingQueueExecuting = true;
const device = this.pingQueue[0];
let pingedSuccessfully = false;
const available = this.availabilityCache[device.ieeeAddr] || this.isAvailable(device);
const attempts = available ? 2 : 1;
for (let i = 0; i < attempts; i++) {
for (let i = 1; i <= attempts; i++) {
try {
// Enable recovery if device is marked as available and first ping fails.
const disableRecovery = !(i == 1 && available);
await device.zh.ping(disableRecovery);
await device.zh.ping(!available || i !== 2);
pingedSuccessfully = true;
logger.debug(`Successfully pinged '${device.name}' (attempt ${i + 1}/${attempts})`);
logger.debug(`Successfully pinged '${device.name}' (attempt ${i}/${attempts})`);
break;
} catch (error) {
logger.warn(`Failed to ping '${device.name}' (attempt ${i + 1}/${attempts}, ${error.message})`);
logger.warning(`Failed to ping '${device.name}' (attempt ${i}/${attempts}, ${(error as Error).message})`);
// Try again in 3 seconds.
const lastAttempt = i - 1 === attempts;
!lastAttempt && await utils.sleep(3);
if (i !== attempts) {
await utils.sleep(3);
}
}
}
@@ -99,14 +109,16 @@ export default class Availability extends Extension {
return;
}
this.publishAvailability(device, !pingedSuccessfully);
await this.publishAvailability(device, !pingedSuccessfully);
this.resetTimer(device);
this.removeFromPingQueue(device);
// Sleep 2 seconds before executing next ping
await utils.sleep(2);
this.pingQueueExecuting = false;
this.pingQueueExecuteNext();
await this.pingQueueExecuteNext();
}
override async start(): Promise<void> {
@@ -114,89 +126,95 @@ export default class Availability extends Extension {
throw new Error('This extension cannot be restarted.');
}
this.eventBus.onEntityRenamed(this, (data) => {
this.eventBus.onEntityRenamed(this, async (data) => {
if (utils.isAvailabilityEnabledForEntity(data.entity, settings.get())) {
this.mqtt.publish(`${data.from}/availability`, null, {retain: true, qos: 1});
this.publishAvailability(data.entity, false, true);
await this.mqtt.publish(`${data.from}/availability`, '', {retain: true, qos: 1});
await this.publishAvailability(data.entity, false, true);
}
});
this.eventBus.onDeviceRemoved(this, (data) => clearTimeout(this.timers[data.ieeeAddr]));
this.eventBus.onEntityRemoved(this, (data) => data.type == 'device' && clearTimeout(this.timers[data.id]));
this.eventBus.onDeviceLeave(this, (data) => clearTimeout(this.timers[data.ieeeAddr]));
this.eventBus.onDeviceAnnounce(this, (data) => this.retrieveState(data.device));
this.eventBus.onLastSeenChanged(this, this.onLastSeenChanged);
this.eventBus.onPublishAvailability(this, this.publishAvailabilityForAllEntities);
this.eventBus.onGroupMembersChanged(this, (data) => this.publishAvailability(data.group, false));
this.publishAvailabilityForAllEntities();
}
// Publish initial availability
await this.publishAvailabilityForAllEntities();
@bind private publishAvailabilityForAllEntities(): void {
for (const entity of [...this.zigbee.devices(false), ...this.zigbee.groups()]) {
if (utils.isAvailabilityEnabledForEntity(entity, settings.get())) {
// Publish initial availability
this.publishAvailability(entity, true, false, true);
// Start availability for the devices
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
if (utils.isAvailabilityEnabledForEntity(device, settings.get())) {
this.resetTimer(device);
if (entity.isDevice()) {
this.resetTimer(entity);
// If an active device is initially unavailable, ping it.
if (this.isActiveDevice(entity) && !this.isAvailable(entity)) {
this.addToPingQueue(entity);
}
// If an active device is unavailable on start, add it to the pingqueue immediately.
if (this.isActiveDevice(device) && !this.isAvailable(device)) {
this.addToPingQueue(device);
}
}
}
}
private publishAvailability(entity: Device | Group, logLastSeen: boolean,
forcePublish=false, skipGroups=false): void {
@bind private async publishAvailabilityForAllEntities(): Promise<void> {
for (const entity of this.zigbee.devicesAndGroupsIterator(utils.deviceNotCoordinator)) {
if (utils.isAvailabilityEnabledForEntity(entity, settings.get())) {
await this.publishAvailability(entity, true, false, true);
}
}
}
private async publishAvailability(entity: Device | Group, logLastSeen: boolean, forcePublish = false, skipGroups = false): Promise<void> {
if (logLastSeen && entity.isDevice()) {
const ago = Date.now() - entity.zh.lastSeen;
const ago = Date.now() - (entity.zh.lastSeen ?? /* v8 ignore next */ 0);
if (this.isActiveDevice(entity)) {
logger.debug(`Active device '${entity.name}' was last seen ` +
`'${(ago / utils.minutes(1)).toFixed(2)}' minutes ago.`);
logger.debug(`Active device '${entity.name}' was last seen '${(ago / utils.minutes(1)).toFixed(2)}' minutes ago.`);
} else {
logger.debug(
`Passive device '${entity.name}' was last seen '${(ago / utils.hours(1)).toFixed(2)}' hours ago.`);
logger.debug(`Passive device '${entity.name}' was last seen '${(ago / utils.hours(1)).toFixed(2)}' hours ago.`);
}
}
const available = this.isAvailable(entity);
if (!forcePublish && this.availabilityCache[entity.ID] == available) {
return;
}
if (entity.isDevice() && entity.ieeeAddr in this.availabilityCache && available &&
this.availabilityCache[entity.ieeeAddr] === false) {
if (entity.isDevice() && entity.ieeeAddr in this.availabilityCache && available && this.availabilityCache[entity.ieeeAddr] === false) {
logger.debug(`Device '${entity.name}' reconnected`);
this.retrieveState(entity);
}
const topic = `${entity.name}/availability`;
const payload = utils.availabilityPayload(available ? 'online' : 'offline', settings.get());
const payload: Zigbee2MQTTAPI['{friendlyName}/availability'] = {state: available ? 'online' : 'offline'};
this.availabilityCache[entity.ID] = available;
this.mqtt.publish(topic, payload, {retain: true, qos: 1});
await this.mqtt.publish(topic, JSON.stringify(payload), {retain: true, qos: 1});
if (!skipGroups && entity.isDevice()) {
this.zigbee.groups().filter((g) => g.hasMember(entity))
.filter((g) => utils.isAvailabilityEnabledForEntity(g, settings.get()))
.forEach((g) => this.publishAvailability(g, false, forcePublish));
for (const group of this.zigbee.groupsIterator()) {
if (group.hasMember(entity) && utils.isAvailabilityEnabledForEntity(group, settings.get())) {
await this.publishAvailability(group, false, forcePublish);
}
}
}
}
@bind private onLastSeenChanged(data: eventdata.LastSeenChanged): void {
@bind private async onLastSeenChanged(data: eventdata.LastSeenChanged): Promise<void> {
if (utils.isAvailabilityEnabledForEntity(data.device, settings.get())) {
// Remove from ping queue, not necessary anymore since we know the device is online.
this.removeFromPingQueue(data.device);
this.resetTimer(data.device);
this.publishAvailability(data.device, false);
await this.publishAvailability(data.device, false);
}
}
override async stop(): Promise<void> {
this.stopped = true;
this.pingQueue = [];
Object.values(this.timers).forEach((t) => clearTimeout(t));
for (const t of Object.values(this.timers)) {
clearTimeout(t);
}
await super.stop();
}
@@ -208,20 +226,33 @@ export default class Availability extends Extension {
if (device.definition && !device.zh.interviewing && !this.retrieveStateDebouncers[device.ieeeAddr]) {
this.retrieveStateDebouncers[device.ieeeAddr] = debounce(async () => {
logger.debug(`Retrieving state of '${device.name}' after reconnect`);
// Color and color temperature converters do both, only needs to be called once.
for (const item of retrieveOnReconnect) {
if (item.condition && this.state.get(device) && !item.condition(this.state.get(device))) continue;
const converter = device.definition.toZigbee.find((c) => c.key.find((k) => item.keys.includes(k)));
for (const item of RETRIEVE_ON_RECONNECT) {
if (item.condition && this.state.get(device) && !item.condition(this.state.get(device))) {
continue;
}
const converter = device.definition!.toZigbee.find((c) => !c.key || c.key.find((k) => item.keys.includes(k)));
const options: KeyValue = device.options;
const state = this.state.get(device);
const meta: zhc.Tz.Meta = {
message: this.state.get(device), mapped: device.definition, logger, endpoint_name: null,
options, state, device: device.zh,
message: this.state.get(device),
mapped: device.definition!,
endpoint_name: undefined,
options,
state,
device: device.zh,
};
await converter?.convertGet?.(device.endpoint(), item.keys[0], meta)
.catch((e) => {
logger.error(`Failed to read state of '${device.name}' after reconnect (${e.message})`);
});
try {
const endpoint = device.endpoint();
assert(endpoint);
await converter?.convertGet?.(endpoint, item.keys[0], meta);
} catch (error) {
logger.error(`Failed to read state of '${device.name}' after reconnect (${(error as Error).message})`);
}
await utils.sleep(500);
}
}, utils.seconds(2));
+402 -282
View File
@@ -1,78 +1,100 @@
import * as settings from '../util/settings';
import logger from '../util/logger';
import utils from '../util/utils';
import Extension from './extension';
import stringify from 'json-stable-stringify-without-jsonify';
import debounce from 'debounce';
import * as zigbeeHerdsman from 'zigbee-herdsman/dist';
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from 'lib/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 {ClusterName} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype';
import Device from '../model/device';
import Group from '../model/group';
import logger from '../util/logger';
import * as settings from '../util/settings';
import utils from '../util/utils';
import Extension from './extension';
const legacyApi = settings.get().advanced.legacy_api;
const legacyTopicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/(bind|unbind)/.+$`);
const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/(bind|unbind)`);
const allClusterCandidates = ['genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl', 'closuresWindowCovering',
'hvacThermostat', 'msIlluminanceMeasurement', 'msTemperatureMeasurement', 'msRelativeHumidity',
'msSoilMoisture', 'msCO2'];
const TOPIC_REGEX = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/(bind|unbind)`);
const ALL_CLUSTER_CANDIDATES: readonly ClusterName[] = [
'genScenes',
'genOnOff',
'genLevelCtrl',
'lightingColorCtrl',
'closuresWindowCovering',
'hvacThermostat',
'msIlluminanceMeasurement',
'msTemperatureMeasurement',
'msRelativeHumidity',
'msSoilMoisture',
'msCO2',
];
// See zigbee-herdsman-converters
const defaultBindGroup = {type: 'group_number', ID: 901, name: 'default_bind_group'};
const DEFAULT_BIND_GROUP = {type: 'group_number', ID: 901, name: 'default_bind_group'};
const DEFAULT_REPORT_CONFIG = {minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1};
const defaultReportConfiguration = {
minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1,
};
const getColorCapabilities = async (endpoint: zh.Endpoint): Promise<{colorTemperature: boolean, colorXY: boolean}> => {
const getColorCapabilities = async (endpoint: zh.Endpoint): Promise<{colorTemperature: boolean; colorXY: boolean}> => {
if (endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') == null) {
await endpoint.read('lightingColorCtrl', ['colorCapabilities']);
}
const value = endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') as number;
return {
colorTemperature: (value & 1<<4) > 0,
colorXY: (value & 1<<3) > 0,
colorTemperature: (value & (1 << 4)) > 0,
colorXY: (value & (1 << 3)) > 0,
};
};
const reportClusters: {[s: string]:
{attribute: string, minimumReportInterval: number, maximumReportInterval: number, reportableChange: number
condition?: (endpoint: zh.Endpoint) => Promise<boolean>}[]} =
{
'genOnOff': [
{attribute: 'onOff', ...defaultReportConfiguration, minimumReportInterval: 0, reportableChange: 0},
],
'genLevelCtrl': [
{attribute: 'currentLevel', ...defaultReportConfiguration},
],
'lightingColorCtrl': [
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}],
lightingColorCtrl: [
{
attribute: 'colorTemperature', ...defaultReportConfiguration,
attribute: 'colorTemperature',
...DEFAULT_REPORT_CONFIG,
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorTemperature,
},
{
attribute: 'currentX', ...defaultReportConfiguration,
attribute: 'currentX',
...DEFAULT_REPORT_CONFIG,
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
},
{
attribute: 'currentY', ...defaultReportConfiguration,
attribute: 'currentY',
...DEFAULT_REPORT_CONFIG,
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
},
],
'closuresWindowCovering': [
{attribute: 'currentPositionLiftPercentage', ...defaultReportConfiguration},
{attribute: 'currentPositionTiltPercentage', ...defaultReportConfiguration},
closuresWindowCovering: [
{attribute: 'currentPositionLiftPercentage', ...DEFAULT_REPORT_CONFIG},
{attribute: 'currentPositionTiltPercentage', ...DEFAULT_REPORT_CONFIG},
],
};
type PollOnMessage = {
cluster: {[s: string]: {type: string, data: KeyValue}[]}
read: {cluster: string, attributes: string[], attributesForEndpoint?: (endpoint: zh.Endpoint) => Promise<string[]>}
manufacturerIDs: number[],
manufacturerNames: string [],
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 pollOnMessage: PollOnMessage = [
const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
{
// On messages that have the cluster and type of below
cluster: {
@@ -89,25 +111,20 @@ const pollOnMessage: PollOnMessage = [
{type: 'commandMove', data: {}},
{type: 'commandMoveToLevelWithOnOff', data: {}},
],
genScenes: [
{type: 'commandRecall', data: {}},
],
genScenes: [{type: 'commandRecall', data: {}}],
},
// Read the following attributes
read: {cluster: 'genLevelCtrl', attributes: ['currentLevel']},
// When the bound devices/members of group have the following manufacturerIDs
manufacturerIDs: [
zigbeeHerdsman.Zcl.ManufacturerCode.Philips,
zigbeeHerdsman.Zcl.ManufacturerCode.ATMEL,
zigbeeHerdsman.Zcl.ManufacturerCode.GLEDOPTO_CO_LTD,
zigbeeHerdsman.Zcl.ManufacturerCode.MUELLER_LICHT_INT,
zigbeeHerdsman.Zcl.ManufacturerCode.TELINK,
zigbeeHerdsman.Zcl.ManufacturerCode.BUSCH_JAEGER,
],
manufacturerNames: [
'GLEDOPTO',
'Trust International B.V.\u0000',
Zcl.ManufacturerCode.SIGNIFY_NETHERLANDS_B_V,
Zcl.ManufacturerCode.ATMEL,
Zcl.ManufacturerCode.GLEDOPTO_CO_LTD,
Zcl.ManufacturerCode.MUELLER_LICHT_INTERNATIONAL_INC,
Zcl.ManufacturerCode.TELINK_MICRO,
Zcl.ManufacturerCode.BUSCH_JAEGER_ELEKTRO,
],
manufacturerNames: ['GLEDOPTO', 'Trust International B.V.\u0000'],
},
{
cluster: {
@@ -123,9 +140,7 @@ const pollOnMessage: PollOnMessage = [
{type: 'commandOffWithEffect', data: {}},
{type: 'commandToggle', data: {}},
],
genScenes: [
{type: 'commandRecall', data: {}},
],
genScenes: [{type: 'commandRecall', data: {}}],
manuSpecificPhilips: [
{type: 'commandHueNotification', data: {button: 1}},
{type: 'commandHueNotification', data: {button: 4}},
@@ -133,23 +148,18 @@ const pollOnMessage: PollOnMessage = [
},
read: {cluster: 'genOnOff', attributes: ['onOff']},
manufacturerIDs: [
zigbeeHerdsman.Zcl.ManufacturerCode.Philips,
zigbeeHerdsman.Zcl.ManufacturerCode.ATMEL,
zigbeeHerdsman.Zcl.ManufacturerCode.GLEDOPTO_CO_LTD,
zigbeeHerdsman.Zcl.ManufacturerCode.MUELLER_LICHT_INT,
zigbeeHerdsman.Zcl.ManufacturerCode.TELINK,
zigbeeHerdsman.Zcl.ManufacturerCode.BUSCH_JAEGER,
],
manufacturerNames: [
'GLEDOPTO',
'Trust International B.V.\u0000',
Zcl.ManufacturerCode.SIGNIFY_NETHERLANDS_B_V,
Zcl.ManufacturerCode.ATMEL,
Zcl.ManufacturerCode.GLEDOPTO_CO_LTD,
Zcl.ManufacturerCode.MUELLER_LICHT_INTERNATIONAL_INC,
Zcl.ManufacturerCode.TELINK_MICRO,
Zcl.ManufacturerCode.BUSCH_JAEGER_ELEKTRO,
],
manufacturerNames: ['GLEDOPTO', 'Trust International B.V.\u0000'],
},
{
cluster: {
genScenes: [
{type: 'commandRecall', data: {}},
],
genScenes: [{type: 'commandRecall', data: {}}],
},
read: {
cluster: 'lightingColorCtrl',
@@ -159,28 +169,42 @@ const pollOnMessage: PollOnMessage = [
attributesForEndpoint: async (endpoint): Promise<string[]> => {
const supportedAttrs = await getColorCapabilities(endpoint);
const readAttrs: string[] = [];
supportedAttrs.colorXY && readAttrs.push('currentX', 'currentY');
supportedAttrs.colorTemperature && readAttrs.push('colorTemperature');
if (supportedAttrs.colorXY) {
readAttrs.push('currentX', 'currentY');
}
if (supportedAttrs.colorTemperature) {
readAttrs.push('colorTemperature');
}
return readAttrs;
},
},
manufacturerIDs: [
zigbeeHerdsman.Zcl.ManufacturerCode.Philips,
zigbeeHerdsman.Zcl.ManufacturerCode.ATMEL,
zigbeeHerdsman.Zcl.ManufacturerCode.GLEDOPTO_CO_LTD,
zigbeeHerdsman.Zcl.ManufacturerCode.MUELLER_LICHT_INT,
zigbeeHerdsman.Zcl.ManufacturerCode.TELINK,
Zcl.ManufacturerCode.SIGNIFY_NETHERLANDS_B_V,
Zcl.ManufacturerCode.ATMEL,
Zcl.ManufacturerCode.GLEDOPTO_CO_LTD,
Zcl.ManufacturerCode.MUELLER_LICHT_INTERNATIONAL_INC,
Zcl.ManufacturerCode.TELINK_MICRO,
// Note: ManufacturerCode.BUSCH_JAEGER is left out intentionally here as their devices don't support colors
],
manufacturerNames: [
'GLEDOPTO',
'Trust International B.V.\u0000',
],
manufacturerNames: ['GLEDOPTO', 'Trust International B.V.\u0000'],
},
];
interface ParsedMQTTMessage {
type: 'bind' | 'unbind', sourceKey: string, targetKey: string, clusters: string[], skipDisableReporting: boolean
type: 'bind' | 'unbind';
sourceKey?: string;
sourceEndpointKey?: string | number;
targetKey?: string;
targetEndpointKey?: string | number;
clusters?: string[];
skipDisableReporting: boolean;
resolvedSource?: Device;
resolvedTarget?: Device | Group | typeof DEFAULT_BIND_GROUP;
resolvedSourceEndpoint?: zh.Endpoint;
resolvedBindTarget?: number | zh.Endpoint | zh.Group;
}
export default class Bind extends Extension {
@@ -192,169 +216,223 @@ export default class Bind extends Extension {
this.eventBus.onGroupMembersChanged(this, this.onGroupMembersChanged);
}
private parseMQTTMessage(data: eventdata.MQTTMessage): ParsedMQTTMessage {
let type: 'bind' | 'unbind' = null;
let sourceKey = null;
let targetKey = null;
let clusters = null;
let skipDisableReporting = false;
private parseMQTTMessage(
data: eventdata.MQTTMessage,
): [raw: KeyValue | undefined, parsed: ParsedMQTTMessage | undefined, error: string | undefined] {
if (data.topic.match(TOPIC_REGEX)) {
const type = data.topic.endsWith('unbind') ? 'unbind' : 'bind';
let skipDisableReporting = false;
const message = JSON.parse(data.message) as Zigbee2MQTTAPI['bridge/request/device/bind'];
if (legacyApi && data.topic.match(legacyTopicRegex)) {
const topic = data.topic.replace(`${settings.get().mqtt.base_topic}/bridge/`, '');
type = topic.split('/')[0] as 'bind' | 'unbind';
sourceKey = topic.replace(`${type}/`, '');
targetKey = data.message;
} else if (data.topic.match(topicRegex)) {
type = data.topic.endsWith('unbind') ? 'unbind' : 'bind';
const message = JSON.parse(data.message);
sourceKey = message.from;
targetKey = message.to;
clusters = message.clusters;
skipDisableReporting = 'skip_disable_reporting' in message ? message.skip_disable_reporting : false;
if (typeof message !== 'object' || message.from == undefined || message.to == undefined) {
return [message, {type, skipDisableReporting}, `Invalid payload`];
}
const sourceKey = message.from;
const sourceEndpointKey = message.from_endpoint ?? 'default';
const targetKey = message.to;
const targetEndpointKey = message.to_endpoint;
const clusters = message.clusters;
skipDisableReporting = message.skip_disable_reporting != undefined ? message.skip_disable_reporting : false;
const resolvedSource = this.zigbee.resolveEntity(message.from) as Device;
if (!resolvedSource || !(resolvedSource instanceof Device)) {
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);
if (!resolvedTarget) {
return [message, {type, skipDisableReporting}, `Target device or group '${message.to}' does not exist`];
}
const resolvedSourceEndpoint = resolvedSource.endpoint(sourceEndpointKey);
if (!resolvedSourceEndpoint) {
return [
message,
{type, skipDisableReporting},
`Source device '${resolvedSource.name}' does not have endpoint '${sourceEndpointKey}'`,
];
}
// resolves to 'default' endpoint if targetEndpointKey is invalid (used by frontend for 'Coordinator')
const resolvedBindTarget =
resolvedTarget instanceof Device
? resolvedTarget.endpoint(targetEndpointKey)
: resolvedTarget instanceof Group
? resolvedTarget.zh
: Number(resolvedTarget.ID);
if (resolvedTarget instanceof Device && !resolvedBindTarget) {
return [
message,
{type, skipDisableReporting},
`Target device '${resolvedTarget.name}' does not have endpoint '${targetEndpointKey}'`,
];
}
return [
message,
{
type,
sourceKey,
sourceEndpointKey,
targetKey,
targetEndpointKey,
clusters,
skipDisableReporting,
resolvedSource,
resolvedTarget,
resolvedSourceEndpoint,
resolvedBindTarget,
},
undefined,
];
} else {
return [undefined, undefined, undefined];
}
return {type, sourceKey, targetKey, clusters, skipDisableReporting};
}
@bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
const {type, sourceKey, targetKey, clusters, skipDisableReporting} = this.parseMQTTMessage(data);
if (!type) return null;
const message = utils.parseJSON(data.message, data.message);
const [raw, parsed, error] = this.parseMQTTMessage(data);
let error = null;
const parsedSource = utils.parseEntityID(sourceKey);
const parsedTarget = utils.parseEntityID(targetKey);
const source = this.zigbee.resolveEntity(parsedSource.ID);
const target = targetKey === 'default_bind_group' ?
defaultBindGroup : this.zigbee.resolveEntity(parsedTarget.ID);
const responseData: KeyValue = {from: sourceKey, to: targetKey};
if (!source || !(source instanceof Device)) {
error = `Source device '${sourceKey}' does not exist`;
} else if (!target) {
error = `Target device or group '${targetKey}' does not exist`;
} else {
const successfulClusters: string[] = [];
const failedClusters = [];
const attemptedClusters = [];
const bindSource: zh.Endpoint = source.endpoint(parsedSource.endpoint);
let bindTarget: number | zh.Group | zh.Endpoint = null;
if (target instanceof Device) bindTarget = target.endpoint(parsedTarget.endpoint);
else if (target instanceof Group) bindTarget = target.zh;
else bindTarget = Number(target.ID);
// Find which clusters are supported by both the source and target.
// Groups are assumed to support all clusters.
const clusterCandidates = clusters ?? allClusterCandidates;
for (const cluster of clusterCandidates) {
let matchingClusters = false;
const anyClusterValid = utils.isZHGroup(bindTarget) || typeof bindTarget === 'number' ||
(target as Device).zh.type === 'Coordinator';
if (!anyClusterValid && utils.isEndpoint(bindTarget)) {
matchingClusters = ((bindTarget.supportsInputCluster(cluster) &&
bindSource.supportsOutputCluster(cluster)) ||
(bindSource.supportsInputCluster(cluster) &&
bindTarget.supportsOutputCluster(cluster)) );
}
const sourceValid = bindSource.supportsInputCluster(cluster) ||
bindSource.supportsOutputCluster(cluster);
if ( sourceValid && (anyClusterValid || matchingClusters)) {
logger.debug(`${type}ing cluster '${cluster}' from '${source.name}' to '${target.name}'`);
attemptedClusters.push(cluster);
try {
if (type === 'bind') {
await bindSource.bind(cluster, bindTarget);
} else {
await bindSource.unbind(cluster, bindTarget);
}
successfulClusters.push(cluster);
logger.info(
`Successfully ${type === 'bind' ? 'bound' : 'unbound'} cluster '${cluster}' from ` +
`'${source.name}' to '${target.name}'`,
);
/* istanbul ignore else */
if (settings.get().advanced.legacy_api) {
this.mqtt.publish(
'bridge/log',
stringify({type: `device_${type}`,
message: {from: source.name, to: target.name, cluster}}),
);
}
} catch (error) {
failedClusters.push(cluster);
logger.error(
`Failed to ${type} cluster '${cluster}' from '${source.name}' to ` +
`'${target.name}' (${error})`,
);
/* istanbul ignore else */
if (settings.get().advanced.legacy_api) {
this.mqtt.publish(
'bridge/log',
stringify({type: `device_${type}_failed`,
message: {from: source.name, to: target.name, cluster}}),
);
}
}
}
}
if (attemptedClusters.length === 0) {
logger.error(`Nothing to ${type} from '${source.name}' to '${target.name}'`);
error = `Nothing to ${type}`;
/* istanbul ignore else */
if (settings.get().advanced.legacy_api) {
this.mqtt.publish(
'bridge/log',
stringify({type: `device_${type}_failed`, message: {from: source.name, to: target.name}}),
);
}
} else if (failedClusters.length === attemptedClusters.length) {
error = `Failed to ${type}`;
}
responseData[`clusters`] = successfulClusters;
responseData[`failed`] = failedClusters;
if (successfulClusters.length !== 0) {
if (type === 'bind') {
await this.setupReporting(bindSource.binds.filter((b) =>
successfulClusters.includes(b.cluster.name) && b.target === bindTarget));
} else if ((typeof bindTarget !== 'number') && !skipDisableReporting) {
await this.disableUnnecessaryReportings(bindTarget);
}
}
}
const triggeredViaLegacyApi = data.topic.match(legacyTopicRegex);
if (!triggeredViaLegacyApi) {
const response = utils.getResponse(message, responseData, error);
await this.mqtt.publish(`bridge/response/device/${type}`, stringify(response));
if (!raw || !parsed) {
return;
}
if (error) {
await this.publishResponse(parsed.type, raw, {}, error);
return;
}
const {
type,
sourceKey,
sourceEndpointKey,
targetKey,
targetEndpointKey,
clusters,
skipDisableReporting,
resolvedSource,
resolvedTarget,
resolvedSourceEndpoint,
resolvedBindTarget,
} = parsed;
assert(resolvedSource, '`resolvedSource` is missing');
assert(resolvedTarget, '`resolvedTarget` is missing');
assert(resolvedSourceEndpoint, '`resolvedSourceEndpoint` is missing');
assert(resolvedBindTarget != undefined, '`resolvedBindTarget` is missing');
const successfulClusters: string[] = [];
const failedClusters = [];
const attemptedClusters = [];
// Find which clusters are supported by both the source and target.
// Groups are assumed to support all clusters.
const clusterCandidates = clusters ?? ALL_CLUSTER_CANDIDATES;
for (const cluster of clusterCandidates) {
let matchingClusters = false;
const anyClusterValid =
utils.isZHGroup(resolvedBindTarget) ||
typeof resolvedBindTarget === 'number' ||
(resolvedTarget instanceof Device && resolvedTarget.zh.type === 'Coordinator');
if (!anyClusterValid && utils.isZHEndpoint(resolvedBindTarget)) {
matchingClusters =
(resolvedBindTarget.supportsInputCluster(cluster) && resolvedSourceEndpoint.supportsOutputCluster(cluster)) ||
(resolvedSourceEndpoint.supportsInputCluster(cluster) && resolvedBindTarget.supportsOutputCluster(cluster));
}
const sourceValid = resolvedSourceEndpoint.supportsInputCluster(cluster) || resolvedSourceEndpoint.supportsOutputCluster(cluster);
if (sourceValid && (anyClusterValid || matchingClusters)) {
logger.debug(`${type}ing cluster '${cluster}' from '${resolvedSource.name}' to '${resolvedTarget.name}'`);
attemptedClusters.push(cluster);
try {
if (type === 'bind') {
await resolvedSourceEndpoint.bind(cluster, resolvedBindTarget);
} else {
await resolvedSourceEndpoint.unbind(cluster, resolvedBindTarget);
}
successfulClusters.push(cluster);
logger.info(
`Successfully ${type === 'bind' ? 'bound' : 'unbound'} cluster '${cluster}' from '${resolvedSource.name}' to '${resolvedTarget.name}'`,
);
} catch (error) {
failedClusters.push(cluster);
logger.error(`Failed to ${type} cluster '${cluster}' from '${resolvedSource.name}' to '${resolvedTarget.name}' (${error})`);
}
}
}
if (attemptedClusters.length === 0) {
logger.error(`Nothing to ${type} from '${resolvedSource.name}' to '${resolvedTarget.name}'`);
await this.publishResponse(parsed.type, raw, {}, `Nothing to ${type}`);
return;
} else if (failedClusters.length === attemptedClusters.length) {
await this.publishResponse(parsed.type, raw, {}, `Failed to ${type}`);
return;
}
const responseData: Zigbee2MQTTAPI['bridge/response/device/bind'] | Zigbee2MQTTAPI['bridge/response/device/unbind'] = {
from: sourceKey!, // valid with assert above on `resolvedSource`
from_endpoint: sourceEndpointKey!, // valid with assert above on `resolvedSourceEndpoint`
to: targetKey!, // valid with assert above on `resolvedTarget`
to_endpoint: targetEndpointKey,
clusters: successfulClusters,
failed: failedClusters,
};
if (successfulClusters.length !== 0) {
if (type === 'bind') {
await this.setupReporting(
resolvedSourceEndpoint.binds.filter((b) => successfulClusters.includes(b.cluster.name) && b.target === resolvedBindTarget),
);
} else if (typeof resolvedBindTarget !== 'number' && !skipDisableReporting) {
await this.disableUnnecessaryReportings(resolvedBindTarget);
}
}
await this.publishResponse(parsed.type, raw, responseData);
this.eventBus.emitDevicesChanged();
}
private async publishResponse<T extends Zigbee2MQTTResponseEndpoints>(
type: ParsedMQTTMessage['type'],
request: KeyValue,
data: Zigbee2MQTTAPI[T],
error?: string,
): Promise<void> {
const response = utils.getResponse(request, data, error);
await this.mqtt.publish(`bridge/response/device/${type}`, stringify(response));
if (error) {
logger.error(error);
} else {
this.eventBus.emitDevicesChanged();
}
}
@bind async onGroupMembersChanged(data: eventdata.GroupMembersChanged): Promise<void> {
if (data.action === 'add') {
const bindsToGroup = this.zigbee.devices(false).map((c) => c.zh.endpoints)
.reduce((a, v) => a.concat(v)).map((e) => e.binds)
.reduce((a, v) => a.concat(v)).filter((b) => b.target === data.group.zh);
const bindsToGroup: zh.Bind[] = [];
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
for (const endpoint of device.zh.endpoints) {
for (const bind of endpoint.binds) {
if (bind.target === data.group.zh) {
bindsToGroup.push(bind);
}
}
}
}
await this.setupReporting(bindsToGroup);
} else { // action === remove/remove_all
} else {
// action === remove/remove_all
if (!data.skipDisableReporting) {
await this.disableUnnecessaryReportings(data.endpoint);
}
@@ -362,36 +440,52 @@ export default class Bind extends Extension {
}
getSetupReportingEndpoints(bind: zh.Bind, coordinatorEp: zh.Endpoint): zh.Endpoint[] {
const endpoints = utils.isEndpoint(bind.target) ? [bind.target] : bind.target.members;
const endpoints = utils.isZHEndpoint(bind.target) ? [bind.target] : bind.target.members;
return endpoints.filter((e) => {
const supportsInputCluster = e.supportsInputCluster(bind.cluster.name);
const hasConfiguredReporting = !!e.configuredReportings.find((c) => c.cluster.name === bind.cluster.name);
const hasBind = !!e.binds.find((b) => b.cluster.name === bind.cluster.name && b.target === coordinatorEp);
return supportsInputCluster && !(hasBind && hasConfiguredReporting);
if (!e.supportsInputCluster(bind.cluster.name)) {
return false;
}
const hasConfiguredReporting = e.configuredReportings.some((c) => c.cluster.name === bind.cluster.name);
if (!hasConfiguredReporting) {
return true;
}
const hasBind = e.binds.some((b) => b.cluster.name === bind.cluster.name && b.target === coordinatorEp);
return !hasBind;
});
}
async setupReporting(binds: zh.Bind[]): Promise<void> {
const coordinatorEndpoint = this.zigbee.firstCoordinatorEndpoint();
for (const bind of binds.filter((b) => b.cluster.name in reportClusters)) {
for (const endpoint of this.getSetupReportingEndpoints(bind, coordinatorEndpoint)) {
const entity = `${this.zigbee.resolveEntity(endpoint.getDevice()).name}/${endpoint.ID}`;
try {
await endpoint.bind(bind.cluster.name, coordinatorEndpoint);
const items = [];
for (const c of reportClusters[bind.cluster.name]) {
/* istanbul ignore else */
if (!c.condition || await c.condition(endpoint)) {
const i = {...c};
delete i.condition;
items.push(i);
}
}
await endpoint.configureReporting(bind.cluster.name, items);
logger.info(`Successfully setup reporting for '${entity}' cluster '${bind.cluster.name}'`);
} catch (error) {
logger.warn(`Failed to setup reporting for '${entity}' cluster '${bind.cluster.name}'`);
for (const bind of binds) {
if (bind.cluster.name in REPORT_CLUSTERS) {
for (const endpoint of this.getSetupReportingEndpoints(bind, coordinatorEndpoint)) {
const entity = `${this.zigbee.resolveEntity(endpoint.getDevice())!.name}/${endpoint.ID}`;
try {
await endpoint.bind(bind.cluster.name, coordinatorEndpoint);
const items = [];
for (const c of REPORT_CLUSTERS[bind.cluster.name as ClusterName]!) {
if (!c.condition || (await c.condition(endpoint))) {
const i = {...c};
delete i.condition;
items.push(i);
}
}
await endpoint.configureReporting(bind.cluster.name, 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})`);
}
}
}
}
@@ -401,39 +495,54 @@ export default class Bind extends Extension {
async disableUnnecessaryReportings(target: zh.Group | zh.Endpoint): Promise<void> {
const coordinator = this.zigbee.firstCoordinatorEndpoint();
const endpoints = utils.isEndpoint(target) ? [target] : target.members;
const endpoints = utils.isZHEndpoint(target) ? [target] : target.members;
const allBinds: zh.Bind[] = [];
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
for (const endpoint of device.zh.endpoints) {
for (const bind of endpoint.binds) {
allBinds.push(bind);
}
}
}
for (const endpoint of endpoints) {
const device = this.zigbee.resolveEntity(endpoint.getDevice()) as Device;
const entity = `${device.name}/${endpoint.ID}`;
const boundClusters = endpoint.binds.filter((b) => b.target === coordinator)
.map((b) => b.cluster.name);
const requiredClusters = this.zigbee.devices(false).map((c) => c.zh.endpoints)
.reduce((a, v) => a.concat(v))
.map((e) => e.binds).reduce((a, v) => a.concat(v)).filter((bind) => {
if (utils.isEndpoint(bind.target)) {
return bind.target === endpoint;
} else {
return bind.target.members.includes(endpoint);
}
}).map((b) => b.cluster.name).filter((v, i, a) => a.indexOf(v) === i);
const requiredClusters: string[] = [];
const boundClusters: string[] = [];
for (const cluster of boundClusters.filter((c) => !requiredClusters.includes(c) && c in reportClusters)) {
for (const bind of allBinds) {
if (utils.isZHEndpoint(bind.target) ? bind.target === endpoint : bind.target.members.includes(endpoint)) {
requiredClusters.push(bind.cluster.name);
}
}
for (const b of endpoint.binds) {
if (b.target === coordinator && !requiredClusters.includes(b.cluster.name) && b.cluster.name in REPORT_CLUSTERS) {
boundClusters.push(b.cluster.name);
}
}
for (const cluster of boundClusters) {
try {
await endpoint.unbind(cluster, coordinator);
const items = [];
for (const item of reportClusters[cluster]) {
/* istanbul ignore else */
if (!item.condition || await item.condition(endpoint)) {
for (const item of REPORT_CLUSTERS[cluster as ClusterName]!) {
if (!item.condition || (await item.condition(endpoint))) {
const i = {...item};
delete i.condition;
items.push({...i, maximumReportInterval: 0xFFFF});
items.push({...i, maximumReportInterval: 0xffff});
}
}
await endpoint.configureReporting(cluster, items);
logger.info(`Successfully disabled reporting for '${entity}' cluster '${cluster}'`);
} catch (error) {
logger.warn(`Failed to disable reporting for '${entity}' cluster '${cluster}'`);
logger.warning(`Failed to disable reporting for '${entity}' cluster '${cluster}' (${(error as Error).message})`);
}
}
@@ -450,15 +559,17 @@ export default class Bind extends Extension {
* When dimming the bulb via the dimmer switch the state is therefore not reported.
* When we receive a message from a Hue dimmer we read the brightness from the bulb (if bound).
*/
const polls = pollOnMessage.filter((p) =>
p.cluster[data.cluster]?.find((c) => c.type === data.type && utils.equalsPartial(data.data, c.data)));
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)),
);
if (polls.length) {
const toPoll: Set<zh.Endpoint> = new Set();
// Add bound devices
for (const endpoint of data.device.zh.endpoints) {
for (const bind of endpoint.binds) {
if (utils.isEndpoint(bind.target) && bind.target.getDevice().type !== 'Coordinator') {
if (utils.isZHEndpoint(bind.target) && bind.target.getDevice().type !== 'Coordinator') {
toPoll.add(bind.target);
}
}
@@ -466,32 +577,41 @@ export default class Bind extends Extension {
// If message is published to a group, add members of the group
const group = data.groupID && data.groupID !== 0 && this.zigbee.groupByID(data.groupID);
if (group) {
group.zh.members.forEach((m) => toPoll.add(m));
for (const member of group.zh.members) {
toPoll.add(member);
}
}
for (const endpoint of toPoll) {
const device = endpoint.getDevice();
for (const poll of polls) {
if ((!poll.manufacturerIDs.includes(endpoint.getDevice().manufacturerID) &&
!poll.manufacturerNames.includes(endpoint.getDevice().manufacturerName)) ||
!endpoint.supportsInputCluster(poll.read.cluster)) {
// XXX: manufacturerID/manufacturerName can be undefined and won't match `includes`, but TS enforces same-type
if (
(!poll.manufacturerIDs.includes(device.manufacturerID!) && !poll.manufacturerNames.includes(device.manufacturerName!)) ||
!endpoint.supportsInputCluster(poll.read.cluster)
) {
continue;
}
let readAttrs = poll.read.attributes;
if (poll.read.attributesForEndpoint) {
const attrsForEndpoint = await poll.read.attributesForEndpoint(endpoint);
readAttrs = [...poll.read.attributes, ...attrsForEndpoint];
}
const key = `${endpoint.getDevice().ieeeAddr}_${endpoint.ID}_${pollOnMessage.indexOf(poll)}`;
const key = `${device.ieeeAddr}_${endpoint.ID}_${POLL_ON_MESSAGE.indexOf(poll)}`;
if (!this.pollDebouncers[key]) {
this.pollDebouncers[key] = debounce(async () => {
try {
await endpoint.read(poll.read.cluster, readAttrs);
} catch (error) {
logger.error(`Failed to poll ${readAttrs} from ` +
`${this.zigbee.resolveEntity(endpoint.getDevice()).name}`);
logger.error(
`Failed to poll ${readAttrs} from ${this.zigbee.resolveEntity(device)!.name} (${(error as Error).message})`,
);
}
}, 1000);
}
+443 -331
View File
File diff suppressed because it is too large Load Diff
+52 -48
View File
@@ -1,11 +1,15 @@
import type {Zigbee2MQTTAPI} from 'lib/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 logger from '../util/logger';
import * as settings from '../util/settings';
import utils from '../util/utils';
import logger from '../util/logger';
import stringify from 'json-stable-stringify-without-jsonify';
import * as zhc from 'zigbee-herdsman-converters';
import Extension from './extension';
import bind from 'bind-decorator';
import Device from '../model/device';
/**
* This extension calls the zigbee-herdsman-converters definition configure() method
@@ -14,11 +18,10 @@ export default class Configure extends Extension {
private configuring = new Set();
private attempts: {[s: string]: number} = {};
private topic = `${settings.get().mqtt.base_topic}/bridge/request/device/configure`;
private legacyTopic = `${settings.get().mqtt.base_topic}/bridge/configure`;
@bind private async onReconfigure(data: eventdata.Reconfigure): Promise<void> {
// Disabling reporting unbinds some cluster which could be bound by configure, re-setup.
if (data.device.zh.meta?.hasOwnProperty('configured')) {
if (data.device.zh.meta?.configured !== undefined) {
delete data.device.zh.meta.configured;
data.device.zh.save();
}
@@ -27,59 +30,53 @@ export default class Configure extends Extension {
}
@bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
if (data.topic === this.legacyTopic) {
const device = this.zigbee.resolveEntity(data.message);
if (!device || !(device instanceof Device)) {
logger.error(`Device '${data.message}' does not exist`);
return;
}
if (data.topic === this.topic) {
const message = utils.parseJSON(data.message, data.message) as Zigbee2MQTTAPI['bridge/request/device/configure'];
const ID = typeof message === 'object' ? message.id : message;
let error: string | undefined;
if (!device.definition || !device.definition.configure) {
logger.warn(`Skipping configure of '${device.name}', device does not require this.`);
return;
}
this.configure(device, 'mqtt_message', true);
} else if (data.topic === this.topic) {
const message = utils.parseJSON(data.message, data.message);
const ID = typeof message === 'object' && message.hasOwnProperty('id') ? message.id : message;
let error = null;
const device = this.zigbee.resolveEntity(ID);
if (!device || !(device instanceof Device)) {
error = `Device '${ID}' does not exist`;
} else if (!device.definition || !device.definition.configure) {
error = `Device '${device.name}' cannot be configured`;
if (ID === undefined) {
error = `Invalid payload`;
} else {
try {
await this.configure(device, 'mqtt_message', true, true);
} catch (e) {
error = `Failed to configure (${e.message})`;
const device = this.zigbee.resolveEntity(ID);
if (!device || !(device instanceof Device)) {
error = `Device '${ID}' does not exist`;
} else if (!device.definition || !device.definition.configure) {
error = `Device '${device.name}' cannot be configured`;
} else {
try {
await this.configure(device, 'mqtt_message', true, true);
} catch (e) {
error = `Failed to configure (${(e as Error).message})`;
}
}
}
const response = utils.getResponse(message, {id: ID}, error);
const response = utils.getResponse<'bridge/response/device/configure'>(message, {id: ID}, error);
await this.mqtt.publish(`bridge/response/device/configure`, stringify(response));
}
}
override async start(): Promise<void> {
setImmediate(async () => {
for (const device of this.zigbee.devices(false)) {
// Sleep 10 seconds between configuring on startup to not DDoS the coordinator
// when many devices have to be configured.
// Only configure routers on startup, end devices are likely sleeping and
// will reconfigure once they send a message
for (const device of this.zigbee.devicesIterator((d) => d.type === 'Router')) {
// Sleep 10 seconds between configuring on startup to not DDoS the coordinator when many devices have to be configured.
await utils.sleep(10);
await this.configure(device, 'started');
}
});
this.eventBus.onDeviceJoined(this, (data) => {
if (data.device.zh.meta.hasOwnProperty('configured')) {
this.eventBus.onDeviceJoined(this, async (data) => {
if (data.device.zh.meta.configured !== undefined) {
delete data.device.zh.meta.configured;
data.device.zh.save();
}
this.configure(data.device, 'zigbee_event');
await this.configure(data.device, 'zigbee_event');
});
this.eventBus.onDeviceInterview(this, (data) => this.configure(data.device, 'zigbee_event'));
this.eventBus.onLastSeenChanged(this, (data) => this.configure(data.device, 'zigbee_event'));
@@ -87,15 +84,22 @@ export default class Configure extends Extension {
this.eventBus.onReconfigure(this, this.onReconfigure);
}
private async configure(device: Device, event: 'started' | 'zigbee_event' | 'reporting_disabled' | 'mqtt_message',
force=false, throwError=false): Promise<void> {
private async configure(
device: Device,
event: 'started' | 'zigbee_event' | 'reporting_disabled' | 'mqtt_message',
force = false,
throwError = false,
): Promise<void> {
if (!device.definition?.configure) {
return;
}
if (!force) {
if (device.options.disabled || !device.definition?.configure || !device.zh.interviewCompleted) {
if (device.options.disabled || !device.zh.interviewCompleted) {
return;
}
if (device.zh.meta?.hasOwnProperty('configured') &&
device.zh.meta.configured === zhc.getConfigureKey(device.definition)) {
if (device.zh.meta?.configured !== undefined) {
return;
}
@@ -111,13 +115,13 @@ export default class Configure extends Extension {
this.configuring.add(device.ieeeAddr);
if (!this.attempts.hasOwnProperty(device.ieeeAddr)) {
if (this.attempts[device.ieeeAddr] === undefined) {
this.attempts[device.ieeeAddr] = 0;
}
logger.info(`Configuring '${device.name}'`);
try {
await device.definition.configure(device.zh, this.zigbee.firstCoordinatorEndpoint(), logger);
await device.definition.configure(device.zh, this.zigbee.firstCoordinatorEndpoint(), device.definition);
logger.info(`Successfully configured '${device.name}'`);
device.zh.meta.configured = zhc.getConfigureKey(device.definition);
device.zh.save();
@@ -125,7 +129,7 @@ export default class Configure extends Extension {
} catch (error) {
this.attempts[device.ieeeAddr]++;
const attempt = this.attempts[device.ieeeAddr];
const msg = `Failed to configure '${device.name}', attempt ${attempt} (${error.stack})`;
const msg = `Failed to configure '${device.name}', attempt ${attempt} (${(error as Error).stack})`;
logger.error(msg);
if (throwError) {
+11 -5
View File
@@ -5,7 +5,7 @@ abstract class Extension {
protected publishEntityState: PublishEntityState;
protected eventBus: EventBus;
protected enableDisableExtension: (enable: boolean, name: string) => Promise<void>;
protected restartCallback: () => void;
protected restartCallback: () => Promise<void>;
protected addExtension: (extension: Extension) => Promise<void>;
/**
@@ -20,9 +20,16 @@ abstract class Extension {
* @param {restartCallback} restartCallback Restart Zigbee2MQTT
* @param {addExtension} addExtension Add an extension
*/
constructor(zigbee: Zigbee, mqtt: MQTT, state: State, publishEntityState: PublishEntityState,
eventBus: EventBus, enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
restartCallback: () => void, addExtension: (extension: Extension) => Promise<void>) {
constructor(
zigbee: Zigbee,
mqtt: MQTT,
state: State,
publishEntityState: PublishEntityState,
eventBus: EventBus,
enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
restartCallback: () => Promise<void>,
addExtension: (extension: Extension) => Promise<void>,
) {
this.zigbee = zigbee;
this.mqtt = mqtt;
this.state = state;
@@ -36,7 +43,6 @@ abstract class Extension {
/**
* Is called once the extension has to start
*/
/* istanbul ignore next */
async start(): Promise<void> {}
/**
+61 -25
View File
@@ -1,32 +1,68 @@
import * as zhc from 'zigbee-herdsman-converters';
import * as settings from '../util/settings';
import {loadExternalConverter} from '../util/utils';
import Extension from './extension';
import type * as zhc from 'zigbee-herdsman-converters';
import {addDefinition, removeExternalDefinitions} from 'zigbee-herdsman-converters';
import logger from '../util/logger';
import ExternalJSExtension from './externalJS';
export default class ExternalConverters extends Extension {
constructor(zigbee: Zigbee, mqtt: MQTT, state: State, publishEntityState: PublishEntityState,
eventBus: EventBus, enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
restartCallback: () => void, addExtension: (extension: Extension) => Promise<void>) {
super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension);
type ModuleExports = zhc.Definition | zhc.Definition[];
for (const file of settings.get().external_converters) {
try {
for (const definition of loadExternalConverter(file)) {
const toAdd = {...definition};
delete toAdd['homeassistant'];
zhc.addDefinition(toAdd);
}
} catch (error) {
logger.error(`Failed to load external converter file '${file}' (${error.message})`);
logger.error(
`Probably there is a syntax error in the file or the external converter is not ` +
`compatible with the current Zigbee2MQTT version`);
logger.error(
`Note that external converters are not meant for long term usage, it's meant for local ` +
`testing after which a pull request should be created to add out-of-the-box support for the device`,
);
export default class ExternalConverters extends ExternalJSExtension<ModuleExports> {
constructor(
zigbee: Zigbee,
mqtt: MQTT,
state: State,
publishEntityState: PublishEntityState,
eventBus: EventBus,
enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
restartCallback: () => Promise<void>,
addExtension: (extension: Extension) => Promise<void>,
) {
super(
zigbee,
mqtt,
state,
publishEntityState,
eventBus,
enableDisableExtension,
restartCallback,
addExtension,
'converter',
'external_converters',
);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected async removeJS(name: string, module: ModuleExports): Promise<void> {
removeExternalDefinitions(name);
await this.zigbee.resolveDevicesDefinitions(true);
}
protected async loadJS(name: string, module: ModuleExports): Promise<void> {
try {
removeExternalDefinitions(name);
for (const definition of this.getDefinitions(module)) {
definition.externalConverterName = name;
addDefinition(definition);
logger.info(`Loaded external converter '${name}'.`);
}
await this.zigbee.resolveDevicesDefinitions(true);
} catch (error) {
logger.error(`Failed to load external converter '${name}'`);
logger.error(`Check the code for syntax error and make sure it is up to date with the current Zigbee2MQTT version.`);
logger.error(
`External converters are not meant for long term usage, but for local testing after which a pull request should be created to add out-of-the-box support for the device`,
);
throw error;
}
}
private getDefinitions(module: ModuleExports): zhc.Definition[] {
return Array.isArray(module) ? module : [module];
}
}
-109
View File
@@ -1,109 +0,0 @@
import * as settings from '../util/settings';
import utils from '../util/utils';
import fs from 'fs';
import data from './../util/data';
import path from 'path';
import logger from './../util/logger';
import stringify from 'json-stable-stringify-without-jsonify';
import bind from 'bind-decorator';
import Extension from './extension';
const requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/extension/(save|remove)`);
export default class ExternalExtension extends Extension {
private requestLookup: {[s: string]: (message: KeyValue) => Promise<MQTTResponse>};
override async start(): Promise<void> {
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
this.requestLookup = {'save': this.saveExtension, 'remove': this.removeExtension};
this.loadUserDefinedExtensions();
await this.publishExtensions();
}
private getExtensionsBasePath(): string {
return data.joinPath('extension');
}
private getListOfUserDefinedExtensions(): {name: string, code: string}[] {
const basePath = this.getExtensionsBasePath();
if (fs.existsSync(basePath)) {
return fs.readdirSync(basePath).filter((f) => f.endsWith('.js')).map((fileName) => {
const extensionFilePath = path.join(basePath, fileName);
return {'name': fileName, 'code': fs.readFileSync(extensionFilePath, 'utf-8')};
});
} else {
return [];
}
}
@bind private async removeExtension(message: KeyValue): Promise<MQTTResponse> {
const {name} = message;
const extensions = this.getListOfUserDefinedExtensions();
const extensionToBeRemoved = extensions.find((e) => e.name === name);
if (extensionToBeRemoved) {
await this.enableDisableExtension(false, extensionToBeRemoved.name);
const basePath = this.getExtensionsBasePath();
const extensionFilePath = path.join(basePath, path.basename(name));
fs.unlinkSync(extensionFilePath);
this.publishExtensions();
logger.info(`Extension ${name} removed`);
return utils.getResponse(message, {}, null);
} else {
return utils.getResponse(message, {}, `Extension ${name} doesn't exists`);
}
}
@bind private async saveExtension(message: KeyValue): Promise<MQTTResponse> {
const {name, code} = message;
const ModuleConstructor = utils.loadModuleFromText(code, name) as typeof Extension;
await this.loadExtension(ModuleConstructor);
const basePath = this.getExtensionsBasePath();
/* istanbul ignore else */
if (!fs.existsSync(basePath)) {
fs.mkdirSync(basePath);
}
const extensionFilePath = path.join(basePath, path.basename(name));
fs.writeFileSync(extensionFilePath, code);
this.publishExtensions();
logger.info(`Extension ${name} loaded`);
return utils.getResponse(message, {}, null);
}
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
const match = data.topic.match(requestRegex);
if (match && this.requestLookup[match[1].toLowerCase()]) {
const message = utils.parseJSON(data.message, data.message) as KeyValue;
try {
const response = await this.requestLookup[match[1].toLowerCase()](message);
await this.mqtt.publish(`bridge/response/extension/${match[1]}`, stringify(response));
} catch (error) {
logger.error(`Request '${data.topic}' failed with error: '${error.message}'`);
const response = utils.getResponse(message, {}, error.message);
await this.mqtt.publish(`bridge/response/extension/${match[1]}`, stringify(response));
}
}
}
@bind private async loadExtension(ConstructorClass: typeof Extension): Promise<void> {
await this.enableDisableExtension(false, ConstructorClass.name);
// @ts-ignore
await this.addExtension(new ConstructorClass(this.zigbee, this.mqtt, this.state, this.publishEntityState,
this.eventBus, settings, logger));
}
private loadUserDefinedExtensions(): void {
const extensions = this.getListOfUserDefinedExtensions();
extensions
.map(({code, name}) => utils.loadModuleFromText(code, name))
.map(this.loadExtension);
}
private async publishExtensions(): Promise<void> {
const extensions = this.getListOfUserDefinedExtensions();
await this.mqtt.publish('bridge/extensions', stringify(extensions), {
retain: true,
qos: 0,
}, settings.get().mqtt.base_topic, true);
}
}
+59
View File
@@ -0,0 +1,59 @@
import type Extension from './extension';
import logger from '../util/logger';
import * as settings from '../util/settings';
import ExternalJSExtension from './externalJS';
type ModuleExports = typeof Extension;
export default class ExternalExtensions extends ExternalJSExtension<ModuleExports> {
constructor(
zigbee: Zigbee,
mqtt: MQTT,
state: State,
publishEntityState: PublishEntityState,
eventBus: EventBus,
enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
restartCallback: () => Promise<void>,
addExtension: (extension: Extension) => Promise<void>,
) {
super(
zigbee,
mqtt,
state,
publishEntityState,
eventBus,
enableDisableExtension,
restartCallback,
addExtension,
'extension',
'external_extensions',
);
}
protected async removeJS(name: string, module: ModuleExports): Promise<void> {
await this.enableDisableExtension(false, module.name);
}
protected async loadJS(name: string, module: ModuleExports): Promise<void> {
// stop if already started
await this.enableDisableExtension(false, module.name);
await this.addExtension(
// @ts-expect-error `module` is the interface, not the actual passed class
new module(
this.zigbee,
this.mqtt,
this.state,
this.publishEntityState,
this.eventBus,
this.enableDisableExtension,
this.restartCallback,
this.addExtension,
settings,
logger,
),
);
logger.info(`Loaded external extension '${name}'.`);
}
}
+191
View File
@@ -0,0 +1,191 @@
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponse} from 'lib/types/api';
import fs from 'node:fs';
import path from 'node:path';
import {Context, runInNewContext} from 'node:vm';
import bind from 'bind-decorator';
import stringify from 'json-stable-stringify-without-jsonify';
import data from '../util/data';
import logger from '../util/logger';
import * as settings from '../util/settings';
import utils from '../util/utils';
import Extension from './extension';
const SUPPORTED_OPERATIONS = ['save', 'remove'];
export default abstract class ExternalJSExtension<M> extends Extension {
protected mqttTopic: string;
protected requestRegex: RegExp;
protected basePath: string;
constructor(
zigbee: Zigbee,
mqtt: MQTT,
state: State,
publishEntityState: PublishEntityState,
eventBus: EventBus,
enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
restartCallback: () => Promise<void>,
addExtension: (extension: Extension) => Promise<void>,
mqttTopic: string,
folderName: string,
) {
super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension);
this.mqttTopic = mqttTopic;
this.requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/${mqttTopic}/(save|remove)`);
this.basePath = data.joinPath(folderName);
}
override async start(): Promise<void> {
await super.start();
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
await this.loadFiles();
await this.publishExternalJS();
}
private getFilePath(name: string, mkBasePath: boolean = false): string {
if (mkBasePath && !fs.existsSync(this.basePath)) {
fs.mkdirSync(this.basePath, {recursive: true});
}
return path.join(this.basePath, name);
}
protected getFileCode(name: string): string {
return fs.readFileSync(path.join(this.basePath, name), 'utf8');
}
protected *getFiles(): Generator<{name: string; code: string}> {
if (!fs.existsSync(this.basePath)) {
return;
}
for (const fileName of fs.readdirSync(this.basePath)) {
if (fileName.endsWith('.js')) {
yield {name: fileName, code: this.getFileCode(fileName)};
}
}
}
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
const match = data.topic.match(this.requestRegex);
if (match && SUPPORTED_OPERATIONS.includes(match[1].toLowerCase())) {
const message = utils.parseJSON(data.message, data.message);
try {
let response;
if (match[1].toLowerCase() === 'save') {
response = await this.save(
message as Zigbee2MQTTAPI['bridge/request/converter/save'] | Zigbee2MQTTAPI['bridge/request/extension/save'],
);
} else {
response = await this.remove(
message as Zigbee2MQTTAPI['bridge/request/converter/remove'] | Zigbee2MQTTAPI['bridge/request/extension/remove'],
);
}
await this.mqtt.publish(`bridge/response/${this.mqttTopic}/${match[1]}`, stringify(response));
} catch (error) {
logger.error(`Request '${data.topic}' failed with error: '${(error as Error).message}'`);
const response = utils.getResponse(message, {}, `${(error as Error).message}`);
await this.mqtt.publish(`bridge/response/${this.mqttTopic}/${match[1]}`, stringify(response));
}
}
}
protected abstract removeJS(name: string, module: M): Promise<void>;
protected abstract loadJS(name: string, module: M): Promise<void>;
@bind private async remove(
message: Zigbee2MQTTAPI['bridge/request/converter/remove'] | Zigbee2MQTTAPI['bridge/request/extension/remove'],
): Promise<Zigbee2MQTTResponse<'bridge/response/converter/remove' | 'bridge/response/extension/remove'>> {
if (!message.name) {
return utils.getResponse(message, {}, `Invalid payload`);
}
const {name} = message;
const toBeRemoved = this.getFilePath(name);
if (fs.existsSync(toBeRemoved)) {
await this.removeJS(name, this.loadModuleFromText(this.getFileCode(name), name));
fs.rmSync(toBeRemoved, {force: true});
logger.info(`${name} (${toBeRemoved}) removed.`);
await this.publishExternalJS();
return utils.getResponse(message, {});
} else {
return utils.getResponse(message, {}, `${name} (${toBeRemoved}) doesn't exists`);
}
}
@bind private async save(
message: Zigbee2MQTTAPI['bridge/request/converter/save'] | Zigbee2MQTTAPI['bridge/request/extension/save'],
): Promise<Zigbee2MQTTResponse<'bridge/response/converter/save' | 'bridge/response/extension/save'>> {
if (!message.name || !message.code) {
return utils.getResponse(message, {}, `Invalid payload`);
}
const {name, code} = message;
try {
await this.loadJS(name, this.loadModuleFromText(code, name));
const filePath = this.getFilePath(name, true);
fs.writeFileSync(filePath, code, 'utf8');
logger.info(`${name} loaded. Contents written to '${filePath}'.`);
await this.publishExternalJS();
return utils.getResponse(message, {});
} catch (error) {
return utils.getResponse(message, {}, `${name} contains invalid code: ${(error as Error).message}`);
}
}
private async loadFiles(): Promise<void> {
for (const extension of this.getFiles()) {
await this.loadJS(extension.name, this.loadModuleFromText(extension.code, extension.name));
}
}
private async publishExternalJS(): Promise<void> {
await this.mqtt.publish(
`bridge/${this.mqttTopic}s`,
stringify(Array.from(this.getFiles())),
{
retain: true,
qos: 0,
},
settings.get().mqtt.base_topic,
true,
);
}
private loadModuleFromText(moduleCode: string, name: string): M {
const moduleFakePath = path.join(__dirname, '..', '..', 'data', 'extension', name);
const sandbox: Context = {
require: require,
module: {},
console,
setTimeout,
clearTimeout,
setInterval,
clearInterval,
setImmediate,
clearImmediate,
};
runInNewContext(moduleCode, sandbox, moduleFakePath);
return sandbox.module.exports;
}
}
+126 -72
View File
@@ -1,46 +1,72 @@
import http from 'http';
import https from 'https';
import gzipStatic, {RequestHandler} from 'connect-gzip-static';
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 {createServer} from 'node:http';
import {createServer as createSecureServer} from 'node:https';
import {posix} from 'node:path';
import {parse} from 'node:url';
import bind from 'bind-decorator';
import expressStaticGzip from 'express-static-gzip';
import finalhandler from 'finalhandler';
import logger from '../util/logger';
import frontend from 'zigbee2mqtt-frontend';
import stringify from 'json-stable-stringify-without-jsonify';
import WebSocket from 'ws';
import net from 'net';
import url from 'url';
import fs from 'fs';
import frontend from 'zigbee2mqtt-frontend';
import data from '../util/data';
import logger from '../util/logger';
import * as settings from '../util/settings';
import utils from '../util/utils';
import stringify from 'json-stable-stringify-without-jsonify';
import Extension from './extension';
import bind from 'bind-decorator';
/**
* This extension servers the frontend
*/
export default class Frontend extends Extension {
private mqttBaseTopic = settings.get().mqtt.base_topic;
private host = settings.get().frontend.host;
private port = settings.get().frontend.port;
private sslCert = settings.get().frontend.ssl_cert;
private sslKey = settings.get().frontend.ssl_key;
private authToken = settings.get().frontend.auth_token;
private retainedMessages = new Map();
private server: http.Server;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private fileServer: RequestHandler;
private wss: WebSocket.Server = null;
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 wss!: WebSocket.Server;
private baseUrl: string;
constructor(zigbee: Zigbee, mqtt: MQTT, state: State, publishEntityState: PublishEntityState,
eventBus: EventBus, enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
restartCallback: () => void, addExtension: (extension: Extension) => Promise<void>) {
constructor(
zigbee: Zigbee,
mqtt: MQTT,
state: State,
publishEntityState: PublishEntityState,
eventBus: EventBus,
enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
restartCallback: () => Promise<void>,
addExtension: (extension: Extension) => Promise<void>,
) {
super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension);
this.eventBus.onMQTTMessagePublished(this, this.onMQTTPublishMessage);
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 {
private isHttpsConfigured(): boolean {
if (this.sslCert && this.sslKey) {
if (!fs.existsSync(this.sslCert) || !fs.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.`); /* eslint-disable-line max-len */
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;
@@ -48,31 +74,40 @@ export default class Frontend extends Extension {
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');
}
},
/* v8 ignore stop */
},
};
this.fileServer = expressStaticGzip(frontend.getPath(), options);
this.deviceIconsFileServer = expressStaticGzip(data.joinPath('device_icons'), options);
this.wss = new WebSocket.Server({noServer: true, path: posix.join(this.baseUrl, 'api')});
this.wss.on('connection', this.onWebSocketConnection);
if (this.isHttpsConfigured()) {
const serverOptions = {
key: fs.readFileSync(this.sslKey),
cert: fs.readFileSync(this.sslCert)};
this.server = https.createServer(serverOptions, this.onRequest);
key: readFileSync(this.sslKey!), // valid from `isHttpsConfigured`
cert: readFileSync(this.sslCert!), // valid from `isHttpsConfigured`
};
this.server = createSecureServer(serverOptions, this.onRequest);
} else {
this.server = http.createServer(this.onRequest);
this.server = createServer(this.onRequest);
}
this.server.on('upgrade', this.onUpgrade);
/* istanbul ignore next */
const options = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setHeaders: (res: any, path: string): void => {
if (path.endsWith('index.html')) {
res.setHeader('Cache-Control', 'no-store');
}
},
};
this.fileServer = gzipStatic(frontend.getPath(), options);
this.wss = new WebSocket.Server({noServer: true});
this.wss.on('connection', this.onWebSocketConnection);
this.eventBus.onMQTTMessagePublished(this, this.onMQTTPublishMessage);
if (!this.host) {
this.server.listen(this.port);
@@ -87,29 +122,46 @@ export default class Frontend extends Extension {
}
override async stop(): Promise<void> {
super.stop();
await super.stop();
this.wss?.clients.forEach((client) => {
client.send(stringify({topic: 'bridge/state', payload: 'offline'}));
client.terminate();
});
this.wss?.close();
/* istanbul ignore else */
if (this.server) {
return new Promise((cb: () => void) => this.server.close(cb));
await new Promise((resolve) => this.server?.close(resolve));
}
@bind private onRequest(request: IncomingMessage, response: ServerResponse): void {
const fin = finalhandler(request, response);
const newUrl = posix.relative(this.baseUrl, request.url!);
// The request url is not within the frontend base url, so the relative path starts with '..'
if (newUrl.startsWith('.')) {
return fin();
}
// 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);
}
}
@bind private onRequest(request: http.IncomingMessage, response: http.ServerResponse): void {
// @ts-ignore
this.fileServer(request, response, finalhandler(request, response));
}
private authenticate(request: http.IncomingMessage, cb: (authenticate: boolean) => void): void {
const {query} = url.parse(request.url, true);
private authenticate(request: IncomingMessage, cb: (authenticate: boolean) => void): void {
const {query} = parse(request.url!, true);
cb(!this.authToken || this.authToken === query.token);
}
@bind private onUpgrade(request: http.IncomingMessage, socket: net.Socket, head: Buffer): void {
@bind private onUpgrade(request: IncomingMessage, socket: Socket, head: Buffer): void {
this.wss.handleUpgrade(request, socket, head, (ws) => {
this.authenticate(request, (isAuthenticated) => {
if (isAuthenticated) {
@@ -131,16 +183,24 @@ export default class Frontend extends Extension {
}
});
for (const [key, value] of this.retainedMessages) {
ws.send(stringify({topic: key, payload: value}));
for (const [topic, payload] of Object.entries(this.mqtt.retainedMessages)) {
if (topic.startsWith(`${this.mqttBaseTopic}/`)) {
ws.send(
stringify({
// Send topic without base_topic
topic: topic.substring(this.mqttBaseTopic.length + 1),
payload: utils.parseJSON(payload.payload, payload.payload),
}),
);
}
}
for (const device of this.zigbee.devices(false)) {
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
const payload = this.state.get(device);
const lastSeen = settings.get().advanced.last_seen;
/* istanbul ignore if */
if (lastSeen !== 'disable') {
payload.last_seen = utils.formatDate(device.zh.lastSeen, lastSeen);
payload.last_seen = utils.formatDate(device.zh.lastSeen ?? /* v8 ignore next */ 0, lastSeen);
}
if (device.zh.linkquality !== undefined) {
@@ -156,16 +216,10 @@ export default class Frontend extends Extension {
// Send topic without base_topic
const topic = data.topic.substring(this.mqttBaseTopic.length + 1);
const payload = utils.parseJSON(data.payload, data.payload);
if (data.options.retain) {
this.retainedMessages.set(topic, payload);
}
if (this.wss) {
for (const client of this.wss.clients) {
/* istanbul ignore else */
if (client.readyState === WebSocket.OPEN) {
client.send(stringify({topic, payload}));
}
for (const client of this.wss.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(stringify({topic, payload}));
}
}
}
+224 -281
View File
@@ -1,139 +1,105 @@
import * as settings from '../util/settings';
import logger from '../util/logger';
import utils from '../util/utils';
import stringify from 'json-stable-stringify-without-jsonify';
import equals from 'fast-deep-equal/es6';
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from 'lib/types/api';
import assert from 'node:assert';
import bind from 'bind-decorator';
import Extension from './extension';
import Device from '../model/device';
import Group from '../model/group';
import equals from 'fast-deep-equal/es6';
import stringify from 'json-stable-stringify-without-jsonify';
import * as zhc from 'zigbee-herdsman-converters';
const topicRegex =
new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/group/members/(remove|add|remove_all)$`);
const legacyTopicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/(.+)/(remove|add|remove_all)$`);
const legacyTopicRegexRemoveAll = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/remove_all$`);
import Device from '../model/device';
import Group from '../model/group';
import logger from '../util/logger';
import * as settings from '../util/settings';
import utils, {isLightExpose} from '../util/utils';
import Extension from './extension';
const stateProperties: {[s: string]: (value: string, exposes: zhc.Expose[]) => boolean} = {
'state': () => true,
'brightness': (value, exposes) =>
!!exposes.find((e) => e.type === 'light' && e.features.find((f) => f.name === 'brightness')),
'color_temp': (value, exposes) =>
!!exposes.find((e) => e.type === 'light' && e.features.find((f) => f.name === 'color_temp')),
'color': (value, exposes) =>
!!exposes.find((e) => e.type === 'light' &&
e.features.find((f) => f.name === 'color_xy' || f.name === 'color_hs')),
'color_mode': (value, exposes) =>
!!exposes.find((e) => e.type === 'light' && (
(e.features.find((f) => f.name === `color_${value}`)) ||
(value === 'color_temp' && e.features.find((f) => f.name === 'color_temp')) )),
const TOPIC_REGEX = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/group/members/(remove|add|remove_all)$`);
const STATE_PROPERTIES: Readonly<Record<string, (value: string, exposes: zhc.Expose[]) => boolean>> = {
state: () => true,
brightness: (value, exposes) => exposes.some((e) => isLightExpose(e) && e.features.some((f) => f.name === 'brightness')),
color_temp: (value, exposes) => exposes.some((e) => isLightExpose(e) && e.features.some((f) => f.name === 'color_temp')),
color: (value, exposes) => exposes.some((e) => isLightExpose(e) && e.features.some((f) => f.name === 'color_xy' || f.name === 'color_hs')),
color_mode: (value, exposes) =>
exposes.some(
(e) =>
isLightExpose(e) &&
(e.features.some((f) => f.name === `color_${value}`) || (value === 'color_temp' && e.features.some((f) => f.name === 'color_temp'))),
),
};
interface ParsedMQTTMessage {
type: 'remove' | 'add' | 'remove_all', resolvedEntityGroup: Group, resolvedEntityDevice: Device,
error: string, groupKey: string, deviceKey: string, triggeredViaLegacyApi: boolean,
skipDisableReporting: boolean, resolvedEntityEndpoint: zh.Endpoint,
type: 'remove' | 'add' | 'remove_all';
resolvedGroup?: Group;
resolvedDevice?: Device;
resolvedEndpoint?: zh.Endpoint;
groupKey?: string;
deviceKey?: string;
endpointKey?: string | number;
skipDisableReporting: boolean;
}
export default class Groups extends Extension {
private legacyApi = settings.get().advanced.legacy_api;
private lastOptimisticState: {[s: string]: KeyValue} = {};
override async start(): Promise<void> {
this.eventBus.onStateChange(this, this.onStateChange);
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
await this.syncGroupsWithSettings();
}
private async syncGroupsWithSettings(): Promise<void> {
const settingsGroups = settings.getGroups();
const zigbeeGroups = this.zigbee.groups();
const addRemoveFromGroup = async (action: 'add' | 'remove', deviceName: string,
groupName: string | number, endpoint: zh.Endpoint, group: Group): Promise<void> => {
try {
logger.info(`${action === 'add' ? 'Adding' : 'Removing'} '${deviceName}' to group '${groupName}'`);
if (action === 'remove') {
await endpoint.removeFromGroup(group.zh);
} else {
await endpoint.addToGroup(group.zh);
}
} catch (error) {
logger.error(`Failed to ${action} '${deviceName}' from '${groupName}'`);
logger.debug(error.stack);
}
};
for (const settingGroup of settingsGroups) {
const groupID = settingGroup.ID;
const zigbeeGroup = zigbeeGroups.find((g) => g.ID === groupID) || this.zigbee.createGroup(groupID);
const settingsEndpoint = settingGroup.devices.map((d) => {
const parsed = utils.parseEntityID(d);
const entity = this.zigbee.resolveEntity(parsed.ID) as Device;
if (!entity) logger.error(`Cannot find '${d}' of group '${settingGroup.friendly_name}'`);
return {'endpoint': entity?.endpoint(parsed.endpoint), 'name': entity?.name};
}).filter((e) => e.endpoint != null);
// In settings but not in zigbee
for (const entity of settingsEndpoint) {
if (!zigbeeGroup.zh.hasMember(entity.endpoint)) {
addRemoveFromGroup('add', entity.name, settingGroup.friendly_name, entity.endpoint, zigbeeGroup);
}
}
// In zigbee but not in settings
for (const endpoint of zigbeeGroup.zh.members) {
if (!settingsEndpoint.find((e) => e.endpoint === endpoint)) {
const deviceName = settings.getDevice(endpoint.getDevice().ieeeAddr).friendly_name;
addRemoveFromGroup('remove', deviceName, settingGroup.friendly_name, endpoint, zigbeeGroup);
}
}
}
for (const zigbeeGroup of zigbeeGroups) {
if (!settingsGroups.find((g) => g.ID === zigbeeGroup.ID)) {
for (const endpoint of zigbeeGroup.zh.members) {
const deviceName = settings.getDevice(endpoint.getDevice().ieeeAddr).friendly_name;
addRemoveFromGroup('remove', deviceName, zigbeeGroup.ID, endpoint, zigbeeGroup);
}
}
}
}
@bind async onStateChange(data: eventdata.StateChange): Promise<void> {
const reason = 'groupOptimistic';
if (data.reason === reason || data.reason === 'publishCached') {
return;
}
const payload: KeyValue = {};
let endpointName: string | undefined;
const endpointNames: string[] = data.entity instanceof Device ? data.entity.getEndpointNames() : [];
for (let prop of Object.keys(data.update)) {
const value = data.update[prop];
const endpointNameMatch = endpointNames.find((n) => prop.endsWith(`_${n}`));
let endpointName: string = null;
for (let [prop, value] of Object.entries(data.update)) {
const endpointNameMatch = utils.endpointNames.find((n) => prop.endsWith(`_${n}`));
if (endpointNameMatch) {
prop = prop.substring(0, prop.length - endpointNameMatch.length - 1);
endpointName = endpointNameMatch;
}
if (prop in stateProperties) {
if (prop in STATE_PROPERTIES) {
payload[prop] = value;
}
}
if (Object.keys(payload).length) {
const payloadKeys = Object.keys(payload);
if (payloadKeys.length) {
const entity = data.entity;
const groups = this.zigbee.groups().filter((g) => {
return g.options && (!g.options.hasOwnProperty('optimistic') || g.options.optimistic);
});
const groups = [];
for (const group of this.zigbee.groupsIterator()) {
if (group.options && (group.options.optimistic == undefined || group.options.optimistic)) {
groups.push(group);
}
}
if (entity instanceof Device) {
for (const group of groups) {
if (group.zh.hasMember(entity.endpoint(endpointName)) &&
!equals(this.lastOptimisticState[group.ID], payload) &&
this.shouldPublishPayloadForGroup(group, payload)) {
this.lastOptimisticState[group.ID] = payload;
await this.publishEntityState(group, payload, reason);
const endpoint = entity.endpoint(endpointName);
if (endpoint) {
for (const group of groups) {
if (
group.zh.hasMember(endpoint) &&
!equals(this.lastOptimisticState[group.ID], payload) &&
this.shouldPublishPayloadForGroup(group, payload)
) {
this.lastOptimisticState[group.ID] = payload;
await this.publishEntityState(group, payload, reason);
}
}
}
} else {
@@ -141,34 +107,43 @@ export default class Groups extends Extension {
delete this.lastOptimisticState[entity.ID];
const groupsToPublish: Set<Group> = new Set();
for (const member of entity.zh.members) {
const device = this.zigbee.resolveEntity(member.getDevice()) as Device;
if (device.options.disabled) continue;
if (device.options.disabled) {
continue;
}
const exposes = device.exposes();
const memberPayload: KeyValue = {};
Object.keys(payload).forEach((key) => {
if (stateProperties[key](payload[key], exposes)) {
for (const key of payloadKeys) {
if (STATE_PROPERTIES[key](payload[key], exposes)) {
memberPayload[key] = payload[key];
}
});
}
const endpointName = device.endpointName(member);
if (endpointName) {
Object.keys(memberPayload).forEach((key) => {
for (const key of Object.keys(memberPayload)) {
memberPayload[`${key}_${endpointName}`] = memberPayload[key];
delete memberPayload[key];
});
}
}
await this.publishEntityState(device, memberPayload, reason);
for (const zigbeeGroup of groups) {
if (zigbeeGroup.zh.hasMember(member) &&
this.shouldPublishPayloadForGroup(zigbeeGroup, payload)) {
if (zigbeeGroup.zh.hasMember(member) && this.shouldPublishPayloadForGroup(zigbeeGroup, payload)) {
groupsToPublish.add(zigbeeGroup);
}
}
}
groupsToPublish.delete(entity);
for (const group of groupsToPublish) {
await this.publishEntityState(group, payload, reason);
}
@@ -177,211 +152,179 @@ export default class Groups extends Extension {
}
private shouldPublishPayloadForGroup(group: Group, payload: KeyValue): boolean {
if (group.options.off_state === 'last_member_state') return true;
if (!payload || payload.state !== 'OFF') return true;
if (this.areAllMembersOff(group)) return true;
return false;
return (
group.options.off_state === 'last_member_state' ||
!payload ||
(payload.state !== 'OFF' && payload.state !== 'CLOSE') ||
this.areAllMembersOffOrClosed(group)
);
}
private areAllMembersOff(group: Group): boolean {
private areAllMembersOffOrClosed(group: Group): boolean {
for (const member of group.zh.members) {
const device = this.zigbee.resolveEntity(member.getDevice());
const device = this.zigbee.resolveEntity(member.getDevice())!;
if (this.state.exists(device)) {
const state = this.state.get(device);
if (state.state === 'ON') {
const endpointNames = device.isDevice() && device.getEndpointNames();
const stateKey =
endpointNames &&
endpointNames.length >= member.ID &&
device.definition?.meta?.multiEndpoint &&
(!device.definition.meta.multiEndpointSkip || !device.definition.meta.multiEndpointSkip.includes('state'))
? `state_${endpointNames[member.ID - 1]}`
: 'state';
if (state[stateKey] === 'ON' || state[stateKey] === 'OPEN') {
return false;
}
}
}
return true;
}
private parseMQTTMessage(data: eventdata.MQTTMessage): ParsedMQTTMessage {
let type: 'remove' | 'add' | 'remove_all' = null;
let resolvedEntityGroup: Group = null;
let resolvedEntityDevice: Device = null;
let resolvedEntityEndpoint: zh.Endpoint = null;
let error: string = null;
let groupKey: string = null;
let deviceKey: string = null;
let triggeredViaLegacyApi = false;
let skipDisableReporting = false;
private parseMQTTMessage(
data: eventdata.MQTTMessage,
): [raw: KeyValue | undefined, parsed: ParsedMQTTMessage | undefined, error: string | undefined] {
const topicRegexMatch = data.topic.match(TOPIC_REGEX);
/* istanbul ignore else */
const topicRegexMatch = data.topic.match(topicRegex);
const legacyTopicRegexRemoveAllMatch = data.topic.match(legacyTopicRegexRemoveAll);
const legacyTopicRegexMatch = data.topic.match(legacyTopicRegex);
if (topicRegexMatch) {
const type = topicRegexMatch[1] as 'remove' | 'add' | 'remove_all';
let resolvedGroup;
let groupKey;
let skipDisableReporting = false;
const message = JSON.parse(data.message) as Zigbee2MQTTAPI['bridge/request/group/members/add'];
if (this.legacyApi && (legacyTopicRegexMatch || legacyTopicRegexRemoveAllMatch)) {
triggeredViaLegacyApi = true;
if (legacyTopicRegexMatch) {
resolvedEntityGroup = this.zigbee.resolveEntity(legacyTopicRegexMatch[1]) as Group;
type = legacyTopicRegexMatch[2] as 'remove' | 'remove_all' | 'add';
if (!resolvedEntityGroup || !(resolvedEntityGroup instanceof Group)) {
logger.error(`Group '${legacyTopicRegexMatch[1]}' does not exist`);
/* istanbul ignore else */
if (settings.get().advanced.legacy_api) {
const payload = {friendly_name: data.message,
group: legacyTopicRegexMatch[1], error: 'group doesn\'t exists'};
this.mqtt.publish(
'bridge/log',
stringify({type: `device_group_${type}_failed`, message: payload}),
);
}
return null;
}
} else {
type = 'remove_all';
if (typeof message !== 'object' || message.device == undefined) {
return [message, {type, skipDisableReporting}, 'Invalid payload'];
}
const parsedEntity = utils.parseEntityID(data.message);
resolvedEntityDevice = this.zigbee.resolveEntity(parsedEntity.ID) as Device;
if (!resolvedEntityDevice || !(resolvedEntityDevice instanceof Device)) {
logger.error(`Device '${data.message}' does not exist`);
/* istanbul ignore else */
if (settings.get().advanced.legacy_api) {
const payload = {
friendly_name: data.message, group: legacyTopicRegexMatch[1], error: 'entity doesn\'t exists',
};
this.mqtt.publish(
'bridge/log',
stringify({type: `device_group_${type}_failed`, message: payload}),
);
}
return null;
}
resolvedEntityEndpoint = resolvedEntityDevice.endpoint(parsedEntity.endpoint);
} else if (topicRegexMatch) {
type = topicRegexMatch[1] as 'remove' | 'add' | 'remove_all';
const message = JSON.parse(data.message);
deviceKey = message.device;
skipDisableReporting = 'skip_disable_reporting' in message ? message.skip_disable_reporting : false;
const deviceKey = message.device;
skipDisableReporting = message.skip_disable_reporting != undefined ? message.skip_disable_reporting : false;
if (type !== 'remove_all') {
groupKey = message.group;
resolvedEntityGroup = this.zigbee.resolveEntity(message.group) as Group;
if (!resolvedEntityGroup || !(resolvedEntityGroup instanceof Group)) {
error = `Group '${message.group}' does not exist`;
if (message.group == undefined) {
return [message, {type, skipDisableReporting}, `Invalid payload`];
}
resolvedGroup = this.zigbee.resolveEntity(message.group);
if (!resolvedGroup || !(resolvedGroup instanceof Group)) {
return [message, {type, skipDisableReporting}, `Group '${message.group}' does not exist`];
}
}
const parsed = utils.parseEntityID(message.device);
resolvedEntityDevice = this.zigbee.resolveEntity(parsed.ID) as Device;
if (!error && (!resolvedEntityDevice || !(resolvedEntityDevice instanceof Device))) {
error = `Device '${message.device}' does not exist`;
}
if (!error) {
resolvedEntityEndpoint = resolvedEntityDevice.endpoint(parsed.endpoint);
}
}
const resolvedDevice = this.zigbee.resolveEntity(message.device);
return {
resolvedEntityGroup, resolvedEntityDevice, type, error, groupKey, deviceKey,
triggeredViaLegacyApi, skipDisableReporting, resolvedEntityEndpoint,
};
if (!resolvedDevice || !(resolvedDevice instanceof Device)) {
return [message, {type, skipDisableReporting}, `Device '${message.device}' does not exist`];
}
const endpointKey = message.endpoint ?? 'default';
const resolvedEndpoint = resolvedDevice.endpoint(message.endpoint);
if (!resolvedEndpoint) {
return [message, {type, skipDisableReporting}, `Device '${resolvedDevice.name}' does not have endpoint '${endpointKey}'`];
}
return [
message,
{
resolvedGroup,
resolvedDevice,
resolvedEndpoint,
type,
groupKey,
deviceKey,
endpointKey,
skipDisableReporting,
},
undefined,
];
} else {
return [undefined, undefined, undefined];
}
}
@bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
const parsed = this.parseMQTTMessage(data);
if (!parsed || !parsed.type) return;
let {
resolvedEntityGroup, resolvedEntityDevice, type, error, triggeredViaLegacyApi,
groupKey, deviceKey, skipDisableReporting, resolvedEntityEndpoint,
} = parsed;
const message = utils.parseJSON(data.message, data.message);
let changedGroups: Group[] = [];
const [raw, parsed, error] = this.parseMQTTMessage(data);
const responseData: KeyValue = {device: deviceKey};
if (groupKey) {
responseData.group = groupKey;
}
if (!error) {
try {
const keys = [
`${resolvedEntityDevice.ieeeAddr}/${resolvedEntityEndpoint.ID}`,
`${resolvedEntityDevice.name}/${resolvedEntityEndpoint.ID}`,
];
const endpointNameLocal = resolvedEntityDevice.endpointName(resolvedEntityEndpoint);
if (endpointNameLocal) {
keys.push(`${resolvedEntityDevice.ieeeAddr}/${endpointNameLocal}`);
keys.push(`${resolvedEntityDevice.name}/${endpointNameLocal}`);
}
if (!endpointNameLocal) {
keys.push(resolvedEntityDevice.name);
keys.push(resolvedEntityDevice.ieeeAddr);
}
if (type === 'add') {
logger.info(`Adding '${resolvedEntityDevice.name}' to '${resolvedEntityGroup.name}'`);
await resolvedEntityEndpoint.addToGroup(resolvedEntityGroup.zh);
settings.addDeviceToGroup(resolvedEntityGroup.ID.toString(), keys);
changedGroups.push(resolvedEntityGroup);
/* istanbul ignore else */
if (settings.get().advanced.legacy_api) {
const payload = {friendly_name: resolvedEntityDevice.name, group: resolvedEntityGroup.name};
this.mqtt.publish(
'bridge/log',
stringify({type: `device_group_add`, message: payload}),
);
}
} else if (type === 'remove') {
logger.info(`Removing '${resolvedEntityDevice.name}' from '${resolvedEntityGroup.name}'`);
await resolvedEntityEndpoint.removeFromGroup(resolvedEntityGroup.zh);
settings.removeDeviceFromGroup(resolvedEntityGroup.ID.toString(), keys);
changedGroups.push(resolvedEntityGroup);
/* istanbul ignore else */
if (settings.get().advanced.legacy_api) {
const payload = {friendly_name: resolvedEntityDevice.name, group: resolvedEntityGroup.name};
this.mqtt.publish(
'bridge/log',
stringify({type: `device_group_remove`, message: payload}),
);
}
} else { // remove_all
logger.info(`Removing '${resolvedEntityDevice.name}' from all groups`);
changedGroups = this.zigbee.groups().filter((g) => g.zh.members.includes(resolvedEntityEndpoint));
await resolvedEntityEndpoint.removeFromAllGroups();
for (const settingsGroup of settings.getGroups()) {
settings.removeDeviceFromGroup(settingsGroup.ID.toString(), keys);
/* istanbul ignore else */
if (settings.get().advanced.legacy_api) {
const payload = {friendly_name: resolvedEntityDevice.name};
this.mqtt.publish(
'bridge/log',
stringify({type: `device_group_remove_all`, message: payload}),
);
}
}
}
} catch (e) {
error = `Failed to ${type} from group (${e.message})`;
logger.debug(e.stack);
}
}
if (!triggeredViaLegacyApi) {
const response = utils.getResponse(message, responseData, error);
await this.mqtt.publish(`bridge/response/group/members/${type}`, stringify(response));
if (!raw || !parsed) {
return;
}
if (error) {
logger.error(error);
} else {
for (const group of changedGroups) {
this.eventBus.emitGroupMembersChanged({
group, action: type, endpoint: resolvedEntityEndpoint, skipDisableReporting});
await this.publishResponse(parsed.type, raw, {}, error);
return;
}
const {resolvedGroup, resolvedDevice, resolvedEndpoint, type, groupKey, deviceKey, endpointKey, skipDisableReporting} = parsed;
const changedGroups: Group[] = [];
assert(resolvedDevice, '`resolvedDevice` is missing');
assert(resolvedEndpoint, '`resolvedEndpoint` is missing');
try {
if (type === 'add') {
assert(resolvedGroup, '`resolvedGroup` is missing');
logger.info(`Adding '${resolvedDevice.name}' to '${resolvedGroup.name}'`);
await resolvedEndpoint.addToGroup(resolvedGroup.zh);
changedGroups.push(resolvedGroup);
await this.publishResponse<'bridge/response/group/members/add'>(parsed.type, raw, {
device: deviceKey!, // valid from resolved asserts
endpoint: endpointKey!, // valid from resolved asserts
group: groupKey!, // valid from resolved asserts
});
} else if (type === 'remove') {
assert(resolvedGroup, '`resolvedGroup` is missing');
logger.info(`Removing '${resolvedDevice.name}' from '${resolvedGroup.name}'`);
await resolvedEndpoint.removeFromGroup(resolvedGroup.zh);
changedGroups.push(resolvedGroup);
await this.publishResponse<'bridge/response/group/members/remove'>(parsed.type, raw, {
device: deviceKey!, // valid from resolved asserts
endpoint: endpointKey!, // valid from resolved asserts
group: groupKey!, // valid from resolved asserts
});
} else {
// remove_all
logger.info(`Removing '${resolvedDevice.name}' from all groups`);
for (const group of this.zigbee.groupsIterator((g) => g.members.includes(resolvedEndpoint))) {
changedGroups.push(group);
}
await resolvedEndpoint.removeFromAllGroups();
await this.publishResponse<'bridge/response/group/members/remove_all'>(parsed.type, raw, {
device: deviceKey!, // valid from resolved asserts
endpoint: endpointKey!, // valid from resolved asserts
});
}
} catch (e) {
const errorMsg = `Failed to ${type} from group (${(e as Error).message})`;
await this.publishResponse(parsed.type, raw, {}, errorMsg);
logger.debug((e as Error).stack!);
return;
}
for (const group of changedGroups) {
this.eventBus.emitGroupMembersChanged({group, action: type, endpoint: resolvedEndpoint, skipDisableReporting});
}
}
private async publishResponse<T extends Zigbee2MQTTResponseEndpoints>(
type: ParsedMQTTMessage['type'],
request: KeyValue,
data: Zigbee2MQTTAPI[T],
error?: string,
): Promise<void> {
const response = utils.getResponse(request, data, error);
await this.mqtt.publish(`bridge/response/group/members/${type}`, stringify(response));
if (error) {
logger.error(error);
}
}
}
File diff suppressed because it is too large Load Diff
-455
View File
@@ -1,455 +0,0 @@
import * as settings from '../../util/settings';
import logger from '../../util/logger';
import utils from '../../util/utils';
import assert from 'assert';
import Extension from '../extension';
import stringify from 'json-stable-stringify-without-jsonify';
import bind from 'bind-decorator';
const configRegex =
new RegExp(`${settings.get().mqtt.base_topic}/bridge/config/((?:\\w+/get)|(?:\\w+/factory_reset)|(?:\\w+))`);
const allowedLogLevels = ['error', 'warn', 'info', 'debug'];
export default class BridgeLegacy extends Extension {
private lastJoinedDeviceName: string = null;
private supportedOptions: {[s: string]: (topic: string, message: string) => Promise<void> | void};
override async start(): Promise<void> {
this.supportedOptions = {
'permit_join': this.permitJoin,
'last_seen': this.lastSeen,
'elapsed': this.elapsed,
'reset': this.reset,
'log_level': this.logLevel,
'devices': this.devices,
'groups': this.groups,
'devices/get': this.devices,
'rename': this.rename,
'rename_last': this.renameLast,
'remove': this.remove,
'force_remove': this.forceRemove,
'ban': this.ban,
'device_options': this.deviceOptions,
'add_group': this.addGroup,
'remove_group': this.removeGroup,
'force_remove_group': this.removeGroup,
'whitelist': this.whitelist,
'touchlink/factory_reset': this.touchlinkFactoryReset,
};
this.eventBus.onDeviceJoined(this, (data) => this.onZigbeeEvent_('deviceJoined', data, data.device));
this.eventBus.onDeviceInterview(this, (data) => this.onZigbeeEvent_('deviceInterview', data, data.device));
this.eventBus.onDeviceAnnounce(this, (data) => this.onZigbeeEvent_('deviceAnnounce', data, data.device));
this.eventBus.onDeviceLeave(this, (data) => this.onZigbeeEvent_('deviceLeave', data, null));
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
await this.publish();
}
@bind whitelist(topic: string, message: string): void {
try {
const entity = settings.getDevice(message);
assert(entity, `Entity '${message}' does not exist`);
settings.addDeviceToPasslist(entity.ID.toString());
logger.info(`Whitelisted '${entity.friendly_name}'`);
this.mqtt.publish(
'bridge/log',
stringify({type: 'device_whitelisted', message: {friendly_name: entity.friendly_name}}),
);
} catch (error) {
logger.error(`Failed to whitelist '${message}' '${error}'`);
}
}
@bind deviceOptions(topic: string, message: string): void {
let json = null;
try {
json = JSON.parse(message);
} catch (e) {
logger.error('Failed to parse message as JSON');
return;
}
if (!json.hasOwnProperty('friendly_name') || !json.hasOwnProperty('options')) {
logger.error('Invalid JSON message, should contain "friendly_name" and "options"');
return;
}
const entity = settings.getDevice(json.friendly_name);
assert(entity, `Entity '${json.friendly_name}' does not exist`);
settings.changeEntityOptions(entity.ID.toString(), json.options);
logger.info(`Changed device specific options of '${json.friendly_name}' (${stringify(json.options)})`);
}
@bind async permitJoin(topic: string, message: string): Promise<void> {
await this.zigbee.permitJoin(message.toLowerCase() === 'true');
this.publish();
}
@bind async reset(): Promise<void> {
try {
await this.zigbee.reset('soft');
logger.info('Soft reset ZNP');
} catch (error) {
logger.error('Soft reset failed');
}
}
@bind lastSeen(topic: string, message: string): void {
const allowed = ['disable', 'ISO_8601', 'epoch', 'ISO_8601_local'];
if (!allowed.includes(message)) {
logger.error(`${message} is not an allowed value, possible: ${allowed}`);
return;
}
settings.set(['advanced', 'last_seen'], message);
logger.info(`Set last_seen to ${message}`);
}
@bind elapsed(topic: string, message: string): void {
const allowed = ['true', 'false'];
if (!allowed.includes(message)) {
logger.error(`${message} is not an allowed value, possible: ${allowed}`);
return;
}
settings.set(['advanced', 'elapsed'], message === 'true');
logger.info(`Set elapsed to ${message}`);
}
@bind logLevel(topic: string, message: string): void {
const level = message.toLowerCase() as 'error' | 'warn' | 'info' | 'debug';
if (allowedLogLevels.includes(level)) {
logger.info(`Switching log level to '${level}'`);
logger.setLevel(level);
} else {
logger.error(`Could not set log level to '${level}'. Allowed level: '${allowedLogLevels.join(',')}'`);
}
this.publish();
}
@bind async devices(topic: string): Promise<void> {
const coordinator = await this.zigbee.getCoordinatorVersion();
const devices = this.zigbee.devices().map((device) => {
const payload: KeyValue = {
ieeeAddr: device.ieeeAddr,
type: device.zh.type,
networkAddress: device.zh.networkAddress,
};
if (device.zh.type !== 'Coordinator') {
const definition = device.definition;
payload.model = definition ? definition.model : device.zh.modelID;
payload.vendor = definition ? definition.vendor : '-';
payload.description = definition ? definition.description : '-';
payload.friendly_name = device.name;
payload.manufacturerID = device.zh.manufacturerID;
payload.manufacturerName = device.zh.manufacturerName;
payload.powerSource = device.zh.powerSource;
payload.modelID = device.zh.modelID;
payload.hardwareVersion = device.zh.hardwareVersion;
payload.softwareBuildID = device.zh.softwareBuildID;
payload.dateCode = device.zh.dateCode;
payload.lastSeen = device.zh.lastSeen;
} else {
payload.friendly_name = 'Coordinator';
payload.softwareBuildID = coordinator.type;
payload.dateCode = coordinator.meta.revision.toString();
payload.lastSeen = Date.now();
}
return payload;
});
if (topic.split('/').pop() == 'get') {
this.mqtt.publish(
`bridge/config/devices`, stringify(devices), {}, settings.get().mqtt.base_topic, false, false,
);
} else {
this.mqtt.publish('bridge/log', stringify({type: 'devices', message: devices}));
}
}
@bind groups(): void {
const payload = settings.getGroups().map((g) => {
return {...g, ID: Number(g.ID)};
});
this.mqtt.publish('bridge/log', stringify({type: 'groups', message: payload}));
}
@bind rename(topic: string, message: string): void {
const invalid =
`Invalid rename message format expected {"old": "friendly_name", "new": "new_name"} got ${message}`;
let json = null;
try {
json = JSON.parse(message);
} catch (e) {
logger.error(invalid);
return;
}
// Validate message
if (!json.new || !json.old) {
logger.error(invalid);
return;
}
this._renameInternal(json.old, json.new);
}
@bind renameLast(topic: string, message: string): void {
if (!this.lastJoinedDeviceName) {
logger.error(`Cannot rename last joined device, no device has joined during this session`);
return;
}
this._renameInternal(this.lastJoinedDeviceName, message);
}
_renameInternal(from: string, to: string): void {
try {
const isGroup = settings.getGroup(from) !== null;
settings.changeFriendlyName(from, to);
logger.info(`Successfully renamed - ${from} to ${to} `);
const entity = this.zigbee.resolveEntity(to);
if (entity.isDevice()) {
this.eventBus.emitEntityRenamed({homeAssisantRename: false, from, to, entity});
}
this.mqtt.publish(
'bridge/log',
stringify({type: `${isGroup ? 'group' : 'device'}_renamed`, message: {from, to}}),
);
} catch (error) {
logger.error(`Failed to rename - ${from} to ${to}`);
}
}
@bind addGroup(topic: string, message: string): void {
let id = null;
let name = null;
try {
// json payload with id and friendly_name
const json = JSON.parse(message);
if (json.hasOwnProperty('id')) {
id = json.id;
name = `group_${id}`;
}
if (json.hasOwnProperty('friendly_name')) {
name = json.friendly_name;
}
} catch (e) {
// just friendly_name
name = message;
}
if (name == null) {
logger.error('Failed to add group, missing friendly_name!');
return;
}
const group = settings.addGroup(name, id);
this.zigbee.createGroup(group.ID);
this.mqtt.publish('bridge/log', stringify({type: `group_added`, message: name}));
logger.info(`Added group '${name}'`);
}
@bind removeGroup(topic: string, message: string): void {
const name = message;
const entity = this.zigbee.resolveEntity(message) as Group;
assert(entity && entity.isGroup(), `Group '${message}' does not exist`);
if (topic.includes('force')) {
entity.zh.removeFromDatabase();
} else {
entity.zh.removeFromNetwork();
}
settings.removeGroup(message);
this.mqtt.publish('bridge/log', stringify({type: `group_removed`, message}));
logger.info(`Removed group '${name}'`);
}
@bind async forceRemove(topic: string, message: string): Promise<void> {
await this.removeForceRemoveOrBan('force_remove', message);
}
@bind async remove(topic: string, message: string): Promise<void> {
await this.removeForceRemoveOrBan('remove', message);
}
@bind async ban(topic: string, message: string): Promise<void> {
await this.removeForceRemoveOrBan('ban', message);
}
@bind async removeForceRemoveOrBan(action: string, message: string): Promise<void> {
const entity = this.zigbee.resolveEntity(message.trim()) as Device;
const lookup: KeyValue = {
ban: ['banned', 'Banning', 'ban'],
force_remove: ['force_removed', 'Force removing', 'force remove'],
remove: ['removed', 'Removing', 'remove'],
};
if (!entity) {
logger.error(`Cannot ${lookup[action][2]}, device '${message}' does not exist`);
this.mqtt.publish('bridge/log', stringify({type: `device_${lookup[action][0]}_failed`, message}));
return;
}
const ieeeAddr = entity.ieeeAddr;
const name = entity.name;
const cleanup = (): void => {
// Fire event
this.eventBus.emitDeviceRemoved({ieeeAddr, name});
// Remove from configuration.yaml
settings.removeDevice(entity.ieeeAddr);
// Remove from state
this.state.remove(ieeeAddr);
logger.info(`Successfully ${lookup[action][0]} ${entity.name}`);
this.mqtt.publish('bridge/log', stringify({type: `device_${lookup[action][0]}`, message}));
};
try {
logger.info(`${lookup[action][1]} '${entity.name}'`);
if (action === 'force_remove') {
await entity.zh.removeFromDatabase();
} else {
await entity.zh.removeFromNetwork();
}
cleanup();
} catch (error) {
logger.error(`Failed to ${lookup[action][2]} ${entity.name} (${error})`);
// eslint-disable-next-line
logger.error(`See https://www.zigbee2mqtt.io/guide/usage/mqtt_topics_and_messages.html#zigbee2mqtt-bridge-request for more info`);
this.mqtt.publish('bridge/log', stringify({type: `device_${lookup[action][0]}_failed`, message}));
}
if (action === 'ban') {
settings.blockDevice(ieeeAddr);
}
}
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
const {topic, message} = data;
if (!topic.match(configRegex)) {
return;
}
const option = topic.match(configRegex)[1];
if (!this.supportedOptions.hasOwnProperty(option)) {
return;
}
await this.supportedOptions[option](topic, message);
return;
}
async publish(): Promise<void> {
const info = await utils.getZigbee2MQTTVersion();
const coordinator = await this.zigbee.getCoordinatorVersion();
const topic = `bridge/config`;
const payload = {
version: info.version,
commit: info.commitHash,
coordinator,
network: await this.zigbee.getNetworkParameters(),
log_level: logger.getLevel(),
permit_join: this.zigbee.getPermitJoin(),
};
await this.mqtt.publish(topic, stringify(payload), {retain: true, qos: 0});
}
onZigbeeEvent_(type: string, data: KeyValue, resolvedEntity: Device): void {
if (type === 'deviceJoined' && resolvedEntity) {
this.lastJoinedDeviceName = resolvedEntity.name;
}
if (type === 'deviceJoined') {
this.mqtt.publish(
'bridge/log',
stringify({type: `device_connected`, message: {friendly_name: resolvedEntity.name}}),
);
} else if (type === 'deviceInterview') {
if (data.status === 'successful') {
if (resolvedEntity.isSupported) {
const {vendor, description, model} = resolvedEntity.definition;
const log = {friendly_name: resolvedEntity.name, model, vendor, description, supported: true};
this.mqtt.publish(
'bridge/log',
stringify({type: `pairing`, message: 'interview_successful', meta: log}),
);
} else {
const meta = {friendly_name: resolvedEntity.name, supported: false};
this.mqtt.publish(
'bridge/log',
stringify({type: `pairing`, message: 'interview_successful', meta}),
);
}
} else if (data.status === 'failed') {
const meta = {friendly_name: resolvedEntity.name};
this.mqtt.publish(
'bridge/log',
stringify({type: `pairing`, message: 'interview_failed', meta}),
);
} else {
/* istanbul ignore else */
if (data.status === 'started') {
const meta = {friendly_name: resolvedEntity.name};
this.mqtt.publish(
'bridge/log',
stringify({type: `pairing`, message: 'interview_started', meta}),
);
}
}
} else if (type === 'deviceAnnounce') {
const meta = {friendly_name: resolvedEntity.name};
this.mqtt.publish('bridge/log', stringify({type: `device_announced`, message: 'announce', meta}));
} else {
/* istanbul ignore else */
if (type === 'deviceLeave') {
const name = data.ieeeAddr;
const meta = {friendly_name: name};
this.mqtt.publish(
'bridge/log',
stringify({type: `device_removed`, message: 'left_network', meta}),
);
}
}
}
@bind async touchlinkFactoryReset(): Promise<void> {
logger.info('Starting touchlink factory reset...');
this.mqtt.publish(
'bridge/log',
stringify({type: `touchlink`, message: 'reset_started', meta: {status: 'started'}}),
);
const result = await this.zigbee.touchlinkFactoryResetFirst();
if (result) {
logger.info('Successfully factory reset device through Touchlink');
this.mqtt.publish(
'bridge/log',
stringify({type: `touchlink`, message: 'reset_success', meta: {status: 'success'}}),
);
} else {
logger.warn('Failed to factory reset device through Touchlink');
this.mqtt.publish(
'bridge/log',
stringify({type: `touchlink`, message: 'reset_failed', meta: {status: 'failed'}}),
);
}
}
}
@@ -1,56 +0,0 @@
/* istanbul ignore file */
import * as settings from '../../util/settings';
import logger from '../../util/logger';
import utils from '../../util/utils';
import Extension from '../extension';
import bind from 'bind-decorator';
import Device from '../../model/device';
const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/device/(.+)/get_group_membership$`);
export default class DeviceGroupMembership extends Extension {
override async start(): Promise<void> {
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
}
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
const match = data.topic.match(topicRegex);
if (!match) {
return null;
}
const parsed = utils.parseEntityID(match[1]);
const device = this.zigbee.resolveEntity(parsed.ID) as Device;
if (!device || !(device instanceof Device)) {
logger.error(`Device '${match[1]}' does not exist`);
return;
}
const endpoint = device.endpoint(parsed.endpoint);
const response = await endpoint.command(
`genGroups`, 'getMembership', {groupcount: 0, grouplist: []}, {},
);
if (!response) {
logger.warn(`Couldn't get group membership of ${device.ieeeAddr}`);
return;
}
let {grouplist, capacity} = response;
grouplist = grouplist.map((gid: string) => {
const g = settings.getGroup(gid);
return g ? g.friendly_name : gid;
});
const msgGroupList = `${device.ieeeAddr} is in groups [${grouplist}]`;
let msgCapacity;
if (capacity === 254) {
msgCapacity = 'it can be a part of at least 1 more group';
} else {
msgCapacity = `its remaining group capacity is ${capacity === 255 ? 'unknown' : capacity}`;
}
logger.info(`${msgGroupList} and ${msgCapacity}`);
this.publishEntityState(device, {group_list: grouplist, group_capacity: capacity});
}
}
-194
View File
@@ -1,194 +0,0 @@
import * as zhc from 'zigbee-herdsman-converters';
import logger from '../../util/logger';
import * as settings from '../../util/settings';
import Extension from '../extension';
const defaultConfiguration = {
minimumReportInterval: 3, maximumReportInterval: 300, reportableChange: 1,
};
const ZNLDP12LM = zhc.definitions.find((d) => d.model === 'ZNLDP12LM');
const devicesNotSupportingReporting = [
zhc.definitions.find((d) => d.model === 'CC2530.ROUTER'),
zhc.definitions.find((d) => d.model === 'BASICZBR3'),
zhc.definitions.find((d) => d.model === 'ZM-CSW032-D'),
zhc.definitions.find((d) => d.model === 'TS0001'),
zhc.definitions.find((d) => d.model === 'TS0115'),
];
const reportKey = 1;
const getColorCapabilities = async (endpoint: zh.Endpoint): Promise<{colorTemperature: boolean, colorXY: boolean}> => {
if (endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') === undefined) {
await endpoint.read('lightingColorCtrl', ['colorCapabilities']);
}
const value = endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') as number;
return {
colorTemperature: (value & 1<<4) > 0,
colorXY: (value & 1<<3) > 0,
};
};
const clusters: {[s: string]:
{attribute: string, minimumReportInterval: number, maximumReportInterval: number, reportableChange: number
condition?: (endpoint: zh.Endpoint) => Promise<boolean>}[]} =
{
'genOnOff': [
{attribute: 'onOff', ...defaultConfiguration, minimumReportInterval: 0, reportableChange: 0},
],
'genLevelCtrl': [
{attribute: 'currentLevel', ...defaultConfiguration},
],
'lightingColorCtrl': [
{
attribute: 'colorTemperature', ...defaultConfiguration,
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorTemperature,
},
{
attribute: 'currentX', ...defaultConfiguration,
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
},
{
attribute: 'currentY', ...defaultConfiguration,
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
},
],
'closuresWindowCovering': [
{attribute: 'currentPositionLiftPercentage', ...defaultConfiguration},
{attribute: 'currentPositionTiltPercentage', ...defaultConfiguration},
],
};
export default class Report extends Extension {
private queue: Set<string> = new Set();
private failed: Set<string> = new Set();
private enabled = settings.get().advanced.report;
shouldIgnoreClusterForDevice(cluster: string, definition: zhc.Definition): boolean {
if (definition === ZNLDP12LM && cluster === 'closuresWindowCovering') {
// Device announces it but doesn't support it
// https://github.com/Koenkk/zigbee2mqtt/issues/2611
return true;
}
return false;
}
async setupReporting(device: Device): Promise<void> {
if (this.queue.has(device.ieeeAddr) || this.failed.has(device.ieeeAddr)) return;
this.queue.add(device.ieeeAddr);
const term1 = this.enabled ? 'Setup' : 'Disable';
const term2 = this.enabled ? 'setup' : 'disabled';
try {
for (const ep of device.zh.endpoints) {
for (const [cluster, configuration] of Object.entries(clusters)) {
if (ep.supportsInputCluster(cluster) &&
!this.shouldIgnoreClusterForDevice(cluster, device.definition)) {
logger.debug(`${term1} reporting for '${device.ieeeAddr}' - ${ep.ID} - ${cluster}`);
const items = [];
for (const entry of configuration) {
if (!entry.hasOwnProperty('condition') || (await entry.condition(ep))) {
const toAdd = {...entry};
if (!this.enabled) toAdd.maximumReportInterval = 0xFFFF;
items.push(toAdd);
delete items[items.length - 1].condition;
}
}
this.enabled ?
await ep.bind(cluster, this.zigbee.firstCoordinatorEndpoint()) :
await ep.unbind(cluster, this.zigbee.firstCoordinatorEndpoint());
await ep.configureReporting(cluster, items);
logger.info(
`Successfully ${term2} reporting for '${device.ieeeAddr}' - ${ep.ID} - ${cluster}`,
);
}
}
}
if (this.enabled) {
device.zh.meta.reporting = reportKey;
} else {
delete device.zh.meta.reporting;
this.eventBus.emitReconfigure({device});
}
this.eventBus.emitDevicesChanged();
} catch (error) {
logger.error(
`Failed to ${term1.toLowerCase()} reporting for '${device.ieeeAddr}' - ${error.stack}`,
);
this.failed.add(device.ieeeAddr);
}
device.zh.save();
this.queue.delete(device.ieeeAddr);
}
shouldSetupReporting(device: Device, messageType: string): boolean {
if (!device || !device.zh || !device.definition) return false;
// Handle messages of type endDeviceAnnce and devIncoming.
// This message is typically send when a device comes online after being powered off
// Ikea TRADFRI tend to forget their reporting after powered off.
// Re-setup reporting.
// Only resetup reporting if configuredReportings was not populated yet,
// else reconfigure is done in zigbee-herdsman-converters ikea.js/bulbOnEvent
// configuredReportings are saved since Zigbee2MQTT 1.17.0
// https://github.com/Koenkk/zigbee2mqtt/issues/966
if (this.enabled && messageType === 'deviceAnnounce' && device.isIkeaTradfri() &&
device.zh.endpoints.filter((e) => e.configuredReportings.length === 0).length ===
device.zh.endpoints.length) {
return true;
}
// These do not support reproting.
// https://github.com/Koenkk/zigbee-herdsman/issues/110
const philipsIgnoreSw = ['5.127.1.26581', '5.130.1.30000'];
if (device.zh.manufacturerName === 'Philips' &&
philipsIgnoreSw.includes(device.zh.softwareBuildID)) return false;
if (device.zh.interviewing === true) return false;
if (device.zh.type !== 'Router' || device.zh.powerSource === 'Battery') return false;
// Gledopto devices don't support reporting.
if (devicesNotSupportingReporting.includes(device.definition) ||
device.definition.vendor === 'Gledopto') return false;
if (this.enabled && device.zh.meta.hasOwnProperty('reporting') &&
device.zh.meta.reporting === reportKey) {
return false;
}
if (!this.enabled && !device.zh.meta.hasOwnProperty('reporting')) {
return false;
}
return true;
}
override async start(): Promise<void> {
for (const device of this.zigbee.devices(false)) {
if (this.shouldSetupReporting(device, null)) {
await this.setupReporting(device);
}
}
this.eventBus.onDeviceAnnounce(this, (data) => this.onZigbeeEvent_('deviceAnnounce', data.device));
this.eventBus.onDeviceMessage(this, (data) => this.onZigbeeEvent_('dummy', data.device));
this.eventBus.onDeviceJoined(this, (data) => this.onZigbeeEvent_('dummy', data.device));
this.eventBus.onDeviceNetworkAddressChanged(this, (data) => this.onZigbeeEvent_('dummy', data.device));
}
async onZigbeeEvent_(type: string, device: Device): Promise<void> {
if (this.shouldSetupReporting(device, type)) {
await this.setupReporting(device);
}
}
}
-62
View File
@@ -1,62 +0,0 @@
/* istanbul ignore file */
// DEPRECATED
import * as settings from '../../util/settings';
import logger from '../../util/logger';
import utils from '../../util/utils';
import Extension from '../extension';
/**
* This extensions soft resets the ZNP after a certain timeout.
*/
export default class SoftReset extends Extension {
private timer: NodeJS.Timeout = null;
private timeout = utils.seconds(settings.get().advanced.soft_reset_timeout);
override async start(): Promise<void> {
logger.debug(`Soft reset timeout set to ${this.timeout / 1000} seconds`);
this.resetTimer();
this.eventBus.onDeviceMessage(this, () => this.resetTimer());
this.eventBus.onDeviceAnnounce(this, () => this.resetTimer());
this.eventBus.onDeviceNetworkAddressChanged(this, () => this.resetTimer());
this.eventBus.onDeviceJoined(this, () => this.resetTimer());
this.eventBus.onDeviceInterview(this, () => this.resetTimer());
}
private clearTimer(): void {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
private resetTimer(): void {
if (this.timeout === 0) {
return;
}
this.clearTimer();
this.timer = setTimeout(() => this.handleTimeout(), this.timeout);
}
private async handleTimeout(): Promise<void> {
logger.warn('Soft reset timeout triggered');
try {
await this.zigbee.reset('soft');
logger.warn('Soft reset ZNP due to timeout');
} catch (error) {
logger.warn('Soft reset failed, trying stop/start');
await this.zigbee.stop();
logger.warn('Zigbee stopped');
try {
await this.zigbee.start();
} catch (error) {
logger.error('Failed to restart!');
}
}
this.resetTimer();
}
}
+155 -146
View File
@@ -1,85 +1,67 @@
import type {Zigbee2MQTTAPI, Zigbee2MQTTNetworkMap} from 'lib/types/api';
import bind from 'bind-decorator';
import stringify from 'json-stable-stringify-without-jsonify';
import logger from '../util/logger';
import * as settings from '../util/settings';
import utils from '../util/utils';
import logger from '../util/logger';
import stringify from 'json-stable-stringify-without-jsonify';
import Extension from './extension';
import bind from 'bind-decorator';
interface Link {
source: {ieeeAddr: string, networkAddress: number}, target: {ieeeAddr: string, networkAddress: number},
linkquality: number, depth: number, routes: zh.RoutingTableEntry[],
sourceIeeeAddr: string, targetIeeeAddr: string, sourceNwkAddr: number, lqi: number, relationship: number,
}
interface Topology {
nodes: {
ieeeAddr: string, friendlyName: string, type: string, networkAddress: number, manufacturerName: string,
modelID: string, failed: string[], lastSeen: number,
definition: {model: string, vendor: string, supports: string, description: string}}[],
links: Link[],
}
const SUPPORTED_FORMATS = ['raw', 'graphviz', 'plantuml'];
/**
* This extension creates a network map
*/
export default class NetworkMap extends Extension {
private legacyApi = settings.get().advanced.legacy_api;
private legacyTopic = `${settings.get().mqtt.base_topic}/bridge/networkmap`;
private legacyTopicRoutes = `${settings.get().mqtt.base_topic}/bridge/networkmap/routes`;
private topic = `${settings.get().mqtt.base_topic}/bridge/request/networkmap`;
private supportedFormats: {[s: string]: (topology: Topology) => KeyValue | string};
override async start(): Promise<void> {
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
this.supportedFormats = {
'raw': this.raw,
'graphviz': this.graphviz,
'plantuml': this.plantuml,
};
}
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
/* istanbul ignore else */
if (this.legacyApi) {
if ((data.topic === this.legacyTopic || data.topic === this.legacyTopicRoutes) &&
this.supportedFormats.hasOwnProperty(data.message)) {
const includeRoutes = data.topic === this.legacyTopicRoutes;
const topology = await this.networkScan(includeRoutes);
let converted = this.supportedFormats[data.message](topology);
converted = data.message === 'raw' ? stringify(converted) : converted;
this.mqtt.publish(`bridge/networkmap/${data.message}`, converted as string, {});
}
}
if (data.topic === this.topic) {
const message = utils.parseJSON(data.message, data.message);
const message = utils.parseJSON(data.message, data.message) as Zigbee2MQTTAPI['bridge/request/networkmap'];
try {
const type = typeof message === 'object' ? message.type : message;
if (!this.supportedFormats.hasOwnProperty(type)) {
throw new Error(`Type '${type}' not supported, allowed are: ${Object.keys(this.supportedFormats)}`);
if (!SUPPORTED_FORMATS.includes(type)) {
throw new Error(`Type '${type}' not supported, allowed are: ${SUPPORTED_FORMATS.join(',')}`);
}
const routes = typeof message === 'object' && message.routes;
const topology = await this.networkScan(routes);
const value = this.supportedFormats[type](topology);
await this.mqtt.publish(
'bridge/response/networkmap',
stringify(utils.getResponse(message, {routes, type, value}, null)),
);
let responseData: Zigbee2MQTTAPI['bridge/response/networkmap'];
switch (type) {
case 'raw': {
responseData = {type, routes, value: this.raw(topology)};
break;
}
case 'graphviz': {
responseData = {type, routes, value: this.graphviz(topology)};
break;
}
case 'plantuml': {
responseData = {type, routes, value: this.plantuml(topology)};
break;
}
}
await this.mqtt.publish('bridge/response/networkmap', stringify(utils.getResponse(message, responseData)));
} catch (error) {
await this.mqtt.publish(
'bridge/response/networkmap',
stringify(utils.getResponse(message, {}, error.message)),
);
await this.mqtt.publish('bridge/response/networkmap', stringify(utils.getResponse(message, {}, (error as Error).message)));
}
}
}
@bind raw(topology: Topology): KeyValue {
raw(topology: Zigbee2MQTTNetworkMap): Zigbee2MQTTNetworkMap {
return topology;
}
@bind graphviz(topology: Topology): string {
graphviz(topology: Zigbee2MQTTNetworkMap): string {
const colors = settings.get().map_options.graphviz.colors;
let text = 'digraph G {\nnode[shape=record];\n';
@@ -94,7 +76,7 @@ export default class NetworkMap extends Extension {
// Add the device short network address, ieeaddr and scan note (if any)
labels.push(
`${node.ieeeAddr} (${utils.toNetworkAddressHex(node.networkAddress)})` +
((node.failed && node.failed.length) ? `failed: ${node.failed.join(',')}` : ''),
(node.failed && node.failed.length ? `failed: ${node.failed.join(',')}` : ''),
);
// Add the device model
@@ -113,35 +95,31 @@ export default class NetworkMap extends Extension {
// Shape the record according to device type
if (node.type == 'Coordinator') {
style = `style="bold, filled", fillcolor="${colors.fill.coordinator}", ` +
`fontcolor="${colors.font.coordinator}"`;
style = `style="bold, filled", fillcolor="${colors.fill.coordinator}", fontcolor="${colors.font.coordinator}"`;
} else if (node.type == 'Router') {
style = `style="rounded, filled", fillcolor="${colors.fill.router}", ` +
`fontcolor="${colors.font.router}"`;
style = `style="rounded, filled", fillcolor="${colors.fill.router}", fontcolor="${colors.font.router}"`;
} else {
style = `style="rounded, dashed, filled", fillcolor="${colors.fill.enddevice}", ` +
`fontcolor="${colors.font.enddevice}"`;
style = `style="rounded, dashed, filled", fillcolor="${colors.fill.enddevice}", fontcolor="${colors.font.enddevice}"`;
}
// Add the device with its labels to the graph as a node.
text += ` "${node.ieeeAddr}" [`+style+`, label="{${labels.join('|')}}"];\n`;
text += ` "${node.ieeeAddr}" [${style}, label="{${labels.join('|')}}"];\n`;
/**
* Add an edge between the device and its child to the graph
* NOTE: There are situations where a device is NOT in the topology, this can be e.g.
* due to not responded to the lqi scan. In that case we do not add an edge for this device.
*/
topology.links.filter((e) => (e.source.ieeeAddr === node.ieeeAddr)).forEach((e) => {
const lineStyle = (node.type=='EndDevice') ? 'penwidth=1, ' :
(!e.routes.length) ? 'penwidth=0.5, ' : 'penwidth=2, ';
const lineWeight = (!e.routes.length) ? `weight=0, color="${colors.line.inactive}", ` :
`weight=1, color="${colors.line.active}", `;
const textRoutes = e.routes.map((r) => utils.toNetworkAddressHex(r.destinationAddress));
const lineLabels = (!e.routes.length) ? `label="${e.linkquality}"` :
`label="${e.linkquality} (routes: ${textRoutes.join(',')})"`;
text += ` "${node.ieeeAddr}" -> "${e.target.ieeeAddr}"`;
text += ` [${lineStyle}${lineWeight}${lineLabels}]\n`;
});
topology.links
.filter((e) => e.source.ieeeAddr === node.ieeeAddr)
.forEach((e) => {
const lineStyle = node.type == 'EndDevice' ? 'penwidth=1, ' : !e.routes.length ? 'penwidth=0.5, ' : 'penwidth=2, ';
const lineWeight = !e.routes.length ? `weight=0, color="${colors.line.inactive}", ` : `weight=1, color="${colors.line.active}", `;
const textRoutes = e.routes.map((r) => utils.toNetworkAddressHex(r.destinationAddress));
const lineLabels = !e.routes.length ? `label="${e.linkquality}"` : `label="${e.linkquality} (routes: ${textRoutes.join(',')})"`;
text += ` "${node.ieeeAddr}" -> "${e.target.ieeeAddr}"`;
text += ` [${lineStyle}${lineWeight}${lineLabels}]\n`;
});
});
text += '}';
@@ -149,43 +127,45 @@ export default class NetworkMap extends Extension {
return text.replace(/\0/g, '');
}
@bind plantuml(topology: Topology): string {
plantuml(topology: Zigbee2MQTTNetworkMap): string {
const text = [];
text.push(`' paste into: https://www.planttext.com/`);
text.push(``);
text.push('@startuml');
topology.nodes.sort((a, b) => a.friendlyName.localeCompare(b.friendlyName)).forEach((node) => {
// Add friendly name
text.push(`card ${node.ieeeAddr} [`);
text.push(`${node.friendlyName}`);
text.push(`---`);
// Add the device short network address, ieeaddr and scan note (if any)
text.push(
`${node.ieeeAddr} (${utils.toNetworkAddressHex(node.networkAddress)})` +
((node.failed && node.failed.length) ? ` failed: ${node.failed.join(',')}` : ''),
);
// Add the device model
if (node.type !== 'Coordinator') {
topology.nodes
.sort((a, b) => a.friendlyName.localeCompare(b.friendlyName))
.forEach((node) => {
// Add friendly name
text.push(`card ${node.ieeeAddr} [`);
text.push(`${node.friendlyName}`);
text.push(`---`);
const definition = (this.zigbee.resolveEntity(node.ieeeAddr) as Device).definition;
text.push(`${definition?.vendor} ${definition?.description} (${definition?.model})`);
}
// Add the device last_seen timestamp
let lastSeen = 'unknown';
const date = node.type === 'Coordinator' ? Date.now() : node.lastSeen;
if (date) {
lastSeen = utils.formatDate(date, 'relative') as string;
}
text.push(`---`);
text.push(lastSeen);
text.push(`]`);
text.push(``);
});
// Add the device short network address, ieeaddr and scan note (if any)
text.push(
`${node.ieeeAddr} (${utils.toNetworkAddressHex(node.networkAddress)})` +
(node.failed && node.failed.length ? ` failed: ${node.failed.join(',')}` : ''),
);
// Add the device model
if (node.type !== 'Coordinator') {
text.push(`---`);
const definition = (this.zigbee.resolveEntity(node.ieeeAddr) as Device).definition;
text.push(`${definition?.vendor} ${definition?.description} (${definition?.model})`);
}
// Add the device last_seen timestamp
let lastSeen = 'unknown';
const date = node.type === 'Coordinator' ? Date.now() : node.lastSeen;
if (date) {
lastSeen = utils.formatDate(date, 'relative') as string;
}
text.push(`---`);
text.push(lastSeen);
text.push(`]`);
text.push(``);
});
/**
* Add edges between the devices
@@ -202,77 +182,95 @@ export default class NetworkMap extends Extension {
return text.join(`\n`);
}
async networkScan(includeRoutes: boolean): Promise<Topology> {
async networkScan(includeRoutes: boolean): Promise<Zigbee2MQTTNetworkMap> {
logger.info(`Starting network scan (includeRoutes '${includeRoutes}')`);
const devices = this.zigbee.devices().filter((d) => d.zh.type !== 'GreenPower' && !d.options.disabled);
const lqis: Map<Device, zh.LQI> = new Map();
const routingTables: Map<Device, zh.RoutingTable> = new Map();
const failed: Map<Device, string[]> = new Map();
const requestWithRetry = async <T>(request: () => Promise<T>): Promise<T> => {
try {
const result = await request();
return result;
} catch {
// Network is possibly congested, sleep 5 seconds to let the network settle.
await utils.sleep(5);
return await request();
}
};
for (const device of this.zigbee.devicesIterator((d) => d.type !== 'GreenPower' && d.type !== 'EndDevice')) {
if (device.options.disabled) {
continue;
}
for (const device of devices.filter((d) => d.zh.type != 'EndDevice')) {
failed.set(device, []);
await utils.sleep(1); // sleep 1 second between each scan to reduce stress on network.
const doRequest = async <T>(request: () => Promise<T>, firstAttempt = true): Promise<T> => {
try {
return await request();
} catch (error) {
if (!firstAttempt) {
throw error;
} else {
// Network is possibly congested, sleep 5 seconds to let the network settle.
await utils.sleep(5);
return doRequest(request, false);
}
}
};
try {
const result = await doRequest<zh.LQI>(async () => device.zh.lqi());
const result = await requestWithRetry<zh.LQI>(async () => await device.zh.lqi());
lqis.set(device, result);
logger.debug(`LQI succeeded for '${device.name}'`);
} catch (error) {
failed.get(device).push('lqi');
failed.get(device)!.push('lqi'); // set above
logger.error(`Failed to execute LQI for '${device.name}'`);
logger.debug(error.stack);
logger.debug((error as Error).stack!);
}
if (includeRoutes) {
try {
const result = await doRequest(async () => device.zh.routingTable());
const result = await requestWithRetry<zh.RoutingTable>(async () => await device.zh.routingTable());
routingTables.set(device, result);
logger.debug(`Routing table succeeded for '${device.name}'`);
} catch (error) {
failed.get(device).push('routingTable');
failed.get(device)!.push('routingTable'); // set above
logger.error(`Failed to execute routing table for '${device.name}'`);
logger.debug((error as Error).stack!);
}
}
}
logger.info(`Network scan finished`);
const topology: Topology = {nodes: [], links: []};
// Add nodes
for (const device of devices) {
const definition = device.definition ? {
model: device.definition.model,
vendor: device.definition.vendor,
description: device.definition.description,
supports: Array.from(new Set((device.exposes()).map((e) => {
return e.name ?? `${e.type} (${e.features.map((f) => f.name).join(', ')})`;
}))).join(', '),
} : null;
const topology: Zigbee2MQTTNetworkMap = {nodes: [], links: []};
// XXX: display GP/disabled devices in the map, better feedback than just hiding them?
for (const device of this.zigbee.devicesIterator((d) => d.type !== 'GreenPower')) {
if (device.options.disabled) {
continue;
}
// Add nodes
const definition = device.definition
? {
model: device.definition.model,
vendor: device.definition.vendor,
description: device.definition.description,
supports: Array.from(
new Set(
device.exposes().map((e) => {
return e.name ?? `${e.type} (${e.features?.map((f) => f.name).join(', ')})`;
}),
),
).join(', '),
}
: undefined;
topology.nodes.push({
ieeeAddr: device.ieeeAddr, friendlyName: device.name, type: device.zh.type,
networkAddress: device.zh.networkAddress, manufacturerName: device.zh.manufacturerName,
modelID: device.zh.modelID, failed: failed.get(device), lastSeen: device.zh.lastSeen,
ieeeAddr: device.ieeeAddr,
friendlyName: device.name,
type: device.zh.type,
networkAddress: device.zh.networkAddress,
manufacturerName: device.zh.manufacturerName,
modelID: device.zh.modelID,
failed: failed.get(device)!,
lastSeen: device.zh.lastSeen,
definition,
});
}
// Add links
lqis.forEach((lqi, device) => {
for (const [device, lqi] of lqis) {
for (const neighbor of lqi.neighbors) {
if (neighbor.relationship > 3) {
// Relationship is not active, skip it
@@ -281,30 +279,41 @@ export default class NetworkMap extends Extension {
// Some Xiaomi devices return 0x00 as the neighbor ieeeAddr (obviously not correct).
// Determine the correct ieeeAddr based on the networkAddress.
const neighborDevice = this.zigbee.deviceByNetworkAddress(neighbor.networkAddress);
if (neighbor.ieeeAddr === '0x0000000000000000' && neighborDevice) {
neighbor.ieeeAddr = neighborDevice.ieeeAddr;
if (neighbor.ieeeAddr === '0x0000000000000000') {
const neighborDevice = this.zigbee.deviceByNetworkAddress(neighbor.networkAddress);
if (neighborDevice) {
neighbor.ieeeAddr = neighborDevice.ieeeAddr;
}
}
const link: Link = {
const link: Zigbee2MQTTNetworkMap['links'][number] = {
source: {ieeeAddr: neighbor.ieeeAddr, networkAddress: neighbor.networkAddress},
target: {ieeeAddr: device.ieeeAddr, networkAddress: device.zh.networkAddress},
linkquality: neighbor.linkquality, depth: neighbor.depth, routes: [],
linkquality: neighbor.linkquality,
depth: neighbor.depth,
routes: [],
// DEPRECATED:
sourceIeeeAddr: neighbor.ieeeAddr, targetIeeeAddr: device.ieeeAddr,
sourceNwkAddr: neighbor.networkAddress, lqi: neighbor.linkquality,
sourceIeeeAddr: neighbor.ieeeAddr,
targetIeeeAddr: device.ieeeAddr,
sourceNwkAddr: neighbor.networkAddress,
lqi: neighbor.linkquality,
relationship: neighbor.relationship,
};
const routingTable = routingTables.get(device);
if (routingTable) {
link.routes = routingTable.table
.filter((t) => t.status === 'ACTIVE' && t.nextHop === neighbor.networkAddress);
for (const entry of routingTable.table) {
if (entry.status === 'ACTIVE' && entry.nextHop === neighbor.networkAddress) {
link.routes.push(entry);
}
}
}
topology.links.push(link);
}
});
}
return topology;
}
+23 -21
View File
@@ -1,4 +1,6 @@
import * as zhc from 'zigbee-herdsman-converters';
import utils from '../util/utils';
import Extension from './extension';
/**
@@ -6,26 +8,22 @@ import Extension from './extension';
*/
export default class OnEvent extends Extension {
override async start(): Promise<void> {
for (const device of this.zigbee.devices(false)) {
this.callOnEvent(device, 'start', {});
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
this.callOnEvent(device, 'start', {}).catch(utils.noop);
}
this.eventBus.onDeviceMessage(this, (data) => this.callOnEvent(data.device, 'message', this.convertData(data)));
this.eventBus.onDeviceJoined(this,
(data) => this.callOnEvent(data.device, 'deviceJoined', this.convertData(data)));
this.eventBus.onDeviceInterview(this,
(data) => this.callOnEvent(data.device, 'deviceInterview', this.convertData(data)));
this.eventBus.onDeviceAnnounce(this,
(data) => this.callOnEvent(data.device, 'deviceAnnounce', this.convertData(data)));
this.eventBus.onDeviceNetworkAddressChanged(this,
(data) => this.callOnEvent(data.device, 'deviceNetworkAddressChanged', this.convertData(data)));
this.eventBus.onEntityOptionsChanged(this,
(data) => {
if (data.entity.isDevice()) {
this.callOnEvent(data.entity, 'deviceOptionsChanged', data)
.then(() => this.eventBus.emitDevicesChanged());
}
});
this.eventBus.onDeviceJoined(this, (data) => this.callOnEvent(data.device, 'deviceJoined', this.convertData(data)));
this.eventBus.onDeviceInterview(this, (data) => this.callOnEvent(data.device, 'deviceInterview', this.convertData(data)));
this.eventBus.onDeviceAnnounce(this, (data) => this.callOnEvent(data.device, 'deviceAnnounce', this.convertData(data)));
this.eventBus.onDeviceNetworkAddressChanged(this, (data) =>
this.callOnEvent(data.device, 'deviceNetworkAddressChanged', this.convertData(data)),
);
this.eventBus.onEntityOptionsChanged(this, async (data) => {
if (data.entity.isDevice()) {
await this.callOnEvent(data.entity, 'deviceOptionsChanged', data).then(() => this.eventBus.emitDevicesChanged());
}
});
}
private convertData(data: KeyValue): KeyValue {
@@ -33,19 +31,23 @@ export default class OnEvent extends Extension {
}
override async stop(): Promise<void> {
super.stop();
for (const device of this.zigbee.devices(false)) {
await super.stop();
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
await this.callOnEvent(device, 'stop', {});
}
}
private async callOnEvent(device: Device, type: zhc.OnEventType, data: KeyValue): Promise<void> {
if (device.options.disabled) return;
const state = this.state.get(device);
zhc.onEvent(type, data, device.zh);
const deviceExposesChanged = (): void => this.eventBus.emitExposesAndDevicesChanged(data.device);
await zhc.onEvent(type, data, device.zh, {deviceExposesChanged});
if (device.definition?.onEvent) {
const options: KeyValue = device.options;
await device.definition.onEvent(type, data, device.zh, options, state);
await device.definition.onEvent(type, data, device.zh, options, state, {deviceExposesChanged});
}
}
}
+159 -194
View File
@@ -1,188 +1,191 @@
import * as settings from '../util/settings';
import logger from '../util/logger';
import stringify from 'json-stable-stringify-without-jsonify';
import utils from '../util/utils';
import Extension from './extension';
import type {Zigbee2MQTTAPI} from 'lib/types/api';
import type {Ota} from 'zigbee-herdsman-converters';
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 {ota} from 'zigbee-herdsman-converters';
import Device from '../model/device';
import dataDir from '../util/data';
import * as URI from 'uri-js';
import path from 'path';
import * as zhc from 'zigbee-herdsman-converters';
function isValidUrl(url: string): boolean {
let parsed;
try {
parsed = URI.parse(url);
} catch (_) {
// istanbul ignore next
return false;
}
return parsed.scheme === 'http' || parsed.scheme === 'https';
}
import logger from '../util/logger';
import * as settings from '../util/settings';
import utils from '../util/utils';
import Extension from './extension';
type UpdateState = 'updating' | 'idle' | 'available';
interface UpdatePayload {
update_available?: boolean
// eslint-disable-next-line camelcase
update: {
progress?: number, remaining?: number, state: UpdateState,
installed_version: number | null, latest_version: number | null
}
progress?: number;
remaining?: number;
state: UpdateState;
installed_version: number | null;
latest_version: number | null;
};
}
const legacyTopicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/ota_update/.+$`);
const topicRegex =
new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/(update|check)`, 'i');
const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/(update|check)/?(downgrade)?`, 'i');
export default class OTAUpdate extends Extension {
private inProgress = new Set();
private lastChecked: {[s: string]: number} = {};
private legacyApi = settings.get().advanced.legacy_api;
override async start(): Promise<void> {
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
this.eventBus.onDeviceMessage(this, this.onZigbeeEvent);
if (settings.get().ota.ikea_ota_use_test_url) {
zhc.ota.tradfri.useTestURL();
}
// Let zigbeeOTA module know if the override index file is provided
let overrideOTAIndex = settings.get().ota.zigbee_ota_override_index_location;
if (overrideOTAIndex) {
// If the file name is not a full path, then treat it as a relative to the data directory
if (!isValidUrl(overrideOTAIndex) && !path.isAbsolute(overrideOTAIndex)) {
overrideOTAIndex = dataDir.joinPath(overrideOTAIndex);
}
const otaSettings = settings.get().ota;
// Let OTA module know if the override index file is provided
let overrideIndexLocation = otaSettings.zigbee_ota_override_index_location;
zhc.ota.zigbeeOTA.useIndexOverride(overrideOTAIndex);
// If the file name is not a full path, then treat it as a relative to the data directory
if (overrideIndexLocation && !ota.isValidUrl(overrideIndexLocation) && !path.isAbsolute(overrideIndexLocation)) {
overrideIndexLocation = dataDir.joinPath(overrideIndexLocation);
}
// In order to support local firmware files we need to let zigbeeOTA know where the data directory is
zhc.ota.setDataDir(dataDir.getPath());
ota.setConfiguration({
dataDir: dataDir.getPath(),
overrideIndexLocation,
// TODO: implement me
imageBlockResponseDelay: otaSettings.image_block_response_delay,
defaultMaximumDataSize: otaSettings.default_maximum_data_size,
});
// In case Zigbee2MQTT is restared during an update, progress and remaining values are still in state.
// remove them.
for (const device of this.zigbee.devices(false)) {
// In case Zigbee2MQTT is restared during an update, progress and remaining values are still in state, remove them.
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
this.removeProgressAndRemainingFromState(device);
// Reset update state, e.g. when Z2M restarted during update.
if (this.state.get(device).update?.state === 'updating') {
this.state.get(device).update.state = 'available';
}
}
}
private removeProgressAndRemainingFromState(device: Device): void {
delete this.state.get(device).update?.progress;
delete this.state.get(device).update?.remaining;
const deviceState = this.state.get(device);
if (deviceState.update) {
delete deviceState.update.progress;
delete deviceState.update.remaining;
}
}
@bind private async onZigbeeEvent(data: eventdata.DeviceMessage): Promise<void> {
if (data.type !== 'commandQueryNextImageRequest' || !data.device.definition ||
this.inProgress.has(data.device.ieeeAddr)) return;
if (data.type !== 'commandQueryNextImageRequest' || !data.device.definition || this.inProgress.has(data.device.ieeeAddr)) return;
logger.debug(`Device '${data.device.name}' requested OTA`);
const automaticOTACheckDisabled = settings.get().ota.disable_automatic_update_check;
let supportsOTA = !!data.device.definition.ota;
if (supportsOTA && !automaticOTACheckDisabled) {
if (data.device.definition.ota && !automaticOTACheckDisabled) {
// When a device does a next image request, it will usually do it a few times after each other
// with only 10 - 60 seconds inbetween. It doesn't make sense to check for a new update
// each time, so this interval can be set by the user. The default is 1,440 minutes (one day).
const updateCheckInterval = settings.get().ota.update_check_interval * 1000 * 60;
const check = this.lastChecked.hasOwnProperty(data.device.ieeeAddr) ?
(Date.now() - this.lastChecked[data.device.ieeeAddr]) > updateCheckInterval : true;
const check =
this.lastChecked[data.device.ieeeAddr] !== undefined
? Date.now() - this.lastChecked[data.device.ieeeAddr] > updateCheckInterval
: true;
if (!check) return;
this.lastChecked[data.device.ieeeAddr] = Date.now();
let availableResult: zhc.OtaUpdateAvailableResult = null;
let availableResult: Ota.UpdateAvailableResult | undefined;
try {
// @ts-expect-error typing guaranteed by data.type
const dataData: zhc.ota.ImageInfo = data.data;
availableResult = await data.device.definition.ota.isUpdateAvailable(data.device.zh, logger, dataData);
} catch (e) {
supportsOTA = false;
logger.debug(`Failed to check if update available for '${data.device.name}' (${e.message})`);
// never use 'previous' when responding to device request
availableResult = await ota.isUpdateAvailable(data.device.zh, data.device.otaExtraMetas, data.data as Ota.ImageInfo, false);
} catch (error) {
logger.debug(`Failed to check if update available for '${data.device.name}' (${error})`);
}
const payload = this.getEntityPublishPayload(data.device, availableResult ?? 'idle');
this.publishEntityState(data.device, payload);
await this.publishEntityState(data.device, this.getEntityPublishPayload(data.device, availableResult ?? 'idle'));
if (availableResult?.available) {
const message = `Update available for '${data.device.name}'`;
logger.info(message);
/* istanbul ignore else */
if (settings.get().advanced.legacy_api) {
const meta = {status: 'available', device: data.device.name};
this.mqtt.publish(
'bridge/log',
stringify({type: `ota_update`, message, meta}),
);
}
}
}
// Respond to the OTA request: respond with NO_IMAGE_AVAILABLE (0x98) (so the client stops requesting OTAs)
// Respond to stop the client from requesting OTAs
const endpoint = data.device.zh.endpoints.find((e) => e.supportsOutputCluster('genOta')) || data.endpoint;
await endpoint.commandResponse('genOta', 'queryNextImageResponse',
{status: 0x98}, undefined, data.meta.zclTransactionSequenceNumber);
await endpoint.commandResponse(
'genOta',
'queryNextImageResponse',
{status: Zcl.Status.NO_IMAGE_AVAILABLE},
undefined,
data.meta.zclTransactionSequenceNumber,
);
logger.debug(`Responded to OTA request of '${data.device.name}' with 'NO_IMAGE_AVAILABLE'`);
}
private async readSoftwareBuildIDAndDateCode(device: Device, sendPolicy?: 'immediate'):
Promise<{softwareBuildID: string, dateCode: string}> {
private async readSoftwareBuildIDAndDateCode(
device: Device,
sendPolicy?: 'immediate',
): Promise<{softwareBuildID: string; dateCode: string} | undefined> {
try {
const endpoint = device.zh.endpoints.find((e) => e.supportsInputCluster('genBasic'));
assert(endpoint);
const result = await endpoint.read('genBasic', ['dateCode', 'swBuildId'], {sendPolicy});
return {softwareBuildID: result.swBuildId, dateCode: result.dateCode};
} catch (e) {
return null;
} catch {
return undefined;
}
}
private getEntityPublishPayload(device: Device, state: zhc.OtaUpdateAvailableResult | UpdateState,
progress: number=null, remaining: number=null): UpdatePayload {
private getEntityPublishPayload(
device: Device,
state: Ota.UpdateAvailableResult | UpdateState,
progress?: number,
remaining?: number,
): UpdatePayload {
const deviceUpdateState = this.state.get(device).update;
const payload: UpdatePayload = {update: {
state: typeof state === 'string' ? state : (state.available ? 'available' : 'idle'),
installed_version: typeof state === 'string' ?
deviceUpdateState?.installed_version : state.currentFileVersion,
latest_version: typeof state === 'string' ?
deviceUpdateState?.latest_version : state.otaFileVersion,
}};
if (progress !== null) payload.update.progress = progress;
if (remaining !== null) payload.update.remaining = Math.round(remaining);
const payload: UpdatePayload = {
update: {
state: typeof state === 'string' ? state : state.available ? 'available' : 'idle',
installed_version: typeof state === 'string' ? deviceUpdateState?.installed_version : state.currentFileVersion,
latest_version: typeof state === 'string' ? deviceUpdateState?.latest_version : state.otaFileVersion,
},
};
/* istanbul ignore else */
if (this.legacyApi) {
payload.update_available = typeof state === 'string' ? state === 'available' : state.available;
if (progress !== undefined) {
payload.update.progress = progress;
}
if (remaining !== undefined) {
payload.update.remaining = Math.round(remaining);
}
return payload;
}
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
if ((!this.legacyApi || !data.topic.match(legacyTopicRegex)) && !data.topic.match(topicRegex)) {
return null;
const topicMatch = data.topic.match(topicRegex);
if (!topicMatch) {
return;
}
const message = utils.parseJSON(data.message, data.message);
const ID = (typeof message === 'object' && message.hasOwnProperty('id') ? message.id : message) as string;
const message = utils.parseJSON(data.message, data.message) as
| Zigbee2MQTTAPI['bridge/request/device/ota_update/check']
| Zigbee2MQTTAPI['bridge/request/device/ota_update/check/downgrade']
| Zigbee2MQTTAPI['bridge/request/device/ota_update/update']
| Zigbee2MQTTAPI['bridge/request/device/ota_update/update/downgrade'];
const ID = (typeof message === 'object' && message['id'] !== undefined ? message.id : message) as string;
const device = this.zigbee.resolveEntity(ID);
const type = data.topic.substring(data.topic.lastIndexOf('/') + 1);
const responseData: {id: string, updateAvailable?: boolean, from?: string, to?: string}= {id: ID};
let error = null;
let errorStack = null;
const type = topicMatch[1];
const downgrade = Boolean(topicMatch[2]);
let error: string | undefined;
let errorStack: string | undefined;
if (!(device instanceof Device)) {
error = `Device '${ID}' does not exist`;
} else if (!device.definition || !device.definition.ota) {
error = `Device '${device.name}' does not support OTA updates`;
/* istanbul ignore else */
if (settings.get().advanced.legacy_api) {
const meta = {status: `not_supported`, device: device.name};
this.mqtt.publish(
'bridge/log',
stringify({type: `ota_update`, message: error, meta}),
);
}
} else if (this.inProgress.has(device.ieeeAddr)) {
error = `Update or check for update already in progress for '${device.name}'`;
} else {
@@ -192,128 +195,90 @@ export default class OTAUpdate extends Extension {
const msg = `Checking if update available for '${device.name}'`;
logger.info(msg);
/* istanbul ignore else */
if (settings.get().advanced.legacy_api) {
const meta = {status: `checking_if_available`, device: device.name};
this.mqtt.publish(
'bridge/log',
stringify({type: `ota_update`, message: msg, meta}),
);
}
try {
const availableResult = await device.definition.ota.isUpdateAvailable(device.zh, logger, null);
const availableResult = await ota.isUpdateAvailable(device.zh, device.otaExtraMetas, undefined, downgrade);
const msg = `${availableResult.available ? 'Update' : 'No update'} available for '${device.name}'`;
logger.info(msg);
/* istanbul ignore else */
if (settings.get().advanced.legacy_api) {
const meta = {
status: availableResult.available ? 'available' : 'not_available', device: device.name};
this.mqtt.publish(
'bridge/log',
stringify({type: `ota_update`, message: msg, meta}),
);
}
await this.publishEntityState(device, this.getEntityPublishPayload(device, availableResult));
const payload = this.getEntityPublishPayload(device, availableResult);
this.publishEntityState(device, payload);
this.lastChecked[device.ieeeAddr] = Date.now();
responseData.updateAvailable = availableResult.available;
} catch (e) {
error = `Failed to check if update available for '${device.name}' (${e.message})`;
errorStack = e.stack;
const response = utils.getResponse<'bridge/response/device/ota_update/check'>(message, {
id: ID,
update_available: availableResult.available,
});
/* istanbul ignore else */
if (settings.get().advanced.legacy_api) {
const meta = {status: `check_failed`, device: device.name};
this.mqtt.publish(
'bridge/log',
stringify({type: `ota_update`, message: error, meta}),
);
}
await this.mqtt.publish(`bridge/response/device/ota_update/check`, stringify(response));
} catch (e) {
error = `Failed to check if update available for '${device.name}' (${(e as Error).message})`;
errorStack = (e as Error).stack;
}
} else { // type === 'update'
const msg = `Updating '${device.name}' to latest firmware`;
} else {
// type === 'update'
const msg = `Updating '${device.name}' to ${downgrade ? 'previous' : 'latest'} firmware`;
logger.info(msg);
/* istanbul ignore else */
if (settings.get().advanced.legacy_api) {
const meta = {status: `update_in_progress`, device: device.name};
this.mqtt.publish(
'bridge/log',
stringify({type: `ota_update`, message: msg, meta}),
);
}
try {
const onProgress = (progress: number, remaining: number): void => {
const firmwareFrom = await this.readSoftwareBuildIDAndDateCode(device, 'immediate');
const fileVersion = await ota.update(device.zh, device.otaExtraMetas, downgrade, async (progress, remaining) => {
let msg = `Update of '${device.name}' at ${progress.toFixed(2)}%`;
if (remaining) {
msg += `, ≈ ${Math.round(remaining / 60)} minutes remaining`;
}
logger.info(msg);
const payload = this.getEntityPublishPayload(device, 'updating', progress, remaining);
this.publishEntityState(device, payload);
await this.publishEntityState(device, this.getEntityPublishPayload(device, 'updating', progress, remaining ?? undefined));
});
/* istanbul ignore else */
if (settings.get().advanced.legacy_api) {
const meta = {status: `update_progress`, device: device.name, progress};
this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: msg, meta}));
}
};
const from_ = await this.readSoftwareBuildIDAndDateCode(device, 'immediate');
const fileVersion = await device.definition.ota.updateToLatest(device.zh, logger, onProgress);
logger.info(`Finished update of '${device.name}'`);
this.eventBus.emitReconfigure({device});
this.removeProgressAndRemainingFromState(device);
const payload = this.getEntityPublishPayload(device,
{available: false, currentFileVersion: fileVersion, otaFileVersion: fileVersion});
this.publishEntityState(device, payload);
const to = await this.readSoftwareBuildIDAndDateCode(device);
const [fromS, toS] = [stringify(from_), stringify(to)];
logger.info(`Device '${device.name}' was updated from '${fromS}' to '${toS}'`);
responseData.from = from_ ? utils.toSnakeCase(from_) : null;
responseData.to = to ? utils.toSnakeCase(to) : null;
await this.publishEntityState(
device,
this.getEntityPublishPayload(device, {available: false, currentFileVersion: fileVersion, otaFileVersion: fileVersion}),
);
const firmwareTo = await this.readSoftwareBuildIDAndDateCode(device);
logger.info(() => `Device '${device.name}' was updated from '${stringify(firmwareFrom)}' to '${stringify(firmwareTo)}'`);
/**
* Re-configure after reading software build ID and date code, some devices use a
* custom attribute for this (e.g. Develco SMSZB-120)
*/
this.eventBus.emitReconfigure({device});
this.eventBus.emitDevicesChanged();
/* istanbul ignore else */
if (settings.get().advanced.legacy_api) {
const meta = {status: `update_succeeded`, device: device.name, from: from_, to};
this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message, meta}));
}
const response = utils.getResponse<'bridge/response/device/ota_update/update'>(message, {
id: ID,
from: firmwareFrom ? {software_build_id: firmwareFrom.softwareBuildID, date_code: firmwareFrom.dateCode} : undefined,
to: firmwareTo ? {software_build_id: firmwareTo.softwareBuildID, date_code: firmwareTo.dateCode} : undefined,
});
await this.mqtt.publish(`bridge/response/device/ota_update/update`, stringify(response));
} catch (e) {
logger.debug(`Update of '${device.name}' failed (${e})`);
error = `Update of '${device.name}' failed (${e.message})`;
errorStack = e.stack;
error = `Update of '${device.name}' failed (${(e as Error).message})`;
errorStack = (e as Error).stack;
this.removeProgressAndRemainingFromState(device);
const payload = this.getEntityPublishPayload(device, 'available');
this.publishEntityState(device, payload);
/* istanbul ignore else */
if (settings.get().advanced.legacy_api) {
const meta = {status: `update_failed`, device: device.name};
this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: error, meta}));
}
await this.publishEntityState(device, this.getEntityPublishPayload(device, 'available'));
}
}
this.inProgress.delete(device.ieeeAddr);
}
const triggeredViaLegacyApi = data.topic.match(legacyTopicRegex);
if (!triggeredViaLegacyApi) {
const response = utils.getResponse(message, responseData, error);
await this.mqtt.publish(`bridge/response/device/ota_update/${type}`, stringify(response));
}
if (error) {
const response = utils.getResponse(message, {}, error);
await this.mqtt.publish(`bridge/response/device/ota_update/${type}`, stringify(response));
logger.error(error);
errorStack && logger.debug(errorStack);
if (errorStack) {
logger.debug(errorStack);
}
}
}
}
+132 -124
View File
@@ -1,113 +1,91 @@
import type * as zhc from 'zigbee-herdsman-converters';
import * as settings from '../util/settings';
import * as zhc from 'zigbee-herdsman-converters';
import * as philips from 'zigbee-herdsman-converters/lib/philips';
import bind from 'bind-decorator';
import stringify from 'json-stable-stringify-without-jsonify';
import Device from '../model/device';
import Group from '../model/group';
import logger from '../util/logger';
import * as settings from '../util/settings';
import utils from '../util/utils';
import Extension from './extension';
import stringify from 'json-stable-stringify-without-jsonify';
import Group from '../model/group';
import Device from '../model/device';
import bind from 'bind-decorator';
const topicRegex = new RegExp(`^(.+?)(?:/(${utils.endpointNames.join('|')}|\\d+))?/(get|set)(?:/(.+))?`);
const propertyEndpointRegex = new RegExp(`^(.*?)_(${utils.endpointNames.join('|')})$`);
const stateValues = ['on', 'off', 'toggle', 'open', 'close', 'stop', 'lock', 'unlock'];
const sceneConverterKeys = ['scene_store', 'scene_add', 'scene_remove', 'scene_remove_all', 'scene_rename'];
let topicGetSetRegex: RegExp;
// Used by `publish.test.js` to reload regex when changing `mqtt.base_topic`.
export const loadTopicGetSetRegex = (): void => {
topicGetSetRegex = new RegExp(`^${settings.get().mqtt.base_topic}/(?!bridge)(.+?)/(get|set)(?:/(.+))?$`);
};
loadTopicGetSetRegex();
// Legacy: don't provide default converters anymore, this is required by older z2m installs not saving group members
const defaultGroupConverters = [
zhc.toZigbee.light_onoff_brightness,
zhc.toZigbee.light_color_colortemp,
philips.tz.effect, // Support Hue effects for groups
zhc.toZigbee.ignore_transition,
zhc.toZigbee.cover_position_tilt,
zhc.toZigbee.thermostat_occupied_heating_setpoint,
zhc.toZigbee.tint_scene,
zhc.toZigbee.light_brightness_move,
zhc.toZigbee.light_brightness_step,
zhc.toZigbee.light_colortemp_step,
zhc.toZigbee.light_colortemp_move,
zhc.toZigbee.light_hue_saturation_move,
zhc.toZigbee.light_hue_saturation_step,
];
const STATE_VALUES: ReadonlyArray<string> = ['on', 'off', 'toggle', 'open', 'close', 'stop', 'lock', 'unlock'];
const SCENE_CONVERTER_KEYS: ReadonlyArray<string> = ['scene_store', 'scene_add', 'scene_remove', 'scene_remove_all', 'scene_rename'];
interface ParsedTopic {ID: string, endpoint: string, attribute: string, type: 'get' | 'set'}
interface ParsedTopic {
ID: string;
endpoint: string | undefined;
attribute: string;
type: 'get' | 'set';
}
export default class Publish extends Extension {
async start(): Promise<void> {
override async start(): Promise<void> {
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
}
parseTopic(topic: string): ParsedTopic | null {
const match = topic.match(topicRegex);
parseTopic(topic: string): ParsedTopic | undefined {
// The function supports the following topic formats (below are for 'set'. 'get' will look the same):
// - <base_topic>/device_name/set (endpoint and attribute is defined in the payload)
// - <base_topic>/device_name/set/attribute (default endpoint used)
// - <base_topic>/device_name/endpoint/set (attribute is defined in the payload)
// - <base_topic>/device_name/endpoint/set/attribute (payload is the value)
// Make the rough split on get/set keyword.
// Before the get/set is the device name and optional endpoint name.
// After it there will be an optional attribute name.
const match = topic.match(topicGetSetRegex);
if (!match) {
return null;
return undefined;
}
const ID = match[1].replace(`${settings.get().mqtt.base_topic}/`, '');
// If we didn't replace base_topic we received something we don't care about
if (ID === match[1] || ID.match(/bridge/)) {
return null;
}
const deviceNameAndEndpoint = match[1];
const attribute = match[3];
return {ID: ID, endpoint: match[2], type: match[3] as 'get' | 'set', attribute: match[4]};
// Now parse the device/group name, and endpoint name
const entity = this.zigbee.resolveEntityAndEndpoint(deviceNameAndEndpoint);
return {ID: entity.ID, endpoint: entity.endpointID, type: match[2] as 'get' | 'set', attribute: attribute};
}
parseMessage(parsedTopic: ParsedTopic, data: eventdata.MQTTMessage): KeyValue | null {
parseMessage(parsedTopic: ParsedTopic, data: eventdata.MQTTMessage): KeyValue | undefined {
if (parsedTopic.attribute) {
try {
return {[parsedTopic.attribute]: JSON.parse(data.message)};
} catch (e) {
} catch {
return {[parsedTopic.attribute]: data.message};
}
} else {
try {
return JSON.parse(data.message);
} catch (e) {
if (stateValues.includes(data.message.toLowerCase())) {
} catch {
if (STATE_VALUES.includes(data.message.toLowerCase())) {
return {state: data.message};
} else {
return null;
return undefined;
}
}
}
}
legacyLog(payload: KeyValue): void {
/* istanbul ignore else */
if (settings.get().advanced.legacy_api) {
this.mqtt.publish('bridge/log', stringify(payload));
}
}
legacyRetrieveState(re: Device | Group, converter: zhc.Tz.Converter, result: zhc.Tz.ConvertSetResult,
target: zh.Endpoint | zh.Group, key: string, meta: zhc.Tz.Meta): void {
// It's possible for devices to get out of sync when writing an attribute that's not reportable.
// So here we re-read the value after a specified timeout, this timeout could for example be the
// transition time of a color change or for forcing a state read for devices that don't
// automatically report a new state when set.
// When reporting is requested for a device (report: true in device-specific settings) we won't
// ever issue a read here, as we assume the device will properly report changes.
// Only do this when the retrieve_state option is enabled for this device.
// retrieve_state == deprecated
if (re instanceof Device && result && result.hasOwnProperty('readAfterWriteTime') &&
re.options.retrieve_state
) {
setTimeout(() => converter.convertGet(target, key, meta), result.readAfterWriteTime);
}
}
updateMessageHomeAssistant(message: KeyValue, entityState: KeyValue): void {
/**
* Home Assistant always publishes 'state', even when e.g. only setting
* the color temperature. This would lead to 2 zigbee publishes, where the first one
* (state) is probably unnecessary.
*/
if (settings.get().homeassistant) {
const hasColorTemp = message.hasOwnProperty('color_temp');
const hasColor = message.hasOwnProperty('color');
const hasBrightness = message.hasOwnProperty('brightness');
if (settings.get().homeassistant.enabled) {
const hasColorTemp = message.color_temp !== undefined;
const hasColor = message.color !== undefined;
const hasBrightness = message.brightness !== undefined;
const isOn = entityState.state === 'ON' ? true : false;
if (isOn && (hasColorTemp || hasColor) && !hasBrightness) {
delete message.state;
@@ -118,45 +96,55 @@ export default class Publish extends Extension {
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
const parsedTopic = this.parseTopic(data.topic);
if (!parsedTopic) return;
if (!parsedTopic) {
return;
}
const re = this.zigbee.resolveEntity(parsedTopic.ID);
if (re == null) {
this.legacyLog({type: `entity_not_found`, message: {friendly_name: parsedTopic.ID}});
if (!re) {
logger.error(`Entity '${parsedTopic.ID}' is unknown`);
return;
}
// Get entity details
const definition = re instanceof Device ? re.definition : re.membersDefinitions();
let definition: zhc.Definition | zhc.Definition[];
if (re instanceof Device) {
if (!re.definition) {
logger.error(`Cannot publish to unsupported device '${re.name}'`);
return;
}
definition = re.definition;
} else {
definition = re.membersDefinitions();
}
const target = re instanceof Group ? re.zh : re.endpoint(parsedTopic.endpoint);
if (target == null) {
if (!target) {
logger.error(`Device '${re.name}' has no endpoint '${parsedTopic.endpoint}'`);
return;
}
const device = re instanceof Device ? re.zh : null;
const entitySettings = re.options;
const entityState = this.state.get(re);
const membersState = re instanceof Group ?
Object.fromEntries(re.zh.members.map((e) => [e.getDevice().ieeeAddr,
this.state.get(this.zigbee.resolveEntity(e.getDevice().ieeeAddr))])) : null;
let converters: zhc.Tz.Converter[];
{
if (Array.isArray(definition)) {
const c = new Set(definition.map((d) => d.toZigbee).flat());
if (c.size == 0) converters = defaultGroupConverters;
else converters = Array.from(c);
} else {
converters = definition.toZigbee;
}
}
// Convert the MQTT message to a Zigbee message.
const message = this.parseMessage(parsedTopic, data);
if (message == null) {
if (!message) {
logger.error(`Invalid message '${message}', skipping...`);
return;
}
const device = re instanceof Device ? re.zh : undefined;
const entitySettings = re.options;
const entityState = this.state.get(re);
const membersState =
re instanceof Group
? Object.fromEntries(
re.zh.members.map((e) => [e.getDevice().ieeeAddr, this.state.get(this.zigbee.resolveEntity(e.getDevice().ieeeAddr)!)]),
)
: undefined;
const converters = this.getDefinitionConverters(definition);
this.updateMessageHomeAssistant(message, entityState);
/**
@@ -178,37 +166,46 @@ export default class Publish extends Extension {
const toPublishEntity: {[s: number | string]: Device | Group} = {};
const addToToPublish = (entity: Device | Group, payload: KeyValue): void => {
const ID = entity.ID;
if (!(ID in toPublish)) {
toPublish[ID] = {};
toPublishEntity[ID] = entity;
}
toPublish[ID] = {...toPublish[ID], ...payload};
};
for (let [key, value] of entries) {
const endpointNames = re instanceof Device ? re.getEndpointNames() : [];
const propertyEndpointRegex = new RegExp(`^(.*?)_(${endpointNames.join('|')})$`);
let scenesChanged = false;
for (const entry of entries) {
let key = entry[0];
const value = entry[1];
let endpointName = parsedTopic.endpoint;
let localTarget = target;
let endpointOrGroupID = utils.isEndpoint(target) ? target.ID : target.groupID;
let endpointOrGroupID = utils.isZHEndpoint(target) ? target.ID : target.groupID;
// When the key has a endpointName included (e.g. state_right), this will override the target.
const propertyEndpointMatch = key.match(propertyEndpointRegex);
if (re instanceof Device && propertyEndpointMatch) {
endpointName = propertyEndpointMatch[2];
key = propertyEndpointMatch[1];
localTarget = re.endpoint(endpointName);
if (localTarget == null) {
logger.error(`Device '${re.name}' has no endpoint '${endpointName}'`);
continue;
}
// endpointName is always matched to an existing endpoint of the device
// since `propertyEndpointRegex` only contains valid endpoints for this device.
localTarget = re.endpoint(endpointName)!;
endpointOrGroupID = localTarget.ID;
}
if (!usedConverters.hasOwnProperty(endpointOrGroupID)) usedConverters[endpointOrGroupID] = [];
/* istanbul ignore next */
const converter = converters.find((c) =>
c.key.includes(key) && (!c.endpoint || c.endpoint == endpointName));
if (usedConverters[endpointOrGroupID] === undefined) usedConverters[endpointOrGroupID] = [];
// Match any key if the toZigbee converter defines no key.
const converter = converters.find(
(c) =>
(!c.key || c.key.includes(key)) && (re instanceof Group || !c.endpoints || (endpointName && c.endpoints.includes(endpointName))),
);
if (parsedTopic.type === 'set' && usedConverters[endpointOrGroupID].includes(converter)) {
if (parsedTopic.type === 'set' && converter && usedConverters[endpointOrGroupID].includes(converter)) {
// Use a converter for set only once
// (e.g. light_onoff_brightness converters can convert state and brightness)
continue;
@@ -220,16 +217,20 @@ export default class Publish extends Extension {
}
// If the endpoint_name name is a number, try to map it to a friendlyName
if (!isNaN(Number(endpointName)) && re.isDevice() && utils.isEndpoint(localTarget) &&
re.endpointName(localTarget)) {
if (!isNaN(Number(endpointName)) && re.isDevice() && utils.isZHEndpoint(localTarget) && re.endpointName(localTarget)) {
endpointName = re.endpointName(localTarget);
}
// Converter didn't return a result, skip
const entitySettingsKeyValue: KeyValue = entitySettings;
const meta = {
endpoint_name: endpointName, options: entitySettingsKeyValue,
message: {...message}, logger, device, state: entityState, membersState, mapped: definition,
const meta: zhc.Tz.Meta = {
endpoint_name: endpointName,
options: entitySettingsKeyValue,
message: {...message},
device,
state: entityState,
membersState,
mapped: definition,
};
// Strip endpoint name from meta.message properties.
@@ -247,7 +248,8 @@ export default class Publish extends Extension {
if (parsedTopic.type === 'set' && converter.convertSet) {
logger.debug(`Publishing '${parsedTopic.type}' '${key}' to '${re.name}'`);
const result = await converter.convertSet(localTarget, key, value, meta);
const optimistic = !entitySettings.hasOwnProperty('optimistic') || entitySettings.optimistic;
const optimistic = entitySettings.optimistic === undefined || entitySettings.optimistic;
if (result && result.state && optimistic) {
const msg = result.state;
@@ -266,11 +268,9 @@ export default class Publish extends Extension {
if (result && result.membersState && optimistic) {
for (const [ieeeAddr, state] of Object.entries(result.membersState)) {
addToToPublish(this.zigbee.resolveEntity(ieeeAddr), state);
addToToPublish(this.zigbee.resolveEntity(ieeeAddr)!, state);
}
}
this.legacyRetrieveState(re, converter, result, localTarget, key, meta);
} else if (parsedTopic.type === 'get' && converter.convertGet) {
logger.debug(`Publishing get '${parsedTopic.type}' '${key}' to '${re.name}'`);
await converter.convertGet(localTarget, key, meta);
@@ -279,26 +279,34 @@ export default class Publish extends Extension {
continue;
}
} catch (error) {
const message =
`Publish '${parsedTopic.type}' '${key}' to '${re.name}' failed: '${error}'`;
const message = `Publish '${parsedTopic.type}' '${key}' to '${re.name}' failed: '${error}'`;
logger.error(message);
logger.debug(error.stack);
this.legacyLog({type: `zigbee_publish_error`, message, meta: {friendly_name: re.name}});
logger.debug((error as Error).stack!);
}
usedConverters[endpointOrGroupID].push(converter);
}
for (const [ID, payload] of Object.entries(toPublish)) {
if (Object.keys(payload).length != 0) {
this.publishEntityState(toPublishEntity[ID], payload);
if (!scenesChanged && converter.key) {
scenesChanged = converter.key.some((k) => SCENE_CONVERTER_KEYS.includes(k));
}
}
for (const [ID, payload] of Object.entries(toPublish)) {
if (!utils.objectIsEmpty(payload)) {
await this.publishEntityState(toPublishEntity[ID], payload);
}
}
const scenesChanged = Object.values(usedConverters)
.some((cl) => cl.some((c) => c.key.some((k) => sceneConverterKeys.includes(k))));
if (scenesChanged) {
this.eventBus.emitScenesChanged({entity: re});
}
}
private getDefinitionConverters(definition: zhc.Definition | zhc.Definition[]): ReadonlyArray<zhc.Tz.Converter> {
if (Array.isArray(definition)) {
return definition.length ? Array.from(new Set(definition.map((d) => d.toZigbee).flat())) : [];
} else {
return definition?.toZigbee;
}
}
}
+70 -53
View File
@@ -1,19 +1,25 @@
import * as settings from '../util/settings';
import logger from '../util/logger';
import debounce from 'debounce';
import Extension from './extension';
import stringify from 'json-stable-stringify-without-jsonify';
import assert from 'node:assert';
import bind from 'bind-decorator';
import utils from '../util/utils';
import debounce from 'debounce';
import stringify from 'json-stable-stringify-without-jsonify';
import throttle from 'throttleit';
import * as zhc from 'zigbee-herdsman-converters';
type DebounceFunction = (() => void) & { clear(): void; } & { flush(): void; };
import logger from '../util/logger';
import * as settings from '../util/settings';
import utils from '../util/utils';
import Extension from './extension';
type DebounceFunction = (() => void) & {clear(): void} & {flush(): void};
export default class Receive extends Extension {
private elapsed: {[s: string]: number} = {};
private debouncers: {[s: string]: {payload: KeyValue, publish: DebounceFunction }} = {};
private debouncers: {[s: string]: {payload: KeyValue; publish: DebounceFunction}} = {};
private throttlers: {[s: string]: {publish: PublishEntityState}} = {};
async start(): Promise<void> {
override async start(): Promise<void> {
this.eventBus.onPublishEntityState(this, this.onPublishEntityState);
this.eventBus.onDeviceMessage(this, this.onDeviceMessage);
}
@@ -24,20 +30,24 @@ export default class Receive extends Extension {
* In case that e.g. the state is currently held back by a debounce and a new state is published
* remove it from the to be send debounced message.
*/
if (data.entity.isDevice() && this.debouncers[data.entity.ieeeAddr] &&
data.stateChangeReason !== 'publishDebounce' && data.stateChangeReason !== 'lastSeenChanged') {
if (
data.entity.isDevice() &&
this.debouncers[data.entity.ieeeAddr] &&
data.stateChangeReason !== 'publishDebounce' &&
data.stateChangeReason !== 'lastSeenChanged'
) {
for (const key of Object.keys(data.payload)) {
delete this.debouncers[data.entity.ieeeAddr].payload[key];
}
}
}
publishDebounce(device: Device, payload: KeyValue, time: number, debounceIgnore: string[]): void {
publishDebounce(device: Device, payload: KeyValue, time: number, debounceIgnore: string[] | undefined): void {
if (!this.debouncers[device.ieeeAddr]) {
this.debouncers[device.ieeeAddr] = {
payload: {},
publish: debounce(() => {
this.publishEntityState(device, this.debouncers[device.ieeeAddr].payload, 'publishDebounce');
publish: debounce(async () => {
await this.publishEntityState(device, this.debouncers[device.ieeeAddr].payload, 'publishDebounce');
this.debouncers[device.ieeeAddr].payload = {};
}, time * 1000),
};
@@ -60,11 +70,25 @@ export default class Receive extends Extension {
this.debouncers[device.ieeeAddr].publish();
}
async publishThrottle(device: Device, payload: KeyValue, time: number): Promise<void> {
if (!this.throttlers[device.ieeeAddr]) {
this.throttlers[device.ieeeAddr] = {
publish: throttle(this.publishEntityState, time * 1000),
};
}
// Update state cache right away. This makes sure that during throttling cached state is always up to date.
// By updating cache we make sure that state cache is always up-to-date.
this.state.set(device, payload);
await this.throttlers[device.ieeeAddr].publish(device, payload, 'publishThrottle');
}
// if debounce_ignore are specified (Array of strings)
// then all newPayload values with key present in debounce_ignore
// should equal or be undefined in oldPayload
// otherwise payload is conflicted
isPayloadConflicted(newPayload: KeyValue, oldPayload: KeyValue, debounceIgnore: string[] | null): boolean {
isPayloadConflicted(newPayload: KeyValue, oldPayload: KeyValue, debounceIgnore: string[] | undefined): boolean {
let result = false;
Object.keys(oldPayload)
.filter((key) => (debounceIgnore || []).includes(key))
@@ -77,22 +101,13 @@ export default class Receive extends Extension {
return result;
}
shouldProcess(data: eventdata.DeviceMessage): boolean {
if (!data.device.definition || data.device.zh.interviewing) {
logger.debug(`Skipping message, still interviewing`);
return false;
}
return true;
}
@bind async onDeviceMessage(data: eventdata.DeviceMessage): Promise<void> {
/* istanbul ignore next */
/* v8 ignore next */
if (!data.device) return;
if (!this.shouldProcess(data)) {
utils.publishLastSeen({device: data.device, reason: 'messageEmitted'},
settings.get(), true, this.publishEntityState);
if (!data.device.definition || data.device.zh.interviewing) {
logger.debug(`Skipping message, still interviewing`);
await utils.publishLastSeen({device: data.device, reason: 'messageEmitted'}, settings.get(), true, this.publishEntityState);
return;
}
@@ -104,10 +119,11 @@ export default class Receive extends Extension {
// Check if there is an available converter, genOta messages are not interesting.
const ignoreClusters: (string | number)[] = ['genOta', 'genTime', 'genBasic', 'genPollCtrl'];
if (converters.length == 0 && !ignoreClusters.includes(data.cluster)) {
logger.debug(`No converter available for '${data.device.definition.model}' with ` +
`cluster '${data.cluster}' and type '${data.type}' and data '${stringify(data.data)}'`);
utils.publishLastSeen({device: data.device, reason: 'messageEmitted'},
settings.get(), true, this.publishEntityState);
logger.debug(
`No converter available for '${data.device.definition.model}' with ` +
`cluster '${data.cluster}' and type '${data.type}' and data '${stringify(data.data)}'`,
);
await utils.publishLastSeen({device: data.device, reason: 'messageEmitted'}, settings.get(), true, this.publishEntityState);
return;
}
@@ -116,9 +132,10 @@ export default class Receive extends Extension {
// - If a payload is returned publish it to the MQTT broker
// - If NO payload is returned do nothing. This is for non-standard behaviour
// for e.g. click switches where we need to count number of clicks and detect long presses.
const publish = (payload: KeyValue): void => {
const publish = async (payload: KeyValue): Promise<void> => {
assert(data.device.definition);
const options: KeyValue = data.device.options;
zhc.postProcessConvertedFromZigbeeMessage(data.device.definition, payload, options, logger);
zhc.postProcessConvertedFromZigbeeMessage(data.device.definition, payload, options);
if (settings.get().advanced.elapsed) {
const now = Date.now();
@@ -129,43 +146,43 @@ export default class Receive extends Extension {
this.elapsed[data.device.ieeeAddr] = now;
}
// Check if we have to debounce
// Check if we have to debounce or throttle
if (data.device.options.debounce) {
this.publishDebounce(data.device, payload, data.device.options.debounce,
data.device.options.debounce_ignore);
this.publishDebounce(data.device, payload, data.device.options.debounce, data.device.options.debounce_ignore);
} else if (data.device.options.throttle) {
await this.publishThrottle(data.device, payload, data.device.options.throttle);
} else {
this.publishEntityState(data.device, payload);
await this.publishEntityState(data.device, payload);
}
};
const deviceExposesChanged = (): void => {
this.eventBus.emitDevicesChanged();
this.eventBus.emitExposesChanged({device: data.device});
const meta = {
device: data.device.zh,
logger,
state: this.state.get(data.device),
deviceExposesChanged: (): void => this.eventBus.emitExposesAndDevicesChanged(data.device),
};
const meta = {device: data.device.zh, logger, state: this.state.get(data.device),
deviceExposesChanged: deviceExposesChanged};
let payload: KeyValue = {};
for (const converter of converters) {
try {
const convertData = {...data, device: data.device.zh};
const options: KeyValue = data.device.options;
const converted = await converter.convert(
data.device.definition, convertData, publish, options, meta);
const converted = await converter.convert(data.device.definition, convertData, publish, options, meta);
if (converted) {
payload = {...payload, ...converted};
}
} catch (error) /* istanbul ignore next */ {
logger.error(`Exception while calling fromZigbee converter: ${error.message}}`);
logger.debug(error.stack);
/* v8 ignore start */
} catch (error) {
logger.error(`Exception while calling fromZigbee converter: ${(error as Error).message}}`);
logger.debug((error as Error).stack!);
}
/* v8 ignore stop */
}
if (Object.keys(payload).length) {
publish(payload);
if (!utils.objectIsEmpty(payload)) {
await publish(payload);
} else {
utils.publishLastSeen({device: data.device, reason: 'messageEmitted'},
settings.get(), true, this.publishEntityState);
await utils.publishLastSeen({device: data.device, reason: 'messageEmitted'}, settings.get(), true, this.publishEntityState);
}
}
}
+89 -31
View File
@@ -1,20 +1,44 @@
/* eslint-disable brace-style */
import * as settings from '../util/settings';
import assert from 'node:assert';
import * as zhc from 'zigbee-herdsman-converters';
import {access, Numeric} from 'zigbee-herdsman-converters';
import {CustomClusters} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype';
import * as settings from '../util/settings';
const LINKQUALITY = new Numeric('linkquality', access.STATE)
.withUnit('lqi')
.withDescription('Link quality (signal strength)')
.withValueMin(0)
.withValueMax(255)
.withCategory('diagnostic');
export default class Device {
public zh: zh.Device;
public definition: zhc.Definition;
private _definitionModelID: string;
public definition?: zhc.Definition;
private _definitionModelID?: string;
get ieeeAddr(): string {return this.zh.ieeeAddr;}
get ID(): string {return this.zh.ieeeAddr;}
get options(): DeviceOptions {return {...settings.get().device_options, ...settings.getDevice(this.ieeeAddr)};}
get ieeeAddr(): string {
return this.zh.ieeeAddr;
}
get ID(): string {
return this.zh.ieeeAddr;
}
get options(): DeviceOptionsWithId {
const deviceOptions = settings.getDevice(this.ieeeAddr) ?? {friendly_name: this.ieeeAddr, ID: this.ieeeAddr};
return {...settings.get().device_options, ...deviceOptions};
}
get name(): string {
return this.zh.type === 'Coordinator' ? 'Coordinator' : this.options?.friendly_name || this.ieeeAddr;
return this.zh.type === 'Coordinator' ? 'Coordinator' : this.options?.friendly_name;
}
get isSupported(): boolean {
return this.zh.type === 'Coordinator' || (this.definition && !this.definition.generated);
return this.zh.type === 'Coordinator' || Boolean(this.definition && !this.definition.generated);
}
get customClusters(): CustomClusters {
return this.zh.customClusters;
}
get otaExtraMetas(): zhc.Ota.ExtraMetas {
return typeof this.definition?.ota === 'object' ? this.definition.ota : {};
}
constructor(device: zh.Device) {
@@ -22,17 +46,20 @@ export default class Device {
}
exposes(): zhc.Expose[] {
/* istanbul ignore if */
const exposes: zhc.Expose[] = [];
assert(this.definition, 'Cannot retreive exposes before definition is resolved');
if (typeof this.definition.exposes == 'function') {
const options: KeyValue = this.options;
return this.definition.exposes(this.zh, options);
exposes.push(...this.definition.exposes(this.zh, options));
} else {
return this.definition.exposes;
exposes.push(...this.definition.exposes);
}
exposes.push(LINKQUALITY);
return exposes;
}
async resolveDefinition(): Promise<void> {
if (!this.zh.interviewing && (!this.definition || this._definitionModelID !== this.zh.modelID)) {
async resolveDefinition(ignoreCache: boolean = false): Promise<void> {
if (!this.zh.interviewing && (!this.definition || this._definitionModelID !== this.zh.modelID || ignoreCache)) {
this.definition = await zhc.findByDevice(this.zh, true);
this._definitionModelID = this.zh.modelID;
}
@@ -44,38 +71,69 @@ export default class Device {
}
}
endpoint(key?: string | number): zh.Endpoint {
let endpoint: zh.Endpoint;
if (key == null || key == '') key = 'default';
endpoint(key?: string | number): zh.Endpoint | undefined {
let endpoint: zh.Endpoint | undefined;
if (key == null || key == '') {
key = 'default';
}
if (!isNaN(Number(key))) {
endpoint = this.zh.getEndpoint(Number(key));
} else if (this.definition?.endpoint) {
const ID = this.definition?.endpoint?.(this.zh)[key];
if (ID) endpoint = this.zh.getEndpoint(ID);
else if (key === 'default') endpoint = this.zh.endpoints[0];
else return null;
if (ID) {
endpoint = this.zh.getEndpoint(ID);
} else if (key === 'default') {
endpoint = this.zh.endpoints[0];
} else {
return undefined;
}
} else {
/* istanbul ignore next */
if (key !== 'default') return null;
if (key !== 'default') {
return undefined;
}
endpoint = this.zh.endpoints[0];
}
return endpoint;
}
endpointName(endpoint: zh.Endpoint): string {
let name = null;
endpointName(endpoint: zh.Endpoint): string | undefined {
let epName = undefined;
if (this.definition?.endpoint) {
name = Object.entries(this.definition?.endpoint(this.zh)).find((e) => e[1] == endpoint.ID)[0];
const mapping = this.definition?.endpoint(this.zh);
for (const [name, id] of Object.entries(mapping)) {
if (id == endpoint.ID) {
epName = name;
}
}
}
/* istanbul ignore next */
return name === 'default' ? null : name;
/* v8 ignore next */
return epName === 'default' ? undefined : epName;
}
isIkeaTradfri(): boolean {return this.zh.manufacturerID === 4476;}
getEndpointNames(): string[] {
const names: string[] = [];
isDevice(): this is Device {return true;}
/* istanbul ignore next */
isGroup(): this is Group {return false;}
for (const name in this.definition?.endpoint?.(this.zh) ?? {}) {
if (name !== 'default') {
names.push(name);
}
}
return names;
}
isDevice(): this is Device {
return true;
}
isGroup(): this is Group {
return false;
}
}
+30 -11
View File
@@ -1,16 +1,23 @@
/* eslint-disable brace-style */
import * as settings from '../util/settings';
import * as zhc from 'zigbee-herdsman-converters';
import * as settings from '../util/settings';
export default class Group {
public zh: zh.Group;
private resolveDevice: (ieeeAddr: string) => Device;
private resolveDevice: (ieeeAddr: string) => Device | undefined;
get ID(): number {return this.zh.groupID;}
get options(): GroupOptions {return {...settings.getGroup(this.ID)};}
get name(): string {return this.options?.friendly_name || this.ID.toString();}
get ID(): number {
return this.zh.groupID;
}
get options(): GroupOptions {
// XXX: Group always exists in settings
return {...settings.getGroup(this.ID)!};
}
get name(): string {
return this.options?.friendly_name || this.ID.toString();
}
constructor(group: zh.Group, resolveDevice: (ieeeAddr: string) => Device) {
constructor(group: zh.Group, resolveDevice: (ieeeAddr: string) => Device | undefined) {
this.zh = group;
this.resolveDevice = resolveDevice;
}
@@ -20,13 +27,25 @@ export default class Group {
}
membersDevices(): Device[] {
return this.zh.members.map((e) => this.resolveDevice(e.getDevice().ieeeAddr)).filter((d) => d);
return this.zh.members.map((d) => this.resolveDevice(d.getDevice().ieeeAddr)!);
}
membersDefinitions(): zhc.Definition[] {
return this.membersDevices().map((d) => d.definition).filter((d) => d);
const definitions: zhc.Definition[] = [];
for (const member of this.membersDevices()) {
if (member.definition) {
definitions.push(member.definition);
}
}
return definitions;
}
isDevice(): this is Device {return false;}
isGroup(): this is Group {return true;}
isDevice(): this is Device {
return false;
}
isGroup(): this is Group {
return true;
}
}
+97 -69
View File
@@ -1,20 +1,27 @@
import * as mqtt from 'mqtt';
import type {IClientOptions, IClientPublishOptions, MqttClient} from 'mqtt';
import type {Zigbee2MQTTAPI} from './types/api';
import fs from 'node:fs';
import bind from 'bind-decorator';
import {connectAsync} from 'mqtt';
import logger from './util/logger';
import * as settings from './util/settings';
import utils from './util/utils';
import fs from 'fs';
import bind from 'bind-decorator';
import type {QoS} from 'mqtt-packet';
const NS = 'z2m:mqtt';
export default class MQTT {
private publishedTopics: Set<string> = new Set();
private connectionTimer: NodeJS.Timeout;
private client: mqtt.MqttClient;
private connectionTimer?: NodeJS.Timeout;
private client!: MqttClient;
private eventBus: EventBus;
private initialConnect = true;
private republishRetainedTimer: NodeJS.Timeout;
private retainedMessages: {[s: string]: {payload: string, options: MQTTOptions,
skipLog: boolean, skipReceive: boolean, topic: string, base: string}} = {};
private republishRetainedTimer?: NodeJS.Timeout;
public retainedMessages: {
[s: string]: {payload: string; options: IClientPublishOptions; skipLog: boolean; skipReceive: boolean; topic: string; base: string};
} = {};
constructor(eventBus: EventBus) {
this.eventBus = eventBus;
@@ -22,15 +29,17 @@ export default class MQTT {
async connect(): Promise<void> {
const mqttSettings = settings.get().mqtt;
logger.info(`Connecting to MQTT server at ${mqttSettings.server}`);
const options: mqtt.IClientOptions = {
const options: IClientOptions = {
will: {
topic: `${settings.get().mqtt.base_topic}/bridge/state`,
payload: Buffer.from(utils.availabilityPayload('offline', settings.get())),
payload: Buffer.from(JSON.stringify({state: 'offline'})),
retain: settings.get().mqtt.force_disable_retain ? false : true,
qos: 1,
},
properties: {maximumPacketSize: mqttSettings.maximum_packet_size},
};
if (mqttSettings.version) {
@@ -58,6 +67,9 @@ export default class MQTT {
logger.debug(`Using MQTT login with username: ${mqttSettings.user}`);
options.username = mqttSettings.user;
options.password = mqttSettings.password;
} else if (mqttSettings.user) {
logger.debug(`Using MQTT login with username only: ${mqttSettings.user}`);
options.username = mqttSettings.user;
} else {
logger.debug(`Using MQTT anonymous login`);
}
@@ -67,94 +79,104 @@ export default class MQTT {
options.clientId = mqttSettings.client_id;
}
if (mqttSettings.hasOwnProperty('reject_unauthorized') && !mqttSettings.reject_unauthorized) {
if (mqttSettings.reject_unauthorized !== undefined && !mqttSettings.reject_unauthorized) {
logger.debug(`MQTT reject_unauthorized set false, ignoring certificate warnings.`);
options.rejectUnauthorized = false;
}
return new Promise((resolve, reject) => {
this.client = mqtt.connect(mqttSettings.server, options);
// @ts-ignore https://github.com/Koenkk/zigbee2mqtt/issues/9822
this.client.stream.setMaxListeners(0);
this.eventBus.onPublishAvailability(this, () => this.publishStateOnline());
this.client = await connectAsync(mqttSettings.server, options);
const onConnect = this.onConnect;
this.client.on('connect', async () => {
await onConnect();
resolve();
});
// https://github.com/Koenkk/zigbee2mqtt/issues/9822
this.client.stream.setMaxListeners(0);
this.client.on('error', (err) => {
logger.error(`MQTT error: ${err.message}`);
reject(err);
});
this.client.on('message', this.onMessage);
this.client.on('error', (err) => {
logger.error(`MQTT error: ${err.message}`);
});
}
@bind private async onConnect(): Promise<void> {
if (mqttSettings.version != undefined && mqttSettings.version >= 5) {
this.client.on('disconnect', (packet) => {
logger.error(`MQTT disconnect: reason ${packet.reasonCode} (${packet.properties?.reasonString})`);
});
}
this.client.on('message', this.onMessage);
await this.onConnect();
this.client.on('connect', this.onConnect);
this.republishRetainedTimer = setTimeout(async () => {
// 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);
}
}, 2000);
// Set timer at interval to check if connected to MQTT server.
clearTimeout(this.connectionTimer);
this.connectionTimer = setInterval(() => {
if (this.client.reconnecting) {
if (!this.isConnected()) {
logger.error('Not connected to MQTT server!');
}
}, utils.seconds(10));
logger.info('Connected to MQTT server');
await this.publishStateOnline();
if (!this.initialConnect) {
this.republishRetainedTimer = setTimeout(() => {
// Republish retained messages in case MQTT broker does not persist them.
// https://github.com/Koenkk/zigbee2mqtt/issues/9629
Object.values(this.retainedMessages).forEach((e) =>
this.publish(e.topic, e.payload, e.options, e.base, e.skipLog, e.skipReceive));
}, 2000);
}
this.initialConnect = false;
this.subscribe(`${settings.get().mqtt.base_topic}/#`);
}
async publishStateOnline(): Promise<void> {
await this.publish('bridge/state', utils.availabilityPayload('online', settings.get()), {retain: true, qos: 0});
}
async disconnect(): Promise<void> {
clearTimeout(this.connectionTimer);
await this.publish('bridge/state', utils.availabilityPayload('offline', settings.get()),
{retain: true, qos: 0});
clearTimeout(this.republishRetainedTimer);
const stateData: Zigbee2MQTTAPI['bridge/state'] = {state: 'offline'};
await this.publish('bridge/state', JSON.stringify(stateData), {retain: true, qos: 0});
this.eventBus.removeListeners(this);
logger.info('Disconnecting from MQTT server');
this.client.end();
await this.client?.endAsync();
}
subscribe(topic: string): void {
this.client.subscribe(topic);
async subscribe(topic: string): Promise<void> {
await this.client.subscribeAsync(topic);
}
async unsubscribe(topic: string): Promise<void> {
await this.client.unsubscribeAsync(topic);
}
@bind private async onConnect(): Promise<void> {
logger.info('Connected to MQTT server');
const stateData: Zigbee2MQTTAPI['bridge/state'] = {state: 'online'};
await this.publish('bridge/state', JSON.stringify(stateData), {retain: true, qos: 0});
await this.subscribe(`${settings.get().mqtt.base_topic}/#`);
}
@bind public onMessage(topic: string, message: Buffer): void {
// Since we subscribe to zigbee2mqtt/# we also receive the message we send ourselves, skip these.
if (!this.publishedTopics.has(topic)) {
logger.debug(`Received MQTT message on '${topic}' with data '${message.toString()}'`);
logger.debug(() => `Received MQTT message on '${topic}' with data '${message.toString()}'`, NS);
this.eventBus.emitMQTTMessage({topic, message: message.toString()});
}
if (this.republishRetainedTimer && topic === `${settings.get().mqtt.base_topic}/bridge/info`) {
clearTimeout(this.republishRetainedTimer);
this.republishRetainedTimer = null;
this.republishRetainedTimer = undefined;
}
}
isConnected(): boolean {
return this.client && !this.client.reconnecting;
return this.client && !this.client.reconnecting && !this.client.disconnecting && !this.client.disconnected;
}
async publish(topic: string, payload: string, options: MQTTOptions={},
base=settings.get().mqtt.base_topic, skipLog=false, skipReceive=true,
async publish(
topic: string,
payload: string,
options: IClientPublishOptions = {},
base = settings.get().mqtt.base_topic,
skipLog = false,
skipReceive = true,
): Promise<void> {
const defaultOptions: {qos: QoS, retain: boolean} = {qos: 0, retain: false};
const defaultOptions = {qos: 0 as const, retain: false};
topic = `${base}/${topic}`;
if (skipReceive) {
@@ -163,8 +185,7 @@ export default class MQTT {
if (options.retain) {
if (payload) {
this.retainedMessages[topic] =
{payload, options, skipReceive, skipLog, topic: topic.substring(base.length + 1), base};
this.retainedMessages[topic] = {payload, options, skipReceive, skipLog, topic: topic.substring(base.length + 1), base};
} else {
delete this.retainedMessages[topic];
}
@@ -177,20 +198,27 @@ export default class MQTT {
logger.error(`Not connected to MQTT server!`);
logger.error(`Cannot send message: topic: '${topic}', payload: '${payload}`);
}
return;
}
if (!skipLog) {
logger.info(`MQTT publish: topic '${topic}', payload '${payload}'`);
logger.info(() => `MQTT publish: topic '${topic}', payload '${payload}'`, NS);
}
const actualOptions: mqtt.IClientPublishOptions = {...defaultOptions, ...options};
const actualOptions: IClientPublishOptions = {...defaultOptions, ...options};
if (settings.get().mqtt.force_disable_retain) {
actualOptions.retain = false;
}
return new Promise((resolve) => {
this.client.publish(topic, payload, actualOptions, () => resolve());
});
try {
await this.client.publishAsync(topic, payload, actualOptions);
} catch (error) {
if (!skipLog) {
logger.error(`MQTT server error: ${(error as Error).message}`);
logger.error(`Could not send message: topic: '${topic}', payload: '${payload}`);
}
}
}
}
+35 -14
View File
@@ -1,24 +1,45 @@
import logger from './util/logger';
import fs from 'node:fs';
import objectAssignDeep from 'object-assign-deep';
import data from './util/data';
import logger from './util/logger';
import * as settings from './util/settings';
import utils from './util/utils';
import fs from 'fs';
import objectAssignDeep from 'object-assign-deep';
const saveInterval = 1000 * 60 * 5; // 5 minutes
const dontCacheProperties = [
'action', 'action_.*', 'button', 'button_left', 'button_right', 'click', 'forgotten', 'keyerror',
'step_size', 'transition_time', 'group_list', 'group_capacity', 'no_occupancy_since',
'step_mode', 'transition_time', 'duration', 'elapsed', 'from_side', 'to_side',
'action',
'action_.*',
'button',
'button_left',
'button_right',
'forgotten',
'keyerror',
'step_size',
'transition_time',
'group_list',
'group_capacity',
'no_occupancy_since',
'step_mode',
'transition_time',
'duration',
'elapsed',
'from_side',
'to_side',
'illuminance_lux', // removed in z2m 2.0.0
];
class State {
private state: {[s: string | number]: KeyValue} = {};
private file = data.joinPath('state.json');
private timer: NodeJS.Timeout = null;
private timer?: NodeJS.Timeout;
constructor(private readonly eventBus: EventBus, private readonly zigbee: Zigbee) {
constructor(
private readonly eventBus: EventBus,
private readonly zigbee: Zigbee,
) {
this.eventBus = eventBus;
this.zigbee = zigbee;
}
@@ -45,8 +66,8 @@ class State {
try {
this.state = JSON.parse(fs.readFileSync(this.file, 'utf8'));
logger.debug(`Loaded state from file ${this.file}`);
} catch (e) {
logger.debug(`Failed to load state from file ${this.file} (corrupt file?)`);
} catch (error) {
logger.debug(`Failed to load state from file ${this.file} (corrupt file?) (${(error as Error).message})`);
}
} else {
logger.debug(`Can't load state from file ${this.file} (doesn't exist)`);
@@ -59,8 +80,8 @@ class State {
const json = JSON.stringify(this.state, null, 4);
try {
fs.writeFileSync(this.file, json, 'utf8');
} catch (e) {
logger.error(`Failed to write state to '${this.file}' (${e.message})`);
} catch (error) {
logger.error(`Failed to write state to '${this.file}' (${error})`);
}
} else {
logger.debug(`Not saving state`);
@@ -68,14 +89,14 @@ class State {
}
exists(entity: Device | Group): boolean {
return this.state.hasOwnProperty(entity.ID);
return this.state[entity.ID] !== undefined;
}
get(entity: Group | Device): KeyValue {
return this.state[entity.ID] || {};
}
set(entity: Group | Device, update: KeyValue, reason: string=null): KeyValue {
set(entity: Group | Device, update: KeyValue, reason?: string): KeyValue {
const fromState = this.state[entity.ID] || {};
const toState = objectAssignDeep({}, fromState, update);
const newCache = {...toState};
+687
View File
@@ -0,0 +1,687 @@
import type * as zhc from 'zigbee-herdsman-converters';
import type {ClusterDefinition, ClusterName, CustomClusters} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype';
import type {LogLevel, schemaJson} from '../util/settings';
export interface Zigbee2MQTTScene {
id: number;
name: string;
}
interface Zigbee2MQTTDeviceEndpoint {
bindings: Zigbee2MQTTDeviceEndpointBinding[];
configured_reportings: Zigbee2MQTTDeviceEndpointConfiguredReporting[];
clusters: {input: string[]; output: string[]};
scenes: Zigbee2MQTTScene[];
}
interface Zigbee2MQTTDeviceEndpointBinding {
cluster: string;
target: Zigbee2MQTTDeviceEndpointBindingTarget;
}
interface Zigbee2MQTTDeviceEndpointBindingTarget {
type: string;
endpoint?: number;
ieee_address?: string;
id?: number;
}
interface Zigbee2MQTTDeviceEndpointConfiguredReporting {
cluster: string;
attribute: string | number;
minimum_report_interval: number;
maximum_report_interval: number;
reportable_change: number;
}
interface Zigbee2MQTTDeviceDefinition {
model: string;
vendor: string;
description: string;
exposes: zhc.Expose[];
supports_ota: boolean;
options: zhc.Option[];
icon: string;
}
export interface Zigbee2MQTTDevice {
ieee_address: zh.Device['ieeeAddr'];
type: zh.Device['type'];
network_address: zh.Device['networkAddress'];
supported: boolean;
friendly_name: string;
disabled: boolean;
description: string | undefined;
definition: Zigbee2MQTTDeviceDefinition | undefined;
power_source: zh.Device['powerSource'];
software_build_id: zh.Device['softwareBuildID'];
date_code: zh.Device['dateCode'];
model_id: zh.Device['modelID'];
interviewing: zh.Device['interviewing'];
interview_completed: zh.Device['interviewCompleted'];
manufacturer: zh.Device['manufacturerName'];
endpoints: Record<number, Zigbee2MQTTDeviceEndpoint>;
}
export interface Zigbee2MQTTGroupMember {
ieee_address: zh.Device['ieeeAddr'];
endpoint: number;
}
export interface Zigbee2MQTTGroup {
id: number;
friendly_name: 'default_bind_group' | string;
description: string | undefined;
scenes: Zigbee2MQTTScene[];
members: Zigbee2MQTTGroupMember[];
}
export interface Zigbee2MQTTNetworkMap {
nodes: {
ieeeAddr: string;
friendlyName: string;
type: string;
networkAddress: number;
manufacturerName: string | undefined;
modelID: string | undefined;
failed: string[];
lastSeen: number | undefined;
definition?: {model: string; vendor: string; supports: string; description: string};
}[];
links: {
source: {ieeeAddr: string; networkAddress: number};
target: {ieeeAddr: string; networkAddress: number};
linkquality: number;
depth: number;
routes: {
destinationAddress: number;
status: string;
nextHop: number;
}[];
sourceIeeeAddr: string;
targetIeeeAddr: string;
sourceNwkAddr: number;
lqi: number;
relationship: number;
}[];
}
/**
* Zigbee2MQTT state/request/response API endpoints
*/
export interface Zigbee2MQTTAPI {
'bridge/logging': {
message: string;
level: LogLevel;
namespace: string;
};
'bridge/state': {
state: 'online' | 'offline';
};
'bridge/definition': {
clusters: Readonly<Record<ClusterName, Readonly<ClusterDefinition>>>;
custom_clusters: Record<string, CustomClusters>;
};
'bridge/event':
| {
type: 'device_leave' | 'device_joined' | 'device_announce';
data: {
friendly_name: string;
ieee_address: string;
};
}
| {
type: 'device_interview';
data:
| {
friendly_name: string;
ieee_address: string;
status: 'started' | 'failed';
}
| {
friendly_name: string;
ieee_address: string;
status: 'successful';
supported: boolean;
definition: Zigbee2MQTTDeviceDefinition | undefined;
};
};
'bridge/info': {
version: string;
commit: string | undefined;
zigbee_herdsman_converters: {version: string};
zigbee_herdsman: {version: string};
coordinator: {
ieee_address: string;
type: string;
meta: {
[s: string]: number | string;
};
};
network: {
pan_id: number;
/** `0x${string}` 8-len */
extended_pan_id: string;
channel: number;
};
log_level: 'debug' | 'info' | 'warning' | 'error';
permit_join: boolean;
permit_join_end: number | undefined;
restart_required: boolean;
config: Settings;
config_schema: typeof schemaJson;
};
'bridge/devices': Zigbee2MQTTDevice[];
'bridge/groups': Zigbee2MQTTGroup[];
'bridge/request/permit_join':
| {
/** [0-254], 0 meaning disable */
time: number;
device?: string;
}
| `${number}`;
'bridge/response/permit_join': {
/** [0-254], 0 meaning disable */
time: number;
device?: string;
};
'bridge/request/health_check': '';
'bridge/response/health_check': {
/** XXX: currently always returns true */
healthy: boolean;
};
'bridge/request/coordinator_check': '';
'bridge/response/coordinator_check': {
missing_routers: {
ieee_address: string;
friendly_name: string;
}[];
};
'bridge/request/restart': '';
'bridge/response/restart': Record<string, never>;
'bridge/request/networkmap':
| {
type: 'raw' | 'graphviz' | 'plantuml';
routes: boolean;
}
| 'raw'
| 'graphviz'
| 'plantuml';
'bridge/response/networkmap':
| {
type: 'raw';
routes: boolean;
value: Zigbee2MQTTNetworkMap;
}
| {
type: 'graphviz' | 'plantuml';
routes: boolean;
value: string;
};
'bridge/request/extension/save': {
name: string;
code: string;
};
'bridge/response/extension/save': Record<string, never>;
'bridge/request/extension/remove': {
name: string;
};
'bridge/response/extension/remove': Record<string, never>;
'bridge/request/converter/save': {
name: string;
code: string;
};
'bridge/response/converter/save': Record<string, never>;
'bridge/request/converter/remove': {
name: string;
};
'bridge/response/converter/remove': Record<string, never>;
'bridge/request/backup': '';
'bridge/response/backup': {
/** base64 encoded ZIP archive */
zip: string;
};
'bridge/request/install_code/add': {
value: string;
};
'bridge/response/install_code/add': {
value: string;
};
/**
* Applied on-the-fly:
* - newSettings.homeassistant
* - newSettings.advanced?.log_level
* - newSettings.advanced?.log_namespaced_levels
* - newSettings.advanced?.log_debug_namespace_ignore
*/
'bridge/request/options': {
options: Record<string, unknown>;
};
'bridge/response/options': {
restart_required: boolean;
};
'bridge/request/device/bind': {
from: string;
from_endpoint: string | number | 'default';
to: string;
to_endpoint?: string | number;
clusters?: string[];
skip_disable_reporting?: boolean;
};
'bridge/response/device/bind': {
from: string;
from_endpoint: string | number;
to: string;
to_endpoint: string | number | undefined;
clusters: string[];
failed: string[];
};
'bridge/request/device/unbind': {
from: string;
from_endpoint: string | number | 'default';
to: string;
to_endpoint?: string | number;
clusters?: string[];
skip_disable_reporting?: boolean;
};
'bridge/response/device/unbind': {
from: string;
from_endpoint: string | number;
to: string;
to_endpoint: string | number | undefined;
clusters: string[];
failed: string[];
};
'bridge/request/device/configure':
| {
id: string | number;
}
| string;
'bridge/response/device/configure': {
id: string | number;
};
'bridge/request/device/remove': {
id: string;
block?: boolean;
force?: boolean;
};
'bridge/response/device/remove': {
id: string;
block: boolean;
force: boolean;
};
'bridge/request/device/ota_update/check': {
id: string;
};
'bridge/request/device/ota_update/check/downgrade': {
id: string;
};
'bridge/response/device/ota_update/check': {
id: string;
update_available: boolean;
};
'bridge/request/device/ota_update/update': {
id: string;
};
'bridge/request/device/ota_update/update/downgrade': {
id: string;
};
'bridge/response/device/ota_update/update': {
id: string;
from:
| {
software_build_id: string;
date_code: string;
}
| undefined;
to:
| {
software_build_id: string;
date_code: string;
}
| undefined;
};
'bridge/request/device/interview': {
id: string | number;
};
'bridge/response/device/interview': {
id: string | number;
};
'bridge/request/device/generate_external_definition': {
id: string | number;
};
'bridge/response/device/generate_external_definition': {
id: string | number;
source: string;
};
'bridge/request/device/options': {
id: string;
options: Record<string, unknown>;
};
'bridge/response/device/options': {
id: string;
from: Record<string, unknown>;
to: Record<string, unknown>;
restart_required: boolean;
};
'bridge/request/device/rename':
| {
last: true;
from?: string;
to: string;
homeassistant_rename?: boolean;
}
| {
last: false | undefined;
from: string;
to: string;
homeassistant_rename?: boolean;
};
'bridge/response/device/rename': {
from: string;
to: string;
homeassistant_rename: boolean;
};
'bridge/request/device/configure_reporting': {
id: string;
endpoint: string | number;
cluster: string | number;
attribute: string | number | {ID: number; type: number};
minimum_report_interval: number;
maximum_report_interval: number;
reportable_change: number;
option: Record<string, unknown>;
};
'bridge/response/device/configure_reporting': {
id: string;
endpoint: string | number;
cluster: string | number;
attribute: string | number | {ID: number; type: number};
minimum_report_interval: number;
maximum_report_interval: number;
reportable_change: number;
};
'bridge/request/group/remove': {
id: string;
force?: boolean;
};
'bridge/response/group/remove': {
id: string;
force: boolean;
};
'bridge/request/group/add': {
friendly_name: string;
id: string;
};
'bridge/response/group/add': {
friendly_name: string;
id: number;
};
'bridge/request/group/rename': {
from: string;
to: string;
homeassistant_rename?: boolean;
};
'bridge/response/group/rename': {
from: string;
to: string;
homeassistant_rename: boolean;
};
'bridge/request/group/options': {
id: string;
options: Record<string, unknown>;
};
'bridge/response/group/options': {
id: string;
from: Record<string, unknown>;
to: Record<string, unknown>;
restart_required: boolean;
};
'bridge/request/group/members/add': {
device: string;
group: string;
endpoint: string | number | 'default';
skip_disable_reporting?: boolean;
};
'bridge/response/group/members/add': {
device: string;
group: string;
endpoint: string | number | 'default';
};
'bridge/request/group/members/remove': {
device: string;
group: string;
endpoint: string | number | 'default';
skip_disable_reporting?: boolean;
};
'bridge/response/group/members/remove': {
device: string;
group: string;
endpoint: string | number | 'default';
};
'bridge/request/group/members/remove_all': {
device: string;
endpoint: string | number | 'default';
skip_disable_reporting?: boolean;
};
'bridge/response/group/members/remove_all': {
device: string;
endpoint: string | number | 'default';
};
'bridge/request/touchlink/factory_reset':
| {
ieee_address: string;
channel: number;
}
| '';
'bridge/response/touchlink/factory_reset':
| {
ieee_address: string;
channel: number;
}
| Record<string, never>;
'bridge/request/touchlink/scan': '';
'bridge/response/touchlink/scan': {
found: {
ieee_address: string;
channel: number;
}[];
};
'bridge/request/touchlink/identify': {
ieee_address: string;
channel: number;
};
'bridge/response/touchlink/identify': {
ieee_address: string;
channel: number;
};
/**
* entity state response
*/
'{friendlyName}': {
[key: string]: unknown;
};
'{friendlyName}/availability': {
state: 'online' | 'offline';
};
/** entity set request */
'{friendlyName}/set': {
[key: string]: unknown;
};
/** entity get request */
'{friendlyName}/get': {
[key: string]: unknown;
};
}
export type Zigbee2MQTTRequestEndpoints =
| 'bridge/request/permit_join'
| 'bridge/request/health_check'
| 'bridge/request/coordinator_check'
| 'bridge/request/restart'
| 'bridge/request/networkmap'
| 'bridge/request/extension/save'
| 'bridge/request/extension/remove'
| 'bridge/request/converter/save'
| 'bridge/request/converter/remove'
| 'bridge/request/backup'
| 'bridge/request/install_code/add'
| 'bridge/request/options'
| 'bridge/request/device/bind'
| 'bridge/request/device/unbind'
| 'bridge/request/device/configure'
| 'bridge/request/device/remove'
| 'bridge/request/device/ota_update/check'
| 'bridge/request/device/ota_update/check/downgrade'
| 'bridge/request/device/ota_update/update'
| 'bridge/request/device/ota_update/update/downgrade'
| 'bridge/request/device/interview'
| 'bridge/request/device/generate_external_definition'
| 'bridge/request/device/options'
| 'bridge/request/device/rename'
| 'bridge/request/device/configure_reporting'
| 'bridge/request/group/remove'
| 'bridge/request/group/add'
| 'bridge/request/group/rename'
| 'bridge/request/group/options'
| 'bridge/request/group/members/add'
| 'bridge/request/group/members/remove'
| 'bridge/request/group/members/remove_all'
| 'bridge/request/touchlink/factory_reset'
| 'bridge/request/touchlink/scan'
| 'bridge/request/touchlink/identify';
export type Zigbee2MQTTResponseEndpoints =
| 'bridge/response/permit_join'
| 'bridge/response/health_check'
| 'bridge/response/coordinator_check'
| 'bridge/response/restart'
| 'bridge/response/networkmap'
| 'bridge/response/extension/save'
| 'bridge/response/extension/remove'
| 'bridge/response/converter/save'
| 'bridge/response/converter/remove'
| 'bridge/response/backup'
| 'bridge/response/install_code/add'
| 'bridge/response/options'
| 'bridge/response/device/bind'
| 'bridge/response/device/unbind'
| 'bridge/response/device/configure'
| 'bridge/response/device/remove'
| 'bridge/response/device/ota_update/check'
| 'bridge/response/device/ota_update/check'
| 'bridge/response/device/ota_update/update'
| 'bridge/response/device/ota_update/update'
| 'bridge/response/device/interview'
| 'bridge/response/device/generate_external_definition'
| 'bridge/response/device/options'
| 'bridge/response/device/rename'
| 'bridge/response/device/configure_reporting'
| 'bridge/response/group/remove'
| 'bridge/response/group/add'
| 'bridge/response/group/rename'
| 'bridge/response/group/options'
| 'bridge/response/group/members/add'
| 'bridge/response/group/members/remove'
| 'bridge/response/group/members/remove_all'
| 'bridge/response/touchlink/factory_reset'
| 'bridge/response/touchlink/scan'
| 'bridge/response/touchlink/identify';
export type Zigbee2MQTTRequest<T extends Zigbee2MQTTRequestEndpoints> = {
transaction?: string;
} & Zigbee2MQTTAPI[T];
export type Zigbee2MQTTResponseOK<T extends Zigbee2MQTTResponseEndpoints> = {
status: 'ok';
data: Zigbee2MQTTAPI[T];
transaction?: string;
};
export type Zigbee2MQTTResponseError = {
status: 'error';
data: Record<string, never>;
error: string;
transaction?: string;
};
export type Zigbee2MQTTResponse<T extends Zigbee2MQTTResponseEndpoints> = Zigbee2MQTTResponseOK<T> | Zigbee2MQTTResponseError;
-3
View File
@@ -1,3 +0,0 @@
declare module 'mkdir-recursive' {
export function mkdirSync(path: string): void;
}
+173 -199
View File
@@ -1,39 +1,16 @@
/* eslint-disable camelcase */
import type {
Device as ZHDevice,
Group as ZHGroup,
Endpoint as ZHEndpoint,
} from 'zigbee-herdsman/dist/controller/model';
import type {
NetworkParameters as ZHNetworkParameters,
CoordinatorVersion as ZHCoordinatorVersion,
LQI as ZHLQI,
RoutingTable as ZHRoutingTable,
RoutingTableEntry as ZHRoutingTableEntry,
} from 'zigbee-herdsman/dist/adapter/tstype';
import type {
Cluster as ZHCluster,
} from 'zigbee-herdsman/dist/zcl/tstype';
import type {
FrameControl as ZHFrameControl,
} from 'zigbee-herdsman/dist/zcl';
import type * as zhc from 'zigbee-herdsman-converters';
import type * as ZHEvents from 'zigbee-herdsman/dist/controller/events';
import type TypeEventBus from 'lib/eventBus';
import type TypeExtension from 'lib/extension/extension';
import type TypeDevice from 'lib/model/device';
import type TypeGroup from 'lib/model/group';
import type TypeMQTT from 'lib/mqtt';
import type TypeState from 'lib/state';
import type TypeZigbee from 'lib/zigbee';
import type TypeDevice from 'lib/model/device';
import type TypeGroup from 'lib/model/group';
import type TypeExtension from 'lib/extension/extension';
import type {AdapterTypes as ZHAdapterTypes, Events as ZHEvents, Models as ZHModels} from 'zigbee-herdsman';
import type {Cluster as ZHCluster, FrameControl as ZHFrameControl} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype';
import type {QoS} from 'mqtt-packet';
import {LogLevel} from 'lib/util/settings';
type OptionalProps<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
declare global {
// Define some class types as global
@@ -46,57 +23,56 @@ declare global {
type Extension = TypeExtension;
// Types
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type ExternalDefinition = zhc.Definition & {homeassistant: any};
interface MQTTResponse {data: KeyValue, status: 'error' | 'ok', error?: string, transaction?: string}
interface MQTTOptions {qos?: QoS, retain?: boolean, properties?: {messageExpiryInterval: number}}
type Scene = {id: number, name: string};
type StateChangeReason = 'publishDebounce' | 'groupOptimistic' | 'lastSeenChanged' | 'publishCached';
type PublishEntityState = (entity: Device | Group, payload: KeyValue,
stateChangeReason?: StateChangeReason) => Promise<void>;
type RecursivePartial<T> = {[P in keyof T]?: RecursivePartial<T[P]>;};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
interface KeyValue {[s: string]: any}
type StateChangeReason = 'publishDebounce' | 'groupOptimistic' | 'lastSeenChanged' | 'publishCached' | 'publishThrottle';
type PublishEntityState = (entity: Device | Group, payload: KeyValue, stateChangeReason?: StateChangeReason) => Promise<void>;
type RecursivePartial<T> = {[P in keyof T]?: RecursivePartial<T[P]>};
interface KeyValue {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[s: string]: any;
}
// zigbee-herdsman
namespace zh {
type Endpoint = ZHEndpoint;
type Device = ZHDevice;
type Group = ZHGroup;
type LQI = ZHLQI;
type RoutingTable = ZHRoutingTable;
type RoutingTableEntry = ZHRoutingTableEntry;
type CoordinatorVersion = ZHCoordinatorVersion;
type NetworkParameters = ZHNetworkParameters;
type Cluster = ZHCluster;
type Endpoint = ZHModels.Endpoint;
type Device = ZHModels.Device;
type Group = ZHModels.Group;
type LQI = ZHAdapterTypes.LQI;
type RoutingTable = ZHAdapterTypes.RoutingTable;
type CoordinatorVersion = ZHAdapterTypes.CoordinatorVersion;
type NetworkParameters = ZHAdapterTypes.NetworkParameters;
interface Bind {
cluster: zh.Cluster;
cluster: ZHCluster;
target: zh.Endpoint | zh.Group;
}
}
namespace eventdata {
type EntityRenamed = { entity: Device | Group, homeAssisantRename: boolean, from: string, to: string };
type DeviceRemoved = { ieeeAddr: string, name: string };
type MQTTMessage = { topic: string, message: string };
type MQTTMessagePublished = { topic: string, payload: string, options: {retain: boolean, qos: number} };
type EntityRenamed = {entity: Device | Group; homeAssisantRename: boolean; from: string; to: string};
type EntityRemoved = {id: number | string; name: string; type: 'device' | 'group'};
type MQTTMessage = {topic: string; message: string};
type MQTTMessagePublished = {topic: string; payload: string; options: {retain: boolean; qos: number}};
type StateChange = {
entity: Device | Group, from: KeyValue, to: KeyValue, reason: string | null, update: KeyValue };
entity: Device | Group;
from: KeyValue;
to: KeyValue;
reason?: string;
update: KeyValue;
};
type PermitJoinChanged = ZHEvents.PermitJoinChangedPayload;
type LastSeenChanged = { device: Device,
reason: 'deviceAnnounce' | 'networkAddress' | 'deviceJoined' | 'messageEmitted' | 'messageNonEmitted'; };
type DeviceNetworkAddressChanged = { device: Device };
type DeviceAnnounce = { device: Device };
type DeviceInterview = { device: Device, status: 'started' | 'successful' | 'failed' };
type DeviceJoined = { device: Device };
type EntityOptionsChanged = { entity: Device | Group, from: KeyValue, to: KeyValue };
type ExposesChanged = { device: Device };
type Reconfigure = { device: Device };
type DeviceLeave = { ieeeAddr: string, name: string };
type GroupMembersChanged = {group: Group, action: 'remove' | 'add' | 'remove_all',
endpoint: zh.Endpoint, skipDisableReporting: boolean };
type PublishEntityState = {entity: Group | Device, message: KeyValue, stateChangeReason: StateChangeReason,
payload: KeyValue};
type LastSeenChanged = {
device: Device;
reason: 'deviceAnnounce' | 'networkAddress' | 'deviceJoined' | 'messageEmitted' | 'messageNonEmitted';
};
type DeviceNetworkAddressChanged = {device: Device};
type DeviceAnnounce = {device: Device};
type DeviceInterview = {device: Device; status: 'started' | 'successful' | 'failed'};
type DeviceJoined = {device: Device};
type EntityOptionsChanged = {entity: Device | Group; from: KeyValue; to: KeyValue};
type ExposesChanged = {device: Device};
type Reconfigure = {device: Device};
type DeviceLeave = {ieeeAddr: string; name: string};
type GroupMembersChanged = {group: Group; action: 'remove' | 'add' | 'remove_all'; endpoint: zh.Endpoint; skipDisableReporting: boolean};
type PublishEntityState = {entity: Group | Device; message: KeyValue; stateChangeReason?: StateChangeReason; payload: KeyValue};
type DeviceMessage = {
type: ZHEvents.MessagePayloadType;
device: Device;
@@ -105,154 +81,152 @@ declare global {
groupID: number;
cluster: string | number;
data: KeyValue | Array<string | number>;
meta: {zclTransactionSequenceNumber?: number; manufacturerCode?: number; frameControl?: ZHFrameControl;};
meta: {zclTransactionSequenceNumber?: number; manufacturerCode?: number; frameControl?: ZHFrameControl};
};
type ScenesChanged = { entity: Device | Group };
type ScenesChanged = {entity: Device | Group};
}
// Settings
// eslint-disable camelcase
interface Settings {
homeassistant?: {
discovery_topic: string,
status_topic: string,
legacy_entity_attributes: boolean,
legacy_triggers: boolean,
},
permit_join?: boolean,
availability?: {
active: {timeout: number},
passive: {timeout: number}
},
external_converters: string[],
version?: number;
homeassistant: {
enabled: boolean;
discovery_topic: string;
status_topic: string;
experimental_event_entities: boolean;
legacy_action_sensor: boolean;
};
availability: {
enabled: boolean;
active: {timeout: number};
passive: {timeout: number};
};
mqtt: {
base_topic: string,
include_device_information: boolean,
force_disable_retain: boolean
version?: 3 | 4 | 5,
user?: string,
password?: string,
server: string,
ca?: string,
keepalive?: number,
key?: string,
cert?: string,
client_id?: string,
reject_unauthorized?: boolean,
},
base_topic: string;
include_device_information: boolean;
force_disable_retain: boolean;
version?: 3 | 4 | 5;
user?: string;
password?: string;
server: string;
ca?: string;
keepalive?: number;
key?: string;
cert?: string;
client_id?: string;
reject_unauthorized?: boolean;
maximum_packet_size: number;
};
serial: {
disable_led: boolean,
port?: string,
adapter?: 'deconz' | 'zstack' | 'ezsp' | 'zigate',
baudrate?: number,
rtscts?: boolean,
},
passlist: string[],
blocklist: string[],
disable_led: boolean;
port?: string;
adapter?: 'deconz' | 'zstack' | 'ezsp' | 'zigate' | 'ember';
baudrate?: number;
rtscts?: boolean;
};
passlist: string[];
blocklist: string[];
map_options: {
graphviz: {
colors: {
fill: {
enddevice: string,
coordinator: string,
router: string,
},
enddevice: string;
coordinator: string;
router: string;
};
font: {
coordinator: string,
router: string,
enddevice: string,
},
coordinator: string;
router: string;
enddevice: string;
};
line: {
active: string,
inactive: string,
},
},
},
},
active: string;
inactive: string;
};
};
};
};
ota: {
update_check_interval: number,
disable_automatic_update_check: boolean,
zigbee_ota_override_index_location?: string,
ikea_ota_use_test_url?: boolean,
},
frontend?: {
auth_token?: string,
host?: string,
port?: number,
url?: string,
ssl_cert?: string,
ssl_key?: string,
},
devices?: {[s: string]: DeviceOptions},
groups?: {[s: string]: GroupOptions},
device_options: KeyValue,
update_check_interval: number;
disable_automatic_update_check: boolean;
zigbee_ota_override_index_location?: string;
image_block_response_delay?: number;
default_maximum_data_size?: number;
};
frontend: {
enabled: boolean;
auth_token?: string;
host?: string;
port: number;
base_url: string;
url?: string;
ssl_cert?: string;
ssl_key?: string;
};
devices: {[s: string]: DeviceOptions};
groups: {[s: string]: Omit<GroupOptions, 'ID'>};
device_options: KeyValue;
advanced: {
legacy_api: boolean,
legacy_availability_payload: boolean,
log_rotation: boolean,
log_symlink_current: boolean,
log_output: ('console' | 'file' | 'syslog')[],
log_directory: string,
log_file: string,
log_level: 'debug' | 'info' | 'error' | 'warn',
log_syslog: KeyValue,
pan_id: number | 'GENERATE',
ext_pan_id: number[] | 'GENERATE',
channel: number,
adapter_concurrent: number | null,
adapter_delay: number | null,
cache_state: boolean,
cache_state_persistent: boolean,
cache_state_send_on_startup: boolean,
last_seen: 'disable' | 'ISO_8601' | 'ISO_8601_local' | 'epoch',
elapsed: boolean,
network_key: number[] | 'GENERATE',
timestamp_format: string,
output: 'json' | 'attribute' | 'attribute_and_json',
transmit_power?: number,
// Everything below is deprecated
availability_timeout?: number,
availability_blocklist?: string[],
availability_passlist?: string[],
availability_blacklist?: string[],
availability_whitelist?: string[],
soft_reset_timeout: number,
report: boolean,
},
log_rotation: boolean;
log_console_json: boolean;
log_symlink_current: boolean;
log_output: ('console' | 'file' | 'syslog')[];
log_directory: string;
log_file: string;
log_level: LogLevel;
log_namespaced_levels: Record<string, LogLevel>;
log_syslog: KeyValue;
log_debug_to_mqtt_frontend: boolean;
log_debug_namespace_ignore: string;
pan_id: number | 'GENERATE';
ext_pan_id: number[] | 'GENERATE';
channel: number;
adapter_concurrent?: number;
adapter_delay?: number;
cache_state: boolean;
cache_state_persistent: boolean;
cache_state_send_on_startup: boolean;
last_seen: 'disable' | 'ISO_8601' | 'ISO_8601_local' | 'epoch';
elapsed: boolean;
network_key: number[] | 'GENERATE';
timestamp_format: string;
output: 'json' | 'attribute' | 'attribute_and_json';
transmit_power?: number;
};
}
interface DeviceOptions {
ID?: string,
disabled?: boolean,
retention?: number,
availability?: boolean | {timeout: number},
optimistic?: boolean,
retrieve_state?: boolean,
debounce?: number,
debounce_ignore?: string[],
filtered_attributes?: string[],
filtered_cache?: string[],
filtered_optimistic?: string[],
icon?: string,
homeassistant?: KeyValue,
legacy?: boolean,
friendly_name: string,
description?: string,
qos?: 0 | 1 | 2,
disabled?: boolean;
retention?: number;
availability?: boolean | {timeout: number};
optimistic?: boolean;
debounce?: number;
debounce_ignore?: string[];
throttle?: number;
filtered_attributes?: string[];
filtered_cache?: string[];
filtered_optimistic?: string[];
icon?: string;
homeassistant?: KeyValue;
friendly_name: string;
description?: string;
qos?: 0 | 1 | 2;
}
interface DeviceOptionsWithId extends DeviceOptions {
ID: string;
}
interface GroupOptions {
devices?: string[],
ID?: number,
optimistic?: boolean,
off_state?: 'all_members_off' | 'last_member_state'
filtered_attributes?: string[],
filtered_cache?: string[],
filtered_optimistic?: string[],
retrieve_state?: boolean,
homeassistant?: KeyValue,
friendly_name: string,
description?: string,
qos?: 0 | 1 | 2,
ID: number;
optimistic?: boolean;
off_state?: 'all_members_off' | 'last_member_state';
filtered_attributes?: string[];
filtered_cache?: string[];
filtered_optimistic?: string[];
homeassistant?: KeyValue;
friendly_name: string;
description?: string;
qos?: 0 | 1 | 2;
}
}
+11 -4
View File
@@ -2,8 +2,15 @@ declare module 'zigbee2mqtt-frontend' {
export function getPath(): string;
}
declare module 'connect-gzip-static' {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type RequestHandler = (req: any, res: any) => void;
export default function gzipStatic(root: string, options?: Record<string, unknown>): RequestHandler;
declare module 'http' {
interface IncomingMessage {
originalUrl?: string;
path?: string;
}
}
declare module 'express-static-gzip' {
import {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;
}
+7 -15
View File
@@ -1,17 +1,10 @@
import path from 'path';
import path from 'node:path';
let dataPath: string = null;
function load(): void {
if (process.env.ZIGBEE2MQTT_DATA) {
dataPath = process.env.ZIGBEE2MQTT_DATA;
} else {
dataPath = path.join(__dirname, '..', '..', 'data');
dataPath = path.normalize(dataPath);
}
function setPath(): string {
return process.env.ZIGBEE2MQTT_DATA ? process.env.ZIGBEE2MQTT_DATA : path.normalize(path.join(__dirname, '..', '..', 'data'));
}
load();
let dataPath = setPath();
function joinPath(file: string): string {
return path.resolve(dataPath, file);
@@ -21,9 +14,8 @@ function getPath(): string {
return dataPath;
}
// eslint-disable-next-line camelcase
function testingOnlyReload(): void {
load();
function _testReload(): void {
dataPath = setPath();
}
export default {joinPath, getPath, testingOnlyReload};
export default {joinPath, getPath, _testReload};
+248 -206
View File
@@ -1,225 +1,267 @@
import winston from 'winston';
import assert from 'node:assert';
import fs from 'node:fs';
import path from 'node:path';
import moment from 'moment';
import * as settings from './settings';
import path from 'path';
import fs from 'fs';
import fx from 'mkdir-recursive';
import {rimrafSync} from 'rimraf';
import assert from 'assert';
import winston from 'winston';
const colorizer = winston.format.colorize();
import * as settings from './settings';
type Z2MLogLevel = 'warn' | 'debug' | 'info' | 'error';
type WinstonLogLevel = 'warning' | 'debug' | 'info' | 'error';
const NAMESPACE_SEPARATOR = ':';
const z2mToWinstonLevel = (level: Z2MLogLevel): WinstonLogLevel => level === 'warn' ? 'warning' : level;
const winstonToZ2mLevel = (level: WinstonLogLevel): Z2MLogLevel => level === 'warning' ? 'warn' : level;
class Logger {
// @ts-expect-error initalized in `init`
private level: settings.LogLevel;
// @ts-expect-error initalized in `init`
private output: string[];
// @ts-expect-error initalized in `init`
private directory: string;
// @ts-expect-error initalized in `init`
private logger: winston.Logger;
// @ts-expect-error initalized in `init`
private fileTransport: winston.transports.FileTransportInstance;
private debugNamespaceIgnoreRegex?: RegExp;
// @ts-expect-error initalized in `init`
private namespacedLevels: Record<string, settings.LogLevel>;
// @ts-expect-error initalized in `init`
private cachedNamespacedLevels: Record<string, settings.LogLevel>;
const levelWithCompensatedLength: {[s: string]: string} = {
'info': 'info ',
'error': 'error',
'warn': 'warn ',
'debug': 'debug',
};
public init(): void {
// What transports to enable
this.output = settings.get().advanced.log_output;
// Directory to log to
const timestamp = moment(Date.now()).format('YYYY-MM-DD.HH-mm-ss');
this.directory = settings.get().advanced.log_directory.replace('%TIMESTAMP%', timestamp);
const logFilename = settings.get().advanced.log_file.replace('%TIMESTAMP%', timestamp);
this.level = settings.get().advanced.log_level;
this.namespacedLevels = settings.get().advanced.log_namespaced_levels;
this.cachedNamespacedLevels = Object.assign({}, this.namespacedLevels);
let logger: winston.Logger;
let fileTransport : winston.transport;
let output: string[];
let directory: string;
let logFilename: string;
let transportsToUse: winston.transport[];
assert(settings.LOG_LEVELS.includes(this.level), `'${this.level}' is not valid log_level, use one of '${settings.LOG_LEVELS.join(', ')}'`);
function init(): void {
// What transports to enable
output = settings.get().advanced.log_output;
const timestampFormat = (): string => moment().format(settings.get().advanced.timestamp_format);
// Directory to log to
const timestamp = moment(Date.now()).format('YYYY-MM-DD.HH-mm-ss');
directory = settings.get().advanced.log_directory.replace('%TIMESTAMP%', timestamp);
logFilename = settings.get().advanced.log_file.replace('%TIMESTAMP%', timestamp);
this.logger = winston.createLogger({
level: 'debug',
format: winston.format.combine(winston.format.errors({stack: true}), winston.format.timestamp({format: timestampFormat})),
levels: winston.config.syslog.levels,
});
// Make sure that log directory exists when not logging to stdout only
if (output.includes('file')) {
fx.mkdirSync(directory);
const consoleSilenced = !this.output.includes('console');
// Print to user what logging is active
let logging = `Logging to console${consoleSilenced ? ' (silenced)' : ''}`;
if (settings.get().advanced.log_symlink_current) {
const current = settings.get().advanced.log_directory.replace('%TIMESTAMP%', 'current');
const actual = './' + timestamp;
/* istanbul ignore next */
if (fs.existsSync(current)) {
fs.unlinkSync(current);
// Setup default console logger
this.logger.add(
new winston.transports.Console({
silent: consoleSilenced,
format: settings.get().advanced.log_console_json
? winston.format.json()
: winston.format.combine(
// winston.config.syslog.levels sets 'warning' as 'red'
winston.format.colorize({colors: {debug: 'blue', info: 'green', warning: 'yellow', error: 'red'}}),
winston.format.printf((info) => {
return `[${info.timestamp}] ${info.level}: \t${info.message}`;
}),
),
}),
);
if (this.output.includes('file')) {
logging += `, file (filename: ${logFilename})`;
// Make sure that log directory exists when not logging to stdout only
fs.mkdirSync(this.directory, {recursive: true});
if (settings.get().advanced.log_symlink_current) {
const current = settings.get().advanced.log_directory.replace('%TIMESTAMP%', 'current');
const actual = './' + timestamp;
/* v8 ignore start */
if (fs.existsSync(current)) {
fs.unlinkSync(current);
}
/* v8 ignore stop */
fs.symlinkSync(actual, current);
}
fs.symlinkSync(actual, current);
}
}
// Determine the log level.
const z2mLevel = settings.get().advanced.log_level;
const validLevels = ['info', 'error', 'warn', 'debug'];
assert(validLevels.includes(z2mLevel),
`'${z2mLevel}' is not valid log_level, use one of '${validLevels.join(', ')}'`);
const level = z2mToWinstonLevel(z2mLevel);
const timestampFormat = (): string => moment().format(settings.get().advanced.timestamp_format);
// Setup default console logger
transportsToUse = [
new winston.transports.Console({
level,
silent: !output.includes('console'),
format: winston.format.combine(
winston.format.timestamp({format: timestampFormat}),
winston.format.printf(/* istanbul ignore next */(info) => {
const {timestamp, level, message} = info;
const l = winstonToZ2mLevel(level as WinstonLogLevel);
const plainPrefix = `Zigbee2MQTT:${levelWithCompensatedLength[l]}`;
let prefix = plainPrefix;
if (process.stdout.isTTY) {
prefix = colorizer.colorize(l, plainPrefix);
}
return `${prefix} ${timestamp.split('.')[0]}: ${message}`;
// Add file logger when enabled
// NOTE: the initiation of the logger even when not added as transport tries to create the logging directory
const transportFileOptions: winston.transports.FileTransportOptions = {
filename: path.join(this.directory, logFilename),
format: winston.format.printf((info) => {
return `[${info.timestamp}] ${info.level}: \t${info.message}`;
}),
),
}),
];
};
// Add file logger when enabled
// NOTE: the initiation of the logger, even when not added as transport tries to create the logging directory
const transportFileOptions: KeyValue = {
filename: path.join(directory, logFilename),
json: false,
level,
format: winston.format.combine(
winston.format.timestamp({format: timestampFormat}),
winston.format.printf(/* istanbul ignore next */(info) => {
const {timestamp, level, message} = info;
const l = winstonToZ2mLevel(level as WinstonLogLevel);
return `${levelWithCompensatedLength[l]} ${timestamp.split('.')[0]}: ${message}`;
}),
),
};
if (settings.get().advanced.log_rotation) {
transportFileOptions.tailable = true;
transportFileOptions.maxFiles = 3; // Keep last 3 files
transportFileOptions.maxsize = 10000000; // 10MB
}
if (output.includes('file')) {
fileTransport = new winston.transports.File(transportFileOptions);
transportsToUse.push(fileTransport);
}
/* istanbul ignore next */
if (output.includes('syslog')) {
// eslint-disable-next-line
require('winston-syslog').Syslog;
const options: KeyValue = {
app_name: 'Zigbee2MQTT',
format: winston.format.printf(/* istanbul ignore next */(info) => {
return `${info.message}`;
}),
...settings.get().advanced.log_syslog,
};
if (options.hasOwnProperty('type')) options.type = options.type.toString();
// @ts-ignore
transportsToUse.push(new winston.transports.Syslog(options));
}
logger = winston.createLogger({transports: transportsToUse, levels: winston.config.syslog.levels});
}
// Cleanup any old log directory.
function cleanup(): void {
if (settings.get().advanced.log_directory.includes('%TIMESTAMP%')) {
const rootDirectory = path.join(directory, '..');
let directories = fs.readdirSync(rootDirectory).map((d) => {
d = path.join(rootDirectory, d);
return {path: d, birth: fs.statSync(d).mtime};
});
directories.sort((a: KeyValue, b: KeyValue) => b.birth - a.birth);
directories = directories.slice(10, directories.length);
directories.forEach((dir) => {
logger.debug(`Removing old log directory '${dir.path}'`);
rimrafSync(dir.path);
});
}
}
// Print to user what logging is enabled
function logOutput(): void {
if (output.includes('file')) {
if (output.includes('console')) {
logger.info(`Logging to console and directory: '${directory}' filename: ${logFilename}`);
} else {
logger.info(`Logging to directory: '${directory}' filename: ${logFilename}`);
}
cleanup();
} else if (output.includes('console')) {
logger.info(`Logging to console only'`);
}
}
function addTransport(transport: winston.transport): void {
transport.level = transportsToUse[0].level;
logger.add(transport);
}
function getLevel(): Z2MLogLevel {
return winstonToZ2mLevel(transportsToUse[0].level as WinstonLogLevel);
}
function setLevel(level: Z2MLogLevel): void {
logger.transports.forEach((transport) => transport.level = z2mToWinstonLevel(level as Z2MLogLevel));
}
function warn(message: string): void {
// winston.config.syslog.levels doesn't have warn, but is required for syslog.
logger.warning(message);
}
function warning(message: string): void {
logger.warning(message);
}
function info(message: string): void {
logger.info(message);
}
function debug(message: string): void {
logger.debug(message);
}
function error(message: string): void {
logger.error(message);
}
// Workaround for https://github.com/winstonjs/winston/issues/1629.
// https://github.com/Koenkk/zigbee2mqtt/pull/10905
/* istanbul ignore next */
async function end(): Promise<void> {
logger.end();
await new Promise<void>((resolve) => {
if (!fileTransport) {
process.nextTick(resolve);
} else {
// @ts-ignore
if (fileTransport._dest) {
// @ts-ignore
fileTransport._dest.on('finish', resolve);
} else {
// @ts-ignore
fileTransport.on('open', () => fileTransport._dest.on('finish', resolve));
if (settings.get().advanced.log_rotation) {
transportFileOptions.tailable = true;
transportFileOptions.maxFiles = 3; // Keep last 3 files
transportFileOptions.maxsize = 10000000; // 10MB
}
this.fileTransport = new winston.transports.File(transportFileOptions);
this.logger.add(this.fileTransport);
this.cleanup();
}
});
/* v8 ignore start */
if (this.output.includes('syslog')) {
logging += `, syslog`;
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unused-expressions
require('winston-syslog').Syslog;
const options: KeyValue = {
app_name: 'Zigbee2MQTT',
format: winston.format.printf((info) => info.message as string),
...settings.get().advanced.log_syslog,
};
if (options['type'] !== undefined) {
options.type = options.type.toString();
}
// @ts-expect-error untyped transport
this.logger.add(new winston.transports.Syslog(options));
}
/* v8 ignore stop */
this.setDebugNamespaceIgnore(settings.get().advanced.log_debug_namespace_ignore);
this.info(logging);
}
get winston(): winston.Logger {
return this.logger;
}
public addTransport(transport: winston.transport): void {
this.logger.add(transport);
}
public removeTransport(transport: winston.transport): void {
this.logger.remove(transport);
}
public getDebugNamespaceIgnore(): string {
return this.debugNamespaceIgnoreRegex?.toString().slice(1, -1) /* remove slashes */ ?? '';
}
public setDebugNamespaceIgnore(value: string): void {
this.debugNamespaceIgnoreRegex = value != '' ? new RegExp(value) : undefined;
}
public getLevel(): settings.LogLevel {
return this.level;
}
public setLevel(level: settings.LogLevel): void {
this.level = level;
this.resetCachedNamespacedLevels();
}
public getNamespacedLevels(): Record<string, settings.LogLevel> {
return this.namespacedLevels;
}
public setNamespacedLevels(nsLevels: Record<string, settings.LogLevel>): void {
this.namespacedLevels = nsLevels;
this.resetCachedNamespacedLevels();
}
private resetCachedNamespacedLevels(): void {
this.cachedNamespacedLevels = Object.assign({}, this.namespacedLevels);
}
private cacheNamespacedLevel(namespace: string): string {
let cached = namespace;
while (this.cachedNamespacedLevels[namespace] == undefined) {
const sep = cached.lastIndexOf(NAMESPACE_SEPARATOR);
if (sep === -1) {
return (this.cachedNamespacedLevels[namespace] = this.level);
}
cached = cached.slice(0, sep);
this.cachedNamespacedLevels[namespace] = this.cachedNamespacedLevels[cached];
}
return this.cachedNamespacedLevels[namespace];
}
private log(level: settings.LogLevel, messageOrLambda: string | (() => string), namespace: string): void {
const nsLevel = this.cacheNamespacedLevel(namespace);
if (settings.LOG_LEVELS.indexOf(level) <= settings.LOG_LEVELS.indexOf(nsLevel)) {
const message: string = messageOrLambda instanceof Function ? messageOrLambda() : messageOrLambda;
this.logger.log(level, `${namespace}: ${message}`);
}
}
public error(messageOrLambda: string | (() => string), namespace: string = 'z2m'): void {
this.log('error', messageOrLambda, namespace);
}
public warning(messageOrLambda: string | (() => string), namespace: string = 'z2m'): void {
this.log('warning', messageOrLambda, namespace);
}
public info(messageOrLambda: string | (() => string), namespace: string = 'z2m'): void {
this.log('info', messageOrLambda, namespace);
}
public debug(messageOrLambda: string | (() => string), namespace: string = 'z2m'): void {
if (this.debugNamespaceIgnoreRegex?.test(namespace)) {
return;
}
this.log('debug', messageOrLambda, namespace);
}
// Cleanup any old log directory.
private cleanup(): void {
if (settings.get().advanced.log_directory.includes('%TIMESTAMP%')) {
const rootDirectory = path.join(this.directory, '..');
let directories = fs.readdirSync(rootDirectory).map((d) => {
d = path.join(rootDirectory, d);
return {path: d, birth: fs.statSync(d).mtime};
});
directories.sort((a: KeyValue, b: KeyValue) => b.birth - a.birth);
directories = directories.slice(10, directories.length);
directories.forEach((dir) => {
this.debug(`Removing old log directory '${dir.path}'`);
rimrafSync(dir.path);
});
}
}
// Workaround for https://github.com/winstonjs/winston/issues/1629.
// https://github.com/Koenkk/zigbee2mqtt/pull/10905
/* v8 ignore start */
public async end(): Promise<void> {
// Only flush the file transport, don't end logger itself as log() might still be called
// causing a UnhandledPromiseRejection (`Error: write after end`). Flushing the file transport
// ensures the log files are written before stopping.
if (this.fileTransport) {
await new Promise<void>((resolve) => {
// @ts-expect-error workaround
if (this.fileTransport._dest) {
// @ts-expect-error workaround
this.fileTransport._dest.on('finish', resolve);
} else {
// @ts-expect-error workaround
this.fileTransport.on('open', () => this.fileTransport._dest.on('finish', resolve));
}
this.fileTransport.end();
});
}
}
/* v8 ignore stop */
}
export default {
init, logOutput, warn, warning, error, info, debug, setLevel, getLevel, cleanup, addTransport, end,
winston: (): winston.Logger => logger,
};
export default new Logger();
File diff suppressed because it is too large Load Diff
+223 -302
View File
@@ -1,47 +1,56 @@
import data from './data';
import utils from './utils';
import objectAssignDeep from 'object-assign-deep';
import path from 'path';
import yaml from './yaml';
import path from 'node:path';
import Ajv, {ValidateFunction} from 'ajv';
import objectAssignDeep from 'object-assign-deep';
import data from './data';
import schemaJson from './settings.schema.json';
export let schema = schemaJson;
// @ts-ignore
schema = {};
objectAssignDeep(schema, schemaJson);
import utils from './utils';
import yaml, {YAMLFileException} from './yaml';
// Remove legacy settings from schema
{
delete schema.properties.advanced.properties.homeassistant_discovery_topic;
delete schema.properties.advanced.properties.homeassistant_legacy_entity_attributes;
delete schema.properties.advanced.properties.homeassistant_legacy_triggers;
delete schema.properties.advanced.properties.homeassistant_status_topic;
delete schema.properties.advanced.properties.soft_reset_timeout;
delete schema.properties.advanced.properties.report;
delete schema.properties.advanced.properties.baudrate;
delete schema.properties.advanced.properties.rtscts;
delete schema.properties.advanced.properties.ikea_ota_use_test_url;
delete schema.properties.experimental;
delete schemaJson.properties.whitelist;
delete schemaJson.properties.ban;
}
export {schemaJson};
// When updating also update:
// - https://github.com/Koenkk/zigbee2mqtt/blob/dev/data/configuration.example.yaml#L2
// - https://github.com/zigbee2mqtt/hassio-zigbee2mqtt/blob/master/common/rootfs/docker-entrypoint.sh#L54
export const CURRENT_VERSION = 4;
/** NOTE: by order of priority, lower index is lower level (more important) */
export const LOG_LEVELS: readonly string[] = ['error', 'warning', 'info', 'debug'] as const;
export type LogLevel = 'error' | 'warning' | 'info' | 'debug';
// DEPRECATED ZIGBEE2MQTT_CONFIG: https://github.com/Koenkk/zigbee2mqtt/issues/4697
const file = process.env.ZIGBEE2MQTT_CONFIG ?? data.joinPath('configuration.yaml');
const CONFIG_FILE_PATH = data.joinPath('configuration.yaml');
const NULLABLE_SETTINGS = ['homeassistant'];
const ajvSetting = new Ajv({allErrors: true}).addKeyword('requiresRestart').compile(schemaJson);
const ajvRestartRequired = new Ajv({allErrors: true})
.addKeyword({keyword: 'requiresRestart', validate: (s: unknown) => !s}).compile(schemaJson);
const ajvRestartRequired = new Ajv({allErrors: true}).addKeyword({keyword: 'requiresRestart', validate: (s: unknown) => !s}).compile(schemaJson);
const ajvRestartRequiredDeviceOptions = new Ajv({allErrors: true})
.addKeyword({keyword: 'requiresRestart', validate: (s: unknown) => !s}).compile(schemaJson.definitions.device);
.addKeyword({keyword: 'requiresRestart', validate: (s: unknown) => !s})
.compile(schemaJson.definitions.device);
const ajvRestartRequiredGroupOptions = new Ajv({allErrors: true})
.addKeyword({keyword: 'requiresRestart', validate: (s: unknown) => !s}).compile(schemaJson.definitions.group);
const defaults: RecursivePartial<Settings> = {
permit_join: false,
external_converters: [],
.addKeyword({keyword: 'requiresRestart', validate: (s: unknown) => !s})
.compile(schemaJson.definitions.group);
export const defaults: RecursivePartial<Settings> = {
homeassistant: {
enabled: false,
discovery_topic: 'homeassistant',
status_topic: 'homeassistant/status',
legacy_action_sensor: false,
experimental_event_entities: false,
},
availability: {
enabled: false,
active: {timeout: 10},
passive: {timeout: 1500},
},
frontend: {
enabled: false,
port: 8080,
base_url: '/',
},
mqtt: {
base_topic: 'zigbee2mqtt',
include_device_information: false,
force_disable_retain: false,
// 1MB = roughly 3.5KB per device * 300 devices for `/bridge/devices`
maximum_packet_size: 1048576,
},
serial: {
disable_led: false,
@@ -71,23 +80,27 @@ const defaults: RecursivePartial<Settings> = {
ota: {
update_check_interval: 24 * 60,
disable_automatic_update_check: false,
image_block_response_delay: 250,
default_maximum_data_size: 50,
},
device_options: {},
advanced: {
legacy_api: true,
legacy_availability_payload: true,
log_rotation: true,
log_console_json: false,
log_symlink_current: false,
log_output: ['console', 'file'],
log_directory: path.join(data.getPath(), 'log', '%TIMESTAMP%'),
log_file: 'log.txt',
log_level: /* istanbul ignore next */ process.env.DEBUG ? 'debug' : 'info',
log_file: 'log.log',
log_level: /* v8 ignore next */ process.env.DEBUG ? 'debug' : 'info',
log_namespaced_levels: {},
log_syslog: {},
log_debug_to_mqtt_frontend: false,
log_debug_namespace_ignore: '',
pan_id: 0x1a62,
ext_pan_id: [0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD],
ext_pan_id: [0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd],
channel: 11,
adapter_concurrent: null,
adapter_delay: null,
adapter_concurrent: undefined,
adapter_delay: undefined,
cache_state: true,
cache_state_persistent: true,
cache_state_send_on_startup: true,
@@ -96,21 +109,18 @@ const defaults: RecursivePartial<Settings> = {
network_key: [1, 3, 5, 7, 9, 11, 13, 15, 0, 2, 4, 6, 8, 10, 12, 13],
timestamp_format: 'YYYY-MM-DD HH:mm:ss',
output: 'json',
// Everything below is deprecated
availability_blocklist: [],
availability_passlist: [],
availability_blacklist: [],
availability_whitelist: [],
soft_reset_timeout: 0,
report: false,
},
};
let _settings: Partial<Settings>;
let _settingsWithDefaults: Settings;
let _settings: Partial<Settings> | undefined;
let _settingsWithDefaults: Settings | undefined;
function loadSettingsWithDefaults(): void {
_settingsWithDefaults = objectAssignDeep({}, defaults, getInternalSettings()) as Settings;
if (!_settings) {
_settings = read();
}
_settingsWithDefaults = objectAssignDeep({}, defaults, getPersistedSettings()) as Settings;
if (!_settingsWithDefaults.devices) {
_settingsWithDefaults.devices = {};
@@ -119,78 +129,9 @@ function loadSettingsWithDefaults(): void {
if (!_settingsWithDefaults.groups) {
_settingsWithDefaults.groups = {};
}
if (_settingsWithDefaults.homeassistant) {
const defaults = {discovery_topic: 'homeassistant', status_topic: 'hass/status',
legacy_entity_attributes: true, legacy_triggers: true};
const sLegacy = {};
if (_settingsWithDefaults.advanced) {
for (const key of ['homeassistant_legacy_triggers', 'homeassistant_discovery_topic',
'homeassistant_legacy_entity_attributes', 'homeassistant_status_topic']) {
// @ts-ignore
if (_settingsWithDefaults.advanced[key] !== undefined) {
// @ts-ignore
sLegacy[key.replace('homeassistant_', '')] = _settingsWithDefaults.advanced[key];
}
}
}
const s = typeof _settingsWithDefaults.homeassistant === 'object' ? _settingsWithDefaults.homeassistant : {};
// @ts-ignore
_settingsWithDefaults.homeassistant = {};
objectAssignDeep(_settingsWithDefaults.homeassistant, defaults, sLegacy, s);
}
if (_settingsWithDefaults.availability || _settingsWithDefaults.advanced?.availability_timeout) {
const defaults = {};
const s = typeof _settingsWithDefaults.availability === 'object' ? _settingsWithDefaults.availability : {};
// @ts-ignore
_settingsWithDefaults.availability = {};
objectAssignDeep(_settingsWithDefaults.availability, defaults, s);
}
if (_settingsWithDefaults.frontend) {
const defaults = {port: 8080, auth_token: false};
const s = typeof _settingsWithDefaults.frontend === 'object' ? _settingsWithDefaults.frontend : {};
// @ts-ignore
_settingsWithDefaults.frontend = {};
objectAssignDeep(_settingsWithDefaults.frontend, defaults, s);
}
if (_settings.advanced?.hasOwnProperty('baudrate') && _settings.serial?.baudrate == null) {
// @ts-ignore
_settingsWithDefaults.serial.baudrate = _settings.advanced.baudrate;
}
if (_settings.advanced?.hasOwnProperty('rtscts') && _settings.serial?.rtscts == null) {
// @ts-ignore
_settingsWithDefaults.serial.rtscts = _settings.advanced.rtscts;
}
if (_settings.advanced?.hasOwnProperty('ikea_ota_use_test_url') && _settings.ota?.ikea_ota_use_test_url == null) {
// @ts-ignore
_settingsWithDefaults.ota.ikea_ota_use_test_url = _settings.advanced.ikea_ota_use_test_url;
}
// @ts-ignore
if (_settings.experimental?.hasOwnProperty('transmit_power') && _settings.advanced?.transmit_power == null) {
// @ts-ignore
_settingsWithDefaults.advanced.transmit_power = _settings.experimental.transmit_power;
}
// @ts-ignore
if (_settings.experimental?.hasOwnProperty('output') && _settings.advanced?.output == null) {
// @ts-ignore
_settingsWithDefaults.advanced.output = _settings.experimental.output;
}
// @ts-ignore
_settingsWithDefaults.ban && _settingsWithDefaults.blocklist.push(..._settingsWithDefaults.ban);
// @ts-ignore
_settingsWithDefaults.whitelist && _settingsWithDefaults.passlist.push(..._settingsWithDefaults.whitelist);
}
function parseValueRef(text: string): {filename: string, key: string} | null {
function parseValueRef(text: string): {filename: string; key: string} | null {
const match = /!(.*) (.*)/g.exec(text);
if (match) {
let filename = match[1];
@@ -205,11 +146,11 @@ function parseValueRef(text: string): {filename: string, key: string} | null {
}
function write(): void {
const settings = getInternalSettings();
const settings = getPersistedSettings();
const toWrite: KeyValue = objectAssignDeep({}, settings);
// Read settings to check if we have to split devices/groups into separate file.
const actual = yaml.read(file);
const actual = yaml.read(CONFIG_FILE_PATH);
// In case the setting is defined in a separate file (e.g. !secret network_key) update it there.
for (const path of [
@@ -236,10 +177,12 @@ function write(): void {
// If an array, only write to first file and only devices which are not in the other files.
if (Array.isArray(actual[type])) {
actual[type].filter((f: string, i: number) => i !== 0)
.map((f: string) => yaml.readIfExists(data.joinPath(f), {}))
.map((c: KeyValue) => Object.keys(c))
.forEach((k: string) => delete content[k]);
// skip i==0
for (let i = 1; i < actual[type].length; i++) {
for (const key in yaml.readIfExists(data.joinPath(actual[type][i]))) {
delete content[key];
}
}
}
yaml.writeIfChanged(data.joinPath(fileToWrite), content);
@@ -249,44 +192,55 @@ function write(): void {
writeDevicesOrGroups('devices');
writeDevicesOrGroups('groups');
yaml.writeIfChanged(file, toWrite);
yaml.writeIfChanged(CONFIG_FILE_PATH, toWrite);
_settings = read();
loadSettingsWithDefaults();
}
export function validate(): string[] {
try {
getInternalSettings();
getPersistedSettings();
} catch (error) {
if (error.name === 'YAMLException') {
return [
`Your YAML file: '${error.file}' is invalid ` +
`(use https://jsonformatter.org/yaml-validator to find and fix the issue)`,
];
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.message];
return [`${error}`];
}
if (!ajvSetting(_settings)) {
return ajvSetting.errors.map((v) => `${v.instancePath.substring(1)} ${v.message}`);
// When `ajvSetting()` return false it always has `errors`.
return ajvSetting.errors!.map((v) => `${v.instancePath.substring(1)} ${v.message}`);
}
const errors = [];
if (_settings.advanced && _settings.advanced.network_key && typeof _settings.advanced.network_key === 'string' &&
_settings.advanced.network_key !== 'GENERATE') {
if (
_settings.advanced &&
_settings.advanced.network_key &&
typeof _settings.advanced.network_key === 'string' &&
_settings.advanced.network_key !== 'GENERATE'
) {
errors.push(`advanced.network_key: should be array or 'GENERATE' (is '${_settings.advanced.network_key}')`);
}
if (_settings.advanced && _settings.advanced.pan_id && typeof _settings.advanced.pan_id === 'string' &&
_settings.advanced.pan_id !== 'GENERATE') {
if (
_settings.advanced &&
_settings.advanced.pan_id &&
typeof _settings.advanced.pan_id === 'string' &&
_settings.advanced.pan_id !== 'GENERATE'
) {
errors.push(`advanced.pan_id: should be number or 'GENERATE' (is '${_settings.advanced.pan_id}')`);
}
if (_settings.advanced && _settings.advanced.ext_pan_id && typeof _settings.advanced.ext_pan_id === 'string' &&
_settings.advanced.ext_pan_id !== 'GENERATE') {
if (
_settings.advanced &&
_settings.advanced.ext_pan_id &&
typeof _settings.advanced.ext_pan_id === 'string' &&
_settings.advanced.ext_pan_id !== 'GENERATE'
) {
errors.push(`advanced.ext_pan_id: should be array or 'GENERATE' (is '${_settings.advanced.ext_pan_id}')`);
}
@@ -296,12 +250,16 @@ export function validate(): string[] {
if (names.includes(e.friendly_name)) errors.push(`Duplicate friendly_name '${e.friendly_name}' found`);
errors.push(...utils.validateFriendlyName(e.friendly_name));
names.push(e.friendly_name);
if ('icon' in e && e.icon && !e.icon.startsWith('http://') && !e.icon.startsWith('https://') && !e.icon.startsWith('device_icons/')) {
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();
Object.values(settingsWithDefaults.devices).forEach((d) => check(d));
Object.values(settingsWithDefaults.groups).forEach((g) => check(g));
@@ -313,35 +271,22 @@ export function validate(): string[] {
}
}
const checkAvailabilityList = (list: string[], type: string): void => {
list.forEach((e) => {
if (!getDevice(e)) {
errors.push(`Non-existing entity '${e}' specified in '${type}'`);
}
});
};
checkAvailabilityList(settingsWithDefaults.advanced.availability_blacklist, 'availability_blacklist');
checkAvailabilityList(settingsWithDefaults.advanced.availability_whitelist, 'availability_whitelist');
checkAvailabilityList(settingsWithDefaults.advanced.availability_blocklist, 'availability_blocklist');
checkAvailabilityList(settingsWithDefaults.advanced.availability_passlist, 'availability_passlist');
return errors;
}
function read(): Settings {
const s = yaml.read(file) as Settings;
function read(): Partial<Settings> {
const s = yaml.read(CONFIG_FILE_PATH) as Partial<Settings>;
applyEnvironmentVariables(s);
// Read !secret MQTT username and password if set
// eslint-disable-next-line
const interpretValue = (value: any): any => {
const ref = parseValueRef(value);
if (ref) {
return yaml.read(data.joinPath(ref.filename))[ref.key];
} else {
return value;
const interpretValue = <T>(value: T): T => {
if (typeof value === 'string') {
const ref = parseValueRef(value);
if (ref) {
return yaml.read(data.joinPath(ref.filename))[ref.key];
}
}
return value;
};
if (s.mqtt?.user) {
@@ -367,12 +312,11 @@ function read(): Settings {
// Read devices/groups configuration from separate file if specified.
const readDevicesOrGroups = (type: 'devices' | 'groups'): void => {
if (typeof s[type] === 'string' || (Array.isArray(s[type]) && Array(s[type]).length > 0)) {
/* eslint-disable-line */ // @ts-ignore
const files: string[] = Array.isArray(s[type]) ? s[type] : [s[type]];
s[type] = {};
for (const file of files) {
const content = yaml.readIfExists(data.joinPath(file), {});
/* eslint-disable-line */ // @ts-ignore
const content = yaml.readIfExists(data.joinPath(file));
// @ts-expect-error noMutate not typed properly
s[type] = objectAssignDeep.noMutate(s[type], content);
}
}
@@ -386,35 +330,39 @@ function read(): Settings {
function applyEnvironmentVariables(settings: Partial<Settings>): void {
const iterate = (obj: KeyValue, path: string[]): void => {
Object.keys(obj).forEach((key) => {
for (const key in obj) {
if (key !== 'type') {
if (key !== 'properties' && obj[key]) {
const type = (obj[key].type || 'object').toString();
const envPart = path.reduce((acc, val) => `${acc}${val}_`, '');
const envVariableName = (`ZIGBEE2MQTT_CONFIG_${envPart}${key}`).toUpperCase();
if (process.env[envVariableName]) {
const envVariableName = `ZIGBEE2MQTT_CONFIG_${envPart}${key}`.toUpperCase();
const envVariable = process.env[envVariableName];
if (envVariable) {
const setting = path.reduce((acc, val) => {
/* eslint-disable-line */ // @ts-ignore
// @ts-expect-error ignore typing
acc[val] = acc[val] || {};
/* eslint-disable-line */ // @ts-ignore
// @ts-expect-error ignore typing
return acc[val];
}, settings);
if (type.indexOf('object') >= 0 || type.indexOf('array') >= 0) {
try {
setting[key] = JSON.parse(process.env[envVariableName]);
} catch (error) {
setting[key] = process.env[envVariableName];
setting[key as keyof Settings] = JSON.parse(envVariable);
} catch {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setting[key as keyof Settings] = envVariable as any;
}
} else if (type.indexOf('number') >= 0) {
/* eslint-disable-line */ // @ts-ignore
setting[key] = process.env[envVariableName] * 1;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setting[key as keyof Settings] = ((envVariable as unknown as number) * 1) as any;
} else if (type.indexOf('boolean') >= 0) {
setting[key] = process.env[envVariableName].toLowerCase() === 'true';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setting[key as keyof Settings] = (envVariable.toLowerCase() === 'true') as any;
} else {
/* istanbul ignore else */
if (type.indexOf('string') >= 0) {
setting[key] = process.env[envVariableName];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setting[key as keyof Settings] = envVariable as any;
}
}
}
@@ -422,18 +370,26 @@ function applyEnvironmentVariables(settings: Partial<Settings>): void {
if (typeof obj[key] === 'object' && obj[key]) {
const newPath = [...path];
if (key !== 'properties' && key !== 'oneOf' && !Number.isInteger(Number(key))) {
newPath.push(key);
}
iterate(obj[key], newPath);
}
}
});
}
};
iterate(schemaJson.properties, []);
}
function getInternalSettings(): Partial<Settings> {
/**
* Get the settings actually written in the yaml.
* Env vars are applied on top.
* Defaults merged on startup are not included.
*/
export function getPersistedSettings(): Partial<Settings> {
if (!_settings) {
_settings = read();
}
@@ -446,12 +402,12 @@ export function get(): Settings {
loadSettingsWithDefaults();
}
return _settingsWithDefaults;
return _settingsWithDefaults!;
}
export function set(path: string[], value: string | number | boolean | KeyValue): void {
/* eslint-disable-next-line */
let settings: any = getInternalSettings();
let settings: any = getPersistedSettings();
for (let i = 0; i < path.length; i++) {
const key = path[i];
@@ -469,50 +425,53 @@ export function set(path: string[], value: string | number | boolean | KeyValue)
write();
}
export function apply(newSettings: Record<string, unknown>): boolean {
export function apply(settings: Record<string, unknown>, throwOnError: boolean = true): boolean {
getPersistedSettings(); // Ensure _settings is initialized.
// @ts-expect-error noMutate not typed properly
const newSettings = objectAssignDeep.noMutate(_settings, settings);
utils.removeNullPropertiesFromObject(newSettings, NULLABLE_SETTINGS);
ajvSetting(newSettings);
const errors = ajvSetting.errors && ajvSetting.errors.filter((e) => e.keyword !== 'required');
if (errors.length) {
const error = errors[0];
throw new Error(`${error.instancePath.substring(1)} ${error.message}`);
if (throwOnError) {
const errors = ajvSetting.errors && ajvSetting.errors.filter((e) => e.keyword !== 'required');
if (errors?.length) {
const error = errors[0];
throw new Error(`${error.instancePath.substring(1)} ${error.message}`);
}
}
getInternalSettings(); // Ensure _settings is initialized.
/* eslint-disable-line */ // @ts-ignore
_settings = objectAssignDeep.noMutate(_settings, newSettings);
_settings = newSettings;
write();
ajvRestartRequired(newSettings);
const restartRequired = ajvRestartRequired.errors &&
!!ajvRestartRequired.errors.find((e) => e.keyword === 'requiresRestart');
ajvRestartRequired(settings);
const restartRequired = Boolean(ajvRestartRequired.errors && !!ajvRestartRequired.errors.find((e) => e.keyword === 'requiresRestart'));
return restartRequired;
}
export function getGroup(IDorName: string | number): GroupOptions {
export function getGroup(IDorName: string | number): GroupOptions | undefined {
const settings = get();
const byID = settings.groups[IDorName];
if (byID) {
return {devices: [], ...byID, ID: Number(IDorName)};
return {...byID, ID: Number(IDorName)};
}
for (const [ID, group] of Object.entries(settings.groups)) {
if (group.friendly_name === IDorName) {
return {devices: [], ...group, ID: Number(ID)};
return {...group, ID: Number(ID)};
}
}
return null;
}
export function getGroups(): GroupOptions[] {
const settings = get();
return Object.entries(settings.groups).map(([ID, group]) => {
return {devices: [], ...group, ID: Number(ID)};
});
return undefined;
}
function getGroupThrowIfNotExists(IDorName: string): GroupOptions {
const group = getGroup(IDorName);
if (!group) {
throw new Error(`Group '${IDorName}' does not exist`);
}
@@ -520,9 +479,10 @@ function getGroupThrowIfNotExists(IDorName: string): GroupOptions {
return group;
}
export function getDevice(IDorName: string): DeviceOptions {
export function getDevice(IDorName: string): DeviceOptionsWithId | undefined {
const settings = get();
const byID = settings.devices[IDorName];
if (byID) {
return {...byID, ID: IDorName};
}
@@ -533,10 +493,10 @@ export function getDevice(IDorName: string): DeviceOptions {
}
}
return null;
return undefined;
}
function getDeviceThrowIfNotExists(IDorName: string): DeviceOptions {
function getDeviceThrowIfNotExists(IDorName: string): DeviceOptionsWithId {
const device = getDevice(IDorName);
if (!device) {
throw new Error(`Device '${IDorName}' does not exist`);
@@ -545,12 +505,12 @@ function getDeviceThrowIfNotExists(IDorName: string): DeviceOptions {
return device;
}
export function addDevice(ID: string): DeviceOptions {
export function addDevice(ID: string): DeviceOptionsWithId {
if (getDevice(ID)) {
throw new Error(`Device '${ID}' already exists`);
}
const settings = getInternalSettings();
const settings = getPersistedSettings();
if (!settings.devices) {
settings.devices = {};
@@ -558,25 +518,12 @@ export function addDevice(ID: string): DeviceOptions {
settings.devices[ID] = {friendly_name: ID};
write();
return getDevice(ID);
}
export function addDeviceToPasslist(ID: string): void {
const settings = getInternalSettings();
if (!settings.passlist) {
settings.passlist = [];
}
if (settings.passlist.includes(ID)) {
throw new Error(`Device '${ID}' already in passlist`);
}
settings.passlist.push(ID);
write();
return getDevice(ID)!; // valid from creation above
}
export function blockDevice(ID: string): void {
const settings = getInternalSettings();
const settings = getPersistedSettings();
if (!settings.blocklist) {
settings.blocklist = [];
}
@@ -587,42 +534,35 @@ export function blockDevice(ID: string): void {
export function removeDevice(IDorName: string): void {
const device = getDeviceThrowIfNotExists(IDorName);
const settings = getInternalSettings();
delete settings.devices[device.ID];
// Remove device from groups
if (settings.groups) {
const regex =
new RegExp(`^(${device.friendly_name}|${device.ID})(/(\\d|${utils.endpointNames.join('|')}))?$`);
for (const group of Object.values(settings.groups).filter((g) => g.devices)) {
group.devices = group.devices.filter((device) => !device.match(regex));
}
}
const settings = getPersistedSettings();
delete settings.devices?.[device.ID];
write();
}
export function addGroup(name: string, ID?: string): GroupOptions {
utils.validateFriendlyName(name, true);
if (getGroup(name) || getDevice(name)) {
throw new Error(`friendly_name '${name}' is already in use`);
}
const settings = getInternalSettings();
const settings = getPersistedSettings();
if (!settings.groups) {
settings.groups = {};
}
if (ID == null) {
if (ID == undefined) {
// look for free ID
ID = '1';
while (settings.groups.hasOwnProperty(ID)) {
while (settings.groups[ID]) {
ID = (Number.parseInt(ID) + 1).toString();
}
} else {
// ensure provided ID is not in use
ID = ID.toString();
if (settings.groups.hasOwnProperty(ID)) {
if (settings.groups[ID]) {
throw new Error(`Group ID '${ID}' is already in use`);
}
}
@@ -630,71 +570,45 @@ export function addGroup(name: string, ID?: string): GroupOptions {
settings.groups[ID] = {friendly_name: name};
write();
return getGroup(ID);
}
function groupGetDevice(group: {devices?: string[]}, keys: string[]): string {
for (const device of group.devices ?? []) {
if (keys.includes(device)) return device;
}
return null;
}
export function addDeviceToGroup(IDorName: string, keys: string[]): void {
const groupID = getGroupThrowIfNotExists(IDorName).ID;
const settings = getInternalSettings();
const group = settings.groups[groupID];
if (!groupGetDevice(group, keys)) {
if (!group.devices) group.devices = [];
group.devices.push(keys[0]);
write();
}
}
export function removeDeviceFromGroup(IDorName: string, keys: string[]): void {
const groupID = getGroupThrowIfNotExists(IDorName).ID;
const settings = getInternalSettings();
const group = settings.groups[groupID];
if (!group.devices) {
return;
}
const key = groupGetDevice(group, keys);
if (key) {
group.devices = group.devices.filter((d) => d != key);
write();
}
return getGroup(ID)!; // valid from creation above
}
export function removeGroup(IDorName: string | number): void {
const groupID = getGroupThrowIfNotExists(IDorName.toString()).ID;
const settings = getInternalSettings();
delete settings.groups[groupID];
const groupID = getGroupThrowIfNotExists(IDorName.toString()).ID!;
const settings = getPersistedSettings();
delete settings.groups![groupID];
write();
}
export function changeEntityOptions(IDorName: string, newOptions: KeyValue): boolean {
const settings = getInternalSettings();
const settings = getPersistedSettings();
delete newOptions.friendly_name;
delete newOptions.devices;
let validator: ValidateFunction;
if (getDevice(IDorName)) {
objectAssignDeep(settings.devices[getDevice(IDorName).ID], newOptions);
utils.removeNullPropertiesFromObject(settings.devices[getDevice(IDorName).ID]);
const device = getDevice(IDorName);
if (device) {
objectAssignDeep(settings.devices![device.ID], newOptions);
utils.removeNullPropertiesFromObject(settings.devices![device.ID], NULLABLE_SETTINGS);
validator = ajvRestartRequiredDeviceOptions;
} else if (getGroup(IDorName)) {
objectAssignDeep(settings.groups[getGroup(IDorName).ID], newOptions);
utils.removeNullPropertiesFromObject(settings.groups[getGroup(IDorName).ID]);
validator = ajvRestartRequiredGroupOptions;
} else {
throw new Error(`Device or group '${IDorName}' does not exist`);
const group = getGroup(IDorName);
if (group) {
objectAssignDeep(settings.groups![group.ID], newOptions);
utils.removeNullPropertiesFromObject(settings.groups![group.ID], NULLABLE_SETTINGS);
validator = ajvRestartRequiredGroupOptions;
} else {
throw new Error(`Device or group '${IDorName}' does not exist`);
}
}
write();
validator(newOptions);
const restartRequired = validator.errors && !!validator.errors.find((e) => e.keyword === 'requiresRestart');
const restartRequired = Boolean(validator.errors && !!validator.errors.find((e) => e.keyword === 'requiresRestart'));
return restartRequired;
}
@@ -704,30 +618,37 @@ export function changeFriendlyName(IDorName: string, newName: string): void {
throw new Error(`friendly_name '${newName}' is already in use`);
}
const settings = getInternalSettings();
if (getDevice(IDorName)) {
settings.devices[getDevice(IDorName).ID].friendly_name = newName;
} else if (getGroup(IDorName)) {
settings.groups[getGroup(IDorName).ID].friendly_name = newName;
const settings = getPersistedSettings();
const device = getDevice(IDorName);
if (device) {
settings.devices![device.ID].friendly_name = newName;
} else {
throw new Error(`Device or group '${IDorName}' does not exist`);
const group = getGroup(IDorName);
if (group) {
settings.groups![group.ID].friendly_name = newName;
} else {
throw new Error(`Device or group '${IDorName}' does not exist`);
}
}
write();
}
export function reRead(): void {
_settings = null;
getInternalSettings();
_settingsWithDefaults = null;
_settings = undefined;
getPersistedSettings();
_settingsWithDefaults = undefined;
get();
}
export const testing = {
write,
clear: (): void => {
_settings = null;
_settingsWithDefaults = null;
_settings = undefined;
_settingsWithDefaults = undefined;
},
defaults,
CURRENT_VERSION,
};
+586
View File
@@ -0,0 +1,586 @@
import {copyFileSync, writeFileSync} from 'node:fs';
import data from './data';
import * as settings from './settings';
import utils from './utils';
interface SettingsMigration {
path: string[];
note: string;
noteIf?: (previousValue: unknown) => boolean;
}
interface SettingsAdd extends Omit<SettingsMigration, 'noteIf'> {
value: unknown;
}
type SettingsRemove = SettingsMigration;
interface SettingsChange extends SettingsMigration {
previousValueAnyOf?: unknown[];
newValue: unknown;
}
interface SettingsTransfer extends SettingsMigration {
newPath: string[];
}
interface SettingsCustomHandler extends Omit<SettingsMigration, 'path'> {
execute: (currentSettings: Partial<Settings>) => [validPath: boolean, previousValue: unknown, changed: boolean];
}
const SUPPORTED_VERSIONS: Settings['version'][] = [undefined, 2, 3, settings.CURRENT_VERSION];
function backupSettings(version: number): void {
const filePath = data.joinPath('configuration.yaml');
copyFileSync(filePath, filePath.replace('.yaml', `_backup_v${version}.yaml`));
}
/**
* Set the given path in given settings to given value. If requested, create path.
*
* @param currentSettings
* @param path
* @param value
* @param createPathIfNotExist
* @returns Returns true if value was set, false if not.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function setValue(currentSettings: any, path: string[], value: unknown, createPathIfNotExist: boolean = false): boolean {
for (let i = 0; i < path.length; i++) {
const key = path[i];
if (i === path.length - 1) {
currentSettings[key] = value;
} else {
if (!currentSettings[key]) {
if (createPathIfNotExist) {
currentSettings[key] = {};
/* v8 ignore start */
} else {
// invalid path
// ignored in test since currently call is always guarded by get-validated path, so this is never reached
return false;
}
/* v8 ignore stop */
}
currentSettings = currentSettings[key];
}
}
return true;
}
/**
* Get the value at the given path in given settings.
*
* @param currentSettings
* @param path
* @returns
* - true if path was valid
* - the value at path
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function getValue(currentSettings: any, path: string[]): [validPath: boolean, value: unknown] {
for (let i = 0; i < path.length; i++) {
const key = path[i];
const value = currentSettings[key];
if (i === path.length - 1) {
return [value !== undefined, value];
} else {
if (!value) {
// invalid path
break;
}
currentSettings = value;
}
}
return [false, undefined];
}
/**
* Add a value at given path, path is created as needed.
* @param currentSettings
* @param addition
*/
function addValue(currentSettings: Partial<Settings>, addition: SettingsAdd): void {
setValue(currentSettings, addition.path, addition.value, true);
}
/**
* Remove value at given path, if path is valid.
* Value is actually set to undefined, which triggers removal when `settings.apply` is called.
* @param currentSettings
* @param removal
* @returns
*/
function removeValue(currentSettings: Partial<Settings>, removal: SettingsRemove): [validPath: boolean, previousValue: unknown] {
const [validPath, previousValue] = getValue(currentSettings, removal.path);
if (validPath && previousValue != undefined) {
setValue(currentSettings, removal.path, undefined);
}
return [validPath, previousValue];
}
/**
* Change value at given path, if path is valid, and value matched one of the defined values (if any).
* @param currentSettings
* @param change
* @returns
*/
function changeValue(currentSettings: Partial<Settings>, change: SettingsChange): [validPath: boolean, previousValue: unknown, changed: boolean] {
const [validPath, previousValue] = getValue(currentSettings, change.path);
let changed: boolean = false;
if (validPath && previousValue !== change.newValue) {
if (!change.previousValueAnyOf || change.previousValueAnyOf.includes(previousValue)) {
setValue(currentSettings, change.path, change.newValue);
changed = true;
}
}
return [validPath, previousValue, changed];
}
/**
* Transfer value at given path, to new path.
* Given path must be valid.
* New path must not be valid or new path value must be nullish, otherwise given path is removed only.
* Value at given path is actually set to undefined, which triggers removal when `settings.apply` is called.
* New path is created as needed.
* @param currentSettings
* @param transfer
* @returns
*/
function transferValue(
currentSettings: Partial<Settings>,
transfer: SettingsTransfer,
): [validPath: boolean, previousValue: unknown, transfered: boolean] {
const [validPath, previousValue] = getValue(currentSettings, transfer.path);
const [destValidPath, destValue] = getValue(currentSettings, transfer.newPath);
const transfered = validPath && previousValue != undefined && (!destValidPath || destValue == undefined || Array.isArray(destValue));
// no point in set if already undefined
if (validPath && previousValue != undefined) {
setValue(currentSettings, transfer.path, undefined);
}
if (transfered) {
if (Array.isArray(previousValue) && Array.isArray(destValue)) {
setValue(currentSettings, transfer.newPath, [...previousValue, ...destValue], true);
} else {
setValue(currentSettings, transfer.newPath, previousValue, true);
}
}
return [validPath, previousValue, transfered];
}
const noteIfWasTrue = (previousValue: unknown): boolean => previousValue === true;
const noteIfWasDefined = (previousValue: unknown): boolean => previousValue != undefined;
const noteIfWasNonEmptyArray = (previousValue: unknown): boolean => Array.isArray(previousValue) && previousValue.length > 0;
function migrateToTwo(
currentSettings: Partial<Settings>,
transfers: SettingsTransfer[],
changes: SettingsChange[],
additions: SettingsAdd[],
removals: SettingsRemove[],
customHandlers: SettingsCustomHandler[],
): void {
transfers.push(
{
path: ['advanced', 'homeassistant_discovery_topic'],
note: `HA discovery_topic was moved from advanced.homeassistant_discovery_topic to homeassistant.discovery_topic.`,
noteIf: noteIfWasDefined,
newPath: ['homeassistant', 'discovery_topic'],
},
{
path: ['advanced', 'homeassistant_status_topic'],
note: `HA status_topic was moved from advanced.homeassistant_status_topic to homeassistant.status_topic.`,
noteIf: noteIfWasDefined,
newPath: ['homeassistant', 'status_topic'],
},
{
path: ['advanced', 'baudrate'],
note: `Baudrate was moved from advanced.baudrate to serial.baudrate.`,
noteIf: noteIfWasDefined,
newPath: ['serial', 'baudrate'],
},
{
path: ['advanced', 'rtscts'],
note: `RTSCTS was moved from advanced.rtscts to serial.rtscts.`,
noteIf: noteIfWasDefined,
newPath: ['serial', 'rtscts'],
},
{
path: ['experimental', 'transmit_power'],
note: `Transmit power was moved from experimental.transmit_power to advanced.transmit_power.`,
noteIf: noteIfWasDefined,
newPath: ['advanced', 'transmit_power'],
},
{
path: ['experimental', 'output'],
note: `Output was moved from experimental.output to advanced.output.`,
noteIf: noteIfWasDefined,
newPath: ['advanced', 'output'],
},
{
path: ['ban'],
note: `ban was renamed to passlist.`,
noteIf: noteIfWasDefined,
newPath: ['blocklist'],
},
{
path: ['whitelist'],
note: `whitelist was renamed to passlist.`,
noteIf: noteIfWasDefined,
newPath: ['passlist'],
},
);
changes.push({
path: ['advanced', 'log_level'],
note: `Log level 'warn' has been renamed to 'warning'.`,
noteIf: (previousValue): boolean => previousValue === 'warn',
previousValueAnyOf: ['warn'],
newValue: 'warning',
});
additions.push({
path: ['version'],
note: `Migrated settings to version 2`,
value: 2,
});
const haLegacyTriggers: SettingsRemove = {
path: ['homeassistant', 'legacy_triggers'],
note: `Action and click sensors have been removed (homeassistant.legacy_triggers setting). This means all sensor.*_action and sensor.*_click entities are removed. Use the MQTT device trigger instead.`,
noteIf: noteIfWasTrue,
};
const haLegacyEntityAttrs: SettingsRemove = {
path: ['homeassistant', 'legacy_entity_attributes'],
note: `Entity attributes (homeassistant.legacy_entity_attributes setting) has been removed. This means that entities discovered by Zigbee2MQTT will no longer have entity attributes (Home Assistant entity attributes are accessed via e.g. states.binary_sensor.my_sensor.attributes).`,
noteIf: noteIfWasTrue,
};
const otaIkeaUseTestUrl: SettingsRemove = {
path: ['ota', 'ikea_ota_use_test_url'],
note: `Due to the OTA rework, the ota.ikea_ota_use_test_url option has been removed.`,
noteIf: noteIfWasTrue,
};
removals.push(
haLegacyTriggers,
haLegacyEntityAttrs,
{
path: ['advanced', 'homeassistant_legacy_triggers'],
note: haLegacyTriggers.note,
noteIf: haLegacyTriggers.noteIf,
},
{
path: ['advanced', 'homeassistant_legacy_entity_attributes'],
note: haLegacyEntityAttrs.note,
noteIf: haLegacyEntityAttrs.noteIf,
},
{
path: ['permit_join'],
note: `The permit_join setting has been removed, use the frontend or MQTT to permit joining.`,
noteIf: noteIfWasTrue,
},
otaIkeaUseTestUrl,
{
path: ['advanced', 'ikea_ota_use_test_url'],
note: otaIkeaUseTestUrl.note,
noteIf: otaIkeaUseTestUrl.noteIf,
},
{
path: ['advanced', 'legacy_api'],
note: `The MQTT legacy API has been removed (advanced.legacy_api setting). See link below for affected topics.`,
noteIf: noteIfWasTrue,
},
{
path: ['advanced', 'legacy_availability_payload'],
note: `Due to the removal of advanced.legacy_availability_payload, zigbee2mqtt/bridge/state will now always be a JSON object ({"state":"online"} or {"state":"offline"})`,
noteIf: noteIfWasTrue,
},
{
path: ['advanced', 'soft_reset_timeout'],
note: `Removed deprecated: Soft reset feature (advanced.soft_reset_timeout setting)`,
noteIf: noteIfWasDefined,
},
{
path: ['advanced', 'report'],
note: `Removed deprecated: Report feature (advanced.report setting)`,
noteIf: noteIfWasTrue,
},
{
path: ['advanced', 'availability_timeout'],
note: `Removed deprecated: advanced.availability_timeout availability settings`,
noteIf: noteIfWasDefined,
},
{
path: ['advanced', 'availability_blocklist'],
note: `Removed deprecated: advanced.availability_blocklist availability settings`,
noteIf: noteIfWasNonEmptyArray,
},
{
path: ['advanced', 'availability_passlist'],
note: `Removed deprecated: advanced.availability_passlist availability settings`,
noteIf: noteIfWasNonEmptyArray,
},
{
path: ['advanced', 'availability_blacklist'],
note: `Removed deprecated: advanced.availability_blacklist availability settings`,
noteIf: noteIfWasNonEmptyArray,
},
{
path: ['advanced', 'availability_whitelist'],
note: `Removed deprecated: advanced.availability_whitelist availability settings`,
noteIf: noteIfWasNonEmptyArray,
},
{
path: ['device_options', 'legacy'],
note: `Removed everything that was enabled through device_options.legacy. See link below for affected devices.`,
noteIf: noteIfWasTrue,
},
{
path: ['experimental'],
note: `The entire experimental section was removed.`,
noteIf: noteIfWasDefined,
},
{
path: ['external_converters'],
note: `External converters are now automatically loaded from the 'data/external_converters' directory without requiring settings to be set. Make sure your external converters are still needed (might be supported out-of-the-box now), and if so, move them to that directory.`,
noteIf: noteIfWasNonEmptyArray,
},
);
// note only once
const noteEntityOptionsRetrieveState = `Retrieve state option ((devices|groups).xyz.retrieve_state setting)`;
for (const deviceKey in currentSettings.devices) {
removals.push({
path: ['devices', deviceKey, 'retrieve_state'],
note: noteEntityOptionsRetrieveState,
noteIf: noteIfWasTrue,
});
}
for (const groupKey in currentSettings.groups) {
removals.push({
path: ['groups', groupKey, 'retrieve_state'],
note: noteEntityOptionsRetrieveState,
noteIf: noteIfWasTrue,
});
removals.push({
path: ['groups', groupKey, 'devices'],
note: `Removed configuring group members through configuration.yaml (groups.xyz.devices setting). This will not impact current group members; however, you will no longer be able to add or remove devices from a group through the configuration.yaml.`,
noteIf: noteIfWasDefined,
});
}
customHandlers.push();
}
function migrateToThree(
currentSettings: Partial<Settings>,
transfers: SettingsTransfer[],
changes: SettingsChange[],
additions: SettingsAdd[],
removals: SettingsRemove[],
customHandlers: SettingsCustomHandler[],
): void {
transfers.push();
changes.push({
path: ['version'],
note: `Migrated settings to version 3`,
newValue: 3,
});
additions.push();
removals.push();
const changeToObject = (currentSettings: Partial<Settings>, path: string[]): ReturnType<SettingsCustomHandler['execute']> => {
const [validPath, previousValue] = getValue(currentSettings, path);
if (validPath) {
if (typeof previousValue === 'boolean') {
setValue(currentSettings, path, {enabled: previousValue});
} else {
setValue(currentSettings, path, {enabled: true, ...(previousValue as object)});
}
}
return [validPath, previousValue, validPath];
};
customHandlers.push(
{
note: `Property 'homeassistant' is now always an object.`,
noteIf: () => true,
execute: (currentSettings) => changeToObject(currentSettings, ['homeassistant']),
},
{
note: `Property 'frontend' is now always an object.`,
noteIf: () => true,
execute: (currentSettings) => changeToObject(currentSettings, ['frontend']),
},
{
note: `Property 'availability' is now always an object.`,
noteIf: () => true,
execute: (currentSettings) => changeToObject(currentSettings, ['availability']),
},
);
}
function migrateToFour(
currentSettings: Partial<Settings>,
transfers: SettingsTransfer[],
changes: SettingsChange[],
additions: SettingsAdd[],
removals: SettingsRemove[],
customHandlers: SettingsCustomHandler[],
): void {
transfers.push();
changes.push({
path: ['version'],
note: `Migrated settings to version 4`,
newValue: 4,
});
additions.push();
removals.push();
const saveBase64DeviceIconsAsImage = (currentSettings: Partial<Settings>): ReturnType<SettingsCustomHandler['execute']> => {
const [validPath, previousValue] = getValue(currentSettings, ['devices']);
let changed = false;
if (validPath) {
for (const deviceKey in currentSettings.devices) {
const base64Match = utils.matchBase64File(currentSettings.devices[deviceKey].icon);
if (base64Match) {
changed = true;
currentSettings.devices[deviceKey].icon = utils.saveBase64DeviceIcon(base64Match);
}
}
}
return [validPath, previousValue, changed];
};
customHandlers.push({
note: `Device icons are now saved as images.`,
noteIf: () => true,
execute: (currentSettings) => saveBase64DeviceIconsAsImage(currentSettings),
});
}
/**
* Order of execution:
* - Transfer
* - Change
* - Add
* - Remove
*
* Should allow the most flexibility whenever combination of migrations is necessary (e.g. Transfer + Change)
*/
export function migrateIfNecessary(): void {
let currentSettings = settings.getPersistedSettings();
if (!SUPPORTED_VERSIONS.includes(currentSettings.version)) {
throw new Error(
`Your configuration.yaml has an unsupported version ${currentSettings.version}, expected one of ${SUPPORTED_VERSIONS.map((v) => String(v)).join(',')}.`,
);
}
/* v8 ignore next */
const finalVersion = process.env.VITEST_WORKER_ID ? settings.testing.CURRENT_VERSION : settings.CURRENT_VERSION;
// when same version as current, nothing left to do
while (currentSettings.version !== finalVersion) {
let migrationNotesFileName: string | undefined;
// don't duplicate outputs
const migrationNotes: Set<string> = new Set();
const transfers: SettingsTransfer[] = [];
const changes: SettingsChange[] = [];
const additions: SettingsAdd[] = [];
const removals: SettingsRemove[] = [];
const customHandlers: SettingsCustomHandler[] = [];
backupSettings(currentSettings.version || 1);
// each version should only bump to the next version so as to gradually migrate if necessary
if (currentSettings.version == undefined) {
// migrating from 1 (`version` did not exist) to 2
migrationNotesFileName = 'migration-1-to-2.log';
migrateToTwo(currentSettings, transfers, changes, additions, removals, customHandlers);
} else if (currentSettings.version === 2) {
migrationNotesFileName = 'migration-2-to-3.log';
migrateToThree(currentSettings, transfers, changes, additions, removals, customHandlers);
} else if (currentSettings.version === 3) {
migrationNotesFileName = 'migration-3-to-4.log';
migrateToFour(currentSettings, transfers, changes, additions, removals, customHandlers);
}
for (const transfer of transfers) {
const [validPath, previousValue, transfered] = transferValue(currentSettings, transfer);
if (validPath && (!transfer.noteIf || transfer.noteIf(previousValue))) {
migrationNotes.add(`[${transfered ? 'TRANSFER' : 'REMOVAL'}] ${transfer.note}`);
}
}
for (const change of changes) {
const [validPath, previousValue, changed] = changeValue(currentSettings, change);
if (validPath && changed && (!change.noteIf || change.noteIf(previousValue))) {
migrationNotes.add(`[CHANGE] ${change.note}`);
}
}
for (const addition of additions) {
addValue(currentSettings, addition);
migrationNotes.add(`[ADDITION] ${addition.note}`);
}
for (const removal of removals) {
const [validPath, previousValue] = removeValue(currentSettings, removal);
if (validPath && (!removal.noteIf || removal.noteIf(previousValue))) {
migrationNotes.add(`[REMOVAL] ${removal.note}`);
}
}
for (const customHandler of customHandlers) {
const [validPath, previousValue, changed] = customHandler.execute(currentSettings);
if (validPath && changed && (!customHandler.noteIf || customHandler.noteIf(previousValue))) {
migrationNotes.add(`[SPECIAL] ${customHandler.note}`);
}
}
if (migrationNotesFileName && migrationNotes.size > 0) {
migrationNotes.add(`For more details, see https://github.com/Koenkk/zigbee2mqtt/discussions/24198`);
const migrationNotesFilePath = data.joinPath(migrationNotesFileName);
writeFileSync(migrationNotesFilePath, Array.from(migrationNotes).join(`\r\n\r\n`), 'utf8');
console.log(`Migration notes written in ${migrationNotesFilePath}`);
}
// don't throw to allow stepping through versions (validates against current schema)
settings.apply(currentSettings as unknown as Record<string, unknown>, false);
settings.reRead();
currentSettings = settings.getPersistedSettings();
}
}
+202 -211
View File
@@ -1,12 +1,22 @@
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponse, Zigbee2MQTTResponseEndpoints, Zigbee2MQTTScene} from 'lib/types/api';
import type * as zhc from 'zigbee-herdsman-converters';
import assert from 'node:assert';
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import equals from 'fast-deep-equal/es6';
import humanizeDuration from 'humanize-duration';
import data from './data';
import vm from 'vm';
import fs from 'fs';
import path from 'path';
import {detailedDiff} from 'deep-object-diff';
import objectAssignDeep from 'object-assign-deep';
import type * as zhc from 'zigbee-herdsman-converters';
const BASE64_IMAGE_REGEX = new RegExp(`data:image/(?<extension>.+);base64,(?<data>.+)`);
function pad(num: number): string {
const norm = Math.floor(Math.abs(num));
return (norm < 10 ? '0' : '') + norm;
}
// construct a local ISO8601 string (instead of UTC-based)
// Example:
@@ -15,62 +25,52 @@ import type * as zhc from 'zigbee-herdsman-converters';
function toLocalISOString(date: Date): string {
const tzOffset = -date.getTimezoneOffset();
const plusOrMinus = tzOffset >= 0 ? '+' : '-';
const pad = (num: number): string => {
const norm = Math.floor(Math.abs(num));
return (norm < 10 ? '0' : '') + norm;
};
return date.getFullYear() +
'-' + pad(date.getMonth() + 1) +
'-' + pad(date.getDate()) +
'T' + pad(date.getHours()) +
':' + pad(date.getMinutes()) +
':' + pad(date.getSeconds()) +
plusOrMinus + pad(tzOffset / 60) +
':' + pad(tzOffset % 60);
return (
date.getFullYear() +
'-' +
pad(date.getMonth() + 1) +
'-' +
pad(date.getDate()) +
'T' +
pad(date.getHours()) +
':' +
pad(date.getMinutes()) +
':' +
pad(date.getSeconds()) +
plusOrMinus +
pad(tzOffset / 60) +
':' +
pad(tzOffset % 60)
);
}
const endpointNames = [
'left', 'right', 'center', 'bottom_left', 'bottom_right', 'default',
'top_left', 'top_right', 'white', 'rgb', 'cct', 'system', 'top', 'bottom', 'center_left', 'center_right',
'ep1', 'ep2', 'row_1', 'row_2', 'row_3', 'row_4', 'relay', 'usb',
'l1', 'l2', 'l3', 'l4', 'l5', 'l6', 'l7', 'l8',
'l9', 'l10', 'l11', 'l12', 'l13', 'l14', 'l15', 'l16',
'l17', 'l18', 'l19', 'l20', 'l21', 'l22', 'l23', 'l24',
'th1', 'th2', 'th3', 'th4', 'th5', 'th6', 'th7', 'th8', 'th9', 'th10',
'button_1', 'button_2', 'button_3', 'button_4', 'button_5',
'button_6', 'button_7', 'button_8', 'button_9', 'button_10',
'button_11', 'button_12', 'button_13', 'button_14', 'button_15',
'button_16', 'button_17', 'button_18', 'button_19', 'button_20',
'button_light', 'button_fan_high', 'button_fan_med', 'button_fan_low',
'heat', 'cool', 'water', 'meter', 'wifi', 'no_occupancy_since',
];
function capitalize(s: string): string {
return s[0].toUpperCase() + s.slice(1);
}
async function getZigbee2MQTTVersion(includeCommitHash=true): Promise<{commitHash: string, version: string}> {
async function getZigbee2MQTTVersion(includeCommitHash = true): Promise<{commitHash?: string; version: string}> {
const git = await import('git-last-commit');
const packageJSON = await import('../..' + '/package.json');
if (!includeCommitHash) {
return {version: packageJSON.version, commitHash: null};
return {version: packageJSON.version, commitHash: undefined};
}
return new Promise((resolve) => {
return await new Promise((resolve) => {
const version = packageJSON.version;
git.getLastCommit((err: Error, commit: {shortHash: string}) => {
let commitHash = null;
let commitHash = undefined;
if (err) {
try {
commitHash = fs.readFileSync(path.join(__dirname, '..', '..', 'dist', '.hash'), 'utf-8');
} catch (error) {
/* istanbul ignore next */
/* v8 ignore start */
} catch {
commitHash = 'unknown';
}
/* v8 ignore stop */
} else {
commitHash = commit.shortHash;
}
@@ -82,10 +82,8 @@ async function getZigbee2MQTTVersion(includeCommitHash=true): Promise<{commitHas
}
async function getDependencyVersion(depend: string): Promise<{version: string}> {
const modulePath = path.dirname(require.resolve(depend));
const packageJSONPath = path.join(modulePath.slice(0, modulePath.indexOf(depend) + depend.length), 'package.json');
const packageJSON = await import(packageJSONPath);
const version = packageJSON.version;
const packageJsonPath = require.resolve(`${depend}/package.json`);
const version = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')).version;
return {version};
}
@@ -93,14 +91,21 @@ function formatDate(time: number, type: 'ISO_8601' | 'ISO_8601_local' | 'epoch'
if (type === 'ISO_8601') return new Date(time).toISOString();
else if (type === 'ISO_8601_local') return toLocalISOString(new Date(time));
else if (type === 'epoch') return time;
else { // relative
else {
// relative
return humanizeDuration(Date.now() - time, {language: 'en', largest: 2, round: true}) + ' ago';
}
}
function objectIsEmpty(object: object): boolean {
// much faster than checking `Object.keys(object).length`
for (const k in object) return false;
return true;
}
function objectHasProperties(object: {[s: string]: unknown}, properties: string[]): boolean {
for (const property of properties) {
if (!object.hasOwnProperty(property)) {
if (object[property] === undefined) {
return false;
}
}
@@ -118,75 +123,67 @@ function equalsPartial(object: KeyValue, expected: KeyValue): boolean {
return true;
}
function getObjectProperty(object: KeyValue, key: string, defaultValue: unknown): unknown {
return object && object.hasOwnProperty(key) ? object[key] : defaultValue;
function getObjectProperty<T>(object: KeyValue, key: string, defaultValue: NoInfer<T>): T {
return object && object[key] !== undefined ? object[key] : defaultValue;
}
function getResponse(request: KeyValue | string, data: KeyValue, error: string): MQTTResponse {
const response: MQTTResponse = {data, status: error ? 'error' : 'ok'};
if (error) response.error = error;
if (typeof request === 'object' && request.hasOwnProperty('transaction')) {
response.transaction = request.transaction;
function getResponse<T extends Zigbee2MQTTResponseEndpoints>(
request: KeyValue | string,
data: Zigbee2MQTTAPI[T],
error?: string,
): Zigbee2MQTTResponse<T> {
if (error !== undefined) {
const response: Zigbee2MQTTResponse<T> = {
data: {}, // always return an empty `data` payload on error
status: 'error',
error: error,
};
if (typeof request === 'object' && request.transaction !== undefined) {
response.transaction = request.transaction;
}
return response;
} else {
const response: Zigbee2MQTTResponse<T> = {
data, // valid from error check
status: 'ok',
};
if (typeof request === 'object' && request.transaction !== undefined) {
response.transaction = request.transaction;
}
return response;
}
return response;
}
function parseJSON(value: string, fallback: string): KeyValue | string {
try {
return JSON.parse(value);
} catch (e) {
} catch {
return fallback;
}
}
function loadModuleFromText(moduleCode: string, name?: string): unknown {
const moduleFakePath = path.join(__dirname, '..', '..', 'data', 'extension', name || 'externally-loaded.js');
const sandbox = {
require: require,
module: {},
console,
setTimeout,
clearTimeout,
setInterval,
clearInterval,
setImmediate,
clearImmediate,
};
vm.runInNewContext(moduleCode, sandbox, moduleFakePath);
/* eslint-disable-line */ // @ts-ignore
return sandbox.module.exports;
}
function loadModuleFromFile(modulePath: string): unknown {
const moduleCode = fs.readFileSync(modulePath, {encoding: 'utf8'});
return loadModuleFromText(moduleCode);
}
export function* loadExternalConverter(moduleName: string): Generator<ExternalDefinition> {
let converter;
if (moduleName.endsWith('.js')) {
converter = loadModuleFromFile(data.joinPath(moduleName));
} else {
converter = require(moduleName);
}
if (Array.isArray(converter)) {
for (const item of converter) {
yield item;
}
} else {
yield converter;
}
}
function removeNullPropertiesFromObject(obj: KeyValue): void {
/**
* Delete all keys from passed object that have null/undefined values.
*
* @param {KeyValue} obj Object to process (in-place)
* @param {string[]} [ignoreKeys] Recursively ignore these keys in the object (keep null/undefined values).
*/
function removeNullPropertiesFromObject(obj: KeyValue, ignoreKeys: string[] = []): void {
for (const key of Object.keys(obj)) {
if (ignoreKeys.includes(key)) {
continue;
}
const value = obj[key];
if (value == null) {
delete obj[key];
} else if (typeof value === 'object') {
removeNullPropertiesFromObject(value);
removeNullPropertiesFromObject(value, ignoreKeys);
}
}
}
@@ -196,41 +193,20 @@ function toNetworkAddressHex(value: number): string {
return `0x${'0'.repeat(4 - hex.length)}${hex}`;
}
// eslint-disable-next-line
function toSnakeCase(value: string | KeyValue): any {
if (typeof value === 'object') {
value = {...value};
for (const key of Object.keys(value)) {
const keySnakeCase = toSnakeCase(key);
if (key !== keySnakeCase) {
value[keySnakeCase] = value[key];
delete value[key];
}
}
return value;
} else {
return value.replace(/\.?([A-Z])/g, (x, y) => '_' + y.toLowerCase()).replace(/^_/, '').replace('_i_d', '_id');
}
}
function charRange(start: string, stop: string): number[] {
const result = [];
for (let idx=start.charCodeAt(0), end=stop.charCodeAt(0); idx <=end; ++idx) {
for (let idx = start.charCodeAt(0), end = stop.charCodeAt(0); idx <= end; ++idx) {
result.push(idx);
}
return result;
}
const controlCharacters = [
...charRange('\u0000', '\u001F'),
...charRange('\u007f', '\u009F'),
...charRange('\ufdd0', '\ufdef'),
];
const controlCharacters = [...charRange('\u0000', '\u001F'), ...charRange('\u007f', '\u009F'), ...charRange('\ufdd0', '\ufdef')];
function containsControlCharacter(str: string): boolean {
for (let i = 0; i < str.length; i++) {
const ch = str.charCodeAt(i);
if (controlCharacters.includes(ch) || [0xFFFE, 0xFFFF].includes(ch & 0xFFFF)) {
if (controlCharacters.includes(ch) || [0xfffe, 0xffff].includes(ch & 0xffff)) {
return true;
}
}
@@ -250,18 +226,12 @@ function getAllFiles(path_: string): string[] {
return result;
}
function validateFriendlyName(name: string, throwFirstError=false): string[] {
function validateFriendlyName(name: string, throwFirstError = false): string[] {
const errors = [];
for (const endpointName of endpointNames) {
if (name.toLowerCase().endsWith('/' + endpointName)) {
errors.push(`friendly_name is not allowed to end with: '/${endpointName}'`);
}
}
if (name.length === 0) errors.push(`friendly_name must be at least 1 char long`);
if (name.endsWith('/') || name.startsWith('/')) errors.push(`friendly_name is not allowed to end or start with /`);
if (containsControlCharacter(name)) errors.push(`friendly_name is not allowed to contain control char`);
if (endpointNames.includes(name)) errors.push(`Following friendly_name are not allowed: '${endpointNames}'`);
if (name.match(/.*\/\d*$/)) errors.push(`Friendly name cannot end with a "/DIGIT" ('${name}')`);
if (name.includes('#') || name.includes('+')) {
errors.push(`MQTT wildcard (+ and #) not allowed in friendly_name ('${name}')`);
@@ -281,46 +251,36 @@ function sleep(seconds: number): Promise<void> {
function sanitizeImageParameter(parameter: string): string {
const replaceByDash = [/\?/g, /&/g, /[^a-z\d\- _./:]/gi];
let sanitized = parameter;
replaceByDash.forEach((r) => sanitized = sanitized.replace(r, '-'));
replaceByDash.forEach((r) => (sanitized = sanitized.replace(r, '-')));
return sanitized;
}
function isAvailabilityEnabledForEntity(entity: Device | Group, settings: Settings): boolean {
if (entity.isGroup()) {
return !entity.membersDevices().map((d) => isAvailabilityEnabledForEntity(d, settings)).includes(false);
if (entity.isDevice() && entity.options.disabled) {
return false;
}
if (entity.options.hasOwnProperty('availability')) {
if (entity.isGroup()) {
return !entity.membersDevices().some((d) => !isAvailabilityEnabledForEntity(d, settings));
}
if (entity.options.availability != null) {
return !!entity.options.availability;
}
// availability_timeout = deprecated
const enabledGlobal = settings.advanced.availability_timeout || settings.availability;
if (!enabledGlobal) return false;
if (entity.isDevice() && entity.options.disabled) return false;
const passlist = settings.advanced.availability_passlist.concat(settings.advanced.availability_whitelist);
if (passlist.length > 0) {
return passlist.includes(entity.name) || passlist.includes(entity.ieeeAddr);
if (!settings.availability.enabled) {
return false;
}
const blocklist = settings.advanced.availability_blacklist.concat(settings.advanced.availability_blocklist);
return !blocklist.includes(entity.name) && !blocklist.includes(entity.ieeeAddr);
return true;
}
const entityIDRegex = new RegExp(`^(.+?)(?:/(${endpointNames.join('|')}|\\d+))?$`);
function parseEntityID(ID: string): {ID: string, endpoint: string} {
const match = ID.match(entityIDRegex);
return match && {ID: match[1], endpoint: match[2]};
}
function isEndpoint(obj: unknown): obj is zh.Endpoint {
return obj.constructor.name.toLowerCase() === 'endpoint';
function isZHEndpoint(obj: unknown): obj is zh.Endpoint {
return obj?.constructor.name.toLowerCase() === 'endpoint';
}
function flatten<Type>(arr: Type[][]): Type[] {
return [].concat(...arr);
return ([] as Type[]).concat(...arr);
}
function arrayUnique<Type>(arr: Type[]): Type[] {
@@ -328,19 +288,19 @@ function arrayUnique<Type>(arr: Type[]): Type[] {
}
function isZHGroup(obj: unknown): obj is zh.Group {
return obj.constructor.name.toLowerCase() === 'group';
}
function availabilityPayload(state: 'online' | 'offline', settings: Settings): string {
return settings.advanced.legacy_availability_payload ? state : JSON.stringify({state});
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;
function publishLastSeen(data: eventdata.LastSeenChanged, settings: Settings, allowMessageEmitted: boolean,
publishEntityState: PublishEntityState): void {
async function publishLastSeen(
data: eventdata.LastSeenChanged,
settings: Settings,
allowMessageEmitted: boolean,
publishEntityState: PublishEntityState,
): Promise<void> {
/**
* Prevent 2 MQTT publishes when 1 message event is received;
* - In case reason == messageEmitted, receive.ts will only call this when it did not publish a
@@ -350,11 +310,11 @@ function publishLastSeen(data: eventdata.LastSeenChanged, settings: Settings, al
*/
const allow = data.reason !== 'messageEmitted' || (data.reason === 'messageEmitted' && allowMessageEmitted);
if (settings.advanced.last_seen && settings.advanced.last_seen !== 'disable' && allow) {
publishEntityState(data.device, {}, 'lastSeenChanged');
await publishEntityState(data.device, {}, 'lastSeenChanged');
}
}
function filterProperties(filter: string[], data: KeyValue): void {
function filterProperties(filter: string[] | undefined, data: KeyValue): void {
if (filter) {
for (const property of Object.keys(data)) {
if (filter.find((p) => property.match(`^${p}$`))) {
@@ -364,59 +324,38 @@ function filterProperties(filter: string[], data: KeyValue): void {
}
}
function clone(obj: KeyValue): KeyValue {
return JSON.parse(JSON.stringify(obj));
export function isNumericExpose(expose: zhc.Expose): expose is zhc.Numeric {
return expose?.type === 'numeric';
}
export function isNumericExposeFeature(feature: zhc.Feature): feature is zhc.Numeric {
return feature?.type === 'numeric';
export function assertEnumExpose(expose: zhc.Expose): asserts expose is zhc.Enum {
assert(expose?.type === 'enum');
}
export function isEnumExposeFeature(feature: zhc.Feature): feature is zhc.Enum {
return feature?.type === 'enum';
export function assertNumericExpose(expose: zhc.Expose): asserts expose is zhc.Numeric {
assert(expose?.type === 'numeric');
}
export function isBinaryExposeFeature(feature: zhc.Feature): feature is zhc.Binary {
return feature?.type === 'binary';
export function assertBinaryExpose(expose: zhc.Expose): asserts expose is zhc.Binary {
assert(expose?.type === 'binary');
}
function computeSettingsToChange(current: KeyValue, new_: KeyValue): KeyValue {
const diff: KeyValue = detailedDiff(current, new_);
// Remove any settings that are in the deleted.diff but not in the passed options
const cleanupDeleted = (options: KeyValue, deleted: KeyValue): void => {
for (const key of Object.keys(deleted)) {
if (!(key in options)) {
delete deleted[key];
} else if (!Array.isArray(options[key])) {
cleanupDeleted(options[key], deleted[key]);
}
}
};
cleanupDeleted(new_, diff.deleted);
// objectAssignDeep requires object prototype which is missing from detailedDiff, therefore clone
const newSettings = objectAssignDeep({}, clone(diff.added), clone(diff.updated), clone(diff.deleted));
// deep-object-diff converts arrays to objects, set original array back here
const convertBackArray = (before: KeyValue, after: KeyValue): void => {
for (const [key, afterValue] of Object.entries(after)) {
const beforeValue = before[key];
if (Array.isArray(beforeValue)) {
after[key] = beforeValue;
} else if (afterValue && typeof beforeValue === 'object') {
convertBackArray(beforeValue, afterValue);
}
}
};
convertBackArray(new_, newSettings);
return newSettings;
export function isEnumExpose(expose: zhc.Expose): expose is zhc.Enum {
return expose?.type === 'enum';
}
function getScenes(entity: zh.Endpoint | zh.Group): Scene[] {
const scenes: {[id: number]: Scene} = {};
const endpoints = isEndpoint(entity) ? [entity] : entity.members;
const groupID = isEndpoint(entity) ? 0 : entity.groupID;
export function isBinaryExpose(expose: zhc.Expose): expose is zhc.Binary {
return expose?.type === 'binary';
}
export function isLightExpose(expose: zhc.Expose): expose is zhc.Light {
return expose.type === 'light';
}
function getScenes(entity: zh.Endpoint | zh.Group): Zigbee2MQTTScene[] {
const scenes: {[id: number]: Zigbee2MQTTScene} = {};
const endpoints = isZHEndpoint(entity) ? [entity] : entity.members;
const groupID = isZHEndpoint(entity) ? 0 : entity.groupID;
for (const endpoint of endpoints) {
for (const [key, data] of Object.entries(endpoint.meta?.scenes || {})) {
@@ -432,11 +371,63 @@ function getScenes(entity: zh.Endpoint | zh.Group): Scene[] {
return Object.values(scenes);
}
function deviceNotCoordinator(device: zh.Device): boolean {
return device.type !== 'Coordinator';
}
function matchBase64File(value: string | undefined): {extension: string; data: string} | false {
if (value !== undefined) {
const match = value.match(BASE64_IMAGE_REGEX);
if (match) {
assert(match.groups?.extension && match.groups?.data);
return {extension: match.groups.extension, data: match.groups.data};
}
}
return false;
}
function saveBase64DeviceIcon(base64Match: {extension: string; data: string}): string {
const md5Hash = crypto.createHash('md5').update(base64Match.data).digest('hex');
const fileSettings = `device_icons/${md5Hash}.${base64Match.extension}`;
const file = path.join(data.getPath(), fileSettings);
fs.mkdirSync(path.dirname(file), {recursive: true});
fs.writeFileSync(file, base64Match.data, {encoding: 'base64'});
return fileSettings;
}
/* v8 ignore next */
const noop = (): void => {};
export default {
endpointNames, capitalize, getZigbee2MQTTVersion, getDependencyVersion, formatDate, objectHasProperties,
equalsPartial, getObjectProperty, getResponse, parseJSON, loadModuleFromText, loadModuleFromFile,
removeNullPropertiesFromObject, toNetworkAddressHex, toSnakeCase,
parseEntityID, isEndpoint, isZHGroup, hours, minutes, seconds, validateFriendlyName, sleep,
sanitizeImageParameter, isAvailabilityEnabledForEntity, publishLastSeen, availabilityPayload,
getAllFiles, filterProperties, flatten, arrayUnique, clone, computeSettingsToChange, getScenes,
matchBase64File,
saveBase64DeviceIcon,
capitalize,
getZigbee2MQTTVersion,
getDependencyVersion,
formatDate,
objectIsEmpty,
objectHasProperties,
equalsPartial,
getObjectProperty,
getResponse,
parseJSON,
removeNullPropertiesFromObject,
toNetworkAddressHex,
isZHEndpoint,
isZHGroup,
hours,
minutes,
seconds,
validateFriendlyName,
sleep,
sanitizeImageParameter,
isAvailabilityEnabledForEntity,
publishLastSeen,
getAllFiles,
filterProperties,
flatten,
arrayUnique,
getScenes,
deviceNotCoordinator,
noop,
};
+25 -7
View File
@@ -1,26 +1,44 @@
import yaml from 'js-yaml';
import fs from 'fs';
import assert from 'node:assert';
import fs from 'node:fs';
import equals from 'fast-deep-equal/es6';
import yaml, {YAMLException} from 'js-yaml';
export class YAMLFileException extends YAMLException {
file: string;
constructor(error: YAMLException, file: string) {
super(error.reason, error.mark);
this.name = 'YAMLFileException';
this.cause = error.cause;
this.message = error.message;
this.stack = error.stack;
this.file = file;
}
}
function read(file: string): KeyValue {
try {
const result = yaml.load(fs.readFileSync(file, 'utf8'));
return result as KeyValue ?? {};
assert(result instanceof Object);
return result as KeyValue;
} catch (error) {
if (error.name === 'YAMLException') {
error.file = file;
if (error instanceof YAMLException) {
throw new YAMLFileException(error, file);
}
throw error;
}
}
function readIfExists(file: string, default_?: KeyValue): KeyValue {
return fs.existsSync(file) ? read(file) : default_;
function readIfExists(file: string, fallback: KeyValue = {}): KeyValue {
return fs.existsSync(file) ? read(file) : fallback;
}
function writeIfChanged(file: string, content: KeyValue): void {
const before = readIfExists(file);
if (!equals(before, content)) {
fs.writeFileSync(file, yaml.dump(content));
}
+139 -81
View File
@@ -1,17 +1,23 @@
import {Controller} from 'zigbee-herdsman';
import logger from './util/logger';
import * as settings from './util/settings';
import data from './util/data';
import utils from './util/utils';
import objectAssignDeep from 'object-assign-deep';
import type {Events as ZHEvents} from 'zigbee-herdsman';
import {randomInt} from 'node:crypto';
import bind from 'bind-decorator';
import stringify from 'json-stable-stringify-without-jsonify';
import {Controller} from 'zigbee-herdsman';
import Device from './model/device';
import Group from './model/group';
import * as ZHEvents from 'zigbee-herdsman/dist/controller/events';
import bind from 'bind-decorator';
import {randomInt} from 'crypto';
import data from './util/data';
import logger from './util/logger';
import * as settings from './util/settings';
import utils from './util/utils';
const entityIDRegex = new RegExp(`^(.+?)(?:/([^/]+))?$`);
export default class Zigbee {
// @ts-expect-error initialized in start
private herdsman: Controller;
private eventBus: EventBus;
private groupLookup: {[s: number]: Group} = {};
@@ -24,15 +30,15 @@ export default class Zigbee {
async start(): Promise<'reset' | 'resumed' | 'restored'> {
const infoHerdsman = await utils.getDependencyVersion('zigbee-herdsman');
logger.info(`Starting zigbee-herdsman (${infoHerdsman.version})`);
const panId = settings.get().advanced.pan_id;
const extPanId = settings.get().advanced.ext_pan_id;
const networkKey = settings.get().advanced.network_key;
const herdsmanSettings = {
network: {
panID: settings.get().advanced.pan_id === 'GENERATE' ?
this.generatePanID() : settings.get().advanced.pan_id as number,
extendedPanID: settings.get().advanced.ext_pan_id === 'GENERATE' ?
this.generateExtPanID() : settings.get().advanced.ext_pan_id as number[],
panID: panId === 'GENERATE' ? this.generatePanID() : panId,
extendedPanID: extPanId === 'GENERATE' ? this.generateExtPanID() : extPanId,
channelList: [settings.get().advanced.channel],
networkKey: settings.get().advanced.network_key === 'GENERATE' ?
this.generateNetworkKey() : settings.get().advanced.network_key as number[],
networkKey: networkKey === 'GENERATE' ? this.generateNetworkKey() : networkKey,
},
databasePath: data.joinPath('database.db'),
databaseBackupPath: data.joinPath('database.db.backup'),
@@ -47,70 +53,74 @@ export default class Zigbee {
concurrent: settings.get().advanced.adapter_concurrent,
delay: settings.get().advanced.adapter_delay,
disableLED: settings.get().serial.disable_led,
transmitPower: settings.get().advanced.transmit_power,
},
acceptJoiningDeviceHandler: this.acceptJoiningDeviceHandler,
};
const herdsmanSettingsLog = objectAssignDeep({}, herdsmanSettings, {network: {networkKey: 'HIDDEN'}});
logger.debug(`Using zigbee-herdsman with settings: '${stringify(herdsmanSettingsLog)}'`);
logger.debug(
() =>
`Using zigbee-herdsman with settings: '${stringify(JSON.stringify(herdsmanSettings).replaceAll(JSON.stringify(herdsmanSettings.network.networkKey), '"HIDDEN"'))}'`,
);
let startResult;
try {
this.herdsman = new Controller(herdsmanSettings, logger);
this.herdsman = new Controller(herdsmanSettings);
startResult = await this.herdsman.start();
} catch (error) {
logger.error(`Error while starting zigbee-herdsman`);
throw error;
}
for (const device of this.devices(false)) {
await device.resolveDefinition();
}
await this.resolveDevicesDefinitions();
this.herdsman.on('adapterDisconnected', () => this.eventBus.emitAdapterDisconnected());
this.herdsman.on('lastSeenChanged', (data: ZHEvents.LastSeenChangedPayload) => {
this.eventBus.emitLastSeenChanged({device: this.resolveDevice(data.device.ieeeAddr), reason: data.reason});
this.eventBus.emitLastSeenChanged({device: this.resolveDevice(data.device.ieeeAddr)!, reason: data.reason});
});
this.herdsman.on('permitJoinChanged', (data: ZHEvents.PermitJoinChangedPayload) => {
this.eventBus.emitPermitJoinChanged(data);
});
this.herdsman.on('deviceNetworkAddressChanged', (data: ZHEvents.DeviceNetworkAddressChangedPayload) => {
const device = this.resolveDevice(data.device.ieeeAddr);
const device = this.resolveDevice(data.device.ieeeAddr)!;
logger.debug(`Device '${device.name}' changed network address`);
this.eventBus.emitDeviceNetworkAddressChanged({device});
});
this.herdsman.on('deviceAnnounce', (data: ZHEvents.DeviceAnnouncePayload) => {
const device = this.resolveDevice(data.device.ieeeAddr);
const device = this.resolveDevice(data.device.ieeeAddr)!;
logger.debug(`Device '${device.name}' announced itself`);
this.eventBus.emitDeviceAnnounce({device});
});
this.herdsman.on('deviceInterview', async (data: ZHEvents.DeviceInterviewPayload) => {
const device = this.resolveDevice(data.device.ieeeAddr);
/* v8 ignore next */ if (!device) return; // Prevent potential race
await device.resolveDefinition();
/* istanbul ignore if */ if (!device) return; // Prevent potential race
const d = {device, status: data.status};
this.logDeviceInterview(d);
this.eventBus.emitDeviceInterview(d);
});
this.herdsman.on('deviceJoined', async (data: ZHEvents.DeviceJoinedPayload) => {
const device = this.resolveDevice(data.device.ieeeAddr);
/* v8 ignore next */ if (!device) return; // Prevent potential race
await device.resolveDefinition();
/* istanbul ignore if */ if (!device) return; // Prevent potential race
logger.info(`Device '${device.name}' joined`);
this.eventBus.emitDeviceJoined({device});
});
this.herdsman.on('deviceLeave', (data: ZHEvents.DeviceLeavePayload) => {
const name = settings.getDevice(data.ieeeAddr)?.friendly_name || data.ieeeAddr;
logger.warn(`Device '${name}' left the network`);
logger.warning(`Device '${name}' left the network`);
this.eventBus.emitDeviceLeave({ieeeAddr: data.ieeeAddr, name});
});
this.herdsman.on('message', async (data: ZHEvents.MessagePayload) => {
const device = this.resolveDevice(data.device.ieeeAddr);
const device = this.resolveDevice(data.device.ieeeAddr)!;
await device.resolveDefinition();
logger.debug(`Received Zigbee message from '${device.name}', type '${data.type}', ` +
`cluster '${data.cluster}', data '${stringify(data.data)}' from endpoint ${data.endpoint.ID}` +
(data.hasOwnProperty('groupID') ? ` with groupID ${data.groupID}` : ``) +
(device.zh.type === 'Coordinator' ? `, ignoring since it is from coordinator` : ``));
logger.debug(
() =>
`Received Zigbee message from '${device.name}', type '${data.type}', ` +
`cluster '${data.cluster}', data '${stringify(data.data)}' from endpoint ${data.endpoint.ID}` +
(data['groupID'] !== undefined ? ` with groupID ${data.groupID}` : ``) +
(device.zh.type === 'Coordinator' ? `, ignoring since it is from coordinator` : ``),
);
if (device.zh.type === 'Coordinator') return;
this.eventBus.emitDeviceMessage({...data, device});
});
@@ -119,7 +129,7 @@ export default class Zigbee {
logger.info(`Coordinator firmware version: '${stringify(await this.getCoordinatorVersion())}'`);
logger.debug(`Zigbee network parameters: ${stringify(await this.herdsman.getNetworkParameters())}`);
for (const device of this.devices(false)) {
for (const device of this.devicesIterator(utils.deviceNotCoordinator)) {
// If a passlist is used, all other device will be removed from the network.
const passlist = settings.get().passlist;
const blocklist = settings.get().blocklist;
@@ -127,27 +137,21 @@ export default class Zigbee {
try {
await device.zh.removeFromNetwork();
} catch (error) {
logger.error(`Failed to remove '${device.ieeeAddr}' (${error.message})`);
logger.error(`Failed to remove '${device.ieeeAddr}' (${(error as Error).message})`);
}
};
if (passlist.length > 0) {
if (!passlist.includes(device.ieeeAddr)) {
logger.warn(`Device which is not on passlist connected (${device.ieeeAddr}), removing...`);
logger.warning(`Device not on passlist currently connected (${device.ieeeAddr}), removing...`);
await remove(device);
}
} else if (blocklist.includes(device.ieeeAddr)) {
logger.warn(`Device on blocklist is connected (${device.ieeeAddr}), removing...`);
logger.warning(`Device on blocklist currently connected (${device.ieeeAddr}), removing...`);
await remove(device);
}
}
// Check if we have to set a transmit power
if (settings.get().advanced.hasOwnProperty('transmit_power')) {
const transmitPower = settings.get().advanced.transmit_power;
await this.herdsman.setTransmitPower(transmitPower);
logger.info(`Set transmit power to '${transmitPower}'`);
}
return startResult;
}
@@ -157,17 +161,19 @@ export default class Zigbee {
logger.info(`Successfully interviewed '${name}', device has successfully been paired`);
if (data.device.isSupported) {
const {vendor, description, model} = data.device.definition;
const {vendor, description, model} = data.device.definition!;
logger.info(`Device '${name}' is supported, identified as: ${vendor} ${description} (${model})`);
} else {
logger.warn(`Device '${name}' with Zigbee model '${data.device.zh.modelID}' and manufacturer name ` +
`'${data.device.zh.manufacturerName}' is NOT supported, ` +
// eslint-disable-next-line max-len
`please follow https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html`);
logger.warning(
`Device '${name}' with Zigbee model '${data.device.zh.modelID}' and manufacturer name ` +
`'${data.device.zh.manufacturerName}' is NOT supported, ` +
`please follow https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html`,
);
}
} else if (data.status === 'failed') {
logger.error(`Failed to interview '${name}', device has not successfully been paired`);
} else { // data.status === 'started'
} else {
// data.status === 'started'
logger.info(`Starting interview of '${name}'`);
}
}
@@ -185,13 +191,13 @@ export default class Zigbee {
}
private generatePanID(): number {
const panID = randomInt(1, 0xFFFF - 1);
const panID = randomInt(1, 0xffff - 1);
settings.set(['advanced', 'pan_id'], panID);
return panID;
}
async getCoordinatorVersion(): Promise<zh.CoordinatorVersion> {
return this.herdsman.getCoordinatorVersion();
return await this.herdsman.getCoordinatorVersion();
}
isStopping(): boolean {
@@ -199,20 +205,16 @@ export default class Zigbee {
}
async backup(): Promise<void> {
return this.herdsman.backup();
return await this.herdsman.backup();
}
async coordinatorCheck(): Promise<{missingRouters: Device[]}> {
const check = await this.herdsman.coordinatorCheck();
return {missingRouters: check.missingRouters.map((d) => this.resolveDevice(d.ieeeAddr))};
return {missingRouters: check.missingRouters.map((d) => this.resolveDevice(d.ieeeAddr)!)};
}
async getNetworkParameters(): Promise<zh.NetworkParameters> {
return this.herdsman.getNetworkParameters();
}
async reset(type: 'soft' | 'hard'): Promise<void> {
await this.herdsman.reset(type);
return await this.herdsman.getNetworkParameters();
}
async stop(): Promise<void> {
@@ -225,28 +227,32 @@ export default class Zigbee {
return this.herdsman.getPermitJoin();
}
getPermitJoinTimeout(): number {
return this.herdsman.getPermitJoinTimeout();
getPermitJoinEnd(): number | undefined {
return this.herdsman.getPermitJoinEnd();
}
async permitJoin(permit: boolean, device?: Device, time: number=undefined): Promise<void> {
if (permit) {
async permitJoin(time: number, device?: Device): Promise<void> {
if (time > 0) {
logger.info(`Zigbee: allowing new devices to join${device ? ` via ${device.name}` : ''}.`);
} else {
logger.info('Zigbee: disabling joining new devices.');
}
if (device && permit) {
await this.herdsman.permitJoin(permit, device.zh, time);
} else {
await this.herdsman.permitJoin(permit, undefined, time);
await this.herdsman.permitJoin(time, device?.zh);
}
async resolveDevicesDefinitions(ignoreCache: boolean = false): Promise<void> {
for (const device of this.devicesIterator(utils.deviceNotCoordinator)) {
await device.resolveDefinition(ignoreCache);
}
}
@bind private resolveDevice(ieeeAddr: string): Device {
@bind private resolveDevice(ieeeAddr: string): Device | undefined {
if (!this.deviceLookup[ieeeAddr]) {
const device = this.herdsman.getDeviceByIeeeAddr(ieeeAddr);
device && (this.deviceLookup[ieeeAddr] = new Device(device));
if (device) {
this.deviceLookup[ieeeAddr] = new Device(device);
}
}
const device = this.deviceLookup[ieeeAddr];
@@ -265,16 +271,20 @@ export default class Zigbee {
return this.groupLookup[groupID];
}
resolveEntity(key: string | number | zh.Device): Device | Group {
resolveEntity(key: string | number | zh.Device): Device | Group | undefined {
if (typeof key === 'object') {
return this.resolveDevice(key.ieeeAddr);
} else if (typeof key === 'string' && key.toLowerCase() === 'coordinator') {
return this.resolveDevice(this.herdsman.getDevicesByType('Coordinator')[0].ieeeAddr);
} else {
const settingsDevice = settings.getDevice(key.toString());
if (settingsDevice) return this.resolveDevice(settingsDevice.ID);
if (settingsDevice) {
return this.resolveDevice(settingsDevice.ID);
}
const groupSettings = settings.getGroup(key);
if (groupSettings) {
const group = this.resolveGroup(groupSettings.ID);
// If group does not exist, create it (since it's already in configuration.yaml)
@@ -283,18 +293,66 @@ export default class Zigbee {
}
}
resolveEntityAndEndpoint(ID: string): {ID: string; entity: Device | Group | undefined; endpointID?: string; endpoint?: zh.Endpoint} {
// This function matches the following entity formats:
// device_name (just device name)
// device_name/ep_name (device name and endpoint numeric ID or name)
// device/name (device name with slashes)
// device/name/ep_name (device name with slashes, and endpoint numeric ID or name)
// The function tries to find an exact match first
let entityName = ID;
let deviceOrGroup = this.resolveEntity(ID);
let endpointNameOrID: string | undefined;
// If exact match did not happen, try matching a device_name/endpoint pattern
if (!deviceOrGroup) {
// First split the input token by the latest slash
const match = ID.match(entityIDRegex);
if (match) {
// Get the resulting IDs from the match
entityName = match[1];
deviceOrGroup = this.resolveEntity(entityName);
endpointNameOrID = match[2];
}
}
// If the function returns non-null endpoint name, but the endpoint field is null, then
// it means that endpoint was not matched because there is no such endpoint on the device
// (or the entity is a group)
const endpoint = deviceOrGroup?.isDevice() ? deviceOrGroup.endpoint(endpointNameOrID) : undefined;
return {ID: entityName, entity: deviceOrGroup, endpointID: endpointNameOrID, endpoint};
}
firstCoordinatorEndpoint(): zh.Endpoint {
return this.herdsman.getDevicesByType('Coordinator')[0].endpoints[0];
}
groups(): Group[] {
return this.herdsman.getGroups().map((g) => this.resolveGroup(g.groupID));
*devicesAndGroupsIterator(
devicePredicate?: (value: zh.Device) => boolean,
groupPredicate?: (value: zh.Group) => boolean,
): Generator<Device | Group> {
for (const device of this.herdsman.getDevicesIterator(devicePredicate)) {
yield this.resolveDevice(device.ieeeAddr)!;
}
for (const group of this.herdsman.getGroupsIterator(groupPredicate)) {
yield this.resolveGroup(group.groupID);
}
}
devices(includeCoordinator=true): Device[] {
return this.herdsman.getDevices()
.map((d) => this.resolveDevice(d.ieeeAddr))
.filter((d) => includeCoordinator || d.zh.type !== 'Coordinator');
*groupsIterator(predicate?: (value: zh.Group) => boolean): Generator<Group> {
for (const group of this.herdsman.getGroupsIterator(predicate)) {
yield this.resolveGroup(group.groupID);
}
}
*devicesIterator(predicate?: (value: zh.Device) => boolean): Generator<Device> {
for (const device of this.herdsman.getDevicesIterator(predicate)) {
yield this.resolveDevice(device.ieeeAddr)!;
}
}
@bind private async acceptJoiningDeviceHandler(ieeeAddr: string): Promise<boolean> {
@@ -323,11 +381,11 @@ export default class Zigbee {
}
async touchlinkFactoryResetFirst(): Promise<boolean> {
return this.herdsman.touchlinkFactoryResetFirst();
return await this.herdsman.touchlinkFactoryResetFirst();
}
async touchlinkFactoryReset(ieeeAddr: string, channel: number): Promise<boolean> {
return this.herdsman.touchlinkFactoryReset(ieeeAddr, channel);
return await this.herdsman.touchlinkFactoryReset(ieeeAddr, channel);
}
async addInstallCode(installCode: string): Promise<void> {
@@ -338,8 +396,8 @@ export default class Zigbee {
await this.herdsman.touchlinkIdentify(ieeeAddr, channel);
}
async touchlinkScan(): Promise<{ieeeAddr: string, channel: number}[]> {
return this.herdsman.touchlinkScan();
async touchlinkScan(): Promise<{ieeeAddr: string; channel: number}[]> {
return await this.herdsman.touchlinkScan();
}
createGroup(ID: number): Group {
@@ -347,7 +405,7 @@ export default class Zigbee {
return this.resolveGroup(ID);
}
deviceByNetworkAddress(networkAddress: number): Device {
deviceByNetworkAddress(networkAddress: number): Device | undefined {
const device = this.herdsman.getDeviceByNetworkAddress(networkAddress);
return device && this.resolveDevice(device.ieeeAddr);
}
-9849
View File
File diff suppressed because it is too large Load Diff
+100 -113
View File
@@ -1,116 +1,103 @@
{
"name": "zigbee2mqtt",
"version": "1.35.2",
"description": "Zigbee to MQTT bridge using Zigbee-herdsman",
"main": "index.js",
"repository": {
"type": "git",
"url": "git+https://github.com/Koenkk/zigbee2mqtt.git"
},
"engines": {
"node": "^18 || ^20 || ^21"
},
"keywords": [
"xiaomi",
"tradfri",
"hue",
"bridge",
"zigbee",
"mqtt",
"cc2531"
],
"scripts": {
"build": "tsc && node index.js writehash",
"build-watch": "tsc --watch",
"eslint": "eslint lib/ --max-warnings=0",
"start": "node index.js",
"test-with-coverage": "jest test --coverage",
"test": "jest test",
"test-watch": "jest test --watch"
},
"author": "Koen Kanters",
"license": "GPL-3.0",
"bugs": {
"url": "https://github.com/Koenkk/zigbee2mqtt/issues"
},
"homepage": "https://koenkk.github.io/zigbee2mqtt",
"dependencies": {
"ajv": "^8.12.0",
"bind-decorator": "^1.0.11",
"connect-gzip-static": "3.0.1",
"debounce": "^2.0.0",
"deep-object-diff": "^1.1.9",
"fast-deep-equal": "^3.1.3",
"finalhandler": "^1.2.0",
"git-last-commit": "^1.0.1",
"humanize-duration": "^3.31.0",
"js-yaml": "^4.1.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
"jszip": "^3.10.1",
"mkdir-recursive": "^0.4.0",
"moment": "^2.30.1",
"mqtt": "^5.3.4",
"object-assign-deep": "^0.4.0",
"rimraf": "^5.0.5",
"semver": "^7.5.4",
"source-map-support": "^0.5.21",
"uri-js": "^4.4.1",
"winston": "^3.11.0",
"winston-syslog": "^2.7.0",
"winston-transport": "^4.6.0",
"ws": "^8.16.0",
"zigbee-herdsman": "0.33.5",
"zigbee-herdsman-converters": "18.25.1",
"zigbee2mqtt-frontend": "0.6.152"
},
"devDependencies": {
"@babel/core": "^7.23.7",
"@babel/plugin-proposal-decorators": "^7.23.7",
"@babel/preset-env": "^7.23.7",
"@babel/preset-typescript": "^7.23.3",
"@types/debounce": "^1.2.4",
"@types/finalhandler": "^1.2.3",
"@types/humanize-duration": "^3.27.3",
"@types/jest": "^29.5.11",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.10.6",
"@types/object-assign-deep": "^0.4.3",
"@types/readable-stream": "4.0.10",
"@types/rimraf": "^3.0.2",
"@types/ws": "8.5.10",
"@typescript-eslint/eslint-plugin": "^6.16.0",
"@typescript-eslint/parser": "^6.16.0",
"babel-jest": "^29.7.0",
"eslint": "^8.56.0",
"eslint-config-google": "^0.14.0",
"eslint-plugin-jest": "^27.6.0",
"jest": "^29.7.0",
"tmp": "^0.2.1",
"typescript": "^5.3.3"
},
"overrides": {
"zigbee-herdsman-converters": {
"zigbee-herdsman": "$zigbee-herdsman"
}
},
"jest": {
"coverageThreshold": {
"global": {
"branches": 100,
"functions": 100,
"lines": 100,
"statements": 100
}
"name": "zigbee2mqtt",
"version": "2.1.1",
"description": "Zigbee to MQTT bridge using Zigbee-herdsman",
"main": "index.js",
"packageManager": "pnpm@9.15.4",
"repository": {
"type": "git",
"url": "git+https://github.com/Koenkk/zigbee2mqtt.git"
},
"collectCoverageFrom": [
"lib/**/*.js",
"lib/**/*.ts"
]
},
"bin": {
"zigbee2mqtt": "cli.js"
},
"optionalDependencies": {
"sd-notify": "^2.8.0"
}
"engines": {
"node": "^18 || ^20 || ^22 || ^23"
},
"keywords": [
"xiaomi",
"tradfri",
"hue",
"bridge",
"zigbee",
"mqtt",
"cc2531"
],
"scripts": {
"build": "tsc && node index.js writehash",
"build-watch": "tsc --watch",
"eslint": "eslint --max-warnings=0",
"pretty:write": "prettier --write .",
"pretty:check": "prettier --check .",
"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",
"prepack": "pnpm run clean && pnpm run build",
"clean": "rimraf coverage dist tsconfig.tsbuildinfo"
},
"author": "Koen Kanters",
"license": "GPL-3.0",
"bugs": {
"url": "https://github.com/Koenkk/zigbee2mqtt/issues"
},
"homepage": "https://koenkk.github.io/zigbee2mqtt",
"dependencies": {
"ajv": "^8.17.1",
"bind-decorator": "^1.0.11",
"debounce": "^2.2.0",
"express-static-gzip": "^2.2.0",
"fast-deep-equal": "^3.1.3",
"finalhandler": "^1.3.1",
"git-last-commit": "^1.0.1",
"humanize-duration": "^3.32.1",
"js-yaml": "^4.1.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
"jszip": "^3.10.1",
"moment": "^2.30.1",
"mqtt": "^5.10.3",
"object-assign-deep": "^0.4.0",
"rimraf": "^6.0.1",
"semver": "^7.7.0",
"source-map-support": "^0.5.21",
"throttleit": "^2.1.0",
"winston": "^3.17.0",
"winston-syslog": "^2.7.1",
"winston-transport": "^4.9.0",
"ws": "^8.18.0",
"zigbee-herdsman": "3.2.5",
"zigbee-herdsman-converters": "21.31.0",
"zigbee2mqtt-frontend": "0.9.4"
},
"devDependencies": {
"@eslint/core": "^0.11.0",
"@eslint/js": "^9.19.0",
"@ianvs/prettier-plugin-sort-imports": "^4.4.1",
"@types/eslint__js": "^8.42.3",
"@types/finalhandler": "^1.2.3",
"@types/humanize-duration": "^3.27.4",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.13.0",
"@types/object-assign-deep": "^0.4.3",
"@types/readable-stream": "4.0.18",
"@types/sd-notify": "^2.8.2",
"@types/serve-static": "^1.15.7",
"@types/ws": "8.5.14",
"@vitest/coverage-v8": "^3.0.4",
"eslint": "^9.19.0",
"eslint-config-prettier": "^10.0.1",
"prettier": "^3.4.2",
"tmp": "^0.2.3",
"typescript": "^5.7.3",
"typescript-eslint": "^8.22.0",
"vitest": "^3.0.4"
},
"pnpm": {
"overrides": {
"zigbee-herdsman": "$zigbee-herdsman"
}
},
"bin": {
"zigbee2mqtt": "cli.js"
},
"optionalDependencies": {
"sd-notify": "^2.8.0"
}
}
+3555
View File
File diff suppressed because it is too large Load Diff
+11 -10
View File
@@ -1,11 +1,12 @@
{
"packages": {
".": {
"release-type": "node",
"include-component-in-tag": false,
"include-v-in-tag": false,
"draft": true
}
},
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
}
"packages": {
".": {
"release-type": "node",
"include-component-in-tag": false,
"include-v-in-tag": false,
"draft": true
}
},
"pull-request-title-pattern": "chore${scope}: release ${version}",
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
}
+42 -20
View File
@@ -1,4 +1,3 @@
/* eslint max-len: 0 */
const path = require('path');
const fs = require('fs');
const process = require('process');
@@ -11,14 +10,27 @@ 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')},
{
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 = /## \[(.+)\]/;
@@ -37,6 +49,9 @@ if (fs.existsSync(commitUserFile)) {
commitUserLookup = JSON.parse(fs.readFileSync(commitUserFile, 'utf8'));
}
const whiteLabels = zhc.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);
@@ -52,22 +67,27 @@ for (const changelog of changelogs) {
} else if (line.startsWith('* **ignore:**')) {
continue;
} else if (changeMatch) {
let localContext = changelog.isFrontend ? 'frontend' : (changeMatch[2] ? changeMatch[2] : context);
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();
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);
message = message.charAt(0).toUpperCase() + message.slice(1);
if (changelog.isFrontend) {
changes[localContext].push(`- [${commit.slice(0, 7)}](https://github.com/${changelog.project}/commit/${commit}) ${message} (@${user})`);
messages.push(message);
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) {
@@ -78,14 +98,16 @@ for (const changelog of changelogs) {
if (localContext === 'add') {
for (const model of message.split(',')) {
const definition = zhc.definitions.find((d) => d.model === model.trim());
if (definition) {
messages.push(`\`${definition.model}\` ${definition.vendor} ${definition.description}`);
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(message);
messages.push(capitalizeFirstChar(message));
}
let issue = changeMatch[4].trim();
@@ -100,7 +122,7 @@ for (const changelog of changelogs) {
} else if (line === '# Changelog' || line === '### ⚠ BREAKING CHANGES' || !line) {
continue;
} else {
throw new Error(`Unmatched line: ${line}`);
changes['error'].push(`- Unmatched line: ${line}`);
}
}
}
@@ -119,7 +141,7 @@ for (const name of names) {
if (name[0] === 'add') {
result += `This release adds support for ${changes['add'].length} devices: \n`;
}
changes[name[0]].forEach((e) => result += `${e}\n`);
changes[name[0]].forEach((e) => (result += `${e}\n`));
result += '\n';
}
-75
View File
@@ -1,75 +0,0 @@
#!/bin/bash
function zigbee2mqtt-show-short-info {
echo "Setup for Zigbee2MQTT bridge."
}
function zigbee2mqtt-show-long-info {
echo "This script installs the Zigbee2MQTT bridge"
}
function zigbee2mqtt-show-copyright-info {
echo "Original concept by Landrash <https://github.com/landrash>."
}
function zigbee2mqtt-install-package {
echo -n "Installing dependencies : "
node=$(which npm)
if [ -z "${node}" ]; then #Installing NodeJS if not already installed.
printf "Downloading and installing NodeJS...\\n"
curl -sL https://deb.nodesource.com/setup_10.x | bash -
apt install -y nodejs
fi
echo "Cloning Zigbee2MQTT git repository"
git clone https://github.com/Koenkk/zigbee2mqtt.git /opt/zigbee2mqtt
chown -R pi:pi /opt/zigbee2mqtt
echo "Running install. This might take a while and can produce some expected errors"
cd /opt/zigbee2mqtt || exit
su pi -c "npm ci"
echo "Creating service file zigbee2mqtt.service"
service_path="/etc/systemd/system/zigbee2mqtt.service"
echo "[Unit]
Description=zigbee2mqtt
After=network.target
[Service]
Type=notify
ExecStart=/usr/bin/node index.js
WorkingDirectory=/opt/zigbee2mqtt
StandardOutput=inherit
StandardError=inherit
WatchdogSec=10s
Restart=always
User=pi
[Install]
WantedBy=multi-user.target" > $service_path
echo "Checking the installation..."
if [ ! -f /opt/zigbee2mqtt/data/configuration.yaml ]; then
validation=""
else
validation="ok"
fi
if [ ! -z "${validation}" ]; then
echo
echo -e "\\e[32mInstallation done..\\e[0m"
echo -e "Update of configuration.yaml is required found at /opt/zigbee2mqtt/data/"
echo -e "Some further configuration is required and details can be found here https://www.zigbee2mqtt.io"
echo
echo -e "Service can be started after configuration but running sudo systemctl start zigbee2mqtt"
echo
else
echo
echo -e "\\e[31mInstallation failed..."
echo
return 1
fi
return 0
}
[[ "$_" == "$0" ]] && zigbee2mqtt-install-package
+2 -2
View File
@@ -18,5 +18,5 @@ const sandbox = {
};
vm.runInNewContext(moduleCode, sandbox, moduleFakePath);
const converter = sandbox.module.exports;
assert(!converter.toZigbee.includes(undefined));
assert(!converter.fromZigbee.includes(undefined));
assert(!converter.toZigbee || !converter.toZigbee.includes(undefined));
assert(!converter.fromZigbee || !converter.fromZigbee.includes(undefined));
+20 -15
View File
@@ -3,7 +3,6 @@ const {ZnpVersion} = require('zigbee-herdsman/dist/adapter/z-stack/adapter/tstyp
const {Subsystem} = require('zigbee-herdsman/dist/adapter/z-stack/unpi/constants');
const {Znp} = require('zigbee-herdsman/dist/adapter/z-stack/znp');
class ZStackNvMemEraser {
constructor(device) {
this.znp = new Znp(device, 115200, false);
@@ -27,8 +26,7 @@ class ZStackNvMemEraser {
this.version = (await this.znp.request(Subsystem.SYS, 'version', {})).payload;
} catch (e) {
console.log(`Failed to get zStack version, assuming 1.2`);
this.version = {'transportrev': 2, 'product': 0, 'majorrel': 2,
'minorrel': 0, 'maintrel': 0, 'revision': ''};
this.version = {transportrev: 2, product: 0, majorrel: 2, minorrel: 0, maintrel: 0, revision: ''};
}
console.log(`Detected znp version '${ZnpVersion[this.version.product]}' (${JSON.stringify(this.version)})`);
@@ -41,34 +39,41 @@ class ZStackNvMemEraser {
async clearAllNvMemItems() {
let maxNvMemId;
switch (this.version.product) {
case ZnpVersion.zStack12: maxNvMemId = 0x0302; break;
case ZnpVersion.zStack30x: maxNvMemId = 0x033F; break;
case ZnpVersion.zStack3x0: maxNvMemId = 0x032F; break;
case ZnpVersion.zStack12:
maxNvMemId = 0x0302;
break;
case ZnpVersion.zStack30x:
maxNvMemId = 0x033f;
break;
case ZnpVersion.zStack3x0:
maxNvMemId = 0x032f;
break;
}
let deletedCount = 0;
console.log(`Clearing all NVMEM items, from 0 to ${maxNvMemId}`);
for (let id=0; id<=maxNvMemId; id++) {
for (let id = 0; id <= maxNvMemId; id++) {
let len;
const needOsal = !(this.version.product == ZnpVersion.zStack3x0 && id <= 7);
if (needOsal) {
const lengthRes = await this.znp.request(Subsystem.SYS, 'osalNvLength', {id: id});
len = lengthRes.payload['length'];
} else {
const lengthRes = await this.znp.request(Subsystem.SYS, 'nvLength',
{sysid: NvSystemIds.ZSTACK, itemid: id, subid: 0});
const lengthRes = await this.znp.request(Subsystem.SYS, 'nvLength', {sysid: NvSystemIds.ZSTACK, itemid: id, subid: 0});
len = lengthRes.payload['len'];
}
if (len != 0) {
console.log(`NVMEM item #${id} - deleting, size: ${len}`);
if (needOsal) {
await this.znp.request(Subsystem.SYS, 'osalNvDelete',
{id: id, len: len},
null, null, [ZnpCommandStatus.SUCCESS, ZnpCommandStatus.NV_ITEM_INITIALIZED]);
await this.znp.request(Subsystem.SYS, 'osalNvDelete', {id: id, len: len}, undefined, undefined, [
ZnpCommandStatus.SUCCESS,
ZnpCommandStatus.NV_ITEM_INITIALIZED,
]);
} else {
await this.znp.request(Subsystem.SYS, 'nvDelete',
{sysid: NvSystemIds.ZSTACK, itemid: id, subid: 0},
null, null, [ZnpCommandStatus.SUCCESS, ZnpCommandStatus.NV_ITEM_INITIALIZED]);
await this.znp.request(Subsystem.SYS, 'nvDelete', {sysid: NvSystemIds.ZSTACK, itemid: id, subid: 0}, undefined, undefined, [
ZnpCommandStatus.SUCCESS,
ZnpCommandStatus.NV_ITEM_INITIALIZED,
]);
}
deletedCount++;
}
@@ -0,0 +1,24 @@
const mockDevices = [
{
mock: 1,
model: 'external_converters_device_1',
zigbeeModel: ['external_converter_device_1'],
vendor: 'external_1',
description: 'external_1',
fromZigbee: [],
toZigbee: [],
exposes: [],
},
{
mock: 2,
model: 'external_converters_device_2',
zigbeeModel: ['external_converter_device_2'],
vendor: 'external_2',
description: 'external_2',
fromZigbee: [],
toZigbee: [],
exposes: [],
},
];
module.exports = mockDevices;
@@ -9,4 +9,4 @@ const mockDevice = {
exposes: [],
};
module.exports = mockDevice;
module.exports = mockDevice;
@@ -0,0 +1,16 @@
class Example2 {
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
this.mqtt = mqtt;
this.mqtt.publish('example2/extension', 'call2 from constructor');
}
start() {
this.mqtt.publish('example2/extension', 'call2 from start');
}
stop() {
this.mqtt.publish('example/extension', 'call2 from stop');
}
}
module.exports = Example2;
@@ -1,11 +1,15 @@
class Example {
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
this.mqtt = mqtt;
this.mqtt.publish('example/extension', 'call from constructor')
this.mqtt.publish('example/extension', 'call from constructor');
}
start() {
this.mqtt.publish('example/extension', 'test')
this.mqtt.publish('example/extension', 'call from start');
}
stop() {
this.mqtt.publish('example/extension', 'call from stop');
}
}
@@ -1,33 +0,0 @@
const homeassistantSwitch = {
type: 'switch',
object_id: 'switch',
discovery_payload: {
payload_off: 'OFF',
payload_on: 'ON',
value_template: '{{ value_json.state }}',
command_topic: true,
},
};
const mockDevices = [{
mock: 1,
model: 'external_converters_device_1',
homeassistant: [homeassistantSwitch],
zigbeeModel: ['external_converter_device_1'],
vendor: 'external_1',
description: 'external_1',
fromZigbee: [],
toZigbee: [],
exposes: [],
}, {
mock: 2,
model: 'external_converters_device_2',
zigbeeModel: ['external_converter_device_2'],
vendor: 'external_2',
description: 'external_2',
fromZigbee: [],
toZigbee: [],
exposes: [],
}];
module.exports = mockDevices;
-372
View File
@@ -1,372 +0,0 @@
import data from './stub/data';
import logger from './stub/logger';
import MQTT from './stub/mqtt';
import zigbeeHerdsman from './stub/zigbeeHerdsman';
import utils from '../lib/util/utils';
import * as settings from '../lib/util/settings';
import Controller from '../lib/controller';
import Availability from '../lib/extension/availability';
import flushPromises from './lib/flushPromises';
import stringify from 'json-stable-stringify-without-jsonify';
const mocks = [MQTT.publish, logger.warn, logger.debug];
const devices = zigbeeHerdsman.devices;
zigbeeHerdsman.returnDevices.push(
...[devices.bulb_color.ieeeAddr, devices.bulb_color_2.ieeeAddr, devices.coordinator.ieeeAddr, devices.remote.ieeeAddr])
describe('Availability', () => {
let controller;
let resetExtension = async () => {
await controller.enableDisableExtension(false, 'Availability');
await controller.enableDisableExtension(true, 'Availability');
}
const advancedTime = async (value) => {
jest.setSystemTime(Date.now() + value);
jest.advanceTimersByTime(value);
await flushPromises();
}
beforeAll(async () => {
jest.spyOn(utils, 'sleep').mockImplementation(async (seconds) => {});
jest.useFakeTimers('modern');
settings.reRead();
settings.set(['availability'], true);
controller = new Controller(jest.fn(), jest.fn());
await controller.start();
await flushPromises();
});
beforeEach(async () => {
jest.useFakeTimers('modern').setSystemTime(utils.minutes(1));
data.writeDefaultConfiguration();
settings.reRead();
settings.set(['availability'], true);
settings.set(['devices', devices.bulb_color_2.ieeeAddr, 'availability'], false);
Object.values(devices).forEach(d => d.lastSeen = utils.minutes(1));
mocks.forEach((m) => m.mockClear());
await resetExtension();
Object.values(devices).forEach((d) => d.ping.mockClear());
});
afterEach(async () => {
Object.values(devices).forEach(d => d.lastSeen = utils.minutes(1));
})
afterAll(async () => {
jest.useRealTimers();
})
it('Should publish availability on startup for device where it is enabled for', async () => {
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability',
'online', {retain: true, qos: 1}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/remote/availability',
'online', {retain: true, qos: 1}, expect.any(Function));
expect(MQTT.publish).not.toHaveBeenCalledWith('zigbee2mqtt/bulb_color_2/availability',
'online', {retain: true, qos: 1}, expect.any(Function));
});
it('Should publish offline for active device when not seen for 10 minutes', async () => {
MQTT.publish.mockClear();
await advancedTime(utils.minutes(5));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
await advancedTime(utils.minutes(7));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability',
'offline', {retain: true, qos: 1}, expect.any(Function));
});
it('Shouldnt do anything for a device when availability: false is set for device', async () => {
MQTT.publish.mockClear();
await zigbeeHerdsman.events.lastSeenChanged({device: devices.bulb_color_2}); // Coverage satisfaction
await advancedTime(utils.minutes(12));
expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(0);
});
it('Should publish offline for passive device when not seen for 25 hours', async () => {
MQTT.publish.mockClear();
await advancedTime(utils.hours(26));
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/remote/availability',
'offline', {retain: true, qos: 1}, expect.any(Function));
});
it('Should reset ping timer when device last seen changes for active device', async () => {
MQTT.publish.mockClear();
await advancedTime(utils.minutes(5));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
await zigbeeHerdsman.events.lastSeenChanged({device: devices.bulb_color});
await advancedTime(utils.minutes(7));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
await advancedTime(utils.minutes(10));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability',
'offline', {retain: true, qos: 1}, expect.any(Function));
});
it('Should ping again when first ping fails', async () => {
MQTT.publish.mockClear();
await advancedTime(utils.minutes(5));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
await zigbeeHerdsman.events.lastSeenChanged({device: devices.bulb_color});
await advancedTime(utils.minutes(7));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
devices.bulb_color.ping.mockImplementationOnce(() => {throw new Error('failed')});
devices.bulb_color.lastSeen = Date.now() + utils.minutes(10);
await advancedTime(utils.minutes(10));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(2, false);
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability',
'offline', {retain: true, qos: 1}, expect.any(Function));
});
it('Should reset ping timer when device last seen changes for passive device', async () => {
MQTT.publish.mockClear();
await advancedTime(utils.hours(24));
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
await zigbeeHerdsman.events.lastSeenChanged({device: devices.remote});
await advancedTime(utils.hours(25));
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
devices.remote.ping.mockImplementationOnce(() => {throw new Error('failed')});
await advancedTime(utils.hours(3));
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/remote/availability',
'offline', {retain: true, qos: 1}, expect.any(Function));
});
it('Should immediately mark device as online when it lastSeen changes', async () => {
MQTT.publish.mockClear();
await advancedTime(utils.minutes(15));
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability',
'offline', {retain: true, qos: 1}, expect.any(Function));
devices.bulb_color.lastSeen = Date.now();
await zigbeeHerdsman.events.lastSeenChanged({device: devices.bulb_color});
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability',
'online', {retain: true, qos: 1}, expect.any(Function));
});
it('Should allow to change availability timeout via device options', async () => {
settings.set(['devices', '0x000b57fffec6a5b3', 'availability'], {timeout: 40});
await resetExtension();
MQTT.publish.mockClear();
await advancedTime(utils.minutes(25));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
await advancedTime(utils.minutes(17));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
});
it('Should not ping disabled devices', async () => {
settings.set(['devices', devices.bulb_color.ieeeAddr, 'disabled'], true);
await resetExtension();
MQTT.publish.mockClear();
await advancedTime(utils.minutes(15));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
});
it('Should allow to change availability timeout via avaiability options', async () => {
settings.set(['availability'], {active: {timeout: 30}});
await resetExtension();
MQTT.publish.mockClear();
await advancedTime(utils.minutes(25));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
await advancedTime(utils.minutes(7));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
});
it('Should stop pinging device when it leaves', async () => {
await resetExtension();
MQTT.publish.mockClear();
await advancedTime(utils.minutes(9));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
await zigbeeHerdsman.events.deviceLeave({ieeeAddr: devices.bulb_color.ieeeAddr});
await flushPromises();
await advancedTime(utils.minutes(3));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
});
it('Should stop pinging device when it is removed', async () => {
await resetExtension();
MQTT.publish.mockClear();
await advancedTime(utils.minutes(9));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
MQTT.events.message('zigbee2mqtt/bridge/request/device/remove', stringify({id: "bulb_color"}));
await flushPromises();
await advancedTime(utils.minutes(3));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
});
it('Should allow to be disabled', async () => {
settings.set(['availability'], false);
await resetExtension();
await advancedTime(utils.minutes(12));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
});
it('Should to enable availability for just one device', async () => {
settings.set(['availability'], false);
settings.set(['devices', devices.bulb_color.ieeeAddr, 'availability'], true);
await resetExtension();
MQTT.publish.mockClear();
await advancedTime(utils.minutes(11));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
});
it('Should retrieve device state when it reconnects', async () => {
MQTT.publish.mockClear();
const device = controller.zigbee.resolveEntity(devices.bulb_color.ieeeAddr);
controller.state.set(device, {state: 'OFF'})
const endpoint = devices.bulb_color.getEndpoint(1);
endpoint.read.mockClear();
await zigbeeHerdsman.events.deviceAnnounce({device: devices.bulb_color});
await flushPromises();
await advancedTime(utils.seconds(1));
await zigbeeHerdsman.events.deviceAnnounce({device: devices.bulb_color});
await flushPromises();
expect(endpoint.read).toHaveBeenCalledTimes(0);
await advancedTime(utils.seconds(2));
expect(endpoint.read).toHaveBeenCalledTimes(1);
expect(endpoint.read).toHaveBeenCalledWith('genOnOff', ['onOff']);
endpoint.read.mockClear();
await zigbeeHerdsman.events.deviceAnnounce({device: devices.bulb_color});
await flushPromises();
endpoint.read.mockImplementationOnce(() => {throw new Error('')});
await advancedTime(utils.seconds(3));
expect(endpoint.read).toHaveBeenCalledTimes(1);
});
it('Should republish availability when device is renamed', async () => {
MQTT.publish.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/request/device/rename', stringify({from: 'bulb_color', to: 'bulb_new_name'}));
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability',
null, {retain: true, qos: 1}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_new_name/availability',
'online', {retain: true, qos: 1}, expect.any(Function));
await advancedTime(utils.hours(12));
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_new_name/availability',
'offline', {retain: true, qos: 1}, expect.any(Function));
});
it('Should publish availability payload in JSON format', async () => {
settings.set(['advanced', 'legacy_availability_payload'], false);
await resetExtension();
MQTT.publish.mockClear();
await advancedTime(utils.hours(26));
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/remote/availability',
stringify({state: 'offline'}), {retain: true, qos: 1}, expect.any(Function));
});
it('Deprecated - should allow to block via advanced.availability_blocklist', async () => {
settings.set(['advanced', 'availability_blocklist'], [devices.bulb_color.ieeeAddr]);
await resetExtension();
await advancedTime(utils.minutes(12));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
});
it('Deprecated - should allow to pass certain devices via availability_passlist', async () => {
settings.set(['advanced', 'availability_passlist'], [devices.bulb_color_2.ieeeAddr]);
settings.changeEntityOptions(devices.bulb_color_2.ieeeAddr, {availability: null});
await resetExtension();
await advancedTime(utils.minutes(12));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(1);
});
it('Deprecated - should allow to enable via availability_timeout', async () => {
settings.set(['availability'], false);
settings.set(['advanced', 'availability_timeout'], 60);
await resetExtension();
await advancedTime(utils.minutes(12));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
});
it('Should publish availability for groups', async () => {
settings.set(['devices', devices.bulb_color_2.ieeeAddr, 'availability'], true);
await resetExtension();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/group_tradfri_remote/availability',
'online', {retain: true, qos: 1}, expect.any(Function));
MQTT.publish.mockClear();
await advancedTime(utils.minutes(12));
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/group_tradfri_remote/availability',
'offline', {retain: true, qos: 1}, expect.any(Function));
MQTT.publish.mockClear();
devices.bulb_color_2.lastSeen = Date.now();
await zigbeeHerdsman.events.lastSeenChanged({device: devices.bulb_color_2});
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/group_tradfri_remote/availability',
'online', {retain: true, qos: 1}, expect.any(Function));
});
it('Should clear the ping queue on stop', async () => {
const availability = controller.extensions.find((extension) => extension instanceof Availability);
const publishAvailabilitySpy = jest.spyOn(availability, 'publishAvailability');
devices.bulb_color.zh = { ping: jest.fn().mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 1000)))};
availability.addToPingQueue(devices.bulb_color);
availability.addToPingQueue(devices.bulb_color_2);
await availability.stop();
await advancedTime(utils.minutes(1));
expect(availability.pingQueue).toEqual([]);
// Validate the stop-interrupt implicitly by checking that it prevents further function invocations
expect(publishAvailabilitySpy).not.toHaveBeenCalled();
});
it('Should prevent instance restart', async () => {
const availability = controller.extensions.find((extension) => extension instanceof Availability);
await availability.stop();
await expect(() => availability.start()).rejects.toThrowError();
});
});
-624
View File
@@ -1,624 +0,0 @@
const data = require('./stub/data');
const logger = require('./stub/logger');
const zigbeeHerdsman = require('./stub/zigbeeHerdsman');
const MQTT = require('./stub/mqtt');
const settings = require('../lib/util/settings');
const Controller = require('../lib/controller');
const flushPromises = require('./lib/flushPromises');
const stringify = require('json-stable-stringify-without-jsonify');
jest.mock('debounce', () => jest.fn(fn => fn));
const debounce = require('debounce');
describe('Bind', () => {
let controller;
const mockClear = (device) => {
for (const endpoint of device.endpoints) {
endpoint.read.mockClear();
endpoint.write.mockClear();
endpoint.configureReporting.mockClear();
endpoint.bind = jest.fn();
endpoint.bind.mockClear();
endpoint.unbind.mockClear();
}
}
let resetExtension = async () => {
await controller.enableDisableExtension(false, 'Bind');
await controller.enableDisableExtension(true, 'Bind');
}
beforeAll(async () => {
jest.useFakeTimers();
controller = new Controller(jest.fn(), jest.fn());
await controller.start();
await flushPromises();
});
beforeEach(async () => {
data.writeDefaultConfiguration();
settings.reRead();
zigbeeHerdsman.groups.group_1.members = [];
zigbeeHerdsman.devices.bulb_color.getEndpoint(1).configureReporting.mockClear();
zigbeeHerdsman.devices.bulb_color.getEndpoint(1).bind.mockClear();
zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read.mockClear();
debounce.mockClear();
await resetExtension();
MQTT.publish.mockClear();
});
afterAll(async () => {
jest.useRealTimers();
})
it('Should bind to device and configure reporting', async () => {
const device = zigbeeHerdsman.devices.remote;
const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1);
const endpoint = device.getEndpoint(1);
// Setup
const originalDeviceOutputClusters = device.getEndpoint(1).outputClusters;
device.getEndpoint(1).outputClusters = [...device.getEndpoint(1).outputClusters, 768];
const originalTargetBinds = target.binds;
target.binds = [{cluster: {name: 'genLevelCtrl'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)}];
target.getClusterAttributeValue.mockImplementationOnce((cluster, value) => undefined);
mockClear(device);
target.configureReporting.mockImplementationOnce(() => {throw new Error("timeout")});
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({transaction: "1234", from: 'remote', to: 'bulb_color'}));
await flushPromises();
expect(target.read).toHaveBeenCalledWith('lightingColorCtrl', [ 'colorCapabilities' ]);
expect(endpoint.bind).toHaveBeenCalledTimes(4);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target);
expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target);
expect(endpoint.bind).toHaveBeenCalledWith("lightingColorCtrl", target);
expect(target.configureReporting).toHaveBeenCalledTimes(3);
expect(target.configureReporting).toHaveBeenCalledWith("genOnOff",[{"attribute": "onOff", "maximumReportInterval": 3600, "minimumReportInterval": 0, "reportableChange": 0}]);
expect(target.configureReporting).toHaveBeenCalledWith("genLevelCtrl",[{"attribute": "currentLevel", "maximumReportInterval": 3600, "minimumReportInterval": 5, "reportableChange": 1}]);
expect(target.configureReporting).toHaveBeenCalledWith("lightingColorCtrl",[{"attribute":"colorTemperature","minimumReportInterval":5,"maximumReportInterval":3600,"reportableChange":1},{"attribute":"currentX","minimumReportInterval":5,"maximumReportInterval":3600,"reportableChange":1},{"attribute":"currentY","minimumReportInterval":5,"maximumReportInterval":3600,"reportableChange":1}]);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({"transaction": "1234","data":{"from":"remote","to":"bulb_color","clusters":["genScenes","genOnOff","genLevelCtrl", "lightingColorCtrl"],"failed":[]},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/devices',
expect.any(String),
{ retain: true, qos: 0 },
expect.any(Function)
);
// Teardown
target.binds = originalTargetBinds;
device.getEndpoint(1).outputClusters = originalDeviceOutputClusters;
});
it('Should bind only specifief clusters', async () => {
const device = zigbeeHerdsman.devices.remote;
const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1);
const endpoint = device.getEndpoint(1);
mockClear(device);
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'bulb_color', clusters: ["genOnOff"]}));
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(1);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({"data":{"from":"remote","to":"bulb_color","clusters":["genOnOff"],"failed":[]},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
);
});
it('Should log error when there is nothing to bind', async () => {
const device = zigbeeHerdsman.devices.bulb_color;
const endpoint = device.getEndpoint(1);
mockClear(device);
logger.error.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'button'}));
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(0);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({"data":{"from":"remote","to":"button","clusters":[],"failed":[]},"status":"error","error":"Nothing to bind"}),
{retain: false, qos: 0}, expect.any(Function)
);
});
it('Should unbind', async () => {
const device = zigbeeHerdsman.devices.remote;
const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1);
// setup
target.configureReporting.mockImplementationOnce(() => {throw new Error("timeout")});
const originalRemoteBinds = device.getEndpoint(1).binds;
device.getEndpoint(1).binds = [];
const originalTargetBinds = target.binds;
target.binds = [
{cluster: {name: 'genOnOff'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)},
{cluster: {name: 'genLevelCtrl'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)},
{cluster: {name: 'lightingColorCtrl'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)}
];
const endpoint = device.getEndpoint(1);
mockClear(device);
delete zigbeeHerdsman.devices.bulb_color.meta.configured;
expect(zigbeeHerdsman.devices.bulb_color.meta.configured).toBe(undefined);
MQTT.events.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'bulb_color'}));
await flushPromises();
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", target);
expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", target);
// Disable reporting
expect(target.configureReporting).toHaveBeenCalledTimes(3);
expect(target.configureReporting).toHaveBeenCalledWith("genOnOff",[{"attribute": "onOff", "maximumReportInterval": 0xFFFF, "minimumReportInterval": 0, "reportableChange": 0}]);
expect(target.configureReporting).toHaveBeenCalledWith("genLevelCtrl",[{"attribute": "currentLevel", "maximumReportInterval": 0xFFFF, "minimumReportInterval": 5, "reportableChange": 1}]);
expect(target.configureReporting).toHaveBeenCalledWith("lightingColorCtrl",[{"attribute":"colorTemperature","minimumReportInterval":5,"maximumReportInterval":0xFFFF,"reportableChange":1},{"attribute":"currentX","minimumReportInterval":5,"maximumReportInterval":0xFFFF,"reportableChange":1},{"attribute":"currentY","minimumReportInterval":5,"maximumReportInterval":0xFFFF,"reportableChange":1}]);
expect(zigbeeHerdsman.devices.bulb_color.meta.configured).toBe(1324213189);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/unbind',
stringify({"data":{"from":"remote","to":"bulb_color","clusters":["genScenes","genOnOff","genLevelCtrl"],"failed":[]},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
);
// Teardown
target.binds = originalTargetBinds;
device.getEndpoint(1).binds = originalRemoteBinds;
});
it('Should unbind coordinator', async () => {
const device = zigbeeHerdsman.devices.remote;
const target = zigbeeHerdsman.devices.coordinator.getEndpoint(1);
const endpoint = device.getEndpoint(1);
mockClear(device);
endpoint.unbind.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'Coordinator'}));
await flushPromises();
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", target);
expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", target);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/unbind',
stringify({"data":{"from":"remote","to":"Coordinator","clusters":["genScenes","genOnOff","genLevelCtrl"],"failed":[]},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
);
});
it('Should bind to groups', async () => {
const device = zigbeeHerdsman.devices.remote;
const target = zigbeeHerdsman.groups.group_1;
const target1Member = zigbeeHerdsman.devices.bulb.getEndpoint(1);
const endpoint = device.getEndpoint(1);
target.members.push(target1Member);
target1Member.configureReporting.mockClear();
mockClear(device);
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'group_1'}));
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(3);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target);
expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target);
expect(target1Member.configureReporting).toHaveBeenCalledTimes(2);
expect(target1Member.configureReporting).toHaveBeenCalledWith("genOnOff",[{"attribute": "onOff", "maximumReportInterval": 3600, "minimumReportInterval": 0, "reportableChange": 0}]);
expect(target1Member.configureReporting).toHaveBeenCalledWith("genLevelCtrl",[{"attribute": "currentLevel", "maximumReportInterval": 3600, "minimumReportInterval": 5, "reportableChange": 1}]);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({"data":{"from":"remote","to":"group_1","clusters":["genScenes","genOnOff","genLevelCtrl"],"failed":[]},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
);
// Should configure reproting for device added to group
target1Member.configureReporting.mockClear();
await MQTT.events.message('zigbee2mqtt/bridge/group/group_1/add', 'bulb');
await flushPromises();
expect(target1Member.configureReporting).toHaveBeenCalledTimes(2);
expect(target1Member.configureReporting).toHaveBeenCalledWith("genOnOff",[{"attribute": "onOff", "maximumReportInterval": 3600, "minimumReportInterval": 0, "reportableChange": 0}]);
expect(target1Member.configureReporting).toHaveBeenCalledWith("genLevelCtrl",[{"attribute": "currentLevel", "maximumReportInterval": 3600, "minimumReportInterval": 5, "reportableChange": 1}]);
});
it('Should unbind from group', async () => {
const device = zigbeeHerdsman.devices.remote;
const target = zigbeeHerdsman.groups.group_1;
const target1Member = zigbeeHerdsman.devices.bulb.getEndpoint(1);
const endpoint = device.getEndpoint(1);
target.members.push(target1Member);
target1Member.configureReporting.mockClear();
mockClear(device);
MQTT.events.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'group_1'}));
await flushPromises();
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", target);
expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", target);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/unbind',
stringify({"data":{"from":"remote","to":"group_1","clusters":["genScenes","genOnOff","genLevelCtrl"],"failed":[]},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
);
});
it('Should unbind from group with skip_disable_reporting=true', async () => {
const device = zigbeeHerdsman.devices.remote;
const target = zigbeeHerdsman.groups.group_1;
const target1Member = zigbeeHerdsman.devices.bulb_2.getEndpoint(1);
const endpoint = device.getEndpoint(1);
target.members.push(target1Member);
// The device unbind mock doesn't remove binds, therefore remove them here already otherwise configure reporiting is not disabled.
const originalBinds = endpoint.binds;
endpoint.binds = [];
target1Member.binds = [{cluster: {name: 'genLevelCtrl'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)}, {cluster: {name: 'genOnOff'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)}];
target1Member.configureReporting.mockClear();
mockClear(device);
MQTT.events.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'group_1', skip_disable_reporting: true}));
await flushPromises();
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
// with skip_disable_reporting set to false, we don't expect it to reconfigure reporting
expect(target1Member.configureReporting).toHaveBeenCalledTimes(0);
endpoint.binds = originalBinds;
});
it('Should unbind from group with skip_disable_reporting=false', async () => {
const device = zigbeeHerdsman.devices.remote;
const target = zigbeeHerdsman.groups.group_1;
const target1Member = zigbeeHerdsman.devices.bulb_2.getEndpoint(1);
const endpoint = device.getEndpoint(1);
target.members.push(target1Member);
// The device unbind mock doesn't remove binds, therefore remove them here already otherwise configure reporiting is not disabled.
const originalBinds = endpoint.binds;
endpoint.binds = [];
target1Member.binds = [{cluster: {name: 'genLevelCtrl'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)}, {cluster: {name: 'genOnOff'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)}];
target1Member.configureReporting.mockClear();
mockClear(device);
MQTT.events.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'group_1', skip_disable_reporting: false}));
await flushPromises();
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
// with skip_disable_reporting set, we expect it to reconfigure reporting
expect(target1Member.configureReporting).toHaveBeenCalledTimes(2);
expect(target1Member.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [{"attribute": "currentLevel", "maximumReportInterval": 65535, "minimumReportInterval": 5, "reportableChange": 1}])
expect(target1Member.configureReporting).toHaveBeenCalledWith('genOnOff', [{"attribute": "onOff", "maximumReportInterval": 65535, "minimumReportInterval": 0, "reportableChange": 0}])
endpoint.binds = originalBinds;
});
it('Should bind to group by number', async () => {
const device = zigbeeHerdsman.devices.remote;
const target = zigbeeHerdsman.groups.group_1;
const endpoint = device.getEndpoint(1);
mockClear(device);
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: '1'}));
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(3);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target);
expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({"data":{"from":"remote","to":"1","clusters":["genScenes","genOnOff","genLevelCtrl"],"failed":[]},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
);
});
it('Should log when bind fails', async () => {
logger.error.mockClear();
const device = zigbeeHerdsman.devices.remote;
const endpoint = device.getEndpoint(1);
mockClear(device);
endpoint.bind.mockImplementation(() => {throw new Error('failed')});
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'bulb_color'}));
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(3);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({"data":{"from":"remote","to":"bulb_color","clusters":[],"failed":["genScenes","genOnOff","genLevelCtrl"]},"status":"error","error":"Failed to bind"}),
{retain: false, qos: 0}, expect.any(Function)
);
});
it('Should bind from non default endpoints', async () => {
const device = zigbeeHerdsman.devices.remote;
const target = zigbeeHerdsman.devices.QBKG03LM.getEndpoint(3);
const endpoint = device.getEndpoint(2);
mockClear(device);
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote/ep2', to: 'wall_switch_double/right'}));
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(1);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({"data":{"from":"remote/ep2","to":"wall_switch_double/right","clusters":["genOnOff"],"failed":[]},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
);
});
it('Should bind server clusters to client clusters', async () => {
const device = zigbeeHerdsman.devices.temperature_sensor;
const target = zigbeeHerdsman.devices.heating_actuator.getEndpoint(1);
const endpoint = device.getEndpoint(1);
mockClear(device);
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'temperature_sensor', to: 'heating_actuator'}));
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(1);
expect(endpoint.bind).toHaveBeenCalledWith("msTemperatureMeasurement", target);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({"data":{"from":"temperature_sensor","to":"heating_actuator","clusters":["msTemperatureMeasurement"],"failed":[]},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
);
});
it('Should bind to default endpoint returned by endpoints()', async () => {
const device = zigbeeHerdsman.devices.remote;
const target = zigbeeHerdsman.devices.QBKG04LM.getEndpoint(2);
const endpoint = device.getEndpoint(2);
mockClear(device);
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote/ep2', to: 'wall_switch'}));
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(1);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({"data":{"from":"remote/ep2","to":"wall_switch","clusters":["genOnOff"],"failed":[]},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
);
});
it('Should unbind from default_bind_group', async () => {
const device = zigbeeHerdsman.devices.remote;
const target = 'default_bind_group';
const endpoint = device.getEndpoint(1);
mockClear(device);
MQTT.events.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(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/unbind',
stringify({"data":{"from":"remote","to":"default_bind_group","clusters":["genScenes","genOnOff","genLevelCtrl"],"failed":[]},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
);
});
it('Error bind fails when source not existing', async () => {
const device = zigbeeHerdsman.devices.remote;
const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1);
const endpoint = device.getEndpoint(1);
mockClear(device);
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote_not_existing', to: 'bulb_color'}));
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({"data":{"from":"remote_not_existing","to":"bulb_color"},"status":"error","error":"Source device 'remote_not_existing' does not exist"}),
{retain: false, qos: 0}, expect.any(Function)
);
});
it('Error bind fails when target not existing', async () => {
const device = zigbeeHerdsman.devices.remote;
const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1);
const endpoint = device.getEndpoint(1);
mockClear(device);
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'bulb_color_not_existing'}));
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({"data":{"from":"remote","to":"bulb_color_not_existing"},"status":"error","error":"Target device or group 'bulb_color_not_existing' does not exist"}),
{retain: false, qos: 0}, expect.any(Function)
);
});
it('Legacy api: Should bind', async () => {
const device = zigbeeHerdsman.devices.remote;
const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1);
const endpoint = device.getEndpoint(1);
mockClear(device);
MQTT.events.message('zigbee2mqtt/bridge/bind/remote', 'bulb_color');
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(3);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target);
expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({type: 'device_bind', message: {from: 'remote', to: 'bulb_color', cluster: 'genScenes'}});
expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({type: 'device_bind', message: {from: 'remote', to: 'bulb_color', cluster: 'genOnOff'}});
expect(MQTT.publish.mock.calls[2][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({type: 'device_bind', message: {from: 'remote', to: 'bulb_color', cluster: 'genLevelCtrl'}});
});
it('Legacy api: Should log error when there is nothing to bind', async () => {
const device = zigbeeHerdsman.devices.bulb_color;
const endpoint = device.getEndpoint(1);
mockClear(device);
logger.error.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/bind/remote', 'button');
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(0);
expect(logger.error).toHaveBeenCalledWith(`Nothing to bind from 'remote' to 'button'`);
});
it('Legacy api: Should unbind', async () => {
const device = zigbeeHerdsman.devices.remote;
const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1);
const endpoint = device.getEndpoint(1);
mockClear(device);
MQTT.events.message('zigbee2mqtt/bridge/unbind/remote', 'bulb_color');
await flushPromises();
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", target);
expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", target);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({type: 'device_unbind', message: {from: 'remote', to: 'bulb_color', cluster: 'genScenes'}});
expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({type: 'device_unbind', message: {from: 'remote', to: 'bulb_color', cluster: 'genOnOff'}});
expect(MQTT.publish.mock.calls[2][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({type: 'device_unbind', message: {from: 'remote', to: 'bulb_color', cluster: 'genLevelCtrl'}});
});
it('Legacy api: Should unbind coordinator', async () => {
const device = zigbeeHerdsman.devices.remote;
const target = zigbeeHerdsman.devices.coordinator.getEndpoint(1);
const endpoint = device.getEndpoint(1);
mockClear(device);
endpoint.unbind.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/unbind/remote', 'Coordinator');
await flushPromises();
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", target);
expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", target);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({type: 'device_unbind', message: {from: 'remote', to: 'Coordinator', cluster: 'genScenes'}});
expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({type: 'device_unbind', message: {from: 'remote', to: 'Coordinator', cluster: 'genOnOff'}});
expect(MQTT.publish.mock.calls[2][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({type: 'device_unbind', message: {from: 'remote', to: 'Coordinator', cluster: 'genLevelCtrl'}});
});
it('Legacy api: Should bind to groups', async () => {
const device = zigbeeHerdsman.devices.remote;
const target = zigbeeHerdsman.groups.group_1;
const endpoint = device.getEndpoint(1);
mockClear(device);
MQTT.events.message('zigbee2mqtt/bridge/bind/remote', 'group_1');
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(3);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target);
expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({type: 'device_bind', message: {from: 'remote', to: 'group_1', cluster: 'genScenes'}});
expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({type: 'device_bind', message: {from: 'remote', to: 'group_1', cluster: 'genOnOff'}});
expect(MQTT.publish.mock.calls[2][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({type: 'device_bind', message: {from: 'remote', to: 'group_1', cluster: 'genLevelCtrl'}});
});
it('Legacy api: Should bind to group by number', async () => {
const device = zigbeeHerdsman.devices.remote;
const target = zigbeeHerdsman.groups.group_1;
const endpoint = device.getEndpoint(1);
mockClear(device);
MQTT.events.message('zigbee2mqtt/bridge/bind/remote', '1');
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(3);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target);
expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({type: 'device_bind', message: {from: 'remote', to: 'group_1', cluster: 'genScenes'}});
expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({type: 'device_bind', message: {from: 'remote', to: 'group_1', cluster: 'genOnOff'}});
expect(MQTT.publish.mock.calls[2][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({type: 'device_bind', message: {from: 'remote', to: 'group_1', cluster: 'genLevelCtrl'}});
});
it('Legacy api: Should log when bind fails', async () => {
logger.error.mockClear();
const device = zigbeeHerdsman.devices.remote;
const endpoint = device.getEndpoint(1);
mockClear(device);
endpoint.bind.mockImplementationOnce(() => {throw new Error('failed')});
MQTT.events.message('zigbee2mqtt/bridge/bind/remote', 'bulb_color');
await flushPromises();
expect(logger.error).toHaveBeenCalledWith("Failed to bind cluster 'genScenes' from 'remote' to 'bulb_color' (Error: failed)");
expect(endpoint.bind).toHaveBeenCalledTimes(3);
});
it('Legacy api: Should bind from non default endpoints', async () => {
const device = zigbeeHerdsman.devices.remote;
const target = zigbeeHerdsman.devices.QBKG03LM.getEndpoint(3);
const endpoint = device.getEndpoint(2);
mockClear(device);
MQTT.events.message('zigbee2mqtt/bridge/bind/remote/ep2', 'wall_switch_double/right');
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(1);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
});
it('Legacy api: Should bind to default endpoint returned by endpoints()', async () => {
const device = zigbeeHerdsman.devices.remote;
const target = zigbeeHerdsman.devices.QBKG04LM.getEndpoint(2);
const endpoint = device.getEndpoint(2);
mockClear(device);
MQTT.events.message('zigbee2mqtt/bridge/bind/remote/ep2', 'wall_switch');
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(1);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
});
it('Legacy api: Should unbind from default_bind_group', async () => {
const device = zigbeeHerdsman.devices.remote;
const target = 'default_bind_group';
const endpoint = device.getEndpoint(1);
mockClear(device);
MQTT.events.message('zigbee2mqtt/bridge/unbind/remote', 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(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({type: 'device_unbind', message: {from: 'remote', to: 'default_bind_group', cluster: 'genScenes'}});
expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({type: 'device_unbind', message: {from: 'remote', to: 'default_bind_group', cluster: 'genOnOff'}});
expect(MQTT.publish.mock.calls[2][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({type: 'device_unbind', message: {from: 'remote', to: 'default_bind_group', cluster: 'genLevelCtrl'}});
});
it('Should poll bounded Hue bulb when receiving message from Hue dimmer', async () => {
const remote = zigbeeHerdsman.devices.remote;
const data = {"button":3,"unknown1":3145728,"type":2,"unknown2":0,"time":1};
const payload = {data, cluster: 'manuSpecificPhilips', device: remote, endpoint: remote.getEndpoint(2), type: 'commandHueNotification', linkquality: 10, groupID: 0};
await zigbeeHerdsman.events.message(payload);
await flushPromises();
expect(debounce).toHaveBeenCalledTimes(1);
expect(zigbeeHerdsman.devices.bulb_color.getEndpoint(1).read).toHaveBeenCalledWith("genLevelCtrl", ["currentLevel"]);
});
it('Should poll bounded Hue bulb when receiving message from scene controller', async () => {
const remote = zigbeeHerdsman.devices.bj_scene_switch;
const data = {"action": "recall_2_row_1"};
zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read.mockImplementationOnce(() => {throw new Error('failed')});
const payload = {data, cluster: 'genScenes', device: remote, endpoint: remote.getEndpoint(10), type: 'commandRecall', linkquality: 10, groupID: 0};
await zigbeeHerdsman.events.message(payload);
await flushPromises();
// Calls to three clusters are expected in this case
expect(debounce).toHaveBeenCalledTimes(3);
expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledWith("genOnOff", ["onOff"]);
expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledWith("genLevelCtrl", ["currentLevel"]);
expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledWith("lightingColorCtrl", ["currentX", "currentY", "colorTemperature"]);
});
it('Should poll grouped Hue bulb when receiving message from TRADFRI remote', async () => {
zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read.mockClear();
zigbeeHerdsman.devices.bulb_2.getEndpoint(1).read.mockClear();
const remote = zigbeeHerdsman.devices.tradfri_remote;
const data = {"stepmode":0,"stepsize":43,"transtime":5};
const payload = {data, cluster: 'genLevelCtrl', device: remote, endpoint: remote.getEndpoint(1), type: 'commandStepWithOnOff', linkquality: 10, groupID: 15071};
await zigbeeHerdsman.events.message(payload);
await flushPromises();
expect(debounce).toHaveBeenCalledTimes(2);
expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledTimes(2);
expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledWith("genLevelCtrl", ["currentLevel"]);
expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledWith("genOnOff", ["onOff"]);
// Should also only debounce once
await zigbeeHerdsman.events.message(payload);
await flushPromises();
expect(debounce).toHaveBeenCalledTimes(2);
expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledTimes(4);
// Should only call Hue bulb, not e.g. tradfri
expect(zigbeeHerdsman.devices.bulb_2.getEndpoint(1).read).toHaveBeenCalledTimes(0);
});
});
-1331
View File
File diff suppressed because one or more lines are too long
-257
View File
@@ -1,257 +0,0 @@
const data = require('./stub/data');
const logger = require('./stub/logger');
const zigbeeHerdsman = require('./stub/zigbeeHerdsman');
const MQTT = require('./stub/mqtt');
const settings = require('../lib/util/settings');
const Controller = require('../lib/controller');
const flushPromises = require('./lib/flushPromises');
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const stringify = require('json-stable-stringify-without-jsonify');
const mocksClear = [MQTT.publish, logger.warn, logger.debug];
describe('Configure', () => {
let controller;
let coordinatorEndpoint;
const expectRemoteConfigured = () => {
const device = zigbeeHerdsman.devices.remote;
const endpoint1 = device.getEndpoint(1);
expect(endpoint1.bind).toHaveBeenCalledTimes(2);
expect(endpoint1.bind).toHaveBeenCalledWith('genOnOff', coordinatorEndpoint);
expect(endpoint1.bind).toHaveBeenCalledWith('genLevelCtrl', coordinatorEndpoint);
const endpoint2 = device.getEndpoint(2);
expect(endpoint2.write).toHaveBeenCalledTimes(1);
expect(endpoint2.write).toHaveBeenCalledWith("genBasic", {"49": {"type": 25, "value": 11}}, {"disableDefaultResponse": true, "manufacturerCode": 4107});
expect(device.meta.configured).toBe(1);
}
const expectBulbConfigured = () => {
const device = zigbeeHerdsman.devices.bulb;
const endpoint1 = device.getEndpoint(1);
expect(endpoint1.read).toHaveBeenCalledTimes(2);
expect(endpoint1.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities']);
expect(endpoint1.read).toHaveBeenCalledWith('lightingColorCtrl', [ 'colorTempPhysicalMin', 'colorTempPhysicalMax' ]);
}
const expectBulbNotConfigured = () => {
const device = zigbeeHerdsman.devices.bulb;
const endpoint1 = device.getEndpoint(1);
expect(endpoint1.read).toHaveBeenCalledTimes(0);
}
const expectRemoteNotConfigured = () => {
const device = zigbeeHerdsman.devices.remote;
const endpoint1 = device.getEndpoint(1);
expect(endpoint1.bind).toHaveBeenCalledTimes(0);
}
const mockClear = (device) => {
for (const endpoint of device.endpoints) {
endpoint.read.mockClear();
endpoint.write.mockClear();
endpoint.configureReporting.mockClear();
endpoint.bind.mockClear();
}
}
let resetExtension = async () => {
await controller.enableDisableExtension(false, 'Configure');
await controller.enableDisableExtension(true, 'Configure');
}
beforeAll(async () => {
jest.useFakeTimers();
controller = new Controller(jest.fn(), jest.fn());
await controller.start();
await jest.runOnlyPendingTimers();
await flushPromises();
});
beforeEach(async () => {
data.writeDefaultConfiguration();
settings.reRead();
mocksClear.forEach((m) => m.mockClear());
coordinatorEndpoint = zigbeeHerdsman.devices.coordinator.getEndpoint(1);
await resetExtension();
await jest.runOnlyPendingTimers();
});
afterAll(async () => {
jest.useRealTimers();
})
it('Should configure Router on startup', async () => {
expectBulbConfigured();
});
it('Should not configure EndDevice on startup', async () => {
expectRemoteNotConfigured();
});
it('Should re-configure when device rejoins', async () => {
expectBulbConfigured();
const device = zigbeeHerdsman.devices.bulb;
const endpoint = device.getEndpoint(1);
await flushPromises();
mockClear(device);
const payload = {device};
zigbeeHerdsman.events.deviceJoined(payload);
await flushPromises();
expectBulbConfigured();
});
it('Should not re-configure disabled devices', async () => {
expectBulbConfigured();
const device = zigbeeHerdsman.devices.bulb;
await flushPromises();
mockClear(device);
settings.set(['devices', device.ieeeAddr, 'disabled'], true);
zigbeeHerdsman.events.deviceJoined({device});
await flushPromises();
expectBulbNotConfigured();
});
it('Should reconfigure reporting on reconfigure event', async () => {
expectBulbConfigured();
const device = controller.zigbee.resolveEntity(zigbeeHerdsman.devices.bulb);
mockClear(device.zh);
expectBulbNotConfigured();
controller.eventBus.emitReconfigure({device});
await flushPromises();
expectBulbConfigured();
});
it('Should not configure twice', async () => {
expectBulbConfigured();
const device = zigbeeHerdsman.devices.bulb;
mockClear(device);
await zigbeeHerdsman.events.deviceInterview({device});
await flushPromises();
expectBulbNotConfigured();
});
it('Should configure on zigbee message when not configured yet', async () => {
const device = zigbeeHerdsman.devices.bulb;
delete device.meta.configured;
mockClear(device);
await zigbeeHerdsman.events.lastSeenChanged({device});
await flushPromises();
expectBulbConfigured();
});
it('Should allow to configure via MQTT', async () => {
mockClear(zigbeeHerdsman.devices.remote);
expectRemoteNotConfigured();
await MQTT.events.message('zigbee2mqtt/bridge/request/device/configure', 'remote');
await flushPromises();
expectRemoteConfigured();
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/configure',
stringify({"data":{"id": "remote"},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
);
});
it('Fail to configure via MQTT when device does not exist', async () => {
await MQTT.events.message('zigbee2mqtt/bridge/request/device/configure', stringify({id: "not_existing_device"}));
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/configure',
stringify({"data":{"id": "not_existing_device"},"status":"error","error": "Device 'not_existing_device' does not exist"}),
{retain: false, qos: 0}, expect.any(Function)
);
});
it('Fail to configure via MQTT when configure fails', async () => {
zigbeeHerdsman.devices.remote.getEndpoint(1).bind.mockImplementationOnce(async () => {throw new Error('Bind timeout after 10s')});
await MQTT.events.message('zigbee2mqtt/bridge/request/device/configure', stringify({id: "remote"}));
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/configure',
stringify({"data":{"id": "remote"},"status":"error","error": "Failed to configure (Bind timeout after 10s)"}),
{retain: false, qos: 0}, expect.any(Function)
);
});
it('Fail to configure via MQTT when device has no configure', async () => {
await MQTT.events.message('zigbee2mqtt/bridge/request/device/configure', stringify({id: "0x0017880104e45521", transaction: 20}));
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/configure',
stringify({"data":{"id": "0x0017880104e45521"},"status":"error","error": "Device 'button_double_key' cannot be configured","transaction":20}),
{retain: false, qos: 0}, expect.any(Function)
);
});
it('Legacy api: Should allow to reconfigure manually', async () => {
mockClear(zigbeeHerdsman.devices.remote);
expectRemoteNotConfigured();
await MQTT.events.message('zigbee2mqtt/bridge/configure', 'remote');
await flushPromises();
expectRemoteConfigured();
});
it('Legacy api: Shouldnt manually reconfigure when device does not exist', async () => {
await MQTT.events.message('zigbee2mqtt/bridge/configure', 'remote_random_non_existing');
await flushPromises();
expect(logger.error).toHaveBeenCalledWith(`Device 'remote_random_non_existing' does not exist`);
});
it('Legacy api: Should skip reconfigure when device does not require this', async () => {
await MQTT.events.message('zigbee2mqtt/bridge/configure', '0x0017880104e45521');
await flushPromises();
expect(logger.warn).toHaveBeenCalledWith(`Skipping configure of 'button_double_key', device does not require this.`)
});
it('Should not configure when interview not completed', async () => {
const device = zigbeeHerdsman.devices.remote;
delete device.meta.configured;
device.interviewCompleted = false;
const endpoint = device.getEndpoint(1);
mockClear(device);
await zigbeeHerdsman.events.lastSeenChanged({device});
await flushPromises();
expectRemoteNotConfigured();
device.interviewCompleted = true;
});
it('Should not configure when already configuring', async () => {
const device = zigbeeHerdsman.devices.remote;
delete device.meta.configured;
const endpoint = device.getEndpoint(1);
endpoint.bind.mockImplementationOnce(async () => await wait(500));
mockClear(device);
await zigbeeHerdsman.events.lastSeenChanged({device});
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(1);
await zigbeeHerdsman.events.lastSeenChanged({device});
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(1);
});
it('Should configure max 3 times when fails', async () => {
controller.extensions.find((e) => e.constructor.name === 'Configure').attempts = {};
const device = zigbeeHerdsman.devices.remote;
delete device.meta.configured;
const endpoint = device.getEndpoint(1);
mockClear(device);
endpoint.bind.mockImplementationOnce(async () => {throw new Error('BLA')});
await zigbeeHerdsman.events.lastSeenChanged({device});
await flushPromises();
endpoint.bind.mockImplementationOnce(async () => {throw new Error('BLA')});
await zigbeeHerdsman.events.lastSeenChanged({device});
await flushPromises();
endpoint.bind.mockImplementationOnce(async () => {throw new Error('BLA')});
await zigbeeHerdsman.events.lastSeenChanged({device});
await flushPromises();
endpoint.bind.mockImplementationOnce(async () => {throw new Error('BLA')});
await zigbeeHerdsman.events.lastSeenChanged({device});
await flushPromises();
endpoint.bind.mockImplementationOnce(async () => {throw new Error('BLA')});
await zigbeeHerdsman.events.lastSeenChanged({device});
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(3);
});
});
-754
View File
@@ -1,754 +0,0 @@
process.env.NOTIFY_SOCKET = "mocked";
const data = require('./stub/data');
const logger = require('./stub/logger');
const zigbeeHerdsman = require('./stub/zigbeeHerdsman');
const MQTT = require('./stub/mqtt');
const path = require('path');
const settings = require('../lib/util/settings');
const Controller = require('../lib/controller');
const stringify = require('json-stable-stringify-without-jsonify');
const flushPromises = require('./lib/flushPromises');
const tmp = require('tmp');
const mocksClear = [
zigbeeHerdsman.permitJoin, MQTT.end, zigbeeHerdsman.stop, logger.debug,
MQTT.publish, MQTT.connect, zigbeeHerdsman.devices.bulb_color.removeFromNetwork,
zigbeeHerdsman.devices.bulb.removeFromNetwork, logger.error,
];
const fs = require('fs');
jest.mock('sd-notify', () => {
return {
watchdogInterval: () => {return 3000;},
startWatchdogMode: (interval) => {},
stopWatchdogMode: () => {},
ready: () => {},
stopping: () => {},
};
}, {virtual: true});
describe('Controller', () => {
let controller;
let mockExit;
beforeAll(async () => {
jest.useFakeTimers();
});
beforeEach(() => {
MQTT.restoreOnMock();
zigbeeHerdsman.returnDevices.splice(0);
mockExit = jest.fn();
data.writeDefaultConfiguration();
settings.reRead();
controller = new Controller(jest.fn(), mockExit);
mocksClear.forEach((m) => m.mockClear());
settings.reRead();
data.writeDefaultState();
});
afterAll(async () => {
jest.useRealTimers();
})
it('Start controller', async () => {
await controller.start();
expect(zigbeeHerdsman.constructor).toHaveBeenCalledWith({"network":{"panID":6754,"extendedPanID":[221,221,221,221,221,221,221,221],"channelList":[11],"networkKey":[1,3,5,7,9,11,13,15,0,2,4,6,8,10,12,13]},"databasePath":path.join(data.mockDir, "database.db"), "databaseBackupPath":path.join(data.mockDir, "database.db.backup"),"backupPath":path.join(data.mockDir, "coordinator_backup.json"),"acceptJoiningDeviceHandler": expect.any(Function),adapter: {concurrent: null, delay: null, disableLED: false}, "serialPort":{"baudRate":undefined,"rtscts":undefined,"path":"/dev/dummy"}}, logger);
expect(zigbeeHerdsman.start).toHaveBeenCalledTimes(1);
expect(zigbeeHerdsman.setTransmitPower).toHaveBeenCalledTimes(0);
expect(zigbeeHerdsman.permitJoin).toHaveBeenCalledTimes(1);
expect(zigbeeHerdsman.permitJoin).toHaveBeenCalledWith(true, undefined, undefined);
expect(logger.info).toHaveBeenCalledWith(`Currently ${Object.values(zigbeeHerdsman.devices).length - 1} devices are joined:`)
expect(logger.info).toHaveBeenCalledWith('bulb (0x000b57fffec6a5b2): LED1545G12 - IKEA TRADFRI LED bulb E26/E27 980 lumen, dimmable, white spectrum, opal white (Router)');
expect(logger.info).toHaveBeenCalledWith('remote (0x0017880104e45517): 324131092621 - Philips Hue dimmer switch (EndDevice)');
expect(logger.info).toHaveBeenCalledWith('0x0017880104e45518 (0x0017880104e45518): Not supported (EndDevice)');
expect(MQTT.connect).toHaveBeenCalledTimes(1);
expect(MQTT.connect).toHaveBeenCalledWith("mqtt://localhost", {"will": {"payload": Buffer.from("offline"), "retain": true, "topic": "zigbee2mqtt/bridge/state", "qos": 1}});
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99}),{ retain: true, qos: 0 }, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/remote', stringify({"brightness":255}), { retain: true, qos: 0 }, expect.any(Function));
});
it('Start controller when permit join fails', async () => {
zigbeeHerdsman.permitJoin.mockImplementationOnce(() => {throw new Error("failed!")});
await controller.start();
expect(zigbeeHerdsman.permitJoin).toHaveBeenCalledTimes(1);
expect(MQTT.connect).toHaveBeenCalledTimes(1);
});
it('Start controller with specific MQTT settings', async () => {
const ca = tmp.fileSync().name;
fs.writeFileSync(ca, "ca");
const key = tmp.fileSync().name;
fs.writeFileSync(key, "key");
const cert = tmp.fileSync().name;
fs.writeFileSync(cert, "cert");
const configuration = {
base_topic: "zigbee2mqtt",
server: "mqtt://localhost",
keepalive: 30,
ca, cert, key,
password: 'pass',
user: 'user1',
client_id: 'my_client_id',
reject_unauthorized: false,
version: 5,
}
settings.set(['mqtt'], configuration)
await controller.start();
await flushPromises();
expect(MQTT.connect).toHaveBeenCalledTimes(1);
const expected = {
"will": {"payload": Buffer.from("offline"), "retain": true, "topic": "zigbee2mqtt/bridge/state", "qos": 1},
keepalive: 30,
ca: Buffer.from([99, 97]),
key: Buffer.from([107, 101, 121]),
cert: Buffer.from([99, 101, 114, 116]),
password: 'pass',
username: 'user1',
clientId: 'my_client_id',
rejectUnauthorized: false,
protocolVersion: 5,
}
expect(MQTT.connect).toHaveBeenCalledWith("mqtt://localhost", expected);
});
it('Should generate network_key, pan_id and ext_pan_id when set to GENERATE', async () => {
settings.set(['advanced', 'network_key'], 'GENERATE');
settings.set(['advanced', 'pan_id'], 'GENERATE');
settings.set(['advanced', 'ext_pan_id'], 'GENERATE');
await controller.start();
await flushPromises();
expect(zigbeeHerdsman.constructor.mock.calls[0][0].network.networkKey.length).toStrictEqual(16);
expect(zigbeeHerdsman.constructor.mock.calls[0][0].network.extendedPanID.length).toStrictEqual(8);
expect(zigbeeHerdsman.constructor.mock.calls[0][0].network.panID).toStrictEqual(expect.any(Number));
expect(data.read().advanced.network_key.length).toStrictEqual(16);
expect(data.read().advanced.ext_pan_id.length).toStrictEqual(8);
expect(data.read().advanced.pan_id).toStrictEqual(expect.any(Number));
});
it('Start controller should publish cached states', async () => {
data.writeDefaultState();
await controller.start();
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99}), {"qos": 0, "retain": true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/remote", stringify({"brightness":255}), {"qos": 0, "retain": true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/group_1", stringify({"state":'ON'}), {"qos": 0, "retain":false}, expect.any(Function));
});
it('Start controller should not publish cached states when disabled', async () => {
settings.set(['advanced', 'cache_state_send_on_startup'], false);
data.writeDefaultState();
await controller.start();
await flushPromises();
const publishedTopics = MQTT.publish.mock.calls.map(m => m[0]);
expect(publishedTopics).toEqual(expect.not.arrayContaining(["zigbee2mqtt/bulb", "zigbee2mqtt/remote"]));
});
it('Start controller should not publish cached states when cache_state is false', async () => {
settings.set(['advanced', 'cache_state'], false);
data.writeDefaultState();
await controller.start();
await flushPromises();
expect(MQTT.publish).not.toHaveBeenCalledWith("zigbee2mqtt/bulb", `{"state":"ON","brightness":50,"color_temp":370,"linkquality":99}`, {"qos": 0, "retain": true}, expect.any(Function));
expect(MQTT.publish).not.toHaveBeenCalledWith("zigbee2mqtt/remote", `{"brightness":255}`, {"qos": 0, "retain": true}, expect.any(Function));
});
it('Log when MQTT client is unavailable', async () => {
await controller.start();
await flushPromises();
logger.error.mockClear();
controller.mqtt.client.reconnecting = true;
jest.advanceTimersByTime(11 * 1000);
expect(logger.error).toHaveBeenCalledWith("Not connected to MQTT server!");
controller.mqtt.client.reconnecting = false;
});
it('Dont publish to mqtt when client is unavailable', async () => {
await controller.start();
await flushPromises();
logger.error.mockClear();
controller.mqtt.client.reconnecting = true;
const device = controller.zigbee.resolveEntity('bulb');
await controller.publishEntityState(device, {state: 'ON', brightness: 50, color_temp: 370, color: {r: 100, g: 50, b: 10}, dummy: {1: 'yes', 2: 'no'}});
await flushPromises();
expect(logger.error).toHaveBeenCalledTimes(2);
expect(logger.error).toHaveBeenCalledWith("Not connected to MQTT server!");
expect(logger.error).toHaveBeenCalledWith("Cannot send message: topic: 'zigbee2mqtt/bulb', payload: '{\"brightness\":50,\"color\":{\"b\":10,\"g\":50,\"r\":100},\"color_temp\":370,\"dummy\":{\"1\":\"yes\",\"2\":\"no\"},\"linkquality\":99,\"state\":\"ON\"}");
controller.mqtt.client.reconnecting = false;
});
it('Load empty state when state file does not exist', async () => {
data.removeState();
await controller.start();
await flushPromises();
expect(controller.state.state).toStrictEqual({});
});
it('Should remove device not on passlist on startup', async () => {
settings.set(['passlist'], [zigbeeHerdsman.devices.bulb_color.ieeeAddr]);
zigbeeHerdsman.devices.bulb.removeFromNetwork.mockImplementationOnce(() => {throw new Error("dummy")});
await controller.start();
await flushPromises();
expect(zigbeeHerdsman.devices.bulb_color.removeFromNetwork).toHaveBeenCalledTimes(0);
expect(zigbeeHerdsman.devices.bulb.removeFromNetwork).toHaveBeenCalledTimes(1);
});
it('Should remove device on blocklist on startup', async () => {
settings.set(['blocklist'], [zigbeeHerdsman.devices.bulb_color.ieeeAddr]);
await controller.start();
await flushPromises();
expect(zigbeeHerdsman.devices.bulb_color.removeFromNetwork).toHaveBeenCalledTimes(1);
expect(zigbeeHerdsman.devices.bulb.removeFromNetwork).toHaveBeenCalledTimes(0);
});
it('Start controller fails', async () => {
zigbeeHerdsman.start.mockImplementationOnce(() => {throw new Error('failed')});
await controller.start();
expect(mockExit).toHaveBeenCalledTimes(1);
});
it('Start controller fails due to MQTT', async () => {
MQTT.on.mockImplementation((type, handler) => {
if (type === 'error') handler({message: 'addr not found'});
});
await controller.start();
await flushPromises();
expect(logger.error).toHaveBeenCalledWith('MQTT error: addr not found');
expect(logger.error).toHaveBeenCalledWith('MQTT failed to connect, exiting...');
expect(mockExit).toHaveBeenCalledTimes(1);
expect(mockExit).toHaveBeenCalledWith(1, false);
});
it('Start controller with permit join true', async () => {
settings.set(['permit_join'], false);
await controller.start();
expect(zigbeeHerdsman.permitJoin).toHaveBeenCalledTimes(1);
expect(zigbeeHerdsman.permitJoin).toHaveBeenCalledWith(false, undefined, undefined);
});
it('Start controller with transmit power', async () => {
settings.set(['experimental', 'transmit_power'], 14);
await controller.start();
expect(zigbeeHerdsman.setTransmitPower).toHaveBeenCalledTimes(1);
expect(zigbeeHerdsman.setTransmitPower).toHaveBeenCalledWith(14);
});
it('Start controller and stop with restart', async () => {
await controller.start();
await controller.stop(true);
expect(MQTT.end).toHaveBeenCalledTimes(1);
expect(zigbeeHerdsman.stop).toHaveBeenCalledTimes(1);
expect(mockExit).toHaveBeenCalledTimes(1);
expect(mockExit).toHaveBeenCalledWith(0, true);
});
it('Start controller and stop', async () => {
zigbeeHerdsman.stop.mockImplementationOnce(() => {throw new Error('failed')})
await controller.start();
await controller.stop();
expect(MQTT.end).toHaveBeenCalledTimes(1);
expect(zigbeeHerdsman.stop).toHaveBeenCalledTimes(1);
expect(mockExit).toHaveBeenCalledTimes(1);
expect(mockExit).toHaveBeenCalledWith(1, false);
});
it('Start controller adapter disconnects', async () => {
zigbeeHerdsman.stop.mockImplementationOnce(() => {throw new Error('failed')})
await controller.start();
await zigbeeHerdsman.events.adapterDisconnected();
await flushPromises();
expect(MQTT.end).toHaveBeenCalledTimes(1);
expect(zigbeeHerdsman.stop).toHaveBeenCalledTimes(1);
expect(mockExit).toHaveBeenCalledTimes(1);
expect(mockExit).toHaveBeenCalledWith(1, false);
});
it('Handle mqtt message', async () => {
await controller.start();
logger.debug.mockClear();
await MQTT.events.message('dummytopic', 'dummymessage');
expect(logger.debug).toHaveBeenCalledWith("Received MQTT message on 'dummytopic' with data 'dummymessage'")
});
it('Skip MQTT messages on topic we published to', async () => {
await controller.start();
logger.debug.mockClear();
await MQTT.events.message('zigbee2mqtt/skip-this-topic', 'skipped');
expect(logger.debug).toHaveBeenCalledWith("Received MQTT message on 'zigbee2mqtt/skip-this-topic' with data 'skipped'")
logger.debug.mockClear();
await controller.mqtt.publish('skip-this-topic', '', {});
await MQTT.events.message('zigbee2mqtt/skip-this-topic', 'skipped');
expect(logger.debug).toHaveBeenCalledTimes(0);
});
it('On zigbee event message', async () => {
await controller.start();
const device = zigbeeHerdsman.devices.bulb;
const payload = {device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10, cluster: 'genBasic', data: {modelId: device.modelID}};
await zigbeeHerdsman.events.message(payload);
await flushPromises();
expect(logger.debug).toHaveBeenCalledWith(`Received Zigbee message from 'bulb', type 'attributeReport', cluster 'genBasic', data '{"modelId":"TRADFRI bulb E27 WS opal 980lm"}' from endpoint 1`);
});
it('On zigbee event message with group ID', async () => {
await controller.start();
const device = zigbeeHerdsman.devices.bulb;
const payload = {device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10, groupID: 0, cluster: 'genBasic', data: {modelId: device.modelID}};
await zigbeeHerdsman.events.message(payload);
await flushPromises();
expect(logger.debug).toHaveBeenCalledWith(`Received Zigbee message from 'bulb', type 'attributeReport', cluster 'genBasic', data '{"modelId":"TRADFRI bulb E27 WS opal 980lm"}' from endpoint 1 with groupID 0`);
});
it('Should add entities which are missing from configuration but are in database to configuration', async () => {
await controller.start();
const device = zigbeeHerdsman.devices.notInSettings;
expect(settings.getDevice(device.ieeeAddr)).not.toBeNull();
});
it('On zigbee deviceJoined', async () => {
await controller.start();
const device = zigbeeHerdsman.devices.bulb;
const payload = {device};
await zigbeeHerdsman.events.deviceJoined(payload);
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bridge/log", stringify({"type":"device_connected","message":{"friendly_name":"bulb"}}), {"retain": false, qos: 0}, expect.any(Function));
});
it('acceptJoiningDeviceHandler reject device on blocklist', async () => {
await controller.start();
const device = zigbeeHerdsman.devices.bulb;
settings.set(['blocklist'], [device.ieeeAddr]);
const handler = zigbeeHerdsman.constructor.mock.calls[0][0].acceptJoiningDeviceHandler;
expect(await handler(device.ieeeAddr)).toBe(false);
});
it('acceptJoiningDeviceHandler accept device not on blocklist', async () => {
await controller.start();
const device = zigbeeHerdsman.devices.bulb;
settings.set(['blocklist'], ['123']);
const handler = zigbeeHerdsman.constructor.mock.calls[0][0].acceptJoiningDeviceHandler;
expect(await handler(device.ieeeAddr)).toBe(true);
});
it('acceptJoiningDeviceHandler accept device on passlist', async () => {
await controller.start();
const device = zigbeeHerdsman.devices.bulb;
settings.set(['passlist'], [device.ieeeAddr]);
const handler = zigbeeHerdsman.constructor.mock.calls[0][0].acceptJoiningDeviceHandler;
expect(await handler(device.ieeeAddr)).toBe(true);
});
it('acceptJoiningDeviceHandler reject device not in passlist', async () => {
await controller.start();
const device = zigbeeHerdsman.devices.bulb;
settings.set(['passlist'], ['123']);
const handler = zigbeeHerdsman.constructor.mock.calls[0][0].acceptJoiningDeviceHandler;
expect(await handler(device.ieeeAddr)).toBe(false);
});
it('acceptJoiningDeviceHandler should prefer passlist above blocklist', async () => {
await controller.start();
const device = zigbeeHerdsman.devices.bulb;
settings.set(['passlist'], [device.ieeeAddr]);
settings.set(['blocklist'], [device.ieeeAddr]);
const handler = zigbeeHerdsman.constructor.mock.calls[0][0].acceptJoiningDeviceHandler;
expect(await handler(device.ieeeAddr)).toBe(true);
});
it('acceptJoiningDeviceHandler accept when not on blocklist and passlist', async () => {
await controller.start();
const device = zigbeeHerdsman.devices.bulb;
const handler = zigbeeHerdsman.constructor.mock.calls[0][0].acceptJoiningDeviceHandler;
expect(await handler(device.ieeeAddr)).toBe(true);
});
it('Shouldnt crash when two device join events are received', async () => {
await controller.start();
const device = zigbeeHerdsman.devices.bulb;
const payload = {device};
zigbeeHerdsman.events.deviceJoined(payload);
zigbeeHerdsman.events.deviceJoined(payload);
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bridge/log", stringify({"type":"device_connected","message":{"friendly_name":"bulb"}}), {"retain": false, qos: 0}, expect.any(Function));
});
it('On zigbee deviceInterview started', async () => {
await controller.start();
const device = zigbeeHerdsman.devices.bulb;
const payload = {device, status: 'started'};
await zigbeeHerdsman.events.deviceInterview(payload);
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/log', stringify({"type":"pairing","message":"interview_started","meta":{"friendly_name":"bulb"}}), { retain: false, qos: 0 }, expect.any(Function));
});
it('On zigbee deviceInterview failed', async () => {
await controller.start();
const device = zigbeeHerdsman.devices.bulb;
const payload = {device, status: 'failed'};
await zigbeeHerdsman.events.deviceInterview(payload);
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/log', stringify({"type":"pairing","message":"interview_failed","meta":{"friendly_name":"bulb"}}), { retain: false, qos: 0 }, expect.any(Function));
});
it('On zigbee deviceInterview successful supported', async () => {
await controller.start();
const device = zigbeeHerdsman.devices.bulb;
const payload = {device, status: 'successful'};
await zigbeeHerdsman.events.deviceInterview(payload);
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/log', stringify({"type":"pairing","message":"interview_successful","meta":{"friendly_name":"bulb","model":"LED1545G12","vendor":"IKEA","description":"TRADFRI LED bulb E26/E27 980 lumen, dimmable, white spectrum, opal white","supported":true}}), { retain: false, qos: 0 }, expect.any(Function));
});
it('On zigbee deviceInterview successful not supported', async () => {
await controller.start();
const device = zigbeeHerdsman.devices.unsupported;
const payload = {device, status: 'successful'};
await zigbeeHerdsman.events.deviceInterview(payload);
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/log', stringify({"type":"pairing","message":"interview_successful","meta":{"friendly_name":"0x0017880104e45518","supported":false}}), { retain: false, qos: 0 }, expect.any(Function));
});
it('On zigbee event device announce', async () => {
await controller.start();
const device = zigbeeHerdsman.devices.bulb;
const payload = {device};
await zigbeeHerdsman.events.deviceAnnounce(payload);
await flushPromises();
expect(logger.debug).toHaveBeenCalledWith(`Device 'bulb' announced itself`);
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/log', stringify({"type":"device_announced","message":"announce","meta":{"friendly_name":"bulb"}}), { retain: false, qos: 0}, expect.any(Function));
});
it('On zigbee event device leave (removed from database and settings)', async () => {
await controller.start();
zigbeeHerdsman.returnDevices.push('0x00124b00120144ae');
settings.set(['devices'], {})
MQTT.publish.mockClear();
const device = zigbeeHerdsman.devices.bulb;
const payload = {ieeeAddr: device.ieeeAddr};
await zigbeeHerdsman.events.deviceLeave(payload);
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/log', stringify({"type":"device_removed","message":"left_network","meta":{"friendly_name":"0x000b57fffec6a5b2"}}), { retain: false, qos: 0}, expect.any(Function));
});
it('On zigbee event device leave (removed from database and NOT settings)', async () => {
await controller.start();
zigbeeHerdsman.returnDevices.push('0x00124b00120144ae');
const device = zigbeeHerdsman.devices.bulb;
MQTT.publish.mockClear();
const payload = {ieeeAddr: device.ieeeAddr};
await zigbeeHerdsman.events.deviceLeave(payload);
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/log', stringify({"type":"device_removed","message":"left_network","meta":{"friendly_name":"0x000b57fffec6a5b2"}}), { retain: false, qos: 0}, expect.any(Function));
});
it('Publish entity state attribute output', async () => {
await controller.start();
settings.set(['experimental', 'output'], 'attribute');
MQTT.publish.mockClear();
const device = controller.zigbee.resolveEntity('bulb');
await controller.publishEntityState(device, {dummy: {1: 'yes', 2: 'no'}, color: {r: 100, g: 50, b: 10}, state: 'ON', test: undefined, test1: null, color_temp: 370, brightness: 50});
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/state", "ON", {"qos": 0, "retain": true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/brightness", "50", {"qos": 0, "retain": true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/color_temp", "370", {"qos": 0, "retain": true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/color", '100,50,10', {"qos": 0, "retain": true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/dummy-1", "yes", {"qos": 0, "retain": true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/dummy-2", "no", {"qos": 0, "retain": true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/test1", '', {"qos": 0, "retain": true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/test", '', {"qos": 0, "retain": true}, expect.any(Function));
});
it('Publish entity state attribute_json output', async () => {
await controller.start();
settings.set(['experimental', 'output'], 'attribute_and_json');
MQTT.publish.mockClear();
const device = controller.zigbee.resolveEntity('bulb');
await controller.publishEntityState(device, {state: 'ON', brightness: 200, color_temp: 370, linkquality: 99});
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(5);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/state", "ON", {"qos": 0, "retain": true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/brightness", "200", {"qos": 0, "retain": true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/color_temp", "370", {"qos": 0, "retain": true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/linkquality", "99", {"qos": 0, "retain": true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({"state":"ON","brightness":200,"color_temp":370,"linkquality":99}), {"qos": 0, "retain": true}, expect.any(Function));
});
it('Publish entity state attribute_json output filtered', async () => {
await controller.start();
settings.set(['experimental', 'output'], 'attribute_and_json');
settings.set(['devices', zigbeeHerdsman.devices.bulb.ieeeAddr, 'filtered_attributes'], ['color_temp', 'linkquality']);
MQTT.publish.mockClear();
const device = controller.zigbee.resolveEntity('bulb');
await controller.publishEntityState(device, {state: 'ON', brightness: 200, color_temp: 370, linkquality: 99});
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(3);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/state", "ON", {"qos": 0, "retain": true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/brightness", "200", {"qos": 0, "retain": true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({"state":"ON","brightness":200}), {"qos": 0, "retain": true}, expect.any(Function));
});
it('Publish entity state attribute_json output filtered (device_options)', async () => {
await controller.start();
settings.set(['experimental', 'output'], 'attribute_and_json');
settings.set(['device_options', 'filtered_attributes'], ['color_temp', 'linkquality']);
MQTT.publish.mockClear();
const device = controller.zigbee.resolveEntity('bulb');
await controller.publishEntityState(device, {state: 'ON', brightness: 200, color_temp: 370, linkquality: 99});
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(3);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/state", "ON", {"qos": 0, "retain": true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/brightness", "200", {"qos": 0, "retain": true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({"state":"ON","brightness":200}), {"qos": 0, "retain": true}, expect.any(Function));
});
it('Publish entity state attribute_json output filtered cache', async () => {
await controller.start();
settings.set(['advanced', 'output'], 'attribute_and_json');
settings.set(['devices', zigbeeHerdsman.devices.bulb.ieeeAddr, 'filtered_cache'], ['linkquality']);
MQTT.publish.mockClear();
const device = controller.zigbee.resolveEntity('bulb');
expect(controller.state.state[device.ieeeAddr]).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();
expect(controller.state.state[device.ieeeAddr]).toStrictEqual({"brightness":200,"color_temp":370,"state":"ON"});
expect(MQTT.publish).toHaveBeenCalledTimes(5);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/state", "ON", {"qos": 0, "retain": true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/brightness", "200", {"qos": 0, "retain": true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/linkquality", "87", {"qos": 0, "retain": true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({"state":"ON","brightness":200,"color_temp":370,"linkquality":87}), {"qos": 0, "retain": true}, expect.any(Function));
});
it('Publish entity state attribute_json output filtered cache (device_options)', async () => {
await controller.start();
settings.set(['advanced', 'output'], 'attribute_and_json');
settings.set(['device_options', 'filtered_cache'], ['linkquality']);
MQTT.publish.mockClear();
const device = controller.zigbee.resolveEntity('bulb');
expect(controller.state.state[device.ieeeAddr]).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();
expect(controller.state.state[device.ieeeAddr]).toStrictEqual({"brightness":200,"color_temp":370,"state":"ON"});
expect(MQTT.publish).toHaveBeenCalledTimes(5);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/state", "ON", {"qos": 0, "retain": true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/brightness", "200", {"qos": 0, "retain": true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/linkquality", "87", {"qos": 0, "retain": true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({"state":"ON","brightness":200,"color_temp":370,"linkquality":87}), {"qos": 0, "retain": true}, expect.any(Function));
});
it('Publish entity state with device information', async () => {
await controller.start();
settings.set(['mqtt', 'include_device_information'], true);
MQTT.publish.mockClear();
let device = controller.zigbee.resolveEntity('bulb');
await controller.publishEntityState(device, {state: 'ON'});
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99,"device":{"friendlyName":"bulb","model":"LED1545G12","ieeeAddr":"0x000b57fffec6a5b2","networkAddress":40369,"type":"Router","manufacturerID":4476,"powerSource":"Mains (single phase)","dateCode":null, "softwareBuildID": null}}), {"qos": 0, "retain": true}, expect.any(Function));
// Unsupported device should have model "unknown"
device = controller.zigbee.resolveEntity('unsupported2');
await controller.publishEntityState(device, {state: 'ON'});
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/unsupported2', stringify({"state":"ON","device":{"friendlyName":"unsupported2","model":"notSupportedModelID","ieeeAddr":"0x0017880104e45529","networkAddress":6536,"type":"EndDevice","manufacturerID":0,"powerSource":"Battery","dateCode":null, "softwareBuildID": null}}), {"qos": 0, "retain": false}, expect.any(Function));
});
it('Should publish entity state without retain', async () => {
await controller.start();
settings.set(['devices', zigbeeHerdsman.devices.bulb.ieeeAddr, 'retain'], false);
MQTT.publish.mockClear();
const device = controller.zigbee.resolveEntity('bulb');
await controller.publishEntityState(device, {state: 'ON'});
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99}), {"qos": 0, "retain": false}, expect.any(Function));
});
it('Should publish entity state with retain', async () => {
await controller.start();
settings.set(['devices', zigbeeHerdsman.devices.bulb.ieeeAddr, 'retain'], true);
MQTT.publish.mockClear();
const device = controller.zigbee.resolveEntity('bulb');
await controller.publishEntityState(device, {state: 'ON'});
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99}), {"qos": 0, "retain": true}, expect.any(Function));
});
it('Should publish entity state with expiring retention', async () => {
await controller.start();
settings.set(['mqtt', 'version'], 5);
settings.set(['devices', zigbeeHerdsman.devices.bulb.ieeeAddr, 'retain'], true);
settings.set(['devices', zigbeeHerdsman.devices.bulb.ieeeAddr, 'retention'], 37);
MQTT.publish.mockClear();
const device = controller.zigbee.resolveEntity('bulb');
await controller.publishEntityState(device, {state: 'ON'});
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99}), {"qos": 0, "retain": true, "properties": {messageExpiryInterval: 37}}, expect.any(Function));
});
it('Publish entity state no empty messages', async () => {
data.writeEmptyState();
await controller.start();
MQTT.publish.mockClear();
const device = controller.zigbee.resolveEntity('bulb');
await controller.publishEntityState(device, {});
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(0);
});
it('Should allow to disable state persistency', async () => {
settings.set(['advanced', 'cache_state_persistent'], false);
data.removeState();
await controller.start();
MQTT.publish.mockClear();
const device = controller.zigbee.resolveEntity('bulb');
await controller.publishEntityState(device, {state: 'ON'});
await controller.publishEntityState(device, {brightness: 200});
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(2);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({state: "ON"}), {"qos": 0, "retain": true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({state: "ON", brightness: 200}), {"qos": 0, "retain": true}, expect.any(Function));
await controller.stop();
expect(data.stateExists()).toBeFalsy();
});
it('Shouldnt crash when it cannot save state', async () => {
data.removeState();
await controller.start();
logger.error.mockClear();
controller.state.file = "/";
await controller.state.save();
expect(logger.error).toHaveBeenCalledWith(expect.stringMatching(/Failed to write state to \'\/\'/));
});
it('Publish should not cache when set', async () => {
settings.set(['advanced', 'cache_state'], false);
data.writeEmptyState();
await controller.start();
MQTT.publish.mockClear();
const device = controller.zigbee.resolveEntity('bulb');
await controller.publishEntityState(device, {state: 'ON'});
await controller.publishEntityState(device, {brightness: 200});
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(2);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({"state":"ON"}), {"qos": 0, "retain": true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({"brightness":200}), {"qos": 0, "retain": true}, expect.any(Function));
});
it('Should start when state is corrupted', async () => {
fs.writeFileSync(path.join(data.mockDir, 'state.json'), 'corrupted');
await controller.start();
await flushPromises();
expect(controller.state.state).toStrictEqual({});
});
it('Start controller with force_disable_retain', async () => {
settings.set(['mqtt', 'force_disable_retain'], true);
await controller.start();
await flushPromises();
expect(MQTT.connect).toHaveBeenCalledTimes(1);
const expected = {
"will": { "payload": Buffer.from("offline"), "retain": false, "topic": "zigbee2mqtt/bridge/state", "qos": 1 },
}
expect(MQTT.connect).toHaveBeenCalledWith("mqtt://localhost", expected);
});
it('Should republish retained messages on MQTT reconnect', async () => {
await controller.start();
MQTT.publish.mockClear();
MQTT.events['connect']();
await flushPromises();
jest.runOnlyPendingTimers();
expect(MQTT.publish).toHaveBeenCalledTimes(13);
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/info', expect.any(String), { retain: true, qos: 0 }, expect.any(Function));
});
it('Should not republish retained messages on MQTT reconnect when retained message are sent', async () => {
await controller.start();
MQTT.publish.mockClear();
MQTT.events['connect']();
await flushPromises();
await MQTT.events.message('zigbee2mqtt/bridge/info', 'dummy');
jest.runOnlyPendingTimers();
expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/state', expect.any(String), { retain: true, qos: 0 }, expect.any(Function));
});
it('Should prevent any message being published with retain flag when force_disable_retain is set', async () => {
settings.set(['mqtt', 'force_disable_retain'], true);
await controller.mqtt.connect()
MQTT.publish.mockClear();
await controller.mqtt.publish('fo', 'bar', { retain: true })
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/fo', 'bar', { retain: false, qos: 0 }, expect.any(Function));
});
it('Should disable legacy options on new network start', async () => {
settings.set(['homeassistant'], true);
settings.reRead();
expect(settings.get().homeassistant.legacy_entity_attributes).toBeTruthy();
expect(settings.get().advanced.legacy_api).toBeTruthy();
zigbeeHerdsman.start.mockReturnValueOnce('reset');
await controller.start();
expect(settings.get().homeassistant.legacy_entity_attributes).toBeFalsy();
expect(settings.get().advanced.legacy_api).toBeFalsy();
});
it('Should publish last seen changes', async () => {
settings.set(['advanced', 'last_seen'], 'epoch');
await controller.start();
await flushPromises();
MQTT.publish.mockClear();
const device = zigbeeHerdsman.devices.remote;
await zigbeeHerdsman.events.lastSeenChanged({device, reason: 'deviceAnnounce'});
expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/remote', stringify({"brightness":255,"last_seen":1000}), { qos: 0, retain: true }, expect.any(Function));
});
it('Should not publish last seen changes when reason is messageEmitted', async () => {
settings.set(['advanced', 'last_seen'], 'epoch');
await controller.start();
await flushPromises();
MQTT.publish.mockClear();
const device = zigbeeHerdsman.devices.remote;
await zigbeeHerdsman.events.lastSeenChanged({device, reason: 'messageEmitted'});
expect(MQTT.publish).toHaveBeenCalledTimes(0);
});
it('Ignore messages from coordinator', async () => {
// https://github.com/Koenkk/zigbee2mqtt/issues/9218
await controller.start();
const device = zigbeeHerdsman.devices.coordinator;
const payload = {device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10, cluster: 'genBasic', data: {modelId: device.modelID}};
await zigbeeHerdsman.events.message(payload);
await flushPromises();
expect(logger.debug).toHaveBeenCalledWith(`Received Zigbee message from 'Coordinator', type 'attributeReport', cluster 'genBasic', data '{"modelId":null}' from endpoint 1, ignoring since it is from coordinator`);
});
it('Should remove state of removed device when stopped', async () => {
await controller.start();
const device = controller.zigbee.resolveEntity('bulb');
expect(controller.state.state[device.ieeeAddr]).toStrictEqual({"brightness":50,"color_temp":370,"linkquality":99,"state":"ON"});
device.zh.isDeleted = true;
await controller.stop();
expect(controller.state.state[device.ieeeAddr]).toStrictEqual(undefined);
});
it('EventBus should handle errors', async () => {
const eventbus = controller.eventBus;
const callback = jest.fn().mockImplementation(async () => {throw new Error('Whoops!')});
eventbus.onStateChange('test', callback);
eventbus.emitStateChange({});
await flushPromises();
expect(callback).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith(`EventBus error 'String/stateChange': Whoops!`);
});
});
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -1,8 +1,8 @@
const logger = require('./stub/logger');
const data = require('../lib/util/data').default;
const path = require('path');
const tmp = require('tmp');
const fs = require('fs');
import path from 'node:path';
import tmp from 'tmp';
import data from '../lib/util/data';
describe('Data', () => {
describe('Get path', () => {
@@ -15,13 +15,13 @@ describe('Data', () => {
it('Should return correct path when ZIGBEE2MQTT_DATA set', () => {
const expected = tmp.dirSync().name;
process.env.ZIGBEE2MQTT_DATA = expected;
data.testingOnlyReload();
data._testReload();
const actual = data.getPath();
expect(actual).toBe(expected);
expect(data.joinPath('test')).toStrictEqual(path.join(expected, 'test'));
expect(data.joinPath('/test')).toStrictEqual(path.resolve(expected, '/test'));
delete process.env.ZIGBEE2MQTT_DATA;
data.testingOnlyReload();
data._testReload();
});
});
});
+394
View File
@@ -0,0 +1,394 @@
import * as data from '../mocks/data';
import {mockLogger} from '../mocks/logger';
import {events as mockMQTTEvents, mockMQTTPublishAsync} from '../mocks/mqtt';
import {flushPromises} from '../mocks/utils';
import {devices, events as mockZHEvents, returnDevices} from '../mocks/zigbeeHerdsman';
import assert from 'node:assert';
import stringify from 'json-stable-stringify-without-jsonify';
import {Controller} from '../../lib/controller';
import Availability from '../../lib/extension/availability';
import * as settings from '../../lib/util/settings';
import utils from '../../lib/util/utils';
const mocksClear = [mockMQTTPublishAsync, mockLogger.warning, mockLogger.info];
returnDevices.push(
devices.bulb_color.ieeeAddr,
devices.bulb_color_2.ieeeAddr,
devices.coordinator.ieeeAddr,
devices.remote.ieeeAddr,
devices.TS0601_thermostat.ieeeAddr,
devices.bulb_2.ieeeAddr,
devices.ZNCZ02LM.ieeeAddr,
devices.GLEDOPTO_2ID.ieeeAddr,
devices.QBKG03LM.ieeeAddr,
devices.hue_twilight.ieeeAddr,
);
describe('Extension: Availability', () => {
let controller: Controller;
const resetExtension = async (): Promise<void> => {
await controller.enableDisableExtension(false, 'Availability');
await controller.enableDisableExtension(true, 'Availability');
};
const setTimeAndAdvanceTimers = async (value: number): Promise<void> => {
vi.setSystemTime(Date.now() + value);
await vi.advanceTimersByTimeAsync(value);
};
beforeAll(async () => {
vi.spyOn(utils, 'sleep').mockImplementation(vi.fn());
vi.useFakeTimers();
settings.reRead();
settings.set(['availability'], {enabled: true});
controller = new Controller(vi.fn(), vi.fn());
await controller.start();
await flushPromises();
});
beforeEach(async () => {
vi.setSystemTime(utils.minutes(1));
data.writeDefaultConfiguration();
settings.reRead();
settings.set(['availability'], {enabled: true});
settings.set(['devices', devices.bulb_color_2.ieeeAddr, 'availability'], false);
Object.values(devices).forEach((d) => (d.lastSeen = utils.minutes(1)));
mocksClear.forEach((m) => m.mockClear());
await resetExtension();
Object.values(devices).forEach((d) => d.ping.mockClear());
});
afterEach(async () => {});
afterAll(async () => {
await controller.stop();
vi.useRealTimers();
});
it('Should publish availability on startup for device where it is enabled for', async () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', stringify({state: 'online'}), {
retain: true,
qos: 1,
});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/remote/availability', stringify({state: 'online'}), {retain: true, qos: 1});
expect(mockMQTTPublishAsync).not.toHaveBeenCalledWith('zigbee2mqtt/bulb_color_2/availability', stringify({state: 'online'}), {
retain: true,
qos: 1,
});
});
it('Should ping on startup for enabled and unavailable devices', async () => {
settings.set(['devices', devices.bulb_color_2.ieeeAddr, 'availability'], true);
devices.bulb_color.lastSeen = Date.now() - utils.minutes(20);
devices.bulb_color_2.lastSeen = Date.now();
await resetExtension();
await setTimeAndAdvanceTimers(utils.minutes(1));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1); // enabled/unavailable
expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(0); // enabled/available
});
it('Should not ping on startup for available or disabled devices', async () => {
settings.set(['devices', devices.bulb_color_2.ieeeAddr, 'availability'], true);
settings.set(['devices', devices.bulb_color_2.ieeeAddr, 'disabled'], true);
devices.bulb_color.lastSeen = Date.now();
devices.bulb_color_2.lastSeen = Date.now() - utils.minutes(20);
await resetExtension();
await setTimeAndAdvanceTimers(utils.minutes(1));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0); // enabled/available
expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(0); // disabled/unavailable
});
it('Should publish offline for active device when not seen for 10 minutes', async () => {
mockMQTTPublishAsync.mockClear();
await setTimeAndAdvanceTimers(utils.minutes(5));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
await setTimeAndAdvanceTimers(utils.minutes(7));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', stringify({state: 'offline'}), {
retain: true,
qos: 1,
});
});
it('Shouldnt do anything for a device when availability: false is set for device', async () => {
await mockZHEvents.lastSeenChanged({device: devices.bulb_color_2}); // Coverage satisfaction
await setTimeAndAdvanceTimers(utils.minutes(12));
expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(0);
});
it('Should publish offline for passive device when not seen for 25 hours', async () => {
mockMQTTPublishAsync.mockClear();
await setTimeAndAdvanceTimers(utils.hours(26));
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/remote/availability', stringify({state: 'offline'}), {retain: true, qos: 1});
});
it('Should reset ping timer when device last seen changes for active device', async () => {
mockMQTTPublishAsync.mockClear();
await setTimeAndAdvanceTimers(utils.minutes(5));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', stringify({state: 'offline'}), {
retain: true,
qos: 1,
});
await setTimeAndAdvanceTimers(utils.minutes(7));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
await setTimeAndAdvanceTimers(utils.minutes(10));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
});
it('Should ping again when first ping fails', async () => {
mockMQTTPublishAsync.mockClear();
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
devices.bulb_color.ping.mockImplementationOnce(() => {
throw new Error('failed');
});
await setTimeAndAdvanceTimers(utils.minutes(15));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(2, false);
});
it('Should reset ping timer when device last seen changes for passive device', async () => {
mockMQTTPublishAsync.mockClear();
await setTimeAndAdvanceTimers(utils.hours(24));
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
await mockZHEvents.lastSeenChanged({device: devices.remote});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/remote/availability', stringify({state: 'offline'}), {retain: true, qos: 1});
await setTimeAndAdvanceTimers(utils.hours(25));
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
await setTimeAndAdvanceTimers(utils.hours(3));
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
});
it('Should immediately mark device as online when it lastSeen changes', async () => {
mockMQTTPublishAsync.mockClear();
await setTimeAndAdvanceTimers(utils.minutes(15));
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', stringify({state: 'offline'}), {
retain: true,
qos: 1,
});
devices.bulb_color.lastSeen = Date.now();
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', stringify({state: 'online'}), {
retain: true,
qos: 1,
});
});
it('Should allow to change availability timeout via device options', async () => {
settings.set(['devices', '0x000b57fffec6a5b3', 'availability'], {timeout: 40});
await resetExtension();
devices.bulb_color.ping.mockClear();
await setTimeAndAdvanceTimers(utils.minutes(25));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
await setTimeAndAdvanceTimers(utils.minutes(17));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
});
it('Should not ping disabled devices', async () => {
settings.set(['devices', devices.bulb_color.ieeeAddr, 'disabled'], true);
await resetExtension();
devices.bulb_color.ping.mockClear();
await setTimeAndAdvanceTimers(utils.minutes(15));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
});
it('Should allow to change availability timeout via avaiability options', async () => {
settings.set(['availability', 'active', 'timeout'], 30);
await resetExtension();
devices.bulb_color.ping.mockClear();
await setTimeAndAdvanceTimers(utils.minutes(25));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
await setTimeAndAdvanceTimers(utils.minutes(7));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
});
it('Should stop pinging device when it leaves', async () => {
await setTimeAndAdvanceTimers(utils.minutes(9));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
await mockZHEvents.deviceLeave({ieeeAddr: devices.bulb_color.ieeeAddr});
await flushPromises();
await setTimeAndAdvanceTimers(utils.minutes(3));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
});
it('Should stop pinging device when it is removed', async () => {
await setTimeAndAdvanceTimers(utils.minutes(9));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/remove', stringify({id: 'bulb_color'}));
await flushPromises();
await setTimeAndAdvanceTimers(utils.minutes(3));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
});
it('Should allow to be disabled', async () => {
settings.set(['availability'], {enabled: false});
await resetExtension();
devices.bulb_color.ping.mockClear();
await setTimeAndAdvanceTimers(utils.minutes(12));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
});
it('Should allow to enable availability for just one device', async () => {
settings.set(['availability'], {enabled: false});
settings.set(['devices', devices.bulb_color.ieeeAddr, 'availability'], true);
await resetExtension();
devices.bulb_color.ping.mockClear();
await setTimeAndAdvanceTimers(utils.minutes(11));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
});
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);
assert(endpoint);
endpoint.read.mockClear();
await mockZHEvents.deviceAnnounce({device: devices.bulb_color});
await flushPromises();
await setTimeAndAdvanceTimers(utils.seconds(1));
await mockZHEvents.deviceAnnounce({device: devices.bulb_color});
await flushPromises();
expect(endpoint.read).toHaveBeenCalledTimes(0);
await setTimeAndAdvanceTimers(utils.seconds(2));
expect(endpoint.read).toHaveBeenCalledTimes(1);
expect(endpoint.read).toHaveBeenCalledWith('genOnOff', ['onOff']);
endpoint.read.mockClear();
await mockZHEvents.deviceAnnounce({device: devices.bulb_color});
await flushPromises();
endpoint.read.mockImplementationOnce(() => {
throw new Error('');
});
await setTimeAndAdvanceTimers(utils.seconds(3));
expect(endpoint.read).toHaveBeenCalledTimes(1);
});
it('Should republish availability when device is renamed', async () => {
mockMQTTPublishAsync.mockClear();
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/rename', stringify({from: 'bulb_color', to: 'bulb_new_name'}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', '', {retain: true, qos: 1});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_new_name/availability', stringify({state: 'online'}), {
retain: true,
qos: 1,
});
await setTimeAndAdvanceTimers(utils.hours(12));
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_new_name/availability', stringify({state: 'offline'}), {
retain: true,
qos: 1,
});
});
it('Should publish availability payload in JSON format', async () => {
await resetExtension();
devices.remote.ping.mockClear();
mockMQTTPublishAsync.mockClear();
await setTimeAndAdvanceTimers(utils.hours(26));
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/remote/availability', stringify({state: 'offline'}), {retain: true, qos: 1});
});
it('Should publish availability for groups', async () => {
settings.set(['devices', devices.bulb_color_2.ieeeAddr, 'availability'], true);
await resetExtension();
devices.bulb_color_2.ping.mockClear();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/group_tradfri_remote/availability', stringify({state: 'online'}), {
retain: true,
qos: 1,
});
mockMQTTPublishAsync.mockClear();
await setTimeAndAdvanceTimers(utils.minutes(12));
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/group_tradfri_remote/availability', stringify({state: 'offline'}), {
retain: true,
qos: 1,
});
mockMQTTPublishAsync.mockClear();
devices.bulb_color_2.lastSeen = Date.now();
await mockZHEvents.lastSeenChanged({device: devices.bulb_color_2});
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/group_tradfri_remote/availability', stringify({state: 'online'}), {
retain: true,
qos: 1,
});
});
it('Should clear the ping queue on stop', async () => {
// @ts-expect-error private
const availability = controller.extensions.find((extension) => extension instanceof Availability)!;
// @ts-expect-error private
const publishAvailabilitySpy = vi.spyOn(availability, 'publishAvailability');
devices.bulb_color.ping.mockImplementationOnce(() => new Promise((resolve) => setTimeout(resolve, 1000)));
// @ts-expect-error private
availability.addToPingQueue(devices.bulb_color);
// @ts-expect-error private
availability.addToPingQueue(devices.bulb_color_2);
await availability.stop();
await setTimeAndAdvanceTimers(utils.minutes(1));
// @ts-expect-error private
expect(availability.pingQueue).toEqual([]);
// Validate the stop-interrupt implicitly by checking that it prevents further function invocations
expect(publishAvailabilitySpy).not.toHaveBeenCalled();
devices.bulb_color.ping = vi.fn(); // ensure reset
});
it('Should prevent instance restart', async () => {
// @ts-expect-error private
const availability = controller.extensions.find((extension) => extension instanceof Availability)!;
await availability.stop();
await expect(() => availability.start()).rejects.toThrow();
});
});
+774
View File
@@ -0,0 +1,774 @@
import * as data from '../mocks/data';
import {mockDebounce} from '../mocks/debounce';
import {mockLogger} from '../mocks/logger';
import {events as mockMQTTEvents, mockMQTTPublishAsync} from '../mocks/mqtt';
import {flushPromises} from '../mocks/utils';
import {Device, devices, groups, events as mockZHEvents} from '../mocks/zigbeeHerdsman';
import stringify from 'json-stable-stringify-without-jsonify';
import {Controller} from '../../lib/controller';
import * as settings from '../../lib/util/settings';
const mocksClear = [
mockDebounce,
mockMQTTPublishAsync,
devices.bulb_color.getEndpoint(1)!.configureReporting,
devices.bulb_color.getEndpoint(1)!.bind,
devices.bulb_color_2.getEndpoint(1)!.read,
];
describe('Extension: Bind', () => {
let controller: Controller;
const resetExtension = async (): Promise<void> => {
await controller.enableDisableExtension(false, 'Bind');
await controller.enableDisableExtension(true, 'Bind');
};
const mockClear = (device: Device): void => {
for (const endpoint of device.endpoints) {
endpoint.read.mockClear();
endpoint.write.mockClear();
endpoint.configureReporting.mockClear();
endpoint.bind = vi.fn();
endpoint.bind.mockClear();
endpoint.unbind.mockClear();
}
};
beforeAll(async () => {
vi.useFakeTimers();
controller = new Controller(vi.fn(), vi.fn());
await controller.start();
await flushPromises();
});
beforeEach(async () => {
data.writeDefaultConfiguration();
settings.reRead();
groups.group_1.members = [];
await resetExtension();
mocksClear.forEach((m) => m.mockClear());
});
afterAll(async () => {
vi.useRealTimers();
});
it('Should bind to device and configure reporting', async () => {
const device = devices.remote;
const target = devices.bulb_color.getEndpoint(1)!;
const endpoint = device.getEndpoint(1)!;
// Setup
const originalDeviceOutputClusters = device.getEndpoint(1)!.outputClusters;
device.getEndpoint(1)!.outputClusters = [...device.getEndpoint(1)!.outputClusters, 768];
const originalTargetBinds = target.binds;
target.binds = [{cluster: {name: 'genLevelCtrl'}, target: devices.coordinator.getEndpoint(1)!}];
target.getClusterAttributeValue.mockReturnValueOnce(undefined);
mockClear(device);
target.configureReporting.mockImplementationOnce(() => {
throw new Error('timeout');
});
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({transaction: '1234', from: 'remote', to: 'bulb_color'}));
await flushPromises();
expect(target.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities']);
expect(endpoint.bind).toHaveBeenCalledTimes(4);
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
expect(endpoint.bind).toHaveBeenCalledWith('lightingColorCtrl', target);
expect(target.configureReporting).toHaveBeenCalledTimes(3);
expect(target.configureReporting).toHaveBeenCalledWith('genOnOff', [
{attribute: 'onOff', maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
]);
expect(target.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
{attribute: 'currentLevel', maximumReportInterval: 3600, minimumReportInterval: 5, reportableChange: 1},
]);
expect(target.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
{attribute: 'colorTemperature', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
{attribute: 'currentX', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
{attribute: 'currentY', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
]);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({
transaction: '1234',
data: {
from: 'remote',
from_endpoint: 'default',
to: 'bulb_color',
clusters: ['genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl'],
failed: [],
},
status: 'ok',
}),
{retain: false, qos: 0},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), {retain: true, qos: 0});
// Teardown
target.binds = originalTargetBinds;
device.getEndpoint(1)!.outputClusters = originalDeviceOutputClusters;
});
it('Should throw error on invalid payload', async () => {
mockMQTTPublishAsync.mockClear();
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({fromz: 'remote', to: 'bulb_color'}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({data: {}, status: 'error', error: 'Invalid payload'}),
{retain: false, qos: 0},
);
});
it('Filters out unsupported clusters for reporting setup', async () => {
const device = devices.remote;
const target = devices.bulb_color.getEndpoint(1)!;
const endpoint = device.getEndpoint(1)!;
// Setup
const originalDeviceInputClusters = device.getEndpoint(1)!.inputClusters;
device.getEndpoint(1)!.inputClusters = [...device.getEndpoint(1)!.inputClusters, 8];
const originalDeviceOutputClusters = device.getEndpoint(1)!.outputClusters;
device.getEndpoint(1)!.outputClusters = [...device.getEndpoint(1)!.outputClusters, 768];
const originalTargetInputClusters = target.inputClusters;
target.inputClusters = [...originalTargetInputClusters];
target.inputClusters.splice(originalTargetInputClusters.indexOf(8), 1); // remove genLevelCtrl
const originalTargetOutputClusters = target.outputClusters;
target.outputClusters = [...target.outputClusters, 8];
const originalTargetBinds = target.binds;
target.binds = [{cluster: {name: 'genLevelCtrl'}, target: devices.coordinator.getEndpoint(1)!}];
target.getClusterAttributeValue.mockReturnValueOnce(undefined);
mockClear(device);
target.configureReporting.mockImplementationOnce(() => {
throw new Error('timeout');
});
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({transaction: '1234', from: 'remote', to: 'bulb_color'}));
await flushPromises();
expect(target.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities']);
expect(endpoint.bind).toHaveBeenCalledTimes(4);
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
expect(endpoint.bind).toHaveBeenCalledWith('lightingColorCtrl', target);
expect(target.configureReporting).toHaveBeenCalledTimes(2);
expect(target.configureReporting).toHaveBeenCalledWith('genOnOff', [
{attribute: 'onOff', maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
]);
// expect(target.configureReporting).toHaveBeenCalledWith("genLevelCtrl",[{"attribute": "currentLevel", "maximumReportInterval": 3600, "minimumReportInterval": 5, "reportableChange": 1}]);
expect(target.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
{attribute: 'colorTemperature', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
{attribute: 'currentX', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
{attribute: 'currentY', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
]);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({
transaction: '1234',
data: {
from: 'remote',
from_endpoint: 'default',
to: 'bulb_color',
clusters: ['genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl'],
failed: [],
},
status: 'ok',
}),
{retain: false, qos: 0},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), {retain: true, qos: 0});
// Teardown
target.binds = originalTargetBinds;
target.inputClusters = originalTargetInputClusters;
target.outputClusters = originalTargetOutputClusters;
device.getEndpoint(1)!.inputClusters = originalDeviceInputClusters;
device.getEndpoint(1)!.outputClusters = originalDeviceOutputClusters;
});
it('Filters out reporting setup based on bind status', async () => {
const device = devices.remote;
const target = devices.bulb_color.getEndpoint(1)!;
const endpoint = device.getEndpoint(1)!;
// Setup
const originalDeviceOutputClusters = device.getEndpoint(1)!.outputClusters;
device.getEndpoint(1)!.outputClusters = [...device.getEndpoint(1)!.outputClusters, 768];
const originalTargetBinds = target.binds;
target.binds = [{cluster: {name: 'genLevelCtrl'}, target: devices.coordinator.getEndpoint(1)!}];
target.getClusterAttributeValue.mockReturnValueOnce(undefined);
mockClear(device);
target.configureReporting.mockImplementationOnce(() => {
throw new Error('timeout');
});
const originalTargetCR = target.configuredReportings;
target.configuredReportings = [
{
cluster: {name: 'genLevelCtrl'},
attribute: {name: 'currentLevel', ID: 0},
minimumReportInterval: 0,
maximumReportInterval: 3600,
reportableChange: 0,
},
];
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({transaction: '1234', from: 'remote', to: 'bulb_color'}));
await flushPromises();
expect(target.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities']);
expect(endpoint.bind).toHaveBeenCalledTimes(4);
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
expect(endpoint.bind).toHaveBeenCalledWith('lightingColorCtrl', target);
expect(target.configureReporting).toHaveBeenCalledTimes(2);
expect(target.configureReporting).toHaveBeenCalledWith('genOnOff', [
{attribute: 'onOff', maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
]);
// expect(target.configureReporting).toHaveBeenCalledWith("genLevelCtrl",[{"attribute": "currentLevel", "maximumReportInterval": 3600, "minimumReportInterval": 5, "reportableChange": 1}]);
expect(target.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
{attribute: 'colorTemperature', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
{attribute: 'currentX', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
{attribute: 'currentY', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
]);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({
transaction: '1234',
data: {
from: 'remote',
from_endpoint: 'default',
to: 'bulb_color',
clusters: ['genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl'],
failed: [],
},
status: 'ok',
}),
{retain: false, qos: 0},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), {retain: true, qos: 0});
// Teardown
target.configuredReportings = originalTargetCR;
target.binds = originalTargetBinds;
device.getEndpoint(1)!.outputClusters = originalDeviceOutputClusters;
});
it('Should bind only specified clusters', async () => {
const device = devices.remote;
const target = devices.bulb_color.getEndpoint(1)!;
const endpoint = device.getEndpoint(1)!;
mockClear(device);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'bulb_color', clusters: ['genOnOff']}));
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: 'bulb_color', clusters: ['genOnOff'], failed: []}, status: 'ok'}),
{retain: false, qos: 0},
);
});
it('Should log error when there is nothing to bind', async () => {
const device = devices.bulb_color;
const endpoint = device.getEndpoint(1)!;
mockClear(device);
mockLogger.error.mockClear();
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'button'}));
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(0);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({data: {}, status: 'error', error: 'Nothing to bind'}),
{retain: false, qos: 0},
);
});
it('Should unbind', async () => {
const device = devices.remote;
const target = devices.bulb_color.getEndpoint(1)!;
// setup
target.configureReporting.mockImplementationOnce(() => {
throw new Error('timeout');
});
const originalRemoteBinds = device.getEndpoint(1)!.binds;
device.getEndpoint(1)!.binds = [];
const originalTargetBinds = target.binds;
target.binds = [
{cluster: {name: 'genOnOff'}, target: devices.coordinator.getEndpoint(1)!},
{cluster: {name: 'genLevelCtrl'}, target: devices.coordinator.getEndpoint(1)!},
{cluster: {name: 'lightingColorCtrl'}, target: devices.coordinator.getEndpoint(1)!},
];
const endpoint = device.getEndpoint(1)!;
mockClear(device);
delete devices.bulb_color.meta.configured;
expect(devices.bulb_color.meta.configured).toBe(undefined);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'bulb_color'}));
await flushPromises();
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', target);
// Disable reporting
expect(target.configureReporting).toHaveBeenCalledTimes(3);
expect(target.configureReporting).toHaveBeenCalledWith('genOnOff', [
{attribute: 'onOff', maximumReportInterval: 0xffff, minimumReportInterval: 0, reportableChange: 0},
]);
expect(target.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
{attribute: 'currentLevel', maximumReportInterval: 0xffff, minimumReportInterval: 5, reportableChange: 1},
]);
expect(target.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
{attribute: 'colorTemperature', minimumReportInterval: 5, maximumReportInterval: 0xffff, reportableChange: 1},
{attribute: 'currentX', minimumReportInterval: 5, maximumReportInterval: 0xffff, reportableChange: 1},
{attribute: 'currentY', minimumReportInterval: 5, maximumReportInterval: 0xffff, reportableChange: 1},
]);
expect(devices.bulb_color.meta.configured).toBe(332242049);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/unbind',
stringify({
data: {from: 'remote', from_endpoint: 'default', to: 'bulb_color', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []},
status: 'ok',
}),
{retain: false, qos: 0},
);
// Teardown
target.binds = originalTargetBinds;
device.getEndpoint(1)!.binds = originalRemoteBinds;
});
it('Should unbind coordinator', async () => {
const device = devices.remote;
const target = devices.coordinator.getEndpoint(1)!;
const endpoint = device.getEndpoint(1)!;
mockClear(device);
endpoint.unbind.mockClear();
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'Coordinator'}));
await flushPromises();
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', target);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/unbind',
stringify({
data: {from: 'remote', from_endpoint: 'default', to: 'Coordinator', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []},
status: 'ok',
}),
{retain: false, qos: 0},
);
});
it('Should bind to groups', async () => {
const device = devices.remote;
const target = groups.group_1;
const target1Member = devices.bulb.getEndpoint(1)!;
const endpoint = device.getEndpoint(1)!;
target.members.push(target1Member);
target1Member.configureReporting.mockClear();
mockClear(device);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'group_1'}));
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(3);
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
expect(target1Member.configureReporting).toHaveBeenCalledTimes(2);
expect(target1Member.configureReporting).toHaveBeenCalledWith('genOnOff', [
{attribute: 'onOff', maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
]);
expect(target1Member.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
{attribute: 'currentLevel', maximumReportInterval: 3600, minimumReportInterval: 5, reportableChange: 1},
]);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({
data: {from: 'remote', from_endpoint: 'default', to: 'group_1', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []},
status: 'ok',
}),
{retain: false, qos: 0},
);
// Should configure reporting for device added to group
target1Member.configureReporting.mockClear();
await mockMQTTEvents.message('zigbee2mqtt/bridge/request/group/members/add', stringify({group: 'group_1', device: 'bulb'}));
await flushPromises();
expect(target1Member.configureReporting).toHaveBeenCalledTimes(2);
expect(target1Member.configureReporting).toHaveBeenCalledWith('genOnOff', [
{attribute: 'onOff', maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
]);
expect(target1Member.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
{attribute: 'currentLevel', maximumReportInterval: 3600, minimumReportInterval: 5, reportableChange: 1},
]);
});
it('Should unbind from group', async () => {
const device = devices.remote;
const target = groups.group_1;
const target1Member = devices.bulb.getEndpoint(1)!;
const endpoint = device.getEndpoint(1)!;
target.members.push(target1Member);
target1Member.configureReporting.mockClear();
mockClear(device);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'group_1'}));
await flushPromises();
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', target);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/unbind',
stringify({
data: {from: 'remote', from_endpoint: 'default', to: 'group_1', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []},
status: 'ok',
}),
{retain: false, qos: 0},
);
});
it('Should unbind from group with skip_disable_reporting=true', async () => {
const device = devices.remote;
const target = groups.group_1;
const target1Member = devices.bulb_2.getEndpoint(1)!;
const endpoint = device.getEndpoint(1)!;
target.members.push(target1Member);
// The device unbind mock doesn't remove binds, therefore remove them here already otherwise configure reporiting is not disabled.
const originalBinds = endpoint.binds;
endpoint.binds = [];
target1Member.binds = [
{cluster: {name: 'genLevelCtrl'}, target: devices.coordinator.getEndpoint(1)!},
{cluster: {name: 'genOnOff'}, target: devices.coordinator.getEndpoint(1)!},
];
target1Member.configureReporting.mockClear();
mockClear(device);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'group_1', skip_disable_reporting: true}));
await flushPromises();
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
// with skip_disable_reporting set to false, we don't expect it to reconfigure reporting
expect(target1Member.configureReporting).toHaveBeenCalledTimes(0);
endpoint.binds = originalBinds;
});
it('Should unbind from group with skip_disable_reporting=false', async () => {
const device = devices.remote;
const target = groups.group_1;
const target1Member = devices.bulb_2.getEndpoint(1)!;
const endpoint = device.getEndpoint(1)!;
target.members.push(target1Member);
// The device unbind mock doesn't remove binds, therefore remove them here already otherwise configure reporiting is not disabled.
const originalBinds = endpoint.binds;
endpoint.binds = [];
target1Member.binds = [
{cluster: {name: 'genLevelCtrl'}, target: devices.coordinator.getEndpoint(1)!},
{cluster: {name: 'genOnOff'}, target: devices.coordinator.getEndpoint(1)!},
];
target1Member.configureReporting.mockClear();
mockClear(device);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'group_1', skip_disable_reporting: false}));
await flushPromises();
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
// with skip_disable_reporting set, we expect it to reconfigure reporting
expect(target1Member.configureReporting).toHaveBeenCalledTimes(2);
expect(target1Member.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
{attribute: 'currentLevel', maximumReportInterval: 65535, minimumReportInterval: 5, reportableChange: 1},
]);
expect(target1Member.configureReporting).toHaveBeenCalledWith('genOnOff', [
{attribute: 'onOff', maximumReportInterval: 65535, minimumReportInterval: 0, reportableChange: 0},
]);
endpoint.binds = originalBinds;
});
it('Should bind to group by number', async () => {
const device = devices.remote;
const target = groups.group_1;
const endpoint = device.getEndpoint(1)!;
mockClear(device);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: '1'}));
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(3);
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({
data: {from: 'remote', from_endpoint: 'default', to: '1', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []},
status: 'ok',
}),
{retain: false, qos: 0},
);
});
it('Should log when bind fails', async () => {
mockLogger.error.mockClear();
const device = devices.remote;
const endpoint = device.getEndpoint(1)!;
mockClear(device);
endpoint.bind.mockImplementation(() => {
throw new Error('failed');
});
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'bulb_color'}));
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(3);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({data: {}, status: 'error', error: 'Failed to bind'}),
{retain: false, qos: 0},
);
});
it('Should bind from non default endpoint names', async () => {
const device = devices.remote;
const target = devices.QBKG03LM.getEndpoint(3)!;
const endpoint = device.getEndpoint(2)!;
mockClear(device);
mockMQTTEvents.message(
'zigbee2mqtt/bridge/request/device/bind',
stringify({from: 'remote', from_endpoint: 'ep2', to: 'wall_switch_double', to_endpoint: 'right'}),
);
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: 'ep2', to: 'wall_switch_double', to_endpoint: 'right', clusters: ['genOnOff'], failed: []},
status: 'ok',
}),
{retain: false, qos: 0},
);
});
it('Should bind from non default endpoint IDs', async () => {
const device = devices.remote;
const target = devices.QBKG03LM.getEndpoint(3)!;
const endpoint = device.getEndpoint(2)!;
mockClear(device);
mockMQTTEvents.message(
'zigbee2mqtt/bridge/request/device/bind',
stringify({from: 'remote', from_endpoint: 2, to: 'wall_switch_double', to_endpoint: 3}),
);
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: 2, to: 'wall_switch_double', to_endpoint: 3, clusters: ['genOnOff'], failed: []},
status: 'ok',
}),
{retain: false, qos: 0},
);
});
it('Should bind server clusters to client clusters', async () => {
const device = devices.temperature_sensor;
const target = devices.heating_actuator.getEndpoint(1)!;
const endpoint = device.getEndpoint(1)!;
mockClear(device);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'temperature_sensor', to: 'heating_actuator'}));
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(1);
expect(endpoint.bind).toHaveBeenCalledWith('msTemperatureMeasurement', target);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({
data: {
from: 'temperature_sensor',
from_endpoint: 'default',
to: 'heating_actuator',
clusters: ['msTemperatureMeasurement'],
failed: [],
},
status: 'ok',
}),
{retain: false, qos: 0},
);
});
it('Should bind to default endpoint returned by endpoints()', async () => {
const device = devices.remote;
const target = devices.QBKG04LM.getEndpoint(2)!;
const endpoint = device.getEndpoint(2)!;
mockClear(device);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', from_endpoint: 'ep2', to: 'wall_switch'}));
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: 'ep2', to: 'wall_switch', clusters: ['genOnOff'], failed: []}, status: 'ok'}),
{retain: false, qos: 0},
);
});
it('Should unbind from default_bind_group', async () => {
const device = devices.remote;
const target = 'default_bind_group';
const endpoint = device.getEndpoint(1)!;
mockClear(device);
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(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/unbind',
stringify({
data: {
from: 'remote',
from_endpoint: 'default',
to: 'default_bind_group',
clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'],
failed: [],
},
status: 'ok',
}),
{retain: false, qos: 0},
);
});
it('Error bind fails when source device does not exist', async () => {
const device = devices.remote;
mockClear(device);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote_not_existing', to: 'bulb_color'}));
await flushPromises();
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},
);
});
it("Error bind fails when source device's endpoint does not exist", async () => {
const device = devices.remote;
mockClear(device);
mockMQTTEvents.message(
'zigbee2mqtt/bridge/request/device/bind',
stringify({from: 'remote', from_endpoint: 'not_existing_endpoint', to: 'bulb_color'}),
);
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({data: {}, status: 'error', error: "Source device 'remote' does not have endpoint 'not_existing_endpoint'"}),
{retain: false, qos: 0},
);
});
it('Error bind fails when target device or group does not exist', async () => {
const device = devices.remote;
mockClear(device);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'bulb_color_not_existing'}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({data: {}, status: 'error', error: "Target device or group 'bulb_color_not_existing' does not exist"}),
{retain: false, qos: 0},
);
});
it("Error bind fails when target device's endpoint does not exist", async () => {
const device = devices.remote;
mockClear(device);
mockMQTTEvents.message(
'zigbee2mqtt/bridge/request/device/bind',
stringify({from: 'remote', to: 'bulb_color', to_endpoint: 'not_existing_endpoint'}),
);
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({data: {}, status: 'error', error: "Target device 'bulb_color' does not have endpoint 'not_existing_endpoint'"}),
{retain: false, qos: 0},
);
});
it('Should poll bounded Hue bulb when receiving message from Hue dimmer', async () => {
const remote = devices.remote;
const data = {button: 3, unknown1: 3145728, type: 2, unknown2: 0, time: 1};
const payload = {
data,
cluster: 'manuSpecificPhilips',
device: remote,
endpoint: remote.getEndpoint(2)!,
type: 'commandHueNotification',
linkquality: 10,
groupID: 0,
};
await mockZHEvents.message(payload);
await flushPromises();
expect(mockDebounce).toHaveBeenCalledTimes(1);
expect(devices.bulb_color.getEndpoint(1)!.read).toHaveBeenCalledWith('genLevelCtrl', ['currentLevel']);
});
it('Should poll bounded Hue bulb when receiving message from scene controller', async () => {
const remote = devices.bj_scene_switch;
const data = {action: 'recall_2_row_1'};
devices.bulb_color_2.getEndpoint(1)!.read.mockImplementationOnce(() => {
throw new Error('failed');
});
const payload = {
data,
cluster: 'genScenes',
device: remote,
endpoint: remote.getEndpoint(10)!,
type: 'commandRecall',
linkquality: 10,
groupID: 0,
};
await mockZHEvents.message(payload);
await flushPromises();
// Calls to three clusters are expected in this case
expect(mockDebounce).toHaveBeenCalledTimes(3);
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledWith('genOnOff', ['onOff']);
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledWith('genLevelCtrl', ['currentLevel']);
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledWith('lightingColorCtrl', ['currentX', 'currentY', 'colorTemperature']);
});
it('Should poll grouped Hue bulb when receiving message from TRADFRI remote', async () => {
devices.bulb_color_2.getEndpoint(1)!.read.mockClear();
devices.bulb_2.getEndpoint(1)!.read.mockClear();
const remote = devices.tradfri_remote;
const data = {stepmode: 0, stepsize: 43, transtime: 5};
const payload = {
data,
cluster: 'genLevelCtrl',
device: remote,
endpoint: remote.getEndpoint(1)!,
type: 'commandStepWithOnOff',
linkquality: 10,
groupID: 15071,
};
await mockZHEvents.message(payload);
await flushPromises();
expect(mockDebounce).toHaveBeenCalledTimes(2);
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledTimes(2);
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledWith('genLevelCtrl', ['currentLevel']);
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledWith('genOnOff', ['onOff']);
// Should also only debounce once
await mockZHEvents.message(payload);
await flushPromises();
expect(mockDebounce).toHaveBeenCalledTimes(2);
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledTimes(4);
// Should only call Hue bulb, not e.g. tradfri
expect(devices.bulb_2.getEndpoint(1)!.read).toHaveBeenCalledTimes(0);
});
});
File diff suppressed because it is too large Load Diff
+262
View File
@@ -0,0 +1,262 @@
import * as data from '../mocks/data';
import {mockLogger} from '../mocks/logger';
import {events as mockMQTTEvents, mockMQTTPublishAsync} from '../mocks/mqtt';
import {flushPromises} from '../mocks/utils';
import {Device, devices, Endpoint, events as mockZHEvents} from '../mocks/zigbeeHerdsman';
import stringify from 'json-stable-stringify-without-jsonify';
import {Controller} from '../../lib/controller';
import * as settings from '../../lib/util/settings';
const mocksClear = [mockMQTTPublishAsync, mockLogger.warning, mockLogger.debug];
describe('Extension: Configure', () => {
let controller: Controller;
let coordinatorEndpoint: Endpoint;
const resetExtension = async (): Promise<void> => {
await controller.enableDisableExtension(false, 'Configure');
await controller.enableDisableExtension(true, 'Configure');
};
const mockClear = (device: Device): void => {
for (const endpoint of device.endpoints) {
endpoint.read.mockClear();
endpoint.write.mockClear();
endpoint.configureReporting.mockClear();
endpoint.bind.mockClear();
}
};
const expectRemoteConfigured = (): void => {
const device = devices.remote;
const endpoint1 = device.getEndpoint(1)!;
expect(endpoint1.bind).toHaveBeenCalledTimes(2);
expect(endpoint1.bind).toHaveBeenCalledWith('genOnOff', coordinatorEndpoint);
expect(endpoint1.bind).toHaveBeenCalledWith('genLevelCtrl', coordinatorEndpoint);
const endpoint2 = device.getEndpoint(2)!;
expect(endpoint2.write).toHaveBeenCalledTimes(1);
expect(endpoint2.write).toHaveBeenCalledWith('genBasic', {49: {type: 25, value: 11}}, {disableDefaultResponse: true, manufacturerCode: 4107});
expect(device.meta.configured).toBe(332242049);
};
const expectBulbConfigured = (): void => {
const device = devices.bulb;
const endpoint1 = device.getEndpoint(1)!;
expect(endpoint1.read).toHaveBeenCalledTimes(2);
expect(endpoint1.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities']);
expect(endpoint1.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorTempPhysicalMin', 'colorTempPhysicalMax']);
};
const expectBulbNotConfigured = (): void => {
const device = devices.bulb;
const endpoint1 = device.getEndpoint(1)!;
expect(endpoint1.read).toHaveBeenCalledTimes(0);
};
const expectRemoteNotConfigured = (): void => {
const device = devices.remote;
const endpoint1 = device.getEndpoint(1)!;
expect(endpoint1.bind).toHaveBeenCalledTimes(0);
};
const wait = async (ms: number): Promise<void> => await new Promise((resolve) => setTimeout(resolve, ms));
beforeAll(async () => {
vi.useFakeTimers();
controller = new Controller(vi.fn(), vi.fn());
await controller.start();
await vi.runOnlyPendingTimersAsync();
});
beforeEach(async () => {
data.writeDefaultConfiguration();
settings.reRead();
mocksClear.forEach((m) => m.mockClear());
coordinatorEndpoint = devices.coordinator.getEndpoint(1)!;
await resetExtension();
await vi.runOnlyPendingTimersAsync();
});
afterAll(async () => {
vi.useRealTimers();
});
it('Should configure Router on startup', async () => {
expectBulbConfigured();
});
it('Should not configure EndDevice on startup', async () => {
expectRemoteNotConfigured();
});
it('Should re-configure when device rejoins', async () => {
expectBulbConfigured();
const device = devices.bulb;
await flushPromises();
mockClear(device);
const payload = {device};
mockZHEvents.deviceJoined(payload);
await flushPromises();
expectBulbConfigured();
});
it('Should not re-configure disabled devices', async () => {
expectBulbConfigured();
const device = devices.bulb;
await flushPromises();
mockClear(device);
settings.set(['devices', device.ieeeAddr, 'disabled'], true);
mockZHEvents.deviceJoined({device});
await flushPromises();
expectBulbNotConfigured();
});
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();
});
it('Should not configure twice', async () => {
expectBulbConfigured();
const device = devices.bulb;
mockClear(device);
await mockZHEvents.deviceInterview({device});
await flushPromises();
expectBulbNotConfigured();
});
it('Should configure on zigbee message when not configured yet', async () => {
const device = devices.bulb;
delete device.meta.configured;
mockClear(device);
await mockZHEvents.lastSeenChanged({device});
await flushPromises();
expectBulbConfigured();
});
it('Should allow to configure via MQTT', async () => {
mockClear(devices.remote);
expectRemoteNotConfigured();
await mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/configure', 'remote');
await flushPromises();
expectRemoteConfigured();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/configure',
stringify({data: {id: 'remote'}, status: 'ok'}),
{retain: false, qos: 0},
);
});
it('Fail to configure via MQTT when device does not exist', async () => {
await mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/configure', stringify({id: 'not_existing_device'}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/configure',
stringify({data: {}, status: 'error', error: "Device 'not_existing_device' does not exist"}),
{retain: false, qos: 0},
);
});
it('Fail to configure via MQTT when configure fails', async () => {
devices.remote.getEndpoint(1)!.bind.mockImplementationOnce(async () => {
throw new Error('Bind timeout after 10s');
});
await mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/configure', stringify({id: 'remote'}));
await flushPromises();
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 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},
);
});
it('Handles invalid payload for configure via MQTT', async () => {
await mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/configure', stringify({idx: '0x0017882104a44559'}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/configure',
stringify({data: {}, status: 'error', error: 'Invalid payload'}),
{retain: false, qos: 0},
);
});
it('Should not configure when interview not completed', async () => {
const device = devices.remote;
delete device.meta.configured;
device.interviewCompleted = false;
mockClear(device);
await mockZHEvents.lastSeenChanged({device});
await flushPromises();
expectRemoteNotConfigured();
device.interviewCompleted = true;
});
it('Should not configure when already configuring', async () => {
const device = devices.remote;
delete device.meta.configured;
const endpoint = device.getEndpoint(1)!;
endpoint.bind.mockImplementationOnce(async () => await wait(500));
mockClear(device);
await mockZHEvents.lastSeenChanged({device});
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(1);
await mockZHEvents.lastSeenChanged({device});
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(1);
});
it('Should configure max 3 times when fails', async () => {
// @ts-expect-error private
controller.extensions.find((e) => e.constructor.name === 'Configure').attempts = {};
const device = devices.remote;
delete device.meta.configured;
const endpoint = device.getEndpoint(1)!;
mockClear(device);
endpoint.bind.mockImplementationOnce(async () => {
throw new Error('BLA');
});
await mockZHEvents.lastSeenChanged({device});
await flushPromises();
endpoint.bind.mockImplementationOnce(async () => {
throw new Error('BLA');
});
await mockZHEvents.lastSeenChanged({device});
await flushPromises();
endpoint.bind.mockImplementationOnce(async () => {
throw new Error('BLA');
});
await mockZHEvents.lastSeenChanged({device});
await flushPromises();
endpoint.bind.mockImplementationOnce(async () => {
throw new Error('BLA');
});
await mockZHEvents.lastSeenChanged({device});
await flushPromises();
endpoint.bind.mockImplementationOnce(async () => {
throw new Error('BLA');
});
await mockZHEvents.lastSeenChanged({device});
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(3);
});
});
+357
View File
@@ -0,0 +1,357 @@
import * as data from '../mocks/data';
import {mockLogger} from '../mocks/logger';
import {mockMQTTEndAsync, events as mockMQTTEvents, mockMQTTPublishAsync} from '../mocks/mqtt';
import {flushPromises} from '../mocks/utils';
import {devices, mockController as mockZHController, returnDevices} from '../mocks/zigbeeHerdsman';
import type Device from '../../lib/model/device';
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 * as settings from '../../lib/util/settings';
const BASE_DIR = 'external_converters';
describe('Extension: ExternalConverters', () => {
const mockBasePath = path.join(data.mockDir, BASE_DIR);
let controller: Controller;
const existsSyncSpy = vi.spyOn(fs, 'existsSync');
const readdirSyncSpy = vi.spyOn(fs, 'readdirSync');
const mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync');
const rmSyncSpy = vi.spyOn(fs, 'rmSync');
const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync');
const zhcAddDefinitionSpy = vi.spyOn(zhc, 'addDefinition');
const zhcRemoveExternalDefinitionsSpy = vi.spyOn(zhc, 'removeExternalDefinitions');
const mocksClear = [
mockMQTTEndAsync,
mockMQTTPublishAsync,
mockLogger.debug,
mockLogger.error,
mockZHController.stop,
devices.bulb.save,
existsSyncSpy,
readdirSyncSpy,
mkdirSyncSpy,
rmSyncSpy,
writeFileSyncSpy,
zhcAddDefinitionSpy,
zhcRemoveExternalDefinitionsSpy,
];
const useAssets = (): void => {
fs.cpSync(path.join(__dirname, '..', 'assets', BASE_DIR), mockBasePath, {recursive: true});
};
const getFileCode = (fileName: string): string => {
return fs.readFileSync(path.join(__dirname, '..', 'assets', BASE_DIR, fileName), 'utf8');
};
const getZ2MDevice = (zhDevice: unknown): Device => {
// @ts-expect-error private
return controller.zigbee.resolveEntity(zhDevice)! as Device;
};
const resetExtension = async (): Promise<void> => {
await controller.enableDisableExtension(false, 'ExternalConverters');
await controller.enableDisableExtension(true, 'ExternalConverters');
};
beforeAll(async () => {
vi.useFakeTimers();
controller = new Controller(vi.fn(), vi.fn());
await controller.start();
await flushPromises();
});
afterAll(async () => {
vi.useRealTimers();
});
beforeEach(async () => {
zhc.removeExternalDefinitions(); // remove all external converters
mocksClear.forEach((m) => m.mockClear());
data.writeDefaultConfiguration();
data.writeDefaultState();
settings.reRead();
returnDevices.push(devices.external_converter_device.ieeeAddr, devices.coordinator.ieeeAddr);
});
afterEach(async () => {
fs.rmSync(mockBasePath, {recursive: true, force: true});
await controller?.stop();
});
describe('from folder', () => {
beforeEach(async () => {
controller = new Controller(vi.fn(), vi.fn());
});
it('loads nothing', async () => {
await controller.start();
await flushPromises();
expect(existsSyncSpy).toHaveBeenCalledWith(mockBasePath);
expect(readdirSyncSpy).not.toHaveBeenCalledWith(mockBasePath);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/converters', stringify([]), {retain: true, qos: 0});
});
it('loads converters', async () => {
useAssets();
await controller.start();
await flushPromises();
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
description: 'external',
model: 'external_converter_device',
vendor: 'external',
zigbeeModel: ['external_converter_device'],
});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/converters',
stringify([
{name: 'mock-external-converter-multiple.js', code: getFileCode('mock-external-converter-multiple.js')},
{name: 'mock-external-converter.js', code: getFileCode('mock-external-converter.js')},
]),
{retain: true, qos: 0},
);
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenCalledTimes(2);
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(1, 'mock-external-converter-multiple.js');
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(2, 'mock-external-converter.js');
expect(zhcAddDefinitionSpy).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
mock: 1,
model: 'external_converters_device_1',
zigbeeModel: ['external_converter_device_1'],
vendor: 'external_1',
description: 'external_1',
}),
);
expect(zhcAddDefinitionSpy).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
mock: 2,
model: 'external_converters_device_2',
zigbeeModel: ['external_converter_device_2'],
vendor: 'external_2',
description: 'external_2',
}),
);
expect(zhcAddDefinitionSpy).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
mock: true,
zigbeeModel: ['external_converter_device'],
vendor: 'external',
model: 'external_converter_device',
description: 'external',
}),
);
const bridgeDevices = mockMQTTPublishAsync.mock.calls.filter((c) => c[0] === 'zigbee2mqtt/bridge/devices');
expect(bridgeDevices.length).toBe(1);
expect(JSON.parse(bridgeDevices[0][1])).toEqual(
expect.arrayContaining([
expect.objectContaining({
model_id: 'external_converter_device',
supported: true,
definition: expect.objectContaining({
description: 'external',
model: 'external_converter_device',
}),
}),
]),
);
});
it('saves and removes from MQTT', async () => {
const converterName = 'foo.js';
const converterCode = getFileCode('mock-external-converter.js');
const converterFilePath = path.join(mockBasePath, converterName);
await controller.start();
await flushPromises();
mocksClear.forEach((m) => m.mockClear());
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
description: 'Automatically generated definition',
model: 'external_converter_device',
vendor: '',
zigbeeModel: ['external_converter_device'],
});
//-- SAVE
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode}));
await flushPromises();
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
description: 'external',
model: 'external_converter_device',
vendor: 'external',
zigbeeModel: ['external_converter_device'],
});
expect(mkdirSyncSpy).toHaveBeenCalledWith(mockBasePath, {recursive: true});
expect(writeFileSyncSpy).toHaveBeenCalledWith(converterFilePath, converterCode, 'utf8');
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenCalledTimes(1);
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(1, converterName);
expect(zhcAddDefinitionSpy).toHaveBeenCalledWith(
expect.objectContaining({
mock: true,
zigbeeModel: ['external_converter_device'],
vendor: 'external',
model: 'external_converter_device',
description: 'external',
}),
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/converters',
stringify([{name: converterName, code: converterCode}]),
{
retain: true,
qos: 0,
},
);
//-- REMOVE
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/remove', stringify({name: converterName}));
await flushPromises();
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
description: 'Automatically generated definition',
model: 'external_converter_device',
vendor: '',
zigbeeModel: ['external_converter_device'],
});
expect(rmSyncSpy).toHaveBeenCalledWith(converterFilePath, {force: true});
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenCalledTimes(2);
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(2, converterName);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/converters', stringify([]), {retain: true, qos: 0});
});
});
it('returns error on invalid code', async () => {
const converterName = 'foo.js';
const converterCode = 'definetly not a correct javascript code';
const converterFilePath = path.join(mockBasePath, converterName);
await resetExtension();
mocksClear.forEach((m) => m.mockClear());
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/converter/save',
expect.stringContaining(`"error":"foo.js contains invalid code`),
{retain: false, qos: 0},
);
expect(writeFileSyncSpy).not.toHaveBeenCalledWith(converterFilePath, converterCode, 'utf8');
});
it('returns error on invalid removal', async () => {
const converterName = 'invalid.js';
const converterFilePath = path.join(mockBasePath, converterName);
await resetExtension();
mocksClear.forEach((m) => m.mockClear());
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/remove', stringify({name: converterName}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/converter/remove',
stringify({data: {}, status: 'error', error: `${converterName} (${converterFilePath}) doesn't exists`}),
{retain: false, qos: 0},
);
expect(rmSyncSpy).not.toHaveBeenCalledWith(converterFilePath, {force: true});
});
it('returns error on invalid definition', async () => {
const converterName = 'foo.js';
const converterCode = getFileCode('mock-external-converter.js');
const converterFilePath = path.join(mockBasePath, converterName);
await resetExtension();
mocksClear.forEach((m) => m.mockClear());
const errorMsg = `Invalid definition`;
zhcAddDefinitionSpy.mockImplementationOnce(() => {
throw new Error(errorMsg);
});
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/response/converter/save', expect.stringContaining(errorMsg), {
retain: false,
qos: 0,
});
expect(writeFileSyncSpy).not.toHaveBeenCalledWith(converterFilePath, converterCode, 'utf8');
});
it('returns error on failed removal', async () => {
const converterName = 'foo.js';
const converterCode = getFileCode('mock-external-converter.js');
const converterFilePath = path.join(mockBasePath, converterName);
await resetExtension();
mocksClear.forEach((m) => m.mockClear());
//-- SAVE
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode}));
await flushPromises();
const errorMsg = `Failed to remove definition`;
zhcRemoveExternalDefinitionsSpy.mockImplementationOnce(() => {
throw new Error(errorMsg);
});
//-- REMOVE
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/remove', stringify({name: converterName}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/converter/remove',
stringify({data: {}, status: 'error', error: errorMsg}),
{retain: false, qos: 0},
);
expect(rmSyncSpy).not.toHaveBeenCalledWith(converterFilePath, {force: true});
});
it('handles invalid payloads', async () => {
await resetExtension();
mocksClear.forEach((m) => m.mockClear());
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: 'test.js', transaction: 1 /* code */}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/converter/save',
stringify({data: {}, status: 'error', error: `Invalid payload`, transaction: 1}),
{retain: false, qos: 0},
);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/remove', stringify({namex: 'test.js', transaction: 2}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/converter/remove',
stringify({data: {}, status: 'error', error: `Invalid payload`, transaction: 2}),
{retain: false, qos: 0},
);
});
});
+207
View File
@@ -0,0 +1,207 @@
import * as data from '../mocks/data';
import {mockLogger} from '../mocks/logger';
import {mockMQTTEndAsync, events as mockMQTTEvents, mockMQTTPublishAsync} from '../mocks/mqtt';
import {flushPromises} from '../mocks/utils';
import {devices, mockController as mockZHController, returnDevices} from '../mocks/zigbeeHerdsman';
import 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';
const BASE_DIR = 'external_extensions';
describe('Extension: ExternalExtensions', () => {
let controller: Controller;
const mockBasePath = path.join(data.mockDir, BASE_DIR);
const existsSyncSpy = vi.spyOn(fs, 'existsSync');
const readdirSyncSpy = vi.spyOn(fs, 'readdirSync');
const mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync');
const rmSyncSpy = vi.spyOn(fs, 'rmSync');
const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync');
const mocksClear = [
mockMQTTEndAsync,
mockMQTTPublishAsync,
mockLogger.debug,
mockLogger.error,
mockZHController.stop,
devices.bulb.save,
existsSyncSpy,
readdirSyncSpy,
mkdirSyncSpy,
rmSyncSpy,
writeFileSyncSpy,
];
const useAssets = (): void => {
fs.cpSync(path.join(__dirname, '..', 'assets', BASE_DIR), mockBasePath, {recursive: true});
};
const getFileCode = (fileName: string): string => {
return fs.readFileSync(path.join(__dirname, '..', 'assets', BASE_DIR, fileName), 'utf8');
};
const resetExtension = async (): Promise<void> => {
await controller.enableDisableExtension(false, 'ExternalExtensions');
await controller.enableDisableExtension(true, 'ExternalExtensions');
};
beforeAll(async () => {
vi.useFakeTimers();
controller = new Controller(vi.fn(), vi.fn());
await controller.start();
await flushPromises();
});
afterAll(async () => {
vi.useRealTimers();
});
beforeEach(async () => {
mocksClear.forEach((m) => m.mockClear());
data.writeDefaultConfiguration();
data.writeDefaultState();
settings.reRead();
returnDevices.splice(0);
});
afterEach(() => {
fs.rmSync(mockBasePath, {recursive: true, force: true});
});
describe('from folder', () => {
beforeEach(async () => {
controller = new Controller(vi.fn(), vi.fn());
});
it('loads nothing', async () => {
await controller.start();
await flushPromises();
expect(existsSyncSpy).toHaveBeenCalledWith(mockBasePath);
expect(readdirSyncSpy).not.toHaveBeenCalledWith(mockBasePath);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/extensions', stringify([]), {retain: true, qos: 0});
});
it('loads extensions', async () => {
useAssets();
await controller.start();
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example2/extension', 'call2 from constructor', {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example2/extension', 'call2 from start', {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from constructor', {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from start', {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/extensions',
stringify([
{name: 'example2Extension.js', code: getFileCode('example2Extension.js')},
{name: 'exampleExtension.js', code: getFileCode('exampleExtension.js')},
]),
{retain: true, qos: 0},
);
});
it('saves and removes from MQTT', async () => {
const extensionName = 'foo.js';
const extensionCode = getFileCode('exampleExtension.js');
const extensionFilePath = path.join(mockBasePath, extensionName);
await controller.start();
await flushPromises();
mocksClear.forEach((m) => m.mockClear());
//-- SAVE
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: extensionName, code: extensionCode}));
await flushPromises();
expect(mkdirSyncSpy).toHaveBeenCalledWith(mockBasePath, {recursive: true});
expect(writeFileSyncSpy).toHaveBeenCalledWith(extensionFilePath, extensionCode, 'utf8');
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from constructor', {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from start', {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/extensions',
stringify([{name: extensionName, code: extensionCode}]),
{
retain: true,
qos: 0,
},
);
//-- REMOVE
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/remove', stringify({name: extensionName}));
await flushPromises();
expect(rmSyncSpy).toHaveBeenCalledWith(extensionFilePath, {force: true});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from stop', {retain: false, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/extensions', stringify([]), {retain: true, qos: 0});
});
});
it('returns error on invalid code', async () => {
const extensionName = 'foo.js';
const extensionCode = 'definetly not a correct javascript code';
const extensionFilePath = path.join(mockBasePath, extensionName);
await resetExtension();
mocksClear.forEach((m) => m.mockClear());
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: extensionName, code: extensionCode}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/extension/save',
expect.stringContaining(`"error":"${extensionName} contains invalid code`),
{retain: false, qos: 0},
);
expect(writeFileSyncSpy).not.toHaveBeenCalledWith(extensionFilePath, extensionCode, 'utf8');
});
it('returns error on invalid removal', async () => {
const converterName = 'invalid.js';
const converterFilePath = path.join(mockBasePath, converterName);
await resetExtension();
mocksClear.forEach((m) => m.mockClear());
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/remove', stringify({name: converterName}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/extension/remove',
stringify({data: {}, status: 'error', error: `${converterName} (${converterFilePath}) doesn't exists`}),
{retain: false, qos: 0},
);
expect(rmSyncSpy).not.toHaveBeenCalledWith(converterFilePath, {force: true});
});
it('handles invalid payloads', async () => {
await resetExtension();
mocksClear.forEach((m) => m.mockClear());
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: 'test.js', transaction: 1 /* code */}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/extension/save',
stringify({data: {}, status: 'error', error: `Invalid payload`, transaction: 1}),
{retain: false, qos: 0},
);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/remove', stringify({namex: 'test.js', transaction: 2}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/extension/remove',
stringify({data: {}, status: 'error', error: `Invalid payload`, transaction: 2}),
{retain: false, qos: 0},
);
});
});

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