mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-08-29 15:18:41 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba337bd329 | ||
|
|
7e7057eccd | ||
|
|
cbcd32064c | ||
|
|
d2d7ebeed9 | ||
|
|
9f72c5c455 | ||
|
|
e050e209b0 | ||
|
|
29d25aed66 | ||
|
|
063aba1b6c | ||
|
|
e8031efd2d | ||
|
|
102857a7fe | ||
|
|
db7b51ac80 | ||
|
|
d2166f2f4f | ||
|
|
07145accba | ||
|
|
44f9fe7a14 | ||
|
|
d522d2cd18 | ||
|
|
4e650b72cd | ||
|
|
a6072adcd0 | ||
|
|
0d193ebac2 | ||
|
|
ede1a470dd | ||
|
|
24779e3476 | ||
|
|
67e1a02525 | ||
|
|
7ce5b581aa | ||
|
|
40f41477e7 | ||
|
|
382c89c49e | ||
|
|
7d63a9a0f2 | ||
|
|
93af062a9b | ||
|
|
758c56647d | ||
|
|
845e03e48a | ||
|
|
dec5ca8b3a | ||
|
|
1e656c2114 | ||
|
|
ba76533c2f | ||
|
|
30d13d6e5e | ||
|
|
49fe147921 | ||
|
|
7a0964ebc9 | ||
|
|
b35cdc3474 | ||
|
|
5e55ef0454 | ||
|
|
3e3cefff1b | ||
|
|
97c74852c9 | ||
|
|
8542b35706 | ||
|
|
1eda69a50a | ||
|
|
16cecd917e | ||
|
|
1accb8b66b | ||
|
|
a3682b5fc7 | ||
|
|
cfea8b76e2 | ||
|
|
9e28818c53 | ||
|
|
bfbbfcda37 | ||
|
|
e4e4f16ec8 | ||
|
|
75c3c5e57f | ||
|
|
6a15328001 | ||
|
|
b960775160 | ||
|
|
41ba32db49 | ||
|
|
83ff4a7684 | ||
|
|
98b5a60886 | ||
|
|
b34723db36 | ||
|
|
5e9d53f0aa | ||
|
|
8c7961de1f | ||
|
|
ff466f817f | ||
|
|
c7a7c3d8c2 | ||
|
|
7540a0d02f | ||
|
|
33d5f0595d | ||
|
|
c4510d7382 | ||
|
|
e670524241 | ||
|
|
75f8ed07ed | ||
|
|
0706a5e039 | ||
|
|
32329695d0 | ||
|
|
cdbd55320a | ||
|
|
7b199f898e | ||
|
|
0462b94f5a | ||
|
|
85460cd5ce | ||
|
|
420a9f2ff6 | ||
|
|
5cf4547fe0 | ||
|
|
2af65cf28e | ||
|
|
e9f74f0958 | ||
|
|
2567b4c39e | ||
|
|
ac1671609c | ||
|
|
8da5fa663d | ||
|
|
3d3c68b54a | ||
|
|
5e60f1dc8d | ||
|
|
a1f74a312a | ||
|
|
b21167b326 | ||
|
|
be17e698a1 | ||
|
|
7392557af2 | ||
|
|
360be57079 | ||
|
|
361f9eef8b | ||
|
|
5b82d7e4df | ||
|
|
9daeba1f0a | ||
|
|
2d5e7bf9ff | ||
|
|
b5bef45cb2 | ||
|
|
f1f73bd464 | ||
|
|
23b709cee2 | ||
|
|
31bdf02a72 | ||
|
|
26ef565c8a | ||
|
|
6f3524b251 | ||
|
|
318dc04132 | ||
|
|
bf354f2121 | ||
|
|
1667ba64ac | ||
|
|
a28a5baf17 | ||
|
|
c33946ffc3 | ||
|
|
0d30eb553b | ||
|
|
43a5b24dcc | ||
|
|
3c46cfb2e9 | ||
|
|
7972950c84 | ||
|
|
060ae99cff | ||
|
|
b7847d8203 | ||
|
|
c586df8242 | ||
|
|
7cf01e9882 | ||
|
|
2d6b00bc69 | ||
|
|
9055eccae9 | ||
|
|
2046c05fd4 | ||
|
|
902db5ea5b | ||
|
|
60c37c11d0 | ||
|
|
61864f0fb2 | ||
|
|
240ab43f2d | ||
|
|
2713125768 | ||
|
|
ed75553603 | ||
|
|
e48cd22ab7 | ||
|
|
1e7e00e8dd | ||
|
|
02666e9139 | ||
|
|
47d73eb23a | ||
|
|
120a5b43e9 | ||
|
|
e8a5521a4c | ||
|
|
329b8c9c27 | ||
|
|
0e19c227fc | ||
|
|
e868a38c09 | ||
|
|
301cb331d5 | ||
|
|
bcec40f84a | ||
|
|
62e18a65f3 | ||
|
|
b8e6385e05 | ||
|
|
b65cc0ee74 | ||
|
|
eaafc7c28a | ||
|
|
2128e8323c | ||
|
|
06f44533af | ||
|
|
5d059b7bcb | ||
|
|
6eae15fb5e | ||
|
|
2abf55ccf8 | ||
|
|
803e9060ea | ||
|
|
7170215e96 | ||
|
|
758ab34747 | ||
|
|
c3b4628a1d | ||
|
|
e7f8de1e94 | ||
|
|
1c75eef16d | ||
|
|
c88f630299 | ||
|
|
ef6524b93f | ||
|
|
9822c75497 | ||
|
|
56746ded19 | ||
|
|
0b62aefbb2 | ||
|
|
e234f42e1f | ||
|
|
72428897a7 | ||
|
|
39c007d211 | ||
|
|
5e71cf2cc8 | ||
|
|
146b6c3860 | ||
|
|
2b08c688d3 | ||
|
|
0a20e4a455 | ||
|
|
08ac795bb3 | ||
|
|
a2027d79e8 | ||
|
|
1dde41f7ea | ||
|
|
e23d300fd9 | ||
|
|
32c341c7f5 | ||
|
|
9eea58eba4 | ||
|
|
ec13ebd3ad | ||
|
|
9fad883ae7 | ||
|
|
bc6eccf615 | ||
|
|
6905c10a05 | ||
|
|
1d176f4358 | ||
|
|
226945cae0 | ||
|
|
7d63a87aad | ||
|
|
f44ea32bca | ||
|
|
7803a166d2 | ||
|
|
ba45445544 | ||
|
|
1a9c79bb2f | ||
|
|
d891320314 | ||
|
|
639e3bb6fd | ||
|
|
7dc92d4b6c | ||
|
|
0687195aca | ||
|
|
0affca4fe4 | ||
|
|
f8ff89d442 | ||
|
|
66404cc9f4 | ||
|
|
d943b31372 | ||
|
|
b370b80dfb | ||
|
|
51f364a1d5 | ||
|
|
53208ae06f | ||
|
|
9227e139de | ||
|
|
71e163fcc3 | ||
|
|
5b907f2f77 | ||
|
|
acba533397 | ||
|
|
9bd20afb9c | ||
|
|
32e10d89f4 | ||
|
|
80b00b6254 | ||
|
|
c2c6b1991c | ||
|
|
1f56496869 | ||
|
|
b581dc6994 | ||
|
|
1ce45c8c41 | ||
|
|
a6b36051f0 | ||
|
|
d78c6facab | ||
|
|
8d58048f34 | ||
|
|
f54719fb89 | ||
|
|
9907005256 | ||
|
|
cc61f67215 | ||
|
|
23d8cea4e2 | ||
|
|
8aef77e57e | ||
|
|
c6958c4991 | ||
|
|
e052988663 | ||
|
|
563514c446 | ||
|
|
95279b02af | ||
|
|
e290057e21 | ||
|
|
61cf5094c7 | ||
|
|
9537ef05d5 | ||
|
|
3f76586a50 | ||
|
|
6f6e7c3ee0 | ||
|
|
2124d3450c | ||
|
|
b6186623f0 | ||
|
|
3220b27c8a | ||
|
|
98b977030e | ||
|
|
1fdc427358 | ||
|
|
9a74b015dc | ||
|
|
861cba63b3 | ||
|
|
59d0eb4534 | ||
|
|
57ee303cda | ||
|
|
4690fe1a55 | ||
|
|
826547a8e1 | ||
|
|
7047aef7f7 | ||
|
|
d82d782bac | ||
|
|
6351f4618e | ||
|
|
2c52c9884c | ||
|
|
5e47a21673 | ||
|
|
5ca4cf4acd | ||
|
|
267d800122 | ||
|
|
790373b896 | ||
|
|
ca6493528d | ||
|
|
1e77893f75 | ||
|
|
82a026e02e | ||
|
|
988984a1ae | ||
|
|
5859947207 | ||
|
|
b1508ae52f | ||
|
|
152ad12508 | ||
|
|
36e452b0f3 | ||
|
|
2a9bbb21ba | ||
|
|
f7e7115f79 | ||
|
|
6e04f7b03e | ||
|
|
2a1ab78f7a | ||
|
|
e5d02c4b95 | ||
|
|
69f7282952 | ||
|
|
b8c3104aa1 | ||
|
|
22e13c52b9 | ||
|
|
32e2637594 | ||
|
|
928635f1da | ||
|
|
a353acc8e3 | ||
|
|
b5d4a253b4 | ||
|
|
7267d27779 | ||
|
|
f2e5d59c35 | ||
|
|
1558a5d8ac | ||
|
|
87cee1aea6 | ||
|
|
0c2cc10ad8 | ||
|
|
84cc92f91a | ||
|
|
a6bfab7934 | ||
|
|
848f250dbb | ||
|
|
c078ccb136 | ||
|
|
67d3f05fd7 |
+86
-41
@@ -14,69 +14,109 @@ jobs:
|
||||
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
|
||||
with:
|
||||
# Required for `release: merge dev -> master and promote dev`
|
||||
token: ${{secrets.GH_TOKEN}}
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
if: ((github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push') == false
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
registry-url: https://registry.npmjs.org/
|
||||
cache: npm
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: pnpm i --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
run: pnpm run build
|
||||
|
||||
- name: Lint
|
||||
run: |
|
||||
npm run pretty:check
|
||||
npm run eslint
|
||||
pnpm run pretty:check
|
||||
pnpm run eslint
|
||||
|
||||
- name: Test
|
||||
run: npm run test-with-coverage
|
||||
- name: Docker login
|
||||
run: pnpm run test:coverage
|
||||
|
||||
- name: Log in to the Docker container registry
|
||||
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
|
||||
run: echo ${{ secrets.DOCKER_KEY }} | docker login -u koenkk --password-stdin
|
||||
- name: Docker login ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: koenkk
|
||||
password: ${{ secrets.DOCKER_KEY }}
|
||||
|
||||
- name: Log in to the GitHub container registry
|
||||
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
|
||||
run: echo ${{ secrets.GH_TOKEN }} | docker login ghcr.io -u koenkk --password-stdin
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: koenkk
|
||||
password: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- name: Docker setup - QEMU
|
||||
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: all
|
||||
|
||||
- name: Docker setup - Buildx
|
||||
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
version: latest
|
||||
- name: 'dev: Docker build'
|
||||
|
||||
- name: dev - Docker build and push
|
||||
if: github.ref == 'refs/heads/dev' && github.event_name == 'push'
|
||||
run: |
|
||||
docker buildx build \
|
||||
--build-arg COMMIT=$(git rev-parse --short HEAD) \
|
||||
--platform linux/arm64/v8,linux/386,linux/amd64,linux/arm/v6,linux/arm/v7 \
|
||||
-f docker/Dockerfile \
|
||||
--provenance=false \
|
||||
--push \
|
||||
-t koenkk/zigbee2mqtt:latest-dev -t ghcr.io/koenkk/zigbee2mqtt:latest-dev \
|
||||
.
|
||||
- name: 'release: Docker build'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: linux/arm64/v8,linux/amd64,linux/arm/v6,linux/arm/v7,linux/riscv64,linux/386
|
||||
tags: koenkk/zigbee2mqtt:latest-dev,ghcr.io/koenkk/zigbee2mqtt:latest-dev
|
||||
push: true
|
||||
build-args: |
|
||||
COMMIT=${{ github.sha }}
|
||||
VERSION=dev
|
||||
DATE=${{ github.event.repository.updated_at }}
|
||||
|
||||
- name: release - Docker meta
|
||||
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
|
||||
run: |
|
||||
TAG="$(git describe --tags)"
|
||||
docker buildx build \
|
||||
--build-arg COMMIT=$(git rev-parse --short HEAD) \
|
||||
--platform linux/arm64/v8,linux/386,linux/amd64,linux/arm/v6,linux/arm/v7 \
|
||||
-f docker/Dockerfile \
|
||||
--provenance=false \
|
||||
--push \
|
||||
-t koenkk/zigbee2mqtt:latest -t "koenkk/zigbee2mqtt:$TAG" -t ghcr.io/koenkk/zigbee2mqtt:latest -t "ghcr.io/koenkk/zigbee2mqtt:$TAG" \
|
||||
.
|
||||
uses: docker/metadata-action@v5
|
||||
id: meta
|
||||
with:
|
||||
images: |
|
||||
koenkk/zigbee2mqtt
|
||||
ghcr.io/koenkk/zigbee2mqtt
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
|
||||
- name: release - Docker build and push
|
||||
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
provenance: false
|
||||
platforms: linux/arm64/v8,linux/amd64,linux/arm/v6,linux/arm/v7,linux/riscv64,linux/386
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
push: true
|
||||
build-args: |
|
||||
COMMIT=${{ github.sha }}
|
||||
VERSION=${{ github.ref_name }}
|
||||
DATE=${{ github.event.repository.updated_at }}
|
||||
|
||||
- name: 'release: Publish to npm'
|
||||
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
|
||||
run: npm publish
|
||||
run: pnpm publish --no-git-checks
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN }}
|
||||
|
||||
- name: 'dev: Trigger zigbee2mqtt/hassio-zigbee2mqtt build'
|
||||
if: github.ref == 'refs/heads/dev' && github.event_name == 'push'
|
||||
run: |
|
||||
@@ -86,6 +126,7 @@ jobs:
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
https://api.github.com/repos/zigbee2mqtt/hassio-zigbee2mqtt/actions/workflows/ci.yml/dispatches \
|
||||
-d '{"ref":"master","inputs":{}}'
|
||||
|
||||
- name: 'release: Trigger zigbee2mqtt/hassio-zigbee2mqtt build'
|
||||
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
|
||||
run: |
|
||||
@@ -98,6 +139,7 @@ jobs:
|
||||
-H "Content-Type: application/json" \
|
||||
https://api.github.com/repos/zigbee2mqtt/hassio-zigbee2mqtt/dispatches \
|
||||
--data "{\"event_type\": \"release\", \"client_payload\": { \"version\": \"$TAG-1\"}}"
|
||||
|
||||
- name: 'release: Trigger zigbee2mqtt-chart image update'
|
||||
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
|
||||
run: |
|
||||
@@ -110,6 +152,7 @@ jobs:
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
https://api.github.com/repos/Koenkk/zigbee2mqtt-chart/actions/workflows/on_zigbee2mqtt_release.yaml/dispatches \
|
||||
--data "{\"ref\": \"main\", \"inputs\": { \"zigbee2mqtt_version\": \"$TAG\"}}"
|
||||
|
||||
- name: 'release: merge dev -> master and promote dev'
|
||||
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
|
||||
run: |
|
||||
@@ -123,9 +166,7 @@ jobs:
|
||||
git push origin master
|
||||
git checkout dev
|
||||
jq --indent 4 ".version = \"$TAG-dev\"" package.json > package.json.tmp
|
||||
jq --indent 4 ".version = \"$TAG-dev\"" package-lock.json > package-lock.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
mv package-lock.json.tmp package-lock.json
|
||||
git add -A
|
||||
git commit -m "chore: promote to dev"
|
||||
git push origin dev
|
||||
@@ -139,15 +180,19 @@ jobs:
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
registry-url: https://registry.npmjs.org/
|
||||
cache: 'npm'
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
# --ignore-scripts prevents the serialport build which often fails on Windows
|
||||
run: npm ci --ignore-scripts
|
||||
run: pnpm i --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
run: pnpm run build
|
||||
|
||||
- name: Test
|
||||
run: npm run test-with-coverage
|
||||
run: pnpm run test:coverage
|
||||
|
||||
@@ -1,23 +1,92 @@
|
||||
name: codeql
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: 'CodeQL'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
branches: ['master', 'dev']
|
||||
pull_request:
|
||||
branches: ['master', 'dev']
|
||||
schedule:
|
||||
- cron: '15 2 * * 2'
|
||||
|
||||
jobs:
|
||||
CodeQL-Build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
# Runner size impacts CodeQL analysis time. To learn more, please see:
|
||||
# - https://gh.io/recommended-hardware-resources-for-running-codeql
|
||||
# - https://gh.io/supported-runners-and-hardware-resources
|
||||
# - https://gh.io/using-larger-runners (GitHub.com only)
|
||||
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
|
||||
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
# required for all workflows
|
||||
security-events: write
|
||||
|
||||
# required to fetch internal or private CodeQL packs
|
||||
packages: read
|
||||
|
||||
# only required for workflows in private repositories
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: javascript-typescript
|
||||
build-mode: none
|
||||
# CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
|
||||
# Use `c-cpp` to analyze code written in C, C++ or both
|
||||
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
|
||||
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
|
||||
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
|
||||
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
|
||||
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
|
||||
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
# If the analyze step fails for one of the languages you are analyzing with
|
||||
# "We were unable to automatically build your code", modify the matrix above
|
||||
# to set the build mode to "manual" for that language. Then modify this step
|
||||
# to build your code.
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
- if: matrix.build-mode == 'manual'
|
||||
shell: bash
|
||||
run: |
|
||||
echo 'If you are using a "manual" build mode for one or more of the' \
|
||||
'languages you are analyzing, replace this with the commands to build' \
|
||||
'your code, for example:'
|
||||
echo ' make bootstrap'
|
||||
echo ' make release'
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
@@ -16,9 +16,13 @@ jobs:
|
||||
release_created: ${{ steps.release.outputs.release_created }}
|
||||
version: '${{steps.release.outputs.major}}.${{steps.release.outputs.minor}}.${{steps.release.outputs.patch}}'
|
||||
steps:
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
|
||||
- uses: googleapis/release-please-action@v4
|
||||
id: release
|
||||
@@ -52,7 +56,7 @@ jobs:
|
||||
MASTER_FRONTEND_VERSION=$(cat z2m-master/package.json | jq -r '.dependencies."zigbee2mqtt-frontend"')
|
||||
wget -q -O - https://raw.githubusercontent.com/Koenkk/zigbee2mqtt/release-please--branches--dev--components--zigbee2mqtt/CHANGELOG.md > z2m/CHANGELOG.md
|
||||
cd z2m
|
||||
npm ci
|
||||
pnpm i --frozen-lockfile
|
||||
node scripts/generateChangelog.js $MASTER_Z2M_VERSION $MASTER_ZHC_VERSION $MASTER_ZH_VERSION $MASTER_FRONTEND_VERSION >> ../changelog.md
|
||||
env:
|
||||
GH_TOKEN: ${{secrets.GH_TOKEN}}
|
||||
|
||||
@@ -12,9 +12,9 @@ jobs:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
stale-issue-message: 'This issue is stale because it has been open 180 days with no activity. Remove stale label or comment or this will be closed in 30 days'
|
||||
stale-pr-message: 'This pull request is stale because it has been open 180 days with no activity. Remove stale label or comment or this will be closed in 30 days'
|
||||
days-before-stale: 180
|
||||
days-before-close: 30
|
||||
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'
|
||||
stale-pr-message: 'This pull request 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'
|
||||
days-before-stale: 60
|
||||
days-before-close: 7
|
||||
exempt-issue-labels: dont-stale
|
||||
operations-per-run: 500
|
||||
|
||||
@@ -17,17 +17,20 @@ jobs:
|
||||
with:
|
||||
ref: dev
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
- run: npm install ${{ github.event.client_payload.package }}@${{ github.event.client_payload.version }} --save-exact
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: |
|
||||
pnpm install ${{ github.event.client_payload.package }}@${{ github.event.client_payload.version }} --save-exact
|
||||
pnpm install --no-frozen-lockfile
|
||||
- uses: peter-evans/create-pull-request@v7
|
||||
id: cpr
|
||||
with:
|
||||
commit-message: 'fix(ignore): update ${{ github.event.client_payload.package }} to ${{ github.event.client_payload.version }}'
|
||||
branch: 'deps/${{ github.event.client_payload.package }}'
|
||||
title: Update ${{ github.event.client_payload.package }} to ${{ github.event.client_payload.version }}
|
||||
title: 'fix(ignore): update ${{ github.event.client_payload.package }} to ${{ github.event.client_payload.version }}'
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
- run: sleep 5 # Otherwise pull request may not exist yet causing automerge to fail
|
||||
- run: gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}"
|
||||
|
||||
@@ -18,17 +18,16 @@ jobs:
|
||||
with:
|
||||
ref: dev
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
# connect-gzip-static@4.0.0 requires Node 20 >=
|
||||
- run: npx npm-check-updates -u -x connect-gzip-static
|
||||
- run: rm -f package-lock.json
|
||||
- run: npm install
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: |
|
||||
pnpm up --latest
|
||||
- uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
commit-message: 'fix(ignore): update dependencies'
|
||||
branch: 'deps/all'
|
||||
title: Update dependencies
|
||||
title: 'fix(ignore): update dependencies'
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
@@ -45,6 +45,9 @@ typings/
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional pnpm cache directory
|
||||
.pnpm-store
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
@@ -68,6 +71,7 @@ tsconfig.tsbuildinfo
|
||||
|
||||
# IDE specific folders
|
||||
.idea
|
||||
.*.swp
|
||||
|
||||
# Ignore config
|
||||
data/*
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
CHANGELOG.md
|
||||
@@ -7,6 +7,7 @@
|
||||
"endOfLine": "lf",
|
||||
"tabWidth": 4,
|
||||
"importOrder": [
|
||||
"^[./]*/mocks",
|
||||
"",
|
||||
"<TYPES>^(node:)",
|
||||
"",
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "1.41.0"
|
||||
".": "2.1.3"
|
||||
}
|
||||
|
||||
+212
@@ -1,5 +1,217 @@
|
||||
# Changelog
|
||||
|
||||
## [2.1.3](https://github.com/Koenkk/zigbee2mqtt/compare/2.1.2...2.1.3) (2025-03-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Home Assistant: fix brightness only lights ([#26560](https://github.com/Koenkk/zigbee2mqtt/issues/26560)) ([7e7057e](https://github.com/Koenkk/zigbee2mqtt/commit/7e7057eccd1fe638dd73b4a4f303c6612b73c89d))
|
||||
|
||||
## [2.1.2](https://github.com/Koenkk/zigbee2mqtt/compare/2.1.1...2.1.2) (2025-03-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add Home Assistant device class to external_temperature_input entity ([#26535](https://github.com/Koenkk/zigbee2mqtt/issues/26535)) ([e8031ef](https://github.com/Koenkk/zigbee2mqtt/commit/e8031efd2d95658c5f3fe72ee6e2f95dd584b5e8))
|
||||
* Allow configuring number of folders of old logs to keep ([#26398](https://github.com/Koenkk/zigbee2mqtt/issues/26398)) ([845e03e](https://github.com/Koenkk/zigbee2mqtt/commit/845e03e48acf1367feadeaae986f3c987bbc164d))
|
||||
* Fix Docker build hanging ([#26547](https://github.com/Koenkk/zigbee2mqtt/issues/26547)) ([e050e20](https://github.com/Koenkk/zigbee2mqtt/commit/e050e209b0d273cad6ad0efcf20761f33963da7b))
|
||||
* Fix HA MQTT dict entry indicating that an update is progress ([#26267](https://github.com/Koenkk/zigbee2mqtt/issues/26267)) ([b35cdc3](https://github.com/Koenkk/zigbee2mqtt/commit/b35cdc347423b219722c9f9dd0d2a82ded8eb24f))
|
||||
* Home Assistant: add `brightness` to `supported_color_modes` ([#26545](https://github.com/Koenkk/zigbee2mqtt/issues/26545)) ([063aba1](https://github.com/Koenkk/zigbee2mqtt/commit/063aba1b6c72901ed2d51a367485092691c358da))
|
||||
* Home Assistant: add `is_updating` attribute ([#26231](https://github.com/Koenkk/zigbee2mqtt/issues/26231)) ([1eda69a](https://github.com/Koenkk/zigbee2mqtt/commit/1eda69a50a8f92c3471acdbdc82b41055d5992f2))
|
||||
* Home Assistant: add missing `device_class` for some `current` and `power` entities ([#26324](https://github.com/Koenkk/zigbee2mqtt/issues/26324)) ([1e656c2](https://github.com/Koenkk/zigbee2mqtt/commit/1e656c2114d58b96fd7b7750e2e7d255a9175653))
|
||||
* Home Assistant: support speed-controlled fans ([#24483](https://github.com/Koenkk/zigbee2mqtt/issues/24483)) ([7ce5b58](https://github.com/Koenkk/zigbee2mqtt/commit/7ce5b581aa57d828043a1ca75ef7efe7b7bf1619))
|
||||
* **ignore:** Add `publish` to `meta` of toZigbee converters ([#26490](https://github.com/Koenkk/zigbee2mqtt/issues/26490)) ([07145ac](https://github.com/Koenkk/zigbee2mqtt/commit/07145accbae6c481f6d6740e66e27ab1baf5eb6d))
|
||||
* **ignore:** remove obsolete @types/eslint__js ([d522d2c](https://github.com/Koenkk/zigbee2mqtt/commit/d522d2cd18b8cb77fa50cd457823cfa573622cb7))
|
||||
* **ignore:** update dependencies ([#26266](https://github.com/Koenkk/zigbee2mqtt/issues/26266)) ([3e3ceff](https://github.com/Koenkk/zigbee2mqtt/commit/3e3cefff1b7f85f59b6bf529f6593339498561a1))
|
||||
* **ignore:** update dependencies ([#26383](https://github.com/Koenkk/zigbee2mqtt/issues/26383)) ([758c566](https://github.com/Koenkk/zigbee2mqtt/commit/758c56647d353e81ea22a34c2fa2665ed76dc149))
|
||||
* **ignore:** update dependencies ([#26478](https://github.com/Koenkk/zigbee2mqtt/issues/26478)) ([d2166f2](https://github.com/Koenkk/zigbee2mqtt/commit/d2166f2f4fa1e1e286face994ec4825a70144908))
|
||||
* **ignore:** update zigbee-herdsman to 3.2.6 ([#26303](https://github.com/Koenkk/zigbee2mqtt/issues/26303)) ([49fe147](https://github.com/Koenkk/zigbee2mqtt/commit/49fe1479213c928786c64adeb3031ca6fe91250d))
|
||||
* **ignore:** update zigbee-herdsman to 3.2.7 ([#26498](https://github.com/Koenkk/zigbee2mqtt/issues/26498)) ([4e650b7](https://github.com/Koenkk/zigbee2mqtt/commit/4e650b72cd187488893213018b619e933835a58e))
|
||||
* **ignore:** update zigbee-herdsman-converters to 21.32.0 ([#26245](https://github.com/Koenkk/zigbee2mqtt/issues/26245)) ([8542b35](https://github.com/Koenkk/zigbee2mqtt/commit/8542b35706cefc87c9ec9dccb2980302ea25f100))
|
||||
* **ignore:** update zigbee-herdsman-converters to 21.33.0 ([#26258](https://github.com/Koenkk/zigbee2mqtt/issues/26258)) ([97c7485](https://github.com/Koenkk/zigbee2mqtt/commit/97c74852c95536945e33d0388dc5fcd0b132cd7e))
|
||||
* **ignore:** update zigbee-herdsman-converters to 21.34.0 ([#26285](https://github.com/Koenkk/zigbee2mqtt/issues/26285)) ([7a0964e](https://github.com/Koenkk/zigbee2mqtt/commit/7a0964ebc905903b4ca5b168d9bd1041b004b4cf))
|
||||
* **ignore:** update zigbee-herdsman-converters to 21.35.0 ([#26305](https://github.com/Koenkk/zigbee2mqtt/issues/26305)) ([30d13d6](https://github.com/Koenkk/zigbee2mqtt/commit/30d13d6e5ee9678f7da812bd287128824a535d48))
|
||||
* **ignore:** update zigbee-herdsman-converters to 21.36.0 ([#26333](https://github.com/Koenkk/zigbee2mqtt/issues/26333)) ([ba76533](https://github.com/Koenkk/zigbee2mqtt/commit/ba76533c2f241c8362f43ad07d10b2fe084479a0))
|
||||
* **ignore:** update zigbee-herdsman-converters to 21.37.0 ([#26367](https://github.com/Koenkk/zigbee2mqtt/issues/26367)) ([dec5ca8](https://github.com/Koenkk/zigbee2mqtt/commit/dec5ca8b3a3703e6289652192657af9a035fe026))
|
||||
* **ignore:** update zigbee-herdsman-converters to 21.38.0 ([#26400](https://github.com/Koenkk/zigbee2mqtt/issues/26400)) ([93af062](https://github.com/Koenkk/zigbee2mqtt/commit/93af062a9b84358c866a5a1191ae5895fc6e866f))
|
||||
* **ignore:** update zigbee-herdsman-converters to 22.0.1 ([#26418](https://github.com/Koenkk/zigbee2mqtt/issues/26418)) ([7d63a9a](https://github.com/Koenkk/zigbee2mqtt/commit/7d63a9a0f29445044bcfe0f3a4b629266db37654))
|
||||
* **ignore:** update zigbee-herdsman-converters to 22.1.0 ([#26445](https://github.com/Koenkk/zigbee2mqtt/issues/26445)) ([40f4147](https://github.com/Koenkk/zigbee2mqtt/commit/40f41477e776b4e75434251d03bf2844fceb7b13))
|
||||
* **ignore:** update zigbee-herdsman-converters to 22.2.0 ([#26469](https://github.com/Koenkk/zigbee2mqtt/issues/26469)) ([67e1a02](https://github.com/Koenkk/zigbee2mqtt/commit/67e1a0252517da28629a4c97b4dd610624d451a1))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.0.0 ([44f9fe7](https://github.com/Koenkk/zigbee2mqtt/commit/44f9fe7a1494a9450e247ad353ce788c621f901c))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.1.0 ([#26509](https://github.com/Koenkk/zigbee2mqtt/issues/26509)) ([db7b51a](https://github.com/Koenkk/zigbee2mqtt/commit/db7b51ac801829801656d978aff2605d8bd7b56a))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.1.1 ([#26510](https://github.com/Koenkk/zigbee2mqtt/issues/26510)) ([102857a](https://github.com/Koenkk/zigbee2mqtt/commit/102857a7fec60e3f65887d92b6fe7a2d81add110))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.2.0 ([#26549](https://github.com/Koenkk/zigbee2mqtt/issues/26549)) ([9f72c5c](https://github.com/Koenkk/zigbee2mqtt/commit/9f72c5c4555bda5b347a6a5406f619b9668813ec))
|
||||
* Make compatible with pnpm 10 ([#26546](https://github.com/Koenkk/zigbee2mqtt/issues/26546)) ([29d25ae](https://github.com/Koenkk/zigbee2mqtt/commit/29d25aed66a66912fb55ede8751ba8056efb673f))
|
||||
* Support FreeBSD for `update.sh` ([#26261](https://github.com/Koenkk/zigbee2mqtt/issues/26261)) ([5e55ef0](https://github.com/Koenkk/zigbee2mqtt/commit/5e55ef045427f78b410e3d292387c3592366f698))
|
||||
|
||||
## [2.1.1](https://github.com/Koenkk/zigbee2mqtt/compare/2.1.0...2.1.1) (2025-02-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ignore:** update dependencies ([#26126](https://github.com/Koenkk/zigbee2mqtt/issues/26126)) ([b960775](https://github.com/Koenkk/zigbee2mqtt/commit/b960775160b33dcd276376efaa7dc1ca3f791a53))
|
||||
* Rename `internal_temperature` to `internalTemperature` to correct typo ([#26114](https://github.com/Koenkk/zigbee2mqtt/issues/26114)) ([e4e4f16](https://github.com/Koenkk/zigbee2mqtt/commit/e4e4f16ec8994a63ba148b36d7e18a09f49ac298))
|
||||
|
||||
## [2.1.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.0.0...2.1.0) (2025-02-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add a settings option to log to console in json format ([#25649](https://github.com/Koenkk/zigbee2mqtt/issues/25649)) ([26ef565](https://github.com/Koenkk/zigbee2mqtt/commit/26ef565c8a20d1b6c2e09c679eb96d3f56811a87))
|
||||
* Allow Home Assistant OTA Update entity to show progress while updating ([#25632](https://github.com/Koenkk/zigbee2mqtt/issues/25632)) ([318dc04](https://github.com/Koenkk/zigbee2mqtt/commit/318dc0413284d204d13a99afd400a5f00f5ef338))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add Home Assistant device class for flow rates (e.g., Sonoff SWV) ([#26035](https://github.com/Koenkk/zigbee2mqtt/issues/26035)) ([cdbd553](https://github.com/Koenkk/zigbee2mqtt/commit/cdbd55320acbf7d857c518e8aac8ab9e495eb6b5))
|
||||
* Add semver tags to Docker releases ([#25530](https://github.com/Koenkk/zigbee2mqtt/issues/25530)) ([0d30eb5](https://github.com/Koenkk/zigbee2mqtt/commit/0d30eb553b9cb027f8ae5424b9e740136742f74c))
|
||||
* Allow to set throttle via frontend ([#25997](https://github.com/Koenkk/zigbee2mqtt/issues/25997)) ([5cf4547](https://github.com/Koenkk/zigbee2mqtt/commit/5cf4547fe04e9a5627826225c3b26586aea3edc1))
|
||||
* Container: remove modifying capabilities of `/usr/bin/node` ([#25456](https://github.com/Koenkk/zigbee2mqtt/issues/25456)) ([43a5b24](https://github.com/Koenkk/zigbee2mqtt/commit/43a5b24dcc22af3ec4c0bb76b45a2706f898421f))
|
||||
* Enable use of MQTT username without password ([#25611](https://github.com/Koenkk/zigbee2mqtt/issues/25611)) ([a28a5ba](https://github.com/Koenkk/zigbee2mqtt/commit/a28a5baf17b8dd3fe4703cdb38d80796157521af))
|
||||
* Fix `Error: write after end` error ([#25737](https://github.com/Koenkk/zigbee2mqtt/issues/25737)) ([2d5e7bf](https://github.com/Koenkk/zigbee2mqtt/commit/2d5e7bf9ff611387f2334a55caab6991d65ad3fa))
|
||||
* Fix `Failed to call 'Frontend' 'stop' (TypeError: Cannot read properties of undefined (reading 'close')` https://github.com/Koenkk/zigbee2mqtt/issues/25715 ([9daeba1](https://github.com/Koenkk/zigbee2mqtt/commit/9daeba1f0ab350d9609bcd4c1e8bc291b5eaab8b))
|
||||
* Fix duplicate `action` publish when `advanced.output` has `attribute` ([#25963](https://github.com/Koenkk/zigbee2mqtt/issues/25963)) ([2af65cf](https://github.com/Koenkk/zigbee2mqtt/commit/2af65cf28eea397013a0f629761536339d6ace63))
|
||||
* **ignore:** Ensure config example is never out of sync with settings ([#25707](https://github.com/Koenkk/zigbee2mqtt/issues/25707)) ([23b709c](https://github.com/Koenkk/zigbee2mqtt/commit/23b709cee28a5926f1608e70e30703ba52011005))
|
||||
* **ignore:** Remove `adapter` `auto` from schema ([#25896](https://github.com/Koenkk/zigbee2mqtt/issues/25896)) ([5e60f1d](https://github.com/Koenkk/zigbee2mqtt/commit/5e60f1dc8dae96a04d1a76a677f89f426da64a94))
|
||||
* **ignore:** update dependencies ([#25535](https://github.com/Koenkk/zigbee2mqtt/issues/25535)) ([6f3524b](https://github.com/Koenkk/zigbee2mqtt/commit/6f3524b251ed8218ffed132df262eb0850687e61))
|
||||
* **ignore:** update dependencies ([#25760](https://github.com/Koenkk/zigbee2mqtt/issues/25760)) ([5b82d7e](https://github.com/Koenkk/zigbee2mqtt/commit/5b82d7e4df9fdc868689f8a745e502b5beea837a))
|
||||
* **ignore:** update dependencies ([#25890](https://github.com/Koenkk/zigbee2mqtt/issues/25890)) ([8da5fa6](https://github.com/Koenkk/zigbee2mqtt/commit/8da5fa663d2fbf4a68e797cff9938b57dd620935))
|
||||
* **ignore:** update dependencies ([#26005](https://github.com/Koenkk/zigbee2mqtt/issues/26005)) ([85460cd](https://github.com/Koenkk/zigbee2mqtt/commit/85460cd5ce2a17a2036a54d3984a5f59b75be41b))
|
||||
* Long startup time when `OnEvent` 'start' times out (repeatedly) ([#25693](https://github.com/Koenkk/zigbee2mqtt/issues/25693)) ([f1f73bd](https://github.com/Koenkk/zigbee2mqtt/commit/f1f73bd46425262fc1ff7e897d879faf16e3d8a9))
|
||||
* Only use endpoint-specific states if the device definition uses them ([#26019](https://github.com/Koenkk/zigbee2mqtt/issues/26019)) ([0462b94](https://github.com/Koenkk/zigbee2mqtt/commit/0462b94f5a346e64a85a90cb6c7e33a36ac98311))
|
||||
* Remove invalid `warn` `log_level` https://github.com/nurikk/zigbee2mqtt-frontend/issues/2369 ([#25634](https://github.com/Koenkk/zigbee2mqtt/issues/25634)) ([1667ba6](https://github.com/Koenkk/zigbee2mqtt/commit/1667ba64ac2b8272cb0dea7ce30e459450e1c847))
|
||||
|
||||
## [2.0.0](https://github.com/Koenkk/zigbee2mqtt/compare/1.42.0...2.0.0) (2025-01-03)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* Change default `homeassistant.status_topic` to `homeassistant/status` ([#25286](https://github.com/Koenkk/zigbee2mqtt/issues/25286))
|
||||
* Docker: update Alpine to 3.21, drop x86 support ([#25171](https://github.com/Koenkk/zigbee2mqtt/issues/25171))
|
||||
* Remove `permit_join_timeout` ([#25082](https://github.com/Koenkk/zigbee2mqtt/issues/25082))
|
||||
* Home Assistant: remove duplicate `sensor`/`select` for `select`/`number`/`button` entities ([#25026](https://github.com/Koenkk/zigbee2mqtt/issues/25026))
|
||||
* Ensure `data` in response is always empty on `error` ([#24971](https://github.com/Koenkk/zigbee2mqtt/issues/24971))
|
||||
* Rework OTA ([#24634](https://github.com/Koenkk/zigbee2mqtt/issues/24634))
|
||||
* Remove deprecated Home Assistant integration features ([#24494](https://github.com/Koenkk/zigbee2mqtt/issues/24494))
|
||||
* Improve bind/bridge/groups parsing and resolving consistency ([#24432](https://github.com/Koenkk/zigbee2mqtt/issues/24432))
|
||||
* Remove configuring group members through `configuration.yaml` ([#24338](https://github.com/Koenkk/zigbee2mqtt/issues/24338))
|
||||
* Improve permit join ([#24257](https://github.com/Koenkk/zigbee2mqtt/issues/24257))
|
||||
* The big cleanup ([#24200](https://github.com/Koenkk/zigbee2mqtt/issues/24200))
|
||||
* HA permit join switch: disable automatically after 254 seconds ([#24245](https://github.com/Koenkk/zigbee2mqtt/issues/24245))
|
||||
|
||||
### Features
|
||||
|
||||
* Automatic settings migration ([#24871](https://github.com/Koenkk/zigbee2mqtt/issues/24871)) ([c6958c4](https://github.com/Koenkk/zigbee2mqtt/commit/c6958c4991c2cfbac87907c78e75bc634401a894))
|
||||
* Docker: RISC-V support ([#22824](https://github.com/Koenkk/zigbee2mqtt/issues/22824)) ([e23d300](https://github.com/Koenkk/zigbee2mqtt/commit/e23d300fd9f5c2380560b2d07071cb332ea93339))
|
||||
* Home Assistant: support endpoints for locks ([#25359](https://github.com/Koenkk/zigbee2mqtt/issues/25359)) ([c586df8](https://github.com/Koenkk/zigbee2mqtt/commit/c586df82429546fba56ab10b579bf225bb33ebb2))
|
||||
* Live loading of external JS (converters/extensions) ([#24764](https://github.com/Koenkk/zigbee2mqtt/issues/24764)) ([9907005](https://github.com/Koenkk/zigbee2mqtt/commit/9907005256c65902cbb9475e9410b795b262b41a))
|
||||
* The big cleanup ([#24200](https://github.com/Koenkk/zigbee2mqtt/issues/24200)) ([b618662](https://github.com/Koenkk/zigbee2mqtt/commit/b6186623f0bcf9d88de1772aebd8a9eaead21a4c))
|
||||
* Typing for MQTT API ([#25098](https://github.com/Koenkk/zigbee2mqtt/issues/25098)) ([1a9c79b](https://github.com/Koenkk/zigbee2mqtt/commit/1a9c79bb2f9b5e123b8cd017e82498ab534596fd))
|
||||
* Use Vitest for testing ([#25284](https://github.com/Koenkk/zigbee2mqtt/issues/25284)) ([06f4453](https://github.com/Koenkk/zigbee2mqtt/commit/06f44533af488cd632d4eec78e2c629d8d6ef278))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add `cap_net_bind_service=+ep` to `/usr/bin/node` in Docker container ([#25385](https://github.com/Koenkk/zigbee2mqtt/issues/25385)) ([#25387](https://github.com/Koenkk/zigbee2mqtt/issues/25387)) ([61864f0](https://github.com/Koenkk/zigbee2mqtt/commit/61864f0fb2c7ffc90bbb85c969ae5b589dbbb92b))
|
||||
* Add more OCI Image Labels ([#25074](https://github.com/Koenkk/zigbee2mqtt/issues/25074)) ([b370b80](https://github.com/Koenkk/zigbee2mqtt/commit/b370b80dfb6701b8601c32f44102aec60a5d5ac8))
|
||||
* Change default `homeassistant.status_topic` to `homeassistant/status` ([#25286](https://github.com/Koenkk/zigbee2mqtt/issues/25286)) ([2abf55c](https://github.com/Koenkk/zigbee2mqtt/commit/2abf55ccf8de35c2ec3463e29c31a80a35d7392b))
|
||||
* Cleanup Dockerfile ([23d8cea](https://github.com/Koenkk/zigbee2mqtt/commit/23d8cea4e243429bf5a376db94d09f535776de49))
|
||||
* Cleanup leftover legacy settings logic ([#24947](https://github.com/Koenkk/zigbee2mqtt/issues/24947)) ([d78c6fa](https://github.com/Koenkk/zigbee2mqtt/commit/d78c6facab57e0ec5676eb76ec099f5dce0f801a))
|
||||
* Docker: update Alpine to 3.21, drop x86 support ([#25171](https://github.com/Koenkk/zigbee2mqtt/issues/25171)) ([32c341c](https://github.com/Koenkk/zigbee2mqtt/commit/32c341c7f58ef920e99940f0bf4b2218301dc377))
|
||||
* Ensure `data` in response is always empty on `error` ([#24971](https://github.com/Koenkk/zigbee2mqtt/issues/24971)) ([a6b3605](https://github.com/Koenkk/zigbee2mqtt/commit/a6b36051f032a2acebefe0232a20abebfb9641be))
|
||||
* Fix `Cannot read properties of null (reading 'toString')` when Home Assistant event entities are enabled https://github.com/Koenkk/zigbee2mqtt/issues/25133 ([9eea58e](https://github.com/Koenkk/zigbee2mqtt/commit/9eea58eba471b9e670dff327a8666e9c78b223e0))
|
||||
* Fix dual cover Home Assistant discovery ([#25240](https://github.com/Koenkk/zigbee2mqtt/issues/25240)) ([ef6524b](https://github.com/Koenkk/zigbee2mqtt/commit/ef6524b93fb8e77ab2944715c2a2d3f3e3bd2923))
|
||||
* Fix group with only multi endpoints devices not being controllable ([#25156](https://github.com/Koenkk/zigbee2mqtt/issues/25156)) ([7242889](https://github.com/Koenkk/zigbee2mqtt/commit/72428897a7ab78057aa3aafa5d2a0e112e6a2a73))
|
||||
* Fix Home Assistant event entities for openHAB ([#25140](https://github.com/Koenkk/zigbee2mqtt/issues/25140)) ([226945c](https://github.com/Koenkk/zigbee2mqtt/commit/226945cae031007a86865dbbe22a42e951999f03))
|
||||
* Fix log directories not being deleted ([#25282](https://github.com/Koenkk/zigbee2mqtt/issues/25282)) ([7170215](https://github.com/Koenkk/zigbee2mqtt/commit/7170215e96d30259d7673cb9ae77c0d6d741431d))
|
||||
* Fix URL in startup failed bailout message ([#25339](https://github.com/Koenkk/zigbee2mqtt/issues/25339)) ([bcec40f](https://github.com/Koenkk/zigbee2mqtt/commit/bcec40f84a73142efef41d239fea0c77ce4bdfa3))
|
||||
* Fixed calculation of group state when including non-default endpoints ([#25059](https://github.com/Koenkk/zigbee2mqtt/issues/25059)) ([9227e13](https://github.com/Koenkk/zigbee2mqtt/commit/9227e139de86299eb3ec4b1585bdd21448bfa94a))
|
||||
* HA permit join switch: disable automatically after 254 seconds ([#24245](https://github.com/Koenkk/zigbee2mqtt/issues/24245)) ([3220b27](https://github.com/Koenkk/zigbee2mqtt/commit/3220b27c8a91fc65912d299db22b8ac489b929b2))
|
||||
* Home Assistant: expose the internal_temperature sensor with the correct device class ([#25289](https://github.com/Koenkk/zigbee2mqtt/issues/25289)) ([5d059b7](https://github.com/Koenkk/zigbee2mqtt/commit/5d059b7bcb7f710df93e34b2bb56a1e880757c05))
|
||||
* Home Assistant: remove duplicate `sensor`/`select` for `select`/`number`/`button` entities ([#25026](https://github.com/Koenkk/zigbee2mqtt/issues/25026)) ([ba45445](https://github.com/Koenkk/zigbee2mqtt/commit/ba45445544948ad74e3db67ffee1d23996a37fb5))
|
||||
* Home Assistant: use extended device identifiers ([#25389](https://github.com/Koenkk/zigbee2mqtt/issues/25389)) ([60c37c1](https://github.com/Koenkk/zigbee2mqtt/commit/60c37c11d0a2e22e0bec0b7c50d5725f3b062b05))
|
||||
* **ignore:** Add back `linux/386` ([#25187](https://github.com/Koenkk/zigbee2mqtt/issues/25187)) ([a2027d7](https://github.com/Koenkk/zigbee2mqtt/commit/a2027d79e899e8db22d3ca090d28d1fa4b9e3bc6))
|
||||
* **ignore:** Add back Home Assistant legacy action sensor ([#25192](https://github.com/Koenkk/zigbee2mqtt/issues/25192)) ([39c007d](https://github.com/Koenkk/zigbee2mqtt/commit/39c007d211453b3dc13335edba2396c13163ae96))
|
||||
* **ignore:** add cap_net_bind_service=+ep to /usr/bin/node ([#25385](https://github.com/Koenkk/zigbee2mqtt/issues/25385)) ([#25424](https://github.com/Koenkk/zigbee2mqtt/issues/25424)) ([7cf01e9](https://github.com/Koenkk/zigbee2mqtt/commit/7cf01e988209b78660ae1cb6fef609906db1df0b))
|
||||
* **ignore:** Changes for improved adapter discovery ([#24147](https://github.com/Koenkk/zigbee2mqtt/issues/24147)) ([98b9770](https://github.com/Koenkk/zigbee2mqtt/commit/98b977030ec73e5a75c28f4f779fce9189b12d13))
|
||||
* **ignore:** Fix 2 to 3 migration enabling frontend/availabilty/homeassistant when not set ([#25349](https://github.com/Koenkk/zigbee2mqtt/issues/25349)) ([02666e9](https://github.com/Koenkk/zigbee2mqtt/commit/02666e91393ec304e1098c01cd468889cccbd9d6))
|
||||
* **ignore:** Fix availabilty, frontend and homeassistant cannot be disabled via the frontend https://github.com/nurikk/zigbee2mqtt-frontend/issues/2339 ([ed75553](https://github.com/Koenkk/zigbee2mqtt/commit/ed7555360392e07b0ae22167dc28e4996e2148cc))
|
||||
* **ignore:** Fix external converter loading ([#25063](https://github.com/Koenkk/zigbee2mqtt/issues/25063)) ([51f364a](https://github.com/Koenkk/zigbee2mqtt/commit/51f364a1d5dfd46f50830c6412ae78fb74994929))
|
||||
* **ignore:** fix tests ([b581dc6](https://github.com/Koenkk/zigbee2mqtt/commit/b581dc6994c92e125b9a83a69ae74f23f4dc20b9))
|
||||
* **ignore:** Fixed additional case of calculation of group state when including no… ([#25104](https://github.com/Koenkk/zigbee2mqtt/issues/25104)) ([d891320](https://github.com/Koenkk/zigbee2mqtt/commit/d891320314b48d463d2926da8fb1f674913efcc9))
|
||||
* **ignore:** Force rebuild of @serialport/bindings-cpp in Docker ([#25202](https://github.com/Koenkk/zigbee2mqtt/issues/25202)) ([0b62aef](https://github.com/Koenkk/zigbee2mqtt/commit/0b62aefbb28d0376f89920f962a88ec5728e0b43))
|
||||
* **ignore:** import using `node:` prefix for built-in ([#25248](https://github.com/Koenkk/zigbee2mqtt/issues/25248)) ([758ab34](https://github.com/Koenkk/zigbee2mqtt/commit/758ab34747baeaf62e7a1552cd452c4445279ff1))
|
||||
* **ignore:** Improvements for [#25299](https://github.com/Koenkk/zigbee2mqtt/issues/25299) ([#25364](https://github.com/Koenkk/zigbee2mqtt/issues/25364)) ([1e7e00e](https://github.com/Koenkk/zigbee2mqtt/commit/1e7e00e8ddd2e491e6ba7d8b8f31121f15f66d31))
|
||||
* **ignore:** Minor cleanup ([#25166](https://github.com/Koenkk/zigbee2mqtt/issues/25166)) ([ec13ebd](https://github.com/Koenkk/zigbee2mqtt/commit/ec13ebd3ad461c3dc06bafda23d1bb87a94f3768))
|
||||
* **ignore:** overrides ([#25246](https://github.com/Koenkk/zigbee2mqtt/issues/25246)) ([c88f630](https://github.com/Koenkk/zigbee2mqtt/commit/c88f630299714d7496165fa1701d2906689e5180))
|
||||
* **ignore:** Rebuild serialport, attempt to fix [#25092](https://github.com/Koenkk/zigbee2mqtt/issues/25092) ([146b6c3](https://github.com/Koenkk/zigbee2mqtt/commit/146b6c386041b5139e3a1dff0688c7bbaaa88ea1))
|
||||
* **ignore:** remove `illuminance_lux` from cached payload https://github.com/Koenkk/zigbee2mqtt/issues/25379 ([2713125](https://github.com/Koenkk/zigbee2mqtt/commit/271312576824ea9315373be72d1a6480ab1e8db3))
|
||||
* **ignore:** remove dep in favor of built-in ([#25250](https://github.com/Koenkk/zigbee2mqtt/issues/25250)) ([1c75eef](https://github.com/Koenkk/zigbee2mqtt/commit/1c75eef16d1ed0c1748b23fcc9f9b39091b7b9b6))
|
||||
* **ignore:** Remove external definition type ([8aef77e](https://github.com/Koenkk/zigbee2mqtt/commit/8aef77e57e76aeb23ba9fd5f848ea5ee50c14a56))
|
||||
* **ignore:** Small cleanup ([#24696](https://github.com/Koenkk/zigbee2mqtt/issues/24696)) ([95279b0](https://github.com/Koenkk/zigbee2mqtt/commit/95279b02afe87788c2080ad7b9b2df2c5cbb5f7b))
|
||||
* **ignore:** some fixes ([cc61f67](https://github.com/Koenkk/zigbee2mqtt/commit/cc61f672155918c11ec7ac6c48096b9f0629695e))
|
||||
* **ignore:** update dependencies ([#24999](https://github.com/Koenkk/zigbee2mqtt/issues/24999)) ([32e10d8](https://github.com/Koenkk/zigbee2mqtt/commit/32e10d89f48871d8646f31772569fddab6d6824a))
|
||||
* **ignore:** update dependencies ([#25120](https://github.com/Koenkk/zigbee2mqtt/issues/25120)) ([7803a16](https://github.com/Koenkk/zigbee2mqtt/commit/7803a166d233ba0067e05ddf2afd35569f8d07e7))
|
||||
* **ignore:** update dependencies ([#25210](https://github.com/Koenkk/zigbee2mqtt/issues/25210)) ([56746de](https://github.com/Koenkk/zigbee2mqtt/commit/56746ded19013d47c681da142444b3c69bfd8992))
|
||||
* **ignore:** update dependencies ([#25288](https://github.com/Koenkk/zigbee2mqtt/issues/25288)) ([2128e83](https://github.com/Koenkk/zigbee2mqtt/commit/2128e8323cae6a79774d570ba36f0077586d09e4))
|
||||
* **ignore:** update dependencies ([#25360](https://github.com/Koenkk/zigbee2mqtt/issues/25360)) ([47d73eb](https://github.com/Koenkk/zigbee2mqtt/commit/47d73eb23a3c452f1757587184ba5a066f0e5edc))
|
||||
* **ignore:** update deps ([1ce45c8](https://github.com/Koenkk/zigbee2mqtt/commit/1ce45c8c4176a17d7aa03117fc6f39510b06f4a4))
|
||||
* **ignore:** Update usb discovery discussion link ([9bd20af](https://github.com/Koenkk/zigbee2mqtt/commit/9bd20afb9c7172a67c2d36f373ab151f20027cb9))
|
||||
* **ignore:** update zigbee-herdsman to 3.0.3 ([0a20e4a](https://github.com/Koenkk/zigbee2mqtt/commit/0a20e4a455ef1e25ab7134d6901512cb4980fe93))
|
||||
* **ignore:** Update zigbee-herdsman-converters to 21.4.0 ([#25191](https://github.com/Koenkk/zigbee2mqtt/issues/25191)) ([5e71cf2](https://github.com/Koenkk/zigbee2mqtt/commit/5e71cf2cc84cd6c5c1c24d801a1f2dadee4e7068))
|
||||
* **ignore:** Use new exports from ZH ([#25350](https://github.com/Koenkk/zigbee2mqtt/issues/25350)) ([120a5b4](https://github.com/Koenkk/zigbee2mqtt/commit/120a5b43e90a8fb206263e937c0031c3c1163da7))
|
||||
* Improve bind/bridge/groups parsing and resolving consistency ([#24432](https://github.com/Koenkk/zigbee2mqtt/issues/24432)) ([9537ef0](https://github.com/Koenkk/zigbee2mqtt/commit/9537ef05d563de815b24e45176a1bec68eb811b8))
|
||||
* Improve device icon serving ([#25299](https://github.com/Koenkk/zigbee2mqtt/issues/25299)) ([329b8c9](https://github.com/Koenkk/zigbee2mqtt/commit/329b8c9c27c2ba2537b59ef16241a6e84167fa7b))
|
||||
* Improve permit join ([#24257](https://github.com/Koenkk/zigbee2mqtt/issues/24257)) ([2124d34](https://github.com/Koenkk/zigbee2mqtt/commit/2124d3450cbc5ad5901a7fa2d657b881152a6db6))
|
||||
* OpenHAB: Fix rejectattr argument in `event` value_template ([#25179](https://github.com/Koenkk/zigbee2mqtt/issues/25179)) ([08ac795](https://github.com/Koenkk/zigbee2mqtt/commit/08ac795bb39854d29388e7ff1e12002cd89b87f8))
|
||||
* Proper extended PAN ID from `getNetworkParameters` ([#25174](https://github.com/Koenkk/zigbee2mqtt/issues/25174)) ([2b08c68](https://github.com/Koenkk/zigbee2mqtt/commit/2b08c688d3fab77fc1becb6646eb038040e5cf0a))
|
||||
* Refactor tests to TS ([#24357](https://github.com/Koenkk/zigbee2mqtt/issues/24357)) ([6f6e7c3](https://github.com/Koenkk/zigbee2mqtt/commit/6f6e7c3ee040504231e1aecda004edbd16301578))
|
||||
* Remove `permit_join_timeout` ([#25082](https://github.com/Koenkk/zigbee2mqtt/issues/25082)) ([7d63a87](https://github.com/Koenkk/zigbee2mqtt/commit/7d63a87aad954225c77156d22576939b1ffe7d6c))
|
||||
* Remove configuring group members through `configuration.yaml` ([#24338](https://github.com/Koenkk/zigbee2mqtt/issues/24338)) ([3f76586](https://github.com/Koenkk/zigbee2mqtt/commit/3f76586a506a3674333d17cff93d32810c76687e))
|
||||
* Remove deprecated Home Assistant integration features ([#24494](https://github.com/Koenkk/zigbee2mqtt/issues/24494)) ([e290057](https://github.com/Koenkk/zigbee2mqtt/commit/e290057e21ce5551563068420ea2942979206509))
|
||||
* Remove oneOf bool/obj from settings schema ([#25195](https://github.com/Koenkk/zigbee2mqtt/issues/25195)) ([c3b4628](https://github.com/Koenkk/zigbee2mqtt/commit/c3b4628a1d6891d186da01d9f4a7a3b089617fd4))
|
||||
* Rework OTA ([#24634](https://github.com/Koenkk/zigbee2mqtt/issues/24634)) ([563514c](https://github.com/Koenkk/zigbee2mqtt/commit/563514c446e39134ccd18829b10f6d2e02bef09b))
|
||||
* Use built-in async for mqtt ([#24786](https://github.com/Koenkk/zigbee2mqtt/issues/24786)) ([e052988](https://github.com/Koenkk/zigbee2mqtt/commit/e052988663d967ed8c41ecb5ea0e52e6f85905b4))
|
||||
|
||||
## [1.42.0](https://github.com/Koenkk/zigbee2mqtt/compare/1.41.0...1.42.0) (2024-12-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add experimental support for Home Assistant `event` entities ([#24233](https://github.com/Koenkk/zigbee2mqtt/issues/24233)) ([848f250](https://github.com/Koenkk/zigbee2mqtt/commit/848f250dbb7e0142c7e702dab97536b8be749f5b))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Home Assistant `event` entities, part 2 ([#24717](https://github.com/Koenkk/zigbee2mqtt/issues/24717)) ([22e13c5](https://github.com/Koenkk/zigbee2mqtt/commit/22e13c52b96152a24d735791c313b8c6c2cf68e7))
|
||||
* Home Assistant: remove the diagnostic category classification for power ([#24575](https://github.com/Koenkk/zigbee2mqtt/issues/24575)) ([c078ccb](https://github.com/Koenkk/zigbee2mqtt/commit/c078ccb13606a0893a19f25378197be70c0127f6))
|
||||
* Home Assistant: template errors when `action` key is missing or empty in state payload ([#24931](https://github.com/Koenkk/zigbee2mqtt/issues/24931)) ([5ca4cf4](https://github.com/Koenkk/zigbee2mqtt/commit/5ca4cf4acd758b9c8163c3f8d07cb9903d91e118))
|
||||
* **ignore:** Fix `Cannot read properties of undefined (reading 'clients')` https://github.com/Koenkk/zigbee2mqtt/issues/24957 ([7047aef](https://github.com/Koenkk/zigbee2mqtt/commit/7047aef7f7810225e80d923e6c45d5922562e12b))
|
||||
* **ignore:** Ignore `None` actions for HA events ([#24972](https://github.com/Koenkk/zigbee2mqtt/issues/24972)) ([4690fe1](https://github.com/Koenkk/zigbee2mqtt/commit/4690fe1a55b4d6a2a8ad39c21c157b67a41d5f62))
|
||||
* **ignore:** Prevent empty events from being published to HA ([#24969](https://github.com/Koenkk/zigbee2mqtt/issues/24969)) ([826547a](https://github.com/Koenkk/zigbee2mqtt/commit/826547a8e14beabfced2d9da408fa1d7a8e8a015))
|
||||
* **ignore:** Prevent incorrect `event_type` due to duplicate keys ([#24954](https://github.com/Koenkk/zigbee2mqtt/issues/24954)) ([d82d782](https://github.com/Koenkk/zigbee2mqtt/commit/d82d782bac0deda328e7025f9a2bcbd50fecf857))
|
||||
* **ignore:** update dependencies ([#24614](https://github.com/Koenkk/zigbee2mqtt/issues/24614)) ([84cc92f](https://github.com/Koenkk/zigbee2mqtt/commit/84cc92f91a020c86d5ae3938c635512cd106cd67))
|
||||
* **ignore:** update dependencies ([#24702](https://github.com/Koenkk/zigbee2mqtt/issues/24702)) ([928635f](https://github.com/Koenkk/zigbee2mqtt/commit/928635f1daea6e3e80afc30ff1eca3bc83de7f97))
|
||||
* **ignore:** Update dependencies ([#24894](https://github.com/Koenkk/zigbee2mqtt/issues/24894)) ([ca64935](https://github.com/Koenkk/zigbee2mqtt/commit/ca6493528db3ffb374068e837ec0151f079a6ac8))
|
||||
* **ignore:** update zigbee-herdsman to 2.1.8 ([#24763](https://github.com/Koenkk/zigbee2mqtt/issues/24763)) ([2a9bbb2](https://github.com/Koenkk/zigbee2mqtt/commit/2a9bbb21ba5c22bf32d702f02b60084c4e12f140))
|
||||
* **ignore:** update zigbee-herdsman to 2.1.9 ([#24819](https://github.com/Koenkk/zigbee2mqtt/issues/24819)) ([152ad12](https://github.com/Koenkk/zigbee2mqtt/commit/152ad12508724be23f1792238e4bc37f48413ec0))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.40.0 ([#24644](https://github.com/Koenkk/zigbee2mqtt/issues/24644)) ([1558a5d](https://github.com/Koenkk/zigbee2mqtt/commit/1558a5d8ace136ac1469f34502901d368603cbd1))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.41.0 ([#24656](https://github.com/Koenkk/zigbee2mqtt/issues/24656)) ([f2e5d59](https://github.com/Koenkk/zigbee2mqtt/commit/f2e5d59c3582ec12e1717c9c28520d54c593dd0e))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.42.0 ([#24666](https://github.com/Koenkk/zigbee2mqtt/issues/24666)) ([b5d4a25](https://github.com/Koenkk/zigbee2mqtt/commit/b5d4a253b4ef5b4930f8d84e878e2343cc0b38df))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.43.0 ([#24680](https://github.com/Koenkk/zigbee2mqtt/issues/24680)) ([a353acc](https://github.com/Koenkk/zigbee2mqtt/commit/a353acc8e3e98bf9ea3a935c1ee179aed335ff58))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.44.0 ([#24715](https://github.com/Koenkk/zigbee2mqtt/issues/24715)) ([32e2637](https://github.com/Koenkk/zigbee2mqtt/commit/32e2637594add5c6fa8f8bfccc28b0f759a5d873))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.45.0 ([#24733](https://github.com/Koenkk/zigbee2mqtt/issues/24733)) ([b8c3104](https://github.com/Koenkk/zigbee2mqtt/commit/b8c3104aa1baaf03c3db7954bc92b5ac71d985c4))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.46.0 ([#24762](https://github.com/Koenkk/zigbee2mqtt/issues/24762)) ([6e04f7b](https://github.com/Koenkk/zigbee2mqtt/commit/6e04f7b03e8c7f0493007ce1d9d359f819088557))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.47.0 ([#24772](https://github.com/Koenkk/zigbee2mqtt/issues/24772)) ([36e452b](https://github.com/Koenkk/zigbee2mqtt/commit/36e452b0f3284eb5ab872fb2d0aab04bbdfe4cee))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.49.1 ([#24830](https://github.com/Koenkk/zigbee2mqtt/issues/24830)) ([5859947](https://github.com/Koenkk/zigbee2mqtt/commit/5859947207adf8597436971706cdff101c1effbe))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.50.0 ([#24843](https://github.com/Koenkk/zigbee2mqtt/issues/24843)) ([988984a](https://github.com/Koenkk/zigbee2mqtt/commit/988984a1ae069d90ce1f67c165dd76c4edafa656))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.51.0 ([#24856](https://github.com/Koenkk/zigbee2mqtt/issues/24856)) ([82a026e](https://github.com/Koenkk/zigbee2mqtt/commit/82a026e02e0ecc662cf8302ff3005793cb66a23f))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.52.0 ([#24874](https://github.com/Koenkk/zigbee2mqtt/issues/24874)) ([1e77893](https://github.com/Koenkk/zigbee2mqtt/commit/1e77893f759908bafc373b047b6409d227516759))
|
||||
* **ignore:** Update zigbee-herdsman-converters to 20.53.0 ([#24906](https://github.com/Koenkk/zigbee2mqtt/issues/24906)) ([790373b](https://github.com/Koenkk/zigbee2mqtt/commit/790373b896efdb6d9ccb5c29c97812db7445c6a3))
|
||||
* **ignore:** update zigbee2mqtt-frontend to 0.7.5 ([#24631](https://github.com/Koenkk/zigbee2mqtt/issues/24631)) ([0c2cc10](https://github.com/Koenkk/zigbee2mqtt/commit/0c2cc10ad8fd8de1ac88566f8ca1a0d131b1055b))
|
||||
* Replace connect-gzip-static with express-static-gzip to become compatible with Node 23 ([#24619](https://github.com/Koenkk/zigbee2mqtt/issues/24619)) ([87cee1a](https://github.com/Koenkk/zigbee2mqtt/commit/87cee1aea6eace2efb31a2d30551b5ea32187e95))
|
||||
* Temporarily disable serving compressed index file. ([#24657](https://github.com/Koenkk/zigbee2mqtt/issues/24657)) ([7267d27](https://github.com/Koenkk/zigbee2mqtt/commit/7267d27779c854a216279b28b27551310b9cee97))
|
||||
|
||||
## [1.41.0](https://github.com/Koenkk/zigbee2mqtt/compare/1.40.2...1.41.0) (2024-10-31)
|
||||
|
||||
|
||||
|
||||
+15
-15
@@ -17,24 +17,24 @@ diverse, inclusive, and healthy community.
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
- The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
|
||||
+4
-4
@@ -2,7 +2,7 @@
|
||||
|
||||
Everybody is invited and welcome to contribute to Zigbee2MQTT. Zigbee2MQTT is written in JavaScript and is based upon [zigbee-herdsman](https://github.com/koenkk/zigbee-herdsman) and [zigbee-herdsman-converters](https://github.com/koenkk/zigbee-herdsman-converters). Zigbee-herdsman-converters contains all device definitions, zigbee-herdsman is responsible for handling all communication with the adapter.
|
||||
|
||||
- Pull requests are always created against the [**dev**](https://github.com/Koenkk/zigbee2mqtt/tree/dev) branch.
|
||||
- Easiest way to start developing Zigbee2MQTT is by setting up a development environment (aka bare-metal installation). You can follow this [guide](https://www.zigbee2mqtt.io/guide/installation/01_linux.html) to do this.
|
||||
- You can run the tests locally by executing `npm test`. Zigbee2MQTT enforces 100% code coverage, in case you add new code check if your code is covered by running `npm run test-with-coverage`. The coverage report can be found under `coverage/lcov-report/index.html`. Linting is also enforced and can be run with `npm run eslint`.
|
||||
- When you want to add support for a new device no changes to Zigbee2MQTT have to be made, only to zigbee-herdsman-converters. You can find a guide for it [here](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html).
|
||||
- Pull requests are always created against the [**dev**](https://github.com/Koenkk/zigbee2mqtt/tree/dev) branch.
|
||||
- Easiest way to start developing Zigbee2MQTT is by setting up a development environment (aka bare-metal installation). You can follow this [guide](https://www.zigbee2mqtt.io/guide/installation/01_linux.html) to do this.
|
||||
- You can run the tests locally by executing `pnpm test`. Zigbee2MQTT enforces 100% code coverage, in case you add new code check if your code is covered by running `pnpm run test-with-coverage`. The coverage report can be found under `coverage/lcov-report/index.html`. Linting is also enforced and can be run with `pnpm run eslint`.
|
||||
- When you want to add support for a new device no changes to Zigbee2MQTT have to be made, only to zigbee-herdsman-converters. You can find a guide for it [here](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html).
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<br>
|
||||
<br>
|
||||
<div style="display: flex;">
|
||||
<a href="https://github.com/Koenkk/zigbee2mqtt/actions?query=workflow%3ACI">
|
||||
<img src="https://github.com/koenkk/zigbee2mqtt/workflows/CI/badge.svg">
|
||||
<a href="https://github.com/Koenkk/zigbee2mqtt/actions?query=workflow%3Aci">
|
||||
<img src="https://github.com/koenkk/zigbee2mqtt/workflows/ci/badge.svg">
|
||||
</a>
|
||||
<a href="https://github.com/Koenkk/zigbee2mqtt/releases">
|
||||
<img src="https://img.shields.io/github/release/koenkk/zigbee2mqtt.svg">
|
||||
@@ -56,8 +56,8 @@ Zigbee2MQTT integrates well with (almost) every home automation solution because
|
||||
|
||||
### [Home Assistant](https://www.home-assistant.io/)
|
||||
|
||||
- [Home Assistant OS](https://www.home-assistant.io/installation/): Using [the official addon](https://github.com/zigbee2mqtt/hassio-zigbee2mqtt)
|
||||
- Other installation: using instructions [here](https://www.zigbee2mqtt.io/guide/usage/integrations/home_assistant.html)
|
||||
- [Home Assistant OS](https://www.home-assistant.io/installation/): Using [the official addon](https://github.com/zigbee2mqtt/hassio-zigbee2mqtt)
|
||||
- Other installation: using instructions [here](https://www.zigbee2mqtt.io/guide/usage/integrations/home_assistant.html)
|
||||
|
||||
<br>
|
||||
|
||||
@@ -65,8 +65,8 @@ Zigbee2MQTT integrates well with (almost) every home automation solution because
|
||||
|
||||
### [Homey](https://homey.app/)
|
||||
|
||||
- Integration implemented in the [Homey App](https://homey.app/nl-nl/app/com.gruijter.zigbee2mqtt/)
|
||||
- Documentation and support in the [Homey Forum](https://community.homey.app/t/83214)
|
||||
- Integration implemented in the [Homey App](https://homey.app/nl-nl/app/com.gruijter.zigbee2mqtt/)
|
||||
- Documentation and support in the [Homey Forum](https://community.homey.app/t/83214)
|
||||
|
||||
<br>
|
||||
|
||||
@@ -74,7 +74,7 @@ Zigbee2MQTT integrates well with (almost) every home automation solution because
|
||||
|
||||
### [Domoticz](https://www.domoticz.com/)
|
||||
|
||||
- Integration implemented in Domoticz ([documentation](https://www.domoticz.com/wiki/Zigbee2MQTT)).
|
||||
- Integration implemented in Domoticz ([documentation](https://www.domoticz.com/wiki/Zigbee2MQTT)).
|
||||
|
||||
<br>
|
||||
|
||||
@@ -82,7 +82,7 @@ Zigbee2MQTT integrates well with (almost) every home automation solution because
|
||||
|
||||
### [Gladys Assistant](https://gladysassistant.com/)
|
||||
|
||||
- Integration implemented natively in Gladys Assistant ([documentation](https://gladysassistant.com/docs/integrations/zigbee2mqtt/)).
|
||||
- Integration implemented natively in Gladys Assistant ([documentation](https://gladysassistant.com/docs/integrations/zigbee2mqtt/)).
|
||||
|
||||
<br>
|
||||
|
||||
@@ -90,7 +90,7 @@ Zigbee2MQTT integrates well with (almost) every home automation solution because
|
||||
|
||||
### [IoBroker](https://www.iobroker.net/)
|
||||
|
||||
- Integration implemented in IoBroker ([documentation](https://github.com/o0shojo0o/ioBroker.zigbee2mqtt)).
|
||||
- Integration implemented in IoBroker ([documentation](https://github.com/o0shojo0o/ioBroker.zigbee2mqtt)).
|
||||
|
||||
<br>
|
||||
|
||||
@@ -104,9 +104,10 @@ Zigbee2MQTT is made up of three modules, each developed in its own Github projec
|
||||
|
||||
### Developing
|
||||
|
||||
Zigbee2MQTT uses TypeScript (partially for now). Therefore after making changes to files in the `lib/` directory you need to recompile Zigbee2MQTT. This can be done by executing `npm run build`. For faster development instead of running `npm run build` you can run `npm run build-watch` in another terminal session, this will recompile as you change files.
|
||||
In first time before building you need to run `npm install --include=dev`
|
||||
Before submitting changes run `npm run test-with-coverage`, `npm run pretty:check` and `npm run eslint`
|
||||
Zigbee2MQTT uses TypeScript (partially for now). Therefore after making changes to files in the `lib/` directory you need to recompile Zigbee2MQTT. This can be done by executing `pnpm run build`. For faster development instead of running `pnpm run build` you can run `pnpm run build-watch` in another terminal session, this will recompile as you change files.
|
||||
|
||||
Before running any of the commands, you'll first need to run `pnpm install --include=dev`.
|
||||
Before submitting changes run `pnpm run test:coverage`, `pnpm run pretty:check` and `pnpm run eslint`
|
||||
|
||||
## Supported devices
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
module.exports = {
|
||||
presets: [['@babel/preset-env', {targets: {node: 'current'}}], '@babel/preset-typescript'],
|
||||
assumptions: {setPublicClassFields: true},
|
||||
plugins: [['@babel/plugin-proposal-decorators', {legacy: true}]],
|
||||
};
|
||||
@@ -1,8 +1,14 @@
|
||||
# Indicates the configuration version (used by configuration migrations)
|
||||
version: 4
|
||||
|
||||
# Home Assistant integration (MQTT discovery)
|
||||
homeassistant: false
|
||||
homeassistant:
|
||||
enabled: false
|
||||
|
||||
# Enable the frontend, runs on port 8080 by default
|
||||
frontend: true
|
||||
frontend:
|
||||
enabled: true
|
||||
# port: 8080
|
||||
|
||||
# MQTT settings
|
||||
mqtt:
|
||||
@@ -14,13 +20,24 @@ mqtt:
|
||||
# user: my_user
|
||||
# password: my_password
|
||||
|
||||
# Serial settings
|
||||
serial:
|
||||
# Location of CC2531 USB sniffer
|
||||
port: /dev/ttyACM0
|
||||
# Serial settings, only required when Zigbee2MQTT fails to start with:
|
||||
# USB adapter discovery error (No valid USB adapter found).
|
||||
# Specify valid 'adapter' and 'port' in your configuration.
|
||||
# serial:
|
||||
# # Location of the adapter
|
||||
# # USB adapters - use format "port: /dev/serial/by-id/XXX"
|
||||
# # Ethernet adapters - use format "port: tcp://192.168.1.12:6638"
|
||||
# port: /dev/serial/by-id/usb-Texas_Instruments_TI_CC2531_USB_CDC___0X00124B0018ED3DDF-if00
|
||||
# # Adapter type, allowed values: `zstack`, `ember`, `deconz`, `zigate` or `zboss`
|
||||
# adapter: zstack
|
||||
|
||||
# Periodically check whether devices are online/offline
|
||||
# availability:
|
||||
# enabled: false
|
||||
|
||||
# Advanced settings
|
||||
advanced:
|
||||
# channel: 11
|
||||
# Let Zigbee2MQTT generate a network key on first start
|
||||
network_key: GENERATE
|
||||
# Let Zigbee2MQTT generate a pan_id on first start
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# Home Assistant integration (MQTT discovery)
|
||||
homeassistant: false
|
||||
|
||||
# allow new devices to join
|
||||
permit_join: true
|
||||
|
||||
# MQTT settings
|
||||
mqtt:
|
||||
# MQTT base topic for zigbee2mqtt MQTT messages
|
||||
base_topic: zigbee2mqtt
|
||||
# MQTT server URL
|
||||
server: 'mqtt://localhost'
|
||||
# MQTT server authentication, uncomment if required:
|
||||
# user: my_user
|
||||
# password: my_password
|
||||
|
||||
# Serial settings
|
||||
serial:
|
||||
# Location of CC2531 USB sniffer
|
||||
port: /dev/ttyACM0
|
||||
+30
-16
@@ -1,33 +1,49 @@
|
||||
FROM alpine:3.18.4 as base
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
# Need to use Alpine 3.18.4 which uses Node 18 for arm/v6 and arm/v7, otherwise the build hangs.
|
||||
# See https://github.com/nodejs/docker-node/issues/2077
|
||||
FROM alpine:3.18.4 AS linux-arm-alpine
|
||||
FROM alpine:3.21 AS linux-arm64-alpine
|
||||
FROM alpine:3.21 AS linux-amd64-alpine
|
||||
FROM alpine:3.21 AS linux-riscv64-alpine
|
||||
FROM alpine:3.21 AS linux-386-alpine
|
||||
|
||||
FROM linux-${TARGETARCH}-alpine AS base
|
||||
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache tzdata eudev tini nodejs
|
||||
|
||||
# Dependencies and build
|
||||
FROM base as dependencies_and_build
|
||||
FROM base AS deps
|
||||
|
||||
COPY package*.json tsconfig.json index.js ./
|
||||
COPY lib ./lib
|
||||
|
||||
RUN apk add --no-cache --virtual .buildtools make gcc g++ python3 linux-headers git npm && \
|
||||
npm ci --production --no-audit --no-optional --no-update-notifier && \
|
||||
# Serialport needs to be rebuild for Alpine https://serialport.io/docs/9.x.x/guide-installation#alpine-linux
|
||||
npm rebuild --build-from-source && \
|
||||
apk del .buildtools
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
# Make and such are needed to compile serialport for riscv64
|
||||
RUN apk add make gcc g++ python3 linux-headers npm && \
|
||||
npm install -g pnpm@10.4.1 && \
|
||||
pnpm install --frozen-lockfile --no-optional && \
|
||||
# serialport has outdated prebuilds that appear to fail on some archs, force build on target platform
|
||||
rm -rf `find ./node_modules/.pnpm/ -wholename "*/@serialport/bindings-cpp/prebuilds" -type d` && \
|
||||
pnpm rebuild @serialport/bindings-cpp
|
||||
|
||||
# Release
|
||||
FROM base as release
|
||||
FROM base AS release
|
||||
|
||||
ARG DATE
|
||||
ARG VERSION
|
||||
LABEL org.opencontainers.image.authors="Koen Kanters"
|
||||
LABEL org.opencontainers.image.title="zigbee2mqtt"
|
||||
LABEL org.opencontainers.image.title="Zigbee2MQTT"
|
||||
LABEL org.opencontainers.image.description="Zigbee to MQTT bridge using Zigbee-herdsman"
|
||||
LABEL org.opencontainers.image.url="https://github.com/Koenkk/zigbee2mqtt"
|
||||
LABEL org.opencontainers.image.documentation="https://www.zigbee2mqtt.io/"
|
||||
LABEL org.opencontainers.image.source="https://github.com/Koenkk/zigbee2mqtt"
|
||||
LABEL org.opencontainers.image.licenses="GPL-3.0"
|
||||
LABEL org.opencontainers.image.created=${DATE}
|
||||
LABEL org.opencontainers.image.version=${VERSION}
|
||||
|
||||
COPY --from=dependencies_and_build /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY dist ./dist
|
||||
COPY package.json LICENSE index.js data/configuration.yaml data/configuration.example.yaml ./
|
||||
COPY package.json LICENSE index.js data/configuration.example.yaml ./
|
||||
|
||||
COPY docker/docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
@@ -37,7 +53,5 @@ RUN mkdir /app/data
|
||||
ARG COMMIT
|
||||
RUN echo "$COMMIT" > dist/.hash
|
||||
|
||||
ENV NODE_ENV production
|
||||
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD [ "/sbin/tini", "--", "node", "index.js"]
|
||||
|
||||
@@ -85,7 +85,7 @@ async function build(reason) {
|
||||
env.NODE_OPTIONS = '--max_old_space_size=256';
|
||||
}
|
||||
|
||||
exec('npm run build', {env, cwd: __dirname}, async (err, stdout, stderr) => {
|
||||
exec('pnpm run build', {env, cwd: __dirname}, async (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
process.stdout.write(', failed\n');
|
||||
|
||||
@@ -129,6 +129,13 @@ async function start() {
|
||||
|
||||
settings.reRead();
|
||||
|
||||
// gc
|
||||
{
|
||||
const settingsMigration = require('./dist/util/settingsMigration');
|
||||
|
||||
settingsMigration.migrateIfNecessary();
|
||||
}
|
||||
|
||||
const errors = settings.validate();
|
||||
|
||||
if (errors.length > 0) {
|
||||
@@ -148,7 +155,7 @@ async function start() {
|
||||
return exit(1);
|
||||
}
|
||||
|
||||
const Controller = require('./dist/controller');
|
||||
const {Controller} = require('./dist/controller');
|
||||
controller = new Controller(restart, exit);
|
||||
|
||||
await controller.start();
|
||||
|
||||
+33
-67
@@ -1,6 +1,9 @@
|
||||
import type {IClientPublishOptions} from 'mqtt';
|
||||
import type * as SdNotify from 'sd-notify';
|
||||
|
||||
import assert from 'assert';
|
||||
import type {Zigbee2MQTTAPI} from './types/api';
|
||||
|
||||
import assert from 'node:assert';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
@@ -14,15 +17,11 @@ import ExtensionBind from './extension/bind';
|
||||
import ExtensionBridge from './extension/bridge';
|
||||
import ExtensionConfigure from './extension/configure';
|
||||
import ExtensionExternalConverters from './extension/externalConverters';
|
||||
import ExtensionExternalExtension from './extension/externalExtension';
|
||||
import ExtensionExternalExtensions from './extension/externalExtensions';
|
||||
// Extensions
|
||||
import ExtensionFrontend from './extension/frontend';
|
||||
import ExtensionGroups from './extension/groups';
|
||||
import ExtensionHomeAssistant from './extension/homeassistant';
|
||||
import ExtensionBridgeLegacy from './extension/legacy/bridgeLegacy';
|
||||
import ExtensionDeviceGroupMembership from './extension/legacy/deviceGroupMembership';
|
||||
import ExtensionReport from './extension/legacy/report';
|
||||
import ExtensionSoftReset from './extension/legacy/softReset';
|
||||
import ExtensionNetworkMap from './extension/networkMap';
|
||||
import ExtensionOnEvent from './extension/onEvent';
|
||||
import ExtensionOTAUpdate from './extension/otaUpdate';
|
||||
@@ -41,20 +40,16 @@ const AllExtensions = [
|
||||
ExtensionPublish,
|
||||
ExtensionReceive,
|
||||
ExtensionNetworkMap,
|
||||
ExtensionSoftReset,
|
||||
ExtensionHomeAssistant,
|
||||
ExtensionConfigure,
|
||||
ExtensionDeviceGroupMembership,
|
||||
ExtensionBridgeLegacy,
|
||||
ExtensionBridge,
|
||||
ExtensionGroups,
|
||||
ExtensionBind,
|
||||
ExtensionReport,
|
||||
ExtensionOnEvent,
|
||||
ExtensionOTAUpdate,
|
||||
ExtensionExternalConverters,
|
||||
ExtensionFrontend,
|
||||
ExtensionExternalExtension,
|
||||
ExtensionExternalExtensions,
|
||||
ExtensionAvailability,
|
||||
];
|
||||
|
||||
@@ -104,41 +99,27 @@ export class Controller {
|
||||
];
|
||||
|
||||
this.extensions = [
|
||||
new ExtensionExternalConverters(...this.extensionArgs),
|
||||
new ExtensionOnEvent(...this.extensionArgs),
|
||||
new ExtensionBridge(...this.extensionArgs),
|
||||
new ExtensionPublish(...this.extensionArgs),
|
||||
new ExtensionReceive(...this.extensionArgs),
|
||||
new ExtensionDeviceGroupMembership(...this.extensionArgs),
|
||||
new ExtensionConfigure(...this.extensionArgs),
|
||||
new ExtensionNetworkMap(...this.extensionArgs),
|
||||
new ExtensionGroups(...this.extensionArgs),
|
||||
new ExtensionBind(...this.extensionArgs),
|
||||
new ExtensionOTAUpdate(...this.extensionArgs),
|
||||
new ExtensionReport(...this.extensionArgs),
|
||||
new ExtensionExternalExtension(...this.extensionArgs),
|
||||
new ExtensionExternalExtensions(...this.extensionArgs),
|
||||
new ExtensionAvailability(...this.extensionArgs),
|
||||
];
|
||||
|
||||
if (settings.get().frontend) {
|
||||
if (settings.get().frontend.enabled) {
|
||||
this.extensions.push(new ExtensionFrontend(...this.extensionArgs));
|
||||
}
|
||||
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
this.extensions.push(new ExtensionBridgeLegacy(...this.extensionArgs));
|
||||
}
|
||||
|
||||
if (settings.get().external_converters.length) {
|
||||
this.extensions.push(new ExtensionExternalConverters(...this.extensionArgs));
|
||||
}
|
||||
|
||||
if (settings.get().homeassistant) {
|
||||
if (settings.get().homeassistant.enabled) {
|
||||
this.extensions.push(new ExtensionHomeAssistant(...this.extensionArgs));
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
if (settings.get().advanced.soft_reset_timeout !== 0) {
|
||||
this.extensions.push(new ExtensionSoftReset(...this.extensionArgs));
|
||||
}
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
@@ -150,31 +131,31 @@ export class Controller {
|
||||
try {
|
||||
this.sdNotify = process.env.NOTIFY_SOCKET ? await import('sd-notify') : undefined;
|
||||
logger.debug('sd-notify loaded');
|
||||
/* v8 ignore start */
|
||||
} catch {
|
||||
// istanbul ignore next
|
||||
logger.debug('sd-notify is not installed');
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
// Start zigbee
|
||||
let startResult;
|
||||
try {
|
||||
startResult = await this.zigbee.start();
|
||||
await this.zigbee.start();
|
||||
this.eventBus.onAdapterDisconnected(this, this.onZigbeeAdapterDisconnected);
|
||||
} catch (error) {
|
||||
logger.error('Failed to start zigbee');
|
||||
logger.error('Check https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start.html for possible solutions');
|
||||
logger.error('Failed to start zigbee-herdsman');
|
||||
logger.error(
|
||||
'Check https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start_crashes-runtime.html for possible solutions',
|
||||
);
|
||||
logger.error('Exiting...');
|
||||
logger.error((error as Error).stack!);
|
||||
return await this.exit(1);
|
||||
}
|
||||
|
||||
// Disable some legacy options on new network creation
|
||||
if (startResult === 'reset') {
|
||||
settings.set(['advanced', 'homeassistant_legacy_entity_attributes'], false);
|
||||
settings.set(['advanced', 'legacy_api'], false);
|
||||
settings.set(['advanced', 'legacy_availability_payload'], false);
|
||||
settings.set(['device_options', 'legacy'], false);
|
||||
await this.enableDisableExtension(false, 'BridgeLegacy');
|
||||
/* v8 ignore start */
|
||||
if ((error as Error).message.includes('USB adapter discovery error (No valid USB adapter found)')) {
|
||||
logger.error('If this happens after updating to Zigbee2MQTT 2.0.0, see https://github.com/Koenkk/zigbee2mqtt/discussions/24364');
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
return await this.exit(1);
|
||||
}
|
||||
|
||||
// Log zigbee clients on startup
|
||||
@@ -192,19 +173,6 @@ export class Controller {
|
||||
|
||||
logger.info(`Currently ${deviceCount} devices are joined.`);
|
||||
|
||||
// Enable zigbee join
|
||||
try {
|
||||
if (settings.get().permit_join) {
|
||||
logger.warning('`permit_join` set to `true` in configuration.yaml.');
|
||||
logger.warning('Allowing new devices to join.');
|
||||
logger.warning('Set `permit_join` to `false` once you joined all devices.');
|
||||
}
|
||||
|
||||
await this.zigbee.permitJoin(settings.get().permit_join);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to set permit join to ${settings.get().permit_join} (${(error as Error).message})`);
|
||||
}
|
||||
|
||||
// MQTT
|
||||
try {
|
||||
await this.mqtt.connect();
|
||||
@@ -293,7 +261,7 @@ export class Controller {
|
||||
}
|
||||
|
||||
@bind async publishEntityState(entity: Group | Device, payload: KeyValue, stateChangeReason?: StateChangeReason): Promise<void> {
|
||||
let message = {...payload};
|
||||
let message: Zigbee2MQTTAPI['{friendlyName}'] = {...payload};
|
||||
|
||||
// Update state cache with new state.
|
||||
const newState = this.state.set(entity, payload, stateChangeReason);
|
||||
@@ -303,14 +271,14 @@ export class Controller {
|
||||
message = newState;
|
||||
}
|
||||
|
||||
const options: MQTTOptions = {
|
||||
retain: utils.getObjectProperty(entity.options, 'retain', false) as boolean,
|
||||
qos: utils.getObjectProperty(entity.options, 'qos', 0) as 0 | 1 | 2,
|
||||
const options: IClientPublishOptions = {
|
||||
retain: utils.getObjectProperty(entity.options, 'retain', false),
|
||||
qos: utils.getObjectProperty(entity.options, 'qos', 0),
|
||||
};
|
||||
const retention = utils.getObjectProperty<number | false>(entity.options, 'retention', false);
|
||||
|
||||
const retention = utils.getObjectProperty(entity.options, 'retention', false);
|
||||
if (retention !== false) {
|
||||
options.properties = {messageExpiryInterval: retention as number};
|
||||
options.properties = {messageExpiryInterval: retention};
|
||||
}
|
||||
|
||||
if (entity.isDevice() && settings.get().mqtt.include_device_information) {
|
||||
@@ -330,6 +298,7 @@ export class Controller {
|
||||
softwareBuildID: entity.zh.softwareBuildID,
|
||||
// Manufacturer name can contain \u0000, remove this.
|
||||
// https://github.com/home-assistant/core/issues/85691
|
||||
/* v8 ignore next */
|
||||
manufacturerName: entity.zh.manufacturerName?.split('\u0000')[0],
|
||||
};
|
||||
}
|
||||
@@ -352,7 +321,7 @@ export class Controller {
|
||||
// Filter mqtt message attributes
|
||||
utils.filterProperties(entity.options.filtered_attributes, message);
|
||||
|
||||
if (Object.entries(message).length) {
|
||||
if (!utils.objectIsEmpty(message)) {
|
||||
const output = settings.get().advanced.output;
|
||||
if (output === 'attribute_and_json' || output === 'json') {
|
||||
await this.mqtt.publish(entity.name, stringify(message), options);
|
||||
@@ -366,7 +335,7 @@ export class Controller {
|
||||
this.eventBus.emitPublishEntityState({entity, message, stateChangeReason, payload});
|
||||
}
|
||||
|
||||
async iteratePayloadAttributeOutput(topicRoot: string, payload: KeyValue, options: MQTTOptions): Promise<void> {
|
||||
async iteratePayloadAttributeOutput(topicRoot: string, payload: KeyValue, options: IClientPublishOptions): Promise<void> {
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
let subPayload = value;
|
||||
let message = null;
|
||||
@@ -398,11 +367,8 @@ export class Controller {
|
||||
try {
|
||||
await extension[method]?.();
|
||||
} catch (error) {
|
||||
/* istanbul ignore next */
|
||||
logger.error(`Failed to call '${extension.constructor.name}' '${method}' (${(error as Error).stack})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Controller;
|
||||
|
||||
+6
-8
@@ -1,4 +1,4 @@
|
||||
import events from 'events';
|
||||
import events from 'node:events';
|
||||
|
||||
import logger from './util/logger';
|
||||
|
||||
@@ -56,13 +56,6 @@ export default class EventBus {
|
||||
this.on('permitJoinChanged', callback, key);
|
||||
}
|
||||
|
||||
public emitPublishAvailability(): void {
|
||||
this.emitter.emit('publishAvailability');
|
||||
}
|
||||
public onPublishAvailability(key: ListenerKey, callback: () => void): void {
|
||||
this.on('publishAvailability', callback, key);
|
||||
}
|
||||
|
||||
public emitEntityRenamed(data: eventdata.EntityRenamed): void {
|
||||
this.emitter.emit('deviceRenamed', data);
|
||||
}
|
||||
@@ -196,6 +189,11 @@ export default class EventBus {
|
||||
this.on('stateChange', callback, key);
|
||||
}
|
||||
|
||||
public emitExposesAndDevicesChanged(device: Device): void {
|
||||
this.emitDevicesChanged();
|
||||
this.emitExposesChanged({device});
|
||||
}
|
||||
|
||||
private on<K extends keyof EventBusMap>(event: K, callback: EventBusListener<K>, key: ListenerKey): void {
|
||||
if (!this.callbacksByExtension[key.constructor.name]) {
|
||||
this.callbacksByExtension[key.constructor.name] = [];
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import assert from 'assert';
|
||||
import type {Zigbee2MQTTAPI} from 'lib/types/api';
|
||||
|
||||
import assert from 'node:assert';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import debounce from 'debounce';
|
||||
@@ -29,14 +31,9 @@ export default class Availability extends Extension {
|
||||
return utils.minutes(device.options.availability.timeout);
|
||||
}
|
||||
|
||||
const key = this.isActiveDevice(device) ? 'active' : 'passive';
|
||||
let value = settings.get().availability?.[key]?.timeout;
|
||||
const type = this.isActiveDevice(device) ? 'active' : 'passive';
|
||||
|
||||
if (value == null) {
|
||||
value = key == 'active' ? 10 : 1500;
|
||||
}
|
||||
|
||||
return utils.minutes(value);
|
||||
return utils.minutes(settings.get().availability[type].timeout);
|
||||
}
|
||||
|
||||
private isActiveDevice(device: Device): boolean {
|
||||
@@ -45,7 +42,7 @@ export default class Availability extends Extension {
|
||||
|
||||
private isAvailable(entity: Device | Group): boolean {
|
||||
if (entity.isDevice()) {
|
||||
return Date.now() - (entity.zh.lastSeen ?? /* istanbul ignore next */ 0) < this.getTimeout(entity);
|
||||
return Date.now() - (entity.zh.lastSeen ?? /* v8 ignore next */ 0) < this.getTimeout(entity);
|
||||
} else {
|
||||
const membersDevices = entity.membersDevices();
|
||||
return membersDevices.length === 0 || membersDevices.some((d) => this.availabilityCache[d.ieeeAddr]);
|
||||
@@ -140,7 +137,6 @@ export default class Availability extends Extension {
|
||||
this.eventBus.onDeviceLeave(this, (data) => clearTimeout(this.timers[data.ieeeAddr]));
|
||||
this.eventBus.onDeviceAnnounce(this, (data) => this.retrieveState(data.device));
|
||||
this.eventBus.onLastSeenChanged(this, this.onLastSeenChanged);
|
||||
this.eventBus.onPublishAvailability(this, this.publishAvailabilityForAllEntities);
|
||||
this.eventBus.onGroupMembersChanged(this, (data) => this.publishAvailability(data.group, false));
|
||||
// Publish initial availability
|
||||
await this.publishAvailabilityForAllEntities();
|
||||
@@ -168,7 +164,7 @@ export default class Availability extends Extension {
|
||||
|
||||
private async publishAvailability(entity: Device | Group, logLastSeen: boolean, forcePublish = false, skipGroups = false): Promise<void> {
|
||||
if (logLastSeen && entity.isDevice()) {
|
||||
const ago = Date.now() - (entity.zh.lastSeen ?? /* istanbul ignore next */ 0);
|
||||
const ago = Date.now() - (entity.zh.lastSeen ?? /* v8 ignore next */ 0);
|
||||
|
||||
if (this.isActiveDevice(entity)) {
|
||||
logger.debug(`Active device '${entity.name}' was last seen '${(ago / utils.minutes(1)).toFixed(2)}' minutes ago.`);
|
||||
@@ -189,9 +185,9 @@ export default class Availability extends Extension {
|
||||
}
|
||||
|
||||
const topic = `${entity.name}/availability`;
|
||||
const payload = utils.availabilityPayload(available ? 'online' : 'offline', settings.get());
|
||||
const payload: Zigbee2MQTTAPI['{friendlyName}/availability'] = {state: available ? 'online' : 'offline'};
|
||||
this.availabilityCache[entity.ID] = available;
|
||||
await this.mqtt.publish(topic, payload, {retain: true, qos: 1});
|
||||
await this.mqtt.publish(topic, JSON.stringify(payload), {retain: true, qos: 1});
|
||||
|
||||
if (!skipGroups && entity.isDevice()) {
|
||||
for (const group of this.zigbee.groupsIterator()) {
|
||||
@@ -247,6 +243,8 @@ export default class Availability extends Extension {
|
||||
options,
|
||||
state,
|
||||
device: device.zh,
|
||||
/* v8 ignore next */
|
||||
publish: (payload: KeyValue) => this.publishEntityState(device, payload),
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
+179
-141
@@ -1,4 +1,6 @@
|
||||
import assert from 'assert';
|
||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from 'lib/types/api';
|
||||
|
||||
import assert from 'node:assert';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import debounce from 'debounce';
|
||||
@@ -14,8 +16,6 @@ import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import Extension from './extension';
|
||||
|
||||
const LEGACY_API = settings.get().advanced.legacy_api;
|
||||
const LEGACY_TOPIC_REGEX = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/(bind|unbind)/.+$`);
|
||||
const TOPIC_REGEX = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/(bind|unbind)`);
|
||||
const ALL_CLUSTER_CANDIDATES: readonly ClusterName[] = [
|
||||
'genScenes',
|
||||
@@ -170,12 +170,10 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
|
||||
const supportedAttrs = await getColorCapabilities(endpoint);
|
||||
const readAttrs: string[] = [];
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (supportedAttrs.colorXY) {
|
||||
readAttrs.push('currentX', 'currentY');
|
||||
}
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (supportedAttrs.colorTemperature) {
|
||||
readAttrs.push('colorTemperature');
|
||||
}
|
||||
@@ -197,17 +195,16 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
|
||||
|
||||
interface ParsedMQTTMessage {
|
||||
type: 'bind' | 'unbind';
|
||||
sourceKey: string;
|
||||
targetKey: string;
|
||||
sourceKey?: string;
|
||||
sourceEndpointKey?: string | number;
|
||||
targetKey?: string;
|
||||
targetEndpointKey?: string | number;
|
||||
clusters?: string[];
|
||||
skipDisableReporting: boolean;
|
||||
}
|
||||
|
||||
interface DataMessage {
|
||||
from: ParsedMQTTMessage['sourceKey'];
|
||||
to: ParsedMQTTMessage['targetKey'];
|
||||
clusters: ParsedMQTTMessage['clusters'];
|
||||
skip_disable_reporting?: ParsedMQTTMessage['skipDisableReporting'];
|
||||
resolvedSource?: Device;
|
||||
resolvedTarget?: Device | Group | typeof DEFAULT_BIND_GROUP;
|
||||
resolvedSourceEndpoint?: zh.Endpoint;
|
||||
resolvedBindTarget?: number | zh.Endpoint | zh.Group;
|
||||
}
|
||||
|
||||
export default class Bind extends Extension {
|
||||
@@ -219,158 +216,203 @@ export default class Bind extends Extension {
|
||||
this.eventBus.onGroupMembersChanged(this, this.onGroupMembersChanged);
|
||||
}
|
||||
|
||||
private parseMQTTMessage(data: eventdata.MQTTMessage): ParsedMQTTMessage | undefined {
|
||||
let type: ParsedMQTTMessage['type'] | undefined;
|
||||
let sourceKey: ParsedMQTTMessage['sourceKey'] | undefined;
|
||||
let targetKey: ParsedMQTTMessage['targetKey'] | undefined;
|
||||
let clusters: ParsedMQTTMessage['clusters'] | undefined;
|
||||
let skipDisableReporting: ParsedMQTTMessage['skipDisableReporting'] = false;
|
||||
private parseMQTTMessage(
|
||||
data: eventdata.MQTTMessage,
|
||||
): [raw: KeyValue | undefined, parsed: ParsedMQTTMessage | undefined, error: string | undefined] {
|
||||
if (data.topic.match(TOPIC_REGEX)) {
|
||||
const type = data.topic.endsWith('unbind') ? 'unbind' : 'bind';
|
||||
let skipDisableReporting = false;
|
||||
const message = JSON.parse(data.message) as Zigbee2MQTTAPI['bridge/request/device/bind'];
|
||||
|
||||
if (LEGACY_API && data.topic.match(LEGACY_TOPIC_REGEX)) {
|
||||
const topic = data.topic.replace(`${settings.get().mqtt.base_topic}/bridge/`, '');
|
||||
type = topic.split('/')[0] as ParsedMQTTMessage['type'];
|
||||
sourceKey = topic.replace(`${type}/`, '');
|
||||
targetKey = data.message;
|
||||
} else if (data.topic.match(TOPIC_REGEX)) {
|
||||
type = data.topic.endsWith('unbind') ? 'unbind' : 'bind';
|
||||
const message: DataMessage = JSON.parse(data.message);
|
||||
sourceKey = message.from;
|
||||
targetKey = message.to;
|
||||
clusters = message.clusters;
|
||||
if (typeof message !== 'object' || message.from == undefined || message.to == undefined) {
|
||||
return [message, {type, skipDisableReporting}, `Invalid payload`];
|
||||
}
|
||||
|
||||
const sourceKey = message.from;
|
||||
const sourceEndpointKey = message.from_endpoint ?? 'default';
|
||||
const targetKey = message.to;
|
||||
const targetEndpointKey = message.to_endpoint;
|
||||
const clusters = message.clusters;
|
||||
skipDisableReporting = message.skip_disable_reporting != undefined ? message.skip_disable_reporting : false;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
const resolvedSource = this.zigbee.resolveEntity(message.from) as Device;
|
||||
|
||||
return {type, sourceKey, targetKey, clusters, skipDisableReporting};
|
||||
if (!resolvedSource || !(resolvedSource instanceof Device)) {
|
||||
return [message, {type, skipDisableReporting}, `Source device '${message.from}' does not exist`];
|
||||
}
|
||||
|
||||
const resolvedTarget = message.to === DEFAULT_BIND_GROUP.name ? DEFAULT_BIND_GROUP : this.zigbee.resolveEntity(message.to);
|
||||
|
||||
if (!resolvedTarget) {
|
||||
return [message, {type, skipDisableReporting}, `Target device or group '${message.to}' does not exist`];
|
||||
}
|
||||
|
||||
const resolvedSourceEndpoint = resolvedSource.endpoint(sourceEndpointKey);
|
||||
|
||||
if (!resolvedSourceEndpoint) {
|
||||
return [
|
||||
message,
|
||||
{type, skipDisableReporting},
|
||||
`Source device '${resolvedSource.name}' does not have endpoint '${sourceEndpointKey}'`,
|
||||
];
|
||||
}
|
||||
|
||||
// resolves to 'default' endpoint if targetEndpointKey is invalid (used by frontend for 'Coordinator')
|
||||
const resolvedBindTarget =
|
||||
resolvedTarget instanceof Device
|
||||
? resolvedTarget.endpoint(targetEndpointKey)
|
||||
: resolvedTarget instanceof Group
|
||||
? resolvedTarget.zh
|
||||
: Number(resolvedTarget.ID);
|
||||
|
||||
if (resolvedTarget instanceof Device && !resolvedBindTarget) {
|
||||
return [
|
||||
message,
|
||||
{type, skipDisableReporting},
|
||||
`Target device '${resolvedTarget.name}' does not have endpoint '${targetEndpointKey}'`,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
message,
|
||||
{
|
||||
type,
|
||||
sourceKey,
|
||||
sourceEndpointKey,
|
||||
targetKey,
|
||||
targetEndpointKey,
|
||||
clusters,
|
||||
skipDisableReporting,
|
||||
resolvedSource,
|
||||
resolvedTarget,
|
||||
resolvedSourceEndpoint,
|
||||
resolvedBindTarget,
|
||||
},
|
||||
undefined,
|
||||
];
|
||||
} else {
|
||||
return [undefined, undefined, undefined];
|
||||
}
|
||||
}
|
||||
|
||||
@bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const parsed = this.parseMQTTMessage(data);
|
||||
const [raw, parsed, error] = this.parseMQTTMessage(data);
|
||||
|
||||
if (!parsed || !parsed.type) {
|
||||
if (!raw || !parsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {type, sourceKey, targetKey, clusters, skipDisableReporting} = parsed;
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
if (error) {
|
||||
await this.publishResponse(parsed.type, raw, {}, error);
|
||||
return;
|
||||
}
|
||||
|
||||
let error: string | undefined;
|
||||
const parsedSource = this.zigbee.resolveEntityAndEndpoint(sourceKey);
|
||||
const parsedTarget = this.zigbee.resolveEntityAndEndpoint(targetKey);
|
||||
const source = parsedSource.entity;
|
||||
const target = targetKey === DEFAULT_BIND_GROUP.name ? DEFAULT_BIND_GROUP : parsedTarget.entity;
|
||||
const responseData: KeyValue = {from: sourceKey, to: targetKey};
|
||||
const {
|
||||
type,
|
||||
sourceKey,
|
||||
sourceEndpointKey,
|
||||
targetKey,
|
||||
targetEndpointKey,
|
||||
clusters,
|
||||
skipDisableReporting,
|
||||
resolvedSource,
|
||||
resolvedTarget,
|
||||
resolvedSourceEndpoint,
|
||||
resolvedBindTarget,
|
||||
} = parsed;
|
||||
|
||||
if (!source || !(source instanceof Device)) {
|
||||
error = `Source device '${sourceKey}' does not exist`;
|
||||
} else if (parsedSource.endpointID && !parsedSource.endpoint) {
|
||||
error = `Source device '${parsedSource.ID}' does not have endpoint '${parsedSource.endpointID}'`;
|
||||
} else if (!target) {
|
||||
error = `Target device or group '${targetKey}' does not exist`;
|
||||
} else if (target instanceof Device && parsedTarget.endpointID && !parsedTarget.endpoint) {
|
||||
error = `Target device '${parsedTarget.ID}' does not have endpoint '${parsedTarget.endpointID}'`;
|
||||
} else {
|
||||
const successfulClusters: string[] = [];
|
||||
const failedClusters = [];
|
||||
const attemptedClusters = [];
|
||||
assert(resolvedSource, '`resolvedSource` is missing');
|
||||
assert(resolvedTarget, '`resolvedTarget` is missing');
|
||||
assert(resolvedSourceEndpoint, '`resolvedSourceEndpoint` is missing');
|
||||
assert(resolvedBindTarget != undefined, '`resolvedBindTarget` is missing');
|
||||
|
||||
const bindSource = parsedSource.endpoint;
|
||||
const bindTarget = target instanceof Device ? parsedTarget.endpoint : target instanceof Group ? target.zh : Number(target.ID);
|
||||
const successfulClusters: string[] = [];
|
||||
const failedClusters = [];
|
||||
const attemptedClusters = [];
|
||||
// Find which clusters are supported by both the source and target.
|
||||
// Groups are assumed to support all clusters.
|
||||
const clusterCandidates = clusters ?? ALL_CLUSTER_CANDIDATES;
|
||||
|
||||
assert(bindSource != undefined && bindTarget != undefined);
|
||||
for (const cluster of clusterCandidates) {
|
||||
let matchingClusters = false;
|
||||
|
||||
// Find which clusters are supported by both the source and target.
|
||||
// Groups are assumed to support all clusters.
|
||||
const clusterCandidates = clusters ?? ALL_CLUSTER_CANDIDATES;
|
||||
const anyClusterValid =
|
||||
utils.isZHGroup(resolvedBindTarget) ||
|
||||
typeof resolvedBindTarget === 'number' ||
|
||||
(resolvedTarget instanceof Device && resolvedTarget.zh.type === 'Coordinator');
|
||||
|
||||
for (const cluster of clusterCandidates) {
|
||||
let matchingClusters = false;
|
||||
if (!anyClusterValid && utils.isZHEndpoint(resolvedBindTarget)) {
|
||||
matchingClusters =
|
||||
(resolvedBindTarget.supportsInputCluster(cluster) && resolvedSourceEndpoint.supportsOutputCluster(cluster)) ||
|
||||
(resolvedSourceEndpoint.supportsInputCluster(cluster) && resolvedBindTarget.supportsOutputCluster(cluster));
|
||||
}
|
||||
|
||||
const anyClusterValid = utils.isZHGroup(bindTarget) || typeof bindTarget === 'number' || (target as Device).zh.type === 'Coordinator';
|
||||
const sourceValid = resolvedSourceEndpoint.supportsInputCluster(cluster) || resolvedSourceEndpoint.supportsOutputCluster(cluster);
|
||||
|
||||
if (!anyClusterValid && utils.isZHEndpoint(bindTarget)) {
|
||||
matchingClusters =
|
||||
(bindTarget.supportsInputCluster(cluster) && bindSource.supportsOutputCluster(cluster)) ||
|
||||
(bindSource.supportsInputCluster(cluster) && bindTarget.supportsOutputCluster(cluster));
|
||||
}
|
||||
if (sourceValid && (anyClusterValid || matchingClusters)) {
|
||||
logger.debug(`${type}ing cluster '${cluster}' from '${resolvedSource.name}' to '${resolvedTarget.name}'`);
|
||||
attemptedClusters.push(cluster);
|
||||
|
||||
const sourceValid = bindSource.supportsInputCluster(cluster) || bindSource.supportsOutputCluster(cluster);
|
||||
|
||||
if (sourceValid && (anyClusterValid || matchingClusters)) {
|
||||
logger.debug(`${type}ing cluster '${cluster}' from '${source.name}' to '${target.name}'`);
|
||||
attemptedClusters.push(cluster);
|
||||
|
||||
try {
|
||||
if (type === 'bind') {
|
||||
await bindSource.bind(cluster, bindTarget);
|
||||
} else {
|
||||
await bindSource.unbind(cluster, bindTarget);
|
||||
}
|
||||
|
||||
successfulClusters.push(cluster);
|
||||
logger.info(
|
||||
`Successfully ${type === 'bind' ? 'bound' : 'unbound'} cluster '${cluster}' from '${source.name}' to '${target.name}'`,
|
||||
);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
await this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_${type}`, message: {from: source.name, to: target.name, cluster}}),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
failedClusters.push(cluster);
|
||||
logger.error(`Failed to ${type} cluster '${cluster}' from '${source.name}' to '${target.name}' (${error})`);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
await this.mqtt.publish(
|
||||
'bridge/log',
|
||||
stringify({type: `device_${type}_failed`, message: {from: source.name, to: target.name, cluster}}),
|
||||
);
|
||||
}
|
||||
try {
|
||||
if (type === 'bind') {
|
||||
await resolvedSourceEndpoint.bind(cluster, resolvedBindTarget);
|
||||
} else {
|
||||
await resolvedSourceEndpoint.unbind(cluster, resolvedBindTarget);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (attemptedClusters.length === 0) {
|
||||
logger.error(`Nothing to ${type} from '${source.name}' to '${target.name}'`);
|
||||
error = `Nothing to ${type}`;
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `device_${type}_failed`, message: {from: source.name, to: target.name}}));
|
||||
}
|
||||
} else if (failedClusters.length === attemptedClusters.length) {
|
||||
error = `Failed to ${type}`;
|
||||
}
|
||||
|
||||
responseData[`clusters`] = successfulClusters;
|
||||
responseData[`failed`] = failedClusters;
|
||||
|
||||
if (successfulClusters.length !== 0) {
|
||||
if (type === 'bind') {
|
||||
await this.setupReporting(bindSource.binds.filter((b) => successfulClusters.includes(b.cluster.name) && b.target === bindTarget));
|
||||
} else if (typeof bindTarget !== 'number' && !skipDisableReporting) {
|
||||
await this.disableUnnecessaryReportings(bindTarget);
|
||||
successfulClusters.push(cluster);
|
||||
logger.info(
|
||||
`Successfully ${type === 'bind' ? 'bound' : 'unbound'} cluster '${cluster}' from '${resolvedSource.name}' to '${resolvedTarget.name}'`,
|
||||
);
|
||||
} catch (error) {
|
||||
failedClusters.push(cluster);
|
||||
logger.error(`Failed to ${type} cluster '${cluster}' from '${resolvedSource.name}' to '${resolvedTarget.name}' (${error})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const triggeredViaLegacyApi = data.topic.match(LEGACY_TOPIC_REGEX);
|
||||
|
||||
if (!triggeredViaLegacyApi) {
|
||||
const response = utils.getResponse(message, responseData, error);
|
||||
|
||||
await this.mqtt.publish(`bridge/response/device/${type}`, stringify(response));
|
||||
if (attemptedClusters.length === 0) {
|
||||
logger.error(`Nothing to ${type} from '${resolvedSource.name}' to '${resolvedTarget.name}'`);
|
||||
await this.publishResponse(parsed.type, raw, {}, `Nothing to ${type}`);
|
||||
return;
|
||||
} else if (failedClusters.length === attemptedClusters.length) {
|
||||
await this.publishResponse(parsed.type, raw, {}, `Failed to ${type}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const responseData: Zigbee2MQTTAPI['bridge/response/device/bind'] | Zigbee2MQTTAPI['bridge/response/device/unbind'] = {
|
||||
from: sourceKey!, // valid with assert above on `resolvedSource`
|
||||
from_endpoint: sourceEndpointKey!, // valid with assert above on `resolvedSourceEndpoint`
|
||||
to: targetKey!, // valid with assert above on `resolvedTarget`
|
||||
to_endpoint: targetEndpointKey,
|
||||
clusters: successfulClusters,
|
||||
failed: failedClusters,
|
||||
};
|
||||
|
||||
if (successfulClusters.length !== 0) {
|
||||
if (type === 'bind') {
|
||||
await this.setupReporting(
|
||||
resolvedSourceEndpoint.binds.filter((b) => successfulClusters.includes(b.cluster.name) && b.target === resolvedBindTarget),
|
||||
);
|
||||
} else if (typeof resolvedBindTarget !== 'number' && !skipDisableReporting) {
|
||||
await this.disableUnnecessaryReportings(resolvedBindTarget);
|
||||
}
|
||||
}
|
||||
|
||||
await this.publishResponse(parsed.type, raw, responseData);
|
||||
this.eventBus.emitDevicesChanged();
|
||||
}
|
||||
|
||||
private async publishResponse<T extends Zigbee2MQTTResponseEndpoints>(
|
||||
type: ParsedMQTTMessage['type'],
|
||||
request: KeyValue,
|
||||
data: Zigbee2MQTTAPI[T],
|
||||
error?: string,
|
||||
): Promise<void> {
|
||||
const response = utils.getResponse(request, data, error);
|
||||
await this.mqtt.publish(`bridge/response/device/${type}`, stringify(response));
|
||||
|
||||
if (error) {
|
||||
logger.error(error);
|
||||
} else {
|
||||
this.eventBus.emitDevicesChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,7 +463,6 @@ export default class Bind extends Extension {
|
||||
const coordinatorEndpoint = this.zigbee.firstCoordinatorEndpoint();
|
||||
|
||||
for (const bind of binds) {
|
||||
/* istanbul ignore else */
|
||||
if (bind.cluster.name in REPORT_CLUSTERS) {
|
||||
for (const endpoint of this.getSetupReportingEndpoints(bind, coordinatorEndpoint)) {
|
||||
const entity = `${this.zigbee.resolveEntity(endpoint.getDevice())!.name}/${endpoint.ID}`;
|
||||
@@ -432,7 +473,6 @@ export default class Bind extends Extension {
|
||||
const items = [];
|
||||
|
||||
for (const c of REPORT_CLUSTERS[bind.cluster.name as ClusterName]!) {
|
||||
/* istanbul ignore else */
|
||||
if (!c.condition || (await c.condition(endpoint))) {
|
||||
const i = {...c};
|
||||
delete i.condition;
|
||||
@@ -479,7 +519,6 @@ export default class Bind extends Extension {
|
||||
}
|
||||
|
||||
for (const b of endpoint.binds) {
|
||||
/* istanbul ignore else */
|
||||
if (b.target === coordinator && !requiredClusters.includes(b.cluster.name) && b.cluster.name in REPORT_CLUSTERS) {
|
||||
boundClusters.push(b.cluster.name);
|
||||
}
|
||||
@@ -492,7 +531,6 @@ export default class Bind extends Extension {
|
||||
const items = [];
|
||||
|
||||
for (const item of REPORT_CLUSTERS[cluster as ClusterName]!) {
|
||||
/* istanbul ignore else */
|
||||
if (!item.condition || (await item.condition(endpoint))) {
|
||||
const i = {...item};
|
||||
delete i.condition;
|
||||
|
||||
+180
-250
@@ -1,4 +1,6 @@
|
||||
import fs from 'fs';
|
||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTDevice, Zigbee2MQTTResponse, Zigbee2MQTTResponseEndpoints} from 'lib/types/api';
|
||||
|
||||
import fs from 'node:fs';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
@@ -7,9 +9,8 @@ import objectAssignDeep from 'object-assign-deep';
|
||||
import winston from 'winston';
|
||||
import Transport from 'winston-transport';
|
||||
|
||||
import {Zcl} from 'zigbee-herdsman';
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
import {Clusters} from 'zigbee-herdsman/dist/zspec/zcl/definition/cluster';
|
||||
import {ClusterDefinition, ClusterName, CustomClusters} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype';
|
||||
|
||||
import Device from '../model/device';
|
||||
import Group from '../model/group';
|
||||
@@ -19,64 +20,41 @@ import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import Extension from './extension';
|
||||
|
||||
const requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/(.*)`);
|
||||
|
||||
type DefinitionPayload = {
|
||||
model: string;
|
||||
vendor: string;
|
||||
description: string;
|
||||
exposes: zhc.Expose[];
|
||||
supports_ota: boolean;
|
||||
icon: string;
|
||||
options: zhc.Option[];
|
||||
};
|
||||
const REQUEST_REGEX = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/(.*)`);
|
||||
|
||||
export default class Bridge extends Extension {
|
||||
// @ts-expect-error initialized in `start`
|
||||
private zigbee2mqttVersion: {commitHash?: string; version: string};
|
||||
// @ts-expect-error initialized in `start`
|
||||
private zigbeeHerdsmanVersion: {version: string};
|
||||
// @ts-expect-error initialized in `start`
|
||||
private zigbeeHerdsmanConvertersVersion: {version: string};
|
||||
// @ts-expect-error initialized in `start`
|
||||
private coordinatorVersion: zh.CoordinatorVersion;
|
||||
private zigbee2mqttVersion!: {commitHash?: string; version: string};
|
||||
private zigbeeHerdsmanVersion!: {version: string};
|
||||
private zigbeeHerdsmanConvertersVersion!: {version: string};
|
||||
private coordinatorVersion!: zh.CoordinatorVersion;
|
||||
private restartRequired = false;
|
||||
private lastJoinedDeviceIeeeAddr?: string;
|
||||
private lastBridgeLoggingPayload?: string;
|
||||
// @ts-expect-error initialized in `start`
|
||||
private logTransport: winston.transport;
|
||||
// @ts-expect-error initialized in `start`
|
||||
private requestLookup: {[key: string]: (message: KeyValue | string) => Promise<MQTTResponse>};
|
||||
private logTransport!: winston.transport;
|
||||
private requestLookup: {[key: string]: (message: KeyValue | string) => Promise<Zigbee2MQTTResponse<Zigbee2MQTTResponseEndpoints>>} = {
|
||||
'device/options': this.deviceOptions,
|
||||
'device/configure_reporting': this.deviceConfigureReporting,
|
||||
'device/remove': this.deviceRemove,
|
||||
'device/interview': this.deviceInterview,
|
||||
'device/generate_external_definition': this.deviceGenerateExternalDefinition,
|
||||
'device/rename': this.deviceRename,
|
||||
'group/add': this.groupAdd,
|
||||
'group/options': this.groupOptions,
|
||||
'group/remove': this.groupRemove,
|
||||
'group/rename': this.groupRename,
|
||||
permit_join: this.permitJoin,
|
||||
restart: this.restart,
|
||||
backup: this.backup,
|
||||
'touchlink/factory_reset': this.touchlinkFactoryReset,
|
||||
'touchlink/identify': this.touchlinkIdentify,
|
||||
'install_code/add': this.installCodeAdd,
|
||||
'touchlink/scan': this.touchlinkScan,
|
||||
health_check: this.healthCheck,
|
||||
coordinator_check: this.coordinatorCheck,
|
||||
options: this.bridgeOptions,
|
||||
};
|
||||
|
||||
override async start(): Promise<void> {
|
||||
this.requestLookup = {
|
||||
'device/options': this.deviceOptions,
|
||||
'device/configure_reporting': this.deviceConfigureReporting,
|
||||
'device/remove': this.deviceRemove,
|
||||
'device/interview': this.deviceInterview,
|
||||
'device/generate_external_definition': this.deviceGenerateExternalDefinition,
|
||||
'device/rename': this.deviceRename,
|
||||
'group/add': this.groupAdd,
|
||||
'group/options': this.groupOptions,
|
||||
'group/remove': this.groupRemove,
|
||||
'group/rename': this.groupRename,
|
||||
permit_join: this.permitJoin,
|
||||
restart: this.restart,
|
||||
backup: this.backup,
|
||||
'touchlink/factory_reset': this.touchlinkFactoryReset,
|
||||
'touchlink/identify': this.touchlinkIdentify,
|
||||
'install_code/add': this.installCodeAdd,
|
||||
'touchlink/scan': this.touchlinkScan,
|
||||
health_check: this.healthCheck,
|
||||
coordinator_check: this.coordinatorCheck,
|
||||
options: this.bridgeOptions,
|
||||
// Below are deprecated
|
||||
'config/last_seen': this.configLastSeen,
|
||||
'config/homeassistant': this.configHomeAssistant,
|
||||
'config/elapsed': this.configElapsed,
|
||||
'config/log_level': this.configLogLevel,
|
||||
};
|
||||
|
||||
const debugToMQTTFrontend = settings.get().advanced.log_debug_to_mqtt_frontend;
|
||||
const baseTopic = settings.get().mqtt.base_topic;
|
||||
|
||||
@@ -91,7 +69,7 @@ export default class Bridge extends Extension {
|
||||
|
||||
if (debugToMQTTFrontend) {
|
||||
class DebugEventTransport extends Transport {
|
||||
log(info: {message: string; level: string; namespace: string}, next: () => void): void {
|
||||
override log(info: {message: string; level: string; namespace: string}, next: () => void): void {
|
||||
bridgeLogging(info.message, info.level, info.namespace);
|
||||
next();
|
||||
}
|
||||
@@ -100,7 +78,7 @@ export default class Bridge extends Extension {
|
||||
this.logTransport = new DebugEventTransport();
|
||||
} else {
|
||||
class EventTransport extends Transport {
|
||||
log(info: {message: string; level: string; namespace: string}, next: () => void): void {
|
||||
override log(info: {message: string; level: string; namespace: string}, next: () => void): void {
|
||||
if (info.level !== 'debug') {
|
||||
bridgeLogging(info.message, info.level, info.namespace);
|
||||
}
|
||||
@@ -140,35 +118,62 @@ export default class Bridge extends Extension {
|
||||
});
|
||||
|
||||
// Zigbee events
|
||||
const publishEvent = async (type: string, data: KeyValue): Promise<void> =>
|
||||
await this.mqtt.publish('bridge/event', stringify({type, data}), {retain: false, qos: 0});
|
||||
this.eventBus.onDeviceJoined(this, async (data) => {
|
||||
this.lastJoinedDeviceIeeeAddr = data.device.ieeeAddr;
|
||||
await this.publishDevices();
|
||||
await publishEvent('device_joined', {friendly_name: data.device.name, ieee_address: data.device.ieeeAddr});
|
||||
|
||||
const payload: Zigbee2MQTTAPI['bridge/event'] = {
|
||||
type: 'device_joined',
|
||||
data: {friendly_name: data.device.name, ieee_address: data.device.ieeeAddr},
|
||||
};
|
||||
|
||||
await this.mqtt.publish('bridge/event', stringify(payload), {retain: false, qos: 0});
|
||||
});
|
||||
this.eventBus.onDeviceLeave(this, async (data) => {
|
||||
await this.publishDevices();
|
||||
await this.publishDefinitions();
|
||||
await publishEvent('device_leave', {ieee_address: data.ieeeAddr, friendly_name: data.name});
|
||||
|
||||
const payload: Zigbee2MQTTAPI['bridge/event'] = {type: 'device_leave', data: {ieee_address: data.ieeeAddr, friendly_name: data.name}};
|
||||
|
||||
await this.mqtt.publish('bridge/event', stringify(payload), {retain: false, qos: 0});
|
||||
});
|
||||
this.eventBus.onDeviceNetworkAddressChanged(this, async () => {
|
||||
await this.publishDevices();
|
||||
});
|
||||
this.eventBus.onDeviceInterview(this, async (data) => {
|
||||
await this.publishDevices();
|
||||
const payload: KeyValue = {friendly_name: data.device.name, status: data.status, ieee_address: data.device.ieeeAddr};
|
||||
|
||||
let payload: Zigbee2MQTTAPI['bridge/event'];
|
||||
|
||||
if (data.status === 'successful') {
|
||||
payload.supported = data.device.isSupported;
|
||||
payload.definition = this.getDefinitionPayload(data.device);
|
||||
payload = {
|
||||
type: 'device_interview',
|
||||
data: {
|
||||
friendly_name: data.device.name,
|
||||
status: data.status,
|
||||
ieee_address: data.device.ieeeAddr,
|
||||
supported: data.device.isSupported,
|
||||
definition: this.getDefinitionPayload(data.device),
|
||||
},
|
||||
};
|
||||
} else {
|
||||
payload = {
|
||||
type: 'device_interview',
|
||||
data: {friendly_name: data.device.name, status: data.status, ieee_address: data.device.ieeeAddr},
|
||||
};
|
||||
}
|
||||
|
||||
await publishEvent('device_interview', payload);
|
||||
await this.mqtt.publish('bridge/event', stringify(payload), {retain: false, qos: 0});
|
||||
});
|
||||
this.eventBus.onDeviceAnnounce(this, async (data) => {
|
||||
await this.publishDevices();
|
||||
await publishEvent('device_announce', {friendly_name: data.device.name, ieee_address: data.device.ieeeAddr});
|
||||
|
||||
const payload: Zigbee2MQTTAPI['bridge/event'] = {
|
||||
type: 'device_announce',
|
||||
data: {friendly_name: data.device.name, ieee_address: data.device.ieeeAddr},
|
||||
};
|
||||
|
||||
await this.mqtt.publish('bridge/event', stringify(payload), {retain: false, qos: 0});
|
||||
});
|
||||
|
||||
await this.publishInfo();
|
||||
@@ -185,7 +190,7 @@ export default class Bridge extends Extension {
|
||||
}
|
||||
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const match = data.topic.match(requestRegex);
|
||||
const match = data.topic.match(REQUEST_REGEX);
|
||||
|
||||
if (!match) {
|
||||
return;
|
||||
@@ -212,30 +217,25 @@ export default class Bridge extends Extension {
|
||||
* Requests
|
||||
*/
|
||||
|
||||
@bind async deviceOptions(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
@bind async deviceOptions(message: KeyValue | string): Promise<Zigbee2MQTTResponse<'bridge/response/device/options'>> {
|
||||
return await this.changeEntityOptions('device', message);
|
||||
}
|
||||
|
||||
@bind async groupOptions(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
@bind async groupOptions(message: KeyValue | string): Promise<Zigbee2MQTTResponse<'bridge/response/group/options'>> {
|
||||
return await this.changeEntityOptions('group', message);
|
||||
}
|
||||
|
||||
@bind async bridgeOptions(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
@bind async bridgeOptions(message: KeyValue | string): Promise<Zigbee2MQTTResponse<'bridge/response/options'>> {
|
||||
if (typeof message !== 'object' || typeof message.options !== 'object') {
|
||||
throw new Error(`Invalid payload`);
|
||||
}
|
||||
|
||||
const newSettings = message.options;
|
||||
const restartRequired = settings.apply(newSettings);
|
||||
if (restartRequired) this.restartRequired = true;
|
||||
const newSettings = message.options as Partial<Settings>;
|
||||
this.restartRequired = settings.apply(newSettings);
|
||||
|
||||
// Apply some settings on-the-fly.
|
||||
if (newSettings.permit_join != undefined) {
|
||||
await this.zigbee.permitJoin(settings.get().permit_join);
|
||||
}
|
||||
|
||||
if (newSettings.homeassistant != undefined) {
|
||||
await this.enableDisableExtension(!!settings.get().homeassistant, 'HomeAssistant');
|
||||
if (newSettings.homeassistant) {
|
||||
await this.enableDisableExtension(settings.get().homeassistant.enabled, 'HomeAssistant');
|
||||
}
|
||||
|
||||
if (newSettings.advanced?.log_level != undefined) {
|
||||
@@ -255,19 +255,19 @@ export default class Bridge extends Extension {
|
||||
return utils.getResponse(message, {restart_required: this.restartRequired});
|
||||
}
|
||||
|
||||
@bind async deviceRemove(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
@bind async deviceRemove(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/device/remove'>> {
|
||||
return await this.removeEntity('device', message);
|
||||
}
|
||||
|
||||
@bind async groupRemove(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
@bind async groupRemove(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/group/remove'>> {
|
||||
return await this.removeEntity('group', message);
|
||||
}
|
||||
|
||||
@bind async healthCheck(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
@bind async healthCheck(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/health_check'>> {
|
||||
return utils.getResponse(message, {healthy: true});
|
||||
}
|
||||
|
||||
@bind async coordinatorCheck(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
@bind async coordinatorCheck(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/coordinator_check'>> {
|
||||
const result = await this.zigbee.coordinatorCheck();
|
||||
const missingRouters = result.missingRouters.map((d) => {
|
||||
return {ieee_address: d.ieeeAddr, friendly_name: d.name};
|
||||
@@ -275,7 +275,7 @@ export default class Bridge extends Extension {
|
||||
return utils.getResponse(message, {missing_routers: missingRouters});
|
||||
}
|
||||
|
||||
@bind async groupAdd(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
@bind async groupAdd(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/group/add'>> {
|
||||
if (typeof message === 'object' && message.friendly_name === undefined) {
|
||||
throw new Error(`Invalid payload`);
|
||||
}
|
||||
@@ -288,22 +288,22 @@ export default class Bridge extends Extension {
|
||||
return utils.getResponse(message, {friendly_name: group.friendly_name, id: group.ID});
|
||||
}
|
||||
|
||||
@bind async deviceRename(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
@bind async deviceRename(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/device/rename'>> {
|
||||
return await this.renameEntity('device', message);
|
||||
}
|
||||
|
||||
@bind async groupRename(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
@bind async groupRename(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/group/rename'>> {
|
||||
return await this.renameEntity('group', message);
|
||||
}
|
||||
|
||||
@bind async restart(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
@bind async restart(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/restart'>> {
|
||||
// Wait 500 ms before restarting so response can be send.
|
||||
setTimeout(this.restartCallback, 500);
|
||||
logger.info('Restarting Zigbee2MQTT');
|
||||
return utils.getResponse(message, {});
|
||||
}
|
||||
|
||||
@bind async backup(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
@bind async backup(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/backup'>> {
|
||||
await this.zigbee.backup();
|
||||
const dataPath = data.getPath();
|
||||
const files = utils
|
||||
@@ -316,7 +316,7 @@ export default class Bridge extends Extension {
|
||||
return utils.getResponse(message, {zip: base64Zip});
|
||||
}
|
||||
|
||||
@bind async installCodeAdd(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
@bind async installCodeAdd(message: KeyValue | string): Promise<Zigbee2MQTTResponse<'bridge/response/install_code/add'>> {
|
||||
if (typeof message === 'object' && message.value === undefined) {
|
||||
throw new Error('Invalid payload');
|
||||
}
|
||||
@@ -327,18 +327,16 @@ export default class Bridge extends Extension {
|
||||
return utils.getResponse(message, {value});
|
||||
}
|
||||
|
||||
@bind async permitJoin(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
if (typeof message === 'object' && message.value === undefined) {
|
||||
throw new Error('Invalid payload');
|
||||
}
|
||||
|
||||
let value: boolean | string;
|
||||
@bind async permitJoin(message: KeyValue | string): Promise<Zigbee2MQTTResponse<'bridge/response/permit_join'>> {
|
||||
let time: number | undefined;
|
||||
let device: Device | undefined;
|
||||
|
||||
if (typeof message === 'object') {
|
||||
value = message.value;
|
||||
time = message.time;
|
||||
if (message.time === undefined) {
|
||||
throw new Error('Invalid payload');
|
||||
}
|
||||
|
||||
time = Number.parseInt(message.time, 10);
|
||||
|
||||
if (message.device) {
|
||||
const resolved = this.zigbee.resolveEntity(message.device);
|
||||
@@ -350,83 +348,21 @@ export default class Bridge extends Extension {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
value = message;
|
||||
time = Number.parseInt(message, 10);
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
value = value.toLowerCase() === 'true';
|
||||
}
|
||||
await this.zigbee.permitJoin(time, device);
|
||||
|
||||
await this.zigbee.permitJoin(value, device, time);
|
||||
const response: {time: number; device?: string} = {time};
|
||||
|
||||
const response: {value: boolean; device?: string; time?: number} = {value};
|
||||
|
||||
if (typeof message === 'object') {
|
||||
if (device) {
|
||||
response.device = message.device;
|
||||
}
|
||||
|
||||
if (time != undefined) {
|
||||
response.time = message.time;
|
||||
}
|
||||
if (device) {
|
||||
response.device = device.name;
|
||||
}
|
||||
|
||||
return utils.getResponse(message, response);
|
||||
}
|
||||
|
||||
// Deprecated
|
||||
@bind async configLastSeen(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
const allowed = ['disable', 'ISO_8601', 'epoch', 'ISO_8601_local'];
|
||||
const value = this.getValue(message);
|
||||
if (typeof value !== 'string' || !allowed.includes(value)) {
|
||||
throw new Error(`'${value}' is not an allowed value, allowed: ${allowed}`);
|
||||
}
|
||||
|
||||
settings.set(['advanced', 'last_seen'], value);
|
||||
await this.publishInfo();
|
||||
return utils.getResponse(message, {value});
|
||||
}
|
||||
|
||||
// Deprecated
|
||||
@bind async configHomeAssistant(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
const allowed = [true, false];
|
||||
const value = this.getValue(message);
|
||||
if (typeof value !== 'boolean' || !allowed.includes(value)) {
|
||||
throw new Error(`'${value}' is not an allowed value, allowed: ${allowed}`);
|
||||
}
|
||||
|
||||
settings.set(['homeassistant'], value);
|
||||
await this.enableDisableExtension(value, 'HomeAssistant');
|
||||
await this.publishInfo();
|
||||
return utils.getResponse(message, {value});
|
||||
}
|
||||
|
||||
// Deprecated
|
||||
@bind async configElapsed(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
const allowed = [true, false];
|
||||
const value = this.getValue(message);
|
||||
if (typeof value !== 'boolean' || !allowed.includes(value)) {
|
||||
throw new Error(`'${value}' is not an allowed value, allowed: ${allowed}`);
|
||||
}
|
||||
|
||||
settings.set(['advanced', 'elapsed'], value);
|
||||
await this.publishInfo();
|
||||
return utils.getResponse(message, {value});
|
||||
}
|
||||
|
||||
// Deprecated
|
||||
@bind async configLogLevel(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
const value = this.getValue(message) as settings.LogLevel;
|
||||
if (typeof value !== 'string' || !settings.LOG_LEVELS.includes(value)) {
|
||||
throw new Error(`'${value}' is not an allowed value, allowed: ${settings.LOG_LEVELS}`);
|
||||
}
|
||||
|
||||
logger.setLevel(value);
|
||||
await this.publishInfo();
|
||||
return utils.getResponse(message, {value});
|
||||
}
|
||||
|
||||
@bind async touchlinkIdentify(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
@bind async touchlinkIdentify(message: KeyValue | string): Promise<Zigbee2MQTTResponse<'bridge/response/touchlink/identify'>> {
|
||||
if (typeof message !== 'object' || message.ieee_address === undefined || message.channel === undefined) {
|
||||
throw new Error('Invalid payload');
|
||||
}
|
||||
@@ -436,14 +372,18 @@ export default class Bridge extends Extension {
|
||||
return utils.getResponse(message, {ieee_address: message.ieee_address, channel: message.channel});
|
||||
}
|
||||
|
||||
@bind async touchlinkFactoryReset(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
@bind async touchlinkFactoryReset(message: KeyValue | string): Promise<Zigbee2MQTTResponse<'bridge/response/touchlink/factory_reset'>> {
|
||||
let result = false;
|
||||
const payload: {ieee_address?: string; channel?: number} = {};
|
||||
let payload: Zigbee2MQTTAPI['bridge/response/touchlink/factory_reset'] = {};
|
||||
|
||||
if (typeof message === 'object' && message.ieee_address !== undefined && message.channel !== undefined) {
|
||||
logger.info(`Start Touchlink factory reset of '${message.ieee_address}' on channel ${message.channel}`);
|
||||
|
||||
result = await this.zigbee.touchlinkFactoryReset(message.ieee_address, message.channel);
|
||||
payload.ieee_address = message.ieee_address;
|
||||
payload.channel = message.channel;
|
||||
payload = {
|
||||
ieee_address: message.ieee_address,
|
||||
channel: message.channel,
|
||||
};
|
||||
} else {
|
||||
logger.info('Start Touchlink factory reset of first found device');
|
||||
result = await this.zigbee.touchlinkFactoryResetFirst();
|
||||
@@ -458,7 +398,7 @@ export default class Bridge extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
@bind async touchlinkScan(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
@bind async touchlinkScan(message: KeyValue | string): Promise<Zigbee2MQTTResponse<'bridge/response/touchlink/scan'>> {
|
||||
logger.info('Start Touchlink scan');
|
||||
const result = await this.zigbee.touchlinkScan();
|
||||
const found = result.map((r) => {
|
||||
@@ -472,19 +412,10 @@ export default class Bridge extends Extension {
|
||||
* Utils
|
||||
*/
|
||||
|
||||
getValue(message: KeyValue | string): string | boolean | number {
|
||||
if (typeof message === 'object') {
|
||||
if (message.value === undefined) {
|
||||
throw new Error('No value given');
|
||||
}
|
||||
|
||||
return message.value;
|
||||
} else {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
async changeEntityOptions(entityType: 'device' | 'group', message: KeyValue | string): Promise<MQTTResponse> {
|
||||
async changeEntityOptions<T extends 'device' | 'group'>(
|
||||
entityType: T,
|
||||
message: KeyValue | string,
|
||||
): Promise<Zigbee2MQTTResponse<T extends 'device' ? 'bridge/response/device/options' : 'bridge/response/group/options'>> {
|
||||
if (typeof message !== 'object' || message.id === undefined || message.options === undefined) {
|
||||
throw new Error(`Invalid payload`);
|
||||
}
|
||||
@@ -501,6 +432,16 @@ export default class Bridge extends Extension {
|
||||
const ID = message.id;
|
||||
const entity = this.getEntity(entityType, ID);
|
||||
const oldOptions = objectAssignDeep({}, cleanup(entity.options));
|
||||
|
||||
if (message.options.icon) {
|
||||
const base64Match = utils.matchBase64File(message.options.icon);
|
||||
if (base64Match) {
|
||||
const fileSettings = utils.saveBase64DeviceIcon(base64Match);
|
||||
message.options.icon = fileSettings;
|
||||
logger.debug(`Saved base64 image as file to '${fileSettings}'`);
|
||||
}
|
||||
}
|
||||
|
||||
const restartRequired = settings.changeEntityOptions(ID, message.options);
|
||||
if (restartRequired) this.restartRequired = true;
|
||||
const newOptions = cleanup(entity.options);
|
||||
@@ -512,10 +453,11 @@ export default class Bridge extends Extension {
|
||||
return utils.getResponse(message, {from: oldOptions, to: newOptions, id: ID, restart_required: this.restartRequired});
|
||||
}
|
||||
|
||||
@bind async deviceConfigureReporting(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
@bind async deviceConfigureReporting(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/device/configure_reporting'>> {
|
||||
if (
|
||||
typeof message !== 'object' ||
|
||||
message.id === undefined ||
|
||||
message.endpoint === undefined ||
|
||||
message.cluster === undefined ||
|
||||
message.maximum_report_interval === undefined ||
|
||||
message.minimum_report_interval === undefined ||
|
||||
@@ -525,14 +467,11 @@ export default class Bridge extends Extension {
|
||||
throw new Error(`Invalid payload`);
|
||||
}
|
||||
|
||||
const device = this.zigbee.resolveEntityAndEndpoint(message.id);
|
||||
if (!device.entity) {
|
||||
throw new Error(`Device '${message.id}' does not exist`);
|
||||
}
|
||||
const device = this.getEntity('device', message.id);
|
||||
const endpoint = device.endpoint(message.endpoint);
|
||||
|
||||
const endpoint = device.endpoint;
|
||||
if (!endpoint) {
|
||||
throw new Error(`Device '${device.ID}' does not have endpoint '${device.endpointID}'`);
|
||||
throw new Error(`Device '${device.ID}' does not have endpoint '${message.endpoint}'`);
|
||||
}
|
||||
|
||||
const coordinatorEndpoint = this.zigbee.firstCoordinatorEndpoint();
|
||||
@@ -557,6 +496,7 @@ export default class Bridge extends Extension {
|
||||
|
||||
return utils.getResponse(message, {
|
||||
id: message.id,
|
||||
endpoint: message.endpoint,
|
||||
cluster: message.cluster,
|
||||
maximum_report_interval: message.maximum_report_interval,
|
||||
minimum_report_interval: message.minimum_report_interval,
|
||||
@@ -565,12 +505,12 @@ export default class Bridge extends Extension {
|
||||
});
|
||||
}
|
||||
|
||||
@bind async deviceInterview(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
@bind async deviceInterview(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/device/interview'>> {
|
||||
if (typeof message !== 'object' || message.id === undefined) {
|
||||
throw new Error(`Invalid payload`);
|
||||
}
|
||||
|
||||
const device = this.getEntity('device', message.id) as Device;
|
||||
const device = this.getEntity('device', message.id);
|
||||
logger.info(`Interviewing '${device.name}'`);
|
||||
|
||||
try {
|
||||
@@ -588,23 +528,23 @@ export default class Bridge extends Extension {
|
||||
return utils.getResponse(message, {id: message.id});
|
||||
}
|
||||
|
||||
@bind async deviceGenerateExternalDefinition(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
@bind async deviceGenerateExternalDefinition(
|
||||
message: string | KeyValue,
|
||||
): Promise<Zigbee2MQTTResponse<'bridge/response/device/generate_external_definition'>> {
|
||||
if (typeof message !== 'object' || message.id === undefined) {
|
||||
throw new Error(`Invalid payload`);
|
||||
}
|
||||
|
||||
const device = this.zigbee.resolveEntityAndEndpoint(message.id).entity as Device;
|
||||
|
||||
if (!device) {
|
||||
throw new Error(`Device '${message.id}' does not exist`);
|
||||
}
|
||||
|
||||
const device = this.getEntity('device', message.id);
|
||||
const source = await zhc.generateExternalDefinitionSource(device.zh);
|
||||
|
||||
return utils.getResponse(message, {id: message.id, source});
|
||||
}
|
||||
|
||||
async renameEntity(entityType: 'group' | 'device', message: string | KeyValue): Promise<MQTTResponse> {
|
||||
async renameEntity<T extends 'device' | 'group'>(
|
||||
entityType: T,
|
||||
message: string | KeyValue,
|
||||
): Promise<Zigbee2MQTTResponse<T extends 'device' ? 'bridge/response/device/rename' : 'bridge/response/group/rename'>> {
|
||||
const deviceAndHasLast = entityType === 'device' && typeof message === 'object' && message.last === true;
|
||||
|
||||
if (typeof message !== 'object' || (message.from === undefined && !deviceAndHasLast) || message.to === undefined) {
|
||||
@@ -641,7 +581,10 @@ export default class Bridge extends Extension {
|
||||
return utils.getResponse(message, {from: oldFriendlyName, to, homeassistant_rename: homeAssisantRename});
|
||||
}
|
||||
|
||||
async removeEntity(entityType: 'group' | 'device', message: string | KeyValue): Promise<MQTTResponse> {
|
||||
async removeEntity<T extends 'device' | 'group'>(
|
||||
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 entity = this.getEntity(entityType, ID);
|
||||
const friendlyName = entity.name;
|
||||
@@ -674,25 +617,17 @@ export default class Bridge extends Extension {
|
||||
} else {
|
||||
await entity.zh.removeFromNetwork();
|
||||
}
|
||||
|
||||
this.eventBus.emitEntityRemoved({id: entityID, name, type: 'device'});
|
||||
settings.removeDevice(entityID as string);
|
||||
} else {
|
||||
if (force) {
|
||||
entity.zh.removeFromDatabase();
|
||||
} else {
|
||||
await entity.zh.removeFromNetwork();
|
||||
}
|
||||
}
|
||||
|
||||
// Fire event
|
||||
if (entity instanceof Device) {
|
||||
this.eventBus.emitEntityRemoved({id: entityID, name, type: 'device'});
|
||||
} else {
|
||||
this.eventBus.emitEntityRemoved({id: entityID, name, type: 'group'});
|
||||
}
|
||||
|
||||
// Remove from configuration.yaml
|
||||
if (entity instanceof Device) {
|
||||
settings.removeDevice(entityID as string);
|
||||
} else {
|
||||
settings.removeGroup(entityID);
|
||||
}
|
||||
|
||||
@@ -709,16 +644,29 @@ export default class Bridge extends Extension {
|
||||
await this.publishDevices();
|
||||
// Refresh Cluster definition
|
||||
await this.publishDefinitions();
|
||||
return utils.getResponse(message, {id: ID, block, force});
|
||||
|
||||
const responseData: Zigbee2MQTTAPI['bridge/response/device/remove'] = {id: ID, block, force};
|
||||
|
||||
return utils.getResponse(message, responseData);
|
||||
} else {
|
||||
await this.publishGroups();
|
||||
return utils.getResponse(message, {id: ID, force: force});
|
||||
|
||||
const responseData: Zigbee2MQTTAPI['bridge/response/group/remove'] = {id: ID, force};
|
||||
|
||||
return utils.getResponse(
|
||||
message,
|
||||
// @ts-expect-error typing infer does not work here
|
||||
responseData,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to remove ${entityType} '${friendlyName}'${blockForceLog} (${error})`);
|
||||
}
|
||||
}
|
||||
|
||||
getEntity(type: 'group', ID: string): Group;
|
||||
getEntity(type: 'device', ID: string): Device;
|
||||
getEntity(type: 'group' | 'device', ID: string): Device | Group;
|
||||
getEntity(type: 'group' | 'device', ID: string): Device | Group {
|
||||
const entity = this.zigbee.resolveEntity(ID);
|
||||
if (!entity || entity.constructor.name.toLowerCase() !== type) {
|
||||
@@ -732,12 +680,10 @@ export default class Bridge extends Extension {
|
||||
// @ts-expect-error hidden from publish
|
||||
delete config.advanced.network_key;
|
||||
delete config.mqtt.password;
|
||||
delete config.frontend.auth_token;
|
||||
|
||||
if (config.frontend) {
|
||||
delete config.frontend.auth_token;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
const networkParams = await this.zigbee.getNetworkParameters();
|
||||
const payload: Zigbee2MQTTAPI['bridge/info'] = {
|
||||
version: this.zigbee2mqttVersion.version,
|
||||
commit: this.zigbee2mqttVersion.commitHash,
|
||||
zigbee_herdsman_converters: this.zigbeeHerdsmanConvertersVersion,
|
||||
@@ -746,40 +692,30 @@ export default class Bridge extends Extension {
|
||||
ieee_address: this.zigbee.firstCoordinatorEndpoint().getDevice().ieeeAddr,
|
||||
...this.coordinatorVersion,
|
||||
},
|
||||
network: utils.toSnakeCaseObject(await this.zigbee.getNetworkParameters()),
|
||||
network: {
|
||||
pan_id: networkParams.panID,
|
||||
extended_pan_id: networkParams.extendedPanID,
|
||||
channel: networkParams.channel,
|
||||
},
|
||||
log_level: logger.getLevel(),
|
||||
permit_join: this.zigbee.getPermitJoin(),
|
||||
permit_join_timeout: this.zigbee.getPermitJoinTimeout(),
|
||||
permit_join_end: this.zigbee.getPermitJoinEnd(),
|
||||
restart_required: this.restartRequired,
|
||||
config,
|
||||
config_schema: settings.schema,
|
||||
config_schema: settings.schemaJson,
|
||||
};
|
||||
|
||||
await this.mqtt.publish('bridge/info', stringify(payload), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
|
||||
}
|
||||
|
||||
async publishDevices(): Promise<void> {
|
||||
interface Data {
|
||||
bindings: {cluster: string; target: {type: string; endpoint?: number; ieee_address?: string; id?: number}}[];
|
||||
configured_reportings: {
|
||||
cluster: string;
|
||||
attribute: string | number;
|
||||
minimum_report_interval: number;
|
||||
maximum_report_interval: number;
|
||||
reportable_change: number;
|
||||
}[];
|
||||
clusters: {input: string[]; output: string[]};
|
||||
scenes: Scene[];
|
||||
}
|
||||
|
||||
// XXX: definition<>DefinitionPayload don't match to use `Device[]` type here
|
||||
const devices: KeyValue[] = [];
|
||||
const devices: Zigbee2MQTTAPI['bridge/devices'] = [];
|
||||
|
||||
for (const device of this.zigbee.devicesIterator()) {
|
||||
const endpoints: {[s: number]: Data} = {};
|
||||
const endpoints: (typeof devices)[number]['endpoints'] = {};
|
||||
|
||||
for (const endpoint of device.zh.endpoints) {
|
||||
const data: Data = {
|
||||
const data: (typeof endpoints)[keyof typeof endpoints] = {
|
||||
scenes: utils.getScenes(endpoint),
|
||||
bindings: [],
|
||||
configured_reportings: [],
|
||||
@@ -833,8 +769,7 @@ export default class Bridge extends Extension {
|
||||
}
|
||||
|
||||
async publishGroups(): Promise<void> {
|
||||
// XXX: id<>ID can't use `Group[]` type
|
||||
const groups: KeyValue[] = [];
|
||||
const groups: Zigbee2MQTTAPI['bridge/groups'] = [];
|
||||
|
||||
for (const group of this.zigbee.groupsIterator()) {
|
||||
const members = [];
|
||||
@@ -856,13 +791,8 @@ export default class Bridge extends Extension {
|
||||
}
|
||||
|
||||
async publishDefinitions(): Promise<void> {
|
||||
interface ClusterDefinitionPayload {
|
||||
clusters: Readonly<Record<ClusterName, Readonly<ClusterDefinition>>>;
|
||||
custom_clusters: {[key: string]: CustomClusters};
|
||||
}
|
||||
|
||||
const data: ClusterDefinitionPayload = {
|
||||
clusters: Clusters,
|
||||
const data: Zigbee2MQTTAPI['bridge/definition'] = {
|
||||
clusters: Zcl.Clusters,
|
||||
custom_clusters: {},
|
||||
};
|
||||
|
||||
@@ -873,7 +803,7 @@ export default class Bridge extends Extension {
|
||||
await this.mqtt.publish('bridge/definitions', stringify(data), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
|
||||
}
|
||||
|
||||
getDefinitionPayload(device: Device): DefinitionPayload | undefined {
|
||||
getDefinitionPayload(device: Device): Zigbee2MQTTDevice['definition'] | undefined {
|
||||
if (!device.definition) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -884,12 +814,12 @@ export default class Bridge extends Extension {
|
||||
let icon = device.options.icon ?? definitionIcon;
|
||||
|
||||
if (icon) {
|
||||
/* istanbul ignore next */
|
||||
/* v8 ignore next */
|
||||
icon = icon.replace('${zigbeeModel}', utils.sanitizeImageParameter(device.zh.modelID ?? ''));
|
||||
icon = icon.replace('${model}', utils.sanitizeImageParameter(device.definition.model));
|
||||
}
|
||||
|
||||
const payload: DefinitionPayload = {
|
||||
const payload: Zigbee2MQTTDevice['definition'] = {
|
||||
model: device.definition.model,
|
||||
vendor: device.definition.vendor,
|
||||
description: device.definition.description,
|
||||
|
||||
+21
-27
@@ -1,3 +1,5 @@
|
||||
import type {Zigbee2MQTTAPI} from 'lib/types/api';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
@@ -16,7 +18,6 @@ export default class Configure extends Extension {
|
||||
private configuring = new Set();
|
||||
private attempts: {[s: string]: number} = {};
|
||||
private topic = `${settings.get().mqtt.base_topic}/bridge/request/device/configure`;
|
||||
private legacyTopic = `${settings.get().mqtt.base_topic}/bridge/configure`;
|
||||
|
||||
@bind private async onReconfigure(data: eventdata.Reconfigure): Promise<void> {
|
||||
// Disabling reporting unbinds some cluster which could be bound by configure, re-setup.
|
||||
@@ -29,38 +30,31 @@ export default class Configure extends Extension {
|
||||
}
|
||||
|
||||
@bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
if (data.topic === this.legacyTopic) {
|
||||
const device = this.zigbee.resolveEntity(data.message);
|
||||
if (!device || !(device instanceof Device)) {
|
||||
logger.error(`Device '${data.message}' does not exist`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!device.definition || !device.definition.configure) {
|
||||
logger.warning(`Skipping configure of '${device.name}', device does not require this.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.configure(device, 'mqtt_message', true);
|
||||
} else if (data.topic === this.topic) {
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
const ID = typeof message === 'object' && message.id !== undefined ? message.id : message;
|
||||
if (data.topic === this.topic) {
|
||||
const message = utils.parseJSON(data.message, data.message) as Zigbee2MQTTAPI['bridge/request/device/configure'];
|
||||
const ID = typeof message === 'object' ? message.id : message;
|
||||
let error: string | undefined;
|
||||
|
||||
const device = this.zigbee.resolveEntity(ID);
|
||||
if (!device || !(device instanceof Device)) {
|
||||
error = `Device '${ID}' does not exist`;
|
||||
} else if (!device.definition || !device.definition.configure) {
|
||||
error = `Device '${device.name}' cannot be configured`;
|
||||
if (ID === undefined) {
|
||||
error = `Invalid payload`;
|
||||
} else {
|
||||
try {
|
||||
await this.configure(device, 'mqtt_message', true, true);
|
||||
} catch (e) {
|
||||
error = `Failed to configure (${(e as Error).message})`;
|
||||
const device = this.zigbee.resolveEntity(ID);
|
||||
|
||||
if (!device || !(device instanceof Device)) {
|
||||
error = `Device '${ID}' does not exist`;
|
||||
} else if (!device.definition || !device.definition.configure) {
|
||||
error = `Device '${device.name}' cannot be configured`;
|
||||
} else {
|
||||
try {
|
||||
await this.configure(device, 'mqtt_message', true, true);
|
||||
} catch (e) {
|
||||
error = `Failed to configure (${(e as Error).message})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = utils.getResponse(message, {id: ID}, error);
|
||||
const response = utils.getResponse<'bridge/response/device/configure'>(message, {id: ID}, error);
|
||||
|
||||
await this.mqtt.publish(`bridge/response/device/configure`, stringify(response));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,6 @@ abstract class Extension {
|
||||
/**
|
||||
* Is called once the extension has to start
|
||||
*/
|
||||
/* istanbul ignore next */
|
||||
async start(): Promise<void> {}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
import type * as zhc from 'zigbee-herdsman-converters';
|
||||
|
||||
import {addExternalDefinition, removeExternalDefinitions} from 'zigbee-herdsman-converters';
|
||||
|
||||
import logger from '../util/logger';
|
||||
import * as settings from '../util/settings';
|
||||
import {loadExternalConverter} from '../util/utils';
|
||||
import Extension from './extension';
|
||||
import ExternalJSExtension from './externalJS';
|
||||
|
||||
export default class ExternalConverters extends Extension {
|
||||
type ModuleExports = zhc.ExternalDefinitionWithExtend | zhc.ExternalDefinitionWithExtend[];
|
||||
|
||||
export default class ExternalConverters extends ExternalJSExtension<ModuleExports> {
|
||||
constructor(
|
||||
zigbee: Zigbee,
|
||||
mqtt: MQTT,
|
||||
@@ -16,27 +18,48 @@ export default class ExternalConverters extends Extension {
|
||||
restartCallback: () => Promise<void>,
|
||||
addExtension: (extension: Extension) => Promise<void>,
|
||||
) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension);
|
||||
super(
|
||||
zigbee,
|
||||
mqtt,
|
||||
state,
|
||||
publishEntityState,
|
||||
eventBus,
|
||||
enableDisableExtension,
|
||||
restartCallback,
|
||||
addExtension,
|
||||
'converter',
|
||||
'external_converters',
|
||||
);
|
||||
}
|
||||
|
||||
for (const file of settings.get().external_converters) {
|
||||
try {
|
||||
for (const definition of loadExternalConverter(file)) {
|
||||
const toAdd = {...definition};
|
||||
delete toAdd['homeassistant'];
|
||||
zhc.addDefinition(toAdd);
|
||||
}
|
||||
logger.info(`Loaded external converter '${file}'`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to load external converter file '${file}' (${(error as Error).message})`);
|
||||
logger.error(
|
||||
`Probably there is a syntax error in the file or the external converter is not ` +
|
||||
`compatible with the current Zigbee2MQTT version`,
|
||||
);
|
||||
logger.error(
|
||||
`Note that external converters are not meant for long term usage, it's meant for local ` +
|
||||
`testing after which a pull request should be created to add out-of-the-box support for the device`,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
protected async removeJS(name: string, module: ModuleExports): Promise<void> {
|
||||
removeExternalDefinitions(name);
|
||||
|
||||
await this.zigbee.resolveDevicesDefinitions(true);
|
||||
}
|
||||
|
||||
protected async loadJS(name: string, module: ModuleExports): Promise<void> {
|
||||
try {
|
||||
removeExternalDefinitions(name);
|
||||
|
||||
const definitions = Array.isArray(module) ? module : [module];
|
||||
for (const definition of definitions) {
|
||||
definition.externalConverterName = name;
|
||||
|
||||
addExternalDefinition(definition);
|
||||
logger.info(`Loaded external converter '${name}'.`);
|
||||
}
|
||||
|
||||
await this.zigbee.resolveDevicesDefinitions(true);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to load external converter '${name}'`);
|
||||
logger.error(`Check the code for syntax error and make sure it is up to date with the current Zigbee2MQTT version.`);
|
||||
logger.error(
|
||||
`External converters are not meant for long term usage, but for local testing after which a pull request should be created to add out-of-the-box support for the device`,
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import data from './../util/data';
|
||||
import logger from './../util/logger';
|
||||
import Extension from './extension';
|
||||
|
||||
const requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/extension/(save|remove)`);
|
||||
|
||||
export default class ExternalExtension extends Extension {
|
||||
private requestLookup: {[s: string]: (message: KeyValue) => Promise<MQTTResponse>} = {
|
||||
save: this.saveExtension,
|
||||
remove: this.removeExtension,
|
||||
};
|
||||
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
await this.loadUserDefinedExtensions();
|
||||
await this.publishExtensions();
|
||||
}
|
||||
|
||||
private getExtensionsBasePath(): string {
|
||||
return data.joinPath('extension');
|
||||
}
|
||||
|
||||
private getListOfUserDefinedExtensions(): {name: string; code: string}[] {
|
||||
const basePath = this.getExtensionsBasePath();
|
||||
if (fs.existsSync(basePath)) {
|
||||
return fs
|
||||
.readdirSync(basePath)
|
||||
.filter((f) => f.endsWith('.js'))
|
||||
.map((fileName) => {
|
||||
const extensionFilePath = path.join(basePath, fileName);
|
||||
return {name: fileName, code: fs.readFileSync(extensionFilePath, 'utf-8')};
|
||||
});
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@bind private async removeExtension(message: KeyValue): Promise<MQTTResponse> {
|
||||
const {name} = message;
|
||||
const extensions = this.getListOfUserDefinedExtensions();
|
||||
const extensionToBeRemoved = extensions.find((e) => e.name === name);
|
||||
|
||||
if (extensionToBeRemoved) {
|
||||
await this.enableDisableExtension(false, extensionToBeRemoved.name);
|
||||
const basePath = this.getExtensionsBasePath();
|
||||
const extensionFilePath = path.join(basePath, path.basename(name));
|
||||
fs.unlinkSync(extensionFilePath);
|
||||
await this.publishExtensions();
|
||||
logger.info(`Extension ${name} removed`);
|
||||
return utils.getResponse(message, {});
|
||||
} else {
|
||||
return utils.getResponse(message, {}, `Extension ${name} doesn't exists`);
|
||||
}
|
||||
}
|
||||
|
||||
@bind private async saveExtension(message: KeyValue): Promise<MQTTResponse> {
|
||||
const {name, code} = message;
|
||||
const ModuleConstructor = utils.loadModuleFromText(code, name) as typeof Extension;
|
||||
await this.loadExtension(ModuleConstructor);
|
||||
const basePath = this.getExtensionsBasePath();
|
||||
/* istanbul ignore else */
|
||||
if (!fs.existsSync(basePath)) {
|
||||
fs.mkdirSync(basePath);
|
||||
}
|
||||
const extensionFilePath = path.join(basePath, path.basename(name));
|
||||
fs.writeFileSync(extensionFilePath, code);
|
||||
await this.publishExtensions();
|
||||
logger.info(`Extension ${name} loaded`);
|
||||
return utils.getResponse(message, {});
|
||||
}
|
||||
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const match = data.topic.match(requestRegex);
|
||||
if (match && this.requestLookup[match[1].toLowerCase()]) {
|
||||
const message = utils.parseJSON(data.message, data.message) as KeyValue;
|
||||
try {
|
||||
const response = await this.requestLookup[match[1].toLowerCase()](message);
|
||||
await this.mqtt.publish(`bridge/response/extension/${match[1]}`, stringify(response));
|
||||
} catch (error) {
|
||||
logger.error(`Request '${data.topic}' failed with error: '${(error as Error).message}'`);
|
||||
const response = utils.getResponse(message, {}, `${(error as Error).message}`);
|
||||
await this.mqtt.publish(`bridge/response/extension/${match[1]}`, stringify(response));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bind private async loadExtension(ConstructorClass: typeof Extension): Promise<void> {
|
||||
await this.enableDisableExtension(false, ConstructorClass.name);
|
||||
// @ts-expect-error `ConstructorClass` is the interface, not the actual passed class
|
||||
await this.addExtension(new ConstructorClass(this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus, settings, logger));
|
||||
}
|
||||
|
||||
private async loadUserDefinedExtensions(): Promise<void> {
|
||||
for (const extension of this.getListOfUserDefinedExtensions()) {
|
||||
await this.loadExtension(utils.loadModuleFromText(extension.code, extension.name) as typeof Extension);
|
||||
}
|
||||
}
|
||||
|
||||
private async publishExtensions(): Promise<void> {
|
||||
const extensions = this.getListOfUserDefinedExtensions();
|
||||
await this.mqtt.publish(
|
||||
'bridge/extensions',
|
||||
stringify(extensions),
|
||||
{
|
||||
retain: true,
|
||||
qos: 0,
|
||||
},
|
||||
settings.get().mqtt.base_topic,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type Extension from './extension';
|
||||
|
||||
import logger from '../util/logger';
|
||||
import * as settings from '../util/settings';
|
||||
import ExternalJSExtension from './externalJS';
|
||||
|
||||
type ModuleExports = typeof Extension;
|
||||
|
||||
export default class ExternalExtensions extends ExternalJSExtension<ModuleExports> {
|
||||
constructor(
|
||||
zigbee: Zigbee,
|
||||
mqtt: MQTT,
|
||||
state: State,
|
||||
publishEntityState: PublishEntityState,
|
||||
eventBus: EventBus,
|
||||
enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
|
||||
restartCallback: () => Promise<void>,
|
||||
addExtension: (extension: Extension) => Promise<void>,
|
||||
) {
|
||||
super(
|
||||
zigbee,
|
||||
mqtt,
|
||||
state,
|
||||
publishEntityState,
|
||||
eventBus,
|
||||
enableDisableExtension,
|
||||
restartCallback,
|
||||
addExtension,
|
||||
'extension',
|
||||
'external_extensions',
|
||||
);
|
||||
}
|
||||
|
||||
protected async removeJS(name: string, module: ModuleExports): Promise<void> {
|
||||
await this.enableDisableExtension(false, module.name);
|
||||
}
|
||||
|
||||
protected async loadJS(name: string, module: ModuleExports): Promise<void> {
|
||||
// stop if already started
|
||||
await this.enableDisableExtension(false, module.name);
|
||||
await this.addExtension(
|
||||
// @ts-expect-error `module` is the interface, not the actual passed class
|
||||
new module(
|
||||
this.zigbee,
|
||||
this.mqtt,
|
||||
this.state,
|
||||
this.publishEntityState,
|
||||
this.eventBus,
|
||||
this.enableDisableExtension,
|
||||
this.restartCallback,
|
||||
this.addExtension,
|
||||
settings,
|
||||
logger,
|
||||
),
|
||||
);
|
||||
|
||||
logger.info(`Loaded external extension '${name}'.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponse} from 'lib/types/api';
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {Context, runInNewContext} from 'node:vm';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
import data from '../util/data';
|
||||
import logger from '../util/logger';
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import Extension from './extension';
|
||||
|
||||
const SUPPORTED_OPERATIONS = ['save', 'remove'];
|
||||
|
||||
export default abstract class ExternalJSExtension<M> extends Extension {
|
||||
protected mqttTopic: string;
|
||||
protected requestRegex: RegExp;
|
||||
protected basePath: string;
|
||||
|
||||
constructor(
|
||||
zigbee: Zigbee,
|
||||
mqtt: MQTT,
|
||||
state: State,
|
||||
publishEntityState: PublishEntityState,
|
||||
eventBus: EventBus,
|
||||
enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
|
||||
restartCallback: () => Promise<void>,
|
||||
addExtension: (extension: Extension) => Promise<void>,
|
||||
mqttTopic: string,
|
||||
folderName: string,
|
||||
) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension);
|
||||
|
||||
this.mqttTopic = mqttTopic;
|
||||
this.requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/${mqttTopic}/(save|remove)`);
|
||||
this.basePath = data.joinPath(folderName);
|
||||
}
|
||||
|
||||
override async start(): Promise<void> {
|
||||
await super.start();
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
await this.loadFiles();
|
||||
await this.publishExternalJS();
|
||||
}
|
||||
|
||||
private getFilePath(name: string, mkBasePath: boolean = false): string {
|
||||
if (mkBasePath && !fs.existsSync(this.basePath)) {
|
||||
fs.mkdirSync(this.basePath, {recursive: true});
|
||||
}
|
||||
|
||||
return path.join(this.basePath, name);
|
||||
}
|
||||
|
||||
protected getFileCode(name: string): string {
|
||||
return fs.readFileSync(path.join(this.basePath, name), 'utf8');
|
||||
}
|
||||
|
||||
protected *getFiles(): Generator<{name: string; code: string}> {
|
||||
if (!fs.existsSync(this.basePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const fileName of fs.readdirSync(this.basePath)) {
|
||||
if (fileName.endsWith('.js')) {
|
||||
yield {name: fileName, code: this.getFileCode(fileName)};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const match = data.topic.match(this.requestRegex);
|
||||
|
||||
if (match && SUPPORTED_OPERATIONS.includes(match[1].toLowerCase())) {
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
|
||||
try {
|
||||
let response;
|
||||
|
||||
if (match[1].toLowerCase() === 'save') {
|
||||
response = await this.save(
|
||||
message as Zigbee2MQTTAPI['bridge/request/converter/save'] | Zigbee2MQTTAPI['bridge/request/extension/save'],
|
||||
);
|
||||
} else {
|
||||
response = await this.remove(
|
||||
message as Zigbee2MQTTAPI['bridge/request/converter/remove'] | Zigbee2MQTTAPI['bridge/request/extension/remove'],
|
||||
);
|
||||
}
|
||||
|
||||
await this.mqtt.publish(`bridge/response/${this.mqttTopic}/${match[1]}`, stringify(response));
|
||||
} catch (error) {
|
||||
logger.error(`Request '${data.topic}' failed with error: '${(error as Error).message}'`);
|
||||
|
||||
const response = utils.getResponse(message, {}, `${(error as Error).message}`);
|
||||
|
||||
await this.mqtt.publish(`bridge/response/${this.mqttTopic}/${match[1]}`, stringify(response));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract removeJS(name: string, module: M): Promise<void>;
|
||||
|
||||
protected abstract loadJS(name: string, module: M): Promise<void>;
|
||||
|
||||
@bind private async remove(
|
||||
message: Zigbee2MQTTAPI['bridge/request/converter/remove'] | Zigbee2MQTTAPI['bridge/request/extension/remove'],
|
||||
): Promise<Zigbee2MQTTResponse<'bridge/response/converter/remove' | 'bridge/response/extension/remove'>> {
|
||||
if (!message.name) {
|
||||
return utils.getResponse(message, {}, `Invalid payload`);
|
||||
}
|
||||
|
||||
const {name} = message;
|
||||
const toBeRemoved = this.getFilePath(name);
|
||||
|
||||
if (fs.existsSync(toBeRemoved)) {
|
||||
await this.removeJS(name, this.loadModuleFromText(this.getFileCode(name), name));
|
||||
|
||||
fs.rmSync(toBeRemoved, {force: true});
|
||||
logger.info(`${name} (${toBeRemoved}) removed.`);
|
||||
await this.publishExternalJS();
|
||||
|
||||
return utils.getResponse(message, {});
|
||||
} else {
|
||||
return utils.getResponse(message, {}, `${name} (${toBeRemoved}) doesn't exists`);
|
||||
}
|
||||
}
|
||||
|
||||
@bind private async save(
|
||||
message: Zigbee2MQTTAPI['bridge/request/converter/save'] | Zigbee2MQTTAPI['bridge/request/extension/save'],
|
||||
): Promise<Zigbee2MQTTResponse<'bridge/response/converter/save' | 'bridge/response/extension/save'>> {
|
||||
if (!message.name || !message.code) {
|
||||
return utils.getResponse(message, {}, `Invalid payload`);
|
||||
}
|
||||
|
||||
const {name, code} = message;
|
||||
|
||||
try {
|
||||
await this.loadJS(name, this.loadModuleFromText(code, name));
|
||||
|
||||
const filePath = this.getFilePath(name, true);
|
||||
|
||||
fs.writeFileSync(filePath, code, 'utf8');
|
||||
logger.info(`${name} loaded. Contents written to '${filePath}'.`);
|
||||
await this.publishExternalJS();
|
||||
|
||||
return utils.getResponse(message, {});
|
||||
} catch (error) {
|
||||
return utils.getResponse(message, {}, `${name} contains invalid code: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadFiles(): Promise<void> {
|
||||
for (const extension of this.getFiles()) {
|
||||
await this.loadJS(extension.name, this.loadModuleFromText(extension.code, extension.name));
|
||||
}
|
||||
}
|
||||
|
||||
private async publishExternalJS(): Promise<void> {
|
||||
await this.mqtt.publish(
|
||||
`bridge/${this.mqttTopic}s`,
|
||||
stringify(Array.from(this.getFiles())),
|
||||
{
|
||||
retain: true,
|
||||
qos: 0,
|
||||
},
|
||||
settings.get().mqtt.base_topic,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
private loadModuleFromText(moduleCode: string, name: string): M {
|
||||
const moduleFakePath = path.join(__dirname, '..', '..', 'data', 'extension', name);
|
||||
const sandbox: Context = {
|
||||
require: require,
|
||||
module: {},
|
||||
console,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
setImmediate,
|
||||
clearImmediate,
|
||||
};
|
||||
|
||||
runInNewContext(moduleCode, sandbox, moduleFakePath);
|
||||
|
||||
return sandbox.module.exports;
|
||||
}
|
||||
}
|
||||
+40
-25
@@ -1,21 +1,24 @@
|
||||
import type {IncomingMessage, Server, ServerResponse} from 'http';
|
||||
import type {Socket} from 'net';
|
||||
import type {IncomingMessage, Server, ServerResponse} from 'node:http';
|
||||
import type {Socket} from 'node:net';
|
||||
|
||||
import assert from 'assert';
|
||||
import {existsSync, readFileSync} from 'fs';
|
||||
import {createServer} from 'http';
|
||||
import {createServer as createSecureServer} from 'https';
|
||||
import {posix} from 'path';
|
||||
import {parse} from 'url';
|
||||
import type {RequestHandler} from 'express-static-gzip';
|
||||
|
||||
import assert from 'node:assert';
|
||||
import {existsSync, readFileSync} from 'node:fs';
|
||||
import {createServer} from 'node:http';
|
||||
import {createServer as createSecureServer} from 'node:https';
|
||||
import {posix} from 'node:path';
|
||||
import {parse} from 'node:url';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import gzipStatic, {RequestHandler} from 'connect-gzip-static';
|
||||
import expressStaticGzip from 'express-static-gzip';
|
||||
import finalhandler from 'finalhandler';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
import frontend from 'zigbee2mqtt-frontend';
|
||||
|
||||
import data from '../util/data';
|
||||
import logger from '../util/logger';
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
@@ -33,6 +36,7 @@ export default class Frontend extends Extension {
|
||||
private authToken: string | undefined;
|
||||
private server!: Server;
|
||||
private fileServer!: RequestHandler;
|
||||
private deviceIconsFileServer!: RequestHandler;
|
||||
private wss!: WebSocket.Server;
|
||||
private baseUrl: string;
|
||||
|
||||
@@ -49,7 +53,7 @@ export default class Frontend extends Extension {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension);
|
||||
|
||||
const frontendSettings = settings.get().frontend;
|
||||
assert(frontendSettings, 'Frontend extension created without having frontend settings');
|
||||
assert(frontendSettings.enabled, `Frontend extension created with setting 'enabled: false'`);
|
||||
this.host = frontendSettings.host;
|
||||
this.port = frontendSettings.port;
|
||||
this.sslCert = frontendSettings.ssl_cert;
|
||||
@@ -71,15 +75,23 @@ export default class Frontend extends Extension {
|
||||
}
|
||||
|
||||
override async start(): Promise<void> {
|
||||
/* istanbul ignore next */
|
||||
const options = {
|
||||
setHeaders: (res: ServerResponse, path: string): void => {
|
||||
if (path.endsWith('index.html')) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
}
|
||||
enableBrotli: true,
|
||||
// TODO: https://github.com/Koenkk/zigbee2mqtt/issues/24654 - enable compressed index serving when express-static-gzip is fixed.
|
||||
index: false,
|
||||
serveStatic: {
|
||||
index: 'index.html',
|
||||
/* v8 ignore start */
|
||||
setHeaders: (res: ServerResponse, path: string): void => {
|
||||
if (path.endsWith('index.html')) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
}
|
||||
},
|
||||
/* v8 ignore stop */
|
||||
},
|
||||
};
|
||||
this.fileServer = gzipStatic(frontend.getPath(), options);
|
||||
this.fileServer = expressStaticGzip(frontend.getPath(), options);
|
||||
this.deviceIconsFileServer = expressStaticGzip(data.joinPath('device_icons'), options);
|
||||
this.wss = new WebSocket.Server({noServer: true, path: posix.join(this.baseUrl, 'api')});
|
||||
|
||||
this.wss.on('connection', this.onWebSocketConnection);
|
||||
@@ -111,13 +123,13 @@ export default class Frontend extends Extension {
|
||||
|
||||
override async stop(): Promise<void> {
|
||||
await super.stop();
|
||||
this.wss.clients.forEach((client) => {
|
||||
this.wss?.clients.forEach((client) => {
|
||||
client.send(stringify({topic: 'bridge/state', payload: 'offline'}));
|
||||
client.terminate();
|
||||
});
|
||||
this.wss.close();
|
||||
this.wss?.close();
|
||||
|
||||
await new Promise((resolve) => this.server.close(resolve));
|
||||
await new Promise((resolve) => this.server?.close(resolve));
|
||||
}
|
||||
|
||||
@bind private onRequest(request: IncomingMessage, response: ServerResponse): void {
|
||||
@@ -133,8 +145,15 @@ export default class Frontend extends Extension {
|
||||
// This is necessary for the browser to resolve relative assets paths correctly.
|
||||
request.originalUrl = request.url;
|
||||
request.url = '/' + newUrl;
|
||||
request.path = request.url;
|
||||
|
||||
this.fileServer(request, response, fin);
|
||||
if (newUrl.startsWith('device_icons/')) {
|
||||
request.path = request.path.replace('device_icons/', '');
|
||||
request.url = request.url.replace('/device_icons', '');
|
||||
this.deviceIconsFileServer(request, response, fin);
|
||||
} else {
|
||||
this.fileServer(request, response, fin);
|
||||
}
|
||||
}
|
||||
|
||||
private authenticate(request: IncomingMessage, cb: (authenticate: boolean) => void): void {
|
||||
@@ -165,7 +184,6 @@ export default class Frontend extends Extension {
|
||||
});
|
||||
|
||||
for (const [topic, payload] of Object.entries(this.mqtt.retainedMessages)) {
|
||||
/* istanbul ignore else */
|
||||
if (topic.startsWith(`${this.mqttBaseTopic}/`)) {
|
||||
ws.send(
|
||||
stringify({
|
||||
@@ -181,9 +199,8 @@ export default class Frontend extends Extension {
|
||||
const payload = this.state.get(device);
|
||||
const lastSeen = settings.get().advanced.last_seen;
|
||||
|
||||
/* istanbul ignore if */
|
||||
if (lastSeen !== 'disable') {
|
||||
payload.last_seen = utils.formatDate(device.zh.lastSeen ?? 0, lastSeen);
|
||||
payload.last_seen = utils.formatDate(device.zh.lastSeen ?? /* v8 ignore next */ 0, lastSeen);
|
||||
}
|
||||
|
||||
if (device.zh.linkquality !== undefined) {
|
||||
@@ -195,14 +212,12 @@ export default class Frontend extends Extension {
|
||||
}
|
||||
|
||||
@bind private onMQTTPublishMessage(data: eventdata.MQTTMessagePublished): void {
|
||||
/* istanbul ignore else */
|
||||
if (data.topic.startsWith(`${this.mqttBaseTopic}/`)) {
|
||||
// Send topic without base_topic
|
||||
const topic = data.topic.substring(this.mqttBaseTopic.length + 1);
|
||||
const payload = utils.parseJSON(data.payload, data.payload);
|
||||
|
||||
for (const client of this.wss.clients) {
|
||||
/* istanbul ignore else */
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(stringify({topic, payload}));
|
||||
}
|
||||
|
||||
+119
-263
@@ -1,4 +1,6 @@
|
||||
import assert from 'assert';
|
||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from 'lib/types/api';
|
||||
|
||||
import assert from 'node:assert';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import equals from 'fast-deep-equal/es6';
|
||||
@@ -14,8 +16,6 @@ import utils, {isLightExpose} from '../util/utils';
|
||||
import Extension from './extension';
|
||||
|
||||
const TOPIC_REGEX = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/group/members/(remove|add|remove_all)$`);
|
||||
const LEGACY_TOPIC_REGEX = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/(.+)/(remove|add|remove_all)$`);
|
||||
const LEGACY_TOPIC_REGEX_REMOVE_ALL = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/remove_all$`);
|
||||
|
||||
const STATE_PROPERTIES: Readonly<Record<string, (value: string, exposes: zhc.Expose[]) => boolean>> = {
|
||||
state: () => true,
|
||||
@@ -32,96 +32,21 @@ const STATE_PROPERTIES: Readonly<Record<string, (value: string, exposes: zhc.Exp
|
||||
|
||||
interface ParsedMQTTMessage {
|
||||
type: 'remove' | 'add' | 'remove_all';
|
||||
resolvedEntityGroup?: Group;
|
||||
resolvedEntityDevice: Device;
|
||||
error?: string;
|
||||
resolvedGroup?: Group;
|
||||
resolvedDevice?: Device;
|
||||
resolvedEndpoint?: zh.Endpoint;
|
||||
groupKey?: string;
|
||||
deviceKey?: string;
|
||||
triggeredViaLegacyApi: boolean;
|
||||
endpointKey?: string | number;
|
||||
skipDisableReporting: boolean;
|
||||
resolvedEntityEndpoint?: zh.Endpoint;
|
||||
}
|
||||
|
||||
export default class Groups extends Extension {
|
||||
private legacyApi = settings.get().advanced.legacy_api;
|
||||
private lastOptimisticState: {[s: string]: KeyValue} = {};
|
||||
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onStateChange(this, this.onStateChange);
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
await this.syncGroupsWithSettings();
|
||||
}
|
||||
|
||||
private async syncGroupsWithSettings(): Promise<void> {
|
||||
const settingsGroups = settings.getGroups();
|
||||
|
||||
const addRemoveFromGroup = async (
|
||||
action: 'add' | 'remove',
|
||||
deviceName: string,
|
||||
groupName: string | number,
|
||||
endpoint: zh.Endpoint,
|
||||
group: Group,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
logger.info(`${action === 'add' ? 'Adding' : 'Removing'} '${deviceName}' to group '${groupName}'`);
|
||||
|
||||
if (action === 'remove') {
|
||||
await endpoint.removeFromGroup(group.zh);
|
||||
} else {
|
||||
await endpoint.addToGroup(group.zh);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ${action} '${deviceName}' from '${groupName}'`);
|
||||
logger.debug((error as Error).stack!);
|
||||
}
|
||||
};
|
||||
|
||||
for (const settingGroup of settingsGroups) {
|
||||
const groupID = settingGroup.ID;
|
||||
const zigbeeGroup = this.zigbee.groupsIterator((g) => g.groupID === groupID).next().value || this.zigbee.createGroup(groupID);
|
||||
const settingsEndpoints: zh.Endpoint[] = [];
|
||||
|
||||
for (const d of settingGroup.devices) {
|
||||
const parsed = this.zigbee.resolveEntityAndEndpoint(d);
|
||||
const device = parsed.entity as Device;
|
||||
|
||||
if (!device) {
|
||||
logger.error(`Cannot find '${d}' of group '${settingGroup.friendly_name}'`);
|
||||
}
|
||||
|
||||
if (!parsed.endpoint) {
|
||||
if (parsed.endpointID) {
|
||||
logger.error(`Cannot find endpoint '${parsed.endpointID}' of device '${parsed.ID}'`);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// In settings but not in zigbee
|
||||
if (!zigbeeGroup.zh.hasMember(parsed.endpoint)) {
|
||||
await addRemoveFromGroup('add', device?.name, settingGroup.friendly_name, parsed.endpoint, zigbeeGroup);
|
||||
}
|
||||
|
||||
settingsEndpoints.push(parsed.endpoint);
|
||||
}
|
||||
|
||||
// In zigbee but not in settings
|
||||
for (const endpoint of zigbeeGroup.zh.members) {
|
||||
if (!settingsEndpoints.includes(endpoint)) {
|
||||
const deviceName = settings.getDevice(endpoint.getDevice().ieeeAddr)!.friendly_name;
|
||||
|
||||
await addRemoveFromGroup('remove', deviceName, settingGroup.friendly_name, endpoint, zigbeeGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const zigbeeGroup of this.zigbee.groupsIterator((zg) => !settingsGroups.some((sg) => sg.ID === zg.groupID))) {
|
||||
for (const endpoint of zigbeeGroup.zh.members) {
|
||||
const deviceName = settings.getDevice(endpoint.getDevice().ieeeAddr)!.friendly_name;
|
||||
|
||||
await addRemoveFromGroup('remove', deviceName, zigbeeGroup.ID, endpoint, zigbeeGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bind async onStateChange(data: eventdata.StateChange): Promise<void> {
|
||||
@@ -164,7 +89,6 @@ export default class Groups extends Extension {
|
||||
if (entity instanceof Device) {
|
||||
const endpoint = entity.endpoint(endpointName);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (endpoint) {
|
||||
for (const group of groups) {
|
||||
if (
|
||||
@@ -242,8 +166,16 @@ export default class Groups extends Extension {
|
||||
|
||||
if (this.state.exists(device)) {
|
||||
const state = this.state.get(device);
|
||||
const endpointNames = device.isDevice() && device.getEndpointNames();
|
||||
const stateKey =
|
||||
endpointNames &&
|
||||
endpointNames.length >= member.ID &&
|
||||
device.definition?.meta?.multiEndpoint &&
|
||||
(!device.definition.meta.multiEndpointSkip || !device.definition.meta.multiEndpointSkip.includes('state'))
|
||||
? `state_${endpointNames[member.ID - 1]}`
|
||||
: 'state';
|
||||
|
||||
if (state.state === 'ON' || state.state === 'OPEN') {
|
||||
if (state[stateKey] === 'ON' || state[stateKey] === 'OPEN') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -252,223 +184,147 @@ export default class Groups extends Extension {
|
||||
return true;
|
||||
}
|
||||
|
||||
private async parseMQTTMessage(data: eventdata.MQTTMessage): Promise<ParsedMQTTMessage | undefined> {
|
||||
let type: ParsedMQTTMessage['type'] | undefined;
|
||||
let resolvedEntityGroup: ParsedMQTTMessage['resolvedEntityGroup'] | undefined;
|
||||
let resolvedEntityDevice: ParsedMQTTMessage['resolvedEntityDevice'] | undefined;
|
||||
let resolvedEntityEndpoint: ParsedMQTTMessage['resolvedEntityEndpoint'] | undefined;
|
||||
let error: ParsedMQTTMessage['error'] | undefined;
|
||||
let groupKey: ParsedMQTTMessage['groupKey'] | undefined;
|
||||
let deviceKey: ParsedMQTTMessage['deviceKey'] | undefined;
|
||||
let triggeredViaLegacyApi: ParsedMQTTMessage['triggeredViaLegacyApi'] = false;
|
||||
let skipDisableReporting: ParsedMQTTMessage['skipDisableReporting'] = false;
|
||||
|
||||
/* istanbul ignore else */
|
||||
private parseMQTTMessage(
|
||||
data: eventdata.MQTTMessage,
|
||||
): [raw: KeyValue | undefined, parsed: ParsedMQTTMessage | undefined, error: string | undefined] {
|
||||
const topicRegexMatch = data.topic.match(TOPIC_REGEX);
|
||||
const legacyTopicRegexRemoveAllMatch = data.topic.match(LEGACY_TOPIC_REGEX_REMOVE_ALL);
|
||||
const legacyTopicRegexMatch = data.topic.match(LEGACY_TOPIC_REGEX);
|
||||
|
||||
if (this.legacyApi && (legacyTopicRegexMatch || legacyTopicRegexRemoveAllMatch)) {
|
||||
triggeredViaLegacyApi = true;
|
||||
if (topicRegexMatch) {
|
||||
const type = topicRegexMatch[1] as 'remove' | 'add' | 'remove_all';
|
||||
let resolvedGroup;
|
||||
let groupKey;
|
||||
let skipDisableReporting = false;
|
||||
const message = JSON.parse(data.message) as Zigbee2MQTTAPI['bridge/request/group/members/add'];
|
||||
|
||||
if (legacyTopicRegexMatch) {
|
||||
resolvedEntityGroup = this.zigbee.resolveEntity(legacyTopicRegexMatch[1]) as Group;
|
||||
type = legacyTopicRegexMatch[2] as ParsedMQTTMessage['type'];
|
||||
|
||||
if (!resolvedEntityGroup || !(resolvedEntityGroup instanceof Group)) {
|
||||
logger.error(`Group '${legacyTopicRegexMatch[1]}' does not exist`);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const message = {friendly_name: data.message, group: legacyTopicRegexMatch[1], error: `group doesn't exists`};
|
||||
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `device_group_${type}_failed`, message}));
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
type = 'remove_all';
|
||||
if (typeof message !== 'object' || message.device == undefined) {
|
||||
return [message, {type, skipDisableReporting}, 'Invalid payload'];
|
||||
}
|
||||
|
||||
const parsedEntity = this.zigbee.resolveEntityAndEndpoint(data.message);
|
||||
resolvedEntityDevice = parsedEntity.entity as Device;
|
||||
|
||||
if (!resolvedEntityDevice || !(resolvedEntityDevice instanceof Device)) {
|
||||
logger.error(`Device '${data.message}' does not exist`);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const message = {friendly_name: data.message, group: legacyTopicRegexMatch![1], error: "entity doesn't exists"};
|
||||
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `device_group_${type}_failed`, message}));
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
resolvedEntityEndpoint = parsedEntity.endpoint;
|
||||
|
||||
if (parsedEntity.endpointID && !resolvedEntityEndpoint) {
|
||||
logger.error(`Device '${parsedEntity.ID}' does not have endpoint '${parsedEntity.endpointID}'`);
|
||||
return undefined;
|
||||
}
|
||||
} else if (topicRegexMatch) {
|
||||
type = topicRegexMatch[1] as 'remove' | 'add' | 'remove_all';
|
||||
const message = JSON.parse(data.message);
|
||||
deviceKey = message.device;
|
||||
skipDisableReporting = 'skip_disable_reporting' in message ? message.skip_disable_reporting : false;
|
||||
const deviceKey = message.device;
|
||||
skipDisableReporting = message.skip_disable_reporting != undefined ? message.skip_disable_reporting : false;
|
||||
|
||||
if (type !== 'remove_all') {
|
||||
groupKey = message.group;
|
||||
resolvedEntityGroup = this.zigbee.resolveEntity(message.group) as Group;
|
||||
|
||||
if (!resolvedEntityGroup || !(resolvedEntityGroup instanceof Group)) {
|
||||
error = `Group '${message.group}' does not exist`;
|
||||
if (message.group == undefined) {
|
||||
return [message, {type, skipDisableReporting}, `Invalid payload`];
|
||||
}
|
||||
|
||||
resolvedGroup = this.zigbee.resolveEntity(message.group);
|
||||
|
||||
if (!resolvedGroup || !(resolvedGroup instanceof Group)) {
|
||||
return [message, {type, skipDisableReporting}, `Group '${message.group}' does not exist`];
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = this.zigbee.resolveEntityAndEndpoint(message.device);
|
||||
resolvedEntityDevice = parsed?.entity as Device;
|
||||
const resolvedDevice = this.zigbee.resolveEntity(message.device);
|
||||
|
||||
if (!error && (!resolvedEntityDevice || !(resolvedEntityDevice instanceof Device))) {
|
||||
error = `Device '${message.device}' does not exist`;
|
||||
if (!resolvedDevice || !(resolvedDevice instanceof Device)) {
|
||||
return [message, {type, skipDisableReporting}, `Device '${message.device}' does not exist`];
|
||||
}
|
||||
|
||||
if (!error) {
|
||||
resolvedEntityEndpoint = parsed.endpoint;
|
||||
const endpointKey = message.endpoint ?? 'default';
|
||||
const resolvedEndpoint = resolvedDevice.endpoint(message.endpoint);
|
||||
|
||||
if (parsed.endpointID && !resolvedEntityEndpoint) {
|
||||
error = `Device '${parsed.ID}' does not have endpoint '${parsed.endpointID}'`;
|
||||
}
|
||||
if (!resolvedEndpoint) {
|
||||
return [message, {type, skipDisableReporting}, `Device '${resolvedDevice.name}' does not have endpoint '${endpointKey}'`];
|
||||
}
|
||||
|
||||
return [
|
||||
message,
|
||||
{
|
||||
resolvedGroup,
|
||||
resolvedDevice,
|
||||
resolvedEndpoint,
|
||||
type,
|
||||
groupKey,
|
||||
deviceKey,
|
||||
endpointKey,
|
||||
skipDisableReporting,
|
||||
},
|
||||
undefined,
|
||||
];
|
||||
} else {
|
||||
return undefined;
|
||||
return [undefined, undefined, undefined];
|
||||
}
|
||||
|
||||
return {
|
||||
resolvedEntityGroup,
|
||||
resolvedEntityDevice,
|
||||
type,
|
||||
error,
|
||||
groupKey,
|
||||
deviceKey,
|
||||
triggeredViaLegacyApi,
|
||||
skipDisableReporting,
|
||||
resolvedEntityEndpoint,
|
||||
};
|
||||
}
|
||||
|
||||
@bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const parsed = await this.parseMQTTMessage(data);
|
||||
const [raw, parsed, error] = this.parseMQTTMessage(data);
|
||||
|
||||
if (!parsed || !parsed.type) {
|
||||
if (!raw || !parsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
resolvedEntityGroup,
|
||||
resolvedEntityDevice,
|
||||
type,
|
||||
triggeredViaLegacyApi,
|
||||
groupKey,
|
||||
deviceKey,
|
||||
skipDisableReporting,
|
||||
resolvedEntityEndpoint,
|
||||
} = parsed;
|
||||
let error = parsed.error;
|
||||
if (error) {
|
||||
await this.publishResponse(parsed.type, raw, {}, error);
|
||||
return;
|
||||
}
|
||||
|
||||
const {resolvedGroup, resolvedDevice, resolvedEndpoint, type, groupKey, deviceKey, endpointKey, skipDisableReporting} = parsed;
|
||||
const changedGroups: Group[] = [];
|
||||
|
||||
if (!error) {
|
||||
assert(resolvedEntityEndpoint, '`resolvedEntityEndpoint` is missing');
|
||||
try {
|
||||
const keys = [
|
||||
`${resolvedEntityDevice.ieeeAddr}/${resolvedEntityEndpoint.ID}`,
|
||||
`${resolvedEntityDevice.name}/${resolvedEntityEndpoint.ID}`,
|
||||
];
|
||||
const endpointNameLocal = resolvedEntityDevice.endpointName(resolvedEntityEndpoint);
|
||||
assert(resolvedDevice, '`resolvedDevice` is missing');
|
||||
assert(resolvedEndpoint, '`resolvedEndpoint` is missing');
|
||||
|
||||
if (endpointNameLocal) {
|
||||
keys.push(`${resolvedEntityDevice.ieeeAddr}/${endpointNameLocal}`);
|
||||
keys.push(`${resolvedEntityDevice.name}/${endpointNameLocal}`);
|
||||
try {
|
||||
if (type === 'add') {
|
||||
assert(resolvedGroup, '`resolvedGroup` is missing');
|
||||
logger.info(`Adding '${resolvedDevice.name}' to '${resolvedGroup.name}'`);
|
||||
await resolvedEndpoint.addToGroup(resolvedGroup.zh);
|
||||
changedGroups.push(resolvedGroup);
|
||||
await this.publishResponse<'bridge/response/group/members/add'>(parsed.type, raw, {
|
||||
device: deviceKey!, // valid from resolved asserts
|
||||
endpoint: endpointKey!, // valid from resolved asserts
|
||||
group: groupKey!, // valid from resolved asserts
|
||||
});
|
||||
} else if (type === 'remove') {
|
||||
assert(resolvedGroup, '`resolvedGroup` is missing');
|
||||
logger.info(`Removing '${resolvedDevice.name}' from '${resolvedGroup.name}'`);
|
||||
await resolvedEndpoint.removeFromGroup(resolvedGroup.zh);
|
||||
changedGroups.push(resolvedGroup);
|
||||
await this.publishResponse<'bridge/response/group/members/remove'>(parsed.type, raw, {
|
||||
device: deviceKey!, // valid from resolved asserts
|
||||
endpoint: endpointKey!, // valid from resolved asserts
|
||||
group: groupKey!, // valid from resolved asserts
|
||||
});
|
||||
} else {
|
||||
// remove_all
|
||||
logger.info(`Removing '${resolvedDevice.name}' from all groups`);
|
||||
|
||||
for (const group of this.zigbee.groupsIterator((g) => g.members.includes(resolvedEndpoint))) {
|
||||
changedGroups.push(group);
|
||||
}
|
||||
|
||||
if (!endpointNameLocal) {
|
||||
keys.push(resolvedEntityDevice.name);
|
||||
keys.push(resolvedEntityDevice.ieeeAddr);
|
||||
}
|
||||
|
||||
if (type === 'add') {
|
||||
assert(resolvedEntityGroup, '`resolvedEntityGroup` is missing');
|
||||
logger.info(`Adding '${resolvedEntityDevice.name}' to '${resolvedEntityGroup.name}'`);
|
||||
await resolvedEntityEndpoint.addToGroup(resolvedEntityGroup.zh);
|
||||
settings.addDeviceToGroup(resolvedEntityGroup.ID.toString(), keys);
|
||||
changedGroups.push(resolvedEntityGroup);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const message = {friendly_name: resolvedEntityDevice.name, group: resolvedEntityGroup.name};
|
||||
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `device_group_add`, message}));
|
||||
}
|
||||
} else if (type === 'remove') {
|
||||
assert(resolvedEntityGroup, '`resolvedEntityGroup` is missing');
|
||||
logger.info(`Removing '${resolvedEntityDevice.name}' from '${resolvedEntityGroup.name}'`);
|
||||
await resolvedEntityEndpoint.removeFromGroup(resolvedEntityGroup.zh);
|
||||
settings.removeDeviceFromGroup(resolvedEntityGroup.ID.toString(), keys);
|
||||
changedGroups.push(resolvedEntityGroup);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const message = {friendly_name: resolvedEntityDevice.name, group: resolvedEntityGroup.name};
|
||||
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `device_group_remove`, message}));
|
||||
}
|
||||
} else {
|
||||
// remove_all
|
||||
logger.info(`Removing '${resolvedEntityDevice.name}' from all groups`);
|
||||
|
||||
for (const group of this.zigbee.groupsIterator((g) => g.members.includes(resolvedEntityEndpoint))) {
|
||||
changedGroups.push(group);
|
||||
}
|
||||
|
||||
await resolvedEntityEndpoint.removeFromAllGroups();
|
||||
|
||||
for (const settingsGroup of settings.getGroups()) {
|
||||
settings.removeDeviceFromGroup(settingsGroup.ID.toString(), keys);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const message = {friendly_name: resolvedEntityDevice.name};
|
||||
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `device_group_remove_all`, message}));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error = `Failed to ${type} from group (${(e as Error).message})`;
|
||||
logger.debug((e as Error).stack!);
|
||||
await resolvedEndpoint.removeFromAllGroups();
|
||||
await this.publishResponse<'bridge/response/group/members/remove_all'>(parsed.type, raw, {
|
||||
device: deviceKey!, // valid from resolved asserts
|
||||
endpoint: endpointKey!, // valid from resolved asserts
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
const errorMsg = `Failed to ${type} from group (${(e as Error).message})`;
|
||||
await this.publishResponse(parsed.type, raw, {}, errorMsg);
|
||||
logger.debug((e as Error).stack!);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!triggeredViaLegacyApi) {
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
const responseData: KeyValue = {device: deviceKey};
|
||||
|
||||
if (groupKey) {
|
||||
responseData.group = groupKey;
|
||||
}
|
||||
|
||||
await this.mqtt.publish(`bridge/response/group/members/${type}`, stringify(utils.getResponse(message, responseData, error)));
|
||||
for (const group of changedGroups) {
|
||||
this.eventBus.emitGroupMembersChanged({group, action: type, endpoint: resolvedEndpoint, skipDisableReporting});
|
||||
}
|
||||
}
|
||||
|
||||
private async publishResponse<T extends Zigbee2MQTTResponseEndpoints>(
|
||||
type: ParsedMQTTMessage['type'],
|
||||
request: KeyValue,
|
||||
data: Zigbee2MQTTAPI[T],
|
||||
error?: string,
|
||||
): Promise<void> {
|
||||
const response = utils.getResponse(request, data, error);
|
||||
await this.mqtt.publish(`bridge/response/group/members/${type}`, stringify(response));
|
||||
|
||||
if (error) {
|
||||
logger.error(error);
|
||||
} else {
|
||||
assert(resolvedEntityEndpoint, '`resolvedEntityEndpoint` is missing');
|
||||
for (const group of changedGroups) {
|
||||
this.eventBus.emitGroupMembersChanged({group, action: type, endpoint: resolvedEntityEndpoint, skipDisableReporting});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+308
-326
File diff suppressed because it is too large
Load Diff
@@ -1,424 +0,0 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
import logger from '../../util/logger';
|
||||
import * as settings from '../../util/settings';
|
||||
import utils from '../../util/utils';
|
||||
import Extension from '../extension';
|
||||
|
||||
const configRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/config/((?:\\w+/get)|(?:\\w+/factory_reset)|(?:\\w+))`);
|
||||
|
||||
export default class BridgeLegacy extends Extension {
|
||||
private lastJoinedDeviceName?: string;
|
||||
// @ts-expect-error initialized in `start`
|
||||
private supportedOptions: {[s: string]: (topic: string, message: string) => Promise<void> | void};
|
||||
|
||||
override async start(): Promise<void> {
|
||||
this.supportedOptions = {
|
||||
permit_join: this.permitJoin,
|
||||
last_seen: this.lastSeen,
|
||||
elapsed: this.elapsed,
|
||||
reset: this.reset,
|
||||
log_level: this.logLevel,
|
||||
devices: this.devices,
|
||||
groups: this.groups,
|
||||
'devices/get': this.devices,
|
||||
rename: this.rename,
|
||||
rename_last: this.renameLast,
|
||||
remove: this.remove,
|
||||
force_remove: this.forceRemove,
|
||||
ban: this.ban,
|
||||
device_options: this.deviceOptions,
|
||||
add_group: this.addGroup,
|
||||
remove_group: this.removeGroup,
|
||||
force_remove_group: this.removeGroup,
|
||||
whitelist: this.whitelist,
|
||||
'touchlink/factory_reset': this.touchlinkFactoryReset,
|
||||
};
|
||||
|
||||
this.eventBus.onDeviceJoined(this, (data) => this.onZigbeeEvent_('deviceJoined', data, data.device));
|
||||
this.eventBus.onDeviceInterview(this, (data) => this.onZigbeeEvent_('deviceInterview', data, data.device));
|
||||
this.eventBus.onDeviceAnnounce(this, (data) => this.onZigbeeEvent_('deviceAnnounce', data, data.device));
|
||||
this.eventBus.onDeviceLeave(this, (data) => this.onZigbeeEvent_('deviceLeave', data, undefined));
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
|
||||
await this.publish();
|
||||
}
|
||||
|
||||
@bind async whitelist(topic: string, message: string): Promise<void> {
|
||||
try {
|
||||
const entity = settings.getDevice(message);
|
||||
assert(entity, `Entity '${message}' does not exist`);
|
||||
settings.addDeviceToPasslist(entity.ID.toString());
|
||||
logger.info(`Whitelisted '${entity.friendly_name}'`);
|
||||
await this.mqtt.publish('bridge/log', stringify({type: 'device_whitelisted', message: {friendly_name: entity.friendly_name}}));
|
||||
} catch (error) {
|
||||
logger.error(`Failed to whitelist '${message}' '${error}'`);
|
||||
}
|
||||
}
|
||||
|
||||
@bind deviceOptions(topic: string, message: string): void {
|
||||
let json = null;
|
||||
try {
|
||||
json = JSON.parse(message);
|
||||
} catch {
|
||||
logger.error('Failed to parse message as JSON');
|
||||
return;
|
||||
}
|
||||
|
||||
if (json.friendly_name === undefined || json.options === undefined) {
|
||||
logger.error('Invalid JSON message, should contain "friendly_name" and "options"');
|
||||
return;
|
||||
}
|
||||
|
||||
const entity = settings.getDevice(json.friendly_name);
|
||||
assert(entity, `Entity '${json.friendly_name}' does not exist`);
|
||||
settings.changeEntityOptions(entity.ID.toString(), json.options);
|
||||
logger.info(`Changed device specific options of '${json.friendly_name}' (${stringify(json.options)})`);
|
||||
}
|
||||
|
||||
@bind async permitJoin(topic: string, message: string): Promise<void> {
|
||||
await this.zigbee.permitJoin(message.toLowerCase() === 'true');
|
||||
await this.publish();
|
||||
}
|
||||
|
||||
@bind async reset(): Promise<void> {
|
||||
try {
|
||||
await this.zigbee.reset('soft');
|
||||
logger.info('Soft reset ZNP');
|
||||
} catch {
|
||||
logger.error('Soft reset failed');
|
||||
}
|
||||
}
|
||||
|
||||
@bind lastSeen(topic: string, message: string): void {
|
||||
const allowed = ['disable', 'ISO_8601', 'epoch', 'ISO_8601_local'];
|
||||
if (!allowed.includes(message)) {
|
||||
logger.error(`${message} is not an allowed value, possible: ${allowed}`);
|
||||
return;
|
||||
}
|
||||
|
||||
settings.set(['advanced', 'last_seen'], message);
|
||||
logger.info(`Set last_seen to ${message}`);
|
||||
}
|
||||
|
||||
@bind elapsed(topic: string, message: string): void {
|
||||
const allowed = ['true', 'false'];
|
||||
if (!allowed.includes(message)) {
|
||||
logger.error(`${message} is not an allowed value, possible: ${allowed}`);
|
||||
return;
|
||||
}
|
||||
|
||||
settings.set(['advanced', 'elapsed'], message === 'true');
|
||||
logger.info(`Set elapsed to ${message}`);
|
||||
}
|
||||
|
||||
@bind async logLevel(topic: string, message: string): Promise<void> {
|
||||
const level = message.toLowerCase() as settings.LogLevel;
|
||||
if (settings.LOG_LEVELS.includes(level)) {
|
||||
logger.info(`Switching log level to '${level}'`);
|
||||
logger.setLevel(level);
|
||||
} else {
|
||||
logger.error(`Could not set log level to '${level}'. Allowed level: '${settings.LOG_LEVELS.join(',')}'`);
|
||||
}
|
||||
|
||||
await this.publish();
|
||||
}
|
||||
|
||||
@bind async devices(topic: string): Promise<void> {
|
||||
const coordinator = await this.zigbee.getCoordinatorVersion();
|
||||
const devices: KeyValue[] = [];
|
||||
|
||||
for (const device of this.zigbee.devicesIterator()) {
|
||||
const payload: KeyValue = {
|
||||
ieeeAddr: device.ieeeAddr,
|
||||
type: device.zh.type,
|
||||
networkAddress: device.zh.networkAddress,
|
||||
};
|
||||
|
||||
if (device.zh.type !== 'Coordinator') {
|
||||
const definition = device.definition;
|
||||
payload.model = definition ? definition.model : device.zh.modelID;
|
||||
payload.vendor = definition ? definition.vendor : '-';
|
||||
payload.description = definition ? definition.description : '-';
|
||||
payload.friendly_name = device.name;
|
||||
payload.manufacturerID = device.zh.manufacturerID;
|
||||
payload.manufacturerName = device.zh.manufacturerName;
|
||||
payload.powerSource = device.zh.powerSource;
|
||||
payload.modelID = device.zh.modelID;
|
||||
payload.hardwareVersion = device.zh.hardwareVersion;
|
||||
payload.softwareBuildID = device.zh.softwareBuildID;
|
||||
payload.dateCode = device.zh.dateCode;
|
||||
payload.lastSeen = device.zh.lastSeen;
|
||||
} else {
|
||||
payload.friendly_name = 'Coordinator';
|
||||
payload.softwareBuildID = coordinator.type;
|
||||
payload.dateCode = coordinator.meta.revision.toString();
|
||||
payload.lastSeen = Date.now();
|
||||
}
|
||||
|
||||
devices.push(payload);
|
||||
}
|
||||
|
||||
if (topic.split('/').pop() == 'get') {
|
||||
await this.mqtt.publish(`bridge/config/devices`, stringify(devices), {}, settings.get().mqtt.base_topic, false, false);
|
||||
} else {
|
||||
await this.mqtt.publish('bridge/log', stringify({type: 'devices', message: devices}));
|
||||
}
|
||||
}
|
||||
|
||||
@bind async groups(): Promise<void> {
|
||||
const payload = settings.getGroups().map((g) => {
|
||||
return {...g, ID: Number(g.ID)};
|
||||
});
|
||||
|
||||
await this.mqtt.publish('bridge/log', stringify({type: 'groups', message: payload}));
|
||||
}
|
||||
|
||||
@bind async rename(topic: string, message: string): Promise<void> {
|
||||
const invalid = `Invalid rename message format expected {"old": "friendly_name", "new": "new_name"} got ${message}`;
|
||||
|
||||
let json = null;
|
||||
try {
|
||||
json = JSON.parse(message);
|
||||
} catch {
|
||||
logger.error(invalid);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate message
|
||||
if (!json.new || !json.old) {
|
||||
logger.error(invalid);
|
||||
return;
|
||||
}
|
||||
|
||||
await this._renameInternal(json.old, json.new);
|
||||
}
|
||||
|
||||
@bind async renameLast(topic: string, message: string): Promise<void> {
|
||||
if (!this.lastJoinedDeviceName) {
|
||||
logger.error(`Cannot rename last joined device, no device has joined during this session`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this._renameInternal(this.lastJoinedDeviceName, message);
|
||||
}
|
||||
|
||||
async _renameInternal(from: string, to: string): Promise<void> {
|
||||
try {
|
||||
const isGroup = settings.getGroup(from) != undefined;
|
||||
settings.changeFriendlyName(from, to);
|
||||
logger.info(`Successfully renamed - ${from} to ${to} `);
|
||||
const entity = this.zigbee.resolveEntity(to);
|
||||
if (entity?.isDevice()) {
|
||||
this.eventBus.emitEntityRenamed({homeAssisantRename: false, from, to, entity});
|
||||
}
|
||||
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `${isGroup ? 'group' : 'device'}_renamed`, message: {from, to}}));
|
||||
} catch {
|
||||
logger.error(`Failed to rename - ${from} to ${to}`);
|
||||
}
|
||||
}
|
||||
|
||||
@bind async addGroup(topic: string, message: string): Promise<void> {
|
||||
let id = null;
|
||||
let name = null;
|
||||
try {
|
||||
// json payload with id and friendly_name
|
||||
const json = JSON.parse(message);
|
||||
if (json.id !== undefined) {
|
||||
id = json.id;
|
||||
name = `group_${id}`;
|
||||
}
|
||||
if (json.friendly_name !== undefined) {
|
||||
name = json.friendly_name;
|
||||
}
|
||||
} catch {
|
||||
// just friendly_name
|
||||
name = message;
|
||||
}
|
||||
|
||||
if (name == null) {
|
||||
logger.error('Failed to add group, missing friendly_name!');
|
||||
return;
|
||||
}
|
||||
|
||||
const group = settings.addGroup(name, id);
|
||||
this.zigbee.createGroup(group.ID);
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `group_added`, message: name}));
|
||||
logger.info(`Added group '${name}'`);
|
||||
}
|
||||
|
||||
@bind async removeGroup(topic: string, message: string): Promise<void> {
|
||||
const name = message;
|
||||
const entity = this.zigbee.resolveEntity(message) as Group;
|
||||
assert(entity && entity.isGroup(), `Group '${message}' does not exist`);
|
||||
|
||||
if (topic.includes('force')) {
|
||||
entity.zh.removeFromDatabase();
|
||||
} else {
|
||||
await entity.zh.removeFromNetwork();
|
||||
}
|
||||
settings.removeGroup(message);
|
||||
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `group_removed`, message}));
|
||||
logger.info(`Removed group '${name}'`);
|
||||
}
|
||||
|
||||
@bind async forceRemove(topic: string, message: string): Promise<void> {
|
||||
await this.removeForceRemoveOrBan('force_remove', message);
|
||||
}
|
||||
|
||||
@bind async remove(topic: string, message: string): Promise<void> {
|
||||
await this.removeForceRemoveOrBan('remove', message);
|
||||
}
|
||||
|
||||
@bind async ban(topic: string, message: string): Promise<void> {
|
||||
await this.removeForceRemoveOrBan('ban', message);
|
||||
}
|
||||
|
||||
@bind async removeForceRemoveOrBan(action: string, message: string): Promise<void> {
|
||||
const entity = this.zigbee.resolveEntity(message.trim()) as Device;
|
||||
const lookup: KeyValue = {
|
||||
ban: ['banned', 'Banning', 'ban'],
|
||||
force_remove: ['force_removed', 'Force removing', 'force remove'],
|
||||
remove: ['removed', 'Removing', 'remove'],
|
||||
};
|
||||
|
||||
if (!entity) {
|
||||
logger.error(`Cannot ${lookup[action][2]}, device '${message}' does not exist`);
|
||||
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `device_${lookup[action][0]}_failed`, message}));
|
||||
return;
|
||||
}
|
||||
|
||||
const ieeeAddr = entity.ieeeAddr;
|
||||
const name = entity.name;
|
||||
|
||||
const cleanup = async (): Promise<void> => {
|
||||
// Fire event
|
||||
this.eventBus.emitEntityRemoved({id: ieeeAddr, name: name, type: 'device'});
|
||||
|
||||
// Remove from configuration.yaml
|
||||
settings.removeDevice(entity.ieeeAddr);
|
||||
|
||||
// Remove from state
|
||||
this.state.remove(ieeeAddr);
|
||||
|
||||
logger.info(`Successfully ${lookup[action][0]} ${entity.name}`);
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `device_${lookup[action][0]}`, message}));
|
||||
};
|
||||
|
||||
try {
|
||||
logger.info(`${lookup[action][1]} '${entity.name}'`);
|
||||
if (action === 'force_remove') {
|
||||
entity.zh.removeFromDatabase();
|
||||
} else {
|
||||
await entity.zh.removeFromNetwork();
|
||||
}
|
||||
|
||||
await cleanup();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ${lookup[action][2]} ${entity.name} (${error})`);
|
||||
|
||||
logger.error(`See https://www.zigbee2mqtt.io/guide/usage/mqtt_topics_and_messages.html#zigbee2mqtt-bridge-request for more info`);
|
||||
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `device_${lookup[action][0]}_failed`, message}));
|
||||
}
|
||||
|
||||
if (action === 'ban') {
|
||||
settings.blockDevice(ieeeAddr);
|
||||
}
|
||||
}
|
||||
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const {topic, message} = data;
|
||||
const match = topic.match(configRegex);
|
||||
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
const option = match[1];
|
||||
|
||||
if (this.supportedOptions[option] === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.supportedOptions[option](topic, message);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
async publish(): Promise<void> {
|
||||
const info = await utils.getZigbee2MQTTVersion();
|
||||
const coordinator = await this.zigbee.getCoordinatorVersion();
|
||||
const topic = `bridge/config`;
|
||||
const payload = {
|
||||
version: info.version,
|
||||
commit: info.commitHash,
|
||||
coordinator,
|
||||
network: await this.zigbee.getNetworkParameters(),
|
||||
log_level: logger.getLevel(),
|
||||
permit_join: this.zigbee.getPermitJoin(),
|
||||
};
|
||||
|
||||
await this.mqtt.publish(topic, stringify(payload), {retain: true, qos: 0});
|
||||
}
|
||||
|
||||
async onZigbeeEvent_(type: string, data: KeyValue, resolvedEntity: Device | undefined): Promise<void> {
|
||||
if (resolvedEntity) {
|
||||
/* istanbul ignore else */
|
||||
if (type === 'deviceJoined') {
|
||||
this.lastJoinedDeviceName = resolvedEntity.name;
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `device_connected`, message: {friendly_name: resolvedEntity.name}}));
|
||||
} else if (type === 'deviceInterview') {
|
||||
if (data.status === 'successful') {
|
||||
if (resolvedEntity.isSupported) {
|
||||
const {vendor, description, model} = resolvedEntity.definition!; // checked by `isSupported`
|
||||
const log = {friendly_name: resolvedEntity.name, model, vendor, description, supported: true};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_successful', meta: log}));
|
||||
} else {
|
||||
const meta = {friendly_name: resolvedEntity.name, supported: false};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_successful', meta}));
|
||||
}
|
||||
} else if (data.status === 'failed') {
|
||||
const meta = {friendly_name: resolvedEntity.name};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_failed', meta}));
|
||||
} else {
|
||||
/* istanbul ignore else */
|
||||
if (data.status === 'started') {
|
||||
const meta = {friendly_name: resolvedEntity.name};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_started', meta}));
|
||||
}
|
||||
}
|
||||
} else if (type === 'deviceAnnounce') {
|
||||
const meta = {friendly_name: resolvedEntity.name};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `device_announced`, message: 'announce', meta}));
|
||||
}
|
||||
} else {
|
||||
/* istanbul ignore else */
|
||||
if (type === 'deviceLeave') {
|
||||
const name = data.ieeeAddr;
|
||||
const meta = {friendly_name: name};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `device_removed`, message: 'left_network', meta}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bind async touchlinkFactoryReset(): Promise<void> {
|
||||
logger.info('Starting touchlink factory reset...');
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `touchlink`, message: 'reset_started', meta: {status: 'started'}}));
|
||||
const result = await this.zigbee.touchlinkFactoryResetFirst();
|
||||
|
||||
if (result) {
|
||||
logger.info('Successfully factory reset device through Touchlink');
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `touchlink`, message: 'reset_success', meta: {status: 'success'}}));
|
||||
} else {
|
||||
logger.warning('Failed to factory reset device through Touchlink');
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `touchlink`, message: 'reset_failed', meta: {status: 'failed'}}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
/* istanbul ignore file */
|
||||
|
||||
import assert from 'assert';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
|
||||
import Device from '../../model/device';
|
||||
import logger from '../../util/logger';
|
||||
import * as settings from '../../util/settings';
|
||||
import Extension from '../extension';
|
||||
|
||||
const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/device/(.+)/get_group_membership$`);
|
||||
|
||||
export default class DeviceGroupMembership extends Extension {
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
}
|
||||
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const match = data.topic.match(topicRegex);
|
||||
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = this.zigbee.resolveEntityAndEndpoint(match[1]);
|
||||
const device = parsed?.entity as Device;
|
||||
|
||||
if (!device || !(device instanceof Device)) {
|
||||
logger.error(`Device '${match[1]}' does not exist`);
|
||||
return;
|
||||
}
|
||||
|
||||
const endpoint = parsed.endpoint;
|
||||
|
||||
if (parsed.endpointID && !endpoint) {
|
||||
logger.error(`Device '${parsed.ID}' does not have endpoint '${parsed.endpointID}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
assert(endpoint !== undefined);
|
||||
const response = await endpoint.command(`genGroups`, 'getMembership', {groupcount: 0, grouplist: []}, {});
|
||||
|
||||
if (!response) {
|
||||
logger.warning(`Couldn't get group membership of ${device.ieeeAddr}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let {grouplist} = response;
|
||||
|
||||
grouplist = grouplist.map((gid: string) => {
|
||||
const g = settings.getGroup(gid);
|
||||
return g ? g.friendly_name : gid;
|
||||
});
|
||||
|
||||
const msgGroupList = `${device.ieeeAddr} is in groups [${grouplist}]`;
|
||||
let msgCapacity;
|
||||
if (response.capacity === 254) {
|
||||
msgCapacity = 'it can be a part of at least 1 more group';
|
||||
} else {
|
||||
msgCapacity = `its remaining group capacity is ${response.capacity === 255 ? 'unknown' : response.capacity}`;
|
||||
}
|
||||
logger.info(`${msgGroupList} and ${msgCapacity}`);
|
||||
|
||||
await this.publishEntityState(device, {group_list: grouplist, group_capacity: response.capacity});
|
||||
}
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
|
||||
import logger from '../../util/logger';
|
||||
import * as settings from '../../util/settings';
|
||||
import utils from '../../util/utils';
|
||||
import Extension from '../extension';
|
||||
|
||||
const defaultConfiguration = {
|
||||
minimumReportInterval: 3,
|
||||
maximumReportInterval: 300,
|
||||
reportableChange: 1,
|
||||
};
|
||||
|
||||
const ZNLDP12LM = zhc.definitions.find((d) => d.model === 'ZNLDP12LM');
|
||||
|
||||
const devicesNotSupportingReporting = [
|
||||
zhc.definitions.find((d) => d.model === 'CC2530.ROUTER'),
|
||||
zhc.definitions.find((d) => d.model === 'BASICZBR3'),
|
||||
zhc.definitions.find((d) => d.model === 'ZM-CSW032-D'),
|
||||
zhc.definitions.find((d) => d.model === 'TS0001'),
|
||||
zhc.definitions.find((d) => d.model === 'TS0115'),
|
||||
];
|
||||
|
||||
const reportKey = 1;
|
||||
|
||||
const getColorCapabilities = async (endpoint: zh.Endpoint): Promise<{colorTemperature: boolean; colorXY: boolean}> => {
|
||||
if (endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') === undefined) {
|
||||
await endpoint.read('lightingColorCtrl', ['colorCapabilities']);
|
||||
}
|
||||
|
||||
const value = endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') as number;
|
||||
return {
|
||||
colorTemperature: (value & (1 << 4)) > 0,
|
||||
colorXY: (value & (1 << 3)) > 0,
|
||||
};
|
||||
};
|
||||
|
||||
const clusters: {
|
||||
[s: string]: {
|
||||
attribute: string;
|
||||
minimumReportInterval: number;
|
||||
maximumReportInterval: number;
|
||||
reportableChange: number;
|
||||
condition?: (endpoint: zh.Endpoint) => Promise<boolean>;
|
||||
}[];
|
||||
} = {
|
||||
genOnOff: [{attribute: 'onOff', ...defaultConfiguration, minimumReportInterval: 0, reportableChange: 0}],
|
||||
genLevelCtrl: [{attribute: 'currentLevel', ...defaultConfiguration}],
|
||||
lightingColorCtrl: [
|
||||
{
|
||||
attribute: 'colorTemperature',
|
||||
...defaultConfiguration,
|
||||
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorTemperature,
|
||||
},
|
||||
{
|
||||
attribute: 'currentX',
|
||||
...defaultConfiguration,
|
||||
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
|
||||
},
|
||||
{
|
||||
attribute: 'currentY',
|
||||
...defaultConfiguration,
|
||||
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
|
||||
},
|
||||
],
|
||||
closuresWindowCovering: [
|
||||
{attribute: 'currentPositionLiftPercentage', ...defaultConfiguration},
|
||||
{attribute: 'currentPositionTiltPercentage', ...defaultConfiguration},
|
||||
],
|
||||
};
|
||||
|
||||
export default class Report extends Extension {
|
||||
private queue: Set<string> = new Set();
|
||||
private failed: Set<string> = new Set();
|
||||
private enabled = settings.get().advanced.report;
|
||||
|
||||
shouldIgnoreClusterForDevice(cluster: string, definition?: zhc.Definition): boolean {
|
||||
if (definition === ZNLDP12LM && cluster === 'closuresWindowCovering') {
|
||||
// Device announces it but doesn't support it
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/2611
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async setupReporting(device: Device): Promise<void> {
|
||||
if (this.queue.has(device.ieeeAddr) || this.failed.has(device.ieeeAddr)) return;
|
||||
this.queue.add(device.ieeeAddr);
|
||||
|
||||
const term1 = this.enabled ? 'Setup' : 'Disable';
|
||||
const term2 = this.enabled ? 'setup' : 'disabled';
|
||||
|
||||
try {
|
||||
for (const ep of device.zh.endpoints) {
|
||||
for (const [cluster, configuration] of Object.entries(clusters)) {
|
||||
if (ep.supportsInputCluster(cluster) && !this.shouldIgnoreClusterForDevice(cluster, device.definition)) {
|
||||
logger.debug(`${term1} reporting for '${device.ieeeAddr}' - ${ep.ID} - ${cluster}`);
|
||||
|
||||
const items = [];
|
||||
for (const entry of configuration) {
|
||||
if (entry.condition == undefined || (await entry.condition(ep))) {
|
||||
const toAdd = {...entry};
|
||||
if (!this.enabled) toAdd.maximumReportInterval = 0xffff;
|
||||
items.push(toAdd);
|
||||
delete items[items.length - 1].condition;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.enabled) {
|
||||
await ep.bind(cluster, this.zigbee.firstCoordinatorEndpoint());
|
||||
} else {
|
||||
await ep.unbind(cluster, this.zigbee.firstCoordinatorEndpoint());
|
||||
}
|
||||
|
||||
await ep.configureReporting(cluster, items);
|
||||
logger.info(`Successfully ${term2} reporting for '${device.ieeeAddr}' - ${ep.ID} - ${cluster}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.enabled) {
|
||||
device.zh.meta.reporting = reportKey;
|
||||
} else {
|
||||
delete device.zh.meta.reporting;
|
||||
this.eventBus.emitReconfigure({device});
|
||||
}
|
||||
|
||||
this.eventBus.emitDevicesChanged();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ${term1.toLowerCase()} reporting for '${device.ieeeAddr}' - ${(error as Error).stack}`);
|
||||
|
||||
this.failed.add(device.ieeeAddr);
|
||||
}
|
||||
|
||||
device.zh.save();
|
||||
this.queue.delete(device.ieeeAddr);
|
||||
}
|
||||
|
||||
shouldSetupReporting(device: Device, messageType?: string): boolean {
|
||||
if (!device || !device.zh || !device.definition) return false;
|
||||
|
||||
// Handle messages of type endDeviceAnnce and devIncoming.
|
||||
// This message is typically send when a device comes online after being powered off
|
||||
// Ikea TRADFRI tend to forget their reporting after powered off.
|
||||
// Re-setup reporting.
|
||||
// Only resetup reporting if configuredReportings was not populated yet,
|
||||
// else reconfigure is done in zigbee-herdsman-converters ikea.js/bulbOnEvent
|
||||
// configuredReportings are saved since Zigbee2MQTT 1.17.0
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/966
|
||||
if (
|
||||
this.enabled &&
|
||||
messageType === 'deviceAnnounce' &&
|
||||
device.isIkeaTradfri() &&
|
||||
device.zh.endpoints.filter((e) => e.configuredReportings.length === 0).length === device.zh.endpoints.length
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// These do not support reporting.
|
||||
// https://github.com/Koenkk/zigbee-herdsman/issues/110
|
||||
if (
|
||||
device.zh.manufacturerName === 'Philips' &&
|
||||
/* istanbul ignore next */
|
||||
(device.zh.softwareBuildID === '5.127.1.26581' || device.zh.softwareBuildID === '5.130.1.30000')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (device.zh.interviewing === true) return false;
|
||||
if (device.zh.type !== 'Router' || device.zh.powerSource === 'Battery') return false;
|
||||
// Gledopto devices don't support reporting.
|
||||
if (devicesNotSupportingReporting.includes(device.definition) || device.definition.vendor === 'Gledopto') return false;
|
||||
|
||||
if (this.enabled && device.zh.meta.reporting !== undefined && device.zh.meta.reporting === reportKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.enabled && device.zh.meta.reporting === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
override async start(): Promise<void> {
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
if (this.shouldSetupReporting(device, undefined)) {
|
||||
await this.setupReporting(device);
|
||||
}
|
||||
}
|
||||
|
||||
this.eventBus.onDeviceAnnounce(this, (data) => this.onZigbeeEvent_('deviceAnnounce', data.device));
|
||||
this.eventBus.onDeviceMessage(this, (data) => this.onZigbeeEvent_('dummy', data.device));
|
||||
this.eventBus.onDeviceJoined(this, (data) => this.onZigbeeEvent_('dummy', data.device));
|
||||
this.eventBus.onDeviceNetworkAddressChanged(this, (data) => this.onZigbeeEvent_('dummy', data.device));
|
||||
}
|
||||
|
||||
async onZigbeeEvent_(type: string, device: Device): Promise<void> {
|
||||
if (this.shouldSetupReporting(device, type)) {
|
||||
await this.setupReporting(device);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
/* istanbul ignore file */
|
||||
|
||||
import logger from '../../util/logger';
|
||||
// DEPRECATED
|
||||
import * as settings from '../../util/settings';
|
||||
import utils from '../../util/utils';
|
||||
import Extension from '../extension';
|
||||
|
||||
/**
|
||||
* This extensions soft resets the ZNP after a certain timeout.
|
||||
*/
|
||||
export default class SoftReset extends Extension {
|
||||
private timer?: NodeJS.Timeout;
|
||||
private timeout = utils.seconds(settings.get().advanced.soft_reset_timeout);
|
||||
|
||||
override async start(): Promise<void> {
|
||||
logger.debug(`Soft reset timeout set to ${this.timeout / 1000} seconds`);
|
||||
this.resetTimer();
|
||||
this.eventBus.onDeviceMessage(this, () => this.resetTimer());
|
||||
this.eventBus.onDeviceAnnounce(this, () => this.resetTimer());
|
||||
this.eventBus.onDeviceNetworkAddressChanged(this, () => this.resetTimer());
|
||||
this.eventBus.onDeviceJoined(this, () => this.resetTimer());
|
||||
this.eventBus.onDeviceInterview(this, () => this.resetTimer());
|
||||
}
|
||||
|
||||
private clearTimer(): void {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
|
||||
private resetTimer(): void {
|
||||
if (this.timeout === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearTimer();
|
||||
this.timer = setTimeout(() => this.handleTimeout(), this.timeout);
|
||||
}
|
||||
|
||||
private async handleTimeout(): Promise<void> {
|
||||
logger.warning('Soft reset timeout triggered');
|
||||
|
||||
try {
|
||||
await this.zigbee.reset('soft');
|
||||
logger.warning('Soft reset ZNP due to timeout');
|
||||
} catch (error) {
|
||||
logger.warning(`Soft reset failed, trying stop/start (${(error as Error).message})`);
|
||||
|
||||
await this.zigbee.stop();
|
||||
logger.warning('Zigbee stopped');
|
||||
|
||||
try {
|
||||
await this.zigbee.start();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to restart! (${(error as Error).message})`);
|
||||
}
|
||||
}
|
||||
|
||||
this.resetTimer();
|
||||
}
|
||||
}
|
||||
+32
-58
@@ -1,3 +1,5 @@
|
||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTNetworkMap} from 'lib/types/api';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
@@ -6,87 +8,60 @@ import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import Extension from './extension';
|
||||
|
||||
interface Link {
|
||||
source: {ieeeAddr: string; networkAddress: number};
|
||||
target: {ieeeAddr: string; networkAddress: number};
|
||||
linkquality: number;
|
||||
depth: number;
|
||||
routes: zh.RoutingTableEntry[];
|
||||
sourceIeeeAddr: string;
|
||||
targetIeeeAddr: string;
|
||||
sourceNwkAddr: number;
|
||||
lqi: number;
|
||||
relationship: number;
|
||||
}
|
||||
|
||||
interface Topology {
|
||||
nodes: {
|
||||
ieeeAddr: string;
|
||||
friendlyName: string;
|
||||
type: string;
|
||||
networkAddress: number;
|
||||
manufacturerName: string | undefined;
|
||||
modelID: string | undefined;
|
||||
failed: string[];
|
||||
lastSeen: number | undefined;
|
||||
definition?: {model: string; vendor: string; supports: string; description: string};
|
||||
}[];
|
||||
links: Link[];
|
||||
}
|
||||
const SUPPORTED_FORMATS = ['raw', 'graphviz', 'plantuml'];
|
||||
|
||||
/**
|
||||
* This extension creates a network map
|
||||
*/
|
||||
export default class NetworkMap extends Extension {
|
||||
private legacyApi = settings.get().advanced.legacy_api;
|
||||
private legacyTopic = `${settings.get().mqtt.base_topic}/bridge/networkmap`;
|
||||
private legacyTopicRoutes = `${settings.get().mqtt.base_topic}/bridge/networkmap/routes`;
|
||||
private topic = `${settings.get().mqtt.base_topic}/bridge/request/networkmap`;
|
||||
private supportedFormats: {[s: string]: (topology: Topology) => KeyValue | string} = {
|
||||
raw: this.raw,
|
||||
graphviz: this.graphviz,
|
||||
plantuml: this.plantuml,
|
||||
};
|
||||
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
}
|
||||
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
/* istanbul ignore else */
|
||||
if (this.legacyApi) {
|
||||
if ((data.topic === this.legacyTopic || data.topic === this.legacyTopicRoutes) && this.supportedFormats[data.message] !== undefined) {
|
||||
const includeRoutes = data.topic === this.legacyTopicRoutes;
|
||||
const topology = await this.networkScan(includeRoutes);
|
||||
let converted = this.supportedFormats[data.message](topology);
|
||||
converted = data.message === 'raw' ? stringify(converted) : converted;
|
||||
await this.mqtt.publish(`bridge/networkmap/${data.message}`, converted as string, {});
|
||||
}
|
||||
}
|
||||
|
||||
if (data.topic === this.topic) {
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
const message = utils.parseJSON(data.message, data.message) as Zigbee2MQTTAPI['bridge/request/networkmap'];
|
||||
|
||||
try {
|
||||
const type = typeof message === 'object' ? message.type : message;
|
||||
if (this.supportedFormats[type] === undefined) {
|
||||
throw new Error(`Type '${type}' not supported, allowed are: ${Object.keys(this.supportedFormats)}`);
|
||||
|
||||
if (!SUPPORTED_FORMATS.includes(type)) {
|
||||
throw new Error(`Type '${type}' not supported, allowed are: ${SUPPORTED_FORMATS.join(',')}`);
|
||||
}
|
||||
|
||||
const routes = typeof message === 'object' && message.routes;
|
||||
const topology = await this.networkScan(routes);
|
||||
const value = this.supportedFormats[type](topology);
|
||||
await this.mqtt.publish('bridge/response/networkmap', stringify(utils.getResponse(message, {routes, type, value})));
|
||||
let responseData: Zigbee2MQTTAPI['bridge/response/networkmap'];
|
||||
|
||||
switch (type) {
|
||||
case 'raw': {
|
||||
responseData = {type, routes, value: this.raw(topology)};
|
||||
break;
|
||||
}
|
||||
case 'graphviz': {
|
||||
responseData = {type, routes, value: this.graphviz(topology)};
|
||||
break;
|
||||
}
|
||||
case 'plantuml': {
|
||||
responseData = {type, routes, value: this.plantuml(topology)};
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await this.mqtt.publish('bridge/response/networkmap', stringify(utils.getResponse(message, responseData)));
|
||||
} catch (error) {
|
||||
await this.mqtt.publish('bridge/response/networkmap', stringify(utils.getResponse(message, {}, (error as Error).message)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bind raw(topology: Topology): KeyValue {
|
||||
raw(topology: Zigbee2MQTTNetworkMap): Zigbee2MQTTNetworkMap {
|
||||
return topology;
|
||||
}
|
||||
|
||||
@bind graphviz(topology: Topology): string {
|
||||
graphviz(topology: Zigbee2MQTTNetworkMap): string {
|
||||
const colors = settings.get().map_options.graphviz.colors;
|
||||
|
||||
let text = 'digraph G {\nnode[shape=record];\n';
|
||||
@@ -152,7 +127,7 @@ export default class NetworkMap extends Extension {
|
||||
return text.replace(/\0/g, '');
|
||||
}
|
||||
|
||||
@bind plantuml(topology: Topology): string {
|
||||
plantuml(topology: Zigbee2MQTTNetworkMap): string {
|
||||
const text = [];
|
||||
|
||||
text.push(`' paste into: https://www.planttext.com/`);
|
||||
@@ -207,7 +182,7 @@ export default class NetworkMap extends Extension {
|
||||
return text.join(`\n`);
|
||||
}
|
||||
|
||||
async networkScan(includeRoutes: boolean): Promise<Topology> {
|
||||
async networkScan(includeRoutes: boolean): Promise<Zigbee2MQTTNetworkMap> {
|
||||
logger.info(`Starting network scan (includeRoutes '${includeRoutes}')`);
|
||||
const lqis: Map<Device, zh.LQI> = new Map();
|
||||
const routingTables: Map<Device, zh.RoutingTable> = new Map();
|
||||
@@ -257,7 +232,7 @@ export default class NetworkMap extends Extension {
|
||||
|
||||
logger.info(`Network scan finished`);
|
||||
|
||||
const topology: Topology = {nodes: [], links: []};
|
||||
const topology: Zigbee2MQTTNetworkMap = {nodes: [], links: []};
|
||||
|
||||
// XXX: display GP/disabled devices in the map, better feedback than just hiding them?
|
||||
for (const device of this.zigbee.devicesIterator((d) => d.type !== 'GreenPower')) {
|
||||
@@ -307,13 +282,12 @@ export default class NetworkMap extends Extension {
|
||||
if (neighbor.ieeeAddr === '0x0000000000000000') {
|
||||
const neighborDevice = this.zigbee.deviceByNetworkAddress(neighbor.networkAddress);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (neighborDevice) {
|
||||
neighbor.ieeeAddr = neighborDevice.ieeeAddr;
|
||||
}
|
||||
}
|
||||
|
||||
const link: Link = {
|
||||
const link: Zigbee2MQTTNetworkMap['links'][number] = {
|
||||
source: {ieeeAddr: neighbor.ieeeAddr, networkAddress: neighbor.networkAddress},
|
||||
target: {ieeeAddr: device.ieeeAddr, networkAddress: device.zh.networkAddress},
|
||||
linkquality: neighbor.linkquality,
|
||||
|
||||
@@ -9,7 +9,7 @@ import Extension from './extension';
|
||||
export default class OnEvent extends Extension {
|
||||
override async start(): Promise<void> {
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
await this.callOnEvent(device, 'start', {});
|
||||
this.callOnEvent(device, 'start', {}).catch(utils.noop);
|
||||
}
|
||||
|
||||
this.eventBus.onDeviceMessage(this, (data) => this.callOnEvent(data.device, 'message', this.convertData(data)));
|
||||
@@ -41,11 +41,13 @@ export default class OnEvent extends Extension {
|
||||
private async callOnEvent(device: Device, type: zhc.OnEventType, data: KeyValue): Promise<void> {
|
||||
if (device.options.disabled) return;
|
||||
const state = this.state.get(device);
|
||||
await zhc.onEvent(type, data, device.zh);
|
||||
const deviceExposesChanged = (): void => this.eventBus.emitExposesAndDevicesChanged(data.device);
|
||||
|
||||
await zhc.onEvent(type, data, device.zh, {deviceExposesChanged});
|
||||
|
||||
if (device.definition?.onEvent) {
|
||||
const options: KeyValue = device.options;
|
||||
await device.definition.onEvent(type, data, device.zh, options, state);
|
||||
await device.definition.onEvent(type, data, device.zh, options, state, {deviceExposesChanged});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+77
-136
@@ -1,12 +1,14 @@
|
||||
import assert from 'assert';
|
||||
import path from 'path';
|
||||
import type {Zigbee2MQTTAPI} from 'lib/types/api';
|
||||
import type {Ota} from 'zigbee-herdsman-converters';
|
||||
|
||||
import assert from 'node:assert';
|
||||
import path from 'node:path';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import * as URI from 'uri-js';
|
||||
|
||||
import {Zcl} from 'zigbee-herdsman';
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
import {ota} from 'zigbee-herdsman-converters';
|
||||
|
||||
import Device from '../model/device';
|
||||
import dataDir from '../util/data';
|
||||
@@ -15,21 +17,8 @@ import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import Extension from './extension';
|
||||
|
||||
function isValidUrl(url: string): boolean {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = URI.parse(url);
|
||||
} catch {
|
||||
// istanbul ignore next
|
||||
return false;
|
||||
}
|
||||
return parsed.scheme === 'http' || parsed.scheme === 'https';
|
||||
}
|
||||
|
||||
type UpdateState = 'updating' | 'idle' | 'available';
|
||||
interface UpdatePayload {
|
||||
update_available?: boolean;
|
||||
|
||||
update: {
|
||||
progress?: number;
|
||||
remaining?: number;
|
||||
@@ -39,34 +28,33 @@ interface UpdatePayload {
|
||||
};
|
||||
}
|
||||
|
||||
const legacyTopicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/ota_update/.+$`);
|
||||
const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/(update|check)`, 'i');
|
||||
const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/(update|check)/?(downgrade)?`, 'i');
|
||||
|
||||
export default class OTAUpdate extends Extension {
|
||||
private inProgress = new Set();
|
||||
private lastChecked: {[s: string]: number} = {};
|
||||
private legacyApi = settings.get().advanced.legacy_api;
|
||||
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
this.eventBus.onDeviceMessage(this, this.onZigbeeEvent);
|
||||
if (settings.get().ota.ikea_ota_use_test_url) {
|
||||
zhc.ota.tradfri.useTestURL();
|
||||
}
|
||||
|
||||
// Let zigbeeOTA module know if the override index file is provided
|
||||
let overrideOTAIndex = settings.get().ota.zigbee_ota_override_index_location;
|
||||
if (overrideOTAIndex) {
|
||||
// If the file name is not a full path, then treat it as a relative to the data directory
|
||||
if (!isValidUrl(overrideOTAIndex) && !path.isAbsolute(overrideOTAIndex)) {
|
||||
overrideOTAIndex = dataDir.joinPath(overrideOTAIndex);
|
||||
}
|
||||
const otaSettings = settings.get().ota;
|
||||
// Let OTA module know if the override index file is provided
|
||||
let overrideIndexLocation = otaSettings.zigbee_ota_override_index_location;
|
||||
|
||||
zhc.ota.zigbeeOTA.useIndexOverride(overrideOTAIndex);
|
||||
// If the file name is not a full path, then treat it as a relative to the data directory
|
||||
if (overrideIndexLocation && !ota.isValidUrl(overrideIndexLocation) && !path.isAbsolute(overrideIndexLocation)) {
|
||||
overrideIndexLocation = dataDir.joinPath(overrideIndexLocation);
|
||||
}
|
||||
|
||||
// In order to support local firmware files we need to let zigbeeOTA know where the data directory is
|
||||
zhc.ota.setDataDir(dataDir.getPath());
|
||||
ota.setConfiguration({
|
||||
dataDir: dataDir.getPath(),
|
||||
overrideIndexLocation,
|
||||
// TODO: implement me
|
||||
imageBlockResponseDelay: otaSettings.image_block_response_delay,
|
||||
defaultMaximumDataSize: otaSettings.default_maximum_data_size,
|
||||
});
|
||||
|
||||
// In case Zigbee2MQTT is restared during an update, progress and remaining values are still in state, remove them.
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
@@ -80,8 +68,12 @@ export default class OTAUpdate extends Extension {
|
||||
}
|
||||
|
||||
private removeProgressAndRemainingFromState(device: Device): void {
|
||||
delete this.state.get(device).update?.progress;
|
||||
delete this.state.get(device).update?.remaining;
|
||||
const deviceState = this.state.get(device);
|
||||
|
||||
if (deviceState.update) {
|
||||
delete deviceState.update.progress;
|
||||
delete deviceState.update.remaining;
|
||||
}
|
||||
}
|
||||
|
||||
@bind private async onZigbeeEvent(data: eventdata.DeviceMessage): Promise<void> {
|
||||
@@ -102,26 +94,20 @@ export default class OTAUpdate extends Extension {
|
||||
if (!check) return;
|
||||
|
||||
this.lastChecked[data.device.ieeeAddr] = Date.now();
|
||||
let availableResult: zhc.OtaUpdateAvailableResult | undefined;
|
||||
let availableResult: Ota.UpdateAvailableResult | undefined;
|
||||
|
||||
try {
|
||||
availableResult = await data.device.definition.ota.isUpdateAvailable(data.device.zh, data.data as zhc.ota.ImageInfo);
|
||||
// never use 'previous' when responding to device request
|
||||
availableResult = await ota.isUpdateAvailable(data.device.zh, data.device.otaExtraMetas, data.data as Ota.ImageInfo, false);
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to check if update available for '${data.device.name}' (${error})`);
|
||||
}
|
||||
|
||||
const payload = this.getEntityPublishPayload(data.device, availableResult ?? 'idle');
|
||||
await this.publishEntityState(data.device, payload);
|
||||
await this.publishEntityState(data.device, this.getEntityPublishPayload(data.device, availableResult ?? 'idle'));
|
||||
|
||||
if (availableResult?.available) {
|
||||
const message = `Update available for '${data.device.name}'`;
|
||||
logger.info(message);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: 'available', device: data.device.name};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message, meta}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +139,7 @@ export default class OTAUpdate extends Extension {
|
||||
|
||||
private getEntityPublishPayload(
|
||||
device: Device,
|
||||
state: zhc.OtaUpdateAvailableResult | UpdateState,
|
||||
state: Ota.UpdateAvailableResult | UpdateState,
|
||||
progress?: number,
|
||||
remaining?: number,
|
||||
): UpdatePayload {
|
||||
@@ -166,32 +152,33 @@ export default class OTAUpdate extends Extension {
|
||||
},
|
||||
};
|
||||
|
||||
if (progress != undefined) {
|
||||
if (progress !== undefined) {
|
||||
payload.update.progress = progress;
|
||||
}
|
||||
|
||||
if (remaining != undefined) {
|
||||
if (remaining !== undefined) {
|
||||
payload.update.remaining = Math.round(remaining);
|
||||
}
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (this.legacyApi) {
|
||||
payload.update_available = typeof state === 'string' ? state === 'available' : state.available;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
if ((!this.legacyApi || !data.topic.match(legacyTopicRegex)) && !data.topic.match(topicRegex)) {
|
||||
const topicMatch = data.topic.match(topicRegex);
|
||||
|
||||
if (!topicMatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
const message = utils.parseJSON(data.message, data.message) as
|
||||
| Zigbee2MQTTAPI['bridge/request/device/ota_update/check']
|
||||
| Zigbee2MQTTAPI['bridge/request/device/ota_update/check/downgrade']
|
||||
| Zigbee2MQTTAPI['bridge/request/device/ota_update/update']
|
||||
| Zigbee2MQTTAPI['bridge/request/device/ota_update/update/downgrade'];
|
||||
const ID = (typeof message === 'object' && message['id'] !== undefined ? message.id : message) as string;
|
||||
const device = this.zigbee.resolveEntity(ID);
|
||||
const type = data.topic.substring(data.topic.lastIndexOf('/') + 1);
|
||||
const responseData: {id: string; updateAvailable?: boolean; from?: KeyValue | null; to?: KeyValue | null} = {id: ID};
|
||||
const type = topicMatch[1];
|
||||
const downgrade = Boolean(topicMatch[2]);
|
||||
let error: string | undefined;
|
||||
let errorStack: string | undefined;
|
||||
|
||||
@@ -199,12 +186,6 @@ export default class OTAUpdate extends Extension {
|
||||
error = `Device '${ID}' does not exist`;
|
||||
} else if (!device.definition || !device.definition.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `not_supported`, device: device.name};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: error, meta}));
|
||||
}
|
||||
} else if (this.inProgress.has(device.ieeeAddr)) {
|
||||
error = `Update or check for update already in progress for '${device.name}'`;
|
||||
} else {
|
||||
@@ -214,85 +195,54 @@ export default class OTAUpdate extends Extension {
|
||||
const msg = `Checking if update available for '${device.name}'`;
|
||||
logger.info(msg);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `checking_if_available`, device: device.name};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: msg, meta}));
|
||||
}
|
||||
|
||||
try {
|
||||
const availableResult = await device.definition.ota.isUpdateAvailable(device.zh, undefined);
|
||||
const availableResult = await ota.isUpdateAvailable(device.zh, device.otaExtraMetas, undefined, downgrade);
|
||||
const msg = `${availableResult.available ? 'Update' : 'No update'} available for '${device.name}'`;
|
||||
logger.info(msg);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {
|
||||
status: availableResult.available ? 'available' : 'not_available',
|
||||
device: device.name,
|
||||
};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: msg, meta}));
|
||||
}
|
||||
await this.publishEntityState(device, this.getEntityPublishPayload(device, availableResult));
|
||||
|
||||
const payload = this.getEntityPublishPayload(device, availableResult);
|
||||
await this.publishEntityState(device, payload);
|
||||
this.lastChecked[device.ieeeAddr] = Date.now();
|
||||
responseData.updateAvailable = availableResult.available;
|
||||
const response = utils.getResponse<'bridge/response/device/ota_update/check'>(message, {
|
||||
id: ID,
|
||||
update_available: availableResult.available,
|
||||
});
|
||||
|
||||
await this.mqtt.publish(`bridge/response/device/ota_update/check`, stringify(response));
|
||||
} catch (e) {
|
||||
error = `Failed to check if update available for '${device.name}' (${(e as Error).message})`;
|
||||
errorStack = (e as Error).stack;
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `check_failed`, device: device.name};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: error, meta}));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// type === 'update'
|
||||
const msg = `Updating '${device.name}' to latest firmware`;
|
||||
const msg = `Updating '${device.name}' to ${downgrade ? 'previous' : 'latest'} firmware`;
|
||||
logger.info(msg);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `update_in_progress`, device: device.name};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: msg, meta}));
|
||||
}
|
||||
|
||||
try {
|
||||
const onProgress = async (progress: number, remaining: number): Promise<void> => {
|
||||
const firmwareFrom = await this.readSoftwareBuildIDAndDateCode(device, 'immediate');
|
||||
const fileVersion = await ota.update(device.zh, device.otaExtraMetas, downgrade, async (progress, remaining) => {
|
||||
let msg = `Update of '${device.name}' at ${progress.toFixed(2)}%`;
|
||||
|
||||
if (remaining) {
|
||||
msg += `, ≈ ${Math.round(remaining / 60)} minutes remaining`;
|
||||
}
|
||||
|
||||
logger.info(msg);
|
||||
|
||||
const payload = this.getEntityPublishPayload(device, 'updating', progress, remaining);
|
||||
await this.publishEntityState(device, payload);
|
||||
await this.publishEntityState(device, this.getEntityPublishPayload(device, 'updating', progress, remaining ?? undefined));
|
||||
});
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `update_progress`, device: device.name, progress};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: msg, meta}));
|
||||
}
|
||||
};
|
||||
|
||||
const from_ = await this.readSoftwareBuildIDAndDateCode(device, 'immediate');
|
||||
const fileVersion = await device.definition.ota.updateToLatest(device.zh, onProgress);
|
||||
logger.info(`Finished update of '${device.name}'`);
|
||||
this.removeProgressAndRemainingFromState(device);
|
||||
const payload = this.getEntityPublishPayload(device, {
|
||||
available: false,
|
||||
currentFileVersion: fileVersion,
|
||||
otaFileVersion: fileVersion,
|
||||
});
|
||||
await this.publishEntityState(device, payload);
|
||||
const to = await this.readSoftwareBuildIDAndDateCode(device);
|
||||
const [fromS, toS] = [stringify(from_), stringify(to)];
|
||||
logger.info(`Device '${device.name}' was updated from '${fromS}' to '${toS}'`);
|
||||
responseData.from = from_ ? utils.toSnakeCaseObject(from_) : null;
|
||||
responseData.to = to ? utils.toSnakeCaseObject(to) : null;
|
||||
await this.publishEntityState(
|
||||
device,
|
||||
this.getEntityPublishPayload(device, {available: false, currentFileVersion: fileVersion, otaFileVersion: fileVersion}),
|
||||
);
|
||||
|
||||
const firmwareTo = await this.readSoftwareBuildIDAndDateCode(device);
|
||||
|
||||
logger.info(() => `Device '${device.name}' was updated from '${stringify(firmwareFrom)}' to '${stringify(firmwareTo)}'`);
|
||||
|
||||
/**
|
||||
* Re-configure after reading software build ID and date code, some devices use a
|
||||
* custom attribute for this (e.g. Develco SMSZB-120)
|
||||
@@ -300,39 +250,30 @@ export default class OTAUpdate extends Extension {
|
||||
this.eventBus.emitReconfigure({device});
|
||||
this.eventBus.emitDevicesChanged();
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `update_succeeded`, device: device.name, from: from_, to};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message, meta}));
|
||||
}
|
||||
const response = utils.getResponse<'bridge/response/device/ota_update/update'>(message, {
|
||||
id: ID,
|
||||
from: firmwareFrom ? {software_build_id: firmwareFrom.softwareBuildID, date_code: firmwareFrom.dateCode} : undefined,
|
||||
to: firmwareTo ? {software_build_id: firmwareTo.softwareBuildID, date_code: firmwareTo.dateCode} : undefined,
|
||||
});
|
||||
|
||||
await this.mqtt.publish(`bridge/response/device/ota_update/update`, stringify(response));
|
||||
} catch (e) {
|
||||
logger.debug(`Update of '${device.name}' failed (${e})`);
|
||||
error = `Update of '${device.name}' failed (${(e as Error).message})`;
|
||||
errorStack = (e as Error).stack;
|
||||
|
||||
this.removeProgressAndRemainingFromState(device);
|
||||
const payload = this.getEntityPublishPayload(device, 'available');
|
||||
await this.publishEntityState(device, payload);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const meta = {status: `update_failed`, device: device.name};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: error, meta}));
|
||||
}
|
||||
await this.publishEntityState(device, this.getEntityPublishPayload(device, 'available'));
|
||||
}
|
||||
}
|
||||
|
||||
this.inProgress.delete(device.ieeeAddr);
|
||||
}
|
||||
|
||||
const triggeredViaLegacyApi = data.topic.match(legacyTopicRegex);
|
||||
|
||||
if (!triggeredViaLegacyApi) {
|
||||
const response = utils.getResponse(message, responseData, error);
|
||||
await this.mqtt.publish(`bridge/response/device/ota_update/${type}`, stringify(response));
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const response = utils.getResponse(message, {}, error);
|
||||
|
||||
await this.mqtt.publish(`bridge/response/device/ota_update/${type}`, stringify(response));
|
||||
logger.error(error);
|
||||
|
||||
if (errorStack) {
|
||||
|
||||
+18
-67
@@ -1,11 +1,8 @@
|
||||
import assert from 'assert';
|
||||
import type * as zhc from 'zigbee-herdsman-converters';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
import * as philips from 'zigbee-herdsman-converters/lib/philips';
|
||||
|
||||
import Device from '../model/device';
|
||||
import Group from '../model/group';
|
||||
import logger from '../util/logger';
|
||||
@@ -23,23 +20,6 @@ loadTopicGetSetRegex();
|
||||
const STATE_VALUES: ReadonlyArray<string> = ['on', 'off', 'toggle', 'open', 'close', 'stop', 'lock', 'unlock'];
|
||||
const SCENE_CONVERTER_KEYS: ReadonlyArray<string> = ['scene_store', 'scene_add', 'scene_remove', 'scene_remove_all', 'scene_rename'];
|
||||
|
||||
// Legacy: don't provide default converters anymore, this is required by older z2m installs not saving group members
|
||||
const DEFAULT_GROUP_CONVERTERS: ReadonlyArray<zhc.Tz.Converter> = [
|
||||
zhc.toZigbee.light_onoff_brightness,
|
||||
zhc.toZigbee.light_color_colortemp,
|
||||
philips.tz.effect, // Support Hue effects for groups
|
||||
zhc.toZigbee.ignore_transition,
|
||||
zhc.toZigbee.cover_position_tilt,
|
||||
zhc.toZigbee.thermostat_occupied_heating_setpoint,
|
||||
zhc.toZigbee.tint_scene,
|
||||
zhc.toZigbee.light_brightness_move,
|
||||
zhc.toZigbee.light_brightness_step,
|
||||
zhc.toZigbee.light_colortemp_step,
|
||||
zhc.toZigbee.light_colortemp_move,
|
||||
zhc.toZigbee.light_hue_saturation_move,
|
||||
zhc.toZigbee.light_hue_saturation_step,
|
||||
];
|
||||
|
||||
interface ParsedTopic {
|
||||
ID: string;
|
||||
endpoint: string | undefined;
|
||||
@@ -48,7 +28,7 @@ interface ParsedTopic {
|
||||
}
|
||||
|
||||
export default class Publish extends Extension {
|
||||
async start(): Promise<void> {
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
}
|
||||
|
||||
@@ -96,43 +76,13 @@ export default class Publish extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
async legacyLog(payload: KeyValue): Promise<void> {
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
await this.mqtt.publish('bridge/log', stringify(payload));
|
||||
}
|
||||
}
|
||||
|
||||
legacyRetrieveState(
|
||||
re: Device | Group,
|
||||
converter: zhc.Tz.Converter,
|
||||
result: zhc.Tz.ConvertSetResult,
|
||||
target: zh.Endpoint | zh.Group,
|
||||
key: string,
|
||||
meta: zhc.Tz.Meta,
|
||||
): void {
|
||||
// It's possible for devices to get out of sync when writing an attribute that's not reportable.
|
||||
// So here we re-read the value after a specified timeout, this timeout could for example be the
|
||||
// transition time of a color change or for forcing a state read for devices that don't
|
||||
// automatically report a new state when set.
|
||||
// When reporting is requested for a device (report: true in device-specific settings) we won't
|
||||
// ever issue a read here, as we assume the device will properly report changes.
|
||||
// Only do this when the retrieve_state option is enabled for this device.
|
||||
// retrieve_state == deprecated
|
||||
if (re instanceof Device && result && result.readAfterWriteTime !== undefined && re.options.retrieve_state) {
|
||||
const convertGet = converter.convertGet;
|
||||
assert(convertGet !== undefined, 'Converter has `readAfterWriteTime` but no `convertGet`');
|
||||
setTimeout(() => convertGet(target, key, meta), result.readAfterWriteTime);
|
||||
}
|
||||
}
|
||||
|
||||
updateMessageHomeAssistant(message: KeyValue, entityState: KeyValue): void {
|
||||
/**
|
||||
* Home Assistant always publishes 'state', even when e.g. only setting
|
||||
* the color temperature. This would lead to 2 zigbee publishes, where the first one
|
||||
* (state) is probably unnecessary.
|
||||
*/
|
||||
if (settings.get().homeassistant) {
|
||||
if (settings.get().homeassistant.enabled) {
|
||||
const hasColorTemp = message.color_temp !== undefined;
|
||||
const hasColor = message.color !== undefined;
|
||||
const hasBrightness = message.brightness !== undefined;
|
||||
@@ -154,7 +104,6 @@ export default class Publish extends Extension {
|
||||
const re = this.zigbee.resolveEntity(parsedTopic.ID);
|
||||
|
||||
if (!re) {
|
||||
await this.legacyLog({type: `entity_not_found`, message: {friendly_name: parsedTopic.ID}});
|
||||
logger.error(`Entity '${parsedTopic.ID}' is unknown`);
|
||||
return;
|
||||
}
|
||||
@@ -194,14 +143,7 @@ export default class Publish extends Extension {
|
||||
re.zh.members.map((e) => [e.getDevice().ieeeAddr, this.state.get(this.zigbee.resolveEntity(e.getDevice().ieeeAddr)!)]),
|
||||
)
|
||||
: undefined;
|
||||
let converters: ReadonlyArray<zhc.Tz.Converter>;
|
||||
|
||||
if (Array.isArray(definition)) {
|
||||
const c = new Set(definition.map((d) => d.toZigbee).flat());
|
||||
converters = c.size === 0 ? DEFAULT_GROUP_CONVERTERS : Array.from(c);
|
||||
} else {
|
||||
converters = definition?.toZigbee;
|
||||
}
|
||||
const converters = this.getDefinitionConverters(definition);
|
||||
|
||||
this.updateMessageHomeAssistant(message, entityState);
|
||||
|
||||
@@ -257,9 +199,11 @@ export default class Publish extends Extension {
|
||||
}
|
||||
|
||||
if (usedConverters[endpointOrGroupID] === undefined) usedConverters[endpointOrGroupID] = [];
|
||||
/* istanbul ignore next */
|
||||
// Match any key if the toZigbee converter defines no key.
|
||||
const converter = converters.find((c) => (!c.key || c.key.includes(key)) && (!c.endpoint || c.endpoint == endpointName));
|
||||
const converter = converters.find(
|
||||
(c) =>
|
||||
(!c.key || c.key.includes(key)) && (re instanceof Group || !c.endpoints || (endpointName && c.endpoints.includes(endpointName))),
|
||||
);
|
||||
|
||||
if (parsedTopic.type === 'set' && converter && usedConverters[endpointOrGroupID].includes(converter)) {
|
||||
// Use a converter for set only once
|
||||
@@ -287,6 +231,8 @@ export default class Publish extends Extension {
|
||||
state: entityState,
|
||||
membersState,
|
||||
mapped: definition,
|
||||
/* v8 ignore next */
|
||||
publish: (payload: KeyValue) => this.publishEntityState(re, payload),
|
||||
};
|
||||
|
||||
// Strip endpoint name from meta.message properties.
|
||||
@@ -327,8 +273,6 @@ export default class Publish extends Extension {
|
||||
addToToPublish(this.zigbee.resolveEntity(ieeeAddr)!, state);
|
||||
}
|
||||
}
|
||||
|
||||
this.legacyRetrieveState(re, converter, result, localTarget, key, meta);
|
||||
} else if (parsedTopic.type === 'get' && converter.convertGet) {
|
||||
logger.debug(`Publishing get '${parsedTopic.type}' '${key}' to '${re.name}'`);
|
||||
await converter.convertGet(localTarget, key, meta);
|
||||
@@ -340,7 +284,6 @@ export default class Publish extends Extension {
|
||||
const message = `Publish '${parsedTopic.type}' '${key}' to '${re.name}' failed: '${error}'`;
|
||||
logger.error(message);
|
||||
logger.debug((error as Error).stack!);
|
||||
await this.legacyLog({type: `zigbee_publish_error`, message, meta: {friendly_name: re.name}});
|
||||
}
|
||||
|
||||
usedConverters[endpointOrGroupID].push(converter);
|
||||
@@ -360,4 +303,12 @@ export default class Publish extends Extension {
|
||||
this.eventBus.emitScenesChanged({entity: re});
|
||||
}
|
||||
}
|
||||
|
||||
private getDefinitionConverters(definition: zhc.Definition | zhc.Definition[]): ReadonlyArray<zhc.Tz.Converter> {
|
||||
if (Array.isArray(definition)) {
|
||||
return definition.length ? Array.from(new Set(definition.map((d) => d.toZigbee).flat())) : [];
|
||||
} else {
|
||||
return definition?.toZigbee;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import assert from 'assert';
|
||||
import assert from 'node:assert';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import debounce from 'debounce';
|
||||
@@ -19,7 +19,7 @@ export default class Receive extends Extension {
|
||||
private debouncers: {[s: string]: {payload: KeyValue; publish: DebounceFunction}} = {};
|
||||
private throttlers: {[s: string]: {publish: PublishEntityState}} = {};
|
||||
|
||||
async start(): Promise<void> {
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onPublishEntityState(this, this.onPublishEntityState);
|
||||
this.eventBus.onDeviceMessage(this, this.onDeviceMessage);
|
||||
}
|
||||
@@ -102,7 +102,7 @@ export default class Receive extends Extension {
|
||||
}
|
||||
|
||||
@bind async onDeviceMessage(data: eventdata.DeviceMessage): Promise<void> {
|
||||
/* istanbul ignore next */
|
||||
/* v8 ignore next */
|
||||
if (!data.device) return;
|
||||
|
||||
if (!data.device.definition || data.device.zh.interviewing) {
|
||||
@@ -156,12 +156,12 @@ export default class Receive extends Extension {
|
||||
}
|
||||
};
|
||||
|
||||
const deviceExposesChanged = (): void => {
|
||||
this.eventBus.emitDevicesChanged();
|
||||
this.eventBus.emitExposesChanged({device: data.device});
|
||||
const meta = {
|
||||
device: data.device.zh,
|
||||
logger,
|
||||
state: this.state.get(data.device),
|
||||
deviceExposesChanged: (): void => this.eventBus.emitExposesAndDevicesChanged(data.device),
|
||||
};
|
||||
|
||||
const meta = {device: data.device.zh, logger, state: this.state.get(data.device), deviceExposesChanged: deviceExposesChanged};
|
||||
let payload: KeyValue = {};
|
||||
for (const converter of converters) {
|
||||
try {
|
||||
@@ -171,10 +171,12 @@ export default class Receive extends Extension {
|
||||
if (converted) {
|
||||
payload = {...payload, ...converted};
|
||||
}
|
||||
} catch (error) /* istanbul ignore next */ {
|
||||
/* v8 ignore start */
|
||||
} catch (error) {
|
||||
logger.error(`Exception while calling fromZigbee converter: ${(error as Error).message}}`);
|
||||
logger.debug((error as Error).stack!);
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
|
||||
if (!utils.objectIsEmpty(payload)) {
|
||||
|
||||
+20
-12
@@ -1,10 +1,18 @@
|
||||
import assert from 'assert';
|
||||
import assert from 'node:assert';
|
||||
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
import {access, Numeric} from 'zigbee-herdsman-converters';
|
||||
import {CustomClusters} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype';
|
||||
|
||||
import * as settings from '../util/settings';
|
||||
|
||||
const LINKQUALITY = new Numeric('linkquality', access.STATE)
|
||||
.withUnit('lqi')
|
||||
.withDescription('Link quality (signal strength)')
|
||||
.withValueMin(0)
|
||||
.withValueMax(255)
|
||||
.withCategory('diagnostic');
|
||||
|
||||
export default class Device {
|
||||
public zh: zh.Device;
|
||||
public definition?: zhc.Definition;
|
||||
@@ -29,23 +37,28 @@ export default class Device {
|
||||
get customClusters(): CustomClusters {
|
||||
return this.zh.customClusters;
|
||||
}
|
||||
get otaExtraMetas(): zhc.Ota.ExtraMetas {
|
||||
return typeof this.definition?.ota === 'object' ? this.definition.ota : {};
|
||||
}
|
||||
|
||||
constructor(device: zh.Device) {
|
||||
this.zh = device;
|
||||
}
|
||||
|
||||
exposes(): zhc.Expose[] {
|
||||
const exposes: zhc.Expose[] = [];
|
||||
assert(this.definition, 'Cannot retreive exposes before definition is resolved');
|
||||
/* istanbul ignore if */
|
||||
if (typeof this.definition.exposes == 'function') {
|
||||
const options: KeyValue = this.options;
|
||||
return this.definition.exposes(this.zh, options);
|
||||
exposes.push(...this.definition.exposes(this.zh, options));
|
||||
} else {
|
||||
return this.definition.exposes;
|
||||
exposes.push(...this.definition.exposes);
|
||||
}
|
||||
exposes.push(LINKQUALITY);
|
||||
return exposes;
|
||||
}
|
||||
|
||||
async resolveDefinition(ignoreCache = false): Promise<void> {
|
||||
async resolveDefinition(ignoreCache: boolean = false): Promise<void> {
|
||||
if (!this.zh.interviewing && (!this.definition || this._definitionModelID !== this.zh.modelID || ignoreCache)) {
|
||||
this.definition = await zhc.findByDevice(this.zh, true);
|
||||
this._definitionModelID = this.zh.modelID;
|
||||
@@ -78,7 +91,6 @@ export default class Device {
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
/* istanbul ignore next */
|
||||
if (key !== 'default') {
|
||||
return undefined;
|
||||
}
|
||||
@@ -101,7 +113,7 @@ export default class Device {
|
||||
}
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
/* v8 ignore next */
|
||||
return epName === 'default' ? undefined : epName;
|
||||
}
|
||||
|
||||
@@ -117,14 +129,10 @@ export default class Device {
|
||||
return names;
|
||||
}
|
||||
|
||||
isIkeaTradfri(): boolean {
|
||||
return this.zh.manufacturerID === 4476;
|
||||
}
|
||||
|
||||
isDevice(): this is Device {
|
||||
return true;
|
||||
}
|
||||
/* istanbul ignore next */
|
||||
|
||||
isGroup(): this is Group {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ export default class Group {
|
||||
const definitions: zhc.Definition[] = [];
|
||||
|
||||
for (const member of this.membersDevices()) {
|
||||
/* istanbul ignore else */
|
||||
if (member.definition) {
|
||||
definitions.push(member.definition);
|
||||
}
|
||||
|
||||
+74
-63
@@ -1,9 +1,11 @@
|
||||
import type {QoS} from 'mqtt-packet';
|
||||
import type {IClientOptions, IClientPublishOptions, MqttClient} from 'mqtt';
|
||||
|
||||
import fs from 'fs';
|
||||
import type {Zigbee2MQTTAPI} from './types/api';
|
||||
|
||||
import fs from 'node:fs';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import * as mqtt from 'mqtt';
|
||||
import {connectAsync} from 'mqtt';
|
||||
|
||||
import logger from './util/logger';
|
||||
import * as settings from './util/settings';
|
||||
@@ -14,13 +16,11 @@ const NS = 'z2m:mqtt';
|
||||
export default class MQTT {
|
||||
private publishedTopics: Set<string> = new Set();
|
||||
private connectionTimer?: NodeJS.Timeout;
|
||||
// @ts-expect-error initialized in `connect`
|
||||
private client: mqtt.MqttClient;
|
||||
private client!: MqttClient;
|
||||
private eventBus: EventBus;
|
||||
private initialConnect = true;
|
||||
private republishRetainedTimer?: NodeJS.Timeout;
|
||||
public retainedMessages: {
|
||||
[s: string]: {payload: string; options: MQTTOptions; skipLog: boolean; skipReceive: boolean; topic: string; base: string};
|
||||
[s: string]: {payload: string; options: IClientPublishOptions; skipLog: boolean; skipReceive: boolean; topic: string; base: string};
|
||||
} = {};
|
||||
|
||||
constructor(eventBus: EventBus) {
|
||||
@@ -32,13 +32,14 @@ export default class MQTT {
|
||||
|
||||
logger.info(`Connecting to MQTT server at ${mqttSettings.server}`);
|
||||
|
||||
const options: mqtt.IClientOptions = {
|
||||
const options: IClientOptions = {
|
||||
will: {
|
||||
topic: `${settings.get().mqtt.base_topic}/bridge/state`,
|
||||
payload: Buffer.from(utils.availabilityPayload('offline', settings.get())),
|
||||
payload: Buffer.from(JSON.stringify({state: 'offline'})),
|
||||
retain: settings.get().mqtt.force_disable_retain ? false : true,
|
||||
qos: 1,
|
||||
},
|
||||
properties: {maximumPacketSize: mqttSettings.maximum_packet_size},
|
||||
};
|
||||
|
||||
if (mqttSettings.version) {
|
||||
@@ -66,6 +67,9 @@ export default class MQTT {
|
||||
logger.debug(`Using MQTT login with username: ${mqttSettings.user}`);
|
||||
options.username = mqttSettings.user;
|
||||
options.password = mqttSettings.password;
|
||||
} else if (mqttSettings.user) {
|
||||
logger.debug(`Using MQTT login with username only: ${mqttSettings.user}`);
|
||||
options.username = mqttSettings.user;
|
||||
} else {
|
||||
logger.debug(`Using MQTT anonymous login`);
|
||||
}
|
||||
@@ -80,67 +84,70 @@ export default class MQTT {
|
||||
options.rejectUnauthorized = false;
|
||||
}
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
this.client = mqtt.connect(mqttSettings.server, options);
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/9822
|
||||
this.client.stream.setMaxListeners(0);
|
||||
this.eventBus.onPublishAvailability(this, this.publishStateOnline);
|
||||
this.client = await connectAsync(mqttSettings.server, options);
|
||||
|
||||
this.client.on('connect', async () => {
|
||||
// Set timer at interval to check if connected to MQTT server.
|
||||
clearTimeout(this.connectionTimer);
|
||||
this.connectionTimer = setInterval(() => {
|
||||
if (this.client.reconnecting) {
|
||||
logger.error('Not connected to MQTT server!');
|
||||
}
|
||||
}, utils.seconds(10));
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/9822
|
||||
this.client.stream.setMaxListeners(0);
|
||||
|
||||
logger.info('Connected to MQTT server');
|
||||
await this.publishStateOnline();
|
||||
|
||||
if (!this.initialConnect) {
|
||||
this.republishRetainedTimer = setTimeout(async () => {
|
||||
// Republish retained messages in case MQTT broker does not persist them.
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/9629
|
||||
for (const msg of Object.values(this.retainedMessages)) {
|
||||
await this.publish(msg.topic, msg.payload, msg.options, msg.base, msg.skipLog, msg.skipReceive);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
this.initialConnect = false;
|
||||
this.subscribe(`${settings.get().mqtt.base_topic}/#`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.client.on('error', (err) => {
|
||||
logger.error(`MQTT error: ${err.message}`);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
this.client.on('message', this.onMessage);
|
||||
this.client.on('error', (err) => {
|
||||
logger.error(`MQTT error: ${err.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
@bind async publishStateOnline(): Promise<void> {
|
||||
await this.publish('bridge/state', utils.availabilityPayload('online', settings.get()), {retain: true, qos: 0});
|
||||
if (mqttSettings.version != undefined && mqttSettings.version >= 5) {
|
||||
this.client.on('disconnect', (packet) => {
|
||||
logger.error(`MQTT disconnect: reason ${packet.reasonCode} (${packet.properties?.reasonString})`);
|
||||
});
|
||||
}
|
||||
|
||||
this.client.on('message', this.onMessage);
|
||||
|
||||
await this.onConnect();
|
||||
|
||||
this.client.on('connect', this.onConnect);
|
||||
|
||||
this.republishRetainedTimer = setTimeout(async () => {
|
||||
// Republish retained messages in case MQTT broker does not persist them.
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/9629
|
||||
for (const msg of Object.values(this.retainedMessages)) {
|
||||
await this.publish(msg.topic, msg.payload, msg.options, msg.base, msg.skipLog, msg.skipReceive);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
// Set timer at interval to check if connected to MQTT server.
|
||||
this.connectionTimer = setInterval(() => {
|
||||
if (!this.isConnected()) {
|
||||
logger.error('Not connected to MQTT server!');
|
||||
}
|
||||
}, utils.seconds(10));
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
clearTimeout(this.connectionTimer);
|
||||
clearTimeout(this.republishRetainedTimer);
|
||||
await this.publish('bridge/state', utils.availabilityPayload('offline', settings.get()), {retain: true, qos: 0});
|
||||
|
||||
const stateData: Zigbee2MQTTAPI['bridge/state'] = {state: 'offline'};
|
||||
|
||||
await this.publish('bridge/state', JSON.stringify(stateData), {retain: true, qos: 0});
|
||||
this.eventBus.removeListeners(this);
|
||||
logger.info('Disconnecting from MQTT server');
|
||||
this.client?.end();
|
||||
await this.client?.endAsync();
|
||||
}
|
||||
|
||||
subscribe(topic: string): void {
|
||||
this.client.subscribe(topic);
|
||||
async subscribe(topic: string): Promise<void> {
|
||||
await this.client.subscribeAsync(topic);
|
||||
}
|
||||
|
||||
unsubscribe(topic: string): void {
|
||||
this.client.unsubscribe(topic);
|
||||
async unsubscribe(topic: string): Promise<void> {
|
||||
await this.client.unsubscribeAsync(topic);
|
||||
}
|
||||
|
||||
@bind private async onConnect(): Promise<void> {
|
||||
logger.info('Connected to MQTT server');
|
||||
|
||||
const stateData: Zigbee2MQTTAPI['bridge/state'] = {state: 'online'};
|
||||
|
||||
await this.publish('bridge/state', JSON.stringify(stateData), {retain: true, qos: 0});
|
||||
await this.subscribe(`${settings.get().mqtt.base_topic}/#`);
|
||||
}
|
||||
|
||||
@bind public onMessage(topic: string, message: Buffer): void {
|
||||
@@ -158,18 +165,18 @@ export default class MQTT {
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.client && !this.client.reconnecting;
|
||||
return this.client && !this.client.reconnecting && !this.client.disconnecting && !this.client.disconnected;
|
||||
}
|
||||
|
||||
async publish(
|
||||
topic: string,
|
||||
payload: string,
|
||||
options: MQTTOptions = {},
|
||||
options: IClientPublishOptions = {},
|
||||
base = settings.get().mqtt.base_topic,
|
||||
skipLog = false,
|
||||
skipReceive = true,
|
||||
): Promise<void> {
|
||||
const defaultOptions: {qos: QoS; retain: boolean} = {qos: 0, retain: false};
|
||||
const defaultOptions = {qos: 0 as const, retain: false};
|
||||
topic = `${base}/${topic}`;
|
||||
|
||||
if (skipReceive) {
|
||||
@@ -187,7 +194,6 @@ export default class MQTT {
|
||||
this.eventBus.emitMQTTMessagePublished({topic, payload, options: {...defaultOptions, ...options}});
|
||||
|
||||
if (!this.isConnected()) {
|
||||
/* istanbul ignore else */
|
||||
if (!skipLog) {
|
||||
logger.error(`Not connected to MQTT server!`);
|
||||
logger.error(`Cannot send message: topic: '${topic}', payload: '${payload}`);
|
||||
@@ -200,14 +206,19 @@ export default class MQTT {
|
||||
logger.info(() => `MQTT publish: topic '${topic}', payload '${payload}'`, NS);
|
||||
}
|
||||
|
||||
const actualOptions: mqtt.IClientPublishOptions = {...defaultOptions, ...options};
|
||||
const actualOptions: IClientPublishOptions = {...defaultOptions, ...options};
|
||||
|
||||
if (settings.get().mqtt.force_disable_retain) {
|
||||
actualOptions.retain = false;
|
||||
}
|
||||
|
||||
return await new Promise<void>((resolve) => {
|
||||
this.client.publish(topic, payload, actualOptions, () => resolve());
|
||||
});
|
||||
try {
|
||||
await this.client.publishAsync(topic, payload, actualOptions);
|
||||
} catch (error) {
|
||||
if (!skipLog) {
|
||||
logger.error(`MQTT server error: ${(error as Error).message}`);
|
||||
logger.error(`Could not send message: topic: '${topic}', payload: '${payload}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import fs from 'fs';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import objectAssignDeep from 'object-assign-deep';
|
||||
|
||||
@@ -15,7 +15,6 @@ const dontCacheProperties = [
|
||||
'button',
|
||||
'button_left',
|
||||
'button_right',
|
||||
'click',
|
||||
'forgotten',
|
||||
'keyerror',
|
||||
'step_size',
|
||||
@@ -29,6 +28,7 @@ const dontCacheProperties = [
|
||||
'elapsed',
|
||||
'from_side',
|
||||
'to_side',
|
||||
'illuminance_lux', // removed in z2m 2.0.0
|
||||
];
|
||||
|
||||
class State {
|
||||
|
||||
@@ -0,0 +1,687 @@
|
||||
import type * as zhc from 'zigbee-herdsman-converters';
|
||||
import type {ClusterDefinition, ClusterName, CustomClusters} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype';
|
||||
|
||||
import type {LogLevel, schemaJson} from '../util/settings';
|
||||
|
||||
export interface Zigbee2MQTTScene {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Zigbee2MQTTDeviceEndpoint {
|
||||
bindings: Zigbee2MQTTDeviceEndpointBinding[];
|
||||
configured_reportings: Zigbee2MQTTDeviceEndpointConfiguredReporting[];
|
||||
clusters: {input: string[]; output: string[]};
|
||||
scenes: Zigbee2MQTTScene[];
|
||||
}
|
||||
|
||||
interface Zigbee2MQTTDeviceEndpointBinding {
|
||||
cluster: string;
|
||||
target: Zigbee2MQTTDeviceEndpointBindingTarget;
|
||||
}
|
||||
|
||||
interface Zigbee2MQTTDeviceEndpointBindingTarget {
|
||||
type: string;
|
||||
endpoint?: number;
|
||||
ieee_address?: string;
|
||||
id?: number;
|
||||
}
|
||||
|
||||
interface Zigbee2MQTTDeviceEndpointConfiguredReporting {
|
||||
cluster: string;
|
||||
attribute: string | number;
|
||||
minimum_report_interval: number;
|
||||
maximum_report_interval: number;
|
||||
reportable_change: number;
|
||||
}
|
||||
|
||||
interface Zigbee2MQTTDeviceDefinition {
|
||||
model: string;
|
||||
vendor: string;
|
||||
description: string;
|
||||
exposes: zhc.Expose[];
|
||||
supports_ota: boolean;
|
||||
options: zhc.Option[];
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface Zigbee2MQTTDevice {
|
||||
ieee_address: zh.Device['ieeeAddr'];
|
||||
type: zh.Device['type'];
|
||||
network_address: zh.Device['networkAddress'];
|
||||
supported: boolean;
|
||||
friendly_name: string;
|
||||
disabled: boolean;
|
||||
description: string | undefined;
|
||||
definition: Zigbee2MQTTDeviceDefinition | undefined;
|
||||
power_source: zh.Device['powerSource'];
|
||||
software_build_id: zh.Device['softwareBuildID'];
|
||||
date_code: zh.Device['dateCode'];
|
||||
model_id: zh.Device['modelID'];
|
||||
interviewing: zh.Device['interviewing'];
|
||||
interview_completed: zh.Device['interviewCompleted'];
|
||||
manufacturer: zh.Device['manufacturerName'];
|
||||
endpoints: Record<number, Zigbee2MQTTDeviceEndpoint>;
|
||||
}
|
||||
|
||||
export interface Zigbee2MQTTGroupMember {
|
||||
ieee_address: zh.Device['ieeeAddr'];
|
||||
endpoint: number;
|
||||
}
|
||||
|
||||
export interface Zigbee2MQTTGroup {
|
||||
id: number;
|
||||
friendly_name: 'default_bind_group' | string;
|
||||
description: string | undefined;
|
||||
scenes: Zigbee2MQTTScene[];
|
||||
members: Zigbee2MQTTGroupMember[];
|
||||
}
|
||||
|
||||
export interface Zigbee2MQTTNetworkMap {
|
||||
nodes: {
|
||||
ieeeAddr: string;
|
||||
friendlyName: string;
|
||||
type: string;
|
||||
networkAddress: number;
|
||||
manufacturerName: string | undefined;
|
||||
modelID: string | undefined;
|
||||
failed: string[];
|
||||
lastSeen: number | undefined;
|
||||
definition?: {model: string; vendor: string; supports: string; description: string};
|
||||
}[];
|
||||
links: {
|
||||
source: {ieeeAddr: string; networkAddress: number};
|
||||
target: {ieeeAddr: string; networkAddress: number};
|
||||
linkquality: number;
|
||||
depth: number;
|
||||
routes: {
|
||||
destinationAddress: number;
|
||||
status: string;
|
||||
nextHop: number;
|
||||
}[];
|
||||
sourceIeeeAddr: string;
|
||||
targetIeeeAddr: string;
|
||||
sourceNwkAddr: number;
|
||||
lqi: number;
|
||||
relationship: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Zigbee2MQTT state/request/response API endpoints
|
||||
*/
|
||||
export interface Zigbee2MQTTAPI {
|
||||
'bridge/logging': {
|
||||
message: string;
|
||||
level: LogLevel;
|
||||
namespace: string;
|
||||
};
|
||||
|
||||
'bridge/state': {
|
||||
state: 'online' | 'offline';
|
||||
};
|
||||
|
||||
'bridge/definition': {
|
||||
clusters: Readonly<Record<ClusterName, Readonly<ClusterDefinition>>>;
|
||||
custom_clusters: Record<string, CustomClusters>;
|
||||
};
|
||||
|
||||
'bridge/event':
|
||||
| {
|
||||
type: 'device_leave' | 'device_joined' | 'device_announce';
|
||||
data: {
|
||||
friendly_name: string;
|
||||
ieee_address: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 'device_interview';
|
||||
data:
|
||||
| {
|
||||
friendly_name: string;
|
||||
ieee_address: string;
|
||||
status: 'started' | 'failed';
|
||||
}
|
||||
| {
|
||||
friendly_name: string;
|
||||
ieee_address: string;
|
||||
status: 'successful';
|
||||
supported: boolean;
|
||||
definition: Zigbee2MQTTDeviceDefinition | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
'bridge/info': {
|
||||
version: string;
|
||||
commit: string | undefined;
|
||||
zigbee_herdsman_converters: {version: string};
|
||||
zigbee_herdsman: {version: string};
|
||||
coordinator: {
|
||||
ieee_address: string;
|
||||
type: string;
|
||||
meta: {
|
||||
[s: string]: number | string;
|
||||
};
|
||||
};
|
||||
network: {
|
||||
pan_id: number;
|
||||
/** `0x${string}` 8-len */
|
||||
extended_pan_id: string;
|
||||
channel: number;
|
||||
};
|
||||
log_level: 'debug' | 'info' | 'warning' | 'error';
|
||||
permit_join: boolean;
|
||||
permit_join_end: number | undefined;
|
||||
restart_required: boolean;
|
||||
config: Settings;
|
||||
config_schema: typeof schemaJson;
|
||||
};
|
||||
|
||||
'bridge/devices': Zigbee2MQTTDevice[];
|
||||
|
||||
'bridge/groups': Zigbee2MQTTGroup[];
|
||||
|
||||
'bridge/request/permit_join':
|
||||
| {
|
||||
/** [0-254], 0 meaning disable */
|
||||
time: number;
|
||||
device?: string;
|
||||
}
|
||||
| `${number}`;
|
||||
|
||||
'bridge/response/permit_join': {
|
||||
/** [0-254], 0 meaning disable */
|
||||
time: number;
|
||||
device?: string;
|
||||
};
|
||||
|
||||
'bridge/request/health_check': '';
|
||||
|
||||
'bridge/response/health_check': {
|
||||
/** XXX: currently always returns true */
|
||||
healthy: boolean;
|
||||
};
|
||||
|
||||
'bridge/request/coordinator_check': '';
|
||||
|
||||
'bridge/response/coordinator_check': {
|
||||
missing_routers: {
|
||||
ieee_address: string;
|
||||
friendly_name: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
'bridge/request/restart': '';
|
||||
|
||||
'bridge/response/restart': Record<string, never>;
|
||||
|
||||
'bridge/request/networkmap':
|
||||
| {
|
||||
type: 'raw' | 'graphviz' | 'plantuml';
|
||||
routes: boolean;
|
||||
}
|
||||
| 'raw'
|
||||
| 'graphviz'
|
||||
| 'plantuml';
|
||||
|
||||
'bridge/response/networkmap':
|
||||
| {
|
||||
type: 'raw';
|
||||
routes: boolean;
|
||||
value: Zigbee2MQTTNetworkMap;
|
||||
}
|
||||
| {
|
||||
type: 'graphviz' | 'plantuml';
|
||||
routes: boolean;
|
||||
value: string;
|
||||
};
|
||||
|
||||
'bridge/request/extension/save': {
|
||||
name: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
'bridge/response/extension/save': Record<string, never>;
|
||||
|
||||
'bridge/request/extension/remove': {
|
||||
name: string;
|
||||
};
|
||||
|
||||
'bridge/response/extension/remove': Record<string, never>;
|
||||
|
||||
'bridge/request/converter/save': {
|
||||
name: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
'bridge/response/converter/save': Record<string, never>;
|
||||
|
||||
'bridge/request/converter/remove': {
|
||||
name: string;
|
||||
};
|
||||
|
||||
'bridge/response/converter/remove': Record<string, never>;
|
||||
|
||||
'bridge/request/backup': '';
|
||||
|
||||
'bridge/response/backup': {
|
||||
/** base64 encoded ZIP archive */
|
||||
zip: string;
|
||||
};
|
||||
|
||||
'bridge/request/install_code/add': {
|
||||
value: string;
|
||||
};
|
||||
|
||||
'bridge/response/install_code/add': {
|
||||
value: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Applied on-the-fly:
|
||||
* - newSettings.homeassistant
|
||||
* - newSettings.advanced?.log_level
|
||||
* - newSettings.advanced?.log_namespaced_levels
|
||||
* - newSettings.advanced?.log_debug_namespace_ignore
|
||||
*/
|
||||
'bridge/request/options': {
|
||||
options: Record<string, unknown>;
|
||||
};
|
||||
|
||||
'bridge/response/options': {
|
||||
restart_required: boolean;
|
||||
};
|
||||
|
||||
'bridge/request/device/bind': {
|
||||
from: string;
|
||||
from_endpoint: string | number | 'default';
|
||||
to: string;
|
||||
to_endpoint?: string | number;
|
||||
clusters?: string[];
|
||||
skip_disable_reporting?: boolean;
|
||||
};
|
||||
|
||||
'bridge/response/device/bind': {
|
||||
from: string;
|
||||
from_endpoint: string | number;
|
||||
to: string;
|
||||
to_endpoint: string | number | undefined;
|
||||
clusters: string[];
|
||||
failed: string[];
|
||||
};
|
||||
|
||||
'bridge/request/device/unbind': {
|
||||
from: string;
|
||||
from_endpoint: string | number | 'default';
|
||||
to: string;
|
||||
to_endpoint?: string | number;
|
||||
clusters?: string[];
|
||||
skip_disable_reporting?: boolean;
|
||||
};
|
||||
|
||||
'bridge/response/device/unbind': {
|
||||
from: string;
|
||||
from_endpoint: string | number;
|
||||
to: string;
|
||||
to_endpoint: string | number | undefined;
|
||||
clusters: string[];
|
||||
failed: string[];
|
||||
};
|
||||
|
||||
'bridge/request/device/configure':
|
||||
| {
|
||||
id: string | number;
|
||||
}
|
||||
| string;
|
||||
|
||||
'bridge/response/device/configure': {
|
||||
id: string | number;
|
||||
};
|
||||
|
||||
'bridge/request/device/remove': {
|
||||
id: string;
|
||||
block?: boolean;
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
'bridge/response/device/remove': {
|
||||
id: string;
|
||||
block: boolean;
|
||||
force: boolean;
|
||||
};
|
||||
|
||||
'bridge/request/device/ota_update/check': {
|
||||
id: string;
|
||||
};
|
||||
|
||||
'bridge/request/device/ota_update/check/downgrade': {
|
||||
id: string;
|
||||
};
|
||||
|
||||
'bridge/response/device/ota_update/check': {
|
||||
id: string;
|
||||
update_available: boolean;
|
||||
};
|
||||
|
||||
'bridge/request/device/ota_update/update': {
|
||||
id: string;
|
||||
};
|
||||
|
||||
'bridge/request/device/ota_update/update/downgrade': {
|
||||
id: string;
|
||||
};
|
||||
|
||||
'bridge/response/device/ota_update/update': {
|
||||
id: string;
|
||||
from:
|
||||
| {
|
||||
software_build_id: string;
|
||||
date_code: string;
|
||||
}
|
||||
| undefined;
|
||||
to:
|
||||
| {
|
||||
software_build_id: string;
|
||||
date_code: string;
|
||||
}
|
||||
| undefined;
|
||||
};
|
||||
|
||||
'bridge/request/device/interview': {
|
||||
id: string | number;
|
||||
};
|
||||
|
||||
'bridge/response/device/interview': {
|
||||
id: string | number;
|
||||
};
|
||||
|
||||
'bridge/request/device/generate_external_definition': {
|
||||
id: string | number;
|
||||
};
|
||||
|
||||
'bridge/response/device/generate_external_definition': {
|
||||
id: string | number;
|
||||
source: string;
|
||||
};
|
||||
|
||||
'bridge/request/device/options': {
|
||||
id: string;
|
||||
options: Record<string, unknown>;
|
||||
};
|
||||
|
||||
'bridge/response/device/options': {
|
||||
id: string;
|
||||
from: Record<string, unknown>;
|
||||
to: Record<string, unknown>;
|
||||
restart_required: boolean;
|
||||
};
|
||||
|
||||
'bridge/request/device/rename':
|
||||
| {
|
||||
last: true;
|
||||
from?: string;
|
||||
to: string;
|
||||
homeassistant_rename?: boolean;
|
||||
}
|
||||
| {
|
||||
last: false | undefined;
|
||||
from: string;
|
||||
to: string;
|
||||
homeassistant_rename?: boolean;
|
||||
};
|
||||
|
||||
'bridge/response/device/rename': {
|
||||
from: string;
|
||||
to: string;
|
||||
homeassistant_rename: boolean;
|
||||
};
|
||||
|
||||
'bridge/request/device/configure_reporting': {
|
||||
id: string;
|
||||
endpoint: string | number;
|
||||
cluster: string | number;
|
||||
attribute: string | number | {ID: number; type: number};
|
||||
minimum_report_interval: number;
|
||||
maximum_report_interval: number;
|
||||
reportable_change: number;
|
||||
option: Record<string, unknown>;
|
||||
};
|
||||
|
||||
'bridge/response/device/configure_reporting': {
|
||||
id: string;
|
||||
endpoint: string | number;
|
||||
cluster: string | number;
|
||||
attribute: string | number | {ID: number; type: number};
|
||||
minimum_report_interval: number;
|
||||
maximum_report_interval: number;
|
||||
reportable_change: number;
|
||||
};
|
||||
|
||||
'bridge/request/group/remove': {
|
||||
id: string;
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
'bridge/response/group/remove': {
|
||||
id: string;
|
||||
force: boolean;
|
||||
};
|
||||
|
||||
'bridge/request/group/add': {
|
||||
friendly_name: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
'bridge/response/group/add': {
|
||||
friendly_name: string;
|
||||
id: number;
|
||||
};
|
||||
|
||||
'bridge/request/group/rename': {
|
||||
from: string;
|
||||
to: string;
|
||||
homeassistant_rename?: boolean;
|
||||
};
|
||||
|
||||
'bridge/response/group/rename': {
|
||||
from: string;
|
||||
to: string;
|
||||
homeassistant_rename: boolean;
|
||||
};
|
||||
|
||||
'bridge/request/group/options': {
|
||||
id: string;
|
||||
options: Record<string, unknown>;
|
||||
};
|
||||
|
||||
'bridge/response/group/options': {
|
||||
id: string;
|
||||
from: Record<string, unknown>;
|
||||
to: Record<string, unknown>;
|
||||
restart_required: boolean;
|
||||
};
|
||||
|
||||
'bridge/request/group/members/add': {
|
||||
device: string;
|
||||
group: string;
|
||||
endpoint: string | number | 'default';
|
||||
skip_disable_reporting?: boolean;
|
||||
};
|
||||
|
||||
'bridge/response/group/members/add': {
|
||||
device: string;
|
||||
group: string;
|
||||
endpoint: string | number | 'default';
|
||||
};
|
||||
|
||||
'bridge/request/group/members/remove': {
|
||||
device: string;
|
||||
group: string;
|
||||
endpoint: string | number | 'default';
|
||||
skip_disable_reporting?: boolean;
|
||||
};
|
||||
|
||||
'bridge/response/group/members/remove': {
|
||||
device: string;
|
||||
group: string;
|
||||
endpoint: string | number | 'default';
|
||||
};
|
||||
|
||||
'bridge/request/group/members/remove_all': {
|
||||
device: string;
|
||||
endpoint: string | number | 'default';
|
||||
skip_disable_reporting?: boolean;
|
||||
};
|
||||
|
||||
'bridge/response/group/members/remove_all': {
|
||||
device: string;
|
||||
endpoint: string | number | 'default';
|
||||
};
|
||||
|
||||
'bridge/request/touchlink/factory_reset':
|
||||
| {
|
||||
ieee_address: string;
|
||||
channel: number;
|
||||
}
|
||||
| '';
|
||||
|
||||
'bridge/response/touchlink/factory_reset':
|
||||
| {
|
||||
ieee_address: string;
|
||||
channel: number;
|
||||
}
|
||||
| Record<string, never>;
|
||||
|
||||
'bridge/request/touchlink/scan': '';
|
||||
|
||||
'bridge/response/touchlink/scan': {
|
||||
found: {
|
||||
ieee_address: string;
|
||||
channel: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
'bridge/request/touchlink/identify': {
|
||||
ieee_address: string;
|
||||
channel: number;
|
||||
};
|
||||
|
||||
'bridge/response/touchlink/identify': {
|
||||
ieee_address: string;
|
||||
channel: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* entity state response
|
||||
*/
|
||||
'{friendlyName}': {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
'{friendlyName}/availability': {
|
||||
state: 'online' | 'offline';
|
||||
};
|
||||
|
||||
/** entity set request */
|
||||
'{friendlyName}/set': {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/** entity get request */
|
||||
'{friendlyName}/get': {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export type Zigbee2MQTTRequestEndpoints =
|
||||
| 'bridge/request/permit_join'
|
||||
| 'bridge/request/health_check'
|
||||
| 'bridge/request/coordinator_check'
|
||||
| 'bridge/request/restart'
|
||||
| 'bridge/request/networkmap'
|
||||
| 'bridge/request/extension/save'
|
||||
| 'bridge/request/extension/remove'
|
||||
| 'bridge/request/converter/save'
|
||||
| 'bridge/request/converter/remove'
|
||||
| 'bridge/request/backup'
|
||||
| 'bridge/request/install_code/add'
|
||||
| 'bridge/request/options'
|
||||
| 'bridge/request/device/bind'
|
||||
| 'bridge/request/device/unbind'
|
||||
| 'bridge/request/device/configure'
|
||||
| 'bridge/request/device/remove'
|
||||
| 'bridge/request/device/ota_update/check'
|
||||
| 'bridge/request/device/ota_update/check/downgrade'
|
||||
| 'bridge/request/device/ota_update/update'
|
||||
| 'bridge/request/device/ota_update/update/downgrade'
|
||||
| 'bridge/request/device/interview'
|
||||
| 'bridge/request/device/generate_external_definition'
|
||||
| 'bridge/request/device/options'
|
||||
| 'bridge/request/device/rename'
|
||||
| 'bridge/request/device/configure_reporting'
|
||||
| 'bridge/request/group/remove'
|
||||
| 'bridge/request/group/add'
|
||||
| 'bridge/request/group/rename'
|
||||
| 'bridge/request/group/options'
|
||||
| 'bridge/request/group/members/add'
|
||||
| 'bridge/request/group/members/remove'
|
||||
| 'bridge/request/group/members/remove_all'
|
||||
| 'bridge/request/touchlink/factory_reset'
|
||||
| 'bridge/request/touchlink/scan'
|
||||
| 'bridge/request/touchlink/identify';
|
||||
|
||||
export type Zigbee2MQTTResponseEndpoints =
|
||||
| 'bridge/response/permit_join'
|
||||
| 'bridge/response/health_check'
|
||||
| 'bridge/response/coordinator_check'
|
||||
| 'bridge/response/restart'
|
||||
| 'bridge/response/networkmap'
|
||||
| 'bridge/response/extension/save'
|
||||
| 'bridge/response/extension/remove'
|
||||
| 'bridge/response/converter/save'
|
||||
| 'bridge/response/converter/remove'
|
||||
| 'bridge/response/backup'
|
||||
| 'bridge/response/install_code/add'
|
||||
| 'bridge/response/options'
|
||||
| 'bridge/response/device/bind'
|
||||
| 'bridge/response/device/unbind'
|
||||
| 'bridge/response/device/configure'
|
||||
| 'bridge/response/device/remove'
|
||||
| 'bridge/response/device/ota_update/check'
|
||||
| 'bridge/response/device/ota_update/check'
|
||||
| 'bridge/response/device/ota_update/update'
|
||||
| 'bridge/response/device/ota_update/update'
|
||||
| 'bridge/response/device/interview'
|
||||
| 'bridge/response/device/generate_external_definition'
|
||||
| 'bridge/response/device/options'
|
||||
| 'bridge/response/device/rename'
|
||||
| 'bridge/response/device/configure_reporting'
|
||||
| 'bridge/response/group/remove'
|
||||
| 'bridge/response/group/add'
|
||||
| 'bridge/response/group/rename'
|
||||
| 'bridge/response/group/options'
|
||||
| 'bridge/response/group/members/add'
|
||||
| 'bridge/response/group/members/remove'
|
||||
| 'bridge/response/group/members/remove_all'
|
||||
| 'bridge/response/touchlink/factory_reset'
|
||||
| 'bridge/response/touchlink/scan'
|
||||
| 'bridge/response/touchlink/identify';
|
||||
|
||||
export type Zigbee2MQTTRequest<T extends Zigbee2MQTTRequestEndpoints> = {
|
||||
transaction?: string;
|
||||
} & Zigbee2MQTTAPI[T];
|
||||
|
||||
export type Zigbee2MQTTResponseOK<T extends Zigbee2MQTTResponseEndpoints> = {
|
||||
status: 'ok';
|
||||
data: Zigbee2MQTTAPI[T];
|
||||
transaction?: string;
|
||||
};
|
||||
|
||||
export type Zigbee2MQTTResponseError = {
|
||||
status: 'error';
|
||||
data: Record<string, never>;
|
||||
error: string;
|
||||
transaction?: string;
|
||||
};
|
||||
|
||||
export type Zigbee2MQTTResponse<T extends Zigbee2MQTTResponseEndpoints> = Zigbee2MQTTResponseOK<T> | Zigbee2MQTTResponseError;
|
||||
@@ -1,3 +0,0 @@
|
||||
declare module 'mkdir-recursive' {
|
||||
export function mkdirSync(path: string): void;
|
||||
}
|
||||
Vendored
+24
-57
@@ -5,17 +5,7 @@ import type TypeGroup from 'lib/model/group';
|
||||
import type TypeMQTT from 'lib/mqtt';
|
||||
import type TypeState from 'lib/state';
|
||||
import type TypeZigbee from 'lib/zigbee';
|
||||
import type {QoS} from 'mqtt-packet';
|
||||
import type * as zhc from 'zigbee-herdsman-converters';
|
||||
import type {
|
||||
CoordinatorVersion as ZHCoordinatorVersion,
|
||||
LQI as ZHLQI,
|
||||
NetworkParameters as ZHNetworkParameters,
|
||||
RoutingTable as ZHRoutingTable,
|
||||
RoutingTableEntry as ZHRoutingTableEntry,
|
||||
} from 'zigbee-herdsman/dist/adapter/tstype';
|
||||
import type * as ZHEvents from 'zigbee-herdsman/dist/controller/events';
|
||||
import type {Device as ZHDevice, Endpoint as ZHEndpoint, Group as ZHGroup} from 'zigbee-herdsman/dist/controller/model';
|
||||
import type {AdapterTypes as ZHAdapterTypes, Events as ZHEvents, Models as ZHModels} from 'zigbee-herdsman';
|
||||
import type {Cluster as ZHCluster, FrameControl as ZHFrameControl} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype';
|
||||
|
||||
import {LogLevel} from 'lib/util/settings';
|
||||
@@ -33,19 +23,6 @@ declare global {
|
||||
type Extension = TypeExtension;
|
||||
|
||||
// Types
|
||||
type ExternalDefinition = zhc.Definition & {homeassistant: unknown};
|
||||
interface MQTTResponse {
|
||||
data: KeyValue;
|
||||
status: 'error' | 'ok';
|
||||
error?: string;
|
||||
transaction?: string;
|
||||
}
|
||||
interface MQTTOptions {
|
||||
qos?: QoS;
|
||||
retain?: boolean;
|
||||
properties?: {messageExpiryInterval: number};
|
||||
}
|
||||
type Scene = {id: number; name: string};
|
||||
type StateChangeReason = 'publishDebounce' | 'groupOptimistic' | 'lastSeenChanged' | 'publishCached' | 'publishThrottle';
|
||||
type PublishEntityState = (entity: Device | Group, payload: KeyValue, stateChangeReason?: StateChangeReason) => Promise<void>;
|
||||
type RecursivePartial<T> = {[P in keyof T]?: RecursivePartial<T[P]>};
|
||||
@@ -56,17 +33,15 @@ declare global {
|
||||
|
||||
// zigbee-herdsman
|
||||
namespace zh {
|
||||
type Endpoint = ZHEndpoint;
|
||||
type Device = ZHDevice;
|
||||
type Group = ZHGroup;
|
||||
type LQI = ZHLQI;
|
||||
type RoutingTable = ZHRoutingTable;
|
||||
type RoutingTableEntry = ZHRoutingTableEntry;
|
||||
type CoordinatorVersion = ZHCoordinatorVersion;
|
||||
type NetworkParameters = ZHNetworkParameters;
|
||||
type Cluster = ZHCluster;
|
||||
type Endpoint = ZHModels.Endpoint;
|
||||
type Device = ZHModels.Device;
|
||||
type Group = ZHModels.Group;
|
||||
type LQI = ZHAdapterTypes.LQI;
|
||||
type RoutingTable = ZHAdapterTypes.RoutingTable;
|
||||
type CoordinatorVersion = ZHAdapterTypes.CoordinatorVersion;
|
||||
type NetworkParameters = ZHAdapterTypes.NetworkParameters;
|
||||
interface Bind {
|
||||
cluster: zh.Cluster;
|
||||
cluster: ZHCluster;
|
||||
target: zh.Endpoint | zh.Group;
|
||||
}
|
||||
}
|
||||
@@ -113,18 +88,19 @@ declare global {
|
||||
|
||||
// Settings
|
||||
interface Settings {
|
||||
homeassistant?: {
|
||||
version?: number;
|
||||
homeassistant: {
|
||||
enabled: boolean;
|
||||
discovery_topic: string;
|
||||
status_topic: string;
|
||||
legacy_entity_attributes: boolean;
|
||||
legacy_triggers: boolean;
|
||||
experimental_event_entities: boolean;
|
||||
legacy_action_sensor: boolean;
|
||||
};
|
||||
permit_join: boolean;
|
||||
availability?: {
|
||||
availability: {
|
||||
enabled: boolean;
|
||||
active: {timeout: number};
|
||||
passive: {timeout: number};
|
||||
};
|
||||
external_converters: string[];
|
||||
mqtt: {
|
||||
base_topic: string;
|
||||
include_device_information: boolean;
|
||||
@@ -139,6 +115,7 @@ declare global {
|
||||
cert?: string;
|
||||
client_id?: string;
|
||||
reject_unauthorized?: boolean;
|
||||
maximum_packet_size: number;
|
||||
};
|
||||
serial: {
|
||||
disable_led: boolean;
|
||||
@@ -173,9 +150,11 @@ declare global {
|
||||
update_check_interval: number;
|
||||
disable_automatic_update_check: boolean;
|
||||
zigbee_ota_override_index_location?: string;
|
||||
ikea_ota_use_test_url?: boolean;
|
||||
image_block_response_delay?: number;
|
||||
default_maximum_data_size?: number;
|
||||
};
|
||||
frontend?: {
|
||||
frontend: {
|
||||
enabled: boolean;
|
||||
auth_token?: string;
|
||||
host?: string;
|
||||
port: number;
|
||||
@@ -185,12 +164,11 @@ declare global {
|
||||
ssl_key?: string;
|
||||
};
|
||||
devices: {[s: string]: DeviceOptions};
|
||||
groups: {[s: string]: OptionalProps<Omit<GroupOptions, 'ID'>, 'devices'>};
|
||||
groups: {[s: string]: Omit<GroupOptions, 'ID'>};
|
||||
device_options: KeyValue;
|
||||
advanced: {
|
||||
legacy_api: boolean;
|
||||
legacy_availability_payload: boolean;
|
||||
log_rotation: boolean;
|
||||
log_console_json: boolean;
|
||||
log_symlink_current: boolean;
|
||||
log_output: ('console' | 'file' | 'syslog')[];
|
||||
log_directory: string;
|
||||
@@ -200,6 +178,7 @@ declare global {
|
||||
log_syslog: KeyValue;
|
||||
log_debug_to_mqtt_frontend: boolean;
|
||||
log_debug_namespace_ignore: string;
|
||||
log_directories_to_keep: number;
|
||||
pan_id: number | 'GENERATE';
|
||||
ext_pan_id: number[] | 'GENERATE';
|
||||
channel: number;
|
||||
@@ -214,14 +193,6 @@ declare global {
|
||||
timestamp_format: string;
|
||||
output: 'json' | 'attribute' | 'attribute_and_json';
|
||||
transmit_power?: number;
|
||||
// Everything below is deprecated
|
||||
availability_timeout?: number;
|
||||
availability_blocklist: string[];
|
||||
availability_passlist: string[];
|
||||
availability_blacklist: string[];
|
||||
availability_whitelist: string[];
|
||||
soft_reset_timeout: number;
|
||||
report: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -230,7 +201,6 @@ declare global {
|
||||
retention?: number;
|
||||
availability?: boolean | {timeout: number};
|
||||
optimistic?: boolean;
|
||||
retrieve_state?: boolean;
|
||||
debounce?: number;
|
||||
debounce_ignore?: string[];
|
||||
throttle?: number;
|
||||
@@ -239,7 +209,6 @@ declare global {
|
||||
filtered_optimistic?: string[];
|
||||
icon?: string;
|
||||
homeassistant?: KeyValue;
|
||||
legacy?: boolean;
|
||||
friendly_name: string;
|
||||
description?: string;
|
||||
qos?: 0 | 1 | 2;
|
||||
@@ -250,14 +219,12 @@ declare global {
|
||||
}
|
||||
|
||||
interface GroupOptions {
|
||||
devices: string[];
|
||||
ID: number;
|
||||
optimistic?: boolean;
|
||||
off_state?: 'all_members_off' | 'last_member_state';
|
||||
filtered_attributes?: string[];
|
||||
filtered_cache?: string[];
|
||||
filtered_optimistic?: string[];
|
||||
retrieve_state?: boolean;
|
||||
homeassistant?: KeyValue;
|
||||
friendly_name: string;
|
||||
description?: string;
|
||||
|
||||
Vendored
+4
-3
@@ -5,11 +5,12 @@ declare module 'zigbee2mqtt-frontend' {
|
||||
declare module 'http' {
|
||||
interface IncomingMessage {
|
||||
originalUrl?: string;
|
||||
path?: string;
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'connect-gzip-static' {
|
||||
import {IncomingMessage, ServerResponse} from 'http';
|
||||
declare module 'express-static-gzip' {
|
||||
import {IncomingMessage, ServerResponse} from 'node:http';
|
||||
export type RequestHandler = (req: IncomingMessage, res: ServerResponse, finalhandler: (err: unknown) => void) => void;
|
||||
export default function gzipStatic(root: string, options?: Record<string, unknown>): RequestHandler;
|
||||
export default function expressStaticGzip(root: string, options?: Record<string, unknown>): RequestHandler;
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import path from 'path';
|
||||
import path from 'node:path';
|
||||
|
||||
function setPath(): string {
|
||||
return process.env.ZIGBEE2MQTT_DATA ? process.env.ZIGBEE2MQTT_DATA : path.normalize(path.join(__dirname, '..', '..', 'data'));
|
||||
|
||||
+36
-33
@@ -1,8 +1,7 @@
|
||||
import assert from 'assert';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import assert from 'node:assert';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import fx from 'mkdir-recursive';
|
||||
import moment from 'moment';
|
||||
import {rimrafSync} from 'rimraf';
|
||||
import winston from 'winston';
|
||||
@@ -57,15 +56,15 @@ class Logger {
|
||||
this.logger.add(
|
||||
new winston.transports.Console({
|
||||
silent: consoleSilenced,
|
||||
// winston.config.syslog.levels sets 'warning' as 'red'
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize({colors: {debug: 'blue', info: 'green', warning: 'yellow', error: 'red'}}),
|
||||
winston.format.printf(
|
||||
/* istanbul ignore next */ (info) => {
|
||||
return `[${info.timestamp}] ${info.level}: \t${info.message}`;
|
||||
},
|
||||
),
|
||||
),
|
||||
format: settings.get().advanced.log_console_json
|
||||
? winston.format.json()
|
||||
: winston.format.combine(
|
||||
// winston.config.syslog.levels sets 'warning' as 'red'
|
||||
winston.format.colorize({colors: {debug: 'blue', info: 'green', warning: 'yellow', error: 'red'}}),
|
||||
winston.format.printf((info) => {
|
||||
return `[${info.timestamp}] ${info.level}: \t${info.message}`;
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -73,15 +72,18 @@ class Logger {
|
||||
logging += `, file (filename: ${logFilename})`;
|
||||
|
||||
// Make sure that log directory exists when not logging to stdout only
|
||||
fx.mkdirSync(this.directory);
|
||||
fs.mkdirSync(this.directory, {recursive: true});
|
||||
|
||||
if (settings.get().advanced.log_symlink_current) {
|
||||
const current = settings.get().advanced.log_directory.replace('%TIMESTAMP%', 'current');
|
||||
const actual = './' + timestamp;
|
||||
/* istanbul ignore next */
|
||||
|
||||
/* v8 ignore start */
|
||||
if (fs.existsSync(current)) {
|
||||
fs.unlinkSync(current);
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
fs.symlinkSync(actual, current);
|
||||
}
|
||||
|
||||
@@ -89,11 +91,9 @@ class Logger {
|
||||
// NOTE: the initiation of the logger even when not added as transport tries to create the logging directory
|
||||
const transportFileOptions: winston.transports.FileTransportOptions = {
|
||||
filename: path.join(this.directory, logFilename),
|
||||
format: winston.format.printf(
|
||||
/* istanbul ignore next */ (info) => {
|
||||
return `[${info.timestamp}] ${info.level}: \t${info.message}`;
|
||||
},
|
||||
),
|
||||
format: winston.format.printf((info) => {
|
||||
return `[${info.timestamp}] ${info.level}: \t${info.message}`;
|
||||
}),
|
||||
};
|
||||
|
||||
if (settings.get().advanced.log_rotation) {
|
||||
@@ -104,9 +104,10 @@ class Logger {
|
||||
|
||||
this.fileTransport = new winston.transports.File(transportFileOptions);
|
||||
this.logger.add(this.fileTransport);
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
/* v8 ignore start */
|
||||
if (this.output.includes('syslog')) {
|
||||
logging += `, syslog`;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unused-expressions
|
||||
@@ -114,7 +115,7 @@ class Logger {
|
||||
|
||||
const options: KeyValue = {
|
||||
app_name: 'Zigbee2MQTT',
|
||||
format: winston.format.printf((info) => info.message),
|
||||
format: winston.format.printf((info) => info.message as string),
|
||||
...settings.get().advanced.log_syslog,
|
||||
};
|
||||
|
||||
@@ -125,6 +126,7 @@ class Logger {
|
||||
// @ts-expect-error untyped transport
|
||||
this.logger.add(new winston.transports.Syslog(options));
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
this.setDebugNamespaceIgnore(settings.get().advanced.log_debug_namespace_ignore);
|
||||
|
||||
@@ -220,7 +222,7 @@ class Logger {
|
||||
}
|
||||
|
||||
// Cleanup any old log directory.
|
||||
public cleanup(): void {
|
||||
private cleanup(): void {
|
||||
if (settings.get().advanced.log_directory.includes('%TIMESTAMP%')) {
|
||||
const rootDirectory = path.join(this.directory, '..');
|
||||
|
||||
@@ -230,7 +232,7 @@ class Logger {
|
||||
});
|
||||
|
||||
directories.sort((a: KeyValue, b: KeyValue) => b.birth - a.birth);
|
||||
directories = directories.slice(10, directories.length);
|
||||
directories = directories.slice(settings.get().advanced.log_directories_to_keep, directories.length);
|
||||
directories.forEach((dir) => {
|
||||
this.debug(`Removing old log directory '${dir.path}'`);
|
||||
rimrafSync(dir.path);
|
||||
@@ -240,14 +242,13 @@ class Logger {
|
||||
|
||||
// Workaround for https://github.com/winstonjs/winston/issues/1629.
|
||||
// https://github.com/Koenkk/zigbee2mqtt/pull/10905
|
||||
/* istanbul ignore next */
|
||||
/* v8 ignore start */
|
||||
public async end(): Promise<void> {
|
||||
this.logger.end();
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
if (!this.fileTransport) {
|
||||
process.nextTick(resolve);
|
||||
} else {
|
||||
// Only flush the file transport, don't end logger itself as log() might still be called
|
||||
// causing a UnhandledPromiseRejection (`Error: write after end`). Flushing the file transport
|
||||
// ensures the log files are written before stopping.
|
||||
if (this.fileTransport) {
|
||||
await new Promise<void>((resolve) => {
|
||||
// @ts-expect-error workaround
|
||||
if (this.fileTransport._dest) {
|
||||
// @ts-expect-error workaround
|
||||
@@ -256,9 +257,11 @@ class Logger {
|
||||
// @ts-expect-error workaround
|
||||
this.fileTransport.on('open', () => this.fileTransport._dest.on('finish', resolve));
|
||||
}
|
||||
}
|
||||
});
|
||||
this.fileTransport.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
|
||||
export default new Logger();
|
||||
|
||||
+189
-285
@@ -3,110 +3,93 @@
|
||||
"properties": {
|
||||
"homeassistant": {
|
||||
"title": "Home Assistant integration",
|
||||
"requiresRestart": true,
|
||||
"description": "Home Assistant integration (MQTT discovery)",
|
||||
"default": false,
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"title": "Home Assistant (simple)"
|
||||
"title": "Enabled",
|
||||
"description": "Enable Home Assistant integration",
|
||||
"default": false,
|
||||
"requiresRestart": true
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "Home Assistant (advanced)",
|
||||
"properties": {
|
||||
"legacy_triggers": {
|
||||
"type": "boolean",
|
||||
"title": "Home Assistant legacy triggers",
|
||||
"description": "Home Assistant legacy triggers, when enabled Zigbee2mqt will send an empty 'action' or 'click' after one has been send. A 'sensor_action' and 'sensor_click' will be discoverd",
|
||||
"default": true
|
||||
},
|
||||
"discovery_topic": {
|
||||
"type": "string",
|
||||
"title": "Homeassistant discovery topic",
|
||||
"description": "Home Assistant discovery topic",
|
||||
"requiresRestart": true,
|
||||
"examples": ["homeassistant"]
|
||||
},
|
||||
"legacy_entity_attributes": {
|
||||
"type": "boolean",
|
||||
"title": "Home Assistant legacy entity attributes",
|
||||
"description": "Home Assistant legacy entity attributes, when enabled Zigbee2MQTT will add state attributes to each entity, additional to the separate entities and devices it already creates",
|
||||
"default": true
|
||||
},
|
||||
"status_topic": {
|
||||
"type": "string",
|
||||
"title": "Home Assistant status topic",
|
||||
"description": "Home Assistant status topic",
|
||||
"requiresRestart": true,
|
||||
"examples": ["homeassistant/status"]
|
||||
}
|
||||
}
|
||||
"discovery_topic": {
|
||||
"type": "string",
|
||||
"title": "Homeassistant discovery topic",
|
||||
"description": "Home Assistant discovery topic",
|
||||
"default": "homeassistant",
|
||||
"requiresRestart": true,
|
||||
"examples": ["homeassistant"]
|
||||
},
|
||||
"status_topic": {
|
||||
"type": "string",
|
||||
"title": "Home Assistant status topic",
|
||||
"description": "Home Assistant status topic",
|
||||
"default": "homeassistant/status",
|
||||
"requiresRestart": true,
|
||||
"examples": ["homeassistant/status"]
|
||||
},
|
||||
"legacy_action_sensor": {
|
||||
"type": "boolean",
|
||||
"title": "Home Assistant legacy action sensors",
|
||||
"description": "Home Assistant legacy actions sensor, when enabled a action sensor will be discoverd and an empty `action` will be send after every published action.",
|
||||
"default": false
|
||||
},
|
||||
"experimental_event_entities": {
|
||||
"type": "boolean",
|
||||
"title": "Home Assistant experimental event entities",
|
||||
"description": "Home Assistant experimental event entities, when enabled Zigbee2MQTT will add event entities for exposed actions. The events and attributes are currently deemed experimental and subject to change.",
|
||||
"default": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"permit_join": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"title": "Permit join",
|
||||
"description": "Allow new devices to join (re-applied at restart)"
|
||||
},
|
||||
"required": []
|
||||
},
|
||||
"availability": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "boolean",
|
||||
"title": "Availability (simple)"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "Availability (advanced)",
|
||||
"properties": {
|
||||
"active": {
|
||||
"type": "object",
|
||||
"title": "Active",
|
||||
"requiresRestart": true,
|
||||
"description": "Options for active devices (routers/mains powered)",
|
||||
"properties": {
|
||||
"timeout": {
|
||||
"type": "number",
|
||||
"title": "Timeout",
|
||||
"requiresRestart": true,
|
||||
"default": 10,
|
||||
"description": "Time after which an active device will be marked as offline in minutes"
|
||||
}
|
||||
}
|
||||
},
|
||||
"passive": {
|
||||
"type": "object",
|
||||
"title": "Passive",
|
||||
"requiresRestart": true,
|
||||
"description": "Options for passive devices (mostly battery powered)",
|
||||
"properties": {
|
||||
"timeout": {
|
||||
"type": "number",
|
||||
"title": "Timeout",
|
||||
"requiresRestart": true,
|
||||
"default": 1500,
|
||||
"description": "Time after which an passive device will be marked as offline in minutes"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"type": "object",
|
||||
"title": "Availability",
|
||||
"requiresRestart": true,
|
||||
"description": "Checks whether devices are online/offline"
|
||||
},
|
||||
"external_converters": {
|
||||
"type": "array",
|
||||
"title": "External converters",
|
||||
"description": "You can define external converters to e.g. add support for a DiY device",
|
||||
"requiresRestart": true,
|
||||
"items": {
|
||||
"type": "string"
|
||||
"description": "Checks whether devices are online/offline",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"title": "Enabled",
|
||||
"description": "Enable availability checks",
|
||||
"default": false,
|
||||
"requiresRestart": true
|
||||
},
|
||||
"active": {
|
||||
"type": "object",
|
||||
"title": "Active",
|
||||
"requiresRestart": true,
|
||||
"description": "Options for active devices (routers/mains powered)",
|
||||
"properties": {
|
||||
"timeout": {
|
||||
"type": "number",
|
||||
"title": "Timeout",
|
||||
"requiresRestart": true,
|
||||
"default": 10,
|
||||
"description": "Time after which an active device will be marked as offline in minutes"
|
||||
}
|
||||
},
|
||||
"required": ["timeout"]
|
||||
},
|
||||
"passive": {
|
||||
"type": "object",
|
||||
"title": "Passive",
|
||||
"requiresRestart": true,
|
||||
"description": "Options for passive devices (mostly battery powered)",
|
||||
"properties": {
|
||||
"timeout": {
|
||||
"type": "number",
|
||||
"title": "Timeout",
|
||||
"requiresRestart": true,
|
||||
"default": 1500,
|
||||
"description": "Time after which an passive device will be marked as offline in minutes"
|
||||
}
|
||||
},
|
||||
"required": ["timeout"]
|
||||
}
|
||||
},
|
||||
"examples": ["DIYRuZ_FreePad.js"]
|
||||
"required": []
|
||||
},
|
||||
"mqtt": {
|
||||
"type": "object",
|
||||
@@ -201,8 +184,17 @@
|
||||
"type": "boolean",
|
||||
"title": "Force disable retain",
|
||||
"requiresRestart": true,
|
||||
"description": "Disable retain for all send messages. ONLY enable if you MQTT broker doesn't support retained message (e.g. AWS IoT core, Azure IoT Hub, Google Cloud IoT core, IBM Watson IoT Platform). Enabling will break the Home Assistant integration",
|
||||
"description": "Disable retain for all send messages. ONLY enable if your MQTT broker doesn't support retained message (e.g. AWS IoT core, Azure IoT Hub, Google Cloud IoT core, IBM Watson IoT Platform). Enabling will break the Home Assistant integration",
|
||||
"default": false
|
||||
},
|
||||
"maximum_packet_size": {
|
||||
"type": "number",
|
||||
"title": "Maximum packet size",
|
||||
"requiresRestart": true,
|
||||
"description": "Specifies the maximum allowed packet length (in bytes) that the server can send to Zigbee2MQTT. NOTE: The same value exists in your MQTT broker but for the length the client can send to it instead.",
|
||||
"default": 1048576,
|
||||
"minimum": 20,
|
||||
"maximum": 268435456
|
||||
}
|
||||
},
|
||||
"required": ["server"]
|
||||
@@ -227,9 +219,8 @@
|
||||
},
|
||||
"adapter": {
|
||||
"type": ["string"],
|
||||
"enum": ["deconz", "zstack", "zigate", "ezsp", "auto", "ember", "zboss"],
|
||||
"enum": ["deconz", "zstack", "zigate", "ezsp", "ember", "zboss"],
|
||||
"title": "Adapter",
|
||||
"default": "auto",
|
||||
"requiresRestart": true,
|
||||
"description": "Adapter type, not needed unless you are experiencing problems"
|
||||
},
|
||||
@@ -337,83 +328,91 @@
|
||||
"description": "Zigbee devices may request a firmware update, and do so frequently, causing Zigbee2MQTT to reach out to third party servers. If you disable these device initiated checks, you can still initiate a firmware update check manually.",
|
||||
"default": false
|
||||
},
|
||||
"ikea_ota_use_test_url": {
|
||||
"type": "boolean",
|
||||
"title": "IKEA TRADFRI OTA use test url",
|
||||
"requiresRestart": true,
|
||||
"description": "Use IKEA TRADFRI OTA test server, see OTA updates documentation",
|
||||
"default": false
|
||||
},
|
||||
"zigbee_ota_override_index_location": {
|
||||
"type": ["string", "null"],
|
||||
"title": "OTA index override file name",
|
||||
"requiresRestart": true,
|
||||
"description": "Location of override OTA index file",
|
||||
"examples": ["index.json"]
|
||||
},
|
||||
"image_block_response_delay": {
|
||||
"type": "number",
|
||||
"title": "Image block response delay",
|
||||
"description": "Limits the rate of requests (in milliseconds) during OTA updates to reduce network congestion. You can increase this value if your network appears unstable during OTA.",
|
||||
"default": 250,
|
||||
"minimum": 50,
|
||||
"requiresRestart": true
|
||||
},
|
||||
"default_maximum_data_size": {
|
||||
"type": "number",
|
||||
"title": "Default maximum data size",
|
||||
"description": "The size of file chunks sent during an update (in bytes). Note: This value may get ignored for manufacturers that require specific values.",
|
||||
"default": 50,
|
||||
"minimum": 10,
|
||||
"maximum": 100,
|
||||
"requiresRestart": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"frontend": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "boolean",
|
||||
"title": "Frontend (simple)"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "Frontend (advanced)",
|
||||
"properties": {
|
||||
"port": {
|
||||
"type": "number",
|
||||
"title": "Port",
|
||||
"description": "Frontend binding port. Ignored when using a unix domain socket",
|
||||
"default": 8080,
|
||||
"requiresRestart": true
|
||||
},
|
||||
"host": {
|
||||
"type": ["string", "null"],
|
||||
"title": "Bind host",
|
||||
"description": "Frontend binding host. Binds to a unix socket when an absolute path is given instead.",
|
||||
"examples": ["127.0.0.1", "::1", "/run/zigbee2mqtt/zigbee2mqtt.sock"],
|
||||
"requiresRestart": true
|
||||
},
|
||||
"auth_token": {
|
||||
"type": ["string", "null"],
|
||||
"title": "Auth token",
|
||||
"description": "Enables authentication, disabled by default",
|
||||
"requiresRestart": true
|
||||
},
|
||||
"url": {
|
||||
"type": ["string", "null"],
|
||||
"title": "URL",
|
||||
"description": "URL on which the frontend can be reached, currently only used for the Home Assistant device configuration page",
|
||||
"requiresRestart": true
|
||||
},
|
||||
"ssl_cert": {
|
||||
"type": ["string", "null"],
|
||||
"title": "Certificate file path",
|
||||
"description": "SSL Certificate file path for exposing HTTPS. The sibling property 'ssl_key' must be set for HTTPS to be activated.",
|
||||
"requiresRestart": true
|
||||
},
|
||||
"ssl_key": {
|
||||
"type": ["string", "null"],
|
||||
"title": "key file path",
|
||||
"description": "SSL key file path for exposing HTTPS. The sibling property 'ssl_cert' must be set for HTTPS to be activated.",
|
||||
"requiresRestart": true
|
||||
},
|
||||
"base_url": {
|
||||
"type": "string",
|
||||
"pattern": "^\\/.*",
|
||||
"title": "Base URL",
|
||||
"description": "Base URL for the frontend. If hosted under a subpath, e.g. 'http://localhost:8080/z2m', set this to '/z2m'",
|
||||
"default": "/",
|
||||
"requiresRestart": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"type": "object",
|
||||
"title": "Frontend",
|
||||
"requiresRestart": true
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"title": "Enabled",
|
||||
"description": "Enable frontend",
|
||||
"default": false,
|
||||
"requiresRestart": true
|
||||
},
|
||||
"port": {
|
||||
"type": "number",
|
||||
"title": "Port",
|
||||
"description": "Frontend binding port. Ignored when using a unix domain socket",
|
||||
"default": 8080,
|
||||
"requiresRestart": true
|
||||
},
|
||||
"host": {
|
||||
"type": ["string", "null"],
|
||||
"title": "Bind host",
|
||||
"description": "Frontend binding host. Binds to a unix socket when an absolute path is given instead.",
|
||||
"examples": ["127.0.0.1", "::1", "/run/zigbee2mqtt/zigbee2mqtt.sock"],
|
||||
"requiresRestart": true
|
||||
},
|
||||
"auth_token": {
|
||||
"type": ["string", "null"],
|
||||
"title": "Auth token",
|
||||
"description": "Enables authentication, disabled by default",
|
||||
"requiresRestart": true
|
||||
},
|
||||
"url": {
|
||||
"type": ["string", "null"],
|
||||
"title": "URL",
|
||||
"description": "URL on which the frontend can be reached, currently only used for the Home Assistant device configuration page",
|
||||
"requiresRestart": true
|
||||
},
|
||||
"ssl_cert": {
|
||||
"type": ["string", "null"],
|
||||
"title": "Certificate file path",
|
||||
"description": "SSL Certificate file path for exposing HTTPS. The sibling property 'ssl_key' must be set for HTTPS to be activated.",
|
||||
"requiresRestart": true
|
||||
},
|
||||
"ssl_key": {
|
||||
"type": ["string", "null"],
|
||||
"title": "key file path",
|
||||
"description": "SSL key file path for exposing HTTPS. The sibling property 'ssl_cert' must be set for HTTPS to be activated.",
|
||||
"requiresRestart": true
|
||||
},
|
||||
"base_url": {
|
||||
"type": "string",
|
||||
"pattern": "^\\/.*",
|
||||
"title": "Base URL",
|
||||
"description": "Base URL for the frontend. If hosted under a subpath, e.g. 'http://localhost:8080/z2m', set this to '/z2m'",
|
||||
"default": "/",
|
||||
"requiresRestart": true
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
},
|
||||
"devices": {
|
||||
"type": "object",
|
||||
@@ -445,20 +444,6 @@
|
||||
"type": "object",
|
||||
"title": "Advanced",
|
||||
"properties": {
|
||||
"legacy_api": {
|
||||
"type": "boolean",
|
||||
"title": "Legacy API",
|
||||
"requiresRestart": true,
|
||||
"description": "Disables the legacy api (false = disable)",
|
||||
"default": true
|
||||
},
|
||||
"legacy_availability_payload": {
|
||||
"type": "boolean",
|
||||
"title": "Legacy availability payload",
|
||||
"requiresRestart": true,
|
||||
"description": "Payload to be used for device availability and bridge/state topics. true = text, false = JSON",
|
||||
"default": true
|
||||
},
|
||||
"log_rotation": {
|
||||
"type": "boolean",
|
||||
"title": "Log rotation",
|
||||
@@ -466,6 +451,13 @@
|
||||
"description": "Log rotation",
|
||||
"default": true
|
||||
},
|
||||
"log_console_json": {
|
||||
"type": "boolean",
|
||||
"title": "Console json log",
|
||||
"requiresRestart": true,
|
||||
"description": "Console json log",
|
||||
"default": false
|
||||
},
|
||||
"log_symlink_current": {
|
||||
"type": "boolean",
|
||||
"title": "Log symlink current",
|
||||
@@ -500,7 +492,7 @@
|
||||
},
|
||||
"log_level": {
|
||||
"type": "string",
|
||||
"enum": ["error", "warning", "info", "debug", "warn"],
|
||||
"enum": ["error", "warning", "info", "debug"],
|
||||
"title": "Log level",
|
||||
"description": "Logging level",
|
||||
"default": "info"
|
||||
@@ -533,6 +525,14 @@
|
||||
"default": "",
|
||||
"examples": ["^zhc:legacy:fz:(tuya|moes)", "^zhc:legacy:fz:(tuya|moes)|^zh:ember:uart:|^zh:controller"]
|
||||
},
|
||||
"log_directories_to_keep": {
|
||||
"type": "number",
|
||||
"title": "Number of past log folders to keep",
|
||||
"description": "Number of log directories to keep before deleting the oldest one",
|
||||
"default": 10,
|
||||
"minimum": 5,
|
||||
"maximum": 1000
|
||||
},
|
||||
"log_syslog": {
|
||||
"requiresRestart": true,
|
||||
"oneOf": [
|
||||
@@ -732,105 +732,8 @@
|
||||
"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)"
|
||||
},
|
||||
"homeassistant_discovery_topic": {
|
||||
"type": "string",
|
||||
"title": "Homeassistant discovery topic",
|
||||
"description": "Home Assistant discovery topic",
|
||||
"requiresRestart": true,
|
||||
"examples": ["homeassistant"]
|
||||
},
|
||||
"homeassistant_legacy_entity_attributes": {
|
||||
"type": "boolean",
|
||||
"title": "Home Assistant legacy entity attributes",
|
||||
"description": "Home Assistant legacy entity attributes, when enabled Zigbee2MQTT will add state attributes to each entity, additional to the separate entities and devices it already creates",
|
||||
"default": true
|
||||
},
|
||||
"homeassistant_status_topic": {
|
||||
"type": "string",
|
||||
"title": "Home Assistant status topic",
|
||||
"description": "Home Assistant status topic",
|
||||
"requiresRestart": true,
|
||||
"examples": ["homeassistant/status"]
|
||||
},
|
||||
"homeassistant_legacy_triggers": {
|
||||
"type": "boolean",
|
||||
"title": "Home Assistant legacy triggers",
|
||||
"description": "Home Assistant legacy triggers, when enabled Zigbee2mqt will send an empty 'action' or 'click' after one has been send. A 'sensor_action' and 'sensor_click' will be discovered",
|
||||
"default": true
|
||||
},
|
||||
"soft_reset_timeout": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"requiresRestart": true,
|
||||
"title": "Soft reset timeout (deprecated)",
|
||||
"description": "Soft reset ZNP after timeout",
|
||||
"readOnly": true
|
||||
},
|
||||
"report": {
|
||||
"type": "boolean",
|
||||
"title": "Reporting",
|
||||
"requiresRestart": true,
|
||||
"readOnly": true,
|
||||
"description": "Enables report feature (deprecated)"
|
||||
},
|
||||
"baudrate": {
|
||||
"type": "number",
|
||||
"title": "Baudrate (deprecated)",
|
||||
"requiresRestart": true,
|
||||
"description": "Baud rate speed for serial port, this can be anything firmware support but default is 115200 for Z-Stack and EZSP, 38400 for Deconz, however note that some EZSP firmware need 57600",
|
||||
"examples": [38400, 57600, 115200]
|
||||
},
|
||||
"rtscts": {
|
||||
"type": "boolean",
|
||||
"title": "RTS / CTS (deprecated)",
|
||||
"requiresRestart": true,
|
||||
"description": "RTS / CTS Hardware Flow Control for serial port"
|
||||
},
|
||||
"ikea_ota_use_test_url": {
|
||||
"type": "boolean",
|
||||
"title": "IKEA TRADFRI OTA use test url (deprecated)",
|
||||
"requiresRestart": true,
|
||||
"description": "Use IKEA TRADFRI OTA test server, see OTA updates documentation",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"experimental": {
|
||||
"type": "object",
|
||||
"title": "Experimental (deprecated)",
|
||||
"properties": {
|
||||
"transmit_power": {
|
||||
"type": ["number", "null"],
|
||||
"title": "Transmit power",
|
||||
"requiresRestart": true,
|
||||
"description": "Transmit power of adapter, only available for Z-Stack (CC253*/CC2652/CC1352) adapters, CC2652 = 5dbm, CC1352 max is = 20dbm (5dbm default)"
|
||||
},
|
||||
"output": {
|
||||
"type": "string",
|
||||
"enum": ["attribute_and_json", "attribute", "json"],
|
||||
"title": "MQTT output type",
|
||||
"description": "Examples when 'state' of a device is published json: topic: 'zigbee2mqtt/my_bulb' payload '{\"state\": \"ON\"}' attribute: topic 'zigbee2mqtt/my_bulb/state' payload 'ON' attribute_and_json: both json and attribute (see above)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"whitelist": {
|
||||
"readOnly": true,
|
||||
"type": "array",
|
||||
"requiresRestart": true,
|
||||
"title": "Whitelist (deprecated, use passlist)",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"ban": {
|
||||
"readOnly": true,
|
||||
"type": "array",
|
||||
"requiresRestart": true,
|
||||
"title": "Ban (deprecated, use blocklist)",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["mqtt"],
|
||||
@@ -865,10 +768,17 @@
|
||||
"title": "QoS",
|
||||
"description": "QoS level for MQTT messages of this device"
|
||||
},
|
||||
"throttle": {
|
||||
"type": "number",
|
||||
"title": "Throttle",
|
||||
"description": "The minimum time between payloads in seconds. Payloads received whilst the device is being throttled will be discarded",
|
||||
"requiresRestart": true
|
||||
},
|
||||
"debounce": {
|
||||
"type": "number",
|
||||
"title": "Debounce",
|
||||
"description": "Debounces messages of this device"
|
||||
"description": "Debounces messages of this device",
|
||||
"requiresRestart": true
|
||||
},
|
||||
"debounce_ignore": {
|
||||
"type": "array",
|
||||
@@ -915,7 +825,7 @@
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"title": "Icon",
|
||||
"description": "The user-defined device icon for the frontend. It can be a full URL link to an image (e.g. https://SOME.SITE/MODEL123.jpg) (you cannot use a path to a local file) or base64 encoded data URL (e.g. image/svg+xml;base64,PHN2ZyB3aW....R0aD)"
|
||||
"description": "The user-defined device icon for the frontend. It can be a full URL link to an image (e.g. https://SOME.SITE/MODEL123.jpg) or a path to a local file inside the `device_icons` directory."
|
||||
},
|
||||
"homeassistant": {
|
||||
"type": ["object", "null"],
|
||||
@@ -940,12 +850,6 @@
|
||||
"retain": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"devices": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"optimistic": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
+86
-251
@@ -1,4 +1,4 @@
|
||||
import path from 'path';
|
||||
import path from 'node:path';
|
||||
|
||||
import Ajv, {ValidateFunction} from 'ajv';
|
||||
import objectAssignDeep from 'object-assign-deep';
|
||||
@@ -8,33 +8,16 @@ import schemaJson from './settings.schema.json';
|
||||
import utils from './utils';
|
||||
import yaml, {YAMLFileException} from './yaml';
|
||||
|
||||
export let schema: KeyValue = schemaJson;
|
||||
|
||||
schema = {};
|
||||
objectAssignDeep(schema, schemaJson);
|
||||
|
||||
// Remove legacy settings from schema
|
||||
{
|
||||
delete schema.properties.advanced.properties.homeassistant_discovery_topic;
|
||||
delete schema.properties.advanced.properties.homeassistant_legacy_entity_attributes;
|
||||
delete schema.properties.advanced.properties.homeassistant_legacy_triggers;
|
||||
delete schema.properties.advanced.properties.homeassistant_status_topic;
|
||||
delete schema.properties.advanced.properties.soft_reset_timeout;
|
||||
delete schema.properties.advanced.properties.report;
|
||||
delete schema.properties.advanced.properties.baudrate;
|
||||
delete schema.properties.advanced.properties.rtscts;
|
||||
delete schema.properties.advanced.properties.ikea_ota_use_test_url;
|
||||
delete schema.properties.experimental;
|
||||
delete (schemaJson as KeyValue).properties.whitelist;
|
||||
delete (schemaJson as KeyValue).properties.ban;
|
||||
}
|
||||
|
||||
export {schemaJson};
|
||||
// When updating also update:
|
||||
// - https://github.com/Koenkk/zigbee2mqtt/blob/dev/data/configuration.example.yaml#L2
|
||||
// - https://github.com/zigbee2mqtt/hassio-zigbee2mqtt/blob/master/common/rootfs/docker-entrypoint.sh#L54
|
||||
export const CURRENT_VERSION = 4;
|
||||
/** NOTE: by order of priority, lower index is lower level (more important) */
|
||||
export const LOG_LEVELS: readonly string[] = ['error', 'warning', 'info', 'debug'] as const;
|
||||
export type LogLevel = (typeof LOG_LEVELS)[number];
|
||||
export type LogLevel = 'error' | 'warning' | 'info' | 'debug';
|
||||
|
||||
// DEPRECATED ZIGBEE2MQTT_CONFIG: https://github.com/Koenkk/zigbee2mqtt/issues/4697
|
||||
const file = process.env.ZIGBEE2MQTT_CONFIG ?? data.joinPath('configuration.yaml');
|
||||
const CONFIG_FILE_PATH = data.joinPath('configuration.yaml');
|
||||
const NULLABLE_SETTINGS = ['homeassistant'];
|
||||
const ajvSetting = new Ajv({allErrors: true}).addKeyword('requiresRestart').compile(schemaJson);
|
||||
const ajvRestartRequired = new Ajv({allErrors: true}).addKeyword({keyword: 'requiresRestart', validate: (s: unknown) => !s}).compile(schemaJson);
|
||||
@@ -44,13 +27,30 @@ const ajvRestartRequiredDeviceOptions = new Ajv({allErrors: true})
|
||||
const ajvRestartRequiredGroupOptions = new Ajv({allErrors: true})
|
||||
.addKeyword({keyword: 'requiresRestart', validate: (s: unknown) => !s})
|
||||
.compile(schemaJson.definitions.group);
|
||||
const defaults: RecursivePartial<Settings> = {
|
||||
permit_join: false,
|
||||
external_converters: [],
|
||||
export const defaults: RecursivePartial<Settings> = {
|
||||
homeassistant: {
|
||||
enabled: false,
|
||||
discovery_topic: 'homeassistant',
|
||||
status_topic: 'homeassistant/status',
|
||||
legacy_action_sensor: false,
|
||||
experimental_event_entities: false,
|
||||
},
|
||||
availability: {
|
||||
enabled: false,
|
||||
active: {timeout: 10},
|
||||
passive: {timeout: 1500},
|
||||
},
|
||||
frontend: {
|
||||
enabled: false,
|
||||
port: 8080,
|
||||
base_url: '/',
|
||||
},
|
||||
mqtt: {
|
||||
base_topic: 'zigbee2mqtt',
|
||||
include_device_information: false,
|
||||
force_disable_retain: false,
|
||||
// 1MB = roughly 3.5KB per device * 300 devices for `/bridge/devices`
|
||||
maximum_packet_size: 1048576,
|
||||
},
|
||||
serial: {
|
||||
disable_led: false,
|
||||
@@ -80,21 +80,23 @@ const defaults: RecursivePartial<Settings> = {
|
||||
ota: {
|
||||
update_check_interval: 24 * 60,
|
||||
disable_automatic_update_check: false,
|
||||
image_block_response_delay: 250,
|
||||
default_maximum_data_size: 50,
|
||||
},
|
||||
device_options: {},
|
||||
advanced: {
|
||||
legacy_api: true,
|
||||
legacy_availability_payload: true,
|
||||
log_rotation: true,
|
||||
log_console_json: false,
|
||||
log_symlink_current: false,
|
||||
log_output: ['console', 'file'],
|
||||
log_directory: path.join(data.getPath(), 'log', '%TIMESTAMP%'),
|
||||
log_file: 'log.log',
|
||||
log_level: /* istanbul ignore next */ process.env.DEBUG ? 'debug' : 'info',
|
||||
log_level: /* v8 ignore next */ process.env.DEBUG ? 'debug' : 'info',
|
||||
log_namespaced_levels: {},
|
||||
log_syslog: {},
|
||||
log_debug_to_mqtt_frontend: false,
|
||||
log_debug_namespace_ignore: '',
|
||||
log_directories_to_keep: 10,
|
||||
pan_id: 0x1a62,
|
||||
ext_pan_id: [0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd],
|
||||
channel: 11,
|
||||
@@ -108,13 +110,6 @@ const defaults: RecursivePartial<Settings> = {
|
||||
network_key: [1, 3, 5, 7, 9, 11, 13, 15, 0, 2, 4, 6, 8, 10, 12, 13],
|
||||
timestamp_format: 'YYYY-MM-DD HH:mm:ss',
|
||||
output: 'json',
|
||||
// Everything below is deprecated
|
||||
availability_blocklist: [],
|
||||
availability_passlist: [],
|
||||
availability_blacklist: [],
|
||||
availability_whitelist: [],
|
||||
soft_reset_timeout: 0,
|
||||
report: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -125,7 +120,8 @@ function loadSettingsWithDefaults(): void {
|
||||
if (!_settings) {
|
||||
_settings = read();
|
||||
}
|
||||
_settingsWithDefaults = objectAssignDeep({}, defaults, getInternalSettings()) as Settings;
|
||||
|
||||
_settingsWithDefaults = objectAssignDeep({}, defaults, getPersistedSettings()) as Settings;
|
||||
|
||||
if (!_settingsWithDefaults.devices) {
|
||||
_settingsWithDefaults.devices = {};
|
||||
@@ -134,95 +130,6 @@ function loadSettingsWithDefaults(): void {
|
||||
if (!_settingsWithDefaults.groups) {
|
||||
_settingsWithDefaults.groups = {};
|
||||
}
|
||||
|
||||
if (_settingsWithDefaults.homeassistant) {
|
||||
const defaults = {discovery_topic: 'homeassistant', status_topic: 'hass/status', legacy_entity_attributes: true, legacy_triggers: true};
|
||||
const sLegacy = {};
|
||||
if (_settingsWithDefaults.advanced) {
|
||||
for (const key of [
|
||||
'homeassistant_legacy_triggers',
|
||||
'homeassistant_discovery_topic',
|
||||
'homeassistant_legacy_entity_attributes',
|
||||
'homeassistant_status_topic',
|
||||
]) {
|
||||
// @ts-expect-error ignore typing
|
||||
if (_settingsWithDefaults.advanced[key] !== undefined) {
|
||||
// @ts-expect-error ignore typing
|
||||
sLegacy[key.replace('homeassistant_', '')] = _settingsWithDefaults.advanced[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const s = typeof _settingsWithDefaults.homeassistant === 'object' ? _settingsWithDefaults.homeassistant : {};
|
||||
// @ts-expect-error ignore typing
|
||||
_settingsWithDefaults.homeassistant = {};
|
||||
// @ts-expect-error ignore typing
|
||||
objectAssignDeep(_settingsWithDefaults.homeassistant, defaults, sLegacy, s);
|
||||
}
|
||||
|
||||
if (_settingsWithDefaults.availability || _settingsWithDefaults.advanced?.availability_timeout) {
|
||||
const defaults = {};
|
||||
const s = typeof _settingsWithDefaults.availability === 'object' ? _settingsWithDefaults.availability : {};
|
||||
// @ts-expect-error ignore typing
|
||||
_settingsWithDefaults.availability = {};
|
||||
// @ts-expect-error ignore typing
|
||||
objectAssignDeep(_settingsWithDefaults.availability, defaults, s);
|
||||
}
|
||||
|
||||
if (_settingsWithDefaults.frontend) {
|
||||
const defaults = {port: 8080, auth_token: null, base_url: '/'};
|
||||
const s = typeof _settingsWithDefaults.frontend === 'object' ? _settingsWithDefaults.frontend : {};
|
||||
// @ts-expect-error ignore typing
|
||||
_settingsWithDefaults.frontend = {};
|
||||
// @ts-expect-error ignore typing
|
||||
objectAssignDeep(_settingsWithDefaults.frontend, defaults, s);
|
||||
}
|
||||
|
||||
// @ts-expect-error ignore typing
|
||||
if (_settings.advanced?.baudrate !== undefined && _settings.serial?.baudrate == null) {
|
||||
// @ts-expect-error ignore typing
|
||||
_settingsWithDefaults.serial.baudrate = _settings.advanced.baudrate;
|
||||
}
|
||||
|
||||
// @ts-expect-error ignore typing
|
||||
if (_settings.advanced?.rtscts !== undefined && _settings.serial?.rtscts == null) {
|
||||
// @ts-expect-error ignore typing
|
||||
_settingsWithDefaults.serial.rtscts = _settings.advanced.rtscts;
|
||||
}
|
||||
|
||||
// @ts-expect-error ignore typing
|
||||
if (_settings.advanced?.ikea_ota_use_test_url !== undefined && _settings.ota?.ikea_ota_use_test_url == null) {
|
||||
// @ts-expect-error ignore typing
|
||||
_settingsWithDefaults.ota.ikea_ota_use_test_url = _settings.advanced.ikea_ota_use_test_url;
|
||||
}
|
||||
|
||||
// @ts-expect-error ignore typing
|
||||
if (_settings.experimental?.transmit_power !== undefined && _settings.advanced?.transmit_power == null) {
|
||||
// @ts-expect-error ignore typing
|
||||
_settingsWithDefaults.advanced.transmit_power = _settings.experimental.transmit_power;
|
||||
}
|
||||
|
||||
// @ts-expect-error ignore typing
|
||||
if (_settings.experimental?.output !== undefined && _settings.advanced?.output == null) {
|
||||
// @ts-expect-error ignore typing
|
||||
_settingsWithDefaults.advanced.output = _settings.experimental.output;
|
||||
}
|
||||
|
||||
if (_settings.advanced?.log_level === 'warn') {
|
||||
_settingsWithDefaults.advanced.log_level = 'warning';
|
||||
}
|
||||
|
||||
// @ts-expect-error ignore typing
|
||||
if (_settingsWithDefaults.ban) {
|
||||
// @ts-expect-error ignore typing
|
||||
_settingsWithDefaults.blocklist.push(..._settingsWithDefaults.ban);
|
||||
}
|
||||
|
||||
// @ts-expect-error ignore typing
|
||||
if (_settingsWithDefaults.whitelist) {
|
||||
// @ts-expect-error ignore typing
|
||||
_settingsWithDefaults.passlist.push(..._settingsWithDefaults.whitelist);
|
||||
}
|
||||
}
|
||||
|
||||
function parseValueRef(text: string): {filename: string; key: string} | null {
|
||||
@@ -240,11 +147,11 @@ function parseValueRef(text: string): {filename: string; key: string} | null {
|
||||
}
|
||||
|
||||
function write(): void {
|
||||
const settings = getInternalSettings();
|
||||
const settings = getPersistedSettings();
|
||||
const toWrite: KeyValue = objectAssignDeep({}, settings);
|
||||
|
||||
// Read settings to check if we have to split devices/groups into separate file.
|
||||
const actual = yaml.read(file);
|
||||
const actual = yaml.read(CONFIG_FILE_PATH);
|
||||
|
||||
// In case the setting is defined in a separate file (e.g. !secret network_key) update it there.
|
||||
for (const path of [
|
||||
@@ -286,16 +193,16 @@ function write(): void {
|
||||
|
||||
writeDevicesOrGroups('devices');
|
||||
writeDevicesOrGroups('groups');
|
||||
|
||||
yaml.writeIfChanged(file, toWrite);
|
||||
yaml.writeIfChanged(CONFIG_FILE_PATH, toWrite);
|
||||
|
||||
_settings = read();
|
||||
|
||||
loadSettingsWithDefaults();
|
||||
}
|
||||
|
||||
export function validate(): string[] {
|
||||
try {
|
||||
getInternalSettings();
|
||||
getPersistedSettings();
|
||||
} catch (error) {
|
||||
if (error instanceof YAMLFileException) {
|
||||
return [`Your YAML file: '${error.file}' is invalid (use https://jsonformatter.org/yaml-validator to find and fix the issue)`];
|
||||
@@ -344,12 +251,16 @@ export function validate(): string[] {
|
||||
if (names.includes(e.friendly_name)) errors.push(`Duplicate friendly_name '${e.friendly_name}' found`);
|
||||
errors.push(...utils.validateFriendlyName(e.friendly_name));
|
||||
names.push(e.friendly_name);
|
||||
if ('icon' in e && e.icon && !e.icon.startsWith('http://') && !e.icon.startsWith('https://') && !e.icon.startsWith('device_icons/')) {
|
||||
errors.push(`Device icon of '${e.friendly_name}' should start with 'device_icons/', got '${e.icon}'`);
|
||||
}
|
||||
if (e.qos != null && ![0, 1, 2].includes(e.qos)) {
|
||||
errors.push(`QOS for '${e.friendly_name}' not valid, should be 0, 1 or 2 got ${e.qos}`);
|
||||
}
|
||||
};
|
||||
|
||||
const settingsWithDefaults = get();
|
||||
|
||||
Object.values(settingsWithDefaults.devices).forEach((d) => check(d));
|
||||
Object.values(settingsWithDefaults.groups).forEach((g) => check(g));
|
||||
|
||||
@@ -361,24 +272,11 @@ export function validate(): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
const checkAvailabilityList = (list: string[], type: string): void => {
|
||||
list.forEach((e) => {
|
||||
if (!getDevice(e)) {
|
||||
errors.push(`Non-existing entity '${e}' specified in '${type}'`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
checkAvailabilityList(settingsWithDefaults.advanced.availability_blacklist, 'availability_blacklist');
|
||||
checkAvailabilityList(settingsWithDefaults.advanced.availability_whitelist, 'availability_whitelist');
|
||||
checkAvailabilityList(settingsWithDefaults.advanced.availability_blocklist, 'availability_blocklist');
|
||||
checkAvailabilityList(settingsWithDefaults.advanced.availability_passlist, 'availability_passlist');
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function read(): Settings {
|
||||
const s = yaml.read(file) as Settings;
|
||||
function read(): Partial<Settings> {
|
||||
const s = yaml.read(CONFIG_FILE_PATH) as Partial<Settings>;
|
||||
applyEnvironmentVariables(s);
|
||||
|
||||
// Read !secret MQTT username and password if set
|
||||
@@ -419,7 +317,7 @@ function read(): Settings {
|
||||
s[type] = {};
|
||||
for (const file of files) {
|
||||
const content = yaml.readIfExists(data.joinPath(file));
|
||||
/* eslint-disable-line */ // @ts-expect-error
|
||||
// @ts-expect-error noMutate not typed properly
|
||||
s[type] = objectAssignDeep.noMutate(s[type], content);
|
||||
}
|
||||
}
|
||||
@@ -451,23 +349,21 @@ function applyEnvironmentVariables(settings: Partial<Settings>): void {
|
||||
|
||||
if (type.indexOf('object') >= 0 || type.indexOf('array') >= 0) {
|
||||
try {
|
||||
// @ts-expect-error ignore typing
|
||||
setting[key] = JSON.parse(envVariable);
|
||||
setting[key as keyof Settings] = JSON.parse(envVariable);
|
||||
} catch {
|
||||
// @ts-expect-error ignore typing
|
||||
setting[key] = envVariable;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setting[key as keyof Settings] = envVariable as any;
|
||||
}
|
||||
} else if (type.indexOf('number') >= 0) {
|
||||
// @ts-expect-error ignore typing
|
||||
setting[key] = (envVariable as unknown as number) * 1;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setting[key as keyof Settings] = ((envVariable as unknown as number) * 1) as any;
|
||||
} else if (type.indexOf('boolean') >= 0) {
|
||||
// @ts-expect-error ignore typing
|
||||
setting[key] = envVariable.toLowerCase() === 'true';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setting[key as keyof Settings] = (envVariable.toLowerCase() === 'true') as any;
|
||||
} else {
|
||||
/* istanbul ignore else */
|
||||
if (type.indexOf('string') >= 0) {
|
||||
// @ts-expect-error ignore typing
|
||||
setting[key] = envVariable;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setting[key as keyof Settings] = envVariable as any;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -489,7 +385,12 @@ function applyEnvironmentVariables(settings: Partial<Settings>): void {
|
||||
iterate(schemaJson.properties, []);
|
||||
}
|
||||
|
||||
function getInternalSettings(): Partial<Settings> {
|
||||
/**
|
||||
* Get the settings actually written in the yaml.
|
||||
* Env vars are applied on top.
|
||||
* Defaults merged on startup are not included.
|
||||
*/
|
||||
export function getPersistedSettings(): Partial<Settings> {
|
||||
if (!_settings) {
|
||||
_settings = read();
|
||||
}
|
||||
@@ -507,7 +408,7 @@ export function get(): Settings {
|
||||
|
||||
export function set(path: string[], value: string | number | boolean | KeyValue): void {
|
||||
/* eslint-disable-next-line */
|
||||
let settings: any = getInternalSettings();
|
||||
let settings: any = getPersistedSettings();
|
||||
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const key = path[i];
|
||||
@@ -525,16 +426,21 @@ export function set(path: string[], value: string | number | boolean | KeyValue)
|
||||
write();
|
||||
}
|
||||
|
||||
export function apply(settings: Record<string, unknown>): boolean {
|
||||
getInternalSettings(); // Ensure _settings is initialized.
|
||||
// @ts-expect-error ignore typing
|
||||
export function apply(settings: Record<string, unknown>, throwOnError: boolean = true): boolean {
|
||||
getPersistedSettings(); // Ensure _settings is initialized.
|
||||
// @ts-expect-error noMutate not typed properly
|
||||
const newSettings = objectAssignDeep.noMutate(_settings, settings);
|
||||
|
||||
utils.removeNullPropertiesFromObject(newSettings, NULLABLE_SETTINGS);
|
||||
ajvSetting(newSettings);
|
||||
const errors = ajvSetting.errors && ajvSetting.errors.filter((e) => e.keyword !== 'required');
|
||||
if (errors?.length) {
|
||||
const error = errors[0];
|
||||
throw new Error(`${error.instancePath.substring(1)} ${error.message}`);
|
||||
|
||||
if (throwOnError) {
|
||||
const errors = ajvSetting.errors && ajvSetting.errors.filter((e) => e.keyword !== 'required');
|
||||
|
||||
if (errors?.length) {
|
||||
const error = errors[0];
|
||||
throw new Error(`${error.instancePath.substring(1)} ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
_settings = newSettings;
|
||||
@@ -552,26 +458,18 @@ export function getGroup(IDorName: string | number): GroupOptions | undefined {
|
||||
const byID = settings.groups[IDorName];
|
||||
|
||||
if (byID) {
|
||||
return {devices: [], ...byID, ID: Number(IDorName)};
|
||||
return {...byID, ID: Number(IDorName)};
|
||||
}
|
||||
|
||||
for (const [ID, group] of Object.entries(settings.groups)) {
|
||||
if (group.friendly_name === IDorName) {
|
||||
return {devices: [], ...group, ID: Number(ID)};
|
||||
return {...group, ID: Number(ID)};
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getGroups(): GroupOptions[] {
|
||||
const settings = get();
|
||||
|
||||
return Object.entries(settings.groups).map(([ID, group]) => {
|
||||
return {devices: [], ...group, ID: Number(ID)};
|
||||
});
|
||||
}
|
||||
|
||||
function getGroupThrowIfNotExists(IDorName: string): GroupOptions {
|
||||
const group = getGroup(IDorName);
|
||||
|
||||
@@ -613,7 +511,7 @@ export function addDevice(ID: string): DeviceOptionsWithId {
|
||||
throw new Error(`Device '${ID}' already exists`);
|
||||
}
|
||||
|
||||
const settings = getInternalSettings();
|
||||
const settings = getPersistedSettings();
|
||||
|
||||
if (!settings.devices) {
|
||||
settings.devices = {};
|
||||
@@ -625,22 +523,8 @@ export function addDevice(ID: string): DeviceOptionsWithId {
|
||||
return getDevice(ID)!; // valid from creation above
|
||||
}
|
||||
|
||||
export function addDeviceToPasslist(ID: string): void {
|
||||
const settings = getInternalSettings();
|
||||
if (!settings.passlist) {
|
||||
settings.passlist = [];
|
||||
}
|
||||
|
||||
if (settings.passlist.includes(ID)) {
|
||||
throw new Error(`Device '${ID}' already in passlist`);
|
||||
}
|
||||
|
||||
settings.passlist.push(ID);
|
||||
write();
|
||||
}
|
||||
|
||||
export function blockDevice(ID: string): void {
|
||||
const settings = getInternalSettings();
|
||||
const settings = getPersistedSettings();
|
||||
if (!settings.blocklist) {
|
||||
settings.blocklist = [];
|
||||
}
|
||||
@@ -651,18 +535,8 @@ export function blockDevice(ID: string): void {
|
||||
|
||||
export function removeDevice(IDorName: string): void {
|
||||
const device = getDeviceThrowIfNotExists(IDorName);
|
||||
const settings = getInternalSettings();
|
||||
const settings = getPersistedSettings();
|
||||
delete settings.devices?.[device.ID];
|
||||
|
||||
// Remove device from groups
|
||||
if (settings.groups) {
|
||||
const regex = new RegExp(`^(${device.friendly_name}|${device.ID})(/[^/]+)?$`);
|
||||
|
||||
for (const group of Object.values(settings.groups).filter((g) => g.devices)) {
|
||||
group.devices = group.devices?.filter((device) => !device.match(regex));
|
||||
}
|
||||
}
|
||||
|
||||
write();
|
||||
}
|
||||
|
||||
@@ -673,7 +547,7 @@ export function addGroup(name: string, ID?: string): GroupOptions {
|
||||
throw new Error(`friendly_name '${name}' is already in use`);
|
||||
}
|
||||
|
||||
const settings = getInternalSettings();
|
||||
const settings = getPersistedSettings();
|
||||
if (!settings.groups) {
|
||||
settings.groups = {};
|
||||
}
|
||||
@@ -700,56 +574,16 @@ export function addGroup(name: string, ID?: string): GroupOptions {
|
||||
return getGroup(ID)!; // valid from creation above
|
||||
}
|
||||
|
||||
function groupGetDevice(group: {devices?: string[]}, keys: string[]): string | undefined {
|
||||
for (const device of group.devices ?? []) {
|
||||
if (keys.includes(device)) {
|
||||
return device;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function addDeviceToGroup(IDorName: string, keys: string[]): void {
|
||||
const groupID = getGroupThrowIfNotExists(IDorName).ID!;
|
||||
const settings = getInternalSettings();
|
||||
|
||||
const group = settings.groups![groupID];
|
||||
|
||||
if (!groupGetDevice(group, keys)) {
|
||||
if (!group.devices) group.devices = [];
|
||||
group.devices.push(keys[0]);
|
||||
write();
|
||||
}
|
||||
}
|
||||
|
||||
export function removeDeviceFromGroup(IDorName: string, keys: string[]): void {
|
||||
const groupID = getGroupThrowIfNotExists(IDorName).ID!;
|
||||
const settings = getInternalSettings();
|
||||
const group = settings.groups![groupID];
|
||||
|
||||
if (!group.devices) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = groupGetDevice(group, keys);
|
||||
|
||||
if (key) {
|
||||
group.devices = group.devices.filter((d) => d != key);
|
||||
write();
|
||||
}
|
||||
}
|
||||
|
||||
export function removeGroup(IDorName: string | number): void {
|
||||
const groupID = getGroupThrowIfNotExists(IDorName.toString()).ID!;
|
||||
const settings = getInternalSettings();
|
||||
const settings = getPersistedSettings();
|
||||
|
||||
delete settings.groups![groupID];
|
||||
write();
|
||||
}
|
||||
|
||||
export function changeEntityOptions(IDorName: string, newOptions: KeyValue): boolean {
|
||||
const settings = getInternalSettings();
|
||||
const settings = getPersistedSettings();
|
||||
delete newOptions.friendly_name;
|
||||
delete newOptions.devices;
|
||||
let validator: ValidateFunction;
|
||||
@@ -785,7 +619,7 @@ export function changeFriendlyName(IDorName: string, newName: string): void {
|
||||
throw new Error(`friendly_name '${newName}' is already in use`);
|
||||
}
|
||||
|
||||
const settings = getInternalSettings();
|
||||
const settings = getPersistedSettings();
|
||||
const device = getDevice(IDorName);
|
||||
|
||||
if (device) {
|
||||
@@ -805,7 +639,7 @@ export function changeFriendlyName(IDorName: string, newName: string): void {
|
||||
|
||||
export function reRead(): void {
|
||||
_settings = undefined;
|
||||
getInternalSettings();
|
||||
getPersistedSettings();
|
||||
_settingsWithDefaults = undefined;
|
||||
get();
|
||||
}
|
||||
@@ -817,4 +651,5 @@ export const testing = {
|
||||
_settingsWithDefaults = undefined;
|
||||
},
|
||||
defaults,
|
||||
CURRENT_VERSION,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,586 @@
|
||||
import {copyFileSync, writeFileSync} from 'node:fs';
|
||||
|
||||
import data from './data';
|
||||
import * as settings from './settings';
|
||||
import utils from './utils';
|
||||
|
||||
interface SettingsMigration {
|
||||
path: string[];
|
||||
note: string;
|
||||
noteIf?: (previousValue: unknown) => boolean;
|
||||
}
|
||||
|
||||
interface SettingsAdd extends Omit<SettingsMigration, 'noteIf'> {
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
type SettingsRemove = SettingsMigration;
|
||||
|
||||
interface SettingsChange extends SettingsMigration {
|
||||
previousValueAnyOf?: unknown[];
|
||||
newValue: unknown;
|
||||
}
|
||||
|
||||
interface SettingsTransfer extends SettingsMigration {
|
||||
newPath: string[];
|
||||
}
|
||||
|
||||
interface SettingsCustomHandler extends Omit<SettingsMigration, 'path'> {
|
||||
execute: (currentSettings: Partial<Settings>) => [validPath: boolean, previousValue: unknown, changed: boolean];
|
||||
}
|
||||
|
||||
const SUPPORTED_VERSIONS: Settings['version'][] = [undefined, 2, 3, settings.CURRENT_VERSION];
|
||||
|
||||
function backupSettings(version: number): void {
|
||||
const filePath = data.joinPath('configuration.yaml');
|
||||
|
||||
copyFileSync(filePath, filePath.replace('.yaml', `_backup_v${version}.yaml`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the given path in given settings to given value. If requested, create path.
|
||||
*
|
||||
* @param currentSettings
|
||||
* @param path
|
||||
* @param value
|
||||
* @param createPathIfNotExist
|
||||
* @returns Returns true if value was set, false if not.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function setValue(currentSettings: any, path: string[], value: unknown, createPathIfNotExist: boolean = false): boolean {
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const key = path[i];
|
||||
|
||||
if (i === path.length - 1) {
|
||||
currentSettings[key] = value;
|
||||
} else {
|
||||
if (!currentSettings[key]) {
|
||||
if (createPathIfNotExist) {
|
||||
currentSettings[key] = {};
|
||||
/* v8 ignore start */
|
||||
} else {
|
||||
// invalid path
|
||||
// ignored in test since currently call is always guarded by get-validated path, so this is never reached
|
||||
return false;
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
|
||||
currentSettings = currentSettings[key];
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value at the given path in given settings.
|
||||
*
|
||||
* @param currentSettings
|
||||
* @param path
|
||||
* @returns
|
||||
* - true if path was valid
|
||||
* - the value at path
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function getValue(currentSettings: any, path: string[]): [validPath: boolean, value: unknown] {
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const key = path[i];
|
||||
const value = currentSettings[key];
|
||||
|
||||
if (i === path.length - 1) {
|
||||
return [value !== undefined, value];
|
||||
} else {
|
||||
if (!value) {
|
||||
// invalid path
|
||||
break;
|
||||
}
|
||||
|
||||
currentSettings = value;
|
||||
}
|
||||
}
|
||||
|
||||
return [false, undefined];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a value at given path, path is created as needed.
|
||||
* @param currentSettings
|
||||
* @param addition
|
||||
*/
|
||||
function addValue(currentSettings: Partial<Settings>, addition: SettingsAdd): void {
|
||||
setValue(currentSettings, addition.path, addition.value, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove value at given path, if path is valid.
|
||||
* Value is actually set to undefined, which triggers removal when `settings.apply` is called.
|
||||
* @param currentSettings
|
||||
* @param removal
|
||||
* @returns
|
||||
*/
|
||||
function removeValue(currentSettings: Partial<Settings>, removal: SettingsRemove): [validPath: boolean, previousValue: unknown] {
|
||||
const [validPath, previousValue] = getValue(currentSettings, removal.path);
|
||||
|
||||
if (validPath && previousValue != undefined) {
|
||||
setValue(currentSettings, removal.path, undefined);
|
||||
}
|
||||
|
||||
return [validPath, previousValue];
|
||||
}
|
||||
|
||||
/**
|
||||
* Change value at given path, if path is valid, and value matched one of the defined values (if any).
|
||||
* @param currentSettings
|
||||
* @param change
|
||||
* @returns
|
||||
*/
|
||||
function changeValue(currentSettings: Partial<Settings>, change: SettingsChange): [validPath: boolean, previousValue: unknown, changed: boolean] {
|
||||
const [validPath, previousValue] = getValue(currentSettings, change.path);
|
||||
let changed: boolean = false;
|
||||
|
||||
if (validPath && previousValue !== change.newValue) {
|
||||
if (!change.previousValueAnyOf || change.previousValueAnyOf.includes(previousValue)) {
|
||||
setValue(currentSettings, change.path, change.newValue);
|
||||
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return [validPath, previousValue, changed];
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfer value at given path, to new path.
|
||||
* Given path must be valid.
|
||||
* New path must not be valid or new path value must be nullish, otherwise given path is removed only.
|
||||
* Value at given path is actually set to undefined, which triggers removal when `settings.apply` is called.
|
||||
* New path is created as needed.
|
||||
* @param currentSettings
|
||||
* @param transfer
|
||||
* @returns
|
||||
*/
|
||||
function transferValue(
|
||||
currentSettings: Partial<Settings>,
|
||||
transfer: SettingsTransfer,
|
||||
): [validPath: boolean, previousValue: unknown, transfered: boolean] {
|
||||
const [validPath, previousValue] = getValue(currentSettings, transfer.path);
|
||||
const [destValidPath, destValue] = getValue(currentSettings, transfer.newPath);
|
||||
const transfered = validPath && previousValue != undefined && (!destValidPath || destValue == undefined || Array.isArray(destValue));
|
||||
|
||||
// no point in set if already undefined
|
||||
if (validPath && previousValue != undefined) {
|
||||
setValue(currentSettings, transfer.path, undefined);
|
||||
}
|
||||
|
||||
if (transfered) {
|
||||
if (Array.isArray(previousValue) && Array.isArray(destValue)) {
|
||||
setValue(currentSettings, transfer.newPath, [...previousValue, ...destValue], true);
|
||||
} else {
|
||||
setValue(currentSettings, transfer.newPath, previousValue, true);
|
||||
}
|
||||
}
|
||||
|
||||
return [validPath, previousValue, transfered];
|
||||
}
|
||||
|
||||
const noteIfWasTrue = (previousValue: unknown): boolean => previousValue === true;
|
||||
const noteIfWasDefined = (previousValue: unknown): boolean => previousValue != undefined;
|
||||
const noteIfWasNonEmptyArray = (previousValue: unknown): boolean => Array.isArray(previousValue) && previousValue.length > 0;
|
||||
|
||||
function migrateToTwo(
|
||||
currentSettings: Partial<Settings>,
|
||||
transfers: SettingsTransfer[],
|
||||
changes: SettingsChange[],
|
||||
additions: SettingsAdd[],
|
||||
removals: SettingsRemove[],
|
||||
customHandlers: SettingsCustomHandler[],
|
||||
): void {
|
||||
transfers.push(
|
||||
{
|
||||
path: ['advanced', 'homeassistant_discovery_topic'],
|
||||
note: `HA discovery_topic was moved from advanced.homeassistant_discovery_topic to homeassistant.discovery_topic.`,
|
||||
noteIf: noteIfWasDefined,
|
||||
newPath: ['homeassistant', 'discovery_topic'],
|
||||
},
|
||||
{
|
||||
path: ['advanced', 'homeassistant_status_topic'],
|
||||
note: `HA status_topic was moved from advanced.homeassistant_status_topic to homeassistant.status_topic.`,
|
||||
noteIf: noteIfWasDefined,
|
||||
newPath: ['homeassistant', 'status_topic'],
|
||||
},
|
||||
{
|
||||
path: ['advanced', 'baudrate'],
|
||||
note: `Baudrate was moved from advanced.baudrate to serial.baudrate.`,
|
||||
noteIf: noteIfWasDefined,
|
||||
newPath: ['serial', 'baudrate'],
|
||||
},
|
||||
{
|
||||
path: ['advanced', 'rtscts'],
|
||||
note: `RTSCTS was moved from advanced.rtscts to serial.rtscts.`,
|
||||
noteIf: noteIfWasDefined,
|
||||
newPath: ['serial', 'rtscts'],
|
||||
},
|
||||
{
|
||||
path: ['experimental', 'transmit_power'],
|
||||
note: `Transmit power was moved from experimental.transmit_power to advanced.transmit_power.`,
|
||||
noteIf: noteIfWasDefined,
|
||||
newPath: ['advanced', 'transmit_power'],
|
||||
},
|
||||
{
|
||||
path: ['experimental', 'output'],
|
||||
note: `Output was moved from experimental.output to advanced.output.`,
|
||||
noteIf: noteIfWasDefined,
|
||||
newPath: ['advanced', 'output'],
|
||||
},
|
||||
{
|
||||
path: ['ban'],
|
||||
note: `ban was renamed to passlist.`,
|
||||
noteIf: noteIfWasDefined,
|
||||
newPath: ['blocklist'],
|
||||
},
|
||||
{
|
||||
path: ['whitelist'],
|
||||
note: `whitelist was renamed to passlist.`,
|
||||
noteIf: noteIfWasDefined,
|
||||
newPath: ['passlist'],
|
||||
},
|
||||
);
|
||||
|
||||
changes.push({
|
||||
path: ['advanced', 'log_level'],
|
||||
note: `Log level 'warn' has been renamed to 'warning'.`,
|
||||
noteIf: (previousValue): boolean => previousValue === 'warn',
|
||||
previousValueAnyOf: ['warn'],
|
||||
newValue: 'warning',
|
||||
});
|
||||
|
||||
additions.push({
|
||||
path: ['version'],
|
||||
note: `Migrated settings to version 2`,
|
||||
value: 2,
|
||||
});
|
||||
|
||||
const haLegacyTriggers: SettingsRemove = {
|
||||
path: ['homeassistant', 'legacy_triggers'],
|
||||
note: `Action and click sensors have been removed (homeassistant.legacy_triggers setting). This means all sensor.*_action and sensor.*_click entities are removed. Use the MQTT device trigger instead.`,
|
||||
noteIf: noteIfWasTrue,
|
||||
};
|
||||
const haLegacyEntityAttrs: SettingsRemove = {
|
||||
path: ['homeassistant', 'legacy_entity_attributes'],
|
||||
note: `Entity attributes (homeassistant.legacy_entity_attributes setting) has been removed. This means that entities discovered by Zigbee2MQTT will no longer have entity attributes (Home Assistant entity attributes are accessed via e.g. states.binary_sensor.my_sensor.attributes).`,
|
||||
noteIf: noteIfWasTrue,
|
||||
};
|
||||
const otaIkeaUseTestUrl: SettingsRemove = {
|
||||
path: ['ota', 'ikea_ota_use_test_url'],
|
||||
note: `Due to the OTA rework, the ota.ikea_ota_use_test_url option has been removed.`,
|
||||
noteIf: noteIfWasTrue,
|
||||
};
|
||||
|
||||
removals.push(
|
||||
haLegacyTriggers,
|
||||
haLegacyEntityAttrs,
|
||||
{
|
||||
path: ['advanced', 'homeassistant_legacy_triggers'],
|
||||
note: haLegacyTriggers.note,
|
||||
noteIf: haLegacyTriggers.noteIf,
|
||||
},
|
||||
{
|
||||
path: ['advanced', 'homeassistant_legacy_entity_attributes'],
|
||||
note: haLegacyEntityAttrs.note,
|
||||
noteIf: haLegacyEntityAttrs.noteIf,
|
||||
},
|
||||
{
|
||||
path: ['permit_join'],
|
||||
note: `The permit_join setting has been removed, use the frontend or MQTT to permit joining.`,
|
||||
noteIf: noteIfWasTrue,
|
||||
},
|
||||
otaIkeaUseTestUrl,
|
||||
{
|
||||
path: ['advanced', 'ikea_ota_use_test_url'],
|
||||
note: otaIkeaUseTestUrl.note,
|
||||
noteIf: otaIkeaUseTestUrl.noteIf,
|
||||
},
|
||||
{
|
||||
path: ['advanced', 'legacy_api'],
|
||||
note: `The MQTT legacy API has been removed (advanced.legacy_api setting). See link below for affected topics.`,
|
||||
noteIf: noteIfWasTrue,
|
||||
},
|
||||
{
|
||||
path: ['advanced', 'legacy_availability_payload'],
|
||||
note: `Due to the removal of advanced.legacy_availability_payload, zigbee2mqtt/bridge/state will now always be a JSON object ({"state":"online"} or {"state":"offline"})`,
|
||||
noteIf: noteIfWasTrue,
|
||||
},
|
||||
{
|
||||
path: ['advanced', 'soft_reset_timeout'],
|
||||
note: `Removed deprecated: Soft reset feature (advanced.soft_reset_timeout setting)`,
|
||||
noteIf: noteIfWasDefined,
|
||||
},
|
||||
{
|
||||
path: ['advanced', 'report'],
|
||||
note: `Removed deprecated: Report feature (advanced.report setting)`,
|
||||
noteIf: noteIfWasTrue,
|
||||
},
|
||||
{
|
||||
path: ['advanced', 'availability_timeout'],
|
||||
note: `Removed deprecated: advanced.availability_timeout availability settings`,
|
||||
noteIf: noteIfWasDefined,
|
||||
},
|
||||
{
|
||||
path: ['advanced', 'availability_blocklist'],
|
||||
note: `Removed deprecated: advanced.availability_blocklist availability settings`,
|
||||
noteIf: noteIfWasNonEmptyArray,
|
||||
},
|
||||
{
|
||||
path: ['advanced', 'availability_passlist'],
|
||||
note: `Removed deprecated: advanced.availability_passlist availability settings`,
|
||||
noteIf: noteIfWasNonEmptyArray,
|
||||
},
|
||||
{
|
||||
path: ['advanced', 'availability_blacklist'],
|
||||
note: `Removed deprecated: advanced.availability_blacklist availability settings`,
|
||||
noteIf: noteIfWasNonEmptyArray,
|
||||
},
|
||||
{
|
||||
path: ['advanced', 'availability_whitelist'],
|
||||
note: `Removed deprecated: advanced.availability_whitelist availability settings`,
|
||||
noteIf: noteIfWasNonEmptyArray,
|
||||
},
|
||||
{
|
||||
path: ['device_options', 'legacy'],
|
||||
note: `Removed everything that was enabled through device_options.legacy. See link below for affected devices.`,
|
||||
noteIf: noteIfWasTrue,
|
||||
},
|
||||
{
|
||||
path: ['experimental'],
|
||||
note: `The entire experimental section was removed.`,
|
||||
noteIf: noteIfWasDefined,
|
||||
},
|
||||
{
|
||||
path: ['external_converters'],
|
||||
note: `External converters are now automatically loaded from the 'data/external_converters' directory without requiring settings to be set. Make sure your external converters are still needed (might be supported out-of-the-box now), and if so, move them to that directory.`,
|
||||
noteIf: noteIfWasNonEmptyArray,
|
||||
},
|
||||
);
|
||||
|
||||
// note only once
|
||||
const noteEntityOptionsRetrieveState = `Retrieve state option ((devices|groups).xyz.retrieve_state setting)`;
|
||||
|
||||
for (const deviceKey in currentSettings.devices) {
|
||||
removals.push({
|
||||
path: ['devices', deviceKey, 'retrieve_state'],
|
||||
note: noteEntityOptionsRetrieveState,
|
||||
noteIf: noteIfWasTrue,
|
||||
});
|
||||
}
|
||||
|
||||
for (const groupKey in currentSettings.groups) {
|
||||
removals.push({
|
||||
path: ['groups', groupKey, 'retrieve_state'],
|
||||
note: noteEntityOptionsRetrieveState,
|
||||
noteIf: noteIfWasTrue,
|
||||
});
|
||||
removals.push({
|
||||
path: ['groups', groupKey, 'devices'],
|
||||
note: `Removed configuring group members through configuration.yaml (groups.xyz.devices setting). This will not impact current group members; however, you will no longer be able to add or remove devices from a group through the configuration.yaml.`,
|
||||
noteIf: noteIfWasDefined,
|
||||
});
|
||||
}
|
||||
|
||||
customHandlers.push();
|
||||
}
|
||||
|
||||
function migrateToThree(
|
||||
currentSettings: Partial<Settings>,
|
||||
transfers: SettingsTransfer[],
|
||||
changes: SettingsChange[],
|
||||
additions: SettingsAdd[],
|
||||
removals: SettingsRemove[],
|
||||
customHandlers: SettingsCustomHandler[],
|
||||
): void {
|
||||
transfers.push();
|
||||
changes.push({
|
||||
path: ['version'],
|
||||
note: `Migrated settings to version 3`,
|
||||
newValue: 3,
|
||||
});
|
||||
additions.push();
|
||||
removals.push();
|
||||
|
||||
const changeToObject = (currentSettings: Partial<Settings>, path: string[]): ReturnType<SettingsCustomHandler['execute']> => {
|
||||
const [validPath, previousValue] = getValue(currentSettings, path);
|
||||
|
||||
if (validPath) {
|
||||
if (typeof previousValue === 'boolean') {
|
||||
setValue(currentSettings, path, {enabled: previousValue});
|
||||
} else {
|
||||
setValue(currentSettings, path, {enabled: true, ...(previousValue as object)});
|
||||
}
|
||||
}
|
||||
|
||||
return [validPath, previousValue, validPath];
|
||||
};
|
||||
|
||||
customHandlers.push(
|
||||
{
|
||||
note: `Property 'homeassistant' is now always an object.`,
|
||||
noteIf: () => true,
|
||||
execute: (currentSettings) => changeToObject(currentSettings, ['homeassistant']),
|
||||
},
|
||||
{
|
||||
note: `Property 'frontend' is now always an object.`,
|
||||
noteIf: () => true,
|
||||
execute: (currentSettings) => changeToObject(currentSettings, ['frontend']),
|
||||
},
|
||||
{
|
||||
note: `Property 'availability' is now always an object.`,
|
||||
noteIf: () => true,
|
||||
execute: (currentSettings) => changeToObject(currentSettings, ['availability']),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function migrateToFour(
|
||||
currentSettings: Partial<Settings>,
|
||||
transfers: SettingsTransfer[],
|
||||
changes: SettingsChange[],
|
||||
additions: SettingsAdd[],
|
||||
removals: SettingsRemove[],
|
||||
customHandlers: SettingsCustomHandler[],
|
||||
): void {
|
||||
transfers.push();
|
||||
changes.push({
|
||||
path: ['version'],
|
||||
note: `Migrated settings to version 4`,
|
||||
newValue: 4,
|
||||
});
|
||||
additions.push();
|
||||
removals.push();
|
||||
|
||||
const saveBase64DeviceIconsAsImage = (currentSettings: Partial<Settings>): ReturnType<SettingsCustomHandler['execute']> => {
|
||||
const [validPath, previousValue] = getValue(currentSettings, ['devices']);
|
||||
let changed = false;
|
||||
|
||||
if (validPath) {
|
||||
for (const deviceKey in currentSettings.devices) {
|
||||
const base64Match = utils.matchBase64File(currentSettings.devices[deviceKey].icon);
|
||||
if (base64Match) {
|
||||
changed = true;
|
||||
currentSettings.devices[deviceKey].icon = utils.saveBase64DeviceIcon(base64Match);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [validPath, previousValue, changed];
|
||||
};
|
||||
|
||||
customHandlers.push({
|
||||
note: `Device icons are now saved as images.`,
|
||||
noteIf: () => true,
|
||||
execute: (currentSettings) => saveBase64DeviceIconsAsImage(currentSettings),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Order of execution:
|
||||
* - Transfer
|
||||
* - Change
|
||||
* - Add
|
||||
* - Remove
|
||||
*
|
||||
* Should allow the most flexibility whenever combination of migrations is necessary (e.g. Transfer + Change)
|
||||
*/
|
||||
export function migrateIfNecessary(): void {
|
||||
let currentSettings = settings.getPersistedSettings();
|
||||
|
||||
if (!SUPPORTED_VERSIONS.includes(currentSettings.version)) {
|
||||
throw new Error(
|
||||
`Your configuration.yaml has an unsupported version ${currentSettings.version}, expected one of ${SUPPORTED_VERSIONS.map((v) => String(v)).join(',')}.`,
|
||||
);
|
||||
}
|
||||
|
||||
/* v8 ignore next */
|
||||
const finalVersion = process.env.VITEST_WORKER_ID ? settings.testing.CURRENT_VERSION : settings.CURRENT_VERSION;
|
||||
|
||||
// when same version as current, nothing left to do
|
||||
while (currentSettings.version !== finalVersion) {
|
||||
let migrationNotesFileName: string | undefined;
|
||||
// don't duplicate outputs
|
||||
const migrationNotes: Set<string> = new Set();
|
||||
const transfers: SettingsTransfer[] = [];
|
||||
const changes: SettingsChange[] = [];
|
||||
const additions: SettingsAdd[] = [];
|
||||
const removals: SettingsRemove[] = [];
|
||||
const customHandlers: SettingsCustomHandler[] = [];
|
||||
|
||||
backupSettings(currentSettings.version || 1);
|
||||
|
||||
// each version should only bump to the next version so as to gradually migrate if necessary
|
||||
if (currentSettings.version == undefined) {
|
||||
// migrating from 1 (`version` did not exist) to 2
|
||||
migrationNotesFileName = 'migration-1-to-2.log';
|
||||
|
||||
migrateToTwo(currentSettings, transfers, changes, additions, removals, customHandlers);
|
||||
} else if (currentSettings.version === 2) {
|
||||
migrationNotesFileName = 'migration-2-to-3.log';
|
||||
|
||||
migrateToThree(currentSettings, transfers, changes, additions, removals, customHandlers);
|
||||
} else if (currentSettings.version === 3) {
|
||||
migrationNotesFileName = 'migration-3-to-4.log';
|
||||
|
||||
migrateToFour(currentSettings, transfers, changes, additions, removals, customHandlers);
|
||||
}
|
||||
|
||||
for (const transfer of transfers) {
|
||||
const [validPath, previousValue, transfered] = transferValue(currentSettings, transfer);
|
||||
|
||||
if (validPath && (!transfer.noteIf || transfer.noteIf(previousValue))) {
|
||||
migrationNotes.add(`[${transfered ? 'TRANSFER' : 'REMOVAL'}] ${transfer.note}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const change of changes) {
|
||||
const [validPath, previousValue, changed] = changeValue(currentSettings, change);
|
||||
|
||||
if (validPath && changed && (!change.noteIf || change.noteIf(previousValue))) {
|
||||
migrationNotes.add(`[CHANGE] ${change.note}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const addition of additions) {
|
||||
addValue(currentSettings, addition);
|
||||
|
||||
migrationNotes.add(`[ADDITION] ${addition.note}`);
|
||||
}
|
||||
|
||||
for (const removal of removals) {
|
||||
const [validPath, previousValue] = removeValue(currentSettings, removal);
|
||||
|
||||
if (validPath && (!removal.noteIf || removal.noteIf(previousValue))) {
|
||||
migrationNotes.add(`[REMOVAL] ${removal.note}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const customHandler of customHandlers) {
|
||||
const [validPath, previousValue, changed] = customHandler.execute(currentSettings);
|
||||
|
||||
if (validPath && changed && (!customHandler.noteIf || customHandler.noteIf(previousValue))) {
|
||||
migrationNotes.add(`[SPECIAL] ${customHandler.note}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (migrationNotesFileName && migrationNotes.size > 0) {
|
||||
migrationNotes.add(`For more details, see https://github.com/Koenkk/zigbee2mqtt/discussions/24198`);
|
||||
const migrationNotesFilePath = data.joinPath(migrationNotesFileName);
|
||||
|
||||
writeFileSync(migrationNotesFilePath, Array.from(migrationNotes).join(`\r\n\r\n`), 'utf8');
|
||||
|
||||
console.log(`Migration notes written in ${migrationNotesFilePath}`);
|
||||
}
|
||||
|
||||
// don't throw to allow stepping through versions (validates against current schema)
|
||||
settings.apply(currentSettings as unknown as Record<string, unknown>, false);
|
||||
settings.reRead();
|
||||
currentSettings = settings.getPersistedSettings();
|
||||
}
|
||||
}
|
||||
+75
-113
@@ -1,15 +1,23 @@
|
||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponse, Zigbee2MQTTResponseEndpoints, Zigbee2MQTTScene} from 'lib/types/api';
|
||||
import type * as zhc from 'zigbee-herdsman-converters';
|
||||
|
||||
import assert from 'assert';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import vm from 'vm';
|
||||
import assert from 'node:assert';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import equals from 'fast-deep-equal/es6';
|
||||
import humanizeDuration from 'humanize-duration';
|
||||
|
||||
import data from './data';
|
||||
|
||||
const BASE64_IMAGE_REGEX = new RegExp(`data:image/(?<extension>.+);base64,(?<data>.+)`);
|
||||
|
||||
function pad(num: number): string {
|
||||
const norm = Math.floor(Math.abs(num));
|
||||
return (norm < 10 ? '0' : '') + norm;
|
||||
}
|
||||
|
||||
// construct a local ISO8601 string (instead of UTC-based)
|
||||
// Example:
|
||||
// - ISO8601 (UTC) = 2019-03-01T15:32:45.941+0000
|
||||
@@ -17,10 +25,6 @@ import data from './data';
|
||||
function toLocalISOString(date: Date): string {
|
||||
const tzOffset = -date.getTimezoneOffset();
|
||||
const plusOrMinus = tzOffset >= 0 ? '+' : '-';
|
||||
const pad = (num: number): string => {
|
||||
const norm = Math.floor(Math.abs(num));
|
||||
return (norm < 10 ? '0' : '') + norm;
|
||||
};
|
||||
|
||||
return (
|
||||
date.getFullYear() +
|
||||
@@ -62,10 +66,11 @@ async function getZigbee2MQTTVersion(includeCommitHash = true): Promise<{commitH
|
||||
if (err) {
|
||||
try {
|
||||
commitHash = fs.readFileSync(path.join(__dirname, '..', '..', 'dist', '.hash'), 'utf-8');
|
||||
/* v8 ignore start */
|
||||
} catch {
|
||||
/* istanbul ignore next */
|
||||
commitHash = 'unknown';
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
} else {
|
||||
commitHash = commit.shortHash;
|
||||
}
|
||||
@@ -77,10 +82,8 @@ async function getZigbee2MQTTVersion(includeCommitHash = true): Promise<{commitH
|
||||
}
|
||||
|
||||
async function getDependencyVersion(depend: string): Promise<{version: string}> {
|
||||
const modulePath = path.dirname(require.resolve(depend));
|
||||
const packageJSONPath = path.join(modulePath.slice(0, modulePath.indexOf(depend) + depend.length), 'package.json');
|
||||
const packageJSON = await import(packageJSONPath);
|
||||
const version = packageJSON.version;
|
||||
const packageJsonPath = require.resolve(`${depend}/package.json`);
|
||||
const version = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')).version;
|
||||
return {version};
|
||||
}
|
||||
|
||||
@@ -120,22 +123,39 @@ function equalsPartial(object: KeyValue, expected: KeyValue): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function getObjectProperty(object: KeyValue, key: string, defaultValue: unknown): unknown {
|
||||
function getObjectProperty<T>(object: KeyValue, key: string, defaultValue: NoInfer<T>): T {
|
||||
return object && object[key] !== undefined ? object[key] : defaultValue;
|
||||
}
|
||||
|
||||
function getResponse(request: KeyValue | string, data: KeyValue, error?: string): MQTTResponse {
|
||||
const response: MQTTResponse = {data, status: error ? 'error' : 'ok'};
|
||||
function getResponse<T extends Zigbee2MQTTResponseEndpoints>(
|
||||
request: KeyValue | string,
|
||||
data: Zigbee2MQTTAPI[T],
|
||||
error?: string,
|
||||
): Zigbee2MQTTResponse<T> {
|
||||
if (error !== undefined) {
|
||||
const response: Zigbee2MQTTResponse<T> = {
|
||||
data: {}, // always return an empty `data` payload on error
|
||||
status: 'error',
|
||||
error: error,
|
||||
};
|
||||
|
||||
if (error) {
|
||||
response.error = error;
|
||||
if (typeof request === 'object' && request.transaction !== undefined) {
|
||||
response.transaction = request.transaction;
|
||||
}
|
||||
|
||||
return response;
|
||||
} else {
|
||||
const response: Zigbee2MQTTResponse<T> = {
|
||||
data, // valid from error check
|
||||
status: 'ok',
|
||||
};
|
||||
|
||||
if (typeof request === 'object' && request.transaction !== undefined) {
|
||||
response.transaction = request.transaction;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
if (typeof request === 'object' && request['transaction'] !== undefined) {
|
||||
response.transaction = request.transaction;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
function parseJSON(value: string, fallback: string): KeyValue | string {
|
||||
@@ -146,48 +166,6 @@ function parseJSON(value: string, fallback: string): KeyValue | string {
|
||||
}
|
||||
}
|
||||
|
||||
function loadModuleFromText(moduleCode: string, name?: string): unknown {
|
||||
const moduleFakePath = path.join(__dirname, '..', '..', 'data', 'extension', name || 'externally-loaded.js');
|
||||
const sandbox = {
|
||||
require: require,
|
||||
module: {},
|
||||
console,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
setImmediate,
|
||||
clearImmediate,
|
||||
};
|
||||
vm.runInNewContext(moduleCode, sandbox, moduleFakePath);
|
||||
/* eslint-disable-line */ // @ts-ignore
|
||||
return sandbox.module.exports;
|
||||
}
|
||||
|
||||
function loadModuleFromFile(modulePath: string): unknown {
|
||||
const moduleCode = fs.readFileSync(modulePath, {encoding: 'utf8'});
|
||||
return loadModuleFromText(moduleCode);
|
||||
}
|
||||
|
||||
export function* loadExternalConverter(moduleName: string): Generator<ExternalDefinition> {
|
||||
let converter;
|
||||
|
||||
if (moduleName.endsWith('.js')) {
|
||||
converter = loadModuleFromFile(data.joinPath(moduleName));
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
converter = require(moduleName);
|
||||
}
|
||||
|
||||
if (Array.isArray(converter)) {
|
||||
for (const item of converter) {
|
||||
yield item;
|
||||
}
|
||||
} else {
|
||||
yield converter;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all keys from passed object that have null/undefined values.
|
||||
*
|
||||
@@ -196,8 +174,12 @@ export function* loadExternalConverter(moduleName: string): Generator<ExternalDe
|
||||
*/
|
||||
function removeNullPropertiesFromObject(obj: KeyValue, ignoreKeys: string[] = []): void {
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (ignoreKeys.includes(key)) continue;
|
||||
if (ignoreKeys.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = obj[key];
|
||||
|
||||
if (value == null) {
|
||||
delete obj[key];
|
||||
} else if (typeof value === 'object') {
|
||||
@@ -211,26 +193,6 @@ function toNetworkAddressHex(value: number): string {
|
||||
return `0x${'0'.repeat(4 - hex.length)}${hex}`;
|
||||
}
|
||||
|
||||
function toSnakeCaseObject(value: KeyValue): KeyValue {
|
||||
value = {...value};
|
||||
for (const key of Object.keys(value)) {
|
||||
const keySnakeCase = toSnakeCaseString(key);
|
||||
assert(typeof keySnakeCase === 'string');
|
||||
if (key !== keySnakeCase) {
|
||||
value[keySnakeCase] = value[key];
|
||||
delete value[key];
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function toSnakeCaseString(value: string): string {
|
||||
return value
|
||||
.replace(/\.?([A-Z])/g, (x, y) => '_' + y.toLowerCase())
|
||||
.replace(/^_/, '')
|
||||
.replace('_i_d', '_id');
|
||||
}
|
||||
|
||||
function charRange(start: string, stop: string): number[] {
|
||||
const result = [];
|
||||
for (let idx = start.charCodeAt(0), end = stop.charCodeAt(0); idx <= end; ++idx) {
|
||||
@@ -306,23 +268,10 @@ function isAvailabilityEnabledForEntity(entity: Device | Group, settings: Settin
|
||||
return !!entity.options.availability;
|
||||
}
|
||||
|
||||
// availability_timeout = deprecated
|
||||
if (!(settings.advanced.availability_timeout || settings.availability)) {
|
||||
if (!settings.availability.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const passlist = settings.advanced.availability_passlist.concat(settings.advanced.availability_whitelist);
|
||||
|
||||
if (passlist.length > 0) {
|
||||
return passlist.includes(entity.name) || passlist.includes(entity.ieeeAddr);
|
||||
}
|
||||
|
||||
const blocklist = settings.advanced.availability_blacklist.concat(settings.advanced.availability_blocklist);
|
||||
|
||||
if (blocklist.length > 0) {
|
||||
return !blocklist.includes(entity.name) && !blocklist.includes(entity.ieeeAddr);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -342,10 +291,6 @@ function isZHGroup(obj: unknown): obj is zh.Group {
|
||||
return obj?.constructor.name.toLowerCase() === 'group';
|
||||
}
|
||||
|
||||
function availabilityPayload(state: 'online' | 'offline', settings: Settings): string {
|
||||
return settings.advanced.legacy_availability_payload ? state : JSON.stringify({state});
|
||||
}
|
||||
|
||||
const hours = (hours: number): number => 1000 * 60 * 60 * hours;
|
||||
const minutes = (minutes: number): number => 1000 * 60 * minutes;
|
||||
const seconds = (seconds: number): number => 1000 * seconds;
|
||||
@@ -407,8 +352,8 @@ export function isLightExpose(expose: zhc.Expose): expose is zhc.Light {
|
||||
return expose.type === 'light';
|
||||
}
|
||||
|
||||
function getScenes(entity: zh.Endpoint | zh.Group): Scene[] {
|
||||
const scenes: {[id: number]: Scene} = {};
|
||||
function getScenes(entity: zh.Endpoint | zh.Group): Zigbee2MQTTScene[] {
|
||||
const scenes: {[id: number]: Zigbee2MQTTScene} = {};
|
||||
const endpoints = isZHEndpoint(entity) ? [entity] : entity.members;
|
||||
const groupID = isZHEndpoint(entity) ? 0 : entity.groupID;
|
||||
|
||||
@@ -430,10 +375,32 @@ function deviceNotCoordinator(device: zh.Device): boolean {
|
||||
return device.type !== 'Coordinator';
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
function matchBase64File(value: string | undefined): {extension: string; data: string} | false {
|
||||
if (value !== undefined) {
|
||||
const match = value.match(BASE64_IMAGE_REGEX);
|
||||
if (match) {
|
||||
assert(match.groups?.extension && match.groups?.data);
|
||||
return {extension: match.groups.extension, data: match.groups.data};
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function saveBase64DeviceIcon(base64Match: {extension: string; data: string}): string {
|
||||
const md5Hash = crypto.createHash('md5').update(base64Match.data).digest('hex');
|
||||
const fileSettings = `device_icons/${md5Hash}.${base64Match.extension}`;
|
||||
const file = path.join(data.getPath(), fileSettings);
|
||||
fs.mkdirSync(path.dirname(file), {recursive: true});
|
||||
fs.writeFileSync(file, base64Match.data, {encoding: 'base64'});
|
||||
return fileSettings;
|
||||
}
|
||||
|
||||
/* v8 ignore next */
|
||||
const noop = (): void => {};
|
||||
|
||||
export default {
|
||||
matchBase64File,
|
||||
saveBase64DeviceIcon,
|
||||
capitalize,
|
||||
getZigbee2MQTTVersion,
|
||||
getDependencyVersion,
|
||||
@@ -444,12 +411,8 @@ export default {
|
||||
getObjectProperty,
|
||||
getResponse,
|
||||
parseJSON,
|
||||
loadModuleFromText,
|
||||
loadModuleFromFile,
|
||||
removeNullPropertiesFromObject,
|
||||
toNetworkAddressHex,
|
||||
toSnakeCaseString,
|
||||
toSnakeCaseObject,
|
||||
isZHEndpoint,
|
||||
isZHGroup,
|
||||
hours,
|
||||
@@ -460,7 +423,6 @@ export default {
|
||||
sanitizeImageParameter,
|
||||
isAvailabilityEnabledForEntity,
|
||||
publishLastSeen,
|
||||
availabilityPayload,
|
||||
getAllFiles,
|
||||
filterProperties,
|
||||
flatten,
|
||||
|
||||
+4
-2
@@ -1,4 +1,5 @@
|
||||
import fs from 'fs';
|
||||
import assert from 'node:assert';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import equals from 'fast-deep-equal/es6';
|
||||
import yaml, {YAMLException} from 'js-yaml';
|
||||
@@ -20,7 +21,8 @@ export class YAMLFileException extends YAMLException {
|
||||
function read(file: string): KeyValue {
|
||||
try {
|
||||
const result = yaml.load(fs.readFileSync(file, 'utf8'));
|
||||
return (result as KeyValue) ?? {};
|
||||
assert(result instanceof Object);
|
||||
return result as KeyValue;
|
||||
} catch (error) {
|
||||
if (error instanceof YAMLException) {
|
||||
throw new YAMLFileException(error, file);
|
||||
|
||||
+22
-27
@@ -1,10 +1,11 @@
|
||||
import {randomInt} from 'crypto';
|
||||
import type {Events as ZHEvents} from 'zigbee-herdsman';
|
||||
|
||||
import {randomInt} from 'node:crypto';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
import {Controller} from 'zigbee-herdsman';
|
||||
import * as ZHEvents from 'zigbee-herdsman/dist/controller/events';
|
||||
|
||||
import Device from './model/device';
|
||||
import Group from './model/group';
|
||||
@@ -29,16 +30,15 @@ export default class Zigbee {
|
||||
async start(): Promise<'reset' | 'resumed' | 'restored'> {
|
||||
const infoHerdsman = await utils.getDependencyVersion('zigbee-herdsman');
|
||||
logger.info(`Starting zigbee-herdsman (${infoHerdsman.version})`);
|
||||
const panId = settings.get().advanced.pan_id;
|
||||
const extPanId = settings.get().advanced.ext_pan_id;
|
||||
const networkKey = settings.get().advanced.network_key;
|
||||
const herdsmanSettings = {
|
||||
network: {
|
||||
panID: settings.get().advanced.pan_id === 'GENERATE' ? this.generatePanID() : (settings.get().advanced.pan_id as number),
|
||||
extendedPanID:
|
||||
settings.get().advanced.ext_pan_id === 'GENERATE' ? this.generateExtPanID() : (settings.get().advanced.ext_pan_id as number[]),
|
||||
panID: panId === 'GENERATE' ? this.generatePanID() : panId,
|
||||
extendedPanID: extPanId === 'GENERATE' ? this.generateExtPanID() : extPanId,
|
||||
channelList: [settings.get().advanced.channel],
|
||||
networkKey:
|
||||
settings.get().advanced.network_key === 'GENERATE'
|
||||
? this.generateNetworkKey()
|
||||
: (settings.get().advanced.network_key as number[]),
|
||||
networkKey: networkKey === 'GENERATE' ? this.generateNetworkKey() : networkKey,
|
||||
},
|
||||
databasePath: data.joinPath('database.db'),
|
||||
databaseBackupPath: data.joinPath('database.db.backup'),
|
||||
@@ -72,9 +72,7 @@ export default class Zigbee {
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const device of this.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
await device.resolveDefinition();
|
||||
}
|
||||
await this.resolveDevicesDefinitions();
|
||||
|
||||
this.herdsman.on('adapterDisconnected', () => this.eventBus.emitAdapterDisconnected());
|
||||
this.herdsman.on('lastSeenChanged', (data: ZHEvents.LastSeenChangedPayload) => {
|
||||
@@ -95,7 +93,7 @@ export default class Zigbee {
|
||||
});
|
||||
this.herdsman.on('deviceInterview', async (data: ZHEvents.DeviceInterviewPayload) => {
|
||||
const device = this.resolveDevice(data.device.ieeeAddr);
|
||||
/* istanbul ignore if */ if (!device) return; // Prevent potential race
|
||||
/* v8 ignore next */ if (!device) return; // Prevent potential race
|
||||
await device.resolveDefinition();
|
||||
const d = {device, status: data.status};
|
||||
this.logDeviceInterview(d);
|
||||
@@ -103,7 +101,7 @@ export default class Zigbee {
|
||||
});
|
||||
this.herdsman.on('deviceJoined', async (data: ZHEvents.DeviceJoinedPayload) => {
|
||||
const device = this.resolveDevice(data.device.ieeeAddr);
|
||||
/* istanbul ignore if */ if (!device) return; // Prevent potential race
|
||||
/* v8 ignore next */ if (!device) return; // Prevent potential race
|
||||
await device.resolveDefinition();
|
||||
logger.info(`Device '${device.name}' joined`);
|
||||
this.eventBus.emitDeviceJoined({device});
|
||||
@@ -219,10 +217,6 @@ export default class Zigbee {
|
||||
return await this.herdsman.getNetworkParameters();
|
||||
}
|
||||
|
||||
async reset(type: 'soft' | 'hard'): Promise<void> {
|
||||
await this.herdsman.reset(type);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
logger.info('Stopping zigbee-herdsman...');
|
||||
await this.herdsman.stop();
|
||||
@@ -233,21 +227,23 @@ export default class Zigbee {
|
||||
return this.herdsman.getPermitJoin();
|
||||
}
|
||||
|
||||
getPermitJoinTimeout(): number | undefined {
|
||||
return this.herdsman.getPermitJoinTimeout();
|
||||
getPermitJoinEnd(): number | undefined {
|
||||
return this.herdsman.getPermitJoinEnd();
|
||||
}
|
||||
|
||||
async permitJoin(permit: boolean, device?: Device, time?: number): Promise<void> {
|
||||
if (permit) {
|
||||
async permitJoin(time: number, device?: Device): Promise<void> {
|
||||
if (time > 0) {
|
||||
logger.info(`Zigbee: allowing new devices to join${device ? ` via ${device.name}` : ''}.`);
|
||||
} else {
|
||||
logger.info('Zigbee: disabling joining new devices.');
|
||||
}
|
||||
|
||||
if (device && permit) {
|
||||
await this.herdsman.permitJoin(permit, device.zh, time);
|
||||
} else {
|
||||
await this.herdsman.permitJoin(permit, undefined, time);
|
||||
await this.herdsman.permitJoin(time, device?.zh);
|
||||
}
|
||||
|
||||
async resolveDevicesDefinitions(ignoreCache: boolean = false): Promise<void> {
|
||||
for (const device of this.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
await device.resolveDefinition(ignoreCache);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,7 +310,6 @@ export default class Zigbee {
|
||||
// First split the input token by the latest slash
|
||||
const match = ID.match(entityIDRegex);
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (match) {
|
||||
// Get the resulting IDs from the match
|
||||
entityName = match[1];
|
||||
|
||||
Generated
-8656
File diff suppressed because it is too large
Load Diff
+38
-50
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "zigbee2mqtt",
|
||||
"version": "1.41.0",
|
||||
"version": "2.1.3",
|
||||
"description": "Zigbee to MQTT bridge using Zigbee-herdsman",
|
||||
"main": "index.js",
|
||||
"packageManager": "pnpm@10.4.1",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Koenkk/zigbee2mqtt.git"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18 || ^20 || ^22"
|
||||
"node": "^18 || ^20 || ^22 || ^23"
|
||||
},
|
||||
"keywords": [
|
||||
"xiaomi",
|
||||
@@ -26,9 +27,11 @@
|
||||
"pretty:write": "prettier --write .",
|
||||
"pretty:check": "prettier --check .",
|
||||
"start": "node index.js",
|
||||
"test-with-coverage": "jest test --silent --maxWorkers=50% --coverage",
|
||||
"test": "jest test --silent --maxWorkers=50%",
|
||||
"test-watch": "jest test --silent --maxWorkers=25% --watch"
|
||||
"test": "vitest run --config ./test/vitest.config.mts",
|
||||
"test:coverage": "vitest run --config ./test/vitest.config.mts --coverage",
|
||||
"test:watch": "vitest watch --config ./test/vitest.config.mts",
|
||||
"prepack": "pnpm run clean && pnpm run build",
|
||||
"clean": "rimraf coverage dist tsconfig.tsbuildinfo"
|
||||
},
|
||||
"author": "Koen Kanters",
|
||||
"license": "GPL-3.0",
|
||||
@@ -39,8 +42,8 @@
|
||||
"dependencies": {
|
||||
"ajv": "^8.17.1",
|
||||
"bind-decorator": "^1.0.11",
|
||||
"connect-gzip-static": "3.0.1",
|
||||
"debounce": "^2.2.0",
|
||||
"express-static-gzip": "^2.2.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"finalhandler": "^1.3.1",
|
||||
"git-last-commit": "^1.0.1",
|
||||
@@ -48,67 +51,52 @@
|
||||
"js-yaml": "^4.1.0",
|
||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||
"jszip": "^3.10.1",
|
||||
"mkdir-recursive": "^0.4.0",
|
||||
"moment": "^2.30.1",
|
||||
"mqtt": "^5.10.1",
|
||||
"mqtt": "^5.10.3",
|
||||
"object-assign-deep": "^0.4.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"semver": "^7.6.3",
|
||||
"semver": "^7.7.1",
|
||||
"source-map-support": "^0.5.21",
|
||||
"throttleit": "^2.1.0",
|
||||
"uri-js": "^4.4.1",
|
||||
"winston": "^3.15.0",
|
||||
"winston": "^3.17.0",
|
||||
"winston-syslog": "^2.7.1",
|
||||
"winston-transport": "^4.8.0",
|
||||
"ws": "^8.18.0",
|
||||
"zigbee-herdsman": "2.1.7",
|
||||
"zigbee-herdsman-converters": "20.38.0",
|
||||
"zigbee2mqtt-frontend": "0.7.4"
|
||||
"winston-transport": "^4.9.0",
|
||||
"ws": "^8.18.1",
|
||||
"zigbee-herdsman": "3.2.7",
|
||||
"zigbee-herdsman-converters": "23.2.0",
|
||||
"zigbee2mqtt-frontend": "0.9.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.26.0",
|
||||
"@babel/plugin-proposal-decorators": "^7.25.9",
|
||||
"@babel/preset-env": "^7.26.0",
|
||||
"@babel/preset-typescript": "^7.26.0",
|
||||
"@eslint/core": "^0.7.0",
|
||||
"@eslint/js": "^9.13.0",
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.3.1",
|
||||
"@types/eslint__js": "^8.42.3",
|
||||
"@eslint/core": "^0.12.0",
|
||||
"@eslint/js": "^9.21.0",
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.4.1",
|
||||
"@types/finalhandler": "^1.2.3",
|
||||
"@types/humanize-duration": "^3.27.4",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.8.1",
|
||||
"@types/node": "^22.13.5",
|
||||
"@types/object-assign-deep": "^0.4.3",
|
||||
"@types/readable-stream": "4.0.16",
|
||||
"@types/readable-stream": "4.0.18",
|
||||
"@types/sd-notify": "^2.8.2",
|
||||
"@types/ws": "8.5.12",
|
||||
"babel-jest": "^29.7.0",
|
||||
"eslint": "^9.13.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.3.3",
|
||||
"@types/serve-static": "^1.15.7",
|
||||
"@types/ws": "8.5.14",
|
||||
"@vitest/coverage-v8": "^3.0.7",
|
||||
"eslint": "^9.21.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"prettier": "^3.5.2",
|
||||
"tmp": "^0.2.3",
|
||||
"typescript": "^5.6.3",
|
||||
"typescript-eslint": "^8.11.0"
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.25.0",
|
||||
"vitest": "^3.0.7"
|
||||
},
|
||||
"overrides": {
|
||||
"zigbee-herdsman-converters": {
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"zigbee-herdsman": "$zigbee-herdsman"
|
||||
}
|
||||
},
|
||||
"jest": {
|
||||
"coverageThreshold": {
|
||||
"global": {
|
||||
"branches": 100,
|
||||
"functions": 100,
|
||||
"lines": 100,
|
||||
"statements": 100
|
||||
}
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"lib/**/*.js",
|
||||
"lib/**/*.ts"
|
||||
"onlyBuiltDependencies": [
|
||||
"@serialport/bindings-cpp",
|
||||
"esbuild",
|
||||
"sd-notify",
|
||||
"unix-dgram"
|
||||
]
|
||||
},
|
||||
"bin": {
|
||||
|
||||
Generated
+3529
File diff suppressed because it is too large
Load Diff
@@ -49,7 +49,8 @@ if (fs.existsSync(commitUserFile)) {
|
||||
commitUserLookup = JSON.parse(fs.readFileSync(commitUserFile, 'utf8'));
|
||||
}
|
||||
|
||||
const whiteLabels = zhc.definitions.filter((d) => d.whiteLabel).flatMap((d) => d.whiteLabel);
|
||||
const definitions = require('zigbee-herdsman-converters/devices/index').default.map((d) => zhc.prepareDefinition(d));
|
||||
const whiteLabels = definitions.filter((d) => d.whiteLabel).flatMap((d) => d.whiteLabel);
|
||||
const capitalizeFirstChar = (str) => str.charAt(0).toUpperCase() + str.slice(1);
|
||||
|
||||
for (const changelog of changelogs) {
|
||||
@@ -97,7 +98,7 @@ for (const changelog of changelogs) {
|
||||
|
||||
if (localContext === 'add') {
|
||||
for (const model of message.split(',')) {
|
||||
const definition = zhc.definitions.find((d) => d.model === model.trim());
|
||||
const definition = definitions.find((d) => d.model === model.trim());
|
||||
const whiteLabel = whiteLabels.find((d) => d.model === model.trim());
|
||||
const match = definition || whiteLabel;
|
||||
if (match) {
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
#!/bin/bash
|
||||
function zigbee2mqtt-show-short-info {
|
||||
echo "Setup for Zigbee2MQTT bridge."
|
||||
}
|
||||
|
||||
function zigbee2mqtt-show-long-info {
|
||||
echo "This script installs the Zigbee2MQTT bridge"
|
||||
}
|
||||
|
||||
function zigbee2mqtt-show-copyright-info {
|
||||
echo "Original concept by Landrash <https://github.com/landrash>."
|
||||
}
|
||||
|
||||
function zigbee2mqtt-install-package {
|
||||
echo -n "Installing dependencies : "
|
||||
node=$(which npm)
|
||||
if [ -z "${node}" ]; then #Installing NodeJS if not already installed.
|
||||
printf "Downloading and installing NodeJS...\\n"
|
||||
curl -sL https://deb.nodesource.com/setup_10.x | bash -
|
||||
apt install -y nodejs
|
||||
fi
|
||||
|
||||
echo "Cloning Zigbee2MQTT git repository"
|
||||
git clone https://github.com/Koenkk/zigbee2mqtt.git /opt/zigbee2mqtt
|
||||
chown -R pi:pi /opt/zigbee2mqtt
|
||||
|
||||
echo "Running install. This might take a while and can produce some expected errors"
|
||||
cd /opt/zigbee2mqtt || exit
|
||||
su pi -c "npm ci"
|
||||
|
||||
echo "Creating service file zigbee2mqtt.service"
|
||||
service_path="/etc/systemd/system/zigbee2mqtt.service"
|
||||
|
||||
echo "[Unit]
|
||||
Description=zigbee2mqtt
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
ExecStart=/usr/bin/node index.js
|
||||
WorkingDirectory=/opt/zigbee2mqtt
|
||||
StandardOutput=inherit
|
||||
StandardError=inherit
|
||||
WatchdogSec=10s
|
||||
Restart=always
|
||||
User=pi
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target" > $service_path
|
||||
|
||||
echo "Checking the installation..."
|
||||
if [ ! -f /opt/zigbee2mqtt/data/configuration.yaml ]; then
|
||||
validation=""
|
||||
else
|
||||
validation="ok"
|
||||
fi
|
||||
|
||||
if [ ! -z "${validation}" ]; then
|
||||
echo
|
||||
echo -e "\\e[32mInstallation done..\\e[0m"
|
||||
echo -e "Update of configuration.yaml is required found at /opt/zigbee2mqtt/data/"
|
||||
echo -e "Some further configuration is required and details can be found here https://www.zigbee2mqtt.io"
|
||||
echo
|
||||
echo -e "Service can be started after configuration but running sudo systemctl start zigbee2mqtt"
|
||||
echo
|
||||
else
|
||||
echo
|
||||
echo -e "\\e[31mInstallation failed..."
|
||||
echo
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
[[ "$_" == "$0" ]] && zigbee2mqtt-install-package
|
||||
-12
@@ -1,19 +1,7 @@
|
||||
const homeassistantSwitch = {
|
||||
type: 'switch',
|
||||
object_id: 'switch',
|
||||
discovery_payload: {
|
||||
payload_off: 'OFF',
|
||||
payload_on: 'ON',
|
||||
value_template: '{{ value_json.state }}',
|
||||
command_topic: true,
|
||||
},
|
||||
};
|
||||
|
||||
const mockDevices = [
|
||||
{
|
||||
mock: 1,
|
||||
model: 'external_converters_device_1',
|
||||
homeassistant: [homeassistantSwitch],
|
||||
zigbeeModel: ['external_converter_device_1'],
|
||||
vendor: 'external_1',
|
||||
description: 'external_1',
|
||||
@@ -0,0 +1,16 @@
|
||||
class Example2 {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
this.mqtt = mqtt;
|
||||
this.mqtt.publish('example2/extension', 'call2 from constructor');
|
||||
}
|
||||
|
||||
start() {
|
||||
this.mqtt.publish('example2/extension', 'call2 from start');
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.mqtt.publish('example/extension', 'call2 from stop');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Example2;
|
||||
+5
-1
@@ -5,7 +5,11 @@ class Example {
|
||||
}
|
||||
|
||||
start() {
|
||||
this.mqtt.publish('example/extension', 'test');
|
||||
this.mqtt.publish('example/extension', 'call from start');
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.mqtt.publish('example/extension', 'call from stop');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,943 +0,0 @@
|
||||
const data = require('./stub/data');
|
||||
const logger = require('./stub/logger');
|
||||
const zigbeeHerdsman = require('./stub/zigbeeHerdsman');
|
||||
const MQTT = require('./stub/mqtt');
|
||||
const settings = require('../lib/util/settings');
|
||||
const Controller = require('../lib/controller');
|
||||
const flushPromises = require('./lib/flushPromises');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
jest.mock('debounce', () => jest.fn((fn) => fn));
|
||||
const debounce = require('debounce');
|
||||
|
||||
describe('Bind', () => {
|
||||
let controller;
|
||||
|
||||
const mockClear = (device) => {
|
||||
for (const endpoint of device.endpoints) {
|
||||
endpoint.read.mockClear();
|
||||
endpoint.write.mockClear();
|
||||
endpoint.configureReporting.mockClear();
|
||||
endpoint.bind = jest.fn();
|
||||
endpoint.bind.mockClear();
|
||||
endpoint.unbind.mockClear();
|
||||
}
|
||||
};
|
||||
|
||||
let resetExtension = async () => {
|
||||
await controller.enableDisableExtension(false, 'Bind');
|
||||
await controller.enableDisableExtension(true, 'Bind');
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.useFakeTimers();
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
zigbeeHerdsman.groups.group_1.members = [];
|
||||
zigbeeHerdsman.devices.bulb_color.getEndpoint(1).configureReporting.mockClear();
|
||||
zigbeeHerdsman.devices.bulb_color.getEndpoint(1).bind.mockClear();
|
||||
zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read.mockClear();
|
||||
debounce.mockClear();
|
||||
await resetExtension();
|
||||
MQTT.publish.mockClear();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('Should bind to device and configure reporting', async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1);
|
||||
const endpoint = device.getEndpoint(1);
|
||||
|
||||
// Setup
|
||||
const originalDeviceOutputClusters = device.getEndpoint(1).outputClusters;
|
||||
device.getEndpoint(1).outputClusters = [...device.getEndpoint(1).outputClusters, 768];
|
||||
const originalTargetBinds = target.binds;
|
||||
target.binds = [{cluster: {name: 'genLevelCtrl'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)}];
|
||||
target.getClusterAttributeValue.mockImplementationOnce((cluster, value) => undefined);
|
||||
mockClear(device);
|
||||
target.configureReporting.mockImplementationOnce(() => {
|
||||
throw new Error('timeout');
|
||||
});
|
||||
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({transaction: '1234', from: 'remote', to: 'bulb_color'}));
|
||||
await flushPromises();
|
||||
expect(target.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities']);
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(4);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('lightingColorCtrl', target);
|
||||
expect(target.configureReporting).toHaveBeenCalledTimes(3);
|
||||
expect(target.configureReporting).toHaveBeenCalledWith('genOnOff', [
|
||||
{attribute: 'onOff', maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
|
||||
]);
|
||||
expect(target.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
|
||||
{attribute: 'currentLevel', maximumReportInterval: 3600, minimumReportInterval: 5, reportableChange: 1},
|
||||
]);
|
||||
expect(target.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
|
||||
{attribute: 'colorTemperature', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
{attribute: 'currentX', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
{attribute: 'currentY', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
]);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({
|
||||
transaction: '1234',
|
||||
data: {from: 'remote', to: 'bulb_color', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl'], failed: []},
|
||||
status: 'ok',
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), {retain: true, qos: 0}, expect.any(Function));
|
||||
|
||||
// Teardown
|
||||
target.binds = originalTargetBinds;
|
||||
device.getEndpoint(1).outputClusters = originalDeviceOutputClusters;
|
||||
});
|
||||
|
||||
it('Filters out unsupported clusters for reporting setup', async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1);
|
||||
const endpoint = device.getEndpoint(1);
|
||||
|
||||
// Setup
|
||||
const originalDeviceInputClusters = device.getEndpoint(1).inputClusters;
|
||||
device.getEndpoint(1).inputClusters = [...device.getEndpoint(1).inputClusters, 8];
|
||||
const originalDeviceOutputClusters = device.getEndpoint(1).outputClusters;
|
||||
device.getEndpoint(1).outputClusters = [...device.getEndpoint(1).outputClusters, 768];
|
||||
const originalTargetInputClusters = target.inputClusters;
|
||||
target.inputClusters = [...originalTargetInputClusters];
|
||||
target.inputClusters.splice(originalTargetInputClusters.indexOf(8), 1); // remove genLevelCtrl
|
||||
const originalTargetOutputClusters = target.outputClusters;
|
||||
target.outputClusters = [...target.outputClusters, 8];
|
||||
const originalTargetBinds = target.binds;
|
||||
target.binds = [{cluster: {name: 'genLevelCtrl'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)}];
|
||||
target.getClusterAttributeValue.mockImplementationOnce((cluster, value) => undefined);
|
||||
mockClear(device);
|
||||
target.configureReporting.mockImplementationOnce(() => {
|
||||
throw new Error('timeout');
|
||||
});
|
||||
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({transaction: '1234', from: 'remote', to: 'bulb_color'}));
|
||||
await flushPromises();
|
||||
|
||||
expect(target.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities']);
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(4);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('lightingColorCtrl', target);
|
||||
expect(target.configureReporting).toHaveBeenCalledTimes(2);
|
||||
expect(target.configureReporting).toHaveBeenCalledWith('genOnOff', [
|
||||
{attribute: 'onOff', maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
|
||||
]);
|
||||
// expect(target.configureReporting).toHaveBeenCalledWith("genLevelCtrl",[{"attribute": "currentLevel", "maximumReportInterval": 3600, "minimumReportInterval": 5, "reportableChange": 1}]);
|
||||
expect(target.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
|
||||
{attribute: 'colorTemperature', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
{attribute: 'currentX', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
{attribute: 'currentY', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
]);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({
|
||||
transaction: '1234',
|
||||
data: {from: 'remote', to: 'bulb_color', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl'], failed: []},
|
||||
status: 'ok',
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), {retain: true, qos: 0}, expect.any(Function));
|
||||
|
||||
// Teardown
|
||||
target.binds = originalTargetBinds;
|
||||
target.inputClusters = originalTargetInputClusters;
|
||||
target.outputClusters = originalTargetOutputClusters;
|
||||
device.getEndpoint(1).inputClusters = originalDeviceInputClusters;
|
||||
device.getEndpoint(1).outputClusters = originalDeviceOutputClusters;
|
||||
});
|
||||
|
||||
it('Filters out reporting setup based on bind status', async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1);
|
||||
const endpoint = device.getEndpoint(1);
|
||||
|
||||
// Setup
|
||||
const originalDeviceOutputClusters = device.getEndpoint(1).outputClusters;
|
||||
device.getEndpoint(1).outputClusters = [...device.getEndpoint(1).outputClusters, 768];
|
||||
const originalTargetBinds = target.binds;
|
||||
target.binds = [{cluster: {name: 'genLevelCtrl'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)}];
|
||||
target.getClusterAttributeValue.mockImplementationOnce((cluster, value) => undefined);
|
||||
mockClear(device);
|
||||
target.configureReporting.mockImplementationOnce(() => {
|
||||
throw new Error('timeout');
|
||||
});
|
||||
const originalTargetCR = target.configuredReportings;
|
||||
target.configuredReportings = [
|
||||
{
|
||||
cluster: {name: 'genLevelCtrl'},
|
||||
attribute: {name: 'currentLevel', ID: 0},
|
||||
minimumReportInterval: 0,
|
||||
maximumReportInterval: 3600,
|
||||
reportableChange: 0,
|
||||
},
|
||||
];
|
||||
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({transaction: '1234', from: 'remote', to: 'bulb_color'}));
|
||||
await flushPromises();
|
||||
expect(target.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities']);
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(4);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('lightingColorCtrl', target);
|
||||
expect(target.configureReporting).toHaveBeenCalledTimes(2);
|
||||
expect(target.configureReporting).toHaveBeenCalledWith('genOnOff', [
|
||||
{attribute: 'onOff', maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
|
||||
]);
|
||||
// expect(target.configureReporting).toHaveBeenCalledWith("genLevelCtrl",[{"attribute": "currentLevel", "maximumReportInterval": 3600, "minimumReportInterval": 5, "reportableChange": 1}]);
|
||||
expect(target.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
|
||||
{attribute: 'colorTemperature', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
{attribute: 'currentX', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
{attribute: 'currentY', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
]);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({
|
||||
transaction: '1234',
|
||||
data: {from: 'remote', to: 'bulb_color', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl'], failed: []},
|
||||
status: 'ok',
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), {retain: true, qos: 0}, expect.any(Function));
|
||||
|
||||
// Teardown
|
||||
target.configuredReportings = originalTargetCR;
|
||||
target.binds = originalTargetBinds;
|
||||
device.getEndpoint(1).outputClusters = originalDeviceOutputClusters;
|
||||
});
|
||||
|
||||
it('Should bind only specified clusters', async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1);
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'bulb_color', clusters: ['genOnOff']}));
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({data: {from: 'remote', to: 'bulb_color', clusters: ['genOnOff'], failed: []}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should log error when there is nothing to bind', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
logger.error.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'button'}));
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(0);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({data: {from: 'remote', to: 'button', clusters: [], failed: []}, status: 'error', error: 'Nothing to bind'}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should unbind', async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1);
|
||||
|
||||
// setup
|
||||
target.configureReporting.mockImplementationOnce(() => {
|
||||
throw new Error('timeout');
|
||||
});
|
||||
const originalRemoteBinds = device.getEndpoint(1).binds;
|
||||
device.getEndpoint(1).binds = [];
|
||||
const originalTargetBinds = target.binds;
|
||||
target.binds = [
|
||||
{cluster: {name: 'genOnOff'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)},
|
||||
{cluster: {name: 'genLevelCtrl'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)},
|
||||
{cluster: {name: 'lightingColorCtrl'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)},
|
||||
];
|
||||
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
delete zigbeeHerdsman.devices.bulb_color.meta.configured;
|
||||
expect(zigbeeHerdsman.devices.bulb_color.meta.configured).toBe(undefined);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'bulb_color'}));
|
||||
await flushPromises();
|
||||
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', target);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', target);
|
||||
|
||||
// Disable reporting
|
||||
expect(target.configureReporting).toHaveBeenCalledTimes(3);
|
||||
expect(target.configureReporting).toHaveBeenCalledWith('genOnOff', [
|
||||
{attribute: 'onOff', maximumReportInterval: 0xffff, minimumReportInterval: 0, reportableChange: 0},
|
||||
]);
|
||||
expect(target.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
|
||||
{attribute: 'currentLevel', maximumReportInterval: 0xffff, minimumReportInterval: 5, reportableChange: 1},
|
||||
]);
|
||||
expect(target.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
|
||||
{attribute: 'colorTemperature', minimumReportInterval: 5, maximumReportInterval: 0xffff, reportableChange: 1},
|
||||
{attribute: 'currentX', minimumReportInterval: 5, maximumReportInterval: 0xffff, reportableChange: 1},
|
||||
{attribute: 'currentY', minimumReportInterval: 5, maximumReportInterval: 0xffff, reportableChange: 1},
|
||||
]);
|
||||
expect(zigbeeHerdsman.devices.bulb_color.meta.configured).toBe(332242049);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/unbind',
|
||||
stringify({data: {from: 'remote', to: 'bulb_color', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
// Teardown
|
||||
target.binds = originalTargetBinds;
|
||||
device.getEndpoint(1).binds = originalRemoteBinds;
|
||||
});
|
||||
|
||||
it('Should unbind coordinator', async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const target = zigbeeHerdsman.devices.coordinator.getEndpoint(1);
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
endpoint.unbind.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'Coordinator'}));
|
||||
await flushPromises();
|
||||
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', target);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', target);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/unbind',
|
||||
stringify({data: {from: 'remote', to: 'Coordinator', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should bind to groups', async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const target = zigbeeHerdsman.groups.group_1;
|
||||
const target1Member = zigbeeHerdsman.devices.bulb.getEndpoint(1);
|
||||
const endpoint = device.getEndpoint(1);
|
||||
target.members.push(target1Member);
|
||||
target1Member.configureReporting.mockClear();
|
||||
mockClear(device);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'group_1'}));
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledTimes(2);
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledWith('genOnOff', [
|
||||
{attribute: 'onOff', maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
|
||||
]);
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
|
||||
{attribute: 'currentLevel', maximumReportInterval: 3600, minimumReportInterval: 5, reportableChange: 1},
|
||||
]);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({data: {from: 'remote', to: 'group_1', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
// Should configure reproting for device added to group
|
||||
target1Member.configureReporting.mockClear();
|
||||
await MQTT.events.message('zigbee2mqtt/bridge/group/group_1/add', 'bulb');
|
||||
await flushPromises();
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledTimes(2);
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledWith('genOnOff', [
|
||||
{attribute: 'onOff', maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
|
||||
]);
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
|
||||
{attribute: 'currentLevel', maximumReportInterval: 3600, minimumReportInterval: 5, reportableChange: 1},
|
||||
]);
|
||||
});
|
||||
|
||||
it('Should unbind from group', async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const target = zigbeeHerdsman.groups.group_1;
|
||||
const target1Member = zigbeeHerdsman.devices.bulb.getEndpoint(1);
|
||||
const endpoint = device.getEndpoint(1);
|
||||
target.members.push(target1Member);
|
||||
target1Member.configureReporting.mockClear();
|
||||
mockClear(device);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'group_1'}));
|
||||
await flushPromises();
|
||||
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', target);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', target);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/unbind',
|
||||
stringify({data: {from: 'remote', to: 'group_1', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should unbind from group with skip_disable_reporting=true', async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const target = zigbeeHerdsman.groups.group_1;
|
||||
const target1Member = zigbeeHerdsman.devices.bulb_2.getEndpoint(1);
|
||||
const endpoint = device.getEndpoint(1);
|
||||
target.members.push(target1Member);
|
||||
|
||||
// The device unbind mock doesn't remove binds, therefore remove them here already otherwise configure reporiting is not disabled.
|
||||
const originalBinds = endpoint.binds;
|
||||
endpoint.binds = [];
|
||||
|
||||
target1Member.binds = [
|
||||
{cluster: {name: 'genLevelCtrl'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)},
|
||||
{cluster: {name: 'genOnOff'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)},
|
||||
];
|
||||
target1Member.configureReporting.mockClear();
|
||||
mockClear(device);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'group_1', skip_disable_reporting: true}));
|
||||
await flushPromises();
|
||||
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
|
||||
// with skip_disable_reporting set to false, we don't expect it to reconfigure reporting
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledTimes(0);
|
||||
endpoint.binds = originalBinds;
|
||||
});
|
||||
|
||||
it('Should unbind from group with skip_disable_reporting=false', async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const target = zigbeeHerdsman.groups.group_1;
|
||||
const target1Member = zigbeeHerdsman.devices.bulb_2.getEndpoint(1);
|
||||
const endpoint = device.getEndpoint(1);
|
||||
target.members.push(target1Member);
|
||||
|
||||
// The device unbind mock doesn't remove binds, therefore remove them here already otherwise configure reporiting is not disabled.
|
||||
const originalBinds = endpoint.binds;
|
||||
endpoint.binds = [];
|
||||
|
||||
target1Member.binds = [
|
||||
{cluster: {name: 'genLevelCtrl'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)},
|
||||
{cluster: {name: 'genOnOff'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)},
|
||||
];
|
||||
target1Member.configureReporting.mockClear();
|
||||
mockClear(device);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'group_1', skip_disable_reporting: false}));
|
||||
await flushPromises();
|
||||
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
|
||||
// with skip_disable_reporting set, we expect it to reconfigure reporting
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledTimes(2);
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
|
||||
{attribute: 'currentLevel', maximumReportInterval: 65535, minimumReportInterval: 5, reportableChange: 1},
|
||||
]);
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledWith('genOnOff', [
|
||||
{attribute: 'onOff', maximumReportInterval: 65535, minimumReportInterval: 0, reportableChange: 0},
|
||||
]);
|
||||
endpoint.binds = originalBinds;
|
||||
});
|
||||
|
||||
it('Should bind to group by number', async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const target = zigbeeHerdsman.groups.group_1;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: '1'}));
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({data: {from: 'remote', to: '1', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should log when bind fails', async () => {
|
||||
logger.error.mockClear();
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
endpoint.bind.mockImplementation(() => {
|
||||
throw new Error('failed');
|
||||
});
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'bulb_color'}));
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(3);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({
|
||||
data: {from: 'remote', to: 'bulb_color', clusters: [], failed: ['genScenes', 'genOnOff', 'genLevelCtrl']},
|
||||
status: 'error',
|
||||
error: 'Failed to bind',
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should bind from non default endpoints', async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const target = zigbeeHerdsman.devices.QBKG03LM.getEndpoint(3);
|
||||
const endpoint = device.getEndpoint(2);
|
||||
mockClear(device);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote/ep2', to: 'wall_switch_double/right'}));
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({data: {from: 'remote/ep2', to: 'wall_switch_double/right', clusters: ['genOnOff'], failed: []}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should bind server clusters to client clusters', async () => {
|
||||
const device = zigbeeHerdsman.devices.temperature_sensor;
|
||||
const target = zigbeeHerdsman.devices.heating_actuator.getEndpoint(1);
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'temperature_sensor', to: 'heating_actuator'}));
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('msTemperatureMeasurement', target);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({data: {from: 'temperature_sensor', to: 'heating_actuator', clusters: ['msTemperatureMeasurement'], failed: []}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should bind to default endpoint returned by endpoints()', async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const target = zigbeeHerdsman.devices.QBKG04LM.getEndpoint(2);
|
||||
const endpoint = device.getEndpoint(2);
|
||||
mockClear(device);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote/ep2', to: 'wall_switch'}));
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({data: {from: 'remote/ep2', to: 'wall_switch', clusters: ['genOnOff'], failed: []}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should unbind from default_bind_group', async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const target = 'default_bind_group';
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: target}));
|
||||
await flushPromises();
|
||||
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', 901);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', 901);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', 901);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/unbind',
|
||||
stringify({
|
||||
data: {from: 'remote', to: 'default_bind_group', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []},
|
||||
status: 'ok',
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Error bind fails when source device does not exist', async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1);
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote_not_existing', to: 'bulb_color'}));
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({
|
||||
data: {from: 'remote_not_existing', to: 'bulb_color'},
|
||||
status: 'error',
|
||||
error: "Source device 'remote_not_existing' does not exist",
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("Error bind fails when source device's endpoint does not exist", async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1);
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote/not_existing_endpoint', to: 'bulb_color'}));
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({
|
||||
data: {from: 'remote/not_existing_endpoint', to: 'bulb_color'},
|
||||
status: 'error',
|
||||
error: "Source device 'remote' does not have endpoint 'not_existing_endpoint'",
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Error bind fails when target device or group does not exist', async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1);
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'bulb_color_not_existing'}));
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({
|
||||
data: {from: 'remote', to: 'bulb_color_not_existing'},
|
||||
status: 'error',
|
||||
error: "Target device or group 'bulb_color_not_existing' does not exist",
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("Error bind fails when target device's endpoint does not exist", async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1);
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'bulb_color/not_existing_endpoint'}));
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({
|
||||
data: {from: 'remote', to: 'bulb_color/not_existing_endpoint'},
|
||||
status: 'error',
|
||||
error: "Target device 'bulb_color' does not have endpoint 'not_existing_endpoint'",
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Legacy api: Should bind', async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1);
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/bind/remote', 'bulb_color');
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({
|
||||
type: 'device_bind',
|
||||
message: {from: 'remote', to: 'bulb_color', cluster: 'genScenes'},
|
||||
});
|
||||
expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bridge/log');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({
|
||||
type: 'device_bind',
|
||||
message: {from: 'remote', to: 'bulb_color', cluster: 'genOnOff'},
|
||||
});
|
||||
expect(MQTT.publish.mock.calls[2][0]).toStrictEqual('zigbee2mqtt/bridge/log');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({
|
||||
type: 'device_bind',
|
||||
message: {from: 'remote', to: 'bulb_color', cluster: 'genLevelCtrl'},
|
||||
});
|
||||
});
|
||||
|
||||
it('Legacy api: Should log error when there is nothing to bind', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
logger.error.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/bind/remote', 'button');
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(0);
|
||||
expect(logger.error).toHaveBeenCalledWith(`Nothing to bind from 'remote' to 'button'`);
|
||||
});
|
||||
|
||||
it('Legacy api: Should unbind', async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1);
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/unbind/remote', 'bulb_color');
|
||||
await flushPromises();
|
||||
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', target);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', target);
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({
|
||||
type: 'device_unbind',
|
||||
message: {from: 'remote', to: 'bulb_color', cluster: 'genScenes'},
|
||||
});
|
||||
expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bridge/log');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({
|
||||
type: 'device_unbind',
|
||||
message: {from: 'remote', to: 'bulb_color', cluster: 'genOnOff'},
|
||||
});
|
||||
expect(MQTT.publish.mock.calls[2][0]).toStrictEqual('zigbee2mqtt/bridge/log');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({
|
||||
type: 'device_unbind',
|
||||
message: {from: 'remote', to: 'bulb_color', cluster: 'genLevelCtrl'},
|
||||
});
|
||||
});
|
||||
|
||||
it('Legacy api: Should unbind coordinator', async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const target = zigbeeHerdsman.devices.coordinator.getEndpoint(1);
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
endpoint.unbind.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/unbind/remote', 'Coordinator');
|
||||
await flushPromises();
|
||||
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', target);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', target);
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({
|
||||
type: 'device_unbind',
|
||||
message: {from: 'remote', to: 'Coordinator', cluster: 'genScenes'},
|
||||
});
|
||||
expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bridge/log');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({
|
||||
type: 'device_unbind',
|
||||
message: {from: 'remote', to: 'Coordinator', cluster: 'genOnOff'},
|
||||
});
|
||||
expect(MQTT.publish.mock.calls[2][0]).toStrictEqual('zigbee2mqtt/bridge/log');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({
|
||||
type: 'device_unbind',
|
||||
message: {from: 'remote', to: 'Coordinator', cluster: 'genLevelCtrl'},
|
||||
});
|
||||
});
|
||||
|
||||
it('Legacy api: Should bind to groups', async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const target = zigbeeHerdsman.groups.group_1;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/bind/remote', 'group_1');
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({
|
||||
type: 'device_bind',
|
||||
message: {from: 'remote', to: 'group_1', cluster: 'genScenes'},
|
||||
});
|
||||
expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bridge/log');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({
|
||||
type: 'device_bind',
|
||||
message: {from: 'remote', to: 'group_1', cluster: 'genOnOff'},
|
||||
});
|
||||
expect(MQTT.publish.mock.calls[2][0]).toStrictEqual('zigbee2mqtt/bridge/log');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({
|
||||
type: 'device_bind',
|
||||
message: {from: 'remote', to: 'group_1', cluster: 'genLevelCtrl'},
|
||||
});
|
||||
});
|
||||
|
||||
it('Legacy api: Should bind to group by number', async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const target = zigbeeHerdsman.groups.group_1;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/bind/remote', '1');
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({
|
||||
type: 'device_bind',
|
||||
message: {from: 'remote', to: 'group_1', cluster: 'genScenes'},
|
||||
});
|
||||
expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bridge/log');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({
|
||||
type: 'device_bind',
|
||||
message: {from: 'remote', to: 'group_1', cluster: 'genOnOff'},
|
||||
});
|
||||
expect(MQTT.publish.mock.calls[2][0]).toStrictEqual('zigbee2mqtt/bridge/log');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({
|
||||
type: 'device_bind',
|
||||
message: {from: 'remote', to: 'group_1', cluster: 'genLevelCtrl'},
|
||||
});
|
||||
});
|
||||
|
||||
it('Legacy api: Should log when bind fails', async () => {
|
||||
logger.error.mockClear();
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
endpoint.bind.mockImplementationOnce(() => {
|
||||
throw new Error('failed');
|
||||
});
|
||||
MQTT.events.message('zigbee2mqtt/bridge/bind/remote', 'bulb_color');
|
||||
await flushPromises();
|
||||
expect(logger.error).toHaveBeenCalledWith("Failed to bind cluster 'genScenes' from 'remote' to 'bulb_color' (Error: failed)");
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('Legacy api: Should bind from non default endpoints', async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const target = zigbeeHerdsman.devices.QBKG03LM.getEndpoint(3);
|
||||
const endpoint = device.getEndpoint(2);
|
||||
mockClear(device);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/bind/remote/ep2', 'wall_switch_double/right');
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
});
|
||||
|
||||
it('Legacy api: Should bind to default endpoint returned by endpoints()', async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const target = zigbeeHerdsman.devices.QBKG04LM.getEndpoint(2);
|
||||
const endpoint = device.getEndpoint(2);
|
||||
mockClear(device);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/bind/remote/ep2', 'wall_switch');
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
});
|
||||
|
||||
it('Legacy api: Should unbind from default_bind_group', async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const target = 'default_bind_group';
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/unbind/remote', target);
|
||||
await flushPromises();
|
||||
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', 901);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', 901);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', 901);
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({
|
||||
type: 'device_unbind',
|
||||
message: {from: 'remote', to: 'default_bind_group', cluster: 'genScenes'},
|
||||
});
|
||||
expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bridge/log');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({
|
||||
type: 'device_unbind',
|
||||
message: {from: 'remote', to: 'default_bind_group', cluster: 'genOnOff'},
|
||||
});
|
||||
expect(MQTT.publish.mock.calls[2][0]).toStrictEqual('zigbee2mqtt/bridge/log');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({
|
||||
type: 'device_unbind',
|
||||
message: {from: 'remote', to: 'default_bind_group', cluster: 'genLevelCtrl'},
|
||||
});
|
||||
});
|
||||
|
||||
it('Should poll bounded Hue bulb when receiving message from Hue dimmer', async () => {
|
||||
const remote = zigbeeHerdsman.devices.remote;
|
||||
const data = {button: 3, unknown1: 3145728, type: 2, unknown2: 0, time: 1};
|
||||
const payload = {
|
||||
data,
|
||||
cluster: 'manuSpecificPhilips',
|
||||
device: remote,
|
||||
endpoint: remote.getEndpoint(2),
|
||||
type: 'commandHueNotification',
|
||||
linkquality: 10,
|
||||
groupID: 0,
|
||||
};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await flushPromises();
|
||||
expect(debounce).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsman.devices.bulb_color.getEndpoint(1).read).toHaveBeenCalledWith('genLevelCtrl', ['currentLevel']);
|
||||
});
|
||||
|
||||
it('Should poll bounded Hue bulb when receiving message from scene controller', async () => {
|
||||
const remote = zigbeeHerdsman.devices.bj_scene_switch;
|
||||
const data = {action: 'recall_2_row_1'};
|
||||
zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read.mockImplementationOnce(() => {
|
||||
throw new Error('failed');
|
||||
});
|
||||
const payload = {
|
||||
data,
|
||||
cluster: 'genScenes',
|
||||
device: remote,
|
||||
endpoint: remote.getEndpoint(10),
|
||||
type: 'commandRecall',
|
||||
linkquality: 10,
|
||||
groupID: 0,
|
||||
};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await flushPromises();
|
||||
// Calls to three clusters are expected in this case
|
||||
expect(debounce).toHaveBeenCalledTimes(3);
|
||||
expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledWith('genOnOff', ['onOff']);
|
||||
expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledWith('genLevelCtrl', ['currentLevel']);
|
||||
expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledWith('lightingColorCtrl', [
|
||||
'currentX',
|
||||
'currentY',
|
||||
'colorTemperature',
|
||||
]);
|
||||
});
|
||||
|
||||
it('Should poll grouped Hue bulb when receiving message from TRADFRI remote', async () => {
|
||||
zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read.mockClear();
|
||||
zigbeeHerdsman.devices.bulb_2.getEndpoint(1).read.mockClear();
|
||||
const remote = zigbeeHerdsman.devices.tradfri_remote;
|
||||
const data = {stepmode: 0, stepsize: 43, transtime: 5};
|
||||
const payload = {
|
||||
data,
|
||||
cluster: 'genLevelCtrl',
|
||||
device: remote,
|
||||
endpoint: remote.getEndpoint(1),
|
||||
type: 'commandStepWithOnOff',
|
||||
linkquality: 10,
|
||||
groupID: 15071,
|
||||
};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await flushPromises();
|
||||
expect(debounce).toHaveBeenCalledTimes(2);
|
||||
expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledTimes(2);
|
||||
expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledWith('genLevelCtrl', ['currentLevel']);
|
||||
expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledWith('genOnOff', ['onOff']);
|
||||
|
||||
// Should also only debounce once
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await flushPromises();
|
||||
expect(debounce).toHaveBeenCalledTimes(2);
|
||||
expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledTimes(4);
|
||||
|
||||
// Should only call Hue bulb, not e.g. tradfri
|
||||
expect(zigbeeHerdsman.devices.bulb_2.getEndpoint(1).read).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
const logger = require('./stub/logger');
|
||||
const data = require('../lib/util/data').default;
|
||||
const path = require('path');
|
||||
const tmp = require('tmp');
|
||||
const fs = require('fs');
|
||||
import path from 'node:path';
|
||||
|
||||
import tmp from 'tmp';
|
||||
|
||||
import data from '../lib/util/data';
|
||||
|
||||
describe('Data', () => {
|
||||
describe('Get path', () => {
|
||||
@@ -1,61 +1,64 @@
|
||||
const data = require('./stub/data');
|
||||
const logger = require('./stub/logger');
|
||||
const zigbeeHerdsman = require('./stub/zigbeeHerdsman');
|
||||
const MQTT = require('./stub/mqtt');
|
||||
const settings = require('../lib/util/settings');
|
||||
const Controller = require('../lib/controller');
|
||||
const flushPromises = require('./lib/flushPromises');
|
||||
const Availability = require('../lib/extension/availability').default;
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
const utils = require('../lib/util/utils').default;
|
||||
import * as data from '../mocks/data';
|
||||
import {mockLogger} from '../mocks/logger';
|
||||
import {events as mockMQTTEvents, mockMQTTPublishAsync} from '../mocks/mqtt';
|
||||
import {flushPromises} from '../mocks/utils';
|
||||
import {devices, events as mockZHEvents, returnDevices} from '../mocks/zigbeeHerdsman';
|
||||
|
||||
const mocks = [MQTT.publish, logger.warning, logger.info];
|
||||
const devices = zigbeeHerdsman.devices;
|
||||
zigbeeHerdsman.returnDevices.push(
|
||||
...[
|
||||
devices.bulb_color.ieeeAddr,
|
||||
devices.bulb_color_2.ieeeAddr,
|
||||
devices.coordinator.ieeeAddr,
|
||||
devices.remote.ieeeAddr,
|
||||
devices.TS0601_thermostat.ieeeAddr,
|
||||
devices.bulb_2.ieeeAddr,
|
||||
devices.ZNCZ02LM.ieeeAddr,
|
||||
devices.GLEDOPTO_2ID.ieeeAddr,
|
||||
devices.QBKG03LM.ieeeAddr,
|
||||
],
|
||||
import assert from 'node:assert';
|
||||
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
import {Controller} from '../../lib/controller';
|
||||
import Availability from '../../lib/extension/availability';
|
||||
import * as settings from '../../lib/util/settings';
|
||||
import utils from '../../lib/util/utils';
|
||||
|
||||
const mocksClear = [mockMQTTPublishAsync, mockLogger.warning, mockLogger.info];
|
||||
|
||||
returnDevices.push(
|
||||
devices.bulb_color.ieeeAddr,
|
||||
devices.bulb_color_2.ieeeAddr,
|
||||
devices.coordinator.ieeeAddr,
|
||||
devices.remote.ieeeAddr,
|
||||
devices.TS0601_thermostat.ieeeAddr,
|
||||
devices.bulb_2.ieeeAddr,
|
||||
devices.ZNCZ02LM.ieeeAddr,
|
||||
devices.GLEDOPTO_2ID.ieeeAddr,
|
||||
devices.QBKG03LM.ieeeAddr,
|
||||
devices.hue_twilight.ieeeAddr,
|
||||
);
|
||||
|
||||
describe('Availability', () => {
|
||||
let controller;
|
||||
describe('Extension: Availability', () => {
|
||||
let controller: Controller;
|
||||
|
||||
let resetExtension = async () => {
|
||||
const resetExtension = async (): Promise<void> => {
|
||||
await controller.enableDisableExtension(false, 'Availability');
|
||||
await controller.enableDisableExtension(true, 'Availability');
|
||||
};
|
||||
|
||||
const setTimeAndAdvanceTimers = async (value) => {
|
||||
jest.setSystemTime(Date.now() + value);
|
||||
await jest.advanceTimersByTimeAsync(value);
|
||||
const setTimeAndAdvanceTimers = async (value: number): Promise<void> => {
|
||||
vi.setSystemTime(Date.now() + value);
|
||||
await vi.advanceTimersByTimeAsync(value);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.spyOn(utils, 'sleep').mockImplementation(async (seconds) => {});
|
||||
jest.useFakeTimers();
|
||||
vi.spyOn(utils, 'sleep').mockImplementation(vi.fn());
|
||||
vi.useFakeTimers();
|
||||
settings.reRead();
|
||||
settings.set(['availability'], true);
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
settings.set(['availability'], {enabled: true});
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.setSystemTime(utils.minutes(1));
|
||||
vi.setSystemTime(utils.minutes(1));
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
settings.set(['availability'], true);
|
||||
settings.set(['availability'], {enabled: true});
|
||||
settings.set(['devices', devices.bulb_color_2.ieeeAddr, 'availability'], false);
|
||||
Object.values(devices).forEach((d) => (d.lastSeen = utils.minutes(1)));
|
||||
mocks.forEach((m) => m.mockClear());
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
await resetExtension();
|
||||
Object.values(devices).forEach((d) => d.ping.mockClear());
|
||||
});
|
||||
@@ -64,18 +67,19 @@ describe('Availability', () => {
|
||||
|
||||
afterAll(async () => {
|
||||
await controller.stop();
|
||||
jest.useRealTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('Should publish availability on startup for device where it is enabled for', async () => {
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', 'online', {retain: true, qos: 1}, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/remote/availability', 'online', {retain: true, qos: 1}, expect.any(Function));
|
||||
expect(MQTT.publish).not.toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb_color_2/availability',
|
||||
'online',
|
||||
{retain: true, qos: 1},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', stringify({state: 'online'}), {
|
||||
retain: true,
|
||||
qos: 1,
|
||||
});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/remote/availability', stringify({state: 'online'}), {retain: true, qos: 1});
|
||||
expect(mockMQTTPublishAsync).not.toHaveBeenCalledWith('zigbee2mqtt/bulb_color_2/availability', stringify({state: 'online'}), {
|
||||
retain: true,
|
||||
qos: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('Should ping on startup for enabled and unavailable devices', async () => {
|
||||
@@ -102,38 +106,44 @@ describe('Availability', () => {
|
||||
});
|
||||
|
||||
it('Should publish offline for active device when not seen for 10 minutes', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
await setTimeAndAdvanceTimers(utils.minutes(5));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(7));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
|
||||
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', 'offline', {retain: true, qos: 1}, expect.any(Function));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', stringify({state: 'offline'}), {
|
||||
retain: true,
|
||||
qos: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('Shouldnt do anything for a device when availability: false is set for device', async () => {
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device: devices.bulb_color_2}); // Coverage satisfaction
|
||||
await mockZHEvents.lastSeenChanged({device: devices.bulb_color_2}); // Coverage satisfaction
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(12));
|
||||
expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should publish offline for passive device when not seen for 25 hours', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
await setTimeAndAdvanceTimers(utils.hours(26));
|
||||
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/remote/availability', 'offline', {retain: true, qos: 1}, expect.any(Function));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/remote/availability', stringify({state: 'offline'}), {retain: true, qos: 1});
|
||||
});
|
||||
|
||||
it('Should reset ping timer when device last seen changes for active device', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(5));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device: devices.bulb_color});
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', 'offline', {retain: true, qos: 1}, expect.any(Function));
|
||||
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', stringify({state: 'offline'}), {
|
||||
retain: true,
|
||||
qos: 1,
|
||||
});
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(7));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
@@ -144,9 +154,9 @@ describe('Availability', () => {
|
||||
});
|
||||
|
||||
it('Should ping again when first ping fails', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device: devices.bulb_color});
|
||||
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
|
||||
|
||||
devices.bulb_color.ping.mockImplementationOnce(() => {
|
||||
throw new Error('failed');
|
||||
@@ -159,13 +169,13 @@ describe('Availability', () => {
|
||||
});
|
||||
|
||||
it('Should reset ping timer when device last seen changes for passive device', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.hours(24));
|
||||
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device: devices.remote});
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/remote/availability', 'offline', {retain: true, qos: 1}, expect.any(Function));
|
||||
await mockZHEvents.lastSeenChanged({device: devices.remote});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/remote/availability', stringify({state: 'offline'}), {retain: true, qos: 1});
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.hours(25));
|
||||
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
|
||||
@@ -175,15 +185,21 @@ describe('Availability', () => {
|
||||
});
|
||||
|
||||
it('Should immediately mark device as online when it lastSeen changes', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(15));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', 'offline', {retain: true, qos: 1}, expect.any(Function));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', stringify({state: 'offline'}), {
|
||||
retain: true,
|
||||
qos: 1,
|
||||
});
|
||||
|
||||
devices.bulb_color.lastSeen = Date.now();
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device: devices.bulb_color});
|
||||
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', 'online', {retain: true, qos: 1}, expect.any(Function));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', stringify({state: 'online'}), {
|
||||
retain: true,
|
||||
qos: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('Should allow to change availability timeout via device options', async () => {
|
||||
@@ -208,7 +224,7 @@ describe('Availability', () => {
|
||||
});
|
||||
|
||||
it('Should allow to change availability timeout via avaiability options', async () => {
|
||||
settings.set(['availability'], {active: {timeout: 30}});
|
||||
settings.set(['availability', 'active', 'timeout'], 30);
|
||||
await resetExtension();
|
||||
devices.bulb_color.ping.mockClear();
|
||||
|
||||
@@ -223,7 +239,7 @@ describe('Availability', () => {
|
||||
await setTimeAndAdvanceTimers(utils.minutes(9));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
await zigbeeHerdsman.events.deviceLeave({ieeeAddr: devices.bulb_color.ieeeAddr});
|
||||
await mockZHEvents.deviceLeave({ieeeAddr: devices.bulb_color.ieeeAddr});
|
||||
await flushPromises();
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(3));
|
||||
@@ -234,7 +250,7 @@ describe('Availability', () => {
|
||||
await setTimeAndAdvanceTimers(utils.minutes(9));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/remove', stringify({id: 'bulb_color'}));
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/remove', stringify({id: 'bulb_color'}));
|
||||
await flushPromises();
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(3));
|
||||
@@ -242,7 +258,7 @@ describe('Availability', () => {
|
||||
});
|
||||
|
||||
it('Should allow to be disabled', async () => {
|
||||
settings.set(['availability'], false);
|
||||
settings.set(['availability'], {enabled: false});
|
||||
await resetExtension();
|
||||
devices.bulb_color.ping.mockClear();
|
||||
|
||||
@@ -251,7 +267,7 @@ describe('Availability', () => {
|
||||
});
|
||||
|
||||
it('Should allow to enable availability for just one device', async () => {
|
||||
settings.set(['availability'], false);
|
||||
settings.set(['availability'], {enabled: false});
|
||||
settings.set(['devices', devices.bulb_color.ieeeAddr, 'availability'], true);
|
||||
|
||||
await resetExtension();
|
||||
@@ -262,18 +278,19 @@ describe('Availability', () => {
|
||||
});
|
||||
|
||||
it('Should retrieve device state when it reconnects', async () => {
|
||||
//@ts-expect-error private
|
||||
// @ts-expect-error private
|
||||
const device = controller.zigbee.resolveEntity(devices.bulb_color.ieeeAddr);
|
||||
//@ts-expect-error private
|
||||
// @ts-expect-error private
|
||||
controller.state.set(device, {state: 'OFF'});
|
||||
|
||||
const endpoint = devices.bulb_color.getEndpoint(1);
|
||||
assert(endpoint);
|
||||
endpoint.read.mockClear();
|
||||
|
||||
await zigbeeHerdsman.events.deviceAnnounce({device: devices.bulb_color});
|
||||
await mockZHEvents.deviceAnnounce({device: devices.bulb_color});
|
||||
await flushPromises();
|
||||
await setTimeAndAdvanceTimers(utils.seconds(1));
|
||||
await zigbeeHerdsman.events.deviceAnnounce({device: devices.bulb_color});
|
||||
await mockZHEvents.deviceAnnounce({device: devices.bulb_color});
|
||||
await flushPromises();
|
||||
|
||||
expect(endpoint.read).toHaveBeenCalledTimes(0);
|
||||
@@ -283,7 +300,7 @@ describe('Availability', () => {
|
||||
expect(endpoint.read).toHaveBeenCalledWith('genOnOff', ['onOff']);
|
||||
|
||||
endpoint.read.mockClear();
|
||||
await zigbeeHerdsman.events.deviceAnnounce({device: devices.bulb_color});
|
||||
await mockZHEvents.deviceAnnounce({device: devices.bulb_color});
|
||||
await flushPromises();
|
||||
endpoint.read.mockImplementationOnce(() => {
|
||||
throw new Error('');
|
||||
@@ -293,61 +310,30 @@ describe('Availability', () => {
|
||||
});
|
||||
|
||||
it('Should republish availability when device is renamed', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/rename', stringify({from: 'bulb_color', to: 'bulb_new_name'}));
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/rename', stringify({from: 'bulb_color', to: 'bulb_new_name'}));
|
||||
await flushPromises();
|
||||
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', '', {retain: true, qos: 1}, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_new_name/availability', 'online', {retain: true, qos: 1}, expect.any(Function));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', '', {retain: true, qos: 1});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_new_name/availability', stringify({state: 'online'}), {
|
||||
retain: true,
|
||||
qos: 1,
|
||||
});
|
||||
await setTimeAndAdvanceTimers(utils.hours(12));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_new_name/availability', 'offline', {retain: true, qos: 1}, expect.any(Function));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_new_name/availability', stringify({state: 'offline'}), {
|
||||
retain: true,
|
||||
qos: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('Should publish availability payload in JSON format', async () => {
|
||||
settings.set(['advanced', 'legacy_availability_payload'], false);
|
||||
await resetExtension();
|
||||
devices.remote.ping.mockClear();
|
||||
MQTT.publish.mockClear();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
await setTimeAndAdvanceTimers(utils.hours(26));
|
||||
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/remote/availability',
|
||||
stringify({state: 'offline'}),
|
||||
{retain: true, qos: 1},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Deprecated - should allow to block via advanced.availability_blocklist', async () => {
|
||||
settings.set(['advanced', 'availability_blocklist'], [devices.bulb_color.ieeeAddr]);
|
||||
await resetExtension();
|
||||
devices.bulb_color.ping.mockClear();
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(12));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Deprecated - should allow to pass certain devices via availability_passlist', async () => {
|
||||
settings.set(['advanced', 'availability_passlist'], [devices.bulb_color_2.ieeeAddr]);
|
||||
settings.changeEntityOptions(devices.bulb_color_2.ieeeAddr, {availability: null});
|
||||
await resetExtension();
|
||||
devices.bulb_color.ping.mockClear();
|
||||
devices.bulb_color_2.ping.mockClear();
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(12));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Deprecated - should allow to enable via availability_timeout', async () => {
|
||||
settings.set(['availability'], false);
|
||||
settings.set(['advanced', 'availability_timeout'], 60);
|
||||
await resetExtension();
|
||||
devices.bulb_color.ping.mockClear();
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(12));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/remote/availability', stringify({state: 'offline'}), {retain: true, qos: 1});
|
||||
});
|
||||
|
||||
it('Should publish availability for groups', async () => {
|
||||
@@ -355,53 +341,51 @@ describe('Availability', () => {
|
||||
await resetExtension();
|
||||
devices.bulb_color_2.ping.mockClear();
|
||||
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/group_tradfri_remote/availability',
|
||||
'online',
|
||||
{retain: true, qos: 1},
|
||||
expect.any(Function),
|
||||
);
|
||||
MQTT.publish.mockClear();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/group_tradfri_remote/availability', stringify({state: 'online'}), {
|
||||
retain: true,
|
||||
qos: 1,
|
||||
});
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
await setTimeAndAdvanceTimers(utils.minutes(12));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/group_tradfri_remote/availability',
|
||||
'offline',
|
||||
{retain: true, qos: 1},
|
||||
expect.any(Function),
|
||||
);
|
||||
MQTT.publish.mockClear();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/group_tradfri_remote/availability', stringify({state: 'offline'}), {
|
||||
retain: true,
|
||||
qos: 1,
|
||||
});
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
devices.bulb_color_2.lastSeen = Date.now();
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device: devices.bulb_color_2});
|
||||
await mockZHEvents.lastSeenChanged({device: devices.bulb_color_2});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/group_tradfri_remote/availability',
|
||||
'online',
|
||||
{retain: true, qos: 1},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/group_tradfri_remote/availability', stringify({state: 'online'}), {
|
||||
retain: true,
|
||||
qos: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('Should clear the ping queue on stop', async () => {
|
||||
//@ts-expect-error private
|
||||
const availability = controller.extensions.find((extension) => extension instanceof Availability);
|
||||
const publishAvailabilitySpy = jest.spyOn(availability, 'publishAvailability');
|
||||
// @ts-expect-error private
|
||||
const availability = controller.extensions.find((extension) => extension instanceof Availability)!;
|
||||
// @ts-expect-error private
|
||||
const publishAvailabilitySpy = vi.spyOn(availability, 'publishAvailability');
|
||||
|
||||
devices.bulb_color.ping.mockImplementationOnce(() => new Promise((resolve) => setTimeout(resolve, 1000)));
|
||||
// @ts-expect-error private
|
||||
availability.addToPingQueue(devices.bulb_color);
|
||||
// @ts-expect-error private
|
||||
availability.addToPingQueue(devices.bulb_color_2);
|
||||
|
||||
await availability.stop();
|
||||
await setTimeAndAdvanceTimers(utils.minutes(1));
|
||||
|
||||
// @ts-expect-error private
|
||||
expect(availability.pingQueue).toEqual([]);
|
||||
// Validate the stop-interrupt implicitly by checking that it prevents further function invocations
|
||||
expect(publishAvailabilitySpy).not.toHaveBeenCalled();
|
||||
devices.bulb_color.ping = jest.fn(); // ensure reset
|
||||
devices.bulb_color.ping = vi.fn(); // ensure reset
|
||||
});
|
||||
|
||||
it('Should prevent instance restart', async () => {
|
||||
//@ts-expect-error private
|
||||
const availability = controller.extensions.find((extension) => extension instanceof Availability);
|
||||
// @ts-expect-error private
|
||||
const availability = controller.extensions.find((extension) => extension instanceof Availability)!;
|
||||
|
||||
await availability.stop();
|
||||
|
||||
@@ -0,0 +1,774 @@
|
||||
import * as data from '../mocks/data';
|
||||
import {mockDebounce} from '../mocks/debounce';
|
||||
import {mockLogger} from '../mocks/logger';
|
||||
import {events as mockMQTTEvents, mockMQTTPublishAsync} from '../mocks/mqtt';
|
||||
import {flushPromises} from '../mocks/utils';
|
||||
import {Device, devices, groups, events as mockZHEvents} from '../mocks/zigbeeHerdsman';
|
||||
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
import {Controller} from '../../lib/controller';
|
||||
import * as settings from '../../lib/util/settings';
|
||||
|
||||
const mocksClear = [
|
||||
mockDebounce,
|
||||
mockMQTTPublishAsync,
|
||||
devices.bulb_color.getEndpoint(1)!.configureReporting,
|
||||
devices.bulb_color.getEndpoint(1)!.bind,
|
||||
devices.bulb_color_2.getEndpoint(1)!.read,
|
||||
];
|
||||
|
||||
describe('Extension: Bind', () => {
|
||||
let controller: Controller;
|
||||
|
||||
const resetExtension = async (): Promise<void> => {
|
||||
await controller.enableDisableExtension(false, 'Bind');
|
||||
await controller.enableDisableExtension(true, 'Bind');
|
||||
};
|
||||
|
||||
const mockClear = (device: Device): void => {
|
||||
for (const endpoint of device.endpoints) {
|
||||
endpoint.read.mockClear();
|
||||
endpoint.write.mockClear();
|
||||
endpoint.configureReporting.mockClear();
|
||||
endpoint.bind = vi.fn();
|
||||
endpoint.bind.mockClear();
|
||||
endpoint.unbind.mockClear();
|
||||
}
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.useFakeTimers();
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
groups.group_1.members = [];
|
||||
await resetExtension();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('Should bind to device and configure reporting', async () => {
|
||||
const device = devices.remote;
|
||||
const target = devices.bulb_color.getEndpoint(1)!;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
|
||||
// Setup
|
||||
const originalDeviceOutputClusters = device.getEndpoint(1)!.outputClusters;
|
||||
device.getEndpoint(1)!.outputClusters = [...device.getEndpoint(1)!.outputClusters, 768];
|
||||
const originalTargetBinds = target.binds;
|
||||
target.binds = [{cluster: {name: 'genLevelCtrl'}, target: devices.coordinator.getEndpoint(1)!}];
|
||||
target.getClusterAttributeValue.mockReturnValueOnce(undefined);
|
||||
mockClear(device);
|
||||
target.configureReporting.mockImplementationOnce(() => {
|
||||
throw new Error('timeout');
|
||||
});
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({transaction: '1234', from: 'remote', to: 'bulb_color'}));
|
||||
await flushPromises();
|
||||
expect(target.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities']);
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(4);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('lightingColorCtrl', target);
|
||||
expect(target.configureReporting).toHaveBeenCalledTimes(3);
|
||||
expect(target.configureReporting).toHaveBeenCalledWith('genOnOff', [
|
||||
{attribute: 'onOff', maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
|
||||
]);
|
||||
expect(target.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
|
||||
{attribute: 'currentLevel', maximumReportInterval: 3600, minimumReportInterval: 5, reportableChange: 1},
|
||||
]);
|
||||
expect(target.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
|
||||
{attribute: 'colorTemperature', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
{attribute: 'currentX', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
{attribute: 'currentY', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({
|
||||
transaction: '1234',
|
||||
data: {
|
||||
from: 'remote',
|
||||
from_endpoint: 'default',
|
||||
to: 'bulb_color',
|
||||
clusters: ['genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl'],
|
||||
failed: [],
|
||||
},
|
||||
status: 'ok',
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), {retain: true, qos: 0});
|
||||
|
||||
// Teardown
|
||||
target.binds = originalTargetBinds;
|
||||
device.getEndpoint(1)!.outputClusters = originalDeviceOutputClusters;
|
||||
});
|
||||
|
||||
it('Should throw error on invalid payload', async () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({fromz: 'remote', to: 'bulb_color'}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({data: {}, status: 'error', error: 'Invalid payload'}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Filters out unsupported clusters for reporting setup', async () => {
|
||||
const device = devices.remote;
|
||||
const target = devices.bulb_color.getEndpoint(1)!;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
|
||||
// Setup
|
||||
const originalDeviceInputClusters = device.getEndpoint(1)!.inputClusters;
|
||||
device.getEndpoint(1)!.inputClusters = [...device.getEndpoint(1)!.inputClusters, 8];
|
||||
const originalDeviceOutputClusters = device.getEndpoint(1)!.outputClusters;
|
||||
device.getEndpoint(1)!.outputClusters = [...device.getEndpoint(1)!.outputClusters, 768];
|
||||
const originalTargetInputClusters = target.inputClusters;
|
||||
target.inputClusters = [...originalTargetInputClusters];
|
||||
target.inputClusters.splice(originalTargetInputClusters.indexOf(8), 1); // remove genLevelCtrl
|
||||
const originalTargetOutputClusters = target.outputClusters;
|
||||
target.outputClusters = [...target.outputClusters, 8];
|
||||
const originalTargetBinds = target.binds;
|
||||
target.binds = [{cluster: {name: 'genLevelCtrl'}, target: devices.coordinator.getEndpoint(1)!}];
|
||||
target.getClusterAttributeValue.mockReturnValueOnce(undefined);
|
||||
mockClear(device);
|
||||
target.configureReporting.mockImplementationOnce(() => {
|
||||
throw new Error('timeout');
|
||||
});
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({transaction: '1234', from: 'remote', to: 'bulb_color'}));
|
||||
await flushPromises();
|
||||
|
||||
expect(target.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities']);
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(4);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('lightingColorCtrl', target);
|
||||
expect(target.configureReporting).toHaveBeenCalledTimes(2);
|
||||
expect(target.configureReporting).toHaveBeenCalledWith('genOnOff', [
|
||||
{attribute: 'onOff', maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
|
||||
]);
|
||||
// expect(target.configureReporting).toHaveBeenCalledWith("genLevelCtrl",[{"attribute": "currentLevel", "maximumReportInterval": 3600, "minimumReportInterval": 5, "reportableChange": 1}]);
|
||||
expect(target.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
|
||||
{attribute: 'colorTemperature', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
{attribute: 'currentX', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
{attribute: 'currentY', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({
|
||||
transaction: '1234',
|
||||
data: {
|
||||
from: 'remote',
|
||||
from_endpoint: 'default',
|
||||
to: 'bulb_color',
|
||||
clusters: ['genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl'],
|
||||
failed: [],
|
||||
},
|
||||
status: 'ok',
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), {retain: true, qos: 0});
|
||||
|
||||
// Teardown
|
||||
target.binds = originalTargetBinds;
|
||||
target.inputClusters = originalTargetInputClusters;
|
||||
target.outputClusters = originalTargetOutputClusters;
|
||||
device.getEndpoint(1)!.inputClusters = originalDeviceInputClusters;
|
||||
device.getEndpoint(1)!.outputClusters = originalDeviceOutputClusters;
|
||||
});
|
||||
|
||||
it('Filters out reporting setup based on bind status', async () => {
|
||||
const device = devices.remote;
|
||||
const target = devices.bulb_color.getEndpoint(1)!;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
|
||||
// Setup
|
||||
const originalDeviceOutputClusters = device.getEndpoint(1)!.outputClusters;
|
||||
device.getEndpoint(1)!.outputClusters = [...device.getEndpoint(1)!.outputClusters, 768];
|
||||
const originalTargetBinds = target.binds;
|
||||
target.binds = [{cluster: {name: 'genLevelCtrl'}, target: devices.coordinator.getEndpoint(1)!}];
|
||||
target.getClusterAttributeValue.mockReturnValueOnce(undefined);
|
||||
mockClear(device);
|
||||
target.configureReporting.mockImplementationOnce(() => {
|
||||
throw new Error('timeout');
|
||||
});
|
||||
const originalTargetCR = target.configuredReportings;
|
||||
target.configuredReportings = [
|
||||
{
|
||||
cluster: {name: 'genLevelCtrl'},
|
||||
attribute: {name: 'currentLevel', ID: 0},
|
||||
minimumReportInterval: 0,
|
||||
maximumReportInterval: 3600,
|
||||
reportableChange: 0,
|
||||
},
|
||||
];
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({transaction: '1234', from: 'remote', to: 'bulb_color'}));
|
||||
await flushPromises();
|
||||
expect(target.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities']);
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(4);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('lightingColorCtrl', target);
|
||||
expect(target.configureReporting).toHaveBeenCalledTimes(2);
|
||||
expect(target.configureReporting).toHaveBeenCalledWith('genOnOff', [
|
||||
{attribute: 'onOff', maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
|
||||
]);
|
||||
// expect(target.configureReporting).toHaveBeenCalledWith("genLevelCtrl",[{"attribute": "currentLevel", "maximumReportInterval": 3600, "minimumReportInterval": 5, "reportableChange": 1}]);
|
||||
expect(target.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
|
||||
{attribute: 'colorTemperature', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
{attribute: 'currentX', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
{attribute: 'currentY', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({
|
||||
transaction: '1234',
|
||||
data: {
|
||||
from: 'remote',
|
||||
from_endpoint: 'default',
|
||||
to: 'bulb_color',
|
||||
clusters: ['genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl'],
|
||||
failed: [],
|
||||
},
|
||||
status: 'ok',
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), {retain: true, qos: 0});
|
||||
|
||||
// Teardown
|
||||
target.configuredReportings = originalTargetCR;
|
||||
target.binds = originalTargetBinds;
|
||||
device.getEndpoint(1)!.outputClusters = originalDeviceOutputClusters;
|
||||
});
|
||||
|
||||
it('Should bind only specified clusters', async () => {
|
||||
const device = devices.remote;
|
||||
const target = devices.bulb_color.getEndpoint(1)!;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'bulb_color', clusters: ['genOnOff']}));
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({data: {from: 'remote', from_endpoint: 'default', to: 'bulb_color', clusters: ['genOnOff'], failed: []}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should log error when there is nothing to bind', async () => {
|
||||
const device = devices.bulb_color;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
mockClear(device);
|
||||
mockLogger.error.mockClear();
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'button'}));
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(0);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({data: {}, status: 'error', error: 'Nothing to bind'}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should unbind', async () => {
|
||||
const device = devices.remote;
|
||||
const target = devices.bulb_color.getEndpoint(1)!;
|
||||
|
||||
// setup
|
||||
target.configureReporting.mockImplementationOnce(() => {
|
||||
throw new Error('timeout');
|
||||
});
|
||||
const originalRemoteBinds = device.getEndpoint(1)!.binds;
|
||||
device.getEndpoint(1)!.binds = [];
|
||||
const originalTargetBinds = target.binds;
|
||||
target.binds = [
|
||||
{cluster: {name: 'genOnOff'}, target: devices.coordinator.getEndpoint(1)!},
|
||||
{cluster: {name: 'genLevelCtrl'}, target: devices.coordinator.getEndpoint(1)!},
|
||||
{cluster: {name: 'lightingColorCtrl'}, target: devices.coordinator.getEndpoint(1)!},
|
||||
];
|
||||
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
mockClear(device);
|
||||
delete devices.bulb_color.meta.configured;
|
||||
expect(devices.bulb_color.meta.configured).toBe(undefined);
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'bulb_color'}));
|
||||
await flushPromises();
|
||||
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', target);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', target);
|
||||
|
||||
// Disable reporting
|
||||
expect(target.configureReporting).toHaveBeenCalledTimes(3);
|
||||
expect(target.configureReporting).toHaveBeenCalledWith('genOnOff', [
|
||||
{attribute: 'onOff', maximumReportInterval: 0xffff, minimumReportInterval: 0, reportableChange: 0},
|
||||
]);
|
||||
expect(target.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
|
||||
{attribute: 'currentLevel', maximumReportInterval: 0xffff, minimumReportInterval: 5, reportableChange: 1},
|
||||
]);
|
||||
expect(target.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
|
||||
{attribute: 'colorTemperature', minimumReportInterval: 5, maximumReportInterval: 0xffff, reportableChange: 1},
|
||||
{attribute: 'currentX', minimumReportInterval: 5, maximumReportInterval: 0xffff, reportableChange: 1},
|
||||
{attribute: 'currentY', minimumReportInterval: 5, maximumReportInterval: 0xffff, reportableChange: 1},
|
||||
]);
|
||||
expect(devices.bulb_color.meta.configured).toBe(332242049);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/unbind',
|
||||
stringify({
|
||||
data: {from: 'remote', from_endpoint: 'default', to: 'bulb_color', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []},
|
||||
status: 'ok',
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
|
||||
// Teardown
|
||||
target.binds = originalTargetBinds;
|
||||
device.getEndpoint(1)!.binds = originalRemoteBinds;
|
||||
});
|
||||
|
||||
it('Should unbind coordinator', async () => {
|
||||
const device = devices.remote;
|
||||
const target = devices.coordinator.getEndpoint(1)!;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
mockClear(device);
|
||||
endpoint.unbind.mockClear();
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'Coordinator'}));
|
||||
await flushPromises();
|
||||
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', target);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', target);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/unbind',
|
||||
stringify({
|
||||
data: {from: 'remote', from_endpoint: 'default', to: 'Coordinator', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []},
|
||||
status: 'ok',
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should bind to groups', async () => {
|
||||
const device = devices.remote;
|
||||
const target = groups.group_1;
|
||||
const target1Member = devices.bulb.getEndpoint(1)!;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
target.members.push(target1Member);
|
||||
target1Member.configureReporting.mockClear();
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'group_1'}));
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledTimes(2);
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledWith('genOnOff', [
|
||||
{attribute: 'onOff', maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
|
||||
]);
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
|
||||
{attribute: 'currentLevel', maximumReportInterval: 3600, minimumReportInterval: 5, reportableChange: 1},
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({
|
||||
data: {from: 'remote', from_endpoint: 'default', to: 'group_1', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []},
|
||||
status: 'ok',
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
|
||||
// Should configure reporting for device added to group
|
||||
target1Member.configureReporting.mockClear();
|
||||
await mockMQTTEvents.message('zigbee2mqtt/bridge/request/group/members/add', stringify({group: 'group_1', device: 'bulb'}));
|
||||
await flushPromises();
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledTimes(2);
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledWith('genOnOff', [
|
||||
{attribute: 'onOff', maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
|
||||
]);
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
|
||||
{attribute: 'currentLevel', maximumReportInterval: 3600, minimumReportInterval: 5, reportableChange: 1},
|
||||
]);
|
||||
});
|
||||
|
||||
it('Should unbind from group', async () => {
|
||||
const device = devices.remote;
|
||||
const target = groups.group_1;
|
||||
const target1Member = devices.bulb.getEndpoint(1)!;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
target.members.push(target1Member);
|
||||
target1Member.configureReporting.mockClear();
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'group_1'}));
|
||||
await flushPromises();
|
||||
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', target);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', target);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/unbind',
|
||||
stringify({
|
||||
data: {from: 'remote', from_endpoint: 'default', to: 'group_1', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []},
|
||||
status: 'ok',
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should unbind from group with skip_disable_reporting=true', async () => {
|
||||
const device = devices.remote;
|
||||
const target = groups.group_1;
|
||||
const target1Member = devices.bulb_2.getEndpoint(1)!;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
target.members.push(target1Member);
|
||||
|
||||
// The device unbind mock doesn't remove binds, therefore remove them here already otherwise configure reporiting is not disabled.
|
||||
const originalBinds = endpoint.binds;
|
||||
endpoint.binds = [];
|
||||
|
||||
target1Member.binds = [
|
||||
{cluster: {name: 'genLevelCtrl'}, target: devices.coordinator.getEndpoint(1)!},
|
||||
{cluster: {name: 'genOnOff'}, target: devices.coordinator.getEndpoint(1)!},
|
||||
];
|
||||
target1Member.configureReporting.mockClear();
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'group_1', skip_disable_reporting: true}));
|
||||
await flushPromises();
|
||||
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
|
||||
// with skip_disable_reporting set to false, we don't expect it to reconfigure reporting
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledTimes(0);
|
||||
endpoint.binds = originalBinds;
|
||||
});
|
||||
|
||||
it('Should unbind from group with skip_disable_reporting=false', async () => {
|
||||
const device = devices.remote;
|
||||
const target = groups.group_1;
|
||||
const target1Member = devices.bulb_2.getEndpoint(1)!;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
target.members.push(target1Member);
|
||||
|
||||
// The device unbind mock doesn't remove binds, therefore remove them here already otherwise configure reporiting is not disabled.
|
||||
const originalBinds = endpoint.binds;
|
||||
endpoint.binds = [];
|
||||
|
||||
target1Member.binds = [
|
||||
{cluster: {name: 'genLevelCtrl'}, target: devices.coordinator.getEndpoint(1)!},
|
||||
{cluster: {name: 'genOnOff'}, target: devices.coordinator.getEndpoint(1)!},
|
||||
];
|
||||
target1Member.configureReporting.mockClear();
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'group_1', skip_disable_reporting: false}));
|
||||
await flushPromises();
|
||||
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
|
||||
// with skip_disable_reporting set, we expect it to reconfigure reporting
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledTimes(2);
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
|
||||
{attribute: 'currentLevel', maximumReportInterval: 65535, minimumReportInterval: 5, reportableChange: 1},
|
||||
]);
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledWith('genOnOff', [
|
||||
{attribute: 'onOff', maximumReportInterval: 65535, minimumReportInterval: 0, reportableChange: 0},
|
||||
]);
|
||||
endpoint.binds = originalBinds;
|
||||
});
|
||||
|
||||
it('Should bind to group by number', async () => {
|
||||
const device = devices.remote;
|
||||
const target = groups.group_1;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: '1'}));
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({
|
||||
data: {from: 'remote', from_endpoint: 'default', to: '1', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []},
|
||||
status: 'ok',
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should log when bind fails', async () => {
|
||||
mockLogger.error.mockClear();
|
||||
const device = devices.remote;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
mockClear(device);
|
||||
endpoint.bind.mockImplementation(() => {
|
||||
throw new Error('failed');
|
||||
});
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'bulb_color'}));
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(3);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({data: {}, status: 'error', error: 'Failed to bind'}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should bind from non default endpoint names', async () => {
|
||||
const device = devices.remote;
|
||||
const target = devices.QBKG03LM.getEndpoint(3)!;
|
||||
const endpoint = device.getEndpoint(2)!;
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message(
|
||||
'zigbee2mqtt/bridge/request/device/bind',
|
||||
stringify({from: 'remote', from_endpoint: 'ep2', to: 'wall_switch_double', to_endpoint: 'right'}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({
|
||||
data: {from: 'remote', from_endpoint: 'ep2', to: 'wall_switch_double', to_endpoint: 'right', clusters: ['genOnOff'], failed: []},
|
||||
status: 'ok',
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should bind from non default endpoint IDs', async () => {
|
||||
const device = devices.remote;
|
||||
const target = devices.QBKG03LM.getEndpoint(3)!;
|
||||
const endpoint = device.getEndpoint(2)!;
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message(
|
||||
'zigbee2mqtt/bridge/request/device/bind',
|
||||
stringify({from: 'remote', from_endpoint: 2, to: 'wall_switch_double', to_endpoint: 3}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({
|
||||
data: {from: 'remote', from_endpoint: 2, to: 'wall_switch_double', to_endpoint: 3, clusters: ['genOnOff'], failed: []},
|
||||
status: 'ok',
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should bind server clusters to client clusters', async () => {
|
||||
const device = devices.temperature_sensor;
|
||||
const target = devices.heating_actuator.getEndpoint(1)!;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'temperature_sensor', to: 'heating_actuator'}));
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('msTemperatureMeasurement', target);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({
|
||||
data: {
|
||||
from: 'temperature_sensor',
|
||||
from_endpoint: 'default',
|
||||
to: 'heating_actuator',
|
||||
clusters: ['msTemperatureMeasurement'],
|
||||
failed: [],
|
||||
},
|
||||
status: 'ok',
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should bind to default endpoint returned by endpoints()', async () => {
|
||||
const device = devices.remote;
|
||||
const target = devices.QBKG04LM.getEndpoint(2)!;
|
||||
const endpoint = device.getEndpoint(2)!;
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', from_endpoint: 'ep2', to: 'wall_switch'}));
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({data: {from: 'remote', from_endpoint: 'ep2', to: 'wall_switch', clusters: ['genOnOff'], failed: []}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should unbind from default_bind_group', async () => {
|
||||
const device = devices.remote;
|
||||
const target = 'default_bind_group';
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: target}));
|
||||
await flushPromises();
|
||||
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', 901);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', 901);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', 901);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/unbind',
|
||||
stringify({
|
||||
data: {
|
||||
from: 'remote',
|
||||
from_endpoint: 'default',
|
||||
to: 'default_bind_group',
|
||||
clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'],
|
||||
failed: [],
|
||||
},
|
||||
status: 'ok',
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Error bind fails when source device does not exist', async () => {
|
||||
const device = devices.remote;
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote_not_existing', to: 'bulb_color'}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({data: {}, status: 'error', error: "Source device 'remote_not_existing' does not exist"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it("Error bind fails when source device's endpoint does not exist", async () => {
|
||||
const device = devices.remote;
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message(
|
||||
'zigbee2mqtt/bridge/request/device/bind',
|
||||
stringify({from: 'remote', from_endpoint: 'not_existing_endpoint', to: 'bulb_color'}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({data: {}, status: 'error', error: "Source device 'remote' does not have endpoint 'not_existing_endpoint'"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Error bind fails when target device or group does not exist', async () => {
|
||||
const device = devices.remote;
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'bulb_color_not_existing'}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({data: {}, status: 'error', error: "Target device or group 'bulb_color_not_existing' does not exist"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it("Error bind fails when target device's endpoint does not exist", async () => {
|
||||
const device = devices.remote;
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message(
|
||||
'zigbee2mqtt/bridge/request/device/bind',
|
||||
stringify({from: 'remote', to: 'bulb_color', to_endpoint: 'not_existing_endpoint'}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({data: {}, status: 'error', error: "Target device 'bulb_color' does not have endpoint 'not_existing_endpoint'"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should poll bounded Hue bulb when receiving message from Hue dimmer', async () => {
|
||||
const remote = devices.remote;
|
||||
const data = {button: 3, unknown1: 3145728, type: 2, unknown2: 0, time: 1};
|
||||
const payload = {
|
||||
data,
|
||||
cluster: 'manuSpecificPhilips',
|
||||
device: remote,
|
||||
endpoint: remote.getEndpoint(2)!,
|
||||
type: 'commandHueNotification',
|
||||
linkquality: 10,
|
||||
groupID: 0,
|
||||
};
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(mockDebounce).toHaveBeenCalledTimes(1);
|
||||
expect(devices.bulb_color.getEndpoint(1)!.read).toHaveBeenCalledWith('genLevelCtrl', ['currentLevel']);
|
||||
});
|
||||
|
||||
it('Should poll bounded Hue bulb when receiving message from scene controller', async () => {
|
||||
const remote = devices.bj_scene_switch;
|
||||
const data = {action: 'recall_2_row_1'};
|
||||
devices.bulb_color_2.getEndpoint(1)!.read.mockImplementationOnce(() => {
|
||||
throw new Error('failed');
|
||||
});
|
||||
const payload = {
|
||||
data,
|
||||
cluster: 'genScenes',
|
||||
device: remote,
|
||||
endpoint: remote.getEndpoint(10)!,
|
||||
type: 'commandRecall',
|
||||
linkquality: 10,
|
||||
groupID: 0,
|
||||
};
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
// Calls to three clusters are expected in this case
|
||||
expect(mockDebounce).toHaveBeenCalledTimes(3);
|
||||
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledWith('genOnOff', ['onOff']);
|
||||
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledWith('genLevelCtrl', ['currentLevel']);
|
||||
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledWith('lightingColorCtrl', ['currentX', 'currentY', 'colorTemperature']);
|
||||
});
|
||||
|
||||
it('Should poll grouped Hue bulb when receiving message from TRADFRI remote', async () => {
|
||||
devices.bulb_color_2.getEndpoint(1)!.read.mockClear();
|
||||
devices.bulb_2.getEndpoint(1)!.read.mockClear();
|
||||
const remote = devices.tradfri_remote;
|
||||
const data = {stepmode: 0, stepsize: 43, transtime: 5};
|
||||
const payload = {
|
||||
data,
|
||||
cluster: 'genLevelCtrl',
|
||||
device: remote,
|
||||
endpoint: remote.getEndpoint(1)!,
|
||||
type: 'commandStepWithOnOff',
|
||||
linkquality: 10,
|
||||
groupID: 15071,
|
||||
};
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(mockDebounce).toHaveBeenCalledTimes(2);
|
||||
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledTimes(2);
|
||||
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledWith('genLevelCtrl', ['currentLevel']);
|
||||
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledWith('genOnOff', ['onOff']);
|
||||
|
||||
// Should also only debounce once
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(mockDebounce).toHaveBeenCalledTimes(2);
|
||||
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledTimes(4);
|
||||
|
||||
// Should only call Hue bulb, not e.g. tradfri
|
||||
expect(devices.bulb_2.getEndpoint(1)!.read).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,53 +1,26 @@
|
||||
const data = require('./stub/data');
|
||||
const logger = require('./stub/logger');
|
||||
const zigbeeHerdsman = require('./stub/zigbeeHerdsman');
|
||||
const MQTT = require('./stub/mqtt');
|
||||
const settings = require('../lib/util/settings');
|
||||
const Controller = require('../lib/controller');
|
||||
const flushPromises = require('./lib/flushPromises');
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
import * as data from '../mocks/data';
|
||||
import {mockLogger} from '../mocks/logger';
|
||||
import {events as mockMQTTEvents, mockMQTTPublishAsync} from '../mocks/mqtt';
|
||||
import {flushPromises} from '../mocks/utils';
|
||||
import {Device, devices, Endpoint, events as mockZHEvents} from '../mocks/zigbeeHerdsman';
|
||||
|
||||
const mocksClear = [MQTT.publish, logger.warning, logger.debug];
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
describe('Configure', () => {
|
||||
let controller;
|
||||
let coordinatorEndpoint;
|
||||
import {Controller} from '../../lib/controller';
|
||||
import * as settings from '../../lib/util/settings';
|
||||
|
||||
const expectRemoteConfigured = () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const endpoint1 = device.getEndpoint(1);
|
||||
expect(endpoint1.bind).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint1.bind).toHaveBeenCalledWith('genOnOff', coordinatorEndpoint);
|
||||
expect(endpoint1.bind).toHaveBeenCalledWith('genLevelCtrl', coordinatorEndpoint);
|
||||
const mocksClear = [mockMQTTPublishAsync, mockLogger.warning, mockLogger.debug];
|
||||
|
||||
const endpoint2 = device.getEndpoint(2);
|
||||
expect(endpoint2.write).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint2.write).toHaveBeenCalledWith('genBasic', {49: {type: 25, value: 11}}, {disableDefaultResponse: true, manufacturerCode: 4107});
|
||||
expect(device.meta.configured).toBe(332242049);
|
||||
describe('Extension: Configure', () => {
|
||||
let controller: Controller;
|
||||
let coordinatorEndpoint: Endpoint;
|
||||
|
||||
const resetExtension = async (): Promise<void> => {
|
||||
await controller.enableDisableExtension(false, 'Configure');
|
||||
await controller.enableDisableExtension(true, 'Configure');
|
||||
};
|
||||
|
||||
const expectBulbConfigured = () => {
|
||||
const device = zigbeeHerdsman.devices.bulb;
|
||||
const endpoint1 = device.getEndpoint(1);
|
||||
expect(endpoint1.read).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint1.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities']);
|
||||
expect(endpoint1.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorTempPhysicalMin', 'colorTempPhysicalMax']);
|
||||
};
|
||||
|
||||
const expectBulbNotConfigured = () => {
|
||||
const device = zigbeeHerdsman.devices.bulb;
|
||||
const endpoint1 = device.getEndpoint(1);
|
||||
expect(endpoint1.read).toHaveBeenCalledTimes(0);
|
||||
};
|
||||
|
||||
const expectRemoteNotConfigured = () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const endpoint1 = device.getEndpoint(1);
|
||||
expect(endpoint1.bind).toHaveBeenCalledTimes(0);
|
||||
};
|
||||
|
||||
const mockClear = (device) => {
|
||||
const mockClear = (device: Device): void => {
|
||||
for (const endpoint of device.endpoints) {
|
||||
endpoint.read.mockClear();
|
||||
endpoint.write.mockClear();
|
||||
@@ -56,30 +29,59 @@ describe('Configure', () => {
|
||||
}
|
||||
};
|
||||
|
||||
let resetExtension = async () => {
|
||||
await controller.enableDisableExtension(false, 'Configure');
|
||||
await controller.enableDisableExtension(true, 'Configure');
|
||||
const expectRemoteConfigured = (): void => {
|
||||
const device = devices.remote;
|
||||
const endpoint1 = device.getEndpoint(1)!;
|
||||
expect(endpoint1.bind).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint1.bind).toHaveBeenCalledWith('genOnOff', coordinatorEndpoint);
|
||||
expect(endpoint1.bind).toHaveBeenCalledWith('genLevelCtrl', coordinatorEndpoint);
|
||||
|
||||
const endpoint2 = device.getEndpoint(2)!;
|
||||
expect(endpoint2.write).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint2.write).toHaveBeenCalledWith('genBasic', {49: {type: 25, value: 11}}, {disableDefaultResponse: true, manufacturerCode: 4107});
|
||||
expect(device.meta.configured).toBe(332242049);
|
||||
};
|
||||
|
||||
const expectBulbConfigured = (): void => {
|
||||
const device = devices.bulb;
|
||||
const endpoint1 = device.getEndpoint(1)!;
|
||||
expect(endpoint1.read).toHaveBeenCalledTimes(2);
|
||||
expect(endpoint1.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities']);
|
||||
expect(endpoint1.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorTempPhysicalMin', 'colorTempPhysicalMax']);
|
||||
};
|
||||
|
||||
const expectBulbNotConfigured = (): void => {
|
||||
const device = devices.bulb;
|
||||
const endpoint1 = device.getEndpoint(1)!;
|
||||
expect(endpoint1.read).toHaveBeenCalledTimes(0);
|
||||
};
|
||||
|
||||
const expectRemoteNotConfigured = (): void => {
|
||||
const device = devices.remote;
|
||||
const endpoint1 = device.getEndpoint(1)!;
|
||||
expect(endpoint1.bind).toHaveBeenCalledTimes(0);
|
||||
};
|
||||
|
||||
const wait = async (ms: number): Promise<void> => await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.useFakeTimers();
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
vi.useFakeTimers();
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
await controller.start();
|
||||
await jest.runOnlyPendingTimers();
|
||||
await flushPromises();
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
coordinatorEndpoint = zigbeeHerdsman.devices.coordinator.getEndpoint(1);
|
||||
coordinatorEndpoint = devices.coordinator.getEndpoint(1)!;
|
||||
await resetExtension();
|
||||
await jest.runOnlyPendingTimers();
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
jest.useRealTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('Should configure Router on startup', async () => {
|
||||
@@ -92,32 +94,33 @@ describe('Configure', () => {
|
||||
|
||||
it('Should re-configure when device rejoins', async () => {
|
||||
expectBulbConfigured();
|
||||
const device = zigbeeHerdsman.devices.bulb;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
const device = devices.bulb;
|
||||
await flushPromises();
|
||||
mockClear(device);
|
||||
const payload = {device};
|
||||
zigbeeHerdsman.events.deviceJoined(payload);
|
||||
mockZHEvents.deviceJoined(payload);
|
||||
await flushPromises();
|
||||
expectBulbConfigured();
|
||||
});
|
||||
|
||||
it('Should not re-configure disabled devices', async () => {
|
||||
expectBulbConfigured();
|
||||
const device = zigbeeHerdsman.devices.bulb;
|
||||
const device = devices.bulb;
|
||||
await flushPromises();
|
||||
mockClear(device);
|
||||
settings.set(['devices', device.ieeeAddr, 'disabled'], true);
|
||||
zigbeeHerdsman.events.deviceJoined({device});
|
||||
mockZHEvents.deviceJoined({device});
|
||||
await flushPromises();
|
||||
expectBulbNotConfigured();
|
||||
});
|
||||
|
||||
it('Should reconfigure reporting on reconfigure event', async () => {
|
||||
expectBulbConfigured();
|
||||
const device = controller.zigbee.resolveEntity(zigbeeHerdsman.devices.bulb);
|
||||
// @ts-expect-error private
|
||||
const device = controller.zigbee.resolveEntity(devices.bulb)!;
|
||||
mockClear(device.zh);
|
||||
expectBulbNotConfigured();
|
||||
// @ts-expect-error private
|
||||
controller.eventBus.emitReconfigure({device});
|
||||
await flushPromises();
|
||||
expectBulbConfigured();
|
||||
@@ -125,148 +128,134 @@ describe('Configure', () => {
|
||||
|
||||
it('Should not configure twice', async () => {
|
||||
expectBulbConfigured();
|
||||
const device = zigbeeHerdsman.devices.bulb;
|
||||
const device = devices.bulb;
|
||||
mockClear(device);
|
||||
await zigbeeHerdsman.events.deviceInterview({device});
|
||||
await mockZHEvents.deviceInterview({device});
|
||||
await flushPromises();
|
||||
expectBulbNotConfigured();
|
||||
});
|
||||
|
||||
it('Should configure on zigbee message when not configured yet', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb;
|
||||
const device = devices.bulb;
|
||||
delete device.meta.configured;
|
||||
mockClear(device);
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device});
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
expectBulbConfigured();
|
||||
});
|
||||
|
||||
it('Should allow to configure via MQTT', async () => {
|
||||
mockClear(zigbeeHerdsman.devices.remote);
|
||||
mockClear(devices.remote);
|
||||
expectRemoteNotConfigured();
|
||||
await MQTT.events.message('zigbee2mqtt/bridge/request/device/configure', 'remote');
|
||||
await mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/configure', 'remote');
|
||||
await flushPromises();
|
||||
expectRemoteConfigured();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/configure',
|
||||
stringify({data: {id: 'remote'}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Fail to configure via MQTT when device does not exist', async () => {
|
||||
await MQTT.events.message('zigbee2mqtt/bridge/request/device/configure', stringify({id: 'not_existing_device'}));
|
||||
await mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/configure', stringify({id: 'not_existing_device'}));
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/configure',
|
||||
stringify({data: {id: 'not_existing_device'}, status: 'error', error: "Device 'not_existing_device' does not exist"}),
|
||||
stringify({data: {}, status: 'error', error: "Device 'not_existing_device' does not exist"}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Fail to configure via MQTT when configure fails', async () => {
|
||||
zigbeeHerdsman.devices.remote.getEndpoint(1).bind.mockImplementationOnce(async () => {
|
||||
devices.remote.getEndpoint(1)!.bind.mockImplementationOnce(async () => {
|
||||
throw new Error('Bind timeout after 10s');
|
||||
});
|
||||
await MQTT.events.message('zigbee2mqtt/bridge/request/device/configure', stringify({id: 'remote'}));
|
||||
await mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/configure', stringify({id: 'remote'}));
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/configure',
|
||||
stringify({data: {id: 'remote'}, status: 'error', error: 'Failed to configure (Bind timeout after 10s)'}),
|
||||
stringify({data: {}, status: 'error', error: 'Failed to configure (Bind timeout after 10s)'}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Fail to configure via MQTT when device has no configure', async () => {
|
||||
await MQTT.events.message('zigbee2mqtt/bridge/request/device/configure', stringify({id: '0x0017882104a44559', transaction: 20}));
|
||||
await mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/configure', stringify({id: '0x0017882104a44559', transaction: 20}));
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/configure',
|
||||
stringify({data: {id: '0x0017882104a44559'}, status: 'error', error: "Device 'TS0601_thermostat' cannot be configured", transaction: 20}),
|
||||
stringify({data: {}, status: 'error', error: "Device 'TS0601_thermostat' cannot be configured", transaction: 20}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Legacy api: Should allow to reconfigure manually', async () => {
|
||||
mockClear(zigbeeHerdsman.devices.remote);
|
||||
expectRemoteNotConfigured();
|
||||
await MQTT.events.message('zigbee2mqtt/bridge/configure', 'remote');
|
||||
it('Handles invalid payload for configure via MQTT', async () => {
|
||||
await mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/configure', stringify({idx: '0x0017882104a44559'}));
|
||||
await flushPromises();
|
||||
expectRemoteConfigured();
|
||||
});
|
||||
|
||||
it('Legacy api: Shouldnt manually reconfigure when device does not exist', async () => {
|
||||
await MQTT.events.message('zigbee2mqtt/bridge/configure', 'remote_random_non_existing');
|
||||
await flushPromises();
|
||||
expect(logger.error).toHaveBeenCalledWith(`Device 'remote_random_non_existing' does not exist`);
|
||||
});
|
||||
|
||||
it('Legacy api: Should skip reconfigure when device does not require this', async () => {
|
||||
await MQTT.events.message('zigbee2mqtt/bridge/configure', '0x0017882104a44559');
|
||||
await flushPromises();
|
||||
expect(logger.warning).toHaveBeenCalledWith(`Skipping configure of 'TS0601_thermostat', device does not require this.`);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/configure',
|
||||
stringify({data: {}, status: 'error', error: 'Invalid payload'}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should not configure when interview not completed', async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const device = devices.remote;
|
||||
delete device.meta.configured;
|
||||
device.interviewCompleted = false;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device});
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
expectRemoteNotConfigured();
|
||||
device.interviewCompleted = true;
|
||||
});
|
||||
|
||||
it('Should not configure when already configuring', async () => {
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const device = devices.remote;
|
||||
delete device.meta.configured;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
endpoint.bind.mockImplementationOnce(async () => await wait(500));
|
||||
mockClear(device);
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device});
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(1);
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device});
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should configure max 3 times when fails', async () => {
|
||||
// @ts-expect-error private
|
||||
controller.extensions.find((e) => e.constructor.name === 'Configure').attempts = {};
|
||||
const device = zigbeeHerdsman.devices.remote;
|
||||
const device = devices.remote;
|
||||
delete device.meta.configured;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
mockClear(device);
|
||||
endpoint.bind.mockImplementationOnce(async () => {
|
||||
throw new Error('BLA');
|
||||
});
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device});
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
endpoint.bind.mockImplementationOnce(async () => {
|
||||
throw new Error('BLA');
|
||||
});
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device});
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
endpoint.bind.mockImplementationOnce(async () => {
|
||||
throw new Error('BLA');
|
||||
});
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device});
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
endpoint.bind.mockImplementationOnce(async () => {
|
||||
throw new Error('BLA');
|
||||
});
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device});
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
endpoint.bind.mockImplementationOnce(async () => {
|
||||
throw new Error('BLA');
|
||||
});
|
||||
await zigbeeHerdsman.events.lastSeenChanged({device});
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
@@ -0,0 +1,357 @@
|
||||
import * as data from '../mocks/data';
|
||||
import {mockLogger} from '../mocks/logger';
|
||||
import {mockMQTTEndAsync, events as mockMQTTEvents, mockMQTTPublishAsync} from '../mocks/mqtt';
|
||||
import {flushPromises} from '../mocks/utils';
|
||||
import {devices, mockController as mockZHController, returnDevices} from '../mocks/zigbeeHerdsman';
|
||||
|
||||
import type Device from '../../lib/model/device';
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
|
||||
import {Controller} from '../../lib/controller';
|
||||
import * as settings from '../../lib/util/settings';
|
||||
|
||||
const BASE_DIR = 'external_converters';
|
||||
|
||||
describe('Extension: ExternalConverters', () => {
|
||||
const mockBasePath = path.join(data.mockDir, BASE_DIR);
|
||||
let controller: Controller;
|
||||
|
||||
const existsSyncSpy = vi.spyOn(fs, 'existsSync');
|
||||
const readdirSyncSpy = vi.spyOn(fs, 'readdirSync');
|
||||
const mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync');
|
||||
const rmSyncSpy = vi.spyOn(fs, 'rmSync');
|
||||
const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync');
|
||||
|
||||
const zhcAddExternalDefinitionSpy = vi.spyOn(zhc, 'addExternalDefinition');
|
||||
const zhcRemoveExternalDefinitionsSpy = vi.spyOn(zhc, 'removeExternalDefinitions');
|
||||
|
||||
const mocksClear = [
|
||||
mockMQTTEndAsync,
|
||||
mockMQTTPublishAsync,
|
||||
mockLogger.debug,
|
||||
mockLogger.error,
|
||||
mockZHController.stop,
|
||||
devices.bulb.save,
|
||||
existsSyncSpy,
|
||||
readdirSyncSpy,
|
||||
mkdirSyncSpy,
|
||||
rmSyncSpy,
|
||||
writeFileSyncSpy,
|
||||
zhcAddExternalDefinitionSpy,
|
||||
zhcRemoveExternalDefinitionsSpy,
|
||||
];
|
||||
|
||||
const useAssets = (): void => {
|
||||
fs.cpSync(path.join(__dirname, '..', 'assets', BASE_DIR), mockBasePath, {recursive: true});
|
||||
};
|
||||
|
||||
const getFileCode = (fileName: string): string => {
|
||||
return fs.readFileSync(path.join(__dirname, '..', 'assets', BASE_DIR, fileName), 'utf8');
|
||||
};
|
||||
|
||||
const getZ2MDevice = (zhDevice: unknown): Device => {
|
||||
// @ts-expect-error private
|
||||
return controller.zigbee.resolveEntity(zhDevice)! as Device;
|
||||
};
|
||||
|
||||
const resetExtension = async (): Promise<void> => {
|
||||
await controller.enableDisableExtension(false, 'ExternalConverters');
|
||||
await controller.enableDisableExtension(true, 'ExternalConverters');
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
zhc.removeExternalDefinitions(); // remove all external converters
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
data.writeDefaultConfiguration();
|
||||
data.writeDefaultState();
|
||||
settings.reRead();
|
||||
returnDevices.push(devices.external_converter_device.ieeeAddr, devices.coordinator.ieeeAddr);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
fs.rmSync(mockBasePath, {recursive: true, force: true});
|
||||
|
||||
await controller?.stop();
|
||||
});
|
||||
|
||||
describe('from folder', () => {
|
||||
beforeEach(async () => {
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
});
|
||||
|
||||
it('loads nothing', async () => {
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(existsSyncSpy).toHaveBeenCalledWith(mockBasePath);
|
||||
expect(readdirSyncSpy).not.toHaveBeenCalledWith(mockBasePath);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/converters', stringify([]), {retain: true, qos: 0});
|
||||
});
|
||||
|
||||
it('loads converters', async () => {
|
||||
useAssets();
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
|
||||
description: 'external',
|
||||
model: 'external_converter_device',
|
||||
vendor: 'external',
|
||||
zigbeeModel: ['external_converter_device'],
|
||||
});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/converters',
|
||||
stringify([
|
||||
{name: 'mock-external-converter-multiple.js', code: getFileCode('mock-external-converter-multiple.js')},
|
||||
{name: 'mock-external-converter.js', code: getFileCode('mock-external-converter.js')},
|
||||
]),
|
||||
{retain: true, qos: 0},
|
||||
);
|
||||
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenCalledTimes(2);
|
||||
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(1, 'mock-external-converter-multiple.js');
|
||||
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(2, 'mock-external-converter.js');
|
||||
expect(zhcAddExternalDefinitionSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
mock: 1,
|
||||
model: 'external_converters_device_1',
|
||||
zigbeeModel: ['external_converter_device_1'],
|
||||
vendor: 'external_1',
|
||||
description: 'external_1',
|
||||
}),
|
||||
);
|
||||
expect(zhcAddExternalDefinitionSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
mock: 2,
|
||||
model: 'external_converters_device_2',
|
||||
zigbeeModel: ['external_converter_device_2'],
|
||||
vendor: 'external_2',
|
||||
description: 'external_2',
|
||||
}),
|
||||
);
|
||||
expect(zhcAddExternalDefinitionSpy).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
mock: true,
|
||||
zigbeeModel: ['external_converter_device'],
|
||||
vendor: 'external',
|
||||
model: 'external_converter_device',
|
||||
description: 'external',
|
||||
}),
|
||||
);
|
||||
|
||||
const bridgeDevices = mockMQTTPublishAsync.mock.calls.filter((c) => c[0] === 'zigbee2mqtt/bridge/devices');
|
||||
expect(bridgeDevices.length).toBe(1);
|
||||
expect(JSON.parse(bridgeDevices[0][1])).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
model_id: 'external_converter_device',
|
||||
supported: true,
|
||||
definition: expect.objectContaining({
|
||||
description: 'external',
|
||||
model: 'external_converter_device',
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('saves and removes from MQTT', async () => {
|
||||
const converterName = 'foo.js';
|
||||
const converterCode = getFileCode('mock-external-converter.js');
|
||||
const converterFilePath = path.join(mockBasePath, converterName);
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
|
||||
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
|
||||
description: 'Automatically generated definition',
|
||||
model: 'external_converter_device',
|
||||
vendor: '',
|
||||
zigbeeModel: ['external_converter_device'],
|
||||
});
|
||||
|
||||
//-- SAVE
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode}));
|
||||
await flushPromises();
|
||||
|
||||
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
|
||||
description: 'external',
|
||||
model: 'external_converter_device',
|
||||
vendor: 'external',
|
||||
zigbeeModel: ['external_converter_device'],
|
||||
});
|
||||
expect(mkdirSyncSpy).toHaveBeenCalledWith(mockBasePath, {recursive: true});
|
||||
expect(writeFileSyncSpy).toHaveBeenCalledWith(converterFilePath, converterCode, 'utf8');
|
||||
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenCalledTimes(1);
|
||||
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(1, converterName);
|
||||
expect(zhcAddExternalDefinitionSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mock: true,
|
||||
zigbeeModel: ['external_converter_device'],
|
||||
vendor: 'external',
|
||||
model: 'external_converter_device',
|
||||
description: 'external',
|
||||
}),
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/converters',
|
||||
stringify([{name: converterName, code: converterCode}]),
|
||||
{
|
||||
retain: true,
|
||||
qos: 0,
|
||||
},
|
||||
);
|
||||
|
||||
//-- REMOVE
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/remove', stringify({name: converterName}));
|
||||
await flushPromises();
|
||||
|
||||
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
|
||||
description: 'Automatically generated definition',
|
||||
model: 'external_converter_device',
|
||||
vendor: '',
|
||||
zigbeeModel: ['external_converter_device'],
|
||||
});
|
||||
expect(rmSyncSpy).toHaveBeenCalledWith(converterFilePath, {force: true});
|
||||
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenCalledTimes(2);
|
||||
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(2, converterName);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/converters', stringify([]), {retain: true, qos: 0});
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error on invalid code', async () => {
|
||||
const converterName = 'foo.js';
|
||||
const converterCode = 'definetly not a correct javascript code';
|
||||
const converterFilePath = path.join(mockBasePath, converterName);
|
||||
|
||||
await resetExtension();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode}));
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/converter/save',
|
||||
expect.stringContaining(`"error":"foo.js contains invalid code`),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
expect(writeFileSyncSpy).not.toHaveBeenCalledWith(converterFilePath, converterCode, 'utf8');
|
||||
});
|
||||
|
||||
it('returns error on invalid removal', async () => {
|
||||
const converterName = 'invalid.js';
|
||||
const converterFilePath = path.join(mockBasePath, converterName);
|
||||
|
||||
await resetExtension();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/remove', stringify({name: converterName}));
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/converter/remove',
|
||||
stringify({data: {}, status: 'error', error: `${converterName} (${converterFilePath}) doesn't exists`}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
expect(rmSyncSpy).not.toHaveBeenCalledWith(converterFilePath, {force: true});
|
||||
});
|
||||
|
||||
it('returns error on invalid definition', async () => {
|
||||
const converterName = 'foo.js';
|
||||
const converterCode = getFileCode('mock-external-converter.js');
|
||||
const converterFilePath = path.join(mockBasePath, converterName);
|
||||
|
||||
await resetExtension();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
|
||||
const errorMsg = `Invalid definition`;
|
||||
|
||||
zhcAddExternalDefinitionSpy.mockImplementationOnce(() => {
|
||||
throw new Error(errorMsg);
|
||||
});
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode}));
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/response/converter/save', expect.stringContaining(errorMsg), {
|
||||
retain: false,
|
||||
qos: 0,
|
||||
});
|
||||
expect(writeFileSyncSpy).not.toHaveBeenCalledWith(converterFilePath, converterCode, 'utf8');
|
||||
});
|
||||
|
||||
it('returns error on failed removal', async () => {
|
||||
const converterName = 'foo.js';
|
||||
const converterCode = getFileCode('mock-external-converter.js');
|
||||
const converterFilePath = path.join(mockBasePath, converterName);
|
||||
|
||||
await resetExtension();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
|
||||
//-- SAVE
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode}));
|
||||
await flushPromises();
|
||||
|
||||
const errorMsg = `Failed to remove definition`;
|
||||
|
||||
zhcRemoveExternalDefinitionsSpy.mockImplementationOnce(() => {
|
||||
throw new Error(errorMsg);
|
||||
});
|
||||
|
||||
//-- REMOVE
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/remove', stringify({name: converterName}));
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/converter/remove',
|
||||
stringify({data: {}, status: 'error', error: errorMsg}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
expect(rmSyncSpy).not.toHaveBeenCalledWith(converterFilePath, {force: true});
|
||||
});
|
||||
|
||||
it('handles invalid payloads', async () => {
|
||||
await resetExtension();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: 'test.js', transaction: 1 /* code */}));
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/converter/save',
|
||||
stringify({data: {}, status: 'error', error: `Invalid payload`, transaction: 1}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/remove', stringify({namex: 'test.js', transaction: 2}));
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/converter/remove',
|
||||
stringify({data: {}, status: 'error', error: `Invalid payload`, transaction: 2}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import * as data from '../mocks/data';
|
||||
import {mockLogger} from '../mocks/logger';
|
||||
import {mockMQTTEndAsync, events as mockMQTTEvents, mockMQTTPublishAsync} from '../mocks/mqtt';
|
||||
import {flushPromises} from '../mocks/utils';
|
||||
import {devices, mockController as mockZHController, returnDevices} from '../mocks/zigbeeHerdsman';
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
import {Controller} from '../../lib/controller';
|
||||
import * as settings from '../../lib/util/settings';
|
||||
|
||||
const BASE_DIR = 'external_extensions';
|
||||
|
||||
describe('Extension: ExternalExtensions', () => {
|
||||
let controller: Controller;
|
||||
const mockBasePath = path.join(data.mockDir, BASE_DIR);
|
||||
|
||||
const existsSyncSpy = vi.spyOn(fs, 'existsSync');
|
||||
const readdirSyncSpy = vi.spyOn(fs, 'readdirSync');
|
||||
const mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync');
|
||||
const rmSyncSpy = vi.spyOn(fs, 'rmSync');
|
||||
const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync');
|
||||
|
||||
const mocksClear = [
|
||||
mockMQTTEndAsync,
|
||||
mockMQTTPublishAsync,
|
||||
mockLogger.debug,
|
||||
mockLogger.error,
|
||||
mockZHController.stop,
|
||||
devices.bulb.save,
|
||||
existsSyncSpy,
|
||||
readdirSyncSpy,
|
||||
mkdirSyncSpy,
|
||||
rmSyncSpy,
|
||||
writeFileSyncSpy,
|
||||
];
|
||||
|
||||
const useAssets = (): void => {
|
||||
fs.cpSync(path.join(__dirname, '..', 'assets', BASE_DIR), mockBasePath, {recursive: true});
|
||||
};
|
||||
|
||||
const getFileCode = (fileName: string): string => {
|
||||
return fs.readFileSync(path.join(__dirname, '..', 'assets', BASE_DIR, fileName), 'utf8');
|
||||
};
|
||||
|
||||
const resetExtension = async (): Promise<void> => {
|
||||
await controller.enableDisableExtension(false, 'ExternalExtensions');
|
||||
await controller.enableDisableExtension(true, 'ExternalExtensions');
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
data.writeDefaultConfiguration();
|
||||
data.writeDefaultState();
|
||||
settings.reRead();
|
||||
returnDevices.splice(0);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(mockBasePath, {recursive: true, force: true});
|
||||
});
|
||||
|
||||
describe('from folder', () => {
|
||||
beforeEach(async () => {
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
});
|
||||
|
||||
it('loads nothing', async () => {
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(existsSyncSpy).toHaveBeenCalledWith(mockBasePath);
|
||||
expect(readdirSyncSpy).not.toHaveBeenCalledWith(mockBasePath);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/extensions', stringify([]), {retain: true, qos: 0});
|
||||
});
|
||||
|
||||
it('loads extensions', async () => {
|
||||
useAssets();
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example2/extension', 'call2 from constructor', {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example2/extension', 'call2 from start', {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from constructor', {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from start', {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/extensions',
|
||||
stringify([
|
||||
{name: 'example2Extension.js', code: getFileCode('example2Extension.js')},
|
||||
{name: 'exampleExtension.js', code: getFileCode('exampleExtension.js')},
|
||||
]),
|
||||
{retain: true, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('saves and removes from MQTT', async () => {
|
||||
const extensionName = 'foo.js';
|
||||
const extensionCode = getFileCode('exampleExtension.js');
|
||||
const extensionFilePath = path.join(mockBasePath, extensionName);
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
|
||||
//-- SAVE
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: extensionName, code: extensionCode}));
|
||||
await flushPromises();
|
||||
|
||||
expect(mkdirSyncSpy).toHaveBeenCalledWith(mockBasePath, {recursive: true});
|
||||
expect(writeFileSyncSpy).toHaveBeenCalledWith(extensionFilePath, extensionCode, 'utf8');
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from constructor', {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from start', {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/extensions',
|
||||
stringify([{name: extensionName, code: extensionCode}]),
|
||||
{
|
||||
retain: true,
|
||||
qos: 0,
|
||||
},
|
||||
);
|
||||
|
||||
//-- REMOVE
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/remove', stringify({name: extensionName}));
|
||||
await flushPromises();
|
||||
|
||||
expect(rmSyncSpy).toHaveBeenCalledWith(extensionFilePath, {force: true});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from stop', {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/extensions', stringify([]), {retain: true, qos: 0});
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error on invalid code', async () => {
|
||||
const extensionName = 'foo.js';
|
||||
const extensionCode = 'definetly not a correct javascript code';
|
||||
const extensionFilePath = path.join(mockBasePath, extensionName);
|
||||
|
||||
await resetExtension();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: extensionName, code: extensionCode}));
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/extension/save',
|
||||
expect.stringContaining(`"error":"${extensionName} contains invalid code`),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
expect(writeFileSyncSpy).not.toHaveBeenCalledWith(extensionFilePath, extensionCode, 'utf8');
|
||||
});
|
||||
|
||||
it('returns error on invalid removal', async () => {
|
||||
const converterName = 'invalid.js';
|
||||
const converterFilePath = path.join(mockBasePath, converterName);
|
||||
|
||||
await resetExtension();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/remove', stringify({name: converterName}));
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/extension/remove',
|
||||
stringify({data: {}, status: 'error', error: `${converterName} (${converterFilePath}) doesn't exists`}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
expect(rmSyncSpy).not.toHaveBeenCalledWith(converterFilePath, {force: true});
|
||||
});
|
||||
|
||||
it('handles invalid payloads', async () => {
|
||||
await resetExtension();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: 'test.js', transaction: 1 /* code */}));
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/extension/save',
|
||||
stringify({data: {}, status: 'error', error: `Invalid payload`, transaction: 1}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/remove', stringify({namex: 'test.js', transaction: 2}));
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/extension/remove',
|
||||
stringify({data: {}, status: 'error', error: `Invalid payload`, transaction: 2}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,437 @@
|
||||
import * as data from '../mocks/data';
|
||||
import {mockLogger} from '../mocks/logger';
|
||||
import {mockMQTTPublishAsync} from '../mocks/mqtt';
|
||||
import {EventHandler, flushPromises} from '../mocks/utils';
|
||||
import {devices} from '../mocks/zigbeeHerdsman';
|
||||
|
||||
import path from 'node:path';
|
||||
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import {Mock} from 'vitest';
|
||||
import ws from 'ws';
|
||||
|
||||
import {Controller} from '../../lib/controller';
|
||||
import * as settings from '../../lib/util/settings';
|
||||
|
||||
let mockHTTPOnRequest: (request: {url: string}, response: number) => void;
|
||||
const mockHTTPEvents: Record<string, EventHandler> = {};
|
||||
const mockHTTP = {
|
||||
listen: vi.fn(),
|
||||
on: (event: string, handler: EventHandler): void => {
|
||||
mockHTTPEvents[event] = handler;
|
||||
},
|
||||
close: vi.fn<(cb: (err?: Error) => void) => void>((cb) => cb()),
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
let mockHTTPSOnRequest: (request: {url: string}, response: number) => void;
|
||||
const mockHTTPSEvents: Record<string, EventHandler> = {};
|
||||
const mockHTTPS = {
|
||||
listen: vi.fn(),
|
||||
on: (event: string, handler: EventHandler): void => {
|
||||
mockHTTPSEvents[event] = handler;
|
||||
},
|
||||
close: vi.fn<(cb: (err?: Error) => void) => void>((cb) => cb()),
|
||||
};
|
||||
|
||||
const mockWSocket = {
|
||||
close: vi.fn<(cb: (err?: Error) => void) => void>(),
|
||||
};
|
||||
|
||||
const mockWSClientEvents: Record<string, EventHandler> = {};
|
||||
const mockWSClient = {
|
||||
on: (event: string, handler: EventHandler): void => {
|
||||
mockWSClientEvents[event] = handler;
|
||||
},
|
||||
send: vi.fn<(data: string) => void>(),
|
||||
terminate: vi.fn<() => void>(),
|
||||
readyState: 'close',
|
||||
};
|
||||
const mockWSEvents: Record<string, EventHandler> = {};
|
||||
const mockWSClients: (typeof mockWSClient)[] = [];
|
||||
const mockWS = {
|
||||
clients: mockWSClients,
|
||||
on: (event: string, handler: EventHandler): void => {
|
||||
mockWSEvents[event] = handler;
|
||||
},
|
||||
handleUpgrade: vi.fn().mockImplementation((request, socket, head, cb) => {
|
||||
cb(mockWSocket);
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
emit: vi.fn<(eventName: string, ...args: any[]) => void>(),
|
||||
close: vi.fn<(code?: number, data?: string | Buffer) => void>(),
|
||||
};
|
||||
|
||||
const frontendPath = 'frontend-path';
|
||||
const deviceIconsPath = path.join(data.mockDir, 'device_icons');
|
||||
let mockNodeStatic: {[s: string]: Mock} = {};
|
||||
|
||||
const mockFinalHandler = vi.fn();
|
||||
|
||||
vi.mock('node:http', () => ({
|
||||
createServer: vi.fn().mockImplementation((onRequest) => {
|
||||
mockHTTPOnRequest = onRequest;
|
||||
return mockHTTP;
|
||||
}),
|
||||
Agent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:https', () => ({
|
||||
createServer: vi.fn().mockImplementation((onRequest) => {
|
||||
mockHTTPSOnRequest = onRequest;
|
||||
return mockHTTPS;
|
||||
}),
|
||||
Agent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('express-static-gzip', () => ({
|
||||
default: vi.fn().mockImplementation((path: string) => {
|
||||
mockNodeStatic[path] = vi.fn();
|
||||
return mockNodeStatic[path];
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('zigbee2mqtt-frontend', () => ({
|
||||
default: {
|
||||
getPath: (): string => frontendPath,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('ws', () => ({
|
||||
default: {
|
||||
OPEN: 'open',
|
||||
Server: vi.fn().mockImplementation(() => {
|
||||
return mockWS;
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('finalhandler', () => ({
|
||||
default: vi.fn().mockImplementation(() => {
|
||||
return mockFinalHandler;
|
||||
}),
|
||||
}));
|
||||
|
||||
const mocksClear = [
|
||||
mockHTTP.close,
|
||||
mockHTTP.listen,
|
||||
mockHTTPS.close,
|
||||
mockHTTPS.listen,
|
||||
mockWSocket.close,
|
||||
mockWS.close,
|
||||
mockWS.handleUpgrade,
|
||||
mockWS.emit,
|
||||
mockWSClient.send,
|
||||
mockWSClient.terminate,
|
||||
mockFinalHandler,
|
||||
mockMQTTPublishAsync,
|
||||
mockLogger.error,
|
||||
];
|
||||
|
||||
describe('Extension: Frontend', () => {
|
||||
let controller: Controller;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
mockNodeStatic = {};
|
||||
mockWS.clients = [];
|
||||
data.writeDefaultConfiguration();
|
||||
data.writeDefaultState();
|
||||
settings.reRead();
|
||||
settings.set(['frontend'], {enabled: true, port: 8081, host: '127.0.0.1'});
|
||||
settings.set(['homeassistant'], {enabled: true});
|
||||
devices.bulb.linkquality = 10;
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
mockWSClient.readyState = 'close';
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
delete devices.bulb.linkquality;
|
||||
});
|
||||
|
||||
it('Start/stop with defaults', async () => {
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
await controller.start();
|
||||
expect(Object.keys(mockNodeStatic)).toStrictEqual([frontendPath, deviceIconsPath]);
|
||||
expect(mockHTTP.listen).toHaveBeenCalledWith(8081, '127.0.0.1');
|
||||
mockWS.clients.push(mockWSClient);
|
||||
await controller.stop();
|
||||
expect(mockWSClient.terminate).toHaveBeenCalledTimes(1);
|
||||
expect(mockHTTP.close).toHaveBeenCalledTimes(1);
|
||||
expect(mockWS.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Start/stop without host', async () => {
|
||||
settings.set(['frontend'], {enabled: true, port: 8081});
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
await controller.start();
|
||||
expect(Object.keys(mockNodeStatic)).toStrictEqual([frontendPath, deviceIconsPath]);
|
||||
expect(mockHTTP.listen).toHaveBeenCalledWith(8081);
|
||||
mockWS.clients.push(mockWSClient);
|
||||
await controller.stop();
|
||||
expect(mockWSClient.terminate).toHaveBeenCalledTimes(1);
|
||||
expect(mockHTTP.close).toHaveBeenCalledTimes(1);
|
||||
expect(mockWS.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Start/stop unix socket', async () => {
|
||||
settings.set(['frontend', 'host'], '/tmp/zigbee2mqtt.sock');
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
await controller.start();
|
||||
expect(Object.keys(mockNodeStatic)).toStrictEqual([frontendPath, deviceIconsPath]);
|
||||
expect(mockHTTP.listen).toHaveBeenCalledWith('/tmp/zigbee2mqtt.sock');
|
||||
mockWS.clients.push(mockWSClient);
|
||||
await controller.stop();
|
||||
expect(mockWSClient.terminate).toHaveBeenCalledTimes(1);
|
||||
expect(mockHTTP.close).toHaveBeenCalledTimes(1);
|
||||
expect(mockWS.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Start/stop HTTPS valid', async () => {
|
||||
settings.set(['frontend', 'ssl_cert'], path.join(__dirname, '..', 'assets', 'certs', 'dummy.crt'));
|
||||
settings.set(['frontend', 'ssl_key'], path.join(__dirname, '..', 'assets', 'certs', 'dummy.key'));
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
await controller.start();
|
||||
expect(mockHTTP.listen).not.toHaveBeenCalledWith(8081, '127.0.0.1');
|
||||
expect(mockHTTPS.listen).toHaveBeenCalledWith(8081, '127.0.0.1');
|
||||
await controller.stop();
|
||||
});
|
||||
|
||||
it('Start/stop HTTPS invalid : missing config', async () => {
|
||||
settings.set(['frontend', 'ssl_cert'], path.join(__dirname, '..', 'assets', 'certs', 'dummy.crt'));
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
await controller.start();
|
||||
expect(mockHTTP.listen).toHaveBeenCalledWith(8081, '127.0.0.1');
|
||||
expect(mockHTTPS.listen).not.toHaveBeenCalledWith(8081, '127.0.0.1');
|
||||
await controller.stop();
|
||||
});
|
||||
|
||||
it('Start/stop HTTPS invalid : missing file', async () => {
|
||||
settings.set(['frontend', 'ssl_cert'], 'filesNotExists.crt');
|
||||
settings.set(['frontend', 'ssl_key'], path.join(__dirname, '..', 'assets', 'certs', 'dummy.key'));
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
await controller.start();
|
||||
expect(mockHTTP.listen).toHaveBeenCalledWith(8081, '127.0.0.1');
|
||||
expect(mockHTTPS.listen).not.toHaveBeenCalledWith(8081, '127.0.0.1');
|
||||
await controller.stop();
|
||||
});
|
||||
|
||||
it('Websocket interaction', async () => {
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
await controller.start();
|
||||
mockWSClient.readyState = 'open';
|
||||
mockWS.clients.push(mockWSClient);
|
||||
await mockWSEvents.connection(mockWSClient);
|
||||
|
||||
const allTopics = mockWSClient.send.mock.calls.map(([m]) => JSON.parse(m).topic);
|
||||
expect(allTopics).toContain('bridge/devices');
|
||||
expect(allTopics).toContain('bridge/info');
|
||||
expect(mockWSClient.send).toHaveBeenCalledWith(stringify({topic: 'bridge/state', payload: {state: 'online'}}));
|
||||
expect(mockWSClient.send).toHaveBeenCalledWith(stringify({topic: 'remote', payload: {brightness: 255}}));
|
||||
|
||||
// Message
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockWSClient.send.mockClear();
|
||||
mockWSClientEvents.message(stringify({topic: 'bulb_color/set', payload: {state: 'ON'}}), false);
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb_color',
|
||||
stringify({
|
||||
state: 'ON',
|
||||
effect: null,
|
||||
power_on_behavior: null,
|
||||
linkquality: null,
|
||||
update: {state: null, installed_version: -1, latest_version: -1},
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
mockWSClientEvents.message(undefined, false);
|
||||
mockWSClientEvents.message('', false);
|
||||
mockWSClientEvents.message(null, false);
|
||||
await flushPromises();
|
||||
|
||||
// Error
|
||||
mockWSClientEvents.error(new Error('This is an error'));
|
||||
expect(mockLogger.error).toHaveBeenCalledWith('WebSocket error: This is an error');
|
||||
|
||||
// Received message on socket
|
||||
expect(mockWSClient.send).toHaveBeenCalledTimes(1);
|
||||
expect(mockWSClient.send).toHaveBeenCalledWith(
|
||||
stringify({
|
||||
topic: 'bulb_color',
|
||||
payload: {
|
||||
state: 'ON',
|
||||
effect: null,
|
||||
power_on_behavior: null,
|
||||
linkquality: null,
|
||||
update: {state: null, installed_version: -1, latest_version: -1},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Shouldnt set when not ready
|
||||
mockWSClient.send.mockClear();
|
||||
mockWSClient.readyState = 'close';
|
||||
mockWSClientEvents.message(stringify({topic: 'bulb_color/set', payload: {state: 'ON'}}), false);
|
||||
expect(mockWSClient.send).toHaveBeenCalledTimes(0);
|
||||
|
||||
// Send last seen on connect
|
||||
mockWSClient.send.mockClear();
|
||||
mockWSClient.readyState = 'open';
|
||||
settings.set(['advanced'], {last_seen: 'ISO_8601'});
|
||||
mockWS.clients.push(mockWSClient);
|
||||
await mockWSEvents.connection(mockWSClient);
|
||||
expect(mockWSClient.send).toHaveBeenCalledWith(
|
||||
stringify({topic: 'remote', payload: {brightness: 255, last_seen: '1970-01-01T00:00:01.000Z'}}),
|
||||
);
|
||||
});
|
||||
|
||||
it('onRequest/onUpgrade', async () => {
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
await controller.start();
|
||||
|
||||
const mockSocket = {destroy: vi.fn()};
|
||||
mockHTTPEvents.upgrade({url: 'http://localhost:8080/api'}, mockSocket, 3);
|
||||
expect(mockWS.handleUpgrade).toHaveBeenCalledTimes(1);
|
||||
expect(mockSocket.destroy).toHaveBeenCalledTimes(0);
|
||||
expect(mockWS.handleUpgrade).toHaveBeenCalledWith({url: 'http://localhost:8080/api'}, mockSocket, 3, expect.any(Function));
|
||||
mockWS.handleUpgrade.mock.calls[0][3](99);
|
||||
expect(mockWS.emit).toHaveBeenCalledWith('connection', 99, {url: 'http://localhost:8080/api'});
|
||||
|
||||
mockHTTPOnRequest({url: '/file.txt'}, 2);
|
||||
expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledTimes(0);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith(
|
||||
{originalUrl: '/file.txt', path: '/file.txt', url: '/file.txt'},
|
||||
2,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should serve device icons', async () => {
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
await controller.start();
|
||||
|
||||
mockHTTPOnRequest({url: '/device_icons/my_device.png'}, 2);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(0);
|
||||
expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledWith(
|
||||
{originalUrl: '/device_icons/my_device.png', path: '/my_device.png', url: '/my_device.png'},
|
||||
2,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Static server', async () => {
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
await controller.start();
|
||||
|
||||
expect(mockHTTP.listen).toHaveBeenCalledWith(8081, '127.0.0.1');
|
||||
});
|
||||
|
||||
it('Authentification', async () => {
|
||||
const authToken = 'sample-secure-token';
|
||||
settings.set(['frontend', 'auth_token'], authToken);
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
await controller.start();
|
||||
|
||||
const mockSocket = {destroy: vi.fn()};
|
||||
mockHTTPEvents.upgrade({url: '/api'}, mockSocket, mockWSocket);
|
||||
expect(mockWS.handleUpgrade).toHaveBeenCalledTimes(1);
|
||||
expect(mockSocket.destroy).toHaveBeenCalledTimes(0);
|
||||
expect(mockWS.handleUpgrade).toHaveBeenCalledWith({url: '/api'}, mockSocket, mockWSocket, expect.any(Function));
|
||||
expect(mockWSocket.close).toHaveBeenCalledWith(4401, 'Unauthorized');
|
||||
|
||||
mockWSocket.close.mockClear();
|
||||
mockWS.emit.mockClear();
|
||||
|
||||
const url = `/api?token=${authToken}`;
|
||||
mockWS.handleUpgrade.mockClear();
|
||||
mockHTTPEvents.upgrade({url: url}, mockSocket, 3);
|
||||
expect(mockWS.handleUpgrade).toHaveBeenCalledTimes(1);
|
||||
expect(mockSocket.destroy).toHaveBeenCalledTimes(0);
|
||||
expect(mockWS.handleUpgrade).toHaveBeenCalledWith({url}, mockSocket, 3, expect.any(Function));
|
||||
expect(mockWSocket.close).toHaveBeenCalledTimes(0);
|
||||
mockWS.handleUpgrade.mock.calls[0][3](mockWSocket);
|
||||
expect(mockWS.emit).toHaveBeenCalledWith('connection', mockWSocket, {url});
|
||||
});
|
||||
|
||||
it.each(['/z2m/', '/z2m'])('Works with non-default base url %s', async (baseUrl) => {
|
||||
settings.set(['frontend', 'base_url'], baseUrl);
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
await controller.start();
|
||||
|
||||
expect(ws.Server).toHaveBeenCalledWith({noServer: true, path: '/z2m/api'});
|
||||
|
||||
mockHTTPOnRequest({url: '/z2m'}, 2);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith({originalUrl: '/z2m', path: '/', url: '/'}, 2, expect.any(Function));
|
||||
expect(mockFinalHandler).not.toHaveBeenCalledWith();
|
||||
|
||||
mockNodeStatic[frontendPath].mockReset();
|
||||
expect(mockFinalHandler).not.toHaveBeenCalledWith();
|
||||
mockHTTPOnRequest({url: '/z2m/file.txt'}, 2);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith(
|
||||
{originalUrl: '/z2m/file.txt', path: '/file.txt', url: '/file.txt'},
|
||||
2,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockFinalHandler).not.toHaveBeenCalledWith();
|
||||
|
||||
mockNodeStatic[frontendPath].mockReset();
|
||||
mockHTTPOnRequest({url: '/z/file.txt'}, 2);
|
||||
expect(mockNodeStatic[frontendPath]).not.toHaveBeenCalled();
|
||||
expect(mockFinalHandler).toHaveBeenCalled();
|
||||
|
||||
mockHTTPOnRequest({url: '/z2m/device_icons/my-device.png'}, 2);
|
||||
expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledWith(
|
||||
{originalUrl: '/z2m/device_icons/my-device.png', path: '/my-device.png', url: '/my-device.png'},
|
||||
2,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Works with non-default complex base url', async () => {
|
||||
const baseUrl = '/z2m-more++/c0mplex.url/';
|
||||
settings.set(['frontend', 'base_url'], baseUrl);
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
await controller.start();
|
||||
|
||||
expect(ws.Server).toHaveBeenCalledWith({noServer: true, path: '/z2m-more++/c0mplex.url/api'});
|
||||
|
||||
mockHTTPOnRequest({url: '/z2m-more++/c0mplex.url'}, 2);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith(
|
||||
{originalUrl: '/z2m-more++/c0mplex.url', path: '/', url: '/'},
|
||||
2,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockFinalHandler).not.toHaveBeenCalledWith();
|
||||
|
||||
mockNodeStatic[frontendPath].mockReset();
|
||||
expect(mockFinalHandler).not.toHaveBeenCalledWith();
|
||||
mockHTTPOnRequest({url: '/z2m-more++/c0mplex.url/file.txt'}, 2);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith(
|
||||
{originalUrl: '/z2m-more++/c0mplex.url/file.txt', path: '/file.txt', url: '/file.txt'},
|
||||
2,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockFinalHandler).not.toHaveBeenCalledWith();
|
||||
|
||||
mockNodeStatic[frontendPath].mockReset();
|
||||
mockHTTPOnRequest({url: '/z/file.txt'}, 2);
|
||||
expect(mockNodeStatic[frontendPath]).not.toHaveBeenCalled();
|
||||
expect(mockFinalHandler).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,799 @@
|
||||
import * as data from '../mocks/data';
|
||||
import {mockLogger} from '../mocks/logger';
|
||||
import {events as mockMQTTEvents, mockMQTTPublishAsync} from '../mocks/mqtt';
|
||||
import {flushPromises} from '../mocks/utils';
|
||||
import {devices, groups, events as mockZHEvents, resetGroupMembers, returnDevices} from '../mocks/zigbeeHerdsman';
|
||||
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
import * as zhcGlobalStore from 'zigbee-herdsman-converters/lib/store';
|
||||
|
||||
import {Controller} from '../../lib/controller';
|
||||
import * as settings from '../../lib/util/settings';
|
||||
|
||||
returnDevices.push(
|
||||
devices.coordinator.ieeeAddr,
|
||||
devices.bulb_color.ieeeAddr,
|
||||
devices.bulb.ieeeAddr,
|
||||
devices.QBKG03LM.ieeeAddr,
|
||||
devices.bulb_color_2.ieeeAddr,
|
||||
devices.bulb_2.ieeeAddr,
|
||||
devices.GLEDOPTO_2ID.ieeeAddr,
|
||||
devices.InovelliVZM31SN.ieeeAddr,
|
||||
);
|
||||
|
||||
describe('Extension: Groups', () => {
|
||||
let controller: Controller;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.useFakeTimers();
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetGroupMembers();
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
groups.gledopto_group.command.mockClear();
|
||||
zhcGlobalStore.clear();
|
||||
// @ts-expect-error private
|
||||
controller.state.state = {};
|
||||
});
|
||||
|
||||
it('Should publish group state change when a device in it changes state', async () => {
|
||||
const device = devices.bulb_color;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
const group = groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
const payload = {data: {onOff: 1}, cluster: 'genOnOff', device, endpoint, type: 'attributeReport', linkquality: 10};
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color', stringify({state: 'ON'}), {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/group_1', stringify({state: 'ON'}), {retain: false, qos: 0});
|
||||
});
|
||||
|
||||
it('Should not republish identical optimistic group states', async () => {
|
||||
const device1 = devices.bulb_2;
|
||||
const device2 = devices.bulb_color_2;
|
||||
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
await mockZHEvents.message({
|
||||
data: {onOff: 1},
|
||||
cluster: 'genOnOff',
|
||||
device: device1,
|
||||
endpoint: device1.getEndpoint(1),
|
||||
type: 'attributeReport',
|
||||
linkquality: 10,
|
||||
});
|
||||
await mockZHEvents.message({
|
||||
data: {onOff: 1},
|
||||
cluster: 'genOnOff',
|
||||
device: device2,
|
||||
endpoint: device2.getEndpoint(1),
|
||||
type: 'attributeReport',
|
||||
linkquality: 10,
|
||||
});
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(6);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/group_tradfri_remote', stringify({state: 'ON'}), {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_2', stringify({state: 'ON'}), {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color_2', stringify({state: 'ON'}), {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/group_with_tradfri', stringify({state: 'ON'}), {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/ha_discovery_group', stringify({state: 'ON'}), {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/switch_group', stringify({state: 'ON'}), {retain: false, qos: 0});
|
||||
});
|
||||
|
||||
it('Should publish state change of all members when a group changes its state', async () => {
|
||||
const device = devices.bulb_color;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
const group = groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
await mockMQTTEvents.message('zigbee2mqtt/group_1/set', stringify({state: 'ON'}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color', stringify({state: 'ON'}), {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/group_1', stringify({state: 'ON'}), {retain: false, qos: 0});
|
||||
});
|
||||
|
||||
it('Should not publish state change when group changes state and device is disabled', async () => {
|
||||
const device = devices.bulb_color;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
const group = groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
settings.set(['devices', device.ieeeAddr, 'disabled'], true);
|
||||
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
await mockMQTTEvents.message('zigbee2mqtt/group_1/set', stringify({state: 'ON'}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/group_1', stringify({state: 'ON'}), {retain: false, qos: 0});
|
||||
});
|
||||
|
||||
it('Should publish state change for group when members state change', async () => {
|
||||
// Created for https://github.com/Koenkk/zigbee2mqtt/issues/5725
|
||||
const device = devices.bulb_color;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
const group = groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
await mockMQTTEvents.message('zigbee2mqtt/bulb_color/set', stringify({state: 'ON'}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color', stringify({state: 'ON'}), {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/group_1', stringify({state: 'ON'}), {retain: false, qos: 0});
|
||||
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
await mockMQTTEvents.message('zigbee2mqtt/group_1/set', stringify({state: 'OFF'}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color', stringify({state: 'OFF'}), {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/group_1', stringify({state: 'OFF'}), {retain: false, qos: 0});
|
||||
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
await mockMQTTEvents.message('zigbee2mqtt/bulb_color/set', stringify({state: 'ON'}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color', stringify({state: 'ON'}), {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/group_1', stringify({state: 'ON'}), {retain: false, qos: 0});
|
||||
});
|
||||
|
||||
it('Should publish state of device with endpoint name', async () => {
|
||||
const group = groups.gledopto_group;
|
||||
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
await mockMQTTEvents.message('zigbee2mqtt/gledopto_group/set', stringify({state: 'ON'}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/GLEDOPTO_2ID', stringify({state_cct: 'ON'}), {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/gledopto_group', stringify({state: 'ON'}), {retain: false, qos: 0});
|
||||
expect(group.command).toHaveBeenCalledTimes(1);
|
||||
expect(group.command).toHaveBeenCalledWith('genOnOff', 'on', {}, {});
|
||||
});
|
||||
|
||||
it('Should publish state of group when specific state of specific endpoint is changed', async () => {
|
||||
const group = groups.gledopto_group;
|
||||
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
await mockMQTTEvents.message('zigbee2mqtt/GLEDOPTO_2ID/set', stringify({state_cct: 'ON'}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/GLEDOPTO_2ID', stringify({state_cct: 'ON'}), {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/gledopto_group', stringify({state: 'ON'}), {retain: false, qos: 0});
|
||||
expect(group.command).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should publish state change of all members when a group changes its state, filtered', async () => {
|
||||
const device = devices.bulb_color;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
const group = groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
settings.set(['groups'], {1: {friendly_name: 'group_1', retain: false, filtered_attributes: ['brightness']}});
|
||||
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
await mockMQTTEvents.message('zigbee2mqtt/group_1/set', stringify({state: 'ON', brightness: 100}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color', stringify({state: 'ON', brightness: 100}), {
|
||||
retain: false,
|
||||
qos: 0,
|
||||
});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/group_1', stringify({state: 'ON'}), {retain: false, qos: 0});
|
||||
});
|
||||
|
||||
it('Shouldnt publish group state change when a group is not optimistic', async () => {
|
||||
const device = devices.bulb_color;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
const group = groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
settings.set(['groups'], {1: {friendly_name: 'group_1', optimistic: false, retain: false}});
|
||||
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
const payload = {data: {onOff: 1}, cluster: 'genOnOff', device, endpoint, type: 'attributeReport', linkquality: 10};
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color', stringify({state: 'ON'}), {retain: false, qos: 0});
|
||||
});
|
||||
|
||||
it('Should publish state change of another group with shared device when a group changes its state', async () => {
|
||||
const device = devices.bulb_color;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
groups.group_1.members.push(endpoint);
|
||||
groups.group_2.members.push(endpoint);
|
||||
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
await mockMQTTEvents.message('zigbee2mqtt/group_1/set', stringify({state: 'ON'}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(3);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color', stringify({state: 'ON'}), {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/group_1', stringify({state: 'ON'}), {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/group_2', stringify({state: 'ON'}), {retain: false, qos: 0});
|
||||
});
|
||||
|
||||
it('Should not publish state change off if any lights within are still on when changed via device', async () => {
|
||||
const device_1 = devices.bulb_color;
|
||||
const device_2 = devices.bulb;
|
||||
const endpoint_1 = device_1.getEndpoint(1)!;
|
||||
const endpoint_2 = device_2.getEndpoint(1)!;
|
||||
const group = groups.group_1;
|
||||
group.members.push(endpoint_1);
|
||||
group.members.push(endpoint_2);
|
||||
|
||||
await mockMQTTEvents.message('zigbee2mqtt/group_1/set', stringify({state: 'ON'}));
|
||||
await flushPromises();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
await mockMQTTEvents.message('zigbee2mqtt/bulb_color/set', stringify({state: 'OFF'}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color', stringify({state: 'OFF'}), {retain: false, qos: 0});
|
||||
});
|
||||
|
||||
it('Should publish state change off if any lights within are still on when changed via device when off_state: last_member_state is used', async () => {
|
||||
const device_1 = devices.bulb_color;
|
||||
const device_2 = devices.bulb;
|
||||
const endpoint_1 = device_1.getEndpoint(1)!;
|
||||
const endpoint_2 = device_2.getEndpoint(1)!;
|
||||
const group = groups.group_1;
|
||||
group.members.push(endpoint_1);
|
||||
group.members.push(endpoint_2);
|
||||
settings.set(['groups'], {
|
||||
1: {friendly_name: 'group_1', retain: false, off_state: 'last_member_state'},
|
||||
});
|
||||
|
||||
await mockMQTTEvents.message('zigbee2mqtt/group_1/set', stringify({state: 'ON'}));
|
||||
await flushPromises();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
await mockMQTTEvents.message('zigbee2mqtt/bulb_color/set', stringify({state: 'OFF'}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(1, 'zigbee2mqtt/group_1', stringify({state: 'OFF'}), {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(2, 'zigbee2mqtt/bulb_color', stringify({state: 'OFF'}), {retain: false, qos: 0});
|
||||
});
|
||||
|
||||
it('Should not publish state change off if any lights within with non default-ep are still on when changed via device', async () => {
|
||||
const device_1 = devices.bulb_color;
|
||||
const device_2 = devices.QBKG03LM;
|
||||
const endpoint_1 = device_1.getEndpoint(1)!;
|
||||
const endpoint_2 = device_2.getEndpoint(2)!;
|
||||
const group = groups.group_1;
|
||||
group.members.push(endpoint_1);
|
||||
group.members.push(endpoint_2);
|
||||
|
||||
await mockMQTTEvents.message('zigbee2mqtt/group_1/set', stringify({state: 'ON'}));
|
||||
await flushPromises();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
await mockMQTTEvents.message('zigbee2mqtt/bulb_color/set', stringify({state: 'OFF'}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color', stringify({state: 'OFF'}), {retain: false, qos: 0});
|
||||
});
|
||||
|
||||
it('Should not publish state change off if any lights within are still on when changed via device with non default-ep', async () => {
|
||||
const device_1 = devices.bulb_color;
|
||||
const device_2 = devices.QBKG03LM;
|
||||
const endpoint_1 = device_1.getEndpoint(1)!;
|
||||
const endpoint_2 = device_2.getEndpoint(2)!;
|
||||
const endpoint_3 = device_2.getEndpoint(3)!;
|
||||
endpoint_3.removeFromGroup(groups.ha_discovery_group);
|
||||
const group = groups.group_1;
|
||||
group.members.push(endpoint_1);
|
||||
group.members.push(endpoint_2);
|
||||
group.members.push(endpoint_3);
|
||||
|
||||
await mockMQTTEvents.message('zigbee2mqtt/group_1/set', stringify({state: 'ON'}));
|
||||
await flushPromises();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
await mockMQTTEvents.message('zigbee2mqtt/wall_switch_double/set', stringify({state_left: 'OFF'}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/wall_switch_double', stringify({state_left: 'OFF', state_right: 'ON'}), {
|
||||
retain: false,
|
||||
qos: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('Should publish state change off if all lights within turn off with non default-ep', async () => {
|
||||
const device_1 = devices.bulb_color;
|
||||
const device_2 = devices.QBKG03LM;
|
||||
const endpoint_1 = device_1.getEndpoint(1)!;
|
||||
const endpoint_2 = device_2.getEndpoint(2)!;
|
||||
const group = groups.group_1;
|
||||
group.members.push(endpoint_1);
|
||||
group.members.push(endpoint_2);
|
||||
settings.set(['groups'], {
|
||||
1: {friendly_name: 'group_1', retain: false},
|
||||
});
|
||||
|
||||
await mockMQTTEvents.message('zigbee2mqtt/group_1/set', stringify({state: 'ON'}));
|
||||
await flushPromises();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
await mockMQTTEvents.message('zigbee2mqtt/bulb_color/set', stringify({state: 'OFF'}));
|
||||
await mockMQTTEvents.message('zigbee2mqtt/wall_switch_double/set', stringify({state_left: 'OFF'}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(3);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color', stringify({state: 'OFF'}), {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/wall_switch_double', stringify({state_left: 'OFF'}), {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/group_1', stringify({state: 'OFF'}), {retain: false, qos: 0});
|
||||
});
|
||||
|
||||
it('Should publish state change off if all lights within turn off with non default-ep, but device state does not use them', async () => {
|
||||
const device_1 = devices.bulb_color;
|
||||
const device_2 = devices.InovelliVZM31SN;
|
||||
const endpoint_1 = device_1.getEndpoint(1)!;
|
||||
const endpoint_2 = device_2.getEndpoint(2)!;
|
||||
const group = groups.group_1;
|
||||
group.members.push(endpoint_1);
|
||||
group.members.push(endpoint_2);
|
||||
settings.set(['groups'], {
|
||||
1: {friendly_name: 'group_1', retain: false},
|
||||
});
|
||||
|
||||
await mockMQTTEvents.message('zigbee2mqtt/group_1/set', stringify({state: 'ON'}));
|
||||
await flushPromises();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
await mockMQTTEvents.message('zigbee2mqtt/bulb_color/set', stringify({state: 'OFF'}));
|
||||
await mockMQTTEvents.message('zigbee2mqtt/wall_switch_double/set', stringify({state_left: 'OFF'}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(3);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color', stringify({state: 'OFF'}), {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/wall_switch_double', stringify({state_left: 'OFF'}), {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/group_1', stringify({state: 'OFF'}), {retain: false, qos: 0});
|
||||
});
|
||||
|
||||
it('Should not publish state change off if any lights within are still on when changed via shared group', async () => {
|
||||
const device_1 = devices.bulb_color;
|
||||
const device_2 = devices.bulb;
|
||||
const endpoint_1 = device_1.getEndpoint(1)!;
|
||||
const endpoint_2 = device_2.getEndpoint(1)!;
|
||||
groups.group_1.members.push(endpoint_1);
|
||||
groups.group_1.members.push(endpoint_2);
|
||||
groups.group_2.members.push(endpoint_1);
|
||||
|
||||
await mockMQTTEvents.message('zigbee2mqtt/group_1/set', stringify({state: 'ON'}));
|
||||
await flushPromises();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
await mockMQTTEvents.message('zigbee2mqtt/group_2/set', stringify({state: 'OFF'}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/group_2', stringify({state: 'OFF'}), {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color', stringify({state: 'OFF'}), {retain: false, qos: 0});
|
||||
});
|
||||
|
||||
it('Should publish state change off if all lights within turn off', async () => {
|
||||
const device_1 = devices.bulb_color;
|
||||
const device_2 = devices.bulb;
|
||||
const endpoint_1 = device_1.getEndpoint(1)!;
|
||||
const endpoint_2 = device_2.getEndpoint(1)!;
|
||||
const group = groups.group_1;
|
||||
group.members.push(endpoint_1);
|
||||
group.members.push(endpoint_2);
|
||||
settings.set(['groups'], {
|
||||
1: {friendly_name: 'group_1', retain: false},
|
||||
});
|
||||
|
||||
await mockMQTTEvents.message('zigbee2mqtt/group_1/set', stringify({state: 'ON'}));
|
||||
await flushPromises();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
await mockMQTTEvents.message('zigbee2mqtt/bulb_color/set', stringify({state: 'OFF'}));
|
||||
await mockMQTTEvents.message('zigbee2mqtt/bulb/set', stringify({state: 'OFF'}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(3);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color', stringify({state: 'OFF'}), {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({state: 'OFF'}), {retain: true, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/group_1', stringify({state: 'OFF'}), {retain: false, qos: 0});
|
||||
});
|
||||
|
||||
it('Should only update group state with changed properties', async () => {
|
||||
const device_1 = devices.bulb_color;
|
||||
const device_2 = devices.bulb;
|
||||
const endpoint_1 = device_1.getEndpoint(1)!;
|
||||
const endpoint_2 = device_2.getEndpoint(1)!;
|
||||
const group = groups.group_1;
|
||||
group.members.push(endpoint_1);
|
||||
group.members.push(endpoint_2);
|
||||
settings.set(['groups'], {
|
||||
1: {friendly_name: 'group_1', retain: false},
|
||||
});
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
await mockMQTTEvents.message('zigbee2mqtt/bulb_color/set', stringify({state: 'OFF', color_temp: 200}));
|
||||
await mockMQTTEvents.message('zigbee2mqtt/bulb/set', stringify({state: 'ON', color_temp: 250}));
|
||||
await flushPromises();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
await mockMQTTEvents.message('zigbee2mqtt/group_1/set', stringify({color_temp: 300}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(3);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb_color',
|
||||
stringify({color_mode: 'color_temp', color_temp: 300, state: 'OFF'}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({color_mode: 'color_temp', color_temp: 300, state: 'ON'}), {
|
||||
retain: true,
|
||||
qos: 0,
|
||||
});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/group_1',
|
||||
stringify({color_mode: 'color_temp', color_temp: 300, state: 'ON'}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should publish state change off even when missing current state', async () => {
|
||||
const device_1 = devices.bulb_color;
|
||||
const device_2 = devices.bulb;
|
||||
const endpoint_1 = device_1.getEndpoint(1)!;
|
||||
const endpoint_2 = device_2.getEndpoint(1)!;
|
||||
const group = groups.group_1;
|
||||
group.members.push(endpoint_1);
|
||||
group.members.push(endpoint_2);
|
||||
settings.set(['groups'], {
|
||||
1: {friendly_name: 'group_1', retain: false},
|
||||
});
|
||||
|
||||
await mockMQTTEvents.message('zigbee2mqtt/group_1/set', stringify({state: 'ON'}));
|
||||
await flushPromises();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
// @ts-expect-error private
|
||||
controller.state.state = {};
|
||||
|
||||
await mockMQTTEvents.message('zigbee2mqtt/bulb_color/set', stringify({state: 'OFF'}));
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color', stringify({state: 'OFF'}), {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/group_1', stringify({state: 'OFF'}), {retain: false, qos: 0});
|
||||
});
|
||||
|
||||
it('Add to group via MQTT', async () => {
|
||||
const device = devices.bulb_color;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
const group = groups.group_1;
|
||||
expect(group.members.length).toBe(0);
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message(
|
||||
'zigbee2mqtt/bridge/request/group/members/add',
|
||||
stringify({transaction: '123', group: 'group_1', device: 'bulb_color'}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(group.members).toStrictEqual([endpoint]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/members/add',
|
||||
stringify({data: {device: 'bulb_color', endpoint: 'default', group: 'group_1'}, transaction: '123', status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Add to group via MQTT fails', async () => {
|
||||
const device = devices.bulb_color;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
const group = groups.group_1;
|
||||
expect(group.members.length).toBe(0);
|
||||
endpoint.addToGroup.mockImplementationOnce(() => {
|
||||
throw new Error('timeout');
|
||||
});
|
||||
await flushPromises();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/group/members/add', stringify({group: 'group_1', device: 'bulb_color'}));
|
||||
await flushPromises();
|
||||
expect(group.members).toStrictEqual([]);
|
||||
expect(mockMQTTPublishAsync).not.toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/members/add',
|
||||
stringify({data: {}, status: 'error', error: 'Failed to add from group (timeout)'}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Add to group with slashes via MQTT', async () => {
|
||||
const device = devices.bulb_color;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
const group = groups['group/with/slashes'];
|
||||
settings.set(['groups'], {99: {friendly_name: 'group/with/slashes', retain: false}});
|
||||
expect(group.members.length).toBe(0);
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/group/members/add', stringify({group: 'group/with/slashes', device: 'bulb_color'}));
|
||||
await flushPromises();
|
||||
expect(group.members).toStrictEqual([endpoint]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/members/add',
|
||||
stringify({data: {device: 'bulb_color', endpoint: 'default', group: 'group/with/slashes'}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Add to group via MQTT with postfix', async () => {
|
||||
const device = devices.QBKG03LM;
|
||||
const endpoint = device.getEndpoint(3)!;
|
||||
const group = groups.group_1;
|
||||
expect(group.members.length).toBe(0);
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message(
|
||||
'zigbee2mqtt/bridge/request/group/members/add',
|
||||
stringify({group: 'group_1', device: 'wall_switch_double', endpoint: 'right'}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(group.members).toStrictEqual([endpoint]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/members/add',
|
||||
stringify({data: {device: 'wall_switch_double', endpoint: 'right', group: 'group_1'}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Add to group via MQTT with postfix shouldnt add it twice', async () => {
|
||||
const device = devices.QBKG03LM;
|
||||
const endpoint = device.getEndpoint(3)!;
|
||||
const group = groups.group_1;
|
||||
expect(group.members.length).toBe(0);
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message(
|
||||
'zigbee2mqtt/bridge/request/group/members/add',
|
||||
stringify({group: 'group_1', device: 'wall_switch_double', endpoint: 'right'}),
|
||||
);
|
||||
await flushPromises();
|
||||
mockMQTTEvents.message(
|
||||
'zigbee2mqtt/bridge/request/group/members/add',
|
||||
stringify({group: 'group_1', device: '0x0017880104e45542', endpoint: '3'}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(group.members).toStrictEqual([endpoint]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/members/add',
|
||||
stringify({data: {device: 'wall_switch_double', endpoint: 'right', group: 'group_1'}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Remove from group via MQTT', async () => {
|
||||
const device = devices.bulb_color;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
const group = groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/group/members/remove', stringify({group: 'group_1', device: 'bulb_color'}));
|
||||
await flushPromises();
|
||||
expect(group.members).toStrictEqual([]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/members/remove',
|
||||
stringify({data: {device: 'bulb_color', endpoint: 'default', group: 'group_1'}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Remove from group via MQTT keeping device reporting', async () => {
|
||||
const device = devices.bulb_color;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
const group = groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message(
|
||||
'zigbee2mqtt/bridge/request/group/members/remove',
|
||||
stringify({group: 'group_1', device: 'bulb_color', skip_disable_reporting: true}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(group.members).toStrictEqual([]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/members/remove',
|
||||
stringify({data: {device: 'bulb_color', endpoint: 'default', group: 'group_1'}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Remove from group via MQTT with postfix variant 1', async () => {
|
||||
const device = devices.QBKG03LM;
|
||||
const endpoint = device.getEndpoint(3)!;
|
||||
const group = groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message(
|
||||
'zigbee2mqtt/bridge/request/group/members/remove',
|
||||
stringify({group: 'group_1', device: '0x0017880104e45542', endpoint: '3'}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(group.members).toStrictEqual([]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/members/remove',
|
||||
stringify({data: {device: '0x0017880104e45542', endpoint: '3', group: 'group_1'}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Remove from group via MQTT with postfix variant 2', async () => {
|
||||
const device = devices.QBKG03LM;
|
||||
const endpoint = device.getEndpoint(3)!;
|
||||
const group = groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message(
|
||||
'zigbee2mqtt/bridge/request/group/members/remove',
|
||||
stringify({group: 'group_1', device: 'wall_switch_double', endpoint: '3'}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(group.members).toStrictEqual([]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/members/remove',
|
||||
stringify({data: {device: 'wall_switch_double', endpoint: '3', group: 'group_1'}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Remove from group via MQTT with postfix variant 3', async () => {
|
||||
const device = devices.QBKG03LM;
|
||||
const endpoint = device.getEndpoint(3)!;
|
||||
const group = groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message(
|
||||
'zigbee2mqtt/bridge/request/group/members/remove',
|
||||
stringify({group: 'group_1', device: '0x0017880104e45542', endpoint: 'right'}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(group.members).toStrictEqual([]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/members/remove',
|
||||
stringify({data: {device: '0x0017880104e45542', endpoint: 'right', group: 'group_1'}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Remove from group all', async () => {
|
||||
const group = groups.group_1;
|
||||
groups.group_1.members.push(devices.QBKG03LM.endpoints[2]);
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/group/members/remove_all', stringify({device: '0x0017880104e45542', endpoint: 'right'}));
|
||||
await flushPromises();
|
||||
expect(group.members).toStrictEqual([]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/members/remove_all',
|
||||
stringify({data: {device: '0x0017880104e45542', endpoint: 'right'}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Error when adding to non-existing group', async () => {
|
||||
mockLogger.error.mockClear();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/group/members/remove', stringify({group: 'group_1_not_existing', device: 'bulb_color'}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).not.toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/members/remove',
|
||||
stringify({data: {}, status: 'error', error: "Group 'group_1_not_existing' does not exist"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Error when adding a non-existing device', async () => {
|
||||
mockLogger.error.mockClear();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/group/members/add', stringify({group: 'group_1', device: 'bulb_color_not_existing'}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).not.toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/members/add',
|
||||
stringify({data: {}, status: 'error', error: "Device 'bulb_color_not_existing' does not exist"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Error when adding a non-existing endpoint', async () => {
|
||||
mockLogger.error.mockClear();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message(
|
||||
'zigbee2mqtt/bridge/request/group/members/add',
|
||||
stringify({group: 'group_1', device: 'bulb_color', endpoint: 'not_existing_endpoint'}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).not.toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/members/add',
|
||||
stringify({data: {}, status: 'error', error: "Device 'bulb_color' does not have endpoint 'not_existing_endpoint'"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Error when invalid payload', async () => {
|
||||
mockLogger.error.mockClear();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/group/members/add', stringify({group: 'group_1', devicez: 'bulb_color'}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).not.toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/members/add',
|
||||
stringify({data: {}, status: 'error', error: 'Invalid payload'}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Error when add/remove with invalid payload', async () => {
|
||||
mockLogger.error.mockClear();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/group/members/add', stringify({groupz: 'group_1', device: 'bulb_color'}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).not.toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/members/add',
|
||||
stringify({data: {}, status: 'error', error: 'Invalid payload'}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should only include relevant properties when publishing member states', async () => {
|
||||
const bulbColor = devices.bulb_color;
|
||||
const bulbColorTemp = devices.bulb;
|
||||
const group = groups.group_1;
|
||||
group.members.push(bulbColor.getEndpoint(1)!);
|
||||
group.members.push(bulbColorTemp.getEndpoint(1)!);
|
||||
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
await mockMQTTEvents.message('zigbee2mqtt/group_1/set', stringify({color_temp: 50}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(3);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color', stringify({color_mode: 'color_temp', color_temp: 50}), {
|
||||
retain: false,
|
||||
qos: 0,
|
||||
});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/group_1', stringify({color_mode: 'color_temp', color_temp: 50}), {
|
||||
retain: false,
|
||||
qos: 0,
|
||||
});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({color_mode: 'color_temp', color_temp: 50}), {
|
||||
retain: true,
|
||||
qos: 0,
|
||||
});
|
||||
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
await mockMQTTEvents.message('zigbee2mqtt/group_1/set', stringify({color: {x: 0.5, y: 0.3}}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(3);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb_color',
|
||||
stringify({color: {x: 0.5, y: 0.3}, color_mode: 'xy', color_temp: 548}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/group_1',
|
||||
stringify({color: {x: 0.5, y: 0.3}, color_mode: 'xy', color_temp: 548}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({color_mode: 'color_temp', color_temp: 548}), {
|
||||
retain: true,
|
||||
qos: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,61 +1,34 @@
|
||||
const data = require('./stub/data');
|
||||
const logger = require('./stub/logger');
|
||||
const zigbeeHerdsman = require('./stub/zigbeeHerdsman');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const {coordinator, bulb, bulb_color, WXKG02LM_rev1, CC2530_ROUTER, unsupported_router, external_converter_device} = zigbeeHerdsman.devices;
|
||||
import * as data from '../mocks/data';
|
||||
import {mockLogger} from '../mocks/logger';
|
||||
import {events as mockMQTTEvents, mockMQTTPublishAsync} from '../mocks/mqtt';
|
||||
import * as mockSleep from '../mocks/sleep';
|
||||
import {flushPromises} from '../mocks/utils';
|
||||
import {devices, events as mockZHEvents, returnDevices} from '../mocks/zigbeeHerdsman';
|
||||
|
||||
zigbeeHerdsman.returnDevices.push(coordinator.ieeeAddr);
|
||||
zigbeeHerdsman.returnDevices.push(bulb.ieeeAddr);
|
||||
zigbeeHerdsman.returnDevices.push(bulb_color.ieeeAddr);
|
||||
zigbeeHerdsman.returnDevices.push(WXKG02LM_rev1.ieeeAddr);
|
||||
zigbeeHerdsman.returnDevices.push(CC2530_ROUTER.ieeeAddr);
|
||||
zigbeeHerdsman.returnDevices.push(unsupported_router.ieeeAddr);
|
||||
zigbeeHerdsman.returnDevices.push(external_converter_device.ieeeAddr);
|
||||
const MQTT = require('./stub/mqtt');
|
||||
const settings = require('../lib/util/settings');
|
||||
const Controller = require('../lib/controller');
|
||||
const flushPromises = require('./lib/flushPromises');
|
||||
const mocksClear = [MQTT.publish, logger.warning, logger.debug];
|
||||
const setTimeoutNative = setTimeout;
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
describe('Networkmap', () => {
|
||||
let controller;
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.useFakeTimers();
|
||||
Date.now = jest.fn();
|
||||
Date.now.mockReturnValue(10000);
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
data.writeEmptyState();
|
||||
fs.copyFileSync(path.join(__dirname, 'assets', 'mock-external-converter.js'), path.join(data.mockDir, 'mock-external-converter.js'));
|
||||
settings.set(['external_converters'], ['mock-external-converter.js']);
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
await flushPromises();
|
||||
});
|
||||
import {Controller} from '../../lib/controller';
|
||||
import * as settings from '../../lib/util/settings';
|
||||
|
||||
beforeEach(async () => {
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
await flushPromises();
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
device.lastSeen = 1000;
|
||||
external_converter_device.lastSeen = 1000;
|
||||
global.setTimeout = (r) => r();
|
||||
});
|
||||
returnDevices.push(
|
||||
devices.coordinator.ieeeAddr,
|
||||
devices.bulb.ieeeAddr,
|
||||
devices.bulb_color.ieeeAddr,
|
||||
devices.WXKG02LM_rev1.ieeeAddr,
|
||||
devices.CC2530_ROUTER.ieeeAddr,
|
||||
devices.unsupported_router.ieeeAddr,
|
||||
devices.external_converter_device.ieeeAddr,
|
||||
);
|
||||
|
||||
afterEach(async () => {
|
||||
global.setTimeout = setTimeoutNative;
|
||||
});
|
||||
const mocksClear = [mockMQTTPublishAsync, mockLogger.warning, mockLogger.debug];
|
||||
|
||||
afterAll(async () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
describe('Extension: NetworkMap', () => {
|
||||
let controller: Controller;
|
||||
|
||||
function mock() {
|
||||
const mock = (): void => {
|
||||
/**
|
||||
* Topology
|
||||
* | -> external_device
|
||||
@@ -66,391 +39,100 @@ describe('Networkmap', () => {
|
||||
* | -> CC2530_ROUTER -> WXKG02LM_rev1
|
||||
*
|
||||
*/
|
||||
coordinator.lqi = jest.fn().mockResolvedValue({
|
||||
devices.coordinator.lqi.mockResolvedValueOnce({
|
||||
neighbors: [
|
||||
{ieeeAddr: bulb_color.ieeeAddr, networkAddress: bulb_color.networkAddress, relationship: 2, depth: 1, linkquality: 120},
|
||||
{ieeeAddr: bulb.ieeeAddr, networkAddress: bulb.networkAddress, relationship: 2, depth: 1, linkquality: 92},
|
||||
{
|
||||
ieeeAddr: external_converter_device.ieeeAddr,
|
||||
networkAddress: external_converter_device.networkAddress,
|
||||
ieeeAddr: devices.bulb_color.ieeeAddr,
|
||||
networkAddress: devices.bulb_color.networkAddress,
|
||||
relationship: 2,
|
||||
depth: 1,
|
||||
linkquality: 92,
|
||||
},
|
||||
],
|
||||
});
|
||||
coordinator.routingTable = jest.fn().mockResolvedValue({
|
||||
table: [{destinationAddress: CC2530_ROUTER.networkAddress, status: 'ACTIVE', nextHop: bulb.networkAddress}],
|
||||
});
|
||||
bulb.lqi = jest.fn().mockResolvedValue({
|
||||
neighbors: [
|
||||
{ieeeAddr: bulb_color.ieeeAddr, networkAddress: bulb_color.networkAddress, relationship: 1, depth: 2, linkquality: 110},
|
||||
{ieeeAddr: CC2530_ROUTER.ieeeAddr, networkAddress: CC2530_ROUTER.networkAddress, relationship: 1, depth: 2, linkquality: 100},
|
||||
],
|
||||
});
|
||||
bulb.routingTable = jest.fn().mockResolvedValue({table: []});
|
||||
bulb_color.lqi = jest.fn().mockResolvedValue({neighbors: []});
|
||||
bulb_color.routingTable = jest.fn().mockResolvedValue({table: []});
|
||||
CC2530_ROUTER.lqi = jest.fn().mockResolvedValue({
|
||||
neighbors: [
|
||||
{ieeeAddr: '0x0000000000000000', networkAddress: WXKG02LM_rev1.networkAddress, relationship: 1, depth: 2, linkquality: 130},
|
||||
{ieeeAddr: bulb_color.ieeeAddr, networkAddress: bulb_color.networkAddress, relationship: 4, depth: 2, linkquality: 130},
|
||||
],
|
||||
});
|
||||
CC2530_ROUTER.routingTable = jest.fn().mockResolvedValue({table: []});
|
||||
unsupported_router.lqi = jest.fn().mockRejectedValue(new Error('failed'));
|
||||
unsupported_router.routingTable = jest.fn().mockRejectedValue(new Error('failed'));
|
||||
}
|
||||
|
||||
it('Output raw networkmap legacy api', async () => {
|
||||
mock();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/networkmap/routes', 'raw');
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
let call = MQTT.publish.mock.calls[0];
|
||||
expect(call[0]).toStrictEqual('zigbee2mqtt/bridge/networkmap/raw');
|
||||
|
||||
const expected = {
|
||||
links: [
|
||||
{
|
||||
depth: 1,
|
||||
linkquality: 120,
|
||||
lqi: 120,
|
||||
relationship: 2,
|
||||
routes: [],
|
||||
source: {ieeeAddr: '0x000b57fffec6a5b3', networkAddress: 40399},
|
||||
sourceIeeeAddr: '0x000b57fffec6a5b3',
|
||||
sourceNwkAddr: 40399,
|
||||
target: {ieeeAddr: '0x00124b00120144ae', networkAddress: 0},
|
||||
targetIeeeAddr: '0x00124b00120144ae',
|
||||
},
|
||||
{ieeeAddr: devices.bulb.ieeeAddr, networkAddress: devices.bulb.networkAddress, relationship: 2, depth: 1, linkquality: 92},
|
||||
{
|
||||
ieeeAddr: devices.external_converter_device.ieeeAddr,
|
||||
networkAddress: devices.external_converter_device.networkAddress,
|
||||
relationship: 2,
|
||||
depth: 1,
|
||||
linkquality: 92,
|
||||
lqi: 92,
|
||||
relationship: 2,
|
||||
routes: [{destinationAddress: 6540, nextHop: 40369, status: 'ACTIVE'}],
|
||||
source: {ieeeAddr: '0x000b57fffec6a5b2', networkAddress: 40369},
|
||||
sourceIeeeAddr: '0x000b57fffec6a5b2',
|
||||
sourceNwkAddr: 40369,
|
||||
target: {ieeeAddr: '0x00124b00120144ae', networkAddress: 0},
|
||||
targetIeeeAddr: '0x00124b00120144ae',
|
||||
},
|
||||
{
|
||||
depth: 1,
|
||||
linkquality: 92,
|
||||
lqi: 92,
|
||||
relationship: 2,
|
||||
routes: [],
|
||||
source: {ieeeAddr: '0x0017880104e45511', networkAddress: 1114},
|
||||
sourceIeeeAddr: '0x0017880104e45511',
|
||||
sourceNwkAddr: 1114,
|
||||
target: {ieeeAddr: '0x00124b00120144ae', networkAddress: 0},
|
||||
targetIeeeAddr: '0x00124b00120144ae',
|
||||
},
|
||||
],
|
||||
});
|
||||
devices.coordinator.routingTable.mockResolvedValueOnce({
|
||||
table: [{destinationAddress: devices.CC2530_ROUTER.networkAddress, status: 'ACTIVE', nextHop: devices.bulb.networkAddress}],
|
||||
});
|
||||
devices.bulb.lqi.mockResolvedValueOnce({
|
||||
neighbors: [
|
||||
{
|
||||
ieeeAddr: devices.bulb_color.ieeeAddr,
|
||||
networkAddress: devices.bulb_color.networkAddress,
|
||||
relationship: 1,
|
||||
depth: 2,
|
||||
linkquality: 110,
|
||||
lqi: 110,
|
||||
relationship: 1,
|
||||
routes: [],
|
||||
source: {ieeeAddr: '0x000b57fffec6a5b3', networkAddress: 40399},
|
||||
sourceIeeeAddr: '0x000b57fffec6a5b3',
|
||||
sourceNwkAddr: 40399,
|
||||
target: {ieeeAddr: '0x000b57fffec6a5b2', networkAddress: 40369},
|
||||
targetIeeeAddr: '0x000b57fffec6a5b2',
|
||||
},
|
||||
{
|
||||
ieeeAddr: devices.CC2530_ROUTER.ieeeAddr,
|
||||
networkAddress: devices.CC2530_ROUTER.networkAddress,
|
||||
relationship: 1,
|
||||
depth: 2,
|
||||
linkquality: 100,
|
||||
lqi: 100,
|
||||
relationship: 1,
|
||||
routes: [],
|
||||
source: {ieeeAddr: '0x0017880104e45559', networkAddress: 6540},
|
||||
sourceIeeeAddr: '0x0017880104e45559',
|
||||
sourceNwkAddr: 6540,
|
||||
target: {ieeeAddr: '0x000b57fffec6a5b2', networkAddress: 40369},
|
||||
targetIeeeAddr: '0x000b57fffec6a5b2',
|
||||
},
|
||||
],
|
||||
});
|
||||
devices.CC2530_ROUTER.lqi.mockResolvedValueOnce({
|
||||
neighbors: [
|
||||
{ieeeAddr: '0x0000000000000000', networkAddress: devices.WXKG02LM_rev1.networkAddress, relationship: 1, depth: 2, linkquality: 130},
|
||||
{
|
||||
ieeeAddr: devices.bulb_color.ieeeAddr,
|
||||
networkAddress: devices.bulb_color.networkAddress,
|
||||
relationship: 4,
|
||||
depth: 2,
|
||||
linkquality: 130,
|
||||
lqi: 130,
|
||||
relationship: 1,
|
||||
routes: [],
|
||||
source: {ieeeAddr: '0x0017880104e45521', networkAddress: 6538},
|
||||
sourceIeeeAddr: '0x0017880104e45521',
|
||||
sourceNwkAddr: 6538,
|
||||
target: {ieeeAddr: '0x0017880104e45559', networkAddress: 6540},
|
||||
targetIeeeAddr: '0x0017880104e45559',
|
||||
},
|
||||
],
|
||||
nodes: [
|
||||
{
|
||||
// definition: null,
|
||||
failed: [],
|
||||
friendlyName: 'Coordinator',
|
||||
ieeeAddr: '0x00124b00120144ae',
|
||||
lastSeen: 1000,
|
||||
modelID: null,
|
||||
networkAddress: 0,
|
||||
type: 'Coordinator',
|
||||
},
|
||||
{
|
||||
definition: {
|
||||
description: 'TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm',
|
||||
model: 'LED1545G12',
|
||||
supports:
|
||||
'light (state, brightness, color_temp, color_temp_startup, level_config), effect, power_on_behavior, color_options, identify, linkquality',
|
||||
vendor: 'IKEA',
|
||||
},
|
||||
failed: [],
|
||||
friendlyName: 'bulb',
|
||||
ieeeAddr: '0x000b57fffec6a5b2',
|
||||
lastSeen: 1000,
|
||||
modelID: 'TRADFRI bulb E27 WS opal 980lm',
|
||||
networkAddress: 40369,
|
||||
type: 'Router',
|
||||
},
|
||||
{
|
||||
definition: {
|
||||
description: 'Hue Go',
|
||||
model: '7146060PH',
|
||||
supports:
|
||||
'light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), power_on_behavior, effect, linkquality',
|
||||
vendor: 'Philips',
|
||||
},
|
||||
failed: [],
|
||||
friendlyName: 'bulb_color',
|
||||
ieeeAddr: '0x000b57fffec6a5b3',
|
||||
lastSeen: 1000,
|
||||
modelID: 'LLC020',
|
||||
networkAddress: 40399,
|
||||
type: 'Router',
|
||||
},
|
||||
{
|
||||
definition: {
|
||||
description: 'Wireless remote switch (double rocker), 2016 model',
|
||||
model: 'WXKG02LM_rev1',
|
||||
supports: 'battery, voltage, power_outage_count, action, linkquality',
|
||||
vendor: 'Aqara',
|
||||
},
|
||||
friendlyName: 'button_double_key',
|
||||
ieeeAddr: '0x0017880104e45521',
|
||||
lastSeen: 1000,
|
||||
modelID: 'lumi.sensor_86sw2.es1',
|
||||
networkAddress: 6538,
|
||||
type: 'EndDevice',
|
||||
},
|
||||
{
|
||||
definition: {
|
||||
description: 'Automatically generated definition',
|
||||
model: 'notSupportedModelID',
|
||||
supports: 'action, linkquality',
|
||||
vendor: 'Boef',
|
||||
},
|
||||
failed: ['lqi', 'routingTable'],
|
||||
friendlyName: '0x0017880104e45525',
|
||||
ieeeAddr: '0x0017880104e45525',
|
||||
lastSeen: 1000,
|
||||
manufacturerName: 'Boef',
|
||||
modelID: 'notSupportedModelID',
|
||||
networkAddress: 6536,
|
||||
type: 'Router',
|
||||
},
|
||||
{
|
||||
definition: {description: 'CC2530 router', model: 'CC2530.ROUTER', supports: 'led, linkquality', vendor: 'Custom devices (DiY)'},
|
||||
failed: [],
|
||||
friendlyName: 'cc2530_router',
|
||||
ieeeAddr: '0x0017880104e45559',
|
||||
lastSeen: 1000,
|
||||
modelID: 'lumi.router',
|
||||
networkAddress: 6540,
|
||||
type: 'Router',
|
||||
},
|
||||
{
|
||||
definition: {description: 'external', model: 'external_converter_device', supports: 'linkquality', vendor: 'external'},
|
||||
friendlyName: '0x0017880104e45511',
|
||||
ieeeAddr: '0x0017880104e45511',
|
||||
lastSeen: 1000,
|
||||
modelID: 'external_converter_device',
|
||||
networkAddress: 1114,
|
||||
type: 'EndDevice',
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(JSON.parse(call[1])).toStrictEqual(expected);
|
||||
|
||||
/**
|
||||
* Check again without routes
|
||||
*/
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/networkmap', 'raw');
|
||||
await flushPromises();
|
||||
call = MQTT.publish.mock.calls[0];
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(call[0]).toStrictEqual('zigbee2mqtt/bridge/networkmap/raw');
|
||||
|
||||
// Remove routing information
|
||||
expected.nodes.forEach((n) => {
|
||||
if (n.failed && n.failed.includes('routingTable')) {
|
||||
n.failed.splice(n.failed.indexOf('routingTable'), 1);
|
||||
}
|
||||
});
|
||||
devices.unsupported_router.lqi.mockRejectedValueOnce('failed').mockRejectedValueOnce('failed');
|
||||
devices.unsupported_router.routingTable.mockRejectedValueOnce('failed').mockRejectedValueOnce('failed');
|
||||
};
|
||||
|
||||
expected.links.forEach((l) => (l.routes = []));
|
||||
expect(JSON.parse(call[1])).toStrictEqual(expected);
|
||||
beforeAll(async () => {
|
||||
vi.useFakeTimers();
|
||||
mockSleep.mock();
|
||||
vi.spyOn(Date, 'now').mockReturnValue(10000);
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
data.writeEmptyState();
|
||||
fs.mkdirSync(path.join(data.mockDir, 'external_converters'));
|
||||
fs.copyFileSync(
|
||||
path.join(__dirname, '..', 'assets', 'external_converters', 'mock-external-converter.js'),
|
||||
path.join(data.mockDir, 'external_converters', 'mock-external-converter.js'),
|
||||
);
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
await controller.start();
|
||||
});
|
||||
|
||||
it('Output graphviz networkmap legacy api', async () => {
|
||||
mock();
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
device.lastSeen = null;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
const data = {modelID: 'test'};
|
||||
const payload = {data, cluster: 'genOnOff', device, endpoint, type: 'readResponse', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/networkmap/routes', 'graphviz');
|
||||
beforeEach(async () => {
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
let call = MQTT.publish.mock.calls[0];
|
||||
expect(call[0]).toStrictEqual('zigbee2mqtt/bridge/networkmap/graphviz');
|
||||
|
||||
const expected = `digraph G {
|
||||
node[shape=record];
|
||||
"0x00124b00120144ae" [style="bold, filled", fillcolor="#e04e5d", fontcolor="#ffffff", label="{Coordinator|0x00124b00120144ae (0x0000)|0 seconds ago}"];
|
||||
"0x000b57fffec6a5b2" [style="rounded, filled", fillcolor="#4ea3e0", fontcolor="#ffffff", label="{bulb|0x000b57fffec6a5b2 (0x9db1)|IKEA TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm (LED1545G12)|9 seconds ago}"];
|
||||
"0x000b57fffec6a5b2" -> "0x00124b00120144ae" [penwidth=2, weight=1, color="#009900", label="92 (routes: 0x198c)"]
|
||||
"0x000b57fffec6a5b3" [style="rounded, filled", fillcolor="#4ea3e0", fontcolor="#ffffff", label="{bulb_color|0x000b57fffec6a5b3 (0x9dcf)|Philips Hue Go (7146060PH)|unknown}"];
|
||||
"0x000b57fffec6a5b3" -> "0x00124b00120144ae" [penwidth=0.5, weight=0, color="#994444", label="120"]
|
||||
"0x000b57fffec6a5b3" -> "0x000b57fffec6a5b2" [penwidth=0.5, weight=0, color="#994444", label="110"]
|
||||
"0x0017880104e45521" [style="rounded, dashed, filled", fillcolor="#fff8ce", fontcolor="#000000", label="{button_double_key|0x0017880104e45521 (0x198a)|Aqara Wireless remote switch (double rocker), 2016 model (WXKG02LM_rev1)|9 seconds ago}"];
|
||||
"0x0017880104e45521" -> "0x0017880104e45559" [penwidth=1, weight=0, color="#994444", label="130"]
|
||||
"0x0017880104e45525" [style="rounded, filled", fillcolor="#4ea3e0", fontcolor="#ffffff", label="{0x0017880104e45525|0x0017880104e45525 (0x1988)failed: lqi,routingTable|Boef Automatically generated definition (notSupportedModelID)|9 seconds ago}"];
|
||||
"0x0017880104e45559" [style="rounded, filled", fillcolor="#4ea3e0", fontcolor="#ffffff", label="{cc2530_router|0x0017880104e45559 (0x198c)|Custom devices (DiY) CC2530 router (CC2530.ROUTER)|9 seconds ago}"];
|
||||
"0x0017880104e45559" -> "0x000b57fffec6a5b2" [penwidth=0.5, weight=0, color="#994444", label="100"]
|
||||
"0x0017880104e45511" [style="rounded, dashed, filled", fillcolor="#fff8ce", fontcolor="#000000", label="{0x0017880104e45511|0x0017880104e45511 (0x045a)|external external (external_converter_device)|9 seconds ago}"];
|
||||
"0x0017880104e45511" -> "0x00124b00120144ae" [penwidth=1, weight=0, color="#994444", label="92"]
|
||||
}`;
|
||||
|
||||
const expectedLines = expected.split('\n');
|
||||
const actualLines = call[1].split('\n');
|
||||
|
||||
for (let i = 0; i < expectedLines.length; i++) {
|
||||
expect(actualLines[i].trim()).toStrictEqual(expectedLines[i].trim());
|
||||
}
|
||||
const device = devices.bulb_color;
|
||||
device.lastSeen = 1000;
|
||||
devices.external_converter_device.lastSeen = 1000;
|
||||
});
|
||||
|
||||
it('Output plantuml networkmap legacy api', async () => {
|
||||
mock();
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
device.lastSeen = null;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
const data = {modelID: 'test'};
|
||||
const payload = {data, cluster: 'genOnOff', device, endpoint, type: 'readResponse', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/networkmap/routes', 'plantuml');
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
let call = MQTT.publish.mock.calls[0];
|
||||
expect(call[0]).toStrictEqual('zigbee2mqtt/bridge/networkmap/plantuml');
|
||||
afterEach(async () => {});
|
||||
|
||||
const expected = `' paste into: https://www.planttext.com/
|
||||
|
||||
@startuml
|
||||
card 0x0017880104e45511 [
|
||||
0x0017880104e45511
|
||||
---
|
||||
0x0017880104e45511 (0x045a)
|
||||
---
|
||||
external external (external_converter_device)
|
||||
---
|
||||
9 seconds ago
|
||||
]
|
||||
|
||||
card 0x0017880104e45525 [
|
||||
0x0017880104e45525
|
||||
---
|
||||
0x0017880104e45525 (0x1988) failed: lqi,routingTable
|
||||
---
|
||||
Boef Automatically generated definition (notSupportedModelID)
|
||||
---
|
||||
9 seconds ago
|
||||
]
|
||||
|
||||
card 0x000b57fffec6a5b2 [
|
||||
bulb
|
||||
---
|
||||
0x000b57fffec6a5b2 (0x9db1)
|
||||
---
|
||||
IKEA TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm (LED1545G12)
|
||||
---
|
||||
9 seconds ago
|
||||
]
|
||||
|
||||
card 0x000b57fffec6a5b3 [
|
||||
bulb_color
|
||||
---
|
||||
0x000b57fffec6a5b3 (0x9dcf)
|
||||
---
|
||||
Philips Hue Go (7146060PH)
|
||||
---
|
||||
unknown
|
||||
]
|
||||
|
||||
card 0x0017880104e45521 [
|
||||
button_double_key
|
||||
---
|
||||
0x0017880104e45521 (0x198a)
|
||||
---
|
||||
Aqara Wireless remote switch (double rocker), 2016 model (WXKG02LM_rev1)
|
||||
---
|
||||
9 seconds ago
|
||||
]
|
||||
|
||||
card 0x0017880104e45559 [
|
||||
cc2530_router
|
||||
---
|
||||
0x0017880104e45559 (0x198c)
|
||||
---
|
||||
Custom devices (DiY) CC2530 router (CC2530.ROUTER)
|
||||
---
|
||||
9 seconds ago
|
||||
]
|
||||
|
||||
card 0x00124b00120144ae [
|
||||
Coordinator
|
||||
---
|
||||
0x00124b00120144ae (0x0000)
|
||||
---
|
||||
0 seconds ago
|
||||
]
|
||||
|
||||
0x000b57fffec6a5b3 --> 0x00124b00120144ae: 120
|
||||
0x000b57fffec6a5b2 --> 0x00124b00120144ae: 92
|
||||
0x0017880104e45511 --> 0x00124b00120144ae: 92
|
||||
0x000b57fffec6a5b3 --> 0x000b57fffec6a5b2: 110
|
||||
0x0017880104e45559 --> 0x000b57fffec6a5b2: 100
|
||||
0x0017880104e45521 --> 0x0017880104e45559: 130
|
||||
|
||||
@enduml`;
|
||||
|
||||
const expectedLines = expected.split('\n');
|
||||
const actualLines = call[1].split('\n');
|
||||
|
||||
for (let i = 0; i < expectedLines.length; i++) {
|
||||
expect(actualLines[i].trim()).toStrictEqual(expectedLines[i].trim());
|
||||
}
|
||||
afterAll(async () => {
|
||||
mockSleep.restore();
|
||||
fs.rmSync(path.join(data.mockDir, 'external_converters'), {recursive: true});
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('Should output raw networkmap', async () => {
|
||||
it('Output raw networkmap', async () => {
|
||||
mock();
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/networkmap', stringify({type: 'raw', routes: true}));
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/networkmap', stringify({type: 'raw', routes: true}));
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
let call = MQTT.publish.mock.calls[0];
|
||||
expect(call[0]).toStrictEqual('zigbee2mqtt/bridge/response/networkmap');
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/response/networkmap');
|
||||
|
||||
const expected = {
|
||||
data: {
|
||||
@@ -538,7 +220,7 @@ describe('Networkmap', () => {
|
||||
friendlyName: 'Coordinator',
|
||||
ieeeAddr: '0x00124b00120144ae',
|
||||
lastSeen: 1000,
|
||||
modelID: null,
|
||||
// modelID: null,
|
||||
networkAddress: 0,
|
||||
type: 'Coordinator',
|
||||
},
|
||||
@@ -633,32 +315,168 @@ describe('Networkmap', () => {
|
||||
},
|
||||
status: 'ok',
|
||||
};
|
||||
const actual = JSON.parse(call[1]);
|
||||
const actual = JSON.parse(mockMQTTPublishAsync.mock.calls[0][1]);
|
||||
expect(actual).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('Output graphviz networkmap', async () => {
|
||||
mock();
|
||||
const device = devices.bulb_color;
|
||||
device.lastSeen = undefined;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
const data = {modelID: 'test'};
|
||||
const payload = {data, cluster: 'genOnOff', device, endpoint, type: 'readResponse', linkquality: 10};
|
||||
await mockZHEvents.message(payload);
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/networkmap', stringify({type: 'graphviz', routes: true}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/response/networkmap');
|
||||
|
||||
const expected = `digraph G {
|
||||
node[shape=record];
|
||||
"0x00124b00120144ae" [style="bold, filled", fillcolor="#e04e5d", fontcolor="#ffffff", label="{Coordinator|0x00124b00120144ae (0x0000)|0 seconds ago}"];
|
||||
"0x000b57fffec6a5b2" [style="rounded, filled", fillcolor="#4ea3e0", fontcolor="#ffffff", label="{bulb|0x000b57fffec6a5b2 (0x9db1)|IKEA TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm (LED1545G12)|9 seconds ago}"];
|
||||
"0x000b57fffec6a5b2" -> "0x00124b00120144ae" [penwidth=2, weight=1, color="#009900", label="92 (routes: 0x198c)"]
|
||||
"0x000b57fffec6a5b3" [style="rounded, filled", fillcolor="#4ea3e0", fontcolor="#ffffff", label="{bulb_color|0x000b57fffec6a5b3 (0x9dcf)|Philips Hue Go (7146060PH)|unknown}"];
|
||||
"0x000b57fffec6a5b3" -> "0x00124b00120144ae" [penwidth=0.5, weight=0, color="#994444", label="120"]
|
||||
"0x000b57fffec6a5b3" -> "0x000b57fffec6a5b2" [penwidth=0.5, weight=0, color="#994444", label="110"]
|
||||
"0x0017880104e45521" [style="rounded, dashed, filled", fillcolor="#fff8ce", fontcolor="#000000", label="{button_double_key|0x0017880104e45521 (0x198a)|Aqara Wireless remote switch (double rocker), 2016 model (WXKG02LM_rev1)|9 seconds ago}"];
|
||||
"0x0017880104e45521" -> "0x0017880104e45559" [penwidth=1, weight=0, color="#994444", label="130"]
|
||||
"0x0017880104e45525" [style="rounded, filled", fillcolor="#4ea3e0", fontcolor="#ffffff", label="{0x0017880104e45525|0x0017880104e45525 (0x1988)failed: lqi,routingTable|Boef Automatically generated definition (notSupportedModelID)|9 seconds ago}"];
|
||||
"0x0017880104e45559" [style="rounded, filled", fillcolor="#4ea3e0", fontcolor="#ffffff", label="{cc2530_router|0x0017880104e45559 (0x198c)|Custom devices (DiY) CC2530 router (CC2530.ROUTER)|9 seconds ago}"];
|
||||
"0x0017880104e45559" -> "0x000b57fffec6a5b2" [penwidth=0.5, weight=0, color="#994444", label="100"]
|
||||
"0x0017880104e45511" [style="rounded, dashed, filled", fillcolor="#fff8ce", fontcolor="#000000", label="{0x0017880104e45511|0x0017880104e45511 (0x045a)|external external (external_converter_device)|9 seconds ago}"];
|
||||
"0x0017880104e45511" -> "0x00124b00120144ae" [penwidth=1, weight=0, color="#994444", label="92"]
|
||||
}`;
|
||||
|
||||
const expectedLines = expected.split('\n');
|
||||
const actualLines = JSON.parse(mockMQTTPublishAsync.mock.calls[0][1]).data.value.split('\n');
|
||||
|
||||
for (let i = 0; i < expectedLines.length; i++) {
|
||||
expect(actualLines[i].trim()).toStrictEqual(expectedLines[i].trim());
|
||||
}
|
||||
});
|
||||
|
||||
it('Output plantuml networkmap', async () => {
|
||||
mock();
|
||||
const device = devices.bulb_color;
|
||||
device.lastSeen = undefined;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
const data = {modelID: 'test'};
|
||||
const payload = {data, cluster: 'genOnOff', device, endpoint, type: 'readResponse', linkquality: 10};
|
||||
await mockZHEvents.message(payload);
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/networkmap', stringify({type: 'plantuml', routes: true}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/response/networkmap');
|
||||
|
||||
const expected = `' paste into: https://www.planttext.com/
|
||||
|
||||
@startuml
|
||||
card 0x0017880104e45511 [
|
||||
0x0017880104e45511
|
||||
---
|
||||
0x0017880104e45511 (0x045a)
|
||||
---
|
||||
external external (external_converter_device)
|
||||
---
|
||||
9 seconds ago
|
||||
]
|
||||
|
||||
card 0x0017880104e45525 [
|
||||
0x0017880104e45525
|
||||
---
|
||||
0x0017880104e45525 (0x1988) failed: lqi,routingTable
|
||||
---
|
||||
Boef Automatically generated definition (notSupportedModelID)
|
||||
---
|
||||
9 seconds ago
|
||||
]
|
||||
|
||||
card 0x000b57fffec6a5b2 [
|
||||
bulb
|
||||
---
|
||||
0x000b57fffec6a5b2 (0x9db1)
|
||||
---
|
||||
IKEA TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm (LED1545G12)
|
||||
---
|
||||
9 seconds ago
|
||||
]
|
||||
|
||||
card 0x000b57fffec6a5b3 [
|
||||
bulb_color
|
||||
---
|
||||
0x000b57fffec6a5b3 (0x9dcf)
|
||||
---
|
||||
Philips Hue Go (7146060PH)
|
||||
---
|
||||
unknown
|
||||
]
|
||||
|
||||
card 0x0017880104e45521 [
|
||||
button_double_key
|
||||
---
|
||||
0x0017880104e45521 (0x198a)
|
||||
---
|
||||
Aqara Wireless remote switch (double rocker), 2016 model (WXKG02LM_rev1)
|
||||
---
|
||||
9 seconds ago
|
||||
]
|
||||
|
||||
card 0x0017880104e45559 [
|
||||
cc2530_router
|
||||
---
|
||||
0x0017880104e45559 (0x198c)
|
||||
---
|
||||
Custom devices (DiY) CC2530 router (CC2530.ROUTER)
|
||||
---
|
||||
9 seconds ago
|
||||
]
|
||||
|
||||
card 0x00124b00120144ae [
|
||||
Coordinator
|
||||
---
|
||||
0x00124b00120144ae (0x0000)
|
||||
---
|
||||
0 seconds ago
|
||||
]
|
||||
|
||||
0x000b57fffec6a5b3 --> 0x00124b00120144ae: 120
|
||||
0x000b57fffec6a5b2 --> 0x00124b00120144ae: 92
|
||||
0x0017880104e45511 --> 0x00124b00120144ae: 92
|
||||
0x000b57fffec6a5b3 --> 0x000b57fffec6a5b2: 110
|
||||
0x0017880104e45559 --> 0x000b57fffec6a5b2: 100
|
||||
0x0017880104e45521 --> 0x0017880104e45559: 130
|
||||
|
||||
@enduml`;
|
||||
|
||||
const expectedLines = expected.split('\n');
|
||||
const actualLines = JSON.parse(mockMQTTPublishAsync.mock.calls[0][1]).data.value.split('\n');
|
||||
|
||||
for (let i = 0; i < expectedLines.length; i++) {
|
||||
expect(actualLines[i].trim()).toStrictEqual(expectedLines[i].trim());
|
||||
}
|
||||
});
|
||||
|
||||
it('Should throw error when requesting invalid type', async () => {
|
||||
mock();
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/networkmap', 'not_existing');
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/networkmap', 'not_existing');
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/networkmap',
|
||||
stringify({data: {}, status: 'error', error: "Type 'not_existing' not supported, allowed are: raw,graphviz,plantuml"}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should exclude disabled devices from networkmap', async () => {
|
||||
settings.set(['devices', '0x000b57fffec6a5b2', 'disabled'], true);
|
||||
mock();
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/networkmap', stringify({type: 'raw', routes: true}));
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/networkmap', stringify({type: 'raw', routes: true}));
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
let call = MQTT.publish.mock.calls[0];
|
||||
expect(call[0]).toStrictEqual('zigbee2mqtt/bridge/response/networkmap');
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/response/networkmap');
|
||||
|
||||
const expected = {
|
||||
data: {
|
||||
@@ -717,12 +535,10 @@ describe('Networkmap', () => {
|
||||
],
|
||||
nodes: [
|
||||
{
|
||||
// definition: null,
|
||||
failed: [],
|
||||
friendlyName: 'Coordinator',
|
||||
ieeeAddr: '0x00124b00120144ae',
|
||||
lastSeen: 1000,
|
||||
modelID: null,
|
||||
networkAddress: 0,
|
||||
type: 'Coordinator',
|
||||
},
|
||||
@@ -801,20 +617,18 @@ describe('Networkmap', () => {
|
||||
},
|
||||
status: 'ok',
|
||||
};
|
||||
const actual = JSON.parse(call[1]);
|
||||
const actual = JSON.parse(mockMQTTPublishAsync.mock.calls[0][1]);
|
||||
expect(actual).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('Handles retrying request when first attempt fails', async () => {
|
||||
settings.set(['devices', '0x000b57fffec6a5b2', 'disabled'], true);
|
||||
mock();
|
||||
bulb.lqi.mockRejectedValueOnce(new Error('failed'));
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/networkmap', stringify({type: 'raw', routes: true}));
|
||||
devices.bulb.lqi.mockRejectedValueOnce('failed');
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/networkmap', stringify({type: 'raw', routes: true}));
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
let call = MQTT.publish.mock.calls[0];
|
||||
expect(call[0]).toStrictEqual('zigbee2mqtt/bridge/response/networkmap');
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/response/networkmap');
|
||||
|
||||
const expected = {
|
||||
data: {
|
||||
@@ -873,12 +687,10 @@ describe('Networkmap', () => {
|
||||
],
|
||||
nodes: [
|
||||
{
|
||||
// definition: null,
|
||||
failed: [],
|
||||
friendlyName: 'Coordinator',
|
||||
ieeeAddr: '0x00124b00120144ae',
|
||||
lastSeen: 1000,
|
||||
modelID: null,
|
||||
networkAddress: 0,
|
||||
type: 'Coordinator',
|
||||
},
|
||||
@@ -957,7 +769,7 @@ describe('Networkmap', () => {
|
||||
},
|
||||
status: 'ok',
|
||||
};
|
||||
const actual = JSON.parse(call[1]);
|
||||
const actual = JSON.parse(mockMQTTPublishAsync.mock.calls[0][1]);
|
||||
expect(actual).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import * as data from '../mocks/data';
|
||||
import {mockLogger} from '../mocks/logger';
|
||||
import {mockMQTTPublishAsync} from '../mocks/mqtt';
|
||||
import {flushPromises} from '../mocks/utils';
|
||||
import {devices, events as mockZHEvents} from '../mocks/zigbeeHerdsman';
|
||||
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
|
||||
import {Controller} from '../../lib/controller';
|
||||
import * as settings from '../../lib/util/settings';
|
||||
|
||||
const mockOnEvent = vi.fn();
|
||||
const mockLivoloOnEvent = vi.fn();
|
||||
// @ts-expect-error mock
|
||||
zhc.onEvent = mockOnEvent;
|
||||
|
||||
const mocksClear = [mockMQTTPublishAsync, mockLogger.warning, mockLogger.debug];
|
||||
|
||||
describe('Extension: OnEvent', () => {
|
||||
let controller: Controller;
|
||||
|
||||
beforeAll(async () => {
|
||||
const mappedLivolo = (await zhc.findByDevice(devices.LIVOLO))!;
|
||||
mappedLivolo.onEvent = mockLivoloOnEvent;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// @ts-expect-error private
|
||||
controller.state.state = {};
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
mockOnEvent.mockClear();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('Should call with start event', async () => {
|
||||
expect(mockLivoloOnEvent).toHaveBeenCalledTimes(1);
|
||||
const call = mockLivoloOnEvent.mock.calls[0];
|
||||
expect(call[0]).toBe('start');
|
||||
expect(call[1]).toStrictEqual({});
|
||||
expect(call[2]).toBe(devices.LIVOLO);
|
||||
expect(call[3]).toStrictEqual(settings.getDevice(devices.LIVOLO.ieeeAddr));
|
||||
expect(call[4]).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('Should call with stop event', async () => {
|
||||
mockLivoloOnEvent.mockClear();
|
||||
await controller.stop();
|
||||
await flushPromises();
|
||||
expect(mockLivoloOnEvent).toHaveBeenCalledTimes(1);
|
||||
const call = mockLivoloOnEvent.mock.calls[0];
|
||||
expect(call[0]).toBe('stop');
|
||||
expect(call[1]).toStrictEqual({});
|
||||
expect(call[2]).toBe(devices.LIVOLO);
|
||||
});
|
||||
|
||||
it('Should call with zigbee event', async () => {
|
||||
mockLivoloOnEvent.mockClear();
|
||||
await mockZHEvents.deviceAnnounce({device: devices.LIVOLO});
|
||||
await flushPromises();
|
||||
expect(mockLivoloOnEvent).toHaveBeenCalledTimes(1);
|
||||
expect(mockLivoloOnEvent).toHaveBeenCalledWith(
|
||||
'deviceAnnounce',
|
||||
{device: devices.LIVOLO},
|
||||
devices.LIVOLO,
|
||||
settings.getDevice(devices.LIVOLO.ieeeAddr),
|
||||
{},
|
||||
{
|
||||
deviceExposesChanged: expect.any(Function),
|
||||
},
|
||||
);
|
||||
|
||||
// Test deviceExposesChanged
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
console.log(mockLivoloOnEvent.mock.calls[0][5].deviceExposesChanged());
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/devices');
|
||||
});
|
||||
|
||||
it('Should call index onEvent with zigbee event', async () => {
|
||||
mockOnEvent.mockClear();
|
||||
await mockZHEvents.deviceAnnounce({device: devices.LIVOLO});
|
||||
await flushPromises();
|
||||
expect(mockOnEvent).toHaveBeenCalledTimes(1);
|
||||
expect(zhc.onEvent).toHaveBeenCalledWith('deviceAnnounce', {device: devices.LIVOLO}, devices.LIVOLO, {
|
||||
deviceExposesChanged: expect.any(Function),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,465 @@
|
||||
import * as data from '../mocks/data';
|
||||
import {mockLogger} from '../mocks/logger';
|
||||
import {events as mockMQTTEvents, mockMQTTPublishAsync} from '../mocks/mqtt';
|
||||
import * as mockSleep from '../mocks/sleep';
|
||||
import {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 OTAUpdate from 'lib/extension/otaUpdate';
|
||||
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
|
||||
import {Controller} from '../../lib/controller';
|
||||
import * as settings from '../../lib/util/settings';
|
||||
|
||||
const mocksClear = [mockMQTTPublishAsync, devices.bulb.save, mockLogger.info];
|
||||
|
||||
const DEFAULT_CONFIG: zhc.Ota.Settings = {
|
||||
dataDir: data.mockDir,
|
||||
imageBlockResponseDelay: 250,
|
||||
defaultMaximumDataSize: 50,
|
||||
};
|
||||
|
||||
describe('Extension: OTAUpdate', () => {
|
||||
let controller: Controller;
|
||||
const updateSpy = vi.spyOn(zhc.ota, 'update');
|
||||
const isUpdateAvailableSpy = vi.spyOn(zhc.ota, 'isUpdateAvailable');
|
||||
|
||||
const resetExtension = async (): Promise<void> => {
|
||||
await controller.enableDisableExtension(false, 'OTAUpdate');
|
||||
await controller.enableDisableExtension(true, 'OTAUpdate');
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.useFakeTimers();
|
||||
mockSleep.mock();
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
settings.reRead();
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
mockSleep.restore();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
zhc.ota.setConfiguration(DEFAULT_CONFIG);
|
||||
// @ts-expect-error private
|
||||
const extension: OTAUpdate = controller.extensions.find((e) => e.constructor.name === 'OTAUpdate');
|
||||
// @ts-expect-error private
|
||||
extension.lastChecked = {};
|
||||
// @ts-expect-error private
|
||||
extension.inProgress = new Set();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
devices.bulb.mockClear();
|
||||
updateSpy.mockClear();
|
||||
isUpdateAvailableSpy.mockClear();
|
||||
// @ts-expect-error private
|
||||
controller.state.state = {};
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
settings.set(['ota', 'disable_automatic_update_check'], false);
|
||||
});
|
||||
|
||||
it.each(['update', 'update/downgrade'])('Should OTA update a device with topic %s', async (type) => {
|
||||
const downgrade = type === 'update/downgrade';
|
||||
let count = 10;
|
||||
devices.bulb.endpoints[0].read.mockImplementation(() => {
|
||||
if (downgrade) {
|
||||
count--;
|
||||
} else {
|
||||
count++;
|
||||
}
|
||||
|
||||
return {swBuildId: count, dateCode: `201901${count}`};
|
||||
});
|
||||
updateSpy.mockImplementationOnce(async (device, extraMetas, previous, onProgress) => {
|
||||
expect(previous).toStrictEqual(downgrade);
|
||||
|
||||
onProgress(0, undefined);
|
||||
onProgress(10, 3600.2123);
|
||||
return 90;
|
||||
});
|
||||
|
||||
mockMQTTEvents.message(`zigbee2mqtt/bridge/request/device/ota_update/${type}`, 'bulb');
|
||||
await flushPromises();
|
||||
const fromSwBuildId = 10 + (downgrade ? -1 : +1);
|
||||
const toSwBuildId = 10 + (downgrade ? -2 : +2);
|
||||
const fromDateCode = `201901${fromSwBuildId}`;
|
||||
const toDateCode = `201901${toSwBuildId}`;
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(`Updating 'bulb' to ${downgrade ? 'previous' : 'latest'} firmware`);
|
||||
expect(isUpdateAvailableSpy).toHaveBeenCalledTimes(0);
|
||||
expect(updateSpy).toHaveBeenCalledTimes(1);
|
||||
expect(updateSpy).toHaveBeenCalledWith(devices.bulb, {}, downgrade, expect.any(Function));
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(`Update of 'bulb' at 0.00%`);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(`Update of 'bulb' at 10.00%, ≈ 60 minutes remaining`);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(`Finished update of 'bulb'`);
|
||||
// note this is a lambda for `info`, so go down to `log` call to get actual message
|
||||
expect(mockLogger.log).toHaveBeenCalledWith(
|
||||
'info',
|
||||
`Device 'bulb' was updated from '{"dateCode":"${fromDateCode}","softwareBuildID":${fromSwBuildId}}' to '{"dateCode":"${toDateCode}","softwareBuildID":${toSwBuildId}}'`,
|
||||
'z2m',
|
||||
);
|
||||
expect(devices.bulb.save).toHaveBeenCalledTimes(1);
|
||||
expect(devices.bulb.endpoints[0].read).toHaveBeenCalledWith('genBasic', ['dateCode', 'swBuildId'], {sendPolicy: 'immediate'});
|
||||
expect(devices.bulb.endpoints[0].read).toHaveBeenCalledWith('genBasic', ['dateCode', 'swBuildId'], {sendPolicy: undefined});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({update: {state: 'updating', progress: 0}}), {
|
||||
retain: true,
|
||||
qos: 0,
|
||||
});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb',
|
||||
stringify({update: {state: 'updating', progress: 10, remaining: 3600}}),
|
||||
{retain: true, qos: 0},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb',
|
||||
stringify({update: {state: 'idle', installed_version: 90, latest_version: 90}}),
|
||||
{retain: true, qos: 0},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/ota_update/update',
|
||||
stringify({
|
||||
data: {
|
||||
from: {date_code: fromDateCode, software_build_id: fromSwBuildId},
|
||||
id: 'bulb',
|
||||
to: {date_code: toDateCode, software_build_id: toSwBuildId},
|
||||
},
|
||||
status: 'ok',
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), {retain: true, qos: 0});
|
||||
});
|
||||
|
||||
it('Should handle when OTA update fails', async () => {
|
||||
devices.bulb.endpoints[0].read.mockImplementation(() => {
|
||||
return {swBuildId: 1, dateCode: '2019010'};
|
||||
});
|
||||
devices.bulb.save.mockClear();
|
||||
updateSpy.mockRejectedValueOnce(new Error('Update failed'));
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/ota_update/update', stringify({id: 'bulb'}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({update: {state: 'available'}}), {retain: true, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/ota_update/update',
|
||||
stringify({data: {}, status: 'error', error: "Update of 'bulb' failed (Update failed)"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should be able to check if OTA update is available', async () => {
|
||||
isUpdateAvailableSpy.mockResolvedValueOnce({available: false, currentFileVersion: 10, otaFileVersion: 10});
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/ota_update/check', 'bulb');
|
||||
await flushPromises();
|
||||
expect(isUpdateAvailableSpy).toHaveBeenCalledTimes(1);
|
||||
expect(isUpdateAvailableSpy).toHaveBeenNthCalledWith(1, devices.bulb, {}, undefined, false);
|
||||
expect(updateSpy).toHaveBeenCalledTimes(0);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/ota_update/check',
|
||||
stringify({data: {id: 'bulb', update_available: false}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
isUpdateAvailableSpy.mockResolvedValueOnce({available: true, currentFileVersion: 10, otaFileVersion: 12});
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/ota_update/check', 'bulb');
|
||||
await flushPromises();
|
||||
expect(isUpdateAvailableSpy).toHaveBeenCalledTimes(2);
|
||||
expect(isUpdateAvailableSpy).toHaveBeenNthCalledWith(2, devices.bulb, {}, undefined, false);
|
||||
expect(updateSpy).toHaveBeenCalledTimes(0);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/ota_update/check',
|
||||
stringify({data: {id: 'bulb', update_available: true}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
isUpdateAvailableSpy.mockResolvedValueOnce({available: false, currentFileVersion: 10, otaFileVersion: 10});
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/ota_update/check/downgrade', 'bulb');
|
||||
await flushPromises();
|
||||
expect(isUpdateAvailableSpy).toHaveBeenCalledTimes(3);
|
||||
expect(isUpdateAvailableSpy).toHaveBeenNthCalledWith(3, devices.bulb, {}, undefined, true);
|
||||
expect(updateSpy).toHaveBeenCalledTimes(0);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/ota_update/check',
|
||||
stringify({data: {id: 'bulb', update_available: false}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
|
||||
// @ts-expect-error private
|
||||
const device = controller.zigbee.resolveDevice(devices.bulb.ieeeAddr)!;
|
||||
const originalDefinition = device.definition;
|
||||
device.definition = Object.assign({}, originalDefinition, {ota: {suppressElementImageParseFailure: true}});
|
||||
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
isUpdateAvailableSpy.mockResolvedValueOnce({available: true, currentFileVersion: 10, otaFileVersion: 12});
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/ota_update/check/downgrade', 'bulb');
|
||||
await flushPromises();
|
||||
expect(isUpdateAvailableSpy).toHaveBeenCalledTimes(4);
|
||||
expect(isUpdateAvailableSpy).toHaveBeenNthCalledWith(4, devices.bulb, {suppressElementImageParseFailure: true}, undefined, true);
|
||||
expect(updateSpy).toHaveBeenCalledTimes(0);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/ota_update/check',
|
||||
stringify({data: {id: 'bulb', update_available: true}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
|
||||
device.definition = originalDefinition;
|
||||
});
|
||||
|
||||
it('Should handle if OTA update check fails', async () => {
|
||||
isUpdateAvailableSpy.mockRejectedValueOnce(new Error('RF signals disturbed because of dogs barking'));
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/ota_update/check', 'bulb');
|
||||
await flushPromises();
|
||||
expect(isUpdateAvailableSpy).toHaveBeenCalledTimes(1);
|
||||
expect(updateSpy).toHaveBeenCalledTimes(0);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/ota_update/check',
|
||||
stringify({
|
||||
data: {},
|
||||
status: 'error',
|
||||
error: `Failed to check if update available for 'bulb' (RF signals disturbed because of dogs barking)`,
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should fail when device does not exist', async () => {
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/ota_update/check', 'not_existing_deviceooo');
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/ota_update/check',
|
||||
stringify({data: {}, status: 'error', error: `Device 'not_existing_deviceooo' does not exist`}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should not check for OTA when device does not support it', async () => {
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/ota_update/check', 'dimmer_wall_switch');
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/ota_update/check',
|
||||
stringify({data: {}, status: 'error', error: `Device 'dimmer_wall_switch' does not support OTA updates`}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should refuse to check/update when already in progress', async () => {
|
||||
isUpdateAvailableSpy.mockImplementationOnce(
|
||||
// @ts-expect-error mocked as needed
|
||||
async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => resolve(), 99999);
|
||||
});
|
||||
},
|
||||
);
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/ota_update/check', 'bulb');
|
||||
await flushPromises();
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/ota_update/check', 'bulb');
|
||||
await flushPromises();
|
||||
expect(isUpdateAvailableSpy).toHaveBeenCalledTimes(1);
|
||||
vi.runOnlyPendingTimers();
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/ota_update/check',
|
||||
stringify({data: {}, status: 'error', error: `Update or check for update already in progress for 'bulb'`}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Shouldnt crash when read modelID before/after OTA update fails', async () => {
|
||||
devices.bulb.endpoints[0].read.mockRejectedValueOnce('Failed from').mockRejectedValueOnce('Failed to');
|
||||
updateSpy.mockImplementationOnce(vi.fn());
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/ota_update/update', 'bulb');
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/ota_update/update',
|
||||
stringify({data: {id: 'bulb', from: undefined, to: undefined}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should check for update when device requests it', async () => {
|
||||
const data = {imageType: 12382};
|
||||
isUpdateAvailableSpy.mockResolvedValueOnce({available: true, currentFileVersion: 10, otaFileVersion: 12});
|
||||
const payload = {
|
||||
data,
|
||||
cluster: 'genOta',
|
||||
device: devices.bulb,
|
||||
endpoint: devices.bulb.getEndpoint(1)!,
|
||||
type: 'commandQueryNextImageRequest',
|
||||
linkquality: 10,
|
||||
meta: {zclTransactionSequenceNumber: 10},
|
||||
};
|
||||
mockLogger.info.mockClear();
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(isUpdateAvailableSpy).toHaveBeenCalledTimes(1);
|
||||
expect(isUpdateAvailableSpy).toHaveBeenCalledWith(devices.bulb, {}, {imageType: 12382}, false);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(`Update available for 'bulb'`);
|
||||
expect(devices.bulb.endpoints[0].commandResponse).toHaveBeenCalledTimes(1);
|
||||
expect(devices.bulb.endpoints[0].commandResponse).toHaveBeenCalledWith('genOta', 'queryNextImageResponse', {status: 0x98}, undefined, 10);
|
||||
|
||||
// Should not request again when device asks again after a short time
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(isUpdateAvailableSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
mockLogger.info.mockClear();
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(mockLogger.info).not.toHaveBeenCalledWith(`Update available for 'bulb'`);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb',
|
||||
stringify({update: {state: 'available', installed_version: 10, latest_version: 12}}),
|
||||
{retain: true, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should respond with NO_IMAGE_AVAILABLE when update available request fails', async () => {
|
||||
const data = {imageType: 12382};
|
||||
isUpdateAvailableSpy.mockRejectedValueOnce('Nothing to find here');
|
||||
const payload = {
|
||||
data,
|
||||
cluster: 'genOta',
|
||||
device: devices.bulb,
|
||||
endpoint: devices.bulb.getEndpoint(1)!,
|
||||
type: 'commandQueryNextImageRequest',
|
||||
linkquality: 10,
|
||||
meta: {zclTransactionSequenceNumber: 10},
|
||||
};
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(isUpdateAvailableSpy).toHaveBeenCalledTimes(1);
|
||||
expect(isUpdateAvailableSpy).toHaveBeenCalledWith(devices.bulb, {}, {imageType: 12382}, false);
|
||||
expect(devices.bulb.endpoints[0].commandResponse).toHaveBeenCalledTimes(1);
|
||||
expect(devices.bulb.endpoints[0].commandResponse).toHaveBeenCalledWith('genOta', 'queryNextImageResponse', {status: 0x98}, undefined, 10);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({update: {state: 'idle'}}), {retain: true, qos: 0});
|
||||
});
|
||||
|
||||
it('Should check for update when device requests it and it is not available', async () => {
|
||||
const data = {imageType: 12382};
|
||||
isUpdateAvailableSpy.mockResolvedValueOnce({available: false, currentFileVersion: 13, otaFileVersion: 13});
|
||||
const payload = {
|
||||
data,
|
||||
cluster: 'genOta',
|
||||
device: devices.bulb,
|
||||
endpoint: devices.bulb.getEndpoint(1)!,
|
||||
type: 'commandQueryNextImageRequest',
|
||||
linkquality: 10,
|
||||
meta: {zclTransactionSequenceNumber: 10},
|
||||
};
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(isUpdateAvailableSpy).toHaveBeenCalledTimes(1);
|
||||
expect(isUpdateAvailableSpy).toHaveBeenCalledWith(devices.bulb, {}, {imageType: 12382}, false);
|
||||
expect(devices.bulb.endpoints[0].commandResponse).toHaveBeenCalledTimes(1);
|
||||
expect(devices.bulb.endpoints[0].commandResponse).toHaveBeenCalledWith('genOta', 'queryNextImageResponse', {status: 0x98}, undefined, 10);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb',
|
||||
stringify({update: {state: 'idle', installed_version: 13, latest_version: 13}}),
|
||||
{retain: true, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should not check for update when device requests it and disable_automatic_update_check is set to true', async () => {
|
||||
settings.set(['ota', 'disable_automatic_update_check'], true);
|
||||
const data = {imageType: 12382};
|
||||
isUpdateAvailableSpy.mockResolvedValueOnce({available: true, currentFileVersion: 10, otaFileVersion: 13});
|
||||
const payload = {
|
||||
data,
|
||||
cluster: 'genOta',
|
||||
device: devices.bulb,
|
||||
endpoint: devices.bulb.getEndpoint(1)!,
|
||||
type: 'commandQueryNextImageRequest',
|
||||
linkquality: 10,
|
||||
meta: {zclTransactionSequenceNumber: 10},
|
||||
};
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(isUpdateAvailableSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should respond with NO_IMAGE_AVAILABLE when not supporting OTA', async () => {
|
||||
const device = devices.HGZB04D;
|
||||
const data = {imageType: 12382};
|
||||
const payload = {
|
||||
data,
|
||||
cluster: 'genOta',
|
||||
device,
|
||||
endpoint: device.getEndpoint(1)!,
|
||||
type: 'commandQueryNextImageRequest',
|
||||
linkquality: 10,
|
||||
meta: {zclTransactionSequenceNumber: 10},
|
||||
};
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(device.endpoints[0].commandResponse).toHaveBeenCalledTimes(1);
|
||||
expect(device.endpoints[0].commandResponse).toHaveBeenCalledWith('genOta', 'queryNextImageResponse', {status: 152}, undefined, 10);
|
||||
});
|
||||
|
||||
it('Should respond with NO_IMAGE_AVAILABLE when not supporting OTA and device has no OTA endpoint to standard endpoint', async () => {
|
||||
const device = devices.SV01;
|
||||
const data = {imageType: 12382};
|
||||
const payload = {
|
||||
data,
|
||||
cluster: 'genOta',
|
||||
device,
|
||||
endpoint: device.getEndpoint(1)!,
|
||||
type: 'commandQueryNextImageRequest',
|
||||
linkquality: 10,
|
||||
meta: {zclTransactionSequenceNumber: 10},
|
||||
};
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(device.endpoints[0].commandResponse).toHaveBeenCalledTimes(1);
|
||||
expect(device.endpoints[0].commandResponse).toHaveBeenCalledWith('genOta', 'queryNextImageResponse', {status: 152}, undefined, 10);
|
||||
});
|
||||
|
||||
it('Sets given configuration', async () => {
|
||||
const setConfiguration = vi.spyOn(zhc.ota, 'setConfiguration');
|
||||
settings.set(['ota', 'zigbee_ota_override_index_location'], 'local.index.json');
|
||||
settings.set(['ota', 'image_block_response_delay'], 10000);
|
||||
settings.set(['ota', 'default_maximum_data_size'], 10);
|
||||
await resetExtension();
|
||||
expect(setConfiguration).toHaveBeenCalledWith({
|
||||
...DEFAULT_CONFIG,
|
||||
overrideIndexLocation: path.join(data.mockDir, 'local.index.json'),
|
||||
imageBlockResponseDelay: 10000,
|
||||
defaultMaximumDataSize: 10,
|
||||
});
|
||||
setConfiguration.mockClear();
|
||||
|
||||
settings.set(['ota', 'zigbee_ota_override_index_location'], 'http://my.site/index.json');
|
||||
settings.set(['ota', 'image_block_response_delay'], 50);
|
||||
settings.set(['ota', 'default_maximum_data_size'], 100);
|
||||
await resetExtension();
|
||||
expect(setConfiguration).toHaveBeenCalledWith({
|
||||
...DEFAULT_CONFIG,
|
||||
overrideIndexLocation: 'http://my.site/index.json',
|
||||
imageBlockResponseDelay: 50,
|
||||
defaultMaximumDataSize: 100,
|
||||
});
|
||||
setConfiguration.mockClear();
|
||||
});
|
||||
|
||||
it('Clear update state on startup', async () => {
|
||||
// @ts-expect-error private
|
||||
const device = controller.zigbee.resolveEntity(devices.bulb_color.ieeeAddr);
|
||||
// @ts-expect-error private
|
||||
controller.state.set(device, {update: {progress: 100, remaining: 10, state: 'updating'}});
|
||||
await resetExtension();
|
||||
// @ts-expect-error private
|
||||
expect(controller.state.get(device)).toStrictEqual({update: {state: 'available'}});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Executable → Regular
+247
-263
@@ -1,82 +1,82 @@
|
||||
const data = require('./stub/data');
|
||||
const sleep = require('./stub/sleep');
|
||||
const logger = require('./stub/logger');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
const zigbeeHerdsman = require('./stub/zigbeeHerdsman');
|
||||
const MQTT = require('./stub/mqtt');
|
||||
const settings = require('../lib/util/settings');
|
||||
const Controller = require('../lib/controller');
|
||||
const flushPromises = require('./lib/flushPromises');
|
||||
import * as data from '../mocks/data';
|
||||
import {mockLogger} from '../mocks/logger';
|
||||
import {events as mockMQTTEvents, mockMQTTPublishAsync} from '../mocks/mqtt';
|
||||
import * as mockSleep from '../mocks/sleep';
|
||||
import {flushPromises} from '../mocks/utils';
|
||||
import {devices, events as mockZHEvents} from '../mocks/zigbeeHerdsman';
|
||||
|
||||
const mocksClear = [MQTT.publish, logger.warning, logger.debug];
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
describe('Receive', () => {
|
||||
let controller;
|
||||
import {Controller} from '../../lib/controller';
|
||||
import * as settings from '../../lib/util/settings';
|
||||
|
||||
const mocksClear = [mockMQTTPublishAsync, mockLogger.warning, mockLogger.debug];
|
||||
|
||||
describe('Extension: Receive', () => {
|
||||
let controller: Controller;
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.useFakeTimers();
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
sleep.mock();
|
||||
vi.useFakeTimers();
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
mockSleep.mock();
|
||||
await controller.start();
|
||||
await jest.runOnlyPendingTimers();
|
||||
await flushPromises();
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// @ts-expect-error private
|
||||
controller.state.state = {};
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
delete zigbeeHerdsman.devices.WXKG11LM.linkquality;
|
||||
delete devices.WXKG11LM.linkquality;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
jest.useRealTimers();
|
||||
sleep.restore();
|
||||
vi.useRealTimers();
|
||||
mockSleep.restore();
|
||||
});
|
||||
|
||||
it('Should handle a zigbee message', async () => {
|
||||
const device = zigbeeHerdsman.devices.WXKG11LM;
|
||||
const device = devices.WXKG11LM;
|
||||
device.linkquality = 10;
|
||||
const data = {onOff: 1};
|
||||
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/button',
|
||||
stringify({action: 'single', click: 'single', linkquality: 10}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/button', stringify({action: 'single', linkquality: 10}), {
|
||||
retain: false,
|
||||
qos: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('Should handle a zigbee message which uses ep (left)', async () => {
|
||||
const device = zigbeeHerdsman.devices.WXKG02LM_rev1;
|
||||
const device = devices.WXKG02LM_rev1;
|
||||
const data = {onOff: 1};
|
||||
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button_double_key');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({click: 'left', action: 'single_left'});
|
||||
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button_double_key');
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({action: 'single_left'});
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
|
||||
});
|
||||
|
||||
it('Should handle a zigbee message which uses ep (right)', async () => {
|
||||
const device = zigbeeHerdsman.devices.WXKG02LM_rev1;
|
||||
const device = devices.WXKG02LM_rev1;
|
||||
const data = {onOff: 1};
|
||||
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(2), type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button_double_key');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({click: 'right', action: 'single_right'});
|
||||
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button_double_key');
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({action: 'single_right'});
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
|
||||
});
|
||||
|
||||
it('Should handle a zigbee message with default precision', async () => {
|
||||
const device = zigbeeHerdsman.devices.WSDCGQ11LM;
|
||||
const device = devices.WSDCGQ11LM;
|
||||
const data = {measuredValue: -85};
|
||||
const payload = {
|
||||
data,
|
||||
@@ -86,19 +86,19 @@ describe('Receive', () => {
|
||||
type: 'attributeReport',
|
||||
linkquality: 10,
|
||||
};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({temperature: -0.85});
|
||||
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 1, retain: false});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({temperature: -0.85});
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 1, retain: false});
|
||||
});
|
||||
|
||||
it('Should allow to invert cover', async () => {
|
||||
const device = zigbeeHerdsman.devices.J1;
|
||||
const device = devices.J1_cover;
|
||||
|
||||
// Non-inverted (open = 100, close = 0)
|
||||
await zigbeeHerdsman.events.message({
|
||||
await mockZHEvents.message({
|
||||
data: {currentPositionLiftPercentage: 90, currentPositionTiltPercentage: 80},
|
||||
cluster: 'closuresWindowCovering',
|
||||
device,
|
||||
@@ -107,18 +107,16 @@ describe('Receive', () => {
|
||||
linkquality: 10,
|
||||
});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/J1_cover',
|
||||
stringify({position: 10, tilt: 20, state: 'OPEN'}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/J1_cover', stringify({position: 10, tilt: 20, state: 'OPEN'}), {
|
||||
retain: false,
|
||||
qos: 0,
|
||||
});
|
||||
|
||||
// Inverted
|
||||
MQTT.publish.mockClear();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
settings.set(['devices', device.ieeeAddr, 'invert_cover'], true);
|
||||
await zigbeeHerdsman.events.message({
|
||||
await mockZHEvents.message({
|
||||
data: {currentPositionLiftPercentage: 90, currentPositionTiltPercentage: 80},
|
||||
cluster: 'closuresWindowCovering',
|
||||
device,
|
||||
@@ -127,28 +125,26 @@ describe('Receive', () => {
|
||||
linkquality: 10,
|
||||
});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/J1_cover',
|
||||
stringify({position: 90, tilt: 80, state: 'OPEN'}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/J1_cover', stringify({position: 90, tilt: 80, state: 'OPEN'}), {
|
||||
retain: false,
|
||||
qos: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('Should allow to disable the legacy integration', async () => {
|
||||
const device = zigbeeHerdsman.devices.WXKG11LM;
|
||||
const device = devices.WXKG11LM;
|
||||
settings.set(['devices', device.ieeeAddr, 'legacy'], false);
|
||||
const data = {onOff: 1};
|
||||
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/button', stringify({action: 'single'}), {retain: false, qos: 0}, expect.any(Function));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/button', stringify({action: 'single'}), {retain: false, qos: 0});
|
||||
});
|
||||
|
||||
it('Should debounce messages', async () => {
|
||||
const device = zigbeeHerdsman.devices.WSDCGQ11LM;
|
||||
const device = devices.WSDCGQ11LM;
|
||||
settings.set(['devices', device.ieeeAddr, 'debounce'], 0.1);
|
||||
const data1 = {measuredValue: 8};
|
||||
const payload1 = {
|
||||
@@ -159,7 +155,7 @@ describe('Receive', () => {
|
||||
type: 'attributeReport',
|
||||
linkquality: 10,
|
||||
};
|
||||
await zigbeeHerdsman.events.message(payload1);
|
||||
await mockZHEvents.message(payload1);
|
||||
const data2 = {measuredValue: 1};
|
||||
const payload2 = {
|
||||
data: data2,
|
||||
@@ -169,7 +165,7 @@ describe('Receive', () => {
|
||||
type: 'attributeReport',
|
||||
linkquality: 10,
|
||||
};
|
||||
await zigbeeHerdsman.events.message(payload2);
|
||||
await mockZHEvents.message(payload2);
|
||||
const data3 = {measuredValue: 2};
|
||||
const payload3 = {
|
||||
data: data3,
|
||||
@@ -179,20 +175,20 @@ describe('Receive', () => {
|
||||
type: 'attributeReport',
|
||||
linkquality: 10,
|
||||
};
|
||||
await zigbeeHerdsman.events.message(payload3);
|
||||
await mockZHEvents.message(payload3);
|
||||
await flushPromises();
|
||||
jest.advanceTimersByTime(50);
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(0);
|
||||
jest.runOnlyPendingTimers();
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
|
||||
vi.runOnlyPendingTimers();
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({temperature: 0.08, humidity: 0.01, pressure: 2});
|
||||
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 1, retain: false});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({temperature: 0.08, humidity: 0.01, pressure: 2});
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 1, retain: false});
|
||||
});
|
||||
|
||||
it('Should debounce and retain messages when set via device_options', async () => {
|
||||
const device = zigbeeHerdsman.devices.WSDCGQ11LM;
|
||||
const device = devices.WSDCGQ11LM;
|
||||
settings.set(['device_options', 'debounce'], 0.1);
|
||||
settings.set(['device_options', 'retain'], true);
|
||||
delete settings.get().devices['0x0017880104e45522']['retain'];
|
||||
@@ -205,7 +201,7 @@ describe('Receive', () => {
|
||||
type: 'attributeReport',
|
||||
linkquality: 10,
|
||||
};
|
||||
await zigbeeHerdsman.events.message(payload1);
|
||||
await mockZHEvents.message(payload1);
|
||||
const data2 = {measuredValue: 1};
|
||||
const payload2 = {
|
||||
data: data2,
|
||||
@@ -215,7 +211,7 @@ describe('Receive', () => {
|
||||
type: 'attributeReport',
|
||||
linkquality: 10,
|
||||
};
|
||||
await zigbeeHerdsman.events.message(payload2);
|
||||
await mockZHEvents.message(payload2);
|
||||
const data3 = {measuredValue: 2};
|
||||
const payload3 = {
|
||||
data: data3,
|
||||
@@ -225,20 +221,20 @@ describe('Receive', () => {
|
||||
type: 'attributeReport',
|
||||
linkquality: 10,
|
||||
};
|
||||
await zigbeeHerdsman.events.message(payload3);
|
||||
await mockZHEvents.message(payload3);
|
||||
await flushPromises();
|
||||
jest.advanceTimersByTime(50);
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(0);
|
||||
jest.runOnlyPendingTimers();
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
|
||||
vi.runOnlyPendingTimers();
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({temperature: 0.08, humidity: 0.01, pressure: 2});
|
||||
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 1, retain: true});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({temperature: 0.08, humidity: 0.01, pressure: 2});
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 1, retain: true});
|
||||
});
|
||||
|
||||
it('Should debounce messages only with the same payload values for provided debounce_ignore keys', async () => {
|
||||
const device = zigbeeHerdsman.devices.WSDCGQ11LM;
|
||||
const device = devices.WSDCGQ11LM;
|
||||
settings.set(['devices', device.ieeeAddr, 'debounce'], 0.1);
|
||||
settings.set(['devices', device.ieeeAddr, 'debounce_ignore'], ['temperature']);
|
||||
const tempMsg = {
|
||||
@@ -249,7 +245,7 @@ describe('Receive', () => {
|
||||
type: 'attributeReport',
|
||||
linkquality: 13,
|
||||
};
|
||||
await zigbeeHerdsman.events.message(tempMsg);
|
||||
await mockZHEvents.message(tempMsg);
|
||||
const pressureMsg = {
|
||||
data: {measuredValue: 2},
|
||||
cluster: 'msPressureMeasurement',
|
||||
@@ -258,7 +254,7 @@ describe('Receive', () => {
|
||||
type: 'attributeReport',
|
||||
linkquality: 13,
|
||||
};
|
||||
await zigbeeHerdsman.events.message(pressureMsg);
|
||||
await mockZHEvents.message(pressureMsg);
|
||||
const tempMsg2 = {
|
||||
data: {measuredValue: 7},
|
||||
cluster: 'msTemperatureMeasurement',
|
||||
@@ -267,7 +263,7 @@ describe('Receive', () => {
|
||||
type: 'attributeReport',
|
||||
linkquality: 13,
|
||||
};
|
||||
await zigbeeHerdsman.events.message(tempMsg2);
|
||||
await mockZHEvents.message(tempMsg2);
|
||||
const humidityMsg = {
|
||||
data: {measuredValue: 3},
|
||||
cluster: 'msRelativeHumidity',
|
||||
@@ -276,25 +272,24 @@ describe('Receive', () => {
|
||||
type: 'attributeReport',
|
||||
linkquality: 13,
|
||||
};
|
||||
await zigbeeHerdsman.events.message(humidityMsg);
|
||||
await mockZHEvents.message(humidityMsg);
|
||||
await flushPromises();
|
||||
jest.advanceTimersByTime(50);
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({temperature: 0.08, pressure: 2});
|
||||
jest.runOnlyPendingTimers();
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({temperature: 0.08, pressure: 2});
|
||||
vi.runOnlyPendingTimers();
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(2);
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({temperature: 0.07, pressure: 2, humidity: 0.03});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toStrictEqual({temperature: 0.07, pressure: 2, humidity: 0.03});
|
||||
});
|
||||
|
||||
it('Should NOT publish old messages from State cache during debouncing', async () => {
|
||||
// Summary:
|
||||
// First send multiple measurements to device that is debouncing. Make sure only one message is sent out to MQTT. This also ensures first message is cached to "State".
|
||||
// First send multiple measurements to device that is debouncing. Make sure only one message is sent out to mockMQTT. This also ensures first message is cached to "State".
|
||||
// Then send another measurement to that same device and trigger asynchronous event to push data from Cache. Newest value should be sent out.
|
||||
|
||||
const device = zigbeeHerdsman.devices.WSDCGQ11LM;
|
||||
const device = devices.WSDCGQ11LM;
|
||||
settings.set(['devices', device.ieeeAddr, 'debounce'], 0.1);
|
||||
await zigbeeHerdsman.events.message({
|
||||
await mockZHEvents.message({
|
||||
data: {measuredValue: 8},
|
||||
cluster: 'msTemperatureMeasurement',
|
||||
device,
|
||||
@@ -302,7 +297,7 @@ describe('Receive', () => {
|
||||
type: 'attributeReport',
|
||||
linkquality: 10,
|
||||
});
|
||||
await zigbeeHerdsman.events.message({
|
||||
await mockZHEvents.message({
|
||||
data: {measuredValue: 1},
|
||||
cluster: 'msRelativeHumidity',
|
||||
device,
|
||||
@@ -310,7 +305,7 @@ describe('Receive', () => {
|
||||
type: 'attributeReport',
|
||||
linkquality: 10,
|
||||
});
|
||||
await zigbeeHerdsman.events.message({
|
||||
await mockZHEvents.message({
|
||||
data: {measuredValue: 2},
|
||||
cluster: 'msPressureMeasurement',
|
||||
device,
|
||||
@@ -319,19 +314,18 @@ describe('Receive', () => {
|
||||
linkquality: 10,
|
||||
});
|
||||
await flushPromises();
|
||||
jest.advanceTimersByTime(50);
|
||||
vi.advanceTimersByTime(50);
|
||||
// Test that measurements are combined(=debounced)
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(0);
|
||||
jest.runOnlyPendingTimers();
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
|
||||
vi.runOnlyPendingTimers();
|
||||
|
||||
// Test that only one MQTT is sent out and test its values.
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({temperature: 0.08, humidity: 0.01, pressure: 2});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({temperature: 0.08, humidity: 0.01, pressure: 2});
|
||||
|
||||
// Send another Zigbee message...
|
||||
await zigbeeHerdsman.events.message({
|
||||
await mockZHEvents.message({
|
||||
data: {measuredValue: 9},
|
||||
cluster: 'msTemperatureMeasurement',
|
||||
device,
|
||||
@@ -339,24 +333,24 @@ describe('Receive', () => {
|
||||
type: 'attributeReport',
|
||||
linkquality: 10,
|
||||
});
|
||||
// @ts-expect-error private
|
||||
const realDevice = controller.zigbee.resolveEntity(device);
|
||||
|
||||
// Trigger asynchronous event while device is "debouncing" to trigger Message to be sent out from State cache.
|
||||
await controller.publishEntityState(realDevice, {});
|
||||
jest.runOnlyPendingTimers();
|
||||
await flushPromises();
|
||||
vi.runOnlyPendingTimers();
|
||||
|
||||
// Total of 3 messages should have triggered.
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(3);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(3);
|
||||
|
||||
// Test that message pushed by asynchronous message contains NEW measurement and not old.
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({temperature: 0.09, humidity: 0.01, pressure: 2});
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toStrictEqual({temperature: 0.09, humidity: 0.01, pressure: 2});
|
||||
// Test that messages after debouncing contains NEW measurement and not old.
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({temperature: 0.09, humidity: 0.01, pressure: 2});
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[2][1])).toStrictEqual({temperature: 0.09, humidity: 0.01, pressure: 2});
|
||||
});
|
||||
|
||||
it('Should throttle multiple messages from spamming devices', async () => {
|
||||
const device = zigbeeHerdsman.devices.SPAMMER;
|
||||
const device = devices.SPAMMER;
|
||||
const throttle_for_testing = 1;
|
||||
settings.set(['device_options', 'throttle'], throttle_for_testing);
|
||||
settings.set(['device_options', 'retain'], true);
|
||||
@@ -370,7 +364,7 @@ describe('Receive', () => {
|
||||
type: 'attributeReport',
|
||||
linkquality: 10,
|
||||
};
|
||||
await zigbeeHerdsman.events.message(payload1);
|
||||
await mockZHEvents.message(payload1);
|
||||
const data2 = {measuredValue: 2};
|
||||
const payload2 = {
|
||||
data: data2,
|
||||
@@ -380,7 +374,7 @@ describe('Receive', () => {
|
||||
type: 'attributeReport',
|
||||
linkquality: 10,
|
||||
};
|
||||
await zigbeeHerdsman.events.message(payload2);
|
||||
await mockZHEvents.message(payload2);
|
||||
const data3 = {measuredValue: 3};
|
||||
const payload3 = {
|
||||
data: data3,
|
||||
@@ -390,25 +384,25 @@ describe('Receive', () => {
|
||||
type: 'attributeReport',
|
||||
linkquality: 10,
|
||||
};
|
||||
await zigbeeHerdsman.events.message(payload3);
|
||||
await mockZHEvents.message(payload3);
|
||||
await flushPromises();
|
||||
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/spammer1');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({temperature: 0.01});
|
||||
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 0, retain: true});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/spammer1');
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({temperature: 0.01});
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 0, retain: true});
|
||||
|
||||
// Now we try after elapsed time to see if it publishes next message
|
||||
const timeshift = throttle_for_testing * 2000;
|
||||
jest.advanceTimersByTime(timeshift);
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(2);
|
||||
vi.advanceTimersByTime(timeshift);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
|
||||
await flushPromises();
|
||||
|
||||
expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/spammer1');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({temperature: 0.03});
|
||||
expect(MQTT.publish.mock.calls[1][2]).toStrictEqual({qos: 0, retain: true});
|
||||
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/spammer1');
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toStrictEqual({temperature: 0.03});
|
||||
expect(mockMQTTPublishAsync.mock.calls[1][2]).toStrictEqual({qos: 0, retain: true});
|
||||
|
||||
const data4 = {measuredValue: 4};
|
||||
const payload4 = {
|
||||
@@ -419,20 +413,20 @@ describe('Receive', () => {
|
||||
type: 'attributeReport',
|
||||
linkquality: 10,
|
||||
};
|
||||
await zigbeeHerdsman.events.message(payload4);
|
||||
await mockZHEvents.message(payload4);
|
||||
await flushPromises();
|
||||
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(3);
|
||||
expect(MQTT.publish.mock.calls[2][0]).toStrictEqual('zigbee2mqtt/spammer1');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({temperature: 0.04});
|
||||
expect(MQTT.publish.mock.calls[2][2]).toStrictEqual({qos: 0, retain: true});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(3);
|
||||
expect(mockMQTTPublishAsync.mock.calls[2][0]).toStrictEqual('zigbee2mqtt/spammer1');
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[2][1])).toStrictEqual({temperature: 0.04});
|
||||
expect(mockMQTTPublishAsync.mock.calls[2][2]).toStrictEqual({qos: 0, retain: true});
|
||||
});
|
||||
|
||||
it('Shouldnt republish old state', async () => {
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/3572
|
||||
const device = zigbeeHerdsman.devices.bulb;
|
||||
const device = devices.bulb;
|
||||
settings.set(['devices', device.ieeeAddr, 'debounce'], 0.1);
|
||||
await zigbeeHerdsman.events.message({
|
||||
await mockZHEvents.message({
|
||||
data: {onOff: 0},
|
||||
cluster: 'genOnOff',
|
||||
device,
|
||||
@@ -440,16 +434,16 @@ describe('Receive', () => {
|
||||
type: 'attributeReport',
|
||||
linkquality: 10,
|
||||
});
|
||||
await MQTT.events.message('zigbee2mqtt/bulb/set', stringify({state: 'ON'}));
|
||||
await mockMQTTEvents.message('zigbee2mqtt/bulb/set', stringify({state: 'ON'}));
|
||||
await flushPromises();
|
||||
jest.runOnlyPendingTimers();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(2);
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({state: 'ON'});
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({state: 'ON'});
|
||||
vi.runOnlyPendingTimers();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({state: 'ON'});
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toStrictEqual({state: 'ON'});
|
||||
});
|
||||
|
||||
it('Should handle a zigbee message with 1 precision', async () => {
|
||||
const device = zigbeeHerdsman.devices.WSDCGQ11LM;
|
||||
const device = devices.WSDCGQ11LM;
|
||||
settings.set(['devices', device.ieeeAddr, 'temperature_precision'], 1);
|
||||
const data = {measuredValue: -85};
|
||||
const payload = {
|
||||
@@ -460,16 +454,16 @@ describe('Receive', () => {
|
||||
type: 'attributeReport',
|
||||
linkquality: 10,
|
||||
};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({temperature: -0.8});
|
||||
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 1, retain: false});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({temperature: -0.8});
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 1, retain: false});
|
||||
});
|
||||
|
||||
it('Should handle a zigbee message with 0 precision', async () => {
|
||||
const device = zigbeeHerdsman.devices.WSDCGQ11LM;
|
||||
const device = devices.WSDCGQ11LM;
|
||||
settings.set(['devices', device.ieeeAddr, 'temperature_precision'], 0);
|
||||
const data = {measuredValue: -85};
|
||||
const payload = {
|
||||
@@ -480,16 +474,16 @@ describe('Receive', () => {
|
||||
type: 'attributeReport',
|
||||
linkquality: 10,
|
||||
};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({temperature: -1});
|
||||
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 1, retain: false});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({temperature: -1});
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 1, retain: false});
|
||||
});
|
||||
|
||||
it('Should handle a zigbee message with 1 precision when set via device_options', async () => {
|
||||
const device = zigbeeHerdsman.devices.WSDCGQ11LM;
|
||||
const device = devices.WSDCGQ11LM;
|
||||
settings.set(['device_options', 'temperature_precision'], 1);
|
||||
const data = {measuredValue: -85};
|
||||
const payload = {
|
||||
@@ -500,16 +494,16 @@ describe('Receive', () => {
|
||||
type: 'attributeReport',
|
||||
linkquality: 10,
|
||||
};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({temperature: -0.8});
|
||||
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 1, retain: false});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({temperature: -0.8});
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 1, retain: false});
|
||||
});
|
||||
|
||||
it('Should handle a zigbee message with 2 precision when overrides device_options', async () => {
|
||||
const device = zigbeeHerdsman.devices.WSDCGQ11LM;
|
||||
const device = devices.WSDCGQ11LM;
|
||||
settings.set(['device_options', 'temperature_precision'], 1);
|
||||
settings.set(['devices', device.ieeeAddr, 'temperature_precision'], 0);
|
||||
const data = {measuredValue: -85};
|
||||
@@ -521,131 +515,125 @@ describe('Receive', () => {
|
||||
type: 'attributeReport',
|
||||
linkquality: 10,
|
||||
};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({temperature: -1});
|
||||
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 1, retain: false});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({temperature: -1});
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 1, retain: false});
|
||||
});
|
||||
|
||||
it('Should handle a zigbee message with voltage 2990', async () => {
|
||||
const device = zigbeeHerdsman.devices.WXKG02LM_rev1;
|
||||
const device = devices.WXKG02LM_rev1;
|
||||
const data = {65281: {1: 2990}};
|
||||
const payload = {data, cluster: 'genBasic', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button_double_key');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({battery: 93, voltage: 2990});
|
||||
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button_double_key');
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({battery: 93, voltage: 2990});
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
|
||||
});
|
||||
|
||||
it('Should publish 1 message when converted twice', async () => {
|
||||
const device = zigbeeHerdsman.devices.RTCGQ11LM;
|
||||
const device = devices.RTCGQ11LM;
|
||||
const data = {65281: {1: 3045, 3: 19, 5: 35, 6: [0, 3], 11: 381, 100: 0}};
|
||||
const payload = {data, cluster: 'genBasic', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/occupancy_sensor');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/occupancy_sensor');
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({
|
||||
battery: 100,
|
||||
illuminance: 381,
|
||||
illuminance_lux: 381,
|
||||
voltage: 3045,
|
||||
device_temperature: 19,
|
||||
power_outage_count: 34,
|
||||
});
|
||||
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
|
||||
});
|
||||
|
||||
it('Should publish 1 message when converted twice', async () => {
|
||||
const device = zigbeeHerdsman.devices.RTCGQ11LM;
|
||||
const device = devices.RTCGQ11LM;
|
||||
const data = {9999: {1: 3045}};
|
||||
const payload = {data, cluster: 'genBasic', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(0);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should publish last_seen epoch', async () => {
|
||||
const device = zigbeeHerdsman.devices.WXKG02LM_rev1;
|
||||
const device = devices.WXKG02LM_rev1;
|
||||
settings.set(['advanced', 'last_seen'], 'epoch');
|
||||
const data = {onOff: 1};
|
||||
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button_double_key');
|
||||
expect(typeof JSON.parse(MQTT.publish.mock.calls[0][1]).last_seen).toBe('number');
|
||||
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button_double_key');
|
||||
expect(typeof JSON.parse(mockMQTTPublishAsync.mock.calls[0][1]).last_seen).toBe('number');
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
|
||||
});
|
||||
|
||||
it('Should publish last_seen ISO_8601', async () => {
|
||||
const device = zigbeeHerdsman.devices.WXKG02LM_rev1;
|
||||
const device = devices.WXKG02LM_rev1;
|
||||
settings.set(['advanced', 'last_seen'], 'ISO_8601');
|
||||
const data = {onOff: 1};
|
||||
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button_double_key');
|
||||
expect(typeof JSON.parse(MQTT.publish.mock.calls[0][1]).last_seen).toBe('string');
|
||||
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button_double_key');
|
||||
expect(typeof JSON.parse(mockMQTTPublishAsync.mock.calls[0][1]).last_seen).toBe('string');
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
|
||||
});
|
||||
|
||||
it('Should publish last_seen ISO_8601_local', async () => {
|
||||
const device = zigbeeHerdsman.devices.WXKG02LM_rev1;
|
||||
const device = devices.WXKG02LM_rev1;
|
||||
settings.set(['advanced', 'last_seen'], 'ISO_8601_local');
|
||||
const data = {onOff: 1};
|
||||
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button_double_key');
|
||||
expect(typeof JSON.parse(MQTT.publish.mock.calls[0][1]).last_seen).toBe('string');
|
||||
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button_double_key');
|
||||
expect(typeof JSON.parse(mockMQTTPublishAsync.mock.calls[0][1]).last_seen).toBe('string');
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
|
||||
});
|
||||
|
||||
it('Should handle messages from Xiaomi router devices', async () => {
|
||||
const device = zigbeeHerdsman.devices.ZNCZ02LM;
|
||||
const device = devices.ZNCZ02LM;
|
||||
const data = {onOff: 1};
|
||||
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 20};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(2);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/power_plug', stringify({state: 'ON'}), {retain: false, qos: 0}, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/switch_group',
|
||||
stringify({state: 'ON'}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/power_plug', stringify({state: 'ON'}), {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/switch_group', stringify({state: 'ON'}), {retain: false, qos: 0});
|
||||
});
|
||||
|
||||
it('Should not handle messages from coordinator', async () => {
|
||||
const device = zigbeeHerdsman.devices.coordinator;
|
||||
const device = devices.coordinator;
|
||||
const data = {onOff: 1};
|
||||
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(0);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should not handle messages from still interviewing devices with unknown definition', async () => {
|
||||
const device = zigbeeHerdsman.devices.interviewing;
|
||||
const device = devices.interviewing;
|
||||
const data = {onOff: 1};
|
||||
logger.debug.mockClear();
|
||||
mockLogger.debug.mockClear();
|
||||
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(0);
|
||||
expect(logger.debug).toHaveBeenCalledWith(`Skipping message, still interviewing`);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(`Skipping message, still interviewing`);
|
||||
});
|
||||
|
||||
it('Should handle a command', async () => {
|
||||
const device = zigbeeHerdsman.devices.E1743;
|
||||
const device = devices.E1743;
|
||||
const data = {};
|
||||
const payload = {
|
||||
data,
|
||||
@@ -656,45 +644,41 @@ describe('Receive', () => {
|
||||
linkquality: 10,
|
||||
meta: {zclTransactionSequenceNumber: 1},
|
||||
};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/ikea_onoff');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({click: 'brightness_stop', action: 'brightness_stop'});
|
||||
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/ikea_onoff');
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({action: 'brightness_stop'});
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
|
||||
});
|
||||
|
||||
it('Should add elapsed', async () => {
|
||||
settings.set(['advanced', 'elapsed'], true);
|
||||
const device = zigbeeHerdsman.devices.E1743;
|
||||
const device = devices.E1743;
|
||||
const payload = {data: {}, cluster: 'genLevelCtrl', device, endpoint: device.getEndpoint(1), type: 'commandStopWithOnOff'};
|
||||
const oldNow = Date.now;
|
||||
Date.now = jest.fn();
|
||||
Date.now.mockReturnValue(new Date(150));
|
||||
await zigbeeHerdsman.events.message({...payload, meta: {zclTransactionSequenceNumber: 2}});
|
||||
vi.spyOn(Date, 'now').mockReturnValueOnce(150).mockReturnValueOnce(200);
|
||||
await mockZHEvents.message({...payload, meta: {zclTransactionSequenceNumber: 2}});
|
||||
await flushPromises();
|
||||
Date.now.mockReturnValue(new Date(200));
|
||||
await zigbeeHerdsman.events.message({...payload, meta: {zclTransactionSequenceNumber: 3}});
|
||||
await mockZHEvents.message({...payload, meta: {zclTransactionSequenceNumber: 3}});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(2);
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/ikea_onoff');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({click: 'brightness_stop', action: 'brightness_stop'});
|
||||
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
|
||||
expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/ikea_onoff');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toMatchObject({click: 'brightness_stop', action: 'brightness_stop'});
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[1][1]).elapsed).toBe(50);
|
||||
expect(MQTT.publish.mock.calls[1][2]).toStrictEqual({qos: 0, retain: false});
|
||||
Date.now = oldNow;
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/ikea_onoff');
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({action: 'brightness_stop'});
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
|
||||
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/ikea_onoff');
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toMatchObject({action: 'brightness_stop'});
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1]).elapsed).toBe(50);
|
||||
expect(mockMQTTPublishAsync.mock.calls[1][2]).toStrictEqual({qos: 0, retain: false});
|
||||
});
|
||||
|
||||
it('Should log when message is from supported device but has no converters', async () => {
|
||||
const device = zigbeeHerdsman.devices.ZNCZ02LM;
|
||||
const device = devices.ZNCZ02LM;
|
||||
const data = {inactiveText: 'hello'};
|
||||
const payload = {data, cluster: 'genBinaryOutput', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 20};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(0);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
"No converter available for 'ZNCZ02LM' with cluster 'genBinaryOutput' and type 'attributeReport' and data '{\"inactiveText\":\"hello\"}'",
|
||||
);
|
||||
});
|
||||
@@ -704,8 +688,8 @@ describe('Receive', () => {
|
||||
// divisor of OLD is not correct and therefore underreports by factor 10.
|
||||
const data = {instantaneousDemand: 496, currentSummDelivered: 6648};
|
||||
|
||||
const SP600_NEW = zigbeeHerdsman.devices.SP600_NEW;
|
||||
await zigbeeHerdsman.events.message({
|
||||
const SP600_NEW = devices.SP600_NEW;
|
||||
await mockZHEvents.message({
|
||||
data,
|
||||
cluster: 'seMetering',
|
||||
device: SP600_NEW,
|
||||
@@ -715,13 +699,13 @@ describe('Receive', () => {
|
||||
meta: {zclTransactionSequenceNumber: 1},
|
||||
});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/SP600_NEW');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({energy: 0.66, power: 49.6});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/SP600_NEW');
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({energy: 0.66, power: 49.6});
|
||||
|
||||
MQTT.publish.mockClear();
|
||||
const SP600_OLD = zigbeeHerdsman.devices.SP600_OLD;
|
||||
await zigbeeHerdsman.events.message({
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
const SP600_OLD = devices.SP600_OLD;
|
||||
await mockZHEvents.message({
|
||||
data,
|
||||
cluster: 'seMetering',
|
||||
device: SP600_OLD,
|
||||
@@ -731,16 +715,16 @@ describe('Receive', () => {
|
||||
meta: {zclTransactionSequenceNumber: 2},
|
||||
});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/SP600_OLD');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({energy: 6.65, power: 496});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/SP600_OLD');
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({energy: 6.65, power: 496});
|
||||
});
|
||||
|
||||
it('Should emit DevicesChanged event when a converter announces changed exposes', async () => {
|
||||
const device = zigbeeHerdsman.devices['BMCT-SLZ'];
|
||||
const device = devices['BMCT-SLZ'];
|
||||
const data = {deviceMode: 0};
|
||||
const payload = {data, cluster: 'boschSpecific', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/devices');
|
||||
await mockZHEvents.message(payload);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/devices');
|
||||
});
|
||||
});
|
||||
@@ -1,167 +0,0 @@
|
||||
const data = require('./stub/data');
|
||||
const logger = require('./stub/logger');
|
||||
const zigbeeHerdsman = require('./stub/zigbeeHerdsman');
|
||||
const MQTT = require('./stub/mqtt');
|
||||
const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {});
|
||||
const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters');
|
||||
const settings = require('../lib/util/settings');
|
||||
const Controller = require('../lib/controller');
|
||||
const flushPromises = require('./lib/flushPromises');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
zigbeeHerdsmanConverters.addDefinition = jest.fn();
|
||||
|
||||
const mocksClear = [
|
||||
zigbeeHerdsmanConverters.addDefinition,
|
||||
zigbeeHerdsman.permitJoin,
|
||||
mockExit,
|
||||
MQTT.end,
|
||||
zigbeeHerdsman.stop,
|
||||
logger.debug,
|
||||
MQTT.publish,
|
||||
MQTT.connect,
|
||||
zigbeeHerdsman.devices.bulb_color.removeFromNetwork,
|
||||
zigbeeHerdsman.devices.bulb.removeFromNetwork,
|
||||
logger.error,
|
||||
];
|
||||
|
||||
jest.mock(
|
||||
'mock-external-converter-module',
|
||||
() => {
|
||||
return {
|
||||
mock: true,
|
||||
};
|
||||
},
|
||||
{
|
||||
virtual: true,
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'mock-multiple-external-converter-module',
|
||||
() => {
|
||||
return [
|
||||
{
|
||||
mock: 1,
|
||||
},
|
||||
{
|
||||
mock: 2,
|
||||
},
|
||||
];
|
||||
},
|
||||
{
|
||||
virtual: true,
|
||||
},
|
||||
);
|
||||
|
||||
describe('Loads external converters', () => {
|
||||
let controller;
|
||||
|
||||
let resetExtension = async () => {
|
||||
await controller.enableDisableExtension(false, 'ExternalConverters');
|
||||
await controller.enableDisableExtension(true, 'ExternalConverters');
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.useFakeTimers();
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
await resetExtension();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('Does not load external converters', async () => {
|
||||
settings.set(['external_converters'], []);
|
||||
await resetExtension();
|
||||
expect(zigbeeHerdsmanConverters.addDefinition).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Loads external converters', async () => {
|
||||
fs.copyFileSync(path.join(__dirname, 'assets', 'mock-external-converter.js'), path.join(data.mockDir, 'mock-external-converter.js'));
|
||||
settings.set(['external_converters'], ['mock-external-converter.js']);
|
||||
await resetExtension();
|
||||
expect(zigbeeHerdsmanConverters.addDefinition).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsmanConverters.addDefinition).toHaveBeenCalledWith({
|
||||
mock: true,
|
||||
zigbeeModel: ['external_converter_device'],
|
||||
vendor: 'external',
|
||||
model: 'external_converter_device',
|
||||
description: 'external',
|
||||
fromZigbee: [],
|
||||
toZigbee: [],
|
||||
exposes: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('Loads multiple external converters', async () => {
|
||||
fs.copyFileSync(
|
||||
path.join(__dirname, 'assets', 'mock-external-converter-multiple.js'),
|
||||
path.join(data.mockDir, 'mock-external-converter-multiple.js'),
|
||||
);
|
||||
settings.set(['external_converters'], ['mock-external-converter-multiple.js']);
|
||||
await resetExtension();
|
||||
expect(zigbeeHerdsmanConverters.addDefinition).toHaveBeenCalledTimes(2);
|
||||
expect(zigbeeHerdsmanConverters.addDefinition).toHaveBeenNthCalledWith(1, {
|
||||
mock: 1,
|
||||
model: 'external_converters_device_1',
|
||||
zigbeeModel: ['external_converter_device_1'],
|
||||
vendor: 'external_1',
|
||||
description: 'external_1',
|
||||
fromZigbee: [],
|
||||
toZigbee: [],
|
||||
exposes: [],
|
||||
});
|
||||
expect(zigbeeHerdsmanConverters.addDefinition).toHaveBeenNthCalledWith(2, {
|
||||
mock: 2,
|
||||
model: 'external_converters_device_2',
|
||||
zigbeeModel: ['external_converter_device_2'],
|
||||
vendor: 'external_2',
|
||||
description: 'external_2',
|
||||
fromZigbee: [],
|
||||
toZigbee: [],
|
||||
exposes: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('Loads external converters from package', async () => {
|
||||
settings.set(['external_converters'], ['mock-external-converter-module']);
|
||||
await resetExtension();
|
||||
expect(zigbeeHerdsmanConverters.addDefinition).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsmanConverters.addDefinition).toHaveBeenCalledWith({
|
||||
mock: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('Loads multiple external converters from package', async () => {
|
||||
settings.set(['external_converters'], ['mock-multiple-external-converter-module']);
|
||||
await resetExtension();
|
||||
expect(zigbeeHerdsmanConverters.addDefinition).toHaveBeenCalledTimes(2);
|
||||
expect(zigbeeHerdsmanConverters.addDefinition).toHaveBeenNthCalledWith(1, {
|
||||
mock: 1,
|
||||
});
|
||||
expect(zigbeeHerdsmanConverters.addDefinition).toHaveBeenNthCalledWith(2, {
|
||||
mock: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('Loads external converters with error', async () => {
|
||||
fs.copyFileSync(path.join(__dirname, 'assets', 'mock-external-converter.js'), path.join(data.mockDir, 'mock-external-converter.js'));
|
||||
settings.set(['external_converters'], ['mock-external-converter.js']);
|
||||
zigbeeHerdsmanConverters.addDefinition.mockImplementationOnce(() => {
|
||||
throw new Error('Invalid definition!');
|
||||
});
|
||||
await resetExtension();
|
||||
expect(logger.error).toHaveBeenCalledWith(`Failed to load external converter file 'mock-external-converter.js' (Invalid definition!)`);
|
||||
});
|
||||
});
|
||||
@@ -1,149 +0,0 @@
|
||||
const data = require('./stub/data');
|
||||
const logger = require('./stub/logger');
|
||||
const zigbeeHerdsman = require('./stub/zigbeeHerdsman');
|
||||
const MQTT = require('./stub/mqtt');
|
||||
const path = require('path');
|
||||
const {rimrafSync} = require('rimraf');
|
||||
const settings = require('../lib/util/settings');
|
||||
const Controller = require('../lib/controller');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
const flushPromises = require('./lib/flushPromises');
|
||||
const mocksClear = [
|
||||
zigbeeHerdsman.permitJoin,
|
||||
MQTT.end,
|
||||
zigbeeHerdsman.stop,
|
||||
logger.debug,
|
||||
MQTT.publish,
|
||||
MQTT.connect,
|
||||
zigbeeHerdsman.devices.bulb_color.removeFromNetwork,
|
||||
zigbeeHerdsman.devices.bulb.removeFromNetwork,
|
||||
logger.error,
|
||||
];
|
||||
|
||||
const fs = require('fs');
|
||||
const mkdirSyncSpy = jest.spyOn(fs, 'mkdirSync');
|
||||
const unlinkSyncSpy = jest.spyOn(fs, 'unlinkSync');
|
||||
|
||||
describe('User extensions', () => {
|
||||
let controller;
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
zigbeeHerdsman.returnDevices.splice(0);
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
data.writeDefaultState();
|
||||
});
|
||||
afterEach(() => {
|
||||
const extensionPath = path.join(data.mockDir, 'extension');
|
||||
rimrafSync(extensionPath);
|
||||
});
|
||||
|
||||
it('Load user extension', async () => {
|
||||
const extensionPath = path.join(data.mockDir, 'extension');
|
||||
const extensionCode = fs.readFileSync(path.join(__dirname, 'assets', 'exampleExtension.js'), 'utf-8');
|
||||
fs.mkdirSync(extensionPath);
|
||||
fs.copyFileSync(path.join(__dirname, 'assets', 'exampleExtension.js'), path.join(extensionPath, 'exampleExtension.js'));
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'test', {retain: false, qos: 0}, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/extensions',
|
||||
stringify([{name: 'exampleExtension.js', code: extensionCode}]),
|
||||
{retain: true, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Load user extension from api call', async () => {
|
||||
const extensionPath = path.join(data.mockDir, 'extension');
|
||||
const extensionCode = fs.readFileSync(path.join(__dirname, 'assets', 'exampleExtension.js'), 'utf-8');
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: 'foo.js', code: extensionCode}));
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/extensions',
|
||||
stringify([{name: 'foo.js', code: extensionCode}]),
|
||||
{retain: true, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/example/extension',
|
||||
'call from constructor',
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mkdirSyncSpy).toHaveBeenCalledWith(extensionPath);
|
||||
});
|
||||
|
||||
it('Do not load corrupted extensions', async () => {
|
||||
const extensionPath = path.join(data.mockDir, 'extension');
|
||||
const extensionCode = 'definetly not a correct javascript code';
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: 'foo.js', code: extensionCode}));
|
||||
await flushPromises();
|
||||
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/extension/save',
|
||||
expect.any(String),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
const payload = JSON.parse(MQTT.publish.mock.calls[0][1]);
|
||||
expect(payload).toEqual(expect.objectContaining({data: {}, status: 'error'}));
|
||||
expect(payload.error).toMatch('Unexpected identifier');
|
||||
});
|
||||
|
||||
it('Removes user extension', async () => {
|
||||
const extensionPath = path.join(data.mockDir, 'extension');
|
||||
const extensionCode = fs.readFileSync(path.join(__dirname, 'assets', 'exampleExtension.js'), 'utf-8');
|
||||
fs.mkdirSync(extensionPath);
|
||||
const extensionFilePath = path.join(extensionPath, 'exampleExtension.js');
|
||||
fs.copyFileSync(path.join(__dirname, 'assets', 'exampleExtension.js'), extensionFilePath);
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'test', {retain: false, qos: 0}, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/extensions',
|
||||
stringify([{name: 'exampleExtension.js', code: extensionCode}]),
|
||||
{retain: true, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/extension/remove', stringify({name: 'exampleExtension.js'}));
|
||||
await flushPromises();
|
||||
expect(unlinkSyncSpy).toHaveBeenCalledWith(extensionFilePath);
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/extension/remove', stringify({name: 'non existing.js'}));
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/extension/remove',
|
||||
stringify({data: {}, status: 'error', error: "Extension non existing.js doesn't exists"}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,415 +0,0 @@
|
||||
const data = require('./stub/data');
|
||||
const logger = require('./stub/logger');
|
||||
require('./stub/zigbeeHerdsman');
|
||||
const MQTT = require('./stub/mqtt');
|
||||
const settings = require('../lib/util/settings');
|
||||
const Controller = require('../lib/controller');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
const flushPromises = require('./lib/flushPromises');
|
||||
const zigbeeHerdsman = require('./stub/zigbeeHerdsman');
|
||||
const path = require('path');
|
||||
const finalhandler = require('finalhandler');
|
||||
const ws = require('ws');
|
||||
jest.spyOn(process, 'exit').mockImplementation(() => {});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const mockHTTP = {
|
||||
implementation: {
|
||||
listen: jest.fn(),
|
||||
on: (event, handler) => {
|
||||
mockHTTP.events[event] = handler;
|
||||
},
|
||||
close: jest.fn().mockImplementation((cb) => cb()),
|
||||
},
|
||||
variables: {},
|
||||
events: {},
|
||||
};
|
||||
|
||||
const mockHTTPS = {
|
||||
implementation: {
|
||||
listen: jest.fn(),
|
||||
on: (event, handler) => {
|
||||
mockHTTPS.events[event] = handler;
|
||||
},
|
||||
close: jest.fn().mockImplementation((cb) => cb()),
|
||||
},
|
||||
variables: {},
|
||||
events: {},
|
||||
};
|
||||
|
||||
const mockWSocket = {
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
const mockWS = {
|
||||
implementation: {
|
||||
clients: [],
|
||||
on: (event, handler) => {
|
||||
mockWS.events[event] = handler;
|
||||
},
|
||||
handleUpgrade: jest.fn().mockImplementation((request, socket, head, cb) => {
|
||||
cb(mockWSocket);
|
||||
}),
|
||||
emit: jest.fn(),
|
||||
close: jest.fn(),
|
||||
},
|
||||
variables: {},
|
||||
events: {},
|
||||
};
|
||||
|
||||
const mockNodeStatic = {
|
||||
implementation: jest.fn(),
|
||||
variables: {},
|
||||
events: {},
|
||||
};
|
||||
|
||||
const mockFinalHandler = {
|
||||
implementation: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('http', () => ({
|
||||
createServer: jest.fn().mockImplementation((onRequest) => {
|
||||
mockHTTP.variables.onRequest = onRequest;
|
||||
return mockHTTP.implementation;
|
||||
}),
|
||||
Agent: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('https', () => ({
|
||||
createServer: jest.fn().mockImplementation((onRequest) => {
|
||||
mockHTTPS.variables.onRequest = onRequest;
|
||||
return mockHTTPS.implementation;
|
||||
}),
|
||||
Agent: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('connect-gzip-static', () =>
|
||||
jest.fn().mockImplementation((path) => {
|
||||
mockNodeStatic.variables.path = path;
|
||||
return mockNodeStatic.implementation;
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('zigbee2mqtt-frontend', () => ({
|
||||
getPath: () => 'my/dummy/path',
|
||||
}));
|
||||
|
||||
jest.mock('ws', () => ({
|
||||
OPEN: 'open',
|
||||
Server: jest.fn().mockImplementation(() => {
|
||||
return mockWS.implementation;
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('finalhandler', () =>
|
||||
jest.fn().mockImplementation(() => {
|
||||
return mockFinalHandler.implementation;
|
||||
}),
|
||||
);
|
||||
|
||||
describe('Frontend', () => {
|
||||
let controller;
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
mockWS.implementation.clients = [];
|
||||
data.writeDefaultConfiguration();
|
||||
data.writeDefaultState();
|
||||
settings.reRead();
|
||||
settings.set(['frontend'], {port: 8081, host: '127.0.0.1'});
|
||||
settings.set(['homeassistant'], true);
|
||||
zigbeeHerdsman.devices.bulb.linkquality = 10;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
delete zigbeeHerdsman.devices.bulb.linkquality;
|
||||
});
|
||||
|
||||
it('Start/stop', async () => {
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
expect(mockNodeStatic.variables.path).toBe('my/dummy/path');
|
||||
expect(mockHTTP.implementation.listen).toHaveBeenCalledWith(8081, '127.0.0.1');
|
||||
const mockWSClient = {
|
||||
implementation: {
|
||||
terminate: jest.fn(),
|
||||
send: jest.fn(),
|
||||
},
|
||||
events: {},
|
||||
};
|
||||
mockWS.implementation.clients.push(mockWSClient.implementation);
|
||||
await controller.stop();
|
||||
expect(mockWSClient.implementation.terminate).toHaveBeenCalledTimes(1);
|
||||
expect(mockHTTP.implementation.close).toHaveBeenCalledTimes(1);
|
||||
expect(mockWS.implementation.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Start/stop without host', async () => {
|
||||
settings.set(['frontend'], {port: 8081});
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
expect(mockNodeStatic.variables.path).toBe('my/dummy/path');
|
||||
expect(mockHTTP.implementation.listen).toHaveBeenCalledWith(8081);
|
||||
const mockWSClient = {
|
||||
implementation: {
|
||||
terminate: jest.fn(),
|
||||
send: jest.fn(),
|
||||
},
|
||||
events: {},
|
||||
};
|
||||
mockWS.implementation.clients.push(mockWSClient.implementation);
|
||||
await controller.stop();
|
||||
expect(mockWSClient.implementation.terminate).toHaveBeenCalledTimes(1);
|
||||
expect(mockHTTP.implementation.close).toHaveBeenCalledTimes(1);
|
||||
expect(mockWS.implementation.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Start/stop unix socket', async () => {
|
||||
settings.set(['frontend'], {host: '/tmp/zigbee2mqtt.sock'});
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
expect(mockNodeStatic.variables.path).toBe('my/dummy/path');
|
||||
expect(mockHTTP.implementation.listen).toHaveBeenCalledWith('/tmp/zigbee2mqtt.sock');
|
||||
const mockWSClient = {
|
||||
implementation: {
|
||||
terminate: jest.fn(),
|
||||
send: jest.fn(),
|
||||
},
|
||||
events: {},
|
||||
};
|
||||
mockWS.implementation.clients.push(mockWSClient.implementation);
|
||||
await controller.stop();
|
||||
expect(mockWSClient.implementation.terminate).toHaveBeenCalledTimes(1);
|
||||
expect(mockHTTP.implementation.close).toHaveBeenCalledTimes(1);
|
||||
expect(mockWS.implementation.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Start/stop HTTPS valid', async () => {
|
||||
settings.set(['frontend', 'ssl_cert'], path.join(__dirname, 'assets', 'certs', 'dummy.crt'));
|
||||
settings.set(['frontend', 'ssl_key'], path.join(__dirname, 'assets', 'certs', 'dummy.key'));
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
expect(mockHTTP.implementation.listen).not.toHaveBeenCalledWith(8081, '127.0.0.1');
|
||||
expect(mockHTTPS.implementation.listen).toHaveBeenCalledWith(8081, '127.0.0.1');
|
||||
await controller.stop();
|
||||
});
|
||||
|
||||
it('Start/stop HTTPS invalid : missing config', async () => {
|
||||
settings.set(['frontend', 'ssl_cert'], path.join(__dirname, 'assets', 'certs', 'dummy.crt'));
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
expect(mockHTTP.implementation.listen).toHaveBeenCalledWith(8081, '127.0.0.1');
|
||||
expect(mockHTTPS.implementation.listen).not.toHaveBeenCalledWith(8081, '127.0.0.1');
|
||||
await controller.stop();
|
||||
});
|
||||
|
||||
it('Start/stop HTTPS invalid : missing file', async () => {
|
||||
settings.set(['frontend', 'ssl_cert'], 'filesNotExists.crt');
|
||||
settings.set(['frontend', 'ssl_key'], path.join(__dirname, 'assets', 'certs', 'dummy.key'));
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
expect(mockHTTP.implementation.listen).toHaveBeenCalledWith(8081, '127.0.0.1');
|
||||
expect(mockHTTPS.implementation.listen).not.toHaveBeenCalledWith(8081, '127.0.0.1');
|
||||
await controller.stop();
|
||||
});
|
||||
|
||||
it('Websocket interaction', async () => {
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
|
||||
// Connect
|
||||
const mockWSClient = {
|
||||
implementation: {
|
||||
on: (event, handler) => {
|
||||
mockWSClient.events[event] = handler;
|
||||
},
|
||||
send: jest.fn(),
|
||||
readyState: 'open',
|
||||
},
|
||||
events: {},
|
||||
};
|
||||
mockWS.implementation.clients.push(mockWSClient.implementation);
|
||||
await mockWS.events.connection(mockWSClient.implementation);
|
||||
|
||||
const allTopics = mockWSClient.implementation.send.mock.calls.map((m) => JSON.parse(m).topic);
|
||||
expect(allTopics).toContain('bridge/devices');
|
||||
expect(allTopics).toContain('bridge/info');
|
||||
expect(allTopics).toContain('bridge/config');
|
||||
expect(mockWSClient.implementation.send).toHaveBeenCalledWith(stringify({topic: 'bridge/state', payload: 'online'}));
|
||||
expect(mockWSClient.implementation.send).toHaveBeenCalledWith(stringify({topic: 'remote', payload: {brightness: 255}}));
|
||||
|
||||
// Message
|
||||
MQTT.publish.mockClear();
|
||||
mockWSClient.implementation.send.mockClear();
|
||||
mockWSClient.events.message(stringify({topic: 'bulb_color/set', payload: {state: 'ON'}}), false);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb_color',
|
||||
stringify({
|
||||
state: 'ON',
|
||||
power_on_behavior: null,
|
||||
linkquality: null,
|
||||
update_available: null,
|
||||
update: {state: null, installed_version: -1, latest_version: -1},
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
mockWSClient.events.message(undefined, false);
|
||||
mockWSClient.events.message('', false);
|
||||
mockWSClient.events.message(null, false);
|
||||
await flushPromises();
|
||||
|
||||
// Error
|
||||
mockWSClient.events.error(new Error('This is an error'));
|
||||
expect(logger.error).toHaveBeenCalledWith('WebSocket error: This is an error');
|
||||
|
||||
// Received message on socket
|
||||
expect(mockWSClient.implementation.send).toHaveBeenCalledTimes(1);
|
||||
expect(mockWSClient.implementation.send).toHaveBeenCalledWith(
|
||||
stringify({
|
||||
topic: 'bulb_color',
|
||||
payload: {
|
||||
state: 'ON',
|
||||
power_on_behavior: null,
|
||||
linkquality: null,
|
||||
update_available: null,
|
||||
update: {state: null, installed_version: -1, latest_version: -1},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Shouldnt set when not ready
|
||||
mockWSClient.implementation.send.mockClear();
|
||||
mockWSClient.implementation.readyState = 'close';
|
||||
mockWSClient.events.message(stringify({topic: 'bulb_color/set', payload: {state: 'ON'}}), false);
|
||||
expect(mockWSClient.implementation.send).toHaveBeenCalledTimes(0);
|
||||
|
||||
// Send last seen on connect
|
||||
mockWSClient.implementation.send.mockClear();
|
||||
mockWSClient.implementation.readyState = 'open';
|
||||
settings.set(['advanced'], {last_seen: 'ISO_8601'});
|
||||
mockWS.implementation.clients.push(mockWSClient.implementation);
|
||||
await mockWS.events.connection(mockWSClient.implementation);
|
||||
expect(mockWSClient.implementation.send).toHaveBeenCalledWith(
|
||||
stringify({topic: 'remote', payload: {brightness: 255, last_seen: '1970-01-01T00:00:01.000Z'}}),
|
||||
);
|
||||
});
|
||||
|
||||
it('onReques/onUpgrade', async () => {
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
|
||||
const mockSocket = {destroy: jest.fn()};
|
||||
mockHTTP.events.upgrade({url: 'http://localhost:8080/api'}, mockSocket, 3);
|
||||
expect(mockWS.implementation.handleUpgrade).toHaveBeenCalledTimes(1);
|
||||
expect(mockSocket.destroy).toHaveBeenCalledTimes(0);
|
||||
expect(mockWS.implementation.handleUpgrade).toHaveBeenCalledWith({url: 'http://localhost:8080/api'}, mockSocket, 3, expect.any(Function));
|
||||
mockWS.implementation.handleUpgrade.mock.calls[0][3](99);
|
||||
expect(mockWS.implementation.emit).toHaveBeenCalledWith('connection', 99, {url: 'http://localhost:8080/api'});
|
||||
|
||||
mockHTTP.variables.onRequest({url: '/file.txt'}, 2);
|
||||
expect(mockNodeStatic.implementation).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic.implementation).toHaveBeenCalledWith({originalUrl: '/file.txt', url: '/file.txt'}, 2, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Static server', async () => {
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
|
||||
expect(mockHTTP.implementation.listen).toHaveBeenCalledWith(8081, '127.0.0.1');
|
||||
});
|
||||
|
||||
it('Authentification', async () => {
|
||||
const authToken = 'sample-secure-token';
|
||||
settings.set(['frontend'], {auth_token: authToken});
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
|
||||
const mockSocket = {destroy: jest.fn()};
|
||||
mockHTTP.events.upgrade({url: '/api'}, mockSocket, mockWSocket);
|
||||
expect(mockWS.implementation.handleUpgrade).toHaveBeenCalledTimes(1);
|
||||
expect(mockSocket.destroy).toHaveBeenCalledTimes(0);
|
||||
expect(mockWS.implementation.handleUpgrade).toHaveBeenCalledWith({url: '/api'}, mockSocket, mockWSocket, expect.any(Function));
|
||||
expect(mockWSocket.close).toHaveBeenCalledWith(4401, 'Unauthorized');
|
||||
|
||||
mockWSocket.close.mockClear();
|
||||
mockWS.implementation.emit.mockClear();
|
||||
|
||||
const url = `/api?token=${authToken}`;
|
||||
mockWS.implementation.handleUpgrade.mockClear();
|
||||
mockHTTP.events.upgrade({url: url}, mockSocket, 3);
|
||||
expect(mockWS.implementation.handleUpgrade).toHaveBeenCalledTimes(1);
|
||||
expect(mockSocket.destroy).toHaveBeenCalledTimes(0);
|
||||
expect(mockWS.implementation.handleUpgrade).toHaveBeenCalledWith({url}, mockSocket, 3, expect.any(Function));
|
||||
expect(mockWSocket.close).toHaveBeenCalledTimes(0);
|
||||
mockWS.implementation.handleUpgrade.mock.calls[0][3](mockWSocket);
|
||||
expect(mockWS.implementation.emit).toHaveBeenCalledWith('connection', mockWSocket, {url});
|
||||
});
|
||||
|
||||
it.each(['/z2m/', '/z2m'])('Works with non-default base url %s', async (baseUrl) => {
|
||||
settings.set(['frontend'], {base_url: baseUrl});
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
|
||||
expect(ws.Server).toHaveBeenCalledWith({noServer: true, path: '/z2m/api'});
|
||||
|
||||
mockHTTP.variables.onRequest({url: '/z2m'}, 2);
|
||||
expect(mockNodeStatic.implementation).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic.implementation).toHaveBeenCalledWith({originalUrl: '/z2m', url: '/'}, 2, expect.any(Function));
|
||||
expect(mockFinalHandler.implementation).not.toHaveBeenCalledWith();
|
||||
|
||||
mockNodeStatic.implementation.mockReset();
|
||||
expect(mockFinalHandler.implementation).not.toHaveBeenCalledWith();
|
||||
mockHTTP.variables.onRequest({url: '/z2m/file.txt'}, 2);
|
||||
expect(mockNodeStatic.implementation).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic.implementation).toHaveBeenCalledWith({originalUrl: '/z2m/file.txt', url: '/file.txt'}, 2, expect.any(Function));
|
||||
expect(mockFinalHandler.implementation).not.toHaveBeenCalledWith();
|
||||
|
||||
mockNodeStatic.implementation.mockReset();
|
||||
mockHTTP.variables.onRequest({url: '/z/file.txt'}, 2);
|
||||
expect(mockNodeStatic.implementation).not.toHaveBeenCalled();
|
||||
expect(mockFinalHandler.implementation).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Works with non-default complex base url', async () => {
|
||||
const baseUrl = '/z2m-more++/c0mplex.url/';
|
||||
settings.set(['frontend'], {base_url: baseUrl});
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
|
||||
expect(ws.Server).toHaveBeenCalledWith({noServer: true, path: '/z2m-more++/c0mplex.url/api'});
|
||||
|
||||
mockHTTP.variables.onRequest({url: '/z2m-more++/c0mplex.url'}, 2);
|
||||
expect(mockNodeStatic.implementation).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic.implementation).toHaveBeenCalledWith({originalUrl: '/z2m-more++/c0mplex.url', url: '/'}, 2, expect.any(Function));
|
||||
expect(mockFinalHandler.implementation).not.toHaveBeenCalledWith();
|
||||
|
||||
mockNodeStatic.implementation.mockReset();
|
||||
expect(mockFinalHandler.implementation).not.toHaveBeenCalledWith();
|
||||
mockHTTP.variables.onRequest({url: '/z2m-more++/c0mplex.url/file.txt'}, 2);
|
||||
expect(mockNodeStatic.implementation).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic.implementation).toHaveBeenCalledWith(
|
||||
{originalUrl: '/z2m-more++/c0mplex.url/file.txt', url: '/file.txt'},
|
||||
2,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockFinalHandler.implementation).not.toHaveBeenCalledWith();
|
||||
|
||||
mockNodeStatic.implementation.mockReset();
|
||||
mockHTTP.variables.onRequest({url: '/z/file.txt'}, 2);
|
||||
expect(mockNodeStatic.implementation).not.toHaveBeenCalled();
|
||||
expect(mockFinalHandler.implementation).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
-1111
File diff suppressed because it is too large
Load Diff
@@ -1,538 +0,0 @@
|
||||
const data = require('../stub/data');
|
||||
const logger = require('../stub/logger');
|
||||
const zigbeeHerdsman = require('../stub/zigbeeHerdsman');
|
||||
const MQTT = require('../stub/mqtt');
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
const path = require('path');
|
||||
const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {});
|
||||
const settings = require('../../lib/util/settings');
|
||||
const Controller = require('../../lib/controller');
|
||||
const flushPromises = require('../lib/flushPromises');
|
||||
|
||||
describe('Bridge legacy', () => {
|
||||
let controller;
|
||||
let version;
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.useFakeTimers();
|
||||
version = await require('../../lib/util/utils').default.getZigbee2MQTTVersion();
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
logger.info.mockClear();
|
||||
logger.warning.mockClear();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('Should publish bridge configuration on startup', async () => {
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/config',
|
||||
stringify({
|
||||
version: version.version,
|
||||
commit: version.commitHash,
|
||||
coordinator: {type: 'z-Stack', meta: {version: 1, revision: 20190425}},
|
||||
network: {panID: 5674, extendedPanID: [0, 11, 22], channel: 15},
|
||||
log_level: 'info',
|
||||
permit_join: false,
|
||||
}),
|
||||
{retain: true, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should allow to set elapsed', async () => {
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/elapsed', 'true');
|
||||
await flushPromises();
|
||||
expect(settings.get().advanced.elapsed).toBe(true);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/elapsed', 'false');
|
||||
await flushPromises();
|
||||
expect(settings.get().advanced.elapsed).toBe(false);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/elapsed', 'wrong');
|
||||
await flushPromises();
|
||||
expect(settings.get().advanced.elapsed).toBe(false);
|
||||
});
|
||||
|
||||
it('Should allow whitelist', async () => {
|
||||
const bulb_color = zigbeeHerdsman.devices.bulb_color;
|
||||
const bulb = zigbeeHerdsman.devices.bulb;
|
||||
expect(settings.get().passlist).toStrictEqual([]);
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/whitelist', 'bulb_color');
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/log',
|
||||
stringify({type: 'device_whitelisted', message: {friendly_name: 'bulb_color'}}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
MQTT.publish.mockClear();
|
||||
expect(settings.get().passlist).toStrictEqual([bulb_color.ieeeAddr]);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/whitelist', 'bulb');
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/log',
|
||||
stringify({type: 'device_whitelisted', message: {friendly_name: 'bulb'}}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
MQTT.publish.mockClear();
|
||||
expect(settings.get().passlist).toStrictEqual([bulb_color.ieeeAddr, bulb.ieeeAddr]);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/whitelist', 'bulb');
|
||||
await flushPromises();
|
||||
expect(settings.get().passlist).toStrictEqual([bulb_color.ieeeAddr, bulb.ieeeAddr]);
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should allow changing device options', async () => {
|
||||
const bulb_color = zigbeeHerdsman.devices.bulb_color;
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual({ID: '0x000b57fffec6a5b3', friendly_name: 'bulb_color', retain: false});
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/device_options', stringify({friendly_name: 'bulb_color', options: {retain: true}}));
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual({ID: '0x000b57fffec6a5b3', friendly_name: 'bulb_color', retain: true});
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/device_options', stringify({friendly_name: 'bulb_color', optionswrong: {retain: true}}));
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual({ID: '0x000b57fffec6a5b3', friendly_name: 'bulb_color', retain: true});
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/device_options', "{friendly_name: 'bulb_color'malformed: {retain: true}}");
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual({ID: '0x000b57fffec6a5b3', friendly_name: 'bulb_color', retain: true});
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/device_options', stringify({friendly_name: 'bulb_color', options: {random_setting: true}}));
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual({
|
||||
ID: '0x000b57fffec6a5b3',
|
||||
friendly_name: 'bulb_color',
|
||||
random_setting: true,
|
||||
retain: true,
|
||||
});
|
||||
MQTT.events.message(
|
||||
'zigbee2mqtt/bridge/config/device_options',
|
||||
stringify({friendly_name: 'bulb_color', options: {options: {random_1: true}}}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual({
|
||||
ID: '0x000b57fffec6a5b3',
|
||||
friendly_name: 'bulb_color',
|
||||
random_setting: true,
|
||||
retain: true,
|
||||
options: {random_1: true},
|
||||
});
|
||||
MQTT.events.message(
|
||||
'zigbee2mqtt/bridge/config/device_options',
|
||||
stringify({friendly_name: 'bulb_color', options: {options: {random_2: false}}}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual({
|
||||
ID: '0x000b57fffec6a5b3',
|
||||
friendly_name: 'bulb_color',
|
||||
random_setting: true,
|
||||
retain: true,
|
||||
options: {random_1: true, random_2: false},
|
||||
});
|
||||
});
|
||||
|
||||
it('Should allow permit join', async () => {
|
||||
zigbeeHerdsman.permitJoin.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/permit_join', 'true');
|
||||
await flushPromises();
|
||||
expect(zigbeeHerdsman.permitJoin).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsman.permitJoin).toHaveBeenCalledWith(true, undefined, undefined);
|
||||
zigbeeHerdsman.permitJoin.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/permit_join', 'false');
|
||||
await flushPromises();
|
||||
expect(zigbeeHerdsman.permitJoin).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsman.permitJoin).toHaveBeenCalledWith(false, undefined, undefined);
|
||||
});
|
||||
|
||||
it('Should allow to reset', async () => {
|
||||
zigbeeHerdsman.reset.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/reset', '');
|
||||
await flushPromises();
|
||||
expect(zigbeeHerdsman.reset).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsman.reset).toHaveBeenCalledWith('soft');
|
||||
zigbeeHerdsman.reset.mockImplementationOnce(() => {
|
||||
throw new Error('');
|
||||
});
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/reset', '');
|
||||
await flushPromises();
|
||||
expect(zigbeeHerdsman.reset).toHaveBeenCalledTimes(2);
|
||||
expect(zigbeeHerdsman.reset.mock.calls[1][0]).toBe('soft');
|
||||
});
|
||||
|
||||
it('Should allow to set last_seen', async () => {
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/last_seen', 'ISO_8601');
|
||||
await flushPromises();
|
||||
expect(settings.get().advanced.last_seen).toBe('ISO_8601');
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/last_seen', 'disable');
|
||||
await flushPromises();
|
||||
expect(settings.get().advanced.last_seen).toBe('disable');
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/last_seen', 'notvalid');
|
||||
await flushPromises();
|
||||
expect(settings.get().advanced.last_seen).toBe('disable');
|
||||
});
|
||||
|
||||
it('Should allow to set log_level', async () => {
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/log_level', 'debug');
|
||||
await flushPromises();
|
||||
expect(logger.getLevel()).toBe('debug');
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/log_level', 'error');
|
||||
await flushPromises();
|
||||
expect(logger.getLevel()).toBe('error');
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/log_level', 'notvalid');
|
||||
await flushPromises();
|
||||
expect(logger.getLevel()).toBe('error');
|
||||
});
|
||||
|
||||
it('Should allow to get devices', async () => {
|
||||
const now = Date.now;
|
||||
Date.now = () => 100;
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/devices/get', '');
|
||||
await flushPromises();
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/config/devices');
|
||||
const payload = JSON.parse(MQTT.publish.mock.calls[0][1]);
|
||||
expect(payload.length).toStrictEqual(Object.values(zigbeeHerdsman.devices).length);
|
||||
expect(payload[1]).toStrictEqual({
|
||||
ieeeAddr: '0x00124b00120144ae',
|
||||
type: 'Coordinator',
|
||||
dateCode: '20190425',
|
||||
friendly_name: 'Coordinator',
|
||||
networkAddress: 0,
|
||||
softwareBuildID: 'z-Stack',
|
||||
lastSeen: 100,
|
||||
});
|
||||
expect(payload[2]).toStrictEqual({
|
||||
dateCode: null,
|
||||
friendly_name: 'bulb',
|
||||
ieeeAddr: '0x000b57fffec6a5b2',
|
||||
lastSeen: 1000,
|
||||
manufacturerID: 4476,
|
||||
model: 'LED1545G12',
|
||||
modelID: 'TRADFRI bulb E27 WS opal 980lm',
|
||||
networkAddress: 40369,
|
||||
powerSource: 'Mains (single phase)',
|
||||
softwareBuildID: null,
|
||||
type: 'Router',
|
||||
description: 'TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm',
|
||||
vendor: 'IKEA',
|
||||
});
|
||||
Date.now = now;
|
||||
});
|
||||
|
||||
it('Should allow to get groups', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/groups', '');
|
||||
await flushPromises();
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log');
|
||||
const payload = JSON.parse(MQTT.publish.mock.calls[0][1]);
|
||||
expect(payload).toStrictEqual({
|
||||
message: [
|
||||
{ID: 1, devices: [], friendly_name: 'group_1', retain: false},
|
||||
{ID: 2, devices: [], friendly_name: 'group_2', retain: false},
|
||||
{ID: 9, devices: ['bulb_color_2', 'bulb_2', 'wall_switch_double/right'], friendly_name: 'ha_discovery_group'},
|
||||
{ID: 11, devices: ['bulb_2'], friendly_name: 'group_with_tradfri', retain: false},
|
||||
{ID: 12, devices: ['TS0601_thermostat'], friendly_name: 'thermostat_group', retain: false},
|
||||
{ID: 14, devices: ['power_plug', 'bulb_2'], friendly_name: 'switch_group', retain: false},
|
||||
{ID: 21, devices: ['GLEDOPTO_2ID/cct'], friendly_name: 'gledopto_group'},
|
||||
{ID: 15071, devices: ['bulb_color_2', 'bulb_2'], friendly_name: 'group_tradfri_remote', retain: false},
|
||||
],
|
||||
type: 'groups',
|
||||
});
|
||||
});
|
||||
|
||||
it('Should allow rename devices', async () => {
|
||||
const bulb_color2 = {ID: '0x000b57fffec6a5b3', friendly_name: 'bulb_color2', retain: false};
|
||||
MQTT.publish.mockClear();
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual({ID: '0x000b57fffec6a5b3', friendly_name: 'bulb_color', retain: false});
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/rename', stringify({old: 'bulb_color', new: 'bulb_color2'}));
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('bulb_color')).toBeUndefined();
|
||||
expect(settings.getDevice('bulb_color2')).toStrictEqual(bulb_color2);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/log',
|
||||
stringify({type: 'device_renamed', message: {from: 'bulb_color', to: 'bulb_color2'}}),
|
||||
{qos: 0, retain: false},
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/rename', stringify({old: 'bulb_color2', newmalformed: 'bulb_color3'}));
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('bulb_color2')).toStrictEqual(bulb_color2);
|
||||
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/rename', "{old: 'bulb_color2'newmalformed: 'bulb_color3'}");
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('bulb_color2')).toStrictEqual(bulb_color2);
|
||||
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/rename', stringify({old: 'bulb_color', new: 'bulb_color3'}));
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('bulb_color2')).toStrictEqual(bulb_color2);
|
||||
});
|
||||
|
||||
it('Should allow rename groups', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
expect(settings.getGroup(1)).toStrictEqual({ID: 1, devices: [], friendly_name: 'group_1', retain: false});
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/rename', stringify({old: 'group_1', new: 'group_1_renamed'}));
|
||||
await flushPromises();
|
||||
expect(settings.getGroup(1)).toStrictEqual({ID: 1, devices: [], friendly_name: 'group_1_renamed', retain: false});
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/log',
|
||||
stringify({type: 'group_renamed', message: {from: 'group_1', to: 'group_1_renamed'}}),
|
||||
{qos: 0, retain: false},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should allow to rename last joined device', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb;
|
||||
const payload = {device};
|
||||
await zigbeeHerdsman.events.deviceJoined(payload);
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('0x000b57fffec6a5b2').friendly_name).toStrictEqual('bulb');
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/rename_last', 'bulb_new_name');
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('0x000b57fffec6a5b2').friendly_name).toStrictEqual('bulb_new_name');
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/log',
|
||||
stringify({type: 'device_renamed', message: {from: 'bulb', to: 'bulb_new_name'}}),
|
||||
{qos: 0, retain: false},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Shouldnt rename when no device has been joined', async () => {
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('0x000b57fffec6a5b2').friendly_name).toStrictEqual('bulb');
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/rename_last', 'bulb_new_name');
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('0x000b57fffec6a5b2').friendly_name).toStrictEqual('bulb');
|
||||
});
|
||||
|
||||
it('Should allow to add groups', async () => {
|
||||
zigbeeHerdsman.createGroup.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/add_group', 'new_group');
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/log',
|
||||
stringify({type: 'group_added', message: 'new_group'}),
|
||||
{qos: 0, retain: false},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(settings.getGroup('new_group')).toStrictEqual({ID: 3, friendly_name: 'new_group', devices: []});
|
||||
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
it('Should allow to add groups with json', async () => {
|
||||
zigbeeHerdsman.createGroup.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/add_group', '{"friendly_name": "new_group"}');
|
||||
await flushPromises();
|
||||
expect(settings.getGroup('new_group')).toStrictEqual({ID: 3, friendly_name: 'new_group', devices: []});
|
||||
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledWith(3);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/log',
|
||||
stringify({type: 'group_added', message: 'new_group'}),
|
||||
{qos: 0, retain: false},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should allow to add groups with json specifying id', async () => {
|
||||
zigbeeHerdsman.createGroup.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/add_group', '{"friendly_name": "new_group", "id": 42}');
|
||||
await flushPromises();
|
||||
expect(settings.getGroup('new_group')).toStrictEqual({ID: 42, friendly_name: 'new_group', devices: []});
|
||||
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledWith(42);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/log',
|
||||
stringify({type: 'group_added', message: 'new_group'}),
|
||||
{qos: 0, retain: false},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should allow to add groups with json specifying only id', async () => {
|
||||
zigbeeHerdsman.createGroup.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/add_group', '{"id": 42}');
|
||||
await flushPromises();
|
||||
expect(settings.getGroup('group_42')).toStrictEqual({ID: 42, friendly_name: 'group_42', devices: []});
|
||||
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledWith(42);
|
||||
});
|
||||
|
||||
it('Should allow to remove groups', async () => {
|
||||
const group = zigbeeHerdsman.groups.group_1;
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/remove_group', 'group_1');
|
||||
await flushPromises();
|
||||
expect(settings.getGroup('to_be_removed')).toBeUndefined();
|
||||
expect(group.removeFromNetwork).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/log',
|
||||
stringify({type: 'group_removed', message: 'group_1'}),
|
||||
{qos: 0, retain: false},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should allow to force remove groups', async () => {
|
||||
const group = zigbeeHerdsman.groups.group_1;
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/force_remove_group', 'group_1');
|
||||
await flushPromises();
|
||||
expect(settings.getGroup('to_be_removed')).toBeUndefined();
|
||||
expect(group.removeFromDatabase).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/log',
|
||||
stringify({type: 'group_removed', message: 'group_1'}),
|
||||
{qos: 0, retain: false},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Shouldnt allow add groups without id or friendly_name in json', async () => {
|
||||
zigbeeHerdsman.createGroup.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/add_group', '{}');
|
||||
await flushPromises();
|
||||
expect(logger.error).toHaveBeenCalledWith('Failed to add group, missing friendly_name!');
|
||||
});
|
||||
|
||||
it('Shouldnt do anything on unsupported topic', async () => {
|
||||
await flushPromises();
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/not_supported', 'to_be_removed');
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should allow to remove device', async () => {
|
||||
controller.state.state = {'0x000b57fffec6a5b3': {brightness: 100}};
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
device.removeFromNetwork.mockClear();
|
||||
expect(settings.get().blocklist.length).toBe(0);
|
||||
await flushPromises();
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/remove', 'bulb_color');
|
||||
await flushPromises();
|
||||
expect(device.removeFromNetwork).toHaveBeenCalledTimes(1);
|
||||
expect(controller.state[device.ieeeAddr]).toBeUndefined();
|
||||
expect(settings.getDevice('bulb_color')).toBeUndefined();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/log',
|
||||
stringify({type: 'device_removed', message: 'bulb_color'}),
|
||||
{qos: 0, retain: false},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(controller.state.state).toStrictEqual({});
|
||||
expect(settings.get().blocklist.length).toBe(0);
|
||||
});
|
||||
|
||||
it('Should allow to force remove device', async () => {
|
||||
controller.state.state = {'0x000b57fffec6a5b3': {brightness: 100}};
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
device.removeFromDatabase.mockClear();
|
||||
expect(settings.get().blocklist.length).toBe(0);
|
||||
await flushPromises();
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/force_remove', 'bulb_color');
|
||||
await flushPromises();
|
||||
expect(device.removeFromDatabase).toHaveBeenCalledTimes(1);
|
||||
expect(controller.state[device.ieeeAddr]).toBeUndefined();
|
||||
expect(settings.getDevice('bulb_color')).toBeUndefined();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/log',
|
||||
stringify({type: 'device_force_removed', message: 'bulb_color'}),
|
||||
{qos: 0, retain: false},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(controller.state.state).toStrictEqual({});
|
||||
expect(settings.get().blocklist.length).toBe(0);
|
||||
});
|
||||
|
||||
it('Should allow to block device', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
device.removeFromNetwork.mockClear();
|
||||
expect(settings.get().blocklist.length).toBe(0);
|
||||
await flushPromises();
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/ban', 'bulb_color');
|
||||
await flushPromises();
|
||||
expect(device.removeFromNetwork).toHaveBeenCalledTimes(1);
|
||||
expect(controller.state[device.ieeeAddr]).toBeUndefined();
|
||||
expect(settings.getDevice('bulb_color')).toBeUndefined();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/log',
|
||||
stringify({type: 'device_banned', message: 'bulb_color'}),
|
||||
{qos: 0, retain: false},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(settings.get().blocklist).toStrictEqual(['0x000b57fffec6a5b3']);
|
||||
});
|
||||
|
||||
it('Shouldnt crash when removing non-existing device', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/remove', 'not_existing_123');
|
||||
await flushPromises();
|
||||
expect(logger.error).toHaveBeenCalledWith(`Cannot remove, device 'not_existing_123' does not exist`);
|
||||
});
|
||||
|
||||
it('Should handle when remove fails', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
device.removeFromNetwork.mockClear();
|
||||
device.removeFromNetwork.mockImplementationOnce(() => {
|
||||
throw new Error('');
|
||||
});
|
||||
await flushPromises();
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/remove', 'bulb_color');
|
||||
await flushPromises();
|
||||
expect(device.removeFromNetwork).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual({ID: '0x000b57fffec6a5b3', friendly_name: 'bulb_color', retain: false});
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should handle when ban fails', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
device.removeFromNetwork.mockClear();
|
||||
device.removeFromNetwork.mockImplementationOnce(() => {
|
||||
throw new Error('');
|
||||
});
|
||||
await flushPromises();
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/ban', 'bulb_color');
|
||||
await flushPromises();
|
||||
expect(device.removeFromNetwork).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual({ID: '0x000b57fffec6a5b3', friendly_name: 'bulb_color', retain: false});
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should allow to touchlink factory reset (OK)', async () => {
|
||||
zigbeeHerdsman.touchlinkFactoryResetFirst.mockClear();
|
||||
|
||||
zigbeeHerdsman.touchlinkFactoryResetFirst.mockReturnValueOnce(true);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/touchlink/factory_reset', '');
|
||||
await flushPromises();
|
||||
expect(zigbeeHerdsman.touchlinkFactoryResetFirst).toHaveBeenCalledTimes(1);
|
||||
expect(logger.info).toHaveBeenCalledWith('Successfully factory reset device through Touchlink');
|
||||
});
|
||||
|
||||
it('Should allow to touchlink factory reset (FAILS)', async () => {
|
||||
zigbeeHerdsman.touchlinkFactoryResetFirst.mockClear();
|
||||
|
||||
zigbeeHerdsman.touchlinkFactoryResetFirst.mockReturnValueOnce(false);
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/touchlink/factory_reset', '');
|
||||
await flushPromises();
|
||||
expect(zigbeeHerdsman.touchlinkFactoryResetFirst).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warning).toHaveBeenCalledWith('Failed to factory reset device through Touchlink');
|
||||
});
|
||||
});
|
||||
@@ -1,315 +0,0 @@
|
||||
const data = require('../stub/data');
|
||||
const logger = require('../stub/logger');
|
||||
const zigbeeHerdsman = require('../stub/zigbeeHerdsman');
|
||||
zigbeeHerdsman.returnDevices.push('0x000b57fffec6a5b3');
|
||||
zigbeeHerdsman.returnDevices.push('0x00124b00120144ae');
|
||||
zigbeeHerdsman.returnDevices.push('0x000b57fffec6a5b2');
|
||||
zigbeeHerdsman.returnDevices.push('0x0017880104e45553');
|
||||
zigbeeHerdsman.returnDevices.push('0x0017880104e45559');
|
||||
zigbeeHerdsman.returnDevices.push('0x000b57fffec6a5b4');
|
||||
zigbeeHerdsman.returnDevices.push('0x000b57fffec6a5b7');
|
||||
zigbeeHerdsman.returnDevices.push('0x0017880104e45524');
|
||||
zigbeeHerdsman.returnDevices.push('0x90fd9ffffe4b64ax');
|
||||
const MQTT = require('../stub/mqtt');
|
||||
const settings = require('../../lib/util/settings');
|
||||
const Controller = require('../../lib/controller');
|
||||
const flushPromises = require('../lib/flushPromises');
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const mocksClear = [MQTT.publish, logger.warning, logger.debug];
|
||||
|
||||
describe('Report', () => {
|
||||
let controller;
|
||||
let extension;
|
||||
|
||||
function expectOnOffBrightnessColorReport(endpoint, colorXY) {
|
||||
const coordinatorEndpoint = zigbeeHerdsman.devices.coordinator.getEndpoint(1);
|
||||
const device = endpoint.getDevice();
|
||||
expect(device.meta.reporting).toBe(1);
|
||||
expect(endpoint.unbind).toHaveBeenCalledTimes(0);
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', coordinatorEndpoint);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', coordinatorEndpoint);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('lightingColorCtrl', coordinatorEndpoint);
|
||||
expect(endpoint.configureReporting).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.configureReporting).toHaveBeenCalledWith('genOnOff', [
|
||||
{attribute: 'onOff', maximumReportInterval: 300, minimumReportInterval: 0, reportableChange: 0},
|
||||
]);
|
||||
expect(endpoint.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
|
||||
{attribute: 'currentLevel', maximumReportInterval: 300, minimumReportInterval: 3, reportableChange: 1},
|
||||
]);
|
||||
if (colorXY) {
|
||||
expect(endpoint.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
|
||||
{attribute: 'colorTemperature', maximumReportInterval: 300, minimumReportInterval: 3, reportableChange: 1},
|
||||
{attribute: 'currentX', maximumReportInterval: 300, minimumReportInterval: 3, reportableChange: 1},
|
||||
{attribute: 'currentY', maximumReportInterval: 300, minimumReportInterval: 3, reportableChange: 1},
|
||||
]);
|
||||
} else {
|
||||
expect(endpoint.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
|
||||
{attribute: 'colorTemperature', maximumReportInterval: 300, minimumReportInterval: 3, reportableChange: 1},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function expectOnOffBrightnessColorReportDisabled(endpoint, colorXY) {
|
||||
const coordinatorEndpoint = zigbeeHerdsman.devices.coordinator.getEndpoint(1);
|
||||
const device = endpoint.getDevice();
|
||||
expect(device.meta.reporting).toBe(undefined);
|
||||
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(0);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', coordinatorEndpoint);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', coordinatorEndpoint);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('lightingColorCtrl', coordinatorEndpoint);
|
||||
expect(endpoint.configureReporting).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.configureReporting).toHaveBeenCalledWith('genOnOff', [
|
||||
{attribute: 'onOff', maximumReportInterval: 0xffff, minimumReportInterval: 0, reportableChange: 0},
|
||||
]);
|
||||
expect(endpoint.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
|
||||
{attribute: 'currentLevel', maximumReportInterval: 0xffff, minimumReportInterval: 3, reportableChange: 1},
|
||||
]);
|
||||
if (colorXY) {
|
||||
expect(endpoint.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
|
||||
{attribute: 'colorTemperature', maximumReportInterval: 0xffff, minimumReportInterval: 3, reportableChange: 1},
|
||||
{attribute: 'currentX', maximumReportInterval: 0xffff, minimumReportInterval: 3, reportableChange: 1},
|
||||
{attribute: 'currentY', maximumReportInterval: 0xffff, minimumReportInterval: 3, reportableChange: 1},
|
||||
]);
|
||||
} else {
|
||||
expect(endpoint.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
|
||||
{attribute: 'colorTemperature', maximumReportInterval: 0xffff, minimumReportInterval: 3, reportableChange: 1},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
const mockClear = (device) => {
|
||||
for (const endpoint of device.endpoints) {
|
||||
endpoint.read.mockClear();
|
||||
endpoint.write.mockClear();
|
||||
endpoint.configureReporting.mockClear();
|
||||
endpoint.bind.mockClear();
|
||||
endpoint.unbind.mockClear();
|
||||
}
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.useFakeTimers();
|
||||
settings.set(['advanced', 'report'], true);
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
extension = controller.extensions.find((e) => e.constructor.name === 'Report');
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
extension.enabled = true;
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
for (const device of Object.values(zigbeeHerdsman.devices)) {
|
||||
mockClear(device);
|
||||
delete device.meta.reporting;
|
||||
}
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
extension.queue = new Set();
|
||||
extension.failed = new Set();
|
||||
await extension.start();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('Should configure reporting on startup', async () => {
|
||||
await extension.start();
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
expectOnOffBrightnessColorReport(endpoint, true);
|
||||
});
|
||||
|
||||
it('Should not configure reporting on startup when disabled', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
delete device.meta.report;
|
||||
extension.enabled = false;
|
||||
await extension.start();
|
||||
expect(device.meta.reporting).toBe(undefined);
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should disable reporting on startup when enabled earlier', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_color;
|
||||
device.meta.reporting = 1;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
extension.enabled = false;
|
||||
mockClear(device);
|
||||
await extension.start();
|
||||
expectOnOffBrightnessColorReportDisabled(endpoint, true);
|
||||
});
|
||||
|
||||
it('Should configure reporting when receicing message from device which has not been setup yet', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
device.save.mockClear();
|
||||
mockClear(device);
|
||||
delete device.meta.reporting;
|
||||
const data = {onOff: 1};
|
||||
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await flushPromises();
|
||||
expectOnOffBrightnessColorReport(endpoint, false);
|
||||
expect(device.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should not configure reporting when still configuring', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
endpoint.bind.mockImplementationOnce(async () => await wait(1000));
|
||||
delete device.meta.reporting;
|
||||
mockClear(device);
|
||||
const payload = {data: {onOff: 1}, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await flushPromises();
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should not mark as configured when reporting setup fails', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
endpoint.bind.mockImplementationOnce(async () => {
|
||||
throw new Error('failed');
|
||||
});
|
||||
delete device.meta.reporting;
|
||||
mockClear(device);
|
||||
const payload = {data: {onOff: 1}, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(1);
|
||||
expect(device.meta.reporting).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Should not configure reporting when interviewing', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_2;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
device.interviewing = true;
|
||||
delete device.meta.reporting;
|
||||
mockClear(device);
|
||||
const payload = {data: {onOff: 1}, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(0);
|
||||
expect(endpoint.configureReporting).toHaveBeenCalledTimes(0);
|
||||
expect(device.meta.reporting).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Should not configure reporting when receicing message from device which has already been setup yet', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
const data = {onOff: 1};
|
||||
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(0);
|
||||
expect(endpoint.configureReporting).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should not configure reporting for end devices', async () => {
|
||||
const device = zigbeeHerdsman.devices.E11_G13;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(0);
|
||||
expect(endpoint.configureReporting).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should configure reporting when deviceAnnounce message from IKEA device', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb_2;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
mockClear(device);
|
||||
const payload = {device};
|
||||
await zigbeeHerdsman.events.deviceAnnounce(payload);
|
||||
await flushPromises();
|
||||
expectOnOffBrightnessColorReport(endpoint, false);
|
||||
});
|
||||
|
||||
it('Should not configure reporting on device leave', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
delete device.meta.reporting;
|
||||
mockClear(device);
|
||||
await zigbeeHerdsman.events.deviceLeave({ieeeAddr: device.ieeeAddr});
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(0);
|
||||
expect(endpoint.configureReporting).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should not configure reporting for the CC2530 router', async () => {
|
||||
const device = zigbeeHerdsman.devices.CC2530_ROUTER;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
delete device.meta.reporting;
|
||||
mockClear(device);
|
||||
await zigbeeHerdsman.events.deviceLeave({ieeeAddr: device.ieeeAddr});
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(0);
|
||||
expect(endpoint.configureReporting).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should not configure reporting again when it already failed once', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb;
|
||||
const endpoint = device.getEndpoint(1);
|
||||
endpoint.bind.mockImplementationOnce(async () => {
|
||||
throw new Error('failed');
|
||||
});
|
||||
delete device.meta.reporting;
|
||||
mockClear(device);
|
||||
const payload = {data: {onOff: 1}, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await flushPromises();
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should not configure reporting for the ZNLDP12LM closuresWindowCovering as it is ignored', async () => {
|
||||
const device = zigbeeHerdsman.devices.ZNLDP12LM;
|
||||
const coordinatorEndpoint = zigbeeHerdsman.devices.coordinator.getEndpoint(1);
|
||||
const endpoint = device.getEndpoint(1);
|
||||
delete device.meta.reporting;
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', coordinatorEndpoint);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', coordinatorEndpoint);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('lightingColorCtrl', coordinatorEndpoint);
|
||||
expect(endpoint.configureReporting).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('Should not setup colorTemperature reporting when bulb does not support it and should read colorCapabilities when its not there yet ', async () => {
|
||||
const device = zigbeeHerdsman.devices.bulb;
|
||||
const coordinatorEndpoint = zigbeeHerdsman.devices.coordinator.getEndpoint(1);
|
||||
const endpoint = device.getEndpoint(1);
|
||||
const configuredReportings = endpoint.configuredReportings;
|
||||
endpoint.configuredReportings = [];
|
||||
delete device.meta.reporting;
|
||||
mockClear(device);
|
||||
endpoint.getClusterAttributeValue = jest.fn();
|
||||
|
||||
let count = 0;
|
||||
endpoint.getClusterAttributeValue.mockImplementation((d) => {
|
||||
count++;
|
||||
if (count === 1) return undefined;
|
||||
return 17;
|
||||
});
|
||||
|
||||
const payload = {device};
|
||||
await zigbeeHerdsman.events.deviceAnnounce(payload);
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', coordinatorEndpoint);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', coordinatorEndpoint);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('lightingColorCtrl', coordinatorEndpoint);
|
||||
expect(endpoint.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities']);
|
||||
expect(endpoint.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
|
||||
{attribute: 'colorTemperature', maximumReportInterval: 300, minimumReportInterval: 3, reportableChange: 1},
|
||||
]);
|
||||
expect(endpoint.configureReporting).toHaveBeenCalledTimes(3);
|
||||
endpoint.configuredReportings = configuredReportings;
|
||||
});
|
||||
});
|
||||
@@ -1,2 +0,0 @@
|
||||
const globalSetImmediate = setImmediate;
|
||||
module.exports = () => new Promise(globalSetImmediate);
|
||||
@@ -1,18 +1,30 @@
|
||||
const tmp = require('tmp');
|
||||
const dir = tmp.dirSync();
|
||||
let settings;
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const data = require('./stub/data');
|
||||
const {rimrafSync} = require('rimraf');
|
||||
const Transport = require('winston-transport');
|
||||
import * as data from './mocks/data';
|
||||
|
||||
import type {MockInstance} from 'vitest';
|
||||
|
||||
import fs from 'node:fs';
|
||||
import {platform} from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {rimrafSync} from 'rimraf';
|
||||
import tmp from 'tmp';
|
||||
import Transport from 'winston-transport';
|
||||
|
||||
import logger from '../lib/util/logger';
|
||||
import * as settings from '../lib/util/settings';
|
||||
|
||||
describe('Logger', () => {
|
||||
let logger;
|
||||
let consoleWriteSpy;
|
||||
let consoleWriteSpy: MockInstance;
|
||||
const dir = tmp.dirSync();
|
||||
|
||||
const getCachedNamespacedLevels = (): Record<string, string> => {
|
||||
// @ts-expect-error private
|
||||
return logger.cachedNamespacedLevels;
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
consoleWriteSpy = jest.spyOn(console._stdout, 'write').mockImplementation(() => {});
|
||||
// @ts-expect-error private
|
||||
consoleWriteSpy = vi.spyOn(console._stdout, 'write').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -21,11 +33,8 @@ describe('Logger', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
data.writeDefaultConfiguration();
|
||||
jest.resetModules();
|
||||
settings = require('../lib/util/settings');
|
||||
settings.set(['advanced', 'log_directory'], dir.name + '/%TIMESTAMP%');
|
||||
settings.reRead();
|
||||
logger = require('../lib/util/logger').default;
|
||||
settings.set(['advanced', 'log_directory'], dir.name + '/%TIMESTAMP%');
|
||||
logger.init();
|
||||
consoleWriteSpy.mockClear();
|
||||
});
|
||||
@@ -37,28 +46,47 @@ describe('Logger', () => {
|
||||
expect(dirs.length).toBe(1);
|
||||
});
|
||||
|
||||
it('Should cleanup', () => {
|
||||
it('Should cleanup (default setting)', () => {
|
||||
for (const d of fs.readdirSync(dir.name)) {
|
||||
rimrafSync(path.join(dir.name, d));
|
||||
}
|
||||
|
||||
for (let i = 0; i < 21; i++) {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
fs.mkdirSync(path.join(dir.name, `log_${i}`));
|
||||
}
|
||||
|
||||
expect(fs.readdirSync(dir.name).length).toBe(21);
|
||||
logger.cleanup();
|
||||
expect(fs.readdirSync(dir.name).length).toBe(20);
|
||||
logger.init();
|
||||
expect(fs.readdirSync(dir.name).length).toBe(10);
|
||||
});
|
||||
|
||||
it('Should cleanup (15 folders setting)', () => {
|
||||
for (const d of fs.readdirSync(dir.name)) {
|
||||
rimrafSync(path.join(dir.name, d));
|
||||
}
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
fs.mkdirSync(path.join(dir.name, `log_${i}`));
|
||||
}
|
||||
|
||||
settings.set(['advanced', 'log_directories_to_keep'], 15);
|
||||
expect(fs.readdirSync(dir.name).length).toBe(20);
|
||||
logger.init();
|
||||
expect(fs.readdirSync(dir.name).length).toBe(15);
|
||||
});
|
||||
|
||||
it('Should not cleanup when there is no timestamp set', () => {
|
||||
for (let i = 30; i < 40; i++) {
|
||||
for (const d of fs.readdirSync(dir.name)) {
|
||||
rimrafSync(path.join(dir.name, d));
|
||||
}
|
||||
|
||||
for (let i = 30; i < 50; i++) {
|
||||
fs.mkdirSync(path.join(dir.name, `log_${i}`));
|
||||
}
|
||||
|
||||
settings.set(['advanced', 'log_directory'], dir.name + '/bla');
|
||||
expect(fs.readdirSync(dir.name).length).toBe(21);
|
||||
logger.cleanup();
|
||||
expect(fs.readdirSync(dir.name).length).toBe(20);
|
||||
logger.init();
|
||||
expect(fs.readdirSync(dir.name).length).toBe(21);
|
||||
});
|
||||
|
||||
@@ -81,7 +109,7 @@ describe('Logger', () => {
|
||||
|
||||
it('Add/remove transport', () => {
|
||||
class DummyTransport extends Transport {
|
||||
log(info, callback) {}
|
||||
log(): void {}
|
||||
}
|
||||
|
||||
expect(logger.winston.transports.length).toBe(2);
|
||||
@@ -93,6 +121,7 @@ describe('Logger', () => {
|
||||
});
|
||||
|
||||
it('Logger should be console and file by default', () => {
|
||||
// @ts-expect-error private
|
||||
const pipes = logger.winston._readableState.pipes;
|
||||
expect(pipes.length).toBe(2);
|
||||
expect(pipes[0].constructor.name).toBe('Console');
|
||||
@@ -104,6 +133,7 @@ describe('Logger', () => {
|
||||
it('Logger can be file only', () => {
|
||||
settings.set(['advanced', 'log_output'], ['file']);
|
||||
logger.init();
|
||||
// @ts-expect-error private
|
||||
const pipes = logger.winston._readableState.pipes;
|
||||
expect(pipes.length).toBe(2);
|
||||
expect(pipes[0].constructor.name).toBe('Console');
|
||||
@@ -115,6 +145,7 @@ describe('Logger', () => {
|
||||
it('Logger can be console only', () => {
|
||||
settings.set(['advanced', 'log_output'], ['console']);
|
||||
logger.init();
|
||||
// @ts-expect-error private
|
||||
const pipes = logger.winston._readableState.pipes;
|
||||
expect(pipes.constructor.name).toBe('Console');
|
||||
expect(pipes.silent).toBe(false);
|
||||
@@ -123,6 +154,7 @@ describe('Logger', () => {
|
||||
it('Logger can be nothing', () => {
|
||||
settings.set(['advanced', 'log_output'], []);
|
||||
logger.init();
|
||||
// @ts-expect-error private
|
||||
const pipes = logger.winston._readableState.pipes;
|
||||
expect(pipes.constructor.name).toBe('Console');
|
||||
expect(pipes.silent).toBe(true);
|
||||
@@ -131,6 +163,7 @@ describe('Logger', () => {
|
||||
it('Should allow to disable log rotation', () => {
|
||||
settings.set(['advanced', 'log_rotation'], false);
|
||||
logger.init();
|
||||
// @ts-expect-error private
|
||||
const pipes = logger.winston._readableState.pipes;
|
||||
expect(pipes[1].constructor.name).toBe('File');
|
||||
expect(pipes[1].maxFiles).toBeNull();
|
||||
@@ -139,11 +172,17 @@ describe('Logger', () => {
|
||||
});
|
||||
|
||||
it('Should allow to symlink logs to current directory', () => {
|
||||
settings.set(['advanced', 'log_symlink_current'], true);
|
||||
logger.init();
|
||||
expect(fs.readdirSync(dir.name).includes('current')).toBeTruthy();
|
||||
try {
|
||||
settings.set(['advanced', 'log_symlink_current'], true);
|
||||
logger.init();
|
||||
expect(fs.readdirSync(dir.name).includes('current')).toBeTruthy();
|
||||
} catch (error) {
|
||||
if (platform() !== 'win32' || !(error as Error).message.startsWith('EPERM')) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
jest.resetModules();
|
||||
// ignore 'operation not permitted' failure on Windows
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -152,26 +191,31 @@ describe('Logger', () => {
|
||||
['warning', {higher: ['error'], lower: ['debug', 'info']}],
|
||||
['error', {higher: [], lower: ['debug', 'info', 'warning']}],
|
||||
])('Logs relevant levels for %s', (level, otherLevels) => {
|
||||
logger.setLevel(level);
|
||||
logger.setLevel(level as settings.LogLevel);
|
||||
|
||||
const logSpy = jest.spyOn(logger.winston, 'log');
|
||||
const logSpy = vi.spyOn(logger.winston, 'log');
|
||||
consoleWriteSpy.mockClear();
|
||||
let i = 1;
|
||||
|
||||
// @ts-expect-error dynamic
|
||||
logger[level]('msg');
|
||||
expect(logSpy).toHaveBeenLastCalledWith(level, 'z2m: msg');
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(i++);
|
||||
// @ts-expect-error dynamic
|
||||
logger[level]('msg', 'abcd');
|
||||
expect(logSpy).toHaveBeenLastCalledWith(level, 'abcd: msg');
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(i++);
|
||||
// @ts-expect-error dynamic
|
||||
logger[level](() => 'func msg', 'abcd');
|
||||
expect(logSpy).toHaveBeenLastCalledWith(level, 'abcd: func msg');
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(i++);
|
||||
|
||||
for (const higherLevel of otherLevels.higher) {
|
||||
// @ts-expect-error dynamic
|
||||
logger[higherLevel]('higher msg');
|
||||
expect(logSpy).toHaveBeenLastCalledWith(higherLevel, 'z2m: higher msg');
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(i++);
|
||||
// @ts-expect-error dynamic
|
||||
logger[higherLevel]('higher msg', 'abcd');
|
||||
expect(logSpy).toHaveBeenLastCalledWith(higherLevel, 'abcd: higher msg');
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(i++);
|
||||
@@ -181,23 +225,17 @@ describe('Logger', () => {
|
||||
consoleWriteSpy.mockClear();
|
||||
|
||||
for (const lowerLevel of otherLevels.lower) {
|
||||
// @ts-expect-error dynamic
|
||||
logger[lowerLevel]('lower msg');
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
expect(consoleWriteSpy).not.toHaveBeenCalled();
|
||||
// @ts-expect-error dynamic
|
||||
logger[lowerLevel]('lower msg', 'abcd');
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
expect(consoleWriteSpy).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('Logs Error object', () => {
|
||||
const logSpy = jest.spyOn(logger.winston, 'log');
|
||||
|
||||
logger.error(new Error('msg')); // test for stack=true
|
||||
expect(logSpy).toHaveBeenLastCalledWith('error', `z2m: ${new Error('msg')}`);
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'^zhc:legacy:fz:(tuya|moes)',
|
||||
@@ -241,8 +279,9 @@ describe('Logger', () => {
|
||||
],
|
||||
])('Sets namespace ignore for debug level %s', (ignore, expected, tests) => {
|
||||
logger.setLevel('debug');
|
||||
const logSpy = jest.spyOn(logger.winston, 'log');
|
||||
const logSpy = vi.spyOn(logger.winston, 'log');
|
||||
logger.setDebugNamespaceIgnore(ignore);
|
||||
// @ts-expect-error private
|
||||
expect(logger.debugNamespaceIgnoreRegex).toStrictEqual(expected);
|
||||
expect(logger.getDebugNamespaceIgnore()).toStrictEqual(ignore);
|
||||
|
||||
@@ -268,7 +307,7 @@ describe('Logger', () => {
|
||||
expect(logger.getNamespacedLevels()).toStrictEqual({'z2m:mqtt': 'warning'});
|
||||
expect(logger.getLevel()).toStrictEqual('debug');
|
||||
|
||||
const logSpy = jest.spyOn(logger.winston, 'log');
|
||||
const logSpy = vi.spyOn(logger.winston, 'log');
|
||||
|
||||
consoleWriteSpy.mockClear();
|
||||
logger.info(`MQTT publish: topic 'abcd/efgh', payload '{"my": {"payload": "injson"}}'`, 'z2m:mqtt');
|
||||
@@ -289,7 +328,7 @@ describe('Logger', () => {
|
||||
expect(logger.getNamespacedLevels()).toStrictEqual({'z2m:mqtt': 'info'});
|
||||
expect(logger.getLevel()).toStrictEqual('warning');
|
||||
|
||||
const logSpy = jest.spyOn(logger.winston, 'log');
|
||||
const logSpy = vi.spyOn(logger.winston, 'log');
|
||||
|
||||
consoleWriteSpy.mockClear();
|
||||
logger.info(`MQTT publish: topic 'abcd/efgh', payload '{"my": {"payload": "injson"}}'`, 'z2m:mqtt');
|
||||
@@ -307,69 +346,94 @@ describe('Logger', () => {
|
||||
});
|
||||
|
||||
it('Logs with namespaced levels hierarchy', () => {
|
||||
const nsLevels = {'zh:zstack': 'debug', 'zh:zstack:unpi:writer': 'error'};
|
||||
let cachedNSLevels = Object.assign({}, nsLevels);
|
||||
const nsLevels = {'zh:zstack': 'debug' as const, 'zh:zstack:unpi:writer': 'error' as const};
|
||||
let cachedNSLevels;
|
||||
cachedNSLevels = Object.assign({}, nsLevels);
|
||||
logger.setNamespacedLevels(nsLevels);
|
||||
logger.setLevel('warning');
|
||||
|
||||
consoleWriteSpy.mockClear();
|
||||
logger.debug(`--- parseNext [] debug picked from hierarchy`, 'zh:zstack:unpi:parser');
|
||||
expect(logger.cachedNamespacedLevels).toStrictEqual(Object.assign(cachedNSLevels, {'zh:zstack:unpi:parser': 'debug'}));
|
||||
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {'zh:zstack:unpi:parser': 'debug'}));
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(1);
|
||||
logger.warning(`--> frame [36,15] warning explicitely supressed`, 'zh:zstack:unpi:writer');
|
||||
expect(logger.cachedNamespacedLevels).toStrictEqual(cachedNSLevels);
|
||||
expect(getCachedNamespacedLevels()).toStrictEqual(cachedNSLevels);
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(1);
|
||||
logger.warning(`Another supressed warning message in a sub namespace`, 'zh:zstack:unpi:writer:sub:ns');
|
||||
expect(logger.cachedNamespacedLevels).toStrictEqual(Object.assign(cachedNSLevels, {'zh:zstack:unpi:writer:sub:ns': 'error'}));
|
||||
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {'zh:zstack:unpi:writer:sub:ns': 'error'}));
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(1);
|
||||
logger.error(`but error should go through`, 'zh:zstack:unpi:writer:another:sub:ns');
|
||||
expect(logger.cachedNamespacedLevels).toStrictEqual(Object.assign(cachedNSLevels, {'zh:zstack:unpi:writer:another:sub:ns': 'error'}));
|
||||
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {'zh:zstack:unpi:writer:another:sub:ns': 'error'}));
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(2);
|
||||
logger.warning(`new unconfigured namespace warning`, 'z2m:mqtt');
|
||||
expect(logger.cachedNamespacedLevels).toStrictEqual(Object.assign(cachedNSLevels, {'z2m:mqtt': 'warning'}));
|
||||
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {'z2m:mqtt': 'warning'}));
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(3);
|
||||
logger.info(`cached unconfigured namespace info should be supressed`, 'z2m:mqtt');
|
||||
expect(logger.cachedNamespacedLevels).toStrictEqual(cachedNSLevels);
|
||||
expect(getCachedNamespacedLevels()).toStrictEqual(cachedNSLevels);
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(3);
|
||||
|
||||
logger.setLevel('info');
|
||||
expect(logger.cachedNamespacedLevels).toStrictEqual((cachedNSLevels = Object.assign({}, nsLevels)));
|
||||
expect(getCachedNamespacedLevels()).toStrictEqual((cachedNSLevels = Object.assign({}, nsLevels)));
|
||||
logger.info(`unconfigured namespace info should now pass after default level change and cache reset`, 'z2m:mqtt');
|
||||
expect(logger.cachedNamespacedLevels).toStrictEqual(Object.assign(cachedNSLevels, {'z2m:mqtt': 'info'}));
|
||||
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {'z2m:mqtt': 'info'}));
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(4);
|
||||
logger.error(`configured namespace hierachy should still work after the cache reset`, 'zh:zstack:unpi:writer:another:sub:ns');
|
||||
expect(logger.cachedNamespacedLevels).toStrictEqual(Object.assign(cachedNSLevels, {'zh:zstack:unpi:writer:another:sub:ns': 'error'}));
|
||||
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {'zh:zstack:unpi:writer:another:sub:ns': 'error'}));
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(5);
|
||||
|
||||
logger.setNamespacedLevels({'zh:zstack': 'warning'});
|
||||
expect(logger.cachedNamespacedLevels).toStrictEqual((cachedNSLevels = {'zh:zstack': 'warning'}));
|
||||
expect(getCachedNamespacedLevels()).toStrictEqual((cachedNSLevels = {'zh:zstack': 'warning'}));
|
||||
logger.error(`error logged`, 'zh:zstack:unpi:writer');
|
||||
expect(logger.cachedNamespacedLevels).toStrictEqual(Object.assign(cachedNSLevels, {'zh:zstack:unpi:writer': 'warning'}));
|
||||
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {'zh:zstack:unpi:writer': 'warning'}));
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(6);
|
||||
logger.debug(`debug suppressed`, 'zh:zstack:unpi');
|
||||
expect(logger.cachedNamespacedLevels).toStrictEqual(Object.assign(cachedNSLevels, {'zh:zstack:unpi': 'warning'}));
|
||||
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {'zh:zstack:unpi': 'warning'}));
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(6);
|
||||
logger.warning(`warning logged`, 'zh:zstack');
|
||||
expect(logger.cachedNamespacedLevels).toStrictEqual(Object.assign(cachedNSLevels, {'zh:zstack': 'warning'}));
|
||||
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {'zh:zstack': 'warning'}));
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(7);
|
||||
logger.info(`unconfigured namespace`, 'z2m:mqtt');
|
||||
expect(logger.cachedNamespacedLevels).toStrictEqual(Object.assign(cachedNSLevels, {'z2m:mqtt': 'info'}));
|
||||
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {'z2m:mqtt': 'info'}));
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(8);
|
||||
});
|
||||
|
||||
it('Ignores SPLAT chars', () => {
|
||||
logger.setLevel('debug');
|
||||
|
||||
const logSpy = jest.spyOn(logger.winston, 'log');
|
||||
const logSpy = vi.spyOn(logger.winston, 'log');
|
||||
consoleWriteSpy.mockClear();
|
||||
|
||||
let net_map = '%d';
|
||||
logger.debug(net_map, 'z2m:mqtt');
|
||||
expect(logSpy).toHaveBeenLastCalledWith('debug', `z2m:mqtt: ${net_map}`);
|
||||
expect(consoleWriteSpy.mock.calls[0][0]).toMatch(new RegExp(`^.*\tz2m:mqtt: ${net_map}`));
|
||||
net_map = 'anything %s goes here';
|
||||
logger.debug(net_map, 'z2m:test');
|
||||
expect(logSpy).toHaveBeenLastCalledWith('debug', `z2m:test: ${net_map}`);
|
||||
expect(consoleWriteSpy.mock.calls[1][0]).toMatch(new RegExp(`^.*\tz2m:test: ${net_map}`));
|
||||
let splatChars = '%d';
|
||||
logger.debug(splatChars, 'z2m:mqtt');
|
||||
expect(logSpy).toHaveBeenLastCalledWith('debug', `z2m:mqtt: ${splatChars}`);
|
||||
expect(consoleWriteSpy.mock.calls[0][0]).toMatch(new RegExp(`^.*\tz2m:mqtt: ${splatChars}`));
|
||||
splatChars = 'anything %s goes here';
|
||||
logger.debug(splatChars, 'z2m:test');
|
||||
expect(logSpy).toHaveBeenLastCalledWith('debug', `z2m:test: ${splatChars}`);
|
||||
expect(consoleWriteSpy.mock.calls[1][0]).toMatch(new RegExp(`^.*\tz2m:test: ${splatChars}`));
|
||||
});
|
||||
|
||||
it('Logs to console in JSON when configured', () => {
|
||||
settings.set(['advanced', 'log_console_json'], true);
|
||||
logger.init();
|
||||
|
||||
consoleWriteSpy.mockClear();
|
||||
logger.info(`Test JSON message`, 'z2m');
|
||||
|
||||
const outputJSON = JSON.parse(consoleWriteSpy.mock.calls[0][0]);
|
||||
expect(outputJSON).toStrictEqual({
|
||||
level: 'info',
|
||||
message: 'z2m: Test JSON message',
|
||||
timestamp: expect.any(String),
|
||||
});
|
||||
|
||||
settings.set(['advanced', 'log_console_json'], false);
|
||||
logger.init();
|
||||
|
||||
consoleWriteSpy.mockClear();
|
||||
logger.info(`Test JSON message`, 'z2m');
|
||||
|
||||
const outputStr: string = consoleWriteSpy.mock.calls[0][0];
|
||||
expect(outputStr.trim().endsWith('\u001b[32minfo\u001b[39m: \tz2m: Test JSON message')).toStrictEqual(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import tmp from 'tmp';
|
||||
|
||||
import yaml from '../../lib/util/yaml';
|
||||
|
||||
export const mockDir: string = tmp.dirSync().name;
|
||||
const stateFile = path.join(mockDir, 'state.json');
|
||||
|
||||
export const DEFAULT_CONFIGURATION = {
|
||||
homeassistant: {enabled: false},
|
||||
frontend: {enabled: false},
|
||||
availability: {enabled: false},
|
||||
mqtt: {
|
||||
base_topic: 'zigbee2mqtt',
|
||||
server: 'mqtt://localhost',
|
||||
},
|
||||
serial: {
|
||||
port: '/dev/dummy',
|
||||
},
|
||||
devices: {
|
||||
'0x18fc2600000d7ae2': {
|
||||
friendly_name: 'bosch_radiator',
|
||||
},
|
||||
'0x000b57fffec6a5b2': {
|
||||
retain: true,
|
||||
friendly_name: 'bulb',
|
||||
description: 'this is my bulb',
|
||||
},
|
||||
'0x0017880104e45517': {
|
||||
retain: true,
|
||||
friendly_name: 'remote',
|
||||
},
|
||||
'0x0017880104e45520': {
|
||||
retain: false,
|
||||
friendly_name: 'button',
|
||||
},
|
||||
'0x0017880104e45521': {
|
||||
retain: false,
|
||||
friendly_name: 'button_double_key',
|
||||
},
|
||||
'0x0017880104e45522': {
|
||||
qos: 1,
|
||||
retain: false,
|
||||
friendly_name: 'weather_sensor',
|
||||
},
|
||||
'0x0017880104e45523': {
|
||||
retain: false,
|
||||
friendly_name: 'occupancy_sensor',
|
||||
},
|
||||
'0x0017880104e45524': {
|
||||
retain: false,
|
||||
friendly_name: 'power_plug',
|
||||
},
|
||||
'0x0017880104e45530': {
|
||||
retain: false,
|
||||
friendly_name: 'button_double_key_interviewing',
|
||||
},
|
||||
'0x0017880104e45540': {
|
||||
friendly_name: 'ikea_onoff',
|
||||
},
|
||||
'0x000b57fffec6a5b7': {
|
||||
retain: false,
|
||||
friendly_name: 'bulb_2',
|
||||
},
|
||||
'0x000b57fffec6a5b3': {
|
||||
retain: false,
|
||||
friendly_name: 'bulb_color',
|
||||
},
|
||||
'0x000b57fffec6a5b4': {
|
||||
retain: false,
|
||||
friendly_name: 'bulb_color_2',
|
||||
},
|
||||
'0x0017880104e45541': {
|
||||
retain: false,
|
||||
friendly_name: 'wall_switch',
|
||||
},
|
||||
'0x0017880104e45542': {
|
||||
retain: false,
|
||||
friendly_name: 'wall_switch_double',
|
||||
},
|
||||
'0x0017880104e45543': {
|
||||
retain: false,
|
||||
friendly_name: 'led_controller_1',
|
||||
},
|
||||
'0x0017880104e45544': {
|
||||
retain: false,
|
||||
friendly_name: 'led_controller_2',
|
||||
},
|
||||
'0x0017880104e45545': {
|
||||
retain: false,
|
||||
friendly_name: 'dimmer_wall_switch',
|
||||
},
|
||||
'0x0017880104e45547': {
|
||||
retain: false,
|
||||
friendly_name: 'curtain',
|
||||
},
|
||||
'0x0017880104e45548': {
|
||||
retain: false,
|
||||
friendly_name: 'fan',
|
||||
},
|
||||
'0x0017880104e45549': {
|
||||
retain: false,
|
||||
friendly_name: 'siren',
|
||||
},
|
||||
'0x0017880104e45529': {
|
||||
retain: false,
|
||||
friendly_name: 'unsupported2',
|
||||
},
|
||||
'0x0017880104e45550': {
|
||||
retain: false,
|
||||
friendly_name: 'thermostat',
|
||||
},
|
||||
'0x0017880104e45551': {
|
||||
retain: false,
|
||||
friendly_name: 'smart vent',
|
||||
},
|
||||
'0x0017880104e45552': {
|
||||
retain: false,
|
||||
friendly_name: 'j1',
|
||||
},
|
||||
'0x0017880104e45553': {
|
||||
retain: false,
|
||||
friendly_name: 'bulb_enddevice',
|
||||
},
|
||||
'0x0017880104e45559': {
|
||||
retain: false,
|
||||
friendly_name: 'cc2530_router',
|
||||
},
|
||||
'0x0017880104e45560': {
|
||||
retain: false,
|
||||
friendly_name: 'livolo',
|
||||
},
|
||||
'0x90fd9ffffe4b64ae': {
|
||||
retain: false,
|
||||
friendly_name: 'tradfri_remote',
|
||||
},
|
||||
'0x90fd9ffffe4b64af': {
|
||||
friendly_name: 'roller_shutter',
|
||||
},
|
||||
'0x90fd9ffffe4b64ax': {
|
||||
friendly_name: 'ZNLDP12LM',
|
||||
},
|
||||
'0x90fd9ffffe4b64aa': {
|
||||
friendly_name: 'SP600_OLD',
|
||||
},
|
||||
'0x90fd9ffffe4b64ab': {
|
||||
friendly_name: 'SP600_NEW',
|
||||
},
|
||||
'0x90fd9ffffe4b64ac': {
|
||||
friendly_name: 'MKS-CM-W5',
|
||||
},
|
||||
'0x0017880104e45526': {
|
||||
friendly_name: 'GL-S-007ZS',
|
||||
},
|
||||
'0x0017880104e43559': {
|
||||
friendly_name: 'U202DST600ZB',
|
||||
},
|
||||
'0xf4ce368a38be56a1': {
|
||||
retain: false,
|
||||
friendly_name: 'zigfred_plus',
|
||||
front_surface_enabled: 'true',
|
||||
dimmer_1_enabled: 'true',
|
||||
dimmer_1_dimming_enabled: 'true',
|
||||
dimmer_2_enabled: 'true',
|
||||
dimmer_2_dimming_enabled: 'true',
|
||||
dimmer_3_enabled: 'true',
|
||||
dimmer_3_dimming_enabled: 'true',
|
||||
dimmer_4_enabled: 'true',
|
||||
dimmer_4_dimming_enabled: 'true',
|
||||
cover_1_enabled: 'true',
|
||||
cover_1_tilt_enabled: 'true',
|
||||
cover_2_enabled: 'true',
|
||||
cover_2_tilt_enabled: 'true',
|
||||
},
|
||||
'0x0017880104e44559': {
|
||||
friendly_name: '3157100_thermostat',
|
||||
},
|
||||
'0x0017880104a44559': {
|
||||
friendly_name: 'J1_cover',
|
||||
},
|
||||
'0x0017882104a44559': {
|
||||
friendly_name: 'TS0601_thermostat',
|
||||
},
|
||||
'0x0017882104a44560': {
|
||||
friendly_name: 'TS0601_switch',
|
||||
},
|
||||
'0x0017882104a44562': {
|
||||
friendly_name: 'TS0601_cover_switch',
|
||||
},
|
||||
'0x0017882194e45543': {
|
||||
friendly_name: 'QS-Zigbee-D02-TRIAC-2C-LN',
|
||||
},
|
||||
'0x0017880104e45724': {
|
||||
friendly_name: 'GLEDOPTO_2ID',
|
||||
},
|
||||
'0x0017880104e45561': {
|
||||
friendly_name: 'temperature_sensor',
|
||||
},
|
||||
'0x0017880104e45562': {
|
||||
friendly_name: 'heating_actuator',
|
||||
},
|
||||
'0x000b57cdfec6a5b3': {
|
||||
friendly_name: 'hue_twilight',
|
||||
},
|
||||
'0x00124b00cfcf3298': {
|
||||
friendly_name: 'fanbee',
|
||||
retain: true,
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
1: {
|
||||
friendly_name: 'group_1',
|
||||
retain: false,
|
||||
},
|
||||
2: {
|
||||
friendly_name: 'group_2',
|
||||
retain: false,
|
||||
},
|
||||
15071: {
|
||||
friendly_name: 'group_tradfri_remote',
|
||||
retain: false,
|
||||
},
|
||||
11: {
|
||||
friendly_name: 'group_with_tradfri',
|
||||
retain: false,
|
||||
},
|
||||
12: {
|
||||
friendly_name: 'thermostat_group',
|
||||
retain: false,
|
||||
},
|
||||
14: {
|
||||
friendly_name: 'switch_group',
|
||||
retain: false,
|
||||
},
|
||||
21: {
|
||||
friendly_name: 'gledopto_group',
|
||||
},
|
||||
9: {
|
||||
friendly_name: 'ha_discovery_group',
|
||||
},
|
||||
19: {
|
||||
friendly_name: 'hue_twilight_group',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function writeDefaultConfiguration(config: unknown = undefined): void {
|
||||
config = config || DEFAULT_CONFIGURATION;
|
||||
|
||||
yaml.writeIfChanged(path.join(mockDir, 'configuration.yaml'), config);
|
||||
}
|
||||
|
||||
export function writeEmptyState(): void {
|
||||
fs.writeFileSync(stateFile, stringify({}));
|
||||
}
|
||||
|
||||
export function removeState(): void {
|
||||
if (stateExists()) {
|
||||
fs.unlinkSync(stateFile);
|
||||
}
|
||||
}
|
||||
|
||||
export function stateExists(): boolean {
|
||||
return fs.existsSync(stateFile);
|
||||
}
|
||||
|
||||
const defaultState = {
|
||||
'0x000b57fffec6a5b2': {
|
||||
state: 'ON',
|
||||
brightness: 50,
|
||||
color_temp: 370,
|
||||
linkquality: 99,
|
||||
},
|
||||
'0x0017880104e45517': {
|
||||
brightness: 255,
|
||||
},
|
||||
1: {
|
||||
state: 'ON',
|
||||
},
|
||||
};
|
||||
|
||||
export function getDefaultState(): typeof defaultState {
|
||||
return defaultState;
|
||||
}
|
||||
|
||||
export function writeDefaultState(): void {
|
||||
fs.writeFileSync(path.join(mockDir, 'state.json'), stringify(defaultState));
|
||||
}
|
||||
|
||||
export function read(): ReturnType<typeof yaml.read> {
|
||||
return yaml.read(path.join(mockDir, 'configuration.yaml'));
|
||||
}
|
||||
|
||||
vi.mock('../../lib/util/data', async () => {
|
||||
const nodePath = await vi.importActual<typeof import('node:path')>('node:path');
|
||||
|
||||
return {
|
||||
default: {
|
||||
joinPath: (file: string): string => nodePath.join(mockDir, file),
|
||||
getPath: (): string => mockDir,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
writeDefaultConfiguration();
|
||||
writeDefaultState();
|
||||
@@ -0,0 +1,5 @@
|
||||
export const mockDebounce = vi.fn((fn) => fn);
|
||||
|
||||
vi.mock('debounce', () => ({
|
||||
default: mockDebounce,
|
||||
}));
|
||||
@@ -0,0 +1,11 @@
|
||||
export const mockJSZipFile = vi.fn();
|
||||
export const mockJSZipGenerateAsync = vi.fn().mockReturnValue('THISISBASE64');
|
||||
|
||||
vi.mock('jszip', () => ({
|
||||
default: vi.fn().mockImplementation(() => {
|
||||
return {
|
||||
file: mockJSZipFile,
|
||||
generateAsync: mockJSZipGenerateAsync,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
@@ -0,0 +1,55 @@
|
||||
import type {LogLevel} from 'lib/util/settings';
|
||||
import type Transport from 'winston-transport';
|
||||
|
||||
let level: LogLevel = 'info';
|
||||
let debugNamespaceIgnore: string = '';
|
||||
let namespacedLevels: Record<string, LogLevel> = {};
|
||||
let transports: Transport[] = [];
|
||||
let transportsEnabled: boolean = false;
|
||||
const getMessage = (messageOrLambda: string | (() => string)): string => (messageOrLambda instanceof Function ? messageOrLambda() : messageOrLambda);
|
||||
|
||||
export const mockLogger = {
|
||||
log: vi.fn().mockImplementation((level, message, namespace = 'z2m') => {
|
||||
if (transportsEnabled) {
|
||||
for (const transport of transports) {
|
||||
transport.log!({level, message, namespace}, () => {});
|
||||
}
|
||||
}
|
||||
}),
|
||||
init: vi.fn(),
|
||||
info: vi.fn().mockImplementation((messageOrLambda, namespace = 'z2m') => mockLogger.log('info', getMessage(messageOrLambda), namespace)),
|
||||
warning: vi.fn().mockImplementation((messageOrLambda, namespace = 'z2m') => mockLogger.log('warning', getMessage(messageOrLambda), namespace)),
|
||||
error: vi.fn().mockImplementation((messageOrLambda, namespace = 'z2m') => mockLogger.log('error', getMessage(messageOrLambda), namespace)),
|
||||
debug: vi.fn().mockImplementation((messageOrLambda, namespace = 'z2m') => mockLogger.log('debug', getMessage(messageOrLambda), namespace)),
|
||||
cleanup: vi.fn(),
|
||||
logOutput: vi.fn(),
|
||||
add: (transport: Transport): void => {
|
||||
transports.push(transport);
|
||||
},
|
||||
addTransport: (transport: Transport): void => {
|
||||
transports.push(transport);
|
||||
},
|
||||
removeTransport: (transport: Transport): void => {
|
||||
transports = transports.filter((t) => t !== transport);
|
||||
},
|
||||
setLevel: (newLevel: LogLevel): void => {
|
||||
level = newLevel;
|
||||
},
|
||||
getLevel: (): LogLevel => level,
|
||||
setNamespacedLevels: (nsLevels: Record<string, LogLevel>): void => {
|
||||
namespacedLevels = nsLevels;
|
||||
},
|
||||
getNamespacedLevels: (): Record<string, LogLevel> => namespacedLevels,
|
||||
setDebugNamespaceIgnore: (newIgnore: string): void => {
|
||||
debugNamespaceIgnore = newIgnore;
|
||||
},
|
||||
getDebugNamespaceIgnore: (): string => debugNamespaceIgnore,
|
||||
setTransportsEnabled: (value: boolean): void => {
|
||||
transportsEnabled = value;
|
||||
},
|
||||
end: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('../../lib/util/logger', () => ({
|
||||
default: mockLogger,
|
||||
}));
|
||||
@@ -0,0 +1,35 @@
|
||||
import type {IClientPublishOptions} from 'mqtt';
|
||||
|
||||
import {EventHandler} from './utils';
|
||||
|
||||
export const events: Record<string, EventHandler> = {};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const mockMQTTPublishAsync = vi.fn(async (topic: string, message: string, opts?: IClientPublishOptions): Promise<void> => {});
|
||||
export const mockMQTTEndAsync = vi.fn(async (): Promise<void> => {});
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const mockMQTTSubscribeAsync = vi.fn(async (topicObject: string): Promise<void> => {});
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const mockMQTTUnsubscribeAsync = vi.fn(async (topic: string): Promise<void> => {});
|
||||
|
||||
export const mockMQTTConnectAsync = vi.fn(() => ({
|
||||
reconnecting: false,
|
||||
disconnecting: false,
|
||||
disconnected: false,
|
||||
publishAsync: mockMQTTPublishAsync,
|
||||
endAsync: mockMQTTEndAsync,
|
||||
subscribeAsync: mockMQTTSubscribeAsync,
|
||||
unsubscribeAsync: mockMQTTUnsubscribeAsync,
|
||||
on: vi.fn((type, handler) => {
|
||||
if (type === 'connect') {
|
||||
handler();
|
||||
}
|
||||
|
||||
events[type] = handler;
|
||||
}),
|
||||
stream: {setMaxListeners: vi.fn()},
|
||||
}));
|
||||
|
||||
vi.mock('mqtt', () => ({
|
||||
connectAsync: mockMQTTConnectAsync,
|
||||
}));
|
||||
@@ -0,0 +1,11 @@
|
||||
import utils from '../../lib/util/utils';
|
||||
|
||||
const spy = vi.spyOn(utils, 'sleep');
|
||||
|
||||
export function mock(): void {
|
||||
spy.mockImplementation(vi.fn());
|
||||
}
|
||||
|
||||
export function restore(): void {
|
||||
spy.mockRestore();
|
||||
}
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
declare module 'json-stable-stringify-without-jsonify' {
|
||||
export default function (obj: unknown): string;
|
||||
}
|
||||
|
||||
declare module 'tmp' {
|
||||
export function dirSync(): {
|
||||
name: string;
|
||||
removeCallback: (err: Error | undefined, name: string, fd: number, cleanupFn: () => void) => void;
|
||||
};
|
||||
export function fileSync(): {
|
||||
name: string;
|
||||
fd: number;
|
||||
removeCallback: (err: Error | undefined, name: string, fd: number, cleanupFn: () => void) => void;
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user