mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-08-29 15:18:41 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: 'github-actions'
|
||||
directory: '/'
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: 'daily'
|
||||
target-branch: dev
|
||||
|
||||
- package-ecosystem: 'docker'
|
||||
directory: '/docker'
|
||||
schedule:
|
||||
interval: 'daily'
|
||||
interval: weekly
|
||||
target-branch: dev
|
||||
groups:
|
||||
minor-patch:
|
||||
applies-to: version-updates
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
|
||||
@@ -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'
|
||||
@@ -30,14 +32,12 @@ jobs:
|
||||
- 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'
|
||||
@@ -180,6 +180,7 @@ jobs:
|
||||
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:
|
||||
@@ -1,4 +1,4 @@
|
||||
name: stale
|
||||
name: Stale
|
||||
|
||||
on:
|
||||
schedule:
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
name: Update dependencies
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 0 * * 0'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
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
|
||||
update-dependencies:
|
||||
if: github.repository == 'Koenkk/zigbee2mqtt'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -24,10 +24,19 @@ jobs:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: |
|
||||
# Disable for now as pnpm updates through corepack
|
||||
# don't work well yet for bare-metal installations.
|
||||
# pnpm self-update
|
||||
pnpm up --latest
|
||||
- uses: peter-evans/create-pull-request@v7
|
||||
id: cpr
|
||||
with:
|
||||
commit-message: 'fix(ignore): update dependencies'
|
||||
branch: 'deps/all'
|
||||
title: 'fix(ignore): update dependencies'
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
- uses: peter-evans/create-or-update-comment@v4
|
||||
if: ${{ steps.cpr.outputs.pull-request-number }}
|
||||
with:
|
||||
issue-number: ${{ steps.cpr.outputs.pull-request-number }}
|
||||
body: 'CC: @Koenkk'
|
||||
@@ -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
|
||||
@@ -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.3"
|
||||
".": "2.3.0"
|
||||
}
|
||||
|
||||
+133
@@ -1,5 +1,138 @@
|
||||
# Changelog
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
+4
-9
@@ -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
|
||||
@@ -43,6 +35,9 @@ LABEL org.opencontainers.image.version=${VERSION}
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY dist ./dist
|
||||
# To prevent `Error: EACCES: permission denied, mkdir '/app/dist/external_converters'`
|
||||
# when running rootless.
|
||||
RUN chmod -R 777 ./dist
|
||||
COPY package.json LICENSE index.js data/configuration.example.yaml ./
|
||||
|
||||
COPY docker/docker-entrypoint.sh /usr/local/bin/
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
|
||||
+197
-143
@@ -1,79 +1,46 @@
|
||||
import type {IClientPublishOptions} from 'mqtt';
|
||||
import type * as SdNotify from 'sd-notify';
|
||||
import type {IClientPublishOptions} from "mqtt";
|
||||
|
||||
import type {Zigbee2MQTTAPI} from './types/api';
|
||||
import type Extension from "./extension/extension";
|
||||
import type {Zigbee2MQTTAPI} from "./types/api";
|
||||
|
||||
import assert from 'node:assert';
|
||||
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 {setLogger as zhSetLogger} from "zigbee-herdsman";
|
||||
import {setLogger as zhcSetLogger} from "zigbee-herdsman-converters";
|
||||
|
||||
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 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 +48,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 +65,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 +78,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 +127,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 +146,171 @@ 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();
|
||||
}
|
||||
|
||||
@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);
|
||||
@@ -272,10 +336,10 @@ export class Controller {
|
||||
}
|
||||
|
||||
const options: IClientPublishOptions = {
|
||||
retain: utils.getObjectProperty(entity.options, 'retain', false),
|
||||
qos: utils.getObjectProperty(entity.options, 'qos', 0),
|
||||
retain: utils.getObjectProperty(entity.options, "retain", false),
|
||||
qos: utils.getObjectProperty(entity.options, "qos", 0),
|
||||
};
|
||||
const retention = utils.getObjectProperty<number | false>(entity.options, 'retention', false);
|
||||
const retention = utils.getObjectProperty<number | false>(entity.options, "retention", false);
|
||||
|
||||
if (retention !== false) {
|
||||
options.properties = {messageExpiryInterval: retention};
|
||||
@@ -299,13 +363,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 +387,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);
|
||||
}
|
||||
}
|
||||
@@ -341,19 +405,19 @@ export class Controller {
|
||||
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 +425,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+163
-80
@@ -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`, "", {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,18 +253,18 @@ 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;
|
||||
const payload: Zigbee2MQTTAPI["{friendlyName}/availability"] = {state: available ? "online" : "offline"};
|
||||
this.lastPublishedAvailabilities.set(entity.ID, available);
|
||||
await this.mqtt.publish(topic, JSON.stringify(payload), {retain: true, qos: 1});
|
||||
|
||||
if (!skipGroups && entity.isDevice()) {
|
||||
@@ -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,43 +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,
|
||||
/* 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);
|
||||
}
|
||||
}, utils.seconds(2));
|
||||
}, utils.seconds(2)),
|
||||
);
|
||||
}
|
||||
|
||||
this.retrieveStateDebouncers[device.ieeeAddr]?.();
|
||||
this.retrieveStateDebouncers.get(device.ieeeAddr)?.();
|
||||
}
|
||||
}
|
||||
|
||||
+116
-103
@@ -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,12 +190,12 @@ 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;
|
||||
@@ -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,20 +222,20 @@ 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)) {
|
||||
@@ -289,9 +291,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 +322,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 +339,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 +355,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 +363,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 +376,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 +410,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 +424,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 +472,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 +540,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 +575,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 +622,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);
|
||||
}
|
||||
|
||||
+188
-179
@@ -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 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,
|
||||
@@ -63,7 +65,7 @@ export default class Bridge extends Extension {
|
||||
|
||||
if (payload !== this.lastBridgeLoggingPayload) {
|
||||
this.lastBridgeLoggingPayload = payload;
|
||||
void this.mqtt.publish(`bridge/logging`, payload, {}, baseTopic, true);
|
||||
void this.mqtt.publish("bridge/logging", payload, {}, baseTopic, true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -79,7 +81,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 +94,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 +124,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), {retain: false, qos: 0});
|
||||
});
|
||||
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), {retain: false, qos: 0});
|
||||
});
|
||||
this.eventBus.onDeviceNetworkAddressChanged(this, async () => {
|
||||
await this.publishDevices();
|
||||
@@ -143,11 +145,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 +160,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), {retain: false, qos: 0});
|
||||
});
|
||||
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), {retain: false, qos: 0});
|
||||
});
|
||||
|
||||
await this.publishInfo();
|
||||
@@ -206,6 +208,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 +220,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 +238,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 +279,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 +371,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 +381,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 +394,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 +421,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 +462,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 +473,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 +514,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,29 +539,29 @@ 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;
|
||||
@@ -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, "", {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, "", {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), {retain: true, qos: 0}, settings.get().mqtt.base_topic, 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), {retain: true, qos: 0}, settings.get().mqtt.base_topic, 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), {retain: true, qos: 0}, settings.get().mqtt.base_topic, 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), {retain: true, qos: 0}, settings.get().mqtt.base_topic, 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 {addExternalDefinition, 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.ExternalDefinitionWithExtend | zhc.ExternalDefinitionWithExtend[];
|
||||
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,36 +27,38 @@ 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);
|
||||
|
||||
const definitions = Array.isArray(module) ? module : [module];
|
||||
const definitions = Array.isArray(mod) ? mod : [mod];
|
||||
|
||||
for (const definition of definitions) {
|
||||
definition.externalConverterName = name;
|
||||
definition.externalConverterName = newName ?? name;
|
||||
|
||||
addExternalDefinition(definition);
|
||||
logger.info(`Loaded external converter '${name}'.`);
|
||||
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;
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
|
||||
+115
-62
@@ -1,28 +1,29 @@
|
||||
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 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"];
|
||||
|
||||
export default abstract class ExternalJSExtension<M> extends Extension {
|
||||
protected folderName: string;
|
||||
protected mqttTopic: string;
|
||||
protected requestRegex: RegExp;
|
||||
protected basePath: string;
|
||||
protected srcBasePath: string;
|
||||
|
||||
constructor(
|
||||
zigbee: Zigbee,
|
||||
mqtt: MQTT,
|
||||
mqtt: Mqtt,
|
||||
state: State,
|
||||
publishEntityState: PublishEntityState,
|
||||
eventBus: EventBus,
|
||||
@@ -34,9 +35,17 @@ 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);
|
||||
// 1-up from this file
|
||||
this.srcBasePath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
// prevent race in vitest with files being manipulated from same location
|
||||
process.env.VITEST_WORKER_ID ? /* v8 ignore next */ `${folderName}_${Math.floor(Math.random() * 10000)}` : folderName,
|
||||
);
|
||||
}
|
||||
|
||||
override async start(): Promise<void> {
|
||||
@@ -46,25 +55,35 @@ export default abstract class ExternalJSExtension<M> extends Extension {
|
||||
await this.publishExternalJS();
|
||||
}
|
||||
|
||||
private getFilePath(name: string, mkBasePath: boolean = false): string {
|
||||
if (mkBasePath && !fs.existsSync(this.basePath)) {
|
||||
fs.mkdirSync(this.basePath, {recursive: true});
|
||||
override async stop(): Promise<void> {
|
||||
// remove src base path on stop to ensure always back to default
|
||||
fs.rmSync(this.srcBasePath, {force: true, recursive: true});
|
||||
await super.stop();
|
||||
}
|
||||
|
||||
private getFilePath(name: string, mkBasePath = false, inSource = false): string {
|
||||
const basePath = inSource ? this.srcBasePath : this.basePath;
|
||||
|
||||
if (mkBasePath && !fs.existsSync(basePath)) {
|
||||
fs.mkdirSync(basePath, {recursive: true});
|
||||
}
|
||||
|
||||
return path.join(this.basePath, name);
|
||||
return path.join(basePath, name);
|
||||
}
|
||||
|
||||
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)) {
|
||||
protected *getFiles(inSource = false): Generator<{name: string; code: string}> {
|
||||
const basePath = inSource ? this.srcBasePath : this.basePath;
|
||||
|
||||
if (!fs.existsSync(basePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const fileName of fs.readdirSync(this.basePath)) {
|
||||
if (fileName.endsWith('.js')) {
|
||||
for (const fileName of fs.readdirSync(basePath)) {
|
||||
if (fileName.endsWith(".js") || fileName.endsWith(".cjs") || fileName.endsWith(".mjs")) {
|
||||
yield {name: fileName, code: this.getFileCode(fileName)};
|
||||
}
|
||||
}
|
||||
@@ -77,15 +96,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,67 +119,115 @@ 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 srcToBeRemoved = this.getFilePath(name, false, true);
|
||||
const toBeRemoved = this.getFilePath(name);
|
||||
|
||||
if (fs.existsSync(toBeRemoved)) {
|
||||
await this.removeJS(name, this.loadModuleFromText(this.getFileCode(name), name));
|
||||
if (fs.existsSync(srcToBeRemoved)) {
|
||||
const mod = await import(this.getImportPath(srcToBeRemoved));
|
||||
|
||||
await this.removeJS(name, mod.default);
|
||||
fs.rmSync(srcToBeRemoved, {force: true});
|
||||
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} (${srcToBeRemoved}) 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 srcFilePath = this.getFilePath(name, true, true);
|
||||
let newName = name;
|
||||
|
||||
if (fs.existsSync(srcFilePath)) {
|
||||
// if file already exist, version it to bypass node module caching
|
||||
const versionMatch = name.match(/\.(\d+)\.(c|m)?js$/);
|
||||
|
||||
if (versionMatch) {
|
||||
const version = Number.parseInt(versionMatch[1], 10);
|
||||
newName = name.replace(`.${version}.`, `.${version + 1}.`);
|
||||
} else {
|
||||
const ext = path.extname(name);
|
||||
newName = name.replace(ext, `.1${ext}`);
|
||||
}
|
||||
|
||||
// remove previous version
|
||||
fs.rmSync(srcFilePath, {force: true});
|
||||
fs.rmSync(this.getFilePath(name, true, false), {force: true});
|
||||
}
|
||||
|
||||
const newSrcFilePath = this.getFilePath(newName, false /* already created above if needed */, true);
|
||||
|
||||
try {
|
||||
await this.loadJS(name, this.loadModuleFromText(code, name));
|
||||
fs.writeFileSync(newSrcFilePath, code, "utf8");
|
||||
|
||||
const filePath = this.getFilePath(name, true);
|
||||
const mod = await import(this.getImportPath(newSrcFilePath));
|
||||
|
||||
fs.writeFileSync(filePath, code, 'utf8');
|
||||
logger.info(`${name} loaded. Contents written to '${filePath}'.`);
|
||||
await this.loadJS(name, mod.default, newName);
|
||||
logger.info(`${newName} loaded. Contents written to '${newSrcFilePath}'.`);
|
||||
// keep original in data folder synced
|
||||
fs.writeFileSync(this.getFilePath(newName, true, false), code, "utf8");
|
||||
await this.publishExternalJS();
|
||||
|
||||
return utils.getResponse(message, {});
|
||||
} catch (error) {
|
||||
return utils.getResponse(message, {}, `${name} contains invalid code: ${(error as Error).message}`);
|
||||
fs.rmSync(newSrcFilePath, {force: true});
|
||||
// NOTE: original in data folder doesn't get written if invalid
|
||||
|
||||
return utils.getResponse(message, {}, `${newName} contains invalid code: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadFiles(): Promise<void> {
|
||||
for (const extension of this.getFiles()) {
|
||||
await this.loadJS(extension.name, this.loadModuleFromText(extension.code, extension.name));
|
||||
const srcFilePath = this.getFilePath(extension.name, true, true);
|
||||
const filePath = this.getFilePath(extension.name);
|
||||
|
||||
try {
|
||||
fs.copyFileSync(filePath, srcFilePath);
|
||||
|
||||
const mod = await import(this.getImportPath(srcFilePath));
|
||||
|
||||
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`);
|
||||
fs.rmSync(srcFilePath, {force: true});
|
||||
|
||||
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())),
|
||||
stringify(Array.from(this.getFiles(true))),
|
||||
{
|
||||
retain: true,
|
||||
qos: 0,
|
||||
@@ -170,22 +237,8 @@ export default abstract class ExternalJSExtension<M> extends Extension {
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
private getImportPath(filePath: string): string {
|
||||
// prevent issues on Windows
|
||||
return path.relative(__dirname, filePath).replaceAll("\\", "/");
|
||||
}
|
||||
}
|
||||
|
||||
+58
-50
@@ -1,33 +1,33 @@
|
||||
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 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;
|
||||
@@ -42,7 +42,7 @@ export default class Frontend extends Extension {
|
||||
|
||||
constructor(
|
||||
zigbee: Zigbee,
|
||||
mqtt: MQTT,
|
||||
mqtt: Mqtt,
|
||||
state: State,
|
||||
publishEntityState: PublishEntityState,
|
||||
eventBus: EventBus,
|
||||
@@ -80,39 +80,37 @@ export default class Frontend extends Extension {
|
||||
// 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')});
|
||||
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`
|
||||
};
|
||||
// biome-ignore lint/style/noNonNullAssertion: valid from `isHttpsConfigured`
|
||||
const serverOptions = {key: readFileSync(this.sslKey!), cert: readFileSync(this.sslCert!)};
|
||||
this.server = createSecureServer(serverOptions, this.onRequest);
|
||||
} else {
|
||||
this.server = createServer(this.onRequest);
|
||||
}
|
||||
|
||||
this.server.on('upgrade', this.onUpgrade);
|
||||
this.server.on("upgrade", this.onUpgrade);
|
||||
this.eventBus.onMQTTMessagePublished(this, this.onMQTTPublishMessage);
|
||||
|
||||
if (!this.host) {
|
||||
this.server.listen(this.port);
|
||||
logger.info(`Started frontend on port ${this.port}`);
|
||||
} else if (this.host.startsWith('/')) {
|
||||
} else if (this.host.startsWith("/")) {
|
||||
this.server.listen(this.host);
|
||||
logger.info(`Started frontend on socket ${this.host}`);
|
||||
} else {
|
||||
@@ -123,33 +121,40 @@ export default class Frontend extends Extension {
|
||||
|
||||
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);
|
||||
@@ -157,6 +162,7 @@ export default class Frontend extends Extension {
|
||||
}
|
||||
|
||||
private authenticate(request: IncomingMessage, cb: (authenticate: boolean) => void): void {
|
||||
// biome-ignore lint/style/noNonNullAssertion: `Only valid for request obtained from Server`
|
||||
const {query} = parse(request.url!, true);
|
||||
cb(!this.authToken || this.authToken === query.token);
|
||||
}
|
||||
@@ -165,17 +171,17 @@ export default class Frontend extends Extension {
|
||||
this.wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
this.authenticate(request, (isAuthenticated) => {
|
||||
if (isAuthenticated) {
|
||||
this.wss.emit('connection', ws, request);
|
||||
this.wss.emit("connection", ws, request);
|
||||
} else {
|
||||
ws.close(4401, 'Unauthorized');
|
||||
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 +205,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);
|
||||
}
|
||||
|
||||
@@ -225,3 +231,5 @@ export default class Frontend extends Extension {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
+594
-611
File diff suppressed because it is too large
Load Diff
+98
-92
@@ -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,6 +268,7 @@ export default class NetworkMap extends Extension {
|
||||
networkAddress: device.zh.networkAddress,
|
||||
manufacturerName: device.zh.manufacturerName,
|
||||
modelID: device.zh.modelID,
|
||||
// biome-ignore lint/style/noNonNullAssertion: TODO: biome migration: wrong typing? will be undefined if type=EndDevice?
|
||||
failed: failed.get(device)!,
|
||||
lastSeen: device.zh.lastSeen,
|
||||
definition,
|
||||
@@ -279,7 +285,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 +293,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 +311,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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+36
-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);
|
||||
}
|
||||
|
||||
@@ -247,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) {
|
||||
@@ -268,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 {
|
||||
@@ -283,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!);
|
||||
}
|
||||
|
||||
@@ -306,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[] {
|
||||
|
||||
+34
-28
@@ -1,20 +1,20 @@
|
||||
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 default class Mqtt {
|
||||
private publishedTopics = new Set<string>();
|
||||
private connectionTimer?: NodeJS.Timeout;
|
||||
private client!: MqttClient;
|
||||
private eventBus: EventBus;
|
||||
@@ -35,8 +35,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 +71,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 +80,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,21 +89,21 @@ 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.
|
||||
@@ -116,7 +116,7 @@ export default class MQTT {
|
||||
// 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 +125,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), {retain: true, qos: 0});
|
||||
this.eventBus.removeListeners(this);
|
||||
logger.info('Disconnecting from MQTT server');
|
||||
logger.info("Disconnecting from MQTT server");
|
||||
await this.client?.endAsync();
|
||||
}
|
||||
|
||||
@@ -142,11 +142,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), {retain: true, qos: 0});
|
||||
await this.subscribe(`${settings.get().mqtt.base_topic}/#`);
|
||||
}
|
||||
|
||||
@@ -176,6 +176,12 @@ export default class MQTT {
|
||||
skipLog = false,
|
||||
skipReceive = true,
|
||||
): 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;
|
||||
}
|
||||
|
||||
const defaultOptions = {qos: 0 as const, retain: false};
|
||||
topic = `${base}/${topic}`;
|
||||
|
||||
@@ -195,7 +201,7 @@ export default class MQTT {
|
||||
|
||||
if (!this.isConnected()) {
|
||||
if (!skipLog) {
|
||||
logger.error(`Not connected to MQTT server!`);
|
||||
logger.error("Not connected to MQTT server!");
|
||||
logger.error(`Cannot 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+432
-205
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
+24
-159
@@ -1,21 +1,19 @@
|
||||
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 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 +21,11 @@ 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]>};
|
||||
interface KeyValue {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// biome-ignore lint/suspicious/noExplicitAny: API
|
||||
[s: string]: any;
|
||||
}
|
||||
|
||||
@@ -36,7 +34,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,7 +47,7 @@ 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 StateChange = {
|
||||
@@ -61,24 +60,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,147 +86,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;
|
||||
log_directories_to_keep: number;
|
||||
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
+5
-4
@@ -1,16 +1,17 @@
|
||||
declare module 'zigbee2mqtt-frontend' {
|
||||
declare module "zigbee2mqtt-frontend" {
|
||||
export function getPath(): string;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
+49
-43
@@ -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);
|
||||
@@ -233,10 +238,11 @@ class Logger {
|
||||
|
||||
directories.sort((a: KeyValue, b: KeyValue) => b.birth - a.birth);
|
||||
directories = directories.slice(settings.get().advanced.log_directories_to_keep, directories.length);
|
||||
directories.forEach((dir) => {
|
||||
|
||||
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;
|
||||
console.log(JSON.stringify(currentSettings));
|
||||
console.log(JSON.stringify(result));
|
||||
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)) {
|
||||
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"]
|
||||
@@ -219,7 +239,7 @@
|
||||
},
|
||||
"adapter": {
|
||||
"type": ["string"],
|
||||
"enum": ["deconz", "zstack", "zigate", "ezsp", "ember", "zboss"],
|
||||
"enum": ["deconz", "zstack", "zigate", "ezsp", "ember", "zboss", "zoh"],
|
||||
"title": "Adapter",
|
||||
"requiresRestart": true,
|
||||
"description": "Adapter type, not needed unless you are experiencing problems"
|
||||
@@ -410,6 +430,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 +537,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",
|
||||
@@ -825,7 +853,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"],
|
||||
|
||||
+164
-129
@@ -1,52 +1,53 @@
|
||||
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,
|
||||
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 +62,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 +89,14 @@ 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],
|
||||
@@ -105,13 +106,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;
|
||||
@@ -137,16 +138,48 @@ 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;
|
||||
}
|
||||
|
||||
function write(): void {
|
||||
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 write(): void {
|
||||
const settings = getPersistedSettings();
|
||||
const toWrite: KeyValue = objectAssignDeep({}, settings);
|
||||
|
||||
@@ -154,25 +187,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]);
|
||||
|
||||
@@ -191,8 +224,11 @@ function write(): void {
|
||||
}
|
||||
};
|
||||
|
||||
writeDevicesOrGroups('devices');
|
||||
writeDevicesOrGroups('groups');
|
||||
writeDevicesOrGroups("devices");
|
||||
writeDevicesOrGroups("groups");
|
||||
|
||||
applyEnvironmentVariables(toWrite);
|
||||
|
||||
yaml.writeIfChanged(CONFIG_FILE_PATH, toWrite);
|
||||
|
||||
_settings = read();
|
||||
@@ -212,36 +248,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}')`);
|
||||
}
|
||||
|
||||
@@ -251,7 +272,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)) {
|
||||
@@ -261,13 +282,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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -277,11 +303,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];
|
||||
@@ -311,8 +336,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) {
|
||||
@@ -323,8 +348,8 @@ function read(): Partial<Settings> {
|
||||
}
|
||||
};
|
||||
|
||||
readDevicesOrGroups('devices');
|
||||
readDevicesOrGroups('groups');
|
||||
readDevicesOrGroups("devices");
|
||||
readDevicesOrGroups("groups");
|
||||
|
||||
return s;
|
||||
}
|
||||
@@ -332,10 +357,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];
|
||||
|
||||
@@ -347,32 +372,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);
|
||||
}
|
||||
|
||||
@@ -403,11 +428,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++) {
|
||||
@@ -426,7 +452,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);
|
||||
@@ -435,7 +461,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];
|
||||
@@ -448,7 +474,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;
|
||||
}
|
||||
@@ -506,9 +532,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();
|
||||
@@ -517,19 +543,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();
|
||||
}
|
||||
|
||||
@@ -540,7 +567,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)) {
|
||||
@@ -552,32 +579,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();
|
||||
}
|
||||
@@ -590,15 +619,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`);
|
||||
@@ -608,7 +641,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;
|
||||
}
|
||||
@@ -623,11 +656,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();
|
||||
}
|
||||
|
||||
+92
-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,31 @@ 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";
|
||||
}
|
||||
|
||||
function getScenes(entity: zh.Endpoint | zh.Group): Zigbee2MQTTScene[] {
|
||||
@@ -359,9 +347,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 +360,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 +375,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) {
|
||||
|
||||
+110
-97
@@ -1,48 +1,49 @@
|
||||
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>();
|
||||
|
||||
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 +64,38 @@ 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;
|
||||
}
|
||||
|
||||
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 +103,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 +161,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 +183,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 +213,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 +222,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 +237,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") {
|
||||
return this.resolveDevice(this.herdsman.getDevicesByType("Coordinator")[0].ieeeAddr);
|
||||
}
|
||||
|
||||
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 +307,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 +333,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 +341,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 +374,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 +412,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 +423,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);
|
||||
}
|
||||
}
|
||||
|
||||
+18
-26
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"name": "zigbee2mqtt",
|
||||
"version": "2.1.3",
|
||||
"version": "2.3.0",
|
||||
"description": "Zigbee to MQTT bridge using Zigbee-herdsman",
|
||||
"main": "index.js",
|
||||
"types": "dist/types/api.d.ts",
|
||||
"packageManager": "pnpm@10.4.1",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -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",
|
||||
@@ -45,14 +45,13 @@
|
||||
"debounce": "^2.2.0",
|
||||
"express-static-gzip": "^2.2.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"finalhandler": "^1.3.1",
|
||||
"git-last-commit": "^1.0.1",
|
||||
"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.1",
|
||||
@@ -62,40 +61,33 @@
|
||||
"winston-syslog": "^2.7.1",
|
||||
"winston-transport": "^4.9.0",
|
||||
"ws": "^8.18.1",
|
||||
"zigbee-herdsman": "3.2.7",
|
||||
"zigbee-herdsman-converters": "23.2.0",
|
||||
"zigbee2mqtt-frontend": "0.9.4"
|
||||
"zigbee-herdsman": "4.0.0",
|
||||
"zigbee-herdsman-converters": "23.36.0",
|
||||
"zigbee2mqtt-frontend": "0.9.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/core": "^0.12.0",
|
||||
"@eslint/js": "^9.21.0",
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.4.1",
|
||||
"@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.5",
|
||||
"@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/serve-static": "^1.15.7",
|
||||
"@types/ws": "8.5.14",
|
||||
"@vitest/coverage-v8": "^3.0.7",
|
||||
"eslint": "^9.21.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"prettier": "^3.5.2",
|
||||
"@types/ws": "8.18.1",
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
"tmp": "^0.2.3",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.25.0",
|
||||
"vitest": "^3.0.7"
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.1.1"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"zigbee-herdsman": "$zigbee-herdsman"
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"@biomejs/biome",
|
||||
"@serialport/bindings-cpp",
|
||||
"esbuild",
|
||||
"sd-notify",
|
||||
"unix-dgram"
|
||||
]
|
||||
},
|
||||
@@ -103,6 +95,6 @@
|
||||
"zigbee2mqtt": "cli.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"sd-notify": "^2.8.0"
|
||||
"unix-dgram": "^2.0.6"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+517
-1307
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,12 @@ 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 definitions = require('zigbee-herdsman-converters/devices/index').default.map((d) => zhc.prepareDefinition(d));
|
||||
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);
|
||||
|
||||
@@ -61,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} `;
|
||||
@@ -79,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(
|
||||
@@ -93,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(',')) {
|
||||
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 {
|
||||
@@ -112,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)})`);
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
+435
-368
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);
|
||||
});
|
||||
});
|
||||
|
||||
+253
-250
@@ -1,14 +1,15 @@
|
||||
import * as data from '../mocks/data';
|
||||
import {mockDebounce} from '../mocks/debounce';
|
||||
import {mockLogger} from '../mocks/logger';
|
||||
import {events as mockMQTTEvents, mockMQTTPublishAsync} from '../mocks/mqtt';
|
||||
import {flushPromises} from '../mocks/utils';
|
||||
import {Device, devices, groups, events as mockZHEvents} from '../mocks/zigbeeHerdsman';
|
||||
import * as data from "../mocks/data";
|
||||
import {mockDebounce} from "../mocks/debounce";
|
||||
import {mockLogger} from "../mocks/logger";
|
||||
import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
|
||||
import {flushPromises} from "../mocks/utils";
|
||||
import {type Device, devices, groups, 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 Bind from "../../lib/extension/bind";
|
||||
import * as settings from "../../lib/util/settings";
|
||||
|
||||
const mocksClear = [
|
||||
mockDebounce,
|
||||
@@ -18,12 +19,12 @@ const mocksClear = [
|
||||
devices.bulb_color_2.getEndpoint(1)!.read,
|
||||
];
|
||||
|
||||
describe('Extension: Bind', () => {
|
||||
describe("Extension: Bind", () => {
|
||||
let controller: Controller;
|
||||
|
||||
const resetExtension = async (): Promise<void> => {
|
||||
await controller.enableDisableExtension(false, 'Bind');
|
||||
await controller.enableDisableExtension(true, 'Bind');
|
||||
await controller.removeExtension(controller.getExtension("Bind")!);
|
||||
await controller.addExtension(new Bind(...controller.extensionArgs));
|
||||
};
|
||||
|
||||
const mockClear = (device: Device): void => {
|
||||
@@ -49,14 +50,16 @@ describe('Extension: Bind', () => {
|
||||
settings.reRead();
|
||||
groups.group_1.members = [];
|
||||
await resetExtension();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
for (const mock of mocksClear) mock.mockClear();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await controller?.stop();
|
||||
await flushPromises();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('Should bind to device and configure reporting', async () => {
|
||||
it("Should bind to device and configure reporting", async () => {
|
||||
const device = devices.remote;
|
||||
const target = devices.bulb_color.getEndpoint(1)!;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
@@ -65,68 +68,68 @@ describe('Extension: Bind', () => {
|
||||
const originalDeviceOutputClusters = device.getEndpoint(1)!.outputClusters;
|
||||
device.getEndpoint(1)!.outputClusters = [...device.getEndpoint(1)!.outputClusters, 768];
|
||||
const originalTargetBinds = target.binds;
|
||||
target.binds = [{cluster: {name: 'genLevelCtrl'}, target: devices.coordinator.getEndpoint(1)!}];
|
||||
target.binds = [{cluster: {name: "genLevelCtrl"}, target: devices.coordinator.getEndpoint(1)!}];
|
||||
target.getClusterAttributeValue.mockReturnValueOnce(undefined);
|
||||
mockClear(device);
|
||||
target.configureReporting.mockImplementationOnce(() => {
|
||||
throw new Error('timeout');
|
||||
throw new Error("timeout");
|
||||
});
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({transaction: '1234', from: 'remote', to: 'bulb_color'}));
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/bind", stringify({transaction: "1234", from: "remote", to: "bulb_color"}));
|
||||
await flushPromises();
|
||||
expect(target.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities']);
|
||||
expect(target.read).toHaveBeenCalledWith("lightingColorCtrl", ["colorCapabilities"]);
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(4);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('lightingColorCtrl', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith("lightingColorCtrl", target);
|
||||
expect(target.configureReporting).toHaveBeenCalledTimes(3);
|
||||
expect(target.configureReporting).toHaveBeenCalledWith('genOnOff', [
|
||||
{attribute: 'onOff', maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
|
||||
expect(target.configureReporting).toHaveBeenCalledWith("genOnOff", [
|
||||
{attribute: "onOff", maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
|
||||
]);
|
||||
expect(target.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
|
||||
{attribute: 'currentLevel', maximumReportInterval: 3600, minimumReportInterval: 5, reportableChange: 1},
|
||||
expect(target.configureReporting).toHaveBeenCalledWith("genLevelCtrl", [
|
||||
{attribute: "currentLevel", maximumReportInterval: 3600, minimumReportInterval: 5, reportableChange: 1},
|
||||
]);
|
||||
expect(target.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
|
||||
{attribute: 'colorTemperature', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
{attribute: 'currentX', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
{attribute: 'currentY', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
expect(target.configureReporting).toHaveBeenCalledWith("lightingColorCtrl", [
|
||||
{attribute: "colorTemperature", minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
{attribute: "currentX", minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
{attribute: "currentY", minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
"zigbee2mqtt/bridge/response/device/bind",
|
||||
stringify({
|
||||
transaction: '1234',
|
||||
transaction: "1234",
|
||||
data: {
|
||||
from: 'remote',
|
||||
from_endpoint: 'default',
|
||||
to: 'bulb_color',
|
||||
clusters: ['genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl'],
|
||||
from: "remote",
|
||||
from_endpoint: "default",
|
||||
to: "bulb_color",
|
||||
clusters: ["genScenes", "genOnOff", "genLevelCtrl", "lightingColorCtrl"],
|
||||
failed: [],
|
||||
},
|
||||
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, qos: 0});
|
||||
|
||||
// Teardown
|
||||
target.binds = originalTargetBinds;
|
||||
device.getEndpoint(1)!.outputClusters = originalDeviceOutputClusters;
|
||||
});
|
||||
|
||||
it('Should throw error on invalid payload', async () => {
|
||||
it("Should throw error on invalid payload", async () => {
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({fromz: 'remote', to: 'bulb_color'}));
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/bind", stringify({fromz: "remote", to: "bulb_color"}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({data: {}, status: 'error', error: 'Invalid payload'}),
|
||||
"zigbee2mqtt/bridge/response/device/bind",
|
||||
stringify({data: {}, status: "error", error: "Invalid payload"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Filters out unsupported clusters for reporting setup', async () => {
|
||||
it("Filters out unsupported clusters for reporting setup", async () => {
|
||||
const device = devices.remote;
|
||||
const target = devices.bulb_color.getEndpoint(1)!;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
@@ -142,49 +145,49 @@ describe('Extension: Bind', () => {
|
||||
const originalTargetOutputClusters = target.outputClusters;
|
||||
target.outputClusters = [...target.outputClusters, 8];
|
||||
const originalTargetBinds = target.binds;
|
||||
target.binds = [{cluster: {name: 'genLevelCtrl'}, target: devices.coordinator.getEndpoint(1)!}];
|
||||
target.binds = [{cluster: {name: "genLevelCtrl"}, target: devices.coordinator.getEndpoint(1)!}];
|
||||
target.getClusterAttributeValue.mockReturnValueOnce(undefined);
|
||||
mockClear(device);
|
||||
target.configureReporting.mockImplementationOnce(() => {
|
||||
throw new Error('timeout');
|
||||
throw new Error("timeout");
|
||||
});
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({transaction: '1234', from: 'remote', to: 'bulb_color'}));
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/bind", stringify({transaction: "1234", from: "remote", to: "bulb_color"}));
|
||||
await flushPromises();
|
||||
|
||||
expect(target.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities']);
|
||||
expect(target.read).toHaveBeenCalledWith("lightingColorCtrl", ["colorCapabilities"]);
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(4);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('lightingColorCtrl', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith("lightingColorCtrl", target);
|
||||
expect(target.configureReporting).toHaveBeenCalledTimes(2);
|
||||
expect(target.configureReporting).toHaveBeenCalledWith('genOnOff', [
|
||||
{attribute: 'onOff', maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
|
||||
expect(target.configureReporting).toHaveBeenCalledWith("genOnOff", [
|
||||
{attribute: "onOff", maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
|
||||
]);
|
||||
// expect(target.configureReporting).toHaveBeenCalledWith("genLevelCtrl",[{"attribute": "currentLevel", "maximumReportInterval": 3600, "minimumReportInterval": 5, "reportableChange": 1}]);
|
||||
expect(target.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
|
||||
{attribute: 'colorTemperature', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
{attribute: 'currentX', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
{attribute: 'currentY', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
expect(target.configureReporting).toHaveBeenCalledWith("lightingColorCtrl", [
|
||||
{attribute: "colorTemperature", minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
{attribute: "currentX", minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
{attribute: "currentY", minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
"zigbee2mqtt/bridge/response/device/bind",
|
||||
stringify({
|
||||
transaction: '1234',
|
||||
transaction: "1234",
|
||||
data: {
|
||||
from: 'remote',
|
||||
from_endpoint: 'default',
|
||||
to: 'bulb_color',
|
||||
clusters: ['genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl'],
|
||||
from: "remote",
|
||||
from_endpoint: "default",
|
||||
to: "bulb_color",
|
||||
clusters: ["genScenes", "genOnOff", "genLevelCtrl", "lightingColorCtrl"],
|
||||
failed: [],
|
||||
},
|
||||
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, qos: 0});
|
||||
|
||||
// Teardown
|
||||
target.binds = originalTargetBinds;
|
||||
@@ -194,7 +197,7 @@ describe('Extension: Bind', () => {
|
||||
device.getEndpoint(1)!.outputClusters = originalDeviceOutputClusters;
|
||||
});
|
||||
|
||||
it('Filters out reporting setup based on bind status', async () => {
|
||||
it("Filters out reporting setup based on bind status", async () => {
|
||||
const device = devices.remote;
|
||||
const target = devices.bulb_color.getEndpoint(1)!;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
@@ -203,58 +206,58 @@ describe('Extension: Bind', () => {
|
||||
const originalDeviceOutputClusters = device.getEndpoint(1)!.outputClusters;
|
||||
device.getEndpoint(1)!.outputClusters = [...device.getEndpoint(1)!.outputClusters, 768];
|
||||
const originalTargetBinds = target.binds;
|
||||
target.binds = [{cluster: {name: 'genLevelCtrl'}, target: devices.coordinator.getEndpoint(1)!}];
|
||||
target.binds = [{cluster: {name: "genLevelCtrl"}, target: devices.coordinator.getEndpoint(1)!}];
|
||||
target.getClusterAttributeValue.mockReturnValueOnce(undefined);
|
||||
mockClear(device);
|
||||
target.configureReporting.mockImplementationOnce(() => {
|
||||
throw new Error('timeout');
|
||||
throw new Error("timeout");
|
||||
});
|
||||
const originalTargetCR = target.configuredReportings;
|
||||
target.configuredReportings = [
|
||||
{
|
||||
cluster: {name: 'genLevelCtrl'},
|
||||
attribute: {name: 'currentLevel', ID: 0},
|
||||
cluster: {name: "genLevelCtrl"},
|
||||
attribute: {name: "currentLevel", ID: 0},
|
||||
minimumReportInterval: 0,
|
||||
maximumReportInterval: 3600,
|
||||
reportableChange: 0,
|
||||
},
|
||||
];
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({transaction: '1234', from: 'remote', to: 'bulb_color'}));
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/bind", stringify({transaction: "1234", from: "remote", to: "bulb_color"}));
|
||||
await flushPromises();
|
||||
expect(target.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities']);
|
||||
expect(target.read).toHaveBeenCalledWith("lightingColorCtrl", ["colorCapabilities"]);
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(4);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('lightingColorCtrl', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith("lightingColorCtrl", target);
|
||||
expect(target.configureReporting).toHaveBeenCalledTimes(2);
|
||||
expect(target.configureReporting).toHaveBeenCalledWith('genOnOff', [
|
||||
{attribute: 'onOff', maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
|
||||
expect(target.configureReporting).toHaveBeenCalledWith("genOnOff", [
|
||||
{attribute: "onOff", maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
|
||||
]);
|
||||
// expect(target.configureReporting).toHaveBeenCalledWith("genLevelCtrl",[{"attribute": "currentLevel", "maximumReportInterval": 3600, "minimumReportInterval": 5, "reportableChange": 1}]);
|
||||
expect(target.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
|
||||
{attribute: 'colorTemperature', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
{attribute: 'currentX', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
{attribute: 'currentY', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
expect(target.configureReporting).toHaveBeenCalledWith("lightingColorCtrl", [
|
||||
{attribute: "colorTemperature", minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
{attribute: "currentX", minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
{attribute: "currentY", minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
"zigbee2mqtt/bridge/response/device/bind",
|
||||
stringify({
|
||||
transaction: '1234',
|
||||
transaction: "1234",
|
||||
data: {
|
||||
from: 'remote',
|
||||
from_endpoint: 'default',
|
||||
to: 'bulb_color',
|
||||
clusters: ['genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl'],
|
||||
from: "remote",
|
||||
from_endpoint: "default",
|
||||
to: "bulb_color",
|
||||
clusters: ["genScenes", "genOnOff", "genLevelCtrl", "lightingColorCtrl"],
|
||||
failed: [],
|
||||
},
|
||||
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, qos: 0});
|
||||
|
||||
// Teardown
|
||||
target.configuredReportings = originalTargetCR;
|
||||
@@ -262,84 +265,84 @@ describe('Extension: Bind', () => {
|
||||
device.getEndpoint(1)!.outputClusters = originalDeviceOutputClusters;
|
||||
});
|
||||
|
||||
it('Should bind only specified clusters', async () => {
|
||||
it("Should bind only specified clusters", async () => {
|
||||
const device = devices.remote;
|
||||
const target = devices.bulb_color.getEndpoint(1)!;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'bulb_color', clusters: ['genOnOff']}));
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/bind", stringify({from: "remote", to: "bulb_color", clusters: ["genOnOff"]}));
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({data: {from: 'remote', from_endpoint: 'default', to: 'bulb_color', clusters: ['genOnOff'], failed: []}, status: 'ok'}),
|
||||
"zigbee2mqtt/bridge/response/device/bind",
|
||||
stringify({data: {from: "remote", from_endpoint: "default", to: "bulb_color", clusters: ["genOnOff"], failed: []}, status: "ok"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should log error when there is nothing to bind', async () => {
|
||||
it("Should log error when there is nothing to bind", async () => {
|
||||
const device = devices.bulb_color;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
mockClear(device);
|
||||
mockLogger.error.mockClear();
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'button'}));
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/bind", stringify({from: "remote", to: "button"}));
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(0);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({data: {}, status: 'error', error: 'Nothing to bind'}),
|
||||
"zigbee2mqtt/bridge/response/device/bind",
|
||||
stringify({data: {}, status: "error", error: "Nothing to bind"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should unbind', async () => {
|
||||
it("Should unbind", async () => {
|
||||
const device = devices.remote;
|
||||
const target = devices.bulb_color.getEndpoint(1)!;
|
||||
|
||||
// setup
|
||||
target.configureReporting.mockImplementationOnce(() => {
|
||||
throw new Error('timeout');
|
||||
throw new Error("timeout");
|
||||
});
|
||||
const originalRemoteBinds = device.getEndpoint(1)!.binds;
|
||||
device.getEndpoint(1)!.binds = [];
|
||||
const originalTargetBinds = target.binds;
|
||||
target.binds = [
|
||||
{cluster: {name: 'genOnOff'}, target: devices.coordinator.getEndpoint(1)!},
|
||||
{cluster: {name: 'genLevelCtrl'}, target: devices.coordinator.getEndpoint(1)!},
|
||||
{cluster: {name: 'lightingColorCtrl'}, target: devices.coordinator.getEndpoint(1)!},
|
||||
{cluster: {name: "genOnOff"}, target: devices.coordinator.getEndpoint(1)!},
|
||||
{cluster: {name: "genLevelCtrl"}, target: devices.coordinator.getEndpoint(1)!},
|
||||
{cluster: {name: "lightingColorCtrl"}, target: devices.coordinator.getEndpoint(1)!},
|
||||
];
|
||||
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
mockClear(device);
|
||||
delete devices.bulb_color.meta.configured;
|
||||
expect(devices.bulb_color.meta.configured).toBe(undefined);
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'bulb_color'}));
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/unbind", stringify({from: "remote", to: "bulb_color"}));
|
||||
await flushPromises();
|
||||
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', target);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', target);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", target);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", target);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", target);
|
||||
|
||||
// Disable reporting
|
||||
expect(target.configureReporting).toHaveBeenCalledTimes(3);
|
||||
expect(target.configureReporting).toHaveBeenCalledWith('genOnOff', [
|
||||
{attribute: 'onOff', maximumReportInterval: 0xffff, minimumReportInterval: 0, reportableChange: 0},
|
||||
expect(target.configureReporting).toHaveBeenCalledWith("genOnOff", [
|
||||
{attribute: "onOff", maximumReportInterval: 0xffff, minimumReportInterval: 0, reportableChange: 0},
|
||||
]);
|
||||
expect(target.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
|
||||
{attribute: 'currentLevel', maximumReportInterval: 0xffff, minimumReportInterval: 5, reportableChange: 1},
|
||||
expect(target.configureReporting).toHaveBeenCalledWith("genLevelCtrl", [
|
||||
{attribute: "currentLevel", maximumReportInterval: 0xffff, minimumReportInterval: 5, reportableChange: 1},
|
||||
]);
|
||||
expect(target.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
|
||||
{attribute: 'colorTemperature', minimumReportInterval: 5, maximumReportInterval: 0xffff, reportableChange: 1},
|
||||
{attribute: 'currentX', minimumReportInterval: 5, maximumReportInterval: 0xffff, reportableChange: 1},
|
||||
{attribute: 'currentY', minimumReportInterval: 5, maximumReportInterval: 0xffff, reportableChange: 1},
|
||||
expect(target.configureReporting).toHaveBeenCalledWith("lightingColorCtrl", [
|
||||
{attribute: "colorTemperature", minimumReportInterval: 5, maximumReportInterval: 0xffff, reportableChange: 1},
|
||||
{attribute: "currentX", minimumReportInterval: 5, maximumReportInterval: 0xffff, reportableChange: 1},
|
||||
{attribute: "currentY", minimumReportInterval: 5, maximumReportInterval: 0xffff, reportableChange: 1},
|
||||
]);
|
||||
expect(devices.bulb_color.meta.configured).toBe(332242049);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/unbind',
|
||||
"zigbee2mqtt/bridge/response/device/unbind",
|
||||
stringify({
|
||||
data: {from: 'remote', from_endpoint: 'default', to: 'bulb_color', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []},
|
||||
status: 'ok',
|
||||
data: {from: "remote", from_endpoint: "default", to: "bulb_color", clusters: ["genScenes", "genOnOff", "genLevelCtrl"], failed: []},
|
||||
status: "ok",
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
@@ -349,29 +352,29 @@ describe('Extension: Bind', () => {
|
||||
device.getEndpoint(1)!.binds = originalRemoteBinds;
|
||||
});
|
||||
|
||||
it('Should unbind coordinator', async () => {
|
||||
it("Should unbind coordinator", async () => {
|
||||
const device = devices.remote;
|
||||
const target = devices.coordinator.getEndpoint(1)!;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
mockClear(device);
|
||||
endpoint.unbind.mockClear();
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'Coordinator'}));
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/unbind", stringify({from: "remote", to: "Coordinator"}));
|
||||
await flushPromises();
|
||||
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', target);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', target);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", target);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", target);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", target);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/unbind',
|
||||
"zigbee2mqtt/bridge/response/device/unbind",
|
||||
stringify({
|
||||
data: {from: 'remote', from_endpoint: 'default', to: 'Coordinator', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []},
|
||||
status: 'ok',
|
||||
data: {from: "remote", from_endpoint: "default", to: "Coordinator", clusters: ["genScenes", "genOnOff", "genLevelCtrl"], failed: []},
|
||||
status: "ok",
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should bind to groups', async () => {
|
||||
it("Should bind to groups", async () => {
|
||||
const device = devices.remote;
|
||||
const target = groups.group_1;
|
||||
const target1Member = devices.bulb.getEndpoint(1)!;
|
||||
@@ -379,42 +382,42 @@ describe('Extension: Bind', () => {
|
||||
target.members.push(target1Member);
|
||||
target1Member.configureReporting.mockClear();
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'group_1'}));
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/bind", stringify({from: "remote", to: "group_1"}));
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target);
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledTimes(2);
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledWith('genOnOff', [
|
||||
{attribute: 'onOff', maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledWith("genOnOff", [
|
||||
{attribute: "onOff", maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
|
||||
]);
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
|
||||
{attribute: 'currentLevel', maximumReportInterval: 3600, minimumReportInterval: 5, reportableChange: 1},
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledWith("genLevelCtrl", [
|
||||
{attribute: "currentLevel", maximumReportInterval: 3600, minimumReportInterval: 5, reportableChange: 1},
|
||||
]);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
"zigbee2mqtt/bridge/response/device/bind",
|
||||
stringify({
|
||||
data: {from: 'remote', from_endpoint: 'default', to: 'group_1', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []},
|
||||
status: 'ok',
|
||||
data: {from: "remote", from_endpoint: "default", to: "group_1", clusters: ["genScenes", "genOnOff", "genLevelCtrl"], failed: []},
|
||||
status: "ok",
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
|
||||
// Should configure reporting for device added to group
|
||||
target1Member.configureReporting.mockClear();
|
||||
await mockMQTTEvents.message('zigbee2mqtt/bridge/request/group/members/add', stringify({group: 'group_1', device: 'bulb'}));
|
||||
await mockMQTTEvents.message("zigbee2mqtt/bridge/request/group/members/add", stringify({group: "group_1", device: "bulb"}));
|
||||
await flushPromises();
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledTimes(2);
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledWith('genOnOff', [
|
||||
{attribute: 'onOff', maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledWith("genOnOff", [
|
||||
{attribute: "onOff", maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
|
||||
]);
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
|
||||
{attribute: 'currentLevel', maximumReportInterval: 3600, minimumReportInterval: 5, reportableChange: 1},
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledWith("genLevelCtrl", [
|
||||
{attribute: "currentLevel", maximumReportInterval: 3600, minimumReportInterval: 5, reportableChange: 1},
|
||||
]);
|
||||
});
|
||||
|
||||
it('Should unbind from group', async () => {
|
||||
it("Should unbind from group", async () => {
|
||||
const device = devices.remote;
|
||||
const target = groups.group_1;
|
||||
const target1Member = devices.bulb.getEndpoint(1)!;
|
||||
@@ -422,23 +425,23 @@ describe('Extension: Bind', () => {
|
||||
target.members.push(target1Member);
|
||||
target1Member.configureReporting.mockClear();
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'group_1'}));
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/unbind", stringify({from: "remote", to: "group_1"}));
|
||||
await flushPromises();
|
||||
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', target);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', target);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", target);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", target);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", target);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/unbind',
|
||||
"zigbee2mqtt/bridge/response/device/unbind",
|
||||
stringify({
|
||||
data: {from: 'remote', from_endpoint: 'default', to: 'group_1', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []},
|
||||
status: 'ok',
|
||||
data: {from: "remote", from_endpoint: "default", to: "group_1", clusters: ["genScenes", "genOnOff", "genLevelCtrl"], failed: []},
|
||||
status: "ok",
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should unbind from group with skip_disable_reporting=true', async () => {
|
||||
it("Should unbind from group with skip_disable_reporting=true", async () => {
|
||||
const device = devices.remote;
|
||||
const target = groups.group_1;
|
||||
const target1Member = devices.bulb_2.getEndpoint(1)!;
|
||||
@@ -450,12 +453,12 @@ describe('Extension: Bind', () => {
|
||||
endpoint.binds = [];
|
||||
|
||||
target1Member.binds = [
|
||||
{cluster: {name: 'genLevelCtrl'}, target: devices.coordinator.getEndpoint(1)!},
|
||||
{cluster: {name: 'genOnOff'}, target: devices.coordinator.getEndpoint(1)!},
|
||||
{cluster: {name: "genLevelCtrl"}, target: devices.coordinator.getEndpoint(1)!},
|
||||
{cluster: {name: "genOnOff"}, target: devices.coordinator.getEndpoint(1)!},
|
||||
];
|
||||
target1Member.configureReporting.mockClear();
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'group_1', skip_disable_reporting: true}));
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/unbind", stringify({from: "remote", to: "group_1", skip_disable_reporting: true}));
|
||||
await flushPromises();
|
||||
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
|
||||
// with skip_disable_reporting set to false, we don't expect it to reconfigure reporting
|
||||
@@ -463,7 +466,7 @@ describe('Extension: Bind', () => {
|
||||
endpoint.binds = originalBinds;
|
||||
});
|
||||
|
||||
it('Should unbind from group with skip_disable_reporting=false', async () => {
|
||||
it("Should unbind from group with skip_disable_reporting=false", async () => {
|
||||
const device = devices.remote;
|
||||
const target = groups.group_1;
|
||||
const target1Member = devices.bulb_2.getEndpoint(1)!;
|
||||
@@ -475,184 +478,184 @@ describe('Extension: Bind', () => {
|
||||
endpoint.binds = [];
|
||||
|
||||
target1Member.binds = [
|
||||
{cluster: {name: 'genLevelCtrl'}, target: devices.coordinator.getEndpoint(1)!},
|
||||
{cluster: {name: 'genOnOff'}, target: devices.coordinator.getEndpoint(1)!},
|
||||
{cluster: {name: "genLevelCtrl"}, target: devices.coordinator.getEndpoint(1)!},
|
||||
{cluster: {name: "genOnOff"}, target: devices.coordinator.getEndpoint(1)!},
|
||||
];
|
||||
target1Member.configureReporting.mockClear();
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'group_1', skip_disable_reporting: false}));
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/unbind", stringify({from: "remote", to: "group_1", skip_disable_reporting: false}));
|
||||
await flushPromises();
|
||||
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
|
||||
// with skip_disable_reporting set, we expect it to reconfigure reporting
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledTimes(2);
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
|
||||
{attribute: 'currentLevel', maximumReportInterval: 65535, minimumReportInterval: 5, reportableChange: 1},
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledWith("genLevelCtrl", [
|
||||
{attribute: "currentLevel", maximumReportInterval: 65535, minimumReportInterval: 5, reportableChange: 1},
|
||||
]);
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledWith('genOnOff', [
|
||||
{attribute: 'onOff', maximumReportInterval: 65535, minimumReportInterval: 0, reportableChange: 0},
|
||||
expect(target1Member.configureReporting).toHaveBeenCalledWith("genOnOff", [
|
||||
{attribute: "onOff", maximumReportInterval: 65535, minimumReportInterval: 0, reportableChange: 0},
|
||||
]);
|
||||
endpoint.binds = originalBinds;
|
||||
});
|
||||
|
||||
it('Should bind to group by number', async () => {
|
||||
it("Should bind to group by number", async () => {
|
||||
const device = devices.remote;
|
||||
const target = groups.group_1;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: '1'}));
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/bind", stringify({from: "remote", to: "1"}));
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
"zigbee2mqtt/bridge/response/device/bind",
|
||||
stringify({
|
||||
data: {from: 'remote', from_endpoint: 'default', to: '1', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []},
|
||||
status: 'ok',
|
||||
data: {from: "remote", from_endpoint: "default", to: "1", clusters: ["genScenes", "genOnOff", "genLevelCtrl"], failed: []},
|
||||
status: "ok",
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should log when bind fails', async () => {
|
||||
it("Should log when bind fails", async () => {
|
||||
mockLogger.error.mockClear();
|
||||
const device = devices.remote;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
mockClear(device);
|
||||
endpoint.bind.mockImplementation(() => {
|
||||
throw new Error('failed');
|
||||
throw new Error("failed");
|
||||
});
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'bulb_color'}));
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/bind", stringify({from: "remote", to: "bulb_color"}));
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(3);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({data: {}, status: 'error', error: 'Failed to bind'}),
|
||||
"zigbee2mqtt/bridge/response/device/bind",
|
||||
stringify({data: {}, status: "error", error: "Failed to bind"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should bind from non default endpoint names', async () => {
|
||||
it("Should bind from non default endpoint names", async () => {
|
||||
const device = devices.remote;
|
||||
const target = devices.QBKG03LM.getEndpoint(3)!;
|
||||
const endpoint = device.getEndpoint(2)!;
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message(
|
||||
'zigbee2mqtt/bridge/request/device/bind',
|
||||
stringify({from: 'remote', from_endpoint: 'ep2', to: 'wall_switch_double', to_endpoint: 'right'}),
|
||||
"zigbee2mqtt/bridge/request/device/bind",
|
||||
stringify({from: "remote", from_endpoint: "ep2", to: "wall_switch_double", to_endpoint: "right"}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
"zigbee2mqtt/bridge/response/device/bind",
|
||||
stringify({
|
||||
data: {from: 'remote', from_endpoint: 'ep2', to: 'wall_switch_double', to_endpoint: 'right', clusters: ['genOnOff'], failed: []},
|
||||
status: 'ok',
|
||||
data: {from: "remote", from_endpoint: "ep2", to: "wall_switch_double", to_endpoint: "right", clusters: ["genOnOff"], failed: []},
|
||||
status: "ok",
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should bind from non default endpoint IDs', async () => {
|
||||
it("Should bind from non default endpoint IDs", async () => {
|
||||
const device = devices.remote;
|
||||
const target = devices.QBKG03LM.getEndpoint(3)!;
|
||||
const endpoint = device.getEndpoint(2)!;
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message(
|
||||
'zigbee2mqtt/bridge/request/device/bind',
|
||||
stringify({from: 'remote', from_endpoint: 2, to: 'wall_switch_double', to_endpoint: 3}),
|
||||
"zigbee2mqtt/bridge/request/device/bind",
|
||||
stringify({from: "remote", from_endpoint: 2, to: "wall_switch_double", to_endpoint: 3}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
"zigbee2mqtt/bridge/response/device/bind",
|
||||
stringify({
|
||||
data: {from: 'remote', from_endpoint: 2, to: 'wall_switch_double', to_endpoint: 3, clusters: ['genOnOff'], failed: []},
|
||||
status: 'ok',
|
||||
data: {from: "remote", from_endpoint: 2, to: "wall_switch_double", to_endpoint: 3, clusters: ["genOnOff"], failed: []},
|
||||
status: "ok",
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should bind server clusters to client clusters', async () => {
|
||||
it("Should bind server clusters to client clusters", async () => {
|
||||
const device = devices.temperature_sensor;
|
||||
const target = devices.heating_actuator.getEndpoint(1)!;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'temperature_sensor', to: 'heating_actuator'}));
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/bind", stringify({from: "temperature_sensor", to: "heating_actuator"}));
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('msTemperatureMeasurement', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith("msTemperatureMeasurement", target);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
"zigbee2mqtt/bridge/response/device/bind",
|
||||
stringify({
|
||||
data: {
|
||||
from: 'temperature_sensor',
|
||||
from_endpoint: 'default',
|
||||
to: 'heating_actuator',
|
||||
clusters: ['msTemperatureMeasurement'],
|
||||
from: "temperature_sensor",
|
||||
from_endpoint: "default",
|
||||
to: "heating_actuator",
|
||||
clusters: ["msTemperatureMeasurement"],
|
||||
failed: [],
|
||||
},
|
||||
status: 'ok',
|
||||
status: "ok",
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should bind to default endpoint returned by endpoints()', async () => {
|
||||
it("Should bind to default endpoint returned by endpoints()", async () => {
|
||||
const device = devices.remote;
|
||||
const target = devices.QBKG04LM.getEndpoint(2)!;
|
||||
const endpoint = device.getEndpoint(2)!;
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', from_endpoint: 'ep2', to: 'wall_switch'}));
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/bind", stringify({from: "remote", from_endpoint: "ep2", to: "wall_switch"}));
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({data: {from: 'remote', from_endpoint: 'ep2', to: 'wall_switch', clusters: ['genOnOff'], failed: []}, status: 'ok'}),
|
||||
"zigbee2mqtt/bridge/response/device/bind",
|
||||
stringify({data: {from: "remote", from_endpoint: "ep2", to: "wall_switch", clusters: ["genOnOff"], failed: []}, status: "ok"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should unbind from default_bind_group', async () => {
|
||||
it("Should unbind from default_bind_group", async () => {
|
||||
const device = devices.remote;
|
||||
const target = 'default_bind_group';
|
||||
const target = "default_bind_group";
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: target}));
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/unbind", stringify({from: "remote", to: target}));
|
||||
await flushPromises();
|
||||
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', 901);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', 901);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', 901);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", 901);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", 901);
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", 901);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/unbind',
|
||||
"zigbee2mqtt/bridge/response/device/unbind",
|
||||
stringify({
|
||||
data: {
|
||||
from: 'remote',
|
||||
from_endpoint: 'default',
|
||||
to: 'default_bind_group',
|
||||
clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'],
|
||||
from: "remote",
|
||||
from_endpoint: "default",
|
||||
to: "default_bind_group",
|
||||
clusters: ["genScenes", "genOnOff", "genLevelCtrl"],
|
||||
failed: [],
|
||||
},
|
||||
status: 'ok',
|
||||
status: "ok",
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Error bind fails when source device does not exist', async () => {
|
||||
it("Error bind fails when source device does not exist", async () => {
|
||||
const device = devices.remote;
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote_not_existing', to: 'bulb_color'}));
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/bind", stringify({from: "remote_not_existing", to: "bulb_color"}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({data: {}, status: 'error', error: "Source device 'remote_not_existing' does not exist"}),
|
||||
"zigbee2mqtt/bridge/response/device/bind",
|
||||
stringify({data: {}, status: "error", error: "Source device 'remote_not_existing' does not exist"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
@@ -661,25 +664,25 @@ describe('Extension: Bind', () => {
|
||||
const device = devices.remote;
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message(
|
||||
'zigbee2mqtt/bridge/request/device/bind',
|
||||
stringify({from: 'remote', from_endpoint: 'not_existing_endpoint', to: 'bulb_color'}),
|
||||
"zigbee2mqtt/bridge/request/device/bind",
|
||||
stringify({from: "remote", from_endpoint: "not_existing_endpoint", to: "bulb_color"}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({data: {}, status: 'error', error: "Source device 'remote' does not have endpoint 'not_existing_endpoint'"}),
|
||||
"zigbee2mqtt/bridge/response/device/bind",
|
||||
stringify({data: {}, status: "error", error: "Source device 'remote' does not have endpoint 'not_existing_endpoint'"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Error bind fails when target device or group does not exist', async () => {
|
||||
it("Error bind fails when target device or group does not exist", async () => {
|
||||
const device = devices.remote;
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'bulb_color_not_existing'}));
|
||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/bind", stringify({from: "remote", to: "bulb_color_not_existing"}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({data: {}, status: 'error', error: "Target device or group 'bulb_color_not_existing' does not exist"}),
|
||||
"zigbee2mqtt/bridge/response/device/bind",
|
||||
stringify({data: {}, status: "error", error: "Target device or group 'bulb_color_not_existing' does not exist"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
@@ -688,47 +691,47 @@ describe('Extension: Bind', () => {
|
||||
const device = devices.remote;
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message(
|
||||
'zigbee2mqtt/bridge/request/device/bind',
|
||||
stringify({from: 'remote', to: 'bulb_color', to_endpoint: 'not_existing_endpoint'}),
|
||||
"zigbee2mqtt/bridge/request/device/bind",
|
||||
stringify({from: "remote", to: "bulb_color", to_endpoint: "not_existing_endpoint"}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({data: {}, status: 'error', error: "Target device 'bulb_color' does not have endpoint 'not_existing_endpoint'"}),
|
||||
"zigbee2mqtt/bridge/response/device/bind",
|
||||
stringify({data: {}, status: "error", error: "Target device 'bulb_color' does not have endpoint 'not_existing_endpoint'"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should poll bounded Hue bulb when receiving message from Hue dimmer', async () => {
|
||||
it("Should poll bounded Hue bulb when receiving message from Hue dimmer", async () => {
|
||||
const remote = devices.remote;
|
||||
const data = {button: 3, unknown1: 3145728, type: 2, unknown2: 0, time: 1};
|
||||
const payload = {
|
||||
data,
|
||||
cluster: 'manuSpecificPhilips',
|
||||
cluster: "manuSpecificPhilips",
|
||||
device: remote,
|
||||
endpoint: remote.getEndpoint(2)!,
|
||||
type: 'commandHueNotification',
|
||||
type: "commandHueNotification",
|
||||
linkquality: 10,
|
||||
groupID: 0,
|
||||
};
|
||||
await mockZHEvents.message(payload);
|
||||
await flushPromises();
|
||||
expect(mockDebounce).toHaveBeenCalledTimes(1);
|
||||
expect(devices.bulb_color.getEndpoint(1)!.read).toHaveBeenCalledWith('genLevelCtrl', ['currentLevel']);
|
||||
expect(devices.bulb_color.getEndpoint(1)!.read).toHaveBeenCalledWith("genLevelCtrl", ["currentLevel"]);
|
||||
});
|
||||
|
||||
it('Should poll bounded Hue bulb when receiving message from scene controller', async () => {
|
||||
it("Should poll bounded Hue bulb when receiving message from scene controller", async () => {
|
||||
const remote = devices.bj_scene_switch;
|
||||
const data = {action: 'recall_2_row_1'};
|
||||
const data = {action: "recall_2_row_1"};
|
||||
devices.bulb_color_2.getEndpoint(1)!.read.mockImplementationOnce(() => {
|
||||
throw new Error('failed');
|
||||
throw new Error("failed");
|
||||
});
|
||||
const payload = {
|
||||
data,
|
||||
cluster: 'genScenes',
|
||||
cluster: "genScenes",
|
||||
device: remote,
|
||||
endpoint: remote.getEndpoint(10)!,
|
||||
type: 'commandRecall',
|
||||
type: "commandRecall",
|
||||
linkquality: 10,
|
||||
groupID: 0,
|
||||
};
|
||||
@@ -736,22 +739,22 @@ describe('Extension: Bind', () => {
|
||||
await flushPromises();
|
||||
// Calls to three clusters are expected in this case
|
||||
expect(mockDebounce).toHaveBeenCalledTimes(3);
|
||||
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledWith('genOnOff', ['onOff']);
|
||||
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledWith('genLevelCtrl', ['currentLevel']);
|
||||
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledWith('lightingColorCtrl', ['currentX', 'currentY', 'colorTemperature']);
|
||||
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledWith("genOnOff", ["onOff"]);
|
||||
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledWith("genLevelCtrl", ["currentLevel"]);
|
||||
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledWith("lightingColorCtrl", ["currentX", "currentY", "colorTemperature"]);
|
||||
});
|
||||
|
||||
it('Should poll grouped Hue bulb when receiving message from TRADFRI remote', async () => {
|
||||
it("Should poll grouped Hue bulb when receiving message from TRADFRI remote", async () => {
|
||||
devices.bulb_color_2.getEndpoint(1)!.read.mockClear();
|
||||
devices.bulb_2.getEndpoint(1)!.read.mockClear();
|
||||
const remote = devices.tradfri_remote;
|
||||
const data = {stepmode: 0, stepsize: 43, transtime: 5};
|
||||
const payload = {
|
||||
data,
|
||||
cluster: 'genLevelCtrl',
|
||||
cluster: "genLevelCtrl",
|
||||
device: remote,
|
||||
endpoint: remote.getEndpoint(1)!,
|
||||
type: 'commandStepWithOnOff',
|
||||
type: "commandStepWithOnOff",
|
||||
linkquality: 10,
|
||||
groupID: 15071,
|
||||
};
|
||||
@@ -759,8 +762,8 @@ describe('Extension: Bind', () => {
|
||||
await flushPromises();
|
||||
expect(mockDebounce).toHaveBeenCalledTimes(2);
|
||||
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledTimes(2);
|
||||
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledWith('genLevelCtrl', ['currentLevel']);
|
||||
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledWith('genOnOff', ['onOff']);
|
||||
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledWith("genLevelCtrl", ["currentLevel"]);
|
||||
expect(devices.bulb_color_2.getEndpoint(1)!.read).toHaveBeenCalledWith("genOnOff", ["onOff"]);
|
||||
|
||||
// Should also only debounce once
|
||||
await mockZHEvents.message(payload);
|
||||
|
||||
+1912
-1876
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'}),
|
||||
"zigbee2mqtt/bridge/response/device/configure",
|
||||
stringify({data: {id: "remote"}, status: "ok"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
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"}),
|
||||
"zigbee2mqtt/bridge/response/device/configure",
|
||||
stringify({data: {}, status: "error", error: "Device 'not_existing_device' does not exist"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
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)'}),
|
||||
"zigbee2mqtt/bridge/response/device/configure",
|
||||
stringify({data: {}, status: "error", error: "Failed to configure (Bind timeout after 10s)"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
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}),
|
||||
"zigbee2mqtt/bridge/response/device/configure",
|
||||
stringify({data: {}, status: "error", error: "Device 'TS0601_thermostat' cannot be configured", transaction: 20}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
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'}),
|
||||
"zigbee2mqtt/bridge/response/device/configure",
|
||||
stringify({data: {}, status: "error", error: "Invalid payload"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should not configure when interview not completed', async () => {
|
||||
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 zhcAddExternalDefinitionSpy = vi.spyOn(zhc, 'addExternalDefinition');
|
||||
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,
|
||||
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,133 +88,313 @@ 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, qos: 0});
|
||||
});
|
||||
|
||||
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},
|
||||
);
|
||||
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenCalledTimes(2);
|
||||
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(1, 'mock-external-converter-multiple.js');
|
||||
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(2, 'mock-external-converter.js');
|
||||
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(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(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, qos: 0},
|
||||
);
|
||||
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(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, qos: 0},
|
||||
);
|
||||
|
||||
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.1.js", code: converterCode},
|
||||
]),
|
||||
{retain: true, qos: 0},
|
||||
);
|
||||
expect(zhcAddExternalDefinitionSpy).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
mock: true,
|
||||
zigbeeModel: ["external_converter_device"],
|
||||
vendor: "external",
|
||||
model: "external_converter_device",
|
||||
description: "external/converter/edited",
|
||||
externalConverterName: "mock-external-converter.1.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.1.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.2.js", code: converterCode},
|
||||
]),
|
||||
{retain: true, qos: 0},
|
||||
);
|
||||
expect(zhcAddExternalDefinitionSpy).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
mock: true,
|
||||
zigbeeModel: ["external_converter_device"],
|
||||
vendor: "external",
|
||||
model: "external_converter_device",
|
||||
description: "external/converter",
|
||||
externalConverterName: "mock-external-converter.2.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, qos: 0},
|
||||
);
|
||||
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 () => {
|
||||
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',
|
||||
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,
|
||||
@@ -225,133 +403,212 @@ describe('Extension: ExternalConverters', () => {
|
||||
);
|
||||
|
||||
//-- 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`;
|
||||
|
||||
zhcAddExternalDefinitionSpy.mockImplementationOnce(() => {
|
||||
throw new Error(errorMsg);
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/converters", stringify([]), {retain: true, qos: 0});
|
||||
});
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode}));
|
||||
await flushPromises();
|
||||
it("MJS: saves and removes", async () => {
|
||||
const converterName = "foo.mjs";
|
||||
const converterCode = getFileCode("mjs", "mock-external-converter.mjs");
|
||||
|
||||
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);
|
||||
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
|
||||
description: "Automatically generated definition",
|
||||
model: "external_converter_device",
|
||||
vendor: "",
|
||||
zigbeeModel: ["external_converter_device"],
|
||||
});
|
||||
|
||||
await resetExtension();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
//-- SAVE
|
||||
await (controller.getExtension("ExternalConverters")! as ExternalConverters).onMQTTMessage({
|
||||
topic: "zigbee2mqtt/bridge/request/converter/save",
|
||||
message: {name: converterName, code: converterCode},
|
||||
});
|
||||
|
||||
//-- SAVE
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode}));
|
||||
await flushPromises();
|
||||
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
|
||||
description: "external/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,
|
||||
},
|
||||
);
|
||||
|
||||
const errorMsg = `Failed to remove definition`;
|
||||
//-- REMOVE
|
||||
await (controller.getExtension("ExternalConverters")! as ExternalConverters).onMQTTMessage({
|
||||
topic: "zigbee2mqtt/bridge/request/converter/remove",
|
||||
message: {name: converterName},
|
||||
});
|
||||
|
||||
zhcRemoveExternalDefinitionsSpy.mockImplementationOnce(() => {
|
||||
throw new Error(errorMsg);
|
||||
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, qos: 0});
|
||||
});
|
||||
|
||||
//-- REMOVE
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/remove', stringify({name: converterName}));
|
||||
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/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/save",
|
||||
message: {name: converterName, code: converterCode},
|
||||
});
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: 'test.js', transaction: 1 /* code */}));
|
||||
await flushPromises();
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/converter/save",
|
||||
expect.stringContaining(`"error":"${converterName} contains invalid code`),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
expect(writeFileSyncSpy).toHaveBeenCalledWith(expect.stringContaining(converterName), converterCode, "utf8");
|
||||
expect(rmSyncSpy).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 removal", async () => {
|
||||
const converterName = "foo2.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},
|
||||
);
|
||||
await (controller.getExtension("ExternalConverters")! as ExternalConverters).onMQTTMessage({
|
||||
topic: "zigbee2mqtt/bridge/request/converter/remove",
|
||||
message: {name: converterName},
|
||||
});
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/response/converter/remove",
|
||||
expect.stringContaining("doesn't exists"),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
expect(rmSyncSpy).not.toHaveBeenCalledWith(expect.stringContaining(converterName), {force: true});
|
||||
});
|
||||
|
||||
it("returns error on invalid definition", async () => {
|
||||
const converterName = "foo3.js";
|
||||
const converterCode = getFileCode("cjs", "mock-external-converter.js");
|
||||
|
||||
await resetExtension();
|
||||
for (const mock of mocksClear) mock.mockClear();
|
||||
|
||||
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), {
|
||||
retain: false,
|
||||
qos: 0,
|
||||
});
|
||||
expect(writeFileSyncSpy).toHaveBeenCalledWith(expect.stringContaining(converterName), converterCode, "utf8");
|
||||
expect(rmSyncSpy).toHaveBeenCalledWith(expect.stringContaining(converterName), {force: true});
|
||||
});
|
||||
|
||||
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}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
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}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
|
||||
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}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,103 +29,219 @@ 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, qos: 0});
|
||||
});
|
||||
|
||||
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", {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example2/extension", "call2 from start", {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from constructor", {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start", {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/extensions',
|
||||
"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},
|
||||
);
|
||||
});
|
||||
|
||||
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", {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example2/extension", "call2 from start", {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from constructor", {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start", {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/extensions",
|
||||
stringify([
|
||||
{name: "example2Extension.mjs", code: getFileCode("mjs", "example2Extension.mjs")},
|
||||
{name: "exampleExtension.mjs", code: getFileCode("mjs", "exampleExtension.mjs")},
|
||||
]),
|
||||
{retain: true, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it("starts extensions only once", async () => {
|
||||
useAssets("mjs");
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension/counter", "start 0", {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).not.toHaveBeenCalledWith("zigbee2mqtt/example/extension/counter", "start 1", {retain: false, qos: 0});
|
||||
});
|
||||
|
||||
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, qos: 0},
|
||||
);
|
||||
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", {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example2/extension", "call2 from start", {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from constructor", {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start", {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/extensions",
|
||||
stringify([
|
||||
{name: "example2Extension.js", code: getFileCode("cjs", "example2Extension.js")},
|
||||
{name: extensionName, code: extensionCode},
|
||||
]),
|
||||
{retain: true, qos: 0},
|
||||
);
|
||||
|
||||
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", {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start - edited", {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/extensions",
|
||||
stringify([
|
||||
{name: "example2Extension.js", code: getFileCode("cjs", "example2Extension.js")},
|
||||
{name: "exampleExtension.1.js", code: extensionCode},
|
||||
]),
|
||||
{retain: true, qos: 0},
|
||||
);
|
||||
|
||||
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.1.js", code: extensionCode},
|
||||
});
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from stop - edited", {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start", {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/extensions",
|
||||
stringify([
|
||||
{name: "example2Extension.js", code: getFileCode("cjs", "example2Extension.js")},
|
||||
{name: "exampleExtension.2.js", code: extensionCode},
|
||||
]),
|
||||
{retain: true, qos: 0},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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", {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start", {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/extensions",
|
||||
stringify([{name: extensionName, code: extensionCode}]),
|
||||
{
|
||||
retain: true,
|
||||
@@ -136,72 +250,117 @@ describe('Extension: ExternalExtensions', () => {
|
||||
);
|
||||
|
||||
//-- 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", {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/extensions", stringify([]), {retain: true, qos: 0});
|
||||
});
|
||||
|
||||
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", {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/example/extension", "call from start", {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
"zigbee2mqtt/bridge/extensions",
|
||||
stringify([{name: extensionName, code: extensionCode}]),
|
||||
{
|
||||
retain: true,
|
||||
qos: 0,
|
||||
},
|
||||
);
|
||||
|
||||
//-- REMOVE
|
||||
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", {retain: false, qos: 0});
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/extensions", stringify([]), {retain: true, qos: 0});
|
||||
});
|
||||
|
||||
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`),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
expect(writeFileSyncSpy).toHaveBeenCalledWith(expect.stringContaining(extensionName), extensionCode, "utf8");
|
||||
expect(rmSyncSpy).toHaveBeenCalledWith(expect.stringContaining(extensionName), {force: true});
|
||||
});
|
||||
|
||||
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"),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
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}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
|
||||
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}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error on invalid code', async () => {
|
||||
const extensionName = 'foo.js';
|
||||
const extensionCode = 'definetly not a correct javascript code';
|
||||
const extensionFilePath = path.join(mockBasePath, extensionName);
|
||||
|
||||
await resetExtension();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: extensionName, code: extensionCode}));
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/extension/save',
|
||||
expect.stringContaining(`"error":"${extensionName} contains invalid code`),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
expect(writeFileSyncSpy).not.toHaveBeenCalledWith(extensionFilePath, extensionCode, 'utf8');
|
||||
});
|
||||
|
||||
it('returns error on invalid removal', async () => {
|
||||
const converterName = 'invalid.js';
|
||||
const converterFilePath = path.join(mockBasePath, converterName);
|
||||
|
||||
await resetExtension();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/remove', stringify({name: converterName}));
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/extension/remove',
|
||||
stringify({data: {}, status: 'error', error: `${converterName} (${converterFilePath}) doesn't exists`}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
expect(rmSyncSpy).not.toHaveBeenCalledWith(converterFilePath, {force: true});
|
||||
});
|
||||
|
||||
it('handles invalid payloads', async () => {
|
||||
await resetExtension();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: 'test.js', transaction: 1 /* code */}));
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/extension/save',
|
||||
stringify({data: {}, status: 'error', error: `Invalid payload`, transaction: 1}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/remove', stringify({namex: 'test.js', transaction: 2}));
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/extension/remove',
|
||||
stringify({data: {}, status: 'error', error: `Invalid payload`, transaction: 2}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+138
-109
@@ -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 {mockMQTTPublishAsync} from "../mocks/mqtt";
|
||||
import {type EventHandler, flushPromises} from "../mocks/utils";
|
||||
import {devices} 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,58 +195,58 @@ 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);
|
||||
|
||||
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,
|
||||
@@ -254,21 +255,21 @@ describe('Extension: Frontend', () => {
|
||||
{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',
|
||||
state: "ON",
|
||||
effect: null,
|
||||
power_on_behavior: null,
|
||||
linkquality: null,
|
||||
@@ -279,76 +280,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("Authentification", async () => {
|
||||
const authToken = "sample-secure-token";
|
||||
settings.set(["frontend", "auth_token"], authToken);
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
await controller.start();
|
||||
|
||||
const mockSocket = {destroy: vi.fn()};
|
||||
mockHTTPEvents.upgrade({url: '/api'}, mockSocket, mockWSocket);
|
||||
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 +362,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 +421,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);
|
||||
});
|
||||
});
|
||||
|
||||
+243
-241
File diff suppressed because it is too large
Load Diff
+1289
-1196
File diff suppressed because it is too large
Load Diff
+270
-253
@@ -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"}),
|
||||
"zigbee2mqtt/bridge/response/networkmap",
|
||||
stringify({data: {}, status: "error", error: "Type 'not_existing' not supported, allowed are: raw,graphviz,plantuml"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
+145
-65
@@ -1,101 +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();
|
||||
// @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', () => {
|
||||
returnDevices.push(devices.bulb.ieeeAddr, devices.LIVOLO.ieeeAddr);
|
||||
|
||||
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 () => {
|
||||
const mappedLivolo = (await zhc.findByDevice(devices.LIVOLO))!;
|
||||
mappedLivolo.onEvent = mockLivoloOnEvent;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
|
||||
controller = new Controller(vi.fn(), vi.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
+441
-146
@@ -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,93 +112,110 @@ 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, qos: 0});
|
||||
});
|
||||
|
||||
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)"}),
|
||||
"zigbee2mqtt/bridge/response/device/ota_update/update",
|
||||
stringify({data: {}, status: "error", error: "Update of 'bulb' failed (Update failed)"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should be able to check if OTA update is available', async () => {
|
||||
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)"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
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'}),
|
||||
"zigbee2mqtt/bridge/response/device/ota_update/check",
|
||||
stringify({data: {id: "bulb", update_available: false}, status: "ok"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
isUpdateAvailableSpy.mockResolvedValueOnce({available: true, currentFileVersion: 10, otaFileVersion: 12});
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/ota_update/check', 'bulb');
|
||||
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'}),
|
||||
"zigbee2mqtt/bridge/response/device/ota_update/check",
|
||||
stringify({data: {id: "bulb", update_available: true}, status: "ok"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
isUpdateAvailableSpy.mockResolvedValueOnce({available: false, currentFileVersion: 10, otaFileVersion: 10});
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/ota_update/check/downgrade', 'bulb');
|
||||
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'}),
|
||||
"zigbee2mqtt/bridge/response/device/ota_update/check",
|
||||
stringify({data: {id: "bulb", update_available: false}, status: "ok"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
|
||||
@@ -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'}),
|
||||
"zigbee2mqtt/bridge/response/device/ota_update/check",
|
||||
stringify({data: {id: "bulb", update_available: true}, status: "ok"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
|
||||
device.definition = originalDefinition;
|
||||
});
|
||||
|
||||
it('Should handle if OTA update check fails', async () => {
|
||||
isUpdateAvailableSpy.mockRejectedValueOnce(new Error('RF signals disturbed because of dogs barking'));
|
||||
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`}),
|
||||
"zigbee2mqtt/bridge/response/device/ota_update/check",
|
||||
stringify({data: {}, status: "error", error: `Device 'not_existing_deviceooo' does not exist`}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should not check for OTA when device does not support it', async () => {
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/ota_update/check', 'dimmer_wall_switch');
|
||||
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`}),
|
||||
"zigbee2mqtt/bridge/response/device/ota_update/check",
|
||||
stringify({data: {}, status: "error", error: `Device 'dimmer_wall_switch' does not support OTA updates`}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Should refuse to check/update when already in progress', async () => {
|
||||
isUpdateAvailableSpy.mockImplementationOnce(
|
||||
// @ts-expect-error mocked as needed
|
||||
async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => resolve(), 99999);
|
||||
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'`}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
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'`}),
|
||||
"zigbee2mqtt/bridge/response/device/ota_update/update",
|
||||
stringify({data: {id: "bulb", from: undefined, to: undefined}, status: "ok"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Shouldnt crash when read modelID before/after OTA update fails', async () => {
|
||||
devices.bulb.endpoints[0].read.mockRejectedValueOnce('Failed from').mockRejectedValueOnce('Failed to');
|
||||
updateSpy.mockImplementationOnce(vi.fn());
|
||||
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"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
|
||||
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, qos: 0});
|
||||
});
|
||||
|
||||
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"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
|
||||
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"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
|
||||
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, qos: 0});
|
||||
});
|
||||
|
||||
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"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
|
||||
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"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
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"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
|
||||
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"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
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"}});
|
||||
});
|
||||
});
|
||||
|
||||
+639
-635
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");
|
||||
});
|
||||
});
|
||||
|
||||
+167
-165
@@ -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 (default setting)', () => {
|
||||
it("Should cleanup (default setting)", () => {
|
||||
for (const d of fs.readdirSync(dir.name)) {
|
||||
rimrafSync(path.join(dir.name, d));
|
||||
}
|
||||
@@ -60,7 +60,7 @@ describe('Logger', () => {
|
||||
expect(fs.readdirSync(dir.name).length).toBe(10);
|
||||
});
|
||||
|
||||
it('Should cleanup (15 folders setting)', () => {
|
||||
it("Should cleanup (15 folders setting)", () => {
|
||||
for (const d of fs.readdirSync(dir.name)) {
|
||||
rimrafSync(path.join(dir.name, d));
|
||||
}
|
||||
@@ -69,13 +69,13 @@ describe('Logger', () => {
|
||||
fs.mkdirSync(path.join(dir.name, `log_${i}`));
|
||||
}
|
||||
|
||||
settings.set(['advanced', 'log_directories_to_keep'], 15);
|
||||
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', () => {
|
||||
it("Should not cleanup when there is no timestamp set", () => {
|
||||
for (const d of fs.readdirSync(dir.name)) {
|
||||
rimrafSync(path.join(dir.name, d));
|
||||
}
|
||||
@@ -84,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 {}
|
||||
}
|
||||
@@ -120,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;
|
||||
}
|
||||
|
||||
@@ -186,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++);
|
||||
}
|
||||
|
||||
@@ -226,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();
|
||||
}
|
||||
@@ -238,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);
|
||||
});
|
||||
});
|
||||
|
||||
+153
-141
@@ -1,248 +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',
|
||||
"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",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -250,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 {
|
||||
@@ -258,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 {
|
||||
@@ -268,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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -287,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,
|
||||
|
||||
+12
-11
@@ -1,15 +1,16 @@
|
||||
import type {LogLevel} from 'lib/util/settings';
|
||||
import type Transport from 'winston-transport';
|
||||
import type Transport from "winston-transport";
|
||||
|
||||
let level: LogLevel = 'info';
|
||||
let debugNamespaceIgnore: string = '';
|
||||
import type {LogLevel} from "../../lib/util/settings";
|
||||
|
||||
let level: LogLevel = "info";
|
||||
let debugNamespaceIgnore = "";
|
||||
let namespacedLevels: Record<string, LogLevel> = {};
|
||||
let transports: Transport[] = [];
|
||||
let transportsEnabled: boolean = false;
|
||||
let transportsEnabled = false;
|
||||
const getMessage = (messageOrLambda: string | (() => string)): string => (messageOrLambda instanceof Function ? messageOrLambda() : messageOrLambda);
|
||||
|
||||
export const mockLogger = {
|
||||
log: vi.fn().mockImplementation((level, message, namespace = 'z2m') => {
|
||||
log: vi.fn().mockImplementation((level, message, namespace = "z2m") => {
|
||||
if (transportsEnabled) {
|
||||
for (const transport of transports) {
|
||||
transport.log!({level, message, namespace}, () => {});
|
||||
@@ -17,10 +18,10 @@ export const mockLogger = {
|
||||
}
|
||||
}),
|
||||
init: vi.fn(),
|
||||
info: vi.fn().mockImplementation((messageOrLambda, namespace = 'z2m') => mockLogger.log('info', getMessage(messageOrLambda), namespace)),
|
||||
warning: vi.fn().mockImplementation((messageOrLambda, namespace = 'z2m') => mockLogger.log('warning', getMessage(messageOrLambda), namespace)),
|
||||
error: vi.fn().mockImplementation((messageOrLambda, namespace = 'z2m') => mockLogger.log('error', getMessage(messageOrLambda), namespace)),
|
||||
debug: vi.fn().mockImplementation((messageOrLambda, namespace = 'z2m') => mockLogger.log('debug', getMessage(messageOrLambda), namespace)),
|
||||
info: vi.fn().mockImplementation((messageOrLambda, namespace = "z2m") => mockLogger.log("info", getMessage(messageOrLambda), namespace)),
|
||||
warning: vi.fn().mockImplementation((messageOrLambda, namespace = "z2m") => mockLogger.log("warning", getMessage(messageOrLambda), namespace)),
|
||||
error: vi.fn().mockImplementation((messageOrLambda, namespace = "z2m") => mockLogger.log("error", getMessage(messageOrLambda), namespace)),
|
||||
debug: vi.fn().mockImplementation((messageOrLambda, namespace = "z2m") => mockLogger.log("debug", getMessage(messageOrLambda), namespace)),
|
||||
cleanup: vi.fn(),
|
||||
logOutput: vi.fn(),
|
||||
add: (transport: Transport): void => {
|
||||
@@ -50,6 +51,6 @@ export const mockLogger = {
|
||||
end: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('../../lib/util/logger', () => ({
|
||||
vi.mock("../../lib/util/logger", () => ({
|
||||
default: mockLogger,
|
||||
}));
|
||||
|
||||
+9
-12
@@ -1,16 +1,13 @@
|
||||
import type {IClientPublishOptions} from 'mqtt';
|
||||
import type {IClientPublishOptions} from "mqtt";
|
||||
|
||||
import {EventHandler} from './utils';
|
||||
import type {EventHandler} from "./utils";
|
||||
|
||||
export const events: Record<string, EventHandler> = {};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const mockMQTTPublishAsync = vi.fn(async (topic: string, message: string, opts?: IClientPublishOptions): Promise<void> => {});
|
||||
export const mockMQTTPublishAsync = vi.fn(async (_topic: string, _message: string, _opts?: IClientPublishOptions): Promise<void> => {});
|
||||
export const mockMQTTEndAsync = vi.fn(async (): Promise<void> => {});
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const mockMQTTSubscribeAsync = vi.fn(async (topicObject: string): Promise<void> => {});
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const mockMQTTUnsubscribeAsync = vi.fn(async (topic: string): Promise<void> => {});
|
||||
export const mockMQTTSubscribeAsync = vi.fn(async (_topicObject: string): Promise<void> => {});
|
||||
export const mockMQTTUnsubscribeAsync = vi.fn(async (_topic: string): Promise<void> => {});
|
||||
|
||||
export const mockMQTTConnectAsync = vi.fn(() => ({
|
||||
reconnecting: false,
|
||||
@@ -20,9 +17,9 @@ export const mockMQTTConnectAsync = vi.fn(() => ({
|
||||
endAsync: mockMQTTEndAsync,
|
||||
subscribeAsync: mockMQTTSubscribeAsync,
|
||||
unsubscribeAsync: mockMQTTUnsubscribeAsync,
|
||||
on: vi.fn((type, handler) => {
|
||||
if (type === 'connect') {
|
||||
handler();
|
||||
on: vi.fn(async (type, handler) => {
|
||||
if (type === "connect") {
|
||||
await handler();
|
||||
}
|
||||
|
||||
events[type] = handler;
|
||||
@@ -30,6 +27,6 @@ export const mockMQTTConnectAsync = vi.fn(() => ({
|
||||
stream: {setMaxListeners: vi.fn()},
|
||||
}));
|
||||
|
||||
vi.mock('mqtt', () => ({
|
||||
vi.mock("mqtt", () => ({
|
||||
connectAsync: mockMQTTConnectAsync,
|
||||
}));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user