Compare commits

...
68 Commits
Author SHA1 Message Date
Koen Kanters 01b25f15bf fix(ignore): update zigbee-herdsman-converters to 26.100.0 (#32960) 2026-08-28 19:12:35 +00:00
Andy b441091f6f fix: Warn when setting unsupported device option (#32928) 2026-08-28 20:12:23 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 9a6b25afa5 fix(ignore): bump the minor-patch group with 3 updates (#32949)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-28 19:48:10 +02:00
Koen Kanters 9473312667 fix(ignore): update zigbee-herdsman to 10.9.1 (#32959) 2026-08-28 17:46:29 +00:00
Michal Feix 22de3f10d8 Solved linting issues in PR from CI test reports. 2026-08-26 12:08:50 +02:00
Michal Feix c867ca5182 fix: Improve Home Assistant cover state discovery 2026-08-26 12:08:50 +02:00
Luan TaraschiandKoen Kanters 7fbbbc3b17 fix: Preserve duration in state (#32900)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2026-08-26 12:02:15 +02:00
venglinandClaude Opus 5 dccf0a98c6 fix: Clamp availability ping delay to the maximum supported timeout (#32937)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:00:09 +02:00
Koen Kanters cf11b1a188 fix(ignore): update zigbee-herdsman-converters to 26.99.0 (#32899) 2026-08-22 02:57:58 +00:00
Koen Kanters 75bb5eed00 fix(ignore): update zigbee-herdsman-converters to 26.98.0 (#32879) 2026-08-18 19:58:29 +00:00
Miguel CabralandKoen Kanters 95d072a788 feat: Allow to preserve device config when removing device (#32765)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2026-08-18 21:13:38 +02:00
AT0myks be1cbb5f3b fix: Home Assistant: fix motor_state not exposed for SONOFF MINI-ZBRBS (#32702) 2026-08-18 20:59:53 +02:00
Koen Kanters 07ae13ad2d fix(ignore): update zigbee-herdsman-converters to 26.97.0 (#32848) 2026-08-14 19:09:33 +00:00
burmistrzak c649d04b1c fix: Home Assistant: Improve various discovery mappings (#32842) 2026-08-14 19:14:31 +02:00
Koen Kanters d3ba21828c fix(ignore): update zigbee-herdsman-converters to 26.96.0 (#32841) 2026-08-13 19:11:09 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> d276190ab8 fix(ignore): bump the minor-patch group across 1 directory with 2 updates (#32827)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-12 21:51:16 +02:00
Koen Kanters e7deca42bf fix(ignore): update zigbee-herdsman-converters to 26.95.0 (#32829) 2026-08-12 19:44:01 +00:00
Koen Kanters 07a4a0bc75 fix(ignore): update zigbee-herdsman to 10.9.0 (#32828) 2026-08-12 19:37:54 +00:00
Koen Kanters 368e31b1e3 fix(ignore): update zigbee-herdsman-converters to 26.94.0 (#32808) 2026-08-10 19:36:01 +00:00
ChillZwixandChillZwix 10611654c7 fix: Apply debounce to messages without payload (#32791)
Co-authored-by: ChillZwix <ChillZwix@users.noreply.github.com>
2026-08-10 20:21:48 +02:00
Igor ClemencovandKoen Kanters f06673506f fix: Home Assistant: avoid duplicate door names (#32752)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2026-08-08 21:42:16 +02:00
Koen Kanters 06290da5be fix(ignore): update zigbee-herdsman-converters to 26.93.0 (#32790) 2026-08-08 21:37:21 +02:00
Luan Taraschi 86dfc90869 fix: Fix stringify don't emit invalid JSON for an empty typed array (#32780) 2026-08-08 21:29:08 +02:00
Luan Taraschi 60786528ae fix: Fix stringify emit every element of a typed array (#32779) 2026-08-08 21:28:21 +02:00
Luan Taraschi 3aaa8c1546 fix: Fix crash on a null color in attribute output (#32784) 2026-08-08 21:27:36 +02:00
Koen Kanters 046817624f fix(ignore): update zigbee-herdsman-converters to 26.92.0 (#32757) 2026-08-05 20:09:18 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 106a4bdef2 fix(ignore): bump the minor-patch group with 2 updates (#32755)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-05 21:34:31 +02:00
Koen Kanters a8171a5464 fix(ignore): update zigbee-herdsman-converters to 26.91.0 (#32746) 2026-08-03 19:15:56 +00:00
Koen Kanters 2f976b7dca fix(ignore): update zigbee-herdsman to 10.8.1 (#32745) 2026-08-03 21:10:16 +02:00
Alexander ChepurnoyandKoen Kanters 29b041ce57 fix: Replace express-static-gzip and finalhandler with srvx (#32685)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2026-08-03 21:05:35 +02:00
Alexander Chepurnoy 828038717f fix: Replace object-assign-deep with local utility (#32684) 2026-08-03 20:54:54 +02:00
github-actions[bot] 5fd4d6b37a chore: promote to dev 2026-08-01 06:04:30 +00:00
Koen Kanters fcbb7ff44b chore(dev): release 2.13.0 (#32446) 2026-08-01 07:50:18 +02:00
Koen Kanters e5e36ac79c fix(ignore): update zigbee-herdsman-converters to 26.90.0 (#32715) 2026-08-01 05:19:47 +00:00
Koen Kanters 28e410bbf4 fix(ignore): update zigbee-herdsman to 10.8.0 (#32701) 2026-07-30 18:57:05 +00:00
Koen Kanters b29fc0e493 fix(ignore): update zigbee-herdsman-converters to 26.89.0 (#32700) 2026-07-30 18:53:16 +00:00
Alexander Chepurnoy 316413b31c fix: Replace jszip with fflate (#32683) 2026-07-30 20:36:38 +02:00
dependabot[bot] a4862c9769 chore: bump actions/stale from 10 to 11 (#32691) 2026-07-30 00:56:42 +02:00
dependabot[bot] 3348cea190 fix(ignore): bump the minor-patch group with 3 updates (#32692) 2026-07-30 00:56:01 +02:00
Nerivec fe96204f0b fix: Publish groups on device leave (#32676) 2026-07-29 06:43:46 +02:00
Koen Kanters 7598b38ed8 chore: merge master to dev 2026-07-28 18:06:23 +02:00
Kobi Hikri 52d5316377 chore: compare the PR title from env instead of interpolating it into the shell (#32675) 2026-07-28 18:05:45 +02:00
Jan BouwhuisandKoen Kanters 32506b4e8a feat: Home Assistant: add discovery support for Tuya infrared receiver (learn mode) and emitter features (#32625)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2026-07-28 18:03:36 +02:00
Koen Kanters 3bec9e87b1 fix(ignore): update zigbee-herdsman-converters to 26.88.0 (#32674) 2026-07-28 16:00:03 +00:00
luar123 b46c77bff0 fix: Avoid running resolveDefinition in parallel (#32662) 2026-07-28 17:20:41 +02:00
Koen Kanters e6e0b6f8f2 fix(ignore): update zigbee-herdsman-converters to 26.87.0 (#32660) 2026-07-26 06:13:48 +00:00
Koenkk 756a824488 fix(ignore): update zigbee-herdsman-converters to 26.86.0 2026-07-24 21:42:03 +02:00
NerivecandKoen Kanters 83ab5228ff feat: Add clear_cache option to device remove request (#32631)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2026-07-24 21:40:04 +02:00
Nerivec 4f18b4e38c fix: Remove json-stable-stringify-without-jsonify dep (#32643) 2026-07-24 20:40:26 +02:00
Andy 5e56c454c3 fix(ignore): Remove attribute_and_json warnings for HA (#32616) 2026-07-23 00:32:56 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> bbad134e8e fix(ignore): bump ws from 8.21.0 to 8.21.1 in the minor-patch group across 1 directory (#32624)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-21 19:58:08 +00:00
Alexander ChepurnoyandKoen Kanters 2d941000a7 fix: Replace source-map-support with native Node source map support (#32620)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2026-07-21 21:32:55 +02:00
Koen Kanters 4ad1ab21e6 chore: merge master to dev 2026-07-21 21:24:03 +02:00
Koen Kanters 6835cbf5a7 chore: update dependabot config 2026-07-21 21:23:43 +02:00
Koen Kanters b8d388ca7a fix(ignore): update zigbee-herdsman to 10.6.3 (#32623) 2026-07-21 19:13:20 +00:00
Alexander Chepurnoy c9ddef703a fix: Drop redundant types js yaml (#32619) 2026-07-21 19:31:22 +02:00
Andy 4174e9cb78 fix: Fix restartRequired flag (#31947) 2026-07-21 19:25:32 +02:00
Koen Kanters 752ab37302 fix(ignore): update zigbee-herdsman-converters to 26.85.0 (#32612) 2026-07-20 15:24:58 +00:00
Andy d6dea17961 fix: Home Assistant: discover device trigger when mqtt output = attribute_and_json and add warnings about incompatible settings (#32603) 2026-07-20 17:08:06 +02:00
Koen Kanters e78b23f5eb fix(ignore): update zigbee-herdsman-converters to 26.84.0 (#32598) 2026-07-18 18:49:36 +00:00
Koen Kanters 22d85a6729 fix(ignore): update zigbee-herdsman-converters to 26.83.0 (#32581) 2026-07-16 17:42:08 +00:00
Alexander Chepurnoy a9ce4b2522 fix: replace rimraf with native fs.rmSync (#32579) 2026-07-16 19:06:28 +02:00
dependabot[bot] e889592baf fix(ignore): bump actions/setup-node from 6 to 7 (#32574) 2026-07-15 19:13:08 +02:00
Koen Kanters 576e1954ef fix(ignore): update zigbee-herdsman-converters to 26.82.0 (#32572) 2026-07-15 10:59:07 +00:00
Jan Bouwhuis 8940f6dabf chore: Add initial devcontainer support (#32555) 2026-07-12 19:08:06 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> fa12ebe27c fix(ignore): bump typescript from 6.0.3 to 7.0.2 (#32552)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-12 08:26:53 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 3f7c0ca715 fix(ignore): bump throttleit from 2.1.0 to 3.0.0 (#32551)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-12 08:26:37 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 66db1bdbcd fix(ignore): bump zigbee2mqtt-windfront from 2.12.1 to 2.13.0 in the minor-patch group (#32550)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-12 08:23:12 +02:00
72 changed files with 2017 additions and 1078 deletions
+23
View File
@@ -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"]
}
}
}
+6 -6
View File
@@ -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)`
+1 -1
View File
@@ -27,4 +27,4 @@ updates:
interval: weekly
target-branch: dev
commit-message:
prefix: fix(ignore)
prefix: chore
+2 -2
View File
@@ -33,7 +33,7 @@ jobs:
- 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/
@@ -224,7 +224,7 @@ jobs:
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node }}
cache: pnpm
+3 -1
View File
@@ -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"
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
with:
version: 9
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: 24
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
ref: dev
token: ${{ secrets.GH_TOKEN }}
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "2.12.1"
".": "2.13.0"
}
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["biomejs.biome", "vitest.explorer"]
}
+11
View File
@@ -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"
}
}
+4 -4
View File
@@ -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.
+53
View File
@@ -1,5 +1,58 @@
# Changelog
## [2.13.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.12.1...2.13.0) (2026-08-01)
### Features
* Add `clear_cache` option to device remove request ([#32631](https://github.com/Koenkk/zigbee2mqtt/issues/32631)) ([83ab522](https://github.com/Koenkk/zigbee2mqtt/commit/83ab5228ff24779773e67bfc217d89f7c7520daa))
* Home Assistant: add discovery support for Tuya infrared receiver (learn mode) and emitter features ([#32625](https://github.com/Koenkk/zigbee2mqtt/issues/32625)) ([32506b4](https://github.com/Koenkk/zigbee2mqtt/commit/32506b4e8abf81996966632719384f85bad7a66c))
### Bug Fixes
* `experimental_event_entities` and `legacy_action_sensor` require restart ([#32535](https://github.com/Koenkk/zigbee2mqtt/issues/32535)) ([2ca80b8](https://github.com/Koenkk/zigbee2mqtt/commit/2ca80b8572293a7c63c019c1b433e942772220c4))
* Avoid running resolveDefinition in parallel ([#32662](https://github.com/Koenkk/zigbee2mqtt/issues/32662)) ([b46c77b](https://github.com/Koenkk/zigbee2mqtt/commit/b46c77bff0612527bdfc7540ea31ec258bd002b1))
* Drop redundant types js yaml ([#32619](https://github.com/Koenkk/zigbee2mqtt/issues/32619)) ([c9ddef7](https://github.com/Koenkk/zigbee2mqtt/commit/c9ddef703a1f83a2dc5c84a1a1eaa087bee3f671))
* Fix restartRequired flag ([#31947](https://github.com/Koenkk/zigbee2mqtt/issues/31947)) ([4174e9c](https://github.com/Koenkk/zigbee2mqtt/commit/4174e9cb783b73527349182b35e06eda4512b101))
* Home Assistant: discover device trigger when mqtt output = attribute_and_json and add warnings about incompatible settings ([#32603](https://github.com/Koenkk/zigbee2mqtt/issues/32603)) ([d6dea17](https://github.com/Koenkk/zigbee2mqtt/commit/d6dea17961e05b8ca8ce05d7852efd483031260d))
* Home Assistant: fix action published to wrong topic ([#32544](https://github.com/Koenkk/zigbee2mqtt/issues/32544)) ([a80c2db](https://github.com/Koenkk/zigbee2mqtt/commit/a80c2db4c6038426fa5102477f6da34355f54134))
* **ignore:** bump @types/node from 24.13.2 to 26.1.0 ([#32453](https://github.com/Koenkk/zigbee2mqtt/issues/32453)) ([83ca274](https://github.com/Koenkk/zigbee2mqtt/commit/83ca2746a789bb92bfdb51506e875edfe5cb06b3))
* **ignore:** bump actions/setup-node from 6 to 7 ([#32574](https://github.com/Koenkk/zigbee2mqtt/issues/32574)) ([e889592](https://github.com/Koenkk/zigbee2mqtt/commit/e889592baf9b2738c7d2088247ea3f888cd08629))
* **ignore:** bump the minor-patch group with 2 updates ([#32504](https://github.com/Koenkk/zigbee2mqtt/issues/32504)) ([8f0d981](https://github.com/Koenkk/zigbee2mqtt/commit/8f0d9817652f97414d927e2d3e4f2b101e3d55ca))
* **ignore:** bump the minor-patch group with 2 updates ([#32514](https://github.com/Koenkk/zigbee2mqtt/issues/32514)) ([74478fd](https://github.com/Koenkk/zigbee2mqtt/commit/74478fd955dc434e039fac547bfe69db5a2f55ae))
* **ignore:** bump the minor-patch group with 3 updates ([#32692](https://github.com/Koenkk/zigbee2mqtt/issues/32692)) ([3348cea](https://github.com/Koenkk/zigbee2mqtt/commit/3348cea190125b9a466434cc5ea95ab7a857559d))
* **ignore:** bump the minor-patch group with 5 updates ([#32452](https://github.com/Koenkk/zigbee2mqtt/issues/32452)) ([e8ba0b2](https://github.com/Koenkk/zigbee2mqtt/commit/e8ba0b24f8b8528e98904234a32515713f457781))
* **ignore:** bump throttleit from 2.1.0 to 3.0.0 ([#32551](https://github.com/Koenkk/zigbee2mqtt/issues/32551)) ([3f7c0ca](https://github.com/Koenkk/zigbee2mqtt/commit/3f7c0ca71502a2a522a11587a878534f1fd42fe1))
* **ignore:** bump typescript from 6.0.3 to 7.0.2 ([#32552](https://github.com/Koenkk/zigbee2mqtt/issues/32552)) ([fa12ebe](https://github.com/Koenkk/zigbee2mqtt/commit/fa12ebe27c1598e7ac1108f3e3d7d3ffbe2eccab))
* **ignore:** bump ws from 8.21.0 to 8.21.1 in the minor-patch group across 1 directory ([#32624](https://github.com/Koenkk/zigbee2mqtt/issues/32624)) ([bbad134](https://github.com/Koenkk/zigbee2mqtt/commit/bbad134e8ea420200e6b5f75a17bd5a5a9d601fe))
* **ignore:** bump zigbee2mqtt-windfront from 2.12.1 to 2.13.0 in the minor-patch group ([#32550](https://github.com/Koenkk/zigbee2mqtt/issues/32550)) ([66db1bd](https://github.com/Koenkk/zigbee2mqtt/commit/66db1bdbcd8b6303c82d7c77679860537b0868e1))
* **ignore:** Remove attribute_and_json warnings for HA ([#32616](https://github.com/Koenkk/zigbee2mqtt/issues/32616)) ([5e56c45](https://github.com/Koenkk/zigbee2mqtt/commit/5e56c454c3fee03c43152c807ec3e449d6211728))
* **ignore:** update zigbee-herdsman to 10.6.2 ([#32500](https://github.com/Koenkk/zigbee2mqtt/issues/32500)) ([6e55716](https://github.com/Koenkk/zigbee2mqtt/commit/6e557162cbf548735c5d907231cec69fa94f6c53))
* **ignore:** update zigbee-herdsman to 10.6.3 ([#32623](https://github.com/Koenkk/zigbee2mqtt/issues/32623)) ([b8d388c](https://github.com/Koenkk/zigbee2mqtt/commit/b8d388ca7afce29cc6f09c452cbe5c01e79d8244))
* **ignore:** update zigbee-herdsman to 10.8.0 ([#32701](https://github.com/Koenkk/zigbee2mqtt/issues/32701)) ([28e410b](https://github.com/Koenkk/zigbee2mqtt/commit/28e410bbf442f32e9be4298d19fe45ac586b8a88))
* **ignore:** update zigbee-herdsman-converters to 26.77.0 ([#32462](https://github.com/Koenkk/zigbee2mqtt/issues/32462)) ([4b0c306](https://github.com/Koenkk/zigbee2mqtt/commit/4b0c3067ffeef42d66417445480048a8e546bc68))
* **ignore:** update zigbee-herdsman-converters to 26.78.0 ([#32489](https://github.com/Koenkk/zigbee2mqtt/issues/32489)) ([f88d992](https://github.com/Koenkk/zigbee2mqtt/commit/f88d99294b8dc42f8657673e80b7ed721220db40))
* **ignore:** update zigbee-herdsman-converters to 26.79.0 ([#32499](https://github.com/Koenkk/zigbee2mqtt/issues/32499)) ([a2973f2](https://github.com/Koenkk/zigbee2mqtt/commit/a2973f21f6cfebc175b6b1acfcb73ca7cab95c5f))
* **ignore:** update zigbee-herdsman-converters to 26.80.0 ([#32522](https://github.com/Koenkk/zigbee2mqtt/issues/32522)) ([912fe4c](https://github.com/Koenkk/zigbee2mqtt/commit/912fe4c250f514f6a0e3c97cf1438b15cef1ec6a))
* **ignore:** update zigbee-herdsman-converters to 26.81.0 ([#32543](https://github.com/Koenkk/zigbee2mqtt/issues/32543)) ([e475de1](https://github.com/Koenkk/zigbee2mqtt/commit/e475de15c901c493dc93f4194fe55b9359712ce3))
* **ignore:** update zigbee-herdsman-converters to 26.82.0 ([#32572](https://github.com/Koenkk/zigbee2mqtt/issues/32572)) ([576e195](https://github.com/Koenkk/zigbee2mqtt/commit/576e1954efcbe39d8209284e37e4df223a3f37a8))
* **ignore:** update zigbee-herdsman-converters to 26.83.0 ([#32581](https://github.com/Koenkk/zigbee2mqtt/issues/32581)) ([22d85a6](https://github.com/Koenkk/zigbee2mqtt/commit/22d85a6729f5bbba72308c58123b0b6345f6095a))
* **ignore:** update zigbee-herdsman-converters to 26.84.0 ([#32598](https://github.com/Koenkk/zigbee2mqtt/issues/32598)) ([e78b23f](https://github.com/Koenkk/zigbee2mqtt/commit/e78b23f5ebeb388402cae31db982759385e74fc5))
* **ignore:** update zigbee-herdsman-converters to 26.85.0 ([#32612](https://github.com/Koenkk/zigbee2mqtt/issues/32612)) ([752ab37](https://github.com/Koenkk/zigbee2mqtt/commit/752ab37302cffaa2cad664e5f43c61451e099ee6))
* **ignore:** update zigbee-herdsman-converters to 26.86.0 ([756a824](https://github.com/Koenkk/zigbee2mqtt/commit/756a824488377f973b710d6293b8deadc310ce42))
* **ignore:** update zigbee-herdsman-converters to 26.87.0 ([#32660](https://github.com/Koenkk/zigbee2mqtt/issues/32660)) ([e6e0b6f](https://github.com/Koenkk/zigbee2mqtt/commit/e6e0b6f8f2b498b771625019b52d42ccbd4aa967))
* **ignore:** update zigbee-herdsman-converters to 26.88.0 ([#32674](https://github.com/Koenkk/zigbee2mqtt/issues/32674)) ([3bec9e8](https://github.com/Koenkk/zigbee2mqtt/commit/3bec9e87b17985b59e8a50d3c27677056838d26b))
* **ignore:** update zigbee-herdsman-converters to 26.89.0 ([#32700](https://github.com/Koenkk/zigbee2mqtt/issues/32700)) ([b29fc0e](https://github.com/Koenkk/zigbee2mqtt/commit/b29fc0e49352f583d418a36bc6708d5745d1a788))
* **ignore:** update zigbee-herdsman-converters to 26.90.0 ([#32715](https://github.com/Koenkk/zigbee2mqtt/issues/32715)) ([e5e36ac](https://github.com/Koenkk/zigbee2mqtt/commit/e5e36ac79c2e1b89ac0fb81f95bcd655455531c1))
* Publish groups on device leave ([#32676](https://github.com/Koenkk/zigbee2mqtt/issues/32676)) ([fe96204](https://github.com/Koenkk/zigbee2mqtt/commit/fe96204f0b0f2b858ec664d408eb21545dc791d2))
* Refresh exposes after manual device configure ([#32486](https://github.com/Koenkk/zigbee2mqtt/issues/32486)) ([344776b](https://github.com/Koenkk/zigbee2mqtt/commit/344776b63379ab91bf7b35e9c208cff0f88bb84b))
* Remove json-stable-stringify-without-jsonify dep ([#32643](https://github.com/Koenkk/zigbee2mqtt/issues/32643)) ([4f18b4e](https://github.com/Koenkk/zigbee2mqtt/commit/4f18b4e38c276c515eb3b132e3328b88ff62bf95))
* Replace jszip with fflate ([#32683](https://github.com/Koenkk/zigbee2mqtt/issues/32683)) ([316413b](https://github.com/Koenkk/zigbee2mqtt/commit/316413b31c760c4b34c5afb14222bca11236d9d0))
* replace rimraf with native fs.rmSync ([#32579](https://github.com/Koenkk/zigbee2mqtt/issues/32579)) ([a9ce4b2](https://github.com/Koenkk/zigbee2mqtt/commit/a9ce4b2522c2d0a5bfcae3497cd98d3889b6f440))
* Replace source-map-support with native Node source map support ([#32620](https://github.com/Koenkk/zigbee2mqtt/issues/32620)) ([2d94100](https://github.com/Koenkk/zigbee2mqtt/commit/2d941000a73e83fbee983e98121510a6710183fc))
* Support Node 26, remove Node 20 support ([#32508](https://github.com/Koenkk/zigbee2mqtt/issues/32508)) ([5591207](https://github.com/Koenkk/zigbee2mqtt/commit/5591207deaea177c0019fe679f4e197e2efcb286))
## [2.12.1](https://github.com/Koenkk/zigbee2mqtt/compare/2.12.0...2.12.1) (2026-06-30)
+1 -1
View File
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
"$schema": "https://biomejs.dev/schemas/2.5.3/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
+1 -1
View File
@@ -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;
+7 -4
View File
@@ -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";
@@ -462,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];
}
@@ -480,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);
}
}
+13 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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";
+1 -2
View File
@@ -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";
+19 -28
View File
@@ -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 -1
View File
@@ -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";
+88 -21
View File
@@ -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";
@@ -157,6 +157,18 @@ 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"},
@@ -188,6 +200,11 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
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"},
@@ -323,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"},
@@ -333,6 +350,7 @@ const ENUM_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
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"},
@@ -359,6 +377,11 @@ 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: {entity_category: "config", icon: "mdi:calendar-clock"},
@@ -372,26 +395,45 @@ const featurePropertyWithoutEndpoint = (feature: zhc.Feature): string => {
return feature.property;
};
const applyHomeAssistantExposeMetadata = (payload: KeyValue, homeAssistant: zhc.Expose["homeassistant"]): void => {
const metadata = homeAssistant as KeyValue | undefined;
if (!metadata) {
const applyHomeAssistantExposeMetadata = (payload: DiscoveryEntry, homeAssistant: zhc.Expose["homeassistant"]): void => {
if (!homeAssistant) {
return;
}
if (typeof metadata.entityCategory === "string") {
payload.entity_category = metadata.entityCategory;
if (homeAssistant.type !== undefined) {
payload.type = homeAssistant.type;
}
if (typeof metadata.deviceClass === "string") {
payload.device_class = metadata.deviceClass;
if (homeAssistant.schema !== undefined) {
payload.discovery_payload.schema = homeAssistant.schema;
}
if (typeof metadata.enabledByDefault === "boolean") {
payload.enabled_by_default = metadata.enabledByDefault;
if (homeAssistant.entityCategory !== undefined) {
payload.discovery_payload.entity_category = homeAssistant.entityCategory;
}
if (typeof metadata.icon === "string") {
payload.icon = metadata.icon;
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;
}
}
};
@@ -482,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;
@@ -500,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;
@@ -914,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 = {
@@ -940,6 +987,9 @@ export class HomeAssistant extends Extension {
// 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()));
@@ -947,8 +997,19 @@ 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 %}";
}
}
@@ -1407,7 +1468,7 @@ export class HomeAssistant extends Extension {
}
for (const entry of discoveryEntries) {
applyHomeAssistantExposeMetadata(entry.discovery_payload, firstExpose.homeassistant);
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.
@@ -1423,7 +1484,11 @@ export class HomeAssistant extends Extension {
// 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 && !NUMERIC_DISCOVERY_LOOKUP[firstExpose.name]?.preserve_name) {
if (
entry.discovery_payload.device_class &&
entry.discovery_payload.name !== null &&
!NUMERIC_DISCOVERY_LOOKUP[firstExpose.name]?.preserve_name
) {
delete entry.discovery_payload.name;
}
@@ -1507,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, {});
}
}
}
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+6 -2
View File
@@ -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),
};
+6 -6
View File
@@ -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
View File
@@ -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;
}
}
+1 -3
View File
@@ -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",
+10 -6
View File
@@ -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;
@@ -224,7 +224,7 @@ export interface Zigbee2MQTTSettings {
interval: number;
reset_on_check: boolean;
};
}
};
export interface Zigbee2MQTTScene {
id: number;
@@ -622,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": {
+6
View File
@@ -10,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 {};
-3
View File
@@ -1,3 +0,0 @@
declare module "json-stable-stringify-without-jsonify" {
export default function (obj: unknown): string;
}
+3
View File
@@ -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;
-7
View File
@@ -5,10 +5,3 @@ declare module "zigbee2mqtt-frontend" {
export default frontend;
}
declare module "node:http" {
interface IncomingMessage {
originalUrl?: string;
path?: string;
}
}
+1 -2
View File
@@ -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}`);
}
+67
View File
@@ -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
View File
@@ -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, () => {
+3 -3
View File
@@ -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
},
@@ -748,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": {
@@ -815,7 +815,7 @@
"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": {
+7 -7
View File
@@ -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";
@@ -231,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])) {
@@ -368,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);
}
}
};
@@ -479,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);
+65
View File
@@ -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();
});
};
}
+158
View File
@@ -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";
}
+8 -4
View File
@@ -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);
}
}
+13 -22
View File
@@ -1,6 +1,6 @@
{
"name": "zigbee2mqtt",
"version": "2.12.1-dev",
"version": "2.13.0-dev",
"description": "Zigbee to MQTT bridge using Zigbee-herdsman",
"main": "index.js",
"types": "dist/types/api.d.ts",
@@ -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.34.0",
"js-yaml": "^5.2.1",
"json-stable-stringify-without-jsonify": "^1.0.1",
"jszip": "^3.10.1",
"fflate": "^0.8.3",
"humanize-duration": "^3.34.1",
"js-yaml": "^5.3.0",
"mqtt": "^5.15.2",
"object-assign-deep": "^0.4.0",
"rimraf": "^6.1.3",
"semver": "^7.8.5",
"source-map-support": "^0.5.21",
"throttleit": "^2.1.0",
"srvx": "^0.12.7",
"throttleit": "^3.0.0",
"winston": "^3.19.0",
"winston-syslog": "^2.7.1",
"winston-transport": "^4.9.0",
"ws": "^8.21.0",
"zigbee-herdsman": "10.6.2",
"zigbee-herdsman-converters": "26.81.0",
"ws": "^8.21.3",
"zigbee-herdsman": "10.9.1",
"zigbee-herdsman-converters": "26.100.0",
"zigbee2mqtt-frontend": "0.9.21",
"zigbee2mqtt-windfront": "2.12.1"
"zigbee2mqtt-windfront": "2.14.1"
},
"devDependencies": {
"@biomejs/biome": "^2.5.3",
"@types/finalhandler": "^1.2.3",
"@types/humanize-duration": "^3.27.4",
"@types/js-yaml": "^4.0.9",
"@types/node": "^26.1.1",
"@types/object-assign-deep": "^0.4.3",
"@types/node": "^26.2.0",
"@types/readable-stream": "4.0.24",
"@types/serve-static": "^2.2.0",
"@types/ws": "8.18.1",
"@vitest/coverage-v8": "^3.1.1",
"tmp": "^0.2.7",
"typescript": "^6.0.3",
"typescript": "^7.0.2",
"vitest": "^3.1.1"
},
"pnpm": {
+290 -455
View File
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -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 () => {
+34 -1
View File
@@ -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";
@@ -1061,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");
@@ -1089,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");
+14 -1
View File
@@ -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);
+1 -1
View File
@@ -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";
+214 -17
View File
@@ -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";
@@ -784,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",
@@ -831,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).",
@@ -1162,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",
@@ -1196,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",
@@ -1962,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",
@@ -2806,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"}}),
@@ -2819,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 () => {
@@ -2973,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();
@@ -2981,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([]);
@@ -2995,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");
@@ -3105,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)",
}),
{},
);
});
@@ -3371,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",
@@ -3554,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");
@@ -4162,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"}),
@@ -4176,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", "");
@@ -4325,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();
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+47 -53
View File
@@ -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 () => {
+1 -1
View File
@@ -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";
+103 -6
View File
@@ -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";
@@ -281,6 +281,25 @@ describe("Extension: HomeAssistant", () => {
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"});
@@ -1650,12 +1669,15 @@ describe("Extension: HomeAssistant", () => {
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: [
@@ -1681,14 +1703,25 @@ describe("Extension: HomeAssistant", () => {
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 %}',
};
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,
qos: 1,
@@ -1699,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();
@@ -1739,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 () => {
@@ -1755,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 () => {
@@ -1797,6 +1888,7 @@ describe("Extension: HomeAssistant", () => {
effect: null,
effect_color: null,
effect_speed: null,
identify: null,
linkquality: null,
state: null,
power_on_behavior: null,
@@ -1822,6 +1914,7 @@ describe("Extension: HomeAssistant", () => {
effect: null,
effect_color: null,
effect_speed: null,
identify: null,
linkquality: null,
state: null,
power_on_behavior: null,
@@ -1846,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},
@@ -2317,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();
@@ -2354,6 +2448,7 @@ describe("Extension: HomeAssistant", () => {
stringify({
action: "single",
battery: null,
identify: null,
linkquality: null,
voltage: null,
power_outage_count: null,
@@ -3269,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,
@@ -3279,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,
+4 -4
View File
@@ -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: [],
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+25 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+19
View File
@@ -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,
}));
-11
View File
@@ -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,
};
}),
}));
-4
View File
@@ -1,7 +1,3 @@
declare module "json-stable-stringify-without-jsonify" {
export default function (obj: unknown): string;
}
declare module "tmp" {
export function dirSync(): {
name: string;
+1
View File
@@ -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,
+161
View File
@@ -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}});
});
});
+110 -203
View File
@@ -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()),
@@ -194,10 +185,7 @@ describe("Onboarding", () => {
mockFindAllDevices.mockClear();
mockHttpErrorListener = undefined;
mockStaticFileServer.mockClear();
mockExpressStaticGzip.mockClear();
mockFinalHandlerNext.mockClear();
mockFinalhandler.mockClear();
mockStaticFileServer.mockClear();
mockCreateStaticFileServer.mockClear();
settings.reRead();
});
@@ -540,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 () => {
@@ -767,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 () => {
@@ -814,7 +723,6 @@ describe("Onboarding", () => {
});
await expect(p).resolves.toStrictEqual(true);
expect(mockFinalhandler).toHaveBeenCalled();
expect(mockStaticFileServer).toHaveBeenCalled();
});
@@ -836,7 +744,6 @@ describe("Onboarding", () => {
});
await expect(p).resolves.toStrictEqual(false);
expect(mockFinalhandler).toHaveBeenCalled();
expect(mockStaticFileServer).toHaveBeenCalled();
});
+10 -11
View File
@@ -4,11 +4,15 @@ import "./mocks/data";
import fs from "node:fs";
import {dump, load} from "js-yaml";
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";
// 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");
@@ -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);
+40 -79
View File
@@ -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);
+185
View File
@@ -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));
});
});
+44
View File
@@ -2,6 +2,7 @@ import {exec} from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import {describe, expect, it, vi} from "vitest";
import {stringify} from "../lib/util/stringify";
import utils, {assertString} from "../lib/util/utils";
// keep the implementations, just spy
@@ -155,4 +156,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: {}}));
});
});