mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-08-29 07:08:56 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdb94da46e | ||
|
|
e0b0248172 | ||
|
|
5e44cf77b3 | ||
|
|
621cae2a63 | ||
|
|
711b456b06 | ||
|
|
278e3fb40e | ||
|
|
6ed18c757d | ||
|
|
1ca24700f2 | ||
|
|
cefc1dbf8d | ||
|
|
a5e4e9ef61 | ||
|
|
f5920fd53b | ||
|
|
a289f981bf | ||
|
|
00dda245e4 | ||
|
|
1960e026d2 | ||
|
|
4bf45630e2 | ||
|
|
ed03c40dad | ||
|
|
02b08834e4 | ||
|
|
1b85968e5e | ||
|
|
ea49d1ae44 | ||
|
|
a085b92c68 | ||
|
|
1303340aaf | ||
|
|
693f0d0a35 | ||
|
|
3edcfd7ff6 | ||
|
|
0ead302c0a | ||
|
|
418991386e | ||
|
|
7449c2357c | ||
|
|
8c3252c213 | ||
|
|
21c9faffaf | ||
|
|
e0611269f3 | ||
|
|
edcfe32572 | ||
|
|
d697c29c7b | ||
|
|
ba41985667 | ||
|
|
f656d9629c | ||
|
|
cc3521c877 | ||
|
|
615c53c93c | ||
|
|
f7e50519e3 | ||
|
|
e09d5fd526 | ||
|
|
8ef01fd9b1 | ||
|
|
19116995bb | ||
|
|
9d1427c104 | ||
|
|
317c9b4046 | ||
|
|
aad87cc3ec | ||
|
|
d8855b8e6f | ||
|
|
7a4f2869b9 | ||
|
|
40652d8a8f | ||
|
|
1d6ba5393a | ||
|
|
69364c958f | ||
|
|
164c3222b9 | ||
|
|
a8ba7eb552 | ||
|
|
5ff2148016 | ||
|
|
bca4bfa074 | ||
|
|
3cbcc4a0ab | ||
|
|
ff823dbaf0 | ||
|
|
3cb63aa922 | ||
|
|
b7b92da8ff | ||
|
|
39e4468308 | ||
|
|
908ecb3e2a | ||
|
|
aec59cb774 | ||
|
|
961a82e213 | ||
|
|
c36a177916 | ||
|
|
97dbe1e8af | ||
|
|
60808b4d36 | ||
|
|
7cbf31a129 | ||
|
|
5304b0ceb7 | ||
|
|
9a9f8d1438 | ||
|
|
a5135168c8 | ||
|
|
c1b62e83ef | ||
|
|
4acbdd7685 | ||
|
|
3ad20ddcdc | ||
|
|
69f9002e31 | ||
|
|
dfeaf22f70 | ||
|
|
faa6ad9ce8 | ||
|
|
3e4bda0fda | ||
|
|
8195b2dd4a | ||
|
|
ecc4696144 | ||
|
|
5e5bb18cf9 | ||
|
|
6fbb8b5ca2 | ||
|
|
130d041f75 | ||
|
|
5a97643211 | ||
|
|
3a474187c6 | ||
|
|
8be1ae16ab | ||
|
|
1e2ad28267 | ||
|
|
8a9f631973 | ||
|
|
b694dc1525 | ||
|
|
0c0e5a3794 | ||
|
|
36e2baf081 | ||
|
|
6e346073e7 | ||
|
|
06b53be5d8 | ||
|
|
12f858fbfe | ||
|
|
690d214caa | ||
|
|
ead72c11f5 | ||
|
|
d1c9f88c1d | ||
|
|
b4ce65bb41 | ||
|
|
e3d214ba9c | ||
|
|
8df5a484fc | ||
|
|
fc31e0af0a | ||
|
|
b8cc120da3 | ||
|
|
80502da80f | ||
|
|
8e547d06ec | ||
|
|
7fb10b1dcb | ||
|
|
850f0820ac | ||
|
|
17adc44a63 | ||
|
|
7d8232dc66 | ||
|
|
a2debc08b4 | ||
|
|
6949bbb883 | ||
|
|
2297b81640 | ||
|
|
d3bf771de0 | ||
|
|
a426bf01ea | ||
|
|
f8221ec2da | ||
|
|
d27ff76921 | ||
|
|
20be4054c1 | ||
|
|
23e71aab14 | ||
|
|
a34f547fa5 | ||
|
|
a14feef8c1 | ||
|
|
d6a5128aa8 | ||
|
|
9d0ee03f38 | ||
|
|
37927e698a | ||
|
|
89cdb1f180 | ||
|
|
e90afb286a | ||
|
|
fb4d01ebce | ||
|
|
9e481fe5e5 | ||
|
|
0b7d0039f7 | ||
|
|
5257c7eae1 | ||
|
|
952116d327 | ||
|
|
c5c07e7dfe | ||
|
|
e02bbdcf0b | ||
|
|
69f0840feb | ||
|
|
ea04f19709 | ||
|
|
9fe38b1677 | ||
|
|
6e6a989159 | ||
|
|
b52cfd87c3 | ||
|
|
516425b44b | ||
|
|
6a65769060 | ||
|
|
0a41af5dd2 | ||
|
|
267c399047 | ||
|
|
f824295b7e | ||
|
|
341c453657 | ||
|
|
c5c7f70ad6 | ||
|
|
440e3a0094 | ||
|
|
53e8226f0a | ||
|
|
7a1621a906 | ||
|
|
9e0c84beed | ||
|
|
5f9b49d66f | ||
|
|
93d0ee44ba | ||
|
|
fc35ac5737 | ||
|
|
2b90d7b3b9 | ||
|
|
f2a1c9a2be | ||
|
|
0714582eb9 | ||
|
|
5288549744 | ||
|
|
05d6f08a0d | ||
|
|
6f1266fd0e | ||
|
|
9792466aac | ||
|
|
52c255bcc2 | ||
|
|
badf107991 | ||
|
|
f872a5180c | ||
|
|
4f2b052016 | ||
|
|
ef5b3de5e2 | ||
|
|
ff7b09eb99 | ||
|
|
1baafbb6cf | ||
|
|
17ce9f4c52 | ||
|
|
e6ce7ecdc1 | ||
|
|
36ba1350a9 | ||
|
|
582bf7d2c6 | ||
|
|
46fd04364d | ||
|
|
747f6ac476 | ||
|
|
6e781eafae | ||
|
|
325199d445 | ||
|
|
e602dc849a | ||
|
|
ad0a222df1 | ||
|
|
6d6372ccf0 | ||
|
|
92955c02a4 | ||
|
|
e5a80e3b1b | ||
|
|
fb48bd0cb9 | ||
|
|
db578d9d11 | ||
|
|
8d2ef6df06 | ||
|
|
5db115057e | ||
|
|
be722a0500 | ||
|
|
e3985002c2 | ||
|
|
fcbb355f47 | ||
|
|
06ba62b833 | ||
|
|
ea5e0d47a5 | ||
|
|
8378949e1d | ||
|
|
78cde99469 | ||
|
|
a5195a6eee | ||
|
|
fb9816659b | ||
|
|
f6f7ba68c9 | ||
|
|
1b6ad46462 | ||
|
|
ad32fec1d0 | ||
|
|
c603f42c88 | ||
|
|
ed7e7c7e53 | ||
|
|
3941692665 | ||
|
|
8775ceb0c1 | ||
|
|
11fb858ff2 | ||
|
|
4fa794a639 | ||
|
|
1fe2d8dae6 | ||
|
|
bfeac07c4c | ||
|
|
b6c0f4e5db | ||
|
|
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 |
@@ -2,19 +2,25 @@ blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 'IMPORTANT: Check development branch changelog first!!'
|
||||
url: https://gist.github.com/Koenkk/bfd4c3d1725a2cccacc11d6ba51008ba
|
||||
about: "Before submitting an issue, check that the issue hasn't already been solved in the development branch. Click 'Open' to see the release notes of the development branch. In case it is, you can read here how to switch to the development branch: https://www.zigbee2mqtt.io/advanced/more/switch-to-dev-branch.html"
|
||||
- name: Questions/discussion
|
||||
about: Before submitting an issue, check that it has not already been solved in the development branch. Click here to see the release notes of the development branch.
|
||||
- name: Switch to the development branch
|
||||
url: https://www.zigbee2mqtt.io/advanced/more/switch-to-dev-branch.html
|
||||
about: If the development branch solves an issue you are facing, click here to see the instructions to switch to the development branch.
|
||||
- name: Questions/discussions
|
||||
url: https://github.com/Koenkk/zigbee2mqtt/discussions/new
|
||||
about: Ask questions, discuss about devices or show things you made
|
||||
- name: Frontend issues
|
||||
url: https://github.com/nurikk/z2m-frontend/issues
|
||||
about: Issues related to the frontend.
|
||||
about: Ask questions, discuss devices, show things you made...
|
||||
- name: Original frontend issues
|
||||
url: https://github.com/nurikk/zigbee2mqtt-frontend/issues
|
||||
about: Issues with the frontend package zigbee2mqtt-frontend
|
||||
- name: WindFront frontend issues
|
||||
url: https://github.com/Nerivec/zigbee2mqtt-windfront/issues
|
||||
about: Issues with the frontend package zigbee2mqtt-windfront
|
||||
- name: Home Assistant addon issues
|
||||
url: https://github.com/zigbee2mqtt/hassio-zigbee2mqtt/issues
|
||||
about: Issues related to the Home Assistant addon.
|
||||
about: Issues with the Home Assistant addon
|
||||
- name: FAQ
|
||||
url: https://www.zigbee2mqtt.io/guide/faq
|
||||
about: Frequently asked questions.
|
||||
about: Frequently asked questions
|
||||
- name: Support Chat
|
||||
url: https://discord.gg/NyseBeK
|
||||
about: Chat for feedback, questions and troubleshooting.
|
||||
about: Chat for feedback, questions and troubleshooting
|
||||
|
||||
@@ -6,9 +6,10 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
**IMPORTANT:** Before submitting:
|
||||
- Zigbee2MQTT fails to start? Read [this](https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start.html)
|
||||
- Raspberry Pi users? Make sure to check [this](https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start_crashes-runtime.html#raspberry-pi-users-use-a-good-power-supply)
|
||||
- You read the [FAQ](https://www.zigbee2mqtt.io/guide/faq/)
|
||||
- Are you using an EZSP adapter (e.g. Dongle-E/SkyConnect)? Try the [new driver](https://github.com/Koenkk/zigbee2mqtt/discussions/21462)
|
||||
- Zigbee2MQTT fails to start? Read [this](https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start.html)
|
||||
- Make sure the bug also occurs in the [dev branch](https://www.zigbee2mqtt.io/advanced/more/switch-to-dev-branch.html)
|
||||
- Make sure you are using the [latest firmware](https://www.zigbee2mqtt.io/guide/adapters/#recommended) on your adapter
|
||||
- The issue has not been [reported already](https://github.com/Koenkk/zigbee2mqtt/issues)
|
||||
@@ -58,11 +59,13 @@ body:
|
||||
placeholder: Electrolama zig-a-zig-ah! (zzh!), Slaesh's CC2652RB stick, ...
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
- type: textarea
|
||||
id: setup
|
||||
attributes:
|
||||
label: Setup
|
||||
description: How do you run Z2M (plain, add-on...) and on what machine (Pi, x86-64, containerized...)?
|
||||
description: |-
|
||||
How do you run Z2M (plain, add-on...) and on what machine (Pi, x86-64, containerized...)?
|
||||
When running on Linux also include output of: `uname -a && cat /etc/issue.net`
|
||||
placeholder: Add-on on Home Assistant OS on Intel NUC, Plain on Docker container, ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
+19
-7
@@ -1,13 +1,25 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: 'github-actions'
|
||||
directory: '/'
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: 'daily'
|
||||
interval: weekly
|
||||
target-branch: dev
|
||||
|
||||
- package-ecosystem: 'docker'
|
||||
directory: '/docker'
|
||||
commit-message:
|
||||
prefix: fix(ignore)
|
||||
groups:
|
||||
minor-patch:
|
||||
applies-to: version-updates
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
- package-ecosystem: docker
|
||||
directory: /
|
||||
schedule:
|
||||
interval: 'daily'
|
||||
interval: weekly
|
||||
target-branch: dev
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
target-branch: dev
|
||||
|
||||
+12
-11
@@ -1,4 +1,4 @@
|
||||
name: ci
|
||||
name: CI
|
||||
|
||||
on: [pull_request, push]
|
||||
|
||||
@@ -9,6 +9,8 @@ permissions:
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
|
||||
@@ -23,21 +25,19 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
registry-url: https://registry.npmjs.org/
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i --frozen-lockfile
|
||||
|
||||
- name: Check
|
||||
run: pnpm run check
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
|
||||
- name: Lint
|
||||
run: |
|
||||
pnpm run pretty:check
|
||||
pnpm run eslint
|
||||
|
||||
- name: Test
|
||||
run: pnpm run test:coverage
|
||||
|
||||
@@ -102,7 +102,6 @@ jobs:
|
||||
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
|
||||
@@ -116,6 +115,7 @@ jobs:
|
||||
run: pnpm publish --no-git-checks
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN }}
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
|
||||
- name: 'dev: Trigger zigbee2mqtt/hassio-zigbee2mqtt build'
|
||||
if: github.ref == 'refs/heads/dev' && github.event_name == 'push'
|
||||
@@ -175,11 +175,12 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
node: [18, 20, 22]
|
||||
node: [20, 22, 24]
|
||||
runs-on: ${{ matrix.os }}
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
@@ -188,8 +189,8 @@ jobs:
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
# --ignore-scripts prevents the serialport build which often fails on Windows
|
||||
run: pnpm i --frozen-lockfile --ignore-scripts
|
||||
# --ignore-scripts prevents build on Windows (only for unix-dgram, so doesn't matter, others have pre-builds)
|
||||
run: pnpm i --frozen-lockfile ${{ matrix.os == 'windows-latest' && '--ignore-scripts' || '' }}
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
# 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: ['master', 'dev']
|
||||
pull_request:
|
||||
branches: ['master', 'dev']
|
||||
schedule:
|
||||
- cron: '15 2 * * 2'
|
||||
|
||||
jobs:
|
||||
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
|
||||
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}}'
|
||||
@@ -1,4 +1,5 @@
|
||||
name: deps-review
|
||||
name: Dependency review
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
permissions:
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
fail_pr:
|
||||
fail-pr-to-master:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Fail PR to master
|
||||
@@ -1,7 +1,7 @@
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
name: ghcr-cleanup
|
||||
name: GHCR cleanup
|
||||
|
||||
permissions: {}
|
||||
jobs:
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
name: merge-master-to-dev
|
||||
name: Merge master to dev
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
merge_master_to_dev:
|
||||
merge-master-to-dev:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -7,7 +7,7 @@ permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
name: release-please
|
||||
name: Release Please
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
|
||||
- uses: googleapis/release-please-action@v4
|
||||
id: release
|
||||
@@ -1,4 +1,4 @@
|
||||
name: stale
|
||||
name: Stale
|
||||
|
||||
on:
|
||||
schedule:
|
||||
@@ -16,5 +16,5 @@ jobs:
|
||||
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
|
||||
exempt-issue-labels: dont-stale,feature-request
|
||||
operations-per-run: 500
|
||||
|
||||
@@ -2,11 +2,11 @@ on:
|
||||
repository_dispatch:
|
||||
types: update_dep
|
||||
|
||||
name: update-dep
|
||||
name: Update dependency
|
||||
|
||||
permissions: {}
|
||||
jobs:
|
||||
update_dep:
|
||||
update-dependency:
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: |
|
||||
pnpm install ${{ github.event.client_payload.package }}@${{ github.event.client_payload.version }} --save-exact
|
||||
@@ -1,33 +0,0 @@
|
||||
name: Update dependencies
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 0 * * 0'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
update_deps:
|
||||
permissions:
|
||||
contents: write # for peter-evans/create-pull-request to create branch
|
||||
pull-requests: write # for peter-evans/create-pull-request to create a PR
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: dev
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
- run: |
|
||||
pnpm up --latest
|
||||
- uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
commit-message: 'fix(ignore): update dependencies'
|
||||
branch: 'deps/all'
|
||||
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,2 +0,0 @@
|
||||
pnpm-lock.yaml
|
||||
CHANGELOG.md
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"singleQuote": true,
|
||||
"printWidth": 150,
|
||||
"bracketSpacing": false,
|
||||
"endOfLine": "lf",
|
||||
"tabWidth": 4,
|
||||
"importOrder": [
|
||||
"^[./]*/mocks",
|
||||
"",
|
||||
"<TYPES>^(node:)",
|
||||
"",
|
||||
"<TYPES>",
|
||||
"",
|
||||
"<TYPES>^[.]",
|
||||
"",
|
||||
"<BUILTIN_MODULES>",
|
||||
"",
|
||||
"<THIRD_PARTY_MODULES>",
|
||||
"",
|
||||
"^zigbee",
|
||||
"",
|
||||
"^[.]"
|
||||
],
|
||||
"importOrderParserPlugins": ["typescript", "decorators"],
|
||||
"plugins": ["@ianvs/prettier-plugin-sort-imports"]
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "2.1.1"
|
||||
".": "2.4.0"
|
||||
}
|
||||
|
||||
+228
@@ -1,5 +1,233 @@
|
||||
# Changelog
|
||||
|
||||
## [2.4.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.3.0...2.4.0) (2025-06-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* WindFront new frontend support ([#27489](https://github.com/Koenkk/zigbee2mqtt/issues/27489)) ([1b85968](https://github.com/Koenkk/zigbee2mqtt/commit/1b85968e5e691182305cf334836c069e70a79775))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* API missing a few request endpoints ([#27551](https://github.com/Koenkk/zigbee2mqtt/issues/27551)) ([a5e4e9e](https://github.com/Koenkk/zigbee2mqtt/commit/a5e4e9ef61e6f9e3251de43f2c9525d94c13249d))
|
||||
* Don't copy external JS to `dist` folder ([#27397](https://github.com/Koenkk/zigbee2mqtt/issues/27397)) ([0ead302](https://github.com/Koenkk/zigbee2mqtt/commit/0ead302c0a01f8fd2356e70c853f7795cb55b1c8))
|
||||
* Drop Node 18 support, add 24 ([#27348](https://github.com/Koenkk/zigbee2mqtt/issues/27348)) ([aad87cc](https://github.com/Koenkk/zigbee2mqtt/commit/aad87cc3ec48c664ec58a02fa39c5c23a73db492))
|
||||
* Fix bind/unbind by coordinator ieeeAddr not working ([#27333](https://github.com/Koenkk/zigbee2mqtt/issues/27333)) ([d8855b8](https://github.com/Koenkk/zigbee2mqtt/commit/d8855b8e6f1abc63daf6771a9ec07279ebc168d9))
|
||||
* Fix import error on Windows (ERR_UNSUPPORTED_ESM_URL_SCHEME) ([#27519](https://github.com/Koenkk/zigbee2mqtt/issues/27519)) ([ed03c40](https://github.com/Koenkk/zigbee2mqtt/commit/ed03c40dad1a8722b24e84f4ed8613b0e37a99ea))
|
||||
* HA integration for external sensors Aqara W100 ([#27520](https://github.com/Koenkk/zigbee2mqtt/issues/27520)) ([4bf4563](https://github.com/Koenkk/zigbee2mqtt/commit/4bf45630e28e5534fe68bb4bac6b020701c0e209))
|
||||
* **ignore:** bump the minor-patch group across 1 directory with 2 updates ([#27562](https://github.com/Koenkk/zigbee2mqtt/issues/27562)) ([278e3fb](https://github.com/Koenkk/zigbee2mqtt/commit/278e3fb40e84c97ab5dce8147fab7029f5bca168))
|
||||
* **ignore:** Cleanup publish options ([#27226](https://github.com/Koenkk/zigbee2mqtt/issues/27226)) ([b7b92da](https://github.com/Koenkk/zigbee2mqtt/commit/b7b92da8ffbaa25ce5927cb6dbf12f9da593a2e0))
|
||||
* **ignore:** Fix passive states not updating in frontend ([#27532](https://github.com/Koenkk/zigbee2mqtt/issues/27532)) ([a289f98](https://github.com/Koenkk/zigbee2mqtt/commit/a289f981bf81ef0734561317628f3de2e3b38f40))
|
||||
* **ignore:** networkmap typing ([#27566](https://github.com/Koenkk/zigbee2mqtt/issues/27566)) ([621cae2](https://github.com/Koenkk/zigbee2mqtt/commit/621cae2a6398d9eeced00d499bf542a8143de456))
|
||||
* **ignore:** update zigbee-herdsman to 4.0.2 ([#27380](https://github.com/Koenkk/zigbee2mqtt/issues/27380)) ([cc3521c](https://github.com/Koenkk/zigbee2mqtt/commit/cc3521c877a2c5048666e27c05f21b5852dceb5c))
|
||||
* **ignore:** update zigbee-herdsman to 4.1.0 ([#27462](https://github.com/Koenkk/zigbee2mqtt/issues/27462)) ([3edcfd7](https://github.com/Koenkk/zigbee2mqtt/commit/3edcfd7ff6b5e19ee0ad8780655d911b37b9bcab))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.37.0 ([#27300](https://github.com/Koenkk/zigbee2mqtt/issues/27300)) ([ff823db](https://github.com/Koenkk/zigbee2mqtt/commit/ff823dbaf0b9818be83b0e3ba02073b1afcd439d))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.38.0 ([#27329](https://github.com/Koenkk/zigbee2mqtt/issues/27329)) ([164c322](https://github.com/Koenkk/zigbee2mqtt/commit/164c3222b9dd6cec12ac407145bc8e38985fa7cb))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.39.0 ([#27340](https://github.com/Koenkk/zigbee2mqtt/issues/27340)) ([1d6ba53](https://github.com/Koenkk/zigbee2mqtt/commit/1d6ba5393a1dc5626b6ca539e3ec245cc3d61a56))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.40.0 ([#27347](https://github.com/Koenkk/zigbee2mqtt/issues/27347)) ([7a4f286](https://github.com/Koenkk/zigbee2mqtt/commit/7a4f2869b9836cd9f0e037c324585bdf453e8e8c))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.40.1 ([#27358](https://github.com/Koenkk/zigbee2mqtt/issues/27358)) ([317c9b4](https://github.com/Koenkk/zigbee2mqtt/commit/317c9b4046c4e02a6bad65b1e2cb9f5e59803f24))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.41.0 ([#27371](https://github.com/Koenkk/zigbee2mqtt/issues/27371)) ([f7e5051](https://github.com/Koenkk/zigbee2mqtt/commit/f7e50519e30ca65a396b07d89c3a81b922916ac9))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.42.0 ([#27392](https://github.com/Koenkk/zigbee2mqtt/issues/27392)) ([edcfe32](https://github.com/Koenkk/zigbee2mqtt/commit/edcfe325725aab283753b10f72521cb3f4b0dca4))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.43.0 ([#27394](https://github.com/Koenkk/zigbee2mqtt/issues/27394)) ([e061126](https://github.com/Koenkk/zigbee2mqtt/commit/e0611269f37aedec70ebfe17f75e44cbd2ce619d))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.44.0 ([#27404](https://github.com/Koenkk/zigbee2mqtt/issues/27404)) ([21c9faf](https://github.com/Koenkk/zigbee2mqtt/commit/21c9faffafafbbf6f52f12ecd20bead82bf20777))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.45.0 ([#27422](https://github.com/Koenkk/zigbee2mqtt/issues/27422)) ([8c3252c](https://github.com/Koenkk/zigbee2mqtt/commit/8c3252c2136b6c7e92c5c63ccfbb3af7a943f818))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.46.0 ([#27444](https://github.com/Koenkk/zigbee2mqtt/issues/27444)) ([7449c23](https://github.com/Koenkk/zigbee2mqtt/commit/7449c2357c2b19fa94f6bb111783e723e0a746f5))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.47.0 ([#27463](https://github.com/Koenkk/zigbee2mqtt/issues/27463)) ([693f0d0](https://github.com/Koenkk/zigbee2mqtt/commit/693f0d0a35231b529261fe1652bb6e355d5854fe))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.48.0 ([#27470](https://github.com/Koenkk/zigbee2mqtt/issues/27470)) ([1303340](https://github.com/Koenkk/zigbee2mqtt/commit/1303340aaf1aede9b6f4042d7a3780407a7d42e5))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.49.0 ([#27479](https://github.com/Koenkk/zigbee2mqtt/issues/27479)) ([ea49d1a](https://github.com/Koenkk/zigbee2mqtt/commit/ea49d1ae446a422ebd480b630a51b2e6f8921276))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.50.0 ([#27521](https://github.com/Koenkk/zigbee2mqtt/issues/27521)) ([1960e02](https://github.com/Koenkk/zigbee2mqtt/commit/1960e026d20cefc8c64d0a47e32f0bea075f24f6))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.50.1 ([#27531](https://github.com/Koenkk/zigbee2mqtt/issues/27531)) ([00dda24](https://github.com/Koenkk/zigbee2mqtt/commit/00dda245e49761afe155d0920059893448af8506))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.51.0 ([#27540](https://github.com/Koenkk/zigbee2mqtt/issues/27540)) ([f5920fd](https://github.com/Koenkk/zigbee2mqtt/commit/f5920fd53be777a289fa480cf1ef62bd1701aba3))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.52.0 ([#27559](https://github.com/Koenkk/zigbee2mqtt/issues/27559)) ([6ed18c7](https://github.com/Koenkk/zigbee2mqtt/commit/6ed18c757dd81899d5a8dcf9f912927dfbdde95d))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.52.1 ([#27563](https://github.com/Koenkk/zigbee2mqtt/issues/27563)) ([711b456](https://github.com/Koenkk/zigbee2mqtt/commit/711b456b068ef86c1add5d9cc16770f20a495622))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.53.0 ([#27572](https://github.com/Koenkk/zigbee2mqtt/issues/27572)) ([e0b0248](https://github.com/Koenkk/zigbee2mqtt/commit/e0b02481729916daf8a7ff02ddc61dbb717cc271))
|
||||
* **ignore:** update zigbee2mqtt-frontend to 0.9.11 ([#27301](https://github.com/Koenkk/zigbee2mqtt/issues/27301)) ([3cb63aa](https://github.com/Koenkk/zigbee2mqtt/commit/3cb63aa9229a3b4a0f099ce3f8481badf241e202))
|
||||
* **ignore:** update zigbee2mqtt-frontend to 0.9.12 ([#27379](https://github.com/Koenkk/zigbee2mqtt/issues/27379)) ([615c53c](https://github.com/Koenkk/zigbee2mqtt/commit/615c53c93c8cda90836d9edf98f54d5e589610b8))
|
||||
* **ignore:** update zigbee2mqtt-frontend to 0.9.13 ([#27382](https://github.com/Koenkk/zigbee2mqtt/issues/27382)) ([f656d96](https://github.com/Koenkk/zigbee2mqtt/commit/f656d9629c405d32067ccbbf411b1c5d6255e4ff))
|
||||
* **ignore:** update zigbee2mqtt-windfront to 1.0.2 ([#27567](https://github.com/Koenkk/zigbee2mqtt/issues/27567)) ([5e44cf7](https://github.com/Koenkk/zigbee2mqtt/commit/5e44cf77b3a4c664be4b97a95455a39a18bd8632))
|
||||
* Improve update script ([#27308](https://github.com/Koenkk/zigbee2mqtt/issues/27308)) ([3cbcc4a](https://github.com/Koenkk/zigbee2mqtt/commit/3cbcc4a0abbe1cb09096d0b989abba48acea8772))
|
||||
* Only send JSON state over WebSocket ([#27225](https://github.com/Koenkk/zigbee2mqtt/issues/27225)) ([39e4468](https://github.com/Koenkk/zigbee2mqtt/commit/39e4468308c1909f03ecd068f5c7da23b040f80b))
|
||||
* Rerun onboarding if Z2M start failed after previous onboarding ([#27386](https://github.com/Koenkk/zigbee2mqtt/issues/27386)) ([d697c29](https://github.com/Koenkk/zigbee2mqtt/commit/d697c29c7be5e7e49d82d25f355a26875ac383a3))
|
||||
* Trim name on rename ([#27332](https://github.com/Koenkk/zigbee2mqtt/issues/27332)) ([69364c9](https://github.com/Koenkk/zigbee2mqtt/commit/69364c958f9f2b7430bfb5a072ffe5267bfcc378))
|
||||
|
||||
## [2.3.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.2.1...2.3.0) (2025-05-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Allow scheduling OTA on device request ([#26823](https://github.com/Koenkk/zigbee2mqtt/issues/26823)) ([6e34607](https://github.com/Koenkk/zigbee2mqtt/commit/6e346073e75685eed52da0d2e229ef68e073c76d))
|
||||
* Availability improvements ([#26811](https://github.com/Koenkk/zigbee2mqtt/issues/26811)) ([8df5a48](https://github.com/Koenkk/zigbee2mqtt/commit/8df5a484fc3dd3881da217605426e80e017ff467))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add icon example to settings schema ([#27194](https://github.com/Koenkk/zigbee2mqtt/issues/27194)) ([4acbdd7](https://github.com/Koenkk/zigbee2mqtt/commit/4acbdd7685a6cd3b6db99859b2ccd220aa60c3a2))
|
||||
* Add interview state ([#27163](https://github.com/Koenkk/zigbee2mqtt/issues/27163)) ([69f9002](https://github.com/Koenkk/zigbee2mqtt/commit/69f9002e316bdb3d1489d1434d5e53d0f9efaf84))
|
||||
* Do not call start() on extensions twice when starting ([#27032](https://github.com/Koenkk/zigbee2mqtt/issues/27032)) ([7fb10b1](https://github.com/Koenkk/zigbee2mqtt/commit/7fb10b1dcb5bce3485c53c331d6671dd81082209))
|
||||
* Don't allow publishing wildcards in MQTT topic ([#27025](https://github.com/Koenkk/zigbee2mqtt/issues/27025)) ([850f082](https://github.com/Koenkk/zigbee2mqtt/commit/850f0820ac2eb8666b7fa91e0129815e1a67c27c))
|
||||
* Guard against ZHC throwing ([#27093](https://github.com/Koenkk/zigbee2mqtt/issues/27093)) ([e3d214b](https://github.com/Koenkk/zigbee2mqtt/commit/e3d214ba9c66503ab023cce219e462b3da2f10ab))
|
||||
* **ignore:** Add missing API request endpoints ([#27242](https://github.com/Koenkk/zigbee2mqtt/issues/27242)) ([5304b0c](https://github.com/Koenkk/zigbee2mqtt/commit/5304b0ceb713c534c8700d12e33d1f8dd8242eaf))
|
||||
* **ignore:** API exporting issues ([#27211](https://github.com/Koenkk/zigbee2mqtt/issues/27211)) ([9a9f8d1](https://github.com/Koenkk/zigbee2mqtt/commit/9a9f8d143839e039fe50594848d658f630396752))
|
||||
* **ignore:** API typing fixes. ([#27266](https://github.com/Koenkk/zigbee2mqtt/issues/27266)) ([c36a177](https://github.com/Koenkk/zigbee2mqtt/commit/c36a1779169ecf75d37d933f32bae75799630365))
|
||||
* **ignore:** Group API proper typing ([#27243](https://github.com/Koenkk/zigbee2mqtt/issues/27243)) ([7cbf31a](https://github.com/Koenkk/zigbee2mqtt/commit/7cbf31a1296722a25b20fea561c2fedf04f22861))
|
||||
* **ignore:** partial cleanup for biome move ([#27076](https://github.com/Koenkk/zigbee2mqtt/issues/27076)) ([fc31e0a](https://github.com/Koenkk/zigbee2mqtt/commit/fc31e0af0a3c1592c2be9057a66287c60fa28a75))
|
||||
* **ignore:** update dependencies ([#27022](https://github.com/Koenkk/zigbee2mqtt/issues/27022)) ([d3bf771](https://github.com/Koenkk/zigbee2mqtt/commit/d3bf771de0365a2fb060d81ef07daccc6151abbf))
|
||||
* **ignore:** update dependencies ([#27094](https://github.com/Koenkk/zigbee2mqtt/issues/27094)) ([b4ce65b](https://github.com/Koenkk/zigbee2mqtt/commit/b4ce65bb41853ef177d910f7cb03131e861a0475))
|
||||
* **ignore:** update zigbee-herdsman to 3.5.0 ([#27000](https://github.com/Koenkk/zigbee2mqtt/issues/27000)) ([23e71aa](https://github.com/Koenkk/zigbee2mqtt/commit/23e71aab14536f4f38f12c3c8cb96eb1cd7ccb60))
|
||||
* **ignore:** update zigbee-herdsman to 3.5.1 ([#27024](https://github.com/Koenkk/zigbee2mqtt/issues/27024)) ([6949bbb](https://github.com/Koenkk/zigbee2mqtt/commit/6949bbb8838802573d2d1fd573fdba67132be446))
|
||||
* **ignore:** update zigbee-herdsman to 3.5.2 ([#27147](https://github.com/Koenkk/zigbee2mqtt/issues/27147)) ([130d041](https://github.com/Koenkk/zigbee2mqtt/commit/130d041f75121918dfd605aa0959e20718c392ab))
|
||||
* **ignore:** update zigbee-herdsman to 4.0.0 ([dfeaf22](https://github.com/Koenkk/zigbee2mqtt/commit/dfeaf22f703712e32dd1e27c96de7a374bc620c5))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.21.0 ([#27001](https://github.com/Koenkk/zigbee2mqtt/issues/27001)) ([20be405](https://github.com/Koenkk/zigbee2mqtt/commit/20be4054c163c0b8fe420de606c163076be8c926))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.22.0 ([#27023](https://github.com/Koenkk/zigbee2mqtt/issues/27023)) ([2297b81](https://github.com/Koenkk/zigbee2mqtt/commit/2297b8164094b648678c52884b7e20b62ccbe93c))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.23.0 ([#27036](https://github.com/Koenkk/zigbee2mqtt/issues/27036)) ([17adc44](https://github.com/Koenkk/zigbee2mqtt/commit/17adc44a63c1bc237101c02b6f780b37f578903c))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.24.0 ([#27061](https://github.com/Koenkk/zigbee2mqtt/issues/27061)) ([8e547d0](https://github.com/Koenkk/zigbee2mqtt/commit/8e547d06ec9981d9bfa95a51e12620a9e4a88c39))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.25.0 ([#27075](https://github.com/Koenkk/zigbee2mqtt/issues/27075)) ([b8cc120](https://github.com/Koenkk/zigbee2mqtt/commit/b8cc120da3f29d8d4d4393d565540f2d24a3db8e))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.26.0 ([06b53be](https://github.com/Koenkk/zigbee2mqtt/commit/06b53be5d891e01b498342e01502296dabeffa02))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.27.0 ([#27121](https://github.com/Koenkk/zigbee2mqtt/issues/27121)) ([b694dc1](https://github.com/Koenkk/zigbee2mqtt/commit/b694dc1525608d1d9394a405193aae88f34d2ada))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.28.0 ([#27130](https://github.com/Koenkk/zigbee2mqtt/issues/27130)) ([8a9f631](https://github.com/Koenkk/zigbee2mqtt/commit/8a9f63197319aad59e3028c86e4bb3da6b3e6a01))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.29.0 ([#27137](https://github.com/Koenkk/zigbee2mqtt/issues/27137)) ([8be1ae1](https://github.com/Koenkk/zigbee2mqtt/commit/8be1ae16ab5c47f5aaec28a6dc328c3d3d80d409))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.30.0 ([#27154](https://github.com/Koenkk/zigbee2mqtt/issues/27154)) ([ecc4696](https://github.com/Koenkk/zigbee2mqtt/commit/ecc46961446d17de540e48c76ab5b7c26f6c8c18))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.31.0 ([#27162](https://github.com/Koenkk/zigbee2mqtt/issues/27162)) ([8195b2d](https://github.com/Koenkk/zigbee2mqtt/commit/8195b2dd4ab514ba4ba944499ce71e66c41d6855))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.32.0 ([#27178](https://github.com/Koenkk/zigbee2mqtt/issues/27178)) ([faa6ad9](https://github.com/Koenkk/zigbee2mqtt/commit/faa6ad9ce883644eada82c04a9c11d41e62b4799))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.33.0 ([#27202](https://github.com/Koenkk/zigbee2mqtt/issues/27202)) ([c1b62e8](https://github.com/Koenkk/zigbee2mqtt/commit/c1b62e83ef22ae5880e833b645586a19bba661af))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.34.0 ([#27224](https://github.com/Koenkk/zigbee2mqtt/issues/27224)) ([a513516](https://github.com/Koenkk/zigbee2mqtt/commit/a5135168c81ae9e78eb84e66f2c4556a3e426442))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.35.0 ([#27253](https://github.com/Koenkk/zigbee2mqtt/issues/27253)) ([97dbe1e](https://github.com/Koenkk/zigbee2mqtt/commit/97dbe1e8af826c115d591712447031321f68cb60))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.36.0 ([#27276](https://github.com/Koenkk/zigbee2mqtt/issues/27276)) ([961a82e](https://github.com/Koenkk/zigbee2mqtt/commit/961a82e213889bee221abec0015ed45e171c6227))
|
||||
* **ignore:** update zigbee2mqtt-frontend to 0.9.10 ([#27191](https://github.com/Koenkk/zigbee2mqtt/issues/27191)) ([3ad20dd](https://github.com/Koenkk/zigbee2mqtt/commit/3ad20ddcdc2995b4a0522b55adeaa25701e279fc))
|
||||
* **ignore:** update zigbee2mqtt-frontend to 0.9.8 ([#27018](https://github.com/Koenkk/zigbee2mqtt/issues/27018)) ([d27ff76](https://github.com/Koenkk/zigbee2mqtt/commit/d27ff76921471ba6d88bfc14fa828665efaad5dd))
|
||||
* **ignore:** update zigbee2mqtt-frontend to 0.9.9 ([#27120](https://github.com/Koenkk/zigbee2mqtt/issues/27120)) ([0c0e5a3](https://github.com/Koenkk/zigbee2mqtt/commit/0c0e5a3794260782ccee759d131762c8f148c0f5))
|
||||
* Migrate to Biome ([#27150](https://github.com/Koenkk/zigbee2mqtt/issues/27150)) ([6fbb8b5](https://github.com/Koenkk/zigbee2mqtt/commit/6fbb8b5ca2d3d98240f23e0208edb2fd05826daa))
|
||||
* OnEvent fixes ([#27063](https://github.com/Koenkk/zigbee2mqtt/issues/27063)) ([80502da](https://github.com/Koenkk/zigbee2mqtt/commit/80502da80f38bcbcd5ad9e0c83a0d1d77a269394))
|
||||
* Use Alpine 3.21 for arm v6 and v7 ([#25417](https://github.com/Koenkk/zigbee2mqtt/issues/25417)) ([1e2ad28](https://github.com/Koenkk/zigbee2mqtt/commit/1e2ad28267030097cc9e2d53edc0cea17d328220))
|
||||
* Use Map for State ([#27105](https://github.com/Koenkk/zigbee2mqtt/issues/27105)) ([36e2baf](https://github.com/Koenkk/zigbee2mqtt/commit/36e2baf081242c609907d616b2945a9ead9d6fdd))
|
||||
|
||||
## [2.2.1](https://github.com/Koenkk/zigbee2mqtt/compare/2.2.0...2.2.1) (2025-04-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Fix issue with some settings when migrating from 1.x.x to 2.2.x ([#26947](https://github.com/Koenkk/zigbee2mqtt/issues/26947)) ([9e481fe](https://github.com/Koenkk/zigbee2mqtt/commit/9e481fe5e57a8e664dd58cff7765b5365630c7c1))
|
||||
* Fix settings being overwriting when env var is set to a ref ([#26988](https://github.com/Koenkk/zigbee2mqtt/issues/26988)) ([d6a5128](https://github.com/Koenkk/zigbee2mqtt/commit/d6a5128aa819a85e2a06e57307329d2b35938397))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.19.0 ([#26938](https://github.com/Koenkk/zigbee2mqtt/issues/26938)) ([0b7d003](https://github.com/Koenkk/zigbee2mqtt/commit/0b7d0039f709215ac1a4895f15f06db41b4d4998))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.19.1 ([#26961](https://github.com/Koenkk/zigbee2mqtt/issues/26961)) ([e90afb2](https://github.com/Koenkk/zigbee2mqtt/commit/e90afb286a15c5416fd1126864acdfc4c03c3a64))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.20.0 ([#26978](https://github.com/Koenkk/zigbee2mqtt/issues/26978)) ([37927e6](https://github.com/Koenkk/zigbee2mqtt/commit/37927e698abcb242965423e9ee37623255feadab))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.20.1 ([#26989](https://github.com/Koenkk/zigbee2mqtt/issues/26989)) ([9d0ee03](https://github.com/Koenkk/zigbee2mqtt/commit/9d0ee03f38ee6881dce3b17c219a6580303607ef))
|
||||
* **ignore:** update zigbee2mqtt-frontend to 0.9.6 ([#26959](https://github.com/Koenkk/zigbee2mqtt/issues/26959)) ([fb4d01e](https://github.com/Koenkk/zigbee2mqtt/commit/fb4d01ebced7046919d6657c7609a196d39a1a6c))
|
||||
* **ignore:** update zigbee2mqtt-frontend to 0.9.7 ([#26976](https://github.com/Koenkk/zigbee2mqtt/issues/26976)) ([89cdb1f](https://github.com/Koenkk/zigbee2mqtt/commit/89cdb1f180bb343b56a50c94dc6a23dda1345582))
|
||||
|
||||
## [2.2.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.1.3...2.2.0) (2025-04-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Frontend: support filtering notifications ([#26805](https://github.com/Koenkk/zigbee2mqtt/issues/26805)) ([f872a51](https://github.com/Koenkk/zigbee2mqtt/commit/f872a5180c5618b7d3e7c0d11f1283a454dcbc6b))
|
||||
* Onboarding ([#26608](https://github.com/Koenkk/zigbee2mqtt/issues/26608)) ([05d6f08](https://github.com/Koenkk/zigbee2mqtt/commit/05d6f08a0dd6a5097b7d19b8de3b39e37e9a8533))
|
||||
* Support ZigBee on Host adapter ([#26742](https://github.com/Koenkk/zigbee2mqtt/issues/26742)) ([92955c0](https://github.com/Koenkk/zigbee2mqtt/commit/92955c02a4de09cbed79249db8bf0e6529423b93))
|
||||
* Use dynamic import for external JS ([#26522](https://github.com/Koenkk/zigbee2mqtt/issues/26522)) ([badf107](https://github.com/Koenkk/zigbee2mqtt/commit/badf1079919baa06f160a42c34e9f742a9b584c9))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add device name to no converter available error message ([#26867](https://github.com/Koenkk/zigbee2mqtt/issues/26867)) ([267c399](https://github.com/Koenkk/zigbee2mqtt/commit/267c3990474dcc646e6987a108867a54b8b940db))
|
||||
* Check if new commits are available in update.sh ([#26631](https://github.com/Koenkk/zigbee2mqtt/issues/26631)) ([1b6ad46](https://github.com/Koenkk/zigbee2mqtt/commit/1b6ad46462681b8f223a6de54c5996d0e8f66f64))
|
||||
* Fix bridge offline message format for frontend ([#26707](https://github.com/Koenkk/zigbee2mqtt/issues/26707)) ([e398500](https://github.com/Koenkk/zigbee2mqtt/commit/e3985002c2bf374c2edd82e95aaa2da7c920b464))
|
||||
* General cleanup ([#26595](https://github.com/Koenkk/zigbee2mqtt/issues/26595)) ([8775ceb](https://github.com/Koenkk/zigbee2mqtt/commit/8775ceb0c1d2bac552a345751935d0191410bfa3))
|
||||
* **gnore:** Onboarding fixes ([#26824](https://github.com/Koenkk/zigbee2mqtt/issues/26824)) ([f2a1c9a](https://github.com/Koenkk/zigbee2mqtt/commit/f2a1c9a2be052ec46d845299d73ff1495d4ea4e1))
|
||||
* Home Assistant: fix electrical measurements sensors category ([#26769](https://github.com/Koenkk/zigbee2mqtt/issues/26769)) ([747f6ac](https://github.com/Koenkk/zigbee2mqtt/commit/747f6ac4767522a51fb5e5cc0b796b3a92243476))
|
||||
* **ignore:** fix `EACCES: permission denied, mkdir '/app/dist/external_converters'` ([7a1621a](https://github.com/Koenkk/zigbee2mqtt/commit/7a1621a9060cd0109643655df2b2833e046158fa))
|
||||
* **ignore:** Fix HA addon onboarding ([#26862](https://github.com/Koenkk/zigbee2mqtt/issues/26862)) ([341c453](https://github.com/Koenkk/zigbee2mqtt/commit/341c453657d74aef61e4a44b24d081fab9671d39))
|
||||
* **ignore:** Fix tests failing when zhc is linked via `pnpm link` ([#26630](https://github.com/Koenkk/zigbee2mqtt/issues/26630)) ([ad32fec](https://github.com/Koenkk/zigbee2mqtt/commit/ad32fec1d02f2d43b7dc18bf38bbd14f7c97ffde))
|
||||
* **ignore:** Fix update check ([#26723](https://github.com/Koenkk/zigbee2mqtt/issues/26723)) ([fb48bd0](https://github.com/Koenkk/zigbee2mqtt/commit/fb48bd0cb9a0311302d82a7f1b9cec21a585799e))
|
||||
* **ignore:** Make update check less verbose ([#26774](https://github.com/Koenkk/zigbee2mqtt/issues/26774)) ([6e781ea](https://github.com/Koenkk/zigbee2mqtt/commit/6e781eafaefa54fad24ed857e1f96261ff3bebb7))
|
||||
* **ignore:** Module `NodeNext` ([#26719](https://github.com/Koenkk/zigbee2mqtt/issues/26719)) ([5db1150](https://github.com/Koenkk/zigbee2mqtt/commit/5db115057ef733a33575c889e981696d2b9e74b9))
|
||||
* **ignore:** Move to `Node16` `module` ([#26704](https://github.com/Koenkk/zigbee2mqtt/issues/26704)) ([ea5e0d4](https://github.com/Koenkk/zigbee2mqtt/commit/ea5e0d47a599ac66dcf4671e7a8b0687d8a08b85))
|
||||
* **ignore:** Move to Node16 module ([#26717](https://github.com/Koenkk/zigbee2mqtt/issues/26717)) ([be722a0](https://github.com/Koenkk/zigbee2mqtt/commit/be722a0500892e6787d669ceb10c7d0bfc1c7e4c))
|
||||
* **ignore:** Reduce chaining in `homeassistant.ts` ([#26582](https://github.com/Koenkk/zigbee2mqtt/issues/26582)) ([4fa794a](https://github.com/Koenkk/zigbee2mqtt/commit/4fa794a6392917d0140b94257bb7cd155fc25936))
|
||||
* **ignore:** Remove uncessary extensions spread ([#26581](https://github.com/Koenkk/zigbee2mqtt/issues/26581)) ([1fe2d8d](https://github.com/Koenkk/zigbee2mqtt/commit/1fe2d8dae6ace27c4b297b7943ac5501c096e8ab))
|
||||
* **ignore:** update dependencies ([#26554](https://github.com/Koenkk/zigbee2mqtt/issues/26554)) ([11fb858](https://github.com/Koenkk/zigbee2mqtt/commit/11fb858ff2819319f8aa4bd8a6b2f25944619a8e))
|
||||
* **ignore:** update dependencies ([#26662](https://github.com/Koenkk/zigbee2mqtt/issues/26662)) ([fb98166](https://github.com/Koenkk/zigbee2mqtt/commit/fb9816659bec15d0a9a39f73d4900fc7586a378e))
|
||||
* **ignore:** update dependencies ([#26751](https://github.com/Koenkk/zigbee2mqtt/issues/26751)) ([e602dc8](https://github.com/Koenkk/zigbee2mqtt/commit/e602dc849a1d17b9097ce06015ba587c59ba5006))
|
||||
* **ignore:** update dependencies ([#26833](https://github.com/Koenkk/zigbee2mqtt/issues/26833)) ([93d0ee4](https://github.com/Koenkk/zigbee2mqtt/commit/93d0ee44ba4445711b7e43ab4bfa06e8ac266d68))
|
||||
* **ignore:** update dependencies ([#26911](https://github.com/Koenkk/zigbee2mqtt/issues/26911)) ([e02bbdc](https://github.com/Koenkk/zigbee2mqtt/commit/e02bbdcf0bbf8577ab6e138c915f7e2ef1a32482))
|
||||
* **ignore:** update zigbee-herdsman to 3.3.0 ([#26628](https://github.com/Koenkk/zigbee2mqtt/issues/26628)) ([ed7e7c7](https://github.com/Koenkk/zigbee2mqtt/commit/ed7e7c7e530010aeb44eb71e50f31145c7285c26))
|
||||
* **ignore:** update zigbee-herdsman to 3.3.1 ([#26674](https://github.com/Koenkk/zigbee2mqtt/issues/26674)) ([a5195a6](https://github.com/Koenkk/zigbee2mqtt/commit/a5195a6eee9a3e852709c4b979c965dee33f2b54))
|
||||
* **ignore:** update zigbee-herdsman to 3.3.2 ([#26702](https://github.com/Koenkk/zigbee2mqtt/issues/26702)) ([8378949](https://github.com/Koenkk/zigbee2mqtt/commit/8378949e1d9cdfb3d33bc771dc61594ca7ef3c05))
|
||||
* **ignore:** update zigbee-herdsman to 3.4.0 ([#26744](https://github.com/Koenkk/zigbee2mqtt/issues/26744)) ([e5a80e3](https://github.com/Koenkk/zigbee2mqtt/commit/e5a80e3b1bf8ab5f693d248f2b6a90481bdb10fc))
|
||||
* **ignore:** update zigbee-herdsman to 3.4.1 ([#26779](https://github.com/Koenkk/zigbee2mqtt/issues/26779)) ([46fd043](https://github.com/Koenkk/zigbee2mqtt/commit/46fd04364ded0b6f7c9fcea6f33a2b523971b389))
|
||||
* **ignore:** update zigbee-herdsman to 3.4.10 ([#26908](https://github.com/Koenkk/zigbee2mqtt/issues/26908)) ([b52cfd8](https://github.com/Koenkk/zigbee2mqtt/commit/b52cfd87c3deedfa0d1d391ce4f6f64083389559))
|
||||
* **ignore:** update zigbee-herdsman to 3.4.11 ([#26924](https://github.com/Koenkk/zigbee2mqtt/issues/26924)) ([9fe38b1](https://github.com/Koenkk/zigbee2mqtt/commit/9fe38b1677681b1f3b47ac6d9876fa7305572244))
|
||||
* **ignore:** update zigbee-herdsman to 3.4.2 ([#26789](https://github.com/Koenkk/zigbee2mqtt/issues/26789)) ([582bf7d](https://github.com/Koenkk/zigbee2mqtt/commit/582bf7d2c6a21003e09892b569ef5fb115f5d1ab))
|
||||
* **ignore:** update zigbee-herdsman to 3.4.3 ([#26802](https://github.com/Koenkk/zigbee2mqtt/issues/26802)) ([4f2b052](https://github.com/Koenkk/zigbee2mqtt/commit/4f2b052016f4d313dfc43d35aca986b682c173b0))
|
||||
* **ignore:** update zigbee-herdsman to 3.4.4 ([#26821](https://github.com/Koenkk/zigbee2mqtt/issues/26821)) ([0714582](https://github.com/Koenkk/zigbee2mqtt/commit/0714582eb975c39ef0fb5205d7f2c76ce7de2a05))
|
||||
* **ignore:** update zigbee-herdsman to 3.4.5 ([#26826](https://github.com/Koenkk/zigbee2mqtt/issues/26826)) ([2b90d7b](https://github.com/Koenkk/zigbee2mqtt/commit/2b90d7b3b974353c312dc00c26b6223be74e1272))
|
||||
* **ignore:** update zigbee-herdsman to 3.4.6 ([#26834](https://github.com/Koenkk/zigbee2mqtt/issues/26834)) ([9e0c84b](https://github.com/Koenkk/zigbee2mqtt/commit/9e0c84beed18d13ec310d91b89e35eba46fe3817))
|
||||
* **ignore:** update zigbee-herdsman to 3.4.7 ([#26859](https://github.com/Koenkk/zigbee2mqtt/issues/26859)) ([440e3a0](https://github.com/Koenkk/zigbee2mqtt/commit/440e3a00942dc8bb7a7b9b66efd825e15dccad6c))
|
||||
* **ignore:** update zigbee-herdsman to 3.4.8 ([#26866](https://github.com/Koenkk/zigbee2mqtt/issues/26866)) ([f824295](https://github.com/Koenkk/zigbee2mqtt/commit/f824295b7efefb78d6f803a4291fb2a485694c96))
|
||||
* **ignore:** update zigbee-herdsman to 3.4.9 ([#26877](https://github.com/Koenkk/zigbee2mqtt/issues/26877)) ([0a41af5](https://github.com/Koenkk/zigbee2mqtt/commit/0a41af5dd23d1806d703b878feaba25d90c2d016))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.10.0 ([#26792](https://github.com/Koenkk/zigbee2mqtt/issues/26792)) ([52c255b](https://github.com/Koenkk/zigbee2mqtt/commit/52c255bcc2a37a7238afe675f7c521c0834e2d9d))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.11.0 ([#26820](https://github.com/Koenkk/zigbee2mqtt/issues/26820)) ([5288549](https://github.com/Koenkk/zigbee2mqtt/commit/5288549744795296a9919828b1a85b1223edd755))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.12.0 ([#26835](https://github.com/Koenkk/zigbee2mqtt/issues/26835)) ([53e8226](https://github.com/Koenkk/zigbee2mqtt/commit/53e8226f0aff0cdfba76f6f30672c0bd3d72d52d))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.13.0 ([#26861](https://github.com/Koenkk/zigbee2mqtt/issues/26861)) ([c5c7f70](https://github.com/Koenkk/zigbee2mqtt/commit/c5c7f70ad6aaca083c4edb4dba783e60f8ec17a5))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.14.0 ([#26886](https://github.com/Koenkk/zigbee2mqtt/issues/26886)) ([6a65769](https://github.com/Koenkk/zigbee2mqtt/commit/6a657690604daacc4a2cc1afa8407fd0d4dc9eaf))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.15.0 ([#26900](https://github.com/Koenkk/zigbee2mqtt/issues/26900)) ([516425b](https://github.com/Koenkk/zigbee2mqtt/commit/516425b44b7f395db7bbed93699b989d6a8276be))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.16.0 ([#26918](https://github.com/Koenkk/zigbee2mqtt/issues/26918)) ([6e6a989](https://github.com/Koenkk/zigbee2mqtt/commit/6e6a989159383a3534ce6d12ea8c9bdc9092d2bc))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.17.0 ([#26925](https://github.com/Koenkk/zigbee2mqtt/issues/26925)) ([ea04f19](https://github.com/Koenkk/zigbee2mqtt/commit/ea04f197090e3a3bc52dc4eb4ecde65146484bfb))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.18.0 ([#26933](https://github.com/Koenkk/zigbee2mqtt/issues/26933)) ([69f0840](https://github.com/Koenkk/zigbee2mqtt/commit/69f0840feb2f8a5332148cfe271d16e413a02ebf))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.3.0 ([#26598](https://github.com/Koenkk/zigbee2mqtt/issues/26598)) ([3941692](https://github.com/Koenkk/zigbee2mqtt/commit/3941692665d96fee575995bb6a372bfb92e0a466))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.4.0 ([#26629](https://github.com/Koenkk/zigbee2mqtt/issues/26629)) ([c603f42](https://github.com/Koenkk/zigbee2mqtt/commit/c603f42c889b5508a56f0f43df7d09dba909b0cd))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.6.0 ([#26691](https://github.com/Koenkk/zigbee2mqtt/issues/26691)) ([06ba62b](https://github.com/Koenkk/zigbee2mqtt/commit/06ba62b8331d8d5db1696c47bd3b643449584b16))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.7.0 ([#26733](https://github.com/Koenkk/zigbee2mqtt/issues/26733)) ([8d2ef6d](https://github.com/Koenkk/zigbee2mqtt/commit/8d2ef6df06bbfcc856a91ef592e8ac54f46a705c))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.8.0 ([#26761](https://github.com/Koenkk/zigbee2mqtt/issues/26761)) ([325199d](https://github.com/Koenkk/zigbee2mqtt/commit/325199d4455adb32f912687827ed01d89e009bc1))
|
||||
* **ignore:** update zigbee2mqtt-frontend to 0.9.5 ([#26814](https://github.com/Koenkk/zigbee2mqtt/issues/26814)) ([9792466](https://github.com/Koenkk/zigbee2mqtt/commit/9792466aac8b94747fe41fe47db766b7a7e93367))
|
||||
* Implement systemd-notify directly ([#26456](https://github.com/Koenkk/zigbee2mqtt/issues/26456)) ([bfeac07](https://github.com/Koenkk/zigbee2mqtt/commit/bfeac07c4c38fb55db99d5f8204e85ec54552564))
|
||||
* Use dynamic import for optional extensions ([#26735](https://github.com/Koenkk/zigbee2mqtt/issues/26735)) ([db578d9](https://github.com/Koenkk/zigbee2mqtt/commit/db578d9d1180c01c14f1b4707e779c28961eb5b8))
|
||||
* Write `ZIGBEE2MQTT_CONFIG_*` envs to yaml as overrides ([#26653](https://github.com/Koenkk/zigbee2mqtt/issues/26653)) ([f6f7ba6](https://github.com/Koenkk/zigbee2mqtt/commit/f6f7ba68c9dd84ccdd82df0769b197756e42bbf7))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ Zigbee2MQTT is made up of three modules, each developed in its own Github projec
|
||||
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`
|
||||
Before submitting changes run `pnpm run check:w` then `pnpm run test:coverage`.
|
||||
|
||||
## Supported devices
|
||||
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"formatter": {
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 4,
|
||||
"lineWidth": 150,
|
||||
"bracketSpacing": false
|
||||
},
|
||||
"files": {
|
||||
"ignore": ["package.json"]
|
||||
},
|
||||
"linter": {
|
||||
"ignore": [],
|
||||
"rules": {
|
||||
"correctness": {
|
||||
"noUnusedImports": "error"
|
||||
},
|
||||
"style": {
|
||||
"noParameterAssign": "off",
|
||||
"useThrowNewError": "error",
|
||||
"useThrowOnlyError": "error",
|
||||
"useNamingConvention": {
|
||||
"level": "error",
|
||||
"options": {
|
||||
"strictCase": false,
|
||||
"conventions": [
|
||||
{
|
||||
"selector": {
|
||||
"kind": "objectLiteralProperty"
|
||||
},
|
||||
"formats": ["snake_case", "camelCase", "CONSTANT_CASE", "PascalCase"]
|
||||
},
|
||||
{
|
||||
"selector": {
|
||||
"kind": "const"
|
||||
},
|
||||
"formats": ["snake_case", "camelCase", "CONSTANT_CASE", "PascalCase"]
|
||||
},
|
||||
{
|
||||
"selector": {
|
||||
"kind": "typeProperty"
|
||||
},
|
||||
"formats": ["snake_case", "camelCase", "CONSTANT_CASE", "PascalCase"]
|
||||
},
|
||||
{
|
||||
"selector": {
|
||||
"kind": "enumMember"
|
||||
},
|
||||
"formats": ["CONSTANT_CASE", "PascalCase"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"performance": {
|
||||
"noDelete": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noConstEnum": "off",
|
||||
"useAwait": "error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"include": ["test/**"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"style": {
|
||||
"noNonNullAssertion": "off",
|
||||
"useNamingConvention": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noImplicitAnyLet": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
const path = require('path');
|
||||
process.env['ZIGBEE2MQTT_DATA'] = process.env['ZIGBEE2MQTT_DATA'] || path.join(process.env['HOME'], '.z2m');
|
||||
require('./index');
|
||||
const path = require("node:path");
|
||||
process.env.ZIGBEE2MQTT_DATA = process.env.ZIGBEE2MQTT_DATA || path.join(process.env.HOME, ".z2m");
|
||||
require("./index");
|
||||
|
||||
+2
-10
@@ -1,14 +1,6 @@
|
||||
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
|
||||
FROM alpine:3.21 AS base
|
||||
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
@@ -20,7 +12,7 @@ FROM base AS deps
|
||||
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 && \
|
||||
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` && \
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
version: '3'
|
||||
services:
|
||||
zigbee2mqtt:
|
||||
container_name: zigbee2mqtt
|
||||
image: koenkk/zigbee2mqtt
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
devices:
|
||||
# CC251
|
||||
#- /dev/ttyUSB_cc2531:/dev/ttyACM0
|
||||
# CC2530 / GBAN GB2530S
|
||||
#- /dev/ttyUSB_cc2530:/dev/ttyACM0
|
||||
restart: always
|
||||
network_mode: host
|
||||
@@ -9,9 +9,4 @@ fi
|
||||
|
||||
echo "Using '$DATA' as data directory"
|
||||
|
||||
if [ ! -f "$DATA/configuration.yaml" ]; then
|
||||
echo "Creating configuration file..."
|
||||
cp /app/configuration.example.yaml "$DATA/configuration.yaml"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import eslint from '@eslint/js';
|
||||
import eslintConfigPrettier from 'eslint-config-prettier';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/await-thenable': 'error',
|
||||
'@typescript-eslint/ban-ts-comment': 'error',
|
||||
'@typescript-eslint/explicit-function-return-type': 'error',
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/no-unused-vars': 'error',
|
||||
'array-bracket-spacing': ['error', 'never'],
|
||||
'@typescript-eslint/return-await': ['error', 'always'],
|
||||
'object-curly-spacing': ['error', 'never'],
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ['dist/', '**/*.js', '**/*.mjs'],
|
||||
},
|
||||
eslintConfigPrettier,
|
||||
);
|
||||
@@ -1,30 +1,29 @@
|
||||
const semver = require('semver');
|
||||
const engines = require('./package.json').engines;
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const {exec} = require('child_process');
|
||||
const {rimrafSync} = require('rimraf');
|
||||
require('source-map-support').install();
|
||||
const semver = require("semver");
|
||||
const engines = require("./package.json").engines;
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const {exec} = require("node:child_process");
|
||||
require("source-map-support").install();
|
||||
|
||||
let controller;
|
||||
let stopping = false;
|
||||
let watchdog = process.env.Z2M_WATCHDOG != undefined;
|
||||
const watchdog = process.env.Z2M_WATCHDOG != null;
|
||||
let watchdogCount = 0;
|
||||
let unsolicitedStop = false;
|
||||
// csv in minutes, default: 1min, 5min, 15min, 30min, 60min
|
||||
let watchdogDelays = [2000, 60000, 300000, 900000, 1800000, 3600000];
|
||||
|
||||
if (watchdog && process.env.Z2M_WATCHDOG !== 'default') {
|
||||
if (/^(?:(?:[0-9]*[.])?[0-9]+)+(?:,?(?:[0-9]*[.])?[0-9]+)*$/.test(process.env.Z2M_WATCHDOG)) {
|
||||
watchdogDelays = process.env.Z2M_WATCHDOG.split(',').map((v) => parseFloat(v) * 60000);
|
||||
if (watchdog && process.env.Z2M_WATCHDOG !== "default") {
|
||||
if (/^\d+(.\d+)?(,\d+(.\d+)?)*$/.test(process.env.Z2M_WATCHDOG)) {
|
||||
watchdogDelays = process.env.Z2M_WATCHDOG.split(",").map((v) => Number.parseFloat(v) * 60000);
|
||||
} else {
|
||||
console.log(`Invalid watchdog delays (must use number-only CSV format representing minutes, example: 'Z2M_WATCHDOG=1,5,15,30,60'.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const hashFile = path.join(__dirname, 'dist', '.hash');
|
||||
const hashFile = path.join(__dirname, "dist", ".hash");
|
||||
|
||||
async function triggerWatchdog(code) {
|
||||
const delay = watchdogDelays[watchdogCount];
|
||||
@@ -58,10 +57,16 @@ async function exit(code, restart = false) {
|
||||
}
|
||||
|
||||
async function currentHash() {
|
||||
const git = require('git-last-commit');
|
||||
return await new Promise((resolve) => {
|
||||
exec("git rev-parse --short=8 HEAD", (error, stdout) => {
|
||||
const commitHash = stdout.trim();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
git.getLastCommit((err, commit) => (err ? resolve('unknown') : resolve(commit.shortHash)));
|
||||
if (error || commitHash === "") {
|
||||
resolve("unknown");
|
||||
} else {
|
||||
resolve(commitHash);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -72,30 +77,30 @@ async function writeHash() {
|
||||
}
|
||||
|
||||
async function build(reason) {
|
||||
return new Promise((resolve, reject) => {
|
||||
process.stdout.write(`Building Zigbee2MQTT... (${reason})`);
|
||||
rimrafSync('dist');
|
||||
process.stdout.write(`Building Zigbee2MQTT... (${reason})`);
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const env = {...process.env};
|
||||
const _600mb = 629145600;
|
||||
const mb600 = 629145600;
|
||||
|
||||
if (_600mb > os.totalmem() && !env.NODE_OPTIONS) {
|
||||
if (mb600 > os.totalmem() && !env.NODE_OPTIONS) {
|
||||
// Prevent OOM on tsc compile for system with low memory
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/12034
|
||||
env.NODE_OPTIONS = '--max_old_space_size=256';
|
||||
env.NODE_OPTIONS = "--max_old_space_size=256";
|
||||
}
|
||||
|
||||
exec('pnpm run build', {env, cwd: __dirname}, async (err, stdout, stderr) => {
|
||||
// clean build, prevent failures due to tsc incremental building
|
||||
exec("pnpm run prepack", {env, cwd: __dirname}, (err) => {
|
||||
if (err) {
|
||||
process.stdout.write(', failed\n');
|
||||
process.stdout.write(", failed\n");
|
||||
|
||||
if (err.code === 134) {
|
||||
process.stderr.write('\n\nBuild failed; ran out-of-memory, free some memory (RAM) and start again\n\n');
|
||||
process.stderr.write("\n\nBuild failed; ran out-of-memory, free some memory (RAM) and start again\n\n");
|
||||
}
|
||||
|
||||
reject(err);
|
||||
} else {
|
||||
process.stdout.write(', finished\n');
|
||||
process.stdout.write(", finished\n");
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
@@ -104,58 +109,41 @@ async function build(reason) {
|
||||
|
||||
async function checkDist() {
|
||||
if (!fs.existsSync(hashFile)) {
|
||||
await build('initial build');
|
||||
await build("initial build");
|
||||
}
|
||||
|
||||
const distHash = fs.readFileSync(hashFile, 'utf-8');
|
||||
const distHash = fs.readFileSync(hashFile, "utf8");
|
||||
const hash = await currentHash();
|
||||
if (hash !== 'unknown' && distHash !== hash) {
|
||||
await build('hash changed');
|
||||
|
||||
if (hash !== "unknown" && distHash !== hash) {
|
||||
await build("hash changed");
|
||||
}
|
||||
}
|
||||
|
||||
async function start() {
|
||||
console.log(`Starting Zigbee2MQTT ${watchdog ? `with watchdog (${watchdogDelays})` : `without watchdog`}.`);
|
||||
console.log(`Starting Zigbee2MQTT ${watchdog ? `with watchdog (${watchdogDelays})` : "without watchdog"}.`);
|
||||
await checkDist();
|
||||
|
||||
const version = engines.node;
|
||||
|
||||
if (!semver.satisfies(process.version, version)) {
|
||||
console.log(`\t\tZigbee2MQTT requires node version ${version}, you are running ${process.version}!\n`);
|
||||
}
|
||||
|
||||
// Validate settings
|
||||
const settings = require('./dist/util/settings');
|
||||
|
||||
settings.reRead();
|
||||
|
||||
// gc
|
||||
{
|
||||
const settingsMigration = require('./dist/util/settingsMigration');
|
||||
const version = engines.node;
|
||||
|
||||
settingsMigration.migrateIfNecessary();
|
||||
}
|
||||
|
||||
const errors = settings.validate();
|
||||
|
||||
if (errors.length > 0) {
|
||||
unsolicitedStop = false;
|
||||
|
||||
console.log(`\n\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!`);
|
||||
console.log(' READ THIS CAREFULLY\n');
|
||||
console.log(`Refusing to start because configuration is not valid, found the following errors:`);
|
||||
|
||||
for (const error of errors) {
|
||||
console.log(`- ${error}`);
|
||||
if (!semver.satisfies(process.version, version)) {
|
||||
console.log(`\t\tZigbee2MQTT requires node version ${version}, you are running ${process.version}!\n`);
|
||||
}
|
||||
|
||||
console.log(`\nIf you don't know how to solve this, read https://www.zigbee2mqtt.io/guide/configuration`);
|
||||
console.log(`\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n\n`);
|
||||
const {onboard} = require("./dist/util/onboarding");
|
||||
|
||||
return exit(1);
|
||||
const success = await onboard();
|
||||
|
||||
if (!success) {
|
||||
unsolicitedStop = false;
|
||||
|
||||
return await exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const {Controller} = require('./dist/controller');
|
||||
const {Controller} = require("./dist/controller");
|
||||
controller = new Controller(restart, exit);
|
||||
|
||||
await controller.start();
|
||||
@@ -184,17 +172,17 @@ async function handleQuit() {
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module || require.main.filename.endsWith(path.sep + 'cli.js')) {
|
||||
if (process.argv.length === 3 && process.argv[2] === 'writehash') {
|
||||
if (require.main === module || require.main.filename.endsWith(`${path.sep}cli.js`)) {
|
||||
if (process.argv.length === 3 && process.argv[2] === "writehash") {
|
||||
writeHash();
|
||||
} else {
|
||||
process.on('SIGINT', handleQuit);
|
||||
process.on('SIGTERM', handleQuit);
|
||||
process.on("SIGINT", handleQuit);
|
||||
process.on("SIGTERM", handleQuit);
|
||||
start();
|
||||
}
|
||||
} else {
|
||||
process.on('SIGINT', handleQuit);
|
||||
process.on('SIGTERM', handleQuit);
|
||||
process.on("SIGINT", handleQuit);
|
||||
process.on("SIGTERM", handleQuit);
|
||||
|
||||
module.exports = {start};
|
||||
}
|
||||
|
||||
+206
-147
@@ -1,79 +1,44 @@
|
||||
import type {IClientPublishOptions} from 'mqtt';
|
||||
import type * as SdNotify from 'sd-notify';
|
||||
import type Extension from "./extension/extension";
|
||||
import type {Zigbee2MQTTAPI} from "./types/api";
|
||||
|
||||
import type {Zigbee2MQTTAPI} from './types/api';
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
|
||||
import assert from 'node:assert';
|
||||
import {setLogger as zhSetLogger} from "zigbee-herdsman";
|
||||
import {setLogger as zhcSetLogger} from "zigbee-herdsman-converters";
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
import {setLogger as zhSetLogger} from 'zigbee-herdsman';
|
||||
import {setLogger as zhcSetLogger} from 'zigbee-herdsman-converters';
|
||||
|
||||
import EventBus from './eventBus';
|
||||
import ExtensionAvailability from './extension/availability';
|
||||
import ExtensionBind from './extension/bind';
|
||||
import ExtensionBridge from './extension/bridge';
|
||||
import ExtensionConfigure from './extension/configure';
|
||||
import ExtensionExternalConverters from './extension/externalConverters';
|
||||
import ExtensionExternalExtensions from './extension/externalExtensions';
|
||||
import EventBus from "./eventBus";
|
||||
// Extensions
|
||||
import ExtensionFrontend from './extension/frontend';
|
||||
import ExtensionGroups from './extension/groups';
|
||||
import ExtensionHomeAssistant from './extension/homeassistant';
|
||||
import ExtensionNetworkMap from './extension/networkMap';
|
||||
import ExtensionOnEvent from './extension/onEvent';
|
||||
import ExtensionOTAUpdate from './extension/otaUpdate';
|
||||
import ExtensionPublish from './extension/publish';
|
||||
import ExtensionReceive from './extension/receive';
|
||||
import MQTT from './mqtt';
|
||||
import State from './state';
|
||||
import logger from './util/logger';
|
||||
import * as settings from './util/settings';
|
||||
import utils from './util/utils';
|
||||
import Zigbee from './zigbee';
|
||||
|
||||
type SdNotifyType = typeof SdNotify;
|
||||
|
||||
const AllExtensions = [
|
||||
ExtensionPublish,
|
||||
ExtensionReceive,
|
||||
ExtensionNetworkMap,
|
||||
ExtensionHomeAssistant,
|
||||
ExtensionConfigure,
|
||||
ExtensionBridge,
|
||||
ExtensionGroups,
|
||||
ExtensionBind,
|
||||
ExtensionOnEvent,
|
||||
ExtensionOTAUpdate,
|
||||
ExtensionExternalConverters,
|
||||
ExtensionFrontend,
|
||||
ExtensionExternalExtensions,
|
||||
ExtensionAvailability,
|
||||
];
|
||||
|
||||
type ExtensionArgs = [
|
||||
Zigbee,
|
||||
MQTT,
|
||||
State,
|
||||
PublishEntityState,
|
||||
EventBus,
|
||||
enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
|
||||
restartCallback: () => Promise<void>,
|
||||
addExtension: (extension: Extension) => Promise<void>,
|
||||
];
|
||||
import ExtensionAvailability from "./extension/availability";
|
||||
import ExtensionBind from "./extension/bind";
|
||||
import ExtensionBridge from "./extension/bridge";
|
||||
import ExtensionConfigure from "./extension/configure";
|
||||
import ExtensionExternalConverters from "./extension/externalConverters";
|
||||
import ExtensionExternalExtensions from "./extension/externalExtensions";
|
||||
import ExtensionGroups from "./extension/groups";
|
||||
import ExtensionNetworkMap from "./extension/networkMap";
|
||||
import ExtensionOnEvent from "./extension/onEvent";
|
||||
import ExtensionOTAUpdate from "./extension/otaUpdate";
|
||||
import ExtensionPublish from "./extension/publish";
|
||||
import ExtensionReceive from "./extension/receive";
|
||||
import Mqtt, {type MqttPublishOptions} from "./mqtt";
|
||||
import State from "./state";
|
||||
import logger from "./util/logger";
|
||||
import {initSdNotify} from "./util/sd-notify";
|
||||
import * as settings from "./util/settings";
|
||||
import utils from "./util/utils";
|
||||
import Zigbee from "./zigbee";
|
||||
|
||||
export class Controller {
|
||||
private eventBus: EventBus;
|
||||
private zigbee: Zigbee;
|
||||
private state: State;
|
||||
private mqtt: MQTT;
|
||||
private mqtt: Mqtt;
|
||||
private restartCallback: () => Promise<void>;
|
||||
private exitCallback: (code: number, restart: boolean) => Promise<void>;
|
||||
private extensions: Extension[];
|
||||
private extensionArgs: ExtensionArgs;
|
||||
private sdNotify: SdNotifyType | undefined;
|
||||
public readonly extensions: Set<Extension>;
|
||||
public readonly extensionArgs: ConstructorParameters<typeof Extension>;
|
||||
private sdNotify: Awaited<ReturnType<typeof initSdNotify>>;
|
||||
|
||||
constructor(restartCallback: () => Promise<void>, exitCallback: (code: number, restart: boolean) => Promise<void>) {
|
||||
logger.init();
|
||||
@@ -81,7 +46,7 @@ export class Controller {
|
||||
zhcSetLogger(logger);
|
||||
this.eventBus = new EventBus();
|
||||
this.zigbee = new Zigbee(this.eventBus);
|
||||
this.mqtt = new MQTT(this.eventBus);
|
||||
this.mqtt = new Mqtt(this.eventBus);
|
||||
this.state = new State(this.eventBus, this.zigbee);
|
||||
this.restartCallback = restartCallback;
|
||||
this.exitCallback = exitCallback;
|
||||
@@ -98,7 +63,7 @@ export class Controller {
|
||||
this.addExtension,
|
||||
];
|
||||
|
||||
this.extensions = [
|
||||
this.extensions = new Set([
|
||||
new ExtensionExternalConverters(...this.extensionArgs),
|
||||
new ExtensionOnEvent(...this.extensionArgs),
|
||||
new ExtensionBridge(...this.extensionArgs),
|
||||
@@ -111,47 +76,43 @@ export class Controller {
|
||||
new ExtensionOTAUpdate(...this.extensionArgs),
|
||||
new ExtensionExternalExtensions(...this.extensionArgs),
|
||||
new ExtensionAvailability(...this.extensionArgs),
|
||||
];
|
||||
|
||||
if (settings.get().frontend.enabled) {
|
||||
this.extensions.push(new ExtensionFrontend(...this.extensionArgs));
|
||||
}
|
||||
|
||||
if (settings.get().homeassistant.enabled) {
|
||||
this.extensions.push(new ExtensionHomeAssistant(...this.extensionArgs));
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (settings.get().frontend.enabled) {
|
||||
const {Frontend} = await import("./extension/frontend.js");
|
||||
|
||||
this.extensions.add(new Frontend(...this.extensionArgs));
|
||||
}
|
||||
|
||||
if (settings.get().homeassistant.enabled) {
|
||||
const {HomeAssistant} = await import("./extension/homeassistant.js");
|
||||
|
||||
this.extensions.add(new HomeAssistant(...this.extensionArgs));
|
||||
}
|
||||
|
||||
this.state.start();
|
||||
|
||||
const info = await utils.getZigbee2MQTTVersion();
|
||||
logger.info(`Starting Zigbee2MQTT version ${info.version} (commit #${info.commitHash})`);
|
||||
|
||||
try {
|
||||
this.sdNotify = process.env.NOTIFY_SOCKET ? await import('sd-notify') : undefined;
|
||||
logger.debug('sd-notify loaded');
|
||||
/* v8 ignore start */
|
||||
} catch {
|
||||
logger.debug('sd-notify is not installed');
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
// Start zigbee
|
||||
try {
|
||||
await this.zigbee.start();
|
||||
this.eventBus.onAdapterDisconnected(this, this.onZigbeeAdapterDisconnected);
|
||||
} catch (error) {
|
||||
logger.error('Failed to start zigbee-herdsman');
|
||||
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',
|
||||
"Check https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start_crashes-runtime.html for possible solutions",
|
||||
);
|
||||
logger.error('Exiting...');
|
||||
logger.error("Exiting...");
|
||||
// biome-ignore lint/style/noNonNullAssertion: always Error
|
||||
logger.error((error as Error).stack!);
|
||||
|
||||
/* 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');
|
||||
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 */
|
||||
|
||||
@@ -164,8 +125,9 @@ export class Controller {
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
// `definition` validated by `isSupported`
|
||||
const model = device.isSupported
|
||||
? `${device.definition!.model} - ${device.definition!.vendor} ${device.definition!.description}`
|
||||
: 'Not supported';
|
||||
? // biome-ignore lint/style/noNonNullAssertion: valid from `isSupported`
|
||||
`${device.definition!.model} - ${device.definition!.vendor} ${device.definition!.description}`
|
||||
: "Not supported";
|
||||
logger.info(`${device.name} (${device.ieeeAddr}): ${model} (${device.zh.type})`);
|
||||
|
||||
deviceCount++;
|
||||
@@ -182,71 +144,173 @@ export class Controller {
|
||||
return await this.exit(1);
|
||||
}
|
||||
|
||||
// Call extensions
|
||||
await this.callExtensions('start', [...this.extensions]);
|
||||
// copy current Set of extensions to ignore possible external extensions added while looping
|
||||
for (const extension of new Set(this.extensions)) {
|
||||
await this.startExtension(extension);
|
||||
}
|
||||
|
||||
// Send all cached states.
|
||||
if (settings.get().advanced.cache_state_send_on_startup && settings.get().advanced.cache_state) {
|
||||
for (const entity of this.zigbee.devicesAndGroupsIterator()) {
|
||||
if (this.state.exists(entity)) {
|
||||
await this.publishEntityState(entity, this.state.get(entity), 'publishCached');
|
||||
await this.publishEntityState(entity, this.state.get(entity), "publishCached");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.eventBus.onLastSeenChanged(this, (data) => utils.publishLastSeen(data, settings.get(), false, this.publishEntityState));
|
||||
|
||||
logger.info(`Zigbee2MQTT started!`);
|
||||
logger.info("Zigbee2MQTT started!");
|
||||
|
||||
const watchdogInterval = this.sdNotify?.watchdogInterval() || 0;
|
||||
if (watchdogInterval > 0) {
|
||||
this.sdNotify?.startWatchdogMode(Math.floor(watchdogInterval / 2));
|
||||
}
|
||||
this.sdNotify?.ready();
|
||||
this.sdNotify = await initSdNotify();
|
||||
|
||||
settings.setOnboarding(false);
|
||||
}
|
||||
|
||||
@bind async enableDisableExtension(enable: boolean, name: string): Promise<void> {
|
||||
if (!enable) {
|
||||
const extension = this.extensions.find((e) => e.constructor.name === name);
|
||||
if (extension) {
|
||||
await this.callExtensions('stop', [extension]);
|
||||
this.extensions.splice(this.extensions.indexOf(extension), 1);
|
||||
if (enable) {
|
||||
switch (name) {
|
||||
case "Frontend": {
|
||||
if (!settings.get().frontend.enabled) {
|
||||
throw new Error("Tried to enable Frontend extension disabled in settings");
|
||||
}
|
||||
|
||||
// this is not actually used, not tested either
|
||||
/* v8 ignore start */
|
||||
const {Frontend} = await import("./extension/frontend.js");
|
||||
|
||||
await this.addExtension(new Frontend(...this.extensionArgs));
|
||||
|
||||
break;
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
case "HomeAssistant": {
|
||||
if (!settings.get().homeassistant.enabled) {
|
||||
throw new Error("Tried to enable HomeAssistant extension disabled in settings");
|
||||
}
|
||||
|
||||
const {HomeAssistant} = await import("./extension/homeassistant.js");
|
||||
|
||||
await this.addExtension(new HomeAssistant(...this.extensionArgs));
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error(
|
||||
`Extension ${name} does not exist (should be added with 'addExtension') or is built-in that cannot be enabled at runtime`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const Extension = AllExtensions.find((e) => e.name === name);
|
||||
assert(Extension, `Extension '${name}' does not exist`);
|
||||
const extension = new Extension(...this.extensionArgs);
|
||||
this.extensions.push(extension);
|
||||
await this.callExtensions('start', [extension]);
|
||||
switch (name) {
|
||||
case "Frontend": {
|
||||
if (settings.get().frontend.enabled) {
|
||||
throw new Error("Tried to disable Frontend extension enabled in settings");
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "HomeAssistant": {
|
||||
if (settings.get().homeassistant.enabled) {
|
||||
throw new Error("Tried to disable HomeAssistant extension enabled in settings");
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "Availability":
|
||||
case "Bind":
|
||||
case "Bridge":
|
||||
case "Configure":
|
||||
case "ExternalConverters":
|
||||
case "ExternalExtensions":
|
||||
case "Groups":
|
||||
case "NetworkMap":
|
||||
case "OnEvent":
|
||||
case "OTAUpdate":
|
||||
case "Publish":
|
||||
case "Receive": {
|
||||
throw new Error(`Built-in extension ${name} cannot be disabled at runtime`);
|
||||
}
|
||||
}
|
||||
|
||||
const extension = this.getExtension(name);
|
||||
|
||||
if (extension) {
|
||||
await this.removeExtension(extension);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public getExtension(name: string): Extension | undefined {
|
||||
for (const extension of this.extensions) {
|
||||
if (extension.constructor.name === name) {
|
||||
return extension;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bind async addExtension(extension: Extension): Promise<void> {
|
||||
this.extensions.push(extension);
|
||||
await this.callExtensions('start', [extension]);
|
||||
for (const ext of this.extensions) {
|
||||
if (ext.constructor.name === extension.constructor.name) {
|
||||
throw new Error(`Extension with name ${ext.constructor.name} already present`);
|
||||
}
|
||||
}
|
||||
|
||||
this.extensions.add(extension);
|
||||
await this.startExtension(extension);
|
||||
}
|
||||
|
||||
async removeExtension(extension: Extension): Promise<void> {
|
||||
if (this.extensions.delete(extension)) {
|
||||
await this.stopExtension(extension);
|
||||
}
|
||||
}
|
||||
|
||||
private async startExtension(extension: Extension): Promise<void> {
|
||||
try {
|
||||
await extension.start();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to start '${extension.constructor.name}' (${(error as Error).stack})`);
|
||||
}
|
||||
}
|
||||
|
||||
private async stopExtension(extension: Extension): Promise<void> {
|
||||
try {
|
||||
await extension.stop();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to stop '${extension.constructor.name}' (${(error as Error).stack})`);
|
||||
}
|
||||
}
|
||||
|
||||
async stop(restart = false): Promise<void> {
|
||||
this.sdNotify?.stopping(process.pid);
|
||||
this.sdNotify?.notifyStopping();
|
||||
|
||||
let code = 0;
|
||||
|
||||
for (const extension of this.extensions) {
|
||||
try {
|
||||
await extension.stop();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to stop '${extension.constructor.name}' (${(error as Error).stack})`);
|
||||
code = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Call extensions
|
||||
await this.callExtensions('stop', this.extensions);
|
||||
this.eventBus.removeListeners(this);
|
||||
|
||||
// Wrap-up
|
||||
this.state.stop();
|
||||
await this.mqtt.disconnect();
|
||||
let code = 0;
|
||||
|
||||
try {
|
||||
await this.zigbee.stop();
|
||||
logger.info('Stopped Zigbee2MQTT');
|
||||
logger.info("Stopped Zigbee2MQTT");
|
||||
} catch (error) {
|
||||
logger.error(`Failed to stop Zigbee2MQTT (${(error as Error).message})`);
|
||||
logger.error(`Failed to stop Zigbee2MQTT (${(error as Error).stack})`);
|
||||
code = 1;
|
||||
}
|
||||
|
||||
this.sdNotify?.stopWatchdogMode();
|
||||
this.sdNotify?.stop();
|
||||
return await this.exit(code, restart);
|
||||
}
|
||||
|
||||
@@ -256,12 +320,12 @@ export class Controller {
|
||||
}
|
||||
|
||||
@bind async onZigbeeAdapterDisconnected(): Promise<void> {
|
||||
logger.error('Adapter disconnected, stopping');
|
||||
logger.error("Adapter disconnected, stopping");
|
||||
await this.stop();
|
||||
}
|
||||
|
||||
@bind async publishEntityState(entity: Group | Device, payload: KeyValue, stateChangeReason?: StateChangeReason): Promise<void> {
|
||||
let message: Zigbee2MQTTAPI['{friendlyName}'] = {...payload};
|
||||
let message: Zigbee2MQTTAPI["{friendlyName}"] = {...payload};
|
||||
|
||||
// Update state cache with new state.
|
||||
const newState = this.state.set(entity, payload, stateChangeReason);
|
||||
@@ -271,14 +335,19 @@ export class Controller {
|
||||
message = newState;
|
||||
}
|
||||
|
||||
const options: IClientPublishOptions = {
|
||||
retain: utils.getObjectProperty(entity.options, 'retain', false),
|
||||
qos: utils.getObjectProperty(entity.options, 'qos', 0),
|
||||
const options: MakePartialExcept<MqttPublishOptions, "clientOptions" | "meta"> = {
|
||||
clientOptions: {
|
||||
retain: utils.getObjectProperty(entity.options, "retain", false),
|
||||
qos: utils.getObjectProperty(entity.options, "qos", 0),
|
||||
},
|
||||
meta: {
|
||||
isEntityState: true,
|
||||
},
|
||||
};
|
||||
const retention = utils.getObjectProperty<number | false>(entity.options, 'retention', false);
|
||||
const retention = utils.getObjectProperty<number | false>(entity.options, "retention", false);
|
||||
|
||||
if (retention !== false) {
|
||||
options.properties = {messageExpiryInterval: retention};
|
||||
options.clientOptions.properties = {messageExpiryInterval: retention};
|
||||
}
|
||||
|
||||
if (entity.isDevice() && settings.get().mqtt.include_device_information) {
|
||||
@@ -299,13 +368,13 @@ export class Controller {
|
||||
// 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],
|
||||
manufacturerName: entity.zh.manufacturerName?.split("\u0000")[0],
|
||||
};
|
||||
}
|
||||
|
||||
// Add lastseen
|
||||
const lastSeen = settings.get().advanced.last_seen;
|
||||
if (entity.isDevice() && lastSeen !== 'disable' && entity.zh.lastSeen) {
|
||||
if (entity.isDevice() && lastSeen !== "disable" && entity.zh.lastSeen) {
|
||||
message.last_seen = utils.formatDate(entity.zh.lastSeen, lastSeen);
|
||||
}
|
||||
|
||||
@@ -323,11 +392,11 @@ export class Controller {
|
||||
|
||||
if (!utils.objectIsEmpty(message)) {
|
||||
const output = settings.get().advanced.output;
|
||||
if (output === 'attribute_and_json' || output === 'json') {
|
||||
if (output === "attribute_and_json" || output === "json") {
|
||||
await this.mqtt.publish(entity.name, stringify(message), options);
|
||||
}
|
||||
|
||||
if (output === 'attribute_and_json' || output === 'attribute') {
|
||||
if (output === "attribute_and_json" || output === "attribute") {
|
||||
await this.iteratePayloadAttributeOutput(`${entity.name}/`, message, options);
|
||||
}
|
||||
}
|
||||
@@ -335,25 +404,25 @@ export class Controller {
|
||||
this.eventBus.emitPublishEntityState({entity, message, stateChangeReason, payload});
|
||||
}
|
||||
|
||||
async iteratePayloadAttributeOutput(topicRoot: string, payload: KeyValue, options: IClientPublishOptions): Promise<void> {
|
||||
async iteratePayloadAttributeOutput(topicRoot: string, payload: KeyValue, options: Partial<MqttPublishOptions>): Promise<void> {
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
let subPayload = value;
|
||||
let message = null;
|
||||
|
||||
// Special cases
|
||||
if (key === 'color' && utils.objectHasProperties(subPayload, ['r', 'g', 'b'])) {
|
||||
if (key === "color" && utils.objectHasProperties(subPayload, ["r", "g", "b"])) {
|
||||
subPayload = [subPayload.r, subPayload.g, subPayload.b];
|
||||
}
|
||||
|
||||
// Check Array first, since it is also an Object
|
||||
if (subPayload === null || subPayload === undefined) {
|
||||
message = '';
|
||||
message = "";
|
||||
} else if (Array.isArray(subPayload)) {
|
||||
message = subPayload.map((x) => `${x}`).join(',');
|
||||
} else if (typeof subPayload === 'object') {
|
||||
message = subPayload.map((x) => `${x}`).join(",");
|
||||
} else if (typeof subPayload === "object") {
|
||||
await this.iteratePayloadAttributeOutput(`${topicRoot}${key}-`, subPayload, options);
|
||||
} else {
|
||||
message = typeof subPayload === 'string' ? subPayload : stringify(subPayload);
|
||||
message = typeof subPayload === "string" ? subPayload : stringify(subPayload);
|
||||
}
|
||||
|
||||
if (message !== null) {
|
||||
@@ -361,14 +430,4 @@ export class Controller {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async callExtensions(method: 'start' | 'stop', extensions: Extension[]): Promise<void> {
|
||||
for (const extension of extensions) {
|
||||
try {
|
||||
await extension[method]?.();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to call '${extension.constructor.name}' '${method}' (${(error as Error).stack})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+57
-49
@@ -1,6 +1,6 @@
|
||||
import events from 'node:events';
|
||||
import events from "node:events";
|
||||
|
||||
import logger from './util/logger';
|
||||
import logger from "./util/logger";
|
||||
|
||||
type ListenerKey = object;
|
||||
|
||||
@@ -35,7 +35,7 @@ type EventBusListener<K> = K extends keyof EventBusMap
|
||||
: never;
|
||||
|
||||
export default class EventBus {
|
||||
private callbacksByExtension: {[s: string]: {event: keyof EventBusMap; callback: EventBusListener<keyof EventBusMap>}[]} = {};
|
||||
private callbacksByExtension = new Map<string, {event: keyof EventBusMap; callback: EventBusListener<keyof EventBusMap>}[]>();
|
||||
private emitter = new events.EventEmitter<EventBusMap>();
|
||||
|
||||
constructor() {
|
||||
@@ -43,150 +43,150 @@ export default class EventBus {
|
||||
}
|
||||
|
||||
public emitAdapterDisconnected(): void {
|
||||
this.emitter.emit('adapterDisconnected');
|
||||
this.emitter.emit("adapterDisconnected");
|
||||
}
|
||||
public onAdapterDisconnected(key: ListenerKey, callback: () => void): void {
|
||||
this.on('adapterDisconnected', callback, key);
|
||||
this.on("adapterDisconnected", callback, key);
|
||||
}
|
||||
|
||||
public emitPermitJoinChanged(data: eventdata.PermitJoinChanged): void {
|
||||
this.emitter.emit('permitJoinChanged', data);
|
||||
this.emitter.emit("permitJoinChanged", data);
|
||||
}
|
||||
public onPermitJoinChanged(key: ListenerKey, callback: (data: eventdata.PermitJoinChanged) => void): void {
|
||||
this.on('permitJoinChanged', callback, key);
|
||||
this.on("permitJoinChanged", callback, key);
|
||||
}
|
||||
|
||||
public emitEntityRenamed(data: eventdata.EntityRenamed): void {
|
||||
this.emitter.emit('deviceRenamed', data);
|
||||
this.emitter.emit("deviceRenamed", data);
|
||||
}
|
||||
public onEntityRenamed(key: ListenerKey, callback: (data: eventdata.EntityRenamed) => void): void {
|
||||
this.on('deviceRenamed', callback, key);
|
||||
this.on("deviceRenamed", callback, key);
|
||||
}
|
||||
|
||||
public emitEntityRemoved(data: eventdata.EntityRemoved): void {
|
||||
this.emitter.emit('deviceRemoved', data);
|
||||
this.emitter.emit("deviceRemoved", data);
|
||||
}
|
||||
public onEntityRemoved(key: ListenerKey, callback: (data: eventdata.EntityRemoved) => void): void {
|
||||
this.on('deviceRemoved', callback, key);
|
||||
this.on("deviceRemoved", callback, key);
|
||||
}
|
||||
|
||||
public emitLastSeenChanged(data: eventdata.LastSeenChanged): void {
|
||||
this.emitter.emit('lastSeenChanged', data);
|
||||
this.emitter.emit("lastSeenChanged", data);
|
||||
}
|
||||
public onLastSeenChanged(key: ListenerKey, callback: (data: eventdata.LastSeenChanged) => void): void {
|
||||
this.on('lastSeenChanged', callback, key);
|
||||
this.on("lastSeenChanged", callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceNetworkAddressChanged(data: eventdata.DeviceNetworkAddressChanged): void {
|
||||
this.emitter.emit('deviceNetworkAddressChanged', data);
|
||||
this.emitter.emit("deviceNetworkAddressChanged", data);
|
||||
}
|
||||
public onDeviceNetworkAddressChanged(key: ListenerKey, callback: (data: eventdata.DeviceNetworkAddressChanged) => void): void {
|
||||
this.on('deviceNetworkAddressChanged', callback, key);
|
||||
this.on("deviceNetworkAddressChanged", callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceAnnounce(data: eventdata.DeviceAnnounce): void {
|
||||
this.emitter.emit('deviceAnnounce', data);
|
||||
this.emitter.emit("deviceAnnounce", data);
|
||||
}
|
||||
public onDeviceAnnounce(key: ListenerKey, callback: (data: eventdata.DeviceAnnounce) => void): void {
|
||||
this.on('deviceAnnounce', callback, key);
|
||||
this.on("deviceAnnounce", callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceInterview(data: eventdata.DeviceInterview): void {
|
||||
this.emitter.emit('deviceInterview', data);
|
||||
this.emitter.emit("deviceInterview", data);
|
||||
}
|
||||
public onDeviceInterview(key: ListenerKey, callback: (data: eventdata.DeviceInterview) => void): void {
|
||||
this.on('deviceInterview', callback, key);
|
||||
this.on("deviceInterview", callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceJoined(data: eventdata.DeviceJoined): void {
|
||||
this.emitter.emit('deviceJoined', data);
|
||||
this.emitter.emit("deviceJoined", data);
|
||||
}
|
||||
public onDeviceJoined(key: ListenerKey, callback: (data: eventdata.DeviceJoined) => void): void {
|
||||
this.on('deviceJoined', callback, key);
|
||||
this.on("deviceJoined", callback, key);
|
||||
}
|
||||
|
||||
public emitEntityOptionsChanged(data: eventdata.EntityOptionsChanged): void {
|
||||
this.emitter.emit('entityOptionsChanged', data);
|
||||
this.emitter.emit("entityOptionsChanged", data);
|
||||
}
|
||||
public onEntityOptionsChanged(key: ListenerKey, callback: (data: eventdata.EntityOptionsChanged) => void): void {
|
||||
this.on('entityOptionsChanged', callback, key);
|
||||
this.on("entityOptionsChanged", callback, key);
|
||||
}
|
||||
|
||||
public emitExposesChanged(data: eventdata.ExposesChanged): void {
|
||||
this.emitter.emit('exposesChanged', data);
|
||||
this.emitter.emit("exposesChanged", data);
|
||||
}
|
||||
public onExposesChanged(key: ListenerKey, callback: (data: eventdata.ExposesChanged) => void): void {
|
||||
this.on('exposesChanged', callback, key);
|
||||
this.on("exposesChanged", callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceLeave(data: eventdata.DeviceLeave): void {
|
||||
this.emitter.emit('deviceLeave', data);
|
||||
this.emitter.emit("deviceLeave", data);
|
||||
}
|
||||
public onDeviceLeave(key: ListenerKey, callback: (data: eventdata.DeviceLeave) => void): void {
|
||||
this.on('deviceLeave', callback, key);
|
||||
this.on("deviceLeave", callback, key);
|
||||
}
|
||||
|
||||
public emitDeviceMessage(data: eventdata.DeviceMessage): void {
|
||||
this.emitter.emit('deviceMessage', data);
|
||||
this.emitter.emit("deviceMessage", data);
|
||||
}
|
||||
public onDeviceMessage(key: ListenerKey, callback: (data: eventdata.DeviceMessage) => void): void {
|
||||
this.on('deviceMessage', callback, key);
|
||||
this.on("deviceMessage", callback, key);
|
||||
}
|
||||
|
||||
public emitMQTTMessage(data: eventdata.MQTTMessage): void {
|
||||
this.emitter.emit('mqttMessage', data);
|
||||
this.emitter.emit("mqttMessage", data);
|
||||
}
|
||||
public onMQTTMessage(key: ListenerKey, callback: (data: eventdata.MQTTMessage) => void): void {
|
||||
this.on('mqttMessage', callback, key);
|
||||
this.on("mqttMessage", callback, key);
|
||||
}
|
||||
|
||||
public emitMQTTMessagePublished(data: eventdata.MQTTMessagePublished): void {
|
||||
this.emitter.emit('mqttMessagePublished', data);
|
||||
this.emitter.emit("mqttMessagePublished", data);
|
||||
}
|
||||
public onMQTTMessagePublished(key: ListenerKey, callback: (data: eventdata.MQTTMessagePublished) => void): void {
|
||||
this.on('mqttMessagePublished', callback, key);
|
||||
this.on("mqttMessagePublished", callback, key);
|
||||
}
|
||||
|
||||
public emitPublishEntityState(data: eventdata.PublishEntityState): void {
|
||||
this.emitter.emit('publishEntityState', data);
|
||||
this.emitter.emit("publishEntityState", data);
|
||||
}
|
||||
public onPublishEntityState(key: ListenerKey, callback: (data: eventdata.PublishEntityState) => void): void {
|
||||
this.on('publishEntityState', callback, key);
|
||||
this.on("publishEntityState", callback, key);
|
||||
}
|
||||
|
||||
public emitGroupMembersChanged(data: eventdata.GroupMembersChanged): void {
|
||||
this.emitter.emit('groupMembersChanged', data);
|
||||
this.emitter.emit("groupMembersChanged", data);
|
||||
}
|
||||
public onGroupMembersChanged(key: ListenerKey, callback: (data: eventdata.GroupMembersChanged) => void): void {
|
||||
this.on('groupMembersChanged', callback, key);
|
||||
this.on("groupMembersChanged", callback, key);
|
||||
}
|
||||
|
||||
public emitDevicesChanged(): void {
|
||||
this.emitter.emit('devicesChanged');
|
||||
this.emitter.emit("devicesChanged");
|
||||
}
|
||||
public onDevicesChanged(key: ListenerKey, callback: () => void): void {
|
||||
this.on('devicesChanged', callback, key);
|
||||
this.on("devicesChanged", callback, key);
|
||||
}
|
||||
|
||||
public emitScenesChanged(data: eventdata.ScenesChanged): void {
|
||||
this.emitter.emit('scenesChanged', data);
|
||||
this.emitter.emit("scenesChanged", data);
|
||||
}
|
||||
public onScenesChanged(key: ListenerKey, callback: (data: eventdata.ScenesChanged) => void): void {
|
||||
this.on('scenesChanged', callback, key);
|
||||
this.on("scenesChanged", callback, key);
|
||||
}
|
||||
|
||||
public emitReconfigure(data: eventdata.Reconfigure): void {
|
||||
this.emitter.emit('reconfigure', data);
|
||||
this.emitter.emit("reconfigure", data);
|
||||
}
|
||||
public onReconfigure(key: ListenerKey, callback: (data: eventdata.Reconfigure) => void): void {
|
||||
this.on('reconfigure', callback, key);
|
||||
this.on("reconfigure", callback, key);
|
||||
}
|
||||
|
||||
public emitStateChange(data: eventdata.StateChange): void {
|
||||
this.emitter.emit('stateChange', data);
|
||||
this.emitter.emit("stateChange", data);
|
||||
}
|
||||
public onStateChange(key: ListenerKey, callback: (data: eventdata.StateChange) => void): void {
|
||||
this.on('stateChange', callback, key);
|
||||
this.on("stateChange", callback, key);
|
||||
}
|
||||
|
||||
public emitExposesAndDevicesChanged(device: Device): void {
|
||||
@@ -195,8 +195,8 @@ export default class EventBus {
|
||||
}
|
||||
|
||||
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] = [];
|
||||
if (!this.callbacksByExtension.has(key.constructor.name)) {
|
||||
this.callbacksByExtension.set(key.constructor.name, []);
|
||||
}
|
||||
|
||||
const wrappedCallback = async (...args: never[]): Promise<void> => {
|
||||
@@ -204,15 +204,23 @@ export default class EventBus {
|
||||
await callback(...args);
|
||||
} catch (error) {
|
||||
logger.error(`EventBus error '${key.constructor.name}/${event}': ${(error as Error).message}`);
|
||||
// biome-ignore lint/style/noNonNullAssertion: always Error
|
||||
logger.debug((error as Error).stack!);
|
||||
}
|
||||
};
|
||||
|
||||
this.callbacksByExtension[key.constructor.name].push({event, callback: wrappedCallback});
|
||||
// biome-ignore lint/style/noNonNullAssertion: just created if wasn't valid
|
||||
this.callbacksByExtension.get(key.constructor.name)!.push({event, callback: wrappedCallback});
|
||||
this.emitter.on(event, wrappedCallback as EventBusListener<K>);
|
||||
}
|
||||
|
||||
public removeListeners(key: ListenerKey): void {
|
||||
this.callbacksByExtension[key.constructor.name]?.forEach((e) => this.emitter.removeListener(e.event, e.callback));
|
||||
const callbacks = this.callbacksByExtension.get(key.constructor.name);
|
||||
|
||||
if (callbacks) {
|
||||
for (const cb of callbacks) {
|
||||
this.emitter.removeListener(cb.event, cb.callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+164
-79
@@ -1,67 +1,132 @@
|
||||
import type {Zigbee2MQTTAPI} from 'lib/types/api';
|
||||
import type * as zhc from "zigbee-herdsman-converters";
|
||||
|
||||
import assert from 'node:assert';
|
||||
import type {Zigbee2MQTTAPI} from "../types/api";
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import debounce from 'debounce';
|
||||
import assert from "node:assert";
|
||||
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
import bind from "bind-decorator";
|
||||
import debounce from "debounce";
|
||||
|
||||
import logger from '../util/logger';
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import Extension from './extension';
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import utils from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
const RETRIEVE_ON_RECONNECT: readonly {keys: string[]; condition?: (state: KeyValue) => boolean}[] = [
|
||||
{keys: ['state']},
|
||||
{keys: ['brightness'], condition: (state: KeyValue): boolean => state.state === 'ON'},
|
||||
{keys: ['color', 'color_temp'], condition: (state: KeyValue): boolean => state.state === 'ON'},
|
||||
{keys: ["state"]},
|
||||
{keys: ["brightness"], condition: (state: KeyValue): boolean => state.state === "ON"},
|
||||
{keys: ["color", "color_temp"], condition: (state: KeyValue): boolean => state.state === "ON"},
|
||||
];
|
||||
|
||||
export default class Availability extends Extension {
|
||||
private timers: {[s: string]: NodeJS.Timeout} = {};
|
||||
private availabilityCache: {[s: string]: boolean} = {};
|
||||
private retrieveStateDebouncers: {[s: string]: () => void} = {};
|
||||
/** Mapped by IEEE address */
|
||||
private readonly timers = new Map<string, NodeJS.Timeout>();
|
||||
/** Mapped by IEEE address or Group ID */
|
||||
private readonly lastPublishedAvailabilities = new Map<string | number, boolean>();
|
||||
/** Mapped by IEEE address */
|
||||
private readonly pingBackoffs = new Map<string, number>();
|
||||
/** IEEE addresses, waiting for last seen changes to take them out of "availability sleep" */
|
||||
private readonly backoffPausedDevices = new Set<string>();
|
||||
/** Mapped by IEEE address */
|
||||
private readonly retrieveStateDebouncers = new Map<string, () => void>();
|
||||
private pingQueue: Device[] = [];
|
||||
private pingQueueExecuting = false;
|
||||
private stopped = false;
|
||||
|
||||
private getTimeout(device: Device): number {
|
||||
if (typeof device.options.availability === 'object' && device.options.availability?.timeout != null) {
|
||||
if (typeof device.options.availability === "object" && device.options.availability?.timeout != null) {
|
||||
return utils.minutes(device.options.availability.timeout);
|
||||
}
|
||||
|
||||
const type = this.isActiveDevice(device) ? 'active' : 'passive';
|
||||
return utils.minutes(this.isActiveDevice(device) ? settings.get().availability.active.timeout : settings.get().availability.passive.timeout);
|
||||
}
|
||||
|
||||
return utils.minutes(settings.get().availability[type].timeout);
|
||||
private getMaxJitter(device: Device): number {
|
||||
if (typeof device.options.availability === "object" && device.options.availability?.max_jitter != null) {
|
||||
return device.options.availability.max_jitter;
|
||||
}
|
||||
|
||||
return settings.get().availability.active.max_jitter;
|
||||
}
|
||||
|
||||
private getBackoff(device: Device): boolean {
|
||||
if (typeof device.options.availability === "object" && device.options.availability?.backoff != null) {
|
||||
return device.options.availability.backoff;
|
||||
}
|
||||
|
||||
return settings.get().availability.active.backoff;
|
||||
}
|
||||
|
||||
private getPauseOnBackoffGt(device: Device): number {
|
||||
if (typeof device.options.availability === "object" && device.options.availability?.pause_on_backoff_gt != null) {
|
||||
return device.options.availability.pause_on_backoff_gt;
|
||||
}
|
||||
|
||||
return settings.get().availability.active.pause_on_backoff_gt;
|
||||
}
|
||||
|
||||
private isActiveDevice(device: Device): boolean {
|
||||
return (device.zh.type === 'Router' && device.zh.powerSource !== 'Battery') || device.zh.powerSource === 'Mains (single phase)';
|
||||
return (
|
||||
(device.zh.type === "Router" && device.zh.powerSource !== "Battery") ||
|
||||
(device.zh.powerSource !== undefined && device.zh.powerSource !== "Unknown" && device.zh.powerSource !== "Battery")
|
||||
);
|
||||
}
|
||||
|
||||
private isAvailable(entity: Device | Group): boolean {
|
||||
if (entity.isDevice()) {
|
||||
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]);
|
||||
const lastSeen = entity.zh.lastSeen ?? /* v8 ignore next */ 0;
|
||||
|
||||
return Date.now() - lastSeen < this.getTimeout(entity) + this.getMaxJitter(entity);
|
||||
}
|
||||
|
||||
for (const memberDevice of entity.membersDevices()) {
|
||||
if (this.lastPublishedAvailabilities.get(memberDevice.ieeeAddr) === true) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private resetTimer(device: Device): void {
|
||||
clearTimeout(this.timers[device.ieeeAddr]);
|
||||
private resetTimer(device: Device, resetBackoff = false): void {
|
||||
clearTimeout(this.timers.get(device.ieeeAddr));
|
||||
this.removeFromPingQueue(device);
|
||||
|
||||
// If the timer triggers, the device is not available anymore otherwise resetTimer already has been called
|
||||
if (this.isActiveDevice(device)) {
|
||||
// If device did not check in, ping it, if that fails it will be marked as offline
|
||||
this.timers[device.ieeeAddr] = setTimeout(() => this.addToPingQueue(device), this.getTimeout(device) + utils.seconds(1));
|
||||
const backoffEnabled = this.getBackoff(device);
|
||||
const jitter = Math.random() * this.getMaxJitter(device);
|
||||
let backoff = 1;
|
||||
|
||||
if (resetBackoff) {
|
||||
// always cleanup even if backoff disabled (ensures proper state if changed at runtime)
|
||||
this.backoffPausedDevices.delete(device.ieeeAddr);
|
||||
this.pingBackoffs.delete(device.ieeeAddr);
|
||||
} else if (backoffEnabled) {
|
||||
backoff = this.pingBackoffs.get(device.ieeeAddr) ?? 1;
|
||||
}
|
||||
|
||||
// never paused if was reset (just deleted) or backoff disabled, might as well skip the Set lookup
|
||||
if (!backoffEnabled || resetBackoff || !this.backoffPausedDevices.has(device.ieeeAddr)) {
|
||||
// If device did not check in, ping it, if that fails it will be marked as offline
|
||||
this.timers.set(
|
||||
device.ieeeAddr,
|
||||
setTimeout(this.addToPingQueue.bind(this, device), (this.getTimeout(device) + utils.seconds(1) + jitter) * backoff),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.timers[device.ieeeAddr] = setTimeout(() => this.publishAvailability(device, true), this.getTimeout(device) + utils.seconds(1));
|
||||
this.timers.set(
|
||||
device.ieeeAddr,
|
||||
setTimeout(this.publishAvailability.bind(this, device, true), this.getTimeout(device) + utils.seconds(1)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private clearTimer(ieeeAddress: string): void {
|
||||
clearTimeout(this.timers.get(ieeeAddress));
|
||||
this.timers.delete(ieeeAddress);
|
||||
}
|
||||
|
||||
private addToPingQueue(device: Device): void {
|
||||
this.pingQueue.push(device);
|
||||
this.pingQueueExecuteNext().catch(utils.noop);
|
||||
@@ -69,7 +134,7 @@ export default class Availability extends Extension {
|
||||
|
||||
private removeFromPingQueue(device: Device): void {
|
||||
const index = this.pingQueue.findIndex((d) => d.ieeeAddr === device.ieeeAddr);
|
||||
if (index != -1) {
|
||||
if (index !== -1) {
|
||||
this.pingQueue.splice(index, 1);
|
||||
}
|
||||
}
|
||||
@@ -81,8 +146,8 @@ export default class Availability extends Extension {
|
||||
|
||||
this.pingQueueExecuting = true;
|
||||
const device = this.pingQueue[0];
|
||||
let pingedSuccessfully = false;
|
||||
const available = this.availabilityCache[device.ieeeAddr] || this.isAvailable(device);
|
||||
let pingSuccess = false;
|
||||
const available = this.lastPublishedAvailabilities.get(device.ieeeAddr) || this.isAvailable(device);
|
||||
const attempts = available ? 2 : 1;
|
||||
|
||||
for (let i = 1; i <= attempts; i++) {
|
||||
@@ -90,7 +155,7 @@ export default class Availability extends Extension {
|
||||
// Enable recovery if device is marked as available and first ping fails.
|
||||
await device.zh.ping(!available || i !== 2);
|
||||
|
||||
pingedSuccessfully = true;
|
||||
pingSuccess = true;
|
||||
|
||||
logger.debug(`Successfully pinged '${device.name}' (attempt ${i}/${attempts})`);
|
||||
break;
|
||||
@@ -109,8 +174,21 @@ export default class Availability extends Extension {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.publishAvailability(device, !pingedSuccessfully);
|
||||
this.resetTimer(device);
|
||||
if (!pingSuccess && this.getBackoff(device)) {
|
||||
const currentBackoff = this.pingBackoffs.get(device.ieeeAddr) ?? 1;
|
||||
// setting is "greater than" but since we already did the ping, we use ">=" for comparison below (pause next)
|
||||
const pauseOnBackoff = this.getPauseOnBackoffGt(device);
|
||||
|
||||
if (pauseOnBackoff > 0 && currentBackoff >= pauseOnBackoff) {
|
||||
this.backoffPausedDevices.add(device.ieeeAddr);
|
||||
} else {
|
||||
// results in backoffs: *1.5, *3, *6, *12... (with default timeout: 10, 15, 30, 60, 120)
|
||||
this.pingBackoffs.set(device.ieeeAddr, currentBackoff * (available ? 1.5 : 2));
|
||||
}
|
||||
}
|
||||
|
||||
await this.publishAvailability(device, !pingSuccess);
|
||||
this.resetTimer(device, pingSuccess);
|
||||
this.removeFromPingQueue(device);
|
||||
|
||||
// Sleep 2 seconds before executing next ping
|
||||
@@ -123,21 +201,21 @@ export default class Availability extends Extension {
|
||||
|
||||
override async start(): Promise<void> {
|
||||
if (this.stopped) {
|
||||
throw new Error('This extension cannot be restarted.');
|
||||
throw new Error("This extension cannot be restarted.");
|
||||
}
|
||||
|
||||
this.eventBus.onEntityRenamed(this, async (data) => {
|
||||
if (utils.isAvailabilityEnabledForEntity(data.entity, settings.get())) {
|
||||
await this.mqtt.publish(`${data.from}/availability`, '', {retain: true, qos: 1});
|
||||
await this.mqtt.publish(`${data.from}/availability`, "", {clientOptions: {retain: true, qos: 1}});
|
||||
await this.publishAvailability(data.entity, false, true);
|
||||
}
|
||||
});
|
||||
|
||||
this.eventBus.onEntityRemoved(this, (data) => data.type == 'device' && clearTimeout(this.timers[data.id]));
|
||||
this.eventBus.onDeviceLeave(this, (data) => clearTimeout(this.timers[data.ieeeAddr]));
|
||||
this.eventBus.onEntityRemoved(this, (data) => data.type === "device" && this.clearTimer(data.id));
|
||||
this.eventBus.onDeviceLeave(this, (data) => this.clearTimer(data.ieeeAddr));
|
||||
this.eventBus.onDeviceAnnounce(this, (data) => this.retrieveState(data.device));
|
||||
this.eventBus.onLastSeenChanged(this, this.onLastSeenChanged);
|
||||
this.eventBus.onGroupMembersChanged(this, (data) => this.publishAvailability(data.group, false));
|
||||
|
||||
// Publish initial availability
|
||||
await this.publishAvailabilityForAllEntities();
|
||||
|
||||
@@ -154,7 +232,7 @@ export default class Availability extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
@bind private async publishAvailabilityForAllEntities(): Promise<void> {
|
||||
private async publishAvailabilityForAllEntities(): Promise<void> {
|
||||
for (const entity of this.zigbee.devicesAndGroupsIterator(utils.deviceNotCoordinator)) {
|
||||
if (utils.isAvailabilityEnabledForEntity(entity, settings.get())) {
|
||||
await this.publishAvailability(entity, true, false, true);
|
||||
@@ -175,19 +253,19 @@ export default class Availability extends Extension {
|
||||
|
||||
const available = this.isAvailable(entity);
|
||||
|
||||
if (!forcePublish && this.availabilityCache[entity.ID] == available) {
|
||||
if (!forcePublish && this.lastPublishedAvailabilities.get(entity.ID) === available) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (entity.isDevice() && entity.ieeeAddr in this.availabilityCache && available && this.availabilityCache[entity.ieeeAddr] === false) {
|
||||
if (entity.isDevice() && available && this.lastPublishedAvailabilities.get(entity.ieeeAddr) === false) {
|
||||
logger.debug(`Device '${entity.name}' reconnected`);
|
||||
this.retrieveState(entity);
|
||||
}
|
||||
|
||||
const topic = `${entity.name}/availability`;
|
||||
const payload: Zigbee2MQTTAPI['{friendlyName}/availability'] = {state: available ? 'online' : 'offline'};
|
||||
this.availabilityCache[entity.ID] = available;
|
||||
await this.mqtt.publish(topic, JSON.stringify(payload), {retain: true, qos: 1});
|
||||
const payload: Zigbee2MQTTAPI["{friendlyName}/availability"] = {state: available ? "online" : "offline"};
|
||||
this.lastPublishedAvailabilities.set(entity.ID, available);
|
||||
await this.mqtt.publish(topic, JSON.stringify(payload), {clientOptions: {retain: true, qos: 1}});
|
||||
|
||||
if (!skipGroups && entity.isDevice()) {
|
||||
for (const group of this.zigbee.groupsIterator()) {
|
||||
@@ -202,7 +280,7 @@ export default class Availability extends Extension {
|
||||
if (utils.isAvailabilityEnabledForEntity(data.device, settings.get())) {
|
||||
// Remove from ping queue, not necessary anymore since we know the device is online.
|
||||
this.removeFromPingQueue(data.device);
|
||||
this.resetTimer(data.device);
|
||||
this.resetTimer(data.device, true);
|
||||
await this.publishAvailability(data.device, false);
|
||||
}
|
||||
}
|
||||
@@ -211,7 +289,7 @@ export default class Availability extends Extension {
|
||||
this.stopped = true;
|
||||
this.pingQueue = [];
|
||||
|
||||
for (const t of Object.values(this.timers)) {
|
||||
for (const [, t] of this.timers) {
|
||||
clearTimeout(t);
|
||||
}
|
||||
|
||||
@@ -223,41 +301,48 @@ export default class Availability extends Extension {
|
||||
* Retrieve state of a device in a debounced manner, this function is called on a 'deviceAnnounce' which a
|
||||
* device can send multiple times after each other.
|
||||
*/
|
||||
if (device.definition && !device.zh.interviewing && !this.retrieveStateDebouncers[device.ieeeAddr]) {
|
||||
this.retrieveStateDebouncers[device.ieeeAddr] = debounce(async () => {
|
||||
logger.debug(`Retrieving state of '${device.name}' after reconnect`);
|
||||
if (device.definition && device.interviewed && !this.retrieveStateDebouncers.get(device.ieeeAddr)) {
|
||||
this.retrieveStateDebouncers.set(
|
||||
device.ieeeAddr,
|
||||
debounce(async () => {
|
||||
logger.debug(`Retrieving state of '${device.name}' after reconnect`);
|
||||
|
||||
// Color and color temperature converters do both, only needs to be called once.
|
||||
for (const item of RETRIEVE_ON_RECONNECT) {
|
||||
if (item.condition && this.state.get(device) && !item.condition(this.state.get(device))) {
|
||||
continue;
|
||||
// Color and color temperature converters do both, only needs to be called once.
|
||||
for (const item of RETRIEVE_ON_RECONNECT) {
|
||||
if (item.condition && this.state.get(device) && !item.condition(this.state.get(device))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// biome-ignore lint/style/noNonNullAssertion: doesn't change once valid
|
||||
const converter = device.definition!.toZigbee.find((c) => !c.key || c.key.find((k) => item.keys.includes(k)));
|
||||
const options: KeyValue = device.options;
|
||||
const state = this.state.get(device);
|
||||
const meta: zhc.Tz.Meta = {
|
||||
message: this.state.get(device),
|
||||
// biome-ignore lint/style/noNonNullAssertion: doesn't change once valid
|
||||
mapped: device.definition!,
|
||||
endpoint_name: undefined,
|
||||
options,
|
||||
state,
|
||||
device: device.zh,
|
||||
/* v8 ignore next */
|
||||
publish: (payload: KeyValue) => this.publishEntityState(device, payload),
|
||||
};
|
||||
|
||||
try {
|
||||
const endpoint = device.endpoint();
|
||||
assert(endpoint);
|
||||
await converter?.convertGet?.(endpoint, item.keys[0], meta);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to read state of '${device.name}' after reconnect (${(error as Error).message})`);
|
||||
}
|
||||
|
||||
await utils.sleep(500);
|
||||
}
|
||||
|
||||
const converter = device.definition!.toZigbee.find((c) => !c.key || c.key.find((k) => item.keys.includes(k)));
|
||||
const options: KeyValue = device.options;
|
||||
const state = this.state.get(device);
|
||||
const meta: zhc.Tz.Meta = {
|
||||
message: this.state.get(device),
|
||||
mapped: device.definition!,
|
||||
endpoint_name: undefined,
|
||||
options,
|
||||
state,
|
||||
device: device.zh,
|
||||
};
|
||||
|
||||
try {
|
||||
const endpoint = device.endpoint();
|
||||
assert(endpoint);
|
||||
await converter?.convertGet?.(endpoint, item.keys[0], meta);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to read state of '${device.name}' after reconnect (${(error as Error).message})`);
|
||||
}
|
||||
|
||||
await utils.sleep(500);
|
||||
}
|
||||
}, utils.seconds(2));
|
||||
}, utils.seconds(2)),
|
||||
);
|
||||
}
|
||||
|
||||
this.retrieveStateDebouncers[device.ieeeAddr]?.();
|
||||
this.retrieveStateDebouncers.get(device.ieeeAddr)?.();
|
||||
}
|
||||
}
|
||||
|
||||
+121
-105
@@ -1,46 +1,47 @@
|
||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from 'lib/types/api';
|
||||
import type {ClusterName} from "zigbee-herdsman/dist/zspec/zcl/definition/tstype";
|
||||
|
||||
import assert from 'node:assert';
|
||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from "../types/api";
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import debounce from 'debounce';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import assert from "node:assert";
|
||||
|
||||
import {Zcl} from 'zigbee-herdsman';
|
||||
import {ClusterName} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype';
|
||||
import bind from "bind-decorator";
|
||||
import debounce from "debounce";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
|
||||
import Device from '../model/device';
|
||||
import Group from '../model/group';
|
||||
import logger from '../util/logger';
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import Extension from './extension';
|
||||
import {Zcl} from "zigbee-herdsman";
|
||||
|
||||
import Device from "../model/device";
|
||||
import Group from "../model/group";
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import utils from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
const TOPIC_REGEX = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/(bind|unbind)`);
|
||||
const ALL_CLUSTER_CANDIDATES: readonly ClusterName[] = [
|
||||
'genScenes',
|
||||
'genOnOff',
|
||||
'genLevelCtrl',
|
||||
'lightingColorCtrl',
|
||||
'closuresWindowCovering',
|
||||
'hvacThermostat',
|
||||
'msIlluminanceMeasurement',
|
||||
'msTemperatureMeasurement',
|
||||
'msRelativeHumidity',
|
||||
'msSoilMoisture',
|
||||
'msCO2',
|
||||
"genScenes",
|
||||
"genOnOff",
|
||||
"genLevelCtrl",
|
||||
"lightingColorCtrl",
|
||||
"closuresWindowCovering",
|
||||
"hvacThermostat",
|
||||
"msIlluminanceMeasurement",
|
||||
"msTemperatureMeasurement",
|
||||
"msRelativeHumidity",
|
||||
"msSoilMoisture",
|
||||
"msCO2",
|
||||
];
|
||||
|
||||
// See zigbee-herdsman-converters
|
||||
const DEFAULT_BIND_GROUP = {type: 'group_number', ID: 901, name: 'default_bind_group'};
|
||||
const DEFAULT_BIND_GROUP = {type: "group_number", ID: 901, name: "default_bind_group"};
|
||||
const DEFAULT_REPORT_CONFIG = {minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1};
|
||||
|
||||
const getColorCapabilities = async (endpoint: zh.Endpoint): Promise<{colorTemperature: boolean; colorXY: boolean}> => {
|
||||
if (endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') == null) {
|
||||
await endpoint.read('lightingColorCtrl', ['colorCapabilities']);
|
||||
if (endpoint.getClusterAttributeValue("lightingColorCtrl", "colorCapabilities") == null) {
|
||||
await endpoint.read("lightingColorCtrl", ["colorCapabilities"]);
|
||||
}
|
||||
|
||||
const value = endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') as number;
|
||||
const value = endpoint.getClusterAttributeValue("lightingColorCtrl", "colorCapabilities") as number;
|
||||
|
||||
return {
|
||||
colorTemperature: (value & (1 << 4)) > 0,
|
||||
@@ -62,28 +63,28 @@ const REPORT_CLUSTERS: Readonly<
|
||||
>
|
||||
>
|
||||
> = {
|
||||
genOnOff: [{attribute: 'onOff', ...DEFAULT_REPORT_CONFIG, minimumReportInterval: 0, reportableChange: 0}],
|
||||
genLevelCtrl: [{attribute: 'currentLevel', ...DEFAULT_REPORT_CONFIG}],
|
||||
genOnOff: [{attribute: "onOff", ...DEFAULT_REPORT_CONFIG, minimumReportInterval: 0, reportableChange: 0}],
|
||||
genLevelCtrl: [{attribute: "currentLevel", ...DEFAULT_REPORT_CONFIG}],
|
||||
lightingColorCtrl: [
|
||||
{
|
||||
attribute: 'colorTemperature',
|
||||
attribute: "colorTemperature",
|
||||
...DEFAULT_REPORT_CONFIG,
|
||||
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorTemperature,
|
||||
},
|
||||
{
|
||||
attribute: 'currentX',
|
||||
attribute: "currentX",
|
||||
...DEFAULT_REPORT_CONFIG,
|
||||
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
|
||||
},
|
||||
{
|
||||
attribute: 'currentY',
|
||||
attribute: "currentY",
|
||||
...DEFAULT_REPORT_CONFIG,
|
||||
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
|
||||
},
|
||||
],
|
||||
closuresWindowCovering: [
|
||||
{attribute: 'currentPositionLiftPercentage', ...DEFAULT_REPORT_CONFIG},
|
||||
{attribute: 'currentPositionTiltPercentage', ...DEFAULT_REPORT_CONFIG},
|
||||
{attribute: "currentPositionLiftPercentage", ...DEFAULT_REPORT_CONFIG},
|
||||
{attribute: "currentPositionTiltPercentage", ...DEFAULT_REPORT_CONFIG},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -99,22 +100,22 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
|
||||
// On messages that have the cluster and type of below
|
||||
cluster: {
|
||||
manuSpecificPhilips: [
|
||||
{type: 'commandHueNotification', data: {button: 2}},
|
||||
{type: 'commandHueNotification', data: {button: 3}},
|
||||
{type: "commandHueNotification", data: {button: 2}},
|
||||
{type: "commandHueNotification", data: {button: 3}},
|
||||
],
|
||||
genLevelCtrl: [
|
||||
{type: 'commandStep', data: {}},
|
||||
{type: 'commandStepWithOnOff', data: {}},
|
||||
{type: 'commandStop', data: {}},
|
||||
{type: 'commandMoveWithOnOff', data: {}},
|
||||
{type: 'commandStopWithOnOff', data: {}},
|
||||
{type: 'commandMove', data: {}},
|
||||
{type: 'commandMoveToLevelWithOnOff', data: {}},
|
||||
{type: "commandStep", data: {}},
|
||||
{type: "commandStepWithOnOff", data: {}},
|
||||
{type: "commandStop", data: {}},
|
||||
{type: "commandMoveWithOnOff", data: {}},
|
||||
{type: "commandStopWithOnOff", data: {}},
|
||||
{type: "commandMove", data: {}},
|
||||
{type: "commandMoveToLevelWithOnOff", data: {}},
|
||||
],
|
||||
genScenes: [{type: 'commandRecall', data: {}}],
|
||||
genScenes: [{type: "commandRecall", data: {}}],
|
||||
},
|
||||
// Read the following attributes
|
||||
read: {cluster: 'genLevelCtrl', attributes: ['currentLevel']},
|
||||
read: {cluster: "genLevelCtrl", attributes: ["currentLevel"]},
|
||||
// When the bound devices/members of group have the following manufacturerIDs
|
||||
manufacturerIDs: [
|
||||
Zcl.ManufacturerCode.SIGNIFY_NETHERLANDS_B_V,
|
||||
@@ -124,29 +125,29 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
|
||||
Zcl.ManufacturerCode.TELINK_MICRO,
|
||||
Zcl.ManufacturerCode.BUSCH_JAEGER_ELEKTRO,
|
||||
],
|
||||
manufacturerNames: ['GLEDOPTO', 'Trust International B.V.\u0000'],
|
||||
manufacturerNames: ["GLEDOPTO", "Trust International B.V.\u0000"],
|
||||
},
|
||||
{
|
||||
cluster: {
|
||||
genLevelCtrl: [
|
||||
{type: 'commandStepWithOnOff', data: {}},
|
||||
{type: 'commandMoveWithOnOff', data: {}},
|
||||
{type: 'commandStopWithOnOff', data: {}},
|
||||
{type: 'commandMoveToLevelWithOnOff', data: {}},
|
||||
{type: "commandStepWithOnOff", data: {}},
|
||||
{type: "commandMoveWithOnOff", data: {}},
|
||||
{type: "commandStopWithOnOff", data: {}},
|
||||
{type: "commandMoveToLevelWithOnOff", data: {}},
|
||||
],
|
||||
genOnOff: [
|
||||
{type: 'commandOn', data: {}},
|
||||
{type: 'commandOff', data: {}},
|
||||
{type: 'commandOffWithEffect', data: {}},
|
||||
{type: 'commandToggle', data: {}},
|
||||
{type: "commandOn", data: {}},
|
||||
{type: "commandOff", data: {}},
|
||||
{type: "commandOffWithEffect", data: {}},
|
||||
{type: "commandToggle", data: {}},
|
||||
],
|
||||
genScenes: [{type: 'commandRecall', data: {}}],
|
||||
genScenes: [{type: "commandRecall", data: {}}],
|
||||
manuSpecificPhilips: [
|
||||
{type: 'commandHueNotification', data: {button: 1}},
|
||||
{type: 'commandHueNotification', data: {button: 4}},
|
||||
{type: "commandHueNotification", data: {button: 1}},
|
||||
{type: "commandHueNotification", data: {button: 4}},
|
||||
],
|
||||
},
|
||||
read: {cluster: 'genOnOff', attributes: ['onOff']},
|
||||
read: {cluster: "genOnOff", attributes: ["onOff"]},
|
||||
manufacturerIDs: [
|
||||
Zcl.ManufacturerCode.SIGNIFY_NETHERLANDS_B_V,
|
||||
Zcl.ManufacturerCode.ATMEL,
|
||||
@@ -155,14 +156,14 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
|
||||
Zcl.ManufacturerCode.TELINK_MICRO,
|
||||
Zcl.ManufacturerCode.BUSCH_JAEGER_ELEKTRO,
|
||||
],
|
||||
manufacturerNames: ['GLEDOPTO', 'Trust International B.V.\u0000'],
|
||||
manufacturerNames: ["GLEDOPTO", "Trust International B.V.\u0000"],
|
||||
},
|
||||
{
|
||||
cluster: {
|
||||
genScenes: [{type: 'commandRecall', data: {}}],
|
||||
genScenes: [{type: "commandRecall", data: {}}],
|
||||
},
|
||||
read: {
|
||||
cluster: 'lightingColorCtrl',
|
||||
cluster: "lightingColorCtrl",
|
||||
attributes: [] as string[],
|
||||
// Since not all devices support the same attributes they need to be calculated dynamically
|
||||
// depending on the capabilities of the endpoint.
|
||||
@@ -171,11 +172,11 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
|
||||
const readAttrs: string[] = [];
|
||||
|
||||
if (supportedAttrs.colorXY) {
|
||||
readAttrs.push('currentX', 'currentY');
|
||||
readAttrs.push("currentX", "currentY");
|
||||
}
|
||||
|
||||
if (supportedAttrs.colorTemperature) {
|
||||
readAttrs.push('colorTemperature');
|
||||
readAttrs.push("colorTemperature");
|
||||
}
|
||||
|
||||
return readAttrs;
|
||||
@@ -189,15 +190,15 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
|
||||
Zcl.ManufacturerCode.TELINK_MICRO,
|
||||
// Note: ManufacturerCode.BUSCH_JAEGER is left out intentionally here as their devices don't support colors
|
||||
],
|
||||
manufacturerNames: ['GLEDOPTO', 'Trust International B.V.\u0000'],
|
||||
manufacturerNames: ["GLEDOPTO", "Trust International B.V.\u0000"],
|
||||
},
|
||||
];
|
||||
|
||||
interface ParsedMQTTMessage {
|
||||
type: 'bind' | 'unbind';
|
||||
type: "bind" | "unbind";
|
||||
sourceKey?: string;
|
||||
sourceEndpointKey?: string | number;
|
||||
targetKey?: string;
|
||||
targetKey?: string | number;
|
||||
targetEndpointKey?: string | number;
|
||||
clusters?: string[];
|
||||
skipDisableReporting: boolean;
|
||||
@@ -210,6 +211,7 @@ interface ParsedMQTTMessage {
|
||||
export default class Bind extends Extension {
|
||||
private pollDebouncers: {[s: string]: () => void} = {};
|
||||
|
||||
// biome-ignore lint/suspicious/useAwait: API
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onDeviceMessage(this, this.poll);
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
@@ -220,27 +222,30 @@ export default class Bind extends Extension {
|
||||
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';
|
||||
const type = data.topic.endsWith("unbind") ? "unbind" : "bind";
|
||||
let skipDisableReporting = false;
|
||||
const message = JSON.parse(data.message) as Zigbee2MQTTAPI['bridge/request/device/bind'];
|
||||
const message = JSON.parse(data.message) as Zigbee2MQTTAPI["bridge/request/device/bind"];
|
||||
|
||||
if (typeof message !== 'object' || message.from == undefined || message.to == undefined) {
|
||||
return [message, {type, skipDisableReporting}, `Invalid payload`];
|
||||
if (typeof message !== "object" || message.from == null || message.to == null) {
|
||||
return [message, {type, skipDisableReporting}, "Invalid payload"];
|
||||
}
|
||||
|
||||
const sourceKey = message.from;
|
||||
const sourceEndpointKey = message.from_endpoint ?? 'default';
|
||||
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;
|
||||
skipDisableReporting = message.skip_disable_reporting != null ? message.skip_disable_reporting : false;
|
||||
const resolvedSource = this.zigbee.resolveEntity(message.from) as Device;
|
||||
|
||||
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);
|
||||
const resolvedTarget =
|
||||
message.to === DEFAULT_BIND_GROUP.name || message.to === DEFAULT_BIND_GROUP.ID
|
||||
? DEFAULT_BIND_GROUP
|
||||
: this.zigbee.resolveEntity(message.to);
|
||||
|
||||
if (!resolvedTarget) {
|
||||
return [message, {type, skipDisableReporting}, `Target device or group '${message.to}' does not exist`];
|
||||
@@ -289,9 +294,9 @@ export default class Bind extends Extension {
|
||||
},
|
||||
undefined,
|
||||
];
|
||||
} else {
|
||||
return [undefined, undefined, undefined];
|
||||
}
|
||||
|
||||
return [undefined, undefined, undefined];
|
||||
}
|
||||
|
||||
@bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
@@ -320,10 +325,10 @@ export default class Bind extends Extension {
|
||||
resolvedBindTarget,
|
||||
} = parsed;
|
||||
|
||||
assert(resolvedSource, '`resolvedSource` is missing');
|
||||
assert(resolvedTarget, '`resolvedTarget` is missing');
|
||||
assert(resolvedSourceEndpoint, '`resolvedSourceEndpoint` is missing');
|
||||
assert(resolvedBindTarget != undefined, '`resolvedBindTarget` is missing');
|
||||
assert(resolvedSource, "`resolvedSource` is missing");
|
||||
assert(resolvedTarget, "`resolvedTarget` is missing");
|
||||
assert(resolvedSourceEndpoint, "`resolvedSourceEndpoint` is missing");
|
||||
assert(resolvedBindTarget !== undefined, "`resolvedBindTarget` is missing");
|
||||
|
||||
const successfulClusters: string[] = [];
|
||||
const failedClusters = [];
|
||||
@@ -337,8 +342,8 @@ export default class Bind extends Extension {
|
||||
|
||||
const anyClusterValid =
|
||||
utils.isZHGroup(resolvedBindTarget) ||
|
||||
typeof resolvedBindTarget === 'number' ||
|
||||
(resolvedTarget instanceof Device && resolvedTarget.zh.type === 'Coordinator');
|
||||
typeof resolvedBindTarget === "number" ||
|
||||
(resolvedTarget instanceof Device && resolvedTarget.zh.type === "Coordinator");
|
||||
|
||||
if (!anyClusterValid && utils.isZHEndpoint(resolvedBindTarget)) {
|
||||
matchingClusters =
|
||||
@@ -353,7 +358,7 @@ export default class Bind extends Extension {
|
||||
attemptedClusters.push(cluster);
|
||||
|
||||
try {
|
||||
if (type === 'bind') {
|
||||
if (type === "bind") {
|
||||
await resolvedSourceEndpoint.bind(cluster, resolvedBindTarget);
|
||||
} else {
|
||||
await resolvedSourceEndpoint.unbind(cluster, resolvedBindTarget);
|
||||
@@ -361,7 +366,7 @@ export default class Bind extends Extension {
|
||||
|
||||
successfulClusters.push(cluster);
|
||||
logger.info(
|
||||
`Successfully ${type === 'bind' ? 'bound' : 'unbound'} cluster '${cluster}' from '${resolvedSource.name}' to '${resolvedTarget.name}'`,
|
||||
`Successfully ${type === "bind" ? "bound" : "unbound"} cluster '${cluster}' from '${resolvedSource.name}' to '${resolvedTarget.name}'`,
|
||||
);
|
||||
} catch (error) {
|
||||
failedClusters.push(cluster);
|
||||
@@ -374,26 +379,31 @@ export default class Bind extends Extension {
|
||||
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) {
|
||||
}
|
||||
|
||||
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`
|
||||
const responseData: Zigbee2MQTTAPI["bridge/response/device/bind"] | Zigbee2MQTTAPI["bridge/response/device/unbind"] = {
|
||||
// biome-ignore lint/style/noNonNullAssertion: valid with assert above on `resolvedSource`
|
||||
from: sourceKey!,
|
||||
// biome-ignore lint/style/noNonNullAssertion: valid with assert above on `resolvedSourceEndpoint`
|
||||
from_endpoint: sourceEndpointKey!,
|
||||
// biome-ignore lint/style/noNonNullAssertion: valid with assert above on `resolvedTarget`
|
||||
to: targetKey!,
|
||||
to_endpoint: targetEndpointKey,
|
||||
clusters: successfulClusters,
|
||||
failed: failedClusters,
|
||||
};
|
||||
|
||||
if (successfulClusters.length !== 0) {
|
||||
if (type === 'bind') {
|
||||
if (type === "bind") {
|
||||
await this.setupReporting(
|
||||
resolvedSourceEndpoint.binds.filter((b) => successfulClusters.includes(b.cluster.name) && b.target === resolvedBindTarget),
|
||||
);
|
||||
} else if (typeof resolvedBindTarget !== 'number' && !skipDisableReporting) {
|
||||
} else if (typeof resolvedBindTarget !== "number" && !skipDisableReporting) {
|
||||
await this.disableUnnecessaryReportings(resolvedBindTarget);
|
||||
}
|
||||
}
|
||||
@@ -403,7 +413,7 @@ export default class Bind extends Extension {
|
||||
}
|
||||
|
||||
private async publishResponse<T extends Zigbee2MQTTResponseEndpoints>(
|
||||
type: ParsedMQTTMessage['type'],
|
||||
type: ParsedMQTTMessage["type"],
|
||||
request: KeyValue,
|
||||
data: Zigbee2MQTTAPI[T],
|
||||
error?: string,
|
||||
@@ -417,7 +427,7 @@ export default class Bind extends Extension {
|
||||
}
|
||||
|
||||
@bind async onGroupMembersChanged(data: eventdata.GroupMembersChanged): Promise<void> {
|
||||
if (data.action === 'add') {
|
||||
if (data.action === "add") {
|
||||
const bindsToGroup: zh.Bind[] = [];
|
||||
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
@@ -465,13 +475,16 @@ export default class Bind extends Extension {
|
||||
for (const bind of binds) {
|
||||
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}`;
|
||||
// biome-ignore lint/style/noNonNullAssertion: TODO: biome migration: ???
|
||||
const resolvedDevice = this.zigbee.resolveEntity(endpoint.getDevice())!;
|
||||
const entity = `${resolvedDevice.name}/${endpoint.ID}`;
|
||||
|
||||
try {
|
||||
await endpoint.bind(bind.cluster.name, coordinatorEndpoint);
|
||||
|
||||
const items = [];
|
||||
|
||||
// biome-ignore lint/style/noNonNullAssertion: valid from outer `if`
|
||||
for (const c of REPORT_CLUSTERS[bind.cluster.name as ClusterName]!) {
|
||||
if (!c.condition || (await c.condition(endpoint))) {
|
||||
const i = {...c};
|
||||
@@ -530,6 +543,7 @@ export default class Bind extends Extension {
|
||||
|
||||
const items = [];
|
||||
|
||||
// biome-ignore lint/style/noNonNullAssertion: valid from loop (pushed to array only if in)
|
||||
for (const item of REPORT_CLUSTERS[cluster as ClusterName]!) {
|
||||
if (!item.condition || (await item.condition(endpoint))) {
|
||||
const i = {...item};
|
||||
@@ -564,31 +578,33 @@ export default class Bind extends Extension {
|
||||
);
|
||||
|
||||
if (polls.length) {
|
||||
const toPoll: Set<zh.Endpoint> = new Set();
|
||||
const toPoll = new Set<zh.Endpoint>();
|
||||
|
||||
// Add bound devices
|
||||
for (const endpoint of data.device.zh.endpoints) {
|
||||
for (const bind of endpoint.binds) {
|
||||
if (utils.isZHEndpoint(bind.target) && bind.target.getDevice().type !== 'Coordinator') {
|
||||
if (utils.isZHEndpoint(bind.target) && bind.target.getDevice().type !== "Coordinator") {
|
||||
toPoll.add(bind.target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If message is published to a group, add members of the group
|
||||
const group = data.groupID && data.groupID !== 0 && this.zigbee.groupByID(data.groupID);
|
||||
if (data.groupID && data.groupID !== 0) {
|
||||
// If message is published to a group, add members of the group
|
||||
const group = this.zigbee.groupByID(data.groupID);
|
||||
|
||||
if (group) {
|
||||
for (const member of group.zh.members) {
|
||||
toPoll.add(member);
|
||||
if (group) {
|
||||
for (const member of group.zh.members) {
|
||||
toPoll.add(member);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const endpoint of toPoll) {
|
||||
const device = endpoint.getDevice();
|
||||
for (const poll of polls) {
|
||||
// XXX: manufacturerID/manufacturerName can be undefined and won't match `includes`, but TS enforces same-type
|
||||
if (
|
||||
// biome-ignore lint/style/noNonNullAssertion: manufacturerID/manufacturerName can be undefined and won't match `includes`, but TS enforces same-type
|
||||
(!poll.manufacturerIDs.includes(device.manufacturerID!) && !poll.manufacturerNames.includes(device.manufacturerName!)) ||
|
||||
!endpoint.supportsInputCluster(poll.read.cluster)
|
||||
) {
|
||||
@@ -609,9 +625,9 @@ export default class Bind extends Extension {
|
||||
try {
|
||||
await endpoint.read(poll.read.cluster, readAttrs);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to poll ${readAttrs} from ${this.zigbee.resolveEntity(device)!.name} (${(error as Error).message})`,
|
||||
);
|
||||
// biome-ignore lint/style/noNonNullAssertion: TODO: biome migration: ???
|
||||
const resolvedDevice = this.zigbee.resolveEntity(device)!;
|
||||
logger.error(`Failed to poll ${readAttrs} from ${resolvedDevice.name} (${(error as Error).message})`);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
+190
-181
@@ -1,24 +1,26 @@
|
||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTDevice, Zigbee2MQTTResponse, Zigbee2MQTTResponseEndpoints} from 'lib/types/api';
|
||||
import type winston from "winston";
|
||||
|
||||
import fs from 'node:fs';
|
||||
import type Group from "../model/group";
|
||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTDevice, Zigbee2MQTTResponse, Zigbee2MQTTResponseEndpoints} from "../types/api";
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import JSZip from 'jszip';
|
||||
import objectAssignDeep from 'object-assign-deep';
|
||||
import winston from 'winston';
|
||||
import Transport from 'winston-transport';
|
||||
import fs from "node:fs";
|
||||
|
||||
import {Zcl} from 'zigbee-herdsman';
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import JSZip from "jszip";
|
||||
import objectAssignDeep from "object-assign-deep";
|
||||
import Transport from "winston-transport";
|
||||
|
||||
import Device from '../model/device';
|
||||
import Group from '../model/group';
|
||||
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';
|
||||
import {Zcl} from "zigbee-herdsman";
|
||||
import * as zhc from "zigbee-herdsman-converters";
|
||||
import {InterviewState} from "zigbee-herdsman/dist/controller/model/device";
|
||||
|
||||
import Device from "../model/device";
|
||||
import data from "../util/data";
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import utils, {assertString} from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
const REQUEST_REGEX = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/(.*)`);
|
||||
|
||||
@@ -32,23 +34,23 @@ export default class Bridge extends Extension {
|
||||
private lastBridgeLoggingPayload?: string;
|
||||
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,
|
||||
"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,
|
||||
"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,
|
||||
@@ -56,14 +58,13 @@ export default class Bridge extends Extension {
|
||||
|
||||
override async start(): Promise<void> {
|
||||
const debugToMQTTFrontend = settings.get().advanced.log_debug_to_mqtt_frontend;
|
||||
const baseTopic = settings.get().mqtt.base_topic;
|
||||
|
||||
const bridgeLogging = (message: string, level: string, namespace: string): void => {
|
||||
const payload = stringify({message, level, namespace});
|
||||
|
||||
if (payload !== this.lastBridgeLoggingPayload) {
|
||||
this.lastBridgeLoggingPayload = payload;
|
||||
void this.mqtt.publish(`bridge/logging`, payload, {}, baseTopic, true);
|
||||
void this.mqtt.publish("bridge/logging", payload, {skipLog: true});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -79,7 +80,7 @@ export default class Bridge extends Extension {
|
||||
} else {
|
||||
class EventTransport extends Transport {
|
||||
override log(info: {message: string; level: string; namespace: string}, next: () => void): void {
|
||||
if (info.level !== 'debug') {
|
||||
if (info.level !== "debug") {
|
||||
bridgeLogging(info.message, info.level, info.namespace);
|
||||
}
|
||||
next();
|
||||
@@ -92,8 +93,8 @@ export default class Bridge extends Extension {
|
||||
logger.addTransport(this.logTransport);
|
||||
|
||||
this.zigbee2mqttVersion = await utils.getZigbee2MQTTVersion();
|
||||
this.zigbeeHerdsmanVersion = await utils.getDependencyVersion('zigbee-herdsman');
|
||||
this.zigbeeHerdsmanConvertersVersion = await utils.getDependencyVersion('zigbee-herdsman-converters');
|
||||
this.zigbeeHerdsmanVersion = await utils.getDependencyVersion("zigbee-herdsman");
|
||||
this.zigbeeHerdsmanConvertersVersion = await utils.getDependencyVersion("zigbee-herdsman-converters");
|
||||
this.coordinatorVersion = await this.zigbee.getCoordinatorVersion();
|
||||
|
||||
this.eventBus.onEntityRenamed(this, async () => {
|
||||
@@ -122,20 +123,20 @@ export default class Bridge extends Extension {
|
||||
this.lastJoinedDeviceIeeeAddr = data.device.ieeeAddr;
|
||||
await this.publishDevices();
|
||||
|
||||
const payload: Zigbee2MQTTAPI['bridge/event'] = {
|
||||
type: 'device_joined',
|
||||
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});
|
||||
await this.mqtt.publish("bridge/event", stringify(payload));
|
||||
});
|
||||
this.eventBus.onDeviceLeave(this, async (data) => {
|
||||
await this.publishDevices();
|
||||
await this.publishDefinitions();
|
||||
|
||||
const payload: Zigbee2MQTTAPI['bridge/event'] = {type: 'device_leave', data: {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});
|
||||
await this.mqtt.publish("bridge/event", stringify(payload));
|
||||
});
|
||||
this.eventBus.onDeviceNetworkAddressChanged(this, async () => {
|
||||
await this.publishDevices();
|
||||
@@ -143,11 +144,11 @@ export default class Bridge extends Extension {
|
||||
this.eventBus.onDeviceInterview(this, async (data) => {
|
||||
await this.publishDevices();
|
||||
|
||||
let payload: Zigbee2MQTTAPI['bridge/event'];
|
||||
let payload: Zigbee2MQTTAPI["bridge/event"];
|
||||
|
||||
if (data.status === 'successful') {
|
||||
if (data.status === "successful") {
|
||||
payload = {
|
||||
type: 'device_interview',
|
||||
type: "device_interview",
|
||||
data: {
|
||||
friendly_name: data.device.name,
|
||||
status: data.status,
|
||||
@@ -158,22 +159,22 @@ export default class Bridge extends Extension {
|
||||
};
|
||||
} else {
|
||||
payload = {
|
||||
type: 'device_interview',
|
||||
type: "device_interview",
|
||||
data: {friendly_name: data.device.name, status: data.status, ieee_address: data.device.ieeeAddr},
|
||||
};
|
||||
}
|
||||
|
||||
await this.mqtt.publish('bridge/event', stringify(payload), {retain: false, qos: 0});
|
||||
await this.mqtt.publish("bridge/event", stringify(payload));
|
||||
});
|
||||
this.eventBus.onDeviceAnnounce(this, async (data) => {
|
||||
await this.publishDevices();
|
||||
|
||||
const payload: Zigbee2MQTTAPI['bridge/event'] = {
|
||||
type: 'device_announce',
|
||||
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.mqtt.publish("bridge/event", stringify(payload));
|
||||
});
|
||||
|
||||
await this.publishInfo();
|
||||
@@ -206,6 +207,7 @@ export default class Bridge extends Extension {
|
||||
await this.mqtt.publish(`bridge/response/${match[1]}`, stringify(response));
|
||||
} catch (error) {
|
||||
logger.error(`Request '${data.topic}' failed with error: '${(error as Error).message}'`);
|
||||
// biome-ignore lint/style/noNonNullAssertion: always using Error
|
||||
logger.debug((error as Error).stack!);
|
||||
const response = utils.getResponse(message, {}, (error as Error).message);
|
||||
await this.mqtt.publish(`bridge/response/${match[1]}`, stringify(response));
|
||||
@@ -217,17 +219,17 @@ export default class Bridge extends Extension {
|
||||
* Requests
|
||||
*/
|
||||
|
||||
@bind async deviceOptions(message: KeyValue | string): Promise<Zigbee2MQTTResponse<'bridge/response/device/options'>> {
|
||||
return await this.changeEntityOptions('device', message);
|
||||
@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<Zigbee2MQTTResponse<'bridge/response/group/options'>> {
|
||||
return await this.changeEntityOptions('group', message);
|
||||
@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<Zigbee2MQTTResponse<'bridge/response/options'>> {
|
||||
if (typeof message !== 'object' || typeof message.options !== 'object') {
|
||||
throw new Error(`Invalid payload`);
|
||||
@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 as Partial<Settings>;
|
||||
@@ -235,39 +237,40 @@ export default class Bridge extends Extension {
|
||||
|
||||
// Apply some settings on-the-fly.
|
||||
if (newSettings.homeassistant) {
|
||||
await this.enableDisableExtension(settings.get().homeassistant.enabled, 'HomeAssistant');
|
||||
await this.enableDisableExtension(settings.get().homeassistant.enabled, "HomeAssistant");
|
||||
}
|
||||
|
||||
if (newSettings.advanced?.log_level != undefined) {
|
||||
if (newSettings.advanced?.log_level != null) {
|
||||
logger.setLevel(settings.get().advanced.log_level);
|
||||
}
|
||||
|
||||
if (newSettings.advanced?.log_namespaced_levels != undefined) {
|
||||
if (newSettings.advanced?.log_namespaced_levels != null) {
|
||||
logger.setNamespacedLevels(settings.get().advanced.log_namespaced_levels);
|
||||
}
|
||||
|
||||
if (newSettings.advanced?.log_debug_namespace_ignore != undefined) {
|
||||
if (newSettings.advanced?.log_debug_namespace_ignore != null) {
|
||||
logger.setDebugNamespaceIgnore(settings.get().advanced.log_debug_namespace_ignore);
|
||||
}
|
||||
|
||||
logger.info('Successfully changed options');
|
||||
logger.info("Successfully changed options");
|
||||
await this.publishInfo();
|
||||
return utils.getResponse(message, {restart_required: this.restartRequired});
|
||||
}
|
||||
|
||||
@bind async deviceRemove(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/device/remove'>> {
|
||||
return await this.removeEntity('device', message);
|
||||
@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<Zigbee2MQTTResponse<'bridge/response/group/remove'>> {
|
||||
return await this.removeEntity('group', message);
|
||||
@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<Zigbee2MQTTResponse<'bridge/response/health_check'>> {
|
||||
// biome-ignore lint/suspicious/useAwait: API
|
||||
@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<Zigbee2MQTTResponse<'bridge/response/coordinator_check'>> {
|
||||
@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,65 +278,70 @@ export default class Bridge extends Extension {
|
||||
return utils.getResponse(message, {missing_routers: missingRouters});
|
||||
}
|
||||
|
||||
@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`);
|
||||
@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");
|
||||
}
|
||||
|
||||
const friendlyName = typeof message === 'object' ? message.friendly_name : message;
|
||||
const ID = typeof message === 'object' && message.id !== undefined ? message.id : null;
|
||||
const friendlyName = typeof message === "object" ? message.friendly_name : message;
|
||||
const ID = typeof message === "object" && message.id !== undefined ? message.id : null;
|
||||
const group = settings.addGroup(friendlyName, ID);
|
||||
this.zigbee.createGroup(group.ID);
|
||||
await this.publishGroups();
|
||||
return utils.getResponse(message, {friendly_name: group.friendly_name, id: group.ID});
|
||||
}
|
||||
|
||||
@bind async deviceRename(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/device/rename'>> {
|
||||
return await this.renameEntity('device', message);
|
||||
@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<Zigbee2MQTTResponse<'bridge/response/group/rename'>> {
|
||||
return await this.renameEntity('group', message);
|
||||
@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<Zigbee2MQTTResponse<'bridge/response/restart'>> {
|
||||
// biome-ignore lint/suspicious/useAwait: API
|
||||
@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');
|
||||
logger.info("Restarting Zigbee2MQTT");
|
||||
return utils.getResponse(message, {});
|
||||
}
|
||||
|
||||
@bind async backup(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/backup'>> {
|
||||
@bind async backup(message: string | KeyValue): Promise<Zigbee2MQTTResponse<"bridge/response/backup">> {
|
||||
await this.zigbee.backup();
|
||||
const dataPath = data.getPath();
|
||||
const files = utils
|
||||
.getAllFiles(dataPath)
|
||||
.map((f) => [f, f.substring(dataPath.length + 1)])
|
||||
.filter((f) => !f[1].startsWith('log'));
|
||||
.filter((f) => !f[1].startsWith("log"));
|
||||
const zip = new JSZip();
|
||||
files.forEach((f) => zip.file(f[1], fs.readFileSync(f[0])));
|
||||
const base64Zip = await zip.generateAsync({type: 'base64'});
|
||||
|
||||
for (const f of files) {
|
||||
zip.file(f[1], fs.readFileSync(f[0]));
|
||||
}
|
||||
|
||||
const base64Zip = await zip.generateAsync({type: "base64"});
|
||||
return utils.getResponse(message, {zip: base64Zip});
|
||||
}
|
||||
|
||||
@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');
|
||||
@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");
|
||||
}
|
||||
|
||||
const value = typeof message === 'object' ? message.value : message;
|
||||
const value = typeof message === "object" ? message.value : message;
|
||||
await this.zigbee.addInstallCode(value);
|
||||
logger.info('Successfully added new install code');
|
||||
logger.info("Successfully added new install code");
|
||||
return utils.getResponse(message, {value});
|
||||
}
|
||||
|
||||
@bind async permitJoin(message: KeyValue | string): Promise<Zigbee2MQTTResponse<'bridge/response/permit_join'>> {
|
||||
@bind async permitJoin(message: KeyValue | string): Promise<Zigbee2MQTTResponse<"bridge/response/permit_join">> {
|
||||
let time: number | undefined;
|
||||
let device: Device | undefined;
|
||||
|
||||
if (typeof message === 'object') {
|
||||
if (typeof message === "object") {
|
||||
if (message.time === undefined) {
|
||||
throw new Error('Invalid payload');
|
||||
throw new Error("Invalid payload");
|
||||
}
|
||||
|
||||
time = Number.parseInt(message.time, 10);
|
||||
@@ -362,9 +370,9 @@ export default class Bridge extends Extension {
|
||||
return utils.getResponse(message, response);
|
||||
}
|
||||
|
||||
@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');
|
||||
@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");
|
||||
}
|
||||
|
||||
logger.info(`Start Touchlink identify of '${message.ieee_address}' on channel ${message.channel}`);
|
||||
@@ -372,11 +380,11 @@ 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<Zigbee2MQTTResponse<'bridge/response/touchlink/factory_reset'>> {
|
||||
@bind async touchlinkFactoryReset(message: KeyValue | string): Promise<Zigbee2MQTTResponse<"bridge/response/touchlink/factory_reset">> {
|
||||
let result = false;
|
||||
let payload: Zigbee2MQTTAPI['bridge/response/touchlink/factory_reset'] = {};
|
||||
let payload: Zigbee2MQTTAPI["bridge/response/touchlink/factory_reset"] = {};
|
||||
|
||||
if (typeof message === 'object' && message.ieee_address !== undefined && message.channel !== undefined) {
|
||||
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);
|
||||
@@ -385,26 +393,26 @@ export default class Bridge extends Extension {
|
||||
channel: message.channel,
|
||||
};
|
||||
} else {
|
||||
logger.info('Start Touchlink factory reset of first found device');
|
||||
logger.info("Start Touchlink factory reset of first found device");
|
||||
result = await this.zigbee.touchlinkFactoryResetFirst();
|
||||
}
|
||||
|
||||
if (result) {
|
||||
logger.info('Successfully factory reset device through Touchlink');
|
||||
logger.info("Successfully factory reset device through Touchlink");
|
||||
return utils.getResponse(message, payload);
|
||||
} else {
|
||||
logger.error('Failed to factory reset device through Touchlink');
|
||||
throw new Error('Failed to factory reset device through Touchlink');
|
||||
}
|
||||
|
||||
logger.error("Failed to factory reset device through Touchlink");
|
||||
throw new Error("Failed to factory reset device through Touchlink");
|
||||
}
|
||||
|
||||
@bind async touchlinkScan(message: KeyValue | string): Promise<Zigbee2MQTTResponse<'bridge/response/touchlink/scan'>> {
|
||||
logger.info('Start Touchlink scan');
|
||||
@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) => {
|
||||
return {ieee_address: r.ieeeAddr, channel: r.channel};
|
||||
});
|
||||
logger.info('Finished Touchlink scan');
|
||||
logger.info("Finished Touchlink scan");
|
||||
return utils.getResponse(message, {found});
|
||||
}
|
||||
|
||||
@@ -412,12 +420,12 @@ export default class Bridge extends Extension {
|
||||
* Utils
|
||||
*/
|
||||
|
||||
async changeEntityOptions<T extends 'device' | 'group'>(
|
||||
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`);
|
||||
): 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");
|
||||
}
|
||||
|
||||
const cleanup = (o: KeyValue): KeyValue => {
|
||||
@@ -453,9 +461,9 @@ 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<Zigbee2MQTTResponse<'bridge/response/device/configure_reporting'>> {
|
||||
@bind async deviceConfigureReporting(message: string | KeyValue): Promise<Zigbee2MQTTResponse<"bridge/response/device/configure_reporting">> {
|
||||
if (
|
||||
typeof message !== 'object' ||
|
||||
typeof message !== "object" ||
|
||||
message.id === undefined ||
|
||||
message.endpoint === undefined ||
|
||||
message.cluster === undefined ||
|
||||
@@ -464,10 +472,10 @@ export default class Bridge extends Extension {
|
||||
message.reportable_change === undefined ||
|
||||
message.attribute === undefined
|
||||
) {
|
||||
throw new Error(`Invalid payload`);
|
||||
throw new Error("Invalid payload");
|
||||
}
|
||||
|
||||
const device = this.getEntity('device', message.id);
|
||||
const device = this.getEntity("device", message.id);
|
||||
const endpoint = device.endpoint(message.endpoint);
|
||||
|
||||
if (!endpoint) {
|
||||
@@ -505,12 +513,12 @@ export default class Bridge extends Extension {
|
||||
});
|
||||
}
|
||||
|
||||
@bind async deviceInterview(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/device/interview'>> {
|
||||
if (typeof message !== 'object' || message.id === undefined) {
|
||||
throw new Error(`Invalid payload`);
|
||||
@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);
|
||||
const device = this.getEntity("device", message.id);
|
||||
logger.info(`Interviewing '${device.name}'`);
|
||||
|
||||
try {
|
||||
@@ -530,33 +538,34 @@ export default class Bridge extends Extension {
|
||||
|
||||
@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`);
|
||||
): Promise<Zigbee2MQTTResponse<"bridge/response/device/generate_external_definition">> {
|
||||
if (typeof message !== "object" || message.id === undefined) {
|
||||
throw new Error("Invalid payload");
|
||||
}
|
||||
|
||||
const device = this.getEntity('device', message.id);
|
||||
const device = this.getEntity("device", message.id);
|
||||
const source = await zhc.generateExternalDefinitionSource(device.zh);
|
||||
|
||||
return utils.getResponse(message, {id: message.id, source});
|
||||
}
|
||||
|
||||
async renameEntity<T extends 'device' | 'group'>(
|
||||
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;
|
||||
): 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) {
|
||||
throw new Error(`Invalid payload`);
|
||||
if (typeof message !== "object" || (message.from === undefined && !deviceAndHasLast) || message.to === undefined) {
|
||||
throw new Error("Invalid payload");
|
||||
}
|
||||
|
||||
if (deviceAndHasLast && !this.lastJoinedDeviceIeeeAddr) {
|
||||
throw new Error('No device has joined since start');
|
||||
throw new Error("No device has joined since start");
|
||||
}
|
||||
|
||||
const from = deviceAndHasLast ? this.lastJoinedDeviceIeeeAddr : message.from;
|
||||
const to = message.to;
|
||||
assertString(message.to, "to");
|
||||
const to = message.to.trim();
|
||||
const homeAssisantRename = message.homeassistant_rename !== undefined ? message.homeassistant_rename : false;
|
||||
const entity = this.getEntity(entityType, from);
|
||||
const oldFriendlyName = entity.options.friendly_name;
|
||||
@@ -564,7 +573,7 @@ export default class Bridge extends Extension {
|
||||
settings.changeFriendlyName(from, to);
|
||||
|
||||
// Clear retained messages
|
||||
await this.mqtt.publish(oldFriendlyName, '', {retain: true});
|
||||
await this.mqtt.publish(oldFriendlyName, "", {clientOptions: {retain: true}});
|
||||
|
||||
this.eventBus.emitEntityRenamed({entity: entity, homeAssisantRename, from: oldFriendlyName, to});
|
||||
|
||||
@@ -581,31 +590,29 @@ export default class Bridge extends Extension {
|
||||
return utils.getResponse(message, {from: oldFriendlyName, to, homeassistant_rename: homeAssisantRename});
|
||||
}
|
||||
|
||||
async removeEntity<T extends 'device' | 'group'>(
|
||||
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();
|
||||
): 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);
|
||||
// note: entity.name is dynamically retrieved, will change once device is removed (friendly => ieee)
|
||||
const friendlyName = entity.name;
|
||||
const entityID = entity.ID;
|
||||
|
||||
let block = false;
|
||||
let force = false;
|
||||
let blockForceLog = '';
|
||||
let blockForceLog = "";
|
||||
|
||||
if (entityType === 'device' && typeof message === 'object') {
|
||||
if (entityType === "device" && typeof message === "object") {
|
||||
block = !!message.block;
|
||||
force = !!message.force;
|
||||
blockForceLog = ` (block: ${block}, force: ${force})`;
|
||||
} else if (entityType === 'group' && typeof message === 'object') {
|
||||
} else if (entityType === "group" && typeof message === "object") {
|
||||
force = !!message.force;
|
||||
blockForceLog = ` (force: ${force})`;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info(`Removing ${entityType} '${entity.name}'${blockForceLog}`);
|
||||
const name = entity.name;
|
||||
logger.info(`Removing ${entityType} '${friendlyName}'${blockForceLog}`);
|
||||
|
||||
if (entity instanceof Device) {
|
||||
if (block) {
|
||||
@@ -618,8 +625,8 @@ export default class Bridge extends Extension {
|
||||
await entity.zh.removeFromNetwork();
|
||||
}
|
||||
|
||||
this.eventBus.emitEntityRemoved({id: entityID, name, type: 'device'});
|
||||
settings.removeDevice(entityID as string);
|
||||
this.eventBus.emitEntityRemoved({id: entity.ID, name: friendlyName, type: "device"});
|
||||
settings.removeDevice(entity.ID as string);
|
||||
} else {
|
||||
if (force) {
|
||||
entity.zh.removeFromDatabase();
|
||||
@@ -627,15 +634,15 @@ export default class Bridge extends Extension {
|
||||
await entity.zh.removeFromNetwork();
|
||||
}
|
||||
|
||||
this.eventBus.emitEntityRemoved({id: entityID, name, type: 'group'});
|
||||
settings.removeGroup(entityID);
|
||||
this.eventBus.emitEntityRemoved({id: entity.ID, name: friendlyName, type: "group"});
|
||||
settings.removeGroup(entity.ID);
|
||||
}
|
||||
|
||||
// Remove from state
|
||||
this.state.remove(entityID);
|
||||
this.state.remove(entity.ID);
|
||||
|
||||
// Clear any retained messages
|
||||
await this.mqtt.publish(friendlyName, '', {retain: true});
|
||||
await this.mqtt.publish(friendlyName, "", {clientOptions: {retain: true}});
|
||||
|
||||
logger.info(`Successfully removed ${entityType} '${friendlyName}'${blockForceLog}`);
|
||||
|
||||
@@ -645,32 +652,32 @@ export default class Bridge extends Extension {
|
||||
// Refresh Cluster definition
|
||||
await this.publishDefinitions();
|
||||
|
||||
const responseData: Zigbee2MQTTAPI['bridge/response/device/remove'] = {id: ID, block, force};
|
||||
const responseData: Zigbee2MQTTAPI["bridge/response/device/remove"] = {id: ID, block, force};
|
||||
|
||||
return utils.getResponse(message, responseData);
|
||||
} else {
|
||||
await this.publishGroups();
|
||||
|
||||
const responseData: Zigbee2MQTTAPI['bridge/response/group/remove'] = {id: ID, force};
|
||||
|
||||
return utils.getResponse(
|
||||
message,
|
||||
// @ts-expect-error typing infer does not work here
|
||||
responseData,
|
||||
);
|
||||
}
|
||||
|
||||
await this.publishGroups();
|
||||
|
||||
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);
|
||||
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) {
|
||||
throw new Error(`${utils.capitalize(type)} '${ID}' does not exist`);
|
||||
throw new Error(`${utils.capitalize(type)} '${id}' does not exist`);
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
@@ -683,13 +690,13 @@ export default class Bridge extends Extension {
|
||||
delete config.frontend.auth_token;
|
||||
|
||||
const networkParams = await this.zigbee.getNetworkParameters();
|
||||
const payload: Zigbee2MQTTAPI['bridge/info'] = {
|
||||
const payload: Zigbee2MQTTAPI["bridge/info"] = {
|
||||
version: this.zigbee2mqttVersion.version,
|
||||
commit: this.zigbee2mqttVersion.commitHash,
|
||||
zigbee_herdsman_converters: this.zigbeeHerdsmanConvertersVersion,
|
||||
zigbee_herdsman: this.zigbeeHerdsmanVersion,
|
||||
coordinator: {
|
||||
ieee_address: this.zigbee.firstCoordinatorEndpoint().getDevice().ieeeAddr,
|
||||
ieee_address: this.zigbee.firstCoordinatorEndpoint().deviceIeeeAddress,
|
||||
...this.coordinatorVersion,
|
||||
},
|
||||
network: {
|
||||
@@ -705,14 +712,14 @@ export default class Bridge extends Extension {
|
||||
config_schema: settings.schemaJson,
|
||||
};
|
||||
|
||||
await this.mqtt.publish('bridge/info', stringify(payload), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
|
||||
await this.mqtt.publish("bridge/info", stringify(payload), {clientOptions: {retain: true}, skipLog: true});
|
||||
}
|
||||
|
||||
async publishDevices(): Promise<void> {
|
||||
const devices: Zigbee2MQTTAPI['bridge/devices'] = [];
|
||||
const devices: Zigbee2MQTTAPI["bridge/devices"] = [];
|
||||
|
||||
for (const device of this.zigbee.devicesIterator()) {
|
||||
const endpoints: (typeof devices)[number]['endpoints'] = {};
|
||||
const endpoints: (typeof devices)[number]["endpoints"] = {};
|
||||
|
||||
for (const endpoint of device.zh.endpoints) {
|
||||
const data: (typeof endpoints)[keyof typeof endpoints] = {
|
||||
@@ -727,8 +734,8 @@ export default class Bridge extends Extension {
|
||||
|
||||
for (const bind of endpoint.binds) {
|
||||
const target = utils.isZHEndpoint(bind.target)
|
||||
? {type: 'endpoint', ieee_address: bind.target.getDevice().ieeeAddr, endpoint: bind.target.ID}
|
||||
: {type: 'group', id: bind.target.groupID};
|
||||
? {type: "endpoint" as const, ieee_address: bind.target.deviceIeeeAddress, endpoint: bind.target.ID}
|
||||
: {type: "group" as const, id: bind.target.groupID};
|
||||
data.bindings.push({cluster: bind.cluster.name, target});
|
||||
}
|
||||
|
||||
@@ -758,40 +765,42 @@ export default class Bridge extends Extension {
|
||||
software_build_id: device.zh.softwareBuildID,
|
||||
date_code: device.zh.dateCode,
|
||||
model_id: device.zh.modelID,
|
||||
interviewing: device.zh.interviewing,
|
||||
interview_completed: device.zh.interviewCompleted,
|
||||
/** @deprecated interviewing and interview_completed are superceded by interview_state */
|
||||
interviewing: device.zh.interviewState === InterviewState.InProgress,
|
||||
interview_completed: device.zh.interviewState === InterviewState.Successful,
|
||||
interview_state: device.zh.interviewState,
|
||||
manufacturer: device.zh.manufacturerName,
|
||||
endpoints,
|
||||
});
|
||||
}
|
||||
|
||||
await this.mqtt.publish('bridge/devices', stringify(devices), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
|
||||
await this.mqtt.publish("bridge/devices", stringify(devices), {clientOptions: {retain: true}, skipLog: true});
|
||||
}
|
||||
|
||||
async publishGroups(): Promise<void> {
|
||||
const groups: Zigbee2MQTTAPI['bridge/groups'] = [];
|
||||
const groups: Zigbee2MQTTAPI["bridge/groups"] = [];
|
||||
|
||||
for (const group of this.zigbee.groupsIterator()) {
|
||||
const members = [];
|
||||
|
||||
for (const member of group.zh.members) {
|
||||
members.push({ieee_address: member.getDevice().ieeeAddr, endpoint: member.ID});
|
||||
members.push({ieee_address: member.deviceIeeeAddress, endpoint: member.ID});
|
||||
}
|
||||
|
||||
groups.push({
|
||||
id: group.ID,
|
||||
friendly_name: group.ID === 901 ? 'default_bind_group' : group.name,
|
||||
friendly_name: group.ID === 901 ? "default_bind_group" : group.name,
|
||||
description: group.options.description,
|
||||
scenes: utils.getScenes(group.zh),
|
||||
members,
|
||||
});
|
||||
}
|
||||
|
||||
await this.mqtt.publish('bridge/groups', stringify(groups), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
|
||||
await this.mqtt.publish("bridge/groups", stringify(groups), {clientOptions: {retain: true}, skipLog: true});
|
||||
}
|
||||
|
||||
async publishDefinitions(): Promise<void> {
|
||||
const data: Zigbee2MQTTAPI['bridge/definition'] = {
|
||||
const data: Zigbee2MQTTAPI["bridge/definitions"] = {
|
||||
clusters: Zcl.Clusters,
|
||||
custom_clusters: {},
|
||||
};
|
||||
@@ -800,10 +809,10 @@ export default class Bridge extends Extension {
|
||||
data.custom_clusters[device.ieeeAddr] = device.customClusters;
|
||||
}
|
||||
|
||||
await this.mqtt.publish('bridge/definitions', stringify(data), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
|
||||
await this.mqtt.publish("bridge/definitions", stringify(data), {clientOptions: {retain: true}, skipLog: true});
|
||||
}
|
||||
|
||||
getDefinitionPayload(device: Device): Zigbee2MQTTDevice['definition'] | undefined {
|
||||
getDefinitionPayload(device: Device): Zigbee2MQTTDevice["definition"] | undefined {
|
||||
if (!device.definition) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -815,11 +824,11 @@ export default class Bridge extends Extension {
|
||||
|
||||
if (icon) {
|
||||
/* v8 ignore next */
|
||||
icon = icon.replace('${zigbeeModel}', utils.sanitizeImageParameter(device.zh.modelID ?? ''));
|
||||
icon = icon.replace('${model}', utils.sanitizeImageParameter(device.definition.model));
|
||||
icon = icon.replace("${zigbeeModel}", utils.sanitizeImageParameter(device.zh.modelID ?? ""));
|
||||
icon = icon.replace("${model}", utils.sanitizeImageParameter(device.definition.model));
|
||||
}
|
||||
|
||||
const payload: Zigbee2MQTTDevice['definition'] = {
|
||||
const payload: Zigbee2MQTTDevice["definition"] = {
|
||||
model: device.definition.model,
|
||||
vendor: device.definition.vendor,
|
||||
description: device.definition.description,
|
||||
|
||||
+24
-24
@@ -1,15 +1,15 @@
|
||||
import type {Zigbee2MQTTAPI} from 'lib/types/api';
|
||||
import type {Zigbee2MQTTAPI} from "../types/api";
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
import * as zhc from "zigbee-herdsman-converters";
|
||||
|
||||
import Device from '../model/device';
|
||||
import logger from '../util/logger';
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import Extension from './extension';
|
||||
import Device from "../model/device";
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import utils from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
/**
|
||||
* This extension calls the zigbee-herdsman-converters definition configure() method
|
||||
@@ -26,17 +26,17 @@ export default class Configure extends Extension {
|
||||
data.device.zh.save();
|
||||
}
|
||||
|
||||
await this.configure(data.device, 'reporting_disabled');
|
||||
await this.configure(data.device, "reporting_disabled");
|
||||
}
|
||||
|
||||
@bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
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;
|
||||
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;
|
||||
|
||||
if (ID === undefined) {
|
||||
error = `Invalid payload`;
|
||||
error = "Invalid payload";
|
||||
} else {
|
||||
const device = this.zigbee.resolveEntity(ID);
|
||||
|
||||
@@ -46,16 +46,16 @@ export default class Configure extends Extension {
|
||||
error = `Device '${device.name}' cannot be configured`;
|
||||
} else {
|
||||
try {
|
||||
await this.configure(device, 'mqtt_message', true, true);
|
||||
await this.configure(device, "mqtt_message", true, true);
|
||||
} catch (e) {
|
||||
error = `Failed to configure (${(e as Error).message})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = utils.getResponse<'bridge/response/device/configure'>(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));
|
||||
await this.mqtt.publish("bridge/response/device/configure", stringify(response));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,10 +63,10 @@ export default class Configure extends Extension {
|
||||
setImmediate(async () => {
|
||||
// Only configure routers on startup, end devices are likely sleeping and
|
||||
// will reconfigure once they send a message
|
||||
for (const device of this.zigbee.devicesIterator((d) => d.type === 'Router')) {
|
||||
for (const device of this.zigbee.devicesIterator((d) => d.type === "Router")) {
|
||||
// Sleep 10 seconds between configuring on startup to not DDoS the coordinator when many devices have to be configured.
|
||||
await utils.sleep(10);
|
||||
await this.configure(device, 'started');
|
||||
await this.configure(device, "started");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -76,17 +76,17 @@ export default class Configure extends Extension {
|
||||
data.device.zh.save();
|
||||
}
|
||||
|
||||
await this.configure(data.device, 'zigbee_event');
|
||||
await this.configure(data.device, "zigbee_event");
|
||||
});
|
||||
this.eventBus.onDeviceInterview(this, (data) => this.configure(data.device, 'zigbee_event'));
|
||||
this.eventBus.onLastSeenChanged(this, (data) => this.configure(data.device, 'zigbee_event'));
|
||||
this.eventBus.onDeviceInterview(this, (data) => this.configure(data.device, "zigbee_event"));
|
||||
this.eventBus.onLastSeenChanged(this, (data) => this.configure(data.device, "zigbee_event"));
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
this.eventBus.onReconfigure(this, this.onReconfigure);
|
||||
}
|
||||
|
||||
private async configure(
|
||||
device: Device,
|
||||
event: 'started' | 'zigbee_event' | 'reporting_disabled' | 'mqtt_message',
|
||||
event: "started" | "zigbee_event" | "reporting_disabled" | "mqtt_message",
|
||||
force = false,
|
||||
throwError = false,
|
||||
): Promise<void> {
|
||||
@@ -95,7 +95,7 @@ export default class Configure extends Extension {
|
||||
}
|
||||
|
||||
if (!force) {
|
||||
if (device.options.disabled || !device.zh.interviewCompleted) {
|
||||
if (device.options.disabled || !device.interviewed) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ export default class Configure extends Extension {
|
||||
}
|
||||
|
||||
// Only configure end devices when it is active, otherwise it will likely fails as they are sleeping.
|
||||
if (device.zh.type === 'EndDevice' && event !== 'zigbee_event') {
|
||||
if (device.zh.type === "EndDevice" && event !== "zigbee_event") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
abstract class Extension {
|
||||
protected zigbee: Zigbee;
|
||||
protected mqtt: MQTT;
|
||||
protected mqtt: Mqtt;
|
||||
protected state: State;
|
||||
protected publishEntityState: PublishEntityState;
|
||||
protected eventBus: EventBus;
|
||||
@@ -12,7 +12,7 @@ abstract class Extension {
|
||||
* Besides initializing variables, the constructor should do nothing!
|
||||
*
|
||||
* @param {Zigbee} zigbee Zigbee controller
|
||||
* @param {MQTT} mqtt MQTT controller
|
||||
* @param {Mqtt} mqtt MQTT controller
|
||||
* @param {State} state State controller
|
||||
* @param {Function} publishEntityState Method to publish device state to MQTT.
|
||||
* @param {EventBus} eventBus The event bus
|
||||
@@ -22,7 +22,7 @@ abstract class Extension {
|
||||
*/
|
||||
constructor(
|
||||
zigbee: Zigbee,
|
||||
mqtt: MQTT,
|
||||
mqtt: Mqtt,
|
||||
state: State,
|
||||
publishEntityState: PublishEntityState,
|
||||
eventBus: EventBus,
|
||||
@@ -48,12 +48,13 @@ abstract class Extension {
|
||||
/**
|
||||
* Is called once the extension has to stop
|
||||
*/
|
||||
|
||||
// biome-ignore lint/suspicious/useAwait: API
|
||||
async stop(): Promise<void> {
|
||||
this.eventBus.removeListeners(this);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public adjustMessageBeforePublish(entity: Group | Device, message: KeyValue): void {}
|
||||
public adjustMessageBeforePublish(_entity: Group | Device, _message: KeyValue): void {}
|
||||
}
|
||||
|
||||
export default Extension;
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type * as zhc from 'zigbee-herdsman-converters';
|
||||
import type {ExternalDefinitionWithExtend} from "zigbee-herdsman-converters";
|
||||
|
||||
import {addDefinition, removeExternalDefinitions} from 'zigbee-herdsman-converters';
|
||||
import {addExternalDefinition, removeExternalDefinitions} from "zigbee-herdsman-converters";
|
||||
|
||||
import logger from '../util/logger';
|
||||
import ExternalJSExtension from './externalJS';
|
||||
import logger from "../util/logger";
|
||||
import ExternalJSExtension from "./externalJS";
|
||||
|
||||
type ModuleExports = zhc.Definition | zhc.Definition[];
|
||||
type TModule = ExternalDefinitionWithExtend | ExternalDefinitionWithExtend[];
|
||||
|
||||
export default class ExternalConverters extends ExternalJSExtension<ModuleExports> {
|
||||
export default class ExternalConverters extends ExternalJSExtension<TModule> {
|
||||
constructor(
|
||||
zigbee: Zigbee,
|
||||
mqtt: MQTT,
|
||||
mqtt: Mqtt,
|
||||
state: State,
|
||||
publishEntityState: PublishEntityState,
|
||||
eventBus: EventBus,
|
||||
@@ -27,42 +27,41 @@ export default class ExternalConverters extends ExternalJSExtension<ModuleExport
|
||||
enableDisableExtension,
|
||||
restartCallback,
|
||||
addExtension,
|
||||
'converter',
|
||||
'external_converters',
|
||||
"converter",
|
||||
"external_converters",
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
protected async removeJS(name: string, module: ModuleExports): Promise<void> {
|
||||
protected async removeJS(name: string, _mod: TModule): Promise<void> {
|
||||
removeExternalDefinitions(name);
|
||||
|
||||
await this.zigbee.resolveDevicesDefinitions(true);
|
||||
}
|
||||
|
||||
protected async loadJS(name: string, module: ModuleExports): Promise<void> {
|
||||
protected async loadJS(name: string, mod: TModule, newName?: string): Promise<void> {
|
||||
try {
|
||||
removeExternalDefinitions(name);
|
||||
|
||||
for (const definition of this.getDefinitions(module)) {
|
||||
definition.externalConverterName = name;
|
||||
const definitions = Array.isArray(mod) ? mod : [mod];
|
||||
|
||||
addDefinition(definition);
|
||||
logger.info(`Loaded external converter '${name}'.`);
|
||||
for (const definition of definitions) {
|
||||
definition.externalConverterName = newName ?? name;
|
||||
|
||||
addExternalDefinition(definition);
|
||||
logger.info(`Loaded external converter '${newName ?? 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`,
|
||||
/* v8 ignore next */
|
||||
`Failed to load external converter '${newName ?? name}'. Check the code for syntax error and make sure it is up to date with the current Zigbee2MQTT version.`,
|
||||
);
|
||||
logger.warning(
|
||||
"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;
|
||||
}
|
||||
}
|
||||
|
||||
private getDefinitions(module: ModuleExports): zhc.Definition[] {
|
||||
return Array.isArray(module) ? module : [module];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type Extension from './extension';
|
||||
import type Extension from "./extension";
|
||||
|
||||
import logger from '../util/logger';
|
||||
import * as settings from '../util/settings';
|
||||
import ExternalJSExtension from './externalJS';
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import ExternalJSExtension from "./externalJS";
|
||||
|
||||
type ModuleExports = typeof Extension;
|
||||
type TModule = new (...args: ConstructorParameters<typeof Extension>) => Extension;
|
||||
|
||||
export default class ExternalExtensions extends ExternalJSExtension<ModuleExports> {
|
||||
export default class ExternalExtensions extends ExternalJSExtension<TModule> {
|
||||
constructor(
|
||||
zigbee: Zigbee,
|
||||
mqtt: MQTT,
|
||||
mqtt: Mqtt,
|
||||
state: State,
|
||||
publishEntityState: PublishEntityState,
|
||||
eventBus: EventBus,
|
||||
@@ -26,34 +26,45 @@ export default class ExternalExtensions extends ExternalJSExtension<ModuleExport
|
||||
enableDisableExtension,
|
||||
restartCallback,
|
||||
addExtension,
|
||||
'extension',
|
||||
'external_extensions',
|
||||
"extension",
|
||||
"external_extensions",
|
||||
);
|
||||
}
|
||||
|
||||
protected async removeJS(name: string, module: ModuleExports): Promise<void> {
|
||||
await this.enableDisableExtension(false, module.name);
|
||||
protected async removeJS(_name: string, mod: TModule): Promise<void> {
|
||||
await this.enableDisableExtension(false, mod.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,
|
||||
),
|
||||
);
|
||||
protected async loadJS(name: string, mod: TModule, newName?: string): Promise<void> {
|
||||
try {
|
||||
// stop if already started
|
||||
await this.enableDisableExtension(false, mod.name);
|
||||
await this.addExtension(
|
||||
new mod(
|
||||
this.zigbee,
|
||||
this.mqtt,
|
||||
this.state,
|
||||
this.publishEntityState,
|
||||
this.eventBus,
|
||||
this.enableDisableExtension,
|
||||
this.restartCallback,
|
||||
this.addExtension,
|
||||
// @ts-expect-error additional params that don't fit the internal `Extension` type
|
||||
settings,
|
||||
logger,
|
||||
),
|
||||
);
|
||||
|
||||
logger.info(`Loaded external extension '${name}'.`);
|
||||
/* v8 ignore start */
|
||||
logger.info(`Loaded external extension '${newName ?? name}'.`);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
/* v8 ignore next */
|
||||
`Failed to load external extension '${newName ?? name}'. Check the code for syntax error and make sure it is up to date with the current Zigbee2MQTT version.`,
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
}
|
||||
|
||||
+106
-69
@@ -1,28 +1,31 @@
|
||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponse} from 'lib/types/api';
|
||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponse} from "../types/api";
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {Context, runInNewContext} from 'node:vm';
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import 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';
|
||||
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'];
|
||||
const SUPPORTED_OPERATIONS = ["save", "remove"];
|
||||
const TMP_PREFIX = ".tmp-ed42d4f2-";
|
||||
|
||||
export default abstract class ExternalJSExtension<M> extends Extension {
|
||||
protected folderName: string;
|
||||
protected mqttTopic: string;
|
||||
protected requestRegex: RegExp;
|
||||
protected basePath: string;
|
||||
protected nodeModulesSymlinked = false;
|
||||
|
||||
constructor(
|
||||
zigbee: Zigbee,
|
||||
mqtt: MQTT,
|
||||
mqtt: Mqtt,
|
||||
state: State,
|
||||
publishEntityState: PublishEntityState,
|
||||
eventBus: EventBus,
|
||||
@@ -34,11 +37,39 @@ export default abstract class ExternalJSExtension<M> extends Extension {
|
||||
) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension);
|
||||
|
||||
this.folderName = folderName;
|
||||
this.mqttTopic = mqttTopic;
|
||||
this.requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/${mqttTopic}/(save|remove)`);
|
||||
this.basePath = data.joinPath(folderName);
|
||||
}
|
||||
|
||||
/**
|
||||
* In case the external JS is not in the Z2M install dir (e.g. when `ZIGBEE2MQTT_DATA` is used), the external
|
||||
* JS cannot import from `node_modules`.
|
||||
* To workaround this create a symlink to `node_modules` in the external JS dir.
|
||||
* https://nodejs.org/api/esm.html#no-node_path
|
||||
*/
|
||||
private symlinkNodeModulesIfNecessary() {
|
||||
if (!this.nodeModulesSymlinked) {
|
||||
this.nodeModulesSymlinked = true;
|
||||
const nodeModulesPath = path.join(__dirname, "..", "..", "node_modules");
|
||||
const z2mDirNormalized = `${path.resolve(path.join(nodeModulesPath, ".."))}${path.sep}`;
|
||||
const basePathNormalized = `${path.resolve(this.basePath)}${path.sep}`;
|
||||
const basePathInZ2mDir = basePathNormalized.startsWith(z2mDirNormalized);
|
||||
if (!basePathInZ2mDir) {
|
||||
logger.debug(`External JS folder '${this.folderName}' is outside the Z2M install dir, creating a symlink to 'node_modules'`);
|
||||
const nodeModulesSymlink = path.join(this.basePath, "node_modules");
|
||||
if (fs.existsSync(nodeModulesSymlink)) {
|
||||
fs.unlinkSync(nodeModulesSymlink);
|
||||
}
|
||||
// Type `junction` is required on Windows.
|
||||
// https://github.com/nodejs/node/issues/18518#issuecomment-513866491
|
||||
/* v8 ignore next */
|
||||
fs.symlinkSync(nodeModulesPath, nodeModulesSymlink, os.platform() === "win32" ? "junction" : "dir");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override async start(): Promise<void> {
|
||||
await super.start();
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
@@ -46,7 +77,7 @@ export default abstract class ExternalJSExtension<M> extends Extension {
|
||||
await this.publishExternalJS();
|
||||
}
|
||||
|
||||
private getFilePath(name: string, mkBasePath: boolean = false): string {
|
||||
private getFilePath(name: string, mkBasePath = false): string {
|
||||
if (mkBasePath && !fs.existsSync(this.basePath)) {
|
||||
fs.mkdirSync(this.basePath, {recursive: true});
|
||||
}
|
||||
@@ -55,17 +86,15 @@ export default abstract class ExternalJSExtension<M> extends Extension {
|
||||
}
|
||||
|
||||
protected getFileCode(name: string): string {
|
||||
return fs.readFileSync(path.join(this.basePath, name), 'utf8');
|
||||
return fs.readFileSync(this.getFilePath(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)};
|
||||
if (fs.existsSync(this.basePath)) {
|
||||
for (const fileName of fs.readdirSync(this.basePath)) {
|
||||
if (!fileName.startsWith(TMP_PREFIX) && (fileName.endsWith(".js") || fileName.endsWith(".cjs") || fileName.endsWith(".mjs"))) {
|
||||
yield {name: fileName, code: this.getFileCode(fileName)};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,15 +106,15 @@ export default abstract class ExternalJSExtension<M> extends Extension {
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
|
||||
try {
|
||||
let response;
|
||||
let response: Awaited<ReturnType<typeof this.save | typeof this.remove>>;
|
||||
|
||||
if (match[1].toLowerCase() === 'save') {
|
||||
if (match[1].toLowerCase() === "save") {
|
||||
response = await this.save(
|
||||
message as Zigbee2MQTTAPI['bridge/request/converter/save'] | Zigbee2MQTTAPI['bridge/request/extension/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'],
|
||||
message as Zigbee2MQTTAPI["bridge/request/converter/remove"] | Zigbee2MQTTAPI["bridge/request/extension/remove"],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,48 +129,50 @@ export default abstract class ExternalJSExtension<M> extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract removeJS(name: string, module: M): Promise<void>;
|
||||
protected abstract removeJS(name: string, mod: M): Promise<void>;
|
||||
|
||||
protected abstract loadJS(name: string, module: M): Promise<void>;
|
||||
protected abstract loadJS(name: string, mod: M, newName?: string): 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'>> {
|
||||
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`);
|
||||
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));
|
||||
const mod = await this.importFile(toBeRemoved);
|
||||
|
||||
await this.removeJS(name, mod.default);
|
||||
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`);
|
||||
}
|
||||
|
||||
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'>> {
|
||||
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`);
|
||||
return utils.getResponse(message, {}, "Invalid payload");
|
||||
}
|
||||
|
||||
const {name, code} = message;
|
||||
|
||||
const filePath = this.getFilePath(name, true);
|
||||
try {
|
||||
await this.loadJS(name, this.loadModuleFromText(code, name));
|
||||
fs.writeFileSync(filePath, code, "utf8");
|
||||
this.symlinkNodeModulesIfNecessary();
|
||||
|
||||
const filePath = this.getFilePath(name, true);
|
||||
const mod = await this.importFile(filePath);
|
||||
|
||||
fs.writeFileSync(filePath, code, 'utf8');
|
||||
await this.loadJS(name, mod.default, name);
|
||||
logger.info(`${name} loaded. Contents written to '${filePath}'.`);
|
||||
await this.publishExternalJS();
|
||||
|
||||
@@ -153,39 +184,45 @@ export default abstract class ExternalJSExtension<M> extends Extension {
|
||||
|
||||
private async loadFiles(): Promise<void> {
|
||||
for (const extension of this.getFiles()) {
|
||||
await this.loadJS(extension.name, this.loadModuleFromText(extension.code, extension.name));
|
||||
this.symlinkNodeModulesIfNecessary();
|
||||
const filePath = this.getFilePath(extension.name);
|
||||
|
||||
try {
|
||||
const mod = await this.importFile(filePath);
|
||||
await this.loadJS(extension.name, mod.default);
|
||||
} catch (error) {
|
||||
// change ext so Z2M doesn't try to load it again and again
|
||||
fs.renameSync(filePath, `${filePath}.invalid`);
|
||||
|
||||
logger.error(
|
||||
`Invalid external ${this.mqttTopic} '${extension.name}' was ignored and renamed to prevent interference with Zigbee2MQTT.`,
|
||||
);
|
||||
// biome-ignore lint/style/noNonNullAssertion: always Error
|
||||
logger.debug((error as Error).stack!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
await this.mqtt.publish(`bridge/${this.mqttTopic}s`, stringify(Array.from(this.getFiles())), {
|
||||
clientOptions: {retain: true},
|
||||
skipLog: 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;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic module
|
||||
private async importFile(file: string): Promise<any> {
|
||||
const ext = path.extname(file);
|
||||
// Create the file in a temp path to bypass node module cache when importing multiple times.
|
||||
const tmpFile = path.join(this.basePath, `${TMP_PREFIX}${path.basename(file, ext)}-${crypto.randomUUID()}${ext}`);
|
||||
fs.copyFileSync(file, tmpFile);
|
||||
try {
|
||||
// Do `replaceAll("\\", "/")` to prevent issues on Windows
|
||||
/* v8 ignore next */
|
||||
const mod = await import(os.platform() === "win32" ? `file:///${tmpFile.replaceAll("\\", "/")}` : tmpFile);
|
||||
return mod;
|
||||
} finally {
|
||||
fs.rmSync(tmpFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+109
-101
@@ -1,39 +1,32 @@
|
||||
import type {IncomingMessage, Server, ServerResponse} from 'node:http';
|
||||
import type {Socket} from 'node:net';
|
||||
import type {IncomingMessage, Server, ServerResponse} from "node:http";
|
||||
import type {Socket} from "node:net";
|
||||
|
||||
import type {RequestHandler} from 'express-static-gzip';
|
||||
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 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 expressStaticGzip from 'express-static-gzip';
|
||||
import finalhandler from 'finalhandler';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import WebSocket from 'ws';
|
||||
import bind from "bind-decorator";
|
||||
import expressStaticGzip from "express-static-gzip";
|
||||
import finalhandler from "finalhandler";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import WebSocket from "ws";
|
||||
|
||||
import 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';
|
||||
import Extension from './extension';
|
||||
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";
|
||||
|
||||
/**
|
||||
* This extension servers the frontend
|
||||
*/
|
||||
export default class Frontend extends Extension {
|
||||
export class Frontend extends Extension {
|
||||
private mqttBaseTopic: string;
|
||||
private host: string | undefined;
|
||||
private port: number;
|
||||
private sslCert: string | undefined;
|
||||
private sslKey: string | undefined;
|
||||
private authToken: string | undefined;
|
||||
private server!: Server;
|
||||
private fileServer!: RequestHandler;
|
||||
private deviceIconsFileServer!: RequestHandler;
|
||||
@@ -42,7 +35,7 @@ export default class Frontend extends Extension {
|
||||
|
||||
constructor(
|
||||
zigbee: Zigbee,
|
||||
mqtt: MQTT,
|
||||
mqtt: Mqtt,
|
||||
state: State,
|
||||
publishEntityState: PublishEntityState,
|
||||
eventBus: EventBus,
|
||||
@@ -54,128 +47,126 @@ export default class Frontend extends Extension {
|
||||
|
||||
const frontendSettings = settings.get().frontend;
|
||||
assert(frontendSettings.enabled, `Frontend extension created with setting 'enabled: false'`);
|
||||
this.host = frontendSettings.host;
|
||||
this.port = frontendSettings.port;
|
||||
this.sslCert = frontendSettings.ssl_cert;
|
||||
this.sslKey = frontendSettings.ssl_key;
|
||||
this.authToken = frontendSettings.auth_token;
|
||||
this.baseUrl = frontendSettings.base_url;
|
||||
this.mqttBaseTopic = settings.get().mqtt.base_topic;
|
||||
}
|
||||
|
||||
private isHttpsConfigured(): boolean {
|
||||
if (this.sslCert && this.sslKey) {
|
||||
if (!existsSync(this.sslCert) || !existsSync(this.sslKey)) {
|
||||
logger.error(`defined ssl_cert '${this.sslCert}' or ssl_key '${this.sslKey}' file path does not exists, server won't be secured.`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
override async start(): Promise<void> {
|
||||
const hasSSL = (val: string | undefined, key: string): val is string => {
|
||||
if (val) {
|
||||
if (!existsSync(val)) {
|
||||
logger.error(`Defined ${key} '${val}' file path does not exists, server won't be secured.`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const {host, port, ssl_key: sslKey, ssl_cert: sslCert} = settings.get().frontend;
|
||||
const options = {
|
||||
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',
|
||||
index: "index.html",
|
||||
/* v8 ignore start */
|
||||
setHeaders: (res: ServerResponse, path: string): void => {
|
||||
if (path.endsWith('index.html')) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
if (path.endsWith("index.html")) {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
}
|
||||
},
|
||||
/* v8 ignore stop */
|
||||
},
|
||||
};
|
||||
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')});
|
||||
const frontend = (await import(settings.get().frontend.package)) as typeof import("zigbee2mqtt-frontend");
|
||||
this.fileServer = expressStaticGzip(frontend.default.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);
|
||||
this.wss.on("connection", this.onWebSocketConnection);
|
||||
|
||||
if (this.isHttpsConfigured()) {
|
||||
const serverOptions = {
|
||||
key: readFileSync(this.sslKey!), // valid from `isHttpsConfigured`
|
||||
cert: readFileSync(this.sslCert!), // valid from `isHttpsConfigured`
|
||||
};
|
||||
if (hasSSL(sslKey, "ssl_key") && hasSSL(sslCert, "ssl_cert")) {
|
||||
const serverOptions = {key: readFileSync(sslKey), cert: readFileSync(sslCert)};
|
||||
this.server = createSecureServer(serverOptions, this.onRequest);
|
||||
} else {
|
||||
this.server = createServer(this.onRequest);
|
||||
}
|
||||
|
||||
this.server.on('upgrade', this.onUpgrade);
|
||||
this.eventBus.onMQTTMessagePublished(this, this.onMQTTPublishMessage);
|
||||
this.server.on("upgrade", this.onUpgrade);
|
||||
this.eventBus.onMQTTMessagePublished(this, this.onMQTTPublishMessageOrEntityState);
|
||||
this.eventBus.onPublishEntityState(this, this.onMQTTPublishMessageOrEntityState);
|
||||
|
||||
if (!this.host) {
|
||||
this.server.listen(this.port);
|
||||
logger.info(`Started frontend on port ${this.port}`);
|
||||
} else if (this.host.startsWith('/')) {
|
||||
this.server.listen(this.host);
|
||||
logger.info(`Started frontend on socket ${this.host}`);
|
||||
if (!host) {
|
||||
this.server.listen(port);
|
||||
logger.info(`Started frontend on port ${port}`);
|
||||
} else if (host.startsWith("/")) {
|
||||
this.server.listen(host);
|
||||
logger.info(`Started frontend on socket ${host}`);
|
||||
} else {
|
||||
this.server.listen(this.port, this.host);
|
||||
logger.info(`Started frontend on port ${this.host}:${this.port}`);
|
||||
this.server.listen(port, host);
|
||||
logger.info(`Started frontend on port ${host}:${port}`);
|
||||
}
|
||||
}
|
||||
|
||||
override async stop(): Promise<void> {
|
||||
await super.stop();
|
||||
this.wss?.clients.forEach((client) => {
|
||||
client.send(stringify({topic: 'bridge/state', payload: 'offline'}));
|
||||
client.terminate();
|
||||
});
|
||||
this.wss?.close();
|
||||
|
||||
if (this.wss) {
|
||||
for (const client of this.wss.clients) {
|
||||
client.send(stringify({topic: "bridge/state", payload: {state: "offline"}}));
|
||||
client.terminate();
|
||||
}
|
||||
|
||||
this.wss.close();
|
||||
}
|
||||
|
||||
await new Promise((resolve) => this.server?.close(resolve));
|
||||
}
|
||||
|
||||
@bind private onRequest(request: IncomingMessage, response: ServerResponse): void {
|
||||
const fin = finalhandler(request, response);
|
||||
// biome-ignore lint/style/noNonNullAssertion: `Only valid for request obtained from Server`
|
||||
const newUrl = posix.relative(this.baseUrl, request.url!);
|
||||
|
||||
// The request url is not within the frontend base url, so the relative path starts with '..'
|
||||
if (newUrl.startsWith('.')) {
|
||||
return fin();
|
||||
if (newUrl.startsWith(".")) {
|
||||
fin();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Attach originalUrl so that static-server can perform a redirect to '/' when serving the root directory.
|
||||
// This is necessary for the browser to resolve relative assets paths correctly.
|
||||
request.originalUrl = request.url;
|
||||
request.url = '/' + newUrl;
|
||||
request.url = `/${newUrl}`;
|
||||
request.path = request.url;
|
||||
|
||||
if (newUrl.startsWith('device_icons/')) {
|
||||
request.path = request.path.replace('device_icons/', '');
|
||||
request.url = request.url.replace('/device_icons', '');
|
||||
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 {
|
||||
const {query} = parse(request.url!, true);
|
||||
cb(!this.authToken || this.authToken === query.token);
|
||||
}
|
||||
|
||||
@bind private onUpgrade(request: IncomingMessage, socket: Socket, head: Buffer): void {
|
||||
this.wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
this.authenticate(request, (isAuthenticated) => {
|
||||
if (isAuthenticated) {
|
||||
this.wss.emit('connection', ws, request);
|
||||
} else {
|
||||
ws.close(4401, 'Unauthorized');
|
||||
}
|
||||
});
|
||||
// biome-ignore lint/style/noNonNullAssertion: `Only valid for request obtained from Server`
|
||||
const {query} = parse(request.url!, true);
|
||||
const authToken = settings.get().frontend.auth_token;
|
||||
|
||||
if (!authToken || authToken === query.token) {
|
||||
this.wss.emit("connection", ws, request);
|
||||
} else {
|
||||
ws.close(4401, "Unauthorized");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@bind private onWebSocketConnection(ws: WebSocket): void {
|
||||
ws.on('error', (msg) => logger.error(`WebSocket error: ${msg.message}`));
|
||||
ws.on('message', (data: Buffer, isBinary: boolean) => {
|
||||
ws.on("error", (msg) => logger.error(`WebSocket error: ${msg.message}`));
|
||||
ws.on("message", (data: Buffer, isBinary: boolean) => {
|
||||
if (!isBinary && data) {
|
||||
const message = data.toString();
|
||||
const {topic, payload} = JSON.parse(message);
|
||||
@@ -199,7 +190,7 @@ export default class Frontend extends Extension {
|
||||
const payload = this.state.get(device);
|
||||
const lastSeen = settings.get().advanced.last_seen;
|
||||
|
||||
if (lastSeen !== 'disable') {
|
||||
if (lastSeen !== "disable") {
|
||||
payload.last_seen = utils.formatDate(device.zh.lastSeen ?? /* v8 ignore next */ 0, lastSeen);
|
||||
}
|
||||
|
||||
@@ -211,17 +202,34 @@ export default class Frontend extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
@bind private onMQTTPublishMessage(data: eventdata.MQTTMessagePublished): void {
|
||||
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);
|
||||
@bind private onMQTTPublishMessageOrEntityState(data: eventdata.MQTTMessagePublished | eventdata.PublishEntityState): void {
|
||||
let topic: string;
|
||||
let payload: KeyValue | string;
|
||||
|
||||
for (const client of this.wss.clients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(stringify({topic, payload}));
|
||||
}
|
||||
if ("topic" in data) {
|
||||
// MQTTMessagePublished
|
||||
if (data.options.meta.isEntityState || !data.topic.startsWith(`${this.mqttBaseTopic}/`)) {
|
||||
// Don't send entity state to frontend on `MQTTMessagePublished` event, this is handled by
|
||||
// `PublishEntityState` instead. Reason for this is to skip attribute messages when `output` is
|
||||
// set to `attribute` or `attribute_and_json`, we only want to send JSON entity states to the
|
||||
// frontend.
|
||||
return;
|
||||
}
|
||||
// Send topic without base_topic
|
||||
topic = data.topic.substring(this.mqttBaseTopic.length + 1);
|
||||
payload = utils.parseJSON(data.payload, data.payload);
|
||||
} else {
|
||||
// PublishEntityState
|
||||
topic = data.entity.name;
|
||||
payload = data.message;
|
||||
}
|
||||
|
||||
for (const client of this.wss.clients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(stringify({topic, payload}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Frontend;
|
||||
|
||||
+67
-64
@@ -1,37 +1,37 @@
|
||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from 'lib/types/api';
|
||||
import type * as zhc from "zigbee-herdsman-converters";
|
||||
|
||||
import assert from 'node:assert';
|
||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from "../types/api";
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import equals from 'fast-deep-equal/es6';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import assert from "node:assert";
|
||||
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
import bind from "bind-decorator";
|
||||
import equals from "fast-deep-equal/es6";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
|
||||
import Device from '../model/device';
|
||||
import Group from '../model/group';
|
||||
import logger from '../util/logger';
|
||||
import * as settings from '../util/settings';
|
||||
import utils, {isLightExpose} from '../util/utils';
|
||||
import Extension from './extension';
|
||||
import Device from "../model/device";
|
||||
import Group from "../model/group";
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
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 STATE_PROPERTIES: Readonly<Record<string, (value: string, exposes: zhc.Expose[]) => boolean>> = {
|
||||
state: () => true,
|
||||
brightness: (value, exposes) => exposes.some((e) => isLightExpose(e) && e.features.some((f) => f.name === 'brightness')),
|
||||
color_temp: (value, exposes) => exposes.some((e) => isLightExpose(e) && e.features.some((f) => f.name === 'color_temp')),
|
||||
color: (value, exposes) => exposes.some((e) => isLightExpose(e) && e.features.some((f) => f.name === 'color_xy' || f.name === 'color_hs')),
|
||||
brightness: (_value, exposes) => exposes.some((e) => isLightExpose(e) && e.features.some((f) => f.name === "brightness")),
|
||||
color_temp: (_value, exposes) => exposes.some((e) => isLightExpose(e) && e.features.some((f) => f.name === "color_temp")),
|
||||
color: (_value, exposes) => exposes.some((e) => isLightExpose(e) && e.features.some((f) => f.name === "color_xy" || f.name === "color_hs")),
|
||||
color_mode: (value, exposes) =>
|
||||
exposes.some(
|
||||
(e) =>
|
||||
isLightExpose(e) &&
|
||||
(e.features.some((f) => f.name === `color_${value}`) || (value === 'color_temp' && e.features.some((f) => f.name === 'color_temp'))),
|
||||
(e.features.some((f) => f.name === `color_${value}`) || (value === "color_temp" && e.features.some((f) => f.name === "color_temp"))),
|
||||
),
|
||||
};
|
||||
|
||||
interface ParsedMQTTMessage {
|
||||
type: 'remove' | 'add' | 'remove_all';
|
||||
type: "remove" | "add" | "remove_all";
|
||||
resolvedGroup?: Group;
|
||||
resolvedDevice?: Device;
|
||||
resolvedEndpoint?: zh.Endpoint;
|
||||
@@ -44,15 +44,16 @@ interface ParsedMQTTMessage {
|
||||
export default class Groups extends Extension {
|
||||
private lastOptimisticState: {[s: string]: KeyValue} = {};
|
||||
|
||||
// biome-ignore lint/suspicious/useAwait: API
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onStateChange(this, this.onStateChange);
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
}
|
||||
|
||||
@bind async onStateChange(data: eventdata.StateChange): Promise<void> {
|
||||
const reason = 'groupOptimistic';
|
||||
const reason = "groupOptimistic";
|
||||
|
||||
if (data.reason === reason || data.reason === 'publishCached') {
|
||||
if (data.reason === reason || data.reason === "publishCached") {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -81,7 +82,7 @@ export default class Groups extends Extension {
|
||||
const groups = [];
|
||||
|
||||
for (const group of this.zigbee.groupsIterator()) {
|
||||
if (group.options && (group.options.optimistic == undefined || group.options.optimistic)) {
|
||||
if (group.options && (group.options.optimistic == null || group.options.optimistic)) {
|
||||
groups.push(group);
|
||||
}
|
||||
}
|
||||
@@ -106,7 +107,7 @@ export default class Groups extends Extension {
|
||||
// Invalidate the last optimistic group state when group state is changed directly.
|
||||
delete this.lastOptimisticState[entity.ID];
|
||||
|
||||
const groupsToPublish: Set<Group> = new Set();
|
||||
const groupsToPublish = new Set<Group>();
|
||||
|
||||
for (const member of entity.zh.members) {
|
||||
const device = this.zigbee.resolveEntity(member.getDevice()) as Device;
|
||||
@@ -153,15 +154,16 @@ export default class Groups extends Extension {
|
||||
|
||||
private shouldPublishPayloadForGroup(group: Group, payload: KeyValue): boolean {
|
||||
return (
|
||||
group.options.off_state === 'last_member_state' ||
|
||||
group.options.off_state === "last_member_state" ||
|
||||
!payload ||
|
||||
(payload.state !== 'OFF' && payload.state !== 'CLOSE') ||
|
||||
(payload.state !== "OFF" && payload.state !== "CLOSE") ||
|
||||
this.areAllMembersOffOrClosed(group)
|
||||
);
|
||||
}
|
||||
|
||||
private areAllMembersOffOrClosed(group: Group): boolean {
|
||||
for (const member of group.zh.members) {
|
||||
// biome-ignore lint/style/noNonNullAssertion: TODO: biome migration: valid from loop?
|
||||
const device = this.zigbee.resolveEntity(member.getDevice())!;
|
||||
|
||||
if (this.state.exists(device)) {
|
||||
@@ -171,11 +173,11 @@ export default class Groups extends Extension {
|
||||
endpointNames &&
|
||||
endpointNames.length >= member.ID &&
|
||||
device.definition?.meta?.multiEndpoint &&
|
||||
(!device.definition.meta.multiEndpointSkip || !device.definition.meta.multiEndpointSkip.includes('state'))
|
||||
(!device.definition.meta.multiEndpointSkip || !device.definition.meta.multiEndpointSkip.includes("state"))
|
||||
? `state_${endpointNames[member.ID - 1]}`
|
||||
: 'state';
|
||||
: "state";
|
||||
|
||||
if (state[stateKey] === 'ON' || state[stateKey] === 'OPEN') {
|
||||
if (state[stateKey] === "ON" || state[stateKey] === "OPEN") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -190,31 +192,36 @@ export default class Groups extends Extension {
|
||||
const topicRegexMatch = data.topic.match(TOPIC_REGEX);
|
||||
|
||||
if (topicRegexMatch) {
|
||||
const type = topicRegexMatch[1] as 'remove' | 'add' | 'remove_all';
|
||||
let resolvedGroup;
|
||||
let groupKey;
|
||||
const type = topicRegexMatch[1] as "remove" | "add" | "remove_all";
|
||||
let resolvedGroup: Group | undefined;
|
||||
let groupKey: string | undefined;
|
||||
let skipDisableReporting = false;
|
||||
const message = JSON.parse(data.message) as Zigbee2MQTTAPI['bridge/request/group/members/add'];
|
||||
const message = JSON.parse(data.message) as
|
||||
| Zigbee2MQTTAPI["bridge/request/group/members/add"]
|
||||
| Zigbee2MQTTAPI["bridge/request/group/members/remove"]
|
||||
| Zigbee2MQTTAPI["bridge/request/group/members/remove_all"];
|
||||
|
||||
if (typeof message !== 'object' || message.device == undefined) {
|
||||
return [message, {type, skipDisableReporting}, 'Invalid payload'];
|
||||
if (typeof message !== "object" || message.device == null) {
|
||||
return [message, {type, skipDisableReporting}, "Invalid payload"];
|
||||
}
|
||||
|
||||
const deviceKey = message.device;
|
||||
skipDisableReporting = message.skip_disable_reporting != undefined ? message.skip_disable_reporting : false;
|
||||
skipDisableReporting = message.skip_disable_reporting != null ? message.skip_disable_reporting : false;
|
||||
|
||||
if (type !== "remove_all") {
|
||||
if (!("group" in message) || message.group == null) {
|
||||
return [message, {type, skipDisableReporting}, "Invalid payload"];
|
||||
}
|
||||
|
||||
if (type !== 'remove_all') {
|
||||
groupKey = message.group;
|
||||
|
||||
if (message.group == undefined) {
|
||||
return [message, {type, skipDisableReporting}, `Invalid payload`];
|
||||
}
|
||||
const group = this.zigbee.resolveEntity(message.group);
|
||||
|
||||
resolvedGroup = this.zigbee.resolveEntity(message.group);
|
||||
|
||||
if (!resolvedGroup || !(resolvedGroup instanceof Group)) {
|
||||
if (!group || !(group instanceof Group)) {
|
||||
return [message, {type, skipDisableReporting}, `Group '${message.group}' does not exist`];
|
||||
}
|
||||
|
||||
resolvedGroup = group;
|
||||
}
|
||||
|
||||
const resolvedDevice = this.zigbee.resolveEntity(message.device);
|
||||
@@ -223,7 +230,7 @@ export default class Groups extends Extension {
|
||||
return [message, {type, skipDisableReporting}, `Device '${message.device}' does not exist`];
|
||||
}
|
||||
|
||||
const endpointKey = message.endpoint ?? 'default';
|
||||
const endpointKey = message.endpoint ?? "default";
|
||||
const resolvedEndpoint = resolvedDevice.endpoint(message.endpoint);
|
||||
|
||||
if (!resolvedEndpoint) {
|
||||
@@ -244,9 +251,9 @@ export default class Groups extends Extension {
|
||||
},
|
||||
undefined,
|
||||
];
|
||||
} else {
|
||||
return [undefined, undefined, undefined];
|
||||
}
|
||||
|
||||
return [undefined, undefined, undefined];
|
||||
}
|
||||
|
||||
@bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
@@ -264,30 +271,26 @@ export default class Groups extends Extension {
|
||||
const {resolvedGroup, resolvedDevice, resolvedEndpoint, type, groupKey, deviceKey, endpointKey, skipDisableReporting} = parsed;
|
||||
const changedGroups: Group[] = [];
|
||||
|
||||
assert(resolvedDevice, '`resolvedDevice` is missing');
|
||||
assert(resolvedEndpoint, '`resolvedEndpoint` is missing');
|
||||
assert(resolvedDevice, "`resolvedDevice` is missing");
|
||||
assert(resolvedEndpoint, "`resolvedEndpoint` is missing");
|
||||
|
||||
try {
|
||||
if (type === 'add') {
|
||||
assert(resolvedGroup, '`resolvedGroup` is missing');
|
||||
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');
|
||||
// biome-ignore lint/style/noNonNullAssertion: valid from resolved asserts
|
||||
const respPayload = {device: deviceKey!, endpoint: endpointKey!, group: groupKey!};
|
||||
await this.publishResponse<"bridge/response/group/members/add">(parsed.type, raw, respPayload);
|
||||
} 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
|
||||
});
|
||||
// biome-ignore lint/style/noNonNullAssertion: valid from resolved asserts
|
||||
const respPayload = {device: deviceKey!, endpoint: endpointKey!, group: groupKey!};
|
||||
await this.publishResponse<"bridge/response/group/members/remove">(parsed.type, raw, respPayload);
|
||||
} else {
|
||||
// remove_all
|
||||
logger.info(`Removing '${resolvedDevice.name}' from all groups`);
|
||||
@@ -297,14 +300,14 @@ export default class Groups extends Extension {
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
// biome-ignore lint/style/noNonNullAssertion: valid from resolved asserts
|
||||
const respPayload = {device: deviceKey!, endpoint: endpointKey!};
|
||||
await this.publishResponse<"bridge/response/group/members/remove_all">(parsed.type, raw, respPayload);
|
||||
}
|
||||
} catch (e) {
|
||||
const errorMsg = `Failed to ${type} from group (${(e as Error).message})`;
|
||||
await this.publishResponse(parsed.type, raw, {}, errorMsg);
|
||||
// biome-ignore lint/style/noNonNullAssertion: always Error
|
||||
logger.debug((e as Error).stack!);
|
||||
return;
|
||||
}
|
||||
@@ -315,7 +318,7 @@ export default class Groups extends Extension {
|
||||
}
|
||||
|
||||
private async publishResponse<T extends Zigbee2MQTTResponseEndpoints>(
|
||||
type: ParsedMQTTMessage['type'],
|
||||
type: ParsedMQTTMessage["type"],
|
||||
request: KeyValue,
|
||||
data: Zigbee2MQTTAPI[T],
|
||||
error?: string,
|
||||
|
||||
+633
-608
File diff suppressed because it is too large
Load Diff
+98
-93
@@ -1,14 +1,14 @@
|
||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTNetworkMap} from 'lib/types/api';
|
||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTNetworkMap} from "../types/api";
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
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';
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import utils from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
const SUPPORTED_FORMATS = ['raw', 'graphviz', 'plantuml'];
|
||||
const SUPPORTED_FORMATS = ["raw", "graphviz", "plantuml"];
|
||||
|
||||
/**
|
||||
* This extension creates a network map
|
||||
@@ -16,43 +16,44 @@ const SUPPORTED_FORMATS = ['raw', 'graphviz', 'plantuml'];
|
||||
export default class NetworkMap extends Extension {
|
||||
private topic = `${settings.get().mqtt.base_topic}/bridge/request/networkmap`;
|
||||
|
||||
// biome-ignore lint/suspicious/useAwait: API
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
}
|
||||
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
if (data.topic === this.topic) {
|
||||
const message = utils.parseJSON(data.message, data.message) as Zigbee2MQTTAPI['bridge/request/networkmap'];
|
||||
const message = utils.parseJSON(data.message, data.message) as Zigbee2MQTTAPI["bridge/request/networkmap"];
|
||||
|
||||
try {
|
||||
const type = typeof message === 'object' ? message.type : message;
|
||||
const type = typeof message === "object" ? message.type : message;
|
||||
|
||||
if (!SUPPORTED_FORMATS.includes(type)) {
|
||||
throw new Error(`Type '${type}' not supported, allowed are: ${SUPPORTED_FORMATS.join(',')}`);
|
||||
throw new Error(`Type '${type}' not supported, allowed are: ${SUPPORTED_FORMATS.join(",")}`);
|
||||
}
|
||||
|
||||
const routes = typeof message === 'object' && message.routes;
|
||||
const routes = typeof message === "object" && message.routes;
|
||||
const topology = await this.networkScan(routes);
|
||||
let responseData: Zigbee2MQTTAPI['bridge/response/networkmap'];
|
||||
let responseData: Zigbee2MQTTAPI["bridge/response/networkmap"];
|
||||
|
||||
switch (type) {
|
||||
case 'raw': {
|
||||
case "raw": {
|
||||
responseData = {type, routes, value: this.raw(topology)};
|
||||
break;
|
||||
}
|
||||
case 'graphviz': {
|
||||
case "graphviz": {
|
||||
responseData = {type, routes, value: this.graphviz(topology)};
|
||||
break;
|
||||
}
|
||||
case 'plantuml': {
|
||||
case "plantuml": {
|
||||
responseData = {type, routes, value: this.plantuml(topology)};
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await this.mqtt.publish('bridge/response/networkmap', stringify(utils.getResponse(message, responseData)));
|
||||
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)));
|
||||
await this.mqtt.publish("bridge/response/networkmap", stringify(utils.getResponse(message, {}, (error as Error).message)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,10 +65,10 @@ export default class NetworkMap extends Extension {
|
||||
graphviz(topology: Zigbee2MQTTNetworkMap): string {
|
||||
const colors = settings.get().map_options.graphviz.colors;
|
||||
|
||||
let text = 'digraph G {\nnode[shape=record];\n';
|
||||
let style = '';
|
||||
let text = "digraph G {\nnode[shape=record];\n";
|
||||
let style = "";
|
||||
|
||||
topology.nodes.forEach((node) => {
|
||||
for (const node of topology.nodes) {
|
||||
const labels = [];
|
||||
|
||||
// Add friendly name
|
||||
@@ -75,118 +76,119 @@ export default class NetworkMap extends Extension {
|
||||
|
||||
// Add the device short network address, ieeaddr and scan note (if any)
|
||||
labels.push(
|
||||
`${node.ieeeAddr} (${utils.toNetworkAddressHex(node.networkAddress)})` +
|
||||
(node.failed && node.failed.length ? `failed: ${node.failed.join(',')}` : ''),
|
||||
`${node.ieeeAddr} (${utils.toNetworkAddressHex(node.networkAddress)})${node.failed?.length ? `failed: ${node.failed.join(",")}` : ""}`,
|
||||
);
|
||||
|
||||
// Add the device model
|
||||
if (node.type !== 'Coordinator') {
|
||||
if (node.type !== "Coordinator") {
|
||||
labels.push(`${node.definition?.vendor} ${node.definition?.description} (${node.definition?.model})`);
|
||||
}
|
||||
|
||||
// Add the device last_seen timestamp
|
||||
let lastSeen = 'unknown';
|
||||
const date = node.type === 'Coordinator' ? Date.now() : node.lastSeen;
|
||||
let lastSeen = "unknown";
|
||||
const date = node.type === "Coordinator" ? Date.now() : node.lastSeen;
|
||||
if (date) {
|
||||
lastSeen = utils.formatDate(date, 'relative') as string;
|
||||
lastSeen = utils.formatDate(date, "relative") as string;
|
||||
}
|
||||
|
||||
labels.push(lastSeen);
|
||||
|
||||
// Shape the record according to device type
|
||||
if (node.type == 'Coordinator') {
|
||||
if (node.type === "Coordinator") {
|
||||
style = `style="bold, filled", fillcolor="${colors.fill.coordinator}", fontcolor="${colors.font.coordinator}"`;
|
||||
} else if (node.type == 'Router') {
|
||||
} else if (node.type === "Router") {
|
||||
style = `style="rounded, filled", fillcolor="${colors.fill.router}", fontcolor="${colors.font.router}"`;
|
||||
} else {
|
||||
style = `style="rounded, dashed, filled", fillcolor="${colors.fill.enddevice}", fontcolor="${colors.font.enddevice}"`;
|
||||
}
|
||||
|
||||
// Add the device with its labels to the graph as a node.
|
||||
text += ` "${node.ieeeAddr}" [${style}, label="{${labels.join('|')}}"];\n`;
|
||||
text += ` "${node.ieeeAddr}" [${style}, label="{${labels.join("|")}}"];\n`;
|
||||
|
||||
/**
|
||||
* Add an edge between the device and its child to the graph
|
||||
* NOTE: There are situations where a device is NOT in the topology, this can be e.g.
|
||||
* due to not responded to the lqi scan. In that case we do not add an edge for this device.
|
||||
*/
|
||||
topology.links
|
||||
.filter((e) => e.source.ieeeAddr === node.ieeeAddr)
|
||||
.forEach((e) => {
|
||||
const lineStyle = node.type == 'EndDevice' ? 'penwidth=1, ' : !e.routes.length ? 'penwidth=0.5, ' : 'penwidth=2, ';
|
||||
const lineWeight = !e.routes.length ? `weight=0, color="${colors.line.inactive}", ` : `weight=1, color="${colors.line.active}", `;
|
||||
const textRoutes = e.routes.map((r) => utils.toNetworkAddressHex(r.destinationAddress));
|
||||
const lineLabels = !e.routes.length ? `label="${e.linkquality}"` : `label="${e.linkquality} (routes: ${textRoutes.join(',')})"`;
|
||||
text += ` "${node.ieeeAddr}" -> "${e.target.ieeeAddr}"`;
|
||||
for (const link of topology.links) {
|
||||
if (link.source.ieeeAddr === node.ieeeAddr) {
|
||||
const lineStyle = node.type === "EndDevice" ? "penwidth=1, " : !link.routes.length ? "penwidth=0.5, " : "penwidth=2, ";
|
||||
const lineWeight = !link.routes.length
|
||||
? `weight=0, color="${colors.line.inactive}", `
|
||||
: `weight=1, color="${colors.line.active}", `;
|
||||
const textRoutes = link.routes.map((r) => utils.toNetworkAddressHex(r.destinationAddress));
|
||||
const lineLabels = !link.routes.length
|
||||
? `label="${link.linkquality}"`
|
||||
: `label="${link.linkquality} (routes: ${textRoutes.join(",")})"`;
|
||||
text += ` "${node.ieeeAddr}" -> "${link.target.ieeeAddr}"`;
|
||||
text += ` [${lineStyle}${lineWeight}${lineLabels}]\n`;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
text += '}';
|
||||
text += "}";
|
||||
|
||||
return text.replace(/\0/g, '');
|
||||
return text.replace(/\0/g, "");
|
||||
}
|
||||
|
||||
plantuml(topology: Zigbee2MQTTNetworkMap): string {
|
||||
const text = [];
|
||||
|
||||
text.push(`' paste into: https://www.planttext.com/`);
|
||||
text.push(``);
|
||||
text.push('@startuml');
|
||||
text.push("");
|
||||
text.push("@startuml");
|
||||
|
||||
topology.nodes
|
||||
.sort((a, b) => a.friendlyName.localeCompare(b.friendlyName))
|
||||
.forEach((node) => {
|
||||
// Add friendly name
|
||||
text.push(`card ${node.ieeeAddr} [`);
|
||||
text.push(`${node.friendlyName}`);
|
||||
text.push(`---`);
|
||||
for (const node of topology.nodes.sort((a, b) => a.friendlyName.localeCompare(b.friendlyName))) {
|
||||
// Add friendly name
|
||||
text.push(`card ${node.ieeeAddr} [`);
|
||||
text.push(`${node.friendlyName}`);
|
||||
text.push("---");
|
||||
|
||||
// Add the device short network address, ieeaddr and scan note (if any)
|
||||
text.push(
|
||||
`${node.ieeeAddr} (${utils.toNetworkAddressHex(node.networkAddress)})` +
|
||||
(node.failed && node.failed.length ? ` failed: ${node.failed.join(',')}` : ''),
|
||||
);
|
||||
// Add the device short network address, ieeaddr and scan note (if any)
|
||||
text.push(
|
||||
`${node.ieeeAddr} (${utils.toNetworkAddressHex(node.networkAddress)})${node.failed?.length ? ` failed: ${node.failed.join(",")}` : ""}`,
|
||||
);
|
||||
|
||||
// Add the device model
|
||||
if (node.type !== 'Coordinator') {
|
||||
text.push(`---`);
|
||||
const definition = (this.zigbee.resolveEntity(node.ieeeAddr) as Device).definition;
|
||||
text.push(`${definition?.vendor} ${definition?.description} (${definition?.model})`);
|
||||
}
|
||||
// Add the device model
|
||||
if (node.type !== "Coordinator") {
|
||||
text.push("---");
|
||||
const definition = (this.zigbee.resolveEntity(node.ieeeAddr) as Device).definition;
|
||||
text.push(`${definition?.vendor} ${definition?.description} (${definition?.model})`);
|
||||
}
|
||||
|
||||
// Add the device last_seen timestamp
|
||||
let lastSeen = 'unknown';
|
||||
const date = node.type === 'Coordinator' ? Date.now() : node.lastSeen;
|
||||
if (date) {
|
||||
lastSeen = utils.formatDate(date, 'relative') as string;
|
||||
}
|
||||
text.push(`---`);
|
||||
text.push(lastSeen);
|
||||
text.push(`]`);
|
||||
text.push(``);
|
||||
});
|
||||
// Add the device last_seen timestamp
|
||||
let lastSeen = "unknown";
|
||||
const date = node.type === "Coordinator" ? Date.now() : node.lastSeen;
|
||||
if (date) {
|
||||
lastSeen = utils.formatDate(date, "relative") as string;
|
||||
}
|
||||
text.push("---");
|
||||
text.push(lastSeen);
|
||||
text.push("]");
|
||||
text.push("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Add edges between the devices
|
||||
* NOTE: There are situations where a device is NOT in the topology, this can be e.g.
|
||||
* due to not responded to the lqi scan. In that case we do not add an edge for this device.
|
||||
*/
|
||||
topology.links.forEach((link) => {
|
||||
for (const link of topology.links) {
|
||||
text.push(`${link.sourceIeeeAddr} --> ${link.targetIeeeAddr}: ${link.lqi}`);
|
||||
});
|
||||
text.push('');
|
||||
}
|
||||
|
||||
text.push(`@enduml`);
|
||||
text.push("");
|
||||
|
||||
return text.join(`\n`);
|
||||
text.push("@enduml");
|
||||
|
||||
return text.join("\n");
|
||||
}
|
||||
|
||||
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();
|
||||
const failed: Map<Device, string[]> = new Map();
|
||||
const lqis = new Map<Device, zh.LQI>();
|
||||
const routingTables = new Map<Device, zh.RoutingTable>();
|
||||
const failed = new Map<Device, string[]>();
|
||||
const requestWithRetry = async <T>(request: () => Promise<T>): Promise<T> => {
|
||||
try {
|
||||
const result = await request();
|
||||
@@ -199,12 +201,13 @@ export default class NetworkMap extends Extension {
|
||||
}
|
||||
};
|
||||
|
||||
for (const device of this.zigbee.devicesIterator((d) => d.type !== 'GreenPower' && d.type !== 'EndDevice')) {
|
||||
for (const device of this.zigbee.devicesIterator((d) => d.type !== "GreenPower" && d.type !== "EndDevice")) {
|
||||
if (device.options.disabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
failed.set(device, []);
|
||||
const deviceFailures: string[] = [];
|
||||
failed.set(device, deviceFailures);
|
||||
await utils.sleep(1); // sleep 1 second between each scan to reduce stress on network.
|
||||
|
||||
try {
|
||||
@@ -212,8 +215,9 @@ export default class NetworkMap extends Extension {
|
||||
lqis.set(device, result);
|
||||
logger.debug(`LQI succeeded for '${device.name}'`);
|
||||
} catch (error) {
|
||||
failed.get(device)!.push('lqi'); // set above
|
||||
deviceFailures.push("lqi"); // set above
|
||||
logger.error(`Failed to execute LQI for '${device.name}'`);
|
||||
// biome-ignore lint/style/noNonNullAssertion: always Error
|
||||
logger.debug((error as Error).stack!);
|
||||
}
|
||||
|
||||
@@ -223,19 +227,20 @@ export default class NetworkMap extends Extension {
|
||||
routingTables.set(device, result);
|
||||
logger.debug(`Routing table succeeded for '${device.name}'`);
|
||||
} catch (error) {
|
||||
failed.get(device)!.push('routingTable'); // set above
|
||||
deviceFailures.push("routingTable"); // set above
|
||||
logger.error(`Failed to execute routing table for '${device.name}'`);
|
||||
// biome-ignore lint/style/noNonNullAssertion: always Error
|
||||
logger.debug((error as Error).stack!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Network scan finished`);
|
||||
logger.info("Network scan finished");
|
||||
|
||||
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')) {
|
||||
for (const device of this.zigbee.devicesIterator((d) => d.type !== "GreenPower")) {
|
||||
if (device.options.disabled) {
|
||||
continue;
|
||||
}
|
||||
@@ -249,10 +254,10 @@ export default class NetworkMap extends Extension {
|
||||
supports: Array.from(
|
||||
new Set(
|
||||
device.exposes().map((e) => {
|
||||
return e.name ?? `${e.type} (${e.features?.map((f) => f.name).join(', ')})`;
|
||||
return e.name ?? `${e.type} (${e.features?.map((f) => f.name).join(", ")})`;
|
||||
}),
|
||||
),
|
||||
).join(', '),
|
||||
).join(", "),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
@@ -263,7 +268,7 @@ export default class NetworkMap extends Extension {
|
||||
networkAddress: device.zh.networkAddress,
|
||||
manufacturerName: device.zh.manufacturerName,
|
||||
modelID: device.zh.modelID,
|
||||
failed: failed.get(device)!,
|
||||
failed: failed.get(device),
|
||||
lastSeen: device.zh.lastSeen,
|
||||
definition,
|
||||
});
|
||||
@@ -279,7 +284,7 @@ export default class NetworkMap extends Extension {
|
||||
|
||||
// Some Xiaomi devices return 0x00 as the neighbor ieeeAddr (obviously not correct).
|
||||
// Determine the correct ieeeAddr based on the networkAddress.
|
||||
if (neighbor.ieeeAddr === '0x0000000000000000') {
|
||||
if (neighbor.ieeeAddr === "0x0000000000000000") {
|
||||
const neighborDevice = this.zigbee.deviceByNetworkAddress(neighbor.networkAddress);
|
||||
|
||||
if (neighborDevice) {
|
||||
@@ -287,7 +292,7 @@ export default class NetworkMap extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
const link: Zigbee2MQTTNetworkMap['links'][number] = {
|
||||
const link: Zigbee2MQTTNetworkMap["links"][number] = {
|
||||
source: {ieeeAddr: neighbor.ieeeAddr, networkAddress: neighbor.networkAddress},
|
||||
target: {ieeeAddr: device.ieeeAddr, networkAddress: device.zh.networkAddress},
|
||||
linkquality: neighbor.linkquality,
|
||||
@@ -305,7 +310,7 @@ export default class NetworkMap extends Extension {
|
||||
|
||||
if (routingTable) {
|
||||
for (const entry of routingTable.table) {
|
||||
if (entry.status === 'ACTIVE' && entry.nextHop === neighbor.networkAddress) {
|
||||
if (entry.status === "ACTIVE" && entry.nextHop === neighbor.networkAddress) {
|
||||
link.routes.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
+45
-24
@@ -1,49 +1,70 @@
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
import {onEvent} from "zigbee-herdsman-converters";
|
||||
|
||||
import utils from '../util/utils';
|
||||
import Extension from './extension';
|
||||
import utils from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
/**
|
||||
* This extension calls the zigbee-herdsman-converters onEvent.
|
||||
*/
|
||||
export default class OnEvent extends Extension {
|
||||
// biome-ignore lint/suspicious/useAwait: API
|
||||
override async start(): Promise<void> {
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
this.callOnEvent(device, 'start', {}).catch(utils.noop);
|
||||
// don't await, in case of repeated failures this would hold startup
|
||||
this.callOnEvent(device, "start", {}).catch(utils.noop);
|
||||
}
|
||||
|
||||
this.eventBus.onDeviceMessage(this, (data) => this.callOnEvent(data.device, 'message', this.convertData(data)));
|
||||
this.eventBus.onDeviceJoined(this, (data) => this.callOnEvent(data.device, 'deviceJoined', this.convertData(data)));
|
||||
this.eventBus.onDeviceInterview(this, (data) => this.callOnEvent(data.device, 'deviceInterview', this.convertData(data)));
|
||||
this.eventBus.onDeviceAnnounce(this, (data) => this.callOnEvent(data.device, 'deviceAnnounce', this.convertData(data)));
|
||||
this.eventBus.onDeviceNetworkAddressChanged(this, (data) =>
|
||||
this.callOnEvent(data.device, 'deviceNetworkAddressChanged', this.convertData(data)),
|
||||
);
|
||||
this.eventBus.onEntityOptionsChanged(this, async (data) => {
|
||||
if (data.entity.isDevice()) {
|
||||
await this.callOnEvent(data.entity, 'deviceOptionsChanged', data).then(() => this.eventBus.emitDevicesChanged());
|
||||
this.eventBus.onDeviceMessage(this, async (data) => {
|
||||
await this.callOnEvent(data.device, "message", {
|
||||
endpoint: data.endpoint,
|
||||
meta: data.meta,
|
||||
cluster: typeof data.cluster === "string" ? data.cluster : /* v8 ignore next */ undefined, // XXX: ZH typing is wrong?
|
||||
type: data.type,
|
||||
data: data.data, // XXX: typing is a bit convoluted: ZHC has `KeyValueAny` here while Z2M has `KeyValue | Array<string | number>`
|
||||
});
|
||||
});
|
||||
this.eventBus.onDeviceJoined(this, async (data) => {
|
||||
await this.callOnEvent(data.device, "deviceJoined", {});
|
||||
});
|
||||
this.eventBus.onDeviceLeave(this, async (data) => {
|
||||
if (data.device) {
|
||||
await this.callOnEvent(data.device, "stop", {});
|
||||
}
|
||||
});
|
||||
this.eventBus.onDeviceInterview(this, async (data) => {
|
||||
await this.callOnEvent(data.device, "deviceInterview", {});
|
||||
});
|
||||
this.eventBus.onDeviceAnnounce(this, async (data) => {
|
||||
await this.callOnEvent(data.device, "deviceAnnounce", {});
|
||||
});
|
||||
this.eventBus.onDeviceNetworkAddressChanged(this, async (data) => {
|
||||
await this.callOnEvent(data.device, "deviceNetworkAddressChanged", {});
|
||||
});
|
||||
this.eventBus.onEntityOptionsChanged(this, async (data) => {
|
||||
if (data.entity.isDevice()) {
|
||||
await this.callOnEvent(data.entity, "deviceOptionsChanged", {});
|
||||
this.eventBus.emitDevicesChanged();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private convertData(data: KeyValue): KeyValue {
|
||||
return {...data, device: data.device.zh};
|
||||
}
|
||||
|
||||
override async stop(): Promise<void> {
|
||||
await super.stop();
|
||||
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
await this.callOnEvent(device, 'stop', {});
|
||||
await this.callOnEvent(device, "stop", {});
|
||||
}
|
||||
}
|
||||
|
||||
private async callOnEvent(device: Device, type: zhc.OnEventType, data: KeyValue): Promise<void> {
|
||||
if (device.options.disabled) return;
|
||||
const state = this.state.get(device);
|
||||
const deviceExposesChanged = (): void => this.eventBus.emitExposesAndDevicesChanged(data.device);
|
||||
private async callOnEvent(device: Device, type: Parameters<typeof onEvent>[0], data: Parameters<typeof onEvent>[1]): Promise<void> {
|
||||
if (device.options.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
await zhc.onEvent(type, data, device.zh, {deviceExposesChanged});
|
||||
const state = this.state.get(device);
|
||||
const deviceExposesChanged = (): void => this.eventBus.emitExposesAndDevicesChanged(device);
|
||||
|
||||
await onEvent(type, data, device.zh, {deviceExposesChanged});
|
||||
|
||||
if (device.definition?.onEvent) {
|
||||
const options: KeyValue = device.options;
|
||||
|
||||
+276
-115
@@ -1,24 +1,26 @@
|
||||
import type {Zigbee2MQTTAPI} from 'lib/types/api';
|
||||
import type {Ota} from 'zigbee-herdsman-converters';
|
||||
import type {Ota} from "zigbee-herdsman-converters";
|
||||
|
||||
import assert from 'node:assert';
|
||||
import path from 'node:path';
|
||||
import type {Zigbee2MQTTAPI} from "../types/api";
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import assert from "node:assert";
|
||||
import path from "node:path";
|
||||
|
||||
import {Zcl} from 'zigbee-herdsman';
|
||||
import {ota} from 'zigbee-herdsman-converters';
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
|
||||
import Device from '../model/device';
|
||||
import dataDir from '../util/data';
|
||||
import logger from '../util/logger';
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import Extension from './extension';
|
||||
import {Zcl} from "zigbee-herdsman";
|
||||
import {ota} from "zigbee-herdsman-converters";
|
||||
|
||||
type UpdateState = 'updating' | 'idle' | 'available';
|
||||
interface UpdatePayload {
|
||||
import Device from "../model/device";
|
||||
import dataDir from "../util/data";
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import utils from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
type UpdateState = "updating" | "idle" | "available" | "scheduled";
|
||||
|
||||
export interface UpdatePayload {
|
||||
update: {
|
||||
progress?: number;
|
||||
remaining?: number;
|
||||
@@ -28,12 +30,18 @@ interface UpdatePayload {
|
||||
};
|
||||
}
|
||||
|
||||
const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/(update|check)/?(downgrade)?`, 'i');
|
||||
const topicRegex = new RegExp(
|
||||
`^${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/(update|check|schedule|unschedule)/?(downgrade)?`,
|
||||
"i",
|
||||
);
|
||||
|
||||
export default class OTAUpdate extends Extension {
|
||||
private inProgress = new Set();
|
||||
private lastChecked: {[s: string]: number} = {};
|
||||
private inProgress = new Set<string>();
|
||||
private lastChecked = new Map<string, number>();
|
||||
private scheduledUpgrades = new Set<string>();
|
||||
private scheduledDowngrades = new Set<string>();
|
||||
|
||||
// biome-ignore lint/suspicious/useAwait: API
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
this.eventBus.onDeviceMessage(this, this.onZigbeeEvent);
|
||||
@@ -61,8 +69,8 @@ export default class OTAUpdate extends Extension {
|
||||
this.removeProgressAndRemainingFromState(device);
|
||||
|
||||
// Reset update state, e.g. when Z2M restarted during update.
|
||||
if (this.state.get(device).update?.state === 'updating') {
|
||||
this.state.get(device).update.state = 'available';
|
||||
if (this.state.get(device).update?.state === "updating") {
|
||||
this.state.get(device).update.state = "available";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,60 +85,145 @@ export default class OTAUpdate extends Extension {
|
||||
}
|
||||
|
||||
@bind private async onZigbeeEvent(data: eventdata.DeviceMessage): Promise<void> {
|
||||
if (data.type !== 'commandQueryNextImageRequest' || !data.device.definition || this.inProgress.has(data.device.ieeeAddr)) return;
|
||||
if (data.type !== "commandQueryNextImageRequest" || !data.device.definition || this.inProgress.has(data.device.ieeeAddr)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// `commandQueryNextImageRequest` check above should ensures this is valid but...
|
||||
assert(
|
||||
data.meta.zclTransactionSequenceNumber !== undefined,
|
||||
"Missing 'queryNextImageRequest' transaction sequence number (cannot match reply)",
|
||||
);
|
||||
|
||||
logger.debug(`Device '${data.device.name}' requested OTA`);
|
||||
|
||||
const automaticOTACheckDisabled = settings.get().ota.disable_automatic_update_check;
|
||||
if (data.device.definition.ota) {
|
||||
if (this.scheduledUpgrades.has(data.device.ieeeAddr) || this.scheduledDowngrades.has(data.device.ieeeAddr)) {
|
||||
this.inProgress.add(data.device.ieeeAddr);
|
||||
|
||||
if (data.device.definition.ota && !automaticOTACheckDisabled) {
|
||||
// When a device does a next image request, it will usually do it a few times after each other
|
||||
// with only 10 - 60 seconds inbetween. It doesn't make sense to check for a new update
|
||||
// each time, so this interval can be set by the user. The default is 1,440 minutes (one day).
|
||||
const updateCheckInterval = settings.get().ota.update_check_interval * 1000 * 60;
|
||||
const check =
|
||||
this.lastChecked[data.device.ieeeAddr] !== undefined
|
||||
? Date.now() - this.lastChecked[data.device.ieeeAddr] > updateCheckInterval
|
||||
: true;
|
||||
if (!check) return;
|
||||
logger.info(`Updating '${data.device.name}' to latest firmware`);
|
||||
|
||||
this.lastChecked[data.device.ieeeAddr] = Date.now();
|
||||
let availableResult: Ota.UpdateAvailableResult | undefined;
|
||||
try {
|
||||
const fileVersion = await ota.update(
|
||||
data.device.zh,
|
||||
data.device.otaExtraMetas,
|
||||
this.scheduledDowngrades.has(data.device.ieeeAddr),
|
||||
async (progress, remaining) => {
|
||||
let msg = `Update of '${data.device.name}' at ${progress.toFixed(2)}%`;
|
||||
|
||||
try {
|
||||
// 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})`);
|
||||
if (remaining) {
|
||||
msg += `, ≈ ${Math.round(remaining / 60)} minutes remaining`;
|
||||
}
|
||||
|
||||
logger.info(msg);
|
||||
|
||||
await this.publishEntityState(
|
||||
data.device,
|
||||
this.getEntityPublishPayload(data.device, "updating", progress, remaining ?? undefined),
|
||||
);
|
||||
},
|
||||
data.data as Ota.ImageInfo,
|
||||
data.meta.zclTransactionSequenceNumber,
|
||||
);
|
||||
|
||||
// remove right away on update success or no image in case any of the below calls fail
|
||||
this.scheduledUpgrades.delete(data.device.ieeeAddr);
|
||||
this.scheduledDowngrades.delete(data.device.ieeeAddr);
|
||||
|
||||
if (fileVersion === undefined) {
|
||||
logger.info(`No image currently available for '${data.device.name}'. Unscheduling.`);
|
||||
|
||||
// XXX: superfluous?
|
||||
this.removeProgressAndRemainingFromState(data.device);
|
||||
await this.publishEntityState(data.device, this.getEntityPublishPayload(data.device, "idle"));
|
||||
this.inProgress.delete(data.device.ieeeAddr);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Finished update of '${data.device.name}'`);
|
||||
|
||||
this.removeProgressAndRemainingFromState(data.device);
|
||||
await this.publishEntityState(
|
||||
data.device,
|
||||
this.getEntityPublishPayload(data.device, {available: false, currentFileVersion: fileVersion, otaFileVersion: fileVersion}),
|
||||
);
|
||||
|
||||
const firmwareTo = await this.readSoftwareBuildIDAndDateCode(data.device);
|
||||
|
||||
logger.info(() => `Device '${data.device.name}' was updated 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)
|
||||
*/
|
||||
this.eventBus.emitReconfigure({device: data.device});
|
||||
this.eventBus.emitDevicesChanged();
|
||||
} catch (e) {
|
||||
logger.debug(`Update of '${data.device.name}' failed (${e}). Retry scheduled for next request.`);
|
||||
|
||||
this.removeProgressAndRemainingFromState(data.device);
|
||||
await this.publishEntityState(data.device, this.getEntityPublishPayload(data.device, "scheduled"));
|
||||
}
|
||||
|
||||
this.inProgress.delete(data.device.ieeeAddr);
|
||||
|
||||
return; // we're done
|
||||
}
|
||||
|
||||
await this.publishEntityState(data.device, this.getEntityPublishPayload(data.device, availableResult ?? 'idle'));
|
||||
if (!settings.get().ota.disable_automatic_update_check) {
|
||||
// When a device does a next image request, it will usually do it a few times after each other
|
||||
// with only 10 - 60 seconds inbetween. It doesn't make sense to check for a new update
|
||||
// each time, so this interval can be set by the user. The default is 1,440 minutes (one day).
|
||||
const updateCheckInterval = settings.get().ota.update_check_interval * 1000 * 60;
|
||||
const deviceLastChecked = this.lastChecked.get(data.device.ieeeAddr);
|
||||
const check = deviceLastChecked !== undefined ? Date.now() - deviceLastChecked > updateCheckInterval : true;
|
||||
|
||||
if (availableResult?.available) {
|
||||
const message = `Update available for '${data.device.name}'`;
|
||||
logger.info(message);
|
||||
if (!check) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.inProgress.add(data.device.ieeeAddr);
|
||||
this.lastChecked.set(data.device.ieeeAddr, Date.now());
|
||||
let availableResult: Ota.UpdateAvailableResult | undefined;
|
||||
|
||||
try {
|
||||
// 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})`);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Respond to stop the client from requesting OTAs
|
||||
const endpoint = data.device.zh.endpoints.find((e) => e.supportsOutputCluster('genOta')) || data.endpoint;
|
||||
const endpoint = data.device.zh.endpoints.find((e) => e.supportsOutputCluster("genOta")) || data.endpoint;
|
||||
await endpoint.commandResponse(
|
||||
'genOta',
|
||||
'queryNextImageResponse',
|
||||
"genOta",
|
||||
"queryNextImageResponse",
|
||||
{status: Zcl.Status.NO_IMAGE_AVAILABLE},
|
||||
undefined,
|
||||
data.meta.zclTransactionSequenceNumber,
|
||||
);
|
||||
logger.debug(`Responded to OTA request of '${data.device.name}' with 'NO_IMAGE_AVAILABLE'`);
|
||||
this.inProgress.delete(data.device.ieeeAddr);
|
||||
}
|
||||
|
||||
private async readSoftwareBuildIDAndDateCode(
|
||||
device: Device,
|
||||
sendPolicy?: 'immediate',
|
||||
sendPolicy?: "immediate",
|
||||
): Promise<{softwareBuildID: string; dateCode: string} | undefined> {
|
||||
try {
|
||||
const endpoint = device.zh.endpoints.find((e) => e.supportsInputCluster('genBasic'));
|
||||
const endpoint = device.zh.endpoints.find((e) => e.supportsInputCluster("genBasic"));
|
||||
assert(endpoint);
|
||||
const result = await endpoint.read('genBasic', ['dateCode', 'swBuildId'], {sendPolicy});
|
||||
const result = await endpoint.read("genBasic", ["dateCode", "swBuildId"], {sendPolicy});
|
||||
return {softwareBuildID: result.swBuildId, dateCode: result.dateCode};
|
||||
} catch {
|
||||
return undefined;
|
||||
@@ -146,9 +239,9 @@ export default class OTAUpdate extends Extension {
|
||||
const deviceUpdateState = this.state.get(device).update;
|
||||
const payload: UpdatePayload = {
|
||||
update: {
|
||||
state: typeof state === 'string' ? state : state.available ? 'available' : 'idle',
|
||||
installed_version: typeof state === 'string' ? deviceUpdateState?.installed_version : state.currentFileVersion,
|
||||
latest_version: typeof state === 'string' ? deviceUpdateState?.latest_version : state.otaFileVersion,
|
||||
state: typeof state === "string" ? state : state.available ? "available" : "idle",
|
||||
installed_version: typeof state === "string" ? deviceUpdateState?.installed_version : state.currentFileVersion,
|
||||
latest_version: typeof state === "string" ? deviceUpdateState?.latest_version : state.otaFileVersion,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -171,14 +264,17 @@ export default class OTAUpdate extends Extension {
|
||||
}
|
||||
|
||||
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;
|
||||
| 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"]
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/schedule"]
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/schedule/downgrade"]
|
||||
| Zigbee2MQTTAPI["bridge/request/device/ota_update/unschedule"];
|
||||
const ID = (typeof message === "object" && message.id !== undefined ? message.id : message) as string;
|
||||
const device = this.zigbee.resolveEntity(ID);
|
||||
const type = topicMatch[1];
|
||||
const downgrade = Boolean(topicMatch[2]);
|
||||
const type = topicMatch[1] as "check" | "update" | "schedule" | "unschedule";
|
||||
const downgrade = topicMatch[2] === "downgrade";
|
||||
let error: string | undefined;
|
||||
let errorStack: string | undefined;
|
||||
|
||||
@@ -187,83 +283,148 @@ export default class OTAUpdate extends Extension {
|
||||
} else if (!device.definition || !device.definition.ota) {
|
||||
error = `Device '${device.name}' does not support OTA updates`;
|
||||
} else if (this.inProgress.has(device.ieeeAddr)) {
|
||||
// also guards against scheduling while check/update op in progress that could result in undesired OTA state
|
||||
error = `Update or check for update already in progress for '${device.name}'`;
|
||||
} else {
|
||||
this.inProgress.add(device.ieeeAddr);
|
||||
switch (type) {
|
||||
case "check": {
|
||||
this.inProgress.add(device.ieeeAddr);
|
||||
|
||||
if (type === 'check') {
|
||||
const msg = `Checking if update available for '${device.name}'`;
|
||||
logger.info(msg);
|
||||
logger.info(`Checking if update available for '${device.name}'`);
|
||||
|
||||
try {
|
||||
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);
|
||||
try {
|
||||
const availableResult = await ota.isUpdateAvailable(device.zh, device.otaExtraMetas, undefined, downgrade);
|
||||
|
||||
await this.publishEntityState(device, this.getEntityPublishPayload(device, availableResult));
|
||||
logger.info(`${availableResult.available ? "Update" : "No update"} available for '${device.name}'`);
|
||||
|
||||
this.lastChecked[device.ieeeAddr] = Date.now();
|
||||
const response = utils.getResponse<'bridge/response/device/ota_update/check'>(message, {
|
||||
id: ID,
|
||||
update_available: availableResult.available,
|
||||
});
|
||||
await this.publishEntityState(device, this.getEntityPublishPayload(device, availableResult));
|
||||
this.lastChecked.set(device.ieeeAddr, Date.now());
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// type === 'update'
|
||||
const msg = `Updating '${device.name}' to ${downgrade ? 'previous' : 'latest'} firmware`;
|
||||
logger.info(msg);
|
||||
|
||||
try {
|
||||
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)}%`;
|
||||
case "update": {
|
||||
this.inProgress.add(device.ieeeAddr);
|
||||
|
||||
if (remaining) {
|
||||
msg += `, ≈ ${Math.round(remaining / 60)} minutes remaining`;
|
||||
if (this.scheduledUpgrades.delete(device.ieeeAddr)) {
|
||||
logger.info(`Previously scheduled '${device.name}' upgrade was cancelled by manual update`);
|
||||
} else if (this.scheduledDowngrades.delete(device.ieeeAddr)) {
|
||||
logger.info(`Previously scheduled '${device.name}' downgrade was cancelled by manual update`);
|
||||
}
|
||||
|
||||
logger.info(`Updating '${device.name}' to ${downgrade ? "previous" : "latest"} firmware`);
|
||||
|
||||
try {
|
||||
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);
|
||||
|
||||
await this.publishEntityState(device, this.getEntityPublishPayload(device, "updating", progress, remaining ?? undefined));
|
||||
});
|
||||
|
||||
if (fileVersion === undefined) {
|
||||
throw new Error("No image currently available");
|
||||
}
|
||||
|
||||
logger.info(msg);
|
||||
logger.info(`Finished update of '${device.name}'`);
|
||||
this.removeProgressAndRemainingFromState(device);
|
||||
await this.publishEntityState(
|
||||
device,
|
||||
this.getEntityPublishPayload(device, {available: false, currentFileVersion: fileVersion, otaFileVersion: fileVersion}),
|
||||
);
|
||||
|
||||
await this.publishEntityState(device, this.getEntityPublishPayload(device, 'updating', progress, remaining ?? undefined));
|
||||
});
|
||||
const firmwareTo = await this.readSoftwareBuildIDAndDateCode(device);
|
||||
|
||||
logger.info(`Finished update of '${device.name}'`);
|
||||
this.removeProgressAndRemainingFromState(device);
|
||||
await this.publishEntityState(
|
||||
device,
|
||||
this.getEntityPublishPayload(device, {available: false, currentFileVersion: fileVersion, otaFileVersion: fileVersion}),
|
||||
);
|
||||
logger.info(() => `Device '${device.name}' was updated from '${stringify(firmwareFrom)}' to '${stringify(firmwareTo)}'`);
|
||||
|
||||
const firmwareTo = await this.readSoftwareBuildIDAndDateCode(device);
|
||||
/**
|
||||
* Re-configure after reading software build ID and date code, some devices use a
|
||||
* custom attribute for this (e.g. Develco SMSZB-120)
|
||||
*/
|
||||
this.eventBus.emitReconfigure({device});
|
||||
this.eventBus.emitDevicesChanged();
|
||||
|
||||
logger.info(() => `Device '${device.name}' was updated from '${stringify(firmwareFrom)}' to '${stringify(firmwareTo)}'`);
|
||||
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,
|
||||
});
|
||||
|
||||
/**
|
||||
* Re-configure after reading software build ID and date code, some devices use a
|
||||
* custom attribute for this (e.g. Develco SMSZB-120)
|
||||
*/
|
||||
this.eventBus.emitReconfigure({device});
|
||||
this.eventBus.emitDevicesChanged();
|
||||
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;
|
||||
|
||||
const response = utils.getResponse<'bridge/response/device/ota_update/update'>(message, {
|
||||
this.removeProgressAndRemainingFromState(device);
|
||||
await this.publishEntityState(device, this.getEntityPublishPayload(device, "available"));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "schedule": {
|
||||
// ensure only one type scheduled by deleting from the other if necessary
|
||||
if (downgrade) {
|
||||
if (this.scheduledUpgrades.delete(device.ieeeAddr)) {
|
||||
logger.info(`Previously scheduled '${device.name}' upgrade was cancelled in favor of new downgrade request`);
|
||||
}
|
||||
|
||||
this.scheduledDowngrades.add(device.ieeeAddr);
|
||||
} else {
|
||||
if (this.scheduledDowngrades.delete(device.ieeeAddr)) {
|
||||
logger.info(`Previously scheduled '${device.name}' downgrade was cancelled in favor of new upgrade request`);
|
||||
}
|
||||
|
||||
this.scheduledUpgrades.add(device.ieeeAddr);
|
||||
}
|
||||
|
||||
logger.info(`Scheduled '${device.name}' to ${downgrade ? "downgrade" : "upgrade"} firmware on next request from device`);
|
||||
|
||||
await this.publishEntityState(device, this.getEntityPublishPayload(device, "scheduled", undefined, undefined));
|
||||
|
||||
const response = utils.getResponse<"bridge/response/device/ota_update/schedule">(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;
|
||||
await this.mqtt.publish("bridge/response/device/ota_update/schedule", stringify(response));
|
||||
|
||||
this.removeProgressAndRemainingFromState(device);
|
||||
await this.publishEntityState(device, this.getEntityPublishPayload(device, 'available'));
|
||||
break;
|
||||
}
|
||||
|
||||
case "unschedule": {
|
||||
if (this.scheduledUpgrades.delete(device.ieeeAddr)) {
|
||||
logger.info(`Previously scheduled '${device.name}' upgrade was cancelled`);
|
||||
} else if (this.scheduledDowngrades.delete(device.ieeeAddr)) {
|
||||
logger.info(`Previously scheduled '${device.name}' downgrade was cancelled`);
|
||||
}
|
||||
|
||||
await this.publishEntityState(device, this.getEntityPublishPayload(device, "idle", undefined, undefined));
|
||||
|
||||
const response = utils.getResponse<"bridge/response/device/ota_update/unschedule">(message, {
|
||||
id: ID,
|
||||
});
|
||||
|
||||
await this.mqtt.publish("bridge/response/device/ota_update/unschedule", stringify(response));
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+38
-40
@@ -1,14 +1,14 @@
|
||||
import type * as zhc from 'zigbee-herdsman-converters';
|
||||
import type * as zhc from "zigbee-herdsman-converters";
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
|
||||
import Device from '../model/device';
|
||||
import Group from '../model/group';
|
||||
import logger from '../util/logger';
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import Extension from './extension';
|
||||
import Device from "../model/device";
|
||||
import Group from "../model/group";
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import utils from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
let topicGetSetRegex: RegExp;
|
||||
// Used by `publish.test.js` to reload regex when changing `mqtt.base_topic`.
|
||||
@@ -17,24 +17,25 @@ export const loadTopicGetSetRegex = (): void => {
|
||||
};
|
||||
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'];
|
||||
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"];
|
||||
|
||||
interface ParsedTopic {
|
||||
ID: string;
|
||||
endpoint: string | undefined;
|
||||
attribute: string;
|
||||
type: 'get' | 'set';
|
||||
type: "get" | "set";
|
||||
}
|
||||
|
||||
export default class Publish extends Extension {
|
||||
// biome-ignore lint/suspicious/useAwait: API
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
}
|
||||
|
||||
parseTopic(topic: string): ParsedTopic | undefined {
|
||||
// The function supports the following topic formats (below are for 'set'. 'get' will look the same):
|
||||
// - <base_topic>/device_name/set (endpoint and attribute is defined in the payload)
|
||||
// - <base_topic>/device_name/set (auto-matches endpoint and attribute is defined in the payload)
|
||||
// - <base_topic>/device_name/set/attribute (default endpoint used)
|
||||
// - <base_topic>/device_name/endpoint/set (attribute is defined in the payload)
|
||||
// - <base_topic>/device_name/endpoint/set/attribute (payload is the value)
|
||||
@@ -53,7 +54,7 @@ export default class Publish extends Extension {
|
||||
|
||||
// Now parse the device/group name, and endpoint name
|
||||
const entity = this.zigbee.resolveEntityAndEndpoint(deviceNameAndEndpoint);
|
||||
return {ID: entity.ID, endpoint: entity.endpointID, type: match[2] as 'get' | 'set', attribute: attribute};
|
||||
return {ID: entity.ID, endpoint: entity.endpointID, type: match[2] as "get" | "set", attribute: attribute};
|
||||
}
|
||||
|
||||
parseMessage(parsedTopic: ParsedTopic, data: eventdata.MQTTMessage): KeyValue | undefined {
|
||||
@@ -67,11 +68,7 @@ export default class Publish extends Extension {
|
||||
try {
|
||||
return JSON.parse(data.message);
|
||||
} catch {
|
||||
if (STATE_VALUES.includes(data.message.toLowerCase())) {
|
||||
return {state: data.message};
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
return STATE_VALUES.includes(data.message.toLowerCase()) ? {state: data.message} : undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,10 +83,9 @@ export default class Publish extends Extension {
|
||||
const hasColorTemp = message.color_temp !== undefined;
|
||||
const hasColor = message.color !== undefined;
|
||||
const hasBrightness = message.brightness !== undefined;
|
||||
const isOn = entityState.state === 'ON' ? true : false;
|
||||
if (isOn && (hasColorTemp || hasColor) && !hasBrightness) {
|
||||
if (entityState.state === "ON" && (hasColorTemp || hasColor) && !hasBrightness) {
|
||||
delete message.state;
|
||||
logger.debug('Skipping state because of Home Assistant');
|
||||
logger.debug("Skipping state because of Home Assistant");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,9 +135,8 @@ export default class Publish extends Extension {
|
||||
const entityState = this.state.get(re);
|
||||
const membersState =
|
||||
re instanceof Group
|
||||
? Object.fromEntries(
|
||||
re.zh.members.map((e) => [e.getDevice().ieeeAddr, this.state.get(this.zigbee.resolveEntity(e.getDevice().ieeeAddr)!)]),
|
||||
)
|
||||
? // biome-ignore lint/style/noNonNullAssertion: TODO: biome migration: might be a bit much assumed here?
|
||||
Object.fromEntries(re.zh.members.map((e) => [e.deviceIeeeAddress, this.state.get(this.zigbee.resolveEntity(e.deviceIeeeAddress)!)]))
|
||||
: undefined;
|
||||
const converters = this.getDefinitionConverters(definition);
|
||||
|
||||
@@ -157,8 +152,8 @@ export default class Publish extends Extension {
|
||||
* bulb off => move state & brightness to the front
|
||||
*/
|
||||
const entries = Object.entries(message);
|
||||
const sorter = typeof message.state === 'string' && message.state.toLowerCase() === 'off' ? 1 : -1;
|
||||
entries.sort((a) => (['state', 'brightness', 'brightness_percent'].includes(a[0]) ? sorter : sorter * -1));
|
||||
const sorter = typeof message.state === "string" && message.state.toLowerCase() === "off" ? 1 : -1;
|
||||
entries.sort((a) => (["state", "brightness", "brightness_percent"].includes(a[0]) ? sorter : sorter * -1));
|
||||
|
||||
// For each attribute call the corresponding converter
|
||||
const usedConverters: {[s: number]: zhc.Tz.Converter[]} = {};
|
||||
@@ -176,7 +171,7 @@ export default class Publish extends Extension {
|
||||
};
|
||||
|
||||
const endpointNames = re instanceof Device ? re.getEndpointNames() : [];
|
||||
const propertyEndpointRegex = new RegExp(`^(.*?)_(${endpointNames.join('|')})$`);
|
||||
const propertyEndpointRegex = new RegExp(`^(.*?)_(${endpointNames.join("|")})$`);
|
||||
let scenesChanged = false;
|
||||
|
||||
for (const entry of entries) {
|
||||
@@ -192,8 +187,7 @@ export default class Publish extends Extension {
|
||||
if (re instanceof Device && propertyEndpointMatch) {
|
||||
endpointName = propertyEndpointMatch[2];
|
||||
key = propertyEndpointMatch[1];
|
||||
// endpointName is always matched to an existing endpoint of the device
|
||||
// since `propertyEndpointRegex` only contains valid endpoints for this device.
|
||||
// biome-ignore lint/style/noNonNullAssertion: endpointName is always matched to an existing endpoint of the device since `propertyEndpointRegex` only contains valid endpoints for this device
|
||||
localTarget = re.endpoint(endpointName)!;
|
||||
endpointOrGroupID = localTarget.ID;
|
||||
}
|
||||
@@ -205,19 +199,19 @@ export default class Publish extends Extension {
|
||||
(!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)) {
|
||||
if (parsedTopic.type === "set" && converter && usedConverters[endpointOrGroupID].includes(converter)) {
|
||||
// Use a converter for set only once
|
||||
// (e.g. light_onoff_brightness converters can convert state and brightness)
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!converter) {
|
||||
logger.error(`No converter available for '${key}' (${stringify(message[key])})`);
|
||||
logger.error(`No converter available for '${key}' on '${re.name}': (${stringify(message[key])})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If the endpoint_name name is a number, try to map it to a friendlyName
|
||||
if (!isNaN(Number(endpointName)) && re.isDevice() && utils.isZHEndpoint(localTarget) && re.endpointName(localTarget)) {
|
||||
if (!Number.isNaN(Number(endpointName)) && re.isDevice() && utils.isZHEndpoint(localTarget) && re.endpointName(localTarget)) {
|
||||
endpointName = re.endpointName(localTarget);
|
||||
}
|
||||
|
||||
@@ -231,6 +225,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.
|
||||
@@ -245,12 +241,12 @@ export default class Publish extends Extension {
|
||||
}
|
||||
|
||||
try {
|
||||
if (parsedTopic.type === 'set' && converter.convertSet) {
|
||||
if (parsedTopic.type === "set" && converter.convertSet) {
|
||||
logger.debug(`Publishing '${parsedTopic.type}' '${key}' to '${re.name}'`);
|
||||
const result = await converter.convertSet(localTarget, key, value, meta);
|
||||
const optimistic = entitySettings.optimistic === undefined || entitySettings.optimistic;
|
||||
|
||||
if (result && result.state && optimistic) {
|
||||
if (result?.state && optimistic) {
|
||||
const msg = result.state;
|
||||
|
||||
if (endpointName) {
|
||||
@@ -266,12 +262,13 @@ export default class Publish extends Extension {
|
||||
addToToPublish(re, msg);
|
||||
}
|
||||
|
||||
if (result && result.membersState && optimistic) {
|
||||
if (result?.membersState && optimistic) {
|
||||
for (const [ieeeAddr, state] of Object.entries(result.membersState)) {
|
||||
// biome-ignore lint/style/noNonNullAssertion: might be a bit much assumed here?
|
||||
addToToPublish(this.zigbee.resolveEntity(ieeeAddr)!, state);
|
||||
}
|
||||
}
|
||||
} else if (parsedTopic.type === 'get' && converter.convertGet) {
|
||||
} else if (parsedTopic.type === "get" && converter.convertGet) {
|
||||
logger.debug(`Publishing get '${parsedTopic.type}' '${key}' to '${re.name}'`);
|
||||
await converter.convertGet(localTarget, key, meta);
|
||||
} else {
|
||||
@@ -281,6 +278,7 @@ export default class Publish extends Extension {
|
||||
} catch (error) {
|
||||
const message = `Publish '${parsedTopic.type}' '${key}' to '${re.name}' failed: '${error}'`;
|
||||
logger.error(message);
|
||||
// biome-ignore lint/style/noNonNullAssertion: always Error
|
||||
logger.debug((error as Error).stack!);
|
||||
}
|
||||
|
||||
@@ -304,9 +302,9 @@ export default class Publish extends Extension {
|
||||
|
||||
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;
|
||||
return definition.length ? Array.from(new Set(definition.flatMap((d) => d.toZigbee))) : [];
|
||||
}
|
||||
|
||||
return definition?.toZigbee;
|
||||
}
|
||||
}
|
||||
|
||||
+31
-29
@@ -1,30 +1,32 @@
|
||||
import assert from 'node:assert';
|
||||
import assert from "node:assert";
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import debounce from 'debounce';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import throttle from 'throttleit';
|
||||
import bind from "bind-decorator";
|
||||
import debounce from "debounce";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import throttle from "throttleit";
|
||||
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
import * 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';
|
||||
import logger from "../util/logger";
|
||||
import * as settings from "../util/settings";
|
||||
import utils from "../util/utils";
|
||||
import Extension from "./extension";
|
||||
|
||||
type DebounceFunction = (() => void) & {clear(): void} & {flush(): void};
|
||||
|
||||
export default class Receive extends Extension {
|
||||
// TODO: move all to `Map`
|
||||
private elapsed: {[s: string]: number} = {};
|
||||
private debouncers: {[s: string]: {payload: KeyValue; publish: DebounceFunction}} = {};
|
||||
private throttlers: {[s: string]: {publish: PublishEntityState}} = {};
|
||||
|
||||
// biome-ignore lint/suspicious/useAwait: API
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onPublishEntityState(this, this.onPublishEntityState);
|
||||
this.eventBus.onDeviceMessage(this, this.onDeviceMessage);
|
||||
}
|
||||
|
||||
@bind async onPublishEntityState(data: eventdata.PublishEntityState): Promise<void> {
|
||||
@bind onPublishEntityState(data: eventdata.PublishEntityState): void {
|
||||
/**
|
||||
* Prevent that outdated properties are being published.
|
||||
* In case that e.g. the state is currently held back by a debounce and a new state is published
|
||||
@@ -33,8 +35,8 @@ export default class Receive extends Extension {
|
||||
if (
|
||||
data.entity.isDevice() &&
|
||||
this.debouncers[data.entity.ieeeAddr] &&
|
||||
data.stateChangeReason !== 'publishDebounce' &&
|
||||
data.stateChangeReason !== 'lastSeenChanged'
|
||||
data.stateChangeReason !== "publishDebounce" &&
|
||||
data.stateChangeReason !== "lastSeenChanged"
|
||||
) {
|
||||
for (const key of Object.keys(data.payload)) {
|
||||
delete this.debouncers[data.entity.ieeeAddr].payload[key];
|
||||
@@ -47,7 +49,7 @@ export default class Receive extends Extension {
|
||||
this.debouncers[device.ieeeAddr] = {
|
||||
payload: {},
|
||||
publish: debounce(async () => {
|
||||
await this.publishEntityState(device, this.debouncers[device.ieeeAddr].payload, 'publishDebounce');
|
||||
await this.publishEntityState(device, this.debouncers[device.ieeeAddr].payload, "publishDebounce");
|
||||
this.debouncers[device.ieeeAddr].payload = {};
|
||||
}, time * 1000),
|
||||
};
|
||||
@@ -81,7 +83,7 @@ export default class Receive extends Extension {
|
||||
// By updating cache we make sure that state cache is always up-to-date.
|
||||
this.state.set(device, payload);
|
||||
|
||||
await this.throttlers[device.ieeeAddr].publish(device, payload, 'publishThrottle');
|
||||
await this.throttlers[device.ieeeAddr].publish(device, payload, "publishThrottle");
|
||||
}
|
||||
|
||||
// if debounce_ignore are specified (Array of strings)
|
||||
@@ -90,13 +92,12 @@ export default class Receive extends Extension {
|
||||
// otherwise payload is conflicted
|
||||
isPayloadConflicted(newPayload: KeyValue, oldPayload: KeyValue, debounceIgnore: string[] | undefined): boolean {
|
||||
let result = false;
|
||||
Object.keys(oldPayload)
|
||||
.filter((key) => (debounceIgnore || []).includes(key))
|
||||
.forEach((key) => {
|
||||
if (typeof newPayload[key] !== 'undefined' && newPayload[key] !== oldPayload[key]) {
|
||||
result = true;
|
||||
}
|
||||
});
|
||||
|
||||
for (const key in oldPayload) {
|
||||
if (debounceIgnore?.includes(key) && typeof newPayload[key] !== "undefined" && newPayload[key] !== oldPayload[key]) {
|
||||
result = true;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -105,9 +106,9 @@ export default class Receive extends Extension {
|
||||
/* v8 ignore next */
|
||||
if (!data.device) return;
|
||||
|
||||
if (!data.device.definition || data.device.zh.interviewing) {
|
||||
logger.debug(`Skipping message, still interviewing`);
|
||||
await utils.publishLastSeen({device: data.device, reason: 'messageEmitted'}, settings.get(), true, this.publishEntityState);
|
||||
if (!data.device.definition || !data.device.interviewed) {
|
||||
logger.debug("Skipping message, still interviewing");
|
||||
await utils.publishLastSeen({device: data.device, reason: "messageEmitted"}, settings.get(), true, this.publishEntityState);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -117,13 +118,13 @@ export default class Receive extends Extension {
|
||||
});
|
||||
|
||||
// Check if there is an available converter, genOta messages are not interesting.
|
||||
const ignoreClusters: (string | number)[] = ['genOta', 'genTime', 'genBasic', 'genPollCtrl'];
|
||||
if (converters.length == 0 && !ignoreClusters.includes(data.cluster)) {
|
||||
const ignoreClusters: (string | number)[] = ["genOta", "genTime", "genBasic", "genPollCtrl"];
|
||||
if (converters.length === 0 && !ignoreClusters.includes(data.cluster)) {
|
||||
logger.debug(
|
||||
`No converter available for '${data.device.definition.model}' with ` +
|
||||
`cluster '${data.cluster}' and type '${data.type}' and data '${stringify(data.data)}'`,
|
||||
);
|
||||
await utils.publishLastSeen({device: data.device, reason: 'messageEmitted'}, settings.get(), true, this.publishEntityState);
|
||||
await utils.publishLastSeen({device: data.device, reason: "messageEmitted"}, settings.get(), true, this.publishEntityState);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -174,6 +175,7 @@ export default class Receive extends Extension {
|
||||
/* v8 ignore start */
|
||||
} catch (error) {
|
||||
logger.error(`Exception while calling fromZigbee converter: ${(error as Error).message}}`);
|
||||
// biome-ignore lint/style/noNonNullAssertion: always Error
|
||||
logger.debug((error as Error).stack!);
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
@@ -182,7 +184,7 @@ export default class Receive extends Extension {
|
||||
if (!utils.objectIsEmpty(payload)) {
|
||||
await publish(payload);
|
||||
} else {
|
||||
await utils.publishLastSeen({device: data.device, reason: 'messageEmitted'}, settings.get(), true, this.publishEntityState);
|
||||
await utils.publishLastSeen({device: data.device, reason: "messageEmitted"}, settings.get(), true, this.publishEntityState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+31
-25
@@ -1,17 +1,19 @@
|
||||
import assert from 'node:assert';
|
||||
import type {CustomClusters} from "zigbee-herdsman/dist/zspec/zcl/definition/tstype";
|
||||
|
||||
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 assert from "node:assert";
|
||||
|
||||
import * as settings from '../util/settings';
|
||||
import * as zhc from "zigbee-herdsman-converters";
|
||||
import {Numeric, access} from "zigbee-herdsman-converters";
|
||||
import {InterviewState} from "zigbee-herdsman/dist/controller/model/device";
|
||||
|
||||
const LINKQUALITY = new Numeric('linkquality', access.STATE)
|
||||
.withUnit('lqi')
|
||||
.withDescription('Link quality (signal strength)')
|
||||
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');
|
||||
.withCategory("diagnostic");
|
||||
|
||||
export default class Device {
|
||||
public zh: zh.Device;
|
||||
@@ -21,6 +23,7 @@ export default class Device {
|
||||
get ieeeAddr(): string {
|
||||
return this.zh.ieeeAddr;
|
||||
}
|
||||
// biome-ignore lint/style/useNamingConvention: API
|
||||
get ID(): string {
|
||||
return this.zh.ieeeAddr;
|
||||
}
|
||||
@@ -29,16 +32,19 @@ export default class Device {
|
||||
return {...settings.get().device_options, ...deviceOptions};
|
||||
}
|
||||
get name(): string {
|
||||
return this.zh.type === 'Coordinator' ? 'Coordinator' : this.options?.friendly_name;
|
||||
return this.zh.type === "Coordinator" ? "Coordinator" : this.options?.friendly_name;
|
||||
}
|
||||
get isSupported(): boolean {
|
||||
return this.zh.type === 'Coordinator' || Boolean(this.definition && !this.definition.generated);
|
||||
return this.zh.type === "Coordinator" || Boolean(this.definition && !this.definition.generated);
|
||||
}
|
||||
get customClusters(): CustomClusters {
|
||||
return this.zh.customClusters;
|
||||
}
|
||||
get otaExtraMetas(): zhc.Ota.ExtraMetas {
|
||||
return typeof this.definition?.ota === 'object' ? this.definition.ota : {};
|
||||
return typeof this.definition?.ota === "object" ? this.definition.ota : {};
|
||||
}
|
||||
get interviewed(): boolean {
|
||||
return this.zh.interviewState === InterviewState.Successful || this.zh.interviewState === InterviewState.Failed;
|
||||
}
|
||||
|
||||
constructor(device: zh.Device) {
|
||||
@@ -47,8 +53,8 @@ export default class Device {
|
||||
|
||||
exposes(): zhc.Expose[] {
|
||||
const exposes: zhc.Expose[] = [];
|
||||
assert(this.definition, 'Cannot retreive exposes before definition is resolved');
|
||||
if (typeof this.definition.exposes == 'function') {
|
||||
assert(this.definition, "Cannot retreive exposes before definition is resolved");
|
||||
if (typeof this.definition.exposes === "function") {
|
||||
const options: KeyValue = this.options;
|
||||
exposes.push(...this.definition.exposes(this.zh, options));
|
||||
} else {
|
||||
@@ -58,15 +64,15 @@ export default class Device {
|
||||
return exposes;
|
||||
}
|
||||
|
||||
async resolveDefinition(ignoreCache: boolean = false): Promise<void> {
|
||||
if (!this.zh.interviewing && (!this.definition || this._definitionModelID !== this.zh.modelID || ignoreCache)) {
|
||||
async resolveDefinition(ignoreCache = false): Promise<void> {
|
||||
if (this.interviewed && (!this.definition || this._definitionModelID !== this.zh.modelID || ignoreCache)) {
|
||||
this.definition = await zhc.findByDevice(this.zh, true);
|
||||
this._definitionModelID = this.zh.modelID;
|
||||
}
|
||||
}
|
||||
|
||||
ensureInSettings(): void {
|
||||
if (this.zh.type !== 'Coordinator' && !settings.getDevice(this.zh.ieeeAddr)) {
|
||||
if (this.zh.type !== "Coordinator" && !settings.getDevice(this.zh.ieeeAddr)) {
|
||||
settings.addDevice(this.zh.ieeeAddr);
|
||||
}
|
||||
}
|
||||
@@ -74,24 +80,24 @@ export default class Device {
|
||||
endpoint(key?: string | number): zh.Endpoint | undefined {
|
||||
let endpoint: zh.Endpoint | undefined;
|
||||
|
||||
if (key == null || key == '') {
|
||||
key = 'default';
|
||||
if (!key) {
|
||||
key = "default";
|
||||
}
|
||||
|
||||
if (!isNaN(Number(key))) {
|
||||
if (!Number.isNaN(Number(key))) {
|
||||
endpoint = this.zh.getEndpoint(Number(key));
|
||||
} else if (this.definition?.endpoint) {
|
||||
const ID = this.definition?.endpoint?.(this.zh)[key];
|
||||
|
||||
if (ID) {
|
||||
endpoint = this.zh.getEndpoint(ID);
|
||||
} else if (key === 'default') {
|
||||
} else if (key === "default") {
|
||||
endpoint = this.zh.endpoints[0];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
if (key !== 'default') {
|
||||
if (key !== "default") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -107,21 +113,21 @@ export default class Device {
|
||||
if (this.definition?.endpoint) {
|
||||
const mapping = this.definition?.endpoint(this.zh);
|
||||
for (const [name, id] of Object.entries(mapping)) {
|
||||
if (id == endpoint.ID) {
|
||||
if (id === endpoint.ID) {
|
||||
epName = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* v8 ignore next */
|
||||
return epName === 'default' ? undefined : epName;
|
||||
return epName === "default" ? undefined : epName;
|
||||
}
|
||||
|
||||
getEndpointNames(): string[] {
|
||||
const names: string[] = [];
|
||||
|
||||
for (const name in this.definition?.endpoint?.(this.zh) ?? {}) {
|
||||
if (name !== 'default') {
|
||||
if (name !== "default") {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
+12
-5
@@ -1,16 +1,17 @@
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
import type * as zhc from "zigbee-herdsman-converters";
|
||||
|
||||
import * as settings from '../util/settings';
|
||||
import * as settings from "../util/settings";
|
||||
|
||||
export default class Group {
|
||||
public zh: zh.Group;
|
||||
private resolveDevice: (ieeeAddr: string) => Device | undefined;
|
||||
|
||||
// biome-ignore lint/style/useNamingConvention: API
|
||||
get ID(): number {
|
||||
return this.zh.groupID;
|
||||
}
|
||||
get options(): GroupOptions {
|
||||
// XXX: Group always exists in settings
|
||||
// biome-ignore lint/style/noNonNullAssertion: Group always exists in settings
|
||||
return {...settings.getGroup(this.ID)!};
|
||||
}
|
||||
get name(): string {
|
||||
@@ -26,8 +27,14 @@ export default class Group {
|
||||
return !!device.zh.endpoints.find((e) => this.zh.members.includes(e));
|
||||
}
|
||||
|
||||
membersDevices(): Device[] {
|
||||
return this.zh.members.map((d) => this.resolveDevice(d.getDevice().ieeeAddr)!);
|
||||
*membersDevices(): Generator<Device> {
|
||||
for (const member of this.zh.members) {
|
||||
const resolvedDevice = this.resolveDevice(member.deviceIeeeAddress);
|
||||
|
||||
if (resolvedDevice) {
|
||||
yield resolvedDevice;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
membersDefinitions(): zhc.Definition[] {
|
||||
|
||||
+65
-53
@@ -1,30 +1,44 @@
|
||||
import type {IClientOptions, IClientPublishOptions, MqttClient} from 'mqtt';
|
||||
import type {IClientOptions, IClientPublishOptions, MqttClient} from "mqtt";
|
||||
|
||||
import type {Zigbee2MQTTAPI} from './types/api';
|
||||
import type {Zigbee2MQTTAPI} from "./types/api";
|
||||
|
||||
import fs from 'node:fs';
|
||||
import fs from "node:fs";
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import {connectAsync} from 'mqtt';
|
||||
import bind from "bind-decorator";
|
||||
import {connectAsync} from "mqtt";
|
||||
|
||||
import logger from './util/logger';
|
||||
import * as settings from './util/settings';
|
||||
import utils from './util/utils';
|
||||
import logger from "./util/logger";
|
||||
import * as settings from "./util/settings";
|
||||
import utils from "./util/utils";
|
||||
|
||||
const NS = 'z2m:mqtt';
|
||||
const NS = "z2m:mqtt";
|
||||
|
||||
export default class MQTT {
|
||||
private publishedTopics: Set<string> = new Set();
|
||||
export interface MqttPublishOptions {
|
||||
clientOptions: IClientPublishOptions;
|
||||
baseTopic: string;
|
||||
skipLog: boolean;
|
||||
skipReceive: boolean;
|
||||
meta: {isEntityState?: boolean};
|
||||
}
|
||||
|
||||
export default class Mqtt {
|
||||
private publishedTopics = new Set<string>();
|
||||
private connectionTimer?: NodeJS.Timeout;
|
||||
private client!: MqttClient;
|
||||
private eventBus: EventBus;
|
||||
private republishRetainedTimer?: NodeJS.Timeout;
|
||||
public retainedMessages: {
|
||||
[s: string]: {payload: string; options: IClientPublishOptions; skipLog: boolean; skipReceive: boolean; topic: string; base: string};
|
||||
} = {};
|
||||
private defaultPublishOptions: MqttPublishOptions;
|
||||
public retainedMessages: {[s: string]: {topic: string; payload: string; options: MqttPublishOptions}} = {};
|
||||
|
||||
constructor(eventBus: EventBus) {
|
||||
this.eventBus = eventBus;
|
||||
this.defaultPublishOptions = {
|
||||
clientOptions: {},
|
||||
baseTopic: settings.get().mqtt.base_topic,
|
||||
skipLog: false,
|
||||
skipReceive: true,
|
||||
meta: {},
|
||||
};
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
@@ -35,8 +49,8 @@ export default class MQTT {
|
||||
const options: IClientOptions = {
|
||||
will: {
|
||||
topic: `${settings.get().mqtt.base_topic}/bridge/state`,
|
||||
payload: Buffer.from(JSON.stringify({state: 'offline'})),
|
||||
retain: settings.get().mqtt.force_disable_retain ? false : true,
|
||||
payload: Buffer.from(JSON.stringify({state: "offline"})),
|
||||
retain: !settings.get().mqtt.force_disable_retain,
|
||||
qos: 1,
|
||||
},
|
||||
properties: {maximumPacketSize: mqttSettings.maximum_packet_size},
|
||||
@@ -71,7 +85,7 @@ export default class MQTT {
|
||||
logger.debug(`Using MQTT login with username only: ${mqttSettings.user}`);
|
||||
options.username = mqttSettings.user;
|
||||
} else {
|
||||
logger.debug(`Using MQTT anonymous login`);
|
||||
logger.debug("Using MQTT anonymous login");
|
||||
}
|
||||
|
||||
if (mqttSettings.client_id) {
|
||||
@@ -80,7 +94,7 @@ export default class MQTT {
|
||||
}
|
||||
|
||||
if (mqttSettings.reject_unauthorized !== undefined && !mqttSettings.reject_unauthorized) {
|
||||
logger.debug(`MQTT reject_unauthorized set false, ignoring certificate warnings.`);
|
||||
logger.debug("MQTT reject_unauthorized set false, ignoring certificate warnings.");
|
||||
options.rejectUnauthorized = false;
|
||||
}
|
||||
|
||||
@@ -89,34 +103,34 @@ export default class MQTT {
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/9822
|
||||
this.client.stream.setMaxListeners(0);
|
||||
|
||||
this.client.on('error', (err) => {
|
||||
this.client.on("error", (err) => {
|
||||
logger.error(`MQTT error: ${err.message}`);
|
||||
});
|
||||
|
||||
if (mqttSettings.version != undefined && mqttSettings.version >= 5) {
|
||||
this.client.on('disconnect', (packet) => {
|
||||
if (mqttSettings.version != null && mqttSettings.version >= 5) {
|
||||
this.client.on("disconnect", (packet) => {
|
||||
logger.error(`MQTT disconnect: reason ${packet.reasonCode} (${packet.properties?.reasonString})`);
|
||||
});
|
||||
}
|
||||
|
||||
this.client.on('message', this.onMessage);
|
||||
this.client.on("message", this.onMessage);
|
||||
|
||||
await this.onConnect();
|
||||
|
||||
this.client.on('connect', 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);
|
||||
await this.publish(msg.topic, msg.payload, msg.options);
|
||||
}
|
||||
}, 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!');
|
||||
logger.error("Not connected to MQTT server!");
|
||||
}
|
||||
}, utils.seconds(10));
|
||||
}
|
||||
@@ -125,11 +139,11 @@ export default class MQTT {
|
||||
clearTimeout(this.connectionTimer);
|
||||
clearTimeout(this.republishRetainedTimer);
|
||||
|
||||
const stateData: Zigbee2MQTTAPI['bridge/state'] = {state: 'offline'};
|
||||
const stateData: Zigbee2MQTTAPI["bridge/state"] = {state: "offline"};
|
||||
|
||||
await this.publish('bridge/state', JSON.stringify(stateData), {retain: true, qos: 0});
|
||||
await this.publish("bridge/state", JSON.stringify(stateData), {clientOptions: {retain: true}});
|
||||
this.eventBus.removeListeners(this);
|
||||
logger.info('Disconnecting from MQTT server');
|
||||
logger.info("Disconnecting from MQTT server");
|
||||
await this.client?.endAsync();
|
||||
}
|
||||
|
||||
@@ -142,11 +156,11 @@ export default class MQTT {
|
||||
}
|
||||
|
||||
@bind private async onConnect(): Promise<void> {
|
||||
logger.info('Connected to MQTT server');
|
||||
logger.info("Connected to MQTT server");
|
||||
|
||||
const stateData: Zigbee2MQTTAPI['bridge/state'] = {state: 'online'};
|
||||
const stateData: Zigbee2MQTTAPI["bridge/state"] = {state: "online"};
|
||||
|
||||
await this.publish('bridge/state', JSON.stringify(stateData), {retain: true, qos: 0});
|
||||
await this.publish("bridge/state", JSON.stringify(stateData), {clientOptions: {retain: true}});
|
||||
await this.subscribe(`${settings.get().mqtt.base_topic}/#`);
|
||||
}
|
||||
|
||||
@@ -168,54 +182,52 @@ export default class MQTT {
|
||||
return this.client && !this.client.reconnecting && !this.client.disconnecting && !this.client.disconnected;
|
||||
}
|
||||
|
||||
async publish(
|
||||
topic: string,
|
||||
payload: string,
|
||||
options: IClientPublishOptions = {},
|
||||
base = settings.get().mqtt.base_topic,
|
||||
skipLog = false,
|
||||
skipReceive = true,
|
||||
): Promise<void> {
|
||||
const defaultOptions = {qos: 0 as const, retain: false};
|
||||
topic = `${base}/${topic}`;
|
||||
async publish(topic: string, payload: string, options: Partial<MqttPublishOptions> = {}): Promise<void> {
|
||||
if (topic.includes("+") || topic.includes("#")) {
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/26939#issuecomment-2772309646
|
||||
logger.error(`Topic '${topic}' includes wildcard characters, skipping publish.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (skipReceive) {
|
||||
const finalOptions = {...this.defaultPublishOptions, ...options};
|
||||
topic = `${finalOptions.baseTopic}/${topic}`;
|
||||
|
||||
if (finalOptions.skipReceive) {
|
||||
this.publishedTopics.add(topic);
|
||||
}
|
||||
|
||||
if (options.retain) {
|
||||
if (finalOptions.clientOptions.retain) {
|
||||
if (payload) {
|
||||
this.retainedMessages[topic] = {payload, options, skipReceive, skipLog, topic: topic.substring(base.length + 1), base};
|
||||
this.retainedMessages[topic] = {payload, options: finalOptions, topic: topic.substring(finalOptions.baseTopic.length + 1)};
|
||||
} else {
|
||||
delete this.retainedMessages[topic];
|
||||
}
|
||||
}
|
||||
|
||||
this.eventBus.emitMQTTMessagePublished({topic, payload, options: {...defaultOptions, ...options}});
|
||||
this.eventBus.emitMQTTMessagePublished({topic, payload, options: finalOptions});
|
||||
|
||||
if (!this.isConnected()) {
|
||||
if (!skipLog) {
|
||||
logger.error(`Not connected to MQTT server!`);
|
||||
if (!finalOptions.skipLog) {
|
||||
logger.error("Not connected to MQTT server!");
|
||||
logger.error(`Cannot send message: topic: '${topic}', payload: '${payload}`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!skipLog) {
|
||||
if (!finalOptions.skipLog) {
|
||||
logger.info(() => `MQTT publish: topic '${topic}', payload '${payload}'`, NS);
|
||||
}
|
||||
|
||||
const actualOptions: IClientPublishOptions = {...defaultOptions, ...options};
|
||||
|
||||
let clientOptions: IClientPublishOptions = finalOptions.clientOptions;
|
||||
if (settings.get().mqtt.force_disable_retain) {
|
||||
actualOptions.retain = false;
|
||||
clientOptions = {...finalOptions.clientOptions, retain: false};
|
||||
}
|
||||
|
||||
try {
|
||||
await this.client.publishAsync(topic, payload, actualOptions);
|
||||
await this.client.publishAsync(topic, payload, clientOptions);
|
||||
} catch (error) {
|
||||
if (!skipLog) {
|
||||
if (!finalOptions.skipLog) {
|
||||
logger.error(`MQTT server error: ${(error as Error).message}`);
|
||||
logger.error(`Could not send message: topic: '${topic}', payload: '${payload}`);
|
||||
}
|
||||
|
||||
+61
-46
@@ -1,39 +1,38 @@
|
||||
import fs from 'node:fs';
|
||||
import {existsSync, readFileSync, writeFileSync} from "node:fs";
|
||||
|
||||
import objectAssignDeep from 'object-assign-deep';
|
||||
import objectAssignDeep from "object-assign-deep";
|
||||
|
||||
import data from './util/data';
|
||||
import logger from './util/logger';
|
||||
import * as settings from './util/settings';
|
||||
import utils from './util/utils';
|
||||
import data from "./util/data";
|
||||
import logger from "./util/logger";
|
||||
import * as settings from "./util/settings";
|
||||
import utils from "./util/utils";
|
||||
|
||||
const saveInterval = 1000 * 60 * 5; // 5 minutes
|
||||
|
||||
const dontCacheProperties = [
|
||||
'action',
|
||||
'action_.*',
|
||||
'button',
|
||||
'button_left',
|
||||
'button_right',
|
||||
'forgotten',
|
||||
'keyerror',
|
||||
'step_size',
|
||||
'transition_time',
|
||||
'group_list',
|
||||
'group_capacity',
|
||||
'no_occupancy_since',
|
||||
'step_mode',
|
||||
'transition_time',
|
||||
'duration',
|
||||
'elapsed',
|
||||
'from_side',
|
||||
'to_side',
|
||||
'illuminance_lux', // removed in z2m 2.0.0
|
||||
const SAVE_INTERVAL = 1000 * 60 * 5; // 5 minutes
|
||||
const CACHE_IGNORE_PROPERTIES = [
|
||||
"action",
|
||||
"action_.*",
|
||||
"button",
|
||||
"button_left",
|
||||
"button_right",
|
||||
"forgotten",
|
||||
"keyerror",
|
||||
"step_size",
|
||||
"transition_time",
|
||||
"group_list",
|
||||
"group_capacity",
|
||||
"no_occupancy_since",
|
||||
"step_mode",
|
||||
"transition_time",
|
||||
"duration",
|
||||
"elapsed",
|
||||
"from_side",
|
||||
"to_side",
|
||||
"illuminance_lux", // removed in z2m 2.0.0
|
||||
];
|
||||
|
||||
class State {
|
||||
private state: {[s: string | number]: KeyValue} = {};
|
||||
private file = data.joinPath('state.json');
|
||||
private readonly state = new Map<string | number, KeyValue>();
|
||||
private readonly file = data.joinPath("state.json");
|
||||
private timer?: NodeJS.Timeout;
|
||||
|
||||
constructor(
|
||||
@@ -48,23 +47,37 @@ class State {
|
||||
this.load();
|
||||
|
||||
// Save the state on every interval
|
||||
this.timer = setInterval(() => this.save(), saveInterval);
|
||||
this.timer = setInterval(() => this.save(), SAVE_INTERVAL);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
// Remove any invalid states (ie when the device has left the network) when the system is stopped
|
||||
Object.keys(this.state)
|
||||
.filter((k) => typeof k === 'string' && !this.zigbee.resolveEntity(k)) // string key = ieeeAddr
|
||||
.forEach((k) => delete this.state[k]);
|
||||
for (const [key] of this.state) {
|
||||
if (typeof key === "string" && key.startsWith("0x") && !this.zigbee.resolveEntity(key)) {
|
||||
// string key = ieeeAddr
|
||||
this.state.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
clearTimeout(this.timer);
|
||||
this.save();
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.state.clear();
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
if (fs.existsSync(this.file)) {
|
||||
this.state.clear();
|
||||
|
||||
if (existsSync(this.file)) {
|
||||
try {
|
||||
this.state = JSON.parse(fs.readFileSync(this.file, 'utf8'));
|
||||
const stateObj = JSON.parse(readFileSync(this.file, "utf8")) as KeyValue;
|
||||
|
||||
for (const key in stateObj) {
|
||||
this.state.set(key.startsWith("0x") ? key : Number.parseInt(key, 10), stateObj[key]);
|
||||
}
|
||||
|
||||
logger.debug(`Loaded state from file ${this.file}`);
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to load state from file ${this.file} (corrupt file?) (${(error as Error).message})`);
|
||||
@@ -77,40 +90,42 @@ class State {
|
||||
private save(): void {
|
||||
if (settings.get().advanced.cache_state_persistent) {
|
||||
logger.debug(`Saving state to file ${this.file}`);
|
||||
const json = JSON.stringify(this.state, null, 4);
|
||||
|
||||
const json = JSON.stringify(Object.fromEntries(this.state), null, 4);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(this.file, json, 'utf8');
|
||||
writeFileSync(this.file, json, "utf8");
|
||||
} catch (error) {
|
||||
logger.error(`Failed to write state to '${this.file}' (${error})`);
|
||||
}
|
||||
} else {
|
||||
logger.debug(`Not saving state`);
|
||||
logger.debug("Not saving state");
|
||||
}
|
||||
}
|
||||
|
||||
exists(entity: Device | Group): boolean {
|
||||
return this.state[entity.ID] !== undefined;
|
||||
return this.state.has(entity.ID);
|
||||
}
|
||||
|
||||
get(entity: Group | Device): KeyValue {
|
||||
return this.state[entity.ID] || {};
|
||||
return this.state.get(entity.ID) || {};
|
||||
}
|
||||
|
||||
set(entity: Group | Device, update: KeyValue, reason?: string): KeyValue {
|
||||
const fromState = this.state[entity.ID] || {};
|
||||
const fromState = this.state.get(entity.ID) || {};
|
||||
const toState = objectAssignDeep({}, fromState, update);
|
||||
const newCache = {...toState};
|
||||
const entityDontCacheProperties = entity.options.filtered_cache || [];
|
||||
|
||||
utils.filterProperties(dontCacheProperties.concat(entityDontCacheProperties), newCache);
|
||||
utils.filterProperties(CACHE_IGNORE_PROPERTIES.concat(entityDontCacheProperties), newCache);
|
||||
|
||||
this.state[entity.ID] = newCache;
|
||||
this.state.set(entity.ID, newCache);
|
||||
this.eventBus.emitStateChange({entity, from: fromState, to: toState, reason, update});
|
||||
return toState;
|
||||
}
|
||||
|
||||
remove(ID: string | number): void {
|
||||
delete this.state[ID];
|
||||
remove(id: string | number): boolean {
|
||||
return this.state.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+448
-210
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,3 +1,3 @@
|
||||
declare module 'json-stable-stringify-without-jsonify' {
|
||||
declare module "json-stable-stringify-without-jsonify" {
|
||||
export default function (obj: unknown): string;
|
||||
}
|
||||
|
||||
Vendored
+27
-159
@@ -1,21 +1,20 @@
|
||||
import type TypeEventBus from 'lib/eventBus';
|
||||
import type TypeExtension from 'lib/extension/extension';
|
||||
import type TypeDevice from 'lib/model/device';
|
||||
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 {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 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';
|
||||
|
||||
type OptionalProps<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
|
||||
import type TypeEventBus from "../eventBus";
|
||||
import type TypeExtension from "../extension/extension";
|
||||
import type TypeDevice from "../model/device";
|
||||
import type TypeGroup from "../model/group";
|
||||
import type TypeMqtt from "../mqtt";
|
||||
import type {MqttPublishOptions} from "../mqtt";
|
||||
import type TypeState from "../state";
|
||||
import type TypeZigbee from "../zigbee";
|
||||
import type {Zigbee2MQTTDeviceOptions, Zigbee2MQTTGroupOptions, Zigbee2MQTTSettings} from "./api";
|
||||
|
||||
declare global {
|
||||
// Define some class types as global
|
||||
type EventBus = TypeEventBus;
|
||||
type MQTT = TypeMQTT;
|
||||
type Mqtt = TypeMqtt;
|
||||
type Zigbee = TypeZigbee;
|
||||
type Group = TypeGroup;
|
||||
type Device = TypeDevice;
|
||||
@@ -23,11 +22,12 @@ declare global {
|
||||
type Extension = TypeExtension;
|
||||
|
||||
// Types
|
||||
type StateChangeReason = 'publishDebounce' | 'groupOptimistic' | 'lastSeenChanged' | 'publishCached' | 'publishThrottle';
|
||||
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]>};
|
||||
type MakePartialExcept<T, K extends keyof T> = Partial<Omit<T, K>> & Pick<T, K>;
|
||||
interface KeyValue {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// biome-ignore lint/suspicious/noExplicitAny: API
|
||||
[s: string]: any;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,8 @@ declare global {
|
||||
type Endpoint = ZHModels.Endpoint;
|
||||
type Device = ZHModels.Device;
|
||||
type Group = ZHModels.Group;
|
||||
type LQI = ZHAdapterTypes.LQI;
|
||||
// biome-ignore lint/style/useNamingConvention: API
|
||||
type LQI = ZHAdapterTypes.Lqi;
|
||||
type RoutingTable = ZHAdapterTypes.RoutingTable;
|
||||
type CoordinatorVersion = ZHAdapterTypes.CoordinatorVersion;
|
||||
type NetworkParameters = ZHAdapterTypes.NetworkParameters;
|
||||
@@ -48,9 +49,9 @@ declare global {
|
||||
|
||||
namespace eventdata {
|
||||
type EntityRenamed = {entity: Device | Group; homeAssisantRename: boolean; from: string; to: string};
|
||||
type EntityRemoved = {id: number | string; name: string; type: 'device' | 'group'};
|
||||
type EntityRemoved = {id: string; name: string; type: "device"} | {id: number; name: string; type: "group"};
|
||||
type MQTTMessage = {topic: string; message: string};
|
||||
type MQTTMessagePublished = {topic: string; payload: string; options: {retain: boolean; qos: number}};
|
||||
type MQTTMessagePublished = {topic: string; payload: string; options: MqttPublishOptions};
|
||||
type StateChange = {
|
||||
entity: Device | Group;
|
||||
from: KeyValue;
|
||||
@@ -61,24 +62,24 @@ declare global {
|
||||
type PermitJoinChanged = ZHEvents.PermitJoinChangedPayload;
|
||||
type LastSeenChanged = {
|
||||
device: Device;
|
||||
reason: 'deviceAnnounce' | 'networkAddress' | 'deviceJoined' | 'messageEmitted' | 'messageNonEmitted';
|
||||
reason: "deviceAnnounce" | "networkAddress" | "deviceJoined" | "messageEmitted" | "messageNonEmitted";
|
||||
};
|
||||
type DeviceNetworkAddressChanged = {device: Device};
|
||||
type DeviceAnnounce = {device: Device};
|
||||
type DeviceInterview = {device: Device; status: 'started' | 'successful' | 'failed'};
|
||||
type DeviceInterview = {device: Device; status: "started" | "successful" | "failed"};
|
||||
type DeviceJoined = {device: Device};
|
||||
type EntityOptionsChanged = {entity: Device | Group; from: KeyValue; to: KeyValue};
|
||||
type ExposesChanged = {device: Device};
|
||||
type Reconfigure = {device: Device};
|
||||
type DeviceLeave = {ieeeAddr: string; name: string};
|
||||
type GroupMembersChanged = {group: Group; action: 'remove' | 'add' | 'remove_all'; endpoint: zh.Endpoint; skipDisableReporting: boolean};
|
||||
type DeviceLeave = {ieeeAddr: string; name: string; device?: Device};
|
||||
type GroupMembersChanged = {group: Group; action: "remove" | "add" | "remove_all"; endpoint: zh.Endpoint; skipDisableReporting: boolean};
|
||||
type PublishEntityState = {entity: Group | Device; message: KeyValue; stateChangeReason?: StateChangeReason; payload: KeyValue};
|
||||
type DeviceMessage = {
|
||||
type: ZHEvents.MessagePayloadType;
|
||||
device: Device;
|
||||
endpoint: zh.Endpoint;
|
||||
linkquality: number;
|
||||
groupID: number;
|
||||
groupID: number; // XXX: should this be `?`
|
||||
cluster: string | number;
|
||||
data: KeyValue | Array<string | number>;
|
||||
meta: {zclTransactionSequenceNumber?: number; manufacturerCode?: number; frameControl?: ZHFrameControl};
|
||||
@@ -87,146 +88,13 @@ declare global {
|
||||
}
|
||||
|
||||
// Settings
|
||||
interface Settings {
|
||||
version?: number;
|
||||
homeassistant: {
|
||||
enabled: boolean;
|
||||
discovery_topic: string;
|
||||
status_topic: string;
|
||||
experimental_event_entities: boolean;
|
||||
legacy_action_sensor: boolean;
|
||||
};
|
||||
availability: {
|
||||
enabled: boolean;
|
||||
active: {timeout: number};
|
||||
passive: {timeout: number};
|
||||
};
|
||||
mqtt: {
|
||||
base_topic: string;
|
||||
include_device_information: boolean;
|
||||
force_disable_retain: boolean;
|
||||
version?: 3 | 4 | 5;
|
||||
user?: string;
|
||||
password?: string;
|
||||
server: string;
|
||||
ca?: string;
|
||||
keepalive?: number;
|
||||
key?: string;
|
||||
cert?: string;
|
||||
client_id?: string;
|
||||
reject_unauthorized?: boolean;
|
||||
maximum_packet_size: number;
|
||||
};
|
||||
serial: {
|
||||
disable_led: boolean;
|
||||
port?: string;
|
||||
adapter?: 'deconz' | 'zstack' | 'ezsp' | 'zigate' | 'ember';
|
||||
baudrate?: number;
|
||||
rtscts?: boolean;
|
||||
};
|
||||
passlist: string[];
|
||||
blocklist: string[];
|
||||
map_options: {
|
||||
graphviz: {
|
||||
colors: {
|
||||
fill: {
|
||||
enddevice: string;
|
||||
coordinator: string;
|
||||
router: string;
|
||||
};
|
||||
font: {
|
||||
coordinator: string;
|
||||
router: string;
|
||||
enddevice: string;
|
||||
};
|
||||
line: {
|
||||
active: string;
|
||||
inactive: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
ota: {
|
||||
update_check_interval: number;
|
||||
disable_automatic_update_check: boolean;
|
||||
zigbee_ota_override_index_location?: string;
|
||||
image_block_response_delay?: number;
|
||||
default_maximum_data_size?: number;
|
||||
};
|
||||
frontend: {
|
||||
enabled: boolean;
|
||||
auth_token?: string;
|
||||
host?: string;
|
||||
port: number;
|
||||
base_url: string;
|
||||
url?: string;
|
||||
ssl_cert?: string;
|
||||
ssl_key?: string;
|
||||
};
|
||||
devices: {[s: string]: DeviceOptions};
|
||||
groups: {[s: string]: Omit<GroupOptions, 'ID'>};
|
||||
device_options: KeyValue;
|
||||
advanced: {
|
||||
log_rotation: boolean;
|
||||
log_console_json: boolean;
|
||||
log_symlink_current: boolean;
|
||||
log_output: ('console' | 'file' | 'syslog')[];
|
||||
log_directory: string;
|
||||
log_file: string;
|
||||
log_level: LogLevel;
|
||||
log_namespaced_levels: Record<string, LogLevel>;
|
||||
log_syslog: KeyValue;
|
||||
log_debug_to_mqtt_frontend: boolean;
|
||||
log_debug_namespace_ignore: string;
|
||||
pan_id: number | 'GENERATE';
|
||||
ext_pan_id: number[] | 'GENERATE';
|
||||
channel: number;
|
||||
adapter_concurrent?: number;
|
||||
adapter_delay?: number;
|
||||
cache_state: boolean;
|
||||
cache_state_persistent: boolean;
|
||||
cache_state_send_on_startup: boolean;
|
||||
last_seen: 'disable' | 'ISO_8601' | 'ISO_8601_local' | 'epoch';
|
||||
elapsed: boolean;
|
||||
network_key: number[] | 'GENERATE';
|
||||
timestamp_format: string;
|
||||
output: 'json' | 'attribute' | 'attribute_and_json';
|
||||
transmit_power?: number;
|
||||
};
|
||||
}
|
||||
type Settings = Zigbee2MQTTSettings;
|
||||
|
||||
interface DeviceOptions {
|
||||
disabled?: boolean;
|
||||
retention?: number;
|
||||
availability?: boolean | {timeout: number};
|
||||
optimistic?: boolean;
|
||||
debounce?: number;
|
||||
debounce_ignore?: string[];
|
||||
throttle?: number;
|
||||
filtered_attributes?: string[];
|
||||
filtered_cache?: string[];
|
||||
filtered_optimistic?: string[];
|
||||
icon?: string;
|
||||
homeassistant?: KeyValue;
|
||||
friendly_name: string;
|
||||
description?: string;
|
||||
qos?: 0 | 1 | 2;
|
||||
}
|
||||
type DeviceOptions = Zigbee2MQTTDeviceOptions;
|
||||
|
||||
interface DeviceOptionsWithId extends DeviceOptions {
|
||||
ID: string;
|
||||
}
|
||||
|
||||
interface GroupOptions {
|
||||
ID: number;
|
||||
optimistic?: boolean;
|
||||
off_state?: 'all_members_off' | 'last_member_state';
|
||||
filtered_attributes?: string[];
|
||||
filtered_cache?: string[];
|
||||
filtered_optimistic?: string[];
|
||||
homeassistant?: KeyValue;
|
||||
friendly_name: string;
|
||||
description?: string;
|
||||
qos?: 0 | 1 | 2;
|
||||
}
|
||||
type GroupOptions = Zigbee2MQTTGroupOptions;
|
||||
}
|
||||
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
declare module "unix-dgram" {
|
||||
import {EventEmitter} from "node:events";
|
||||
|
||||
export class UnixDgramSocket extends EventEmitter {
|
||||
send(buf: Buffer, callback?: (err?: Error) => void): void;
|
||||
send(buf: Buffer, offset: number, length: number, path: string, callback?: (err?: Error) => void): void;
|
||||
bind(path: string): void;
|
||||
connect(remotePath: string): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export function createSocket(type: "unix_dgram", listener?: (msg: Buffer) => void): UnixDgramSocket;
|
||||
}
|
||||
Vendored
+10
-5
@@ -1,16 +1,21 @@
|
||||
declare module 'zigbee2mqtt-frontend' {
|
||||
export function getPath(): string;
|
||||
declare module "zigbee2mqtt-frontend" {
|
||||
const frontend: {
|
||||
getPath: () => string;
|
||||
};
|
||||
|
||||
export default frontend;
|
||||
}
|
||||
|
||||
declare module 'http' {
|
||||
declare module "http" {
|
||||
interface IncomingMessage {
|
||||
originalUrl?: string;
|
||||
path?: string;
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'express-static-gzip' {
|
||||
import {IncomingMessage, ServerResponse} from 'node:http';
|
||||
declare module "express-static-gzip" {
|
||||
import type {IncomingMessage, ServerResponse} from "node:http";
|
||||
|
||||
export type RequestHandler = (req: IncomingMessage, res: ServerResponse, finalhandler: (err: unknown) => void) => void;
|
||||
export default function expressStaticGzip(root: string, options?: Record<string, unknown>): RequestHandler;
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import path from 'node:path';
|
||||
import path from "node:path";
|
||||
|
||||
function setPath(): string {
|
||||
return process.env.ZIGBEE2MQTT_DATA ? process.env.ZIGBEE2MQTT_DATA : path.normalize(path.join(__dirname, '..', '..', 'data'));
|
||||
return process.env.ZIGBEE2MQTT_DATA ? process.env.ZIGBEE2MQTT_DATA : path.normalize(path.join(__dirname, "..", "..", "data"));
|
||||
}
|
||||
|
||||
let dataPath = setPath();
|
||||
|
||||
+50
-44
@@ -1,14 +1,14 @@
|
||||
import assert from 'node:assert';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import assert from "node:assert";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import moment from 'moment';
|
||||
import {rimrafSync} from 'rimraf';
|
||||
import winston from 'winston';
|
||||
import moment from "moment";
|
||||
import {rimrafSync} from "rimraf";
|
||||
import winston from "winston";
|
||||
|
||||
import * as settings from './settings';
|
||||
import * as settings from "./settings";
|
||||
|
||||
const NAMESPACE_SEPARATOR = ':';
|
||||
const NAMESPACE_SEPARATOR = ":";
|
||||
|
||||
class Logger {
|
||||
// @ts-expect-error initalized in `init`
|
||||
@@ -31,26 +31,26 @@ class Logger {
|
||||
// What transports to enable
|
||||
this.output = settings.get().advanced.log_output;
|
||||
// Directory to log to
|
||||
const timestamp = moment(Date.now()).format('YYYY-MM-DD.HH-mm-ss');
|
||||
this.directory = settings.get().advanced.log_directory.replace('%TIMESTAMP%', timestamp);
|
||||
const logFilename = settings.get().advanced.log_file.replace('%TIMESTAMP%', timestamp);
|
||||
const timestamp = moment(Date.now()).format("YYYY-MM-DD.HH-mm-ss");
|
||||
this.directory = settings.get().advanced.log_directory.replace("%TIMESTAMP%", timestamp);
|
||||
const logFilename = settings.get().advanced.log_file.replace("%TIMESTAMP%", timestamp);
|
||||
this.level = settings.get().advanced.log_level;
|
||||
this.namespacedLevels = settings.get().advanced.log_namespaced_levels;
|
||||
this.cachedNamespacedLevels = Object.assign({}, this.namespacedLevels);
|
||||
|
||||
assert(settings.LOG_LEVELS.includes(this.level), `'${this.level}' is not valid log_level, use one of '${settings.LOG_LEVELS.join(', ')}'`);
|
||||
assert(settings.LOG_LEVELS.includes(this.level), `'${this.level}' is not valid log_level, use one of '${settings.LOG_LEVELS.join(", ")}'`);
|
||||
|
||||
const timestampFormat = (): string => moment().format(settings.get().advanced.timestamp_format);
|
||||
|
||||
this.logger = winston.createLogger({
|
||||
level: 'debug',
|
||||
level: "debug",
|
||||
format: winston.format.combine(winston.format.errors({stack: true}), winston.format.timestamp({format: timestampFormat})),
|
||||
levels: winston.config.syslog.levels,
|
||||
});
|
||||
|
||||
const consoleSilenced = !this.output.includes('console');
|
||||
const consoleSilenced = !this.output.includes("console");
|
||||
// Print to user what logging is active
|
||||
let logging = `Logging to console${consoleSilenced ? ' (silenced)' : ''}`;
|
||||
let logging = `Logging to console${consoleSilenced ? " (silenced)" : ""}`;
|
||||
|
||||
// Setup default console logger
|
||||
this.logger.add(
|
||||
@@ -60,7 +60,7 @@ class Logger {
|
||||
? 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.colorize({colors: {debug: "blue", info: "green", warning: "yellow", error: "red"}}),
|
||||
winston.format.printf((info) => {
|
||||
return `[${info.timestamp}] ${info.level}: \t${info.message}`;
|
||||
}),
|
||||
@@ -68,15 +68,15 @@ class Logger {
|
||||
}),
|
||||
);
|
||||
|
||||
if (this.output.includes('file')) {
|
||||
if (this.output.includes("file")) {
|
||||
logging += `, file (filename: ${logFilename})`;
|
||||
|
||||
// Make sure that log directory exists when not logging to stdout only
|
||||
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;
|
||||
const current = settings.get().advanced.log_directory.replace("%TIMESTAMP%", "current");
|
||||
const actual = `./${timestamp}`;
|
||||
|
||||
/* v8 ignore start */
|
||||
if (fs.existsSync(current)) {
|
||||
@@ -108,18 +108,17 @@ class Logger {
|
||||
}
|
||||
|
||||
/* v8 ignore start */
|
||||
if (this.output.includes('syslog')) {
|
||||
logging += `, syslog`;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unused-expressions
|
||||
require('winston-syslog').Syslog;
|
||||
if (this.output.includes("syslog")) {
|
||||
logging += ", syslog";
|
||||
require("winston-syslog").Syslog;
|
||||
|
||||
const options: KeyValue = {
|
||||
app_name: 'Zigbee2MQTT',
|
||||
app_name: "Zigbee2MQTT",
|
||||
format: winston.format.printf((info) => info.message as string),
|
||||
...settings.get().advanced.log_syslog,
|
||||
};
|
||||
|
||||
if (options['type'] !== undefined) {
|
||||
if (options.type !== undefined) {
|
||||
options.type = options.type.toString();
|
||||
}
|
||||
|
||||
@@ -146,11 +145,15 @@ class Logger {
|
||||
}
|
||||
|
||||
public getDebugNamespaceIgnore(): string {
|
||||
return this.debugNamespaceIgnoreRegex?.toString().slice(1, -1) /* remove slashes */ ?? '';
|
||||
return (
|
||||
this.debugNamespaceIgnoreRegex
|
||||
?.toString()
|
||||
.slice(1, -1) /* remove slashes */ ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
public setDebugNamespaceIgnore(value: string): void {
|
||||
this.debugNamespaceIgnoreRegex = value != '' ? new RegExp(value) : undefined;
|
||||
this.debugNamespaceIgnoreRegex = value !== "" ? new RegExp(value) : undefined;
|
||||
}
|
||||
|
||||
public getLevel(): settings.LogLevel {
|
||||
@@ -178,11 +181,13 @@ class Logger {
|
||||
private cacheNamespacedLevel(namespace: string): string {
|
||||
let cached = namespace;
|
||||
|
||||
while (this.cachedNamespacedLevels[namespace] == undefined) {
|
||||
while (this.cachedNamespacedLevels[namespace] === undefined) {
|
||||
const sep = cached.lastIndexOf(NAMESPACE_SEPARATOR);
|
||||
|
||||
if (sep === -1) {
|
||||
return (this.cachedNamespacedLevels[namespace] = this.level);
|
||||
this.cachedNamespacedLevels[namespace] = this.level;
|
||||
|
||||
return this.level;
|
||||
}
|
||||
|
||||
cached = cached.slice(0, sep);
|
||||
@@ -201,30 +206,30 @@ class Logger {
|
||||
}
|
||||
}
|
||||
|
||||
public error(messageOrLambda: string | (() => string), namespace: string = 'z2m'): void {
|
||||
this.log('error', messageOrLambda, namespace);
|
||||
public error(messageOrLambda: string | (() => string), namespace = "z2m"): void {
|
||||
this.log("error", messageOrLambda, namespace);
|
||||
}
|
||||
|
||||
public warning(messageOrLambda: string | (() => string), namespace: string = 'z2m'): void {
|
||||
this.log('warning', messageOrLambda, namespace);
|
||||
public warning(messageOrLambda: string | (() => string), namespace = "z2m"): void {
|
||||
this.log("warning", messageOrLambda, namespace);
|
||||
}
|
||||
|
||||
public info(messageOrLambda: string | (() => string), namespace: string = 'z2m'): void {
|
||||
this.log('info', messageOrLambda, namespace);
|
||||
public info(messageOrLambda: string | (() => string), namespace = "z2m"): void {
|
||||
this.log("info", messageOrLambda, namespace);
|
||||
}
|
||||
|
||||
public debug(messageOrLambda: string | (() => string), namespace: string = 'z2m'): void {
|
||||
public debug(messageOrLambda: string | (() => string), namespace = "z2m"): void {
|
||||
if (this.debugNamespaceIgnoreRegex?.test(namespace)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.log('debug', messageOrLambda, namespace);
|
||||
this.log("debug", messageOrLambda, namespace);
|
||||
}
|
||||
|
||||
// Cleanup any old log directory.
|
||||
private cleanup(): void {
|
||||
if (settings.get().advanced.log_directory.includes('%TIMESTAMP%')) {
|
||||
const rootDirectory = path.join(this.directory, '..');
|
||||
if (settings.get().advanced.log_directory.includes("%TIMESTAMP%")) {
|
||||
const rootDirectory = path.join(this.directory, "..");
|
||||
|
||||
let directories = fs.readdirSync(rootDirectory).map((d) => {
|
||||
d = path.join(rootDirectory, d);
|
||||
@@ -232,11 +237,12 @@ class Logger {
|
||||
});
|
||||
|
||||
directories.sort((a: KeyValue, b: KeyValue) => b.birth - a.birth);
|
||||
directories = directories.slice(10, directories.length);
|
||||
directories.forEach((dir) => {
|
||||
directories = directories.slice(settings.get().advanced.log_directories_to_keep, directories.length);
|
||||
|
||||
for (const dir of directories) {
|
||||
this.debug(`Removing old log directory '${dir.path}'`);
|
||||
rimrafSync(dir.path);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,10 +258,10 @@ class Logger {
|
||||
// @ts-expect-error workaround
|
||||
if (this.fileTransport._dest) {
|
||||
// @ts-expect-error workaround
|
||||
this.fileTransport._dest.on('finish', resolve);
|
||||
this.fileTransport._dest.on("finish", resolve);
|
||||
} else {
|
||||
// @ts-expect-error workaround
|
||||
this.fileTransport.on('open', () => this.fileTransport._dest.on('finish', resolve));
|
||||
this.fileTransport.on("open", () => this.fileTransport._dest.on("finish", resolve));
|
||||
}
|
||||
this.fileTransport.end();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,579 @@
|
||||
import {existsSync, mkdirSync} from "node:fs";
|
||||
import {createServer} from "node:http";
|
||||
import {parse} from "node:querystring";
|
||||
|
||||
import {findAllDevices} from "zigbee-herdsman/dist/adapter/adapterDiscovery";
|
||||
|
||||
import data from "./data";
|
||||
import * as settings from "./settings";
|
||||
|
||||
type OnboardSettings = {
|
||||
mqtt_base_topic?: string;
|
||||
mqtt_server?: string;
|
||||
mqtt_user?: string;
|
||||
mqtt_password?: string;
|
||||
serial_port?: string;
|
||||
serial_adapter?: Settings["serial"]["adapter"];
|
||||
serial_baudrate?: string;
|
||||
serial_rtscts?: "on";
|
||||
network_channel?: string;
|
||||
network_key?: string;
|
||||
network_pan_id?: string;
|
||||
network_ext_pan_id?: string;
|
||||
frontend_enabled?: "on";
|
||||
frontend_port?: string;
|
||||
homeassistant_enabled?: "on";
|
||||
log_level?: Settings["advanced"]["log_level"];
|
||||
};
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/[^0-9A-Za-z \-_.]/g, (c) => `&#${c.charCodeAt(0)};`);
|
||||
}
|
||||
|
||||
function generateHtmlDone(frontendUrl: string | undefined): string {
|
||||
return `
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Zigbee2MQTT Onboarding</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Zigbee2MQTT Onboarding</h1>
|
||||
<p>Settings saved.</p>
|
||||
<p>Zigbee2MQTT is now starting...</p>
|
||||
<small>${frontendUrl ? `Redirecting to Zigbee2MQTT frontend at <a href="${frontendUrl}">${frontendUrl}</a> in 30 seconds.` : "You can close this page."}</small>
|
||||
</main>
|
||||
${frontendUrl ? `<script>setTimeout(() => { window.location.replace("${frontendUrl}"); }, 30000);</script>` : ""}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
function generateHtmlForm(currentSettings: RecursivePartial<Settings>, devices: Awaited<ReturnType<typeof findAllDevices>>): string {
|
||||
let devicesSelect = "";
|
||||
|
||||
if (devices.length > 0) {
|
||||
devicesSelect += '<select id="found_device" onchange="setFoundDevice(this)">';
|
||||
devicesSelect += '<option value="">Select a device</option>';
|
||||
|
||||
for (const device of devices) {
|
||||
// just in case name has commas, remove them to not mess with `split` logic
|
||||
const deviceStr = `${device.name.replaceAll(",", "")}, ${device.path}, ${device.adapter ?? "unknown"}`;
|
||||
|
||||
devicesSelect += `<option value="${deviceStr}">${deviceStr}</option>`;
|
||||
}
|
||||
|
||||
devicesSelect += "</select>";
|
||||
devicesSelect += "<small>Optionally allows to configure coordinator port and type (if known) automatically.</small>";
|
||||
} else {
|
||||
devicesSelect = "<small>No device found</small>";
|
||||
}
|
||||
|
||||
let generateCheckbox = "";
|
||||
|
||||
if (
|
||||
Array.isArray(currentSettings.advanced?.network_key) ||
|
||||
typeof currentSettings.advanced?.pan_id === "number" ||
|
||||
Array.isArray(currentSettings.advanced?.ext_pan_id)
|
||||
) {
|
||||
generateCheckbox = `
|
||||
<label for="generate_network">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="generate_network"
|
||||
onclick="setGenerate(this)"
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_NETWORK_KEY || process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_PAN_ID || process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_EXT_PAN_ID ? "disabled" : ""}>
|
||||
Generate network?
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
/* v8 ignore start */
|
||||
return `
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Zigbee2MQTT Onboarding</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Zigbee2MQTT Onboarding</h1>
|
||||
<p>Set the base configuration to start Zigbee2MQTT.</p>
|
||||
<p>Optional fields will either be ignored or fallback to defaults if not set (see appropriate documentation page for more details).</p>
|
||||
<p>If a field is disabled, it means <a href="https://www.zigbee2mqtt.io/guide/configuration/#environment-variables" target="_blank">environment variables</a> are being used to override specific values (for example, through the Home Assistant add-on configuration page).</p>
|
||||
<hr>
|
||||
<form method="post">
|
||||
<fieldset ${process.env.ZIGBEE2MQTT_CONFIG_SERIAL || process.env.ZIGBEE2MQTT_CONFIG_SERIAL_PORT || process.env.ZIGBEE2MQTT_CONFIG_SERIAL_ADAPTER ? "disabled" : ""}>
|
||||
<label for="found_device">Found Devices</label>
|
||||
${devicesSelect}
|
||||
</fieldset>
|
||||
<fieldset ${process.env.ZIGBEE2MQTT_CONFIG_SERIAL ? "disabled" : ""}>
|
||||
<label for="serial_port">Coordinator/Adapter Port/Path</label>
|
||||
<input
|
||||
type="text"
|
||||
id="serial_port"
|
||||
name="serial_port"
|
||||
value="${currentSettings.serial?.port ?? ""}"
|
||||
required
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_SERIAL_PORT ? "disabled" : ""}>
|
||||
<label for="serial_adapter">Coordinator/Adapter Type/Stack/Driver</label>
|
||||
<select id="serial_adapter" name="serial_adapter" required ${process.env.ZIGBEE2MQTT_CONFIG_SERIAL_ADAPTER ? "disabled" : ""}>
|
||||
<option value="zstack" ${currentSettings.serial?.adapter === "zstack" ? "selected" : ""}>zstack</option>
|
||||
<option value="ember" ${currentSettings.serial?.adapter === "ember" ? "selected" : ""}>ember</option>
|
||||
<option value="deconz" ${currentSettings.serial?.adapter === "deconz" ? "selected" : ""}>deconz</option>
|
||||
<option value="zigate" ${currentSettings.serial?.adapter === "zigate" ? "selected" : ""}>zigate</option>
|
||||
<option value="zboss" ${currentSettings.serial?.adapter === "zboss" ? "selected" : ""}>zboss</option>
|
||||
</select>
|
||||
<label for="serial_baudrate">Coordinator/Adapter Baudrate</label>
|
||||
<select id="serial_baudrate" name="serial_baudrate" ${process.env.ZIGBEE2MQTT_CONFIG_SERIAL_BAUDRATE ? "disabled" : ""}>
|
||||
<option value="38400" ${currentSettings.serial?.baudrate === 38400 ? "selected" : ""}>38400</option>
|
||||
<option value="57600" ${currentSettings.serial?.baudrate === 57600 ? "selected" : ""}>57600</option>
|
||||
<option value="115200" ${!currentSettings.serial?.baudrate || currentSettings.serial?.baudrate === 115200 ? "selected" : ""}>115200</option>
|
||||
<option value="230400" ${currentSettings.serial?.baudrate === 230400 ? "selected" : ""}>230400</option>
|
||||
<option value="460800" ${currentSettings.serial?.baudrate === 460800 ? "selected" : ""}>460800</option>
|
||||
<option value="921600" ${currentSettings.serial?.baudrate === 921600 ? "selected" : ""}>921600</option>
|
||||
</select>
|
||||
<small>Can be ignored for networked coordinators (TCP).</small>
|
||||
<label for="serial_rtscts">Coordinator/Adapter Hardware Flow Control ("rtscts: true")</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="serial_rtscts"
|
||||
name="serial_rtscts"
|
||||
${currentSettings.serial?.rtscts ? "checked" : ""}
|
||||
style="margin-bottom: 1rem;">
|
||||
<small>Can be ignored for networked coordinators (TCP).</small>
|
||||
</fieldset>
|
||||
<small>
|
||||
<a href="https://www.zigbee2mqtt.io/guide/configuration/adapter-settings.html" target="_blank">https://www.zigbee2mqtt.io/guide/configuration/adapter-settings.html</a>
|
||||
</small>
|
||||
<hr>
|
||||
<fieldset ${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED ? "disabled" : ""}>
|
||||
<label for="closest_wifi_channel">Closest WiFi Channel</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="14"
|
||||
id="closest_wifi_channel"
|
||||
value="0"
|
||||
onclick="setBestZigbeeChannel(this)"
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_CHANNEL ? "disabled" : ""}>
|
||||
<small>Optionally set to your closest WiFi channel to pick the best value for "Network channel" below.</small>
|
||||
<label for="network_channel">Network Channel</label>
|
||||
<input
|
||||
type="number"
|
||||
min="11"
|
||||
max="26"
|
||||
id="network_channel"
|
||||
name="network_channel"
|
||||
value="${currentSettings.advanced?.channel ?? "25"}"
|
||||
required
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_CHANNEL ? "disabled" : ""}>
|
||||
</fieldset>
|
||||
<fieldset ${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED ? "disabled" : ""}>
|
||||
${generateCheckbox}
|
||||
<label for="network_key">Network Key</label>
|
||||
<input
|
||||
type="text"
|
||||
id="network_key"
|
||||
name="network_key"
|
||||
value="${currentSettings.advanced?.network_key ?? "GENERATE"}"
|
||||
pattern="^([0-9]+(,[0-9]+){15})|GENERATE$"
|
||||
required
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_NETWORK_KEY ? "disabled" : ""}>
|
||||
<label for="network_pan_id">Network PAN ID</label>
|
||||
<input
|
||||
type="text"
|
||||
id="network_pan_id"
|
||||
name="network_pan_id"
|
||||
value="${currentSettings.advanced?.pan_id ?? "GENERATE"}"
|
||||
pattern="^([0-9]{1,5})|GENERATE$"
|
||||
required
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_PAN_ID ? "disabled" : ""}>
|
||||
<label for="network_ext_pan_id">Network Extended PAN ID</label>
|
||||
<input
|
||||
type="text"
|
||||
id="network_ext_pan_id"
|
||||
name="network_ext_pan_id"
|
||||
value="${currentSettings.advanced?.ext_pan_id ?? "GENERATE"}"
|
||||
pattern="^([0-9]+(,[0-9]+){7})|GENERATE$"
|
||||
required
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_EXT_PAN_ID ? "disabled" : ""}>
|
||||
</fieldset>
|
||||
<small>
|
||||
<a href="https://www.zigbee2mqtt.io/guide/configuration/zigbee-network.html" target="_blank">https://www.zigbee2mqtt.io/guide/configuration/zigbee-network.html</a>
|
||||
</small>
|
||||
<hr>
|
||||
<fieldset ${process.env.ZIGBEE2MQTT_CONFIG_MQTT ? "disabled" : ""}>
|
||||
<label for="mqtt_base_topic">MQTT Base Topic</label>
|
||||
<input
|
||||
type="text"
|
||||
id="mqtt_base_topic"
|
||||
name="mqtt_base_topic"
|
||||
value="${currentSettings.mqtt?.base_topic ?? "zigbee2mqtt"}"
|
||||
required
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_MQTT_BASE_TOPIC ? "disabled" : ""}>
|
||||
<label for="mqtt_server">MQTT Server</label>
|
||||
<input
|
||||
type="text"
|
||||
id="mqtt_server"
|
||||
name="mqtt_server"
|
||||
value="${currentSettings.mqtt?.server ?? "mqtt://localhost:1883"}"
|
||||
required
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_MQTT_SERVER ? "disabled" : ""}>
|
||||
<label for="mqtt_user">MQTT User</label>
|
||||
<input
|
||||
type="text"
|
||||
id="mqtt_user"
|
||||
name="mqtt_user"
|
||||
value="${currentSettings.mqtt?.user ?? ""}"
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_MQTT_USER ? "disabled" : ""}>
|
||||
<small>Optional. Set only if using authentication.</small>
|
||||
<label for="mqtt_password">MQTT Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="mqtt_password"
|
||||
name="mqtt_password"
|
||||
value="${currentSettings.mqtt?.password ?? ""}"
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_MQTT_PASSWORD ? "disabled" : ""}>
|
||||
<small>Optional. Set only if using authentication.</small>
|
||||
</fieldset>
|
||||
<small>
|
||||
<a href="https://www.zigbee2mqtt.io/guide/configuration/mqtt.html" target="_blank">https://www.zigbee2mqtt.io/guide/configuration/mqtt.html</a>
|
||||
</small>
|
||||
<hr>
|
||||
<fieldset ${process.env.ZIGBEE2MQTT_CONFIG_FRONTEND ? "disabled" : ""}>
|
||||
<label for="frontend_enabled">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="frontend_enabled"
|
||||
name="frontend_enabled"
|
||||
${currentSettings.frontend?.enabled ? "checked" : ""}
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_FRONTEND_ENABLED ? "disabled" : ""}>
|
||||
Frontend enabled?
|
||||
</label>
|
||||
<label for="frontend_port">Frontend Port</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="65535"
|
||||
id="frontend_port"
|
||||
name="frontend_port"
|
||||
value="${currentSettings.frontend?.port ?? "8080"}"
|
||||
required
|
||||
${process.env.ZIGBEE2MQTT_CONFIG_FRONTEND_PORT ? "disabled" : ""}>
|
||||
</fieldset>
|
||||
<small>
|
||||
<a href="https://www.zigbee2mqtt.io/guide/configuration/frontend.html" target="_blank">https://www.zigbee2mqtt.io/guide/configuration/frontend.html</a>
|
||||
</small>
|
||||
<fieldset ${process.env.ZIGBEE2MQTT_CONFIG_HOMEASSISTANT ? "disabled" : ""}>
|
||||
<label for="homeassistant_enabled" ${process.env.ZIGBEE2MQTT_CONFIG_HOMEASSISTANT_ENABLED ? "disabled" : ""}>
|
||||
<input type="checkbox" id="homeassistant_enabled" name="homeassistant_enabled" ${currentSettings.homeassistant?.enabled ? "checked" : ""}>
|
||||
Home Assistant enabled?
|
||||
</label>
|
||||
</fieldset>
|
||||
<small>
|
||||
<a href="https://www.zigbee2mqtt.io/guide/configuration/homeassistant.html" target="_blank">https://www.zigbee2mqtt.io/guide/configuration/homeassistant.html</a>
|
||||
</small>
|
||||
<hr>
|
||||
<fieldset ${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED ? "disabled" : ""}>
|
||||
<label for="log_level">Log Level</label>
|
||||
<select id="log_level" name="log_level" ${process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_LOG_LEVEL ? "disabled" : ""}>
|
||||
<option value="error" ${currentSettings.advanced?.log_level === "error" ? "selected" : ""}>error</option>
|
||||
<option value="warning" ${currentSettings.advanced?.log_level === "warning" ? "selected" : ""}>warning</option>
|
||||
<option value="info" ${!currentSettings.advanced?.log_level || currentSettings.advanced?.log_level === "info" ? "selected" : ""}>info</option>
|
||||
<option value="debug" ${currentSettings.advanced?.log_level === "debug" ? "selected" : ""}>debug</option>
|
||||
</select>
|
||||
</fieldset>
|
||||
<small>
|
||||
<a href="https://www.zigbee2mqtt.io/guide/configuration/logging.html" target="_blank">https://www.zigbee2mqtt.io/guide/configuration/logging.html</a>
|
||||
</small>
|
||||
<hr>
|
||||
<input type="submit" value="Submit">
|
||||
</form>
|
||||
</main>
|
||||
<script>
|
||||
function setFoundDevice(e) {
|
||||
if (!e.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [, path, adapter] = e.value.split(", ");
|
||||
const serialPortEl = document.querySelector("#serial_port");
|
||||
serialPortEl.value = path;
|
||||
const serialAdapterEl = document.querySelector("#serial_adapter");
|
||||
|
||||
if (['zstack', 'ember', 'deconz', 'zigate', 'zboss'].includes(adapter)) {
|
||||
serialAdapterEl.value = adapter;
|
||||
} else {
|
||||
serialAdapterEl.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function setBestZigbeeChannel(e) {
|
||||
const wifiChannel = parseInt(e.value, 10);
|
||||
const networkChannelEl = document.querySelector("#network_channel");
|
||||
|
||||
if (wifiChannel >= 11) {
|
||||
// WiFi 11-14
|
||||
networkChannelEl.value = 15;
|
||||
} else if (wifiChannel >= 6) {
|
||||
// WiFi 6-10
|
||||
networkChannelEl.value = 11;
|
||||
} else {
|
||||
// WiFi 1-5
|
||||
networkChannelEl.value = 25;
|
||||
}
|
||||
}
|
||||
|
||||
function setGenerate(e) {
|
||||
document.querySelector("#network_key").value = e.checked ? "GENERATE" : "${currentSettings.advanced?.network_key ?? "GENERATE"}";
|
||||
document.querySelector("#network_pan_id").value = e.checked ? "GENERATE" : "${currentSettings.advanced?.pan_id ?? "GENERATE"}";
|
||||
document.querySelector("#network_ext_pan_id").value = e.checked ? "GENERATE" : "${currentSettings.advanced?.ext_pan_id ?? "GENERATE"}";
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
|
||||
function generateHtmlError(errors: string): string {
|
||||
return `
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Zigbee2MQTT Onboarding</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Zigbee2MQTT configuration is not valid</h1>
|
||||
<p style="color: #F00;">Found the following errors:</p>
|
||||
${errors}
|
||||
<hr>
|
||||
<p>If you don't know how to solve this, read <a href="https://www.zigbee2mqtt.io/guide/configuration" target="_blank">https://www.zigbee2mqtt.io/guide/configuration</a></p>
|
||||
<form method="post" action="/">
|
||||
<input type="submit" value="Close">
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
function getServerUrl(): URL {
|
||||
return new URL(process.env.Z2M_ONBOARD_URL ?? "http://0.0.0.0:8080");
|
||||
}
|
||||
|
||||
async function startOnboardingServer(): Promise<boolean> {
|
||||
const currentSettings = settings.get();
|
||||
const serverUrl = getServerUrl();
|
||||
let server: ReturnType<typeof createServer> | undefined;
|
||||
let failed = false;
|
||||
|
||||
const success = await new Promise<boolean>((resolve) => {
|
||||
server = createServer(async (req, res) => {
|
||||
if (req.method === "POST") {
|
||||
if (failed) {
|
||||
res.end(() => {
|
||||
resolve(false);
|
||||
});
|
||||
} else {
|
||||
let body = "";
|
||||
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
|
||||
req.on("end", () => {
|
||||
const result = parse(body) as unknown as OnboardSettings;
|
||||
const frontendEnabled = result.frontend_enabled === "on";
|
||||
const updatedSettings: RecursivePartial<Settings> = {
|
||||
mqtt: {
|
||||
base_topic: result.mqtt_base_topic,
|
||||
server: result.mqtt_server,
|
||||
user: result.mqtt_user || undefined, // empty string => removed
|
||||
password: result.mqtt_password || undefined, // empty string => removed
|
||||
},
|
||||
serial: {
|
||||
port: result.serial_port,
|
||||
adapter: result.serial_adapter,
|
||||
baudrate: result.serial_baudrate ? Number.parseInt(result.serial_baudrate, 10) : undefined,
|
||||
rtscts: result.serial_rtscts === "on",
|
||||
},
|
||||
advanced: {
|
||||
log_level: result.log_level,
|
||||
channel: result.network_channel ? Number.parseInt(result.network_channel, 10) : undefined,
|
||||
network_key: result.network_key
|
||||
? result.network_key === "GENERATE"
|
||||
? result.network_key
|
||||
: result.network_key.split(",").map((v) => Number.parseInt(v, 10))
|
||||
: undefined,
|
||||
pan_id: result.network_pan_id
|
||||
? result.network_pan_id === "GENERATE"
|
||||
? result.network_pan_id
|
||||
: Number.parseInt(result.network_pan_id, 10)
|
||||
: undefined,
|
||||
ext_pan_id: result.network_ext_pan_id
|
||||
? result.network_ext_pan_id === "GENERATE"
|
||||
? result.network_ext_pan_id
|
||||
: result.network_ext_pan_id.split(",").map((v) => Number.parseInt(v, 10))
|
||||
: undefined,
|
||||
},
|
||||
frontend: {
|
||||
enabled: frontendEnabled,
|
||||
port: result.frontend_port ? Number.parseInt(result.frontend_port, 10) : undefined,
|
||||
},
|
||||
homeassistant: {
|
||||
enabled: result.homeassistant_enabled === "on",
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
settings.apply(updatedSettings);
|
||||
|
||||
// to redirect, make sure frontend "will be" enabled, and host isn't socket
|
||||
const redirect =
|
||||
!process.env.Z2M_ONBOARD_NO_REDIRECT &&
|
||||
frontendEnabled &&
|
||||
(!currentSettings.frontend?.host || !currentSettings.frontend.host.startsWith("/"));
|
||||
const protocol = currentSettings.frontend?.ssl_cert && currentSettings.frontend.ssl_key ? "https" : "http";
|
||||
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.writeHead(200);
|
||||
res.end(
|
||||
generateHtmlDone(
|
||||
redirect
|
||||
? /* v8 ignore next */ `${protocol}://${currentSettings.frontend?.host ?? "localhost"}:${currentSettings.frontend?.port ?? "8080"}${currentSettings.frontend?.base_url ?? "/"}`
|
||||
: undefined,
|
||||
),
|
||||
() => {
|
||||
resolve(true);
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Failed to apply configuration: ${(error as Error).message}`);
|
||||
failed = true;
|
||||
|
||||
if (process.env.Z2M_ONBOARD_NO_FAILURE_PAGE) {
|
||||
res.end(() => {
|
||||
resolve(false);
|
||||
});
|
||||
} else {
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.writeHead(406);
|
||||
res.end(generateHtmlError(`<p>${escapeHtml((error as Error).message)}</p>`));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.writeHead(200);
|
||||
res.end(generateHtmlForm(currentSettings, await findAllDevices()));
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(Number.parseInt(serverUrl.port), serverUrl.hostname, () => {
|
||||
console.log(`Onboarding page is available at ${serverUrl.href}`);
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise((resolve) => server?.close(resolve));
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
async function startFailureServer(errors: string): Promise<void> {
|
||||
const serverUrl = getServerUrl();
|
||||
let server: ReturnType<typeof createServer> | undefined;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server = createServer(async (req, res) => {
|
||||
if (req.method === "POST") {
|
||||
res.end(() => {
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.writeHead(406);
|
||||
res.end(generateHtmlError(errors));
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(Number.parseInt(serverUrl.port), serverUrl.hostname, () => {
|
||||
console.error(`Failure page is available at ${serverUrl.href}`);
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise((resolve) => server?.close(resolve));
|
||||
}
|
||||
|
||||
export async function onboard(): Promise<boolean> {
|
||||
if (!existsSync(data.getPath())) {
|
||||
mkdirSync(data.getPath(), {recursive: true});
|
||||
}
|
||||
|
||||
const confExists = existsSync(data.joinPath("configuration.yaml"));
|
||||
|
||||
if (!confExists) {
|
||||
settings.writeMinimalDefaults();
|
||||
} else {
|
||||
// migrate first
|
||||
const {migrateIfNecessary} = await import("./settingsMigration.js");
|
||||
|
||||
migrateIfNecessary();
|
||||
// trigger initial writing of `ZIGBEE2MQTT_CONFIG_*` ENVs
|
||||
settings.write();
|
||||
}
|
||||
|
||||
// use `configuration.yaml` file to detect "brand new install"
|
||||
// env allows to re-run onboard even with existing install
|
||||
if (!process.env.Z2M_ONBOARD_NO_SERVER && (process.env.Z2M_ONBOARD_FORCE_RUN || !confExists || settings.get().onboarding)) {
|
||||
settings.setOnboarding(true);
|
||||
|
||||
const success = await startOnboardingServer();
|
||||
|
||||
if (!success) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
settings.reRead();
|
||||
|
||||
const errors = settings.validate();
|
||||
|
||||
if (errors.length > 0) {
|
||||
let pErrors = "";
|
||||
|
||||
console.error("\n\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
|
||||
console.error(" READ THIS CAREFULLY\n");
|
||||
console.error("Refusing to start because configuration is not valid, found the following errors:");
|
||||
|
||||
for (const error of errors) {
|
||||
console.error(`- ${error}`);
|
||||
|
||||
pErrors += `<p>- ${escapeHtml(error)}</p>`;
|
||||
}
|
||||
|
||||
console.error("\nIf you don't know how to solve this, read https://www.zigbee2mqtt.io/guide/configuration");
|
||||
console.error("\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n\n");
|
||||
|
||||
if (!process.env.Z2M_ONBOARD_NO_SERVER && !process.env.Z2M_ONBOARD_NO_FAILURE_PAGE) {
|
||||
await startFailureServer(pErrors);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type {UnixDgramSocket} from "unix-dgram";
|
||||
|
||||
import {platform} from "node:os";
|
||||
|
||||
import logger from "./logger";
|
||||
|
||||
/**
|
||||
* Handle sd_notify protocol, @see https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html
|
||||
* No-op if running on unsupported platforms or without Type=notify
|
||||
* Soft-fails if improperly setup (this is not necessary for Zigbee2MQTT to function properly)
|
||||
*/
|
||||
export async function initSdNotify(): Promise<{notifyStopping: () => void; stop: () => void} | undefined> {
|
||||
if (!process.env.NOTIFY_SOCKET) {
|
||||
return;
|
||||
}
|
||||
|
||||
let socket: UnixDgramSocket | undefined;
|
||||
|
||||
try {
|
||||
const {createSocket} = await import("unix-dgram");
|
||||
socket = createSocket("unix_dgram");
|
||||
} catch (error) {
|
||||
if (platform() !== "win32" || process.env.WSL_DISTRO_NAME) {
|
||||
// not on plain Windows
|
||||
logger.error(`Could not init sd_notify: ${(error as Error).message}`);
|
||||
// biome-ignore lint/style/noNonNullAssertion: always Error
|
||||
logger.debug((error as Error).stack!);
|
||||
} else {
|
||||
// this should not happen
|
||||
logger.warning(`NOTIFY_SOCKET env is set: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const sendToSystemd = (msg: string): void => {
|
||||
const buffer = Buffer.from(msg);
|
||||
|
||||
// biome-ignore lint/style/noNonNullAssertion: valid from start of function
|
||||
socket.send(buffer, 0, buffer.byteLength, process.env.NOTIFY_SOCKET!, (err) => {
|
||||
if (err) {
|
||||
logger.warning(`Failed to send "${msg}" to systemd: ${err.message}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
const notifyStopping = (): void => sendToSystemd("STOPPING=1");
|
||||
|
||||
sendToSystemd("READY=1");
|
||||
|
||||
const wdUSec = process.env.WATCHDOG_USEC !== undefined ? Math.max(0, Number.parseInt(process.env.WATCHDOG_USEC, 10)) : -1;
|
||||
|
||||
if (wdUSec > 0) {
|
||||
// Convert us to ms, send twice as frequently as the timeout
|
||||
const watchdogInterval = setInterval(() => sendToSystemd("WATCHDOG=1"), wdUSec / 1000 / 2);
|
||||
|
||||
return {
|
||||
notifyStopping,
|
||||
stop: (): void => clearInterval(watchdogInterval),
|
||||
};
|
||||
}
|
||||
|
||||
if (wdUSec !== -1) {
|
||||
logger.warning(`WATCHDOG_USEC invalid: "${process.env.WATCHDOG_USEC}", parsed to "${wdUSec}"`);
|
||||
}
|
||||
|
||||
return {
|
||||
notifyStopping,
|
||||
stop: (): void => {},
|
||||
};
|
||||
}
|
||||
@@ -68,6 +68,26 @@
|
||||
"requiresRestart": true,
|
||||
"default": 10,
|
||||
"description": "Time after which an active device will be marked as offline in minutes"
|
||||
},
|
||||
"max_jitter": {
|
||||
"type": "number",
|
||||
"title": "Max jitter",
|
||||
"default": 30000,
|
||||
"minimum": 1000,
|
||||
"description": "Maximum jitter (in msec) allowed on timeout to avoid availability pings trying to trigger around the same time"
|
||||
},
|
||||
"backoff": {
|
||||
"type": "boolean",
|
||||
"title": "Enabled",
|
||||
"description": "Enable timeout backoff on failed availability pings (x1.5, x3, x6, x12...)",
|
||||
"default": true
|
||||
},
|
||||
"pause_on_backoff_gt": {
|
||||
"type": "number",
|
||||
"title": "Pause on backoff greater than",
|
||||
"default": 0,
|
||||
"minimum": 0,
|
||||
"description": "Pause availability pings when backoff reaches over this limit until a new Zigbee message is received from the device. A value of zero disables pausing."
|
||||
}
|
||||
},
|
||||
"required": ["timeout"]
|
||||
@@ -218,8 +238,8 @@
|
||||
"default": false
|
||||
},
|
||||
"adapter": {
|
||||
"type": ["string"],
|
||||
"enum": ["deconz", "zstack", "zigate", "ezsp", "ember", "zboss"],
|
||||
"type": "string",
|
||||
"enum": ["deconz", "zstack", "zigate", "ezsp", "ember", "zboss", "zoh"],
|
||||
"title": "Adapter",
|
||||
"requiresRestart": true,
|
||||
"description": "Adapter type, not needed unless you are experiencing problems"
|
||||
@@ -365,6 +385,14 @@
|
||||
"default": false,
|
||||
"requiresRestart": true
|
||||
},
|
||||
"package": {
|
||||
"type": "string",
|
||||
"enum": ["zigbee2mqtt-frontend", "zigbee2mqtt-windfront"],
|
||||
"title": "Package",
|
||||
"default": "zigbee2mqtt-frontend",
|
||||
"requiresRestart": true,
|
||||
"description": "Package used for the frontend"
|
||||
},
|
||||
"port": {
|
||||
"type": "number",
|
||||
"title": "Port",
|
||||
@@ -410,6 +438,14 @@
|
||||
"description": "Base URL for the frontend. If hosted under a subpath, e.g. 'http://localhost:8080/z2m', set this to '/z2m'",
|
||||
"default": "/",
|
||||
"requiresRestart": true
|
||||
},
|
||||
"notification_filter": {
|
||||
"title": "Notification Filter",
|
||||
"description": "Hide frontend notifications matching specified regex strings. Example: 'z2m: Failed to ping.*'",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
@@ -509,7 +545,7 @@
|
||||
"title": "Log Namespaced Levels",
|
||||
"description": "Set individual log levels for certain namespaces",
|
||||
"default": {},
|
||||
"examples": [{"z2m:mqtt": "warning"}, {"zh:ember:uart:ash": "info"}]
|
||||
"examples": [{ "z2m:mqtt": "warning" }, { "zh:ember:uart:ash": "info" }]
|
||||
},
|
||||
"log_debug_to_mqtt_frontend": {
|
||||
"type": "boolean",
|
||||
@@ -525,6 +561,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": [
|
||||
@@ -817,7 +861,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) or a path to a local file inside the `device_icons` directory."
|
||||
"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 (e.g. device_icons/MODEL123.png)."
|
||||
},
|
||||
"homeassistant": {
|
||||
"type": ["object", "null"],
|
||||
|
||||
+182
-129
@@ -1,52 +1,54 @@
|
||||
import path from 'node:path';
|
||||
import type {ValidateFunction} from "ajv";
|
||||
|
||||
import Ajv, {ValidateFunction} from 'ajv';
|
||||
import objectAssignDeep from 'object-assign-deep';
|
||||
import path from "node:path";
|
||||
|
||||
import data from './data';
|
||||
import schemaJson from './settings.schema.json';
|
||||
import utils from './utils';
|
||||
import yaml, {YAMLFileException} from './yaml';
|
||||
import Ajv from "ajv";
|
||||
import objectAssignDeep from "object-assign-deep";
|
||||
|
||||
import data from "./data";
|
||||
import schemaJson from "./settings.schema.json";
|
||||
import utils from "./utils";
|
||||
import yaml, {YAMLFileException} from "./yaml";
|
||||
|
||||
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 = 'error' | 'warning' | 'info' | 'debug';
|
||||
export const LOG_LEVELS: readonly string[] = ["error", "warning", "info", "debug"] as const;
|
||||
export type LogLevel = "error" | "warning" | "info" | "debug";
|
||||
|
||||
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);
|
||||
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);
|
||||
const ajvRestartRequiredDeviceOptions = new Ajv({allErrors: true})
|
||||
.addKeyword({keyword: 'requiresRestart', validate: (s: unknown) => !s})
|
||||
.addKeyword({keyword: "requiresRestart", validate: (s: unknown) => !s})
|
||||
.compile(schemaJson.definitions.device);
|
||||
const ajvRestartRequiredGroupOptions = new Ajv({allErrors: true})
|
||||
.addKeyword({keyword: 'requiresRestart', validate: (s: unknown) => !s})
|
||||
.addKeyword({keyword: "requiresRestart", validate: (s: unknown) => !s})
|
||||
.compile(schemaJson.definitions.group);
|
||||
export const defaults: RecursivePartial<Settings> = {
|
||||
export const defaults = {
|
||||
homeassistant: {
|
||||
enabled: false,
|
||||
discovery_topic: 'homeassistant',
|
||||
status_topic: 'homeassistant/status',
|
||||
discovery_topic: "homeassistant",
|
||||
status_topic: "homeassistant/status",
|
||||
legacy_action_sensor: false,
|
||||
experimental_event_entities: false,
|
||||
},
|
||||
availability: {
|
||||
enabled: false,
|
||||
active: {timeout: 10},
|
||||
active: {timeout: 10, max_jitter: 30000, backoff: true, pause_on_backoff_gt: 0},
|
||||
passive: {timeout: 1500},
|
||||
},
|
||||
frontend: {
|
||||
enabled: false,
|
||||
package: "zigbee2mqtt-frontend",
|
||||
port: 8080,
|
||||
base_url: '/',
|
||||
base_url: "/",
|
||||
},
|
||||
mqtt: {
|
||||
base_topic: 'zigbee2mqtt',
|
||||
base_topic: "zigbee2mqtt",
|
||||
include_device_information: false,
|
||||
force_disable_retain: false,
|
||||
// 1MB = roughly 3.5KB per device * 300 devices for `/bridge/devices`
|
||||
@@ -61,18 +63,18 @@ export const defaults: RecursivePartial<Settings> = {
|
||||
graphviz: {
|
||||
colors: {
|
||||
fill: {
|
||||
enddevice: '#fff8ce',
|
||||
coordinator: '#e04e5d',
|
||||
router: '#4ea3e0',
|
||||
enddevice: "#fff8ce",
|
||||
coordinator: "#e04e5d",
|
||||
router: "#4ea3e0",
|
||||
},
|
||||
font: {
|
||||
coordinator: '#ffffff',
|
||||
router: '#ffffff',
|
||||
enddevice: '#000000',
|
||||
coordinator: "#ffffff",
|
||||
router: "#ffffff",
|
||||
enddevice: "#000000",
|
||||
},
|
||||
line: {
|
||||
active: '#009900',
|
||||
inactive: '#994444',
|
||||
active: "#009900",
|
||||
inactive: "#994444",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -88,14 +90,15 @@ export const defaults: RecursivePartial<Settings> = {
|
||||
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: /* v8 ignore next */ process.env.DEBUG ? 'debug' : 'info',
|
||||
log_output: ["console", "file"],
|
||||
log_directory: path.join(data.getPath(), "log", "%TIMESTAMP%"),
|
||||
log_file: "log.log",
|
||||
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_debug_namespace_ignore: "",
|
||||
log_directories_to_keep: 10,
|
||||
pan_id: 0x1a62,
|
||||
ext_pan_id: [0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd],
|
||||
channel: 11,
|
||||
@@ -104,13 +107,13 @@ export const defaults: RecursivePartial<Settings> = {
|
||||
cache_state: true,
|
||||
cache_state_persistent: true,
|
||||
cache_state_send_on_startup: true,
|
||||
last_seen: 'disable',
|
||||
last_seen: "disable",
|
||||
elapsed: false,
|
||||
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',
|
||||
timestamp_format: "YYYY-MM-DD HH:mm:ss",
|
||||
output: "json",
|
||||
},
|
||||
};
|
||||
} satisfies RecursivePartial<Settings>;
|
||||
|
||||
let _settings: Partial<Settings> | undefined;
|
||||
let _settingsWithDefaults: Settings | undefined;
|
||||
@@ -136,16 +139,64 @@ function parseValueRef(text: string): {filename: string; key: string} | null {
|
||||
if (match) {
|
||||
let filename = match[1];
|
||||
// This is mainly for backward compatibility.
|
||||
if (!filename.endsWith('.yaml') && !filename.endsWith('.yml')) {
|
||||
filename += '.yaml';
|
||||
if (!filename.endsWith(".yaml") && !filename.endsWith(".yml")) {
|
||||
filename += ".yaml";
|
||||
}
|
||||
return {filename, key: match[2]};
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function writeMinimalDefaults(): void {
|
||||
const minimal = {
|
||||
version: CURRENT_VERSION,
|
||||
mqtt: {
|
||||
base_topic: defaults.mqtt.base_topic,
|
||||
server: "mqtt://localhost:1883",
|
||||
},
|
||||
serial: {},
|
||||
advanced: {
|
||||
log_level: defaults.advanced.log_level,
|
||||
channel: defaults.advanced.channel,
|
||||
network_key: "GENERATE",
|
||||
pan_id: "GENERATE",
|
||||
ext_pan_id: "GENERATE",
|
||||
},
|
||||
frontend: {
|
||||
enabled: defaults.frontend.enabled,
|
||||
port: defaults.frontend.port,
|
||||
},
|
||||
homeassistant: {
|
||||
enabled: defaults.homeassistant.enabled,
|
||||
},
|
||||
} as Partial<Settings>;
|
||||
|
||||
applyEnvironmentVariables(minimal);
|
||||
yaml.writeIfChanged(CONFIG_FILE_PATH, minimal);
|
||||
|
||||
_settings = read();
|
||||
|
||||
loadSettingsWithDefaults();
|
||||
}
|
||||
|
||||
export function setOnboarding(value: boolean): void {
|
||||
const settings = getPersistedSettings();
|
||||
|
||||
if (value) {
|
||||
if (!settings.onboarding) {
|
||||
settings.onboarding = value;
|
||||
|
||||
write();
|
||||
}
|
||||
} else if (settings.onboarding) {
|
||||
delete settings.onboarding;
|
||||
|
||||
write();
|
||||
}
|
||||
}
|
||||
|
||||
function write(): void {
|
||||
export function write(): void {
|
||||
const settings = getPersistedSettings();
|
||||
const toWrite: KeyValue = objectAssignDeep({}, settings);
|
||||
|
||||
@@ -153,25 +204,25 @@ function write(): void {
|
||||
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 [
|
||||
['mqtt', 'server'],
|
||||
['mqtt', 'user'],
|
||||
['mqtt', 'password'],
|
||||
['advanced', 'network_key'],
|
||||
['frontend', 'auth_token'],
|
||||
for (const [ns, key] of [
|
||||
["mqtt", "server"],
|
||||
["mqtt", "user"],
|
||||
["mqtt", "password"],
|
||||
["advanced", "network_key"],
|
||||
["frontend", "auth_token"],
|
||||
]) {
|
||||
if (actual[path[0]] && actual[path[0]][path[1]]) {
|
||||
const ref = parseValueRef(actual[path[0]][path[1]]);
|
||||
if (actual[ns]?.[key]) {
|
||||
const ref = parseValueRef(actual[ns][key]);
|
||||
if (ref) {
|
||||
yaml.updateIfChanged(data.joinPath(ref.filename), ref.key, toWrite[path[0]][path[1]]);
|
||||
toWrite[path[0]][path[1]] = actual[path[0]][path[1]];
|
||||
yaml.updateIfChanged(data.joinPath(ref.filename), ref.key, toWrite[ns][key]);
|
||||
toWrite[ns][key] = actual[ns][key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write devices/groups to separate file if required.
|
||||
const writeDevicesOrGroups = (type: 'devices' | 'groups'): void => {
|
||||
if (typeof actual[type] === 'string' || (Array.isArray(actual[type]) && actual[type].length > 0)) {
|
||||
const writeDevicesOrGroups = (type: "devices" | "groups"): void => {
|
||||
if (typeof actual[type] === "string" || (Array.isArray(actual[type]) && actual[type].length > 0)) {
|
||||
const fileToWrite = Array.isArray(actual[type]) ? actual[type][0] : actual[type];
|
||||
const content = objectAssignDeep({}, settings[type]);
|
||||
|
||||
@@ -190,8 +241,11 @@ function write(): void {
|
||||
}
|
||||
};
|
||||
|
||||
writeDevicesOrGroups('devices');
|
||||
writeDevicesOrGroups('groups');
|
||||
writeDevicesOrGroups("devices");
|
||||
writeDevicesOrGroups("groups");
|
||||
|
||||
applyEnvironmentVariables(toWrite);
|
||||
|
||||
yaml.writeIfChanged(CONFIG_FILE_PATH, toWrite);
|
||||
|
||||
_settings = read();
|
||||
@@ -211,36 +265,21 @@ export function validate(): string[] {
|
||||
}
|
||||
|
||||
if (!ajvSetting(_settings)) {
|
||||
// When `ajvSetting()` return false it always has `errors`.
|
||||
// biome-ignore lint/style/noNonNullAssertion: When `ajvSetting()` return false it always has `errors`
|
||||
return ajvSetting.errors!.map((v) => `${v.instancePath.substring(1)} ${v.message}`);
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
|
||||
if (
|
||||
_settings.advanced &&
|
||||
_settings.advanced.network_key &&
|
||||
typeof _settings.advanced.network_key === 'string' &&
|
||||
_settings.advanced.network_key !== 'GENERATE'
|
||||
) {
|
||||
if (_settings.advanced?.network_key && typeof _settings.advanced.network_key === "string" && _settings.advanced.network_key !== "GENERATE") {
|
||||
errors.push(`advanced.network_key: should be array or 'GENERATE' (is '${_settings.advanced.network_key}')`);
|
||||
}
|
||||
|
||||
if (
|
||||
_settings.advanced &&
|
||||
_settings.advanced.pan_id &&
|
||||
typeof _settings.advanced.pan_id === 'string' &&
|
||||
_settings.advanced.pan_id !== 'GENERATE'
|
||||
) {
|
||||
if (_settings.advanced?.pan_id && typeof _settings.advanced.pan_id === "string" && _settings.advanced.pan_id !== "GENERATE") {
|
||||
errors.push(`advanced.pan_id: should be number or 'GENERATE' (is '${_settings.advanced.pan_id}')`);
|
||||
}
|
||||
|
||||
if (
|
||||
_settings.advanced &&
|
||||
_settings.advanced.ext_pan_id &&
|
||||
typeof _settings.advanced.ext_pan_id === 'string' &&
|
||||
_settings.advanced.ext_pan_id !== 'GENERATE'
|
||||
) {
|
||||
if (_settings.advanced?.ext_pan_id && typeof _settings.advanced.ext_pan_id === "string" && _settings.advanced.ext_pan_id !== "GENERATE") {
|
||||
errors.push(`advanced.ext_pan_id: should be array or 'GENERATE' (is '${_settings.advanced.ext_pan_id}')`);
|
||||
}
|
||||
|
||||
@@ -250,7 +289,7 @@ 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/')) {
|
||||
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)) {
|
||||
@@ -260,13 +299,18 @@ export function validate(): string[] {
|
||||
|
||||
const settingsWithDefaults = get();
|
||||
|
||||
Object.values(settingsWithDefaults.devices).forEach((d) => check(d));
|
||||
Object.values(settingsWithDefaults.groups).forEach((g) => check(g));
|
||||
for (const key in settingsWithDefaults.devices) {
|
||||
check(settingsWithDefaults.devices[key]);
|
||||
}
|
||||
|
||||
for (const key in settingsWithDefaults.groups) {
|
||||
check(settingsWithDefaults.groups[key]);
|
||||
}
|
||||
|
||||
if (settingsWithDefaults.mqtt.version !== 5) {
|
||||
for (const device of Object.values(settingsWithDefaults.devices)) {
|
||||
if (device.retention) {
|
||||
errors.push('MQTT retention requires protocol version 5');
|
||||
errors.push("MQTT retention requires protocol version 5");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,11 +320,10 @@ export function validate(): string[] {
|
||||
|
||||
function read(): Partial<Settings> {
|
||||
const s = yaml.read(CONFIG_FILE_PATH) as Partial<Settings>;
|
||||
applyEnvironmentVariables(s);
|
||||
|
||||
// Read !secret MQTT username and password if set
|
||||
const interpretValue = <T>(value: T): T => {
|
||||
if (typeof value === 'string') {
|
||||
if (typeof value === "string") {
|
||||
const ref = parseValueRef(value);
|
||||
if (ref) {
|
||||
return yaml.read(data.joinPath(ref.filename))[ref.key];
|
||||
@@ -310,8 +353,8 @@ function read(): Partial<Settings> {
|
||||
}
|
||||
|
||||
// Read devices/groups configuration from separate file if specified.
|
||||
const readDevicesOrGroups = (type: 'devices' | 'groups'): void => {
|
||||
if (typeof s[type] === 'string' || (Array.isArray(s[type]) && Array(s[type]).length > 0)) {
|
||||
const readDevicesOrGroups = (type: "devices" | "groups"): void => {
|
||||
if (typeof s[type] === "string" || (Array.isArray(s[type]) && Array(s[type]).length > 0)) {
|
||||
const files: string[] = Array.isArray(s[type]) ? s[type] : [s[type]];
|
||||
s[type] = {};
|
||||
for (const file of files) {
|
||||
@@ -322,8 +365,8 @@ function read(): Partial<Settings> {
|
||||
}
|
||||
};
|
||||
|
||||
readDevicesOrGroups('devices');
|
||||
readDevicesOrGroups('groups');
|
||||
readDevicesOrGroups("devices");
|
||||
readDevicesOrGroups("groups");
|
||||
|
||||
return s;
|
||||
}
|
||||
@@ -331,10 +374,10 @@ function read(): Partial<Settings> {
|
||||
function applyEnvironmentVariables(settings: Partial<Settings>): void {
|
||||
const iterate = (obj: KeyValue, path: string[]): void => {
|
||||
for (const key in obj) {
|
||||
if (key !== 'type') {
|
||||
if (key !== 'properties' && obj[key]) {
|
||||
const type = (obj[key].type || 'object').toString();
|
||||
const envPart = path.reduce((acc, val) => `${acc}${val}_`, '');
|
||||
if (key !== "type") {
|
||||
if (key !== "properties" && obj[key]) {
|
||||
const type = (obj[key].type || "object").toString();
|
||||
const envPart = path.reduce((acc, val) => `${acc}${val}_`, "");
|
||||
const envVariableName = `ZIGBEE2MQTT_CONFIG_${envPart}${key}`.toUpperCase();
|
||||
const envVariable = process.env[envVariableName];
|
||||
|
||||
@@ -346,32 +389,32 @@ function applyEnvironmentVariables(settings: Partial<Settings>): void {
|
||||
return acc[val];
|
||||
}, settings);
|
||||
|
||||
if (type.indexOf('object') >= 0 || type.indexOf('array') >= 0) {
|
||||
if (type.indexOf("object") >= 0 || type.indexOf("array") >= 0) {
|
||||
try {
|
||||
setting[key as keyof Settings] = JSON.parse(envVariable);
|
||||
} catch {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// biome-ignore lint/suspicious/noExplicitAny: auto-parsing
|
||||
setting[key as keyof Settings] = envVariable as any;
|
||||
}
|
||||
} else if (type.indexOf('number') >= 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} else if (type.indexOf("number") >= 0) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: auto-parsing
|
||||
setting[key as keyof Settings] = ((envVariable as unknown as number) * 1) as any;
|
||||
} else if (type.indexOf('boolean') >= 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setting[key as keyof Settings] = (envVariable.toLowerCase() === 'true') as any;
|
||||
} else if (type.indexOf("boolean") >= 0) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: auto-parsing
|
||||
setting[key as keyof Settings] = (envVariable.toLowerCase() === "true") as any;
|
||||
} else {
|
||||
if (type.indexOf('string') >= 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (type.indexOf("string") >= 0) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: auto-parsing
|
||||
setting[key as keyof Settings] = envVariable as any;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof obj[key] === 'object' && obj[key]) {
|
||||
if (typeof obj[key] === "object" && obj[key]) {
|
||||
const newPath = [...path];
|
||||
|
||||
if (key !== 'properties' && key !== 'oneOf' && !Number.isInteger(Number(key))) {
|
||||
if (key !== "properties" && key !== "oneOf" && !Number.isInteger(Number(key))) {
|
||||
newPath.push(key);
|
||||
}
|
||||
|
||||
@@ -402,11 +445,12 @@ export function get(): Settings {
|
||||
loadSettingsWithDefaults();
|
||||
}
|
||||
|
||||
// biome-ignore lint/style/noNonNullAssertion: just loaded
|
||||
return _settingsWithDefaults!;
|
||||
}
|
||||
|
||||
export function set(path: string[], value: string | number | boolean | KeyValue): void {
|
||||
/* eslint-disable-next-line */
|
||||
// biome-ignore lint/suspicious/noExplicitAny: auto-parsing
|
||||
let settings: any = getPersistedSettings();
|
||||
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
@@ -425,7 +469,7 @@ export function set(path: string[], value: string | number | boolean | KeyValue)
|
||||
write();
|
||||
}
|
||||
|
||||
export function apply(settings: Record<string, unknown>, throwOnError: boolean = true): boolean {
|
||||
export function apply(settings: Record<string, unknown>, throwOnError = true): boolean {
|
||||
getPersistedSettings(); // Ensure _settings is initialized.
|
||||
// @ts-expect-error noMutate not typed properly
|
||||
const newSettings = objectAssignDeep.noMutate(_settings, settings);
|
||||
@@ -434,7 +478,7 @@ export function apply(settings: Record<string, unknown>, throwOnError: boolean =
|
||||
ajvSetting(newSettings);
|
||||
|
||||
if (throwOnError) {
|
||||
const errors = ajvSetting.errors && ajvSetting.errors.filter((e) => e.keyword !== 'required');
|
||||
const errors = ajvSetting.errors?.filter((e) => e.keyword !== "required");
|
||||
|
||||
if (errors?.length) {
|
||||
const error = errors[0];
|
||||
@@ -447,7 +491,7 @@ export function apply(settings: Record<string, unknown>, throwOnError: boolean =
|
||||
|
||||
ajvRestartRequired(settings);
|
||||
|
||||
const restartRequired = Boolean(ajvRestartRequired.errors && !!ajvRestartRequired.errors.find((e) => e.keyword === 'requiresRestart'));
|
||||
const restartRequired = Boolean(ajvRestartRequired.errors && !!ajvRestartRequired.errors.find((e) => e.keyword === "requiresRestart"));
|
||||
|
||||
return restartRequired;
|
||||
}
|
||||
@@ -505,9 +549,9 @@ function getDeviceThrowIfNotExists(IDorName: string): DeviceOptionsWithId {
|
||||
return device;
|
||||
}
|
||||
|
||||
export function addDevice(ID: string): DeviceOptionsWithId {
|
||||
if (getDevice(ID)) {
|
||||
throw new Error(`Device '${ID}' already exists`);
|
||||
export function addDevice(id: string): DeviceOptionsWithId {
|
||||
if (getDevice(id)) {
|
||||
throw new Error(`Device '${id}' already exists`);
|
||||
}
|
||||
|
||||
const settings = getPersistedSettings();
|
||||
@@ -516,19 +560,20 @@ export function addDevice(ID: string): DeviceOptionsWithId {
|
||||
settings.devices = {};
|
||||
}
|
||||
|
||||
settings.devices[ID] = {friendly_name: ID};
|
||||
settings.devices[id] = {friendly_name: id};
|
||||
write();
|
||||
|
||||
return getDevice(ID)!; // valid from creation above
|
||||
// biome-ignore lint/style/noNonNullAssertion: valid from creation above
|
||||
return getDevice(id)!;
|
||||
}
|
||||
|
||||
export function blockDevice(ID: string): void {
|
||||
export function blockDevice(id: string): void {
|
||||
const settings = getPersistedSettings();
|
||||
if (!settings.blocklist) {
|
||||
settings.blocklist = [];
|
||||
}
|
||||
|
||||
settings.blocklist.push(ID);
|
||||
settings.blocklist.push(id);
|
||||
write();
|
||||
}
|
||||
|
||||
@@ -539,7 +584,7 @@ export function removeDevice(IDorName: string): void {
|
||||
write();
|
||||
}
|
||||
|
||||
export function addGroup(name: string, ID?: string): GroupOptions {
|
||||
export function addGroup(name: string, id?: string): GroupOptions {
|
||||
utils.validateFriendlyName(name, true);
|
||||
|
||||
if (getGroup(name) || getDevice(name)) {
|
||||
@@ -551,32 +596,34 @@ export function addGroup(name: string, ID?: string): GroupOptions {
|
||||
settings.groups = {};
|
||||
}
|
||||
|
||||
if (ID == undefined) {
|
||||
if (id == null) {
|
||||
// look for free ID
|
||||
ID = '1';
|
||||
id = "1";
|
||||
|
||||
while (settings.groups[ID]) {
|
||||
ID = (Number.parseInt(ID) + 1).toString();
|
||||
while (settings.groups[id]) {
|
||||
id = (Number.parseInt(id) + 1).toString();
|
||||
}
|
||||
} else {
|
||||
// ensure provided ID is not in use
|
||||
ID = ID.toString();
|
||||
id = id.toString();
|
||||
|
||||
if (settings.groups[ID]) {
|
||||
throw new Error(`Group ID '${ID}' is already in use`);
|
||||
if (settings.groups[id]) {
|
||||
throw new Error(`Group ID '${id}' is already in use`);
|
||||
}
|
||||
}
|
||||
|
||||
settings.groups[ID] = {friendly_name: name};
|
||||
settings.groups[id] = {friendly_name: name};
|
||||
write();
|
||||
|
||||
return getGroup(ID)!; // valid from creation above
|
||||
// biome-ignore lint/style/noNonNullAssertion: valid from creation above
|
||||
return getGroup(id)!;
|
||||
}
|
||||
|
||||
export function removeGroup(IDorName: string | number): void {
|
||||
const groupID = getGroupThrowIfNotExists(IDorName.toString()).ID!;
|
||||
const groupID = getGroupThrowIfNotExists(IDorName.toString()).ID;
|
||||
const settings = getPersistedSettings();
|
||||
|
||||
// biome-ignore lint/style/noNonNullAssertion: throwing above if not valid
|
||||
delete settings.groups![groupID];
|
||||
write();
|
||||
}
|
||||
@@ -589,15 +636,19 @@ export function changeEntityOptions(IDorName: string, newOptions: KeyValue): boo
|
||||
const device = getDevice(IDorName);
|
||||
|
||||
if (device) {
|
||||
objectAssignDeep(settings.devices![device.ID], newOptions);
|
||||
utils.removeNullPropertiesFromObject(settings.devices![device.ID], NULLABLE_SETTINGS);
|
||||
// biome-ignore lint/style/noNonNullAssertion: valid from above
|
||||
const settingsDevice = settings.devices![device.ID];
|
||||
objectAssignDeep(settingsDevice, newOptions);
|
||||
utils.removeNullPropertiesFromObject(settingsDevice, NULLABLE_SETTINGS);
|
||||
validator = ajvRestartRequiredDeviceOptions;
|
||||
} else {
|
||||
const group = getGroup(IDorName);
|
||||
|
||||
if (group) {
|
||||
objectAssignDeep(settings.groups![group.ID], newOptions);
|
||||
utils.removeNullPropertiesFromObject(settings.groups![group.ID], NULLABLE_SETTINGS);
|
||||
// biome-ignore lint/style/noNonNullAssertion: valid from above
|
||||
const settingsGroup = settings.groups![group.ID];
|
||||
objectAssignDeep(settingsGroup, newOptions);
|
||||
utils.removeNullPropertiesFromObject(settingsGroup, NULLABLE_SETTINGS);
|
||||
validator = ajvRestartRequiredGroupOptions;
|
||||
} else {
|
||||
throw new Error(`Device or group '${IDorName}' does not exist`);
|
||||
@@ -607,7 +658,7 @@ export function changeEntityOptions(IDorName: string, newOptions: KeyValue): boo
|
||||
write();
|
||||
validator(newOptions);
|
||||
|
||||
const restartRequired = Boolean(validator.errors && !!validator.errors.find((e) => e.keyword === 'requiresRestart'));
|
||||
const restartRequired = Boolean(validator.errors && !!validator.errors.find((e) => e.keyword === "requiresRestart"));
|
||||
|
||||
return restartRequired;
|
||||
}
|
||||
@@ -622,11 +673,13 @@ export function changeFriendlyName(IDorName: string, newName: string): void {
|
||||
const device = getDevice(IDorName);
|
||||
|
||||
if (device) {
|
||||
// biome-ignore lint/style/noNonNullAssertion: valid from above
|
||||
settings.devices![device.ID].friendly_name = newName;
|
||||
} else {
|
||||
const group = getGroup(IDorName);
|
||||
|
||||
if (group) {
|
||||
// biome-ignore lint/style/noNonNullAssertion: valid from above
|
||||
settings.groups![group.ID].friendly_name = newName;
|
||||
} else {
|
||||
throw new Error(`Device or group '${IDorName}' does not exist`);
|
||||
|
||||
+127
-124
@@ -1,8 +1,8 @@
|
||||
import {copyFileSync, writeFileSync} from 'node:fs';
|
||||
import {copyFileSync, writeFileSync} from "node:fs";
|
||||
|
||||
import data from './data';
|
||||
import * as settings from './settings';
|
||||
import utils from './utils';
|
||||
import data from "./data";
|
||||
import * as settings from "./settings";
|
||||
import utils from "./utils";
|
||||
|
||||
interface SettingsMigration {
|
||||
path: string[];
|
||||
@@ -10,7 +10,7 @@ interface SettingsMigration {
|
||||
noteIf?: (previousValue: unknown) => boolean;
|
||||
}
|
||||
|
||||
interface SettingsAdd extends Omit<SettingsMigration, 'noteIf'> {
|
||||
interface SettingsAdd extends Omit<SettingsMigration, "noteIf"> {
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
@@ -25,16 +25,16 @@ interface SettingsTransfer extends SettingsMigration {
|
||||
newPath: string[];
|
||||
}
|
||||
|
||||
interface SettingsCustomHandler extends Omit<SettingsMigration, 'path'> {
|
||||
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];
|
||||
const SUPPORTED_VERSIONS: Settings["version"][] = [undefined, 2, 3, settings.CURRENT_VERSION];
|
||||
|
||||
function backupSettings(version: number): void {
|
||||
const filePath = data.joinPath('configuration.yaml');
|
||||
const filePath = data.joinPath("configuration.yaml");
|
||||
|
||||
copyFileSync(filePath, filePath.replace('.yaml', `_backup_v${version}.yaml`));
|
||||
copyFileSync(filePath, filePath.replace(".yaml", `_backup_v${version}.yaml`));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,8 +46,8 @@ function backupSettings(version: number): void {
|
||||
* @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 {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: auto-parsing
|
||||
function setValue(currentSettings: any, path: string[], value: unknown, createPathIfNotExist = false): boolean {
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const key = path[i];
|
||||
|
||||
@@ -82,7 +82,7 @@ function setValue(currentSettings: any, path: string[], value: unknown, createPa
|
||||
* - true if path was valid
|
||||
* - the value at path
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// biome-ignore lint/suspicious/noExplicitAny: auto-parsing
|
||||
function getValue(currentSettings: any, path: string[]): [validPath: boolean, value: unknown] {
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const key = path[i];
|
||||
@@ -90,14 +90,14 @@ function getValue(currentSettings: any, path: string[]): [validPath: boolean, va
|
||||
|
||||
if (i === path.length - 1) {
|
||||
return [value !== undefined, value];
|
||||
} else {
|
||||
if (!value) {
|
||||
// invalid path
|
||||
break;
|
||||
}
|
||||
|
||||
currentSettings = value;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
// invalid path
|
||||
break;
|
||||
}
|
||||
|
||||
currentSettings = value;
|
||||
}
|
||||
|
||||
return [false, undefined];
|
||||
@@ -122,7 +122,7 @@ function addValue(currentSettings: Partial<Settings>, addition: SettingsAdd): vo
|
||||
function removeValue(currentSettings: Partial<Settings>, removal: SettingsRemove): [validPath: boolean, previousValue: unknown] {
|
||||
const [validPath, previousValue] = getValue(currentSettings, removal.path);
|
||||
|
||||
if (validPath && previousValue != undefined) {
|
||||
if (validPath && previousValue != null) {
|
||||
setValue(currentSettings, removal.path, undefined);
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ function removeValue(currentSettings: Partial<Settings>, removal: SettingsRemove
|
||||
*/
|
||||
function changeValue(currentSettings: Partial<Settings>, change: SettingsChange): [validPath: boolean, previousValue: unknown, changed: boolean] {
|
||||
const [validPath, previousValue] = getValue(currentSettings, change.path);
|
||||
let changed: boolean = false;
|
||||
let changed = false;
|
||||
|
||||
if (validPath && previousValue !== change.newValue) {
|
||||
if (!change.previousValueAnyOf || change.previousValueAnyOf.includes(previousValue)) {
|
||||
@@ -166,10 +166,10 @@ function transferValue(
|
||||
): [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));
|
||||
const transfered = validPath && previousValue != null && (!destValidPath || destValue == null || Array.isArray(destValue));
|
||||
|
||||
// no point in set if already undefined
|
||||
if (validPath && previousValue != undefined) {
|
||||
if (validPath && previousValue != null) {
|
||||
setValue(currentSettings, transfer.path, undefined);
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ function transferValue(
|
||||
}
|
||||
|
||||
const noteIfWasTrue = (previousValue: unknown): boolean => previousValue === true;
|
||||
const noteIfWasDefined = (previousValue: unknown): boolean => previousValue != undefined;
|
||||
const noteIfWasDefined = (previousValue: unknown): boolean => previousValue != null;
|
||||
const noteIfWasNonEmptyArray = (previousValue: unknown): boolean => Array.isArray(previousValue) && previousValue.length > 0;
|
||||
|
||||
function migrateToTwo(
|
||||
@@ -198,82 +198,82 @@ function migrateToTwo(
|
||||
): void {
|
||||
transfers.push(
|
||||
{
|
||||
path: ['advanced', 'homeassistant_discovery_topic'],
|
||||
note: `HA discovery_topic was moved from advanced.homeassistant_discovery_topic to homeassistant.discovery_topic.`,
|
||||
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'],
|
||||
newPath: ["homeassistant", "discovery_topic"],
|
||||
},
|
||||
{
|
||||
path: ['advanced', 'homeassistant_status_topic'],
|
||||
note: `HA status_topic was moved from advanced.homeassistant_status_topic to homeassistant.status_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'],
|
||||
newPath: ["homeassistant", "status_topic"],
|
||||
},
|
||||
{
|
||||
path: ['advanced', 'baudrate'],
|
||||
note: `Baudrate was moved from advanced.baudrate to serial.baudrate.`,
|
||||
path: ["advanced", "baudrate"],
|
||||
note: "Baudrate was moved from advanced.baudrate to serial.baudrate.",
|
||||
noteIf: noteIfWasDefined,
|
||||
newPath: ['serial', 'baudrate'],
|
||||
newPath: ["serial", "baudrate"],
|
||||
},
|
||||
{
|
||||
path: ['advanced', 'rtscts'],
|
||||
note: `RTSCTS was moved from advanced.rtscts to serial.rtscts.`,
|
||||
path: ["advanced", "rtscts"],
|
||||
note: "RTSCTS was moved from advanced.rtscts to serial.rtscts.",
|
||||
noteIf: noteIfWasDefined,
|
||||
newPath: ['serial', 'rtscts'],
|
||||
newPath: ["serial", "rtscts"],
|
||||
},
|
||||
{
|
||||
path: ['experimental', 'transmit_power'],
|
||||
note: `Transmit power was moved from experimental.transmit_power to advanced.transmit_power.`,
|
||||
path: ["experimental", "transmit_power"],
|
||||
note: "Transmit power was moved from experimental.transmit_power to advanced.transmit_power.",
|
||||
noteIf: noteIfWasDefined,
|
||||
newPath: ['advanced', 'transmit_power'],
|
||||
newPath: ["advanced", "transmit_power"],
|
||||
},
|
||||
{
|
||||
path: ['experimental', 'output'],
|
||||
note: `Output was moved from experimental.output to advanced.output.`,
|
||||
path: ["experimental", "output"],
|
||||
note: "Output was moved from experimental.output to advanced.output.",
|
||||
noteIf: noteIfWasDefined,
|
||||
newPath: ['advanced', 'output'],
|
||||
newPath: ["advanced", "output"],
|
||||
},
|
||||
{
|
||||
path: ['ban'],
|
||||
note: `ban was renamed to passlist.`,
|
||||
path: ["ban"],
|
||||
note: "ban was renamed to passlist.",
|
||||
noteIf: noteIfWasDefined,
|
||||
newPath: ['blocklist'],
|
||||
newPath: ["blocklist"],
|
||||
},
|
||||
{
|
||||
path: ['whitelist'],
|
||||
note: `whitelist was renamed to passlist.`,
|
||||
path: ["whitelist"],
|
||||
note: "whitelist was renamed to passlist.",
|
||||
noteIf: noteIfWasDefined,
|
||||
newPath: ['passlist'],
|
||||
newPath: ["passlist"],
|
||||
},
|
||||
);
|
||||
|
||||
changes.push({
|
||||
path: ['advanced', 'log_level'],
|
||||
path: ["advanced", "log_level"],
|
||||
note: `Log level 'warn' has been renamed to 'warning'.`,
|
||||
noteIf: (previousValue): boolean => previousValue === 'warn',
|
||||
previousValueAnyOf: ['warn'],
|
||||
newValue: 'warning',
|
||||
noteIf: (previousValue): boolean => previousValue === "warn",
|
||||
previousValueAnyOf: ["warn"],
|
||||
newValue: "warning",
|
||||
});
|
||||
|
||||
additions.push({
|
||||
path: ['version'],
|
||||
note: `Migrated settings to version 2`,
|
||||
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.`,
|
||||
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).`,
|
||||
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.`,
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -281,94 +281,94 @@ function migrateToTwo(
|
||||
haLegacyTriggers,
|
||||
haLegacyEntityAttrs,
|
||||
{
|
||||
path: ['advanced', 'homeassistant_legacy_triggers'],
|
||||
path: ["advanced", "homeassistant_legacy_triggers"],
|
||||
note: haLegacyTriggers.note,
|
||||
noteIf: haLegacyTriggers.noteIf,
|
||||
},
|
||||
{
|
||||
path: ['advanced', 'homeassistant_legacy_entity_attributes'],
|
||||
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.`,
|
||||
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'],
|
||||
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.`,
|
||||
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"})`,
|
||||
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)`,
|
||||
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)`,
|
||||
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`,
|
||||
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`,
|
||||
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`,
|
||||
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`,
|
||||
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`,
|
||||
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.`,
|
||||
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.`,
|
||||
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.`,
|
||||
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)`;
|
||||
const noteEntityOptionsRetrieveState = "Retrieve state option ((devices|groups).xyz.retrieve_state setting)";
|
||||
|
||||
for (const deviceKey in currentSettings.devices) {
|
||||
removals.push({
|
||||
path: ['devices', deviceKey, 'retrieve_state'],
|
||||
path: ["devices", deviceKey, "retrieve_state"],
|
||||
note: noteEntityOptionsRetrieveState,
|
||||
noteIf: noteIfWasTrue,
|
||||
});
|
||||
@@ -376,13 +376,13 @@ function migrateToTwo(
|
||||
|
||||
for (const groupKey in currentSettings.groups) {
|
||||
removals.push({
|
||||
path: ['groups', groupKey, 'retrieve_state'],
|
||||
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.`,
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -391,7 +391,7 @@ function migrateToTwo(
|
||||
}
|
||||
|
||||
function migrateToThree(
|
||||
currentSettings: Partial<Settings>,
|
||||
_currentSettings: Partial<Settings>,
|
||||
transfers: SettingsTransfer[],
|
||||
changes: SettingsChange[],
|
||||
additions: SettingsAdd[],
|
||||
@@ -400,18 +400,18 @@ function migrateToThree(
|
||||
): void {
|
||||
transfers.push();
|
||||
changes.push({
|
||||
path: ['version'],
|
||||
note: `Migrated settings to version 3`,
|
||||
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 changeToObject = (currentSettings: Partial<Settings>, path: string[]): ReturnType<SettingsCustomHandler["execute"]> => {
|
||||
const [validPath, previousValue] = getValue(currentSettings, path);
|
||||
|
||||
if (validPath) {
|
||||
if (typeof previousValue === 'boolean') {
|
||||
if (typeof previousValue === "boolean") {
|
||||
setValue(currentSettings, path, {enabled: previousValue});
|
||||
} else {
|
||||
setValue(currentSettings, path, {enabled: true, ...(previousValue as object)});
|
||||
@@ -425,23 +425,23 @@ function migrateToThree(
|
||||
{
|
||||
note: `Property 'homeassistant' is now always an object.`,
|
||||
noteIf: () => true,
|
||||
execute: (currentSettings) => changeToObject(currentSettings, ['homeassistant']),
|
||||
execute: (currentSettings) => changeToObject(currentSettings, ["homeassistant"]),
|
||||
},
|
||||
{
|
||||
note: `Property 'frontend' is now always an object.`,
|
||||
noteIf: () => true,
|
||||
execute: (currentSettings) => changeToObject(currentSettings, ['frontend']),
|
||||
execute: (currentSettings) => changeToObject(currentSettings, ["frontend"]),
|
||||
},
|
||||
{
|
||||
note: `Property 'availability' is now always an object.`,
|
||||
noteIf: () => true,
|
||||
execute: (currentSettings) => changeToObject(currentSettings, ['availability']),
|
||||
execute: (currentSettings) => changeToObject(currentSettings, ["availability"]),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function migrateToFour(
|
||||
currentSettings: Partial<Settings>,
|
||||
_currentSettings: Partial<Settings>,
|
||||
transfers: SettingsTransfer[],
|
||||
changes: SettingsChange[],
|
||||
additions: SettingsAdd[],
|
||||
@@ -450,15 +450,15 @@ function migrateToFour(
|
||||
): void {
|
||||
transfers.push();
|
||||
changes.push({
|
||||
path: ['version'],
|
||||
note: `Migrated settings to version 4`,
|
||||
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']);
|
||||
const saveBase64DeviceIconsAsImage = (currentSettings: Partial<Settings>): ReturnType<SettingsCustomHandler["execute"]> => {
|
||||
const [validPath, previousValue] = getValue(currentSettings, ["devices"]);
|
||||
let changed = false;
|
||||
|
||||
if (validPath) {
|
||||
@@ -475,7 +475,7 @@ function migrateToFour(
|
||||
};
|
||||
|
||||
customHandlers.push({
|
||||
note: `Device icons are now saved as images.`,
|
||||
note: "Device icons are now saved as images.",
|
||||
noteIf: () => true,
|
||||
execute: (currentSettings) => saveBase64DeviceIconsAsImage(currentSettings),
|
||||
});
|
||||
@@ -491,22 +491,26 @@ function migrateToFour(
|
||||
* Should allow the most flexibility whenever combination of migrations is necessary (e.g. Transfer + Change)
|
||||
*/
|
||||
export function migrateIfNecessary(): void {
|
||||
let currentSettings = settings.getPersistedSettings();
|
||||
const 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(',')}.`,
|
||||
`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
|
||||
if (currentSettings.version === finalVersion) {
|
||||
// when same version as current, nothing to do
|
||||
return;
|
||||
}
|
||||
|
||||
while (currentSettings.version !== finalVersion) {
|
||||
let migrationNotesFileName: string | undefined;
|
||||
// don't duplicate outputs
|
||||
const migrationNotes: Set<string> = new Set();
|
||||
const migrationNotes = new Set<string>();
|
||||
const transfers: SettingsTransfer[] = [];
|
||||
const changes: SettingsChange[] = [];
|
||||
const additions: SettingsAdd[] = [];
|
||||
@@ -516,17 +520,17 @@ export function migrateIfNecessary(): void {
|
||||
backupSettings(currentSettings.version || 1);
|
||||
|
||||
// each version should only bump to the next version so as to gradually migrate if necessary
|
||||
if (currentSettings.version == undefined) {
|
||||
if (currentSettings.version == null) {
|
||||
// migrating from 1 (`version` did not exist) to 2
|
||||
migrationNotesFileName = 'migration-1-to-2.log';
|
||||
migrationNotesFileName = "migration-1-to-2.log";
|
||||
|
||||
migrateToTwo(currentSettings, transfers, changes, additions, removals, customHandlers);
|
||||
} else if (currentSettings.version === 2) {
|
||||
migrationNotesFileName = 'migration-2-to-3.log';
|
||||
migrationNotesFileName = "migration-2-to-3.log";
|
||||
|
||||
migrateToThree(currentSettings, transfers, changes, additions, removals, customHandlers);
|
||||
} else if (currentSettings.version === 3) {
|
||||
migrationNotesFileName = 'migration-3-to-4.log';
|
||||
migrationNotesFileName = "migration-3-to-4.log";
|
||||
|
||||
migrateToFour(currentSettings, transfers, changes, additions, removals, customHandlers);
|
||||
}
|
||||
@@ -535,7 +539,7 @@ export function migrateIfNecessary(): void {
|
||||
const [validPath, previousValue, transfered] = transferValue(currentSettings, transfer);
|
||||
|
||||
if (validPath && (!transfer.noteIf || transfer.noteIf(previousValue))) {
|
||||
migrationNotes.add(`[${transfered ? 'TRANSFER' : 'REMOVAL'}] ${transfer.note}`);
|
||||
migrationNotes.add(`[${transfered ? "TRANSFER" : "REMOVAL"}] ${transfer.note}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,17 +574,16 @@ export function migrateIfNecessary(): void {
|
||||
}
|
||||
|
||||
if (migrationNotesFileName && migrationNotes.size > 0) {
|
||||
migrationNotes.add(`For more details, see https://github.com/Koenkk/zigbee2mqtt/discussions/24198`);
|
||||
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');
|
||||
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();
|
||||
}
|
||||
|
||||
// don't throw, onboarding will validate at end of process
|
||||
settings.apply(currentSettings as unknown as Record<string, unknown>, false);
|
||||
settings.reRead();
|
||||
}
|
||||
|
||||
+98
-104
@@ -1,21 +1,23 @@
|
||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponse, Zigbee2MQTTResponseEndpoints, Zigbee2MQTTScene} from 'lib/types/api';
|
||||
import type * as zhc from 'zigbee-herdsman-converters';
|
||||
import type * as zhc from "zigbee-herdsman-converters";
|
||||
|
||||
import assert from 'node:assert';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponse, Zigbee2MQTTResponseEndpoints, Zigbee2MQTTScene} from "../types/api";
|
||||
|
||||
import equals from 'fast-deep-equal/es6';
|
||||
import humanizeDuration from 'humanize-duration';
|
||||
import assert from "node:assert";
|
||||
import {exec} from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import data from './data';
|
||||
import equals from "fast-deep-equal/es6";
|
||||
import humanizeDuration from "humanize-duration";
|
||||
|
||||
const BASE64_IMAGE_REGEX = new RegExp(`data:image/(?<extension>.+);base64,(?<data>.+)`);
|
||||
import data from "./data";
|
||||
|
||||
const BASE64_IMAGE_REGEX = /data:image\/(?<extension>.+);base64,(?<data>.+)/;
|
||||
|
||||
function pad(num: number): string {
|
||||
const norm = Math.floor(Math.abs(num));
|
||||
return (norm < 10 ? '0' : '') + norm;
|
||||
return (norm < 10 ? "0" : "") + norm;
|
||||
}
|
||||
|
||||
// construct a local ISO8601 string (instead of UTC-based)
|
||||
@@ -24,82 +26,69 @@ function pad(num: number): string {
|
||||
// - ISO8601 (local) = 2019-03-01T16:32:45.941+0100 (for timezone GMT+1)
|
||||
function toLocalISOString(date: Date): string {
|
||||
const tzOffset = -date.getTimezoneOffset();
|
||||
const plusOrMinus = tzOffset >= 0 ? '+' : '-';
|
||||
const plusOrMinus = tzOffset >= 0 ? "+" : "-";
|
||||
|
||||
return (
|
||||
date.getFullYear() +
|
||||
'-' +
|
||||
pad(date.getMonth() + 1) +
|
||||
'-' +
|
||||
pad(date.getDate()) +
|
||||
'T' +
|
||||
pad(date.getHours()) +
|
||||
':' +
|
||||
pad(date.getMinutes()) +
|
||||
':' +
|
||||
pad(date.getSeconds()) +
|
||||
plusOrMinus +
|
||||
pad(tzOffset / 60) +
|
||||
':' +
|
||||
pad(tzOffset % 60)
|
||||
);
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}${plusOrMinus}${pad(tzOffset / 60)}:${pad(tzOffset % 60)}`;
|
||||
}
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s[0].toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
async function getZigbee2MQTTVersion(includeCommitHash = true): Promise<{commitHash?: string; version: string}> {
|
||||
const git = await import('git-last-commit');
|
||||
const packageJSON = await import('../..' + '/package.json');
|
||||
export async function getZigbee2MQTTVersion(includeCommitHash = true): Promise<{commitHash?: string; version: string}> {
|
||||
const packageJSON = (await import("../../package.json", {with: {type: "json"}})).default;
|
||||
const version = packageJSON.version;
|
||||
let commitHash: string | undefined;
|
||||
|
||||
if (!includeCommitHash) {
|
||||
return {version: packageJSON.version, commitHash: undefined};
|
||||
return {version, commitHash};
|
||||
}
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const version = packageJSON.version;
|
||||
exec("git rev-parse --short=8 HEAD", (error, stdout) => {
|
||||
commitHash = stdout.trim();
|
||||
|
||||
git.getLastCommit((err: Error, commit: {shortHash: string}) => {
|
||||
let commitHash = undefined;
|
||||
|
||||
if (err) {
|
||||
if (error || commitHash === "") {
|
||||
try {
|
||||
commitHash = fs.readFileSync(path.join(__dirname, '..', '..', 'dist', '.hash'), 'utf-8');
|
||||
/* v8 ignore start */
|
||||
commitHash = fs.readFileSync(path.join(__dirname, "..", "..", "dist", ".hash"), "utf-8");
|
||||
} catch {
|
||||
commitHash = 'unknown';
|
||||
commitHash = "unknown";
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
} else {
|
||||
commitHash = commit.shortHash;
|
||||
}
|
||||
|
||||
commitHash = commitHash.trim();
|
||||
resolve({commitHash, version});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function getDependencyVersion(depend: string): Promise<{version: string}> {
|
||||
const packageJsonPath = require.resolve(`${depend}/package.json`);
|
||||
const version = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')).version;
|
||||
return {version};
|
||||
const packageJSON = (await import(`${depend}/package.json`, {with: {type: "json"}})).default;
|
||||
return {version: packageJSON.version};
|
||||
}
|
||||
|
||||
function formatDate(time: number, type: 'ISO_8601' | 'ISO_8601_local' | 'epoch' | 'relative'): string | number {
|
||||
if (type === 'ISO_8601') return new Date(time).toISOString();
|
||||
else if (type === 'ISO_8601_local') return toLocalISOString(new Date(time));
|
||||
else if (type === 'epoch') return time;
|
||||
else {
|
||||
// relative
|
||||
return humanizeDuration(Date.now() - time, {language: 'en', largest: 2, round: true}) + ' ago';
|
||||
function formatDate(time: number, type: "ISO_8601" | "ISO_8601_local" | "epoch" | "relative"): string | number {
|
||||
switch (type) {
|
||||
case "ISO_8601":
|
||||
// ISO8601 (UTC) = 2019-03-01T15:32:45.941Z
|
||||
return new Date(time).toISOString();
|
||||
|
||||
case "ISO_8601_local":
|
||||
// ISO8601 (local) = 2019-03-01T16:32:45.941+01:00 (for timezone GMT+1)
|
||||
return toLocalISOString(new Date(time));
|
||||
|
||||
case "epoch":
|
||||
return time;
|
||||
|
||||
default:
|
||||
// relative
|
||||
return `${humanizeDuration(Date.now() - time, {language: "en", largest: 2, round: true})} ago`;
|
||||
}
|
||||
}
|
||||
|
||||
function objectIsEmpty(object: object): boolean {
|
||||
// much faster than checking `Object.keys(object).length`
|
||||
for (const k in object) return false;
|
||||
// biome-ignore lint/style/useNamingConvention: bad detection
|
||||
for (const _k in object) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -135,27 +124,27 @@ function getResponse<T extends Zigbee2MQTTResponseEndpoints>(
|
||||
if (error !== undefined) {
|
||||
const response: Zigbee2MQTTResponse<T> = {
|
||||
data: {}, // always return an empty `data` payload on error
|
||||
status: 'error',
|
||||
status: "error",
|
||||
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) {
|
||||
if (typeof request === "object" && request.transaction !== undefined) {
|
||||
response.transaction = request.transaction;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
const response: Zigbee2MQTTResponse<T> = {
|
||||
data, // valid from error check
|
||||
status: "ok",
|
||||
};
|
||||
|
||||
if (typeof request === "object" && request.transaction !== undefined) {
|
||||
response.transaction = request.transaction;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
function parseJSON(value: string, fallback: string): KeyValue | string {
|
||||
@@ -182,7 +171,7 @@ function removeNullPropertiesFromObject(obj: KeyValue, ignoreKeys: string[] = []
|
||||
|
||||
if (value == null) {
|
||||
delete obj[key];
|
||||
} else if (typeof value === 'object') {
|
||||
} else if (typeof value === "object") {
|
||||
removeNullPropertiesFromObject(value, ignoreKeys);
|
||||
}
|
||||
}
|
||||
@@ -190,7 +179,7 @@ function removeNullPropertiesFromObject(obj: KeyValue, ignoreKeys: string[] = []
|
||||
|
||||
function toNetworkAddressHex(value: number): string {
|
||||
const hex = value.toString(16);
|
||||
return `0x${'0'.repeat(4 - hex.length)}${hex}`;
|
||||
return `0x${"0".repeat(4 - hex.length)}${hex}`;
|
||||
}
|
||||
|
||||
function charRange(start: string, stop: string): number[] {
|
||||
@@ -201,7 +190,7 @@ function charRange(start: string, stop: string): number[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
const controlCharacters = [...charRange('\u0000', '\u001F'), ...charRange('\u007f', '\u009F'), ...charRange('\ufdd0', '\ufdef')];
|
||||
const controlCharacters = [...charRange("\u0000", "\u001F"), ...charRange("\u007f", "\u009F"), ...charRange("\ufdd0", "\ufdef")];
|
||||
|
||||
function containsControlCharacter(str: string): boolean {
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
@@ -229,11 +218,11 @@ function getAllFiles(path_: string): string[] {
|
||||
function validateFriendlyName(name: string, throwFirstError = false): string[] {
|
||||
const errors = [];
|
||||
|
||||
if (name.length === 0) errors.push(`friendly_name must be at least 1 char long`);
|
||||
if (name.endsWith('/') || name.startsWith('/')) errors.push(`friendly_name is not allowed to end or start with /`);
|
||||
if (containsControlCharacter(name)) errors.push(`friendly_name is not allowed to contain control char`);
|
||||
if (name.length === 0) errors.push("friendly_name must be at least 1 char long");
|
||||
if (name.endsWith("/") || name.startsWith("/")) errors.push("friendly_name is not allowed to end or start with /");
|
||||
if (containsControlCharacter(name)) errors.push("friendly_name is not allowed to contain control char");
|
||||
if (name.match(/.*\/\d*$/)) errors.push(`Friendly name cannot end with a "/DIGIT" ('${name}')`);
|
||||
if (name.includes('#') || name.includes('+')) {
|
||||
if (name.includes("#") || name.includes("+")) {
|
||||
errors.push(`MQTT wildcard (+ and #) not allowed in friendly_name ('${name}')`);
|
||||
}
|
||||
|
||||
@@ -249,10 +238,7 @@ function sleep(seconds: number): Promise<void> {
|
||||
}
|
||||
|
||||
function sanitizeImageParameter(parameter: string): string {
|
||||
const replaceByDash = [/\?/g, /&/g, /[^a-z\d\- _./:]/gi];
|
||||
let sanitized = parameter;
|
||||
replaceByDash.forEach((r) => (sanitized = sanitized.replace(r, '-')));
|
||||
return sanitized;
|
||||
return parameter.replace(/\?|&|[^a-z\d\- _./:]/gi, "-");
|
||||
}
|
||||
|
||||
function isAvailabilityEnabledForEntity(entity: Device | Group, settings: Settings): boolean {
|
||||
@@ -261,22 +247,24 @@ function isAvailabilityEnabledForEntity(entity: Device | Group, settings: Settin
|
||||
}
|
||||
|
||||
if (entity.isGroup()) {
|
||||
return !entity.membersDevices().some((d) => !isAvailabilityEnabledForEntity(d, settings));
|
||||
for (const memberDevice of entity.membersDevices()) {
|
||||
if (!isAvailabilityEnabledForEntity(memberDevice, settings)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (entity.options.availability != null) {
|
||||
return !!entity.options.availability;
|
||||
}
|
||||
|
||||
if (!settings.availability.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return settings.availability.enabled;
|
||||
}
|
||||
|
||||
function isZHEndpoint(obj: unknown): obj is zh.Endpoint {
|
||||
return obj?.constructor.name.toLowerCase() === 'endpoint';
|
||||
return obj?.constructor.name.toLowerCase() === "endpoint";
|
||||
}
|
||||
|
||||
function flatten<Type>(arr: Type[][]): Type[] {
|
||||
@@ -288,7 +276,7 @@ function arrayUnique<Type>(arr: Type[]): Type[] {
|
||||
}
|
||||
|
||||
function isZHGroup(obj: unknown): obj is zh.Group {
|
||||
return obj?.constructor.name.toLowerCase() === 'group';
|
||||
return obj?.constructor.name.toLowerCase() === "group";
|
||||
}
|
||||
|
||||
const hours = (hours: number): number => 1000 * 60 * 60 * hours;
|
||||
@@ -308,9 +296,9 @@ async function publishLastSeen(
|
||||
* - In case reason !== messageEmitted, controller.ts will call this based on the zigbee-herdsman
|
||||
* lastSeenChanged event.
|
||||
*/
|
||||
const allow = data.reason !== 'messageEmitted' || (data.reason === 'messageEmitted' && allowMessageEmitted);
|
||||
if (settings.advanced.last_seen && settings.advanced.last_seen !== 'disable' && allow) {
|
||||
await publishEntityState(data.device, {}, 'lastSeenChanged');
|
||||
const allow = data.reason !== "messageEmitted" || (data.reason === "messageEmitted" && allowMessageEmitted);
|
||||
if (settings.advanced.last_seen && settings.advanced.last_seen !== "disable" && allow) {
|
||||
await publishEntityState(data.device, {}, "lastSeenChanged");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,31 +313,37 @@ function filterProperties(filter: string[] | undefined, data: KeyValue): void {
|
||||
}
|
||||
|
||||
export function isNumericExpose(expose: zhc.Expose): expose is zhc.Numeric {
|
||||
return expose?.type === 'numeric';
|
||||
return expose?.type === "numeric";
|
||||
}
|
||||
|
||||
export function assertEnumExpose(expose: zhc.Expose): asserts expose is zhc.Enum {
|
||||
assert(expose?.type === 'enum');
|
||||
assert(expose?.type === "enum");
|
||||
}
|
||||
|
||||
export function assertNumericExpose(expose: zhc.Expose): asserts expose is zhc.Numeric {
|
||||
assert(expose?.type === 'numeric');
|
||||
assert(expose?.type === "numeric");
|
||||
}
|
||||
|
||||
export function assertBinaryExpose(expose: zhc.Expose): asserts expose is zhc.Binary {
|
||||
assert(expose?.type === 'binary');
|
||||
assert(expose?.type === "binary");
|
||||
}
|
||||
|
||||
export function isEnumExpose(expose: zhc.Expose): expose is zhc.Enum {
|
||||
return expose?.type === 'enum';
|
||||
return expose?.type === "enum";
|
||||
}
|
||||
|
||||
export function isBinaryExpose(expose: zhc.Expose): expose is zhc.Binary {
|
||||
return expose?.type === 'binary';
|
||||
return expose?.type === "binary";
|
||||
}
|
||||
|
||||
export function isLightExpose(expose: zhc.Expose): expose is zhc.Light {
|
||||
return expose.type === 'light';
|
||||
return expose.type === "light";
|
||||
}
|
||||
|
||||
export function assertString(value: unknown, property: string): asserts value is string {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(`${property} is not a string, got ${typeof value} (${value})`);
|
||||
}
|
||||
}
|
||||
|
||||
function getScenes(entity: zh.Endpoint | zh.Group): Zigbee2MQTTScene[] {
|
||||
@@ -359,9 +353,9 @@ function getScenes(entity: zh.Endpoint | zh.Group): Zigbee2MQTTScene[] {
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
for (const [key, data] of Object.entries(endpoint.meta?.scenes || {})) {
|
||||
const split = key.split('_');
|
||||
const sceneID = parseInt(split[0], 10);
|
||||
const sceneGroupID = parseInt(split[1], 10);
|
||||
const split = key.split("_");
|
||||
const sceneID = Number.parseInt(split[0], 10);
|
||||
const sceneGroupID = Number.parseInt(split[1], 10);
|
||||
if (sceneGroupID === groupID) {
|
||||
scenes[sceneID] = {id: sceneID, name: (data as KeyValue).name || `Scene ${sceneID}`};
|
||||
}
|
||||
@@ -372,7 +366,7 @@ function getScenes(entity: zh.Endpoint | zh.Group): Zigbee2MQTTScene[] {
|
||||
}
|
||||
|
||||
function deviceNotCoordinator(device: zh.Device): boolean {
|
||||
return device.type !== 'Coordinator';
|
||||
return device.type !== "Coordinator";
|
||||
}
|
||||
|
||||
function matchBase64File(value: string | undefined): {extension: string; data: string} | false {
|
||||
@@ -387,11 +381,11 @@ function matchBase64File(value: string | undefined): {extension: string; data: s
|
||||
}
|
||||
|
||||
function saveBase64DeviceIcon(base64Match: {extension: string; data: string}): string {
|
||||
const md5Hash = crypto.createHash('md5').update(base64Match.data).digest('hex');
|
||||
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'});
|
||||
fs.writeFileSync(file, base64Match.data, {encoding: "base64"});
|
||||
return fileSettings;
|
||||
}
|
||||
|
||||
|
||||
+6
-6
@@ -1,8 +1,8 @@
|
||||
import assert from 'node:assert';
|
||||
import fs from 'node:fs';
|
||||
import assert from "node:assert";
|
||||
import fs from "node:fs";
|
||||
|
||||
import equals from 'fast-deep-equal/es6';
|
||||
import yaml, {YAMLException} from 'js-yaml';
|
||||
import equals from "fast-deep-equal/es6";
|
||||
import yaml, {YAMLException} from "js-yaml";
|
||||
|
||||
export class YAMLFileException extends YAMLException {
|
||||
file: string;
|
||||
@@ -10,7 +10,7 @@ export class YAMLFileException extends YAMLException {
|
||||
constructor(error: YAMLException, file: string) {
|
||||
super(error.reason, error.mark);
|
||||
|
||||
this.name = 'YAMLFileException';
|
||||
this.name = "YAMLFileException";
|
||||
this.cause = error.cause;
|
||||
this.message = error.message;
|
||||
this.stack = error.stack;
|
||||
@@ -20,7 +20,7 @@ export class YAMLFileException extends YAMLException {
|
||||
|
||||
function read(file: string): KeyValue {
|
||||
try {
|
||||
const result = yaml.load(fs.readFileSync(file, 'utf8'));
|
||||
const result = yaml.load(fs.readFileSync(file, "utf8"));
|
||||
assert(result instanceof Object);
|
||||
return result as KeyValue;
|
||||
} catch (error) {
|
||||
|
||||
+112
-97
@@ -1,48 +1,50 @@
|
||||
import type {Events as ZHEvents} from 'zigbee-herdsman';
|
||||
import type {Events as ZHEvents} from "zigbee-herdsman";
|
||||
import type {StartResult} from "zigbee-herdsman/dist/adapter/tstype";
|
||||
|
||||
import {randomInt} from 'node:crypto';
|
||||
import {randomInt} from "node:crypto";
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import bind from "bind-decorator";
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
|
||||
import {Controller} from 'zigbee-herdsman';
|
||||
import {Controller} from "zigbee-herdsman";
|
||||
|
||||
import Device from './model/device';
|
||||
import Group from './model/group';
|
||||
import data from './util/data';
|
||||
import logger from './util/logger';
|
||||
import * as settings from './util/settings';
|
||||
import utils from './util/utils';
|
||||
import Device from "./model/device";
|
||||
import Group from "./model/group";
|
||||
import data from "./util/data";
|
||||
import logger from "./util/logger";
|
||||
import * as settings from "./util/settings";
|
||||
import utils from "./util/utils";
|
||||
|
||||
const entityIDRegex = new RegExp(`^(.+?)(?:/([^/]+))?$`);
|
||||
const entityIDRegex = /^(.+?)(?:\/([^/]+))?$/;
|
||||
|
||||
export default class Zigbee {
|
||||
// @ts-expect-error initialized in start
|
||||
private herdsman: Controller;
|
||||
private eventBus: EventBus;
|
||||
private groupLookup: {[s: number]: Group} = {};
|
||||
private deviceLookup: {[s: string]: Device} = {};
|
||||
private groupLookup = new Map<number /* group ID */, Group>();
|
||||
private deviceLookup = new Map<string /* IEEE address */, Device>();
|
||||
private coordinatorIeeeAddr!: string;
|
||||
|
||||
constructor(eventBus: EventBus) {
|
||||
this.eventBus = eventBus;
|
||||
}
|
||||
|
||||
async start(): Promise<'reset' | 'resumed' | 'restored'> {
|
||||
const infoHerdsman = await utils.getDependencyVersion('zigbee-herdsman');
|
||||
async start(): Promise<StartResult> {
|
||||
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: panId === 'GENERATE' ? this.generatePanID() : panId,
|
||||
extendedPanID: extPanId === 'GENERATE' ? this.generateExtPanID() : extPanId,
|
||||
panID: panId === "GENERATE" ? this.generatePanID() : panId,
|
||||
extendedPanID: extPanId === "GENERATE" ? this.generateExtPanID() : extPanId,
|
||||
channelList: [settings.get().advanced.channel],
|
||||
networkKey: networkKey === 'GENERATE' ? this.generateNetworkKey() : networkKey,
|
||||
networkKey: networkKey === "GENERATE" ? this.generateNetworkKey() : networkKey,
|
||||
},
|
||||
databasePath: data.joinPath('database.db'),
|
||||
databaseBackupPath: data.joinPath('database.db.backup'),
|
||||
backupPath: data.joinPath('coordinator_backup.json'),
|
||||
databasePath: data.joinPath("database.db"),
|
||||
databaseBackupPath: data.joinPath("database.db.backup"),
|
||||
backupPath: data.joinPath("coordinator_backup.json"),
|
||||
serialPort: {
|
||||
baudRate: settings.get().serial.baudrate,
|
||||
rtscts: settings.get().serial.rtscts,
|
||||
@@ -63,35 +65,39 @@ export default class Zigbee {
|
||||
`Using zigbee-herdsman with settings: '${stringify(JSON.stringify(herdsmanSettings).replaceAll(JSON.stringify(herdsmanSettings.network.networkKey), '"HIDDEN"'))}'`,
|
||||
);
|
||||
|
||||
let startResult;
|
||||
let startResult: StartResult;
|
||||
try {
|
||||
this.herdsman = new Controller(herdsmanSettings);
|
||||
startResult = await this.herdsman.start();
|
||||
} catch (error) {
|
||||
logger.error(`Error while starting zigbee-herdsman`);
|
||||
logger.error("Error while starting zigbee-herdsman");
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.coordinatorIeeeAddr = this.herdsman.getDevicesByType("Coordinator")[0].ieeeAddr;
|
||||
await this.resolveDevicesDefinitions();
|
||||
|
||||
this.herdsman.on('adapterDisconnected', () => this.eventBus.emitAdapterDisconnected());
|
||||
this.herdsman.on('lastSeenChanged', (data: ZHEvents.LastSeenChangedPayload) => {
|
||||
this.herdsman.on("adapterDisconnected", () => this.eventBus.emitAdapterDisconnected());
|
||||
this.herdsman.on("lastSeenChanged", (data: ZHEvents.LastSeenChangedPayload) => {
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
this.eventBus.emitLastSeenChanged({device: this.resolveDevice(data.device.ieeeAddr)!, reason: data.reason});
|
||||
});
|
||||
this.herdsman.on('permitJoinChanged', (data: ZHEvents.PermitJoinChangedPayload) => {
|
||||
this.herdsman.on("permitJoinChanged", (data: ZHEvents.PermitJoinChangedPayload) => {
|
||||
this.eventBus.emitPermitJoinChanged(data);
|
||||
});
|
||||
this.herdsman.on('deviceNetworkAddressChanged', (data: ZHEvents.DeviceNetworkAddressChangedPayload) => {
|
||||
this.herdsman.on("deviceNetworkAddressChanged", (data: ZHEvents.DeviceNetworkAddressChangedPayload) => {
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
const device = this.resolveDevice(data.device.ieeeAddr)!;
|
||||
logger.debug(`Device '${device.name}' changed network address`);
|
||||
this.eventBus.emitDeviceNetworkAddressChanged({device});
|
||||
});
|
||||
this.herdsman.on('deviceAnnounce', (data: ZHEvents.DeviceAnnouncePayload) => {
|
||||
this.herdsman.on("deviceAnnounce", (data: ZHEvents.DeviceAnnouncePayload) => {
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
const device = this.resolveDevice(data.device.ieeeAddr)!;
|
||||
logger.debug(`Device '${device.name}' announced itself`);
|
||||
this.eventBus.emitDeviceAnnounce({device});
|
||||
});
|
||||
this.herdsman.on('deviceInterview', async (data: ZHEvents.DeviceInterviewPayload) => {
|
||||
this.herdsman.on("deviceInterview", async (data: ZHEvents.DeviceInterviewPayload) => {
|
||||
const device = this.resolveDevice(data.device.ieeeAddr);
|
||||
/* v8 ignore next */ if (!device) return; // Prevent potential race
|
||||
await device.resolveDefinition();
|
||||
@@ -99,29 +105,29 @@ export default class Zigbee {
|
||||
this.logDeviceInterview(d);
|
||||
this.eventBus.emitDeviceInterview(d);
|
||||
});
|
||||
this.herdsman.on('deviceJoined', async (data: ZHEvents.DeviceJoinedPayload) => {
|
||||
this.herdsman.on("deviceJoined", async (data: ZHEvents.DeviceJoinedPayload) => {
|
||||
const device = this.resolveDevice(data.device.ieeeAddr);
|
||||
/* v8 ignore next */ if (!device) return; // Prevent potential race
|
||||
await device.resolveDefinition();
|
||||
logger.info(`Device '${device.name}' joined`);
|
||||
this.eventBus.emitDeviceJoined({device});
|
||||
});
|
||||
this.herdsman.on('deviceLeave', (data: ZHEvents.DeviceLeavePayload) => {
|
||||
this.herdsman.on("deviceLeave", (data: ZHEvents.DeviceLeavePayload) => {
|
||||
const name = settings.getDevice(data.ieeeAddr)?.friendly_name || data.ieeeAddr;
|
||||
logger.warning(`Device '${name}' left the network`);
|
||||
this.eventBus.emitDeviceLeave({ieeeAddr: data.ieeeAddr, name});
|
||||
this.eventBus.emitDeviceLeave({ieeeAddr: data.ieeeAddr, name, device: this.deviceLookup.get(data.ieeeAddr)});
|
||||
});
|
||||
this.herdsman.on('message', async (data: ZHEvents.MessagePayload) => {
|
||||
this.herdsman.on("message", async (data: ZHEvents.MessagePayload) => {
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
const device = this.resolveDevice(data.device.ieeeAddr)!;
|
||||
await device.resolveDefinition();
|
||||
logger.debug(
|
||||
() =>
|
||||
`Received Zigbee message from '${device.name}', type '${data.type}', ` +
|
||||
`cluster '${data.cluster}', data '${stringify(data.data)}' from endpoint ${data.endpoint.ID}` +
|
||||
(data['groupID'] !== undefined ? ` with groupID ${data.groupID}` : ``) +
|
||||
(device.zh.type === 'Coordinator' ? `, ignoring since it is from coordinator` : ``),
|
||||
);
|
||||
if (device.zh.type === 'Coordinator') return;
|
||||
logger.debug(() => {
|
||||
const groupId = data.groupID !== undefined ? ` with groupID ${data.groupID}` : "";
|
||||
const fromCoord = device.zh.type === "Coordinator" ? ", ignoring since it is from coordinator" : "";
|
||||
|
||||
return `Received Zigbee message from '${device.name}', type '${data.type}', cluster '${data.cluster}', data '${stringify(data.data)}' from endpoint ${data.endpoint.ID}${groupId}${fromCoord}`;
|
||||
});
|
||||
if (device.zh.type === "Coordinator") return;
|
||||
this.eventBus.emitDeviceMessage({...data, device});
|
||||
});
|
||||
|
||||
@@ -157,20 +163,19 @@ export default class Zigbee {
|
||||
|
||||
private logDeviceInterview(data: eventdata.DeviceInterview): void {
|
||||
const name = data.device.name;
|
||||
if (data.status === 'successful') {
|
||||
if (data.status === "successful") {
|
||||
logger.info(`Successfully interviewed '${name}', device has successfully been paired`);
|
||||
|
||||
if (data.device.isSupported) {
|
||||
// biome-ignore lint/style/noNonNullAssertion: valid from `isSupported`
|
||||
const {vendor, description, model} = data.device.definition!;
|
||||
logger.info(`Device '${name}' is supported, identified as: ${vendor} ${description} (${model})`);
|
||||
} else {
|
||||
logger.warning(
|
||||
`Device '${name}' with Zigbee model '${data.device.zh.modelID}' and manufacturer name ` +
|
||||
`'${data.device.zh.manufacturerName}' is NOT supported, ` +
|
||||
`please follow https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html`,
|
||||
`Device '${name}' with Zigbee model '${data.device.zh.modelID}' and manufacturer name '${data.device.zh.manufacturerName}' is NOT supported, please follow https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html`,
|
||||
);
|
||||
}
|
||||
} else if (data.status === 'failed') {
|
||||
} else if (data.status === "failed") {
|
||||
logger.error(`Failed to interview '${name}', device has not successfully been paired`);
|
||||
} else {
|
||||
// data.status === 'started'
|
||||
@@ -180,19 +185,19 @@ export default class Zigbee {
|
||||
|
||||
private generateNetworkKey(): number[] {
|
||||
const key = Array.from({length: 16}, () => randomInt(256));
|
||||
settings.set(['advanced', 'network_key'], key);
|
||||
settings.set(["advanced", "network_key"], key);
|
||||
return key;
|
||||
}
|
||||
|
||||
private generateExtPanID(): number[] {
|
||||
const key = Array.from({length: 8}, () => randomInt(256));
|
||||
settings.set(['advanced', 'ext_pan_id'], key);
|
||||
settings.set(["advanced", "ext_pan_id"], key);
|
||||
return key;
|
||||
}
|
||||
|
||||
private generatePanID(): number {
|
||||
const panID = randomInt(1, 0xffff - 1);
|
||||
settings.set(['advanced', 'pan_id'], panID);
|
||||
settings.set(["advanced", "pan_id"], panID);
|
||||
return panID;
|
||||
}
|
||||
|
||||
@@ -210,6 +215,7 @@ export default class Zigbee {
|
||||
|
||||
async coordinatorCheck(): Promise<{missingRouters: Device[]}> {
|
||||
const check = await this.herdsman.coordinatorCheck();
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
return {missingRouters: check.missingRouters.map((d) => this.resolveDevice(d.ieeeAddr)!)};
|
||||
}
|
||||
|
||||
@@ -218,9 +224,9 @@ export default class Zigbee {
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
logger.info('Stopping zigbee-herdsman...');
|
||||
logger.info("Stopping zigbee-herdsman...");
|
||||
await this.herdsman.stop();
|
||||
logger.info('Stopped zigbee-herdsman');
|
||||
logger.info("Stopped zigbee-herdsman");
|
||||
}
|
||||
|
||||
getPermitJoin(): boolean {
|
||||
@@ -233,67 +239,69 @@ export default class Zigbee {
|
||||
|
||||
async permitJoin(time: number, device?: Device): Promise<void> {
|
||||
if (time > 0) {
|
||||
logger.info(`Zigbee: allowing new devices to join${device ? ` via ${device.name}` : ''}.`);
|
||||
logger.info(`Zigbee: allowing new devices to join${device ? ` via ${device.name}` : ""}.`);
|
||||
} else {
|
||||
logger.info('Zigbee: disabling joining new devices.');
|
||||
logger.info("Zigbee: disabling joining new devices.");
|
||||
}
|
||||
|
||||
await this.herdsman.permitJoin(time, device?.zh);
|
||||
}
|
||||
|
||||
async resolveDevicesDefinitions(ignoreCache: boolean = false): Promise<void> {
|
||||
async resolveDevicesDefinitions(ignoreCache = false): Promise<void> {
|
||||
for (const device of this.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
await device.resolveDefinition(ignoreCache);
|
||||
}
|
||||
}
|
||||
|
||||
@bind private resolveDevice(ieeeAddr: string): Device | undefined {
|
||||
if (!this.deviceLookup[ieeeAddr]) {
|
||||
if (!this.deviceLookup.has(ieeeAddr)) {
|
||||
const device = this.herdsman.getDeviceByIeeeAddr(ieeeAddr);
|
||||
if (device) {
|
||||
this.deviceLookup[ieeeAddr] = new Device(device);
|
||||
this.deviceLookup.set(ieeeAddr, new Device(device));
|
||||
}
|
||||
}
|
||||
|
||||
const device = this.deviceLookup[ieeeAddr];
|
||||
const device = this.deviceLookup.get(ieeeAddr);
|
||||
if (device && !device.zh.isDeleted) {
|
||||
device.ensureInSettings();
|
||||
return device;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveGroup(groupID: number): Group {
|
||||
private resolveGroup(groupID: number): Group | undefined {
|
||||
const group = this.herdsman.getGroupByID(Number(groupID));
|
||||
if (group && !this.groupLookup[groupID]) {
|
||||
this.groupLookup[groupID] = new Group(group, this.resolveDevice);
|
||||
if (group && !this.groupLookup.has(groupID)) {
|
||||
this.groupLookup.set(groupID, new Group(group, this.resolveDevice));
|
||||
}
|
||||
|
||||
return this.groupLookup[groupID];
|
||||
return this.groupLookup.get(groupID);
|
||||
}
|
||||
|
||||
resolveEntity(key: string | number | zh.Device): Device | Group | undefined {
|
||||
if (typeof key === 'object') {
|
||||
if (typeof key === "object") {
|
||||
return this.resolveDevice(key.ieeeAddr);
|
||||
} else if (typeof key === 'string' && key.toLowerCase() === 'coordinator') {
|
||||
return this.resolveDevice(this.herdsman.getDevicesByType('Coordinator')[0].ieeeAddr);
|
||||
} else {
|
||||
const settingsDevice = settings.getDevice(key.toString());
|
||||
}
|
||||
|
||||
if (settingsDevice) {
|
||||
return this.resolveDevice(settingsDevice.ID);
|
||||
}
|
||||
if (typeof key === "string" && (key.toLowerCase() === "coordinator" || key === this.coordinatorIeeeAddr)) {
|
||||
return this.resolveDevice(this.coordinatorIeeeAddr);
|
||||
}
|
||||
|
||||
const groupSettings = settings.getGroup(key);
|
||||
const settingsDevice = settings.getDevice(key.toString());
|
||||
|
||||
if (groupSettings) {
|
||||
const group = this.resolveGroup(groupSettings.ID);
|
||||
// If group does not exist, create it (since it's already in configuration.yaml)
|
||||
return group ? group : this.createGroup(groupSettings.ID);
|
||||
}
|
||||
if (settingsDevice) {
|
||||
return this.resolveDevice(settingsDevice.ID);
|
||||
}
|
||||
|
||||
const groupSettings = settings.getGroup(key);
|
||||
|
||||
if (groupSettings) {
|
||||
const group = this.resolveGroup(groupSettings.ID);
|
||||
// If group does not exist, create it (since it's already in configuration.yaml)
|
||||
return group ? group : this.createGroup(groupSettings.ID);
|
||||
}
|
||||
}
|
||||
|
||||
resolveEntityAndEndpoint(ID: string): {ID: string; entity: Device | Group | undefined; endpointID?: string; endpoint?: zh.Endpoint} {
|
||||
resolveEntityAndEndpoint(id: string): {ID: string; entity: Device | Group | undefined; endpointID?: string; endpoint?: zh.Endpoint} {
|
||||
// This function matches the following entity formats:
|
||||
// device_name (just device name)
|
||||
// device_name/ep_name (device name and endpoint numeric ID or name)
|
||||
@@ -301,14 +309,14 @@ export default class Zigbee {
|
||||
// device/name/ep_name (device name with slashes, and endpoint numeric ID or name)
|
||||
|
||||
// The function tries to find an exact match first
|
||||
let entityName = ID;
|
||||
let deviceOrGroup = this.resolveEntity(ID);
|
||||
let entityName = id;
|
||||
let deviceOrGroup = this.resolveEntity(id);
|
||||
let endpointNameOrID: string | undefined;
|
||||
|
||||
// If exact match did not happen, try matching a device_name/endpoint pattern
|
||||
if (!deviceOrGroup) {
|
||||
// First split the input token by the latest slash
|
||||
const match = ID.match(entityIDRegex);
|
||||
const match = id.match(entityIDRegex);
|
||||
|
||||
if (match) {
|
||||
// Get the resulting IDs from the match
|
||||
@@ -327,7 +335,7 @@ export default class Zigbee {
|
||||
}
|
||||
|
||||
firstCoordinatorEndpoint(): zh.Endpoint {
|
||||
return this.herdsman.getDevicesByType('Coordinator')[0].endpoints[0];
|
||||
return this.herdsman.getDevicesByType("Coordinator")[0].endpoints[0];
|
||||
}
|
||||
|
||||
*devicesAndGroupsIterator(
|
||||
@@ -335,26 +343,31 @@ export default class Zigbee {
|
||||
groupPredicate?: (value: zh.Group) => boolean,
|
||||
): Generator<Device | Group> {
|
||||
for (const device of this.herdsman.getDevicesIterator(devicePredicate)) {
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
yield this.resolveDevice(device.ieeeAddr)!;
|
||||
}
|
||||
|
||||
for (const group of this.herdsman.getGroupsIterator(groupPredicate)) {
|
||||
yield this.resolveGroup(group.groupID);
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
yield this.resolveGroup(group.groupID)!;
|
||||
}
|
||||
}
|
||||
|
||||
*groupsIterator(predicate?: (value: zh.Group) => boolean): Generator<Group> {
|
||||
for (const group of this.herdsman.getGroupsIterator(predicate)) {
|
||||
yield this.resolveGroup(group.groupID);
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
yield this.resolveGroup(group.groupID)!;
|
||||
}
|
||||
}
|
||||
|
||||
*devicesIterator(predicate?: (value: zh.Device) => boolean): Generator<Device> {
|
||||
for (const device of this.herdsman.getDevicesIterator(predicate)) {
|
||||
// biome-ignore lint/style/noNonNullAssertion: assumed valid
|
||||
yield this.resolveDevice(device.ieeeAddr)!;
|
||||
}
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/useAwait: API
|
||||
@bind private async acceptJoiningDeviceHandler(ieeeAddr: string): Promise<boolean> {
|
||||
// If passlist is set, all devices not on passlist will be rejected to join the network
|
||||
const passlist = settings.get().passlist;
|
||||
@@ -363,21 +376,22 @@ export default class Zigbee {
|
||||
if (passlist.includes(ieeeAddr)) {
|
||||
logger.info(`Accepting joining device which is on passlist '${ieeeAddr}'`);
|
||||
return true;
|
||||
} else {
|
||||
logger.info(`Rejecting joining not in passlist device '${ieeeAddr}'`);
|
||||
return false;
|
||||
}
|
||||
} else if (blocklist.length > 0) {
|
||||
|
||||
logger.info(`Rejecting joining not in passlist device '${ieeeAddr}'`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (blocklist.length > 0) {
|
||||
if (blocklist.includes(ieeeAddr)) {
|
||||
logger.info(`Rejecting joining device which is on blocklist '${ieeeAddr}'`);
|
||||
return false;
|
||||
} else {
|
||||
logger.info(`Accepting joining not in blocklist device '${ieeeAddr}'`);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
return true;
|
||||
|
||||
logger.info(`Accepting joining not in blocklist device '${ieeeAddr}'`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async touchlinkFactoryResetFirst(): Promise<boolean> {
|
||||
@@ -400,9 +414,10 @@ export default class Zigbee {
|
||||
return await this.herdsman.touchlinkScan();
|
||||
}
|
||||
|
||||
createGroup(ID: number): Group {
|
||||
this.herdsman.createGroup(ID);
|
||||
return this.resolveGroup(ID);
|
||||
createGroup(id: number): Group {
|
||||
this.herdsman.createGroup(id);
|
||||
// biome-ignore lint/style/noNonNullAssertion: just created
|
||||
return this.resolveGroup(id)!;
|
||||
}
|
||||
|
||||
deviceByNetworkAddress(networkAddress: number): Device | undefined {
|
||||
@@ -410,7 +425,7 @@ export default class Zigbee {
|
||||
return device && this.resolveDevice(device.ieeeAddr);
|
||||
}
|
||||
|
||||
groupByID(ID: number): Group {
|
||||
return this.resolveGroup(ID);
|
||||
groupByID(id: number): Group | undefined {
|
||||
return this.resolveGroup(id);
|
||||
}
|
||||
}
|
||||
|
||||
+32
-34
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "zigbee2mqtt",
|
||||
"version": "2.1.1",
|
||||
"version": "2.4.0",
|
||||
"description": "Zigbee to MQTT bridge using Zigbee-herdsman",
|
||||
"main": "index.js",
|
||||
"packageManager": "pnpm@9.15.4",
|
||||
"types": "dist/types/api.d.ts",
|
||||
"packageManager": "pnpm@10.4.1",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Koenkk/zigbee2mqtt.git"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18 || ^20 || ^22 || ^23"
|
||||
"node": "^20 || ^22 || ^24"
|
||||
},
|
||||
"keywords": [
|
||||
"xiaomi",
|
||||
@@ -22,10 +23,9 @@
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc && node index.js writehash",
|
||||
"build-watch": "tsc --watch",
|
||||
"eslint": "eslint --max-warnings=0",
|
||||
"pretty:write": "prettier --write .",
|
||||
"pretty:check": "prettier --check .",
|
||||
"build:watch": "tsc --watch",
|
||||
"check": "biome check",
|
||||
"check:w": "biome check --write",
|
||||
"start": "node index.js",
|
||||
"test": "vitest run --config ./test/vitest.config.mts",
|
||||
"test:coverage": "vitest run --config ./test/vitest.config.mts --coverage",
|
||||
@@ -43,61 +43,59 @@
|
||||
"ajv": "^8.17.1",
|
||||
"bind-decorator": "^1.0.11",
|
||||
"debounce": "^2.2.0",
|
||||
"express-static-gzip": "^2.2.0",
|
||||
"express-static-gzip": "^3.0.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"finalhandler": "^1.3.1",
|
||||
"git-last-commit": "^1.0.1",
|
||||
"finalhandler": "^2.1.0",
|
||||
"humanize-duration": "^3.32.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||
"jszip": "^3.10.1",
|
||||
"moment": "^2.30.1",
|
||||
"mqtt": "^5.10.3",
|
||||
"mqtt": "^5.11.0",
|
||||
"object-assign-deep": "^0.4.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"semver": "^7.7.0",
|
||||
"semver": "^7.7.1",
|
||||
"source-map-support": "^0.5.21",
|
||||
"throttleit": "^2.1.0",
|
||||
"winston": "^3.17.0",
|
||||
"winston-syslog": "^2.7.1",
|
||||
"winston-transport": "^4.9.0",
|
||||
"ws": "^8.18.0",
|
||||
"zigbee-herdsman": "3.2.5",
|
||||
"zigbee-herdsman-converters": "21.31.0",
|
||||
"zigbee2mqtt-frontend": "0.9.4"
|
||||
"ws": "^8.18.1",
|
||||
"zigbee-herdsman": "4.1.0",
|
||||
"zigbee-herdsman-converters": "23.53.0",
|
||||
"zigbee2mqtt-frontend": "0.9.13",
|
||||
"zigbee2mqtt-windfront": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/core": "^0.11.0",
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.4.1",
|
||||
"@types/eslint__js": "^8.42.3",
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@types/finalhandler": "^1.2.3",
|
||||
"@types/humanize-duration": "^3.27.4",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.13.0",
|
||||
"@types/node": "^22.14.1",
|
||||
"@types/object-assign-deep": "^0.4.3",
|
||||
"@types/readable-stream": "4.0.18",
|
||||
"@types/sd-notify": "^2.8.2",
|
||||
"@types/readable-stream": "4.0.20",
|
||||
"@types/serve-static": "^1.15.7",
|
||||
"@types/ws": "8.5.14",
|
||||
"@vitest/coverage-v8": "^3.0.4",
|
||||
"eslint": "^9.19.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"prettier": "^3.4.2",
|
||||
"@types/ws": "8.18.1",
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
"tmp": "^0.2.3",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.22.0",
|
||||
"vitest": "^3.0.4"
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.1.1"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"zigbee-herdsman": "$zigbee-herdsman"
|
||||
}
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"@biomejs/biome",
|
||||
"@serialport/bindings-cpp",
|
||||
"esbuild",
|
||||
"unix-dgram"
|
||||
]
|
||||
},
|
||||
"bin": {
|
||||
"zigbee2mqtt": "cli.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"sd-notify": "^2.8.0"
|
||||
"unix-dgram": "^2.0.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+638
-1463
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const process = require('process');
|
||||
const {execSync} = require('child_process');
|
||||
const zhc = require('zigbee-herdsman-converters');
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const process = require("node:process");
|
||||
const {execSync} = require("node:child_process");
|
||||
const zhc = require("zigbee-herdsman-converters");
|
||||
|
||||
const z2mTillVersion = process.argv[2];
|
||||
const zhcTillVersion = process.argv[3];
|
||||
@@ -12,24 +12,24 @@ const frontendTillVersion = process.argv[5];
|
||||
const changelogs = [
|
||||
{
|
||||
tillVersion: z2mTillVersion,
|
||||
project: 'koenkk/zigbee2mqtt',
|
||||
contents: fs.readFileSync(path.join(__dirname, '..', 'CHANGELOG.md'), 'utf-8').split('\n'),
|
||||
project: "koenkk/zigbee2mqtt",
|
||||
contents: fs.readFileSync(path.join(__dirname, "..", "CHANGELOG.md"), "utf-8").split("\n"),
|
||||
},
|
||||
{
|
||||
tillVersion: zhcTillVersion,
|
||||
project: 'koenkk/zigbee-herdsman-converters',
|
||||
contents: fs.readFileSync(path.join(__dirname, '..', 'node_modules', 'zigbee-herdsman-converters', 'CHANGELOG.md'), 'utf-8').split('\n'),
|
||||
project: "koenkk/zigbee-herdsman-converters",
|
||||
contents: fs.readFileSync(path.join(__dirname, "..", "node_modules", "zigbee-herdsman-converters", "CHANGELOG.md"), "utf-8").split("\n"),
|
||||
},
|
||||
{
|
||||
tillVersion: zhTillVersion,
|
||||
project: 'koenkk/zigbee-herdsman',
|
||||
contents: fs.readFileSync(path.join(__dirname, '..', 'node_modules', 'zigbee-herdsman', 'CHANGELOG.md'), 'utf-8').split('\n'),
|
||||
project: "koenkk/zigbee-herdsman",
|
||||
contents: fs.readFileSync(path.join(__dirname, "..", "node_modules", "zigbee-herdsman", "CHANGELOG.md"), "utf-8").split("\n"),
|
||||
},
|
||||
{
|
||||
tillVersion: frontendTillVersion,
|
||||
project: 'nurikk/zigbee2mqtt-frontend',
|
||||
project: "nurikk/zigbee2mqtt-frontend",
|
||||
isFrontend: true,
|
||||
contents: fs.readFileSync(path.join(__dirname, '..', 'node_modules', 'zigbee2mqtt-frontend', 'CHANGELOG.md'), 'utf-8').split('\n'),
|
||||
contents: fs.readFileSync(path.join(__dirname, "..", "node_modules", "zigbee2mqtt-frontend", "CHANGELOG.md"), "utf-8").split("\n"),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -44,12 +44,13 @@ const changeRe = [
|
||||
const frontendChangeRe = /^\* (\*\*.+:\*\* )()?(.+) \(\[.+\]\(https:.+\/()(.+)\)\)(.+)?$/;
|
||||
|
||||
let commitUserLookup = {};
|
||||
const commitUserFile = path.join(__dirname, 'commit-user-lookup.json');
|
||||
const commitUserFile = path.join(__dirname, "commit-user-lookup.json");
|
||||
if (fs.existsSync(commitUserFile)) {
|
||||
commitUserLookup = JSON.parse(fs.readFileSync(commitUserFile, 'utf8'));
|
||||
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) {
|
||||
@@ -60,15 +61,15 @@ for (const changelog of changelogs) {
|
||||
if (releaseMatch[1] === changelog.tillVersion) {
|
||||
break;
|
||||
}
|
||||
} else if (line === '### Features') {
|
||||
context = 'features';
|
||||
} else if (line === '### Bug Fixes') {
|
||||
context = 'fixes';
|
||||
} else if (line.startsWith('* **ignore:**')) {
|
||||
continue;
|
||||
} else if (line === "### Features") {
|
||||
context = "features";
|
||||
} else if (line === "### Bug Fixes") {
|
||||
context = "fixes";
|
||||
} else if (line.startsWith("* **ignore:**")) {
|
||||
// continue;
|
||||
} else if (changeMatch) {
|
||||
let localContext = changelog.isFrontend ? 'frontend' : changeMatch[2] ? changeMatch[2] : context;
|
||||
if (!changes[localContext]) localContext = 'error';
|
||||
let localContext = changelog.isFrontend ? "frontend" : changeMatch[2] ? changeMatch[2] : context;
|
||||
if (!changes[localContext]) localContext = "error";
|
||||
|
||||
const commit = changeMatch[5];
|
||||
const commitUserKey = `${changelog.project}-${commit} `;
|
||||
@@ -78,10 +79,10 @@ for (const changelog of changelogs) {
|
||||
: execSync(`curl -s https://api.github.com/repos/${changelog.project}/commits/${commit} | jq -r '.author.login'`)
|
||||
.toString()
|
||||
.trim();
|
||||
if (user !== 'null') commitUserLookup[commitUserKey] = user;
|
||||
if (user !== "null") commitUserLookup[commitUserKey] = user;
|
||||
const messages = [];
|
||||
let message = changeMatch[3].trim();
|
||||
if (message.endsWith('.')) message = message.substring(0, message.length - 1);
|
||||
if (message.endsWith(".")) message = message.substring(0, message.length - 1);
|
||||
|
||||
if (changelog.isFrontend) {
|
||||
changes[localContext].push(
|
||||
@@ -92,18 +93,18 @@ for (const changelog of changelogs) {
|
||||
const otherUser = message.match(/\[@(.+)\]\(https:\/\/github.com\/.+\)/) || message.match(/@(.+)/);
|
||||
if (otherUser) {
|
||||
user = otherUser[1];
|
||||
message = message.replace(otherUser[0], '');
|
||||
message = message.replace(otherUser[0], "");
|
||||
}
|
||||
|
||||
if (localContext === 'add') {
|
||||
for (const model of message.split(',')) {
|
||||
const definition = zhc.definitions.find((d) => d.model === model.trim());
|
||||
if (localContext === "add") {
|
||||
for (const model of message.split(",")) {
|
||||
const definition = definitions.find((d) => d.model === model.trim());
|
||||
const whiteLabel = whiteLabels.find((d) => d.model === model.trim());
|
||||
const match = definition || whiteLabel;
|
||||
if (match) {
|
||||
messages.push(`\`${match.model}\` ${match.vendor} ${match.description}`);
|
||||
} else {
|
||||
changes['error'].push(`${line} (model '${model}' does not exist)`);
|
||||
changes.error.push(`${line} (model '${model}' does not exist)`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -111,40 +112,46 @@ for (const changelog of changelogs) {
|
||||
}
|
||||
|
||||
let issue = changeMatch[4].trim();
|
||||
if (issue && !issue.startsWith('[#')) issue = `[#${issue.split('/').pop()}](${issue})`;
|
||||
if (issue && !issue.startsWith("[#")) issue = `[#${issue.split("/").pop()}](${issue})`;
|
||||
if (!issue) {
|
||||
issue = '_NO_ISSUE_';
|
||||
localContext = 'error';
|
||||
issue = "_NO_ISSUE_";
|
||||
localContext = "error";
|
||||
}
|
||||
|
||||
messages.forEach((m) => changes[localContext].push(`- ${issue} ${m} (@${user})`));
|
||||
for (const message of messages) {
|
||||
changes[localContext].push(`- ${issue} ${message} (@${user})`);
|
||||
}
|
||||
}
|
||||
} else if (line === '# Changelog' || line === '### ⚠ BREAKING CHANGES' || !line) {
|
||||
continue;
|
||||
} else if (line === "# Changelog" || line === "### ⚠ BREAKING CHANGES" || !line) {
|
||||
// continue;
|
||||
} else {
|
||||
changes['error'].push(`- Unmatched line: ${line}`);
|
||||
changes.error.push(`- Unmatched line: ${line}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let result = '';
|
||||
let result = "";
|
||||
const names = [
|
||||
['features', 'Improvements'],
|
||||
['fixes', 'Fixes'],
|
||||
['frontend', 'Frontend'],
|
||||
['add', 'New supported devices'],
|
||||
['detect', 'Fixed device detections'],
|
||||
['error', 'Changelog generator error'],
|
||||
["features", "Improvements"],
|
||||
["fixes", "Fixes"],
|
||||
["frontend", "Frontend"],
|
||||
["add", "New supported devices"],
|
||||
["detect", "Fixed device detections"],
|
||||
["error", "Changelog generator error"],
|
||||
];
|
||||
for (const name of names) {
|
||||
result += `# ${name[1]}\n`;
|
||||
if (name[0] === 'add') {
|
||||
result += `This release adds support for ${changes['add'].length} devices: \n`;
|
||||
if (name[0] === "add") {
|
||||
result += `This release adds support for ${changes.add.length} devices: \n`;
|
||||
}
|
||||
changes[name[0]].forEach((e) => (result += `${e}\n`));
|
||||
result += '\n';
|
||||
|
||||
for (const change of changes[name[0]]) {
|
||||
result += `${change}\n`;
|
||||
}
|
||||
|
||||
result += "\n";
|
||||
}
|
||||
|
||||
fs.writeFileSync(commitUserFile, JSON.stringify(commitUserLookup), 'utf-8');
|
||||
fs.writeFileSync(commitUserFile, JSON.stringify(commitUserLookup), "utf-8");
|
||||
|
||||
console.log(result.trim());
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
const assert = require('assert');
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const assert = require("node:assert");
|
||||
const vm = require("node:vm");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const filename = process.argv[2];
|
||||
const moduleCode = fs.readFileSync(filename);
|
||||
const moduleFakePath = path.join(__dirname, 'externally-loaded.js');
|
||||
const moduleFakePath = path.join(__dirname, "externally-loaded.js");
|
||||
const sandbox = {
|
||||
require: require,
|
||||
module: {},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const {ZnpCommandStatus, NvSystemIds} = require('zigbee-herdsman/dist/adapter/z-stack/constants/common');
|
||||
const {ZnpVersion} = require('zigbee-herdsman/dist/adapter/z-stack/adapter/tstype');
|
||||
const {Subsystem} = require('zigbee-herdsman/dist/adapter/z-stack/unpi/constants');
|
||||
const {Znp} = require('zigbee-herdsman/dist/adapter/z-stack/znp');
|
||||
const {ZnpCommandStatus, NvSystemIds} = require("zigbee-herdsman/dist/adapter/z-stack/constants/common");
|
||||
const {ZnpVersion} = require("zigbee-herdsman/dist/adapter/z-stack/adapter/tstype");
|
||||
const {Subsystem} = require("zigbee-herdsman/dist/adapter/z-stack/unpi/constants");
|
||||
const {Znp} = require("zigbee-herdsman/dist/adapter/z-stack/znp");
|
||||
|
||||
class ZStackNvMemEraser {
|
||||
constructor(device) {
|
||||
@@ -13,7 +13,7 @@ class ZStackNvMemEraser {
|
||||
const attempts = 3;
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try {
|
||||
await this.znp.request(Subsystem.SYS, 'ping', {capabilities: 1});
|
||||
await this.znp.request(Subsystem.SYS, "ping", {capabilities: 1});
|
||||
break;
|
||||
} catch (e) {
|
||||
if (attempts - 1 === i) {
|
||||
@@ -23,10 +23,10 @@ class ZStackNvMemEraser {
|
||||
}
|
||||
// Old firmware did not support version, assume it's Z-Stack 1.2 for now.
|
||||
try {
|
||||
this.version = (await this.znp.request(Subsystem.SYS, 'version', {})).payload;
|
||||
} catch (e) {
|
||||
console.log(`Failed to get zStack version, assuming 1.2`);
|
||||
this.version = {transportrev: 2, product: 0, majorrel: 2, minorrel: 0, maintrel: 0, revision: ''};
|
||||
this.version = (await this.znp.request(Subsystem.SYS, "version", {})).payload;
|
||||
} catch {
|
||||
console.log("Failed to get zStack version, assuming 1.2");
|
||||
this.version = {transportrev: 2, product: 0, majorrel: 2, minorrel: 0, maintrel: 0, revision: ""};
|
||||
}
|
||||
|
||||
console.log(`Detected znp version '${ZnpVersion[this.version.product]}' (${JSON.stringify(this.version)})`);
|
||||
@@ -39,13 +39,13 @@ class ZStackNvMemEraser {
|
||||
async clearAllNvMemItems() {
|
||||
let maxNvMemId;
|
||||
switch (this.version.product) {
|
||||
case ZnpVersion.zStack12:
|
||||
case ZnpVersion.ZStack12:
|
||||
maxNvMemId = 0x0302;
|
||||
break;
|
||||
case ZnpVersion.zStack30x:
|
||||
case ZnpVersion.ZStack30x:
|
||||
maxNvMemId = 0x033f;
|
||||
break;
|
||||
case ZnpVersion.zStack3x0:
|
||||
case ZnpVersion.ZStack3x0:
|
||||
maxNvMemId = 0x032f;
|
||||
break;
|
||||
}
|
||||
@@ -54,23 +54,23 @@ class ZStackNvMemEraser {
|
||||
console.log(`Clearing all NVMEM items, from 0 to ${maxNvMemId}`);
|
||||
for (let id = 0; id <= maxNvMemId; id++) {
|
||||
let len;
|
||||
const needOsal = !(this.version.product == ZnpVersion.zStack3x0 && id <= 7);
|
||||
const needOsal = !(this.version.product === ZnpVersion.zStack3x0 && id <= 7);
|
||||
if (needOsal) {
|
||||
const lengthRes = await this.znp.request(Subsystem.SYS, 'osalNvLength', {id: id});
|
||||
len = lengthRes.payload['length'];
|
||||
const lengthRes = await this.znp.request(Subsystem.SYS, "osalNvLength", {id: id});
|
||||
len = lengthRes.payload.length;
|
||||
} else {
|
||||
const lengthRes = await this.znp.request(Subsystem.SYS, 'nvLength', {sysid: NvSystemIds.ZSTACK, itemid: id, subid: 0});
|
||||
len = lengthRes.payload['len'];
|
||||
const lengthRes = await this.znp.request(Subsystem.SYS, "nvLength", {sysid: NvSystemIds.ZSTACK, itemid: id, subid: 0});
|
||||
len = lengthRes.payload.len;
|
||||
}
|
||||
if (len != 0) {
|
||||
if (len !== 0) {
|
||||
console.log(`NVMEM item #${id} - deleting, size: ${len}`);
|
||||
if (needOsal) {
|
||||
await this.znp.request(Subsystem.SYS, 'osalNvDelete', {id: id, len: len}, undefined, undefined, [
|
||||
await this.znp.request(Subsystem.SYS, "osalNvDelete", {id: id, len: len}, undefined, undefined, [
|
||||
ZnpCommandStatus.SUCCESS,
|
||||
ZnpCommandStatus.NV_ITEM_INITIALIZED,
|
||||
]);
|
||||
} else {
|
||||
await this.znp.request(Subsystem.SYS, 'nvDelete', {sysid: NvSystemIds.ZSTACK, itemid: id, subid: 0}, undefined, undefined, [
|
||||
await this.znp.request(Subsystem.SYS, "nvDelete", {sysid: NvSystemIds.ZSTACK, itemid: id, subid: 0}, undefined, undefined, [
|
||||
ZnpCommandStatus.SUCCESS,
|
||||
ZnpCommandStatus.NV_ITEM_INITIALIZED,
|
||||
]);
|
||||
@@ -82,10 +82,10 @@ class ZStackNvMemEraser {
|
||||
}
|
||||
}
|
||||
const processArgs = process.argv.slice(2);
|
||||
if (processArgs.length != 1) {
|
||||
console.log('ZStack NVMEM eraser.');
|
||||
console.log('Usage:');
|
||||
console.log(' node zStackEraseAllNvMem.js <SERIAL_PORT>');
|
||||
if (processArgs.length !== 1) {
|
||||
console.log("ZStack NVMEM eraser.");
|
||||
console.log("Usage:");
|
||||
console.log(" node zStackEraseAllNvMem.js <SERIAL_PORT>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
const mockDevices = [
|
||||
{
|
||||
mock: 1,
|
||||
model: "external_converters_device_1",
|
||||
zigbeeModel: ["external_converter_device_1"],
|
||||
vendor: "external_1",
|
||||
description: "external_1",
|
||||
fromZigbee: [],
|
||||
toZigbee: [],
|
||||
exposes: [],
|
||||
},
|
||||
{
|
||||
mock: 2,
|
||||
model: "external_converters_device_2",
|
||||
zigbeeModel: ["external_converter_device_2"],
|
||||
vendor: "external_2",
|
||||
description: "external_2",
|
||||
fromZigbee: [],
|
||||
toZigbee: [],
|
||||
exposes: [],
|
||||
},
|
||||
];
|
||||
|
||||
module.exports = mockDevices;
|
||||
@@ -0,0 +1,14 @@
|
||||
const {posix} = require("node:path");
|
||||
|
||||
const mockDevice = {
|
||||
mock: true,
|
||||
zigbeeModel: ["external_converter_device"],
|
||||
vendor: "external",
|
||||
model: "external_converter_device",
|
||||
description: posix.join("external", "converter"),
|
||||
fromZigbee: [],
|
||||
toZigbee: [],
|
||||
exposes: [],
|
||||
};
|
||||
|
||||
module.exports = mockDevice;
|
||||
@@ -0,0 +1,22 @@
|
||||
export default [
|
||||
{
|
||||
mock: 1,
|
||||
model: "external_converters_device_1",
|
||||
zigbeeModel: ["external_converter_device_1"],
|
||||
vendor: "external_1",
|
||||
description: "external_1",
|
||||
fromZigbee: [],
|
||||
toZigbee: [],
|
||||
exposes: [],
|
||||
},
|
||||
{
|
||||
mock: 2,
|
||||
model: "external_converters_device_2",
|
||||
zigbeeModel: ["external_converter_device_2"],
|
||||
vendor: "external_2",
|
||||
description: "external_2",
|
||||
fromZigbee: [],
|
||||
toZigbee: [],
|
||||
exposes: [],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,12 @@
|
||||
import {posix} from "node:path";
|
||||
|
||||
import {identify} from "zigbee-herdsman-converters/lib/modernExtend";
|
||||
|
||||
export default {
|
||||
mock: true,
|
||||
zigbeeModel: ["external_converter_device"],
|
||||
vendor: "external",
|
||||
model: "external_converter_device",
|
||||
description: posix.join("external", "converter"),
|
||||
extend: [identify()],
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
const mockDevices = [
|
||||
{
|
||||
mock: 1,
|
||||
model: 'external_converters_device_1',
|
||||
zigbeeModel: ['external_converter_device_1'],
|
||||
vendor: 'external_1',
|
||||
description: 'external_1',
|
||||
fromZigbee: [],
|
||||
toZigbee: [],
|
||||
exposes: [],
|
||||
},
|
||||
{
|
||||
mock: 2,
|
||||
model: 'external_converters_device_2',
|
||||
zigbeeModel: ['external_converter_device_2'],
|
||||
vendor: 'external_2',
|
||||
description: 'external_2',
|
||||
fromZigbee: [],
|
||||
toZigbee: [],
|
||||
exposes: [],
|
||||
},
|
||||
];
|
||||
|
||||
module.exports = mockDevices;
|
||||
@@ -1,12 +0,0 @@
|
||||
const mockDevice = {
|
||||
mock: true,
|
||||
zigbeeModel: ['external_converter_device'],
|
||||
vendor: 'external',
|
||||
model: 'external_converter_device',
|
||||
description: 'external',
|
||||
fromZigbee: [],
|
||||
toZigbee: [],
|
||||
exposes: [],
|
||||
};
|
||||
|
||||
module.exports = mockDevice;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,16 @@
|
||||
class Example {
|
||||
constructor(_zigbee, mqtt, _state, _publishEntityState, _eventBus) {
|
||||
this.mqtt = mqtt;
|
||||
this.mqtt.publish("example/extension", "call from constructor");
|
||||
}
|
||||
|
||||
start() {
|
||||
this.mqtt.publish("example/extension", "call from start");
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.mqtt.publish("example/extension", "call from stop");
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Example;
|
||||
@@ -1,16 +0,0 @@
|
||||
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;
|
||||
@@ -1,16 +0,0 @@
|
||||
class Example {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
this.mqtt = mqtt;
|
||||
this.mqtt.publish('example/extension', 'call from constructor');
|
||||
}
|
||||
|
||||
start() {
|
||||
this.mqtt.publish('example/extension', 'call from start');
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.mqtt.publish('example/extension', 'call from stop');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Example;
|
||||
@@ -0,0 +1,14 @@
|
||||
export default 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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export default class Example {
|
||||
constructor(_zigbee, mqtt, _state, _publishEntityState, _eventBus) {
|
||||
this.mqtt = mqtt;
|
||||
this.mqtt.publish("example/extension", "call from constructor");
|
||||
this.counter = 0;
|
||||
}
|
||||
|
||||
start() {
|
||||
this.mqtt.publish("example/extension", "call from start");
|
||||
this.mqtt.publish("example/extension/counter", `start ${this.counter++}`);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.mqtt.publish("example/extension", "call from stop");
|
||||
this.mqtt.publish("example/extension/counter", `stop ${--this.counter}`);
|
||||
}
|
||||
}
|
||||
+469
-377
File diff suppressed because it is too large
Load Diff
+10
-10
@@ -1,25 +1,25 @@
|
||||
import path from 'node:path';
|
||||
import path from "node:path";
|
||||
|
||||
import tmp from 'tmp';
|
||||
import tmp from "tmp";
|
||||
|
||||
import data from '../lib/util/data';
|
||||
import data from "../lib/util/data";
|
||||
|
||||
describe('Data', () => {
|
||||
describe('Get path', () => {
|
||||
it('Should return correct path', () => {
|
||||
const expected = path.normalize(path.join(__dirname, '..', 'data'));
|
||||
describe("Data", () => {
|
||||
describe("Get path", () => {
|
||||
it("Should return correct path", () => {
|
||||
const expected = path.normalize(path.join(__dirname, "..", "data"));
|
||||
const actual = data.getPath();
|
||||
expect(actual).toBe(expected);
|
||||
});
|
||||
|
||||
it('Should return correct path when ZIGBEE2MQTT_DATA set', () => {
|
||||
it("Should return correct path when ZIGBEE2MQTT_DATA set", () => {
|
||||
const expected = tmp.dirSync().name;
|
||||
process.env.ZIGBEE2MQTT_DATA = expected;
|
||||
data._testReload();
|
||||
const actual = data.getPath();
|
||||
expect(actual).toBe(expected);
|
||||
expect(data.joinPath('test')).toStrictEqual(path.join(expected, 'test'));
|
||||
expect(data.joinPath('/test')).toStrictEqual(path.resolve(expected, '/test'));
|
||||
expect(data.joinPath("test")).toStrictEqual(path.join(expected, "test"));
|
||||
expect(data.joinPath("/test")).toStrictEqual(path.resolve(expected, "/test"));
|
||||
delete process.env.ZIGBEE2MQTT_DATA;
|
||||
data._testReload();
|
||||
});
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
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';
|
||||
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";
|
||||
|
||||
import assert from 'node:assert';
|
||||
import assert from "node:assert";
|
||||
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
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';
|
||||
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];
|
||||
|
||||
@@ -28,12 +28,12 @@ returnDevices.push(
|
||||
devices.hue_twilight.ieeeAddr,
|
||||
);
|
||||
|
||||
describe('Extension: Availability', () => {
|
||||
describe("Extension: Availability", () => {
|
||||
let controller: Controller;
|
||||
|
||||
const resetExtension = async (): Promise<void> => {
|
||||
await controller.enableDisableExtension(false, 'Availability');
|
||||
await controller.enableDisableExtension(true, 'Availability');
|
||||
await controller.removeExtension(controller.getExtension("Availability")!);
|
||||
await controller.addExtension(new Availability(...controller.extensionArgs));
|
||||
};
|
||||
|
||||
const setTimeAndAdvanceTimers = async (value: number): Promise<void> => {
|
||||
@@ -42,10 +42,10 @@ describe('Extension: Availability', () => {
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.spyOn(utils, 'sleep').mockImplementation(vi.fn());
|
||||
vi.spyOn(utils, "sleep").mockImplementation(vi.fn());
|
||||
vi.useFakeTimers();
|
||||
settings.reRead();
|
||||
settings.set(['availability'], {enabled: true});
|
||||
settings.set(["availability"], {enabled: true});
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
@@ -55,35 +55,39 @@ describe('Extension: Availability', () => {
|
||||
vi.setSystemTime(utils.minutes(1));
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
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)));
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
settings.set(["availability"], {enabled: true});
|
||||
settings.set(["devices", devices.bulb_color_2.ieeeAddr, "availability"], false);
|
||||
settings.set(["devices", devices.hue_twilight.ieeeAddr, "availability"], {max_jitter: 1000});
|
||||
settings.set(["devices", devices.GLEDOPTO_2ID.ieeeAddr, "availability"], {backoff: false});
|
||||
settings.set(["devices", devices.QBKG03LM.ieeeAddr, "availability"], {pause_on_backoff_gt: 1.5});
|
||||
for (const key in devices) devices[key as keyof typeof devices].lastSeen = utils.minutes(1);
|
||||
for (const mock of mocksClear) mock.mockClear();
|
||||
await resetExtension();
|
||||
Object.values(devices).forEach((d) => d.ping.mockClear());
|
||||
for (const key in devices) devices[key as keyof typeof devices].ping.mockClear();
|
||||
});
|
||||
|
||||
afterEach(async () => {});
|
||||
|
||||
afterAll(async () => {
|
||||
await controller.stop();
|
||||
await controller?.stop();
|
||||
await flushPromises();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('Should publish availability on startup for device where it is enabled for', async () => {
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', stringify({state: 'online'}), {
|
||||
it("Should publish availability on startup for device where it is enabled for", () => {
|
||||
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'}), {
|
||||
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 () => {
|
||||
settings.set(['devices', devices.bulb_color_2.ieeeAddr, 'availability'], true);
|
||||
it("Should ping on startup for enabled and unavailable devices", async () => {
|
||||
settings.set(["devices", devices.bulb_color_2.ieeeAddr, "availability"], true);
|
||||
devices.bulb_color.lastSeen = Date.now() - utils.minutes(20);
|
||||
devices.bulb_color_2.lastSeen = Date.now();
|
||||
await resetExtension();
|
||||
@@ -93,9 +97,9 @@ describe('Extension: Availability', () => {
|
||||
expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(0); // enabled/available
|
||||
});
|
||||
|
||||
it('Should not ping on startup for available or disabled devices', async () => {
|
||||
settings.set(['devices', devices.bulb_color_2.ieeeAddr, 'availability'], true);
|
||||
settings.set(['devices', devices.bulb_color_2.ieeeAddr, 'disabled'], true);
|
||||
it("Should not ping on startup for available or disabled devices", async () => {
|
||||
settings.set(["devices", devices.bulb_color_2.ieeeAddr, "availability"], true);
|
||||
settings.set(["devices", devices.bulb_color_2.ieeeAddr, "disabled"], true);
|
||||
devices.bulb_color.lastSeen = Date.now();
|
||||
devices.bulb_color_2.lastSeen = Date.now() - utils.minutes(20);
|
||||
await resetExtension();
|
||||
@@ -105,7 +109,7 @@ describe('Extension: Availability', () => {
|
||||
expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(0); // disabled/unavailable
|
||||
});
|
||||
|
||||
it('Should publish offline for active device when not seen for 10 minutes', async () => {
|
||||
it("Should publish offline for active device when not seen for 10 minutes", async () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
await setTimeAndAdvanceTimers(utils.minutes(5));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
@@ -113,34 +117,34 @@ describe('Extension: Availability', () => {
|
||||
await setTimeAndAdvanceTimers(utils.minutes(7));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
|
||||
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', stringify({state: 'offline'}), {
|
||||
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 () => {
|
||||
it("Shouldnt do anything for a device when availability: false is set for device", async () => {
|
||||
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 () => {
|
||||
it("Should publish offline for passive device when not seen for 25 hours", async () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
await setTimeAndAdvanceTimers(utils.hours(26));
|
||||
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/remote/availability', stringify({state: 'offline'}), {retain: true, qos: 1});
|
||||
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 () => {
|
||||
it("Should reset ping timer when device last seen changes for active device", async () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(5));
|
||||
await setTimeAndAdvanceTimers(utils.minutes(6));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', stringify({state: 'offline'}), {
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb_color/availability", stringify({state: "offline"}), {
|
||||
retain: true,
|
||||
qos: 1,
|
||||
});
|
||||
@@ -153,13 +157,13 @@ describe('Extension: Availability', () => {
|
||||
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
|
||||
});
|
||||
|
||||
it('Should ping again when first ping fails', async () => {
|
||||
it("Should ping again when first ping fails", async () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
|
||||
|
||||
devices.bulb_color.ping.mockImplementationOnce(() => {
|
||||
throw new Error('failed');
|
||||
throw new Error("failed");
|
||||
});
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(15));
|
||||
@@ -168,14 +172,14 @@ describe('Extension: Availability', () => {
|
||||
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(2, false);
|
||||
});
|
||||
|
||||
it('Should reset ping timer when device last seen changes for passive device', async () => {
|
||||
it("Should reset ping timer when device last seen changes for passive device", async () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.hours(24));
|
||||
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
await mockZHEvents.lastSeenChanged({device: devices.remote});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/remote/availability', stringify({state: 'offline'}), {retain: true, qos: 1});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/remote/availability", stringify({state: "offline"}), {retain: true, qos: 1});
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.hours(25));
|
||||
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
|
||||
@@ -184,11 +188,11 @@ describe('Extension: Availability', () => {
|
||||
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should immediately mark device as online when it lastSeen changes', async () => {
|
||||
it("Should immediately mark device as online when it lastSeen changes", async () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(15));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', stringify({state: 'offline'}), {
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb_color/availability", stringify({state: "offline"}), {
|
||||
retain: true,
|
||||
qos: 1,
|
||||
});
|
||||
@@ -196,14 +200,14 @@ describe('Extension: Availability', () => {
|
||||
devices.bulb_color.lastSeen = Date.now();
|
||||
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', stringify({state: 'online'}), {
|
||||
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 () => {
|
||||
settings.set(['devices', '0x000b57fffec6a5b3', 'availability'], {timeout: 40});
|
||||
it("Should allow to change availability timeout via device options", async () => {
|
||||
settings.set(["devices", "0x000b57fffec6a5b3", "availability"], {timeout: 40});
|
||||
await resetExtension();
|
||||
devices.bulb_color.ping.mockClear();
|
||||
|
||||
@@ -214,8 +218,8 @@ describe('Extension: Availability', () => {
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should not ping disabled devices', async () => {
|
||||
settings.set(['devices', devices.bulb_color.ieeeAddr, 'disabled'], true);
|
||||
it("Should not ping disabled devices", async () => {
|
||||
settings.set(["devices", devices.bulb_color.ieeeAddr, "disabled"], true);
|
||||
await resetExtension();
|
||||
devices.bulb_color.ping.mockClear();
|
||||
|
||||
@@ -223,8 +227,8 @@ describe('Extension: Availability', () => {
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should allow to change availability timeout via avaiability options', async () => {
|
||||
settings.set(['availability', 'active', 'timeout'], 30);
|
||||
it("Should allow to change availability timeout via avaiability options", async () => {
|
||||
settings.set(["availability", "active", "timeout"], 30);
|
||||
await resetExtension();
|
||||
devices.bulb_color.ping.mockClear();
|
||||
|
||||
@@ -235,7 +239,7 @@ describe('Extension: Availability', () => {
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should stop pinging device when it leaves', async () => {
|
||||
it("Should stop pinging device when it leaves", async () => {
|
||||
await setTimeAndAdvanceTimers(utils.minutes(9));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
@@ -246,19 +250,19 @@ describe('Extension: Availability', () => {
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should stop pinging device when it is removed', async () => {
|
||||
it("Should stop pinging device when it is removed", async () => {
|
||||
await setTimeAndAdvanceTimers(utils.minutes(9));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
|
||||
mockMQTTEvents.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));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should allow to be disabled', async () => {
|
||||
settings.set(['availability'], {enabled: false});
|
||||
it("Should allow to be disabled", async () => {
|
||||
settings.set(["availability"], {enabled: false});
|
||||
await resetExtension();
|
||||
devices.bulb_color.ping.mockClear();
|
||||
|
||||
@@ -266,9 +270,9 @@ describe('Extension: Availability', () => {
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should allow to enable availability for just one device', async () => {
|
||||
settings.set(['availability'], {enabled: false});
|
||||
settings.set(['devices', devices.bulb_color.ieeeAddr, 'availability'], true);
|
||||
it("Should allow to enable availability for just one device", async () => {
|
||||
settings.set(["availability"], {enabled: false});
|
||||
settings.set(["devices", devices.bulb_color.ieeeAddr, "availability"], true);
|
||||
|
||||
await resetExtension();
|
||||
devices.bulb_color.ping.mockClear();
|
||||
@@ -277,11 +281,11 @@ describe('Extension: Availability', () => {
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should retrieve device state when it reconnects', async () => {
|
||||
it("Should retrieve device state when it reconnects", async () => {
|
||||
// @ts-expect-error private
|
||||
const device = controller.zigbee.resolveEntity(devices.bulb_color.ieeeAddr);
|
||||
// @ts-expect-error private
|
||||
controller.state.set(device, {state: 'OFF'});
|
||||
controller.state.set(device, {state: "OFF"});
|
||||
|
||||
const endpoint = devices.bulb_color.getEndpoint(1);
|
||||
assert(endpoint);
|
||||
@@ -297,57 +301,57 @@ describe('Extension: Availability', () => {
|
||||
await setTimeAndAdvanceTimers(utils.seconds(2));
|
||||
|
||||
expect(endpoint.read).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.read).toHaveBeenCalledWith('genOnOff', ['onOff']);
|
||||
expect(endpoint.read).toHaveBeenCalledWith("genOnOff", ["onOff"]);
|
||||
|
||||
endpoint.read.mockClear();
|
||||
await mockZHEvents.deviceAnnounce({device: devices.bulb_color});
|
||||
await flushPromises();
|
||||
endpoint.read.mockImplementationOnce(() => {
|
||||
throw new Error('');
|
||||
throw new Error("");
|
||||
});
|
||||
await setTimeAndAdvanceTimers(utils.seconds(3));
|
||||
expect(endpoint.read).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should republish availability when device is renamed', async () => {
|
||||
it("Should republish availability when device is renamed", async () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
|
||||
mockMQTTEvents.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(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', '', {retain: true, qos: 1});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_new_name/availability', stringify({state: 'online'}), {
|
||||
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(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_new_name/availability', stringify({state: 'offline'}), {
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb_new_name/availability", stringify({state: "offline"}), {
|
||||
retain: true,
|
||||
qos: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('Should publish availability payload in JSON format', async () => {
|
||||
it("Should publish availability payload in JSON format", async () => {
|
||||
await resetExtension();
|
||||
devices.remote.ping.mockClear();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
await setTimeAndAdvanceTimers(utils.hours(26));
|
||||
expect(devices.remote.ping).toHaveBeenCalledTimes(0);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/remote/availability', stringify({state: 'offline'}), {retain: true, qos: 1});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/remote/availability", stringify({state: "offline"}), {retain: true, qos: 1});
|
||||
});
|
||||
|
||||
it('Should publish availability for groups', async () => {
|
||||
settings.set(['devices', devices.bulb_color_2.ieeeAddr, 'availability'], true);
|
||||
it("Should publish availability for groups", async () => {
|
||||
settings.set(["devices", devices.bulb_color_2.ieeeAddr, "availability"], true);
|
||||
await resetExtension();
|
||||
devices.bulb_color_2.ping.mockClear();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/group_tradfri_remote/availability', stringify({state: 'online'}), {
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/group_tradfri_remote/availability", stringify({state: "online"}), {
|
||||
retain: true,
|
||||
qos: 1,
|
||||
});
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
await setTimeAndAdvanceTimers(utils.minutes(12));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/group_tradfri_remote/availability', stringify({state: 'offline'}), {
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/group_tradfri_remote/availability", stringify({state: "offline"}), {
|
||||
retain: true,
|
||||
qos: 1,
|
||||
});
|
||||
@@ -355,17 +359,16 @@ describe('Extension: Availability', () => {
|
||||
devices.bulb_color_2.lastSeen = Date.now();
|
||||
await mockZHEvents.lastSeenChanged({device: devices.bulb_color_2});
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/group_tradfri_remote/availability', stringify({state: 'online'}), {
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/group_tradfri_remote/availability", stringify({state: "online"}), {
|
||||
retain: true,
|
||||
qos: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('Should clear the ping queue on stop', async () => {
|
||||
it("Should clear the ping queue on stop", async () => {
|
||||
const availability = controller.getExtension("Availability")! as Availability;
|
||||
// @ts-expect-error private
|
||||
const availability = controller.extensions.find((extension) => extension instanceof Availability)!;
|
||||
// @ts-expect-error private
|
||||
const publishAvailabilitySpy = vi.spyOn(availability, 'publishAvailability');
|
||||
const publishAvailabilitySpy = vi.spyOn(availability, "publishAvailability");
|
||||
|
||||
devices.bulb_color.ping.mockImplementationOnce(() => new Promise((resolve) => setTimeout(resolve, 1000)));
|
||||
// @ts-expect-error private
|
||||
@@ -383,12 +386,296 @@ describe('Extension: Availability', () => {
|
||||
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)!;
|
||||
it("Should prevent instance restart", async () => {
|
||||
const availability = controller.getExtension("Availability")! as Availability;
|
||||
|
||||
await availability.stop();
|
||||
|
||||
await expect(() => availability.start()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("jitters pings", async () => {
|
||||
const devicesPings = [
|
||||
devices.bulb_color.ping,
|
||||
devices.TS0601_thermostat.ping,
|
||||
devices.bulb_2.ping,
|
||||
devices.ZNCZ02LM.ping,
|
||||
devices.GLEDOPTO_2ID.ping,
|
||||
devices.QBKG03LM.ping,
|
||||
devices.hue_twilight.ping,
|
||||
];
|
||||
let called = 0;
|
||||
let lastCalled = 0;
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(10));
|
||||
|
||||
for (const p of devicesPings) {
|
||||
called += p.mock.calls.length;
|
||||
}
|
||||
|
||||
expect(called).toStrictEqual(0);
|
||||
|
||||
lastCalled = called;
|
||||
called = 0;
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.seconds(2)); // 10:02
|
||||
|
||||
expect(devices.hue_twilight.ping).toHaveBeenCalledTimes(1); // max_jitter: 1000
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.seconds(8)); // 10:10
|
||||
|
||||
for (const p of devicesPings) {
|
||||
called += p.mock.calls.length;
|
||||
}
|
||||
|
||||
expect(called).toBeGreaterThanOrEqual(0);
|
||||
expect(called).toBeLessThan(7);
|
||||
expect(called).toBeGreaterThanOrEqual(lastCalled);
|
||||
|
||||
lastCalled = called;
|
||||
called = 0;
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.seconds(20)); // 10:20
|
||||
|
||||
for (const p of devicesPings) {
|
||||
called += p.mock.calls.length;
|
||||
}
|
||||
|
||||
expect(called).toBeGreaterThanOrEqual(lastCalled);
|
||||
|
||||
lastCalled = called;
|
||||
called = 0;
|
||||
await setTimeAndAdvanceTimers(utils.seconds(35)); // 10:35
|
||||
|
||||
for (const p of devicesPings) {
|
||||
called += p.mock.calls.length;
|
||||
}
|
||||
|
||||
expect(called).toStrictEqual(7);
|
||||
|
||||
for (const p of devicesPings) {
|
||||
expect(p).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not trigger backoff on ping success", async () => {
|
||||
settings.set(["availability", "active", "max_jitter"], 0); // easier testing
|
||||
|
||||
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(10.5)); // 10:30
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
|
||||
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(10.5)); // 21:00
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("triggers backoff on successive ping failures", async () => {
|
||||
settings.set(["availability", "active", "max_jitter"], 0); // easier testing
|
||||
|
||||
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
|
||||
await mockZHEvents.lastSeenChanged({device: devices.GLEDOPTO_2ID}); // backoff: false
|
||||
|
||||
devices.bulb_color.ping
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("failed");
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("failed");
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("failed");
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("failed");
|
||||
});
|
||||
devices.GLEDOPTO_2ID.ping
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("failed");
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("failed");
|
||||
});
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(10.5)); // 10:30
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
|
||||
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
|
||||
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(2, false);
|
||||
expect(devices.GLEDOPTO_2ID.ping).toHaveBeenCalledTimes(2);
|
||||
expect(devices.GLEDOPTO_2ID.ping).toHaveBeenNthCalledWith(1, true);
|
||||
expect(devices.GLEDOPTO_2ID.ping).toHaveBeenNthCalledWith(2, false);
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(14.5)); // 25:00
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
|
||||
expect(devices.GLEDOPTO_2ID.ping).toHaveBeenCalledTimes(3);
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(0.5)); // 25:30
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(3);
|
||||
expect(devices.GLEDOPTO_2ID.ping).toHaveBeenCalledTimes(3);
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(29.5)); // 55:00
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(3);
|
||||
expect(devices.GLEDOPTO_2ID.ping).toHaveBeenCalledTimes(6);
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(0.5)); // 55:30
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(4);
|
||||
expect(devices.GLEDOPTO_2ID.ping).toHaveBeenCalledTimes(6);
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(59.5)); // 115:00
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(4);
|
||||
expect(devices.GLEDOPTO_2ID.ping).toHaveBeenCalledTimes(12);
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(0.5)); // 115:30
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(5);
|
||||
expect(devices.GLEDOPTO_2ID.ping).toHaveBeenCalledTimes(12);
|
||||
|
||||
// backoff was reset (4 mocks done)
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(10.5)); // 126:00
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(6);
|
||||
expect(devices.GLEDOPTO_2ID.ping).toHaveBeenCalledTimes(13);
|
||||
});
|
||||
|
||||
it("resets backoff on last seen", async () => {
|
||||
settings.set(["availability", "active", "max_jitter"], 0); // easier testing
|
||||
|
||||
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
|
||||
|
||||
devices.bulb_color.ping
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("failed");
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("failed");
|
||||
});
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(10.5)); // 10:30
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
|
||||
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
|
||||
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(2, false);
|
||||
|
||||
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(10.5)); // 21:00
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("pauses when backoff reaches configured value and unpauses on last seen", async () => {
|
||||
settings.set(["availability", "active", "max_jitter"], 0); // easier testing
|
||||
settings.set(["availability", "active", "pause_on_backoff_gt"], 3);
|
||||
|
||||
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
|
||||
await mockZHEvents.lastSeenChanged({device: devices.QBKG03LM}); // pause_on_backoff_gt: 1.5
|
||||
|
||||
devices.bulb_color.ping
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("failed");
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("failed");
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("failed");
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("failed");
|
||||
});
|
||||
devices.QBKG03LM.ping
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("failed");
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("failed");
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("failed");
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("failed");
|
||||
});
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(10.5)); // 10:30
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
|
||||
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
|
||||
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(2, false);
|
||||
expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(2);
|
||||
expect(devices.QBKG03LM.ping).toHaveBeenNthCalledWith(1, true);
|
||||
expect(devices.QBKG03LM.ping).toHaveBeenNthCalledWith(2, false);
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(14.5)); // 25:00
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
|
||||
expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(2);
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(0.5)); // 25:30
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(3);
|
||||
expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(3);
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(29.5)); // 55:00
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(3);
|
||||
expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(3);
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(0.5)); // 55:30
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(4);
|
||||
expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(3);
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(100));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(4);
|
||||
expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(3);
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(100));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(4);
|
||||
expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(3);
|
||||
|
||||
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
|
||||
await mockZHEvents.lastSeenChanged({device: devices.QBKG03LM});
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(10.5));
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(5);
|
||||
expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("allows to disable backoff", async () => {
|
||||
settings.set(["availability", "active", "max_jitter"], 0); // easier testing
|
||||
settings.set(["availability", "active", "backoff"], false);
|
||||
|
||||
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
|
||||
|
||||
devices.bulb_color.ping
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("failed");
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("failed");
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("failed");
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("failed");
|
||||
});
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(10.5)); // 10:30
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
|
||||
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
|
||||
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(2, false);
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(9.5)); // 20:00
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(0.5)); // 20:30
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(3);
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(9.5)); // 30:00
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(3);
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(0.5)); // 30:30
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(4);
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(9.5)); // 40:00
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(4);
|
||||
|
||||
await setTimeAndAdvanceTimers(utils.minutes(0.5)); // 40:30
|
||||
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
});
|
||||
|
||||
+293
-271
File diff suppressed because it is too large
Load Diff
+2015
-1976
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,26 @@
|
||||
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';
|
||||
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 {type Device, type Endpoint, devices, events as mockZHEvents} from "../mocks/zigbeeHerdsman";
|
||||
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
|
||||
import {Controller} from '../../lib/controller';
|
||||
import * as settings from '../../lib/util/settings';
|
||||
import {InterviewState} from "zigbee-herdsman/dist/controller/model/device";
|
||||
|
||||
import {Controller} from "../../lib/controller";
|
||||
import Configure from "../../lib/extension/configure";
|
||||
import * as settings from "../../lib/util/settings";
|
||||
|
||||
const mocksClear = [mockMQTTPublishAsync, mockLogger.warning, mockLogger.debug];
|
||||
|
||||
describe('Extension: Configure', () => {
|
||||
describe("Extension: Configure", () => {
|
||||
let controller: Controller;
|
||||
let coordinatorEndpoint: Endpoint;
|
||||
|
||||
const resetExtension = async (): Promise<void> => {
|
||||
await controller.enableDisableExtension(false, 'Configure');
|
||||
await controller.enableDisableExtension(true, 'Configure');
|
||||
await controller.removeExtension(controller.getExtension("Configure")!);
|
||||
await controller.addExtension(new Configure(...controller.extensionArgs));
|
||||
};
|
||||
|
||||
const mockClear = (device: Device): void => {
|
||||
@@ -33,12 +36,12 @@ describe('Extension: Configure', () => {
|
||||
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);
|
||||
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(endpoint2.write).toHaveBeenCalledWith("genBasic", {49: {type: 25, value: 11}}, {disableDefaultResponse: true, manufacturerCode: 4107});
|
||||
expect(device.meta.configured).toBe(332242049);
|
||||
};
|
||||
|
||||
@@ -46,8 +49,8 @@ describe('Extension: Configure', () => {
|
||||
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']);
|
||||
expect(endpoint1.read).toHaveBeenCalledWith("lightingColorCtrl", ["colorCapabilities"]);
|
||||
expect(endpoint1.read).toHaveBeenCalledWith("lightingColorCtrl", ["colorTempPhysicalMin", "colorTempPhysicalMax"]);
|
||||
};
|
||||
|
||||
const expectBulbNotConfigured = (): void => {
|
||||
@@ -74,25 +77,27 @@ describe('Extension: Configure', () => {
|
||||
beforeEach(async () => {
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
for (const mock of mocksClear) mock.mockClear();
|
||||
coordinatorEndpoint = devices.coordinator.getEndpoint(1)!;
|
||||
await resetExtension();
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await controller?.stop();
|
||||
await flushPromises();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('Should configure Router on startup', async () => {
|
||||
it("Should configure Router on startup", () => {
|
||||
expectBulbConfigured();
|
||||
});
|
||||
|
||||
it('Should not configure EndDevice on startup', async () => {
|
||||
it("Should not configure EndDevice on startup", () => {
|
||||
expectRemoteNotConfigured();
|
||||
});
|
||||
|
||||
it('Should re-configure when device rejoins', async () => {
|
||||
it("Should re-configure when device rejoins", async () => {
|
||||
expectBulbConfigured();
|
||||
const device = devices.bulb;
|
||||
await flushPromises();
|
||||
@@ -103,18 +108,18 @@ describe('Extension: Configure', () => {
|
||||
expectBulbConfigured();
|
||||
});
|
||||
|
||||
it('Should not re-configure disabled devices', async () => {
|
||||
it("Should not re-configure disabled devices", async () => {
|
||||
expectBulbConfigured();
|
||||
const device = devices.bulb;
|
||||
await flushPromises();
|
||||
mockClear(device);
|
||||
settings.set(['devices', device.ieeeAddr, 'disabled'], true);
|
||||
settings.set(["devices", device.ieeeAddr, "disabled"], true);
|
||||
mockZHEvents.deviceJoined({device});
|
||||
await flushPromises();
|
||||
expectBulbNotConfigured();
|
||||
});
|
||||
|
||||
it('Should reconfigure reporting on reconfigure event', async () => {
|
||||
it("Should reconfigure reporting on reconfigure event", async () => {
|
||||
expectBulbConfigured();
|
||||
// @ts-expect-error private
|
||||
const device = controller.zigbee.resolveEntity(devices.bulb)!;
|
||||
@@ -126,7 +131,7 @@ describe('Extension: Configure', () => {
|
||||
expectBulbConfigured();
|
||||
});
|
||||
|
||||
it('Should not configure twice', async () => {
|
||||
it("Should not configure twice", async () => {
|
||||
expectBulbConfigured();
|
||||
const device = devices.bulb;
|
||||
mockClear(device);
|
||||
@@ -135,7 +140,7 @@ describe('Extension: Configure', () => {
|
||||
expectBulbNotConfigured();
|
||||
});
|
||||
|
||||
it('Should configure on zigbee message when not configured yet', async () => {
|
||||
it("Should configure on zigbee message when not configured yet", async () => {
|
||||
const device = devices.bulb;
|
||||
delete device.meta.configured;
|
||||
mockClear(device);
|
||||
@@ -144,74 +149,72 @@ describe('Extension: Configure', () => {
|
||||
expectBulbConfigured();
|
||||
});
|
||||
|
||||
it('Should allow to configure via MQTT', async () => {
|
||||
it("Should allow to configure via MQTT", async () => {
|
||||
mockClear(devices.remote);
|
||||
expectRemoteNotConfigured();
|
||||
await mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/configure', 'remote');
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/configure", "remote");
|
||||
await flushPromises();
|
||||
expectRemoteConfigured();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/configure',
|
||||
stringify({data: {id: 'remote'}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
"zigbee2mqtt/bridge/response/device/configure",
|
||||
stringify({data: {id: "remote"}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('Fail to configure via MQTT when device does not exist', async () => {
|
||||
await mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/configure', stringify({id: 'not_existing_device'}));
|
||||
it("Fail to configure via MQTT when device does not exist", async () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/configure", stringify({id: "not_existing_device"}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/configure',
|
||||
stringify({data: {}, status: 'error', error: "Device 'not_existing_device' does not exist"}),
|
||||
{retain: false, qos: 0},
|
||||
"zigbee2mqtt/bridge/response/device/configure",
|
||||
stringify({data: {}, status: "error", error: "Device 'not_existing_device' does not exist"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('Fail to configure via MQTT when configure fails', async () => {
|
||||
devices.remote.getEndpoint(1)!.bind.mockImplementationOnce(async () => {
|
||||
throw new Error('Bind timeout after 10s');
|
||||
});
|
||||
await mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/configure', stringify({id: 'remote'}));
|
||||
it("Fail to configure via MQTT when configure fails", async () => {
|
||||
devices.remote.getEndpoint(1)!.bind.mockRejectedValueOnce(new Error("Bind timeout after 10s"));
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/configure", stringify({id: "remote"}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/configure',
|
||||
stringify({data: {}, status: 'error', error: 'Failed to configure (Bind timeout after 10s)'}),
|
||||
{retain: false, qos: 0},
|
||||
"zigbee2mqtt/bridge/response/device/configure",
|
||||
stringify({data: {}, status: "error", error: "Failed to configure (Bind timeout after 10s)"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('Fail to configure via MQTT when device has no configure', async () => {
|
||||
await mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/configure', stringify({id: '0x0017882104a44559', transaction: 20}));
|
||||
it("Fail to configure via MQTT when device has no configure", async () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/configure", stringify({id: "0x0017882104a44559", transaction: 20}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/configure',
|
||||
stringify({data: {}, status: 'error', error: "Device 'TS0601_thermostat' cannot be configured", transaction: 20}),
|
||||
{retain: false, qos: 0},
|
||||
"zigbee2mqtt/bridge/response/device/configure",
|
||||
stringify({data: {}, status: "error", error: "Device 'TS0601_thermostat' cannot be configured", transaction: 20}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('Handles invalid payload for configure via MQTT', async () => {
|
||||
await mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/configure', stringify({idx: '0x0017882104a44559'}));
|
||||
it("Handles invalid payload for configure via MQTT", async () => {
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/configure", stringify({idx: "0x0017882104a44559"}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/configure',
|
||||
stringify({data: {}, status: 'error', error: 'Invalid payload'}),
|
||||
{retain: false, qos: 0},
|
||||
"zigbee2mqtt/bridge/response/device/configure",
|
||||
stringify({data: {}, status: "error", error: "Invalid payload"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should not configure when interview not completed', async () => {
|
||||
it("Should not configure when interview not completed", async () => {
|
||||
const device = devices.remote;
|
||||
delete device.meta.configured;
|
||||
device.interviewCompleted = false;
|
||||
device.interviewState = InterviewState.Pending;
|
||||
mockClear(device);
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
expectRemoteNotConfigured();
|
||||
device.interviewCompleted = true;
|
||||
device.interviewState = InterviewState.Successful;
|
||||
});
|
||||
|
||||
it('Should not configure when already configuring', async () => {
|
||||
it("Should not configure when already configuring", async () => {
|
||||
const device = devices.remote;
|
||||
delete device.meta.configured;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
@@ -225,36 +228,26 @@ describe('Extension: Configure', () => {
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should configure max 3 times when fails', async () => {
|
||||
it("Should configure max 3 times when fails", async () => {
|
||||
// @ts-expect-error private
|
||||
controller.extensions.find((e) => e.constructor.name === 'Configure').attempts = {};
|
||||
(controller.getExtension("Configure")! as Configure).attempts = {};
|
||||
const device = devices.remote;
|
||||
delete device.meta.configured;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
mockClear(device);
|
||||
endpoint.bind.mockImplementationOnce(async () => {
|
||||
throw new Error('BLA');
|
||||
});
|
||||
endpoint.bind.mockRejectedValueOnce(new Error("BLA"));
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
endpoint.bind.mockImplementationOnce(async () => {
|
||||
throw new Error('BLA');
|
||||
});
|
||||
endpoint.bind.mockRejectedValueOnce(new Error("BLA"));
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
endpoint.bind.mockImplementationOnce(async () => {
|
||||
throw new Error('BLA');
|
||||
});
|
||||
endpoint.bind.mockRejectedValueOnce(new Error("BLA"));
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
endpoint.bind.mockImplementationOnce(async () => {
|
||||
throw new Error('BLA');
|
||||
});
|
||||
endpoint.bind.mockRejectedValueOnce(new Error("BLA"));
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
endpoint.bind.mockImplementationOnce(async () => {
|
||||
throw new Error('BLA');
|
||||
});
|
||||
endpoint.bind.mockRejectedValueOnce(new Error("BLA"));
|
||||
await mockZHEvents.lastSeenChanged({device});
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(3);
|
||||
|
||||
@@ -1,35 +1,34 @@
|
||||
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 * as data from "../mocks/data";
|
||||
import {mockLogger} from "../mocks/logger";
|
||||
import {mockMQTTEndAsync, 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 type Device from "../../lib/model/device";
|
||||
import type {Device as ZhDevice} from "../mocks/zigbeeHerdsman";
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
import * as zhc from "zigbee-herdsman-converters";
|
||||
|
||||
import {Controller} from '../../lib/controller';
|
||||
import * as settings from '../../lib/util/settings';
|
||||
import {Controller} from "../../lib/controller";
|
||||
import ExternalConverters from "../../lib/extension/externalConverters";
|
||||
import * as settings from "../../lib/util/settings";
|
||||
|
||||
const BASE_DIR = 'external_converters';
|
||||
const BASE_DIR = "external_converters";
|
||||
|
||||
describe('Extension: ExternalConverters', () => {
|
||||
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 rmSyncSpy = vi.spyOn(fs, "rmSync");
|
||||
const writeFileSyncSpy = vi.spyOn(fs, "writeFileSync");
|
||||
|
||||
const zhcAddDefinitionSpy = vi.spyOn(zhc, 'addDefinition');
|
||||
const zhcRemoveExternalDefinitionsSpy = vi.spyOn(zhc, 'removeExternalDefinitions');
|
||||
const zhcAddExternalDefinitionSpy = vi.spyOn(zhc, "addExternalDefinition");
|
||||
const zhcRemoveExternalDefinitionsSpy = vi.spyOn(zhc, "removeExternalDefinitions");
|
||||
|
||||
const mocksClear = [
|
||||
mockMQTTEndAsync,
|
||||
@@ -38,31 +37,28 @@ describe('Extension: ExternalConverters', () => {
|
||||
mockLogger.error,
|
||||
mockZHController.stop,
|
||||
devices.bulb.save,
|
||||
existsSyncSpy,
|
||||
readdirSyncSpy,
|
||||
mkdirSyncSpy,
|
||||
rmSyncSpy,
|
||||
writeFileSyncSpy,
|
||||
zhcAddDefinitionSpy,
|
||||
zhcAddExternalDefinitionSpy,
|
||||
zhcRemoveExternalDefinitionsSpy,
|
||||
];
|
||||
|
||||
const useAssets = (): void => {
|
||||
fs.cpSync(path.join(__dirname, '..', 'assets', BASE_DIR), mockBasePath, {recursive: true});
|
||||
const useAssets = (mtype: "cjs" | "mjs"): void => {
|
||||
fs.cpSync(path.join(__dirname, "..", "assets", BASE_DIR, mtype), mockBasePath, {recursive: true});
|
||||
};
|
||||
|
||||
const getFileCode = (fileName: string): string => {
|
||||
return fs.readFileSync(path.join(__dirname, '..', 'assets', BASE_DIR, fileName), 'utf8');
|
||||
const getFileCode = (mtype: "cjs" | "mjs", fileName: string): string => {
|
||||
return fs.readFileSync(path.join(__dirname, "..", "assets", BASE_DIR, mtype, fileName), "utf8");
|
||||
};
|
||||
|
||||
const getZ2MDevice = (zhDevice: unknown): Device => {
|
||||
const getZ2MDevice = (zhDevice: string | number | ZhDevice): 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');
|
||||
await controller.removeExtension(controller.getExtension("ExternalConverters")!);
|
||||
await controller.addExtension(new ExternalConverters(...controller.extensionArgs));
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -73,13 +69,15 @@ describe('Extension: ExternalConverters', () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
zhc.removeExternalDefinitions(); // remove all external converters
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
// @ts-expect-error private - clear cached
|
||||
await controller.zigbee.resolveDevicesDefinitions(true);
|
||||
for (const mock of mocksClear) mock.mockClear();
|
||||
data.writeDefaultConfiguration();
|
||||
data.writeDefaultState();
|
||||
settings.reRead();
|
||||
@@ -90,268 +88,526 @@ describe('Extension: ExternalConverters', () => {
|
||||
fs.rmSync(mockBasePath, {recursive: true, force: true});
|
||||
|
||||
await controller?.stop();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
describe('from folder', () => {
|
||||
beforeEach(async () => {
|
||||
describe("from folder", () => {
|
||||
beforeEach(() => {
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
});
|
||||
|
||||
it('loads nothing', async () => {
|
||||
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});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/converters", stringify([]), {retain: true});
|
||||
});
|
||||
|
||||
it('loads converters', async () => {
|
||||
useAssets();
|
||||
it("CJS: loads converters", async () => {
|
||||
useAssets("cjs");
|
||||
|
||||
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'],
|
||||
description: "external/converter",
|
||||
model: "external_converter_device",
|
||||
vendor: "external",
|
||||
zigbeeModel: ["external_converter_device"],
|
||||
});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/converters',
|
||||
"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')},
|
||||
{name: "mock-external-converter-multiple.js", code: getFileCode("cjs", "mock-external-converter-multiple.js")},
|
||||
{name: "mock-external-converter.js", code: getFileCode("cjs", "mock-external-converter.js")},
|
||||
]),
|
||||
{retain: true, qos: 0},
|
||||
{retain: true},
|
||||
);
|
||||
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenCalledTimes(2);
|
||||
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(1, 'mock-external-converter-multiple.js');
|
||||
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(2, 'mock-external-converter.js');
|
||||
expect(zhcAddDefinitionSpy).toHaveBeenNthCalledWith(
|
||||
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',
|
||||
model: "external_converters_device_1",
|
||||
zigbeeModel: ["external_converter_device_1"],
|
||||
vendor: "external_1",
|
||||
description: "external_1",
|
||||
}),
|
||||
);
|
||||
expect(zhcAddDefinitionSpy).toHaveBeenNthCalledWith(
|
||||
expect(zhcAddExternalDefinitionSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
mock: 2,
|
||||
model: 'external_converters_device_2',
|
||||
zigbeeModel: ['external_converter_device_2'],
|
||||
vendor: 'external_2',
|
||||
description: 'external_2',
|
||||
model: "external_converters_device_2",
|
||||
zigbeeModel: ["external_converter_device_2"],
|
||||
vendor: "external_2",
|
||||
description: "external_2",
|
||||
}),
|
||||
);
|
||||
expect(zhcAddDefinitionSpy).toHaveBeenNthCalledWith(
|
||||
expect(zhcAddExternalDefinitionSpy).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
mock: true,
|
||||
zigbeeModel: ['external_converter_device'],
|
||||
vendor: 'external',
|
||||
model: 'external_converter_device',
|
||||
description: 'external',
|
||||
zigbeeModel: ["external_converter_device"],
|
||||
vendor: "external",
|
||||
model: "external_converter_device",
|
||||
description: "external/converter",
|
||||
}),
|
||||
);
|
||||
|
||||
const bridgeDevices = mockMQTTPublishAsync.mock.calls.filter((c) => c[0] === 'zigbee2mqtt/bridge/devices');
|
||||
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',
|
||||
model_id: "external_converter_device",
|
||||
supported: true,
|
||||
definition: expect.objectContaining({
|
||||
description: 'external',
|
||||
model: 'external_converter_device',
|
||||
description: "external/converter",
|
||||
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);
|
||||
it("MJS: loads converters", async () => {
|
||||
useAssets("mjs");
|
||||
|
||||
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'],
|
||||
description: "external/converter",
|
||||
model: "external_converter_device",
|
||||
vendor: "external",
|
||||
zigbeeModel: ["external_converter_device"],
|
||||
});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/converters",
|
||||
stringify([
|
||||
{name: "mock-external-converter-multiple.mjs", code: getFileCode("mjs", "mock-external-converter-multiple.mjs")},
|
||||
{name: "mock-external-converter.mjs", code: getFileCode("mjs", "mock-external-converter.mjs")},
|
||||
]),
|
||||
{retain: true},
|
||||
);
|
||||
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenCalledTimes(2);
|
||||
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(1, "mock-external-converter-multiple.mjs");
|
||||
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(2, "mock-external-converter.mjs");
|
||||
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/converter",
|
||||
}),
|
||||
);
|
||||
|
||||
//-- SAVE
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode}));
|
||||
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/converter",
|
||||
model: "external_converter_device",
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates after edit from MQTT", async () => {
|
||||
const converterName = "mock-external-converter.js";
|
||||
let converterCode = getFileCode("cjs", converterName);
|
||||
|
||||
useAssets("cjs");
|
||||
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'],
|
||||
description: "external/converter",
|
||||
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(zhcAddDefinitionSpy).toHaveBeenCalledWith(
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/converters",
|
||||
stringify([
|
||||
{name: "mock-external-converter-multiple.js", code: getFileCode("cjs", "mock-external-converter-multiple.js")},
|
||||
{name: converterName, code: converterCode},
|
||||
]),
|
||||
{retain: true},
|
||||
);
|
||||
|
||||
converterCode = converterCode.replace('posix.join("external", "converter")', 'posix.join("external", "converter", "edited")');
|
||||
|
||||
await (controller.getExtension("ExternalConverters")! as ExternalConverters).onMQTTMessage({
|
||||
topic: "zigbee2mqtt/bridge/request/converter/save",
|
||||
message: {name: converterName, code: converterCode},
|
||||
});
|
||||
|
||||
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
|
||||
description: "external/converter/edited",
|
||||
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("cjs", "mock-external-converter-multiple.js")},
|
||||
{name: "mock-external-converter.js", code: converterCode},
|
||||
]),
|
||||
{retain: true},
|
||||
);
|
||||
expect(zhcAddExternalDefinitionSpy).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
mock: true,
|
||||
zigbeeModel: ['external_converter_device'],
|
||||
vendor: 'external',
|
||||
model: 'external_converter_device',
|
||||
description: 'external',
|
||||
zigbeeModel: ["external_converter_device"],
|
||||
vendor: "external",
|
||||
model: "external_converter_device",
|
||||
description: "external/converter/edited",
|
||||
externalConverterName: "mock-external-converter.js",
|
||||
}),
|
||||
);
|
||||
|
||||
converterCode = converterCode.replace('posix.join("external", "converter", "edited")', 'posix.join("external", "converter")');
|
||||
|
||||
await (controller.getExtension("ExternalConverters")! as ExternalConverters).onMQTTMessage({
|
||||
topic: "zigbee2mqtt/bridge/request/converter/save",
|
||||
message: {name: "mock-external-converter.js", code: converterCode},
|
||||
});
|
||||
|
||||
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
|
||||
description: "external/converter",
|
||||
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("cjs", "mock-external-converter-multiple.js")},
|
||||
{name: "mock-external-converter.js", code: converterCode},
|
||||
]),
|
||||
{retain: true},
|
||||
);
|
||||
expect(zhcAddExternalDefinitionSpy).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
mock: true,
|
||||
zigbeeModel: ["external_converter_device"],
|
||||
vendor: "external",
|
||||
model: "external_converter_device",
|
||||
description: "external/converter",
|
||||
externalConverterName: "mock-external-converter.js",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("loads all valid converters, relocates & skips ones with errors", async () => {
|
||||
useAssets("mjs");
|
||||
|
||||
const filepath = path.join(mockBasePath, "invalid.mjs");
|
||||
|
||||
fs.writeFileSync(filepath, "invalid js", "utf8");
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/converters",
|
||||
stringify([
|
||||
{name: "mock-external-converter-multiple.mjs", code: getFileCode("mjs", "mock-external-converter-multiple.mjs")},
|
||||
{name: "mock-external-converter.mjs", code: getFileCode("mjs", "mock-external-converter.mjs")},
|
||||
]),
|
||||
{retain: true},
|
||||
);
|
||||
expect(fs.existsSync(filepath)).toStrictEqual(false);
|
||||
expect(fs.existsSync(path.join(mockBasePath, "invalid.mjs.invalid"))).toStrictEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("from MQTT", () => {
|
||||
it("CJS: saves and removes", async () => {
|
||||
// Create a dummy 'node_modules' file to test to externalJS.ts recreates the symlink.
|
||||
fs.mkdirSync(mockBasePath);
|
||||
fs.writeFileSync(path.join(mockBasePath, "node_modules"), "");
|
||||
|
||||
const converterName = "foo.js";
|
||||
const converterCode = getFileCode("cjs", "mock-external-converter.js");
|
||||
|
||||
await resetExtension();
|
||||
for (const mock of mocksClear) mock.mockClear();
|
||||
|
||||
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
|
||||
description: "Automatically generated definition",
|
||||
model: "external_converter_device",
|
||||
vendor: "",
|
||||
zigbeeModel: ["external_converter_device"],
|
||||
});
|
||||
|
||||
//-- SAVE
|
||||
await (controller.getExtension("ExternalConverters")! as ExternalConverters).onMQTTMessage({
|
||||
topic: "zigbee2mqtt/bridge/request/converter/save",
|
||||
message: {name: converterName, code: converterCode},
|
||||
});
|
||||
|
||||
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
|
||||
description: "external/converter",
|
||||
model: "external_converter_device",
|
||||
vendor: "external",
|
||||
zigbeeModel: ["external_converter_device"],
|
||||
});
|
||||
expect(writeFileSyncSpy).toHaveBeenCalledWith(expect.stringContaining(converterName), 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/converter",
|
||||
}),
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/converters',
|
||||
"zigbee2mqtt/bridge/converters",
|
||||
stringify([{name: converterName, code: converterCode}]),
|
||||
{
|
||||
retain: true,
|
||||
},
|
||||
);
|
||||
// Ensure that the .tmp import file is deleted.
|
||||
expect(fs.readdirSync(mockBasePath)).toStrictEqual(["foo.js", "node_modules"]);
|
||||
|
||||
//-- REMOVE
|
||||
await (controller.getExtension("ExternalConverters")! as ExternalConverters).onMQTTMessage({
|
||||
topic: "zigbee2mqtt/bridge/request/converter/remove",
|
||||
message: {name: converterName},
|
||||
});
|
||||
|
||||
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
|
||||
description: "Automatically generated definition",
|
||||
model: "external_converter_device",
|
||||
vendor: "",
|
||||
zigbeeModel: ["external_converter_device"],
|
||||
});
|
||||
expect(rmSyncSpy).toHaveBeenCalledWith(expect.stringContaining(converterName), {force: true});
|
||||
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenCalledTimes(2);
|
||||
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(2, converterName);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/converters", stringify([]), {retain: true});
|
||||
});
|
||||
|
||||
it("MJS: saves and removes", async () => {
|
||||
const converterName = "foo.mjs";
|
||||
const converterCode = getFileCode("mjs", "mock-external-converter.mjs");
|
||||
|
||||
await resetExtension();
|
||||
for (const mock of mocksClear) mock.mockClear();
|
||||
|
||||
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
|
||||
description: "Automatically generated definition",
|
||||
model: "external_converter_device",
|
||||
vendor: "",
|
||||
zigbeeModel: ["external_converter_device"],
|
||||
});
|
||||
|
||||
//-- SAVE
|
||||
await (controller.getExtension("ExternalConverters")! as ExternalConverters).onMQTTMessage({
|
||||
topic: "zigbee2mqtt/bridge/request/converter/save",
|
||||
message: {name: converterName, code: converterCode},
|
||||
});
|
||||
|
||||
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
|
||||
description: "external/converter",
|
||||
model: "external_converter_device",
|
||||
vendor: "external",
|
||||
zigbeeModel: ["external_converter_device"],
|
||||
});
|
||||
expect(writeFileSyncSpy).toHaveBeenCalledWith(expect.stringContaining(converterName), 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/converter",
|
||||
}),
|
||||
);
|
||||
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();
|
||||
await (controller.getExtension("ExternalConverters")! as ExternalConverters).onMQTTMessage({
|
||||
topic: "zigbee2mqtt/bridge/request/converter/remove",
|
||||
message: {name: converterName},
|
||||
});
|
||||
|
||||
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
|
||||
description: 'Automatically generated definition',
|
||||
model: 'external_converter_device',
|
||||
vendor: '',
|
||||
zigbeeModel: ['external_converter_device'],
|
||||
description: "Automatically generated definition",
|
||||
model: "external_converter_device",
|
||||
vendor: "",
|
||||
zigbeeModel: ["external_converter_device"],
|
||||
});
|
||||
expect(rmSyncSpy).toHaveBeenCalledWith(converterFilePath, {force: true});
|
||||
expect(rmSyncSpy).toHaveBeenCalledWith(expect.stringContaining(converterName), {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`;
|
||||
|
||||
zhcAddDefinitionSpy.mockImplementationOnce(() => {
|
||||
throw new Error(errorMsg);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/converters", stringify([]), {retain: true});
|
||||
});
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode}));
|
||||
await flushPromises();
|
||||
it("returns error on invalid code", async () => {
|
||||
const converterName = "foo1.js";
|
||||
const converterCode = "definetly not a correct javascript code";
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/response/converter/save', expect.stringContaining(errorMsg), {
|
||||
retain: false,
|
||||
qos: 0,
|
||||
});
|
||||
expect(writeFileSyncSpy).not.toHaveBeenCalledWith(converterFilePath, converterCode, 'utf8');
|
||||
});
|
||||
await resetExtension();
|
||||
for (const mock of mocksClear) mock.mockClear();
|
||||
|
||||
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 (controller.getExtension("ExternalConverters")! as ExternalConverters).onMQTTMessage({
|
||||
topic: "zigbee2mqtt/bridge/request/converter/save",
|
||||
message: {name: converterName, code: converterCode},
|
||||
});
|
||||
|
||||
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);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/converter/save",
|
||||
expect.stringContaining(`"error":"${converterName} contains invalid code`),
|
||||
{},
|
||||
);
|
||||
expect(writeFileSyncSpy).toHaveBeenCalledWith(expect.stringContaining(converterName), converterCode, "utf8");
|
||||
});
|
||||
|
||||
//-- REMOVE
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/remove', stringify({name: converterName}));
|
||||
await flushPromises();
|
||||
it("returns error on invalid removal", async () => {
|
||||
const converterName = "foo2.js";
|
||||
|
||||
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});
|
||||
});
|
||||
await resetExtension();
|
||||
for (const mock of mocksClear) mock.mockClear();
|
||||
|
||||
it('handles invalid payloads', async () => {
|
||||
await resetExtension();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
await (controller.getExtension("ExternalConverters")! as ExternalConverters).onMQTTMessage({
|
||||
topic: "zigbee2mqtt/bridge/request/converter/remove",
|
||||
message: {name: converterName},
|
||||
});
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: 'test.js', transaction: 1 /* code */}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/converter/remove",
|
||||
expect.stringContaining("doesn't exists"),
|
||||
{},
|
||||
);
|
||||
expect(rmSyncSpy).not.toHaveBeenCalledWith(expect.stringContaining(converterName), {force: true});
|
||||
});
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/converter/save',
|
||||
stringify({data: {}, status: 'error', error: `Invalid payload`, transaction: 1}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
it("returns error on invalid definition", async () => {
|
||||
const converterName = "foo3.js";
|
||||
const converterCode = getFileCode("cjs", "mock-external-converter.js");
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/remove', stringify({namex: 'test.js', transaction: 2}));
|
||||
await flushPromises();
|
||||
await resetExtension();
|
||||
for (const mock of mocksClear) mock.mockClear();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/converter/remove',
|
||||
stringify({data: {}, status: 'error', error: `Invalid payload`, transaction: 2}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
const errorMsg = "Invalid definition";
|
||||
|
||||
zhcAddExternalDefinitionSpy.mockImplementationOnce(() => {
|
||||
throw new Error(errorMsg);
|
||||
});
|
||||
|
||||
await (controller.getExtension("ExternalConverters")! as ExternalConverters).onMQTTMessage({
|
||||
topic: "zigbee2mqtt/bridge/request/converter/save",
|
||||
message: {name: converterName, code: converterCode},
|
||||
});
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/response/converter/save", expect.stringContaining(errorMsg), {});
|
||||
expect(writeFileSyncSpy).toHaveBeenCalledWith(expect.stringContaining(converterName), converterCode, "utf8");
|
||||
});
|
||||
|
||||
it("returns error on failed removal", async () => {
|
||||
const converterName = "foo4.js";
|
||||
const converterCode = getFileCode("cjs", "mock-external-converter.js");
|
||||
|
||||
await resetExtension();
|
||||
for (const mock of mocksClear) mock.mockClear();
|
||||
|
||||
//-- SAVE
|
||||
await (controller.getExtension("ExternalConverters")! as ExternalConverters).onMQTTMessage({
|
||||
topic: "zigbee2mqtt/bridge/request/converter/save",
|
||||
message: {name: converterName, code: converterCode},
|
||||
});
|
||||
|
||||
const errorMsg = "Failed to remove definition";
|
||||
|
||||
zhcRemoveExternalDefinitionsSpy.mockImplementationOnce(() => {
|
||||
throw new Error(errorMsg);
|
||||
});
|
||||
|
||||
//-- REMOVE
|
||||
await (controller.getExtension("ExternalConverters")! as ExternalConverters).onMQTTMessage({
|
||||
topic: "zigbee2mqtt/bridge/request/converter/remove",
|
||||
message: {name: converterName},
|
||||
});
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/converter/remove",
|
||||
stringify({data: {}, status: "error", error: errorMsg}),
|
||||
{},
|
||||
);
|
||||
expect(rmSyncSpy).not.toHaveBeenCalledWith(expect.stringContaining(converterName), {force: true});
|
||||
});
|
||||
|
||||
it("handles invalid payloads", async () => {
|
||||
await resetExtension();
|
||||
for (const mock of mocksClear) mock.mockClear();
|
||||
|
||||
await (controller.getExtension("ExternalConverters")! as ExternalConverters).onMQTTMessage({
|
||||
topic: "zigbee2mqtt/bridge/request/converter/save",
|
||||
message: {name: "foo5.js", transaction: 1 /* code */},
|
||||
});
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/converter/save",
|
||||
stringify({data: {}, status: "error", error: "Invalid payload", transaction: 1}),
|
||||
{},
|
||||
);
|
||||
|
||||
await (controller.getExtension("ExternalConverters")! as ExternalConverters).onMQTTMessage({
|
||||
topic: "zigbee2mqtt/bridge/request/converter/remove",
|
||||
message: {namex: "foo5.js", transaction: 2},
|
||||
});
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/converter/remove",
|
||||
stringify({data: {}, status: "error", error: "Invalid payload", transaction: 2}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,28 +1,26 @@
|
||||
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 * as data from "../mocks/data";
|
||||
import {mockLogger} from "../mocks/logger";
|
||||
import {mockMQTTEndAsync, 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 fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
|
||||
import {Controller} from '../../lib/controller';
|
||||
import * as settings from '../../lib/util/settings';
|
||||
import {Controller} from "../../lib/controller";
|
||||
import ExternalExtensions from "../../lib/extension/externalExtensions";
|
||||
import * as settings from "../../lib/util/settings";
|
||||
|
||||
const BASE_DIR = 'external_extensions';
|
||||
const BASE_DIR = "external_extensions";
|
||||
|
||||
describe('Extension: ExternalExtensions', () => {
|
||||
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 rmSyncSpy = vi.spyOn(fs, "rmSync");
|
||||
const writeFileSyncSpy = vi.spyOn(fs, "writeFileSync");
|
||||
|
||||
const mocksClear = [
|
||||
mockMQTTEndAsync,
|
||||
@@ -31,177 +29,338 @@ describe('Extension: ExternalExtensions', () => {
|
||||
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 useAssets = (mtype: "cjs" | "mjs"): void => {
|
||||
fs.cpSync(path.join(__dirname, "..", "assets", BASE_DIR, mtype), mockBasePath, {recursive: true});
|
||||
};
|
||||
|
||||
const getFileCode = (fileName: string): string => {
|
||||
return fs.readFileSync(path.join(__dirname, '..', 'assets', BASE_DIR, fileName), 'utf8');
|
||||
const getFileCode = (mtype: "cjs" | "mjs", fileName: string): string => {
|
||||
return fs.readFileSync(path.join(__dirname, "..", "assets", BASE_DIR, mtype, fileName), "utf8");
|
||||
};
|
||||
|
||||
const resetExtension = async (): Promise<void> => {
|
||||
await controller.enableDisableExtension(false, 'ExternalExtensions');
|
||||
await controller.enableDisableExtension(true, 'ExternalExtensions');
|
||||
await controller.removeExtension(controller.getExtension("ExternalExtensions")!);
|
||||
await controller.addExtension(new ExternalExtensions(...controller.extensionArgs));
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
beforeEach(() => {
|
||||
for (const mock of mocksClear) mock.mockClear();
|
||||
data.writeDefaultConfiguration();
|
||||
data.writeDefaultState();
|
||||
settings.reRead();
|
||||
returnDevices.splice(0);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
fs.rmSync(mockBasePath, {recursive: true, force: true});
|
||||
|
||||
await controller?.stop();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
describe('from folder', () => {
|
||||
beforeEach(async () => {
|
||||
describe("from folder", () => {
|
||||
beforeEach(() => {
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
});
|
||||
|
||||
it('loads nothing', async () => {
|
||||
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});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/extensions", stringify([]), {retain: true});
|
||||
});
|
||||
|
||||
it('loads extensions', async () => {
|
||||
useAssets();
|
||||
it("CJS: loads extensions", async () => {
|
||||
useAssets("cjs");
|
||||
|
||||
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/example2/extension", "call2 from constructor", {});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example2/extension", "call2 from start", {});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from constructor", {});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start", {});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/extensions',
|
||||
"zigbee2mqtt/bridge/extensions",
|
||||
stringify([
|
||||
{name: 'example2Extension.js', code: getFileCode('example2Extension.js')},
|
||||
{name: 'exampleExtension.js', code: getFileCode('exampleExtension.js')},
|
||||
{name: "example2Extension.js", code: getFileCode("cjs", "example2Extension.js")},
|
||||
{name: "exampleExtension.js", code: getFileCode("cjs", "exampleExtension.js")},
|
||||
]),
|
||||
{retain: true, qos: 0},
|
||||
{retain: true},
|
||||
);
|
||||
});
|
||||
|
||||
it('saves and removes from MQTT', async () => {
|
||||
const extensionName = 'foo.js';
|
||||
const extensionCode = getFileCode('exampleExtension.js');
|
||||
const extensionFilePath = path.join(mockBasePath, extensionName);
|
||||
it("MJS: loads extensions", async () => {
|
||||
useAssets("mjs");
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
|
||||
//-- SAVE
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: extensionName, code: extensionCode}));
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example2/extension", "call2 from constructor", {});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example2/extension", "call2 from start", {});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from constructor", {});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start", {});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/extensions",
|
||||
stringify([
|
||||
{name: "example2Extension.mjs", code: getFileCode("mjs", "example2Extension.mjs")},
|
||||
{name: "exampleExtension.mjs", code: getFileCode("mjs", "exampleExtension.mjs")},
|
||||
]),
|
||||
{retain: true},
|
||||
);
|
||||
});
|
||||
|
||||
it("starts extensions only once", async () => {
|
||||
useAssets("mjs");
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension/counter", "start 0", {});
|
||||
expect(mockMQTTPublishAsync).not.toHaveBeenCalledWith("zigbee2mqtt/example/extension/counter", "start 1", {});
|
||||
});
|
||||
|
||||
it("loads all valid extensions, relocates & skips ones with errors", async () => {
|
||||
useAssets("mjs");
|
||||
|
||||
const filepath = path.join(mockBasePath, "invalid.mjs");
|
||||
|
||||
fs.writeFileSync(filepath, "invalid js", "utf8");
|
||||
|
||||
await controller.start();
|
||||
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',
|
||||
"zigbee2mqtt/bridge/extensions",
|
||||
stringify([
|
||||
{name: "example2Extension.mjs", code: getFileCode("mjs", "example2Extension.mjs")},
|
||||
{name: "exampleExtension.mjs", code: getFileCode("mjs", "exampleExtension.mjs")},
|
||||
]),
|
||||
{retain: true},
|
||||
);
|
||||
expect(fs.existsSync(filepath)).toStrictEqual(false);
|
||||
expect(fs.existsSync(path.join(mockBasePath, "invalid.mjs.invalid"))).toStrictEqual(true);
|
||||
});
|
||||
|
||||
it("updates after edit from MQTT", async () => {
|
||||
const extensionName = "exampleExtension.js";
|
||||
let extensionCode = getFileCode("cjs", extensionName);
|
||||
|
||||
useAssets("cjs");
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example2/extension", "call2 from constructor", {});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example2/extension", "call2 from start", {});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from constructor", {});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start", {});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/extensions",
|
||||
stringify([
|
||||
{name: "example2Extension.js", code: getFileCode("cjs", "example2Extension.js")},
|
||||
{name: extensionName, code: extensionCode},
|
||||
]),
|
||||
{retain: true},
|
||||
);
|
||||
|
||||
extensionCode = extensionCode
|
||||
.replace('"call from start"', '"call from start - edited"')
|
||||
.replace('"call from stop"', '"call from stop - edited"');
|
||||
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
await (controller.getExtension("ExternalExtensions")! as ExternalExtensions).onMQTTMessage({
|
||||
topic: "zigbee2mqtt/bridge/request/extension/save",
|
||||
message: {name: extensionName, code: extensionCode},
|
||||
});
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from stop", {});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start - edited", {});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/extensions",
|
||||
stringify([
|
||||
{name: "example2Extension.js", code: getFileCode("cjs", "example2Extension.js")},
|
||||
{name: "exampleExtension.js", code: extensionCode},
|
||||
]),
|
||||
{retain: true},
|
||||
);
|
||||
|
||||
extensionCode = extensionCode
|
||||
.replace('"call from start - edited"', '"call from start"')
|
||||
.replace('"call from stop - edited"', '"call from stop"');
|
||||
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
await (controller.getExtension("ExternalExtensions")! as ExternalExtensions).onMQTTMessage({
|
||||
topic: "zigbee2mqtt/bridge/request/extension/save",
|
||||
message: {name: "exampleExtension.js", code: extensionCode},
|
||||
});
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from stop - edited", {});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start", {});
|
||||
expect(mockMQTTPublishAsync).not.toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start - edited", {});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/extensions",
|
||||
stringify([
|
||||
{name: "example2Extension.js", code: getFileCode("cjs", "example2Extension.js")},
|
||||
{name: "exampleExtension.js", code: extensionCode},
|
||||
]),
|
||||
{retain: true},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("from MQTT", () => {
|
||||
it("CJS: saves and removes", async () => {
|
||||
const extensionName = "foo.js";
|
||||
const extensionCode = getFileCode("cjs", "exampleExtension.js");
|
||||
|
||||
await resetExtension();
|
||||
for (const mock of mocksClear) mock.mockClear();
|
||||
|
||||
//-- SAVE
|
||||
await (controller.getExtension("ExternalExtensions")! as ExternalExtensions).onMQTTMessage({
|
||||
topic: "zigbee2mqtt/bridge/request/extension/save",
|
||||
message: {name: extensionName, code: extensionCode},
|
||||
});
|
||||
|
||||
expect(writeFileSyncSpy).toHaveBeenCalledWith(expect.stringContaining(extensionName), extensionCode, "utf8");
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from constructor", {});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start", {});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/extensions",
|
||||
stringify([{name: extensionName, code: extensionCode}]),
|
||||
{
|
||||
retain: true,
|
||||
},
|
||||
);
|
||||
// Ensure that the .tmp import file is deleted.
|
||||
expect(fs.readdirSync(mockBasePath)).toStrictEqual(["foo.js", "node_modules"]);
|
||||
|
||||
//-- REMOVE
|
||||
await (controller.getExtension("ExternalExtensions")! as ExternalExtensions).onMQTTMessage({
|
||||
topic: "zigbee2mqtt/bridge/request/extension/remove",
|
||||
message: {name: extensionName},
|
||||
});
|
||||
|
||||
expect(rmSyncSpy).toHaveBeenCalledWith(expect.stringContaining(extensionName), {force: true});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from stop", {});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/extensions", stringify([]), {retain: true});
|
||||
});
|
||||
|
||||
it("MJS: saves and removes", async () => {
|
||||
const extensionName = "foo.mjs";
|
||||
const extensionCode = getFileCode("mjs", "exampleExtension.mjs");
|
||||
|
||||
await resetExtension();
|
||||
for (const mock of mocksClear) mock.mockClear();
|
||||
|
||||
//-- SAVE
|
||||
await (controller.getExtension("ExternalExtensions")! as ExternalExtensions).onMQTTMessage({
|
||||
topic: "zigbee2mqtt/bridge/request/extension/save",
|
||||
message: {name: extensionName, code: extensionCode},
|
||||
});
|
||||
|
||||
expect(writeFileSyncSpy).toHaveBeenCalledWith(expect.stringContaining(extensionName), extensionCode, "utf8");
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from constructor", {});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start", {});
|
||||
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();
|
||||
await (controller.getExtension("ExternalExtensions")! as ExternalExtensions).onMQTTMessage({
|
||||
topic: "zigbee2mqtt/bridge/request/extension/remove",
|
||||
message: {name: extensionName},
|
||||
});
|
||||
|
||||
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});
|
||||
expect(rmSyncSpy).toHaveBeenCalledWith(expect.stringContaining(extensionName), {force: true});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from stop", {});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/extensions", stringify([]), {retain: true});
|
||||
});
|
||||
|
||||
it("returns error on invalid code", async () => {
|
||||
const extensionName = "foo1.js";
|
||||
const extensionCode = "definetly not a correct javascript code";
|
||||
|
||||
await resetExtension();
|
||||
for (const mock of mocksClear) mock.mockClear();
|
||||
|
||||
await (controller.getExtension("ExternalExtensions")! as ExternalExtensions).onMQTTMessage({
|
||||
topic: "zigbee2mqtt/bridge/request/extension/save",
|
||||
message: {name: extensionName, code: extensionCode},
|
||||
});
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/extension/save",
|
||||
expect.stringContaining(`"error":"${extensionName} contains invalid code`),
|
||||
{},
|
||||
);
|
||||
expect(writeFileSyncSpy).toHaveBeenCalledWith(expect.stringContaining(extensionName), extensionCode, "utf8");
|
||||
});
|
||||
|
||||
it("returns error on invalid removal", async () => {
|
||||
const extensionName = "foo2.js";
|
||||
|
||||
await resetExtension();
|
||||
for (const mock of mocksClear) mock.mockClear();
|
||||
|
||||
await (controller.getExtension("ExternalExtensions")! as ExternalExtensions).onMQTTMessage({
|
||||
topic: "zigbee2mqtt/bridge/request/extension/remove",
|
||||
message: {name: extensionName},
|
||||
});
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/extension/remove",
|
||||
expect.stringContaining("doesn't exists"),
|
||||
{},
|
||||
);
|
||||
expect(rmSyncSpy).not.toHaveBeenCalledWith(expect.stringContaining(extensionName), {force: true});
|
||||
});
|
||||
|
||||
it("handles invalid payloads", async () => {
|
||||
await resetExtension();
|
||||
for (const mock of mocksClear) mock.mockClear();
|
||||
|
||||
await (controller.getExtension("ExternalExtensions")! as ExternalExtensions).onMQTTMessage({
|
||||
topic: "zigbee2mqtt/bridge/request/extension/save",
|
||||
message: {name: "foo3.js", transaction: 1 /* code */},
|
||||
});
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/extension/save",
|
||||
stringify({data: {}, status: "error", error: "Invalid payload", transaction: 1}),
|
||||
{},
|
||||
);
|
||||
|
||||
await (controller.getExtension("ExternalExtensions")! as ExternalExtensions).onMQTTMessage({
|
||||
topic: "zigbee2mqtt/bridge/request/extension/remove",
|
||||
message: {namex: "foo3.js", transaction: 2},
|
||||
});
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/extension/remove",
|
||||
stringify({data: {}, status: "error", error: "Invalid payload", transaction: 2}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+169
-112
@@ -1,17 +1,17 @@
|
||||
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 * as data from "../mocks/data";
|
||||
import {mockLogger} from "../mocks/logger";
|
||||
import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
|
||||
import {type EventHandler, flushPromises} from "../mocks/utils";
|
||||
import {devices, events as mockZHEvents} from "../mocks/zigbeeHerdsman";
|
||||
|
||||
import path from 'node:path';
|
||||
import path from "node:path";
|
||||
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import {Mock} from 'vitest';
|
||||
import ws from 'ws';
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import type {Mock} from "vitest";
|
||||
import ws from "ws";
|
||||
|
||||
import {Controller} from '../../lib/controller';
|
||||
import * as settings from '../../lib/util/settings';
|
||||
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> = {};
|
||||
@@ -23,7 +23,6 @@ const mockHTTP = {
|
||||
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 = {
|
||||
@@ -45,7 +44,7 @@ const mockWSClient = {
|
||||
},
|
||||
send: vi.fn<(data: string) => void>(),
|
||||
terminate: vi.fn<() => void>(),
|
||||
readyState: 'close',
|
||||
readyState: "close",
|
||||
};
|
||||
const mockWSEvents: Record<string, EventHandler> = {};
|
||||
const mockWSClients: (typeof mockWSClient)[] = [];
|
||||
@@ -54,21 +53,21 @@ const mockWS = {
|
||||
on: (event: string, handler: EventHandler): void => {
|
||||
mockWSEvents[event] = handler;
|
||||
},
|
||||
handleUpgrade: vi.fn().mockImplementation((request, socket, head, cb) => {
|
||||
handleUpgrade: vi.fn().mockImplementation((_request, _socket, _head, cb) => {
|
||||
cb(mockWSocket);
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
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');
|
||||
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', () => ({
|
||||
vi.mock("node:http", () => ({
|
||||
createServer: vi.fn().mockImplementation((onRequest) => {
|
||||
mockHTTPOnRequest = onRequest;
|
||||
return mockHTTP;
|
||||
@@ -76,7 +75,7 @@ vi.mock('node:http', () => ({
|
||||
Agent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:https', () => ({
|
||||
vi.mock("node:https", () => ({
|
||||
createServer: vi.fn().mockImplementation((onRequest) => {
|
||||
mockHTTPSOnRequest = onRequest;
|
||||
return mockHTTPS;
|
||||
@@ -84,29 +83,29 @@ vi.mock('node:https', () => ({
|
||||
Agent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('express-static-gzip', () => ({
|
||||
vi.mock("express-static-gzip", () => ({
|
||||
default: vi.fn().mockImplementation((path: string) => {
|
||||
mockNodeStatic[path] = vi.fn();
|
||||
return mockNodeStatic[path];
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('zigbee2mqtt-frontend', () => ({
|
||||
vi.mock("zigbee2mqtt-frontend", () => ({
|
||||
default: {
|
||||
getPath: (): string => frontendPath,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('ws', () => ({
|
||||
vi.mock("ws", () => ({
|
||||
default: {
|
||||
OPEN: 'open',
|
||||
OPEN: "open",
|
||||
Server: vi.fn().mockImplementation(() => {
|
||||
return mockWS;
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('finalhandler', () => ({
|
||||
vi.mock("finalhandler", () => ({
|
||||
default: vi.fn().mockImplementation(() => {
|
||||
return mockFinalHandler;
|
||||
}),
|
||||
@@ -128,39 +127,41 @@ const mocksClear = [
|
||||
mockLogger.error,
|
||||
];
|
||||
|
||||
describe('Extension: Frontend', () => {
|
||||
describe("Extension: Frontend", () => {
|
||||
let controller: Controller;
|
||||
|
||||
beforeAll(async () => {
|
||||
beforeAll(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
beforeEach(() => {
|
||||
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});
|
||||
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';
|
||||
for (const mock of mocksClear) mock.mockClear();
|
||||
mockWSClient.readyState = "close";
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
delete devices.bulb.linkquality;
|
||||
await controller?.stop();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it('Start/stop with defaults', async () => {
|
||||
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');
|
||||
expect(mockHTTP.listen).toHaveBeenCalledWith(8081, "127.0.0.1");
|
||||
mockWS.clients.push(mockWSClient);
|
||||
await controller.stop();
|
||||
expect(mockWSClient.terminate).toHaveBeenCalledTimes(1);
|
||||
@@ -168,8 +169,8 @@ describe('Extension: Frontend', () => {
|
||||
expect(mockWS.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Start/stop without host', async () => {
|
||||
settings.set(['frontend'], {enabled: true, port: 8081});
|
||||
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]);
|
||||
@@ -181,12 +182,12 @@ describe('Extension: Frontend', () => {
|
||||
expect(mockWS.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Start/stop unix socket', async () => {
|
||||
settings.set(['frontend', 'host'], '/tmp/zigbee2mqtt.sock');
|
||||
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');
|
||||
expect(mockHTTP.listen).toHaveBeenCalledWith("/tmp/zigbee2mqtt.sock");
|
||||
mockWS.clients.push(mockWSClient);
|
||||
await controller.stop();
|
||||
expect(mockWSClient.terminate).toHaveBeenCalledTimes(1);
|
||||
@@ -194,84 +195,112 @@ describe('Extension: Frontend', () => {
|
||||
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'));
|
||||
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');
|
||||
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'));
|
||||
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');
|
||||
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'));
|
||||
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');
|
||||
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 () => {
|
||||
it("Websocket interaction", async () => {
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
await controller.start();
|
||||
mockWSClient.readyState = 'open';
|
||||
mockWSClient.readyState = "open";
|
||||
mockWS.clients.push(mockWSClient);
|
||||
await mockWSEvents.connection(mockWSClient);
|
||||
devices.bulb_color.linkquality = 20;
|
||||
|
||||
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}}));
|
||||
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);
|
||||
mockWSClientEvents.message(stringify({topic: "bulb_color/set", payload: {state: "ON"}}), false);
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bulb_color',
|
||||
"zigbee2mqtt/bulb_color",
|
||||
stringify({
|
||||
state: 'ON',
|
||||
state: "ON",
|
||||
effect: null,
|
||||
power_on_behavior: null,
|
||||
linkquality: null,
|
||||
linkquality: 20,
|
||||
update: {state: null, installed_version: -1, latest_version: -1},
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
mockWSClientEvents.message(undefined, false);
|
||||
mockWSClientEvents.message('', 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');
|
||||
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',
|
||||
topic: "bulb_color",
|
||||
payload: {
|
||||
state: 'ON',
|
||||
effect: null,
|
||||
state: "ON",
|
||||
power_on_behavior: null,
|
||||
linkquality: null,
|
||||
effect: null,
|
||||
linkquality: 20,
|
||||
update: {state: null, installed_version: -1, latest_version: -1},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Should publish bridge messages
|
||||
await mockZHEvents.deviceJoined({device: devices.bulb});
|
||||
await flushPromises();
|
||||
expect(mockWSClient.send).toHaveBeenCalledWith(
|
||||
stringify({payload: {data: {friendly_name: "bulb", ieee_address: "0x000b57fffec6a5b2"}, type: "device_joined"}, topic: "bridge/event"}),
|
||||
);
|
||||
|
||||
// Should send JSON state event when `output: attribute`
|
||||
mockWSClient.send.mockClear();
|
||||
settings.set(["advanced", "output"], "attribute");
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({brightness: 90}));
|
||||
await flushPromises();
|
||||
expect(mockWSClient.send).toHaveBeenCalledTimes(1);
|
||||
expect(mockWSClient.send).toHaveBeenCalledWith(
|
||||
stringify({
|
||||
topic: "bulb_color",
|
||||
payload: {
|
||||
state: "ON",
|
||||
brightness: 90,
|
||||
power_on_behavior: null,
|
||||
effect: null,
|
||||
linkquality: 20,
|
||||
update: {state: null, installed_version: -1, latest_version: -1},
|
||||
},
|
||||
}),
|
||||
@@ -279,76 +308,76 @@ describe('Extension: Frontend', () => {
|
||||
|
||||
// Shouldnt set when not ready
|
||||
mockWSClient.send.mockClear();
|
||||
mockWSClient.readyState = 'close';
|
||||
mockWSClientEvents.message(stringify({topic: 'bulb_color/set', payload: {state: 'ON'}}), false);
|
||||
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'});
|
||||
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'}}),
|
||||
stringify({topic: "remote", payload: {brightness: 255, last_seen: "1970-01-01T00:00:01.000Z"}}),
|
||||
);
|
||||
});
|
||||
|
||||
it('onRequest/onUpgrade', async () => {
|
||||
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);
|
||||
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));
|
||||
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'});
|
||||
expect(mockWS.emit).toHaveBeenCalledWith("connection", 99, {url: "http://localhost:8080/api"});
|
||||
|
||||
mockHTTPOnRequest({url: '/file.txt'}, 2);
|
||||
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'},
|
||||
{originalUrl: "/file.txt", path: "/file.txt", url: "/file.txt"},
|
||||
2,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should serve device icons', async () => {
|
||||
it("Should serve device icons", async () => {
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
await controller.start();
|
||||
|
||||
mockHTTPOnRequest({url: '/device_icons/my_device.png'}, 2);
|
||||
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'},
|
||||
{originalUrl: "/device_icons/my_device.png", path: "/my_device.png", url: "/my_device.png"},
|
||||
2,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Static server', async () => {
|
||||
it("Static server", async () => {
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
await controller.start();
|
||||
|
||||
expect(mockHTTP.listen).toHaveBeenCalledWith(8081, '127.0.0.1');
|
||||
expect(mockHTTP.listen).toHaveBeenCalledWith(8081, "127.0.0.1");
|
||||
});
|
||||
|
||||
it('Authentification', async () => {
|
||||
const authToken = 'sample-secure-token';
|
||||
settings.set(['frontend', 'auth_token'], authToken);
|
||||
it("Authentication", 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);
|
||||
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');
|
||||
expect(mockWS.handleUpgrade).toHaveBeenCalledWith({url: "/api"}, mockSocket, mockWSocket, expect.any(Function));
|
||||
expect(mockWSocket.close).toHaveBeenCalledWith(4401, "Unauthorized");
|
||||
|
||||
mockWSocket.close.mockClear();
|
||||
mockWS.emit.mockClear();
|
||||
@@ -361,58 +390,58 @@ describe('Extension: Frontend', () => {
|
||||
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});
|
||||
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);
|
||||
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'});
|
||||
expect(ws.Server).toHaveBeenCalledWith({noServer: true, path: "/z2m/api"});
|
||||
|
||||
mockHTTPOnRequest({url: '/z2m'}, 2);
|
||||
mockHTTPOnRequest({url: "/z2m"}, 2);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith({originalUrl: '/z2m', path: '/', url: '/'}, 2, expect.any(Function));
|
||||
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);
|
||||
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'},
|
||||
{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);
|
||||
mockHTTPOnRequest({url: "/z/file.txt"}, 2);
|
||||
expect(mockNodeStatic[frontendPath]).not.toHaveBeenCalled();
|
||||
expect(mockFinalHandler).toHaveBeenCalled();
|
||||
|
||||
mockHTTPOnRequest({url: '/z2m/device_icons/my-device.png'}, 2);
|
||||
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'},
|
||||
{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);
|
||||
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'});
|
||||
expect(ws.Server).toHaveBeenCalledWith({noServer: true, path: "/z2m-more++/c0mplex.url/api"});
|
||||
|
||||
mockHTTPOnRequest({url: '/z2m-more++/c0mplex.url'}, 2);
|
||||
mockHTTPOnRequest({url: "/z2m-more++/c0mplex.url"}, 2);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith(
|
||||
{originalUrl: '/z2m-more++/c0mplex.url', path: '/', url: '/'},
|
||||
{originalUrl: "/z2m-more++/c0mplex.url", path: "/", url: "/"},
|
||||
2,
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -420,18 +449,46 @@ describe('Extension: Frontend', () => {
|
||||
|
||||
mockNodeStatic[frontendPath].mockReset();
|
||||
expect(mockFinalHandler).not.toHaveBeenCalledWith();
|
||||
mockHTTPOnRequest({url: '/z2m-more++/c0mplex.url/file.txt'}, 2);
|
||||
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'},
|
||||
{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);
|
||||
mockHTTPOnRequest({url: "/z/file.txt"}, 2);
|
||||
expect(mockNodeStatic[frontendPath]).not.toHaveBeenCalled();
|
||||
expect(mockFinalHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prevents mismatching setting/extension state", async () => {
|
||||
settings.set(["frontend", "enabled"], false);
|
||||
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
await controller.start();
|
||||
|
||||
await expect(async () => {
|
||||
await controller.enableDisableExtension(true, "Frontend");
|
||||
}).rejects.toThrow("Tried to enable Frontend extension disabled in settings");
|
||||
|
||||
settings.set(["frontend", "enabled"], true);
|
||||
|
||||
await expect(async () => {
|
||||
await controller.enableDisableExtension(false, "Frontend");
|
||||
}).rejects.toThrow("Tried to disable Frontend extension enabled in settings");
|
||||
|
||||
await controller.enableDisableExtension(true, "Frontend");
|
||||
|
||||
await expect(async () => {
|
||||
await controller.enableDisableExtension(true, "Frontend");
|
||||
}).rejects.toThrow("Extension with name Frontend already present");
|
||||
|
||||
settings.set(["frontend", "enabled"], false);
|
||||
await controller.enableDisableExtension(false, "Frontend");
|
||||
|
||||
await vi.waitFor(() => controller.getExtension("Frontend") === undefined);
|
||||
});
|
||||
});
|
||||
|
||||
+260
-258
File diff suppressed because it is too large
Load Diff
+1342
-1181
File diff suppressed because it is too large
Load Diff
+271
-254
@@ -1,17 +1,17 @@
|
||||
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';
|
||||
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";
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
|
||||
import {Controller} from '../../lib/controller';
|
||||
import * as settings from '../../lib/util/settings';
|
||||
import {Controller} from "../../lib/controller";
|
||||
import * as settings from "../../lib/util/settings";
|
||||
|
||||
returnDevices.push(
|
||||
devices.coordinator.ieeeAddr,
|
||||
@@ -25,7 +25,7 @@ returnDevices.push(
|
||||
|
||||
const mocksClear = [mockMQTTPublishAsync, mockLogger.warning, mockLogger.debug];
|
||||
|
||||
describe('Extension: NetworkMap', () => {
|
||||
describe("Extension: NetworkMap", () => {
|
||||
let controller: Controller;
|
||||
|
||||
const mock = (): void => {
|
||||
@@ -59,7 +59,7 @@ describe('Extension: NetworkMap', () => {
|
||||
],
|
||||
});
|
||||
devices.coordinator.routingTable.mockResolvedValueOnce({
|
||||
table: [{destinationAddress: devices.CC2530_ROUTER.networkAddress, status: 'ACTIVE', nextHop: devices.bulb.networkAddress}],
|
||||
table: [{destinationAddress: devices.CC2530_ROUTER.networkAddress, status: "ACTIVE", nextHop: devices.bulb.networkAddress}],
|
||||
});
|
||||
devices.bulb.lqi.mockResolvedValueOnce({
|
||||
neighbors: [
|
||||
@@ -81,7 +81,7 @@ describe('Extension: NetworkMap', () => {
|
||||
});
|
||||
devices.CC2530_ROUTER.lqi.mockResolvedValueOnce({
|
||||
neighbors: [
|
||||
{ieeeAddr: '0x0000000000000000', networkAddress: devices.WXKG02LM_rev1.networkAddress, relationship: 1, depth: 2, linkquality: 130},
|
||||
{ieeeAddr: "0x0000000000000000", networkAddress: devices.WXKG02LM_rev1.networkAddress, relationship: 1, depth: 2, linkquality: 130},
|
||||
{
|
||||
ieeeAddr: devices.bulb_color.ieeeAddr,
|
||||
networkAddress: devices.bulb_color.networkAddress,
|
||||
@@ -91,28 +91,28 @@ describe('Extension: NetworkMap', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
devices.unsupported_router.lqi.mockRejectedValueOnce('failed').mockRejectedValueOnce('failed');
|
||||
devices.unsupported_router.routingTable.mockRejectedValueOnce('failed').mockRejectedValueOnce('failed');
|
||||
devices.unsupported_router.lqi.mockRejectedValueOnce("failed").mockRejectedValueOnce("failed");
|
||||
devices.unsupported_router.routingTable.mockRejectedValueOnce("failed").mockRejectedValueOnce("failed");
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.useFakeTimers();
|
||||
mockSleep.mock();
|
||||
vi.spyOn(Date, 'now').mockReturnValue(10000);
|
||||
vi.spyOn(Date, "now").mockReturnValue(10000);
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
data.writeEmptyState();
|
||||
fs.mkdirSync(path.join(data.mockDir, 'external_converters'));
|
||||
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'),
|
||||
path.join(__dirname, "..", "assets", "external_converters", "cjs", "mock-external-converter.js"),
|
||||
path.join(data.mockDir, "external_converters", "mock-external-converter.js"),
|
||||
);
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
await controller.start();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
for (const mock of mocksClear) mock.mockClear();
|
||||
await flushPromises();
|
||||
const device = devices.bulb_color;
|
||||
device.lastSeen = 1000;
|
||||
@@ -123,21 +123,23 @@ describe('Extension: NetworkMap', () => {
|
||||
|
||||
afterAll(async () => {
|
||||
mockSleep.restore();
|
||||
fs.rmSync(path.join(data.mockDir, 'external_converters'), {recursive: true});
|
||||
fs.rmSync(path.join(data.mockDir, "external_converters"), {recursive: true});
|
||||
await controller?.stop();
|
||||
await flushPromises();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('Output raw networkmap', async () => {
|
||||
it("Output raw networkmap", async () => {
|
||||
mock();
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/networkmap', stringify({type: 'raw', routes: true}));
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/networkmap", stringify({type: "raw", routes: true}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/response/networkmap');
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bridge/response/networkmap");
|
||||
|
||||
const expected = {
|
||||
data: {
|
||||
routes: true,
|
||||
type: 'raw',
|
||||
type: "raw",
|
||||
value: {
|
||||
links: [
|
||||
{
|
||||
@@ -146,23 +148,23 @@ describe('Extension: NetworkMap', () => {
|
||||
lqi: 120,
|
||||
relationship: 2,
|
||||
routes: [],
|
||||
source: {ieeeAddr: '0x000b57fffec6a5b3', networkAddress: 40399},
|
||||
sourceIeeeAddr: '0x000b57fffec6a5b3',
|
||||
source: {ieeeAddr: "0x000b57fffec6a5b3", networkAddress: 40399},
|
||||
sourceIeeeAddr: "0x000b57fffec6a5b3",
|
||||
sourceNwkAddr: 40399,
|
||||
target: {ieeeAddr: '0x00124b00120144ae', networkAddress: 0},
|
||||
targetIeeeAddr: '0x00124b00120144ae',
|
||||
target: {ieeeAddr: "0x00124b00120144ae", networkAddress: 0},
|
||||
targetIeeeAddr: "0x00124b00120144ae",
|
||||
},
|
||||
{
|
||||
depth: 1,
|
||||
linkquality: 92,
|
||||
lqi: 92,
|
||||
relationship: 2,
|
||||
routes: [{destinationAddress: 6540, nextHop: 40369, status: 'ACTIVE'}],
|
||||
source: {ieeeAddr: '0x000b57fffec6a5b2', networkAddress: 40369},
|
||||
sourceIeeeAddr: '0x000b57fffec6a5b2',
|
||||
routes: [{destinationAddress: 6540, nextHop: 40369, status: "ACTIVE"}],
|
||||
source: {ieeeAddr: "0x000b57fffec6a5b2", networkAddress: 40369},
|
||||
sourceIeeeAddr: "0x000b57fffec6a5b2",
|
||||
sourceNwkAddr: 40369,
|
||||
target: {ieeeAddr: '0x00124b00120144ae', networkAddress: 0},
|
||||
targetIeeeAddr: '0x00124b00120144ae',
|
||||
target: {ieeeAddr: "0x00124b00120144ae", networkAddress: 0},
|
||||
targetIeeeAddr: "0x00124b00120144ae",
|
||||
},
|
||||
{
|
||||
depth: 1,
|
||||
@@ -170,11 +172,11 @@ describe('Extension: NetworkMap', () => {
|
||||
lqi: 92,
|
||||
relationship: 2,
|
||||
routes: [],
|
||||
source: {ieeeAddr: '0x0017880104e45511', networkAddress: 1114},
|
||||
sourceIeeeAddr: '0x0017880104e45511',
|
||||
source: {ieeeAddr: "0x0017880104e45511", networkAddress: 1114},
|
||||
sourceIeeeAddr: "0x0017880104e45511",
|
||||
sourceNwkAddr: 1114,
|
||||
target: {ieeeAddr: '0x00124b00120144ae', networkAddress: 0},
|
||||
targetIeeeAddr: '0x00124b00120144ae',
|
||||
target: {ieeeAddr: "0x00124b00120144ae", networkAddress: 0},
|
||||
targetIeeeAddr: "0x00124b00120144ae",
|
||||
},
|
||||
{
|
||||
depth: 2,
|
||||
@@ -182,11 +184,11 @@ describe('Extension: NetworkMap', () => {
|
||||
lqi: 110,
|
||||
relationship: 1,
|
||||
routes: [],
|
||||
source: {ieeeAddr: '0x000b57fffec6a5b3', networkAddress: 40399},
|
||||
sourceIeeeAddr: '0x000b57fffec6a5b3',
|
||||
source: {ieeeAddr: "0x000b57fffec6a5b3", networkAddress: 40399},
|
||||
sourceIeeeAddr: "0x000b57fffec6a5b3",
|
||||
sourceNwkAddr: 40399,
|
||||
target: {ieeeAddr: '0x000b57fffec6a5b2', networkAddress: 40369},
|
||||
targetIeeeAddr: '0x000b57fffec6a5b2',
|
||||
target: {ieeeAddr: "0x000b57fffec6a5b2", networkAddress: 40369},
|
||||
targetIeeeAddr: "0x000b57fffec6a5b2",
|
||||
},
|
||||
{
|
||||
depth: 2,
|
||||
@@ -194,11 +196,11 @@ describe('Extension: NetworkMap', () => {
|
||||
lqi: 100,
|
||||
relationship: 1,
|
||||
routes: [],
|
||||
source: {ieeeAddr: '0x0017880104e45559', networkAddress: 6540},
|
||||
sourceIeeeAddr: '0x0017880104e45559',
|
||||
source: {ieeeAddr: "0x0017880104e45559", networkAddress: 6540},
|
||||
sourceIeeeAddr: "0x0017880104e45559",
|
||||
sourceNwkAddr: 6540,
|
||||
target: {ieeeAddr: '0x000b57fffec6a5b2', networkAddress: 40369},
|
||||
targetIeeeAddr: '0x000b57fffec6a5b2',
|
||||
target: {ieeeAddr: "0x000b57fffec6a5b2", networkAddress: 40369},
|
||||
targetIeeeAddr: "0x000b57fffec6a5b2",
|
||||
},
|
||||
{
|
||||
depth: 2,
|
||||
@@ -206,131 +208,136 @@ describe('Extension: NetworkMap', () => {
|
||||
lqi: 130,
|
||||
relationship: 1,
|
||||
routes: [],
|
||||
source: {ieeeAddr: '0x0017880104e45521', networkAddress: 6538},
|
||||
sourceIeeeAddr: '0x0017880104e45521',
|
||||
source: {ieeeAddr: "0x0017880104e45521", networkAddress: 6538},
|
||||
sourceIeeeAddr: "0x0017880104e45521",
|
||||
sourceNwkAddr: 6538,
|
||||
target: {ieeeAddr: '0x0017880104e45559', networkAddress: 6540},
|
||||
targetIeeeAddr: '0x0017880104e45559',
|
||||
target: {ieeeAddr: "0x0017880104e45559", networkAddress: 6540},
|
||||
targetIeeeAddr: "0x0017880104e45559",
|
||||
},
|
||||
],
|
||||
nodes: [
|
||||
{
|
||||
// definition: null,
|
||||
failed: [],
|
||||
friendlyName: 'Coordinator',
|
||||
ieeeAddr: '0x00124b00120144ae',
|
||||
friendlyName: "Coordinator",
|
||||
ieeeAddr: "0x00124b00120144ae",
|
||||
lastSeen: 1000,
|
||||
// modelID: null,
|
||||
networkAddress: 0,
|
||||
type: 'Coordinator',
|
||||
type: "Coordinator",
|
||||
},
|
||||
{
|
||||
definition: {
|
||||
description: 'TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm',
|
||||
model: 'LED1545G12',
|
||||
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',
|
||||
"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',
|
||||
friendlyName: "bulb",
|
||||
ieeeAddr: "0x000b57fffec6a5b2",
|
||||
lastSeen: 1000,
|
||||
modelID: 'TRADFRI bulb E27 WS opal 980lm',
|
||||
modelID: "TRADFRI bulb E27 WS opal 980lm",
|
||||
networkAddress: 40369,
|
||||
type: 'Router',
|
||||
type: "Router",
|
||||
},
|
||||
{
|
||||
definition: {
|
||||
description: 'Hue Go',
|
||||
model: '7146060PH',
|
||||
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',
|
||||
"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',
|
||||
friendlyName: "bulb_color",
|
||||
ieeeAddr: "0x000b57fffec6a5b3",
|
||||
lastSeen: 1000,
|
||||
modelID: 'LLC020',
|
||||
modelID: "LLC020",
|
||||
networkAddress: 40399,
|
||||
type: 'Router',
|
||||
type: "Router",
|
||||
},
|
||||
{
|
||||
definition: {
|
||||
description: 'Wireless remote switch (double rocker), 2016 model',
|
||||
model: 'WXKG02LM_rev1',
|
||||
supports: 'battery, voltage, power_outage_count, action, linkquality',
|
||||
vendor: 'Aqara',
|
||||
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',
|
||||
friendlyName: "button_double_key",
|
||||
ieeeAddr: "0x0017880104e45521",
|
||||
lastSeen: 1000,
|
||||
modelID: 'lumi.sensor_86sw2.es1',
|
||||
modelID: "lumi.sensor_86sw2.es1",
|
||||
networkAddress: 6538,
|
||||
type: 'EndDevice',
|
||||
type: "EndDevice",
|
||||
},
|
||||
{
|
||||
definition: {
|
||||
description: 'Automatically generated definition',
|
||||
model: 'notSupportedModelID',
|
||||
supports: 'action, linkquality',
|
||||
vendor: 'Boef',
|
||||
description: "Automatically generated definition",
|
||||
model: "notSupportedModelID",
|
||||
supports: "action, linkquality",
|
||||
vendor: "Boef",
|
||||
},
|
||||
failed: ['lqi', 'routingTable'],
|
||||
friendlyName: '0x0017880104e45525',
|
||||
ieeeAddr: '0x0017880104e45525',
|
||||
failed: ["lqi", "routingTable"],
|
||||
friendlyName: "0x0017880104e45525",
|
||||
ieeeAddr: "0x0017880104e45525",
|
||||
lastSeen: 1000,
|
||||
manufacturerName: 'Boef',
|
||||
modelID: 'notSupportedModelID',
|
||||
manufacturerName: "Boef",
|
||||
modelID: "notSupportedModelID",
|
||||
networkAddress: 6536,
|
||||
type: 'Router',
|
||||
type: "Router",
|
||||
},
|
||||
{
|
||||
definition: {
|
||||
description: 'CC2530 router',
|
||||
model: 'CC2530.ROUTER',
|
||||
supports: 'led, linkquality',
|
||||
vendor: 'Custom devices (DiY)',
|
||||
description: "CC2530 router",
|
||||
model: "CC2530.ROUTER",
|
||||
supports: "led, linkquality",
|
||||
vendor: "Custom devices (DiY)",
|
||||
},
|
||||
failed: [],
|
||||
friendlyName: 'cc2530_router',
|
||||
ieeeAddr: '0x0017880104e45559',
|
||||
friendlyName: "cc2530_router",
|
||||
ieeeAddr: "0x0017880104e45559",
|
||||
lastSeen: 1000,
|
||||
modelID: 'lumi.router',
|
||||
modelID: "lumi.router",
|
||||
networkAddress: 6540,
|
||||
type: 'Router',
|
||||
type: "Router",
|
||||
},
|
||||
{
|
||||
definition: {description: 'external', model: 'external_converter_device', supports: 'linkquality', vendor: 'external'},
|
||||
friendlyName: '0x0017880104e45511',
|
||||
ieeeAddr: '0x0017880104e45511',
|
||||
definition: {
|
||||
description: "external/converter",
|
||||
model: "external_converter_device",
|
||||
supports: "linkquality",
|
||||
vendor: "external",
|
||||
},
|
||||
friendlyName: "0x0017880104e45511",
|
||||
ieeeAddr: "0x0017880104e45511",
|
||||
lastSeen: 1000,
|
||||
modelID: 'external_converter_device',
|
||||
modelID: "external_converter_device",
|
||||
networkAddress: 1114,
|
||||
type: 'EndDevice',
|
||||
type: "EndDevice",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
status: 'ok',
|
||||
status: "ok",
|
||||
};
|
||||
const actual = JSON.parse(mockMQTTPublishAsync.mock.calls[0][1]);
|
||||
expect(actual).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('Output graphviz networkmap', async () => {
|
||||
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};
|
||||
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}));
|
||||
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');
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bridge/response/networkmap");
|
||||
|
||||
const expected = `digraph G {
|
||||
node[shape=record];
|
||||
@@ -345,30 +352,30 @@ describe('Extension: NetworkMap', () => {
|
||||
"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" [style="rounded, dashed, filled", fillcolor="#fff8ce", fontcolor="#000000", label="{0x0017880104e45511|0x0017880104e45511 (0x045a)|external external/converter (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');
|
||||
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 () => {
|
||||
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};
|
||||
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}));
|
||||
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');
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bridge/response/networkmap");
|
||||
|
||||
const expected = `' paste into: https://www.planttext.com/
|
||||
|
||||
@@ -378,7 +385,7 @@ describe('Extension: NetworkMap', () => {
|
||||
---
|
||||
0x0017880104e45511 (0x045a)
|
||||
---
|
||||
external external (external_converter_device)
|
||||
external external/converter (external_converter_device)
|
||||
---
|
||||
9 seconds ago
|
||||
]
|
||||
@@ -450,38 +457,38 @@ describe('Extension: NetworkMap', () => {
|
||||
|
||||
@enduml`;
|
||||
|
||||
const expectedLines = expected.split('\n');
|
||||
const actualLines = JSON.parse(mockMQTTPublishAsync.mock.calls[0][1]).data.value.split('\n');
|
||||
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 () => {
|
||||
it("Should throw error when requesting invalid type", async () => {
|
||||
mock();
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/networkmap', 'not_existing');
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/networkmap", "not_existing");
|
||||
await flushPromises();
|
||||
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},
|
||||
"zigbee2mqtt/bridge/response/networkmap",
|
||||
stringify({data: {}, status: "error", error: "Type 'not_existing' not supported, allowed are: raw,graphviz,plantuml"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should exclude disabled devices from networkmap', async () => {
|
||||
settings.set(['devices', '0x000b57fffec6a5b2', 'disabled'], true);
|
||||
it("Should exclude disabled devices from networkmap", async () => {
|
||||
settings.set(["devices", "0x000b57fffec6a5b2", "disabled"], true);
|
||||
mock();
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/networkmap', stringify({type: 'raw', routes: true}));
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/networkmap", stringify({type: "raw", routes: true}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/response/networkmap');
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bridge/response/networkmap");
|
||||
|
||||
const expected = {
|
||||
data: {
|
||||
routes: true,
|
||||
type: 'raw',
|
||||
type: "raw",
|
||||
value: {
|
||||
links: [
|
||||
{
|
||||
@@ -490,23 +497,23 @@ describe('Extension: NetworkMap', () => {
|
||||
lqi: 120,
|
||||
relationship: 2,
|
||||
routes: [],
|
||||
source: {ieeeAddr: '0x000b57fffec6a5b3', networkAddress: 40399},
|
||||
sourceIeeeAddr: '0x000b57fffec6a5b3',
|
||||
source: {ieeeAddr: "0x000b57fffec6a5b3", networkAddress: 40399},
|
||||
sourceIeeeAddr: "0x000b57fffec6a5b3",
|
||||
sourceNwkAddr: 40399,
|
||||
target: {ieeeAddr: '0x00124b00120144ae', networkAddress: 0},
|
||||
targetIeeeAddr: '0x00124b00120144ae',
|
||||
target: {ieeeAddr: "0x00124b00120144ae", networkAddress: 0},
|
||||
targetIeeeAddr: "0x00124b00120144ae",
|
||||
},
|
||||
{
|
||||
depth: 1,
|
||||
linkquality: 92,
|
||||
lqi: 92,
|
||||
relationship: 2,
|
||||
routes: [{destinationAddress: 6540, nextHop: 40369, status: 'ACTIVE'}],
|
||||
source: {ieeeAddr: '0x000b57fffec6a5b2', networkAddress: 40369},
|
||||
sourceIeeeAddr: '0x000b57fffec6a5b2',
|
||||
routes: [{destinationAddress: 6540, nextHop: 40369, status: "ACTIVE"}],
|
||||
source: {ieeeAddr: "0x000b57fffec6a5b2", networkAddress: 40369},
|
||||
sourceIeeeAddr: "0x000b57fffec6a5b2",
|
||||
sourceNwkAddr: 40369,
|
||||
target: {ieeeAddr: '0x00124b00120144ae', networkAddress: 0},
|
||||
targetIeeeAddr: '0x00124b00120144ae',
|
||||
target: {ieeeAddr: "0x00124b00120144ae", networkAddress: 0},
|
||||
targetIeeeAddr: "0x00124b00120144ae",
|
||||
},
|
||||
{
|
||||
depth: 1,
|
||||
@@ -514,11 +521,11 @@ describe('Extension: NetworkMap', () => {
|
||||
lqi: 92,
|
||||
relationship: 2,
|
||||
routes: [],
|
||||
source: {ieeeAddr: '0x0017880104e45511', networkAddress: 1114},
|
||||
sourceIeeeAddr: '0x0017880104e45511',
|
||||
source: {ieeeAddr: "0x0017880104e45511", networkAddress: 1114},
|
||||
sourceIeeeAddr: "0x0017880104e45511",
|
||||
sourceNwkAddr: 1114,
|
||||
target: {ieeeAddr: '0x00124b00120144ae', networkAddress: 0},
|
||||
targetIeeeAddr: '0x00124b00120144ae',
|
||||
target: {ieeeAddr: "0x00124b00120144ae", networkAddress: 0},
|
||||
targetIeeeAddr: "0x00124b00120144ae",
|
||||
},
|
||||
{
|
||||
depth: 2,
|
||||
@@ -526,114 +533,119 @@ describe('Extension: NetworkMap', () => {
|
||||
lqi: 130,
|
||||
relationship: 1,
|
||||
routes: [],
|
||||
source: {ieeeAddr: '0x0017880104e45521', networkAddress: 6538},
|
||||
sourceIeeeAddr: '0x0017880104e45521',
|
||||
source: {ieeeAddr: "0x0017880104e45521", networkAddress: 6538},
|
||||
sourceIeeeAddr: "0x0017880104e45521",
|
||||
sourceNwkAddr: 6538,
|
||||
target: {ieeeAddr: '0x0017880104e45559', networkAddress: 6540},
|
||||
targetIeeeAddr: '0x0017880104e45559',
|
||||
target: {ieeeAddr: "0x0017880104e45559", networkAddress: 6540},
|
||||
targetIeeeAddr: "0x0017880104e45559",
|
||||
},
|
||||
],
|
||||
nodes: [
|
||||
{
|
||||
failed: [],
|
||||
friendlyName: 'Coordinator',
|
||||
ieeeAddr: '0x00124b00120144ae',
|
||||
friendlyName: "Coordinator",
|
||||
ieeeAddr: "0x00124b00120144ae",
|
||||
lastSeen: 1000,
|
||||
networkAddress: 0,
|
||||
type: 'Coordinator',
|
||||
type: "Coordinator",
|
||||
},
|
||||
{
|
||||
definition: {
|
||||
description: 'Hue Go',
|
||||
model: '7146060PH',
|
||||
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',
|
||||
"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',
|
||||
friendlyName: "bulb_color",
|
||||
ieeeAddr: "0x000b57fffec6a5b3",
|
||||
lastSeen: 1000,
|
||||
modelID: 'LLC020',
|
||||
modelID: "LLC020",
|
||||
networkAddress: 40399,
|
||||
type: 'Router',
|
||||
type: "Router",
|
||||
},
|
||||
{
|
||||
definition: {
|
||||
description: 'Wireless remote switch (double rocker), 2016 model',
|
||||
model: 'WXKG02LM_rev1',
|
||||
supports: 'battery, voltage, power_outage_count, action, linkquality',
|
||||
vendor: 'Aqara',
|
||||
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',
|
||||
friendlyName: "button_double_key",
|
||||
ieeeAddr: "0x0017880104e45521",
|
||||
lastSeen: 1000,
|
||||
modelID: 'lumi.sensor_86sw2.es1',
|
||||
modelID: "lumi.sensor_86sw2.es1",
|
||||
networkAddress: 6538,
|
||||
type: 'EndDevice',
|
||||
type: "EndDevice",
|
||||
},
|
||||
{
|
||||
definition: {
|
||||
description: 'Automatically generated definition',
|
||||
model: 'notSupportedModelID',
|
||||
supports: 'action, linkquality',
|
||||
vendor: 'Boef',
|
||||
description: "Automatically generated definition",
|
||||
model: "notSupportedModelID",
|
||||
supports: "action, linkquality",
|
||||
vendor: "Boef",
|
||||
},
|
||||
failed: ['lqi', 'routingTable'],
|
||||
friendlyName: '0x0017880104e45525',
|
||||
ieeeAddr: '0x0017880104e45525',
|
||||
failed: ["lqi", "routingTable"],
|
||||
friendlyName: "0x0017880104e45525",
|
||||
ieeeAddr: "0x0017880104e45525",
|
||||
lastSeen: 1000,
|
||||
manufacturerName: 'Boef',
|
||||
modelID: 'notSupportedModelID',
|
||||
manufacturerName: "Boef",
|
||||
modelID: "notSupportedModelID",
|
||||
networkAddress: 6536,
|
||||
type: 'Router',
|
||||
type: "Router",
|
||||
},
|
||||
{
|
||||
definition: {
|
||||
description: 'CC2530 router',
|
||||
model: 'CC2530.ROUTER',
|
||||
supports: 'led, linkquality',
|
||||
vendor: 'Custom devices (DiY)',
|
||||
description: "CC2530 router",
|
||||
model: "CC2530.ROUTER",
|
||||
supports: "led, linkquality",
|
||||
vendor: "Custom devices (DiY)",
|
||||
},
|
||||
failed: [],
|
||||
friendlyName: 'cc2530_router',
|
||||
ieeeAddr: '0x0017880104e45559',
|
||||
friendlyName: "cc2530_router",
|
||||
ieeeAddr: "0x0017880104e45559",
|
||||
lastSeen: 1000,
|
||||
modelID: 'lumi.router',
|
||||
modelID: "lumi.router",
|
||||
networkAddress: 6540,
|
||||
type: 'Router',
|
||||
type: "Router",
|
||||
},
|
||||
{
|
||||
definition: {description: 'external', model: 'external_converter_device', supports: 'linkquality', vendor: 'external'},
|
||||
friendlyName: '0x0017880104e45511',
|
||||
ieeeAddr: '0x0017880104e45511',
|
||||
definition: {
|
||||
description: "external/converter",
|
||||
model: "external_converter_device",
|
||||
supports: "linkquality",
|
||||
vendor: "external",
|
||||
},
|
||||
friendlyName: "0x0017880104e45511",
|
||||
ieeeAddr: "0x0017880104e45511",
|
||||
lastSeen: 1000,
|
||||
modelID: 'external_converter_device',
|
||||
modelID: "external_converter_device",
|
||||
networkAddress: 1114,
|
||||
type: 'EndDevice',
|
||||
type: "EndDevice",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
status: 'ok',
|
||||
status: "ok",
|
||||
};
|
||||
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);
|
||||
it("Handles retrying request when first attempt fails", async () => {
|
||||
settings.set(["devices", "0x000b57fffec6a5b2", "disabled"], true);
|
||||
mock();
|
||||
devices.bulb.lqi.mockRejectedValueOnce('failed');
|
||||
mockMQTTEvents.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(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/response/networkmap');
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bridge/response/networkmap");
|
||||
|
||||
const expected = {
|
||||
data: {
|
||||
routes: true,
|
||||
type: 'raw',
|
||||
type: "raw",
|
||||
value: {
|
||||
links: [
|
||||
{
|
||||
@@ -642,23 +654,23 @@ describe('Extension: NetworkMap', () => {
|
||||
lqi: 120,
|
||||
relationship: 2,
|
||||
routes: [],
|
||||
source: {ieeeAddr: '0x000b57fffec6a5b3', networkAddress: 40399},
|
||||
sourceIeeeAddr: '0x000b57fffec6a5b3',
|
||||
source: {ieeeAddr: "0x000b57fffec6a5b3", networkAddress: 40399},
|
||||
sourceIeeeAddr: "0x000b57fffec6a5b3",
|
||||
sourceNwkAddr: 40399,
|
||||
target: {ieeeAddr: '0x00124b00120144ae', networkAddress: 0},
|
||||
targetIeeeAddr: '0x00124b00120144ae',
|
||||
target: {ieeeAddr: "0x00124b00120144ae", networkAddress: 0},
|
||||
targetIeeeAddr: "0x00124b00120144ae",
|
||||
},
|
||||
{
|
||||
depth: 1,
|
||||
linkquality: 92,
|
||||
lqi: 92,
|
||||
relationship: 2,
|
||||
routes: [{destinationAddress: 6540, nextHop: 40369, status: 'ACTIVE'}],
|
||||
source: {ieeeAddr: '0x000b57fffec6a5b2', networkAddress: 40369},
|
||||
sourceIeeeAddr: '0x000b57fffec6a5b2',
|
||||
routes: [{destinationAddress: 6540, nextHop: 40369, status: "ACTIVE"}],
|
||||
source: {ieeeAddr: "0x000b57fffec6a5b2", networkAddress: 40369},
|
||||
sourceIeeeAddr: "0x000b57fffec6a5b2",
|
||||
sourceNwkAddr: 40369,
|
||||
target: {ieeeAddr: '0x00124b00120144ae', networkAddress: 0},
|
||||
targetIeeeAddr: '0x00124b00120144ae',
|
||||
target: {ieeeAddr: "0x00124b00120144ae", networkAddress: 0},
|
||||
targetIeeeAddr: "0x00124b00120144ae",
|
||||
},
|
||||
{
|
||||
depth: 1,
|
||||
@@ -666,11 +678,11 @@ describe('Extension: NetworkMap', () => {
|
||||
lqi: 92,
|
||||
relationship: 2,
|
||||
routes: [],
|
||||
source: {ieeeAddr: '0x0017880104e45511', networkAddress: 1114},
|
||||
sourceIeeeAddr: '0x0017880104e45511',
|
||||
source: {ieeeAddr: "0x0017880104e45511", networkAddress: 1114},
|
||||
sourceIeeeAddr: "0x0017880104e45511",
|
||||
sourceNwkAddr: 1114,
|
||||
target: {ieeeAddr: '0x00124b00120144ae', networkAddress: 0},
|
||||
targetIeeeAddr: '0x00124b00120144ae',
|
||||
target: {ieeeAddr: "0x00124b00120144ae", networkAddress: 0},
|
||||
targetIeeeAddr: "0x00124b00120144ae",
|
||||
},
|
||||
{
|
||||
depth: 2,
|
||||
@@ -678,96 +690,101 @@ describe('Extension: NetworkMap', () => {
|
||||
lqi: 130,
|
||||
relationship: 1,
|
||||
routes: [],
|
||||
source: {ieeeAddr: '0x0017880104e45521', networkAddress: 6538},
|
||||
sourceIeeeAddr: '0x0017880104e45521',
|
||||
source: {ieeeAddr: "0x0017880104e45521", networkAddress: 6538},
|
||||
sourceIeeeAddr: "0x0017880104e45521",
|
||||
sourceNwkAddr: 6538,
|
||||
target: {ieeeAddr: '0x0017880104e45559', networkAddress: 6540},
|
||||
targetIeeeAddr: '0x0017880104e45559',
|
||||
target: {ieeeAddr: "0x0017880104e45559", networkAddress: 6540},
|
||||
targetIeeeAddr: "0x0017880104e45559",
|
||||
},
|
||||
],
|
||||
nodes: [
|
||||
{
|
||||
failed: [],
|
||||
friendlyName: 'Coordinator',
|
||||
ieeeAddr: '0x00124b00120144ae',
|
||||
friendlyName: "Coordinator",
|
||||
ieeeAddr: "0x00124b00120144ae",
|
||||
lastSeen: 1000,
|
||||
networkAddress: 0,
|
||||
type: 'Coordinator',
|
||||
type: "Coordinator",
|
||||
},
|
||||
{
|
||||
definition: {
|
||||
description: 'Hue Go',
|
||||
model: '7146060PH',
|
||||
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',
|
||||
"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',
|
||||
friendlyName: "bulb_color",
|
||||
ieeeAddr: "0x000b57fffec6a5b3",
|
||||
lastSeen: 1000,
|
||||
modelID: 'LLC020',
|
||||
modelID: "LLC020",
|
||||
networkAddress: 40399,
|
||||
type: 'Router',
|
||||
type: "Router",
|
||||
},
|
||||
{
|
||||
definition: {
|
||||
description: 'Wireless remote switch (double rocker), 2016 model',
|
||||
model: 'WXKG02LM_rev1',
|
||||
supports: 'battery, voltage, power_outage_count, action, linkquality',
|
||||
vendor: 'Aqara',
|
||||
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',
|
||||
friendlyName: "button_double_key",
|
||||
ieeeAddr: "0x0017880104e45521",
|
||||
lastSeen: 1000,
|
||||
modelID: 'lumi.sensor_86sw2.es1',
|
||||
modelID: "lumi.sensor_86sw2.es1",
|
||||
networkAddress: 6538,
|
||||
type: 'EndDevice',
|
||||
type: "EndDevice",
|
||||
},
|
||||
{
|
||||
definition: {
|
||||
description: 'Automatically generated definition',
|
||||
model: 'notSupportedModelID',
|
||||
supports: 'action, linkquality',
|
||||
vendor: 'Boef',
|
||||
description: "Automatically generated definition",
|
||||
model: "notSupportedModelID",
|
||||
supports: "action, linkquality",
|
||||
vendor: "Boef",
|
||||
},
|
||||
failed: ['lqi', 'routingTable'],
|
||||
friendlyName: '0x0017880104e45525',
|
||||
ieeeAddr: '0x0017880104e45525',
|
||||
failed: ["lqi", "routingTable"],
|
||||
friendlyName: "0x0017880104e45525",
|
||||
ieeeAddr: "0x0017880104e45525",
|
||||
lastSeen: 1000,
|
||||
manufacturerName: 'Boef',
|
||||
modelID: 'notSupportedModelID',
|
||||
manufacturerName: "Boef",
|
||||
modelID: "notSupportedModelID",
|
||||
networkAddress: 6536,
|
||||
type: 'Router',
|
||||
type: "Router",
|
||||
},
|
||||
{
|
||||
definition: {
|
||||
description: 'CC2530 router',
|
||||
model: 'CC2530.ROUTER',
|
||||
supports: 'led, linkquality',
|
||||
vendor: 'Custom devices (DiY)',
|
||||
description: "CC2530 router",
|
||||
model: "CC2530.ROUTER",
|
||||
supports: "led, linkquality",
|
||||
vendor: "Custom devices (DiY)",
|
||||
},
|
||||
failed: [],
|
||||
friendlyName: 'cc2530_router',
|
||||
ieeeAddr: '0x0017880104e45559',
|
||||
friendlyName: "cc2530_router",
|
||||
ieeeAddr: "0x0017880104e45559",
|
||||
lastSeen: 1000,
|
||||
modelID: 'lumi.router',
|
||||
modelID: "lumi.router",
|
||||
networkAddress: 6540,
|
||||
type: 'Router',
|
||||
type: "Router",
|
||||
},
|
||||
{
|
||||
definition: {description: 'external', model: 'external_converter_device', supports: 'linkquality', vendor: 'external'},
|
||||
friendlyName: '0x0017880104e45511',
|
||||
ieeeAddr: '0x0017880104e45511',
|
||||
definition: {
|
||||
description: "external/converter",
|
||||
model: "external_converter_device",
|
||||
supports: "linkquality",
|
||||
vendor: "external",
|
||||
},
|
||||
friendlyName: "0x0017880104e45511",
|
||||
ieeeAddr: "0x0017880104e45511",
|
||||
lastSeen: 1000,
|
||||
modelID: 'external_converter_device',
|
||||
modelID: "external_converter_device",
|
||||
networkAddress: 1114,
|
||||
type: 'EndDevice',
|
||||
type: "EndDevice",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
status: 'ok',
|
||||
status: "ok",
|
||||
};
|
||||
const actual = JSON.parse(mockMQTTPublishAsync.mock.calls[0][1]);
|
||||
expect(actual).toStrictEqual(expected);
|
||||
|
||||
+147
-64
@@ -1,98 +1,181 @@
|
||||
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 data from "../mocks/data";
|
||||
import {mockLogger} from "../mocks/logger";
|
||||
import {mockMQTTPublishAsync} from "../mocks/mqtt";
|
||||
import {flushPromises} from "../mocks/utils";
|
||||
import {devices, events as mockZHEvents, returnDevices} from "../mocks/zigbeeHerdsman";
|
||||
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
import type {MockInstance} from "vitest";
|
||||
import type {OnEvent as DefinitionOnEvent} from "zigbee-herdsman-converters/lib/types";
|
||||
|
||||
import {Controller} from '../../lib/controller';
|
||||
import * as settings from '../../lib/util/settings';
|
||||
import type Device from "../../lib/model/device";
|
||||
import type {Device as ZhDevice} from "../mocks/zigbeeHerdsman";
|
||||
|
||||
const mockOnEvent = vi.fn();
|
||||
const mockLivoloOnEvent = vi.fn();
|
||||
const mappedLivolo = zhc.findByModel('TI0001')!;
|
||||
mappedLivolo.onEvent = mockLivoloOnEvent;
|
||||
// @ts-expect-error mock
|
||||
zhc.onEvent = mockOnEvent;
|
||||
import * as zhc from "zigbee-herdsman-converters";
|
||||
|
||||
import {Controller} from "../../lib/controller";
|
||||
import OnEvent from "../../lib/extension/onEvent";
|
||||
import * as settings from "../../lib/util/settings";
|
||||
|
||||
const mocksClear = [mockMQTTPublishAsync, mockLogger.warning, mockLogger.debug];
|
||||
|
||||
describe('Extension: OnEvent', () => {
|
||||
let controller: Controller;
|
||||
returnDevices.push(devices.bulb.ieeeAddr, devices.LIVOLO.ieeeAddr, devices.coordinator.ieeeAddr);
|
||||
|
||||
beforeEach(async () => {
|
||||
describe("Extension: OnEvent", () => {
|
||||
let controller: Controller;
|
||||
let onEventSpy: MockInstance<typeof zhc.onEvent>;
|
||||
let deviceOnEventSpy: MockInstance<DefinitionOnEvent>;
|
||||
|
||||
const getZ2MDevice = (zhDevice: string | number | ZhDevice): Device => {
|
||||
// @ts-expect-error private
|
||||
return controller.zigbee.resolveEntity(zhDevice)! as Device;
|
||||
};
|
||||
|
||||
const clearOnEventSpies = (): void => {
|
||||
onEventSpy.mockClear();
|
||||
deviceOnEventSpy.mockClear();
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.useFakeTimers();
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
onEventSpy = vi.spyOn(zhc, "onEvent");
|
||||
deviceOnEventSpy = vi.spyOn(getZ2MDevice(devices.LIVOLO).definition!, "onEvent");
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// @ts-expect-error private
|
||||
controller.state.state = {};
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
mockOnEvent.mockClear();
|
||||
for (const mock of mocksClear) {
|
||||
mock.mockClear();
|
||||
}
|
||||
|
||||
await controller.removeExtension(controller.getExtension("OnEvent")!);
|
||||
clearOnEventSpies();
|
||||
await controller.addExtension(new OnEvent(...controller.extensionArgs));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await controller.stop();
|
||||
await flushPromises();
|
||||
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},
|
||||
it("starts & stops", async () => {
|
||||
expect(onEventSpy).toHaveBeenCalledTimes(2);
|
||||
expect(deviceOnEventSpy).toHaveBeenCalledTimes(1);
|
||||
expect(deviceOnEventSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"start",
|
||||
{},
|
||||
devices.LIVOLO,
|
||||
settings.getDevice(devices.LIVOLO.ieeeAddr),
|
||||
{},
|
||||
{
|
||||
deviceExposesChanged: expect.any(Function),
|
||||
},
|
||||
{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');
|
||||
await controller.stop();
|
||||
|
||||
expect(onEventSpy).toHaveBeenCalledTimes(4);
|
||||
expect(deviceOnEventSpy).toHaveBeenCalledTimes(2);
|
||||
expect(deviceOnEventSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"stop",
|
||||
{},
|
||||
devices.LIVOLO,
|
||||
settings.getDevice(devices.LIVOLO.ieeeAddr),
|
||||
{},
|
||||
{deviceExposesChanged: expect.any(Function)},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should call index onEvent with zigbee event', async () => {
|
||||
mockOnEvent.mockClear();
|
||||
it("calls on device events", async () => {
|
||||
clearOnEventSpies();
|
||||
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),
|
||||
|
||||
expect(deviceOnEventSpy).toHaveBeenCalledTimes(1);
|
||||
expect(deviceOnEventSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"deviceAnnounce",
|
||||
{},
|
||||
devices.LIVOLO,
|
||||
settings.getDevice(devices.LIVOLO.ieeeAddr),
|
||||
{},
|
||||
{deviceExposesChanged: expect.any(Function)},
|
||||
);
|
||||
|
||||
const emitExposesAndDevicesChangedSpy = vi.spyOn(
|
||||
// @ts-expect-error protected
|
||||
controller.getExtension("OnEvent")!.eventBus,
|
||||
"emitExposesAndDevicesChanged",
|
||||
);
|
||||
|
||||
deviceOnEventSpy.mock.calls[0][5]!.deviceExposesChanged();
|
||||
|
||||
expect(emitExposesAndDevicesChangedSpy).toHaveBeenCalledTimes(1);
|
||||
expect(emitExposesAndDevicesChangedSpy).toHaveBeenCalledWith(getZ2MDevice(devices.LIVOLO));
|
||||
|
||||
await mockZHEvents.deviceLeave({ieeeAddr: devices.LIVOLO.ieeeAddr});
|
||||
await flushPromises();
|
||||
|
||||
expect(deviceOnEventSpy).toHaveBeenCalledTimes(2);
|
||||
expect(deviceOnEventSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"stop",
|
||||
{},
|
||||
devices.LIVOLO,
|
||||
settings.getDevice(devices.LIVOLO.ieeeAddr),
|
||||
{},
|
||||
{deviceExposesChanged: expect.any(Function)},
|
||||
);
|
||||
});
|
||||
|
||||
it("calls on device message", async () => {
|
||||
clearOnEventSpies();
|
||||
|
||||
await mockZHEvents.message({
|
||||
type: "attributeReport",
|
||||
device: devices.LIVOLO,
|
||||
endpoint: devices.LIVOLO.endpoints[0],
|
||||
linkquality: 213,
|
||||
groupID: 0,
|
||||
cluster: "genBasic",
|
||||
data: {zclVersion: 8},
|
||||
meta: {zclTransactionSequenceNumber: 1, manufacturerCode: devices.LIVOLO.manufacturerID},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(deviceOnEventSpy).toHaveBeenCalledTimes(1);
|
||||
expect(deviceOnEventSpy).toHaveBeenCalledWith(
|
||||
"message",
|
||||
{
|
||||
type: "attributeReport",
|
||||
endpoint: devices.LIVOLO.endpoints[0],
|
||||
cluster: "genBasic",
|
||||
data: {zclVersion: 8},
|
||||
meta: {zclTransactionSequenceNumber: 1, manufacturerCode: devices.LIVOLO.manufacturerID},
|
||||
},
|
||||
devices.LIVOLO,
|
||||
settings.getDevice(devices.LIVOLO.ieeeAddr),
|
||||
{},
|
||||
{deviceExposesChanged: expect.any(Function)},
|
||||
);
|
||||
});
|
||||
|
||||
it("does not block startup on failure", async () => {
|
||||
await controller.removeExtension(controller.getExtension("OnEvent")!);
|
||||
clearOnEventSpies();
|
||||
deviceOnEventSpy.mockImplementationOnce(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10000));
|
||||
throw new Error("Failed");
|
||||
});
|
||||
await controller.addExtension(new OnEvent(...controller.extensionArgs));
|
||||
|
||||
expect(onEventSpy).toHaveBeenCalledTimes(2);
|
||||
expect(deviceOnEventSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
+451
-156
@@ -1,19 +1,19 @@
|
||||
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 * 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 path from "node:path";
|
||||
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import OTAUpdate from 'lib/extension/otaUpdate';
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
import * as zhc from "zigbee-herdsman-converters";
|
||||
|
||||
import {Controller} from '../../lib/controller';
|
||||
import * as settings from '../../lib/util/settings';
|
||||
import {Controller} from "../../lib/controller";
|
||||
import OTAUpdate from "../../lib/extension/otaUpdate";
|
||||
import * as settings from "../../lib/util/settings";
|
||||
|
||||
const mocksClear = [mockMQTTPublishAsync, devices.bulb.save, mockLogger.info];
|
||||
|
||||
@@ -23,14 +23,14 @@ const DEFAULT_CONFIG: zhc.Ota.Settings = {
|
||||
defaultMaximumDataSize: 50,
|
||||
};
|
||||
|
||||
describe('Extension: OTAUpdate', () => {
|
||||
describe("Extension: OTAUpdate", () => {
|
||||
let controller: Controller;
|
||||
const updateSpy = vi.spyOn(zhc.ota, 'update');
|
||||
const isUpdateAvailableSpy = vi.spyOn(zhc.ota, 'isUpdateAvailable');
|
||||
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');
|
||||
await controller.removeExtension(controller.getExtension("OTAUpdate")!);
|
||||
await controller.addExtension(new OTAUpdate(...controller.extensionArgs));
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -38,7 +38,6 @@ describe('Extension: OTAUpdate', () => {
|
||||
mockSleep.mock();
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
settings.reRead();
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
@@ -46,31 +45,40 @@ describe('Extension: OTAUpdate', () => {
|
||||
|
||||
afterAll(async () => {
|
||||
mockSleep.restore();
|
||||
await controller?.stop();
|
||||
await flushPromises();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
beforeEach(() => {
|
||||
zhc.ota.setConfiguration(DEFAULT_CONFIG);
|
||||
const extension = controller.getExtension("OTAUpdate")! as OTAUpdate;
|
||||
// @ts-expect-error private
|
||||
const extension: OTAUpdate = controller.extensions.find((e) => e.constructor.name === 'OTAUpdate');
|
||||
// @ts-expect-error private
|
||||
extension.lastChecked = {};
|
||||
extension.lastChecked = new Map();
|
||||
// @ts-expect-error private
|
||||
extension.inProgress = new Set();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
// @ts-expect-error private
|
||||
extension.scheduledUpgrades = new Set();
|
||||
// @ts-expect-error private
|
||||
extension.scheduledDowngrades = new Set();
|
||||
|
||||
for (const mock of mocksClear) {
|
||||
mock.mockClear();
|
||||
}
|
||||
|
||||
devices.bulb.mockClear();
|
||||
updateSpy.mockClear();
|
||||
isUpdateAvailableSpy.mockClear();
|
||||
// @ts-expect-error private
|
||||
controller.state.state = {};
|
||||
controller.state.clear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
settings.set(['ota', 'disable_automatic_update_check'], false);
|
||||
afterEach(() => {
|
||||
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';
|
||||
it.each(["update", "update/downgrade"])("updates a device with topic %s", async (type) => {
|
||||
const downgrade = type === "update/downgrade";
|
||||
let count = 10;
|
||||
devices.bulb.endpoints[0].read.mockImplementation(() => {
|
||||
if (downgrade) {
|
||||
@@ -81,21 +89,21 @@ describe('Extension: OTAUpdate', () => {
|
||||
|
||||
return {swBuildId: count, dateCode: `201901${count}`};
|
||||
});
|
||||
updateSpy.mockImplementationOnce(async (device, extraMetas, previous, onProgress) => {
|
||||
updateSpy.mockImplementationOnce(async (_device, _extraMetas, previous, onProgress) => {
|
||||
expect(previous).toStrictEqual(downgrade);
|
||||
|
||||
onProgress(0, undefined);
|
||||
onProgress(10, 3600.2123);
|
||||
return 90;
|
||||
return await Promise.resolve(90);
|
||||
});
|
||||
|
||||
mockMQTTEvents.message(`zigbee2mqtt/bridge/request/device/ota_update/${type}`, 'bulb');
|
||||
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(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));
|
||||
@@ -104,94 +112,111 @@ describe('Extension: OTAUpdate', () => {
|
||||
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',
|
||||
"info",
|
||||
`Device 'bulb' was updated from '{"dateCode":"${fromDateCode}","softwareBuildID":${fromSwBuildId}}' to '{"dateCode":"${toDateCode}","softwareBuildID":${toSwBuildId}}'`,
|
||||
'z2m',
|
||||
"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}}), {
|
||||
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}}),
|
||||
"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}}),
|
||||
"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',
|
||||
"zigbee2mqtt/bridge/response/device/ota_update/update",
|
||||
stringify({
|
||||
data: {
|
||||
from: {date_code: fromDateCode, software_build_id: fromSwBuildId},
|
||||
id: 'bulb',
|
||||
id: "bulb",
|
||||
to: {date_code: toDateCode, software_build_id: toSwBuildId},
|
||||
},
|
||||
status: 'ok',
|
||||
status: "ok",
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
{},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), {retain: true, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), {retain: true});
|
||||
});
|
||||
|
||||
it('Should handle when OTA update fails', async () => {
|
||||
it("handles when OTA update fails", async () => {
|
||||
devices.bulb.endpoints[0].read.mockImplementation(() => {
|
||||
return {swBuildId: 1, dateCode: '2019010'};
|
||||
return {swBuildId: 1, dateCode: "2019010"};
|
||||
});
|
||||
devices.bulb.save.mockClear();
|
||||
updateSpy.mockRejectedValueOnce(new Error('Update failed'));
|
||||
updateSpy.mockRejectedValueOnce(new Error("Update failed"));
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/ota_update/update', stringify({id: 'bulb'}));
|
||||
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/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},
|
||||
"zigbee2mqtt/bridge/response/device/ota_update/update",
|
||||
stringify({data: {}, status: "error", error: "Update of 'bulb' failed (Update failed)"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should be able to check if OTA update is available', async () => {
|
||||
it("handles when OTA update returns no image available", async () => {
|
||||
devices.bulb.endpoints[0].read.mockImplementation(() => {
|
||||
return {swBuildId: 1, dateCode: "2019010"};
|
||||
});
|
||||
devices.bulb.save.mockClear();
|
||||
updateSpy.mockResolvedValueOnce(undefined);
|
||||
|
||||
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 (No image currently available)"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("is 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');
|
||||
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},
|
||||
"zigbee2mqtt/bridge/response/device/ota_update/check",
|
||||
stringify({data: {id: "bulb", update_available: false}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
isUpdateAvailableSpy.mockResolvedValueOnce({available: true, currentFileVersion: 10, otaFileVersion: 12});
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/ota_update/check', 'bulb');
|
||||
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},
|
||||
"zigbee2mqtt/bridge/response/device/ota_update/check",
|
||||
stringify({data: {id: "bulb", update_available: true}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
isUpdateAvailableSpy.mockResolvedValueOnce({available: false, currentFileVersion: 10, otaFileVersion: 10});
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/ota_update/check/downgrade', 'bulb');
|
||||
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},
|
||||
"zigbee2mqtt/bridge/response/device/ota_update/check",
|
||||
stringify({data: {id: "bulb", update_available: false}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
|
||||
// @ts-expect-error private
|
||||
@@ -201,103 +226,147 @@ describe('Extension: OTAUpdate', () => {
|
||||
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
isUpdateAvailableSpy.mockResolvedValueOnce({available: true, currentFileVersion: 10, otaFileVersion: 12});
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/ota_update/check/downgrade', 'bulb');
|
||||
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},
|
||||
"zigbee2mqtt/bridge/response/device/ota_update/check",
|
||||
stringify({data: {id: "bulb", update_available: true}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
|
||||
device.definition = originalDefinition;
|
||||
});
|
||||
|
||||
it('Should handle if OTA update check fails', async () => {
|
||||
isUpdateAvailableSpy.mockRejectedValueOnce(new Error('RF signals disturbed because of dogs barking'));
|
||||
it("handles 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');
|
||||
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',
|
||||
"zigbee2mqtt/bridge/response/device/ota_update/check",
|
||||
stringify({
|
||||
data: {},
|
||||
status: 'error',
|
||||
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');
|
||||
it("fails 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},
|
||||
"zigbee2mqtt/bridge/response/device/ota_update/check",
|
||||
stringify({data: {}, status: "error", error: `Device 'not_existing_deviceooo' does not exist`}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
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');
|
||||
it("does 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},
|
||||
"zigbee2mqtt/bridge/response/device/ota_update/check",
|
||||
stringify({data: {}, status: "error", error: `Device 'dimmer_wall_switch' does not support OTA updates`}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
it.each(["check", "check/downgrade", "update", "update/downgrade", "schedule", "schedule/downgrade", "unschedule"])(
|
||||
"refuses to %s when already in progress",
|
||||
async (type) => {
|
||||
if (type.includes("schedule") || type.includes("check")) {
|
||||
isUpdateAvailableSpy.mockImplementationOnce(async () => {
|
||||
return await new Promise((resolve) => {
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
available: false,
|
||||
currentFileVersion: 1,
|
||||
otaFileVersion: 1,
|
||||
}),
|
||||
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();
|
||||
} else {
|
||||
updateSpy.mockImplementationOnce(async () => {
|
||||
return await new Promise((resolve) => {
|
||||
setTimeout(() => resolve(1), 99999);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
mockMQTTEvents.message(`zigbee2mqtt/bridge/request/device/ota_update/${type.includes("schedule") ? "check" : type}`, "bulb");
|
||||
await flushPromises();
|
||||
mockMQTTEvents.message(`zigbee2mqtt/bridge/request/device/ota_update/${type}`, "bulb");
|
||||
await flushPromises();
|
||||
|
||||
if (type.includes("schedule") || type.includes("check")) {
|
||||
expect(isUpdateAvailableSpy).toHaveBeenCalledTimes(1);
|
||||
} else {
|
||||
expect(updateSpy).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
`zigbee2mqtt/bridge/response/device/ota_update/${type.replace("/downgrade", "")}`,
|
||||
stringify({data: {}, status: "error", error: `Update or check for update already in progress for 'bulb'`}),
|
||||
{},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not crash when read modelID before/after OTA update fails", async () => {
|
||||
devices.bulb.endpoints[0].read.mockRejectedValueOnce("Failed from").mockRejectedValueOnce("Failed to");
|
||||
updateSpy.mockResolvedValueOnce(1);
|
||||
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/update", "bulb");
|
||||
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},
|
||||
"zigbee2mqtt/bridge/response/device/ota_update/update",
|
||||
stringify({data: {id: "bulb", from: undefined, to: undefined}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
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());
|
||||
it("cancels scheduled when direct update requested", async () => {
|
||||
updateSpy.mockResolvedValueOnce(1);
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/ota_update/update', 'bulb');
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/schedule", "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},
|
||||
);
|
||||
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/update", "bulb");
|
||||
await flushPromises();
|
||||
|
||||
expect(mockLogger.info).toHaveBeenCalledWith("Previously scheduled 'bulb' upgrade was cancelled by manual update");
|
||||
|
||||
updateSpy.mockResolvedValueOnce(1);
|
||||
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/schedule/downgrade", "bulb");
|
||||
await flushPromises();
|
||||
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/update", "bulb");
|
||||
await flushPromises();
|
||||
|
||||
expect(mockLogger.info).toHaveBeenCalledWith("Previously scheduled 'bulb' downgrade was cancelled by manual update");
|
||||
});
|
||||
|
||||
it('Should check for update when device requests it', async () => {
|
||||
it("checks for update when device requests it", async () => {
|
||||
const data = {imageType: 12382};
|
||||
isUpdateAvailableSpy.mockResolvedValueOnce({available: true, currentFileVersion: 10, otaFileVersion: 12});
|
||||
const payload = {
|
||||
data,
|
||||
cluster: 'genOta',
|
||||
cluster: "genOta",
|
||||
device: devices.bulb,
|
||||
endpoint: devices.bulb.getEndpoint(1)!,
|
||||
type: 'commandQueryNextImageRequest',
|
||||
type: "commandQueryNextImageRequest",
|
||||
linkquality: 10,
|
||||
meta: {zclTransactionSequenceNumber: 10},
|
||||
};
|
||||
@@ -308,7 +377,7 @@ describe('Extension: OTAUpdate', () => {
|
||||
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);
|
||||
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);
|
||||
@@ -320,21 +389,21 @@ describe('Extension: OTAUpdate', () => {
|
||||
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}}),
|
||||
"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 () => {
|
||||
it("responds with NO_IMAGE_AVAILABLE when update available request fails", async () => {
|
||||
const data = {imageType: 12382};
|
||||
isUpdateAvailableSpy.mockRejectedValueOnce('Nothing to find here');
|
||||
isUpdateAvailableSpy.mockRejectedValueOnce("Nothing to find here");
|
||||
const payload = {
|
||||
data,
|
||||
cluster: 'genOta',
|
||||
cluster: "genOta",
|
||||
device: devices.bulb,
|
||||
endpoint: devices.bulb.getEndpoint(1)!,
|
||||
type: 'commandQueryNextImageRequest',
|
||||
type: "commandQueryNextImageRequest",
|
||||
linkquality: 10,
|
||||
meta: {zclTransactionSequenceNumber: 10},
|
||||
};
|
||||
@@ -343,19 +412,19 @@ describe('Extension: OTAUpdate', () => {
|
||||
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});
|
||||
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 () => {
|
||||
it("checks 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',
|
||||
cluster: "genOta",
|
||||
device: devices.bulb,
|
||||
endpoint: devices.bulb.getEndpoint(1)!,
|
||||
type: 'commandQueryNextImageRequest',
|
||||
type: "commandQueryNextImageRequest",
|
||||
linkquality: 10,
|
||||
meta: {zclTransactionSequenceNumber: 10},
|
||||
};
|
||||
@@ -364,24 +433,24 @@ describe('Extension: OTAUpdate', () => {
|
||||
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(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}}),
|
||||
"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);
|
||||
it("does 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',
|
||||
cluster: "genOta",
|
||||
device: devices.bulb,
|
||||
endpoint: devices.bulb.getEndpoint(1)!,
|
||||
type: 'commandQueryNextImageRequest',
|
||||
type: "commandQueryNextImageRequest",
|
||||
linkquality: 10,
|
||||
meta: {zclTransactionSequenceNumber: 10},
|
||||
};
|
||||
@@ -390,76 +459,302 @@ describe('Extension: OTAUpdate', () => {
|
||||
expect(isUpdateAvailableSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should respond with NO_IMAGE_AVAILABLE when not supporting OTA', async () => {
|
||||
it.each(["schedule", "schedule/downgrade"])("schedules and performs an update with topic %s", async (type) => {
|
||||
const downgrade = type === "schedule/downgrade";
|
||||
|
||||
if (downgrade) {
|
||||
settings.set(["ota", "disable_automatic_update_check"], true); // coverage, scheduling not affected by this
|
||||
}
|
||||
|
||||
updateSpy.mockImplementationOnce(async (_device, _extraMetas, previous, onProgress) => {
|
||||
expect(previous).toStrictEqual(downgrade);
|
||||
|
||||
onProgress(0, undefined);
|
||||
onProgress(10, 3600.2123);
|
||||
return await Promise.resolve(2);
|
||||
});
|
||||
|
||||
mockMQTTEvents.message(`zigbee2mqtt/bridge/request/device/ota_update/${type}`, "bulb");
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(1, "zigbee2mqtt/bulb", stringify({update: {state: "scheduled"}}), {
|
||||
retain: true,
|
||||
qos: 0,
|
||||
});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"zigbee2mqtt/bridge/response/device/ota_update/schedule",
|
||||
stringify({data: {id: "bulb"}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
|
||||
const data = {imageType: 12382};
|
||||
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(updateSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(3, "zigbee2mqtt/bulb", stringify({update: {state: "updating", progress: 0}}), {
|
||||
retain: true,
|
||||
qos: 0,
|
||||
});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
"zigbee2mqtt/bulb",
|
||||
stringify({update: {state: "updating", progress: 10, remaining: 3600}}),
|
||||
{retain: true, qos: 0},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
|
||||
5,
|
||||
"zigbee2mqtt/bulb",
|
||||
stringify({update: {state: "idle", installed_version: 2, latest_version: 2}}),
|
||||
{retain: true, qos: 0},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(6, "zigbee2mqtt/bridge/devices", expect.any(String), {retain: true});
|
||||
});
|
||||
|
||||
it("schedules and cancels an update when no image available", async () => {
|
||||
updateSpy.mockResolvedValueOnce(undefined);
|
||||
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/schedule", "bulb");
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(1, "zigbee2mqtt/bulb", stringify({update: {state: "scheduled"}}), {
|
||||
retain: true,
|
||||
qos: 0,
|
||||
});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"zigbee2mqtt/bridge/response/device/ota_update/schedule",
|
||||
stringify({data: {id: "bulb"}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
|
||||
const data = {imageType: 12382};
|
||||
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(updateSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(3, "zigbee2mqtt/bulb", stringify({update: {state: "idle"}}), {retain: true, qos: 0});
|
||||
});
|
||||
|
||||
it("schedules and re-schedules an update when failed", async () => {
|
||||
updateSpy.mockRejectedValueOnce("Update failed").mockImplementationOnce(async (_device, _extraMetas, _previous, onProgress) => {
|
||||
onProgress(0, undefined);
|
||||
onProgress(10, 3600.2123);
|
||||
return await Promise.resolve(2);
|
||||
});
|
||||
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/schedule", "bulb");
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(1, "zigbee2mqtt/bulb", stringify({update: {state: "scheduled"}}), {
|
||||
retain: true,
|
||||
qos: 0,
|
||||
});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"zigbee2mqtt/bridge/response/device/ota_update/schedule",
|
||||
stringify({data: {id: "bulb"}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
|
||||
const data = {imageType: 12382};
|
||||
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(updateSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(3, "zigbee2mqtt/bulb", stringify({update: {state: "scheduled"}}), {
|
||||
retain: true,
|
||||
qos: 0,
|
||||
});
|
||||
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
console.log(mockMQTTPublishAsync.mock.calls.map((c) => c[0]));
|
||||
|
||||
expect(updateSpy).toHaveBeenCalledTimes(2);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(4, "zigbee2mqtt/bulb", stringify({update: {state: "updating", progress: 0}}), {
|
||||
retain: true,
|
||||
qos: 0,
|
||||
});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
|
||||
5,
|
||||
"zigbee2mqtt/bulb",
|
||||
stringify({update: {state: "updating", progress: 10, remaining: 3600}}),
|
||||
{retain: true, qos: 0},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
|
||||
6,
|
||||
"zigbee2mqtt/bulb",
|
||||
stringify({update: {state: "idle", installed_version: 2, latest_version: 2}}),
|
||||
{retain: true, qos: 0},
|
||||
);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(7, "zigbee2mqtt/bridge/devices", expect.any(String), {retain: true});
|
||||
});
|
||||
|
||||
it("overwrites current schedule on re-schedule", async () => {
|
||||
for (const [type, overwriteType] of [
|
||||
["schedule", "schedule/downgrade"],
|
||||
["schedule/downgrade", "schedule"],
|
||||
]) {
|
||||
mockMQTTEvents.message(`zigbee2mqtt/bridge/request/device/ota_update/${type}`, "bulb");
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(1, "zigbee2mqtt/bulb", stringify({update: {state: "scheduled"}}), {
|
||||
retain: true,
|
||||
qos: 0,
|
||||
});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"zigbee2mqtt/bridge/response/device/ota_update/schedule",
|
||||
stringify({data: {id: "bulb"}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
|
||||
mockMQTTEvents.message(`zigbee2mqtt/bridge/request/device/ota_update/${overwriteType}`, "bulb");
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(3, "zigbee2mqtt/bulb", stringify({update: {state: "scheduled"}}), {
|
||||
retain: true,
|
||||
qos: 0,
|
||||
});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
"zigbee2mqtt/bridge/response/device/ota_update/schedule",
|
||||
stringify({data: {id: "bulb"}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["schedule", "schedule/downgrade"])("unschedules", async (type) => {
|
||||
mockMQTTEvents.message(`zigbee2mqtt/bridge/request/device/ota_update/${type}`, "bulb");
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(1, "zigbee2mqtt/bulb", stringify({update: {state: "scheduled"}}), {
|
||||
retain: true,
|
||||
qos: 0,
|
||||
});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"zigbee2mqtt/bridge/response/device/ota_update/schedule",
|
||||
stringify({data: {id: "bulb"}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/unschedule", "bulb");
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(3, "zigbee2mqtt/bulb", stringify({update: {state: "idle"}}), {retain: true, qos: 0});
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
"zigbee2mqtt/bridge/response/device/ota_update/unschedule",
|
||||
stringify({data: {id: "bulb"}, status: "ok"}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("responds with NO_IMAGE_AVAILABLE when not supporting OTA", async () => {
|
||||
const device = devices.HGZB04D;
|
||||
const data = {imageType: 12382};
|
||||
const payload = {
|
||||
data,
|
||||
cluster: 'genOta',
|
||||
cluster: "genOta",
|
||||
device,
|
||||
endpoint: device.getEndpoint(1)!,
|
||||
type: 'commandQueryNextImageRequest',
|
||||
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);
|
||||
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 () => {
|
||||
it("responds 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',
|
||||
cluster: "genOta",
|
||||
device,
|
||||
endpoint: device.getEndpoint(1)!,
|
||||
type: 'commandQueryNextImageRequest',
|
||||
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);
|
||||
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);
|
||||
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'),
|
||||
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);
|
||||
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',
|
||||
overrideIndexLocation: "http://my.site/index.json",
|
||||
imageBlockResponseDelay: 50,
|
||||
defaultMaximumDataSize: 100,
|
||||
});
|
||||
setConfiguration.mockClear();
|
||||
});
|
||||
|
||||
it('Clear update state on startup', async () => {
|
||||
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'}});
|
||||
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'}});
|
||||
expect(controller.state.get(device)).toStrictEqual({update: {state: "available"}});
|
||||
});
|
||||
});
|
||||
|
||||
+635
-633
File diff suppressed because it is too large
Load Diff
+187
-185
@@ -1,18 +1,18 @@
|
||||
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 * 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 stringify from 'json-stable-stringify-without-jsonify';
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
|
||||
import {Controller} from '../../lib/controller';
|
||||
import * as settings from '../../lib/util/settings';
|
||||
import {Controller} from "../../lib/controller";
|
||||
import * as settings from "../../lib/util/settings";
|
||||
|
||||
const mocksClear = [mockMQTTPublishAsync, mockLogger.warning, mockLogger.debug];
|
||||
|
||||
describe('Extension: Receive', () => {
|
||||
describe("Extension: Receive", () => {
|
||||
let controller: Controller;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -23,156 +23,158 @@ describe('Extension: Receive', () => {
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
beforeEach(() => {
|
||||
// @ts-expect-error private
|
||||
controller.state.state = {};
|
||||
controller.state.clear();
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
for (const mock of mocksClear) mock.mockClear();
|
||||
delete devices.WXKG11LM.linkquality;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
vi.useRealTimers();
|
||||
mockSleep.restore();
|
||||
await controller?.stop();
|
||||
await flushPromises();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('Should handle a zigbee message', async () => {
|
||||
it("Should handle a zigbee message", async () => {
|
||||
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};
|
||||
const payload = {data, cluster: "genOnOff", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/button', stringify({action: 'single', linkquality: 10}), {
|
||||
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 () => {
|
||||
it("Should handle a zigbee message which uses ep (left)", async () => {
|
||||
const device = devices.WXKG02LM_rev1;
|
||||
const data = {onOff: 1};
|
||||
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
const payload = {data, cluster: "genOnOff", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
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][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 () => {
|
||||
it("Should handle a zigbee message which uses ep (right)", async () => {
|
||||
const device = devices.WXKG02LM_rev1;
|
||||
const data = {onOff: 1};
|
||||
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(2), type: 'attributeReport', linkquality: 10};
|
||||
const payload = {data, cluster: "genOnOff", device, endpoint: device.getEndpoint(2), type: "attributeReport", linkquality: 10};
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
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][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 () => {
|
||||
it("Should handle a zigbee message with default precision", async () => {
|
||||
const device = devices.WSDCGQ11LM;
|
||||
const data = {measuredValue: -85};
|
||||
const payload = {
|
||||
data,
|
||||
cluster: 'msTemperatureMeasurement',
|
||||
cluster: "msTemperatureMeasurement",
|
||||
device,
|
||||
endpoint: device.getEndpoint(1),
|
||||
type: 'attributeReport',
|
||||
type: "attributeReport",
|
||||
linkquality: 10,
|
||||
};
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
|
||||
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 () => {
|
||||
it("Should allow to invert cover", async () => {
|
||||
const device = devices.J1_cover;
|
||||
|
||||
// Non-inverted (open = 100, close = 0)
|
||||
await mockZHEvents.message({
|
||||
data: {currentPositionLiftPercentage: 90, currentPositionTiltPercentage: 80},
|
||||
cluster: 'closuresWindowCovering',
|
||||
cluster: "closuresWindowCovering",
|
||||
device,
|
||||
endpoint: device.getEndpoint(1),
|
||||
type: 'attributeReport',
|
||||
type: "attributeReport",
|
||||
linkquality: 10,
|
||||
});
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/J1_cover', stringify({position: 10, tilt: 20, state: 'OPEN'}), {
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/J1_cover", stringify({position: 10, tilt: 20, state: "OPEN"}), {
|
||||
retain: false,
|
||||
qos: 0,
|
||||
});
|
||||
|
||||
// Inverted
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
settings.set(['devices', device.ieeeAddr, 'invert_cover'], true);
|
||||
settings.set(["devices", device.ieeeAddr, "invert_cover"], true);
|
||||
await mockZHEvents.message({
|
||||
data: {currentPositionLiftPercentage: 90, currentPositionTiltPercentage: 80},
|
||||
cluster: 'closuresWindowCovering',
|
||||
cluster: "closuresWindowCovering",
|
||||
device,
|
||||
endpoint: device.getEndpoint(1),
|
||||
type: 'attributeReport',
|
||||
type: "attributeReport",
|
||||
linkquality: 10,
|
||||
});
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/J1_cover', stringify({position: 90, tilt: 80, state: 'OPEN'}), {
|
||||
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 () => {
|
||||
it("Should allow to disable the legacy integration", async () => {
|
||||
const device = devices.WXKG11LM;
|
||||
settings.set(['devices', device.ieeeAddr, 'legacy'], false);
|
||||
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};
|
||||
const payload = {data, cluster: "genOnOff", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/button', stringify({action: 'single'}), {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/button", stringify({action: "single"}), {retain: false, qos: 0});
|
||||
});
|
||||
|
||||
it('Should debounce messages', async () => {
|
||||
it("Should debounce messages", async () => {
|
||||
const device = devices.WSDCGQ11LM;
|
||||
settings.set(['devices', device.ieeeAddr, 'debounce'], 0.1);
|
||||
settings.set(["devices", device.ieeeAddr, "debounce"], 0.1);
|
||||
const data1 = {measuredValue: 8};
|
||||
const payload1 = {
|
||||
data: data1,
|
||||
cluster: 'msTemperatureMeasurement',
|
||||
cluster: "msTemperatureMeasurement",
|
||||
device,
|
||||
endpoint: device.getEndpoint(1),
|
||||
type: 'attributeReport',
|
||||
type: "attributeReport",
|
||||
linkquality: 10,
|
||||
};
|
||||
await mockZHEvents.message(payload1);
|
||||
const data2 = {measuredValue: 1};
|
||||
const payload2 = {
|
||||
data: data2,
|
||||
cluster: 'msRelativeHumidity',
|
||||
cluster: "msRelativeHumidity",
|
||||
device,
|
||||
endpoint: device.getEndpoint(1),
|
||||
type: 'attributeReport',
|
||||
type: "attributeReport",
|
||||
linkquality: 10,
|
||||
};
|
||||
await mockZHEvents.message(payload2);
|
||||
const data3 = {measuredValue: 2};
|
||||
const payload3 = {
|
||||
data: data3,
|
||||
cluster: 'msPressureMeasurement',
|
||||
cluster: "msPressureMeasurement",
|
||||
device,
|
||||
endpoint: device.getEndpoint(1),
|
||||
type: 'attributeReport',
|
||||
type: "attributeReport",
|
||||
linkquality: 10,
|
||||
};
|
||||
await mockZHEvents.message(payload3);
|
||||
@@ -182,43 +184,43 @@ describe('Extension: Receive', () => {
|
||||
vi.runOnlyPendingTimers();
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
|
||||
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 () => {
|
||||
it("Should debounce and retain messages when set via device_options", async () => {
|
||||
const device = devices.WSDCGQ11LM;
|
||||
settings.set(['device_options', 'debounce'], 0.1);
|
||||
settings.set(['device_options', 'retain'], true);
|
||||
delete settings.get().devices['0x0017880104e45522']['retain'];
|
||||
settings.set(["device_options", "debounce"], 0.1);
|
||||
settings.set(["device_options", "retain"], true);
|
||||
delete settings.get().devices["0x0017880104e45522"].retain;
|
||||
const data1 = {measuredValue: 8};
|
||||
const payload1 = {
|
||||
data: data1,
|
||||
cluster: 'msTemperatureMeasurement',
|
||||
cluster: "msTemperatureMeasurement",
|
||||
device,
|
||||
endpoint: device.getEndpoint(1),
|
||||
type: 'attributeReport',
|
||||
type: "attributeReport",
|
||||
linkquality: 10,
|
||||
};
|
||||
await mockZHEvents.message(payload1);
|
||||
const data2 = {measuredValue: 1};
|
||||
const payload2 = {
|
||||
data: data2,
|
||||
cluster: 'msRelativeHumidity',
|
||||
cluster: "msRelativeHumidity",
|
||||
device,
|
||||
endpoint: device.getEndpoint(1),
|
||||
type: 'attributeReport',
|
||||
type: "attributeReport",
|
||||
linkquality: 10,
|
||||
};
|
||||
await mockZHEvents.message(payload2);
|
||||
const data3 = {measuredValue: 2};
|
||||
const payload3 = {
|
||||
data: data3,
|
||||
cluster: 'msPressureMeasurement',
|
||||
cluster: "msPressureMeasurement",
|
||||
device,
|
||||
endpoint: device.getEndpoint(1),
|
||||
type: 'attributeReport',
|
||||
type: "attributeReport",
|
||||
linkquality: 10,
|
||||
};
|
||||
await mockZHEvents.message(payload3);
|
||||
@@ -228,48 +230,48 @@ describe('Extension: Receive', () => {
|
||||
vi.runOnlyPendingTimers();
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
|
||||
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 () => {
|
||||
it("Should debounce messages only with the same payload values for provided debounce_ignore keys", async () => {
|
||||
const device = devices.WSDCGQ11LM;
|
||||
settings.set(['devices', device.ieeeAddr, 'debounce'], 0.1);
|
||||
settings.set(['devices', device.ieeeAddr, 'debounce_ignore'], ['temperature']);
|
||||
settings.set(["devices", device.ieeeAddr, "debounce"], 0.1);
|
||||
settings.set(["devices", device.ieeeAddr, "debounce_ignore"], ["temperature"]);
|
||||
const tempMsg = {
|
||||
data: {measuredValue: 8},
|
||||
cluster: 'msTemperatureMeasurement',
|
||||
cluster: "msTemperatureMeasurement",
|
||||
device,
|
||||
endpoint: device.getEndpoint(1),
|
||||
type: 'attributeReport',
|
||||
type: "attributeReport",
|
||||
linkquality: 13,
|
||||
};
|
||||
await mockZHEvents.message(tempMsg);
|
||||
const pressureMsg = {
|
||||
data: {measuredValue: 2},
|
||||
cluster: 'msPressureMeasurement',
|
||||
cluster: "msPressureMeasurement",
|
||||
device,
|
||||
endpoint: device.getEndpoint(1),
|
||||
type: 'attributeReport',
|
||||
type: "attributeReport",
|
||||
linkquality: 13,
|
||||
};
|
||||
await mockZHEvents.message(pressureMsg);
|
||||
const tempMsg2 = {
|
||||
data: {measuredValue: 7},
|
||||
cluster: 'msTemperatureMeasurement',
|
||||
cluster: "msTemperatureMeasurement",
|
||||
device,
|
||||
endpoint: device.getEndpoint(1),
|
||||
type: 'attributeReport',
|
||||
type: "attributeReport",
|
||||
linkquality: 13,
|
||||
};
|
||||
await mockZHEvents.message(tempMsg2);
|
||||
const humidityMsg = {
|
||||
data: {measuredValue: 3},
|
||||
cluster: 'msRelativeHumidity',
|
||||
cluster: "msRelativeHumidity",
|
||||
device,
|
||||
endpoint: device.getEndpoint(1),
|
||||
type: 'attributeReport',
|
||||
type: "attributeReport",
|
||||
linkquality: 13,
|
||||
};
|
||||
await mockZHEvents.message(humidityMsg);
|
||||
@@ -283,34 +285,34 @@ describe('Extension: Receive', () => {
|
||||
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 () => {
|
||||
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 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 = devices.WSDCGQ11LM;
|
||||
settings.set(['devices', device.ieeeAddr, 'debounce'], 0.1);
|
||||
settings.set(["devices", device.ieeeAddr, "debounce"], 0.1);
|
||||
await mockZHEvents.message({
|
||||
data: {measuredValue: 8},
|
||||
cluster: 'msTemperatureMeasurement',
|
||||
cluster: "msTemperatureMeasurement",
|
||||
device,
|
||||
endpoint: device.getEndpoint(1),
|
||||
type: 'attributeReport',
|
||||
type: "attributeReport",
|
||||
linkquality: 10,
|
||||
});
|
||||
await mockZHEvents.message({
|
||||
data: {measuredValue: 1},
|
||||
cluster: 'msRelativeHumidity',
|
||||
cluster: "msRelativeHumidity",
|
||||
device,
|
||||
endpoint: device.getEndpoint(1),
|
||||
type: 'attributeReport',
|
||||
type: "attributeReport",
|
||||
linkquality: 10,
|
||||
});
|
||||
await mockZHEvents.message({
|
||||
data: {measuredValue: 2},
|
||||
cluster: 'msPressureMeasurement',
|
||||
cluster: "msPressureMeasurement",
|
||||
device,
|
||||
endpoint: device.getEndpoint(1),
|
||||
type: 'attributeReport',
|
||||
type: "attributeReport",
|
||||
linkquality: 10,
|
||||
});
|
||||
await flushPromises();
|
||||
@@ -321,16 +323,16 @@ describe('Extension: Receive', () => {
|
||||
|
||||
// Test that only one MQTT is sent out and test its values.
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
|
||||
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 mockZHEvents.message({
|
||||
data: {measuredValue: 9},
|
||||
cluster: 'msTemperatureMeasurement',
|
||||
cluster: "msTemperatureMeasurement",
|
||||
device,
|
||||
endpoint: device.getEndpoint(1),
|
||||
type: 'attributeReport',
|
||||
type: "attributeReport",
|
||||
linkquality: 10,
|
||||
});
|
||||
// @ts-expect-error private
|
||||
@@ -349,39 +351,39 @@ describe('Extension: Receive', () => {
|
||||
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 () => {
|
||||
it("Should throttle multiple messages from spamming devices", async () => {
|
||||
const device = devices.SPAMMER;
|
||||
const throttle_for_testing = 1;
|
||||
settings.set(['device_options', 'throttle'], throttle_for_testing);
|
||||
settings.set(['device_options', 'retain'], true);
|
||||
settings.set(['devices', device.ieeeAddr, 'friendly_name'], 'spammer1');
|
||||
settings.set(["device_options", "throttle"], throttle_for_testing);
|
||||
settings.set(["device_options", "retain"], true);
|
||||
settings.set(["devices", device.ieeeAddr, "friendly_name"], "spammer1");
|
||||
const data1 = {measuredValue: 1};
|
||||
const payload1 = {
|
||||
data: data1,
|
||||
cluster: 'msTemperatureMeasurement',
|
||||
cluster: "msTemperatureMeasurement",
|
||||
device,
|
||||
endpoint: device.getEndpoint(1),
|
||||
type: 'attributeReport',
|
||||
type: "attributeReport",
|
||||
linkquality: 10,
|
||||
};
|
||||
await mockZHEvents.message(payload1);
|
||||
const data2 = {measuredValue: 2};
|
||||
const payload2 = {
|
||||
data: data2,
|
||||
cluster: 'msTemperatureMeasurement',
|
||||
cluster: "msTemperatureMeasurement",
|
||||
device,
|
||||
endpoint: device.getEndpoint(1),
|
||||
type: 'attributeReport',
|
||||
type: "attributeReport",
|
||||
linkquality: 10,
|
||||
};
|
||||
await mockZHEvents.message(payload2);
|
||||
const data3 = {measuredValue: 3};
|
||||
const payload3 = {
|
||||
data: data3,
|
||||
cluster: 'msTemperatureMeasurement',
|
||||
cluster: "msTemperatureMeasurement",
|
||||
device,
|
||||
endpoint: device.getEndpoint(1),
|
||||
type: 'attributeReport',
|
||||
type: "attributeReport",
|
||||
linkquality: 10,
|
||||
};
|
||||
await mockZHEvents.message(payload3);
|
||||
@@ -390,7 +392,7 @@ describe('Extension: Receive', () => {
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/spammer1');
|
||||
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});
|
||||
|
||||
@@ -400,149 +402,149 @@ describe('Extension: Receive', () => {
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/spammer1');
|
||||
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 = {
|
||||
data: data4,
|
||||
cluster: 'msTemperatureMeasurement',
|
||||
cluster: "msTemperatureMeasurement",
|
||||
device,
|
||||
endpoint: device.getEndpoint(1),
|
||||
type: 'attributeReport',
|
||||
type: "attributeReport",
|
||||
linkquality: 10,
|
||||
};
|
||||
await mockZHEvents.message(payload4);
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(3);
|
||||
expect(mockMQTTPublishAsync.mock.calls[2][0]).toStrictEqual('zigbee2mqtt/spammer1');
|
||||
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 () => {
|
||||
it("Shouldnt republish old state", async () => {
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/3572
|
||||
const device = devices.bulb;
|
||||
settings.set(['devices', device.ieeeAddr, 'debounce'], 0.1);
|
||||
settings.set(["devices", device.ieeeAddr, "debounce"], 0.1);
|
||||
await mockZHEvents.message({
|
||||
data: {onOff: 0},
|
||||
cluster: 'genOnOff',
|
||||
cluster: "genOnOff",
|
||||
device,
|
||||
endpoint: device.getEndpoint(1),
|
||||
type: 'attributeReport',
|
||||
type: "attributeReport",
|
||||
linkquality: 10,
|
||||
});
|
||||
await mockMQTTEvents.message('zigbee2mqtt/bulb/set', stringify({state: 'ON'}));
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bulb/set", stringify({state: "ON"}));
|
||||
await flushPromises();
|
||||
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'});
|
||||
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 () => {
|
||||
it("Should handle a zigbee message with 1 precision", async () => {
|
||||
const device = devices.WSDCGQ11LM;
|
||||
settings.set(['devices', device.ieeeAddr, 'temperature_precision'], 1);
|
||||
settings.set(["devices", device.ieeeAddr, "temperature_precision"], 1);
|
||||
const data = {measuredValue: -85};
|
||||
const payload = {
|
||||
data,
|
||||
cluster: 'msTemperatureMeasurement',
|
||||
cluster: "msTemperatureMeasurement",
|
||||
device,
|
||||
endpoint: device.getEndpoint(1),
|
||||
type: 'attributeReport',
|
||||
type: "attributeReport",
|
||||
linkquality: 10,
|
||||
};
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
|
||||
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 () => {
|
||||
it("Should handle a zigbee message with 0 precision", async () => {
|
||||
const device = devices.WSDCGQ11LM;
|
||||
settings.set(['devices', device.ieeeAddr, 'temperature_precision'], 0);
|
||||
settings.set(["devices", device.ieeeAddr, "temperature_precision"], 0);
|
||||
const data = {measuredValue: -85};
|
||||
const payload = {
|
||||
data,
|
||||
cluster: 'msTemperatureMeasurement',
|
||||
cluster: "msTemperatureMeasurement",
|
||||
device,
|
||||
endpoint: device.getEndpoint(1),
|
||||
type: 'attributeReport',
|
||||
type: "attributeReport",
|
||||
linkquality: 10,
|
||||
};
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
|
||||
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 () => {
|
||||
it("Should handle a zigbee message with 1 precision when set via device_options", async () => {
|
||||
const device = devices.WSDCGQ11LM;
|
||||
settings.set(['device_options', 'temperature_precision'], 1);
|
||||
settings.set(["device_options", "temperature_precision"], 1);
|
||||
const data = {measuredValue: -85};
|
||||
const payload = {
|
||||
data,
|
||||
cluster: 'msTemperatureMeasurement',
|
||||
cluster: "msTemperatureMeasurement",
|
||||
device,
|
||||
endpoint: device.getEndpoint(1),
|
||||
type: 'attributeReport',
|
||||
type: "attributeReport",
|
||||
linkquality: 10,
|
||||
};
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
|
||||
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 () => {
|
||||
it("Should handle a zigbee message with 2 precision when overrides device_options", async () => {
|
||||
const device = devices.WSDCGQ11LM;
|
||||
settings.set(['device_options', 'temperature_precision'], 1);
|
||||
settings.set(['devices', device.ieeeAddr, 'temperature_precision'], 0);
|
||||
settings.set(["device_options", "temperature_precision"], 1);
|
||||
settings.set(["devices", device.ieeeAddr, "temperature_precision"], 0);
|
||||
const data = {measuredValue: -85};
|
||||
const payload = {
|
||||
data,
|
||||
cluster: 'msTemperatureMeasurement',
|
||||
cluster: "msTemperatureMeasurement",
|
||||
device,
|
||||
endpoint: device.getEndpoint(1),
|
||||
type: 'attributeReport',
|
||||
type: "attributeReport",
|
||||
linkquality: 10,
|
||||
};
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
|
||||
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 () => {
|
||||
it("Should handle a zigbee message with voltage 2990", async () => {
|
||||
const device = devices.WXKG02LM_rev1;
|
||||
const data = {65281: {1: 2990}};
|
||||
const payload = {data, cluster: 'genBasic', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
const payload = {data, cluster: "genBasic", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button_double_key');
|
||||
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 () => {
|
||||
it("Should publish 1 message when converted twice", async () => {
|
||||
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};
|
||||
const payload = {data, cluster: "genBasic", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/occupancy_sensor');
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/occupancy_sensor");
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({
|
||||
battery: 100,
|
||||
illuminance: 381,
|
||||
@@ -553,128 +555,128 @@ describe('Extension: Receive', () => {
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
|
||||
});
|
||||
|
||||
it('Should publish 1 message when converted twice', async () => {
|
||||
it("Should publish 1 message when converted twice", async () => {
|
||||
const device = devices.RTCGQ11LM;
|
||||
const data = {9999: {1: 3045}};
|
||||
const payload = {data, cluster: 'genBasic', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
const payload = {data, cluster: "genBasic", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should publish last_seen epoch', async () => {
|
||||
it("Should publish last_seen epoch", async () => {
|
||||
const device = devices.WXKG02LM_rev1;
|
||||
settings.set(['advanced', 'last_seen'], 'epoch');
|
||||
settings.set(["advanced", "last_seen"], "epoch");
|
||||
const data = {onOff: 1};
|
||||
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
const payload = {data, cluster: "genOnOff", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
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][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 () => {
|
||||
it("Should publish last_seen ISO_8601", async () => {
|
||||
const device = devices.WXKG02LM_rev1;
|
||||
settings.set(['advanced', 'last_seen'], 'ISO_8601');
|
||||
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};
|
||||
const payload = {data, cluster: "genOnOff", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
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][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 () => {
|
||||
it("Should publish last_seen ISO_8601_local", async () => {
|
||||
const device = devices.WXKG02LM_rev1;
|
||||
settings.set(['advanced', 'last_seen'], 'ISO_8601_local');
|
||||
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};
|
||||
const payload = {data, cluster: "genOnOff", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
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][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 () => {
|
||||
it("Should handle messages from Xiaomi router devices", async () => {
|
||||
const device = devices.ZNCZ02LM;
|
||||
const data = {onOff: 1};
|
||||
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 20};
|
||||
const payload = {data, cluster: "genOnOff", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 20};
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
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});
|
||||
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 () => {
|
||||
it("Should not handle messages from coordinator", async () => {
|
||||
const device = devices.coordinator;
|
||||
const data = {onOff: 1};
|
||||
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
const payload = {data, cluster: "genOnOff", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should not handle messages from still interviewing devices with unknown definition', async () => {
|
||||
it("Should not handle messages from still interviewing devices with unknown definition", async () => {
|
||||
const device = devices.interviewing;
|
||||
const data = {onOff: 1};
|
||||
mockLogger.debug.mockClear();
|
||||
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
const payload = {data, cluster: "genOnOff", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(`Skipping message, still interviewing`);
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith("Skipping message, still interviewing");
|
||||
});
|
||||
|
||||
it('Should handle a command', async () => {
|
||||
it("Should handle a command", async () => {
|
||||
const device = devices.E1743;
|
||||
const data = {};
|
||||
const payload = {
|
||||
data,
|
||||
cluster: 'genLevelCtrl',
|
||||
cluster: "genLevelCtrl",
|
||||
device,
|
||||
endpoint: device.getEndpoint(1),
|
||||
type: 'commandStopWithOnOff',
|
||||
type: "commandStopWithOnOff",
|
||||
linkquality: 10,
|
||||
meta: {zclTransactionSequenceNumber: 1},
|
||||
};
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
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][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);
|
||||
it("Should add elapsed", async () => {
|
||||
settings.set(["advanced", "elapsed"], true);
|
||||
const device = devices.E1743;
|
||||
const payload = {data: {}, cluster: 'genLevelCtrl', device, endpoint: device.getEndpoint(1), type: 'commandStopWithOnOff'};
|
||||
vi.spyOn(Date, 'now').mockReturnValueOnce(150).mockReturnValueOnce(200);
|
||||
const payload = {data: {}, cluster: "genLevelCtrl", device, endpoint: device.getEndpoint(1), type: "commandStopWithOnOff"};
|
||||
vi.spyOn(Date, "now").mockReturnValueOnce(150).mockReturnValueOnce(200);
|
||||
await mockZHEvents.message({...payload, meta: {zclTransactionSequenceNumber: 2}});
|
||||
await flushPromises();
|
||||
await mockZHEvents.message({...payload, meta: {zclTransactionSequenceNumber: 3}});
|
||||
await flushPromises();
|
||||
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][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(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 () => {
|
||||
it("Should log when message is from supported device but has no converters", async () => {
|
||||
const device = devices.ZNCZ02LM;
|
||||
const data = {inactiveText: 'hello'};
|
||||
const payload = {data, cluster: 'genBinaryOutput', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 20};
|
||||
const data = {inactiveText: "hello"};
|
||||
const payload = {data, cluster: "genBinaryOutput", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 20};
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
|
||||
@@ -683,7 +685,7 @@ describe('Extension: Receive', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('Should report correct energy and power values for different versions of SP600', async () => {
|
||||
it("Should report correct energy and power values for different versions of SP600", async () => {
|
||||
// https://github.com/Koenkk/zigbee-herdsman-converters/issues/915, OLD and NEW use different date code
|
||||
// divisor of OLD is not correct and therefore underreports by factor 10.
|
||||
const data = {instantaneousDemand: 496, currentSummDelivered: 6648};
|
||||
@@ -691,40 +693,40 @@ describe('Extension: Receive', () => {
|
||||
const SP600_NEW = devices.SP600_NEW;
|
||||
await mockZHEvents.message({
|
||||
data,
|
||||
cluster: 'seMetering',
|
||||
cluster: "seMetering",
|
||||
device: SP600_NEW,
|
||||
endpoint: SP600_NEW.getEndpoint(1),
|
||||
type: 'attributeReport',
|
||||
type: "attributeReport",
|
||||
linkquality: 10,
|
||||
meta: {zclTransactionSequenceNumber: 1},
|
||||
});
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/SP600_NEW');
|
||||
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});
|
||||
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
const SP600_OLD = devices.SP600_OLD;
|
||||
await mockZHEvents.message({
|
||||
data,
|
||||
cluster: 'seMetering',
|
||||
cluster: "seMetering",
|
||||
device: SP600_OLD,
|
||||
endpoint: SP600_OLD.getEndpoint(1),
|
||||
type: 'attributeReport',
|
||||
type: "attributeReport",
|
||||
linkquality: 10,
|
||||
meta: {zclTransactionSequenceNumber: 2},
|
||||
});
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/SP600_OLD');
|
||||
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 = devices['BMCT-SLZ'];
|
||||
it("Should emit DevicesChanged event when a converter announces changed exposes", async () => {
|
||||
const device = devices["BMCT-SLZ"];
|
||||
const data = {deviceMode: 0};
|
||||
const payload = {data, cluster: 'boschSpecific', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
|
||||
const payload = {data, cluster: "boschSpecific", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
|
||||
await mockZHEvents.message(payload);
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/devices');
|
||||
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bridge/devices");
|
||||
});
|
||||
});
|
||||
|
||||
+180
-163
@@ -1,19 +1,19 @@
|
||||
import * as data from './mocks/data';
|
||||
import * as data from "./mocks/data";
|
||||
|
||||
import type {MockInstance} from 'vitest';
|
||||
import type {MockInstance} from "vitest";
|
||||
|
||||
import fs from 'node:fs';
|
||||
import {platform} from 'node:os';
|
||||
import path from 'node:path';
|
||||
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 {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';
|
||||
import logger from "../lib/util/logger";
|
||||
import * as settings from "../lib/util/settings";
|
||||
|
||||
describe('Logger', () => {
|
||||
describe("Logger", () => {
|
||||
let consoleWriteSpy: MockInstance;
|
||||
const dir = tmp.dirSync();
|
||||
|
||||
@@ -24,29 +24,29 @@ describe('Logger', () => {
|
||||
|
||||
beforeAll(() => {
|
||||
// @ts-expect-error private
|
||||
consoleWriteSpy = vi.spyOn(console._stdout, 'write').mockImplementation(() => {});
|
||||
consoleWriteSpy = vi.spyOn(console._stdout, "write").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
consoleWriteSpy.mockRestore();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
beforeEach(() => {
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
settings.set(['advanced', 'log_directory'], dir.name + '/%TIMESTAMP%');
|
||||
settings.set(["advanced", "log_directory"], `${dir.name}/%TIMESTAMP%`);
|
||||
logger.init();
|
||||
consoleWriteSpy.mockClear();
|
||||
});
|
||||
|
||||
afterEach(async () => {});
|
||||
|
||||
it('Create log directory', () => {
|
||||
it("Create log directory", () => {
|
||||
const dirs = fs.readdirSync(dir.name);
|
||||
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));
|
||||
}
|
||||
@@ -60,7 +60,22 @@ describe('Logger', () => {
|
||||
expect(fs.readdirSync(dir.name).length).toBe(10);
|
||||
});
|
||||
|
||||
it('Should not cleanup when there is no timestamp set', () => {
|
||||
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 (const d of fs.readdirSync(dir.name)) {
|
||||
rimrafSync(path.join(dir.name, d));
|
||||
}
|
||||
@@ -69,30 +84,30 @@ describe('Logger', () => {
|
||||
fs.mkdirSync(path.join(dir.name, `log_${i}`));
|
||||
}
|
||||
|
||||
settings.set(['advanced', 'log_directory'], dir.name + '/bla');
|
||||
settings.set(["advanced", "log_directory"], `${dir.name}/bla`);
|
||||
expect(fs.readdirSync(dir.name).length).toBe(20);
|
||||
logger.init();
|
||||
expect(fs.readdirSync(dir.name).length).toBe(21);
|
||||
});
|
||||
|
||||
it('Set and get log level', () => {
|
||||
logger.setLevel('debug');
|
||||
expect(logger.getLevel()).toBe('debug');
|
||||
logger.setLevel('info');
|
||||
expect(logger.getLevel()).toBe('info');
|
||||
logger.setLevel('warning');
|
||||
expect(logger.getLevel()).toBe('warning');
|
||||
logger.setLevel('error');
|
||||
expect(logger.getLevel()).toBe('error');
|
||||
it("Set and get log level", () => {
|
||||
logger.setLevel("debug");
|
||||
expect(logger.getLevel()).toBe("debug");
|
||||
logger.setLevel("info");
|
||||
expect(logger.getLevel()).toBe("info");
|
||||
logger.setLevel("warning");
|
||||
expect(logger.getLevel()).toBe("warning");
|
||||
logger.setLevel("error");
|
||||
expect(logger.getLevel()).toBe("error");
|
||||
|
||||
// winston level always stays at 'debug', logic handled by custom logger
|
||||
expect(logger.winston.level).toStrictEqual('debug');
|
||||
expect(logger.winston.level).toStrictEqual("debug");
|
||||
for (const transport of logger.winston.transports) {
|
||||
expect(transport.level).toStrictEqual(undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it('Add/remove transport', () => {
|
||||
it("Add/remove transport", () => {
|
||||
class DummyTransport extends Transport {
|
||||
log(): void {}
|
||||
}
|
||||
@@ -105,64 +120,64 @@ describe('Logger', () => {
|
||||
expect(logger.winston.transports.length).toBe(2);
|
||||
});
|
||||
|
||||
it('Logger should be console and file by default', () => {
|
||||
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');
|
||||
expect(pipes[0].constructor.name).toBe("Console");
|
||||
expect(pipes[0].silent).toBe(false);
|
||||
expect(pipes[1].constructor.name).toBe('File');
|
||||
expect(pipes[1].constructor.name).toBe("File");
|
||||
expect(pipes[1].dirname.startsWith(dir.name)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Logger can be file only', () => {
|
||||
settings.set(['advanced', 'log_output'], ['file']);
|
||||
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');
|
||||
expect(pipes[0].constructor.name).toBe("Console");
|
||||
expect(pipes[0].silent).toBe(true);
|
||||
expect(pipes[1].constructor.name).toBe('File');
|
||||
expect(pipes[1].constructor.name).toBe("File");
|
||||
expect(pipes[1].dirname.startsWith(dir.name)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Logger can be console only', () => {
|
||||
settings.set(['advanced', 'log_output'], ['console']);
|
||||
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.constructor.name).toBe("Console");
|
||||
expect(pipes.silent).toBe(false);
|
||||
});
|
||||
|
||||
it('Logger can be nothing', () => {
|
||||
settings.set(['advanced', 'log_output'], []);
|
||||
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.constructor.name).toBe("Console");
|
||||
expect(pipes.silent).toBe(true);
|
||||
});
|
||||
|
||||
it('Should allow to disable log rotation', () => {
|
||||
settings.set(['advanced', 'log_rotation'], false);
|
||||
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].constructor.name).toBe("File");
|
||||
expect(pipes[1].maxFiles).toBeNull();
|
||||
expect(pipes[1].tailable).toBeFalsy();
|
||||
expect(pipes[1].maxsize).toBeNull();
|
||||
});
|
||||
|
||||
it('Should allow to symlink logs to current directory', () => {
|
||||
it("Should allow to symlink logs to current directory", () => {
|
||||
try {
|
||||
settings.set(['advanced', 'log_symlink_current'], true);
|
||||
settings.set(["advanced", "log_symlink_current"], true);
|
||||
logger.init();
|
||||
expect(fs.readdirSync(dir.name).includes('current')).toBeTruthy();
|
||||
expect(fs.readdirSync(dir.name).includes("current")).toBeTruthy();
|
||||
} catch (error) {
|
||||
if (platform() !== 'win32' || !(error as Error).message.startsWith('EPERM')) {
|
||||
if (platform() !== "win32" || !(error as Error).message.startsWith("EPERM")) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -171,38 +186,38 @@ describe('Logger', () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
['debug', {higher: ['info', 'warning', 'error'], lower: []}],
|
||||
['info', {higher: ['warning', 'error'], lower: ['debug']}],
|
||||
['warning', {higher: ['error'], lower: ['debug', 'info']}],
|
||||
['error', {higher: [], lower: ['debug', 'info', 'warning']}],
|
||||
])('Logs relevant levels for %s', (level, otherLevels) => {
|
||||
["debug", {higher: ["info", "warning", "error"], lower: []}],
|
||||
["info", {higher: ["warning", "error"], lower: ["debug"]}],
|
||||
["warning", {higher: ["error"], lower: ["debug", "info"]}],
|
||||
["error", {higher: [], lower: ["debug", "info", "warning"]}],
|
||||
])("Logs relevant levels for %s", (level, otherLevels) => {
|
||||
logger.setLevel(level as settings.LogLevel);
|
||||
|
||||
const logSpy = vi.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');
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
logger[higherLevel]("higher msg", "abcd");
|
||||
expect(logSpy).toHaveBeenLastCalledWith(higherLevel, "abcd: higher msg");
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(i++);
|
||||
}
|
||||
|
||||
@@ -211,11 +226,11 @@ describe('Logger', () => {
|
||||
|
||||
for (const lowerLevel of otherLevels.lower) {
|
||||
// @ts-expect-error dynamic
|
||||
logger[lowerLevel]('lower msg');
|
||||
logger[lowerLevel]("lower msg");
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
expect(consoleWriteSpy).not.toHaveBeenCalled();
|
||||
// @ts-expect-error dynamic
|
||||
logger[lowerLevel]('lower msg', 'abcd');
|
||||
logger[lowerLevel]("lower msg", "abcd");
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
expect(consoleWriteSpy).not.toHaveBeenCalled();
|
||||
}
|
||||
@@ -223,202 +238,204 @@ describe('Logger', () => {
|
||||
|
||||
it.each([
|
||||
[
|
||||
'^zhc:legacy:fz:(tuya|moes)',
|
||||
"^zhc:legacy:fz:(tuya|moes)",
|
||||
new RegExp(/^zhc:legacy:fz:(tuya|moes)/),
|
||||
[
|
||||
{ns: 'zhc:legacy:fz:tuya_device12', match: true},
|
||||
{ns: 'zhc:legacy:fz:moes_dimmer', match: true},
|
||||
{ns: 'zhc:legacy:fz:not_moes', match: false},
|
||||
{ns: 'zhc:legacy:fz', match: false},
|
||||
{ns: 'zhc:legacy:fz:', match: false},
|
||||
{ns: '1zhc:legacy:fz:tuya_device12', match: false},
|
||||
{ns: "zhc:legacy:fz:tuya_device12", match: true},
|
||||
{ns: "zhc:legacy:fz:moes_dimmer", match: true},
|
||||
{ns: "zhc:legacy:fz:not_moes", match: false},
|
||||
{ns: "zhc:legacy:fz", match: false},
|
||||
{ns: "zhc:legacy:fz:", match: false},
|
||||
{ns: "1zhc:legacy:fz:tuya_device12", match: false},
|
||||
],
|
||||
],
|
||||
[
|
||||
'^zhc:legacy:fz:(tuya|moes)|^zh:ember:uart:|^zh:controller',
|
||||
"^zhc:legacy:fz:(tuya|moes)|^zh:ember:uart:|^zh:controller",
|
||||
new RegExp(/^zhc:legacy:fz:(tuya|moes)|^zh:ember:uart:|^zh:controller/),
|
||||
[
|
||||
{ns: 'zh:ember:uart:ash', match: true},
|
||||
{ns: 'zh:ember:uart', match: false},
|
||||
{ns: 'zh:controller', match: true},
|
||||
{ns: 'zh:controller:', match: true},
|
||||
{ns: 'azh:controller:', match: false},
|
||||
{ns: "zh:ember:uart:ash", match: true},
|
||||
{ns: "zh:ember:uart", match: false},
|
||||
{ns: "zh:controller", match: true},
|
||||
{ns: "zh:controller:", match: true},
|
||||
{ns: "azh:controller:", match: false},
|
||||
],
|
||||
],
|
||||
[
|
||||
'',
|
||||
"",
|
||||
undefined,
|
||||
[
|
||||
{ns: 'zhc:legacy:fz:tuya_device12', match: false},
|
||||
{ns: 'zhc:legacy:fz:moes_dimmer', match: false},
|
||||
{ns: 'zhc:legacy:fz:not_moes', match: false},
|
||||
{ns: 'zhc:legacy:fz', match: false},
|
||||
{ns: 'zhc:legacy:fz:', match: false},
|
||||
{ns: '1zhc:legacy:fz:tuya_device12', match: false},
|
||||
{ns: 'zh:ember:uart:ash', match: false},
|
||||
{ns: 'zh:ember:uart', match: false},
|
||||
{ns: 'zh:controller', match: false},
|
||||
{ns: 'zh:controller:', match: false},
|
||||
{ns: 'azh:controller:', match: false},
|
||||
{ns: "zhc:legacy:fz:tuya_device12", match: false},
|
||||
{ns: "zhc:legacy:fz:moes_dimmer", match: false},
|
||||
{ns: "zhc:legacy:fz:not_moes", match: false},
|
||||
{ns: "zhc:legacy:fz", match: false},
|
||||
{ns: "zhc:legacy:fz:", match: false},
|
||||
{ns: "1zhc:legacy:fz:tuya_device12", match: false},
|
||||
{ns: "zh:ember:uart:ash", match: false},
|
||||
{ns: "zh:ember:uart", match: false},
|
||||
{ns: "zh:controller", match: false},
|
||||
{ns: "zh:controller:", match: false},
|
||||
{ns: "azh:controller:", match: false},
|
||||
],
|
||||
],
|
||||
])('Sets namespace ignore for debug level %s', (ignore, expected, tests) => {
|
||||
logger.setLevel('debug');
|
||||
const logSpy = vi.spyOn(logger.winston, 'log');
|
||||
])("Sets namespace ignore for debug level %s", (ignore, expected, tests) => {
|
||||
logger.setLevel("debug");
|
||||
const logSpy = vi.spyOn(logger.winston, "log");
|
||||
logger.setDebugNamespaceIgnore(ignore);
|
||||
// @ts-expect-error private
|
||||
expect(logger.debugNamespaceIgnoreRegex).toStrictEqual(expected);
|
||||
expect(logger.getDebugNamespaceIgnore()).toStrictEqual(ignore);
|
||||
|
||||
for (const test of tests) {
|
||||
logger.debug('Test message', test.ns);
|
||||
logger.debug("Test message", test.ns);
|
||||
|
||||
if (test.match) {
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(logSpy).toHaveBeenLastCalledWith('debug', `${test.ns}: Test message`);
|
||||
expect(logSpy).toHaveBeenLastCalledWith("debug", `${test.ns}: Test message`);
|
||||
}
|
||||
|
||||
logSpy.mockClear();
|
||||
}
|
||||
});
|
||||
|
||||
it('Logs with namespaced levels or default - higher', () => {
|
||||
settings.set(['advanced', 'log_namespaced_levels'], {
|
||||
'z2m:mqtt': 'warning',
|
||||
it("Logs with namespaced levels or default - higher", () => {
|
||||
settings.set(["advanced", "log_namespaced_levels"], {
|
||||
"z2m:mqtt": "warning",
|
||||
});
|
||||
logger.init();
|
||||
logger.setLevel('debug');
|
||||
expect(logger.getNamespacedLevels()).toStrictEqual({'z2m:mqtt': 'warning'});
|
||||
expect(logger.getLevel()).toStrictEqual('debug');
|
||||
logger.setLevel("debug");
|
||||
expect(logger.getNamespacedLevels()).toStrictEqual({"z2m:mqtt": "warning"});
|
||||
expect(logger.getLevel()).toStrictEqual("debug");
|
||||
|
||||
const logSpy = vi.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');
|
||||
logger.info(`MQTT publish: topic 'abcd/efgh', payload '{"my": {"payload": "injson"}}'`, "z2m:mqtt");
|
||||
expect(logSpy).toHaveBeenCalledTimes(0);
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(0);
|
||||
logger.error(`Not connected to MQTT server!`, 'z2m:mqtt');
|
||||
logger.error("Not connected to MQTT server!", "z2m:mqtt");
|
||||
expect(logSpy).toHaveBeenCalledTimes(1);
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(1);
|
||||
logger.info(`Just another info message`, 'z2m:notmqtt');
|
||||
logger.info("Just another info message", "z2m:notmqtt");
|
||||
expect(logSpy).toHaveBeenCalledTimes(2);
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('Logs with namespaced levels or default - lower', () => {
|
||||
it("Logs with namespaced levels or default - lower", () => {
|
||||
expect(logger.getNamespacedLevels()).toStrictEqual({});
|
||||
logger.setNamespacedLevels({'z2m:mqtt': 'info'});
|
||||
logger.setLevel('warning');
|
||||
expect(logger.getNamespacedLevels()).toStrictEqual({'z2m:mqtt': 'info'});
|
||||
expect(logger.getLevel()).toStrictEqual('warning');
|
||||
logger.setNamespacedLevels({"z2m:mqtt": "info"});
|
||||
logger.setLevel("warning");
|
||||
expect(logger.getNamespacedLevels()).toStrictEqual({"z2m:mqtt": "info"});
|
||||
expect(logger.getLevel()).toStrictEqual("warning");
|
||||
|
||||
const logSpy = vi.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');
|
||||
logger.info(`MQTT publish: topic 'abcd/efgh', payload '{"my": {"payload": "injson"}}'`, "z2m:mqtt");
|
||||
expect(logSpy).toHaveBeenCalledTimes(1);
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(1);
|
||||
logger.error(`Not connected to MQTT server!`, 'z2m:mqtt');
|
||||
logger.error("Not connected to MQTT server!", "z2m:mqtt");
|
||||
expect(logSpy).toHaveBeenCalledTimes(2);
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(2);
|
||||
logger.info(`Just another info message`, 'z2m:notmqtt');
|
||||
logger.info("Just another info message", "z2m:notmqtt");
|
||||
expect(logSpy).toHaveBeenCalledTimes(2);
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(2);
|
||||
logger.warning(`Just another warning message`, 'z2m:notmqtt');
|
||||
logger.warning("Just another warning message", "z2m:notmqtt");
|
||||
expect(logSpy).toHaveBeenCalledTimes(3);
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('Logs with namespaced levels hierarchy', () => {
|
||||
const nsLevels = {'zh:zstack': 'debug' as const, 'zh:zstack:unpi:writer': 'error' as const};
|
||||
it("Logs with namespaced levels hierarchy", () => {
|
||||
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');
|
||||
logger.setLevel("warning");
|
||||
|
||||
consoleWriteSpy.mockClear();
|
||||
logger.debug(`--- parseNext [] debug picked from hierarchy`, 'zh:zstack:unpi:parser');
|
||||
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {'zh:zstack:unpi:parser': 'debug'}));
|
||||
logger.debug("--- parseNext [] debug picked from hierarchy", "zh:zstack:unpi:parser");
|
||||
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');
|
||||
logger.warning("--> frame [36,15] warning explicitely supressed", "zh:zstack:unpi:writer");
|
||||
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(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {'zh:zstack:unpi:writer:sub:ns': 'error'}));
|
||||
logger.warning("Another supressed warning message in a sub namespace", "zh:zstack:unpi:writer:sub:ns");
|
||||
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(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {'zh:zstack:unpi:writer:another:sub:ns': 'error'}));
|
||||
logger.error("but error should go through", "zh:zstack:unpi:writer:another:sub:ns");
|
||||
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(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {'z2m:mqtt': 'warning'}));
|
||||
logger.warning("new unconfigured namespace warning", "z2m:mqtt");
|
||||
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {"z2m:mqtt": "warning"}));
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(3);
|
||||
logger.info(`cached unconfigured namespace info should be supressed`, 'z2m:mqtt');
|
||||
logger.info("cached unconfigured namespace info should be supressed", "z2m:mqtt");
|
||||
expect(getCachedNamespacedLevels()).toStrictEqual(cachedNSLevels);
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(3);
|
||||
|
||||
logger.setLevel('info');
|
||||
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(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {'z2m:mqtt': 'info'}));
|
||||
logger.setLevel("info");
|
||||
cachedNSLevels = Object.assign({}, nsLevels);
|
||||
expect(getCachedNamespacedLevels()).toStrictEqual(cachedNSLevels);
|
||||
logger.info("unconfigured namespace info should now pass after default level change and cache reset", "z2m:mqtt");
|
||||
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(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {'zh:zstack:unpi:writer:another:sub:ns': 'error'}));
|
||||
logger.error("configured namespace hierachy should still work after the cache reset", "zh:zstack:unpi:writer:another:sub:ns");
|
||||
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {"zh:zstack:unpi:writer:another:sub:ns": "error"}));
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(5);
|
||||
|
||||
logger.setNamespacedLevels({'zh:zstack': 'warning'});
|
||||
expect(getCachedNamespacedLevels()).toStrictEqual((cachedNSLevels = {'zh:zstack': 'warning'}));
|
||||
logger.error(`error logged`, 'zh:zstack:unpi:writer');
|
||||
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {'zh:zstack:unpi:writer': 'warning'}));
|
||||
logger.setNamespacedLevels({"zh:zstack": "warning"});
|
||||
cachedNSLevels = {"zh:zstack": "warning"};
|
||||
expect(getCachedNamespacedLevels()).toStrictEqual(cachedNSLevels);
|
||||
logger.error("error logged", "zh:zstack:unpi:writer");
|
||||
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {"zh:zstack:unpi:writer": "warning"}));
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(6);
|
||||
logger.debug(`debug suppressed`, 'zh:zstack:unpi');
|
||||
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {'zh:zstack:unpi': 'warning'}));
|
||||
logger.debug("debug suppressed", "zh:zstack:unpi");
|
||||
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {"zh:zstack:unpi": "warning"}));
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(6);
|
||||
logger.warning(`warning logged`, 'zh:zstack');
|
||||
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {'zh:zstack': 'warning'}));
|
||||
logger.warning("warning logged", "zh:zstack");
|
||||
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {"zh:zstack": "warning"}));
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(7);
|
||||
logger.info(`unconfigured namespace`, 'z2m:mqtt');
|
||||
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {'z2m:mqtt': 'info'}));
|
||||
logger.info("unconfigured namespace", "z2m:mqtt");
|
||||
expect(getCachedNamespacedLevels()).toStrictEqual(Object.assign(cachedNSLevels, {"z2m:mqtt": "info"}));
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(8);
|
||||
});
|
||||
|
||||
it('Ignores SPLAT chars', () => {
|
||||
logger.setLevel('debug');
|
||||
it("Ignores SPLAT chars", () => {
|
||||
logger.setLevel("debug");
|
||||
|
||||
const logSpy = vi.spyOn(logger.winston, 'log');
|
||||
const logSpy = vi.spyOn(logger.winston, "log");
|
||||
consoleWriteSpy.mockClear();
|
||||
|
||||
let splatChars = '%d';
|
||||
logger.debug(splatChars, 'z2m:mqtt');
|
||||
expect(logSpy).toHaveBeenLastCalledWith('debug', `z2m:mqtt: ${splatChars}`);
|
||||
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}`);
|
||||
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);
|
||||
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');
|
||||
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',
|
||||
level: "info",
|
||||
message: "z2m: Test JSON message",
|
||||
timestamp: expect.any(String),
|
||||
});
|
||||
|
||||
settings.set(['advanced', 'log_console_json'], false);
|
||||
settings.set(["advanced", "log_console_json"], false);
|
||||
logger.init();
|
||||
|
||||
consoleWriteSpy.mockClear();
|
||||
logger.info(`Test JSON message`, 'z2m');
|
||||
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);
|
||||
expect(outputStr.trim().endsWith("\u001b[32minfo\u001b[39m: \tz2m: Test JSON message")).toStrictEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
+155
-139
@@ -1,244 +1,250 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import tmp from 'tmp';
|
||||
import stringify from "json-stable-stringify-without-jsonify";
|
||||
import tmp from "tmp";
|
||||
|
||||
import yaml from '../../lib/util/yaml';
|
||||
import yaml from "../../lib/util/yaml";
|
||||
|
||||
export const mockDir: string = tmp.dirSync().name;
|
||||
const stateFile = path.join(mockDir, 'state.json');
|
||||
const configFile = path.join(mockDir, "configuration.yaml");
|
||||
const stateFile = path.join(mockDir, "state.json");
|
||||
const databaseFile = path.join(mockDir, "database.db");
|
||||
|
||||
export const DEFAULT_CONFIGURATION = {
|
||||
homeassistant: {enabled: false},
|
||||
frontend: {enabled: false},
|
||||
availability: {enabled: false},
|
||||
mqtt: {
|
||||
base_topic: 'zigbee2mqtt',
|
||||
server: 'mqtt://localhost',
|
||||
base_topic: "zigbee2mqtt",
|
||||
server: "mqtt://localhost",
|
||||
},
|
||||
serial: {
|
||||
port: '/dev/dummy',
|
||||
port: "/dev/dummy",
|
||||
},
|
||||
devices: {
|
||||
'0x18fc2600000d7ae2': {
|
||||
friendly_name: 'bosch_radiator',
|
||||
"0x18fc2600000d7ae2": {
|
||||
friendly_name: "bosch_radiator",
|
||||
},
|
||||
'0x000b57fffec6a5b2': {
|
||||
"0x000b57fffec6a5b2": {
|
||||
retain: true,
|
||||
friendly_name: 'bulb',
|
||||
description: 'this is my bulb',
|
||||
friendly_name: "bulb",
|
||||
description: "this is my bulb",
|
||||
},
|
||||
'0x0017880104e45517': {
|
||||
"0x0017880104e45517": {
|
||||
retain: true,
|
||||
friendly_name: 'remote',
|
||||
friendly_name: "remote",
|
||||
},
|
||||
'0x0017880104e45520': {
|
||||
"0x0017880104e45520": {
|
||||
retain: false,
|
||||
friendly_name: 'button',
|
||||
friendly_name: "button",
|
||||
},
|
||||
'0x0017880104e45521': {
|
||||
"0x0017880104e45521": {
|
||||
retain: false,
|
||||
friendly_name: 'button_double_key',
|
||||
friendly_name: "button_double_key",
|
||||
},
|
||||
'0x0017880104e45522': {
|
||||
"0x0017880104e45522": {
|
||||
qos: 1,
|
||||
retain: false,
|
||||
friendly_name: 'weather_sensor',
|
||||
friendly_name: "weather_sensor",
|
||||
},
|
||||
'0x0017880104e45523': {
|
||||
"0x0017880104e45523": {
|
||||
retain: false,
|
||||
friendly_name: 'occupancy_sensor',
|
||||
friendly_name: "occupancy_sensor",
|
||||
},
|
||||
'0x0017880104e45524': {
|
||||
"0x0017880104e45524": {
|
||||
retain: false,
|
||||
friendly_name: 'power_plug',
|
||||
friendly_name: "power_plug",
|
||||
},
|
||||
'0x0017880104e45530': {
|
||||
"0x0017880104e45530": {
|
||||
retain: false,
|
||||
friendly_name: 'button_double_key_interviewing',
|
||||
friendly_name: "button_double_key_interviewing",
|
||||
},
|
||||
'0x0017880104e45540': {
|
||||
friendly_name: 'ikea_onoff',
|
||||
"0x0017880104e45540": {
|
||||
friendly_name: "ikea_onoff",
|
||||
},
|
||||
'0x000b57fffec6a5b7': {
|
||||
"0x000b57fffec6a5b7": {
|
||||
retain: false,
|
||||
friendly_name: 'bulb_2',
|
||||
friendly_name: "bulb_2",
|
||||
},
|
||||
'0x000b57fffec6a5b3': {
|
||||
"0x000b57fffec6a5b3": {
|
||||
retain: false,
|
||||
friendly_name: 'bulb_color',
|
||||
friendly_name: "bulb_color",
|
||||
},
|
||||
'0x000b57fffec6a5b4': {
|
||||
"0x000b57fffec6a5b4": {
|
||||
retain: false,
|
||||
friendly_name: 'bulb_color_2',
|
||||
friendly_name: "bulb_color_2",
|
||||
},
|
||||
'0x0017880104e45541': {
|
||||
"0x0017880104e45541": {
|
||||
retain: false,
|
||||
friendly_name: 'wall_switch',
|
||||
friendly_name: "wall_switch",
|
||||
},
|
||||
'0x0017880104e45542': {
|
||||
"0x0017880104e45542": {
|
||||
retain: false,
|
||||
friendly_name: 'wall_switch_double',
|
||||
friendly_name: "wall_switch_double",
|
||||
},
|
||||
'0x0017880104e45543': {
|
||||
"0x0017880104e45543": {
|
||||
retain: false,
|
||||
friendly_name: 'led_controller_1',
|
||||
friendly_name: "led_controller_1",
|
||||
},
|
||||
'0x0017880104e45544': {
|
||||
"0x0017880104e45544": {
|
||||
retain: false,
|
||||
friendly_name: 'led_controller_2',
|
||||
friendly_name: "led_controller_2",
|
||||
},
|
||||
'0x0017880104e45545': {
|
||||
"0x0017880104e45545": {
|
||||
retain: false,
|
||||
friendly_name: 'dimmer_wall_switch',
|
||||
friendly_name: "dimmer_wall_switch",
|
||||
},
|
||||
'0x0017880104e45547': {
|
||||
"0x0017880104e45547": {
|
||||
retain: false,
|
||||
friendly_name: 'curtain',
|
||||
friendly_name: "curtain",
|
||||
},
|
||||
'0x0017880104e45548': {
|
||||
"0x0017880104e45548": {
|
||||
retain: false,
|
||||
friendly_name: 'fan',
|
||||
friendly_name: "fan",
|
||||
},
|
||||
'0x0017880104e45549': {
|
||||
"0x0017880104e45549": {
|
||||
retain: false,
|
||||
friendly_name: 'siren',
|
||||
friendly_name: "siren",
|
||||
},
|
||||
'0x0017880104e45529': {
|
||||
"0x0017880104e45529": {
|
||||
retain: false,
|
||||
friendly_name: 'unsupported2',
|
||||
friendly_name: "unsupported2",
|
||||
},
|
||||
'0x0017880104e45550': {
|
||||
"0x0017880104e45550": {
|
||||
retain: false,
|
||||
friendly_name: 'thermostat',
|
||||
friendly_name: "thermostat",
|
||||
},
|
||||
'0x0017880104e45551': {
|
||||
"0x0017880104e45551": {
|
||||
retain: false,
|
||||
friendly_name: 'smart vent',
|
||||
friendly_name: "smart vent",
|
||||
},
|
||||
'0x0017880104e45552': {
|
||||
"0x0017880104e45552": {
|
||||
retain: false,
|
||||
friendly_name: 'j1',
|
||||
friendly_name: "j1",
|
||||
},
|
||||
'0x0017880104e45553': {
|
||||
"0x0017880104e45553": {
|
||||
retain: false,
|
||||
friendly_name: 'bulb_enddevice',
|
||||
friendly_name: "bulb_enddevice",
|
||||
},
|
||||
'0x0017880104e45559': {
|
||||
"0x0017880104e45559": {
|
||||
retain: false,
|
||||
friendly_name: 'cc2530_router',
|
||||
friendly_name: "cc2530_router",
|
||||
},
|
||||
'0x0017880104e45560': {
|
||||
"0x0017880104e45560": {
|
||||
retain: false,
|
||||
friendly_name: 'livolo',
|
||||
friendly_name: "livolo",
|
||||
},
|
||||
'0x90fd9ffffe4b64ae': {
|
||||
"0x90fd9ffffe4b64ae": {
|
||||
retain: false,
|
||||
friendly_name: 'tradfri_remote',
|
||||
friendly_name: "tradfri_remote",
|
||||
},
|
||||
'0x90fd9ffffe4b64af': {
|
||||
friendly_name: 'roller_shutter',
|
||||
"0x90fd9ffffe4b64af": {
|
||||
friendly_name: "roller_shutter",
|
||||
},
|
||||
'0x90fd9ffffe4b64ax': {
|
||||
friendly_name: 'ZNLDP12LM',
|
||||
"0x90fd9ffffe4b64ax": {
|
||||
friendly_name: "ZNLDP12LM",
|
||||
},
|
||||
'0x90fd9ffffe4b64aa': {
|
||||
friendly_name: 'SP600_OLD',
|
||||
"0x90fd9ffffe4b64aa": {
|
||||
friendly_name: "SP600_OLD",
|
||||
},
|
||||
'0x90fd9ffffe4b64ab': {
|
||||
friendly_name: 'SP600_NEW',
|
||||
"0x90fd9ffffe4b64ab": {
|
||||
friendly_name: "SP600_NEW",
|
||||
},
|
||||
'0x90fd9ffffe4b64ac': {
|
||||
friendly_name: 'MKS-CM-W5',
|
||||
"0x90fd9ffffe4b64ac": {
|
||||
friendly_name: "MKS-CM-W5",
|
||||
},
|
||||
'0x0017880104e45526': {
|
||||
friendly_name: 'GL-S-007ZS',
|
||||
"0x0017880104e45526": {
|
||||
friendly_name: "GL-S-007ZS",
|
||||
},
|
||||
'0x0017880104e43559': {
|
||||
friendly_name: 'U202DST600ZB',
|
||||
"0x0017880104e43559": {
|
||||
friendly_name: "U202DST600ZB",
|
||||
},
|
||||
'0xf4ce368a38be56a1': {
|
||||
"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',
|
||||
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',
|
||||
"0x0017880104e44559": {
|
||||
friendly_name: "3157100_thermostat",
|
||||
},
|
||||
'0x0017880104a44559': {
|
||||
friendly_name: 'J1_cover',
|
||||
"0x0017880104a44559": {
|
||||
friendly_name: "J1_cover",
|
||||
},
|
||||
'0x0017882104a44559': {
|
||||
friendly_name: 'TS0601_thermostat',
|
||||
"0x0017882104a44559": {
|
||||
friendly_name: "TS0601_thermostat",
|
||||
},
|
||||
'0x0017882104a44560': {
|
||||
friendly_name: 'TS0601_switch',
|
||||
"0x0017882104a44560": {
|
||||
friendly_name: "TS0601_switch",
|
||||
},
|
||||
'0x0017882104a44562': {
|
||||
friendly_name: 'TS0601_cover_switch',
|
||||
"0x0017882104a44562": {
|
||||
friendly_name: "TS0601_cover_switch",
|
||||
},
|
||||
'0x0017882194e45543': {
|
||||
friendly_name: 'QS-Zigbee-D02-TRIAC-2C-LN',
|
||||
"0x0017882194e45543": {
|
||||
friendly_name: "QS-Zigbee-D02-TRIAC-2C-LN",
|
||||
},
|
||||
'0x0017880104e45724': {
|
||||
friendly_name: 'GLEDOPTO_2ID',
|
||||
"0x0017880104e45724": {
|
||||
friendly_name: "GLEDOPTO_2ID",
|
||||
},
|
||||
'0x0017880104e45561': {
|
||||
friendly_name: 'temperature_sensor',
|
||||
"0x0017880104e45561": {
|
||||
friendly_name: "temperature_sensor",
|
||||
},
|
||||
'0x0017880104e45562': {
|
||||
friendly_name: 'heating_actuator',
|
||||
"0x0017880104e45562": {
|
||||
friendly_name: "heating_actuator",
|
||||
},
|
||||
'0x000b57cdfec6a5b3': {
|
||||
friendly_name: 'hue_twilight',
|
||||
"0x000b57cdfec6a5b3": {
|
||||
friendly_name: "hue_twilight",
|
||||
},
|
||||
"0x00124b00cfcf3298": {
|
||||
friendly_name: "fanbee",
|
||||
retain: true,
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
1: {
|
||||
friendly_name: 'group_1',
|
||||
friendly_name: "group_1",
|
||||
retain: false,
|
||||
},
|
||||
2: {
|
||||
friendly_name: 'group_2',
|
||||
friendly_name: "group_2",
|
||||
retain: false,
|
||||
},
|
||||
15071: {
|
||||
friendly_name: 'group_tradfri_remote',
|
||||
friendly_name: "group_tradfri_remote",
|
||||
retain: false,
|
||||
},
|
||||
11: {
|
||||
friendly_name: 'group_with_tradfri',
|
||||
friendly_name: "group_with_tradfri",
|
||||
retain: false,
|
||||
},
|
||||
12: {
|
||||
friendly_name: 'thermostat_group',
|
||||
friendly_name: "thermostat_group",
|
||||
retain: false,
|
||||
},
|
||||
14: {
|
||||
friendly_name: 'switch_group',
|
||||
friendly_name: "switch_group",
|
||||
retain: false,
|
||||
},
|
||||
21: {
|
||||
friendly_name: 'gledopto_group',
|
||||
friendly_name: "gledopto_group",
|
||||
},
|
||||
9: {
|
||||
friendly_name: 'ha_discovery_group',
|
||||
friendly_name: "ha_discovery_group",
|
||||
},
|
||||
19: {
|
||||
friendly_name: 'hue_twilight_group',
|
||||
friendly_name: "hue_twilight_group",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -246,7 +252,15 @@ export const DEFAULT_CONFIGURATION = {
|
||||
export function writeDefaultConfiguration(config: unknown = undefined): void {
|
||||
config = config || DEFAULT_CONFIGURATION;
|
||||
|
||||
yaml.writeIfChanged(path.join(mockDir, 'configuration.yaml'), config);
|
||||
yaml.writeIfChanged(configFile, config);
|
||||
}
|
||||
|
||||
export function read(): ReturnType<typeof yaml.read> {
|
||||
return yaml.read(configFile);
|
||||
}
|
||||
|
||||
export function removeConfiguration(): void {
|
||||
fs.rmSync(configFile, {force: true});
|
||||
}
|
||||
|
||||
export function writeEmptyState(): void {
|
||||
@@ -254,9 +268,7 @@ export function writeEmptyState(): void {
|
||||
}
|
||||
|
||||
export function removeState(): void {
|
||||
if (stateExists()) {
|
||||
fs.unlinkSync(stateFile);
|
||||
}
|
||||
fs.rmSync(stateFile, {force: true});
|
||||
}
|
||||
|
||||
export function stateExists(): boolean {
|
||||
@@ -264,17 +276,17 @@ export function stateExists(): boolean {
|
||||
}
|
||||
|
||||
const defaultState = {
|
||||
'0x000b57fffec6a5b2': {
|
||||
state: 'ON',
|
||||
"0x000b57fffec6a5b2": {
|
||||
state: "ON",
|
||||
brightness: 50,
|
||||
color_temp: 370,
|
||||
linkquality: 99,
|
||||
},
|
||||
'0x0017880104e45517': {
|
||||
"0x0017880104e45517": {
|
||||
brightness: 255,
|
||||
},
|
||||
1: {
|
||||
state: 'ON',
|
||||
state: "ON",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -283,15 +295,19 @@ export function getDefaultState(): typeof defaultState {
|
||||
}
|
||||
|
||||
export function writeDefaultState(): void {
|
||||
fs.writeFileSync(path.join(mockDir, 'state.json'), stringify(defaultState));
|
||||
fs.writeFileSync(path.join(mockDir, "state.json"), stringify(defaultState));
|
||||
}
|
||||
|
||||
export function read(): ReturnType<typeof yaml.read> {
|
||||
return yaml.read(path.join(mockDir, 'configuration.yaml'));
|
||||
export function writeEmptyDatabase(): void {
|
||||
fs.writeFileSync(databaseFile, "");
|
||||
}
|
||||
|
||||
vi.mock('../../lib/util/data', async () => {
|
||||
const nodePath = await vi.importActual<typeof import('node:path')>('node:path');
|
||||
export function removeDatabase(): void {
|
||||
fs.rmSync(databaseFile, {force: true});
|
||||
}
|
||||
|
||||
vi.mock("../../lib/util/data", async () => {
|
||||
const nodePath = await vi.importActual<typeof import("node:path")>("node:path");
|
||||
|
||||
return {
|
||||
default: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const mockDebounce = vi.fn((fn) => fn);
|
||||
|
||||
vi.mock('debounce', () => ({
|
||||
vi.mock("debounce", () => ({
|
||||
default: mockDebounce,
|
||||
}));
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
export const mockJSZipFile = vi.fn();
|
||||
export const mockJSZipGenerateAsync = vi.fn().mockReturnValue('THISISBASE64');
|
||||
export const mockJSZipGenerateAsync = vi.fn().mockReturnValue("THISISBASE64");
|
||||
|
||||
vi.mock('jszip', () => ({
|
||||
vi.mock("jszip", () => ({
|
||||
default: vi.fn().mockImplementation(() => {
|
||||
return {
|
||||
file: mockJSZipFile,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user