mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-08-29 07:08:56 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94751abaac | ||
|
|
cc8456617a | ||
|
|
8727abdefc | ||
|
|
24e90273c9 | ||
|
|
845bdb7dba | ||
|
|
55f12ff520 | ||
|
|
19eb05a0e0 | ||
|
|
9f7ea9b7c7 | ||
|
|
0d7b8f189c | ||
|
|
4639243cf9 | ||
|
|
df26459b46 | ||
|
|
c58162344f | ||
|
|
5974286445 | ||
|
|
4e527bbce8 | ||
|
|
b3983b35de | ||
|
|
429c5aea58 | ||
|
|
e71507835b | ||
|
|
3bb3d56c05 | ||
|
|
305b7bcafa | ||
|
|
7ba232e27d | ||
|
|
78f440bada | ||
|
|
a54775dea1 | ||
|
|
041972669a | ||
|
|
0a7dfcef59 | ||
|
|
55d8d1f4d0 | ||
|
|
cdd7357522 | ||
|
|
4294512486 | ||
|
|
0c9ec722db | ||
|
|
a0204ad3f0 | ||
|
|
4a63e65981 | ||
|
|
4818cc4163 | ||
|
|
9e27181b64 | ||
|
|
a50065cbb4 | ||
|
|
e6dd5c73bc | ||
|
|
aef3242319 | ||
|
|
6b567c1286 | ||
|
|
23f9847994 | ||
|
|
08e4e9edeb | ||
|
|
673d81f51a | ||
|
|
2b485a98c5 | ||
|
|
62ab814d73 | ||
|
|
fb3189b743 | ||
|
|
73d8ae8956 | ||
|
|
969db9ae85 | ||
|
|
66985a8ce8 | ||
|
|
52b063bb32 | ||
|
|
3f2c00cdb7 | ||
|
|
dde637af2f | ||
|
|
867179c430 | ||
|
|
2e5e362818 | ||
|
|
64286ebb30 | ||
|
|
6fb9d563e6 | ||
|
|
e054d7124b | ||
|
|
35567cb67f | ||
|
|
075f87c782 | ||
|
|
9f49d0ec67 | ||
|
|
67aa5099df | ||
|
|
69641d5467 | ||
|
|
6fca3744c9 | ||
|
|
f60db2d7a6 | ||
|
|
08bf2cbc07 | ||
|
|
c790e7b68f | ||
|
|
a8633760bc | ||
|
|
ff01c8718d | ||
|
|
cd6977bce6 | ||
|
|
9143924482 | ||
|
|
0f781ee9c0 | ||
|
|
9f1eb764a6 | ||
|
|
b7f1cecb49 | ||
|
|
05946b5955 | ||
|
|
4a8369295c | ||
|
|
1747618afa | ||
|
|
284d86e93b | ||
|
|
d6323825e4 | ||
|
|
b7b43030eb | ||
|
|
aefbb8be14 | ||
|
|
fc3a1968f6 | ||
|
|
c56fbca457 | ||
|
|
85875aee27 | ||
|
|
7f0a9aa2a3 | ||
|
|
ff528567e6 | ||
|
|
3473aa3f40 | ||
|
|
8ee5b1f03b | ||
|
|
89192f9dec | ||
|
|
a9a6ebe007 | ||
|
|
c7d54ec360 | ||
|
|
c880d36f29 | ||
|
|
e1e856d7da | ||
|
|
b9d9e5e75c | ||
|
|
f8385b8899 | ||
|
|
a77f48f5c4 | ||
|
|
79bcf276f5 | ||
|
|
93dc9a3664 | ||
|
|
1d5184f2bd | ||
|
|
8ecf353dd6 | ||
|
|
8560c11e06 | ||
|
|
4d287eed0c | ||
|
|
8d1240eba1 | ||
|
|
0c43c98306 | ||
|
|
396eb38e4b | ||
|
|
5957085fcc | ||
|
|
02a2fd8fef | ||
|
|
9d566f4c01 | ||
|
|
42d88fe90d | ||
|
|
909d2b32b7 | ||
|
|
061262ff97 | ||
|
|
142754b71c | ||
|
|
c599e19bf2 | ||
|
|
28a9f39dbc | ||
|
|
4e360d0bf2 | ||
|
|
15e385bac6 | ||
|
|
c7dae39799 | ||
|
|
b585e31304 | ||
|
|
bf888de478 | ||
|
|
f1156db227 | ||
|
|
d475aa5be5 | ||
|
+2 |
b71461e03c | ||
|
|
6cce05a514 | ||
|
|
30d311e849 | ||
|
|
402ed6094e | ||
|
|
e381d54f7e | ||
|
|
4b039103c0 | ||
|
|
8d8d37e388 | ||
|
|
09d010ea06 | ||
|
|
ede1dc5951 |
@@ -31,7 +31,7 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
if: ((github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push') == false
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v5
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -63,14 +63,14 @@ jobs:
|
||||
|
||||
- name: Log in to the Docker container registry
|
||||
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: koenkk
|
||||
password: ${{ secrets.DOCKER_KEY }}
|
||||
|
||||
- name: Log in to the GitHub container registry
|
||||
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: koenkk
|
||||
@@ -78,20 +78,20 @@ jobs:
|
||||
|
||||
- name: Docker setup - QEMU
|
||||
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: all
|
||||
|
||||
- name: Docker setup - Buildx
|
||||
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- name: dev - Docker build and push
|
||||
if: github.ref == 'refs/heads/dev' && github.event_name == 'push'
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
|
||||
- name: release - Docker meta
|
||||
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
id: meta
|
||||
with:
|
||||
images: |
|
||||
@@ -139,7 +139,7 @@ jobs:
|
||||
|
||||
- name: release - Docker build and push
|
||||
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
@@ -222,7 +222,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v5
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Delete untagged images
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
path: zhc
|
||||
|
||||
- name: Comment on new device support/external converter issue
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const {newDeviceSupport} = await import("${{ github.workspace }}/z2m/scripts/issueBot.mjs")
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
release_created: ${{ steps.release.outputs.release_created }}
|
||||
version: '${{steps.release.outputs.major}}.${{steps.release.outputs.minor}}.${{steps.release.outputs.patch}}'
|
||||
steps:
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v5
|
||||
with:
|
||||
version: 9
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- uses: googleapis/release-please-action@v4
|
||||
- uses: googleapis/release-please-action@v5
|
||||
id: release
|
||||
with:
|
||||
target-branch: dev
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
with:
|
||||
ref: dev
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "2.8.0"
|
||||
".": "2.10.1"
|
||||
}
|
||||
|
||||
+144
@@ -1,5 +1,149 @@
|
||||
# Changelog
|
||||
|
||||
## [2.10.1](https://github.com/Koenkk/zigbee2mqtt/compare/2.10.0...2.10.1) (2026-05-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ignore:** bump @biomejs/biome from 2.4.13 to 2.4.14 in the minor-patch group across 1 directory ([#31881](https://github.com/Koenkk/zigbee2mqtt/issues/31881)) ([55f12ff](https://github.com/Koenkk/zigbee2mqtt/commit/55f12ff52063d576be0fe82891678c52181f3b8e))
|
||||
* **ignore:** bump express-static-gzip from 3.0.0 to 3.0.1 in the minor-patch group ([#31924](https://github.com/Koenkk/zigbee2mqtt/issues/31924)) ([24e9027](https://github.com/Koenkk/zigbee2mqtt/commit/24e90273c9e712e122887e74ccf8208a8b42e762))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.43.0 ([#31878](https://github.com/Koenkk/zigbee2mqtt/issues/31878)) ([19eb05a](https://github.com/Koenkk/zigbee2mqtt/commit/19eb05a0e023c7e2e8639c1dcda18399e96ae74d))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.44.0 ([#31900](https://github.com/Koenkk/zigbee2mqtt/issues/31900)) ([845bdb7](https://github.com/Koenkk/zigbee2mqtt/commit/845bdb7dba5fe0f5e67e1b92c68872da0f3abf6a))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.45.0 ([#31925](https://github.com/Koenkk/zigbee2mqtt/issues/31925)) ([8727abd](https://github.com/Koenkk/zigbee2mqtt/commit/8727abdefc9114a5e2e1036d764271c90065e0a1))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.46.0 ([#31937](https://github.com/Koenkk/zigbee2mqtt/issues/31937)) ([cc84566](https://github.com/Koenkk/zigbee2mqtt/commit/cc8456617a4a389e7297e380901bfd6bb0223b32))
|
||||
* Replace deprecated `url.parse` ([#31845](https://github.com/Koenkk/zigbee2mqtt/issues/31845)) ([9f7ea9b](https://github.com/Koenkk/zigbee2mqtt/commit/9f7ea9b7c79db7a781b431fbe43568e15647f8e5))
|
||||
|
||||
## [2.10.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.9.2...2.10.0) (2026-05-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Home Assistant: add group entities in discovery config ([#31663](https://github.com/Koenkk/zigbee2mqtt/issues/31663)) ([0419726](https://github.com/Koenkk/zigbee2mqtt/commit/041972669a6a8108e8fee1a0fffb555292371285))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Clarify units of pause_on_backoff_gt ([#31668](https://github.com/Koenkk/zigbee2mqtt/issues/31668)) ([4a63e65](https://github.com/Koenkk/zigbee2mqtt/commit/4a63e65981b6bbd746a730e7ca2291aba7a6c975))
|
||||
* **deps:** bump googleapis/release-please-action from 4 to 5 ([#31805](https://github.com/Koenkk/zigbee2mqtt/issues/31805)) ([4e527bb](https://github.com/Koenkk/zigbee2mqtt/commit/4e527bbce8612c85fd636a1c493059af9793991f))
|
||||
* **ignore:** bump @biomejs/biome from 2.4.10 to 2.4.11 in the minor-patch group ([#31699](https://github.com/Koenkk/zigbee2mqtt/issues/31699)) ([cdd7357](https://github.com/Koenkk/zigbee2mqtt/commit/cdd73575227527ae7eca5732d96687a30cea8538))
|
||||
* **ignore:** bump @types/node from 24.12.0 to 24.12.2 in the minor-patch group across 1 directory ([#31628](https://github.com/Koenkk/zigbee2mqtt/issues/31628)) ([e6dd5c7](https://github.com/Koenkk/zigbee2mqtt/commit/e6dd5c73bc2d76b32c45ef19633626dbcdde8379))
|
||||
* **ignore:** bump the minor-patch group with 2 updates ([#31758](https://github.com/Koenkk/zigbee2mqtt/issues/31758)) ([7ba232e](https://github.com/Koenkk/zigbee2mqtt/commit/7ba232e27d973119530119ca136aaab3e8e22ab6))
|
||||
* **ignore:** bump the minor-patch group with 3 updates ([#31806](https://github.com/Koenkk/zigbee2mqtt/issues/31806)) ([b3983b3](https://github.com/Koenkk/zigbee2mqtt/commit/b3983b35de0b6242d8e4d01f0d1d1e535e2d0b60))
|
||||
* **ignore:** bump typescript from 5.9.3 to 6.0.2 ([#31560](https://github.com/Koenkk/zigbee2mqtt/issues/31560)) ([9e27181](https://github.com/Koenkk/zigbee2mqtt/commit/9e27181b64b771250db283b5a4b1da7835470ee6))
|
||||
* **ignore:** update zigbee-herdsman to 10.0.6 ([#31636](https://github.com/Koenkk/zigbee2mqtt/issues/31636)) ([aef3242](https://github.com/Koenkk/zigbee2mqtt/commit/aef32423195914aa9ef08875d4fe6de36df71f11))
|
||||
* **ignore:** update zigbee-herdsman to 10.0.7 ([#31673](https://github.com/Koenkk/zigbee2mqtt/issues/31673)) ([a0204ad](https://github.com/Koenkk/zigbee2mqtt/commit/a0204ad3f00ed366e4c41455ae4189bd31870a95))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.28.0 ([#31574](https://github.com/Koenkk/zigbee2mqtt/issues/31574)) ([08e4e9e](https://github.com/Koenkk/zigbee2mqtt/commit/08e4e9edeb048a07f89d6283e5bea04274a3fb47))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.29.0 ([#31586](https://github.com/Koenkk/zigbee2mqtt/issues/31586)) ([23f9847](https://github.com/Koenkk/zigbee2mqtt/commit/23f9847994da94fa8b38ba823a8e55418b006e7c))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.30.0 ([#31608](https://github.com/Koenkk/zigbee2mqtt/issues/31608)) ([6b567c1](https://github.com/Koenkk/zigbee2mqtt/commit/6b567c1286201aa9fef4744f7378798bc852c9ff))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.31.0 ([#31637](https://github.com/Koenkk/zigbee2mqtt/issues/31637)) ([a50065c](https://github.com/Koenkk/zigbee2mqtt/commit/a50065cbb40bb149a87046bb83a7602a5587b3b0))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.32.0 ([#31653](https://github.com/Koenkk/zigbee2mqtt/issues/31653)) ([4818cc4](https://github.com/Koenkk/zigbee2mqtt/commit/4818cc4163d578990339daf6c5e725bb40a08216))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.33.1 ([#31674](https://github.com/Koenkk/zigbee2mqtt/issues/31674)) ([0c9ec72](https://github.com/Koenkk/zigbee2mqtt/commit/0c9ec722db3f53c57870de7f09de9971d69cbda8))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.34.0 ([#31702](https://github.com/Koenkk/zigbee2mqtt/issues/31702)) ([55d8d1f](https://github.com/Koenkk/zigbee2mqtt/commit/55d8d1f4d0a5895ce146dbec98630d82adcd4e22))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.35.0 ([#31713](https://github.com/Koenkk/zigbee2mqtt/issues/31713)) ([0a7dfce](https://github.com/Koenkk/zigbee2mqtt/commit/0a7dfcef596c3201f9f0cd81960c0e4ad8add0c4))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.36.0 ([#31737](https://github.com/Koenkk/zigbee2mqtt/issues/31737)) ([a54775d](https://github.com/Koenkk/zigbee2mqtt/commit/a54775dea10a14b6d2f25ebb3c79a8a9d979e970))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.37.0 ([#31749](https://github.com/Koenkk/zigbee2mqtt/issues/31749)) ([78f440b](https://github.com/Koenkk/zigbee2mqtt/commit/78f440bada9a14abb1d84af12fc77b1af7d250ea))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.38.0 ([#31768](https://github.com/Koenkk/zigbee2mqtt/issues/31768)) ([305b7bc](https://github.com/Koenkk/zigbee2mqtt/commit/305b7bcafab59250173eddb9c08cb2fed0d966a0))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.38.1 ([#31777](https://github.com/Koenkk/zigbee2mqtt/issues/31777)) ([3bb3d56](https://github.com/Koenkk/zigbee2mqtt/commit/3bb3d56c05b92e349d2b117973df6cf9414b8450))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.39.1 ([#31783](https://github.com/Koenkk/zigbee2mqtt/issues/31783)) ([e715078](https://github.com/Koenkk/zigbee2mqtt/commit/e71507835b2c0673ec0df488e9d1db39a7b64a05))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.40.0 ([#31800](https://github.com/Koenkk/zigbee2mqtt/issues/31800)) ([429c5ae](https://github.com/Koenkk/zigbee2mqtt/commit/429c5aea585da1525347adc13712bc556b4ea2ae))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.41.0 ([#31820](https://github.com/Koenkk/zigbee2mqtt/issues/31820)) ([c581623](https://github.com/Koenkk/zigbee2mqtt/commit/c58162344fddc1cdb49027f6dcc9f1f35030bc61))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.42.0 ([#31830](https://github.com/Koenkk/zigbee2mqtt/issues/31830)) ([df26459](https://github.com/Koenkk/zigbee2mqtt/commit/df26459b46e67f4f3b1079eb6fa6a439bf1979af))
|
||||
* Improve transmit power description ([#31735](https://github.com/Koenkk/zigbee2mqtt/issues/31735)) ([5974286](https://github.com/Koenkk/zigbee2mqtt/commit/59742864457a9e29783363ebde8ede0e0aca5ea2))
|
||||
|
||||
## [2.9.2](https://github.com/Koenkk/zigbee2mqtt/compare/2.9.1...2.9.2) (2026-03-31)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Export definition version ([#31270](https://github.com/Koenkk/zigbee2mqtt/issues/31270)) ([9f1eb76](https://github.com/Koenkk/zigbee2mqtt/commit/9f1eb764a62b041a7d9782066a8ffcaa3562eaad))
|
||||
* Home Assistant: fix entity names for derived weather sensors by removing `device_class` ([#31234](https://github.com/Koenkk/zigbee2mqtt/issues/31234)) ([b7b4303](https://github.com/Koenkk/zigbee2mqtt/commit/b7b43030eb8aaa144dbb5477805638d860efdd38))
|
||||
* **ignore:** bump @biomejs/biome from 2.4.4 to 2.4.5 in the minor-patch group ([#31237](https://github.com/Koenkk/zigbee2mqtt/issues/31237)) ([aefbb8b](https://github.com/Koenkk/zigbee2mqtt/commit/aefbb8be14ae5d84495278cbe9a6ca9d750553cb))
|
||||
* **ignore:** bump the minor-patch group with 2 updates ([#31411](https://github.com/Koenkk/zigbee2mqtt/issues/31411)) ([67aa509](https://github.com/Koenkk/zigbee2mqtt/commit/67aa5099df2371eb9ee220be7a8821f0d4f8b38b))
|
||||
* **ignore:** bump the minor-patch group with 3 updates ([#31295](https://github.com/Koenkk/zigbee2mqtt/issues/31295)) ([b7f1cec](https://github.com/Koenkk/zigbee2mqtt/commit/b7f1cecb49f088b7fc79dcef068034ca1d012c1d))
|
||||
* **ignore:** bump the minor-patch group with 3 updates ([#31489](https://github.com/Koenkk/zigbee2mqtt/issues/31489)) ([52b063b](https://github.com/Koenkk/zigbee2mqtt/commit/52b063bb32429e14b30319c905e596b7632bd674))
|
||||
* **ignore:** bump the minor-patch group with 3 updates ([#31559](https://github.com/Koenkk/zigbee2mqtt/issues/31559)) ([73d8ae8](https://github.com/Koenkk/zigbee2mqtt/commit/73d8ae895613af0d77f0a684f7305d688af11146))
|
||||
* **ignore:** update zigbee-herdsman to 10.0.1 ([#31383](https://github.com/Koenkk/zigbee2mqtt/issues/31383)) ([6fca374](https://github.com/Koenkk/zigbee2mqtt/commit/6fca3744c9715dd4afb7fc777cf783c84bfb91fc))
|
||||
* **ignore:** update zigbee-herdsman to 10.0.2 ([#31413](https://github.com/Koenkk/zigbee2mqtt/issues/31413)) ([075f87c](https://github.com/Koenkk/zigbee2mqtt/commit/075f87c7822c29c74fc1c4031866aca8c445c9be))
|
||||
* **ignore:** update zigbee-herdsman to 10.0.3 ([#31450](https://github.com/Koenkk/zigbee2mqtt/issues/31450)) ([6fb9d56](https://github.com/Koenkk/zigbee2mqtt/commit/6fb9d563e69c2db3b31fd0f163e5f30924047e3f))
|
||||
* **ignore:** update zigbee-herdsman to 10.0.4 ([#31478](https://github.com/Koenkk/zigbee2mqtt/issues/31478)) ([867179c](https://github.com/Koenkk/zigbee2mqtt/commit/867179c430eacf9bd66ef359b9a7435f2299d944))
|
||||
* **ignore:** update zigbee-herdsman to 10.0.5 ([#31562](https://github.com/Koenkk/zigbee2mqtt/issues/31562)) ([fb3189b](https://github.com/Koenkk/zigbee2mqtt/commit/fb3189b7431cb203092bbe235bd35194e2b44a44))
|
||||
* **ignore:** update zigbee-herdsman to 9.0.10 ([#31245](https://github.com/Koenkk/zigbee2mqtt/issues/31245)) ([d632382](https://github.com/Koenkk/zigbee2mqtt/commit/d6323825e4ae3168d52a096e7647bc7174d3cdb1))
|
||||
* **ignore:** update zigbee-herdsman to 9.0.11 ([#31257](https://github.com/Koenkk/zigbee2mqtt/issues/31257)) ([1747618](https://github.com/Koenkk/zigbee2mqtt/commit/1747618afa3948ac9fb8899d94fba8456198e4b5))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.13.0 ([#31231](https://github.com/Koenkk/zigbee2mqtt/issues/31231)) ([fc3a196](https://github.com/Koenkk/zigbee2mqtt/commit/fc3a1968f6635d246ad2d68e5c6b79d495fd96bb))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.14.0 ([#31246](https://github.com/Koenkk/zigbee2mqtt/issues/31246)) ([284d86e](https://github.com/Koenkk/zigbee2mqtt/commit/284d86e93ba8cd83139d6dd0217519f67c01e565))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.16.1 ([#31268](https://github.com/Koenkk/zigbee2mqtt/issues/31268)) ([05946b5](https://github.com/Koenkk/zigbee2mqtt/commit/05946b59552608c6ae8a8b02098552fe611f027a))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.17.0 ([#31314](https://github.com/Koenkk/zigbee2mqtt/issues/31314)) ([9143924](https://github.com/Koenkk/zigbee2mqtt/commit/91439244823d9897998a57448321d2e8af9b083f))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.18.0 ([#31362](https://github.com/Koenkk/zigbee2mqtt/issues/31362)) ([f60db2d](https://github.com/Koenkk/zigbee2mqtt/commit/f60db2d7a641ab5fccbb3a7b6a0e1e73983559ef))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.19.1 ([#31384](https://github.com/Koenkk/zigbee2mqtt/issues/31384)) ([69641d5](https://github.com/Koenkk/zigbee2mqtt/commit/69641d5467ec5e3ddfebb4ff98142d0ff990c814))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.20.0 ([#31415](https://github.com/Koenkk/zigbee2mqtt/issues/31415)) ([9f49d0e](https://github.com/Koenkk/zigbee2mqtt/commit/9f49d0ec6738ebd1ea65a3bf1aae553548217f2e))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.21.0 ([#31427](https://github.com/Koenkk/zigbee2mqtt/issues/31427)) ([e054d71](https://github.com/Koenkk/zigbee2mqtt/commit/e054d7124bd4dc5bdb18c9de4a6923b06df6b929))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.22.0 ([#31451](https://github.com/Koenkk/zigbee2mqtt/issues/31451)) ([64286eb](https://github.com/Koenkk/zigbee2mqtt/commit/64286ebb3054bc15e020aab431c1d3af76e2c914))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.23.0 ([#31462](https://github.com/Koenkk/zigbee2mqtt/issues/31462)) ([2e5e362](https://github.com/Koenkk/zigbee2mqtt/commit/2e5e3628188765cddbd4116f41703eab491670df))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.24.0 ([#31479](https://github.com/Koenkk/zigbee2mqtt/issues/31479)) ([dde637a](https://github.com/Koenkk/zigbee2mqtt/commit/dde637af2f8449ae24684fa34a32bec106c24913))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.25.0 ([#31497](https://github.com/Koenkk/zigbee2mqtt/issues/31497)) ([66985a8](https://github.com/Koenkk/zigbee2mqtt/commit/66985a8ce8d4cefbc4ddacc754e182e0b5953cea))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.26.0 ([#31510](https://github.com/Koenkk/zigbee2mqtt/issues/31510)) ([969db9a](https://github.com/Koenkk/zigbee2mqtt/commit/969db9ae85838b955e07a80f5f72f655f6beffcf))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.27.0 ([#31563](https://github.com/Koenkk/zigbee2mqtt/issues/31563)) ([62ab814](https://github.com/Koenkk/zigbee2mqtt/commit/62ab814d73b7de957fb8b28679b9dea8ae087133))
|
||||
* Match new cluster-related typing from ZH ([#31298](https://github.com/Koenkk/zigbee2mqtt/issues/31298)) ([0f781ee](https://github.com/Koenkk/zigbee2mqtt/commit/0f781ee9c03ae502745841f4dd7d6f3775c620d4))
|
||||
* Reintroduce onboarding improvements ([#31273](https://github.com/Koenkk/zigbee2mqtt/issues/31273)) ([4a83692](https://github.com/Koenkk/zigbee2mqtt/commit/4a8369295c5d6c07ff1ac66f90a35b0e8b915296))
|
||||
|
||||
## [2.9.1](https://github.com/Koenkk/zigbee2mqtt/compare/2.9.0...2.9.1) (2026-03-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Fix onboarding ([#31228](https://github.com/Koenkk/zigbee2mqtt/issues/31228)) ([7f0a9aa](https://github.com/Koenkk/zigbee2mqtt/commit/7f0a9aa2a30ab79c182d8ea9e875339e9031fdbd))
|
||||
|
||||
## [2.9.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.8.0...2.9.0) (2026-03-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Improve onboarding ([#31152](https://github.com/Koenkk/zigbee2mqtt/issues/31152)) ([8ecf353](https://github.com/Koenkk/zigbee2mqtt/commit/8ecf353dd634cc56f8cbbfbe21646945c262a11e))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Fix Home Assistant options cannot be updated while running ([#31151](https://github.com/Koenkk/zigbee2mqtt/issues/31151)) ([a77f48f](https://github.com/Koenkk/zigbee2mqtt/commit/a77f48f5c464903712f7143bb6a85ea701a39c5e))
|
||||
* Home Assistant: add `measurement` `state_class` to `_count` entities ([#30978](https://github.com/Koenkk/zigbee2mqtt/issues/30978)) ([d475aa5](https://github.com/Koenkk/zigbee2mqtt/commit/d475aa5be5caa85a5dc3f0883dc40b2bcddc09b2))
|
||||
* Home Assistant: add `state_class: measurement` for `external_humidity` and `occupancy_level` https://github.com/Koenkk/zigbee2mqtt/issues/31022 ([02a2fd8](https://github.com/Koenkk/zigbee2mqtt/commit/02a2fd8fef786be9488c1adc97093b1ec6bff9e1))
|
||||
* Home Assistant: add weather station sensor discovery lookup entries ([#31136](https://github.com/Koenkk/zigbee2mqtt/issues/31136)) ([8d1240e](https://github.com/Koenkk/zigbee2mqtt/commit/8d1240eba161e8eb92ce6fdda757710a07c4456e))
|
||||
* Home Assistant: discover `temperature_probe` as `temperature` `device_class` https://github.com/Koenkk/zigbee2mqtt/issues/30862 ([8d8d37e](https://github.com/Koenkk/zigbee2mqtt/commit/8d8d37e3888cbfa4899831874ee9df6e306ddb52))
|
||||
* Home Assistant: expose siren entity for IAS warning devices ([#31000](https://github.com/Koenkk/zigbee2mqtt/issues/31000)) ([c7dae39](https://github.com/Koenkk/zigbee2mqtt/commit/c7dae397999102ec30c6681bc6a7577e97c8b707))
|
||||
* Home Assistant: set `state_class` `measurement` for `external_temperature` https://github.com/Koenkk/zigbee2mqtt/issues/31022 ([28a9f39](https://github.com/Koenkk/zigbee2mqtt/commit/28a9f39dbcd5a04e5eb5630855de2b82ec676dac))
|
||||
* **ignore:** bump @biomejs/biome from 2.4.1 to 2.4.4 in the minor-patch group ([#31159](https://github.com/Koenkk/zigbee2mqtt/issues/31159)) ([1d5184f](https://github.com/Koenkk/zigbee2mqtt/commit/1d5184f2bd46afa50faa470a97e807888ed81a78))
|
||||
* **ignore:** bump mqtt from 5.14.1 to 5.15.0 in the minor-patch group ([#30918](https://github.com/Koenkk/zigbee2mqtt/issues/30918)) ([4b03910](https://github.com/Koenkk/zigbee2mqtt/commit/4b039103c0a9bd32458011dbc2e7bea0e609d0c7))
|
||||
* **ignore:** bump the minor-patch group with 2 updates ([#31211](https://github.com/Koenkk/zigbee2mqtt/issues/31211)) ([89192f9](https://github.com/Koenkk/zigbee2mqtt/commit/89192f9decdf41338ec925c88860b560906b3ad4))
|
||||
* **ignore:** bump the minor-patch group with 3 updates ([#31011](https://github.com/Koenkk/zigbee2mqtt/issues/31011)) ([15e385b](https://github.com/Koenkk/zigbee2mqtt/commit/15e385bac6dbfd87869df87f1be9a66cf4db76f1))
|
||||
* **ignore:** bump the minor-patch group with 4 updates ([#31081](https://github.com/Koenkk/zigbee2mqtt/issues/31081)) ([42d88fe](https://github.com/Koenkk/zigbee2mqtt/commit/42d88fe90d6272f2c8241ac7a6e6b2c5ce5a55af))
|
||||
* **ignore:** Changes for new ZHC `definition.version` ([#30402](https://github.com/Koenkk/zigbee2mqtt/issues/30402)) ([b71461e](https://github.com/Koenkk/zigbee2mqtt/commit/b71461e03cd8ceea5365f4c0b2e794e34341325e))
|
||||
* **ignore:** Fix enableDisableExtension ([#31176](https://github.com/Koenkk/zigbee2mqtt/issues/31176)) ([e1e856d](https://github.com/Koenkk/zigbee2mqtt/commit/e1e856d7da9c92952c3de94853794337fea1945d))
|
||||
* **ignore:** update zigbee-herdsman to 9.0.3 ([#30960](https://github.com/Koenkk/zigbee2mqtt/issues/30960)) ([30d311e](https://github.com/Koenkk/zigbee2mqtt/commit/30d311e8494c00e6ade7759a0a2bfdc9ab79022d))
|
||||
* **ignore:** update zigbee-herdsman to 9.0.4 ([#30996](https://github.com/Koenkk/zigbee2mqtt/issues/30996)) ([bf888de](https://github.com/Koenkk/zigbee2mqtt/commit/bf888de4789b01e824ca6b5f82506691b4f689a9))
|
||||
* **ignore:** update zigbee-herdsman to 9.0.5 ([#31117](https://github.com/Koenkk/zigbee2mqtt/issues/31117)) ([5957085](https://github.com/Koenkk/zigbee2mqtt/commit/5957085fcc8d6acdedf426a3f16632f1de55a061))
|
||||
* **ignore:** update zigbee-herdsman to 9.0.6 ([#31149](https://github.com/Koenkk/zigbee2mqtt/issues/31149)) ([8560c11](https://github.com/Koenkk/zigbee2mqtt/commit/8560c11e0667304a643eac907d3c62e35bcc2e23))
|
||||
* **ignore:** update zigbee-herdsman to 9.0.7 ([#31174](https://github.com/Koenkk/zigbee2mqtt/issues/31174)) ([79bcf27](https://github.com/Koenkk/zigbee2mqtt/commit/79bcf276f57454a77ea76b6d8286544abd4d7cca))
|
||||
* **ignore:** update zigbee-herdsman to 9.0.8 ([#31207](https://github.com/Koenkk/zigbee2mqtt/issues/31207)) ([c7d54ec](https://github.com/Koenkk/zigbee2mqtt/commit/c7d54ec360ee34ba4cb062dcd474154fb70467a5))
|
||||
* **ignore:** update zigbee-herdsman to 9.0.9 ([#31215](https://github.com/Koenkk/zigbee2mqtt/issues/31215)) ([8ee5b1f](https://github.com/Koenkk/zigbee2mqtt/commit/8ee5b1f03bdc06062d58913e827f086b6bbcb026))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.121.0 ([#30898](https://github.com/Koenkk/zigbee2mqtt/issues/30898)) ([09d010e](https://github.com/Koenkk/zigbee2mqtt/commit/09d010ea067446d26b6c77411aac78d045dbc6ea))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.122.0 ([#30935](https://github.com/Koenkk/zigbee2mqtt/issues/30935)) ([402ed60](https://github.com/Koenkk/zigbee2mqtt/commit/402ed6094eedb40ecdb841b4b1f7d29d59afc89c))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.0.0 ([6cce05a](https://github.com/Koenkk/zigbee2mqtt/commit/6cce05a5141089e24211d85d1d77d09f8d2b36f0))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.1.0 ([#30981](https://github.com/Koenkk/zigbee2mqtt/issues/30981)) ([f1156db](https://github.com/Koenkk/zigbee2mqtt/commit/f1156db227bf937354d6ab4e70d6da59307dedad))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.10.1 ([#31175](https://github.com/Koenkk/zigbee2mqtt/issues/31175)) ([b9d9e5e](https://github.com/Koenkk/zigbee2mqtt/commit/b9d9e5e75c3f175ec0fc69f6315c5ca75a8e8128))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.11.0 ([#31185](https://github.com/Koenkk/zigbee2mqtt/issues/31185)) ([c880d36](https://github.com/Koenkk/zigbee2mqtt/commit/c880d36f29a8f146170cae34bef15b86d01f5889))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.12.0 ([#31208](https://github.com/Koenkk/zigbee2mqtt/issues/31208)) ([a9a6ebe](https://github.com/Koenkk/zigbee2mqtt/commit/a9a6ebe00743b47bd00667ae64d0f0fb25f5e38d))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.2.0 ([#30998](https://github.com/Koenkk/zigbee2mqtt/issues/30998)) ([b585e31](https://github.com/Koenkk/zigbee2mqtt/commit/b585e3130431c7c27b18b9779ed038b9e6dd30c6))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.2.1 ([#31015](https://github.com/Koenkk/zigbee2mqtt/issues/31015)) ([4e360d0](https://github.com/Koenkk/zigbee2mqtt/commit/4e360d0bf2fed651f7c801bcce8455dc8cf4d649))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.3.0 ([#31028](https://github.com/Koenkk/zigbee2mqtt/issues/31028)) ([c599e19](https://github.com/Koenkk/zigbee2mqtt/commit/c599e19bf2252b90554ec3a68abd64487acd8882))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.4.0 ([#31036](https://github.com/Koenkk/zigbee2mqtt/issues/31036)) ([142754b](https://github.com/Koenkk/zigbee2mqtt/commit/142754b71cf99ac97d5d25d6a124c90d17bf321d))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.5.0 ([#31058](https://github.com/Koenkk/zigbee2mqtt/issues/31058)) ([061262f](https://github.com/Koenkk/zigbee2mqtt/commit/061262ff97c5f30571f77df5b06362023c282fa6))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.6.0 ([#31076](https://github.com/Koenkk/zigbee2mqtt/issues/31076)) ([909d2b3](https://github.com/Koenkk/zigbee2mqtt/commit/909d2b32b7ee7fe9144351b82d174e31fd0bad40))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.7.0 ([#31090](https://github.com/Koenkk/zigbee2mqtt/issues/31090)) ([9d566f4](https://github.com/Koenkk/zigbee2mqtt/commit/9d566f4c015e7a31adaeef3b04bcc3c2312d583f))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.8.0 ([#31118](https://github.com/Koenkk/zigbee2mqtt/issues/31118)) ([396eb38](https://github.com/Koenkk/zigbee2mqtt/commit/396eb38e4b9a329a651dec46ec816be537a3c997))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.9.0 ([#31137](https://github.com/Koenkk/zigbee2mqtt/issues/31137)) ([4d287ee](https://github.com/Koenkk/zigbee2mqtt/commit/4d287eed0c2b746e1cfbd18ad61ed7b5129a98d9))
|
||||
* Improve configure attempts lazy+cleanup ([#31126](https://github.com/Koenkk/zigbee2mqtt/issues/31126)) ([0c43c98](https://github.com/Koenkk/zigbee2mqtt/commit/0c43c983063c1aa01b85318c85bf7101f0085430))
|
||||
* Improve startup signals behavior ([#31130](https://github.com/Koenkk/zigbee2mqtt/issues/31130)) ([f8385b8](https://github.com/Koenkk/zigbee2mqtt/commit/f8385b88993cfcf3a27c315999c86b961fd8fbfa))
|
||||
* update zigbee2mqtt-windfront to 2.9.0 ([#31162](https://github.com/Koenkk/zigbee2mqtt/issues/31162)) ([93dc9a3](https://github.com/Koenkk/zigbee2mqtt/commit/93dc9a3664f23a4a13a55e54c16714d721e596d7))
|
||||
|
||||
## [2.8.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.7.2...2.8.0) (2026-02-01)
|
||||
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ Zigbee2MQTT integrates well with (almost) every home automation solution because
|
||||
|
||||
### [Home Assistant](https://www.home-assistant.io/)
|
||||
|
||||
<img align="left" height="75px" width="75px" src="https://user-images.githubusercontent.com/7738048/40914297-49e6e560-6800-11e8-8904-36cce896e5a8.png">
|
||||
<a href="https://www.home-assistant.io/"><img align="left" src="https://avatars.githubusercontent.com/u/13844975?s=75" style="padding-right: 20px;" alt="Home Assistant"></a>
|
||||
|
||||
<br clear="right">
|
||||
|
||||
@@ -58,7 +58,7 @@ Zigbee2MQTT integrates well with (almost) every home automation solution because
|
||||
|
||||
### [Homey](https://homey.app/)
|
||||
|
||||
<img align="left" height="75px" width="75px" src="https://etc.athom.com/logo/white/256.png">
|
||||
<a href="https://homey.app/"><img align="left" src="https://avatars.githubusercontent.com/u/8502422?s=75" style="padding-right: 20px;" alt="Homey"></a>
|
||||
|
||||
<br clear="right">
|
||||
|
||||
@@ -68,7 +68,7 @@ Integration implemented in the [Homey App](https://homey.app/nl-nl/app/com.gruij
|
||||
|
||||
### [Domoticz](https://www.domoticz.com/)
|
||||
|
||||
<img align="left" height="75px" width="75px" src="https://user-images.githubusercontent.com/2734836/47615848-b8dd8700-dabd-11e8-9d77-175002dd8987.png">
|
||||
<a href="https://www.domoticz.com/"><img align="left" src="https://avatars.githubusercontent.com/u/6067010?s=75" style="padding-right: 20px;" alt="Domoticz"></a>
|
||||
|
||||
<br clear="right">
|
||||
|
||||
@@ -78,7 +78,7 @@ Integration implemented in Domoticz ([documentation](https://www.domoticz.com/wi
|
||||
|
||||
### [Gladys Assistant](https://gladysassistant.com/)
|
||||
|
||||
<img align="left" height="75px" width="75px" src="./images/gladys-assistant-logo.jpg">
|
||||
<a href="https://gladysassistant.com/"><img align="left" src="https://avatars.githubusercontent.com/u/7365162?s=75" style="padding-right: 20px;" alt="Gladys Assistant"></a>
|
||||
|
||||
<br clear="right">
|
||||
|
||||
@@ -86,13 +86,13 @@ Integration implemented natively in Gladys Assistant ([documentation](https://gl
|
||||
|
||||
<br clear="both">
|
||||
|
||||
### [IoBroker](https://www.iobroker.net/)
|
||||
### [ioBroker](https://www.iobroker.net/)
|
||||
|
||||
<img align="left" height="75px" width="75px" src="https://forum.iobroker.net/assets/uploads/system/site-logo.png">
|
||||
<a href="https://www.iobroker.net/"><img align="left" src="https://avatars.githubusercontent.com/u/7231148?s=75" style="padding-right: 20px;" alt="ioBroker"></a>
|
||||
|
||||
<br clear="right">
|
||||
|
||||
Integration implemented in IoBroker ([documentation](https://github.com/o0shojo0o/ioBroker.zigbee2mqtt)).
|
||||
Integration implemented in ioBroker ([documentation](https://github.com/o0shojo0o/ioBroker.zigbee2mqtt)).
|
||||
|
||||
<br clear="both">
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.12/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.10/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
const semver = require("semver");
|
||||
const engines = require("./package.json").engines;
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const {exec} = require("node:child_process");
|
||||
require("source-map-support").install();
|
||||
|
||||
/** @type {import("./dist/controller").Controller | undefined} */
|
||||
let controller;
|
||||
let stopping = false;
|
||||
const watchdog = process.env.Z2M_WATCHDOG != null;
|
||||
let watchdogCount = 0;
|
||||
let unsolicitedStop = false;
|
||||
// csv in minutes, default: 1min, 5min, 15min, 30min, 60min
|
||||
let watchdogDelays = [2000, 60000, 300000, 900000, 1800000, 3600000];
|
||||
|
||||
if (watchdog && process.env.Z2M_WATCHDOG !== "default") {
|
||||
if (process.env.Z2M_WATCHDOG != null && process.env.Z2M_WATCHDOG !== "default") {
|
||||
if (/^\d+(.\d+)?(,\d+(.\d+)?)*$/.test(process.env.Z2M_WATCHDOG)) {
|
||||
watchdogDelays = process.env.Z2M_WATCHDOG.split(",").map((v) => Number.parseFloat(v) * 60000);
|
||||
} else {
|
||||
@@ -25,6 +22,7 @@ if (watchdog && process.env.Z2M_WATCHDOG !== "default") {
|
||||
|
||||
const hashFile = path.join(__dirname, "dist", ".hash");
|
||||
|
||||
/** @type {(code: number) => Promise<void>} */
|
||||
async function triggerWatchdog(code) {
|
||||
const delay = watchdogDelays[watchdogCount];
|
||||
watchdogCount += 1;
|
||||
@@ -41,14 +39,16 @@ async function triggerWatchdog(code) {
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {() => Promise<void>} */
|
||||
async function restart() {
|
||||
await stop(true);
|
||||
await start();
|
||||
}
|
||||
|
||||
/** @type {(code: number, restart?: boolean) => Promise<void>} */
|
||||
async function exit(code, restart = false) {
|
||||
if (!restart) {
|
||||
if (watchdog && unsolicitedStop) {
|
||||
if (process.env.Z2M_WATCHDOG != null && unsolicitedStop) {
|
||||
await triggerWatchdog(code);
|
||||
} else {
|
||||
process.exit(code);
|
||||
@@ -56,6 +56,7 @@ async function exit(code, restart = false) {
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {() => Promise<string>} */
|
||||
async function currentHash() {
|
||||
return await new Promise((resolve) => {
|
||||
exec("git rev-parse --short=8 HEAD", (error, stdout) => {
|
||||
@@ -70,20 +71,23 @@ async function currentHash() {
|
||||
});
|
||||
}
|
||||
|
||||
/** @type {() => Promise<void>} */
|
||||
async function writeHash() {
|
||||
const hash = await currentHash();
|
||||
|
||||
fs.writeFileSync(hashFile, hash);
|
||||
}
|
||||
|
||||
/** @type {(reason: "initial build" | "hash changed") => Promise<void>} */
|
||||
async function build(reason) {
|
||||
process.stdout.write(`Building Zigbee2MQTT... (${reason})`);
|
||||
const {totalmem} = await import("node:os");
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const env = {...process.env};
|
||||
const mb600 = 629145600;
|
||||
|
||||
if (mb600 > os.totalmem() && !env.NODE_OPTIONS) {
|
||||
if (mb600 > totalmem() && !env.NODE_OPTIONS) {
|
||||
// Prevent OOM on tsc compile for system with low memory
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/12034
|
||||
env.NODE_OPTIONS = "--max_old_space_size=256";
|
||||
@@ -107,6 +111,7 @@ async function build(reason) {
|
||||
});
|
||||
}
|
||||
|
||||
/** @type {() => Promise<void>} */
|
||||
async function checkDist() {
|
||||
if (!fs.existsSync(hashFile)) {
|
||||
await build("initial build");
|
||||
@@ -120,20 +125,22 @@ async function checkDist() {
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {() => Promise<void>} */
|
||||
async function start() {
|
||||
console.log(`Starting Zigbee2MQTT ${watchdog ? `with watchdog (${watchdogDelays})` : "without watchdog"}.`);
|
||||
console.log(`Starting Zigbee2MQTT ${process.env.Z2M_WATCHDOG != null ? `with watchdog (${watchdogDelays})` : "without watchdog"}.`);
|
||||
await checkDist();
|
||||
|
||||
// gc
|
||||
{
|
||||
const version = engines.node;
|
||||
const packageJSON = (await import("./package.json", {with: {type: "json"}})).default;
|
||||
const version = packageJSON.engines.node;
|
||||
const {satisfies} = await import("semver");
|
||||
|
||||
if (!semver.satisfies(process.version, version)) {
|
||||
if (!satisfies(process.version, version)) {
|
||||
console.log(`\t\tZigbee2MQTT requires node version ${version}, you are running ${process.version}!\n`);
|
||||
}
|
||||
|
||||
const {onboard} = require("./dist/util/onboarding");
|
||||
|
||||
const {onboard} = await import("./dist/util/onboarding.js");
|
||||
const success = await onboard();
|
||||
|
||||
if (!success) {
|
||||
@@ -143,7 +150,7 @@ async function start() {
|
||||
}
|
||||
}
|
||||
|
||||
const {Controller} = require("./dist/controller");
|
||||
const {Controller} = await import("./dist/controller.js");
|
||||
controller = new Controller(restart, exit);
|
||||
|
||||
await controller.start();
|
||||
@@ -153,26 +160,28 @@ async function start() {
|
||||
watchdogCount = 0; // reset
|
||||
}
|
||||
|
||||
async function stop(restart) {
|
||||
/** @type {(restart: boolean, signal?: NodeJS.Signals) => Promise<void>} */
|
||||
async function stop(restart, signal = undefined) {
|
||||
// `handleQuit` or `restart` never unsolicited
|
||||
unsolicitedStop = false;
|
||||
|
||||
await controller.stop(restart);
|
||||
await controller?.stop(restart, undefined, signal);
|
||||
}
|
||||
|
||||
async function handleQuit() {
|
||||
/** @type {(signal: NodeJS.Signals) => Promise<void>} */
|
||||
async function handleQuit(signal) {
|
||||
if (!stopping) {
|
||||
if (controller) {
|
||||
stopping = true;
|
||||
|
||||
await stop(false);
|
||||
await stop(false, signal);
|
||||
} else {
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module || require.main.filename.endsWith(`${path.sep}cli.js`)) {
|
||||
if (require.main === module || require.main?.filename.endsWith(`${path.sep}cli.js`)) {
|
||||
if (process.argv.length === 3 && process.argv[2] === "writehash") {
|
||||
void writeHash();
|
||||
} else {
|
||||
|
||||
+70
-22
@@ -28,6 +28,8 @@ import utils from "./util/utils";
|
||||
import Zigbee from "./zigbee";
|
||||
|
||||
export class Controller {
|
||||
/** Allows canceling in-progress startup sequence if necessary. Signal can be passed to long-running ops as necessary for finer control. */
|
||||
#startAbortController: AbortController | undefined;
|
||||
public readonly eventBus: EventBus;
|
||||
public readonly zigbee: Zigbee;
|
||||
public readonly state: State;
|
||||
@@ -79,6 +81,9 @@ export class Controller {
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.#startAbortController = new AbortController();
|
||||
const abortSignal = this.#startAbortController.signal;
|
||||
|
||||
if (settings.get().frontend.enabled) {
|
||||
const {Frontend} = await import("./extension/frontend.js");
|
||||
|
||||
@@ -93,33 +98,48 @@ export class Controller {
|
||||
|
||||
this.state.start();
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
logger.info(`Zigbee2MQTT start aborted, reason=${abortSignal.reason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const info = await utils.getZigbee2MQTTVersion();
|
||||
logger.info(`Starting Zigbee2MQTT version ${info.version} (commit #${info.commitHash})`);
|
||||
|
||||
// Start zigbee
|
||||
try {
|
||||
await this.zigbee.start();
|
||||
const started = await this.zigbee.start(abortSignal);
|
||||
|
||||
this.eventBus.onAdapterDisconnected(this, async () => {
|
||||
logger.error("Adapter disconnected, stopping");
|
||||
await this.stop(false, 2);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to start zigbee-herdsman");
|
||||
logger.error(
|
||||
"Check https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start_crashes-runtime.html for possible solutions",
|
||||
);
|
||||
logger.error("Exiting...");
|
||||
// biome-ignore lint/style/noNonNullAssertion: always Error
|
||||
logger.error((error as Error).stack!);
|
||||
|
||||
/* v8 ignore start */
|
||||
if ((error as Error).message.includes("USB adapter discovery error (No valid USB adapter found)")) {
|
||||
logger.error("If this happens after updating to Zigbee2MQTT 2.0.0, see https://github.com/Koenkk/zigbee2mqtt/discussions/24364");
|
||||
if (started) {
|
||||
this.eventBus.onAdapterDisconnected(this, async () => {
|
||||
logger.error("Adapter disconnected, stopping");
|
||||
await this.stop(false, 2);
|
||||
});
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
} catch (error) {
|
||||
// skip if aborted by `stop`
|
||||
if (error !== "SIGINT" && error !== "SIGTERM" && error !== "STOPABORT") {
|
||||
logger.error("Failed to start zigbee-herdsman");
|
||||
logger.error(
|
||||
"Check https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start_crashes-runtime.html for possible solutions",
|
||||
);
|
||||
logger.error("Exiting...");
|
||||
// biome-ignore lint/style/noNonNullAssertion: always Error
|
||||
logger.error((error as Error).stack!);
|
||||
|
||||
return await this.exit(1);
|
||||
/* v8 ignore start */
|
||||
if ((error as Error).message.includes("USB adapter discovery error (No valid USB adapter found)")) {
|
||||
logger.error("If this happens after updating to Zigbee2MQTT 2.0.0, see https://github.com/Koenkk/zigbee2mqtt/discussions/24364");
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
return await this.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
logger.info(`Zigbee2MQTT start aborted, reason=${abortSignal.reason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Log zigbee clients on startup
|
||||
@@ -147,9 +167,19 @@ export class Controller {
|
||||
return await this.exit(1);
|
||||
}
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
logger.info(`Zigbee2MQTT start aborted, reason=${abortSignal.reason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// copy current Set of extensions to ignore possible external extensions added while looping
|
||||
for (const extension of new Set(this.extensions)) {
|
||||
await this.startExtension(extension);
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
logger.info(`Zigbee2MQTT start aborted, reason=${abortSignal.reason}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Send all cached states.
|
||||
@@ -157,6 +187,11 @@ export class Controller {
|
||||
for (const entity of this.zigbee.devicesAndGroupsIterator()) {
|
||||
if (this.state.exists(entity)) {
|
||||
await this.publishEntityState(entity, this.state.get(entity), "publishCached");
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
logger.info(`Zigbee2MQTT start aborted, reason=${abortSignal.reason}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,10 +205,14 @@ export class Controller {
|
||||
this.sdNotify = await initSdNotify();
|
||||
|
||||
settings.setOnboarding(false);
|
||||
|
||||
this.#startAbortController = undefined; // gc
|
||||
}
|
||||
|
||||
@bind async enableDisableExtension(enable: boolean, name: string): Promise<void> {
|
||||
if (enable) {
|
||||
let extension: Extension;
|
||||
|
||||
switch (name) {
|
||||
case "Frontend": {
|
||||
if (!settings.get().frontend.enabled) {
|
||||
@@ -184,7 +223,7 @@ export class Controller {
|
||||
/* v8 ignore start */
|
||||
const {Frontend} = await import("./extension/frontend.js");
|
||||
|
||||
await this.addExtension(new Frontend(...this.extensionArgs));
|
||||
extension = new Frontend(...this.extensionArgs);
|
||||
|
||||
break;
|
||||
/* v8 ignore stop */
|
||||
@@ -196,7 +235,7 @@ export class Controller {
|
||||
|
||||
const {HomeAssistant} = await import("./extension/homeassistant.js");
|
||||
|
||||
await this.addExtension(new HomeAssistant(...this.extensionArgs));
|
||||
extension = new HomeAssistant(...this.extensionArgs);
|
||||
|
||||
break;
|
||||
}
|
||||
@@ -206,6 +245,12 @@ export class Controller {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const existingExtension = this.getExtension(name);
|
||||
if (existingExtension) {
|
||||
await this.removeExtension(existingExtension);
|
||||
}
|
||||
await this.extensions.add(extension);
|
||||
} else {
|
||||
switch (name) {
|
||||
case "Frontend": {
|
||||
@@ -287,7 +332,10 @@ export class Controller {
|
||||
}
|
||||
}
|
||||
|
||||
async stop(restart = false, code = 0): Promise<void> {
|
||||
async stop(restart = false, code = 0, signal: NodeJS.Signals | undefined = undefined): Promise<void> {
|
||||
logger.info(`Stopping Zigbee2MQTT (restart=${restart}, code=${code}, signal=${signal})`);
|
||||
|
||||
this.#startAbortController?.abort(signal ?? "STOPABORT");
|
||||
this.sdNotify?.notifyStopping();
|
||||
|
||||
let localCode = 0;
|
||||
|
||||
@@ -900,6 +900,7 @@ export default class Bridge extends Extension {
|
||||
exposes: device.exposes(),
|
||||
supports_ota: !!device.definition.ota,
|
||||
options: device.definition.options ?? [],
|
||||
version: device.definition.version,
|
||||
icon,
|
||||
};
|
||||
|
||||
|
||||
+31
-18
@@ -1,6 +1,5 @@
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import * as zhc from "zigbee-herdsman-converters";
|
||||
import Device from "../model/device";
|
||||
import type {Zigbee2MQTTAPI} from "../types/api";
|
||||
import logger from "../util/logger";
|
||||
@@ -12,8 +11,8 @@ import Extension from "./extension";
|
||||
* This extension calls the zigbee-herdsman-converters definition configure() method
|
||||
*/
|
||||
export default class Configure extends Extension {
|
||||
private configuring = new Set();
|
||||
private attempts: {[s: string]: number} = {};
|
||||
private configuring = new Set<string>();
|
||||
private attempts = new Map<string, number>();
|
||||
private topic = `${settings.get().mqtt.base_topic}/bridge/request/device/configure`;
|
||||
|
||||
@bind private async onReconfigure(data: eventdata.Reconfigure): Promise<void> {
|
||||
@@ -39,7 +38,7 @@ export default class Configure extends Extension {
|
||||
|
||||
if (!device || !(device instanceof Device)) {
|
||||
error = `Device '${ID}' does not exist`;
|
||||
} else if (!device.definition || !device.definition.configure) {
|
||||
} else if (!device.definition?.configure) {
|
||||
error = `Device '${device.name}' cannot be configured`;
|
||||
} else {
|
||||
try {
|
||||
@@ -94,42 +93,56 @@ export default class Configure extends Extension {
|
||||
return;
|
||||
}
|
||||
|
||||
const definitionVersion = device.definition.version;
|
||||
|
||||
if (!force) {
|
||||
if (device.options.disabled || !device.interviewed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (device.zh.meta?.configured !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only configure end devices when it is active, otherwise it will likely fails as they are sleeping.
|
||||
if (device.zh.type === "EndDevice" && event !== "zigbee_event") {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldReconfigure =
|
||||
// Should always reconfigure when not configured before
|
||||
device.zh.meta?.configured === undefined ||
|
||||
// Or should reconfigure when definition.version is not '0.0.0' and differs from last `meta.configured`.
|
||||
// In older Z2M versions the stored `meta.configured` was the hash of the configure function.
|
||||
// Since we don't want to reconfigure all devices, we don't re-configure when the definition has the default version of '0.0.0'.
|
||||
(definitionVersion !== "0.0.0" && device.zh.meta?.configured !== definitionVersion);
|
||||
if (!shouldReconfigure) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.configuring.has(device.ieeeAddr) || (this.attempts[device.ieeeAddr] >= 3 && !force)) {
|
||||
if (this.configuring.has(device.ieeeAddr)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attempts = this.attempts.get(device.ieeeAddr) ?? 0;
|
||||
|
||||
if (attempts >= 3 && !force) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.configuring.add(device.ieeeAddr);
|
||||
|
||||
if (this.attempts[device.ieeeAddr] === undefined) {
|
||||
this.attempts[device.ieeeAddr] = 0;
|
||||
}
|
||||
|
||||
logger.info(`Configuring '${device.name}'`);
|
||||
|
||||
try {
|
||||
await device.definition.configure(device.zh, this.zigbee.firstCoordinatorEndpoint(), device.definition);
|
||||
logger.info(`Successfully configured '${device.name}'`);
|
||||
device.zh.meta.configured = zhc.getConfigureKey(device.definition);
|
||||
this.attempts.delete(device.ieeeAddr);
|
||||
logger.info(`Successfully configured '${device.name}' (definition v${definitionVersion})`);
|
||||
device.zh.meta.configured = definitionVersion;
|
||||
device.zh.save();
|
||||
this.eventBus.emitDevicesChanged();
|
||||
} catch (error) {
|
||||
this.attempts[device.ieeeAddr]++;
|
||||
const attempt = this.attempts[device.ieeeAddr];
|
||||
const msg = `Failed to configure '${device.name}', attempt ${attempt} (${(error as Error).stack})`;
|
||||
const newAttempts = attempts + 1;
|
||||
this.attempts.set(device.ieeeAddr, newAttempts);
|
||||
|
||||
const msg = `Failed to configure '${device.name}', attempt ${newAttempts} (${(error as Error).stack})`;
|
||||
logger.error(msg);
|
||||
|
||||
if (throwError) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import {createServer} from "node:http";
|
||||
import {createServer as createSecureServer} from "node:https";
|
||||
import type {Socket} from "node:net";
|
||||
import {posix} from "node:path";
|
||||
import {parse} from "node:url";
|
||||
import bind from "bind-decorator";
|
||||
import expressStaticGzip from "express-static-gzip";
|
||||
import finalhandler from "finalhandler";
|
||||
@@ -157,10 +156,10 @@ export class Frontend extends Extension {
|
||||
@bind private onUpgrade(request: IncomingMessage, socket: Socket, head: Buffer): void {
|
||||
this.wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
// biome-ignore lint/style/noNonNullAssertion: `Only valid for request obtained from Server`
|
||||
const {query} = parse(request.url!, true);
|
||||
const {searchParams} = new URL(request.url!, "http://localhost"); // dummy base, may not be absolute
|
||||
const authToken = settings.get().frontend.auth_token;
|
||||
|
||||
if (!authToken || authToken === query.token) {
|
||||
if (!authToken || authToken === searchParams.get("token")) {
|
||||
this.wss.emit("connection", ws, request);
|
||||
} else {
|
||||
ws.close(4401, "Unauthorized");
|
||||
|
||||
@@ -168,7 +168,7 @@ export default class Groups extends Extension {
|
||||
endpointNames &&
|
||||
endpointNames.length >= member.ID &&
|
||||
device.definition?.meta?.multiEndpoint &&
|
||||
(!device.definition.meta.multiEndpointSkip || !device.definition.meta.multiEndpointSkip.includes("state"))
|
||||
!device.definition.meta.multiEndpointSkip?.includes("state")
|
||||
? `state_${endpointNames[member.ID - 1]}`
|
||||
: "state";
|
||||
|
||||
|
||||
+172
-72
@@ -2,7 +2,7 @@ import assert from "node:assert";
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import type * as zhc from "zigbee-herdsman-converters";
|
||||
|
||||
import type {Zh} from "zigbee-herdsman-converters/lib/types";
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import utils, {assertBinaryExpose, assertEnumExpose, assertNumericExpose, isBinaryExpose, isEnumExpose, isNumericExpose} from "../util/utils";
|
||||
@@ -18,6 +18,7 @@ interface DiscoveryEntry {
|
||||
type: string;
|
||||
object_id: string;
|
||||
discovery_payload: KeyValue;
|
||||
endpoint?: Zh.Endpoint;
|
||||
}
|
||||
|
||||
interface Discovered {
|
||||
@@ -86,6 +87,7 @@ const BINARY_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
occupancy: {device_class: "occupancy"},
|
||||
power_outage_memory: {entity_category: "config", icon: "mdi:memory"},
|
||||
presence: {device_class: "occupancy"},
|
||||
rain_status: {device_class: "moisture", icon: "mdi:weather-pouring"},
|
||||
setup: {device_class: "running"},
|
||||
smoke: {device_class: "smoke"},
|
||||
sos: {device_class: "safety"},
|
||||
@@ -118,6 +120,7 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
alarm_temperature_min: {device_class: "temperature", entity_category: "config", icon: "mdi:thermometer-low"},
|
||||
angle: {icon: "angle-acute"},
|
||||
angle_axis: {icon: "angle-acute"},
|
||||
apparent_temperature: {icon: "mdi:thermometer-lines", state_class: "measurement"},
|
||||
aqi: {device_class: "aqi", state_class: "measurement"},
|
||||
auto_relock_time: {entity_category: "config", icon: "mdi:timer"},
|
||||
away_preset_days: {entity_category: "config", icon: "mdi:timer"},
|
||||
@@ -152,19 +155,23 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
entity_category: "diagnostic",
|
||||
state_class: "measurement",
|
||||
},
|
||||
dew_point: {icon: "mdi:thermometer-water", state_class: "measurement"},
|
||||
distance: {device_class: "distance", state_class: "measurement"},
|
||||
duration: {entity_category: "config", icon: "mdi:timer"},
|
||||
eco2: {device_class: "volatile_organic_compounds_parts", state_class: "measurement"},
|
||||
eco_temperature: {entity_category: "config", icon: "mdi:thermometer"},
|
||||
energy: {device_class: "energy", state_class: "total_increasing"},
|
||||
external_temperature_input: {device_class: "temperature", icon: "mdi:thermometer"},
|
||||
external_temperature: {device_class: "temperature", icon: "mdi:thermometer"},
|
||||
external_humidity: {device_class: "humidity", icon: "mdi:water-percent"},
|
||||
external_temperature: {device_class: "temperature", icon: "mdi:thermometer", state_class: "measurement"},
|
||||
external_humidity: {device_class: "humidity", icon: "mdi:water-percent", state_class: "measurement"},
|
||||
formaldehyd: {state_class: "measurement"},
|
||||
flow: {device_class: "volume_flow_rate", state_class: "measurement"},
|
||||
gas: {device_class: "gas", state_class: "total_increasing", icon: "mdi:meter-gas"},
|
||||
gas_density: {icon: "mdi:google-circles-communities", state_class: "measurement"},
|
||||
gust_speed: {icon: "mdi:weather-windy-variant", state_class: "measurement"},
|
||||
hcho: {icon: "mdi:air-filter", state_class: "measurement"},
|
||||
heat_stress: {icon: "mdi:weather-sunny-alert", state_class: "measurement"},
|
||||
humidex: {icon: "mdi:thermometer-alert", state_class: "measurement"},
|
||||
humidity: {device_class: "humidity", state_class: "measurement"},
|
||||
humidity_calibration: {entity_category: "config", icon: "mdi:wrench-clock"},
|
||||
humidity_max: {entity_category: "config", icon: "mdi:water-percent"},
|
||||
@@ -196,7 +203,7 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
noise: {device_class: "sound_pressure", state_class: "measurement"},
|
||||
noise_detect_level: {icon: "mdi:volume-equal"},
|
||||
noise_timeout: {icon: "mdi:timer"},
|
||||
occupancy_level: {icon: "mdi:motion-sensor"},
|
||||
occupancy_level: {icon: "mdi:motion-sensor", state_class: "measurement"},
|
||||
occupancy_sensitivity: {entity_category: "config", icon: "mdi:motion-sensor"},
|
||||
occupancy_timeout: {entity_category: "config", icon: "mdi:timer"},
|
||||
overload_protection: {icon: "mdi:flash"},
|
||||
@@ -208,10 +215,13 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
power_phase_b: {device_class: "power", state_class: "measurement"},
|
||||
power_phase_c: {device_class: "power", state_class: "measurement"},
|
||||
power_factor: {device_class: "power_factor", enabled_by_default: false, entity_category: "diagnostic", state_class: "measurement"},
|
||||
power_outage_count: {icon: "mdi:counter", enabled_by_default: false},
|
||||
power_outage_count: {icon: "mdi:counter", enabled_by_default: false, state_class: "measurement"},
|
||||
precipitation: {device_class: "precipitation", icon: "mdi:weather-rainy", state_class: "total_increasing"},
|
||||
precision: {entity_category: "config", icon: "mdi:decimal-comma-increase"},
|
||||
pressure: {device_class: "atmospheric_pressure", state_class: "measurement"},
|
||||
pressure_trend: {icon: "mdi:trending-up", state_class: "measurement"},
|
||||
presence_timeout: {entity_category: "config", icon: "mdi:timer"},
|
||||
rain_rate: {device_class: "precipitation_intensity", icon: "mdi:weather-pouring", state_class: "measurement"},
|
||||
reporting_time: {entity_category: "config", icon: "mdi:clock-time-one-outline"},
|
||||
requested_brightness_level: {
|
||||
enabled_by_default: false,
|
||||
@@ -226,12 +236,14 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
smoke_density: {icon: "mdi:google-circles-communities", state_class: "measurement"},
|
||||
soil_moisture: {device_class: "moisture", state_class: "measurement"},
|
||||
temperature: {device_class: "temperature", state_class: "measurement"},
|
||||
temperature_probe: {device_class: "temperature", state_class: "measurement"},
|
||||
temperature_calibration: {entity_category: "config", icon: "mdi:wrench-clock"},
|
||||
temperature_max: {entity_category: "config", icon: "mdi:thermometer-plus"},
|
||||
temperature_min: {entity_category: "config", icon: "mdi:thermometer-minus"},
|
||||
temperature_offset: {icon: "mdi:thermometer-lines"},
|
||||
transition: {entity_category: "config", icon: "mdi:transition"},
|
||||
trigger_count: {icon: "mdi:counter", enabled_by_default: false},
|
||||
trigger_count: {icon: "mdi:counter", enabled_by_default: false, state_class: "measurement"},
|
||||
uv_index: {icon: "mdi:white-balance-sunny", state_class: "measurement"},
|
||||
voc: {device_class: "volatile_organic_compounds", state_class: "measurement"},
|
||||
voc_index: {state_class: "measurement", icon: "mdi:molecule"},
|
||||
voc_parts: {device_class: "volatile_organic_compounds_parts", state_class: "measurement"},
|
||||
@@ -243,6 +255,9 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
device_class: "water",
|
||||
state_class: "total_increasing",
|
||||
},
|
||||
wind_chill: {icon: "mdi:snowflake-thermometer", state_class: "measurement"},
|
||||
wind_direction: {icon: "mdi:compass-outline", state_class: "measurement"},
|
||||
wind_speed: {device_class: "wind_speed", icon: "mdi:weather-windy", state_class: "measurement"},
|
||||
x: {icon: "mdi:axis-x-arrow", state_class: "measurement"},
|
||||
x_axis: {icon: "mdi:axis-x-arrow", state_class: "measurement"},
|
||||
y: {icon: "mdi:axis-y-arrow", state_class: "measurement"},
|
||||
@@ -290,6 +305,7 @@ const ENUM_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
thermostat_unit: {entity_category: "config", icon: "mdi:thermometer"},
|
||||
update: {device_class: "update"},
|
||||
volume: {entity_category: "config", icon: "mdi: volume-high"},
|
||||
weather_condition: {icon: "mdi:weather-partly-cloudy"},
|
||||
week: {entity_category: "config", icon: "mdi:calendar-clock"},
|
||||
} as const;
|
||||
const LIST_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
@@ -369,6 +385,7 @@ export class HomeAssistant extends Extension {
|
||||
private discoveryTopic: string;
|
||||
private discoveryRegex: RegExp;
|
||||
private discoveryRegexWoTopic = /(.*)\/(.*)\/(.*)\/config/;
|
||||
private groupMemberLookup = new Map<string, string>();
|
||||
private statusTopic: string;
|
||||
private legacyActionSensor: boolean;
|
||||
private experimentalEventEntities: boolean;
|
||||
@@ -444,10 +461,15 @@ export class HomeAssistant extends Extension {
|
||||
const discoverWait = 5;
|
||||
// Discover with `published = false`, this will populate `this.discovered` without publishing the discoveries.
|
||||
// This is needed for clearing outdated entries in `this.onMQTTMessage()`
|
||||
// Discover devices before groups to populate `this.groupMemberLookup` for group discovery.
|
||||
await this.discover(this.bridge, false);
|
||||
|
||||
for (const e of this.zigbee.devicesAndGroupsIterator(utils.deviceNotCoordinator)) {
|
||||
await this.discover(e, false);
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
await this.discover(device, false);
|
||||
}
|
||||
|
||||
for (const group of this.zigbee.groupsIterator()) {
|
||||
await this.discover(group, false);
|
||||
}
|
||||
|
||||
logger.debug(`Discovering entities to Home Assistant in ${discoverWait}s`);
|
||||
@@ -472,21 +494,16 @@ export class HomeAssistant extends Extension {
|
||||
return this.discovered[ID];
|
||||
}
|
||||
|
||||
private exposeToConfig(
|
||||
exposes: zhc.Expose[],
|
||||
entityType: "device" | "group",
|
||||
allExposes: zhc.Expose[],
|
||||
definition?: zhc.Definition,
|
||||
): DiscoveryEntry[] {
|
||||
private exposeToConfig(exposes: zhc.Expose[], entity: Device | Group, allExposes: zhc.Expose[]): DiscoveryEntry[] {
|
||||
// For groups an array of exposes (of the same type) is passed, this is to determine e.g. what features
|
||||
// to use for a bulb (e.g. color_xy/color_temp)
|
||||
assert(entityType === "group" || exposes.length === 1, "Multiple exposes for device not allowed");
|
||||
assert(entity.isGroup() || exposes.length === 1, "Multiple exposes for device not allowed");
|
||||
const firstExpose = exposes[0];
|
||||
assert(entityType === "device" || GROUP_SUPPORTED_TYPES.includes(firstExpose.type), `Unsupported expose type ${firstExpose.type} for group`);
|
||||
assert(entity.isDevice() || GROUP_SUPPORTED_TYPES.includes(firstExpose.type), `Unsupported expose type ${firstExpose.type} for group`);
|
||||
|
||||
const discoveryEntries: DiscoveryEntry[] = [];
|
||||
const endpoint = entityType === "device" ? exposes[0].endpoint : undefined;
|
||||
const getProperty = (feature: zhc.Feature): string => (entityType === "group" ? featurePropertyWithoutEndpoint(feature) : feature.property);
|
||||
const endpointName = entity.isDevice() ? exposes[0].endpoint : undefined;
|
||||
const getProperty = (feature: zhc.Feature): string => (entity.isGroup() ? featurePropertyWithoutEndpoint(feature) : feature.property);
|
||||
|
||||
switch (firstExpose.type) {
|
||||
case "light": {
|
||||
@@ -505,16 +522,16 @@ export class HomeAssistant extends Extension {
|
||||
|
||||
const discoveryEntry: DiscoveryEntry = {
|
||||
type: "light",
|
||||
object_id: endpoint ? `light_${endpoint}` : "light",
|
||||
object_id: endpointName ? `light_${endpointName}` : "light",
|
||||
mockProperties: [{property: state.property, value: null}],
|
||||
discovery_payload: {
|
||||
name: endpoint ? utils.capitalize(endpoint) : null,
|
||||
name: endpointName ? utils.capitalize(endpointName) : null,
|
||||
brightness: !!hasBrightness,
|
||||
schema: "json",
|
||||
command_topic: true,
|
||||
brightness_scale: 254,
|
||||
command_topic_prefix: endpoint,
|
||||
state_topic_postfix: endpoint,
|
||||
command_topic_prefix: endpointName,
|
||||
state_topic_postfix: endpointName,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -567,15 +584,15 @@ export class HomeAssistant extends Extension {
|
||||
const property = getProperty(state);
|
||||
const discoveryEntry: DiscoveryEntry = {
|
||||
type: "switch",
|
||||
object_id: endpoint ? `switch_${endpoint}` : "switch",
|
||||
object_id: endpointName ? `switch_${endpointName}` : "switch",
|
||||
mockProperties: [{property: property, value: null}],
|
||||
discovery_payload: {
|
||||
name: endpoint ? utils.capitalize(endpoint) : null,
|
||||
name: endpointName ? utils.capitalize(endpointName) : null,
|
||||
payload_off: state.value_off,
|
||||
payload_on: state.value_on,
|
||||
value_template: `{{ value_json.${property} }}`,
|
||||
command_topic: true,
|
||||
command_topic_prefix: endpoint,
|
||||
command_topic_prefix: endpointName,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -606,10 +623,10 @@ export class HomeAssistant extends Extension {
|
||||
|
||||
const discoveryEntry: DiscoveryEntry = {
|
||||
type: "climate",
|
||||
object_id: endpoint ? `climate_${endpoint}` : "climate",
|
||||
object_id: endpointName ? `climate_${endpointName}` : "climate",
|
||||
mockProperties: [],
|
||||
discovery_payload: {
|
||||
name: endpoint ? utils.capitalize(endpoint) : null,
|
||||
name: endpointName ? utils.capitalize(endpointName) : null,
|
||||
// Static
|
||||
state_topic: false,
|
||||
temperature_unit: "C",
|
||||
@@ -620,7 +637,7 @@ export class HomeAssistant extends Extension {
|
||||
// Temperature
|
||||
current_temperature_topic: true,
|
||||
current_temperature_template: `{{ value_json.${temperature.property} }}`,
|
||||
command_topic_prefix: endpoint,
|
||||
command_topic_prefix: endpointName,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -689,13 +706,13 @@ export class HomeAssistant extends Extension {
|
||||
if (tempCalibration) {
|
||||
const discoveryEntry: DiscoveryEntry = {
|
||||
type: "number",
|
||||
object_id: endpoint ? `${tempCalibration.name}_${endpoint}` : `${tempCalibration.name}`,
|
||||
object_id: endpointName ? `${tempCalibration.name}_${endpointName}` : `${tempCalibration.name}`,
|
||||
mockProperties: [{property: tempCalibration.property, value: null}],
|
||||
discovery_payload: {
|
||||
name: endpoint ? `${tempCalibration.label} ${endpoint}` : tempCalibration.label,
|
||||
name: endpointName ? `${tempCalibration.label} ${endpointName}` : tempCalibration.label,
|
||||
value_template: `{{ value_json.${tempCalibration.property} }}`,
|
||||
command_topic: true,
|
||||
command_topic_prefix: endpoint,
|
||||
command_topic_prefix: endpointName,
|
||||
command_topic_postfix: tempCalibration.property,
|
||||
device_class: "temperature_delta",
|
||||
entity_category: "config",
|
||||
@@ -714,10 +731,10 @@ export class HomeAssistant extends Extension {
|
||||
const piHeatingDemand = (firstExpose as zhc.Climate).features.filter(isNumericExpose).find((f) => f.name === "pi_heating_demand");
|
||||
if (piHeatingDemand) {
|
||||
const discoveryEntry: Partial<DiscoveryEntry> = {
|
||||
object_id: endpoint ? `${piHeatingDemand.name}_${endpoint}` : `${piHeatingDemand.name}`,
|
||||
object_id: endpointName ? `${piHeatingDemand.name}_${endpointName}` : `${piHeatingDemand.name}`,
|
||||
mockProperties: [{property: piHeatingDemand.property, value: null}],
|
||||
discovery_payload: {
|
||||
name: endpoint ? `${piHeatingDemand.label} ${endpoint}` : piHeatingDemand.label,
|
||||
name: endpointName ? `${piHeatingDemand.label} ${endpointName}` : piHeatingDemand.label,
|
||||
value_template: `{{ value_json.${piHeatingDemand.property} }}`,
|
||||
...(piHeatingDemand.unit && {unit_of_measurement: piHeatingDemand.unit}),
|
||||
icon: "mdi:radiator",
|
||||
@@ -729,7 +746,7 @@ export class HomeAssistant extends Extension {
|
||||
if (piHeatingDemand.access & ACCESS_SET) {
|
||||
discoveryEntry.type = "number";
|
||||
discoveryEntry.discovery_payload.command_topic = true;
|
||||
discoveryEntry.discovery_payload.command_topic_prefix = endpoint;
|
||||
discoveryEntry.discovery_payload.command_topic_prefix = endpointName;
|
||||
discoveryEntry.discovery_payload.command_topic_postfix = piHeatingDemand.property;
|
||||
discoveryEntry.discovery_payload.min = piHeatingDemand.value_min;
|
||||
discoveryEntry.discovery_payload.max = piHeatingDemand.value_max;
|
||||
@@ -745,10 +762,10 @@ export class HomeAssistant extends Extension {
|
||||
if (piCoolingDemand) {
|
||||
const discoveryEntry: DiscoveryEntry = {
|
||||
type: "sensor",
|
||||
object_id: endpoint ? /* v8 ignore next */ `${piCoolingDemand.name}_${endpoint}` : `${piCoolingDemand.name}`,
|
||||
object_id: endpointName ? /* v8 ignore next */ `${piCoolingDemand.name}_${endpointName}` : `${piCoolingDemand.name}`,
|
||||
mockProperties: [{property: piCoolingDemand.property, value: null}],
|
||||
discovery_payload: {
|
||||
name: endpoint ? /* v8 ignore next */ `${piCoolingDemand.label} ${endpoint}` : piCoolingDemand.label,
|
||||
name: endpointName ? /* v8 ignore next */ `${piCoolingDemand.label} ${endpointName}` : piCoolingDemand.label,
|
||||
value_template: `{{ value_json.${piCoolingDemand.property} }}`,
|
||||
...(piCoolingDemand.unit && {unit_of_measurement: piCoolingDemand.unit}),
|
||||
entity_category: "diagnostic",
|
||||
@@ -767,10 +784,10 @@ export class HomeAssistant extends Extension {
|
||||
if (localTemperature && !temperatureSensor && !localTemperatureSensor) {
|
||||
const discoveryEntry: DiscoveryEntry = {
|
||||
type: "sensor",
|
||||
object_id: endpoint ? `${localTemperature.name}_${endpoint}` : `${localTemperature.name}`,
|
||||
object_id: endpointName ? `${localTemperature.name}_${endpointName}` : `${localTemperature.name}`,
|
||||
mockProperties: [{property: localTemperature.property, value: null}],
|
||||
discovery_payload: {
|
||||
name: endpoint ? `${localTemperature.label} ${endpoint}` : localTemperature.label,
|
||||
name: endpointName ? `${localTemperature.label} ${endpointName}` : localTemperature.label,
|
||||
value_template: `{{ value_json.${localTemperature.property} }}`,
|
||||
...(localTemperature.unit && {unit_of_measurement: localTemperature.unit}),
|
||||
device_class: "temperature",
|
||||
@@ -796,18 +813,18 @@ export class HomeAssistant extends Extension {
|
||||
const discoveryEntry: DiscoveryEntry = {
|
||||
type: "lock",
|
||||
/* v8 ignore next */
|
||||
object_id: endpoint ? `lock_${endpoint}` : "lock",
|
||||
object_id: endpointName ? `lock_${endpointName}` : "lock",
|
||||
mockProperties: [{property: state.property, value: null}],
|
||||
discovery_payload: {
|
||||
/* v8 ignore next */
|
||||
name: endpoint ? utils.capitalize(endpoint) : null,
|
||||
command_topic_prefix: endpoint,
|
||||
name: endpointName ? utils.capitalize(endpointName) : null,
|
||||
command_topic_prefix: endpointName,
|
||||
command_topic: true,
|
||||
value_template: `{{ value_json.${state.property} }}`,
|
||||
state_locked: state.value_on,
|
||||
state_unlocked: state.value_off,
|
||||
/* v8 ignore next */
|
||||
command_topic_postfix: endpoint ? state.property : null,
|
||||
command_topic_postfix: endpointName ? state.property : null,
|
||||
},
|
||||
};
|
||||
discoveryEntries.push(discoveryEntry);
|
||||
@@ -832,13 +849,13 @@ export class HomeAssistant extends Extension {
|
||||
const discoveryEntry: DiscoveryEntry = {
|
||||
type: "cover",
|
||||
mockProperties: [{property: state.property, value: null}],
|
||||
object_id: endpoint ? `cover_${endpoint}` : "cover",
|
||||
object_id: endpointName ? `cover_${endpointName}` : "cover",
|
||||
discovery_payload: {
|
||||
name: endpoint ? utils.capitalize(endpoint) : null,
|
||||
command_topic_prefix: endpoint,
|
||||
name: endpointName ? utils.capitalize(endpointName) : null,
|
||||
command_topic_prefix: endpointName,
|
||||
command_topic: true,
|
||||
state_topic: true,
|
||||
state_topic_postfix: endpoint,
|
||||
state_topic_postfix: endpointName,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -901,7 +918,7 @@ export class HomeAssistant extends Extension {
|
||||
break;
|
||||
}
|
||||
case "fan": {
|
||||
assert(!endpoint, "Endpoint not supported for fan type");
|
||||
assert(!endpointName, "Endpoint not supported for fan type");
|
||||
const discoveryEntry: DiscoveryEntry = {
|
||||
type: "fan",
|
||||
object_id: "fan",
|
||||
@@ -937,7 +954,7 @@ export class HomeAssistant extends Extension {
|
||||
);
|
||||
let presets = ["on", "auto", "smart"].filter((s) => modeEmulatedSpeed.values.includes(s));
|
||||
|
||||
if (definition?.model === "99432") {
|
||||
if (entity.isDevice() && entity.definition?.model === "99432") {
|
||||
// The Hampton Bay 99432 fan implements 4 speeds using the ZCL
|
||||
// hvacFanCtrl values `low`, `medium`, `high`, and `on`, and
|
||||
// 1 preset called "Comfort Breeze" using the ZCL value `smart`.
|
||||
@@ -999,9 +1016,9 @@ export class HomeAssistant extends Extension {
|
||||
const discoveryEntry: DiscoveryEntry = {
|
||||
type: "switch",
|
||||
mockProperties: [{property: firstExpose.property, value: null}],
|
||||
object_id: endpoint ? `switch_${firstExpose.name}_${endpoint}` : `switch_${firstExpose.name}`,
|
||||
object_id: endpointName ? `switch_${firstExpose.name}_${endpointName}` : `switch_${firstExpose.name}`,
|
||||
discovery_payload: {
|
||||
name: endpoint ? /* v8 ignore next */ `${firstExpose.label} ${endpoint}` : firstExpose.label,
|
||||
name: endpointName ? /* v8 ignore next */ `${firstExpose.label} ${endpointName}` : firstExpose.label,
|
||||
value_template:
|
||||
typeof firstExpose.value_on === "boolean"
|
||||
? `{% if value_json.${firstExpose.property} %}true{% else %}false{% endif %}`
|
||||
@@ -1009,7 +1026,7 @@ export class HomeAssistant extends Extension {
|
||||
payload_on: firstExpose.value_on.toString(),
|
||||
payload_off: firstExpose.value_off.toString(),
|
||||
command_topic: true,
|
||||
command_topic_prefix: endpoint,
|
||||
command_topic_prefix: endpointName,
|
||||
command_topic_postfix: firstExpose.property,
|
||||
...(BINARY_DISCOVERY_LOOKUP[firstExpose.name] || {}),
|
||||
},
|
||||
@@ -1019,10 +1036,10 @@ export class HomeAssistant extends Extension {
|
||||
} else {
|
||||
const discoveryEntry: DiscoveryEntry = {
|
||||
type: "binary_sensor",
|
||||
object_id: endpoint ? `${firstExpose.name}_${endpoint}` : `${firstExpose.name}`,
|
||||
object_id: endpointName ? `${firstExpose.name}_${endpointName}` : `${firstExpose.name}`,
|
||||
mockProperties: [{property: firstExpose.property, value: null}],
|
||||
discovery_payload: {
|
||||
name: endpoint ? /* v8 ignore next */ `${firstExpose.label} ${endpoint}` : firstExpose.label,
|
||||
name: endpointName ? /* v8 ignore next */ `${firstExpose.label} ${endpointName}` : firstExpose.label,
|
||||
value_template: `{{ value_json.${firstExpose.property} }}`,
|
||||
payload_on: firstExpose.value_on,
|
||||
payload_off: firstExpose.value_off,
|
||||
@@ -1044,13 +1061,13 @@ export class HomeAssistant extends Extension {
|
||||
if (allowsSet) {
|
||||
const discoveryEntry: DiscoveryEntry = {
|
||||
type: "number",
|
||||
object_id: endpoint ? `${firstExpose.name}_${endpoint}` : `${firstExpose.name}`,
|
||||
object_id: endpointName ? `${firstExpose.name}_${endpointName}` : `${firstExpose.name}`,
|
||||
mockProperties: [{property: firstExpose.property, value: null}],
|
||||
discovery_payload: {
|
||||
name: endpoint ? `${firstExpose.label} ${endpoint}` : firstExpose.label,
|
||||
name: endpointName ? `${firstExpose.label} ${endpointName}` : firstExpose.label,
|
||||
value_template: `{{ value_json.${firstExpose.property} }}`,
|
||||
command_topic: true,
|
||||
command_topic_prefix: endpoint,
|
||||
command_topic_prefix: endpointName,
|
||||
command_topic_postfix: firstExpose.property,
|
||||
...(firstExpose.unit && {unit_of_measurement: firstExpose.unit}),
|
||||
...(firstExpose.value_step && {step: firstExpose.value_step}),
|
||||
@@ -1095,10 +1112,10 @@ export class HomeAssistant extends Extension {
|
||||
|
||||
const discoveryEntry: DiscoveryEntry = {
|
||||
type: "sensor",
|
||||
object_id: endpoint ? `${firstExpose.name}_${endpoint}` : `${firstExpose.name}`,
|
||||
object_id: endpointName ? `${firstExpose.name}_${endpointName}` : `${firstExpose.name}`,
|
||||
mockProperties: [{property: firstExpose.property, value: null}],
|
||||
discovery_payload: {
|
||||
name: endpoint ? `${firstExpose.label} ${endpoint}` : firstExpose.label,
|
||||
name: endpointName ? `${firstExpose.label} ${endpointName}` : firstExpose.label,
|
||||
value_template: `{{ value_json.${firstExpose.property} }}`,
|
||||
enabled_by_default: !allowsSet,
|
||||
...(firstExpose.unit && {unit_of_measurement: firstExpose.unit}),
|
||||
@@ -1140,7 +1157,7 @@ export class HomeAssistant extends Extension {
|
||||
object_id: firstExpose.property,
|
||||
mockProperties: [],
|
||||
discovery_payload: {
|
||||
name: endpoint ? /* v8 ignore next */ `${firstExpose.label} ${endpoint}` : firstExpose.label,
|
||||
name: endpointName ? /* v8 ignore next */ `${firstExpose.label} ${endpointName}` : firstExpose.label,
|
||||
state_topic: true,
|
||||
event_types: this.prepareActionEventTypes(firstExpose.values),
|
||||
value_template: this.actionValueTemplate,
|
||||
@@ -1164,9 +1181,9 @@ export class HomeAssistant extends Extension {
|
||||
object_id: firstExpose.property,
|
||||
mockProperties: [{property: firstExpose.property, value: null}],
|
||||
discovery_payload: {
|
||||
name: endpoint ? /* v8 ignore next */ `${firstExpose.label} ${endpoint}` : firstExpose.label,
|
||||
name: endpointName ? /* v8 ignore next */ `${firstExpose.label} ${endpointName}` : firstExpose.label,
|
||||
state_topic: false,
|
||||
command_topic_prefix: endpoint,
|
||||
command_topic_prefix: endpointName,
|
||||
command_topic: true,
|
||||
command_topic_postfix: firstExpose.property,
|
||||
payload_press: firstExpose.values[0].toString(),
|
||||
@@ -1185,10 +1202,10 @@ export class HomeAssistant extends Extension {
|
||||
object_id: firstExpose.property,
|
||||
mockProperties: [{property: firstExpose.property, value: null}],
|
||||
discovery_payload: {
|
||||
name: endpoint ? `${firstExpose.label} ${endpoint}` : firstExpose.label,
|
||||
name: endpointName ? `${firstExpose.label} ${endpointName}` : firstExpose.label,
|
||||
value_template: valueTemplate,
|
||||
state_topic: !!(firstExpose.access & ACCESS_STATE),
|
||||
command_topic_prefix: endpoint,
|
||||
command_topic_prefix: endpointName,
|
||||
command_topic: true,
|
||||
command_topic_postfix: firstExpose.property,
|
||||
options: firstExpose.values.map((v) => v.toString()),
|
||||
@@ -1207,7 +1224,7 @@ export class HomeAssistant extends Extension {
|
||||
object_id: firstExpose.property,
|
||||
mockProperties: [{property: firstExpose.property, value: null}],
|
||||
discovery_payload: {
|
||||
name: endpoint ? `${firstExpose.label} ${endpoint}` : firstExpose.label,
|
||||
name: endpointName ? `${firstExpose.label} ${endpointName}` : firstExpose.label,
|
||||
value_template: valueTemplate,
|
||||
...ENUM_DISCOVERY_LOOKUP[firstExpose.name],
|
||||
},
|
||||
@@ -1219,16 +1236,71 @@ export class HomeAssistant extends Extension {
|
||||
case "composite":
|
||||
case "list": {
|
||||
const firstExposeTyped = firstExpose as zhc.Text | zhc.Composite | zhc.List;
|
||||
|
||||
// Warning composite → HA siren entity
|
||||
if (firstExposeTyped.type === "composite" && firstExposeTyped.name === "warning" && firstExposeTyped.access & ACCESS_SET) {
|
||||
const warningExpose = firstExpose as zhc.Composite;
|
||||
const modeFeature = warningExpose.features.filter(isEnumExpose).find((f) => f.name === "mode");
|
||||
const levelFeature = warningExpose.features.filter(isEnumExpose).find((f) => f.name === "level");
|
||||
const durationFeature = warningExpose.features.filter(isNumericExpose).find((f) => f.name === "duration");
|
||||
|
||||
const discoveryEntry: DiscoveryEntry = {
|
||||
type: "siren",
|
||||
object_id: endpointName ? /* v8 ignore next */ `siren_${endpointName}` : "siren",
|
||||
mockProperties: [{property: warningExpose.property, value: null}],
|
||||
discovery_payload: {
|
||||
name: endpointName ? /* v8 ignore next */ utils.capitalize(endpointName) : null,
|
||||
command_topic: true,
|
||||
command_topic_prefix: endpointName,
|
||||
state_topic: false,
|
||||
optimistic: true,
|
||||
},
|
||||
};
|
||||
|
||||
if (modeFeature) {
|
||||
const tones = modeFeature.values.filter((v) => v !== "stop");
|
||||
if (tones.length) {
|
||||
discoveryEntry.discovery_payload.available_tones = tones;
|
||||
}
|
||||
}
|
||||
|
||||
if (levelFeature) {
|
||||
discoveryEntry.discovery_payload.support_volume_set = true;
|
||||
}
|
||||
|
||||
if (durationFeature) {
|
||||
discoveryEntry.discovery_payload.support_duration = true;
|
||||
}
|
||||
|
||||
const levelTemplate =
|
||||
"{% if volume_level is defined %}" +
|
||||
"{% if volume_level | float <= 0.25 %}low" +
|
||||
"{% elif volume_level | float <= 0.5 %}medium" +
|
||||
"{% elif volume_level | float <= 0.75 %}high" +
|
||||
"{% else %}very_high{% endif %}" +
|
||||
"{% else %}medium{% endif %}";
|
||||
|
||||
discoveryEntry.discovery_payload.command_template =
|
||||
`{"warning": {"mode": "{{ tone | default('emergency') }}", ` +
|
||||
`"level": "${levelTemplate}", ` +
|
||||
`"duration": {{ duration | default(10) }}}}`;
|
||||
|
||||
discoveryEntry.discovery_payload.command_off_template = '{"warning": {"mode": "stop"}}';
|
||||
|
||||
discoveryEntries.push(discoveryEntry);
|
||||
break;
|
||||
}
|
||||
|
||||
if (firstExposeTyped.type === "text" && firstExposeTyped.access & ACCESS_SET) {
|
||||
discoveryEntries.push({
|
||||
type: "text",
|
||||
object_id: firstExposeTyped.property,
|
||||
mockProperties: [{property: firstExposeTyped.property, value: null}],
|
||||
discovery_payload: {
|
||||
name: endpoint ? `${firstExposeTyped.label} ${endpoint}` : firstExposeTyped.label,
|
||||
name: endpointName ? `${firstExposeTyped.label} ${endpointName}` : firstExposeTyped.label,
|
||||
state_topic: firstExposeTyped.access & ACCESS_STATE,
|
||||
value_template: `{{ value_json.${firstExposeTyped.property} }}`,
|
||||
command_topic_prefix: endpoint,
|
||||
command_topic_prefix: endpointName,
|
||||
command_topic: true,
|
||||
command_topic_postfix: firstExposeTyped.property,
|
||||
...LIST_DISCOVERY_LOOKUP[firstExposeTyped.name],
|
||||
@@ -1242,7 +1314,7 @@ export class HomeAssistant extends Extension {
|
||||
object_id: firstExposeTyped.property,
|
||||
mockProperties: [{property: firstExposeTyped.property, value: null}],
|
||||
discovery_payload: {
|
||||
name: endpoint ? `${firstExposeTyped.label} ${endpoint}` : firstExposeTyped.label,
|
||||
name: endpointName ? `${firstExposeTyped.label} ${endpointName}` : firstExposeTyped.label,
|
||||
// Truncate text if it's too long
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/23199
|
||||
value_template: `{{ value_json.${firstExposeTyped.property} | default('',True) | string | truncate(254, True, '', 0) }}`,
|
||||
@@ -1279,6 +1351,8 @@ export class HomeAssistant extends Extension {
|
||||
if (entry.discovery_payload.device_class) {
|
||||
delete entry.discovery_payload.name;
|
||||
}
|
||||
|
||||
entry.endpoint = entity.isDevice() ? entity.endpoint(endpointName) : undefined;
|
||||
}
|
||||
|
||||
return discoveryEntries;
|
||||
@@ -1404,7 +1478,7 @@ export class HomeAssistant extends Extension {
|
||||
if (isDevice) {
|
||||
const exposes = entity.exposes(); // avoid calling it hundred of times/s
|
||||
for (const expose of exposes) {
|
||||
configs.push(...this.exposeToConfig([expose], "device", exposes, entity.definition));
|
||||
configs.push(...this.exposeToConfig([expose], entity, exposes));
|
||||
}
|
||||
} else if (isGroup) {
|
||||
// group
|
||||
@@ -1433,7 +1507,7 @@ export class HomeAssistant extends Extension {
|
||||
}
|
||||
|
||||
configs = ([] as DiscoveryEntry[]).concat(
|
||||
...Object.values(exposesByType).map((exposes) => this.exposeToConfig(exposes, "group", allExposes)),
|
||||
...Object.values(exposesByType).map((exposes) => this.exposeToConfig(exposes, entity, allExposes)),
|
||||
);
|
||||
} else {
|
||||
// Discover bridge config.
|
||||
@@ -1586,7 +1660,33 @@ export class HomeAssistant extends Extension {
|
||||
payload.default_entity_id = `${config.type}.${payload.object_id}`;
|
||||
|
||||
// Set unique_id
|
||||
payload.unique_id = `${entity.options.ID}_${config.object_id}_${settings.get().mqtt.base_topic}`;
|
||||
const uniqueId = `${entity.options.ID}_${config.object_id}_${settings.get().mqtt.base_topic}`;
|
||||
payload.unique_id = uniqueId;
|
||||
|
||||
if (config.endpoint) {
|
||||
assert(entity.isDevice());
|
||||
const memberKey = `${config.endpoint.deviceIeeeAddress}_${config.endpoint.ID}_${config.type}`;
|
||||
this.groupMemberLookup.set(memberKey, uniqueId);
|
||||
}
|
||||
|
||||
// Add group member unique_ids for Home Assistant entity grouping
|
||||
// This assumes that `discover()` already has been called for all devices, since it depends on the
|
||||
// `groupMemberLookup` being populated.
|
||||
// https://www.home-assistant.io/integrations/mqtt#grouping-entities
|
||||
if (isGroup) {
|
||||
const memberIds: string[] = [];
|
||||
for (const endpoint of entity.zh.members) {
|
||||
const memberKey = `${endpoint.deviceIeeeAddress}_${endpoint.ID}_${config.type}`;
|
||||
const memberUniqueId = this.groupMemberLookup.get(memberKey);
|
||||
if (memberUniqueId) {
|
||||
memberIds.push(memberUniqueId);
|
||||
}
|
||||
}
|
||||
|
||||
if (memberIds.length > 0) {
|
||||
payload.group = memberIds;
|
||||
}
|
||||
}
|
||||
|
||||
// Attributes for device registry and origin
|
||||
payload.device = devicePayload;
|
||||
@@ -1797,11 +1897,11 @@ export class HomeAssistant extends Extension {
|
||||
try {
|
||||
message = JSON.parse(data.message);
|
||||
const baseTopic = `${settings.get().mqtt.base_topic}/`;
|
||||
if (isDeviceAutomation && (!message.topic || !message.topic.startsWith(baseTopic))) {
|
||||
if (isDeviceAutomation && !message.topic?.startsWith(baseTopic)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isDeviceAutomation && (!message.availability || !message.availability[0].topic.startsWith(baseTopic))) {
|
||||
if (!isDeviceAutomation && !message.availability?.[0].topic.startsWith(baseTopic)) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -61,8 +61,11 @@ export default class OnEvent extends Extension {
|
||||
override async stop(): Promise<void> {
|
||||
await super.stop();
|
||||
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
await this.callOnEvent(device, {type: "stop", data: {ieeeAddr: device.ieeeAddr}});
|
||||
// ensure properly started, else this throws undesired errors (e.g. SIGINT during startup)
|
||||
if (this.zigbee.zhController !== undefined) {
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
await this.callOnEvent(device, {type: "stop", data: {ieeeAddr: device.ieeeAddr}});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -284,11 +284,11 @@ export default class OTAUpdate extends Extension {
|
||||
|
||||
if (payload.url) {
|
||||
source.url = payload.url;
|
||||
} else if (!device.definition || !device.definition.ota) {
|
||||
} else if (!device.definition?.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
break;
|
||||
}
|
||||
} else if (!device.definition || !device.definition.ota) {
|
||||
} else if (!device.definition?.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
break;
|
||||
}
|
||||
@@ -344,7 +344,7 @@ export default class OTAUpdate extends Extension {
|
||||
source.url = writeFirmwareHexToDataDir(payload.hex.data, payload.hex.file_name, device.ieeeAddr);
|
||||
} else if (payload.url) {
|
||||
source.url = payload.url;
|
||||
} else if (!device.definition || !device.definition.ota) {
|
||||
} else if (!device.definition?.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
break;
|
||||
}
|
||||
@@ -360,7 +360,7 @@ export default class OTAUpdate extends Extension {
|
||||
if (payload.default_maximum_data_size) {
|
||||
dataSettings.baseSize = payload.default_maximum_data_size;
|
||||
}
|
||||
} else if (!device.definition || !device.definition.ota) {
|
||||
} else if (!device.definition?.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
break;
|
||||
}
|
||||
@@ -415,11 +415,11 @@ export default class OTAUpdate extends Extension {
|
||||
source.url = writeFirmwareHexToDataDir(payload.hex.data, payload.hex.file_name, device.ieeeAddr);
|
||||
} else if (payload.url) {
|
||||
source.url = payload.url;
|
||||
} else if (!device.definition || !device.definition.ota) {
|
||||
} else if (!device.definition?.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
break;
|
||||
}
|
||||
} else if (!device.definition || !device.definition.ota) {
|
||||
} else if (!device.definition?.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
break;
|
||||
}
|
||||
|
||||
+6
-1
@@ -152,7 +152,11 @@ export default class Mqtt {
|
||||
|
||||
const stateData: Zigbee2MQTTAPI["bridge/state"] = {state: "offline"};
|
||||
|
||||
await this.publish("bridge/state", JSON.stringify(stateData), {clientOptions: {retain: true}});
|
||||
// prevent undesirable error when receiving SIGTERM/SIGINT during startup
|
||||
if (this.client) {
|
||||
await this.publish("bridge/state", JSON.stringify(stateData), {clientOptions: {retain: true}});
|
||||
}
|
||||
|
||||
this.eventBus.removeListeners(this);
|
||||
logger.info("Disconnecting from MQTT server");
|
||||
await this.client?.endAsync();
|
||||
@@ -194,6 +198,7 @@ export default class Mqtt {
|
||||
}
|
||||
|
||||
async publish(topic: string, payload: string, options: Partial<MqttPublishOptions> = {}): Promise<void> {
|
||||
// TODO: add `options.validateTopic: boolean` to bypass these checks when topic is "controlled"
|
||||
if (topic.includes("+") || topic.includes("#")) {
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/26939#issuecomment-2772309646
|
||||
logger.error(`Topic '${topic}' includes wildcard characters, skipping publish.`);
|
||||
|
||||
+8
-5
@@ -51,11 +51,14 @@ class State {
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
// Remove any invalid states (ie when the device has left the network) when the system is stopped
|
||||
for (const [key] of this.state) {
|
||||
if (typeof key === "string" && key.startsWith("0x") && !this.zigbee.resolveEntity(key)) {
|
||||
// string key = ieeeAddr
|
||||
this.state.delete(key);
|
||||
// ensure properly started, else this throws undesired errors (e.g. SIGINT during startup)
|
||||
if (this.zigbee.zhController !== undefined) {
|
||||
// Remove any invalid states (ie when the device has left the network) when the system is stopped
|
||||
for (const [key] of this.state) {
|
||||
if (typeof key === "string" && key.startsWith("0x") && !this.zigbee.resolveEntity(key)) {
|
||||
// string key = ieeeAddr
|
||||
this.state.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+30
-2
@@ -1,7 +1,7 @@
|
||||
import type * as zigbeeHerdsman from "zigbee-herdsman/dist";
|
||||
import type {ZclPayload} from "zigbee-herdsman/dist/adapter/events";
|
||||
import type {Eui64} from "zigbee-herdsman/dist/zspec/tstypes";
|
||||
import type {ClusterDefinition, ClusterName, CustomClusters} from "zigbee-herdsman/dist/zspec/zcl/definition/tstype";
|
||||
import type {Cluster, ClusterName, CustomClusters} from "zigbee-herdsman/dist/zspec/zcl/definition/tstype";
|
||||
import type {GenericZdoResponse, RoutingTableEntry} from "zigbee-herdsman/dist/zspec/zdo/definition/tstypes";
|
||||
import type * as zigbeeHerdsmanConverter from "zigbee-herdsman-converters";
|
||||
import type {Base} from "zigbee-herdsman-converters/lib/exposes";
|
||||
@@ -32,6 +32,33 @@ import type {LogLevel, schemaJson} from "../util/settings";
|
||||
// biome-ignore lint/suspicious/noExplicitAny: API
|
||||
type KeyValue = Record<string, any>;
|
||||
|
||||
export interface OnboardInitData {
|
||||
page: "form";
|
||||
settings: Zigbee2MQTTSettings;
|
||||
settingsSchema: typeof schemaJson;
|
||||
devices: {
|
||||
name: string;
|
||||
path: Zigbee2MQTTSettings["serial"]["port"];
|
||||
adapter?: Zigbee2MQTTSettings["serial"]["adapter"];
|
||||
baudRate?: Zigbee2MQTTSettings["serial"]["baudrate"];
|
||||
rtscts?: Zigbee2MQTTSettings["serial"]["rtscts"];
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface OnboardDoneData {
|
||||
page: "done";
|
||||
frontendUrl: string | null;
|
||||
}
|
||||
|
||||
export interface OnboardFailureData {
|
||||
page: "failure";
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export type OnboardData = OnboardInitData | OnboardDoneData | OnboardFailureData;
|
||||
|
||||
export type OnboardSubmitResponse = {success: true; frontendUrl: string | null} | {success: false; error: string};
|
||||
|
||||
export interface Zigbee2MQTTDeviceOptions {
|
||||
disabled?: boolean;
|
||||
retention?: number;
|
||||
@@ -232,6 +259,7 @@ export interface Zigbee2MQTTDeviceDefinition {
|
||||
exposes: zigbeeHerdsmanConverter.Expose[];
|
||||
supports_ota: boolean;
|
||||
options: zigbeeHerdsmanConverter.Option[];
|
||||
version: `0.0.${number}`;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
@@ -316,7 +344,7 @@ export interface Zigbee2MQTTAPI {
|
||||
};
|
||||
|
||||
"bridge/definitions": {
|
||||
clusters: Readonly<Record<ClusterName, Readonly<ClusterDefinition>>>;
|
||||
clusters: Readonly<Record<ClusterName, Cluster>>;
|
||||
custom_clusters: Record<string, CustomClusters>;
|
||||
actions: string[];
|
||||
};
|
||||
|
||||
+1
-5
@@ -142,11 +142,7 @@ class Logger {
|
||||
}
|
||||
|
||||
public getDebugNamespaceIgnore(): string {
|
||||
return (
|
||||
this.debugNamespaceIgnoreRegex
|
||||
?.toString()
|
||||
.slice(1, -1) /* remove slashes */ ?? ""
|
||||
);
|
||||
return this.debugNamespaceIgnoreRegex?.toString().slice(1, -1) /* remove slashes */ ?? "";
|
||||
}
|
||||
|
||||
public setDebugNamespaceIgnore(value: string): void {
|
||||
|
||||
+187
-459
@@ -1,398 +1,98 @@
|
||||
import {existsSync, mkdirSync} from "node:fs";
|
||||
import {existsSync, mkdirSync, writeFileSync} from "node:fs";
|
||||
import type {ServerResponse} from "node:http";
|
||||
import {createServer} from "node:http";
|
||||
import {parse} from "node:querystring";
|
||||
import path from "node:path";
|
||||
import expressStaticGzip from "express-static-gzip";
|
||||
import finalhandler from "finalhandler";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import JSZip from "jszip";
|
||||
import {findAllDevices} from "zigbee-herdsman/dist/adapter/adapterDiscovery";
|
||||
import type {OnboardData, OnboardFailureData, OnboardSubmitResponse, Zigbee2MQTTSettings} from "../types/api";
|
||||
import data from "./data";
|
||||
import * as settings from "./settings";
|
||||
import {YAMLFileException} from "./yaml";
|
||||
|
||||
type OnboardSettings = {
|
||||
mqtt_base_topic?: string;
|
||||
mqtt_server?: string;
|
||||
mqtt_user?: string;
|
||||
mqtt_password?: string;
|
||||
serial_port?: string;
|
||||
serial_adapter?: Settings["serial"]["adapter"];
|
||||
serial_baudrate?: string;
|
||||
serial_rtscts?: "on";
|
||||
network_channel?: string;
|
||||
network_key?: string;
|
||||
network_pan_id?: string;
|
||||
network_ext_pan_id?: string;
|
||||
frontend_enabled?: "on";
|
||||
frontend_port?: string;
|
||||
homeassistant_enabled?: "on";
|
||||
log_level?: Settings["advanced"]["log_level"];
|
||||
/** same as extension/frontend */
|
||||
const FILE_SERVER_OPTIONS: expressStaticGzip.ExpressStaticGzipOptions = {
|
||||
enableBrotli: true,
|
||||
serveStatic: {
|
||||
/* v8 ignore start */
|
||||
setHeaders: (res: ServerResponse, path: string): void => {
|
||||
if (path.endsWith("index.html")) {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
}
|
||||
},
|
||||
/* v8 ignore stop */
|
||||
},
|
||||
};
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/[^0-9A-Za-z \-_.]/g, (c) => `&#${c.charCodeAt(0)};`);
|
||||
}
|
||||
|
||||
function generateHtmlDone(frontendUrl: string | undefined): string {
|
||||
return `
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Zigbee2MQTT Onboarding</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Zigbee2MQTT Onboarding</h1>
|
||||
<p>Settings saved.</p>
|
||||
<p>Zigbee2MQTT is now starting...</p>
|
||||
<small>${frontendUrl ? `Redirecting to Zigbee2MQTT frontend at <a href="${frontendUrl}">${frontendUrl}</a> in 30 seconds.` : "You can close this page."}</small>
|
||||
</main>
|
||||
${frontendUrl ? `<script>setTimeout(() => { window.location.replace("${frontendUrl}"); }, 30000);</script>` : ""}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
function generateHtmlForm(currentSettings: RecursivePartial<Settings>, devices: Awaited<ReturnType<typeof findAllDevices>>): string {
|
||||
let devicesSelect = "";
|
||||
|
||||
if (devices.length > 0) {
|
||||
devicesSelect += '<select id="found_device" onchange="setFoundDevice(this)">';
|
||||
devicesSelect += '<option value="">Select a device</option>';
|
||||
|
||||
for (const device of devices) {
|
||||
// just in case name has commas, remove them to not mess with `split` logic
|
||||
const deviceStr = `${device.name.replaceAll(",", "")}, ${device.path}, ${device.adapter ?? "unknown"}`;
|
||||
|
||||
devicesSelect += `<option value="${deviceStr}">${deviceStr}</option>`;
|
||||
}
|
||||
|
||||
devicesSelect += "</select>";
|
||||
devicesSelect += "<small>Optionally allows to configure coordinator port and type (if known) automatically.</small>";
|
||||
} else {
|
||||
devicesSelect = "<small>No device found</small>";
|
||||
}
|
||||
|
||||
let generateCheckbox = "";
|
||||
|
||||
if (
|
||||
Array.isArray(currentSettings.advanced?.network_key) ||
|
||||
typeof currentSettings.advanced?.pan_id === "number" ||
|
||||
Array.isArray(currentSettings.advanced?.ext_pan_id)
|
||||
) {
|
||||
generateCheckbox = `
|
||||
<label for="generate_network">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="generate_network"
|
||||
onclick="setGenerate(this)"
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_NETWORK_KEY || process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_PAN_ID || process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_EXT_PAN_ID ? "disabled" : ""}>
|
||||
Generate network?
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
/* v8 ignore start */
|
||||
return `
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Zigbee2MQTT Onboarding</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Zigbee2MQTT Onboarding</h1>
|
||||
<p>Set the base configuration to start Zigbee2MQTT.</p>
|
||||
<p>Optional fields will either be ignored or fallback to defaults if not set (see appropriate documentation page for more details).</p>
|
||||
<p>If a field is disabled, it means <a href="https://www.zigbee2mqtt.io/guide/configuration/#environment-variables" target="_blank">environment variables</a> are being used to override specific values (for example, through the Home Assistant add-on configuration page).</p>
|
||||
<hr>
|
||||
<form method="post">
|
||||
<fieldset ${process.env.ZIGBEE2MQTT_CONFIG_SERIAL || process.env.ZIGBEE2MQTT_CONFIG_SERIAL_PORT || process.env.ZIGBEE2MQTT_CONFIG_SERIAL_ADAPTER ? "disabled" : ""}>
|
||||
<label for="found_device">Found Devices</label>
|
||||
${devicesSelect}
|
||||
</fieldset>
|
||||
<fieldset ${process.env.ZIGBEE2MQTT_CONFIG_SERIAL ? "disabled" : ""}>
|
||||
<label for="serial_port">Coordinator/Adapter Port/Path</label>
|
||||
<input
|
||||
type="text"
|
||||
id="serial_port"
|
||||
name="serial_port"
|
||||
value="${currentSettings.serial?.port ?? ""}"
|
||||
required
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_SERIAL_PORT ? "disabled" : ""}>
|
||||
<label for="serial_adapter">Coordinator/Adapter Type/Stack/Driver</label>
|
||||
<select id="serial_adapter" name="serial_adapter" required ${process.env.ZIGBEE2MQTT_CONFIG_SERIAL_ADAPTER ? "disabled" : ""}>
|
||||
<option value="zstack" ${currentSettings.serial?.adapter === "zstack" ? "selected" : ""}>zstack</option>
|
||||
<option value="ember" ${currentSettings.serial?.adapter === "ember" ? "selected" : ""}>ember</option>
|
||||
<option value="deconz" ${currentSettings.serial?.adapter === "deconz" ? "selected" : ""}>deconz</option>
|
||||
<option value="zigate" ${currentSettings.serial?.adapter === "zigate" ? "selected" : ""}>zigate</option>
|
||||
<option value="zboss" ${currentSettings.serial?.adapter === "zboss" ? "selected" : ""}>zboss</option>
|
||||
</select>
|
||||
<label for="serial_baudrate">Coordinator/Adapter Baudrate</label>
|
||||
<select id="serial_baudrate" name="serial_baudrate" ${process.env.ZIGBEE2MQTT_CONFIG_SERIAL_BAUDRATE ? "disabled" : ""}>
|
||||
<option value="38400" ${currentSettings.serial?.baudrate === 38400 ? "selected" : ""}>38400</option>
|
||||
<option value="57600" ${currentSettings.serial?.baudrate === 57600 ? "selected" : ""}>57600</option>
|
||||
<option value="115200" ${!currentSettings.serial?.baudrate || currentSettings.serial?.baudrate === 115200 ? "selected" : ""}>115200</option>
|
||||
<option value="230400" ${currentSettings.serial?.baudrate === 230400 ? "selected" : ""}>230400</option>
|
||||
<option value="460800" ${currentSettings.serial?.baudrate === 460800 ? "selected" : ""}>460800</option>
|
||||
<option value="921600" ${currentSettings.serial?.baudrate === 921600 ? "selected" : ""}>921600</option>
|
||||
</select>
|
||||
<small>Can be ignored for networked coordinators (TCP).</small>
|
||||
<label for="serial_rtscts">Coordinator/Adapter Hardware Flow Control ("rtscts: true")</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="serial_rtscts"
|
||||
name="serial_rtscts"
|
||||
${currentSettings.serial?.rtscts ? "checked" : ""}
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_SERIAL_RTSCTS ? "disabled" : ""}
|
||||
style="margin-bottom: 1rem;">
|
||||
<small>Can be ignored for networked coordinators (TCP).</small>
|
||||
</fieldset>
|
||||
<small>
|
||||
<a href="https://www.zigbee2mqtt.io/guide/configuration/adapter-settings.html" target="_blank">https://www.zigbee2mqtt.io/guide/configuration/adapter-settings.html</a>
|
||||
</small>
|
||||
<hr>
|
||||
<fieldset ${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED ? "disabled" : ""}>
|
||||
<label for="closest_wifi_channel">Closest WiFi Channel</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="14"
|
||||
id="closest_wifi_channel"
|
||||
value="0"
|
||||
onclick="setBestZigbeeChannel(this)"
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_CHANNEL ? "disabled" : ""}>
|
||||
<small>Optionally set to your closest WiFi channel to pick the best value for "Network channel" below.</small>
|
||||
<label for="network_channel">Network Channel</label>
|
||||
<input
|
||||
type="number"
|
||||
min="11"
|
||||
max="26"
|
||||
id="network_channel"
|
||||
name="network_channel"
|
||||
value="${currentSettings.advanced?.channel ?? "25"}"
|
||||
required
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_CHANNEL ? "disabled" : ""}>
|
||||
</fieldset>
|
||||
<fieldset ${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED ? "disabled" : ""}>
|
||||
${generateCheckbox}
|
||||
<label for="network_key">Network Key</label>
|
||||
<input
|
||||
type="text"
|
||||
id="network_key"
|
||||
name="network_key"
|
||||
value="${currentSettings.advanced?.network_key ?? "GENERATE"}"
|
||||
pattern="^([0-9]+(,[0-9]+){15})|GENERATE$"
|
||||
required
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_NETWORK_KEY ? "disabled" : ""}>
|
||||
<label for="network_pan_id">Network PAN ID</label>
|
||||
<input
|
||||
type="text"
|
||||
id="network_pan_id"
|
||||
name="network_pan_id"
|
||||
value="${currentSettings.advanced?.pan_id ?? "GENERATE"}"
|
||||
pattern="^([0-9]{1,5})|GENERATE$"
|
||||
required
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_PAN_ID ? "disabled" : ""}>
|
||||
<label for="network_ext_pan_id">Network Extended PAN ID</label>
|
||||
<input
|
||||
type="text"
|
||||
id="network_ext_pan_id"
|
||||
name="network_ext_pan_id"
|
||||
value="${currentSettings.advanced?.ext_pan_id ?? "GENERATE"}"
|
||||
pattern="^([0-9]+(,[0-9]+){7})|GENERATE$"
|
||||
required
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_EXT_PAN_ID ? "disabled" : ""}>
|
||||
</fieldset>
|
||||
<small>
|
||||
<a href="https://www.zigbee2mqtt.io/guide/configuration/zigbee-network.html" target="_blank">https://www.zigbee2mqtt.io/guide/configuration/zigbee-network.html</a>
|
||||
</small>
|
||||
<hr>
|
||||
<fieldset ${process.env.ZIGBEE2MQTT_CONFIG_MQTT ? "disabled" : ""}>
|
||||
<label for="mqtt_base_topic">MQTT Base Topic</label>
|
||||
<input
|
||||
type="text"
|
||||
id="mqtt_base_topic"
|
||||
name="mqtt_base_topic"
|
||||
value="${currentSettings.mqtt?.base_topic ?? "zigbee2mqtt"}"
|
||||
required
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_MQTT_BASE_TOPIC ? "disabled" : ""}>
|
||||
<label for="mqtt_server">MQTT Server</label>
|
||||
<input
|
||||
type="text"
|
||||
id="mqtt_server"
|
||||
name="mqtt_server"
|
||||
value="${currentSettings.mqtt?.server ?? "mqtt://localhost:1883"}"
|
||||
required
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_MQTT_SERVER ? "disabled" : ""}>
|
||||
<label for="mqtt_user">MQTT User</label>
|
||||
<input
|
||||
type="text"
|
||||
id="mqtt_user"
|
||||
name="mqtt_user"
|
||||
value="${currentSettings.mqtt?.user ?? ""}"
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_MQTT_USER ? "disabled" : ""}>
|
||||
<small>Optional. Set only if using authentication.</small>
|
||||
<label for="mqtt_password">MQTT Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="mqtt_password"
|
||||
name="mqtt_password"
|
||||
value="${currentSettings.mqtt?.password ?? ""}"
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_MQTT_PASSWORD ? "disabled" : ""}>
|
||||
<small>Optional. Set only if using authentication.</small>
|
||||
</fieldset>
|
||||
<small>
|
||||
<a href="https://www.zigbee2mqtt.io/guide/configuration/mqtt.html" target="_blank">https://www.zigbee2mqtt.io/guide/configuration/mqtt.html</a>
|
||||
</small>
|
||||
<hr>
|
||||
<fieldset ${process.env.ZIGBEE2MQTT_CONFIG_FRONTEND ? "disabled" : ""}>
|
||||
<label for="frontend_enabled">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="frontend_enabled"
|
||||
name="frontend_enabled"
|
||||
${currentSettings.frontend?.enabled ? "checked" : ""}
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_FRONTEND_ENABLED ? "disabled" : ""}>
|
||||
Frontend enabled?
|
||||
</label>
|
||||
<label for="frontend_port">Frontend Port</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="65535"
|
||||
id="frontend_port"
|
||||
name="frontend_port"
|
||||
value="${currentSettings.frontend?.port ?? "8080"}"
|
||||
required
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_FRONTEND_PORT ? "disabled" : ""}>
|
||||
</fieldset>
|
||||
<small>
|
||||
<a href="https://www.zigbee2mqtt.io/guide/configuration/frontend.html" target="_blank">https://www.zigbee2mqtt.io/guide/configuration/frontend.html</a>
|
||||
</small>
|
||||
<fieldset ${process.env.ZIGBEE2MQTT_CONFIG_HOMEASSISTANT ? "disabled" : ""}>
|
||||
<label for="homeassistant_enabled">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="homeassistant_enabled"
|
||||
name="homeassistant_enabled"
|
||||
${currentSettings.homeassistant?.enabled ? "checked" : ""}
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_HOMEASSISTANT_ENABLED ? "disabled" : ""}>
|
||||
Home Assistant enabled?
|
||||
</label>
|
||||
</fieldset>
|
||||
<small>
|
||||
<a href="https://www.zigbee2mqtt.io/guide/configuration/homeassistant.html" target="_blank">https://www.zigbee2mqtt.io/guide/configuration/homeassistant.html</a>
|
||||
</small>
|
||||
<hr>
|
||||
<fieldset ${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED ? "disabled" : ""}>
|
||||
<label for="log_level">Log Level</label>
|
||||
<select id="log_level" name="log_level" ${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_LOG_LEVEL ? "disabled" : ""}>
|
||||
<option value="error" ${currentSettings.advanced?.log_level === "error" ? "selected" : ""}>error</option>
|
||||
<option value="warning" ${currentSettings.advanced?.log_level === "warning" ? "selected" : ""}>warning</option>
|
||||
<option value="info" ${!currentSettings.advanced?.log_level || currentSettings.advanced?.log_level === "info" ? "selected" : ""}>info</option>
|
||||
<option value="debug" ${currentSettings.advanced?.log_level === "debug" ? "selected" : ""}>debug</option>
|
||||
</select>
|
||||
</fieldset>
|
||||
<small>
|
||||
<a href="https://www.zigbee2mqtt.io/guide/configuration/logging.html" target="_blank">https://www.zigbee2mqtt.io/guide/configuration/logging.html</a>
|
||||
</small>
|
||||
<hr>
|
||||
<input type="submit" value="Submit">
|
||||
</form>
|
||||
</main>
|
||||
<script>
|
||||
function setFoundDevice(e) {
|
||||
if (!e.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [, path, adapter] = e.value.split(", ");
|
||||
const serialPortEl = document.querySelector("#serial_port");
|
||||
serialPortEl.value = path;
|
||||
const serialAdapterEl = document.querySelector("#serial_adapter");
|
||||
|
||||
if (['zstack', 'ember', 'deconz', 'zigate', 'zboss'].includes(adapter)) {
|
||||
serialAdapterEl.value = adapter;
|
||||
} else {
|
||||
serialAdapterEl.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function setBestZigbeeChannel(e) {
|
||||
const wifiChannel = parseInt(e.value, 10);
|
||||
const networkChannelEl = document.querySelector("#network_channel");
|
||||
|
||||
if (wifiChannel >= 11) {
|
||||
// WiFi 11-14
|
||||
networkChannelEl.value = 15;
|
||||
} else if (wifiChannel >= 6) {
|
||||
// WiFi 6-10
|
||||
networkChannelEl.value = 11;
|
||||
} else {
|
||||
// WiFi 1-5
|
||||
networkChannelEl.value = 25;
|
||||
}
|
||||
}
|
||||
|
||||
function setGenerate(e) {
|
||||
document.querySelector("#network_key").value = e.checked ? "GENERATE" : "${currentSettings.advanced?.network_key ?? "GENERATE"}";
|
||||
document.querySelector("#network_pan_id").value = e.checked ? "GENERATE" : "${currentSettings.advanced?.pan_id ?? "GENERATE"}";
|
||||
document.querySelector("#network_ext_pan_id").value = e.checked ? "GENERATE" : "${currentSettings.advanced?.ext_pan_id ?? "GENERATE"}";
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
|
||||
function generateHtmlError(errors: string): string {
|
||||
return `
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Zigbee2MQTT Onboarding</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Zigbee2MQTT configuration is not valid</h1>
|
||||
<p style="color: #F00;">Found the following errors:</p>
|
||||
${errors}
|
||||
<hr>
|
||||
<p>If you don't know how to solve this, read <a href="https://www.zigbee2mqtt.io/guide/configuration" target="_blank">https://www.zigbee2mqtt.io/guide/configuration</a></p>
|
||||
<form method="post" action="/">
|
||||
<input type="submit" value="Close">
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
function getServerUrl(): URL {
|
||||
return new URL(process.env.Z2M_ONBOARD_URL ?? "http://0.0.0.0:8080");
|
||||
}
|
||||
|
||||
function getZipEntryTargetPath(entryName: string): string {
|
||||
const normalizedEntry = entryName.replace(/\\/g, "/");
|
||||
|
||||
if (!normalizedEntry || normalizedEntry.startsWith("/") || normalizedEntry.includes("\0")) {
|
||||
throw new Error(`Invalid ZIP entry path '${entryName}'`);
|
||||
}
|
||||
|
||||
const basePath = path.resolve(data.getPath());
|
||||
const targetPath = path.resolve(basePath, normalizedEntry);
|
||||
const relativePath = path.relative(basePath, targetPath);
|
||||
|
||||
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
||||
throw new Error(`Unsafe ZIP entry path '${entryName}'`);
|
||||
}
|
||||
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
async function extractZipDataToDataPath(zipContent: Buffer): Promise<void> {
|
||||
const zip = await JSZip.loadAsync(zipContent);
|
||||
|
||||
for (const key in zip.files) {
|
||||
const entry = zip.files[key];
|
||||
const targetPath = getZipEntryTargetPath(entry.name);
|
||||
|
||||
if (entry.dir) {
|
||||
mkdirSync(targetPath, {recursive: true});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
mkdirSync(path.dirname(targetPath), {recursive: true});
|
||||
writeFileSync(targetPath, await entry.async("nodebuffer"));
|
||||
}
|
||||
}
|
||||
|
||||
async function startOnboardingServer(): Promise<boolean> {
|
||||
const currentSettings = settings.get();
|
||||
const serverUrl = getServerUrl();
|
||||
let server: ReturnType<typeof createServer> | undefined;
|
||||
let failed = false;
|
||||
const fileServer = expressStaticGzip((await import("zigbee2mqtt-windfront")).default.getOnboardingPath(), FILE_SERVER_OPTIONS);
|
||||
|
||||
const success = await new Promise<boolean>((resolve) => {
|
||||
server = createServer(async (req, res) => {
|
||||
const pathname = new URL(req.url /* v8 ignore next */ ?? "/", serverUrl).pathname;
|
||||
|
||||
if (req.method === "GET" && pathname === "/data") {
|
||||
const payload: OnboardData = {
|
||||
page: "form",
|
||||
settings: currentSettings,
|
||||
settingsSchema: settings.schemaJson,
|
||||
devices: await findAllDevices(),
|
||||
};
|
||||
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.writeHead(200);
|
||||
res.end(stringify(payload));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST") {
|
||||
if (failed) {
|
||||
res.end(() => {
|
||||
resolve(false);
|
||||
});
|
||||
} else {
|
||||
if (pathname === "/submit") {
|
||||
let body = "";
|
||||
|
||||
req.on("data", (chunk) => {
|
||||
@@ -400,92 +100,108 @@ async function startOnboardingServer(): Promise<boolean> {
|
||||
});
|
||||
|
||||
req.on("end", () => {
|
||||
const result = parse(body) as unknown as OnboardSettings;
|
||||
const frontendEnabled = result.frontend_enabled === "on";
|
||||
const updatedSettings: RecursivePartial<Settings> = {
|
||||
mqtt: {
|
||||
base_topic: result.mqtt_base_topic,
|
||||
server: result.mqtt_server,
|
||||
user: result.mqtt_user || undefined, // empty string => removed
|
||||
password: result.mqtt_password || undefined, // empty string => removed
|
||||
},
|
||||
serial: {
|
||||
port: result.serial_port,
|
||||
adapter: result.serial_adapter,
|
||||
baudrate: result.serial_baudrate ? Number.parseInt(result.serial_baudrate, 10) : undefined,
|
||||
rtscts: result.serial_rtscts === "on",
|
||||
},
|
||||
advanced: {
|
||||
log_level: result.log_level,
|
||||
channel: result.network_channel ? Number.parseInt(result.network_channel, 10) : undefined,
|
||||
network_key: result.network_key
|
||||
? result.network_key === "GENERATE"
|
||||
? result.network_key
|
||||
: result.network_key.split(",").map((v) => Number.parseInt(v, 10))
|
||||
: undefined,
|
||||
pan_id: result.network_pan_id
|
||||
? result.network_pan_id === "GENERATE"
|
||||
? result.network_pan_id
|
||||
: Number.parseInt(result.network_pan_id, 10)
|
||||
: undefined,
|
||||
ext_pan_id: result.network_ext_pan_id
|
||||
? result.network_ext_pan_id === "GENERATE"
|
||||
? result.network_ext_pan_id
|
||||
: result.network_ext_pan_id.split(",").map((v) => Number.parseInt(v, 10))
|
||||
: undefined,
|
||||
},
|
||||
frontend: {
|
||||
enabled: frontendEnabled,
|
||||
port: result.frontend_port ? Number.parseInt(result.frontend_port, 10) : undefined,
|
||||
},
|
||||
homeassistant: {
|
||||
enabled: result.homeassistant_enabled === "on",
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
settings.apply(updatedSettings);
|
||||
const result = (body ? JSON.parse(body) : {}) as RecursivePartial<Zigbee2MQTTSettings>;
|
||||
|
||||
// to redirect, make sure frontend "will be" enabled, and host isn't socket
|
||||
settings.apply(result);
|
||||
|
||||
const appliedSettings = settings.get();
|
||||
const redirect =
|
||||
!process.env.Z2M_ONBOARD_NO_REDIRECT &&
|
||||
frontendEnabled &&
|
||||
(!currentSettings.frontend?.host || !currentSettings.frontend.host.startsWith("/"));
|
||||
const protocol = currentSettings.frontend?.ssl_cert && currentSettings.frontend.ssl_key ? "https" : "http";
|
||||
appliedSettings.frontend.enabled &&
|
||||
!appliedSettings.frontend.host?.startsWith("/");
|
||||
const protocol = appliedSettings.frontend.ssl_cert && appliedSettings.frontend.ssl_key ? "https" : "http";
|
||||
const frontendUrl = redirect
|
||||
? `${protocol}://${appliedSettings.frontend.host ?? "localhost"}:${appliedSettings.frontend.port}${appliedSettings.frontend.base_url}`
|
||||
: null;
|
||||
const payload: OnboardSubmitResponse = {success: true, frontendUrl};
|
||||
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.writeHead(200);
|
||||
res.end(
|
||||
generateHtmlDone(
|
||||
redirect
|
||||
? /* v8 ignore next */ `${protocol}://${currentSettings.frontend?.host ?? "localhost"}:${currentSettings.frontend?.port ?? "8080"}${currentSettings.frontend?.base_url ?? "/"}`
|
||||
: undefined,
|
||||
),
|
||||
() => {
|
||||
resolve(true);
|
||||
},
|
||||
);
|
||||
res.end(stringify(payload), () => {
|
||||
resolve(true);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to apply configuration: ${(error as Error).message}`);
|
||||
failed = true;
|
||||
|
||||
if (process.env.Z2M_ONBOARD_NO_FAILURE_PAGE) {
|
||||
res.end(() => {
|
||||
resolve(false);
|
||||
});
|
||||
} else {
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.writeHead(406);
|
||||
res.end(generateHtmlError(`<p>${escapeHtml((error as Error).message)}</p>`));
|
||||
}
|
||||
const payload: OnboardSubmitResponse = {success: false, error: (error as Error).message};
|
||||
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.writeHead(406);
|
||||
res.end(stringify(payload));
|
||||
}
|
||||
});
|
||||
|
||||
req.on("error", (error: Error) => {
|
||||
console.error(`Failed to parse request body: ${error.message}`);
|
||||
|
||||
const payload: OnboardSubmitResponse = {success: false, error: error.message};
|
||||
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.writeHead(406);
|
||||
res.end(stringify(payload));
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/submit-zip") {
|
||||
let body = "";
|
||||
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
|
||||
req.on("end", async () => {
|
||||
try {
|
||||
if (!body) {
|
||||
throw new Error("Invalid ZIP payload: missing content");
|
||||
}
|
||||
|
||||
const zipContent = Buffer.from(body, "base64");
|
||||
|
||||
await extractZipDataToDataPath(zipContent);
|
||||
|
||||
const payload: OnboardSubmitResponse = {success: true, frontendUrl: null};
|
||||
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.writeHead(200);
|
||||
res.end(stringify(payload), () => {
|
||||
resolve(true);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to apply ZIP data: ${(error as Error).message}`);
|
||||
|
||||
const payload: OnboardSubmitResponse = {success: false, error: (error as Error).message};
|
||||
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.writeHead(406);
|
||||
res.end(stringify(payload));
|
||||
}
|
||||
});
|
||||
|
||||
req.on("error", (error: Error) => {
|
||||
console.error(`Failed to parse ZIP request body: ${error.message}`);
|
||||
|
||||
const payload: OnboardSubmitResponse = {success: false, error: error.message};
|
||||
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.writeHead(406);
|
||||
res.end(stringify(payload));
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.writeHead(200);
|
||||
res.end(generateHtmlForm(currentSettings, await findAllDevices()));
|
||||
}
|
||||
|
||||
const next = finalhandler(req, res);
|
||||
|
||||
fileServer(req, res, next);
|
||||
});
|
||||
|
||||
server.on("error", (error: Error) => {
|
||||
console.error("Failed to start onboarding server", error);
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
server.listen(Number.parseInt(serverUrl.port, 10), serverUrl.hostname, () => {
|
||||
@@ -498,21 +214,37 @@ async function startOnboardingServer(): Promise<boolean> {
|
||||
return success;
|
||||
}
|
||||
|
||||
async function startFailureServer(errors: string): Promise<void> {
|
||||
async function startFailureServer(errors: string[]): Promise<void> {
|
||||
const serverUrl = getServerUrl();
|
||||
let server: ReturnType<typeof createServer> | undefined;
|
||||
const fileServer = expressStaticGzip((await import("zigbee2mqtt-windfront")).default.getOnboardingPath(), FILE_SERVER_OPTIONS);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server = createServer((req, res) => {
|
||||
if (req.method === "POST") {
|
||||
const pathname = new URL(req.url /* v8 ignore next */ ?? "/", serverUrl).pathname;
|
||||
|
||||
if (req.method === "GET" && pathname === "/data") {
|
||||
const payload: OnboardFailureData = {page: "failure", errors};
|
||||
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.writeHead(200);
|
||||
res.end(stringify(payload));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && pathname === "/submit") {
|
||||
res.writeHead(200);
|
||||
res.end(() => {
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.writeHead(406);
|
||||
res.end(generateHtmlError(errors));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const next = finalhandler(req, res);
|
||||
|
||||
fileServer(req, res, next);
|
||||
});
|
||||
|
||||
server.listen(Number.parseInt(serverUrl.port, 10), serverUrl.hostname, () => {
|
||||
@@ -524,23 +256,19 @@ async function startFailureServer(errors: string): Promise<void> {
|
||||
}
|
||||
|
||||
async function onSettingsErrors(errors: string[]): Promise<void> {
|
||||
let pErrors = "";
|
||||
|
||||
console.error("\n\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
|
||||
console.error(" READ THIS CAREFULLY\n");
|
||||
console.error("Refusing to start because configuration is not valid, found the following errors:");
|
||||
|
||||
for (const error of errors) {
|
||||
console.error(`- ${error}`);
|
||||
|
||||
pErrors += `<p>- ${escapeHtml(error)}</p>`;
|
||||
}
|
||||
|
||||
console.error("\nIf you don't know how to solve this, read https://www.zigbee2mqtt.io/guide/configuration");
|
||||
console.error("\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n\n");
|
||||
|
||||
if (!process.env.Z2M_ONBOARD_NO_SERVER && !process.env.Z2M_ONBOARD_NO_FAILURE_PAGE) {
|
||||
await startFailureServer(pErrors);
|
||||
await startFailureServer(errors);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
"title": "Pause on backoff greater than",
|
||||
"default": 0,
|
||||
"minimum": 0,
|
||||
"description": "Pause availability pings when backoff reaches over this limit until a new Zigbee message is received from the device. A value of zero disables pausing."
|
||||
"description": "Pause availability pings when the backoff multiplier reaches over this limit until a new Zigbee message is received from the device. A value of zero disables pausing."
|
||||
}
|
||||
},
|
||||
"required": ["timeout"]
|
||||
@@ -800,7 +800,7 @@
|
||||
"requiresRestart": true,
|
||||
"minimum": -128,
|
||||
"maximum": 127,
|
||||
"description": "Transmit power of adapter, only available for Z-Stack (CC253*/CC2652/CC1352) adapters, CC2652 = 5dbm, CC1352 max is = 20dbm (5dbm default)"
|
||||
"description": "Transmit power of adapter, in dBm (max is often 20, refer to chip specifications)"
|
||||
},
|
||||
"output": {
|
||||
"type": "string",
|
||||
|
||||
+53
-22
@@ -28,7 +28,7 @@ export default class Zigbee {
|
||||
return this.#herdsman;
|
||||
}
|
||||
|
||||
async start(): Promise<StartResult> {
|
||||
async start(abortSignal: AbortSignal): Promise<boolean> {
|
||||
const infoHerdsman = await utils.getDependencyVersion("zigbee-herdsman");
|
||||
logger.info(`Starting zigbee-herdsman (${infoHerdsman.version})`);
|
||||
const panId = settings.get().advanced.pan_id;
|
||||
@@ -67,14 +67,18 @@ export default class Zigbee {
|
||||
let startResult: StartResult;
|
||||
try {
|
||||
this.#herdsman = new Controller(herdsmanSettings);
|
||||
startResult = await this.#herdsman.start();
|
||||
startResult = await this.#herdsman.start(abortSignal);
|
||||
} catch (error) {
|
||||
logger.error("Error while starting zigbee-herdsman");
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.coordinatorIeeeAddr = this.#herdsman.getDevicesByType("Coordinator")[0].ieeeAddr;
|
||||
await this.resolveDevicesDefinitions();
|
||||
await this.resolveDevicesDefinitions(false, abortSignal);
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.#herdsman.on("adapterDisconnected", () => this.eventBus.emitAdapterDisconnected());
|
||||
this.#herdsman.on("lastSeenChanged", (data: ZHEvents.LastSeenChangedPayload) => {
|
||||
@@ -134,30 +138,53 @@ 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.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
// If a passlist is used, all other device will be removed from the network.
|
||||
const passlist = settings.get().passlist;
|
||||
const blocklist = settings.get().blocklist;
|
||||
const remove = async (device: Device): Promise<void> => {
|
||||
try {
|
||||
await device.zh.removeFromNetwork();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to remove '${device.ieeeAddr}' (${(error as Error).message})`);
|
||||
}
|
||||
};
|
||||
if (abortSignal.aborted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (passlist.length > 0) {
|
||||
const remove = async (device: Device): Promise<void> => {
|
||||
try {
|
||||
await device.zh.removeFromNetwork();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to remove '${device.ieeeAddr}' (${(error as Error).message})`);
|
||||
}
|
||||
};
|
||||
// If a passlist is used, all other device will be removed from the network.
|
||||
const passlist = settings.get().passlist;
|
||||
|
||||
if (passlist.length > 0) {
|
||||
for (const device of this.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
if (!passlist.includes(device.ieeeAddr)) {
|
||||
logger.warning(`Device not on passlist currently connected (${device.ieeeAddr}), removing...`);
|
||||
logger.warning(`Device not on passlist currently on the network (${device.ieeeAddr}), removing...`);
|
||||
await remove(device);
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const ieee of settings.get().blocklist) {
|
||||
const device = this.resolveDevice(ieee);
|
||||
|
||||
if (device) {
|
||||
if (device.zh.type === "Coordinator") {
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.warning(`Device on blocklist currently on the network (${device.ieeeAddr}), removing...`);
|
||||
await remove(device);
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
logger.debug(`Ignoring blocklist device ${ieee}, not currently on the network`);
|
||||
}
|
||||
} else if (blocklist.includes(device.ieeeAddr)) {
|
||||
logger.warning(`Device on blocklist currently connected (${device.ieeeAddr}), removing...`);
|
||||
await remove(device);
|
||||
}
|
||||
}
|
||||
|
||||
return startResult;
|
||||
return true;
|
||||
}
|
||||
|
||||
private logDeviceInterview(data: eventdata.DeviceInterview): void {
|
||||
@@ -224,7 +251,7 @@ export default class Zigbee {
|
||||
|
||||
async stop(): Promise<void> {
|
||||
logger.info("Stopping zigbee-herdsman...");
|
||||
await this.#herdsman.stop();
|
||||
await this.#herdsman?.stop(); // could be undefined if this is called during startup (abort)
|
||||
logger.info("Stopped zigbee-herdsman");
|
||||
}
|
||||
|
||||
@@ -246,9 +273,13 @@ export default class Zigbee {
|
||||
await this.#herdsman.permitJoin(time, device?.zh);
|
||||
}
|
||||
|
||||
async resolveDevicesDefinitions(ignoreCache = false): Promise<void> {
|
||||
async resolveDevicesDefinitions(ignoreCache = false, abortSignal: AbortSignal | undefined = undefined): Promise<void> {
|
||||
for (const device of this.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
await device.resolveDefinition(ignoreCache);
|
||||
|
||||
if (abortSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+13
-13
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "zigbee2mqtt",
|
||||
"version": "2.8.0",
|
||||
"version": "2.10.1",
|
||||
"description": "Zigbee to MQTT bridge using Zigbee-herdsman",
|
||||
"main": "index.js",
|
||||
"types": "dist/types/api.d.ts",
|
||||
@@ -42,44 +42,44 @@
|
||||
},
|
||||
"homepage": "https://koenkk.github.io/zigbee2mqtt",
|
||||
"dependencies": {
|
||||
"ajv": "^8.17.1",
|
||||
"ajv": "^8.20.0",
|
||||
"bind-decorator": "^1.0.11",
|
||||
"debounce": "^3.0.0",
|
||||
"express-static-gzip": "^3.0.0",
|
||||
"express-static-gzip": "^3.0.1",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"finalhandler": "^2.1.1",
|
||||
"humanize-duration": "^3.33.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||
"jszip": "^3.10.1",
|
||||
"mqtt": "^5.14.1",
|
||||
"mqtt": "^5.15.1",
|
||||
"object-assign-deep": "^0.4.0",
|
||||
"rimraf": "^6.1.2",
|
||||
"semver": "^7.7.3",
|
||||
"rimraf": "^6.1.3",
|
||||
"semver": "^7.7.4",
|
||||
"source-map-support": "^0.5.21",
|
||||
"throttleit": "^2.1.0",
|
||||
"winston": "^3.19.0",
|
||||
"winston-syslog": "^2.7.1",
|
||||
"winston-transport": "^4.9.0",
|
||||
"ws": "^8.19.0",
|
||||
"zigbee-herdsman": "9.0.2",
|
||||
"zigbee-herdsman-converters": "25.120.0",
|
||||
"ws": "^8.20.0",
|
||||
"zigbee-herdsman": "10.0.7",
|
||||
"zigbee-herdsman-converters": "26.46.0",
|
||||
"zigbee2mqtt-frontend": "0.9.21",
|
||||
"zigbee2mqtt-windfront": "2.8.1"
|
||||
"zigbee2mqtt-windfront": "2.11.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.13",
|
||||
"@biomejs/biome": "^2.4.14",
|
||||
"@types/finalhandler": "^1.2.3",
|
||||
"@types/humanize-duration": "^3.27.4",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^24.10.9",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/object-assign-deep": "^0.4.3",
|
||||
"@types/readable-stream": "4.0.23",
|
||||
"@types/serve-static": "^2.2.0",
|
||||
"@types/ws": "8.18.1",
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
"tmp": "^0.2.5",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^3.1.1"
|
||||
},
|
||||
"pnpm": {
|
||||
|
||||
Generated
+230
-219
File diff suppressed because it is too large
Load Diff
@@ -18,5 +18,5 @@ const sandbox = {
|
||||
};
|
||||
vm.runInNewContext(moduleCode, sandbox, moduleFakePath);
|
||||
const converter = sandbox.module.exports;
|
||||
assert(!converter.toZigbee || !converter.toZigbee.includes(undefined));
|
||||
assert(!converter.fromZigbee || !converter.fromZigbee.includes(undefined));
|
||||
assert(!converter.toZigbee?.includes(undefined));
|
||||
assert(!converter.fromZigbee?.includes(undefined));
|
||||
|
||||
+266
-3
@@ -25,6 +25,7 @@ import {Controller as ZHController} from "zigbee-herdsman";
|
||||
import {Controller} from "../lib/controller";
|
||||
import type Device from "../lib/model/device";
|
||||
import * as settings from "../lib/util/settings";
|
||||
import Frontend from "../lib/extension/frontend";
|
||||
|
||||
const LOG_MQTT_NS = "z2m:mqtt";
|
||||
|
||||
@@ -54,6 +55,7 @@ const mocksClear = [
|
||||
describe("Controller", () => {
|
||||
let controller: Controller;
|
||||
let mockExit: Mock;
|
||||
let stopAfter = true;
|
||||
|
||||
const getZ2MDevice = (zhDevice: string | number | ZhDevice): Device => {
|
||||
return controller.zigbee.resolveEntity(zhDevice)! as Device;
|
||||
@@ -64,6 +66,7 @@ describe("Controller", () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
stopAfter = true;
|
||||
returnDevices.splice(0);
|
||||
mockExit = vi.fn();
|
||||
data.writeDefaultConfiguration();
|
||||
@@ -79,7 +82,10 @@ describe("Controller", () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await controller?.stop();
|
||||
if (stopAfter) {
|
||||
await controller?.stop();
|
||||
}
|
||||
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
@@ -106,7 +112,7 @@ describe("Controller", () => {
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
"bulb (0x000b57fffec6a5b2): LED1545G12 - IKEA TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm (Router)",
|
||||
);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith("remote (0x0017880104e45517): 324131092621 - Philips Hue dimmer switch (EndDevice)");
|
||||
expect(mockLogger.info).toHaveBeenCalledWith("remote (0x0017880104e45517): 324131092621 - Philips Hue dimmer switch gen 1 (EndDevice)");
|
||||
expect(mockLogger.info).toHaveBeenCalledWith("0x0017880104e45518 (0x0017880104e45518): Not supported (EndDevice)");
|
||||
expect(mockMQTTConnectAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTConnectAsync).toHaveBeenCalledWith("mqtt://localhost", {
|
||||
@@ -323,11 +329,14 @@ describe("Controller", () => {
|
||||
});
|
||||
|
||||
it("Should remove device on blocklist on startup", async () => {
|
||||
settings.set(["blocklist"], [devices.bulb_color.ieeeAddr]);
|
||||
settings.set(["blocklist"], [devices.bulb_color.ieeeAddr, "0x9998889998889990", "0x00124b00120144ae"]);
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
expect(devices.bulb_color.removeFromNetwork).toHaveBeenCalledTimes(1);
|
||||
expect(devices.bulb.removeFromNetwork).toHaveBeenCalledTimes(0);
|
||||
expect(devices.coordinator.removeFromNetwork).toHaveBeenCalledTimes(0);
|
||||
const debugCalls = mockLogger.debug.mock.calls.map((c) => (typeof c[0] === "string" ? c[0] : c[0]()));
|
||||
expect(debugCalls.find((v) => v === "Ignoring blocklist device 0x9998889998889990, not currently on the network")).toBeDefined();
|
||||
});
|
||||
|
||||
it("Start controller fails", async () => {
|
||||
@@ -409,6 +418,252 @@ describe("Controller", () => {
|
||||
delete process.env.NOTIFY_SOCKET;
|
||||
});
|
||||
|
||||
describe("aborts during startup", () => {
|
||||
let stateStartSpy: MockInstance<() => void>;
|
||||
let zigbeeStartSpy: MockInstance<() => Promise<boolean>>;
|
||||
let mqttConnectSpy: MockInstance<() => Promise<void>>;
|
||||
let extensionStartSpy: MockInstance<() => Promise<void>>;
|
||||
let publishEntityStateSpy: MockInstance<() => Promise<void>>;
|
||||
let extensionStopSpy: MockInstance<() => Promise<void>>;
|
||||
let mqttDisconnectSpy: MockInstance<() => Promise<void>>;
|
||||
let mqttPublishSpy: MockInstance<() => Promise<void>>;
|
||||
|
||||
beforeEach(() => {
|
||||
stateStartSpy = vi.spyOn(controller.state, "start");
|
||||
zigbeeStartSpy = vi.spyOn(controller.zigbee, "start");
|
||||
mqttConnectSpy = vi.spyOn(controller.mqtt, "connect");
|
||||
const [firstExt] = controller.extensions;
|
||||
extensionStartSpy = vi.spyOn(firstExt, "start");
|
||||
publishEntityStateSpy = vi.spyOn(controller, "publishEntityState");
|
||||
extensionStopSpy = vi.spyOn(firstExt, "stop");
|
||||
mqttDisconnectSpy = vi.spyOn(controller.mqtt, "disconnect");
|
||||
mqttPublishSpy = vi.spyOn(controller.mqtt, "publish");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stopAfter = false;
|
||||
stateStartSpy.mockRestore();
|
||||
zigbeeStartSpy.mockRestore();
|
||||
mqttConnectSpy.mockRestore();
|
||||
extensionStartSpy.mockRestore();
|
||||
publishEntityStateSpy.mockRestore();
|
||||
extensionStopSpy.mockRestore();
|
||||
mqttDisconnectSpy.mockRestore();
|
||||
mqttPublishSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("during state start", async () => {
|
||||
stateStartSpy.mockImplementationOnce(async () => {
|
||||
await controller.stop(undefined, undefined, "SIGINT");
|
||||
});
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(stateStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeStartSpy).toHaveBeenCalledTimes(0);
|
||||
expect(mqttConnectSpy).toHaveBeenCalledTimes(0);
|
||||
expect(extensionStartSpy).toHaveBeenCalledTimes(0);
|
||||
expect(publishEntityStateSpy).toHaveBeenCalledTimes(0);
|
||||
expect(extensionStopSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttDisconnectSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttPublishSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("during zigbee resolve definitions", async () => {
|
||||
const resolveDevicesDefinitionsSpy = vi.spyOn(controller.zigbee, "resolveDevicesDefinitions");
|
||||
resolveDevicesDefinitionsSpy.mockImplementationOnce(async (ignoreCache, abortSignal) => {
|
||||
const device = controller.zigbee.resolveEntity(devices.bulb)! as Device;
|
||||
device.resolveDefinition = vi.fn(async () => {
|
||||
await controller.stop(undefined, undefined, "SIGINT");
|
||||
});
|
||||
|
||||
return await controller.zigbee.resolveDevicesDefinitions(ignoreCache, abortSignal);
|
||||
});
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(stateStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttConnectSpy).toHaveBeenCalledTimes(0);
|
||||
expect(extensionStartSpy).toHaveBeenCalledTimes(0);
|
||||
expect(publishEntityStateSpy).toHaveBeenCalledTimes(0);
|
||||
expect(extensionStopSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttDisconnectSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttPublishSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("during MQTT connect", async () => {
|
||||
mqttConnectSpy.mockImplementationOnce(async () => {
|
||||
await controller.stop(undefined, undefined, "SIGINT");
|
||||
});
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(stateStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttConnectSpy).toHaveBeenCalledTimes(1);
|
||||
expect(extensionStartSpy).toHaveBeenCalledTimes(0);
|
||||
expect(publishEntityStateSpy).toHaveBeenCalledTimes(0);
|
||||
expect(extensionStopSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttDisconnectSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttPublishSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("during extension start", async () => {
|
||||
extensionStartSpy.mockImplementationOnce(async () => {
|
||||
await controller.stop(undefined, undefined, "SIGINT");
|
||||
});
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(stateStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttConnectSpy).toHaveBeenCalledTimes(1);
|
||||
expect(extensionStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(publishEntityStateSpy).toHaveBeenCalledTimes(0);
|
||||
expect(extensionStopSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttDisconnectSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttPublishSpy).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("during entity state publish", async () => {
|
||||
publishEntityStateSpy.mockImplementationOnce(async () => {
|
||||
await controller.stop(undefined, undefined, "SIGINT");
|
||||
});
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(stateStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttConnectSpy).toHaveBeenCalledTimes(1);
|
||||
expect(extensionStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(publishEntityStateSpy).toHaveBeenCalledTimes(1);
|
||||
expect(extensionStopSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttDisconnectSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttPublishSpy).toHaveBeenCalledTimes(13);
|
||||
});
|
||||
|
||||
it("without signal during state start", async () => {
|
||||
stateStartSpy.mockImplementationOnce(async () => {
|
||||
await controller.stop();
|
||||
});
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(stateStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeStartSpy).toHaveBeenCalledTimes(0);
|
||||
expect(mqttConnectSpy).toHaveBeenCalledTimes(0);
|
||||
expect(extensionStartSpy).toHaveBeenCalledTimes(0);
|
||||
expect(publishEntityStateSpy).toHaveBeenCalledTimes(0);
|
||||
expect(extensionStopSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttDisconnectSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttPublishSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("without signal during MQTT connect", async () => {
|
||||
mqttConnectSpy.mockImplementationOnce(async () => {
|
||||
await controller.stop();
|
||||
});
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(stateStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttConnectSpy).toHaveBeenCalledTimes(1);
|
||||
expect(extensionStartSpy).toHaveBeenCalledTimes(0);
|
||||
expect(publishEntityStateSpy).toHaveBeenCalledTimes(0);
|
||||
expect(extensionStopSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttDisconnectSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttPublishSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("during ZH Controller start", async () => {
|
||||
mockZHController.start.mockImplementationOnce(async (abortSignal) => {
|
||||
void controller.stop(undefined, undefined, "SIGINT");
|
||||
abortSignal?.throwIfAborted();
|
||||
|
||||
return await Promise.resolve("resumed");
|
||||
});
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(stateStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttConnectSpy).toHaveBeenCalledTimes(0);
|
||||
expect(extensionStartSpy).toHaveBeenCalledTimes(0);
|
||||
expect(publishEntityStateSpy).toHaveBeenCalledTimes(0);
|
||||
expect(extensionStopSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttDisconnectSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttPublishSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("during ZH Controller getNetworkParameters", async () => {
|
||||
mockZHController.getNetworkParameters.mockImplementationOnce(async () => {
|
||||
await controller.stop(undefined, undefined, "SIGINT");
|
||||
|
||||
return await Promise.resolve({panID: 0x162a, extendedPanID: "0x64c5fd698daf0c00", channel: 15, nwkUpdateID: 0});
|
||||
});
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(stateStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttConnectSpy).toHaveBeenCalledTimes(0);
|
||||
expect(extensionStartSpy).toHaveBeenCalledTimes(0);
|
||||
expect(publishEntityStateSpy).toHaveBeenCalledTimes(0);
|
||||
expect(extensionStopSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttDisconnectSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttPublishSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("during ZH Controller passlist", async () => {
|
||||
settings.set(["passlist"], [devices.bulb_color.ieeeAddr]);
|
||||
devices.bulb.removeFromNetwork.mockImplementationOnce(async () => {
|
||||
await controller.stop(undefined, undefined, "SIGINT");
|
||||
});
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(stateStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttConnectSpy).toHaveBeenCalledTimes(0);
|
||||
expect(extensionStartSpy).toHaveBeenCalledTimes(0);
|
||||
expect(publishEntityStateSpy).toHaveBeenCalledTimes(0);
|
||||
expect(extensionStopSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttDisconnectSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttPublishSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("during ZH Controller blocklist", async () => {
|
||||
settings.set(["blocklist"], [devices.bulb_color.ieeeAddr]);
|
||||
devices.bulb_color.removeFromNetwork.mockImplementationOnce(async () => {
|
||||
await controller.stop(undefined, undefined, "SIGINT");
|
||||
});
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(stateStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeStartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttConnectSpy).toHaveBeenCalledTimes(0);
|
||||
expect(extensionStartSpy).toHaveBeenCalledTimes(0);
|
||||
expect(publishEntityStateSpy).toHaveBeenCalledTimes(0);
|
||||
expect(extensionStopSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttDisconnectSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mqttPublishSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("Start controller adapter disconnects", async () => {
|
||||
// Fail to stop extension exit code 1 should not override adapter disconnect exit code 2
|
||||
vi.spyOn(Array.from(controller.extensions)[0], "stop").mockRejectedValueOnce(new Error("failed"));
|
||||
@@ -1168,4 +1423,12 @@ describe("Controller", () => {
|
||||
await controller.enableDisableExtension(false, "Availability");
|
||||
}).rejects.toThrow("Built-in extension Availability cannot be disabled at runtime");
|
||||
});
|
||||
|
||||
it("throws error when adding extension twice", async () => {
|
||||
settings.set(["frontend", "enabled"], true);
|
||||
await controller.start();
|
||||
await expect(async () => {
|
||||
await controller.addExtension(new Frontend(...controller.extensionArgs));
|
||||
}).rejects.toThrow("Extension with name Frontend already present");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -358,7 +358,7 @@ describe("Extension: Bind", () => {
|
||||
{attribute: "currentX", minimumReportInterval: 5, maximumReportInterval: 0xffff, reportableChange: 1},
|
||||
{attribute: "currentY", minimumReportInterval: 5, maximumReportInterval: 0xffff, reportableChange: 1},
|
||||
]);
|
||||
expect(devices.bulb_color.meta.configured).toBe(332242049);
|
||||
expect(devices.bulb_color.meta.configured).toBe("0.0.0");
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/unbind",
|
||||
stringify({
|
||||
|
||||
@@ -597,6 +597,7 @@ describe("Extension: Bridge", () => {
|
||||
],
|
||||
supports_ota: true,
|
||||
vendor: "IKEA",
|
||||
version: "0.0.0",
|
||||
},
|
||||
description: "this is my bulb",
|
||||
disabled: false,
|
||||
@@ -734,7 +735,7 @@ describe("Extension: Bridge", () => {
|
||||
values: ["off", "on", "toggle", "previous"],
|
||||
},
|
||||
{
|
||||
access: 2,
|
||||
access: 3,
|
||||
label: "Effect",
|
||||
name: "effect",
|
||||
property: "effect",
|
||||
@@ -747,11 +748,41 @@ describe("Extension: Bridge", () => {
|
||||
"candle",
|
||||
"fireplace",
|
||||
"colorloop",
|
||||
"sunset",
|
||||
"sunrise",
|
||||
"sparkle",
|
||||
"opal",
|
||||
"glisten",
|
||||
"underwater",
|
||||
"cosmos",
|
||||
"sunbeam",
|
||||
"enchant",
|
||||
"none",
|
||||
"finish_effect",
|
||||
"stop_effect",
|
||||
"stop_hue_effect",
|
||||
],
|
||||
},
|
||||
{
|
||||
access: 3,
|
||||
description: "Animation speed for the active effect (0=slowest, 1=fastest)",
|
||||
label: "Effect speed",
|
||||
name: "effect_speed",
|
||||
property: "effect_speed",
|
||||
type: "numeric",
|
||||
value_max: 1,
|
||||
value_min: 0,
|
||||
value_step: 0.01,
|
||||
},
|
||||
{
|
||||
access: 2,
|
||||
description:
|
||||
'Set the base color of the active effect without stopping it (hex e.g. #FF4400, or JSON {"x":0.6,"y":0.3})',
|
||||
label: "Effect color",
|
||||
name: "effect_color",
|
||||
property: "effect_color",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
access: 1,
|
||||
category: "diagnostic",
|
||||
@@ -767,6 +798,27 @@ describe("Extension: Bridge", () => {
|
||||
],
|
||||
model: "7146060PH",
|
||||
options: [
|
||||
{
|
||||
access: 2,
|
||||
description:
|
||||
"Control this light using a Philips-specific protocol instead of standard Zigbee commands. When enabled, on/off, brightness, color, and color temperature are combined into single atomic commands. This is required to use the Effect color update mode. When disabled (default), standard Zigbee commands are used, which preserves the usual behavior, including simulating on/off transitions.",
|
||||
label: "Hue native control",
|
||||
name: "hue_native_control",
|
||||
property: "hue_native_control",
|
||||
type: "binary",
|
||||
value_off: false,
|
||||
value_on: true,
|
||||
},
|
||||
{
|
||||
access: 2,
|
||||
description:
|
||||
"Controls what happens when color is changed while an effect is active (requires Hue native control). 'stop' (default): color change stops the effect (Hue app behavior). 'update': color change re-sends the effect with the new color.",
|
||||
label: "Effect color mode",
|
||||
name: "effect_color_mode",
|
||||
property: "effect_color_mode",
|
||||
type: "enum",
|
||||
values: ["stop", "update"],
|
||||
},
|
||||
{
|
||||
access: 2,
|
||||
description:
|
||||
@@ -778,6 +830,16 @@ describe("Extension: Bridge", () => {
|
||||
value_min: 0,
|
||||
value_step: 0.1,
|
||||
},
|
||||
{
|
||||
access: 2,
|
||||
description: "State actions will also be published as 'action' when true (default false).",
|
||||
label: "State action",
|
||||
name: "state_action",
|
||||
property: "state_action",
|
||||
type: "binary",
|
||||
value_off: false,
|
||||
value_on: true,
|
||||
},
|
||||
{
|
||||
access: 2,
|
||||
description:
|
||||
@@ -789,19 +851,10 @@ describe("Extension: Bridge", () => {
|
||||
value_off: false,
|
||||
value_on: true,
|
||||
},
|
||||
{
|
||||
access: 2,
|
||||
description: "State actions will also be published as 'action' when true (default false).",
|
||||
label: "State action",
|
||||
name: "state_action",
|
||||
property: "state_action",
|
||||
type: "binary",
|
||||
value_off: false,
|
||||
value_on: true,
|
||||
},
|
||||
],
|
||||
supports_ota: true,
|
||||
vendor: "Philips",
|
||||
version: "0.0.0",
|
||||
},
|
||||
disabled: false,
|
||||
endpoints: {
|
||||
@@ -831,7 +884,7 @@ describe("Extension: Bridge", () => {
|
||||
{
|
||||
definition: {
|
||||
source: "native",
|
||||
description: "Hue dimmer switch",
|
||||
description: "Hue dimmer switch gen 1",
|
||||
exposes: [
|
||||
{
|
||||
access: 1,
|
||||
@@ -930,6 +983,7 @@ describe("Extension: Bridge", () => {
|
||||
],
|
||||
supports_ota: true,
|
||||
vendor: "Philips",
|
||||
version: "0.0.0",
|
||||
},
|
||||
disabled: false,
|
||||
endpoints: {
|
||||
@@ -1038,6 +1092,7 @@ describe("Extension: Bridge", () => {
|
||||
],
|
||||
supports_ota: false,
|
||||
vendor: "notSupportedMfg",
|
||||
version: "0.0.0",
|
||||
},
|
||||
disabled: false,
|
||||
endpoints: {
|
||||
@@ -1143,6 +1198,7 @@ describe("Extension: Bridge", () => {
|
||||
],
|
||||
supports_ota: false,
|
||||
vendor: "Aqara",
|
||||
version: "0.0.0",
|
||||
},
|
||||
disabled: false,
|
||||
endpoints: {
|
||||
@@ -1304,6 +1360,7 @@ describe("Extension: Bridge", () => {
|
||||
],
|
||||
supports_ota: false,
|
||||
vendor: "Aqara",
|
||||
version: "0.0.0",
|
||||
},
|
||||
disabled: false,
|
||||
endpoints: {"1": {bindings: [], clusters: {input: ["genBasic"], output: []}, configured_reportings: [], scenes: []}},
|
||||
@@ -1455,6 +1512,7 @@ describe("Extension: Bridge", () => {
|
||||
],
|
||||
supports_ota: true,
|
||||
vendor: "Xiaomi",
|
||||
version: "0.0.0",
|
||||
},
|
||||
disabled: false,
|
||||
endpoints: {"1": {bindings: [], clusters: {input: ["genBasic", "genOnOff"], output: []}, configured_reportings: [], scenes: []}},
|
||||
@@ -1914,6 +1972,7 @@ describe("Extension: Bridge", () => {
|
||||
],
|
||||
supports_ota: false,
|
||||
vendor: "Siglis",
|
||||
version: "0.0.0",
|
||||
},
|
||||
disabled: false,
|
||||
endpoints: {
|
||||
@@ -2204,6 +2263,7 @@ describe("Extension: Bridge", () => {
|
||||
],
|
||||
supports_ota: true,
|
||||
vendor: "IKEA",
|
||||
version: "0.0.0",
|
||||
},
|
||||
disabled: false,
|
||||
endpoints: {
|
||||
@@ -2639,6 +2699,7 @@ describe("Extension: Bridge", () => {
|
||||
],
|
||||
supports_ota: true,
|
||||
vendor: "IKEA",
|
||||
version: "0.0.0",
|
||||
},
|
||||
friendly_name: "bulb",
|
||||
ieee_address: "0x000b57fffec6a5b2",
|
||||
@@ -2725,6 +2786,7 @@ describe("Extension: Bridge", () => {
|
||||
],
|
||||
supports_ota: false,
|
||||
vendor: "notSupportedMfg",
|
||||
version: "0.0.0",
|
||||
},
|
||||
friendly_name: "0x0017880104e45518",
|
||||
ieee_address: "0x0017880104e45518",
|
||||
@@ -4132,6 +4194,15 @@ describe("Extension: Bridge", () => {
|
||||
stringify({data: {restart_required: true}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
|
||||
// Change an option
|
||||
await mockMQTTEvents.message(
|
||||
"zigbee2mqtt/bridge/request/options",
|
||||
stringify({options: {homeassistant: {enabled: true, experimental_event_entities: true}}}),
|
||||
);
|
||||
// @ts-expect-error - private property
|
||||
await vi.waitUntil(() => controller.getExtension("HomeAssistant")?.experimentalEventEntities === true);
|
||||
|
||||
// revert
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bridge/request/options", stringify({options: {homeassistant: {enabled: false}}}));
|
||||
await vi.waitUntil(() => controller.getExtension("HomeAssistant") === undefined);
|
||||
|
||||
@@ -4,13 +4,15 @@ import * as data from "../mocks/data";
|
||||
import {mockLogger} from "../mocks/logger";
|
||||
import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
|
||||
import {flushPromises} from "../mocks/utils";
|
||||
import {type Device, devices, type Endpoint, events as mockZHEvents} from "../mocks/zigbeeHerdsman";
|
||||
import {devices, type Endpoint, events as mockZHEvents, type Device as ZhDevice} from "../mocks/zigbeeHerdsman";
|
||||
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {InterviewState} from "zigbee-herdsman/dist/controller/model/device";
|
||||
import {Controller} from "../../lib/controller";
|
||||
import Device from "../../lib/model/device";
|
||||
import Configure from "../../lib/extension/configure";
|
||||
import * as settings from "../../lib/util/settings";
|
||||
import assert from "node:assert";
|
||||
|
||||
const mocksClear = [mockMQTTPublishAsync, mockLogger.warning, mockLogger.debug];
|
||||
|
||||
@@ -23,12 +25,9 @@ describe("Extension: Configure", () => {
|
||||
await controller.addExtension(new Configure(...controller.extensionArgs));
|
||||
};
|
||||
|
||||
const mockClear = (device: Device): void => {
|
||||
const mockClear = (device: ZhDevice): void => {
|
||||
for (const endpoint of device.endpoints) {
|
||||
endpoint.read.mockClear();
|
||||
endpoint.write.mockClear();
|
||||
endpoint.configureReporting.mockClear();
|
||||
endpoint.bind.mockClear();
|
||||
endpoint.mockClear();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -42,7 +41,7 @@ describe("Extension: Configure", () => {
|
||||
const endpoint2 = device.getEndpoint(2)!;
|
||||
expect(endpoint2.write).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint2.write).toHaveBeenCalledWith("genBasic", {49: {type: 25, value: 11}}, {disableDefaultResponse: true, manufacturerCode: 4107});
|
||||
expect(device.meta.configured).toBe(332242049);
|
||||
expect(device.meta.configured).toBe("0.0.0");
|
||||
};
|
||||
|
||||
const expectBulbConfigured = (): void => {
|
||||
@@ -147,6 +146,52 @@ describe("Extension: Configure", () => {
|
||||
expectBulbConfigured();
|
||||
});
|
||||
|
||||
it("Should re-configure when the version of the definition changes to a different value than the default (0.0.0)", async () => {
|
||||
// Device is initially configured (definition has uses default version of 0.0.0)
|
||||
const device = devices.bulb;
|
||||
expectBulbConfigured();
|
||||
|
||||
// Nothing happens when receiving a Zigbee message
|
||||
mockClear(device);
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
expectBulbNotConfigured();
|
||||
|
||||
// Simulate that the definition version changes
|
||||
const resolvedEntity = controller.zigbee.resolveEntity(device);
|
||||
assert(resolvedEntity instanceof Device && resolvedEntity.definition);
|
||||
assert(resolvedEntity.definition.version === "0.0.0");
|
||||
|
||||
try {
|
||||
// Change the version to a different value
|
||||
resolvedEntity.definition.version = "0.0.1";
|
||||
|
||||
// Now it should re-configure upon receiving a Zigbee message
|
||||
mockClear(device);
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
expectBulbConfigured();
|
||||
} finally {
|
||||
resolvedEntity.definition.version = "0.0.0";
|
||||
}
|
||||
});
|
||||
|
||||
it("Should NOT re-configure when version of the definition is 0.0.0", async () => {
|
||||
// This test the migration from the old configureKey (hash of the configure function) to the new definition version system.
|
||||
// See commment in `configure.ts` -> `shouldReconfigure` for more details.
|
||||
|
||||
// Device is initially configured (definition has uses default version of 0.0.0)
|
||||
const device = devices.bulb;
|
||||
expectBulbConfigured();
|
||||
device.meta.configured = 1321;
|
||||
|
||||
// Nothing happens when receiving a Zigbee message
|
||||
mockClear(device);
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
expectBulbNotConfigured();
|
||||
});
|
||||
|
||||
it("Should allow to configure via MQTT", async () => {
|
||||
mockClear(devices.remote);
|
||||
expectRemoteNotConfigured();
|
||||
@@ -227,27 +272,68 @@ describe("Extension: Configure", () => {
|
||||
});
|
||||
|
||||
it("Should configure max 3 times when fails", async () => {
|
||||
// @ts-expect-error private
|
||||
(controller.getExtension("Configure")! as Configure).attempts = {};
|
||||
const device = devices.remote;
|
||||
delete device.meta.configured;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
mockClear(device);
|
||||
endpoint.bind.mockRejectedValueOnce(new Error("BLA"));
|
||||
endpoint.bind.mockRejectedValueOnce(new Error("BLA1"));
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
endpoint.bind.mockRejectedValueOnce(new Error("BLA"));
|
||||
endpoint.bind.mockRejectedValueOnce(new Error("BLA2"));
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
endpoint.bind.mockRejectedValueOnce(new Error("BLA"));
|
||||
endpoint.bind.mockRejectedValueOnce(new Error("BLA3"));
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
endpoint.bind.mockRejectedValueOnce(new Error("BLA"));
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
endpoint.bind.mockRejectedValueOnce(new Error("BLA"));
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("successfully configures after couple fails", async () => {
|
||||
const device = devices.remote;
|
||||
delete device.meta.configured;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
mockClear(device);
|
||||
endpoint.bind.mockRejectedValueOnce(new Error("BLA1"));
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
endpoint.bind.mockRejectedValueOnce(new Error("BLA2"));
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(2 /* fails */ + 2 /* definition */);
|
||||
});
|
||||
|
||||
it("successfully configures with force after fails", async () => {
|
||||
const device = devices.remote;
|
||||
delete device.meta.configured;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
mockClear(device);
|
||||
endpoint.bind.mockRejectedValueOnce(new Error("BLA1"));
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
endpoint.bind.mockRejectedValueOnce(new Error("BLA2"));
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
endpoint.bind.mockRejectedValueOnce(new Error("BLA3"));
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(3);
|
||||
|
||||
endpoint.bind.mockRejectedValueOnce(new Error("BLA4"));
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/configure", stringify({id: device.ieeeAddr}));
|
||||
await flushPromises();
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/configure", stringify({id: device.ieeeAddr}));
|
||||
await flushPromises();
|
||||
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(4 /* fails */ + 2 /* definition */);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -245,6 +245,8 @@ describe("Extension: Frontend", () => {
|
||||
stringify({
|
||||
state: "ON",
|
||||
effect: null,
|
||||
effect_color: null,
|
||||
effect_speed: null,
|
||||
power_on_behavior: null,
|
||||
linkquality: 20,
|
||||
update: {state: null, installed_version: -1, latest_version: -1},
|
||||
@@ -269,6 +271,8 @@ describe("Extension: Frontend", () => {
|
||||
state: "ON",
|
||||
power_on_behavior: null,
|
||||
effect: null,
|
||||
effect_color: null,
|
||||
effect_speed: null,
|
||||
linkquality: 20,
|
||||
update: {state: null, installed_version: -1, latest_version: -1},
|
||||
},
|
||||
@@ -296,6 +300,8 @@ describe("Extension: Frontend", () => {
|
||||
brightness: 90,
|
||||
power_on_behavior: null,
|
||||
effect: null,
|
||||
effect_color: null,
|
||||
effect_speed: null,
|
||||
linkquality: 20,
|
||||
update: {state: null, installed_version: -1, latest_version: -1},
|
||||
},
|
||||
@@ -478,10 +484,6 @@ describe("Extension: Frontend", () => {
|
||||
|
||||
await controller.enableDisableExtension(true, "Frontend");
|
||||
|
||||
await expect(async () => {
|
||||
await controller.enableDisableExtension(true, "Frontend");
|
||||
}).rejects.toThrow("Extension with name Frontend already present");
|
||||
|
||||
settings.set(["frontend", "enabled"], false);
|
||||
await controller.enableDisableExtension(false, "Frontend");
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
definition: d,
|
||||
isDevice: (): boolean => true,
|
||||
isGroup: (): boolean => false,
|
||||
endpoint: () => undefined,
|
||||
options: {},
|
||||
exposes: (): unknown[] => exposes,
|
||||
zh: {endpoints: []},
|
||||
@@ -124,7 +125,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
}
|
||||
}
|
||||
|
||||
expect(duplicated).toHaveLength(0);
|
||||
expect(duplicated).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("Should discover devices and groups", async () => {
|
||||
@@ -163,6 +164,16 @@ describe("Extension: HomeAssistant", () => {
|
||||
"candle",
|
||||
"fireplace",
|
||||
"colorloop",
|
||||
"sunset",
|
||||
"sunrise",
|
||||
"sparkle",
|
||||
"opal",
|
||||
"glisten",
|
||||
"underwater",
|
||||
"cosmos",
|
||||
"sunbeam",
|
||||
"enchant",
|
||||
"none",
|
||||
"finish_effect",
|
||||
"stop_effect",
|
||||
"stop_hue_effect",
|
||||
@@ -170,6 +181,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
object_id: "ha_discovery_group",
|
||||
default_entity_id: "light.ha_discovery_group",
|
||||
unique_id: "9_light_zigbee2mqtt",
|
||||
group: ["0x000b57fffec6a5b4_light_zigbee2mqtt", "0x000b57fffec6a5b7_light_zigbee2mqtt"],
|
||||
origin: origin,
|
||||
};
|
||||
|
||||
@@ -225,6 +237,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
object_id: "ha_discovery_group",
|
||||
default_entity_id: "switch.ha_discovery_group",
|
||||
unique_id: "9_switch_zigbee2mqtt",
|
||||
group: ["0x0017880104e45542_switch_right_zigbee2mqtt"],
|
||||
origin: origin,
|
||||
value_template: "{{ value_json.state }}",
|
||||
};
|
||||
@@ -944,6 +957,45 @@ describe("Extension: HomeAssistant", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("Should discover siren (HS2WD-E)", () => {
|
||||
const payload = {
|
||||
available_tones: ["emergency"],
|
||||
support_duration: true,
|
||||
optimistic: true,
|
||||
command_topic: "zigbee2mqtt/siren/set",
|
||||
command_template:
|
||||
'{"warning": {"mode": "{{ tone | default(\'emergency\') }}", ' +
|
||||
'"level": "' +
|
||||
"{% if volume_level is defined %}" +
|
||||
"{% if volume_level | float <= 0.25 %}low" +
|
||||
"{% elif volume_level | float <= 0.5 %}medium" +
|
||||
"{% elif volume_level | float <= 0.75 %}high" +
|
||||
"{% else %}very_high{% endif %}" +
|
||||
'{% else %}medium{% endif %}", ' +
|
||||
'"duration": {{ duration | default(10) }}}}',
|
||||
command_off_template: '{"warning": {"mode": "stop"}}',
|
||||
name: null,
|
||||
object_id: "siren",
|
||||
default_entity_id: "siren.siren",
|
||||
unique_id: "0x0017880104e45549_siren_zigbee2mqtt",
|
||||
origin: origin,
|
||||
device: {
|
||||
identifiers: ["zigbee2mqtt_0x0017880104e45549"],
|
||||
name: "siren",
|
||||
model: "Smart siren",
|
||||
model_id: "HS2WD-E",
|
||||
manufacturer: "Heiman",
|
||||
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
|
||||
},
|
||||
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
|
||||
};
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/siren/0x0017880104e45549/siren/config", stringify(payload), {
|
||||
retain: true,
|
||||
qos: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("Should discover devices with speed-controlled fan", () => {
|
||||
const payload = {
|
||||
state_topic: "zigbee2mqtt/fanbee",
|
||||
@@ -1519,6 +1571,8 @@ describe("Extension: HomeAssistant", () => {
|
||||
color: {hue: 0, saturation: 100, h: 0, s: 100},
|
||||
color_mode: "hs",
|
||||
effect: null,
|
||||
effect_color: null,
|
||||
effect_speed: null,
|
||||
linkquality: null,
|
||||
state: null,
|
||||
power_on_behavior: null,
|
||||
@@ -1542,6 +1596,8 @@ describe("Extension: HomeAssistant", () => {
|
||||
color: {x: 0.4576, y: 0.41},
|
||||
color_mode: "xy",
|
||||
effect: null,
|
||||
effect_color: null,
|
||||
effect_speed: null,
|
||||
linkquality: null,
|
||||
state: null,
|
||||
power_on_behavior: null,
|
||||
@@ -1564,6 +1620,8 @@ describe("Extension: HomeAssistant", () => {
|
||||
stringify({
|
||||
linkquality: null,
|
||||
effect: null,
|
||||
effect_color: null,
|
||||
effect_speed: null,
|
||||
state: "ON",
|
||||
power_on_behavior: null,
|
||||
update: {state: null, installed_version: -1, latest_version: -1},
|
||||
@@ -1926,6 +1984,16 @@ describe("Extension: HomeAssistant", () => {
|
||||
"candle",
|
||||
"fireplace",
|
||||
"colorloop",
|
||||
"sunset",
|
||||
"sunrise",
|
||||
"sparkle",
|
||||
"opal",
|
||||
"glisten",
|
||||
"underwater",
|
||||
"cosmos",
|
||||
"sunbeam",
|
||||
"enchant",
|
||||
"none",
|
||||
"finish_effect",
|
||||
"stop_effect",
|
||||
"stop_hue_effect",
|
||||
@@ -1933,6 +2001,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
object_id: "ha_discovery_group_new",
|
||||
default_entity_id: "light.ha_discovery_group_new",
|
||||
unique_id: "9_light_zigbee2mqtt",
|
||||
group: ["0x000b57fffec6a5b4_light_zigbee2mqtt", "0x000b57fffec6a5b7_light_zigbee2mqtt"],
|
||||
origin: origin,
|
||||
};
|
||||
|
||||
@@ -2395,6 +2464,16 @@ describe("Extension: HomeAssistant", () => {
|
||||
"candle",
|
||||
"fireplace",
|
||||
"colorloop",
|
||||
"sunset",
|
||||
"sunrise",
|
||||
"sparkle",
|
||||
"opal",
|
||||
"glisten",
|
||||
"underwater",
|
||||
"cosmos",
|
||||
"sunbeam",
|
||||
"enchant",
|
||||
"none",
|
||||
"finish_effect",
|
||||
"stop_effect",
|
||||
"stop_hue_effect",
|
||||
@@ -2402,6 +2481,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
object_id: "ha_discovery_group",
|
||||
default_entity_id: "light.ha_discovery_group",
|
||||
unique_id: "9_light_zigbee2mqtt",
|
||||
group: ["0x000b57fffec6a5b4_light_zigbee2mqtt", "0x000b57fffec6a5b7_light_zigbee2mqtt"],
|
||||
origin: origin,
|
||||
};
|
||||
|
||||
@@ -2440,6 +2520,16 @@ describe("Extension: HomeAssistant", () => {
|
||||
"candle",
|
||||
"fireplace",
|
||||
"colorloop",
|
||||
"sunset",
|
||||
"sunrise",
|
||||
"sparkle",
|
||||
"opal",
|
||||
"glisten",
|
||||
"underwater",
|
||||
"cosmos",
|
||||
"sunbeam",
|
||||
"enchant",
|
||||
"none",
|
||||
"finish_effect",
|
||||
"stop_effect",
|
||||
"stop_hue_effect",
|
||||
@@ -2447,6 +2537,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
object_id: "ha_discovery_group",
|
||||
default_entity_id: "light.ha_discovery_group",
|
||||
unique_id: "9_light_zigbee2mqtt",
|
||||
group: ["0x000b57fffec6a5b4_light_zigbee2mqtt", "0x000b57fffec6a5b7_light_zigbee2mqtt"],
|
||||
origin: origin,
|
||||
};
|
||||
|
||||
@@ -2976,10 +3067,6 @@ describe("Extension: HomeAssistant", () => {
|
||||
await controller.enableDisableExtension(false, "HomeAssistant");
|
||||
}).rejects.toThrow("Tried to disable HomeAssistant extension enabled in settings");
|
||||
|
||||
await expect(async () => {
|
||||
await controller.enableDisableExtension(true, "HomeAssistant");
|
||||
}).rejects.toThrow("Extension with name HomeAssistant already present");
|
||||
|
||||
settings.set(["homeassistant", "enabled"], false);
|
||||
|
||||
await expect(async () => {
|
||||
|
||||
@@ -286,7 +286,7 @@ describe("Extension: NetworkMap", () => {
|
||||
description: "Hue Go",
|
||||
model: "7146060PH",
|
||||
supports:
|
||||
"light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), power_on_behavior, effect, linkquality",
|
||||
"light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), power_on_behavior, effect, effect_speed, effect_color, linkquality",
|
||||
vendor: "Philips",
|
||||
},
|
||||
failed: [],
|
||||
@@ -616,7 +616,7 @@ describe("Extension: NetworkMap", () => {
|
||||
description: "Hue Go",
|
||||
model: "7146060PH",
|
||||
supports:
|
||||
"light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), power_on_behavior, effect, linkquality",
|
||||
"light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), power_on_behavior, effect, effect_speed, effect_color, linkquality",
|
||||
vendor: "Philips",
|
||||
},
|
||||
failed: [],
|
||||
@@ -785,7 +785,7 @@ describe("Extension: NetworkMap", () => {
|
||||
description: "Hue Go",
|
||||
model: "7146060PH",
|
||||
supports:
|
||||
"light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), power_on_behavior, effect, linkquality",
|
||||
"light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), power_on_behavior, effect, effect_speed, effect_color, linkquality",
|
||||
vendor: "Philips",
|
||||
},
|
||||
failed: [],
|
||||
|
||||
@@ -143,7 +143,7 @@ describe("Extension: OTAUpdate", () => {
|
||||
expect(infoCalls[7]).toStrictEqual(`Interviewing 'bulb'`);
|
||||
expect(infoCalls[8]).toStrictEqual(`Configuring 'bulb'`);
|
||||
expect(infoCalls[10]).toStrictEqual(`Successfully interviewed 'bulb'`);
|
||||
expect(infoCalls[11]).toStrictEqual(`Successfully configured 'bulb'`);
|
||||
expect(infoCalls[11]).toStrictEqual(`Successfully configured 'bulb' (definition v0.0.0)`);
|
||||
|
||||
expect(devices.bulb.save).toHaveBeenCalledTimes(1);
|
||||
expect(devices.bulb.endpoints[0].read).toHaveBeenCalledWith("genBasic", ["dateCode", "swBuildId"], {sendPolicy: "immediate"});
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import type {IClientPublishOptions} from "mqtt";
|
||||
|
||||
import {vi} from "vitest";
|
||||
import type {EventHandler} from "./utils";
|
||||
|
||||
export const events: Record<string, EventHandler> = {};
|
||||
|
||||
@@ -1192,7 +1192,11 @@ export const mockController = {
|
||||
on: (type: string, handler: EventHandler): void => {
|
||||
events[type] = handler;
|
||||
},
|
||||
start: vi.fn((): Promise<AdapterTypes.StartResult> => Promise.resolve("reset")),
|
||||
start: vi.fn(async (abortSignal?: AbortSignal): Promise<AdapterTypes.StartResult> => {
|
||||
abortSignal?.throwIfAborted();
|
||||
|
||||
return await Promise.resolve("reset");
|
||||
}),
|
||||
stop: vi.fn(),
|
||||
touchlink: {identify: vi.fn(), scan: vi.fn(), factoryReset: vi.fn(), factoryResetFirst: vi.fn()},
|
||||
addInstallCode: vi.fn(),
|
||||
|
||||
+710
-146
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,6 @@
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "dist",
|
||||
"baseUrl": ".",
|
||||
"rootDir": "lib",
|
||||
"inlineSourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
|
||||
Reference in New Issue
Block a user