mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-08-29 07:08:56 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01b25f15bf | ||
|
|
b441091f6f | ||
|
|
9a6b25afa5 | ||
|
|
9473312667 | ||
|
|
22de3f10d8 | ||
|
|
c867ca5182 | ||
|
|
7fbbbc3b17 | ||
|
|
dccf0a98c6 | ||
|
|
cf11b1a188 | ||
|
|
75bb5eed00 | ||
|
|
95d072a788 | ||
|
|
be1cbb5f3b | ||
|
|
07ae13ad2d | ||
|
|
c649d04b1c | ||
|
|
d3ba21828c | ||
|
|
d276190ab8 | ||
|
|
e7deca42bf | ||
|
|
07a4a0bc75 | ||
|
|
368e31b1e3 | ||
|
|
10611654c7 | ||
|
|
f06673506f | ||
|
|
06290da5be | ||
|
|
86dfc90869 | ||
|
|
60786528ae | ||
|
|
3aaa8c1546 | ||
|
|
046817624f | ||
|
|
106a4bdef2 | ||
|
|
a8171a5464 | ||
|
|
2f976b7dca | ||
|
|
29b041ce57 | ||
|
|
828038717f | ||
|
|
5fd4d6b37a | ||
|
|
fcbb7ff44b | ||
|
|
e5e36ac79c | ||
|
|
28e410bbf4 | ||
|
|
b29fc0e493 | ||
|
|
316413b31c | ||
|
|
a4862c9769 | ||
|
|
3348cea190 | ||
|
|
fe96204f0b | ||
|
|
7598b38ed8 | ||
|
|
52d5316377 | ||
|
|
32506b4e8a | ||
|
|
3bec9e87b1 | ||
|
|
b46c77bff0 | ||
|
|
e6e0b6f8f2 | ||
|
|
756a824488 | ||
|
|
83ab5228ff | ||
|
|
4f18b4e38c | ||
|
|
5e56c454c3 | ||
|
|
bbad134e8e | ||
|
|
2d941000a7 | ||
|
|
4ad1ab21e6 | ||
|
|
6835cbf5a7 | ||
|
|
b8d388ca7a | ||
|
|
c9ddef703a | ||
|
|
4174e9cb78 | ||
|
|
752ab37302 | ||
|
|
d6dea17961 | ||
|
|
e78b23f5eb | ||
|
|
22d85a6729 | ||
|
|
a9ce4b2522 | ||
|
|
e889592baf | ||
|
|
576e1954ef | ||
|
|
8940f6dabf | ||
|
|
fa12ebe27c | ||
|
|
3f7c0ca715 | ||
|
|
66db1bdbcd | ||
|
|
a80c2db4c6 | ||
|
|
e475de15c9 | ||
|
|
2ca80b8572 | ||
|
|
912fe4c250 | ||
|
|
74478fd955 | ||
|
|
5591207dea | ||
|
|
83ca2746a7 | ||
|
|
8f0d981765 | ||
|
|
6e557162cb | ||
|
|
a2973f21f6 | ||
|
|
f88d99294b | ||
|
|
344776b633 | ||
|
|
4b0c3067ff | ||
|
|
e8ba0b24f8 | ||
|
|
2cfdd807ed | ||
|
|
aa909a8a62 | ||
|
|
f867694831 | ||
|
|
740de2d363 | ||
|
|
e8a7e6f29d | ||
|
|
3760e63183 | ||
|
|
dcb16b8271 | ||
|
|
31fb3a6e89 | ||
|
|
0018ca8f80 | ||
|
|
2f26083ce1 | ||
|
|
8d5fdada38 | ||
|
|
6ab52df08a | ||
|
|
0140708076 | ||
|
|
7775ac5cd2 | ||
|
|
814552a615 | ||
|
|
280442b671 | ||
|
|
628f97f498 | ||
|
|
b58503bb57 | ||
|
|
98a0f94870 | ||
|
|
72be1d746b | ||
|
|
20ab0a2a4f | ||
|
|
87689523ae | ||
|
|
2cbf740f1b | ||
|
|
262139a43f | ||
|
|
e85ff5513c | ||
|
|
9a7cac1e4c | ||
|
|
679436e09b | ||
|
|
c6e7d2dd16 | ||
|
|
29ee3a7552 | ||
|
|
0377f965a3 | ||
|
|
1de8609456 | ||
|
|
95c67c0e22 | ||
|
|
2f44449953 | ||
|
|
299b9e64f5 | ||
|
|
719de7adcf | ||
|
|
758a80b30c | ||
|
|
f4f97d1920 | ||
|
|
a68419f0f5 | ||
|
|
ced6d93d20 | ||
|
|
a81e90b981 | ||
|
|
0cc3b1e7f1 | ||
|
|
fd8bafc81d | ||
|
|
68daf4f1de | ||
|
|
41f6886591 | ||
|
|
582f153896 | ||
|
|
6c2c874641 | ||
|
|
120f6ac779 | ||
|
|
de58711e11 | ||
|
|
ea6ccac938 | ||
|
|
ef8dcb4dc2 | ||
|
|
babf7a17d4 | ||
|
|
f9e1bac588 | ||
|
|
6aac849452 | ||
|
|
2b5a670ba2 | ||
|
|
58feb78362 | ||
|
|
c6e13ccb45 | ||
|
|
ac8c45f67f | ||
|
|
a8364ad67b | ||
|
|
e2b49113c7 | ||
|
|
7b9407d020 | ||
|
|
873ad6082c | ||
|
|
8beaf488f7 | ||
|
|
7a1c2d52f0 | ||
|
|
5a79c338d4 | ||
|
|
16fd422c4a | ||
|
|
e5f70d5ba8 | ||
|
|
0618aca73a | ||
|
|
cfc7ab8dd8 | ||
|
|
56aab4e59c | ||
|
|
7075b78a46 | ||
|
|
b31e8c286e | ||
|
|
2c5b536134 | ||
|
|
52201c96bf | ||
|
|
bf964cc6d9 | ||
|
|
1e114dee70 | ||
|
|
897aa8fb7c | ||
|
|
bbcbed12d5 | ||
|
|
36c4db7f8f | ||
|
|
5458e5084d | ||
|
|
744e4a6d5c | ||
|
|
6ea21c383b | ||
|
|
1a1c09449c | ||
|
|
9426c3ea11 | ||
|
|
4d9e9504ef | ||
|
|
32499d14d5 | ||
|
|
c4fd415cbe | ||
|
|
502030c016 | ||
|
|
7a05464f03 | ||
|
|
15fd9b371e | ||
|
|
04a43652d6 | ||
|
|
7b37e97e00 | ||
|
|
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 | ||
|
|
b0b02c30b2 | ||
|
|
9ddbb2a21a | ||
|
|
49b752da35 | ||
|
|
788b0d3993 | ||
|
|
f68834049c | ||
|
|
4afbce62eb | ||
|
|
4f412cb4da | ||
|
|
5a4d6b56ad | ||
|
|
26a0fd4de0 | ||
|
|
8d980ae6ec | ||
|
|
99e06a8d3e | ||
|
|
476efaa6e1 | ||
|
|
10478e797e | ||
|
|
5de47a0d93 | ||
|
|
14bffd58b1 | ||
|
|
999df2913f | ||
|
|
da1f77ec6e | ||
|
|
036ee471dd | ||
|
|
dd1c449796 | ||
|
|
6d5e7e334a | ||
|
|
8e682956e5 | ||
|
|
971ddaa3cb | ||
|
|
ad51d12a2d | ||
|
|
d9e4244f80 | ||
|
|
2bb44f7a8b | ||
|
|
20af50201e | ||
|
|
2e36084852 | ||
|
|
9d266bda9a | ||
|
|
043dd0ff1c | ||
|
|
ee6afaf1c6 | ||
|
|
4815d5149d | ||
|
|
6f0b02e009 | ||
|
|
5e1b7d4830 | ||
|
|
535f75e90c | ||
|
|
d44cdbde4f | ||
|
|
9a41ec2a39 | ||
|
|
1d87d2f234 | ||
|
|
29c757145d | ||
|
|
e68403d8d3 | ||
|
|
a32c8eec36 | ||
|
|
d4522c53ca | ||
|
|
57245e5234 | ||
|
|
4313baca09 | ||
|
|
ed52c49e34 | ||
|
|
a2b81ab894 | ||
|
|
3e60f915ff | ||
|
|
1eaa2f3e75 | ||
|
|
3adf7d5d8e | ||
|
|
1147cf16dd | ||
|
|
97a9b15240 | ||
|
|
3a49c95786 | ||
|
|
0eba0c953d | ||
|
|
4d6269e7e8 | ||
|
|
3727f078ec | ||
|
|
93c9704fcd | ||
|
|
2721b0ea78 | ||
|
|
4c48e9cfbc | ||
|
|
7cb5c58dc0 | ||
|
|
c61f67f061 | ||
|
|
58d98c7263 | ||
|
|
b10fa93700 | ||
|
|
c28a024664 | ||
|
|
8549b37228 | ||
|
|
4c02e3da8f | ||
|
|
ab0894715a | ||
|
|
1688a3b532 | ||
|
|
97694d2241 | ||
|
|
d6088c56db | ||
|
|
b39b8d478e | ||
|
|
01304cfc66 | ||
|
|
1d15c2142f | ||
|
|
3818c50b8a | ||
|
|
7f5f6a5849 | ||
|
|
0025ef87c5 | ||
|
|
3460f157df | ||
|
|
ecb0af4057 | ||
|
|
5db6ae0a19 | ||
|
|
ad3c09036b | ||
|
|
0c89629526 | ||
|
|
4574ffc71a | ||
|
|
1707f3abf3 | ||
|
|
8916fb7b50 | ||
|
|
6e70ef24d0 | ||
|
|
8a3c4bd59f | ||
|
|
c18d1329c8 | ||
|
|
ed1e7b333f | ||
|
|
95c38810a8 | ||
|
|
b70701f8b8 |
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"image": "mcr.microsoft.com/devcontainers/javascript-node:24",
|
||||
|
||||
"postCreateCommand": "pnpm config set store-dir /home/node/.local/share/pnpm/store && npm install typescript -g",
|
||||
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"settings": {
|
||||
"workbench.colorTheme": "Default Dark Modern",
|
||||
"window.menuBarVisibility": "classic",
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"notebook.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnPaste": true,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.tabSize": 4,
|
||||
"editor.insertSpaces": true,
|
||||
"files.defaultLanguage": "typescript",
|
||||
"files.eol": "\n"
|
||||
},
|
||||
"extensions": ["biomejs.biome", "vitest.explorer"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ abstract class Extension {
|
||||
protected state: State;
|
||||
protected publishEntityState: PublishEntityState;
|
||||
protected eventBus: EventBus;
|
||||
|
||||
|
||||
async start(): Promise<void> {}
|
||||
async stop(): Promise<void> {}
|
||||
}
|
||||
@@ -181,9 +181,9 @@ logger.debug("message");
|
||||
- Use TypeScript's strict mode features (`noImplicitAny`, `noImplicitThis`)
|
||||
|
||||
### Performance
|
||||
- Use `rimrafSync` for synchronous file deletion when appropriate
|
||||
- Use `fs.rmSync(path, {recursive: true, force: true})` for synchronous file deletion when appropriate
|
||||
- Leverage async/await for I/O operations to avoid blocking
|
||||
- Use JSON stable stringify for consistent object serialization: `json-stable-stringify-without-jsonify`
|
||||
- Use JSON stable stringify util for consistent object serialization
|
||||
- Cache computed values when appropriate (see device model patterns)
|
||||
- Use getter methods for computed properties that should be cached
|
||||
|
||||
@@ -217,10 +217,10 @@ describe("ComponentName", () => {
|
||||
it("Should do something specific", async () => {
|
||||
// Arrange
|
||||
const input = {};
|
||||
|
||||
|
||||
// Act
|
||||
const result = await someFunction(input);
|
||||
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
@@ -414,7 +414,7 @@ this.eventBus.on('deviceMessage', this.onDeviceMessage, this);
|
||||
- Device operations through `zigbee-herdsman` API
|
||||
- Event handling through EventBus wrappers
|
||||
|
||||
### MQTT Integration
|
||||
### MQTT Integration
|
||||
- Connect: `await this.mqtt.connect()`
|
||||
- Subscribe: `await this.mqtt.subscribe(topic)`
|
||||
- Publish: `await this.mqtt.publish(topic, message, options)`
|
||||
|
||||
@@ -19,8 +19,12 @@ updates:
|
||||
schedule:
|
||||
interval: weekly
|
||||
target-branch: dev
|
||||
commit-message:
|
||||
prefix: fix(ignore)
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
target-branch: dev
|
||||
commit-message:
|
||||
prefix: chore
|
||||
|
||||
+15
-15
@@ -22,18 +22,18 @@ jobs:
|
||||
actions: read # for Nerivec/action-ci-bench
|
||||
pull-requests: write # for Nerivec/action-ci-bench
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
|
||||
with:
|
||||
# Required for `release: merge dev -> master and promote dev`
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
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@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
registry-url: https://registry.npmjs.org/
|
||||
@@ -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
|
||||
@@ -216,15 +216,15 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
node: [20, 22, 24]
|
||||
node: [22, 24, 26]
|
||||
runs-on: ${{ matrix.os }}
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: pnpm
|
||||
|
||||
@@ -10,6 +10,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'Checkout repository'
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- name: 'Dependency review'
|
||||
uses: actions/dependency-review-action@v4
|
||||
uses: actions/dependency-review-action@v5
|
||||
|
||||
@@ -10,8 +10,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Fail PR to master
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
run: |
|
||||
if [[ "${{ github.event.pull_request.title }}" == "chore(dev): release"* ]]; then
|
||||
if [[ "$PR_TITLE" == "chore(dev): release"* ]]; then
|
||||
echo "PR title starts with 'chore(dev): release', allowing PR"
|
||||
else
|
||||
echo "Pull requests to the master branch are not allowed, target dev branch"
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -17,13 +17,13 @@ jobs:
|
||||
startsWith(github.event.issue.title, '[New device support]') ||
|
||||
startsWith(github.event.issue.title, '[External Converter]')
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
sparse-checkout: |
|
||||
scripts
|
||||
path: z2m
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: Koenkk/zigbee-herdsman-converters
|
||||
ref: master
|
||||
@@ -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")
|
||||
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
merge-master-to-dev:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: devmasx/merge-branch@master
|
||||
with:
|
||||
type: now
|
||||
|
||||
@@ -16,33 +16,33 @@ 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@v6
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- uses: googleapis/release-please-action@v4
|
||||
- uses: googleapis/release-please-action@v5
|
||||
id: release
|
||||
with:
|
||||
target-branch: dev
|
||||
token: ${{secrets.GH_TOKEN}}
|
||||
|
||||
# Checkout repos
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: koenkk/zigbee2mqtt
|
||||
path: ./z2m
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: koenkk/zigbee2mqtt
|
||||
path: ./z2m-master
|
||||
ref: master
|
||||
|
||||
- name: Restore cache commit-user-lookup.json
|
||||
uses: actions/cache/restore@v4
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: z2m/scripts/commit-user-lookup.json
|
||||
key: commit-user-lookup-dummy
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{secrets.GH_TOKEN}}
|
||||
- name: Save cache commit-user-lookup.json
|
||||
uses: actions/cache/save@v4
|
||||
uses: actions/cache/save@v6
|
||||
if: always()
|
||||
with:
|
||||
path: z2m/scripts/commit-user-lookup.json
|
||||
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
- uses: actions/stale@v11
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days'
|
||||
|
||||
@@ -13,19 +13,19 @@ jobs:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: dev
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: |
|
||||
pnpm install ${{ github.event.client_payload.package }}@${{ github.event.client_payload.version }} --save-exact
|
||||
pnpm install --no-frozen-lockfile
|
||||
- uses: peter-evans/create-pull-request@v7
|
||||
- uses: peter-evans/create-pull-request@v8
|
||||
id: cpr
|
||||
with:
|
||||
commit-message: 'fix(ignore): update ${{ github.event.client_payload.package }} to ${{ github.event.client_payload.version }}'
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "2.7.1"
|
||||
".": "2.13.0"
|
||||
}
|
||||
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["biomejs.biome", "vitest.explorer"]
|
||||
}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"notebook.defaultFormatter": "biomejs.biome",
|
||||
"editor.tabSize": 4,
|
||||
"editor.insertSpaces": true,
|
||||
"files.defaultLanguage": "typescript",
|
||||
"files.eol": "\n",
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ Zigbee2MQTT is a Zigbee to MQTT bridge that allows you to use your Zigbee device
|
||||
- **Language**: TypeScript 5.9.3 compiled to JavaScript (ES modules with NodeNext resolution)
|
||||
- **Runtime**: Node.js (versions 20, 22, or 24)
|
||||
- **Package Manager**: pnpm 10.12.1 (strictly enforced via `packageManager` field)
|
||||
- **Core Dependencies**:
|
||||
- **Core Dependencies**:
|
||||
- `zigbee-herdsman` (6.2.0 - exact version, handles Zigbee adapter communication)
|
||||
- `zigbee-herdsman-converters` (25.42.0 - exact version, device definitions)
|
||||
- `mqtt` (5.14.1 - MQTT client)
|
||||
@@ -227,7 +227,7 @@ abstract class Extension {
|
||||
protected state: State;
|
||||
protected publishEntityState: PublishEntityState;
|
||||
protected eventBus: EventBus;
|
||||
|
||||
|
||||
async start(): Promise<void> {} // Initialize extension
|
||||
async stop(): Promise<void> {} // Cleanup extension
|
||||
}
|
||||
@@ -326,7 +326,7 @@ https://www.zigbee2mqtt.io/guide/installation/01_linux.html
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
- Use `rimrafSync` for synchronous file operations
|
||||
- Use `fs.rmSync(path, {recursive: true, force: true})` for synchronous file operations
|
||||
- Leverage async/await to avoid blocking
|
||||
- Cache computed values in getters when appropriate
|
||||
- EventBus provides loose coupling between components
|
||||
@@ -344,7 +344,7 @@ These dependencies use **exact versions** (no semver ranges) - do not upgrade wi
|
||||
|
||||
Only these Node.js versions are supported:
|
||||
- Node.js 20.x
|
||||
- Node.js 22.x
|
||||
- Node.js 22.x
|
||||
- Node.js 24.x
|
||||
|
||||
Using other versions may cause runtime errors or incompatibilities.
|
||||
|
||||
+391
@@ -1,5 +1,396 @@
|
||||
# Changelog
|
||||
|
||||
## [2.13.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.12.1...2.13.0) (2026-08-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add `clear_cache` option to device remove request ([#32631](https://github.com/Koenkk/zigbee2mqtt/issues/32631)) ([83ab522](https://github.com/Koenkk/zigbee2mqtt/commit/83ab5228ff24779773e67bfc217d89f7c7520daa))
|
||||
* Home Assistant: add discovery support for Tuya infrared receiver (learn mode) and emitter features ([#32625](https://github.com/Koenkk/zigbee2mqtt/issues/32625)) ([32506b4](https://github.com/Koenkk/zigbee2mqtt/commit/32506b4e8abf81996966632719384f85bad7a66c))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* `experimental_event_entities` and `legacy_action_sensor` require restart ([#32535](https://github.com/Koenkk/zigbee2mqtt/issues/32535)) ([2ca80b8](https://github.com/Koenkk/zigbee2mqtt/commit/2ca80b8572293a7c63c019c1b433e942772220c4))
|
||||
* Avoid running resolveDefinition in parallel ([#32662](https://github.com/Koenkk/zigbee2mqtt/issues/32662)) ([b46c77b](https://github.com/Koenkk/zigbee2mqtt/commit/b46c77bff0612527bdfc7540ea31ec258bd002b1))
|
||||
* Drop redundant types js yaml ([#32619](https://github.com/Koenkk/zigbee2mqtt/issues/32619)) ([c9ddef7](https://github.com/Koenkk/zigbee2mqtt/commit/c9ddef703a1f83a2dc5c84a1a1eaa087bee3f671))
|
||||
* Fix restartRequired flag ([#31947](https://github.com/Koenkk/zigbee2mqtt/issues/31947)) ([4174e9c](https://github.com/Koenkk/zigbee2mqtt/commit/4174e9cb783b73527349182b35e06eda4512b101))
|
||||
* Home Assistant: discover device trigger when mqtt output = attribute_and_json and add warnings about incompatible settings ([#32603](https://github.com/Koenkk/zigbee2mqtt/issues/32603)) ([d6dea17](https://github.com/Koenkk/zigbee2mqtt/commit/d6dea17961e05b8ca8ce05d7852efd483031260d))
|
||||
* Home Assistant: fix action published to wrong topic ([#32544](https://github.com/Koenkk/zigbee2mqtt/issues/32544)) ([a80c2db](https://github.com/Koenkk/zigbee2mqtt/commit/a80c2db4c6038426fa5102477f6da34355f54134))
|
||||
* **ignore:** bump @types/node from 24.13.2 to 26.1.0 ([#32453](https://github.com/Koenkk/zigbee2mqtt/issues/32453)) ([83ca274](https://github.com/Koenkk/zigbee2mqtt/commit/83ca2746a789bb92bfdb51506e875edfe5cb06b3))
|
||||
* **ignore:** bump actions/setup-node from 6 to 7 ([#32574](https://github.com/Koenkk/zigbee2mqtt/issues/32574)) ([e889592](https://github.com/Koenkk/zigbee2mqtt/commit/e889592baf9b2738c7d2088247ea3f888cd08629))
|
||||
* **ignore:** bump the minor-patch group with 2 updates ([#32504](https://github.com/Koenkk/zigbee2mqtt/issues/32504)) ([8f0d981](https://github.com/Koenkk/zigbee2mqtt/commit/8f0d9817652f97414d927e2d3e4f2b101e3d55ca))
|
||||
* **ignore:** bump the minor-patch group with 2 updates ([#32514](https://github.com/Koenkk/zigbee2mqtt/issues/32514)) ([74478fd](https://github.com/Koenkk/zigbee2mqtt/commit/74478fd955dc434e039fac547bfe69db5a2f55ae))
|
||||
* **ignore:** bump the minor-patch group with 3 updates ([#32692](https://github.com/Koenkk/zigbee2mqtt/issues/32692)) ([3348cea](https://github.com/Koenkk/zigbee2mqtt/commit/3348cea190125b9a466434cc5ea95ab7a857559d))
|
||||
* **ignore:** bump the minor-patch group with 5 updates ([#32452](https://github.com/Koenkk/zigbee2mqtt/issues/32452)) ([e8ba0b2](https://github.com/Koenkk/zigbee2mqtt/commit/e8ba0b24f8b8528e98904234a32515713f457781))
|
||||
* **ignore:** bump throttleit from 2.1.0 to 3.0.0 ([#32551](https://github.com/Koenkk/zigbee2mqtt/issues/32551)) ([3f7c0ca](https://github.com/Koenkk/zigbee2mqtt/commit/3f7c0ca71502a2a522a11587a878534f1fd42fe1))
|
||||
* **ignore:** bump typescript from 6.0.3 to 7.0.2 ([#32552](https://github.com/Koenkk/zigbee2mqtt/issues/32552)) ([fa12ebe](https://github.com/Koenkk/zigbee2mqtt/commit/fa12ebe27c1598e7ac1108f3e3d7d3ffbe2eccab))
|
||||
* **ignore:** bump ws from 8.21.0 to 8.21.1 in the minor-patch group across 1 directory ([#32624](https://github.com/Koenkk/zigbee2mqtt/issues/32624)) ([bbad134](https://github.com/Koenkk/zigbee2mqtt/commit/bbad134e8ea420200e6b5f75a17bd5a5a9d601fe))
|
||||
* **ignore:** bump zigbee2mqtt-windfront from 2.12.1 to 2.13.0 in the minor-patch group ([#32550](https://github.com/Koenkk/zigbee2mqtt/issues/32550)) ([66db1bd](https://github.com/Koenkk/zigbee2mqtt/commit/66db1bdbcd8b6303c82d7c77679860537b0868e1))
|
||||
* **ignore:** Remove attribute_and_json warnings for HA ([#32616](https://github.com/Koenkk/zigbee2mqtt/issues/32616)) ([5e56c45](https://github.com/Koenkk/zigbee2mqtt/commit/5e56c454c3fee03c43152c807ec3e449d6211728))
|
||||
* **ignore:** update zigbee-herdsman to 10.6.2 ([#32500](https://github.com/Koenkk/zigbee2mqtt/issues/32500)) ([6e55716](https://github.com/Koenkk/zigbee2mqtt/commit/6e557162cbf548735c5d907231cec69fa94f6c53))
|
||||
* **ignore:** update zigbee-herdsman to 10.6.3 ([#32623](https://github.com/Koenkk/zigbee2mqtt/issues/32623)) ([b8d388c](https://github.com/Koenkk/zigbee2mqtt/commit/b8d388ca7afce29cc6f09c452cbe5c01e79d8244))
|
||||
* **ignore:** update zigbee-herdsman to 10.8.0 ([#32701](https://github.com/Koenkk/zigbee2mqtt/issues/32701)) ([28e410b](https://github.com/Koenkk/zigbee2mqtt/commit/28e410bbf442f32e9be4298d19fe45ac586b8a88))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.77.0 ([#32462](https://github.com/Koenkk/zigbee2mqtt/issues/32462)) ([4b0c306](https://github.com/Koenkk/zigbee2mqtt/commit/4b0c3067ffeef42d66417445480048a8e546bc68))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.78.0 ([#32489](https://github.com/Koenkk/zigbee2mqtt/issues/32489)) ([f88d992](https://github.com/Koenkk/zigbee2mqtt/commit/f88d99294b8dc42f8657673e80b7ed721220db40))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.79.0 ([#32499](https://github.com/Koenkk/zigbee2mqtt/issues/32499)) ([a2973f2](https://github.com/Koenkk/zigbee2mqtt/commit/a2973f21f6cfebc175b6b1acfcb73ca7cab95c5f))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.80.0 ([#32522](https://github.com/Koenkk/zigbee2mqtt/issues/32522)) ([912fe4c](https://github.com/Koenkk/zigbee2mqtt/commit/912fe4c250f514f6a0e3c97cf1438b15cef1ec6a))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.81.0 ([#32543](https://github.com/Koenkk/zigbee2mqtt/issues/32543)) ([e475de1](https://github.com/Koenkk/zigbee2mqtt/commit/e475de15c901c493dc93f4194fe55b9359712ce3))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.82.0 ([#32572](https://github.com/Koenkk/zigbee2mqtt/issues/32572)) ([576e195](https://github.com/Koenkk/zigbee2mqtt/commit/576e1954efcbe39d8209284e37e4df223a3f37a8))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.83.0 ([#32581](https://github.com/Koenkk/zigbee2mqtt/issues/32581)) ([22d85a6](https://github.com/Koenkk/zigbee2mqtt/commit/22d85a6729f5bbba72308c58123b0b6345f6095a))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.84.0 ([#32598](https://github.com/Koenkk/zigbee2mqtt/issues/32598)) ([e78b23f](https://github.com/Koenkk/zigbee2mqtt/commit/e78b23f5ebeb388402cae31db982759385e74fc5))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.85.0 ([#32612](https://github.com/Koenkk/zigbee2mqtt/issues/32612)) ([752ab37](https://github.com/Koenkk/zigbee2mqtt/commit/752ab37302cffaa2cad664e5f43c61451e099ee6))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.86.0 ([756a824](https://github.com/Koenkk/zigbee2mqtt/commit/756a824488377f973b710d6293b8deadc310ce42))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.87.0 ([#32660](https://github.com/Koenkk/zigbee2mqtt/issues/32660)) ([e6e0b6f](https://github.com/Koenkk/zigbee2mqtt/commit/e6e0b6f8f2b498b771625019b52d42ccbd4aa967))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.88.0 ([#32674](https://github.com/Koenkk/zigbee2mqtt/issues/32674)) ([3bec9e8](https://github.com/Koenkk/zigbee2mqtt/commit/3bec9e87b17985b59e8a50d3c27677056838d26b))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.89.0 ([#32700](https://github.com/Koenkk/zigbee2mqtt/issues/32700)) ([b29fc0e](https://github.com/Koenkk/zigbee2mqtt/commit/b29fc0e49352f583d418a36bc6708d5745d1a788))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.90.0 ([#32715](https://github.com/Koenkk/zigbee2mqtt/issues/32715)) ([e5e36ac](https://github.com/Koenkk/zigbee2mqtt/commit/e5e36ac79c2e1b89ac0fb81f95bcd655455531c1))
|
||||
* Publish groups on device leave ([#32676](https://github.com/Koenkk/zigbee2mqtt/issues/32676)) ([fe96204](https://github.com/Koenkk/zigbee2mqtt/commit/fe96204f0b0f2b858ec664d408eb21545dc791d2))
|
||||
* Refresh exposes after manual device configure ([#32486](https://github.com/Koenkk/zigbee2mqtt/issues/32486)) ([344776b](https://github.com/Koenkk/zigbee2mqtt/commit/344776b63379ab91bf7b35e9c208cff0f88bb84b))
|
||||
* Remove json-stable-stringify-without-jsonify dep ([#32643](https://github.com/Koenkk/zigbee2mqtt/issues/32643)) ([4f18b4e](https://github.com/Koenkk/zigbee2mqtt/commit/4f18b4e38c276c515eb3b132e3328b88ff62bf95))
|
||||
* Replace jszip with fflate ([#32683](https://github.com/Koenkk/zigbee2mqtt/issues/32683)) ([316413b](https://github.com/Koenkk/zigbee2mqtt/commit/316413b31c760c4b34c5afb14222bca11236d9d0))
|
||||
* replace rimraf with native fs.rmSync ([#32579](https://github.com/Koenkk/zigbee2mqtt/issues/32579)) ([a9ce4b2](https://github.com/Koenkk/zigbee2mqtt/commit/a9ce4b2522c2d0a5bfcae3497cd98d3889b6f440))
|
||||
* Replace source-map-support with native Node source map support ([#32620](https://github.com/Koenkk/zigbee2mqtt/issues/32620)) ([2d94100](https://github.com/Koenkk/zigbee2mqtt/commit/2d941000a73e83fbee983e98121510a6710183fc))
|
||||
* Support Node 26, remove Node 20 support ([#32508](https://github.com/Koenkk/zigbee2mqtt/issues/32508)) ([5591207](https://github.com/Koenkk/zigbee2mqtt/commit/5591207deaea177c0019fe679f4e197e2efcb286))
|
||||
|
||||
## [2.12.1](https://github.com/Koenkk/zigbee2mqtt/compare/2.12.0...2.12.1) (2026-06-30)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add `mqtt.server_name` option to override TLS SNI ([#32417](https://github.com/Koenkk/zigbee2mqtt/issues/32417)) ([2f26083](https://github.com/Koenkk/zigbee2mqtt/commit/2f26083ce13c30227ac8d1d891751d4c253b3572))
|
||||
* Docker: bump alpine from 3.23 to 3.24 ([#32311](https://github.com/Koenkk/zigbee2mqtt/issues/32311)) ([758a80b](https://github.com/Koenkk/zigbee2mqtt/commit/758a80b30c5162b9adb0c2be1785670276905e09))
|
||||
* Home Assistant: add conductivity discovery for soil_fertility ([#32356](https://github.com/Koenkk/zigbee2mqtt/issues/32356)) ([29ee3a7](https://github.com/Koenkk/zigbee2mqtt/commit/29ee3a7552017e648c534708a93329e8b3fc52c5))
|
||||
* Home Assistant: allow discovery user overrides to win over converter ([#32361](https://github.com/Koenkk/zigbee2mqtt/issues/32361)) ([20ab0a2](https://github.com/Koenkk/zigbee2mqtt/commit/20ab0a2a4f44b760b6db73d105dc8578426b27a0))
|
||||
* Home Assistant: apply expose-level Home Assistant discovery metadata ([#32380](https://github.com/Koenkk/zigbee2mqtt/issues/32380)) ([0140708](https://github.com/Koenkk/zigbee2mqtt/commit/0140708076fbd99d33d896e8f214b0e2a8aad8f3))
|
||||
* Home Assistant: mark device settings as config ([#32439](https://github.com/Koenkk/zigbee2mqtt/issues/32439)) ([e8a7e6f](https://github.com/Koenkk/zigbee2mqtt/commit/e8a7e6f29da3773ffc2bda63623298a307fbb597))
|
||||
* Home Assistant: mark legacy action sensors diagnostic ([#32377](https://github.com/Koenkk/zigbee2mqtt/issues/32377)) ([72be1d7](https://github.com/Koenkk/zigbee2mqtt/commit/72be1d746bb79fda65b056e4441dcd73bf6c69c6))
|
||||
* Home Assistant: mark thermostat configuration switches as config ([#32378](https://github.com/Koenkk/zigbee2mqtt/issues/32378)) ([98a0f94](https://github.com/Koenkk/zigbee2mqtt/commit/98a0f9487072cf5a64a901f305c20a73e25c594b))
|
||||
* Home Assistant: pass device options to HA discovery overrides ([#32379](https://github.com/Koenkk/zigbee2mqtt/issues/32379)) ([814552a](https://github.com/Koenkk/zigbee2mqtt/commit/814552a615d33ac24c38160f7a24514c7b358623))
|
||||
* Home Assistant: support cooling setpoint in climate discovery ([#32411](https://github.com/Koenkk/zigbee2mqtt/issues/32411)) ([0018ca8](https://github.com/Koenkk/zigbee2mqtt/commit/0018ca8f809708d22221c9d557b3208ba2e5c057))
|
||||
* Home Assistant: unit conversion for derived weather sensors by restoring device_class with name preservation ([#32392](https://github.com/Koenkk/zigbee2mqtt/issues/32392)) ([7775ac5](https://github.com/Koenkk/zigbee2mqtt/commit/7775ac5cd2ae3929580e401640d97b20facf1491))
|
||||
* **ignore:** bump actions/cache from 5 to 6 ([#32394](https://github.com/Koenkk/zigbee2mqtt/issues/32394)) ([280442b](https://github.com/Koenkk/zigbee2mqtt/commit/280442b6710c03cfa4776d69a14396b29d32c4ba))
|
||||
* **ignore:** bump actions/checkout from 6 to 7 ([#32368](https://github.com/Koenkk/zigbee2mqtt/issues/32368)) ([679436e](https://github.com/Koenkk/zigbee2mqtt/commit/679436e09b40edea5bb17094e9aa9115733b916f))
|
||||
* **ignore:** bump js-yaml from 4.2.0 to 5.0.0 ([#32371](https://github.com/Koenkk/zigbee2mqtt/issues/32371)) ([262139a](https://github.com/Koenkk/zigbee2mqtt/commit/262139a43f878ceef2f327369432e33b74fbd972))
|
||||
* **ignore:** bump semver from 7.8.4 to 7.8.5 in the minor-patch group ([#32369](https://github.com/Koenkk/zigbee2mqtt/issues/32369)) ([9a7cac1](https://github.com/Koenkk/zigbee2mqtt/commit/9a7cac1e4c82c1b0ef566b13620e2d4d438d725e))
|
||||
* **ignore:** bump the minor-patch group with 3 updates ([#32312](https://github.com/Koenkk/zigbee2mqtt/issues/32312)) ([719de7a](https://github.com/Koenkk/zigbee2mqtt/commit/719de7adcfffc64e2e9133142045e5dd56e4e8d4))
|
||||
* **ignore:** update zigbee-herdsman to 10.4.1 ([#32333](https://github.com/Koenkk/zigbee2mqtt/issues/32333)) ([2f44449](https://github.com/Koenkk/zigbee2mqtt/commit/2f4444995361257cbeef0c780532381ec6cc254f))
|
||||
* **ignore:** update zigbee-herdsman to 10.4.2 ([#32388](https://github.com/Koenkk/zigbee2mqtt/issues/32388)) ([628f97f](https://github.com/Koenkk/zigbee2mqtt/commit/628f97f498747cd2e6be5808c7c609b948af528f))
|
||||
* **ignore:** update zigbee-herdsman to 10.5.0 ([#32406](https://github.com/Koenkk/zigbee2mqtt/issues/32406)) ([6ab52df](https://github.com/Koenkk/zigbee2mqtt/commit/6ab52df08af1e9b80eb4b58752b7f403d52844a7))
|
||||
* **ignore:** update zigbee-herdsman to 10.6.0 ([#32422](https://github.com/Koenkk/zigbee2mqtt/issues/32422)) ([dcb16b8](https://github.com/Koenkk/zigbee2mqtt/commit/dcb16b8271349390deee9dc0a0ca4d02239a54bc))
|
||||
* **ignore:** update zigbee-herdsman to 10.6.1 ([#32442](https://github.com/Koenkk/zigbee2mqtt/issues/32442)) ([f867694](https://github.com/Koenkk/zigbee2mqtt/commit/f86769483102762798272655c4fb117f33263489))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.64.0 ([#32282](https://github.com/Koenkk/zigbee2mqtt/issues/32282)) ([a68419f](https://github.com/Koenkk/zigbee2mqtt/commit/a68419f0f58324c1809055938cc59bdd07442aa9))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.65.0 ([#32298](https://github.com/Koenkk/zigbee2mqtt/issues/32298)) ([f4f97d1](https://github.com/Koenkk/zigbee2mqtt/commit/f4f97d19208ab951256b69fd7673bfaba667af65))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.66.0 ([#32315](https://github.com/Koenkk/zigbee2mqtt/issues/32315)) ([299b9e6](https://github.com/Koenkk/zigbee2mqtt/commit/299b9e64f52c99d1d3034321e82fc40a49bec604))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.67.0 ([#32334](https://github.com/Koenkk/zigbee2mqtt/issues/32334)) ([95c67c0](https://github.com/Koenkk/zigbee2mqtt/commit/95c67c0e2271e24a236dabe717772c16f52a3017))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.68.0 ([#32340](https://github.com/Koenkk/zigbee2mqtt/issues/32340)) ([1de8609](https://github.com/Koenkk/zigbee2mqtt/commit/1de860945645e748d6173a0368c4ccb44e9f5f13))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.69.0 ([#32350](https://github.com/Koenkk/zigbee2mqtt/issues/32350)) ([0377f96](https://github.com/Koenkk/zigbee2mqtt/commit/0377f965a3a3c21e42afb885f8b3685d6de2fdd1))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.70.0 ([#32357](https://github.com/Koenkk/zigbee2mqtt/issues/32357)) ([c6e7d2d](https://github.com/Koenkk/zigbee2mqtt/commit/c6e7d2dd16b1a7369b8f784caf3ceed6296a9da2))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.71.0 ([#32375](https://github.com/Koenkk/zigbee2mqtt/issues/32375)) ([8768952](https://github.com/Koenkk/zigbee2mqtt/commit/87689523ae685258fa20ee8c03b7a09e8df0f4ba))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.72.0 ([#32387](https://github.com/Koenkk/zigbee2mqtt/issues/32387)) ([b58503b](https://github.com/Koenkk/zigbee2mqtt/commit/b58503bb57054e24855bcde353b43be6a61273c6))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.73.0 ([#32407](https://github.com/Koenkk/zigbee2mqtt/issues/32407)) ([8d5fdad](https://github.com/Koenkk/zigbee2mqtt/commit/8d5fdada38ce32f8e9ee86b3e39b2e00e2e76529))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.74.0 ([#32420](https://github.com/Koenkk/zigbee2mqtt/issues/32420)) ([31fb3a6](https://github.com/Koenkk/zigbee2mqtt/commit/31fb3a6e895170993e893e9ea0d63c6600859259))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.75.0 ([#32431](https://github.com/Koenkk/zigbee2mqtt/issues/32431)) ([3760e63](https://github.com/Koenkk/zigbee2mqtt/commit/3760e631835c8e6e11cea5241bb2c8cf3d374722))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.76.0 ([#32441](https://github.com/Koenkk/zigbee2mqtt/issues/32441)) ([740de2d](https://github.com/Koenkk/zigbee2mqtt/commit/740de2d363109707a90cdfa8c019361f4423a1aa))
|
||||
* improve zigbee2mqtt maintenance path ([#32255](https://github.com/Koenkk/zigbee2mqtt/issues/32255)) ([ced6d93](https://github.com/Koenkk/zigbee2mqtt/commit/ced6d93d20502f115d4d0b17b5d292fee7ad30e4))
|
||||
* Republish bridge/state online when HA comes online ([#32258](https://github.com/Koenkk/zigbee2mqtt/issues/32258)) ([a81e90b](https://github.com/Koenkk/zigbee2mqtt/commit/a81e90b9812acd795a11a78b6d0489c080dc8f5b))
|
||||
|
||||
## [2.12.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.11.0...2.12.0) (2026-06-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add ability to abort running OTA ([#32022](https://github.com/Koenkk/zigbee2mqtt/issues/32022)) ([de58711](https://github.com/Koenkk/zigbee2mqtt/commit/de58711e11a5ca07c5b55a0c17a1d9f3f452c110))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ignore:** bump the minor-patch group across 1 directory with 3 updates ([#32236](https://github.com/Koenkk/zigbee2mqtt/issues/32236)) ([41f6886](https://github.com/Koenkk/zigbee2mqtt/commit/41f688659119012a9a84063bd67c2769a1b1892c))
|
||||
* **ignore:** bump the minor-patch group with 2 updates ([#32183](https://github.com/Koenkk/zigbee2mqtt/issues/32183)) ([ef8dcb4](https://github.com/Koenkk/zigbee2mqtt/commit/ef8dcb4dc2800defd97fda7523297eab8d2566dc))
|
||||
* **ignore:** missing type for new OTA endpoint ([#32195](https://github.com/Koenkk/zigbee2mqtt/issues/32195)) ([6c2c874](https://github.com/Koenkk/zigbee2mqtt/commit/6c2c874641ada9cae1c9bdd963a3201cb1cf0fef))
|
||||
* **ignore:** update zigbee-herdsman to 10.2.0 ([#32179](https://github.com/Koenkk/zigbee2mqtt/issues/32179)) ([f9e1bac](https://github.com/Koenkk/zigbee2mqtt/commit/f9e1bac588e65b223e75f4a2b2e9d528933fdbd8))
|
||||
* **ignore:** update zigbee-herdsman to 10.3.0 ([#32193](https://github.com/Koenkk/zigbee2mqtt/issues/32193)) ([ea6ccac](https://github.com/Koenkk/zigbee2mqtt/commit/ea6ccac9380af1d9f5672174b2545409f36c2769))
|
||||
* **ignore:** update zigbee-herdsman to 10.4.0 ([#32249](https://github.com/Koenkk/zigbee2mqtt/issues/32249)) ([582f153](https://github.com/Koenkk/zigbee2mqtt/commit/582f1538961af9c1b6434e6a1bc4acebc63a2541))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.61.2 ([#32180](https://github.com/Koenkk/zigbee2mqtt/issues/32180)) ([babf7a1](https://github.com/Koenkk/zigbee2mqtt/commit/babf7a17d4617217297eef45f6b8307265f9b921))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.62.0 ([#32194](https://github.com/Koenkk/zigbee2mqtt/issues/32194)) ([120f6ac](https://github.com/Koenkk/zigbee2mqtt/commit/120f6ac779c9fda6914fc68c56674e589c0e82ba))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.63.0 ([#32252](https://github.com/Koenkk/zigbee2mqtt/issues/32252)) ([68daf4f](https://github.com/Koenkk/zigbee2mqtt/commit/68daf4f1deafad3ee6e542acbd77d403340e2caa))
|
||||
|
||||
## [2.11.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.10.1...2.11.0) (2026-06-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* allow to disable external JS extensions ([#31826](https://github.com/Koenkk/zigbee2mqtt/issues/31826)) ([15fd9b3](https://github.com/Koenkk/zigbee2mqtt/commit/15fd9b371e30ed352cf2de2d241a0d0f7fafa9d1))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps-dev:** bump tmp from 0.2.5 to 0.2.6 ([#32122](https://github.com/Koenkk/zigbee2mqtt/issues/32122)) ([7b9407d](https://github.com/Koenkk/zigbee2mqtt/commit/7b9407d020b9db1093b360f5f6e217bbab7afb63))
|
||||
* **deps:** bump actions/dependency-review-action from 4 to 5 ([#31980](https://github.com/Koenkk/zigbee2mqtt/issues/31980)) ([4d9e950](https://github.com/Koenkk/zigbee2mqtt/commit/4d9e9504ef9c857a1985c79d3a6799552abc3aea))
|
||||
* **deps:** bump brace-expansion from 2.0.2 to 5.0.6 ([#32038](https://github.com/Koenkk/zigbee2mqtt/issues/32038)) ([897aa8f](https://github.com/Koenkk/zigbee2mqtt/commit/897aa8fb7c82dbc9c906f986477c4c1673bb3199))
|
||||
* **deps:** bump pnpm/action-setup from 5 to 6 ([#31698](https://github.com/Koenkk/zigbee2mqtt/issues/31698)) ([1a1c094](https://github.com/Koenkk/zigbee2mqtt/commit/1a1c09449c9d077ea06048d7e2ecea90ff850c9b))
|
||||
* **deps:** bump ws from 8.20.0 to 8.20.1 ([#32041](https://github.com/Koenkk/zigbee2mqtt/issues/32041)) ([bf964cc](https://github.com/Koenkk/zigbee2mqtt/commit/bf964cc6d976d70e687844e5a09c4577763dc0d3))
|
||||
* Fix default value of "optimistic" group setting ([#32054](https://github.com/Koenkk/zigbee2mqtt/issues/32054)) ([b31e8c2](https://github.com/Koenkk/zigbee2mqtt/commit/b31e8c286e7a77517a88e3e7eb2247e9a0fd2cc1))
|
||||
* **ignore:** bump the minor-patch group with 2 updates ([#32137](https://github.com/Koenkk/zigbee2mqtt/issues/32137)) ([ac8c45f](https://github.com/Koenkk/zigbee2mqtt/commit/ac8c45f67f0a4aae2688e55b98dabdc23f73a259))
|
||||
* **ignore:** bump the minor-patch group with 3 updates ([#31982](https://github.com/Koenkk/zigbee2mqtt/issues/31982)) ([9426c3e](https://github.com/Koenkk/zigbee2mqtt/commit/9426c3ea118de9e60326e8dc257a0c2474380f8c))
|
||||
* **ignore:** bump the minor-patch group with 3 updates ([#32106](https://github.com/Koenkk/zigbee2mqtt/issues/32106)) ([e5f70d5](https://github.com/Koenkk/zigbee2mqtt/commit/e5f70d5ba82923f9f37fbda79bed08d581706129))
|
||||
* **ignore:** update zigbee-herdsman to 10.0.8 ([#31945](https://github.com/Koenkk/zigbee2mqtt/issues/31945)) ([04a4365](https://github.com/Koenkk/zigbee2mqtt/commit/04a43652d6e131694e29e06ca646eeac37ed3c7d))
|
||||
* **ignore:** update zigbee-herdsman to 10.1.0 ([#32027](https://github.com/Koenkk/zigbee2mqtt/issues/32027)) ([36c4db7](https://github.com/Koenkk/zigbee2mqtt/commit/36c4db7f8f4e3438f6ebb82cd6ecadf6c54e231e))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.47.0 ([#31946](https://github.com/Koenkk/zigbee2mqtt/issues/31946)) ([7a05464](https://github.com/Koenkk/zigbee2mqtt/commit/7a05464f0355380ee855415bc270632263e3f5d3))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.48.0 ([#31959](https://github.com/Koenkk/zigbee2mqtt/issues/31959)) ([502030c](https://github.com/Koenkk/zigbee2mqtt/commit/502030c0168c8ba6eaf7495a9014489f2f7a7c50))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.49.0 ([#31976](https://github.com/Koenkk/zigbee2mqtt/issues/31976)) ([32499d1](https://github.com/Koenkk/zigbee2mqtt/commit/32499d14d5f0c9eada160d596d6be92da6c4af95))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.50.0 ([#31988](https://github.com/Koenkk/zigbee2mqtt/issues/31988)) ([6ea21c3](https://github.com/Koenkk/zigbee2mqtt/commit/6ea21c383b4801f28e30b022194e71375db94b17))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.51.0 ([#31999](https://github.com/Koenkk/zigbee2mqtt/issues/31999)) ([744e4a6](https://github.com/Koenkk/zigbee2mqtt/commit/744e4a6d5c4dd179e7c3d1a2b4fbcdc820195547))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.51.1 ([#32016](https://github.com/Koenkk/zigbee2mqtt/issues/32016)) ([5458e50](https://github.com/Koenkk/zigbee2mqtt/commit/5458e5084def411aaad9af08e415acb6bc16d181))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.52.0 ([#32040](https://github.com/Koenkk/zigbee2mqtt/issues/32040)) ([1e114de](https://github.com/Koenkk/zigbee2mqtt/commit/1e114dee70b8079eda31d4d0cc52fa073a772798))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.53.0 ([#32051](https://github.com/Koenkk/zigbee2mqtt/issues/32051)) ([52201c9](https://github.com/Koenkk/zigbee2mqtt/commit/52201c96bf897af9bcad30435575923a0452b6dd))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.54.0 ([#32065](https://github.com/Koenkk/zigbee2mqtt/issues/32065)) ([2c5b536](https://github.com/Koenkk/zigbee2mqtt/commit/2c5b53613448ae4c6548f874a36acc1226d53ec3))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.55.0 ([#32068](https://github.com/Koenkk/zigbee2mqtt/issues/32068)) ([7075b78](https://github.com/Koenkk/zigbee2mqtt/commit/7075b78a462a0d1f76cf91cc78601d90edfd8b4e))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.56.0 ([#32087](https://github.com/Koenkk/zigbee2mqtt/issues/32087)) ([56aab4e](https://github.com/Koenkk/zigbee2mqtt/commit/56aab4e59c4ea7bdbd93a1ed6632171994d8ed56))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.57.0 ([#32113](https://github.com/Koenkk/zigbee2mqtt/issues/32113)) ([7a1c2d5](https://github.com/Koenkk/zigbee2mqtt/commit/7a1c2d52f0b0699854f3c8c9375761cb0b22ac55))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.58.0 ([#32126](https://github.com/Koenkk/zigbee2mqtt/issues/32126)) ([e2b4911](https://github.com/Koenkk/zigbee2mqtt/commit/e2b49113c7932c4cd8ef7b23a27e3cd5d4e7150d))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.59.1 ([#32136](https://github.com/Koenkk/zigbee2mqtt/issues/32136)) ([a8364ad](https://github.com/Koenkk/zigbee2mqtt/commit/a8364ad67b0dcbde93b1d6445c269a821321e9b3))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.60.0 ([#32150](https://github.com/Koenkk/zigbee2mqtt/issues/32150)) ([c6e13cc](https://github.com/Koenkk/zigbee2mqtt/commit/c6e13ccb45666f9604e8c9c9655bbe7ea682712d))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.61.1 ([#32156](https://github.com/Koenkk/zigbee2mqtt/issues/32156)) ([58feb78](https://github.com/Koenkk/zigbee2mqtt/commit/58feb783626fd2429bea0f8a0db37bdc107c431b))
|
||||
* Prevent invalid external JS file name on save ([#32037](https://github.com/Koenkk/zigbee2mqtt/issues/32037)) ([bbcbed1](https://github.com/Koenkk/zigbee2mqtt/commit/bbcbed12d53408eaa8ceec110c7071fff8e158e4))
|
||||
* Use Jinja-safe property access in HA discovery templates ([#31930](https://github.com/Koenkk/zigbee2mqtt/issues/31930)) ([c4fd415](https://github.com/Koenkk/zigbee2mqtt/commit/c4fd415cbe7b01719679b1a96d2b6e07b0793125))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Improve OTA ([#30566](https://github.com/Koenkk/zigbee2mqtt/issues/30566)) ([dd1c449](https://github.com/Koenkk/zigbee2mqtt/commit/dd1c44979667360d77f05ac30729011f9f2d6cd6))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Home Assisant: set state class to `measurement` for `illuminane_raw` https://github.com/Koenkk/zigbee2mqtt/issues/30439 ([3e60f91](https://github.com/Koenkk/zigbee2mqtt/commit/3e60f915ffe1de640690695ccf6536ad235c336e))
|
||||
* Home Assistant: add `gas` device class ([#30653](https://github.com/Koenkk/zigbee2mqtt/issues/30653)) ([4815d51](https://github.com/Koenkk/zigbee2mqtt/commit/4815d5149d3fbc70fe374fc4aa99213ac5f8a8c7))
|
||||
* Home Assistant: Change `device_class` of `eco2` to `volatile_organic_compounds_parts` ([#30840](https://github.com/Koenkk/zigbee2mqtt/issues/30840)) ([4f412cb](https://github.com/Koenkk/zigbee2mqtt/commit/4f412cb4da6217fcca57b5ea1afc40d34d662e19))
|
||||
* Home Assistant: discover temperature sensor for thermostats ([#30804](https://github.com/Koenkk/zigbee2mqtt/issues/30804)) ([26a0fd4](https://github.com/Koenkk/zigbee2mqtt/commit/26a0fd4de0f39d10a404517f42d4c37aff1eff8e))
|
||||
* Home Assistant: expose group settings override ([#30627](https://github.com/Koenkk/zigbee2mqtt/issues/30627)) ([6f0b02e](https://github.com/Koenkk/zigbee2mqtt/commit/6f0b02e0093cc7fd61d30c00fb1cd61d0cfdf09f))
|
||||
* Home Assistant: mark `load_estimate` as `state_class` `measurement` https://github.com/Koenkk/zigbee-herdsman-converters/issues/11240 ([a32c8ee](https://github.com/Koenkk/zigbee2mqtt/commit/a32c8eec3604a3ad0d16e87edf027581db7f6920))
|
||||
* Home Assistant: treat `Area1-4Occupancy` as `occupancy` ([#30712](https://github.com/Koenkk/zigbee2mqtt/issues/30712)) ([2e36084](https://github.com/Koenkk/zigbee2mqtt/commit/2e36084852e84f26dd375820a87c0a8520d4c3e7))
|
||||
* Home Assistant: Use `temperature_delta` for calibration ([#30784](https://github.com/Koenkk/zigbee2mqtt/issues/30784)) ([8e68295](https://github.com/Koenkk/zigbee2mqtt/commit/8e682956e5977101ed4ca80e795f413a5bfb4f09))
|
||||
* **ignore:** bump @biomejs/biome from 2.3.10 to 2.3.11 in the minor-patch group ([#30511](https://github.com/Koenkk/zigbee2mqtt/issues/30511)) ([ed52c49](https://github.com/Koenkk/zigbee2mqtt/commit/ed52c49e34a200f9adf5deb2065c78fbe8df3189))
|
||||
* **ignore:** bump @biomejs/biome from 2.3.12 to 2.3.13 in the minor-patch group ([#30810](https://github.com/Koenkk/zigbee2mqtt/issues/30810)) ([14bffd5](https://github.com/Koenkk/zigbee2mqtt/commit/14bffd58b1ae90cf5c32b654ddbb7a43d12a36cc))
|
||||
* **ignore:** bump @types/node from 24.10.7 to 24.10.9 in the minor-patch group ([#30713](https://github.com/Koenkk/zigbee2mqtt/issues/30713)) ([043dd0f](https://github.com/Koenkk/zigbee2mqtt/commit/043dd0ff1cc94ffa8ea59f6de674e0363f9f6920))
|
||||
* **ignore:** bump the minor-patch group with 2 updates ([#30617](https://github.com/Koenkk/zigbee2mqtt/issues/30617)) ([1d87d2f](https://github.com/Koenkk/zigbee2mqtt/commit/1d87d2f2349f9eed6351a032645eec075476f148))
|
||||
* **ignore:** bump the minor-patch group with 2 updates ([#30790](https://github.com/Koenkk/zigbee2mqtt/issues/30790)) ([da1f77e](https://github.com/Koenkk/zigbee2mqtt/commit/da1f77ec6eb599a480f66cc82edb12acc6c22ca8))
|
||||
* **ignore:** bump zigbee2mqtt-windfront from 2.8.0 to 2.8.1 in the minor-patch group ([#30879](https://github.com/Koenkk/zigbee2mqtt/issues/30879)) ([9ddbb2a](https://github.com/Koenkk/zigbee2mqtt/commit/9ddbb2a21a0d302ed39ac4c658587d9f33c48960))
|
||||
* **ignore:** bump zigbee2mqtt-windfront in the minor-patch group ([8d980ae](https://github.com/Koenkk/zigbee2mqtt/commit/8d980ae6ecd759c6c6fb42fc7fbb1d701a3317fa))
|
||||
* **ignore:** Replace multiple area occupancy fields with camelCase instead of PascalCase ([#30745](https://github.com/Koenkk/zigbee2mqtt/issues/30745)) ([d9e4244](https://github.com/Koenkk/zigbee2mqtt/commit/d9e4244f801aaa24b8347ab684521391df1ce0e2))
|
||||
* **ignore:** update zigbee-herdsman to 8.0.2 ([#30480](https://github.com/Koenkk/zigbee2mqtt/issues/30480)) ([1147cf1](https://github.com/Koenkk/zigbee2mqtt/commit/1147cf16dd62dce5863a93983835c46e8e92e95d))
|
||||
* **ignore:** update zigbee-herdsman to 8.0.3 ([#30632](https://github.com/Koenkk/zigbee2mqtt/issues/30632)) ([d44cdbd](https://github.com/Koenkk/zigbee2mqtt/commit/d44cdbde4f5247f69af4c79fd45a62c03037eca7))
|
||||
* **ignore:** update zigbee-herdsman to 8.1.0 ([#30725](https://github.com/Koenkk/zigbee2mqtt/issues/30725)) ([9d266bd](https://github.com/Koenkk/zigbee2mqtt/commit/9d266bda9a34efbe726f8d984da48e5ba3c46a83))
|
||||
* **ignore:** update zigbee-herdsman to 9.0.0 ([#30787](https://github.com/Koenkk/zigbee2mqtt/issues/30787)) ([971ddaa](https://github.com/Koenkk/zigbee2mqtt/commit/971ddaa3cbc2132150f7cde4cdbe0575349eabac))
|
||||
* **ignore:** update zigbee-herdsman to 9.0.1 ([#30788](https://github.com/Koenkk/zigbee2mqtt/issues/30788)) ([6d5e7e3](https://github.com/Koenkk/zigbee2mqtt/commit/6d5e7e334a9a560b9f6293cf448ad84de8504b67))
|
||||
* **ignore:** update zigbee-herdsman to 9.0.2 ([10478e7](https://github.com/Koenkk/zigbee2mqtt/commit/10478e797e1733f9bf66defce0e84b6cc1f23020))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.100.0 ([#30499](https://github.com/Koenkk/zigbee2mqtt/issues/30499)) ([a2b81ab](https://github.com/Koenkk/zigbee2mqtt/commit/a2b81ab89491f8babf40001404969817f19c5b5e))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.101.0 ([#30520](https://github.com/Koenkk/zigbee2mqtt/issues/30520)) ([4313bac](https://github.com/Koenkk/zigbee2mqtt/commit/4313baca09b8476466b8134c551b59f92996b53f))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.102.0 ([#30537](https://github.com/Koenkk/zigbee2mqtt/issues/30537)) ([57245e5](https://github.com/Koenkk/zigbee2mqtt/commit/57245e52343568678a4dace9bf569c05f508decc))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.103.0 ([#30552](https://github.com/Koenkk/zigbee2mqtt/issues/30552)) ([d4522c5](https://github.com/Koenkk/zigbee2mqtt/commit/d4522c53caca86ba3a087fa67839e2ebf5d9deca))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.104.0 ([#30564](https://github.com/Koenkk/zigbee2mqtt/issues/30564)) ([e68403d](https://github.com/Koenkk/zigbee2mqtt/commit/e68403d8d3ac68f2d09df066d1be8db87f2e7977))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.105.0 ([#30605](https://github.com/Koenkk/zigbee2mqtt/issues/30605)) ([29c7571](https://github.com/Koenkk/zigbee2mqtt/commit/29c757145d6ed16a8629b68379cd85fabe45a8f1))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.106.0 ([#30631](https://github.com/Koenkk/zigbee2mqtt/issues/30631)) ([9a41ec2](https://github.com/Koenkk/zigbee2mqtt/commit/9a41ec2a39ca2373dbdcb15b09aa0eb09116c31e))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.107.0 ([#30662](https://github.com/Koenkk/zigbee2mqtt/issues/30662)) ([535f75e](https://github.com/Koenkk/zigbee2mqtt/commit/535f75e90ca936afcac0c79e4c2452dce7b9b08d))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.108.0 ([#30672](https://github.com/Koenkk/zigbee2mqtt/issues/30672)) ([5e1b7d4](https://github.com/Koenkk/zigbee2mqtt/commit/5e1b7d4830b5597eb993f32e3a6d1edd84b48e4c))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.109.0 ([#30709](https://github.com/Koenkk/zigbee2mqtt/issues/30709)) ([ee6afaf](https://github.com/Koenkk/zigbee2mqtt/commit/ee6afaf1c608b89efef452d7cb337345b035d76a))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.110.0 ([#30726](https://github.com/Koenkk/zigbee2mqtt/issues/30726)) ([20af502](https://github.com/Koenkk/zigbee2mqtt/commit/20af50201ef1e081223ee8b2f0b6304777c7b7f1))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.111.0 ([#30739](https://github.com/Koenkk/zigbee2mqtt/issues/30739)) ([2bb44f7](https://github.com/Koenkk/zigbee2mqtt/commit/2bb44f7a8b8bd9fc1a21b127b764a5d697e65c3e))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.112.0 ([#30767](https://github.com/Koenkk/zigbee2mqtt/issues/30767)) ([ad51d12](https://github.com/Koenkk/zigbee2mqtt/commit/ad51d12a2d1325e0b0c1b56a633b229f0f3d048b))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.113.0 ([#30789](https://github.com/Koenkk/zigbee2mqtt/issues/30789)) ([036ee47](https://github.com/Koenkk/zigbee2mqtt/commit/036ee471ddfda094a3ae3f970dedb5600c9d8808))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.114.0 ([#30803](https://github.com/Koenkk/zigbee2mqtt/issues/30803)) ([999df29](https://github.com/Koenkk/zigbee2mqtt/commit/999df2913ff7e47fe9438ed9398a0922c0b40916))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.115.0 ([#30826](https://github.com/Koenkk/zigbee2mqtt/issues/30826)) ([99e06a8](https://github.com/Koenkk/zigbee2mqtt/commit/99e06a8d3ea66ed2a6acf6cddece2f2070cce6ae))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.116.0 ([#30839](https://github.com/Koenkk/zigbee2mqtt/issues/30839)) ([5a4d6b5](https://github.com/Koenkk/zigbee2mqtt/commit/5a4d6b56ad2ea62378b7dd161eba31916f08c65b))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.117.0 ([#30849](https://github.com/Koenkk/zigbee2mqtt/issues/30849)) ([4afbce6](https://github.com/Koenkk/zigbee2mqtt/commit/4afbce62ebc667fc7d94b8b23c0bb0125f065d79))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.118.0 ([#30865](https://github.com/Koenkk/zigbee2mqtt/issues/30865)) ([f688340](https://github.com/Koenkk/zigbee2mqtt/commit/f68834049cde265f731ab271b50fcfa6f85fa971))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.119.0 ([#30874](https://github.com/Koenkk/zigbee2mqtt/issues/30874)) ([788b0d3](https://github.com/Koenkk/zigbee2mqtt/commit/788b0d3993c877adc0361a4b2f9d2df79cea0b8d))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.120.0 ([#30876](https://github.com/Koenkk/zigbee2mqtt/issues/30876)) ([49b752d](https://github.com/Koenkk/zigbee2mqtt/commit/49b752da3527920dcdfae6a617f4aa084f6a8901))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.99.0 ([#30481](https://github.com/Koenkk/zigbee2mqtt/issues/30481)) ([3adf7d5](https://github.com/Koenkk/zigbee2mqtt/commit/3adf7d5d8ec6da3fabbd9f293d5d0bcaef3c9144))
|
||||
* Log error before renaming failed to load converters ([#30436](https://github.com/Koenkk/zigbee2mqtt/issues/30436)) ([1eaa2f3](https://github.com/Koenkk/zigbee2mqtt/commit/1eaa2f3e759351bc746dec2ccc57b92cdcf1b5d7))
|
||||
* Network map: escape double-quotes and backslashes in device attributes ([#30746](https://github.com/Koenkk/zigbee2mqtt/issues/30746)) ([5de47a0](https://github.com/Koenkk/zigbee2mqtt/commit/5de47a0d93f5fdc187e4f8a7489580a107a2fd49))
|
||||
* OTA availability detection ([#30815](https://github.com/Koenkk/zigbee2mqtt/issues/30815)) ([476efaa](https://github.com/Koenkk/zigbee2mqtt/commit/476efaa6e18a1d2ea069ea9c128536356df547ac))
|
||||
|
||||
## [2.7.2](https://github.com/Koenkk/zigbee2mqtt/compare/2.7.1...2.7.2) (2026-01-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Biome floating promises detection ([#30137](https://github.com/Koenkk/zigbee2mqtt/issues/30137)) ([0025ef8](https://github.com/Koenkk/zigbee2mqtt/commit/0025ef87c502970b94e1b720516d42631e21323c))
|
||||
* Don't fail to start when removing old log directory fails ([#30216](https://github.com/Koenkk/zigbee2mqtt/issues/30216)) ([b39b8d4](https://github.com/Koenkk/zigbee2mqtt/commit/b39b8d478e99c5a5ee5a3a610547168b3724ca91))
|
||||
* **ignore:** bump @types/node from 24.10.2 to 24.10.4 in the minor-patch group ([#30390](https://github.com/Koenkk/zigbee2mqtt/issues/30390)) ([93c9704](https://github.com/Koenkk/zigbee2mqtt/commit/93c9704fcd5552e234dd03a7ae1cddc11403622b))
|
||||
* **ignore:** bump the minor-patch group across 1 directory with 4 updates ([#30117](https://github.com/Koenkk/zigbee2mqtt/issues/30117)) ([ad3c090](https://github.com/Koenkk/zigbee2mqtt/commit/ad3c09036b804eb108238036f22776d83ea371b2))
|
||||
* **ignore:** bump the minor-patch group with 2 updates ([#30207](https://github.com/Koenkk/zigbee2mqtt/issues/30207)) ([1d15c21](https://github.com/Koenkk/zigbee2mqtt/commit/1d15c2142fefb9c7ce96c947e5ac7ddc6101439f))
|
||||
* **ignore:** bump the minor-patch group with 2 updates ([#30309](https://github.com/Koenkk/zigbee2mqtt/issues/30309)) ([b10fa93](https://github.com/Koenkk/zigbee2mqtt/commit/b10fa93700febe84634040dbdeb4857258cd2882))
|
||||
* **ignore:** bump zigbee2mqtt-windfront from 2.6.1 to 2.6.2 in the minor-patch group ([#30412](https://github.com/Koenkk/zigbee2mqtt/issues/30412)) ([4d6269e](https://github.com/Koenkk/zigbee2mqtt/commit/4d6269e7e89af52df0b2576925abade2f6b60e09))
|
||||
* **ignore:** update zigbee-herdsman to 7.0.5 ([#30116](https://github.com/Koenkk/zigbee2mqtt/issues/30116)) ([4574ffc](https://github.com/Koenkk/zigbee2mqtt/commit/4574ffc71a799846c6a04121c3b5908f968ff0be))
|
||||
* **ignore:** update zigbee-herdsman to 7.0.6 ([#30142](https://github.com/Koenkk/zigbee2mqtt/issues/30142)) ([ecb0af4](https://github.com/Koenkk/zigbee2mqtt/commit/ecb0af40575ebaadf887685c50cd1a9968cc1c68))
|
||||
* **ignore:** update zigbee-herdsman to 8.0.0 and zigbee-herdsman-converters to 25.87.0 ([#30186](https://github.com/Koenkk/zigbee2mqtt/issues/30186)) ([7f5f6a5](https://github.com/Koenkk/zigbee2mqtt/commit/7f5f6a5849d940a6e5288865edf7999c41af12e2))
|
||||
* **ignore:** update zigbee-herdsman to 8.0.1 ([#30240](https://github.com/Koenkk/zigbee2mqtt/issues/30240)) ([1688a3b](https://github.com/Koenkk/zigbee2mqtt/commit/1688a3b5323a8774ad2c36775fdb99f48057f72a))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.84.0 ([#30081](https://github.com/Koenkk/zigbee2mqtt/issues/30081)) ([c18d132](https://github.com/Koenkk/zigbee2mqtt/commit/c18d1329c801ce48cf5277c00682ad2f9098f383))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.85.0 ([#30120](https://github.com/Koenkk/zigbee2mqtt/issues/30120)) ([5db6ae0](https://github.com/Koenkk/zigbee2mqtt/commit/5db6ae0a197a180abc5e497aebe6e59fcb9b6b3c))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.86.0 ([#30143](https://github.com/Koenkk/zigbee2mqtt/issues/30143)) ([3460f15](https://github.com/Koenkk/zigbee2mqtt/commit/3460f157df84d10f8de87a37d6b0b0b243de6874))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.88.0 ([#30196](https://github.com/Koenkk/zigbee2mqtt/issues/30196)) ([3818c50](https://github.com/Koenkk/zigbee2mqtt/commit/3818c50b8ae516e56ad07e8fce3c451f35e1b678))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.89.0 ([#30215](https://github.com/Koenkk/zigbee2mqtt/issues/30215)) ([01304cf](https://github.com/Koenkk/zigbee2mqtt/commit/01304cfc6604b86acf181dc3f07688f56ecbe8a1))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.90.0 ([#30267](https://github.com/Koenkk/zigbee2mqtt/issues/30267)) ([ab08947](https://github.com/Koenkk/zigbee2mqtt/commit/ab0894715a002bd5c14d8af45a67e5e546889590))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.91.0 ([#30279](https://github.com/Koenkk/zigbee2mqtt/issues/30279)) ([4c02e3d](https://github.com/Koenkk/zigbee2mqtt/commit/4c02e3da8f66cafbdcde89303bb1aea6eaf73bcd))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.92.0 ([#30300](https://github.com/Koenkk/zigbee2mqtt/issues/30300)) ([c28a024](https://github.com/Koenkk/zigbee2mqtt/commit/c28a024664989fe8a044babe3cdf43403b0ee2b5))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.93.0 ([#30331](https://github.com/Koenkk/zigbee2mqtt/issues/30331)) ([c61f67f](https://github.com/Koenkk/zigbee2mqtt/commit/c61f67f061d2766e99c5cb9c99c02b172832271c))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.94.0 ([#30355](https://github.com/Koenkk/zigbee2mqtt/issues/30355)) ([7cb5c58](https://github.com/Koenkk/zigbee2mqtt/commit/7cb5c58dc028f86c59935b64a738444950ef8e12))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.95.0 ([#30371](https://github.com/Koenkk/zigbee2mqtt/issues/30371)) ([4c48e9c](https://github.com/Koenkk/zigbee2mqtt/commit/4c48e9cfbcedb9cd6aedee9f694db2993038eaee))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.96.0 ([#30383](https://github.com/Koenkk/zigbee2mqtt/issues/30383)) ([2721b0e](https://github.com/Koenkk/zigbee2mqtt/commit/2721b0ea78f4e86e6fe3b5f4831c5787a28585a8))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.97.0 ([#30396](https://github.com/Koenkk/zigbee2mqtt/issues/30396)) ([3727f07](https://github.com/Koenkk/zigbee2mqtt/commit/3727f078ecde73a5c444c42909f36b4a0f35d8c3))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.98.0 ([#30417](https://github.com/Koenkk/zigbee2mqtt/issues/30417)) ([0eba0c9](https://github.com/Koenkk/zigbee2mqtt/commit/0eba0c953d7543b2850a9b9672ffbc3d87840fb3))
|
||||
* log dir tz format not working on some systems ([#30324](https://github.com/Koenkk/zigbee2mqtt/issues/30324)) ([58d98c7](https://github.com/Koenkk/zigbee2mqtt/commit/58d98c7263dbe687eb677199eb760799ecff8e8f))
|
||||
* Proper timezone in logs dir ([#30297](https://github.com/Koenkk/zigbee2mqtt/issues/30297)) ([8549b37](https://github.com/Koenkk/zigbee2mqtt/commit/8549b372283da8b55804c4049c7341294ced1538))
|
||||
* Remove extra `>` in onboarding([#30065](https://github.com/Koenkk/zigbee2mqtt/issues/30065)) ([ed1e7b3](https://github.com/Koenkk/zigbee2mqtt/commit/ed1e7b333f481a4b3a37b021f2058331b86ebd84))
|
||||
|
||||
## [2.7.1](https://github.com/Koenkk/zigbee2mqtt/compare/2.7.0...2.7.1) (2025-12-06)
|
||||
|
||||
|
||||
|
||||
+10
-4
@@ -1,8 +1,14 @@
|
||||
# Contributing to Zigbee2MQTT
|
||||
|
||||
Everybody is invited and welcome to contribute to Zigbee2MQTT. Zigbee2MQTT is written in JavaScript and is based upon [zigbee-herdsman](https://github.com/koenkk/zigbee-herdsman) and [zigbee-herdsman-converters](https://github.com/koenkk/zigbee-herdsman-converters). Zigbee-herdsman-converters contains all device definitions, zigbee-herdsman is responsible for handling all communication with the adapter.
|
||||
> [!WARNING]
|
||||
> Any AI-driven pull request with more than 500 lines of code will not be considered. If wanting to submit something that requires more, split the work into easily review-able pull requests that can be introduced in increments (e.g. pre-refactor, base feature, additional features).
|
||||
|
||||
Everybody is invited and welcomed to contribute to Zigbee2MQTT.
|
||||
Zigbee2MQTT is written in TypeScript.
|
||||
It uses [zigbee-herdsman](https://github.com/koenkk/zigbee-herdsman) for communication with the adapter/coordinator and [zigbee-herdsman-converters](https://github.com/koenkk/zigbee-herdsman-converters) to provide device-specific definitions.
|
||||
|
||||
- Pull requests are always created against the [**dev**](https://github.com/Koenkk/zigbee2mqtt/tree/dev) branch.
|
||||
- Easiest way to start developing Zigbee2MQTT is by setting up a development environment (aka bare-metal installation). You can follow this [guide](https://www.zigbee2mqtt.io/guide/installation/01_linux.html) to do this.
|
||||
- You can run the tests locally by executing `pnpm test`. Zigbee2MQTT enforces 100% code coverage, in case you add new code check if your code is covered by running `pnpm run test:coverage`. The coverage report can be found under `coverage/lcov-report/index.html`. Linting is also enforced and can be run with `pnpm run eslint`.
|
||||
- When you want to add support for a new device no changes to Zigbee2MQTT have to be made, only to zigbee-herdsman-converters. You can find a guide for it [here](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html).
|
||||
- Easiest way to start developing Zigbee2MQTT is by setting up a development environment (a.k.a. bare-metal installation). You can follow this [guide](https://www.zigbee2mqtt.io/guide/installation/01_linux.html) to do this.
|
||||
- You can run the tests locally by executing `pnpm test`. Zigbee2MQTT enforces 100% code coverage, in case you add new code check if your code is covered by running `pnpm run test:coverage`. The coverage report can be found under `coverage/lcov-report/index.html`.
|
||||
- Linting & formatting is also enforced and can be run with `pnpm run check` (can use `pnpm run check:w` to fix small issues automatically).
|
||||
- If you want to add support for a new device no change to Zigbee2MQTT has to be made, only to zigbee-herdsman-converters. You can find a guide for it [here](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html).
|
||||
@@ -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">
|
||||
|
||||
|
||||
+7
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.5.3/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
@@ -12,7 +12,7 @@
|
||||
"bracketSpacing": false
|
||||
},
|
||||
"files": {
|
||||
"includes": ["**", "!package.json"]
|
||||
"includes": ["**", "!package.json", "!!**/dist", "!!**/coverage", "!images"]
|
||||
},
|
||||
"linter": {
|
||||
"includes": ["**"],
|
||||
@@ -72,6 +72,11 @@
|
||||
"suspicious": {
|
||||
"noConstEnum": "off",
|
||||
"useAwait": "error"
|
||||
},
|
||||
"nursery": {
|
||||
"useExhaustiveSwitchCases": "error",
|
||||
"noFloatingPromises": "error",
|
||||
"noMisusedPromises": "error"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Indicates the configuration version (used by configuration migrations)
|
||||
version: 4
|
||||
version: 5
|
||||
|
||||
# Home Assistant integration (MQTT discovery)
|
||||
homeassistant:
|
||||
@@ -15,7 +15,7 @@ mqtt:
|
||||
# MQTT base topic for zigbee2mqtt MQTT messages
|
||||
base_topic: zigbee2mqtt
|
||||
# MQTT server URL
|
||||
server: 'mqtt://localhost'
|
||||
server: "mqtt://localhost"
|
||||
# MQTT server authentication, uncomment if required:
|
||||
# user: my_user
|
||||
# password: my_password
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
FROM alpine:3.22 AS base
|
||||
FROM alpine:3.24 AS base
|
||||
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
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();
|
||||
process.setSourceMapsEnabled(true);
|
||||
|
||||
/** @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 (/^\d+(.\d+)?(,\d+(.\d+)?)*$/.test(process.env.Z2M_WATCHDOG)) {
|
||||
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 {
|
||||
console.log(`Invalid watchdog delays (must use number-only CSV format representing minutes, example: 'Z2M_WATCHDOG=1,5,15,30,60'.`);
|
||||
@@ -25,6 +22,7 @@ if (watchdog && process.env.Z2M_WATCHDOG !== "default") {
|
||||
|
||||
const hashFile = path.join(__dirname, "dist", ".hash");
|
||||
|
||||
/** @type {(code: number) => Promise<void>} */
|
||||
async function triggerWatchdog(code) {
|
||||
const delay = watchdogDelays[watchdogCount];
|
||||
watchdogCount += 1;
|
||||
@@ -41,14 +39,16 @@ async function triggerWatchdog(code) {
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {() => Promise<void>} */
|
||||
async function restart() {
|
||||
await stop(true);
|
||||
await start();
|
||||
}
|
||||
|
||||
/** @type {(code: number, restart?: boolean) => Promise<void>} */
|
||||
async function exit(code, restart = false) {
|
||||
if (!restart) {
|
||||
if (watchdog && unsolicitedStop) {
|
||||
if (process.env.Z2M_WATCHDOG != null && unsolicitedStop) {
|
||||
await triggerWatchdog(code);
|
||||
} else {
|
||||
process.exit(code);
|
||||
@@ -56,6 +56,7 @@ async function exit(code, restart = false) {
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {() => Promise<string>} */
|
||||
async function currentHash() {
|
||||
return await new Promise((resolve) => {
|
||||
exec("git rev-parse --short=8 HEAD", (error, stdout) => {
|
||||
@@ -70,20 +71,23 @@ async function currentHash() {
|
||||
});
|
||||
}
|
||||
|
||||
/** @type {() => Promise<void>} */
|
||||
async function writeHash() {
|
||||
const hash = await currentHash();
|
||||
|
||||
fs.writeFileSync(hashFile, hash);
|
||||
}
|
||||
|
||||
/** @type {(reason: "initial build" | "hash changed") => Promise<void>} */
|
||||
async function build(reason) {
|
||||
process.stdout.write(`Building Zigbee2MQTT... (${reason})`);
|
||||
const {totalmem} = await import("node:os");
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const env = {...process.env};
|
||||
const mb600 = 629145600;
|
||||
|
||||
if (mb600 > os.totalmem() && !env.NODE_OPTIONS) {
|
||||
if (mb600 > totalmem() && !env.NODE_OPTIONS) {
|
||||
// Prevent OOM on tsc compile for system with low memory
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/12034
|
||||
env.NODE_OPTIONS = "--max_old_space_size=256";
|
||||
@@ -107,6 +111,7 @@ async function build(reason) {
|
||||
});
|
||||
}
|
||||
|
||||
/** @type {() => Promise<void>} */
|
||||
async function checkDist() {
|
||||
if (!fs.existsSync(hashFile)) {
|
||||
await build("initial build");
|
||||
@@ -120,20 +125,22 @@ async function checkDist() {
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {() => Promise<void>} */
|
||||
async function start() {
|
||||
console.log(`Starting Zigbee2MQTT ${watchdog ? `with watchdog (${watchdogDelays})` : "without watchdog"}.`);
|
||||
console.log(`Starting Zigbee2MQTT ${process.env.Z2M_WATCHDOG != null ? `with watchdog (${watchdogDelays})` : "without watchdog"}.`);
|
||||
await checkDist();
|
||||
|
||||
// gc
|
||||
{
|
||||
const version = engines.node;
|
||||
const packageJSON = (await import("./package.json", {with: {type: "json"}})).default;
|
||||
const version = packageJSON.engines.node;
|
||||
const {satisfies} = await import("semver");
|
||||
|
||||
if (!semver.satisfies(process.version, version)) {
|
||||
if (!satisfies(process.version, version)) {
|
||||
console.log(`\t\tZigbee2MQTT requires node version ${version}, you are running ${process.version}!\n`);
|
||||
}
|
||||
|
||||
const {onboard} = require("./dist/util/onboarding");
|
||||
|
||||
const {onboard} = await import("./dist/util/onboarding.js");
|
||||
const success = await onboard();
|
||||
|
||||
if (!success) {
|
||||
@@ -143,7 +150,7 @@ async function start() {
|
||||
}
|
||||
}
|
||||
|
||||
const {Controller} = require("./dist/controller");
|
||||
const {Controller} = await import("./dist/controller.js");
|
||||
controller = new Controller(restart, exit);
|
||||
|
||||
await controller.start();
|
||||
@@ -153,32 +160,34 @@ async function start() {
|
||||
watchdogCount = 0; // reset
|
||||
}
|
||||
|
||||
async function stop(restart) {
|
||||
/** @type {(restart: boolean, signal?: NodeJS.Signals) => Promise<void>} */
|
||||
async function stop(restart, signal = undefined) {
|
||||
// `handleQuit` or `restart` never unsolicited
|
||||
unsolicitedStop = false;
|
||||
|
||||
await controller.stop(restart);
|
||||
await controller?.stop(restart, undefined, signal);
|
||||
}
|
||||
|
||||
async function handleQuit() {
|
||||
/** @type {(signal: NodeJS.Signals) => Promise<void>} */
|
||||
async function handleQuit(signal) {
|
||||
if (!stopping) {
|
||||
if (controller) {
|
||||
stopping = true;
|
||||
|
||||
await stop(false);
|
||||
await stop(false, signal);
|
||||
} else {
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module || require.main.filename.endsWith(`${path.sep}cli.js`)) {
|
||||
if (require.main === module || require.main?.filename.endsWith(`${path.sep}cli.js`)) {
|
||||
if (process.argv.length === 3 && process.argv[2] === "writehash") {
|
||||
writeHash();
|
||||
void writeHash();
|
||||
} else {
|
||||
process.on("SIGINT", handleQuit);
|
||||
process.on("SIGTERM", handleQuit);
|
||||
start();
|
||||
void start();
|
||||
}
|
||||
} else {
|
||||
process.on("SIGINT", handleQuit);
|
||||
|
||||
+104
-44
@@ -1,5 +1,4 @@
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {setLogger as zhSetLogger} from "zigbee-herdsman";
|
||||
import {setLogger as zhcSetLogger} from "zigbee-herdsman-converters";
|
||||
import EventBus from "./eventBus";
|
||||
@@ -24,10 +23,13 @@ import type {Zigbee2MQTTAPI} from "./types/api";
|
||||
import logger from "./util/logger";
|
||||
import {initSdNotify} from "./util/sd-notify";
|
||||
import * as settings from "./util/settings";
|
||||
import {stringify} from "./util/stringify";
|
||||
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;
|
||||
@@ -48,7 +50,6 @@ export class Controller {
|
||||
this.state = new State(this.eventBus, this.zigbee);
|
||||
this.restartCallback = restartCallback;
|
||||
this.exitCallback = exitCallback;
|
||||
|
||||
// Initialize extensions.
|
||||
this.extensionArgs = [
|
||||
this.zigbee,
|
||||
@@ -60,25 +61,35 @@ export class Controller {
|
||||
this.restartCallback,
|
||||
this.addExtension,
|
||||
];
|
||||
this.extensions = new Set();
|
||||
|
||||
this.extensions = new Set([
|
||||
new ExtensionExternalConverters(...this.extensionArgs),
|
||||
new ExtensionOnEvent(...this.extensionArgs),
|
||||
new ExtensionBridge(...this.extensionArgs),
|
||||
new ExtensionPublish(...this.extensionArgs),
|
||||
new ExtensionReceive(...this.extensionArgs),
|
||||
new ExtensionConfigure(...this.extensionArgs),
|
||||
new ExtensionNetworkMap(...this.extensionArgs),
|
||||
new ExtensionGroups(...this.extensionArgs),
|
||||
new ExtensionBind(...this.extensionArgs),
|
||||
new ExtensionOTAUpdate(...this.extensionArgs),
|
||||
new ExtensionExternalExtensions(...this.extensionArgs),
|
||||
new ExtensionAvailability(...this.extensionArgs),
|
||||
new ExtensionHealth(...this.extensionArgs),
|
||||
]);
|
||||
if (settings.get().advanced.enable_external_js) {
|
||||
this.extensions.add(new ExtensionExternalConverters(...this.extensionArgs));
|
||||
} else {
|
||||
logger.info("External JS (converters/extensions) is disabled");
|
||||
}
|
||||
|
||||
this.extensions.add(new ExtensionOnEvent(...this.extensionArgs));
|
||||
this.extensions.add(new ExtensionBridge(...this.extensionArgs));
|
||||
this.extensions.add(new ExtensionPublish(...this.extensionArgs));
|
||||
this.extensions.add(new ExtensionReceive(...this.extensionArgs));
|
||||
this.extensions.add(new ExtensionConfigure(...this.extensionArgs));
|
||||
this.extensions.add(new ExtensionNetworkMap(...this.extensionArgs));
|
||||
this.extensions.add(new ExtensionGroups(...this.extensionArgs));
|
||||
this.extensions.add(new ExtensionBind(...this.extensionArgs));
|
||||
this.extensions.add(new ExtensionOTAUpdate(...this.extensionArgs));
|
||||
this.extensions.add(new ExtensionAvailability(...this.extensionArgs));
|
||||
this.extensions.add(new ExtensionHealth(...this.extensionArgs));
|
||||
|
||||
if (settings.get().advanced.enable_external_js) {
|
||||
this.extensions.add(new ExtensionExternalExtensions(...this.extensionArgs));
|
||||
}
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.#startAbortController = new AbortController();
|
||||
const abortSignal = this.#startAbortController.signal;
|
||||
|
||||
if (settings.get().frontend.enabled) {
|
||||
const {Frontend} = await import("./extension/frontend.js");
|
||||
|
||||
@@ -93,29 +104,48 @@ export class Controller {
|
||||
|
||||
this.state.start();
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
logger.info(`Zigbee2MQTT start aborted, reason=${abortSignal.reason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const info = await utils.getZigbee2MQTTVersion();
|
||||
logger.info(`Starting Zigbee2MQTT version ${info.version} (commit #${info.commitHash})`);
|
||||
|
||||
// Start zigbee
|
||||
try {
|
||||
await this.zigbee.start();
|
||||
this.eventBus.onAdapterDisconnected(this, this.onZigbeeAdapterDisconnected);
|
||||
} catch (error) {
|
||||
logger.error("Failed to start zigbee-herdsman");
|
||||
logger.error(
|
||||
"Check https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start_crashes-runtime.html for possible solutions",
|
||||
);
|
||||
logger.error("Exiting...");
|
||||
// biome-ignore lint/style/noNonNullAssertion: always Error
|
||||
logger.error((error as Error).stack!);
|
||||
const started = await this.zigbee.start(abortSignal);
|
||||
|
||||
/* v8 ignore start */
|
||||
if ((error as Error).message.includes("USB adapter discovery error (No valid USB adapter found)")) {
|
||||
logger.error("If this happens after updating to Zigbee2MQTT 2.0.0, see https://github.com/Koenkk/zigbee2mqtt/discussions/24364");
|
||||
if (started) {
|
||||
this.eventBus.onAdapterDisconnected(this, async () => {
|
||||
logger.error("Adapter disconnected, stopping");
|
||||
await this.stop(false, 2);
|
||||
});
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
} catch (error) {
|
||||
// skip if aborted by `stop`
|
||||
if (error !== "SIGINT" && error !== "SIGTERM" && error !== "STOPABORT") {
|
||||
logger.error("Failed to start zigbee-herdsman");
|
||||
logger.error(
|
||||
"Check https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start_crashes-runtime.html for possible solutions",
|
||||
);
|
||||
logger.error("Exiting...");
|
||||
// biome-ignore lint/style/noNonNullAssertion: always Error
|
||||
logger.error((error as Error).stack!);
|
||||
|
||||
return await this.exit(1);
|
||||
/* v8 ignore start */
|
||||
if ((error as Error).message.includes("USB adapter discovery error (No valid USB adapter found)")) {
|
||||
logger.error("If this happens after updating to Zigbee2MQTT 2.0.0, see https://github.com/Koenkk/zigbee2mqtt/discussions/24364");
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
return await this.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
logger.info(`Zigbee2MQTT start aborted, reason=${abortSignal.reason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Log zigbee clients on startup
|
||||
@@ -143,9 +173,19 @@ export class Controller {
|
||||
return await this.exit(1);
|
||||
}
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
logger.info(`Zigbee2MQTT start aborted, reason=${abortSignal.reason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// copy current Set of extensions to ignore possible external extensions added while looping
|
||||
for (const extension of new Set(this.extensions)) {
|
||||
await this.startExtension(extension);
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
logger.info(`Zigbee2MQTT start aborted, reason=${abortSignal.reason}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Send all cached states.
|
||||
@@ -153,21 +193,32 @@ export class Controller {
|
||||
for (const entity of this.zigbee.devicesAndGroupsIterator()) {
|
||||
if (this.state.exists(entity)) {
|
||||
await this.publishEntityState(entity, this.state.get(entity), "publishCached");
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
logger.info(`Zigbee2MQTT start aborted, reason=${abortSignal.reason}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.eventBus.onLastSeenChanged(this, (data) => utils.publishLastSeen(data, settings.get(), false, this.publishEntityState));
|
||||
this.eventBus.onLastSeenChanged(this, (data) => {
|
||||
utils.publishLastSeen(data, settings.get(), false, this.publishEntityState).catch(() => {});
|
||||
});
|
||||
|
||||
logger.info("Zigbee2MQTT started!");
|
||||
|
||||
this.sdNotify = await initSdNotify();
|
||||
|
||||
settings.setOnboarding(false);
|
||||
|
||||
this.#startAbortController = undefined; // gc
|
||||
}
|
||||
|
||||
@bind async enableDisableExtension(enable: boolean, name: string): Promise<void> {
|
||||
if (enable) {
|
||||
let extension: Extension;
|
||||
|
||||
switch (name) {
|
||||
case "Frontend": {
|
||||
if (!settings.get().frontend.enabled) {
|
||||
@@ -178,7 +229,7 @@ export class Controller {
|
||||
/* v8 ignore start */
|
||||
const {Frontend} = await import("./extension/frontend.js");
|
||||
|
||||
await this.addExtension(new Frontend(...this.extensionArgs));
|
||||
extension = new Frontend(...this.extensionArgs);
|
||||
|
||||
break;
|
||||
/* v8 ignore stop */
|
||||
@@ -190,7 +241,7 @@ export class Controller {
|
||||
|
||||
const {HomeAssistant} = await import("./extension/homeassistant.js");
|
||||
|
||||
await this.addExtension(new HomeAssistant(...this.extensionArgs));
|
||||
extension = new HomeAssistant(...this.extensionArgs);
|
||||
|
||||
break;
|
||||
}
|
||||
@@ -200,6 +251,14 @@ export class Controller {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const existingExtension = this.getExtension(name);
|
||||
|
||||
if (existingExtension) {
|
||||
await this.removeExtension(existingExtension);
|
||||
}
|
||||
|
||||
this.extensions.add(extension);
|
||||
} else {
|
||||
switch (name) {
|
||||
case "Frontend": {
|
||||
@@ -281,7 +340,10 @@ export class Controller {
|
||||
}
|
||||
}
|
||||
|
||||
async stop(restart = false, code = 0): Promise<void> {
|
||||
async stop(restart = false, code = 0, signal: NodeJS.Signals | undefined = undefined): Promise<void> {
|
||||
logger.info(`Stopping Zigbee2MQTT (restart=${restart}, code=${code}, signal=${signal})`);
|
||||
|
||||
this.#startAbortController?.abort(signal ?? "STOPABORT");
|
||||
this.sdNotify?.notifyStopping();
|
||||
|
||||
let localCode = 0;
|
||||
@@ -317,11 +379,6 @@ export class Controller {
|
||||
return await this.exitCallback(code, restart);
|
||||
}
|
||||
|
||||
@bind async onZigbeeAdapterDisconnected(): Promise<void> {
|
||||
logger.error("Adapter disconnected, stopping");
|
||||
await this.stop(false, 2);
|
||||
}
|
||||
|
||||
@bind async publishEntityState(entity: Group | Device, payload: KeyValue, stateChangeReason?: StateChangeReason): Promise<void> {
|
||||
let message: Zigbee2MQTTAPI["{friendlyName}"] = {...payload};
|
||||
|
||||
@@ -405,10 +462,13 @@ export class Controller {
|
||||
async iteratePayloadAttributeOutput(topicRoot: string, payload: KeyValue, options: Partial<MqttPublishOptions>): Promise<void> {
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
let subPayload = value;
|
||||
let message = null;
|
||||
let message: string | undefined;
|
||||
|
||||
// Special cases
|
||||
if (key === "color" && utils.objectHasProperties(subPayload, ["r", "g", "b"])) {
|
||||
// `objectHasProperties` indexes its argument, so it has to be given an object.
|
||||
// The null check three lines below is too late: `color` is nullable like any
|
||||
// other attribute, and a null one reaches here before that branch runs.
|
||||
if (key === "color" && subPayload != null && utils.objectHasProperties(subPayload, ["r", "g", "b"])) {
|
||||
subPayload = [subPayload.r, subPayload.g, subPayload.b];
|
||||
}
|
||||
|
||||
@@ -423,7 +483,7 @@ export class Controller {
|
||||
message = typeof subPayload === "string" ? subPayload : stringify(subPayload);
|
||||
}
|
||||
|
||||
if (message !== null) {
|
||||
if (message !== undefined) {
|
||||
await this.mqtt.publish(`${topicRoot}${key}`, message, options);
|
||||
}
|
||||
}
|
||||
|
||||
+7
-6
@@ -61,7 +61,7 @@ export default class EventBus {
|
||||
public emitAdapterDisconnected(): void {
|
||||
this.emitter.emit("adapterDisconnected");
|
||||
}
|
||||
public onAdapterDisconnected(key: ListenerKey, callback: () => void): void {
|
||||
public onAdapterDisconnected(key: ListenerKey, callback: () => Promise<void>): void {
|
||||
this.on("adapterDisconnected", callback, key);
|
||||
}
|
||||
|
||||
@@ -247,19 +247,20 @@ export default class EventBus {
|
||||
this.callbacksByExtension.set(key.constructor.name, []);
|
||||
}
|
||||
|
||||
const wrappedCallback = async (...args: never[]): Promise<void> => {
|
||||
const typedCallback = callback as (...args: EventBusMap[K]) => Promise<void> | void;
|
||||
const wrappedCallback = (async (...args: EventBusMap[K]): Promise<void> => {
|
||||
try {
|
||||
await callback(...args);
|
||||
await typedCallback(...args);
|
||||
} catch (error) {
|
||||
logger.error(`EventBus error '${key.constructor.name}/${event}': ${(error as Error).message}`);
|
||||
// biome-ignore lint/style/noNonNullAssertion: always Error
|
||||
logger.debug((error as Error).stack!);
|
||||
}
|
||||
};
|
||||
}) as EventBusListener<keyof EventBusMap>;
|
||||
|
||||
// biome-ignore lint/style/noNonNullAssertion: just created if wasn't valid
|
||||
this.callbacksByExtension.get(key.constructor.name)!.push({event, callback: wrappedCallback});
|
||||
this.emitter.on(event, wrappedCallback as EventBusListener<K>);
|
||||
(this.emitter as events.EventEmitter).on(event, wrappedCallback);
|
||||
}
|
||||
|
||||
public removeListeners(key: ListenerKey): void {
|
||||
@@ -267,7 +268,7 @@ export default class EventBus {
|
||||
|
||||
if (callbacks) {
|
||||
for (const cb of callbacks) {
|
||||
this.emitter.removeListener(cb.event, cb.callback);
|
||||
(this.emitter as events.EventEmitter).removeListener(cb.event, cb.callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,12 @@ import * as settings from "../util/settings";
|
||||
import utils from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
/**
|
||||
* Upper bound for a `setTimeout` delay. Node.js stores the delay as a 32-bit signed integer; anything above this
|
||||
* is coerced to `1`, which would turn an ever-growing backoff into a tight loop instead of an ever-longer wait.
|
||||
*/
|
||||
const MAX_TIMEOUT = 2147483647;
|
||||
|
||||
const RETRIEVE_ON_RECONNECT: readonly {keys: string[]; condition?: (state: KeyValue) => boolean}[] = [
|
||||
{keys: ["state"]},
|
||||
{keys: ["brightness"], condition: (state: KeyValue): boolean => state.state === "ON"},
|
||||
@@ -108,7 +114,10 @@ export default class Availability extends Extension {
|
||||
// If device did not check in, ping it, if that fails it will be marked as offline
|
||||
this.timers.set(
|
||||
device.ieeeAddr,
|
||||
setTimeout(this.addToPingQueue.bind(this, device), (this.getTimeout(device) + utils.seconds(1) + jitter) * backoff),
|
||||
setTimeout(
|
||||
this.addToPingQueue.bind(this, device),
|
||||
Math.min((this.getTimeout(device) + utils.seconds(1) + jitter) * backoff, MAX_TIMEOUT),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -322,6 +331,9 @@ export default class Availability extends Extension {
|
||||
options,
|
||||
state,
|
||||
device: device.zh,
|
||||
/* v8 ignore start */
|
||||
deviceExposesChanged: (): void => this.eventBus.emitExposesAndDevicesChanged(device),
|
||||
/* v8 ignore stop */
|
||||
/* v8 ignore next */
|
||||
publish: (payload: KeyValue) => this.publishEntityState(device, payload),
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import assert from "node:assert";
|
||||
import bind from "bind-decorator";
|
||||
import debounce from "debounce";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {Zcl} from "zigbee-herdsman";
|
||||
import type {TClusterAttributeKeys} from "zigbee-herdsman/dist/zspec/zcl/definition/clusters-types";
|
||||
import type {ClusterName} from "zigbee-herdsman/dist/zspec/zcl/definition/tstype";
|
||||
@@ -10,6 +9,7 @@ import Group from "../model/group";
|
||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from "../types/api";
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import {stringify} from "../util/stringify";
|
||||
import utils, {DEFAULT_BIND_GROUP_ID} from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
|
||||
+86
-42
@@ -1,8 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import JSZip from "jszip";
|
||||
import objectAssignDeep from "object-assign-deep";
|
||||
import {zip} from "fflate";
|
||||
import type winston from "winston";
|
||||
import Transport from "winston-transport";
|
||||
import {Zcl} from "zigbee-herdsman";
|
||||
@@ -13,7 +12,9 @@ import type Group from "../model/group";
|
||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTDevice, Zigbee2MQTTResponse, Zigbee2MQTTResponseEndpoints} from "../types/api";
|
||||
import data from "../util/data";
|
||||
import logger from "../util/logger";
|
||||
import {objectAssignDeep} from "../util/objectAssignDeep";
|
||||
import * as settings from "../util/settings";
|
||||
import {stringify} from "../util/stringify";
|
||||
import utils, {assertString, DEFAULT_BIND_GROUP_ID} from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
@@ -140,6 +141,7 @@ export default class Bridge extends Extension {
|
||||
await this.mqtt.publish("bridge/event", stringify(payload));
|
||||
});
|
||||
this.eventBus.onDeviceLeave(this, async (data) => {
|
||||
await this.publishGroups();
|
||||
await this.publishDevices();
|
||||
await this.publishDefinitions();
|
||||
|
||||
@@ -242,7 +244,10 @@ export default class Bridge extends Extension {
|
||||
}
|
||||
|
||||
const newSettings = message.options as Partial<Settings>;
|
||||
this.restartRequired = settings.apply(newSettings);
|
||||
const newRestartRequired = settings.apply(newSettings);
|
||||
if (newRestartRequired) {
|
||||
this.restartRequired = newRestartRequired;
|
||||
}
|
||||
|
||||
// Apply some settings on-the-fly.
|
||||
if (newSettings.homeassistant) {
|
||||
@@ -261,7 +266,11 @@ export default class Bridge extends Extension {
|
||||
logger.setDebugNamespaceIgnore(settings.get().advanced.log_debug_namespace_ignore);
|
||||
}
|
||||
|
||||
logger.info("Successfully changed options");
|
||||
if (newRestartRequired) {
|
||||
logger.info("Changes require restart to take effect");
|
||||
} else {
|
||||
logger.info("Successfully changed options");
|
||||
}
|
||||
await this.publishInfo();
|
||||
return utils.getResponse(message, {restart_required: this.restartRequired});
|
||||
}
|
||||
@@ -319,18 +328,27 @@ export default class Bridge extends Extension {
|
||||
@bind async backup(message: string | KeyValue): Promise<Zigbee2MQTTResponse<"bridge/response/backup">> {
|
||||
await this.zigbee.backup();
|
||||
const dataPath = data.getPath();
|
||||
const files = utils
|
||||
.getAllFiles(dataPath)
|
||||
.map((f) => [f, f.substring(dataPath.length + 1)])
|
||||
.filter((f) => !f[1].startsWith("log"));
|
||||
const zip = new JSZip();
|
||||
const files = utils.getAllFiles(dataPath);
|
||||
const zipFiles: Record<string, Uint8Array> = {};
|
||||
const logDir = `log${path.sep}`;
|
||||
const otaDir = `ota${path.sep}`;
|
||||
|
||||
for (const f of files) {
|
||||
zip.file(f[1], fs.readFileSync(f[0]));
|
||||
const name = f.slice(dataPath.length + 1);
|
||||
|
||||
// XXX: `log` could technically be something else depending on `log_directory` setting
|
||||
if (!name.startsWith(logDir) && !name.startsWith(otaDir)) {
|
||||
zipFiles[name] = fs.readFileSync(f);
|
||||
}
|
||||
}
|
||||
|
||||
const base64Zip = await zip.generateAsync({type: "base64"});
|
||||
return utils.getResponse(message, {zip: base64Zip});
|
||||
const zipContent = await new Promise<Uint8Array>((resolve, reject) => {
|
||||
// `jszip` defaulted to `STORE`, so backups used to be uncompressed; `fflate`'s default level shrinks them substantially
|
||||
zip(zipFiles, {level: 6}, (error, data) => (error ? reject(error) : resolve(data)));
|
||||
});
|
||||
|
||||
// TODO: replace with `zipContent.toBase64()` once the Node requirement is >=25
|
||||
return utils.getResponse(message, {zip: Buffer.from(zipContent).toString("base64")});
|
||||
}
|
||||
|
||||
@bind async installCodeAdd(message: KeyValue | string): Promise<Zigbee2MQTTResponse<"bridge/response/install_code/add">> {
|
||||
@@ -448,6 +466,20 @@ export default class Bridge extends Extension {
|
||||
|
||||
const ID = message.id;
|
||||
const entity = this.getEntity(entityType, ID);
|
||||
|
||||
if (entity instanceof Device) {
|
||||
const supportedOptions = new Set(Object.keys(settings.schemaJson.definitions.device.properties));
|
||||
for (const option of entity.definition?.options ?? []) {
|
||||
supportedOptions.add(option.property);
|
||||
}
|
||||
|
||||
for (const option of Object.keys(message.options)) {
|
||||
if (!supportedOptions.has(option)) {
|
||||
logger.warning(`Device '${ID}' does not support option '${option}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const oldOptions = objectAssignDeep({}, cleanup(entity.options));
|
||||
|
||||
if (message.options.icon) {
|
||||
@@ -459,12 +491,16 @@ export default class Bridge extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
const restartRequired = settings.changeEntityOptions(ID, message.options);
|
||||
if (restartRequired) this.restartRequired = true;
|
||||
const newRestartRequired = settings.changeEntityOptions(ID, message.options);
|
||||
if (newRestartRequired) this.restartRequired = true;
|
||||
const newOptions = cleanup(entity.options);
|
||||
await this.publishInfo();
|
||||
|
||||
logger.info(`Changed config for ${entityType} ${ID}`);
|
||||
if (newRestartRequired) {
|
||||
logger.info(`New config for ${entityType} ${ID} requires restart to take effect`);
|
||||
} else {
|
||||
logger.info(`Successfully changed config for ${entityType} ${ID}`);
|
||||
}
|
||||
|
||||
this.eventBus.emitEntityOptionsChanged({from: oldOptions, to: newOptions, entity});
|
||||
return utils.getResponse(message, {from: oldOptions, to: newOptions, id: ID, restart_required: this.restartRequired});
|
||||
@@ -568,19 +604,8 @@ export default class Bridge extends Extension {
|
||||
}
|
||||
|
||||
const device = this.getEntity("device", message.id);
|
||||
logger.info(`Interviewing '${device.name}'`);
|
||||
|
||||
try {
|
||||
await device.zh.interview(true);
|
||||
logger.info(`Successfully interviewed '${device.name}'`);
|
||||
} catch (error) {
|
||||
throw new Error(`interview of '${device.name}' (${device.ieeeAddr}) failed: ${error}`, {cause: error});
|
||||
}
|
||||
|
||||
// A re-interview can for example result in a different modelId, therefore reconsider the definition.
|
||||
await device.resolveDefinition(true);
|
||||
this.eventBus.emitDevicesChanged();
|
||||
this.eventBus.emitExposesChanged({device});
|
||||
await device.reInterview(this.eventBus);
|
||||
|
||||
return utils.getResponse(message, {id: message.id});
|
||||
}
|
||||
@@ -659,20 +684,27 @@ export default class Bridge extends Extension {
|
||||
entityType: T,
|
||||
message: string | KeyValue,
|
||||
): Promise<Zigbee2MQTTResponse<T extends "device" ? "bridge/response/device/remove" : "bridge/response/group/remove">> {
|
||||
const ID = typeof message === "object" ? message.id : message.trim();
|
||||
const messageIsObject = typeof message === "object";
|
||||
const ID = messageIsObject ? message.id : message.trim();
|
||||
const entity = this.getEntity(entityType, ID);
|
||||
// note: entity.name is dynamically retrieved, will change once device is removed (friendly => ieee)
|
||||
const friendlyName = entity.name;
|
||||
let block = false;
|
||||
let force = false;
|
||||
let keepConfig = false;
|
||||
let clearCache = false;
|
||||
let blockForceLog = "";
|
||||
|
||||
if (entityType === "device" && typeof message === "object") {
|
||||
block = !!message.block;
|
||||
force = !!message.force;
|
||||
blockForceLog = ` (block: ${block}, force: ${force})`;
|
||||
} else if (entityType === "group" && typeof message === "object") {
|
||||
force = !!message.force;
|
||||
if (entityType === "device" && messageIsObject) {
|
||||
const payload = message as Zigbee2MQTTAPI["bridge/request/device/remove"];
|
||||
block = !!payload.block;
|
||||
force = !!payload.force;
|
||||
keepConfig = !!payload.keep_config;
|
||||
clearCache = !!payload.clear_cache;
|
||||
blockForceLog = ` (block: ${block}, force: ${force}, keep config: ${keepConfig}, clear cache: ${clearCache})`;
|
||||
} else if (entityType === "group" && messageIsObject) {
|
||||
const payload = message as Zigbee2MQTTAPI["bridge/request/group/remove"];
|
||||
force = !!payload.force;
|
||||
blockForceLog = ` (force: ${force})`;
|
||||
}
|
||||
|
||||
@@ -685,12 +717,18 @@ export default class Bridge extends Extension {
|
||||
}
|
||||
|
||||
if (force) {
|
||||
entity.zh.removeFromDatabase();
|
||||
entity.zh.removeFromDatabase(clearCache);
|
||||
} else {
|
||||
await entity.zh.removeFromNetwork();
|
||||
await entity.zh.removeFromNetwork(clearCache);
|
||||
}
|
||||
|
||||
settings.removeDevice(entity.ID as string);
|
||||
if (clearCache) {
|
||||
this.zigbee.removeDeviceFromLookup(entity.ID);
|
||||
}
|
||||
|
||||
if (!keepConfig) {
|
||||
settings.removeDevice(entity.ID as string);
|
||||
}
|
||||
} else {
|
||||
if (force) {
|
||||
entity.zh.removeFromDatabase();
|
||||
@@ -712,19 +750,24 @@ export default class Bridge extends Extension {
|
||||
|
||||
logger.info(`Successfully removed ${entityType} '${friendlyName}'${blockForceLog}`);
|
||||
|
||||
await this.publishGroups();
|
||||
|
||||
if (entity instanceof Device) {
|
||||
await this.publishGroups();
|
||||
await this.publishDevices();
|
||||
// Refresh Cluster definition
|
||||
await this.publishDefinitions();
|
||||
|
||||
const responseData: Zigbee2MQTTAPI["bridge/response/device/remove"] = {id: ID, block, force};
|
||||
const responseData: Zigbee2MQTTAPI["bridge/response/device/remove"] = {
|
||||
id: ID,
|
||||
block,
|
||||
force,
|
||||
keep_config: keepConfig,
|
||||
clear_cache: clearCache,
|
||||
};
|
||||
|
||||
return utils.getResponse(message, responseData);
|
||||
}
|
||||
|
||||
await this.publishGroups();
|
||||
|
||||
const responseData: Zigbee2MQTTAPI["bridge/response/group/remove"] = {id: ID, force};
|
||||
|
||||
return utils.getResponse(
|
||||
@@ -906,6 +949,7 @@ export default class Bridge extends Extension {
|
||||
exposes: device.exposes(),
|
||||
supports_ota: !!device.definition.ota,
|
||||
options: device.definition.options ?? [],
|
||||
version: device.definition.version,
|
||||
icon,
|
||||
};
|
||||
|
||||
|
||||
+34
-20
@@ -1,10 +1,9 @@
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import * as zhc from "zigbee-herdsman-converters";
|
||||
import Device from "../model/device";
|
||||
import type {Zigbee2MQTTAPI} from "../types/api";
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import {stringify} from "../util/stringify";
|
||||
import utils from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
@@ -12,8 +11,8 @@ import Extension from "./extension";
|
||||
* This extension calls the zigbee-herdsman-converters definition configure() method
|
||||
*/
|
||||
export default class Configure extends Extension {
|
||||
private configuring = new Set();
|
||||
private attempts: {[s: string]: number} = {};
|
||||
private configuring = new Set<string>();
|
||||
private attempts = new Map<string, number>();
|
||||
private topic = `${settings.get().mqtt.base_topic}/bridge/request/device/configure`;
|
||||
|
||||
@bind private async onReconfigure(data: eventdata.Reconfigure): Promise<void> {
|
||||
@@ -39,7 +38,7 @@ export default class Configure extends Extension {
|
||||
|
||||
if (!device || !(device instanceof Device)) {
|
||||
error = `Device '${ID}' does not exist`;
|
||||
} else if (!device.definition || !device.definition.configure) {
|
||||
} else if (!device.definition?.configure) {
|
||||
error = `Device '${device.name}' cannot be configured`;
|
||||
} else {
|
||||
try {
|
||||
@@ -75,6 +74,7 @@ export default class Configure extends Extension {
|
||||
|
||||
await this.configure(data.device, "zigbee_event");
|
||||
});
|
||||
// TODO: this is triggering for any `data.status`, but should only for `successful`? (relies on `device.definition?` early-return?)
|
||||
this.eventBus.onDeviceInterview(this, (data) => this.configure(data.device, "zigbee_event"));
|
||||
this.eventBus.onLastSeenChanged(this, (data) => this.configure(data.device, "zigbee_event"));
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
@@ -93,42 +93,56 @@ export default class Configure extends Extension {
|
||||
return;
|
||||
}
|
||||
|
||||
const definitionVersion = device.definition.version;
|
||||
|
||||
if (!force) {
|
||||
if (device.options.disabled || !device.interviewed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (device.zh.meta?.configured !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only configure end devices when it is active, otherwise it will likely fails as they are sleeping.
|
||||
if (device.zh.type === "EndDevice" && event !== "zigbee_event") {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldReconfigure =
|
||||
// Should always reconfigure when not configured before
|
||||
device.zh.meta?.configured === undefined ||
|
||||
// Or should reconfigure when definition.version is not '0.0.0' and differs from last `meta.configured`.
|
||||
// In older Z2M versions the stored `meta.configured` was the hash of the configure function.
|
||||
// Since we don't want to reconfigure all devices, we don't re-configure when the definition has the default version of '0.0.0'.
|
||||
(definitionVersion !== "0.0.0" && device.zh.meta?.configured !== definitionVersion);
|
||||
if (!shouldReconfigure) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.configuring.has(device.ieeeAddr) || (this.attempts[device.ieeeAddr] >= 3 && !force)) {
|
||||
if (this.configuring.has(device.ieeeAddr)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attempts = this.attempts.get(device.ieeeAddr) ?? 0;
|
||||
|
||||
if (attempts >= 3 && !force) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.configuring.add(device.ieeeAddr);
|
||||
|
||||
if (this.attempts[device.ieeeAddr] === undefined) {
|
||||
this.attempts[device.ieeeAddr] = 0;
|
||||
}
|
||||
|
||||
logger.info(`Configuring '${device.name}'`);
|
||||
|
||||
try {
|
||||
await device.definition.configure(device.zh, this.zigbee.firstCoordinatorEndpoint(), device.definition);
|
||||
logger.info(`Successfully configured '${device.name}'`);
|
||||
device.zh.meta.configured = zhc.getConfigureKey(device.definition);
|
||||
this.attempts.delete(device.ieeeAddr);
|
||||
logger.info(`Successfully configured '${device.name}' (definition v${definitionVersion})`);
|
||||
device.zh.meta.configured = definitionVersion;
|
||||
device.zh.save();
|
||||
this.eventBus.emitDevicesChanged();
|
||||
this.eventBus.emitExposesAndDevicesChanged(device);
|
||||
} 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) {
|
||||
|
||||
@@ -2,12 +2,11 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponse} from "../types/api";
|
||||
|
||||
import data from "../util/data";
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import {stringify} from "../util/stringify";
|
||||
import utils from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
@@ -176,7 +175,13 @@ export default abstract class ExternalJSExtension<M> extends Extension {
|
||||
}
|
||||
|
||||
const {name, code} = message;
|
||||
|
||||
if (!name.endsWith(".mjs") && !name.endsWith(".js") && !name.endsWith(".cjs")) {
|
||||
return utils.getResponse(message, {}, "JavaScript file must have '.mjs', '.js' or '.cjs' extension");
|
||||
}
|
||||
|
||||
const filePath = this.getFilePath(name, true);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(filePath, code, "utf8");
|
||||
this.symlinkNodeModulesIfNecessary();
|
||||
@@ -202,14 +207,14 @@ export default abstract class ExternalJSExtension<M> extends Extension {
|
||||
const mod = await this.importFile(filePath);
|
||||
await this.loadJS(extension.name, mod.default);
|
||||
} catch (error) {
|
||||
// change ext so Z2M doesn't try to load it again and again
|
||||
fs.renameSync(filePath, `${filePath}.invalid`);
|
||||
|
||||
logger.error(
|
||||
`Invalid external ${this.mqttTopic} '${extension.name}' was ignored and renamed to prevent interference with Zigbee2MQTT. (${(error as Error).message})`,
|
||||
);
|
||||
// biome-ignore lint/style/noNonNullAssertion: always Error
|
||||
logger.debug((error as Error).stack!);
|
||||
|
||||
// change ext so Z2M doesn't try to load it again and again
|
||||
fs.renameSync(filePath, `${filePath}.invalid`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+21
-31
@@ -5,16 +5,13 @@ import {createServer} from "node:http";
|
||||
import {createServer as createSecureServer} from "node:https";
|
||||
import type {Socket} from "node:net";
|
||||
import {posix} from "node:path";
|
||||
import {parse} from "node:url";
|
||||
import bind from "bind-decorator";
|
||||
import expressStaticGzip from "express-static-gzip";
|
||||
import finalhandler from "finalhandler";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import WebSocket from "ws";
|
||||
|
||||
import data from "../util/data";
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import {createStaticFileServer, sendNotFound} from "../util/staticFileServer";
|
||||
import {stringify} from "../util/stringify";
|
||||
import utils from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
@@ -67,46 +64,39 @@ export class Frontend extends Extension {
|
||||
|
||||
return false;
|
||||
};
|
||||
const options: expressStaticGzip.ExpressStaticGzipOptions = {
|
||||
enableBrotli: true,
|
||||
serveStatic: {
|
||||
/* v8 ignore start */
|
||||
setHeaders: (res: ServerResponse, path: string): void => {
|
||||
if (path.endsWith("index.html")) {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
}
|
||||
},
|
||||
/* v8 ignore stop */
|
||||
},
|
||||
};
|
||||
const frontend = (await import(settings.get().frontend.package)) as typeof import("zigbee2mqtt-frontend");
|
||||
const fileServer = expressStaticGzip(frontend.default.getPath(), options);
|
||||
const deviceIconsFileServer = expressStaticGzip(data.joinPath("device_icons"), options);
|
||||
const logError = logger.error.bind(logger);
|
||||
const fileServer = createStaticFileServer(frontend.default.getPath(), logError);
|
||||
const deviceIconsFileServer = createStaticFileServer(data.joinPath("device_icons"), logError);
|
||||
const onRequest = (request: IncomingMessage, response: ServerResponse): void => {
|
||||
const next = finalhandler(request, response);
|
||||
// biome-ignore lint/style/noNonNullAssertion: `Only valid for request obtained from Server`
|
||||
const newUrl = posix.relative(this.baseUrl, request.url!);
|
||||
const url = request.url!;
|
||||
const newUrl = posix.relative(this.baseUrl, url);
|
||||
|
||||
// The request url is not within the frontend base url, so the relative path starts with '..'
|
||||
if (newUrl.startsWith(".")) {
|
||||
next();
|
||||
sendNotFound(request, response);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// The base url itself is a directory, redirect to its trailing slash form so the browser resolves the
|
||||
// relative asset paths in `index.html` against the frontend root instead of against its parent.
|
||||
if (newUrl === "" && !url.endsWith("/")) {
|
||||
response.writeHead(301, {Location: `${url}/`});
|
||||
response.end();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Attach originalUrl so that static-server can perform a redirect to '/' when serving the root directory.
|
||||
// This is necessary for the browser to resolve relative assets paths correctly.
|
||||
request.originalUrl = request.url;
|
||||
request.url = `/${newUrl}`;
|
||||
request.path = request.url;
|
||||
|
||||
if (newUrl.startsWith("device_icons/")) {
|
||||
request.path = request.path.replace("device_icons/", "");
|
||||
request.url = request.url.replace("/device_icons", "");
|
||||
|
||||
deviceIconsFileServer(request, response, next);
|
||||
deviceIconsFileServer(request, response);
|
||||
} else {
|
||||
fileServer(request, response, next);
|
||||
fileServer(request, response);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -157,10 +147,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");
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import assert from "node:assert";
|
||||
import bind from "bind-decorator";
|
||||
import equals from "fast-deep-equal/es6";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import type * as zhc from "zigbee-herdsman-converters";
|
||||
import Device from "../model/device";
|
||||
import Group from "../model/group";
|
||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from "../types/api";
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import {stringify} from "../util/stringify";
|
||||
import utils, {isLightExpose} from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
+417
-142
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,10 @@
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import type {Eui64} from "zigbee-herdsman/dist/zspec/tstypes";
|
||||
import type {LQITableEntry, RoutingTableEntry} from "zigbee-herdsman/dist/zspec/zdo/definition/tstypes";
|
||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTNetworkMap} from "../types/api";
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import {stringify} from "../util/stringify";
|
||||
import utils from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
@@ -92,6 +92,8 @@ export default class NetworkMap extends Extension {
|
||||
}
|
||||
|
||||
labels.push(lastSeen);
|
||||
// Escape backslashes and double-quotes to avoid breaking Graphviz .dot files.
|
||||
const escapedLabels = labels.map((label) => label.replace(/\\/g, "\\\\").replace(/"/g, '\\"'));
|
||||
|
||||
// Shape the record according to device type
|
||||
if (node.type === "Coordinator") {
|
||||
@@ -103,7 +105,7 @@ export default class NetworkMap extends Extension {
|
||||
}
|
||||
|
||||
// Add the device with its labels to the graph as a node.
|
||||
text += ` "${node.ieeeAddr}" [${style}, label="{${labels.join("|")}}"];\n`;
|
||||
text += ` "${node.ieeeAddr}" [${style}, label="{${escapedLabels.join("|")}}"];\n`;
|
||||
|
||||
/**
|
||||
* Add an edge between the device and its child to the graph
|
||||
|
||||
@@ -37,6 +37,8 @@ export default class OnEvent extends Extension {
|
||||
}
|
||||
});
|
||||
this.eventBus.onDeviceInterview(this, async (data) => {
|
||||
// TODO: this is triggering for any `data.status`, any use outside `successful`?
|
||||
// ZHC definition would skip from `device.definition?` but OnEvent from ZHC index wouldn't => triple triggering?
|
||||
await this.callOnEvent(data.device, {type: "deviceInterview", data: {...this.#getOnEventBaseData(data.device), status: data.status}});
|
||||
});
|
||||
this.eventBus.onDeviceAnnounce(this, async (data) => {
|
||||
@@ -59,8 +61,11 @@ export default class OnEvent extends Extension {
|
||||
override async stop(): Promise<void> {
|
||||
await super.stop();
|
||||
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
await this.callOnEvent(device, {type: "stop", data: {ieeeAddr: device.ieeeAddr}});
|
||||
// ensure properly started, else this throws undesired errors (e.g. SIGINT during startup)
|
||||
if (this.zigbee.zhController !== undefined) {
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
await this.callOnEvent(device, {type: "stop", data: {ieeeAddr: device.ieeeAddr}});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+307
-205
@@ -1,15 +1,15 @@
|
||||
import assert from "node:assert";
|
||||
import path from "node:path";
|
||||
import {existsSync, mkdirSync, rmSync, writeFileSync} from "node:fs";
|
||||
import {join} from "node:path";
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {Zcl} from "zigbee-herdsman";
|
||||
import type {Ota} from "zigbee-herdsman-converters";
|
||||
import {ota} from "zigbee-herdsman-converters";
|
||||
import {setOtaConfiguration, Zcl} from "zigbee-herdsman";
|
||||
import type {OtaDataSettings, OtaSource, OtaUpdateAvailableResult} from "zigbee-herdsman/dist/controller/tstype";
|
||||
import Device from "../model/device";
|
||||
import type {Zigbee2MQTTAPI} from "../types/api";
|
||||
import dataDir from "../util/data";
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import {stringify} from "../util/stringify";
|
||||
import utils from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
@@ -22,54 +22,65 @@ export interface UpdatePayload {
|
||||
state: UpdateState;
|
||||
installed_version: number | null;
|
||||
latest_version: number | null;
|
||||
latest_source: string | null;
|
||||
latest_release_notes: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Write to `dataDir` and return created path
|
||||
*/
|
||||
function writeFirmwareHexToDataDir(hex: string, fileName: string | undefined, deviceIeee: string): string {
|
||||
if (!fileName) {
|
||||
fileName = `${deviceIeee}_${Date.now()}`;
|
||||
}
|
||||
|
||||
const baseDir = dataDir.joinPath("ota");
|
||||
|
||||
if (!existsSync(baseDir)) {
|
||||
mkdirSync(baseDir, {recursive: true});
|
||||
}
|
||||
|
||||
const filePath = join(baseDir, fileName);
|
||||
|
||||
writeFileSync(filePath, Buffer.from(hex, "hex"));
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export default class OTAUpdate extends Extension {
|
||||
#topicRegex = new RegExp(
|
||||
`^${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/(update|check|schedule|unschedule)/?(downgrade)?`,
|
||||
`^${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/(update|check|schedule|unschedule)/?(downgrade|abort)?`,
|
||||
"i",
|
||||
);
|
||||
private inProgress = new Set<string>();
|
||||
private lastChecked = new Map<string, number>();
|
||||
private scheduledUpgrades = new Set<string>();
|
||||
private scheduledDowngrades = new Set<string>();
|
||||
#inProgress = new Set<string>();
|
||||
#lastChecked = new Map<string, number>();
|
||||
|
||||
// biome-ignore lint/suspicious/useAwait: API
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
this.eventBus.onDeviceMessage(this, this.onZigbeeEvent);
|
||||
|
||||
const otaSettings = settings.get().ota;
|
||||
// Let OTA module know if the override index file is provided
|
||||
let overrideIndexLocation = otaSettings.zigbee_ota_override_index_location;
|
||||
|
||||
// If the file name is not a full path, then treat it as a relative to the data directory
|
||||
if (overrideIndexLocation && !ota.isValidUrl(overrideIndexLocation) && !path.isAbsolute(overrideIndexLocation)) {
|
||||
overrideIndexLocation = dataDir.joinPath(overrideIndexLocation);
|
||||
}
|
||||
|
||||
// In order to support local firmware files we need to let zigbeeOTA know where the data directory is
|
||||
ota.setConfiguration({
|
||||
dataDir: dataDir.getPath(),
|
||||
overrideIndexLocation,
|
||||
// TODO: implement me
|
||||
imageBlockResponseDelay: otaSettings.image_block_response_delay,
|
||||
defaultMaximumDataSize: otaSettings.default_maximum_data_size,
|
||||
});
|
||||
setOtaConfiguration(dataDir.getPath(), settings.get().ota.zigbee_ota_override_index_location);
|
||||
|
||||
// In case Zigbee2MQTT is restared during an update, progress and remaining values are still in state, remove them.
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
this.removeProgressAndRemainingFromState(device);
|
||||
this.#removeProgressAndRemainingFromState(device);
|
||||
|
||||
// Reset update state, e.g. when Z2M restarted during update.
|
||||
if (this.state.get(device).update?.state === "updating") {
|
||||
this.state.get(device).update.state = "available";
|
||||
this.state.get(device).update.state = "idle";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private removeProgressAndRemainingFromState(device: Device): void {
|
||||
// mostly intended for testing
|
||||
clearState(): void {
|
||||
this.#inProgress.clear();
|
||||
this.#lastChecked.clear();
|
||||
}
|
||||
|
||||
#removeProgressAndRemainingFromState(device: Device): void {
|
||||
const deviceState = this.state.get(device);
|
||||
|
||||
if (deviceState.update) {
|
||||
@@ -79,7 +90,7 @@ export default class OTAUpdate extends Extension {
|
||||
}
|
||||
|
||||
@bind private async onZigbeeEvent(data: eventdata.DeviceMessage): Promise<void> {
|
||||
if (data.type !== "commandQueryNextImageRequest" || !data.device.definition || this.inProgress.has(data.device.ieeeAddr)) {
|
||||
if (data.type !== "commandQueryNextImageRequest" || !data.device.definition || this.#inProgress.has(data.device.ieeeAddr)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -91,115 +102,84 @@ export default class OTAUpdate extends Extension {
|
||||
|
||||
logger.debug(`Device '${data.device.name}' requested OTA`);
|
||||
|
||||
if (data.device.definition.ota) {
|
||||
if (this.scheduledUpgrades.has(data.device.ieeeAddr) || this.scheduledDowngrades.has(data.device.ieeeAddr)) {
|
||||
this.inProgress.add(data.device.ieeeAddr);
|
||||
if (data.device.zh.scheduledOta) {
|
||||
// allow custom source to override check for definition `ota`
|
||||
if (data.device.zh.scheduledOta?.url !== undefined || data.device.definition.ota) {
|
||||
this.#inProgress.add(data.device.ieeeAddr);
|
||||
|
||||
logger.info(`Updating '${data.device.name}' to latest firmware`);
|
||||
|
||||
try {
|
||||
const fileVersion = await ota.update(
|
||||
data.device.zh,
|
||||
data.device.otaExtraMetas,
|
||||
this.scheduledDowngrades.has(data.device.ieeeAddr),
|
||||
async (progress, remaining) => {
|
||||
let msg = `Update of '${data.device.name}' at ${progress.toFixed(2)}%`;
|
||||
|
||||
if (remaining) {
|
||||
msg += `, ≈ ${Math.round(remaining / 60)} minutes remaining`;
|
||||
}
|
||||
|
||||
logger.info(msg);
|
||||
|
||||
await this.publishEntityState(
|
||||
data.device,
|
||||
this.getEntityPublishPayload(data.device, "updating", progress, remaining ?? undefined),
|
||||
);
|
||||
},
|
||||
data.data as Ota.ImageInfo,
|
||||
data.meta.zclTransactionSequenceNumber,
|
||||
);
|
||||
|
||||
// remove right away on update success or no image in case any of the below calls fail
|
||||
this.scheduledUpgrades.delete(data.device.ieeeAddr);
|
||||
this.scheduledDowngrades.delete(data.device.ieeeAddr);
|
||||
|
||||
if (fileVersion === undefined) {
|
||||
logger.info(`No image currently available for '${data.device.name}'. Unscheduling.`);
|
||||
|
||||
// XXX: superfluous?
|
||||
this.removeProgressAndRemainingFromState(data.device);
|
||||
await this.publishEntityState(data.device, this.getEntityPublishPayload(data.device, "idle"));
|
||||
this.inProgress.delete(data.device.ieeeAddr);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Finished update of '${data.device.name}'`);
|
||||
|
||||
this.removeProgressAndRemainingFromState(data.device);
|
||||
await this.publishEntityState(
|
||||
const otaSettings = settings.get().ota;
|
||||
const [, toVersion] = await this.#update(
|
||||
undefined, // uses internally registered schedule
|
||||
data.device,
|
||||
this.getEntityPublishPayload(data.device, {available: false, currentFileVersion: fileVersion, otaFileVersion: fileVersion}),
|
||||
data.data as Zcl.ClustersTypes.TClusterCommandPayload<"genOta", "queryNextImageRequest">,
|
||||
data.meta.zclTransactionSequenceNumber,
|
||||
{
|
||||
// fallbacks are only to satisfy typing, should always be defined from settings defaults
|
||||
requestTimeout: otaSettings.image_block_request_timeout ?? /* v8 ignore next */ 150000,
|
||||
responseDelay: otaSettings.image_block_response_delay ?? /* v8 ignore next */ 250,
|
||||
baseSize: otaSettings.default_maximum_data_size ?? /* v8 ignore next */ 50,
|
||||
},
|
||||
data.endpoint,
|
||||
);
|
||||
|
||||
const firmwareTo = await this.readSoftwareBuildIDAndDateCode(data.device);
|
||||
|
||||
logger.info(() => `Device '${data.device.name}' was updated to '${stringify(firmwareTo)}'`);
|
||||
|
||||
/**
|
||||
* Re-configure after reading software build ID and date code, some devices use a
|
||||
* custom attribute for this (e.g. Develco SMSZB-120)
|
||||
*/
|
||||
this.eventBus.emitReconfigure({device: data.device});
|
||||
this.eventBus.emitDevicesChanged();
|
||||
if (toVersion === undefined) {
|
||||
logger.info(`No OTA image currently available for '${data.device.name}'. Unscheduled.`);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.debug(`Update of '${data.device.name}' failed (${e}). Retry scheduled for next request.`);
|
||||
logger.debug(`OTA update of '${data.device.name}' failed (${e}). Retry scheduled for next request.`);
|
||||
|
||||
this.removeProgressAndRemainingFromState(data.device);
|
||||
await this.publishEntityState(data.device, this.getEntityPublishPayload(data.device, "scheduled"));
|
||||
this.#removeProgressAndRemainingFromState(data.device);
|
||||
await this.publishEntityState(data.device, this.#getEntityPublishPayload(data.device, "scheduled"));
|
||||
}
|
||||
|
||||
this.inProgress.delete(data.device.ieeeAddr);
|
||||
this.#inProgress.delete(data.device.ieeeAddr);
|
||||
|
||||
return; // we're done
|
||||
}
|
||||
}
|
||||
|
||||
if (!settings.get().ota.disable_automatic_update_check) {
|
||||
if (data.device.definition.ota) {
|
||||
if (!data.device.options.disable_automatic_update_check && !settings.get().ota.disable_automatic_update_check) {
|
||||
// When a device does a next image request, it will usually do it a few times after each other
|
||||
// with only 10 - 60 seconds inbetween. It doesn't make sense to check for a new update
|
||||
// each time, so this interval can be set by the user. The default is 1,440 minutes (one day).
|
||||
const updateCheckInterval = settings.get().ota.update_check_interval * 1000 * 60;
|
||||
const deviceLastChecked = this.lastChecked.get(data.device.ieeeAddr);
|
||||
const deviceLastChecked = this.#lastChecked.get(data.device.ieeeAddr);
|
||||
const check = deviceLastChecked !== undefined ? Date.now() - deviceLastChecked > updateCheckInterval : true;
|
||||
|
||||
if (!check) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.inProgress.add(data.device.ieeeAddr);
|
||||
this.lastChecked.set(data.device.ieeeAddr, Date.now());
|
||||
let availableResult: Ota.UpdateAvailableResult | undefined;
|
||||
this.#inProgress.add(data.device.ieeeAddr);
|
||||
this.#lastChecked.set(data.device.ieeeAddr, Date.now());
|
||||
let availableResult: OtaUpdateAvailableResult | undefined;
|
||||
|
||||
try {
|
||||
// never use 'previous' when responding to device request
|
||||
availableResult = await ota.isUpdateAvailable(data.device.zh, data.device.otaExtraMetas, data.data as Ota.ImageInfo, false);
|
||||
// auto-check defaults to zigbee-OTA + potential local index, and never `downgrade`
|
||||
availableResult = await data.device.zh.checkOta(
|
||||
{downgrade: false},
|
||||
data.data as Zcl.ClustersTypes.TClusterCommandPayload<"genOta", "queryNextImageRequest">,
|
||||
data.device.otaExtraMetas,
|
||||
data.endpoint,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to check if update available for '${data.device.name}' (${error})`);
|
||||
logger.debug(`Failed to check if OTA update available for '${data.device.name}' (${error})`);
|
||||
}
|
||||
|
||||
await this.publishEntityState(data.device, this.getEntityPublishPayload(data.device, availableResult ?? "idle"));
|
||||
await this.publishEntityState(data.device, this.#getEntityPublishPayload(data.device, availableResult ?? "idle"));
|
||||
|
||||
if (availableResult?.available) {
|
||||
const message = `Update available for '${data.device.name}'`;
|
||||
logger.info(message);
|
||||
logger.info(`OTA update available for '${data.device.name}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Respond to stop the client from requesting OTAs
|
||||
const endpoint = data.device.zh.endpoints.find((e) => e.supportsOutputCluster("genOta")) || data.endpoint;
|
||||
await endpoint.commandResponse(
|
||||
await data.endpoint.commandResponse(
|
||||
"genOta",
|
||||
"queryNextImageResponse",
|
||||
{status: Zcl.Status.NO_IMAGE_AVAILABLE},
|
||||
@@ -207,47 +187,53 @@ export default class OTAUpdate extends Extension {
|
||||
data.meta.zclTransactionSequenceNumber,
|
||||
);
|
||||
logger.debug(`Responded to OTA request of '${data.device.name}' with 'NO_IMAGE_AVAILABLE'`);
|
||||
this.inProgress.delete(data.device.ieeeAddr);
|
||||
this.#inProgress.delete(data.device.ieeeAddr);
|
||||
}
|
||||
|
||||
private async readSoftwareBuildIDAndDateCode(
|
||||
async #readSoftwareBuildIDAndDateCode(
|
||||
device: Device,
|
||||
sendPolicy?: "immediate",
|
||||
): Promise<{softwareBuildID: string; dateCode: string} | undefined> {
|
||||
try {
|
||||
const endpoint = device.zh.endpoints.find((e) => e.supportsInputCluster("genBasic"));
|
||||
assert(endpoint);
|
||||
|
||||
const result = await endpoint.read("genBasic", ["dateCode", "swBuildId"], {sendPolicy});
|
||||
|
||||
return {softwareBuildID: result.swBuildId, dateCode: result.dateCode};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private getEntityPublishPayload(
|
||||
device: Device,
|
||||
state: Ota.UpdateAvailableResult | UpdateState,
|
||||
progress?: number,
|
||||
remaining?: number,
|
||||
): UpdatePayload {
|
||||
const deviceUpdateState = this.state.get(device).update;
|
||||
const payload: UpdatePayload = {
|
||||
update: {
|
||||
state: typeof state === "string" ? state : state.available ? "available" : "idle",
|
||||
installed_version: typeof state === "string" ? deviceUpdateState?.installed_version : state.currentFileVersion,
|
||||
latest_version: typeof state === "string" ? deviceUpdateState?.latest_version : state.otaFileVersion,
|
||||
},
|
||||
};
|
||||
#getEntityPublishPayload(device: Device, state: OtaUpdateAvailableResult | UpdateState, progress?: number, remaining?: number): UpdatePayload {
|
||||
const deviceUpdateState = this.state.get(device).update as UpdatePayload["update"];
|
||||
const update: UpdatePayload["update"] =
|
||||
typeof state === "string"
|
||||
? {
|
||||
state,
|
||||
installed_version: deviceUpdateState?.installed_version,
|
||||
latest_version: deviceUpdateState?.latest_version,
|
||||
latest_source: deviceUpdateState?.latest_source,
|
||||
latest_release_notes: deviceUpdateState?.latest_release_notes,
|
||||
}
|
||||
: {
|
||||
state: state.available ? "available" : "idle",
|
||||
installed_version: state.current.fileVersion,
|
||||
latest_version: state.availableMeta?.fileVersion ?? state.current.fileVersion,
|
||||
latest_source: state.availableMeta?.url || null,
|
||||
latest_release_notes: state.availableMeta?.releaseNotes || null,
|
||||
};
|
||||
|
||||
if (progress !== undefined) {
|
||||
payload.update.progress = progress;
|
||||
update.progress = progress;
|
||||
}
|
||||
|
||||
if (remaining !== undefined) {
|
||||
payload.update.remaining = Math.round(remaining);
|
||||
update.remaining = Math.round(remaining);
|
||||
}
|
||||
|
||||
return payload;
|
||||
return {update};
|
||||
}
|
||||
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
@@ -262,46 +248,86 @@ export default class OTAUpdate extends Extension {
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/check/downgrade"]
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/update"]
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/update/downgrade"]
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/update/abort"]
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/schedule"]
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/schedule/downgrade"]
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/unschedule"];
|
||||
const ID = (typeof message === "object" && message.id !== undefined ? message.id : message) as string;
|
||||
const device = this.zigbee.resolveEntity(ID);
|
||||
// TODO: deprecated 3.0 should remove string payload, enforce object
|
||||
const messageObject = typeof message === "object";
|
||||
|
||||
if (messageObject) {
|
||||
assert(message.id, "Invalid payload");
|
||||
}
|
||||
|
||||
const id = (messageObject ? message.id : message) as string;
|
||||
const device = this.zigbee.resolveEntity(id);
|
||||
const type = topicMatch[1] as "check" | "update" | "schedule" | "unschedule";
|
||||
const downgrade = topicMatch[2] === "downgrade";
|
||||
const abort = topicMatch[2] === "abort";
|
||||
let error: string | undefined;
|
||||
let errorStack: string | undefined;
|
||||
|
||||
if (!(device instanceof Device)) {
|
||||
error = `Device '${ID}' does not exist`;
|
||||
} else if (!device.definition || !device.definition.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
} else if (this.inProgress.has(device.ieeeAddr)) {
|
||||
// also guards against scheduling while check/update op in progress that could result in undesired OTA state
|
||||
error = `Update or check for update already in progress for '${device.name}'`;
|
||||
error = `Device '${id}' does not exist`;
|
||||
} else if (this.#inProgress.has(device.ieeeAddr)) {
|
||||
if (abort) {
|
||||
device.zh.abortOta();
|
||||
this.#inProgress.delete(device.ieeeAddr);
|
||||
// cleanup same as a fail
|
||||
this.#removeProgressAndRemainingFromState(device);
|
||||
await this.publishEntityState(device, this.#getEntityPublishPayload(device, "available"));
|
||||
await this.mqtt.publish(
|
||||
"bridge/response/device/ota_update/update/abort",
|
||||
stringify(utils.getResponse<"bridge/response/device/ota_update/update/abort">(message, {id})),
|
||||
);
|
||||
} else {
|
||||
// also guards against scheduling while check/update op in progress that could result in undesired OTA state
|
||||
error = `OTA update or check for update already in progress for '${device.name}'`;
|
||||
}
|
||||
} else {
|
||||
switch (type) {
|
||||
case "check": {
|
||||
this.inProgress.add(device.ieeeAddr);
|
||||
this.#inProgress.add(device.ieeeAddr);
|
||||
|
||||
logger.info(`Checking if update available for '${device.name}'`);
|
||||
const source: OtaSource = {downgrade};
|
||||
|
||||
if (messageObject) {
|
||||
const payload = message as
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/check"]
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/check/downgrade"];
|
||||
|
||||
if (payload.url) {
|
||||
source.url = payload.url;
|
||||
} else if (!device.definition?.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
break;
|
||||
}
|
||||
} else if (!device.definition?.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
break;
|
||||
}
|
||||
|
||||
logger.info(`Checking if OTA update available for '${device.name}'`);
|
||||
|
||||
try {
|
||||
const availableResult = await ota.isUpdateAvailable(device.zh, device.otaExtraMetas, undefined, downgrade);
|
||||
const availableResult = await device.zh.checkOta(source, undefined, device.otaExtraMetas);
|
||||
|
||||
logger.info(`${availableResult.available ? "Update" : "No update"} available for '${device.name}'`);
|
||||
logger.info(`${availableResult.available ? "" : "No "}OTA update available for '${device.name}'`);
|
||||
|
||||
await this.publishEntityState(device, this.getEntityPublishPayload(device, availableResult));
|
||||
this.lastChecked.set(device.ieeeAddr, Date.now());
|
||||
await this.publishEntityState(device, this.#getEntityPublishPayload(device, availableResult));
|
||||
this.#lastChecked.set(device.ieeeAddr, Date.now());
|
||||
|
||||
const response = utils.getResponse<"bridge/response/device/ota_update/check">(message, {
|
||||
id: ID,
|
||||
id,
|
||||
update_available: availableResult.available,
|
||||
downgrade: source.downgrade,
|
||||
source: availableResult.availableMeta?.url,
|
||||
release_notes: availableResult.availableMeta?.releaseNotes,
|
||||
});
|
||||
|
||||
await this.mqtt.publish("bridge/response/device/ota_update/check", stringify(response));
|
||||
} catch (e) {
|
||||
error = `Failed to check if update available for '${device.name}' (${(e as Error).message})`;
|
||||
error = `Failed to check if OTA update available for '${device.name}' (${(e as Error).message})`;
|
||||
errorStack = (e as Error).stack;
|
||||
}
|
||||
|
||||
@@ -309,94 +335,118 @@ export default class OTAUpdate extends Extension {
|
||||
}
|
||||
|
||||
case "update": {
|
||||
this.inProgress.add(device.ieeeAddr);
|
||||
|
||||
if (this.scheduledUpgrades.delete(device.ieeeAddr)) {
|
||||
logger.info(`Previously scheduled '${device.name}' upgrade was cancelled by manual update`);
|
||||
} else if (this.scheduledDowngrades.delete(device.ieeeAddr)) {
|
||||
logger.info(`Previously scheduled '${device.name}' downgrade was cancelled by manual update`);
|
||||
if (abort) {
|
||||
error = `No OTA in progress to abort for device '${device.name}'`;
|
||||
break;
|
||||
}
|
||||
|
||||
logger.info(`Updating '${device.name}' to ${downgrade ? "previous" : "latest"} firmware`);
|
||||
this.#inProgress.add(device.ieeeAddr);
|
||||
|
||||
try {
|
||||
const firmwareFrom = await this.readSoftwareBuildIDAndDateCode(device, "immediate");
|
||||
const fileVersion = await ota.update(device.zh, device.otaExtraMetas, downgrade, async (progress, remaining) => {
|
||||
let msg = `Update of '${device.name}' at ${progress.toFixed(2)}%`;
|
||||
const otaSettings = settings.get().ota;
|
||||
const source: OtaSource = {downgrade};
|
||||
const dataSettings: OtaDataSettings = {
|
||||
// fallbacks are only to satisfy typing, should always be defined from settings defaults
|
||||
requestTimeout: otaSettings.image_block_request_timeout ?? /* v8 ignore next */ 150000,
|
||||
responseDelay: otaSettings.image_block_response_delay ?? /* v8 ignore next */ 250,
|
||||
baseSize: otaSettings.default_maximum_data_size ?? /* v8 ignore next */ 50,
|
||||
};
|
||||
|
||||
if (remaining) {
|
||||
msg += `, ≈ ${Math.round(remaining / 60)} minutes remaining`;
|
||||
}
|
||||
if (messageObject) {
|
||||
const payload = message as
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/update"]
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/update/downgrade"];
|
||||
|
||||
logger.info(msg);
|
||||
if (payload.hex) {
|
||||
assert(payload.hex.data);
|
||||
|
||||
await this.publishEntityState(device, this.getEntityPublishPayload(device, "updating", progress, remaining ?? undefined));
|
||||
});
|
||||
|
||||
if (fileVersion === undefined) {
|
||||
throw new Error("No image currently available");
|
||||
// write to `dataDir` and pass created path as source URL
|
||||
source.url = writeFirmwareHexToDataDir(payload.hex.data, payload.hex.file_name, device.ieeeAddr);
|
||||
} else if (payload.url) {
|
||||
source.url = payload.url;
|
||||
} else if (!device.definition?.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
break;
|
||||
}
|
||||
|
||||
logger.info(`Finished update of '${device.name}'`);
|
||||
this.removeProgressAndRemainingFromState(device);
|
||||
await this.publishEntityState(
|
||||
device,
|
||||
this.getEntityPublishPayload(device, {available: false, currentFileVersion: fileVersion, otaFileVersion: fileVersion}),
|
||||
);
|
||||
if (payload.image_block_request_timeout) {
|
||||
dataSettings.requestTimeout = payload.image_block_request_timeout;
|
||||
}
|
||||
|
||||
const firmwareTo = await this.readSoftwareBuildIDAndDateCode(device);
|
||||
if (payload.image_block_response_delay) {
|
||||
dataSettings.responseDelay = payload.image_block_response_delay;
|
||||
}
|
||||
|
||||
logger.info(() => `Device '${device.name}' was updated from '${stringify(firmwareFrom)}' to '${stringify(firmwareTo)}'`);
|
||||
if (payload.default_maximum_data_size) {
|
||||
dataSettings.baseSize = payload.default_maximum_data_size;
|
||||
}
|
||||
} else if (!device.definition?.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-configure after reading software build ID and date code, some devices use a
|
||||
* custom attribute for this (e.g. Develco SMSZB-120)
|
||||
*/
|
||||
this.eventBus.emitReconfigure({device});
|
||||
this.eventBus.emitDevicesChanged();
|
||||
logger.info(`OTA updating '${device.name}' to ${downgrade ? "previous" : "latest"} firmware`);
|
||||
|
||||
try {
|
||||
const firmwareFrom = await this.#readSoftwareBuildIDAndDateCode(device, "immediate");
|
||||
const [fromVersion, toVersion] = await this.#update(source, device, undefined, undefined, dataSettings);
|
||||
|
||||
if (toVersion === undefined) {
|
||||
error = `Update of '${device.name}' failed (No image currently available)`;
|
||||
break;
|
||||
}
|
||||
|
||||
const firmwareTo = await this.#readSoftwareBuildIDAndDateCode(device);
|
||||
const response = utils.getResponse<"bridge/response/device/ota_update/update">(message, {
|
||||
id: ID,
|
||||
from: firmwareFrom ? {software_build_id: firmwareFrom.softwareBuildID, date_code: firmwareFrom.dateCode} : undefined,
|
||||
to: firmwareTo ? {software_build_id: firmwareTo.softwareBuildID, date_code: firmwareTo.dateCode} : undefined,
|
||||
id,
|
||||
from: {
|
||||
file_version: fromVersion,
|
||||
software_build_id: firmwareFrom?.softwareBuildID,
|
||||
date_code: firmwareFrom?.dateCode,
|
||||
},
|
||||
to: {file_version: toVersion, software_build_id: firmwareTo?.softwareBuildID, date_code: firmwareTo?.dateCode},
|
||||
});
|
||||
|
||||
await this.mqtt.publish("bridge/response/device/ota_update/update", stringify(response));
|
||||
} catch (e) {
|
||||
logger.debug(`Update of '${device.name}' failed (${e})`);
|
||||
error = `Update of '${device.name}' failed (${(e as Error).message})`;
|
||||
logger.debug(`OTA update of '${device.name}' failed (${e})`);
|
||||
error = `OTA update of '${device.name}' failed (${(e as Error).message})`;
|
||||
errorStack = (e as Error).stack;
|
||||
|
||||
this.removeProgressAndRemainingFromState(device);
|
||||
await this.publishEntityState(device, this.getEntityPublishPayload(device, "available"));
|
||||
this.#removeProgressAndRemainingFromState(device);
|
||||
await this.publishEntityState(device, this.#getEntityPublishPayload(device, "available"));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "schedule": {
|
||||
// ensure only one type scheduled by deleting from the other if necessary
|
||||
if (downgrade) {
|
||||
if (this.scheduledUpgrades.delete(device.ieeeAddr)) {
|
||||
logger.info(`Previously scheduled '${device.name}' upgrade was cancelled in favor of new downgrade request`);
|
||||
}
|
||||
const source: OtaSource = {downgrade};
|
||||
|
||||
this.scheduledDowngrades.add(device.ieeeAddr);
|
||||
} else {
|
||||
if (this.scheduledDowngrades.delete(device.ieeeAddr)) {
|
||||
logger.info(`Previously scheduled '${device.name}' downgrade was cancelled in favor of new upgrade request`);
|
||||
}
|
||||
if (messageObject) {
|
||||
const payload = message as
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/schedule"]
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/schedule/downgrade"];
|
||||
|
||||
this.scheduledUpgrades.add(device.ieeeAddr);
|
||||
if (payload.hex) {
|
||||
assert(payload.hex.data);
|
||||
|
||||
// write to `dataDir` and pass created path as source URL
|
||||
source.url = writeFirmwareHexToDataDir(payload.hex.data, payload.hex.file_name, device.ieeeAddr);
|
||||
} else if (payload.url) {
|
||||
source.url = payload.url;
|
||||
} else if (!device.definition?.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
break;
|
||||
}
|
||||
} else if (!device.definition?.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
break;
|
||||
}
|
||||
|
||||
logger.info(`Scheduled '${device.name}' to ${downgrade ? "downgrade" : "upgrade"} firmware on next request from device`);
|
||||
device.zh.scheduleOta(source);
|
||||
await this.publishEntityState(device, this.#getEntityPublishPayload(device, "scheduled"));
|
||||
|
||||
await this.publishEntityState(device, this.getEntityPublishPayload(device, "scheduled", undefined, undefined));
|
||||
|
||||
const response = utils.getResponse<"bridge/response/device/ota_update/schedule">(message, {
|
||||
id: ID,
|
||||
});
|
||||
const response = utils.getResponse<"bridge/response/device/ota_update/schedule">(message, {id, url: source.url});
|
||||
|
||||
await this.mqtt.publish("bridge/response/device/ota_update/schedule", stringify(response));
|
||||
|
||||
@@ -404,17 +454,14 @@ export default class OTAUpdate extends Extension {
|
||||
}
|
||||
|
||||
case "unschedule": {
|
||||
if (this.scheduledUpgrades.delete(device.ieeeAddr)) {
|
||||
logger.info(`Previously scheduled '${device.name}' upgrade was cancelled`);
|
||||
} else if (this.scheduledDowngrades.delete(device.ieeeAddr)) {
|
||||
logger.info(`Previously scheduled '${device.name}' downgrade was cancelled`);
|
||||
if (device.zh.scheduledOta?.url?.startsWith(dataDir.joinPath("ota"))) {
|
||||
rmSync(device.zh.scheduledOta.url, {force: true});
|
||||
}
|
||||
|
||||
await this.publishEntityState(device, this.getEntityPublishPayload(device, "idle", undefined, undefined));
|
||||
device.zh.unscheduleOta();
|
||||
await this.publishEntityState(device, this.#getEntityPublishPayload(device, "idle"));
|
||||
|
||||
const response = utils.getResponse<"bridge/response/device/ota_update/unschedule">(message, {
|
||||
id: ID,
|
||||
});
|
||||
const response = utils.getResponse<"bridge/response/device/ota_update/unschedule">(message, {id});
|
||||
|
||||
await this.mqtt.publish("bridge/response/device/ota_update/unschedule", stringify(response));
|
||||
|
||||
@@ -422,7 +469,7 @@ export default class OTAUpdate extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
this.inProgress.delete(device.ieeeAddr);
|
||||
this.#inProgress.delete(device.ieeeAddr);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
@@ -436,4 +483,59 @@ export default class OTAUpdate extends Extension {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Do the bulk of the update work (hand over to zigbee-herdsman, then re-interview).
|
||||
*
|
||||
* `dataSettings` object may be mutated by zigbee-herdsman to fit request (e.g. known manuf quirk)
|
||||
*/
|
||||
async #update(
|
||||
source: OtaSource | undefined,
|
||||
device: Device,
|
||||
requestPayload: Zcl.ClustersTypes.TClusterCommandPayload<"genOta", "queryNextImageRequest"> | undefined,
|
||||
requestTsn: number | undefined,
|
||||
dataSettings: OtaDataSettings,
|
||||
endpoint?: zh.Endpoint,
|
||||
): Promise<[from: number, to: number | undefined]> {
|
||||
const [from, to] = await device.zh.updateOta(
|
||||
source,
|
||||
requestPayload,
|
||||
requestTsn,
|
||||
device.otaExtraMetas,
|
||||
async (progress, remaining) => {
|
||||
await this.publishEntityState(device, this.#getEntityPublishPayload(device, "updating", progress, remaining));
|
||||
},
|
||||
dataSettings,
|
||||
endpoint,
|
||||
);
|
||||
|
||||
if (to === undefined) {
|
||||
this.#removeProgressAndRemainingFromState(device);
|
||||
await this.publishEntityState(device, this.#getEntityPublishPayload(device, {available: false, current: from}));
|
||||
|
||||
return [from.fileVersion, undefined];
|
||||
}
|
||||
|
||||
logger.info(`Finished update of '${device.name}'`);
|
||||
|
||||
this.#removeProgressAndRemainingFromState(device);
|
||||
await this.publishEntityState(device, this.#getEntityPublishPayload(device, {available: false, current: to}));
|
||||
|
||||
logger.info(() => `Device '${device.name}' was OTA updated from '${from.fileVersion}' to '${to.fileVersion}'`);
|
||||
|
||||
// OTA update can bring new features & co, force full re-interview and re-configure, same as a "device joined"
|
||||
if (device.zh.meta.configured !== undefined) {
|
||||
delete device.zh.meta.configured;
|
||||
|
||||
device.zh.save();
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
device.reInterview(this.eventBus).catch((error) => {
|
||||
logger.error(`${error.message}. Re-try manually after some time.`);
|
||||
});
|
||||
}, 5000);
|
||||
|
||||
return [from.fileVersion, to.fileVersion];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import type * as zhc from "zigbee-herdsman-converters";
|
||||
|
||||
import Device from "../model/device";
|
||||
import Group from "../model/group";
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import {stringify} from "../util/stringify";
|
||||
import utils from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
@@ -225,6 +224,11 @@ export default class Publish extends Extension {
|
||||
state: entityState,
|
||||
membersState,
|
||||
mapped: definition,
|
||||
/* v8 ignore start */
|
||||
deviceExposesChanged: (): void => {
|
||||
if (re instanceof Device) this.eventBus.emitExposesAndDevicesChanged(re);
|
||||
},
|
||||
/* v8 ignore stop */
|
||||
/* v8 ignore next */
|
||||
publish: (payload: KeyValue) => this.publishEntityState(re, payload),
|
||||
};
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import assert from "node:assert";
|
||||
|
||||
import bind from "bind-decorator";
|
||||
import debounce from "debounce";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import throttle from "throttleit";
|
||||
|
||||
import * as zhc from "zigbee-herdsman-converters";
|
||||
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import {stringify} from "../util/stringify";
|
||||
import utils from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
@@ -183,8 +180,11 @@ export default class Receive extends Extension {
|
||||
|
||||
if (!utils.objectIsEmpty(payload)) {
|
||||
await publish(payload);
|
||||
} else {
|
||||
await utils.publishLastSeen({device: data.device, reason: "messageEmitted"}, settings.get(), true, this.publishEntityState);
|
||||
} else if (settings.get().advanced.last_seen && settings.get().advanced.last_seen !== "disable") {
|
||||
// A message was received that produced no payload (e.g. a frame the converter has no data
|
||||
// for). Publish through the regular publish() path so the per-device debounce/throttle
|
||||
// still applies, instead of publishing the full cached state immediately via publishLastSeen.
|
||||
await publish({});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+24
-3
@@ -1,9 +1,10 @@
|
||||
import assert from "node:assert";
|
||||
import {InterviewState} from "zigbee-herdsman/dist/controller/model/device";
|
||||
import type {OtaExtraMetas} from "zigbee-herdsman/dist/controller/tstype";
|
||||
import type {CustomClusters} from "zigbee-herdsman/dist/zspec/zcl/definition/tstype";
|
||||
import * as zhc from "zigbee-herdsman-converters";
|
||||
import {access, Numeric} from "zigbee-herdsman-converters";
|
||||
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
|
||||
const LINKQUALITY = new Numeric("linkquality", access.STATE)
|
||||
@@ -17,6 +18,7 @@ export default class Device {
|
||||
public zh: zh.Device;
|
||||
public definition?: zhc.Definition;
|
||||
private _definitionModelID?: string;
|
||||
#isResolvingDefinition?: boolean;
|
||||
|
||||
get ieeeAddr(): string {
|
||||
return this.zh.ieeeAddr;
|
||||
@@ -38,7 +40,7 @@ export default class Device {
|
||||
get customClusters(): CustomClusters {
|
||||
return this.zh.customClusters;
|
||||
}
|
||||
get otaExtraMetas(): zhc.Ota.ExtraMetas {
|
||||
get otaExtraMetas(): OtaExtraMetas {
|
||||
return typeof this.definition?.ota === "object" ? this.definition.ota : {};
|
||||
}
|
||||
get interviewed(): boolean {
|
||||
@@ -63,9 +65,28 @@ export default class Device {
|
||||
}
|
||||
|
||||
async resolveDefinition(ignoreCache = false): Promise<void> {
|
||||
if (this.interviewed && (!this.definition || this._definitionModelID !== this.zh.modelID || ignoreCache)) {
|
||||
if (this.interviewed && !this.#isResolvingDefinition && (!this.definition || this._definitionModelID !== this.zh.modelID || ignoreCache)) {
|
||||
this.#isResolvingDefinition = true;
|
||||
this.definition = await zhc.findByDevice(this.zh, true);
|
||||
this._definitionModelID = this.zh.modelID;
|
||||
this.#isResolvingDefinition = false;
|
||||
}
|
||||
}
|
||||
|
||||
async reInterview(eventBus: EventBus): Promise<void> {
|
||||
// logic follows that of receiving `deviceInterview` event from ZH layer
|
||||
logger.info(`Interviewing '${this.name}'`);
|
||||
eventBus.emitDeviceInterview({status: "started", device: this});
|
||||
|
||||
try {
|
||||
await this.zh.interview(true);
|
||||
// A re-interview can for example result in a different modelId, therefore reconsider the definition.
|
||||
await this.resolveDefinition(true);
|
||||
logger.info(`Successfully interviewed '${this.name}'`);
|
||||
eventBus.emitDeviceInterview({status: "successful", device: this});
|
||||
} catch (error) {
|
||||
eventBus.emitDeviceInterview({status: "failed", device: this});
|
||||
throw new Error(`Interview of '${this.name}' (${this.ieeeAddr}) failed: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-1
@@ -109,6 +109,11 @@ export default class Mqtt {
|
||||
options.rejectUnauthorized = false;
|
||||
}
|
||||
|
||||
if (mqttSettings.server_name) {
|
||||
logger.debug(`MQTT SSL/TLS: SNI server name = ${mqttSettings.server_name}`);
|
||||
options.servername = mqttSettings.server_name;
|
||||
}
|
||||
|
||||
this.client = await connectAsync(mqttSettings.server, options);
|
||||
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/9822
|
||||
@@ -152,7 +157,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 +203,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.`);
|
||||
|
||||
+9
-8
@@ -1,9 +1,8 @@
|
||||
import {existsSync, readFileSync, writeFileSync} from "node:fs";
|
||||
|
||||
import objectAssignDeep from "object-assign-deep";
|
||||
|
||||
import data from "./util/data";
|
||||
import logger from "./util/logger";
|
||||
import {objectAssignDeep} from "./util/objectAssignDeep";
|
||||
import * as settings from "./util/settings";
|
||||
import utils from "./util/utils";
|
||||
|
||||
@@ -23,7 +22,6 @@ const CACHE_IGNORE_PROPERTIES = [
|
||||
"no_occupancy_since",
|
||||
"step_mode",
|
||||
"transition_time",
|
||||
"duration",
|
||||
"elapsed",
|
||||
"from_side",
|
||||
"to_side",
|
||||
@@ -51,11 +49,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+101
-12
@@ -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,7 +32,34 @@ import type {LogLevel, schemaJson} from "../util/settings";
|
||||
// biome-ignore lint/suspicious/noExplicitAny: API
|
||||
type KeyValue = Record<string, any>;
|
||||
|
||||
export interface Zigbee2MQTTDeviceOptions {
|
||||
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 type Zigbee2MQTTDeviceOptions = {
|
||||
disabled?: boolean;
|
||||
retention?: number;
|
||||
availability?:
|
||||
@@ -55,9 +82,10 @@ export interface Zigbee2MQTTDeviceOptions {
|
||||
friendly_name: string;
|
||||
description?: string;
|
||||
qos?: 0 | 1 | 2;
|
||||
}
|
||||
disable_automatic_update_check?: boolean;
|
||||
};
|
||||
|
||||
export interface Zigbee2MQTTGroupOptions {
|
||||
export type Zigbee2MQTTGroupOptions = {
|
||||
ID: number;
|
||||
optimistic?: boolean;
|
||||
off_state?: "all_members_off" | "last_member_state";
|
||||
@@ -68,9 +96,9 @@ export interface Zigbee2MQTTGroupOptions {
|
||||
friendly_name: string;
|
||||
description?: string;
|
||||
qos?: 0 | 1 | 2;
|
||||
}
|
||||
};
|
||||
|
||||
export interface Zigbee2MQTTSettings {
|
||||
export type Zigbee2MQTTSettings = {
|
||||
version?: number;
|
||||
/** only used internally during startup, removed on successful Z2M start */
|
||||
onboarding?: true;
|
||||
@@ -105,6 +133,7 @@ export interface Zigbee2MQTTSettings {
|
||||
cert?: string;
|
||||
client_id?: string;
|
||||
reject_unauthorized?: boolean;
|
||||
server_name?: string;
|
||||
maximum_packet_size: number;
|
||||
};
|
||||
serial: {
|
||||
@@ -140,6 +169,7 @@ export interface Zigbee2MQTTSettings {
|
||||
update_check_interval: number;
|
||||
disable_automatic_update_check: boolean;
|
||||
zigbee_ota_override_index_location?: string;
|
||||
image_block_request_timeout?: number;
|
||||
image_block_response_delay?: number;
|
||||
default_maximum_data_size?: number;
|
||||
};
|
||||
@@ -186,13 +216,15 @@ export interface Zigbee2MQTTSettings {
|
||||
timestamp_format: string;
|
||||
output: "json" | "attribute" | "attribute_and_json";
|
||||
transmit_power?: number;
|
||||
/** 3.0: default to false (JSON schema & settings `defaults`) */
|
||||
enable_external_js: boolean;
|
||||
};
|
||||
health: {
|
||||
/** in minutes */
|
||||
interval: number;
|
||||
reset_on_check: boolean;
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export interface Zigbee2MQTTScene {
|
||||
id: number;
|
||||
@@ -230,6 +262,7 @@ export interface Zigbee2MQTTDeviceDefinition {
|
||||
exposes: zigbeeHerdsmanConverter.Expose[];
|
||||
supports_ota: boolean;
|
||||
options: zigbeeHerdsmanConverter.Option[];
|
||||
version: `0.0.${number}`;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
@@ -314,7 +347,7 @@ export interface Zigbee2MQTTAPI {
|
||||
};
|
||||
|
||||
"bridge/definitions": {
|
||||
clusters: Readonly<Record<ClusterName, Readonly<ClusterDefinition>>>;
|
||||
clusters: Readonly<Record<ClusterName, Cluster>>;
|
||||
custom_clusters: Record<string, CustomClusters>;
|
||||
actions: string[];
|
||||
};
|
||||
@@ -589,61 +622,115 @@ export interface Zigbee2MQTTAPI {
|
||||
id: string;
|
||||
block?: boolean;
|
||||
force?: boolean;
|
||||
keep_config?: boolean;
|
||||
clear_cache?: boolean;
|
||||
};
|
||||
|
||||
"bridge/response/device/remove": {
|
||||
id: string;
|
||||
block: boolean;
|
||||
force: boolean;
|
||||
keep_config: boolean;
|
||||
clear_cache: boolean;
|
||||
};
|
||||
|
||||
"bridge/request/device/ota_update/check": {
|
||||
id: string;
|
||||
/** expected to point to an index json if provided */
|
||||
url?: string | null;
|
||||
};
|
||||
|
||||
"bridge/request/device/ota_update/check/downgrade": {
|
||||
id: string;
|
||||
/** expected to point to an index json if provided */
|
||||
url?: string | null;
|
||||
};
|
||||
|
||||
"bridge/response/device/ota_update/check": {
|
||||
id: string;
|
||||
update_available: boolean;
|
||||
downgrade?: boolean;
|
||||
source?: string;
|
||||
release_notes?: string;
|
||||
};
|
||||
|
||||
"bridge/request/device/ota_update/update": {
|
||||
id: string;
|
||||
url?: string | null;
|
||||
/**
|
||||
* Full firmware file in hex form (expected compatible with `Buffer.from(hex, "hex")`)
|
||||
* If file name supplied, will be used instead of <ieee_utc> to store firmware in data dir.
|
||||
*/
|
||||
hex?: {data: string; file_name?: string} | null;
|
||||
image_block_request_timeout?: number | null;
|
||||
image_block_response_delay?: number | null;
|
||||
/** may be overridden internally to match specific device needs */
|
||||
default_maximum_data_size?: number | null;
|
||||
};
|
||||
|
||||
"bridge/request/device/ota_update/update/downgrade": {
|
||||
id: string;
|
||||
url?: string | null;
|
||||
/**
|
||||
* Full firmware file in hex form (expected compatible with `Buffer.from(hex, "hex")`)
|
||||
* If file name supplied, will be used instead of <ieee_utc> to store firmware in data dir.
|
||||
*/
|
||||
hex?: {data: string; file_name?: string} | null;
|
||||
image_block_request_timeout?: number | null;
|
||||
image_block_response_delay?: number | null;
|
||||
/** may be overridden internally to match specific device needs */
|
||||
default_maximum_data_size?: number | null;
|
||||
};
|
||||
|
||||
"bridge/request/device/ota_update/update/abort": {
|
||||
id: string;
|
||||
};
|
||||
|
||||
"bridge/response/device/ota_update/update/abort": {
|
||||
id: string;
|
||||
};
|
||||
|
||||
"bridge/response/device/ota_update/update": {
|
||||
id: string;
|
||||
from:
|
||||
| {
|
||||
software_build_id: string;
|
||||
date_code: string;
|
||||
file_version: number;
|
||||
software_build_id?: string;
|
||||
date_code?: string;
|
||||
}
|
||||
| undefined;
|
||||
to:
|
||||
| {
|
||||
software_build_id: string;
|
||||
date_code: string;
|
||||
file_version: number;
|
||||
software_build_id?: string;
|
||||
date_code?: string;
|
||||
}
|
||||
| undefined;
|
||||
};
|
||||
|
||||
"bridge/request/device/ota_update/schedule": {
|
||||
id: string;
|
||||
url?: string | null;
|
||||
/**
|
||||
* Full firmware file in hex form (expected compatible with `Buffer.from(hex, "hex")`)
|
||||
* If file name supplied, will be used instead of <ieee_utc> to store firmware in data dir.
|
||||
*/
|
||||
hex?: {data: string; file_name?: string} | null;
|
||||
};
|
||||
|
||||
"bridge/request/device/ota_update/schedule/downgrade": {
|
||||
id: string;
|
||||
url?: string | null;
|
||||
/**
|
||||
* Full firmware file in hex form (expected compatible with `Buffer.from(hex, "hex")`)
|
||||
* If file name supplied, will be used instead of <ieee_utc> to store firmware in data dir.
|
||||
*/
|
||||
hex?: {data: string; file_name?: string} | null;
|
||||
};
|
||||
|
||||
"bridge/response/device/ota_update/schedule": {
|
||||
id: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
"bridge/request/device/ota_update/unschedule": {
|
||||
@@ -927,6 +1014,7 @@ export type Zigbee2MQTTRequestEndpoints =
|
||||
| "bridge/request/device/ota_update/check/downgrade"
|
||||
| "bridge/request/device/ota_update/update"
|
||||
| "bridge/request/device/ota_update/update/downgrade"
|
||||
| "bridge/request/device/ota_update/update/abort"
|
||||
| "bridge/request/device/ota_update/schedule"
|
||||
| "bridge/request/device/ota_update/schedule/downgrade"
|
||||
| "bridge/request/device/ota_update/unschedule"
|
||||
@@ -976,6 +1064,7 @@ export type Zigbee2MQTTResponseEndpoints =
|
||||
| "bridge/response/device/remove"
|
||||
| "bridge/response/device/ota_update/check"
|
||||
| "bridge/response/device/ota_update/update"
|
||||
| "bridge/response/device/ota_update/update/abort"
|
||||
| "bridge/response/device/ota_update/schedule"
|
||||
| "bridge/response/device/ota_update/unschedule"
|
||||
| "bridge/response/device/interview"
|
||||
|
||||
Vendored
+6
-3
@@ -1,8 +1,5 @@
|
||||
// minimal required because of sub-deps in mqtt >= 5.14.0 to avoid requiring `dom` type
|
||||
declare global {
|
||||
// map to node, doesn't really matter, just needs to be there, and the right "type vs value" to avoid lib check problems
|
||||
/** @deprecated DOM SHIM, DO NOT USE */
|
||||
type MessagePort = import("node:worker_threads").MessagePort;
|
||||
/** @deprecated DOM SHIM, DO NOT USE */
|
||||
type Worker = import("node:worker_threads").Worker;
|
||||
/** @deprecated DOM SHIM, DO NOT USE */
|
||||
@@ -13,6 +10,12 @@ declare global {
|
||||
const removeEventListener: import("node:events").EventEmitter["removeListener"];
|
||||
/** @deprecated DOM SHIM, DO NOT USE */
|
||||
const postMessage: import("node:worker_threads").MessagePort["postMessage"];
|
||||
/**
|
||||
* Required by `srvx` <= 0.12.5, remove once a release including https://github.com/h3js/srvx/pull/288 is out.
|
||||
*
|
||||
* @deprecated DOM SHIM, DO NOT USE
|
||||
*/
|
||||
type HeadersInit = string[][] | Record<string, string> | Headers;
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
declare module "json-stable-stringify-without-jsonify" {
|
||||
export default function (obj: unknown): string;
|
||||
}
|
||||
Vendored
+3
@@ -26,6 +26,9 @@ declare global {
|
||||
type PublishEntityState = (entity: Device | Group, payload: KeyValue, stateChangeReason?: StateChangeReason) => Promise<void>;
|
||||
type RecursivePartial<T> = {[P in keyof T]?: RecursivePartial<T[P]>};
|
||||
type MakePartialExcept<T, K extends keyof T> = Partial<Omit<T, K>> & Pick<T, K>;
|
||||
/** Convert `A | B | C` into `A & B & C` */
|
||||
// biome-ignore lint/suspicious/noExplicitAny: distributive conditional requires `any`
|
||||
type UnionToIntersection<U> = (U extends any ? (x: U) => void : never) extends (x: infer I) => void ? I : never;
|
||||
interface KeyValue {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: API
|
||||
[s: string]: any;
|
||||
|
||||
Vendored
-7
@@ -5,10 +5,3 @@ declare module "zigbee2mqtt-frontend" {
|
||||
|
||||
export default frontend;
|
||||
}
|
||||
|
||||
declare module "http" {
|
||||
interface IncomingMessage {
|
||||
originalUrl?: string;
|
||||
path?: string;
|
||||
}
|
||||
}
|
||||
|
||||
+24
-24
@@ -2,34 +2,31 @@ import assert from "node:assert";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import {rimrafSync} from "rimraf";
|
||||
import winston from "winston";
|
||||
import * as settings from "./settings";
|
||||
|
||||
const NAMESPACE_SEPARATOR = ":";
|
||||
|
||||
class Logger {
|
||||
// @ts-expect-error initalized in `init`
|
||||
private level: settings.LogLevel;
|
||||
// @ts-expect-error initalized in `init`
|
||||
private output: string[];
|
||||
// @ts-expect-error initalized in `init`
|
||||
private directory: string;
|
||||
// @ts-expect-error initalized in `init`
|
||||
private logger: winston.Logger;
|
||||
// @ts-expect-error initalized in `init`
|
||||
private fileTransport: winston.transports.FileTransportInstance;
|
||||
private level!: settings.LogLevel;
|
||||
private output!: string[];
|
||||
private directory!: string;
|
||||
private logger!: winston.Logger;
|
||||
private fileTransport: winston.transports.FileTransportInstance | undefined;
|
||||
private debugNamespaceIgnoreRegex?: RegExp;
|
||||
// @ts-expect-error initalized in `init`
|
||||
private namespacedLevels: Record<string, settings.LogLevel>;
|
||||
// @ts-expect-error initalized in `init`
|
||||
private cachedNamespacedLevels: Record<string, settings.LogLevel>;
|
||||
private namespacedLevels!: Record<string, settings.LogLevel>;
|
||||
private cachedNamespacedLevels!: Record<string, settings.LogLevel>;
|
||||
|
||||
public init(): void {
|
||||
// What transports to enable
|
||||
this.output = settings.get().advanced.log_output;
|
||||
// Directory to log to
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace("T", ".").replace(/:/g, "-");
|
||||
const date = new Date();
|
||||
// offset UTC by current timezone, ISO keeps "Z" (UTC) which is then wrong but we strip it
|
||||
const timestamp = new Date(date.getTime() - date.getTimezoneOffset() * 60000)
|
||||
.toISOString()
|
||||
.slice(0, 19)
|
||||
.replace("T", ".")
|
||||
.replaceAll(":", "-");
|
||||
this.directory = settings.get().advanced.log_directory.replace("%TIMESTAMP%", timestamp);
|
||||
const logFilename = settings.get().advanced.log_file.replace("%TIMESTAMP%", timestamp);
|
||||
this.level = settings.get().advanced.log_level;
|
||||
@@ -144,11 +141,7 @@ class Logger {
|
||||
}
|
||||
|
||||
public getDebugNamespaceIgnore(): string {
|
||||
return (
|
||||
this.debugNamespaceIgnoreRegex
|
||||
?.toString()
|
||||
.slice(1, -1) /* remove slashes */ ?? ""
|
||||
);
|
||||
return this.debugNamespaceIgnoreRegex?.toString().slice(1, -1) /* remove slashes */ ?? "";
|
||||
}
|
||||
|
||||
public setDebugNamespaceIgnore(value: string): void {
|
||||
@@ -240,7 +233,11 @@ class Logger {
|
||||
|
||||
for (const dir of directories) {
|
||||
this.debug(`Removing old log directory '${dir.path}'`);
|
||||
rimrafSync(dir.path);
|
||||
try {
|
||||
fs.rmSync(dir.path, {recursive: true, force: true});
|
||||
} catch (e) {
|
||||
this.error(`Failed to remove old log directory '${dir.path}': ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -262,7 +259,10 @@ class Logger {
|
||||
// @ts-expect-error workaround
|
||||
this.fileTransport.on("open", () => this.fileTransport._dest.on("finish", resolve));
|
||||
}
|
||||
this.fileTransport.end();
|
||||
|
||||
if (this.fileTransport) {
|
||||
this.fileTransport.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/** Anything mergeable: a plain-ish object, explicitly not an array or another iterable. */
|
||||
export type UnknownRecord = Record<string | number, unknown> & {[Symbol.iterator]?: never};
|
||||
|
||||
function isUnknownRecord(value: unknown): value is UnknownRecord {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function cloneArray(input: readonly unknown[]): unknown[] {
|
||||
const len = input.length;
|
||||
const output: unknown[] = new Array(len);
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
const val = input[i];
|
||||
output[i] = isUnknownRecord(val) ? cloneObject(val) : Array.isArray(val) ? cloneArray(val) : val;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function cloneObject(input: UnknownRecord): UnknownRecord {
|
||||
const output: UnknownRecord = {};
|
||||
|
||||
for (const key of Object.keys(input)) {
|
||||
if (key !== "__proto__" && key !== "constructor" && key !== "prototype") {
|
||||
const val = input[key];
|
||||
output[key] = isUnknownRecord(val) ? cloneObject(val) : Array.isArray(val) ? cloneArray(val) : val;
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge all sources into `target` recursively.
|
||||
*
|
||||
* Key behavior:
|
||||
* - ignore properties `__proto__`, `constructor` & `prototype`
|
||||
* - assumes no infinite circular possible (unhandled for perf)
|
||||
*
|
||||
* Pass empty object `{}` as `target` to return a new object without modifying any existing objects.
|
||||
*/
|
||||
export function objectAssignDeep<T extends UnknownRecord, S extends readonly UnknownRecord[]>(
|
||||
target: T,
|
||||
...sources: S
|
||||
): T & UnionToIntersection<S[number]> {
|
||||
for (const source of sources) {
|
||||
for (const key of Object.keys(source)) {
|
||||
if (key === "__proto__" || key === "constructor" || key === "prototype") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = source[key];
|
||||
|
||||
if (isUnknownRecord(value)) {
|
||||
const existing = target[key];
|
||||
|
||||
(target as UnknownRecord)[key] = isUnknownRecord(existing) ? objectAssignDeep({}, existing, value) : cloneObject(value);
|
||||
} else if (Array.isArray(value)) {
|
||||
(target as UnknownRecord)[key] = cloneArray(value);
|
||||
} else {
|
||||
(target as UnknownRecord)[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return target as T & UnionToIntersection<S[number]>;
|
||||
}
|
||||
+171
-461
@@ -1,398 +1,84 @@
|
||||
import {existsSync, mkdirSync} from "node:fs";
|
||||
import {existsSync, mkdirSync, writeFileSync} from "node:fs";
|
||||
import {createServer} from "node:http";
|
||||
import {parse} from "node:querystring";
|
||||
import path from "node:path";
|
||||
import {type Unzipped, unzip} from "fflate";
|
||||
import {findAllDevices} from "zigbee-herdsman/dist/adapter/adapterDiscovery";
|
||||
import type {OnboardData, OnboardFailureData, OnboardSubmitResponse, Zigbee2MQTTSettings} from "../types/api";
|
||||
import {stringify} from "../util/stringify";
|
||||
import data from "./data";
|
||||
import * as settings from "./settings";
|
||||
import {createStaticFileServer} from "./staticFileServer";
|
||||
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"];
|
||||
};
|
||||
|
||||
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 entries = await new Promise<Unzipped>((resolve, reject) => {
|
||||
unzip(zipContent, (error, data) => (error ? reject(error) : resolve(data)));
|
||||
});
|
||||
|
||||
for (const name in entries) {
|
||||
const targetPath = getZipEntryTargetPath(name);
|
||||
|
||||
// directory entries are identified by a trailing slash
|
||||
if (name.endsWith("/")) {
|
||||
mkdirSync(targetPath, {recursive: true});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
mkdirSync(path.dirname(targetPath), {recursive: true});
|
||||
writeFileSync(targetPath, entries[name]);
|
||||
}
|
||||
}
|
||||
|
||||
async function startOnboardingServer(): Promise<boolean> {
|
||||
const currentSettings = settings.get();
|
||||
const serverUrl = getServerUrl();
|
||||
let server: ReturnType<typeof createServer> | undefined;
|
||||
let failed = false;
|
||||
const fileServer = createStaticFileServer((await import("zigbee2mqtt-windfront")).default.getOnboardingPath(), console.error);
|
||||
|
||||
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 +86,106 @@ 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()));
|
||||
}
|
||||
|
||||
fileServer(req, res);
|
||||
});
|
||||
|
||||
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 +198,35 @@ 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 = createStaticFileServer((await import("zigbee2mqtt-windfront")).default.getOnboardingPath(), console.error);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
fileServer(req, res);
|
||||
});
|
||||
|
||||
server.listen(Number.parseInt(serverUrl.port, 10), serverUrl.hostname, () => {
|
||||
@@ -524,23 +238,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"title": "Enabled",
|
||||
"description": "Enable Home Assistant integration",
|
||||
"description": "Enable Home Assistant integration. Also check 'cache_state' and 'output' options under 'advanced'.",
|
||||
"default": false,
|
||||
"requiresRestart": true
|
||||
},
|
||||
@@ -33,12 +33,14 @@
|
||||
"type": "boolean",
|
||||
"title": "Home Assistant legacy action sensors",
|
||||
"description": "Home Assistant legacy actions sensor, when enabled a action sensor will be discoverd and an empty `action` will be send after every published action.",
|
||||
"requiresRestart": true,
|
||||
"default": false
|
||||
},
|
||||
"experimental_event_entities": {
|
||||
"type": "boolean",
|
||||
"title": "Home Assistant experimental event entities",
|
||||
"description": "Home Assistant experimental event entities, when enabled Zigbee2MQTT will add event entities for exposed actions. The events and attributes are currently deemed experimental and subject to change.",
|
||||
"requiresRestart": true,
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
@@ -88,7 +90,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"]
|
||||
@@ -188,6 +190,13 @@
|
||||
"description": "Disable self-signed SSL certificate",
|
||||
"default": true
|
||||
},
|
||||
"server_name": {
|
||||
"type": "string",
|
||||
"title": "TLS server name (SNI)",
|
||||
"requiresRestart": true,
|
||||
"description": "Override the TLS SNI / hostname used for certificate verification when it differs from the host in 'server' (e.g. connecting to an internal service DNS name while validating a public certificate SAN). Leave unset to use the hostname from 'server'.",
|
||||
"examples": ["mqtt.example.com"]
|
||||
},
|
||||
"include_device_information": {
|
||||
"type": "boolean",
|
||||
"title": "Include device information",
|
||||
@@ -366,13 +375,20 @@
|
||||
"description": "Location of override OTA index file",
|
||||
"examples": ["index.json"]
|
||||
},
|
||||
"image_block_request_timeout": {
|
||||
"type": "number",
|
||||
"title": "Image block request timeout",
|
||||
"description": "Timeout (in milliseconds) during OTA updates. You can increase this value if your device is requesting blocks too slowly.",
|
||||
"default": 150000,
|
||||
"minimum": 10000,
|
||||
"maximum": 2147483647
|
||||
},
|
||||
"image_block_response_delay": {
|
||||
"type": "number",
|
||||
"title": "Image block response delay",
|
||||
"description": "Limits the rate of requests (in milliseconds) during OTA updates to reduce network congestion. You can increase this value if your network appears unstable during OTA.",
|
||||
"default": 250,
|
||||
"minimum": 50,
|
||||
"requiresRestart": true
|
||||
"minimum": 50
|
||||
},
|
||||
"default_maximum_data_size": {
|
||||
"type": "number",
|
||||
@@ -380,8 +396,7 @@
|
||||
"description": "The size of file chunks sent during an update (in bytes). Note: This value may get ignored for manufacturers that require specific values.",
|
||||
"default": 50,
|
||||
"minimum": 10,
|
||||
"maximum": 100,
|
||||
"requiresRestart": true
|
||||
"maximum": 100
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -733,7 +748,7 @@
|
||||
"cache_state": {
|
||||
"type": "boolean",
|
||||
"title": "Cache state",
|
||||
"description": "MQTT message payload will contain all attributes, not only changed ones. Has to be true when integrating via Home Assistant",
|
||||
"description": "MQTT message payload will contain all attributes, not only changed ones. Must be true when integrating via Home Assistant",
|
||||
"default": true
|
||||
},
|
||||
"cache_state_persistent": {
|
||||
@@ -794,14 +809,21 @@
|
||||
"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",
|
||||
"enum": ["attribute_and_json", "attribute", "json"],
|
||||
"title": "MQTT output type",
|
||||
"description": "Examples when 'state' of a device is published json: topic: 'zigbee2mqtt/my_bulb' payload '{\"state\": \"ON\"}' attribute: topic 'zigbee2mqtt/my_bulb/state' payload 'ON' attribute_and_json: both json and attribute (see above)",
|
||||
"description": "How the 'state' of a device is published. json: topic 'zigbee2mqtt/my_bulb' payload '{\"state\": \"ON\"}'. attribute: topic 'zigbee2mqtt/my_bulb/state' payload 'ON'. attribute_and_json: both json and attribute (see above). Home Assistant requires json",
|
||||
"default": "json"
|
||||
},
|
||||
"enable_external_js": {
|
||||
"type": "boolean",
|
||||
"title": "Enable external JS",
|
||||
"description": "Enable external JavaScript (extensions and converters) that can execute arbitrary user-provided code. WARNING: If unused, it is advised to disable this.",
|
||||
"default": true,
|
||||
"requiresRestart": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -842,7 +864,8 @@
|
||||
"retain": {
|
||||
"type": "boolean",
|
||||
"title": "Retain",
|
||||
"description": "Retain MQTT messages of this device"
|
||||
"description": "Retain MQTT messages of this device",
|
||||
"default": false
|
||||
},
|
||||
"disabled": {
|
||||
"type": "boolean",
|
||||
@@ -930,6 +953,12 @@
|
||||
"description": "Name of the device in Home Assistant"
|
||||
}
|
||||
}
|
||||
},
|
||||
"disable_automatic_update_check": {
|
||||
"type": "boolean",
|
||||
"title": "Disable automatic update check",
|
||||
"description": "Zigbee devices may request a firmware update, and do so frequently, causing Zigbee2MQTT to reach out to third party servers. If you disable these device initiated checks, you can still initiate a firmware update check manually.",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": ["friendly_name"]
|
||||
@@ -941,10 +970,16 @@
|
||||
"type": "string"
|
||||
},
|
||||
"retain": {
|
||||
"type": "boolean"
|
||||
"type": "boolean",
|
||||
"title": "Retain",
|
||||
"description": "Retain MQTT messages of this group",
|
||||
"default": false
|
||||
},
|
||||
"optimistic": {
|
||||
"type": "boolean"
|
||||
"type": "boolean",
|
||||
"title": "Optimistic",
|
||||
"description": "Publish the expected state of group members after set",
|
||||
"default": true
|
||||
},
|
||||
"qos": {
|
||||
"type": ["number"],
|
||||
@@ -965,6 +1000,17 @@
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"homeassistant": {
|
||||
"type": ["object", "null"],
|
||||
"title": "Home Assistant",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": ["string", "null"],
|
||||
"title": "Home Assistant name",
|
||||
"description": "Name of the group in Home Assistant"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["friendly_name"]
|
||||
|
||||
+11
-8
@@ -1,8 +1,8 @@
|
||||
import path from "node:path";
|
||||
import type {ValidateFunction} from "ajv";
|
||||
import Ajv from "ajv";
|
||||
import objectAssignDeep from "object-assign-deep";
|
||||
import data from "./data";
|
||||
import {objectAssignDeep} from "./objectAssignDeep";
|
||||
import schemaJson from "./settings.schema.json";
|
||||
import utils from "./utils";
|
||||
import yaml from "./yaml";
|
||||
@@ -10,7 +10,7 @@ import yaml from "./yaml";
|
||||
export {schemaJson};
|
||||
// When updating also update:
|
||||
// - https://github.com/Koenkk/zigbee2mqtt/blob/dev/data/configuration.example.yaml#L2
|
||||
export const CURRENT_VERSION = 4;
|
||||
export const CURRENT_VERSION = 5;
|
||||
/** NOTE: by order of priority, lower index is lower level (more important) */
|
||||
export const LOG_LEVELS: readonly string[] = ["error", "warning", "info", "debug"] as const;
|
||||
export type LogLevel = "error" | "warning" | "info" | "debug";
|
||||
@@ -82,6 +82,7 @@ export const defaults = {
|
||||
ota: {
|
||||
update_check_interval: 24 * 60,
|
||||
disable_automatic_update_check: false,
|
||||
image_block_request_timeout: 150000,
|
||||
image_block_response_delay: 250,
|
||||
default_maximum_data_size: 50,
|
||||
},
|
||||
@@ -112,6 +113,7 @@ export const defaults = {
|
||||
network_key: [1, 3, 5, 7, 9, 11, 13, 15, 0, 2, 4, 6, 8, 10, 12, 13],
|
||||
timestamp_format: "YYYY-MM-DD HH:mm:ss",
|
||||
output: "json",
|
||||
enable_external_js: true,
|
||||
},
|
||||
health: {
|
||||
interval: 10,
|
||||
@@ -166,6 +168,7 @@ export function writeMinimalDefaults(): void {
|
||||
network_key: "GENERATE",
|
||||
pan_id: "GENERATE",
|
||||
ext_pan_id: "GENERATE",
|
||||
enable_external_js: false,
|
||||
},
|
||||
frontend: {
|
||||
enabled: defaults.frontend.enabled,
|
||||
@@ -228,7 +231,10 @@ export function write(): void {
|
||||
const writeDevicesOrGroups = (type: "devices" | "groups"): void => {
|
||||
if (typeof actual[type] === "string" || (Array.isArray(actual[type]) && actual[type].length > 0)) {
|
||||
const fileToWrite = Array.isArray(actual[type]) ? actual[type][0] : actual[type];
|
||||
const content = objectAssignDeep({}, settings[type]);
|
||||
// `readDevicesOrGroups()` already set this to an object whenever the config points at separate files, but the
|
||||
// persisted settings are `Partial`, so the fallback is only here to satisfy the type
|
||||
/* v8 ignore next */
|
||||
const content = objectAssignDeep({}, settings[type] ?? {});
|
||||
|
||||
// If an array, only write to first file and only devices which are not in the other files.
|
||||
if (Array.isArray(actual[type])) {
|
||||
@@ -365,8 +371,7 @@ function read(): Partial<Settings> {
|
||||
s[type] = {};
|
||||
for (const file of files) {
|
||||
const content = yaml.readIfExists(data.joinPath(file));
|
||||
// @ts-expect-error noMutate not typed properly
|
||||
s[type] = objectAssignDeep.noMutate(s[type], content);
|
||||
s[type] = objectAssignDeep({}, s[type], content);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -476,9 +481,7 @@ export function set(path: string[], value: string | number | boolean | KeyValue)
|
||||
}
|
||||
|
||||
export function apply(settings: Record<string, unknown>, throwOnError = true): boolean {
|
||||
getPersistedSettings(); // Ensure _settings is initialized.
|
||||
// @ts-expect-error noMutate not typed properly
|
||||
const newSettings = objectAssignDeep.noMutate(_settings, settings);
|
||||
const newSettings = objectAssignDeep({}, getPersistedSettings(), settings);
|
||||
|
||||
utils.removeNullPropertiesFromObject(newSettings, NULLABLE_SETTINGS);
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {copyFileSync, writeFileSync} from "node:fs";
|
||||
|
||||
import {copyFileSync, existsSync, readFileSync, writeFileSync} from "node:fs";
|
||||
import data from "./data";
|
||||
import * as settings from "./settings";
|
||||
import utils from "./utils";
|
||||
@@ -29,7 +28,7 @@ interface SettingsCustomHandler extends Omit<SettingsMigration, "path"> {
|
||||
execute: (currentSettings: Partial<Settings>) => [validPath: boolean, previousValue: unknown, changed: boolean];
|
||||
}
|
||||
|
||||
const SUPPORTED_VERSIONS: Settings["version"][] = [undefined, 2, 3, settings.CURRENT_VERSION];
|
||||
const SUPPORTED_VERSIONS: Settings["version"][] = [undefined, 2, 3, 4, settings.CURRENT_VERSION];
|
||||
|
||||
function backupSettings(version: number): void {
|
||||
const filePath = data.joinPath("configuration.yaml");
|
||||
@@ -481,6 +480,58 @@ function migrateToFour(
|
||||
});
|
||||
}
|
||||
|
||||
function migrateToFive(
|
||||
_currentSettings: Partial<Settings>,
|
||||
transfers: SettingsTransfer[],
|
||||
changes: SettingsChange[],
|
||||
additions: SettingsAdd[],
|
||||
removals: SettingsRemove[],
|
||||
customHandlers: SettingsCustomHandler[],
|
||||
): void {
|
||||
transfers.push();
|
||||
changes.push({
|
||||
path: ["version"],
|
||||
note: "Migrated settings to version 5",
|
||||
newValue: 5,
|
||||
});
|
||||
additions.push();
|
||||
removals.push();
|
||||
|
||||
const resetOtaStates = (_currentSettings: Partial<Settings>): ReturnType<SettingsCustomHandler["execute"]> => {
|
||||
const filePath = data.joinPath("state.json");
|
||||
|
||||
if (existsSync(filePath)) {
|
||||
try {
|
||||
const stateFile = readFileSync(filePath, "utf8");
|
||||
const stateObj = JSON.parse(stateFile) as KeyValue;
|
||||
|
||||
for (const key in stateObj) {
|
||||
const entry = stateObj[key];
|
||||
|
||||
if (entry.update) {
|
||||
delete entry.update;
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(filePath, JSON.stringify(stateObj), "utf8");
|
||||
|
||||
// hack, this does not actually affect settings, we just want to ensure the migration note triggers
|
||||
return [true, undefined, true];
|
||||
} catch (error) {
|
||||
console.error(`Failed to write state to '${filePath}' (${error})`);
|
||||
}
|
||||
}
|
||||
|
||||
return [false, undefined, false];
|
||||
};
|
||||
|
||||
customHandlers.push({
|
||||
note: "Reset all cached OTA states due to internal changes.",
|
||||
noteIf: () => true,
|
||||
execute: (currentSettings) => resetOtaStates(currentSettings),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Order of execution:
|
||||
* - Transfer
|
||||
@@ -533,6 +584,10 @@ export function migrateIfNecessary(): void {
|
||||
migrationNotesFileName = "migration-3-to-4.log";
|
||||
|
||||
migrateToFour(currentSettings, transfers, changes, additions, removals, customHandlers);
|
||||
} else if (currentSettings.version === 4) {
|
||||
migrationNotesFileName = "migration-4-to-5.log";
|
||||
|
||||
migrateToFive(currentSettings, transfers, changes, additions, removals, customHandlers);
|
||||
}
|
||||
|
||||
for (const transfer of transfers) {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import type {IncomingMessage, ServerResponse} from "node:http";
|
||||
import {NodeRequest, sendNodeResponse} from "srvx/node";
|
||||
import {staticMiddleware} from "srvx/static";
|
||||
|
||||
export type StaticFileServer = (request: IncomingMessage, response: ServerResponse) => void;
|
||||
|
||||
const escapeHtml = (value: string): string => value.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
|
||||
|
||||
/** Terminal `404` handler for requests no file matched, mirroring the response `finalhandler` used to produce. */
|
||||
export function sendNotFound(request: IncomingMessage, response: ServerResponse): void {
|
||||
const method = request.method /* v8 ignore next */ ?? "GET";
|
||||
const url = request.url /* v8 ignore next */ ?? "/";
|
||||
const message = escapeHtml(`Cannot ${method} ${encodeURI(url)}`);
|
||||
const body = `<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<title>Error</title>\n</head>\n<body>\n<pre>${message}</pre>\n</body>\n</html>\n`;
|
||||
|
||||
response.setHeader("Content-Security-Policy", "default-src 'none'");
|
||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||
response.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
response.setHeader("Content-Length", Buffer.byteLength(body));
|
||||
response.writeHead(404);
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves `dir` on top of a plain `node:http` server, preferring the precompressed `.br`/`.gz` variant of a file when the client accepts it.
|
||||
*
|
||||
* Requests that match no file are answered by {@link sendNotFound}.
|
||||
*/
|
||||
export function createStaticFileServer(dir: string, logError: (message: string) => void): StaticFileServer {
|
||||
// `compress: false` restricts serving to the precompressed variants shipped on disk, never compressing on the fly
|
||||
const serveDir = staticMiddleware({dir, encodings: true, compress: false});
|
||||
const handle = async (request: IncomingMessage, response: ServerResponse): Promise<void> => {
|
||||
let matched = true;
|
||||
const staticResponse = await serveDir(new NodeRequest({req: request, res: response}), () => {
|
||||
matched = false;
|
||||
|
||||
return new Response(null, {status: 404});
|
||||
});
|
||||
|
||||
if (!matched) {
|
||||
sendNotFound(request, response);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// the HTML entry document must never be cached, so a newly installed frontend version is picked up right away
|
||||
if (staticResponse.headers.get("Content-Type")?.startsWith("text/html")) {
|
||||
staticResponse.headers.set("Cache-Control", "no-store");
|
||||
}
|
||||
|
||||
await sendNodeResponse(response, staticResponse);
|
||||
};
|
||||
|
||||
return (request, response) => {
|
||||
handle(request, response).catch((error) => {
|
||||
logError(`Failed to serve '${request.url}': ${(error as Error).message}`);
|
||||
|
||||
if (!response.headersSent) {
|
||||
response.writeHead(500);
|
||||
}
|
||||
|
||||
response.end();
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// Stable stringify inspired by https://github.com/BridgeAR/safe-stable-stringify
|
||||
// Takes advantage of Node env and Z2M's object-only use-case.
|
||||
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: escape regex
|
||||
const STR_ESC_SEQ_REGEXP = /[\u0000-\u001f\u0022\u005c\ud800-\udfff]/;
|
||||
|
||||
// Escape C0 control characters, double quotes, the backslash and every code
|
||||
// unit with a numeric value in the inclusive range 0xD800 to 0xDFFF.
|
||||
function strEscape(str: string): string {
|
||||
// Some magic numbers that worked out fine while benchmarking with v8 8.0
|
||||
if (str.length < 5000 && !STR_ESC_SEQ_REGEXP.test(str)) {
|
||||
return `"${str}"`;
|
||||
}
|
||||
|
||||
return JSON.stringify(str);
|
||||
}
|
||||
|
||||
function sort(array: string[]) {
|
||||
// Insertion sort is very efficient for small input sizes, but it has a bad
|
||||
// worst case complexity. Thus, use native array sort for bigger values.
|
||||
if (array.length > 2e2) {
|
||||
return array.sort();
|
||||
}
|
||||
|
||||
for (let i = 1; i < array.length; i++) {
|
||||
const currentValue = array[i];
|
||||
let position = i;
|
||||
|
||||
while (position !== 0 && array[position - 1] > currentValue) {
|
||||
array[position] = array[position - 1];
|
||||
position--;
|
||||
}
|
||||
|
||||
array[position] = currentValue;
|
||||
}
|
||||
}
|
||||
|
||||
function isTypedArray(value: unknown): value is unknown[] {
|
||||
return ArrayBuffer.isView(value) && !(value instanceof DataView);
|
||||
}
|
||||
|
||||
function stringifyTypedArray(array: unknown[]): string {
|
||||
if (array.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const isBigInt = typeof array[0] === "bigint";
|
||||
let res = `"0":${isBigInt ? `"${array[0]}"` : array[0]}`;
|
||||
|
||||
for (let i = 1; i < array.length; i++) {
|
||||
res += `,"${i}":${isBigInt ? `"${array[i]}"` : array[i]}`;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
function stringifySimple(key: string, value: unknown, stack: unknown[]): string | undefined {
|
||||
switch (typeof value) {
|
||||
case "string":
|
||||
return strEscape(value);
|
||||
case "object": {
|
||||
if (value === null) {
|
||||
return "null";
|
||||
}
|
||||
|
||||
if ("toJSON" in value && typeof value.toJSON === "function") {
|
||||
value = value.toJSON(key);
|
||||
|
||||
// Prevent calling `toJSON` again
|
||||
if (typeof value !== "object") {
|
||||
return stringifySimple(key, value, stack);
|
||||
}
|
||||
|
||||
if (value === null) {
|
||||
return "null";
|
||||
}
|
||||
}
|
||||
|
||||
if (stack.indexOf(value) !== -1) {
|
||||
return '"[Circular]"';
|
||||
}
|
||||
|
||||
let res = "";
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return "[]";
|
||||
}
|
||||
|
||||
stack.push(value);
|
||||
|
||||
let i = 0;
|
||||
|
||||
for (; i < value.length - 1; i++) {
|
||||
const tmp = stringifySimple(`${i}`, value[i], stack);
|
||||
res += tmp !== undefined ? tmp : "null";
|
||||
res += ",";
|
||||
}
|
||||
|
||||
const tmp = stringifySimple(`${i}`, value[i], stack);
|
||||
res += tmp !== undefined ? tmp : "null";
|
||||
|
||||
stack.pop();
|
||||
|
||||
return `[${res}]`;
|
||||
}
|
||||
|
||||
let keys = Object.keys(value);
|
||||
const keysLength = keys.length;
|
||||
|
||||
if (keysLength === 0) {
|
||||
return "{}";
|
||||
}
|
||||
|
||||
let separator = "";
|
||||
let propsToStringify = keysLength;
|
||||
|
||||
if (isTypedArray(value)) {
|
||||
res += stringifyTypedArray(value);
|
||||
keys = keys.slice(value.length);
|
||||
propsToStringify -= value.length;
|
||||
// Only separate from something that was actually written.
|
||||
separator = value.length > 0 ? "," : "";
|
||||
}
|
||||
|
||||
sort(keys);
|
||||
stack.push(value);
|
||||
|
||||
for (let i = 0; i < propsToStringify; i++) {
|
||||
const valKey = keys[i];
|
||||
const tmp = stringifySimple(valKey, (value as Record<string, unknown>)[valKey], stack);
|
||||
|
||||
if (tmp !== undefined) {
|
||||
res += `${separator}${strEscape(valKey)}:${tmp}`;
|
||||
separator = ",";
|
||||
}
|
||||
}
|
||||
|
||||
stack.pop();
|
||||
|
||||
return `{${res}}`;
|
||||
}
|
||||
case "number":
|
||||
return Number.isFinite(value) ? `${value}` : "null";
|
||||
case "boolean":
|
||||
return value === true ? "true" : "false";
|
||||
case "undefined":
|
||||
return undefined;
|
||||
case "bigint":
|
||||
return `"${value}"`;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function stringify(value: object): string {
|
||||
return stringifySimple("", value, []) ?? "null";
|
||||
}
|
||||
+3
-3
@@ -2,7 +2,7 @@ import assert from "node:assert";
|
||||
import fs from "node:fs";
|
||||
|
||||
import equals from "fast-deep-equal/es6";
|
||||
import yaml, {YAMLException} from "js-yaml";
|
||||
import {dump, load, YAMLException} from "js-yaml";
|
||||
|
||||
export class YAMLFileException extends YAMLException {
|
||||
file: string;
|
||||
@@ -20,7 +20,7 @@ export class YAMLFileException extends YAMLException {
|
||||
|
||||
function read(file: string): KeyValue {
|
||||
try {
|
||||
const result = yaml.load(fs.readFileSync(file, "utf8"));
|
||||
const result = load(fs.readFileSync(file, "utf8"));
|
||||
assert(result instanceof Object, `The content of ${file} is expected to be an object`);
|
||||
return result as KeyValue;
|
||||
} catch (error) {
|
||||
@@ -40,7 +40,7 @@ function writeIfChanged(file: string, content: KeyValue): void {
|
||||
const before = readIfExists(file);
|
||||
|
||||
if (!equals(before, content)) {
|
||||
fs.writeFileSync(file, yaml.dump(content));
|
||||
fs.writeFileSync(file, dump(content));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+61
-26
@@ -1,6 +1,5 @@
|
||||
import {randomInt} from "node:crypto";
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import type {Events as ZHEvents} from "zigbee-herdsman";
|
||||
import {Controller} from "zigbee-herdsman";
|
||||
import type {StartResult} from "zigbee-herdsman/dist/adapter/tstype";
|
||||
@@ -9,6 +8,7 @@ import Group from "./model/group";
|
||||
import data from "./util/data";
|
||||
import logger from "./util/logger";
|
||||
import * as settings from "./util/settings";
|
||||
import {stringify} from "./util/stringify";
|
||||
import utils from "./util/utils";
|
||||
|
||||
const entityIDRegex = /^(.+?)(?:\/([^/]+))?$/;
|
||||
@@ -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;
|
||||
@@ -61,20 +61,24 @@ export default class Zigbee {
|
||||
|
||||
logger.debug(
|
||||
() =>
|
||||
`Using zigbee-herdsman with settings: '${stringify(JSON.stringify(herdsmanSettings).replaceAll(JSON.stringify(herdsmanSettings.network.networkKey), '"HIDDEN"'))}'`,
|
||||
`Using zigbee-herdsman with settings: '${stringify(herdsmanSettings).replaceAll(stringify(herdsmanSettings.network.networkKey), '"HIDDEN"')}'`,
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,7 +467,11 @@ export default class Zigbee {
|
||||
return this.resolveGroup(id);
|
||||
}
|
||||
|
||||
removeGroupFromLookup(id: number): void {
|
||||
this.groupLookup.delete(id);
|
||||
removeDeviceFromLookup(ieee: string): boolean {
|
||||
return this.deviceLookup.delete(ieee);
|
||||
}
|
||||
|
||||
removeGroupFromLookup(id: number): boolean {
|
||||
return this.groupLookup.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
+21
-30
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "zigbee2mqtt",
|
||||
"version": "2.7.1",
|
||||
"version": "2.13.0-dev",
|
||||
"description": "Zigbee to MQTT bridge using Zigbee-herdsman",
|
||||
"main": "index.js",
|
||||
"types": "dist/types/api.d.ts",
|
||||
@@ -10,7 +10,7 @@
|
||||
"url": "git+https://github.com/Koenkk/zigbee2mqtt.git"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.15.0 || ^22.2.0 || ^24"
|
||||
"node": "^22.2.0 || ^24 || ^26"
|
||||
},
|
||||
"keywords": [
|
||||
"xiaomi",
|
||||
@@ -33,7 +33,7 @@
|
||||
"test:watch": "vitest watch --config ./test/vitest.config.mts",
|
||||
"bench": "vitest bench --run --config ./test/vitest.config.mts",
|
||||
"prepack": "pnpm run clean && pnpm run build",
|
||||
"clean": "rimraf coverage dist tsconfig.tsbuildinfo"
|
||||
"clean": "node -e \"for (const p of ['coverage', 'dist', 'tsconfig.tsbuildinfo']) require('node:fs').rmSync(p, {recursive: true, force: true, maxRetries: process.platform === 'win32' ? 10 : 0})\""
|
||||
},
|
||||
"author": "Koen Kanters",
|
||||
"license": "GPL-3.0",
|
||||
@@ -42,44 +42,35 @@
|
||||
},
|
||||
"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",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"finalhandler": "^2.1.1",
|
||||
"humanize-duration": "^3.33.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||
"jszip": "^3.10.1",
|
||||
"mqtt": "^5.14.1",
|
||||
"object-assign-deep": "^0.4.0",
|
||||
"rimraf": "^6.1.2",
|
||||
"semver": "^7.7.3",
|
||||
"source-map-support": "^0.5.21",
|
||||
"throttleit": "^2.1.0",
|
||||
"winston": "^3.18.3",
|
||||
"fflate": "^0.8.3",
|
||||
"humanize-duration": "^3.34.1",
|
||||
"js-yaml": "^5.3.0",
|
||||
"mqtt": "^5.15.2",
|
||||
"semver": "^7.8.5",
|
||||
"srvx": "^0.12.7",
|
||||
"throttleit": "^3.0.0",
|
||||
"winston": "^3.19.0",
|
||||
"winston-syslog": "^2.7.1",
|
||||
"winston-transport": "^4.9.0",
|
||||
"ws": "^8.18.1",
|
||||
"zigbee-herdsman": "7.0.4",
|
||||
"zigbee-herdsman-converters": "25.83.1",
|
||||
"ws": "^8.21.3",
|
||||
"zigbee-herdsman": "10.9.1",
|
||||
"zigbee-herdsman-converters": "26.100.0",
|
||||
"zigbee2mqtt-frontend": "0.9.21",
|
||||
"zigbee2mqtt-windfront": "2.4.2"
|
||||
"zigbee2mqtt-windfront": "2.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.8",
|
||||
"@types/finalhandler": "^1.2.3",
|
||||
"@biomejs/biome": "^2.5.3",
|
||||
"@types/humanize-duration": "^3.27.4",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/object-assign-deep": "^0.4.3",
|
||||
"@types/readable-stream": "4.0.22",
|
||||
"@types/serve-static": "^2.2.0",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/readable-stream": "4.0.24",
|
||||
"@types/ws": "8.18.1",
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
"tmp": "^0.2.5",
|
||||
"typescript": "^5.9.3",
|
||||
"tmp": "^0.2.7",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^3.1.1"
|
||||
},
|
||||
"pnpm": {
|
||||
|
||||
Generated
+454
-628
File diff suppressed because it is too large
Load Diff
@@ -59,7 +59,13 @@ if (fs.existsSync(commitUserFile)) {
|
||||
}
|
||||
|
||||
const definitions = definitionIndex.default.map((d) => zhc.prepareDefinition(d));
|
||||
const whiteLabels = definitions.filter((d) => d.whiteLabel).flatMap((d) => d.whiteLabel);
|
||||
const whiteLabels = definitions
|
||||
.filter((d) => d.whiteLabel)
|
||||
.flatMap((d) =>
|
||||
d.whiteLabel.map((wl) => {
|
||||
return {model: wl.model, vendor: wl.vendor ?? d.vendor, description: wl.description ?? d.description};
|
||||
}),
|
||||
);
|
||||
const capitalizeFirstChar = (str) => str.charAt(0).toUpperCase() + str.slice(1);
|
||||
|
||||
for (const changelog of changelogs) {
|
||||
|
||||
+49
-22
@@ -1,4 +1,47 @@
|
||||
import {execSync} from "node:child_process";
|
||||
import {execFileSync} from "node:child_process";
|
||||
|
||||
function grepTypeScriptFiles(needle, dir) {
|
||||
try {
|
||||
return execFileSync("grep", ["-r", "-F", "--include=*.ts", "--", needle, dir], {encoding: "utf8"});
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkDuplicateIssue(github, context, name) {
|
||||
// Search for existing issues with the same `name`
|
||||
const searchQuery = `repo:${context.repo.owner}/${context.repo.repo} is:issue -is:pr "${name}" label:"new device support","external converter"`;
|
||||
|
||||
try {
|
||||
const searchResults = await github.rest.search.issuesAndPullRequests({q: searchQuery, per_page: 100});
|
||||
|
||||
// Filter out the current issue and return the first duplicate found
|
||||
const existingIssues = searchResults.data.items.filter((item) => item.number !== context.payload.issue.number).map((i) => `#${i.number}`);
|
||||
if (existingIssues.length > 0) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
body: `👋 Hi there! This issue appears to be a duplicate of ${existingIssues.join(", ")}
|
||||
|
||||
This issue will be closed. Please follow the existing issue for updates.`,
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
state: "closed",
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error searching for duplicate issues with ${name}:`, error);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function newDeviceSupport(github, _core, context, zhcDir) {
|
||||
const issue = context.payload.issue;
|
||||
@@ -27,13 +70,8 @@ export async function newDeviceSupport(github, _core, context, zhcDir) {
|
||||
console.log("Found tuyaManufacturerNames", tuyaManufacturerNames);
|
||||
if (tuyaManufacturerNames.length > 0) {
|
||||
for (const [fullName, partialName] of tuyaManufacturerNames) {
|
||||
const fullMatch = (() => {
|
||||
try {
|
||||
return execSync(`grep -r --include="*.ts" "${fullName}" "${zhcDir}"`, {encoding: "utf8"});
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
if (await checkDuplicateIssue(github, context, fullName)) return;
|
||||
const fullMatch = grepTypeScriptFiles(fullName, zhcDir);
|
||||
|
||||
console.log(`Checking full match for '${fullName}', result: '${fullMatch}'`);
|
||||
if (fullMatch) {
|
||||
@@ -58,13 +96,7 @@ If you need help with the process, feel free to ask here and we'll be happy to a
|
||||
return;
|
||||
}
|
||||
|
||||
const partialMatch = (() => {
|
||||
try {
|
||||
return execSync(`grep -r --include="*.ts" "${partialName}" "${zhcDir}"`, {encoding: "utf8"});
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
const partialMatch = grepTypeScriptFiles(partialName, zhcDir);
|
||||
|
||||
console.log(`Checking partial match for '${partialName}', result: '${partialMatch}'`);
|
||||
if (partialMatch) {
|
||||
@@ -93,13 +125,8 @@ Let us know if it works so we can support this device out-of-the-box!`,
|
||||
|
||||
if (zigbeeModels.length > 0) {
|
||||
for (const zigbeeModel of zigbeeModels) {
|
||||
const fullMatch = (() => {
|
||||
try {
|
||||
return execSync(`grep -r --include="*.ts" '"${zigbeeModel}"' "${zhcDir}"`, {encoding: "utf8"});
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
if (await checkDuplicateIssue(github, context, zigbeeModel)) return;
|
||||
const fullMatch = grepTypeScriptFiles(`"${zigbeeModel}"`, zhcDir);
|
||||
|
||||
if (fullMatch) {
|
||||
await github.rest.issues.createComment({
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -91,4 +91,4 @@ if (processArgs.length !== 1) {
|
||||
|
||||
const eraser = new ZStackNvMemEraser(processArgs[0]);
|
||||
|
||||
eraser.start();
|
||||
void eraser.start();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {existsSync, mkdirSync} from "node:fs";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {bench, describe, vi} from "vitest";
|
||||
import {type Controller, Zcl, Zdo, ZSpec} from "zigbee-herdsman";
|
||||
import type Adapter from "zigbee-herdsman/dist/adapter/adapter";
|
||||
@@ -12,6 +11,7 @@ import type {DeviceType} from "zigbee-herdsman/dist/controller/tstype";
|
||||
import {Foundation} from "zigbee-herdsman/dist/zspec/zcl/definition/foundation";
|
||||
import type {RequestToResponseMap} from "zigbee-herdsman/dist/zspec/zdo/definition/tstypes";
|
||||
import data from "../lib/util/data";
|
||||
import {stringify} from "../lib/util/stringify";
|
||||
import {BENCH_OPTIONS} from "./benchOptions";
|
||||
|
||||
vi.doMock("zigbee-herdsman", async (importOriginal) => {
|
||||
@@ -257,7 +257,7 @@ const adapter = {
|
||||
switch (zclFrame.command.ID) {
|
||||
case Foundation.read.ID: {
|
||||
for (const attr of zclFrame.payload) {
|
||||
const attribute = zclFrame.cluster.getAttribute(attr.attrId);
|
||||
const attribute = Zcl.Utils.getClusterAttribute(zclFrame.cluster, attr.attrId, undefined);
|
||||
|
||||
if (attribute && attribute.type !== Zcl.DataType.NO_DATA && attribute.type < Zcl.DataType.OCTET_STR) {
|
||||
payload.push({
|
||||
@@ -421,7 +421,6 @@ const initController = async () => {
|
||||
disconnecting: false,
|
||||
disconnected: false,
|
||||
endAsync: async () => {},
|
||||
// @ts-expect-error Z2M does not make use of return
|
||||
publishAsync: async () => {},
|
||||
};
|
||||
controller.mqtt.connect = async () => {
|
||||
|
||||
+302
-4
@@ -17,14 +17,15 @@ import {devices, mockController as mockZHController, events as mockZHEvents, ret
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import tmp from "tmp";
|
||||
|
||||
import {stringify} from "../lib/util/stringify";
|
||||
import type {Mock, MockInstance} from "vitest";
|
||||
import {Controller as ZHController} from "zigbee-herdsman";
|
||||
import {Controller} from "../lib/controller";
|
||||
import type Device from "../lib/model/device";
|
||||
import * as settings from "../lib/util/settings";
|
||||
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", {
|
||||
@@ -143,6 +149,7 @@ describe("Controller", () => {
|
||||
user: "user1",
|
||||
client_id: "my_client_id",
|
||||
reject_unauthorized: false,
|
||||
server_name: "mqtt.example.com",
|
||||
version: 5,
|
||||
maximum_packet_size: 20000,
|
||||
};
|
||||
@@ -160,6 +167,7 @@ describe("Controller", () => {
|
||||
username: "user1",
|
||||
clientId: "my_client_id",
|
||||
rejectUnauthorized: false,
|
||||
servername: "mqtt.example.com",
|
||||
protocolVersion: 5,
|
||||
properties: {maximumPacketSize: 20000},
|
||||
};
|
||||
@@ -323,11 +331,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 +420,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"));
|
||||
@@ -804,6 +1061,17 @@ describe("Controller", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("Publish entity state attribute output with a null color", async () => {
|
||||
await controller.start();
|
||||
settings.set(["advanced", "output"], "attribute_and_json");
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
const device = getZ2MDevice("bulb");
|
||||
await controller.publishEntityState(device, {state: "ON", color: null});
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb/state", "ON", {qos: 0, retain: true});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb/color", "", {qos: 0, retain: true});
|
||||
});
|
||||
|
||||
it("Publish entity state attribute_json output filtered", async () => {
|
||||
await controller.start();
|
||||
settings.set(["advanced", "output"], "attribute_and_json");
|
||||
@@ -832,6 +1100,28 @@ describe("Controller", () => {
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({state: "ON", brightness: 200}), {qos: 0, retain: true});
|
||||
});
|
||||
|
||||
it("Publish entity state caches a duration reported by the device", async () => {
|
||||
await controller.start();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
const device = getZ2MDevice("bulb");
|
||||
await controller.publishEntityState(device, {state: "ON", duration: 30});
|
||||
await flushPromises();
|
||||
|
||||
expect(controller.state.get(device)).toStrictEqual({brightness: 50, color_temp: 370, linkquality: 99, state: "ON", duration: 30});
|
||||
});
|
||||
|
||||
it("Publish entity state keeps an action_duration out of the cache", async () => {
|
||||
await controller.start();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
const device = getZ2MDevice("bulb");
|
||||
await controller.publishEntityState(device, {state: "ON", action_duration: 1500});
|
||||
await flushPromises();
|
||||
|
||||
expect(controller.state.get(device)).toStrictEqual({brightness: 50, color_temp: 370, linkquality: 99, state: "ON"});
|
||||
});
|
||||
|
||||
it("Publish entity state attribute_json output filtered cache", async () => {
|
||||
await controller.start();
|
||||
settings.set(["advanced", "output"], "attribute_and_json");
|
||||
@@ -1168,4 +1458,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");
|
||||
});
|
||||
});
|
||||
|
||||
+15
-6
@@ -15,12 +15,21 @@ describe("Data", () => {
|
||||
const expected = tmp.dirSync().name;
|
||||
process.env.ZIGBEE2MQTT_DATA = expected;
|
||||
data._testReload();
|
||||
const actual = data.getPath();
|
||||
expect(actual).toBe(expected);
|
||||
expect(data.joinPath("test")).toStrictEqual(path.join(expected, "test"));
|
||||
expect(data.joinPath("/test")).toStrictEqual(path.resolve(expected, "/test"));
|
||||
delete process.env.ZIGBEE2MQTT_DATA;
|
||||
data._testReload();
|
||||
|
||||
try {
|
||||
const actual = data.getPath();
|
||||
expect(actual).toBe(expected);
|
||||
expect(data.joinPath("test")).toStrictEqual(path.join(expected, "test"));
|
||||
expect(data.joinPath("/test")).toStrictEqual(path.resolve(expected, "/test"));
|
||||
} finally {
|
||||
delete process.env.ZIGBEE2MQTT_DATA;
|
||||
data._testReload();
|
||||
}
|
||||
});
|
||||
|
||||
it("Should return data path when joinPath with empty string", () => {
|
||||
const expected = data.getPath();
|
||||
expect(data.joinPath("")).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import {flushPromises} from "../mocks/utils";
|
||||
import {devices, events as mockZHEvents, returnDevices} from "../mocks/zigbeeHerdsman";
|
||||
|
||||
import assert from "node:assert";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {stringify} from "../../lib/util/stringify";
|
||||
import {Controller} from "../../lib/controller";
|
||||
import Availability from "../../lib/extension/availability";
|
||||
import * as settings from "../../lib/util/settings";
|
||||
@@ -633,6 +633,19 @@ describe("Extension: Availability", () => {
|
||||
expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("clamps the ping delay to the maximum supported timeout", async () => {
|
||||
// `setTimeout` takes a 32-bit signed integer and coerces anything above it to `1`. A delay can exceed
|
||||
// that either directly, through a long `timeout`, or gradually, once `backoff` has multiplied a normal
|
||||
// one over successive failures. Unclamped, that turns an ever-longer wait into a tight ping loop.
|
||||
settings.set(["devices", devices.bulb_color.ieeeAddr, "availability"], {timeout: 40000, max_jitter: 0}); // ~27.8 days
|
||||
await resetExtension();
|
||||
|
||||
// unclamped, the delay collapses to 1ms, so pings would already be looping by now
|
||||
await setTimeAndAdvanceTimers(utils.seconds(1));
|
||||
|
||||
expect(devices.bulb_color.ping).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows to disable backoff", async () => {
|
||||
settings.set(["availability", "active", "max_jitter"], 0); // easier testing
|
||||
settings.set(["availability", "active", "backoff"], false);
|
||||
|
||||
@@ -7,7 +7,7 @@ import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
|
||||
import {flushPromises} from "../mocks/utils";
|
||||
import {type Device, devices, groups, events as mockZHEvents} from "../mocks/zigbeeHerdsman";
|
||||
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {stringify} from "../../lib/util/stringify";
|
||||
import {Controller} from "../../lib/controller";
|
||||
import Bind from "../../lib/extension/bind";
|
||||
import * as settings from "../../lib/util/settings";
|
||||
@@ -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({
|
||||
|
||||
+302
-30
@@ -2,7 +2,7 @@
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
|
||||
import {Zdo} from "zigbee-herdsman";
|
||||
import * as data from "../mocks/data";
|
||||
import {mockJSZipFile, mockJSZipGenerateAsync} from "../mocks/jszip";
|
||||
import {mockFflateZip, mockFflateZipFailOnce} from "../mocks/fflate";
|
||||
import {mockLogger} from "../mocks/logger";
|
||||
import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
|
||||
import {flushPromises} from "../mocks/utils";
|
||||
@@ -12,7 +12,7 @@ import assert from "node:assert";
|
||||
import fs from "node:fs";
|
||||
import {platform} from "node:os";
|
||||
import path from "node:path";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {stringify} from "../../lib/util/stringify";
|
||||
import type {Mock} from "vitest";
|
||||
import {Controller} from "../../lib/controller";
|
||||
import Bridge from "../../lib/extension/bridge";
|
||||
@@ -144,6 +144,7 @@ describe("Extension: Bridge", () => {
|
||||
output: "json",
|
||||
pan_id: 6754,
|
||||
timestamp_format: "YYYY-MM-DD HH:mm:ss",
|
||||
enable_external_js: true,
|
||||
},
|
||||
blocklist: [],
|
||||
device_options: {},
|
||||
@@ -322,6 +323,7 @@ describe("Extension: Bridge", () => {
|
||||
ota: {
|
||||
default_maximum_data_size: 50,
|
||||
disable_automatic_update_check: false,
|
||||
image_block_request_timeout: 150000,
|
||||
image_block_response_delay: 250,
|
||||
update_check_interval: 1440,
|
||||
},
|
||||
@@ -596,6 +598,7 @@ describe("Extension: Bridge", () => {
|
||||
],
|
||||
supports_ota: true,
|
||||
vendor: "IKEA",
|
||||
version: "0.0.0",
|
||||
},
|
||||
description: "this is my bulb",
|
||||
disabled: false,
|
||||
@@ -733,7 +736,7 @@ describe("Extension: Bridge", () => {
|
||||
values: ["off", "on", "toggle", "previous"],
|
||||
},
|
||||
{
|
||||
access: 2,
|
||||
access: 3,
|
||||
label: "Effect",
|
||||
name: "effect",
|
||||
property: "effect",
|
||||
@@ -746,11 +749,51 @@ 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: 2,
|
||||
category: "config",
|
||||
description: "Initiate device identification",
|
||||
label: "Identify",
|
||||
name: "identify",
|
||||
property: "identify",
|
||||
type: "enum",
|
||||
values: ["identify"],
|
||||
},
|
||||
{
|
||||
access: 1,
|
||||
category: "diagnostic",
|
||||
@@ -766,6 +809,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:
|
||||
@@ -777,6 +841,27 @@ describe("Extension: Bridge", () => {
|
||||
value_min: 0,
|
||||
value_step: 0.1,
|
||||
},
|
||||
{
|
||||
access: 2,
|
||||
description:
|
||||
"Sets the duration of the identification procedure in seconds (i.e., how long the device would flash).The value ranges from 1 to 30 seconds (default: 3).",
|
||||
label: "Identify timeout",
|
||||
name: "identify_timeout",
|
||||
property: "identify_timeout",
|
||||
type: "numeric",
|
||||
value_max: 30,
|
||||
value_min: 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:
|
||||
@@ -788,19 +873,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: {
|
||||
@@ -830,7 +906,7 @@ describe("Extension: Bridge", () => {
|
||||
{
|
||||
definition: {
|
||||
source: "native",
|
||||
description: "Hue dimmer switch",
|
||||
description: "Hue dimmer switch gen 1",
|
||||
exposes: [
|
||||
{
|
||||
access: 1,
|
||||
@@ -929,6 +1005,7 @@ describe("Extension: Bridge", () => {
|
||||
],
|
||||
supports_ota: true,
|
||||
vendor: "Philips",
|
||||
version: "0.0.0",
|
||||
},
|
||||
disabled: false,
|
||||
endpoints: {
|
||||
@@ -1037,6 +1114,7 @@ describe("Extension: Bridge", () => {
|
||||
],
|
||||
supports_ota: false,
|
||||
vendor: "notSupportedMfg",
|
||||
version: "0.0.0",
|
||||
},
|
||||
disabled: false,
|
||||
endpoints: {
|
||||
@@ -1105,6 +1183,17 @@ describe("Extension: Bridge", () => {
|
||||
property: "power_outage_count",
|
||||
type: "numeric",
|
||||
},
|
||||
{
|
||||
access: 2,
|
||||
category: "config",
|
||||
description:
|
||||
"Initiate device identification. This device is asleep by default.You may need to wake it up first before sending the identify command.",
|
||||
label: "Identify",
|
||||
name: "identify",
|
||||
property: "identify",
|
||||
type: "enum",
|
||||
values: ["identify"],
|
||||
},
|
||||
{
|
||||
access: 1,
|
||||
category: "diagnostic",
|
||||
@@ -1139,9 +1228,21 @@ describe("Extension: Bridge", () => {
|
||||
type: "numeric",
|
||||
value_step: 0.1,
|
||||
},
|
||||
{
|
||||
access: 2,
|
||||
description:
|
||||
"Sets the duration of the identification procedure in seconds (i.e., how long the device would flash).The value ranges from 1 to 30 seconds (default: 3).",
|
||||
label: "Identify timeout",
|
||||
name: "identify_timeout",
|
||||
property: "identify_timeout",
|
||||
type: "numeric",
|
||||
value_max: 30,
|
||||
value_min: 1,
|
||||
},
|
||||
],
|
||||
supports_ota: false,
|
||||
vendor: "Aqara",
|
||||
version: "0.0.0",
|
||||
},
|
||||
disabled: false,
|
||||
endpoints: {
|
||||
@@ -1303,6 +1404,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: []}},
|
||||
@@ -1454,6 +1556,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: []}},
|
||||
@@ -1902,7 +2005,7 @@ describe("Extension: Bridge", () => {
|
||||
},
|
||||
{
|
||||
access: 2,
|
||||
description: "Inverts the cover position, false: open=100,close=0, true: open=0,close=100 (default false).",
|
||||
description: "Inverts the cover position and state, false: open=100,close=0, true: open=0,close=100 (default false).",
|
||||
label: "Invert cover",
|
||||
name: "invert_cover",
|
||||
property: "invert_cover",
|
||||
@@ -1913,6 +2016,7 @@ describe("Extension: Bridge", () => {
|
||||
],
|
||||
supports_ota: false,
|
||||
vendor: "Siglis",
|
||||
version: "0.0.0",
|
||||
},
|
||||
disabled: false,
|
||||
endpoints: {
|
||||
@@ -2203,6 +2307,7 @@ describe("Extension: Bridge", () => {
|
||||
],
|
||||
supports_ota: true,
|
||||
vendor: "IKEA",
|
||||
version: "0.0.0",
|
||||
},
|
||||
disabled: false,
|
||||
endpoints: {
|
||||
@@ -2638,6 +2743,7 @@ describe("Extension: Bridge", () => {
|
||||
],
|
||||
supports_ota: true,
|
||||
vendor: "IKEA",
|
||||
version: "0.0.0",
|
||||
},
|
||||
friendly_name: "bulb",
|
||||
ieee_address: "0x000b57fffec6a5b2",
|
||||
@@ -2724,6 +2830,7 @@ describe("Extension: Bridge", () => {
|
||||
],
|
||||
supports_ota: false,
|
||||
vendor: "notSupportedMfg",
|
||||
version: "0.0.0",
|
||||
},
|
||||
friendly_name: "0x0017880104e45518",
|
||||
ieee_address: "0x0017880104e45518",
|
||||
@@ -2742,7 +2849,7 @@ describe("Extension: Bridge", () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
await mockZHEvents.deviceLeave({ieeeAddr: devices.bulb.ieeeAddr});
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(3);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(4);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/event",
|
||||
stringify({type: "device_leave", data: {ieee_address: "0x000b57fffec6a5b2", friendly_name: "bulb"}}),
|
||||
@@ -2755,6 +2862,7 @@ describe("Extension: Bridge", () => {
|
||||
expect.any(String),
|
||||
{retain: true},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/groups", expect.any(String), {retain: true});
|
||||
});
|
||||
|
||||
it("Should allow permit join on all", async () => {
|
||||
@@ -2909,6 +3017,7 @@ describe("Extension: Bridge", () => {
|
||||
|
||||
it("Should allow to remove device by string", async () => {
|
||||
const device = devices.bulb;
|
||||
const removeSpy = vi.spyOn(controller.zigbee, "removeDeviceFromLookup");
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/remove", "bulb");
|
||||
await flushPromises();
|
||||
@@ -2917,11 +3026,12 @@ describe("Extension: Bridge", () => {
|
||||
expect(device.removeFromNetwork).toHaveBeenCalledTimes(1);
|
||||
expect(device.removeFromDatabase).not.toHaveBeenCalled();
|
||||
expect(settings.getDevice("bulb")).toBeUndefined();
|
||||
expect(removeSpy).not.toHaveBeenCalled();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb", "", {retain: true});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/remove",
|
||||
stringify({data: {id: "bulb", block: false, force: false}, status: "ok"}),
|
||||
stringify({data: {id: "bulb", block: false, force: false, keep_config: false, clear_cache: false}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
expect(settings.get().blocklist).toStrictEqual([]);
|
||||
@@ -2931,52 +3041,94 @@ describe("Extension: Bridge", () => {
|
||||
|
||||
it("Should allow to remove device by object ID", async () => {
|
||||
const device = devices.bulb;
|
||||
const removeSpy = vi.spyOn(controller.zigbee, "removeDeviceFromLookup");
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/remove", stringify({id: "bulb"}));
|
||||
await flushPromises();
|
||||
expect(device.removeFromNetwork).toHaveBeenCalledTimes(1);
|
||||
expect(device.removeFromDatabase).not.toHaveBeenCalled();
|
||||
expect(settings.getDevice("bulb")).toBeUndefined();
|
||||
expect(removeSpy).not.toHaveBeenCalled();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/remove",
|
||||
stringify({data: {id: "bulb", block: false, force: false}, status: "ok"}),
|
||||
stringify({data: {id: "bulb", block: false, force: false, keep_config: false, clear_cache: false}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("Should allow to force remove device", async () => {
|
||||
const device = devices.bulb;
|
||||
const removeSpy = vi.spyOn(controller.zigbee, "removeDeviceFromLookup");
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/remove", stringify({id: "bulb", force: true}));
|
||||
await flushPromises();
|
||||
expect(device.removeFromDatabase).toHaveBeenCalledTimes(1);
|
||||
expect(device.removeFromNetwork).not.toHaveBeenCalled();
|
||||
expect(settings.getDevice("bulb")).toBeUndefined();
|
||||
expect(removeSpy).not.toHaveBeenCalled();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/remove",
|
||||
stringify({data: {id: "bulb", block: false, force: true}, status: "ok"}),
|
||||
stringify({data: {id: "bulb", block: false, force: true, keep_config: false, clear_cache: false}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("Should allow to block device", async () => {
|
||||
const device = devices.bulb;
|
||||
const removeSpy = vi.spyOn(controller.zigbee, "removeDeviceFromLookup");
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/remove", stringify({id: "bulb", block: true, force: true}));
|
||||
await flushPromises();
|
||||
expect(device.removeFromDatabase).toHaveBeenCalledTimes(1);
|
||||
expect(settings.getDevice("bulb")).toBeUndefined();
|
||||
expect(removeSpy).not.toHaveBeenCalled();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/remove",
|
||||
stringify({data: {id: "bulb", block: true, force: true}, status: "ok"}),
|
||||
stringify({data: {id: "bulb", block: true, force: true, keep_config: false, clear_cache: false}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
expect(settings.get().blocklist).toStrictEqual(["0x000b57fffec6a5b2"]);
|
||||
});
|
||||
|
||||
it("Should allow to keep configuration when removing device", async () => {
|
||||
const device = devices.bulb;
|
||||
const removeSpy = vi.spyOn(controller.zigbee, "removeDeviceFromLookup");
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/remove", stringify({id: "bulb", keep_config: true}));
|
||||
await flushPromises();
|
||||
expect(device.removeFromDatabase).not.toHaveBeenCalled();
|
||||
expect(device.removeFromNetwork).toHaveBeenCalledTimes(1);
|
||||
expect(settings.getDevice("bulb")).toBeDefined();
|
||||
expect(removeSpy).not.toHaveBeenCalled();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/remove",
|
||||
stringify({data: {id: "bulb", block: false, force: false, keep_config: true, clear_cache: false}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("Should allow to clear cache when removing device", async () => {
|
||||
const device = devices.bulb;
|
||||
const removeSpy = vi.spyOn(controller.zigbee, "removeDeviceFromLookup");
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/remove", stringify({id: "bulb", clear_cache: true}));
|
||||
await flushPromises();
|
||||
expect(device.removeFromNetwork).toHaveBeenCalledTimes(1);
|
||||
expect(device.removeFromDatabase).not.toHaveBeenCalled();
|
||||
expect(settings.getDevice("bulb")).toBeUndefined();
|
||||
expect(removeSpy).toHaveNthReturnedWith(1, true);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/remove",
|
||||
stringify({data: {id: "bulb", block: false, force: false, keep_config: false, clear_cache: true}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("Should allow to remove group", async () => {
|
||||
const group = groups.group_1;
|
||||
const removeGroupFromLookup = vi.spyOn(controller.zigbee, "removeGroupFromLookup");
|
||||
@@ -3041,7 +3193,11 @@ describe("Extension: Bridge", () => {
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/remove",
|
||||
stringify({data: {}, status: "error", error: "Failed to remove device 'bulb' (block: false, force: false) (Error: device timeout)"}),
|
||||
stringify({
|
||||
data: {},
|
||||
status: "error",
|
||||
error: "Failed to remove device 'bulb' (block: false, force: false, keep config: false, clear cache: false) (Error: device timeout)",
|
||||
}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
@@ -3263,7 +3419,7 @@ describe("Extension: Bridge", () => {
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/interview",
|
||||
stringify({data: {}, status: "error", error: "interview of 'bulb' (0x000b57fffec6a5b2) failed: Error: something went wrong"}),
|
||||
stringify({data: {}, status: "error", error: "Interview of 'bulb' (0x000b57fffec6a5b2) failed: Error: something went wrong"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
@@ -3307,7 +3463,7 @@ describe("Extension: Bridge", () => {
|
||||
" model: 'lumi.plug',\n" +
|
||||
" vendor: '',\n" +
|
||||
" description: 'Automatically generated definition',\n" +
|
||||
' extend: [m.onOff({"powerOnBehavior":false})],\n' +
|
||||
" extend: [m.onOff()],\n" +
|
||||
"};\n",
|
||||
},
|
||||
status: "ok",
|
||||
@@ -3490,6 +3646,36 @@ describe("Extension: Bridge", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("Should warn on unsupported device option", async () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockLogger.warning.mockClear();
|
||||
|
||||
const device = controller.zigbee.resolveEntity(devices.bulb.ieeeAddr);
|
||||
assert(device && "definition" in device);
|
||||
const definitionOptions = device.definition?.options;
|
||||
device.definition!.options = undefined;
|
||||
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/options", stringify({options: {unsupported: true}, id: "bulb"}));
|
||||
await flushPromises();
|
||||
device.definition!.options = definitionOptions;
|
||||
|
||||
expect(settings.getDevice("bulb")).toHaveProperty("unsupported");
|
||||
expect(mockLogger.warning).toHaveBeenCalledWith("Device 'bulb' does not support option 'unsupported'");
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/options",
|
||||
stringify({
|
||||
data: {
|
||||
from: {retain: true, description: "this is my bulb"},
|
||||
to: {retain: true, description: "this is my bulb", unsupported: true},
|
||||
id: "bulb",
|
||||
restart_required: false,
|
||||
},
|
||||
status: "ok",
|
||||
}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("Should allow to add group by string", async () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/group/add", "group_193");
|
||||
@@ -4085,6 +4271,8 @@ describe("Extension: Bridge", () => {
|
||||
fs.writeFileSync(path.join(data.mockDir, "ext_converters", "afile.js"), "test123");
|
||||
fs.mkdirSync(path.join(data.mockDir, "log"));
|
||||
fs.writeFileSync(path.join(data.mockDir, "log", "log.log"), "test123");
|
||||
fs.mkdirSync(path.join(data.mockDir, "ota"));
|
||||
fs.writeFileSync(path.join(data.mockDir, "ota", "my.ota"), "test123");
|
||||
fs.mkdirSync(path.join(data.mockDir, "ext_converters", "123"));
|
||||
fs.writeFileSync(path.join(data.mockDir, "ext_converters", "123", "myfile.js"), "test123");
|
||||
fs.symlinkSync(
|
||||
@@ -4096,13 +4284,24 @@ describe("Extension: Bridge", () => {
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/backup", "");
|
||||
await flushPromises();
|
||||
expect(mockZHController.backup).toHaveBeenCalledTimes(1);
|
||||
expect(mockJSZipFile).toHaveBeenCalledTimes(4);
|
||||
expect(mockJSZipFile).toHaveBeenNthCalledWith(1, "configuration.yaml", expect.any(Object));
|
||||
expect(mockJSZipFile).toHaveBeenNthCalledWith(2, path.join("ext_converters", "123", "myfile.js"), expect.any(Object));
|
||||
expect(mockJSZipFile).toHaveBeenNthCalledWith(3, path.join("ext_converters", "afile.js"), expect.any(Object));
|
||||
expect(mockJSZipFile).toHaveBeenNthCalledWith(4, "state.json", expect.any(Object));
|
||||
expect(mockJSZipGenerateAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockJSZipGenerateAsync).toHaveBeenNthCalledWith(1, {type: "base64"});
|
||||
expect(mockFflateZip).toHaveBeenCalledTimes(1);
|
||||
expect(mockFflateZip).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{
|
||||
"configuration.yaml": expect.any(Buffer),
|
||||
[path.join("ext_converters", "123", "myfile.js")]: expect.any(Buffer),
|
||||
[path.join("ext_converters", "afile.js")]: expect.any(Buffer),
|
||||
"state.json": expect.any(Buffer),
|
||||
},
|
||||
{level: 6},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(Object.keys(mockFflateZip.mock.calls[0][0])).toStrictEqual([
|
||||
"configuration.yaml",
|
||||
path.join("ext_converters", "123", "myfile.js"),
|
||||
path.join("ext_converters", "afile.js"),
|
||||
"state.json",
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/backup",
|
||||
stringify({data: {zip: "THISISBASE64"}, status: "ok"}),
|
||||
@@ -4110,6 +4309,18 @@ describe("Extension: Bridge", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("Should return an error when the backup archive cannot be created", async () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockFflateZipFailOnce(new Error("invalid zip data"));
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/backup", "");
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/backup",
|
||||
stringify({data: {}, status: "error", error: "invalid zip data"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("Should allow to restart", async () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/restart", "");
|
||||
@@ -4129,6 +4340,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);
|
||||
@@ -4250,6 +4470,58 @@ describe("Extension: Bridge", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("Change options consecutively, check restart required", async () => {
|
||||
settings.apply({health: {interval: 10, reset_on_check: false}});
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
// Change option that doesn't require restart
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/options", stringify({options: {health: {reset_on_check: true}}}));
|
||||
await flushPromises();
|
||||
|
||||
expect(settings.get().health.reset_on_check).toBe(true);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/options",
|
||||
stringify({data: {restart_required: false}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/info", expect.stringContaining('"restart_required":false'), {
|
||||
retain: true,
|
||||
});
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
// Change option that requires restart
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/options", stringify({options: {health: {interval: 11}}}));
|
||||
await flushPromises();
|
||||
|
||||
expect(settings.get().health.interval).toBe(11);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/options",
|
||||
stringify({data: {restart_required: true}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/info", expect.stringContaining('"restart_required":true'), {
|
||||
retain: true,
|
||||
});
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
// Change option that doesn't require restart
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/options", stringify({options: {health: {reset_on_check: false}}}));
|
||||
await flushPromises();
|
||||
|
||||
expect(settings.get().health.reset_on_check).toBe(false);
|
||||
|
||||
// System still requires restart
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/options",
|
||||
stringify({data: {restart_required: true}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/info", expect.stringContaining('"restart_required":true'), {
|
||||
retain: true,
|
||||
});
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
});
|
||||
|
||||
it("Icon link handling", () => {
|
||||
const bridge = controller.getExtension("Bridge")! as Bridge;
|
||||
expect(bridge).toBeDefined();
|
||||
|
||||
@@ -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 {stringify} from "../../lib/util/stringify";
|
||||
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,17 +146,75 @@ 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 () => {
|
||||
const emitDevicesChanged = vi.spyOn(controller.eventBus, "emitDevicesChanged");
|
||||
const emitExposesChanged = vi.spyOn(controller.eventBus, "emitExposesChanged");
|
||||
|
||||
mockClear(devices.remote);
|
||||
expectRemoteNotConfigured();
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/configure", "remote");
|
||||
await flushPromises();
|
||||
expectRemoteConfigured();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/configure",
|
||||
stringify({data: {id: "remote"}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
try {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/configure", "remote");
|
||||
await flushPromises();
|
||||
expectRemoteConfigured();
|
||||
expect(emitDevicesChanged).toHaveBeenCalledTimes(1);
|
||||
expect(emitExposesChanged).toHaveBeenCalledWith({
|
||||
device: controller.zigbee.resolveEntity(devices.remote),
|
||||
});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/configure",
|
||||
stringify({data: {id: "remote"}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
} finally {
|
||||
emitExposesChanged.mockRestore();
|
||||
emitDevicesChanged.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("Fail to configure via MQTT when device does not exist", async () => {
|
||||
@@ -227,27 +284,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 */);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import {devices, mockController as mockZHController, returnDevices} from "../moc
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {stringify} from "../../lib/util/stringify";
|
||||
import * as zhc from "zigbee-herdsman-converters";
|
||||
import {Controller} from "../../lib/controller";
|
||||
import ExternalConverters from "../../lib/extension/externalConverters";
|
||||
@@ -488,6 +488,25 @@ describe("Extension: ExternalConverters", () => {
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/converters", stringify([]), {retain: true});
|
||||
});
|
||||
|
||||
it("returns error on invalid name", async () => {
|
||||
const converterName = "foo1";
|
||||
|
||||
await resetExtension();
|
||||
for (const mock of mocksClear) mock.mockClear();
|
||||
|
||||
await (controller.getExtension("ExternalConverters")! as ExternalConverters).onMQTTMessage({
|
||||
topic: "zigbee2mqtt/bridge/request/converter/save",
|
||||
message: {name: converterName, code: "a"},
|
||||
});
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/converter/save",
|
||||
expect.stringContaining(`JavaScript file must have '.mjs', '.js' or '.cjs' extension`),
|
||||
{},
|
||||
);
|
||||
expect(writeFileSyncSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("returns error on invalid code", async () => {
|
||||
const converterName = "foo1.js";
|
||||
const converterCode = "definetly not a correct javascript code";
|
||||
@@ -609,4 +628,15 @@ describe("Extension: ExternalConverters", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("doesn't add extension when external JS disabled", async () => {
|
||||
settings.set(["advanced", "enable_external_js"], false);
|
||||
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(controller.getExtension("ExternalConverters")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import {devices, mockController as mockZHController, returnDevices} from "../moc
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {stringify} from "../../lib/util/stringify";
|
||||
import {Controller} from "../../lib/controller";
|
||||
import ExternalExtensions from "../../lib/extension/externalExtensions";
|
||||
import * as settings from "../../lib/util/settings";
|
||||
@@ -299,6 +299,25 @@ describe("Extension: ExternalExtensions", () => {
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/extensions", stringify([]), {retain: true});
|
||||
});
|
||||
|
||||
it("returns error on invalid name", async () => {
|
||||
const extensionName = "foo1";
|
||||
|
||||
await resetExtension();
|
||||
for (const mock of mocksClear) mock.mockClear();
|
||||
|
||||
await (controller.getExtension("ExternalExtensions")! as ExternalExtensions).onMQTTMessage({
|
||||
topic: "zigbee2mqtt/bridge/request/extension/save",
|
||||
message: {name: extensionName, code: "a"},
|
||||
});
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/extension/save",
|
||||
expect.stringContaining(`JavaScript file must have '.mjs', '.js' or '.cjs' extension`),
|
||||
{},
|
||||
);
|
||||
expect(writeFileSyncSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("returns error on invalid code", async () => {
|
||||
const extensionName = "foo1.js";
|
||||
const extensionCode = "definetly not a correct javascript code";
|
||||
@@ -365,4 +384,15 @@ describe("Extension: ExternalExtensions", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("doesn't add extension when external JS disabled", async () => {
|
||||
settings.set(["advanced", "enable_external_js"], false);
|
||||
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(controller.getExtension("ExternalExtensions")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,13 +7,18 @@ import {type EventHandler, flushPromises} from "../mocks/utils";
|
||||
import {devices, events as mockZHEvents} from "../mocks/zigbeeHerdsman";
|
||||
|
||||
import path from "node:path";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {stringify} from "../../lib/util/stringify";
|
||||
import type {Mock} from "vitest";
|
||||
import ws from "ws";
|
||||
import {Controller} from "../../lib/controller";
|
||||
import * as settings from "../../lib/util/settings";
|
||||
|
||||
let mockHTTPOnRequest: (request: {url: string}, response: number) => void;
|
||||
const mockRedirectResponse = {
|
||||
writeHead: vi.fn<(statusCode: number, headers: Record<string, string>) => void>(),
|
||||
end: vi.fn<() => void>(),
|
||||
};
|
||||
|
||||
let mockHTTPOnRequest: (request: {url: string}, response: number | typeof mockRedirectResponse) => void;
|
||||
const mockHTTPEvents: Record<string, EventHandler> = {};
|
||||
const mockHTTP = {
|
||||
listen: vi.fn(),
|
||||
@@ -64,7 +69,7 @@ const frontendPath = "frontend-path";
|
||||
const deviceIconsPath = path.join(data.mockDir, "device_icons");
|
||||
let mockNodeStatic: {[s: string]: Mock} = {};
|
||||
|
||||
const mockFinalHandler = vi.fn();
|
||||
const mockSendNotFound = vi.fn();
|
||||
|
||||
vi.mock("node:http", () => ({
|
||||
createServer: vi.fn().mockImplementation((onRequest) => {
|
||||
@@ -79,11 +84,12 @@ vi.mock("node:https", () => ({
|
||||
Agent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("express-static-gzip", () => ({
|
||||
default: vi.fn().mockImplementation((path: string) => {
|
||||
vi.mock("../../lib/util/staticFileServer", () => ({
|
||||
createStaticFileServer: vi.fn().mockImplementation((path: string) => {
|
||||
mockNodeStatic[path] = vi.fn();
|
||||
return mockNodeStatic[path];
|
||||
}),
|
||||
sendNotFound: vi.fn().mockImplementation((...args: unknown[]) => mockSendNotFound(...args)),
|
||||
}));
|
||||
|
||||
vi.mock("zigbee2mqtt-windfront", () => ({
|
||||
@@ -101,12 +107,6 @@ vi.mock("ws", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("finalhandler", () => ({
|
||||
default: vi.fn().mockImplementation(() => {
|
||||
return mockFinalHandler;
|
||||
}),
|
||||
}));
|
||||
|
||||
const mocksClear = [
|
||||
mockHTTP.close,
|
||||
mockHTTP.listen,
|
||||
@@ -118,7 +118,9 @@ const mocksClear = [
|
||||
mockWS.emit,
|
||||
mockWSClient.send,
|
||||
mockWSClient.terminate,
|
||||
mockFinalHandler,
|
||||
mockSendNotFound,
|
||||
mockRedirectResponse.writeHead,
|
||||
mockRedirectResponse.end,
|
||||
mockMQTTPublishAsync,
|
||||
mockLogger.error,
|
||||
];
|
||||
@@ -245,6 +247,9 @@ describe("Extension: Frontend", () => {
|
||||
stringify({
|
||||
state: "ON",
|
||||
effect: null,
|
||||
effect_color: null,
|
||||
effect_speed: null,
|
||||
identify: null,
|
||||
power_on_behavior: null,
|
||||
linkquality: 20,
|
||||
update: {state: null, installed_version: -1, latest_version: -1},
|
||||
@@ -269,6 +274,9 @@ describe("Extension: Frontend", () => {
|
||||
state: "ON",
|
||||
power_on_behavior: null,
|
||||
effect: null,
|
||||
effect_color: null,
|
||||
effect_speed: null,
|
||||
identify: null,
|
||||
linkquality: 20,
|
||||
update: {state: null, installed_version: -1, latest_version: -1},
|
||||
},
|
||||
@@ -296,6 +304,9 @@ describe("Extension: Frontend", () => {
|
||||
brightness: 90,
|
||||
power_on_behavior: null,
|
||||
effect: null,
|
||||
effect_color: null,
|
||||
effect_speed: null,
|
||||
identify: null,
|
||||
linkquality: 20,
|
||||
update: {state: null, installed_version: -1, latest_version: -1},
|
||||
},
|
||||
@@ -334,11 +345,7 @@ describe("Extension: Frontend", () => {
|
||||
mockHTTPOnRequest({url: "/file.txt"}, 2);
|
||||
expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledTimes(0);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith(
|
||||
{originalUrl: "/file.txt", path: "/file.txt", url: "/file.txt"},
|
||||
2,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith({url: "/file.txt"}, 2);
|
||||
});
|
||||
|
||||
it("Should serve device icons", async () => {
|
||||
@@ -348,11 +355,7 @@ describe("Extension: Frontend", () => {
|
||||
mockHTTPOnRequest({url: "/device_icons/my_device.png"}, 2);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(0);
|
||||
expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledWith(
|
||||
{originalUrl: "/device_icons/my_device.png", path: "/my_device.png", url: "/my_device.png"},
|
||||
2,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledWith({url: "/my_device.png"}, 2);
|
||||
});
|
||||
|
||||
it("Static server", async () => {
|
||||
@@ -396,34 +399,33 @@ describe("Extension: Frontend", () => {
|
||||
|
||||
expect(ws.Server).toHaveBeenCalledWith({noServer: true, path: "/z2m/api"});
|
||||
|
||||
mockHTTPOnRequest({url: "/z2m"}, 2);
|
||||
// the base url without trailing slash points at a directory, redirect so relative asset paths resolve against it
|
||||
mockHTTPOnRequest({url: "/z2m"}, mockRedirectResponse);
|
||||
expect(mockNodeStatic[frontendPath]).not.toHaveBeenCalled();
|
||||
expect(mockRedirectResponse.writeHead).toHaveBeenCalledWith(301, {Location: "/z2m/"});
|
||||
expect(mockRedirectResponse.end).toHaveBeenCalledTimes(1);
|
||||
expect(mockSendNotFound).not.toHaveBeenCalled();
|
||||
|
||||
mockHTTPOnRequest({url: "/z2m/"}, 2);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith({originalUrl: "/z2m", path: "/", url: "/"}, 2, expect.any(Function));
|
||||
expect(mockFinalHandler).not.toHaveBeenCalledWith();
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith({url: "/"}, 2);
|
||||
expect(mockSendNotFound).not.toHaveBeenCalledWith();
|
||||
|
||||
mockNodeStatic[frontendPath].mockReset();
|
||||
expect(mockFinalHandler).not.toHaveBeenCalledWith();
|
||||
expect(mockSendNotFound).not.toHaveBeenCalledWith();
|
||||
mockHTTPOnRequest({url: "/z2m/file.txt"}, 2);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith(
|
||||
{originalUrl: "/z2m/file.txt", path: "/file.txt", url: "/file.txt"},
|
||||
2,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockFinalHandler).not.toHaveBeenCalledWith();
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith({url: "/file.txt"}, 2);
|
||||
expect(mockSendNotFound).not.toHaveBeenCalledWith();
|
||||
|
||||
mockNodeStatic[frontendPath].mockReset();
|
||||
mockHTTPOnRequest({url: "/z/file.txt"}, 2);
|
||||
expect(mockNodeStatic[frontendPath]).not.toHaveBeenCalled();
|
||||
expect(mockFinalHandler).toHaveBeenCalled();
|
||||
expect(mockSendNotFound).toHaveBeenCalled();
|
||||
|
||||
mockHTTPOnRequest({url: "/z2m/device_icons/my-device.png"}, 2);
|
||||
expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledWith(
|
||||
{originalUrl: "/z2m/device_icons/my-device.png", path: "/my-device.png", url: "/my-device.png"},
|
||||
2,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledWith({url: "/my-device.png"}, 2);
|
||||
});
|
||||
|
||||
it("Works with non-default complex base url", async () => {
|
||||
@@ -434,30 +436,28 @@ describe("Extension: Frontend", () => {
|
||||
|
||||
expect(ws.Server).toHaveBeenCalledWith({noServer: true, path: "/z2m-more++/c0mplex.url/api"});
|
||||
|
||||
mockHTTPOnRequest({url: "/z2m-more++/c0mplex.url"}, 2);
|
||||
mockHTTPOnRequest({url: "/z2m-more++/c0mplex.url"}, mockRedirectResponse);
|
||||
expect(mockNodeStatic[frontendPath]).not.toHaveBeenCalled();
|
||||
expect(mockRedirectResponse.writeHead).toHaveBeenCalledWith(301, {Location: "/z2m-more++/c0mplex.url/"});
|
||||
expect(mockRedirectResponse.end).toHaveBeenCalledTimes(1);
|
||||
expect(mockSendNotFound).not.toHaveBeenCalled();
|
||||
|
||||
mockHTTPOnRequest({url: "/z2m-more++/c0mplex.url/"}, 2);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith(
|
||||
{originalUrl: "/z2m-more++/c0mplex.url", path: "/", url: "/"},
|
||||
2,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockFinalHandler).not.toHaveBeenCalledWith();
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith({url: "/"}, 2);
|
||||
expect(mockSendNotFound).not.toHaveBeenCalledWith();
|
||||
|
||||
mockNodeStatic[frontendPath].mockReset();
|
||||
expect(mockFinalHandler).not.toHaveBeenCalledWith();
|
||||
expect(mockSendNotFound).not.toHaveBeenCalledWith();
|
||||
mockHTTPOnRequest({url: "/z2m-more++/c0mplex.url/file.txt"}, 2);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith(
|
||||
{originalUrl: "/z2m-more++/c0mplex.url/file.txt", path: "/file.txt", url: "/file.txt"},
|
||||
2,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockFinalHandler).not.toHaveBeenCalledWith();
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith({url: "/file.txt"}, 2);
|
||||
expect(mockSendNotFound).not.toHaveBeenCalledWith();
|
||||
|
||||
mockNodeStatic[frontendPath].mockReset();
|
||||
mockHTTPOnRequest({url: "/z/file.txt"}, 2);
|
||||
expect(mockNodeStatic[frontendPath]).not.toHaveBeenCalled();
|
||||
expect(mockFinalHandler).toHaveBeenCalled();
|
||||
expect(mockSendNotFound).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prevents mismatching setting/extension state", async () => {
|
||||
@@ -478,10 +478,6 @@ describe("Extension: Frontend", () => {
|
||||
|
||||
await controller.enableDisableExtension(true, "Frontend");
|
||||
|
||||
await expect(async () => {
|
||||
await controller.enableDisableExtension(true, "Frontend");
|
||||
}).rejects.toThrow("Extension with name Frontend already present");
|
||||
|
||||
settings.set(["frontend", "enabled"], false);
|
||||
await controller.enableDisableExtension(false, "Frontend");
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
|
||||
import {flushPromises} from "../mocks/utils";
|
||||
import {devices, groups, events as mockZHEvents, resetGroupMembers, returnDevices} from "../mocks/zigbeeHerdsman";
|
||||
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {stringify} from "../../lib/util/stringify";
|
||||
import * as zhcGlobalStore from "zigbee-herdsman-converters/lib/store";
|
||||
import {Controller} from "../../lib/controller";
|
||||
import * as settings from "../../lib/util/settings";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ import {devices, events as mockZHEvents, returnDevices} from "../mocks/zigbeeHer
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {stringify} from "../../lib/util/stringify";
|
||||
import {Controller} from "../../lib/controller";
|
||||
import * as settings from "../../lib/util/settings";
|
||||
|
||||
@@ -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, identify, linkquality",
|
||||
vendor: "Philips",
|
||||
},
|
||||
failed: [],
|
||||
@@ -367,23 +367,34 @@ describe("Extension: NetworkMap", () => {
|
||||
|
||||
it("Output graphviz networkmap", async () => {
|
||||
mock();
|
||||
const device = devices.bulb_color;
|
||||
device.lastSeen = undefined;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
const data = {modelID: "test"};
|
||||
const payload = {data, cluster: "genOnOff", device, endpoint, type: "readResponse", linkquality: 10};
|
||||
await mockZHEvents.message(payload);
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/networkmap", stringify({type: "graphviz", routes: true}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bridge/response/networkmap");
|
||||
const z2mDevice = controller.zigbee.resolveEntity(devices.bulb_color.ieeeAddr);
|
||||
const originalDef = z2mDevice.definition;
|
||||
z2mDevice.definition = {
|
||||
...originalDef,
|
||||
description: `${originalDef.description} 6"`,
|
||||
};
|
||||
|
||||
try {
|
||||
const device = devices.bulb_color;
|
||||
device.lastSeen = undefined;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
const data = {modelID: "test"};
|
||||
const payload = {data, cluster: "genOnOff", device, endpoint, type: "readResponse", linkquality: 10};
|
||||
await mockZHEvents.message(payload);
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/networkmap", stringify({type: "graphviz", routes: true}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bridge/response/networkmap");
|
||||
} finally {
|
||||
z2mDevice.definition = originalDef;
|
||||
}
|
||||
|
||||
const expected = `digraph G {
|
||||
node[shape=record];
|
||||
"0x00124b00120144ae" [style="bold, filled", fillcolor="#e04e5d", fontcolor="#ffffff", label="{Coordinator|0x00124b00120144ae (0x0000)|0 seconds ago}"];
|
||||
"0x000b57fffec6a5b2" [style="rounded, filled", fillcolor="#4ea3e0", fontcolor="#ffffff", label="{bulb|0x000b57fffec6a5b2 (0x9db1)|IKEA TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm (LED1545G12)|9 seconds ago}"];
|
||||
"0x000b57fffec6a5b2" -> "0x00124b00120144ae" [penwidth=2, weight=1, color="#009900", label="92 (routes: 0x198c)"]
|
||||
"0x000b57fffec6a5b3" [style="rounded, filled", fillcolor="#4ea3e0", fontcolor="#ffffff", label="{bulb_color|0x000b57fffec6a5b3 (0x9dcf)|Philips Hue Go (7146060PH)|unknown}"];
|
||||
"0x000b57fffec6a5b3" [style="rounded, filled", fillcolor="#4ea3e0", fontcolor="#ffffff", label="{bulb_color|0x000b57fffec6a5b3 (0x9dcf)|Philips Hue Go 6\\" (7146060PH)|unknown}"];
|
||||
"0x000b57fffec6a5b3" -> "0x00124b00120144ae" [penwidth=0.5, weight=0, color="#994444", label="120"]
|
||||
"0x000b57fffec6a5b3" -> "0x000b57fffec6a5b2" [penwidth=0.5, weight=0, color="#994444", label="110"]
|
||||
"0x0017880104e45521" [style="rounded, dashed, filled", fillcolor="#fff8ce", fontcolor="#000000", label="{button_double_key|0x0017880104e45521 (0x198a)|Aqara Wireless remote switch (double rocker), 2016 model (WXKG02LM_rev1)|9 seconds ago}"];
|
||||
@@ -605,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, identify, linkquality",
|
||||
vendor: "Philips",
|
||||
},
|
||||
failed: [],
|
||||
@@ -774,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, identify, linkquality",
|
||||
vendor: "Philips",
|
||||
},
|
||||
failed: [],
|
||||
|
||||
+862
-205
File diff suppressed because it is too large
Load Diff
+412
-72
@@ -7,7 +7,7 @@ import * as mockSleep from "../mocks/sleep";
|
||||
import {flushPromises} from "../mocks/utils";
|
||||
import {devices, groups, events as mockZHEvents} from "../mocks/zigbeeHerdsman";
|
||||
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {stringify} from "../../lib/util/stringify";
|
||||
import {clearGlobalStore} from "zigbee-herdsman-converters";
|
||||
import {Controller} from "../../lib/controller";
|
||||
import {loadTopicGetSetRegex} from "../../lib/extension/publish";
|
||||
@@ -73,7 +73,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({brightness: "200"}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 200, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 200, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({brightness: 200, state: "ON"});
|
||||
@@ -119,7 +124,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({brightness_percent: "92"}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 235, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 235, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({state: "ON", brightness: 235});
|
||||
@@ -132,7 +142,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({brightness: 230}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 230, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 230, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({state: "ON", brightness: 230});
|
||||
@@ -145,7 +160,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({color_temp: "222"}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColorTemp", {colortemp: 222, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"lightingColorCtrl",
|
||||
"moveToColorTemp",
|
||||
{colortemp: 222, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({color_mode: "color_temp", color_temp: 222});
|
||||
@@ -158,7 +178,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({color_temp_percent: 100}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColorTemp", {colortemp: 500, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"lightingColorCtrl",
|
||||
"moveToColorTemp",
|
||||
{colortemp: 500, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({color_mode: "color_temp", color_temp: 500});
|
||||
@@ -223,7 +248,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/0x0017882194e45543/set", stringify({state_l2: "ON", brightness_l2: 50}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 50, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 50, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/QS-Zigbee-D02-TRIAC-2C-LN", stringify({brightness_l2: 50, state_l2: "ON"}), {
|
||||
retain: false,
|
||||
qos: 0,
|
||||
@@ -284,7 +314,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({color: {x: 100, y: 50}}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColor", {colorx: 6553500, colory: 3276750, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"lightingColorCtrl",
|
||||
"moveToColor",
|
||||
{colorx: 6553500, colory: 3276750, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({color_mode: "xy", color: {x: 100, y: 50}});
|
||||
@@ -298,7 +333,12 @@ describe("Extension: Publish", () => {
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genOnOff", "on", {}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColor", {colorx: 6553500, colory: 3276750, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"lightingColorCtrl",
|
||||
"moveToColor",
|
||||
{colorx: 6553500, colory: 3276750, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bulb_color",
|
||||
@@ -313,8 +353,18 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({color: {x: 100, y: 50}, brightness: 20}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 20, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColor", {colorx: 6553500, colory: 3276750, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 20, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"lightingColorCtrl",
|
||||
"moveToColor",
|
||||
{colorx: 6553500, colory: 3276750, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bulb_color",
|
||||
@@ -329,8 +379,18 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({color: {x: 100, y: 50}, brightness: 20, state: "ON"}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 20, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColor", {colorx: 6553500, colory: 3276750, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 20, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"lightingColorCtrl",
|
||||
"moveToColor",
|
||||
{colorx: 6553500, colory: 3276750, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bulb_color",
|
||||
@@ -345,7 +405,13 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({color: {x: 100, y: 50}, brightness: 20, state: "OFF"}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint.command).toHaveBeenNthCalledWith(1, "lightingColorCtrl", "moveToColor", {colorx: 6553500, colory: 3276750, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"lightingColorCtrl",
|
||||
"moveToColor",
|
||||
{colorx: 6553500, colory: 3276750, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(endpoint.command).toHaveBeenNthCalledWith(2, "genOnOff", "off", {}, {});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
@@ -361,7 +427,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({color: {r: 100, g: 200, b: 10}}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColor", {colorx: 17806, colory: 43155, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"lightingColorCtrl",
|
||||
"moveToColor",
|
||||
{colorx: 17806, colory: 43155, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({color: {x: 0.2717, y: 0.6585}, color_mode: "xy"});
|
||||
@@ -373,7 +444,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({color: {rgb: "100,200,10"}}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColor", {colorx: 17806, colory: 43155, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"lightingColorCtrl",
|
||||
"moveToColor",
|
||||
{colorx: 17806, colory: 43155, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({color: {x: 0.2717, y: 0.6585}, color_mode: "xy"});
|
||||
@@ -385,7 +461,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "ON", brightness: "50"}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 50, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 50, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({state: "ON", brightness: 50});
|
||||
@@ -410,7 +491,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/group_1/set", stringify({brightness_percent: 50}));
|
||||
await flushPromises();
|
||||
expect(group.command).toHaveBeenCalledTimes(1);
|
||||
expect(group.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 128, transtime: 0}, {});
|
||||
expect(group.command).toHaveBeenCalledWith(
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 128, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2); // 'zigbee2mqtt/bulb_color' + below
|
||||
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual("zigbee2mqtt/group_1");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toStrictEqual({state: "ON", brightness: 128});
|
||||
@@ -449,7 +535,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/group_1/set", stringify({state: "ON", brightness: 50}));
|
||||
await flushPromises();
|
||||
expect(group.command).toHaveBeenCalledTimes(1);
|
||||
expect(group.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 50, transtime: 0}, {});
|
||||
expect(group.command).toHaveBeenCalledWith(
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 50, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2); // 'zigbee2mqtt/bulb_color' + below
|
||||
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual("zigbee2mqtt/group_1");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toStrictEqual({state: "ON", brightness: 50});
|
||||
@@ -475,7 +566,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/group_1/set", stringify({color: {x: 0.37, y: 0.28}}));
|
||||
await flushPromises();
|
||||
expect(group.command).toHaveBeenCalledTimes(1);
|
||||
expect(group.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColor", {colorx: 24248, colory: 18350, transtime: 0}, {});
|
||||
expect(group.command).toHaveBeenCalledWith(
|
||||
"lightingColorCtrl",
|
||||
"moveToColor",
|
||||
{colorx: 24248, colory: 18350, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2); // 'zigbee2mqtt/bulb_color' + below
|
||||
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual("zigbee2mqtt/group_1");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toStrictEqual({color: {x: 0.37, y: 0.28}, color_mode: "xy"});
|
||||
@@ -488,7 +584,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/group_1/set", stringify({color_temp: 100}));
|
||||
await flushPromises();
|
||||
expect(group.command).toHaveBeenCalledTimes(1);
|
||||
expect(group.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColorTemp", {colortemp: 100, transtime: 0}, {});
|
||||
expect(group.command).toHaveBeenCalledWith(
|
||||
"lightingColorCtrl",
|
||||
"moveToColorTemp",
|
||||
{colortemp: 100, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2); // 'zigbee2mqtt/bulb_color' + below
|
||||
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual("zigbee2mqtt/group_1");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toStrictEqual({color_temp: 100, color_mode: "color_temp"});
|
||||
@@ -515,7 +616,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({brightness: "200"}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 200, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 200, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
@@ -526,7 +632,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({brightness: "200"}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 200, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 200, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({state: "ON"});
|
||||
});
|
||||
@@ -681,7 +792,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set/color", "#64C80A");
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColor", {colorx: 17806, colory: 43155, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"lightingColorCtrl",
|
||||
"moveToColor",
|
||||
{colorx: 17806, colory: 43155, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({color: {x: 0.2717, y: 0.6585}, color_mode: "xy"});
|
||||
@@ -798,7 +914,12 @@ describe("Extension: Publish", () => {
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genOnOff", "on", {}, {}]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual(["lightingColorCtrl", "moveToColor", {colorx: 45940, colory: 19595, transtime: 0}, {}]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual([
|
||||
"lightingColorCtrl",
|
||||
"moveToColor",
|
||||
{colorx: 45940, colory: 19595, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
});
|
||||
|
||||
it("Should also use on/off cluster when controlling group with switch", async () => {
|
||||
@@ -809,7 +930,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/switch_group/set", stringify({state: "ON", brightness: 100}));
|
||||
await flushPromises();
|
||||
expect(group.command).toHaveBeenCalledTimes(2);
|
||||
expect(group.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 100, transtime: 0}, {});
|
||||
expect(group.command).toHaveBeenCalledWith(
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 100, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(group.command).toHaveBeenCalledWith("genOnOff", "on", {}, {});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/switch_group", stringify({state: "ON", brightness: 100}), {
|
||||
retain: false,
|
||||
@@ -835,7 +961,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/group_1/set", stringify({brightness: 100}));
|
||||
await flushPromises();
|
||||
expect(group.command).toHaveBeenCalledTimes(1);
|
||||
expect(group.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 100, transtime: 200}, {});
|
||||
expect(group.command).toHaveBeenCalledWith(
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 100, transtime: 200, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2); // zigbee2mqtt/bulb_color + below
|
||||
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual("zigbee2mqtt/group_1");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toStrictEqual({state: "ON", brightness: 100});
|
||||
@@ -850,7 +981,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify(payload));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 20, transtime: 200}, {}]);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 20, transtime: 200, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
});
|
||||
|
||||
it("Should use transition from device_options on brightness command", async () => {
|
||||
@@ -861,7 +997,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify(payload));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 20, transtime: 200}, {}]);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 20, transtime: 200, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
});
|
||||
|
||||
it("Should turn bulb on with correct brightness when device is turned off twice and brightness is reported", async () => {
|
||||
@@ -909,10 +1050,34 @@ describe("Extension: Publish", () => {
|
||||
});
|
||||
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(4);
|
||||
expect(endpoint.command).toHaveBeenNthCalledWith(1, "genLevelCtrl", "moveToLevelWithOnOff", {level: 200, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenNthCalledWith(2, "genLevelCtrl", "moveToLevelWithOnOff", {level: 0, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenNthCalledWith(3, "genLevelCtrl", "moveToLevelWithOnOff", {level: 0, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenNthCalledWith(4, "genLevelCtrl", "moveToLevelWithOnOff", {level: 200, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 200, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(endpoint.command).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 0, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(endpoint.command).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 0, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(endpoint.command).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 200, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("Should turn bulb on with full brightness when transition is used and no brightness is known", async () => {
|
||||
@@ -929,8 +1094,18 @@ describe("Extension: Publish", () => {
|
||||
qos: 0,
|
||||
});
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 0, transtime: 5}, {}]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 254, transtime: 5}, {}]);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 0, transtime: 5, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 254, transtime: 5, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
});
|
||||
|
||||
it("Transition parameter should not influence brightness on state ON", async () => {
|
||||
@@ -944,9 +1119,19 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "ON", transition: 1}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 50, transtime: 0}, {}]);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 50, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual(["genOnOff", "on", {}, {}]);
|
||||
expect(endpoint.command.mock.calls[2]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 50, transtime: 10}, {}]);
|
||||
expect(endpoint.command.mock.calls[2]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 50, transtime: 10, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
});
|
||||
|
||||
it("Should use transition when color temp", async () => {
|
||||
@@ -957,7 +1142,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify(payload));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["lightingColorCtrl", "moveToColorTemp", {colortemp: 200, transtime: 200}, {}]);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"lightingColorCtrl",
|
||||
"moveToColorTemp",
|
||||
{colortemp: 200, transtime: 200, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
});
|
||||
|
||||
it("Should use transition only once when setting brightness and color temperature for TRADFRI", async () => {
|
||||
@@ -967,8 +1157,18 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb/set", stringify(payload));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 20, transtime: 0}, {}]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual(["lightingColorCtrl", "moveToColorTemp", {colortemp: 200, transtime: 200}, {}]);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 20, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual([
|
||||
"lightingColorCtrl",
|
||||
"moveToColorTemp",
|
||||
{colortemp: 200, transtime: 200, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
});
|
||||
|
||||
it("Should use transition only once when setting brightness and color temperature for group which contains TRADFRI", async () => {
|
||||
@@ -976,8 +1176,18 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/group_with_tradfri/set", stringify({state: "ON", transition: 60, brightness: 20, color_temp: 400}));
|
||||
await flushPromises();
|
||||
expect(group.command).toHaveBeenCalledTimes(2);
|
||||
expect(group.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 20, transtime: 0}, {}]);
|
||||
expect(group.command.mock.calls[1]).toEqual(["lightingColorCtrl", "moveToColorTemp", {colortemp: 400, transtime: 600}, {}]);
|
||||
expect(group.command.mock.calls[0]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 20, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(group.command.mock.calls[1]).toEqual([
|
||||
"lightingColorCtrl",
|
||||
"moveToColorTemp",
|
||||
{colortemp: 400, transtime: 600, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
});
|
||||
|
||||
it("Message transition should overrule options transition", async () => {
|
||||
@@ -988,7 +1198,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify(payload));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 200, transtime: 100}, {}]);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 200, transtime: 100, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
});
|
||||
|
||||
it("Should set state with brightness before color", async () => {
|
||||
@@ -998,8 +1213,18 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify(payload));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 100, transtime: 30}, {}]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual(["lightingColorCtrl", "moveToColor", {colorx: 45940, colory: 19595, transtime: 30}, {}]);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 100, transtime: 30, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual([
|
||||
"lightingColorCtrl",
|
||||
"moveToColor",
|
||||
{colorx: 45940, colory: 19595, transtime: 30, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
});
|
||||
|
||||
it("Should turn device off when brightness 0 is send", async () => {
|
||||
@@ -1012,9 +1237,24 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "ON"}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 50, transtime: 0}, {}]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 0, transtime: 0}, {}]);
|
||||
expect(endpoint.command.mock.calls[2]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 50, transtime: 0}, {}]);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 50, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 0, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(endpoint.command.mock.calls[2]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 50, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(3);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0]).toEqual([
|
||||
"zigbee2mqtt/bulb_color",
|
||||
@@ -1043,9 +1283,24 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "ON"}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 50, transtime: 0}, {}]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 0, transtime: 30}, {}]);
|
||||
expect(endpoint.command.mock.calls[2]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 50, transtime: 0}, {}]);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 50, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 0, transtime: 30, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(endpoint.command.mock.calls[2]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 50, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(3);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0]).toEqual([
|
||||
"zigbee2mqtt/bulb_color",
|
||||
@@ -1074,7 +1329,7 @@ describe("Extension: Publish", () => {
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"lightingColorCtrl",
|
||||
"enhancedMoveToHueAndSaturation",
|
||||
{enhancehue: 45510, saturation: 127, transtime: 0},
|
||||
{enhancehue: 45510, saturation: 127, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
@@ -1129,7 +1384,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify(payload));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 254, transtime: 30}, {}]);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 254, transtime: 30, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
});
|
||||
|
||||
it("Should turn device on with on/off with transition when transition 0 is provided", async () => {
|
||||
@@ -1139,7 +1399,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify(payload));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 254, transtime: 0}, {}]);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 254, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
});
|
||||
|
||||
it("Should turn device off with onOff on off with transition", async () => {
|
||||
@@ -1149,7 +1414,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify(payload));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 0, transtime: 10}, {}]);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 0, transtime: 10, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({state: "OFF"});
|
||||
@@ -1168,7 +1438,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "OFF", transition: 3}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 0, transtime: 30}, {}]);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 0, transtime: 30, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0]).toEqual([
|
||||
"zigbee2mqtt/bulb_color",
|
||||
@@ -1197,7 +1472,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "ON", transition: 3}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 200, transtime: 30}, {}]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 200, transtime: 30, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(3);
|
||||
expect(mockMQTTPublishAsync.mock.calls[2]).toEqual([
|
||||
"zigbee2mqtt/bulb_color",
|
||||
@@ -1219,7 +1499,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "OFF", transition: 3}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 0, transtime: 30}, {}]);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 0, transtime: 30, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0]).toEqual([
|
||||
"zigbee2mqtt/bulb_color",
|
||||
@@ -1231,7 +1516,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "ON"}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 200, transtime: 0}, {}]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 200, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mockMQTTPublishAsync.mock.calls[1]).toEqual([
|
||||
"zigbee2mqtt/bulb_color",
|
||||
@@ -1264,7 +1554,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify(payload));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["lightingColorCtrl", "moveToColorTemp", {colortemp: 100, transtime: 0}, {}]);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"lightingColorCtrl",
|
||||
"moveToColorTemp",
|
||||
{colortemp: 100, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({state: "ON", color_temp: 100, color_mode: "color_temp"});
|
||||
@@ -1281,7 +1576,12 @@ describe("Extension: Publish", () => {
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genOnOff", "on", {}, {}]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual(["lightingColorCtrl", "moveToColorTemp", {colortemp: 100, transtime: 0}, {}]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual([
|
||||
"lightingColorCtrl",
|
||||
"moveToColorTemp",
|
||||
{colortemp: 100, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bulb_color",
|
||||
@@ -1300,7 +1600,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify(payload));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["lightingColorCtrl", "moveToColor", {colorx: 26869, colory: 16384, transtime: 0}, {}]);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual([
|
||||
"lightingColorCtrl",
|
||||
"moveToColor",
|
||||
{colorx: 26869, colory: 16384, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bulb_color");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({color: {x: 0.41, y: 0.25}, state: "ON", color_mode: "xy"});
|
||||
@@ -1363,7 +1668,7 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({brightness_move: -40}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "move", {movemode: 1, rate: 40}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "move", {movemode: 1, rate: 40, optionsMask: 0, optionsOverride: 0}, {});
|
||||
});
|
||||
|
||||
it("Should publish brightness_move down to zigbee devices", async () => {
|
||||
@@ -1371,7 +1676,7 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({brightness_move: 30}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "move", {movemode: 0, rate: 30}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "move", {movemode: 0, rate: 30, optionsMask: 0, optionsOverride: 0}, {});
|
||||
});
|
||||
|
||||
it("HS2WD-E burglar warning", async () => {
|
||||
@@ -1442,7 +1747,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/roller_shutter/set", stringify({state: "OPEN"}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 255, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 255, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/roller_shutter");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({position: 100});
|
||||
@@ -1476,7 +1786,12 @@ describe("Extension: Publish", () => {
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genOnOff", "on", {}, {}]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 20, transtime: 0}, {}]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 20, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/GL-S-007ZS", stringify({state: "ON", brightness: 20}), {
|
||||
qos: 0,
|
||||
retain: false,
|
||||
@@ -1509,8 +1824,18 @@ describe("Extension: Publish", () => {
|
||||
await flushPromises();
|
||||
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 20, transtime: 0}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 20, transtime: 10}, {});
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 20, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
expect(endpoint.command).toHaveBeenCalledWith(
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 20, transtime: 10, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("Should restore brightness when its turned off without transition and is turned on with", async () => {
|
||||
@@ -1527,7 +1852,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "ON", brightness: 123}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 123, transtime: 0}, {}]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 123, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "OFF"}));
|
||||
await flushPromises();
|
||||
@@ -1537,7 +1867,12 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({state: "ON", transition: 1.0}));
|
||||
await flushPromises();
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(4);
|
||||
expect(endpoint.command.mock.calls[3]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 123, transtime: 10}, {}]);
|
||||
expect(endpoint.command.mock.calls[3]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 123, transtime: 10, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
});
|
||||
|
||||
it("Shouldnt use moveToLevelWithOnOff on turn on when no transition has been used as some devices do not turn on in that case", async () => {
|
||||
@@ -1556,7 +1891,12 @@ describe("Extension: Publish", () => {
|
||||
|
||||
expect(endpoint.command).toHaveBeenCalledTimes(4);
|
||||
expect(endpoint.command.mock.calls[0]).toEqual(["genOnOff", "on", {}, {}]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual(["genLevelCtrl", "moveToLevelWithOnOff", {level: 150, transtime: 0}, {}]);
|
||||
expect(endpoint.command.mock.calls[1]).toEqual([
|
||||
"genLevelCtrl",
|
||||
"moveToLevelWithOnOff",
|
||||
{level: 150, transtime: 0, optionsMask: 0, optionsOverride: 0},
|
||||
{},
|
||||
]);
|
||||
expect(endpoint.command.mock.calls[2]).toEqual(["genOnOff", "off", {}, {}]);
|
||||
expect(endpoint.command.mock.calls[3]).toEqual(["genOnOff", "on", {}, {}]);
|
||||
|
||||
@@ -1601,7 +1941,7 @@ describe("Extension: Publish", () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/group_tradfri_remote/set", stringify({scene_recall: 1}));
|
||||
await flushPromises();
|
||||
expect(group.command).toHaveBeenCalledTimes(1);
|
||||
expect(group.command).toHaveBeenCalledWith("genScenes", "recall", {groupid: 15071, sceneid: 1}, {});
|
||||
expect(group.command).toHaveBeenCalledWith("genScenes", "recall", {groupid: 15071, sceneid: 1, transitionTime: 0xffff}, {});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(8);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as mockSleep from "../mocks/sleep";
|
||||
import {flushPromises} from "../mocks/utils";
|
||||
import {devices, events as mockZHEvents} from "../mocks/zigbeeHerdsman";
|
||||
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {stringify} from "../../lib/util/stringify";
|
||||
import {Controller} from "../../lib/controller";
|
||||
import * as settings from "../../lib/util/settings";
|
||||
|
||||
@@ -190,6 +190,30 @@ describe("Extension: Receive", () => {
|
||||
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual("zigbee2mqtt/bridge/health");
|
||||
});
|
||||
|
||||
it("Should not bypass the debounce when a message produces no payload", async () => {
|
||||
const device = devices.WSDCGQ11LM;
|
||||
settings.set(["devices", device.ieeeAddr, "debounce"], 0.1);
|
||||
settings.set(["advanced", "last_seen"], "ISO_8601");
|
||||
// Attribute report without measuredValue: the lumi_temperature converter returns nothing.
|
||||
const payload = {
|
||||
data: {},
|
||||
cluster: "msTemperatureMeasurement",
|
||||
device,
|
||||
endpoint: device.getEndpoint(1),
|
||||
type: "attributeReport",
|
||||
linkquality: 10,
|
||||
};
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
// The empty payload must not be published immediately (bypassing the debounce).
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
|
||||
vi.runOnlyPendingTimers();
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/weather_sensor");
|
||||
});
|
||||
|
||||
it("Should debounce and retain messages when set via device_options", async () => {
|
||||
const device = devices.WSDCGQ11LM;
|
||||
settings.set(["device_options", "debounce"], 0.1);
|
||||
|
||||
+29
-4
@@ -5,13 +5,14 @@ import * as data from "./mocks/data";
|
||||
import fs from "node:fs";
|
||||
import {platform} from "node:os";
|
||||
import path from "node:path";
|
||||
import {rimrafSync} from "rimraf";
|
||||
import tmp from "tmp";
|
||||
import type {MockInstance} from "vitest";
|
||||
import Transport from "winston-transport";
|
||||
import logger from "../lib/util/logger";
|
||||
import * as settings from "../lib/util/settings";
|
||||
|
||||
const rmSync = (target: string): void => fs.rmSync(target, {recursive: true, force: true});
|
||||
|
||||
describe("Logger", () => {
|
||||
let consoleWriteSpy: MockInstance;
|
||||
const dir = tmp.dirSync();
|
||||
@@ -43,11 +44,12 @@ describe("Logger", () => {
|
||||
it("Create log directory", () => {
|
||||
const dirs = fs.readdirSync(dir.name);
|
||||
expect(dirs.length).toBe(1);
|
||||
expect(dirs[0]).toMatch(/^[0-9]{4}-[0-9]{2}-[0-9]{2}\.[0-9]{2}-[0-9]{2}-[0-9]{2}$/);
|
||||
});
|
||||
|
||||
it("Should cleanup (default setting)", () => {
|
||||
for (const d of fs.readdirSync(dir.name)) {
|
||||
rimrafSync(path.join(dir.name, d));
|
||||
rmSync(path.join(dir.name, d));
|
||||
}
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
@@ -59,9 +61,32 @@ describe("Logger", () => {
|
||||
expect(fs.readdirSync(dir.name).length).toBe(10);
|
||||
});
|
||||
|
||||
it("Should handle cleanup error", () => {
|
||||
for (const d of fs.readdirSync(dir.name)) {
|
||||
rmSync(path.join(dir.name, d));
|
||||
}
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
fs.mkdirSync(path.join(dir.name, `log_${i}`));
|
||||
}
|
||||
|
||||
const rmSyncSpy = vi.spyOn(fs, "rmSync").mockImplementationOnce(() => {
|
||||
throw new Error("EACCES: permission denied");
|
||||
});
|
||||
|
||||
const errorSpy = vi.spyOn(logger, "error");
|
||||
|
||||
try {
|
||||
logger.init();
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringMatching(/Failed to remove old log directory '.*': Error: EACCES: permission denied/));
|
||||
} finally {
|
||||
rmSyncSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("Should cleanup (15 folders setting)", () => {
|
||||
for (const d of fs.readdirSync(dir.name)) {
|
||||
rimrafSync(path.join(dir.name, d));
|
||||
rmSync(path.join(dir.name, d));
|
||||
}
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
@@ -76,7 +101,7 @@ describe("Logger", () => {
|
||||
|
||||
it("Should not cleanup when there is no timestamp set", () => {
|
||||
for (const d of fs.readdirSync(dir.name)) {
|
||||
rimrafSync(path.join(dir.name, d));
|
||||
rmSync(path.join(dir.name, d));
|
||||
}
|
||||
|
||||
for (let i = 30; i < 50; i++) {
|
||||
|
||||
+8
-5
@@ -1,9 +1,8 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import tmp from "tmp";
|
||||
|
||||
import {vi} from "vitest";
|
||||
import {stringify} from "../../lib/util/stringify";
|
||||
import yaml from "../../lib/util/yaml";
|
||||
|
||||
export const mockDir: string = tmp.dirSync().name;
|
||||
@@ -278,6 +277,10 @@ export function stateExists(): boolean {
|
||||
return fs.existsSync(stateFile);
|
||||
}
|
||||
|
||||
export function readState(): Record<string, unknown> {
|
||||
return JSON.parse(fs.readFileSync(stateFile, "utf8"));
|
||||
}
|
||||
|
||||
const defaultState = {
|
||||
"0x000b57fffec6a5b2": {
|
||||
state: "ON",
|
||||
@@ -297,8 +300,8 @@ export function getDefaultState(): typeof defaultState {
|
||||
return defaultState;
|
||||
}
|
||||
|
||||
export function writeDefaultState(): void {
|
||||
fs.writeFileSync(path.join(mockDir, "state.json"), stringify(defaultState));
|
||||
export function writeDefaultState(data: Record<string, unknown> = defaultState): void {
|
||||
fs.writeFileSync(stateFile, stringify(data));
|
||||
}
|
||||
|
||||
export function writeEmptyDatabase(): void {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type {AsyncZipOptions, AsyncZippable, FlateError} from "fflate";
|
||||
|
||||
/** `THISISBASE64` is valid base64, so it round-trips through `Buffer.from(...).toString("base64")` */
|
||||
export const mockFflateZipContent = Uint8Array.from(Buffer.from("THISISBASE64", "base64"));
|
||||
|
||||
export const mockFflateZip = vi.fn((_data: AsyncZippable, _opts: AsyncZipOptions, cb: (error: FlateError | null, data: Uint8Array) => void): void => {
|
||||
cb(null, mockFflateZipContent);
|
||||
});
|
||||
|
||||
/** Makes the next `zip` call report the given error through its callback */
|
||||
export const mockFflateZipFailOnce = (error: Error): void => {
|
||||
mockFflateZip.mockImplementationOnce((_data, _opts, cb) => {
|
||||
cb(error as FlateError, new Uint8Array());
|
||||
});
|
||||
};
|
||||
|
||||
vi.mock("fflate", () => ({
|
||||
zip: mockFflateZip,
|
||||
}));
|
||||
@@ -1,11 +0,0 @@
|
||||
export const mockJSZipFile = vi.fn();
|
||||
export const mockJSZipGenerateAsync = vi.fn().mockReturnValue("THISISBASE64");
|
||||
|
||||
vi.mock("jszip", () => ({
|
||||
default: vi.fn().mockImplementation(() => {
|
||||
return {
|
||||
file: mockJSZipFile,
|
||||
generateAsync: mockJSZipGenerateAsync,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
@@ -1,5 +1,5 @@
|
||||
import {vi} from "vitest";
|
||||
import type Transport from "winston-transport";
|
||||
|
||||
import type {LogLevel} from "../../lib/util/settings";
|
||||
|
||||
let level: LogLevel = "info";
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import type {IClientPublishOptions} from "mqtt";
|
||||
|
||||
import {vi} from "vitest";
|
||||
import type {EventHandler} from "./utils";
|
||||
|
||||
export const events: Record<string, EventHandler> = {};
|
||||
|
||||
Vendored
-4
@@ -1,7 +1,3 @@
|
||||
declare module "json-stable-stringify-without-jsonify" {
|
||||
export default function (obj: unknown): string;
|
||||
}
|
||||
|
||||
declare module "tmp" {
|
||||
export function dirSync(): {
|
||||
name: string;
|
||||
|
||||
@@ -3,6 +3,8 @@ import {type Mock, vi} from "vitest";
|
||||
import type {AdapterTypes} from "zigbee-herdsman";
|
||||
import {Zcl} from "zigbee-herdsman";
|
||||
import {InterviewState} from "zigbee-herdsman/dist/controller/model/device";
|
||||
import type {OtaDataSettings, OtaExtraMetas, OtaSource, OtaUpdateAvailableResult} from "zigbee-herdsman/dist/controller/tstype";
|
||||
import type {TClusterCommandPayload} from "zigbee-herdsman/dist/zspec/zcl/definition/clusters-types";
|
||||
import type {BindingTableEntry, LQITableEntry, RoutingTableEntry} from "zigbee-herdsman/dist/zspec/zdo/definition/tstypes";
|
||||
import {DEFAULT_BIND_GROUP_ID} from "../../lib/util/utils";
|
||||
import type {EventHandler} from "./utils";
|
||||
@@ -32,6 +34,7 @@ const CLUSTERS = {
|
||||
lightingColorCtrl: Zcl.Clusters.lightingColorCtrl.ID,
|
||||
closuresWindowCovering: Zcl.Clusters.closuresWindowCovering.ID,
|
||||
hvacThermostat: Zcl.Clusters.hvacThermostat.ID,
|
||||
hvacFanCtrl: Zcl.Clusters.hvacFanCtrl.ID,
|
||||
msIlluminanceMeasurement: Zcl.Clusters.msIlluminanceMeasurement.ID,
|
||||
msTemperatureMeasurement: Zcl.Clusters.msTemperatureMeasurement.ID,
|
||||
msRelativeHumidity: Zcl.Clusters.msRelativeHumidity.ID,
|
||||
@@ -254,6 +257,35 @@ export class Device {
|
||||
routingTable: Mock<() => Promise<RoutingTableEntry[]>>;
|
||||
bindingTable: Mock<() => Promise<BindingTableEntry[]>>;
|
||||
clearAllBindings: Mock<() => Promise<void>>;
|
||||
checkOta =
|
||||
vi.fn<
|
||||
(
|
||||
source: OtaSource,
|
||||
current: TClusterCommandPayload<"genOta", "queryNextImageRequest"> | undefined,
|
||||
extraMetas: OtaExtraMetas,
|
||||
endpoint: Endpoint,
|
||||
) => Promise<OtaUpdateAvailableResult>
|
||||
>();
|
||||
updateOta =
|
||||
vi.fn<
|
||||
(
|
||||
source: Readonly<OtaSource> | undefined,
|
||||
requestPayload: TClusterCommandPayload<"genOta", "queryNextImageRequest"> | undefined,
|
||||
requestTsn: number | undefined,
|
||||
extraMetas: Readonly<OtaExtraMetas>,
|
||||
onProgress: (progress: number, remaining: number) => void,
|
||||
dataSettings: OtaDataSettings,
|
||||
endpoint: Endpoint,
|
||||
) => Promise<[from: OtaUpdateAvailableResult["current"], to: OtaUpdateAvailableResult["current"] | undefined]>
|
||||
>();
|
||||
scheduleOta = vi.fn((source) => {
|
||||
this.scheduledOta = source;
|
||||
});
|
||||
unscheduleOta = vi.fn(() => {
|
||||
this.scheduledOta = undefined;
|
||||
});
|
||||
abortOta = vi.fn();
|
||||
scheduledOta: OtaSource | undefined = undefined;
|
||||
|
||||
constructor(
|
||||
type: string,
|
||||
@@ -311,7 +343,13 @@ export class Device {
|
||||
this.routingTable.mockClear();
|
||||
this.bindingTable.mockClear();
|
||||
this.clearAllBindings.mockClear();
|
||||
this.checkOta.mockClear();
|
||||
this.updateOta.mockClear();
|
||||
this.scheduleOta.mockClear();
|
||||
this.unscheduleOta.mockClear();
|
||||
this.abortOta.mockClear();
|
||||
this.meta = {};
|
||||
this.scheduledOta = undefined;
|
||||
|
||||
for (const ep of this.endpoints) {
|
||||
ep.mockClear();
|
||||
@@ -1157,7 +1195,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(),
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import {describe, expect, it} from "vitest";
|
||||
import {objectAssignDeep} from "../lib/util/objectAssignDeep";
|
||||
|
||||
/** Creates an object with a real own `__proto__`/`constructor`/`prototype` property, like a parsed YAML/JSON payload can. */
|
||||
const parse = (json: string): Record<string, unknown> => JSON.parse(json);
|
||||
|
||||
describe("objectAssignDeep", () => {
|
||||
it("Mutates and returns the target", () => {
|
||||
const target = {a: 1};
|
||||
const result = objectAssignDeep(target, {b: 2});
|
||||
|
||||
expect(result).toBe(target);
|
||||
expect(result).toStrictEqual({a: 1, b: 2});
|
||||
});
|
||||
|
||||
it("Applies sources in order, later ones win", () => {
|
||||
expect(objectAssignDeep({}, {a: 1, b: 1}, {b: 2, c: 2})).toStrictEqual({a: 1, b: 2, c: 2});
|
||||
});
|
||||
|
||||
it("Copies keys missing from the target", () => {
|
||||
expect(objectAssignDeep({}, {nested: {deep: {value: 1}}})).toStrictEqual({nested: {deep: {value: 1}}});
|
||||
});
|
||||
|
||||
it("Deep merges nested objects present in both", () => {
|
||||
const target = {mqtt: {base_topic: "zigbee2mqtt", server: "old"}, advanced: {channel: 11}};
|
||||
const result = objectAssignDeep(target, {mqtt: {server: "new"}});
|
||||
|
||||
expect(result).toStrictEqual({mqtt: {base_topic: "zigbee2mqtt", server: "new"}, advanced: {channel: 11}});
|
||||
});
|
||||
|
||||
it("Replaces nested objects of the target instead of mutating them", () => {
|
||||
const nested = {a: 1};
|
||||
const target = {nested};
|
||||
|
||||
objectAssignDeep(target, {nested: {b: 2}});
|
||||
|
||||
expect(nested).toStrictEqual({a: 1});
|
||||
expect(target.nested).not.toBe(nested);
|
||||
expect(target.nested).toStrictEqual({a: 1, b: 2});
|
||||
});
|
||||
|
||||
it("Replaces an existing non-object value with a clone of the source object", () => {
|
||||
expect(objectAssignDeep({a: 5}, {a: {b: 1}})).toStrictEqual({a: {b: 1}});
|
||||
expect(objectAssignDeep({a: "str"}, {a: {b: 1}})).toStrictEqual({a: {b: 1}});
|
||||
expect(objectAssignDeep({a: [1, 2]}, {a: {b: 1}})).toStrictEqual({a: {b: 1}});
|
||||
// `null` is not `undefined`, so it takes the "existing value" path but is not merged into
|
||||
expect(objectAssignDeep({a: null}, {a: {b: 1}})).toStrictEqual({a: {b: 1}});
|
||||
});
|
||||
|
||||
it("Overwrites with null and undefined", () => {
|
||||
expect(objectAssignDeep({a: {b: 1}, c: 1}, {a: null, c: null})).toStrictEqual({a: null, c: null});
|
||||
expect(objectAssignDeep({a: {b: 1}, c: 1}, {a: undefined, c: undefined})).toStrictEqual({a: undefined, c: undefined});
|
||||
});
|
||||
|
||||
it("Replaces arrays instead of concatenating them", () => {
|
||||
expect(objectAssignDeep({a: [1, 2, 3]}, {a: [4]})).toStrictEqual({a: [4]});
|
||||
expect(objectAssignDeep({a: [1, 2, 3]}, {a: []})).toStrictEqual({a: []});
|
||||
// no existing array either
|
||||
expect(objectAssignDeep({a: 1}, {a: [4]})).toStrictEqual({a: [4]});
|
||||
expect(objectAssignDeep({}, {a: [4]})).toStrictEqual({a: [4]});
|
||||
});
|
||||
|
||||
it("Clones arrays and the objects nested inside them", () => {
|
||||
const source = {a: [{b: 1}, [{c: 2}]]};
|
||||
const result = objectAssignDeep({}, source) as typeof source;
|
||||
|
||||
expect(result).toStrictEqual(source);
|
||||
expect(result.a).not.toBe(source.a);
|
||||
expect(result.a[0]).not.toBe(source.a[0]);
|
||||
expect((result.a[1] as {c: number}[])[0]).not.toBe((source.a[1] as {c: number}[])[0]);
|
||||
});
|
||||
|
||||
it("Breaks all references to the sources", () => {
|
||||
const source = {a: {b: {c: 1}}};
|
||||
const result = objectAssignDeep({}, source) as typeof source;
|
||||
|
||||
source.a.b.c = 99;
|
||||
|
||||
expect(result.a.b.c).toStrictEqual(1);
|
||||
});
|
||||
|
||||
it("Does not mutate the sources", () => {
|
||||
const source = {a: {b: 1}};
|
||||
|
||||
objectAssignDeep({a: {c: 2}}, source);
|
||||
|
||||
expect(source).toStrictEqual({a: {b: 1}});
|
||||
});
|
||||
|
||||
it("Copies functions and primitives by value/reference", () => {
|
||||
const fn = (): number => 1;
|
||||
const symbol = Symbol("s");
|
||||
const result = objectAssignDeep({}, {fn, symbol, big: 1n, nan: Number.NaN});
|
||||
|
||||
expect(result.fn).toBe(fn);
|
||||
expect(result.symbol).toBe(symbol);
|
||||
expect(result.big).toStrictEqual(1n);
|
||||
expect(result.nan).toBeNaN();
|
||||
});
|
||||
|
||||
it("Reduces non-plain objects to their own enumerable properties", () => {
|
||||
// documented (inherited) behaviour: only own enumerable properties survive, the prototype is lost
|
||||
expect(objectAssignDeep({}, {date: new Date(0)})).toStrictEqual({date: {}});
|
||||
expect(objectAssignDeep({}, {regexp: /abc/g})).toStrictEqual({regexp: {}});
|
||||
expect(objectAssignDeep({}, {map: new Map([["k", 1]])})).toStrictEqual({map: {}});
|
||||
expect(objectAssignDeep({}, {set: new Set([1])})).toStrictEqual({set: {}});
|
||||
|
||||
class Device {
|
||||
id = 1;
|
||||
get computed(): number {
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
const result = objectAssignDeep({}, {device: new Device()});
|
||||
|
||||
expect(result.device).toStrictEqual({id: 1});
|
||||
expect(result.device).not.toBeInstanceOf(Device);
|
||||
});
|
||||
|
||||
it("Merges deeply nested objects coming from multiple sources", () => {
|
||||
const result = objectAssignDeep({}, {a: {b: {c: 1}}}, {a: {b: {d: 2}, e: 3}});
|
||||
|
||||
expect(result).toStrictEqual({a: {b: {c: 1, d: 2}, e: 3}});
|
||||
});
|
||||
|
||||
it("Never copies keys that could tamper with the prototype chain", () => {
|
||||
const result = objectAssignDeep({}, parse('{"__proto__": {"polluted": "yes"}, "constructor": {"x": 1}, "prototype": {"y": 2}, "safe": 1}'));
|
||||
|
||||
expect(result).toStrictEqual({safe: 1});
|
||||
expect(Object.getPrototypeOf(result)).toBe(Object.prototype);
|
||||
expect(({} as {polluted?: string}).polluted).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Never copies unsafe keys nested inside cloned objects", () => {
|
||||
const result = objectAssignDeep({}, {nested: parse('{"__proto__": {"polluted": "yes"}, "constructor": 1, "prototype": 2, "safe": 1}')});
|
||||
|
||||
expect(result).toStrictEqual({nested: {safe: 1}});
|
||||
expect(Object.getPrototypeOf(result.nested)).toBe(Object.prototype);
|
||||
});
|
||||
|
||||
it("Never copies unsafe keys when merging into an existing object", () => {
|
||||
const result = objectAssignDeep({nested: {safe: 1}}, {nested: parse('{"__proto__": {"polluted": "yes"}, "other": 2}')});
|
||||
|
||||
expect(result).toStrictEqual({nested: {safe: 1, other: 2}});
|
||||
expect(Object.getPrototypeOf(result.nested)).toBe(Object.prototype);
|
||||
});
|
||||
|
||||
it("Leaves every source untouched when given an empty target", () => {
|
||||
const first = {a: {b: 1}};
|
||||
const second = {a: {c: 2}};
|
||||
const result = objectAssignDeep({}, first, second);
|
||||
|
||||
expect(result).toStrictEqual({a: {b: 1, c: 2}});
|
||||
expect(result).not.toBe(first);
|
||||
expect(result).not.toBe(second);
|
||||
expect(result.a).not.toBe(first.a);
|
||||
expect(first).toStrictEqual({a: {b: 1}});
|
||||
expect(second).toStrictEqual({a: {c: 2}});
|
||||
});
|
||||
});
|
||||
+621
-147
File diff suppressed because it is too large
Load Diff
+13
-14
@@ -3,12 +3,16 @@ import {beforeEach, describe, expect, it} from "vitest";
|
||||
import "./mocks/data";
|
||||
|
||||
import fs from "node:fs";
|
||||
import yaml from "js-yaml";
|
||||
import objectAssignDeep from "object-assign-deep";
|
||||
import {dump, load} from "js-yaml";
|
||||
|
||||
import mockedData from "../lib/util/data";
|
||||
import {objectAssignDeep} from "../lib/util/objectAssignDeep";
|
||||
import * as settings from "../lib/util/settings";
|
||||
|
||||
// mirrors the global `KeyValue`, which is not visible from the test project, previously implied by the untyped `object-assign-deep`
|
||||
// biome-ignore lint/suspicious/noExplicitAny: freely mutated to build the expected settings
|
||||
type ExpectedSettings = Record<string, any>;
|
||||
|
||||
const configurationFile = mockedData.joinPath("configuration.yaml");
|
||||
const devicesFile = mockedData.joinPath("devices.yaml");
|
||||
const devicesFile2 = mockedData.joinPath("devices2.yaml");
|
||||
@@ -21,14 +25,14 @@ const minimalConfig = {
|
||||
|
||||
describe("Settings", () => {
|
||||
const write = (file: string, json: Record<string, unknown>, reread = true): void => {
|
||||
fs.writeFileSync(file, yaml.dump(json));
|
||||
fs.writeFileSync(file, dump(json));
|
||||
|
||||
if (reread) {
|
||||
settings.reRead();
|
||||
}
|
||||
};
|
||||
|
||||
const read = (file: string): unknown => yaml.load(fs.readFileSync(file, "utf8"));
|
||||
const read = (file: string): unknown => load(fs.readFileSync(file, "utf8"));
|
||||
|
||||
const remove = (file: string): void => {
|
||||
if (fs.existsSync(file)) {
|
||||
@@ -86,8 +90,7 @@ describe("Settings", () => {
|
||||
it("Should return default settings", () => {
|
||||
write(configurationFile, {});
|
||||
const s = settings.get();
|
||||
// @ts-expect-error workaround
|
||||
const expected = objectAssignDeep.noMutate({}, settings.testing.defaults);
|
||||
const expected: ExpectedSettings = objectAssignDeep({}, settings.testing.defaults);
|
||||
expected.devices = {};
|
||||
expected.groups = {};
|
||||
expect(s).toStrictEqual(expected);
|
||||
@@ -96,8 +99,7 @@ describe("Settings", () => {
|
||||
it("Should return settings", () => {
|
||||
write(configurationFile, {serial: {disable_led: true}});
|
||||
const s = settings.get();
|
||||
// @ts-expect-error workaround
|
||||
const expected = objectAssignDeep.noMutate({}, settings.testing.defaults);
|
||||
const expected: ExpectedSettings = objectAssignDeep({}, settings.testing.defaults);
|
||||
expected.devices = {};
|
||||
expected.groups = {};
|
||||
expected.serial = {disable_led: true};
|
||||
@@ -124,8 +126,7 @@ describe("Settings", () => {
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-expect-error workaround
|
||||
const expected = objectAssignDeep.noMutate({}, settings.testing.defaults);
|
||||
const expected: ExpectedSettings = objectAssignDeep({}, settings.testing.defaults);
|
||||
expected.devices = {
|
||||
"0x00158d00018255df": {
|
||||
friendly_name: "0x00158d00018255df",
|
||||
@@ -178,8 +179,7 @@ describe("Settings", () => {
|
||||
|
||||
write(configurationFile, {});
|
||||
|
||||
// @ts-expect-error workaround
|
||||
const expected = objectAssignDeep.noMutate({}, settings.testing.defaults);
|
||||
const expected: ExpectedSettings = objectAssignDeep({}, settings.testing.defaults);
|
||||
expected.frontend.enabled = true;
|
||||
expected.frontend.port = 8099;
|
||||
expected.homeassistant.enabled = true;
|
||||
@@ -212,8 +212,7 @@ describe("Settings", () => {
|
||||
expect(settings.validate()).toStrictEqual([]);
|
||||
|
||||
const s = settings.get();
|
||||
// @ts-expect-error workaround
|
||||
const expected = objectAssignDeep.noMutate({groups: {}, devices: {}}, settings.testing.defaults);
|
||||
const expected: ExpectedSettings = objectAssignDeep({}, {groups: {}, devices: {}}, settings.testing.defaults);
|
||||
expected.mqtt.password = "password-in-env-var";
|
||||
expected.mqtt.server = "server";
|
||||
expect(s).toStrictEqual(expected);
|
||||
|
||||
+108
-71
@@ -1,12 +1,13 @@
|
||||
// biome-ignore assist/source/organizeImports: import mocks first
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from "vitest";
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
|
||||
import * as data from "./mocks/data";
|
||||
|
||||
import {existsSync, readFileSync, rmSync} from "node:fs";
|
||||
import objectAssignDeep from "object-assign-deep";
|
||||
import {existsSync, readFileSync, rmSync, writeFileSync} from "node:fs";
|
||||
import mockedData from "../lib/util/data";
|
||||
import {objectAssignDeep} from "../lib/util/objectAssignDeep";
|
||||
import * as settings from "../lib/util/settings";
|
||||
import * as settingsMigration from "../lib/util/settingsMigration";
|
||||
import path from "node:path";
|
||||
|
||||
describe("Settings Migration", () => {
|
||||
beforeAll(() => {});
|
||||
@@ -277,8 +278,7 @@ describe("Settings Migration", () => {
|
||||
});
|
||||
|
||||
it("no change needed - only add version", () => {
|
||||
// @ts-expect-error workaround
|
||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
afterSettings.version = 2;
|
||||
|
||||
settingsMigration.migrateIfNecessary();
|
||||
@@ -289,10 +289,8 @@ describe("Settings Migration", () => {
|
||||
});
|
||||
|
||||
it("remove all", () => {
|
||||
// @ts-expect-error workaround
|
||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
// @ts-expect-error workaround
|
||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
afterSettings.version = 2;
|
||||
|
||||
settings.set(["homeassistant", "legacy_triggers"], true);
|
||||
@@ -319,8 +317,7 @@ describe("Settings Migration", () => {
|
||||
settings.set(["external_converters"], ["zyx.js"]);
|
||||
|
||||
expect(settings.getPersistedSettings()).toStrictEqual(
|
||||
// @ts-expect-error workaround
|
||||
objectAssignDeep.noMutate(beforeSettings, {
|
||||
objectAssignDeep({}, beforeSettings, {
|
||||
permit_join: true,
|
||||
homeassistant: {
|
||||
legacy_triggers: true,
|
||||
@@ -394,10 +391,8 @@ describe("Settings Migration", () => {
|
||||
});
|
||||
|
||||
it("remove partial", () => {
|
||||
// @ts-expect-error workaround
|
||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
// @ts-expect-error workaround
|
||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
afterSettings.version = 2;
|
||||
|
||||
settings.set(["advanced", "homeassistant_legacy_triggers"], true);
|
||||
@@ -416,8 +411,7 @@ describe("Settings Migration", () => {
|
||||
// console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2));
|
||||
|
||||
expect(settings.getPersistedSettings()).toStrictEqual(
|
||||
// @ts-expect-error workaround
|
||||
objectAssignDeep.noMutate(beforeSettings, {
|
||||
objectAssignDeep({}, beforeSettings, {
|
||||
permit_join: true,
|
||||
advanced: {
|
||||
homeassistant_legacy_triggers: true,
|
||||
@@ -471,10 +465,8 @@ describe("Settings Migration", () => {
|
||||
});
|
||||
|
||||
it("changes log_level", () => {
|
||||
// @ts-expect-error workaround
|
||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
// @ts-expect-error workaround
|
||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
afterSettings.version = 2;
|
||||
afterSettings.advanced = {log_level: "warning"};
|
||||
|
||||
@@ -483,8 +475,7 @@ describe("Settings Migration", () => {
|
||||
// console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2));
|
||||
|
||||
expect(settings.getPersistedSettings()).toStrictEqual(
|
||||
// @ts-expect-error workaround
|
||||
objectAssignDeep.noMutate(beforeSettings, {
|
||||
objectAssignDeep({}, beforeSettings, {
|
||||
advanced: {
|
||||
log_level: "warn",
|
||||
},
|
||||
@@ -504,10 +495,8 @@ describe("Settings Migration", () => {
|
||||
});
|
||||
|
||||
it("does not changes already migrated log_level", () => {
|
||||
// @ts-expect-error workaround
|
||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
// @ts-expect-error workaround
|
||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
afterSettings.version = 2;
|
||||
afterSettings.advanced = {log_level: "warning"};
|
||||
|
||||
@@ -516,8 +505,7 @@ describe("Settings Migration", () => {
|
||||
// console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2));
|
||||
|
||||
expect(settings.getPersistedSettings()).toStrictEqual(
|
||||
// @ts-expect-error workaround
|
||||
objectAssignDeep.noMutate(beforeSettings, {
|
||||
objectAssignDeep({}, beforeSettings, {
|
||||
advanced: {
|
||||
log_level: "warning",
|
||||
},
|
||||
@@ -537,10 +525,8 @@ describe("Settings Migration", () => {
|
||||
});
|
||||
|
||||
it("does not changes other log_level", () => {
|
||||
// @ts-expect-error workaround
|
||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
// @ts-expect-error workaround
|
||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
afterSettings.version = 2;
|
||||
afterSettings.advanced = {log_level: "info"};
|
||||
|
||||
@@ -549,8 +535,7 @@ describe("Settings Migration", () => {
|
||||
// console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2));
|
||||
|
||||
expect(settings.getPersistedSettings()).toStrictEqual(
|
||||
// @ts-expect-error workaround
|
||||
objectAssignDeep.noMutate(beforeSettings, {
|
||||
objectAssignDeep({}, beforeSettings, {
|
||||
advanced: {
|
||||
log_level: "info",
|
||||
},
|
||||
@@ -570,10 +555,8 @@ describe("Settings Migration", () => {
|
||||
});
|
||||
|
||||
it("transfer all", () => {
|
||||
// @ts-expect-error workaround
|
||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
// @ts-expect-error workaround
|
||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
afterSettings.version = 2;
|
||||
afterSettings.advanced = {
|
||||
transmit_power: 12,
|
||||
@@ -603,8 +586,7 @@ describe("Settings Migration", () => {
|
||||
// console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2));
|
||||
|
||||
expect(settings.getPersistedSettings()).toStrictEqual(
|
||||
// @ts-expect-error workaround
|
||||
objectAssignDeep.noMutate(beforeSettings, {
|
||||
objectAssignDeep({}, beforeSettings, {
|
||||
advanced: {
|
||||
homeassistant_discovery_topic: "ha_disc",
|
||||
homeassistant_status_topic: "ha_stat",
|
||||
@@ -648,10 +630,8 @@ describe("Settings Migration", () => {
|
||||
});
|
||||
|
||||
it("transfer partial", () => {
|
||||
// @ts-expect-error workaround
|
||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
// @ts-expect-error workaround
|
||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
afterSettings.version = 2;
|
||||
afterSettings.advanced = {}; // caused by pushing to key and removing all
|
||||
afterSettings.serial.baudrate = 115200;
|
||||
@@ -672,8 +652,7 @@ describe("Settings Migration", () => {
|
||||
// console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2));
|
||||
|
||||
expect(settings.getPersistedSettings()).toStrictEqual(
|
||||
// @ts-expect-error workaround
|
||||
objectAssignDeep.noMutate(beforeSettings, {
|
||||
objectAssignDeep({}, beforeSettings, {
|
||||
homeassistant: {discovery_topic: "ha_disc_newer"},
|
||||
advanced: {
|
||||
homeassistant_discovery_topic: "ha_disc",
|
||||
@@ -717,10 +696,8 @@ describe("Settings Migration", () => {
|
||||
});
|
||||
|
||||
it("Update", () => {
|
||||
// @ts-expect-error workaround
|
||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
// @ts-expect-error workaround
|
||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
afterSettings.version = 3;
|
||||
afterSettings.homeassistant = {enabled: false};
|
||||
afterSettings.frontend = {enabled: true};
|
||||
@@ -738,8 +715,7 @@ describe("Settings Migration", () => {
|
||||
settings.set(["experimental", "transmit_power"], 12);
|
||||
|
||||
expect(settings.getPersistedSettings()).toStrictEqual(
|
||||
// @ts-expect-error workaround
|
||||
objectAssignDeep.noMutate(beforeSettings, {
|
||||
objectAssignDeep({}, beforeSettings, {
|
||||
homeassistant: false,
|
||||
frontend: true,
|
||||
availability: {active: {timeout: 15}},
|
||||
@@ -772,10 +748,8 @@ describe("Settings Migration", () => {
|
||||
});
|
||||
|
||||
it("Update", () => {
|
||||
// @ts-expect-error workaround
|
||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
// @ts-expect-error workaround
|
||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
afterSettings.version = 3;
|
||||
afterSettings.homeassistant = {enabled: false};
|
||||
afterSettings.frontend = {enabled: true};
|
||||
@@ -786,8 +760,7 @@ describe("Settings Migration", () => {
|
||||
settings.set(["availability"], {active: {timeout: 15}});
|
||||
|
||||
expect(settings.getPersistedSettings()).toStrictEqual(
|
||||
// @ts-expect-error workaround
|
||||
objectAssignDeep.noMutate(beforeSettings, {
|
||||
objectAssignDeep({}, beforeSettings, {
|
||||
homeassistant: false,
|
||||
frontend: true,
|
||||
availability: {active: {timeout: 15}},
|
||||
@@ -808,18 +781,15 @@ describe("Settings Migration", () => {
|
||||
});
|
||||
|
||||
it("Update when not set, tests that frontend/availability is not added when not set", () => {
|
||||
// @ts-expect-error workaround
|
||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
// @ts-expect-error workaround
|
||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
afterSettings.version = 3;
|
||||
afterSettings.homeassistant = {enabled: false};
|
||||
|
||||
settings.set(["homeassistant"], false);
|
||||
|
||||
expect(settings.getPersistedSettings()).toStrictEqual(
|
||||
// @ts-expect-error workaround
|
||||
objectAssignDeep.noMutate(beforeSettings, {
|
||||
objectAssignDeep({}, beforeSettings, {
|
||||
homeassistant: false,
|
||||
}),
|
||||
);
|
||||
@@ -853,10 +823,8 @@ describe("Settings Migration", () => {
|
||||
});
|
||||
|
||||
it("Update", () => {
|
||||
// @ts-expect-error workaround
|
||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
// @ts-expect-error workaround
|
||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
afterSettings.version = 4;
|
||||
afterSettings.devices = {
|
||||
"0x123127fffe8d96bc": {
|
||||
@@ -887,8 +855,7 @@ describe("Settings Migration", () => {
|
||||
});
|
||||
|
||||
expect(settings.getPersistedSettings()).toStrictEqual(
|
||||
// @ts-expect-error workaround
|
||||
objectAssignDeep.noMutate(beforeSettings, {
|
||||
objectAssignDeep({}, beforeSettings, {
|
||||
devices: {
|
||||
"0x123127fffe8d96bc": {
|
||||
friendly_name: "0x847127fffe8d96bc",
|
||||
@@ -916,4 +883,74 @@ describe("Settings Migration", () => {
|
||||
expect(migrationNotesContent).toContain("[SPECIAL] Device icons are now saved as images.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Migrates v4 to v5", () => {
|
||||
const BASE_CONFIG = {
|
||||
version: 4,
|
||||
mqtt: {
|
||||
server: "mqtt://localhost",
|
||||
},
|
||||
};
|
||||
const DEFAULT_STATE: Record<string, unknown> = {
|
||||
"0x000b57fffec6a5b2": {
|
||||
state: "ON",
|
||||
brightness: 50,
|
||||
color_temp: 370,
|
||||
linkquality: 99,
|
||||
},
|
||||
"0x0017880104e45517": {
|
||||
brightness: 255,
|
||||
update: {state: "idle"},
|
||||
},
|
||||
1: {
|
||||
state: "ON",
|
||||
update: {state: "available", installed_version: 1, latest_version: 2},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
settings.testing.CURRENT_VERSION = 5; // stop update after this version
|
||||
data.writeDefaultConfiguration(BASE_CONFIG);
|
||||
data.writeDefaultState(DEFAULT_STATE);
|
||||
settings.reRead();
|
||||
});
|
||||
|
||||
it("Update", () => {
|
||||
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
afterSettings.version = 5;
|
||||
|
||||
expect(settings.getPersistedSettings()).toStrictEqual(beforeSettings);
|
||||
expect(data.readState()).toStrictEqual(DEFAULT_STATE);
|
||||
|
||||
settingsMigration.migrateIfNecessary();
|
||||
|
||||
const migratedSettings = settings.getPersistedSettings();
|
||||
expect(migratedSettings).toStrictEqual(afterSettings);
|
||||
|
||||
const migratedState = objectAssignDeep({}, DEFAULT_STATE);
|
||||
delete (migratedState["0x0017880104e45517"] as Record<string, unknown>).update;
|
||||
delete (migratedState[1] as Record<string, unknown>).update;
|
||||
|
||||
expect(data.readState()).toStrictEqual(migratedState);
|
||||
});
|
||||
|
||||
it("handles errors gracefully", () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error");
|
||||
writeFileSync(path.join(data.mockDir, "state.json"), "notjson", "utf8");
|
||||
|
||||
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
afterSettings.version = 5;
|
||||
|
||||
expect(settings.getPersistedSettings()).toStrictEqual(beforeSettings);
|
||||
|
||||
settingsMigration.migrateIfNecessary();
|
||||
|
||||
const migratedSettings = settings.getPersistedSettings();
|
||||
expect(migratedSettings).toStrictEqual(afterSettings);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to write state"));
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("is not valid JSON"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import {mkdirSync, writeFileSync} from "node:fs";
|
||||
import {createServer, type Server} from "node:http";
|
||||
import {type AddressInfo, connect} from "node:net";
|
||||
import {join} from "node:path";
|
||||
import {brotliCompressSync, gzipSync} from "node:zlib";
|
||||
import tmp from "tmp";
|
||||
import {afterAll, beforeAll, describe, expect, it, vi} from "vitest";
|
||||
import {createStaticFileServer, type sendNotFound} from "../lib/util/staticFileServer";
|
||||
|
||||
const INDEX_HTML = "<!DOCTYPE html><html lang='en'><body>index</body></html>";
|
||||
const APP_JS = `console.log("${"x".repeat(2048)}");`;
|
||||
/** Written next to the served directory, never inside it, so a traversal that succeeds is actually observable. */
|
||||
const SECRET = "topsecret-must-never-be-served";
|
||||
|
||||
const mockLogError = vi.fn<(message: string) => void>();
|
||||
|
||||
let dir: string;
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
|
||||
/** Starts a `node:http` server serving `dir`, mirroring how the frontend/onboarding extensions wire it up. */
|
||||
function listen(handler: (request: Parameters<typeof sendNotFound>[0], response: Parameters<typeof sendNotFound>[1]) => void): Promise<void> {
|
||||
server = createServer(handler);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Writes a request line verbatim, bypassing the path normalization `fetch` applies before sending. */
|
||||
function rawRequest(target: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = connect((server.address() as AddressInfo).port, "127.0.0.1", () => {
|
||||
socket.write(`GET ${target} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n`);
|
||||
});
|
||||
let received = "";
|
||||
|
||||
socket.setEncoding("utf8");
|
||||
socket.on("data", (chunk) => {
|
||||
received += chunk;
|
||||
});
|
||||
socket.on("end", () => resolve(received));
|
||||
socket.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
describe("StaticFileServer", () => {
|
||||
beforeAll(async () => {
|
||||
const root = tmp.dirSync().name;
|
||||
|
||||
dir = join(root, "public");
|
||||
|
||||
// outside the served directory: only a working traversal could reach it
|
||||
writeFileSync(join(root, "secret.txt"), SECRET);
|
||||
mkdirSync(join(dir, "sub"), {recursive: true});
|
||||
writeFileSync(join(dir, "index.html"), INDEX_HTML);
|
||||
writeFileSync(join(dir, "app.js"), APP_JS);
|
||||
// precompressed variants, as shipped by the frontend packages
|
||||
writeFileSync(join(dir, "app.js.gz"), gzipSync(APP_JS));
|
||||
writeFileSync(join(dir, "app.js.br"), brotliCompressSync(APP_JS));
|
||||
writeFileSync(join(dir, "sub", "icon.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||||
|
||||
await listen(createStaticFileServer(dir, mockLogError));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
|
||||
it("serves a file with its content type", async () => {
|
||||
const response = await fetch(`${baseUrl}/sub/icon.png`);
|
||||
|
||||
expect(response.status).toStrictEqual(200);
|
||||
expect(response.headers.get("content-type")).toStrictEqual("image/png");
|
||||
expect(response.headers.get("content-encoding")).toBeNull();
|
||||
});
|
||||
|
||||
it("serves index.html for the root, never cached", async () => {
|
||||
const response = await fetch(`${baseUrl}/`);
|
||||
|
||||
expect(response.status).toStrictEqual(200);
|
||||
expect(response.headers.get("content-type")).toStrictEqual("text/html; charset=utf-8");
|
||||
expect(response.headers.get("cache-control")).toStrictEqual("no-store");
|
||||
await expect(response.text()).resolves.toStrictEqual(INDEX_HTML);
|
||||
});
|
||||
|
||||
it("serves the precompressed brotli variant", async () => {
|
||||
const response = await fetch(`${baseUrl}/app.js`, {headers: {"Accept-Encoding": "br"}});
|
||||
|
||||
expect(response.status).toStrictEqual(200);
|
||||
expect(response.headers.get("content-encoding")).toStrictEqual("br");
|
||||
expect(response.headers.get("content-type")).toStrictEqual("text/javascript; charset=utf-8");
|
||||
expect(response.headers.get("vary")).toStrictEqual("Accept-Encoding");
|
||||
// decoded by fetch, so the served bytes must be the brotli variant of the original file
|
||||
await expect(response.text()).resolves.toStrictEqual(APP_JS);
|
||||
});
|
||||
|
||||
it("serves the precompressed gzip variant", async () => {
|
||||
const response = await fetch(`${baseUrl}/app.js`, {headers: {"Accept-Encoding": "gzip"}});
|
||||
|
||||
expect(response.status).toStrictEqual(200);
|
||||
expect(response.headers.get("content-encoding")).toStrictEqual("gzip");
|
||||
await expect(response.text()).resolves.toStrictEqual(APP_JS);
|
||||
});
|
||||
|
||||
it("serves the identity file when no encoding is accepted", async () => {
|
||||
const response = await fetch(`${baseUrl}/app.js`, {headers: {"Accept-Encoding": "identity"}});
|
||||
|
||||
expect(response.status).toStrictEqual(200);
|
||||
expect(response.headers.get("content-encoding")).toBeNull();
|
||||
expect(response.headers.get("content-length")).toStrictEqual(String(Buffer.byteLength(APP_JS)));
|
||||
await expect(response.text()).resolves.toStrictEqual(APP_JS);
|
||||
});
|
||||
|
||||
it("revalidates with an etag", async () => {
|
||||
const response = await fetch(`${baseUrl}/app.js`, {headers: {"Accept-Encoding": "identity"}});
|
||||
const etag = response.headers.get("etag");
|
||||
|
||||
expect(etag).toBeTruthy();
|
||||
|
||||
const revalidated = await fetch(`${baseUrl}/app.js`, {headers: {"Accept-Encoding": "identity", "If-None-Match": etag as string}});
|
||||
|
||||
expect(revalidated.status).toStrictEqual(304);
|
||||
});
|
||||
|
||||
it("returns 404 for an unknown file", async () => {
|
||||
const response = await fetch(`${baseUrl}/nope.js`);
|
||||
|
||||
expect(response.status).toStrictEqual(404);
|
||||
expect(response.headers.get("content-type")).toStrictEqual("text/html; charset=utf-8");
|
||||
expect(response.headers.get("content-security-policy")).toStrictEqual("default-src 'none'");
|
||||
expect(response.headers.get("x-content-type-options")).toStrictEqual("nosniff");
|
||||
await expect(response.text()).resolves.toContain("Cannot GET /nope.js");
|
||||
});
|
||||
|
||||
it("escapes the url in the 404 body", async () => {
|
||||
const response = await fetch(`${baseUrl}/%3Cscript%3E`);
|
||||
|
||||
expect(response.status).toStrictEqual(404);
|
||||
await expect(response.text()).resolves.not.toContain("<script>");
|
||||
});
|
||||
|
||||
it("does not serve files outside of the served directory", async () => {
|
||||
// `fetch` resolves `..` and `%2e%2e` segments away before they ever reach the server, so these have to go out raw
|
||||
for (const target of ["/../secret.txt", "/sub/../../secret.txt", "/%2e%2e/secret.txt", "/..%2fsecret.txt"]) {
|
||||
const response = await rawRequest(target);
|
||||
|
||||
expect(response).toContain("404 Not Found");
|
||||
expect(response).not.toContain(SECRET);
|
||||
}
|
||||
});
|
||||
|
||||
it("reports a failure to serve with a 500", async () => {
|
||||
const failing = createStaticFileServer(dir, mockLogError);
|
||||
const failingServer = createServer((request, response) => {
|
||||
const setHeader = response.setHeader.bind(response);
|
||||
|
||||
response.setHeader = (name: string, value: number | string | readonly string[]): never => {
|
||||
if (name === "Content-Security-Policy") {
|
||||
throw new Error("socket gone");
|
||||
}
|
||||
|
||||
setHeader(name, value);
|
||||
|
||||
return undefined as never;
|
||||
};
|
||||
|
||||
failing(request, response);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => failingServer.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
const port = (failingServer.address() as AddressInfo).port;
|
||||
const response = await fetch(`http://127.0.0.1:${port}/nope.js`);
|
||||
|
||||
expect(response.status).toStrictEqual(500);
|
||||
expect(mockLogError).toHaveBeenCalledWith("Failed to serve '/nope.js': socket gone");
|
||||
|
||||
await new Promise((resolve) => failingServer.close(resolve));
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import {exec} from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {describe, expect, it, vi} from "vitest";
|
||||
import {stringify} from "../lib/util/stringify";
|
||||
import utils, {assertString} from "../lib/util/utils";
|
||||
|
||||
// keep the implementations, just spy
|
||||
@@ -155,4 +156,47 @@ describe("Utils", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("stable stringify", () => {
|
||||
expect(
|
||||
stringify({
|
||||
a: "a",
|
||||
b: 2,
|
||||
3: "c",
|
||||
d: Buffer.from([1, 2]),
|
||||
e: new Int16Array([0xfffd, 0xff11, 0x0001, 0x7fff]),
|
||||
beef: 0xfacen,
|
||||
zed: new BigUint64Array([1n, 0xffffffffn, 42n]),
|
||||
ris: [1, undefined, "b", 0xfeefn, Number.NaN, undefined],
|
||||
ls: undefined,
|
||||
// one and two elements, on both the number and the bigint branch
|
||||
one: new Uint8Array([7]),
|
||||
two: new Int16Array([0x0001, 0x7fff]),
|
||||
oneBig: new BigInt64Array([-9n]),
|
||||
twoBig: new BigUint64Array([1n, 42n]),
|
||||
}),
|
||||
).toStrictEqual(
|
||||
`{"3":"c","a":"a","b":2,"beef":"64206","d":{"data":[1,2],"type":"Buffer"},"e":{"0":-3,"1":-239,"2":1,"3":32767},"one":{"0":7},"oneBig":{"0":"-9"},"ris":[1,null,"b","65263",null,null],"two":{"0":1,"1":32767},"twoBig":{"0":"1","1":"42"},"zed":{"0":"1","1":"4294967295","2":"42"}}`,
|
||||
);
|
||||
// @ts-expect-error intentional to reach code for coverage
|
||||
expect(stringify(undefined)).toStrictEqual("null");
|
||||
|
||||
const circularObj: Record<string, unknown> = {a: 1, b: undefined};
|
||||
circularObj.b = circularObj;
|
||||
|
||||
expect(stringify(circularObj)).toStrictEqual(`{"a":1,"b":"[Circular]"}`);
|
||||
|
||||
const toJSONIsString = {a: 1, toJSON: () => `{"a":1}`};
|
||||
|
||||
expect(stringify(toJSONIsString)).toStrictEqual(`"{\\"a\\":1}"`);
|
||||
|
||||
const toJSONIsNull = {a: 1, toJSON: () => null};
|
||||
|
||||
expect(stringify(toJSONIsNull)).toStrictEqual("null");
|
||||
|
||||
const emptyTypedArrayWithProperty = Object.assign(new Uint8Array(0), {unit: "raw"});
|
||||
|
||||
expect(stringify({data: emptyTypedArrayWithProperty})).toStrictEqual(JSON.stringify({data: {unit: "raw"}}));
|
||||
expect(stringify({data: new Uint8Array(0)})).toStrictEqual(JSON.stringify({data: {}}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "dist",
|
||||
"baseUrl": ".",
|
||||
"rootDir": "lib",
|
||||
"inlineSourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
|
||||
Reference in New Issue
Block a user