mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-08-29 07:08:56 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5c07e7dfe | ||
|
|
e02bbdcf0b | ||
|
|
69f0840feb | ||
|
|
ea04f19709 | ||
|
|
9fe38b1677 | ||
|
|
6e6a989159 | ||
|
|
b52cfd87c3 | ||
|
|
516425b44b | ||
|
|
6a65769060 | ||
|
|
0a41af5dd2 | ||
|
|
267c399047 | ||
|
|
f824295b7e | ||
|
|
341c453657 | ||
|
|
c5c7f70ad6 | ||
|
|
440e3a0094 | ||
|
|
53e8226f0a | ||
|
|
7a1621a906 | ||
|
|
9e0c84beed | ||
|
|
5f9b49d66f | ||
|
|
93d0ee44ba | ||
|
|
fc35ac5737 | ||
|
|
2b90d7b3b9 | ||
|
|
f2a1c9a2be | ||
|
|
0714582eb9 | ||
|
|
5288549744 | ||
|
|
05d6f08a0d | ||
|
|
6f1266fd0e | ||
|
|
9792466aac | ||
|
|
52c255bcc2 | ||
|
|
badf107991 | ||
|
|
f872a5180c | ||
|
|
4f2b052016 | ||
|
|
ef5b3de5e2 | ||
|
|
ff7b09eb99 | ||
|
|
1baafbb6cf | ||
|
|
17ce9f4c52 | ||
|
|
e6ce7ecdc1 | ||
|
|
36ba1350a9 | ||
|
|
582bf7d2c6 | ||
|
|
46fd04364d | ||
|
|
747f6ac476 | ||
|
|
6e781eafae | ||
|
|
325199d445 | ||
|
|
e602dc849a | ||
|
|
ad0a222df1 | ||
|
|
6d6372ccf0 | ||
|
|
92955c02a4 | ||
|
|
e5a80e3b1b | ||
|
|
fb48bd0cb9 | ||
|
|
db578d9d11 | ||
|
|
8d2ef6df06 | ||
|
|
5db115057e | ||
|
|
be722a0500 | ||
|
|
e3985002c2 | ||
|
|
fcbb355f47 | ||
|
|
06ba62b833 | ||
|
|
ea5e0d47a5 | ||
|
|
8378949e1d | ||
|
|
78cde99469 | ||
|
|
a5195a6eee | ||
|
|
fb9816659b | ||
|
|
f6f7ba68c9 | ||
|
|
1b6ad46462 | ||
|
|
ad32fec1d0 | ||
|
|
c603f42c88 | ||
|
|
ed7e7c7e53 | ||
|
|
3941692665 | ||
|
|
8775ceb0c1 | ||
|
|
11fb858ff2 | ||
|
|
4fa794a639 | ||
|
|
1fe2d8dae6 | ||
|
|
bfeac07c4c | ||
|
|
b6c0f4e5db | ||
|
|
ba337bd329 | ||
|
|
7e7057eccd | ||
|
|
cbcd32064c | ||
|
|
d2d7ebeed9 | ||
|
|
9f72c5c455 | ||
|
|
e050e209b0 | ||
|
|
29d25aed66 | ||
|
|
063aba1b6c | ||
|
|
e8031efd2d | ||
|
|
102857a7fe | ||
|
|
db7b51ac80 | ||
|
|
d2166f2f4f | ||
|
|
07145accba | ||
|
|
44f9fe7a14 | ||
|
|
d522d2cd18 | ||
|
|
4e650b72cd | ||
|
|
a6072adcd0 | ||
|
|
0d193ebac2 | ||
|
|
ede1a470dd | ||
|
|
24779e3476 | ||
|
|
67e1a02525 | ||
|
|
7ce5b581aa | ||
|
|
40f41477e7 | ||
|
|
382c89c49e | ||
|
|
7d63a9a0f2 | ||
|
|
93af062a9b | ||
|
|
758c56647d | ||
|
|
845e03e48a | ||
|
|
dec5ca8b3a | ||
|
|
1e656c2114 | ||
|
|
ba76533c2f | ||
|
|
30d13d6e5e | ||
|
|
49fe147921 | ||
|
|
7a0964ebc9 | ||
|
|
b35cdc3474 | ||
|
|
5e55ef0454 | ||
|
|
3e3cefff1b | ||
|
|
97c74852c9 | ||
|
|
8542b35706 | ||
|
|
1eda69a50a | ||
|
|
16cecd917e |
+11
-10
@@ -1,4 +1,4 @@
|
||||
name: ci
|
||||
name: CI
|
||||
|
||||
on: [pull_request, push]
|
||||
|
||||
@@ -9,6 +9,8 @@ permissions:
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
|
||||
@@ -23,20 +25,18 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
registry-url: https://registry.npmjs.org/
|
||||
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
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: 'CodeQL'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['master', 'dev']
|
||||
pull_request:
|
||||
branches: ['master', 'dev']
|
||||
schedule:
|
||||
- cron: '15 2 * * 2'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
# Runner size impacts CodeQL analysis time. To learn more, please see:
|
||||
# - https://gh.io/recommended-hardware-resources-for-running-codeql
|
||||
# - https://gh.io/supported-runners-and-hardware-resources
|
||||
# - https://gh.io/using-larger-runners (GitHub.com only)
|
||||
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
|
||||
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
# required for all workflows
|
||||
security-events: write
|
||||
|
||||
# required to fetch internal or private CodeQL packs
|
||||
packages: read
|
||||
|
||||
# only required for workflows in private repositories
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: javascript-typescript
|
||||
build-mode: none
|
||||
# CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
|
||||
# Use `c-cpp` to analyze code written in C, C++ or both
|
||||
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
|
||||
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
|
||||
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
|
||||
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
|
||||
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
|
||||
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
# If the analyze step fails for one of the languages you are analyzing with
|
||||
# "We were unable to automatically build your code", modify the matrix above
|
||||
# to set the build mode to "manual" for that language. Then modify this step
|
||||
# to build your code.
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
- if: matrix.build-mode == 'manual'
|
||||
shell: bash
|
||||
run: |
|
||||
echo 'If you are using a "manual" build mode for one or more of the' \
|
||||
'languages you are analyzing, replace this with the commands to build' \
|
||||
'your code, for example:'
|
||||
echo ' make bootstrap'
|
||||
echo ' make release'
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
@@ -1,4 +1,5 @@
|
||||
name: deps-review
|
||||
name: Dependency review
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
permissions:
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
fail_pr:
|
||||
fail-pr-to-master:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Fail PR to master
|
||||
@@ -1,7 +1,7 @@
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
name: ghcr-cleanup
|
||||
name: GHCR cleanup
|
||||
|
||||
permissions: {}
|
||||
jobs:
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
name: merge-master-to-dev
|
||||
name: Merge master to dev
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
merge_master_to_dev:
|
||||
merge-master-to-dev:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -7,7 +7,7 @@ permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
name: release-please
|
||||
name: Release Please
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
|
||||
- uses: googleapis/release-please-action@v4
|
||||
id: release
|
||||
@@ -1,4 +1,4 @@
|
||||
name: stale
|
||||
name: Stale
|
||||
|
||||
on:
|
||||
schedule:
|
||||
|
||||
@@ -1,33 +1,41 @@
|
||||
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
|
||||
with:
|
||||
node-version: 20
|
||||
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
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: |
|
||||
pnpm install ${{ github.event.client_payload.package }}@${{ github.event.client_payload.version }} --save-exact
|
||||
@@ -45,6 +45,9 @@ typings/
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional pnpm cache directory
|
||||
.pnpm-store
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
@@ -68,6 +71,7 @@ tsconfig.tsbuildinfo
|
||||
|
||||
# IDE specific folders
|
||||
.idea
|
||||
.*.swp
|
||||
|
||||
# Ignore config
|
||||
data/*
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "2.1.1"
|
||||
".": "2.2.0"
|
||||
}
|
||||
|
||||
+112
@@ -1,5 +1,117 @@
|
||||
# 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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add Home Assistant device class to external_temperature_input entity ([#26535](https://github.com/Koenkk/zigbee2mqtt/issues/26535)) ([e8031ef](https://github.com/Koenkk/zigbee2mqtt/commit/e8031efd2d95658c5f3fe72ee6e2f95dd584b5e8))
|
||||
* Allow configuring number of folders of old logs to keep ([#26398](https://github.com/Koenkk/zigbee2mqtt/issues/26398)) ([845e03e](https://github.com/Koenkk/zigbee2mqtt/commit/845e03e48acf1367feadeaae986f3c987bbc164d))
|
||||
* Fix Docker build hanging ([#26547](https://github.com/Koenkk/zigbee2mqtt/issues/26547)) ([e050e20](https://github.com/Koenkk/zigbee2mqtt/commit/e050e209b0d273cad6ad0efcf20761f33963da7b))
|
||||
* Fix HA MQTT dict entry indicating that an update is progress ([#26267](https://github.com/Koenkk/zigbee2mqtt/issues/26267)) ([b35cdc3](https://github.com/Koenkk/zigbee2mqtt/commit/b35cdc347423b219722c9f9dd0d2a82ded8eb24f))
|
||||
* Home Assistant: add `brightness` to `supported_color_modes` ([#26545](https://github.com/Koenkk/zigbee2mqtt/issues/26545)) ([063aba1](https://github.com/Koenkk/zigbee2mqtt/commit/063aba1b6c72901ed2d51a367485092691c358da))
|
||||
* Home Assistant: add `is_updating` attribute ([#26231](https://github.com/Koenkk/zigbee2mqtt/issues/26231)) ([1eda69a](https://github.com/Koenkk/zigbee2mqtt/commit/1eda69a50a8f92c3471acdbdc82b41055d5992f2))
|
||||
* Home Assistant: add missing `device_class` for some `current` and `power` entities ([#26324](https://github.com/Koenkk/zigbee2mqtt/issues/26324)) ([1e656c2](https://github.com/Koenkk/zigbee2mqtt/commit/1e656c2114d58b96fd7b7750e2e7d255a9175653))
|
||||
* Home Assistant: support speed-controlled fans ([#24483](https://github.com/Koenkk/zigbee2mqtt/issues/24483)) ([7ce5b58](https://github.com/Koenkk/zigbee2mqtt/commit/7ce5b581aa57d828043a1ca75ef7efe7b7bf1619))
|
||||
* **ignore:** Add `publish` to `meta` of toZigbee converters ([#26490](https://github.com/Koenkk/zigbee2mqtt/issues/26490)) ([07145ac](https://github.com/Koenkk/zigbee2mqtt/commit/07145accbae6c481f6d6740e66e27ab1baf5eb6d))
|
||||
* **ignore:** remove obsolete @types/eslint__js ([d522d2c](https://github.com/Koenkk/zigbee2mqtt/commit/d522d2cd18b8cb77fa50cd457823cfa573622cb7))
|
||||
* **ignore:** update dependencies ([#26266](https://github.com/Koenkk/zigbee2mqtt/issues/26266)) ([3e3ceff](https://github.com/Koenkk/zigbee2mqtt/commit/3e3cefff1b7f85f59b6bf529f6593339498561a1))
|
||||
* **ignore:** update dependencies ([#26383](https://github.com/Koenkk/zigbee2mqtt/issues/26383)) ([758c566](https://github.com/Koenkk/zigbee2mqtt/commit/758c56647d353e81ea22a34c2fa2665ed76dc149))
|
||||
* **ignore:** update dependencies ([#26478](https://github.com/Koenkk/zigbee2mqtt/issues/26478)) ([d2166f2](https://github.com/Koenkk/zigbee2mqtt/commit/d2166f2f4fa1e1e286face994ec4825a70144908))
|
||||
* **ignore:** update zigbee-herdsman to 3.2.6 ([#26303](https://github.com/Koenkk/zigbee2mqtt/issues/26303)) ([49fe147](https://github.com/Koenkk/zigbee2mqtt/commit/49fe1479213c928786c64adeb3031ca6fe91250d))
|
||||
* **ignore:** update zigbee-herdsman to 3.2.7 ([#26498](https://github.com/Koenkk/zigbee2mqtt/issues/26498)) ([4e650b7](https://github.com/Koenkk/zigbee2mqtt/commit/4e650b72cd187488893213018b619e933835a58e))
|
||||
* **ignore:** update zigbee-herdsman-converters to 21.32.0 ([#26245](https://github.com/Koenkk/zigbee2mqtt/issues/26245)) ([8542b35](https://github.com/Koenkk/zigbee2mqtt/commit/8542b35706cefc87c9ec9dccb2980302ea25f100))
|
||||
* **ignore:** update zigbee-herdsman-converters to 21.33.0 ([#26258](https://github.com/Koenkk/zigbee2mqtt/issues/26258)) ([97c7485](https://github.com/Koenkk/zigbee2mqtt/commit/97c74852c95536945e33d0388dc5fcd0b132cd7e))
|
||||
* **ignore:** update zigbee-herdsman-converters to 21.34.0 ([#26285](https://github.com/Koenkk/zigbee2mqtt/issues/26285)) ([7a0964e](https://github.com/Koenkk/zigbee2mqtt/commit/7a0964ebc905903b4ca5b168d9bd1041b004b4cf))
|
||||
* **ignore:** update zigbee-herdsman-converters to 21.35.0 ([#26305](https://github.com/Koenkk/zigbee2mqtt/issues/26305)) ([30d13d6](https://github.com/Koenkk/zigbee2mqtt/commit/30d13d6e5ee9678f7da812bd287128824a535d48))
|
||||
* **ignore:** update zigbee-herdsman-converters to 21.36.0 ([#26333](https://github.com/Koenkk/zigbee2mqtt/issues/26333)) ([ba76533](https://github.com/Koenkk/zigbee2mqtt/commit/ba76533c2f241c8362f43ad07d10b2fe084479a0))
|
||||
* **ignore:** update zigbee-herdsman-converters to 21.37.0 ([#26367](https://github.com/Koenkk/zigbee2mqtt/issues/26367)) ([dec5ca8](https://github.com/Koenkk/zigbee2mqtt/commit/dec5ca8b3a3703e6289652192657af9a035fe026))
|
||||
* **ignore:** update zigbee-herdsman-converters to 21.38.0 ([#26400](https://github.com/Koenkk/zigbee2mqtt/issues/26400)) ([93af062](https://github.com/Koenkk/zigbee2mqtt/commit/93af062a9b84358c866a5a1191ae5895fc6e866f))
|
||||
* **ignore:** update zigbee-herdsman-converters to 22.0.1 ([#26418](https://github.com/Koenkk/zigbee2mqtt/issues/26418)) ([7d63a9a](https://github.com/Koenkk/zigbee2mqtt/commit/7d63a9a0f29445044bcfe0f3a4b629266db37654))
|
||||
* **ignore:** update zigbee-herdsman-converters to 22.1.0 ([#26445](https://github.com/Koenkk/zigbee2mqtt/issues/26445)) ([40f4147](https://github.com/Koenkk/zigbee2mqtt/commit/40f41477e776b4e75434251d03bf2844fceb7b13))
|
||||
* **ignore:** update zigbee-herdsman-converters to 22.2.0 ([#26469](https://github.com/Koenkk/zigbee2mqtt/issues/26469)) ([67e1a02](https://github.com/Koenkk/zigbee2mqtt/commit/67e1a0252517da28629a4c97b4dd610624d451a1))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.0.0 ([44f9fe7](https://github.com/Koenkk/zigbee2mqtt/commit/44f9fe7a1494a9450e247ad353ce788c621f901c))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.1.0 ([#26509](https://github.com/Koenkk/zigbee2mqtt/issues/26509)) ([db7b51a](https://github.com/Koenkk/zigbee2mqtt/commit/db7b51ac801829801656d978aff2605d8bd7b56a))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.1.1 ([#26510](https://github.com/Koenkk/zigbee2mqtt/issues/26510)) ([102857a](https://github.com/Koenkk/zigbee2mqtt/commit/102857a7fec60e3f65887d92b6fe7a2d81add110))
|
||||
* **ignore:** update zigbee-herdsman-converters to 23.2.0 ([#26549](https://github.com/Koenkk/zigbee2mqtt/issues/26549)) ([9f72c5c](https://github.com/Koenkk/zigbee2mqtt/commit/9f72c5c4555bda5b347a6a5406f619b9668813ec))
|
||||
* Make compatible with pnpm 10 ([#26546](https://github.com/Koenkk/zigbee2mqtt/issues/26546)) ([29d25ae](https://github.com/Koenkk/zigbee2mqtt/commit/29d25aed66a66912fb55ede8751ba8056efb673f))
|
||||
* Support FreeBSD for `update.sh` ([#26261](https://github.com/Koenkk/zigbee2mqtt/issues/26261)) ([5e55ef0](https://github.com/Koenkk/zigbee2mqtt/commit/5e55ef045427f78b410e3d292387c3592366f698))
|
||||
|
||||
## [2.1.1](https://github.com/Koenkk/zigbee2mqtt/compare/2.1.0...2.1.1) (2025-02-06)
|
||||
|
||||
|
||||
|
||||
+4
-1
@@ -20,7 +20,7 @@ FROM base AS deps
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
# Make and such are needed to compile serialport for riscv64
|
||||
RUN apk add make gcc g++ python3 linux-headers npm && \
|
||||
npm install -g pnpm && \
|
||||
npm install -g pnpm@10.4.1 && \
|
||||
pnpm install --frozen-lockfile --no-optional && \
|
||||
# serialport has outdated prebuilds that appear to fail on some archs, force build on target platform
|
||||
rm -rf `find ./node_modules/.pnpm/ -wholename "*/@serialport/bindings-cpp/prebuilds" -type d` && \
|
||||
@@ -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/
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
version: '3'
|
||||
services:
|
||||
zigbee2mqtt:
|
||||
container_name: zigbee2mqtt
|
||||
image: koenkk/zigbee2mqtt
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
devices:
|
||||
# CC251
|
||||
#- /dev/ttyUSB_cc2531:/dev/ttyACM0
|
||||
# CC2530 / GBAN GB2530S
|
||||
#- /dev/ttyUSB_cc2530:/dev/ttyACM0
|
||||
restart: always
|
||||
network_mode: host
|
||||
@@ -9,9 +9,4 @@ fi
|
||||
|
||||
echo "Using '$DATA' as data directory"
|
||||
|
||||
if [ ! -f "$DATA/configuration.yaml" ]; then
|
||||
echo "Creating configuration file..."
|
||||
cp /app/configuration.example.yaml "$DATA/configuration.yaml"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
|
||||
@@ -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
@@ -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,4 +1,4 @@
|
||||
import type {Zigbee2MQTTAPI} from 'lib/types/api';
|
||||
import type {Zigbee2MQTTAPI} from '../types/api';
|
||||
|
||||
import assert from 'node:assert';
|
||||
|
||||
@@ -243,6 +243,8 @@ export default class Availability extends Extension {
|
||||
options,
|
||||
state,
|
||||
device: device.zh,
|
||||
/* v8 ignore next */
|
||||
publish: (payload: KeyValue) => this.publishEntityState(device, payload),
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -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,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,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';
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type * as zhc from 'zigbee-herdsman-converters';
|
||||
import type {ExternalDefinitionWithExtend} from 'zigbee-herdsman-converters';
|
||||
|
||||
import {addDefinition, removeExternalDefinitions} from 'zigbee-herdsman-converters';
|
||||
import {addExternalDefinition, removeExternalDefinitions} from 'zigbee-herdsman-converters';
|
||||
|
||||
import logger from '../util/logger';
|
||||
import ExternalJSExtension from './externalJS';
|
||||
|
||||
type ModuleExports = zhc.Definition | zhc.Definition[];
|
||||
type TModule = ExternalDefinitionWithExtend | ExternalDefinitionWithExtend[];
|
||||
|
||||
export default class ExternalConverters extends ExternalJSExtension<ModuleExports> {
|
||||
export default class ExternalConverters extends ExternalJSExtension<TModule> {
|
||||
constructor(
|
||||
zigbee: Zigbee,
|
||||
mqtt: MQTT,
|
||||
@@ -33,36 +33,36 @@ 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);
|
||||
|
||||
for (const definition of this.getDefinitions(module)) {
|
||||
definition.externalConverterName = name;
|
||||
const definitions = Array.isArray(mod) ? mod : [mod];
|
||||
|
||||
addDefinition(definition);
|
||||
logger.info(`Loaded external converter '${name}'.`);
|
||||
for (const definition of definitions) {
|
||||
definition.externalConverterName = newName ?? name;
|
||||
|
||||
addExternalDefinition(definition);
|
||||
logger.info(`Loaded external converter '${newName ?? name}'.`);
|
||||
}
|
||||
|
||||
await this.zigbee.resolveDevicesDefinitions(true);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to load external converter '${name}'`);
|
||||
logger.error(`Check the code for syntax error and make sure it is up to date with the current Zigbee2MQTT version.`);
|
||||
logger.error(
|
||||
/* v8 ignore next */
|
||||
`Failed to load external converter '${newName ?? name}'. Check the code for syntax error and make sure it is up to date with the current Zigbee2MQTT version.`,
|
||||
);
|
||||
logger.warning(
|
||||
`External converters are not meant for long term usage, but for local testing after which a pull request should be created to add out-of-the-box support for the device`,
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private getDefinitions(module: ModuleExports): zhc.Definition[] {
|
||||
return Array.isArray(module) ? module : [module];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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('\\', '/');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from 'lib/types/api';
|
||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from '../types/api';
|
||||
|
||||
import assert from 'node:assert';
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type * as zhc from 'zigbee-herdsman-converters';
|
||||
|
||||
import assert from 'node:assert';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
|
||||
import logger from '../util/logger';
|
||||
import * as settings from '../util/settings';
|
||||
import utils, {assertBinaryExpose, assertEnumExpose, assertNumericExpose, isBinaryExpose, isEnumExpose, isNumericExpose} from '../util/utils';
|
||||
@@ -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: {
|
||||
@@ -170,7 +155,7 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||
eco2: {device_class: 'carbon_dioxide', state_class: 'measurement'},
|
||||
eco_temperature: {entity_category: 'config', icon: 'mdi:thermometer'},
|
||||
energy: {device_class: 'energy', state_class: 'total_increasing'},
|
||||
external_temperature_input: {icon: 'mdi:thermometer'},
|
||||
external_temperature_input: {device_class: 'temperature', icon: 'mdi:thermometer'},
|
||||
formaldehyd: {state_class: 'measurement'},
|
||||
flow: {device_class: 'volume_flow_rate', state_class: 'measurement'},
|
||||
gas_density: {icon: 'mdi:google-circles-communities', state_class: 'measurement'},
|
||||
@@ -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;
|
||||
@@ -544,6 +514,13 @@ export default class HomeAssistant extends Extension {
|
||||
|
||||
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) {
|
||||
@@ -872,15 +849,17 @@ export default class HomeAssistant extends Extension {
|
||||
discovery_payload: {
|
||||
name: null,
|
||||
state_topic: true,
|
||||
state_value_template: '{{ value_json.fan_state }}',
|
||||
command_topic: true,
|
||||
command_topic_postfix: 'fan_state',
|
||||
},
|
||||
};
|
||||
|
||||
const speed = (firstExpose as zhc.Fan).features.filter(isEnumExpose).find((e) => e.name === 'mode');
|
||||
const modeEmulatedSpeed = (firstExpose as zhc.Fan).features.filter(isEnumExpose).find((e) => e.name === 'mode');
|
||||
const nativeSpeed = (firstExpose as zhc.Fan).features.filter(isNumericExpose).find((e) => e.name === 'speed');
|
||||
|
||||
if (speed) {
|
||||
// Exactly one mode needs to be active (logical xor)
|
||||
assert(!modeEmulatedSpeed != !nativeSpeed, 'Fans need to be either mode- or speed-controlled');
|
||||
|
||||
if (modeEmulatedSpeed) {
|
||||
// A fan entity in Home Assistant 2021.3 and above may have a speed,
|
||||
// controlled by a percentage from 1 to 100, and/or non-speed presets.
|
||||
// The MQTT Fan integration allows the speed percentage to be mapped
|
||||
@@ -894,9 +873,9 @@ export default class HomeAssistant extends Extension {
|
||||
// ZCL. This supports a generic ZCL HVAC Fan Control fan. "Off" is
|
||||
// always a valid speed.
|
||||
let speeds = ['off'].concat(
|
||||
['low', 'medium', 'high', '1', '2', '3', '4', '5', '6', '7', '8', '9'].filter((s) => speed.values.includes(s)),
|
||||
['low', 'medium', 'high', '1', '2', '3', '4', '5', '6', '7', '8', '9'].filter((s) => modeEmulatedSpeed.values.includes(s)),
|
||||
);
|
||||
let presets = ['on', 'auto', 'smart'].filter((s) => speed.values.includes(s));
|
||||
let presets = ['on', 'auto', 'smart'].filter((s) => modeEmulatedSpeed.values.includes(s));
|
||||
|
||||
if (['99432'].includes(definition!.model)) {
|
||||
// The Hampton Bay 99432 fan implements 4 speeds using the ZCL
|
||||
@@ -908,22 +887,37 @@ export default class HomeAssistant extends Extension {
|
||||
}
|
||||
|
||||
const allowed = [...speeds, ...presets];
|
||||
speed.values.forEach((s) => assert(allowed.includes(s.toString())));
|
||||
modeEmulatedSpeed.values.forEach((s) => assert(allowed.includes(s.toString())));
|
||||
const percentValues = speeds.map((s, i) => `'${s}':${i}`).join(', ');
|
||||
const percentCommands = speeds.map((s, i) => `${i}:'${s}'`).join(', ');
|
||||
const presetList = presets.map((s) => `'${s}'`).join(', ');
|
||||
|
||||
discoveryEntry.discovery_payload.percentage_state_topic = true;
|
||||
discoveryEntry.discovery_payload.percentage_command_topic = true;
|
||||
discoveryEntry.discovery_payload.percentage_value_template = `{{ {${percentValues}}[value_json.${speed.property}] | default('None') }}`;
|
||||
discoveryEntry.discovery_payload.percentage_command_topic = 'fan_mode';
|
||||
discoveryEntry.discovery_payload.percentage_value_template = `{{ {${percentValues}}[value_json.${modeEmulatedSpeed.property}] | default('None') }}`;
|
||||
discoveryEntry.discovery_payload.percentage_command_template = `{{ {${percentCommands}}[value] | default('') }}`;
|
||||
discoveryEntry.discovery_payload.speed_range_min = 1;
|
||||
discoveryEntry.discovery_payload.speed_range_max = speeds.length - 1;
|
||||
assert(presets.length !== 0);
|
||||
discoveryEntry.discovery_payload.preset_mode_state_topic = true;
|
||||
discoveryEntry.discovery_payload.preset_mode_command_topic = 'fan_mode';
|
||||
discoveryEntry.discovery_payload.preset_mode_value_template = `{{ value_json.${speed.property} if value_json.${speed.property} in [${presetList}] else 'None' | default('None') }}`;
|
||||
discoveryEntry.discovery_payload.preset_mode_value_template = `{{ value_json.${modeEmulatedSpeed.property} if value_json.${modeEmulatedSpeed.property} in [${presetList}] else 'None' | default('None') }}`;
|
||||
discoveryEntry.discovery_payload.preset_modes = presets;
|
||||
|
||||
// Emulate state based on mode
|
||||
discoveryEntry.discovery_payload.state_value_template = '{{ value_json.fan_state }}';
|
||||
discoveryEntry.discovery_payload.command_topic_postfix = 'fan_state';
|
||||
} else if (nativeSpeed) {
|
||||
discoveryEntry.discovery_payload.percentage_state_topic = true;
|
||||
discoveryEntry.discovery_payload.percentage_command_topic = 'speed';
|
||||
discoveryEntry.discovery_payload.percentage_value_template = `{{ value_json.${nativeSpeed.property} | default('None') }}`;
|
||||
discoveryEntry.discovery_payload.percentage_command_template = `{{ value | default('') }}`;
|
||||
discoveryEntry.discovery_payload.speed_range_min = nativeSpeed.value_min;
|
||||
discoveryEntry.discovery_payload.speed_range_max = nativeSpeed.value_max;
|
||||
|
||||
// Speed-controlled fans generally have an onOff cluster, use that for state
|
||||
discoveryEntry.discovery_payload.state_value_template = '{{ value_json.state }}';
|
||||
discoveryEntry.discovery_payload.command_topic_postfix = 'state';
|
||||
}
|
||||
|
||||
discoveryEntries.push(discoveryEntry);
|
||||
@@ -1019,6 +1013,14 @@ export default class HomeAssistant extends Extension {
|
||||
if (firstExpose.unit && ['Wh', 'kWh'].includes(firstExpose.unit)) {
|
||||
Object.assign(extraAttrs, {device_class: 'energy', state_class: 'total_increasing'});
|
||||
}
|
||||
// If a variable includes A or mA, mark it as current
|
||||
else if (firstExpose.unit && ['A', 'mA'].includes(firstExpose.unit)) {
|
||||
Object.assign(extraAttrs, {device_class: 'current', state_class: 'measurement'});
|
||||
}
|
||||
// If a variable includes mW, W, kW mark it as power
|
||||
else if (firstExpose.unit && ['mW', 'W', 'kW'].includes(firstExpose.unit)) {
|
||||
Object.assign(extraAttrs, {device_class: 'power', state_class: 'measurement'});
|
||||
}
|
||||
|
||||
let key = firstExpose.name;
|
||||
|
||||
@@ -1348,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))) {
|
||||
@@ -1367,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)),
|
||||
@@ -1411,7 +1413,7 @@ export default class HomeAssistant extends Extension {
|
||||
entity_category: 'config',
|
||||
command_topic: `${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/update`,
|
||||
payload_install: `{"id": "${entity.ieeeAddr}"}`,
|
||||
value_template: `{"latest_version":"{{ value_json['update']['latest_version'] }}","installed_version":"{{ value_json['update']['installed_version'] }}","update_percentage":{{ value_json['update'].get('progress', 'null') }}}`,
|
||||
value_template: `{"latest_version":"{{ value_json['update']['latest_version'] }}","installed_version":"{{ value_json['update']['installed_version'] }}","update_percentage":{{ value_json['update'].get('progress', 'null') }},"in_progress":{{ (value_json['update']['state'] == 'updating')|lower }}}`,
|
||||
},
|
||||
};
|
||||
configs.push(updateSensor);
|
||||
@@ -1621,7 +1623,7 @@ export default class HomeAssistant extends Extension {
|
||||
}
|
||||
|
||||
if (payload.percentage_command_topic) {
|
||||
payload.percentage_command_topic = `${baseTopic}/${commandTopicPrefix}set/fan_mode`;
|
||||
payload.percentage_command_topic = `${baseTopic}/${commandTopicPrefix}set/${payload.percentage_command_topic}`;
|
||||
}
|
||||
|
||||
if (payload.preset_mode_state_topic) {
|
||||
@@ -2118,3 +2120,5 @@ export default class HomeAssistant extends Extension {
|
||||
return value_template;
|
||||
}
|
||||
}
|
||||
|
||||
export default HomeAssistant;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -231,6 +231,8 @@ export default class Publish extends Extension {
|
||||
state: entityState,
|
||||
membersState,
|
||||
mapped: definition,
|
||||
/* v8 ignore next */
|
||||
publish: (payload: KeyValue) => this.publishEntityState(re, payload),
|
||||
};
|
||||
|
||||
// Strip endpoint name from meta.message properties.
|
||||
|
||||
Vendored
+12
-9
@@ -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'>};
|
||||
@@ -178,6 +180,7 @@ declare global {
|
||||
log_syslog: KeyValue;
|
||||
log_debug_to_mqtt_frontend: boolean;
|
||||
log_debug_namespace_ignore: string;
|
||||
log_directories_to_keep: number;
|
||||
pan_id: number | 'GENERATE';
|
||||
ext_pan_id: number[] | 'GENERATE';
|
||||
channel: number;
|
||||
|
||||
Vendored
+14
@@ -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;
|
||||
}
|
||||
+1
-1
@@ -232,7 +232,7 @@ class Logger {
|
||||
});
|
||||
|
||||
directories.sort((a: KeyValue, b: KeyValue) => b.birth - a.birth);
|
||||
directories = directories.slice(10, directories.length);
|
||||
directories = directories.slice(settings.get().advanced.log_directories_to_keep, directories.length);
|
||||
directories.forEach((dir) => {
|
||||
this.debug(`Removing old log directory '${dir.path}'`);
|
||||
rimrafSync(dir.path);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 => {},
|
||||
};
|
||||
}
|
||||
@@ -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": []
|
||||
@@ -525,6 +533,14 @@
|
||||
"default": "",
|
||||
"examples": ["^zhc:legacy:fz:(tuya|moes)", "^zhc:legacy:fz:(tuya|moes)|^zh:ember:uart:|^zh:controller"]
|
||||
},
|
||||
"log_directories_to_keep": {
|
||||
"type": "number",
|
||||
"title": "Number of past log folders to keep",
|
||||
"description": "Number of log directories to keep before deleting the oldest one",
|
||||
"default": 10,
|
||||
"minimum": 5,
|
||||
"maximum": 1000
|
||||
},
|
||||
"log_syslog": {
|
||||
"requiresRestart": true,
|
||||
"oneOf": [
|
||||
|
||||
+36
-3
@@ -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;
|
||||
@@ -96,6 +95,7 @@ export const defaults: RecursivePartial<Settings> = {
|
||||
log_syslog: {},
|
||||
log_debug_to_mqtt_frontend: false,
|
||||
log_debug_namespace_ignore: '',
|
||||
log_directories_to_keep: 10,
|
||||
pan_id: 0x1a62,
|
||||
ext_pan_id: [0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd],
|
||||
channel: 11,
|
||||
@@ -145,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);
|
||||
|
||||
@@ -276,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
@@ -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});
|
||||
});
|
||||
});
|
||||
|
||||
+29
-26
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "zigbee2mqtt",
|
||||
"version": "2.1.1",
|
||||
"version": "2.2.0",
|
||||
"description": "Zigbee to MQTT bridge using Zigbee-herdsman",
|
||||
"main": "index.js",
|
||||
"packageManager": "pnpm@9.15.4",
|
||||
"packageManager": "pnpm@10.4.1",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Koenkk/zigbee2mqtt.git"
|
||||
@@ -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,59 +46,61 @@
|
||||
"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.0",
|
||||
"semver": "^7.7.1",
|
||||
"source-map-support": "^0.5.21",
|
||||
"throttleit": "^2.1.0",
|
||||
"winston": "^3.17.0",
|
||||
"winston-syslog": "^2.7.1",
|
||||
"winston-transport": "^4.9.0",
|
||||
"ws": "^8.18.0",
|
||||
"zigbee-herdsman": "3.2.5",
|
||||
"zigbee-herdsman-converters": "21.31.0",
|
||||
"zigbee2mqtt-frontend": "0.9.4"
|
||||
"ws": "^8.18.1",
|
||||
"zigbee-herdsman": "3.4.11",
|
||||
"zigbee-herdsman-converters": "23.18.0",
|
||||
"zigbee2mqtt-frontend": "0.9.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/core": "^0.11.0",
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@eslint/core": "^0.12.0",
|
||||
"@eslint/js": "^9.23.0",
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.4.1",
|
||||
"@types/eslint__js": "^8.42.3",
|
||||
"@types/finalhandler": "^1.2.3",
|
||||
"@types/humanize-duration": "^3.27.4",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.13.0",
|
||||
"@types/node": "^22.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.4",
|
||||
"eslint": "^9.19.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"prettier": "^3.4.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.22.0",
|
||||
"vitest": "^3.0.4"
|
||||
"typescript": "^5.8.2",
|
||||
"typescript-eslint": "^8.28.0",
|
||||
"vitest": "^3.0.9"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"zigbee-herdsman": "$zigbee-herdsman"
|
||||
}
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"@serialport/bindings-cpp",
|
||||
"esbuild",
|
||||
"unix-dgram"
|
||||
]
|
||||
},
|
||||
"bin": {
|
||||
"zigbee2mqtt": "cli.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"sd-notify": "^2.8.0"
|
||||
"unix-dgram": "^2.0.6"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+580
-616
File diff suppressed because it is too large
Load Diff
@@ -49,7 +49,8 @@ if (fs.existsSync(commitUserFile)) {
|
||||
commitUserLookup = JSON.parse(fs.readFileSync(commitUserFile, 'utf8'));
|
||||
}
|
||||
|
||||
const whiteLabels = zhc.definitions.filter((d) => d.whiteLabel).flatMap((d) => d.whiteLabel);
|
||||
const definitions = require('zigbee-herdsman-converters/devices/index').default.map((d) => zhc.prepareDefinition(d));
|
||||
const whiteLabels = definitions.filter((d) => d.whiteLabel).flatMap((d) => d.whiteLabel);
|
||||
const capitalizeFirstChar = (str) => str.charAt(0).toUpperCase() + str.slice(1);
|
||||
|
||||
for (const changelog of changelogs) {
|
||||
@@ -97,7 +98,7 @@ for (const changelog of changelogs) {
|
||||
|
||||
if (localContext === 'add') {
|
||||
for (const model of message.split(',')) {
|
||||
const definition = zhc.definitions.find((d) => d.model === model.trim());
|
||||
const definition = definitions.find((d) => d.model === model.trim());
|
||||
const whiteLabel = whiteLabels.find((d) => d.model === model.trim());
|
||||
const match = definition || whiteLabel;
|
||||
if (match) {
|
||||
|
||||
+3
-1
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -122,6 +121,7 @@ describe('Extension: Bridge', () => {
|
||||
log_rotation: true,
|
||||
log_symlink_current: false,
|
||||
log_syslog: {},
|
||||
log_directories_to_keep: 10,
|
||||
output: 'json',
|
||||
pan_id: 6754,
|
||||
timestamp_format: 'YYYY-MM-DD HH:mm:ss',
|
||||
@@ -141,6 +141,7 @@ describe('Extension: Bridge', () => {
|
||||
retain: false,
|
||||
},
|
||||
'0x000b57fffec6a5b7': {friendly_name: 'bulb_2', retain: false},
|
||||
'0x00124b00cfcf3298': {friendly_name: 'fanbee', retain: true},
|
||||
'0x0017880104a44559': {friendly_name: 'J1_cover'},
|
||||
'0x0017880104e43559': {friendly_name: 'U202DST600ZB'},
|
||||
'0x0017880104e44559': {friendly_name: '3157100_thermostat'},
|
||||
@@ -1262,7 +1263,6 @@ describe('Extension: Bridge', () => {
|
||||
},
|
||||
{
|
||||
access: 5,
|
||||
category: 'diagnostic',
|
||||
description: 'Instantaneous measured power',
|
||||
label: 'Power',
|
||||
name: 'power',
|
||||
@@ -3172,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',
|
||||
}),
|
||||
@@ -3757,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',
|
||||
@@ -3772,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 () => {
|
||||
@@ -3895,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: [],
|
||||
|
||||
@@ -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)!;
|
||||
|
||||
@@ -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,13 +23,10 @@ describe('Extension: ExternalConverters', () => {
|
||||
const mockBasePath = path.join(data.mockDir, BASE_DIR);
|
||||
let controller: Controller;
|
||||
|
||||
const existsSyncSpy = vi.spyOn(fs, 'existsSync');
|
||||
const readdirSyncSpy = vi.spyOn(fs, 'readdirSync');
|
||||
const mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync');
|
||||
const rmSyncSpy = vi.spyOn(fs, 'rmSync');
|
||||
const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync');
|
||||
|
||||
const zhcAddDefinitionSpy = vi.spyOn(zhc, 'addDefinition');
|
||||
const zhcAddExternalDefinitionSpy = vi.spyOn(zhc, 'addExternalDefinition');
|
||||
const zhcRemoveExternalDefinitionsSpy = vi.spyOn(zhc, 'removeExternalDefinitions');
|
||||
|
||||
const mocksClear = [
|
||||
@@ -38,21 +36,18 @@ describe('Extension: ExternalConverters', () => {
|
||||
mockLogger.error,
|
||||
mockZHController.stop,
|
||||
devices.bulb.save,
|
||||
existsSyncSpy,
|
||||
readdirSyncSpy,
|
||||
mkdirSyncSpy,
|
||||
rmSyncSpy,
|
||||
writeFileSyncSpy,
|
||||
zhcAddDefinitionSpy,
|
||||
zhcAddExternalDefinitionSpy,
|
||||
zhcRemoveExternalDefinitionsSpy,
|
||||
];
|
||||
|
||||
const useAssets = (): void => {
|
||||
fs.cpSync(path.join(__dirname, '..', 'assets', BASE_DIR), mockBasePath, {recursive: true});
|
||||
const useAssets = (mtype: 'cjs' | 'mjs'): void => {
|
||||
fs.cpSync(path.join(__dirname, '..', 'assets', BASE_DIR, mtype), mockBasePath, {recursive: true});
|
||||
};
|
||||
|
||||
const getFileCode = (fileName: string): string => {
|
||||
return fs.readFileSync(path.join(__dirname, '..', 'assets', BASE_DIR, fileName), 'utf8');
|
||||
const getFileCode = (mtype: 'cjs' | 'mjs', fileName: string): string => {
|
||||
return fs.readFileSync(path.join(__dirname, '..', 'assets', BASE_DIR, mtype, fileName), 'utf8');
|
||||
};
|
||||
|
||||
const getZ2MDevice = (zhDevice: unknown): Device => {
|
||||
@@ -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,15 +117,15 @@ 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},
|
||||
);
|
||||
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenCalledTimes(2);
|
||||
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(1, 'mock-external-converter-multiple.js');
|
||||
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(2, 'mock-external-converter.js');
|
||||
expect(zhcAddDefinitionSpy).toHaveBeenNthCalledWith(
|
||||
expect(zhcAddExternalDefinitionSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
mock: 1,
|
||||
@@ -139,7 +135,7 @@ describe('Extension: ExternalConverters', () => {
|
||||
description: 'external_1',
|
||||
}),
|
||||
);
|
||||
expect(zhcAddDefinitionSpy).toHaveBeenNthCalledWith(
|
||||
expect(zhcAddExternalDefinitionSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
mock: 2,
|
||||
@@ -149,14 +145,14 @@ describe('Extension: ExternalConverters', () => {
|
||||
description: 'external_2',
|
||||
}),
|
||||
);
|
||||
expect(zhcAddDefinitionSpy).toHaveBeenNthCalledWith(
|
||||
expect(zhcAddExternalDefinitionSpy).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
mock: true,
|
||||
zigbeeModel: ['external_converter_device'],
|
||||
vendor: 'external',
|
||||
model: 'external_converter_device',
|
||||
description: 'external',
|
||||
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,26 +369,27 @@ 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(zhcAddDefinitionSpy).toHaveBeenCalledWith(
|
||||
expect(zhcAddExternalDefinitionSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mock: true,
|
||||
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`;
|
||||
|
||||
zhcAddDefinitionSpy.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},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import {devices, groups, events as mockZHEvents, resetGroupMembers, returnDevice
|
||||
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
import {toZigbee as zhcToZigbee} from 'zigbee-herdsman-converters';
|
||||
import * as zhcGlobalStore from 'zigbee-herdsman-converters/lib/store';
|
||||
|
||||
import {Controller} from '../../lib/controller';
|
||||
import * as settings from '../../lib/util/settings';
|
||||
@@ -33,6 +33,8 @@ describe('Extension: Groups', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await controller?.stop();
|
||||
await flushPromises();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -42,7 +44,7 @@ describe('Extension: Groups', () => {
|
||||
settings.reRead();
|
||||
mockMQTTPublishAsync.mockClear();
|
||||
groups.gledopto_group.command.mockClear();
|
||||
zhcToZigbee.__clearStore__();
|
||||
zhcGlobalStore.clear();
|
||||
// @ts-expect-error private
|
||||
controller.state.state = {};
|
||||
});
|
||||
|
||||
@@ -2,13 +2,16 @@ 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';
|
||||
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
import {KeyValueAny} from 'zigbee-herdsman-converters/lib/types';
|
||||
|
||||
import {Controller} from '../../lib/controller';
|
||||
import HomeAssistant from '../../lib/extension/homeassistant';
|
||||
import * as settings from '../../lib/util/settings';
|
||||
@@ -23,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();
|
||||
@@ -46,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;
|
||||
@@ -62,8 +64,10 @@ describe('Extension: HomeAssistant', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
vi.useRealTimers();
|
||||
mockSleep.restore();
|
||||
await controller?.stop();
|
||||
await flushPromises();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -79,7 +83,9 @@ describe('Extension: HomeAssistant', () => {
|
||||
|
||||
it('Should not have duplicate type/object_ids in a mapping', async () => {
|
||||
const duplicated: string[] = [];
|
||||
(await import('zigbee-herdsman-converters')).definitions.forEach((d) => {
|
||||
|
||||
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,
|
||||
@@ -104,7 +110,7 @@ describe('Extension: HomeAssistant', () => {
|
||||
cfgTypeObjectIds.push(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
expect(duplicated).toHaveLength(0);
|
||||
});
|
||||
@@ -157,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',
|
||||
@@ -873,6 +906,42 @@ describe('Extension: HomeAssistant', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('Should discover devices with speed-controlled fan', async () => {
|
||||
const payload = {
|
||||
state_topic: 'zigbee2mqtt/fanbee',
|
||||
state_value_template: '{{ value_json.state }}',
|
||||
command_topic: 'zigbee2mqtt/fanbee/set/state',
|
||||
percentage_state_topic: 'zigbee2mqtt/fanbee',
|
||||
percentage_command_topic: 'zigbee2mqtt/fanbee/set/speed',
|
||||
percentage_value_template: "{{ value_json.speed | default('None') }}",
|
||||
percentage_command_template: "{{ value | default('') }}",
|
||||
speed_range_min: 1,
|
||||
speed_range_max: 254,
|
||||
name: null,
|
||||
object_id: 'fanbee',
|
||||
unique_id: '0x00124b00cfcf3298_fan_zigbee2mqtt',
|
||||
origin: origin,
|
||||
device: {
|
||||
identifiers: ['zigbee2mqtt_0x00124b00cfcf3298'],
|
||||
name: 'fanbee',
|
||||
model: 'Fan with valve',
|
||||
model_id: 'FanBee',
|
||||
manufacturer: 'Lorenz Brun',
|
||||
via_device: 'zigbee2mqtt_bridge_0x00124b00120144ae',
|
||||
},
|
||||
availability: [
|
||||
{
|
||||
topic: 'zigbee2mqtt/bridge/state',
|
||||
value_template: '{{ value_json.state }}',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const idx = mockMQTTPublishAsync.mock.calls.findIndex((c) => c[0] === 'homeassistant/fan/0x00124b00cfcf3298/fan/config');
|
||||
expect(idx).not.toBe(-1);
|
||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[idx][1])).toStrictEqual(payload);
|
||||
});
|
||||
|
||||
it('Should discover thermostat devices', async () => {
|
||||
const payload = {
|
||||
action_template:
|
||||
@@ -1139,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 () => {
|
||||
@@ -1681,7 +1754,7 @@ describe('Extension: HomeAssistant', () => {
|
||||
state_topic: 'zigbee2mqtt/bulb',
|
||||
unique_id: '0x000b57fffec6a5b2_update_zigbee2mqtt',
|
||||
value_template:
|
||||
"{\"latest_version\":\"{{ value_json['update']['latest_version'] }}\",\"installed_version\":\"{{ value_json['update']['installed_version'] }}\",\"update_percentage\":{{ value_json['update'].get('progress', 'null') }}}",
|
||||
"{\"latest_version\":\"{{ value_json['update']['latest_version'] }}\",\"installed_version\":\"{{ value_json['update']['installed_version'] }}\",\"update_percentage\":{{ value_json['update'].get('progress', 'null') }},\"in_progress\":{{ (value_json['update']['state'] == 'updating')|lower }}}",
|
||||
};
|
||||
|
||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('homeassistant/update/0x000b57fffec6a5b2/update/config', stringify(payload), {
|
||||
@@ -2256,7 +2329,7 @@ describe('Extension: HomeAssistant', () => {
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
await flushPromises();
|
||||
|
||||
let payload = {
|
||||
let payload: KeyValueAny = {
|
||||
name: 'Chill scene',
|
||||
command_topic: 'zigbee2mqtt/bulb_color_2/set',
|
||||
payload_on: '{ "scene_recall": 1 }',
|
||||
@@ -2621,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
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();
|
||||
const mappedLivolo = zhc.findByModel('TI0001')!;
|
||||
mappedLivolo.onEvent = mockLivoloOnEvent;
|
||||
// @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 livoloDefinition = (await getZhcBaseDefinitions()).find((d) => d.zigbeeModel?.includes(devices.LIVOLO.modelID!))!;
|
||||
mockLivoloOnEvent = vi.spyOn(livoloDefinition, 'onEvent');
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
@@ -36,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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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,7 +7,7 @@ import {devices, groups, events as mockZHEvents} from '../mocks/zigbeeHerdsman';
|
||||
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
import {toZigbee} from 'zigbee-herdsman-converters';
|
||||
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();
|
||||
});
|
||||
|
||||
toZigbee.__clearStore__();
|
||||
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();
|
||||
|
||||
toZigbee.__clearStore__();
|
||||
clearGlobalStore();
|
||||
|
||||
await mockMQTTEvents.message('zigbee2mqtt/bulb_color/set', stringify({state: 'ON', transition: 1.0}));
|
||||
await flushPromises();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
+16
-1
@@ -46,7 +46,7 @@ describe('Logger', () => {
|
||||
expect(dirs.length).toBe(1);
|
||||
});
|
||||
|
||||
it('Should cleanup', () => {
|
||||
it('Should cleanup (default setting)', () => {
|
||||
for (const d of fs.readdirSync(dir.name)) {
|
||||
rimrafSync(path.join(dir.name, d));
|
||||
}
|
||||
@@ -60,6 +60,21 @@ describe('Logger', () => {
|
||||
expect(fs.readdirSync(dir.name).length).toBe(10);
|
||||
});
|
||||
|
||||
it('Should cleanup (15 folders setting)', () => {
|
||||
for (const d of fs.readdirSync(dir.name)) {
|
||||
rimrafSync(path.join(dir.name, d));
|
||||
}
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
fs.mkdirSync(path.join(dir.name, `log_${i}`));
|
||||
}
|
||||
|
||||
settings.set(['advanced', 'log_directories_to_keep'], 15);
|
||||
expect(fs.readdirSync(dir.name).length).toBe(20);
|
||||
logger.init();
|
||||
expect(fs.readdirSync(dir.name).length).toBe(15);
|
||||
});
|
||||
|
||||
it('Should not cleanup when there is no timestamp set', () => {
|
||||
for (const d of fs.readdirSync(dir.name)) {
|
||||
rimrafSync(path.join(dir.name, d));
|
||||
|
||||
+22
-6
@@ -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},
|
||||
@@ -205,6 +207,10 @@ export const DEFAULT_CONFIGURATION = {
|
||||
'0x000b57cdfec6a5b3': {
|
||||
friendly_name: 'hue_twilight',
|
||||
},
|
||||
'0x00124b00cfcf3298': {
|
||||
friendly_name: 'fanbee',
|
||||
retain: true,
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
1: {
|
||||
@@ -246,7 +252,15 @@ export const DEFAULT_CONFIGURATION = {
|
||||
export function writeDefaultConfiguration(config: unknown = undefined): void {
|
||||
config = config || DEFAULT_CONFIGURATION;
|
||||
|
||||
yaml.writeIfChanged(path.join(mockDir, 'configuration.yaml'), config);
|
||||
yaml.writeIfChanged(configFile, config);
|
||||
}
|
||||
|
||||
export function read(): ReturnType<typeof yaml.read> {
|
||||
return yaml.read(configFile);
|
||||
}
|
||||
|
||||
export function removeConfiguration(): void {
|
||||
fs.rmSync(configFile, {force: true});
|
||||
}
|
||||
|
||||
export function writeEmptyState(): void {
|
||||
@@ -254,9 +268,7 @@ export function writeEmptyState(): void {
|
||||
}
|
||||
|
||||
export function removeState(): void {
|
||||
if (stateExists()) {
|
||||
fs.unlinkSync(stateFile);
|
||||
}
|
||||
fs.rmSync(stateFile, {force: true});
|
||||
}
|
||||
|
||||
export function stateExists(): boolean {
|
||||
@@ -286,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 () => {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1136,6 +1136,16 @@ export const devices = {
|
||||
undefined,
|
||||
CUSTOM_CLUSTERS,
|
||||
),
|
||||
fanbee: new Device(
|
||||
'Router',
|
||||
'0x00124b00cfcf3298',
|
||||
18129,
|
||||
0xfff1,
|
||||
[new Endpoint(8, [0, 3, 4, 5, 6, 8], []), new Endpoint(242, [], [33])],
|
||||
true,
|
||||
'DC Source',
|
||||
'FanBee1',
|
||||
),
|
||||
};
|
||||
|
||||
export const mockController = {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -1,17 +1,39 @@
|
||||
#!/bin/bash -e
|
||||
#!/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
|
||||
|
||||
if which systemctl 2> /dev/null > /dev/null; then
|
||||
OSNAME="$(uname -s)"
|
||||
if [ "$OSNAME" == "FreeBSD" ]; then
|
||||
echo "Checking Zigbee2MQTT status..."
|
||||
if systemctl is-active --quiet zigbee2mqtt; then
|
||||
if service zigbee2mqtt status >/dev/null; then
|
||||
echo "Stopping Zigbee2MQTT..."
|
||||
sudo systemctl stop zigbee2mqtt
|
||||
service zigbee2mqtt stop
|
||||
NEED_RESTART=1
|
||||
fi
|
||||
else
|
||||
echo "Skipped stopping Zigbee2MQTT, no systemctl found"
|
||||
if which systemctl 2> /dev/null > /dev/null; then
|
||||
echo "Checking Zigbee2MQTT status..."
|
||||
if systemctl is-active --quiet zigbee2mqtt; then
|
||||
echo "Stopping Zigbee2MQTT..."
|
||||
sudo systemctl stop zigbee2mqtt
|
||||
NEED_RESTART=1
|
||||
fi
|
||||
else
|
||||
echo "Skipped stopping Zigbee2MQTT, no systemctl found"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Updating..."
|
||||
@@ -25,7 +47,11 @@ pnpm run build
|
||||
|
||||
if [ $NEED_RESTART -eq 1 ]; then
|
||||
echo "Starting Zigbee2MQTT..."
|
||||
sudo systemctl start zigbee2mqtt
|
||||
if [ "$OSNAME" == "FreeBSD" ]; then
|
||||
service zigbee2mqtt start
|
||||
else
|
||||
sudo systemctl start zigbee2mqtt
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Done!"
|
||||
|
||||
Reference in New Issue
Block a user