mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-08-29 07:08:56 +00:00
Compare commits
36
Commits
fix/ha-type
...
2.13.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcbb7ff44b | ||
|
|
e5e36ac79c | ||
|
|
28e410bbf4 | ||
|
|
b29fc0e493 | ||
|
|
316413b31c | ||
|
|
a4862c9769 | ||
|
|
3348cea190 | ||
|
|
fe96204f0b | ||
|
|
7598b38ed8 | ||
|
|
52d5316377 | ||
|
|
32506b4e8a | ||
|
|
3bec9e87b1 | ||
|
|
b46c77bff0 | ||
|
|
e6e0b6f8f2 | ||
|
|
756a824488 | ||
|
|
83ab5228ff | ||
|
|
4f18b4e38c | ||
|
|
5e56c454c3 | ||
|
|
bbad134e8e | ||
|
|
2d941000a7 | ||
|
|
4ad1ab21e6 | ||
|
|
6835cbf5a7 | ||
|
|
b8d388ca7a | ||
|
|
c9ddef703a | ||
|
|
4174e9cb78 | ||
|
|
752ab37302 | ||
|
|
d6dea17961 | ||
|
|
e78b23f5eb | ||
|
|
22d85a6729 | ||
|
|
a9ce4b2522 | ||
|
|
e889592baf | ||
|
|
576e1954ef | ||
|
|
8940f6dabf | ||
|
|
fa12ebe27c | ||
|
|
3f7c0ca715 | ||
|
|
66db1bdbcd |
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"image": "mcr.microsoft.com/devcontainers/javascript-node:24",
|
||||
|
||||
"postCreateCommand": "pnpm config set store-dir /home/node/.local/share/pnpm/store && npm install typescript -g",
|
||||
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"settings": {
|
||||
"workbench.colorTheme": "Default Dark Modern",
|
||||
"window.menuBarVisibility": "classic",
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"notebook.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnPaste": true,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.tabSize": 4,
|
||||
"editor.insertSpaces": true,
|
||||
"files.defaultLanguage": "typescript",
|
||||
"files.eol": "\n"
|
||||
},
|
||||
"extensions": ["biomejs.biome", "vitest.explorer"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ abstract class Extension {
|
||||
protected state: State;
|
||||
protected publishEntityState: PublishEntityState;
|
||||
protected eventBus: EventBus;
|
||||
|
||||
|
||||
async start(): Promise<void> {}
|
||||
async stop(): Promise<void> {}
|
||||
}
|
||||
@@ -181,9 +181,9 @@ logger.debug("message");
|
||||
- Use TypeScript's strict mode features (`noImplicitAny`, `noImplicitThis`)
|
||||
|
||||
### Performance
|
||||
- Use `rimrafSync` for synchronous file deletion when appropriate
|
||||
- Use `fs.rmSync(path, {recursive: true, force: true})` for synchronous file deletion when appropriate
|
||||
- Leverage async/await for I/O operations to avoid blocking
|
||||
- Use JSON stable stringify for consistent object serialization: `json-stable-stringify-without-jsonify`
|
||||
- Use JSON stable stringify util for consistent object serialization
|
||||
- Cache computed values when appropriate (see device model patterns)
|
||||
- Use getter methods for computed properties that should be cached
|
||||
|
||||
@@ -217,10 +217,10 @@ describe("ComponentName", () => {
|
||||
it("Should do something specific", async () => {
|
||||
// Arrange
|
||||
const input = {};
|
||||
|
||||
|
||||
// Act
|
||||
const result = await someFunction(input);
|
||||
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
@@ -414,7 +414,7 @@ this.eventBus.on('deviceMessage', this.onDeviceMessage, this);
|
||||
- Device operations through `zigbee-herdsman` API
|
||||
- Event handling through EventBus wrappers
|
||||
|
||||
### MQTT Integration
|
||||
### MQTT Integration
|
||||
- Connect: `await this.mqtt.connect()`
|
||||
- Subscribe: `await this.mqtt.subscribe(topic)`
|
||||
- Publish: `await this.mqtt.publish(topic, message, options)`
|
||||
|
||||
@@ -27,4 +27,4 @@ updates:
|
||||
interval: weekly
|
||||
target-branch: dev
|
||||
commit-message:
|
||||
prefix: fix(ignore)
|
||||
prefix: chore
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
{
|
||||
".": "2.12.1"
|
||||
".": "2.13.0"
|
||||
}
|
||||
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["biomejs.biome", "vitest.explorer"]
|
||||
}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"notebook.defaultFormatter": "biomejs.biome",
|
||||
"editor.tabSize": 4,
|
||||
"editor.insertSpaces": true,
|
||||
"files.defaultLanguage": "typescript",
|
||||
"files.eol": "\n",
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ Zigbee2MQTT is a Zigbee to MQTT bridge that allows you to use your Zigbee device
|
||||
- **Language**: TypeScript 5.9.3 compiled to JavaScript (ES modules with NodeNext resolution)
|
||||
- **Runtime**: Node.js (versions 20, 22, or 24)
|
||||
- **Package Manager**: pnpm 10.12.1 (strictly enforced via `packageManager` field)
|
||||
- **Core Dependencies**:
|
||||
- **Core Dependencies**:
|
||||
- `zigbee-herdsman` (6.2.0 - exact version, handles Zigbee adapter communication)
|
||||
- `zigbee-herdsman-converters` (25.42.0 - exact version, device definitions)
|
||||
- `mqtt` (5.14.1 - MQTT client)
|
||||
@@ -227,7 +227,7 @@ abstract class Extension {
|
||||
protected state: State;
|
||||
protected publishEntityState: PublishEntityState;
|
||||
protected eventBus: EventBus;
|
||||
|
||||
|
||||
async start(): Promise<void> {} // Initialize extension
|
||||
async stop(): Promise<void> {} // Cleanup extension
|
||||
}
|
||||
@@ -326,7 +326,7 @@ https://www.zigbee2mqtt.io/guide/installation/01_linux.html
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
- Use `rimrafSync` for synchronous file operations
|
||||
- Use `fs.rmSync(path, {recursive: true, force: true})` for synchronous file operations
|
||||
- Leverage async/await to avoid blocking
|
||||
- Cache computed values in getters when appropriate
|
||||
- EventBus provides loose coupling between components
|
||||
@@ -344,7 +344,7 @@ These dependencies use **exact versions** (no semver ranges) - do not upgrade wi
|
||||
|
||||
Only these Node.js versions are supported:
|
||||
- Node.js 20.x
|
||||
- Node.js 22.x
|
||||
- Node.js 22.x
|
||||
- Node.js 24.x
|
||||
|
||||
Using other versions may cause runtime errors or incompatibilities.
|
||||
|
||||
@@ -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
@@ -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,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;
|
||||
|
||||
+3
-3
@@ -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,7 +462,7 @@ export class Controller {
|
||||
async iteratePayloadAttributeOutput(topicRoot: string, payload: KeyValue, options: Partial<MqttPublishOptions>): Promise<void> {
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
let subPayload = value;
|
||||
let message = null;
|
||||
let message: string | undefined;
|
||||
|
||||
// Special cases
|
||||
if (key === "color" && utils.objectHasProperties(subPayload, ["r", "g", "b"])) {
|
||||
@@ -480,7 +480,7 @@ export class Controller {
|
||||
message = typeof subPayload === "string" ? subPayload : stringify(subPayload);
|
||||
}
|
||||
|
||||
if (message !== null) {
|
||||
if (message !== undefined) {
|
||||
await this.mqtt.publish(`${topicRoot}${key}`, message, options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
+49
-24
@@ -1,8 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import JSZip from "jszip";
|
||||
import {zip} from "fflate";
|
||||
import objectAssignDeep from "object-assign-deep";
|
||||
import type winston from "winston";
|
||||
import Transport from "winston-transport";
|
||||
@@ -15,6 +14,7 @@ import type {Zigbee2MQTTAPI, Zigbee2MQTTDevice, Zigbee2MQTTResponse, Zigbee2MQTT
|
||||
import data from "../util/data";
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import {stringify} from "../util/stringify";
|
||||
import utils, {assertString, DEFAULT_BIND_GROUP_ID} from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
@@ -141,6 +141,7 @@ export default class Bridge extends Extension {
|
||||
await this.mqtt.publish("bridge/event", stringify(payload));
|
||||
});
|
||||
this.eventBus.onDeviceLeave(this, async (data) => {
|
||||
await this.publishGroups();
|
||||
await this.publishDevices();
|
||||
await this.publishDefinitions();
|
||||
|
||||
@@ -243,7 +244,10 @@ export default class Bridge extends Extension {
|
||||
}
|
||||
|
||||
const newSettings = message.options as Partial<Settings>;
|
||||
this.restartRequired = settings.apply(newSettings);
|
||||
const newRestartRequired = settings.apply(newSettings);
|
||||
if (newRestartRequired) {
|
||||
this.restartRequired = newRestartRequired;
|
||||
}
|
||||
|
||||
// Apply some settings on-the-fly.
|
||||
if (newSettings.homeassistant) {
|
||||
@@ -262,7 +266,11 @@ export default class Bridge extends Extension {
|
||||
logger.setDebugNamespaceIgnore(settings.get().advanced.log_debug_namespace_ignore);
|
||||
}
|
||||
|
||||
logger.info("Successfully changed options");
|
||||
if (newRestartRequired) {
|
||||
logger.info("Changes require restart to take effect");
|
||||
} else {
|
||||
logger.info("Successfully changed options");
|
||||
}
|
||||
await this.publishInfo();
|
||||
return utils.getResponse(message, {restart_required: this.restartRequired});
|
||||
}
|
||||
@@ -321,7 +329,7 @@ export default class Bridge extends Extension {
|
||||
await this.zigbee.backup();
|
||||
const dataPath = data.getPath();
|
||||
const files = utils.getAllFiles(dataPath);
|
||||
const zip = new JSZip();
|
||||
const zipFiles: Record<string, Uint8Array> = {};
|
||||
const logDir = `log${path.sep}`;
|
||||
const otaDir = `ota${path.sep}`;
|
||||
|
||||
@@ -330,12 +338,17 @@ export default class Bridge extends Extension {
|
||||
|
||||
// XXX: `log` could technically be something else depending on `log_directory` setting
|
||||
if (!name.startsWith(logDir) && !name.startsWith(otaDir)) {
|
||||
zip.file(name, fs.readFileSync(f));
|
||||
zipFiles[name] = fs.readFileSync(f);
|
||||
}
|
||||
}
|
||||
|
||||
const base64Zip = await zip.generateAsync({type: "base64"});
|
||||
return utils.getResponse(message, {zip: base64Zip});
|
||||
const zipContent = await new Promise<Uint8Array>((resolve, reject) => {
|
||||
// `jszip` defaulted to `STORE`, so backups used to be uncompressed; `fflate`'s default level shrinks them substantially
|
||||
zip(zipFiles, {level: 6}, (error, data) => (error ? reject(error) : resolve(data)));
|
||||
});
|
||||
|
||||
// TODO: replace with `zipContent.toBase64()` once the Node requirement is >=25
|
||||
return utils.getResponse(message, {zip: Buffer.from(zipContent).toString("base64")});
|
||||
}
|
||||
|
||||
@bind async installCodeAdd(message: KeyValue | string): Promise<Zigbee2MQTTResponse<"bridge/response/install_code/add">> {
|
||||
@@ -464,12 +477,16 @@ export default class Bridge extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
const restartRequired = settings.changeEntityOptions(ID, message.options);
|
||||
if (restartRequired) this.restartRequired = true;
|
||||
const newRestartRequired = settings.changeEntityOptions(ID, message.options);
|
||||
if (newRestartRequired) this.restartRequired = true;
|
||||
const newOptions = cleanup(entity.options);
|
||||
await this.publishInfo();
|
||||
|
||||
logger.info(`Changed config for ${entityType} ${ID}`);
|
||||
if (newRestartRequired) {
|
||||
logger.info(`New config for ${entityType} ${ID} requires restart to take effect`);
|
||||
} else {
|
||||
logger.info(`Successfully changed config for ${entityType} ${ID}`);
|
||||
}
|
||||
|
||||
this.eventBus.emitEntityOptionsChanged({from: oldOptions, to: newOptions, entity});
|
||||
return utils.getResponse(message, {from: oldOptions, to: newOptions, id: ID, restart_required: this.restartRequired});
|
||||
@@ -653,20 +670,25 @@ export default class Bridge extends Extension {
|
||||
entityType: T,
|
||||
message: string | KeyValue,
|
||||
): Promise<Zigbee2MQTTResponse<T extends "device" ? "bridge/response/device/remove" : "bridge/response/group/remove">> {
|
||||
const ID = typeof message === "object" ? message.id : message.trim();
|
||||
const messageIsObject = typeof message === "object";
|
||||
const ID = messageIsObject ? message.id : message.trim();
|
||||
const entity = this.getEntity(entityType, ID);
|
||||
// note: entity.name is dynamically retrieved, will change once device is removed (friendly => ieee)
|
||||
const friendlyName = entity.name;
|
||||
let block = false;
|
||||
let force = false;
|
||||
let clearCache = false;
|
||||
let blockForceLog = "";
|
||||
|
||||
if (entityType === "device" && typeof message === "object") {
|
||||
block = !!message.block;
|
||||
force = !!message.force;
|
||||
blockForceLog = ` (block: ${block}, force: ${force})`;
|
||||
} else if (entityType === "group" && typeof message === "object") {
|
||||
force = !!message.force;
|
||||
if (entityType === "device" && messageIsObject) {
|
||||
const payload = message as Zigbee2MQTTAPI["bridge/request/device/remove"];
|
||||
block = !!payload.block;
|
||||
force = !!payload.force;
|
||||
clearCache = !!payload.clear_cache;
|
||||
blockForceLog = ` (block: ${block}, force: ${force}, clear cache: ${clearCache})`;
|
||||
} else if (entityType === "group" && messageIsObject) {
|
||||
const payload = message as Zigbee2MQTTAPI["bridge/request/group/remove"];
|
||||
force = !!payload.force;
|
||||
blockForceLog = ` (force: ${force})`;
|
||||
}
|
||||
|
||||
@@ -679,9 +701,13 @@ export default class Bridge extends Extension {
|
||||
}
|
||||
|
||||
if (force) {
|
||||
entity.zh.removeFromDatabase();
|
||||
entity.zh.removeFromDatabase(clearCache);
|
||||
} else {
|
||||
await entity.zh.removeFromNetwork();
|
||||
await entity.zh.removeFromNetwork(clearCache);
|
||||
}
|
||||
|
||||
if (clearCache) {
|
||||
this.zigbee.removeDeviceFromLookup(entity.ID);
|
||||
}
|
||||
|
||||
settings.removeDevice(entity.ID as string);
|
||||
@@ -706,19 +732,18 @@ export default class Bridge extends Extension {
|
||||
|
||||
logger.info(`Successfully removed ${entityType} '${friendlyName}'${blockForceLog}`);
|
||||
|
||||
await this.publishGroups();
|
||||
|
||||
if (entity instanceof Device) {
|
||||
await this.publishGroups();
|
||||
await this.publishDevices();
|
||||
// Refresh Cluster definition
|
||||
await this.publishDefinitions();
|
||||
|
||||
const responseData: Zigbee2MQTTAPI["bridge/response/device/remove"] = {id: ID, block, force};
|
||||
const responseData: Zigbee2MQTTAPI["bridge/response/device/remove"] = {id: ID, block, force, clear_cache: clearCache};
|
||||
|
||||
return utils.getResponse(message, responseData);
|
||||
}
|
||||
|
||||
await this.publishGroups();
|
||||
|
||||
const responseData: Zigbee2MQTTAPI["bridge/response/group/remove"] = {id: ID, force};
|
||||
|
||||
return utils.getResponse(
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -8,12 +8,11 @@ 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 {stringify} from "../util/stringify";
|
||||
import utils from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -372,26 +372,41 @@ 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.valueTemplate !== undefined) {
|
||||
if (homeAssistant.valueTemplate === null) {
|
||||
delete payload.discovery_payload.value_template;
|
||||
} else {
|
||||
payload.discovery_payload.value_template = homeAssistant.valueTemplate;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -482,9 +497,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 +519,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;
|
||||
@@ -1407,7 +1427,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.
|
||||
@@ -1507,10 +1527,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,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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import type * as zhc from "zigbee-herdsman-converters";
|
||||
|
||||
import Device from "../model/device";
|
||||
import Group from "../model/group";
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import {stringify} from "../util/stringify";
|
||||
import utils from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import assert from "node:assert";
|
||||
|
||||
import bind from "bind-decorator";
|
||||
import debounce from "debounce";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import throttle from "throttleit";
|
||||
|
||||
import * as zhc from "zigbee-herdsman-converters";
|
||||
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import {stringify} from "../util/stringify";
|
||||
import utils from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
|
||||
+4
-1
@@ -18,6 +18,7 @@ export default class Device {
|
||||
public zh: zh.Device;
|
||||
public definition?: zhc.Definition;
|
||||
private _definitionModelID?: string;
|
||||
#isResolvingDefinition?: boolean;
|
||||
|
||||
get ieeeAddr(): string {
|
||||
return this.zh.ieeeAddr;
|
||||
@@ -64,9 +65,11 @@ export default class Device {
|
||||
}
|
||||
|
||||
async resolveDefinition(ignoreCache = false): Promise<void> {
|
||||
if (this.interviewed && (!this.definition || this._definitionModelID !== this.zh.modelID || ignoreCache)) {
|
||||
if (this.interviewed && !this.#isResolvingDefinition && (!this.definition || this._definitionModelID !== this.zh.modelID || ignoreCache)) {
|
||||
this.#isResolvingDefinition = true;
|
||||
this.definition = await zhc.findByDevice(this.zh, true);
|
||||
this._definitionModelID = this.zh.modelID;
|
||||
this.#isResolvingDefinition = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -622,12 +622,14 @@ export interface Zigbee2MQTTAPI {
|
||||
id: string;
|
||||
block?: boolean;
|
||||
force?: boolean;
|
||||
clear_cache?: boolean;
|
||||
};
|
||||
|
||||
"bridge/response/device/remove": {
|
||||
id: string;
|
||||
block: boolean;
|
||||
force: boolean;
|
||||
clear_cache: boolean;
|
||||
};
|
||||
|
||||
"bridge/request/device/ota_update/check": {
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
declare module "json-stable-stringify-without-jsonify" {
|
||||
export default function (obj: unknown): string;
|
||||
}
|
||||
+1
-2
@@ -2,7 +2,6 @@ import assert from "node:assert";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import {rimrafSync} from "rimraf";
|
||||
import winston from "winston";
|
||||
import * as settings from "./settings";
|
||||
|
||||
@@ -235,7 +234,7 @@ class Logger {
|
||||
for (const dir of directories) {
|
||||
this.debug(`Removing old log directory '${dir.path}'`);
|
||||
try {
|
||||
rimrafSync(dir.path);
|
||||
fs.rmSync(dir.path, {recursive: true, force: true});
|
||||
} catch (e) {
|
||||
this.error(`Failed to remove old log directory '${dir.path}': ${e}`);
|
||||
}
|
||||
|
||||
+10
-8
@@ -3,11 +3,11 @@ import type {ServerResponse} from "node:http";
|
||||
import {createServer} from "node:http";
|
||||
import path from "node:path";
|
||||
import expressStaticGzip from "express-static-gzip";
|
||||
import {type Unzipped, unzip} from "fflate";
|
||||
import finalhandler from "finalhandler";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import JSZip from "jszip";
|
||||
import {findAllDevices} from "zigbee-herdsman/dist/adapter/adapterDiscovery";
|
||||
import type {OnboardData, OnboardFailureData, OnboardSubmitResponse, Zigbee2MQTTSettings} from "../types/api";
|
||||
import {stringify} from "../util/stringify";
|
||||
import data from "./data";
|
||||
import * as settings from "./settings";
|
||||
import {YAMLFileException} from "./yaml";
|
||||
@@ -49,20 +49,22 @@ function getZipEntryTargetPath(entryName: string): string {
|
||||
}
|
||||
|
||||
async function extractZipDataToDataPath(zipContent: Buffer): Promise<void> {
|
||||
const zip = await JSZip.loadAsync(zipContent);
|
||||
const entries = await new Promise<Unzipped>((resolve, reject) => {
|
||||
unzip(zipContent, (error, data) => (error ? reject(error) : resolve(data)));
|
||||
});
|
||||
|
||||
for (const key in zip.files) {
|
||||
const entry = zip.files[key];
|
||||
const targetPath = getZipEntryTargetPath(entry.name);
|
||||
for (const name in entries) {
|
||||
const targetPath = getZipEntryTargetPath(name);
|
||||
|
||||
if (entry.dir) {
|
||||
// directory entries are identified by a trailing slash
|
||||
if (name.endsWith("/")) {
|
||||
mkdirSync(targetPath, {recursive: true});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
mkdirSync(path.dirname(targetPath), {recursive: true});
|
||||
writeFileSync(targetPath, await entry.async("nodebuffer"));
|
||||
writeFileSync(targetPath, entries[name]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// Stable stringify inspired by https://github.com/BridgeAR/safe-stable-stringify
|
||||
// Takes advantage of Node env and Z2M's object-only use-case.
|
||||
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: escape regex
|
||||
const STR_ESC_SEQ_REGEXP = /[\u0000-\u001f\u0022\u005c\ud800-\udfff]/;
|
||||
|
||||
// Escape C0 control characters, double quotes, the backslash and every code
|
||||
// unit with a numeric value in the inclusive range 0xD800 to 0xDFFF.
|
||||
function strEscape(str: string): string {
|
||||
// Some magic numbers that worked out fine while benchmarking with v8 8.0
|
||||
if (str.length < 5000 && !STR_ESC_SEQ_REGEXP.test(str)) {
|
||||
return `"${str}"`;
|
||||
}
|
||||
|
||||
return JSON.stringify(str);
|
||||
}
|
||||
|
||||
function sort(array: string[]) {
|
||||
// Insertion sort is very efficient for small input sizes, but it has a bad
|
||||
// worst case complexity. Thus, use native array sort for bigger values.
|
||||
if (array.length > 2e2) {
|
||||
return array.sort();
|
||||
}
|
||||
|
||||
for (let i = 1; i < array.length; i++) {
|
||||
const currentValue = array[i];
|
||||
let position = i;
|
||||
|
||||
while (position !== 0 && array[position - 1] > currentValue) {
|
||||
array[position] = array[position - 1];
|
||||
position--;
|
||||
}
|
||||
|
||||
array[position] = currentValue;
|
||||
}
|
||||
}
|
||||
|
||||
function isTypedArray(value: unknown): value is unknown[] {
|
||||
return ArrayBuffer.isView(value) && !(value instanceof DataView);
|
||||
}
|
||||
|
||||
function stringifyTypedArray(array: unknown[]): string {
|
||||
const isBigInt = typeof array[0] === "bigint";
|
||||
let res = `"0":${isBigInt ? `"${array[0]}"` : array[0]}`;
|
||||
|
||||
for (let i = 1; i < array.length; i++) {
|
||||
res += `,"${i}":${isBigInt ? `"${array[1]}"` : array[1]}`;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
function stringifySimple(key: string, value: unknown, stack: unknown[]): string | undefined {
|
||||
switch (typeof value) {
|
||||
case "string":
|
||||
return strEscape(value);
|
||||
case "object": {
|
||||
if (value === null) {
|
||||
return "null";
|
||||
}
|
||||
|
||||
if ("toJSON" in value && typeof value.toJSON === "function") {
|
||||
value = value.toJSON(key);
|
||||
|
||||
// Prevent calling `toJSON` again
|
||||
if (typeof value !== "object") {
|
||||
return stringifySimple(key, value, stack);
|
||||
}
|
||||
|
||||
if (value === null) {
|
||||
return "null";
|
||||
}
|
||||
}
|
||||
|
||||
if (stack.indexOf(value) !== -1) {
|
||||
return '"[Circular]"';
|
||||
}
|
||||
|
||||
let res = "";
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return "[]";
|
||||
}
|
||||
|
||||
stack.push(value);
|
||||
|
||||
let i = 0;
|
||||
|
||||
for (; i < value.length - 1; i++) {
|
||||
const tmp = stringifySimple(`${i}`, value[i], stack);
|
||||
res += tmp !== undefined ? tmp : "null";
|
||||
res += ",";
|
||||
}
|
||||
|
||||
const tmp = stringifySimple(`${i}`, value[i], stack);
|
||||
res += tmp !== undefined ? tmp : "null";
|
||||
|
||||
stack.pop();
|
||||
|
||||
return `[${res}]`;
|
||||
}
|
||||
|
||||
let keys = Object.keys(value);
|
||||
const keysLength = keys.length;
|
||||
|
||||
if (keysLength === 0) {
|
||||
return "{}";
|
||||
}
|
||||
|
||||
let separator = "";
|
||||
let propsToStringify = keysLength;
|
||||
|
||||
if (isTypedArray(value)) {
|
||||
res += stringifyTypedArray(value);
|
||||
keys = keys.slice(value.length);
|
||||
propsToStringify -= value.length;
|
||||
separator = ",";
|
||||
}
|
||||
|
||||
sort(keys);
|
||||
stack.push(value);
|
||||
|
||||
for (let i = 0; i < propsToStringify; i++) {
|
||||
const valKey = keys[i];
|
||||
const tmp = stringifySimple(valKey, (value as Record<string, unknown>)[valKey], stack);
|
||||
|
||||
if (tmp !== undefined) {
|
||||
res += `${separator}${strEscape(valKey)}:${tmp}`;
|
||||
separator = ",";
|
||||
}
|
||||
}
|
||||
|
||||
stack.pop();
|
||||
|
||||
return `{${res}}`;
|
||||
}
|
||||
case "number":
|
||||
return Number.isFinite(value) ? `${value}` : "null";
|
||||
case "boolean":
|
||||
return value === true ? "true" : "false";
|
||||
case "undefined":
|
||||
return undefined;
|
||||
case "bigint":
|
||||
return `"${value}"`;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function stringify(value: object): string {
|
||||
return stringifySimple("", value, []) ?? "null";
|
||||
}
|
||||
+8
-4
@@ -1,6 +1,5 @@
|
||||
import {randomInt} from "node:crypto";
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import type {Events as ZHEvents} from "zigbee-herdsman";
|
||||
import {Controller} from "zigbee-herdsman";
|
||||
import type {StartResult} from "zigbee-herdsman/dist/adapter/tstype";
|
||||
@@ -9,6 +8,7 @@ import Group from "./model/group";
|
||||
import data from "./util/data";
|
||||
import logger from "./util/logger";
|
||||
import * as settings from "./util/settings";
|
||||
import {stringify} from "./util/stringify";
|
||||
import utils from "./util/utils";
|
||||
|
||||
const entityIDRegex = /^(.+?)(?:\/([^/]+))?$/;
|
||||
@@ -61,7 +61,7 @@ export default class Zigbee {
|
||||
|
||||
logger.debug(
|
||||
() =>
|
||||
`Using zigbee-herdsman with settings: '${stringify(JSON.stringify(herdsmanSettings).replaceAll(JSON.stringify(herdsmanSettings.network.networkKey), '"HIDDEN"'))}'`,
|
||||
`Using zigbee-herdsman with settings: '${stringify(herdsmanSettings).replaceAll(stringify(herdsmanSettings.network.networkKey), '"HIDDEN"')}'`,
|
||||
);
|
||||
|
||||
let startResult: StartResult;
|
||||
@@ -467,7 +467,11 @@ export default class Zigbee {
|
||||
return this.resolveGroup(id);
|
||||
}
|
||||
|
||||
removeGroupFromLookup(id: number): void {
|
||||
this.groupLookup.delete(id);
|
||||
removeDeviceFromLookup(ieee: string): boolean {
|
||||
return this.deviceLookup.delete(ieee);
|
||||
}
|
||||
|
||||
removeGroupFromLookup(id: number): boolean {
|
||||
return this.groupLookup.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
+12
-16
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "zigbee2mqtt",
|
||||
"version": "2.12.1-dev",
|
||||
"version": "2.13.0",
|
||||
"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",
|
||||
@@ -47,39 +47,35 @@
|
||||
"debounce": "^3.0.0",
|
||||
"express-static-gzip": "^3.0.1",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fflate": "^0.8.3",
|
||||
"finalhandler": "^2.1.1",
|
||||
"humanize-duration": "^3.34.0",
|
||||
"js-yaml": "^5.2.1",
|
||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||
"jszip": "^3.10.1",
|
||||
"js-yaml": "^5.2.2",
|
||||
"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",
|
||||
"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.1",
|
||||
"zigbee-herdsman": "10.8.0",
|
||||
"zigbee-herdsman-converters": "26.90.0",
|
||||
"zigbee2mqtt-frontend": "0.9.21",
|
||||
"zigbee2mqtt-windfront": "2.12.1"
|
||||
"zigbee2mqtt-windfront": "2.14.0"
|
||||
},
|
||||
"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/node": "^26.1.2",
|
||||
"@types/object-assign-deep": "^0.4.3",
|
||||
"@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": {
|
||||
@@ -99,4 +95,4 @@
|
||||
"optionalDependencies": {
|
||||
"unix-dgram": "^2.0.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+277
-247
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,4 @@
|
||||
import {existsSync, mkdirSync} from "node:fs";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {bench, describe, vi} from "vitest";
|
||||
import {type Controller, Zcl, Zdo, ZSpec} from "zigbee-herdsman";
|
||||
import type Adapter from "zigbee-herdsman/dist/adapter/adapter";
|
||||
@@ -12,6 +11,7 @@ import type {DeviceType} from "zigbee-herdsman/dist/controller/tstype";
|
||||
import {Foundation} from "zigbee-herdsman/dist/zspec/zcl/definition/foundation";
|
||||
import type {RequestToResponseMap} from "zigbee-herdsman/dist/zspec/zdo/definition/tstypes";
|
||||
import data from "../lib/util/data";
|
||||
import {stringify} from "../lib/util/stringify";
|
||||
import {BENCH_OPTIONS} from "./benchOptions";
|
||||
|
||||
vi.doMock("zigbee-herdsman", async (importOriginal) => {
|
||||
@@ -257,7 +257,7 @@ const adapter = {
|
||||
switch (zclFrame.command.ID) {
|
||||
case Foundation.read.ID: {
|
||||
for (const attr of zclFrame.payload) {
|
||||
const attribute = zclFrame.cluster.getAttribute(attr.attrId);
|
||||
const attribute = Zcl.Utils.getClusterAttribute(zclFrame.cluster, attr.attrId, undefined);
|
||||
|
||||
if (attribute && attribute.type !== Zcl.DataType.NO_DATA && attribute.type < Zcl.DataType.OCTET_STR) {
|
||||
payload.push({
|
||||
@@ -421,7 +421,6 @@ const initController = async () => {
|
||||
disconnecting: false,
|
||||
disconnected: false,
|
||||
endAsync: async () => {},
|
||||
// @ts-expect-error Z2M does not make use of return
|
||||
publishAsync: async () => {},
|
||||
};
|
||||
controller.mqtt.connect = async () => {
|
||||
|
||||
@@ -17,9 +17,9 @@ import {devices, mockController as mockZHController, events as mockZHEvents, ret
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import tmp from "tmp";
|
||||
|
||||
import {stringify} from "../lib/util/stringify";
|
||||
import type {Mock, MockInstance} from "vitest";
|
||||
import {Controller as ZHController} from "zigbee-herdsman";
|
||||
import {Controller} from "../lib/controller";
|
||||
|
||||
@@ -7,7 +7,7 @@ import {flushPromises} from "../mocks/utils";
|
||||
import {devices, events as mockZHEvents, returnDevices} from "../mocks/zigbeeHerdsman";
|
||||
|
||||
import assert from "node:assert";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {stringify} from "../../lib/util/stringify";
|
||||
import {Controller} from "../../lib/controller";
|
||||
import Availability from "../../lib/extension/availability";
|
||||
import * as settings from "../../lib/util/settings";
|
||||
|
||||
@@ -7,7 +7,7 @@ import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
|
||||
import {flushPromises} from "../mocks/utils";
|
||||
import {type Device, devices, groups, events as mockZHEvents} from "../mocks/zigbeeHerdsman";
|
||||
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {stringify} from "../../lib/util/stringify";
|
||||
import {Controller} from "../../lib/controller";
|
||||
import Bind from "../../lib/extension/bind";
|
||||
import * as settings from "../../lib/util/settings";
|
||||
|
||||
+121
-15
@@ -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";
|
||||
@@ -2806,7 +2806,7 @@ describe("Extension: Bridge", () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
await mockZHEvents.deviceLeave({ieeeAddr: devices.bulb.ieeeAddr});
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(3);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(4);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/event",
|
||||
stringify({type: "device_leave", data: {ieee_address: "0x000b57fffec6a5b2", friendly_name: "bulb"}}),
|
||||
@@ -2819,6 +2819,7 @@ describe("Extension: Bridge", () => {
|
||||
expect.any(String),
|
||||
{retain: true},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/groups", expect.any(String), {retain: true});
|
||||
});
|
||||
|
||||
it("Should allow permit join on all", async () => {
|
||||
@@ -2973,6 +2974,7 @@ describe("Extension: Bridge", () => {
|
||||
|
||||
it("Should allow to remove device by string", async () => {
|
||||
const device = devices.bulb;
|
||||
const removeSpy = vi.spyOn(controller.zigbee, "removeDeviceFromLookup");
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/remove", "bulb");
|
||||
await flushPromises();
|
||||
@@ -2981,11 +2983,12 @@ describe("Extension: Bridge", () => {
|
||||
expect(device.removeFromNetwork).toHaveBeenCalledTimes(1);
|
||||
expect(device.removeFromDatabase).not.toHaveBeenCalled();
|
||||
expect(settings.getDevice("bulb")).toBeUndefined();
|
||||
expect(removeSpy).not.toHaveBeenCalled();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb", "", {retain: true});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/remove",
|
||||
stringify({data: {id: "bulb", block: false, force: false}, status: "ok"}),
|
||||
stringify({data: {id: "bulb", block: false, force: false, clear_cache: false}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
expect(settings.get().blocklist).toStrictEqual([]);
|
||||
@@ -2995,52 +2998,76 @@ describe("Extension: Bridge", () => {
|
||||
|
||||
it("Should allow to remove device by object ID", async () => {
|
||||
const device = devices.bulb;
|
||||
const removeSpy = vi.spyOn(controller.zigbee, "removeDeviceFromLookup");
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/remove", stringify({id: "bulb"}));
|
||||
await flushPromises();
|
||||
expect(device.removeFromNetwork).toHaveBeenCalledTimes(1);
|
||||
expect(device.removeFromDatabase).not.toHaveBeenCalled();
|
||||
expect(settings.getDevice("bulb")).toBeUndefined();
|
||||
expect(removeSpy).not.toHaveBeenCalled();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/remove",
|
||||
stringify({data: {id: "bulb", block: false, force: false}, status: "ok"}),
|
||||
stringify({data: {id: "bulb", block: false, force: false, clear_cache: false}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("Should allow to force remove device", async () => {
|
||||
const device = devices.bulb;
|
||||
const removeSpy = vi.spyOn(controller.zigbee, "removeDeviceFromLookup");
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/remove", stringify({id: "bulb", force: true}));
|
||||
await flushPromises();
|
||||
expect(device.removeFromDatabase).toHaveBeenCalledTimes(1);
|
||||
expect(device.removeFromNetwork).not.toHaveBeenCalled();
|
||||
expect(settings.getDevice("bulb")).toBeUndefined();
|
||||
expect(removeSpy).not.toHaveBeenCalled();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/remove",
|
||||
stringify({data: {id: "bulb", block: false, force: true}, status: "ok"}),
|
||||
stringify({data: {id: "bulb", block: false, force: true, clear_cache: false}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("Should allow to block device", async () => {
|
||||
const device = devices.bulb;
|
||||
const removeSpy = vi.spyOn(controller.zigbee, "removeDeviceFromLookup");
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/remove", stringify({id: "bulb", block: true, force: true}));
|
||||
await flushPromises();
|
||||
expect(device.removeFromDatabase).toHaveBeenCalledTimes(1);
|
||||
expect(settings.getDevice("bulb")).toBeUndefined();
|
||||
expect(removeSpy).not.toHaveBeenCalled();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/remove",
|
||||
stringify({data: {id: "bulb", block: true, force: true}, status: "ok"}),
|
||||
stringify({data: {id: "bulb", block: true, force: true, clear_cache: false}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
expect(settings.get().blocklist).toStrictEqual(["0x000b57fffec6a5b2"]);
|
||||
});
|
||||
|
||||
it("Should allow to clear cache when removing device", async () => {
|
||||
const device = devices.bulb;
|
||||
const removeSpy = vi.spyOn(controller.zigbee, "removeDeviceFromLookup");
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/remove", stringify({id: "bulb", clear_cache: true}));
|
||||
await flushPromises();
|
||||
expect(device.removeFromNetwork).toHaveBeenCalledTimes(1);
|
||||
expect(device.removeFromDatabase).not.toHaveBeenCalled();
|
||||
expect(settings.getDevice("bulb")).toBeUndefined();
|
||||
expect(removeSpy).toHaveNthReturnedWith(1, true);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/remove",
|
||||
stringify({data: {id: "bulb", block: false, force: false, clear_cache: true}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("Should allow to remove group", async () => {
|
||||
const group = groups.group_1;
|
||||
const removeGroupFromLookup = vi.spyOn(controller.zigbee, "removeGroupFromLookup");
|
||||
@@ -3105,7 +3132,11 @@ describe("Extension: Bridge", () => {
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/device/remove",
|
||||
stringify({data: {}, status: "error", error: "Failed to remove device 'bulb' (block: false, force: false) (Error: device timeout)"}),
|
||||
stringify({
|
||||
data: {},
|
||||
status: "error",
|
||||
error: "Failed to remove device 'bulb' (block: false, force: false, clear cache: false) (Error: device timeout)",
|
||||
}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
@@ -4162,13 +4193,24 @@ describe("Extension: Bridge", () => {
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/backup", "");
|
||||
await flushPromises();
|
||||
expect(mockZHController.backup).toHaveBeenCalledTimes(1);
|
||||
expect(mockJSZipFile).toHaveBeenCalledTimes(4);
|
||||
expect(mockJSZipFile).toHaveBeenNthCalledWith(1, "configuration.yaml", expect.any(Object));
|
||||
expect(mockJSZipFile).toHaveBeenNthCalledWith(2, path.join("ext_converters", "123", "myfile.js"), expect.any(Object));
|
||||
expect(mockJSZipFile).toHaveBeenNthCalledWith(3, path.join("ext_converters", "afile.js"), expect.any(Object));
|
||||
expect(mockJSZipFile).toHaveBeenNthCalledWith(4, "state.json", expect.any(Object));
|
||||
expect(mockJSZipGenerateAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockJSZipGenerateAsync).toHaveBeenNthCalledWith(1, {type: "base64"});
|
||||
expect(mockFflateZip).toHaveBeenCalledTimes(1);
|
||||
expect(mockFflateZip).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{
|
||||
"configuration.yaml": expect.any(Buffer),
|
||||
[path.join("ext_converters", "123", "myfile.js")]: expect.any(Buffer),
|
||||
[path.join("ext_converters", "afile.js")]: expect.any(Buffer),
|
||||
"state.json": expect.any(Buffer),
|
||||
},
|
||||
{level: 6},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(Object.keys(mockFflateZip.mock.calls[0][0])).toStrictEqual([
|
||||
"configuration.yaml",
|
||||
path.join("ext_converters", "123", "myfile.js"),
|
||||
path.join("ext_converters", "afile.js"),
|
||||
"state.json",
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/backup",
|
||||
stringify({data: {zip: "THISISBASE64"}, status: "ok"}),
|
||||
@@ -4176,6 +4218,18 @@ describe("Extension: Bridge", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("Should return an error when the backup archive cannot be created", async () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockFflateZipFailOnce(new Error("invalid zip data"));
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/backup", "");
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/backup",
|
||||
stringify({data: {}, status: "error", error: "invalid zip data"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("Should allow to restart", async () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/restart", "");
|
||||
@@ -4325,6 +4379,58 @@ describe("Extension: Bridge", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("Change options consecutively, check restart required", async () => {
|
||||
settings.apply({health: {interval: 10, reset_on_check: false}});
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
// Change option that doesn't require restart
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/options", stringify({options: {health: {reset_on_check: true}}}));
|
||||
await flushPromises();
|
||||
|
||||
expect(settings.get().health.reset_on_check).toBe(true);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/options",
|
||||
stringify({data: {restart_required: false}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/info", expect.stringContaining('"restart_required":false'), {
|
||||
retain: true,
|
||||
});
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
// Change option that requires restart
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/options", stringify({options: {health: {interval: 11}}}));
|
||||
await flushPromises();
|
||||
|
||||
expect(settings.get().health.interval).toBe(11);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/options",
|
||||
stringify({data: {restart_required: true}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/info", expect.stringContaining('"restart_required":true'), {
|
||||
retain: true,
|
||||
});
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
// Change option that doesn't require restart
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/options", stringify({options: {health: {reset_on_check: false}}}));
|
||||
await flushPromises();
|
||||
|
||||
expect(settings.get().health.reset_on_check).toBe(false);
|
||||
|
||||
// System still requires restart
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/options",
|
||||
stringify({data: {restart_required: true}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/info", expect.stringContaining('"restart_required":true'), {
|
||||
retain: true,
|
||||
});
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
});
|
||||
|
||||
it("Icon link handling", () => {
|
||||
const bridge = controller.getExtension("Bridge")! as Bridge;
|
||||
expect(bridge).toBeDefined();
|
||||
|
||||
@@ -6,7 +6,7 @@ import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
|
||||
import {flushPromises} from "../mocks/utils";
|
||||
import {devices, type Endpoint, events as mockZHEvents, type Device as ZhDevice} from "../mocks/zigbeeHerdsman";
|
||||
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {stringify} from "../../lib/util/stringify";
|
||||
import {InterviewState} from "zigbee-herdsman/dist/controller/model/device";
|
||||
import {Controller} from "../../lib/controller";
|
||||
import Device from "../../lib/model/device";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -7,7 +7,7 @@ import {type EventHandler, flushPromises} from "../mocks/utils";
|
||||
import {devices, events as mockZHEvents} from "../mocks/zigbeeHerdsman";
|
||||
|
||||
import path from "node:path";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {stringify} from "../../lib/util/stringify";
|
||||
import type {Mock} from "vitest";
|
||||
import ws from "ws";
|
||||
import {Controller} from "../../lib/controller";
|
||||
|
||||
@@ -6,7 +6,7 @@ import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
|
||||
import {flushPromises} from "../mocks/utils";
|
||||
import {devices, groups, events as mockZHEvents, resetGroupMembers, returnDevices} from "../mocks/zigbeeHerdsman";
|
||||
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {stringify} from "../../lib/util/stringify";
|
||||
import * as zhcGlobalStore from "zigbee-herdsman-converters/lib/store";
|
||||
import {Controller} from "../../lib/controller";
|
||||
import * as settings from "../../lib/util/settings";
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {Device as ZhDevice} from "../mocks/zigbeeHerdsman";
|
||||
import {devices, groups, events as mockZHEvents} from "../mocks/zigbeeHerdsman";
|
||||
|
||||
import assert from "node:assert";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {stringify} from "../../lib/util/stringify";
|
||||
import type {MockInstance} from "vitest";
|
||||
import * as zhc from "zigbee-herdsman-converters";
|
||||
import type {KeyValueAny} from "zigbee-herdsman-converters/lib/types";
|
||||
@@ -1699,6 +1699,62 @@ describe("Extension: HomeAssistant", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("Should discover an infrared emitter entity", () => {
|
||||
const infraredEmitterExpose = new zhc.Text("emitter", zhc.access.SET).withHomeAssistant({
|
||||
type: "infrared",
|
||||
schema: "emitter",
|
||||
valueTemplate: null,
|
||||
});
|
||||
const device = {
|
||||
definition: {},
|
||||
isDevice: (): boolean => true,
|
||||
isGroup: (): boolean => false,
|
||||
endpoint: () => undefined,
|
||||
options: {},
|
||||
exposes: (): zhc.Expose[] => [infraredEmitterExpose],
|
||||
zh: {endpoints: []},
|
||||
} as Device;
|
||||
|
||||
// @ts-expect-error private
|
||||
const configs = extension.getConfigs(device);
|
||||
const infrared = configs.find((c) => c.type === "infrared");
|
||||
expect(infrared).toBeDefined();
|
||||
expect(infrared!.discovery_payload).toMatchObject({
|
||||
name: "Emitter",
|
||||
schema: "emitter",
|
||||
command_topic: true,
|
||||
state_topic: 0,
|
||||
});
|
||||
expect(infrared!.discovery_payload).not.toHaveProperty("value_template");
|
||||
});
|
||||
|
||||
it("Should discover an infrared receiver entity", () => {
|
||||
const infraredReceiverExpose = new zhc.Text("receiver", zhc.access.STATE).withHomeAssistant({
|
||||
type: "infrared",
|
||||
schema: "receiver",
|
||||
valueTemplate: "{{ json_value.emitter }}",
|
||||
});
|
||||
const device = {
|
||||
definition: {},
|
||||
isDevice: (): boolean => true,
|
||||
isGroup: (): boolean => false,
|
||||
endpoint: () => undefined,
|
||||
options: {},
|
||||
exposes: (): zhc.Expose[] => [infraredReceiverExpose],
|
||||
zh: {endpoints: []},
|
||||
} as Device;
|
||||
|
||||
// @ts-expect-error private
|
||||
const configs = extension.getConfigs(device);
|
||||
const infrared = configs.find((c) => c.type === "infrared");
|
||||
expect(infrared).toBeDefined();
|
||||
expect(infrared!.discovery_payload).toMatchObject({
|
||||
name: "Receiver",
|
||||
schema: "receiver",
|
||||
});
|
||||
expect(infrared!.discovery_payload).toHaveProperty("value_template");
|
||||
});
|
||||
|
||||
it("Should discover devices with custom homeassistant.discovery_topic", async () => {
|
||||
settings.set(["homeassistant", "discovery_topic"], "my_custom_discovery_topic");
|
||||
await resetExtension();
|
||||
@@ -1739,7 +1795,7 @@ describe("Extension: HomeAssistant", () => {
|
||||
|
||||
await expect(async () => {
|
||||
await controller.start();
|
||||
}).rejects.toThrow("Home Assistant integration is not possible with attribute output!");
|
||||
}).rejects.toThrow("Home Assistant integration requires 'output: json' under 'advanced'");
|
||||
});
|
||||
|
||||
it("Should throw error when homeassistant.discovery_topic equals the mqtt.base_topic", async () => {
|
||||
@@ -1755,7 +1811,9 @@ describe("Extension: HomeAssistant", () => {
|
||||
settings.set(["advanced", "cache_state"], false);
|
||||
mockLogger.warning.mockClear();
|
||||
await resetExtension();
|
||||
expect(mockLogger.warning).toHaveBeenCalledWith("In order for Home Assistant integration to work properly set `cache_state: true");
|
||||
expect(mockLogger.warning).toHaveBeenCalledWith(
|
||||
"In order for Home Assistant integration to work properly, set `cache_state: true` under `advanced`",
|
||||
);
|
||||
});
|
||||
|
||||
it("Should set missing values to null", async () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as mockSleep from "../mocks/sleep";
|
||||
import {flushPromises} from "../mocks/utils";
|
||||
import {devices, groups, events as mockZHEvents} from "../mocks/zigbeeHerdsman";
|
||||
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {stringify} from "../../lib/util/stringify";
|
||||
import {clearGlobalStore} from "zigbee-herdsman-converters";
|
||||
import {Controller} from "../../lib/controller";
|
||||
import {loadTopicGetSetRegex} from "../../lib/extension/publish";
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as mockSleep from "../mocks/sleep";
|
||||
import {flushPromises} from "../mocks/utils";
|
||||
import {devices, events as mockZHEvents} from "../mocks/zigbeeHerdsman";
|
||||
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import {stringify} from "../../lib/util/stringify";
|
||||
import {Controller} from "../../lib/controller";
|
||||
import * as settings from "../../lib/util/settings";
|
||||
|
||||
|
||||
+13
-15
@@ -5,20 +5,13 @@ import * as data from "./mocks/data";
|
||||
import fs from "node:fs";
|
||||
import {platform} from "node:os";
|
||||
import path from "node:path";
|
||||
import {rimrafSync} from "rimraf";
|
||||
import tmp from "tmp";
|
||||
import type {MockInstance} from "vitest";
|
||||
import Transport from "winston-transport";
|
||||
import logger from "../lib/util/logger";
|
||||
import * as settings from "../lib/util/settings";
|
||||
|
||||
vi.mock("rimraf", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("rimraf")>();
|
||||
return {
|
||||
...actual,
|
||||
rimrafSync: vi.fn(actual.rimrafSync),
|
||||
};
|
||||
});
|
||||
const rmSync = (target: string): void => fs.rmSync(target, {recursive: true, force: true});
|
||||
|
||||
describe("Logger", () => {
|
||||
let consoleWriteSpy: MockInstance;
|
||||
@@ -56,7 +49,7 @@ describe("Logger", () => {
|
||||
|
||||
it("Should cleanup (default setting)", () => {
|
||||
for (const d of fs.readdirSync(dir.name)) {
|
||||
rimrafSync(path.join(dir.name, d));
|
||||
rmSync(path.join(dir.name, d));
|
||||
}
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
@@ -70,25 +63,30 @@ describe("Logger", () => {
|
||||
|
||||
it("Should handle cleanup error", () => {
|
||||
for (const d of fs.readdirSync(dir.name)) {
|
||||
rimrafSync(path.join(dir.name, d));
|
||||
rmSync(path.join(dir.name, d));
|
||||
}
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
fs.mkdirSync(path.join(dir.name, `log_${i}`));
|
||||
}
|
||||
|
||||
vi.mocked(rimrafSync).mockImplementationOnce(() => {
|
||||
const rmSyncSpy = vi.spyOn(fs, "rmSync").mockImplementationOnce(() => {
|
||||
throw new Error("EACCES: permission denied");
|
||||
});
|
||||
|
||||
const errorSpy = vi.spyOn(logger, "error");
|
||||
logger.init();
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringMatching(/Failed to remove old log directory '.*': Error: EACCES: permission denied/));
|
||||
|
||||
try {
|
||||
logger.init();
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringMatching(/Failed to remove old log directory '.*': Error: EACCES: permission denied/));
|
||||
} finally {
|
||||
rmSyncSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("Should cleanup (15 folders setting)", () => {
|
||||
for (const d of fs.readdirSync(dir.name)) {
|
||||
rimrafSync(path.join(dir.name, d));
|
||||
rmSync(path.join(dir.name, d));
|
||||
}
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
@@ -103,7 +101,7 @@ describe("Logger", () => {
|
||||
|
||||
it("Should not cleanup when there is no timestamp set", () => {
|
||||
for (const d of fs.readdirSync(dir.name)) {
|
||||
rimrafSync(path.join(dir.name, d));
|
||||
rmSync(path.join(dir.name, d));
|
||||
}
|
||||
|
||||
for (let i = 30; i < 50; i++) {
|
||||
|
||||
+1
-1
@@ -1,8 +1,8 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import tmp from "tmp";
|
||||
import {vi} from "vitest";
|
||||
import {stringify} from "../../lib/util/stringify";
|
||||
import yaml from "../../lib/util/yaml";
|
||||
|
||||
export const mockDir: string = tmp.dirSync().name;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type {AsyncZipOptions, AsyncZippable, FlateError} from "fflate";
|
||||
|
||||
/** `THISISBASE64` is valid base64, so it round-trips through `Buffer.from(...).toString("base64")` */
|
||||
export const mockFflateZipContent = Uint8Array.from(Buffer.from("THISISBASE64", "base64"));
|
||||
|
||||
export const mockFflateZip = vi.fn((_data: AsyncZippable, _opts: AsyncZipOptions, cb: (error: FlateError | null, data: Uint8Array) => void): void => {
|
||||
cb(null, mockFflateZipContent);
|
||||
});
|
||||
|
||||
/** Makes the next `zip` call report the given error through its callback */
|
||||
export const mockFflateZipFailOnce = (error: Error): void => {
|
||||
mockFflateZip.mockImplementationOnce((_data, _opts, cb) => {
|
||||
cb(error as FlateError, new Uint8Array());
|
||||
});
|
||||
};
|
||||
|
||||
vi.mock("fflate", () => ({
|
||||
zip: mockFflateZip,
|
||||
}));
|
||||
@@ -1,11 +0,0 @@
|
||||
export const mockJSZipFile = vi.fn();
|
||||
export const mockJSZipGenerateAsync = vi.fn().mockReturnValue("THISISBASE64");
|
||||
|
||||
vi.mock("jszip", () => ({
|
||||
default: vi.fn().mockImplementation(() => {
|
||||
return {
|
||||
file: mockJSZipFile,
|
||||
generateAsync: mockJSZipGenerateAsync,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
Vendored
-4
@@ -1,7 +1,3 @@
|
||||
declare module "json-stable-stringify-without-jsonify" {
|
||||
export default function (obj: unknown): string;
|
||||
}
|
||||
|
||||
declare module "tmp" {
|
||||
export function dirSync(): {
|
||||
name: string;
|
||||
|
||||
+105
-184
@@ -5,7 +5,7 @@ import * as data from "./mocks/data";
|
||||
import {readFileSync, rmSync, writeFileSync} from "node:fs";
|
||||
import {join} from "node:path";
|
||||
import type {IncomingMessage, OutgoingHttpHeader, OutgoingHttpHeaders, RequestListener, Server, ServerResponse} from "node:http";
|
||||
import JSZip from "jszip";
|
||||
import {zipSync} from "fflate";
|
||||
import type {findAllDevices} from "zigbee-herdsman/dist/adapter/adapterDiscovery";
|
||||
import type {OnboardFailureData, OnboardInitData, OnboardSubmitResponse} from "../lib/types/api";
|
||||
import {onboard} from "../lib/util/onboarding";
|
||||
@@ -540,205 +540,132 @@ describe("Onboarding", () => {
|
||||
await responsePromise;
|
||||
};
|
||||
|
||||
const createZipRestore = (): Awaited<ReturnType<typeof JSZip.loadAsync>> => {
|
||||
return {
|
||||
files: {
|
||||
"configuration.yaml": {
|
||||
name: "configuration.yaml",
|
||||
dir: false,
|
||||
// @ts-expect-error minimal mock
|
||||
async: async () => await Promise.resolve(Buffer.from(JSON.stringify(SAMPLE_SETTINGS_SAVE))),
|
||||
},
|
||||
// @ts-expect-error minimal mock
|
||||
"nested/": {
|
||||
name: "nested/",
|
||||
dir: true,
|
||||
},
|
||||
"nested/notes.txt": {
|
||||
name: "nested/notes.txt",
|
||||
dir: false,
|
||||
// @ts-expect-error minimal mock
|
||||
async: async () => await Promise.resolve(Buffer.from("zip-restore")),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
const createZipPayload = (files: Parameters<typeof zipSync>[0]): string => Buffer.from(zipSync(files)).toString("base64");
|
||||
|
||||
const createZipRestore = (): string =>
|
||||
createZipPayload({
|
||||
"configuration.yaml": Buffer.from(JSON.stringify(SAMPLE_SETTINGS_SAVE)),
|
||||
nested: {"notes.txt": Buffer.from("zip-restore")},
|
||||
});
|
||||
|
||||
it("extracts uploaded ZIP files into the data path", async () => {
|
||||
data.removeConfiguration();
|
||||
const loadAsyncSpy = vi.spyOn(JSZip, "loadAsync").mockResolvedValue(createZipRestore());
|
||||
|
||||
try {
|
||||
let p;
|
||||
const submitData = await new Promise<OnboardSubmitResponse>((resolve, reject) => {
|
||||
mockHttpOnListen.mockImplementationOnce(async () => {
|
||||
try {
|
||||
resolve(await submitZipPayload(Buffer.from("zip").toString("base64"), false, false));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
p = onboard();
|
||||
let p;
|
||||
const submitData = await new Promise<OnboardSubmitResponse>((resolve, reject) => {
|
||||
mockHttpOnListen.mockImplementationOnce(async () => {
|
||||
try {
|
||||
resolve(await submitZipPayload(createZipRestore(), false, false));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
await expect(p).resolves.toStrictEqual(true);
|
||||
expect(data.read()).toStrictEqual(SAMPLE_SETTINGS_SAVE);
|
||||
expect(readFileSync(join(data.mockDir, "nested", "notes.txt"), "utf8")).toStrictEqual("zip-restore");
|
||||
expect(loadAsyncSpy).toHaveBeenCalledTimes(1);
|
||||
expect(submitData).toStrictEqual({success: true, frontendUrl: null});
|
||||
} finally {
|
||||
loadAsyncSpy.mockRestore();
|
||||
}
|
||||
p = onboard();
|
||||
});
|
||||
|
||||
await expect(p).resolves.toStrictEqual(true);
|
||||
expect(data.read()).toStrictEqual(SAMPLE_SETTINGS_SAVE);
|
||||
expect(readFileSync(join(data.mockDir, "nested", "notes.txt"), "utf8")).toStrictEqual("zip-restore");
|
||||
expect(submitData).toStrictEqual({success: true, frontendUrl: null});
|
||||
});
|
||||
|
||||
it("rejects non-zip upload payloads", async () => {
|
||||
data.removeConfiguration();
|
||||
const loadAsyncSpy = vi
|
||||
.spyOn(JSZip, "loadAsync")
|
||||
.mockRejectedValueOnce(new Error("Can't find end of central directory : is this a zip file ?"))
|
||||
.mockResolvedValueOnce(createZipRestore());
|
||||
|
||||
try {
|
||||
let p;
|
||||
const [firstSubmitData, secondSubmitData] = await new Promise<[OnboardSubmitResponse, OnboardSubmitResponse]>((resolve, reject) => {
|
||||
mockHttpOnListen.mockImplementationOnce(async () => {
|
||||
try {
|
||||
const failedSubmit = await submitZipPayload(Buffer.from("ignored").toString("base64"), true, false);
|
||||
const successfulSubmit = await submitZipPayload(Buffer.from("zip").toString("base64"), false, false);
|
||||
let p;
|
||||
const [firstSubmitData, secondSubmitData] = await new Promise<[OnboardSubmitResponse, OnboardSubmitResponse]>((resolve, reject) => {
|
||||
mockHttpOnListen.mockImplementationOnce(async () => {
|
||||
try {
|
||||
const failedSubmit = await submitZipPayload(Buffer.from("not-a-zip-file").toString("base64"), true, false);
|
||||
const successfulSubmit = await submitZipPayload(createZipRestore(), false, false);
|
||||
|
||||
resolve([failedSubmit, successfulSubmit]);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
p = onboard();
|
||||
resolve([failedSubmit, successfulSubmit]);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
await expect(p).resolves.toStrictEqual(true);
|
||||
expect(loadAsyncSpy).toHaveBeenCalledTimes(2);
|
||||
expect(data.read()).toStrictEqual(SAMPLE_SETTINGS_SAVE);
|
||||
expect(readFileSync(join(data.mockDir, "nested", "notes.txt"), "utf8")).toStrictEqual("zip-restore");
|
||||
expect(firstSubmitData).toStrictEqual({success: false, error: expect.stringContaining("is this a zip file")});
|
||||
expect(secondSubmitData).toStrictEqual({success: true, frontendUrl: null});
|
||||
} finally {
|
||||
loadAsyncSpy.mockRestore();
|
||||
}
|
||||
p = onboard();
|
||||
});
|
||||
|
||||
await expect(p).resolves.toStrictEqual(true);
|
||||
expect(data.read()).toStrictEqual(SAMPLE_SETTINGS_SAVE);
|
||||
expect(readFileSync(join(data.mockDir, "nested", "notes.txt"), "utf8")).toStrictEqual("zip-restore");
|
||||
expect(firstSubmitData).toStrictEqual({success: false, error: expect.stringContaining("invalid zip data")});
|
||||
expect(secondSubmitData).toStrictEqual({success: true, frontendUrl: null});
|
||||
});
|
||||
|
||||
it("rejects ZIP upload payloads with invalid entry paths", async () => {
|
||||
data.removeConfiguration();
|
||||
const loadAsyncSpy = vi
|
||||
.spyOn(JSZip, "loadAsync")
|
||||
.mockResolvedValueOnce({
|
||||
files: {
|
||||
"/dragons.txt": {
|
||||
name: "/dragons.txt",
|
||||
dir: false,
|
||||
// @ts-expect-error minimal mock
|
||||
async: async () => await Promise.resolve(Buffer.from("dragons")),
|
||||
},
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce(createZipRestore());
|
||||
|
||||
try {
|
||||
let p;
|
||||
const [firstSubmitData, secondSubmitData] = await new Promise<[OnboardSubmitResponse, OnboardSubmitResponse]>((resolve, reject) => {
|
||||
mockHttpOnListen.mockImplementationOnce(async () => {
|
||||
try {
|
||||
const failedSubmit = await submitZipPayload(Buffer.from("zip-invalid-path").toString("base64"), true, false);
|
||||
const successfulSubmit = await submitZipPayload(Buffer.from("zip").toString("base64"), false, false);
|
||||
let p;
|
||||
const [firstSubmitData, secondSubmitData] = await new Promise<[OnboardSubmitResponse, OnboardSubmitResponse]>((resolve, reject) => {
|
||||
mockHttpOnListen.mockImplementationOnce(async () => {
|
||||
try {
|
||||
const failedSubmit = await submitZipPayload(createZipPayload({"/dragons.txt": Buffer.from("dragons")}), true, false);
|
||||
const successfulSubmit = await submitZipPayload(createZipRestore(), false, false);
|
||||
|
||||
resolve([failedSubmit, successfulSubmit]);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
p = onboard();
|
||||
resolve([failedSubmit, successfulSubmit]);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
await expect(p).resolves.toStrictEqual(true);
|
||||
expect(firstSubmitData).toStrictEqual({success: false, error: expect.stringContaining("Invalid ZIP entry path")});
|
||||
expect(secondSubmitData).toStrictEqual({success: true, frontendUrl: null});
|
||||
expect(loadAsyncSpy).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
loadAsyncSpy.mockRestore();
|
||||
}
|
||||
p = onboard();
|
||||
});
|
||||
|
||||
await expect(p).resolves.toStrictEqual(true);
|
||||
expect(firstSubmitData).toStrictEqual({success: false, error: expect.stringContaining("Invalid ZIP entry path")});
|
||||
expect(secondSubmitData).toStrictEqual({success: true, frontendUrl: null});
|
||||
});
|
||||
|
||||
it("rejects ZIP upload payloads with unsafe relative entry paths", async () => {
|
||||
data.removeConfiguration();
|
||||
const loadAsyncSpy = vi
|
||||
.spyOn(JSZip, "loadAsync")
|
||||
.mockResolvedValueOnce({
|
||||
files: {
|
||||
"../dragons.txt": {
|
||||
name: "../dragons.txt",
|
||||
dir: false,
|
||||
// @ts-expect-error minimal mock
|
||||
async: async () => await Promise.resolve(Buffer.from("dragons")),
|
||||
},
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce(createZipRestore());
|
||||
|
||||
try {
|
||||
let p;
|
||||
const [firstSubmitData, secondSubmitData] = await new Promise<[OnboardSubmitResponse, OnboardSubmitResponse]>((resolve, reject) => {
|
||||
mockHttpOnListen.mockImplementationOnce(async () => {
|
||||
try {
|
||||
const failedSubmit = await submitZipPayload(Buffer.from("zip-unsafe-path").toString("base64"), true, false);
|
||||
const successfulSubmit = await submitZipPayload(Buffer.from("zip").toString("base64"), false, false);
|
||||
let p;
|
||||
const [firstSubmitData, secondSubmitData] = await new Promise<[OnboardSubmitResponse, OnboardSubmitResponse]>((resolve, reject) => {
|
||||
mockHttpOnListen.mockImplementationOnce(async () => {
|
||||
try {
|
||||
const failedSubmit = await submitZipPayload(createZipPayload({"../dragons.txt": Buffer.from("dragons")}), true, false);
|
||||
const successfulSubmit = await submitZipPayload(createZipRestore(), false, false);
|
||||
|
||||
resolve([failedSubmit, successfulSubmit]);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
p = onboard();
|
||||
resolve([failedSubmit, successfulSubmit]);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
await expect(p).resolves.toStrictEqual(true);
|
||||
expect(firstSubmitData).toStrictEqual({success: false, error: expect.stringContaining("Unsafe ZIP entry path")});
|
||||
expect(secondSubmitData).toStrictEqual({success: true, frontendUrl: null});
|
||||
expect(loadAsyncSpy).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
loadAsyncSpy.mockRestore();
|
||||
}
|
||||
p = onboard();
|
||||
});
|
||||
|
||||
await expect(p).resolves.toStrictEqual(true);
|
||||
expect(firstSubmitData).toStrictEqual({success: false, error: expect.stringContaining("Unsafe ZIP entry path")});
|
||||
expect(secondSubmitData).toStrictEqual({success: true, frontendUrl: null});
|
||||
});
|
||||
|
||||
it("handles empty ZIP upload payloads", async () => {
|
||||
data.removeConfiguration();
|
||||
const loadAsyncSpy = vi.spyOn(JSZip, "loadAsync").mockResolvedValue(createZipRestore());
|
||||
|
||||
try {
|
||||
let p;
|
||||
const [firstSubmitData, secondSubmitData] = await new Promise<[OnboardSubmitResponse, OnboardSubmitResponse]>((resolve, reject) => {
|
||||
mockHttpOnListen.mockImplementationOnce(async () => {
|
||||
try {
|
||||
const failedSubmit = await submitZipPayload("", true, false);
|
||||
const successfulSubmit = await submitZipPayload(Buffer.from("zip").toString("base64"), false, false);
|
||||
let p;
|
||||
const [firstSubmitData, secondSubmitData] = await new Promise<[OnboardSubmitResponse, OnboardSubmitResponse]>((resolve, reject) => {
|
||||
mockHttpOnListen.mockImplementationOnce(async () => {
|
||||
try {
|
||||
const failedSubmit = await submitZipPayload("", true, false);
|
||||
const successfulSubmit = await submitZipPayload(createZipRestore(), false, false);
|
||||
|
||||
resolve([failedSubmit, successfulSubmit]);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
p = onboard();
|
||||
resolve([failedSubmit, successfulSubmit]);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
await expect(p).resolves.toStrictEqual(true);
|
||||
expect(firstSubmitData).toStrictEqual({success: false, error: "Invalid ZIP payload: missing content"});
|
||||
expect(secondSubmitData).toStrictEqual({success: true, frontendUrl: null});
|
||||
expect(loadAsyncSpy).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
loadAsyncSpy.mockRestore();
|
||||
}
|
||||
p = onboard();
|
||||
});
|
||||
|
||||
await expect(p).resolves.toStrictEqual(true);
|
||||
expect(firstSubmitData).toStrictEqual({success: false, error: "Invalid ZIP payload: missing content"});
|
||||
expect(secondSubmitData).toStrictEqual({success: true, frontendUrl: null});
|
||||
});
|
||||
|
||||
it("handles request stream errors for submit endpoint", async () => {
|
||||
@@ -767,32 +694,26 @@ describe("Onboarding", () => {
|
||||
|
||||
it("handles request stream errors for submit-zip endpoint", async () => {
|
||||
data.removeConfiguration();
|
||||
const loadAsyncSpy = vi.spyOn(JSZip, "loadAsync").mockResolvedValue(createZipRestore());
|
||||
|
||||
try {
|
||||
let p;
|
||||
const [firstSubmitData, secondSubmitData] = await new Promise<[OnboardSubmitResponse, OnboardSubmitResponse]>((resolve, reject) => {
|
||||
mockHttpOnListen.mockImplementationOnce(async () => {
|
||||
try {
|
||||
const failedSubmit = await submitZipPayload("", true, true);
|
||||
const successfulSubmit = await submitZipPayload(Buffer.from("zip").toString("base64"), false, false);
|
||||
let p;
|
||||
const [firstSubmitData, secondSubmitData] = await new Promise<[OnboardSubmitResponse, OnboardSubmitResponse]>((resolve, reject) => {
|
||||
mockHttpOnListen.mockImplementationOnce(async () => {
|
||||
try {
|
||||
const failedSubmit = await submitZipPayload("", true, true);
|
||||
const successfulSubmit = await submitZipPayload(createZipRestore(), false, false);
|
||||
|
||||
resolve([failedSubmit, successfulSubmit]);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
p = onboard();
|
||||
resolve([failedSubmit, successfulSubmit]);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
await expect(p).resolves.toStrictEqual(true);
|
||||
expect(firstSubmitData).toStrictEqual({success: false, error: "request error submit-zip"});
|
||||
expect(secondSubmitData).toStrictEqual({success: true, frontendUrl: null});
|
||||
expect(loadAsyncSpy).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
loadAsyncSpy.mockRestore();
|
||||
}
|
||||
p = onboard();
|
||||
});
|
||||
|
||||
await expect(p).resolves.toStrictEqual(true);
|
||||
expect(firstSubmitData).toStrictEqual({success: false, error: "request error submit-zip"});
|
||||
expect(secondSubmitData).toStrictEqual({success: true, frontendUrl: null});
|
||||
});
|
||||
|
||||
it("passes unknown onboarding routes to static file server", async () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import {exec} from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {describe, expect, it, vi} from "vitest";
|
||||
import {stringify} from "../lib/util/stringify";
|
||||
import utils, {assertString} from "../lib/util/utils";
|
||||
|
||||
// keep the implementations, just spy
|
||||
@@ -155,4 +156,37 @@ describe("Utils", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("stable stringify", () => {
|
||||
expect(
|
||||
stringify({
|
||||
a: "a",
|
||||
b: 2,
|
||||
3: "c",
|
||||
d: Buffer.from([1, 2]),
|
||||
e: new Int16Array([0xfffd, 0xff11]),
|
||||
beef: 0xfacen,
|
||||
zed: new BigUint64Array([1n, 0xffffffffn]),
|
||||
ris: [1, undefined, "b", 0xfeefn, Number.NaN, undefined],
|
||||
ls: undefined,
|
||||
}),
|
||||
).toStrictEqual(
|
||||
`{"3":"c","a":"a","b":2,"beef":"64206","d":{"data":[1,2],"type":"Buffer"},"e":{"0":-3,"1":-239},"ris":[1,null,"b","65263",null,null],"zed":{"0":"1","1":"4294967295"}}`,
|
||||
);
|
||||
// @ts-expect-error intentional to reach code for coverage
|
||||
expect(stringify(undefined)).toStrictEqual("null");
|
||||
|
||||
const circularObj: Record<string, unknown> = {a: 1, b: undefined};
|
||||
circularObj.b = circularObj;
|
||||
|
||||
expect(stringify(circularObj)).toStrictEqual(`{"a":1,"b":"[Circular]"}`);
|
||||
|
||||
const toJSONIsString = {a: 1, toJSON: () => `{"a":1}`};
|
||||
|
||||
expect(stringify(toJSONIsString)).toStrictEqual(`"{\\"a\\":1}"`);
|
||||
|
||||
const toJSONIsNull = {a: 1, toJSON: () => null};
|
||||
|
||||
expect(stringify(toJSONIsNull)).toStrictEqual("null");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user