mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-08-28 22:48:20 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"image": "mcr.microsoft.com/devcontainers/javascript-node:24",
|
||||
|
||||
"postCreateCommand": "pnpm config set store-dir /home/node/.local/share/pnpm/store && npm install typescript -g",
|
||||
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"settings": {
|
||||
"workbench.colorTheme": "Default Dark Modern",
|
||||
"window.menuBarVisibility": "classic",
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"notebook.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnPaste": true,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.tabSize": 4,
|
||||
"editor.insertSpaces": true,
|
||||
"files.defaultLanguage": "typescript",
|
||||
"files.eol": "\n"
|
||||
},
|
||||
"extensions": ["biomejs.biome", "vitest.explorer"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ abstract class Extension {
|
||||
protected state: State;
|
||||
protected publishEntityState: PublishEntityState;
|
||||
protected eventBus: EventBus;
|
||||
|
||||
|
||||
async start(): Promise<void> {}
|
||||
async stop(): Promise<void> {}
|
||||
}
|
||||
@@ -181,9 +181,9 @@ logger.debug("message");
|
||||
- Use TypeScript's strict mode features (`noImplicitAny`, `noImplicitThis`)
|
||||
|
||||
### Performance
|
||||
- Use `rimrafSync` for synchronous file deletion when appropriate
|
||||
- Use `fs.rmSync(path, {recursive: true, force: true})` for synchronous file deletion when appropriate
|
||||
- Leverage async/await for I/O operations to avoid blocking
|
||||
- Use JSON stable stringify for consistent object serialization: `json-stable-stringify-without-jsonify`
|
||||
- Use JSON stable stringify util for consistent object serialization
|
||||
- Cache computed values when appropriate (see device model patterns)
|
||||
- Use getter methods for computed properties that should be cached
|
||||
|
||||
@@ -217,10 +217,10 @@ describe("ComponentName", () => {
|
||||
it("Should do something specific", async () => {
|
||||
// Arrange
|
||||
const input = {};
|
||||
|
||||
|
||||
// Act
|
||||
const result = await someFunction(input);
|
||||
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
@@ -414,7 +414,7 @@ this.eventBus.on('deviceMessage', this.onDeviceMessage, this);
|
||||
- Device operations through `zigbee-herdsman` API
|
||||
- Event handling through EventBus wrappers
|
||||
|
||||
### MQTT Integration
|
||||
### MQTT Integration
|
||||
- Connect: `await this.mqtt.connect()`
|
||||
- Subscribe: `await this.mqtt.subscribe(topic)`
|
||||
- Publish: `await this.mqtt.publish(topic, message, options)`
|
||||
|
||||
@@ -19,8 +19,12 @@ updates:
|
||||
schedule:
|
||||
interval: weekly
|
||||
target-branch: dev
|
||||
commit-message:
|
||||
prefix: fix(ignore)
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
target-branch: dev
|
||||
commit-message:
|
||||
prefix: chore
|
||||
|
||||
+15
-15
@@ -22,18 +22,18 @@ jobs:
|
||||
actions: read # for Nerivec/action-ci-bench
|
||||
pull-requests: write # for Nerivec/action-ci-bench
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
|
||||
with:
|
||||
# Required for `release: merge dev -> master and promote dev`
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
if: ((github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push') == false
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
registry-url: https://registry.npmjs.org/
|
||||
@@ -63,14 +63,14 @@ jobs:
|
||||
|
||||
- name: Log in to the Docker container registry
|
||||
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: koenkk
|
||||
password: ${{ secrets.DOCKER_KEY }}
|
||||
|
||||
- name: Log in to the GitHub container registry
|
||||
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: koenkk
|
||||
@@ -78,20 +78,20 @@ jobs:
|
||||
|
||||
- name: Docker setup - QEMU
|
||||
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: all
|
||||
|
||||
- name: Docker setup - Buildx
|
||||
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- name: dev - Docker build and push
|
||||
if: github.ref == 'refs/heads/dev' && github.event_name == 'push'
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
|
||||
- name: release - Docker meta
|
||||
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
id: meta
|
||||
with:
|
||||
images: |
|
||||
@@ -139,7 +139,7 @@ jobs:
|
||||
|
||||
- name: release - Docker build and push
|
||||
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
@@ -216,15 +216,15 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
node: [20, 22, 24]
|
||||
node: [22, 24, 26]
|
||||
runs-on: ${{ matrix.os }}
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: pnpm
|
||||
|
||||
@@ -10,6 +10,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'Checkout repository'
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- name: 'Dependency review'
|
||||
uses: actions/dependency-review-action@v4
|
||||
uses: actions/dependency-review-action@v5
|
||||
|
||||
@@ -10,8 +10,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Fail PR to master
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
run: |
|
||||
if [[ "${{ github.event.pull_request.title }}" == "chore(dev): release"* ]]; then
|
||||
if [[ "$PR_TITLE" == "chore(dev): release"* ]]; then
|
||||
echo "PR title starts with 'chore(dev): release', allowing PR"
|
||||
else
|
||||
echo "Pull requests to the master branch are not allowed, target dev branch"
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Delete untagged images
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
|
||||
@@ -17,13 +17,13 @@ jobs:
|
||||
startsWith(github.event.issue.title, '[New device support]') ||
|
||||
startsWith(github.event.issue.title, '[External Converter]')
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
sparse-checkout: |
|
||||
scripts
|
||||
path: z2m
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: Koenkk/zigbee-herdsman-converters
|
||||
ref: master
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
path: zhc
|
||||
|
||||
- name: Comment on new device support/external converter issue
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const {newDeviceSupport} = await import("${{ github.workspace }}/z2m/scripts/issueBot.mjs")
|
||||
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
merge-master-to-dev:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: devmasx/merge-branch@master
|
||||
with:
|
||||
type: now
|
||||
|
||||
@@ -16,33 +16,33 @@ jobs:
|
||||
release_created: ${{ steps.release.outputs.release_created }}
|
||||
version: '${{steps.release.outputs.major}}.${{steps.release.outputs.minor}}.${{steps.release.outputs.patch}}'
|
||||
steps:
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- uses: googleapis/release-please-action@v4
|
||||
- uses: googleapis/release-please-action@v5
|
||||
id: release
|
||||
with:
|
||||
target-branch: dev
|
||||
token: ${{secrets.GH_TOKEN}}
|
||||
|
||||
# Checkout repos
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: koenkk/zigbee2mqtt
|
||||
path: ./z2m
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: koenkk/zigbee2mqtt
|
||||
path: ./z2m-master
|
||||
ref: master
|
||||
|
||||
- name: Restore cache commit-user-lookup.json
|
||||
uses: actions/cache/restore@v5
|
||||
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@v5
|
||||
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,12 +13,12 @@ jobs:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: dev
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "2.9.0"
|
||||
".": "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.
|
||||
|
||||
+248
@@ -1,5 +1,253 @@
|
||||
# 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)
|
||||
|
||||
|
||||
|
||||
+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).
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.1/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", "!!**/dist", "!!**/coverage"]
|
||||
"includes": ["**", "!package.json", "!!**/dist", "!!**/coverage", "!images"]
|
||||
},
|
||||
"linter": {
|
||||
"includes": ["**"],
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
FROM alpine:3.23 AS base
|
||||
FROM alpine:3.24 AS base
|
||||
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const fs = require("node:fs");
|
||||
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;
|
||||
@@ -12,7 +12,7 @@ let unsolicitedStop = false;
|
||||
let watchdogDelays = [2000, 60000, 300000, 900000, 1800000, 3600000];
|
||||
|
||||
if (process.env.Z2M_WATCHDOG != null && process.env.Z2M_WATCHDOG !== "default") {
|
||||
if (/^\d+(.\d+)?(,\d+(.\d+)?)*$/.test(process.env.Z2M_WATCHDOG)) {
|
||||
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'.`);
|
||||
|
||||
+28
-20
@@ -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,6 +23,7 @@ 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";
|
||||
|
||||
@@ -50,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,
|
||||
@@ -62,22 +61,29 @@ 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> {
|
||||
@@ -247,10 +253,12 @@ export class Controller {
|
||||
}
|
||||
|
||||
const existingExtension = this.getExtension(name);
|
||||
|
||||
if (existingExtension) {
|
||||
await this.removeExtension(existingExtension);
|
||||
}
|
||||
await this.extensions.add(extension);
|
||||
|
||||
this.extensions.add(extension);
|
||||
} else {
|
||||
switch (name) {
|
||||
case "Frontend": {
|
||||
@@ -454,7 +462,7 @@ 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"])) {
|
||||
@@ -472,7 +480,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);
|
||||
}
|
||||
}
|
||||
|
||||
+6
-5
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
+50
-24
@@ -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 {zip} from "fflate";
|
||||
import objectAssignDeep from "object-assign-deep";
|
||||
import type winston from "winston";
|
||||
import Transport from "winston-transport";
|
||||
@@ -15,6 +14,7 @@ import type {Zigbee2MQTTAPI, Zigbee2MQTTDevice, Zigbee2MQTTResponse, Zigbee2MQTT
|
||||
import data from "../util/data";
|
||||
import logger from "../util/logger";
|
||||
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";
|
||||
|
||||
@@ -141,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();
|
||||
|
||||
@@ -243,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) {
|
||||
@@ -262,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});
|
||||
}
|
||||
@@ -321,7 +329,7 @@ export default class Bridge extends Extension {
|
||||
await this.zigbee.backup();
|
||||
const dataPath = data.getPath();
|
||||
const files = utils.getAllFiles(dataPath);
|
||||
const zip = new JSZip();
|
||||
const zipFiles: Record<string, Uint8Array> = {};
|
||||
const logDir = `log${path.sep}`;
|
||||
const otaDir = `ota${path.sep}`;
|
||||
|
||||
@@ -330,12 +338,17 @@ export default class Bridge extends Extension {
|
||||
|
||||
// XXX: `log` could technically be something else depending on `log_directory` setting
|
||||
if (!name.startsWith(logDir) && !name.startsWith(otaDir)) {
|
||||
zip.file(name, fs.readFileSync(f));
|
||||
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">> {
|
||||
@@ -464,12 +477,16 @@ export default class Bridge extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
const restartRequired = settings.changeEntityOptions(ID, message.options);
|
||||
if (restartRequired) this.restartRequired = true;
|
||||
const newRestartRequired = settings.changeEntityOptions(ID, message.options);
|
||||
if (newRestartRequired) this.restartRequired = true;
|
||||
const newOptions = cleanup(entity.options);
|
||||
await this.publishInfo();
|
||||
|
||||
logger.info(`Changed config for ${entityType} ${ID}`);
|
||||
if (newRestartRequired) {
|
||||
logger.info(`New config for ${entityType} ${ID} requires restart to take effect`);
|
||||
} else {
|
||||
logger.info(`Successfully changed config for ${entityType} ${ID}`);
|
||||
}
|
||||
|
||||
this.eventBus.emitEntityOptionsChanged({from: oldOptions, to: newOptions, entity});
|
||||
return utils.getResponse(message, {from: oldOptions, to: newOptions, id: ID, restart_required: this.restartRequired});
|
||||
@@ -653,20 +670,25 @@ 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 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;
|
||||
clearCache = !!payload.clear_cache;
|
||||
blockForceLog = ` (block: ${block}, force: ${force}, clear cache: ${clearCache})`;
|
||||
} else if (entityType === "group" && messageIsObject) {
|
||||
const payload = message as Zigbee2MQTTAPI["bridge/request/group/remove"];
|
||||
force = !!payload.force;
|
||||
blockForceLog = ` (force: ${force})`;
|
||||
}
|
||||
|
||||
@@ -679,9 +701,13 @@ 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);
|
||||
}
|
||||
|
||||
if (clearCache) {
|
||||
this.zigbee.removeDeviceFromLookup(entity.ID);
|
||||
}
|
||||
|
||||
settings.removeDevice(entity.ID as string);
|
||||
@@ -706,19 +732,18 @@ 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, clear_cache: clearCache};
|
||||
|
||||
return utils.getResponse(message, responseData);
|
||||
}
|
||||
|
||||
await this.publishGroups();
|
||||
|
||||
const responseData: Zigbee2MQTTAPI["bridge/response/group/remove"] = {id: ID, force};
|
||||
|
||||
return utils.getResponse(
|
||||
@@ -900,6 +925,7 @@ export default class Bridge extends Extension {
|
||||
exposes: device.exposes(),
|
||||
supports_ota: !!device.definition.ota,
|
||||
options: device.definition.options ?? [],
|
||||
version: device.definition.version,
|
||||
icon,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
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";
|
||||
|
||||
@@ -38,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 {
|
||||
@@ -137,7 +137,7 @@ export default class Configure extends Extension {
|
||||
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) {
|
||||
const newAttempts = attempts + 1;
|
||||
this.attempts.set(device.ieeeAddr, newAttempts);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -5,16 +5,14 @@ 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 {stringify} from "../util/stringify";
|
||||
import utils from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
@@ -157,10 +155,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";
|
||||
|
||||
|
||||
+274
-143
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,10 @@
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import type {Eui64} from "zigbee-herdsman/dist/zspec/tstypes";
|
||||
import type {LQITableEntry, RoutingTableEntry} from "zigbee-herdsman/dist/zspec/zdo/definition/tstypes";
|
||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTNetworkMap} from "../types/api";
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import {stringify} from "../util/stringify";
|
||||
import utils from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
|
||||
+36
-19
@@ -2,7 +2,6 @@ import assert from "node:assert";
|
||||
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 {setOtaConfiguration, Zcl} from "zigbee-herdsman";
|
||||
import type {OtaDataSettings, OtaSource, OtaUpdateAvailableResult} from "zigbee-herdsman/dist/controller/tstype";
|
||||
import Device from "../model/device";
|
||||
@@ -10,6 +9,7 @@ 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";
|
||||
|
||||
@@ -50,7 +50,7 @@ function writeFirmwareHexToDataDir(hex: string, fileName: string | undefined, de
|
||||
|
||||
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",
|
||||
);
|
||||
#inProgress = new Set<string>();
|
||||
@@ -248,6 +248,7 @@ 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"];
|
||||
@@ -258,18 +259,31 @@ export default class OTAUpdate extends Extension {
|
||||
assert(message.id, "Invalid payload");
|
||||
}
|
||||
|
||||
const ID = (messageObject ? message.id : message) as string;
|
||||
const device = this.zigbee.resolveEntity(ID);
|
||||
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`;
|
||||
error = `Device '${id}' does not exist`;
|
||||
} 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 = `OTA update or check for update already in progress for '${device.name}'`;
|
||||
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": {
|
||||
@@ -284,11 +298,11 @@ export default class OTAUpdate extends Extension {
|
||||
|
||||
if (payload.url) {
|
||||
source.url = payload.url;
|
||||
} else if (!device.definition || !device.definition.ota) {
|
||||
} else if (!device.definition?.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
break;
|
||||
}
|
||||
} else if (!device.definition || !device.definition.ota) {
|
||||
} else if (!device.definition?.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
break;
|
||||
}
|
||||
@@ -304,7 +318,7 @@ export default class OTAUpdate extends Extension {
|
||||
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,
|
||||
@@ -321,6 +335,11 @@ export default class OTAUpdate extends Extension {
|
||||
}
|
||||
|
||||
case "update": {
|
||||
if (abort) {
|
||||
error = `No OTA in progress to abort for device '${device.name}'`;
|
||||
break;
|
||||
}
|
||||
|
||||
this.#inProgress.add(device.ieeeAddr);
|
||||
|
||||
const otaSettings = settings.get().ota;
|
||||
@@ -344,7 +363,7 @@ export default class OTAUpdate extends Extension {
|
||||
source.url = writeFirmwareHexToDataDir(payload.hex.data, payload.hex.file_name, device.ieeeAddr);
|
||||
} else if (payload.url) {
|
||||
source.url = payload.url;
|
||||
} else if (!device.definition || !device.definition.ota) {
|
||||
} else if (!device.definition?.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
break;
|
||||
}
|
||||
@@ -360,7 +379,7 @@ export default class OTAUpdate extends Extension {
|
||||
if (payload.default_maximum_data_size) {
|
||||
dataSettings.baseSize = payload.default_maximum_data_size;
|
||||
}
|
||||
} else if (!device.definition || !device.definition.ota) {
|
||||
} else if (!device.definition?.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
break;
|
||||
}
|
||||
@@ -378,7 +397,7 @@ export default class OTAUpdate extends Extension {
|
||||
|
||||
const firmwareTo = await this.#readSoftwareBuildIDAndDateCode(device);
|
||||
const response = utils.getResponse<"bridge/response/device/ota_update/update">(message, {
|
||||
id: ID,
|
||||
id,
|
||||
from: {
|
||||
file_version: fromVersion,
|
||||
software_build_id: firmwareFrom?.softwareBuildID,
|
||||
@@ -415,11 +434,11 @@ export default class OTAUpdate extends Extension {
|
||||
source.url = writeFirmwareHexToDataDir(payload.hex.data, payload.hex.file_name, device.ieeeAddr);
|
||||
} else if (payload.url) {
|
||||
source.url = payload.url;
|
||||
} else if (!device.definition || !device.definition.ota) {
|
||||
} else if (!device.definition?.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
break;
|
||||
}
|
||||
} else if (!device.definition || !device.definition.ota) {
|
||||
} else if (!device.definition?.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
break;
|
||||
}
|
||||
@@ -427,7 +446,7 @@ export default class OTAUpdate extends Extension {
|
||||
device.zh.scheduleOta(source);
|
||||
await this.publishEntityState(device, this.#getEntityPublishPayload(device, "scheduled"));
|
||||
|
||||
const response = utils.getResponse<"bridge/response/device/ota_update/schedule">(message, {id: ID, url: source.url});
|
||||
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));
|
||||
|
||||
@@ -442,9 +461,7 @@ export default class OTAUpdate extends Extension {
|
||||
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));
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
+4
-1
@@ -18,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;
|
||||
@@ -64,9 +65,11 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+18
-2
@@ -1,7 +1,7 @@
|
||||
import type * as zigbeeHerdsman from "zigbee-herdsman/dist";
|
||||
import type {ZclPayload} from "zigbee-herdsman/dist/adapter/events";
|
||||
import type {Eui64} from "zigbee-herdsman/dist/zspec/tstypes";
|
||||
import type {ClusterDefinition, ClusterName, CustomClusters} from "zigbee-herdsman/dist/zspec/zcl/definition/tstype";
|
||||
import type {Cluster, ClusterName, CustomClusters} from "zigbee-herdsman/dist/zspec/zcl/definition/tstype";
|
||||
import type {GenericZdoResponse, RoutingTableEntry} from "zigbee-herdsman/dist/zspec/zdo/definition/tstypes";
|
||||
import type * as zigbeeHerdsmanConverter from "zigbee-herdsman-converters";
|
||||
import type {Base} from "zigbee-herdsman-converters/lib/exposes";
|
||||
@@ -133,6 +133,7 @@ export interface Zigbee2MQTTSettings {
|
||||
cert?: string;
|
||||
client_id?: string;
|
||||
reject_unauthorized?: boolean;
|
||||
server_name?: string;
|
||||
maximum_packet_size: number;
|
||||
};
|
||||
serial: {
|
||||
@@ -215,6 +216,8 @@ 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 */
|
||||
@@ -259,6 +262,7 @@ export interface Zigbee2MQTTDeviceDefinition {
|
||||
exposes: zigbeeHerdsmanConverter.Expose[];
|
||||
supports_ota: boolean;
|
||||
options: zigbeeHerdsmanConverter.Option[];
|
||||
version: `0.0.${number}`;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
@@ -343,7 +347,7 @@ export interface Zigbee2MQTTAPI {
|
||||
};
|
||||
|
||||
"bridge/definitions": {
|
||||
clusters: Readonly<Record<ClusterName, Readonly<ClusterDefinition>>>;
|
||||
clusters: Readonly<Record<ClusterName, Cluster>>;
|
||||
custom_clusters: Record<string, CustomClusters>;
|
||||
actions: string[];
|
||||
};
|
||||
@@ -618,12 +622,14 @@ export interface Zigbee2MQTTAPI {
|
||||
id: string;
|
||||
block?: boolean;
|
||||
force?: boolean;
|
||||
clear_cache?: boolean;
|
||||
};
|
||||
|
||||
"bridge/response/device/remove": {
|
||||
id: string;
|
||||
block: boolean;
|
||||
force: boolean;
|
||||
clear_cache: boolean;
|
||||
};
|
||||
|
||||
"bridge/request/device/ota_update/check": {
|
||||
@@ -674,6 +680,14 @@ export interface Zigbee2MQTTAPI {
|
||||
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:
|
||||
@@ -998,6 +1012,7 @@ export type Zigbee2MQTTRequestEndpoints =
|
||||
| "bridge/request/device/ota_update/check/downgrade"
|
||||
| "bridge/request/device/ota_update/update"
|
||||
| "bridge/request/device/ota_update/update/downgrade"
|
||||
| "bridge/request/device/ota_update/update/abort"
|
||||
| "bridge/request/device/ota_update/schedule"
|
||||
| "bridge/request/device/ota_update/schedule/downgrade"
|
||||
| "bridge/request/device/ota_update/unschedule"
|
||||
@@ -1047,6 +1062,7 @@ export type Zigbee2MQTTResponseEndpoints =
|
||||
| "bridge/response/device/remove"
|
||||
| "bridge/response/device/ota_update/check"
|
||||
| "bridge/response/device/ota_update/update"
|
||||
| "bridge/response/device/ota_update/update/abort"
|
||||
| "bridge/response/device/ota_update/schedule"
|
||||
| "bridge/response/device/ota_update/unschedule"
|
||||
| "bridge/response/device/interview"
|
||||
|
||||
Vendored
-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 */
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
declare module "json-stable-stringify-without-jsonify" {
|
||||
export default function (obj: unknown): string;
|
||||
}
|
||||
Vendored
+1
-1
@@ -6,7 +6,7 @@ declare module "zigbee2mqtt-frontend" {
|
||||
export default frontend;
|
||||
}
|
||||
|
||||
declare module "http" {
|
||||
declare module "node:http" {
|
||||
interface IncomingMessage {
|
||||
originalUrl?: string;
|
||||
path?: string;
|
||||
|
||||
+1
-2
@@ -2,7 +2,6 @@ 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";
|
||||
|
||||
@@ -235,7 +234,7 @@ class Logger {
|
||||
for (const dir of directories) {
|
||||
this.debug(`Removing old log directory '${dir.path}'`);
|
||||
try {
|
||||
rimrafSync(dir.path);
|
||||
fs.rmSync(dir.path, {recursive: true, force: true});
|
||||
} catch (e) {
|
||||
this.error(`Failed to remove old log directory '${dir.path}': ${e}`);
|
||||
}
|
||||
|
||||
+11
-9
@@ -3,11 +3,11 @@ import type {ServerResponse} from "node:http";
|
||||
import {createServer} from "node:http";
|
||||
import path from "node:path";
|
||||
import expressStaticGzip from "express-static-gzip";
|
||||
import {type Unzipped, unzip} from "fflate";
|
||||
import finalhandler from "finalhandler";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import JSZip from "jszip";
|
||||
import {findAllDevices} from "zigbee-herdsman/dist/adapter/adapterDiscovery";
|
||||
import type {OnboardData, OnboardFailureData, OnboardSubmitResponse, Zigbee2MQTTSettings} from "../types/api";
|
||||
import {stringify} from "../util/stringify";
|
||||
import data from "./data";
|
||||
import * as settings from "./settings";
|
||||
import {YAMLFileException} from "./yaml";
|
||||
@@ -49,20 +49,22 @@ function getZipEntryTargetPath(entryName: string): string {
|
||||
}
|
||||
|
||||
async function extractZipDataToDataPath(zipContent: Buffer): Promise<void> {
|
||||
const zip = await JSZip.loadAsync(zipContent);
|
||||
const entries = await new Promise<Unzipped>((resolve, reject) => {
|
||||
unzip(zipContent, (error, data) => (error ? reject(error) : resolve(data)));
|
||||
});
|
||||
|
||||
for (const key in zip.files) {
|
||||
const entry = zip.files[key];
|
||||
const targetPath = getZipEntryTargetPath(entry.name);
|
||||
for (const name in entries) {
|
||||
const targetPath = getZipEntryTargetPath(name);
|
||||
|
||||
if (entry.dir) {
|
||||
// directory entries are identified by a trailing slash
|
||||
if (name.endsWith("/")) {
|
||||
mkdirSync(targetPath, {recursive: true});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
mkdirSync(path.dirname(targetPath), {recursive: true});
|
||||
writeFileSync(targetPath, await entry.async("nodebuffer"));
|
||||
writeFileSync(targetPath, entries[name]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +111,7 @@ async function startOnboardingServer(): Promise<boolean> {
|
||||
const redirect =
|
||||
!process.env.Z2M_ONBOARD_NO_REDIRECT &&
|
||||
appliedSettings.frontend.enabled &&
|
||||
(!appliedSettings.frontend.host || !appliedSettings.frontend.host.startsWith("/"));
|
||||
!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}`
|
||||
|
||||
@@ -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",
|
||||
@@ -739,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": {
|
||||
@@ -800,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
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -848,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",
|
||||
@@ -953,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"],
|
||||
|
||||
@@ -113,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,
|
||||
@@ -167,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,
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// 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 {
|
||||
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[1]}"` : array[1]}`;
|
||||
}
|
||||
|
||||
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;
|
||||
separator = ",";
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-4
@@ -1,6 +1,5 @@
|
||||
import {randomInt} from "node:crypto";
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import type {Events as ZHEvents} from "zigbee-herdsman";
|
||||
import {Controller} from "zigbee-herdsman";
|
||||
import type {StartResult} from "zigbee-herdsman/dist/adapter/tstype";
|
||||
@@ -9,6 +8,7 @@ import Group from "./model/group";
|
||||
import data from "./util/data";
|
||||
import logger from "./util/logger";
|
||||
import * as settings from "./util/settings";
|
||||
import {stringify} from "./util/stringify";
|
||||
import utils from "./util/utils";
|
||||
|
||||
const entityIDRegex = /^(.+?)(?:\/([^/]+))?$/;
|
||||
@@ -61,7 +61,7 @@ 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;
|
||||
@@ -467,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);
|
||||
}
|
||||
}
|
||||
|
||||
+20
-24
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "zigbee2mqtt",
|
||||
"version": "2.9.0",
|
||||
"version": "2.13.0",
|
||||
"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,40 @@
|
||||
},
|
||||
"homepage": "https://koenkk.github.io/zigbee2mqtt",
|
||||
"dependencies": {
|
||||
"ajv": "^8.18.0",
|
||||
"ajv": "^8.20.0",
|
||||
"bind-decorator": "^1.0.11",
|
||||
"debounce": "^3.0.0",
|
||||
"express-static-gzip": "^3.0.0",
|
||||
"express-static-gzip": "^3.0.1",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fflate": "^0.8.3",
|
||||
"finalhandler": "^2.1.1",
|
||||
"humanize-duration": "^3.33.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||
"jszip": "^3.10.1",
|
||||
"mqtt": "^5.15.0",
|
||||
"humanize-duration": "^3.34.0",
|
||||
"js-yaml": "^5.2.2",
|
||||
"mqtt": "^5.15.2",
|
||||
"object-assign-deep": "^0.4.0",
|
||||
"rimraf": "^6.1.3",
|
||||
"semver": "^7.7.4",
|
||||
"source-map-support": "^0.5.21",
|
||||
"throttleit": "^2.1.0",
|
||||
"semver": "^7.8.5",
|
||||
"throttleit": "^3.0.0",
|
||||
"winston": "^3.19.0",
|
||||
"winston-syslog": "^2.7.1",
|
||||
"winston-transport": "^4.9.0",
|
||||
"ws": "^8.19.0",
|
||||
"zigbee-herdsman": "9.0.9",
|
||||
"zigbee-herdsman-converters": "26.12.0",
|
||||
"ws": "^8.21.1",
|
||||
"zigbee-herdsman": "10.8.0",
|
||||
"zigbee-herdsman-converters": "26.90.0",
|
||||
"zigbee2mqtt-frontend": "0.9.21",
|
||||
"zigbee2mqtt-windfront": "2.9.2"
|
||||
"zigbee2mqtt-windfront": "2.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.4",
|
||||
"@biomejs/biome": "^2.5.3",
|
||||
"@types/finalhandler": "^1.2.3",
|
||||
"@types/humanize-duration": "^3.27.4",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^24.11.0",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/object-assign-deep": "^0.4.3",
|
||||
"@types/readable-stream": "4.0.23",
|
||||
"@types/readable-stream": "4.0.24",
|
||||
"@types/serve-static": "^2.2.0",
|
||||
"@types/ws": "8.18.1",
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
"tmp": "^0.2.5",
|
||||
"typescript": "^5.9.3",
|
||||
"tmp": "^0.2.7",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^3.1.1"
|
||||
},
|
||||
"pnpm": {
|
||||
|
||||
Generated
+435
-424
File diff suppressed because it is too large
Load Diff
+13
-23
@@ -1,4 +1,12 @@
|
||||
import {execSync} from "node:child_process";
|
||||
import {execFileSync} from "node:child_process";
|
||||
|
||||
function grepTypeScriptFiles(needle, dir) {
|
||||
try {
|
||||
return execFileSync("grep", ["-r", "-F", "--include=*.ts", "--", needle, dir], {encoding: "utf8"});
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkDuplicateIssue(github, context, name) {
|
||||
// Search for existing issues with the same `name`
|
||||
@@ -63,13 +71,7 @@ export async function newDeviceSupport(github, _core, context, zhcDir) {
|
||||
if (tuyaManufacturerNames.length > 0) {
|
||||
for (const [fullName, partialName] of tuyaManufacturerNames) {
|
||||
if (await checkDuplicateIssue(github, context, fullName)) return;
|
||||
const fullMatch = (() => {
|
||||
try {
|
||||
return execSync(`grep -r --include="*.ts" "${fullName}" "${zhcDir}"`, {encoding: "utf8"});
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
const fullMatch = grepTypeScriptFiles(fullName, zhcDir);
|
||||
|
||||
console.log(`Checking full match for '${fullName}', result: '${fullMatch}'`);
|
||||
if (fullMatch) {
|
||||
@@ -94,13 +96,7 @@ If you need help with the process, feel free to ask here and we'll be happy to a
|
||||
return;
|
||||
}
|
||||
|
||||
const partialMatch = (() => {
|
||||
try {
|
||||
return execSync(`grep -r --include="*.ts" "${partialName}" "${zhcDir}"`, {encoding: "utf8"});
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
const partialMatch = grepTypeScriptFiles(partialName, zhcDir);
|
||||
|
||||
console.log(`Checking partial match for '${partialName}', result: '${partialMatch}'`);
|
||||
if (partialMatch) {
|
||||
@@ -129,14 +125,8 @@ Let us know if it works so we can support this device out-of-the-box!`,
|
||||
|
||||
if (zigbeeModels.length > 0) {
|
||||
for (const zigbeeModel of zigbeeModels) {
|
||||
if (await checkDuplicateIssue(github, context, fullName)) return;
|
||||
const fullMatch = (() => {
|
||||
try {
|
||||
return execSync(`grep -r --include="*.ts" '"${zigbeeModel}"' "${zhcDir}"`, {encoding: "utf8"});
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
if (await checkDuplicateIssue(github, context, zigbeeModel)) return;
|
||||
const fullMatch = grepTypeScriptFiles(`"${zigbeeModel}"`, zhcDir);
|
||||
|
||||
if (fullMatch) {
|
||||
await github.rest.issues.createComment({
|
||||
|
||||
@@ -18,5 +18,5 @@ const sandbox = {
|
||||
};
|
||||
vm.runInNewContext(moduleCode, sandbox, moduleFakePath);
|
||||
const converter = sandbox.module.exports;
|
||||
assert(!converter.toZigbee || !converter.toZigbee.includes(undefined));
|
||||
assert(!converter.fromZigbee || !converter.fromZigbee.includes(undefined));
|
||||
assert(!converter.toZigbee?.includes(undefined));
|
||||
assert(!converter.fromZigbee?.includes(undefined));
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {existsSync, mkdirSync} from "node:fs";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {bench, describe, vi} from "vitest";
|
||||
import {type Controller, Zcl, Zdo, ZSpec} from "zigbee-herdsman";
|
||||
import type Adapter from "zigbee-herdsman/dist/adapter/adapter";
|
||||
@@ -12,6 +11,7 @@ import type {DeviceType} from "zigbee-herdsman/dist/controller/tstype";
|
||||
import {Foundation} from "zigbee-herdsman/dist/zspec/zcl/definition/foundation";
|
||||
import type {RequestToResponseMap} from "zigbee-herdsman/dist/zspec/zdo/definition/tstypes";
|
||||
import data from "../lib/util/data";
|
||||
import {stringify} from "../lib/util/stringify";
|
||||
import {BENCH_OPTIONS} from "./benchOptions";
|
||||
|
||||
vi.doMock("zigbee-herdsman", async (importOriginal) => {
|
||||
@@ -257,7 +257,7 @@ const adapter = {
|
||||
switch (zclFrame.command.ID) {
|
||||
case Foundation.read.ID: {
|
||||
for (const attr of zclFrame.payload) {
|
||||
const attribute = zclFrame.cluster.getAttribute(attr.attrId);
|
||||
const attribute = Zcl.Utils.getClusterAttribute(zclFrame.cluster, attr.attrId, undefined);
|
||||
|
||||
if (attribute && attribute.type !== Zcl.DataType.NO_DATA && attribute.type < Zcl.DataType.OCTET_STR) {
|
||||
payload.push({
|
||||
@@ -421,7 +421,6 @@ const initController = async () => {
|
||||
disconnecting: false,
|
||||
disconnected: false,
|
||||
endAsync: async () => {},
|
||||
// @ts-expect-error Z2M does not make use of return
|
||||
publishAsync: async () => {},
|
||||
};
|
||||
controller.mqtt.connect = async () => {
|
||||
|
||||
@@ -17,9 +17,9 @@ 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";
|
||||
@@ -112,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", {
|
||||
@@ -149,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,
|
||||
};
|
||||
@@ -166,6 +167,7 @@ describe("Controller", () => {
|
||||
username: "user1",
|
||||
clientId: "my_client_id",
|
||||
rejectUnauthorized: false,
|
||||
servername: "mqtt.example.com",
|
||||
protocolVersion: 5,
|
||||
properties: {maximumPacketSize: 20000},
|
||||
};
|
||||
|
||||
+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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
+196
-27
@@ -2,7 +2,7 @@
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
|
||||
import {Zdo} from "zigbee-herdsman";
|
||||
import * as data from "../mocks/data";
|
||||
import {mockJSZipFile, mockJSZipGenerateAsync} from "../mocks/jszip";
|
||||
import {mockFflateZip, mockFflateZipFailOnce} from "../mocks/fflate";
|
||||
import {mockLogger} from "../mocks/logger";
|
||||
import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
|
||||
import {flushPromises} from "../mocks/utils";
|
||||
@@ -12,7 +12,7 @@ import assert from "node:assert";
|
||||
import fs from "node:fs";
|
||||
import {platform} from "node:os";
|
||||
import path from "node:path";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {stringify} from "../../lib/util/stringify";
|
||||
import type {Mock} from "vitest";
|
||||
import {Controller} from "../../lib/controller";
|
||||
import Bridge from "../../lib/extension/bridge";
|
||||
@@ -144,6 +144,7 @@ describe("Extension: Bridge", () => {
|
||||
output: "json",
|
||||
pan_id: 6754,
|
||||
timestamp_format: "YYYY-MM-DD HH:mm:ss",
|
||||
enable_external_js: true,
|
||||
},
|
||||
blocklist: [],
|
||||
device_options: {},
|
||||
@@ -597,6 +598,7 @@ describe("Extension: Bridge", () => {
|
||||
],
|
||||
supports_ota: true,
|
||||
vendor: "IKEA",
|
||||
version: "0.0.0",
|
||||
},
|
||||
description: "this is my bulb",
|
||||
disabled: false,
|
||||
@@ -734,7 +736,7 @@ describe("Extension: Bridge", () => {
|
||||
values: ["off", "on", "toggle", "previous"],
|
||||
},
|
||||
{
|
||||
access: 2,
|
||||
access: 3,
|
||||
label: "Effect",
|
||||
name: "effect",
|
||||
property: "effect",
|
||||
@@ -747,11 +749,41 @@ describe("Extension: Bridge", () => {
|
||||
"candle",
|
||||
"fireplace",
|
||||
"colorloop",
|
||||
"sunset",
|
||||
"sunrise",
|
||||
"sparkle",
|
||||
"opal",
|
||||
"glisten",
|
||||
"underwater",
|
||||
"cosmos",
|
||||
"sunbeam",
|
||||
"enchant",
|
||||
"none",
|
||||
"finish_effect",
|
||||
"stop_effect",
|
||||
"stop_hue_effect",
|
||||
],
|
||||
},
|
||||
{
|
||||
access: 3,
|
||||
description: "Animation speed for the active effect (0=slowest, 1=fastest)",
|
||||
label: "Effect speed",
|
||||
name: "effect_speed",
|
||||
property: "effect_speed",
|
||||
type: "numeric",
|
||||
value_max: 1,
|
||||
value_min: 0,
|
||||
value_step: 0.01,
|
||||
},
|
||||
{
|
||||
access: 2,
|
||||
description:
|
||||
'Set the base color of the active effect without stopping it (hex e.g. #FF4400, or JSON {"x":0.6,"y":0.3})',
|
||||
label: "Effect color",
|
||||
name: "effect_color",
|
||||
property: "effect_color",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
access: 1,
|
||||
category: "diagnostic",
|
||||
@@ -767,6 +799,27 @@ describe("Extension: Bridge", () => {
|
||||
],
|
||||
model: "7146060PH",
|
||||
options: [
|
||||
{
|
||||
access: 2,
|
||||
description:
|
||||
"Control this light using a Philips-specific protocol instead of standard Zigbee commands. When enabled, on/off, brightness, color, and color temperature are combined into single atomic commands. This is required to use the Effect color update mode. When disabled (default), standard Zigbee commands are used, which preserves the usual behavior, including simulating on/off transitions.",
|
||||
label: "Hue native control",
|
||||
name: "hue_native_control",
|
||||
property: "hue_native_control",
|
||||
type: "binary",
|
||||
value_off: false,
|
||||
value_on: true,
|
||||
},
|
||||
{
|
||||
access: 2,
|
||||
description:
|
||||
"Controls what happens when color is changed while an effect is active (requires Hue native control). 'stop' (default): color change stops the effect (Hue app behavior). 'update': color change re-sends the effect with the new color.",
|
||||
label: "Effect color mode",
|
||||
name: "effect_color_mode",
|
||||
property: "effect_color_mode",
|
||||
type: "enum",
|
||||
values: ["stop", "update"],
|
||||
},
|
||||
{
|
||||
access: 2,
|
||||
description:
|
||||
@@ -778,6 +831,16 @@ describe("Extension: Bridge", () => {
|
||||
value_min: 0,
|
||||
value_step: 0.1,
|
||||
},
|
||||
{
|
||||
access: 2,
|
||||
description: "State actions will also be published as 'action' when true (default false).",
|
||||
label: "State action",
|
||||
name: "state_action",
|
||||
property: "state_action",
|
||||
type: "binary",
|
||||
value_off: false,
|
||||
value_on: true,
|
||||
},
|
||||
{
|
||||
access: 2,
|
||||
description:
|
||||
@@ -789,19 +852,10 @@ describe("Extension: Bridge", () => {
|
||||
value_off: false,
|
||||
value_on: true,
|
||||
},
|
||||
{
|
||||
access: 2,
|
||||
description: "State actions will also be published as 'action' when true (default false).",
|
||||
label: "State action",
|
||||
name: "state_action",
|
||||
property: "state_action",
|
||||
type: "binary",
|
||||
value_off: false,
|
||||
value_on: true,
|
||||
},
|
||||
],
|
||||
supports_ota: true,
|
||||
vendor: "Philips",
|
||||
version: "0.0.0",
|
||||
},
|
||||
disabled: false,
|
||||
endpoints: {
|
||||
@@ -831,7 +885,7 @@ describe("Extension: Bridge", () => {
|
||||
{
|
||||
definition: {
|
||||
source: "native",
|
||||
description: "Hue dimmer switch",
|
||||
description: "Hue dimmer switch gen 1",
|
||||
exposes: [
|
||||
{
|
||||
access: 1,
|
||||
@@ -930,6 +984,7 @@ describe("Extension: Bridge", () => {
|
||||
],
|
||||
supports_ota: true,
|
||||
vendor: "Philips",
|
||||
version: "0.0.0",
|
||||
},
|
||||
disabled: false,
|
||||
endpoints: {
|
||||
@@ -1038,6 +1093,7 @@ describe("Extension: Bridge", () => {
|
||||
],
|
||||
supports_ota: false,
|
||||
vendor: "notSupportedMfg",
|
||||
version: "0.0.0",
|
||||
},
|
||||
disabled: false,
|
||||
endpoints: {
|
||||
@@ -1143,6 +1199,7 @@ describe("Extension: Bridge", () => {
|
||||
],
|
||||
supports_ota: false,
|
||||
vendor: "Aqara",
|
||||
version: "0.0.0",
|
||||
},
|
||||
disabled: false,
|
||||
endpoints: {
|
||||
@@ -1304,6 +1361,7 @@ describe("Extension: Bridge", () => {
|
||||
],
|
||||
supports_ota: false,
|
||||
vendor: "Aqara",
|
||||
version: "0.0.0",
|
||||
},
|
||||
disabled: false,
|
||||
endpoints: {"1": {bindings: [], clusters: {input: ["genBasic"], output: []}, configured_reportings: [], scenes: []}},
|
||||
@@ -1455,6 +1513,7 @@ describe("Extension: Bridge", () => {
|
||||
],
|
||||
supports_ota: true,
|
||||
vendor: "Xiaomi",
|
||||
version: "0.0.0",
|
||||
},
|
||||
disabled: false,
|
||||
endpoints: {"1": {bindings: [], clusters: {input: ["genBasic", "genOnOff"], output: []}, configured_reportings: [], scenes: []}},
|
||||
@@ -1914,6 +1973,7 @@ describe("Extension: Bridge", () => {
|
||||
],
|
||||
supports_ota: false,
|
||||
vendor: "Siglis",
|
||||
version: "0.0.0",
|
||||
},
|
||||
disabled: false,
|
||||
endpoints: {
|
||||
@@ -2204,6 +2264,7 @@ describe("Extension: Bridge", () => {
|
||||
],
|
||||
supports_ota: true,
|
||||
vendor: "IKEA",
|
||||
version: "0.0.0",
|
||||
},
|
||||
disabled: false,
|
||||
endpoints: {
|
||||
@@ -2639,6 +2700,7 @@ describe("Extension: Bridge", () => {
|
||||
],
|
||||
supports_ota: true,
|
||||
vendor: "IKEA",
|
||||
version: "0.0.0",
|
||||
},
|
||||
friendly_name: "bulb",
|
||||
ieee_address: "0x000b57fffec6a5b2",
|
||||
@@ -2725,6 +2787,7 @@ describe("Extension: Bridge", () => {
|
||||
],
|
||||
supports_ota: false,
|
||||
vendor: "notSupportedMfg",
|
||||
version: "0.0.0",
|
||||
},
|
||||
friendly_name: "0x0017880104e45518",
|
||||
ieee_address: "0x0017880104e45518",
|
||||
@@ -2743,7 +2806,7 @@ describe("Extension: Bridge", () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
await mockZHEvents.deviceLeave({ieeeAddr: devices.bulb.ieeeAddr});
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(3);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(4);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/event",
|
||||
stringify({type: "device_leave", data: {ieee_address: "0x000b57fffec6a5b2", friendly_name: "bulb"}}),
|
||||
@@ -2756,6 +2819,7 @@ describe("Extension: Bridge", () => {
|
||||
expect.any(String),
|
||||
{retain: true},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/groups", expect.any(String), {retain: true});
|
||||
});
|
||||
|
||||
it("Should allow permit join on all", async () => {
|
||||
@@ -2910,6 +2974,7 @@ describe("Extension: Bridge", () => {
|
||||
|
||||
it("Should allow to remove device by string", async () => {
|
||||
const device = devices.bulb;
|
||||
const removeSpy = vi.spyOn(controller.zigbee, "removeDeviceFromLookup");
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/remove", "bulb");
|
||||
await flushPromises();
|
||||
@@ -2918,11 +2983,12 @@ describe("Extension: Bridge", () => {
|
||||
expect(device.removeFromNetwork).toHaveBeenCalledTimes(1);
|
||||
expect(device.removeFromDatabase).not.toHaveBeenCalled();
|
||||
expect(settings.getDevice("bulb")).toBeUndefined();
|
||||
expect(removeSpy).not.toHaveBeenCalled();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb", "", {retain: true});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/remove",
|
||||
stringify({data: {id: "bulb", block: false, force: false}, status: "ok"}),
|
||||
stringify({data: {id: "bulb", block: false, force: false, clear_cache: false}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
expect(settings.get().blocklist).toStrictEqual([]);
|
||||
@@ -2932,52 +2998,76 @@ describe("Extension: Bridge", () => {
|
||||
|
||||
it("Should allow to remove device by object ID", async () => {
|
||||
const device = devices.bulb;
|
||||
const removeSpy = vi.spyOn(controller.zigbee, "removeDeviceFromLookup");
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/remove", stringify({id: "bulb"}));
|
||||
await flushPromises();
|
||||
expect(device.removeFromNetwork).toHaveBeenCalledTimes(1);
|
||||
expect(device.removeFromDatabase).not.toHaveBeenCalled();
|
||||
expect(settings.getDevice("bulb")).toBeUndefined();
|
||||
expect(removeSpy).not.toHaveBeenCalled();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/remove",
|
||||
stringify({data: {id: "bulb", block: false, force: false}, status: "ok"}),
|
||||
stringify({data: {id: "bulb", block: false, force: false, clear_cache: false}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("Should allow to force remove device", async () => {
|
||||
const device = devices.bulb;
|
||||
const removeSpy = vi.spyOn(controller.zigbee, "removeDeviceFromLookup");
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/remove", stringify({id: "bulb", force: true}));
|
||||
await flushPromises();
|
||||
expect(device.removeFromDatabase).toHaveBeenCalledTimes(1);
|
||||
expect(device.removeFromNetwork).not.toHaveBeenCalled();
|
||||
expect(settings.getDevice("bulb")).toBeUndefined();
|
||||
expect(removeSpy).not.toHaveBeenCalled();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/remove",
|
||||
stringify({data: {id: "bulb", block: false, force: true}, status: "ok"}),
|
||||
stringify({data: {id: "bulb", block: false, force: true, clear_cache: false}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("Should allow to block device", async () => {
|
||||
const device = devices.bulb;
|
||||
const removeSpy = vi.spyOn(controller.zigbee, "removeDeviceFromLookup");
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/remove", stringify({id: "bulb", block: true, force: true}));
|
||||
await flushPromises();
|
||||
expect(device.removeFromDatabase).toHaveBeenCalledTimes(1);
|
||||
expect(settings.getDevice("bulb")).toBeUndefined();
|
||||
expect(removeSpy).not.toHaveBeenCalled();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/remove",
|
||||
stringify({data: {id: "bulb", block: true, force: true}, status: "ok"}),
|
||||
stringify({data: {id: "bulb", block: true, force: true, clear_cache: false}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
expect(settings.get().blocklist).toStrictEqual(["0x000b57fffec6a5b2"]);
|
||||
});
|
||||
|
||||
it("Should allow to clear cache when removing device", async () => {
|
||||
const device = devices.bulb;
|
||||
const removeSpy = vi.spyOn(controller.zigbee, "removeDeviceFromLookup");
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/remove", stringify({id: "bulb", clear_cache: true}));
|
||||
await flushPromises();
|
||||
expect(device.removeFromNetwork).toHaveBeenCalledTimes(1);
|
||||
expect(device.removeFromDatabase).not.toHaveBeenCalled();
|
||||
expect(settings.getDevice("bulb")).toBeUndefined();
|
||||
expect(removeSpy).toHaveNthReturnedWith(1, true);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/remove",
|
||||
stringify({data: {id: "bulb", block: false, force: false, clear_cache: true}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("Should allow to remove group", async () => {
|
||||
const group = groups.group_1;
|
||||
const removeGroupFromLookup = vi.spyOn(controller.zigbee, "removeGroupFromLookup");
|
||||
@@ -3042,7 +3132,11 @@ describe("Extension: Bridge", () => {
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/remove",
|
||||
stringify({data: {}, status: "error", error: "Failed to remove device 'bulb' (block: false, force: false) (Error: device timeout)"}),
|
||||
stringify({
|
||||
data: {},
|
||||
status: "error",
|
||||
error: "Failed to remove device 'bulb' (block: false, force: false, clear cache: false) (Error: device timeout)",
|
||||
}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
@@ -4099,13 +4193,24 @@ describe("Extension: Bridge", () => {
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/backup", "");
|
||||
await flushPromises();
|
||||
expect(mockZHController.backup).toHaveBeenCalledTimes(1);
|
||||
expect(mockJSZipFile).toHaveBeenCalledTimes(4);
|
||||
expect(mockJSZipFile).toHaveBeenNthCalledWith(1, "configuration.yaml", expect.any(Object));
|
||||
expect(mockJSZipFile).toHaveBeenNthCalledWith(2, path.join("ext_converters", "123", "myfile.js"), expect.any(Object));
|
||||
expect(mockJSZipFile).toHaveBeenNthCalledWith(3, path.join("ext_converters", "afile.js"), expect.any(Object));
|
||||
expect(mockJSZipFile).toHaveBeenNthCalledWith(4, "state.json", expect.any(Object));
|
||||
expect(mockJSZipGenerateAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockJSZipGenerateAsync).toHaveBeenNthCalledWith(1, {type: "base64"});
|
||||
expect(mockFflateZip).toHaveBeenCalledTimes(1);
|
||||
expect(mockFflateZip).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{
|
||||
"configuration.yaml": expect.any(Buffer),
|
||||
[path.join("ext_converters", "123", "myfile.js")]: expect.any(Buffer),
|
||||
[path.join("ext_converters", "afile.js")]: expect.any(Buffer),
|
||||
"state.json": expect.any(Buffer),
|
||||
},
|
||||
{level: 6},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(Object.keys(mockFflateZip.mock.calls[0][0])).toStrictEqual([
|
||||
"configuration.yaml",
|
||||
path.join("ext_converters", "123", "myfile.js"),
|
||||
path.join("ext_converters", "afile.js"),
|
||||
"state.json",
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/backup",
|
||||
stringify({data: {zip: "THISISBASE64"}, status: "ok"}),
|
||||
@@ -4113,6 +4218,18 @@ describe("Extension: Bridge", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("Should return an error when the backup archive cannot be created", async () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockFflateZipFailOnce(new Error("invalid zip data"));
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/backup", "");
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/backup",
|
||||
stringify({data: {}, status: "error", error: "invalid zip data"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("Should allow to restart", async () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/restart", "");
|
||||
@@ -4262,6 +4379,58 @@ describe("Extension: Bridge", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("Change options consecutively, check restart required", async () => {
|
||||
settings.apply({health: {interval: 10, reset_on_check: false}});
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
// Change option that doesn't require restart
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/options", stringify({options: {health: {reset_on_check: true}}}));
|
||||
await flushPromises();
|
||||
|
||||
expect(settings.get().health.reset_on_check).toBe(true);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/options",
|
||||
stringify({data: {restart_required: false}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/info", expect.stringContaining('"restart_required":false'), {
|
||||
retain: true,
|
||||
});
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
// Change option that requires restart
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/options", stringify({options: {health: {interval: 11}}}));
|
||||
await flushPromises();
|
||||
|
||||
expect(settings.get().health.interval).toBe(11);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/options",
|
||||
stringify({data: {restart_required: true}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/info", expect.stringContaining('"restart_required":true'), {
|
||||
retain: true,
|
||||
});
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
// Change option that doesn't require restart
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/options", stringify({options: {health: {reset_on_check: false}}}));
|
||||
await flushPromises();
|
||||
|
||||
expect(settings.get().health.reset_on_check).toBe(false);
|
||||
|
||||
// System still requires restart
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/options",
|
||||
stringify({data: {restart_required: true}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/info", expect.stringContaining('"restart_required":true'), {
|
||||
retain: true,
|
||||
});
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
});
|
||||
|
||||
it("Icon link handling", () => {
|
||||
const bridge = controller.getExtension("Bridge")! as Bridge;
|
||||
expect(bridge).toBeDefined();
|
||||
|
||||
@@ -6,7 +6,7 @@ import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
|
||||
import {flushPromises} from "../mocks/utils";
|
||||
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";
|
||||
@@ -193,16 +193,28 @@ describe("Extension: Configure", () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -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,7 +7,7 @@ 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";
|
||||
@@ -245,6 +245,8 @@ describe("Extension: Frontend", () => {
|
||||
stringify({
|
||||
state: "ON",
|
||||
effect: null,
|
||||
effect_color: null,
|
||||
effect_speed: null,
|
||||
power_on_behavior: null,
|
||||
linkquality: 20,
|
||||
update: {state: null, installed_version: -1, latest_version: -1},
|
||||
@@ -269,6 +271,8 @@ describe("Extension: Frontend", () => {
|
||||
state: "ON",
|
||||
power_on_behavior: null,
|
||||
effect: null,
|
||||
effect_color: null,
|
||||
effect_speed: null,
|
||||
linkquality: 20,
|
||||
update: {state: null, installed_version: -1, latest_version: -1},
|
||||
},
|
||||
@@ -296,6 +300,8 @@ describe("Extension: Frontend", () => {
|
||||
brightness: 90,
|
||||
power_on_behavior: null,
|
||||
effect: null,
|
||||
effect_color: null,
|
||||
effect_speed: null,
|
||||
linkquality: 20,
|
||||
update: {state: null, installed_version: -1, latest_version: -1},
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {Device as ZhDevice} from "../mocks/zigbeeHerdsman";
|
||||
import {devices, groups, events as mockZHEvents} from "../mocks/zigbeeHerdsman";
|
||||
|
||||
import assert from "node:assert";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {stringify} from "../../lib/util/stringify";
|
||||
import type {MockInstance} from "vitest";
|
||||
import * as zhc from "zigbee-herdsman-converters";
|
||||
import type {KeyValueAny} from "zigbee-herdsman-converters/lib/types";
|
||||
@@ -103,6 +103,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
definition: d,
|
||||
isDevice: (): boolean => true,
|
||||
isGroup: (): boolean => false,
|
||||
endpoint: () => undefined,
|
||||
options: {},
|
||||
exposes: (): unknown[] => exposes,
|
||||
zh: {endpoints: []},
|
||||
@@ -124,7 +125,160 @@ describe("Extension: HomeAssistant", () => {
|
||||
}
|
||||
}
|
||||
|
||||
expect(duplicated).toHaveLength(0);
|
||||
expect(duplicated).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("Should mark thermostat configuration toggles as config entities", () => {
|
||||
const switchExposes = [
|
||||
new zhc.Switch().withLabel("Auto lock").withState("auto_lock", false, "Enable/disable auto lock", zhc.access.STATE_SET, "AUTO", "MANUAL"),
|
||||
new zhc.Switch().withLabel("Away mode").withState("away_mode", false, "Enable/disable away mode", zhc.access.STATE_SET),
|
||||
new zhc.Switch().withLabel("Valve detection").withState("valve_detection", true, "Valve detection", zhc.access.STATE_SET),
|
||||
new zhc.Switch()
|
||||
.withLabel("Window detection")
|
||||
.withState("window_detection", true, "Enables/disables window detection", zhc.access.STATE_SET),
|
||||
];
|
||||
const binaryExposes = [
|
||||
new zhc.Binary("frost_protection", zhc.access.STATE_SET, "ON", "OFF").withDescription("Anti-freeze protection"),
|
||||
new zhc.Binary("heating_stop", zhc.access.STATE_SET, "ON", "OFF").withDescription("Heating stop"),
|
||||
new zhc.Binary("away_mode", zhc.access.STATE_SET, "ON", "OFF").withDescription("Away mode"),
|
||||
new zhc.Binary("window_detection", zhc.access.STATE_SET, "ON", "OFF").withDescription("Open window detection"),
|
||||
];
|
||||
const getDiscoveryConfigs = (expose: zhc.Expose): KeyValueAny[] => {
|
||||
const device = {
|
||||
definition: {},
|
||||
isDevice: (): boolean => true,
|
||||
isGroup: (): boolean => false,
|
||||
endpoint: () => undefined,
|
||||
options: {},
|
||||
exposes: (): zhc.Expose[] => [expose],
|
||||
zh: {endpoints: []},
|
||||
};
|
||||
// @ts-expect-error private method and minimal test device
|
||||
return extension.getConfigs(device);
|
||||
};
|
||||
|
||||
for (const expose of switchExposes) {
|
||||
const [config] = getDiscoveryConfigs(expose);
|
||||
expect(config.type).toStrictEqual("switch");
|
||||
expect(config.object_id).toStrictEqual(expose.features[0].property);
|
||||
expect(config.discovery_payload.entity_category).toStrictEqual("config");
|
||||
expect(config.discovery_payload.command_topic_postfix).toStrictEqual(expose.features[0].property);
|
||||
}
|
||||
|
||||
for (const expose of binaryExposes) {
|
||||
const [config] = getDiscoveryConfigs(expose);
|
||||
expect(config.type).toStrictEqual("switch");
|
||||
expect(config.object_id).toStrictEqual(`switch_${expose.name}`);
|
||||
expect(config.discovery_payload.entity_category).toStrictEqual("config");
|
||||
expect(config.discovery_payload.command_topic_postfix).toStrictEqual(expose.property);
|
||||
}
|
||||
});
|
||||
|
||||
it("Should mark device settings as config entities", () => {
|
||||
const getDiscoveryConfigs = (expose: zhc.Expose): KeyValueAny[] => {
|
||||
const device = {
|
||||
definition: {},
|
||||
isDevice: (): boolean => true,
|
||||
isGroup: (): boolean => false,
|
||||
endpoint: () => undefined,
|
||||
options: {},
|
||||
exposes: (): zhc.Expose[] => [expose],
|
||||
zh: {endpoints: []},
|
||||
};
|
||||
// @ts-expect-error private method and minimal test device
|
||||
return extension.getConfigs(device);
|
||||
};
|
||||
|
||||
const enumExposes = [
|
||||
new zhc.Enum("set_limits", zhc.access.STATE_SET, ["START", "END", "RESET"]),
|
||||
new zhc.Enum("motor_direction", zhc.access.STATE_SET, ["forward", "back"]),
|
||||
new zhc.Enum("temperature_unit", zhc.access.STATE_SET, ["celsius", "fahrenheit"]),
|
||||
];
|
||||
|
||||
for (const expose of enumExposes) {
|
||||
const [config] = getDiscoveryConfigs(expose);
|
||||
expect(config.type).toStrictEqual("select");
|
||||
expect(config.object_id).toStrictEqual(expose.property);
|
||||
expect(config.discovery_payload.entity_category).toStrictEqual("config");
|
||||
}
|
||||
|
||||
const binaryExposes = [
|
||||
new zhc.Binary("tilt_mode", zhc.access.STATE_SET, "ON", "OFF"),
|
||||
new zhc.Binary("calibration_left", zhc.access.STATE_SET, "ON", "OFF"),
|
||||
new zhc.Binary("motor_reversal_right", zhc.access.STATE_SET, "ON", "OFF"),
|
||||
new zhc.Binary("enable_display", zhc.access.STATE_SET, "ON", "OFF"),
|
||||
new zhc.Binary("indicator", zhc.access.STATE_SET, "ON", "OFF"),
|
||||
];
|
||||
|
||||
for (const expose of binaryExposes) {
|
||||
const [config] = getDiscoveryConfigs(expose);
|
||||
expect(config.type).toStrictEqual("switch");
|
||||
expect(config.object_id).toStrictEqual(`switch_${expose.property}`);
|
||||
expect(config.discovery_payload.entity_category).toStrictEqual("config");
|
||||
}
|
||||
|
||||
const numericExposes = [
|
||||
new zhc.Numeric("calibration_time_left", zhc.access.STATE_SET),
|
||||
new zhc.Numeric("comfort_temperature_min", zhc.access.STATE_SET),
|
||||
new zhc.Numeric("comfort_humidity_max", zhc.access.STATE_SET),
|
||||
new zhc.Numeric("measurement_interval", zhc.access.STATE_SET),
|
||||
new zhc.Numeric("minimum_range", zhc.access.STATE_SET),
|
||||
new zhc.Numeric("maximum_range", zhc.access.STATE_SET),
|
||||
new zhc.Numeric("detection_delay", zhc.access.STATE_SET),
|
||||
new zhc.Numeric("fading_time", zhc.access.STATE_SET),
|
||||
new zhc.Numeric("large_motion_detection_sensitivity", zhc.access.STATE_SET),
|
||||
new zhc.Numeric("medium_motion_detection_distance", zhc.access.STATE_SET),
|
||||
new zhc.Numeric("small_detection_sensitivity", zhc.access.STATE_SET),
|
||||
new zhc.Numeric("soil_calibration", zhc.access.STATE_SET),
|
||||
new zhc.Numeric("soil_sampling", zhc.access.STATE_SET),
|
||||
new zhc.Numeric("soil_warning", zhc.access.STATE_SET),
|
||||
];
|
||||
|
||||
for (const expose of numericExposes) {
|
||||
const [config] = getDiscoveryConfigs(expose);
|
||||
expect(config.type).toStrictEqual("number");
|
||||
expect(config.object_id).toStrictEqual(expose.property);
|
||||
expect(config.discovery_payload.entity_category).toStrictEqual("config");
|
||||
}
|
||||
|
||||
const [textConfig] = getDiscoveryConfigs(new zhc.Text("schedule_settings", zhc.access.STATE_SET));
|
||||
expect(textConfig.type).toStrictEqual("text");
|
||||
expect(textConfig.object_id).toStrictEqual("schedule_settings");
|
||||
expect(textConfig.discovery_payload.entity_category).toStrictEqual("config");
|
||||
});
|
||||
|
||||
it("Should apply expose-level Home Assistant discovery metadata", () => {
|
||||
const createDevice = (exposes: zhc.Expose[]): Device =>
|
||||
({
|
||||
definition: {},
|
||||
isDevice: (): boolean => true,
|
||||
isGroup: (): boolean => false,
|
||||
endpoint: () => undefined,
|
||||
options: {},
|
||||
exposes: (): zhc.Expose[] => exposes,
|
||||
zh: {endpoints: []},
|
||||
}) as Device;
|
||||
|
||||
const voltageExpose = new zhc.Numeric("voltage", zhc.access.STATE).withUnit("V");
|
||||
Object.assign(voltageExpose, {
|
||||
homeassistant: {
|
||||
type: "valve",
|
||||
entityCategory: "diagnostic",
|
||||
deviceClass: "voltage",
|
||||
enabledByDefault: false,
|
||||
icon: "mdi:flash",
|
||||
},
|
||||
});
|
||||
|
||||
// @ts-expect-error private
|
||||
const configs = extension.getConfigs(createDevice([voltageExpose]));
|
||||
expect(configs.find((config) => config.object_id === "voltage")?.discovery_payload).toMatchObject({
|
||||
device_class: "voltage",
|
||||
enabled_by_default: false,
|
||||
entity_category: "diagnostic",
|
||||
icon: "mdi:flash",
|
||||
});
|
||||
expect(configs.find((config) => config.object_id === "voltage")?.discovery_payload).not.toHaveProperty("type");
|
||||
});
|
||||
|
||||
it("Should discover devices and groups", async () => {
|
||||
@@ -163,6 +317,16 @@ describe("Extension: HomeAssistant", () => {
|
||||
"candle",
|
||||
"fireplace",
|
||||
"colorloop",
|
||||
"sunset",
|
||||
"sunrise",
|
||||
"sparkle",
|
||||
"opal",
|
||||
"glisten",
|
||||
"underwater",
|
||||
"cosmos",
|
||||
"sunbeam",
|
||||
"enchant",
|
||||
"none",
|
||||
"finish_effect",
|
||||
"stop_effect",
|
||||
"stop_hue_effect",
|
||||
@@ -170,6 +334,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
object_id: "ha_discovery_group",
|
||||
default_entity_id: "light.ha_discovery_group",
|
||||
unique_id: "9_light_zigbee2mqtt",
|
||||
group: ["0x000b57fffec6a5b4_light_zigbee2mqtt", "0x000b57fffec6a5b7_light_zigbee2mqtt"],
|
||||
origin: origin,
|
||||
};
|
||||
|
||||
@@ -225,8 +390,9 @@ describe("Extension: HomeAssistant", () => {
|
||||
object_id: "ha_discovery_group",
|
||||
default_entity_id: "switch.ha_discovery_group",
|
||||
unique_id: "9_switch_zigbee2mqtt",
|
||||
group: ["0x0017880104e45542_switch_right_zigbee2mqtt"],
|
||||
origin: origin,
|
||||
value_template: "{{ value_json.state }}",
|
||||
value_template: '{{ value_json["state"] }}',
|
||||
};
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
@@ -239,7 +405,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
unit_of_measurement: "°C",
|
||||
device_class: "temperature",
|
||||
state_class: "measurement",
|
||||
value_template: "{{ value_json.temperature }}",
|
||||
value_template: '{{ value_json["temperature"] }}',
|
||||
state_topic: "zigbee2mqtt/weather_sensor",
|
||||
object_id: "weather_sensor_temperature",
|
||||
default_entity_id: "sensor.weather_sensor_temperature",
|
||||
@@ -266,7 +432,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
unit_of_measurement: "%",
|
||||
device_class: "humidity",
|
||||
state_class: "measurement",
|
||||
value_template: "{{ value_json.humidity }}",
|
||||
value_template: '{{ value_json["humidity"] }}',
|
||||
state_topic: "zigbee2mqtt/weather_sensor",
|
||||
object_id: "weather_sensor_humidity",
|
||||
default_entity_id: "sensor.weather_sensor_humidity",
|
||||
@@ -293,7 +459,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
unit_of_measurement: "hPa",
|
||||
device_class: "atmospheric_pressure",
|
||||
state_class: "measurement",
|
||||
value_template: "{{ value_json.pressure }}",
|
||||
value_template: '{{ value_json["pressure"] }}',
|
||||
state_topic: "zigbee2mqtt/weather_sensor",
|
||||
object_id: "weather_sensor_pressure",
|
||||
default_entity_id: "sensor.weather_sensor_pressure",
|
||||
@@ -320,7 +486,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
unit_of_measurement: "%",
|
||||
device_class: "battery",
|
||||
state_class: "measurement",
|
||||
value_template: "{{ value_json.battery }}",
|
||||
value_template: '{{ value_json["battery"] }}',
|
||||
state_topic: "zigbee2mqtt/weather_sensor",
|
||||
object_id: "weather_sensor_battery",
|
||||
default_entity_id: "sensor.weather_sensor_battery",
|
||||
@@ -350,7 +516,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
entity_category: "diagnostic",
|
||||
unit_of_measurement: "lqi",
|
||||
state_class: "measurement",
|
||||
value_template: "{{ value_json.linkquality }}",
|
||||
value_template: '{{ value_json["linkquality"] }}',
|
||||
state_topic: "zigbee2mqtt/weather_sensor",
|
||||
name: "Linkquality",
|
||||
object_id: "weather_sensor_linkquality",
|
||||
@@ -392,7 +558,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
default_entity_id: "switch.wall_switch_double_left",
|
||||
unique_id: "0x0017880104e45542_switch_left_zigbee2mqtt",
|
||||
origin: origin,
|
||||
value_template: "{{ value_json.state_left }}",
|
||||
value_template: '{{ value_json["state_left"] }}',
|
||||
};
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/switch/0x0017880104e45542/switch_left/config", stringify(payload), {
|
||||
@@ -419,7 +585,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
default_entity_id: "switch.wall_switch_double_right",
|
||||
unique_id: "0x0017880104e45542_switch_right_zigbee2mqtt",
|
||||
origin: origin,
|
||||
value_template: "{{ value_json.state_right }}",
|
||||
value_template: '{{ value_json["state_right"] }}',
|
||||
};
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/switch/0x0017880104e45542/switch_right/config", stringify(payload), {
|
||||
@@ -522,7 +688,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
unit_of_measurement: "%",
|
||||
device_class: "humidity",
|
||||
state_class: "measurement",
|
||||
value_template: "{{ value_json.humidity }}",
|
||||
value_template: '{{ value_json["humidity"] }}',
|
||||
state_topic: "zigbee2mqtt/weather_sensor",
|
||||
object_id: "weather_sensor_humidity",
|
||||
default_entity_id: "sensor.weather_sensor_humidity",
|
||||
@@ -594,7 +760,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
device_class: "temperature",
|
||||
state_class: "measurement",
|
||||
enabled_by_default: true,
|
||||
value_template: "{{ value_json.temperature }}",
|
||||
value_template: '{{ value_json["temperature"] }}',
|
||||
state_topic: "zigbee2mqtt/weather_sensor",
|
||||
object_id: "weather_sensor_temperature",
|
||||
default_entity_id: "sensor.weather_sensor_temperature",
|
||||
@@ -620,7 +786,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
unit_of_measurement: "%",
|
||||
device_class: "humidity",
|
||||
state_class: "measurement",
|
||||
value_template: "{{ value_json.humidity }}",
|
||||
value_template: '{{ value_json["humidity"] }}',
|
||||
state_topic: "zigbee2mqtt/weather_sensor",
|
||||
object_id: "weather_sensor_humidity",
|
||||
default_entity_id: "sensor.weather_sensor_humidity",
|
||||
@@ -647,7 +813,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
unit_of_measurement: "hPa",
|
||||
device_class: "atmospheric_pressure",
|
||||
state_class: "measurement",
|
||||
value_template: "{{ value_json.pressure }}",
|
||||
value_template: '{{ value_json["pressure"] }}',
|
||||
state_topic: "zigbee2mqtt/weather_sensor",
|
||||
enabled_by_default: true,
|
||||
object_id: "weather_sensor_pressure",
|
||||
@@ -705,7 +871,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
unit_of_measurement: "°C",
|
||||
device_class: "temperature",
|
||||
state_class: "measurement",
|
||||
value_template: "{{ value_json.temperature }}",
|
||||
value_template: '{{ value_json["temperature"] }}',
|
||||
state_topic: "zigbee2mqtt/weather_sensor",
|
||||
enabled_by_default: true,
|
||||
object_id: "weather_sensor_temperature",
|
||||
@@ -735,7 +901,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
unit_of_measurement: "%",
|
||||
device_class: "humidity",
|
||||
state_class: "measurement",
|
||||
value_template: "{{ value_json.humidity }}",
|
||||
value_template: '{{ value_json["humidity"] }}',
|
||||
state_topic: "zigbee2mqtt/weather_sensor",
|
||||
enabled_by_default: true,
|
||||
device: {
|
||||
@@ -778,7 +944,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
unit_of_measurement: "°C",
|
||||
device_class: "temperature",
|
||||
state_class: "measurement",
|
||||
value_template: "{{ value_json.temperature }}",
|
||||
value_template: '{{ value_json["temperature"] }}',
|
||||
state_topic: "zigbee2mqtt/weather_sensor",
|
||||
object_id: "weather_sensor_temperature",
|
||||
default_entity_id: "sensor.weather_sensor_temperature",
|
||||
@@ -805,7 +971,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
unit_of_measurement: "%",
|
||||
device_class: "humidity",
|
||||
state_class: "measurement",
|
||||
value_template: "{{ value_json.humidity }}",
|
||||
value_template: '{{ value_json["humidity"] }}',
|
||||
state_topic: "zigbee2mqtt/weather_sensor",
|
||||
object_id: "weather_sensor_humidity",
|
||||
default_entity_id: "sensor.weather_sensor_humidity",
|
||||
@@ -868,7 +1034,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
default_entity_id: "light.my_switch",
|
||||
unique_id: "0x0017880104e45541_light_zigbee2mqtt",
|
||||
origin: origin,
|
||||
value_template: "{{ value_json.state }}",
|
||||
value_template: '{{ value_json["state"] }}',
|
||||
};
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/light/0x0017880104e45541/light/config", stringify(payload), {
|
||||
@@ -914,11 +1080,11 @@ describe("Extension: HomeAssistant", () => {
|
||||
command_topic: "zigbee2mqtt/fan/set/fan_state",
|
||||
percentage_state_topic: "zigbee2mqtt/fan",
|
||||
percentage_command_topic: "zigbee2mqtt/fan/set/fan_mode",
|
||||
percentage_value_template: "{{ {'off':0, 'low':1, 'medium':2, 'high':3, 'on':4}[value_json.fan_mode] | default('None') }}",
|
||||
percentage_value_template: "{{ {'off':0, 'low':1, 'medium':2, 'high':3, 'on':4}[value_json[\"fan_mode\"]] | default('None') }}",
|
||||
percentage_command_template: "{{ {0:'off', 1:'low', 2:'medium', 3:'high', 4:'on'}[value] | default('') }}",
|
||||
preset_mode_state_topic: "zigbee2mqtt/fan",
|
||||
preset_mode_command_topic: "zigbee2mqtt/fan/set/fan_mode",
|
||||
preset_mode_value_template: "{{ value_json.fan_mode if value_json.fan_mode in ['smart'] else 'None' | default('None') }}",
|
||||
preset_mode_value_template: "{{ value_json[\"fan_mode\"] if value_json[\"fan_mode\"] in ['smart'] else 'None' | default('None') }}",
|
||||
preset_modes: ["smart"],
|
||||
speed_range_min: 1,
|
||||
speed_range_max: 4,
|
||||
@@ -990,7 +1156,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
command_topic: "zigbee2mqtt/fanbee/set/state",
|
||||
percentage_state_topic: "zigbee2mqtt/fanbee",
|
||||
percentage_command_topic: "zigbee2mqtt/fanbee/set/speed",
|
||||
percentage_value_template: "{{ value_json.speed | default('None') }}",
|
||||
percentage_value_template: "{{ value_json[\"speed\"] | default('None') }}",
|
||||
percentage_command_template: "{{ value | default('') }}",
|
||||
speed_range_min: 1,
|
||||
speed_range_max: 254,
|
||||
@@ -1023,7 +1189,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
it("Should discover thermostat devices", () => {
|
||||
const payload = {
|
||||
action_template:
|
||||
"{% set values = {None:None,'idle':'idle','heat':'heating','cool':'cooling','fan_only':'fan'} %}{{ values[value_json.running_state] }}",
|
||||
"{% set values = {None:None,'idle':'idle','heat':'heating','cool':'cooling','fan_only':'fan'} %}{{ values[value_json[\"running_state\"]] }}",
|
||||
action_topic: "zigbee2mqtt/TS0601_thermostat",
|
||||
availability: [
|
||||
{
|
||||
@@ -1031,7 +1197,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
value_template: "{{ value_json.state }}",
|
||||
},
|
||||
],
|
||||
current_temperature_template: "{{ value_json.local_temperature }}",
|
||||
current_temperature_template: '{{ value_json["local_temperature"] }}',
|
||||
current_temperature_topic: "zigbee2mqtt/TS0601_thermostat",
|
||||
device: {
|
||||
identifiers: ["zigbee2mqtt_0x0017882104a44559"],
|
||||
@@ -1043,18 +1209,18 @@ describe("Extension: HomeAssistant", () => {
|
||||
},
|
||||
preset_mode_command_topic: "zigbee2mqtt/TS0601_thermostat/set/preset",
|
||||
preset_modes: ["schedule", "manual", "boost", "complex", "comfort", "eco", "away"],
|
||||
preset_mode_value_template: "{{ value_json.preset }}",
|
||||
preset_mode_value_template: '{{ value_json["preset"] }}',
|
||||
preset_mode_state_topic: "zigbee2mqtt/TS0601_thermostat",
|
||||
max_temp: "35",
|
||||
min_temp: "5",
|
||||
mode_command_topic: "zigbee2mqtt/TS0601_thermostat/set/system_mode",
|
||||
mode_state_template: "{{ value_json.system_mode }}",
|
||||
mode_state_template: '{{ value_json["system_mode"] }}',
|
||||
mode_state_topic: "zigbee2mqtt/TS0601_thermostat",
|
||||
modes: ["heat", "auto", "off"],
|
||||
name: null,
|
||||
temp_step: 0.5,
|
||||
temperature_command_topic: "zigbee2mqtt/TS0601_thermostat/set/current_heating_setpoint",
|
||||
temperature_state_template: "{{ value_json.current_heating_setpoint }}",
|
||||
temperature_state_template: '{{ value_json["current_heating_setpoint"] }}',
|
||||
temperature_state_topic: "zigbee2mqtt/TS0601_thermostat",
|
||||
temperature_unit: "C",
|
||||
object_id: "ts0601_thermostat",
|
||||
@@ -1094,7 +1260,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
state_topic: "zigbee2mqtt/thermostat",
|
||||
unique_id: "0x0017880104e45550_pi_heating_demand_zigbee2mqtt",
|
||||
unit_of_measurement: "%",
|
||||
value_template: "{{ value_json.pi_heating_demand }}",
|
||||
value_template: '{{ value_json["pi_heating_demand"] }}',
|
||||
};
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/sensor/0x0017880104e45550/pi_heating_demand/config", stringify(payload), {
|
||||
@@ -1131,7 +1297,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
state_topic: "zigbee2mqtt/bosch_radiator",
|
||||
unique_id: "0x18fc2600000d7ae2_pi_heating_demand_zigbee2mqtt",
|
||||
unit_of_measurement: "%",
|
||||
value_template: "{{ value_json.pi_heating_demand }}",
|
||||
value_template: '{{ value_json["pi_heating_demand"] }}',
|
||||
};
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/number/0x18fc2600000d7ae2/pi_heating_demand/config", stringify(payload), {
|
||||
@@ -1143,10 +1309,10 @@ describe("Extension: HomeAssistant", () => {
|
||||
it("Should discover Bosch BTH-RA with a compatibility mapping", () => {
|
||||
const payload = {
|
||||
action_template:
|
||||
"{% set values = {None:None,'idle':'idle','heat':'heating','cool':'cooling','fan_only':'fan'} %}{{ values[value_json.running_state] }}",
|
||||
"{% set values = {None:None,'idle':'idle','heat':'heating','cool':'cooling','fan_only':'fan'} %}{{ values[value_json[\"running_state\"]] }}",
|
||||
action_topic: "zigbee2mqtt/bosch_radiator",
|
||||
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
|
||||
current_temperature_template: "{{ value_json.local_temperature }}",
|
||||
current_temperature_template: '{{ value_json["local_temperature"] }}',
|
||||
current_temperature_topic: "zigbee2mqtt/bosch_radiator",
|
||||
device: {
|
||||
identifiers: ["zigbee2mqtt_0x18fc2600000d7ae2"],
|
||||
@@ -1171,7 +1337,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
origin: origin,
|
||||
temp_step: 0.5,
|
||||
temperature_command_topic: "zigbee2mqtt/bosch_radiator/set/occupied_heating_setpoint",
|
||||
temperature_state_template: "{{ value_json.occupied_heating_setpoint }}",
|
||||
temperature_state_template: '{{ value_json["occupied_heating_setpoint"] }}',
|
||||
temperature_state_topic: "zigbee2mqtt/bosch_radiator",
|
||||
temperature_unit: "C",
|
||||
unique_id: "0x18fc2600000d7ae2_climate_zigbee2mqtt",
|
||||
@@ -1183,6 +1349,24 @@ describe("Extension: HomeAssistant", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("Should apply user configuration after converter compatibility mapping", async () => {
|
||||
settings.set(["devices", "0x18fc2600000d7ae2", "homeassistant", "climate"], {
|
||||
modes: ["off", "heat", "auto"],
|
||||
mode_command_template: null,
|
||||
});
|
||||
|
||||
await resetExtension();
|
||||
await flushPromises();
|
||||
|
||||
const call = mockMQTTPublishAsync.mock.calls.find((c) => c[0] === "homeassistant/climate/0x18fc2600000d7ae2/climate/config");
|
||||
expect(call).toBeDefined();
|
||||
const payload = JSON.parse(call![1] as string);
|
||||
|
||||
expect(payload.modes).toStrictEqual(["off", "heat", "auto"]);
|
||||
expect(payload.mode_command_template).toBeUndefined();
|
||||
expect(payload.mode_command_topic).toStrictEqual("zigbee2mqtt/bosch_radiator/set");
|
||||
});
|
||||
|
||||
it("does not throw when discovery payload override throws", async () => {
|
||||
const bosch = getZ2MEntity(devices["RBSH-TRV0-ZB-EU"]) as Device;
|
||||
assert(typeof bosch.definition?.meta?.overrideHaDiscoveryPayload === "function");
|
||||
@@ -1198,10 +1382,10 @@ describe("Extension: HomeAssistant", () => {
|
||||
|
||||
const payload = {
|
||||
action_template:
|
||||
"{% set values = {None:None,'idle':'idle','heat':'heating','cool':'cooling','fan_only':'fan'} %}{{ values[value_json.running_state] }}",
|
||||
"{% set values = {None:None,'idle':'idle','heat':'heating','cool':'cooling','fan_only':'fan'} %}{{ values[value_json[\"running_state\"]] }}",
|
||||
action_topic: "zigbee2mqtt/bosch_radiator",
|
||||
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
|
||||
current_temperature_template: "{{ value_json.local_temperature }}",
|
||||
current_temperature_template: '{{ value_json["local_temperature"] }}',
|
||||
current_temperature_topic: "zigbee2mqtt/bosch_radiator",
|
||||
device: {
|
||||
identifiers: ["zigbee2mqtt_0x18fc2600000d7ae2"],
|
||||
@@ -1215,7 +1399,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
max_temp: "30",
|
||||
min_temp: "5",
|
||||
mode_command_topic: "zigbee2mqtt/bosch_radiator/set/system_mode",
|
||||
mode_state_template: "{{ value_json.system_mode }}",
|
||||
mode_state_template: '{{ value_json["system_mode"] }}',
|
||||
mode_state_topic: "zigbee2mqtt/bosch_radiator",
|
||||
modes: ["heat"],
|
||||
name: null,
|
||||
@@ -1224,7 +1408,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
origin: origin,
|
||||
temp_step: 0.5,
|
||||
temperature_command_topic: "zigbee2mqtt/bosch_radiator/set/occupied_heating_setpoint",
|
||||
temperature_state_template: "{{ value_json.occupied_heating_setpoint }}",
|
||||
temperature_state_template: '{{ value_json["occupied_heating_setpoint"] }}',
|
||||
temperature_state_topic: "zigbee2mqtt/bosch_radiator",
|
||||
temperature_unit: "C",
|
||||
unique_id: "0x18fc2600000d7ae2_climate_zigbee2mqtt",
|
||||
@@ -1239,15 +1423,39 @@ describe("Extension: HomeAssistant", () => {
|
||||
overrideSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("passes device options to discovery payload overrides", async () => {
|
||||
const bosch = getZ2MEntity(devices["RBSH-TRV0-ZB-EU"]) as Device;
|
||||
assert(typeof bosch.definition?.meta?.overrideHaDiscoveryPayload === "function");
|
||||
const overrideSpy = vi.spyOn(bosch.definition.meta, "overrideHaDiscoveryPayload") as MockInstance;
|
||||
settings.set(["devices", "0x18fc2600000d7ae2", "discovery_option_marker"], "passed");
|
||||
|
||||
overrideSpy.mockImplementation((payload, options) => {
|
||||
if (payload.mode_command_topic?.endsWith("/system_mode")) {
|
||||
payload.discovery_option_marker = options?.discovery_option_marker;
|
||||
}
|
||||
});
|
||||
|
||||
await resetExtension();
|
||||
|
||||
expect(overrideSpy).toHaveBeenCalledWith(expect.any(Object), expect.objectContaining({discovery_option_marker: "passed"}));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"homeassistant/climate/0x18fc2600000d7ae2/climate/config",
|
||||
expect.stringContaining('"discovery_option_marker":"passed"'),
|
||||
{qos: 1, retain: true},
|
||||
);
|
||||
|
||||
overrideSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("Should discover Bosch BTH-RM230Z with a current_humidity attribute", () => {
|
||||
const payload = {
|
||||
action_template:
|
||||
"{% set values = {None:None,'idle':'idle','heat':'heating','cool':'cooling','fan_only':'fan'} %}{{ values[value_json.running_state] }}",
|
||||
"{% set values = {None:None,'idle':'idle','heat':'heating','cool':'cooling','fan_only':'fan'} %}{{ values[value_json[\"running_state\"]] }}",
|
||||
action_topic: "zigbee2mqtt/bosch_rm230z",
|
||||
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
|
||||
current_humidity_template: "{{ value_json.humidity }}",
|
||||
current_humidity_template: '{{ value_json["humidity"] }}',
|
||||
current_humidity_topic: "zigbee2mqtt/bosch_rm230z",
|
||||
current_temperature_template: "{{ value_json.local_temperature }}",
|
||||
current_temperature_template: '{{ value_json["local_temperature"] }}',
|
||||
current_temperature_topic: "zigbee2mqtt/bosch_rm230z",
|
||||
default_entity_id: "climate.bosch_rm230z",
|
||||
device: {
|
||||
@@ -1262,20 +1470,20 @@ describe("Extension: HomeAssistant", () => {
|
||||
min_temp: "5",
|
||||
mode_command_topic: "zigbee2mqtt/bosch_rm230z/set",
|
||||
mode_state_template:
|
||||
"{% set values = {'schedule':'auto','manual':'heat','pause':'off'} %}{% set value = value_json.operating_mode %}{% if value == \"manual\" %}{{ value_json.system_mode }}{% else %}{{ values[value] if value in values.keys() else 'off' }}{% endif %}",
|
||||
"{% set active_modes = ['heat'] %}{% set fallback_mode = 'heat' %}{% set values = {'schedule':'auto','pause':'off'} %}{% set value = value_json.operating_mode %}{% set mode = value_json.system_mode %}{% if value == 'manual' %}{{ mode if mode in active_modes else fallback_mode }}{% else %}{{ values[value] if value in values.keys() else 'off' }}{% endif %}",
|
||||
mode_command_template:
|
||||
"{% set values = { 'auto':'schedule','heat':'manual','cool':'manual','off':'pause'} %}{% if value == \"heat\" or value == \"cool\" %}{\"operating_mode\": \"manual\", \"system_mode\": \"{{ value }}\"}{% else %}{\"operating_mode\": \"{{ values[value] if value in values.keys() else 'pause' }}\"}{% endif %}",
|
||||
"{% set active_modes = ['heat'] %}{% set values = {'auto':'schedule','off':'pause'} %}{% if value in active_modes %}{\"operating_mode\": \"manual\", \"system_mode\": \"{{ value }}\"}{% else %}{\"operating_mode\": \"{{ values[value] if value in values.keys() else 'pause' }}\"}{% endif %}",
|
||||
mode_state_topic: "zigbee2mqtt/bosch_rm230z",
|
||||
modes: ["off", "heat", "cool", "auto"],
|
||||
modes: ["off", "heat", "auto"],
|
||||
name: null,
|
||||
object_id: "bosch_rm230z",
|
||||
origin,
|
||||
temp_step: 0.5,
|
||||
temperature_high_command_topic: "zigbee2mqtt/bosch_rm230z/set/occupied_cooling_setpoint",
|
||||
temperature_high_state_template: "{{ value_json.occupied_cooling_setpoint }}",
|
||||
temperature_high_state_template: '{{ value_json["occupied_cooling_setpoint"] }}',
|
||||
temperature_high_state_topic: "zigbee2mqtt/bosch_rm230z",
|
||||
temperature_low_command_topic: "zigbee2mqtt/bosch_rm230z/set/occupied_heating_setpoint",
|
||||
temperature_low_state_template: "{{ value_json.occupied_heating_setpoint }}",
|
||||
temperature_low_state_template: '{{ value_json["occupied_heating_setpoint"] }}',
|
||||
temperature_low_state_topic: "zigbee2mqtt/bosch_rm230z",
|
||||
temperature_unit: "C",
|
||||
unique_id: "0x18fc2600000d7ae3_climate_zigbee2mqtt",
|
||||
@@ -1306,7 +1514,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
state_topic: "zigbee2mqtt/bosch_rm230z",
|
||||
unique_id: "0x18fc2600000d7ae3_local_temperature_zigbee2mqtt",
|
||||
unit_of_measurement: "°C",
|
||||
value_template: "{{ value_json.local_temperature }}",
|
||||
value_template: '{{ value_json["local_temperature"] }}',
|
||||
};
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/sensor/0x18fc2600000d7ae3/local_temperature/config", stringify(payload), {
|
||||
@@ -1315,6 +1523,37 @@ describe("Extension: HomeAssistant", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("Should discover climate with cooling-only setpoint", () => {
|
||||
const climateExpose = new zhc.Climate()
|
||||
.withSetpoint("occupied_cooling_setpoint", 16, 32, 0.5)
|
||||
.withLocalTemperature()
|
||||
.withSystemMode(["off", "cool", "auto"]);
|
||||
const device = {
|
||||
definition: {},
|
||||
isDevice: (): boolean => true,
|
||||
isGroup: (): boolean => false,
|
||||
endpoint: () => undefined,
|
||||
options: {},
|
||||
exposes: (): zhc.Expose[] => [climateExpose],
|
||||
zh: {endpoints: []},
|
||||
} as Device;
|
||||
|
||||
// @ts-expect-error private
|
||||
const configs = extension.getConfigs(device);
|
||||
const climate = configs.find((c) => c.type === "climate");
|
||||
expect(climate).toBeDefined();
|
||||
expect(climate!.discovery_payload).toMatchObject({
|
||||
temperature_command_topic: "occupied_cooling_setpoint",
|
||||
temperature_state_template: '{{ value_json["occupied_cooling_setpoint"] }}',
|
||||
temperature_state_topic: true,
|
||||
min_temp: "16",
|
||||
max_temp: "32",
|
||||
temp_step: 0.5,
|
||||
});
|
||||
expect(climate!.discovery_payload).not.toHaveProperty("temperature_low_command_topic");
|
||||
expect(climate!.discovery_payload).not.toHaveProperty("temperature_high_command_topic");
|
||||
});
|
||||
|
||||
it("Should discover devices with cover_position", () => {
|
||||
let payload;
|
||||
|
||||
@@ -1323,9 +1562,9 @@ describe("Extension: HomeAssistant", () => {
|
||||
position_topic: "zigbee2mqtt/smart vent",
|
||||
set_position_topic: "zigbee2mqtt/smart vent/set",
|
||||
set_position_template: '{ "position": {{ position }} }',
|
||||
position_template: "{{ value_json.position }}",
|
||||
position_template: '{{ value_json["position"] }}',
|
||||
state_topic: "zigbee2mqtt/smart vent",
|
||||
value_template: "{{ value_json.state }}",
|
||||
value_template: '{{ value_json["state"] }}',
|
||||
state_open: "OPEN",
|
||||
state_closed: "CLOSE",
|
||||
state_stopped: "STOP",
|
||||
@@ -1362,7 +1601,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
|
||||
},
|
||||
name: "L6",
|
||||
position_template: "{{ value_json.position }}",
|
||||
position_template: '{{ value_json["position"] }}',
|
||||
position_topic: "zigbee2mqtt/zigfred_plus/l6",
|
||||
set_position_template: '{ "position_l6": {{ position }} }',
|
||||
set_position_topic: "zigbee2mqtt/zigfred_plus/l6/set",
|
||||
@@ -1371,13 +1610,13 @@ describe("Extension: HomeAssistant", () => {
|
||||
state_open: "OPEN",
|
||||
state_topic: "zigbee2mqtt/zigfred_plus/l6",
|
||||
tilt_command_topic: "zigbee2mqtt/zigfred_plus/l6/set/tilt",
|
||||
tilt_status_template: "{{ value_json.tilt }}",
|
||||
tilt_status_template: '{{ value_json["tilt"] }}',
|
||||
tilt_status_topic: "zigbee2mqtt/zigfred_plus/l6",
|
||||
object_id: "zigfred_plus_l6",
|
||||
default_entity_id: "cover.zigfred_plus_l6",
|
||||
unique_id: "0xf4ce368a38be56a1_cover_l6_zigbee2mqtt",
|
||||
origin: origin,
|
||||
value_template: "{{ value_json.state }}",
|
||||
value_template: '{{ value_json["state"] }}',
|
||||
};
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/cover/0xf4ce368a38be56a1/cover_l6/config", stringify(payload), {
|
||||
@@ -1407,7 +1646,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
object_id: "0xa4c138018cf95021_left",
|
||||
default_entity_id: "cover.0xa4c138018cf95021_left",
|
||||
origin: origin,
|
||||
position_template: "{{ value_json.position }}",
|
||||
position_template: '{{ value_json["position"] }}',
|
||||
position_topic: "zigbee2mqtt/0xa4c138018cf95021/left",
|
||||
set_position_template: '{ "position_left": {{ position }} }',
|
||||
set_position_topic: "zigbee2mqtt/0xa4c138018cf95021/left/set",
|
||||
@@ -1416,7 +1655,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
state_stopped: "STOP",
|
||||
state_topic: "zigbee2mqtt/0xa4c138018cf95021/left",
|
||||
unique_id: "0xa4c138018cf95021_cover_left_zigbee2mqtt",
|
||||
value_template: '{% if "moving" in value_json and value_json.moving %} {{ value_json.moving }} {% else %} STOP {% endif %}',
|
||||
value_template: '{% if "moving" in value_json and value_json["moving"] %} {{ value_json["moving"] }} {% else %} STOP {% endif %}',
|
||||
};
|
||||
const payload_right = {
|
||||
availability: [
|
||||
@@ -1438,7 +1677,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
object_id: "0xa4c138018cf95021_right",
|
||||
default_entity_id: "cover.0xa4c138018cf95021_right",
|
||||
origin: origin,
|
||||
position_template: "{{ value_json.position }}",
|
||||
position_template: '{{ value_json["position"] }}',
|
||||
position_topic: "zigbee2mqtt/0xa4c138018cf95021/right",
|
||||
set_position_template: '{ "position_right": {{ position }} }',
|
||||
set_position_topic: "zigbee2mqtt/0xa4c138018cf95021/right/set",
|
||||
@@ -1447,11 +1686,9 @@ describe("Extension: HomeAssistant", () => {
|
||||
state_stopped: "STOP",
|
||||
state_topic: "zigbee2mqtt/0xa4c138018cf95021/right",
|
||||
unique_id: "0xa4c138018cf95021_cover_right_zigbee2mqtt",
|
||||
value_template: '{% if "moving" in value_json and value_json.moving %} {{ value_json.moving }} {% else %} STOP {% endif %}',
|
||||
value_template: '{% if "moving" in value_json and value_json["moving"] %} {{ value_json["moving"] }} {% else %} STOP {% endif %}',
|
||||
};
|
||||
|
||||
console.log(mockMQTTPublishAsync.mock.calls.find((c) => c[0] === "homeassistant/cover/0xa4c138018cf95021/cover_left/config"));
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/cover/0xa4c138018cf95021/cover_left/config", stringify(payload_left), {
|
||||
retain: true,
|
||||
qos: 1,
|
||||
@@ -1462,6 +1699,62 @@ describe("Extension: HomeAssistant", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("Should discover an infrared emitter entity", () => {
|
||||
const infraredEmitterExpose = new zhc.Text("emitter", zhc.access.SET).withHomeAssistant({
|
||||
type: "infrared",
|
||||
schema: "emitter",
|
||||
valueTemplate: null,
|
||||
});
|
||||
const device = {
|
||||
definition: {},
|
||||
isDevice: (): boolean => true,
|
||||
isGroup: (): boolean => false,
|
||||
endpoint: () => undefined,
|
||||
options: {},
|
||||
exposes: (): zhc.Expose[] => [infraredEmitterExpose],
|
||||
zh: {endpoints: []},
|
||||
} as Device;
|
||||
|
||||
// @ts-expect-error private
|
||||
const configs = extension.getConfigs(device);
|
||||
const infrared = configs.find((c) => c.type === "infrared");
|
||||
expect(infrared).toBeDefined();
|
||||
expect(infrared!.discovery_payload).toMatchObject({
|
||||
name: "Emitter",
|
||||
schema: "emitter",
|
||||
command_topic: true,
|
||||
state_topic: 0,
|
||||
});
|
||||
expect(infrared!.discovery_payload).not.toHaveProperty("value_template");
|
||||
});
|
||||
|
||||
it("Should discover an infrared receiver entity", () => {
|
||||
const infraredReceiverExpose = new zhc.Text("receiver", zhc.access.STATE).withHomeAssistant({
|
||||
type: "infrared",
|
||||
schema: "receiver",
|
||||
valueTemplate: "{{ json_value.emitter }}",
|
||||
});
|
||||
const device = {
|
||||
definition: {},
|
||||
isDevice: (): boolean => true,
|
||||
isGroup: (): boolean => false,
|
||||
endpoint: () => undefined,
|
||||
options: {},
|
||||
exposes: (): zhc.Expose[] => [infraredReceiverExpose],
|
||||
zh: {endpoints: []},
|
||||
} as Device;
|
||||
|
||||
// @ts-expect-error private
|
||||
const configs = extension.getConfigs(device);
|
||||
const infrared = configs.find((c) => c.type === "infrared");
|
||||
expect(infrared).toBeDefined();
|
||||
expect(infrared!.discovery_payload).toMatchObject({
|
||||
name: "Receiver",
|
||||
schema: "receiver",
|
||||
});
|
||||
expect(infrared!.discovery_payload).toHaveProperty("value_template");
|
||||
});
|
||||
|
||||
it("Should discover devices with custom homeassistant.discovery_topic", async () => {
|
||||
settings.set(["homeassistant", "discovery_topic"], "my_custom_discovery_topic");
|
||||
await resetExtension();
|
||||
@@ -1470,7 +1763,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
unit_of_measurement: "°C",
|
||||
device_class: "temperature",
|
||||
state_class: "measurement",
|
||||
value_template: "{{ value_json.temperature }}",
|
||||
value_template: '{{ value_json["temperature"] }}',
|
||||
state_topic: "zigbee2mqtt/weather_sensor",
|
||||
enabled_by_default: true,
|
||||
object_id: "weather_sensor_temperature",
|
||||
@@ -1502,7 +1795,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
|
||||
await expect(async () => {
|
||||
await controller.start();
|
||||
}).rejects.toThrow("Home Assistant integration is not possible with attribute output!");
|
||||
}).rejects.toThrow("Home Assistant integration requires 'output: json' under 'advanced'");
|
||||
});
|
||||
|
||||
it("Should throw error when homeassistant.discovery_topic equals the mqtt.base_topic", async () => {
|
||||
@@ -1518,7 +1811,9 @@ describe("Extension: HomeAssistant", () => {
|
||||
settings.set(["advanced", "cache_state"], false);
|
||||
mockLogger.warning.mockClear();
|
||||
await resetExtension();
|
||||
expect(mockLogger.warning).toHaveBeenCalledWith("In order for Home Assistant integration to work properly set `cache_state: true");
|
||||
expect(mockLogger.warning).toHaveBeenCalledWith(
|
||||
"In order for Home Assistant integration to work properly, set `cache_state: true` under `advanced`",
|
||||
);
|
||||
});
|
||||
|
||||
it("Should set missing values to null", async () => {
|
||||
@@ -1558,6 +1853,8 @@ describe("Extension: HomeAssistant", () => {
|
||||
color: {hue: 0, saturation: 100, h: 0, s: 100},
|
||||
color_mode: "hs",
|
||||
effect: null,
|
||||
effect_color: null,
|
||||
effect_speed: null,
|
||||
linkquality: null,
|
||||
state: null,
|
||||
power_on_behavior: null,
|
||||
@@ -1581,6 +1878,8 @@ describe("Extension: HomeAssistant", () => {
|
||||
color: {x: 0.4576, y: 0.41},
|
||||
color_mode: "xy",
|
||||
effect: null,
|
||||
effect_color: null,
|
||||
effect_speed: null,
|
||||
linkquality: null,
|
||||
state: null,
|
||||
power_on_behavior: null,
|
||||
@@ -1603,6 +1902,8 @@ describe("Extension: HomeAssistant", () => {
|
||||
stringify({
|
||||
linkquality: null,
|
||||
effect: null,
|
||||
effect_color: null,
|
||||
effect_speed: null,
|
||||
state: "ON",
|
||||
power_on_behavior: null,
|
||||
update: {state: null, installed_version: -1, latest_version: -1},
|
||||
@@ -1650,7 +1951,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
device_class: "temperature",
|
||||
enabled_by_default: true,
|
||||
state_class: "measurement",
|
||||
value_template: "{{ value_json.temperature }}",
|
||||
value_template: '{{ value_json["temperature"] }}',
|
||||
state_topic: "zigbee2mqtt/weather_sensor",
|
||||
object_id: "weather_sensor_temperature",
|
||||
default_entity_id: "sensor.weather_sensor_temperature",
|
||||
@@ -1705,6 +2006,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
await flushPromises();
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/state", stringify({state: "online"}), {retain: true, qos: 1});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bulb",
|
||||
stringify({
|
||||
@@ -1745,6 +2047,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
await flushPromises();
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/state", stringify({state: "online"}), {retain: true, qos: 1});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bulb",
|
||||
stringify({
|
||||
@@ -1811,7 +2114,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
device_class: "temperature",
|
||||
enabled_by_default: true,
|
||||
state_class: "measurement",
|
||||
value_template: "{{ value_json.temperature }}",
|
||||
value_template: '{{ value_json["temperature"] }}',
|
||||
state_topic: "zigbee2mqtt/weather_sensor",
|
||||
object_id: "weather_sensor_temperature",
|
||||
default_entity_id: "sensor.weather_sensor_temperature",
|
||||
@@ -1881,7 +2184,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
device_class: "temperature",
|
||||
state_class: "measurement",
|
||||
enabled_by_default: true,
|
||||
value_template: "{{ value_json.temperature }}",
|
||||
value_template: '{{ value_json["temperature"] }}',
|
||||
state_topic: "zigbee2mqtt/weather_sensor_renamed",
|
||||
object_id: "weather_sensor_renamed_temperature",
|
||||
default_entity_id: "sensor.weather_sensor_renamed_temperature",
|
||||
@@ -1965,6 +2268,16 @@ describe("Extension: HomeAssistant", () => {
|
||||
"candle",
|
||||
"fireplace",
|
||||
"colorloop",
|
||||
"sunset",
|
||||
"sunrise",
|
||||
"sparkle",
|
||||
"opal",
|
||||
"glisten",
|
||||
"underwater",
|
||||
"cosmos",
|
||||
"sunbeam",
|
||||
"enchant",
|
||||
"none",
|
||||
"finish_effect",
|
||||
"stop_effect",
|
||||
"stop_hue_effect",
|
||||
@@ -1972,6 +2285,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
object_id: "ha_discovery_group_new",
|
||||
default_entity_id: "light.ha_discovery_group_new",
|
||||
unique_id: "9_light_zigbee2mqtt",
|
||||
group: ["0x000b57fffec6a5b4_light_zigbee2mqtt", "0x000b57fffec6a5b7_light_zigbee2mqtt"],
|
||||
origin: origin,
|
||||
};
|
||||
|
||||
@@ -2004,7 +2318,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
device_class: "temperature",
|
||||
state_class: "measurement",
|
||||
enabled_by_default: true,
|
||||
value_template: "{{ value_json.temperature }}",
|
||||
value_template: '{{ value_json["temperature"] }}',
|
||||
state_topic: "zigbee2mqtt/weather_sensor_renamed",
|
||||
object_id: "weather_sensor_renamed_temperature",
|
||||
default_entity_id: "sensor.weather_sensor_renamed_temperature",
|
||||
@@ -2434,6 +2748,16 @@ describe("Extension: HomeAssistant", () => {
|
||||
"candle",
|
||||
"fireplace",
|
||||
"colorloop",
|
||||
"sunset",
|
||||
"sunrise",
|
||||
"sparkle",
|
||||
"opal",
|
||||
"glisten",
|
||||
"underwater",
|
||||
"cosmos",
|
||||
"sunbeam",
|
||||
"enchant",
|
||||
"none",
|
||||
"finish_effect",
|
||||
"stop_effect",
|
||||
"stop_hue_effect",
|
||||
@@ -2441,6 +2765,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
object_id: "ha_discovery_group",
|
||||
default_entity_id: "light.ha_discovery_group",
|
||||
unique_id: "9_light_zigbee2mqtt",
|
||||
group: ["0x000b57fffec6a5b4_light_zigbee2mqtt", "0x000b57fffec6a5b7_light_zigbee2mqtt"],
|
||||
origin: origin,
|
||||
};
|
||||
|
||||
@@ -2479,6 +2804,16 @@ describe("Extension: HomeAssistant", () => {
|
||||
"candle",
|
||||
"fireplace",
|
||||
"colorloop",
|
||||
"sunset",
|
||||
"sunrise",
|
||||
"sparkle",
|
||||
"opal",
|
||||
"glisten",
|
||||
"underwater",
|
||||
"cosmos",
|
||||
"sunbeam",
|
||||
"enchant",
|
||||
"none",
|
||||
"finish_effect",
|
||||
"stop_effect",
|
||||
"stop_hue_effect",
|
||||
@@ -2486,6 +2821,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
object_id: "ha_discovery_group",
|
||||
default_entity_id: "light.ha_discovery_group",
|
||||
unique_id: "9_light_zigbee2mqtt",
|
||||
group: ["0x000b57fffec6a5b4_light_zigbee2mqtt", "0x000b57fffec6a5b7_light_zigbee2mqtt"],
|
||||
origin: origin,
|
||||
};
|
||||
|
||||
@@ -2592,7 +2928,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
device_class: "temperature",
|
||||
state_class: "measurement",
|
||||
enabled_by_default: true,
|
||||
value_template: "{{ value_json.temperature }}",
|
||||
value_template: '{{ value_json["temperature"] }}',
|
||||
state_topic: "zigbee2mqtt/weather_sensor",
|
||||
object_id: "weather_sensor_temperature",
|
||||
default_entity_id: "sensor.weather_sensor_temperature",
|
||||
@@ -2959,7 +3295,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
origin: origin,
|
||||
state_topic: "zigbee2mqtt/0x18fc26000000cafe",
|
||||
unique_id: "0x18fc26000000cafe_device_mode_zigbee2mqtt",
|
||||
value_template: "{{ value_json.device_mode }}",
|
||||
value_template: '{{ value_json["device_mode"] }}',
|
||||
};
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/select/0x18fc26000000cafe/device_mode/config", stringify(payload), {
|
||||
retain: true,
|
||||
@@ -2971,11 +3307,15 @@ describe("Extension: HomeAssistant", () => {
|
||||
settings.set(["homeassistant", "legacy_action_sensor"], true);
|
||||
await resetExtension();
|
||||
|
||||
// Should discovery action sensor
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/sensor/0x0017880104e45520/action/config", expect.any(String), {
|
||||
retain: true,
|
||||
qos: 1,
|
||||
// Should discover action sensor as a diagnostic helper instead of a primary entity.
|
||||
const actionDiscovery = mockMQTTPublishAsync.mock.calls.find(([topic]) => topic === "homeassistant/sensor/0x0017880104e45520/action/config");
|
||||
assert(actionDiscovery);
|
||||
expect(JSON.parse(actionDiscovery[1])).toMatchObject({
|
||||
entity_category: "diagnostic",
|
||||
name: "Action",
|
||||
object_id: "button_action",
|
||||
});
|
||||
expect(actionDiscovery[2]).toStrictEqual({retain: true, qos: 1});
|
||||
|
||||
// Should counter an action payload with an empty payload
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
@@ -10,7 +10,7 @@ import {devices, events as mockZHEvents, returnDevices} from "../mocks/zigbeeHer
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {stringify} from "../../lib/util/stringify";
|
||||
import {Controller} from "../../lib/controller";
|
||||
import * as settings from "../../lib/util/settings";
|
||||
|
||||
@@ -286,7 +286,7 @@ describe("Extension: NetworkMap", () => {
|
||||
description: "Hue Go",
|
||||
model: "7146060PH",
|
||||
supports:
|
||||
"light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), power_on_behavior, effect, linkquality",
|
||||
"light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), power_on_behavior, effect, effect_speed, effect_color, linkquality",
|
||||
vendor: "Philips",
|
||||
},
|
||||
failed: [],
|
||||
@@ -616,7 +616,7 @@ describe("Extension: NetworkMap", () => {
|
||||
description: "Hue Go",
|
||||
model: "7146060PH",
|
||||
supports:
|
||||
"light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), power_on_behavior, effect, linkquality",
|
||||
"light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), power_on_behavior, effect, effect_speed, effect_color, linkquality",
|
||||
vendor: "Philips",
|
||||
},
|
||||
failed: [],
|
||||
@@ -785,7 +785,7 @@ describe("Extension: NetworkMap", () => {
|
||||
description: "Hue Go",
|
||||
model: "7146060PH",
|
||||
supports:
|
||||
"light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), power_on_behavior, effect, linkquality",
|
||||
"light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), power_on_behavior, effect, effect_speed, effect_color, linkquality",
|
||||
vendor: "Philips",
|
||||
},
|
||||
failed: [],
|
||||
|
||||
@@ -9,7 +9,7 @@ import {devices, events as mockZHEvents} from "../mocks/zigbeeHerdsman";
|
||||
|
||||
import {join} from "node:path";
|
||||
import {existsSync, readFileSync, rmSync} from "node:fs";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {stringify} from "../../lib/util/stringify";
|
||||
import {Controller} from "../../lib/controller";
|
||||
import OTAUpdate from "../../lib/extension/otaUpdate";
|
||||
import * as settings from "../../lib/util/settings";
|
||||
@@ -546,6 +546,71 @@ describe("Extension: OTAUpdate", () => {
|
||||
expect(existsSync(saveFilePath)).toStrictEqual(false);
|
||||
});
|
||||
|
||||
it("aborts running OTA", async () => {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
devices.bulb.updateOta.mockImplementationOnce(
|
||||
async (_source, _requestPayload, _requestTsn, _extraMetas, onProgress, _dataSettings, _endpoint) => {
|
||||
onProgress(0, 36000.5678);
|
||||
onProgress(10, 3600.2123);
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
resolve([
|
||||
{
|
||||
...DEFAULT_CURRENT,
|
||||
fileVersion: 1,
|
||||
},
|
||||
{
|
||||
...DEFAULT_CURRENT,
|
||||
fileVersion: 90,
|
||||
},
|
||||
]),
|
||||
10000,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/update", stringify({id: "bulb"}));
|
||||
await flushPromises();
|
||||
await vi.advanceTimersByTimeAsync(9000);
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/update/abort", stringify({id: "bulb"}));
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bulb",
|
||||
stringify({update: {state: "updating", progress: 0, remaining: 36001}}),
|
||||
{
|
||||
retain: true,
|
||||
qos: 0,
|
||||
},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bulb",
|
||||
stringify({update: {state: "updating", progress: 10, remaining: 3600}}),
|
||||
{retain: true, qos: 0},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({update: {state: "available"}}), {retain: true, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/ota_update/update/abort",
|
||||
stringify({data: {id: "bulb"}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
|
||||
clearTimeout(timer);
|
||||
});
|
||||
|
||||
it("handles abort when no running OTA", async () => {
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/update/abort", stringify({id: "bulb"}));
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/ota_update/update",
|
||||
stringify({data: {}, status: "error", error: `No OTA in progress to abort for device 'bulb'`}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("handles when OTA update fails", async () => {
|
||||
devices.bulb.endpoints[0].read.mockImplementation(() => {
|
||||
return {swBuildId: 1, dateCode: "2019010"};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
+13
-15
@@ -5,20 +5,13 @@ 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";
|
||||
|
||||
vi.mock("rimraf", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("rimraf")>();
|
||||
return {
|
||||
...actual,
|
||||
rimrafSync: vi.fn(actual.rimrafSync),
|
||||
};
|
||||
});
|
||||
const rmSync = (target: string): void => fs.rmSync(target, {recursive: true, force: true});
|
||||
|
||||
describe("Logger", () => {
|
||||
let consoleWriteSpy: MockInstance;
|
||||
@@ -56,7 +49,7 @@ describe("Logger", () => {
|
||||
|
||||
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++) {
|
||||
@@ -70,25 +63,30 @@ describe("Logger", () => {
|
||||
|
||||
it("Should handle cleanup error", () => {
|
||||
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++) {
|
||||
fs.mkdirSync(path.join(dir.name, `log_${i}`));
|
||||
}
|
||||
|
||||
vi.mocked(rimrafSync).mockImplementationOnce(() => {
|
||||
const rmSyncSpy = vi.spyOn(fs, "rmSync").mockImplementationOnce(() => {
|
||||
throw new Error("EACCES: permission denied");
|
||||
});
|
||||
|
||||
const errorSpy = vi.spyOn(logger, "error");
|
||||
logger.init();
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringMatching(/Failed to remove old log directory '.*': Error: EACCES: permission denied/));
|
||||
|
||||
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++) {
|
||||
@@ -103,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++) {
|
||||
|
||||
+1
-1
@@ -1,8 +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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
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;
|
||||
|
||||
@@ -283,6 +283,7 @@ export class Device {
|
||||
unscheduleOta = vi.fn(() => {
|
||||
this.scheduledOta = undefined;
|
||||
});
|
||||
abortOta = vi.fn();
|
||||
scheduledOta: OtaSource | undefined = undefined;
|
||||
|
||||
constructor(
|
||||
@@ -345,6 +346,7 @@ export class Device {
|
||||
this.updateOta.mockClear();
|
||||
this.scheduleOta.mockClear();
|
||||
this.unscheduleOta.mockClear();
|
||||
this.abortOta.mockClear();
|
||||
this.meta = {};
|
||||
this.scheduledOta = undefined;
|
||||
|
||||
|
||||
+108
-184
@@ -5,7 +5,7 @@ import * as data from "./mocks/data";
|
||||
import {readFileSync, rmSync, writeFileSync} from "node:fs";
|
||||
import {join} from "node:path";
|
||||
import type {IncomingMessage, OutgoingHttpHeader, OutgoingHttpHeaders, RequestListener, Server, ServerResponse} from "node:http";
|
||||
import JSZip from "jszip";
|
||||
import {zipSync} from "fflate";
|
||||
import type {findAllDevices} from "zigbee-herdsman/dist/adapter/adapterDiscovery";
|
||||
import type {OnboardFailureData, OnboardInitData, OnboardSubmitResponse} from "../lib/types/api";
|
||||
import {onboard} from "../lib/util/onboarding";
|
||||
@@ -90,6 +90,7 @@ const SETTINGS_MINIMAL_DEFAULTS = {
|
||||
network_key: "GENERATE",
|
||||
pan_id: "GENERATE",
|
||||
ext_pan_id: "GENERATE",
|
||||
enable_external_js: false,
|
||||
},
|
||||
frontend: {
|
||||
enabled: settings.defaults.frontend!.enabled,
|
||||
@@ -147,6 +148,7 @@ const SAMPLE_SETTINGS_SAVE = {
|
||||
network_key: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
|
||||
pan_id: 12345,
|
||||
ext_pan_id: [8, 7, 6, 5, 4, 3, 2, 1],
|
||||
enable_external_js: false,
|
||||
},
|
||||
frontend: {
|
||||
enabled: true,
|
||||
@@ -538,205 +540,132 @@ describe("Onboarding", () => {
|
||||
await responsePromise;
|
||||
};
|
||||
|
||||
const createZipRestore = (): Awaited<ReturnType<typeof JSZip.loadAsync>> => {
|
||||
return {
|
||||
files: {
|
||||
"configuration.yaml": {
|
||||
name: "configuration.yaml",
|
||||
dir: false,
|
||||
// @ts-expect-error minimal mock
|
||||
async: async () => await Promise.resolve(Buffer.from(JSON.stringify(SAMPLE_SETTINGS_SAVE))),
|
||||
},
|
||||
// @ts-expect-error minimal mock
|
||||
"nested/": {
|
||||
name: "nested/",
|
||||
dir: true,
|
||||
},
|
||||
"nested/notes.txt": {
|
||||
name: "nested/notes.txt",
|
||||
dir: false,
|
||||
// @ts-expect-error minimal mock
|
||||
async: async () => await Promise.resolve(Buffer.from("zip-restore")),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
const createZipPayload = (files: Parameters<typeof zipSync>[0]): string => Buffer.from(zipSync(files)).toString("base64");
|
||||
|
||||
const createZipRestore = (): string =>
|
||||
createZipPayload({
|
||||
"configuration.yaml": Buffer.from(JSON.stringify(SAMPLE_SETTINGS_SAVE)),
|
||||
nested: {"notes.txt": Buffer.from("zip-restore")},
|
||||
});
|
||||
|
||||
it("extracts uploaded ZIP files into the data path", async () => {
|
||||
data.removeConfiguration();
|
||||
const loadAsyncSpy = vi.spyOn(JSZip, "loadAsync").mockResolvedValue(createZipRestore());
|
||||
|
||||
try {
|
||||
let p;
|
||||
const submitData = await new Promise<OnboardSubmitResponse>((resolve, reject) => {
|
||||
mockHttpOnListen.mockImplementationOnce(async () => {
|
||||
try {
|
||||
resolve(await submitZipPayload(Buffer.from("zip").toString("base64"), false, false));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
p = onboard();
|
||||
let p;
|
||||
const submitData = await new Promise<OnboardSubmitResponse>((resolve, reject) => {
|
||||
mockHttpOnListen.mockImplementationOnce(async () => {
|
||||
try {
|
||||
resolve(await submitZipPayload(createZipRestore(), false, false));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
await expect(p).resolves.toStrictEqual(true);
|
||||
expect(data.read()).toStrictEqual(SAMPLE_SETTINGS_SAVE);
|
||||
expect(readFileSync(join(data.mockDir, "nested", "notes.txt"), "utf8")).toStrictEqual("zip-restore");
|
||||
expect(loadAsyncSpy).toHaveBeenCalledTimes(1);
|
||||
expect(submitData).toStrictEqual({success: true, frontendUrl: null});
|
||||
} finally {
|
||||
loadAsyncSpy.mockRestore();
|
||||
}
|
||||
p = onboard();
|
||||
});
|
||||
|
||||
await expect(p).resolves.toStrictEqual(true);
|
||||
expect(data.read()).toStrictEqual(SAMPLE_SETTINGS_SAVE);
|
||||
expect(readFileSync(join(data.mockDir, "nested", "notes.txt"), "utf8")).toStrictEqual("zip-restore");
|
||||
expect(submitData).toStrictEqual({success: true, frontendUrl: null});
|
||||
});
|
||||
|
||||
it("rejects non-zip upload payloads", async () => {
|
||||
data.removeConfiguration();
|
||||
const loadAsyncSpy = vi
|
||||
.spyOn(JSZip, "loadAsync")
|
||||
.mockRejectedValueOnce(new Error("Can't find end of central directory : is this a zip file ?"))
|
||||
.mockResolvedValueOnce(createZipRestore());
|
||||
|
||||
try {
|
||||
let p;
|
||||
const [firstSubmitData, secondSubmitData] = await new Promise<[OnboardSubmitResponse, OnboardSubmitResponse]>((resolve, reject) => {
|
||||
mockHttpOnListen.mockImplementationOnce(async () => {
|
||||
try {
|
||||
const failedSubmit = await submitZipPayload(Buffer.from("ignored").toString("base64"), true, false);
|
||||
const successfulSubmit = await submitZipPayload(Buffer.from("zip").toString("base64"), false, false);
|
||||
let p;
|
||||
const [firstSubmitData, secondSubmitData] = await new Promise<[OnboardSubmitResponse, OnboardSubmitResponse]>((resolve, reject) => {
|
||||
mockHttpOnListen.mockImplementationOnce(async () => {
|
||||
try {
|
||||
const failedSubmit = await submitZipPayload(Buffer.from("not-a-zip-file").toString("base64"), true, false);
|
||||
const successfulSubmit = await submitZipPayload(createZipRestore(), false, false);
|
||||
|
||||
resolve([failedSubmit, successfulSubmit]);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
p = onboard();
|
||||
resolve([failedSubmit, successfulSubmit]);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
await expect(p).resolves.toStrictEqual(true);
|
||||
expect(loadAsyncSpy).toHaveBeenCalledTimes(2);
|
||||
expect(data.read()).toStrictEqual(SAMPLE_SETTINGS_SAVE);
|
||||
expect(readFileSync(join(data.mockDir, "nested", "notes.txt"), "utf8")).toStrictEqual("zip-restore");
|
||||
expect(firstSubmitData).toStrictEqual({success: false, error: expect.stringContaining("is this a zip file")});
|
||||
expect(secondSubmitData).toStrictEqual({success: true, frontendUrl: null});
|
||||
} finally {
|
||||
loadAsyncSpy.mockRestore();
|
||||
}
|
||||
p = onboard();
|
||||
});
|
||||
|
||||
await expect(p).resolves.toStrictEqual(true);
|
||||
expect(data.read()).toStrictEqual(SAMPLE_SETTINGS_SAVE);
|
||||
expect(readFileSync(join(data.mockDir, "nested", "notes.txt"), "utf8")).toStrictEqual("zip-restore");
|
||||
expect(firstSubmitData).toStrictEqual({success: false, error: expect.stringContaining("invalid zip data")});
|
||||
expect(secondSubmitData).toStrictEqual({success: true, frontendUrl: null});
|
||||
});
|
||||
|
||||
it("rejects ZIP upload payloads with invalid entry paths", async () => {
|
||||
data.removeConfiguration();
|
||||
const loadAsyncSpy = vi
|
||||
.spyOn(JSZip, "loadAsync")
|
||||
.mockResolvedValueOnce({
|
||||
files: {
|
||||
"/dragons.txt": {
|
||||
name: "/dragons.txt",
|
||||
dir: false,
|
||||
// @ts-expect-error minimal mock
|
||||
async: async () => await Promise.resolve(Buffer.from("dragons")),
|
||||
},
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce(createZipRestore());
|
||||
|
||||
try {
|
||||
let p;
|
||||
const [firstSubmitData, secondSubmitData] = await new Promise<[OnboardSubmitResponse, OnboardSubmitResponse]>((resolve, reject) => {
|
||||
mockHttpOnListen.mockImplementationOnce(async () => {
|
||||
try {
|
||||
const failedSubmit = await submitZipPayload(Buffer.from("zip-invalid-path").toString("base64"), true, false);
|
||||
const successfulSubmit = await submitZipPayload(Buffer.from("zip").toString("base64"), false, false);
|
||||
let p;
|
||||
const [firstSubmitData, secondSubmitData] = await new Promise<[OnboardSubmitResponse, OnboardSubmitResponse]>((resolve, reject) => {
|
||||
mockHttpOnListen.mockImplementationOnce(async () => {
|
||||
try {
|
||||
const failedSubmit = await submitZipPayload(createZipPayload({"/dragons.txt": Buffer.from("dragons")}), true, false);
|
||||
const successfulSubmit = await submitZipPayload(createZipRestore(), false, false);
|
||||
|
||||
resolve([failedSubmit, successfulSubmit]);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
p = onboard();
|
||||
resolve([failedSubmit, successfulSubmit]);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
await expect(p).resolves.toStrictEqual(true);
|
||||
expect(firstSubmitData).toStrictEqual({success: false, error: expect.stringContaining("Invalid ZIP entry path")});
|
||||
expect(secondSubmitData).toStrictEqual({success: true, frontendUrl: null});
|
||||
expect(loadAsyncSpy).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
loadAsyncSpy.mockRestore();
|
||||
}
|
||||
p = onboard();
|
||||
});
|
||||
|
||||
await expect(p).resolves.toStrictEqual(true);
|
||||
expect(firstSubmitData).toStrictEqual({success: false, error: expect.stringContaining("Invalid ZIP entry path")});
|
||||
expect(secondSubmitData).toStrictEqual({success: true, frontendUrl: null});
|
||||
});
|
||||
|
||||
it("rejects ZIP upload payloads with unsafe relative entry paths", async () => {
|
||||
data.removeConfiguration();
|
||||
const loadAsyncSpy = vi
|
||||
.spyOn(JSZip, "loadAsync")
|
||||
.mockResolvedValueOnce({
|
||||
files: {
|
||||
"../dragons.txt": {
|
||||
name: "../dragons.txt",
|
||||
dir: false,
|
||||
// @ts-expect-error minimal mock
|
||||
async: async () => await Promise.resolve(Buffer.from("dragons")),
|
||||
},
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce(createZipRestore());
|
||||
|
||||
try {
|
||||
let p;
|
||||
const [firstSubmitData, secondSubmitData] = await new Promise<[OnboardSubmitResponse, OnboardSubmitResponse]>((resolve, reject) => {
|
||||
mockHttpOnListen.mockImplementationOnce(async () => {
|
||||
try {
|
||||
const failedSubmit = await submitZipPayload(Buffer.from("zip-unsafe-path").toString("base64"), true, false);
|
||||
const successfulSubmit = await submitZipPayload(Buffer.from("zip").toString("base64"), false, false);
|
||||
let p;
|
||||
const [firstSubmitData, secondSubmitData] = await new Promise<[OnboardSubmitResponse, OnboardSubmitResponse]>((resolve, reject) => {
|
||||
mockHttpOnListen.mockImplementationOnce(async () => {
|
||||
try {
|
||||
const failedSubmit = await submitZipPayload(createZipPayload({"../dragons.txt": Buffer.from("dragons")}), true, false);
|
||||
const successfulSubmit = await submitZipPayload(createZipRestore(), false, false);
|
||||
|
||||
resolve([failedSubmit, successfulSubmit]);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
p = onboard();
|
||||
resolve([failedSubmit, successfulSubmit]);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
await expect(p).resolves.toStrictEqual(true);
|
||||
expect(firstSubmitData).toStrictEqual({success: false, error: expect.stringContaining("Unsafe ZIP entry path")});
|
||||
expect(secondSubmitData).toStrictEqual({success: true, frontendUrl: null});
|
||||
expect(loadAsyncSpy).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
loadAsyncSpy.mockRestore();
|
||||
}
|
||||
p = onboard();
|
||||
});
|
||||
|
||||
await expect(p).resolves.toStrictEqual(true);
|
||||
expect(firstSubmitData).toStrictEqual({success: false, error: expect.stringContaining("Unsafe ZIP entry path")});
|
||||
expect(secondSubmitData).toStrictEqual({success: true, frontendUrl: null});
|
||||
});
|
||||
|
||||
it("handles empty ZIP upload payloads", async () => {
|
||||
data.removeConfiguration();
|
||||
const loadAsyncSpy = vi.spyOn(JSZip, "loadAsync").mockResolvedValue(createZipRestore());
|
||||
|
||||
try {
|
||||
let p;
|
||||
const [firstSubmitData, secondSubmitData] = await new Promise<[OnboardSubmitResponse, OnboardSubmitResponse]>((resolve, reject) => {
|
||||
mockHttpOnListen.mockImplementationOnce(async () => {
|
||||
try {
|
||||
const failedSubmit = await submitZipPayload("", true, false);
|
||||
const successfulSubmit = await submitZipPayload(Buffer.from("zip").toString("base64"), false, false);
|
||||
let p;
|
||||
const [firstSubmitData, secondSubmitData] = await new Promise<[OnboardSubmitResponse, OnboardSubmitResponse]>((resolve, reject) => {
|
||||
mockHttpOnListen.mockImplementationOnce(async () => {
|
||||
try {
|
||||
const failedSubmit = await submitZipPayload("", true, false);
|
||||
const successfulSubmit = await submitZipPayload(createZipRestore(), false, false);
|
||||
|
||||
resolve([failedSubmit, successfulSubmit]);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
p = onboard();
|
||||
resolve([failedSubmit, successfulSubmit]);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
await expect(p).resolves.toStrictEqual(true);
|
||||
expect(firstSubmitData).toStrictEqual({success: false, error: "Invalid ZIP payload: missing content"});
|
||||
expect(secondSubmitData).toStrictEqual({success: true, frontendUrl: null});
|
||||
expect(loadAsyncSpy).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
loadAsyncSpy.mockRestore();
|
||||
}
|
||||
p = onboard();
|
||||
});
|
||||
|
||||
await expect(p).resolves.toStrictEqual(true);
|
||||
expect(firstSubmitData).toStrictEqual({success: false, error: "Invalid ZIP payload: missing content"});
|
||||
expect(secondSubmitData).toStrictEqual({success: true, frontendUrl: null});
|
||||
});
|
||||
|
||||
it("handles request stream errors for submit endpoint", async () => {
|
||||
@@ -765,32 +694,26 @@ describe("Onboarding", () => {
|
||||
|
||||
it("handles request stream errors for submit-zip endpoint", async () => {
|
||||
data.removeConfiguration();
|
||||
const loadAsyncSpy = vi.spyOn(JSZip, "loadAsync").mockResolvedValue(createZipRestore());
|
||||
|
||||
try {
|
||||
let p;
|
||||
const [firstSubmitData, secondSubmitData] = await new Promise<[OnboardSubmitResponse, OnboardSubmitResponse]>((resolve, reject) => {
|
||||
mockHttpOnListen.mockImplementationOnce(async () => {
|
||||
try {
|
||||
const failedSubmit = await submitZipPayload("", true, true);
|
||||
const successfulSubmit = await submitZipPayload(Buffer.from("zip").toString("base64"), false, false);
|
||||
let p;
|
||||
const [firstSubmitData, secondSubmitData] = await new Promise<[OnboardSubmitResponse, OnboardSubmitResponse]>((resolve, reject) => {
|
||||
mockHttpOnListen.mockImplementationOnce(async () => {
|
||||
try {
|
||||
const failedSubmit = await submitZipPayload("", true, true);
|
||||
const successfulSubmit = await submitZipPayload(createZipRestore(), false, false);
|
||||
|
||||
resolve([failedSubmit, successfulSubmit]);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
p = onboard();
|
||||
resolve([failedSubmit, successfulSubmit]);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
await expect(p).resolves.toStrictEqual(true);
|
||||
expect(firstSubmitData).toStrictEqual({success: false, error: "request error submit-zip"});
|
||||
expect(secondSubmitData).toStrictEqual({success: true, frontendUrl: null});
|
||||
expect(loadAsyncSpy).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
loadAsyncSpy.mockRestore();
|
||||
}
|
||||
p = onboard();
|
||||
});
|
||||
|
||||
await expect(p).resolves.toStrictEqual(true);
|
||||
expect(firstSubmitData).toStrictEqual({success: false, error: "request error submit-zip"});
|
||||
expect(secondSubmitData).toStrictEqual({success: true, frontendUrl: null});
|
||||
});
|
||||
|
||||
it("passes unknown onboarding routes to static file server", async () => {
|
||||
@@ -963,6 +886,7 @@ describe("Onboarding", () => {
|
||||
network_key: "GENERATE",
|
||||
pan_id: "GENERATE",
|
||||
ext_pan_id: "GENERATE",
|
||||
enable_external_js: false,
|
||||
},
|
||||
serial: {
|
||||
port: SAMPLE_SETTINGS_SAVE.serial.port,
|
||||
|
||||
@@ -3,7 +3,7 @@ import {beforeEach, describe, expect, it} from "vitest";
|
||||
import "./mocks/data";
|
||||
|
||||
import fs from "node:fs";
|
||||
import yaml from "js-yaml";
|
||||
import {dump, load} from "js-yaml";
|
||||
import objectAssignDeep from "object-assign-deep";
|
||||
|
||||
import mockedData from "../lib/util/data";
|
||||
@@ -21,14 +21,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)) {
|
||||
|
||||
@@ -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,37 @@ describe("Utils", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("stable stringify", () => {
|
||||
expect(
|
||||
stringify({
|
||||
a: "a",
|
||||
b: 2,
|
||||
3: "c",
|
||||
d: Buffer.from([1, 2]),
|
||||
e: new Int16Array([0xfffd, 0xff11]),
|
||||
beef: 0xfacen,
|
||||
zed: new BigUint64Array([1n, 0xffffffffn]),
|
||||
ris: [1, undefined, "b", 0xfeefn, Number.NaN, undefined],
|
||||
ls: undefined,
|
||||
}),
|
||||
).toStrictEqual(
|
||||
`{"3":"c","a":"a","b":2,"beef":"64206","d":{"data":[1,2],"type":"Buffer"},"e":{"0":-3,"1":-239},"ris":[1,null,"b","65263",null,null],"zed":{"0":"1","1":"4294967295"}}`,
|
||||
);
|
||||
// @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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "dist",
|
||||
"baseUrl": ".",
|
||||
"rootDir": "lib",
|
||||
"inlineSourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
|
||||
Reference in New Issue
Block a user