mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-08-29 15:18:41 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
861cba63b3 | ||
|
|
59d0eb4534 | ||
|
|
57ee303cda | ||
|
|
4690fe1a55 | ||
|
|
826547a8e1 | ||
|
|
7047aef7f7 | ||
|
|
d82d782bac | ||
|
|
6351f4618e | ||
|
|
2c52c9884c | ||
|
|
5e47a21673 | ||
|
|
5ca4cf4acd | ||
|
|
267d800122 | ||
|
|
790373b896 | ||
|
|
ca6493528d | ||
|
|
1e77893f75 | ||
|
|
82a026e02e | ||
|
|
988984a1ae | ||
|
|
5859947207 | ||
|
|
b1508ae52f | ||
|
|
152ad12508 | ||
|
|
36e452b0f3 | ||
|
|
2a9bbb21ba | ||
|
|
f7e7115f79 | ||
|
|
6e04f7b03e | ||
|
|
2a1ab78f7a | ||
|
|
e5d02c4b95 | ||
|
|
69f7282952 | ||
|
|
b8c3104aa1 | ||
|
|
22e13c52b9 | ||
|
|
32e2637594 | ||
|
|
928635f1da | ||
|
|
a353acc8e3 | ||
|
|
b5d4a253b4 | ||
|
|
7267d27779 | ||
|
|
f2e5d59c35 | ||
|
|
1558a5d8ac | ||
|
|
87cee1aea6 | ||
|
|
0c2cc10ad8 | ||
|
|
84cc92f91a | ||
|
|
a6bfab7934 | ||
|
|
848f250dbb | ||
|
|
c078ccb136 | ||
|
|
67d3f05fd7 | ||
|
|
cc31ceabd1 | ||
|
|
c5d30b585b | ||
|
|
f1615a255c | ||
|
|
a2ce3230ac | ||
|
|
bd1f6d24a1 | ||
|
|
9837488f44 | ||
|
|
b620a21d46 | ||
|
|
2139e7a794 | ||
|
|
e0a0f868c0 | ||
|
|
ab726061b7 | ||
|
|
074b2e20e0 | ||
|
|
2b460d3a5f | ||
|
|
c09aeecd95 | ||
|
|
9ad7c70f24 | ||
|
|
64778650d3 | ||
|
|
eb606a5b98 | ||
|
|
669a16f13a | ||
|
|
b2d3bbce3c | ||
|
|
12cb6e135d | ||
|
|
d79e71b13e | ||
|
|
dbda1481ec | ||
|
|
d12b117b43 | ||
|
|
e2f19f19b4 | ||
|
|
618b318214 | ||
|
|
9f12ebb54e | ||
|
|
db00759a03 | ||
|
|
5f601873fe | ||
|
|
1fdf0a0a47 | ||
|
|
3bb4af253a | ||
|
|
076ada2325 | ||
|
|
5c3f71292d | ||
|
|
02292590ee | ||
|
|
b09ae10588 | ||
|
|
e5fac76060 | ||
|
|
17ac9cf8e7 | ||
|
|
063d4620d2 | ||
|
|
e06848db8c | ||
|
|
c82f93230d | ||
|
|
13e4937db2 | ||
|
|
58c856bbd0 | ||
|
|
df1913bc56 | ||
|
|
37f80dfb69 | ||
|
|
8fe76e090f | ||
|
|
15d845bf92 | ||
|
|
22e1480f74 | ||
|
|
5dd1bfb8a6 | ||
|
|
b1c7fd0033 | ||
|
|
2314147d0e | ||
|
|
ee4becb64e | ||
|
|
a5bb49aa71 | ||
|
|
d289194a71 | ||
|
|
7ad51ce662 | ||
|
|
951bbd3576 | ||
|
|
8a54a01583 | ||
|
|
e7a3f0da0a | ||
|
|
2ae19a7c42 | ||
|
|
da2a6974e0 | ||
|
|
d3de77da13 | ||
|
|
28650daf83 | ||
|
|
cedc0623f7 | ||
|
|
3efc2b237b | ||
|
|
03b5b0043a | ||
|
|
70a65c7dcf | ||
|
|
eb8685682f | ||
|
|
e642f7c86a | ||
|
|
c6ca472ca1 | ||
|
|
0540e4fbb4 | ||
|
|
9c72a768f2 | ||
|
|
8c0476612f | ||
|
|
afd80449b3 | ||
|
|
d989061122 | ||
|
|
48d77e4b5e | ||
|
|
63c3bb4166 |
@@ -1,2 +0,0 @@
|
||||
node_modules/*
|
||||
test/
|
||||
@@ -1,63 +0,0 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
'jest/globals': true,
|
||||
es6: true,
|
||||
node: true,
|
||||
},
|
||||
extends: ['eslint:recommended', 'plugin:jest/recommended', 'plugin:jest/style', 'prettier'],
|
||||
parserOptions: {
|
||||
ecmaVersion: 2018,
|
||||
sourceType: 'module',
|
||||
},
|
||||
rules: {
|
||||
'require-jsdoc': 'off',
|
||||
'no-prototype-builtins': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
},
|
||||
plugins: ['jest', 'perfectionist'],
|
||||
overrides: [
|
||||
{
|
||||
files: ['*.ts'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
extends: ['plugin:@typescript-eslint/recommended'],
|
||||
parserOptions: {
|
||||
project: './tsconfig.json',
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/await-thenable': 'error',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'error',
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/no-unused-vars': 'error',
|
||||
'no-return-await': 'error',
|
||||
'perfectionist/sort-imports': [
|
||||
'error',
|
||||
{
|
||||
groups: [
|
||||
'type',
|
||||
['builtin', 'external'],
|
||||
'internal-type',
|
||||
'internal',
|
||||
['parent-type', 'sibling-type', 'index-type'],
|
||||
['parent', 'sibling', 'index'],
|
||||
'object',
|
||||
'unknown',
|
||||
],
|
||||
customGroups: {
|
||||
value: {},
|
||||
type: {},
|
||||
},
|
||||
newlinesBetween: 'always',
|
||||
internalPattern: ['~/**'],
|
||||
type: 'natural',
|
||||
order: 'asc',
|
||||
ignoreCase: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -103,13 +103,13 @@ jobs:
|
||||
run: |
|
||||
TAG=${GITHUB_REF#refs/*/}
|
||||
echo "Triggering with tag '$TAG'"
|
||||
curl \
|
||||
curl -L \
|
||||
-X POST \
|
||||
-H "Authorization: token ${{ secrets.GH_TOKEN }}" \
|
||||
-H "Accept: application/vnd.github.everest-preview+json" \
|
||||
-H "Content-Type: application/json" \
|
||||
https://api.github.com/repos/Koenkk/zigbee2mqtt-chart/actions/workflows/OnZigbee2MQTTRelease/dispatches \
|
||||
-d '{"ref":"main","inputs":{"zigbee2mqtt_version":"$TAG" }}'
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer ${{ secrets.GH_TOKEN }}" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
https://api.github.com/repos/Koenkk/zigbee2mqtt-chart/actions/workflows/on_zigbee2mqtt_release.yaml/dispatches \
|
||||
--data "{\"ref\": \"main\", \"inputs\": { \"zigbee2mqtt_version\": \"$TAG\"}}"
|
||||
- name: 'release: merge dev -> master and promote dev'
|
||||
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
|
||||
run: |
|
||||
|
||||
@@ -17,7 +17,10 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: '/language:javascript-typescript'
|
||||
|
||||
@@ -23,8 +23,7 @@ jobs:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
# connect-gzip-static@4.0.0 requires Node 20 >=
|
||||
# eslint: https://github.com/typescript-eslint/typescript-eslint/issues/8211
|
||||
- run: npx npm-check-updates -u -x connect-gzip-static -x eslint
|
||||
- run: npx npm-check-updates -u -x connect-gzip-static
|
||||
- run: rm -f package-lock.json
|
||||
- run: npm install
|
||||
- uses: peter-evans/create-pull-request@v7
|
||||
|
||||
+19
-1
@@ -5,5 +5,23 @@
|
||||
"printWidth": 150,
|
||||
"bracketSpacing": false,
|
||||
"endOfLine": "lf",
|
||||
"tabWidth": 4
|
||||
"tabWidth": 4,
|
||||
"importOrder": [
|
||||
"",
|
||||
"<TYPES>^(node:)",
|
||||
"",
|
||||
"<TYPES>",
|
||||
"",
|
||||
"<TYPES>^[.]",
|
||||
"",
|
||||
"<BUILTIN_MODULES>",
|
||||
"",
|
||||
"<THIRD_PARTY_MODULES>",
|
||||
"",
|
||||
"^zigbee",
|
||||
"",
|
||||
"^[.]"
|
||||
],
|
||||
"importOrderParserPlugins": ["typescript", "decorators"],
|
||||
"plugins": ["@ianvs/prettier-plugin-sort-imports"]
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "1.40.1"
|
||||
".": "1.42.0"
|
||||
}
|
||||
|
||||
+119
@@ -1,5 +1,124 @@
|
||||
# Changelog
|
||||
|
||||
## [1.42.0](https://github.com/Koenkk/zigbee2mqtt/compare/1.41.0...1.42.0) (2024-12-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add experimental support for Home Assistant `event` entities ([#24233](https://github.com/Koenkk/zigbee2mqtt/issues/24233)) ([848f250](https://github.com/Koenkk/zigbee2mqtt/commit/848f250dbb7e0142c7e702dab97536b8be749f5b))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Home Assistant `event` entities, part 2 ([#24717](https://github.com/Koenkk/zigbee2mqtt/issues/24717)) ([22e13c5](https://github.com/Koenkk/zigbee2mqtt/commit/22e13c52b96152a24d735791c313b8c6c2cf68e7))
|
||||
* Home Assistant: remove the diagnostic category classification for power ([#24575](https://github.com/Koenkk/zigbee2mqtt/issues/24575)) ([c078ccb](https://github.com/Koenkk/zigbee2mqtt/commit/c078ccb13606a0893a19f25378197be70c0127f6))
|
||||
* Home Assistant: template errors when `action` key is missing or empty in state payload ([#24931](https://github.com/Koenkk/zigbee2mqtt/issues/24931)) ([5ca4cf4](https://github.com/Koenkk/zigbee2mqtt/commit/5ca4cf4acd758b9c8163c3f8d07cb9903d91e118))
|
||||
* **ignore:** Fix `Cannot read properties of undefined (reading 'clients')` https://github.com/Koenkk/zigbee2mqtt/issues/24957 ([7047aef](https://github.com/Koenkk/zigbee2mqtt/commit/7047aef7f7810225e80d923e6c45d5922562e12b))
|
||||
* **ignore:** Ignore `None` actions for HA events ([#24972](https://github.com/Koenkk/zigbee2mqtt/issues/24972)) ([4690fe1](https://github.com/Koenkk/zigbee2mqtt/commit/4690fe1a55b4d6a2a8ad39c21c157b67a41d5f62))
|
||||
* **ignore:** Prevent empty events from being published to HA ([#24969](https://github.com/Koenkk/zigbee2mqtt/issues/24969)) ([826547a](https://github.com/Koenkk/zigbee2mqtt/commit/826547a8e14beabfced2d9da408fa1d7a8e8a015))
|
||||
* **ignore:** Prevent incorrect `event_type` due to duplicate keys ([#24954](https://github.com/Koenkk/zigbee2mqtt/issues/24954)) ([d82d782](https://github.com/Koenkk/zigbee2mqtt/commit/d82d782bac0deda328e7025f9a2bcbd50fecf857))
|
||||
* **ignore:** update dependencies ([#24614](https://github.com/Koenkk/zigbee2mqtt/issues/24614)) ([84cc92f](https://github.com/Koenkk/zigbee2mqtt/commit/84cc92f91a020c86d5ae3938c635512cd106cd67))
|
||||
* **ignore:** update dependencies ([#24702](https://github.com/Koenkk/zigbee2mqtt/issues/24702)) ([928635f](https://github.com/Koenkk/zigbee2mqtt/commit/928635f1daea6e3e80afc30ff1eca3bc83de7f97))
|
||||
* **ignore:** Update dependencies ([#24894](https://github.com/Koenkk/zigbee2mqtt/issues/24894)) ([ca64935](https://github.com/Koenkk/zigbee2mqtt/commit/ca6493528db3ffb374068e837ec0151f079a6ac8))
|
||||
* **ignore:** update zigbee-herdsman to 2.1.8 ([#24763](https://github.com/Koenkk/zigbee2mqtt/issues/24763)) ([2a9bbb2](https://github.com/Koenkk/zigbee2mqtt/commit/2a9bbb21ba5c22bf32d702f02b60084c4e12f140))
|
||||
* **ignore:** update zigbee-herdsman to 2.1.9 ([#24819](https://github.com/Koenkk/zigbee2mqtt/issues/24819)) ([152ad12](https://github.com/Koenkk/zigbee2mqtt/commit/152ad12508724be23f1792238e4bc37f48413ec0))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.40.0 ([#24644](https://github.com/Koenkk/zigbee2mqtt/issues/24644)) ([1558a5d](https://github.com/Koenkk/zigbee2mqtt/commit/1558a5d8ace136ac1469f34502901d368603cbd1))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.41.0 ([#24656](https://github.com/Koenkk/zigbee2mqtt/issues/24656)) ([f2e5d59](https://github.com/Koenkk/zigbee2mqtt/commit/f2e5d59c3582ec12e1717c9c28520d54c593dd0e))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.42.0 ([#24666](https://github.com/Koenkk/zigbee2mqtt/issues/24666)) ([b5d4a25](https://github.com/Koenkk/zigbee2mqtt/commit/b5d4a253b4ef5b4930f8d84e878e2343cc0b38df))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.43.0 ([#24680](https://github.com/Koenkk/zigbee2mqtt/issues/24680)) ([a353acc](https://github.com/Koenkk/zigbee2mqtt/commit/a353acc8e3e98bf9ea3a935c1ee179aed335ff58))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.44.0 ([#24715](https://github.com/Koenkk/zigbee2mqtt/issues/24715)) ([32e2637](https://github.com/Koenkk/zigbee2mqtt/commit/32e2637594add5c6fa8f8bfccc28b0f759a5d873))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.45.0 ([#24733](https://github.com/Koenkk/zigbee2mqtt/issues/24733)) ([b8c3104](https://github.com/Koenkk/zigbee2mqtt/commit/b8c3104aa1baaf03c3db7954bc92b5ac71d985c4))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.46.0 ([#24762](https://github.com/Koenkk/zigbee2mqtt/issues/24762)) ([6e04f7b](https://github.com/Koenkk/zigbee2mqtt/commit/6e04f7b03e8c7f0493007ce1d9d359f819088557))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.47.0 ([#24772](https://github.com/Koenkk/zigbee2mqtt/issues/24772)) ([36e452b](https://github.com/Koenkk/zigbee2mqtt/commit/36e452b0f3284eb5ab872fb2d0aab04bbdfe4cee))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.49.1 ([#24830](https://github.com/Koenkk/zigbee2mqtt/issues/24830)) ([5859947](https://github.com/Koenkk/zigbee2mqtt/commit/5859947207adf8597436971706cdff101c1effbe))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.50.0 ([#24843](https://github.com/Koenkk/zigbee2mqtt/issues/24843)) ([988984a](https://github.com/Koenkk/zigbee2mqtt/commit/988984a1ae069d90ce1f67c165dd76c4edafa656))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.51.0 ([#24856](https://github.com/Koenkk/zigbee2mqtt/issues/24856)) ([82a026e](https://github.com/Koenkk/zigbee2mqtt/commit/82a026e02e0ecc662cf8302ff3005793cb66a23f))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.52.0 ([#24874](https://github.com/Koenkk/zigbee2mqtt/issues/24874)) ([1e77893](https://github.com/Koenkk/zigbee2mqtt/commit/1e77893f759908bafc373b047b6409d227516759))
|
||||
* **ignore:** Update zigbee-herdsman-converters to 20.53.0 ([#24906](https://github.com/Koenkk/zigbee2mqtt/issues/24906)) ([790373b](https://github.com/Koenkk/zigbee2mqtt/commit/790373b896efdb6d9ccb5c29c97812db7445c6a3))
|
||||
* **ignore:** update zigbee2mqtt-frontend to 0.7.5 ([#24631](https://github.com/Koenkk/zigbee2mqtt/issues/24631)) ([0c2cc10](https://github.com/Koenkk/zigbee2mqtt/commit/0c2cc10ad8fd8de1ac88566f8ca1a0d131b1055b))
|
||||
* Replace connect-gzip-static with express-static-gzip to become compatible with Node 23 ([#24619](https://github.com/Koenkk/zigbee2mqtt/issues/24619)) ([87cee1a](https://github.com/Koenkk/zigbee2mqtt/commit/87cee1aea6eace2efb31a2d30551b5ea32187e95))
|
||||
* Temporarily disable serving compressed index file. ([#24657](https://github.com/Koenkk/zigbee2mqtt/issues/24657)) ([7267d27](https://github.com/Koenkk/zigbee2mqtt/commit/7267d27779c854a216279b28b27551310b9cee97))
|
||||
|
||||
## [1.41.0](https://github.com/Koenkk/zigbee2mqtt/compare/1.40.2...1.41.0) (2024-10-31)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add `throttle` option for devices ([#24122](https://github.com/Koenkk/zigbee2mqtt/issues/24122)) ([17ac9cf](https://github.com/Koenkk/zigbee2mqtt/commit/17ac9cf8e777dfa4b86066650f3c6b2712b011b6))
|
||||
* Allow serving frontend under subpath ([#24244](https://github.com/Koenkk/zigbee2mqtt/issues/24244)) ([1fdf0a0](https://github.com/Koenkk/zigbee2mqtt/commit/1fdf0a0a47d2b11962862ff433089d7f17bb7421))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add additional discovery lookups for Home Assistant ([#24555](https://github.com/Koenkk/zigbee2mqtt/issues/24555)) ([a2ce323](https://github.com/Koenkk/zigbee2mqtt/commit/a2ce3230acff41ed52b684385130b67dbee3ad17))
|
||||
* Fix group state change for open/close devices ([#24226](https://github.com/Koenkk/zigbee2mqtt/issues/24226)) ([076ada2](https://github.com/Koenkk/zigbee2mqtt/commit/076ada23257dccbe206ae9de9e70be850e756262))
|
||||
* Frontend code cleanup @Nerivec ([#24322](https://github.com/Koenkk/zigbee2mqtt/issues/24322)) ([e2f19f1](https://github.com/Koenkk/zigbee2mqtt/commit/e2f19f19b4413fc4cdad6f64879840497bb2ca81))
|
||||
* **ignore:** update dependencies ([#24236](https://github.com/Koenkk/zigbee2mqtt/issues/24236)) ([5c3f712](https://github.com/Koenkk/zigbee2mqtt/commit/5c3f71292d330210474e381d18e73840ffd5f5df))
|
||||
* **ignore:** update dependencies ([#24309](https://github.com/Koenkk/zigbee2mqtt/issues/24309)) ([9f12ebb](https://github.com/Koenkk/zigbee2mqtt/commit/9f12ebb54e207ffbde7769f55ad494d689142d9c))
|
||||
* **ignore:** update dependencies ([#24411](https://github.com/Koenkk/zigbee2mqtt/issues/24411)) ([eb606a5](https://github.com/Koenkk/zigbee2mqtt/commit/eb606a5b9832866b494d24dc6ebfe4e776069780))
|
||||
* **ignore:** update dependencies ([#24498](https://github.com/Koenkk/zigbee2mqtt/issues/24498)) ([2139e7a](https://github.com/Koenkk/zigbee2mqtt/commit/2139e7a794c6bb1f32889eb1a951b039da124231))
|
||||
* **ignore:** update zigbee-herdsman to 2.1.4 ([#24397](https://github.com/Koenkk/zigbee2mqtt/issues/24397)) ([12cb6e1](https://github.com/Koenkk/zigbee2mqtt/commit/12cb6e135d20e1631562d5b8e7d2bdf26cf22aa0))
|
||||
* **ignore:** update zigbee-herdsman to 2.1.5 ([#24433](https://github.com/Koenkk/zigbee2mqtt/issues/24433)) ([6477865](https://github.com/Koenkk/zigbee2mqtt/commit/64778650d34cfa712c60bf662d25ea3e954bb3eb))
|
||||
* **ignore:** update zigbee-herdsman to 2.1.6 ([#24545](https://github.com/Koenkk/zigbee2mqtt/issues/24545)) ([9837488](https://github.com/Koenkk/zigbee2mqtt/commit/9837488f44f24f0d5e7631fe79c68ce82ed50093))
|
||||
* **ignore:** update zigbee-herdsman to 2.1.7 ([#24566](https://github.com/Koenkk/zigbee2mqtt/issues/24566)) ([f1615a2](https://github.com/Koenkk/zigbee2mqtt/commit/f1615a255c085c263519779be2444742765ee509))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.22.0 ([#24186](https://github.com/Koenkk/zigbee2mqtt/issues/24186)) ([e5fac76](https://github.com/Koenkk/zigbee2mqtt/commit/e5fac76060b6cb9e762584b204fff69c8f68f3cf))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.23.0 ([#24201](https://github.com/Koenkk/zigbee2mqtt/issues/24201)) ([b09ae10](https://github.com/Koenkk/zigbee2mqtt/commit/b09ae105882af2008dbb6a5f0f445ee0f03d1b3c))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.23.1 ([#24225](https://github.com/Koenkk/zigbee2mqtt/issues/24225)) ([0229259](https://github.com/Koenkk/zigbee2mqtt/commit/02292590eee8e5ab0f655da39bba26681b814bec))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.24.0 ([#24246](https://github.com/Koenkk/zigbee2mqtt/issues/24246)) ([3bb4af2](https://github.com/Koenkk/zigbee2mqtt/commit/3bb4af253ad8439b1354ce7fda2cc12ad25c934f))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.25.0 ([#24285](https://github.com/Koenkk/zigbee2mqtt/issues/24285)) ([5f60187](https://github.com/Koenkk/zigbee2mqtt/commit/5f601873feca094ae9c6c73fc3ff25e4fcd01907))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.26.0 ([#24307](https://github.com/Koenkk/zigbee2mqtt/issues/24307)) ([db00759](https://github.com/Koenkk/zigbee2mqtt/commit/db00759a030957ad6f80b8e1c230f959efb563ed))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.27.2 ([#24321](https://github.com/Koenkk/zigbee2mqtt/issues/24321)) ([618b318](https://github.com/Koenkk/zigbee2mqtt/commit/618b318214ca58b94a1068e0320d9a163a1cd22f))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.28.0 ([#24337](https://github.com/Koenkk/zigbee2mqtt/issues/24337)) ([d12b117](https://github.com/Koenkk/zigbee2mqtt/commit/d12b117b433f937266fa65e5e79af3973b376ab7))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.29.0 ([#24363](https://github.com/Koenkk/zigbee2mqtt/issues/24363)) ([d79e71b](https://github.com/Koenkk/zigbee2mqtt/commit/d79e71b13e24002bf9dca469d558c7d70f39a487))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.31.0 ([#24408](https://github.com/Koenkk/zigbee2mqtt/issues/24408)) ([669a16f](https://github.com/Koenkk/zigbee2mqtt/commit/669a16f13a4f88825ae343fa7140615ba2913004))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.32.0 ([#24434](https://github.com/Koenkk/zigbee2mqtt/issues/24434)) ([9ad7c70](https://github.com/Koenkk/zigbee2mqtt/commit/9ad7c70f242e3b0bf68aef8568aff70ab28a9636))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.33.0 ([#24457](https://github.com/Koenkk/zigbee2mqtt/issues/24457)) ([c09aeec](https://github.com/Koenkk/zigbee2mqtt/commit/c09aeecd95e2656548df297e509e2fd6bda7896e))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.34.0 ([#24493](https://github.com/Koenkk/zigbee2mqtt/issues/24493)) ([2b460d3](https://github.com/Koenkk/zigbee2mqtt/commit/2b460d3a5fef83e732160890153db9941f5ba000))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.35.0 ([#24514](https://github.com/Koenkk/zigbee2mqtt/issues/24514)) ([074b2e2](https://github.com/Koenkk/zigbee2mqtt/commit/074b2e20e03fe2a47636e9b072d21429681f9475))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.36.0 ([#24542](https://github.com/Koenkk/zigbee2mqtt/issues/24542)) ([b620a21](https://github.com/Koenkk/zigbee2mqtt/commit/b620a21d469d1f69df411e459ca4539221bd4b55))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.37.0 ([#24553](https://github.com/Koenkk/zigbee2mqtt/issues/24553)) ([bd1f6d2](https://github.com/Koenkk/zigbee2mqtt/commit/bd1f6d24a1febcbea6092716e503d280f2ba7916))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.38.0 ([#24567](https://github.com/Koenkk/zigbee2mqtt/issues/24567)) ([c5d30b5](https://github.com/Koenkk/zigbee2mqtt/commit/c5d30b585b263b5de4d97dc2d2d02772ef01f34c))
|
||||
* Improve `power_phase_b` and `power_phase_c` Home Assistant discovery ([#24538](https://github.com/Koenkk/zigbee2mqtt/issues/24538)) ([e0a0f86](https://github.com/Koenkk/zigbee2mqtt/commit/e0a0f868c05fd02cf21c802256c25a33ff8e89a0))
|
||||
* Remove extraneous whitespace in template for binary attributes ([#24520](https://github.com/Koenkk/zigbee2mqtt/issues/24520)) ([ab72606](https://github.com/Koenkk/zigbee2mqtt/commit/ab726061b71935d91c14517689fafeb238ab0228))
|
||||
|
||||
## [1.40.2](https://github.com/Koenkk/zigbee2mqtt/compare/1.40.1...1.40.2) (2024-10-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Enforce TS `strict` type checking ([#23601](https://github.com/Koenkk/zigbee2mqtt/issues/23601)) ([48d77e4](https://github.com/Koenkk/zigbee2mqtt/commit/48d77e4b5ef88e985a363fecf45b8c23038b2a11))
|
||||
* Fix Home Assistant `Invalid state message` error when state is too long ([#24045](https://github.com/Koenkk/zigbee2mqtt/issues/24045)) ([7ad51ce](https://github.com/Koenkk/zigbee2mqtt/commit/7ad51ce662e6e80aaaede7c7e5d1ecb37d89dff0))
|
||||
* **ignore:** Improvement for afd80449b35a10de40cc5393ccb6daa515f26e9b ([9c72a76](https://github.com/Koenkk/zigbee2mqtt/commit/9c72a768f255898141818bce5987547473982dbe))
|
||||
* **ignore:** Migrate to eslint 9 ([#23800](https://github.com/Koenkk/zigbee2mqtt/issues/23800)) ([afd8044](https://github.com/Koenkk/zigbee2mqtt/commit/afd80449b35a10de40cc5393ccb6daa515f26e9b))
|
||||
* **ignore:** Sync eslint settings from zhc ([#23951](https://github.com/Koenkk/zigbee2mqtt/issues/23951)) ([e642f7c](https://github.com/Koenkk/zigbee2mqtt/commit/e642f7c86a25e329c3f367f2f40c6634c207caee))
|
||||
* **ignore:** update dependencies ([#23898](https://github.com/Koenkk/zigbee2mqtt/issues/23898)) ([d989061](https://github.com/Koenkk/zigbee2mqtt/commit/d989061122334e6c5a7cde5a65bfee680b14db2d))
|
||||
* **ignore:** update dependencies ([#23904](https://github.com/Koenkk/zigbee2mqtt/issues/23904)) ([8c04766](https://github.com/Koenkk/zigbee2mqtt/commit/8c0476612fe88ca0eeab28c193d29963e128f5a7))
|
||||
* **ignore:** update dependencies ([#23973](https://github.com/Koenkk/zigbee2mqtt/issues/23973)) ([3efc2b2](https://github.com/Koenkk/zigbee2mqtt/commit/3efc2b237b51db932a758b2be90bd18dd6e25311))
|
||||
* **ignore:** update dependencies ([#24057](https://github.com/Koenkk/zigbee2mqtt/issues/24057)) ([a5bb49a](https://github.com/Koenkk/zigbee2mqtt/commit/a5bb49aa7118ea2d9822b62f1109b2f6f183ab78))
|
||||
* **ignore:** update dependencies ([#24132](https://github.com/Koenkk/zigbee2mqtt/issues/24132)) ([37f80df](https://github.com/Koenkk/zigbee2mqtt/commit/37f80dfb69fccd54db65de72024f36b7f9619cae))
|
||||
* **ignore:** update zigbee-herdsman to 0.57.4 ([#23968](https://github.com/Koenkk/zigbee2mqtt/issues/23968)) ([70a65c7](https://github.com/Koenkk/zigbee2mqtt/commit/70a65c7dcf35a0fe88662edf257bf8117203420f))
|
||||
* **ignore:** update zigbee-herdsman to 1.0.0 ([#24008](https://github.com/Koenkk/zigbee2mqtt/issues/24008)) ([da2a697](https://github.com/Koenkk/zigbee2mqtt/commit/da2a6974e078af2413db74b0c8eaa88c6a179126))
|
||||
* **ignore:** update zigbee-herdsman to 1.0.1 ([#24032](https://github.com/Koenkk/zigbee2mqtt/issues/24032)) ([8a54a01](https://github.com/Koenkk/zigbee2mqtt/commit/8a54a0158351c71ef5da9fb80ef8e35c15d770ca))
|
||||
* **ignore:** update zigbee-herdsman to 1.1.0 ([#24054](https://github.com/Koenkk/zigbee2mqtt/issues/24054)) ([d289194](https://github.com/Koenkk/zigbee2mqtt/commit/d289194a71f67dd821335d467532f74b3d47f03a))
|
||||
* **ignore:** update zigbee-herdsman to 2.0.0 ([#24064](https://github.com/Koenkk/zigbee2mqtt/issues/24064)) ([ee4becb](https://github.com/Koenkk/zigbee2mqtt/commit/ee4becb64ecd35ad2cfdf4cc78688967700e8719))
|
||||
* **ignore:** update zigbee-herdsman to 2.1.0 ([#24076](https://github.com/Koenkk/zigbee2mqtt/issues/24076)) ([2314147](https://github.com/Koenkk/zigbee2mqtt/commit/2314147d0eb4d513ca84405245c4ff460134700a))
|
||||
* **ignore:** update zigbee-herdsman to 2.1.1 ([#24093](https://github.com/Koenkk/zigbee2mqtt/issues/24093)) ([5dd1bfb](https://github.com/Koenkk/zigbee2mqtt/commit/5dd1bfb8a62d8e176800b2cfb66874e0ab73f125))
|
||||
* **ignore:** update zigbee-herdsman to 2.1.2 ([#24138](https://github.com/Koenkk/zigbee2mqtt/issues/24138)) ([df1913b](https://github.com/Koenkk/zigbee2mqtt/commit/df1913bc56f9ac60beb8ae4d648faf3ce57f349c))
|
||||
* **ignore:** update zigbee-herdsman to 2.1.3 ([#24171](https://github.com/Koenkk/zigbee2mqtt/issues/24171)) ([13e4937](https://github.com/Koenkk/zigbee2mqtt/commit/13e4937db2d2cb916013479fc9bd9c6650c1bb8f))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.13.0 ([#23927](https://github.com/Koenkk/zigbee2mqtt/issues/23927)) ([0540e4f](https://github.com/Koenkk/zigbee2mqtt/commit/0540e4fbb4c8e81322f1fb1d5de1ba3d2c0d0cfc))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.14.1 ([#23952](https://github.com/Koenkk/zigbee2mqtt/issues/23952)) ([eb86856](https://github.com/Koenkk/zigbee2mqtt/commit/eb8685682f64c7a2dd09e22d04922b4d47053aa2))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.15.0 ([#23969](https://github.com/Koenkk/zigbee2mqtt/issues/23969)) ([03b5b00](https://github.com/Koenkk/zigbee2mqtt/commit/03b5b0043a314c9fa14832ed0111b8582deb1857))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.16.0 ([#23990](https://github.com/Koenkk/zigbee2mqtt/issues/23990)) ([d3de77d](https://github.com/Koenkk/zigbee2mqtt/commit/d3de77da13a391d717a773e33197c0b851227ac5))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.16.1 ([#24009](https://github.com/Koenkk/zigbee2mqtt/issues/24009)) ([2ae19a7](https://github.com/Koenkk/zigbee2mqtt/commit/2ae19a7c4247fa511ef16b3db449c6ce6eb10eb8))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.16.2 ([#24022](https://github.com/Koenkk/zigbee2mqtt/issues/24022)) ([e7a3f0d](https://github.com/Koenkk/zigbee2mqtt/commit/e7a3f0da0aadfc9b7c12fed7534acc18f05ffa9d))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.16.3 ([#24033](https://github.com/Koenkk/zigbee2mqtt/issues/24033)) ([951bbd3](https://github.com/Koenkk/zigbee2mqtt/commit/951bbd357690d849790a7342d352f32e2e0b1f76))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.17.0 ([#24077](https://github.com/Koenkk/zigbee2mqtt/issues/24077)) ([b1c7fd0](https://github.com/Koenkk/zigbee2mqtt/commit/b1c7fd0033e57f9119f78b6c94d0b9b053013ead))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.18.0 ([#24105](https://github.com/Koenkk/zigbee2mqtt/issues/24105)) ([15d845b](https://github.com/Koenkk/zigbee2mqtt/commit/15d845bf923f3a845a88a1f8bf506207c27a8aee))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.19.0 ([#24130](https://github.com/Koenkk/zigbee2mqtt/issues/24130)) ([8fe76e0](https://github.com/Koenkk/zigbee2mqtt/commit/8fe76e090f84d91c74e88555ee83716510b19caa))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.20.0 ([#24144](https://github.com/Koenkk/zigbee2mqtt/issues/24144)) ([58c856b](https://github.com/Koenkk/zigbee2mqtt/commit/58c856bbd0595c8270ee225685a09bfb34334d02))
|
||||
* **ignore:** update zigbee-herdsman-converters to 20.21.0 ([#24172](https://github.com/Koenkk/zigbee2mqtt/issues/24172)) ([c82f932](https://github.com/Koenkk/zigbee2mqtt/commit/c82f93230da8d430cbbdb4aa3a35c1a3d7659b1c))
|
||||
* Improve performance when logging is disabled ([#23893](https://github.com/Koenkk/zigbee2mqtt/issues/23893)) ([c6ca472](https://github.com/Koenkk/zigbee2mqtt/commit/c6ca472ca1374c1b217063816359315931bbcdb0))
|
||||
|
||||
## [1.40.1](https://github.com/Koenkk/zigbee2mqtt/compare/1.40.0...1.40.1) (2024-09-07)
|
||||
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<br>
|
||||
<br>
|
||||
<div style="display: flex;">
|
||||
<a href="https://github.com/Koenkk/zigbee2mqtt/actions?query=workflow%3ACI">
|
||||
<img src="https://github.com/koenkk/zigbee2mqtt/workflows/CI/badge.svg">
|
||||
<a href="https://github.com/Koenkk/zigbee2mqtt/actions?query=workflow%3Aci">
|
||||
<img src="https://github.com/koenkk/zigbee2mqtt/workflows/ci/badge.svg">
|
||||
</a>
|
||||
<a href="https://github.com/Koenkk/zigbee2mqtt/releases">
|
||||
<img src="https://img.shields.io/github/release/koenkk/zigbee2mqtt.svg">
|
||||
@@ -105,6 +105,8 @@ Zigbee2MQTT is made up of three modules, each developed in its own Github projec
|
||||
### Developing
|
||||
|
||||
Zigbee2MQTT uses TypeScript (partially for now). Therefore after making changes to files in the `lib/` directory you need to recompile Zigbee2MQTT. This can be done by executing `npm run build`. For faster development instead of running `npm run build` you can run `npm run build-watch` in another terminal session, this will recompile as you change files.
|
||||
In first time before building you need to run `npm install --include=dev`
|
||||
Before submitting changes run `npm run test-with-coverage`, `npm run pretty:check` and `npm run eslint`
|
||||
|
||||
## Supported devices
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// @ts-check
|
||||
|
||||
import eslint from '@eslint/js';
|
||||
import eslintConfigPrettier from 'eslint-config-prettier';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/await-thenable': 'error',
|
||||
'@typescript-eslint/ban-ts-comment': 'error',
|
||||
'@typescript-eslint/explicit-function-return-type': 'error',
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/no-unused-vars': 'error',
|
||||
'array-bracket-spacing': ['error', 'never'],
|
||||
'@typescript-eslint/return-await': ['error', 'always'],
|
||||
'object-curly-spacing': ['error', 'never'],
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ['dist/', '**/*.js', '**/*.mjs'],
|
||||
},
|
||||
eslintConfigPrettier,
|
||||
);
|
||||
+53
-31
@@ -1,6 +1,10 @@
|
||||
import type * as SdNotify from 'sd-notify';
|
||||
|
||||
import assert from 'assert';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
import {setLogger as zhSetLogger} from 'zigbee-herdsman';
|
||||
import {setLogger as zhcSetLogger} from 'zigbee-herdsman-converters';
|
||||
|
||||
@@ -31,6 +35,8 @@ import * as settings from './util/settings';
|
||||
import utils from './util/utils';
|
||||
import Zigbee from './zigbee';
|
||||
|
||||
type SdNotifyType = typeof SdNotify;
|
||||
|
||||
const AllExtensions = [
|
||||
ExtensionPublish,
|
||||
ExtensionReceive,
|
||||
@@ -63,15 +69,6 @@ type ExtensionArgs = [
|
||||
addExtension: (extension: Extension) => Promise<void>,
|
||||
];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let sdNotify: any = null;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
sdNotify = process.env.NOTIFY_SOCKET ? require('sd-notify') : null;
|
||||
} catch {
|
||||
// sd-notify is optional
|
||||
}
|
||||
|
||||
export class Controller {
|
||||
private eventBus: EventBus;
|
||||
private zigbee: Zigbee;
|
||||
@@ -81,6 +78,7 @@ export class Controller {
|
||||
private exitCallback: (code: number, restart: boolean) => Promise<void>;
|
||||
private extensions: Extension[];
|
||||
private extensionArgs: ExtensionArgs;
|
||||
private sdNotify: SdNotifyType | undefined;
|
||||
|
||||
constructor(restartCallback: () => Promise<void>, exitCallback: (code: number, restart: boolean) => Promise<void>) {
|
||||
logger.init();
|
||||
@@ -119,13 +117,28 @@ export class Controller {
|
||||
new ExtensionReport(...this.extensionArgs),
|
||||
new ExtensionExternalExtension(...this.extensionArgs),
|
||||
new ExtensionAvailability(...this.extensionArgs),
|
||||
settings.get().frontend && new ExtensionFrontend(...this.extensionArgs),
|
||||
settings.get().advanced.legacy_api && new ExtensionBridgeLegacy(...this.extensionArgs),
|
||||
settings.get().external_converters.length && new ExtensionExternalConverters(...this.extensionArgs),
|
||||
settings.get().homeassistant && new ExtensionHomeAssistant(...this.extensionArgs),
|
||||
/* istanbul ignore next */
|
||||
settings.get().advanced.soft_reset_timeout !== 0 && new ExtensionSoftReset(...this.extensionArgs),
|
||||
].filter((n) => n);
|
||||
];
|
||||
|
||||
if (settings.get().frontend) {
|
||||
this.extensions.push(new ExtensionFrontend(...this.extensionArgs));
|
||||
}
|
||||
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
this.extensions.push(new ExtensionBridgeLegacy(...this.extensionArgs));
|
||||
}
|
||||
|
||||
if (settings.get().external_converters.length) {
|
||||
this.extensions.push(new ExtensionExternalConverters(...this.extensionArgs));
|
||||
}
|
||||
|
||||
if (settings.get().homeassistant) {
|
||||
this.extensions.push(new ExtensionHomeAssistant(...this.extensionArgs));
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
if (settings.get().advanced.soft_reset_timeout !== 0) {
|
||||
this.extensions.push(new ExtensionSoftReset(...this.extensionArgs));
|
||||
}
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
@@ -134,6 +147,14 @@ export class Controller {
|
||||
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');
|
||||
} catch {
|
||||
// istanbul ignore next
|
||||
logger.debug('sd-notify is not installed');
|
||||
}
|
||||
|
||||
// Start zigbee
|
||||
let startResult;
|
||||
try {
|
||||
@@ -143,8 +164,8 @@ export class Controller {
|
||||
logger.error('Failed to start zigbee');
|
||||
logger.error('Check https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start.html for possible solutions');
|
||||
logger.error('Exiting...');
|
||||
logger.error(error.stack);
|
||||
return this.exit(1);
|
||||
logger.error((error as Error).stack!);
|
||||
return await this.exit(1);
|
||||
}
|
||||
|
||||
// Disable some legacy options on new network creation
|
||||
@@ -160,8 +181,9 @@ export class Controller {
|
||||
let deviceCount = 0;
|
||||
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
// `definition` validated by `isSupported`
|
||||
const model = device.isSupported
|
||||
? `${device.definition.model} - ${device.definition.vendor} ${device.definition.description}`
|
||||
? `${device.definition!.model} - ${device.definition!.vendor} ${device.definition!.description}`
|
||||
: 'Not supported';
|
||||
logger.info(`${device.name} (${device.ieeeAddr}): ${model} (${device.zh.type})`);
|
||||
|
||||
@@ -180,16 +202,16 @@ export class Controller {
|
||||
|
||||
await this.zigbee.permitJoin(settings.get().permit_join);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to set permit join to ${settings.get().permit_join} (${error.message})`);
|
||||
logger.error(`Failed to set permit join to ${settings.get().permit_join} (${(error as Error).message})`);
|
||||
}
|
||||
|
||||
// MQTT
|
||||
try {
|
||||
await this.mqtt.connect();
|
||||
} catch (error) {
|
||||
logger.error(`MQTT failed to connect, exiting... (${error.message})`);
|
||||
logger.error(`MQTT failed to connect, exiting... (${(error as Error).message})`);
|
||||
await this.zigbee.stop();
|
||||
return this.exit(1);
|
||||
return await this.exit(1);
|
||||
}
|
||||
|
||||
// Call extensions
|
||||
@@ -208,11 +230,11 @@ export class Controller {
|
||||
|
||||
logger.info(`Zigbee2MQTT started!`);
|
||||
|
||||
const watchdogInterval = sdNotify?.watchdogInterval() || 0;
|
||||
const watchdogInterval = this.sdNotify?.watchdogInterval() || 0;
|
||||
if (watchdogInterval > 0) {
|
||||
sdNotify.startWatchdogMode(Math.floor(watchdogInterval / 2));
|
||||
this.sdNotify?.startWatchdogMode(Math.floor(watchdogInterval / 2));
|
||||
}
|
||||
sdNotify?.ready();
|
||||
this.sdNotify?.ready();
|
||||
}
|
||||
|
||||
@bind async enableDisableExtension(enable: boolean, name: string): Promise<void> {
|
||||
@@ -237,7 +259,7 @@ export class Controller {
|
||||
}
|
||||
|
||||
async stop(restart = false): Promise<void> {
|
||||
sdNotify?.stopping();
|
||||
this.sdNotify?.stopping(process.pid);
|
||||
|
||||
// Call extensions
|
||||
await this.callExtensions('stop', this.extensions);
|
||||
@@ -252,17 +274,17 @@ export class Controller {
|
||||
await this.zigbee.stop();
|
||||
logger.info('Stopped Zigbee2MQTT');
|
||||
} catch (error) {
|
||||
logger.error(`Failed to stop Zigbee2MQTT (${error.message})`);
|
||||
logger.error(`Failed to stop Zigbee2MQTT (${(error as Error).message})`);
|
||||
code = 1;
|
||||
}
|
||||
|
||||
sdNotify?.stopWatchdogMode();
|
||||
return this.exit(code, restart);
|
||||
this.sdNotify?.stopWatchdogMode();
|
||||
return await this.exit(code, restart);
|
||||
}
|
||||
|
||||
async exit(code: number, restart = false): Promise<void> {
|
||||
await logger.end();
|
||||
return this.exitCallback(code, restart);
|
||||
return await this.exitCallback(code, restart);
|
||||
}
|
||||
|
||||
@bind async onZigbeeAdapterDisconnected(): Promise<void> {
|
||||
@@ -377,7 +399,7 @@ export class Controller {
|
||||
await extension[method]?.();
|
||||
} catch (error) {
|
||||
/* istanbul ignore next */
|
||||
logger.error(`Failed to call '${extension.constructor.name}' '${method}' (${error.stack})`);
|
||||
logger.error(`Failed to call '${extension.constructor.name}' '${method}' (${(error as Error).stack})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+47
-9
@@ -2,12 +2,41 @@ import events from 'events';
|
||||
|
||||
import logger from './util/logger';
|
||||
|
||||
// eslint-disable-next-line
|
||||
type ListenerKey = object;
|
||||
|
||||
interface EventBusMap {
|
||||
adapterDisconnected: [];
|
||||
permitJoinChanged: [data: eventdata.PermitJoinChanged];
|
||||
publishAvailability: [];
|
||||
deviceRenamed: [data: eventdata.EntityRenamed];
|
||||
deviceRemoved: [data: eventdata.EntityRemoved];
|
||||
lastSeenChanged: [data: eventdata.LastSeenChanged];
|
||||
deviceNetworkAddressChanged: [data: eventdata.DeviceNetworkAddressChanged];
|
||||
deviceAnnounce: [data: eventdata.DeviceAnnounce];
|
||||
deviceInterview: [data: eventdata.DeviceInterview];
|
||||
deviceJoined: [data: eventdata.DeviceJoined];
|
||||
entityOptionsChanged: [data: eventdata.EntityOptionsChanged];
|
||||
exposesChanged: [data: eventdata.ExposesChanged];
|
||||
deviceLeave: [data: eventdata.DeviceLeave];
|
||||
deviceMessage: [data: eventdata.DeviceMessage];
|
||||
mqttMessage: [data: eventdata.MQTTMessage];
|
||||
mqttMessagePublished: [data: eventdata.MQTTMessagePublished];
|
||||
publishEntityState: [data: eventdata.PublishEntityState];
|
||||
groupMembersChanged: [data: eventdata.GroupMembersChanged];
|
||||
devicesChanged: [];
|
||||
scenesChanged: [data: eventdata.ScenesChanged];
|
||||
reconfigure: [data: eventdata.Reconfigure];
|
||||
stateChange: [data: eventdata.StateChange];
|
||||
}
|
||||
type EventBusListener<K> = K extends keyof EventBusMap
|
||||
? EventBusMap[K] extends unknown[]
|
||||
? (...args: EventBusMap[K]) => Promise<void> | void
|
||||
: never
|
||||
: never;
|
||||
|
||||
export default class EventBus {
|
||||
private callbacksByExtension: {[s: string]: {event: string; callback: (...args: unknown[]) => void}[]} = {};
|
||||
private emitter = new events.EventEmitter();
|
||||
private callbacksByExtension: {[s: string]: {event: keyof EventBusMap; callback: EventBusListener<keyof EventBusMap>}[]} = {};
|
||||
private emitter = new events.EventEmitter<EventBusMap>();
|
||||
|
||||
constructor() {
|
||||
this.emitter.setMaxListeners(100);
|
||||
@@ -167,18 +196,27 @@ export default class EventBus {
|
||||
this.on('stateChange', callback, key);
|
||||
}
|
||||
|
||||
private on(event: string, callback: (...args: unknown[]) => Promise<void> | void, key: ListenerKey): void {
|
||||
if (!this.callbacksByExtension[key.constructor.name]) this.callbacksByExtension[key.constructor.name] = [];
|
||||
const wrappedCallback = async (...args: unknown[]): Promise<void> => {
|
||||
public emitExposesAndDevicesChanged(device: Device): void {
|
||||
this.emitDevicesChanged();
|
||||
this.emitExposesChanged({device});
|
||||
}
|
||||
|
||||
private on<K extends keyof EventBusMap>(event: K, callback: EventBusListener<K>, key: ListenerKey): void {
|
||||
if (!this.callbacksByExtension[key.constructor.name]) {
|
||||
this.callbacksByExtension[key.constructor.name] = [];
|
||||
}
|
||||
|
||||
const wrappedCallback = async (...args: never[]): Promise<void> => {
|
||||
try {
|
||||
await callback(...args);
|
||||
} catch (error) {
|
||||
logger.error(`EventBus error '${key.constructor.name}/${event}': ${error.message}`);
|
||||
logger.debug(error.stack);
|
||||
logger.error(`EventBus error '${key.constructor.name}/${event}': ${(error as Error).message}`);
|
||||
logger.debug((error as Error).stack!);
|
||||
}
|
||||
};
|
||||
|
||||
this.callbacksByExtension[key.constructor.name].push({event, callback: wrappedCallback});
|
||||
this.emitter.on(event, wrappedCallback);
|
||||
this.emitter.on(event, wrappedCallback as EventBusListener<K>);
|
||||
}
|
||||
|
||||
public removeListeners(key: ListenerKey): void {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import debounce from 'debounce';
|
||||
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
|
||||
import logger from '../util/logger';
|
||||
@@ -41,9 +44,12 @@ export default class Availability extends Extension {
|
||||
}
|
||||
|
||||
private isAvailable(entity: Device | Group): boolean {
|
||||
return entity.isDevice()
|
||||
? Date.now() - entity.zh.lastSeen < this.getTimeout(entity)
|
||||
: entity.membersDevices().length === 0 || entity.membersDevices().some((d) => this.availabilityCache[d.ieeeAddr]);
|
||||
if (entity.isDevice()) {
|
||||
return Date.now() - (entity.zh.lastSeen ?? /* istanbul ignore next */ 0) < this.getTimeout(entity);
|
||||
} else {
|
||||
const membersDevices = entity.membersDevices();
|
||||
return membersDevices.length === 0 || membersDevices.some((d) => this.availabilityCache[d.ieeeAddr]);
|
||||
}
|
||||
}
|
||||
|
||||
private resetTimer(device: Device): void {
|
||||
@@ -92,7 +98,7 @@ export default class Availability extends Extension {
|
||||
logger.debug(`Successfully pinged '${device.name}' (attempt ${i}/${attempts})`);
|
||||
break;
|
||||
} catch (error) {
|
||||
logger.warning(`Failed to ping '${device.name}' (attempt ${i}/${attempts}, ${error.message})`);
|
||||
logger.warning(`Failed to ping '${device.name}' (attempt ${i}/${attempts}, ${(error as Error).message})`);
|
||||
|
||||
// Try again in 3 seconds.
|
||||
if (i !== attempts) {
|
||||
@@ -125,7 +131,7 @@ export default class Availability extends Extension {
|
||||
|
||||
this.eventBus.onEntityRenamed(this, async (data) => {
|
||||
if (utils.isAvailabilityEnabledForEntity(data.entity, settings.get())) {
|
||||
await this.mqtt.publish(`${data.from}/availability`, null, {retain: true, qos: 1});
|
||||
await this.mqtt.publish(`${data.from}/availability`, '', {retain: true, qos: 1});
|
||||
await this.publishAvailability(data.entity, false, true);
|
||||
}
|
||||
});
|
||||
@@ -162,7 +168,8 @@ export default class Availability extends Extension {
|
||||
|
||||
private async publishAvailability(entity: Device | Group, logLastSeen: boolean, forcePublish = false, skipGroups = false): Promise<void> {
|
||||
if (logLastSeen && entity.isDevice()) {
|
||||
const ago = Date.now() - entity.zh.lastSeen;
|
||||
const ago = Date.now() - (entity.zh.lastSeen ?? /* istanbul ignore next */ 0);
|
||||
|
||||
if (this.isActiveDevice(entity)) {
|
||||
logger.debug(`Active device '${entity.name}' was last seen '${(ago / utils.minutes(1)).toFixed(2)}' minutes ago.`);
|
||||
} else {
|
||||
@@ -230,22 +237,24 @@ export default class Availability extends Extension {
|
||||
continue;
|
||||
}
|
||||
|
||||
const converter = device.definition.toZigbee.find((c) => !c.key || c.key.find((k) => item.keys.includes(k)));
|
||||
const converter = device.definition!.toZigbee.find((c) => !c.key || c.key.find((k) => item.keys.includes(k)));
|
||||
const options: KeyValue = device.options;
|
||||
const state = this.state.get(device);
|
||||
const meta: zhc.Tz.Meta = {
|
||||
message: this.state.get(device),
|
||||
mapped: device.definition,
|
||||
endpoint_name: null,
|
||||
mapped: device.definition!,
|
||||
endpoint_name: undefined,
|
||||
options,
|
||||
state,
|
||||
device: device.zh,
|
||||
};
|
||||
|
||||
try {
|
||||
await converter?.convertGet?.(device.endpoint(), item.keys[0], meta);
|
||||
const endpoint = device.endpoint();
|
||||
assert(endpoint);
|
||||
await converter?.convertGet?.(endpoint, item.keys[0], meta);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to read state of '${device.name}' after reconnect (${error.message})`);
|
||||
logger.error(`Failed to read state of '${device.name}' after reconnect (${(error as Error).message})`);
|
||||
}
|
||||
|
||||
await utils.sleep(500);
|
||||
|
||||
+46
-30
@@ -1,6 +1,9 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import debounce from 'debounce';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
import {Zcl} from 'zigbee-herdsman';
|
||||
import {ClusterName} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype';
|
||||
|
||||
@@ -87,7 +90,7 @@ const REPORT_CLUSTERS: Readonly<
|
||||
type PollOnMessage = {
|
||||
cluster: Readonly<Partial<Record<ClusterName, {type: string; data: KeyValue}[]>>>;
|
||||
read: Readonly<{cluster: string; attributes: string[]; attributesForEndpoint?: (endpoint: zh.Endpoint) => Promise<string[]>}>;
|
||||
manufacturerIDs: readonly number[];
|
||||
manufacturerIDs: readonly Zcl.ManufacturerCode[];
|
||||
manufacturerNames: readonly string[];
|
||||
}[];
|
||||
|
||||
@@ -196,10 +199,17 @@ interface ParsedMQTTMessage {
|
||||
type: 'bind' | 'unbind';
|
||||
sourceKey: string;
|
||||
targetKey: string;
|
||||
clusters: string[];
|
||||
clusters?: string[];
|
||||
skipDisableReporting: boolean;
|
||||
}
|
||||
|
||||
interface DataMessage {
|
||||
from: ParsedMQTTMessage['sourceKey'];
|
||||
to: ParsedMQTTMessage['targetKey'];
|
||||
clusters: ParsedMQTTMessage['clusters'];
|
||||
skip_disable_reporting?: ParsedMQTTMessage['skipDisableReporting'];
|
||||
}
|
||||
|
||||
export default class Bind extends Extension {
|
||||
private pollDebouncers: {[s: string]: () => void} = {};
|
||||
|
||||
@@ -209,11 +219,11 @@ export default class Bind extends Extension {
|
||||
this.eventBus.onGroupMembersChanged(this, this.onGroupMembersChanged);
|
||||
}
|
||||
|
||||
private parseMQTTMessage(data: eventdata.MQTTMessage): ParsedMQTTMessage {
|
||||
let type: ParsedMQTTMessage['type'] = null;
|
||||
let sourceKey: ParsedMQTTMessage['sourceKey'] = null;
|
||||
let targetKey: ParsedMQTTMessage['targetKey'] = null;
|
||||
let clusters: ParsedMQTTMessage['clusters'] = null;
|
||||
private parseMQTTMessage(data: eventdata.MQTTMessage): ParsedMQTTMessage | undefined {
|
||||
let type: ParsedMQTTMessage['type'] | undefined;
|
||||
let sourceKey: ParsedMQTTMessage['sourceKey'] | undefined;
|
||||
let targetKey: ParsedMQTTMessage['targetKey'] | undefined;
|
||||
let clusters: ParsedMQTTMessage['clusters'] | undefined;
|
||||
let skipDisableReporting: ParsedMQTTMessage['skipDisableReporting'] = false;
|
||||
|
||||
if (LEGACY_API && data.topic.match(LEGACY_TOPIC_REGEX)) {
|
||||
@@ -223,26 +233,29 @@ export default class Bind extends Extension {
|
||||
targetKey = data.message;
|
||||
} else if (data.topic.match(TOPIC_REGEX)) {
|
||||
type = data.topic.endsWith('unbind') ? 'unbind' : 'bind';
|
||||
const message = JSON.parse(data.message);
|
||||
const message: DataMessage = JSON.parse(data.message);
|
||||
sourceKey = message.from;
|
||||
targetKey = message.to;
|
||||
clusters = message.clusters;
|
||||
skipDisableReporting = 'skip_disable_reporting' in message ? message.skip_disable_reporting : false;
|
||||
skipDisableReporting = message.skip_disable_reporting != undefined ? message.skip_disable_reporting : false;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {type, sourceKey, targetKey, clusters, skipDisableReporting};
|
||||
}
|
||||
|
||||
@bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const {type, sourceKey, targetKey, clusters, skipDisableReporting} = this.parseMQTTMessage(data);
|
||||
const parsed = this.parseMQTTMessage(data);
|
||||
|
||||
if (!type) {
|
||||
return null;
|
||||
if (!parsed || !parsed.type) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {type, sourceKey, targetKey, clusters, skipDisableReporting} = parsed;
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
|
||||
let error = null;
|
||||
let error: string | undefined;
|
||||
const parsedSource = this.zigbee.resolveEntityAndEndpoint(sourceKey);
|
||||
const parsedTarget = this.zigbee.resolveEntityAndEndpoint(targetKey);
|
||||
const source = parsedSource.entity;
|
||||
@@ -262,9 +275,11 @@ export default class Bind extends Extension {
|
||||
const failedClusters = [];
|
||||
const attemptedClusters = [];
|
||||
|
||||
const bindSource: zh.Endpoint = parsedSource.endpoint;
|
||||
const bindTarget: number | zh.Group | zh.Endpoint =
|
||||
target instanceof Device ? parsedTarget.endpoint : target instanceof Group ? target.zh : Number(target.ID);
|
||||
const bindSource = parsedSource.endpoint;
|
||||
const bindTarget = target instanceof Device ? parsedTarget.endpoint : target instanceof Group ? target.zh : Number(target.ID);
|
||||
|
||||
assert(bindSource != undefined && bindTarget != undefined);
|
||||
|
||||
// Find which clusters are supported by both the source and target.
|
||||
// Groups are assumed to support all clusters.
|
||||
const clusterCandidates = clusters ?? ALL_CLUSTER_CANDIDATES;
|
||||
@@ -274,7 +289,7 @@ export default class Bind extends Extension {
|
||||
|
||||
const anyClusterValid = utils.isZHGroup(bindTarget) || typeof bindTarget === 'number' || (target as Device).zh.type === 'Coordinator';
|
||||
|
||||
if (!anyClusterValid && utils.isEndpoint(bindTarget)) {
|
||||
if (!anyClusterValid && utils.isZHEndpoint(bindTarget)) {
|
||||
matchingClusters =
|
||||
(bindTarget.supportsInputCluster(cluster) && bindSource.supportsOutputCluster(cluster)) ||
|
||||
(bindSource.supportsInputCluster(cluster) && bindTarget.supportsOutputCluster(cluster));
|
||||
@@ -383,7 +398,7 @@ export default class Bind extends Extension {
|
||||
}
|
||||
|
||||
getSetupReportingEndpoints(bind: zh.Bind, coordinatorEp: zh.Endpoint): zh.Endpoint[] {
|
||||
const endpoints = utils.isEndpoint(bind.target) ? [bind.target] : bind.target.members;
|
||||
const endpoints = utils.isZHEndpoint(bind.target) ? [bind.target] : bind.target.members;
|
||||
|
||||
return endpoints.filter((e) => {
|
||||
if (!e.supportsInputCluster(bind.cluster.name)) {
|
||||
@@ -409,14 +424,14 @@ export default class Bind extends Extension {
|
||||
/* istanbul ignore else */
|
||||
if (bind.cluster.name in REPORT_CLUSTERS) {
|
||||
for (const endpoint of this.getSetupReportingEndpoints(bind, coordinatorEndpoint)) {
|
||||
const entity = `${this.zigbee.resolveEntity(endpoint.getDevice()).name}/${endpoint.ID}`;
|
||||
const entity = `${this.zigbee.resolveEntity(endpoint.getDevice())!.name}/${endpoint.ID}`;
|
||||
|
||||
try {
|
||||
await endpoint.bind(bind.cluster.name, coordinatorEndpoint);
|
||||
|
||||
const items = [];
|
||||
|
||||
for (const c of REPORT_CLUSTERS[bind.cluster.name as ClusterName]) {
|
||||
for (const c of REPORT_CLUSTERS[bind.cluster.name as ClusterName]!) {
|
||||
/* istanbul ignore else */
|
||||
if (!c.condition || (await c.condition(endpoint))) {
|
||||
const i = {...c};
|
||||
@@ -429,7 +444,7 @@ export default class Bind extends Extension {
|
||||
await endpoint.configureReporting(bind.cluster.name, items);
|
||||
logger.info(`Successfully setup reporting for '${entity}' cluster '${bind.cluster.name}'`);
|
||||
} catch (error) {
|
||||
logger.warning(`Failed to setup reporting for '${entity}' cluster '${bind.cluster.name}' (${error.message})`);
|
||||
logger.warning(`Failed to setup reporting for '${entity}' cluster '${bind.cluster.name}' (${(error as Error).message})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -440,7 +455,7 @@ export default class Bind extends Extension {
|
||||
|
||||
async disableUnnecessaryReportings(target: zh.Group | zh.Endpoint): Promise<void> {
|
||||
const coordinator = this.zigbee.firstCoordinatorEndpoint();
|
||||
const endpoints = utils.isEndpoint(target) ? [target] : target.members;
|
||||
const endpoints = utils.isZHEndpoint(target) ? [target] : target.members;
|
||||
const allBinds: zh.Bind[] = [];
|
||||
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
@@ -458,7 +473,7 @@ export default class Bind extends Extension {
|
||||
const boundClusters: string[] = [];
|
||||
|
||||
for (const bind of allBinds) {
|
||||
if (utils.isEndpoint(bind.target) ? bind.target === endpoint : bind.target.members.includes(endpoint)) {
|
||||
if (utils.isZHEndpoint(bind.target) ? bind.target === endpoint : bind.target.members.includes(endpoint)) {
|
||||
requiredClusters.push(bind.cluster.name);
|
||||
}
|
||||
}
|
||||
@@ -476,7 +491,7 @@ export default class Bind extends Extension {
|
||||
|
||||
const items = [];
|
||||
|
||||
for (const item of REPORT_CLUSTERS[cluster as ClusterName]) {
|
||||
for (const item of REPORT_CLUSTERS[cluster as ClusterName]!) {
|
||||
/* istanbul ignore else */
|
||||
if (!item.condition || (await item.condition(endpoint))) {
|
||||
const i = {...item};
|
||||
@@ -489,7 +504,7 @@ export default class Bind extends Extension {
|
||||
await endpoint.configureReporting(cluster, items);
|
||||
logger.info(`Successfully disabled reporting for '${entity}' cluster '${cluster}'`);
|
||||
} catch (error) {
|
||||
logger.warning(`Failed to disable reporting for '${entity}' cluster '${cluster}' (${error.message})`);
|
||||
logger.warning(`Failed to disable reporting for '${entity}' cluster '${cluster}' (${(error as Error).message})`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,7 +531,7 @@ export default class Bind extends Extension {
|
||||
// Add bound devices
|
||||
for (const endpoint of data.device.zh.endpoints) {
|
||||
for (const bind of endpoint.binds) {
|
||||
if (utils.isEndpoint(bind.target) && bind.target.getDevice().type !== 'Coordinator') {
|
||||
if (utils.isZHEndpoint(bind.target) && bind.target.getDevice().type !== 'Coordinator') {
|
||||
toPoll.add(bind.target);
|
||||
}
|
||||
}
|
||||
@@ -532,10 +547,11 @@ export default class Bind extends Extension {
|
||||
}
|
||||
|
||||
for (const endpoint of toPoll) {
|
||||
const device = endpoint.getDevice();
|
||||
for (const poll of polls) {
|
||||
// XXX: manufacturerID/manufacturerName can be undefined and won't match `includes`, but TS enforces same-type
|
||||
if (
|
||||
(!poll.manufacturerIDs.includes(endpoint.getDevice().manufacturerID) &&
|
||||
!poll.manufacturerNames.includes(endpoint.getDevice().manufacturerName)) ||
|
||||
(!poll.manufacturerIDs.includes(device.manufacturerID!) && !poll.manufacturerNames.includes(device.manufacturerName!)) ||
|
||||
!endpoint.supportsInputCluster(poll.read.cluster)
|
||||
) {
|
||||
continue;
|
||||
@@ -548,7 +564,7 @@ export default class Bind extends Extension {
|
||||
readAttrs = [...poll.read.attributes, ...attrsForEndpoint];
|
||||
}
|
||||
|
||||
const key = `${endpoint.getDevice().ieeeAddr}_${endpoint.ID}_${POLL_ON_MESSAGE.indexOf(poll)}`;
|
||||
const key = `${device.ieeeAddr}_${endpoint.ID}_${POLL_ON_MESSAGE.indexOf(poll)}`;
|
||||
|
||||
if (!this.pollDebouncers[key]) {
|
||||
this.pollDebouncers[key] = debounce(async () => {
|
||||
@@ -556,7 +572,7 @@ export default class Bind extends Extension {
|
||||
await endpoint.read(poll.read.cluster, readAttrs);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to poll ${readAttrs} from ${this.zigbee.resolveEntity(endpoint.getDevice()).name} (${error.message})`,
|
||||
`Failed to poll ${readAttrs} from ${this.zigbee.resolveEntity(device)!.name} (${(error as Error).message})`,
|
||||
);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
+137
-89
@@ -1,14 +1,15 @@
|
||||
/* eslint-disable camelcase */
|
||||
import bind from 'bind-decorator';
|
||||
import fs from 'fs';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import JSZip from 'jszip';
|
||||
import objectAssignDeep from 'object-assign-deep';
|
||||
import winston from 'winston';
|
||||
import Transport from 'winston-transport';
|
||||
import {Clusters} from 'zigbee-herdsman/dist/zspec/zcl/definition/cluster';
|
||||
import {CustomClusters, ClusterDefinition, ClusterName} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype';
|
||||
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
import {Clusters} from 'zigbee-herdsman/dist/zspec/zcl/definition/cluster';
|
||||
import {ClusterDefinition, ClusterName, CustomClusters} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype';
|
||||
|
||||
import Device from '../model/device';
|
||||
import Group from '../model/group';
|
||||
@@ -27,18 +28,24 @@ type DefinitionPayload = {
|
||||
exposes: zhc.Expose[];
|
||||
supports_ota: boolean;
|
||||
icon: string;
|
||||
options: zhc.Expose[];
|
||||
options: zhc.Option[];
|
||||
};
|
||||
|
||||
export default class Bridge extends Extension {
|
||||
private zigbee2mqttVersion: {commitHash: string; version: string};
|
||||
// @ts-expect-error initialized in `start`
|
||||
private zigbee2mqttVersion: {commitHash?: string; version: string};
|
||||
// @ts-expect-error initialized in `start`
|
||||
private zigbeeHerdsmanVersion: {version: string};
|
||||
// @ts-expect-error initialized in `start`
|
||||
private zigbeeHerdsmanConvertersVersion: {version: string};
|
||||
// @ts-expect-error initialized in `start`
|
||||
private coordinatorVersion: zh.CoordinatorVersion;
|
||||
private restartRequired = false;
|
||||
private lastJoinedDeviceIeeeAddr: string;
|
||||
private lastBridgeLoggingPayload: string;
|
||||
private lastJoinedDeviceIeeeAddr?: string;
|
||||
private lastBridgeLoggingPayload?: string;
|
||||
// @ts-expect-error initialized in `start`
|
||||
private logTransport: winston.transport;
|
||||
// @ts-expect-error initialized in `start`
|
||||
private requestLookup: {[key: string]: (message: KeyValue | string) => Promise<MQTTResponse>};
|
||||
|
||||
override async start(): Promise<void> {
|
||||
@@ -111,10 +118,22 @@ export default class Bridge extends Extension {
|
||||
this.zigbeeHerdsmanConvertersVersion = await utils.getDependencyVersion('zigbee-herdsman-converters');
|
||||
this.coordinatorVersion = await this.zigbee.getCoordinatorVersion();
|
||||
|
||||
this.eventBus.onEntityRenamed(this, () => this.publishInfo());
|
||||
this.eventBus.onGroupMembersChanged(this, () => this.publishGroups());
|
||||
this.eventBus.onDevicesChanged(this, () => this.publishDevices() && this.publishInfo() && this.publishDefinitions());
|
||||
this.eventBus.onPermitJoinChanged(this, () => !this.zigbee.isStopping() && this.publishInfo());
|
||||
this.eventBus.onEntityRenamed(this, async () => {
|
||||
await this.publishInfo();
|
||||
});
|
||||
this.eventBus.onGroupMembersChanged(this, async () => {
|
||||
await this.publishGroups();
|
||||
});
|
||||
this.eventBus.onDevicesChanged(this, async () => {
|
||||
await this.publishDevices();
|
||||
await this.publishInfo();
|
||||
await this.publishDefinitions();
|
||||
});
|
||||
this.eventBus.onPermitJoinChanged(this, async () => {
|
||||
if (!this.zigbee.isStopping()) {
|
||||
await this.publishInfo();
|
||||
}
|
||||
});
|
||||
this.eventBus.onScenesChanged(this, async () => {
|
||||
await this.publishDevices();
|
||||
await this.publishGroups();
|
||||
@@ -122,7 +141,7 @@ export default class Bridge extends Extension {
|
||||
|
||||
// Zigbee events
|
||||
const publishEvent = async (type: string, data: KeyValue): Promise<void> =>
|
||||
this.mqtt.publish('bridge/event', stringify({type, data}), {retain: false, qos: 0});
|
||||
await this.mqtt.publish('bridge/event', stringify({type, data}), {retain: false, qos: 0});
|
||||
this.eventBus.onDeviceJoined(this, async (data) => {
|
||||
this.lastJoinedDeviceIeeeAddr = data.device.ieeeAddr;
|
||||
await this.publishDevices();
|
||||
@@ -133,14 +152,18 @@ export default class Bridge extends Extension {
|
||||
await this.publishDefinitions();
|
||||
await publishEvent('device_leave', {ieee_address: data.ieeeAddr, friendly_name: data.name});
|
||||
});
|
||||
this.eventBus.onDeviceNetworkAddressChanged(this, () => this.publishDevices());
|
||||
this.eventBus.onDeviceNetworkAddressChanged(this, async () => {
|
||||
await this.publishDevices();
|
||||
});
|
||||
this.eventBus.onDeviceInterview(this, async (data) => {
|
||||
await this.publishDevices();
|
||||
const payload: KeyValue = {friendly_name: data.device.name, status: data.status, ieee_address: data.device.ieeeAddr};
|
||||
|
||||
if (data.status === 'successful') {
|
||||
payload.supported = data.device.isSupported;
|
||||
payload.definition = this.getDefinitionPayload(data.device);
|
||||
}
|
||||
|
||||
await publishEvent('device_interview', payload);
|
||||
});
|
||||
this.eventBus.onDeviceAnnounce(this, async (data) => {
|
||||
@@ -163,7 +186,13 @@ export default class Bridge extends Extension {
|
||||
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const match = data.topic.match(requestRegex);
|
||||
const key = match?.[1]?.toLowerCase();
|
||||
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = match[1].toLowerCase();
|
||||
|
||||
if (key in this.requestLookup) {
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
|
||||
@@ -171,9 +200,9 @@ export default class Bridge extends Extension {
|
||||
const response = await this.requestLookup[key](message);
|
||||
await this.mqtt.publish(`bridge/response/${match[1]}`, stringify(response));
|
||||
} catch (error) {
|
||||
logger.error(`Request '${data.topic}' failed with error: '${error.message}'`);
|
||||
logger.debug(error.stack);
|
||||
const response = utils.getResponse(message, {}, error.message);
|
||||
logger.error(`Request '${data.topic}' failed with error: '${(error as Error).message}'`);
|
||||
logger.debug((error as Error).stack!);
|
||||
const response = utils.getResponse(message, {}, (error as Error).message);
|
||||
await this.mqtt.publish(`bridge/response/${match[1]}`, stringify(response));
|
||||
}
|
||||
}
|
||||
@@ -184,11 +213,11 @@ export default class Bridge extends Extension {
|
||||
*/
|
||||
|
||||
@bind async deviceOptions(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
return this.changeEntityOptions('device', message);
|
||||
return await this.changeEntityOptions('device', message);
|
||||
}
|
||||
|
||||
@bind async groupOptions(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
return this.changeEntityOptions('group', message);
|
||||
return await this.changeEntityOptions('group', message);
|
||||
}
|
||||
|
||||
@bind async bridgeOptions(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
@@ -223,19 +252,19 @@ export default class Bridge extends Extension {
|
||||
|
||||
logger.info('Successfully changed options');
|
||||
await this.publishInfo();
|
||||
return utils.getResponse(message, {restart_required: this.restartRequired}, null);
|
||||
return utils.getResponse(message, {restart_required: this.restartRequired});
|
||||
}
|
||||
|
||||
@bind async deviceRemove(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
return this.removeEntity('device', message);
|
||||
return await this.removeEntity('device', message);
|
||||
}
|
||||
|
||||
@bind async groupRemove(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
return this.removeEntity('group', message);
|
||||
return await this.removeEntity('group', message);
|
||||
}
|
||||
|
||||
@bind async healthCheck(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
return utils.getResponse(message, {healthy: true}, null);
|
||||
return utils.getResponse(message, {healthy: true});
|
||||
}
|
||||
|
||||
@bind async coordinatorCheck(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
@@ -243,35 +272,35 @@ export default class Bridge extends Extension {
|
||||
const missingRouters = result.missingRouters.map((d) => {
|
||||
return {ieee_address: d.ieeeAddr, friendly_name: d.name};
|
||||
});
|
||||
return utils.getResponse(message, {missing_routers: missingRouters}, null);
|
||||
return utils.getResponse(message, {missing_routers: missingRouters});
|
||||
}
|
||||
|
||||
@bind async groupAdd(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
if (typeof message === 'object' && !message.hasOwnProperty('friendly_name')) {
|
||||
if (typeof message === 'object' && message.friendly_name === undefined) {
|
||||
throw new Error(`Invalid payload`);
|
||||
}
|
||||
|
||||
const friendlyName = typeof message === 'object' ? message.friendly_name : message;
|
||||
const ID = typeof message === 'object' && message.hasOwnProperty('id') ? message.id : null;
|
||||
const ID = typeof message === 'object' && message.id !== undefined ? message.id : null;
|
||||
const group = settings.addGroup(friendlyName, ID);
|
||||
this.zigbee.createGroup(group.ID);
|
||||
await this.publishGroups();
|
||||
return utils.getResponse(message, {friendly_name: group.friendly_name, id: group.ID}, null);
|
||||
return utils.getResponse(message, {friendly_name: group.friendly_name, id: group.ID});
|
||||
}
|
||||
|
||||
@bind async deviceRename(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
return this.renameEntity('device', message);
|
||||
return await this.renameEntity('device', message);
|
||||
}
|
||||
|
||||
@bind async groupRename(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
return this.renameEntity('group', message);
|
||||
return await this.renameEntity('group', message);
|
||||
}
|
||||
|
||||
@bind async restart(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
// Wait 500 ms before restarting so response can be send.
|
||||
setTimeout(this.restartCallback, 500);
|
||||
logger.info('Restarting Zigbee2MQTT');
|
||||
return utils.getResponse(message, {}, null);
|
||||
return utils.getResponse(message, {});
|
||||
}
|
||||
|
||||
@bind async backup(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
@@ -284,33 +313,36 @@ export default class Bridge extends Extension {
|
||||
const zip = new JSZip();
|
||||
files.forEach((f) => zip.file(f[1], fs.readFileSync(f[0])));
|
||||
const base64Zip = await zip.generateAsync({type: 'base64'});
|
||||
return utils.getResponse(message, {zip: base64Zip}, null);
|
||||
return utils.getResponse(message, {zip: base64Zip});
|
||||
}
|
||||
|
||||
@bind async installCodeAdd(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
if (typeof message === 'object' && !message.hasOwnProperty('value')) {
|
||||
if (typeof message === 'object' && message.value === undefined) {
|
||||
throw new Error('Invalid payload');
|
||||
}
|
||||
|
||||
const value = typeof message === 'object' ? message.value : message;
|
||||
await this.zigbee.addInstallCode(value);
|
||||
logger.info('Successfully added new install code');
|
||||
return utils.getResponse(message, {value}, null);
|
||||
return utils.getResponse(message, {value});
|
||||
}
|
||||
|
||||
@bind async permitJoin(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
if (typeof message === 'object' && !message.hasOwnProperty('value')) {
|
||||
if (typeof message === 'object' && message.value === undefined) {
|
||||
throw new Error('Invalid payload');
|
||||
}
|
||||
|
||||
let value: boolean | string;
|
||||
let time: number;
|
||||
let device: Device = null;
|
||||
let time: number | undefined;
|
||||
let device: Device | undefined;
|
||||
|
||||
if (typeof message === 'object') {
|
||||
value = message.value;
|
||||
time = message.time;
|
||||
|
||||
if (message.device) {
|
||||
const resolved = this.zigbee.resolveEntity(message.device);
|
||||
|
||||
if (resolved instanceof Device) {
|
||||
device = resolved;
|
||||
} else {
|
||||
@@ -326,10 +358,20 @@ export default class Bridge extends Extension {
|
||||
}
|
||||
|
||||
await this.zigbee.permitJoin(value, device, time);
|
||||
|
||||
const response: {value: boolean; device?: string; time?: number} = {value};
|
||||
if (device && typeof message === 'object') response.device = message.device;
|
||||
if (time && typeof message === 'object') response.time = message.time;
|
||||
return utils.getResponse(message, response, null);
|
||||
|
||||
if (typeof message === 'object') {
|
||||
if (device) {
|
||||
response.device = message.device;
|
||||
}
|
||||
|
||||
if (time != undefined) {
|
||||
response.time = message.time;
|
||||
}
|
||||
}
|
||||
|
||||
return utils.getResponse(message, response);
|
||||
}
|
||||
|
||||
// Deprecated
|
||||
@@ -342,7 +384,7 @@ export default class Bridge extends Extension {
|
||||
|
||||
settings.set(['advanced', 'last_seen'], value);
|
||||
await this.publishInfo();
|
||||
return utils.getResponse(message, {value}, null);
|
||||
return utils.getResponse(message, {value});
|
||||
}
|
||||
|
||||
// Deprecated
|
||||
@@ -353,10 +395,10 @@ export default class Bridge extends Extension {
|
||||
throw new Error(`'${value}' is not an allowed value, allowed: ${allowed}`);
|
||||
}
|
||||
|
||||
await this.enableDisableExtension(value, 'HomeAssistant');
|
||||
settings.set(['homeassistant'], value);
|
||||
await this.enableDisableExtension(value, 'HomeAssistant');
|
||||
await this.publishInfo();
|
||||
return utils.getResponse(message, {value}, null);
|
||||
return utils.getResponse(message, {value});
|
||||
}
|
||||
|
||||
// Deprecated
|
||||
@@ -369,7 +411,7 @@ export default class Bridge extends Extension {
|
||||
|
||||
settings.set(['advanced', 'elapsed'], value);
|
||||
await this.publishInfo();
|
||||
return utils.getResponse(message, {value}, null);
|
||||
return utils.getResponse(message, {value});
|
||||
}
|
||||
|
||||
// Deprecated
|
||||
@@ -381,23 +423,23 @@ export default class Bridge extends Extension {
|
||||
|
||||
logger.setLevel(value);
|
||||
await this.publishInfo();
|
||||
return utils.getResponse(message, {value}, null);
|
||||
return utils.getResponse(message, {value});
|
||||
}
|
||||
|
||||
@bind async touchlinkIdentify(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
if (typeof message !== 'object' || !message.hasOwnProperty('ieee_address') || !message.hasOwnProperty('channel')) {
|
||||
if (typeof message !== 'object' || message.ieee_address === undefined || message.channel === undefined) {
|
||||
throw new Error('Invalid payload');
|
||||
}
|
||||
|
||||
logger.info(`Start Touchlink identify of '${message.ieee_address}' on channel ${message.channel}`);
|
||||
await this.zigbee.touchlinkIdentify(message.ieee_address, message.channel);
|
||||
return utils.getResponse(message, {ieee_address: message.ieee_address, channel: message.channel}, null);
|
||||
return utils.getResponse(message, {ieee_address: message.ieee_address, channel: message.channel});
|
||||
}
|
||||
|
||||
@bind async touchlinkFactoryReset(message: KeyValue | string): Promise<MQTTResponse> {
|
||||
let result = false;
|
||||
const payload: {ieee_address?: string; channel?: number} = {};
|
||||
if (typeof message === 'object' && message.hasOwnProperty('ieee_address') && message.hasOwnProperty('channel')) {
|
||||
if (typeof message === 'object' && message.ieee_address !== undefined && message.channel !== undefined) {
|
||||
logger.info(`Start Touchlink factory reset of '${message.ieee_address}' on channel ${message.channel}`);
|
||||
result = await this.zigbee.touchlinkFactoryReset(message.ieee_address, message.channel);
|
||||
payload.ieee_address = message.ieee_address;
|
||||
@@ -409,7 +451,7 @@ export default class Bridge extends Extension {
|
||||
|
||||
if (result) {
|
||||
logger.info('Successfully factory reset device through Touchlink');
|
||||
return utils.getResponse(message, payload, null);
|
||||
return utils.getResponse(message, payload);
|
||||
} else {
|
||||
logger.error('Failed to factory reset device through Touchlink');
|
||||
throw new Error('Failed to factory reset device through Touchlink');
|
||||
@@ -423,7 +465,7 @@ export default class Bridge extends Extension {
|
||||
return {ieee_address: r.ieeeAddr, channel: r.channel};
|
||||
});
|
||||
logger.info('Finished Touchlink scan');
|
||||
return utils.getResponse(message, {found}, null);
|
||||
return utils.getResponse(message, {found});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -432,7 +474,7 @@ export default class Bridge extends Extension {
|
||||
|
||||
getValue(message: KeyValue | string): string | boolean | number {
|
||||
if (typeof message === 'object') {
|
||||
if (!message.hasOwnProperty('value')) {
|
||||
if (message.value === undefined) {
|
||||
throw new Error('No value given');
|
||||
}
|
||||
|
||||
@@ -443,7 +485,7 @@ export default class Bridge extends Extension {
|
||||
}
|
||||
|
||||
async changeEntityOptions(entityType: 'device' | 'group', message: KeyValue | string): Promise<MQTTResponse> {
|
||||
if (typeof message !== 'object' || !message.hasOwnProperty('id') || !message.hasOwnProperty('options')) {
|
||||
if (typeof message !== 'object' || message.id === undefined || message.options === undefined) {
|
||||
throw new Error(`Invalid payload`);
|
||||
}
|
||||
|
||||
@@ -467,27 +509,29 @@ export default class Bridge extends Extension {
|
||||
logger.info(`Changed config for ${entityType} ${ID}`);
|
||||
|
||||
this.eventBus.emitEntityOptionsChanged({from: oldOptions, to: newOptions, entity});
|
||||
return utils.getResponse(message, {from: oldOptions, to: newOptions, id: ID, restart_required: this.restartRequired}, null);
|
||||
return utils.getResponse(message, {from: oldOptions, to: newOptions, id: ID, restart_required: this.restartRequired});
|
||||
}
|
||||
|
||||
@bind async deviceConfigureReporting(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
if (
|
||||
typeof message !== 'object' ||
|
||||
!message.hasOwnProperty('id') ||
|
||||
!message.hasOwnProperty('cluster') ||
|
||||
!message.hasOwnProperty('maximum_report_interval') ||
|
||||
!message.hasOwnProperty('minimum_report_interval') ||
|
||||
!message.hasOwnProperty('reportable_change') ||
|
||||
!message.hasOwnProperty('attribute')
|
||||
message.id === undefined ||
|
||||
message.cluster === undefined ||
|
||||
message.maximum_report_interval === undefined ||
|
||||
message.minimum_report_interval === undefined ||
|
||||
message.reportable_change === undefined ||
|
||||
message.attribute === undefined
|
||||
) {
|
||||
throw new Error(`Invalid payload`);
|
||||
}
|
||||
|
||||
const device = this.zigbee.resolveEntityAndEndpoint(message.id);
|
||||
if (!device.entity) throw new Error(`Device '${message.id}' does not exist`);
|
||||
if (!device.entity) {
|
||||
throw new Error(`Device '${message.id}' does not exist`);
|
||||
}
|
||||
|
||||
const endpoint = device.endpoint;
|
||||
if (device.endpointID && !endpoint) {
|
||||
if (!endpoint) {
|
||||
throw new Error(`Device '${device.ID}' does not have endpoint '${device.endpointID}'`);
|
||||
}
|
||||
|
||||
@@ -511,22 +555,18 @@ export default class Bridge extends Extension {
|
||||
|
||||
logger.info(`Configured reporting for '${message.id}', '${message.cluster}.${message.attribute}'`);
|
||||
|
||||
return utils.getResponse(
|
||||
message,
|
||||
{
|
||||
id: message.id,
|
||||
cluster: message.cluster,
|
||||
maximum_report_interval: message.maximum_report_interval,
|
||||
minimum_report_interval: message.minimum_report_interval,
|
||||
reportable_change: message.reportable_change,
|
||||
attribute: message.attribute,
|
||||
},
|
||||
null,
|
||||
);
|
||||
return utils.getResponse(message, {
|
||||
id: message.id,
|
||||
cluster: message.cluster,
|
||||
maximum_report_interval: message.maximum_report_interval,
|
||||
minimum_report_interval: message.minimum_report_interval,
|
||||
reportable_change: message.reportable_change,
|
||||
attribute: message.attribute,
|
||||
});
|
||||
}
|
||||
|
||||
@bind async deviceInterview(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
if (typeof message !== 'object' || !message.hasOwnProperty('id')) {
|
||||
if (typeof message !== 'object' || message.id === undefined) {
|
||||
throw new Error(`Invalid payload`);
|
||||
}
|
||||
|
||||
@@ -545,25 +585,29 @@ export default class Bridge extends Extension {
|
||||
this.eventBus.emitDevicesChanged();
|
||||
this.eventBus.emitExposesChanged({device});
|
||||
|
||||
return utils.getResponse(message, {id: message.id}, null);
|
||||
return utils.getResponse(message, {id: message.id});
|
||||
}
|
||||
|
||||
@bind async deviceGenerateExternalDefinition(message: string | KeyValue): Promise<MQTTResponse> {
|
||||
if (typeof message !== 'object' || !message.hasOwnProperty('id')) {
|
||||
if (typeof message !== 'object' || message.id === undefined) {
|
||||
throw new Error(`Invalid payload`);
|
||||
}
|
||||
|
||||
const device = this.zigbee.resolveEntityAndEndpoint(message.id).entity as Device;
|
||||
if (!device) throw new Error(`Device '${message.id}' does not exist`);
|
||||
|
||||
if (!device) {
|
||||
throw new Error(`Device '${message.id}' does not exist`);
|
||||
}
|
||||
|
||||
const source = await zhc.generateExternalDefinitionSource(device.zh);
|
||||
|
||||
return utils.getResponse(message, {id: message.id, source}, null);
|
||||
return utils.getResponse(message, {id: message.id, source});
|
||||
}
|
||||
|
||||
async renameEntity(entityType: 'group' | 'device', message: string | KeyValue): Promise<MQTTResponse> {
|
||||
const deviceAndHasLast = entityType === 'device' && typeof message === 'object' && message.last === true;
|
||||
if (typeof message !== 'object' || (!message.hasOwnProperty('from') && !deviceAndHasLast) || !message.hasOwnProperty('to')) {
|
||||
|
||||
if (typeof message !== 'object' || (message.from === undefined && !deviceAndHasLast) || message.to === undefined) {
|
||||
throw new Error(`Invalid payload`);
|
||||
}
|
||||
|
||||
@@ -573,7 +617,7 @@ export default class Bridge extends Extension {
|
||||
|
||||
const from = deviceAndHasLast ? this.lastJoinedDeviceIeeeAddr : message.from;
|
||||
const to = message.to;
|
||||
const homeAssisantRename = message.hasOwnProperty('homeassistant_rename') ? message.homeassistant_rename : false;
|
||||
const homeAssisantRename = message.homeassistant_rename !== undefined ? message.homeassistant_rename : false;
|
||||
const entity = this.getEntity(entityType, from);
|
||||
const oldFriendlyName = entity.options.friendly_name;
|
||||
|
||||
@@ -594,7 +638,7 @@ export default class Bridge extends Extension {
|
||||
// Republish entity state
|
||||
await this.publishEntityState(entity, {});
|
||||
|
||||
return utils.getResponse(message, {from: oldFriendlyName, to, homeassistant_rename: homeAssisantRename}, null);
|
||||
return utils.getResponse(message, {from: oldFriendlyName, to, homeassistant_rename: homeAssisantRename});
|
||||
}
|
||||
|
||||
async removeEntity(entityType: 'group' | 'device', message: string | KeyValue): Promise<MQTTResponse> {
|
||||
@@ -665,10 +709,10 @@ export default class Bridge extends Extension {
|
||||
await this.publishDevices();
|
||||
// Refresh Cluster definition
|
||||
await this.publishDefinitions();
|
||||
return utils.getResponse(message, {id: ID, block, force}, null);
|
||||
return utils.getResponse(message, {id: ID, block, force});
|
||||
} else {
|
||||
await this.publishGroups();
|
||||
return utils.getResponse(message, {id: ID, force: force}, null);
|
||||
return utils.getResponse(message, {id: ID, force: force});
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to remove ${entityType} '${friendlyName}'${blockForceLog} (${error})`);
|
||||
@@ -685,11 +729,14 @@ export default class Bridge extends Extension {
|
||||
|
||||
async publishInfo(): Promise<void> {
|
||||
const config = objectAssignDeep({}, settings.get());
|
||||
// @ts-expect-error hidden from publish
|
||||
delete config.advanced.network_key;
|
||||
delete config.mqtt.password;
|
||||
|
||||
if (config.frontend) {
|
||||
delete config.frontend.auth_token;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
version: this.zigbee2mqttVersion.version,
|
||||
commit: this.zigbee2mqttVersion.commitHash,
|
||||
@@ -699,7 +746,7 @@ export default class Bridge extends Extension {
|
||||
ieee_address: this.zigbee.firstCoordinatorEndpoint().getDevice().ieeeAddr,
|
||||
...this.coordinatorVersion,
|
||||
},
|
||||
network: utils.toSnakeCase(await this.zigbee.getNetworkParameters()),
|
||||
network: utils.toSnakeCaseObject(await this.zigbee.getNetworkParameters()),
|
||||
log_level: logger.getLevel(),
|
||||
permit_join: this.zigbee.getPermitJoin(),
|
||||
permit_join_timeout: this.zigbee.getPermitJoinTimeout(),
|
||||
@@ -743,7 +790,7 @@ export default class Bridge extends Extension {
|
||||
};
|
||||
|
||||
for (const bind of endpoint.binds) {
|
||||
const target = utils.isEndpoint(bind.target)
|
||||
const target = utils.isZHEndpoint(bind.target)
|
||||
? {type: 'endpoint', ieee_address: bind.target.getDevice().ieeeAddr, endpoint: bind.target.ID}
|
||||
: {type: 'group', id: bind.target.groupID};
|
||||
data.bindings.push({cluster: bind.cluster.name, target});
|
||||
@@ -826,9 +873,9 @@ export default class Bridge extends Extension {
|
||||
await this.mqtt.publish('bridge/definitions', stringify(data), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
|
||||
}
|
||||
|
||||
getDefinitionPayload(device: Device): DefinitionPayload | null {
|
||||
getDefinitionPayload(device: Device): DefinitionPayload | undefined {
|
||||
if (!device.definition) {
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// TODO: better typing to avoid @ts-expect-error
|
||||
@@ -837,7 +884,8 @@ export default class Bridge extends Extension {
|
||||
let icon = device.options.icon ?? definitionIcon;
|
||||
|
||||
if (icon) {
|
||||
icon = icon.replace('${zigbeeModel}', utils.sanitizeImageParameter(device.zh.modelID));
|
||||
/* istanbul ignore next */
|
||||
icon = icon.replace('${zigbeeModel}', utils.sanitizeImageParameter(device.zh.modelID ?? ''));
|
||||
icon = icon.replace('${model}', utils.sanitizeImageParameter(device.definition.model));
|
||||
}
|
||||
|
||||
@@ -847,7 +895,7 @@ export default class Bridge extends Extension {
|
||||
description: device.definition.description,
|
||||
exposes: device.exposes(),
|
||||
supports_ota: !!device.definition.ota,
|
||||
options: device.definition.options,
|
||||
options: device.definition.options ?? [],
|
||||
icon,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
|
||||
import Device from '../model/device';
|
||||
@@ -19,7 +20,7 @@ export default class Configure extends Extension {
|
||||
|
||||
@bind private async onReconfigure(data: eventdata.Reconfigure): Promise<void> {
|
||||
// Disabling reporting unbinds some cluster which could be bound by configure, re-setup.
|
||||
if (data.device.zh.meta?.hasOwnProperty('configured')) {
|
||||
if (data.device.zh.meta?.configured !== undefined) {
|
||||
delete data.device.zh.meta.configured;
|
||||
data.device.zh.save();
|
||||
}
|
||||
@@ -43,8 +44,8 @@ export default class Configure extends Extension {
|
||||
await this.configure(device, 'mqtt_message', true);
|
||||
} else if (data.topic === this.topic) {
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
const ID = typeof message === 'object' && message.hasOwnProperty('id') ? message.id : message;
|
||||
let error = null;
|
||||
const ID = typeof message === 'object' && message.id !== undefined ? message.id : message;
|
||||
let error: string | undefined;
|
||||
|
||||
const device = this.zigbee.resolveEntity(ID);
|
||||
if (!device || !(device instanceof Device)) {
|
||||
@@ -55,7 +56,7 @@ export default class Configure extends Extension {
|
||||
try {
|
||||
await this.configure(device, 'mqtt_message', true, true);
|
||||
} catch (e) {
|
||||
error = `Failed to configure (${e.message})`;
|
||||
error = `Failed to configure (${(e as Error).message})`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +77,7 @@ export default class Configure extends Extension {
|
||||
});
|
||||
|
||||
this.eventBus.onDeviceJoined(this, async (data) => {
|
||||
if (data.device.zh.meta.hasOwnProperty('configured')) {
|
||||
if (data.device.zh.meta.configured !== undefined) {
|
||||
delete data.device.zh.meta.configured;
|
||||
data.device.zh.save();
|
||||
}
|
||||
@@ -95,12 +96,16 @@ export default class Configure extends Extension {
|
||||
force = false,
|
||||
throwError = false,
|
||||
): Promise<void> {
|
||||
if (!device.definition?.configure) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!force) {
|
||||
if (device.options.disabled || !device.definition?.configure || !device.zh.interviewCompleted) {
|
||||
if (device.options.disabled || !device.zh.interviewCompleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (device.zh.meta?.hasOwnProperty('configured')) {
|
||||
if (device.zh.meta?.configured !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -116,7 +121,7 @@ export default class Configure extends Extension {
|
||||
|
||||
this.configuring.add(device.ieeeAddr);
|
||||
|
||||
if (!this.attempts.hasOwnProperty(device.ieeeAddr)) {
|
||||
if (this.attempts[device.ieeeAddr] === undefined) {
|
||||
this.attempts[device.ieeeAddr] = 0;
|
||||
}
|
||||
|
||||
@@ -130,7 +135,7 @@ export default class Configure extends Extension {
|
||||
} catch (error) {
|
||||
this.attempts[device.ieeeAddr]++;
|
||||
const attempt = this.attempts[device.ieeeAddr];
|
||||
const msg = `Failed to configure '${device.name}', attempt ${attempt} (${error.stack})`;
|
||||
const msg = `Failed to configure '${device.name}', attempt ${attempt} (${(error as Error).stack})`;
|
||||
logger.error(msg);
|
||||
|
||||
if (throwError) {
|
||||
|
||||
@@ -27,7 +27,7 @@ export default class ExternalConverters extends Extension {
|
||||
}
|
||||
logger.info(`Loaded external converter '${file}'`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to load external converter file '${file}' (${error.message})`);
|
||||
logger.error(`Failed to load external converter file '${file}' (${(error as Error).message})`);
|
||||
logger.error(
|
||||
`Probably there is a syntax error in the file or the external converter is not ` +
|
||||
`compatible with the current Zigbee2MQTT version`,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import bind from 'bind-decorator';
|
||||
import fs from 'fs';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import path from 'path';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import data from './../util/data';
|
||||
@@ -12,11 +13,13 @@ import Extension from './extension';
|
||||
const requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/extension/(save|remove)`);
|
||||
|
||||
export default class ExternalExtension extends Extension {
|
||||
private requestLookup: {[s: string]: (message: KeyValue) => Promise<MQTTResponse>};
|
||||
private requestLookup: {[s: string]: (message: KeyValue) => Promise<MQTTResponse>} = {
|
||||
save: this.saveExtension,
|
||||
remove: this.removeExtension,
|
||||
};
|
||||
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
this.requestLookup = {save: this.saveExtension, remove: this.removeExtension};
|
||||
await this.loadUserDefinedExtensions();
|
||||
await this.publishExtensions();
|
||||
}
|
||||
@@ -52,7 +55,7 @@ export default class ExternalExtension extends Extension {
|
||||
fs.unlinkSync(extensionFilePath);
|
||||
await this.publishExtensions();
|
||||
logger.info(`Extension ${name} removed`);
|
||||
return utils.getResponse(message, {}, null);
|
||||
return utils.getResponse(message, {});
|
||||
} else {
|
||||
return utils.getResponse(message, {}, `Extension ${name} doesn't exists`);
|
||||
}
|
||||
@@ -71,7 +74,7 @@ export default class ExternalExtension extends Extension {
|
||||
fs.writeFileSync(extensionFilePath, code);
|
||||
await this.publishExtensions();
|
||||
logger.info(`Extension ${name} loaded`);
|
||||
return utils.getResponse(message, {}, null);
|
||||
return utils.getResponse(message, {});
|
||||
}
|
||||
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
@@ -82,8 +85,8 @@ export default class ExternalExtension extends Extension {
|
||||
const response = await this.requestLookup[match[1].toLowerCase()](message);
|
||||
await this.mqtt.publish(`bridge/response/extension/${match[1]}`, stringify(response));
|
||||
} catch (error) {
|
||||
logger.error(`Request '${data.topic}' failed with error: '${error.message}'`);
|
||||
const response = utils.getResponse(message, {}, error.message);
|
||||
logger.error(`Request '${data.topic}' failed with error: '${(error as Error).message}'`);
|
||||
const response = utils.getResponse(message, {}, `${(error as Error).message}`);
|
||||
await this.mqtt.publish(`bridge/response/extension/${match[1]}`, stringify(response));
|
||||
}
|
||||
}
|
||||
@@ -91,7 +94,7 @@ export default class ExternalExtension extends Extension {
|
||||
|
||||
@bind private async loadExtension(ConstructorClass: typeof Extension): Promise<void> {
|
||||
await this.enableDisableExtension(false, ConstructorClass.name);
|
||||
// @ts-ignore
|
||||
// @ts-expect-error `ConstructorClass` is the interface, not the actual passed class
|
||||
await this.addExtension(new ConstructorClass(this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus, settings, logger));
|
||||
}
|
||||
|
||||
|
||||
+93
-47
@@ -1,13 +1,19 @@
|
||||
import type {IncomingMessage, Server, ServerResponse} from 'http';
|
||||
import type {Socket} from 'net';
|
||||
|
||||
import assert from 'assert';
|
||||
import {existsSync, readFileSync} from 'fs';
|
||||
import {createServer} from 'http';
|
||||
import {createServer as createSecureServer} from 'https';
|
||||
import {posix} from 'path';
|
||||
import {parse} from 'url';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import gzipStatic, {RequestHandler} from 'connect-gzip-static';
|
||||
import expressStaticGzip, {RequestHandler} from 'express-static-gzip';
|
||||
import finalhandler from 'finalhandler';
|
||||
import fs from 'fs';
|
||||
import http from 'http';
|
||||
import https from 'https';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import net from 'net';
|
||||
import url from 'url';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
import frontend from 'zigbee2mqtt-frontend';
|
||||
|
||||
import logger from '../util/logger';
|
||||
@@ -19,20 +25,43 @@ import Extension from './extension';
|
||||
* This extension servers the frontend
|
||||
*/
|
||||
export default class Frontend extends Extension {
|
||||
private mqttBaseTopic = settings.get().mqtt.base_topic;
|
||||
private host = settings.get().frontend.host;
|
||||
private port = settings.get().frontend.port;
|
||||
private sslCert = settings.get().frontend.ssl_cert;
|
||||
private sslKey = settings.get().frontend.ssl_key;
|
||||
private authToken = settings.get().frontend.auth_token;
|
||||
private server: http.Server;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private fileServer: RequestHandler;
|
||||
private wss: WebSocket.Server = null;
|
||||
private mqttBaseTopic: string;
|
||||
private host: string | undefined;
|
||||
private port: number;
|
||||
private sslCert: string | undefined;
|
||||
private sslKey: string | undefined;
|
||||
private authToken: string | undefined;
|
||||
private server!: Server;
|
||||
private fileServer!: RequestHandler;
|
||||
private wss!: WebSocket.Server;
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(
|
||||
zigbee: Zigbee,
|
||||
mqtt: MQTT,
|
||||
state: State,
|
||||
publishEntityState: PublishEntityState,
|
||||
eventBus: EventBus,
|
||||
enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
|
||||
restartCallback: () => Promise<void>,
|
||||
addExtension: (extension: Extension) => Promise<void>,
|
||||
) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension);
|
||||
|
||||
const frontendSettings = settings.get().frontend;
|
||||
assert(frontendSettings, 'Frontend extension created without having frontend settings');
|
||||
this.host = frontendSettings.host;
|
||||
this.port = frontendSettings.port;
|
||||
this.sslCert = frontendSettings.ssl_cert;
|
||||
this.sslKey = frontendSettings.ssl_key;
|
||||
this.authToken = frontendSettings.auth_token;
|
||||
this.baseUrl = frontendSettings.base_url;
|
||||
this.mqttBaseTopic = settings.get().mqtt.base_topic;
|
||||
}
|
||||
|
||||
private isHttpsConfigured(): boolean {
|
||||
if (this.sslCert && this.sslKey) {
|
||||
if (!fs.existsSync(this.sslCert) || !fs.existsSync(this.sslKey)) {
|
||||
if (!existsSync(this.sslCert) || !existsSync(this.sslKey)) {
|
||||
logger.error(`defined ssl_cert '${this.sslCert}' or ssl_key '${this.sslKey}' file path does not exists, server won't be secured.`);
|
||||
return false;
|
||||
}
|
||||
@@ -42,31 +71,36 @@ export default class Frontend extends Extension {
|
||||
}
|
||||
|
||||
override async start(): Promise<void> {
|
||||
/* istanbul ignore next */
|
||||
const options = {
|
||||
enableBrotli: true,
|
||||
// TODO: https://github.com/Koenkk/zigbee2mqtt/issues/24654 - enable compressed index serving when express-static-gzip is fixed.
|
||||
index: false,
|
||||
serveStatic: {
|
||||
index: 'index.html',
|
||||
setHeaders: (res: ServerResponse, path: string): void => {
|
||||
if (path.endsWith('index.html')) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
this.fileServer = expressStaticGzip(frontend.getPath(), options);
|
||||
this.wss = new WebSocket.Server({noServer: true, path: posix.join(this.baseUrl, 'api')});
|
||||
|
||||
this.wss.on('connection', this.onWebSocketConnection);
|
||||
|
||||
if (this.isHttpsConfigured()) {
|
||||
const serverOptions = {
|
||||
key: fs.readFileSync(this.sslKey),
|
||||
cert: fs.readFileSync(this.sslCert),
|
||||
key: readFileSync(this.sslKey!), // valid from `isHttpsConfigured`
|
||||
cert: readFileSync(this.sslCert!), // valid from `isHttpsConfigured`
|
||||
};
|
||||
this.server = https.createServer(serverOptions, this.onRequest);
|
||||
this.server = createSecureServer(serverOptions, this.onRequest);
|
||||
} else {
|
||||
this.server = http.createServer(this.onRequest);
|
||||
this.server = createServer(this.onRequest);
|
||||
}
|
||||
|
||||
this.server.on('upgrade', this.onUpgrade);
|
||||
|
||||
/* istanbul ignore next */
|
||||
const options = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setHeaders: (res: any, path: string): void => {
|
||||
if (path.endsWith('index.html')) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
}
|
||||
},
|
||||
};
|
||||
this.fileServer = gzipStatic(frontend.getPath(), options);
|
||||
this.wss = new WebSocket.Server({noServer: true});
|
||||
this.wss.on('connection', this.onWebSocketConnection);
|
||||
|
||||
this.eventBus.onMQTTMessagePublished(this, this.onMQTTPublishMessage);
|
||||
|
||||
if (!this.host) {
|
||||
@@ -88,23 +122,34 @@ export default class Frontend extends Extension {
|
||||
client.terminate();
|
||||
});
|
||||
this.wss?.close();
|
||||
/* istanbul ignore else */
|
||||
if (this.server) {
|
||||
return new Promise((cb: () => void) => this.server.close(cb));
|
||||
|
||||
await new Promise((resolve) => this.server.close(resolve));
|
||||
}
|
||||
|
||||
@bind private onRequest(request: IncomingMessage, response: ServerResponse): void {
|
||||
const fin = finalhandler(request, response);
|
||||
const newUrl = posix.relative(this.baseUrl, request.url!);
|
||||
|
||||
// The request url is not within the frontend base url, so the relative path starts with '..'
|
||||
if (newUrl.startsWith('.')) {
|
||||
return fin();
|
||||
}
|
||||
|
||||
// Attach originalUrl so that static-server can perform a redirect to '/' when serving the root directory.
|
||||
// This is necessary for the browser to resolve relative assets paths correctly.
|
||||
request.originalUrl = request.url;
|
||||
request.url = '/' + newUrl;
|
||||
request.path = request.url;
|
||||
|
||||
this.fileServer(request, response, fin);
|
||||
}
|
||||
|
||||
@bind private onRequest(request: http.IncomingMessage, response: http.ServerResponse): void {
|
||||
// @ts-ignore
|
||||
this.fileServer(request, response, finalhandler(request, response));
|
||||
}
|
||||
|
||||
private authenticate(request: http.IncomingMessage, cb: (authenticate: boolean) => void): void {
|
||||
const {query} = url.parse(request.url, true);
|
||||
private authenticate(request: IncomingMessage, cb: (authenticate: boolean) => void): void {
|
||||
const {query} = parse(request.url!, true);
|
||||
cb(!this.authToken || this.authToken === query.token);
|
||||
}
|
||||
|
||||
@bind private onUpgrade(request: http.IncomingMessage, socket: net.Socket, head: Buffer): void {
|
||||
@bind private onUpgrade(request: IncomingMessage, socket: Socket, head: Buffer): void {
|
||||
this.wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
this.authenticate(request, (isAuthenticated) => {
|
||||
if (isAuthenticated) {
|
||||
@@ -142,9 +187,10 @@ export default class Frontend extends Extension {
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
const payload = this.state.get(device);
|
||||
const lastSeen = settings.get().advanced.last_seen;
|
||||
|
||||
/* istanbul ignore if */
|
||||
if (lastSeen !== 'disable') {
|
||||
payload.last_seen = utils.formatDate(device.zh.lastSeen, lastSeen);
|
||||
payload.last_seen = utils.formatDate(device.zh.lastSeen ?? 0, lastSeen);
|
||||
}
|
||||
|
||||
if (device.zh.linkquality !== undefined) {
|
||||
|
||||
+59
-40
@@ -1,13 +1,16 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import equals from 'fast-deep-equal/es6';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
|
||||
import Device from '../model/device';
|
||||
import Group from '../model/group';
|
||||
import logger from '../util/logger';
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import utils, {isLightExpose} from '../util/utils';
|
||||
import Extension from './extension';
|
||||
|
||||
const TOPIC_REGEX = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/group/members/(remove|add|remove_all)$`);
|
||||
@@ -16,27 +19,27 @@ const LEGACY_TOPIC_REGEX_REMOVE_ALL = new RegExp(`^${settings.get().mqtt.base_to
|
||||
|
||||
const STATE_PROPERTIES: Readonly<Record<string, (value: string, exposes: zhc.Expose[]) => boolean>> = {
|
||||
state: () => true,
|
||||
brightness: (value, exposes) => exposes.some((e) => e.type === 'light' && e.features.some((f) => f.name === 'brightness')),
|
||||
color_temp: (value, exposes) => exposes.some((e) => e.type === 'light' && e.features.some((f) => f.name === 'color_temp')),
|
||||
color: (value, exposes) => exposes.some((e) => e.type === 'light' && e.features.some((f) => f.name === 'color_xy' || f.name === 'color_hs')),
|
||||
brightness: (value, exposes) => exposes.some((e) => isLightExpose(e) && e.features.some((f) => f.name === 'brightness')),
|
||||
color_temp: (value, exposes) => exposes.some((e) => isLightExpose(e) && e.features.some((f) => f.name === 'color_temp')),
|
||||
color: (value, exposes) => exposes.some((e) => isLightExpose(e) && e.features.some((f) => f.name === 'color_xy' || f.name === 'color_hs')),
|
||||
color_mode: (value, exposes) =>
|
||||
exposes.some(
|
||||
(e) =>
|
||||
e.type === 'light' &&
|
||||
isLightExpose(e) &&
|
||||
(e.features.some((f) => f.name === `color_${value}`) || (value === 'color_temp' && e.features.some((f) => f.name === 'color_temp'))),
|
||||
),
|
||||
};
|
||||
|
||||
interface ParsedMQTTMessage {
|
||||
type: 'remove' | 'add' | 'remove_all';
|
||||
resolvedEntityGroup: Group;
|
||||
resolvedEntityGroup?: Group;
|
||||
resolvedEntityDevice: Device;
|
||||
error: string;
|
||||
groupKey: string;
|
||||
deviceKey: string;
|
||||
error?: string;
|
||||
groupKey?: string;
|
||||
deviceKey?: string;
|
||||
triggeredViaLegacyApi: boolean;
|
||||
skipDisableReporting: boolean;
|
||||
resolvedEntityEndpoint: zh.Endpoint;
|
||||
resolvedEntityEndpoint?: zh.Endpoint;
|
||||
}
|
||||
|
||||
export default class Groups extends Extension {
|
||||
@@ -69,7 +72,7 @@ export default class Groups extends Extension {
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ${action} '${deviceName}' from '${groupName}'`);
|
||||
logger.debug(error.stack);
|
||||
logger.debug((error as Error).stack!);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -105,7 +108,7 @@ export default class Groups extends Extension {
|
||||
// In zigbee but not in settings
|
||||
for (const endpoint of zigbeeGroup.zh.members) {
|
||||
if (!settingsEndpoints.includes(endpoint)) {
|
||||
const deviceName = settings.getDevice(endpoint.getDevice().ieeeAddr).friendly_name;
|
||||
const deviceName = settings.getDevice(endpoint.getDevice().ieeeAddr)!.friendly_name;
|
||||
|
||||
await addRemoveFromGroup('remove', deviceName, settingGroup.friendly_name, endpoint, zigbeeGroup);
|
||||
}
|
||||
@@ -114,7 +117,7 @@ export default class Groups extends Extension {
|
||||
|
||||
for (const zigbeeGroup of this.zigbee.groupsIterator((zg) => !settingsGroups.some((sg) => sg.ID === zg.groupID))) {
|
||||
for (const endpoint of zigbeeGroup.zh.members) {
|
||||
const deviceName = settings.getDevice(endpoint.getDevice().ieeeAddr).friendly_name;
|
||||
const deviceName = settings.getDevice(endpoint.getDevice().ieeeAddr)!.friendly_name;
|
||||
|
||||
await addRemoveFromGroup('remove', deviceName, zigbeeGroup.ID, endpoint, zigbeeGroup);
|
||||
}
|
||||
@@ -129,7 +132,7 @@ export default class Groups extends Extension {
|
||||
}
|
||||
|
||||
const payload: KeyValue = {};
|
||||
let endpointName: string = null;
|
||||
let endpointName: string | undefined;
|
||||
const endpointNames: string[] = data.entity instanceof Device ? data.entity.getEndpointNames() : [];
|
||||
|
||||
for (let prop of Object.keys(data.update)) {
|
||||
@@ -159,15 +162,20 @@ export default class Groups extends Extension {
|
||||
}
|
||||
|
||||
if (entity instanceof Device) {
|
||||
for (const group of groups) {
|
||||
if (
|
||||
group.zh.hasMember(entity.endpoint(endpointName)) &&
|
||||
!equals(this.lastOptimisticState[group.ID], payload) &&
|
||||
this.shouldPublishPayloadForGroup(group, payload)
|
||||
) {
|
||||
this.lastOptimisticState[group.ID] = payload;
|
||||
const endpoint = entity.endpoint(endpointName);
|
||||
|
||||
await this.publishEntityState(group, payload, reason);
|
||||
/* istanbul ignore else */
|
||||
if (endpoint) {
|
||||
for (const group of groups) {
|
||||
if (
|
||||
group.zh.hasMember(endpoint) &&
|
||||
!equals(this.lastOptimisticState[group.ID], payload) &&
|
||||
this.shouldPublishPayloadForGroup(group, payload)
|
||||
) {
|
||||
this.lastOptimisticState[group.ID] = payload;
|
||||
|
||||
await this.publishEntityState(group, payload, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -220,17 +228,22 @@ export default class Groups extends Extension {
|
||||
}
|
||||
|
||||
private shouldPublishPayloadForGroup(group: Group, payload: KeyValue): boolean {
|
||||
return group.options.off_state === 'last_member_state' || !payload || payload.state !== 'OFF' || this.areAllMembersOff(group);
|
||||
return (
|
||||
group.options.off_state === 'last_member_state' ||
|
||||
!payload ||
|
||||
(payload.state !== 'OFF' && payload.state !== 'CLOSE') ||
|
||||
this.areAllMembersOffOrClosed(group)
|
||||
);
|
||||
}
|
||||
|
||||
private areAllMembersOff(group: Group): boolean {
|
||||
private areAllMembersOffOrClosed(group: Group): boolean {
|
||||
for (const member of group.zh.members) {
|
||||
const device = this.zigbee.resolveEntity(member.getDevice());
|
||||
const device = this.zigbee.resolveEntity(member.getDevice())!;
|
||||
|
||||
if (this.state.exists(device)) {
|
||||
const state = this.state.get(device);
|
||||
|
||||
if (state.state === 'ON') {
|
||||
if (state.state === 'ON' || state.state === 'OPEN') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -239,14 +252,14 @@ export default class Groups extends Extension {
|
||||
return true;
|
||||
}
|
||||
|
||||
private async parseMQTTMessage(data: eventdata.MQTTMessage): Promise<ParsedMQTTMessage> {
|
||||
let type: ParsedMQTTMessage['type'] = null;
|
||||
let resolvedEntityGroup: ParsedMQTTMessage['resolvedEntityGroup'] = null;
|
||||
let resolvedEntityDevice: ParsedMQTTMessage['resolvedEntityDevice'] = null;
|
||||
let resolvedEntityEndpoint: ParsedMQTTMessage['resolvedEntityEndpoint'] = null;
|
||||
let error: ParsedMQTTMessage['error'] = null;
|
||||
let groupKey: ParsedMQTTMessage['groupKey'] = null;
|
||||
let deviceKey: ParsedMQTTMessage['deviceKey'] = null;
|
||||
private async parseMQTTMessage(data: eventdata.MQTTMessage): Promise<ParsedMQTTMessage | undefined> {
|
||||
let type: ParsedMQTTMessage['type'] | undefined;
|
||||
let resolvedEntityGroup: ParsedMQTTMessage['resolvedEntityGroup'] | undefined;
|
||||
let resolvedEntityDevice: ParsedMQTTMessage['resolvedEntityDevice'] | undefined;
|
||||
let resolvedEntityEndpoint: ParsedMQTTMessage['resolvedEntityEndpoint'] | undefined;
|
||||
let error: ParsedMQTTMessage['error'] | undefined;
|
||||
let groupKey: ParsedMQTTMessage['groupKey'] | undefined;
|
||||
let deviceKey: ParsedMQTTMessage['deviceKey'] | undefined;
|
||||
let triggeredViaLegacyApi: ParsedMQTTMessage['triggeredViaLegacyApi'] = false;
|
||||
let skipDisableReporting: ParsedMQTTMessage['skipDisableReporting'] = false;
|
||||
|
||||
@@ -272,7 +285,7 @@ export default class Groups extends Extension {
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `device_group_${type}_failed`, message}));
|
||||
}
|
||||
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
type = 'remove_all';
|
||||
@@ -286,19 +299,19 @@ export default class Groups extends Extension {
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
const message = {friendly_name: data.message, group: legacyTopicRegexMatch[1], error: "entity doesn't exists"};
|
||||
const message = {friendly_name: data.message, group: legacyTopicRegexMatch![1], error: "entity doesn't exists"};
|
||||
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `device_group_${type}_failed`, message}));
|
||||
}
|
||||
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
resolvedEntityEndpoint = parsedEntity.endpoint;
|
||||
|
||||
if (parsedEntity.endpointID && !resolvedEntityEndpoint) {
|
||||
logger.error(`Device '${parsedEntity.ID}' does not have endpoint '${parsedEntity.endpointID}'`);
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
} else if (topicRegexMatch) {
|
||||
type = topicRegexMatch[1] as 'remove' | 'add' | 'remove_all';
|
||||
@@ -329,6 +342,8 @@ export default class Groups extends Extension {
|
||||
error = `Device '${parsed.ID}' does not have endpoint '${parsed.endpointID}'`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -365,6 +380,7 @@ export default class Groups extends Extension {
|
||||
const changedGroups: Group[] = [];
|
||||
|
||||
if (!error) {
|
||||
assert(resolvedEntityEndpoint, '`resolvedEntityEndpoint` is missing');
|
||||
try {
|
||||
const keys = [
|
||||
`${resolvedEntityDevice.ieeeAddr}/${resolvedEntityEndpoint.ID}`,
|
||||
@@ -383,6 +399,7 @@ export default class Groups extends Extension {
|
||||
}
|
||||
|
||||
if (type === 'add') {
|
||||
assert(resolvedEntityGroup, '`resolvedEntityGroup` is missing');
|
||||
logger.info(`Adding '${resolvedEntityDevice.name}' to '${resolvedEntityGroup.name}'`);
|
||||
await resolvedEntityEndpoint.addToGroup(resolvedEntityGroup.zh);
|
||||
settings.addDeviceToGroup(resolvedEntityGroup.ID.toString(), keys);
|
||||
@@ -395,6 +412,7 @@ export default class Groups extends Extension {
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `device_group_add`, message}));
|
||||
}
|
||||
} else if (type === 'remove') {
|
||||
assert(resolvedEntityGroup, '`resolvedEntityGroup` is missing');
|
||||
logger.info(`Removing '${resolvedEntityDevice.name}' from '${resolvedEntityGroup.name}'`);
|
||||
await resolvedEntityEndpoint.removeFromGroup(resolvedEntityGroup.zh);
|
||||
settings.removeDeviceFromGroup(resolvedEntityGroup.ID.toString(), keys);
|
||||
@@ -428,8 +446,8 @@ export default class Groups extends Extension {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error = `Failed to ${type} from group (${e.message})`;
|
||||
logger.debug(e.stack);
|
||||
error = `Failed to ${type} from group (${(e as Error).message})`;
|
||||
logger.debug((e as Error).stack!);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,6 +465,7 @@ export default class Groups extends Extension {
|
||||
if (error) {
|
||||
logger.error(error);
|
||||
} else {
|
||||
assert(resolvedEntityEndpoint, '`resolvedEntityEndpoint` is missing');
|
||||
for (const group of changedGroups) {
|
||||
this.eventBus.emitGroupMembersChanged({group, action: type, endpoint: resolvedEntityEndpoint, skipDisableReporting});
|
||||
}
|
||||
|
||||
+1121
-928
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
@@ -10,7 +11,8 @@ import Extension from '../extension';
|
||||
const configRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/config/((?:\\w+/get)|(?:\\w+/factory_reset)|(?:\\w+))`);
|
||||
|
||||
export default class BridgeLegacy extends Extension {
|
||||
private lastJoinedDeviceName: string = null;
|
||||
private lastJoinedDeviceName?: string;
|
||||
// @ts-expect-error initialized in `start`
|
||||
private supportedOptions: {[s: string]: (topic: string, message: string) => Promise<void> | void};
|
||||
|
||||
override async start(): Promise<void> {
|
||||
@@ -39,7 +41,7 @@ export default class BridgeLegacy extends Extension {
|
||||
this.eventBus.onDeviceJoined(this, (data) => this.onZigbeeEvent_('deviceJoined', data, data.device));
|
||||
this.eventBus.onDeviceInterview(this, (data) => this.onZigbeeEvent_('deviceInterview', data, data.device));
|
||||
this.eventBus.onDeviceAnnounce(this, (data) => this.onZigbeeEvent_('deviceAnnounce', data, data.device));
|
||||
this.eventBus.onDeviceLeave(this, (data) => this.onZigbeeEvent_('deviceLeave', data, null));
|
||||
this.eventBus.onDeviceLeave(this, (data) => this.onZigbeeEvent_('deviceLeave', data, undefined));
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
|
||||
await this.publish();
|
||||
@@ -66,7 +68,7 @@ export default class BridgeLegacy extends Extension {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!json.hasOwnProperty('friendly_name') || !json.hasOwnProperty('options')) {
|
||||
if (json.friendly_name === undefined || json.options === undefined) {
|
||||
logger.error('Invalid JSON message, should contain "friendly_name" and "options"');
|
||||
return;
|
||||
}
|
||||
@@ -206,11 +208,11 @@ export default class BridgeLegacy extends Extension {
|
||||
|
||||
async _renameInternal(from: string, to: string): Promise<void> {
|
||||
try {
|
||||
const isGroup = settings.getGroup(from) !== null;
|
||||
const isGroup = settings.getGroup(from) != undefined;
|
||||
settings.changeFriendlyName(from, to);
|
||||
logger.info(`Successfully renamed - ${from} to ${to} `);
|
||||
const entity = this.zigbee.resolveEntity(to);
|
||||
if (entity.isDevice()) {
|
||||
if (entity?.isDevice()) {
|
||||
this.eventBus.emitEntityRenamed({homeAssisantRename: false, from, to, entity});
|
||||
}
|
||||
|
||||
@@ -226,11 +228,11 @@ export default class BridgeLegacy extends Extension {
|
||||
try {
|
||||
// json payload with id and friendly_name
|
||||
const json = JSON.parse(message);
|
||||
if (json.hasOwnProperty('id')) {
|
||||
if (json.id !== undefined) {
|
||||
id = json.id;
|
||||
name = `group_${id}`;
|
||||
}
|
||||
if (json.hasOwnProperty('friendly_name')) {
|
||||
if (json.friendly_name !== undefined) {
|
||||
name = json.friendly_name;
|
||||
}
|
||||
} catch {
|
||||
@@ -320,7 +322,7 @@ export default class BridgeLegacy extends Extension {
|
||||
await cleanup();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ${lookup[action][2]} ${entity.name} (${error})`);
|
||||
// eslint-disable-next-line
|
||||
|
||||
logger.error(`See https://www.zigbee2mqtt.io/guide/usage/mqtt_topics_and_messages.html#zigbee2mqtt-bridge-request for more info`);
|
||||
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `device_${lookup[action][0]}_failed`, message}));
|
||||
@@ -333,13 +335,15 @@ export default class BridgeLegacy extends Extension {
|
||||
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const {topic, message} = data;
|
||||
if (!topic.match(configRegex)) {
|
||||
const match = topic.match(configRegex);
|
||||
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
const option = topic.match(configRegex)[1];
|
||||
const option = match[1];
|
||||
|
||||
if (!this.supportedOptions.hasOwnProperty(option)) {
|
||||
if (this.supportedOptions[option] === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -364,36 +368,36 @@ export default class BridgeLegacy extends Extension {
|
||||
await this.mqtt.publish(topic, stringify(payload), {retain: true, qos: 0});
|
||||
}
|
||||
|
||||
async onZigbeeEvent_(type: string, data: KeyValue, resolvedEntity: Device): Promise<void> {
|
||||
if (type === 'deviceJoined' && resolvedEntity) {
|
||||
this.lastJoinedDeviceName = resolvedEntity.name;
|
||||
}
|
||||
|
||||
if (type === 'deviceJoined') {
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `device_connected`, message: {friendly_name: resolvedEntity.name}}));
|
||||
} else if (type === 'deviceInterview') {
|
||||
if (data.status === 'successful') {
|
||||
if (resolvedEntity.isSupported) {
|
||||
const {vendor, description, model} = resolvedEntity.definition;
|
||||
const log = {friendly_name: resolvedEntity.name, model, vendor, description, supported: true};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_successful', meta: log}));
|
||||
} else {
|
||||
const meta = {friendly_name: resolvedEntity.name, supported: false};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_successful', meta}));
|
||||
}
|
||||
} else if (data.status === 'failed') {
|
||||
const meta = {friendly_name: resolvedEntity.name};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_failed', meta}));
|
||||
} else {
|
||||
/* istanbul ignore else */
|
||||
if (data.status === 'started') {
|
||||
async onZigbeeEvent_(type: string, data: KeyValue, resolvedEntity: Device | undefined): Promise<void> {
|
||||
if (resolvedEntity) {
|
||||
/* istanbul ignore else */
|
||||
if (type === 'deviceJoined') {
|
||||
this.lastJoinedDeviceName = resolvedEntity.name;
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `device_connected`, message: {friendly_name: resolvedEntity.name}}));
|
||||
} else if (type === 'deviceInterview') {
|
||||
if (data.status === 'successful') {
|
||||
if (resolvedEntity.isSupported) {
|
||||
const {vendor, description, model} = resolvedEntity.definition!; // checked by `isSupported`
|
||||
const log = {friendly_name: resolvedEntity.name, model, vendor, description, supported: true};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_successful', meta: log}));
|
||||
} else {
|
||||
const meta = {friendly_name: resolvedEntity.name, supported: false};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_successful', meta}));
|
||||
}
|
||||
} else if (data.status === 'failed') {
|
||||
const meta = {friendly_name: resolvedEntity.name};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_started', meta}));
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_failed', meta}));
|
||||
} else {
|
||||
/* istanbul ignore else */
|
||||
if (data.status === 'started') {
|
||||
const meta = {friendly_name: resolvedEntity.name};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_started', meta}));
|
||||
}
|
||||
}
|
||||
} else if (type === 'deviceAnnounce') {
|
||||
const meta = {friendly_name: resolvedEntity.name};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `device_announced`, message: 'announce', meta}));
|
||||
}
|
||||
} else if (type === 'deviceAnnounce') {
|
||||
const meta = {friendly_name: resolvedEntity.name};
|
||||
await this.mqtt.publish('bridge/log', stringify({type: `device_announced`, message: 'announce', meta}));
|
||||
} else {
|
||||
/* istanbul ignore else */
|
||||
if (type === 'deviceLeave') {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
/* istanbul ignore file */
|
||||
|
||||
import assert from 'assert';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
|
||||
import Device from '../../model/device';
|
||||
@@ -15,23 +18,27 @@ export default class DeviceGroupMembership extends Extension {
|
||||
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const match = data.topic.match(topicRegex);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = this.zigbee.resolveEntityAndEndpoint(match[1]);
|
||||
const device = parsed?.entity as Device;
|
||||
|
||||
if (!device || !(device instanceof Device)) {
|
||||
logger.error(`Device '${match[1]}' does not exist`);
|
||||
return;
|
||||
}
|
||||
|
||||
const endpoint = parsed.endpoint;
|
||||
|
||||
if (parsed.endpointID && !endpoint) {
|
||||
logger.error(`Device '${parsed.ID}' does not have endpoint '${parsed.endpointID}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
assert(endpoint !== undefined);
|
||||
const response = await endpoint.command(`genGroups`, 'getMembership', {groupcount: 0, grouplist: []}, {});
|
||||
|
||||
if (!response) {
|
||||
|
||||
@@ -74,7 +74,7 @@ export default class Report extends Extension {
|
||||
private failed: Set<string> = new Set();
|
||||
private enabled = settings.get().advanced.report;
|
||||
|
||||
shouldIgnoreClusterForDevice(cluster: string, definition: zhc.Definition): boolean {
|
||||
shouldIgnoreClusterForDevice(cluster: string, definition?: zhc.Definition): boolean {
|
||||
if (definition === ZNLDP12LM && cluster === 'closuresWindowCovering') {
|
||||
// Device announces it but doesn't support it
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/2611
|
||||
@@ -99,7 +99,7 @@ export default class Report extends Extension {
|
||||
|
||||
const items = [];
|
||||
for (const entry of configuration) {
|
||||
if (!entry.hasOwnProperty('condition') || (await entry.condition(ep))) {
|
||||
if (entry.condition == undefined || (await entry.condition(ep))) {
|
||||
const toAdd = {...entry};
|
||||
if (!this.enabled) toAdd.maximumReportInterval = 0xffff;
|
||||
items.push(toAdd);
|
||||
@@ -128,7 +128,7 @@ export default class Report extends Extension {
|
||||
|
||||
this.eventBus.emitDevicesChanged();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ${term1.toLowerCase()} reporting for '${device.ieeeAddr}' - ${error.stack}`);
|
||||
logger.error(`Failed to ${term1.toLowerCase()} reporting for '${device.ieeeAddr}' - ${(error as Error).stack}`);
|
||||
|
||||
this.failed.add(device.ieeeAddr);
|
||||
}
|
||||
@@ -137,7 +137,7 @@ export default class Report extends Extension {
|
||||
this.queue.delete(device.ieeeAddr);
|
||||
}
|
||||
|
||||
shouldSetupReporting(device: Device, messageType: string): boolean {
|
||||
shouldSetupReporting(device: Device, messageType?: string): boolean {
|
||||
if (!device || !device.zh || !device.definition) return false;
|
||||
|
||||
// Handle messages of type endDeviceAnnce and devIncoming.
|
||||
@@ -157,21 +157,26 @@ export default class Report extends Extension {
|
||||
return true;
|
||||
}
|
||||
|
||||
// These do not support reproting.
|
||||
// These do not support reporting.
|
||||
// https://github.com/Koenkk/zigbee-herdsman/issues/110
|
||||
const philipsIgnoreSw = ['5.127.1.26581', '5.130.1.30000'];
|
||||
if (device.zh.manufacturerName === 'Philips' && philipsIgnoreSw.includes(device.zh.softwareBuildID)) return false;
|
||||
if (
|
||||
device.zh.manufacturerName === 'Philips' &&
|
||||
/* istanbul ignore next */
|
||||
(device.zh.softwareBuildID === '5.127.1.26581' || device.zh.softwareBuildID === '5.130.1.30000')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (device.zh.interviewing === true) return false;
|
||||
if (device.zh.type !== 'Router' || device.zh.powerSource === 'Battery') return false;
|
||||
// Gledopto devices don't support reporting.
|
||||
if (devicesNotSupportingReporting.includes(device.definition) || device.definition.vendor === 'Gledopto') return false;
|
||||
|
||||
if (this.enabled && device.zh.meta.hasOwnProperty('reporting') && device.zh.meta.reporting === reportKey) {
|
||||
if (this.enabled && device.zh.meta.reporting !== undefined && device.zh.meta.reporting === reportKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.enabled && !device.zh.meta.hasOwnProperty('reporting')) {
|
||||
if (!this.enabled && device.zh.meta.reporting === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -180,7 +185,7 @@ export default class Report extends Extension {
|
||||
|
||||
override async start(): Promise<void> {
|
||||
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
if (this.shouldSetupReporting(device, null)) {
|
||||
if (this.shouldSetupReporting(device, undefined)) {
|
||||
await this.setupReporting(device);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* istanbul ignore file */
|
||||
|
||||
import logger from '../../util/logger';
|
||||
// DEPRECATED
|
||||
import * as settings from '../../util/settings';
|
||||
@@ -9,7 +10,7 @@ import Extension from '../extension';
|
||||
* This extensions soft resets the ZNP after a certain timeout.
|
||||
*/
|
||||
export default class SoftReset extends Extension {
|
||||
private timer: NodeJS.Timeout = null;
|
||||
private timer?: NodeJS.Timeout;
|
||||
private timeout = utils.seconds(settings.get().advanced.soft_reset_timeout);
|
||||
|
||||
override async start(): Promise<void> {
|
||||
@@ -23,10 +24,8 @@ export default class SoftReset extends Extension {
|
||||
}
|
||||
|
||||
private clearTimer(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
|
||||
private resetTimer(): void {
|
||||
@@ -45,7 +44,7 @@ export default class SoftReset extends Extension {
|
||||
await this.zigbee.reset('soft');
|
||||
logger.warning('Soft reset ZNP due to timeout');
|
||||
} catch (error) {
|
||||
logger.warning(`Soft reset failed, trying stop/start (${error.message})`);
|
||||
logger.warning(`Soft reset failed, trying stop/start (${(error as Error).message})`);
|
||||
|
||||
await this.zigbee.stop();
|
||||
logger.warning('Zigbee stopped');
|
||||
@@ -53,7 +52,7 @@ export default class SoftReset extends Extension {
|
||||
try {
|
||||
await this.zigbee.start();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to restart! (${error.message})`);
|
||||
logger.error(`Failed to restart! (${(error as Error).message})`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-24
@@ -25,11 +25,11 @@ interface Topology {
|
||||
friendlyName: string;
|
||||
type: string;
|
||||
networkAddress: number;
|
||||
manufacturerName: string;
|
||||
modelID: string;
|
||||
manufacturerName: string | undefined;
|
||||
modelID: string | undefined;
|
||||
failed: string[];
|
||||
lastSeen: number;
|
||||
definition: {model: string; vendor: string; supports: string; description: string};
|
||||
lastSeen: number | undefined;
|
||||
definition?: {model: string; vendor: string; supports: string; description: string};
|
||||
}[];
|
||||
links: Link[];
|
||||
}
|
||||
@@ -42,21 +42,20 @@ export default class NetworkMap extends Extension {
|
||||
private legacyTopic = `${settings.get().mqtt.base_topic}/bridge/networkmap`;
|
||||
private legacyTopicRoutes = `${settings.get().mqtt.base_topic}/bridge/networkmap/routes`;
|
||||
private topic = `${settings.get().mqtt.base_topic}/bridge/request/networkmap`;
|
||||
private supportedFormats: {[s: string]: (topology: Topology) => KeyValue | string};
|
||||
private supportedFormats: {[s: string]: (topology: Topology) => KeyValue | string} = {
|
||||
raw: this.raw,
|
||||
graphviz: this.graphviz,
|
||||
plantuml: this.plantuml,
|
||||
};
|
||||
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
this.supportedFormats = {
|
||||
raw: this.raw,
|
||||
graphviz: this.graphviz,
|
||||
plantuml: this.plantuml,
|
||||
};
|
||||
}
|
||||
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
/* istanbul ignore else */
|
||||
if (this.legacyApi) {
|
||||
if ((data.topic === this.legacyTopic || data.topic === this.legacyTopicRoutes) && this.supportedFormats.hasOwnProperty(data.message)) {
|
||||
if ((data.topic === this.legacyTopic || data.topic === this.legacyTopicRoutes) && this.supportedFormats[data.message] !== undefined) {
|
||||
const includeRoutes = data.topic === this.legacyTopicRoutes;
|
||||
const topology = await this.networkScan(includeRoutes);
|
||||
let converted = this.supportedFormats[data.message](topology);
|
||||
@@ -69,16 +68,16 @@ export default class NetworkMap extends Extension {
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
try {
|
||||
const type = typeof message === 'object' ? message.type : message;
|
||||
if (!this.supportedFormats.hasOwnProperty(type)) {
|
||||
if (this.supportedFormats[type] === undefined) {
|
||||
throw new Error(`Type '${type}' not supported, allowed are: ${Object.keys(this.supportedFormats)}`);
|
||||
}
|
||||
|
||||
const routes = typeof message === 'object' && message.routes;
|
||||
const topology = await this.networkScan(routes);
|
||||
const value = this.supportedFormats[type](topology);
|
||||
await this.mqtt.publish('bridge/response/networkmap', stringify(utils.getResponse(message, {routes, type, value}, null)));
|
||||
await this.mqtt.publish('bridge/response/networkmap', stringify(utils.getResponse(message, {routes, type, value})));
|
||||
} catch (error) {
|
||||
await this.mqtt.publish('bridge/response/networkmap', stringify(utils.getResponse(message, {}, error.message)));
|
||||
await this.mqtt.publish('bridge/response/networkmap', stringify(utils.getResponse(message, {}, (error as Error).message)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,7 +220,7 @@ export default class NetworkMap extends Extension {
|
||||
} catch {
|
||||
// Network is possibly congested, sleep 5 seconds to let the network settle.
|
||||
await utils.sleep(5);
|
||||
return request();
|
||||
return await request();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -234,24 +233,24 @@ export default class NetworkMap extends Extension {
|
||||
await utils.sleep(1); // sleep 1 second between each scan to reduce stress on network.
|
||||
|
||||
try {
|
||||
const result = await requestWithRetry<zh.LQI>(async () => device.zh.lqi());
|
||||
const result = await requestWithRetry<zh.LQI>(async () => await device.zh.lqi());
|
||||
lqis.set(device, result);
|
||||
logger.debug(`LQI succeeded for '${device.name}'`);
|
||||
} catch (error) {
|
||||
failed.get(device).push('lqi');
|
||||
failed.get(device)!.push('lqi'); // set above
|
||||
logger.error(`Failed to execute LQI for '${device.name}'`);
|
||||
logger.debug(error.stack);
|
||||
logger.debug((error as Error).stack!);
|
||||
}
|
||||
|
||||
if (includeRoutes) {
|
||||
try {
|
||||
const result = await requestWithRetry<zh.RoutingTable>(async () => device.zh.routingTable());
|
||||
const result = await requestWithRetry<zh.RoutingTable>(async () => await device.zh.routingTable());
|
||||
routingTables.set(device, result);
|
||||
logger.debug(`Routing table succeeded for '${device.name}'`);
|
||||
} catch (error) {
|
||||
failed.get(device).push('routingTable');
|
||||
failed.get(device)!.push('routingTable'); // set above
|
||||
logger.error(`Failed to execute routing table for '${device.name}'`);
|
||||
logger.debug(error.stack);
|
||||
logger.debug((error as Error).stack!);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -275,12 +274,12 @@ export default class NetworkMap extends Extension {
|
||||
supports: Array.from(
|
||||
new Set(
|
||||
device.exposes().map((e) => {
|
||||
return e.name ?? `${e.type} (${e.features.map((f) => f.name).join(', ')})`;
|
||||
return e.name ?? `${e.type} (${e.features?.map((f) => f.name).join(', ')})`;
|
||||
}),
|
||||
),
|
||||
).join(', '),
|
||||
}
|
||||
: null;
|
||||
: undefined;
|
||||
|
||||
topology.nodes.push({
|
||||
ieeeAddr: device.ieeeAddr,
|
||||
@@ -289,7 +288,7 @@ export default class NetworkMap extends Extension {
|
||||
networkAddress: device.zh.networkAddress,
|
||||
manufacturerName: device.zh.manufacturerName,
|
||||
modelID: device.zh.modelID,
|
||||
failed: failed.get(device),
|
||||
failed: failed.get(device)!,
|
||||
lastSeen: device.zh.lastSeen,
|
||||
definition,
|
||||
});
|
||||
|
||||
@@ -41,11 +41,13 @@ export default class OnEvent extends Extension {
|
||||
private async callOnEvent(device: Device, type: zhc.OnEventType, data: KeyValue): Promise<void> {
|
||||
if (device.options.disabled) return;
|
||||
const state = this.state.get(device);
|
||||
await zhc.onEvent(type, data, device.zh);
|
||||
const deviceExposesChanged = (): void => this.eventBus.emitExposesAndDevicesChanged(data.device);
|
||||
|
||||
await zhc.onEvent(type, data, device.zh, {deviceExposesChanged});
|
||||
|
||||
if (device.definition?.onEvent) {
|
||||
const options: KeyValue = device.options;
|
||||
await device.definition.onEvent(type, data, device.zh, options, state);
|
||||
await device.definition.onEvent(type, data, device.zh, options, state, {deviceExposesChanged});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+45
-30
@@ -1,7 +1,10 @@
|
||||
import assert from 'assert';
|
||||
import path from 'path';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import path from 'path';
|
||||
import * as URI from 'uri-js';
|
||||
|
||||
import {Zcl} from 'zigbee-herdsman';
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
|
||||
@@ -26,7 +29,7 @@ function isValidUrl(url: string): boolean {
|
||||
type UpdateState = 'updating' | 'idle' | 'available';
|
||||
interface UpdatePayload {
|
||||
update_available?: boolean;
|
||||
// eslint-disable-next-line camelcase
|
||||
|
||||
update: {
|
||||
progress?: number;
|
||||
remaining?: number;
|
||||
@@ -86,25 +89,25 @@ export default class OTAUpdate extends Extension {
|
||||
logger.debug(`Device '${data.device.name}' requested OTA`);
|
||||
|
||||
const automaticOTACheckDisabled = settings.get().ota.disable_automatic_update_check;
|
||||
let supportsOTA = !!data.device.definition.ota;
|
||||
if (supportsOTA && !automaticOTACheckDisabled) {
|
||||
|
||||
if (data.device.definition.ota && !automaticOTACheckDisabled) {
|
||||
// When a device does a next image request, it will usually do it a few times after each other
|
||||
// with only 10 - 60 seconds inbetween. It doesn't make sense to check for a new update
|
||||
// each time, so this interval can be set by the user. The default is 1,440 minutes (one day).
|
||||
const updateCheckInterval = settings.get().ota.update_check_interval * 1000 * 60;
|
||||
const check = this.lastChecked.hasOwnProperty(data.device.ieeeAddr)
|
||||
? Date.now() - this.lastChecked[data.device.ieeeAddr] > updateCheckInterval
|
||||
: true;
|
||||
const check =
|
||||
this.lastChecked[data.device.ieeeAddr] !== undefined
|
||||
? Date.now() - this.lastChecked[data.device.ieeeAddr] > updateCheckInterval
|
||||
: true;
|
||||
if (!check) return;
|
||||
|
||||
this.lastChecked[data.device.ieeeAddr] = Date.now();
|
||||
let availableResult: zhc.OtaUpdateAvailableResult = null;
|
||||
let availableResult: zhc.OtaUpdateAvailableResult | undefined;
|
||||
|
||||
try {
|
||||
availableResult = await data.device.definition.ota.isUpdateAvailable(data.device.zh, data.data as zhc.ota.ImageInfo);
|
||||
} catch (e) {
|
||||
supportsOTA = false;
|
||||
logger.debug(`Failed to check if update available for '${data.device.name}' (${e.message})`);
|
||||
logger.debug(e.stack);
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to check if update available for '${data.device.name}' (${error})`);
|
||||
}
|
||||
|
||||
const payload = this.getEntityPublishPayload(data.device, availableResult ?? 'idle');
|
||||
@@ -134,21 +137,25 @@ export default class OTAUpdate extends Extension {
|
||||
logger.debug(`Responded to OTA request of '${data.device.name}' with 'NO_IMAGE_AVAILABLE'`);
|
||||
}
|
||||
|
||||
private async readSoftwareBuildIDAndDateCode(device: Device, sendPolicy?: 'immediate'): Promise<{softwareBuildID: string; dateCode: string}> {
|
||||
private async readSoftwareBuildIDAndDateCode(
|
||||
device: Device,
|
||||
sendPolicy?: 'immediate',
|
||||
): Promise<{softwareBuildID: string; dateCode: string} | undefined> {
|
||||
try {
|
||||
const endpoint = device.zh.endpoints.find((e) => e.supportsInputCluster('genBasic'));
|
||||
assert(endpoint);
|
||||
const result = await endpoint.read('genBasic', ['dateCode', 'swBuildId'], {sendPolicy});
|
||||
return {softwareBuildID: result.swBuildId, dateCode: result.dateCode};
|
||||
} catch {
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private getEntityPublishPayload(
|
||||
device: Device,
|
||||
state: zhc.OtaUpdateAvailableResult | UpdateState,
|
||||
progress: number = null,
|
||||
remaining: number = null,
|
||||
progress?: number,
|
||||
remaining?: number,
|
||||
): UpdatePayload {
|
||||
const deviceUpdateState = this.state.get(device).update;
|
||||
const payload: UpdatePayload = {
|
||||
@@ -158,8 +165,14 @@ export default class OTAUpdate extends Extension {
|
||||
latest_version: typeof state === 'string' ? deviceUpdateState?.latest_version : state.otaFileVersion,
|
||||
},
|
||||
};
|
||||
if (progress !== null) payload.update.progress = progress;
|
||||
if (remaining !== null) payload.update.remaining = Math.round(remaining);
|
||||
|
||||
if (progress != undefined) {
|
||||
payload.update.progress = progress;
|
||||
}
|
||||
|
||||
if (remaining != undefined) {
|
||||
payload.update.remaining = Math.round(remaining);
|
||||
}
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (this.legacyApi) {
|
||||
@@ -171,16 +184,16 @@ export default class OTAUpdate extends Extension {
|
||||
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
if ((!this.legacyApi || !data.topic.match(legacyTopicRegex)) && !data.topic.match(topicRegex)) {
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
const ID = (typeof message === 'object' && message.hasOwnProperty('id') ? message.id : message) as string;
|
||||
const ID = (typeof message === 'object' && message['id'] !== undefined ? message.id : message) as string;
|
||||
const device = this.zigbee.resolveEntity(ID);
|
||||
const type = data.topic.substring(data.topic.lastIndexOf('/') + 1);
|
||||
const responseData: {id: string; updateAvailable?: boolean; from?: string; to?: string} = {id: ID};
|
||||
let error = null;
|
||||
let errorStack = null;
|
||||
const responseData: {id: string; updateAvailable?: boolean; from?: KeyValue | null; to?: KeyValue | null} = {id: ID};
|
||||
let error: string | undefined;
|
||||
let errorStack: string | undefined;
|
||||
|
||||
if (!(device instanceof Device)) {
|
||||
error = `Device '${ID}' does not exist`;
|
||||
@@ -208,7 +221,7 @@ export default class OTAUpdate extends Extension {
|
||||
}
|
||||
|
||||
try {
|
||||
const availableResult = await device.definition.ota.isUpdateAvailable(device.zh, null);
|
||||
const availableResult = await device.definition.ota.isUpdateAvailable(device.zh, undefined);
|
||||
const msg = `${availableResult.available ? 'Update' : 'No update'} available for '${device.name}'`;
|
||||
logger.info(msg);
|
||||
|
||||
@@ -226,8 +239,8 @@ export default class OTAUpdate extends Extension {
|
||||
this.lastChecked[device.ieeeAddr] = Date.now();
|
||||
responseData.updateAvailable = availableResult.available;
|
||||
} catch (e) {
|
||||
error = `Failed to check if update available for '${device.name}' (${e.message})`;
|
||||
errorStack = e.stack;
|
||||
error = `Failed to check if update available for '${device.name}' (${(e as Error).message})`;
|
||||
errorStack = (e as Error).stack;
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (settings.get().advanced.legacy_api) {
|
||||
@@ -278,8 +291,8 @@ export default class OTAUpdate extends Extension {
|
||||
const to = await this.readSoftwareBuildIDAndDateCode(device);
|
||||
const [fromS, toS] = [stringify(from_), stringify(to)];
|
||||
logger.info(`Device '${device.name}' was updated from '${fromS}' to '${toS}'`);
|
||||
responseData.from = from_ ? utils.toSnakeCase(from_) : null;
|
||||
responseData.to = to ? utils.toSnakeCase(to) : null;
|
||||
responseData.from = from_ ? utils.toSnakeCaseObject(from_) : null;
|
||||
responseData.to = to ? utils.toSnakeCaseObject(to) : null;
|
||||
/**
|
||||
* Re-configure after reading software build ID and date code, some devices use a
|
||||
* custom attribute for this (e.g. Develco SMSZB-120)
|
||||
@@ -294,8 +307,8 @@ export default class OTAUpdate extends Extension {
|
||||
}
|
||||
} catch (e) {
|
||||
logger.debug(`Update of '${device.name}' failed (${e})`);
|
||||
error = `Update of '${device.name}' failed (${e.message})`;
|
||||
errorStack = e.stack;
|
||||
error = `Update of '${device.name}' failed (${(e as Error).message})`;
|
||||
errorStack = (e as Error).stack;
|
||||
|
||||
this.removeProgressAndRemainingFromState(device);
|
||||
const payload = this.getEntityPublishPayload(device, 'available');
|
||||
@@ -313,6 +326,7 @@ export default class OTAUpdate extends Extension {
|
||||
}
|
||||
|
||||
const triggeredViaLegacyApi = data.topic.match(legacyTopicRegex);
|
||||
|
||||
if (!triggeredViaLegacyApi) {
|
||||
const response = utils.getResponse(message, responseData, error);
|
||||
await this.mqtt.publish(`bridge/response/device/ota_update/${type}`, stringify(response));
|
||||
@@ -320,6 +334,7 @@ export default class OTAUpdate extends Extension {
|
||||
|
||||
if (error) {
|
||||
logger.error(error);
|
||||
|
||||
if (errorStack) {
|
||||
logger.debug(errorStack);
|
||||
}
|
||||
|
||||
+82
-48
@@ -1,5 +1,8 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
import * as philips from 'zigbee-herdsman-converters/lib/philips';
|
||||
|
||||
@@ -17,11 +20,11 @@ export const loadTopicGetSetRegex = (): void => {
|
||||
};
|
||||
loadTopicGetSetRegex();
|
||||
|
||||
const stateValues = ['on', 'off', 'toggle', 'open', 'close', 'stop', 'lock', 'unlock'];
|
||||
const sceneConverterKeys = ['scene_store', 'scene_add', 'scene_remove', 'scene_remove_all', 'scene_rename'];
|
||||
const STATE_VALUES: ReadonlyArray<string> = ['on', 'off', 'toggle', 'open', 'close', 'stop', 'lock', 'unlock'];
|
||||
const SCENE_CONVERTER_KEYS: ReadonlyArray<string> = ['scene_store', 'scene_add', 'scene_remove', 'scene_remove_all', 'scene_rename'];
|
||||
|
||||
// Legacy: don't provide default converters anymore, this is required by older z2m installs not saving group members
|
||||
const defaultGroupConverters = [
|
||||
const DEFAULT_GROUP_CONVERTERS: ReadonlyArray<zhc.Tz.Converter> = [
|
||||
zhc.toZigbee.light_onoff_brightness,
|
||||
zhc.toZigbee.light_color_colortemp,
|
||||
philips.tz.effect, // Support Hue effects for groups
|
||||
@@ -39,7 +42,7 @@ const defaultGroupConverters = [
|
||||
|
||||
interface ParsedTopic {
|
||||
ID: string;
|
||||
endpoint: string;
|
||||
endpoint: string | undefined;
|
||||
attribute: string;
|
||||
type: 'get' | 'set';
|
||||
}
|
||||
@@ -49,7 +52,7 @@ export default class Publish extends Extension {
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
}
|
||||
|
||||
parseTopic(topic: string): ParsedTopic | null {
|
||||
parseTopic(topic: string): ParsedTopic | undefined {
|
||||
// The function supports the following topic formats (below are for 'set'. 'get' will look the same):
|
||||
// - <base_topic>/device_name/set (endpoint and attribute is defined in the payload)
|
||||
// - <base_topic>/device_name/set/attribute (default endpoint used)
|
||||
@@ -60,7 +63,10 @@ export default class Publish extends Extension {
|
||||
// Before the get/set is the device name and optional endpoint name.
|
||||
// After it there will be an optional attribute name.
|
||||
const match = topic.match(topicGetSetRegex);
|
||||
if (!match) return null;
|
||||
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const deviceNameAndEndpoint = match[1];
|
||||
const attribute = match[3];
|
||||
@@ -70,7 +76,7 @@ export default class Publish extends Extension {
|
||||
return {ID: entity.ID, endpoint: entity.endpointID, type: match[2] as 'get' | 'set', attribute: attribute};
|
||||
}
|
||||
|
||||
parseMessage(parsedTopic: ParsedTopic, data: eventdata.MQTTMessage): KeyValue | null {
|
||||
parseMessage(parsedTopic: ParsedTopic, data: eventdata.MQTTMessage): KeyValue | undefined {
|
||||
if (parsedTopic.attribute) {
|
||||
try {
|
||||
return {[parsedTopic.attribute]: JSON.parse(data.message)};
|
||||
@@ -81,10 +87,10 @@ export default class Publish extends Extension {
|
||||
try {
|
||||
return JSON.parse(data.message);
|
||||
} catch {
|
||||
if (stateValues.includes(data.message.toLowerCase())) {
|
||||
if (STATE_VALUES.includes(data.message.toLowerCase())) {
|
||||
return {state: data.message};
|
||||
} else {
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,8 +119,10 @@ export default class Publish extends Extension {
|
||||
// ever issue a read here, as we assume the device will properly report changes.
|
||||
// Only do this when the retrieve_state option is enabled for this device.
|
||||
// retrieve_state == deprecated
|
||||
if (re instanceof Device && result && result.hasOwnProperty('readAfterWriteTime') && re.options.retrieve_state) {
|
||||
setTimeout(() => converter.convertGet(target, key, meta), result.readAfterWriteTime);
|
||||
if (re instanceof Device && result && result.readAfterWriteTime !== undefined && re.options.retrieve_state) {
|
||||
const convertGet = converter.convertGet;
|
||||
assert(convertGet !== undefined, 'Converter has `readAfterWriteTime` but no `convertGet`');
|
||||
setTimeout(() => convertGet(target, key, meta), result.readAfterWriteTime);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,9 +133,9 @@ export default class Publish extends Extension {
|
||||
* (state) is probably unnecessary.
|
||||
*/
|
||||
if (settings.get().homeassistant) {
|
||||
const hasColorTemp = message.hasOwnProperty('color_temp');
|
||||
const hasColor = message.hasOwnProperty('color');
|
||||
const hasBrightness = message.hasOwnProperty('brightness');
|
||||
const hasColorTemp = message.color_temp !== undefined;
|
||||
const hasColor = message.color !== undefined;
|
||||
const hasBrightness = message.brightness !== undefined;
|
||||
const isOn = entityState.state === 'ON' ? true : false;
|
||||
if (isOn && (hasColorTemp || hasColor) && !hasBrightness) {
|
||||
delete message.state;
|
||||
@@ -138,48 +146,63 @@ export default class Publish extends Extension {
|
||||
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const parsedTopic = this.parseTopic(data.topic);
|
||||
if (!parsedTopic) return;
|
||||
|
||||
if (!parsedTopic) {
|
||||
return;
|
||||
}
|
||||
|
||||
const re = this.zigbee.resolveEntity(parsedTopic.ID);
|
||||
if (re == null) {
|
||||
|
||||
if (!re) {
|
||||
await this.legacyLog({type: `entity_not_found`, message: {friendly_name: parsedTopic.ID}});
|
||||
logger.error(`Entity '${parsedTopic.ID}' is unknown`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get entity details
|
||||
const definition = re instanceof Device ? re.definition : re.membersDefinitions();
|
||||
let definition: zhc.Definition | zhc.Definition[];
|
||||
if (re instanceof Device) {
|
||||
if (!re.definition) {
|
||||
logger.error(`Cannot publish to unsupported device '${re.name}'`);
|
||||
return;
|
||||
}
|
||||
definition = re.definition;
|
||||
} else {
|
||||
definition = re.membersDefinitions();
|
||||
}
|
||||
const target = re instanceof Group ? re.zh : re.endpoint(parsedTopic.endpoint);
|
||||
if (target == null) {
|
||||
|
||||
if (!target) {
|
||||
logger.error(`Device '${re.name}' has no endpoint '${parsedTopic.endpoint}'`);
|
||||
return;
|
||||
}
|
||||
const device = re instanceof Device ? re.zh : null;
|
||||
|
||||
// Convert the MQTT message to a Zigbee message.
|
||||
const message = this.parseMessage(parsedTopic, data);
|
||||
|
||||
if (!message) {
|
||||
logger.error(`Invalid message '${message}', skipping...`);
|
||||
return;
|
||||
}
|
||||
|
||||
const device = re instanceof Device ? re.zh : undefined;
|
||||
const entitySettings = re.options;
|
||||
const entityState = this.state.get(re);
|
||||
const membersState =
|
||||
re instanceof Group
|
||||
? Object.fromEntries(
|
||||
re.zh.members.map((e) => [e.getDevice().ieeeAddr, this.state.get(this.zigbee.resolveEntity(e.getDevice().ieeeAddr))]),
|
||||
re.zh.members.map((e) => [e.getDevice().ieeeAddr, this.state.get(this.zigbee.resolveEntity(e.getDevice().ieeeAddr)!)]),
|
||||
)
|
||||
: null;
|
||||
let converters: zhc.Tz.Converter[];
|
||||
{
|
||||
if (Array.isArray(definition)) {
|
||||
const c = new Set(definition.map((d) => d.toZigbee).flat());
|
||||
if (c.size == 0) converters = defaultGroupConverters;
|
||||
else converters = Array.from(c);
|
||||
} else {
|
||||
converters = definition.toZigbee;
|
||||
}
|
||||
: undefined;
|
||||
let converters: ReadonlyArray<zhc.Tz.Converter>;
|
||||
|
||||
if (Array.isArray(definition)) {
|
||||
const c = new Set(definition.map((d) => d.toZigbee).flat());
|
||||
converters = c.size === 0 ? DEFAULT_GROUP_CONVERTERS : Array.from(c);
|
||||
} else {
|
||||
converters = definition?.toZigbee;
|
||||
}
|
||||
|
||||
// Convert the MQTT message to a Zigbee message.
|
||||
const message = this.parseMessage(parsedTopic, data);
|
||||
if (message == null) {
|
||||
logger.error(`Invalid message '${message}', skipping...`);
|
||||
return;
|
||||
}
|
||||
this.updateMessageHomeAssistant(message, entityState);
|
||||
|
||||
/**
|
||||
@@ -201,38 +224,46 @@ export default class Publish extends Extension {
|
||||
const toPublishEntity: {[s: number | string]: Device | Group} = {};
|
||||
const addToToPublish = (entity: Device | Group, payload: KeyValue): void => {
|
||||
const ID = entity.ID;
|
||||
|
||||
if (!(ID in toPublish)) {
|
||||
toPublish[ID] = {};
|
||||
toPublishEntity[ID] = entity;
|
||||
}
|
||||
|
||||
toPublish[ID] = {...toPublish[ID], ...payload};
|
||||
};
|
||||
|
||||
const endpointNames = re instanceof Device ? re.getEndpointNames() : [];
|
||||
const propertyEndpointRegex = new RegExp(`^(.*?)_(${endpointNames.join('|')})$`);
|
||||
let scenesChanged = false;
|
||||
|
||||
for (const entry of entries) {
|
||||
let key = entry[0];
|
||||
const value = entry[1];
|
||||
let endpointName = parsedTopic.endpoint;
|
||||
let localTarget = target;
|
||||
let endpointOrGroupID = utils.isEndpoint(target) ? target.ID : target.groupID;
|
||||
let endpointOrGroupID = utils.isZHEndpoint(target) ? target.ID : target.groupID;
|
||||
|
||||
// When the key has a endpointName included (e.g. state_right), this will override the target.
|
||||
const propertyEndpointMatch = key.match(propertyEndpointRegex);
|
||||
|
||||
if (re instanceof Device && propertyEndpointMatch) {
|
||||
endpointName = propertyEndpointMatch[2];
|
||||
key = propertyEndpointMatch[1];
|
||||
localTarget = re.endpoint(endpointName);
|
||||
// endpointName is always matched to an existing endpoint of the device
|
||||
// since `propertyEndpointRegex` only contains valid endpoints for this device.
|
||||
localTarget = re.endpoint(endpointName)!;
|
||||
endpointOrGroupID = localTarget.ID;
|
||||
}
|
||||
|
||||
if (!usedConverters.hasOwnProperty(endpointOrGroupID)) usedConverters[endpointOrGroupID] = [];
|
||||
if (usedConverters[endpointOrGroupID] === undefined) usedConverters[endpointOrGroupID] = [];
|
||||
/* istanbul ignore next */
|
||||
// Match any key if the toZigbee converter defines no key.
|
||||
const converter = converters.find((c) => (!c.key || c.key.includes(key)) && (!c.endpoint || c.endpoint == endpointName));
|
||||
const converter = converters.find(
|
||||
(c) => (!c.key || c.key.includes(key)) && (!c.endpoints || (endpointName && c.endpoints.includes(endpointName))),
|
||||
);
|
||||
|
||||
if (parsedTopic.type === 'set' && usedConverters[endpointOrGroupID].includes(converter)) {
|
||||
if (parsedTopic.type === 'set' && converter && usedConverters[endpointOrGroupID].includes(converter)) {
|
||||
// Use a converter for set only once
|
||||
// (e.g. light_onoff_brightness converters can convert state and brightness)
|
||||
continue;
|
||||
@@ -244,17 +275,16 @@ export default class Publish extends Extension {
|
||||
}
|
||||
|
||||
// If the endpoint_name name is a number, try to map it to a friendlyName
|
||||
if (!isNaN(Number(endpointName)) && re.isDevice() && utils.isEndpoint(localTarget) && re.endpointName(localTarget)) {
|
||||
if (!isNaN(Number(endpointName)) && re.isDevice() && utils.isZHEndpoint(localTarget) && re.endpointName(localTarget)) {
|
||||
endpointName = re.endpointName(localTarget);
|
||||
}
|
||||
|
||||
// Converter didn't return a result, skip
|
||||
const entitySettingsKeyValue: KeyValue = entitySettings;
|
||||
const meta = {
|
||||
const meta: zhc.Tz.Meta = {
|
||||
endpoint_name: endpointName,
|
||||
options: entitySettingsKeyValue,
|
||||
message: {...message},
|
||||
logger,
|
||||
device,
|
||||
state: entityState,
|
||||
membersState,
|
||||
@@ -276,7 +306,8 @@ export default class Publish extends Extension {
|
||||
if (parsedTopic.type === 'set' && converter.convertSet) {
|
||||
logger.debug(`Publishing '${parsedTopic.type}' '${key}' to '${re.name}'`);
|
||||
const result = await converter.convertSet(localTarget, key, value, meta);
|
||||
const optimistic = !entitySettings.hasOwnProperty('optimistic') || entitySettings.optimistic;
|
||||
const optimistic = entitySettings.optimistic === undefined || entitySettings.optimistic;
|
||||
|
||||
if (result && result.state && optimistic) {
|
||||
const msg = result.state;
|
||||
|
||||
@@ -295,7 +326,7 @@ export default class Publish extends Extension {
|
||||
|
||||
if (result && result.membersState && optimistic) {
|
||||
for (const [ieeeAddr, state] of Object.entries(result.membersState)) {
|
||||
addToToPublish(this.zigbee.resolveEntity(ieeeAddr), state);
|
||||
addToToPublish(this.zigbee.resolveEntity(ieeeAddr)!, state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,11 +341,15 @@ export default class Publish extends Extension {
|
||||
} catch (error) {
|
||||
const message = `Publish '${parsedTopic.type}' '${key}' to '${re.name}' failed: '${error}'`;
|
||||
logger.error(message);
|
||||
logger.debug(error.stack);
|
||||
logger.debug((error as Error).stack!);
|
||||
await this.legacyLog({type: `zigbee_publish_error`, message, meta: {friendly_name: re.name}});
|
||||
}
|
||||
|
||||
usedConverters[endpointOrGroupID].push(converter);
|
||||
|
||||
if (!scenesChanged && converter.key) {
|
||||
scenesChanged = converter.key.some((k) => SCENE_CONVERTER_KEYS.includes(k));
|
||||
}
|
||||
}
|
||||
|
||||
for (const [ID, payload] of Object.entries(toPublish)) {
|
||||
@@ -323,7 +358,6 @@ export default class Publish extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
const scenesChanged = Object.values(usedConverters).some((cl) => cl.some((c) => c.key?.some((k) => sceneConverterKeys.includes(k))));
|
||||
if (scenesChanged) {
|
||||
this.eventBus.emitScenesChanged({entity: re});
|
||||
}
|
||||
|
||||
+34
-20
@@ -1,6 +1,10 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import debounce from 'debounce';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import throttle from 'throttleit';
|
||||
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
|
||||
import logger from '../util/logger';
|
||||
@@ -13,6 +17,7 @@ type DebounceFunction = (() => void) & {clear(): void} & {flush(): void};
|
||||
export default class Receive extends Extension {
|
||||
private elapsed: {[s: string]: number} = {};
|
||||
private debouncers: {[s: string]: {payload: KeyValue; publish: DebounceFunction}} = {};
|
||||
private throttlers: {[s: string]: {publish: PublishEntityState}} = {};
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.eventBus.onPublishEntityState(this, this.onPublishEntityState);
|
||||
@@ -37,7 +42,7 @@ export default class Receive extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
publishDebounce(device: Device, payload: KeyValue, time: number, debounceIgnore: string[]): void {
|
||||
publishDebounce(device: Device, payload: KeyValue, time: number, debounceIgnore: string[] | undefined): void {
|
||||
if (!this.debouncers[device.ieeeAddr]) {
|
||||
this.debouncers[device.ieeeAddr] = {
|
||||
payload: {},
|
||||
@@ -65,11 +70,25 @@ export default class Receive extends Extension {
|
||||
this.debouncers[device.ieeeAddr].publish();
|
||||
}
|
||||
|
||||
async publishThrottle(device: Device, payload: KeyValue, time: number): Promise<void> {
|
||||
if (!this.throttlers[device.ieeeAddr]) {
|
||||
this.throttlers[device.ieeeAddr] = {
|
||||
publish: throttle(this.publishEntityState, time * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
// Update state cache right away. This makes sure that during throttling cached state is always up to date.
|
||||
// By updating cache we make sure that state cache is always up-to-date.
|
||||
this.state.set(device, payload);
|
||||
|
||||
await this.throttlers[device.ieeeAddr].publish(device, payload, 'publishThrottle');
|
||||
}
|
||||
|
||||
// if debounce_ignore are specified (Array of strings)
|
||||
// then all newPayload values with key present in debounce_ignore
|
||||
// should equal or be undefined in oldPayload
|
||||
// otherwise payload is conflicted
|
||||
isPayloadConflicted(newPayload: KeyValue, oldPayload: KeyValue, debounceIgnore: string[] | null): boolean {
|
||||
isPayloadConflicted(newPayload: KeyValue, oldPayload: KeyValue, debounceIgnore: string[] | undefined): boolean {
|
||||
let result = false;
|
||||
Object.keys(oldPayload)
|
||||
.filter((key) => (debounceIgnore || []).includes(key))
|
||||
@@ -82,20 +101,12 @@ export default class Receive extends Extension {
|
||||
return result;
|
||||
}
|
||||
|
||||
shouldProcess(data: eventdata.DeviceMessage): boolean {
|
||||
if (!data.device.definition || data.device.zh.interviewing) {
|
||||
logger.debug(`Skipping message, still interviewing`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@bind async onDeviceMessage(data: eventdata.DeviceMessage): Promise<void> {
|
||||
/* istanbul ignore next */
|
||||
if (!data.device) return;
|
||||
|
||||
if (!this.shouldProcess(data)) {
|
||||
if (!data.device.definition || data.device.zh.interviewing) {
|
||||
logger.debug(`Skipping message, still interviewing`);
|
||||
await utils.publishLastSeen({device: data.device, reason: 'messageEmitted'}, settings.get(), true, this.publishEntityState);
|
||||
return;
|
||||
}
|
||||
@@ -122,6 +133,7 @@ export default class Receive extends Extension {
|
||||
// - If NO payload is returned do nothing. This is for non-standard behaviour
|
||||
// for e.g. click switches where we need to count number of clicks and detect long presses.
|
||||
const publish = async (payload: KeyValue): Promise<void> => {
|
||||
assert(data.device.definition);
|
||||
const options: KeyValue = data.device.options;
|
||||
zhc.postProcessConvertedFromZigbeeMessage(data.device.definition, payload, options);
|
||||
|
||||
@@ -134,20 +146,22 @@ export default class Receive extends Extension {
|
||||
this.elapsed[data.device.ieeeAddr] = now;
|
||||
}
|
||||
|
||||
// Check if we have to debounce
|
||||
// Check if we have to debounce or throttle
|
||||
if (data.device.options.debounce) {
|
||||
this.publishDebounce(data.device, payload, data.device.options.debounce, data.device.options.debounce_ignore);
|
||||
} else if (data.device.options.throttle) {
|
||||
await this.publishThrottle(data.device, payload, data.device.options.throttle);
|
||||
} else {
|
||||
await this.publishEntityState(data.device, payload);
|
||||
}
|
||||
};
|
||||
|
||||
const deviceExposesChanged = (): void => {
|
||||
this.eventBus.emitDevicesChanged();
|
||||
this.eventBus.emitExposesChanged({device: data.device});
|
||||
const meta = {
|
||||
device: data.device.zh,
|
||||
logger,
|
||||
state: this.state.get(data.device),
|
||||
deviceExposesChanged: (): void => this.eventBus.emitExposesAndDevicesChanged(data.device),
|
||||
};
|
||||
|
||||
const meta = {device: data.device.zh, logger, state: this.state.get(data.device), deviceExposesChanged: deviceExposesChanged};
|
||||
let payload: KeyValue = {};
|
||||
for (const converter of converters) {
|
||||
try {
|
||||
@@ -158,8 +172,8 @@ export default class Receive extends Extension {
|
||||
payload = {...payload, ...converted};
|
||||
}
|
||||
} catch (error) /* istanbul ignore next */ {
|
||||
logger.error(`Exception while calling fromZigbee converter: ${error.message}}`);
|
||||
logger.debug(error.stack);
|
||||
logger.error(`Exception while calling fromZigbee converter: ${(error as Error).message}}`);
|
||||
logger.debug((error as Error).stack!);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+43
-19
@@ -1,13 +1,14 @@
|
||||
/* eslint-disable brace-style */
|
||||
import {CustomClusters} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype';
|
||||
import assert from 'assert';
|
||||
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
import {CustomClusters} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype';
|
||||
|
||||
import * as settings from '../util/settings';
|
||||
|
||||
export default class Device {
|
||||
public zh: zh.Device;
|
||||
public definition: zhc.Definition;
|
||||
private _definitionModelID: string;
|
||||
public definition?: zhc.Definition;
|
||||
private _definitionModelID?: string;
|
||||
|
||||
get ieeeAddr(): string {
|
||||
return this.zh.ieeeAddr;
|
||||
@@ -15,14 +16,15 @@ export default class Device {
|
||||
get ID(): string {
|
||||
return this.zh.ieeeAddr;
|
||||
}
|
||||
get options(): DeviceOptions {
|
||||
return {...settings.get().device_options, ...settings.getDevice(this.ieeeAddr)};
|
||||
get options(): DeviceOptionsWithId {
|
||||
const deviceOptions = settings.getDevice(this.ieeeAddr) ?? {friendly_name: this.ieeeAddr, ID: this.ieeeAddr};
|
||||
return {...settings.get().device_options, ...deviceOptions};
|
||||
}
|
||||
get name(): string {
|
||||
return this.zh.type === 'Coordinator' ? 'Coordinator' : this.options?.friendly_name || this.ieeeAddr;
|
||||
return this.zh.type === 'Coordinator' ? 'Coordinator' : this.options?.friendly_name;
|
||||
}
|
||||
get isSupported(): boolean {
|
||||
return this.zh.type === 'Coordinator' || (this.definition && !this.definition.generated);
|
||||
return this.zh.type === 'Coordinator' || Boolean(this.definition && !this.definition.generated);
|
||||
}
|
||||
get customClusters(): CustomClusters {
|
||||
return this.zh.customClusters;
|
||||
@@ -33,6 +35,7 @@ export default class Device {
|
||||
}
|
||||
|
||||
exposes(): zhc.Expose[] {
|
||||
assert(this.definition, 'Cannot retreive exposes before definition is resolved');
|
||||
/* istanbul ignore if */
|
||||
if (typeof this.definition.exposes == 'function') {
|
||||
const options: KeyValue = this.options;
|
||||
@@ -55,28 +58,40 @@ export default class Device {
|
||||
}
|
||||
}
|
||||
|
||||
endpoint(key?: string | number): zh.Endpoint {
|
||||
let endpoint: zh.Endpoint;
|
||||
if (key == null || key == '') key = 'default';
|
||||
endpoint(key?: string | number): zh.Endpoint | undefined {
|
||||
let endpoint: zh.Endpoint | undefined;
|
||||
|
||||
if (key == null || key == '') {
|
||||
key = 'default';
|
||||
}
|
||||
|
||||
if (!isNaN(Number(key))) {
|
||||
endpoint = this.zh.getEndpoint(Number(key));
|
||||
} else if (this.definition?.endpoint) {
|
||||
const ID = this.definition?.endpoint?.(this.zh)[key];
|
||||
if (ID) endpoint = this.zh.getEndpoint(ID);
|
||||
else if (key === 'default') endpoint = this.zh.endpoints[0];
|
||||
else return null;
|
||||
|
||||
if (ID) {
|
||||
endpoint = this.zh.getEndpoint(ID);
|
||||
} else if (key === 'default') {
|
||||
endpoint = this.zh.endpoints[0];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
/* istanbul ignore next */
|
||||
if (key !== 'default') return null;
|
||||
if (key !== 'default') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
endpoint = this.zh.endpoints[0];
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
endpointName(endpoint: zh.Endpoint): string {
|
||||
let epName = null;
|
||||
endpointName(endpoint: zh.Endpoint): string | undefined {
|
||||
let epName = undefined;
|
||||
|
||||
if (this.definition?.endpoint) {
|
||||
const mapping = this.definition?.endpoint(this.zh);
|
||||
for (const [name, id] of Object.entries(mapping)) {
|
||||
@@ -85,12 +100,21 @@ export default class Device {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
return epName === 'default' ? null : epName;
|
||||
return epName === 'default' ? undefined : epName;
|
||||
}
|
||||
|
||||
getEndpointNames(): string[] {
|
||||
return Object.keys(this.definition?.endpoint?.(this.zh) ?? {}).filter((name) => name !== 'default');
|
||||
const names: string[] = [];
|
||||
|
||||
for (const name in this.definition?.endpoint?.(this.zh) ?? {}) {
|
||||
if (name !== 'default') {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
isIkeaTradfri(): boolean {
|
||||
|
||||
+15
-8
@@ -1,23 +1,23 @@
|
||||
/* eslint-disable brace-style */
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
|
||||
import * as settings from '../util/settings';
|
||||
|
||||
export default class Group {
|
||||
public zh: zh.Group;
|
||||
private resolveDevice: (ieeeAddr: string) => Device;
|
||||
private resolveDevice: (ieeeAddr: string) => Device | undefined;
|
||||
|
||||
get ID(): number {
|
||||
return this.zh.groupID;
|
||||
}
|
||||
get options(): GroupOptions {
|
||||
return {...settings.getGroup(this.ID)};
|
||||
// XXX: Group always exists in settings
|
||||
return {...settings.getGroup(this.ID)!};
|
||||
}
|
||||
get name(): string {
|
||||
return this.options?.friendly_name || this.ID.toString();
|
||||
}
|
||||
|
||||
constructor(group: zh.Group, resolveDevice: (ieeeAddr: string) => Device) {
|
||||
constructor(group: zh.Group, resolveDevice: (ieeeAddr: string) => Device | undefined) {
|
||||
this.zh = group;
|
||||
this.resolveDevice = resolveDevice;
|
||||
}
|
||||
@@ -27,13 +27,20 @@ export default class Group {
|
||||
}
|
||||
|
||||
membersDevices(): Device[] {
|
||||
return this.zh.members.map((e) => this.resolveDevice(e.getDevice().ieeeAddr)).filter((d) => d);
|
||||
return this.zh.members.map((d) => this.resolveDevice(d.getDevice().ieeeAddr)!);
|
||||
}
|
||||
|
||||
membersDefinitions(): zhc.Definition[] {
|
||||
return this.membersDevices()
|
||||
.map((d) => d.definition)
|
||||
.filter((d) => d);
|
||||
const definitions: zhc.Definition[] = [];
|
||||
|
||||
for (const member of this.membersDevices()) {
|
||||
/* istanbul ignore else */
|
||||
if (member.definition) {
|
||||
definitions.push(member.definition);
|
||||
}
|
||||
}
|
||||
|
||||
return definitions;
|
||||
}
|
||||
|
||||
isDevice(): this is Device {
|
||||
|
||||
+13
-10
@@ -1,7 +1,8 @@
|
||||
import type {QoS} from 'mqtt-packet';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import fs from 'fs';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import * as mqtt from 'mqtt';
|
||||
|
||||
import logger from './util/logger';
|
||||
@@ -12,11 +13,12 @@ const NS = 'z2m:mqtt';
|
||||
|
||||
export default class MQTT {
|
||||
private publishedTopics: Set<string> = new Set();
|
||||
private connectionTimer: NodeJS.Timeout;
|
||||
private connectionTimer?: NodeJS.Timeout;
|
||||
// @ts-expect-error initialized in `connect`
|
||||
private client: mqtt.MqttClient;
|
||||
private eventBus: EventBus;
|
||||
private initialConnect = true;
|
||||
private republishRetainedTimer: NodeJS.Timeout;
|
||||
private republishRetainedTimer?: NodeJS.Timeout;
|
||||
public retainedMessages: {
|
||||
[s: string]: {payload: string; options: MQTTOptions; skipLog: boolean; skipReceive: boolean; topic: string; base: string};
|
||||
} = {};
|
||||
@@ -73,14 +75,14 @@ export default class MQTT {
|
||||
options.clientId = mqttSettings.client_id;
|
||||
}
|
||||
|
||||
if (mqttSettings.hasOwnProperty('reject_unauthorized') && !mqttSettings.reject_unauthorized) {
|
||||
if (mqttSettings.reject_unauthorized !== undefined && !mqttSettings.reject_unauthorized) {
|
||||
logger.debug(`MQTT reject_unauthorized set false, ignoring certificate warnings.`);
|
||||
options.rejectUnauthorized = false;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
return await new Promise((resolve, reject) => {
|
||||
this.client = mqtt.connect(mqttSettings.server, options);
|
||||
// @ts-ignore https://github.com/Koenkk/zigbee2mqtt/issues/9822
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/9822
|
||||
this.client.stream.setMaxListeners(0);
|
||||
this.eventBus.onPublishAvailability(this, this.publishStateOnline);
|
||||
|
||||
@@ -126,6 +128,7 @@ export default class MQTT {
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
clearTimeout(this.connectionTimer);
|
||||
clearTimeout(this.republishRetainedTimer);
|
||||
await this.publish('bridge/state', utils.availabilityPayload('offline', settings.get()), {retain: true, qos: 0});
|
||||
this.eventBus.removeListeners(this);
|
||||
logger.info('Disconnecting from MQTT server');
|
||||
@@ -143,14 +146,14 @@ export default class MQTT {
|
||||
@bind public onMessage(topic: string, message: Buffer): void {
|
||||
// Since we subscribe to zigbee2mqtt/# we also receive the message we send ourselves, skip these.
|
||||
if (!this.publishedTopics.has(topic)) {
|
||||
logger.debug(`Received MQTT message on '${topic}' with data '${message.toString()}'`, NS);
|
||||
logger.debug(() => `Received MQTT message on '${topic}' with data '${message.toString()}'`, NS);
|
||||
this.eventBus.emitMQTTMessage({topic, message: message.toString()});
|
||||
}
|
||||
|
||||
if (this.republishRetainedTimer && topic === `${settings.get().mqtt.base_topic}/bridge/info`) {
|
||||
clearTimeout(this.republishRetainedTimer);
|
||||
|
||||
this.republishRetainedTimer = null;
|
||||
this.republishRetainedTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,7 +197,7 @@ export default class MQTT {
|
||||
}
|
||||
|
||||
if (!skipLog) {
|
||||
logger.info(`MQTT publish: topic '${topic}', payload '${payload}'`, NS);
|
||||
logger.info(() => `MQTT publish: topic '${topic}', payload '${payload}'`, NS);
|
||||
}
|
||||
|
||||
const actualOptions: mqtt.IClientPublishOptions = {...defaultOptions, ...options};
|
||||
@@ -203,7 +206,7 @@ export default class MQTT {
|
||||
actualOptions.retain = false;
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
return await new Promise<void>((resolve) => {
|
||||
this.client.publish(topic, payload, actualOptions, () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
+7
-6
@@ -1,4 +1,5 @@
|
||||
import fs from 'fs';
|
||||
|
||||
import objectAssignDeep from 'object-assign-deep';
|
||||
|
||||
import data from './util/data';
|
||||
@@ -33,7 +34,7 @@ const dontCacheProperties = [
|
||||
class State {
|
||||
private state: {[s: string | number]: KeyValue} = {};
|
||||
private file = data.joinPath('state.json');
|
||||
private timer: NodeJS.Timeout = null;
|
||||
private timer?: NodeJS.Timeout;
|
||||
|
||||
constructor(
|
||||
private readonly eventBus: EventBus,
|
||||
@@ -66,7 +67,7 @@ class State {
|
||||
this.state = JSON.parse(fs.readFileSync(this.file, 'utf8'));
|
||||
logger.debug(`Loaded state from file ${this.file}`);
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to load state from file ${this.file} (corrupt file?) (${error.message})`);
|
||||
logger.debug(`Failed to load state from file ${this.file} (corrupt file?) (${(error as Error).message})`);
|
||||
}
|
||||
} else {
|
||||
logger.debug(`Can't load state from file ${this.file} (doesn't exist)`);
|
||||
@@ -79,8 +80,8 @@ class State {
|
||||
const json = JSON.stringify(this.state, null, 4);
|
||||
try {
|
||||
fs.writeFileSync(this.file, json, 'utf8');
|
||||
} catch (e) {
|
||||
logger.error(`Failed to write state to '${this.file}' (${e.message})`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to write state to '${this.file}' (${error})`);
|
||||
}
|
||||
} else {
|
||||
logger.debug(`Not saving state`);
|
||||
@@ -88,14 +89,14 @@ class State {
|
||||
}
|
||||
|
||||
exists(entity: Device | Group): boolean {
|
||||
return this.state.hasOwnProperty(entity.ID);
|
||||
return this.state[entity.ID] !== undefined;
|
||||
}
|
||||
|
||||
get(entity: Group | Device): KeyValue {
|
||||
return this.state[entity.ID] || {};
|
||||
}
|
||||
|
||||
set(entity: Group | Device, update: KeyValue, reason: string = null): KeyValue {
|
||||
set(entity: Group | Device, update: KeyValue, reason?: string): KeyValue {
|
||||
const fromState = this.state[entity.ID] || {};
|
||||
const toState = objectAssignDeep({}, fromState, update);
|
||||
const newCache = {...toState};
|
||||
|
||||
Vendored
+28
-23
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable camelcase */
|
||||
import type TypeEventBus from 'lib/eventBus';
|
||||
import type TypeExtension from 'lib/extension/extension';
|
||||
import type TypeDevice from 'lib/model/device';
|
||||
@@ -7,20 +6,22 @@ import type TypeMQTT from 'lib/mqtt';
|
||||
import type TypeState from 'lib/state';
|
||||
import type TypeZigbee from 'lib/zigbee';
|
||||
import type {QoS} from 'mqtt-packet';
|
||||
import type * as zhc from 'zigbee-herdsman-converters';
|
||||
import type {
|
||||
NetworkParameters as ZHNetworkParameters,
|
||||
CoordinatorVersion as ZHCoordinatorVersion,
|
||||
LQI as ZHLQI,
|
||||
NetworkParameters as ZHNetworkParameters,
|
||||
RoutingTable as ZHRoutingTable,
|
||||
RoutingTableEntry as ZHRoutingTableEntry,
|
||||
} from 'zigbee-herdsman/dist/adapter/tstype';
|
||||
import type * as ZHEvents from 'zigbee-herdsman/dist/controller/events';
|
||||
import type {Device as ZHDevice, Group as ZHGroup, Endpoint as ZHEndpoint} from 'zigbee-herdsman/dist/controller/model';
|
||||
import type {Device as ZHDevice, Endpoint as ZHEndpoint, Group as ZHGroup} from 'zigbee-herdsman/dist/controller/model';
|
||||
import type {Cluster as ZHCluster, FrameControl as ZHFrameControl} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype';
|
||||
import type * as zhc from 'zigbee-herdsman-converters';
|
||||
|
||||
import {LogLevel} from 'lib/util/settings';
|
||||
|
||||
type OptionalProps<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
|
||||
|
||||
declare global {
|
||||
// Define some class types as global
|
||||
type EventBus = TypeEventBus;
|
||||
@@ -32,8 +33,7 @@ declare global {
|
||||
type Extension = TypeExtension;
|
||||
|
||||
// Types
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type ExternalDefinition = zhc.Definition & {homeassistant: any};
|
||||
type ExternalDefinition = zhc.Definition & {homeassistant: unknown};
|
||||
interface MQTTResponse {
|
||||
data: KeyValue;
|
||||
status: 'error' | 'ok';
|
||||
@@ -46,7 +46,7 @@ declare global {
|
||||
properties?: {messageExpiryInterval: number};
|
||||
}
|
||||
type Scene = {id: number; name: string};
|
||||
type StateChangeReason = 'publishDebounce' | 'groupOptimistic' | 'lastSeenChanged' | 'publishCached';
|
||||
type StateChangeReason = 'publishDebounce' | 'groupOptimistic' | 'lastSeenChanged' | 'publishCached' | 'publishThrottle';
|
||||
type PublishEntityState = (entity: Device | Group, payload: KeyValue, stateChangeReason?: StateChangeReason) => Promise<void>;
|
||||
type RecursivePartial<T> = {[P in keyof T]?: RecursivePartial<T[P]>};
|
||||
interface KeyValue {
|
||||
@@ -80,7 +80,7 @@ declare global {
|
||||
entity: Device | Group;
|
||||
from: KeyValue;
|
||||
to: KeyValue;
|
||||
reason: string | null;
|
||||
reason?: string;
|
||||
update: KeyValue;
|
||||
};
|
||||
type PermitJoinChanged = ZHEvents.PermitJoinChangedPayload;
|
||||
@@ -97,7 +97,7 @@ declare global {
|
||||
type Reconfigure = {device: Device};
|
||||
type DeviceLeave = {ieeeAddr: string; name: string};
|
||||
type GroupMembersChanged = {group: Group; action: 'remove' | 'add' | 'remove_all'; endpoint: zh.Endpoint; skipDisableReporting: boolean};
|
||||
type PublishEntityState = {entity: Group | Device; message: KeyValue; stateChangeReason: StateChangeReason; payload: KeyValue};
|
||||
type PublishEntityState = {entity: Group | Device; message: KeyValue; stateChangeReason?: StateChangeReason; payload: KeyValue};
|
||||
type DeviceMessage = {
|
||||
type: ZHEvents.MessagePayloadType;
|
||||
device: Device;
|
||||
@@ -112,15 +112,15 @@ declare global {
|
||||
}
|
||||
|
||||
// Settings
|
||||
// eslint-disable camelcase
|
||||
interface Settings {
|
||||
homeassistant?: {
|
||||
discovery_topic: string;
|
||||
status_topic: string;
|
||||
legacy_entity_attributes: boolean;
|
||||
legacy_triggers: boolean;
|
||||
experimental_event_entities: boolean;
|
||||
};
|
||||
permit_join?: boolean;
|
||||
permit_join: boolean;
|
||||
availability?: {
|
||||
active: {timeout: number};
|
||||
passive: {timeout: number};
|
||||
@@ -179,13 +179,14 @@ declare global {
|
||||
frontend?: {
|
||||
auth_token?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
port: number;
|
||||
base_url: string;
|
||||
url?: string;
|
||||
ssl_cert?: string;
|
||||
ssl_key?: string;
|
||||
};
|
||||
devices?: {[s: string]: DeviceOptions};
|
||||
groups?: {[s: string]: GroupOptions};
|
||||
devices: {[s: string]: DeviceOptions};
|
||||
groups: {[s: string]: OptionalProps<Omit<GroupOptions, 'ID'>, 'devices'>};
|
||||
device_options: KeyValue;
|
||||
advanced: {
|
||||
legacy_api: boolean;
|
||||
@@ -203,8 +204,8 @@ declare global {
|
||||
pan_id: number | 'GENERATE';
|
||||
ext_pan_id: number[] | 'GENERATE';
|
||||
channel: number;
|
||||
adapter_concurrent: number | null;
|
||||
adapter_delay: number | null;
|
||||
adapter_concurrent?: number;
|
||||
adapter_delay?: number;
|
||||
cache_state: boolean;
|
||||
cache_state_persistent: boolean;
|
||||
cache_state_send_on_startup: boolean;
|
||||
@@ -216,17 +217,16 @@ declare global {
|
||||
transmit_power?: number;
|
||||
// Everything below is deprecated
|
||||
availability_timeout?: number;
|
||||
availability_blocklist?: string[];
|
||||
availability_passlist?: string[];
|
||||
availability_blacklist?: string[];
|
||||
availability_whitelist?: string[];
|
||||
availability_blocklist: string[];
|
||||
availability_passlist: string[];
|
||||
availability_blacklist: string[];
|
||||
availability_whitelist: string[];
|
||||
soft_reset_timeout: number;
|
||||
report: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface DeviceOptions {
|
||||
ID?: string;
|
||||
disabled?: boolean;
|
||||
retention?: number;
|
||||
availability?: boolean | {timeout: number};
|
||||
@@ -234,6 +234,7 @@ declare global {
|
||||
retrieve_state?: boolean;
|
||||
debounce?: number;
|
||||
debounce_ignore?: string[];
|
||||
throttle?: number;
|
||||
filtered_attributes?: string[];
|
||||
filtered_cache?: string[];
|
||||
filtered_optimistic?: string[];
|
||||
@@ -245,9 +246,13 @@ declare global {
|
||||
qos?: 0 | 1 | 2;
|
||||
}
|
||||
|
||||
interface DeviceOptionsWithId extends DeviceOptions {
|
||||
ID: string;
|
||||
}
|
||||
|
||||
interface GroupOptions {
|
||||
devices?: string[];
|
||||
ID?: number;
|
||||
devices: string[];
|
||||
ID: number;
|
||||
optimistic?: boolean;
|
||||
off_state?: 'all_members_off' | 'last_member_state';
|
||||
filtered_attributes?: string[];
|
||||
|
||||
Vendored
+11
-4
@@ -2,8 +2,15 @@ declare module 'zigbee2mqtt-frontend' {
|
||||
export function getPath(): string;
|
||||
}
|
||||
|
||||
declare module 'connect-gzip-static' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type RequestHandler = (req: any, res: any) => void;
|
||||
export default function gzipStatic(root: string, options?: Record<string, unknown>): RequestHandler;
|
||||
declare module 'http' {
|
||||
interface IncomingMessage {
|
||||
originalUrl?: string;
|
||||
path?: string;
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'express-static-gzip' {
|
||||
import {IncomingMessage, ServerResponse} from 'http';
|
||||
export type RequestHandler = (req: IncomingMessage, res: ServerResponse, finalhandler: (err: unknown) => void) => void;
|
||||
export default function expressStaticGzip(root: string, options?: Record<string, unknown>): RequestHandler;
|
||||
}
|
||||
|
||||
+6
-14
@@ -1,17 +1,10 @@
|
||||
import path from 'path';
|
||||
|
||||
let dataPath: string = null;
|
||||
|
||||
function load(): void {
|
||||
if (process.env.ZIGBEE2MQTT_DATA) {
|
||||
dataPath = process.env.ZIGBEE2MQTT_DATA;
|
||||
} else {
|
||||
dataPath = path.join(__dirname, '..', '..', 'data');
|
||||
dataPath = path.normalize(dataPath);
|
||||
}
|
||||
function setPath(): string {
|
||||
return process.env.ZIGBEE2MQTT_DATA ? process.env.ZIGBEE2MQTT_DATA : path.normalize(path.join(__dirname, '..', '..', 'data'));
|
||||
}
|
||||
|
||||
load();
|
||||
let dataPath = setPath();
|
||||
|
||||
function joinPath(file: string): string {
|
||||
return path.resolve(dataPath, file);
|
||||
@@ -21,9 +14,8 @@ function getPath(): string {
|
||||
return dataPath;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line camelcase
|
||||
function testingOnlyReload(): void {
|
||||
load();
|
||||
function _testReload(): void {
|
||||
dataPath = setPath();
|
||||
}
|
||||
|
||||
export default {joinPath, getPath, testingOnlyReload};
|
||||
export default {joinPath, getPath, _testReload};
|
||||
|
||||
+22
-13
@@ -1,8 +1,9 @@
|
||||
import assert from 'assert';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import fx from 'mkdir-recursive';
|
||||
import moment from 'moment';
|
||||
import path from 'path';
|
||||
import {rimrafSync} from 'rimraf';
|
||||
import winston from 'winston';
|
||||
|
||||
@@ -11,13 +12,20 @@ import * as settings from './settings';
|
||||
const NAMESPACE_SEPARATOR = ':';
|
||||
|
||||
class Logger {
|
||||
// @ts-expect-error initalized in `init`
|
||||
private level: settings.LogLevel;
|
||||
// @ts-expect-error initalized in `init`
|
||||
private output: string[];
|
||||
// @ts-expect-error initalized in `init`
|
||||
private directory: string;
|
||||
// @ts-expect-error initalized in `init`
|
||||
private logger: winston.Logger;
|
||||
// @ts-expect-error initalized in `init`
|
||||
private fileTransport: winston.transports.FileTransportInstance;
|
||||
private debugNamespaceIgnoreRegex?: RegExp;
|
||||
// @ts-expect-error initalized in `init`
|
||||
private namespacedLevels: Record<string, settings.LogLevel>;
|
||||
// @ts-expect-error initalized in `init`
|
||||
private cachedNamespacedLevels: Record<string, settings.LogLevel>;
|
||||
|
||||
public init(): void {
|
||||
@@ -101,16 +109,16 @@ class Logger {
|
||||
/* istanbul ignore next */
|
||||
if (this.output.includes('syslog')) {
|
||||
logging += `, syslog`;
|
||||
// eslint-disable-next-line
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unused-expressions
|
||||
require('winston-syslog').Syslog;
|
||||
|
||||
const options: KeyValue = {
|
||||
app_name: 'Zigbee2MQTT',
|
||||
format: winston.format.printf((info) => info.message),
|
||||
format: winston.format.printf((info) => info.message as string),
|
||||
...settings.get().advanced.log_syslog,
|
||||
};
|
||||
|
||||
if (options.hasOwnProperty('type')) {
|
||||
if (options['type'] !== undefined) {
|
||||
options.type = options.type.toString();
|
||||
}
|
||||
|
||||
@@ -182,32 +190,33 @@ class Logger {
|
||||
return this.cachedNamespacedLevels[namespace];
|
||||
}
|
||||
|
||||
private log(level: settings.LogLevel, message: string, namespace: string): void {
|
||||
private log(level: settings.LogLevel, messageOrLambda: string | (() => string), namespace: string): void {
|
||||
const nsLevel = this.cacheNamespacedLevel(namespace);
|
||||
|
||||
if (settings.LOG_LEVELS.indexOf(level) <= settings.LOG_LEVELS.indexOf(nsLevel)) {
|
||||
const message: string = messageOrLambda instanceof Function ? messageOrLambda() : messageOrLambda;
|
||||
this.logger.log(level, `${namespace}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public error(message: string, namespace: string = 'z2m'): void {
|
||||
this.log('error', message, namespace);
|
||||
public error(messageOrLambda: string | (() => string), namespace: string = 'z2m'): void {
|
||||
this.log('error', messageOrLambda, namespace);
|
||||
}
|
||||
|
||||
public warning(message: string, namespace: string = 'z2m'): void {
|
||||
this.log('warning', message, namespace);
|
||||
public warning(messageOrLambda: string | (() => string), namespace: string = 'z2m'): void {
|
||||
this.log('warning', messageOrLambda, namespace);
|
||||
}
|
||||
|
||||
public info(message: string, namespace: string = 'z2m'): void {
|
||||
this.log('info', message, namespace);
|
||||
public info(messageOrLambda: string | (() => string), namespace: string = 'z2m'): void {
|
||||
this.log('info', messageOrLambda, namespace);
|
||||
}
|
||||
|
||||
public debug(message: string, namespace: string = 'z2m'): void {
|
||||
public debug(messageOrLambda: string | (() => string), namespace: string = 'z2m'): void {
|
||||
if (this.debugNamespaceIgnoreRegex?.test(namespace)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.log('debug', message, namespace);
|
||||
this.log('debug', messageOrLambda, namespace);
|
||||
}
|
||||
|
||||
// Cleanup any old log directory.
|
||||
|
||||
@@ -40,6 +40,12 @@
|
||||
"description": "Home Assistant status topic",
|
||||
"requiresRestart": true,
|
||||
"examples": ["homeassistant/status"]
|
||||
},
|
||||
"experimental_event_entities": {
|
||||
"type": "boolean",
|
||||
"title": "Home Assistant experimental event entities",
|
||||
"description": "Home Assistant experimental event entities, when enabled Zigbee2MQTT will add event entities for exposed actions. The events and attributes are currently deemed experimental and subject to change.",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -400,6 +406,14 @@
|
||||
"title": "key file path",
|
||||
"description": "SSL key file path for exposing HTTPS. The sibling property 'ssl_cert' must be set for HTTPS to be activated.",
|
||||
"requiresRestart": true
|
||||
},
|
||||
"base_url": {
|
||||
"type": "string",
|
||||
"pattern": "^\\/.*",
|
||||
"title": "Base URL",
|
||||
"description": "Base URL for the frontend. If hosted under a subpath, e.g. 'http://localhost:8080/z2m', set this to '/z2m'",
|
||||
"default": "/",
|
||||
"requiresRestart": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -950,7 +964,7 @@
|
||||
"title": "Group off state",
|
||||
"default": "auto",
|
||||
"requiresRestart": true,
|
||||
"description": "Control when to publish state OFF for a group. 'all_members_off': only publish state OFF when all group members are in state OFF, 'last_member_state': publish state OFF whenever one of its members changes to OFF"
|
||||
"description": "Control when to publish state OFF or CLOSE for a group. 'all_members_off': only publish state OFF/CLOSE when all group members are in state OFF/CLOSE, 'last_member_state': publish state OFF whenever one of its members changes to OFF"
|
||||
},
|
||||
"filtered_attributes": {
|
||||
"type": "array",
|
||||
|
||||
+163
-104
@@ -1,13 +1,15 @@
|
||||
import path from 'path';
|
||||
|
||||
import Ajv, {ValidateFunction} from 'ajv';
|
||||
import objectAssignDeep from 'object-assign-deep';
|
||||
import path from 'path';
|
||||
|
||||
import data from './data';
|
||||
import schemaJson from './settings.schema.json';
|
||||
import utils from './utils';
|
||||
import yaml from './yaml';
|
||||
export let schema = schemaJson;
|
||||
// @ts-expect-error
|
||||
import yaml, {YAMLFileException} from './yaml';
|
||||
|
||||
export let schema: KeyValue = schemaJson;
|
||||
|
||||
schema = {};
|
||||
objectAssignDeep(schema, schemaJson);
|
||||
|
||||
@@ -23,8 +25,8 @@ objectAssignDeep(schema, schemaJson);
|
||||
delete schema.properties.advanced.properties.rtscts;
|
||||
delete schema.properties.advanced.properties.ikea_ota_use_test_url;
|
||||
delete schema.properties.experimental;
|
||||
delete schemaJson.properties.whitelist;
|
||||
delete schemaJson.properties.ban;
|
||||
delete (schemaJson as KeyValue).properties.whitelist;
|
||||
delete (schemaJson as KeyValue).properties.ban;
|
||||
}
|
||||
|
||||
/** NOTE: by order of priority, lower index is lower level (more important) */
|
||||
@@ -96,8 +98,8 @@ const defaults: RecursivePartial<Settings> = {
|
||||
pan_id: 0x1a62,
|
||||
ext_pan_id: [0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd],
|
||||
channel: 11,
|
||||
adapter_concurrent: null,
|
||||
adapter_delay: null,
|
||||
adapter_concurrent: undefined,
|
||||
adapter_delay: undefined,
|
||||
cache_state: true,
|
||||
cache_state_persistent: true,
|
||||
cache_state_send_on_startup: true,
|
||||
@@ -116,10 +118,13 @@ const defaults: RecursivePartial<Settings> = {
|
||||
},
|
||||
};
|
||||
|
||||
let _settings: Partial<Settings>;
|
||||
let _settingsWithDefaults: Settings;
|
||||
let _settings: Partial<Settings> | undefined;
|
||||
let _settingsWithDefaults: Settings | undefined;
|
||||
|
||||
function loadSettingsWithDefaults(): void {
|
||||
if (!_settings) {
|
||||
_settings = read();
|
||||
}
|
||||
_settingsWithDefaults = objectAssignDeep({}, defaults, getInternalSettings()) as Settings;
|
||||
|
||||
if (!_settingsWithDefaults.devices) {
|
||||
@@ -131,7 +136,13 @@ function loadSettingsWithDefaults(): void {
|
||||
}
|
||||
|
||||
if (_settingsWithDefaults.homeassistant) {
|
||||
const defaults = {discovery_topic: 'homeassistant', status_topic: 'hass/status', legacy_entity_attributes: true, legacy_triggers: true};
|
||||
const defaults = {
|
||||
discovery_topic: 'homeassistant',
|
||||
status_topic: 'hass/status',
|
||||
legacy_entity_attributes: true,
|
||||
legacy_triggers: true,
|
||||
experimental_event_entities: false,
|
||||
};
|
||||
const sLegacy = {};
|
||||
if (_settingsWithDefaults.advanced) {
|
||||
for (const key of [
|
||||
@@ -140,59 +151,66 @@ function loadSettingsWithDefaults(): void {
|
||||
'homeassistant_legacy_entity_attributes',
|
||||
'homeassistant_status_topic',
|
||||
]) {
|
||||
// @ts-expect-error
|
||||
// @ts-expect-error ignore typing
|
||||
if (_settingsWithDefaults.advanced[key] !== undefined) {
|
||||
// @ts-expect-error
|
||||
// @ts-expect-error ignore typing
|
||||
sLegacy[key.replace('homeassistant_', '')] = _settingsWithDefaults.advanced[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const s = typeof _settingsWithDefaults.homeassistant === 'object' ? _settingsWithDefaults.homeassistant : {};
|
||||
// @ts-expect-error
|
||||
// @ts-expect-error ignore typing
|
||||
_settingsWithDefaults.homeassistant = {};
|
||||
// @ts-expect-error ignore typing
|
||||
objectAssignDeep(_settingsWithDefaults.homeassistant, defaults, sLegacy, s);
|
||||
}
|
||||
|
||||
if (_settingsWithDefaults.availability || _settingsWithDefaults.advanced?.availability_timeout) {
|
||||
const defaults = {};
|
||||
const s = typeof _settingsWithDefaults.availability === 'object' ? _settingsWithDefaults.availability : {};
|
||||
// @ts-expect-error
|
||||
// @ts-expect-error ignore typing
|
||||
_settingsWithDefaults.availability = {};
|
||||
// @ts-expect-error ignore typing
|
||||
objectAssignDeep(_settingsWithDefaults.availability, defaults, s);
|
||||
}
|
||||
|
||||
if (_settingsWithDefaults.frontend) {
|
||||
const defaults = {port: 8080, auth_token: false};
|
||||
const defaults = {port: 8080, auth_token: null, base_url: '/'};
|
||||
const s = typeof _settingsWithDefaults.frontend === 'object' ? _settingsWithDefaults.frontend : {};
|
||||
// @ts-expect-error ignore typing
|
||||
_settingsWithDefaults.frontend = {};
|
||||
// @ts-expect-error ignore typing
|
||||
objectAssignDeep(_settingsWithDefaults.frontend, defaults, s);
|
||||
}
|
||||
|
||||
if (_settings.advanced?.hasOwnProperty('baudrate') && _settings.serial?.baudrate == null) {
|
||||
// @ts-expect-error
|
||||
// @ts-expect-error ignore typing
|
||||
if (_settings.advanced?.baudrate !== undefined && _settings.serial?.baudrate == null) {
|
||||
// @ts-expect-error ignore typing
|
||||
_settingsWithDefaults.serial.baudrate = _settings.advanced.baudrate;
|
||||
}
|
||||
|
||||
if (_settings.advanced?.hasOwnProperty('rtscts') && _settings.serial?.rtscts == null) {
|
||||
// @ts-expect-error
|
||||
// @ts-expect-error ignore typing
|
||||
if (_settings.advanced?.rtscts !== undefined && _settings.serial?.rtscts == null) {
|
||||
// @ts-expect-error ignore typing
|
||||
_settingsWithDefaults.serial.rtscts = _settings.advanced.rtscts;
|
||||
}
|
||||
|
||||
if (_settings.advanced?.hasOwnProperty('ikea_ota_use_test_url') && _settings.ota?.ikea_ota_use_test_url == null) {
|
||||
// @ts-expect-error
|
||||
// @ts-expect-error ignore typing
|
||||
if (_settings.advanced?.ikea_ota_use_test_url !== undefined && _settings.ota?.ikea_ota_use_test_url == null) {
|
||||
// @ts-expect-error ignore typing
|
||||
_settingsWithDefaults.ota.ikea_ota_use_test_url = _settings.advanced.ikea_ota_use_test_url;
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
if (_settings.experimental?.hasOwnProperty('transmit_power') && _settings.advanced?.transmit_power == null) {
|
||||
// @ts-expect-error
|
||||
// @ts-expect-error ignore typing
|
||||
if (_settings.experimental?.transmit_power !== undefined && _settings.advanced?.transmit_power == null) {
|
||||
// @ts-expect-error ignore typing
|
||||
_settingsWithDefaults.advanced.transmit_power = _settings.experimental.transmit_power;
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
if (_settings.experimental?.hasOwnProperty('output') && _settings.advanced?.output == null) {
|
||||
// @ts-expect-error
|
||||
// @ts-expect-error ignore typing
|
||||
if (_settings.experimental?.output !== undefined && _settings.advanced?.output == null) {
|
||||
// @ts-expect-error ignore typing
|
||||
_settingsWithDefaults.advanced.output = _settings.experimental.output;
|
||||
}
|
||||
|
||||
@@ -200,15 +218,15 @@ function loadSettingsWithDefaults(): void {
|
||||
_settingsWithDefaults.advanced.log_level = 'warning';
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
// @ts-expect-error ignore typing
|
||||
if (_settingsWithDefaults.ban) {
|
||||
// @ts-expect-error
|
||||
// @ts-expect-error ignore typing
|
||||
_settingsWithDefaults.blocklist.push(..._settingsWithDefaults.ban);
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
// @ts-expect-error ignore typing
|
||||
if (_settingsWithDefaults.whitelist) {
|
||||
// @ts-expect-error
|
||||
// @ts-expect-error ignore typing
|
||||
_settingsWithDefaults.passlist.push(..._settingsWithDefaults.whitelist);
|
||||
}
|
||||
}
|
||||
@@ -259,12 +277,12 @@ function write(): void {
|
||||
|
||||
// If an array, only write to first file and only devices which are not in the other files.
|
||||
if (Array.isArray(actual[type])) {
|
||||
actual[type]
|
||||
.filter((f: string, i: number) => i !== 0)
|
||||
.map((f: string) => yaml.readIfExists(data.joinPath(f), {}))
|
||||
.map((c: KeyValue) => Object.keys(c))
|
||||
// @ts-expect-error
|
||||
.forEach((k: string) => delete content[k]);
|
||||
// skip i==0
|
||||
for (let i = 1; i < actual[type].length; i++) {
|
||||
for (const key in yaml.readIfExists(data.joinPath(actual[type][i]))) {
|
||||
delete content[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
yaml.writeIfChanged(data.joinPath(fileToWrite), content);
|
||||
@@ -285,18 +303,20 @@ export function validate(): string[] {
|
||||
try {
|
||||
getInternalSettings();
|
||||
} catch (error) {
|
||||
if (error.name === 'YAMLException') {
|
||||
if (error instanceof YAMLFileException) {
|
||||
return [`Your YAML file: '${error.file}' is invalid (use https://jsonformatter.org/yaml-validator to find and fix the issue)`];
|
||||
}
|
||||
|
||||
return [error.message];
|
||||
return [`${error}`];
|
||||
}
|
||||
|
||||
if (!ajvSetting(_settings)) {
|
||||
return ajvSetting.errors.map((v) => `${v.instancePath.substring(1)} ${v.message}`);
|
||||
// When `ajvSetting()` return false it always has `errors`.
|
||||
return ajvSetting.errors!.map((v) => `${v.instancePath.substring(1)} ${v.message}`);
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
|
||||
if (
|
||||
_settings.advanced &&
|
||||
_settings.advanced.network_key &&
|
||||
@@ -368,14 +388,14 @@ function read(): Settings {
|
||||
applyEnvironmentVariables(s);
|
||||
|
||||
// Read !secret MQTT username and password if set
|
||||
// eslint-disable-next-line
|
||||
const interpretValue = (value: any): any => {
|
||||
const ref = parseValueRef(value);
|
||||
if (ref) {
|
||||
return yaml.read(data.joinPath(ref.filename))[ref.key];
|
||||
} else {
|
||||
return value;
|
||||
const interpretValue = <T>(value: T): T => {
|
||||
if (typeof value === 'string') {
|
||||
const ref = parseValueRef(value);
|
||||
if (ref) {
|
||||
return yaml.read(data.joinPath(ref.filename))[ref.key];
|
||||
}
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
if (s.mqtt?.user) {
|
||||
@@ -401,11 +421,10 @@ function read(): Settings {
|
||||
// Read devices/groups configuration from separate file if specified.
|
||||
const readDevicesOrGroups = (type: 'devices' | 'groups'): void => {
|
||||
if (typeof s[type] === 'string' || (Array.isArray(s[type]) && Array(s[type]).length > 0)) {
|
||||
/* eslint-disable-line */
|
||||
const files: string[] = Array.isArray(s[type]) ? s[type] : [s[type]];
|
||||
s[type] = {};
|
||||
for (const file of files) {
|
||||
const content = yaml.readIfExists(data.joinPath(file), {});
|
||||
const content = yaml.readIfExists(data.joinPath(file));
|
||||
/* eslint-disable-line */ // @ts-expect-error
|
||||
s[type] = objectAssignDeep.noMutate(s[type], content);
|
||||
}
|
||||
@@ -420,35 +439,41 @@ function read(): Settings {
|
||||
|
||||
function applyEnvironmentVariables(settings: Partial<Settings>): void {
|
||||
const iterate = (obj: KeyValue, path: string[]): void => {
|
||||
Object.keys(obj).forEach((key) => {
|
||||
for (const key in obj) {
|
||||
if (key !== 'type') {
|
||||
if (key !== 'properties' && obj[key]) {
|
||||
const type = (obj[key].type || 'object').toString();
|
||||
const envPart = path.reduce((acc, val) => `${acc}${val}_`, '');
|
||||
const envVariableName = `ZIGBEE2MQTT_CONFIG_${envPart}${key}`.toUpperCase();
|
||||
if (process.env[envVariableName]) {
|
||||
const envVariable = process.env[envVariableName];
|
||||
|
||||
if (envVariable) {
|
||||
const setting = path.reduce((acc, val) => {
|
||||
/* eslint-disable-line */ // @ts-expect-error
|
||||
// @ts-expect-error ignore typing
|
||||
acc[val] = acc[val] || {};
|
||||
/* eslint-disable-line */ // @ts-expect-error
|
||||
// @ts-expect-error ignore typing
|
||||
return acc[val];
|
||||
}, settings);
|
||||
|
||||
if (type.indexOf('object') >= 0 || type.indexOf('array') >= 0) {
|
||||
try {
|
||||
setting[key] = JSON.parse(process.env[envVariableName]);
|
||||
// @ts-expect-error ignore typing
|
||||
setting[key] = JSON.parse(envVariable);
|
||||
} catch {
|
||||
setting[key] = process.env[envVariableName];
|
||||
// @ts-expect-error ignore typing
|
||||
setting[key] = envVariable;
|
||||
}
|
||||
} else if (type.indexOf('number') >= 0) {
|
||||
/* eslint-disable-line */ // @ts-expect-error
|
||||
setting[key] = process.env[envVariableName] * 1;
|
||||
// @ts-expect-error ignore typing
|
||||
setting[key] = (envVariable as unknown as number) * 1;
|
||||
} else if (type.indexOf('boolean') >= 0) {
|
||||
setting[key] = process.env[envVariableName].toLowerCase() === 'true';
|
||||
// @ts-expect-error ignore typing
|
||||
setting[key] = envVariable.toLowerCase() === 'true';
|
||||
} else {
|
||||
/* istanbul ignore else */
|
||||
if (type.indexOf('string') >= 0) {
|
||||
setting[key] = process.env[envVariableName];
|
||||
// @ts-expect-error ignore typing
|
||||
setting[key] = envVariable;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -456,14 +481,17 @@ function applyEnvironmentVariables(settings: Partial<Settings>): void {
|
||||
|
||||
if (typeof obj[key] === 'object' && obj[key]) {
|
||||
const newPath = [...path];
|
||||
|
||||
if (key !== 'properties' && key !== 'oneOf' && !Number.isInteger(Number(key))) {
|
||||
newPath.push(key);
|
||||
}
|
||||
|
||||
iterate(obj[key], newPath);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
iterate(schemaJson.properties, []);
|
||||
}
|
||||
|
||||
@@ -480,7 +508,7 @@ export function get(): Settings {
|
||||
loadSettingsWithDefaults();
|
||||
}
|
||||
|
||||
return _settingsWithDefaults;
|
||||
return _settingsWithDefaults!;
|
||||
}
|
||||
|
||||
export function set(path: string[], value: string | number | boolean | KeyValue): void {
|
||||
@@ -505,7 +533,7 @@ export function set(path: string[], value: string | number | boolean | KeyValue)
|
||||
|
||||
export function apply(settings: Record<string, unknown>): boolean {
|
||||
getInternalSettings(); // Ensure _settings is initialized.
|
||||
/* eslint-disable-line */ // @ts-expect-error
|
||||
// @ts-expect-error ignore typing
|
||||
const newSettings = objectAssignDeep.noMutate(_settings, settings);
|
||||
utils.removeNullPropertiesFromObject(newSettings, NULLABLE_SETTINGS);
|
||||
ajvSetting(newSettings);
|
||||
@@ -519,13 +547,16 @@ export function apply(settings: Record<string, unknown>): boolean {
|
||||
write();
|
||||
|
||||
ajvRestartRequired(settings);
|
||||
const restartRequired = ajvRestartRequired.errors && !!ajvRestartRequired.errors.find((e) => e.keyword === 'requiresRestart');
|
||||
|
||||
const restartRequired = Boolean(ajvRestartRequired.errors && !!ajvRestartRequired.errors.find((e) => e.keyword === 'requiresRestart'));
|
||||
|
||||
return restartRequired;
|
||||
}
|
||||
|
||||
export function getGroup(IDorName: string | number): GroupOptions {
|
||||
export function getGroup(IDorName: string | number): GroupOptions | undefined {
|
||||
const settings = get();
|
||||
const byID = settings.groups[IDorName];
|
||||
|
||||
if (byID) {
|
||||
return {devices: [], ...byID, ID: Number(IDorName)};
|
||||
}
|
||||
@@ -536,11 +567,12 @@ export function getGroup(IDorName: string | number): GroupOptions {
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getGroups(): GroupOptions[] {
|
||||
const settings = get();
|
||||
|
||||
return Object.entries(settings.groups).map(([ID, group]) => {
|
||||
return {devices: [], ...group, ID: Number(ID)};
|
||||
});
|
||||
@@ -548,6 +580,7 @@ export function getGroups(): GroupOptions[] {
|
||||
|
||||
function getGroupThrowIfNotExists(IDorName: string): GroupOptions {
|
||||
const group = getGroup(IDorName);
|
||||
|
||||
if (!group) {
|
||||
throw new Error(`Group '${IDorName}' does not exist`);
|
||||
}
|
||||
@@ -555,9 +588,10 @@ function getGroupThrowIfNotExists(IDorName: string): GroupOptions {
|
||||
return group;
|
||||
}
|
||||
|
||||
export function getDevice(IDorName: string): DeviceOptions {
|
||||
export function getDevice(IDorName: string): DeviceOptionsWithId | undefined {
|
||||
const settings = get();
|
||||
const byID = settings.devices[IDorName];
|
||||
|
||||
if (byID) {
|
||||
return {...byID, ID: IDorName};
|
||||
}
|
||||
@@ -568,10 +602,10 @@ export function getDevice(IDorName: string): DeviceOptions {
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getDeviceThrowIfNotExists(IDorName: string): DeviceOptions {
|
||||
function getDeviceThrowIfNotExists(IDorName: string): DeviceOptionsWithId {
|
||||
const device = getDevice(IDorName);
|
||||
if (!device) {
|
||||
throw new Error(`Device '${IDorName}' does not exist`);
|
||||
@@ -580,7 +614,7 @@ function getDeviceThrowIfNotExists(IDorName: string): DeviceOptions {
|
||||
return device;
|
||||
}
|
||||
|
||||
export function addDevice(ID: string): DeviceOptions {
|
||||
export function addDevice(ID: string): DeviceOptionsWithId {
|
||||
if (getDevice(ID)) {
|
||||
throw new Error(`Device '${ID}' already exists`);
|
||||
}
|
||||
@@ -593,7 +627,8 @@ export function addDevice(ID: string): DeviceOptions {
|
||||
|
||||
settings.devices[ID] = {friendly_name: ID};
|
||||
write();
|
||||
return getDevice(ID);
|
||||
|
||||
return getDevice(ID)!; // valid from creation above
|
||||
}
|
||||
|
||||
export function addDeviceToPasslist(ID: string): void {
|
||||
@@ -623,13 +658,14 @@ export function blockDevice(ID: string): void {
|
||||
export function removeDevice(IDorName: string): void {
|
||||
const device = getDeviceThrowIfNotExists(IDorName);
|
||||
const settings = getInternalSettings();
|
||||
delete settings.devices[device.ID];
|
||||
delete settings.devices?.[device.ID];
|
||||
|
||||
// Remove device from groups
|
||||
if (settings.groups) {
|
||||
const regex = new RegExp(`^(${device.friendly_name}|${device.ID})(/[^/]+)?$`);
|
||||
|
||||
for (const group of Object.values(settings.groups).filter((g) => g.devices)) {
|
||||
group.devices = group.devices.filter((device) => !device.match(regex));
|
||||
group.devices = group.devices?.filter((device) => !device.match(regex));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -638,6 +674,7 @@ export function removeDevice(IDorName: string): void {
|
||||
|
||||
export function addGroup(name: string, ID?: string): GroupOptions {
|
||||
utils.validateFriendlyName(name, true);
|
||||
|
||||
if (getGroup(name) || getDevice(name)) {
|
||||
throw new Error(`friendly_name '${name}' is already in use`);
|
||||
}
|
||||
@@ -647,16 +684,18 @@ export function addGroup(name: string, ID?: string): GroupOptions {
|
||||
settings.groups = {};
|
||||
}
|
||||
|
||||
if (ID == null) {
|
||||
if (ID == undefined) {
|
||||
// look for free ID
|
||||
ID = '1';
|
||||
while (settings.groups.hasOwnProperty(ID)) {
|
||||
|
||||
while (settings.groups[ID]) {
|
||||
ID = (Number.parseInt(ID) + 1).toString();
|
||||
}
|
||||
} else {
|
||||
// ensure provided ID is not in use
|
||||
ID = ID.toString();
|
||||
if (settings.groups.hasOwnProperty(ID)) {
|
||||
|
||||
if (settings.groups[ID]) {
|
||||
throw new Error(`Group ID '${ID}' is already in use`);
|
||||
}
|
||||
}
|
||||
@@ -664,22 +703,25 @@ export function addGroup(name: string, ID?: string): GroupOptions {
|
||||
settings.groups[ID] = {friendly_name: name};
|
||||
write();
|
||||
|
||||
return getGroup(ID);
|
||||
return getGroup(ID)!; // valid from creation above
|
||||
}
|
||||
|
||||
function groupGetDevice(group: {devices?: string[]}, keys: string[]): string {
|
||||
function groupGetDevice(group: {devices?: string[]}, keys: string[]): string | undefined {
|
||||
for (const device of group.devices ?? []) {
|
||||
if (keys.includes(device)) return device;
|
||||
if (keys.includes(device)) {
|
||||
return device;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function addDeviceToGroup(IDorName: string, keys: string[]): void {
|
||||
const groupID = getGroupThrowIfNotExists(IDorName).ID;
|
||||
const groupID = getGroupThrowIfNotExists(IDorName).ID!;
|
||||
const settings = getInternalSettings();
|
||||
|
||||
const group = settings.groups[groupID];
|
||||
const group = settings.groups![groupID];
|
||||
|
||||
if (!groupGetDevice(group, keys)) {
|
||||
if (!group.devices) group.devices = [];
|
||||
group.devices.push(keys[0]);
|
||||
@@ -688,14 +730,16 @@ export function addDeviceToGroup(IDorName: string, keys: string[]): void {
|
||||
}
|
||||
|
||||
export function removeDeviceFromGroup(IDorName: string, keys: string[]): void {
|
||||
const groupID = getGroupThrowIfNotExists(IDorName).ID;
|
||||
const groupID = getGroupThrowIfNotExists(IDorName).ID!;
|
||||
const settings = getInternalSettings();
|
||||
const group = settings.groups[groupID];
|
||||
const group = settings.groups![groupID];
|
||||
|
||||
if (!group.devices) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = groupGetDevice(group, keys);
|
||||
|
||||
if (key) {
|
||||
group.devices = group.devices.filter((d) => d != key);
|
||||
write();
|
||||
@@ -703,9 +747,10 @@ export function removeDeviceFromGroup(IDorName: string, keys: string[]): void {
|
||||
}
|
||||
|
||||
export function removeGroup(IDorName: string | number): void {
|
||||
const groupID = getGroupThrowIfNotExists(IDorName.toString()).ID;
|
||||
const groupID = getGroupThrowIfNotExists(IDorName.toString()).ID!;
|
||||
const settings = getInternalSettings();
|
||||
delete settings.groups[groupID];
|
||||
|
||||
delete settings.groups![groupID];
|
||||
write();
|
||||
}
|
||||
|
||||
@@ -714,21 +759,29 @@ export function changeEntityOptions(IDorName: string, newOptions: KeyValue): boo
|
||||
delete newOptions.friendly_name;
|
||||
delete newOptions.devices;
|
||||
let validator: ValidateFunction;
|
||||
if (getDevice(IDorName)) {
|
||||
objectAssignDeep(settings.devices[getDevice(IDorName).ID], newOptions);
|
||||
utils.removeNullPropertiesFromObject(settings.devices[getDevice(IDorName).ID], NULLABLE_SETTINGS);
|
||||
const device = getDevice(IDorName);
|
||||
|
||||
if (device) {
|
||||
objectAssignDeep(settings.devices![device.ID], newOptions);
|
||||
utils.removeNullPropertiesFromObject(settings.devices![device.ID], NULLABLE_SETTINGS);
|
||||
validator = ajvRestartRequiredDeviceOptions;
|
||||
} else if (getGroup(IDorName)) {
|
||||
objectAssignDeep(settings.groups[getGroup(IDorName).ID], newOptions);
|
||||
utils.removeNullPropertiesFromObject(settings.groups[getGroup(IDorName).ID], NULLABLE_SETTINGS);
|
||||
validator = ajvRestartRequiredGroupOptions;
|
||||
} else {
|
||||
throw new Error(`Device or group '${IDorName}' does not exist`);
|
||||
const group = getGroup(IDorName);
|
||||
|
||||
if (group) {
|
||||
objectAssignDeep(settings.groups![group.ID], newOptions);
|
||||
utils.removeNullPropertiesFromObject(settings.groups![group.ID], NULLABLE_SETTINGS);
|
||||
validator = ajvRestartRequiredGroupOptions;
|
||||
} else {
|
||||
throw new Error(`Device or group '${IDorName}' does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
write();
|
||||
validator(newOptions);
|
||||
const restartRequired = validator.errors && !!validator.errors.find((e) => e.keyword === 'requiresRestart');
|
||||
|
||||
const restartRequired = Boolean(validator.errors && !!validator.errors.find((e) => e.keyword === 'requiresRestart'));
|
||||
|
||||
return restartRequired;
|
||||
}
|
||||
|
||||
@@ -739,29 +792,35 @@ export function changeFriendlyName(IDorName: string, newName: string): void {
|
||||
}
|
||||
|
||||
const settings = getInternalSettings();
|
||||
if (getDevice(IDorName)) {
|
||||
settings.devices[getDevice(IDorName).ID].friendly_name = newName;
|
||||
} else if (getGroup(IDorName)) {
|
||||
settings.groups[getGroup(IDorName).ID].friendly_name = newName;
|
||||
const device = getDevice(IDorName);
|
||||
|
||||
if (device) {
|
||||
settings.devices![device.ID].friendly_name = newName;
|
||||
} else {
|
||||
throw new Error(`Device or group '${IDorName}' does not exist`);
|
||||
const group = getGroup(IDorName);
|
||||
|
||||
if (group) {
|
||||
settings.groups![group.ID].friendly_name = newName;
|
||||
} else {
|
||||
throw new Error(`Device or group '${IDorName}' does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
write();
|
||||
}
|
||||
|
||||
export function reRead(): void {
|
||||
_settings = null;
|
||||
_settings = undefined;
|
||||
getInternalSettings();
|
||||
_settingsWithDefaults = null;
|
||||
_settingsWithDefaults = undefined;
|
||||
get();
|
||||
}
|
||||
|
||||
export const testing = {
|
||||
write,
|
||||
clear: (): void => {
|
||||
_settings = null;
|
||||
_settingsWithDefaults = null;
|
||||
_settings = undefined;
|
||||
_settingsWithDefaults = undefined;
|
||||
},
|
||||
defaults,
|
||||
};
|
||||
|
||||
+66
-42
@@ -1,11 +1,13 @@
|
||||
import type * as zhc from 'zigbee-herdsman-converters';
|
||||
|
||||
import equals from 'fast-deep-equal/es6';
|
||||
import assert from 'assert';
|
||||
import fs from 'fs';
|
||||
import humanizeDuration from 'humanize-duration';
|
||||
import path from 'path';
|
||||
import vm from 'vm';
|
||||
|
||||
import equals from 'fast-deep-equal/es6';
|
||||
import humanizeDuration from 'humanize-duration';
|
||||
|
||||
import data from './data';
|
||||
|
||||
// construct a local ISO8601 string (instead of UTC-based)
|
||||
@@ -43,19 +45,19 @@ function capitalize(s: string): string {
|
||||
return s[0].toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
async function getZigbee2MQTTVersion(includeCommitHash = true): Promise<{commitHash: string; version: string}> {
|
||||
async function getZigbee2MQTTVersion(includeCommitHash = true): Promise<{commitHash?: string; version: string}> {
|
||||
const git = await import('git-last-commit');
|
||||
const packageJSON = await import('../..' + '/package.json');
|
||||
|
||||
if (!includeCommitHash) {
|
||||
return {version: packageJSON.version, commitHash: null};
|
||||
return {version: packageJSON.version, commitHash: undefined};
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
return await new Promise((resolve) => {
|
||||
const version = packageJSON.version;
|
||||
|
||||
git.getLastCommit((err: Error, commit: {shortHash: string}) => {
|
||||
let commitHash = null;
|
||||
let commitHash = undefined;
|
||||
|
||||
if (err) {
|
||||
try {
|
||||
@@ -100,7 +102,7 @@ function objectIsEmpty(object: object): boolean {
|
||||
|
||||
function objectHasProperties(object: {[s: string]: unknown}, properties: string[]): boolean {
|
||||
for (const property of properties) {
|
||||
if (!object.hasOwnProperty(property)) {
|
||||
if (object[property] === undefined) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -119,15 +121,20 @@ function equalsPartial(object: KeyValue, expected: KeyValue): boolean {
|
||||
}
|
||||
|
||||
function getObjectProperty(object: KeyValue, key: string, defaultValue: unknown): unknown {
|
||||
return object && object.hasOwnProperty(key) ? object[key] : defaultValue;
|
||||
return object && object[key] !== undefined ? object[key] : defaultValue;
|
||||
}
|
||||
|
||||
function getResponse(request: KeyValue | string, data: KeyValue, error: string): MQTTResponse {
|
||||
function getResponse(request: KeyValue | string, data: KeyValue, error?: string): MQTTResponse {
|
||||
const response: MQTTResponse = {data, status: error ? 'error' : 'ok'};
|
||||
if (error) response.error = error;
|
||||
if (typeof request === 'object' && request.hasOwnProperty('transaction')) {
|
||||
|
||||
if (error) {
|
||||
response.error = error;
|
||||
}
|
||||
|
||||
if (typeof request === 'object' && request['transaction'] !== undefined) {
|
||||
response.transaction = request.transaction;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -204,24 +211,24 @@ function toNetworkAddressHex(value: number): string {
|
||||
return `0x${'0'.repeat(4 - hex.length)}${hex}`;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
function toSnakeCase(value: string | KeyValue): any {
|
||||
if (typeof value === 'object') {
|
||||
value = {...value};
|
||||
for (const key of Object.keys(value)) {
|
||||
const keySnakeCase = toSnakeCase(key);
|
||||
if (key !== keySnakeCase) {
|
||||
value[keySnakeCase] = value[key];
|
||||
delete value[key];
|
||||
}
|
||||
function toSnakeCaseObject(value: KeyValue): KeyValue {
|
||||
value = {...value};
|
||||
for (const key of Object.keys(value)) {
|
||||
const keySnakeCase = toSnakeCaseString(key);
|
||||
assert(typeof keySnakeCase === 'string');
|
||||
if (key !== keySnakeCase) {
|
||||
value[keySnakeCase] = value[key];
|
||||
delete value[key];
|
||||
}
|
||||
return value;
|
||||
} else {
|
||||
return value
|
||||
.replace(/\.?([A-Z])/g, (x, y) => '_' + y.toLowerCase())
|
||||
.replace(/^_/, '')
|
||||
.replace('_i_d', '_id');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function toSnakeCaseString(value: string): string {
|
||||
return value
|
||||
.replace(/\.?([A-Z])/g, (x, y) => '_' + y.toLowerCase())
|
||||
.replace(/^_/, '')
|
||||
.replace('_i_d', '_id');
|
||||
}
|
||||
|
||||
function charRange(start: string, stop: string): number[] {
|
||||
@@ -319,12 +326,12 @@ function isAvailabilityEnabledForEntity(entity: Device | Group, settings: Settin
|
||||
return true;
|
||||
}
|
||||
|
||||
function isEndpoint(obj: unknown): obj is zh.Endpoint {
|
||||
return obj.constructor.name.toLowerCase() === 'endpoint';
|
||||
function isZHEndpoint(obj: unknown): obj is zh.Endpoint {
|
||||
return obj?.constructor.name.toLowerCase() === 'endpoint';
|
||||
}
|
||||
|
||||
function flatten<Type>(arr: Type[][]): Type[] {
|
||||
return [].concat(...arr);
|
||||
return ([] as Type[]).concat(...arr);
|
||||
}
|
||||
|
||||
function arrayUnique<Type>(arr: Type[]): Type[] {
|
||||
@@ -332,7 +339,7 @@ function arrayUnique<Type>(arr: Type[]): Type[] {
|
||||
}
|
||||
|
||||
function isZHGroup(obj: unknown): obj is zh.Group {
|
||||
return obj.constructor.name.toLowerCase() === 'group';
|
||||
return obj?.constructor.name.toLowerCase() === 'group';
|
||||
}
|
||||
|
||||
function availabilityPayload(state: 'online' | 'offline', settings: Settings): string {
|
||||
@@ -362,7 +369,7 @@ async function publishLastSeen(
|
||||
}
|
||||
}
|
||||
|
||||
function filterProperties(filter: string[], data: KeyValue): void {
|
||||
function filterProperties(filter: string[] | undefined, data: KeyValue): void {
|
||||
if (filter) {
|
||||
for (const property of Object.keys(data)) {
|
||||
if (filter.find((p) => property.match(`^${p}$`))) {
|
||||
@@ -372,22 +379,38 @@ function filterProperties(filter: string[], data: KeyValue): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function isNumericExposeFeature(feature: zhc.Expose): feature is zhc.Numeric {
|
||||
return feature?.type === 'numeric';
|
||||
export function isNumericExpose(expose: zhc.Expose): expose is zhc.Numeric {
|
||||
return expose?.type === 'numeric';
|
||||
}
|
||||
|
||||
export function isEnumExposeFeature(feature: zhc.Expose): feature is zhc.Enum {
|
||||
return feature?.type === 'enum';
|
||||
export function assertEnumExpose(expose: zhc.Expose): asserts expose is zhc.Enum {
|
||||
assert(expose?.type === 'enum');
|
||||
}
|
||||
|
||||
export function isBinaryExposeFeature(feature: zhc.Expose): feature is zhc.Binary {
|
||||
return feature?.type === 'binary';
|
||||
export function assertNumericExpose(expose: zhc.Expose): asserts expose is zhc.Numeric {
|
||||
assert(expose?.type === 'numeric');
|
||||
}
|
||||
|
||||
export function assertBinaryExpose(expose: zhc.Expose): asserts expose is zhc.Binary {
|
||||
assert(expose?.type === 'binary');
|
||||
}
|
||||
|
||||
export function isEnumExpose(expose: zhc.Expose): expose is zhc.Enum {
|
||||
return expose?.type === 'enum';
|
||||
}
|
||||
|
||||
export function isBinaryExpose(expose: zhc.Expose): expose is zhc.Binary {
|
||||
return expose?.type === 'binary';
|
||||
}
|
||||
|
||||
export function isLightExpose(expose: zhc.Expose): expose is zhc.Light {
|
||||
return expose.type === 'light';
|
||||
}
|
||||
|
||||
function getScenes(entity: zh.Endpoint | zh.Group): Scene[] {
|
||||
const scenes: {[id: number]: Scene} = {};
|
||||
const endpoints = isEndpoint(entity) ? [entity] : entity.members;
|
||||
const groupID = isEndpoint(entity) ? 0 : entity.groupID;
|
||||
const endpoints = isZHEndpoint(entity) ? [entity] : entity.members;
|
||||
const groupID = isZHEndpoint(entity) ? 0 : entity.groupID;
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
for (const [key, data] of Object.entries(endpoint.meta?.scenes || {})) {
|
||||
@@ -425,8 +448,9 @@ export default {
|
||||
loadModuleFromFile,
|
||||
removeNullPropertiesFromObject,
|
||||
toNetworkAddressHex,
|
||||
toSnakeCase,
|
||||
isEndpoint,
|
||||
toSnakeCaseString,
|
||||
toSnakeCaseObject,
|
||||
isZHEndpoint,
|
||||
isZHGroup,
|
||||
hours,
|
||||
minutes,
|
||||
|
||||
+22
-6
@@ -1,26 +1,42 @@
|
||||
import equals from 'fast-deep-equal/es6';
|
||||
import fs from 'fs';
|
||||
import yaml from 'js-yaml';
|
||||
|
||||
import equals from 'fast-deep-equal/es6';
|
||||
import yaml, {YAMLException} from 'js-yaml';
|
||||
|
||||
export class YAMLFileException extends YAMLException {
|
||||
file: string;
|
||||
|
||||
constructor(error: YAMLException, file: string) {
|
||||
super(error.reason, error.mark);
|
||||
|
||||
this.name = 'YAMLFileException';
|
||||
this.cause = error.cause;
|
||||
this.message = error.message;
|
||||
this.stack = error.stack;
|
||||
this.file = file;
|
||||
}
|
||||
}
|
||||
|
||||
function read(file: string): KeyValue {
|
||||
try {
|
||||
const result = yaml.load(fs.readFileSync(file, 'utf8'));
|
||||
return (result as KeyValue) ?? {};
|
||||
} catch (error) {
|
||||
if (error.name === 'YAMLException') {
|
||||
error.file = file;
|
||||
if (error instanceof YAMLException) {
|
||||
throw new YAMLFileException(error, file);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function readIfExists(file: string, default_?: KeyValue): KeyValue {
|
||||
return fs.existsSync(file) ? read(file) : default_;
|
||||
function readIfExists(file: string, fallback: KeyValue = {}): KeyValue {
|
||||
return fs.existsSync(file) ? read(file) : fallback;
|
||||
}
|
||||
|
||||
function writeIfChanged(file: string, content: KeyValue): void {
|
||||
const before = readIfExists(file);
|
||||
|
||||
if (!equals(before, content)) {
|
||||
fs.writeFileSync(file, yaml.dump(content));
|
||||
}
|
||||
|
||||
+48
-35
@@ -1,6 +1,8 @@
|
||||
import bind from 'bind-decorator';
|
||||
import {randomInt} from 'crypto';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
import {Controller} from 'zigbee-herdsman';
|
||||
import * as ZHEvents from 'zigbee-herdsman/dist/controller/events';
|
||||
|
||||
@@ -14,6 +16,7 @@ import utils from './util/utils';
|
||||
const entityIDRegex = new RegExp(`^(.+?)(?:/([^/]+))?$`);
|
||||
|
||||
export default class Zigbee {
|
||||
// @ts-expect-error initialized in start
|
||||
private herdsman: Controller;
|
||||
private eventBus: EventBus;
|
||||
private groupLookup: {[s: number]: Group} = {};
|
||||
@@ -55,8 +58,10 @@ export default class Zigbee {
|
||||
acceptJoiningDeviceHandler: this.acceptJoiningDeviceHandler,
|
||||
};
|
||||
|
||||
const herdsmanSettingsLog = JSON.stringify(herdsmanSettings).replaceAll(JSON.stringify(herdsmanSettings.network.networkKey), '"HIDDEN"');
|
||||
logger.debug(`Using zigbee-herdsman with settings: '${stringify(herdsmanSettingsLog)}'`);
|
||||
logger.debug(
|
||||
() =>
|
||||
`Using zigbee-herdsman with settings: '${stringify(JSON.stringify(herdsmanSettings).replaceAll(JSON.stringify(herdsmanSettings.network.networkKey), '"HIDDEN"'))}'`,
|
||||
);
|
||||
|
||||
let startResult;
|
||||
try {
|
||||
@@ -73,18 +78,18 @@ export default class Zigbee {
|
||||
|
||||
this.herdsman.on('adapterDisconnected', () => this.eventBus.emitAdapterDisconnected());
|
||||
this.herdsman.on('lastSeenChanged', (data: ZHEvents.LastSeenChangedPayload) => {
|
||||
this.eventBus.emitLastSeenChanged({device: this.resolveDevice(data.device.ieeeAddr), reason: data.reason});
|
||||
this.eventBus.emitLastSeenChanged({device: this.resolveDevice(data.device.ieeeAddr)!, reason: data.reason});
|
||||
});
|
||||
this.herdsman.on('permitJoinChanged', (data: ZHEvents.PermitJoinChangedPayload) => {
|
||||
this.eventBus.emitPermitJoinChanged(data);
|
||||
});
|
||||
this.herdsman.on('deviceNetworkAddressChanged', (data: ZHEvents.DeviceNetworkAddressChangedPayload) => {
|
||||
const device = this.resolveDevice(data.device.ieeeAddr);
|
||||
const device = this.resolveDevice(data.device.ieeeAddr)!;
|
||||
logger.debug(`Device '${device.name}' changed network address`);
|
||||
this.eventBus.emitDeviceNetworkAddressChanged({device});
|
||||
});
|
||||
this.herdsman.on('deviceAnnounce', (data: ZHEvents.DeviceAnnouncePayload) => {
|
||||
const device = this.resolveDevice(data.device.ieeeAddr);
|
||||
const device = this.resolveDevice(data.device.ieeeAddr)!;
|
||||
logger.debug(`Device '${device.name}' announced itself`);
|
||||
this.eventBus.emitDeviceAnnounce({device});
|
||||
});
|
||||
@@ -109,12 +114,13 @@ export default class Zigbee {
|
||||
this.eventBus.emitDeviceLeave({ieeeAddr: data.ieeeAddr, name});
|
||||
});
|
||||
this.herdsman.on('message', async (data: ZHEvents.MessagePayload) => {
|
||||
const device = this.resolveDevice(data.device.ieeeAddr);
|
||||
const device = this.resolveDevice(data.device.ieeeAddr)!;
|
||||
await device.resolveDefinition();
|
||||
logger.debug(
|
||||
`Received Zigbee message from '${device.name}', type '${data.type}', ` +
|
||||
() =>
|
||||
`Received Zigbee message from '${device.name}', type '${data.type}', ` +
|
||||
`cluster '${data.cluster}', data '${stringify(data.data)}' from endpoint ${data.endpoint.ID}` +
|
||||
(data.hasOwnProperty('groupID') ? ` with groupID ${data.groupID}` : ``) +
|
||||
(data['groupID'] !== undefined ? ` with groupID ${data.groupID}` : ``) +
|
||||
(device.zh.type === 'Coordinator' ? `, ignoring since it is from coordinator` : ``),
|
||||
);
|
||||
if (device.zh.type === 'Coordinator') return;
|
||||
@@ -133,7 +139,7 @@ export default class Zigbee {
|
||||
try {
|
||||
await device.zh.removeFromNetwork();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to remove '${device.ieeeAddr}' (${error.message})`);
|
||||
logger.error(`Failed to remove '${device.ieeeAddr}' (${(error as Error).message})`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -157,7 +163,7 @@ export default class Zigbee {
|
||||
logger.info(`Successfully interviewed '${name}', device has successfully been paired`);
|
||||
|
||||
if (data.device.isSupported) {
|
||||
const {vendor, description, model} = data.device.definition;
|
||||
const {vendor, description, model} = data.device.definition!;
|
||||
logger.info(`Device '${name}' is supported, identified as: ${vendor} ${description} (${model})`);
|
||||
} else {
|
||||
logger.warning(
|
||||
@@ -193,7 +199,7 @@ export default class Zigbee {
|
||||
}
|
||||
|
||||
async getCoordinatorVersion(): Promise<zh.CoordinatorVersion> {
|
||||
return this.herdsman.getCoordinatorVersion();
|
||||
return await this.herdsman.getCoordinatorVersion();
|
||||
}
|
||||
|
||||
isStopping(): boolean {
|
||||
@@ -201,16 +207,16 @@ export default class Zigbee {
|
||||
}
|
||||
|
||||
async backup(): Promise<void> {
|
||||
return this.herdsman.backup();
|
||||
return await this.herdsman.backup();
|
||||
}
|
||||
|
||||
async coordinatorCheck(): Promise<{missingRouters: Device[]}> {
|
||||
const check = await this.herdsman.coordinatorCheck();
|
||||
return {missingRouters: check.missingRouters.map((d) => this.resolveDevice(d.ieeeAddr))};
|
||||
return {missingRouters: check.missingRouters.map((d) => this.resolveDevice(d.ieeeAddr)!)};
|
||||
}
|
||||
|
||||
async getNetworkParameters(): Promise<zh.NetworkParameters> {
|
||||
return this.herdsman.getNetworkParameters();
|
||||
return await this.herdsman.getNetworkParameters();
|
||||
}
|
||||
|
||||
async reset(type: 'soft' | 'hard'): Promise<void> {
|
||||
@@ -227,11 +233,11 @@ export default class Zigbee {
|
||||
return this.herdsman.getPermitJoin();
|
||||
}
|
||||
|
||||
getPermitJoinTimeout(): number {
|
||||
getPermitJoinTimeout(): number | undefined {
|
||||
return this.herdsman.getPermitJoinTimeout();
|
||||
}
|
||||
|
||||
async permitJoin(permit: boolean, device?: Device, time: number = undefined): Promise<void> {
|
||||
async permitJoin(permit: boolean, device?: Device, time?: number): Promise<void> {
|
||||
if (permit) {
|
||||
logger.info(`Zigbee: allowing new devices to join${device ? ` via ${device.name}` : ''}.`);
|
||||
} else {
|
||||
@@ -245,7 +251,7 @@ export default class Zigbee {
|
||||
}
|
||||
}
|
||||
|
||||
@bind private resolveDevice(ieeeAddr: string): Device {
|
||||
@bind private resolveDevice(ieeeAddr: string): Device | undefined {
|
||||
if (!this.deviceLookup[ieeeAddr]) {
|
||||
const device = this.herdsman.getDeviceByIeeeAddr(ieeeAddr);
|
||||
if (device) {
|
||||
@@ -269,16 +275,20 @@ export default class Zigbee {
|
||||
return this.groupLookup[groupID];
|
||||
}
|
||||
|
||||
resolveEntity(key: string | number | zh.Device): Device | Group {
|
||||
resolveEntity(key: string | number | zh.Device): Device | Group | undefined {
|
||||
if (typeof key === 'object') {
|
||||
return this.resolveDevice(key.ieeeAddr);
|
||||
} else if (typeof key === 'string' && key.toLowerCase() === 'coordinator') {
|
||||
return this.resolveDevice(this.herdsman.getDevicesByType('Coordinator')[0].ieeeAddr);
|
||||
} else {
|
||||
const settingsDevice = settings.getDevice(key.toString());
|
||||
if (settingsDevice) return this.resolveDevice(settingsDevice.ID);
|
||||
|
||||
if (settingsDevice) {
|
||||
return this.resolveDevice(settingsDevice.ID);
|
||||
}
|
||||
|
||||
const groupSettings = settings.getGroup(key);
|
||||
|
||||
if (groupSettings) {
|
||||
const group = this.resolveGroup(groupSettings.ID);
|
||||
// If group does not exist, create it (since it's already in configuration.yaml)
|
||||
@@ -287,7 +297,7 @@ export default class Zigbee {
|
||||
}
|
||||
}
|
||||
|
||||
resolveEntityAndEndpoint(ID: string): {ID: string; entity: Device | Group; endpointID: string; endpoint: zh.Endpoint} {
|
||||
resolveEntityAndEndpoint(ID: string): {ID: string; entity: Device | Group | undefined; endpointID?: string; endpoint?: zh.Endpoint} {
|
||||
// This function matches the following entity formats:
|
||||
// device_name (just device name)
|
||||
// device_name/ep_name (device name and endpoint numeric ID or name)
|
||||
@@ -297,25 +307,28 @@ export default class Zigbee {
|
||||
// The function tries to find an exact match first
|
||||
let entityName = ID;
|
||||
let deviceOrGroup = this.resolveEntity(ID);
|
||||
let endpointNameOrID = undefined;
|
||||
let endpointNameOrID: string | undefined;
|
||||
|
||||
// If exact match did not happenc, try matching a device_name/endpoint pattern
|
||||
// If exact match did not happen, try matching a device_name/endpoint pattern
|
||||
if (!deviceOrGroup) {
|
||||
// First split the input token by the latest slash
|
||||
const match = ID.match(entityIDRegex);
|
||||
|
||||
// Get the resulting IDs from the match
|
||||
entityName = match[1];
|
||||
deviceOrGroup = this.resolveEntity(match[1]);
|
||||
endpointNameOrID = match[2];
|
||||
/* istanbul ignore else */
|
||||
if (match) {
|
||||
// Get the resulting IDs from the match
|
||||
entityName = match[1];
|
||||
deviceOrGroup = this.resolveEntity(entityName);
|
||||
endpointNameOrID = match[2];
|
||||
}
|
||||
}
|
||||
|
||||
// If the function returns non-null endpoint name, but the endpoint field is null, then
|
||||
// it means that endpoint was not matched because there is no such endpoint on the device
|
||||
// (or the entity is a group)
|
||||
const endpoint = deviceOrGroup?.isDevice() ? deviceOrGroup.endpoint(endpointNameOrID) : null;
|
||||
const endpoint = deviceOrGroup?.isDevice() ? deviceOrGroup.endpoint(endpointNameOrID) : undefined;
|
||||
|
||||
return {ID: entityName, entity: deviceOrGroup, endpointID: endpointNameOrID, endpoint: endpoint};
|
||||
return {ID: entityName, entity: deviceOrGroup, endpointID: endpointNameOrID, endpoint};
|
||||
}
|
||||
|
||||
firstCoordinatorEndpoint(): zh.Endpoint {
|
||||
@@ -327,7 +340,7 @@ export default class Zigbee {
|
||||
groupPredicate?: (value: zh.Group) => boolean,
|
||||
): Generator<Device | Group> {
|
||||
for (const device of this.herdsman.getDevicesIterator(devicePredicate)) {
|
||||
yield this.resolveDevice(device.ieeeAddr);
|
||||
yield this.resolveDevice(device.ieeeAddr)!;
|
||||
}
|
||||
|
||||
for (const group of this.herdsman.getGroupsIterator(groupPredicate)) {
|
||||
@@ -343,7 +356,7 @@ export default class Zigbee {
|
||||
|
||||
*devicesIterator(predicate?: (value: zh.Device) => boolean): Generator<Device> {
|
||||
for (const device of this.herdsman.getDevicesIterator(predicate)) {
|
||||
yield this.resolveDevice(device.ieeeAddr);
|
||||
yield this.resolveDevice(device.ieeeAddr)!;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,11 +386,11 @@ export default class Zigbee {
|
||||
}
|
||||
|
||||
async touchlinkFactoryResetFirst(): Promise<boolean> {
|
||||
return this.herdsman.touchlinkFactoryResetFirst();
|
||||
return await this.herdsman.touchlinkFactoryResetFirst();
|
||||
}
|
||||
|
||||
async touchlinkFactoryReset(ieeeAddr: string, channel: number): Promise<boolean> {
|
||||
return this.herdsman.touchlinkFactoryReset(ieeeAddr, channel);
|
||||
return await this.herdsman.touchlinkFactoryReset(ieeeAddr, channel);
|
||||
}
|
||||
|
||||
async addInstallCode(installCode: string): Promise<void> {
|
||||
@@ -389,7 +402,7 @@ export default class Zigbee {
|
||||
}
|
||||
|
||||
async touchlinkScan(): Promise<{ieeeAddr: string; channel: number}[]> {
|
||||
return this.herdsman.touchlinkScan();
|
||||
return await this.herdsman.touchlinkScan();
|
||||
}
|
||||
|
||||
createGroup(ID: number): Group {
|
||||
@@ -397,7 +410,7 @@ export default class Zigbee {
|
||||
return this.resolveGroup(ID);
|
||||
}
|
||||
|
||||
deviceByNetworkAddress(networkAddress: number): Device {
|
||||
deviceByNetworkAddress(networkAddress: number): Device | undefined {
|
||||
const device = this.herdsman.getDeviceByNetworkAddress(networkAddress);
|
||||
return device && this.resolveDevice(device.ieeeAddr);
|
||||
}
|
||||
|
||||
Generated
+1251
-3123
File diff suppressed because it is too large
Load Diff
+30
-26
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "zigbee2mqtt",
|
||||
"version": "1.40.1",
|
||||
"version": "1.42.0",
|
||||
"description": "Zigbee to MQTT bridge using Zigbee-herdsman",
|
||||
"main": "index.js",
|
||||
"repository": {
|
||||
@@ -8,7 +8,7 @@
|
||||
"url": "git+https://github.com/Koenkk/zigbee2mqtt.git"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18 || ^20 || ^22"
|
||||
"node": "^18 || ^20 || ^22 || ^23"
|
||||
},
|
||||
"keywords": [
|
||||
"xiaomi",
|
||||
@@ -22,7 +22,7 @@
|
||||
"scripts": {
|
||||
"build": "tsc && node index.js writehash",
|
||||
"build-watch": "tsc --watch",
|
||||
"eslint": "eslint lib/ --max-warnings=0",
|
||||
"eslint": "eslint --max-warnings=0",
|
||||
"pretty:write": "prettier --write .",
|
||||
"pretty:check": "prettier --check .",
|
||||
"start": "node index.js",
|
||||
@@ -39,10 +39,10 @@
|
||||
"dependencies": {
|
||||
"ajv": "^8.17.1",
|
||||
"bind-decorator": "^1.0.11",
|
||||
"connect-gzip-static": "3.0.1",
|
||||
"debounce": "^2.1.0",
|
||||
"debounce": "^2.2.0",
|
||||
"express-static-gzip": "^2.2.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"finalhandler": "^1.2.0",
|
||||
"finalhandler": "^1.3.1",
|
||||
"git-last-commit": "^1.0.1",
|
||||
"humanize-duration": "^3.32.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
@@ -50,44 +50,48 @@
|
||||
"jszip": "^3.10.1",
|
||||
"mkdir-recursive": "^0.4.0",
|
||||
"moment": "^2.30.1",
|
||||
"mqtt": "^5.10.1",
|
||||
"mqtt": "^5.10.2",
|
||||
"object-assign-deep": "^0.4.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"semver": "^7.6.3",
|
||||
"source-map-support": "^0.5.21",
|
||||
"throttleit": "^2.1.0",
|
||||
"uri-js": "^4.4.1",
|
||||
"winston": "^3.14.2",
|
||||
"winston": "^3.17.0",
|
||||
"winston-syslog": "^2.7.1",
|
||||
"winston-transport": "^4.7.1",
|
||||
"winston-transport": "^4.9.0",
|
||||
"ws": "^8.18.0",
|
||||
"zigbee-herdsman": "0.57.3",
|
||||
"zigbee-herdsman-converters": "20.12.1",
|
||||
"zigbee2mqtt-frontend": "0.7.4"
|
||||
"zigbee-herdsman": "2.1.9",
|
||||
"zigbee-herdsman-converters": "20.58.0",
|
||||
"zigbee2mqtt-frontend": "0.7.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/plugin-proposal-decorators": "^7.24.7",
|
||||
"@babel/preset-env": "^7.25.4",
|
||||
"@babel/preset-typescript": "^7.24.7",
|
||||
"@babel/core": "^7.26.0",
|
||||
"@babel/plugin-proposal-decorators": "^7.25.9",
|
||||
"@babel/preset-env": "^7.26.0",
|
||||
"@babel/preset-typescript": "^7.26.0",
|
||||
"@eslint/core": "^0.9.0",
|
||||
"@eslint/js": "^9.15.0",
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.4.0",
|
||||
"@types/eslint__js": "^8.42.3",
|
||||
"@types/finalhandler": "^1.2.3",
|
||||
"@types/humanize-duration": "^3.27.4",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.5.1",
|
||||
"@types/node": "^22.9.3",
|
||||
"@types/object-assign-deep": "^0.4.3",
|
||||
"@types/readable-stream": "4.0.15",
|
||||
"@types/ws": "8.5.12",
|
||||
"@typescript-eslint/eslint-plugin": "^8.3.0",
|
||||
"@typescript-eslint/parser": "^8.3.0",
|
||||
"@types/readable-stream": "4.0.18",
|
||||
"@types/sd-notify": "^2.8.2",
|
||||
"@types/serve-static": "^1.15.7",
|
||||
"@types/ws": "8.5.13",
|
||||
"babel-jest": "^29.7.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-jest": "^28.8.1",
|
||||
"eslint-plugin-perfectionist": "^3.3.0",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.3.3",
|
||||
"tmp": "^0.2.3",
|
||||
"typescript": "^5.5.4"
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.15.0"
|
||||
},
|
||||
"overrides": {
|
||||
"zigbee-herdsman-converters": {
|
||||
|
||||
@@ -18,5 +18,5 @@ const sandbox = {
|
||||
};
|
||||
vm.runInNewContext(moduleCode, sandbox, moduleFakePath);
|
||||
const converter = sandbox.module.exports;
|
||||
assert(!converter.toZigbee.includes(undefined));
|
||||
assert(!converter.fromZigbee.includes(undefined));
|
||||
assert(!converter.toZigbee || !converter.toZigbee.includes(undefined));
|
||||
assert(!converter.fromZigbee || !converter.fromZigbee.includes(undefined));
|
||||
|
||||
@@ -65,12 +65,12 @@ class ZStackNvMemEraser {
|
||||
if (len != 0) {
|
||||
console.log(`NVMEM item #${id} - deleting, size: ${len}`);
|
||||
if (needOsal) {
|
||||
await this.znp.request(Subsystem.SYS, 'osalNvDelete', {id: id, len: len}, null, null, [
|
||||
await this.znp.request(Subsystem.SYS, 'osalNvDelete', {id: id, len: len}, undefined, undefined, [
|
||||
ZnpCommandStatus.SUCCESS,
|
||||
ZnpCommandStatus.NV_ITEM_INITIALIZED,
|
||||
]);
|
||||
} else {
|
||||
await this.znp.request(Subsystem.SYS, 'nvDelete', {sysid: NvSystemIds.ZSTACK, itemid: id, subid: 0}, null, null, [
|
||||
await this.znp.request(Subsystem.SYS, 'nvDelete', {sysid: NvSystemIds.ZSTACK, itemid: id, subid: 0}, undefined, undefined, [
|
||||
ZnpCommandStatus.SUCCESS,
|
||||
ZnpCommandStatus.NV_ITEM_INITIALIZED,
|
||||
]);
|
||||
|
||||
+22
-13
@@ -1,19 +1,28 @@
|
||||
import data from './stub/data';
|
||||
import logger from './stub/logger';
|
||||
import MQTT from './stub/mqtt';
|
||||
import zigbeeHerdsman from './stub/zigbeeHerdsman';
|
||||
|
||||
import utils from '../lib/util/utils';
|
||||
import * as settings from '../lib/util/settings';
|
||||
import Controller from '../lib/controller';
|
||||
import Availability from '../lib/extension/availability';
|
||||
import flushPromises from './lib/flushPromises';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
const data = require('./stub/data');
|
||||
const logger = require('./stub/logger');
|
||||
const zigbeeHerdsman = require('./stub/zigbeeHerdsman');
|
||||
const MQTT = require('./stub/mqtt');
|
||||
const settings = require('../lib/util/settings');
|
||||
const Controller = require('../lib/controller');
|
||||
const flushPromises = require('./lib/flushPromises');
|
||||
const Availability = require('../lib/extension/availability').default;
|
||||
const stringify = require('json-stable-stringify-without-jsonify');
|
||||
const utils = require('../lib/util/utils').default;
|
||||
|
||||
const mocks = [MQTT.publish, logger.warning, logger.info];
|
||||
const devices = zigbeeHerdsman.devices;
|
||||
zigbeeHerdsman.returnDevices.push(
|
||||
...[devices.bulb_color.ieeeAddr, devices.bulb_color_2.ieeeAddr, devices.coordinator.ieeeAddr, devices.remote.ieeeAddr],
|
||||
...[
|
||||
devices.bulb_color.ieeeAddr,
|
||||
devices.bulb_color_2.ieeeAddr,
|
||||
devices.coordinator.ieeeAddr,
|
||||
devices.remote.ieeeAddr,
|
||||
devices.TS0601_thermostat.ieeeAddr,
|
||||
devices.bulb_2.ieeeAddr,
|
||||
devices.ZNCZ02LM.ieeeAddr,
|
||||
devices.GLEDOPTO_2ID.ieeeAddr,
|
||||
devices.QBKG03LM.ieeeAddr,
|
||||
],
|
||||
);
|
||||
|
||||
describe('Availability', () => {
|
||||
@@ -289,7 +298,7 @@ describe('Availability', () => {
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/rename', stringify({from: 'bulb_color', to: 'bulb_new_name'}));
|
||||
await flushPromises();
|
||||
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', null, {retain: true, qos: 1}, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', '', {retain: true, qos: 1}, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_new_name/availability', 'online', {retain: true, qos: 1}, expect.any(Function));
|
||||
await setTimeAndAdvanceTimers(utils.hours(12));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_new_name/availability', 'offline', {retain: true, qos: 1}, expect.any(Function));
|
||||
|
||||
+123
-24
@@ -92,8 +92,8 @@ describe('Bridge', () => {
|
||||
commit: version.commitHash,
|
||||
config: {
|
||||
advanced: {
|
||||
adapter_concurrent: null,
|
||||
adapter_delay: null,
|
||||
adapter_concurrent: undefined,
|
||||
adapter_delay: undefined,
|
||||
availability_blacklist: [],
|
||||
availability_blocklist: [],
|
||||
availability_passlist: [],
|
||||
@@ -233,13 +233,12 @@ describe('Bridge', () => {
|
||||
|
||||
it('Should publish devices on startup', async () => {
|
||||
await resetExtension();
|
||||
// console.log(MQTT.publish.mock.calls.find((c) => c[0] === 'zigbee2mqtt/bridge/devices')[1])
|
||||
// console.log(MQTT.publish.mock.calls.find((c) => c[0] === 'zigbee2mqtt/bridge/devices')[1]);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/devices',
|
||||
stringify([
|
||||
{
|
||||
date_code: null,
|
||||
definition: null,
|
||||
disabled: false,
|
||||
endpoints: {1: {bindings: [], clusters: {input: [], output: []}, configured_reportings: [], scenes: []}},
|
||||
friendly_name: 'Coordinator',
|
||||
@@ -318,6 +317,41 @@ describe('Bridge', () => {
|
||||
value_max: 454,
|
||||
value_min: 250,
|
||||
},
|
||||
{
|
||||
access: 7,
|
||||
description: 'Configure genLevelCtrl',
|
||||
features: [
|
||||
{
|
||||
access: 7,
|
||||
description:
|
||||
'this setting can affect the "on_level", "current_level_startup" or "brightness" setting',
|
||||
label: 'Execute if off',
|
||||
name: 'execute_if_off',
|
||||
property: 'execute_if_off',
|
||||
type: 'binary',
|
||||
value_off: false,
|
||||
value_on: true,
|
||||
},
|
||||
{
|
||||
access: 7,
|
||||
description: 'Defines the desired startup level for a device when it is supplied with power',
|
||||
label: 'Current level startup',
|
||||
name: 'current_level_startup',
|
||||
presets: [
|
||||
{description: 'Use minimum permitted value', name: 'minimum', value: 'minimum'},
|
||||
{description: 'Use previous value', name: 'previous', value: 'previous'},
|
||||
],
|
||||
property: 'current_level_startup',
|
||||
type: 'numeric',
|
||||
value_max: 254,
|
||||
value_min: 1,
|
||||
},
|
||||
],
|
||||
label: 'Level config',
|
||||
name: 'level_config',
|
||||
property: 'level_config',
|
||||
type: 'composite',
|
||||
},
|
||||
],
|
||||
type: 'light',
|
||||
},
|
||||
@@ -333,8 +367,7 @@ describe('Bridge', () => {
|
||||
{
|
||||
access: 7,
|
||||
category: 'config',
|
||||
description:
|
||||
'Controls the behavior when the device is powered on after power loss. If you get an `UNSUPPORTED_ATTRIBUTE` error, the device does not support it.',
|
||||
description: 'Controls the behavior when the device is powered on after power loss',
|
||||
label: 'Power-on behavior',
|
||||
name: 'power_on_behavior',
|
||||
property: 'power_on_behavior',
|
||||
@@ -560,8 +593,7 @@ describe('Bridge', () => {
|
||||
{
|
||||
access: 7,
|
||||
category: 'config',
|
||||
description:
|
||||
'Controls the behavior when the device is powered on after power loss. If you get an `UNSUPPORTED_ATTRIBUTE` error, the device does not support it.',
|
||||
description: 'Controls the behavior when the device is powered on after power loss',
|
||||
label: 'Power-on behavior',
|
||||
name: 'power_on_behavior',
|
||||
property: 'power_on_behavior',
|
||||
@@ -1878,6 +1910,41 @@ describe('Bridge', () => {
|
||||
value_max: 454,
|
||||
value_min: 250,
|
||||
},
|
||||
{
|
||||
access: 7,
|
||||
description: 'Configure genLevelCtrl',
|
||||
features: [
|
||||
{
|
||||
access: 7,
|
||||
description:
|
||||
'this setting can affect the "on_level", "current_level_startup" or "brightness" setting',
|
||||
label: 'Execute if off',
|
||||
name: 'execute_if_off',
|
||||
property: 'execute_if_off',
|
||||
type: 'binary',
|
||||
value_off: false,
|
||||
value_on: true,
|
||||
},
|
||||
{
|
||||
access: 7,
|
||||
description: 'Defines the desired startup level for a device when it is supplied with power',
|
||||
label: 'Current level startup',
|
||||
name: 'current_level_startup',
|
||||
presets: [
|
||||
{description: 'Use minimum permitted value', name: 'minimum', value: 'minimum'},
|
||||
{description: 'Use previous value', name: 'previous', value: 'previous'},
|
||||
],
|
||||
property: 'current_level_startup',
|
||||
type: 'numeric',
|
||||
value_max: 254,
|
||||
value_min: 1,
|
||||
},
|
||||
],
|
||||
label: 'Level config',
|
||||
name: 'level_config',
|
||||
property: 'level_config',
|
||||
type: 'composite',
|
||||
},
|
||||
],
|
||||
type: 'light',
|
||||
},
|
||||
@@ -1893,8 +1960,7 @@ describe('Bridge', () => {
|
||||
{
|
||||
access: 7,
|
||||
category: 'config',
|
||||
description:
|
||||
'Controls the behavior when the device is powered on after power loss. If you get an `UNSUPPORTED_ATTRIBUTE` error, the device does not support it.',
|
||||
description: 'Controls the behavior when the device is powered on after power loss',
|
||||
label: 'Power-on behavior',
|
||||
name: 'power_on_behavior',
|
||||
property: 'power_on_behavior',
|
||||
@@ -2186,7 +2252,7 @@ describe('Bridge', () => {
|
||||
await zigbeeHerdsman.events.deviceInterview({device: zigbeeHerdsman.devices.unsupported, status: 'successful'});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(7);
|
||||
// console.log(MQTT.publish.mock.calls.filter((c) => c[0] === 'zigbee2mqtt/bridge/event'))
|
||||
// console.log(MQTT.publish.mock.calls.filter((c) => c[0] === 'zigbee2mqtt/bridge/event'));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/event',
|
||||
stringify({
|
||||
@@ -2254,6 +2320,40 @@ describe('Bridge', () => {
|
||||
value_max: 454,
|
||||
value_min: 250,
|
||||
},
|
||||
{
|
||||
access: 7,
|
||||
description: 'Configure genLevelCtrl',
|
||||
features: [
|
||||
{
|
||||
access: 7,
|
||||
description: `this setting can affect the "on_level", "current_level_startup" or "brightness" setting`,
|
||||
label: 'Execute if off',
|
||||
name: 'execute_if_off',
|
||||
property: 'execute_if_off',
|
||||
type: 'binary',
|
||||
value_off: false,
|
||||
value_on: true,
|
||||
},
|
||||
{
|
||||
access: 7,
|
||||
description: 'Defines the desired startup level for a device when it is supplied with power',
|
||||
label: 'Current level startup',
|
||||
name: 'current_level_startup',
|
||||
presets: [
|
||||
{description: 'Use minimum permitted value', name: 'minimum', value: 'minimum'},
|
||||
{description: 'Use previous value', name: 'previous', value: 'previous'},
|
||||
],
|
||||
property: 'current_level_startup',
|
||||
type: 'numeric',
|
||||
value_max: 254,
|
||||
value_min: 1,
|
||||
},
|
||||
],
|
||||
label: 'Level config',
|
||||
name: 'level_config',
|
||||
property: 'level_config',
|
||||
type: 'composite',
|
||||
},
|
||||
],
|
||||
type: 'light',
|
||||
},
|
||||
@@ -2269,8 +2369,7 @@ describe('Bridge', () => {
|
||||
{
|
||||
access: 7,
|
||||
category: 'config',
|
||||
description:
|
||||
'Controls the behavior when the device is powered on after power loss. If you get an `UNSUPPORTED_ATTRIBUTE` error, the device does not support it.',
|
||||
description: 'Controls the behavior when the device is powered on after power loss',
|
||||
label: 'Power-on behavior',
|
||||
name: 'power_on_behavior',
|
||||
property: 'power_on_behavior',
|
||||
@@ -2561,7 +2660,7 @@ describe('Bridge', () => {
|
||||
|
||||
it('Should republish bridge info when permit join changes', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
await zigbeeHerdsman.events.permitJoinChanged({permitted: false, time: 10});
|
||||
await zigbeeHerdsman.events.permitJoinChanged({permitted: false, timeout: 10});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/info', expect.any(String), {retain: true, qos: 0}, expect.any(Function));
|
||||
});
|
||||
@@ -2569,7 +2668,7 @@ describe('Bridge', () => {
|
||||
it('Shouldnt republish bridge info when permit join changes and hersman is stopping', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
zigbeeHerdsman.isStopping.mockImplementationOnce(() => true);
|
||||
await zigbeeHerdsman.events.permitJoinChanged({permitted: false, time: 10});
|
||||
await zigbeeHerdsman.events.permitJoinChanged({permitted: false, timeout: 10});
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).not.toHaveBeenCalledWith('zigbee2mqtt/bridge/info', expect.any(String), {retain: true, qos: 0}, expect.any(Function));
|
||||
});
|
||||
@@ -2706,7 +2805,7 @@ describe('Bridge', () => {
|
||||
expect(controller.state[device.ieeeAddr]).toBeUndefined();
|
||||
expect(device.removeFromNetwork).toHaveBeenCalledTimes(1);
|
||||
expect(device.removeFromDatabase).not.toHaveBeenCalled();
|
||||
expect(settings.getDevice('bulb')).toBeNull();
|
||||
expect(settings.getDevice('bulb')).toBeUndefined();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb', '', {retain: true, qos: 0}, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), expect.any(Object), expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
@@ -2728,7 +2827,7 @@ describe('Bridge', () => {
|
||||
await flushPromises();
|
||||
expect(device.removeFromNetwork).toHaveBeenCalledTimes(1);
|
||||
expect(device.removeFromDatabase).not.toHaveBeenCalled();
|
||||
expect(settings.getDevice('bulb')).toBeNull();
|
||||
expect(settings.getDevice('bulb')).toBeUndefined();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), expect.any(Object), expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/remove',
|
||||
@@ -2745,7 +2844,7 @@ describe('Bridge', () => {
|
||||
await flushPromises();
|
||||
expect(device.removeFromDatabase).toHaveBeenCalledTimes(1);
|
||||
expect(device.removeFromNetwork).not.toHaveBeenCalled();
|
||||
expect(settings.getDevice('bulb')).toBeNull();
|
||||
expect(settings.getDevice('bulb')).toBeUndefined();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), expect.any(Object), expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/remove',
|
||||
@@ -2761,7 +2860,7 @@ describe('Bridge', () => {
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/remove', stringify({id: 'bulb', block: true, force: true}));
|
||||
await flushPromises();
|
||||
expect(device.removeFromDatabase).toHaveBeenCalledTimes(1);
|
||||
expect(settings.getDevice('bulb')).toBeNull();
|
||||
expect(settings.getDevice('bulb')).toBeUndefined();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), expect.any(Object), expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/remove',
|
||||
@@ -2778,7 +2877,7 @@ describe('Bridge', () => {
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/group/remove', 'group_1');
|
||||
await flushPromises();
|
||||
expect(group.removeFromNetwork).toHaveBeenCalledTimes(1);
|
||||
expect(settings.getGroup('group_1')).toBeNull();
|
||||
expect(settings.getGroup('group_1')).toBeUndefined();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/remove',
|
||||
@@ -2794,7 +2893,7 @@ describe('Bridge', () => {
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/group/remove', stringify({id: 'group_1', force: true}));
|
||||
await flushPromises();
|
||||
expect(group.removeFromDatabase).toHaveBeenCalledTimes(1);
|
||||
expect(settings.getGroup('group_1')).toBeNull();
|
||||
expect(settings.getGroup('group_1')).toBeUndefined();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/remove',
|
||||
@@ -2858,7 +2957,7 @@ describe('Bridge', () => {
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/rename', stringify({from: 'bulb', to: 'bulb_new_name'}));
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('bulb')).toBeNull();
|
||||
expect(settings.getDevice('bulb')).toBeUndefined();
|
||||
expect(settings.getDevice('bulb_new_name')).toStrictEqual({
|
||||
ID: '0x000b57fffec6a5b2',
|
||||
friendly_name: 'bulb_new_name',
|
||||
@@ -2892,7 +2991,7 @@ describe('Bridge', () => {
|
||||
MQTT.publish.mockClear();
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/group/rename', stringify({from: 'group_1', to: 'group_new_name'}));
|
||||
await flushPromises();
|
||||
expect(settings.getGroup('group_1')).toBeNull();
|
||||
expect(settings.getGroup('group_1')).toBeUndefined();
|
||||
expect(settings.getGroup('group_new_name')).toStrictEqual({ID: 1, devices: [], friendly_name: 'group_new_name', retain: false});
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
@@ -2932,7 +3031,7 @@ describe('Bridge', () => {
|
||||
await zigbeeHerdsman.events.deviceJoined({device: zigbeeHerdsman.devices.bulb});
|
||||
MQTT.events.message('zigbee2mqtt/bridge/request/device/rename', stringify({last: true, to: 'bulb_new_name'}));
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('bulb')).toBeNull();
|
||||
expect(settings.getDevice('bulb')).toBeUndefined();
|
||||
expect(settings.getDevice('bulb_new_name')).toStrictEqual({
|
||||
ID: '0x000b57fffec6a5b2',
|
||||
friendly_name: 'bulb_new_name',
|
||||
|
||||
+20
-7
@@ -79,7 +79,7 @@ describe('Controller', () => {
|
||||
databaseBackupPath: path.join(data.mockDir, 'database.db.backup'),
|
||||
backupPath: path.join(data.mockDir, 'coordinator_backup.json'),
|
||||
acceptJoiningDeviceHandler: expect.any(Function),
|
||||
adapter: {concurrent: null, delay: null, disableLED: false, transmitPower: 14},
|
||||
adapter: {concurrent: undefined, delay: undefined, disableLED: false, transmitPower: 14},
|
||||
serialPort: {baudRate: undefined, rtscts: undefined, path: '/dev/dummy'},
|
||||
});
|
||||
expect(zigbeeHerdsman.start).toHaveBeenCalledTimes(1);
|
||||
@@ -325,17 +325,24 @@ describe('Controller', () => {
|
||||
});
|
||||
|
||||
it('Handle mqtt message', async () => {
|
||||
const eventbus = controller.eventBus;
|
||||
let spyEventbusEmitMQTTMessage = jest.spyOn(eventbus, 'emitMQTTMessage').mockImplementation();
|
||||
|
||||
await controller.start();
|
||||
logger.debug.mockClear();
|
||||
await MQTT.events.message('dummytopic', 'dummymessage');
|
||||
expect(logger.debug).toHaveBeenCalledWith("Received MQTT message on 'dummytopic' with data 'dummymessage'", LOG_MQTT_NS);
|
||||
expect(spyEventbusEmitMQTTMessage).toHaveBeenCalledWith({topic: 'dummytopic', message: 'dummymessage'});
|
||||
expect(logger.log).toHaveBeenCalledWith('debug', "Received MQTT message on 'dummytopic' with data 'dummymessage'", LOG_MQTT_NS);
|
||||
});
|
||||
|
||||
it('Skip MQTT messages on topic we published to', async () => {
|
||||
const eventbus = controller.eventBus;
|
||||
let spyEventbusEmitMQTTMessage = jest.spyOn(eventbus, 'emitMQTTMessage').mockImplementation();
|
||||
|
||||
await controller.start();
|
||||
logger.debug.mockClear();
|
||||
await MQTT.events.message('zigbee2mqtt/skip-this-topic', 'skipped');
|
||||
expect(logger.debug).toHaveBeenCalledWith("Received MQTT message on 'zigbee2mqtt/skip-this-topic' with data 'skipped'", LOG_MQTT_NS);
|
||||
expect(spyEventbusEmitMQTTMessage).toHaveBeenCalledWith({topic: 'zigbee2mqtt/skip-this-topic', message: 'skipped'});
|
||||
logger.debug.mockClear();
|
||||
await controller.mqtt.publish('skip-this-topic', '', {});
|
||||
await MQTT.events.message('zigbee2mqtt/skip-this-topic', 'skipped');
|
||||
@@ -355,8 +362,10 @@ describe('Controller', () => {
|
||||
};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await flushPromises();
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect(logger.log).toHaveBeenCalledWith(
|
||||
'debug',
|
||||
`Received Zigbee message from 'bulb', type 'attributeReport', cluster 'genBasic', data '{"modelId":"TRADFRI bulb E27 WS opal 980lm"}' from endpoint 1`,
|
||||
'z2m',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -374,15 +383,17 @@ describe('Controller', () => {
|
||||
};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await flushPromises();
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect(logger.log).toHaveBeenCalledWith(
|
||||
'debug',
|
||||
`Received Zigbee message from 'bulb', type 'attributeReport', cluster 'genBasic', data '{"modelId":"TRADFRI bulb E27 WS opal 980lm"}' from endpoint 1 with groupID 0`,
|
||||
'z2m',
|
||||
);
|
||||
});
|
||||
|
||||
it('Should add entities which are missing from configuration but are in database to configuration', async () => {
|
||||
await controller.start();
|
||||
const device = zigbeeHerdsman.devices.notInSettings;
|
||||
expect(settings.getDevice(device.ieeeAddr)).not.toBeNull();
|
||||
expect(settings.getDevice(device.ieeeAddr)).not.toBeUndefined();
|
||||
});
|
||||
|
||||
it('On zigbee deviceJoined', async () => {
|
||||
@@ -964,8 +975,10 @@ describe('Controller', () => {
|
||||
};
|
||||
await zigbeeHerdsman.events.message(payload);
|
||||
await flushPromises();
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect(logger.log).toHaveBeenCalledWith(
|
||||
'debug',
|
||||
`Received Zigbee message from 'Coordinator', type 'attributeReport', cluster 'genBasic', data '{"modelId":null}' from endpoint 1, ignoring since it is from coordinator`,
|
||||
'z2m',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+2
-2
@@ -15,13 +15,13 @@ describe('Data', () => {
|
||||
it('Should return correct path when ZIGBEE2MQTT_DATA set', () => {
|
||||
const expected = tmp.dirSync().name;
|
||||
process.env.ZIGBEE2MQTT_DATA = expected;
|
||||
data.testingOnlyReload();
|
||||
data._testReload();
|
||||
const actual = data.getPath();
|
||||
expect(actual).toBe(expected);
|
||||
expect(data.joinPath('test')).toStrictEqual(path.join(expected, 'test'));
|
||||
expect(data.joinPath('/test')).toStrictEqual(path.resolve(expected, '/test'));
|
||||
delete process.env.ZIGBEE2MQTT_DATA;
|
||||
data.testingOnlyReload();
|
||||
data._testReload();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+86
-23
@@ -8,8 +8,14 @@ const stringify = require('json-stable-stringify-without-jsonify');
|
||||
const flushPromises = require('./lib/flushPromises');
|
||||
const zigbeeHerdsman = require('./stub/zigbeeHerdsman');
|
||||
const path = require('path');
|
||||
const finalhandler = require('finalhandler');
|
||||
const ws = require('ws');
|
||||
jest.spyOn(process, 'exit').mockImplementation(() => {});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const mockHTTP = {
|
||||
implementation: {
|
||||
listen: jest.fn(),
|
||||
@@ -60,6 +66,10 @@ const mockNodeStatic = {
|
||||
events: {},
|
||||
};
|
||||
|
||||
const mockFinalHandler = {
|
||||
implementation: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('http', () => ({
|
||||
createServer: jest.fn().mockImplementation((onRequest) => {
|
||||
mockHTTP.variables.onRequest = onRequest;
|
||||
@@ -76,7 +86,7 @@ jest.mock('https', () => ({
|
||||
Agent: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('connect-gzip-static', () =>
|
||||
jest.mock('express-static-gzip', () =>
|
||||
jest.fn().mockImplementation((path) => {
|
||||
mockNodeStatic.variables.path = path;
|
||||
return mockNodeStatic.implementation;
|
||||
@@ -94,6 +104,12 @@ jest.mock('ws', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('finalhandler', () =>
|
||||
jest.fn().mockImplementation(() => {
|
||||
return mockFinalHandler.implementation;
|
||||
}),
|
||||
);
|
||||
|
||||
describe('Frontend', () => {
|
||||
let controller;
|
||||
|
||||
@@ -136,10 +152,6 @@ describe('Frontend', () => {
|
||||
expect(mockWSClient.implementation.terminate).toHaveBeenCalledTimes(1);
|
||||
expect(mockHTTP.implementation.close).toHaveBeenCalledTimes(1);
|
||||
expect(mockWS.implementation.close).toHaveBeenCalledTimes(1);
|
||||
mockWS.implementation.close.mockClear();
|
||||
mockHTTP.implementation.close.mockClear();
|
||||
mockHTTP.implementation.listen.mockClear();
|
||||
mockHTTPS.implementation.listen.mockClear();
|
||||
});
|
||||
|
||||
it('Start/stop without host', async () => {
|
||||
@@ -160,10 +172,6 @@ describe('Frontend', () => {
|
||||
expect(mockWSClient.implementation.terminate).toHaveBeenCalledTimes(1);
|
||||
expect(mockHTTP.implementation.close).toHaveBeenCalledTimes(1);
|
||||
expect(mockWS.implementation.close).toHaveBeenCalledTimes(1);
|
||||
mockWS.implementation.close.mockClear();
|
||||
mockHTTP.implementation.close.mockClear();
|
||||
mockHTTP.implementation.listen.mockClear();
|
||||
mockHTTPS.implementation.listen.mockClear();
|
||||
});
|
||||
|
||||
it('Start/stop unix socket', async () => {
|
||||
@@ -184,10 +192,6 @@ describe('Frontend', () => {
|
||||
expect(mockWSClient.implementation.terminate).toHaveBeenCalledTimes(1);
|
||||
expect(mockHTTP.implementation.close).toHaveBeenCalledTimes(1);
|
||||
expect(mockWS.implementation.close).toHaveBeenCalledTimes(1);
|
||||
mockWS.implementation.close.mockClear();
|
||||
mockHTTP.implementation.close.mockClear();
|
||||
mockHTTP.implementation.listen.mockClear();
|
||||
mockHTTPS.implementation.listen.mockClear();
|
||||
});
|
||||
|
||||
it('Start/stop HTTPS valid', async () => {
|
||||
@@ -198,8 +202,6 @@ describe('Frontend', () => {
|
||||
expect(mockHTTP.implementation.listen).not.toHaveBeenCalledWith(8081, '127.0.0.1');
|
||||
expect(mockHTTPS.implementation.listen).toHaveBeenCalledWith(8081, '127.0.0.1');
|
||||
await controller.stop();
|
||||
mockHTTP.implementation.listen.mockClear();
|
||||
mockHTTPS.implementation.listen.mockClear();
|
||||
});
|
||||
|
||||
it('Start/stop HTTPS invalid : missing config', async () => {
|
||||
@@ -209,8 +211,6 @@ describe('Frontend', () => {
|
||||
expect(mockHTTP.implementation.listen).toHaveBeenCalledWith(8081, '127.0.0.1');
|
||||
expect(mockHTTPS.implementation.listen).not.toHaveBeenCalledWith(8081, '127.0.0.1');
|
||||
await controller.stop();
|
||||
mockHTTP.implementation.listen.mockClear();
|
||||
mockHTTPS.implementation.listen.mockClear();
|
||||
});
|
||||
|
||||
it('Start/stop HTTPS invalid : missing file', async () => {
|
||||
@@ -221,8 +221,6 @@ describe('Frontend', () => {
|
||||
expect(mockHTTP.implementation.listen).toHaveBeenCalledWith(8081, '127.0.0.1');
|
||||
expect(mockHTTPS.implementation.listen).not.toHaveBeenCalledWith(8081, '127.0.0.1');
|
||||
await controller.stop();
|
||||
mockHTTP.implementation.listen.mockClear();
|
||||
mockHTTPS.implementation.listen.mockClear();
|
||||
});
|
||||
|
||||
it('Websocket interaction', async () => {
|
||||
@@ -314,7 +312,6 @@ describe('Frontend', () => {
|
||||
await controller.start();
|
||||
|
||||
const mockSocket = {destroy: jest.fn()};
|
||||
mockWS.implementation.handleUpgrade.mockClear();
|
||||
mockHTTP.events.upgrade({url: 'http://localhost:8080/api'}, mockSocket, 3);
|
||||
expect(mockWS.implementation.handleUpgrade).toHaveBeenCalledTimes(1);
|
||||
expect(mockSocket.destroy).toHaveBeenCalledTimes(0);
|
||||
@@ -322,9 +319,13 @@ describe('Frontend', () => {
|
||||
mockWS.implementation.handleUpgrade.mock.calls[0][3](99);
|
||||
expect(mockWS.implementation.emit).toHaveBeenCalledWith('connection', 99, {url: 'http://localhost:8080/api'});
|
||||
|
||||
mockHTTP.variables.onRequest(1, 2);
|
||||
mockHTTP.variables.onRequest({url: '/file.txt'}, 2);
|
||||
expect(mockNodeStatic.implementation).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic.implementation).toHaveBeenCalledWith(1, 2, expect.any(Function));
|
||||
expect(mockNodeStatic.implementation).toHaveBeenCalledWith(
|
||||
{originalUrl: '/file.txt', url: '/file.txt', path: '/file.txt'},
|
||||
2,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Static server', async () => {
|
||||
@@ -341,7 +342,6 @@ describe('Frontend', () => {
|
||||
await controller.start();
|
||||
|
||||
const mockSocket = {destroy: jest.fn()};
|
||||
mockWS.implementation.handleUpgrade.mockClear();
|
||||
mockHTTP.events.upgrade({url: '/api'}, mockSocket, mockWSocket);
|
||||
expect(mockWS.implementation.handleUpgrade).toHaveBeenCalledTimes(1);
|
||||
expect(mockSocket.destroy).toHaveBeenCalledTimes(0);
|
||||
@@ -361,4 +361,67 @@ describe('Frontend', () => {
|
||||
mockWS.implementation.handleUpgrade.mock.calls[0][3](mockWSocket);
|
||||
expect(mockWS.implementation.emit).toHaveBeenCalledWith('connection', mockWSocket, {url});
|
||||
});
|
||||
|
||||
it.each(['/z2m/', '/z2m'])('Works with non-default base url %s', async (baseUrl) => {
|
||||
settings.set(['frontend'], {base_url: baseUrl});
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
|
||||
expect(ws.Server).toHaveBeenCalledWith({noServer: true, path: '/z2m/api'});
|
||||
|
||||
mockHTTP.variables.onRequest({url: '/z2m'}, 2);
|
||||
expect(mockNodeStatic.implementation).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic.implementation).toHaveBeenCalledWith({originalUrl: '/z2m', url: '/', path: '/'}, 2, expect.any(Function));
|
||||
expect(mockFinalHandler.implementation).not.toHaveBeenCalledWith();
|
||||
|
||||
mockNodeStatic.implementation.mockReset();
|
||||
expect(mockFinalHandler.implementation).not.toHaveBeenCalledWith();
|
||||
mockHTTP.variables.onRequest({url: '/z2m/file.txt'}, 2);
|
||||
expect(mockNodeStatic.implementation).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic.implementation).toHaveBeenCalledWith(
|
||||
{originalUrl: '/z2m/file.txt', url: '/file.txt', path: '/file.txt'},
|
||||
2,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockFinalHandler.implementation).not.toHaveBeenCalledWith();
|
||||
|
||||
mockNodeStatic.implementation.mockReset();
|
||||
mockHTTP.variables.onRequest({url: '/z/file.txt'}, 2);
|
||||
expect(mockNodeStatic.implementation).not.toHaveBeenCalled();
|
||||
expect(mockFinalHandler.implementation).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Works with non-default complex base url', async () => {
|
||||
const baseUrl = '/z2m-more++/c0mplex.url/';
|
||||
settings.set(['frontend'], {base_url: baseUrl});
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
|
||||
expect(ws.Server).toHaveBeenCalledWith({noServer: true, path: '/z2m-more++/c0mplex.url/api'});
|
||||
|
||||
mockHTTP.variables.onRequest({url: '/z2m-more++/c0mplex.url'}, 2);
|
||||
expect(mockNodeStatic.implementation).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic.implementation).toHaveBeenCalledWith(
|
||||
{originalUrl: '/z2m-more++/c0mplex.url', url: '/', path: '/'},
|
||||
2,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockFinalHandler.implementation).not.toHaveBeenCalledWith();
|
||||
|
||||
mockNodeStatic.implementation.mockReset();
|
||||
expect(mockFinalHandler.implementation).not.toHaveBeenCalledWith();
|
||||
mockHTTP.variables.onRequest({url: '/z2m-more++/c0mplex.url/file.txt'}, 2);
|
||||
expect(mockNodeStatic.implementation).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic.implementation).toHaveBeenCalledWith(
|
||||
{originalUrl: '/z2m-more++/c0mplex.url/file.txt', url: '/file.txt', path: '/file.txt'},
|
||||
2,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockFinalHandler.implementation).not.toHaveBeenCalledWith();
|
||||
|
||||
mockNodeStatic.implementation.mockReset();
|
||||
mockHTTP.variables.onRequest({url: '/z/file.txt'}, 2);
|
||||
expect(mockNodeStatic.implementation).not.toHaveBeenCalled();
|
||||
expect(mockFinalHandler.implementation).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+118
-29
@@ -89,6 +89,9 @@ describe('HomeAssistant extension', () => {
|
||||
});
|
||||
|
||||
it('Should discover devices and groups', async () => {
|
||||
settings.set(['homeassistant'], {experimental_event_entities: true});
|
||||
await resetExtension();
|
||||
|
||||
let payload;
|
||||
|
||||
payload = {
|
||||
@@ -404,6 +407,57 @@ describe('HomeAssistant extension', () => {
|
||||
{retain: true, qos: 1},
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
payload = {
|
||||
availability: [{topic: 'zigbee2mqtt/bridge/state'}],
|
||||
device: {
|
||||
identifiers: ['zigbee2mqtt_0x0017880104e45520'],
|
||||
manufacturer: 'Aqara',
|
||||
model: 'Wireless mini switch (WXKG11LM)',
|
||||
name: 'button',
|
||||
sw_version: null,
|
||||
via_device: 'zigbee2mqtt_bridge_0x00124b00120144ae',
|
||||
},
|
||||
event_types: ['single', 'double', 'triple', 'quadruple', 'hold', 'release'],
|
||||
icon: 'mdi:gesture-double-tap',
|
||||
json_attributes_topic: 'zigbee2mqtt/button',
|
||||
name: 'Action',
|
||||
object_id: 'button_action',
|
||||
origin: origin,
|
||||
state_topic: 'zigbee2mqtt/button',
|
||||
unique_id: '0x0017880104e45520_action_zigbee2mqtt',
|
||||
// Needs to be updated whenever one of the ACTION_*_PATTERN constants changes.
|
||||
value_template:
|
||||
"{% set patterns = [\n{\"pattern\": '^(?P<button>(?:button_)?[a-z0-9]+)_(?P<action>(?:press|hold)(?:_release)?)$', \"groups\": [\"button\", \"action\"]},\n{\"pattern\": '^(?P<action>recall|scene)_(?P<scene>[0-2][0-9]{0,2})$', \"groups\": [\"action\", \"scene\"]},\n{\"pattern\": '^(?P<actionPrefix>region_)(?P<region>[1-9]|10)_(?P<action>enter|leave|occupied|unoccupied)$', \"groups\": [\"actionPrefix\", \"region\", \"action\"]},\n{\"pattern\": '^(?P<action>dial_rotate)_(?P<direction>left|right)_(?P<speed>step|slow|fast)$', \"groups\": [\"action\", \"direction\", \"speed\"]},\n{\"pattern\": '^(?P<action>brightness_step)(?:_(?P<direction>up|down))?$', \"groups\": [\"action\", \"direction\"]}\n] %}\n{% set action_value = value_json.action|default('') %}\n{% set ns = namespace(r=[('action', action_value)]) %}\n{% for p in patterns %}\n {% set m = action_value|regex_findall(p.pattern) %}\n {% if m[0] is undefined %}{% continue %}{% endif %}\n {% for key, value in zip(p.groups, m[0]) %}\n {% set ns.r = ns.r|rejectattr(0, 'eq', key)|list + [(key, value)] %}\n {% endfor %}\n{% endfor %}\n{% if ns.r|selectattr(0, 'eq', 'actionPrefix')|first is defined %}\n {% set ns.r = ns.r|rejectattr(0, 'eq', 'action')|list + [('action', ns.r|selectattr(0, 'eq', 'actionPrefix')|map(attribute=1)|first + ns.r|selectattr(0, 'eq', 'action')|map(attribute=1)|first)] %}\n{% endif %}\n{% set ns.r = ns.r + [('event_type', ns.r|selectattr(0, 'eq', 'action')|map(attribute=1)|first)] %}\n{{dict.from_keys(ns.r|rejectattr(0, 'in', 'action, actionPrefix')|reject('eq', ('event_type', None))|reject('eq', ('event_type', '')))|to_json}}",
|
||||
};
|
||||
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'homeassistant/event/0x0017880104e45520/action/config',
|
||||
stringify(payload),
|
||||
{retain: true, qos: 1},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['recall_1', {action: 'recall', scene: '1'}],
|
||||
['recall_*', {action: 'recall', scene: 'wildcard'}],
|
||||
['on', {action: 'on'}],
|
||||
['on_1', {action: 'on_1'}],
|
||||
['release_left', {action: 'release_left'}],
|
||||
['region_1_enter', {action: 'region_enter', region: '1'}],
|
||||
['region_*_leave', {action: 'region_leave', region: 'wildcard'}],
|
||||
['left_press', {action: 'press', button: 'left'}],
|
||||
['left_press_release', {action: 'press_release', button: 'left'}],
|
||||
['right_hold', {action: 'hold', button: 'right'}],
|
||||
['right_hold_release', {action: 'hold_release', button: 'right'}],
|
||||
['button_4_hold_release', {action: 'hold_release', button: 'button_4'}],
|
||||
['dial_rotate_left_step', {action: 'dial_rotate', direction: 'left', speed: 'step'}],
|
||||
['dial_rotate_right_fast', {action: 'dial_rotate', direction: 'right', speed: 'fast'}],
|
||||
['brightness_step_up', {action: 'brightness_step', direction: 'up'}],
|
||||
['brightness_stop', {action: 'brightness_stop'}],
|
||||
])('Should parse action names correctly', (action, expected) => {
|
||||
expect(extension.parseActionValue(action)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('Should not discovery devices which are already discovered', async () => {
|
||||
@@ -462,7 +516,7 @@ describe('HomeAssistant extension', () => {
|
||||
|
||||
expect(MQTT.publish).not.toHaveBeenCalledWith(topic1, expect.anything(), expect.any(Object), expect.any(Function));
|
||||
// Device automation should not be cleared
|
||||
expect(MQTT.publish).not.toHaveBeenCalledWith(topic2, null, expect.any(Object), expect.any(Function));
|
||||
expect(MQTT.publish).not.toHaveBeenCalledWith(topic2, '', expect.any(Object), expect.any(Function));
|
||||
expect(logger.debug).toHaveBeenCalledWith(`Skipping discovery of 'sensor/0x0017880104e45522/humidity/config', already discovered`);
|
||||
});
|
||||
|
||||
@@ -1413,31 +1467,31 @@ describe('HomeAssistant extension', () => {
|
||||
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'homeassistant/sensor/0x0017880104e45522/temperature/config',
|
||||
null,
|
||||
'',
|
||||
{retain: true, qos: 1},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'homeassistant/sensor/0x0017880104e45522/humidity/config',
|
||||
null,
|
||||
'',
|
||||
{retain: true, qos: 1},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'homeassistant/sensor/0x0017880104e45522/pressure/config',
|
||||
null,
|
||||
'',
|
||||
{retain: true, qos: 1},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'homeassistant/sensor/0x0017880104e45522/battery/config',
|
||||
null,
|
||||
'',
|
||||
{retain: true, qos: 1},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'homeassistant/sensor/0x0017880104e45522/linkquality/config',
|
||||
null,
|
||||
'',
|
||||
{retain: true, qos: 1},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -1450,7 +1504,7 @@ describe('HomeAssistant extension', () => {
|
||||
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'homeassistant/light/1221051039810110150109113116116_9/light/config',
|
||||
null,
|
||||
'',
|
||||
{retain: true, qos: 1},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -1502,7 +1556,7 @@ describe('HomeAssistant extension', () => {
|
||||
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'homeassistant/sensor/0x0017880104e45522/temperature/config',
|
||||
null,
|
||||
'',
|
||||
{retain: true, qos: 1},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -1587,7 +1641,7 @@ describe('HomeAssistant extension', () => {
|
||||
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'homeassistant/light/1221051039810110150109113116116_9/light/config',
|
||||
null,
|
||||
'',
|
||||
{retain: true, qos: 1},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -1603,7 +1657,7 @@ describe('HomeAssistant extension', () => {
|
||||
|
||||
expect(MQTT.publish).not.toHaveBeenCalledWith(
|
||||
'homeassistant/sensor/0x0017880104e45522/temperature/config',
|
||||
null,
|
||||
'',
|
||||
{retain: true, qos: 1},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -1915,6 +1969,46 @@ describe('HomeAssistant extension', () => {
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('Should enable experimental event entities', async () => {
|
||||
settings.set(['homeassistant'], {experimental_event_entities: true});
|
||||
settings.set(['devices', '0x0017880104e45520'], {
|
||||
legacy: false,
|
||||
friendly_name: 'button',
|
||||
retain: false,
|
||||
});
|
||||
await resetExtension();
|
||||
|
||||
const payload = {
|
||||
availability: [{topic: 'zigbee2mqtt/bridge/state'}],
|
||||
device: {
|
||||
identifiers: ['zigbee2mqtt_0x0017880104e45520'],
|
||||
manufacturer: 'Aqara',
|
||||
model: 'Wireless mini switch (WXKG11LM)',
|
||||
name: 'button',
|
||||
sw_version: null,
|
||||
via_device: 'zigbee2mqtt_bridge_0x00124b00120144ae',
|
||||
},
|
||||
event_types: ['single', 'double', 'triple', 'quadruple', 'hold', 'release'],
|
||||
icon: 'mdi:gesture-double-tap',
|
||||
json_attributes_topic: 'zigbee2mqtt/button',
|
||||
name: 'Action',
|
||||
object_id: 'button_action',
|
||||
origin: origin,
|
||||
state_topic: 'zigbee2mqtt/button',
|
||||
unique_id: '0x0017880104e45520_action_zigbee2mqtt',
|
||||
// Needs to be updated whenever one of the ACTION_*_PATTERN constants changes.
|
||||
value_template:
|
||||
"{% set patterns = [\n{\"pattern\": '^(?P<button>(?:button_)?[a-z0-9]+)_(?P<action>(?:press|hold)(?:_release)?)$', \"groups\": [\"button\", \"action\"]},\n{\"pattern\": '^(?P<action>recall|scene)_(?P<scene>[0-2][0-9]{0,2})$', \"groups\": [\"action\", \"scene\"]},\n{\"pattern\": '^(?P<actionPrefix>region_)(?P<region>[1-9]|10)_(?P<action>enter|leave|occupied|unoccupied)$', \"groups\": [\"actionPrefix\", \"region\", \"action\"]},\n{\"pattern\": '^(?P<action>dial_rotate)_(?P<direction>left|right)_(?P<speed>step|slow|fast)$', \"groups\": [\"action\", \"direction\", \"speed\"]},\n{\"pattern\": '^(?P<action>brightness_step)(?:_(?P<direction>up|down))?$', \"groups\": [\"action\", \"direction\"]}\n] %}\n{% set action_value = value_json.action|default('') %}\n{% set ns = namespace(r=[('action', action_value)]) %}\n{% for p in patterns %}\n {% set m = action_value|regex_findall(p.pattern) %}\n {% if m[0] is undefined %}{% continue %}{% endif %}\n {% for key, value in zip(p.groups, m[0]) %}\n {% set ns.r = ns.r|rejectattr(0, 'eq', key)|list + [(key, value)] %}\n {% endfor %}\n{% endfor %}\n{% if ns.r|selectattr(0, 'eq', 'actionPrefix')|first is defined %}\n {% set ns.r = ns.r|rejectattr(0, 'eq', 'action')|list + [('action', ns.r|selectattr(0, 'eq', 'actionPrefix')|map(attribute=1)|first + ns.r|selectattr(0, 'eq', 'action')|map(attribute=1)|first)] %}\n{% endif %}\n{% set ns.r = ns.r + [('event_type', ns.r|selectattr(0, 'eq', 'action')|map(attribute=1)|first)] %}\n{{dict.from_keys(ns.r|rejectattr(0, 'in', 'action, actionPrefix')|reject('eq', ('event_type', None))|reject('eq', ('event_type', '')))|to_json}}",
|
||||
};
|
||||
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'homeassistant/event/0x0017880104e45520/action/config',
|
||||
stringify(payload),
|
||||
{retain: true, qos: 1},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should republish payload to postfix topic with lightWithPostfix config', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
|
||||
@@ -1922,19 +2016,19 @@ describe('HomeAssistant extension', () => {
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/U202DST600ZB',
|
||||
stringify({state_l2: 'ON', brightness_l2: 20, linkquality: null, state_l1: null, power_on_behavior: null}),
|
||||
stringify({state_l2: 'ON', brightness_l2: 20, linkquality: null, state_l1: null, power_on_behavior_l1: null, power_on_behavior_l2: null}),
|
||||
{qos: 0, retain: false},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/U202DST600ZB/l2',
|
||||
stringify({state: 'ON', brightness: 20}),
|
||||
stringify({state: 'ON', brightness: 20, power_on_behavior: null}),
|
||||
{qos: 0, retain: false},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/U202DST600ZB/l1',
|
||||
stringify({state: null}),
|
||||
stringify({state: null, power_on_behavior: null}),
|
||||
{qos: 0, retain: false},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -1995,7 +2089,7 @@ describe('HomeAssistant extension', () => {
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'homeassistant/light/1221051039810110150109113116116_91231/light/config',
|
||||
null,
|
||||
'',
|
||||
{qos: 1, retain: true},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -2014,7 +2108,7 @@ describe('HomeAssistant extension', () => {
|
||||
await MQTT.events.message('homeassistant/light/9/light/config', stringify({availability: [{topic: 'zigbee2mqtt/bridge/state'}]}));
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('homeassistant/light/9/light/config', null, {qos: 1, retain: true}, expect.any(Function));
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('homeassistant/light/9/light/config', '', {qos: 1, retain: true}, expect.any(Function));
|
||||
|
||||
// Existing group, non existing config -> clear
|
||||
MQTT.publish.mockClear();
|
||||
@@ -2026,7 +2120,7 @@ describe('HomeAssistant extension', () => {
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'homeassistant/light/1221051039810110150109113116116_9/switch/config',
|
||||
null,
|
||||
'',
|
||||
{qos: 1, retain: true},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -2036,12 +2130,7 @@ describe('HomeAssistant extension', () => {
|
||||
await MQTT.events.message('homeassistant/sensor/0x123/temperature/config', stringify({availability: [{topic: 'zigbee2mqtt/bridge/state'}]}));
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'homeassistant/sensor/0x123/temperature/config',
|
||||
null,
|
||||
{qos: 1, retain: true},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith('homeassistant/sensor/0x123/temperature/config', '', {qos: 1, retain: true}, expect.any(Function));
|
||||
|
||||
// Existing device -> don't clear
|
||||
MQTT.publish.mockClear();
|
||||
@@ -2071,7 +2160,7 @@ describe('HomeAssistant extension', () => {
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'homeassistant/sensor/0x000b57fffec6a5b2/update_available/config',
|
||||
null,
|
||||
'',
|
||||
{qos: 1, retain: true},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -2112,7 +2201,7 @@ describe('HomeAssistant extension', () => {
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'homeassistant/sensor/0x000b57fffec6a5b2/update_available/config',
|
||||
null,
|
||||
'',
|
||||
{qos: 1, retain: true},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -2124,7 +2213,7 @@ describe('HomeAssistant extension', () => {
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'homeassistant/device_automation/0x000b57fffec6a5b2/action_button_3_single/config',
|
||||
null,
|
||||
'',
|
||||
{qos: 1, retain: true},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -2406,7 +2495,7 @@ describe('HomeAssistant extension', () => {
|
||||
// Discovery messages for scenes have been purged.
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
`homeassistant/scene/0x000b57fffec6a5b4/scene_1/config`,
|
||||
null,
|
||||
'',
|
||||
{retain: true, qos: 1},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -2450,7 +2539,7 @@ describe('HomeAssistant extension', () => {
|
||||
// Discovery messages for scenes have been purged.
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
`homeassistant/scene/1221051039810110150109113116116_9/scene_4/config`,
|
||||
null,
|
||||
'',
|
||||
{retain: true, qos: 1},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -2514,7 +2603,7 @@ describe('HomeAssistant extension', () => {
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(MQTT.publish).not.toHaveBeenCalledWith(topic, null, {retain: true, qos: 1}, expect.any(Function));
|
||||
expect(MQTT.publish).not.toHaveBeenCalledWith(topic, '', {retain: true, qos: 1}, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Should discover bridge entities', async () => {
|
||||
@@ -2737,7 +2826,7 @@ describe('HomeAssistant extension', () => {
|
||||
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'homeassistant/light/0xf4ce368a38be56a1/light_l2/config',
|
||||
null,
|
||||
'',
|
||||
{retain: true, qos: 1},
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
@@ -253,7 +253,7 @@ describe('Bridge legacy', () => {
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual({ID: '0x000b57fffec6a5b3', friendly_name: 'bulb_color', retain: false});
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/rename', stringify({old: 'bulb_color', new: 'bulb_color2'}));
|
||||
await flushPromises();
|
||||
expect(settings.getDevice('bulb_color')).toStrictEqual(null);
|
||||
expect(settings.getDevice('bulb_color')).toBeUndefined();
|
||||
expect(settings.getDevice('bulb_color2')).toStrictEqual(bulb_color2);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/log',
|
||||
@@ -374,7 +374,7 @@ describe('Bridge legacy', () => {
|
||||
const group = zigbeeHerdsman.groups.group_1;
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/remove_group', 'group_1');
|
||||
await flushPromises();
|
||||
expect(settings.getGroup('to_be_removed')).toStrictEqual(null);
|
||||
expect(settings.getGroup('to_be_removed')).toBeUndefined();
|
||||
expect(group.removeFromNetwork).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/log',
|
||||
@@ -388,7 +388,7 @@ describe('Bridge legacy', () => {
|
||||
const group = zigbeeHerdsman.groups.group_1;
|
||||
MQTT.events.message('zigbee2mqtt/bridge/config/force_remove_group', 'group_1');
|
||||
await flushPromises();
|
||||
expect(settings.getGroup('to_be_removed')).toStrictEqual(null);
|
||||
expect(settings.getGroup('to_be_removed')).toBeUndefined();
|
||||
expect(group.removeFromDatabase).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/log',
|
||||
@@ -424,7 +424,7 @@ describe('Bridge legacy', () => {
|
||||
await flushPromises();
|
||||
expect(device.removeFromNetwork).toHaveBeenCalledTimes(1);
|
||||
expect(controller.state[device.ieeeAddr]).toBeUndefined();
|
||||
expect(settings.getDevice('bulb_color')).toBeNull();
|
||||
expect(settings.getDevice('bulb_color')).toBeUndefined();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/log',
|
||||
stringify({type: 'device_removed', message: 'bulb_color'}),
|
||||
@@ -446,7 +446,7 @@ describe('Bridge legacy', () => {
|
||||
await flushPromises();
|
||||
expect(device.removeFromDatabase).toHaveBeenCalledTimes(1);
|
||||
expect(controller.state[device.ieeeAddr]).toBeUndefined();
|
||||
expect(settings.getDevice('bulb_color')).toBeNull();
|
||||
expect(settings.getDevice('bulb_color')).toBeUndefined();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/log',
|
||||
stringify({type: 'device_force_removed', message: 'bulb_color'}),
|
||||
@@ -467,7 +467,7 @@ describe('Bridge legacy', () => {
|
||||
await flushPromises();
|
||||
expect(device.removeFromNetwork).toHaveBeenCalledTimes(1);
|
||||
expect(controller.state[device.ieeeAddr]).toBeUndefined();
|
||||
expect(settings.getDevice('bulb_color')).toBeNull();
|
||||
expect(settings.getDevice('bulb_color')).toBeUndefined();
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/log',
|
||||
stringify({type: 'device_banned', message: 'bulb_color'}),
|
||||
|
||||
@@ -164,6 +164,9 @@ describe('Logger', () => {
|
||||
logger[level]('msg', 'abcd');
|
||||
expect(logSpy).toHaveBeenLastCalledWith(level, 'abcd: msg');
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(i++);
|
||||
logger[level](() => 'func msg', 'abcd');
|
||||
expect(logSpy).toHaveBeenLastCalledWith(level, 'abcd: func msg');
|
||||
expect(consoleWriteSpy).toHaveBeenCalledTimes(i++);
|
||||
|
||||
for (const higherLevel of otherLevels.higher) {
|
||||
logger[higherLevel]('higher msg');
|
||||
|
||||
@@ -187,7 +187,7 @@ describe('Networkmap', () => {
|
||||
],
|
||||
nodes: [
|
||||
{
|
||||
definition: null,
|
||||
// definition: null,
|
||||
failed: [],
|
||||
friendlyName: 'Coordinator',
|
||||
ieeeAddr: '0x00124b00120144ae',
|
||||
@@ -201,7 +201,7 @@ describe('Networkmap', () => {
|
||||
description: 'TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm',
|
||||
model: 'LED1545G12',
|
||||
supports:
|
||||
'light (state, brightness, color_temp, color_temp_startup), effect, power_on_behavior, color_options, identify, linkquality',
|
||||
'light (state, brightness, color_temp, color_temp_startup, level_config), effect, power_on_behavior, color_options, identify, linkquality',
|
||||
vendor: 'IKEA',
|
||||
},
|
||||
failed: [],
|
||||
@@ -533,7 +533,7 @@ describe('Networkmap', () => {
|
||||
],
|
||||
nodes: [
|
||||
{
|
||||
definition: null,
|
||||
// definition: null,
|
||||
failed: [],
|
||||
friendlyName: 'Coordinator',
|
||||
ieeeAddr: '0x00124b00120144ae',
|
||||
@@ -547,7 +547,7 @@ describe('Networkmap', () => {
|
||||
description: 'TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm',
|
||||
model: 'LED1545G12',
|
||||
supports:
|
||||
'light (state, brightness, color_temp, color_temp_startup), effect, power_on_behavior, color_options, identify, linkquality',
|
||||
'light (state, brightness, color_temp, color_temp_startup, level_config), effect, power_on_behavior, color_options, identify, linkquality',
|
||||
vendor: 'IKEA',
|
||||
},
|
||||
failed: [],
|
||||
@@ -717,7 +717,7 @@ describe('Networkmap', () => {
|
||||
],
|
||||
nodes: [
|
||||
{
|
||||
definition: null,
|
||||
// definition: null,
|
||||
failed: [],
|
||||
friendlyName: 'Coordinator',
|
||||
ieeeAddr: '0x00124b00120144ae',
|
||||
@@ -873,7 +873,7 @@ describe('Networkmap', () => {
|
||||
],
|
||||
nodes: [
|
||||
{
|
||||
definition: null,
|
||||
// definition: null,
|
||||
failed: [],
|
||||
friendlyName: 'Coordinator',
|
||||
ieeeAddr: '0x00124b00120144ae',
|
||||
|
||||
+18
-2
@@ -67,7 +67,21 @@ describe('On event', () => {
|
||||
await zigbeeHerdsman.events.deviceAnnounce({device});
|
||||
await flushPromises();
|
||||
expect(mockOnEvent).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnEvent).toHaveBeenCalledWith('deviceAnnounce', {device}, device, settings.getDevice(device.ieeeAddr), {});
|
||||
expect(mockOnEvent).toHaveBeenCalledWith(
|
||||
'deviceAnnounce',
|
||||
{device},
|
||||
device,
|
||||
settings.getDevice(device.ieeeAddr),
|
||||
{},
|
||||
{
|
||||
deviceExposesChanged: expect.any(Function),
|
||||
},
|
||||
);
|
||||
|
||||
// Test deviceExposesChanged
|
||||
MQTT.publish.mockClear();
|
||||
console.log(mockOnEvent.mock.calls[0][5].deviceExposesChanged());
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/devices');
|
||||
});
|
||||
|
||||
it('Should call index onEvent with zigbee event', async () => {
|
||||
@@ -75,6 +89,8 @@ describe('On event', () => {
|
||||
await zigbeeHerdsman.events.deviceAnnounce({device});
|
||||
await flushPromises();
|
||||
expect(zigbeeHerdsmanConverters.onEvent).toHaveBeenCalledTimes(1);
|
||||
expect(zigbeeHerdsmanConverters.onEvent).toHaveBeenCalledWith('deviceAnnounce', {device}, device);
|
||||
expect(zigbeeHerdsmanConverters.onEvent).toHaveBeenCalledWith('deviceAnnounce', {device}, device, {
|
||||
deviceExposesChanged: expect.any(Function),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -594,6 +594,15 @@ describe('Publish', () => {
|
||||
expect(endpoint2.read).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should log error when device has no definition', async () => {
|
||||
const device = zigbeeHerdsman.devices.interviewing;
|
||||
logger.error.mockClear();
|
||||
await MQTT.events.message(`zigbee2mqtt/${device.ieeeAddr}/set`, stringify({state: 'OFF'}));
|
||||
await flushPromises();
|
||||
console.log(logger.error.mock.calls);
|
||||
expect(logger.log).toHaveBeenCalledWith('error', `Cannot publish to unsupported device 'button_double_key_interviewing'`, 'z2m');
|
||||
});
|
||||
|
||||
it('Should log error when device has no such endpoint (via property)', async () => {
|
||||
const device = zigbeeHerdsman.devices.QBKG03LM;
|
||||
const endpoint2 = device.getEndpoint(2);
|
||||
|
||||
+74
-1
@@ -355,6 +355,79 @@ describe('Receive', () => {
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({temperature: 0.09, humidity: 0.01, pressure: 2});
|
||||
});
|
||||
|
||||
it('Should throttle multiple messages from spamming devices', async () => {
|
||||
const device = zigbeeHerdsman.devices.SPAMMER;
|
||||
const throttle_for_testing = 1;
|
||||
settings.set(['device_options', 'throttle'], throttle_for_testing);
|
||||
settings.set(['device_options', 'retain'], true);
|
||||
settings.set(['devices', device.ieeeAddr, 'friendly_name'], 'spammer1');
|
||||
const data1 = {measuredValue: 1};
|
||||
const payload1 = {
|
||||
data: data1,
|
||||
cluster: 'msTemperatureMeasurement',
|
||||
device,
|
||||
endpoint: device.getEndpoint(1),
|
||||
type: 'attributeReport',
|
||||
linkquality: 10,
|
||||
};
|
||||
await zigbeeHerdsman.events.message(payload1);
|
||||
const data2 = {measuredValue: 2};
|
||||
const payload2 = {
|
||||
data: data2,
|
||||
cluster: 'msTemperatureMeasurement',
|
||||
device,
|
||||
endpoint: device.getEndpoint(1),
|
||||
type: 'attributeReport',
|
||||
linkquality: 10,
|
||||
};
|
||||
await zigbeeHerdsman.events.message(payload2);
|
||||
const data3 = {measuredValue: 3};
|
||||
const payload3 = {
|
||||
data: data3,
|
||||
cluster: 'msTemperatureMeasurement',
|
||||
device,
|
||||
endpoint: device.getEndpoint(1),
|
||||
type: 'attributeReport',
|
||||
linkquality: 10,
|
||||
};
|
||||
await zigbeeHerdsman.events.message(payload3);
|
||||
await flushPromises();
|
||||
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
await flushPromises();
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(1);
|
||||
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/spammer1');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({temperature: 0.01});
|
||||
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 0, retain: true});
|
||||
|
||||
// Now we try after elapsed time to see if it publishes next message
|
||||
const timeshift = throttle_for_testing * 2000;
|
||||
jest.advanceTimersByTime(timeshift);
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(2);
|
||||
await flushPromises();
|
||||
|
||||
expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/spammer1');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({temperature: 0.03});
|
||||
expect(MQTT.publish.mock.calls[1][2]).toStrictEqual({qos: 0, retain: true});
|
||||
|
||||
const data4 = {measuredValue: 4};
|
||||
const payload4 = {
|
||||
data: data4,
|
||||
cluster: 'msTemperatureMeasurement',
|
||||
device,
|
||||
endpoint: device.getEndpoint(1),
|
||||
type: 'attributeReport',
|
||||
linkquality: 10,
|
||||
};
|
||||
await zigbeeHerdsman.events.message(payload4);
|
||||
await flushPromises();
|
||||
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(3);
|
||||
expect(MQTT.publish.mock.calls[2][0]).toStrictEqual('zigbee2mqtt/spammer1');
|
||||
expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({temperature: 0.04});
|
||||
expect(MQTT.publish.mock.calls[2][2]).toStrictEqual({qos: 0, retain: true});
|
||||
});
|
||||
|
||||
it('Shouldnt republish old state', async () => {
|
||||
// https://github.com/Koenkk/zigbee2mqtt/issues/3572
|
||||
const device = zigbeeHerdsman.devices.bulb;
|
||||
@@ -629,7 +702,7 @@ describe('Receive', () => {
|
||||
it('Should report correct energy and power values for different versions of SP600', async () => {
|
||||
// https://github.com/Koenkk/zigbee-herdsman-converters/issues/915, OLD and NEW use different date code
|
||||
// divisor of OLD is not correct and therefore underreports by factor 10.
|
||||
const data = {instantaneousDemand: 496, currentSummDelivered: [0, 6648]};
|
||||
const data = {instantaneousDemand: 496, currentSummDelivered: 6648};
|
||||
|
||||
const SP600_NEW = zigbeeHerdsman.devices.SP600_NEW;
|
||||
await zigbeeHerdsman.events.message({
|
||||
|
||||
@@ -795,7 +795,7 @@ describe('Settings', () => {
|
||||
|
||||
it('Should throw error when yaml file does not exist', () => {
|
||||
settings.testing.clear();
|
||||
expect(settings.validate()[0].startsWith(`ENOENT: no such file or directory, open `)).toBeTruthy();
|
||||
expect(settings.validate()[0]).toContain(`ENOENT: no such file or directory, open `);
|
||||
});
|
||||
|
||||
it('Configuration shouldnt be valid when invalid QOS value is used', async () => {
|
||||
@@ -978,7 +978,7 @@ describe('Settings', () => {
|
||||
write(configurationFile, {...minimalConfig, frontend: true});
|
||||
|
||||
settings.reRead();
|
||||
expect(settings.get().frontend).toStrictEqual({port: 8080, auth_token: false});
|
||||
expect(settings.get().frontend).toStrictEqual({port: 8080, auth_token: null, base_url: '/'});
|
||||
});
|
||||
|
||||
it('Baudrate config', () => {
|
||||
@@ -1026,6 +1026,7 @@ describe('Settings', () => {
|
||||
settings.reRead();
|
||||
expect(settings.get().homeassistant).toStrictEqual({
|
||||
discovery_topic: 'new',
|
||||
experimental_event_entities: false,
|
||||
legacy_entity_attributes: true,
|
||||
legacy_triggers: true,
|
||||
status_topic: 'olds',
|
||||
|
||||
+12
-13
@@ -1,24 +1,23 @@
|
||||
let level = 'info';
|
||||
let debugNamespaceIgnore = '';
|
||||
let namespacedLevels = {};
|
||||
|
||||
let transports = [];
|
||||
|
||||
let transportsEnabled = false;
|
||||
const callTransports = (level, message, namespace) => {
|
||||
if (transportsEnabled) {
|
||||
for (const transport of transports) {
|
||||
transport.log({level, message, namespace}, () => {});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getMessage = (messageOrLambda) => (messageOrLambda instanceof Function ? messageOrLambda() : messageOrLambda);
|
||||
const mock = {
|
||||
log: jest.fn().mockImplementation((level, message, namespace = 'z2m') => {
|
||||
if (transportsEnabled) {
|
||||
for (const transport of transports) {
|
||||
transport.log({level, message, namespace}, () => {});
|
||||
}
|
||||
}
|
||||
}),
|
||||
init: jest.fn(),
|
||||
info: jest.fn().mockImplementation((msg, namespace = 'z2m') => callTransports('info', msg, namespace)),
|
||||
warning: jest.fn().mockImplementation((msg, namespace = 'z2m') => callTransports('warning', msg, namespace)),
|
||||
error: jest.fn().mockImplementation((msg, namespace = 'z2m') => callTransports('error', msg, namespace)),
|
||||
debug: jest.fn().mockImplementation((msg, namespace = 'z2m') => callTransports('debug', msg, namespace)),
|
||||
info: jest.fn().mockImplementation((messageOrLambda, namespace = 'z2m') => mock.log('info', getMessage(messageOrLambda), namespace)),
|
||||
warning: jest.fn().mockImplementation((messageOrLambda, namespace = 'z2m') => mock.log('warning', getMessage(messageOrLambda), namespace)),
|
||||
error: jest.fn().mockImplementation((messageOrLambda, namespace = 'z2m') => mock.log('error', getMessage(messageOrLambda), namespace)),
|
||||
debug: jest.fn().mockImplementation((messageOrLambda, namespace = 'z2m') => mock.log('debug', getMessage(messageOrLambda), namespace)),
|
||||
cleanup: jest.fn(),
|
||||
logOutput: jest.fn(),
|
||||
add: (transport) => transports.push(transport),
|
||||
|
||||
@@ -503,6 +503,8 @@ const devices = {
|
||||
'lumi.sensor_86sw2.es1',
|
||||
),
|
||||
WSDCGQ11LM: new Device('EndDevice', '0x0017880104e45522', 6539, 4151, [new Endpoint(1, [0], [])], true, 'Battery', 'lumi.weather'),
|
||||
// This are not a real spammer device, just copy of previous to test the throttle filter
|
||||
SPAMMER: new Device('EndDevice', '0x0017880104e455fe', 6539, 4151, [new Endpoint(1, [0], [])], true, 'Battery', 'lumi.weather'),
|
||||
RTCGQ11LM: new Device('EndDevice', '0x0017880104e45523', 6540, 4151, [new Endpoint(1, [0], [])], true, 'Battery', 'lumi.sensor_motion.aq2'),
|
||||
ZNCZ02LM: ZNCZ02LM,
|
||||
E1743: new Device('Router', '0x0017880104e45540', 6540, 4476, [new Endpoint(1, [0], [])], true, 'Mains (single phase)', 'TRADFRI on/off switch'),
|
||||
@@ -861,7 +863,7 @@ const mock = {
|
||||
for (const key in devices) {
|
||||
const device = devices[key];
|
||||
|
||||
if ((returnDevices.length === 0 || returnDevices.includes(device.ieeeAddr)) && (!predicate || predicate(device))) {
|
||||
if ((returnDevices.length === 0 || returnDevices.includes(device.ieeeAddr)) && !device.isDeleted && (!predicate || predicate(device))) {
|
||||
yield device;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"esModuleInterop": true,
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitThis": true,
|
||||
"moduleResolution": "node",
|
||||
|
||||
Reference in New Issue
Block a user