Compare commits

...
76 Commits
Author SHA1 Message Date
Koen Kanters c5c07e7dfe chore(dev): release 2.2.0 (#26565) 2025-04-01 20:43:39 +02:00
Koen Kanters e02bbdcf0b fix(ignore): update dependencies (#26911) 2025-04-01 20:37:25 +02:00
Koen Kanters 69f0840feb fix(ignore): update zigbee-herdsman-converters to 23.18.0 (#26933) 2025-04-01 18:30:07 +00:00
Koen Kanters ea04f19709 fix(ignore): update zigbee-herdsman-converters to 23.17.0 (#26925) 2025-03-31 19:14:49 +00:00
Koen Kanters 9fe38b1677 fix(ignore): update zigbee-herdsman to 3.4.11 (#26924) 2025-03-31 18:50:39 +00:00
Koen Kanters 6e6a989159 fix(ignore): update zigbee-herdsman-converters to 23.16.0 (#26918) 2025-03-30 19:59:11 +00:00
Koen Kanters b52cfd87c3 fix(ignore): update zigbee-herdsman to 3.4.10 (#26908) 2025-03-29 17:23:23 +00:00
Koen Kanters 516425b44b fix(ignore): update zigbee-herdsman-converters to 23.15.0 (#26900) 2025-03-28 19:39:14 +00:00
Koen Kanters 6a65769060 fix(ignore): update zigbee-herdsman-converters to 23.14.0 (#26886) 2025-03-27 19:27:28 +00:00
Koen Kanters 0a41af5dd2 fix(ignore): update zigbee-herdsman to 3.4.9 (#26877) 2025-03-26 22:18:27 +00:00
clockbrain 267c399047 fix: Add device name to no converter available error message (#26867) 2025-03-26 21:45:33 +01:00
Koen Kanters f824295b7e fix(ignore): update zigbee-herdsman to 3.4.8 (#26866) 2025-03-25 22:50:21 +00:00
Koen Kanters 341c453657 fix(ignore): Fix HA addon onboarding (#26862) 2025-03-25 22:06:00 +01:00
Koen Kanters c5c7f70ad6 fix(ignore): update zigbee-herdsman-converters to 23.13.0 (#26861) 2025-03-25 19:41:18 +00:00
Koen Kanters 440e3a0094 fix(ignore): update zigbee-herdsman to 3.4.7 (#26859) 2025-03-25 18:50:46 +00:00
Koen Kanters 53e8226f0a fix(ignore): update zigbee-herdsman-converters to 23.12.0 (#26835) 2025-03-23 21:06:27 +00:00
Koen Kanters 7a1621a906 fix(ignore): fix EACCES: permission denied, mkdir '/app/dist/external_converters' 2025-03-23 22:02:17 +01:00
Koen Kanters 9e0c84beed fix(ignore): update zigbee-herdsman to 3.4.6 (#26834) 2025-03-23 20:59:26 +00:00
Koen Kanters 5f9b49d66f Merge remote-tracking branch 'origin/master' into dev 2025-03-23 21:54:59 +01:00
Koen Kanters 93d0ee44ba fix(ignore): update dependencies (#26833) 2025-03-23 21:46:52 +01:00
Koen Kanters fc35ac5737 chore: fix update-dependencies 2025-03-23 21:44:40 +01:00
Koen Kanters 2b90d7b3b9 fix(ignore): update zigbee-herdsman to 3.4.5 (#26826) 2025-03-22 19:24:20 +00:00
Nerivec f2a1c9a2be fix(gnore): Onboarding fixes (#26824) 2025-03-22 16:11:06 +01:00
Koen Kanters 0714582eb9 fix(ignore): update zigbee-herdsman to 3.4.4 (#26821) 2025-03-21 21:46:26 +00:00
Koen Kanters 5288549744 fix(ignore): update zigbee-herdsman-converters to 23.11.0 (#26820) 2025-03-21 20:36:17 +00:00
Nerivec 05d6f08a0d feat: Onboarding (#26608) 2025-03-21 21:30:19 +01:00
Koen Kanters 6f1266fd0e chore: remove docker-compose.yml 2025-03-21 21:10:30 +01:00
Koen Kantersandnurikk 9792466aac fix(ignore): update zigbee2mqtt-frontend to 0.9.5 (#26814)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2025-03-21 10:52:52 +00:00
Koen Kanters 52c255bcc2 fix(ignore): update zigbee-herdsman-converters to 23.10.0 (#26792) 2025-03-20 20:35:53 +00:00
Nerivec badf107991 feat: Use dynamic import for external JS (#26522) 2025-03-20 21:08:32 +01:00
Simon f872a5180c feat: Frontend: support filtering notifications (#26805) 2025-03-20 20:59:58 +01:00
Koen Kanters 4f2b052016 fix(ignore): update zigbee-herdsman to 3.4.3 (#26802) 2025-03-19 20:30:37 +00:00
Koen Kanters ef5b3de5e2 chore: enable provenance for release Docker image 2025-03-18 21:20:07 +01:00
Koen Kanters ff7b09eb99 chore: fix provenance 2025-03-18 21:16:36 +01:00
Koen Kanters 1baafbb6cf chore: enable provenance for NPM package 2025-03-18 20:54:49 +01:00
Koen Kanters 17ce9f4c52 chore: enable provenance for NPM package 2025-03-18 20:52:05 +01:00
Koen Kanters e6ce7ecdc1 chore: disable automatic pnpm updates 2025-03-18 20:50:24 +01:00
Koen Kanters 36ba1350a9 chore: remove codeql (enable basic) 2025-03-18 20:45:50 +01:00
Koen Kanters 582bf7d2c6 fix(ignore): update zigbee-herdsman to 3.4.2 (#26789) 2025-03-18 19:18:19 +00:00
Koen Kanters 46fd04364d fix(ignore): update zigbee-herdsman to 3.4.1 (#26779) 2025-03-17 20:22:38 +00:00
Alexander D. Kanevskiy 747f6ac476 fix: Home Assistant: fix electrical measurements sensors category (#26769) 2025-03-17 21:03:05 +01:00
Ömer Güven 6e781eafae fix(ignore): Make update check less verbose (#26774) 2025-03-17 20:43:56 +01:00
Koen Kanters 325199d445 fix(ignore): update zigbee-herdsman-converters to 23.8.0 (#26761) 2025-03-16 22:16:34 +01:00
Koen Kanters e602dc849a fix(ignore): update dependencies (#26751) 2025-03-16 22:09:37 +01:00
Koen Kanters ad0a222df1 Merge branch 'master' into dev 2025-03-16 21:58:34 +01:00
Koen Kanters 6d6372ccf0 chore: Skip updating dependencies on forks 2025-03-16 21:27:40 +01:00
NerivecandKoen Kanters 92955c02a4 feat: Support ZigBee on Host adapter (#26742)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2025-03-15 08:36:52 +01:00
Koen Kanters e5a80e3b1b fix(ignore): update zigbee-herdsman to 3.4.0 (#26744) 2025-03-15 07:26:13 +00:00
Ömer Güven fb48bd0cb9 fix(ignore): Fix update check (#26723) 2025-03-15 08:09:55 +01:00
Nerivec db578d9d11 fix: Use dynamic import for optional extensions (#26735) 2025-03-15 08:07:56 +01:00
Koen Kanters 8d2ef6df06 fix(ignore): update zigbee-herdsman-converters to 23.7.0 (#26733) 2025-03-13 19:13:26 +00:00
Koen Kanters 5db115057e fix(ignore): Module NodeNext (#26719) 2025-03-12 21:37:32 +01:00
Koen Kanters be722a0500 fix(ignore): Move to Node16 module (#26717) 2025-03-12 20:01:11 +00:00
BlueMöhre e3985002c2 fix: Fix bridge offline message format for frontend (#26707) 2025-03-12 20:23:40 +01:00
Koen Kanters fcbb355f47 Revert "fix(ignore): Move to Node16 module (#26704)"
This reverts commit ea5e0d47a5.
2025-03-12 07:25:37 +01:00
Koen Kanters 06ba62b833 fix(ignore): update zigbee-herdsman-converters to 23.6.0 (#26691) 2025-03-11 21:55:32 +01:00
Koen Kanters ea5e0d47a5 fix(ignore): Move to Node16 module (#26704) 2025-03-11 21:51:04 +01:00
Koen Kanters 8378949e1d fix(ignore): update zigbee-herdsman to 3.3.2 (#26702) 2025-03-11 19:52:35 +00:00
Koen Kanters 78cde99469 chore: Small improvements to CI pipelines (#26690) 2025-03-10 21:09:20 +01:00
Koen Kanters a5195a6eee fix(ignore): update zigbee-herdsman to 3.3.1 (#26674) 2025-03-09 19:32:28 +00:00
Koen Kanters fb9816659b fix(ignore): update dependencies (#26662) 2025-03-09 20:22:16 +01:00
Nerivec f6f7ba68c9 fix: Write ZIGBEE2MQTT_CONFIG_* envs to yaml as overrides (#26653) 2025-03-08 14:32:52 +01:00
Ömer Güven 1b6ad46462 fix: Check if new commits are available in update.sh (#26631) 2025-03-08 14:14:25 +01:00
Koen Kanters ad32fec1d0 fix(ignore): Fix tests failing when zhc is linked via pnpm link (#26630) 2025-03-06 20:27:17 +00:00
Koen Kanters c603f42c88 fix(ignore): update zigbee-herdsman-converters to 23.4.0 (#26629) 2025-03-06 20:20:30 +00:00
Koen Kanters ed7e7c7e53 fix(ignore): update zigbee-herdsman to 3.3.0 (#26628) 2025-03-06 19:20:29 +00:00
Koen Kanters 3941692665 fix(ignore): update zigbee-herdsman-converters to 23.3.0 (#26598) 2025-03-03 20:39:41 +00:00
Nerivec 8775ceb0c1 fix: General cleanup (#26595) 2025-03-03 21:12:57 +01:00
11fb858ff2 fix(ignore): update dependencies (#26554)
Co-authored-by: Simon Rettberg <srett@users.noreply.github.com>
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
2025-03-02 22:21:46 +01:00
Koen Kanters 4fa794a639 fix(ignore): Reduce chaining in homeassistant.ts (#26582) 2025-03-02 20:47:23 +00:00
Koen Kanters 1fe2d8dae6 fix(ignore): Remove uncessary extensions spread (#26581) 2025-03-02 20:30:36 +00:00
bfeac07c4c fix: Implement systemd-notify directly (#26456)
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2025-03-02 08:59:28 +01:00
github-actions[bot] b6c0f4e5db chore: promote to dev 2025-03-02 07:58:15 +00:00
Koen Kanters ba337bd329 chore(dev): release 2.1.3 (#26550) 2025-03-02 08:44:41 +01:00
Koen Kanters 7e7057eccd fix: Home Assistant: fix brightness only lights (#26560) 2025-03-02 08:43:21 +01:00
github-actions[bot] cbcd32064c chore: promote to dev 2025-03-01 20:51:01 +00:00
72 changed files with 3641 additions and 1316 deletions
+10 -9
View File
@@ -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'
@@ -28,15 +30,13 @@ jobs:
cache: pnpm
- name: Install dependencies
run: pnpm i --frozen-lockfile
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm run build
- name: Lint
run: |
pnpm run pretty:check
pnpm run eslint
- name: Check
run: pnpm run check
- 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
-92
View File
@@ -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:
@@ -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 -1
View File
@@ -1,4 +1,4 @@
name: stale
name: Stale
on:
schedule:
@@ -1,22 +1,21 @@
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
with:
ref: dev
token: ${{ secrets.GH_TOKEN }}
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
@@ -24,10 +23,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 -1
View File
@@ -1,3 +1,3 @@
{
".": "2.1.2"
".": "2.2.0"
}
+75
View File
@@ -1,5 +1,80 @@
# Changelog
## [2.2.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.1.3...2.2.0) (2025-04-01)
### Features
* Frontend: support filtering notifications ([#26805](https://github.com/Koenkk/zigbee2mqtt/issues/26805)) ([f872a51](https://github.com/Koenkk/zigbee2mqtt/commit/f872a5180c5618b7d3e7c0d11f1283a454dcbc6b))
* Onboarding ([#26608](https://github.com/Koenkk/zigbee2mqtt/issues/26608)) ([05d6f08](https://github.com/Koenkk/zigbee2mqtt/commit/05d6f08a0dd6a5097b7d19b8de3b39e37e9a8533))
* Support ZigBee on Host adapter ([#26742](https://github.com/Koenkk/zigbee2mqtt/issues/26742)) ([92955c0](https://github.com/Koenkk/zigbee2mqtt/commit/92955c02a4de09cbed79249db8bf0e6529423b93))
* Use dynamic import for external JS ([#26522](https://github.com/Koenkk/zigbee2mqtt/issues/26522)) ([badf107](https://github.com/Koenkk/zigbee2mqtt/commit/badf1079919baa06f160a42c34e9f742a9b584c9))
### Bug Fixes
* Add device name to no converter available error message ([#26867](https://github.com/Koenkk/zigbee2mqtt/issues/26867)) ([267c399](https://github.com/Koenkk/zigbee2mqtt/commit/267c3990474dcc646e6987a108867a54b8b940db))
* Check if new commits are available in update.sh ([#26631](https://github.com/Koenkk/zigbee2mqtt/issues/26631)) ([1b6ad46](https://github.com/Koenkk/zigbee2mqtt/commit/1b6ad46462681b8f223a6de54c5996d0e8f66f64))
* Fix bridge offline message format for frontend ([#26707](https://github.com/Koenkk/zigbee2mqtt/issues/26707)) ([e398500](https://github.com/Koenkk/zigbee2mqtt/commit/e3985002c2bf374c2edd82e95aaa2da7c920b464))
* General cleanup ([#26595](https://github.com/Koenkk/zigbee2mqtt/issues/26595)) ([8775ceb](https://github.com/Koenkk/zigbee2mqtt/commit/8775ceb0c1d2bac552a345751935d0191410bfa3))
* **gnore:** Onboarding fixes ([#26824](https://github.com/Koenkk/zigbee2mqtt/issues/26824)) ([f2a1c9a](https://github.com/Koenkk/zigbee2mqtt/commit/f2a1c9a2be052ec46d845299d73ff1495d4ea4e1))
* Home Assistant: fix electrical measurements sensors category ([#26769](https://github.com/Koenkk/zigbee2mqtt/issues/26769)) ([747f6ac](https://github.com/Koenkk/zigbee2mqtt/commit/747f6ac4767522a51fb5e5cc0b796b3a92243476))
* **ignore:** fix `EACCES: permission denied, mkdir '/app/dist/external_converters'` ([7a1621a](https://github.com/Koenkk/zigbee2mqtt/commit/7a1621a9060cd0109643655df2b2833e046158fa))
* **ignore:** Fix HA addon onboarding ([#26862](https://github.com/Koenkk/zigbee2mqtt/issues/26862)) ([341c453](https://github.com/Koenkk/zigbee2mqtt/commit/341c453657d74aef61e4a44b24d081fab9671d39))
* **ignore:** Fix tests failing when zhc is linked via `pnpm link` ([#26630](https://github.com/Koenkk/zigbee2mqtt/issues/26630)) ([ad32fec](https://github.com/Koenkk/zigbee2mqtt/commit/ad32fec1d02f2d43b7dc18bf38bbd14f7c97ffde))
* **ignore:** Fix update check ([#26723](https://github.com/Koenkk/zigbee2mqtt/issues/26723)) ([fb48bd0](https://github.com/Koenkk/zigbee2mqtt/commit/fb48bd0cb9a0311302d82a7f1b9cec21a585799e))
* **ignore:** Make update check less verbose ([#26774](https://github.com/Koenkk/zigbee2mqtt/issues/26774)) ([6e781ea](https://github.com/Koenkk/zigbee2mqtt/commit/6e781eafaefa54fad24ed857e1f96261ff3bebb7))
* **ignore:** Module `NodeNext` ([#26719](https://github.com/Koenkk/zigbee2mqtt/issues/26719)) ([5db1150](https://github.com/Koenkk/zigbee2mqtt/commit/5db115057ef733a33575c889e981696d2b9e74b9))
* **ignore:** Move to `Node16` `module` ([#26704](https://github.com/Koenkk/zigbee2mqtt/issues/26704)) ([ea5e0d4](https://github.com/Koenkk/zigbee2mqtt/commit/ea5e0d47a599ac66dcf4671e7a8b0687d8a08b85))
* **ignore:** Move to Node16 module ([#26717](https://github.com/Koenkk/zigbee2mqtt/issues/26717)) ([be722a0](https://github.com/Koenkk/zigbee2mqtt/commit/be722a0500892e6787d669ceb10c7d0bfc1c7e4c))
* **ignore:** Reduce chaining in `homeassistant.ts` ([#26582](https://github.com/Koenkk/zigbee2mqtt/issues/26582)) ([4fa794a](https://github.com/Koenkk/zigbee2mqtt/commit/4fa794a6392917d0140b94257bb7cd155fc25936))
* **ignore:** Remove uncessary extensions spread ([#26581](https://github.com/Koenkk/zigbee2mqtt/issues/26581)) ([1fe2d8d](https://github.com/Koenkk/zigbee2mqtt/commit/1fe2d8dae6ace27c4b297b7943ac5501c096e8ab))
* **ignore:** update dependencies ([#26554](https://github.com/Koenkk/zigbee2mqtt/issues/26554)) ([11fb858](https://github.com/Koenkk/zigbee2mqtt/commit/11fb858ff2819319f8aa4bd8a6b2f25944619a8e))
* **ignore:** update dependencies ([#26662](https://github.com/Koenkk/zigbee2mqtt/issues/26662)) ([fb98166](https://github.com/Koenkk/zigbee2mqtt/commit/fb9816659bec15d0a9a39f73d4900fc7586a378e))
* **ignore:** update dependencies ([#26751](https://github.com/Koenkk/zigbee2mqtt/issues/26751)) ([e602dc8](https://github.com/Koenkk/zigbee2mqtt/commit/e602dc849a1d17b9097ce06015ba587c59ba5006))
* **ignore:** update dependencies ([#26833](https://github.com/Koenkk/zigbee2mqtt/issues/26833)) ([93d0ee4](https://github.com/Koenkk/zigbee2mqtt/commit/93d0ee44ba4445711b7e43ab4bfa06e8ac266d68))
* **ignore:** update dependencies ([#26911](https://github.com/Koenkk/zigbee2mqtt/issues/26911)) ([e02bbdc](https://github.com/Koenkk/zigbee2mqtt/commit/e02bbdcf0bbf8577ab6e138c915f7e2ef1a32482))
* **ignore:** update zigbee-herdsman to 3.3.0 ([#26628](https://github.com/Koenkk/zigbee2mqtt/issues/26628)) ([ed7e7c7](https://github.com/Koenkk/zigbee2mqtt/commit/ed7e7c7e530010aeb44eb71e50f31145c7285c26))
* **ignore:** update zigbee-herdsman to 3.3.1 ([#26674](https://github.com/Koenkk/zigbee2mqtt/issues/26674)) ([a5195a6](https://github.com/Koenkk/zigbee2mqtt/commit/a5195a6eee9a3e852709c4b979c965dee33f2b54))
* **ignore:** update zigbee-herdsman to 3.3.2 ([#26702](https://github.com/Koenkk/zigbee2mqtt/issues/26702)) ([8378949](https://github.com/Koenkk/zigbee2mqtt/commit/8378949e1d9cdfb3d33bc771dc61594ca7ef3c05))
* **ignore:** update zigbee-herdsman to 3.4.0 ([#26744](https://github.com/Koenkk/zigbee2mqtt/issues/26744)) ([e5a80e3](https://github.com/Koenkk/zigbee2mqtt/commit/e5a80e3b1bf8ab5f693d248f2b6a90481bdb10fc))
* **ignore:** update zigbee-herdsman to 3.4.1 ([#26779](https://github.com/Koenkk/zigbee2mqtt/issues/26779)) ([46fd043](https://github.com/Koenkk/zigbee2mqtt/commit/46fd04364ded0b6f7c9fcea6f33a2b523971b389))
* **ignore:** update zigbee-herdsman to 3.4.10 ([#26908](https://github.com/Koenkk/zigbee2mqtt/issues/26908)) ([b52cfd8](https://github.com/Koenkk/zigbee2mqtt/commit/b52cfd87c3deedfa0d1d391ce4f6f64083389559))
* **ignore:** update zigbee-herdsman to 3.4.11 ([#26924](https://github.com/Koenkk/zigbee2mqtt/issues/26924)) ([9fe38b1](https://github.com/Koenkk/zigbee2mqtt/commit/9fe38b1677681b1f3b47ac6d9876fa7305572244))
* **ignore:** update zigbee-herdsman to 3.4.2 ([#26789](https://github.com/Koenkk/zigbee2mqtt/issues/26789)) ([582bf7d](https://github.com/Koenkk/zigbee2mqtt/commit/582bf7d2c6a21003e09892b569ef5fb115f5d1ab))
* **ignore:** update zigbee-herdsman to 3.4.3 ([#26802](https://github.com/Koenkk/zigbee2mqtt/issues/26802)) ([4f2b052](https://github.com/Koenkk/zigbee2mqtt/commit/4f2b052016f4d313dfc43d35aca986b682c173b0))
* **ignore:** update zigbee-herdsman to 3.4.4 ([#26821](https://github.com/Koenkk/zigbee2mqtt/issues/26821)) ([0714582](https://github.com/Koenkk/zigbee2mqtt/commit/0714582eb975c39ef0fb5205d7f2c76ce7de2a05))
* **ignore:** update zigbee-herdsman to 3.4.5 ([#26826](https://github.com/Koenkk/zigbee2mqtt/issues/26826)) ([2b90d7b](https://github.com/Koenkk/zigbee2mqtt/commit/2b90d7b3b974353c312dc00c26b6223be74e1272))
* **ignore:** update zigbee-herdsman to 3.4.6 ([#26834](https://github.com/Koenkk/zigbee2mqtt/issues/26834)) ([9e0c84b](https://github.com/Koenkk/zigbee2mqtt/commit/9e0c84beed18d13ec310d91b89e35eba46fe3817))
* **ignore:** update zigbee-herdsman to 3.4.7 ([#26859](https://github.com/Koenkk/zigbee2mqtt/issues/26859)) ([440e3a0](https://github.com/Koenkk/zigbee2mqtt/commit/440e3a00942dc8bb7a7b9b66efd825e15dccad6c))
* **ignore:** update zigbee-herdsman to 3.4.8 ([#26866](https://github.com/Koenkk/zigbee2mqtt/issues/26866)) ([f824295](https://github.com/Koenkk/zigbee2mqtt/commit/f824295b7efefb78d6f803a4291fb2a485694c96))
* **ignore:** update zigbee-herdsman to 3.4.9 ([#26877](https://github.com/Koenkk/zigbee2mqtt/issues/26877)) ([0a41af5](https://github.com/Koenkk/zigbee2mqtt/commit/0a41af5dd23d1806d703b878feaba25d90c2d016))
* **ignore:** update zigbee-herdsman-converters to 23.10.0 ([#26792](https://github.com/Koenkk/zigbee2mqtt/issues/26792)) ([52c255b](https://github.com/Koenkk/zigbee2mqtt/commit/52c255bcc2a37a7238afe675f7c521c0834e2d9d))
* **ignore:** update zigbee-herdsman-converters to 23.11.0 ([#26820](https://github.com/Koenkk/zigbee2mqtt/issues/26820)) ([5288549](https://github.com/Koenkk/zigbee2mqtt/commit/5288549744795296a9919828b1a85b1223edd755))
* **ignore:** update zigbee-herdsman-converters to 23.12.0 ([#26835](https://github.com/Koenkk/zigbee2mqtt/issues/26835)) ([53e8226](https://github.com/Koenkk/zigbee2mqtt/commit/53e8226f0aff0cdfba76f6f30672c0bd3d72d52d))
* **ignore:** update zigbee-herdsman-converters to 23.13.0 ([#26861](https://github.com/Koenkk/zigbee2mqtt/issues/26861)) ([c5c7f70](https://github.com/Koenkk/zigbee2mqtt/commit/c5c7f70ad6aaca083c4edb4dba783e60f8ec17a5))
* **ignore:** update zigbee-herdsman-converters to 23.14.0 ([#26886](https://github.com/Koenkk/zigbee2mqtt/issues/26886)) ([6a65769](https://github.com/Koenkk/zigbee2mqtt/commit/6a657690604daacc4a2cc1afa8407fd0d4dc9eaf))
* **ignore:** update zigbee-herdsman-converters to 23.15.0 ([#26900](https://github.com/Koenkk/zigbee2mqtt/issues/26900)) ([516425b](https://github.com/Koenkk/zigbee2mqtt/commit/516425b44b7f395db7bbed93699b989d6a8276be))
* **ignore:** update zigbee-herdsman-converters to 23.16.0 ([#26918](https://github.com/Koenkk/zigbee2mqtt/issues/26918)) ([6e6a989](https://github.com/Koenkk/zigbee2mqtt/commit/6e6a989159383a3534ce6d12ea8c9bdc9092d2bc))
* **ignore:** update zigbee-herdsman-converters to 23.17.0 ([#26925](https://github.com/Koenkk/zigbee2mqtt/issues/26925)) ([ea04f19](https://github.com/Koenkk/zigbee2mqtt/commit/ea04f197090e3a3bc52dc4eb4ecde65146484bfb))
* **ignore:** update zigbee-herdsman-converters to 23.18.0 ([#26933](https://github.com/Koenkk/zigbee2mqtt/issues/26933)) ([69f0840](https://github.com/Koenkk/zigbee2mqtt/commit/69f0840feb2f8a5332148cfe271d16e413a02ebf))
* **ignore:** update zigbee-herdsman-converters to 23.3.0 ([#26598](https://github.com/Koenkk/zigbee2mqtt/issues/26598)) ([3941692](https://github.com/Koenkk/zigbee2mqtt/commit/3941692665d96fee575995bb6a372bfb92e0a466))
* **ignore:** update zigbee-herdsman-converters to 23.4.0 ([#26629](https://github.com/Koenkk/zigbee2mqtt/issues/26629)) ([c603f42](https://github.com/Koenkk/zigbee2mqtt/commit/c603f42c889b5508a56f0f43df7d09dba909b0cd))
* **ignore:** update zigbee-herdsman-converters to 23.6.0 ([#26691](https://github.com/Koenkk/zigbee2mqtt/issues/26691)) ([06ba62b](https://github.com/Koenkk/zigbee2mqtt/commit/06ba62b8331d8d5db1696c47bd3b643449584b16))
* **ignore:** update zigbee-herdsman-converters to 23.7.0 ([#26733](https://github.com/Koenkk/zigbee2mqtt/issues/26733)) ([8d2ef6d](https://github.com/Koenkk/zigbee2mqtt/commit/8d2ef6df06bbfcc856a91ef592e8ac54f46a705c))
* **ignore:** update zigbee-herdsman-converters to 23.8.0 ([#26761](https://github.com/Koenkk/zigbee2mqtt/issues/26761)) ([325199d](https://github.com/Koenkk/zigbee2mqtt/commit/325199d4455adb32f912687827ed01d89e009bc1))
* **ignore:** update zigbee2mqtt-frontend to 0.9.5 ([#26814](https://github.com/Koenkk/zigbee2mqtt/issues/26814)) ([9792466](https://github.com/Koenkk/zigbee2mqtt/commit/9792466aac8b94747fe41fe47db766b7a7e93367))
* Implement systemd-notify directly ([#26456](https://github.com/Koenkk/zigbee2mqtt/issues/26456)) ([bfeac07](https://github.com/Koenkk/zigbee2mqtt/commit/bfeac07c4c38fb55db99d5f8204e85ec54552564))
* Use dynamic import for optional extensions ([#26735](https://github.com/Koenkk/zigbee2mqtt/issues/26735)) ([db578d9](https://github.com/Koenkk/zigbee2mqtt/commit/db578d9d1180c01c14f1b4707e779c28961eb5b8))
* Write `ZIGBEE2MQTT_CONFIG_*` envs to yaml as overrides ([#26653](https://github.com/Koenkk/zigbee2mqtt/issues/26653)) ([f6f7ba6](https://github.com/Koenkk/zigbee2mqtt/commit/f6f7ba68c9dd84ccdd82df0769b197756e42bbf7))
## [2.1.3](https://github.com/Koenkk/zigbee2mqtt/compare/2.1.2...2.1.3) (2025-03-02)
### Bug Fixes
* Home Assistant: fix brightness only lights ([#26560](https://github.com/Koenkk/zigbee2mqtt/issues/26560)) ([7e7057e](https://github.com/Koenkk/zigbee2mqtt/commit/7e7057eccd1fe638dd73b4a4f303c6612b73c89d))
## [2.1.2](https://github.com/Koenkk/zigbee2mqtt/compare/2.1.1...2.1.2) (2025-03-01)
+3
View File
@@ -43,6 +43,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/
-14
View File
@@ -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
-5
View File
@@ -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 "$@"
+27 -39
View File
@@ -4,7 +4,6 @@ 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();
let controller;
@@ -16,7 +15,7 @@ let unsolicitedStop = false;
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)) {
if (/^\d+(.\d+)?(,\d+(.\d+)?)*$/.test(process.env.Z2M_WATCHDOG)) {
watchdogDelays = process.env.Z2M_WATCHDOG.split(',').map((v) => 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'.`);
@@ -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,10 +77,9 @@ 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;
@@ -85,7 +89,8 @@ async function build(reason) {
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}, async (err, stdout, stderr) => {
if (err) {
process.stdout.write(', failed\n');
@@ -107,8 +112,9 @@ async function checkDist() {
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');
}
@@ -118,41 +124,23 @@ async function start() {
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');
+136 -91
View File
@@ -1,10 +1,8 @@
import type {IClientPublishOptions} from 'mqtt';
import type * as SdNotify from 'sd-notify';
import type Extension from './extension/extension';
import type {Zigbee2MQTTAPI} from './types/api';
import assert from 'node:assert';
import bind from 'bind-decorator';
import stringify from 'json-stable-stringify-without-jsonify';
@@ -12,16 +10,14 @@ import {setLogger as zhSetLogger} from 'zigbee-herdsman';
import {setLogger as zhcSetLogger} from 'zigbee-herdsman-converters';
import EventBus from './eventBus';
// Extensions
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';
// 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';
@@ -30,40 +26,11 @@ 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';
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>,
];
export class Controller {
private eventBus: EventBus;
private zigbee: Zigbee;
@@ -71,9 +38,9 @@ export class Controller {
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();
@@ -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,32 +78,27 @@ 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();
@@ -182,8 +144,9 @@ export class Controller {
return await this.exit(1);
}
// Call extensions
await this.callExtensions('start', [...this.extensions]);
for (const extension of this.extensions) {
await this.startExtension(extension);
}
// Send all cached states.
if (settings.get().advanced.cache_state_send_on_startup && settings.get().advanced.cache_state) {
@@ -198,39 +161,131 @@ export class Controller {
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();
for (const extension of this.extensions) {
await this.stopExtension(extension);
}
// Call extensions
await this.callExtensions('stop', this.extensions);
this.eventBus.removeListeners(this);
// Wrap-up
@@ -246,7 +301,7 @@ export class Controller {
code = 1;
}
this.sdNotify?.stopWatchdogMode();
this.sdNotify?.stop();
return await this.exit(code, restart);
}
@@ -361,14 +416,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})`);
}
}
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type {Zigbee2MQTTAPI} from 'lib/types/api';
import type {Zigbee2MQTTAPI} from '../types/api';
import assert from 'node:assert';
+1 -1
View File
@@ -1,4 +1,4 @@
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from 'lib/types/api';
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from '../types/api';
import assert from 'node:assert';
+1 -1
View File
@@ -1,4 +1,4 @@
import type {Zigbee2MQTTAPI, Zigbee2MQTTDevice, Zigbee2MQTTResponse, Zigbee2MQTTResponseEndpoints} from 'lib/types/api';
import type {Zigbee2MQTTAPI, Zigbee2MQTTDevice, Zigbee2MQTTResponse, Zigbee2MQTTResponseEndpoints} from '../types/api';
import fs from 'node:fs';
+1 -1
View File
@@ -1,4 +1,4 @@
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';
+13 -10
View File
@@ -1,13 +1,13 @@
import type * as zhc from 'zigbee-herdsman-converters';
import type {ExternalDefinitionWithExtend} from 'zigbee-herdsman-converters';
import {addExternalDefinition, removeExternalDefinitions} from 'zigbee-herdsman-converters';
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,
@@ -33,29 +33,32 @@ export default class ExternalConverters extends ExternalJSExtension<ModuleExport
}
// 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(
/* 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`,
);
+34 -23
View File
@@ -4,9 +4,9 @@ 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,
@@ -31,29 +31,40 @@ export default class ExternalExtensions extends ExternalJSExtension<ModuleExport
);
}
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 */
}
}
+92 -40
View File
@@ -1,8 +1,7 @@
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 bind from 'bind-decorator';
import stringify from 'json-stable-stringify-without-jsonify';
@@ -16,9 +15,11 @@ import Extension from './extension';
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,
@@ -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)};
}
}
@@ -100,9 +119,9 @@ 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'],
@@ -112,18 +131,21 @@ export default abstract class ExternalJSExtension<M> extends Extension {
}
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`);
}
}
@@ -135,32 +157,76 @@ export default abstract class ExternalJSExtension<M> extends Extension {
}
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 = 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.`,
);
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 +236,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('\\', '/');
}
}
+4 -2
View File
@@ -27,7 +27,7 @@ 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;
@@ -124,7 +124,7 @@ 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.send(stringify({topic: 'bridge/state', payload: {state: 'offline'}}));
client.terminate();
});
this.wss?.close();
@@ -225,3 +225,5 @@ export default class Frontend extends Extension {
}
}
}
export default Frontend;
+1 -1
View File
@@ -1,4 +1,4 @@
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from 'lib/types/api';
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from '../types/api';
import assert from 'node:assert';
+27 -45
View File
@@ -108,7 +108,7 @@ const BINARY_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
window_open: {device_class: 'window'},
} as const;
const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
ac_frequency: {device_class: 'frequency', enabled_by_default: false, entity_category: 'diagnostic', state_class: 'measurement'},
ac_frequency: {device_class: 'frequency', state_class: 'measurement'},
action_duration: {icon: 'mdi:timer', device_class: 'duration'},
alarm_humidity_max: {device_class: 'humidity', entity_category: 'config', icon: 'mdi:water-plus'},
alarm_humidity_min: {device_class: 'humidity', entity_category: 'config', icon: 'mdi:water-minus'},
@@ -140,24 +140,9 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
state_class: 'measurement',
},
cube_side: {icon: 'mdi:cube'},
current: {
device_class: 'current',
enabled_by_default: false,
entity_category: 'diagnostic',
state_class: 'measurement',
},
current_phase_b: {
device_class: 'current',
enabled_by_default: false,
entity_category: 'diagnostic',
state_class: 'measurement',
},
current_phase_c: {
device_class: 'current',
enabled_by_default: false,
entity_category: 'diagnostic',
state_class: 'measurement',
},
current: {device_class: 'current', state_class: 'measurement'},
current_phase_b: {device_class: 'current', state_class: 'measurement'},
current_phase_c: {device_class: 'current', state_class: 'measurement'},
deadzone_temperature: {entity_category: 'config', icon: 'mdi:thermometer'},
detection_interval: {icon: 'mdi:timer'},
device_temperature: {
@@ -244,24 +229,9 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
voc_index: {state_class: 'measurement', icon: 'mdi:molecule'},
voc_parts: {device_class: 'volatile_organic_compounds_parts', state_class: 'measurement'},
vibration_timeout: {entity_category: 'config', icon: 'mdi:timer'},
voltage: {
device_class: 'voltage',
enabled_by_default: false,
entity_category: 'diagnostic',
state_class: 'measurement',
},
voltage_phase_b: {
device_class: 'voltage',
enabled_by_default: false,
entity_category: 'diagnostic',
state_class: 'measurement',
},
voltage_phase_c: {
device_class: 'voltage',
enabled_by_default: false,
entity_category: 'diagnostic',
state_class: 'measurement',
},
voltage: {device_class: 'voltage', state_class: 'measurement'},
voltage_phase_b: {device_class: 'voltage', state_class: 'measurement'},
voltage_phase_c: {device_class: 'voltage', state_class: 'measurement'},
water_consumed: {
device_class: 'water',
state_class: 'total_increasing',
@@ -383,7 +353,7 @@ class Bridge {
/**
* This extensions handles integration with HomeAssistant
*/
export default class HomeAssistant extends Extension {
export class HomeAssistant extends Extension {
private discovered: {[s: string]: Discovered} = {};
private discoveryTopic: string;
private discoveryRegex: RegExp;
@@ -536,13 +506,23 @@ export default class HomeAssistant extends Extension {
},
};
discoveryEntry.discovery_payload.supported_color_modes = [
'brightness',
const colorModes = [
hasColorXY && !preferHS ? 'xy' : null,
(!hasColorXY || preferHS) && hasColorHS ? 'hs' : null,
hasColorTemp ? 'color_temp' : null,
].filter((c) => c);
if (colorModes.length) {
discoveryEntry.discovery_payload.supported_color_modes = colorModes;
} else {
/**
* All bulbs support brightness, note that `brightness` cannot be combined
* with other color modes.
* https://github.com/Koenkk/zigbee2mqtt/issues/26520#issuecomment-2692432058
*/
discoveryEntry.discovery_payload.supported_color_modes = ['brightness'];
}
if (hasColorTemp) {
const colorTemps = (exposes as zhc.Light[])
.map((expose) => expose.features.find((e) => e.name === 'color_temp'))
@@ -1370,10 +1350,9 @@ export default class HomeAssistant extends Extension {
const exposesByType: {[s: string]: zhc.Expose[]} = {};
const allExposes: zhc.Expose[] = [];
entity.zh.members
.map((e) => this.zigbee.resolveEntity(e.getDevice()) as Device)
.filter((d) => d.definition)
.forEach((device) => {
for (const member of entity.zh.members) {
const device = this.zigbee.resolveEntity(member.getDevice()) as Device;
if (device.definition) {
const exposes = device.exposes();
allExposes.push(...exposes);
for (const expose of exposes.filter((e) => GROUP_SUPPORTED_TYPES.includes(e.type))) {
@@ -1389,7 +1368,8 @@ export default class HomeAssistant extends Extension {
if (!exposesByType[key]) exposesByType[key] = [];
exposesByType[key].push(expose);
}
});
}
}
configs = ([] as DiscoveryEntry[]).concat(
...Object.values(exposesByType).map((exposes) => this.exposeToConfig(exposes, 'group', allExposes)),
@@ -2140,3 +2120,5 @@ export default class HomeAssistant extends Extension {
return value_template;
}
}
export default HomeAssistant;
+1 -1
View File
@@ -1,4 +1,4 @@
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';
+2 -1
View File
@@ -1,6 +1,7 @@
import type {Zigbee2MQTTAPI} from 'lib/types/api';
import type {Ota} from 'zigbee-herdsman-converters';
import type {Zigbee2MQTTAPI} from '../types/api';
import assert from 'node:assert';
import path from 'node:path';
+1 -1
View File
@@ -212,7 +212,7 @@ export default class Publish extends Extension {
}
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;
}
+11 -9
View File
@@ -1,14 +1,15 @@
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 {LogLevel} from 'lib/util/settings';
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 {LogLevel} from '../util/settings';
type OptionalProps<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
@@ -120,7 +121,7 @@ declare global {
serial: {
disable_led: boolean;
port?: string;
adapter?: 'deconz' | 'zstack' | 'ezsp' | 'zigate' | 'ember';
adapter?: 'deconz' | 'zstack' | 'ezsp' | 'zigate' | 'ember' | 'zboss' | 'zoh';
baudrate?: number;
rtscts?: boolean;
};
@@ -162,6 +163,7 @@ declare global {
url?: string;
ssl_cert?: string;
ssl_key?: string;
notification_filter?: string[];
};
devices: {[s: string]: DeviceOptions};
groups: {[s: string]: Omit<GroupOptions, 'ID'>};
+14
View File
@@ -0,0 +1,14 @@
declare module 'unix-dgram' {
import {EventEmitter} from 'events';
import {Buffer} from 'buffer';
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;
}
+585
View File
@@ -0,0 +1,585 @@
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(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(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'));
let checkMigration = true;
if (!confExists) {
settings.writeMinimalDefaults();
// don't check for migration if conf was just written
checkMigration = false;
} else {
// 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();
if (checkMigration) {
const {migrateIfNecessary} = await import('./settingsMigration.js');
migrateIfNecessary();
}
const errors = settings.validate();
if (errors.length > 0) {
let pErrors: string = '';
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;
}
+68
View File
@@ -0,0 +1,68 @@
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}`);
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);
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, 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 => {},
};
}
+9 -1
View File
@@ -219,7 +219,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 +410,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": []
+35 -3
View File
@@ -11,7 +11,6 @@ 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;
@@ -146,10 +145,44 @@ function parseValueRef(text: string): {filename: string; key: string} | 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);
applyEnvironmentVariables(toWrite);
// Read settings to check if we have to split devices/groups into separate file.
const actual = yaml.read(CONFIG_FILE_PATH);
@@ -277,7 +310,6 @@ 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 => {
+11 -15
View File
@@ -1,6 +1,8 @@
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponse, Zigbee2MQTTResponseEndpoints, Zigbee2MQTTScene} from 'lib/types/api';
import type * as zhc from 'zigbee-herdsman-converters';
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponse, Zigbee2MQTTResponseEndpoints, Zigbee2MQTTScene} from '../types/api';
import {exec} from 'child_process';
import assert from 'node:assert';
import crypto from 'node:crypto';
import fs from 'node:fs';
@@ -49,33 +51,27 @@ 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 */
} catch {
commitHash = 'unknown';
}
/* v8 ignore stop */
} else {
commitHash = commit.shortHash;
}
commitHash = commitHash.trim();
resolve({commitHash, version});
});
});
+19 -21
View File
@@ -1,6 +1,6 @@
{
"name": "zigbee2mqtt",
"version": "2.1.2",
"version": "2.2.0",
"description": "Zigbee to MQTT bridge using Zigbee-herdsman",
"main": "index.js",
"packageManager": "pnpm@10.4.1",
@@ -22,11 +22,12 @@
],
"scripts": {
"build": "tsc && node index.js writehash",
"build-watch": "tsc --watch",
"build:watch": "tsc --watch",
"eslint": "eslint --max-warnings=0",
"pretty:write": "prettier --write .",
"pretty:check": "prettier --check .",
"start": "node index.js",
"check": "pnpm run pretty:check && pnpm run eslint",
"test": "vitest run --config ./test/vitest.config.mts",
"test:coverage": "vitest run --config ./test/vitest.config.mts --coverage",
"test:watch": "vitest watch --config ./test/vitest.config.mts",
@@ -45,14 +46,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.10.4",
"object-assign-deep": "^0.4.0",
"rimraf": "^6.0.1",
"semver": "^7.7.1",
@@ -62,31 +62,30 @@
"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": "3.4.11",
"zigbee-herdsman-converters": "23.18.0",
"zigbee2mqtt-frontend": "0.9.5"
},
"devDependencies": {
"@eslint/core": "^0.12.0",
"@eslint/js": "^9.21.0",
"@eslint/js": "^9.23.0",
"@ianvs/prettier-plugin-sort-imports": "^4.4.1",
"@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.13.14",
"@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.0",
"@vitest/coverage-v8": "^3.0.9",
"eslint": "^9.23.0",
"eslint-config-prettier": "^10.1.1",
"prettier": "^3.5.3",
"tmp": "^0.2.3",
"typescript": "^5.7.3",
"typescript-eslint": "^8.25.0",
"vitest": "^3.0.7"
"typescript": "^5.8.2",
"typescript-eslint": "^8.28.0",
"vitest": "^3.0.9"
},
"pnpm": {
"overrides": {
@@ -95,7 +94,6 @@
"onlyBuiltDependencies": [
"@serialport/bindings-cpp",
"esbuild",
"sd-notify",
"unix-dgram"
]
},
@@ -103,6 +101,6 @@
"zigbee2mqtt": "cli.js"
},
"optionalDependencies": {
"sd-notify": "^2.8.0"
"unix-dgram": "^2.0.6"
}
}
+522 -532
View File
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,11 @@
const {posix} = require('node:path');
const mockDevice = {
mock: true,
zigbeeModel: ['external_converter_device'],
vendor: 'external',
model: 'external_converter_device',
description: 'external',
description: posix.join('external', 'converter'),
fromZigbee: [],
toZigbee: [],
exposes: [],
@@ -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()],
};
@@ -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,14 @@
export default 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');
}
}
+38 -8
View File
@@ -14,6 +14,7 @@ import {devices, mockController as mockZHController, events as mockZHEvents, ret
import type {Mock, MockInstance} from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import stringify from 'json-stable-stringify-without-jsonify';
@@ -24,15 +25,12 @@ import {Controller as ZHController} from 'zigbee-herdsman';
import {Controller} from '../lib/controller';
import * as settings from '../lib/util/settings';
process.env.NOTIFY_SOCKET = 'mocked';
const LOG_MQTT_NS = 'z2m:mqtt';
vi.mock('sd-notify', () => ({
watchdogInterval: vi.fn(() => 3000),
startWatchdogMode: vi.fn(),
stopWatchdogMode: vi.fn(),
ready: vi.fn(),
stopping: vi.fn(),
const mockUnixDgramSend = vi.fn();
vi.mock('unix-dgram', () => ({
createSocket: vi.fn(() => ({send: mockUnixDgramSend})),
}));
const mocksClear = [
@@ -49,6 +47,7 @@ const mocksClear = [
mockLogger.debug,
mockLogger.info,
mockLogger.error,
mockUnixDgramSend,
];
describe('Controller', () => {
@@ -76,6 +75,7 @@ describe('Controller', () => {
afterEach(async () => {
await controller?.stop();
await flushPromises();
});
it('Start controller', async () => {
@@ -338,7 +338,7 @@ describe('Controller', () => {
expect(mockExit).toHaveBeenCalledWith(0, true);
});
it('Start controller and stop', async () => {
it('Start controller and stop without SdNotify', async () => {
mockZHController.stop.mockRejectedValueOnce('failed');
await controller.start();
await controller.stop();
@@ -346,6 +346,24 @@ describe('Controller', () => {
expect(mockZHController.stop).toHaveBeenCalledTimes(1);
expect(mockExit).toHaveBeenCalledTimes(1);
expect(mockExit).toHaveBeenCalledWith(1, false);
expect(mockUnixDgramSend).toHaveBeenCalledTimes(0);
});
it('Start controller and stop with SdNotify', async () => {
vi.spyOn(os, 'platform').mockImplementationOnce(() => 'linux');
process.env.NOTIFY_SOCKET = 'mocked'; // coverage
mockZHController.stop.mockRejectedValueOnce('failed');
await controller.start();
await controller.stop();
expect(mockMQTTEndAsync).toHaveBeenCalledTimes(1);
expect(mockZHController.stop).toHaveBeenCalledTimes(1);
expect(mockExit).toHaveBeenCalledTimes(1);
expect(mockExit).toHaveBeenCalledWith(1, false);
expect(mockUnixDgramSend).toHaveBeenCalledTimes(2);
delete process.env.NOTIFY_SOCKET;
});
it('Start controller adapter disconnects', async () => {
@@ -1090,4 +1108,16 @@ describe('Controller', () => {
expect(callback).toHaveBeenCalledTimes(1);
expect(mockLogger.error).toHaveBeenCalledWith(`EventBus error 'Test/stateChange': Whoops!`);
});
it('prevents interacting with invalid extensions', async () => {
await controller.start();
await expect(async () => {
await controller.enableDisableExtension(true, 'Fake');
}).rejects.toThrow("Extension Fake does not exist (should be added with 'addExtension') or is built-in that cannot be enabled at runtime");
await expect(async () => {
await controller.enableDisableExtension(false, 'Availability');
}).rejects.toThrow('Built-in extension Availability cannot be disabled at runtime');
});
});
+6 -7
View File
@@ -32,8 +32,8 @@ 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> => {
@@ -66,7 +66,8 @@ describe('Extension: Availability', () => {
afterEach(async () => {});
afterAll(async () => {
await controller.stop();
await controller?.stop();
await flushPromises();
vi.useRealTimers();
});
@@ -362,8 +363,7 @@ describe('Extension: Availability', () => {
});
it('Should clear the ping queue on stop', async () => {
// @ts-expect-error private
const availability = controller.extensions.find((extension) => extension instanceof Availability)!;
const availability = controller.getExtension('Availability')! as Availability;
// @ts-expect-error private
const publishAvailabilitySpy = vi.spyOn(availability, 'publishAvailability');
@@ -384,8 +384,7 @@ describe('Extension: Availability', () => {
});
it('Should prevent instance restart', async () => {
// @ts-expect-error private
const availability = controller.extensions.find((extension) => extension instanceof Availability)!;
const availability = controller.getExtension('Availability')! as Availability;
await availability.stop();
+5 -2
View File
@@ -8,6 +8,7 @@ import {Device, devices, groups, events as mockZHEvents} from '../mocks/zigbeeHe
import stringify from 'json-stable-stringify-without-jsonify';
import {Controller} from '../../lib/controller';
import Bind from '../../lib/extension/bind';
import * as settings from '../../lib/util/settings';
const mocksClear = [
@@ -22,8 +23,8 @@ 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 => {
@@ -53,6 +54,8 @@ describe('Extension: Bind', () => {
});
afterAll(async () => {
await controller?.stop();
await flushPromises();
vi.useRealTimers();
});
+17 -29
View File
@@ -7,8 +7,6 @@ import {CUSTOM_CLUSTERS, devices, groups, mockController as mockZHController, ev
import type {Mock} from 'vitest';
import type Bridge from '../../lib/extension/bridge';
import assert from 'node:assert';
import fs from 'node:fs';
import path from 'node:path';
@@ -16,6 +14,7 @@ import path from 'node:path';
import stringify from 'json-stable-stringify-without-jsonify';
import {Controller} from '../../lib/controller';
import Bridge from '../../lib/extension/bridge';
import * as settings from '../../lib/util/settings';
import utils from '../../lib/util/utils';
@@ -48,10 +47,9 @@ describe('Extension: Bridge', () => {
let extension: Bridge;
const resetExtension = async (): Promise<void> => {
await controller.enableDisableExtension(false, 'Bridge');
await controller.enableDisableExtension(true, 'Bridge');
// @ts-expect-error private
extension = controller.extensions.find((e) => e.constructor.name === 'Bridge');
await controller.removeExtension(controller.getExtension('Bridge')!);
await controller.addExtension(new Bridge(...controller.extensionArgs));
extension = controller.getExtension('Bridge')! as Bridge;
};
beforeAll(async () => {
@@ -60,8 +58,7 @@ describe('Extension: Bridge', () => {
controller = new Controller(mockRestart, vi.fn());
await controller.start();
await flushPromises();
// @ts-expect-error private
extension = controller.extensions.find((e) => e.constructor.name === 'Bridge');
extension = controller.getExtension('Bridge')! as Bridge;
});
beforeEach(async () => {
@@ -86,6 +83,8 @@ describe('Extension: Bridge', () => {
});
afterAll(async () => {
await controller?.stop();
await flushPromises();
vi.useRealTimers();
});
@@ -1264,7 +1263,6 @@ describe('Extension: Bridge', () => {
},
{
access: 5,
category: 'diagnostic',
description: 'Instantaneous measured power',
label: 'Power',
name: 'power',
@@ -3174,18 +3172,16 @@ describe('Extension: Bridge', () => {
data: {
id: '0x0017880104e45524',
source:
"const m = require('zigbee-herdsman-converters/lib/modernExtend');\n" +
"import * as m from 'zigbee-herdsman-converters/lib/modernExtend';\n" +
'\n' +
'const definition = {\n' +
'export default {\n' +
" zigbeeModel: ['lumi.plug'],\n" +
" model: 'lumi.plug',\n" +
" vendor: '',\n" +
" description: 'Automatically generated definition',\n" +
' extend: [m.onOff({"powerOnBehavior":false})],\n' +
' meta: {},\n' +
'};\n' +
'\n' +
'module.exports = definition;',
'};\n',
},
status: 'ok',
}),
@@ -3759,14 +3755,9 @@ describe('Extension: Bridge', () => {
});
it('Change options and apply - homeassistant', async () => {
// @ts-expect-error private
expect(controller.extensions.find((e) => e.constructor.name === 'HomeAssistant')).toBeUndefined();
mockMQTTEvents.message('zigbee2mqtt/bridge/request/options', stringify({options: {homeassistant: {enabled: true}}}));
// TODO: there appears to be a race condition somewhere in here, calls in `bridgeOptions` are not properly ordered when logged
await vi.advanceTimersByTimeAsync(10000);
await flushPromises();
// @ts-expect-error private
expect(controller.extensions.find((e) => e.constructor.name === 'HomeAssistant')).not.toBeUndefined();
expect(controller.getExtension('HomeAssistant')).toBeUndefined();
await mockMQTTEvents.message('zigbee2mqtt/bridge/request/options', stringify({options: {homeassistant: {enabled: true}}}));
await expect(vi.waitUntil(() => controller.getExtension('HomeAssistant'))).resolves.toBeDefined();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/info', expect.any(String), {retain: true, qos: 0});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/options',
@@ -3774,10 +3765,8 @@ describe('Extension: Bridge', () => {
{retain: false, qos: 0},
);
// revert
mockMQTTEvents.message('zigbee2mqtt/bridge/request/options', stringify({options: {homeassistant: {enabled: false}}}));
await flushPromises();
// @ts-expect-error private
expect(controller.extensions.find((e) => e.constructor.name === 'HomeAssistant')).toBeUndefined();
await mockMQTTEvents.message('zigbee2mqtt/bridge/request/options', stringify({options: {homeassistant: {enabled: false}}}));
await vi.waitUntil(() => controller.getExtension('HomeAssistant') === undefined);
});
it('Change options and apply - log_level', async () => {
@@ -3897,9 +3886,8 @@ describe('Extension: Bridge', () => {
});
it('Icon link handling', async () => {
// @ts-expect-error private
const bridge: Bridge = controller.extensions.find((e) => e.constructor.name === 'Bridge');
expect(bridge).not.toBeUndefined();
const bridge = controller.getExtension('Bridge')! as Bridge;
expect(bridge).toBeDefined();
const definition = {
fingerprint: [],
+6 -3
View File
@@ -7,6 +7,7 @@ import {Device, devices, Endpoint, events as mockZHEvents} from '../mocks/zigbee
import stringify from 'json-stable-stringify-without-jsonify';
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];
@@ -16,8 +17,8 @@ describe('Extension: Configure', () => {
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 => {
@@ -81,6 +82,8 @@ describe('Extension: Configure', () => {
});
afterAll(async () => {
await controller?.stop();
await flushPromises();
vi.useRealTimers();
});
@@ -227,7 +230,7 @@ describe('Extension: Configure', () => {
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)!;
+385 -129
View File
@@ -1,6 +1,6 @@
import * as data from '../mocks/data';
import {mockLogger} from '../mocks/logger';
import {mockMQTTEndAsync, events as mockMQTTEvents, mockMQTTPublishAsync} from '../mocks/mqtt';
import {mockMQTTEndAsync, mockMQTTPublishAsync} from '../mocks/mqtt';
import {flushPromises} from '../mocks/utils';
import {devices, mockController as mockZHController, returnDevices} from '../mocks/zigbeeHerdsman';
@@ -14,6 +14,7 @@ import stringify from 'json-stable-stringify-without-jsonify';
import * as zhc from 'zigbee-herdsman-converters';
import {Controller} from '../../lib/controller';
import ExternalConverters from '../../lib/extension/externalConverters';
import * as settings from '../../lib/util/settings';
const BASE_DIR = 'external_converters';
@@ -22,9 +23,6 @@ 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');
@@ -38,21 +36,18 @@ 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 => {
@@ -61,8 +56,8 @@ describe('Extension: ExternalConverters', () => {
};
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 () => {
@@ -79,6 +74,8 @@ describe('Extension: ExternalConverters', () => {
beforeEach(async () => {
zhc.removeExternalDefinitions(); // remove all external converters
// @ts-expect-error private - clear cached
await controller.zigbee.resolveDevicesDefinitions(true);
mocksClear.forEach((m) => m.mockClear());
data.writeDefaultConfiguration();
data.writeDefaultState();
@@ -90,6 +87,7 @@ describe('Extension: ExternalConverters', () => {
fs.rmSync(mockBasePath, {recursive: true, force: true});
await controller?.stop();
await flushPromises();
});
describe('from folder', () => {
@@ -101,19 +99,17 @@ describe('Extension: ExternalConverters', () => {
await controller.start();
await flushPromises();
expect(existsSyncSpy).toHaveBeenCalledWith(mockBasePath);
expect(readdirSyncSpy).not.toHaveBeenCalledWith(mockBasePath);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/converters', stringify([]), {retain: true, qos: 0});
});
it('loads converters', async () => {
useAssets();
it('CJS: loads converters', async () => {
useAssets('cjs');
await controller.start();
await flushPromises();
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
description: 'external',
description: 'external/converter',
model: 'external_converter_device',
vendor: 'external',
zigbeeModel: ['external_converter_device'],
@@ -121,8 +117,8 @@ describe('Extension: ExternalConverters', () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/converters',
stringify([
{name: 'mock-external-converter-multiple.js', code: getFileCode('mock-external-converter-multiple.js')},
{name: 'mock-external-converter.js', code: getFileCode('mock-external-converter.js')},
{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},
);
@@ -156,7 +152,7 @@ describe('Extension: ExternalConverters', () => {
zigbeeModel: ['external_converter_device'],
vendor: 'external',
model: 'external_converter_device',
description: 'external',
description: 'external/converter',
}),
);
@@ -168,7 +164,7 @@ describe('Extension: ExternalConverters', () => {
model_id: 'external_converter_device',
supported: true,
definition: expect.objectContaining({
description: 'external',
description: 'external/converter',
model: 'external_converter_device',
}),
}),
@@ -176,13 +172,193 @@ describe('Extension: ExternalConverters', () => {
);
});
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();
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.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',
}),
);
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/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: 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();
mocksClear.forEach((m) => m.mockClear());
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
@@ -193,17 +369,18 @@ describe('Extension: ExternalConverters', () => {
});
//-- SAVE
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode}));
await flushPromises();
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',
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(writeFileSyncSpy).toHaveBeenCalledWith(expect.stringContaining(converterName), converterCode, 'utf8');
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenCalledTimes(1);
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(1, converterName);
expect(zhcAddExternalDefinitionSpy).toHaveBeenCalledWith(
@@ -212,7 +389,7 @@ describe('Extension: ExternalConverters', () => {
zigbeeModel: ['external_converter_device'],
vendor: 'external',
model: 'external_converter_device',
description: 'external',
description: 'external/converter',
}),
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
@@ -225,8 +402,10 @@ 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',
@@ -234,124 +413,201 @@ describe('Extension: ExternalConverters', () => {
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);
it('MJS: saves and removes', async () => {
const converterName = 'foo.mjs';
const converterCode = getFileCode('mjs', 'mock-external-converter.mjs');
await resetExtension();
mocksClear.forEach((m) => m.mockClear());
await resetExtension();
mocksClear.forEach((m) => m.mockClear());
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode}));
await flushPromises();
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
description: 'Automatically generated definition',
model: 'external_converter_device',
vendor: '',
zigbeeModel: ['external_converter_device'],
});
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');
});
//-- SAVE
await (controller.getExtension('ExternalConverters')! as ExternalConverters).onMQTTMessage({
topic: 'zigbee2mqtt/bridge/request/converter/save',
message: {name: converterName, code: converterCode},
});
it('returns error on invalid removal', async () => {
const converterName = 'invalid.js';
const converterFilePath = path.join(mockBasePath, converterName);
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,
},
);
await resetExtension();
mocksClear.forEach((m) => m.mockClear());
//-- REMOVE
await (controller.getExtension('ExternalConverters')! as ExternalConverters).onMQTTMessage({
topic: 'zigbee2mqtt/bridge/request/converter/remove',
message: {name: converterName},
});
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(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});
});
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode}));
await flushPromises();
it('returns error on invalid code', async () => {
const converterName = 'foo1.js';
const converterCode = 'definetly not a correct javascript code';
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/response/converter/save', expect.stringContaining(errorMsg), {
retain: false,
qos: 0,
});
expect(writeFileSyncSpy).not.toHaveBeenCalledWith(converterFilePath, converterCode, 'utf8');
});
await resetExtension();
mocksClear.forEach((m) => m.mockClear());
it('returns error on failed removal', async () => {
const converterName = 'foo.js';
const converterCode = getFileCode('mock-external-converter.js');
const converterFilePath = path.join(mockBasePath, converterName);
await (controller.getExtension('ExternalConverters')! as ExternalConverters).onMQTTMessage({
topic: 'zigbee2mqtt/bridge/request/converter/save',
message: {name: converterName, code: converterCode},
});
await resetExtension();
mocksClear.forEach((m) => m.mockClear());
//-- SAVE
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode}));
await flushPromises();
const errorMsg = `Failed to remove definition`;
zhcRemoveExternalDefinitionsSpy.mockImplementationOnce(() => {
throw new Error(errorMsg);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/converter/save',
expect.stringContaining(`"error":"${converterName} contains invalid code`),
{retain: false, qos: 0},
);
expect(writeFileSyncSpy).toHaveBeenCalledWith(expect.stringContaining(converterName), converterCode, 'utf8');
expect(rmSyncSpy).toHaveBeenCalledWith(expect.stringContaining(converterName), {force: true});
});
//-- REMOVE
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/remove', stringify({name: converterName}));
await flushPromises();
it('returns error on invalid removal', async () => {
const converterName = 'foo2.js';
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/converter/remove',
stringify({data: {}, status: 'error', error: errorMsg}),
{retain: false, qos: 0},
);
expect(rmSyncSpy).not.toHaveBeenCalledWith(converterFilePath, {force: true});
});
await resetExtension();
mocksClear.forEach((m) => m.mockClear());
it('handles invalid payloads', async () => {
await resetExtension();
mocksClear.forEach((m) => m.mockClear());
await (controller.getExtension('ExternalConverters')! as ExternalConverters).onMQTTMessage({
topic: 'zigbee2mqtt/bridge/request/converter/remove',
message: {name: converterName},
});
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: 'test.js', transaction: 1 /* code */}));
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/converter/remove',
expect.stringContaining("doesn't exists"),
{retain: false, qos: 0},
);
expect(rmSyncSpy).not.toHaveBeenCalledWith(expect.stringContaining(converterName), {force: true});
});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/converter/save',
stringify({data: {}, status: 'error', error: `Invalid payload`, transaction: 1}),
{retain: false, qos: 0},
);
it('returns error on invalid definition', async () => {
const converterName = 'foo3.js';
const converterCode = getFileCode('cjs', 'mock-external-converter.js');
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/remove', stringify({namex: 'test.js', transaction: 2}));
await flushPromises();
await resetExtension();
mocksClear.forEach((m) => m.mockClear());
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/converter/remove',
stringify({data: {}, status: 'error', error: `Invalid payload`, transaction: 2}),
{retain: false, qos: 0},
);
const errorMsg = `Invalid definition`;
zhcAddExternalDefinitionSpy.mockImplementationOnce(() => {
throw new Error(errorMsg);
});
await (controller.getExtension('ExternalConverters')! as ExternalConverters).onMQTTMessage({
topic: 'zigbee2mqtt/bridge/request/converter/save',
message: {name: converterName, code: converterCode},
});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/response/converter/save', expect.stringContaining(errorMsg), {
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();
mocksClear.forEach((m) => m.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();
mocksClear.forEach((m) => m.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},
);
});
});
});
+221 -78
View File
@@ -1,6 +1,6 @@
import * as data from '../mocks/data';
import {mockLogger} from '../mocks/logger';
import {mockMQTTEndAsync, events as mockMQTTEvents, mockMQTTPublishAsync} from '../mocks/mqtt';
import {mockMQTTEndAsync, mockMQTTPublishAsync} from '../mocks/mqtt';
import {flushPromises} from '../mocks/utils';
import {devices, mockController as mockZHController, returnDevices} from '../mocks/zigbeeHerdsman';
@@ -10,6 +10,7 @@ import path from 'node:path';
import stringify from 'json-stable-stringify-without-jsonify';
import {Controller} from '../../lib/controller';
import ExternalExtensions from '../../lib/extension/externalExtensions';
import * as settings from '../../lib/util/settings';
const BASE_DIR = 'external_extensions';
@@ -18,9 +19,6 @@ 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');
@@ -31,30 +29,28 @@ 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();
});
@@ -71,8 +67,11 @@ describe('Extension: ExternalExtensions', () => {
returnDevices.splice(0);
});
afterEach(() => {
afterEach(async () => {
fs.rmSync(mockBasePath, {recursive: true, force: true});
await controller?.stop();
await flushPromises();
});
describe('from folder', () => {
@@ -84,13 +83,11 @@ describe('Extension: ExternalExtensions', () => {
await controller.start();
await flushPromises();
expect(existsSyncSpy).toHaveBeenCalledWith(mockBasePath);
expect(readdirSyncSpy).not.toHaveBeenCalledWith(mockBasePath);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/extensions', stringify([]), {retain: true, qos: 0});
});
it('loads extensions', async () => {
useAssets();
it('CJS: loads extensions', async () => {
useAssets('cjs');
await controller.start();
await flushPromises();
@@ -102,28 +99,129 @@ describe('Extension: ExternalExtensions', () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'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();
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('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(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},
);
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'");
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 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'");
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 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();
mocksClear.forEach((m) => m.mockClear());
//-- SAVE
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: extensionName, code: extensionCode}));
await flushPromises();
await (controller.getExtension('ExternalExtensions')! as ExternalExtensions).onMQTTMessage({
topic: 'zigbee2mqtt/bridge/request/extension/save',
message: {name: extensionName, code: extensionCode},
});
expect(mkdirSyncSpy).toHaveBeenCalledWith(mockBasePath, {recursive: true});
expect(writeFileSyncSpy).toHaveBeenCalledWith(extensionFilePath, extensionCode, 'utf8');
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(
@@ -136,72 +234,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(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 = 'foo.js';
const extensionCode = 'definetly not a correct javascript code';
const extensionFilePath = path.join(mockBasePath, extensionName);
it('MJS: saves and removes', async () => {
const extensionName = 'foo.mjs';
const extensionCode = getFileCode('mjs', 'exampleExtension.mjs');
await resetExtension();
mocksClear.forEach((m) => m.mockClear());
await resetExtension();
mocksClear.forEach((m) => m.mockClear());
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: extensionName, code: extensionCode}));
await flushPromises();
//-- SAVE
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).not.toHaveBeenCalledWith(extensionFilePath, extensionCode, 'utf8');
});
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,
},
);
it('returns error on invalid removal', async () => {
const converterName = 'invalid.js';
const converterFilePath = path.join(mockBasePath, converterName);
//-- REMOVE
await (controller.getExtension('ExternalExtensions')! as ExternalExtensions).onMQTTMessage({
topic: 'zigbee2mqtt/bridge/request/extension/remove',
message: {name: extensionName},
});
await resetExtension();
mocksClear.forEach((m) => m.mockClear());
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});
});
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/remove', stringify({name: converterName}));
await flushPromises();
it('returns error on invalid code', async () => {
const extensionName = 'foo1.js';
const extensionCode = 'definetly not a correct javascript code';
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});
});
await resetExtension();
mocksClear.forEach((m) => m.mockClear());
it('handles invalid payloads', async () => {
await resetExtension();
mocksClear.forEach((m) => m.mockClear());
await (controller.getExtension('ExternalExtensions')! as ExternalExtensions).onMQTTMessage({
topic: 'zigbee2mqtt/bridge/request/extension/save',
message: {name: extensionName, code: extensionCode},
});
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: 'test.js', transaction: 1 /* code */}));
await flushPromises();
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});
});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/extension/save',
stringify({data: {}, status: 'error', error: `Invalid payload`, transaction: 1}),
{retain: false, qos: 0},
);
it('returns error on invalid removal', async () => {
const extensionName = 'foo2.js';
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/remove', stringify({namex: 'test.js', transaction: 2}));
await flushPromises();
await resetExtension();
mocksClear.forEach((m) => m.mockClear());
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/extension/remove',
stringify({data: {}, status: 'error', error: `Invalid payload`, transaction: 2}),
{retain: false, qos: 0},
);
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();
mocksClear.forEach((m) => m.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},
);
});
});
});
+30
View File
@@ -154,6 +154,8 @@ describe('Extension: Frontend', () => {
afterEach(async () => {
delete devices.bulb.linkquality;
await controller?.stop();
await flushPromises();
});
it('Start/stop with defaults', async () => {
@@ -434,4 +436,32 @@ describe('Extension: Frontend', () => {
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);
});
});
+2
View File
@@ -33,6 +33,8 @@ describe('Extension: Groups', () => {
});
afterAll(async () => {
await controller?.stop();
await flushPromises();
vi.useRealTimers();
});
+78 -25
View File
@@ -2,7 +2,7 @@ import * as data from '../mocks/data';
import {mockLogger} from '../mocks/logger';
import {events as mockMQTTEvents, mockMQTTPublishAsync, mockMQTTSubscribeAsync, mockMQTTUnsubscribeAsync} from '../mocks/mqtt';
import * as mockSleep from '../mocks/sleep';
import {flushPromises} from '../mocks/utils';
import {flushPromises, getZhcBaseDefinitions} from '../mocks/utils';
import {devices, groups, events as mockZHEvents} from '../mocks/zigbeeHerdsman';
import assert from 'node:assert';
@@ -26,11 +26,10 @@ describe('Extension: HomeAssistant', () => {
const origin = {name: 'Zigbee2MQTT', sw: '', url: 'https://www.zigbee2mqtt.io'};
const resetExtension = async (runTimers = true): Promise<void> => {
await controller.enableDisableExtension(false, 'HomeAssistant');
await controller.removeExtension(controller.getExtension('HomeAssistant')!);
mocksClear.forEach((m) => m.mockClear());
await controller.enableDisableExtension(true, 'HomeAssistant');
// @ts-expect-error private
extension = controller.extensions.find((e) => e.constructor.name === 'HomeAssistant');
await controller.addExtension(new HomeAssistant(...controller.extensionArgs));
extension = controller.getExtension('HomeAssistant')! as HomeAssistant;
if (runTimers) {
await vi.runOnlyPendingTimersAsync();
@@ -49,7 +48,7 @@ describe('Extension: HomeAssistant', () => {
};
beforeAll(async () => {
const {getZigbee2MQTTVersion} = (await import('../../lib/util/utils')).default;
const {getZigbee2MQTTVersion} = await import('../../lib/util/utils.js');
z2m_version = (await getZigbee2MQTTVersion()).version;
version = `Zigbee2MQTT ${z2m_version}`;
origin.sw = z2m_version;
@@ -65,8 +64,10 @@ describe('Extension: HomeAssistant', () => {
});
afterAll(async () => {
vi.useRealTimers();
mockSleep.restore();
await controller?.stop();
await flushPromises();
vi.useRealTimers();
});
beforeEach(async () => {
@@ -83,11 +84,8 @@ describe('Extension: HomeAssistant', () => {
it('Should not have duplicate type/object_ids in a mapping', async () => {
const duplicated: string[] = [];
(await import('zigbee-herdsman-converters/devices/index')).default.forEach((baseDefinition) => {
const d = zhc.prepareDefinition(
// @ts-expect-error inferred type is wrong
baseDefinition,
);
for (const baseDefinition of await getZhcBaseDefinitions()) {
const d = zhc.prepareDefinition(baseDefinition);
const exposes = typeof d.exposes == 'function' ? d.exposes(undefined, undefined) : d.exposes;
const device = {
definition: d,
@@ -112,7 +110,7 @@ describe('Extension: HomeAssistant', () => {
cfgTypeObjectIds.push(id);
}
});
});
}
expect(duplicated).toHaveLength(0);
});
@@ -141,7 +139,7 @@ describe('Extension: HomeAssistant', () => {
name: null,
schema: 'json',
state_topic: 'zigbee2mqtt/ha_discovery_group',
supported_color_modes: ['brightness', 'xy', 'color_temp'],
supported_color_modes: ['xy', 'color_temp'],
effect: true,
effect_list: [
'blink',
@@ -165,6 +163,33 @@ describe('Extension: HomeAssistant', () => {
qos: 1,
});
payload = {
availability: [{topic: 'zigbee2mqtt/bridge/state', value_template: '{{ value_json.state }}'}],
brightness: true,
brightness_scale: 254,
command_topic: 'zigbee2mqtt/bulb_enddevice/set',
device: {
identifiers: ['zigbee2mqtt_0x0017880104e45553'],
manufacturer: 'Sengled',
model: 'Element classic (A19)',
model_id: 'E11-G13',
name: 'bulb_enddevice',
via_device: 'zigbee2mqtt_bridge_0x00124b00120144ae',
},
name: null,
object_id: 'bulb_enddevice',
origin: origin,
schema: 'json',
state_topic: 'zigbee2mqtt/bulb_enddevice',
supported_color_modes: ['brightness'],
unique_id: '0x0017880104e45553_light_zigbee2mqtt',
};
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('homeassistant/light/0x0017880104e45553/light/config', stringify(payload), {
retain: true,
qos: 1,
});
payload = {
availability: [{topic: 'zigbee2mqtt/bridge/state', value_template: '{{ value_json.state }}'}],
command_topic: 'zigbee2mqtt/ha_discovery_group/set',
@@ -381,7 +406,7 @@ describe('Extension: HomeAssistant', () => {
availability: [{topic: 'zigbee2mqtt/bridge/state', value_template: '{{ value_json.state }}'}],
brightness: true,
brightness_scale: 254,
supported_color_modes: ['brightness', 'color_temp'],
supported_color_modes: ['color_temp'],
min_mireds: 250,
max_mireds: 454,
command_topic: 'zigbee2mqtt/bulb/set',
@@ -1183,16 +1208,20 @@ describe('Extension: HomeAssistant', () => {
it('Should throw error when starting with attributes output', async () => {
settings.set(['advanced', 'output'], 'attribute');
settings.set(['homeassistant'], {enabled: true});
expect(() => {
new Controller(vi.fn(), vi.fn());
}).toThrow('Home Assistant integration is not possible with attribute output!');
const controller = new Controller(vi.fn(), vi.fn());
await expect(async () => {
await controller.start();
}).rejects.toThrow('Home Assistant integration is not possible with attribute output!');
});
it('Should throw error when homeassistant.discovery_topic equals the mqtt.base_topic', async () => {
settings.set(['mqtt', 'base_topic'], 'homeassistant');
expect(() => {
new Controller(vi.fn(), vi.fn());
}).toThrow("'homeassistant.discovery_topic' cannot not be equal to the 'mqtt.base_topic' (got 'homeassistant')");
const controller = new Controller(vi.fn(), vi.fn());
await expect(async () => {
await controller.start();
}).rejects.toThrow("'homeassistant.discovery_topic' cannot not be equal to the 'mqtt.base_topic' (got 'homeassistant')");
});
it('Should warn when starting with cache_state false', async () => {
@@ -1633,7 +1662,7 @@ describe('Extension: HomeAssistant', () => {
name: null,
schema: 'json',
state_topic: 'zigbee2mqtt/ha_discovery_group_new',
supported_color_modes: ['brightness', 'xy', 'color_temp'],
supported_color_modes: ['xy', 'color_temp'],
effect: true,
effect_list: [
'blink',
@@ -2101,7 +2130,7 @@ describe('Extension: HomeAssistant', () => {
name: null,
schema: 'json',
state_topic: 'zigbee2mqtt/ha_discovery_group',
supported_color_modes: ['brightness', 'xy', 'color_temp'],
supported_color_modes: ['xy', 'color_temp'],
effect: true,
effect_list: [
'blink',
@@ -2145,7 +2174,7 @@ describe('Extension: HomeAssistant', () => {
name: null,
schema: 'json',
state_topic: 'zigbee2mqtt/ha_discovery_group',
supported_color_modes: ['brightness', 'xy', 'color_temp'],
supported_color_modes: ['xy', 'color_temp'],
effect: true,
effect_list: [
'blink',
@@ -2200,7 +2229,7 @@ describe('Extension: HomeAssistant', () => {
name: null,
schema: 'json',
state_topic: 'zigbee2mqtt/bulb',
supported_color_modes: ['brightness', 'color_temp'],
supported_color_modes: ['color_temp'],
object_id: 'bulb',
unique_id: '0x000b57fffec6a5b2_light_zigbee2mqtt',
origin: origin,
@@ -2665,4 +2694,28 @@ describe('Extension: HomeAssistant', () => {
expect(mockMQTTPublishAsync.mock.calls[2][0]).toStrictEqual('homeassistant/device_automation/0x0017880104e45520/action_single/config');
expect(mockMQTTPublishAsync.mock.calls[3][0]).toStrictEqual('zigbee2mqtt/button/action');
});
it('prevents mismatching setting/extension state', async () => {
settings.set(['homeassistant', 'enabled'], true);
await resetExtension();
await expect(async () => {
await controller.enableDisableExtension(false, 'HomeAssistant');
}).rejects.toThrow('Tried to disable HomeAssistant extension enabled in settings');
await expect(async () => {
await controller.enableDisableExtension(true, 'HomeAssistant');
}).rejects.toThrow('Extension with name HomeAssistant already present');
settings.set(['homeassistant', 'enabled'], false);
await expect(async () => {
await controller.enableDisableExtension(true, 'HomeAssistant');
}).rejects.toThrow('Tried to enable HomeAssistant extension disabled in settings');
settings.set(['homeassistant', 'enabled'], false);
await controller.enableDisableExtension(false, 'HomeAssistant');
await vi.waitFor(() => controller.getExtension('HomeAssistant') === undefined);
});
});
+23 -6
View File
@@ -104,7 +104,7 @@ describe('Extension: NetworkMap', () => {
data.writeEmptyState();
fs.mkdirSync(path.join(data.mockDir, 'external_converters'));
fs.copyFileSync(
path.join(__dirname, '..', 'assets', '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());
@@ -124,6 +124,8 @@ describe('Extension: NetworkMap', () => {
afterAll(async () => {
mockSleep.restore();
fs.rmSync(path.join(data.mockDir, 'external_converters'), {recursive: true});
await controller?.stop();
await flushPromises();
vi.useRealTimers();
});
@@ -302,7 +304,12 @@ describe('Extension: NetworkMap', () => {
type: 'Router',
},
{
definition: {description: 'external', model: 'external_converter_device', supports: 'linkquality', vendor: 'external'},
definition: {
description: 'external/converter',
model: 'external_converter_device',
supports: 'linkquality',
vendor: 'external',
},
friendlyName: '0x0017880104e45511',
ieeeAddr: '0x0017880104e45511',
lastSeen: 1000,
@@ -345,7 +352,7 @@ 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"]
}`;
@@ -378,7 +385,7 @@ describe('Extension: NetworkMap', () => {
---
0x0017880104e45511 (0x045a)
---
external external (external_converter_device)
external external/converter (external_converter_device)
---
9 seconds ago
]
@@ -604,7 +611,12 @@ describe('Extension: NetworkMap', () => {
type: 'Router',
},
{
definition: {description: 'external', model: 'external_converter_device', supports: 'linkquality', vendor: 'external'},
definition: {
description: 'external/converter',
model: 'external_converter_device',
supports: 'linkquality',
vendor: 'external',
},
friendlyName: '0x0017880104e45511',
ieeeAddr: '0x0017880104e45511',
lastSeen: 1000,
@@ -756,7 +768,12 @@ describe('Extension: NetworkMap', () => {
type: 'Router',
},
{
definition: {description: 'external', model: 'external_converter_device', supports: 'linkquality', vendor: 'external'},
definition: {
description: 'external/converter',
model: 'external_converter_device',
supports: 'linkquality',
vendor: 'external',
},
friendlyName: '0x0017880104e45511',
ieeeAddr: '0x0017880104e45511',
lastSeen: 1000,
+10 -10
View File
@@ -1,27 +1,26 @@
import * as data from '../mocks/data';
import {mockLogger} from '../mocks/logger';
import {mockMQTTPublishAsync} from '../mocks/mqtt';
import {flushPromises} from '../mocks/utils';
import {flushPromises, getZhcBaseDefinitions} from '../mocks/utils';
import {devices, events as mockZHEvents} from '../mocks/zigbeeHerdsman';
import {MockInstance} from 'vitest';
import * as zhc from 'zigbee-herdsman-converters';
import {Controller} from '../../lib/controller';
import * as settings from '../../lib/util/settings';
const mockOnEvent = vi.fn();
const mockLivoloOnEvent = vi.fn();
// @ts-expect-error mock
zhc.onEvent = mockOnEvent;
const mocksClear = [mockMQTTPublishAsync, mockLogger.warning, mockLogger.debug];
const mockOnEvent = vi.spyOn(zhc, 'onEvent');
const mocksClear = [mockMQTTPublishAsync, mockLogger.warning, mockLogger.debug, mockOnEvent];
describe('Extension: OnEvent', () => {
let controller: Controller;
let mockLivoloOnEvent: MockInstance;
beforeAll(async () => {
const mappedLivolo = (await zhc.findByDevice(devices.LIVOLO))!;
mappedLivolo.onEvent = mockLivoloOnEvent;
const livoloDefinition = (await getZhcBaseDefinitions()).find((d) => d.zigbeeModel?.includes(devices.LIVOLO.modelID!))!;
mockLivoloOnEvent = vi.spyOn(livoloDefinition, 'onEvent');
});
beforeEach(async () => {
@@ -39,10 +38,11 @@ describe('Extension: OnEvent', () => {
data.writeDefaultConfiguration();
settings.reRead();
mocksClear.forEach((m) => m.mockClear());
mockOnEvent.mockClear();
});
afterAll(async () => {
await controller?.stop();
await flushPromises();
vi.useRealTimers();
});
+6 -5
View File
@@ -8,11 +8,11 @@ import {devices, events as mockZHEvents} from '../mocks/zigbeeHerdsman';
import path from 'node:path';
import stringify from 'json-stable-stringify-without-jsonify';
import OTAUpdate from 'lib/extension/otaUpdate';
import * as zhc from 'zigbee-herdsman-converters';
import {Controller} from '../../lib/controller';
import OTAUpdate from '../../lib/extension/otaUpdate';
import * as settings from '../../lib/util/settings';
const mocksClear = [mockMQTTPublishAsync, devices.bulb.save, mockLogger.info];
@@ -29,8 +29,8 @@ describe('Extension: OTAUpdate', () => {
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 () => {
@@ -46,13 +46,14 @@ describe('Extension: OTAUpdate', () => {
afterAll(async () => {
mockSleep.restore();
await controller?.stop();
await flushPromises();
vi.useRealTimers();
});
beforeEach(async () => {
zhc.ota.setConfiguration(DEFAULT_CONFIG);
// @ts-expect-error private
const extension: OTAUpdate = controller.extensions.find((e) => e.constructor.name === 'OTAUpdate');
const extension = controller.getExtension('OTAUpdate')! as OTAUpdate;
// @ts-expect-error private
extension.lastChecked = {};
// @ts-expect-error private
+7 -5
View File
@@ -7,7 +7,7 @@ import {devices, groups, events as mockZHEvents} from '../mocks/zigbeeHerdsman';
import stringify from 'json-stable-stringify-without-jsonify';
import * as zhcGlobalStore from 'zigbee-herdsman-converters/lib/store';
import {clearGlobalStore} from 'zigbee-herdsman-converters';
import {Controller} from '../../lib/controller';
import {loadTopicGetSetRegex} from '../../lib/extension/publish';
@@ -58,13 +58,15 @@ describe('Extension: Publish', () => {
g.command.mockClear();
});
zhcGlobalStore.clear();
clearGlobalStore();
});
afterAll(async () => {
await vi.runOnlyPendingTimersAsync();
vi.useRealTimers();
mockSleep.restore();
await controller?.stop();
await flushPromises();
vi.useRealTimers();
});
it('Should publish messages to zigbee devices', async () => {
@@ -626,7 +628,7 @@ describe('Extension: Publish', () => {
mockLogger.error.mockClear();
await mockMQTTEvents.message('zigbee2mqtt/0x0017880104e45542/get', stringify({state_center: '', state_right: ''}));
await flushPromises();
expect(mockLogger.error).toHaveBeenCalledWith(`No converter available for 'state_center' ("")`);
expect(mockLogger.error).toHaveBeenCalledWith(`No converter available for 'state_center' on 'wall_switch_double': ("")`);
expect(endpoint2.read).toHaveBeenCalledTimes(0);
expect(endpoint3.read).toHaveBeenCalledTimes(1);
expect(endpoint3.read).toHaveBeenCalledWith('genOnOff', ['onOff']);
@@ -1511,7 +1513,7 @@ describe('Extension: Publish', () => {
await mockMQTTEvents.message('zigbee2mqtt/bulb_color/set', stringify({state: 'ON', brightness: 20, transition: 0.0}));
await flushPromises();
zhcGlobalStore.clear();
clearGlobalStore();
await mockMQTTEvents.message('zigbee2mqtt/bulb_color/set', stringify({state: 'ON', transition: 1.0}));
await flushPromises();
+3 -1
View File
@@ -33,8 +33,10 @@ describe('Extension: Receive', () => {
});
afterAll(async () => {
vi.useRealTimers();
mockSleep.restore();
await controller?.stop();
await flushPromises();
vi.useRealTimers();
});
it('Should handle a zigbee message', async () => {
+18 -6
View File
@@ -7,7 +7,9 @@ import tmp from 'tmp';
import yaml from '../../lib/util/yaml';
export const mockDir: string = tmp.dirSync().name;
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},
@@ -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 {
@@ -290,8 +298,12 @@ export function writeDefaultState(): void {
fs.writeFileSync(path.join(mockDir, 'state.json'), stringify(defaultState));
}
export function read(): ReturnType<typeof yaml.read> {
return yaml.read(path.join(mockDir, 'configuration.yaml'));
export function writeEmptyDatabase(): void {
fs.writeFileSync(databaseFile, '');
}
export function removeDatabase(): void {
fs.rmSync(databaseFile, {force: true});
}
vi.mock('../../lib/util/data', async () => {
+2 -1
View File
@@ -1,6 +1,7 @@
import type {LogLevel} from 'lib/util/settings';
import type Transport from 'winston-transport';
import type {LogLevel} from '../../lib/util/settings';
let level: LogLevel = 'info';
let debugNamespaceIgnore: string = '';
let namespacedLevels: Record<string, LogLevel> = {};
+2 -2
View File
@@ -20,9 +20,9 @@ export const mockMQTTConnectAsync = vi.fn(() => ({
endAsync: mockMQTTEndAsync,
subscribeAsync: mockMQTTSubscribeAsync,
unsubscribeAsync: mockMQTTUnsubscribeAsync,
on: vi.fn((type, handler) => {
on: vi.fn(async (type, handler) => {
if (type === 'connect') {
handler();
await handler();
}
events[type] = handler;
+6
View File
@@ -1,3 +1,5 @@
import type {DefinitionWithExtend} from 'zigbee-herdsman-converters';
export type EventHandler = (...args: unknown[]) => unknown;
export async function flushPromises(): Promise<void> {
@@ -12,3 +14,7 @@ export function defuseRejection<T>(promise: Promise<T>): Promise<T> {
return promise;
}
export async function getZhcBaseDefinitions(): Promise<DefinitionWithExtend[]> {
return (await import('zigbee-herdsman-converters/devices/index')).default;
}
+739
View File
@@ -0,0 +1,739 @@
import * as data from './mocks/data';
import type {IncomingMessage, OutgoingHttpHeader, OutgoingHttpHeaders, RequestListener, Server, ServerResponse} from 'node:http';
import {rmSync} from 'node:fs';
import {findAllDevices} from 'zigbee-herdsman/dist/adapter/adapterDiscovery';
import {onboard} from '../lib/util/onboarding';
import * as settings from '../lib/util/settings';
const mockHttpOnListen = vi.fn(() => Promise.resolve());
const mockHttpListener = vi.fn<RequestListener<typeof IncomingMessage, typeof ServerResponse>>();
const mockHttpListen = vi.fn<Server['listen']>(
// @ts-expect-error mocked for used definition
async (port, host, listeningListener) => {
if (typeof listeningListener === 'function') {
listeningListener();
}
await mockHttpOnListen();
},
);
const mockHttpClose = vi.fn<Server['close']>(
// @ts-expect-error minimal mock
(cb) => {
cb?.();
},
);
const mockFindAllDevices = vi.fn<typeof findAllDevices>(async () => []);
vi.mock('node:fs', {spy: true});
vi.mock('node:http', () => ({
createServer: vi.fn((listener) => {
if (listener) {
mockHttpListener.mockImplementation(listener);
}
return {
listen: mockHttpListen,
close: mockHttpClose,
};
}),
}));
vi.mock('zigbee-herdsman/dist/adapter/adapterDiscovery', () => ({
findAllDevices: vi.fn(() => mockFindAllDevices()),
}));
const SETTINGS_MINIMAL_DEFAULTS = {
version: settings.CURRENT_VERSION,
mqtt: {
base_topic: settings.defaults.mqtt!.base_topic,
server: 'mqtt://localhost:1883',
},
serial: {},
advanced: {
log_level: settings.defaults.advanced!.log_level,
channel: settings.defaults.advanced!.channel,
network_key: 'GENERATE',
pan_id: 'GENERATE',
ext_pan_id: 'GENERATE',
},
frontend: {
enabled: settings.defaults.frontend!.enabled,
port: settings.defaults.frontend!.port,
},
homeassistant: {
enabled: settings.defaults.homeassistant!.enabled,
},
};
const SAMPLE_SETTINGS_INIT = {
version: settings.CURRENT_VERSION,
mqtt: {
base_topic: 'zigbee2mqtt',
server: 'mqtt://localhost:1883',
},
serial: {
port: '/dev/ttyUSB0',
adapter: 'zstack',
baudrate: 115200,
rtscts: false,
},
advanced: {
log_level: 'info',
channel: 15,
network_key: [13, 53, 58, 7, 93, 131, 113, 215, 40, 32, 4, 26, 8, 110, 142, 213],
pan_id: 54321,
ext_pan_id: [0xee, 0xdd, 0xcc, 0xdd, 0xaa, 0xdd, 0x11, 0xdd],
},
frontend: {
enabled: false,
port: 8080,
},
homeassistant: {
enabled: false,
},
};
const SAMPLE_SETTINGS_SAVE = {
version: settings.CURRENT_VERSION,
mqtt: {
base_topic: 'zigbee2mqtt2',
server: 'mqtt://192.168.1.200:1883',
},
serial: {
port: 'COM3',
adapter: 'ember',
baudrate: 230400,
rtscts: true,
},
advanced: {
log_level: 'debug',
channel: 25,
network_key: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
pan_id: 12345,
ext_pan_id: [8, 7, 6, 5, 4, 3, 2, 1],
},
frontend: {
enabled: true,
port: 8080,
},
homeassistant: {
enabled: true,
},
};
const SAMPLE_SETTINGS_SAVE_PARAMS = {
mqtt_base_topic: `zigbee2mqtt2`,
mqtt_server: `mqtt://192.168.1.200:1883`,
mqtt_user: '',
mqtt_password: '',
serial_port: `COM3`,
serial_adapter: `ember`,
serial_baudrate: `230400`,
serial_rtscts: `on`,
log_level: `debug`,
network_channel: `25`,
network_key: `1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16`,
network_pan_id: `12345`,
network_ext_pan_id: `8,7,6,5,4,3,2,1`,
frontend_enabled: `on`,
frontend_port: '8080',
homeassistant_enabled: `on`,
};
describe('Onboarding', () => {
beforeAll(() => {
vi.useFakeTimers();
});
afterAll(() => {
vi.useRealTimers();
});
beforeEach(() => {
delete process.env.Z2M_ONBOARD_NO_SERVER;
delete process.env.Z2M_ONBOARD_FORCE_RUN;
delete process.env.Z2M_ONBOARD_URL;
delete process.env.Z2M_ONBOARD_NO_FAILURE_PAGE;
delete process.env.Z2M_ONBOARD_NO_REDIRECT;
delete process.env.ZIGBEE2MQTT_CONFIG_MQTT_SERVER;
delete process.env.ZIGBEE2MQTT_CONFIG_SERIAL_BAUDRATE;
delete process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_CHANNEL;
delete process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_NETWORK_KEY;
delete process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_PAN_ID;
delete process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_EXT_PAN_ID;
delete process.env.ZIGBEE2MQTT_CONFIG_FRONTEND_PORT;
data.writeDefaultConfiguration(SAMPLE_SETTINGS_INIT);
data.removeState();
data.removeDatabase();
mockHttpListener.mockClear();
mockHttpListen.mockClear();
mockHttpClose.mockClear();
mockFindAllDevices.mockClear();
settings.reRead();
});
afterEach(() => {});
const runOnboarding = async (
params: Record<string, string>,
expectWriteMinimal: boolean,
expectFailure: boolean,
): Promise<[getHtml: string, postHtml: string]> => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const reqDataListener = vi.fn<(chunk: any) => void>();
const reqEndListener = vi.fn<() => void>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resEnd = vi.fn<(chunk: any | (() => void), cb?: () => void) => ServerResponse<IncomingMessage>>(
// @ts-expect-error return not used
(chunk, cb) => {
if (typeof chunk === 'function') {
chunk();
} else if (cb) {
cb();
}
},
);
const resSetHeader = vi.fn<(name: string, value: number | string | readonly string[]) => ServerResponse<IncomingMessage>>();
const resWriteHead =
vi.fn<
(statusCode: number, statusMessage?: string, headers?: OutgoingHttpHeaders | OutgoingHttpHeader[]) => ServerResponse<IncomingMessage>
>();
mockHttpListener(
{
method: 'GET',
// @ts-expect-error return not used
on: () => {},
},
{
end: resEnd,
setHeader: resSetHeader,
writeHead: resWriteHead,
},
);
await vi.advanceTimersByTimeAsync(100); // flush
if (expectWriteMinimal) {
const minimal = process.env.ZIGBEE2MQTT_CONFIG_MQTT_SERVER
? Object.assign({}, SETTINGS_MINIMAL_DEFAULTS, {
mqtt: {server: process.env.ZIGBEE2MQTT_CONFIG_MQTT_SERVER, base_topic: SETTINGS_MINIMAL_DEFAULTS.mqtt.base_topic},
})
: SETTINGS_MINIMAL_DEFAULTS;
expect(data.read()).toStrictEqual(minimal);
}
expect(mockFindAllDevices).toHaveBeenCalledTimes(1);
expect(resSetHeader).toHaveBeenNthCalledWith(1, 'Content-Type', 'text/html');
expect(resWriteHead).toHaveBeenNthCalledWith(1, 200);
expect(resEnd).toHaveBeenCalledTimes(1);
mockHttpListener(
{
method: 'POST',
// @ts-expect-error return not used
on: (event, listener) => {
if (event === 'data') {
reqDataListener.mockImplementation(listener);
} else if (event === 'end') {
// @ts-expect-error typing not narrowed
reqEndListener.mockImplementation(listener);
}
},
},
{
end: resEnd,
setHeader: resSetHeader,
writeHead: resWriteHead,
},
);
for (const k in params) {
reqDataListener(`${k}=${params[k as keyof typeof params]}&`);
}
reqEndListener();
await vi.advanceTimersByTimeAsync(100); // flush
if (expectFailure) {
if (process.env.Z2M_ONBOARD_NO_FAILURE_PAGE) {
expect(resEnd).toHaveBeenCalledTimes(2);
} else {
mockHttpListener(
{
method: 'POST',
// @ts-expect-error return not used
on: () => {},
},
{
end: resEnd,
setHeader: resSetHeader,
writeHead: resWriteHead,
},
);
await vi.advanceTimersByTimeAsync(100); // flush
expect(resSetHeader).toHaveBeenNthCalledWith(2, 'Content-Type', 'text/html');
expect(resWriteHead).toHaveBeenNthCalledWith(2, 406);
expect(resEnd).toHaveBeenCalledTimes(3);
}
} else {
expect(resSetHeader).toHaveBeenNthCalledWith(2, 'Content-Type', 'text/html');
expect(resWriteHead).toHaveBeenNthCalledWith(2, 200);
expect(resEnd).toHaveBeenCalledTimes(2);
}
const serverUrl = new URL(process.env.Z2M_ONBOARD_URL ?? 'http://0.0.0.0:8080');
expect(mockHttpListen).toHaveBeenCalledWith(parseInt(serverUrl.port), serverUrl.hostname, expect.any(Function));
return [resEnd.mock.calls[0][0], resEnd.mock.calls[1][0]];
};
const runFailure = async (): Promise<string> => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resEnd = vi.fn<(chunk: any | (() => void), cb?: () => void) => ServerResponse<IncomingMessage>>(
// @ts-expect-error return not used
(chunk, cb) => {
if (typeof chunk === 'function') {
chunk();
} else if (cb) {
cb();
}
},
);
const resSetHeader = vi.fn<(name: string, value: number | string | readonly string[]) => ServerResponse<IncomingMessage>>();
const resWriteHead =
vi.fn<
(statusCode: number, statusMessage?: string, headers?: OutgoingHttpHeaders | OutgoingHttpHeader[]) => ServerResponse<IncomingMessage>
>();
mockHttpListener(
{
method: 'GET',
// @ts-expect-error return not used
on: () => {},
},
{
end: resEnd,
setHeader: resSetHeader,
writeHead: resWriteHead,
},
);
await vi.advanceTimersByTimeAsync(100); // flush
expect(resSetHeader).toHaveBeenNthCalledWith(1, 'Content-Type', 'text/html');
expect(resWriteHead).toHaveBeenNthCalledWith(1, 406);
expect(resEnd).toHaveBeenCalledTimes(1);
mockHttpListener(
{
method: 'POST',
// @ts-expect-error return not used
on: () => {},
},
{
end: resEnd,
setHeader: resSetHeader,
writeHead: resWriteHead,
},
);
await vi.advanceTimersByTimeAsync(100); // flush
expect(resEnd).toHaveBeenCalledTimes(2);
const serverUrl = new URL(process.env.Z2M_ONBOARD_URL ?? 'http://0.0.0.0:8080');
expect(mockHttpListen).toHaveBeenCalledWith(parseInt(serverUrl.port), serverUrl.hostname, expect.any(Function));
return resEnd.mock.calls[0][0];
};
it('creates config file and sets given settings', async () => {
data.removeConfiguration();
let p;
const [getHtml, postHtml] = await new Promise<[string, string]>((resolve, reject) => {
mockHttpOnListen.mockImplementationOnce(async () => {
try {
resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE_PARAMS, true, false));
} catch (error) {
reject(error);
}
});
p = onboard();
});
await expect(p).resolves.toStrictEqual(true);
expect(data.read()).toStrictEqual(SAMPLE_SETTINGS_SAVE);
expect(getHtml).toContain('No device found');
expect(getHtml).not.toContain('generate_network');
expect(postHtml).toContain('<a href="http://localhost:8080/">');
});
it('creates config file and sets given unusual settings', async () => {
data.removeConfiguration();
process.env.ZIGBEE2MQTT_CONFIG_MQTT_SERVER = 'mqtt://core-mosquitto:1883';
mockFindAllDevices.mockResolvedValueOnce([
{name: 'My Device', path: '/dev/serial/by-id/my-device-001', adapter: 'ember'},
{name: 'My Device 2', path: '/dev/serial/by-id/my-device-002', adapter: undefined},
]);
let p;
const [getHtml, postHtml] = await new Promise<[string, string]>((resolve, reject) => {
mockHttpOnListen.mockImplementationOnce(async () => {
try {
resolve(
await runOnboarding(
Object.assign({}, SAMPLE_SETTINGS_SAVE_PARAMS, {
mqtt_user: 'abcd',
mqtt_password: 'defg',
frontend_enabled: undefined,
network_key: 'GENERATE',
network_pan_id: 'GENERATE',
network_ext_pan_id: 'GENERATE',
}),
true,
false,
),
);
} catch (error) {
reject(error);
}
});
p = onboard();
});
await expect(p).resolves.toStrictEqual(true);
expect(data.read()).toStrictEqual(
Object.assign({}, SAMPLE_SETTINGS_SAVE, {
advanced: {
log_level: SAMPLE_SETTINGS_SAVE.advanced.log_level,
channel: SAMPLE_SETTINGS_SAVE.advanced.channel,
network_key: 'GENERATE',
pan_id: 'GENERATE',
ext_pan_id: 'GENERATE',
},
frontend: {
enabled: false,
port: SAMPLE_SETTINGS_SAVE.frontend.port,
},
mqtt: {
base_topic: SAMPLE_SETTINGS_SAVE.mqtt.base_topic,
server: process.env.ZIGBEE2MQTT_CONFIG_MQTT_SERVER,
user: 'abcd',
password: 'defg',
},
}),
);
expect(getHtml).toContain(`<option value="My Device, /dev/serial/by-id/my-device-001, ember">`);
expect(getHtml).toContain(`<option value="My Device 2, /dev/serial/by-id/my-device-002, unknown">`);
expect(getHtml).toContain(process.env.ZIGBEE2MQTT_CONFIG_MQTT_SERVER);
expect(postHtml).toContain('You can close this page');
});
it('rerun onboard via ENV and sets given settings', async () => {
// data.removeConfiguration();
process.env.Z2M_ONBOARD_FORCE_RUN = '1';
let p;
const [getHtml, postHtml] = await new Promise<[string, string]>((resolve, reject) => {
mockHttpOnListen.mockImplementationOnce(async () => {
try {
resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE_PARAMS, false, false));
} catch (error) {
reject(error);
}
});
p = onboard();
});
await expect(p).resolves.toStrictEqual(true);
expect(data.read()).toStrictEqual(SAMPLE_SETTINGS_SAVE);
expect(getHtml).toContain('No device found');
expect(getHtml).toContain('generate_network');
expect(postHtml).toContain('<a href="http://localhost:8080/">');
});
it('sets given settings - no frontend redirect', async () => {
data.removeConfiguration();
vi.spyOn(settings, 'writeMinimalDefaults').mockImplementationOnce(() => {
settings.writeMinimalDefaults();
settings.set(['frontend', 'host'], '/run/zigbee2mqtt/zigbee2mqtt.sock');
});
let p;
const [getHtml, postHtml] = await new Promise<[string, string]>((resolve, reject) => {
mockHttpOnListen.mockImplementationOnce(async () => {
try {
resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE_PARAMS, false, false));
} catch (error) {
reject(error);
}
});
p = onboard();
});
await expect(p).resolves.toStrictEqual(true);
expect(data.read()).toStrictEqual(
Object.assign({}, SAMPLE_SETTINGS_SAVE, {
frontend: {
enabled: SAMPLE_SETTINGS_SAVE.frontend.enabled,
port: SAMPLE_SETTINGS_SAVE.frontend.port,
host: '/run/zigbee2mqtt/zigbee2mqtt.sock',
},
}),
);
expect(getHtml).toContain('No device found');
expect(postHtml).toContain('You can close this page');
});
it('sets given settings - no frontend redirect via ENV', async () => {
data.removeConfiguration();
process.env.Z2M_ONBOARD_NO_REDIRECT = '1';
let p;
const [getHtml, postHtml] = await new Promise<[string, string]>((resolve, reject) => {
mockHttpOnListen.mockImplementationOnce(async () => {
try {
resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE_PARAMS, false, false));
} catch (error) {
reject(error);
}
});
p = onboard();
});
await expect(p).resolves.toStrictEqual(true);
expect(data.read()).toStrictEqual(SAMPLE_SETTINGS_SAVE);
expect(getHtml).toContain('No device found');
expect(postHtml).toContain('You can close this page');
});
it('sets given settings - frontend SSL redirect', async () => {
data.removeConfiguration();
vi.spyOn(settings, 'writeMinimalDefaults').mockImplementationOnce(() => {
settings.writeMinimalDefaults();
settings.set(['frontend', 'ssl_cert'], 'dummy');
settings.set(['frontend', 'ssl_key'], 'dummy2');
});
let p;
const [getHtml, postHtml] = await new Promise<[string, string]>((resolve, reject) => {
mockHttpOnListen.mockImplementationOnce(async () => {
try {
resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE_PARAMS, false, false));
} catch (error) {
reject(error);
}
});
p = onboard();
});
await expect(p).resolves.toStrictEqual(true);
expect(data.read()).toStrictEqual(
Object.assign({}, SAMPLE_SETTINGS_SAVE, {
frontend: {
enabled: SAMPLE_SETTINGS_SAVE.frontend.enabled,
port: SAMPLE_SETTINGS_SAVE.frontend.port,
ssl_cert: 'dummy',
ssl_key: 'dummy2',
},
}),
);
expect(getHtml).toContain('No device found');
expect(postHtml).toContain('<a href="https://localhost:8080/">');
});
it('handles saving errors', async () => {
process.env.Z2M_ONBOARD_FORCE_RUN = '1';
let p;
const [getHtml, postHtml] = await new Promise<[string, string]>((resolve, reject) => {
mockHttpOnListen.mockImplementationOnce(async () => {
try {
resolve(await runOnboarding(Object.assign({}, SAMPLE_SETTINGS_SAVE_PARAMS, {serial_adapter: 'emberz'}), false, true));
} catch (error) {
reject(error);
}
});
p = onboard();
});
await expect(p).resolves.toStrictEqual(false);
expect(data.read()).toStrictEqual(SAMPLE_SETTINGS_INIT);
expect(getHtml).toContain('No device found');
expect(postHtml).toContain('adapter must be equal to one of the allowed values');
});
it('handles configuring onboarding via ENV', async () => {
data.removeConfiguration();
process.env.Z2M_ONBOARD_URL = 'http://192.168.1.123:8888';
process.env.Z2M_ONBOARD_NO_FAILURE_PAGE = '1';
process.env.ZIGBEE2MQTT_CONFIG_MQTT_SERVER = 'mqtt://core-mosquitto:1883';
let p;
await new Promise<[string, string]>((resolve, reject) => {
mockHttpOnListen.mockImplementationOnce(async () => {
try {
resolve(await runOnboarding(Object.assign({}, SAMPLE_SETTINGS_SAVE_PARAMS, {serial_adapter: 'emberz'}), true, true));
} catch (error) {
reject(error);
}
});
p = onboard();
});
await expect(p).resolves.toStrictEqual(false);
expect(data.read()).toStrictEqual(
Object.assign({}, SETTINGS_MINIMAL_DEFAULTS, {
mqtt: {server: process.env.ZIGBEE2MQTT_CONFIG_MQTT_SERVER, base_topic: SETTINGS_MINIMAL_DEFAULTS.mqtt.base_topic},
}),
);
});
it('handles disabling onboarding server via ENV', async () => {
data.removeConfiguration();
process.env.Z2M_ONBOARD_NO_SERVER = '1';
process.env.ZIGBEE2MQTT_CONFIG_MQTT_SERVER = 'mqtt://core-mosquitto:1883';
const p = onboard();
await expect(p).resolves.toStrictEqual(true);
expect(data.read()).toStrictEqual(
Object.assign({}, SETTINGS_MINIMAL_DEFAULTS, {
mqtt: {server: process.env.ZIGBEE2MQTT_CONFIG_MQTT_SERVER, base_topic: SETTINGS_MINIMAL_DEFAULTS.mqtt.base_topic},
}),
);
});
it('handles configuring onboarding with config ENV overrides', async () => {
process.env.Z2M_ONBOARD_FORCE_RUN = '1';
process.env.ZIGBEE2MQTT_CONFIG_SERIAL_BAUDRATE = '230400';
process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_CHANNEL = '20';
process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_NETWORK_KEY = '[11,22,33,44,55,66,77,88,99,10,11,12,13,14,15,16]';
process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_PAN_ID = '1';
process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_EXT_PAN_ID = '[11,22,33,44,55,66,15,16]';
process.env.ZIGBEE2MQTT_CONFIG_FRONTEND_PORT = '8282';
let p;
await new Promise<[string, string]>((resolve, reject) => {
mockHttpOnListen.mockImplementationOnce(async () => {
const newSettings = Object.assign({}, SAMPLE_SETTINGS_SAVE_PARAMS);
// @ts-expect-error mock disabled field
delete newSettings.serial_baudrate;
// @ts-expect-error mock disabled field
delete newSettings.network_channel;
// @ts-expect-error mock disabled field
delete newSettings.network_key;
// @ts-expect-error mock disabled field
delete newSettings.network_pan_id;
// @ts-expect-error mock disabled field
delete newSettings.network_ext_pan_id;
// @ts-expect-error mock disabled field
delete newSettings.frontend_port;
try {
resolve(await runOnboarding(newSettings, false, false));
} catch (error) {
reject(error);
}
});
p = onboard();
});
await expect(p).resolves.toStrictEqual(true);
expect(data.read()).toStrictEqual(
Object.assign({}, SAMPLE_SETTINGS_SAVE, {
serial: {
port: SAMPLE_SETTINGS_SAVE.serial.port,
adapter: SAMPLE_SETTINGS_SAVE.serial.adapter,
baudrate: 230400,
rtscts: SAMPLE_SETTINGS_SAVE.serial.rtscts,
},
advanced: {
log_level: SAMPLE_SETTINGS_SAVE.advanced.log_level,
channel: 20,
network_key: [11, 22, 33, 44, 55, 66, 77, 88, 99, 10, 11, 12, 13, 14, 15, 16],
pan_id: 1,
ext_pan_id: [11, 22, 33, 44, 55, 66, 15, 16],
},
frontend: {
enabled: SAMPLE_SETTINGS_SAVE.frontend.enabled,
port: 8282,
},
}),
);
});
it('runs migrations', async () => {
settings.set(['version'], settings.CURRENT_VERSION - 1);
const p = onboard();
await expect(p).resolves.toStrictEqual(true);
expect(settings.get().version).toStrictEqual(settings.CURRENT_VERSION);
});
it('handles validation failure', async () => {
settings.set(['serial', 'adapter'], 'emberz');
let p;
const getHtml = await new Promise<string>((resolve, reject) => {
mockHttpOnListen.mockImplementationOnce(async () => {
try {
resolve(await runFailure());
} catch (error) {
reject(error);
}
});
p = onboard();
});
await expect(p).resolves.toStrictEqual(false);
expect(getHtml).toContain('adapter must be equal to one of the allowed values');
});
it('handles creating data path', async () => {
rmSync(data.mockDir, {force: true, recursive: true});
let p;
await new Promise<[string, string]>((resolve, reject) => {
mockHttpOnListen.mockImplementationOnce(async () => {
try {
resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE_PARAMS, true, false));
} catch (error) {
reject(error);
}
});
p = onboard();
});
await expect(p).resolves.toStrictEqual(true);
expect(data.read()).toStrictEqual(SAMPLE_SETTINGS_SAVE);
});
});
+175
View File
@@ -0,0 +1,175 @@
import {mockLogger} from './mocks/logger';
import {initSdNotify} from '../lib/util/sd-notify';
const mockPlatform = vi.fn(() => 'linux');
vi.mock('node:os', () => ({
platform: vi.fn(() => mockPlatform()),
}));
const mockUnixDgramSocket = {
send: vi.fn(),
};
const mockCreateSocket = vi.fn(() => {
if (mockPlatform() !== 'win32') {
return mockUnixDgramSocket;
}
throw new Error('Unix datagrams not available on this platform');
});
vi.mock('unix-dgram', () => ({
createSocket: mockCreateSocket,
}));
const mocksClear = [
mockLogger.log,
mockLogger.debug,
mockLogger.info,
mockLogger.warning,
mockLogger.error,
mockUnixDgramSocket.send,
mockCreateSocket,
mockPlatform,
];
describe('sd-notify', () => {
const expectSocketNthSend = (nth: number, message: string): void => {
expect(mockUnixDgramSocket.send).toHaveBeenNthCalledWith(nth, Buffer.from(message), 0, expect.any(Number), 'mocked', expect.any(Function));
};
beforeAll(async () => {
vi.useFakeTimers();
});
afterAll(async () => {
vi.useRealTimers();
});
beforeEach(() => {
mocksClear.forEach((m) => m.mockClear());
delete process.env.NOTIFY_SOCKET;
delete process.env.WATCHDOG_USEC;
delete process.env.WSL_DISTRO_NAME;
});
it('No socket', async () => {
const res = await initSdNotify();
expect(mockCreateSocket).toHaveBeenCalledTimes(0);
expect(res).toBeUndefined();
expect(mockUnixDgramSocket.send).toHaveBeenCalledTimes(0);
});
it('Error on unsupported platform', async () => {
// also called by `mockCreateSocket`
mockPlatform.mockImplementationOnce(() => 'win32').mockImplementationOnce(() => 'win32');
process.env.NOTIFY_SOCKET = 'mocked';
const res = await initSdNotify();
expect(mockCreateSocket).toHaveBeenCalledTimes(1);
expect(res).toBeUndefined();
expect(mockUnixDgramSocket.send).toHaveBeenCalledTimes(0);
expect(mockLogger.warning).toHaveBeenCalledWith(`NOTIFY_SOCKET env is set: Unix datagrams not available on this platform`);
});
it('Error on supported platform', async () => {
// NOTE: `import('unix-dgram')` can also fail in similar way when bindings are missing (not compiled)
mockCreateSocket.mockImplementationOnce(() => {
throw new Error('Error create socket');
});
process.env.NOTIFY_SOCKET = 'mocked';
const res = await initSdNotify();
expect(mockCreateSocket).toHaveBeenCalledTimes(1);
expect(res).toBeUndefined();
expect(mockUnixDgramSocket.send).toHaveBeenCalledTimes(0);
expect(mockLogger.error).toHaveBeenCalledWith('Could not init sd_notify: Error create socket');
});
it('Socket only', async () => {
process.env.NOTIFY_SOCKET = 'mocked';
const res = await initSdNotify();
expect(res).toStrictEqual({notifyStopping: expect.any(Function), stop: expect.any(Function)});
expect(mockUnixDgramSocket.send).toHaveBeenCalledTimes(1);
expectSocketNthSend(1, 'READY=1');
await vi.advanceTimersByTimeAsync(7500);
res!.notifyStopping();
expect(mockUnixDgramSocket.send).toHaveBeenCalledTimes(2);
expectSocketNthSend(2, 'STOPPING=1');
res!.stop();
expect(mockUnixDgramSocket.send).toHaveBeenCalledTimes(2);
});
it('Invalid watchdog timeout - socket only', async () => {
process.env.NOTIFY_SOCKET = 'mocked';
process.env.WATCHDOG_USEC = 'mocked';
const res = await initSdNotify();
expect(res).toStrictEqual({notifyStopping: expect.any(Function), stop: expect.any(Function)});
expect(mockUnixDgramSocket.send).toHaveBeenCalledTimes(1);
expectSocketNthSend(1, 'READY=1');
await vi.advanceTimersByTimeAsync(7500);
res!.notifyStopping();
expect(mockUnixDgramSocket.send).toHaveBeenCalledTimes(2);
expectSocketNthSend(2, 'STOPPING=1');
res!.stop();
expect(mockUnixDgramSocket.send).toHaveBeenCalledTimes(2);
});
it('Socket and watchdog', async () => {
process.env.NOTIFY_SOCKET = 'mocked';
process.env.WATCHDOG_USEC = '10000000';
const res = await initSdNotify();
expect(res).toStrictEqual({notifyStopping: expect.any(Function), stop: expect.any(Function)});
expect(mockUnixDgramSocket.send).toHaveBeenCalledTimes(1);
expectSocketNthSend(1, 'READY=1');
await vi.advanceTimersByTimeAsync(7500);
expect(mockUnixDgramSocket.send).toHaveBeenCalledTimes(2);
expectSocketNthSend(2, 'WATCHDOG=1');
res!.notifyStopping();
expect(mockUnixDgramSocket.send).toHaveBeenCalledTimes(3);
expectSocketNthSend(3, 'STOPPING=1');
await vi.advanceTimersByTimeAsync(6000);
expect(mockUnixDgramSocket.send).toHaveBeenCalledTimes(4);
expectSocketNthSend(4, 'WATCHDOG=1');
res!.stop();
expect(mockUnixDgramSocket.send).toHaveBeenCalledTimes(4);
await vi.advanceTimersByTimeAsync(10000);
expect(mockUnixDgramSocket.send).toHaveBeenCalledTimes(4);
});
it('Fails to send', async () => {
mockUnixDgramSocket.send.mockImplementationOnce(
(buf: Buffer, offset: number, length: number, path: string, callback?: (err?: Error) => void) => {
callback!(new Error('Failure'));
},
);
process.env.NOTIFY_SOCKET = 'mocked';
const res = await initSdNotify();
expect(res).toStrictEqual({notifyStopping: expect.any(Function), stop: expect.any(Function)});
expect(mockUnixDgramSocket.send).toHaveBeenCalledTimes(1);
expectSocketNthSend(1, 'READY=1');
expect(mockLogger.warning).toHaveBeenCalledWith(`Failed to send "READY=1" to systemd: Failure`);
});
});
+17 -12
View File
@@ -38,7 +38,7 @@ describe('Settings', () => {
const clearEnvironmentVariables = (): void => {
for (const key in process.env) {
if (key.indexOf('ZIGBEE2MQTT_CONFIG_') >= 0) {
if (key.startsWith('ZIGBEE2MQTT_CONFIG_')) {
delete process.env[key];
}
}
@@ -104,17 +104,16 @@ describe('Settings', () => {
expect(s).toStrictEqual(expected);
});
it('Should apply environment variables', () => {
process.env['ZIGBEE2MQTT_CONFIG_SERIAL_DISABLE_LED'] = 'true';
process.env['ZIGBEE2MQTT_CONFIG_ADVANCED_CHANNEL'] = '15';
process.env['ZIGBEE2MQTT_CONFIG_ADVANCED_OUTPUT'] = 'attribute_and_json';
process.env['ZIGBEE2MQTT_CONFIG_ADVANCED_LOG_OUTPUT'] = '["console"]';
process.env['ZIGBEE2MQTT_CONFIG_MAP_OPTIONS_GRAPHVIZ_COLORS_FILL'] =
'{"enddevice": "#ff0000", "coordinator": "#00ff00", "router": "#0000ff"}';
process.env['ZIGBEE2MQTT_CONFIG_MQTT_BASE_TOPIC'] = 'testtopic';
process.env['ZIGBEE2MQTT_CONFIG_MQTT_SERVER'] = 'testserver';
process.env['ZIGBEE2MQTT_CONFIG_ADVANCED_NETWORK_KEY'] = 'GENERATE';
process.env['ZIGBEE2MQTT_CONFIG_DEVICES'] = 'devices.yaml';
it('Should apply environment variables as overrides', () => {
process.env.ZIGBEE2MQTT_CONFIG_SERIAL_DISABLE_LED = 'true';
process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_CHANNEL = '15';
process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_OUTPUT = 'attribute_and_json';
process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_LOG_OUTPUT = '["console"]';
process.env.ZIGBEE2MQTT_CONFIG_MAP_OPTIONS_GRAPHVIZ_COLORS_FILL = '{"enddevice": "#ff0000", "coordinator": "#00ff00", "router": "#0000ff"}';
process.env.ZIGBEE2MQTT_CONFIG_MQTT_BASE_TOPIC = 'testtopic';
process.env.ZIGBEE2MQTT_CONFIG_MQTT_SERVER = 'testserver';
process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_NETWORK_KEY = 'GENERATE';
process.env.ZIGBEE2MQTT_CONFIG_DEVICES = 'devices.yaml';
const contentDevices = {
'0x00158d00018255df': {
@@ -125,6 +124,7 @@ describe('Settings', () => {
write(configurationFile, {});
write(devicesFile, contentDevices);
expect(settings.write()); // trigger writing of ENVs
expect(settings.validate()).toStrictEqual([]);
const s = settings.get();
@@ -147,6 +147,11 @@ describe('Settings', () => {
expected.advanced.network_key = 'GENERATE';
expect(s).toStrictEqual(expected);
settings.set(['advanced', 'channel'], 25);
expect(settings.get().advanced.channel).toStrictEqual(15);
expect(read(configurationFile)).toMatchObject({advanced: {channel: 15}});
});
it('Should add devices', () => {
+38 -9
View File
@@ -1,13 +1,11 @@
import {exec} from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import utils from '../lib/util/utils';
const mockGetLastCommit = vi.fn<() => [boolean, {shortHash: string} | null]>(() => [false, {shortHash: '123'}]);
vi.mock('git-last-commit', () => ({
getLastCommit: vi.fn((cb) => cb(...mockGetLastCommit())),
}));
// keep the implementations, just spy
vi.mock('node:child_process', {spy: true});
describe('Utils', () => {
it('Object is empty', () => {
@@ -20,13 +18,44 @@ describe('Utils', () => {
expect(utils.objectHasProperties({a: 1, b: 2, c: 3}, ['a', 'b', 'd'])).toBeFalsy();
});
it('git last commit', async () => {
it('get Z2M version', async () => {
const readFileSyncSpy = vi.spyOn(fs, 'readFileSync');
const version = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version;
expect(await utils.getZigbee2MQTTVersion()).toStrictEqual({commitHash: '123', version: version});
expect(await utils.getZigbee2MQTTVersion()).toStrictEqual({commitHash: expect.stringMatching(/^(?!unknown)[a-z0-9]{8}$/), version});
expect(exec).toHaveBeenCalledTimes(1);
mockGetLastCommit.mockReturnValueOnce([true, null]);
expect(await utils.getZigbee2MQTTVersion()).toStrictEqual({commitHash: expect.any(String), version: version});
// @ts-expect-error mock spy
exec.mockImplementationOnce((cmd, cb) => {
cb(null, 'abcd1234');
});
expect(await utils.getZigbee2MQTTVersion()).toStrictEqual({commitHash: 'abcd1234', version});
// @ts-expect-error mock spy
exec.mockImplementationOnce((cmd, cb) => {
cb(null, '');
});
// hash file may or may not be present during testing, don't failing matching if not
expect(await utils.getZigbee2MQTTVersion()).toStrictEqual({commitHash: expect.stringMatching(/^(unknown|([a-z0-9]{8}))$/), version});
readFileSyncSpy.mockImplementationOnce(() => {
throw new Error('no hash file');
});
// @ts-expect-error mock spy
exec.mockImplementationOnce((cmd, cb) => {
cb(null, '');
});
expect(await utils.getZigbee2MQTTVersion()).toStrictEqual({commitHash: 'unknown', version});
readFileSyncSpy.mockImplementationOnce(() => {
throw new Error('no hash file');
});
// @ts-expect-error mock spy
exec.mockImplementationOnce((cmd, cb) => {
cb(new Error('invalid'), '');
});
expect(await utils.getZigbee2MQTTVersion()).toStrictEqual({commitHash: 'unknown', version});
expect(exec).toHaveBeenCalledTimes(5);
});
it('Check dependency version', async () => {
+1 -2
View File
@@ -1,14 +1,13 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"module": "commonjs",
"module": "NodeNext",
"esModuleInterop": true,
"target": "ES2022",
"lib": ["ES2022"],
"strict": true,
"noImplicitAny": true,
"noImplicitThis": true,
"moduleResolution": "node",
"declaration": true,
"declarationMap": true,
"outDir": "dist",
+12
View File
@@ -1,6 +1,18 @@
#!/usr/bin/env bash
cd "$(dirname "$0")"
if [ "$1" != "force" ]; then
echo "Checking for updates..."
git fetch -q
NEW_COMMITS="$(git rev-list HEAD...@{upstream} --count)"
if [ "$NEW_COMMITS" -gt 0 ]; then
echo "Update available!"
else
echo "No update available. Use '$0 force' to skip the check."
exit 0
fi
fi
NEED_RESTART=0
OSNAME="$(uname -s)"