Compare commits

...
37 Commits
Author SHA1 Message Date
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
56 changed files with 2926 additions and 2387 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: |
+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.40.2"
}
+38
View File
@@ -1,5 +1,43 @@
# Changelog
## [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)
+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})`);
}
}
}
+42 -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,22 @@ 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> => {
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));
}
+48 -26
View File
@@ -1,13 +1,16 @@
import bind from 'bind-decorator';
import gzipStatic, {RequestHandler} from 'connect-gzip-static';
import finalhandler from 'finalhandler';
import assert from 'assert';
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 bind from 'bind-decorator';
import gzipStatic, {RequestHandler} from 'connect-gzip-static';
import finalhandler from 'finalhandler';
import stringify from 'json-stable-stringify-without-jsonify';
import WebSocket from 'ws';
import frontend from 'zigbee2mqtt-frontend';
import logger from '../util/logger';
@@ -19,16 +22,37 @@ 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: http.Server | undefined;
private fileServer: RequestHandler | undefined;
private wss: WebSocket.Server | undefined;
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.mqttBaseTopic = settings.get().mqtt.base_topic;
}
private isHttpsConfigured(): boolean {
if (this.sslCert && this.sslKey) {
@@ -44,8 +68,8 @@ export default class Frontend extends Extension {
override async start(): Promise<void> {
if (this.isHttpsConfigured()) {
const serverOptions = {
key: fs.readFileSync(this.sslKey),
cert: fs.readFileSync(this.sslCert),
key: fs.readFileSync(this.sslKey!), // valid from `isHttpsConfigured`
cert: fs.readFileSync(this.sslCert!), // valid from `isHttpsConfigured`
};
this.server = https.createServer(serverOptions, this.onRequest);
} else {
@@ -56,8 +80,7 @@ export default class Frontend extends Extension {
/* istanbul ignore next */
const options = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setHeaders: (res: any, path: string): void => {
setHeaders: (res: http.ServerResponse, path: string): void => {
if (path.endsWith('index.html')) {
res.setHeader('Cache-Control', 'no-store');
}
@@ -90,25 +113,24 @@ export default class Frontend extends Extension {
this.wss?.close();
/* istanbul ignore else */
if (this.server) {
return new Promise((cb: () => void) => this.server.close(cb));
return await new Promise((cb: () => void) => this.server!.close(cb));
}
}
@bind private onRequest(request: http.IncomingMessage, response: http.ServerResponse): void {
// @ts-ignore
this.fileServer(request, response, finalhandler(request, response));
this.fileServer?.(request, response, finalhandler(request, response));
}
private authenticate(request: http.IncomingMessage, cb: (authenticate: boolean) => void): void {
const {query} = url.parse(request.url, true);
const {query} = url.parse(request.url!, true);
cb(!this.authToken || this.authToken === query.token);
}
@bind private onUpgrade(request: http.IncomingMessage, socket: net.Socket, head: Buffer): void {
this.wss.handleUpgrade(request, socket, head, (ws) => {
this.wss!.handleUpgrade(request, socket, head, (ws) => {
this.authenticate(request, (isAuthenticated) => {
if (isAuthenticated) {
this.wss.emit('connection', ws, request);
this.wss!.emit('connection', ws, request);
} else {
ws.close(4401, 'Unauthorized');
}
@@ -144,7 +166,7 @@ export default class Frontend extends Extension {
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) {
@@ -162,7 +184,7 @@ export default class Frontend extends Extension {
const topic = data.topic.substring(this.mqttBaseTopic.length + 1);
const payload = utils.parseJSON(data.payload, data.payload);
for (const client of this.wss.clients) {
for (const client of this.wss!.clients) {
/* istanbul ignore else */
if (client.readyState === WebSocket.OPEN) {
client.send(stringify({topic, payload}));
+51 -37
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 {
@@ -225,7 +233,7 @@ export default class Groups extends Extension {
private areAllMembersOff(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);
@@ -239,14 +247,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 +280,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 +294,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 +337,8 @@ export default class Groups extends Extension {
error = `Device '${parsed.ID}' does not have endpoint '${parsed.endpointID}'`;
}
}
} else {
return undefined;
}
return {
@@ -365,6 +375,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 +394,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 +407,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 +441,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 +460,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,
});
+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);
}
+79 -47
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,44 @@ 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));
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 +273,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 +304,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 +324,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 +339,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 +356,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});
}
+10 -14
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 * as zhc from 'zigbee-herdsman-converters';
import logger from '../util/logger';
@@ -37,7 +40,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: {},
@@ -69,7 +72,7 @@ export default class Receive extends Extension {
// 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 +85,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 +117,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);
@@ -158,8 +154,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};
+24 -22
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';
@@ -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,7 +112,6 @@ declare global {
}
// Settings
// eslint-disable camelcase
interface Settings {
homeassistant?: {
discovery_topic: string;
@@ -120,7 +119,7 @@ declare global {
legacy_entity_attributes: boolean;
legacy_triggers: boolean;
};
permit_join?: boolean;
permit_join: boolean;
availability?: {
active: {timeout: number};
passive: {timeout: number};
@@ -179,13 +178,13 @@ declare global {
frontend?: {
auth_token?: string;
host?: string;
port?: number;
port: number;
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 +202,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 +215,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};
@@ -245,9 +243,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[];
+2 -2
View File
@@ -3,7 +3,7 @@ declare module 'zigbee2mqtt-frontend' {
}
declare module 'connect-gzip-static' {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type RequestHandler = (req: any, res: any) => void;
import {IncomingMessage, ServerResponse} from 'http';
export type RequestHandler = (req: IncomingMessage, res: ServerResponse, finalhandler: (err: unknown) => void) => void;
export default function gzipStatic(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};
+21 -12
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,7 +109,7 @@ 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 = {
@@ -110,7 +118,7 @@ class Logger {
...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.
+155 -102
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) {
@@ -140,59 +145,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 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 +212,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 +271,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 +297,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 +382,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 +415,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 +433,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 +475,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 +502,7 @@ export function get(): Settings {
loadSettingsWithDefaults();
}
return _settingsWithDefaults;
return _settingsWithDefaults!;
}
export function set(path: string[], value: string | number | boolean | KeyValue): void {
@@ -505,7 +527,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 +541,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 +561,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 +574,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 +582,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 +596,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 +608,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 +621,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 +652,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 +668,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 +678,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 +697,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 +724,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 +741,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 +753,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 +786,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);
}
+515 -574
View File
File diff suppressed because it is too large Load Diff
+17 -15
View File
@@ -1,6 +1,6 @@
{
"name": "zigbee2mqtt",
"version": "1.40.1",
"version": "1.40.2",
"description": "Zigbee to MQTT bridge using Zigbee-herdsman",
"main": "index.js",
"repository": {
@@ -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",
@@ -40,9 +40,9 @@
"ajv": "^8.17.1",
"bind-decorator": "^1.0.11",
"connect-gzip-static": "3.0.1",
"debounce": "^2.1.0",
"debounce": "^2.1.1",
"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",
@@ -58,10 +58,10 @@
"uri-js": "^4.4.1",
"winston": "^3.14.2",
"winston-syslog": "^2.7.1",
"winston-transport": "^4.7.1",
"winston-transport": "^4.8.0",
"ws": "^8.18.0",
"zigbee-herdsman": "0.57.3",
"zigbee-herdsman-converters": "20.12.1",
"zigbee-herdsman": "2.1.3",
"zigbee-herdsman-converters": "20.21.0",
"zigbee2mqtt-frontend": "0.7.4"
},
"devDependencies": {
@@ -69,25 +69,27 @@
"@babel/plugin-proposal-decorators": "^7.24.7",
"@babel/preset-env": "^7.25.4",
"@babel/preset-typescript": "^7.24.7",
"@eslint/core": "^0.6.0",
"@eslint/js": "^9.11.1",
"@ianvs/prettier-plugin-sort-imports": "^4.3.1",
"@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.13",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.5.1",
"@types/node": "^22.7.4",
"@types/object-assign-deep": "^0.4.3",
"@types/readable-stream": "4.0.15",
"@types/sd-notify": "^2.8.2",
"@types/ws": "8.5.12",
"@typescript-eslint/eslint-plugin": "^8.3.0",
"@typescript-eslint/parser": "^8.3.0",
"babel-jest": "^29.7.0",
"eslint": "^8.57.0",
"eslint": "^9.9.1",
"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.6.2",
"typescript-eslint": "^8.7.0"
},
"overrides": {
"zigbee-herdsman-converters": {
+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));
+120 -16
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,13 @@ 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,
// definition: null,
disabled: false,
endpoints: {1: {bindings: [], clusters: {input: [], output: []}, configured_reportings: [], scenes: []}},
friendly_name: 'Coordinator',
@@ -318,6 +318,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: 0},
{description: 'Use previous value', name: 'previous', value: 255},
],
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',
},
@@ -1878,6 +1913,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: 0},
{description: 'Use previous value', name: 'previous', value: 255},
],
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',
},
@@ -2186,7 +2256,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 +2324,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: 0},
{description: 'Use previous value', name: 'previous', value: 255},
],
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',
},
@@ -2561,7 +2665,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 +2673,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 +2810,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 +2832,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 +2849,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 +2865,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 +2882,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 +2898,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 +2962,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 +2996,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 +3036,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();
});
});
});
+24 -29
View File
@@ -462,7 +462,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 +1413,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 +1450,7 @@ describe('HomeAssistant extension', () => {
expect(MQTT.publish).toHaveBeenCalledWith(
'homeassistant/light/1221051039810110150109113116116_9/light/config',
null,
'',
{retain: true, qos: 1},
expect.any(Function),
);
@@ -1502,7 +1502,7 @@ describe('HomeAssistant extension', () => {
expect(MQTT.publish).toHaveBeenCalledWith(
'homeassistant/sensor/0x0017880104e45522/temperature/config',
null,
'',
{retain: true, qos: 1},
expect.any(Function),
);
@@ -1587,7 +1587,7 @@ describe('HomeAssistant extension', () => {
expect(MQTT.publish).toHaveBeenCalledWith(
'homeassistant/light/1221051039810110150109113116116_9/light/config',
null,
'',
{retain: true, qos: 1},
expect.any(Function),
);
@@ -1603,7 +1603,7 @@ describe('HomeAssistant extension', () => {
expect(MQTT.publish).not.toHaveBeenCalledWith(
'homeassistant/sensor/0x0017880104e45522/temperature/config',
null,
'',
{retain: true, qos: 1},
expect.any(Function),
);
@@ -1922,19 +1922,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 +1995,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 +2014,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 +2026,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 +2036,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 +2066,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 +2107,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 +2119,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 +2401,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 +2445,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 +2509,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 +2732,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',
+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);
+1 -1
View File
@@ -629,7 +629,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({
+1 -1
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 () => {
+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),
+1 -1
View File
@@ -861,7 +861,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",