Compare commits

...
116 Commits
Author SHA1 Message Date
Koen Kanters 861cba63b3 chore(dev): release 1.42.0 (#24569) 2024-12-01 14:33:44 +01:00
Koen Kanters 59d0eb4534 Update zigbee-herdsman-converters to 20.58.0 (#24987) 2024-12-01 13:30:48 +00:00
Koen Kanters 57ee303cda Update zigbee-herdsman-converters to 20.57.0 (#24977) 2024-11-30 20:21:19 +00:00
Der Mundschenk & Compagnie 4690fe1a55 fix(ignore): Ignore None actions for HA events (#24972) 2024-11-30 21:09:09 +01:00
Der Mundschenk & CompagnieandKoen Kanters 826547a8e1 fix(ignore): Prevent empty events from being published to HA (#24969)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2024-11-30 13:46:31 +01:00
Koen Kanters 7047aef7f7 fix(ignore): Fix Cannot read properties of undefined (reading 'clients') https://github.com/Koenkk/zigbee2mqtt/issues/24957 2024-11-29 20:43:30 +01:00
Der Mundschenk & Compagnie d82d782bac fix(ignore): Prevent incorrect event_type due to duplicate keys (#24954) 2024-11-29 20:26:33 +01:00
Koen Kantersandnurikk 6351f4618e Update zigbee2mqtt-frontend to 0.7.6 (#24955)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2024-11-28 20:44:30 +00:00
Koen Kanters 2c52c9884c Update zigbee-herdsman-converters to 20.56.0 (#24952) 2024-11-28 19:56:10 +00:00
Koen Kanters 5e47a21673 Update zigbee-herdsman-converters to 20.55.0 (#24939) 2024-11-27 19:41:38 +00:00
Der Mundschenk & Compagnie 5ca4cf4acd fix: Home Assistant: template errors when action key is missing or empty in state payload (#24931) 2024-11-27 08:11:07 +01:00
Koen Kanters 267d800122 Update zigbee-herdsman-converters to 20.54.0 (#24920) 2024-11-25 19:26:00 +00:00
Koen KantersandIlya Kirov 790373b896 fix(ignore): Update zigbee-herdsman-converters to 20.53.0 (#24906)
Co-authored-by: Ilya Kirov <kirovilya@gmail.com>
2024-11-24 21:29:37 +00:00
Koen Kanters ca6493528d fix(ignore): Update dependencies (#24894) 2024-11-24 22:01:37 +01:00
Koen Kanters 1e77893f75 fix(ignore): update zigbee-herdsman-converters to 20.52.0 (#24874) 2024-11-22 20:55:41 +00:00
Koen Kanters 82a026e02e fix(ignore): update zigbee-herdsman-converters to 20.51.0 (#24856) 2024-11-21 20:58:55 +00:00
Koen Kanters 988984a1ae fix(ignore): update zigbee-herdsman-converters to 20.50.0 (#24843) 2024-11-20 20:27:28 +00:00
Koen Kanters 5859947207 fix(ignore): update zigbee-herdsman-converters to 20.49.1 (#24830) 2024-11-19 19:43:54 +00:00
Koen Kanters b1508ae52f Update zigbee-herdsman-converters to 20.48.0 (#24820)
* fix(ignore): update zigbee-herdsman-converters to 20.48.0

* u
2024-11-18 21:06:11 +00:00
Koen Kanters 152ad12508 fix(ignore): update zigbee-herdsman to 2.1.9 (#24819) 2024-11-18 20:05:50 +00:00
Koen Kanters 36e452b0f3 fix(ignore): update zigbee-herdsman-converters to 20.47.0 (#24772) 2024-11-14 20:36:53 +00:00
Koen Kanters 2a9bbb21ba fix(ignore): update zigbee-herdsman to 2.1.8 (#24763) 2024-11-13 20:56:59 +00:00
Koen Kanters f7e7115f79 chore: attempt to fix codeql.yaml 2024-11-13 21:53:01 +01:00
Koen Kanters 6e04f7b03e fix(ignore): update zigbee-herdsman-converters to 20.46.0 (#24762) 2024-11-13 20:49:13 +00:00
Nerivec 2a1ab78f7a chore: Update codeql config (#24751)
* Update codeql config

* Fix prettier
2024-11-13 21:01:37 +01:00
Koen Kanters e5d02c4b95 chore: merge master to dev 2024-11-12 21:30:24 +01:00
Koen Kanters 69f7282952 chore: fix README.md badge 2024-11-12 21:30:05 +01:00
Koen Kanters b8c3104aa1 fix(ignore): update zigbee-herdsman-converters to 20.45.0 (#24733) 2024-11-12 20:23:45 +00:00
Der Mundschenk & Compagnie 22e13c52b9 fix: Home Assistant event entities, part 2 (#24717)
* Add Philips Hue Tap dial rotation

* Add support for Hue Tap buttons

* Refactor action parsing
2024-11-11 21:10:20 +01:00
Koen Kanters 32e2637594 fix(ignore): update zigbee-herdsman-converters to 20.44.0 (#24715) 2024-11-10 19:39:18 +00:00
Koen Kanters 928635f1da fix(ignore): update dependencies (#24702) 2024-11-10 15:11:17 +01:00
Koen Kanters a353acc8e3 fix(ignore): update zigbee-herdsman-converters to 20.43.0 (#24680) 2024-11-07 19:53:05 +00:00
Koen Kanters b5d4a253b4 fix(ignore): update zigbee-herdsman-converters to 20.42.0 (#24666) 2024-11-06 20:47:04 +00:00
Boris Dolgov 7267d27779 fix: Temporarily disable serving compressed index file. (#24657)
express-static-gzip does not handle redirects with custom base URLs
properly. As index.html file is only 5KB, compression is not critical
for it.
2024-11-06 21:11:02 +01:00
Koen Kanters f2e5d59c35 fix(ignore): update zigbee-herdsman-converters to 20.41.0 (#24656) 2024-11-05 21:16:36 +00:00
Koen Kanters 1558a5d8ac fix(ignore): update zigbee-herdsman-converters to 20.40.0 (#24644) 2024-11-04 21:07:13 +00:00
Boris Dolgov 87cee1aea6 fix: Replace connect-gzip-static with express-static-gzip to become compatible with Node 23 (#24619)
Old versions of connect-gzip-static are not compatible with node 23,
new versions of connect-gzip-static are not compatible with node 18,
but we want to support both. express-static-gzip is compatible with
all versions.
2024-11-04 20:57:25 +01:00
Koen Kantersandnurikk 0c2cc10ad8 fix(ignore): update zigbee2mqtt-frontend to 0.7.5 (#24631)
Co-authored-by: nurikk <1525421+nurikk@users.noreply.github.com>
2024-11-04 10:56:25 +00:00
Koen Kanters 84cc92f91a fix(ignore): update dependencies (#24614) 2024-11-03 16:00:30 +01:00
Koen Kanters a6bfab7934 Update zigbee-herdsman-converters to 20.39.2 (#24609)
* fix(ignore): update zigbee-herdsman-converters to 20.39.2

* u
2024-11-03 14:34:04 +00:00
Der Mundschenk & CompagnieandKoen Kanters 848f250dbb feat: Add experimental support for Home Assistant event entities (#24233)
* Expose new event entity for actions

* Unify exposed actions for HA

* Fix event tests

* Only add event entities wen homeassistant/experimental_event_entities is true

---------

Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2024-11-03 14:54:42 +01:00
Magnus Ihse BursieandKoen Kanters c078ccb136 fix: Home Assistant: remove the diagnostic category classification for power (#24575)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2024-11-02 18:56:57 +00:00
github-actions[bot] 67d3f05fd7 chore: promote to dev 2024-11-01 07:57:31 +00:00
Koen Kanters cc31ceabd1 chore(dev): release 1.41.0 (#24173) 2024-11-01 08:46:52 +01:00
Koen Kanters c5d30b585b fix(ignore): update zigbee-herdsman-converters to 20.38.0 (#24567) 2024-10-31 20:23:53 +00:00
Koen Kanters f1615a255c fix(ignore): update zigbee-herdsman to 2.1.7 (#24566) 2024-10-31 20:17:43 +00:00
Magnus Ihse Bursie a2ce3230ac fix: Add additional discovery lookups for Home Assistant (#24555) 2024-10-31 20:36:52 +01:00
Koen Kanters bd1f6d24a1 fix(ignore): update zigbee-herdsman-converters to 20.37.0 (#24553) 2024-10-30 19:47:11 +00:00
Koen Kanters 9837488f44 fix(ignore): update zigbee-herdsman to 2.1.6 (#24545) 2024-10-29 20:08:04 +00:00
Koen Kanters b620a21d46 fix(ignore): update zigbee-herdsman-converters to 20.36.0 (#24542) 2024-10-29 19:31:53 +00:00
Koen Kanters 2139e7a794 fix(ignore): update dependencies (#24498) 2024-10-29 20:26:52 +01:00
Bacchus777 e0a0f868c0 fix: Improve power_phase_b and power_phase_c Home Assistant discovery (#24538)
Added power_phase_b and power_phase_c
2024-10-29 20:07:07 +01:00
Cody Cutrer ab726061b7 fix: Remove extraneous whitespace in template for binary attributes (#24520)
I assume Home Assistant strips the whitespace at some point before
comparing it to payload_on/payload_off, but I haven't quite found where yet.
I have confirmed Python preserves the whitespace when simply evaluating
the Jinja.

For openHAB, it does not strip the whitespace, causing errors because
it doesn't match the payload_on or payload_off values. At some point I'd
like to bring openHAB inline with Home Assistant, but I need to do so
carefully without breaking something unexpectedly (especially with non-
Home Assistant MQTT integrations). In the meantime, this should fix the
issue in openHAB, without causing issues for Home Assistant or any other
software that follows Home Assistant's MQTT discovery process.
2024-10-28 21:16:40 +01:00
Koen Kanters 074b2e20e0 fix(ignore): update zigbee-herdsman-converters to 20.35.0 (#24514) 2024-10-27 20:54:05 +00:00
Koen Kanters 2b460d3a5f fix(ignore): update zigbee-herdsman-converters to 20.34.0 (#24493) 2024-10-26 13:20:20 +00:00
Koen Kanters c09aeecd95 fix(ignore): update zigbee-herdsman-converters to 20.33.0 (#24457) 2024-10-23 20:27:22 +00:00
Koen Kanters 9ad7c70f24 fix(ignore): update zigbee-herdsman-converters to 20.32.0 (#24434) 2024-10-21 20:24:51 +00:00
Koen Kanters 64778650d3 fix(ignore): update zigbee-herdsman to 2.1.5 (#24433) 2024-10-21 19:51:21 +00:00
Koen Kanters eb606a5b98 fix(ignore): update dependencies (#24411) 2024-10-20 20:22:33 +02:00
Koen Kanters 669a16f13a fix(ignore): update zigbee-herdsman-converters to 20.31.0 (#24408) 2024-10-19 18:48:46 +00:00
Koen Kanters b2d3bbce3c Update zigbee-herdsman-converters to 20.30.0 (#24398)
* fix(ignore): update zigbee-herdsman-converters to 20.30.0

* u
2024-10-19 12:00:50 +00:00
Koen Kanters 12cb6e135d fix(ignore): update zigbee-herdsman to 2.1.4 (#24397) 2024-10-18 18:11:34 +00:00
Koen Kanters d79e71b13e fix(ignore): update zigbee-herdsman-converters to 20.29.0 (#24363) 2024-10-16 20:07:32 +00:00
Koen Kanters dbda1481ec chore: improve testExternalConverter.js 2024-10-16 21:59:24 +02:00
Koen Kanters d12b117b43 fix(ignore): update zigbee-herdsman-converters to 20.28.0 (#24337) 2024-10-14 20:10:31 +00:00
Koen KantersandNerivec e2f19f19b4 fix: Frontend code cleanup @Nerivec (#24322)
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
2024-10-14 19:56:00 +02:00
Koen Kanters 618b318214 fix(ignore): update zigbee-herdsman-converters to 20.27.2 (#24321) 2024-10-13 20:32:13 +00:00
Koen Kanters 9f12ebb54e fix(ignore): update dependencies (#24309) 2024-10-13 20:10:19 +02:00
Koen Kanters db00759a03 fix(ignore): update zigbee-herdsman-converters to 20.26.0 (#24307) 2024-10-12 20:44:31 +00:00
Koen Kanters 5f601873fe fix(ignore): update zigbee-herdsman-converters to 20.25.0 (#24285) 2024-10-10 20:35:52 +00:00
Vladimir Kotikov 1fdf0a0a47 feat: Allow serving frontend under subpath (#24244)
* Allow app running in the server subdirectory

* Use path.posix to manage urls

* Add base_utl to config schema
2024-10-10 14:22:48 +02:00
Koen Kanters 3bb4af253a fix(ignore): update zigbee-herdsman-converters to 20.24.0 (#24246) 2024-10-06 21:07:58 +00:00
Massimiliano 076ada2325 fix: Fix group state change for open/close devices (#24226)
* Fix group state change if OPEN

* Runned Prettier
2024-10-06 21:35:46 +02:00
Koen Kanters 5c3f71292d fix(ignore): update dependencies (#24236) 2024-10-06 21:33:07 +02:00
Koen Kanters 02292590ee fix(ignore): update zigbee-herdsman-converters to 20.23.1 (#24225) 2024-10-05 13:40:41 +00:00
Koen Kanters b09ae10588 fix(ignore): update zigbee-herdsman-converters to 20.23.0 (#24201) 2024-10-03 19:34:26 +00:00
Koen Kanters e5fac76060 fix(ignore): update zigbee-herdsman-converters to 20.22.0 (#24186) 2024-10-02 19:48:01 +00:00
Ivan F. MartinezandKoen Kanters 17ac9cf8e7 feat: Add throttle option for devices (#24122)
* basic spam control

* used npm run pretty:write

* add test for SPAMMER description to comply with 100% coverage test

* define friendly name to spammer test devices

* Update README.md

Co-authored-by: Koen Kanters <koenkanters94@gmail.com>

* trying now with throttleit library

* lint corrections

* last lint request

* correct await / async definiction

* remove description support

* change first command to be executed

---------

Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2024-10-02 21:15:15 +02:00
github-actions[bot] 063d4620d2 chore: promote to dev 2024-10-01 19:41:45 +00:00
Koen Kanters e06848db8c chore(dev): release 1.40.2 (#23885) 2024-10-01 21:30:57 +02:00
Koen Kanters c82f93230d fix(ignore): update zigbee-herdsman-converters to 20.21.0 (#24172)
* fix(ignore): update zigbee-herdsman-converters to 20.21.0

* fix
2024-10-01 21:27:13 +02:00
Koen Kanters 13e4937db2 fix(ignore): update zigbee-herdsman to 2.1.3 (#24171) 2024-10-01 18:45:21 +00:00
Koen Kanters 58c856bbd0 fix(ignore): update zigbee-herdsman-converters to 20.20.0 (#24144) 2024-09-29 18:25:56 +00:00
Koen Kanters df1913bc56 fix(ignore): update zigbee-herdsman to 2.1.2 (#24138) 2024-09-29 11:47:51 +00:00
Koen Kanters 37f80dfb69 fix(ignore): update dependencies (#24132) 2024-09-29 13:20:17 +02:00
Koen Kanters 8fe76e090f fix(ignore): update zigbee-herdsman-converters to 20.19.0 (#24130)
* fix(ignore): update zigbee-herdsman-converters to 20.19.0

* fix tests
2024-09-28 18:47:32 +00:00
Koen Kanters 15d845bf92 fix(ignore): update zigbee-herdsman-converters to 20.18.0 (#24105) 2024-09-25 19:33:27 +00:00
Koen Kanters 22e1480f74 Update zigbee-herdsman-converters to 20.17.1 (#24092)
* fix(ignore): update zigbee-herdsman-converters to 20.17.1

* Update
2024-09-24 20:29:25 +00:00
Koen Kanters 5dd1bfb8a6 fix(ignore): update zigbee-herdsman to 2.1.1 (#24093) 2024-09-24 20:01:58 +00:00
Koen Kanters b1c7fd0033 fix(ignore): update zigbee-herdsman-converters to 20.17.0 (#24077) 2024-09-23 18:46:59 +00:00
Koen Kanters 2314147d0e fix(ignore): update zigbee-herdsman to 2.1.0 (#24076) 2024-09-23 18:35:57 +00:00
Koen Kanters ee4becb64e fix(ignore): update zigbee-herdsman to 2.0.0 (#24064) 2024-09-22 19:36:21 +00:00
Koen Kanters a5bb49aa71 fix(ignore): update dependencies (#24057)
* fix(ignore): update dependencies

* u
2024-09-22 20:08:50 +02:00
Koen Kanters d289194a71 fix(ignore): update zigbee-herdsman to 1.1.0 (#24054) 2024-09-21 19:06:54 +00:00
Koen Kanters 7ad51ce662 fix: Fix Home Assistant Invalid state message error when state is too long (#24045) 2024-09-21 00:06:44 +02:00
Koen Kanters 951bbd3576 fix(ignore): update zigbee-herdsman-converters to 20.16.3 (#24033) 2024-09-19 20:17:21 +00:00
Koen Kanters 8a54a01583 fix(ignore): update zigbee-herdsman to 1.0.1 (#24032) 2024-09-19 19:58:47 +00:00
Koen Kanters e7a3f0da0a fix(ignore): update zigbee-herdsman-converters to 20.16.2 (#24022) 2024-09-18 20:09:35 +00:00
Koen Kanters 2ae19a7c42 fix(ignore): update zigbee-herdsman-converters to 20.16.1 (#24009) 2024-09-17 20:30:55 +00:00
Koen Kanters da2a6974e0 fix(ignore): update zigbee-herdsman to 1.0.0 (#24008) 2024-09-17 20:24:12 +00:00
Koen Kanters d3de77da13 fix(ignore): update zigbee-herdsman-converters to 20.16.0 (#23990) 2024-09-16 20:49:03 +00:00
Koen Kanters 28650daf83 chore: fix cedc0623f7 2024-09-16 22:46:37 +02:00
cedc0623f7 chore: Fix CI - trigger helm update (#23977)
* Fix CI - trigger helm update

* Update .github/workflows/ci.yml

Co-authored-by: Koen Kanters <koenkanters94@gmail.com>

---------

Co-authored-by: jose <jose.pedrosa@reddit.com>
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2024-09-16 20:23:13 +02:00
Koen Kanters 3efc2b237b fix(ignore): update dependencies (#23973) 2024-09-15 20:46:51 +02:00
Koen Kanters 03b5b0043a fix(ignore): update zigbee-herdsman-converters to 20.15.0 (#23969) 2024-09-14 19:01:07 +00:00
Koen Kanters 70a65c7dcf fix(ignore): update zigbee-herdsman to 0.57.4 (#23968) 2024-09-14 18:46:56 +00:00
Koen Kanters eb8685682f fix(ignore): update zigbee-herdsman-converters to 20.14.1 (#23952) 2024-09-12 21:02:39 +00:00
Koen Kanters e642f7c86a fix(ignore): Sync eslint settings from zhc (#23951)
* fix(ignore): Sync eslint settings from zhc

* u
2024-09-12 19:36:57 +00:00
Tomer c6ca472ca1 fix: Improve performance when logging is disabled (#23893)
* Improve log performance

* Bug fix

* Revert sample configuration

* Align package versions

* Revert dependecies version to allow testing

* Update package-lock.json

* Fix prettier issue

* Coverage to 100%

* Fix CR comments

* remove uneeded changes

* Make the same perf optimization to the MQTT messages

* Fix MQTT tests

* prettier fix

* Allow easy mocking of stub logger callTransports function to receive the actual message

* cleanup leftover

* Fix prettier issues

* Finalize logger mock structure and add few lambda log calls

* fix prettier issue

* Run prettier

* Revert "fix prettier issue"

This reverts commit b409d538e3.

* Update zigbee.ts

Fix prettier issue

* Fix test issues

* Revert await removal
2024-09-12 20:33:05 +02:00
Koen Kanters 0540e4fbb4 fix(ignore): update zigbee-herdsman-converters to 20.13.0 (#23927) 2024-09-09 20:13:08 +00:00
Koen Kanters 9c72a768f2 fix(ignore): Improvement for afd80449b3 2024-09-08 21:33:56 +02:00
Koen Kanters 8c0476612f fix(ignore): update dependencies (#23904) 2024-09-08 14:28:03 +02:00
Koen Kanters afd80449b3 fix(ignore): Migrate to eslint 9 (#23800)
* Eslint 9

* Automatic changes

* Manual changes

* Process feedback

* u
2024-09-08 14:26:18 +02:00
Koen Kanters d989061122 fix(ignore): update dependencies (#23898) 2024-09-08 13:08:03 +02:00
NerivecandKoen Kanters 48d77e4b5e fix: Enforce TS strict type checking (#23601)
* Enforce TS `strict` type checking.

* updates

* updates

* updates

* Updates

* Updates

* pretty

* u

* u

* u

* Updates

* updates

* Updates

* Updates

* `ReadonlyArray`

* scenesChanged

* objectID

* Improve coverage

* u

* u

* process feedback

---------

Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2024-09-07 11:40:50 +00:00
github-actions[bot] 63c3bb4166 chore: promote to dev 2024-09-07 11:33:51 +00:00
63 changed files with 4299 additions and 5030 deletions
-2
View File
@@ -1,2 +0,0 @@
node_modules/*
test/
-63
View File
@@ -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,
},
],
},
},
],
};
+6 -6
View File
@@ -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: |
+5 -2
View File
@@ -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'
+1 -2
View File
@@ -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
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.40.1"
".": "1.42.0"
}
+119
View File
@@ -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)
+4 -2
View File
@@ -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
+32
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+20 -11
View File
@@ -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
View File
@@ -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
View File
@@ -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,
};
+14 -9
View File
@@ -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) {
+1 -1
View File
@@ -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`,
+12 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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});
}
File diff suppressed because it is too large Load Diff
+42 -38
View File
@@ -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) {
+15 -10
View File
@@ -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);
}
}
+6 -7
View File
@@ -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
View File
@@ -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,
});
+4 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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};
+28 -23
View File
@@ -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[];
+11 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+15 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
+1251 -3123
View File
File diff suppressed because it is too large Load Diff
+30 -26
View File
@@ -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": {
+2 -2
View File
@@ -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));
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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),
);
+6 -6
View File
@@ -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'}),
+3
View File
@@ -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');
+6 -6
View File
@@ -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
View File
@@ -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),
});
});
});
+9
View File
@@ -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
View File
@@ -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({
+3 -2
View File
@@ -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
View File
@@ -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),
+3 -1
View File
@@ -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;
}
}
+1
View File
@@ -5,6 +5,7 @@
"esModuleInterop": true,
"target": "ES2022",
"lib": ["ES2022"],
"strict": true,
"noImplicitAny": true,
"noImplicitThis": true,
"moduleResolution": "node",