mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-08-29 15:18:41 +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 | ||
|
|
6d30fa156c | ||
|
|
9b6035387b | ||
|
|
9ca85dfac8 | ||
|
|
334fa2a60d | ||
|
|
a7f1345a91 | ||
|
|
bae9bb8812 | ||
|
|
740edd8b58 | ||
|
|
267fee3414 | ||
|
|
fa99c6dcca | ||
|
|
91f4b76970 | ||
|
|
fd4282b98d | ||
|
|
403bd4a725 | ||
|
|
ce27ff3cbf | ||
|
|
5f474e03b8 | ||
|
|
110149fa20 | ||
|
|
445890a071 | ||
|
|
3dce8548ce | ||
|
|
4a020fd8de | ||
|
|
d411fc5c69 | ||
|
|
631c4f6a57 | ||
|
|
c02ed09211 | ||
|
|
49b4876768 | ||
|
|
802271ed37 | ||
|
|
42fc6e03a7 | ||
|
|
35d8a5da7f | ||
|
|
72d862f4a0 | ||
|
|
ad0696856a | ||
|
|
a61646d584 | ||
|
|
854df498bd | ||
|
|
e2232e9e91 | ||
|
|
6775c4f17b | ||
|
|
17520f7cfb | ||
|
|
1707bcab88 | ||
|
|
c577c8e5f0 | ||
|
|
7b8efbe2c4 | ||
|
|
459b695f60 | ||
|
|
f26ade4f93 | ||
|
|
3e2aefbcf8 | ||
|
|
ba77ce7c8d | ||
|
|
4c21b66079 | ||
|
|
be774a4d0d | ||
|
|
7b7b988960 | ||
|
|
d6bf5174be | ||
|
|
73225e2436 | ||
|
|
afedab9680 | ||
|
|
53b7eaabf0 | ||
|
|
16d0e56968 | ||
|
|
aea41b5c82 | ||
|
|
564e9ab9eb | ||
|
|
61c6ec6111 | ||
|
|
4794b3c81f | ||
|
|
517b286583 | ||
|
|
aee173d10a | ||
|
|
940fff244c | ||
|
|
8822aedb5e | ||
|
|
e788c520a9 | ||
|
|
39c5bc8c04 | ||
|
|
5c4e43fd10 | ||
|
|
5a6a9cf4d2 | ||
|
|
ad0e91e61e | ||
|
|
5797b56871 | ||
|
|
ea1c5a8f4e | ||
|
|
8099322cc2 | ||
|
|
619b939b14 | ||
|
|
eeed56ef2c | ||
|
|
e9b4a6221b |
@@ -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@v5
|
||||
- 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@v5
|
||||
- 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@v5
|
||||
- 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@v5
|
||||
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: |
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Issue Bot
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
contains(github.event.issue.labels.*.name, 'new device support') ||
|
||||
contains(github.event.issue.labels.*.name, 'external converter') ||
|
||||
startsWith(github.event.issue.title, '[New device support]') ||
|
||||
startsWith(github.event.issue.title, '[External Converter]')
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
sparse-checkout: |
|
||||
scripts
|
||||
path: z2m
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: Koenkk/zigbee-herdsman-converters
|
||||
ref: master
|
||||
fetch-depth: 1
|
||||
path: zhc
|
||||
|
||||
- name: Comment on new device support/external converter issue
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const {newDeviceSupport} = await import("${{ github.workspace }}/z2m/scripts/issueBot.mjs")
|
||||
await newDeviceSupport(github, core, context, "${{ github.workspace }}/zhc")
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
merge-master-to-dev:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
- uses: devmasx/merge-branch@master
|
||||
with:
|
||||
type: now
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
name: New Device Support/External Converter Issue Bot
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Comment on new device support/external converter issue
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
const newDeviceSupport = issue.title && issue.title.startsWith("[New device support]");
|
||||
const externalConverter = issue.title && issue.title.startsWith("[External Converter]");
|
||||
|
||||
// Only proceed for new device support or external converter issues
|
||||
if (!newDeviceSupport && !externalConverter) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide previous bot comments
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
});
|
||||
|
||||
for (const comment of comments.data) {
|
||||
if (comment.user.type === 'Bot' && comment.user.login === 'github-actions[bot]') {
|
||||
await github.graphql(`
|
||||
mutation {
|
||||
minimizeComment(input: {subjectId: "${comment.node_id}", classifier: OUTDATED}) {
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
// Checkout the repo and grep for the manufacturer name
|
||||
const execSync = require('child_process').execSync;
|
||||
execSync(`git clone https://github.com/Koenkk/zigbee-herdsman-converters.git zhc`, {stdio: 'ignore'});
|
||||
|
||||
// Check if Tuya manufacturer name is already supported.
|
||||
const tuyaManufacturerNameRe = /['"](_T\w+_(\w+))['"]/g;
|
||||
const tuyaManufacturerNames = Array.from(issue.body.matchAll(tuyaManufacturerNameRe), m => [m[1], m[2]]);
|
||||
if (tuyaManufacturerNames.length > 0) {
|
||||
for (const [fullName, partialName] of tuyaManufacturerNames) {
|
||||
const fullMatch = (() => { try { return execSync(`grep -r --include="*.ts" "${fullName}" zhc`, {encoding: 'utf8'}); } catch { return undefined; } })();
|
||||
if (fullMatch) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: [
|
||||
`👋 Hi there! The Tuya device with manufacturer name \`${fullName}\` is already supported in the latest dev branch.`,
|
||||
"See this [guide](https://www.zigbee2mqtt.io/advanced/more/switch-to-dev-branch.html) on how to update, after updating you can remove your external converter.",
|
||||
"",
|
||||
"In case you created the external converter with the goal to extend or fix an issue with the out-of-the-box support, please submit a pull request.",
|
||||
"For instructions on how to create a pull request see the [docs](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html#_4-create-a-pull-request).",
|
||||
"If you need help with the process, feel free to ask here and we'll be happy to assist."
|
||||
].join('\n')
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: "closed",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const partialMatch = (() => { try { return execSync(`grep -r --include="*.ts" "${partialName}" zhc`, {encoding: 'utf8'}); } catch { return undefined; } })();
|
||||
if (partialMatch) {
|
||||
const candidates = Array.from(partialMatch.matchAll(tuyaManufacturerNameRe), m => m[1]);
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: [
|
||||
`👋 Hi there! A similar Tuya device of which the manufacturer name also ends with \`_${partialName}\` is already supported.`,
|
||||
"This means the device can probably be easily be supported by re-using the existing converter.",
|
||||
"",
|
||||
"I found the following matches: " + candidates.map((c) => `\`${c}\``).join(', '),
|
||||
`Try to stop Z2M, change all occurrences of \`${fullName}\` in the \`data/database.db\` to one of the matches above and start Z2M.`,
|
||||
"Let us know if it works so we can support this device out-of-the-box!",
|
||||
].join('\n')
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Check if zigbee model is already supported.
|
||||
const zigbeeModelRe = /zigbeeModel: \[['"](.+)['"]\]/g;
|
||||
const zigbeeModels = Array.from(issue.body.matchAll(zigbeeModelRe), m => m[1]);
|
||||
if (zigbeeModels.length > 0) {
|
||||
for (const zigbeeModel of zigbeeModels) {
|
||||
const fullMatch = (() => { try { return execSync(`grep -r --include="*.ts" '"${zigbeeModel}"' zhc`, {encoding: 'utf8'}); } catch { return undefined; } })();
|
||||
if (fullMatch) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: [
|
||||
`👋 Hi there! The device with zigbee model \`${zigbeeModel}\` is already supported in the latest dev branch.`,
|
||||
"See this [guide](https://www.zigbee2mqtt.io/advanced/more/switch-to-dev-branch.html) on how to update, after updating you can remove your external converter.",
|
||||
"",
|
||||
"In case you created the external converter with the goal to extend or fix an issue with the out-of-the-box support, please submit a pull request.",
|
||||
"For instructions on how to create a pull request see the [docs](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html#_4-create-a-pull-request).",
|
||||
"If you need help with the process, feel free to ask here and we'll be happy to assist."
|
||||
].join('\n')
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: "closed",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a request to pull request comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: [
|
||||
"🙏 Thank you for creating this issue and sharing your external converter!",
|
||||
"",
|
||||
"In case all features work, please submit a pull request on this repository so the device can be supported out-of-the-box with the next release.",
|
||||
"For instructions on how to create a pull request see the [docs](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html#_4-create-a-pull-request).",
|
||||
"",
|
||||
"If **NOT** all features work, please follow the [How To Support new devices](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html)",
|
||||
tuyaManufacturerNames.length > 0 ? "Since this is a Tuya also consider [How To Support new Tuya devices](https://www.zigbee2mqtt.io/advanced/support-new-devices/02_support_new_tuya_devices.html)" : "",
|
||||
"",
|
||||
"If you need help with the process, feel free to ask here and we'll be happy to assist."
|
||||
].join('\n')
|
||||
});
|
||||
@@ -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@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: koenkk/zigbee2mqtt
|
||||
path: ./z2m
|
||||
- uses: actions/checkout@v5
|
||||
- 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@v5
|
||||
- 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.6.3"
|
||||
".": "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.
|
||||
|
||||
+457
@@ -1,5 +1,462 @@
|
||||
# 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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Home Assistant: Accept multiple access levels for `current_humidity` source property ([#29936](https://github.com/Koenkk/zigbee2mqtt/issues/29936)) ([fa99c6d](https://github.com/Koenkk/zigbee2mqtt/commit/fa99c6dcca97d2c8c90136249293dea3d42e0998))
|
||||
* Home Assistant: Translate boolean to state topic for `current_humidity_topic` ([#30014](https://github.com/Koenkk/zigbee2mqtt/issues/30014)) ([9ca85df](https://github.com/Koenkk/zigbee2mqtt/commit/9ca85dfac8b0f0421ad0622dfdbe36c8766d22b4))
|
||||
* **ignore:** bump finalhandler from 2.1.0 to 2.1.1 in the minor-patch group ([#29920](https://github.com/Koenkk/zigbee2mqtt/issues/29920)) ([ce27ff3](https://github.com/Koenkk/zigbee2mqtt/commit/ce27ff3cbfac204579b38d034e71733e4e4bb18f))
|
||||
* **ignore:** update zigbee-herdsman to 7.0.2 ([#29956](https://github.com/Koenkk/zigbee2mqtt/issues/29956)) ([403bd4a](https://github.com/Koenkk/zigbee2mqtt/commit/403bd4a72541bf7342de246b033c5090b186df32))
|
||||
* **ignore:** update zigbee-herdsman to 7.0.3 ([#30008](https://github.com/Koenkk/zigbee2mqtt/issues/30008)) ([bae9bb8](https://github.com/Koenkk/zigbee2mqtt/commit/bae9bb8812c81e46d4068b4284e0f9957821f88a))
|
||||
* **ignore:** update zigbee-herdsman to 7.0.4 ([#30041](https://github.com/Koenkk/zigbee2mqtt/issues/30041)) ([334fa2a](https://github.com/Koenkk/zigbee2mqtt/commit/334fa2a60d04c66749978b8b8f4b9b5af6c7c7eb))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.81.0 ([#29959](https://github.com/Koenkk/zigbee2mqtt/issues/29959)) ([fd4282b](https://github.com/Koenkk/zigbee2mqtt/commit/fd4282b98d9630f9a98e82f627c12c5920d810b4))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.82.0 ([#29990](https://github.com/Koenkk/zigbee2mqtt/issues/29990)) ([740edd8](https://github.com/Koenkk/zigbee2mqtt/commit/740edd8b58b7d72b7a2a5247f7f6ac8ee99ff0fa))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.83.0 ([#30011](https://github.com/Koenkk/zigbee2mqtt/issues/30011)) ([a7f1345](https://github.com/Koenkk/zigbee2mqtt/commit/a7f1345a9164f6c83cb81688c33511b14646fab8))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.83.1 ([#30050](https://github.com/Koenkk/zigbee2mqtt/issues/30050)) ([9b60353](https://github.com/Koenkk/zigbee2mqtt/commit/9b6035387be6f285fab455f7845baa75f3f722be))
|
||||
|
||||
## [2.7.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.6.3...2.7.0) (2025-12-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add `action` bridge/request API ([#29730](https://github.com/Koenkk/zigbee2mqtt/issues/29730)) ([a61646d](https://github.com/Koenkk/zigbee2mqtt/commit/a61646d584c0a0de46abb3545cd964f65d84312d))
|
||||
* Add new bind/reporting/map features ([#29750](https://github.com/Koenkk/zigbee2mqtt/issues/29750)) ([f26ade4](https://github.com/Koenkk/zigbee2mqtt/commit/f26ade4f938350d10ed22a32a2e30365f7514a72))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add port 8080 `EXPOSE` to container ([#29754](https://github.com/Koenkk/zigbee2mqtt/issues/29754)) ([4c21b66](https://github.com/Koenkk/zigbee2mqtt/commit/4c21b660794ee3b85c9a9a6d01b0df5ec8bce639))
|
||||
* Home Assistant: expose `current_humidity` for `climate` devices exposing `humidity` ([#29842](https://github.com/Koenkk/zigbee2mqtt/issues/29842)) ([631c4f6](https://github.com/Koenkk/zigbee2mqtt/commit/631c4f6a5729888d65a1125e0cca8f9ba2ff0e59))
|
||||
* **ignore:** bump @biomejs/biome from 2.3.3 to 2.3.4 in the minor-patch group ([#29571](https://github.com/Koenkk/zigbee2mqtt/issues/29571)) ([517b286](https://github.com/Koenkk/zigbee2mqtt/commit/517b2865835a7c21b4c5ce68018fd30ae45d1a3f))
|
||||
* **ignore:** bump debounce from 2.2.0 to 3.0.0 ([#29444](https://github.com/Koenkk/zigbee2mqtt/issues/29444)) ([8099322](https://github.com/Koenkk/zigbee2mqtt/commit/8099322cc2e93e5c1faf355d41485f2c673e0cea))
|
||||
* **ignore:** bump the minor-patch group with 2 updates ([#29443](https://github.com/Koenkk/zigbee2mqtt/issues/29443)) ([619b939](https://github.com/Koenkk/zigbee2mqtt/commit/619b939b148394bd6a083c17cc5cf254c8eb3aea))
|
||||
* **ignore:** bump the minor-patch group with 2 updates ([#29859](https://github.com/Koenkk/zigbee2mqtt/issues/29859)) ([c02ed09](https://github.com/Koenkk/zigbee2mqtt/commit/c02ed092116c7454220c0611bff5d078e12a8ecc))
|
||||
* **ignore:** bump the minor-patch group with 3 updates ([#29804](https://github.com/Koenkk/zigbee2mqtt/issues/29804)) ([e2232e9](https://github.com/Koenkk/zigbee2mqtt/commit/e2232e9e9173ea9ec55152ac33bc0ee0141471bb))
|
||||
* **ignore:** bump zigbee2mqtt-windfront from 2.4.0 to 2.4.1 in the minor-patch group ([#29905](https://github.com/Koenkk/zigbee2mqtt/issues/29905)) ([4a020fd](https://github.com/Koenkk/zigbee2mqtt/commit/4a020fd8de2dab2973b859c34b934416a15e82a5))
|
||||
* **ignore:** bump zigbee2mqtt-windfront to 2.4.2 ([#29919](https://github.com/Koenkk/zigbee2mqtt/issues/29919)) ([3dce854](https://github.com/Koenkk/zigbee2mqtt/commit/3dce8548ceee111f49a5b3460fdb2cbb4f47da62))
|
||||
* **ignore:** prop name convention on new API endpoints ([#29820](https://github.com/Koenkk/zigbee2mqtt/issues/29820)) ([ad06968](https://github.com/Koenkk/zigbee2mqtt/commit/ad0696856a9a63fc74b9c2115f08d7fd97850545))
|
||||
* **ignore:** update zigbee-herdsman to 6.3.3 ([#29500](https://github.com/Koenkk/zigbee2mqtt/issues/29500)) ([39c5bc8](https://github.com/Koenkk/zigbee2mqtt/commit/39c5bc8c0446a7ccdffe6f82ae9e7f31f60f20ba))
|
||||
* **ignore:** update zigbee-herdsman to 6.4.0 ([#29522](https://github.com/Koenkk/zigbee2mqtt/issues/29522)) ([e788c52](https://github.com/Koenkk/zigbee2mqtt/commit/e788c520a9268498daf0f88d7bedb289546d226e))
|
||||
* **ignore:** update zigbee-herdsman to 6.4.1 ([#29595](https://github.com/Koenkk/zigbee2mqtt/issues/29595)) ([61c6ec6](https://github.com/Koenkk/zigbee2mqtt/commit/61c6ec611174529e4c041a56146ba8405cfe9ed8))
|
||||
* **ignore:** update zigbee-herdsman to 6.4.2 ([#29683](https://github.com/Koenkk/zigbee2mqtt/issues/29683)) ([53b7eaa](https://github.com/Koenkk/zigbee2mqtt/commit/53b7eaabf0647c8d1edb0de24b89776991af576c))
|
||||
* **ignore:** update zigbee-herdsman to 7.0.0 ([3e2aefb](https://github.com/Koenkk/zigbee2mqtt/commit/3e2aefbcf84f43d45c1d6ca33397c8bb76648822))
|
||||
* **ignore:** update zigbee-herdsman to 7.0.1 ([#29855](https://github.com/Koenkk/zigbee2mqtt/issues/29855)) ([802271e](https://github.com/Koenkk/zigbee2mqtt/commit/802271ed37d404944d315f83f3b623ba9e424bb9))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.60.0 ([#29426](https://github.com/Koenkk/zigbee2mqtt/issues/29426)) ([eeed56e](https://github.com/Koenkk/zigbee2mqtt/commit/eeed56ef2c01dfe67e7a0cd7cafd35ef1876f5b4))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.62.0 ([#29449](https://github.com/Koenkk/zigbee2mqtt/issues/29449)) ([ea1c5a8](https://github.com/Koenkk/zigbee2mqtt/commit/ea1c5a8f4e8537a2f8c59988bb712f62a0adffd7))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.63.0 ([#29482](https://github.com/Koenkk/zigbee2mqtt/issues/29482)) ([5a6a9cf](https://github.com/Koenkk/zigbee2mqtt/commit/5a6a9cf4d2de803a10cd38bc19f3ec256eff5929))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.64.0 ([#29499](https://github.com/Koenkk/zigbee2mqtt/issues/29499)) ([5c4e43f](https://github.com/Koenkk/zigbee2mqtt/commit/5c4e43fd109321ddcfad3a67b60423f0197c51a6))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.65.0 ([#29523](https://github.com/Koenkk/zigbee2mqtt/issues/29523)) ([8822aed](https://github.com/Koenkk/zigbee2mqtt/commit/8822aedb5e254f5be5f6cda0cfe18fc6f69c84d6))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.66.0 ([#29558](https://github.com/Koenkk/zigbee2mqtt/issues/29558)) ([940fff2](https://github.com/Koenkk/zigbee2mqtt/commit/940fff244c39da04d0de5d7e4076359dc6245aed))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.67.0 ([#29572](https://github.com/Koenkk/zigbee2mqtt/issues/29572)) ([4794b3c](https://github.com/Koenkk/zigbee2mqtt/commit/4794b3c81f9e38660f8549995b7f3ef8b4cd4318))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.68.0 ([#29606](https://github.com/Koenkk/zigbee2mqtt/issues/29606)) ([564e9ab](https://github.com/Koenkk/zigbee2mqtt/commit/564e9ab9eb49c3517913917af8b23d4758c6b6c7))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.69.0 ([#29659](https://github.com/Koenkk/zigbee2mqtt/issues/29659)) ([16d0e56](https://github.com/Koenkk/zigbee2mqtt/commit/16d0e5696859e79be4701154dd35f7780e47acae))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.70.0 ([#29684](https://github.com/Koenkk/zigbee2mqtt/issues/29684)) ([73225e2](https://github.com/Koenkk/zigbee2mqtt/commit/73225e2436bb56ff4ed47d787e8a1793fd10998d))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.71.0 ([#29716](https://github.com/Koenkk/zigbee2mqtt/issues/29716)) ([d6bf517](https://github.com/Koenkk/zigbee2mqtt/commit/d6bf5174be905128432e90d54b3c43c2a68e75ac))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.72.0 ([#29735](https://github.com/Koenkk/zigbee2mqtt/issues/29735)) ([7b7b988](https://github.com/Koenkk/zigbee2mqtt/commit/7b7b9889607a6e48001ae3cda5950386c24f5fd6))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.73.0 ([#29752](https://github.com/Koenkk/zigbee2mqtt/issues/29752)) ([be774a4](https://github.com/Koenkk/zigbee2mqtt/commit/be774a4d0d70122ced0f6ca79f49c060a7d2d2cb))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.74.0 ([#29770](https://github.com/Koenkk/zigbee2mqtt/issues/29770)) ([ba77ce7](https://github.com/Koenkk/zigbee2mqtt/commit/ba77ce7c8df0b29845a090c9defb4ce8aaf0f924))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.75.0 ([#29811](https://github.com/Koenkk/zigbee2mqtt/issues/29811)) ([854df49](https://github.com/Koenkk/zigbee2mqtt/commit/854df498bd3c8999393b683052dfba6b906ba669))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.76.0 ([#29823](https://github.com/Koenkk/zigbee2mqtt/issues/29823)) ([72d862f](https://github.com/Koenkk/zigbee2mqtt/commit/72d862f4a080bfcd91bf99d4c13387e719dfdb8e))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.77.0 ([#29838](https://github.com/Koenkk/zigbee2mqtt/issues/29838)) ([35d8a5d](https://github.com/Koenkk/zigbee2mqtt/commit/35d8a5da7f1d817727f8b029da1992901f5b4115))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.78.0 ([#29856](https://github.com/Koenkk/zigbee2mqtt/issues/29856)) ([49b4876](https://github.com/Koenkk/zigbee2mqtt/commit/49b4876768c5d15fcff1ad0c9c2d02dda96543d0))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.79.0 ([#29903](https://github.com/Koenkk/zigbee2mqtt/issues/29903)) ([d411fc5](https://github.com/Koenkk/zigbee2mqtt/commit/d411fc5c693300863a19c2798f0a7a05cfc7e55f))
|
||||
* **ignore:** update zigbee-herdsman-converters to 25.80.0 ([#29924](https://github.com/Koenkk/zigbee2mqtt/issues/29924)) ([445890a](https://github.com/Koenkk/zigbee2mqtt/commit/445890a0710ba7f6b101b651bf379f10390f9e7e))
|
||||
* **ignore:** update zigbee2mqtt-windfront to 2.3.1 ([#29788](https://github.com/Koenkk/zigbee2mqtt/issues/29788)) ([17520f7](https://github.com/Koenkk/zigbee2mqtt/commit/17520f7cfb69b577ebd986b86da80931a15a39db))
|
||||
* Reporting payload detection fixes ([#29854](https://github.com/Koenkk/zigbee2mqtt/issues/29854)) ([42fc6e0](https://github.com/Koenkk/zigbee2mqtt/commit/42fc6e03a7a3092502e3c3d1defbcb3ed6438230))
|
||||
|
||||
## [2.6.3](https://github.com/Koenkk/zigbee2mqtt/compare/2.6.2...2.6.3) (2025-11-01)
|
||||
|
||||
|
||||
|
||||
+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">
|
||||
|
||||
@@ -106,7 +106,7 @@ Zigbee2MQTT is made up of three modules, each developed in its own Github projec
|
||||
|
||||
### Developing
|
||||
|
||||
Zigbee2MQTT uses TypeScript. Therefore after making changes to files in the `lib/` directory you need to recompile Zigbee2MQTT. This can be done by executing `pnpm run build`. For faster development instead of running `pnpm run build` you can run `pnpm run build-watch` in another terminal session, this will recompile as you change files.
|
||||
Zigbee2MQTT uses TypeScript. Therefore after making changes to files in the `lib/` directory you need to recompile Zigbee2MQTT. This can be done by executing `pnpm run build`. For faster development instead of running `pnpm run build` you can run `pnpm run build:watch` in another terminal session, this will recompile as you change files.
|
||||
|
||||
Before running any of the commands, you'll first need to run `pnpm install --include=dev`.
|
||||
Before submitting changes run `pnpm run check:w` then `pnpm run test:coverage`.
|
||||
|
||||
+7
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.5/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
|
||||
|
||||
+4
-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
|
||||
@@ -52,5 +52,8 @@ RUN mkdir /app/data
|
||||
ARG COMMIT
|
||||
RUN echo "$COMMIT" > dist/.hash
|
||||
|
||||
# Expose the default frontend port (see lib/util/settings.ts defaults.frontend.port)
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD [ "/sbin/tini", "--", "node", "index.js"]
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
+34
-6
@@ -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";
|
||||
|
||||
@@ -184,7 +184,7 @@ interface ParsedMQTTMessage {
|
||||
}
|
||||
|
||||
export default class Bind extends Extension {
|
||||
#topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/(bind|unbind)`);
|
||||
#topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/(bind|unbind|binds/clear)`);
|
||||
private pollDebouncers: {[s: string]: () => void} = {};
|
||||
|
||||
// biome-ignore lint/suspicious/useAwait: API
|
||||
@@ -276,6 +276,36 @@ export default class Bind extends Extension {
|
||||
}
|
||||
|
||||
@bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
if (data.topic.endsWith("binds/clear")) {
|
||||
const message = JSON.parse(data.message) as Zigbee2MQTTAPI["bridge/request/device/binds/clear"];
|
||||
|
||||
if (typeof message !== "object" || typeof message.target !== "string") {
|
||||
await this.publishResponse("binds/clear", message, {}, "Invalid payload");
|
||||
return;
|
||||
}
|
||||
|
||||
const target = this.zigbee.resolveEntity(message.target);
|
||||
|
||||
if (!(target instanceof Device)) {
|
||||
await this.publishResponse("binds/clear", message, {}, "Invalid target");
|
||||
return;
|
||||
}
|
||||
|
||||
// this list is raw (not resolved) to allow clearing any specific target (not only currently known)
|
||||
const eui64List = message.ieee_list ?? ["0xffffffffffffffff"];
|
||||
|
||||
await target.zh.clearAllBindings(eui64List);
|
||||
|
||||
const responseData: Zigbee2MQTTAPI["bridge/response/device/binds/clear"] = {
|
||||
target: message.target,
|
||||
ieee_list: eui64List,
|
||||
};
|
||||
|
||||
await this.publishResponse("binds/clear", message, responseData);
|
||||
this.eventBus.emitDevicesChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
const [raw, parsed, error] = this.parseMQTTMessage(data);
|
||||
|
||||
if (!raw || !parsed) {
|
||||
@@ -389,7 +419,7 @@ export default class Bind extends Extension {
|
||||
}
|
||||
|
||||
private async publishResponse<T extends Zigbee2MQTTResponseEndpoints>(
|
||||
type: ParsedMQTTMessage["type"],
|
||||
type: ParsedMQTTMessage["type"] | "binds/clear",
|
||||
request: KeyValue,
|
||||
data: Zigbee2MQTTAPI[T],
|
||||
error?: string,
|
||||
@@ -488,9 +518,7 @@ export default class Bind extends Extension {
|
||||
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
for (const endpoint of device.zh.endpoints) {
|
||||
for (const bind of endpoint.binds) {
|
||||
allBinds.push(bind);
|
||||
}
|
||||
allBinds.push(...endpoint.binds);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+153
-48
@@ -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";
|
||||
|
||||
@@ -31,7 +32,10 @@ export default class Bridge extends Extension {
|
||||
private logTransport!: winston.transport;
|
||||
private requestLookup: {[key: string]: (message: KeyValue | string) => Promise<Zigbee2MQTTResponse<Zigbee2MQTTResponseEndpoints>>} = {
|
||||
"device/options": this.deviceOptions,
|
||||
"device/configure_reporting": this.deviceConfigureReporting,
|
||||
/** @deprecated 3.0 */
|
||||
"device/configure_reporting": this.deviceReportingConfigure,
|
||||
"device/reporting/configure": this.deviceReportingConfigure,
|
||||
"device/reporting/read": this.deviceReportingRead,
|
||||
"device/remove": this.deviceRemove,
|
||||
"device/interview": this.deviceInterview,
|
||||
"device/generate_external_definition": this.deviceGenerateExternalDefinition,
|
||||
@@ -50,6 +54,7 @@ export default class Bridge extends Extension {
|
||||
health_check: this.healthCheck,
|
||||
coordinator_check: this.coordinatorCheck,
|
||||
options: this.bridgeOptions,
|
||||
action: this.action,
|
||||
};
|
||||
|
||||
override async start(): Promise<void> {
|
||||
@@ -136,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();
|
||||
|
||||
@@ -238,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) {
|
||||
@@ -257,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});
|
||||
}
|
||||
@@ -315,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">> {
|
||||
@@ -444,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) {
|
||||
@@ -455,27 +491,31 @@ 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});
|
||||
}
|
||||
|
||||
@bind async deviceConfigureReporting(message: string | KeyValue): Promise<Zigbee2MQTTResponse<"bridge/response/device/configure_reporting">> {
|
||||
@bind async deviceReportingConfigure(message: string | KeyValue): Promise<Zigbee2MQTTResponse<"bridge/response/device/reporting/configure">> {
|
||||
if (
|
||||
typeof message !== "object" ||
|
||||
message.id === undefined ||
|
||||
message.endpoint === undefined ||
|
||||
message.cluster === undefined ||
|
||||
message.maximum_report_interval === undefined ||
|
||||
message.minimum_report_interval === undefined ||
|
||||
message.reportable_change === undefined ||
|
||||
message.attribute === undefined
|
||||
message.attribute === undefined ||
|
||||
typeof message.maximum_report_interval !== "number" ||
|
||||
typeof message.minimum_report_interval !== "number" ||
|
||||
(message.reportable_change !== undefined && typeof message.reportable_change !== "number")
|
||||
) {
|
||||
throw new Error("Invalid payload");
|
||||
}
|
||||
@@ -518,25 +558,54 @@ export default class Bridge extends Extension {
|
||||
});
|
||||
}
|
||||
|
||||
@bind async deviceReportingRead(message: string | KeyValue): Promise<Zigbee2MQTTResponse<"bridge/response/device/reporting/read">> {
|
||||
if (
|
||||
typeof message !== "object" ||
|
||||
message.id === undefined ||
|
||||
message.endpoint === undefined ||
|
||||
message.cluster === undefined ||
|
||||
message.configs === undefined
|
||||
) {
|
||||
throw new Error("Invalid payload");
|
||||
}
|
||||
|
||||
const device = this.getEntity("device", message.id);
|
||||
const endpoint = device.endpoint(message.endpoint);
|
||||
|
||||
if (!endpoint) {
|
||||
throw new Error(`Device '${device.ID}' does not have endpoint '${message.endpoint}'`);
|
||||
}
|
||||
|
||||
const response = await endpoint.readReportingConfig(
|
||||
message.cluster,
|
||||
message.configs,
|
||||
message.manufacturer_code ? {manufacturerCode: message.manufacturer_code} : {},
|
||||
);
|
||||
|
||||
await this.publishDevices();
|
||||
|
||||
const responseData: Zigbee2MQTTAPI["bridge/response/device/reporting/read"] = {
|
||||
id: message.id,
|
||||
endpoint: message.endpoint,
|
||||
cluster: message.cluster,
|
||||
configs: response,
|
||||
};
|
||||
|
||||
if (message.manufacturer_code) {
|
||||
responseData.manufacturer_code = message.manufacturer_code;
|
||||
}
|
||||
|
||||
return utils.getResponse(message, responseData);
|
||||
}
|
||||
|
||||
@bind async deviceInterview(message: string | KeyValue): Promise<Zigbee2MQTTResponse<"bridge/response/device/interview">> {
|
||||
if (typeof message !== "object" || message.id === undefined) {
|
||||
throw new Error("Invalid payload");
|
||||
}
|
||||
|
||||
const device = this.getEntity("device", message.id);
|
||||
logger.info(`Interviewing '${device.name}'`);
|
||||
|
||||
try {
|
||||
await device.zh.interview(true);
|
||||
logger.info(`Successfully interviewed '${device.name}'`);
|
||||
} catch (error) {
|
||||
throw new Error(`interview of '${device.name}' (${device.ieeeAddr}) failed: ${error}`, {cause: error});
|
||||
}
|
||||
|
||||
// A re-interview can for example result in a different modelId, therefore reconsider the definition.
|
||||
await device.resolveDefinition(true);
|
||||
this.eventBus.emitDevicesChanged();
|
||||
this.eventBus.emitExposesChanged({device});
|
||||
await device.reInterview(this.eventBus);
|
||||
|
||||
return utils.getResponse(message, {id: message.id});
|
||||
}
|
||||
@@ -554,6 +623,22 @@ export default class Bridge extends Extension {
|
||||
return utils.getResponse(message, {id: message.id, source});
|
||||
}
|
||||
|
||||
@bind async action(message: string | KeyValue): Promise<Zigbee2MQTTResponse<"bridge/response/action">> {
|
||||
if (typeof message !== "object" || !message.action) {
|
||||
throw new Error("Invalid payload");
|
||||
}
|
||||
|
||||
const action = zhc.ACTIONS[message.action];
|
||||
|
||||
if (action === undefined) {
|
||||
throw new Error("Invalid action");
|
||||
}
|
||||
|
||||
const response = await action(this.zigbee.zhController, message.params ?? {});
|
||||
|
||||
return utils.getResponse(message, response);
|
||||
}
|
||||
|
||||
async renameEntity<T extends "device" | "group">(
|
||||
entityType: T,
|
||||
message: string | KeyValue,
|
||||
@@ -599,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})`;
|
||||
}
|
||||
|
||||
@@ -625,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();
|
||||
@@ -652,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(
|
||||
@@ -812,6 +915,7 @@ export default class Bridge extends Extension {
|
||||
const data: Zigbee2MQTTAPI["bridge/definitions"] = {
|
||||
clusters: Zcl.Clusters,
|
||||
custom_clusters: {},
|
||||
actions: Object.keys(zhc.ACTIONS),
|
||||
};
|
||||
|
||||
for (const device of this.zigbee.devicesIterator((d) => !utils.objectIsEmpty(d.customClusters))) {
|
||||
@@ -845,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";
|
||||
|
||||
|
||||
+426
-141
File diff suppressed because it is too large
Load Diff
+31
-23
@@ -1,9 +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";
|
||||
|
||||
@@ -91,6 +92,8 @@ export default class NetworkMap extends Extension {
|
||||
}
|
||||
|
||||
labels.push(lastSeen);
|
||||
// Escape backslashes and double-quotes to avoid breaking Graphviz .dot files.
|
||||
const escapedLabels = labels.map((label) => label.replace(/\\/g, "\\\\").replace(/"/g, '\\"'));
|
||||
|
||||
// Shape the record according to device type
|
||||
if (node.type === "Coordinator") {
|
||||
@@ -102,7 +105,7 @@ export default class NetworkMap extends Extension {
|
||||
}
|
||||
|
||||
// Add the device with its labels to the graph as a node.
|
||||
text += ` "${node.ieeeAddr}" [${style}, label="{${labels.join("|")}}"];\n`;
|
||||
text += ` "${node.ieeeAddr}" [${style}, label="{${escapedLabels.join("|")}}"];\n`;
|
||||
|
||||
/**
|
||||
* Add an edge between the device and its child to the graph
|
||||
@@ -185,8 +188,8 @@ export default class NetworkMap extends Extension {
|
||||
|
||||
async networkScan(includeRoutes: boolean): Promise<Zigbee2MQTTNetworkMap> {
|
||||
logger.info(`Starting network scan (includeRoutes '${includeRoutes}')`);
|
||||
const lqis = new Map<Device, zh.LQI>();
|
||||
const routingTables = new Map<Device, zh.RoutingTable>();
|
||||
const lqis = new Map<Device, LQITableEntry[]>();
|
||||
const routingTables = new Map<Device, RoutingTableEntry[]>();
|
||||
const failed = new Map<Device, string[]>();
|
||||
const requestWithRetry = async <T>(request: () => Promise<T>): Promise<T> => {
|
||||
try {
|
||||
@@ -210,7 +213,7 @@ export default class NetworkMap extends Extension {
|
||||
await utils.sleep(1); // sleep 1 second between each scan to reduce stress on network.
|
||||
|
||||
try {
|
||||
const result = await requestWithRetry<zh.LQI>(async () => await device.zh.lqi());
|
||||
const result = await requestWithRetry<LQITableEntry[]>(async () => await device.zh.lqi());
|
||||
lqis.set(device, result);
|
||||
logger.debug(`LQI succeeded for '${device.name}'`);
|
||||
} catch (error) {
|
||||
@@ -222,7 +225,7 @@ export default class NetworkMap extends Extension {
|
||||
|
||||
if (includeRoutes) {
|
||||
try {
|
||||
const result = await requestWithRetry<zh.RoutingTable>(async () => await device.zh.routingTable());
|
||||
const result = await requestWithRetry<RoutingTableEntry[]>(async () => await device.zh.routingTable());
|
||||
routingTables.set(device, result);
|
||||
logger.debug(`Routing table succeeded for '${device.name}'`);
|
||||
} catch (error) {
|
||||
@@ -274,42 +277,47 @@ export default class NetworkMap extends Extension {
|
||||
}
|
||||
|
||||
// Add links
|
||||
for (const [device, lqi] of lqis) {
|
||||
for (const neighbor of lqi.neighbors) {
|
||||
for (const [device, table] of lqis) {
|
||||
for (const neighbor of table) {
|
||||
if (neighbor.relationship > 3) {
|
||||
// Relationship is not active, skip it
|
||||
continue;
|
||||
}
|
||||
|
||||
let neighborEui64 = neighbor.eui64;
|
||||
|
||||
// Some Xiaomi devices return 0x00 as the neighbor ieeeAddr (obviously not correct).
|
||||
// Determine the correct ieeeAddr based on the networkAddress.
|
||||
if (neighbor.ieeeAddr === "0x0000000000000000") {
|
||||
const neighborDevice = this.zigbee.deviceByNetworkAddress(neighbor.networkAddress);
|
||||
if (neighborEui64 === "0x0000000000000000") {
|
||||
const neighborDevice = this.zigbee.deviceByNetworkAddress(neighbor.nwkAddress);
|
||||
|
||||
if (neighborDevice) {
|
||||
neighbor.ieeeAddr = neighborDevice.ieeeAddr;
|
||||
neighborEui64 = neighborDevice.ieeeAddr as Eui64;
|
||||
}
|
||||
}
|
||||
|
||||
const link: Zigbee2MQTTNetworkMap["links"][number] = {
|
||||
source: {ieeeAddr: neighbor.ieeeAddr, networkAddress: neighbor.networkAddress},
|
||||
source: {ieeeAddr: neighborEui64, networkAddress: neighbor.nwkAddress},
|
||||
target: {ieeeAddr: device.ieeeAddr, networkAddress: device.zh.networkAddress},
|
||||
linkquality: neighbor.linkquality,
|
||||
depth: neighbor.depth,
|
||||
routes: [],
|
||||
// DEPRECATED:
|
||||
sourceIeeeAddr: neighbor.ieeeAddr,
|
||||
targetIeeeAddr: device.ieeeAddr,
|
||||
sourceNwkAddr: neighbor.networkAddress,
|
||||
lqi: neighbor.linkquality,
|
||||
deviceType: neighbor.deviceType,
|
||||
rxOnWhenIdle: neighbor.rxOnWhenIdle,
|
||||
relationship: neighbor.relationship,
|
||||
permitJoining: neighbor.permitJoining,
|
||||
depth: neighbor.depth,
|
||||
lqi: neighbor.lqi,
|
||||
routes: [],
|
||||
// below are @deprecated
|
||||
sourceIeeeAddr: neighborEui64,
|
||||
targetIeeeAddr: device.ieeeAddr,
|
||||
sourceNwkAddr: neighbor.nwkAddress,
|
||||
linkquality: neighbor.lqi,
|
||||
};
|
||||
|
||||
const routingTable = routingTables.get(device);
|
||||
|
||||
if (routingTable) {
|
||||
for (const entry of routingTable.table) {
|
||||
if (entry.status === "ACTIVE" && entry.nextHop === neighbor.networkAddress) {
|
||||
for (const entry of routingTable) {
|
||||
if (entry.nextHopAddress === neighbor.nwkAddress) {
|
||||
link.routes.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+162
-30
@@ -1,5 +1,8 @@
|
||||
import type * as zigbeeHerdsman from "zigbee-herdsman/dist";
|
||||
import type {ClusterDefinition, ClusterName, CustomClusters} from "zigbee-herdsman/dist/zspec/zcl/definition/tstype";
|
||||
import type {ZclPayload} from "zigbee-herdsman/dist/adapter/events";
|
||||
import type {Eui64} from "zigbee-herdsman/dist/zspec/tstypes";
|
||||
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";
|
||||
|
||||
@@ -29,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?:
|
||||
@@ -52,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";
|
||||
@@ -65,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;
|
||||
@@ -102,6 +133,7 @@ export interface Zigbee2MQTTSettings {
|
||||
cert?: string;
|
||||
client_id?: string;
|
||||
reject_unauthorized?: boolean;
|
||||
server_name?: string;
|
||||
maximum_packet_size: number;
|
||||
};
|
||||
serial: {
|
||||
@@ -137,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;
|
||||
};
|
||||
@@ -183,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;
|
||||
@@ -227,6 +262,7 @@ export interface Zigbee2MQTTDeviceDefinition {
|
||||
exposes: zigbeeHerdsmanConverter.Expose[];
|
||||
supports_ota: boolean;
|
||||
options: zigbeeHerdsmanConverter.Option[];
|
||||
version: `0.0.${number}`;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
@@ -278,18 +314,21 @@ export interface Zigbee2MQTTNetworkMap {
|
||||
links: {
|
||||
source: {ieeeAddr: string; networkAddress: number};
|
||||
target: {ieeeAddr: string; networkAddress: number};
|
||||
linkquality: number;
|
||||
depth: number;
|
||||
routes: {
|
||||
destinationAddress: number;
|
||||
status: string;
|
||||
nextHop: number;
|
||||
}[];
|
||||
sourceIeeeAddr: string;
|
||||
targetIeeeAddr: string;
|
||||
sourceNwkAddr: number;
|
||||
lqi: number;
|
||||
deviceType: number;
|
||||
rxOnWhenIdle: number;
|
||||
relationship: number;
|
||||
permitJoining: number;
|
||||
depth: number;
|
||||
lqi: number;
|
||||
routes: RoutingTableEntry[];
|
||||
/** @deprecated 3.0 */
|
||||
linkquality: number;
|
||||
/** @deprecated 3.0 */
|
||||
sourceIeeeAddr: string;
|
||||
/** @deprecated 3.0 */
|
||||
targetIeeeAddr: string;
|
||||
/** @deprecated 3.0 */
|
||||
sourceNwkAddr: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -308,8 +347,9 @@ export interface Zigbee2MQTTAPI {
|
||||
};
|
||||
|
||||
"bridge/definitions": {
|
||||
clusters: Readonly<Record<ClusterName, Readonly<ClusterDefinition>>>;
|
||||
clusters: Readonly<Record<ClusterName, Cluster>>;
|
||||
custom_clusters: Record<string, CustomClusters>;
|
||||
actions: string[];
|
||||
};
|
||||
|
||||
"bridge/event":
|
||||
@@ -558,6 +598,16 @@ export interface Zigbee2MQTTAPI {
|
||||
failed: string[];
|
||||
};
|
||||
|
||||
"bridge/request/device/binds/clear": {
|
||||
target: string;
|
||||
ieee_list?: Eui64[];
|
||||
};
|
||||
|
||||
"bridge/response/device/binds/clear": {
|
||||
target: string;
|
||||
ieee_list?: Eui64[];
|
||||
};
|
||||
|
||||
"bridge/request/device/configure":
|
||||
| {
|
||||
id: string | number;
|
||||
@@ -572,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": {
|
||||
@@ -686,25 +790,41 @@ export interface Zigbee2MQTTAPI {
|
||||
homeassistant_rename: boolean;
|
||||
};
|
||||
|
||||
"bridge/request/device/configure_reporting": {
|
||||
"bridge/request/device/reporting/configure": {
|
||||
id: string;
|
||||
endpoint: string | number;
|
||||
cluster: string | number;
|
||||
attribute: string | number | {ID: number; type: number};
|
||||
minimum_report_interval: number;
|
||||
maximum_report_interval: number;
|
||||
reportable_change: number;
|
||||
reportable_change?: number;
|
||||
option: Record<string, unknown>;
|
||||
};
|
||||
|
||||
"bridge/response/device/configure_reporting": {
|
||||
"bridge/response/device/reporting/configure": {
|
||||
id: string;
|
||||
endpoint: string | number;
|
||||
cluster: string | number;
|
||||
attribute: string | number | {ID: number; type: number};
|
||||
minimum_report_interval: number;
|
||||
maximum_report_interval: number;
|
||||
reportable_change: number;
|
||||
reportable_change?: number;
|
||||
};
|
||||
|
||||
"bridge/request/device/reporting/read": {
|
||||
id: string;
|
||||
endpoint: string | number;
|
||||
cluster: string | number;
|
||||
configs: {direction?: number; attribute: string | number | {ID: number; type: number}}[];
|
||||
manufacturer_code?: number;
|
||||
};
|
||||
|
||||
"bridge/response/device/reporting/read": {
|
||||
id: string;
|
||||
endpoint: string | number;
|
||||
cluster: string | number;
|
||||
configs: zigbeeHerdsman.Zcl.ClustersTypes.TFoundation["readReportConfigRsp"];
|
||||
manufacturer_code?: number;
|
||||
};
|
||||
|
||||
"bridge/request/group/remove": {
|
||||
@@ -821,6 +941,10 @@ export interface Zigbee2MQTTAPI {
|
||||
channel: number;
|
||||
};
|
||||
|
||||
"bridge/request/action": {action: string; params?: Record<string, unknown>};
|
||||
|
||||
"bridge/response/action": GenericZdoResponse | ZclPayload | undefined;
|
||||
|
||||
/**
|
||||
* entity state response
|
||||
*/
|
||||
@@ -883,12 +1007,14 @@ export type Zigbee2MQTTRequestEndpoints =
|
||||
| "bridge/request/options"
|
||||
| "bridge/request/device/bind"
|
||||
| "bridge/request/device/unbind"
|
||||
| "bridge/request/device/binds/clear"
|
||||
| "bridge/request/device/configure"
|
||||
| "bridge/request/device/remove"
|
||||
| "bridge/request/device/ota_update/check"
|
||||
| "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"
|
||||
@@ -896,7 +1022,8 @@ export type Zigbee2MQTTRequestEndpoints =
|
||||
| "bridge/request/device/generate_external_definition"
|
||||
| "bridge/request/device/options"
|
||||
| "bridge/request/device/rename"
|
||||
| "bridge/request/device/configure_reporting"
|
||||
| "bridge/request/device/reporting/configure"
|
||||
| "bridge/request/device/reporting/read"
|
||||
| "bridge/request/group/remove"
|
||||
| "bridge/request/group/add"
|
||||
| "bridge/request/group/rename"
|
||||
@@ -907,6 +1034,7 @@ export type Zigbee2MQTTRequestEndpoints =
|
||||
| "bridge/request/touchlink/factory_reset"
|
||||
| "bridge/request/touchlink/scan"
|
||||
| "bridge/request/touchlink/identify"
|
||||
| "bridge/request/action"
|
||||
| "{friendlyNameOrId}/set"
|
||||
| "{friendlyNameOrId}/set/{attribute}"
|
||||
| "{friendlyNameOrId}/{endpoint}/set"
|
||||
@@ -931,17 +1059,20 @@ export type Zigbee2MQTTResponseEndpoints =
|
||||
| "bridge/response/options"
|
||||
| "bridge/response/device/bind"
|
||||
| "bridge/response/device/unbind"
|
||||
| "bridge/response/device/binds/clear"
|
||||
| "bridge/response/device/configure"
|
||||
| "bridge/response/device/remove"
|
||||
| "bridge/response/device/ota_update/check"
|
||||
| "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"
|
||||
| "bridge/response/device/generate_external_definition"
|
||||
| "bridge/response/device/options"
|
||||
| "bridge/response/device/rename"
|
||||
| "bridge/response/device/configure_reporting"
|
||||
| "bridge/response/device/reporting/configure"
|
||||
| "bridge/response/device/reporting/read"
|
||||
| "bridge/response/group/remove"
|
||||
| "bridge/response/group/add"
|
||||
| "bridge/response/group/rename"
|
||||
@@ -951,7 +1082,8 @@ export type Zigbee2MQTTResponseEndpoints =
|
||||
| "bridge/response/group/members/remove_all"
|
||||
| "bridge/response/touchlink/factory_reset"
|
||||
| "bridge/response/touchlink/scan"
|
||||
| "bridge/response/touchlink/identify";
|
||||
| "bridge/response/touchlink/identify"
|
||||
| "bridge/response/action";
|
||||
|
||||
export type Zigbee2MQTTRequest<T extends Zigbee2MQTTRequestEndpoints> = {
|
||||
transaction?: string;
|
||||
|
||||
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
-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;
|
||||
@@ -36,9 +39,6 @@ declare global {
|
||||
type Endpoint = ZHModels.Endpoint;
|
||||
type Device = ZHModels.Device;
|
||||
type Group = ZHModels.Group;
|
||||
// biome-ignore lint/style/useNamingConvention: API
|
||||
type LQI = ZHAdapterTypes.Lqi;
|
||||
type RoutingTable = ZHAdapterTypes.RoutingTable;
|
||||
type CoordinatorVersion = ZHAdapterTypes.CoordinatorVersion;
|
||||
type NetworkParameters = ZHAdapterTypes.NetworkParameters;
|
||||
interface Bind {
|
||||
|
||||
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]>;
|
||||
}
|
||||
+173
-463
@@ -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,95 +86,109 @@ 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.listen(Number.parseInt(serverUrl.port), serverUrl.hostname, () => {
|
||||
server.on("error", (error: Error) => {
|
||||
console.error("Failed to start onboarding server", error);
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
server.listen(Number.parseInt(serverUrl.port, 10), serverUrl.hostname, () => {
|
||||
console.log(`Onboarding page is available at ${serverUrl.href}`);
|
||||
});
|
||||
});
|
||||
@@ -498,24 +198,38 @@ 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), serverUrl.hostname, () => {
|
||||
server.listen(Number.parseInt(serverUrl.port, 10), serverUrl.hostname, () => {
|
||||
console.error(`Failure page is available at ${serverUrl.href}`);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
|
||||
+12
-9
@@ -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);
|
||||
|
||||
@@ -607,7 +610,7 @@ export function addGroup(name: string, id?: string): GroupOptions {
|
||||
id = "1";
|
||||
|
||||
while (settings.groups[id]) {
|
||||
id = (Number.parseInt(id) + 1).toString();
|
||||
id = (Number.parseInt(id, 10) + 1).toString();
|
||||
}
|
||||
} else {
|
||||
// ensure provided ID is not in use
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+100
-62
@@ -1,21 +1,20 @@
|
||||
import {randomInt} from "node:crypto";
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import type {Events as ZHEvents} from "zigbee-herdsman";
|
||||
import {Controller} from "zigbee-herdsman";
|
||||
import type {StartResult} from "zigbee-herdsman/dist/adapter/tstype";
|
||||
|
||||
import Device from "./model/device";
|
||||
import Group from "./model/group";
|
||||
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 = /^(.+?)(?:\/([^/]+))?$/;
|
||||
|
||||
export default class Zigbee {
|
||||
private herdsman!: Controller;
|
||||
#herdsman!: Controller;
|
||||
private eventBus: EventBus;
|
||||
private groupLookup = new Map<number /* group ID */, Group>();
|
||||
private deviceLookup = new Map<string /* IEEE address */, Device>();
|
||||
@@ -25,7 +24,11 @@ export default class Zigbee {
|
||||
this.eventBus = eventBus;
|
||||
}
|
||||
|
||||
async start(): Promise<StartResult> {
|
||||
get zhController() {
|
||||
return this.#herdsman;
|
||||
}
|
||||
|
||||
async start(abortSignal: AbortSignal): Promise<boolean> {
|
||||
const infoHerdsman = await utils.getDependencyVersion("zigbee-herdsman");
|
||||
logger.info(`Starting zigbee-herdsman (${infoHerdsman.version})`);
|
||||
const panId = settings.get().advanced.pan_id;
|
||||
@@ -58,42 +61,46 @@ 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();
|
||||
this.#herdsman = new Controller(herdsmanSettings);
|
||||
startResult = await this.#herdsman.start(abortSignal);
|
||||
} catch (error) {
|
||||
logger.error("Error while starting zigbee-herdsman");
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.coordinatorIeeeAddr = this.herdsman.getDevicesByType("Coordinator")[0].ieeeAddr;
|
||||
await this.resolveDevicesDefinitions();
|
||||
this.coordinatorIeeeAddr = this.#herdsman.getDevicesByType("Coordinator")[0].ieeeAddr;
|
||||
await this.resolveDevicesDefinitions(false, abortSignal);
|
||||
|
||||
this.herdsman.on("adapterDisconnected", () => this.eventBus.emitAdapterDisconnected());
|
||||
this.herdsman.on("lastSeenChanged", (data: ZHEvents.LastSeenChangedPayload) => {
|
||||
if (abortSignal.aborted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.#herdsman.on("adapterDisconnected", () => this.eventBus.emitAdapterDisconnected());
|
||||
this.#herdsman.on("lastSeenChanged", (data: ZHEvents.LastSeenChangedPayload) => {
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
this.eventBus.emitLastSeenChanged({device: this.resolveDevice(data.device.ieeeAddr)!, reason: data.reason});
|
||||
});
|
||||
this.herdsman.on("permitJoinChanged", (data: ZHEvents.PermitJoinChangedPayload) => {
|
||||
this.#herdsman.on("permitJoinChanged", (data: ZHEvents.PermitJoinChangedPayload) => {
|
||||
this.eventBus.emitPermitJoinChanged(data);
|
||||
});
|
||||
this.herdsman.on("deviceNetworkAddressChanged", (data: ZHEvents.DeviceNetworkAddressChangedPayload) => {
|
||||
this.#herdsman.on("deviceNetworkAddressChanged", (data: ZHEvents.DeviceNetworkAddressChangedPayload) => {
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
const device = this.resolveDevice(data.device.ieeeAddr)!;
|
||||
logger.debug(`Device '${device.name}' changed network address`);
|
||||
this.eventBus.emitDeviceNetworkAddressChanged({device});
|
||||
});
|
||||
this.herdsman.on("deviceAnnounce", (data: ZHEvents.DeviceAnnouncePayload) => {
|
||||
this.#herdsman.on("deviceAnnounce", (data: ZHEvents.DeviceAnnouncePayload) => {
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
const device = this.resolveDevice(data.device.ieeeAddr)!;
|
||||
logger.debug(`Device '${device.name}' announced itself`);
|
||||
this.eventBus.emitDeviceAnnounce({device});
|
||||
});
|
||||
this.herdsman.on("deviceInterview", async (data: ZHEvents.DeviceInterviewPayload) => {
|
||||
this.#herdsman.on("deviceInterview", async (data: ZHEvents.DeviceInterviewPayload) => {
|
||||
const device = this.resolveDevice(data.device.ieeeAddr);
|
||||
/* v8 ignore next */ if (!device) return; // Prevent potential race
|
||||
await device.resolveDefinition();
|
||||
@@ -101,19 +108,19 @@ export default class Zigbee {
|
||||
this.logDeviceInterview(d);
|
||||
this.eventBus.emitDeviceInterview(d);
|
||||
});
|
||||
this.herdsman.on("deviceJoined", async (data: ZHEvents.DeviceJoinedPayload) => {
|
||||
this.#herdsman.on("deviceJoined", async (data: ZHEvents.DeviceJoinedPayload) => {
|
||||
const device = this.resolveDevice(data.device.ieeeAddr);
|
||||
/* v8 ignore next */ if (!device) return; // Prevent potential race
|
||||
await device.resolveDefinition();
|
||||
logger.info(`Device '${device.name}' joined`);
|
||||
this.eventBus.emitDeviceJoined({device});
|
||||
});
|
||||
this.herdsman.on("deviceLeave", (data: ZHEvents.DeviceLeavePayload) => {
|
||||
this.#herdsman.on("deviceLeave", (data: ZHEvents.DeviceLeavePayload) => {
|
||||
const name = settings.getDevice(data.ieeeAddr)?.friendly_name || data.ieeeAddr;
|
||||
logger.warning(`Device '${name}' left the network`);
|
||||
this.eventBus.emitDeviceLeave({ieeeAddr: data.ieeeAddr, name, device: this.deviceLookup.get(data.ieeeAddr)});
|
||||
});
|
||||
this.herdsman.on("message", async (data: ZHEvents.MessagePayload) => {
|
||||
this.#herdsman.on("message", async (data: ZHEvents.MessagePayload) => {
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
const device = this.resolveDevice(data.device.ieeeAddr)!;
|
||||
await device.resolveDefinition();
|
||||
@@ -129,32 +136,55 @@ export default class Zigbee {
|
||||
|
||||
logger.info(`zigbee-herdsman started (${startResult})`);
|
||||
logger.info(`Coordinator firmware version: '${stringify(await this.getCoordinatorVersion())}'`);
|
||||
logger.debug(`Zigbee network parameters: ${stringify(await this.herdsman.getNetworkParameters())}`);
|
||||
logger.debug(`Zigbee network parameters: ${stringify(await this.#herdsman.getNetworkParameters())}`);
|
||||
|
||||
for (const device of this.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
// If a passlist is used, all other device will be removed from the network.
|
||||
const passlist = settings.get().passlist;
|
||||
const blocklist = settings.get().blocklist;
|
||||
const remove = async (device: Device): Promise<void> => {
|
||||
try {
|
||||
await device.zh.removeFromNetwork();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to remove '${device.ieeeAddr}' (${(error as Error).message})`);
|
||||
}
|
||||
};
|
||||
if (abortSignal.aborted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (passlist.length > 0) {
|
||||
const remove = async (device: Device): Promise<void> => {
|
||||
try {
|
||||
await device.zh.removeFromNetwork();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to remove '${device.ieeeAddr}' (${(error as Error).message})`);
|
||||
}
|
||||
};
|
||||
// If a passlist is used, all other device will be removed from the network.
|
||||
const passlist = settings.get().passlist;
|
||||
|
||||
if (passlist.length > 0) {
|
||||
for (const device of this.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
if (!passlist.includes(device.ieeeAddr)) {
|
||||
logger.warning(`Device not on passlist currently connected (${device.ieeeAddr}), removing...`);
|
||||
logger.warning(`Device not on passlist currently on the network (${device.ieeeAddr}), removing...`);
|
||||
await remove(device);
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const ieee of settings.get().blocklist) {
|
||||
const device = this.resolveDevice(ieee);
|
||||
|
||||
if (device) {
|
||||
if (device.zh.type === "Coordinator") {
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.warning(`Device on blocklist currently on the network (${device.ieeeAddr}), removing...`);
|
||||
await remove(device);
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
logger.debug(`Ignoring blocklist device ${ieee}, not currently on the network`);
|
||||
}
|
||||
} else if (blocklist.includes(device.ieeeAddr)) {
|
||||
logger.warning(`Device on blocklist currently connected (${device.ieeeAddr}), removing...`);
|
||||
await remove(device);
|
||||
}
|
||||
}
|
||||
|
||||
return startResult;
|
||||
return true;
|
||||
}
|
||||
|
||||
private logDeviceInterview(data: eventdata.DeviceInterview): void {
|
||||
@@ -198,39 +228,39 @@ export default class Zigbee {
|
||||
}
|
||||
|
||||
async getCoordinatorVersion(): Promise<zh.CoordinatorVersion> {
|
||||
return await this.herdsman.getCoordinatorVersion();
|
||||
return await this.#herdsman.getCoordinatorVersion();
|
||||
}
|
||||
|
||||
isStopping(): boolean {
|
||||
return this.herdsman.isStopping();
|
||||
return this.#herdsman.isStopping();
|
||||
}
|
||||
|
||||
async backup(): Promise<void> {
|
||||
return await this.herdsman.backup();
|
||||
return await this.#herdsman.backup();
|
||||
}
|
||||
|
||||
async coordinatorCheck(): Promise<{missingRouters: Device[]}> {
|
||||
const check = await this.herdsman.coordinatorCheck();
|
||||
const check = await this.#herdsman.coordinatorCheck();
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
return {missingRouters: check.missingRouters.map((d) => this.resolveDevice(d.ieeeAddr)!)};
|
||||
}
|
||||
|
||||
async getNetworkParameters(): Promise<zh.NetworkParameters> {
|
||||
return await this.herdsman.getNetworkParameters();
|
||||
return await this.#herdsman.getNetworkParameters();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
logger.info("Stopping zigbee-herdsman...");
|
||||
await this.herdsman.stop();
|
||||
await this.#herdsman?.stop(); // could be undefined if this is called during startup (abort)
|
||||
logger.info("Stopped zigbee-herdsman");
|
||||
}
|
||||
|
||||
getPermitJoin(): boolean {
|
||||
return this.herdsman.getPermitJoin();
|
||||
return this.#herdsman.getPermitJoin();
|
||||
}
|
||||
|
||||
getPermitJoinEnd(): number | undefined {
|
||||
return this.herdsman.getPermitJoinEnd();
|
||||
return this.#herdsman.getPermitJoinEnd();
|
||||
}
|
||||
|
||||
async permitJoin(time: number, device?: Device): Promise<void> {
|
||||
@@ -240,18 +270,22 @@ export default class Zigbee {
|
||||
logger.info("Zigbee: disabling joining new devices.");
|
||||
}
|
||||
|
||||
await this.herdsman.permitJoin(time, device?.zh);
|
||||
await this.#herdsman.permitJoin(time, device?.zh);
|
||||
}
|
||||
|
||||
async resolveDevicesDefinitions(ignoreCache = false): Promise<void> {
|
||||
async resolveDevicesDefinitions(ignoreCache = false, abortSignal: AbortSignal | undefined = undefined): Promise<void> {
|
||||
for (const device of this.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
await device.resolveDefinition(ignoreCache);
|
||||
|
||||
if (abortSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bind private resolveDevice(ieeeAddr: string): Device | undefined {
|
||||
if (!this.deviceLookup.has(ieeeAddr)) {
|
||||
const device = this.herdsman.getDeviceByIeeeAddr(ieeeAddr);
|
||||
const device = this.#herdsman.getDeviceByIeeeAddr(ieeeAddr);
|
||||
if (device) {
|
||||
this.deviceLookup.set(ieeeAddr, new Device(device));
|
||||
}
|
||||
@@ -266,7 +300,7 @@ export default class Zigbee {
|
||||
|
||||
private resolveGroup(groupID: number): Group | undefined {
|
||||
if (!this.groupLookup.has(groupID)) {
|
||||
const group = this.herdsman.getGroupByID(groupID);
|
||||
const group = this.#herdsman.getGroupByID(groupID);
|
||||
|
||||
if (group) {
|
||||
this.groupLookup.set(groupID, new Group(group, this.resolveDevice));
|
||||
@@ -339,33 +373,33 @@ export default class Zigbee {
|
||||
}
|
||||
|
||||
firstCoordinatorEndpoint(): zh.Endpoint {
|
||||
return this.herdsman.getDevicesByType("Coordinator")[0].endpoints[0];
|
||||
return this.#herdsman.getDevicesByType("Coordinator")[0].endpoints[0];
|
||||
}
|
||||
|
||||
*devicesAndGroupsIterator(
|
||||
devicePredicate?: (value: zh.Device) => boolean,
|
||||
groupPredicate?: (value: zh.Group) => boolean,
|
||||
): Generator<Device | Group> {
|
||||
for (const device of this.herdsman.getDevicesIterator(devicePredicate)) {
|
||||
for (const device of this.#herdsman.getDevicesIterator(devicePredicate)) {
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
yield this.resolveDevice(device.ieeeAddr)!;
|
||||
}
|
||||
|
||||
for (const group of this.herdsman.getGroupsIterator(groupPredicate)) {
|
||||
for (const group of this.#herdsman.getGroupsIterator(groupPredicate)) {
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
yield this.resolveGroup(group.groupID)!;
|
||||
}
|
||||
}
|
||||
|
||||
*groupsIterator(predicate?: (value: zh.Group) => boolean): Generator<Group> {
|
||||
for (const group of this.herdsman.getGroupsIterator(predicate)) {
|
||||
for (const group of this.#herdsman.getGroupsIterator(predicate)) {
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
yield this.resolveGroup(group.groupID)!;
|
||||
}
|
||||
}
|
||||
|
||||
*devicesIterator(predicate?: (value: zh.Device) => boolean): Generator<Device> {
|
||||
for (const device of this.herdsman.getDevicesIterator(predicate)) {
|
||||
for (const device of this.#herdsman.getDevicesIterator(predicate)) {
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
yield this.resolveDevice(device.ieeeAddr)!;
|
||||
}
|
||||
@@ -399,33 +433,33 @@ export default class Zigbee {
|
||||
}
|
||||
|
||||
async touchlinkFactoryResetFirst(): Promise<boolean> {
|
||||
return await this.herdsman.touchlinkFactoryResetFirst();
|
||||
return await this.#herdsman.touchlink.factoryResetFirst();
|
||||
}
|
||||
|
||||
async touchlinkFactoryReset(ieeeAddr: string, channel: number): Promise<boolean> {
|
||||
return await this.herdsman.touchlinkFactoryReset(ieeeAddr, channel);
|
||||
return await this.#herdsman.touchlink.factoryReset(ieeeAddr, channel);
|
||||
}
|
||||
|
||||
async addInstallCode(installCode: string): Promise<void> {
|
||||
await this.herdsman.addInstallCode(installCode);
|
||||
await this.#herdsman.addInstallCode(installCode);
|
||||
}
|
||||
|
||||
async touchlinkIdentify(ieeeAddr: string, channel: number): Promise<void> {
|
||||
await this.herdsman.touchlinkIdentify(ieeeAddr, channel);
|
||||
await this.#herdsman.touchlink.identify(ieeeAddr, channel);
|
||||
}
|
||||
|
||||
async touchlinkScan(): Promise<{ieeeAddr: string; channel: number}[]> {
|
||||
return await this.herdsman.touchlinkScan();
|
||||
return await this.#herdsman.touchlink.scan();
|
||||
}
|
||||
|
||||
createGroup(id: number): Group {
|
||||
this.herdsman.createGroup(id);
|
||||
this.#herdsman.createGroup(id);
|
||||
// biome-ignore lint/style/noNonNullAssertion: just created
|
||||
return this.resolveGroup(id)!;
|
||||
}
|
||||
|
||||
deviceByNetworkAddress(networkAddress: number): Device | undefined {
|
||||
const device = this.herdsman.getDeviceByNetworkAddress(networkAddress);
|
||||
const device = this.#herdsman.getDeviceByNetworkAddress(networkAddress);
|
||||
return device && this.resolveDevice(device.ieeeAddr);
|
||||
}
|
||||
|
||||
@@ -433,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);
|
||||
}
|
||||
}
|
||||
|
||||
+23
-32
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "zigbee2mqtt",
|
||||
"version": "2.6.3",
|
||||
"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": "^2.2.0",
|
||||
"express-static-gzip": "^3.0.0",
|
||||
"debounce": "^3.0.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"finalhandler": "^2.1.0",
|
||||
"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.0",
|
||||
"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": "6.3.2",
|
||||
"zigbee-herdsman-converters": "25.59.0",
|
||||
"ws": "^8.21.3",
|
||||
"zigbee-herdsman": "10.9.1",
|
||||
"zigbee-herdsman-converters": "26.100.0",
|
||||
"zigbee2mqtt-frontend": "0.9.21",
|
||||
"zigbee2mqtt-windfront": "2.2.3"
|
||||
"zigbee2mqtt-windfront": "2.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.2",
|
||||
"@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.9.2",
|
||||
"@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": {
|
||||
@@ -99,4 +90,4 @@
|
||||
"optionalDependencies": {
|
||||
"unix-dgram": "^2.0.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+467
-647
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) {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
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;
|
||||
// Hide previous bot comments
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
});
|
||||
|
||||
for (const comment of comments.data) {
|
||||
if (comment.user.type === "Bot" && comment.user.login === "github-actions[bot]") {
|
||||
await github.graphql(`mutation {
|
||||
minimizeComment(input: {subjectId: "${comment.node_id}", classifier: OUTDATED}) {
|
||||
clientMutationId
|
||||
}
|
||||
}`);
|
||||
}
|
||||
}
|
||||
|
||||
const titleAndBody = `${issue.title}\n\n${issue.body ?? ""}`;
|
||||
|
||||
// Check if Tuya manufacturer name is already supported.
|
||||
const tuyaManufacturerNameRe = /['"](_T\w+_(\w+))['"]/g;
|
||||
const tuyaManufacturerNames = Array.from(titleAndBody.matchAll(tuyaManufacturerNameRe), (m) => [m[1], m[2]]);
|
||||
console.log("Found tuyaManufacturerNames", tuyaManufacturerNames);
|
||||
if (tuyaManufacturerNames.length > 0) {
|
||||
for (const [fullName, partialName] of tuyaManufacturerNames) {
|
||||
if (await checkDuplicateIssue(github, context, fullName)) return;
|
||||
const fullMatch = grepTypeScriptFiles(fullName, zhcDir);
|
||||
|
||||
console.log(`Checking full match for '${fullName}', result: '${fullMatch}'`);
|
||||
if (fullMatch) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: `👋 Hi there! The Tuya device with manufacturer name \`${fullName}\` is already supported in the latest dev branch.
|
||||
See this [guide](https://www.zigbee2mqtt.io/advanced/more/switch-to-dev-branch.html) on how to update, after updating you can remove your external converter.
|
||||
|
||||
In case you created the external converter with the goal to extend or fix an issue with the out-of-the-box support, please submit a pull request.
|
||||
For instructions on how to create a pull request see the [docs](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html#_4-create-a-pull-request).
|
||||
If you need help with the process, feel free to ask here and we'll be happy to assist.`,
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: "closed",
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const partialMatch = grepTypeScriptFiles(partialName, zhcDir);
|
||||
|
||||
console.log(`Checking partial match for '${partialName}', result: '${partialMatch}'`);
|
||||
if (partialMatch) {
|
||||
const candidates = Array.from(partialMatch.matchAll(tuyaManufacturerNameRe), (m) => m[1]);
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: `👋 Hi there! A similar Tuya device of which the manufacturer name also ends with \`_${partialName}\` is already supported.
|
||||
This means the device can probably be easily be supported by re-using the existing converter.
|
||||
|
||||
I found the following matches: ${candidates.map((c) => `\`${c}\``).join(", ")}
|
||||
Try to stop Z2M, change all occurrences of \`${fullName}\` in the \`data/database.db\` to one of the matches above and start Z2M.
|
||||
|
||||
Let us know if it works so we can support this device out-of-the-box!`,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Check if zigbee model is already supported.
|
||||
const zigbeeModelRe = /zigbeeModel: \[['"](.+)['"]\]/g;
|
||||
const zigbeeModels = Array.from(titleAndBody.matchAll(zigbeeModelRe), (m) => m[1]);
|
||||
|
||||
if (zigbeeModels.length > 0) {
|
||||
for (const zigbeeModel of zigbeeModels) {
|
||||
if (await checkDuplicateIssue(github, context, zigbeeModel)) return;
|
||||
const fullMatch = grepTypeScriptFiles(`"${zigbeeModel}"`, zhcDir);
|
||||
|
||||
if (fullMatch) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: `👋 Hi there! The device with zigbee model \`${zigbeeModel}\` is already supported in the latest dev branch.
|
||||
See this [guide](https://www.zigbee2mqtt.io/advanced/more/switch-to-dev-branch.html) on how to update, after updating you can remove your external converter.
|
||||
|
||||
In case you created the external converter with the goal to extend or fix an issue with the out-of-the-box support, please submit a pull request.
|
||||
For instructions on how to create a pull request see the [docs](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html#_4-create-a-pull-request).
|
||||
|
||||
If you need help with the process, feel free to ask here and we'll be happy to assist.`,
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: "closed",
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a request to pull request comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: `🙏 Thank you for creating this issue and sharing your external converter!
|
||||
|
||||
In case all features work, please submit a pull request on this repository so the device can be supported out-of-the-box with the next release.
|
||||
For instructions on how to create a pull request see the [docs](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html#_4-create-a-pull-request).
|
||||
|
||||
If **NOT** all features work, please follow the [How To Support new devices](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html).
|
||||
${
|
||||
tuyaManufacturerNames.length > 0
|
||||
? "Since this is a Tuya also consider [How To Support new Tuya devices](https://www.zigbee2mqtt.io/advanced/support-new-devices/02_support_new_tuya_devices.html)."
|
||||
: ""
|
||||
}
|
||||
|
||||
If you need help with the process, feel free to ask here and we'll be happy to assist.`,
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
+46
-44
@@ -1,7 +1,6 @@
|
||||
import {existsSync, mkdirSync} from "node:fs";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {bench, describe} from "vitest";
|
||||
import {Zcl, Zdo, ZSpec} from "zigbee-herdsman";
|
||||
import {bench, describe, vi} from "vitest";
|
||||
import {type Controller, Zcl, Zdo, ZSpec} from "zigbee-herdsman";
|
||||
import type Adapter from "zigbee-herdsman/dist/adapter/adapter";
|
||||
import type {ZclPayload} from "zigbee-herdsman/dist/adapter/events";
|
||||
import {Device, InterviewState} from "zigbee-herdsman/dist/controller/model/device";
|
||||
@@ -12,8 +11,51 @@ 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) => {
|
||||
const actual = await importOriginal<typeof import("zigbee-herdsman")>();
|
||||
class MockHerdsman {
|
||||
on: Controller["on"] = vi.fn();
|
||||
start: Controller["start"] = async () => "resumed" as const;
|
||||
stop: Controller["stop"] = async () => {};
|
||||
isStopping: Controller["isStopping"] = () => false;
|
||||
getCoordinatorVersion: Controller["getCoordinatorVersion"] = async () =>
|
||||
Promise.resolve({
|
||||
type: "Dummy",
|
||||
meta: {revision: "9.9.9"},
|
||||
});
|
||||
getNetworkParameters: Controller["getNetworkParameters"] = async () => Promise.resolve({...NETWORK_PARAMS});
|
||||
getPermitJoin: Controller["getPermitJoin"] = () => false;
|
||||
getPermitJoinEnd: Controller["getPermitJoinEnd"] = () => undefined;
|
||||
getDeviceByIeeeAddr: Controller["getDeviceByIeeeAddr"] = (ieeeAddr) => ZH_DEVICES.find((device) => device.ieeeAddr === ieeeAddr);
|
||||
getGroupByID: Controller["getGroupByID"] = (id) => ZH_GROUPS.find((group) => group.groupID === id);
|
||||
getDevicesByType: Controller["getDevicesByType"] = (type) => ZH_DEVICES.filter((device) => device.type === type);
|
||||
getDeviceByNetworkAddress: Controller["getDeviceByNetworkAddress"] = (networkAddress) =>
|
||||
ZH_DEVICES.find((device) => device.networkAddress === networkAddress);
|
||||
*getDevicesIterator(predicate: ((device: Device) => boolean) | undefined) {
|
||||
for (const device of ZH_DEVICES) {
|
||||
if (!predicate || predicate(device)) {
|
||||
yield device;
|
||||
}
|
||||
}
|
||||
}
|
||||
*getGroupsIterator(predicate: ((group: Group) => boolean) | undefined) {
|
||||
for (const group of ZH_GROUPS) {
|
||||
if (!predicate || predicate(group)) {
|
||||
yield group;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...actual,
|
||||
Controller: MockHerdsman,
|
||||
};
|
||||
});
|
||||
|
||||
process.env.ZIGBEE2MQTT_DATA = "data-bench";
|
||||
data._testReload();
|
||||
|
||||
@@ -215,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({
|
||||
@@ -365,45 +407,6 @@ const initController = async () => {
|
||||
async () => {},
|
||||
);
|
||||
|
||||
controller.zigbee.start = async () => {
|
||||
await controller.zigbee.resolveDevicesDefinitions();
|
||||
|
||||
return "resumed";
|
||||
};
|
||||
controller.zigbee.stop = async () => {
|
||||
await Promise.resolve();
|
||||
};
|
||||
// @ts-expect-error mocking private
|
||||
controller.zigbee.herdsman = {
|
||||
isStopping: () => false,
|
||||
getCoordinatorVersion: async () =>
|
||||
Promise.resolve({
|
||||
type: "Dummy",
|
||||
meta: {revision: "9.9.9"},
|
||||
}),
|
||||
getNetworkParameters: async () => Promise.resolve({...NETWORK_PARAMS}),
|
||||
getPermitJoin: () => false,
|
||||
getPermitJoinEnd: () => undefined,
|
||||
getDeviceByIeeeAddr: (ieeeAddr) => ZH_DEVICES.find((device) => device.ieeeAddr === ieeeAddr),
|
||||
getGroupByID: (id) => ZH_GROUPS.find((group) => group.groupID === id),
|
||||
getDevicesByType: (type) => ZH_DEVICES.filter((device) => device.type === type),
|
||||
getDeviceByNetworkAddress: (networkAddress) => ZH_DEVICES.find((device) => device.networkAddress === networkAddress),
|
||||
*getDevicesIterator(predicate) {
|
||||
for (const device of ZH_DEVICES) {
|
||||
if (!predicate || predicate(device)) {
|
||||
yield device;
|
||||
}
|
||||
}
|
||||
},
|
||||
*getGroupsIterator(predicate) {
|
||||
for (const group of ZH_GROUPS) {
|
||||
if (!predicate || predicate(group)) {
|
||||
yield group;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// all dummies, can trigger `controller.mqtt.onMessage(topic, message)` as needed
|
||||
// @ts-expect-error mocking private
|
||||
controller.mqtt.client = {
|
||||
@@ -418,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({
|
||||
@@ -795,4 +795,84 @@ describe("Extension: Bind", () => {
|
||||
// Should only call Hue bulb, not e.g. tradfri
|
||||
expect(devices.bulb_2.getEndpoint(1)!.read).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("clears all bindings", async () => {
|
||||
const device = devices.remote;
|
||||
|
||||
device.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/binds/clear", stringify({transaction: "1234", target: "remote"}));
|
||||
await flushPromises();
|
||||
|
||||
expect(device.clearAllBindings).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/binds/clear",
|
||||
stringify({
|
||||
transaction: "1234",
|
||||
data: {
|
||||
target: "remote",
|
||||
ieee_list: ["0xffffffffffffffff"],
|
||||
},
|
||||
status: "ok",
|
||||
}),
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), {retain: true});
|
||||
});
|
||||
|
||||
it("clears targeted bindings", async () => {
|
||||
const device = devices.remote;
|
||||
const target = devices.bulb_color;
|
||||
|
||||
device.mockClear();
|
||||
mockMQTTEvents.message(
|
||||
"zigbee2mqtt/bridge/request/device/binds/clear",
|
||||
stringify({transaction: "1234", target: "remote", ieee_list: [target.ieeeAddr]}),
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
expect(device.clearAllBindings).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/binds/clear",
|
||||
stringify({
|
||||
transaction: "1234",
|
||||
data: {
|
||||
target: "remote",
|
||||
ieee_list: [target.ieeeAddr],
|
||||
},
|
||||
status: "ok",
|
||||
}),
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), {retain: true});
|
||||
});
|
||||
|
||||
it("throw on invalid clears bindings payload", async () => {
|
||||
const device = devices.remote;
|
||||
|
||||
device.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/binds/clear", stringify({targetz: "remote"}));
|
||||
await flushPromises();
|
||||
|
||||
expect(device.clearAllBindings).toHaveBeenCalledTimes(0);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/binds/clear",
|
||||
stringify({data: {}, status: "error", error: "Invalid payload"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("throw on invalid clears bindings target", async () => {
|
||||
const device = devices.remote;
|
||||
|
||||
device.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/binds/clear", stringify({target: "remotez"}));
|
||||
await flushPromises();
|
||||
|
||||
expect(device.clearAllBindings).toHaveBeenCalledTimes(0);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/binds/clear",
|
||||
stringify({data: {}, status: "error", error: "Invalid target"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+667
-58
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -1,5 +1,6 @@
|
||||
// biome-ignore assist/source/organizeImports: import mocks first
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
|
||||
import type {Eui64} from "zigbee-herdsman/dist/zspec/tstypes";
|
||||
import * as data from "../mocks/data";
|
||||
import {mockLogger} from "../mocks/logger";
|
||||
import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
|
||||
@@ -9,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";
|
||||
|
||||
@@ -25,6 +26,22 @@ returnDevices.push(
|
||||
|
||||
const mocksClear = [mockMQTTPublishAsync, mockLogger.warning, mockLogger.debug];
|
||||
|
||||
const LQI_BASE_RSP = {
|
||||
extendedPanId: [],
|
||||
deviceType: 0,
|
||||
rxOnWhenIdle: 0,
|
||||
reserved1: 0,
|
||||
permitJoining: 0,
|
||||
reserved2: 0,
|
||||
};
|
||||
|
||||
const ROUTING_BASE_RSP = {
|
||||
memoryConstrained: 0,
|
||||
manyToOne: 0,
|
||||
routeRecordRequired: 0,
|
||||
reserved1: 0,
|
||||
};
|
||||
|
||||
describe("Extension: NetworkMap", () => {
|
||||
let controller: Controller;
|
||||
|
||||
@@ -39,58 +56,62 @@ describe("Extension: NetworkMap", () => {
|
||||
* | -> CC2530_ROUTER -> WXKG02LM_rev1
|
||||
*
|
||||
*/
|
||||
devices.coordinator.lqi.mockResolvedValueOnce({
|
||||
neighbors: [
|
||||
{
|
||||
ieeeAddr: devices.bulb_color.ieeeAddr,
|
||||
networkAddress: devices.bulb_color.networkAddress,
|
||||
relationship: 2,
|
||||
depth: 1,
|
||||
linkquality: 120,
|
||||
},
|
||||
{ieeeAddr: devices.bulb.ieeeAddr, networkAddress: devices.bulb.networkAddress, relationship: 2, depth: 1, linkquality: 92},
|
||||
{
|
||||
ieeeAddr: devices.external_converter_device.ieeeAddr,
|
||||
networkAddress: devices.external_converter_device.networkAddress,
|
||||
relationship: 2,
|
||||
depth: 1,
|
||||
linkquality: 92,
|
||||
},
|
||||
],
|
||||
});
|
||||
devices.coordinator.routingTable.mockResolvedValueOnce({
|
||||
table: [{destinationAddress: devices.CC2530_ROUTER.networkAddress, status: "ACTIVE", nextHop: devices.bulb.networkAddress}],
|
||||
});
|
||||
devices.bulb.lqi.mockResolvedValueOnce({
|
||||
neighbors: [
|
||||
{
|
||||
ieeeAddr: devices.bulb_color.ieeeAddr,
|
||||
networkAddress: devices.bulb_color.networkAddress,
|
||||
relationship: 1,
|
||||
depth: 2,
|
||||
linkquality: 110,
|
||||
},
|
||||
{
|
||||
ieeeAddr: devices.CC2530_ROUTER.ieeeAddr,
|
||||
networkAddress: devices.CC2530_ROUTER.networkAddress,
|
||||
relationship: 1,
|
||||
depth: 2,
|
||||
linkquality: 100,
|
||||
},
|
||||
],
|
||||
});
|
||||
devices.CC2530_ROUTER.lqi.mockResolvedValueOnce({
|
||||
neighbors: [
|
||||
{ieeeAddr: "0x0000000000000000", networkAddress: devices.WXKG02LM_rev1.networkAddress, relationship: 1, depth: 2, linkquality: 130},
|
||||
{
|
||||
ieeeAddr: devices.bulb_color.ieeeAddr,
|
||||
networkAddress: devices.bulb_color.networkAddress,
|
||||
relationship: 4,
|
||||
depth: 2,
|
||||
linkquality: 130,
|
||||
},
|
||||
],
|
||||
});
|
||||
devices.coordinator.lqi.mockResolvedValueOnce([
|
||||
{
|
||||
eui64: devices.bulb_color.ieeeAddr as Eui64,
|
||||
nwkAddress: devices.bulb_color.networkAddress,
|
||||
relationship: 2,
|
||||
depth: 1,
|
||||
lqi: 120,
|
||||
...LQI_BASE_RSP,
|
||||
},
|
||||
{eui64: devices.bulb.ieeeAddr as Eui64, nwkAddress: devices.bulb.networkAddress, relationship: 2, depth: 1, lqi: 92, ...LQI_BASE_RSP},
|
||||
{
|
||||
eui64: devices.external_converter_device.ieeeAddr as Eui64,
|
||||
nwkAddress: devices.external_converter_device.networkAddress,
|
||||
relationship: 2,
|
||||
depth: 1,
|
||||
lqi: 92,
|
||||
...LQI_BASE_RSP,
|
||||
},
|
||||
]);
|
||||
devices.coordinator.routingTable.mockResolvedValueOnce([
|
||||
{
|
||||
destinationAddress: devices.CC2530_ROUTER.networkAddress,
|
||||
status: "ACTIVE",
|
||||
nextHopAddress: devices.bulb.networkAddress,
|
||||
...ROUTING_BASE_RSP,
|
||||
},
|
||||
]);
|
||||
devices.bulb.lqi.mockResolvedValueOnce([
|
||||
{
|
||||
eui64: devices.bulb_color.ieeeAddr as Eui64,
|
||||
nwkAddress: devices.bulb_color.networkAddress,
|
||||
relationship: 1,
|
||||
depth: 2,
|
||||
lqi: 110,
|
||||
...LQI_BASE_RSP,
|
||||
},
|
||||
{
|
||||
eui64: devices.CC2530_ROUTER.ieeeAddr as Eui64,
|
||||
nwkAddress: devices.CC2530_ROUTER.networkAddress,
|
||||
relationship: 1,
|
||||
depth: 2,
|
||||
lqi: 100,
|
||||
...LQI_BASE_RSP,
|
||||
},
|
||||
]);
|
||||
devices.CC2530_ROUTER.lqi.mockResolvedValueOnce([
|
||||
{eui64: "0x0000000000000000", nwkAddress: devices.WXKG02LM_rev1.networkAddress, relationship: 1, depth: 2, lqi: 130, ...LQI_BASE_RSP},
|
||||
{
|
||||
eui64: devices.bulb_color.ieeeAddr as Eui64,
|
||||
nwkAddress: devices.bulb_color.networkAddress,
|
||||
relationship: 4,
|
||||
depth: 2,
|
||||
lqi: 130,
|
||||
...LQI_BASE_RSP,
|
||||
},
|
||||
]);
|
||||
devices.unsupported_router.lqi.mockRejectedValueOnce("failed").mockRejectedValueOnce("failed");
|
||||
devices.unsupported_router.routingTable.mockRejectedValueOnce("failed").mockRejectedValueOnce("failed");
|
||||
};
|
||||
@@ -143,6 +164,9 @@ describe("Extension: NetworkMap", () => {
|
||||
value: {
|
||||
links: [
|
||||
{
|
||||
deviceType: 0,
|
||||
rxOnWhenIdle: 0,
|
||||
permitJoining: 0,
|
||||
depth: 1,
|
||||
linkquality: 120,
|
||||
lqi: 120,
|
||||
@@ -155,11 +179,14 @@ describe("Extension: NetworkMap", () => {
|
||||
targetIeeeAddr: "0x00124b00120144ae",
|
||||
},
|
||||
{
|
||||
deviceType: 0,
|
||||
rxOnWhenIdle: 0,
|
||||
permitJoining: 0,
|
||||
depth: 1,
|
||||
linkquality: 92,
|
||||
lqi: 92,
|
||||
relationship: 2,
|
||||
routes: [{destinationAddress: 6540, nextHop: 40369, status: "ACTIVE"}],
|
||||
routes: [{destinationAddress: 6540, nextHopAddress: 40369, status: "ACTIVE", ...ROUTING_BASE_RSP}],
|
||||
source: {ieeeAddr: "0x000b57fffec6a5b2", networkAddress: 40369},
|
||||
sourceIeeeAddr: "0x000b57fffec6a5b2",
|
||||
sourceNwkAddr: 40369,
|
||||
@@ -167,6 +194,9 @@ describe("Extension: NetworkMap", () => {
|
||||
targetIeeeAddr: "0x00124b00120144ae",
|
||||
},
|
||||
{
|
||||
deviceType: 0,
|
||||
rxOnWhenIdle: 0,
|
||||
permitJoining: 0,
|
||||
depth: 1,
|
||||
linkquality: 92,
|
||||
lqi: 92,
|
||||
@@ -179,6 +209,9 @@ describe("Extension: NetworkMap", () => {
|
||||
targetIeeeAddr: "0x00124b00120144ae",
|
||||
},
|
||||
{
|
||||
deviceType: 0,
|
||||
rxOnWhenIdle: 0,
|
||||
permitJoining: 0,
|
||||
depth: 2,
|
||||
linkquality: 110,
|
||||
lqi: 110,
|
||||
@@ -191,6 +224,9 @@ describe("Extension: NetworkMap", () => {
|
||||
targetIeeeAddr: "0x000b57fffec6a5b2",
|
||||
},
|
||||
{
|
||||
deviceType: 0,
|
||||
rxOnWhenIdle: 0,
|
||||
permitJoining: 0,
|
||||
depth: 2,
|
||||
linkquality: 100,
|
||||
lqi: 100,
|
||||
@@ -203,6 +239,9 @@ describe("Extension: NetworkMap", () => {
|
||||
targetIeeeAddr: "0x000b57fffec6a5b2",
|
||||
},
|
||||
{
|
||||
deviceType: 0,
|
||||
rxOnWhenIdle: 0,
|
||||
permitJoining: 0,
|
||||
depth: 2,
|
||||
linkquality: 130,
|
||||
lqi: 130,
|
||||
@@ -247,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: [],
|
||||
@@ -328,23 +367,34 @@ describe("Extension: NetworkMap", () => {
|
||||
|
||||
it("Output graphviz networkmap", async () => {
|
||||
mock();
|
||||
const device = devices.bulb_color;
|
||||
device.lastSeen = undefined;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
const data = {modelID: "test"};
|
||||
const payload = {data, cluster: "genOnOff", device, endpoint, type: "readResponse", linkquality: 10};
|
||||
await mockZHEvents.message(payload);
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/networkmap", stringify({type: "graphviz", routes: true}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bridge/response/networkmap");
|
||||
const z2mDevice = controller.zigbee.resolveEntity(devices.bulb_color.ieeeAddr);
|
||||
const originalDef = z2mDevice.definition;
|
||||
z2mDevice.definition = {
|
||||
...originalDef,
|
||||
description: `${originalDef.description} 6"`,
|
||||
};
|
||||
|
||||
try {
|
||||
const device = devices.bulb_color;
|
||||
device.lastSeen = undefined;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
const data = {modelID: "test"};
|
||||
const payload = {data, cluster: "genOnOff", device, endpoint, type: "readResponse", linkquality: 10};
|
||||
await mockZHEvents.message(payload);
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/networkmap", stringify({type: "graphviz", routes: true}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bridge/response/networkmap");
|
||||
} finally {
|
||||
z2mDevice.definition = originalDef;
|
||||
}
|
||||
|
||||
const expected = `digraph G {
|
||||
node[shape=record];
|
||||
"0x00124b00120144ae" [style="bold, filled", fillcolor="#e04e5d", fontcolor="#ffffff", label="{Coordinator|0x00124b00120144ae (0x0000)|0 seconds ago}"];
|
||||
"0x000b57fffec6a5b2" [style="rounded, filled", fillcolor="#4ea3e0", fontcolor="#ffffff", label="{bulb|0x000b57fffec6a5b2 (0x9db1)|IKEA TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm (LED1545G12)|9 seconds ago}"];
|
||||
"0x000b57fffec6a5b2" -> "0x00124b00120144ae" [penwidth=2, weight=1, color="#009900", label="92 (routes: 0x198c)"]
|
||||
"0x000b57fffec6a5b3" [style="rounded, filled", fillcolor="#4ea3e0", fontcolor="#ffffff", label="{bulb_color|0x000b57fffec6a5b3 (0x9dcf)|Philips Hue Go (7146060PH)|unknown}"];
|
||||
"0x000b57fffec6a5b3" [style="rounded, filled", fillcolor="#4ea3e0", fontcolor="#ffffff", label="{bulb_color|0x000b57fffec6a5b3 (0x9dcf)|Philips Hue Go 6\\" (7146060PH)|unknown}"];
|
||||
"0x000b57fffec6a5b3" -> "0x00124b00120144ae" [penwidth=0.5, weight=0, color="#994444", label="120"]
|
||||
"0x000b57fffec6a5b3" -> "0x000b57fffec6a5b2" [penwidth=0.5, weight=0, color="#994444", label="110"]
|
||||
"0x0017880104e45521" [style="rounded, dashed, filled", fillcolor="#fff8ce", fontcolor="#000000", label="{button_double_key|0x0017880104e45521 (0x198a)|Aqara Wireless remote switch (double rocker), 2016 model (WXKG02LM_rev1)|9 seconds ago}"];
|
||||
@@ -492,6 +542,9 @@ describe("Extension: NetworkMap", () => {
|
||||
value: {
|
||||
links: [
|
||||
{
|
||||
deviceType: 0,
|
||||
rxOnWhenIdle: 0,
|
||||
permitJoining: 0,
|
||||
depth: 1,
|
||||
linkquality: 120,
|
||||
lqi: 120,
|
||||
@@ -504,11 +557,14 @@ describe("Extension: NetworkMap", () => {
|
||||
targetIeeeAddr: "0x00124b00120144ae",
|
||||
},
|
||||
{
|
||||
deviceType: 0,
|
||||
rxOnWhenIdle: 0,
|
||||
permitJoining: 0,
|
||||
depth: 1,
|
||||
linkquality: 92,
|
||||
lqi: 92,
|
||||
relationship: 2,
|
||||
routes: [{destinationAddress: 6540, nextHop: 40369, status: "ACTIVE"}],
|
||||
routes: [{destinationAddress: 6540, nextHopAddress: 40369, status: "ACTIVE", ...ROUTING_BASE_RSP}],
|
||||
source: {ieeeAddr: "0x000b57fffec6a5b2", networkAddress: 40369},
|
||||
sourceIeeeAddr: "0x000b57fffec6a5b2",
|
||||
sourceNwkAddr: 40369,
|
||||
@@ -516,6 +572,9 @@ describe("Extension: NetworkMap", () => {
|
||||
targetIeeeAddr: "0x00124b00120144ae",
|
||||
},
|
||||
{
|
||||
deviceType: 0,
|
||||
rxOnWhenIdle: 0,
|
||||
permitJoining: 0,
|
||||
depth: 1,
|
||||
linkquality: 92,
|
||||
lqi: 92,
|
||||
@@ -528,6 +587,9 @@ describe("Extension: NetworkMap", () => {
|
||||
targetIeeeAddr: "0x00124b00120144ae",
|
||||
},
|
||||
{
|
||||
deviceType: 0,
|
||||
rxOnWhenIdle: 0,
|
||||
permitJoining: 0,
|
||||
depth: 2,
|
||||
linkquality: 130,
|
||||
lqi: 130,
|
||||
@@ -554,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: [],
|
||||
@@ -649,6 +711,9 @@ describe("Extension: NetworkMap", () => {
|
||||
value: {
|
||||
links: [
|
||||
{
|
||||
deviceType: 0,
|
||||
rxOnWhenIdle: 0,
|
||||
permitJoining: 0,
|
||||
depth: 1,
|
||||
linkquality: 120,
|
||||
lqi: 120,
|
||||
@@ -661,11 +726,14 @@ describe("Extension: NetworkMap", () => {
|
||||
targetIeeeAddr: "0x00124b00120144ae",
|
||||
},
|
||||
{
|
||||
deviceType: 0,
|
||||
rxOnWhenIdle: 0,
|
||||
permitJoining: 0,
|
||||
depth: 1,
|
||||
linkquality: 92,
|
||||
lqi: 92,
|
||||
relationship: 2,
|
||||
routes: [{destinationAddress: 6540, nextHop: 40369, status: "ACTIVE"}],
|
||||
routes: [{destinationAddress: 6540, nextHopAddress: 40369, status: "ACTIVE", ...ROUTING_BASE_RSP}],
|
||||
source: {ieeeAddr: "0x000b57fffec6a5b2", networkAddress: 40369},
|
||||
sourceIeeeAddr: "0x000b57fffec6a5b2",
|
||||
sourceNwkAddr: 40369,
|
||||
@@ -673,6 +741,9 @@ describe("Extension: NetworkMap", () => {
|
||||
targetIeeeAddr: "0x00124b00120144ae",
|
||||
},
|
||||
{
|
||||
deviceType: 0,
|
||||
rxOnWhenIdle: 0,
|
||||
permitJoining: 0,
|
||||
depth: 1,
|
||||
linkquality: 92,
|
||||
lqi: 92,
|
||||
@@ -685,6 +756,9 @@ describe("Extension: NetworkMap", () => {
|
||||
targetIeeeAddr: "0x00124b00120144ae",
|
||||
},
|
||||
{
|
||||
deviceType: 0,
|
||||
rxOnWhenIdle: 0,
|
||||
permitJoining: 0,
|
||||
depth: 2,
|
||||
linkquality: 130,
|
||||
lqi: 130,
|
||||
@@ -711,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: [],
|
||||
|
||||
+899
-237
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++) {
|
||||
|
||||
+11
-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;
|
||||
@@ -26,6 +25,9 @@ export const DEFAULT_CONFIGURATION = {
|
||||
"0x18fc2600000d7ae2": {
|
||||
friendly_name: "bosch_radiator",
|
||||
},
|
||||
"0x18fc2600000d7ae3": {
|
||||
friendly_name: "bosch_rm230z",
|
||||
},
|
||||
"0x000b57fffec6a5b2": {
|
||||
retain: true,
|
||||
friendly_name: "bulb",
|
||||
@@ -275,6 +277,10 @@ export function stateExists(): boolean {
|
||||
return fs.existsSync(stateFile);
|
||||
}
|
||||
|
||||
export function readState(): Record<string, unknown> {
|
||||
return JSON.parse(fs.readFileSync(stateFile, "utf8"));
|
||||
}
|
||||
|
||||
const defaultState = {
|
||||
"0x000b57fffec6a5b2": {
|
||||
state: "ON",
|
||||
@@ -294,8 +300,8 @@ export function getDefaultState(): typeof defaultState {
|
||||
return defaultState;
|
||||
}
|
||||
|
||||
export function writeDefaultState(): void {
|
||||
fs.writeFileSync(path.join(mockDir, "state.json"), stringify(defaultState));
|
||||
export function writeDefaultState(data: Record<string, unknown> = defaultState): void {
|
||||
fs.writeFileSync(stateFile, stringify(data));
|
||||
}
|
||||
|
||||
export function writeEmptyDatabase(): void {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import assert from "node:assert";
|
||||
import type {Mock} from "vitest";
|
||||
import {type Mock, vi} from "vitest";
|
||||
import type {AdapterTypes} from "zigbee-herdsman";
|
||||
|
||||
import {Zcl} from "zigbee-herdsman";
|
||||
import {InterviewState} from "zigbee-herdsman/dist/controller/model/device";
|
||||
import type {OtaDataSettings, OtaExtraMetas, OtaSource, OtaUpdateAvailableResult} from "zigbee-herdsman/dist/controller/tstype";
|
||||
import type {TClusterCommandPayload} from "zigbee-herdsman/dist/zspec/zcl/definition/clusters-types";
|
||||
import type {BindingTableEntry, LQITableEntry, RoutingTableEntry} from "zigbee-herdsman/dist/zspec/zdo/definition/tstypes";
|
||||
import {DEFAULT_BIND_GROUP_ID} from "../../lib/util/utils";
|
||||
import type {EventHandler} from "./utils";
|
||||
|
||||
@@ -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,
|
||||
@@ -104,6 +107,7 @@ export class Endpoint {
|
||||
unbind: Mock;
|
||||
save: Mock;
|
||||
configureReporting: Mock;
|
||||
readReportingConfig: Mock;
|
||||
meta: Record<string, unknown>;
|
||||
binds: ZHBind[];
|
||||
profileID: number | undefined;
|
||||
@@ -138,6 +142,7 @@ export class Endpoint {
|
||||
this.unbind = vi.fn();
|
||||
this.save = vi.fn();
|
||||
this.configureReporting = vi.fn();
|
||||
this.readReportingConfig = vi.fn();
|
||||
this.meta = meta;
|
||||
this.binds = binds;
|
||||
this.profileID = profileID;
|
||||
@@ -218,6 +223,7 @@ export class Endpoint {
|
||||
this.unbind.mockClear();
|
||||
this.save.mockClear();
|
||||
this.configureReporting.mockClear();
|
||||
this.readReportingConfig.mockClear();
|
||||
this.addToGroup.mockClear();
|
||||
this.removeFromGroup.mockClear();
|
||||
this.getClusterAttributeValue.mockClear();
|
||||
@@ -247,8 +253,39 @@ export class Device {
|
||||
lastSeen: number | undefined;
|
||||
isDeleted: boolean;
|
||||
linkquality?: number;
|
||||
lqi: Mock;
|
||||
routingTable: Mock;
|
||||
lqi: Mock<() => Promise<LQITableEntry[]>>;
|
||||
routingTable: Mock<() => Promise<RoutingTableEntry[]>>;
|
||||
bindingTable: Mock<() => Promise<BindingTableEntry[]>>;
|
||||
clearAllBindings: Mock<() => Promise<void>>;
|
||||
checkOta =
|
||||
vi.fn<
|
||||
(
|
||||
source: OtaSource,
|
||||
current: TClusterCommandPayload<"genOta", "queryNextImageRequest"> | undefined,
|
||||
extraMetas: OtaExtraMetas,
|
||||
endpoint: Endpoint,
|
||||
) => Promise<OtaUpdateAvailableResult>
|
||||
>();
|
||||
updateOta =
|
||||
vi.fn<
|
||||
(
|
||||
source: Readonly<OtaSource> | undefined,
|
||||
requestPayload: TClusterCommandPayload<"genOta", "queryNextImageRequest"> | undefined,
|
||||
requestTsn: number | undefined,
|
||||
extraMetas: Readonly<OtaExtraMetas>,
|
||||
onProgress: (progress: number, remaining: number) => void,
|
||||
dataSettings: OtaDataSettings,
|
||||
endpoint: Endpoint,
|
||||
) => Promise<[from: OtaUpdateAvailableResult["current"], to: OtaUpdateAvailableResult["current"] | undefined]>
|
||||
>();
|
||||
scheduleOta = vi.fn((source) => {
|
||||
this.scheduledOta = source;
|
||||
});
|
||||
unscheduleOta = vi.fn(() => {
|
||||
this.scheduledOta = undefined;
|
||||
});
|
||||
abortOta = vi.fn();
|
||||
scheduledOta: OtaSource | undefined = undefined;
|
||||
|
||||
constructor(
|
||||
type: string,
|
||||
@@ -285,8 +322,10 @@ export class Device {
|
||||
this.manufacturerName = manufacturerName;
|
||||
this.lastSeen = 1000;
|
||||
this.isDeleted = false;
|
||||
this.lqi = vi.fn(() => ({neighbors: []}));
|
||||
this.routingTable = vi.fn(() => ({table: []}));
|
||||
this.lqi = vi.fn(() => Promise.resolve([] as LQITableEntry[]));
|
||||
this.routingTable = vi.fn(() => Promise.resolve([] as RoutingTableEntry[]));
|
||||
this.bindingTable = vi.fn(() => Promise.resolve([] as BindingTableEntry[]));
|
||||
this.clearAllBindings = vi.fn(() => {});
|
||||
}
|
||||
|
||||
getEndpoint(ID: number): Endpoint | undefined {
|
||||
@@ -302,7 +341,15 @@ export class Device {
|
||||
this.save.mockClear();
|
||||
this.lqi.mockClear();
|
||||
this.routingTable.mockClear();
|
||||
this.bindingTable.mockClear();
|
||||
this.clearAllBindings.mockClear();
|
||||
this.checkOta.mockClear();
|
||||
this.updateOta.mockClear();
|
||||
this.scheduleOta.mockClear();
|
||||
this.unscheduleOta.mockClear();
|
||||
this.abortOta.mockClear();
|
||||
this.meta = {};
|
||||
this.scheduledOta = undefined;
|
||||
|
||||
for (const ep of this.endpoints) {
|
||||
ep.mockClear();
|
||||
@@ -1128,18 +1175,33 @@ export const devices = {
|
||||
"DC Source",
|
||||
"FanBee1",
|
||||
),
|
||||
BTH_RM230Z: new Device(
|
||||
"Router",
|
||||
"0x18fc2600000d7ae3",
|
||||
35902,
|
||||
4617,
|
||||
[new Endpoint(1, [0, 3, 513, 516, 1029, 2821], [10, 25], "0x18fc2600000d7ae3")],
|
||||
InterviewState.Successful,
|
||||
"Mains (single phase)",
|
||||
"RBSH-RTH0-ZB-EU",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
CUSTOM_CLUSTER_BTHRA,
|
||||
),
|
||||
};
|
||||
|
||||
export const mockController = {
|
||||
on: (type: string, handler: EventHandler): void => {
|
||||
events[type] = handler;
|
||||
},
|
||||
start: vi.fn((): Promise<AdapterTypes.StartResult> => Promise.resolve("reset")),
|
||||
start: vi.fn(async (abortSignal?: AbortSignal): Promise<AdapterTypes.StartResult> => {
|
||||
abortSignal?.throwIfAborted();
|
||||
|
||||
return await Promise.resolve("reset");
|
||||
}),
|
||||
stop: vi.fn(),
|
||||
touchlinkIdentify: vi.fn(),
|
||||
touchlinkScan: vi.fn(),
|
||||
touchlinkFactoryReset: vi.fn(),
|
||||
touchlinkFactoryResetFirst: vi.fn(),
|
||||
touchlink: {identify: vi.fn(), scan: vi.fn(), factoryReset: vi.fn(), factoryResetFirst: vi.fn()},
|
||||
addInstallCode: vi.fn(),
|
||||
permitJoin: vi.fn(),
|
||||
getPermitJoin: vi.fn((): boolean => false),
|
||||
@@ -1195,6 +1257,9 @@ export const mockController = {
|
||||
groups[`group_${groupID}` as keyof typeof groups] = group;
|
||||
return group;
|
||||
}),
|
||||
sendRaw: vi.fn(async (/*rawPayload: RawPayload, customClusters: CustomClusters = {}*/) =>
|
||||
Promise.resolve([0x00, {nwkAddress: 0x1234, eui64: "", startIndex: 0, assocDevList: []}]),
|
||||
),
|
||||
};
|
||||
|
||||
vi.mock("zigbee-herdsman", async (importOriginal) => ({
|
||||
|
||||
@@ -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}});
|
||||
});
|
||||
});
|
||||
+623
-149
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