mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-08-29 15:18:41 +00:00
Compare commits
174
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd07d70ec2 | ||
|
|
01b25f15bf | ||
|
|
b441091f6f | ||
|
|
9a6b25afa5 | ||
|
|
9473312667 | ||
|
|
22de3f10d8 | ||
|
|
c867ca5182 | ||
|
|
7fbbbc3b17 | ||
|
|
dccf0a98c6 | ||
|
|
cf11b1a188 | ||
|
|
75bb5eed00 | ||
|
|
95d072a788 | ||
|
|
be1cbb5f3b | ||
|
|
07ae13ad2d | ||
|
|
c649d04b1c | ||
|
|
d3ba21828c | ||
|
|
d276190ab8 | ||
|
|
e7deca42bf | ||
|
|
07a4a0bc75 | ||
|
|
368e31b1e3 | ||
|
|
10611654c7 | ||
|
|
f06673506f | ||
|
|
06290da5be | ||
|
|
86dfc90869 | ||
|
|
60786528ae | ||
|
|
3aaa8c1546 | ||
|
|
046817624f | ||
|
|
106a4bdef2 | ||
|
|
a8171a5464 | ||
|
|
2f976b7dca | ||
|
|
29b041ce57 | ||
|
|
828038717f | ||
|
|
5fd4d6b37a | ||
|
|
fcbb7ff44b | ||
|
|
e5e36ac79c | ||
|
|
28e410bbf4 | ||
|
|
b29fc0e493 | ||
|
|
316413b31c | ||
|
|
a4862c9769 | ||
|
|
3348cea190 | ||
|
|
fe96204f0b | ||
|
|
7598b38ed8 | ||
|
|
52d5316377 | ||
|
|
32506b4e8a | ||
|
|
3bec9e87b1 | ||
|
|
b46c77bff0 | ||
|
|
e6e0b6f8f2 | ||
|
|
756a824488 | ||
|
|
83ab5228ff | ||
|
|
4f18b4e38c | ||
|
|
5e56c454c3 | ||
|
|
bbad134e8e | ||
|
|
2d941000a7 | ||
|
|
4ad1ab21e6 | ||
|
|
6835cbf5a7 | ||
|
|
b8d388ca7a | ||
|
|
c9ddef703a | ||
|
|
4174e9cb78 | ||
|
|
752ab37302 | ||
|
|
d6dea17961 | ||
|
|
e78b23f5eb | ||
|
|
22d85a6729 | ||
|
|
a9ce4b2522 | ||
|
|
e889592baf | ||
|
|
576e1954ef | ||
|
|
8940f6dabf | ||
|
|
fa12ebe27c | ||
|
|
3f7c0ca715 | ||
|
|
66db1bdbcd | ||
|
|
a80c2db4c6 | ||
|
|
e475de15c9 | ||
|
|
2ca80b8572 | ||
|
|
912fe4c250 | ||
|
|
74478fd955 | ||
|
|
5591207dea | ||
|
|
83ca2746a7 | ||
|
|
8f0d981765 | ||
|
|
6e557162cb | ||
|
|
a2973f21f6 | ||
|
|
f88d99294b | ||
|
|
344776b633 | ||
|
|
4b0c3067ff | ||
|
|
e8ba0b24f8 | ||
|
|
2cfdd807ed | ||
|
|
aa909a8a62 | ||
|
|
f867694831 | ||
|
|
740de2d363 | ||
|
|
e8a7e6f29d | ||
|
|
3760e63183 | ||
|
|
dcb16b8271 | ||
|
|
31fb3a6e89 | ||
|
|
0018ca8f80 | ||
|
|
2f26083ce1 | ||
|
|
8d5fdada38 | ||
|
|
6ab52df08a | ||
|
|
0140708076 | ||
|
|
7775ac5cd2 | ||
|
|
814552a615 | ||
|
|
280442b671 | ||
|
|
628f97f498 | ||
|
|
b58503bb57 | ||
|
|
98a0f94870 | ||
|
|
72be1d746b | ||
|
|
20ab0a2a4f | ||
|
|
87689523ae | ||
|
|
2cbf740f1b | ||
|
|
262139a43f | ||
|
|
e85ff5513c | ||
|
|
9a7cac1e4c | ||
|
|
679436e09b | ||
|
|
c6e7d2dd16 | ||
|
|
29ee3a7552 | ||
|
|
0377f965a3 | ||
|
|
1de8609456 | ||
|
|
95c67c0e22 | ||
|
|
2f44449953 | ||
|
|
299b9e64f5 | ||
|
|
719de7adcf | ||
|
|
758a80b30c | ||
|
|
f4f97d1920 | ||
|
|
a68419f0f5 | ||
|
|
ced6d93d20 | ||
|
|
a81e90b981 | ||
|
|
0cc3b1e7f1 | ||
|
|
fd8bafc81d | ||
|
|
68daf4f1de | ||
|
|
41f6886591 | ||
|
|
582f153896 | ||
|
|
6c2c874641 | ||
|
|
120f6ac779 | ||
|
|
de58711e11 | ||
|
|
ea6ccac938 | ||
|
|
ef8dcb4dc2 | ||
|
|
babf7a17d4 | ||
|
|
f9e1bac588 | ||
|
|
6aac849452 | ||
|
|
2b5a670ba2 | ||
|
|
58feb78362 | ||
|
|
c6e13ccb45 | ||
|
|
ac8c45f67f | ||
|
|
a8364ad67b | ||
|
|
e2b49113c7 | ||
|
|
7b9407d020 | ||
|
|
873ad6082c | ||
|
|
8beaf488f7 | ||
|
|
7a1c2d52f0 | ||
|
|
5a79c338d4 | ||
|
|
16fd422c4a | ||
|
|
e5f70d5ba8 | ||
|
|
0618aca73a | ||
|
|
cfc7ab8dd8 | ||
|
|
56aab4e59c | ||
|
|
7075b78a46 | ||
|
|
b31e8c286e | ||
|
|
2c5b536134 | ||
|
|
52201c96bf | ||
|
|
bf964cc6d9 | ||
|
|
1e114dee70 | ||
|
|
897aa8fb7c | ||
|
|
bbcbed12d5 | ||
|
|
36c4db7f8f | ||
|
|
5458e5084d | ||
|
|
744e4a6d5c | ||
|
|
6ea21c383b | ||
|
|
1a1c09449c | ||
|
|
9426c3ea11 | ||
|
|
4d9e9504ef | ||
|
|
32499d14d5 | ||
|
|
c4fd415cbe | ||
|
|
502030c016 | ||
|
|
7a05464f03 | ||
|
|
15fd9b371e | ||
|
|
04a43652d6 | ||
|
|
7b37e97e00 |
@@ -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
|
||||
|
||||
@@ -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@v5
|
||||
- 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/
|
||||
@@ -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@v5
|
||||
- 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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,11 +16,11 @@ 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@v5
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
@@ -31,18 +31,18 @@ jobs:
|
||||
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@v5
|
||||
- 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.10.1"
|
||||
".": "2.14.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.
|
||||
|
||||
+195
@@ -1,5 +1,200 @@
|
||||
# Changelog
|
||||
|
||||
## [2.14.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.13.0...2.14.0) (2026-08-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Allow to preserve device config when removing device ([#32765](https://github.com/Koenkk/zigbee2mqtt/issues/32765)) ([95d072a](https://github.com/Koenkk/zigbee2mqtt/commit/95d072a788e2d3d3588bb9e553a5cb5ae763d0c8))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Apply debounce to messages without payload ([#32791](https://github.com/Koenkk/zigbee2mqtt/issues/32791)) ([1061165](https://github.com/Koenkk/zigbee2mqtt/commit/10611654c711ba2cd7c17b42effc01b66bdcb81d))
|
||||
* Clamp availability ping delay to the maximum supported timeout ([#32937](https://github.com/Koenkk/zigbee2mqtt/issues/32937)) ([dccf0a9](https://github.com/Koenkk/zigbee2mqtt/commit/dccf0a98c6d78cf888f7a9d907da795f285ae50a))
|
||||
* Fix crash on a `null` color in attribute output ([#32784](https://github.com/Koenkk/zigbee2mqtt/issues/32784)) ([3aaa8c1](https://github.com/Koenkk/zigbee2mqtt/commit/3aaa8c15464fbcfbdd7af17a0a46a2fc5df1eabd))
|
||||
* Fix stringify don't emit invalid JSON for an empty typed array ([#32780](https://github.com/Koenkk/zigbee2mqtt/issues/32780)) ([86dfc90](https://github.com/Koenkk/zigbee2mqtt/commit/86dfc908693021dcb52bad364e9a72b3bc82220a))
|
||||
* Fix stringify emit every element of a typed array ([#32779](https://github.com/Koenkk/zigbee2mqtt/issues/32779)) ([6078652](https://github.com/Koenkk/zigbee2mqtt/commit/60786528ae14e51702c7a386dfdbe61032131e28))
|
||||
* Home Assistant: avoid duplicate door names ([#32752](https://github.com/Koenkk/zigbee2mqtt/issues/32752)) ([f066735](https://github.com/Koenkk/zigbee2mqtt/commit/f06673506f5b5ee98310fb856058f2e378dd5f76))
|
||||
* Home Assistant: fix `motor_state` not exposed for SONOFF MINI-ZBRBS ([#32702](https://github.com/Koenkk/zigbee2mqtt/issues/32702)) ([be1cbb5](https://github.com/Koenkk/zigbee2mqtt/commit/be1cbb5f3bbf4f6e8e609592ad0fa52c39c6317f))
|
||||
* Home Assistant: Improve various discovery mappings ([#32842](https://github.com/Koenkk/zigbee2mqtt/issues/32842)) ([c649d04](https://github.com/Koenkk/zigbee2mqtt/commit/c649d04b1ca9676f6574d16a3079e972f95d100c))
|
||||
* **ignore:** bump the minor-patch group across 1 directory with 2 updates ([#32827](https://github.com/Koenkk/zigbee2mqtt/issues/32827)) ([d276190](https://github.com/Koenkk/zigbee2mqtt/commit/d276190ab85447fe67ea1d2ed0d95d67757650b4))
|
||||
* **ignore:** bump the minor-patch group with 2 updates ([#32755](https://github.com/Koenkk/zigbee2mqtt/issues/32755)) ([106a4bd](https://github.com/Koenkk/zigbee2mqtt/commit/106a4bdef223be8b63e34c0b81df3c55e735441e))
|
||||
* **ignore:** bump the minor-patch group with 3 updates ([#32949](https://github.com/Koenkk/zigbee2mqtt/issues/32949)) ([9a6b25a](https://github.com/Koenkk/zigbee2mqtt/commit/9a6b25afa58daf86a64759d4635af7635503ea33))
|
||||
* **ignore:** update zigbee-herdsman to 10.8.1 ([#32745](https://github.com/Koenkk/zigbee2mqtt/issues/32745)) ([2f976b7](https://github.com/Koenkk/zigbee2mqtt/commit/2f976b7dca740ec8f7b7bc604d2337c1341b7036))
|
||||
* **ignore:** update zigbee-herdsman to 10.9.0 ([#32828](https://github.com/Koenkk/zigbee2mqtt/issues/32828)) ([07a4a0b](https://github.com/Koenkk/zigbee2mqtt/commit/07a4a0bc75f49d3a78b1f83606f98cf5bcb15dc8))
|
||||
* **ignore:** update zigbee-herdsman to 10.9.1 ([#32959](https://github.com/Koenkk/zigbee2mqtt/issues/32959)) ([9473312](https://github.com/Koenkk/zigbee2mqtt/commit/9473312667a8138262d0087f84d12ebd48a745e9))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.100.0 ([#32960](https://github.com/Koenkk/zigbee2mqtt/issues/32960)) ([01b25f1](https://github.com/Koenkk/zigbee2mqtt/commit/01b25f15bf31c4b64883a8b261f108e994cb5bc9))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.91.0 ([#32746](https://github.com/Koenkk/zigbee2mqtt/issues/32746)) ([a8171a5](https://github.com/Koenkk/zigbee2mqtt/commit/a8171a5464a325a8b24f47162897b895766df1f5))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.92.0 ([#32757](https://github.com/Koenkk/zigbee2mqtt/issues/32757)) ([0468176](https://github.com/Koenkk/zigbee2mqtt/commit/046817624f624934a5e637baba2eedad844ccbb5))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.93.0 ([#32790](https://github.com/Koenkk/zigbee2mqtt/issues/32790)) ([06290da](https://github.com/Koenkk/zigbee2mqtt/commit/06290da5be805dac56d58ba5ac6d1bc7aab5f545))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.94.0 ([#32808](https://github.com/Koenkk/zigbee2mqtt/issues/32808)) ([368e31b](https://github.com/Koenkk/zigbee2mqtt/commit/368e31b1e34ac9281b5238597fa1db3bcc794853))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.95.0 ([#32829](https://github.com/Koenkk/zigbee2mqtt/issues/32829)) ([e7deca4](https://github.com/Koenkk/zigbee2mqtt/commit/e7deca42bfaef6cf13a5f4a855e6a3fcda825c08))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.96.0 ([#32841](https://github.com/Koenkk/zigbee2mqtt/issues/32841)) ([d3ba218](https://github.com/Koenkk/zigbee2mqtt/commit/d3ba21828cf809a6bea66cc7b0c99d9ce37632f6))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.97.0 ([#32848](https://github.com/Koenkk/zigbee2mqtt/issues/32848)) ([07ae13a](https://github.com/Koenkk/zigbee2mqtt/commit/07ae13ad2d7025f8af0acfb753558d7c0320bff2))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.98.0 ([#32879](https://github.com/Koenkk/zigbee2mqtt/issues/32879)) ([75bb5ee](https://github.com/Koenkk/zigbee2mqtt/commit/75bb5eed0085ebb7184afbce21c8dd73b7f0f09d))
|
||||
* **ignore:** update zigbee-herdsman-converters to 26.99.0 ([#32899](https://github.com/Koenkk/zigbee2mqtt/issues/32899)) ([cf11b1a](https://github.com/Koenkk/zigbee2mqtt/commit/cf11b1a188fdf160f181fde9eb4925ec0054eac9))
|
||||
* Improve Home Assistant cover state discovery ([c867ca5](https://github.com/Koenkk/zigbee2mqtt/commit/c867ca5182d457b15ca0176b1eece83a6e677055))
|
||||
* Preserve duration in state ([#32900](https://github.com/Koenkk/zigbee2mqtt/issues/32900)) ([7fbbbc3](https://github.com/Koenkk/zigbee2mqtt/commit/7fbbbc3b17f7f1cc6e8ada0e70d57287ac1a9bf2))
|
||||
* Replace express-static-gzip and finalhandler with srvx ([#32685](https://github.com/Koenkk/zigbee2mqtt/issues/32685)) ([29b041c](https://github.com/Koenkk/zigbee2mqtt/commit/29b041ce57bf42c8ae820318c7494e2d2e87f8ff))
|
||||
* Replace object-assign-deep with local utility ([#32684](https://github.com/Koenkk/zigbee2mqtt/issues/32684)) ([8280387](https://github.com/Koenkk/zigbee2mqtt/commit/828038717fcf659fd368d9a008364eb0ba707529))
|
||||
* Warn when setting unsupported device option ([#32928](https://github.com/Koenkk/zigbee2mqtt/issues/32928)) ([b441091](https://github.com/Koenkk/zigbee2mqtt/commit/b441091f6fd51dbd77f0c75d17064ea19adc8dd8))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
+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.10/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'.`);
|
||||
|
||||
+32
-21
@@ -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,10 +462,13 @@ export class Controller {
|
||||
async iteratePayloadAttributeOutput(topicRoot: string, payload: KeyValue, options: Partial<MqttPublishOptions>): Promise<void> {
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
let subPayload = value;
|
||||
let message = null;
|
||||
let message: string | undefined;
|
||||
|
||||
// Special cases
|
||||
if (key === "color" && utils.objectHasProperties(subPayload, ["r", "g", "b"])) {
|
||||
// `objectHasProperties` indexes its argument, so it has to be given an object.
|
||||
// The null check three lines below is too late: `color` is nullable like any
|
||||
// other attribute, and a null one reaches here before that branch runs.
|
||||
if (key === "color" && subPayload != null && utils.objectHasProperties(subPayload, ["r", "g", "b"])) {
|
||||
subPayload = [subPayload.r, subPayload.g, subPayload.b];
|
||||
}
|
||||
|
||||
@@ -472,7 +483,7 @@ export class Controller {
|
||||
message = typeof subPayload === "string" ? subPayload : stringify(subPayload);
|
||||
}
|
||||
|
||||
if (message !== null) {
|
||||
if (message !== undefined) {
|
||||
await this.mqtt.publish(`${topicRoot}${key}`, message, options);
|
||||
}
|
||||
}
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,12 @@ import * as settings from "../util/settings";
|
||||
import utils from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
/**
|
||||
* Upper bound for a `setTimeout` delay. Node.js stores the delay as a 32-bit signed integer; anything above this
|
||||
* is coerced to `1`, which would turn an ever-growing backoff into a tight loop instead of an ever-longer wait.
|
||||
*/
|
||||
const MAX_TIMEOUT = 2147483647;
|
||||
|
||||
const RETRIEVE_ON_RECONNECT: readonly {keys: string[]; condition?: (state: KeyValue) => boolean}[] = [
|
||||
{keys: ["state"]},
|
||||
{keys: ["brightness"], condition: (state: KeyValue): boolean => state.state === "ON"},
|
||||
@@ -108,7 +114,10 @@ export default class Availability extends Extension {
|
||||
// If device did not check in, ping it, if that fails it will be marked as offline
|
||||
this.timers.set(
|
||||
device.ieeeAddr,
|
||||
setTimeout(this.addToPingQueue.bind(this, device), (this.getTimeout(device) + utils.seconds(1) + jitter) * backoff),
|
||||
setTimeout(
|
||||
this.addToPingQueue.bind(this, device),
|
||||
Math.min((this.getTimeout(device) + utils.seconds(1) + jitter) * backoff, MAX_TIMEOUT),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -322,6 +331,9 @@ export default class Availability extends Extension {
|
||||
options,
|
||||
state,
|
||||
device: device.zh,
|
||||
/* v8 ignore start */
|
||||
deviceExposesChanged: (): void => this.eventBus.emitExposesAndDevicesChanged(device),
|
||||
/* v8 ignore stop */
|
||||
/* v8 ignore next */
|
||||
publish: (payload: KeyValue) => this.publishEntityState(device, payload),
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import assert from "node:assert";
|
||||
import bind from "bind-decorator";
|
||||
import debounce from "debounce";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {Zcl} from "zigbee-herdsman";
|
||||
import type {TClusterAttributeKeys} from "zigbee-herdsman/dist/zspec/zcl/definition/clusters-types";
|
||||
import type {ClusterName} from "zigbee-herdsman/dist/zspec/zcl/definition/tstype";
|
||||
@@ -10,6 +9,7 @@ import Group from "../model/group";
|
||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from "../types/api";
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import {stringify} from "../util/stringify";
|
||||
import utils, {DEFAULT_BIND_GROUP_ID} from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
|
||||
+75
-26
@@ -1,9 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import JSZip from "jszip";
|
||||
import objectAssignDeep from "object-assign-deep";
|
||||
import {zip} from "fflate";
|
||||
import type winston from "winston";
|
||||
import Transport from "winston-transport";
|
||||
import {Zcl} from "zigbee-herdsman";
|
||||
@@ -14,7 +12,9 @@ import type Group from "../model/group";
|
||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTDevice, Zigbee2MQTTResponse, Zigbee2MQTTResponseEndpoints} from "../types/api";
|
||||
import data from "../util/data";
|
||||
import logger from "../util/logger";
|
||||
import {objectAssignDeep} from "../util/objectAssignDeep";
|
||||
import * as settings from "../util/settings";
|
||||
import {stringify} from "../util/stringify";
|
||||
import utils, {assertString, DEFAULT_BIND_GROUP_ID} from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
@@ -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">> {
|
||||
@@ -453,6 +466,20 @@ export default class Bridge extends Extension {
|
||||
|
||||
const ID = message.id;
|
||||
const entity = this.getEntity(entityType, ID);
|
||||
|
||||
if (entity instanceof Device) {
|
||||
const supportedOptions = new Set(Object.keys(settings.schemaJson.definitions.device.properties));
|
||||
for (const option of entity.definition?.options ?? []) {
|
||||
supportedOptions.add(option.property);
|
||||
}
|
||||
|
||||
for (const option of Object.keys(message.options)) {
|
||||
if (!supportedOptions.has(option)) {
|
||||
logger.warning(`Device '${ID}' does not support option '${option}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const oldOptions = objectAssignDeep({}, cleanup(entity.options));
|
||||
|
||||
if (message.options.icon) {
|
||||
@@ -464,12 +491,16 @@ export default class Bridge extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
const restartRequired = settings.changeEntityOptions(ID, message.options);
|
||||
if (restartRequired) this.restartRequired = true;
|
||||
const newRestartRequired = settings.changeEntityOptions(ID, message.options);
|
||||
if (newRestartRequired) this.restartRequired = true;
|
||||
const newOptions = cleanup(entity.options);
|
||||
await this.publishInfo();
|
||||
|
||||
logger.info(`Changed config for ${entityType} ${ID}`);
|
||||
if (newRestartRequired) {
|
||||
logger.info(`New config for ${entityType} ${ID} requires restart to take effect`);
|
||||
} else {
|
||||
logger.info(`Successfully changed config for ${entityType} ${ID}`);
|
||||
}
|
||||
|
||||
this.eventBus.emitEntityOptionsChanged({from: oldOptions, to: newOptions, entity});
|
||||
return utils.getResponse(message, {from: oldOptions, to: newOptions, id: ID, restart_required: this.restartRequired});
|
||||
@@ -653,20 +684,27 @@ export default class Bridge extends Extension {
|
||||
entityType: T,
|
||||
message: string | KeyValue,
|
||||
): Promise<Zigbee2MQTTResponse<T extends "device" ? "bridge/response/device/remove" : "bridge/response/group/remove">> {
|
||||
const ID = typeof message === "object" ? message.id : message.trim();
|
||||
const messageIsObject = typeof message === "object";
|
||||
const ID = messageIsObject ? message.id : message.trim();
|
||||
const entity = this.getEntity(entityType, ID);
|
||||
// note: entity.name is dynamically retrieved, will change once device is removed (friendly => ieee)
|
||||
const friendlyName = entity.name;
|
||||
let block = false;
|
||||
let force = false;
|
||||
let keepConfig = false;
|
||||
let clearCache = false;
|
||||
let blockForceLog = "";
|
||||
|
||||
if (entityType === "device" && typeof message === "object") {
|
||||
block = !!message.block;
|
||||
force = !!message.force;
|
||||
blockForceLog = ` (block: ${block}, force: ${force})`;
|
||||
} else if (entityType === "group" && typeof message === "object") {
|
||||
force = !!message.force;
|
||||
if (entityType === "device" && messageIsObject) {
|
||||
const payload = message as Zigbee2MQTTAPI["bridge/request/device/remove"];
|
||||
block = !!payload.block;
|
||||
force = !!payload.force;
|
||||
keepConfig = !!payload.keep_config;
|
||||
clearCache = !!payload.clear_cache;
|
||||
blockForceLog = ` (block: ${block}, force: ${force}, keep config: ${keepConfig}, clear cache: ${clearCache})`;
|
||||
} else if (entityType === "group" && messageIsObject) {
|
||||
const payload = message as Zigbee2MQTTAPI["bridge/request/group/remove"];
|
||||
force = !!payload.force;
|
||||
blockForceLog = ` (force: ${force})`;
|
||||
}
|
||||
|
||||
@@ -679,12 +717,18 @@ export default class Bridge extends Extension {
|
||||
}
|
||||
|
||||
if (force) {
|
||||
entity.zh.removeFromDatabase();
|
||||
entity.zh.removeFromDatabase(clearCache);
|
||||
} else {
|
||||
await entity.zh.removeFromNetwork();
|
||||
await entity.zh.removeFromNetwork(clearCache);
|
||||
}
|
||||
|
||||
settings.removeDevice(entity.ID as string);
|
||||
if (clearCache) {
|
||||
this.zigbee.removeDeviceFromLookup(entity.ID);
|
||||
}
|
||||
|
||||
if (!keepConfig) {
|
||||
settings.removeDevice(entity.ID as string);
|
||||
}
|
||||
} else {
|
||||
if (force) {
|
||||
entity.zh.removeFromDatabase();
|
||||
@@ -706,19 +750,24 @@ export default class Bridge extends Extension {
|
||||
|
||||
logger.info(`Successfully removed ${entityType} '${friendlyName}'${blockForceLog}`);
|
||||
|
||||
await this.publishGroups();
|
||||
|
||||
if (entity instanceof Device) {
|
||||
await this.publishGroups();
|
||||
await this.publishDevices();
|
||||
// Refresh Cluster definition
|
||||
await this.publishDefinitions();
|
||||
|
||||
const responseData: Zigbee2MQTTAPI["bridge/response/device/remove"] = {id: ID, block, force};
|
||||
const responseData: Zigbee2MQTTAPI["bridge/response/device/remove"] = {
|
||||
id: ID,
|
||||
block,
|
||||
force,
|
||||
keep_config: keepConfig,
|
||||
clear_cache: clearCache,
|
||||
};
|
||||
|
||||
return utils.getResponse(message, responseData);
|
||||
}
|
||||
|
||||
await this.publishGroups();
|
||||
|
||||
const responseData: Zigbee2MQTTAPI["bridge/response/group/remove"] = {id: ID, force};
|
||||
|
||||
return utils.getResponse(
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
+19
-28
@@ -6,14 +6,12 @@ import {createServer as createSecureServer} from "node:https";
|
||||
import type {Socket} from "node:net";
|
||||
import {posix} from "node:path";
|
||||
import bind from "bind-decorator";
|
||||
import expressStaticGzip from "express-static-gzip";
|
||||
import finalhandler from "finalhandler";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import WebSocket from "ws";
|
||||
|
||||
import data from "../util/data";
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import {createStaticFileServer, sendNotFound} from "../util/staticFileServer";
|
||||
import {stringify} from "../util/stringify";
|
||||
import utils from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
@@ -66,46 +64,39 @@ export class Frontend extends Extension {
|
||||
|
||||
return false;
|
||||
};
|
||||
const options: expressStaticGzip.ExpressStaticGzipOptions = {
|
||||
enableBrotli: true,
|
||||
serveStatic: {
|
||||
/* v8 ignore start */
|
||||
setHeaders: (res: ServerResponse, path: string): void => {
|
||||
if (path.endsWith("index.html")) {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
}
|
||||
},
|
||||
/* v8 ignore stop */
|
||||
},
|
||||
};
|
||||
const frontend = (await import(settings.get().frontend.package)) as typeof import("zigbee2mqtt-frontend");
|
||||
const fileServer = expressStaticGzip(frontend.default.getPath(), options);
|
||||
const deviceIconsFileServer = expressStaticGzip(data.joinPath("device_icons"), options);
|
||||
const logError = logger.error.bind(logger);
|
||||
const fileServer = createStaticFileServer(frontend.default.getPath(), logError);
|
||||
const deviceIconsFileServer = createStaticFileServer(data.joinPath("device_icons"), logError);
|
||||
const onRequest = (request: IncomingMessage, response: ServerResponse): void => {
|
||||
const next = finalhandler(request, response);
|
||||
// biome-ignore lint/style/noNonNullAssertion: `Only valid for request obtained from Server`
|
||||
const newUrl = posix.relative(this.baseUrl, request.url!);
|
||||
const url = request.url!;
|
||||
const newUrl = posix.relative(this.baseUrl, url);
|
||||
|
||||
// The request url is not within the frontend base url, so the relative path starts with '..'
|
||||
if (newUrl.startsWith(".")) {
|
||||
next();
|
||||
sendNotFound(request, response);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// The base url itself is a directory, redirect to its trailing slash form so the browser resolves the
|
||||
// relative asset paths in `index.html` against the frontend root instead of against its parent.
|
||||
if (newUrl === "" && !url.endsWith("/")) {
|
||||
response.writeHead(301, {Location: `${url}/`});
|
||||
response.end();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Attach originalUrl so that static-server can perform a redirect to '/' when serving the root directory.
|
||||
// This is necessary for the browser to resolve relative assets paths correctly.
|
||||
request.originalUrl = request.url;
|
||||
request.url = `/${newUrl}`;
|
||||
request.path = request.url;
|
||||
|
||||
if (newUrl.startsWith("device_icons/")) {
|
||||
request.path = request.path.replace("device_icons/", "");
|
||||
request.url = request.url.replace("/device_icons", "");
|
||||
|
||||
deviceIconsFileServer(request, response, next);
|
||||
deviceIconsFileServer(request, response);
|
||||
} else {
|
||||
fileServer(request, response, next);
|
||||
fileServer(request, response);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
+221
-75
@@ -1,10 +1,10 @@
|
||||
import assert from "node:assert";
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import type * as zhc from "zigbee-herdsman-converters";
|
||||
import type {Zh} from "zigbee-herdsman-converters/lib/types";
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import {stringify} from "../util/stringify";
|
||||
import utils, {assertBinaryExpose, assertEnumExpose, assertNumericExpose, isBinaryExpose, isEnumExpose, isNumericExpose} from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
@@ -48,17 +48,31 @@ const GROUP_SUPPORTED_TYPES: ReadonlyArray<string> = ["light", "switch", "lock",
|
||||
const COVER_OPENING_LOOKUP: ReadonlyArray<string> = ["opening", "open", "forward", "up", "rising"];
|
||||
const COVER_CLOSING_LOOKUP: ReadonlyArray<string> = ["closing", "close", "backward", "back", "reverse", "down", "declining"];
|
||||
const COVER_STOPPED_LOOKUP: ReadonlyArray<string> = ["stopped", "stop", "pause", "paused"];
|
||||
const SWITCH_DIFFERENT: ReadonlyArray<string> = ["valve_detection", "window_detection", "auto_lock", "away_mode"];
|
||||
const CONFIG_SWITCH_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
auto_lock: {entity_category: "config", icon: "mdi:lock"},
|
||||
away_mode: {entity_category: "config", icon: "mdi:home-export-outline"},
|
||||
comfort_smiley: {entity_category: "config", icon: "mdi:emoticon-happy-outline"},
|
||||
enable_display: {entity_category: "config", icon: "mdi:monitor"},
|
||||
indicator: {entity_category: "config", icon: "mdi:led-on"},
|
||||
tilt_mode: {entity_category: "config", icon: "mdi:angle-acute"},
|
||||
valve_detection: {entity_category: "config", icon: "mdi:pipe-valve"},
|
||||
window_detection: {entity_category: "config", icon: "mdi:window-open-variant"},
|
||||
} as const;
|
||||
const SWITCH_DIFFERENT: ReadonlyArray<string> = Object.keys(CONFIG_SWITCH_DISCOVERY_LOOKUP);
|
||||
const BINARY_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
activity_led_indicator: {icon: "mdi:led-on"},
|
||||
area1Occupancy: {device_class: "occupancy"},
|
||||
area2Occupancy: {device_class: "occupancy"},
|
||||
area3Occupancy: {device_class: "occupancy"},
|
||||
area4Occupancy: {device_class: "occupancy"},
|
||||
auto_lock: {entity_category: "config", icon: "mdi:lock"},
|
||||
auto_off: {icon: "mdi:flash-auto"},
|
||||
away_mode: {entity_category: "config", icon: "mdi:home-export-outline"},
|
||||
battery_low: {entity_category: "diagnostic", device_class: "battery"},
|
||||
button_lock: {entity_category: "config", icon: "mdi:lock"},
|
||||
calibration: {entity_category: "config", icon: "mdi:progress-wrench"},
|
||||
calibration_left: {entity_category: "config", icon: "mdi:progress-wrench"},
|
||||
calibration_right: {entity_category: "config", icon: "mdi:progress-wrench"},
|
||||
capabilities_configurable_curve: {entity_category: "diagnostic", icon: "mdi:tune"},
|
||||
capabilities_forward_phase_control: {entity_category: "diagnostic", icon: "mdi:tune"},
|
||||
capabilities_overload_detection: {entity_category: "diagnostic", icon: "mdi:tune"},
|
||||
@@ -71,16 +85,22 @@ const BINARY_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
consumer_connected: {device_class: "plug"},
|
||||
contact: {device_class: "door"},
|
||||
garage_door_contact: {device_class: "garage_door", payload_on: false, payload_off: true},
|
||||
frost_protection: {entity_category: "config", icon: "mdi:snowflake-thermometer"},
|
||||
heating_stop: {entity_category: "config", icon: "mdi:radiator-off"},
|
||||
eco_mode: {entity_category: "config", icon: "mdi:leaf"},
|
||||
enable_display: {entity_category: "config", icon: "mdi:monitor"},
|
||||
expose_pin: {entity_category: "config", icon: "mdi:pin"},
|
||||
flip_indicator_light: {entity_category: "config", icon: "mdi:arrow-left-right"},
|
||||
gas: {device_class: "gas"},
|
||||
indicator: {entity_category: "config", icon: "mdi:led-on"},
|
||||
indicator_mode: {entity_category: "config", icon: "mdi:led-on"},
|
||||
invert_cover: {entity_category: "config", icon: "mdi:arrow-left-right"},
|
||||
led_disabled_night: {entity_category: "config", icon: "mdi:led-off"},
|
||||
led_indication: {entity_category: "config", icon: "mdi:led-on"},
|
||||
led_enable: {entity_category: "config", icon: "mdi:led-on"},
|
||||
motor_reversal: {entity_category: "config", icon: "mdi:arrow-left-right"},
|
||||
motor_reversal_left: {entity_category: "config", icon: "mdi:arrow-left-right"},
|
||||
motor_reversal_right: {entity_category: "config", icon: "mdi:arrow-left-right"},
|
||||
moving: {device_class: "moving"},
|
||||
no_position_support: {entity_category: "config", icon: "mdi:minus-circle-outline"},
|
||||
noise_detected: {device_class: "sound"},
|
||||
@@ -101,14 +121,15 @@ const BINARY_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
temperature_scale: {entity_category: "config", icon: "mdi:temperature-celsius"},
|
||||
test: {entity_category: "diagnostic", icon: "mdi:test-tube"},
|
||||
th_heater: {icon: "mdi:heat-wave"},
|
||||
tilt_mode: {entity_category: "config", icon: "mdi:angle-acute"},
|
||||
trigger_indicator: {icon: "mdi:led-on"},
|
||||
valve_alarm: {device_class: "problem"},
|
||||
valve_detection: {icon: "mdi:pipe-valve"},
|
||||
valve_detection: {entity_category: "config", icon: "mdi:pipe-valve"},
|
||||
valve_state: {device_class: "opening"},
|
||||
vibration: {device_class: "vibration"},
|
||||
water_leak: {device_class: "moisture"},
|
||||
window: {device_class: "window"},
|
||||
window_detection: {icon: "mdi:window-open-variant"},
|
||||
window_detection: {entity_category: "config", icon: "mdi:window-open-variant"},
|
||||
window_open: {device_class: "window"},
|
||||
} as const;
|
||||
const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
@@ -120,7 +141,7 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
alarm_temperature_min: {device_class: "temperature", entity_category: "config", icon: "mdi:thermometer-low"},
|
||||
angle: {icon: "angle-acute"},
|
||||
angle_axis: {icon: "angle-acute"},
|
||||
apparent_temperature: {icon: "mdi:thermometer-lines", state_class: "measurement"},
|
||||
apparent_temperature: {device_class: "temperature", icon: "mdi:thermometer-lines", preserve_name: true, state_class: "measurement"},
|
||||
aqi: {device_class: "aqi", state_class: "measurement"},
|
||||
auto_relock_time: {entity_category: "config", icon: "mdi:timer"},
|
||||
away_preset_days: {entity_category: "config", icon: "mdi:timer"},
|
||||
@@ -136,9 +157,27 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
boost_heating_countdown_time_set: {entity_category: "config", icon: "mdi:timer"},
|
||||
boost_time: {entity_category: "config", icon: "mdi:timer"},
|
||||
calibration: {entity_category: "config", icon: "mdi:wrench-clock"},
|
||||
calibration_button_hold_time: {
|
||||
enabled_by_default: false,
|
||||
entity_category: "config",
|
||||
icon: "mdi:wrench-clock",
|
||||
},
|
||||
calibration_closing_time: {entity_category: "config", icon: "mdi:wrench-clock"},
|
||||
calibration_motor_start_delay: {
|
||||
enabled_by_default: false,
|
||||
entity_category: "config",
|
||||
icon: "mdi:wrench-clock",
|
||||
},
|
||||
calibration_opening_time: {entity_category: "config", icon: "mdi:wrench-clock"},
|
||||
calibration_time: {entity_category: "config", icon: "mdi:wrench-clock"},
|
||||
calibration_time_left: {entity_category: "config", icon: "mdi:wrench-clock"},
|
||||
calibration_time_right: {entity_category: "config", icon: "mdi:wrench-clock"},
|
||||
co2: {device_class: "carbon_dioxide", state_class: "measurement"},
|
||||
comfort_humidity_max: {device_class: "humidity", entity_category: "config", icon: "mdi:water-percent"},
|
||||
comfort_humidity_min: {device_class: "humidity", entity_category: "config", icon: "mdi:water-percent"},
|
||||
comfort_temperature: {entity_category: "config", icon: "mdi:thermometer"},
|
||||
comfort_temperature_max: {device_class: "temperature", entity_category: "config", icon: "mdi:thermometer-high"},
|
||||
comfort_temperature_min: {device_class: "temperature", entity_category: "config", icon: "mdi:thermometer-low"},
|
||||
cpu_temperature: {
|
||||
device_class: "temperature",
|
||||
entity_category: "diagnostic",
|
||||
@@ -149,29 +188,36 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
current_phase_b: {device_class: "current", state_class: "measurement"},
|
||||
current_phase_c: {device_class: "current", state_class: "measurement"},
|
||||
deadzone_temperature: {entity_category: "config", icon: "mdi:thermometer"},
|
||||
detection_delay: {entity_category: "config", icon: "mdi:timer"},
|
||||
detection_interval: {icon: "mdi:timer"},
|
||||
device_temperature: {
|
||||
device_class: "temperature",
|
||||
entity_category: "diagnostic",
|
||||
state_class: "measurement",
|
||||
},
|
||||
dew_point: {icon: "mdi:thermometer-water", state_class: "measurement"},
|
||||
dew_point: {device_class: "temperature", icon: "mdi:thermometer-water", preserve_name: true, state_class: "measurement"},
|
||||
distance: {device_class: "distance", state_class: "measurement"},
|
||||
duration: {entity_category: "config", icon: "mdi:timer"},
|
||||
eco2: {device_class: "volatile_organic_compounds_parts", state_class: "measurement"},
|
||||
eco_temperature: {entity_category: "config", icon: "mdi:thermometer"},
|
||||
effect_speed: {
|
||||
enabled_by_default: false,
|
||||
entity_category: "config",
|
||||
icon: "mdi:motion-outline",
|
||||
},
|
||||
energy: {device_class: "energy", state_class: "total_increasing"},
|
||||
external_temperature_input: {device_class: "temperature", icon: "mdi:thermometer"},
|
||||
external_temperature: {device_class: "temperature", icon: "mdi:thermometer", state_class: "measurement"},
|
||||
external_humidity: {device_class: "humidity", icon: "mdi:water-percent", state_class: "measurement"},
|
||||
fading_time: {entity_category: "config", icon: "mdi:timer"},
|
||||
formaldehyd: {state_class: "measurement"},
|
||||
flow: {device_class: "volume_flow_rate", state_class: "measurement"},
|
||||
gas: {device_class: "gas", state_class: "total_increasing", icon: "mdi:meter-gas"},
|
||||
gas_density: {icon: "mdi:google-circles-communities", state_class: "measurement"},
|
||||
gust_speed: {icon: "mdi:weather-windy-variant", state_class: "measurement"},
|
||||
gust_speed: {device_class: "wind_speed", icon: "mdi:weather-windy-variant", preserve_name: true, state_class: "measurement"},
|
||||
hcho: {icon: "mdi:air-filter", state_class: "measurement"},
|
||||
heat_stress: {icon: "mdi:weather-sunny-alert", state_class: "measurement"},
|
||||
humidex: {icon: "mdi:thermometer-alert", state_class: "measurement"},
|
||||
humidex: {device_class: "temperature", icon: "mdi:thermometer-alert", preserve_name: true, state_class: "measurement"},
|
||||
humidity: {device_class: "humidity", state_class: "measurement"},
|
||||
humidity_calibration: {entity_category: "config", icon: "mdi:wrench-clock"},
|
||||
humidity_max: {entity_category: "config", icon: "mdi:water-percent"},
|
||||
@@ -179,6 +225,7 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
illuminance_calibration: {entity_category: "config", icon: "mdi:wrench-clock"},
|
||||
illuminance: {device_class: "illuminance", state_class: "measurement"},
|
||||
illuminance_raw: {state_class: "measurement"},
|
||||
interval_time: {entity_category: "config", icon: "mdi:timer"},
|
||||
internalTemperature: {
|
||||
device_class: "temperature",
|
||||
entity_category: "diagnostic",
|
||||
@@ -192,13 +239,20 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
},
|
||||
load_estimate: {state_class: "measurement"},
|
||||
local_temperature: {device_class: "temperature", state_class: "measurement"},
|
||||
large_motion_detection_distance: {entity_category: "config", icon: "mdi:signal-distance-variant"},
|
||||
large_motion_detection_sensitivity: {entity_category: "config", icon: "mdi:motion-sensor"},
|
||||
max_range: {entity_category: "config", icon: "mdi:signal-distance-variant"},
|
||||
max_temperature: {entity_category: "config", icon: "mdi:thermometer-high"},
|
||||
max_temperature_limit: {entity_category: "config", icon: "mdi:thermometer-high"},
|
||||
maximum_range: {entity_category: "config", icon: "mdi:signal-distance-variant"},
|
||||
measurement_interval: {entity_category: "config", icon: "mdi:clock-out"},
|
||||
min_temperature_limit: {entity_category: "config", icon: "mdi:thermometer-low"},
|
||||
min_temperature: {entity_category: "config", icon: "mdi:thermometer-low"},
|
||||
minimum_range: {entity_category: "config", icon: "mdi:signal-distance-variant"},
|
||||
minimum_on_level: {entity_category: "config"},
|
||||
measurement_poll_interval: {entity_category: "config", icon: "mdi:clock-out"},
|
||||
medium_motion_detection_distance: {entity_category: "config", icon: "mdi:signal-distance-variant"},
|
||||
medium_motion_detection_sensitivity: {entity_category: "config", icon: "mdi:motion-sensor"},
|
||||
motion_sensitivity: {entity_category: "config", icon: "mdi:motion-sensor"},
|
||||
noise: {device_class: "sound_pressure", state_class: "measurement"},
|
||||
noise_detect_level: {icon: "mdi:volume-equal"},
|
||||
@@ -234,13 +288,21 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
icon: "mdi:brightness-5",
|
||||
},
|
||||
smoke_density: {icon: "mdi:google-circles-communities", state_class: "measurement"},
|
||||
sensitivity: {entity_category: "config", icon: "mdi:tune"},
|
||||
small_detection_distance: {entity_category: "config", icon: "mdi:signal-distance-variant"},
|
||||
small_detection_sensitivity: {entity_category: "config", icon: "mdi:motion-sensor"},
|
||||
soil_calibration: {entity_category: "config", icon: "mdi:wrench-clock"},
|
||||
soil_fertility: {device_class: "conductivity", state_class: "measurement"},
|
||||
soil_moisture: {device_class: "moisture", state_class: "measurement"},
|
||||
soil_sampling: {entity_category: "config", icon: "mdi:clock-out"},
|
||||
soil_warning: {entity_category: "config", icon: "mdi:water-percent-alert"},
|
||||
temperature: {device_class: "temperature", state_class: "measurement"},
|
||||
temperature_probe: {device_class: "temperature", state_class: "measurement"},
|
||||
temperature_calibration: {entity_category: "config", icon: "mdi:wrench-clock"},
|
||||
temperature_max: {entity_category: "config", icon: "mdi:thermometer-plus"},
|
||||
temperature_min: {entity_category: "config", icon: "mdi:thermometer-minus"},
|
||||
temperature_offset: {icon: "mdi:thermometer-lines"},
|
||||
temperature_sampling: {entity_category: "config", icon: "mdi:clock-out"},
|
||||
transition: {entity_category: "config", icon: "mdi:transition"},
|
||||
trigger_count: {icon: "mdi:counter", enabled_by_default: false, state_class: "measurement"},
|
||||
uv_index: {icon: "mdi:white-balance-sunny", state_class: "measurement"},
|
||||
@@ -255,7 +317,7 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
device_class: "water",
|
||||
state_class: "total_increasing",
|
||||
},
|
||||
wind_chill: {icon: "mdi:snowflake-thermometer", state_class: "measurement"},
|
||||
wind_chill: {device_class: "temperature", icon: "mdi:snowflake-thermometer", preserve_name: true, state_class: "measurement"},
|
||||
wind_direction: {icon: "mdi:compass-outline", state_class: "measurement"},
|
||||
wind_speed: {device_class: "wind_speed", icon: "mdi:weather-windy", state_class: "measurement"},
|
||||
x: {icon: "mdi:axis-x-arrow", state_class: "measurement"},
|
||||
@@ -278,7 +340,7 @@ const ENUM_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
effect: {enabled_by_default: false, icon: "mdi:palette"},
|
||||
force: {entity_category: "config", icon: "mdi:valve"},
|
||||
keep_time: {entity_category: "config", icon: "mdi:av-timer"},
|
||||
identify: {device_class: "identify"},
|
||||
identify: {entity_category: "diagnostic", device_class: "identify"},
|
||||
keypad_lockout: {entity_category: "config", icon: "mdi:lock"},
|
||||
load_detection_mode: {entity_category: "config", icon: "mdi:tune"},
|
||||
load_dimmable: {entity_category: "config", icon: "mdi:chart-bell-curve"},
|
||||
@@ -287,6 +349,8 @@ const ENUM_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
mode_phase_control: {entity_category: "config", icon: "mdi:tune"},
|
||||
mode: {entity_category: "config", icon: "mdi:tune"},
|
||||
mode_switch: {icon: "mdi:tune"},
|
||||
motor_direction: {entity_category: "config", icon: "mdi:arrow-left-right"},
|
||||
motor_state: {entity_category: "diagnostic", icon: "mdi:state-machine"},
|
||||
motion_sensitivity: {entity_category: "config", icon: "mdi:tune"},
|
||||
operation_mode: {entity_category: "config", icon: "mdi:tune"},
|
||||
power_on_behavior: {entity_category: "config", icon: "mdi:power-settings"},
|
||||
@@ -297,11 +361,13 @@ const ENUM_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
sensitivity: {entity_category: "config", icon: "mdi:tune"},
|
||||
sensor: {icon: "mdi:tune"},
|
||||
sensors_type: {entity_category: "config", icon: "mdi:tune"},
|
||||
set_limits: {entity_category: "config", icon: "mdi:ray-start-end"},
|
||||
sound_volume: {entity_category: "config", icon: "mdi:volume-high"},
|
||||
status: {icon: "mdi:state-machine"},
|
||||
switch_type: {entity_category: "config", icon: "mdi:tune"},
|
||||
temperature_display_mode: {entity_category: "config", icon: "mdi:thermometer"},
|
||||
temperature_sensor_select: {entity_category: "config", icon: "mdi:home-thermometer"},
|
||||
temperature_unit: {entity_category: "config", icon: "mdi:temperature-celsius"},
|
||||
thermostat_unit: {entity_category: "config", icon: "mdi:thermometer"},
|
||||
update: {device_class: "update"},
|
||||
volume: {entity_category: "config", icon: "mdi: volume-high"},
|
||||
@@ -311,9 +377,14 @@ const ENUM_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
const LIST_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
action: {icon: "mdi:gesture-double-tap"},
|
||||
color_options: {icon: "mdi:palette"},
|
||||
effect_color: {
|
||||
enabled_by_default: false,
|
||||
entity_category: "config",
|
||||
icon: "mdi:palette-swatch",
|
||||
},
|
||||
level_config: {entity_category: "diagnostic"},
|
||||
programming_mode: {icon: "mdi:calendar-clock"},
|
||||
schedule_settings: {icon: "mdi:calendar-clock"},
|
||||
schedule_settings: {entity_category: "config", icon: "mdi:calendar-clock"},
|
||||
} as const;
|
||||
|
||||
const featurePropertyWithoutEndpoint = (feature: zhc.Feature): string => {
|
||||
@@ -324,6 +395,48 @@ const featurePropertyWithoutEndpoint = (feature: zhc.Feature): string => {
|
||||
return feature.property;
|
||||
};
|
||||
|
||||
const applyHomeAssistantExposeMetadata = (payload: DiscoveryEntry, homeAssistant: zhc.Expose["homeassistant"]): void => {
|
||||
if (!homeAssistant) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (homeAssistant.type !== undefined) {
|
||||
payload.type = homeAssistant.type;
|
||||
}
|
||||
|
||||
if (homeAssistant.schema !== undefined) {
|
||||
payload.discovery_payload.schema = homeAssistant.schema;
|
||||
}
|
||||
|
||||
if (homeAssistant.entityCategory !== undefined) {
|
||||
payload.discovery_payload.entity_category = homeAssistant.entityCategory;
|
||||
}
|
||||
|
||||
if (homeAssistant.deviceClass !== undefined) {
|
||||
payload.discovery_payload.device_class = homeAssistant.deviceClass;
|
||||
}
|
||||
|
||||
if (homeAssistant.enabledByDefault !== undefined) {
|
||||
payload.discovery_payload.enabled_by_default = homeAssistant.enabledByDefault;
|
||||
}
|
||||
|
||||
if (homeAssistant.icon !== undefined) {
|
||||
payload.discovery_payload.icon = homeAssistant.icon;
|
||||
}
|
||||
|
||||
if (homeAssistant.name !== undefined) {
|
||||
payload.discovery_payload.name = homeAssistant.name;
|
||||
}
|
||||
|
||||
if (homeAssistant.valueTemplate !== undefined) {
|
||||
if (homeAssistant.valueTemplate === null) {
|
||||
delete payload.discovery_payload.value_template;
|
||||
} else {
|
||||
payload.discovery_payload.value_template = homeAssistant.valueTemplate;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This class handles the bridge entity configuration for Home Assistant Discovery.
|
||||
*/
|
||||
@@ -411,9 +524,13 @@ export class HomeAssistant extends Extension {
|
||||
) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension);
|
||||
if (settings.get().advanced.output === "attribute") {
|
||||
throw new Error("Home Assistant integration is not possible with attribute output!");
|
||||
throw new Error("Home Assistant integration requires 'output: json' under 'advanced'");
|
||||
}
|
||||
|
||||
// TODO (Z2M 3.0.0): Prevent starting without cache_state, instead of warning
|
||||
// if (!settings.get().advanced.cache_state) {
|
||||
// throw new Error("Home Assistant integration is not possible without caching states! Set `cache_state: true` under `advanced`");
|
||||
// }
|
||||
const haSettings = settings.get().homeassistant;
|
||||
assert(haSettings.enabled, `Home Assistant extension created with setting 'enabled: false'`);
|
||||
this.discoveryTopic = haSettings.discovery_topic;
|
||||
@@ -429,8 +546,9 @@ export class HomeAssistant extends Extension {
|
||||
}
|
||||
|
||||
override async start(): Promise<void> {
|
||||
// TODO (Z2M 3.0.0): Prevent starting without cache_state, instead of warning
|
||||
if (!settings.get().advanced.cache_state) {
|
||||
logger.warning("In order for Home Assistant integration to work properly set `cache_state: true");
|
||||
logger.warning("In order for Home Assistant integration to work properly, set `cache_state: true` under `advanced`");
|
||||
}
|
||||
|
||||
this.zigbee2MQTTVersion = (await utils.getZigbee2MQTTVersion(false)).version;
|
||||
@@ -590,7 +708,7 @@ export class HomeAssistant extends Extension {
|
||||
name: endpointName ? utils.capitalize(endpointName) : null,
|
||||
payload_off: state.value_off,
|
||||
payload_on: state.value_on,
|
||||
value_template: `{{ value_json.${property} }}`,
|
||||
value_template: `{{ value_json["${property}"] }}`,
|
||||
command_topic: true,
|
||||
command_topic_prefix: endpointName,
|
||||
},
|
||||
@@ -602,25 +720,26 @@ export class HomeAssistant extends Extension {
|
||||
discoveryEntry.discovery_payload.state_off = state.value_off;
|
||||
discoveryEntry.discovery_payload.state_on = state.value_on;
|
||||
discoveryEntry.object_id = property;
|
||||
|
||||
if (property === "window_detection") {
|
||||
discoveryEntry.discovery_payload.icon = "mdi:window-open-variant";
|
||||
}
|
||||
Object.assign(discoveryEntry.discovery_payload, CONFIG_SWITCH_DISCOVERY_LOOKUP[property]);
|
||||
}
|
||||
|
||||
discoveryEntries.push(discoveryEntry);
|
||||
break;
|
||||
}
|
||||
case "climate": {
|
||||
const setpointProperties = ["occupied_heating_setpoint", "current_heating_setpoint"];
|
||||
const setpoint = (firstExpose as zhc.Climate).features.filter(isNumericExpose).find((f) => setpointProperties.includes(f.name));
|
||||
const heatingSetpoint = (firstExpose as zhc.Climate).features
|
||||
.filter(isNumericExpose)
|
||||
.find((f) => ["occupied_heating_setpoint", "current_heating_setpoint"].includes(f.name));
|
||||
const coolingSetpoint = (firstExpose as zhc.Climate).features
|
||||
.filter(isNumericExpose)
|
||||
.find((f) => f.name === "occupied_cooling_setpoint");
|
||||
const primarySetpoint = heatingSetpoint ?? coolingSetpoint;
|
||||
assert(
|
||||
setpoint && setpoint.value_min !== undefined && setpoint.value_max !== undefined,
|
||||
primarySetpoint && primarySetpoint.value_min !== undefined && primarySetpoint.value_max !== undefined,
|
||||
"No setpoint found or it is missing value_min/max",
|
||||
);
|
||||
const temperature = (firstExpose as zhc.Climate).features.find((f) => f.name === "local_temperature");
|
||||
assert(temperature, "No temperature found");
|
||||
|
||||
const discoveryEntry: DiscoveryEntry = {
|
||||
type: "climate",
|
||||
object_id: endpointName ? `climate_${endpointName}` : "climate",
|
||||
@@ -631,12 +750,12 @@ export class HomeAssistant extends Extension {
|
||||
state_topic: false,
|
||||
temperature_unit: "C",
|
||||
// Setpoint
|
||||
temp_step: setpoint.value_step,
|
||||
min_temp: setpoint.value_min.toString(),
|
||||
max_temp: setpoint.value_max.toString(),
|
||||
temp_step: primarySetpoint.value_step,
|
||||
min_temp: primarySetpoint.value_min.toString(),
|
||||
max_temp: primarySetpoint.value_max.toString(),
|
||||
// Temperature
|
||||
current_temperature_topic: true,
|
||||
current_temperature_template: `{{ value_json.${temperature.property} }}`,
|
||||
current_temperature_template: `{{ value_json["${temperature.property}"] }}`,
|
||||
command_topic_prefix: endpointName,
|
||||
},
|
||||
};
|
||||
@@ -650,7 +769,7 @@ export class HomeAssistant extends Extension {
|
||||
mode.values.splice(mode.values.indexOf("sleep"), 1);
|
||||
}
|
||||
discoveryEntry.discovery_payload.mode_state_topic = true;
|
||||
discoveryEntry.discovery_payload.mode_state_template = `{{ value_json.${mode.property} }}`;
|
||||
discoveryEntry.discovery_payload.mode_state_template = `{{ value_json["${mode.property}"] }}`;
|
||||
discoveryEntry.discovery_payload.modes = mode.values;
|
||||
discoveryEntry.discovery_payload.mode_command_topic = true;
|
||||
}
|
||||
@@ -659,20 +778,19 @@ export class HomeAssistant extends Extension {
|
||||
if (state) {
|
||||
discoveryEntry.mockProperties.push({property: state.property, value: null});
|
||||
discoveryEntry.discovery_payload.action_topic = true;
|
||||
discoveryEntry.discovery_payload.action_template = `{% set values = {None:None,'idle':'idle','heat':'heating','cool':'cooling','fan_only':'fan'} %}{{ values[value_json.${state.property}] }}`;
|
||||
discoveryEntry.discovery_payload.action_template = `{% set values = {None:None,'idle':'idle','heat':'heating','cool':'cooling','fan_only':'fan'} %}{{ values[value_json["${state.property}"]] }}`;
|
||||
}
|
||||
|
||||
const coolingSetpoint = (firstExpose as zhc.Climate).features.find((f) => f.name === "occupied_cooling_setpoint");
|
||||
if (coolingSetpoint) {
|
||||
discoveryEntry.discovery_payload.temperature_low_command_topic = setpoint.name;
|
||||
discoveryEntry.discovery_payload.temperature_low_state_template = `{{ value_json.${setpoint.property} }}`;
|
||||
if (heatingSetpoint && coolingSetpoint) {
|
||||
discoveryEntry.discovery_payload.temperature_low_command_topic = heatingSetpoint.name;
|
||||
discoveryEntry.discovery_payload.temperature_low_state_template = `{{ value_json["${heatingSetpoint.property}"] }}`;
|
||||
discoveryEntry.discovery_payload.temperature_low_state_topic = true;
|
||||
discoveryEntry.discovery_payload.temperature_high_command_topic = coolingSetpoint.name;
|
||||
discoveryEntry.discovery_payload.temperature_high_state_template = `{{ value_json.${coolingSetpoint.property} }}`;
|
||||
discoveryEntry.discovery_payload.temperature_high_state_template = `{{ value_json["${coolingSetpoint.property}"] }}`;
|
||||
discoveryEntry.discovery_payload.temperature_high_state_topic = true;
|
||||
} else {
|
||||
discoveryEntry.discovery_payload.temperature_command_topic = setpoint.name;
|
||||
discoveryEntry.discovery_payload.temperature_state_template = `{{ value_json.${setpoint.property} }}`;
|
||||
discoveryEntry.discovery_payload.temperature_command_topic = primarySetpoint.name;
|
||||
discoveryEntry.discovery_payload.temperature_state_template = `{{ value_json["${primarySetpoint.property}"] }}`;
|
||||
discoveryEntry.discovery_payload.temperature_state_topic = true;
|
||||
}
|
||||
|
||||
@@ -680,7 +798,7 @@ export class HomeAssistant extends Extension {
|
||||
if (fanMode) {
|
||||
discoveryEntry.discovery_payload.fan_modes = fanMode.values;
|
||||
discoveryEntry.discovery_payload.fan_mode_command_topic = true;
|
||||
discoveryEntry.discovery_payload.fan_mode_state_template = `{{ value_json.${fanMode.property} }}`;
|
||||
discoveryEntry.discovery_payload.fan_mode_state_template = `{{ value_json["${fanMode.property}"] }}`;
|
||||
discoveryEntry.discovery_payload.fan_mode_state_topic = true;
|
||||
}
|
||||
|
||||
@@ -688,7 +806,7 @@ export class HomeAssistant extends Extension {
|
||||
if (swingMode) {
|
||||
discoveryEntry.discovery_payload.swing_modes = swingMode.values;
|
||||
discoveryEntry.discovery_payload.swing_mode_command_topic = true;
|
||||
discoveryEntry.discovery_payload.swing_mode_state_template = `{{ value_json.${swingMode.property} }}`;
|
||||
discoveryEntry.discovery_payload.swing_mode_state_template = `{{ value_json["${swingMode.property}"] }}`;
|
||||
discoveryEntry.discovery_payload.swing_mode_state_topic = true;
|
||||
}
|
||||
|
||||
@@ -696,7 +814,7 @@ export class HomeAssistant extends Extension {
|
||||
if (preset) {
|
||||
discoveryEntry.discovery_payload.preset_modes = preset.values;
|
||||
discoveryEntry.discovery_payload.preset_mode_command_topic = "preset";
|
||||
discoveryEntry.discovery_payload.preset_mode_value_template = `{{ value_json.${preset.property} }}`;
|
||||
discoveryEntry.discovery_payload.preset_mode_value_template = `{{ value_json["${preset.property}"] }}`;
|
||||
discoveryEntry.discovery_payload.preset_mode_state_topic = true;
|
||||
}
|
||||
|
||||
@@ -710,7 +828,7 @@ export class HomeAssistant extends Extension {
|
||||
mockProperties: [{property: tempCalibration.property, value: null}],
|
||||
discovery_payload: {
|
||||
name: endpointName ? `${tempCalibration.label} ${endpointName}` : tempCalibration.label,
|
||||
value_template: `{{ value_json.${tempCalibration.property} }}`,
|
||||
value_template: `{{ value_json["${tempCalibration.property}"] }}`,
|
||||
command_topic: true,
|
||||
command_topic_prefix: endpointName,
|
||||
command_topic_postfix: tempCalibration.property,
|
||||
@@ -735,7 +853,7 @@ export class HomeAssistant extends Extension {
|
||||
mockProperties: [{property: piHeatingDemand.property, value: null}],
|
||||
discovery_payload: {
|
||||
name: endpointName ? `${piHeatingDemand.label} ${endpointName}` : piHeatingDemand.label,
|
||||
value_template: `{{ value_json.${piHeatingDemand.property} }}`,
|
||||
value_template: `{{ value_json["${piHeatingDemand.property}"] }}`,
|
||||
...(piHeatingDemand.unit && {unit_of_measurement: piHeatingDemand.unit}),
|
||||
icon: "mdi:radiator",
|
||||
},
|
||||
@@ -766,7 +884,7 @@ export class HomeAssistant extends Extension {
|
||||
mockProperties: [{property: piCoolingDemand.property, value: null}],
|
||||
discovery_payload: {
|
||||
name: endpointName ? /* v8 ignore next */ `${piCoolingDemand.label} ${endpointName}` : piCoolingDemand.label,
|
||||
value_template: `{{ value_json.${piCoolingDemand.property} }}`,
|
||||
value_template: `{{ value_json["${piCoolingDemand.property}"] }}`,
|
||||
...(piCoolingDemand.unit && {unit_of_measurement: piCoolingDemand.unit}),
|
||||
entity_category: "diagnostic",
|
||||
icon: "mdi:air-conditioner",
|
||||
@@ -788,7 +906,7 @@ export class HomeAssistant extends Extension {
|
||||
mockProperties: [{property: localTemperature.property, value: null}],
|
||||
discovery_payload: {
|
||||
name: endpointName ? `${localTemperature.label} ${endpointName}` : localTemperature.label,
|
||||
value_template: `{{ value_json.${localTemperature.property} }}`,
|
||||
value_template: `{{ value_json["${localTemperature.property}"] }}`,
|
||||
...(localTemperature.unit && {unit_of_measurement: localTemperature.unit}),
|
||||
device_class: "temperature",
|
||||
state_class: "measurement",
|
||||
@@ -800,7 +918,7 @@ export class HomeAssistant extends Extension {
|
||||
|
||||
const currentHumidity = allExposes?.filter(isNumericExpose).find((e) => e.name === "humidity" && e.access & ACCESS_STATE);
|
||||
if (currentHumidity) {
|
||||
discoveryEntry.discovery_payload.current_humidity_template = `{{ value_json.${currentHumidity.property} }}`;
|
||||
discoveryEntry.discovery_payload.current_humidity_template = `{{ value_json["${currentHumidity.property}"] }}`;
|
||||
discoveryEntry.discovery_payload.current_humidity_topic = true;
|
||||
}
|
||||
|
||||
@@ -820,7 +938,7 @@ export class HomeAssistant extends Extension {
|
||||
name: endpointName ? utils.capitalize(endpointName) : null,
|
||||
command_topic_prefix: endpointName,
|
||||
command_topic: true,
|
||||
value_template: `{{ value_json.${state.property} }}`,
|
||||
value_template: `{{ value_json["${state.property}"] }}`,
|
||||
state_locked: state.value_on,
|
||||
state_unlocked: state.value_off,
|
||||
/* v8 ignore next */
|
||||
@@ -843,7 +961,7 @@ export class HomeAssistant extends Extension {
|
||||
?.features.find((f) => f.name === "tilt");
|
||||
const motorState = allExposes
|
||||
?.filter(isEnumExpose)
|
||||
.find((e) => ["motor_state", "moving"].includes(e.name) && e.access === ACCESS_STATE);
|
||||
.find((e) => ["motor_state", "moving"].includes(e.name) && e.access & ACCESS_STATE);
|
||||
const running = allExposes?.filter(isBinaryExpose)?.find((e) => e.name === "running");
|
||||
|
||||
const discoveryEntry: DiscoveryEntry = {
|
||||
@@ -863,12 +981,15 @@ export class HomeAssistant extends Extension {
|
||||
// The movement direction is calculated (assumed) in this case.
|
||||
if (running) {
|
||||
assert(position, `Cover must have 'position' when it has 'running'`);
|
||||
discoveryEntry.discovery_payload.value_template = `{% if "${featurePropertyWithoutEndpoint(running)}" in value_json and value_json.${featurePropertyWithoutEndpoint(running)} %} {% if value_json.${featurePropertyWithoutEndpoint(position)} > 0 %} closing {% else %} opening {% endif %} {% else %} stopped {% endif %}`;
|
||||
discoveryEntry.discovery_payload.value_template = `{% if "${featurePropertyWithoutEndpoint(running)}" in value_json and value_json["${featurePropertyWithoutEndpoint(running)}"] %} {% if value_json["${featurePropertyWithoutEndpoint(position)}"] > 0 %} closing {% else %} opening {% endif %} {% else %} stopped {% endif %}`;
|
||||
}
|
||||
|
||||
// If curtains have `motor_state` or `moving` property, lookup for possible
|
||||
// state names to detect movement direction and use this in discovery.
|
||||
if (motorState) {
|
||||
const motorStateProperty = featurePropertyWithoutEndpoint(motorState);
|
||||
const stateProperty = featurePropertyWithoutEndpoint(state);
|
||||
|
||||
const openingState = motorState.values.find((s) => COVER_OPENING_LOOKUP.includes(s.toString().toLowerCase()));
|
||||
const closingState = motorState.values.find((s) => COVER_CLOSING_LOOKUP.includes(s.toString().toLowerCase()));
|
||||
const stoppedState = motorState.values.find((s) => COVER_STOPPED_LOOKUP.includes(s.toString().toLowerCase()));
|
||||
@@ -876,14 +997,25 @@ export class HomeAssistant extends Extension {
|
||||
if (openingState && closingState && stoppedState) {
|
||||
discoveryEntry.discovery_payload.state_opening = openingState;
|
||||
discoveryEntry.discovery_payload.state_closing = closingState;
|
||||
discoveryEntry.discovery_payload.state_open = "OPEN";
|
||||
discoveryEntry.discovery_payload.state_closed = "CLOSE";
|
||||
discoveryEntry.discovery_payload.state_stopped = stoppedState;
|
||||
discoveryEntry.discovery_payload.value_template = `{% if "${featurePropertyWithoutEndpoint(motorState)}" in value_json and value_json.${featurePropertyWithoutEndpoint(motorState)} %} {{ value_json.${featurePropertyWithoutEndpoint(motorState)} }} {% else %} ${stoppedState} {% endif %}`;
|
||||
discoveryEntry.discovery_payload.value_template =
|
||||
`{% if "${motorStateProperty}" in value_json and value_json["${motorStateProperty}"] == "${openingState}" %}` +
|
||||
`${openingState}` +
|
||||
`{% elif "${motorStateProperty}" in value_json and value_json["${motorStateProperty}"] == "${closingState}" %}` +
|
||||
`${closingState}` +
|
||||
`{% elif "${stateProperty}" in value_json %}` +
|
||||
`{{ value_json["${stateProperty}"] }}` +
|
||||
"{% else %}" +
|
||||
`${stoppedState}` +
|
||||
"{% endif %}";
|
||||
}
|
||||
}
|
||||
|
||||
// If curtains do not have `running`, `motor_state` or `moving` properties.
|
||||
if (!discoveryEntry.discovery_payload.value_template) {
|
||||
discoveryEntry.discovery_payload.value_template = `{{ value_json.${featurePropertyWithoutEndpoint(state)} }}`;
|
||||
discoveryEntry.discovery_payload.value_template = `{{ value_json["${featurePropertyWithoutEndpoint(state)}"] }}`;
|
||||
discoveryEntry.discovery_payload.state_open = "OPEN";
|
||||
discoveryEntry.discovery_payload.state_closed = "CLOSE";
|
||||
discoveryEntry.discovery_payload.state_stopped = "STOP";
|
||||
@@ -898,7 +1030,7 @@ export class HomeAssistant extends Extension {
|
||||
if (position) {
|
||||
discoveryEntry.discovery_payload = {
|
||||
...discoveryEntry.discovery_payload,
|
||||
position_template: `{{ value_json.${featurePropertyWithoutEndpoint(position)} }}`,
|
||||
position_template: `{{ value_json["${featurePropertyWithoutEndpoint(position)}"] }}`,
|
||||
set_position_template: `{ "${getProperty(position)}": {{ position }} }`,
|
||||
set_position_topic: true,
|
||||
position_topic: true,
|
||||
@@ -910,7 +1042,7 @@ export class HomeAssistant extends Extension {
|
||||
...discoveryEntry.discovery_payload,
|
||||
tilt_command_topic: true,
|
||||
tilt_status_topic: true,
|
||||
tilt_status_template: `{{ value_json.${featurePropertyWithoutEndpoint(tilt)} }}`,
|
||||
tilt_status_template: `{{ value_json["${featurePropertyWithoutEndpoint(tilt)}"] }}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -975,14 +1107,14 @@ export class HomeAssistant extends Extension {
|
||||
|
||||
discoveryEntry.discovery_payload.percentage_state_topic = true;
|
||||
discoveryEntry.discovery_payload.percentage_command_topic = "fan_mode";
|
||||
discoveryEntry.discovery_payload.percentage_value_template = `{{ {${percentValues}}[value_json.${modeEmulatedSpeed.property}] | default('None') }}`;
|
||||
discoveryEntry.discovery_payload.percentage_value_template = `{{ {${percentValues}}[value_json["${modeEmulatedSpeed.property}"]] | default('None') }}`;
|
||||
discoveryEntry.discovery_payload.percentage_command_template = `{{ {${percentCommands}}[value] | default('') }}`;
|
||||
discoveryEntry.discovery_payload.speed_range_min = 1;
|
||||
discoveryEntry.discovery_payload.speed_range_max = speeds.length - 1;
|
||||
assert(presets.length !== 0);
|
||||
discoveryEntry.discovery_payload.preset_mode_state_topic = true;
|
||||
discoveryEntry.discovery_payload.preset_mode_command_topic = "fan_mode";
|
||||
discoveryEntry.discovery_payload.preset_mode_value_template = `{{ value_json.${modeEmulatedSpeed.property} if value_json.${modeEmulatedSpeed.property} in [${presetList}] else 'None' | default('None') }}`;
|
||||
discoveryEntry.discovery_payload.preset_mode_value_template = `{{ value_json["${modeEmulatedSpeed.property}"] if value_json["${modeEmulatedSpeed.property}"] in [${presetList}] else 'None' | default('None') }}`;
|
||||
discoveryEntry.discovery_payload.preset_modes = presets;
|
||||
|
||||
// Emulate state based on mode
|
||||
@@ -991,7 +1123,7 @@ export class HomeAssistant extends Extension {
|
||||
} else if (nativeSpeed) {
|
||||
discoveryEntry.discovery_payload.percentage_state_topic = true;
|
||||
discoveryEntry.discovery_payload.percentage_command_topic = "speed";
|
||||
discoveryEntry.discovery_payload.percentage_value_template = `{{ value_json.${nativeSpeed.property} | default('None') }}`;
|
||||
discoveryEntry.discovery_payload.percentage_value_template = `{{ value_json["${nativeSpeed.property}"] | default('None') }}`;
|
||||
discoveryEntry.discovery_payload.percentage_command_template = `{{ value | default('') }}`;
|
||||
discoveryEntry.discovery_payload.speed_range_min = nativeSpeed.value_min;
|
||||
discoveryEntry.discovery_payload.speed_range_max = nativeSpeed.value_max;
|
||||
@@ -1021,8 +1153,8 @@ export class HomeAssistant extends Extension {
|
||||
name: endpointName ? /* v8 ignore next */ `${firstExpose.label} ${endpointName}` : firstExpose.label,
|
||||
value_template:
|
||||
typeof firstExpose.value_on === "boolean"
|
||||
? `{% if value_json.${firstExpose.property} %}true{% else %}false{% endif %}`
|
||||
: `{{ value_json.${firstExpose.property} }}`,
|
||||
? `{% if value_json["${firstExpose.property}"] %}true{% else %}false{% endif %}`
|
||||
: `{{ value_json["${firstExpose.property}"] }}`,
|
||||
payload_on: firstExpose.value_on.toString(),
|
||||
payload_off: firstExpose.value_off.toString(),
|
||||
command_topic: true,
|
||||
@@ -1040,7 +1172,7 @@ export class HomeAssistant extends Extension {
|
||||
mockProperties: [{property: firstExpose.property, value: null}],
|
||||
discovery_payload: {
|
||||
name: endpointName ? /* v8 ignore next */ `${firstExpose.label} ${endpointName}` : firstExpose.label,
|
||||
value_template: `{{ value_json.${firstExpose.property} }}`,
|
||||
value_template: `{{ value_json["${firstExpose.property}"] }}`,
|
||||
payload_on: firstExpose.value_on,
|
||||
payload_off: firstExpose.value_off,
|
||||
...(BINARY_DISCOVERY_LOOKUP[firstExpose.name] || {}),
|
||||
@@ -1065,7 +1197,7 @@ export class HomeAssistant extends Extension {
|
||||
mockProperties: [{property: firstExpose.property, value: null}],
|
||||
discovery_payload: {
|
||||
name: endpointName ? `${firstExpose.label} ${endpointName}` : firstExpose.label,
|
||||
value_template: `{{ value_json.${firstExpose.property} }}`,
|
||||
value_template: `{{ value_json["${firstExpose.property}"] }}`,
|
||||
command_topic: true,
|
||||
command_topic_prefix: endpointName,
|
||||
command_topic_postfix: firstExpose.property,
|
||||
@@ -1116,7 +1248,7 @@ export class HomeAssistant extends Extension {
|
||||
mockProperties: [{property: firstExpose.property, value: null}],
|
||||
discovery_payload: {
|
||||
name: endpointName ? `${firstExpose.label} ${endpointName}` : firstExpose.label,
|
||||
value_template: `{{ value_json.${firstExpose.property} }}`,
|
||||
value_template: `{{ value_json["${firstExpose.property}"] }}`,
|
||||
enabled_by_default: !allowsSet,
|
||||
...(firstExpose.unit && {unit_of_measurement: firstExpose.unit}),
|
||||
...NUMERIC_DISCOVERY_LOOKUP[key],
|
||||
@@ -1170,7 +1302,7 @@ export class HomeAssistant extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
const valueTemplate = firstExpose.access & ACCESS_STATE ? `{{ value_json.${firstExpose.property} }}` : undefined;
|
||||
const valueTemplate = firstExpose.access & ACCESS_STATE ? `{{ value_json["${firstExpose.property}"] }}` : undefined;
|
||||
|
||||
/**
|
||||
* If enum has only one item and has SET access then expose as BUTTON entity.
|
||||
@@ -1226,6 +1358,7 @@ export class HomeAssistant extends Extension {
|
||||
discovery_payload: {
|
||||
name: endpointName ? `${firstExpose.label} ${endpointName}` : firstExpose.label,
|
||||
value_template: valueTemplate,
|
||||
...(firstExpose.property === "action" ? {entity_category: "diagnostic"} : {}),
|
||||
...ENUM_DISCOVERY_LOOKUP[firstExpose.name],
|
||||
},
|
||||
});
|
||||
@@ -1299,7 +1432,7 @@ export class HomeAssistant extends Extension {
|
||||
discovery_payload: {
|
||||
name: endpointName ? `${firstExposeTyped.label} ${endpointName}` : firstExposeTyped.label,
|
||||
state_topic: firstExposeTyped.access & ACCESS_STATE,
|
||||
value_template: `{{ value_json.${firstExposeTyped.property} }}`,
|
||||
value_template: `{{ value_json["${firstExposeTyped.property}"] }}`,
|
||||
command_topic_prefix: endpointName,
|
||||
command_topic: true,
|
||||
command_topic_postfix: firstExposeTyped.property,
|
||||
@@ -1317,7 +1450,7 @@ export class HomeAssistant extends Extension {
|
||||
name: endpointName ? `${firstExposeTyped.label} ${endpointName}` : firstExposeTyped.label,
|
||||
// Truncate text if it's too long
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/23199
|
||||
value_template: `{{ value_json.${firstExposeTyped.property} | default('',True) | string | truncate(254, True, '', 0) }}`,
|
||||
value_template: `{{ value_json["${firstExposeTyped.property}"] | default('',True) | string | truncate(254, True, '', 0) }}`,
|
||||
...LIST_DISCOVERY_LOOKUP[firstExposeTyped.name],
|
||||
},
|
||||
});
|
||||
@@ -1335,6 +1468,8 @@ export class HomeAssistant extends Extension {
|
||||
}
|
||||
|
||||
for (const entry of discoveryEntries) {
|
||||
applyHomeAssistantExposeMetadata(entry, firstExpose.homeassistant);
|
||||
|
||||
// If a sensor has entity category `config`, then change
|
||||
// it to `diagnostic`. Sensors have no input, so can't be configured.
|
||||
// https://github.com/Koenkk/zigbee2mqtt/pull/19474
|
||||
@@ -1347,8 +1482,13 @@ export class HomeAssistant extends Extension {
|
||||
delete entry.discovery_payload.entity_category;
|
||||
}
|
||||
|
||||
// Let Home Assistant generate entity name when device_class is present
|
||||
if (entry.discovery_payload.device_class) {
|
||||
// Let Home Assistant generate entity name when device_class is present.
|
||||
// preserve_name allows device_class and explicit name to coexist (e.g. derived sensors).
|
||||
if (
|
||||
entry.discovery_payload.device_class &&
|
||||
entry.discovery_payload.name !== null &&
|
||||
!NUMERIC_DISCOVERY_LOOKUP[firstExpose.name]?.preserve_name
|
||||
) {
|
||||
delete entry.discovery_payload.name;
|
||||
}
|
||||
|
||||
@@ -1403,7 +1543,7 @@ export class HomeAssistant extends Extension {
|
||||
|
||||
if (match) {
|
||||
const endpoint = match[1];
|
||||
const endpointRegExp = new RegExp(`(.*)_${endpoint}`);
|
||||
const endpointRegExp = new RegExp(`(.*)_${endpoint}$`);
|
||||
const payload: KeyValue = {};
|
||||
for (const key of Object.keys(data.message)) {
|
||||
const keyMatch = endpointRegExp.exec(key);
|
||||
@@ -1432,10 +1572,12 @@ export class HomeAssistant extends Extension {
|
||||
* Whenever a device publish an {action: *} we discover an MQTT device trigger sensor
|
||||
* and republish it to zigbee2mqtt/my_device/action
|
||||
*/
|
||||
if (settings.get().advanced.output === "json" && entity.isDevice() && entity.definition && data.message.action) {
|
||||
if (entity.isDevice() && entity.definition && data.message.action) {
|
||||
const value = data.message.action.toString();
|
||||
await this.publishDeviceTriggerDiscover(entity, "action", value);
|
||||
await this.mqtt.publish(`${data.entity.name}/action`, value, {});
|
||||
if (settings.get().advanced.output === "json") {
|
||||
await this.mqtt.publish(`${data.entity.name}/action`, value, {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1814,7 +1956,16 @@ export class HomeAssistant extends Extension {
|
||||
payload.current_humidity_topic = stateTopic;
|
||||
}
|
||||
|
||||
// Override configuration with user settings.
|
||||
if (entity.isDevice()) {
|
||||
try {
|
||||
entity.definition?.meta?.overrideHaDiscoveryPayload?.(payload, entity.options);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to override HA discovery payload (${(error as Error).stack})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Override configuration with user settings after converter compatibility
|
||||
// mappings, so per-device configuration remains the final authority.
|
||||
if (entity.options.homeassistant != null) {
|
||||
const add = (obj: KeyValue, ignoreName: boolean): void => {
|
||||
for (const key in obj) {
|
||||
@@ -1845,14 +1996,6 @@ export class HomeAssistant extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
if (entity.isDevice()) {
|
||||
try {
|
||||
entity.definition?.meta?.overrideHaDiscoveryPayload?.(payload);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to override HA discovery payload (${(error as Error).stack})`);
|
||||
}
|
||||
}
|
||||
|
||||
const topic = this.getDiscoveryTopic(config, entity);
|
||||
const payloadStr = stringify(payload);
|
||||
newDiscoveredTopics.add(topic);
|
||||
@@ -1938,6 +2081,9 @@ export class HomeAssistant extends Extension {
|
||||
}
|
||||
} else if (data.topic === this.statusTopic && data.message.toLowerCase() === "online") {
|
||||
const timer = setTimeout(async () => {
|
||||
// Re-publish bridge state so HA marks all entities as available before receiving cached device states.
|
||||
await this.mqtt.publish("bridge/state", stringify({state: "online"}), {clientOptions: {retain: true, qos: 1}});
|
||||
|
||||
// Publish all device states.
|
||||
for (const entity of this.zigbee.devicesAndGroupsIterator(utils.deviceNotCoordinator)) {
|
||||
if (this.state.exists(entity)) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
+30
-13
@@ -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": {
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
@@ -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";
|
||||
|
||||
@@ -225,6 +224,11 @@ export default class Publish extends Extension {
|
||||
state: entityState,
|
||||
membersState,
|
||||
mapped: definition,
|
||||
/* v8 ignore start */
|
||||
deviceExposesChanged: (): void => {
|
||||
if (re instanceof Device) this.eventBus.emitExposesAndDevicesChanged(re);
|
||||
},
|
||||
/* v8 ignore stop */
|
||||
/* v8 ignore next */
|
||||
publish: (payload: KeyValue) => this.publishEntityState(re, payload),
|
||||
};
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import assert from "node:assert";
|
||||
|
||||
import bind from "bind-decorator";
|
||||
import debounce from "debounce";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import throttle from "throttleit";
|
||||
|
||||
import * as zhc from "zigbee-herdsman-converters";
|
||||
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import {stringify} from "../util/stringify";
|
||||
import utils from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
@@ -183,8 +180,11 @@ export default class Receive extends Extension {
|
||||
|
||||
if (!utils.objectIsEmpty(payload)) {
|
||||
await publish(payload);
|
||||
} else {
|
||||
await utils.publishLastSeen({device: data.device, reason: "messageEmitted"}, settings.get(), true, this.publishEntityState);
|
||||
} else if (settings.get().advanced.last_seen && settings.get().advanced.last_seen !== "disable") {
|
||||
// A message was received that produced no payload (e.g. a frame the converter has no data
|
||||
// for). Publish through the regular publish() path so the per-device debounce/throttle
|
||||
// still applies, instead of publishing the full cached state immediately via publishLastSeen.
|
||||
await publish({});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
+1
-3
@@ -1,9 +1,8 @@
|
||||
import {existsSync, readFileSync, writeFileSync} from "node:fs";
|
||||
|
||||
import objectAssignDeep from "object-assign-deep";
|
||||
|
||||
import data from "./util/data";
|
||||
import logger from "./util/logger";
|
||||
import {objectAssignDeep} from "./util/objectAssignDeep";
|
||||
import * as settings from "./util/settings";
|
||||
import utils from "./util/utils";
|
||||
|
||||
@@ -23,7 +22,6 @@ const CACHE_IGNORE_PROPERTIES = [
|
||||
"no_occupancy_since",
|
||||
"step_mode",
|
||||
"transition_time",
|
||||
"duration",
|
||||
"elapsed",
|
||||
"from_side",
|
||||
"to_side",
|
||||
|
||||
+23
-6
@@ -59,7 +59,7 @@ export type OnboardData = OnboardInitData | OnboardDoneData | OnboardFailureData
|
||||
|
||||
export type OnboardSubmitResponse = {success: true; frontendUrl: string | null} | {success: false; error: string};
|
||||
|
||||
export interface Zigbee2MQTTDeviceOptions {
|
||||
export type Zigbee2MQTTDeviceOptions = {
|
||||
disabled?: boolean;
|
||||
retention?: number;
|
||||
availability?:
|
||||
@@ -83,9 +83,9 @@ export interface Zigbee2MQTTDeviceOptions {
|
||||
description?: string;
|
||||
qos?: 0 | 1 | 2;
|
||||
disable_automatic_update_check?: boolean;
|
||||
}
|
||||
};
|
||||
|
||||
export interface Zigbee2MQTTGroupOptions {
|
||||
export type Zigbee2MQTTGroupOptions = {
|
||||
ID: number;
|
||||
optimistic?: boolean;
|
||||
off_state?: "all_members_off" | "last_member_state";
|
||||
@@ -96,9 +96,9 @@ export interface Zigbee2MQTTGroupOptions {
|
||||
friendly_name: string;
|
||||
description?: string;
|
||||
qos?: 0 | 1 | 2;
|
||||
}
|
||||
};
|
||||
|
||||
export interface Zigbee2MQTTSettings {
|
||||
export type Zigbee2MQTTSettings = {
|
||||
version?: number;
|
||||
/** only used internally during startup, removed on successful Z2M start */
|
||||
onboarding?: true;
|
||||
@@ -133,6 +133,7 @@ export interface Zigbee2MQTTSettings {
|
||||
cert?: string;
|
||||
client_id?: string;
|
||||
reject_unauthorized?: boolean;
|
||||
server_name?: string;
|
||||
maximum_packet_size: number;
|
||||
};
|
||||
serial: {
|
||||
@@ -215,13 +216,15 @@ export interface Zigbee2MQTTSettings {
|
||||
timestamp_format: string;
|
||||
output: "json" | "attribute" | "attribute_and_json";
|
||||
transmit_power?: number;
|
||||
/** 3.0: default to false (JSON schema & settings `defaults`) */
|
||||
enable_external_js: boolean;
|
||||
};
|
||||
health: {
|
||||
/** in minutes */
|
||||
interval: number;
|
||||
reset_on_check: boolean;
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export interface Zigbee2MQTTScene {
|
||||
id: number;
|
||||
@@ -619,12 +622,16 @@ export interface Zigbee2MQTTAPI {
|
||||
id: string;
|
||||
block?: boolean;
|
||||
force?: boolean;
|
||||
keep_config?: boolean;
|
||||
clear_cache?: boolean;
|
||||
};
|
||||
|
||||
"bridge/response/device/remove": {
|
||||
id: string;
|
||||
block: boolean;
|
||||
force: boolean;
|
||||
keep_config: boolean;
|
||||
clear_cache: boolean;
|
||||
};
|
||||
|
||||
"bridge/request/device/ota_update/check": {
|
||||
@@ -675,6 +682,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:
|
||||
@@ -999,6 +1014,7 @@ export type Zigbee2MQTTRequestEndpoints =
|
||||
| "bridge/request/device/ota_update/check/downgrade"
|
||||
| "bridge/request/device/ota_update/update"
|
||||
| "bridge/request/device/ota_update/update/downgrade"
|
||||
| "bridge/request/device/ota_update/update/abort"
|
||||
| "bridge/request/device/ota_update/schedule"
|
||||
| "bridge/request/device/ota_update/schedule/downgrade"
|
||||
| "bridge/request/device/ota_update/unschedule"
|
||||
@@ -1048,6 +1064,7 @@ export type Zigbee2MQTTResponseEndpoints =
|
||||
| "bridge/response/device/remove"
|
||||
| "bridge/response/device/ota_update/check"
|
||||
| "bridge/response/device/ota_update/update"
|
||||
| "bridge/response/device/ota_update/update/abort"
|
||||
| "bridge/response/device/ota_update/schedule"
|
||||
| "bridge/response/device/ota_update/unschedule"
|
||||
| "bridge/response/device/interview"
|
||||
|
||||
Vendored
+6
-3
@@ -1,8 +1,5 @@
|
||||
// minimal required because of sub-deps in mqtt >= 5.14.0 to avoid requiring `dom` type
|
||||
declare global {
|
||||
// map to node, doesn't really matter, just needs to be there, and the right "type vs value" to avoid lib check problems
|
||||
/** @deprecated DOM SHIM, DO NOT USE */
|
||||
type MessagePort = import("node:worker_threads").MessagePort;
|
||||
/** @deprecated DOM SHIM, DO NOT USE */
|
||||
type Worker = import("node:worker_threads").Worker;
|
||||
/** @deprecated DOM SHIM, DO NOT USE */
|
||||
@@ -13,6 +10,12 @@ declare global {
|
||||
const removeEventListener: import("node:events").EventEmitter["removeListener"];
|
||||
/** @deprecated DOM SHIM, DO NOT USE */
|
||||
const postMessage: import("node:worker_threads").MessagePort["postMessage"];
|
||||
/**
|
||||
* Required by `srvx` <= 0.12.5, remove once a release including https://github.com/h3js/srvx/pull/288 is out.
|
||||
*
|
||||
* @deprecated DOM SHIM, DO NOT USE
|
||||
*/
|
||||
type HeadersInit = string[][] | Record<string, string> | Headers;
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
declare module "json-stable-stringify-without-jsonify" {
|
||||
export default function (obj: unknown): string;
|
||||
}
|
||||
Vendored
+3
@@ -26,6 +26,9 @@ declare global {
|
||||
type PublishEntityState = (entity: Device | Group, payload: KeyValue, stateChangeReason?: StateChangeReason) => Promise<void>;
|
||||
type RecursivePartial<T> = {[P in keyof T]?: RecursivePartial<T[P]>};
|
||||
type MakePartialExcept<T, K extends keyof T> = Partial<Omit<T, K>> & Pick<T, K>;
|
||||
/** Convert `A | B | C` into `A & B & C` */
|
||||
// biome-ignore lint/suspicious/noExplicitAny: distributive conditional requires `any`
|
||||
type UnionToIntersection<U> = (U extends any ? (x: U) => void : never) extends (x: infer I) => void ? I : never;
|
||||
interface KeyValue {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: API
|
||||
[s: string]: any;
|
||||
|
||||
Vendored
-7
@@ -5,10 +5,3 @@ declare module "zigbee2mqtt-frontend" {
|
||||
|
||||
export default frontend;
|
||||
}
|
||||
|
||||
declare module "http" {
|
||||
interface IncomingMessage {
|
||||
originalUrl?: string;
|
||||
path?: string;
|
||||
}
|
||||
}
|
||||
|
||||
+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}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/** Anything mergeable: a plain-ish object, explicitly not an array or another iterable. */
|
||||
export type UnknownRecord = Record<string | number, unknown> & {[Symbol.iterator]?: never};
|
||||
|
||||
function isUnknownRecord(value: unknown): value is UnknownRecord {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function cloneArray(input: readonly unknown[]): unknown[] {
|
||||
const len = input.length;
|
||||
const output: unknown[] = new Array(len);
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
const val = input[i];
|
||||
output[i] = isUnknownRecord(val) ? cloneObject(val) : Array.isArray(val) ? cloneArray(val) : val;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function cloneObject(input: UnknownRecord): UnknownRecord {
|
||||
const output: UnknownRecord = {};
|
||||
|
||||
for (const key of Object.keys(input)) {
|
||||
if (key !== "__proto__" && key !== "constructor" && key !== "prototype") {
|
||||
const val = input[key];
|
||||
output[key] = isUnknownRecord(val) ? cloneObject(val) : Array.isArray(val) ? cloneArray(val) : val;
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge all sources into `target` recursively.
|
||||
*
|
||||
* Key behavior:
|
||||
* - ignore properties `__proto__`, `constructor` & `prototype`
|
||||
* - assumes no infinite circular possible (unhandled for perf)
|
||||
*
|
||||
* Pass empty object `{}` as `target` to return a new object without modifying any existing objects.
|
||||
*/
|
||||
export function objectAssignDeep<T extends UnknownRecord, S extends readonly UnknownRecord[]>(
|
||||
target: T,
|
||||
...sources: S
|
||||
): T & UnionToIntersection<S[number]> {
|
||||
for (const source of sources) {
|
||||
for (const key of Object.keys(source)) {
|
||||
if (key === "__proto__" || key === "constructor" || key === "prototype") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = source[key];
|
||||
|
||||
if (isUnknownRecord(value)) {
|
||||
const existing = target[key];
|
||||
|
||||
(target as UnknownRecord)[key] = isUnknownRecord(existing) ? objectAssignDeep({}, existing, value) : cloneObject(value);
|
||||
} else if (Array.isArray(value)) {
|
||||
(target as UnknownRecord)[key] = cloneArray(value);
|
||||
} else {
|
||||
(target as UnknownRecord)[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return target as T & UnionToIntersection<S[number]>;
|
||||
}
|
||||
+15
-33
@@ -1,31 +1,15 @@
|
||||
import {existsSync, mkdirSync, writeFileSync} from "node:fs";
|
||||
import type {ServerResponse} from "node:http";
|
||||
import {createServer} from "node:http";
|
||||
import path from "node:path";
|
||||
import expressStaticGzip from "express-static-gzip";
|
||||
import finalhandler from "finalhandler";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import JSZip from "jszip";
|
||||
import {type Unzipped, unzip} from "fflate";
|
||||
import {findAllDevices} from "zigbee-herdsman/dist/adapter/adapterDiscovery";
|
||||
import type {OnboardData, OnboardFailureData, OnboardSubmitResponse, Zigbee2MQTTSettings} from "../types/api";
|
||||
import {stringify} from "../util/stringify";
|
||||
import data from "./data";
|
||||
import * as settings from "./settings";
|
||||
import {createStaticFileServer} from "./staticFileServer";
|
||||
import {YAMLFileException} from "./yaml";
|
||||
|
||||
/** same as extension/frontend */
|
||||
const FILE_SERVER_OPTIONS: expressStaticGzip.ExpressStaticGzipOptions = {
|
||||
enableBrotli: true,
|
||||
serveStatic: {
|
||||
/* v8 ignore start */
|
||||
setHeaders: (res: ServerResponse, path: string): void => {
|
||||
if (path.endsWith("index.html")) {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
}
|
||||
},
|
||||
/* v8 ignore stop */
|
||||
},
|
||||
};
|
||||
|
||||
function getServerUrl(): URL {
|
||||
return new URL(process.env.Z2M_ONBOARD_URL ?? "http://0.0.0.0:8080");
|
||||
}
|
||||
@@ -49,20 +33,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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +56,7 @@ async function startOnboardingServer(): Promise<boolean> {
|
||||
const currentSettings = settings.get();
|
||||
const serverUrl = getServerUrl();
|
||||
let server: ReturnType<typeof createServer> | undefined;
|
||||
const fileServer = expressStaticGzip((await import("zigbee2mqtt-windfront")).default.getOnboardingPath(), FILE_SERVER_OPTIONS);
|
||||
const fileServer = createStaticFileServer((await import("zigbee2mqtt-windfront")).default.getOnboardingPath(), console.error);
|
||||
|
||||
const success = await new Promise<boolean>((resolve) => {
|
||||
server = createServer(async (req, res) => {
|
||||
@@ -194,9 +180,7 @@ async function startOnboardingServer(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
const next = finalhandler(req, res);
|
||||
|
||||
fileServer(req, res, next);
|
||||
fileServer(req, res);
|
||||
});
|
||||
|
||||
server.on("error", (error: Error) => {
|
||||
@@ -217,7 +201,7 @@ async function startOnboardingServer(): Promise<boolean> {
|
||||
async function startFailureServer(errors: string[]): Promise<void> {
|
||||
const serverUrl = getServerUrl();
|
||||
let server: ReturnType<typeof createServer> | undefined;
|
||||
const fileServer = expressStaticGzip((await import("zigbee2mqtt-windfront")).default.getOnboardingPath(), FILE_SERVER_OPTIONS);
|
||||
const fileServer = createStaticFileServer((await import("zigbee2mqtt-windfront")).default.getOnboardingPath(), console.error);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server = createServer((req, res) => {
|
||||
@@ -242,9 +226,7 @@ async function startFailureServer(errors: string[]): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = finalhandler(req, res);
|
||||
|
||||
fileServer(req, res, next);
|
||||
fileServer(req, res);
|
||||
});
|
||||
|
||||
server.listen(Number.parseInt(serverUrl.port, 10), serverUrl.hostname, () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
@@ -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": {
|
||||
@@ -806,8 +815,15 @@
|
||||
"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"],
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import path from "node:path";
|
||||
import type {ValidateFunction} from "ajv";
|
||||
import Ajv from "ajv";
|
||||
import objectAssignDeep from "object-assign-deep";
|
||||
import data from "./data";
|
||||
import {objectAssignDeep} from "./objectAssignDeep";
|
||||
import schemaJson from "./settings.schema.json";
|
||||
import utils from "./utils";
|
||||
import yaml from "./yaml";
|
||||
@@ -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,
|
||||
@@ -229,7 +231,10 @@ export function write(): void {
|
||||
const writeDevicesOrGroups = (type: "devices" | "groups"): void => {
|
||||
if (typeof actual[type] === "string" || (Array.isArray(actual[type]) && actual[type].length > 0)) {
|
||||
const fileToWrite = Array.isArray(actual[type]) ? actual[type][0] : actual[type];
|
||||
const content = objectAssignDeep({}, settings[type]);
|
||||
// `readDevicesOrGroups()` already set this to an object whenever the config points at separate files, but the
|
||||
// persisted settings are `Partial`, so the fallback is only here to satisfy the type
|
||||
/* v8 ignore next */
|
||||
const content = objectAssignDeep({}, settings[type] ?? {});
|
||||
|
||||
// If an array, only write to first file and only devices which are not in the other files.
|
||||
if (Array.isArray(actual[type])) {
|
||||
@@ -366,8 +371,7 @@ function read(): Partial<Settings> {
|
||||
s[type] = {};
|
||||
for (const file of files) {
|
||||
const content = yaml.readIfExists(data.joinPath(file));
|
||||
// @ts-expect-error noMutate not typed properly
|
||||
s[type] = objectAssignDeep.noMutate(s[type], content);
|
||||
s[type] = objectAssignDeep({}, s[type], content);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -477,9 +481,7 @@ export function set(path: string[], value: string | number | boolean | KeyValue)
|
||||
}
|
||||
|
||||
export function apply(settings: Record<string, unknown>, throwOnError = true): boolean {
|
||||
getPersistedSettings(); // Ensure _settings is initialized.
|
||||
// @ts-expect-error noMutate not typed properly
|
||||
const newSettings = objectAssignDeep.noMutate(_settings, settings);
|
||||
const newSettings = objectAssignDeep({}, getPersistedSettings(), settings);
|
||||
|
||||
utils.removeNullPropertiesFromObject(newSettings, NULLABLE_SETTINGS);
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import type {IncomingMessage, ServerResponse} from "node:http";
|
||||
import {NodeRequest, sendNodeResponse} from "srvx/node";
|
||||
import {staticMiddleware} from "srvx/static";
|
||||
|
||||
export type StaticFileServer = (request: IncomingMessage, response: ServerResponse) => void;
|
||||
|
||||
const escapeHtml = (value: string): string => value.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
|
||||
|
||||
/** Terminal `404` handler for requests no file matched, mirroring the response `finalhandler` used to produce. */
|
||||
export function sendNotFound(request: IncomingMessage, response: ServerResponse): void {
|
||||
const method = request.method /* v8 ignore next */ ?? "GET";
|
||||
const url = request.url /* v8 ignore next */ ?? "/";
|
||||
const message = escapeHtml(`Cannot ${method} ${encodeURI(url)}`);
|
||||
const body = `<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<title>Error</title>\n</head>\n<body>\n<pre>${message}</pre>\n</body>\n</html>\n`;
|
||||
|
||||
response.setHeader("Content-Security-Policy", "default-src 'none'");
|
||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||
response.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
response.setHeader("Content-Length", Buffer.byteLength(body));
|
||||
response.writeHead(404);
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves `dir` on top of a plain `node:http` server, preferring the precompressed `.br`/`.gz` variant of a file when the client accepts it.
|
||||
*
|
||||
* Requests that match no file are answered by {@link sendNotFound}.
|
||||
*/
|
||||
export function createStaticFileServer(dir: string, logError: (message: string) => void): StaticFileServer {
|
||||
// `compress: false` restricts serving to the precompressed variants shipped on disk, never compressing on the fly
|
||||
const serveDir = staticMiddleware({dir, encodings: true, compress: false});
|
||||
const handle = async (request: IncomingMessage, response: ServerResponse): Promise<void> => {
|
||||
let matched = true;
|
||||
const staticResponse = await serveDir(new NodeRequest({req: request, res: response}), () => {
|
||||
matched = false;
|
||||
|
||||
return new Response(null, {status: 404});
|
||||
});
|
||||
|
||||
if (!matched) {
|
||||
sendNotFound(request, response);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// the HTML entry document must never be cached, so a newly installed frontend version is picked up right away
|
||||
if (staticResponse.headers.get("Content-Type")?.startsWith("text/html")) {
|
||||
staticResponse.headers.set("Cache-Control", "no-store");
|
||||
}
|
||||
|
||||
await sendNodeResponse(response, staticResponse);
|
||||
};
|
||||
|
||||
return (request, response) => {
|
||||
handle(request, response).catch((error) => {
|
||||
logError(`Failed to serve '${request.url}': ${(error as Error).message}`);
|
||||
|
||||
if (!response.headersSent) {
|
||||
response.writeHead(500);
|
||||
}
|
||||
|
||||
response.end();
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// Stable stringify inspired by https://github.com/BridgeAR/safe-stable-stringify
|
||||
// Takes advantage of Node env and Z2M's object-only use-case.
|
||||
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: escape regex
|
||||
const STR_ESC_SEQ_REGEXP = /[\u0000-\u001f\u0022\u005c\ud800-\udfff]/;
|
||||
|
||||
// Escape C0 control characters, double quotes, the backslash and every code
|
||||
// unit with a numeric value in the inclusive range 0xD800 to 0xDFFF.
|
||||
function strEscape(str: string): string {
|
||||
// Some magic numbers that worked out fine while benchmarking with v8 8.0
|
||||
if (str.length < 5000 && !STR_ESC_SEQ_REGEXP.test(str)) {
|
||||
return `"${str}"`;
|
||||
}
|
||||
|
||||
return JSON.stringify(str);
|
||||
}
|
||||
|
||||
function sort(array: string[]) {
|
||||
// Insertion sort is very efficient for small input sizes, but it has a bad
|
||||
// worst case complexity. Thus, use native array sort for bigger values.
|
||||
if (array.length > 2e2) {
|
||||
return array.sort();
|
||||
}
|
||||
|
||||
for (let i = 1; i < array.length; i++) {
|
||||
const currentValue = array[i];
|
||||
let position = i;
|
||||
|
||||
while (position !== 0 && array[position - 1] > currentValue) {
|
||||
array[position] = array[position - 1];
|
||||
position--;
|
||||
}
|
||||
|
||||
array[position] = currentValue;
|
||||
}
|
||||
}
|
||||
|
||||
function isTypedArray(value: unknown): value is unknown[] {
|
||||
return ArrayBuffer.isView(value) && !(value instanceof DataView);
|
||||
}
|
||||
|
||||
function stringifyTypedArray(array: unknown[]): string {
|
||||
if (array.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const isBigInt = typeof array[0] === "bigint";
|
||||
let res = `"0":${isBigInt ? `"${array[0]}"` : array[0]}`;
|
||||
|
||||
for (let i = 1; i < array.length; i++) {
|
||||
res += `,"${i}":${isBigInt ? `"${array[i]}"` : array[i]}`;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
function stringifySimple(key: string, value: unknown, stack: unknown[]): string | undefined {
|
||||
switch (typeof value) {
|
||||
case "string":
|
||||
return strEscape(value);
|
||||
case "object": {
|
||||
if (value === null) {
|
||||
return "null";
|
||||
}
|
||||
|
||||
if ("toJSON" in value && typeof value.toJSON === "function") {
|
||||
value = value.toJSON(key);
|
||||
|
||||
// Prevent calling `toJSON` again
|
||||
if (typeof value !== "object") {
|
||||
return stringifySimple(key, value, stack);
|
||||
}
|
||||
|
||||
if (value === null) {
|
||||
return "null";
|
||||
}
|
||||
}
|
||||
|
||||
if (stack.indexOf(value) !== -1) {
|
||||
return '"[Circular]"';
|
||||
}
|
||||
|
||||
let res = "";
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return "[]";
|
||||
}
|
||||
|
||||
stack.push(value);
|
||||
|
||||
let i = 0;
|
||||
|
||||
for (; i < value.length - 1; i++) {
|
||||
const tmp = stringifySimple(`${i}`, value[i], stack);
|
||||
res += tmp !== undefined ? tmp : "null";
|
||||
res += ",";
|
||||
}
|
||||
|
||||
const tmp = stringifySimple(`${i}`, value[i], stack);
|
||||
res += tmp !== undefined ? tmp : "null";
|
||||
|
||||
stack.pop();
|
||||
|
||||
return `[${res}]`;
|
||||
}
|
||||
|
||||
let keys = Object.keys(value);
|
||||
const keysLength = keys.length;
|
||||
|
||||
if (keysLength === 0) {
|
||||
return "{}";
|
||||
}
|
||||
|
||||
let separator = "";
|
||||
let propsToStringify = keysLength;
|
||||
|
||||
if (isTypedArray(value)) {
|
||||
res += stringifyTypedArray(value);
|
||||
keys = keys.slice(value.length);
|
||||
propsToStringify -= value.length;
|
||||
// Only separate from something that was actually written.
|
||||
separator = value.length > 0 ? "," : "";
|
||||
}
|
||||
|
||||
sort(keys);
|
||||
stack.push(value);
|
||||
|
||||
for (let i = 0; i < propsToStringify; i++) {
|
||||
const valKey = keys[i];
|
||||
const tmp = stringifySimple(valKey, (value as Record<string, unknown>)[valKey], stack);
|
||||
|
||||
if (tmp !== undefined) {
|
||||
res += `${separator}${strEscape(valKey)}:${tmp}`;
|
||||
separator = ",";
|
||||
}
|
||||
}
|
||||
|
||||
stack.pop();
|
||||
|
||||
return `{${res}}`;
|
||||
}
|
||||
case "number":
|
||||
return Number.isFinite(value) ? `${value}` : "null";
|
||||
case "boolean":
|
||||
return value === true ? "true" : "false";
|
||||
case "undefined":
|
||||
return undefined;
|
||||
case "bigint":
|
||||
return `"${value}"`;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function stringify(value: object): string {
|
||||
return stringifySimple("", value, []) ?? "null";
|
||||
}
|
||||
+3
-3
@@ -2,7 +2,7 @@ import assert from "node:assert";
|
||||
import fs from "node:fs";
|
||||
|
||||
import equals from "fast-deep-equal/es6";
|
||||
import yaml, {YAMLException} from "js-yaml";
|
||||
import {dump, load, YAMLException} from "js-yaml";
|
||||
|
||||
export class YAMLFileException extends YAMLException {
|
||||
file: string;
|
||||
@@ -20,7 +20,7 @@ export class YAMLFileException extends YAMLException {
|
||||
|
||||
function read(file: string): KeyValue {
|
||||
try {
|
||||
const result = yaml.load(fs.readFileSync(file, "utf8"));
|
||||
const result = load(fs.readFileSync(file, "utf8"));
|
||||
assert(result instanceof Object, `The content of ${file} is expected to be an object`);
|
||||
return result as KeyValue;
|
||||
} catch (error) {
|
||||
@@ -40,7 +40,7 @@ function writeIfChanged(file: string, content: KeyValue): void {
|
||||
const before = readIfExists(file);
|
||||
|
||||
if (!equals(before, content)) {
|
||||
fs.writeFileSync(file, yaml.dump(content));
|
||||
fs.writeFileSync(file, dump(content));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
|
||||
+19
-28
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "zigbee2mqtt",
|
||||
"version": "2.10.1",
|
||||
"version": "2.14.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",
|
||||
@@ -45,41 +45,32 @@
|
||||
"ajv": "^8.20.0",
|
||||
"bind-decorator": "^1.0.11",
|
||||
"debounce": "^3.0.0",
|
||||
"express-static-gzip": "^3.0.1",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"finalhandler": "^2.1.1",
|
||||
"humanize-duration": "^3.33.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||
"jszip": "^3.10.1",
|
||||
"mqtt": "^5.15.1",
|
||||
"object-assign-deep": "^0.4.0",
|
||||
"rimraf": "^6.1.3",
|
||||
"semver": "^7.7.4",
|
||||
"source-map-support": "^0.5.21",
|
||||
"throttleit": "^2.1.0",
|
||||
"fflate": "^0.8.3",
|
||||
"humanize-duration": "^3.34.1",
|
||||
"js-yaml": "^5.3.0",
|
||||
"mqtt": "^5.15.2",
|
||||
"semver": "^7.8.5",
|
||||
"srvx": "^0.12.7",
|
||||
"throttleit": "^3.0.0",
|
||||
"winston": "^3.19.0",
|
||||
"winston-syslog": "^2.7.1",
|
||||
"winston-transport": "^4.9.0",
|
||||
"ws": "^8.20.0",
|
||||
"zigbee-herdsman": "10.0.7",
|
||||
"zigbee-herdsman-converters": "26.46.0",
|
||||
"ws": "^8.21.3",
|
||||
"zigbee-herdsman": "10.9.1",
|
||||
"zigbee-herdsman-converters": "26.100.0",
|
||||
"zigbee2mqtt-frontend": "0.9.21",
|
||||
"zigbee2mqtt-windfront": "2.11.2"
|
||||
"zigbee2mqtt-windfront": "2.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.14",
|
||||
"@types/finalhandler": "^1.2.3",
|
||||
"@biomejs/biome": "^2.5.3",
|
||||
"@types/humanize-duration": "^3.27.4",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/object-assign-deep": "^0.4.3",
|
||||
"@types/readable-stream": "4.0.23",
|
||||
"@types/serve-static": "^2.2.0",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/readable-stream": "4.0.24",
|
||||
"@types/ws": "8.18.1",
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
"tmp": "^0.2.5",
|
||||
"typescript": "^6.0.3",
|
||||
"tmp": "^0.2.7",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^3.1.1"
|
||||
},
|
||||
"pnpm": {
|
||||
|
||||
Generated
+409
-587
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({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
+36
-1
@@ -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";
|
||||
@@ -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},
|
||||
};
|
||||
@@ -1059,6 +1061,17 @@ describe("Controller", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("Publish entity state attribute output with a null color", async () => {
|
||||
await controller.start();
|
||||
settings.set(["advanced", "output"], "attribute_and_json");
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
const device = getZ2MDevice("bulb");
|
||||
await controller.publishEntityState(device, {state: "ON", color: null});
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb/state", "ON", {qos: 0, retain: true});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb/color", "", {qos: 0, retain: true});
|
||||
});
|
||||
|
||||
it("Publish entity state attribute_json output filtered", async () => {
|
||||
await controller.start();
|
||||
settings.set(["advanced", "output"], "attribute_and_json");
|
||||
@@ -1087,6 +1100,28 @@ describe("Controller", () => {
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({state: "ON", brightness: 200}), {qos: 0, retain: true});
|
||||
});
|
||||
|
||||
it("Publish entity state caches a duration reported by the device", async () => {
|
||||
await controller.start();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
const device = getZ2MDevice("bulb");
|
||||
await controller.publishEntityState(device, {state: "ON", duration: 30});
|
||||
await flushPromises();
|
||||
|
||||
expect(controller.state.get(device)).toStrictEqual({brightness: 50, color_temp: 370, linkquality: 99, state: "ON", duration: 30});
|
||||
});
|
||||
|
||||
it("Publish entity state keeps an action_duration out of the cache", async () => {
|
||||
await controller.start();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
const device = getZ2MDevice("bulb");
|
||||
await controller.publishEntityState(device, {state: "ON", action_duration: 1500});
|
||||
await flushPromises();
|
||||
|
||||
expect(controller.state.get(device)).toStrictEqual({brightness: 50, color_temp: 370, linkquality: 99, state: "ON"});
|
||||
});
|
||||
|
||||
it("Publish entity state attribute_json output filtered cache", async () => {
|
||||
await controller.start();
|
||||
settings.set(["advanced", "output"], "attribute_and_json");
|
||||
|
||||
+15
-6
@@ -15,12 +15,21 @@ describe("Data", () => {
|
||||
const expected = tmp.dirSync().name;
|
||||
process.env.ZIGBEE2MQTT_DATA = expected;
|
||||
data._testReload();
|
||||
const actual = data.getPath();
|
||||
expect(actual).toBe(expected);
|
||||
expect(data.joinPath("test")).toStrictEqual(path.join(expected, "test"));
|
||||
expect(data.joinPath("/test")).toStrictEqual(path.resolve(expected, "/test"));
|
||||
delete process.env.ZIGBEE2MQTT_DATA;
|
||||
data._testReload();
|
||||
|
||||
try {
|
||||
const actual = data.getPath();
|
||||
expect(actual).toBe(expected);
|
||||
expect(data.joinPath("test")).toStrictEqual(path.join(expected, "test"));
|
||||
expect(data.joinPath("/test")).toStrictEqual(path.resolve(expected, "/test"));
|
||||
} finally {
|
||||
delete process.env.ZIGBEE2MQTT_DATA;
|
||||
data._testReload();
|
||||
}
|
||||
});
|
||||
|
||||
it("Should return data path when joinPath with empty string", () => {
|
||||
const expected = data.getPath();
|
||||
expect(data.joinPath("")).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import {flushPromises} from "../mocks/utils";
|
||||
import {devices, events as mockZHEvents, returnDevices} from "../mocks/zigbeeHerdsman";
|
||||
|
||||
import assert from "node:assert";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {stringify} from "../../lib/util/stringify";
|
||||
import {Controller} from "../../lib/controller";
|
||||
import Availability from "../../lib/extension/availability";
|
||||
import * as settings from "../../lib/util/settings";
|
||||
@@ -633,6 +633,19 @@ describe("Extension: Availability", () => {
|
||||
expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("clamps the ping delay to the maximum supported timeout", async () => {
|
||||
// `setTimeout` takes a 32-bit signed integer and coerces anything above it to `1`. A delay can exceed
|
||||
// that either directly, through a long `timeout`, or gradually, once `backoff` has multiplied a normal
|
||||
// one over successive failures. Unclamped, that turns an ever-longer wait into a tight ping loop.
|
||||
settings.set(["devices", devices.bulb_color.ieeeAddr, "availability"], {timeout: 40000, max_jitter: 0}); // ~27.8 days
|
||||
await resetExtension();
|
||||
|
||||
// unclamped, the delay collapses to 1ms, so pings would already be looping by now
|
||||
await setTimeAndAdvanceTimers(utils.seconds(1));
|
||||
|
||||
expect(devices.bulb_color.ping).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows to disable backoff", async () => {
|
||||
settings.set(["availability", "active", "max_jitter"], 0); // easier testing
|
||||
settings.set(["availability", "active", "backoff"], false);
|
||||
|
||||
@@ -7,7 +7,7 @@ import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
|
||||
import {flushPromises} from "../mocks/utils";
|
||||
import {type Device, devices, groups, events as mockZHEvents} from "../mocks/zigbeeHerdsman";
|
||||
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {stringify} from "../../lib/util/stringify";
|
||||
import {Controller} from "../../lib/controller";
|
||||
import Bind from "../../lib/extension/bind";
|
||||
import * as settings from "../../lib/util/settings";
|
||||
|
||||
+215
-17
@@ -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: {},
|
||||
@@ -783,6 +784,16 @@ describe("Extension: Bridge", () => {
|
||||
property: "effect_color",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
access: 2,
|
||||
category: "config",
|
||||
description: "Initiate device identification",
|
||||
label: "Identify",
|
||||
name: "identify",
|
||||
property: "identify",
|
||||
type: "enum",
|
||||
values: ["identify"],
|
||||
},
|
||||
{
|
||||
access: 1,
|
||||
category: "diagnostic",
|
||||
@@ -830,6 +841,17 @@ describe("Extension: Bridge", () => {
|
||||
value_min: 0,
|
||||
value_step: 0.1,
|
||||
},
|
||||
{
|
||||
access: 2,
|
||||
description:
|
||||
"Sets the duration of the identification procedure in seconds (i.e., how long the device would flash).The value ranges from 1 to 30 seconds (default: 3).",
|
||||
label: "Identify timeout",
|
||||
name: "identify_timeout",
|
||||
property: "identify_timeout",
|
||||
type: "numeric",
|
||||
value_max: 30,
|
||||
value_min: 1,
|
||||
},
|
||||
{
|
||||
access: 2,
|
||||
description: "State actions will also be published as 'action' when true (default false).",
|
||||
@@ -1161,6 +1183,17 @@ describe("Extension: Bridge", () => {
|
||||
property: "power_outage_count",
|
||||
type: "numeric",
|
||||
},
|
||||
{
|
||||
access: 2,
|
||||
category: "config",
|
||||
description:
|
||||
"Initiate device identification. This device is asleep by default.You may need to wake it up first before sending the identify command.",
|
||||
label: "Identify",
|
||||
name: "identify",
|
||||
property: "identify",
|
||||
type: "enum",
|
||||
values: ["identify"],
|
||||
},
|
||||
{
|
||||
access: 1,
|
||||
category: "diagnostic",
|
||||
@@ -1195,6 +1228,17 @@ describe("Extension: Bridge", () => {
|
||||
type: "numeric",
|
||||
value_step: 0.1,
|
||||
},
|
||||
{
|
||||
access: 2,
|
||||
description:
|
||||
"Sets the duration of the identification procedure in seconds (i.e., how long the device would flash).The value ranges from 1 to 30 seconds (default: 3).",
|
||||
label: "Identify timeout",
|
||||
name: "identify_timeout",
|
||||
property: "identify_timeout",
|
||||
type: "numeric",
|
||||
value_max: 30,
|
||||
value_min: 1,
|
||||
},
|
||||
],
|
||||
supports_ota: false,
|
||||
vendor: "Aqara",
|
||||
@@ -1961,7 +2005,7 @@ describe("Extension: Bridge", () => {
|
||||
},
|
||||
{
|
||||
access: 2,
|
||||
description: "Inverts the cover position, false: open=100,close=0, true: open=0,close=100 (default false).",
|
||||
description: "Inverts the cover position and state, false: open=100,close=0, true: open=0,close=100 (default false).",
|
||||
label: "Invert cover",
|
||||
name: "invert_cover",
|
||||
property: "invert_cover",
|
||||
@@ -2805,7 +2849,7 @@ describe("Extension: Bridge", () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
await mockZHEvents.deviceLeave({ieeeAddr: devices.bulb.ieeeAddr});
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(3);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(4);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/event",
|
||||
stringify({type: "device_leave", data: {ieee_address: "0x000b57fffec6a5b2", friendly_name: "bulb"}}),
|
||||
@@ -2818,6 +2862,7 @@ describe("Extension: Bridge", () => {
|
||||
expect.any(String),
|
||||
{retain: true},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/groups", expect.any(String), {retain: true});
|
||||
});
|
||||
|
||||
it("Should allow permit join on all", async () => {
|
||||
@@ -2972,6 +3017,7 @@ describe("Extension: Bridge", () => {
|
||||
|
||||
it("Should allow to remove device by string", async () => {
|
||||
const device = devices.bulb;
|
||||
const removeSpy = vi.spyOn(controller.zigbee, "removeDeviceFromLookup");
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/remove", "bulb");
|
||||
await flushPromises();
|
||||
@@ -2980,11 +3026,12 @@ describe("Extension: Bridge", () => {
|
||||
expect(device.removeFromNetwork).toHaveBeenCalledTimes(1);
|
||||
expect(device.removeFromDatabase).not.toHaveBeenCalled();
|
||||
expect(settings.getDevice("bulb")).toBeUndefined();
|
||||
expect(removeSpy).not.toHaveBeenCalled();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb", "", {retain: true});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/remove",
|
||||
stringify({data: {id: "bulb", block: false, force: false}, status: "ok"}),
|
||||
stringify({data: {id: "bulb", block: false, force: false, keep_config: false, clear_cache: false}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
expect(settings.get().blocklist).toStrictEqual([]);
|
||||
@@ -2994,52 +3041,94 @@ describe("Extension: Bridge", () => {
|
||||
|
||||
it("Should allow to remove device by object ID", async () => {
|
||||
const device = devices.bulb;
|
||||
const removeSpy = vi.spyOn(controller.zigbee, "removeDeviceFromLookup");
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/remove", stringify({id: "bulb"}));
|
||||
await flushPromises();
|
||||
expect(device.removeFromNetwork).toHaveBeenCalledTimes(1);
|
||||
expect(device.removeFromDatabase).not.toHaveBeenCalled();
|
||||
expect(settings.getDevice("bulb")).toBeUndefined();
|
||||
expect(removeSpy).not.toHaveBeenCalled();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/remove",
|
||||
stringify({data: {id: "bulb", block: false, force: false}, status: "ok"}),
|
||||
stringify({data: {id: "bulb", block: false, force: false, keep_config: false, clear_cache: false}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("Should allow to force remove device", async () => {
|
||||
const device = devices.bulb;
|
||||
const removeSpy = vi.spyOn(controller.zigbee, "removeDeviceFromLookup");
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/remove", stringify({id: "bulb", force: true}));
|
||||
await flushPromises();
|
||||
expect(device.removeFromDatabase).toHaveBeenCalledTimes(1);
|
||||
expect(device.removeFromNetwork).not.toHaveBeenCalled();
|
||||
expect(settings.getDevice("bulb")).toBeUndefined();
|
||||
expect(removeSpy).not.toHaveBeenCalled();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/remove",
|
||||
stringify({data: {id: "bulb", block: false, force: true}, status: "ok"}),
|
||||
stringify({data: {id: "bulb", block: false, force: true, keep_config: false, clear_cache: false}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("Should allow to block device", async () => {
|
||||
const device = devices.bulb;
|
||||
const removeSpy = vi.spyOn(controller.zigbee, "removeDeviceFromLookup");
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/remove", stringify({id: "bulb", block: true, force: true}));
|
||||
await flushPromises();
|
||||
expect(device.removeFromDatabase).toHaveBeenCalledTimes(1);
|
||||
expect(settings.getDevice("bulb")).toBeUndefined();
|
||||
expect(removeSpy).not.toHaveBeenCalled();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/remove",
|
||||
stringify({data: {id: "bulb", block: true, force: true}, status: "ok"}),
|
||||
stringify({data: {id: "bulb", block: true, force: true, keep_config: false, clear_cache: false}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
expect(settings.get().blocklist).toStrictEqual(["0x000b57fffec6a5b2"]);
|
||||
});
|
||||
|
||||
it("Should allow to keep configuration when removing device", async () => {
|
||||
const device = devices.bulb;
|
||||
const removeSpy = vi.spyOn(controller.zigbee, "removeDeviceFromLookup");
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/remove", stringify({id: "bulb", keep_config: true}));
|
||||
await flushPromises();
|
||||
expect(device.removeFromDatabase).not.toHaveBeenCalled();
|
||||
expect(device.removeFromNetwork).toHaveBeenCalledTimes(1);
|
||||
expect(settings.getDevice("bulb")).toBeDefined();
|
||||
expect(removeSpy).not.toHaveBeenCalled();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/remove",
|
||||
stringify({data: {id: "bulb", block: false, force: false, keep_config: true, clear_cache: false}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("Should allow to clear cache when removing device", async () => {
|
||||
const device = devices.bulb;
|
||||
const removeSpy = vi.spyOn(controller.zigbee, "removeDeviceFromLookup");
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/remove", stringify({id: "bulb", clear_cache: true}));
|
||||
await flushPromises();
|
||||
expect(device.removeFromNetwork).toHaveBeenCalledTimes(1);
|
||||
expect(device.removeFromDatabase).not.toHaveBeenCalled();
|
||||
expect(settings.getDevice("bulb")).toBeUndefined();
|
||||
expect(removeSpy).toHaveNthReturnedWith(1, true);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/remove",
|
||||
stringify({data: {id: "bulb", block: false, force: false, keep_config: false, clear_cache: true}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("Should allow to remove group", async () => {
|
||||
const group = groups.group_1;
|
||||
const removeGroupFromLookup = vi.spyOn(controller.zigbee, "removeGroupFromLookup");
|
||||
@@ -3104,7 +3193,11 @@ describe("Extension: Bridge", () => {
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/remove",
|
||||
stringify({data: {}, status: "error", error: "Failed to remove device 'bulb' (block: false, force: false) (Error: device timeout)"}),
|
||||
stringify({
|
||||
data: {},
|
||||
status: "error",
|
||||
error: "Failed to remove device 'bulb' (block: false, force: false, keep config: false, clear cache: false) (Error: device timeout)",
|
||||
}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
@@ -3370,7 +3463,7 @@ describe("Extension: Bridge", () => {
|
||||
" model: 'lumi.plug',\n" +
|
||||
" vendor: '',\n" +
|
||||
" description: 'Automatically generated definition',\n" +
|
||||
' extend: [m.onOff({"powerOnBehavior":false})],\n' +
|
||||
" extend: [m.onOff()],\n" +
|
||||
"};\n",
|
||||
},
|
||||
status: "ok",
|
||||
@@ -3553,6 +3646,36 @@ describe("Extension: Bridge", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("Should warn on unsupported device option", async () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockLogger.warning.mockClear();
|
||||
|
||||
const device = controller.zigbee.resolveEntity(devices.bulb.ieeeAddr);
|
||||
assert(device && "definition" in device);
|
||||
const definitionOptions = device.definition?.options;
|
||||
device.definition!.options = undefined;
|
||||
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/options", stringify({options: {unsupported: true}, id: "bulb"}));
|
||||
await flushPromises();
|
||||
device.definition!.options = definitionOptions;
|
||||
|
||||
expect(settings.getDevice("bulb")).toHaveProperty("unsupported");
|
||||
expect(mockLogger.warning).toHaveBeenCalledWith("Device 'bulb' does not support option 'unsupported'");
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/options",
|
||||
stringify({
|
||||
data: {
|
||||
from: {retain: true, description: "this is my bulb"},
|
||||
to: {retain: true, description: "this is my bulb", unsupported: true},
|
||||
id: "bulb",
|
||||
restart_required: false,
|
||||
},
|
||||
status: "ok",
|
||||
}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("Should allow to add group by string", async () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/group/add", "group_193");
|
||||
@@ -4161,13 +4284,24 @@ describe("Extension: Bridge", () => {
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/backup", "");
|
||||
await flushPromises();
|
||||
expect(mockZHController.backup).toHaveBeenCalledTimes(1);
|
||||
expect(mockJSZipFile).toHaveBeenCalledTimes(4);
|
||||
expect(mockJSZipFile).toHaveBeenNthCalledWith(1, "configuration.yaml", expect.any(Object));
|
||||
expect(mockJSZipFile).toHaveBeenNthCalledWith(2, path.join("ext_converters", "123", "myfile.js"), expect.any(Object));
|
||||
expect(mockJSZipFile).toHaveBeenNthCalledWith(3, path.join("ext_converters", "afile.js"), expect.any(Object));
|
||||
expect(mockJSZipFile).toHaveBeenNthCalledWith(4, "state.json", expect.any(Object));
|
||||
expect(mockJSZipGenerateAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockJSZipGenerateAsync).toHaveBeenNthCalledWith(1, {type: "base64"});
|
||||
expect(mockFflateZip).toHaveBeenCalledTimes(1);
|
||||
expect(mockFflateZip).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{
|
||||
"configuration.yaml": expect.any(Buffer),
|
||||
[path.join("ext_converters", "123", "myfile.js")]: expect.any(Buffer),
|
||||
[path.join("ext_converters", "afile.js")]: expect.any(Buffer),
|
||||
"state.json": expect.any(Buffer),
|
||||
},
|
||||
{level: 6},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(Object.keys(mockFflateZip.mock.calls[0][0])).toStrictEqual([
|
||||
"configuration.yaml",
|
||||
path.join("ext_converters", "123", "myfile.js"),
|
||||
path.join("ext_converters", "afile.js"),
|
||||
"state.json",
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/backup",
|
||||
stringify({data: {zip: "THISISBASE64"}, status: "ok"}),
|
||||
@@ -4175,6 +4309,18 @@ describe("Extension: Bridge", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("Should return an error when the backup archive cannot be created", async () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockFflateZipFailOnce(new Error("invalid zip data"));
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/backup", "");
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/backup",
|
||||
stringify({data: {}, status: "error", error: "invalid zip data"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("Should allow to restart", async () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/restart", "");
|
||||
@@ -4324,6 +4470,58 @@ describe("Extension: Bridge", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("Change options consecutively, check restart required", async () => {
|
||||
settings.apply({health: {interval: 10, reset_on_check: false}});
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
// Change option that doesn't require restart
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/options", stringify({options: {health: {reset_on_check: true}}}));
|
||||
await flushPromises();
|
||||
|
||||
expect(settings.get().health.reset_on_check).toBe(true);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/options",
|
||||
stringify({data: {restart_required: false}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/info", expect.stringContaining('"restart_required":false'), {
|
||||
retain: true,
|
||||
});
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
// Change option that requires restart
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/options", stringify({options: {health: {interval: 11}}}));
|
||||
await flushPromises();
|
||||
|
||||
expect(settings.get().health.interval).toBe(11);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/options",
|
||||
stringify({data: {restart_required: true}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/info", expect.stringContaining('"restart_required":true'), {
|
||||
retain: true,
|
||||
});
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
// Change option that doesn't require restart
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/options", stringify({options: {health: {reset_on_check: false}}}));
|
||||
await flushPromises();
|
||||
|
||||
expect(settings.get().health.reset_on_check).toBe(false);
|
||||
|
||||
// System still requires restart
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/options",
|
||||
stringify({data: {restart_required: true}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/info", expect.stringContaining('"restart_required":true'), {
|
||||
retain: true,
|
||||
});
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
});
|
||||
|
||||
it("Icon link handling", () => {
|
||||
const bridge = controller.getExtension("Bridge")! as Bridge;
|
||||
expect(bridge).toBeDefined();
|
||||
|
||||
@@ -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,13 +7,18 @@ import {type EventHandler, flushPromises} from "../mocks/utils";
|
||||
import {devices, events as mockZHEvents} from "../mocks/zigbeeHerdsman";
|
||||
|
||||
import path from "node:path";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {stringify} from "../../lib/util/stringify";
|
||||
import type {Mock} from "vitest";
|
||||
import ws from "ws";
|
||||
import {Controller} from "../../lib/controller";
|
||||
import * as settings from "../../lib/util/settings";
|
||||
|
||||
let mockHTTPOnRequest: (request: {url: string}, response: number) => void;
|
||||
const mockRedirectResponse = {
|
||||
writeHead: vi.fn<(statusCode: number, headers: Record<string, string>) => void>(),
|
||||
end: vi.fn<() => void>(),
|
||||
};
|
||||
|
||||
let mockHTTPOnRequest: (request: {url: string}, response: number | typeof mockRedirectResponse) => void;
|
||||
const mockHTTPEvents: Record<string, EventHandler> = {};
|
||||
const mockHTTP = {
|
||||
listen: vi.fn(),
|
||||
@@ -64,7 +69,7 @@ const frontendPath = "frontend-path";
|
||||
const deviceIconsPath = path.join(data.mockDir, "device_icons");
|
||||
let mockNodeStatic: {[s: string]: Mock} = {};
|
||||
|
||||
const mockFinalHandler = vi.fn();
|
||||
const mockSendNotFound = vi.fn();
|
||||
|
||||
vi.mock("node:http", () => ({
|
||||
createServer: vi.fn().mockImplementation((onRequest) => {
|
||||
@@ -79,11 +84,12 @@ vi.mock("node:https", () => ({
|
||||
Agent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("express-static-gzip", () => ({
|
||||
default: vi.fn().mockImplementation((path: string) => {
|
||||
vi.mock("../../lib/util/staticFileServer", () => ({
|
||||
createStaticFileServer: vi.fn().mockImplementation((path: string) => {
|
||||
mockNodeStatic[path] = vi.fn();
|
||||
return mockNodeStatic[path];
|
||||
}),
|
||||
sendNotFound: vi.fn().mockImplementation((...args: unknown[]) => mockSendNotFound(...args)),
|
||||
}));
|
||||
|
||||
vi.mock("zigbee2mqtt-windfront", () => ({
|
||||
@@ -101,12 +107,6 @@ vi.mock("ws", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("finalhandler", () => ({
|
||||
default: vi.fn().mockImplementation(() => {
|
||||
return mockFinalHandler;
|
||||
}),
|
||||
}));
|
||||
|
||||
const mocksClear = [
|
||||
mockHTTP.close,
|
||||
mockHTTP.listen,
|
||||
@@ -118,7 +118,9 @@ const mocksClear = [
|
||||
mockWS.emit,
|
||||
mockWSClient.send,
|
||||
mockWSClient.terminate,
|
||||
mockFinalHandler,
|
||||
mockSendNotFound,
|
||||
mockRedirectResponse.writeHead,
|
||||
mockRedirectResponse.end,
|
||||
mockMQTTPublishAsync,
|
||||
mockLogger.error,
|
||||
];
|
||||
@@ -247,6 +249,7 @@ describe("Extension: Frontend", () => {
|
||||
effect: null,
|
||||
effect_color: null,
|
||||
effect_speed: null,
|
||||
identify: null,
|
||||
power_on_behavior: null,
|
||||
linkquality: 20,
|
||||
update: {state: null, installed_version: -1, latest_version: -1},
|
||||
@@ -273,6 +276,7 @@ describe("Extension: Frontend", () => {
|
||||
effect: null,
|
||||
effect_color: null,
|
||||
effect_speed: null,
|
||||
identify: null,
|
||||
linkquality: 20,
|
||||
update: {state: null, installed_version: -1, latest_version: -1},
|
||||
},
|
||||
@@ -302,6 +306,7 @@ describe("Extension: Frontend", () => {
|
||||
effect: null,
|
||||
effect_color: null,
|
||||
effect_speed: null,
|
||||
identify: null,
|
||||
linkquality: 20,
|
||||
update: {state: null, installed_version: -1, latest_version: -1},
|
||||
},
|
||||
@@ -340,11 +345,7 @@ describe("Extension: Frontend", () => {
|
||||
mockHTTPOnRequest({url: "/file.txt"}, 2);
|
||||
expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledTimes(0);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith(
|
||||
{originalUrl: "/file.txt", path: "/file.txt", url: "/file.txt"},
|
||||
2,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith({url: "/file.txt"}, 2);
|
||||
});
|
||||
|
||||
it("Should serve device icons", async () => {
|
||||
@@ -354,11 +355,7 @@ describe("Extension: Frontend", () => {
|
||||
mockHTTPOnRequest({url: "/device_icons/my_device.png"}, 2);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(0);
|
||||
expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledWith(
|
||||
{originalUrl: "/device_icons/my_device.png", path: "/my_device.png", url: "/my_device.png"},
|
||||
2,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledWith({url: "/my_device.png"}, 2);
|
||||
});
|
||||
|
||||
it("Static server", async () => {
|
||||
@@ -402,34 +399,33 @@ describe("Extension: Frontend", () => {
|
||||
|
||||
expect(ws.Server).toHaveBeenCalledWith({noServer: true, path: "/z2m/api"});
|
||||
|
||||
mockHTTPOnRequest({url: "/z2m"}, 2);
|
||||
// the base url without trailing slash points at a directory, redirect so relative asset paths resolve against it
|
||||
mockHTTPOnRequest({url: "/z2m"}, mockRedirectResponse);
|
||||
expect(mockNodeStatic[frontendPath]).not.toHaveBeenCalled();
|
||||
expect(mockRedirectResponse.writeHead).toHaveBeenCalledWith(301, {Location: "/z2m/"});
|
||||
expect(mockRedirectResponse.end).toHaveBeenCalledTimes(1);
|
||||
expect(mockSendNotFound).not.toHaveBeenCalled();
|
||||
|
||||
mockHTTPOnRequest({url: "/z2m/"}, 2);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith({originalUrl: "/z2m", path: "/", url: "/"}, 2, expect.any(Function));
|
||||
expect(mockFinalHandler).not.toHaveBeenCalledWith();
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith({url: "/"}, 2);
|
||||
expect(mockSendNotFound).not.toHaveBeenCalledWith();
|
||||
|
||||
mockNodeStatic[frontendPath].mockReset();
|
||||
expect(mockFinalHandler).not.toHaveBeenCalledWith();
|
||||
expect(mockSendNotFound).not.toHaveBeenCalledWith();
|
||||
mockHTTPOnRequest({url: "/z2m/file.txt"}, 2);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith(
|
||||
{originalUrl: "/z2m/file.txt", path: "/file.txt", url: "/file.txt"},
|
||||
2,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockFinalHandler).not.toHaveBeenCalledWith();
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith({url: "/file.txt"}, 2);
|
||||
expect(mockSendNotFound).not.toHaveBeenCalledWith();
|
||||
|
||||
mockNodeStatic[frontendPath].mockReset();
|
||||
mockHTTPOnRequest({url: "/z/file.txt"}, 2);
|
||||
expect(mockNodeStatic[frontendPath]).not.toHaveBeenCalled();
|
||||
expect(mockFinalHandler).toHaveBeenCalled();
|
||||
expect(mockSendNotFound).toHaveBeenCalled();
|
||||
|
||||
mockHTTPOnRequest({url: "/z2m/device_icons/my-device.png"}, 2);
|
||||
expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledWith(
|
||||
{originalUrl: "/z2m/device_icons/my-device.png", path: "/my-device.png", url: "/my-device.png"},
|
||||
2,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledWith({url: "/my-device.png"}, 2);
|
||||
});
|
||||
|
||||
it("Works with non-default complex base url", async () => {
|
||||
@@ -440,30 +436,28 @@ describe("Extension: Frontend", () => {
|
||||
|
||||
expect(ws.Server).toHaveBeenCalledWith({noServer: true, path: "/z2m-more++/c0mplex.url/api"});
|
||||
|
||||
mockHTTPOnRequest({url: "/z2m-more++/c0mplex.url"}, 2);
|
||||
mockHTTPOnRequest({url: "/z2m-more++/c0mplex.url"}, mockRedirectResponse);
|
||||
expect(mockNodeStatic[frontendPath]).not.toHaveBeenCalled();
|
||||
expect(mockRedirectResponse.writeHead).toHaveBeenCalledWith(301, {Location: "/z2m-more++/c0mplex.url/"});
|
||||
expect(mockRedirectResponse.end).toHaveBeenCalledTimes(1);
|
||||
expect(mockSendNotFound).not.toHaveBeenCalled();
|
||||
|
||||
mockHTTPOnRequest({url: "/z2m-more++/c0mplex.url/"}, 2);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith(
|
||||
{originalUrl: "/z2m-more++/c0mplex.url", path: "/", url: "/"},
|
||||
2,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockFinalHandler).not.toHaveBeenCalledWith();
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith({url: "/"}, 2);
|
||||
expect(mockSendNotFound).not.toHaveBeenCalledWith();
|
||||
|
||||
mockNodeStatic[frontendPath].mockReset();
|
||||
expect(mockFinalHandler).not.toHaveBeenCalledWith();
|
||||
expect(mockSendNotFound).not.toHaveBeenCalledWith();
|
||||
mockHTTPOnRequest({url: "/z2m-more++/c0mplex.url/file.txt"}, 2);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith(
|
||||
{originalUrl: "/z2m-more++/c0mplex.url/file.txt", path: "/file.txt", url: "/file.txt"},
|
||||
2,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockFinalHandler).not.toHaveBeenCalledWith();
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith({url: "/file.txt"}, 2);
|
||||
expect(mockSendNotFound).not.toHaveBeenCalledWith();
|
||||
|
||||
mockNodeStatic[frontendPath].mockReset();
|
||||
mockHTTPOnRequest({url: "/z/file.txt"}, 2);
|
||||
expect(mockNodeStatic[frontendPath]).not.toHaveBeenCalled();
|
||||
expect(mockFinalHandler).toHaveBeenCalled();
|
||||
expect(mockSendNotFound).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prevents mismatching setting/extension state", async () => {
|
||||
|
||||
@@ -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";
|
||||
@@ -128,6 +128,178 @@ describe("Extension: HomeAssistant", () => {
|
||||
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 set discovery name to null when expose specifies homeassistant name null", () => {
|
||||
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 contactExpose = new zhc.Binary("contact", zhc.access.STATE, false, true).withHomeAssistant({name: null});
|
||||
|
||||
// @ts-expect-error private
|
||||
const configs = extension.getConfigs(createDevice([contactExpose]));
|
||||
expect(configs.find((config) => config.object_id === "contact")?.discovery_payload.name).toBeNull();
|
||||
});
|
||||
|
||||
it("Should discover devices and groups", async () => {
|
||||
settings.set(["homeassistant", "experimental_event_entities"], true);
|
||||
settings.set(["groups", "9", "homeassistant"], {name: "HA Discovery Group", icon: "mdi:lightbulb-group"});
|
||||
@@ -239,7 +411,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
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(
|
||||
@@ -252,7 +424,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",
|
||||
@@ -279,7 +451,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",
|
||||
@@ -306,7 +478,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",
|
||||
@@ -333,7 +505,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",
|
||||
@@ -363,7 +535,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",
|
||||
@@ -405,7 +577,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), {
|
||||
@@ -432,7 +604,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), {
|
||||
@@ -535,7 +707,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",
|
||||
@@ -607,7 +779,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",
|
||||
@@ -633,7 +805,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",
|
||||
@@ -660,7 +832,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",
|
||||
@@ -718,7 +890,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",
|
||||
@@ -748,7 +920,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: {
|
||||
@@ -791,7 +963,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",
|
||||
@@ -818,7 +990,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",
|
||||
@@ -881,7 +1053,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), {
|
||||
@@ -927,11 +1099,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,
|
||||
@@ -1003,7 +1175,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,
|
||||
@@ -1036,7 +1208,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: [
|
||||
{
|
||||
@@ -1044,7 +1216,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"],
|
||||
@@ -1056,18 +1228,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",
|
||||
@@ -1107,7 +1279,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), {
|
||||
@@ -1144,7 +1316,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), {
|
||||
@@ -1156,10 +1328,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"],
|
||||
@@ -1184,7 +1356,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",
|
||||
@@ -1196,6 +1368,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");
|
||||
@@ -1211,10 +1401,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"],
|
||||
@@ -1228,7 +1418,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,
|
||||
@@ -1237,7 +1427,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",
|
||||
@@ -1252,15 +1442,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: {
|
||||
@@ -1275,20 +1489,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",
|
||||
@@ -1319,7 +1533,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), {
|
||||
@@ -1328,6 +1542,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;
|
||||
|
||||
@@ -1336,9 +1581,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",
|
||||
@@ -1375,7 +1620,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",
|
||||
@@ -1384,13 +1629,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), {
|
||||
@@ -1420,16 +1665,19 @@ 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",
|
||||
state_closed: "CLOSE",
|
||||
state_closing: "DOWN",
|
||||
state_open: "OPEN",
|
||||
state_opening: "UP",
|
||||
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"] == "UP" %}UP{% elif "moving" in value_json and value_json["moving"] == "DOWN" %}DOWN{% elif "state" in value_json %}{{ value_json["state"] }}{% else %}STOP{% endif %}',
|
||||
};
|
||||
const payload_right = {
|
||||
availability: [
|
||||
@@ -1451,19 +1699,28 @@ 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",
|
||||
state_closed: "CLOSE",
|
||||
state_closing: "DOWN",
|
||||
state_open: "OPEN",
|
||||
state_opening: "UP",
|
||||
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"] == "UP" %}UP{% elif "moving" in value_json and value_json["moving"] == "DOWN" %}DOWN{% elif "state" in value_json %}{{ value_json["state"] }}{% else %}STOP{% endif %}',
|
||||
};
|
||||
|
||||
console.log(mockMQTTPublishAsync.mock.calls.find((c) => c[0] === "homeassistant/cover/0xa4c138018cf95021/cover_left/config"));
|
||||
const coverLeftCalls = mockMQTTPublishAsync.mock.calls.filter(
|
||||
([topic]) => topic === "homeassistant/cover/0xa4c138018cf95021/cover_left/config",
|
||||
);
|
||||
|
||||
for (const [, actualPayload] of coverLeftCalls) {
|
||||
console.log(JSON.parse(actualPayload));
|
||||
}
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/cover/0xa4c138018cf95021/cover_left/config", stringify(payload_left), {
|
||||
retain: true,
|
||||
@@ -1475,6 +1732,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();
|
||||
@@ -1483,7 +1796,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",
|
||||
@@ -1515,7 +1828,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 () => {
|
||||
@@ -1531,7 +1844,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 () => {
|
||||
@@ -1573,6 +1888,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
effect: null,
|
||||
effect_color: null,
|
||||
effect_speed: null,
|
||||
identify: null,
|
||||
linkquality: null,
|
||||
state: null,
|
||||
power_on_behavior: null,
|
||||
@@ -1598,6 +1914,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
effect: null,
|
||||
effect_color: null,
|
||||
effect_speed: null,
|
||||
identify: null,
|
||||
linkquality: null,
|
||||
state: null,
|
||||
power_on_behavior: null,
|
||||
@@ -1622,6 +1939,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
effect: null,
|
||||
effect_color: null,
|
||||
effect_speed: null,
|
||||
identify: null,
|
||||
state: "ON",
|
||||
power_on_behavior: null,
|
||||
update: {state: null, installed_version: -1, latest_version: -1},
|
||||
@@ -1669,7 +1987,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",
|
||||
@@ -1724,6 +2042,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({
|
||||
@@ -1764,6 +2083,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({
|
||||
@@ -1830,7 +2150,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",
|
||||
@@ -1900,7 +2220,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",
|
||||
@@ -2034,7 +2354,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",
|
||||
@@ -2091,7 +2411,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
|
||||
it("Should discover trigger when action is published", async () => {
|
||||
const discovered = mockMQTTPublishAsync.mock.calls.filter((c) => c[0].includes("0x0017880104e45520")).map((c) => c[0]);
|
||||
expect(discovered.length).toBe(5);
|
||||
expect(discovered.length).toBe(6);
|
||||
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
@@ -2128,6 +2448,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
stringify({
|
||||
action: "single",
|
||||
battery: null,
|
||||
identify: null,
|
||||
linkquality: null,
|
||||
voltage: null,
|
||||
power_outage_count: null,
|
||||
@@ -2644,7 +2965,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",
|
||||
@@ -3011,7 +3332,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,
|
||||
@@ -3023,11 +3344,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();
|
||||
@@ -3039,6 +3364,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({
|
||||
action: "single",
|
||||
battery: null,
|
||||
identify: null,
|
||||
linkquality: null,
|
||||
voltage: null,
|
||||
power_outage_count: null,
|
||||
@@ -3049,6 +3375,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toStrictEqual({
|
||||
action: "",
|
||||
battery: null,
|
||||
identify: null,
|
||||
linkquality: null,
|
||||
voltage: null,
|
||||
power_outage_count: null,
|
||||
|
||||
@@ -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, effect_speed, effect_color, linkquality",
|
||||
"light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), power_on_behavior, effect, effect_speed, effect_color, identify, linkquality",
|
||||
vendor: "Philips",
|
||||
},
|
||||
failed: [],
|
||||
@@ -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, effect_speed, effect_color, linkquality",
|
||||
"light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), power_on_behavior, effect, effect_speed, effect_color, identify, linkquality",
|
||||
vendor: "Philips",
|
||||
},
|
||||
failed: [],
|
||||
@@ -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, effect_speed, effect_color, linkquality",
|
||||
"light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), power_on_behavior, effect, effect_speed, effect_color, identify, linkquality",
|
||||
vendor: "Philips",
|
||||
},
|
||||
failed: [],
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -190,6 +190,30 @@ describe("Extension: Receive", () => {
|
||||
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual("zigbee2mqtt/bridge/health");
|
||||
});
|
||||
|
||||
it("Should not bypass the debounce when a message produces no payload", async () => {
|
||||
const device = devices.WSDCGQ11LM;
|
||||
settings.set(["devices", device.ieeeAddr, "debounce"], 0.1);
|
||||
settings.set(["advanced", "last_seen"], "ISO_8601");
|
||||
// Attribute report without measuredValue: the lumi_temperature converter returns nothing.
|
||||
const payload = {
|
||||
data: {},
|
||||
cluster: "msTemperatureMeasurement",
|
||||
device,
|
||||
endpoint: device.getEndpoint(1),
|
||||
type: "attributeReport",
|
||||
linkquality: 10,
|
||||
};
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
// The empty payload must not be published immediately (bypassing the debounce).
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
|
||||
vi.runOnlyPendingTimers();
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/weather_sensor");
|
||||
});
|
||||
|
||||
it("Should debounce and retain messages when set via device_options", async () => {
|
||||
const device = devices.WSDCGQ11LM;
|
||||
settings.set(["device_options", "debounce"], 0.1);
|
||||
|
||||
+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;
|
||||
|
||||
@@ -34,6 +34,7 @@ const CLUSTERS = {
|
||||
lightingColorCtrl: Zcl.Clusters.lightingColorCtrl.ID,
|
||||
closuresWindowCovering: Zcl.Clusters.closuresWindowCovering.ID,
|
||||
hvacThermostat: Zcl.Clusters.hvacThermostat.ID,
|
||||
hvacFanCtrl: Zcl.Clusters.hvacFanCtrl.ID,
|
||||
msIlluminanceMeasurement: Zcl.Clusters.msIlluminanceMeasurement.ID,
|
||||
msTemperatureMeasurement: Zcl.Clusters.msTemperatureMeasurement.ID,
|
||||
msRelativeHumidity: Zcl.Clusters.msRelativeHumidity.ID,
|
||||
@@ -283,6 +284,7 @@ export class Device {
|
||||
unscheduleOta = vi.fn(() => {
|
||||
this.scheduledOta = undefined;
|
||||
});
|
||||
abortOta = vi.fn();
|
||||
scheduledOta: OtaSource | undefined = undefined;
|
||||
|
||||
constructor(
|
||||
@@ -345,6 +347,7 @@ export class Device {
|
||||
this.updateOta.mockClear();
|
||||
this.scheduleOta.mockClear();
|
||||
this.unscheduleOta.mockClear();
|
||||
this.abortOta.mockClear();
|
||||
this.meta = {};
|
||||
this.scheduledOta = undefined;
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import {describe, expect, it} from "vitest";
|
||||
import {objectAssignDeep} from "../lib/util/objectAssignDeep";
|
||||
|
||||
/** Creates an object with a real own `__proto__`/`constructor`/`prototype` property, like a parsed YAML/JSON payload can. */
|
||||
const parse = (json: string): Record<string, unknown> => JSON.parse(json);
|
||||
|
||||
describe("objectAssignDeep", () => {
|
||||
it("Mutates and returns the target", () => {
|
||||
const target = {a: 1};
|
||||
const result = objectAssignDeep(target, {b: 2});
|
||||
|
||||
expect(result).toBe(target);
|
||||
expect(result).toStrictEqual({a: 1, b: 2});
|
||||
});
|
||||
|
||||
it("Applies sources in order, later ones win", () => {
|
||||
expect(objectAssignDeep({}, {a: 1, b: 1}, {b: 2, c: 2})).toStrictEqual({a: 1, b: 2, c: 2});
|
||||
});
|
||||
|
||||
it("Copies keys missing from the target", () => {
|
||||
expect(objectAssignDeep({}, {nested: {deep: {value: 1}}})).toStrictEqual({nested: {deep: {value: 1}}});
|
||||
});
|
||||
|
||||
it("Deep merges nested objects present in both", () => {
|
||||
const target = {mqtt: {base_topic: "zigbee2mqtt", server: "old"}, advanced: {channel: 11}};
|
||||
const result = objectAssignDeep(target, {mqtt: {server: "new"}});
|
||||
|
||||
expect(result).toStrictEqual({mqtt: {base_topic: "zigbee2mqtt", server: "new"}, advanced: {channel: 11}});
|
||||
});
|
||||
|
||||
it("Replaces nested objects of the target instead of mutating them", () => {
|
||||
const nested = {a: 1};
|
||||
const target = {nested};
|
||||
|
||||
objectAssignDeep(target, {nested: {b: 2}});
|
||||
|
||||
expect(nested).toStrictEqual({a: 1});
|
||||
expect(target.nested).not.toBe(nested);
|
||||
expect(target.nested).toStrictEqual({a: 1, b: 2});
|
||||
});
|
||||
|
||||
it("Replaces an existing non-object value with a clone of the source object", () => {
|
||||
expect(objectAssignDeep({a: 5}, {a: {b: 1}})).toStrictEqual({a: {b: 1}});
|
||||
expect(objectAssignDeep({a: "str"}, {a: {b: 1}})).toStrictEqual({a: {b: 1}});
|
||||
expect(objectAssignDeep({a: [1, 2]}, {a: {b: 1}})).toStrictEqual({a: {b: 1}});
|
||||
// `null` is not `undefined`, so it takes the "existing value" path but is not merged into
|
||||
expect(objectAssignDeep({a: null}, {a: {b: 1}})).toStrictEqual({a: {b: 1}});
|
||||
});
|
||||
|
||||
it("Overwrites with null and undefined", () => {
|
||||
expect(objectAssignDeep({a: {b: 1}, c: 1}, {a: null, c: null})).toStrictEqual({a: null, c: null});
|
||||
expect(objectAssignDeep({a: {b: 1}, c: 1}, {a: undefined, c: undefined})).toStrictEqual({a: undefined, c: undefined});
|
||||
});
|
||||
|
||||
it("Replaces arrays instead of concatenating them", () => {
|
||||
expect(objectAssignDeep({a: [1, 2, 3]}, {a: [4]})).toStrictEqual({a: [4]});
|
||||
expect(objectAssignDeep({a: [1, 2, 3]}, {a: []})).toStrictEqual({a: []});
|
||||
// no existing array either
|
||||
expect(objectAssignDeep({a: 1}, {a: [4]})).toStrictEqual({a: [4]});
|
||||
expect(objectAssignDeep({}, {a: [4]})).toStrictEqual({a: [4]});
|
||||
});
|
||||
|
||||
it("Clones arrays and the objects nested inside them", () => {
|
||||
const source = {a: [{b: 1}, [{c: 2}]]};
|
||||
const result = objectAssignDeep({}, source) as typeof source;
|
||||
|
||||
expect(result).toStrictEqual(source);
|
||||
expect(result.a).not.toBe(source.a);
|
||||
expect(result.a[0]).not.toBe(source.a[0]);
|
||||
expect((result.a[1] as {c: number}[])[0]).not.toBe((source.a[1] as {c: number}[])[0]);
|
||||
});
|
||||
|
||||
it("Breaks all references to the sources", () => {
|
||||
const source = {a: {b: {c: 1}}};
|
||||
const result = objectAssignDeep({}, source) as typeof source;
|
||||
|
||||
source.a.b.c = 99;
|
||||
|
||||
expect(result.a.b.c).toStrictEqual(1);
|
||||
});
|
||||
|
||||
it("Does not mutate the sources", () => {
|
||||
const source = {a: {b: 1}};
|
||||
|
||||
objectAssignDeep({a: {c: 2}}, source);
|
||||
|
||||
expect(source).toStrictEqual({a: {b: 1}});
|
||||
});
|
||||
|
||||
it("Copies functions and primitives by value/reference", () => {
|
||||
const fn = (): number => 1;
|
||||
const symbol = Symbol("s");
|
||||
const result = objectAssignDeep({}, {fn, symbol, big: 1n, nan: Number.NaN});
|
||||
|
||||
expect(result.fn).toBe(fn);
|
||||
expect(result.symbol).toBe(symbol);
|
||||
expect(result.big).toStrictEqual(1n);
|
||||
expect(result.nan).toBeNaN();
|
||||
});
|
||||
|
||||
it("Reduces non-plain objects to their own enumerable properties", () => {
|
||||
// documented (inherited) behaviour: only own enumerable properties survive, the prototype is lost
|
||||
expect(objectAssignDeep({}, {date: new Date(0)})).toStrictEqual({date: {}});
|
||||
expect(objectAssignDeep({}, {regexp: /abc/g})).toStrictEqual({regexp: {}});
|
||||
expect(objectAssignDeep({}, {map: new Map([["k", 1]])})).toStrictEqual({map: {}});
|
||||
expect(objectAssignDeep({}, {set: new Set([1])})).toStrictEqual({set: {}});
|
||||
|
||||
class Device {
|
||||
id = 1;
|
||||
get computed(): number {
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
const result = objectAssignDeep({}, {device: new Device()});
|
||||
|
||||
expect(result.device).toStrictEqual({id: 1});
|
||||
expect(result.device).not.toBeInstanceOf(Device);
|
||||
});
|
||||
|
||||
it("Merges deeply nested objects coming from multiple sources", () => {
|
||||
const result = objectAssignDeep({}, {a: {b: {c: 1}}}, {a: {b: {d: 2}, e: 3}});
|
||||
|
||||
expect(result).toStrictEqual({a: {b: {c: 1, d: 2}, e: 3}});
|
||||
});
|
||||
|
||||
it("Never copies keys that could tamper with the prototype chain", () => {
|
||||
const result = objectAssignDeep({}, parse('{"__proto__": {"polluted": "yes"}, "constructor": {"x": 1}, "prototype": {"y": 2}, "safe": 1}'));
|
||||
|
||||
expect(result).toStrictEqual({safe: 1});
|
||||
expect(Object.getPrototypeOf(result)).toBe(Object.prototype);
|
||||
expect(({} as {polluted?: string}).polluted).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Never copies unsafe keys nested inside cloned objects", () => {
|
||||
const result = objectAssignDeep({}, {nested: parse('{"__proto__": {"polluted": "yes"}, "constructor": 1, "prototype": 2, "safe": 1}')});
|
||||
|
||||
expect(result).toStrictEqual({nested: {safe: 1}});
|
||||
expect(Object.getPrototypeOf(result.nested)).toBe(Object.prototype);
|
||||
});
|
||||
|
||||
it("Never copies unsafe keys when merging into an existing object", () => {
|
||||
const result = objectAssignDeep({nested: {safe: 1}}, {nested: parse('{"__proto__": {"polluted": "yes"}, "other": 2}')});
|
||||
|
||||
expect(result).toStrictEqual({nested: {safe: 1, other: 2}});
|
||||
expect(Object.getPrototypeOf(result.nested)).toBe(Object.prototype);
|
||||
});
|
||||
|
||||
it("Leaves every source untouched when given an empty target", () => {
|
||||
const first = {a: {b: 1}};
|
||||
const second = {a: {c: 2}};
|
||||
const result = objectAssignDeep({}, first, second);
|
||||
|
||||
expect(result).toStrictEqual({a: {b: 1, c: 2}});
|
||||
expect(result).not.toBe(first);
|
||||
expect(result).not.toBe(second);
|
||||
expect(result.a).not.toBe(first.a);
|
||||
expect(first).toStrictEqual({a: {b: 1}});
|
||||
expect(second).toStrictEqual({a: {c: 2}});
|
||||
});
|
||||
});
|
||||
+113
-203
@@ -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";
|
||||
@@ -31,16 +31,10 @@ const mockHttpClose = vi.fn<Server["close"]>(
|
||||
},
|
||||
);
|
||||
const mockFindAllDevices = vi.fn<typeof findAllDevices>(async () => []);
|
||||
const mockStaticFileServer = vi.fn((_req, res, next) => {
|
||||
if (typeof next === "function") {
|
||||
next();
|
||||
}
|
||||
|
||||
const mockStaticFileServer = vi.fn((_req, res) => {
|
||||
res.end();
|
||||
});
|
||||
const mockExpressStaticGzip = vi.fn((_path: unknown, _options: unknown) => mockStaticFileServer);
|
||||
const mockFinalHandlerNext = vi.fn();
|
||||
const mockFinalhandler = vi.fn((_req: unknown, _res: unknown) => mockFinalHandlerNext);
|
||||
const mockCreateStaticFileServer = vi.fn((_dir: unknown, _logError: unknown) => mockStaticFileServer);
|
||||
|
||||
vi.mock("node:fs", {spy: true});
|
||||
vi.mock("node:http", () => ({
|
||||
@@ -62,11 +56,8 @@ vi.mock("node:http", () => ({
|
||||
};
|
||||
}),
|
||||
}));
|
||||
vi.mock("express-static-gzip", () => ({
|
||||
default: vi.fn((path, options) => mockExpressStaticGzip(path, options)),
|
||||
}));
|
||||
vi.mock("finalhandler", () => ({
|
||||
default: vi.fn((req, res) => mockFinalhandler(req, res)),
|
||||
vi.mock("../lib/util/staticFileServer", () => ({
|
||||
createStaticFileServer: vi.fn((dir, logError) => mockCreateStaticFileServer(dir, logError)),
|
||||
}));
|
||||
vi.mock("zigbee-herdsman/dist/adapter/adapterDiscovery", () => ({
|
||||
findAllDevices: vi.fn(() => mockFindAllDevices()),
|
||||
@@ -90,6 +81,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 +139,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,
|
||||
@@ -192,10 +185,7 @@ describe("Onboarding", () => {
|
||||
mockFindAllDevices.mockClear();
|
||||
mockHttpErrorListener = undefined;
|
||||
mockStaticFileServer.mockClear();
|
||||
mockExpressStaticGzip.mockClear();
|
||||
mockFinalHandlerNext.mockClear();
|
||||
mockFinalhandler.mockClear();
|
||||
mockStaticFileServer.mockClear();
|
||||
mockCreateStaticFileServer.mockClear();
|
||||
settings.reRead();
|
||||
});
|
||||
|
||||
@@ -538,205 +528,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 +682,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 () => {
|
||||
@@ -812,7 +723,6 @@ describe("Onboarding", () => {
|
||||
});
|
||||
|
||||
await expect(p).resolves.toStrictEqual(true);
|
||||
expect(mockFinalhandler).toHaveBeenCalled();
|
||||
expect(mockStaticFileServer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -834,7 +744,6 @@ describe("Onboarding", () => {
|
||||
});
|
||||
|
||||
await expect(p).resolves.toStrictEqual(false);
|
||||
expect(mockFinalhandler).toHaveBeenCalled();
|
||||
expect(mockStaticFileServer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -963,6 +872,7 @@ describe("Onboarding", () => {
|
||||
network_key: "GENERATE",
|
||||
pan_id: "GENERATE",
|
||||
ext_pan_id: "GENERATE",
|
||||
enable_external_js: false,
|
||||
},
|
||||
serial: {
|
||||
port: SAMPLE_SETTINGS_SAVE.serial.port,
|
||||
|
||||
+13
-14
@@ -3,12 +3,16 @@ import {beforeEach, describe, expect, it} from "vitest";
|
||||
import "./mocks/data";
|
||||
|
||||
import fs from "node:fs";
|
||||
import yaml from "js-yaml";
|
||||
import objectAssignDeep from "object-assign-deep";
|
||||
import {dump, load} from "js-yaml";
|
||||
|
||||
import mockedData from "../lib/util/data";
|
||||
import {objectAssignDeep} from "../lib/util/objectAssignDeep";
|
||||
import * as settings from "../lib/util/settings";
|
||||
|
||||
// mirrors the global `KeyValue`, which is not visible from the test project, previously implied by the untyped `object-assign-deep`
|
||||
// biome-ignore lint/suspicious/noExplicitAny: freely mutated to build the expected settings
|
||||
type ExpectedSettings = Record<string, any>;
|
||||
|
||||
const configurationFile = mockedData.joinPath("configuration.yaml");
|
||||
const devicesFile = mockedData.joinPath("devices.yaml");
|
||||
const devicesFile2 = mockedData.joinPath("devices2.yaml");
|
||||
@@ -21,14 +25,14 @@ const minimalConfig = {
|
||||
|
||||
describe("Settings", () => {
|
||||
const write = (file: string, json: Record<string, unknown>, reread = true): void => {
|
||||
fs.writeFileSync(file, yaml.dump(json));
|
||||
fs.writeFileSync(file, dump(json));
|
||||
|
||||
if (reread) {
|
||||
settings.reRead();
|
||||
}
|
||||
};
|
||||
|
||||
const read = (file: string): unknown => yaml.load(fs.readFileSync(file, "utf8"));
|
||||
const read = (file: string): unknown => load(fs.readFileSync(file, "utf8"));
|
||||
|
||||
const remove = (file: string): void => {
|
||||
if (fs.existsSync(file)) {
|
||||
@@ -86,8 +90,7 @@ describe("Settings", () => {
|
||||
it("Should return default settings", () => {
|
||||
write(configurationFile, {});
|
||||
const s = settings.get();
|
||||
// @ts-expect-error workaround
|
||||
const expected = objectAssignDeep.noMutate({}, settings.testing.defaults);
|
||||
const expected: ExpectedSettings = objectAssignDeep({}, settings.testing.defaults);
|
||||
expected.devices = {};
|
||||
expected.groups = {};
|
||||
expect(s).toStrictEqual(expected);
|
||||
@@ -96,8 +99,7 @@ describe("Settings", () => {
|
||||
it("Should return settings", () => {
|
||||
write(configurationFile, {serial: {disable_led: true}});
|
||||
const s = settings.get();
|
||||
// @ts-expect-error workaround
|
||||
const expected = objectAssignDeep.noMutate({}, settings.testing.defaults);
|
||||
const expected: ExpectedSettings = objectAssignDeep({}, settings.testing.defaults);
|
||||
expected.devices = {};
|
||||
expected.groups = {};
|
||||
expected.serial = {disable_led: true};
|
||||
@@ -124,8 +126,7 @@ describe("Settings", () => {
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-expect-error workaround
|
||||
const expected = objectAssignDeep.noMutate({}, settings.testing.defaults);
|
||||
const expected: ExpectedSettings = objectAssignDeep({}, settings.testing.defaults);
|
||||
expected.devices = {
|
||||
"0x00158d00018255df": {
|
||||
friendly_name: "0x00158d00018255df",
|
||||
@@ -178,8 +179,7 @@ describe("Settings", () => {
|
||||
|
||||
write(configurationFile, {});
|
||||
|
||||
// @ts-expect-error workaround
|
||||
const expected = objectAssignDeep.noMutate({}, settings.testing.defaults);
|
||||
const expected: ExpectedSettings = objectAssignDeep({}, settings.testing.defaults);
|
||||
expected.frontend.enabled = true;
|
||||
expected.frontend.port = 8099;
|
||||
expected.homeassistant.enabled = true;
|
||||
@@ -212,8 +212,7 @@ describe("Settings", () => {
|
||||
expect(settings.validate()).toStrictEqual([]);
|
||||
|
||||
const s = settings.get();
|
||||
// @ts-expect-error workaround
|
||||
const expected = objectAssignDeep.noMutate({groups: {}, devices: {}}, settings.testing.defaults);
|
||||
const expected: ExpectedSettings = objectAssignDeep({}, {groups: {}, devices: {}}, settings.testing.defaults);
|
||||
expected.mqtt.password = "password-in-env-var";
|
||||
expected.mqtt.server = "server";
|
||||
expect(s).toStrictEqual(expected);
|
||||
|
||||
@@ -3,8 +3,8 @@ import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} fr
|
||||
import * as data from "./mocks/data";
|
||||
|
||||
import {existsSync, readFileSync, rmSync, writeFileSync} from "node:fs";
|
||||
import objectAssignDeep from "object-assign-deep";
|
||||
import mockedData from "../lib/util/data";
|
||||
import {objectAssignDeep} from "../lib/util/objectAssignDeep";
|
||||
import * as settings from "../lib/util/settings";
|
||||
import * as settingsMigration from "../lib/util/settingsMigration";
|
||||
import path from "node:path";
|
||||
@@ -278,8 +278,7 @@ describe("Settings Migration", () => {
|
||||
});
|
||||
|
||||
it("no change needed - only add version", () => {
|
||||
// @ts-expect-error workaround
|
||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
afterSettings.version = 2;
|
||||
|
||||
settingsMigration.migrateIfNecessary();
|
||||
@@ -290,10 +289,8 @@ describe("Settings Migration", () => {
|
||||
});
|
||||
|
||||
it("remove all", () => {
|
||||
// @ts-expect-error workaround
|
||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
// @ts-expect-error workaround
|
||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
afterSettings.version = 2;
|
||||
|
||||
settings.set(["homeassistant", "legacy_triggers"], true);
|
||||
@@ -320,8 +317,7 @@ describe("Settings Migration", () => {
|
||||
settings.set(["external_converters"], ["zyx.js"]);
|
||||
|
||||
expect(settings.getPersistedSettings()).toStrictEqual(
|
||||
// @ts-expect-error workaround
|
||||
objectAssignDeep.noMutate(beforeSettings, {
|
||||
objectAssignDeep({}, beforeSettings, {
|
||||
permit_join: true,
|
||||
homeassistant: {
|
||||
legacy_triggers: true,
|
||||
@@ -395,10 +391,8 @@ describe("Settings Migration", () => {
|
||||
});
|
||||
|
||||
it("remove partial", () => {
|
||||
// @ts-expect-error workaround
|
||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
// @ts-expect-error workaround
|
||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
afterSettings.version = 2;
|
||||
|
||||
settings.set(["advanced", "homeassistant_legacy_triggers"], true);
|
||||
@@ -417,8 +411,7 @@ describe("Settings Migration", () => {
|
||||
// console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2));
|
||||
|
||||
expect(settings.getPersistedSettings()).toStrictEqual(
|
||||
// @ts-expect-error workaround
|
||||
objectAssignDeep.noMutate(beforeSettings, {
|
||||
objectAssignDeep({}, beforeSettings, {
|
||||
permit_join: true,
|
||||
advanced: {
|
||||
homeassistant_legacy_triggers: true,
|
||||
@@ -472,10 +465,8 @@ describe("Settings Migration", () => {
|
||||
});
|
||||
|
||||
it("changes log_level", () => {
|
||||
// @ts-expect-error workaround
|
||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
// @ts-expect-error workaround
|
||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
afterSettings.version = 2;
|
||||
afterSettings.advanced = {log_level: "warning"};
|
||||
|
||||
@@ -484,8 +475,7 @@ describe("Settings Migration", () => {
|
||||
// console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2));
|
||||
|
||||
expect(settings.getPersistedSettings()).toStrictEqual(
|
||||
// @ts-expect-error workaround
|
||||
objectAssignDeep.noMutate(beforeSettings, {
|
||||
objectAssignDeep({}, beforeSettings, {
|
||||
advanced: {
|
||||
log_level: "warn",
|
||||
},
|
||||
@@ -505,10 +495,8 @@ describe("Settings Migration", () => {
|
||||
});
|
||||
|
||||
it("does not changes already migrated log_level", () => {
|
||||
// @ts-expect-error workaround
|
||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
// @ts-expect-error workaround
|
||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
afterSettings.version = 2;
|
||||
afterSettings.advanced = {log_level: "warning"};
|
||||
|
||||
@@ -517,8 +505,7 @@ describe("Settings Migration", () => {
|
||||
// console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2));
|
||||
|
||||
expect(settings.getPersistedSettings()).toStrictEqual(
|
||||
// @ts-expect-error workaround
|
||||
objectAssignDeep.noMutate(beforeSettings, {
|
||||
objectAssignDeep({}, beforeSettings, {
|
||||
advanced: {
|
||||
log_level: "warning",
|
||||
},
|
||||
@@ -538,10 +525,8 @@ describe("Settings Migration", () => {
|
||||
});
|
||||
|
||||
it("does not changes other log_level", () => {
|
||||
// @ts-expect-error workaround
|
||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
// @ts-expect-error workaround
|
||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
afterSettings.version = 2;
|
||||
afterSettings.advanced = {log_level: "info"};
|
||||
|
||||
@@ -550,8 +535,7 @@ describe("Settings Migration", () => {
|
||||
// console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2));
|
||||
|
||||
expect(settings.getPersistedSettings()).toStrictEqual(
|
||||
// @ts-expect-error workaround
|
||||
objectAssignDeep.noMutate(beforeSettings, {
|
||||
objectAssignDeep({}, beforeSettings, {
|
||||
advanced: {
|
||||
log_level: "info",
|
||||
},
|
||||
@@ -571,10 +555,8 @@ describe("Settings Migration", () => {
|
||||
});
|
||||
|
||||
it("transfer all", () => {
|
||||
// @ts-expect-error workaround
|
||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
// @ts-expect-error workaround
|
||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
afterSettings.version = 2;
|
||||
afterSettings.advanced = {
|
||||
transmit_power: 12,
|
||||
@@ -604,8 +586,7 @@ describe("Settings Migration", () => {
|
||||
// console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2));
|
||||
|
||||
expect(settings.getPersistedSettings()).toStrictEqual(
|
||||
// @ts-expect-error workaround
|
||||
objectAssignDeep.noMutate(beforeSettings, {
|
||||
objectAssignDeep({}, beforeSettings, {
|
||||
advanced: {
|
||||
homeassistant_discovery_topic: "ha_disc",
|
||||
homeassistant_status_topic: "ha_stat",
|
||||
@@ -649,10 +630,8 @@ describe("Settings Migration", () => {
|
||||
});
|
||||
|
||||
it("transfer partial", () => {
|
||||
// @ts-expect-error workaround
|
||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
// @ts-expect-error workaround
|
||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
afterSettings.version = 2;
|
||||
afterSettings.advanced = {}; // caused by pushing to key and removing all
|
||||
afterSettings.serial.baudrate = 115200;
|
||||
@@ -673,8 +652,7 @@ describe("Settings Migration", () => {
|
||||
// console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2));
|
||||
|
||||
expect(settings.getPersistedSettings()).toStrictEqual(
|
||||
// @ts-expect-error workaround
|
||||
objectAssignDeep.noMutate(beforeSettings, {
|
||||
objectAssignDeep({}, beforeSettings, {
|
||||
homeassistant: {discovery_topic: "ha_disc_newer"},
|
||||
advanced: {
|
||||
homeassistant_discovery_topic: "ha_disc",
|
||||
@@ -718,10 +696,8 @@ describe("Settings Migration", () => {
|
||||
});
|
||||
|
||||
it("Update", () => {
|
||||
// @ts-expect-error workaround
|
||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
// @ts-expect-error workaround
|
||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
afterSettings.version = 3;
|
||||
afterSettings.homeassistant = {enabled: false};
|
||||
afterSettings.frontend = {enabled: true};
|
||||
@@ -739,8 +715,7 @@ describe("Settings Migration", () => {
|
||||
settings.set(["experimental", "transmit_power"], 12);
|
||||
|
||||
expect(settings.getPersistedSettings()).toStrictEqual(
|
||||
// @ts-expect-error workaround
|
||||
objectAssignDeep.noMutate(beforeSettings, {
|
||||
objectAssignDeep({}, beforeSettings, {
|
||||
homeassistant: false,
|
||||
frontend: true,
|
||||
availability: {active: {timeout: 15}},
|
||||
@@ -773,10 +748,8 @@ describe("Settings Migration", () => {
|
||||
});
|
||||
|
||||
it("Update", () => {
|
||||
// @ts-expect-error workaround
|
||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
// @ts-expect-error workaround
|
||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
afterSettings.version = 3;
|
||||
afterSettings.homeassistant = {enabled: false};
|
||||
afterSettings.frontend = {enabled: true};
|
||||
@@ -787,8 +760,7 @@ describe("Settings Migration", () => {
|
||||
settings.set(["availability"], {active: {timeout: 15}});
|
||||
|
||||
expect(settings.getPersistedSettings()).toStrictEqual(
|
||||
// @ts-expect-error workaround
|
||||
objectAssignDeep.noMutate(beforeSettings, {
|
||||
objectAssignDeep({}, beforeSettings, {
|
||||
homeassistant: false,
|
||||
frontend: true,
|
||||
availability: {active: {timeout: 15}},
|
||||
@@ -809,18 +781,15 @@ describe("Settings Migration", () => {
|
||||
});
|
||||
|
||||
it("Update when not set, tests that frontend/availability is not added when not set", () => {
|
||||
// @ts-expect-error workaround
|
||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
// @ts-expect-error workaround
|
||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
afterSettings.version = 3;
|
||||
afterSettings.homeassistant = {enabled: false};
|
||||
|
||||
settings.set(["homeassistant"], false);
|
||||
|
||||
expect(settings.getPersistedSettings()).toStrictEqual(
|
||||
// @ts-expect-error workaround
|
||||
objectAssignDeep.noMutate(beforeSettings, {
|
||||
objectAssignDeep({}, beforeSettings, {
|
||||
homeassistant: false,
|
||||
}),
|
||||
);
|
||||
@@ -854,10 +823,8 @@ describe("Settings Migration", () => {
|
||||
});
|
||||
|
||||
it("Update", () => {
|
||||
// @ts-expect-error workaround
|
||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
// @ts-expect-error workaround
|
||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
afterSettings.version = 4;
|
||||
afterSettings.devices = {
|
||||
"0x123127fffe8d96bc": {
|
||||
@@ -888,8 +855,7 @@ describe("Settings Migration", () => {
|
||||
});
|
||||
|
||||
expect(settings.getPersistedSettings()).toStrictEqual(
|
||||
// @ts-expect-error workaround
|
||||
objectAssignDeep.noMutate(beforeSettings, {
|
||||
objectAssignDeep({}, beforeSettings, {
|
||||
devices: {
|
||||
"0x123127fffe8d96bc": {
|
||||
friendly_name: "0x847127fffe8d96bc",
|
||||
@@ -950,10 +916,8 @@ describe("Settings Migration", () => {
|
||||
});
|
||||
|
||||
it("Update", () => {
|
||||
// @ts-expect-error workaround
|
||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
// @ts-expect-error workaround
|
||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
afterSettings.version = 5;
|
||||
|
||||
expect(settings.getPersistedSettings()).toStrictEqual(beforeSettings);
|
||||
@@ -964,8 +928,7 @@ describe("Settings Migration", () => {
|
||||
const migratedSettings = settings.getPersistedSettings();
|
||||
expect(migratedSettings).toStrictEqual(afterSettings);
|
||||
|
||||
// @ts-expect-error workaround
|
||||
const migratedState = objectAssignDeep.noMutate({}, DEFAULT_STATE);
|
||||
const migratedState = objectAssignDeep({}, DEFAULT_STATE);
|
||||
delete (migratedState["0x0017880104e45517"] as Record<string, unknown>).update;
|
||||
delete (migratedState[1] as Record<string, unknown>).update;
|
||||
|
||||
@@ -976,10 +939,8 @@ describe("Settings Migration", () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error");
|
||||
writeFileSync(path.join(data.mockDir, "state.json"), "notjson", "utf8");
|
||||
|
||||
// @ts-expect-error workaround
|
||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
// @ts-expect-error workaround
|
||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
||||
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||
afterSettings.version = 5;
|
||||
|
||||
expect(settings.getPersistedSettings()).toStrictEqual(beforeSettings);
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import {mkdirSync, writeFileSync} from "node:fs";
|
||||
import {createServer, type Server} from "node:http";
|
||||
import {type AddressInfo, connect} from "node:net";
|
||||
import {join} from "node:path";
|
||||
import {brotliCompressSync, gzipSync} from "node:zlib";
|
||||
import tmp from "tmp";
|
||||
import {afterAll, beforeAll, describe, expect, it, vi} from "vitest";
|
||||
import {createStaticFileServer, type sendNotFound} from "../lib/util/staticFileServer";
|
||||
|
||||
const INDEX_HTML = "<!DOCTYPE html><html lang='en'><body>index</body></html>";
|
||||
const APP_JS = `console.log("${"x".repeat(2048)}");`;
|
||||
/** Written next to the served directory, never inside it, so a traversal that succeeds is actually observable. */
|
||||
const SECRET = "topsecret-must-never-be-served";
|
||||
|
||||
const mockLogError = vi.fn<(message: string) => void>();
|
||||
|
||||
let dir: string;
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
|
||||
/** Starts a `node:http` server serving `dir`, mirroring how the frontend/onboarding extensions wire it up. */
|
||||
function listen(handler: (request: Parameters<typeof sendNotFound>[0], response: Parameters<typeof sendNotFound>[1]) => void): Promise<void> {
|
||||
server = createServer(handler);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Writes a request line verbatim, bypassing the path normalization `fetch` applies before sending. */
|
||||
function rawRequest(target: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = connect((server.address() as AddressInfo).port, "127.0.0.1", () => {
|
||||
socket.write(`GET ${target} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n`);
|
||||
});
|
||||
let received = "";
|
||||
|
||||
socket.setEncoding("utf8");
|
||||
socket.on("data", (chunk) => {
|
||||
received += chunk;
|
||||
});
|
||||
socket.on("end", () => resolve(received));
|
||||
socket.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
describe("StaticFileServer", () => {
|
||||
beforeAll(async () => {
|
||||
const root = tmp.dirSync().name;
|
||||
|
||||
dir = join(root, "public");
|
||||
|
||||
// outside the served directory: only a working traversal could reach it
|
||||
writeFileSync(join(root, "secret.txt"), SECRET);
|
||||
mkdirSync(join(dir, "sub"), {recursive: true});
|
||||
writeFileSync(join(dir, "index.html"), INDEX_HTML);
|
||||
writeFileSync(join(dir, "app.js"), APP_JS);
|
||||
// precompressed variants, as shipped by the frontend packages
|
||||
writeFileSync(join(dir, "app.js.gz"), gzipSync(APP_JS));
|
||||
writeFileSync(join(dir, "app.js.br"), brotliCompressSync(APP_JS));
|
||||
writeFileSync(join(dir, "sub", "icon.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||||
|
||||
await listen(createStaticFileServer(dir, mockLogError));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
|
||||
it("serves a file with its content type", async () => {
|
||||
const response = await fetch(`${baseUrl}/sub/icon.png`);
|
||||
|
||||
expect(response.status).toStrictEqual(200);
|
||||
expect(response.headers.get("content-type")).toStrictEqual("image/png");
|
||||
expect(response.headers.get("content-encoding")).toBeNull();
|
||||
});
|
||||
|
||||
it("serves index.html for the root, never cached", async () => {
|
||||
const response = await fetch(`${baseUrl}/`);
|
||||
|
||||
expect(response.status).toStrictEqual(200);
|
||||
expect(response.headers.get("content-type")).toStrictEqual("text/html; charset=utf-8");
|
||||
expect(response.headers.get("cache-control")).toStrictEqual("no-store");
|
||||
await expect(response.text()).resolves.toStrictEqual(INDEX_HTML);
|
||||
});
|
||||
|
||||
it("serves the precompressed brotli variant", async () => {
|
||||
const response = await fetch(`${baseUrl}/app.js`, {headers: {"Accept-Encoding": "br"}});
|
||||
|
||||
expect(response.status).toStrictEqual(200);
|
||||
expect(response.headers.get("content-encoding")).toStrictEqual("br");
|
||||
expect(response.headers.get("content-type")).toStrictEqual("text/javascript; charset=utf-8");
|
||||
expect(response.headers.get("vary")).toStrictEqual("Accept-Encoding");
|
||||
// decoded by fetch, so the served bytes must be the brotli variant of the original file
|
||||
await expect(response.text()).resolves.toStrictEqual(APP_JS);
|
||||
});
|
||||
|
||||
it("serves the precompressed gzip variant", async () => {
|
||||
const response = await fetch(`${baseUrl}/app.js`, {headers: {"Accept-Encoding": "gzip"}});
|
||||
|
||||
expect(response.status).toStrictEqual(200);
|
||||
expect(response.headers.get("content-encoding")).toStrictEqual("gzip");
|
||||
await expect(response.text()).resolves.toStrictEqual(APP_JS);
|
||||
});
|
||||
|
||||
it("serves the identity file when no encoding is accepted", async () => {
|
||||
const response = await fetch(`${baseUrl}/app.js`, {headers: {"Accept-Encoding": "identity"}});
|
||||
|
||||
expect(response.status).toStrictEqual(200);
|
||||
expect(response.headers.get("content-encoding")).toBeNull();
|
||||
expect(response.headers.get("content-length")).toStrictEqual(String(Buffer.byteLength(APP_JS)));
|
||||
await expect(response.text()).resolves.toStrictEqual(APP_JS);
|
||||
});
|
||||
|
||||
it("revalidates with an etag", async () => {
|
||||
const response = await fetch(`${baseUrl}/app.js`, {headers: {"Accept-Encoding": "identity"}});
|
||||
const etag = response.headers.get("etag");
|
||||
|
||||
expect(etag).toBeTruthy();
|
||||
|
||||
const revalidated = await fetch(`${baseUrl}/app.js`, {headers: {"Accept-Encoding": "identity", "If-None-Match": etag as string}});
|
||||
|
||||
expect(revalidated.status).toStrictEqual(304);
|
||||
});
|
||||
|
||||
it("returns 404 for an unknown file", async () => {
|
||||
const response = await fetch(`${baseUrl}/nope.js`);
|
||||
|
||||
expect(response.status).toStrictEqual(404);
|
||||
expect(response.headers.get("content-type")).toStrictEqual("text/html; charset=utf-8");
|
||||
expect(response.headers.get("content-security-policy")).toStrictEqual("default-src 'none'");
|
||||
expect(response.headers.get("x-content-type-options")).toStrictEqual("nosniff");
|
||||
await expect(response.text()).resolves.toContain("Cannot GET /nope.js");
|
||||
});
|
||||
|
||||
it("escapes the url in the 404 body", async () => {
|
||||
const response = await fetch(`${baseUrl}/%3Cscript%3E`);
|
||||
|
||||
expect(response.status).toStrictEqual(404);
|
||||
await expect(response.text()).resolves.not.toContain("<script>");
|
||||
});
|
||||
|
||||
it("does not serve files outside of the served directory", async () => {
|
||||
// `fetch` resolves `..` and `%2e%2e` segments away before they ever reach the server, so these have to go out raw
|
||||
for (const target of ["/../secret.txt", "/sub/../../secret.txt", "/%2e%2e/secret.txt", "/..%2fsecret.txt"]) {
|
||||
const response = await rawRequest(target);
|
||||
|
||||
expect(response).toContain("404 Not Found");
|
||||
expect(response).not.toContain(SECRET);
|
||||
}
|
||||
});
|
||||
|
||||
it("reports a failure to serve with a 500", async () => {
|
||||
const failing = createStaticFileServer(dir, mockLogError);
|
||||
const failingServer = createServer((request, response) => {
|
||||
const setHeader = response.setHeader.bind(response);
|
||||
|
||||
response.setHeader = (name: string, value: number | string | readonly string[]): never => {
|
||||
if (name === "Content-Security-Policy") {
|
||||
throw new Error("socket gone");
|
||||
}
|
||||
|
||||
setHeader(name, value);
|
||||
|
||||
return undefined as never;
|
||||
};
|
||||
|
||||
failing(request, response);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => failingServer.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
const port = (failingServer.address() as AddressInfo).port;
|
||||
const response = await fetch(`http://127.0.0.1:${port}/nope.js`);
|
||||
|
||||
expect(response.status).toStrictEqual(500);
|
||||
expect(mockLogError).toHaveBeenCalledWith("Failed to serve '/nope.js': socket gone");
|
||||
|
||||
await new Promise((resolve) => failingServer.close(resolve));
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import {exec} from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {describe, expect, it, vi} from "vitest";
|
||||
import {stringify} from "../lib/util/stringify";
|
||||
import utils, {assertString} from "../lib/util/utils";
|
||||
|
||||
// keep the implementations, just spy
|
||||
@@ -155,4 +156,47 @@ describe("Utils", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("stable stringify", () => {
|
||||
expect(
|
||||
stringify({
|
||||
a: "a",
|
||||
b: 2,
|
||||
3: "c",
|
||||
d: Buffer.from([1, 2]),
|
||||
e: new Int16Array([0xfffd, 0xff11, 0x0001, 0x7fff]),
|
||||
beef: 0xfacen,
|
||||
zed: new BigUint64Array([1n, 0xffffffffn, 42n]),
|
||||
ris: [1, undefined, "b", 0xfeefn, Number.NaN, undefined],
|
||||
ls: undefined,
|
||||
// one and two elements, on both the number and the bigint branch
|
||||
one: new Uint8Array([7]),
|
||||
two: new Int16Array([0x0001, 0x7fff]),
|
||||
oneBig: new BigInt64Array([-9n]),
|
||||
twoBig: new BigUint64Array([1n, 42n]),
|
||||
}),
|
||||
).toStrictEqual(
|
||||
`{"3":"c","a":"a","b":2,"beef":"64206","d":{"data":[1,2],"type":"Buffer"},"e":{"0":-3,"1":-239,"2":1,"3":32767},"one":{"0":7},"oneBig":{"0":"-9"},"ris":[1,null,"b","65263",null,null],"two":{"0":1,"1":32767},"twoBig":{"0":"1","1":"42"},"zed":{"0":"1","1":"4294967295","2":"42"}}`,
|
||||
);
|
||||
// @ts-expect-error intentional to reach code for coverage
|
||||
expect(stringify(undefined)).toStrictEqual("null");
|
||||
|
||||
const circularObj: Record<string, unknown> = {a: 1, b: undefined};
|
||||
circularObj.b = circularObj;
|
||||
|
||||
expect(stringify(circularObj)).toStrictEqual(`{"a":1,"b":"[Circular]"}`);
|
||||
|
||||
const toJSONIsString = {a: 1, toJSON: () => `{"a":1}`};
|
||||
|
||||
expect(stringify(toJSONIsString)).toStrictEqual(`"{\\"a\\":1}"`);
|
||||
|
||||
const toJSONIsNull = {a: 1, toJSON: () => null};
|
||||
|
||||
expect(stringify(toJSONIsNull)).toStrictEqual("null");
|
||||
|
||||
const emptyTypedArrayWithProperty = Object.assign(new Uint8Array(0), {unit: "raw"});
|
||||
|
||||
expect(stringify({data: emptyTypedArrayWithProperty})).toStrictEqual(JSON.stringify({data: {unit: "raw"}}));
|
||||
expect(stringify({data: new Uint8Array(0)})).toStrictEqual(JSON.stringify({data: {}}));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user