From 6fbb8b5ca2d3d98240f23e0208edb2fd05826daa Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Fri, 18 Apr 2025 20:34:47 +0200 Subject: [PATCH] fix: Migrate to Biome (#27150) --- .github/workflows/ci.yml | 8 +- .prettierignore | 3 - .prettierrc | 28 - README.md | 2 +- biome.json | 86 + cli.js | 6 +- eslint.config.mjs | 32 - index.js | 68 +- lib/controller.ts | 156 +- lib/eventBus.ts | 90 +- lib/extension/availability.ts | 46 +- lib/extension/bind.ts | 194 +- lib/extension/bridge.ts | 311 +- lib/extension/configure.ts | 46 +- lib/extension/extension.ts | 11 +- lib/extension/externalConverters.ts | 19 +- lib/extension/externalExtensions.ts | 16 +- lib/extension/externalJS.ts | 55 +- lib/extension/frontend.ts | 86 +- lib/extension/groups.ts | 102 +- lib/extension/homeassistant.ts | 1004 ++--- lib/extension/networkMap.ts | 121 +- lib/extension/onEvent.ts | 27 +- lib/extension/otaUpdate.ts | 131 +- lib/extension/publish.ts | 51 +- lib/extension/receive.ts | 45 +- lib/model/device.ts | 41 +- lib/model/group.ts | 13 +- lib/mqtt.ts | 52 +- lib/state.ts | 66 +- lib/types/api.ts | 386 +- ...json-stable-stringify-without-jsonify.d.ts | 2 +- lib/types/types.d.ts | 55 +- lib/types/unix-dgram.d.ts | 6 +- lib/types/zigbee2mqtt-frontend.d.ts | 8 +- lib/util/data.ts | 4 +- lib/util/logger.ts | 77 +- lib/util/onboarding.ts | 208 +- lib/util/sd-notify.ts | 20 +- lib/util/settings.schema.json | 2 +- lib/util/settings.ts | 232 +- lib/util/settingsMigration.ts | 200 +- lib/util/utils.ts | 108 +- lib/util/yaml.ts | 12 +- lib/zigbee.ts | 120 +- package.json | 30 +- pnpm-lock.yaml | 1028 +---- scripts/generateChangelog.js | 104 +- scripts/testExternalConverter.js | 10 +- scripts/zStackEraseAllNvMem.js | 42 +- .../cjs/mock-external-converter-multiple.js | 16 +- .../cjs/mock-external-converter.js | 10 +- .../mjs/mock-external-converter-multiple.mjs | 16 +- .../mjs/mock-external-converter.mjs | 12 +- .../cjs/example2Extension.js | 8 +- .../cjs/exampleExtension.js | 8 +- .../mjs/example2Extension.mjs | 8 +- .../mjs/exampleExtension.mjs | 12 +- test/controller.test.ts | 733 ++-- test/data.test.ts | 20 +- test/extensions/availability.test.ts | 228 +- test/extensions/bind.test.ts | 500 +-- test/extensions/bridge.test.ts | 3738 ++++++++--------- test/extensions/configure.test.ts | 122 +- test/extensions/externalConverters.test.ts | 470 +-- test/extensions/externalExtensions.test.ts | 282 +- test/extensions/frontend.test.ts | 243 +- test/extensions/groups.test.ts | 478 +-- test/extensions/homeassistant.test.ts | 2452 +++++------ test/extensions/networkMap.test.ts | 520 +-- test/extensions/onEvent.test.ts | 68 +- test/extensions/otaUpdate.test.ts | 390 +- test/extensions/publish.test.ts | 1260 +++--- test/extensions/receive.test.ts | 366 +- test/logger.test.ts | 332 +- test/mocks/data.ts | 276 +- test/mocks/debounce.ts | 2 +- test/mocks/jszip.ts | 4 +- test/mocks/logger.ts | 22 +- test/mocks/mqtt.ts | 17 +- test/mocks/sleep.ts | 4 +- test/mocks/types.d.ts | 4 +- test/mocks/utils.ts | 6 +- test/mocks/zigbeeHerdsman.ts | 760 ++-- test/onboarding.test.ts | 272 +- test/sd-notify.test.ts | 84 +- test/settings.test.ts | 554 +-- test/settingsMigration.test.ts | 684 +-- test/tsconfig.json | 2 +- test/utils.test.ts | 92 +- test/vitest.config.mts | 15 +- 91 files changed, 9944 insertions(+), 10716 deletions(-) delete mode 100644 .prettierignore delete mode 100644 .prettierrc create mode 100644 biome.json delete mode 100644 eslint.config.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93a55c8c9..8a0fd73fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,14 +30,14 @@ jobs: cache: pnpm - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build - run: pnpm run build + run: pnpm i --frozen-lockfile - name: Check run: pnpm run check + - name: Build + run: pnpm run build + - name: Test run: pnpm run test:coverage diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 4d0e6cecf..000000000 --- a/.prettierignore +++ /dev/null @@ -1,3 +0,0 @@ -pnpm-lock.yaml -CHANGELOG.md -release-notes.md \ No newline at end of file diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index aa720a4e8..000000000 --- a/.prettierrc +++ /dev/null @@ -1,28 +0,0 @@ -{ - "semi": true, - "trailingComma": "all", - "singleQuote": true, - "printWidth": 150, - "bracketSpacing": false, - "endOfLine": "lf", - "tabWidth": 4, - "importOrder": [ - "^[./]*/mocks", - "", - "^(node:)", - "", - "", - "", - "^[.]", - "", - "", - "", - "", - "", - "^zigbee", - "", - "^[.]" - ], - "importOrderParserPlugins": ["typescript", "decorators"], - "plugins": ["@ianvs/prettier-plugin-sort-imports"] -} diff --git a/README.md b/README.md index b0982f540..286f34442 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ Zigbee2MQTT is made up of three modules, each developed in its own Github projec Zigbee2MQTT uses TypeScript (partially for now). Therefore after making changes to files in the `lib/` directory you need to recompile Zigbee2MQTT. This can be done by executing `pnpm run build`. For faster development instead of running `pnpm run build` you can run `pnpm run build-watch` in another terminal session, this will recompile as you change files. Before running any of the commands, you'll first need to run `pnpm install --include=dev`. -Before submitting changes run `pnpm run test:coverage`, `pnpm run pretty:check` and `pnpm run eslint` +Before submitting changes run `pnpm run check:w` then `pnpm run test:coverage`. ## Supported devices diff --git a/biome.json b/biome.json new file mode 100644 index 000000000..10d9ae012 --- /dev/null +++ b/biome.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "formatter": { + "indentStyle": "space", + "indentWidth": 4, + "lineWidth": 150, + "bracketSpacing": false + }, + "linter": { + "ignore": [], + "rules": { + "correctness": { + "noUnusedImports": "error", + "noUnusedVariables": { + "level": "warn", + "fix": "none" + } + }, + "style": { + "noParameterAssign": "off", + "useThrowNewError": "error", + "useThrowOnlyError": "error", + "useNamingConvention": { + "level": "error", + "options": { + "strictCase": false, + "conventions": [ + { + "selector": { + "kind": "objectLiteralProperty" + }, + "formats": ["snake_case", "camelCase", "CONSTANT_CASE", "PascalCase"] + }, + { + "selector": { + "kind": "const" + }, + "formats": ["snake_case", "camelCase", "CONSTANT_CASE", "PascalCase"] + }, + { + "selector": { + "kind": "typeProperty" + }, + "formats": ["snake_case", "camelCase", "CONSTANT_CASE", "PascalCase"] + }, + { + "selector": { + "kind": "enumMember" + }, + "formats": ["CONSTANT_CASE", "PascalCase"] + } + ] + } + } + }, + "performance": { + "noDelete": "off" + }, + "suspicious": { + "noConstEnum": "off", + "useAwait": "error" + } + } + }, + "overrides": [ + { + "include": ["test/**"], + "linter": { + "rules": { + "style": { + "noNonNullAssertion": "off", + "useNamingConvention": "off" + }, + "suspicious": { + "noImplicitAnyLet": "off" + } + } + } + } + ] +} diff --git a/cli.js b/cli.js index b993a0690..ff1ef7049 100755 --- a/cli.js +++ b/cli.js @@ -1,4 +1,4 @@ #!/usr/bin/env node -const path = require('path'); -process.env['ZIGBEE2MQTT_DATA'] = process.env['ZIGBEE2MQTT_DATA'] || path.join(process.env['HOME'], '.z2m'); -require('./index'); +const path = require("node:path"); +process.env.ZIGBEE2MQTT_DATA = process.env.ZIGBEE2MQTT_DATA || path.join(process.env.HOME, ".z2m"); +require("./index"); diff --git a/eslint.config.mjs b/eslint.config.mjs deleted file mode 100644 index 89c17e5b3..000000000 --- a/eslint.config.mjs +++ /dev/null @@ -1,32 +0,0 @@ -// @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, -); diff --git a/index.js b/index.js index bd1b0a28a..af7ecf2f0 100644 --- a/index.js +++ b/index.js @@ -1,29 +1,29 @@ -const semver = require('semver'); -const engines = require('./package.json').engines; -const fs = require('fs'); -const os = require('os'); -const path = require('path'); -const {exec} = require('child_process'); -require('source-map-support').install(); +const semver = require("semver"); +const engines = require("./package.json").engines; +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const {exec} = require("node:child_process"); +require("source-map-support").install(); let controller; let stopping = false; -let watchdog = process.env.Z2M_WATCHDOG != undefined; +const watchdog = process.env.Z2M_WATCHDOG != null; let watchdogCount = 0; let unsolicitedStop = false; // csv in minutes, default: 1min, 5min, 15min, 30min, 60min let watchdogDelays = [2000, 60000, 300000, 900000, 1800000, 3600000]; -if (watchdog && process.env.Z2M_WATCHDOG !== 'default') { +if (watchdog && process.env.Z2M_WATCHDOG !== "default") { if (/^\d+(.\d+)?(,\d+(.\d+)?)*$/.test(process.env.Z2M_WATCHDOG)) { - watchdogDelays = process.env.Z2M_WATCHDOG.split(',').map((v) => parseFloat(v) * 60000); + watchdogDelays = process.env.Z2M_WATCHDOG.split(",").map((v) => Number.parseFloat(v) * 60000); } else { console.log(`Invalid watchdog delays (must use number-only CSV format representing minutes, example: 'Z2M_WATCHDOG=1,5,15,30,60'.`); process.exit(1); } } -const hashFile = path.join(__dirname, 'dist', '.hash'); +const hashFile = path.join(__dirname, "dist", ".hash"); async function triggerWatchdog(code) { const delay = watchdogDelays[watchdogCount]; @@ -58,11 +58,11 @@ async function exit(code, restart = false) { async function currentHash() { return await new Promise((resolve) => { - exec('git rev-parse --short=8 HEAD', (error, stdout) => { + exec("git rev-parse --short=8 HEAD", (error, stdout) => { const commitHash = stdout.trim(); - if (error || commitHash === '') { - resolve('unknown'); + if (error || commitHash === "") { + resolve("unknown"); } else { resolve(commitHash); } @@ -81,26 +81,26 @@ async function build(reason) { return await new Promise((resolve, reject) => { const env = {...process.env}; - const _600mb = 629145600; + const mb600 = 629145600; - if (_600mb > os.totalmem() && !env.NODE_OPTIONS) { + if (mb600 > os.totalmem() && !env.NODE_OPTIONS) { // Prevent OOM on tsc compile for system with low memory // https://github.com/Koenkk/zigbee2mqtt/issues/12034 - env.NODE_OPTIONS = '--max_old_space_size=256'; + env.NODE_OPTIONS = "--max_old_space_size=256"; } // clean build, prevent failures due to tsc incremental building - exec('pnpm run prepack', {env, cwd: __dirname}, async (err, stdout, stderr) => { + exec("pnpm run prepack", {env, cwd: __dirname}, (err) => { if (err) { - process.stdout.write(', failed\n'); + process.stdout.write(", failed\n"); if (err.code === 134) { - process.stderr.write('\n\nBuild failed; ran out-of-memory, free some memory (RAM) and start again\n\n'); + process.stderr.write("\n\nBuild failed; ran out-of-memory, free some memory (RAM) and start again\n\n"); } reject(err); } else { - process.stdout.write(', finished\n'); + process.stdout.write(", finished\n"); resolve(); } }); @@ -109,19 +109,19 @@ async function build(reason) { async function checkDist() { if (!fs.existsSync(hashFile)) { - await build('initial build'); + await build("initial build"); } - const distHash = fs.readFileSync(hashFile, 'utf8'); + const distHash = fs.readFileSync(hashFile, "utf8"); const hash = await currentHash(); - if (hash !== 'unknown' && distHash !== hash) { - await build('hash changed'); + if (hash !== "unknown" && distHash !== hash) { + await build("hash changed"); } } async function start() { - console.log(`Starting Zigbee2MQTT ${watchdog ? `with watchdog (${watchdogDelays})` : `without watchdog`}.`); + console.log(`Starting Zigbee2MQTT ${watchdog ? `with watchdog (${watchdogDelays})` : "without watchdog"}.`); await checkDist(); // gc @@ -132,7 +132,7 @@ async function start() { console.log(`\t\tZigbee2MQTT requires node version ${version}, you are running ${process.version}!\n`); } - const {onboard} = require('./dist/util/onboarding'); + const {onboard} = require("./dist/util/onboarding"); const success = await onboard(); @@ -143,7 +143,7 @@ async function start() { } } - const {Controller} = require('./dist/controller'); + const {Controller} = require("./dist/controller"); controller = new Controller(restart, exit); await controller.start(); @@ -172,17 +172,17 @@ async function handleQuit() { } } -if (require.main === module || require.main.filename.endsWith(path.sep + 'cli.js')) { - if (process.argv.length === 3 && process.argv[2] === 'writehash') { +if (require.main === module || require.main.filename.endsWith(`${path.sep}cli.js`)) { + if (process.argv.length === 3 && process.argv[2] === "writehash") { writeHash(); } else { - process.on('SIGINT', handleQuit); - process.on('SIGTERM', handleQuit); + process.on("SIGINT", handleQuit); + process.on("SIGTERM", handleQuit); start(); } } else { - process.on('SIGINT', handleQuit); - process.on('SIGTERM', handleQuit); + process.on("SIGINT", handleQuit); + process.on("SIGTERM", handleQuit); module.exports = {start}; } diff --git a/lib/controller.ts b/lib/controller.ts index 1165ebf3b..902f958c0 100644 --- a/lib/controller.ts +++ b/lib/controller.ts @@ -1,41 +1,41 @@ -import type {IClientPublishOptions} from 'mqtt'; +import type {IClientPublishOptions} from "mqtt"; -import type Extension from './extension/extension'; -import type {Zigbee2MQTTAPI} from './types/api'; +import type Extension from "./extension/extension"; +import type {Zigbee2MQTTAPI} from "./types/api"; -import bind from 'bind-decorator'; -import stringify from 'json-stable-stringify-without-jsonify'; +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'; +import {setLogger as zhSetLogger} from "zigbee-herdsman"; +import {setLogger as zhcSetLogger} from "zigbee-herdsman-converters"; -import EventBus from './eventBus'; +import EventBus from "./eventBus"; // Extensions -import ExtensionAvailability from './extension/availability'; -import ExtensionBind from './extension/bind'; -import ExtensionBridge from './extension/bridge'; -import ExtensionConfigure from './extension/configure'; -import ExtensionExternalConverters from './extension/externalConverters'; -import ExtensionExternalExtensions from './extension/externalExtensions'; -import ExtensionGroups from './extension/groups'; -import ExtensionNetworkMap from './extension/networkMap'; -import ExtensionOnEvent from './extension/onEvent'; -import ExtensionOTAUpdate from './extension/otaUpdate'; -import ExtensionPublish from './extension/publish'; -import ExtensionReceive from './extension/receive'; -import MQTT from './mqtt'; -import State from './state'; -import logger from './util/logger'; -import {initSdNotify} from './util/sd-notify'; -import * as settings from './util/settings'; -import utils from './util/utils'; -import Zigbee from './zigbee'; +import ExtensionAvailability from "./extension/availability"; +import ExtensionBind from "./extension/bind"; +import ExtensionBridge from "./extension/bridge"; +import ExtensionConfigure from "./extension/configure"; +import ExtensionExternalConverters from "./extension/externalConverters"; +import ExtensionExternalExtensions from "./extension/externalExtensions"; +import ExtensionGroups from "./extension/groups"; +import ExtensionNetworkMap from "./extension/networkMap"; +import ExtensionOnEvent from "./extension/onEvent"; +import ExtensionOTAUpdate from "./extension/otaUpdate"; +import ExtensionPublish from "./extension/publish"; +import ExtensionReceive from "./extension/receive"; +import Mqtt from "./mqtt"; +import State from "./state"; +import logger from "./util/logger"; +import {initSdNotify} from "./util/sd-notify"; +import * as settings from "./util/settings"; +import utils from "./util/utils"; +import Zigbee from "./zigbee"; export class Controller { private eventBus: EventBus; private zigbee: Zigbee; private state: State; - private mqtt: MQTT; + private mqtt: Mqtt; private restartCallback: () => Promise; private exitCallback: (code: number, restart: boolean) => Promise; public readonly extensions: Set; @@ -48,7 +48,7 @@ export class Controller { zhcSetLogger(logger); this.eventBus = new EventBus(); this.zigbee = new Zigbee(this.eventBus); - this.mqtt = new MQTT(this.eventBus); + this.mqtt = new Mqtt(this.eventBus); this.state = new State(this.eventBus, this.zigbee); this.restartCallback = restartCallback; this.exitCallback = exitCallback; @@ -83,13 +83,13 @@ export class Controller { async start(): Promise { if (settings.get().frontend.enabled) { - const {Frontend} = await import('./extension/frontend.js'); + const {Frontend} = await import("./extension/frontend.js"); this.extensions.add(new Frontend(...this.extensionArgs)); } if (settings.get().homeassistant.enabled) { - const {HomeAssistant} = await import('./extension/homeassistant.js'); + const {HomeAssistant} = await import("./extension/homeassistant.js"); this.extensions.add(new HomeAssistant(...this.extensionArgs)); } @@ -104,16 +104,17 @@ export class Controller { await this.zigbee.start(); this.eventBus.onAdapterDisconnected(this, this.onZigbeeAdapterDisconnected); } catch (error) { - logger.error('Failed to start zigbee-herdsman'); + logger.error("Failed to start zigbee-herdsman"); logger.error( - 'Check https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start_crashes-runtime.html for possible solutions', + "Check https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start_crashes-runtime.html for possible solutions", ); - logger.error('Exiting...'); + logger.error("Exiting..."); + // biome-ignore lint/style/noNonNullAssertion: always Error logger.error((error as Error).stack!); /* v8 ignore start */ - if ((error as Error).message.includes('USB adapter discovery error (No valid USB adapter found)')) { - logger.error('If this happens after updating to Zigbee2MQTT 2.0.0, see https://github.com/Koenkk/zigbee2mqtt/discussions/24364'); + if ((error as Error).message.includes("USB adapter discovery error (No valid USB adapter found)")) { + logger.error("If this happens after updating to Zigbee2MQTT 2.0.0, see https://github.com/Koenkk/zigbee2mqtt/discussions/24364"); } /* v8 ignore stop */ @@ -126,8 +127,9 @@ export class Controller { 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}` - : 'Not supported'; + ? // biome-ignore lint/style/noNonNullAssertion: valid from `isSupported` + `${device.definition!.model} - ${device.definition!.vendor} ${device.definition!.description}` + : "Not supported"; logger.info(`${device.name} (${device.ieeeAddr}): ${model} (${device.zh.type})`); deviceCount++; @@ -153,14 +155,14 @@ export class Controller { if (settings.get().advanced.cache_state_send_on_startup && settings.get().advanced.cache_state) { for (const entity of this.zigbee.devicesAndGroupsIterator()) { if (this.state.exists(entity)) { - await this.publishEntityState(entity, this.state.get(entity), 'publishCached'); + await this.publishEntityState(entity, this.state.get(entity), "publishCached"); } } } this.eventBus.onLastSeenChanged(this, (data) => utils.publishLastSeen(data, settings.get(), false, this.publishEntityState)); - logger.info('Zigbee2MQTT started!'); + logger.info("Zigbee2MQTT started!"); this.sdNotify = await initSdNotify(); } @@ -168,26 +170,26 @@ export class Controller { @bind async enableDisableExtension(enable: boolean, name: string): Promise { if (enable) { switch (name) { - case 'Frontend': { + case "Frontend": { if (!settings.get().frontend.enabled) { - throw new Error('Tried to enable Frontend extension disabled in settings'); + throw new Error("Tried to enable Frontend extension disabled in settings"); } // this is not actually used, not tested either /* v8 ignore start */ - const {Frontend} = await import('./extension/frontend.js'); + const {Frontend} = await import("./extension/frontend.js"); await this.addExtension(new Frontend(...this.extensionArgs)); break; /* v8 ignore stop */ } - case 'HomeAssistant': { + case "HomeAssistant": { if (!settings.get().homeassistant.enabled) { - throw new Error('Tried to enable HomeAssistant extension disabled in settings'); + throw new Error("Tried to enable HomeAssistant extension disabled in settings"); } - const {HomeAssistant} = await import('./extension/homeassistant.js'); + const {HomeAssistant} = await import("./extension/homeassistant.js"); await this.addExtension(new HomeAssistant(...this.extensionArgs)); @@ -201,32 +203,32 @@ export class Controller { } } else { switch (name) { - case 'Frontend': { + case "Frontend": { if (settings.get().frontend.enabled) { - throw new Error('Tried to disable Frontend extension enabled in settings'); + throw new Error("Tried to disable Frontend extension enabled in settings"); } break; } - case 'HomeAssistant': { + case "HomeAssistant": { if (settings.get().homeassistant.enabled) { - throw new Error('Tried to disable HomeAssistant extension enabled in settings'); + throw new Error("Tried to disable HomeAssistant extension enabled in settings"); } break; } - case 'Availability': - case 'Bind': - case 'Bridge': - case 'Configure': - case 'ExternalConverters': - case 'ExternalExtensions': - case 'Groups': - case 'NetworkMap': - case 'OnEvent': - case 'OTAUpdate': - case 'Publish': - case 'Receive': { + case "Availability": + case "Bind": + case "Bridge": + case "Configure": + case "ExternalConverters": + case "ExternalExtensions": + case "Groups": + case "NetworkMap": + case "OnEvent": + case "OTAUpdate": + case "Publish": + case "Receive": { throw new Error(`Built-in extension ${name} cannot be disabled at runtime`); } } @@ -302,7 +304,7 @@ export class Controller { try { await this.zigbee.stop(); - logger.info('Stopped Zigbee2MQTT'); + logger.info("Stopped Zigbee2MQTT"); } catch (error) { logger.error(`Failed to stop Zigbee2MQTT (${(error as Error).stack})`); code = 1; @@ -318,12 +320,12 @@ export class Controller { } @bind async onZigbeeAdapterDisconnected(): Promise { - logger.error('Adapter disconnected, stopping'); + logger.error("Adapter disconnected, stopping"); await this.stop(); } @bind async publishEntityState(entity: Group | Device, payload: KeyValue, stateChangeReason?: StateChangeReason): Promise { - let message: Zigbee2MQTTAPI['{friendlyName}'] = {...payload}; + let message: Zigbee2MQTTAPI["{friendlyName}"] = {...payload}; // Update state cache with new state. const newState = this.state.set(entity, payload, stateChangeReason); @@ -334,10 +336,10 @@ export class Controller { } const options: IClientPublishOptions = { - retain: utils.getObjectProperty(entity.options, 'retain', false), - qos: utils.getObjectProperty(entity.options, 'qos', 0), + retain: utils.getObjectProperty(entity.options, "retain", false), + qos: utils.getObjectProperty(entity.options, "qos", 0), }; - const retention = utils.getObjectProperty(entity.options, 'retention', false); + const retention = utils.getObjectProperty(entity.options, "retention", false); if (retention !== false) { options.properties = {messageExpiryInterval: retention}; @@ -361,13 +363,13 @@ export class Controller { // Manufacturer name can contain \u0000, remove this. // https://github.com/home-assistant/core/issues/85691 /* v8 ignore next */ - manufacturerName: entity.zh.manufacturerName?.split('\u0000')[0], + manufacturerName: entity.zh.manufacturerName?.split("\u0000")[0], }; } // Add lastseen const lastSeen = settings.get().advanced.last_seen; - if (entity.isDevice() && lastSeen !== 'disable' && entity.zh.lastSeen) { + if (entity.isDevice() && lastSeen !== "disable" && entity.zh.lastSeen) { message.last_seen = utils.formatDate(entity.zh.lastSeen, lastSeen); } @@ -385,11 +387,11 @@ export class Controller { if (!utils.objectIsEmpty(message)) { const output = settings.get().advanced.output; - if (output === 'attribute_and_json' || output === 'json') { + if (output === "attribute_and_json" || output === "json") { await this.mqtt.publish(entity.name, stringify(message), options); } - if (output === 'attribute_and_json' || output === 'attribute') { + if (output === "attribute_and_json" || output === "attribute") { await this.iteratePayloadAttributeOutput(`${entity.name}/`, message, options); } } @@ -403,19 +405,19 @@ export class Controller { let message = null; // Special cases - if (key === 'color' && utils.objectHasProperties(subPayload, ['r', 'g', 'b'])) { + if (key === "color" && utils.objectHasProperties(subPayload, ["r", "g", "b"])) { subPayload = [subPayload.r, subPayload.g, subPayload.b]; } // Check Array first, since it is also an Object if (subPayload === null || subPayload === undefined) { - message = ''; + message = ""; } else if (Array.isArray(subPayload)) { - message = subPayload.map((x) => `${x}`).join(','); - } else if (typeof subPayload === 'object') { + message = subPayload.map((x) => `${x}`).join(","); + } else if (typeof subPayload === "object") { await this.iteratePayloadAttributeOutput(`${topicRoot}${key}-`, subPayload, options); } else { - message = typeof subPayload === 'string' ? subPayload : stringify(subPayload); + message = typeof subPayload === "string" ? subPayload : stringify(subPayload); } if (message !== null) { diff --git a/lib/eventBus.ts b/lib/eventBus.ts index a2fa90435..b524298cb 100644 --- a/lib/eventBus.ts +++ b/lib/eventBus.ts @@ -1,6 +1,6 @@ -import events from 'node:events'; +import events from "node:events"; -import logger from './util/logger'; +import logger from "./util/logger"; type ListenerKey = object; @@ -43,150 +43,150 @@ export default class EventBus { } public emitAdapterDisconnected(): void { - this.emitter.emit('adapterDisconnected'); + this.emitter.emit("adapterDisconnected"); } public onAdapterDisconnected(key: ListenerKey, callback: () => void): void { - this.on('adapterDisconnected', callback, key); + this.on("adapterDisconnected", callback, key); } public emitPermitJoinChanged(data: eventdata.PermitJoinChanged): void { - this.emitter.emit('permitJoinChanged', data); + this.emitter.emit("permitJoinChanged", data); } public onPermitJoinChanged(key: ListenerKey, callback: (data: eventdata.PermitJoinChanged) => void): void { - this.on('permitJoinChanged', callback, key); + this.on("permitJoinChanged", callback, key); } public emitEntityRenamed(data: eventdata.EntityRenamed): void { - this.emitter.emit('deviceRenamed', data); + this.emitter.emit("deviceRenamed", data); } public onEntityRenamed(key: ListenerKey, callback: (data: eventdata.EntityRenamed) => void): void { - this.on('deviceRenamed', callback, key); + this.on("deviceRenamed", callback, key); } public emitEntityRemoved(data: eventdata.EntityRemoved): void { - this.emitter.emit('deviceRemoved', data); + this.emitter.emit("deviceRemoved", data); } public onEntityRemoved(key: ListenerKey, callback: (data: eventdata.EntityRemoved) => void): void { - this.on('deviceRemoved', callback, key); + this.on("deviceRemoved", callback, key); } public emitLastSeenChanged(data: eventdata.LastSeenChanged): void { - this.emitter.emit('lastSeenChanged', data); + this.emitter.emit("lastSeenChanged", data); } public onLastSeenChanged(key: ListenerKey, callback: (data: eventdata.LastSeenChanged) => void): void { - this.on('lastSeenChanged', callback, key); + this.on("lastSeenChanged", callback, key); } public emitDeviceNetworkAddressChanged(data: eventdata.DeviceNetworkAddressChanged): void { - this.emitter.emit('deviceNetworkAddressChanged', data); + this.emitter.emit("deviceNetworkAddressChanged", data); } public onDeviceNetworkAddressChanged(key: ListenerKey, callback: (data: eventdata.DeviceNetworkAddressChanged) => void): void { - this.on('deviceNetworkAddressChanged', callback, key); + this.on("deviceNetworkAddressChanged", callback, key); } public emitDeviceAnnounce(data: eventdata.DeviceAnnounce): void { - this.emitter.emit('deviceAnnounce', data); + this.emitter.emit("deviceAnnounce", data); } public onDeviceAnnounce(key: ListenerKey, callback: (data: eventdata.DeviceAnnounce) => void): void { - this.on('deviceAnnounce', callback, key); + this.on("deviceAnnounce", callback, key); } public emitDeviceInterview(data: eventdata.DeviceInterview): void { - this.emitter.emit('deviceInterview', data); + this.emitter.emit("deviceInterview", data); } public onDeviceInterview(key: ListenerKey, callback: (data: eventdata.DeviceInterview) => void): void { - this.on('deviceInterview', callback, key); + this.on("deviceInterview", callback, key); } public emitDeviceJoined(data: eventdata.DeviceJoined): void { - this.emitter.emit('deviceJoined', data); + this.emitter.emit("deviceJoined", data); } public onDeviceJoined(key: ListenerKey, callback: (data: eventdata.DeviceJoined) => void): void { - this.on('deviceJoined', callback, key); + this.on("deviceJoined", callback, key); } public emitEntityOptionsChanged(data: eventdata.EntityOptionsChanged): void { - this.emitter.emit('entityOptionsChanged', data); + this.emitter.emit("entityOptionsChanged", data); } public onEntityOptionsChanged(key: ListenerKey, callback: (data: eventdata.EntityOptionsChanged) => void): void { - this.on('entityOptionsChanged', callback, key); + this.on("entityOptionsChanged", callback, key); } public emitExposesChanged(data: eventdata.ExposesChanged): void { - this.emitter.emit('exposesChanged', data); + this.emitter.emit("exposesChanged", data); } public onExposesChanged(key: ListenerKey, callback: (data: eventdata.ExposesChanged) => void): void { - this.on('exposesChanged', callback, key); + this.on("exposesChanged", callback, key); } public emitDeviceLeave(data: eventdata.DeviceLeave): void { - this.emitter.emit('deviceLeave', data); + this.emitter.emit("deviceLeave", data); } public onDeviceLeave(key: ListenerKey, callback: (data: eventdata.DeviceLeave) => void): void { - this.on('deviceLeave', callback, key); + this.on("deviceLeave", callback, key); } public emitDeviceMessage(data: eventdata.DeviceMessage): void { - this.emitter.emit('deviceMessage', data); + this.emitter.emit("deviceMessage", data); } public onDeviceMessage(key: ListenerKey, callback: (data: eventdata.DeviceMessage) => void): void { - this.on('deviceMessage', callback, key); + this.on("deviceMessage", callback, key); } public emitMQTTMessage(data: eventdata.MQTTMessage): void { - this.emitter.emit('mqttMessage', data); + this.emitter.emit("mqttMessage", data); } public onMQTTMessage(key: ListenerKey, callback: (data: eventdata.MQTTMessage) => void): void { - this.on('mqttMessage', callback, key); + this.on("mqttMessage", callback, key); } public emitMQTTMessagePublished(data: eventdata.MQTTMessagePublished): void { - this.emitter.emit('mqttMessagePublished', data); + this.emitter.emit("mqttMessagePublished", data); } public onMQTTMessagePublished(key: ListenerKey, callback: (data: eventdata.MQTTMessagePublished) => void): void { - this.on('mqttMessagePublished', callback, key); + this.on("mqttMessagePublished", callback, key); } public emitPublishEntityState(data: eventdata.PublishEntityState): void { - this.emitter.emit('publishEntityState', data); + this.emitter.emit("publishEntityState", data); } public onPublishEntityState(key: ListenerKey, callback: (data: eventdata.PublishEntityState) => void): void { - this.on('publishEntityState', callback, key); + this.on("publishEntityState", callback, key); } public emitGroupMembersChanged(data: eventdata.GroupMembersChanged): void { - this.emitter.emit('groupMembersChanged', data); + this.emitter.emit("groupMembersChanged", data); } public onGroupMembersChanged(key: ListenerKey, callback: (data: eventdata.GroupMembersChanged) => void): void { - this.on('groupMembersChanged', callback, key); + this.on("groupMembersChanged", callback, key); } public emitDevicesChanged(): void { - this.emitter.emit('devicesChanged'); + this.emitter.emit("devicesChanged"); } public onDevicesChanged(key: ListenerKey, callback: () => void): void { - this.on('devicesChanged', callback, key); + this.on("devicesChanged", callback, key); } public emitScenesChanged(data: eventdata.ScenesChanged): void { - this.emitter.emit('scenesChanged', data); + this.emitter.emit("scenesChanged", data); } public onScenesChanged(key: ListenerKey, callback: (data: eventdata.ScenesChanged) => void): void { - this.on('scenesChanged', callback, key); + this.on("scenesChanged", callback, key); } public emitReconfigure(data: eventdata.Reconfigure): void { - this.emitter.emit('reconfigure', data); + this.emitter.emit("reconfigure", data); } public onReconfigure(key: ListenerKey, callback: (data: eventdata.Reconfigure) => void): void { - this.on('reconfigure', callback, key); + this.on("reconfigure", callback, key); } public emitStateChange(data: eventdata.StateChange): void { - this.emitter.emit('stateChange', data); + this.emitter.emit("stateChange", data); } public onStateChange(key: ListenerKey, callback: (data: eventdata.StateChange) => void): void { - this.on('stateChange', callback, key); + this.on("stateChange", callback, key); } public emitExposesAndDevicesChanged(device: Device): void { @@ -204,10 +204,12 @@ export default class EventBus { await callback(...args); } catch (error) { logger.error(`EventBus error '${key.constructor.name}/${event}': ${(error as Error).message}`); + // biome-ignore lint/style/noNonNullAssertion: always Error logger.debug((error as Error).stack!); } }; + // biome-ignore lint/style/noNonNullAssertion: just created if wasn't valid this.callbacksByExtension.get(key.constructor.name)!.push({event, callback: wrappedCallback}); this.emitter.on(event, wrappedCallback as EventBusListener); } diff --git a/lib/extension/availability.ts b/lib/extension/availability.ts index feb735390..6ae9bb524 100644 --- a/lib/extension/availability.ts +++ b/lib/extension/availability.ts @@ -1,21 +1,21 @@ -import type * as zhc from 'zigbee-herdsman-converters'; +import type * as zhc from "zigbee-herdsman-converters"; -import type {Zigbee2MQTTAPI} from '../types/api'; +import type {Zigbee2MQTTAPI} from "../types/api"; -import assert from 'node:assert'; +import assert from "node:assert"; -import bind from 'bind-decorator'; -import debounce from 'debounce'; +import bind from "bind-decorator"; +import debounce from "debounce"; -import logger from '../util/logger'; -import * as settings from '../util/settings'; -import utils from '../util/utils'; -import Extension from './extension'; +import logger from "../util/logger"; +import * as settings from "../util/settings"; +import utils from "../util/utils"; +import Extension from "./extension"; const RETRIEVE_ON_RECONNECT: readonly {keys: string[]; condition?: (state: KeyValue) => boolean}[] = [ - {keys: ['state']}, - {keys: ['brightness'], condition: (state: KeyValue): boolean => state.state === 'ON'}, - {keys: ['color', 'color_temp'], condition: (state: KeyValue): boolean => state.state === 'ON'}, + {keys: ["state"]}, + {keys: ["brightness"], condition: (state: KeyValue): boolean => state.state === "ON"}, + {keys: ["color", "color_temp"], condition: (state: KeyValue): boolean => state.state === "ON"}, ]; export default class Availability extends Extension { @@ -34,7 +34,7 @@ export default class Availability extends Extension { private stopped = false; private getTimeout(device: Device): number { - if (typeof device.options.availability === 'object' && device.options.availability?.timeout != null) { + if (typeof device.options.availability === "object" && device.options.availability?.timeout != null) { return utils.minutes(device.options.availability.timeout); } @@ -42,7 +42,7 @@ export default class Availability extends Extension { } private getMaxJitter(device: Device): number { - if (typeof device.options.availability === 'object' && device.options.availability?.max_jitter != null) { + if (typeof device.options.availability === "object" && device.options.availability?.max_jitter != null) { return device.options.availability.max_jitter; } @@ -50,7 +50,7 @@ export default class Availability extends Extension { } private getBackoff(device: Device): boolean { - if (typeof device.options.availability === 'object' && device.options.availability?.backoff != null) { + if (typeof device.options.availability === "object" && device.options.availability?.backoff != null) { return device.options.availability.backoff; } @@ -58,7 +58,7 @@ export default class Availability extends Extension { } private getPauseOnBackoffGt(device: Device): number { - if (typeof device.options.availability === 'object' && device.options.availability?.pause_on_backoff_gt != null) { + if (typeof device.options.availability === "object" && device.options.availability?.pause_on_backoff_gt != null) { return device.options.availability.pause_on_backoff_gt; } @@ -67,8 +67,8 @@ export default class Availability extends Extension { private isActiveDevice(device: Device): boolean { return ( - (device.zh.type === 'Router' && device.zh.powerSource !== 'Battery') || - (device.zh.powerSource !== undefined && device.zh.powerSource !== 'Unknown' && device.zh.powerSource !== 'Battery') + (device.zh.type === "Router" && device.zh.powerSource !== "Battery") || + (device.zh.powerSource !== undefined && device.zh.powerSource !== "Unknown" && device.zh.powerSource !== "Battery") ); } @@ -201,16 +201,16 @@ export default class Availability extends Extension { override async start(): Promise { if (this.stopped) { - throw new Error('This extension cannot be restarted.'); + throw new Error("This extension cannot be restarted."); } this.eventBus.onEntityRenamed(this, async (data) => { if (utils.isAvailabilityEnabledForEntity(data.entity, settings.get())) { - await this.mqtt.publish(`${data.from}/availability`, '', {retain: true, qos: 1}); + await this.mqtt.publish(`${data.from}/availability`, "", {retain: true, qos: 1}); await this.publishAvailability(data.entity, false, true); } }); - this.eventBus.onEntityRemoved(this, (data) => data.type === 'device' && this.clearTimer(data.id)); + this.eventBus.onEntityRemoved(this, (data) => data.type === "device" && this.clearTimer(data.id)); this.eventBus.onDeviceLeave(this, (data) => this.clearTimer(data.ieeeAddr)); this.eventBus.onDeviceAnnounce(this, (data) => this.retrieveState(data.device)); this.eventBus.onLastSeenChanged(this, this.onLastSeenChanged); @@ -263,7 +263,7 @@ export default class Availability extends Extension { } const topic = `${entity.name}/availability`; - const payload: Zigbee2MQTTAPI['{friendlyName}/availability'] = {state: available ? 'online' : 'offline'}; + const payload: Zigbee2MQTTAPI["{friendlyName}/availability"] = {state: available ? "online" : "offline"}; this.lastPublishedAvailabilities.set(entity.ID, available); await this.mqtt.publish(topic, JSON.stringify(payload), {retain: true, qos: 1}); @@ -313,11 +313,13 @@ export default class Availability extends Extension { continue; } + // biome-ignore lint/style/noNonNullAssertion: doesn't change once valid 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), + // biome-ignore lint/style/noNonNullAssertion: doesn't change once valid mapped: device.definition!, endpoint_name: undefined, options, diff --git a/lib/extension/bind.ts b/lib/extension/bind.ts index 860b6b59e..c577704cf 100755 --- a/lib/extension/bind.ts +++ b/lib/extension/bind.ts @@ -1,47 +1,47 @@ -import type {ClusterName} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype'; +import type {ClusterName} from "zigbee-herdsman/dist/zspec/zcl/definition/tstype"; -import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from '../types/api'; +import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from "../types/api"; -import assert from 'node:assert'; +import assert from "node:assert"; -import bind from 'bind-decorator'; -import debounce from 'debounce'; -import stringify from 'json-stable-stringify-without-jsonify'; +import bind from "bind-decorator"; +import debounce from "debounce"; +import stringify from "json-stable-stringify-without-jsonify"; -import {Zcl} from 'zigbee-herdsman'; +import {Zcl} from "zigbee-herdsman"; -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 Extension from './extension'; +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 Extension from "./extension"; const TOPIC_REGEX = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/(bind|unbind)`); const ALL_CLUSTER_CANDIDATES: readonly ClusterName[] = [ - 'genScenes', - 'genOnOff', - 'genLevelCtrl', - 'lightingColorCtrl', - 'closuresWindowCovering', - 'hvacThermostat', - 'msIlluminanceMeasurement', - 'msTemperatureMeasurement', - 'msRelativeHumidity', - 'msSoilMoisture', - 'msCO2', + "genScenes", + "genOnOff", + "genLevelCtrl", + "lightingColorCtrl", + "closuresWindowCovering", + "hvacThermostat", + "msIlluminanceMeasurement", + "msTemperatureMeasurement", + "msRelativeHumidity", + "msSoilMoisture", + "msCO2", ]; // See zigbee-herdsman-converters -const DEFAULT_BIND_GROUP = {type: 'group_number', ID: 901, name: 'default_bind_group'}; +const DEFAULT_BIND_GROUP = {type: "group_number", ID: 901, name: "default_bind_group"}; const DEFAULT_REPORT_CONFIG = {minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1}; const getColorCapabilities = async (endpoint: zh.Endpoint): Promise<{colorTemperature: boolean; colorXY: boolean}> => { - if (endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') == null) { - await endpoint.read('lightingColorCtrl', ['colorCapabilities']); + if (endpoint.getClusterAttributeValue("lightingColorCtrl", "colorCapabilities") == null) { + await endpoint.read("lightingColorCtrl", ["colorCapabilities"]); } - const value = endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') as number; + const value = endpoint.getClusterAttributeValue("lightingColorCtrl", "colorCapabilities") as number; return { colorTemperature: (value & (1 << 4)) > 0, @@ -63,28 +63,28 @@ const REPORT_CLUSTERS: Readonly< > > > = { - genOnOff: [{attribute: 'onOff', ...DEFAULT_REPORT_CONFIG, minimumReportInterval: 0, reportableChange: 0}], - genLevelCtrl: [{attribute: 'currentLevel', ...DEFAULT_REPORT_CONFIG}], + genOnOff: [{attribute: "onOff", ...DEFAULT_REPORT_CONFIG, minimumReportInterval: 0, reportableChange: 0}], + genLevelCtrl: [{attribute: "currentLevel", ...DEFAULT_REPORT_CONFIG}], lightingColorCtrl: [ { - attribute: 'colorTemperature', + attribute: "colorTemperature", ...DEFAULT_REPORT_CONFIG, condition: async (endpoint): Promise => (await getColorCapabilities(endpoint)).colorTemperature, }, { - attribute: 'currentX', + attribute: "currentX", ...DEFAULT_REPORT_CONFIG, condition: async (endpoint): Promise => (await getColorCapabilities(endpoint)).colorXY, }, { - attribute: 'currentY', + attribute: "currentY", ...DEFAULT_REPORT_CONFIG, condition: async (endpoint): Promise => (await getColorCapabilities(endpoint)).colorXY, }, ], closuresWindowCovering: [ - {attribute: 'currentPositionLiftPercentage', ...DEFAULT_REPORT_CONFIG}, - {attribute: 'currentPositionTiltPercentage', ...DEFAULT_REPORT_CONFIG}, + {attribute: "currentPositionLiftPercentage", ...DEFAULT_REPORT_CONFIG}, + {attribute: "currentPositionTiltPercentage", ...DEFAULT_REPORT_CONFIG}, ], }; @@ -100,22 +100,22 @@ const POLL_ON_MESSAGE: Readonly = [ // On messages that have the cluster and type of below cluster: { manuSpecificPhilips: [ - {type: 'commandHueNotification', data: {button: 2}}, - {type: 'commandHueNotification', data: {button: 3}}, + {type: "commandHueNotification", data: {button: 2}}, + {type: "commandHueNotification", data: {button: 3}}, ], genLevelCtrl: [ - {type: 'commandStep', data: {}}, - {type: 'commandStepWithOnOff', data: {}}, - {type: 'commandStop', data: {}}, - {type: 'commandMoveWithOnOff', data: {}}, - {type: 'commandStopWithOnOff', data: {}}, - {type: 'commandMove', data: {}}, - {type: 'commandMoveToLevelWithOnOff', data: {}}, + {type: "commandStep", data: {}}, + {type: "commandStepWithOnOff", data: {}}, + {type: "commandStop", data: {}}, + {type: "commandMoveWithOnOff", data: {}}, + {type: "commandStopWithOnOff", data: {}}, + {type: "commandMove", data: {}}, + {type: "commandMoveToLevelWithOnOff", data: {}}, ], - genScenes: [{type: 'commandRecall', data: {}}], + genScenes: [{type: "commandRecall", data: {}}], }, // Read the following attributes - read: {cluster: 'genLevelCtrl', attributes: ['currentLevel']}, + read: {cluster: "genLevelCtrl", attributes: ["currentLevel"]}, // When the bound devices/members of group have the following manufacturerIDs manufacturerIDs: [ Zcl.ManufacturerCode.SIGNIFY_NETHERLANDS_B_V, @@ -125,29 +125,29 @@ const POLL_ON_MESSAGE: Readonly = [ Zcl.ManufacturerCode.TELINK_MICRO, Zcl.ManufacturerCode.BUSCH_JAEGER_ELEKTRO, ], - manufacturerNames: ['GLEDOPTO', 'Trust International B.V.\u0000'], + manufacturerNames: ["GLEDOPTO", "Trust International B.V.\u0000"], }, { cluster: { genLevelCtrl: [ - {type: 'commandStepWithOnOff', data: {}}, - {type: 'commandMoveWithOnOff', data: {}}, - {type: 'commandStopWithOnOff', data: {}}, - {type: 'commandMoveToLevelWithOnOff', data: {}}, + {type: "commandStepWithOnOff", data: {}}, + {type: "commandMoveWithOnOff", data: {}}, + {type: "commandStopWithOnOff", data: {}}, + {type: "commandMoveToLevelWithOnOff", data: {}}, ], genOnOff: [ - {type: 'commandOn', data: {}}, - {type: 'commandOff', data: {}}, - {type: 'commandOffWithEffect', data: {}}, - {type: 'commandToggle', data: {}}, + {type: "commandOn", data: {}}, + {type: "commandOff", data: {}}, + {type: "commandOffWithEffect", data: {}}, + {type: "commandToggle", data: {}}, ], - genScenes: [{type: 'commandRecall', data: {}}], + genScenes: [{type: "commandRecall", data: {}}], manuSpecificPhilips: [ - {type: 'commandHueNotification', data: {button: 1}}, - {type: 'commandHueNotification', data: {button: 4}}, + {type: "commandHueNotification", data: {button: 1}}, + {type: "commandHueNotification", data: {button: 4}}, ], }, - read: {cluster: 'genOnOff', attributes: ['onOff']}, + read: {cluster: "genOnOff", attributes: ["onOff"]}, manufacturerIDs: [ Zcl.ManufacturerCode.SIGNIFY_NETHERLANDS_B_V, Zcl.ManufacturerCode.ATMEL, @@ -156,14 +156,14 @@ const POLL_ON_MESSAGE: Readonly = [ Zcl.ManufacturerCode.TELINK_MICRO, Zcl.ManufacturerCode.BUSCH_JAEGER_ELEKTRO, ], - manufacturerNames: ['GLEDOPTO', 'Trust International B.V.\u0000'], + manufacturerNames: ["GLEDOPTO", "Trust International B.V.\u0000"], }, { cluster: { - genScenes: [{type: 'commandRecall', data: {}}], + genScenes: [{type: "commandRecall", data: {}}], }, read: { - cluster: 'lightingColorCtrl', + cluster: "lightingColorCtrl", attributes: [] as string[], // Since not all devices support the same attributes they need to be calculated dynamically // depending on the capabilities of the endpoint. @@ -172,11 +172,11 @@ const POLL_ON_MESSAGE: Readonly = [ const readAttrs: string[] = []; if (supportedAttrs.colorXY) { - readAttrs.push('currentX', 'currentY'); + readAttrs.push("currentX", "currentY"); } if (supportedAttrs.colorTemperature) { - readAttrs.push('colorTemperature'); + readAttrs.push("colorTemperature"); } return readAttrs; @@ -190,12 +190,12 @@ const POLL_ON_MESSAGE: Readonly = [ Zcl.ManufacturerCode.TELINK_MICRO, // Note: ManufacturerCode.BUSCH_JAEGER is left out intentionally here as their devices don't support colors ], - manufacturerNames: ['GLEDOPTO', 'Trust International B.V.\u0000'], + manufacturerNames: ["GLEDOPTO", "Trust International B.V.\u0000"], }, ]; interface ParsedMQTTMessage { - type: 'bind' | 'unbind'; + type: "bind" | "unbind"; sourceKey?: string; sourceEndpointKey?: string | number; targetKey?: string; @@ -211,6 +211,7 @@ interface ParsedMQTTMessage { export default class Bind extends Extension { private pollDebouncers: {[s: string]: () => void} = {}; + // biome-ignore lint/suspicious/useAwait: API override async start(): Promise { this.eventBus.onDeviceMessage(this, this.poll); this.eventBus.onMQTTMessage(this, this.onMQTTMessage); @@ -221,16 +222,16 @@ export default class Bind extends Extension { data: eventdata.MQTTMessage, ): [raw: KeyValue | undefined, parsed: ParsedMQTTMessage | undefined, error: string | undefined] { if (data.topic.match(TOPIC_REGEX)) { - const type = data.topic.endsWith('unbind') ? 'unbind' : 'bind'; + const type = data.topic.endsWith("unbind") ? "unbind" : "bind"; let skipDisableReporting = false; - const message = JSON.parse(data.message) as Zigbee2MQTTAPI['bridge/request/device/bind']; + const message = JSON.parse(data.message) as Zigbee2MQTTAPI["bridge/request/device/bind"]; - if (typeof message !== 'object' || message.from == null || message.to == null) { - return [message, {type, skipDisableReporting}, 'Invalid payload']; + if (typeof message !== "object" || message.from == null || message.to == null) { + return [message, {type, skipDisableReporting}, "Invalid payload"]; } const sourceKey = message.from; - const sourceEndpointKey = message.from_endpoint ?? 'default'; + const sourceEndpointKey = message.from_endpoint ?? "default"; const targetKey = message.to; const targetEndpointKey = message.to_endpoint; const clusters = message.clusters; @@ -321,10 +322,10 @@ export default class Bind extends Extension { resolvedBindTarget, } = parsed; - assert(resolvedSource, '`resolvedSource` is missing'); - assert(resolvedTarget, '`resolvedTarget` is missing'); - assert(resolvedSourceEndpoint, '`resolvedSourceEndpoint` is missing'); - assert(resolvedBindTarget !== undefined, '`resolvedBindTarget` is missing'); + assert(resolvedSource, "`resolvedSource` is missing"); + assert(resolvedTarget, "`resolvedTarget` is missing"); + assert(resolvedSourceEndpoint, "`resolvedSourceEndpoint` is missing"); + assert(resolvedBindTarget !== undefined, "`resolvedBindTarget` is missing"); const successfulClusters: string[] = []; const failedClusters = []; @@ -338,8 +339,8 @@ export default class Bind extends Extension { const anyClusterValid = utils.isZHGroup(resolvedBindTarget) || - typeof resolvedBindTarget === 'number' || - (resolvedTarget instanceof Device && resolvedTarget.zh.type === 'Coordinator'); + typeof resolvedBindTarget === "number" || + (resolvedTarget instanceof Device && resolvedTarget.zh.type === "Coordinator"); if (!anyClusterValid && utils.isZHEndpoint(resolvedBindTarget)) { matchingClusters = @@ -354,7 +355,7 @@ export default class Bind extends Extension { attemptedClusters.push(cluster); try { - if (type === 'bind') { + if (type === "bind") { await resolvedSourceEndpoint.bind(cluster, resolvedBindTarget); } else { await resolvedSourceEndpoint.unbind(cluster, resolvedBindTarget); @@ -362,7 +363,7 @@ export default class Bind extends Extension { successfulClusters.push(cluster); logger.info( - `Successfully ${type === 'bind' ? 'bound' : 'unbound'} cluster '${cluster}' from '${resolvedSource.name}' to '${resolvedTarget.name}'`, + `Successfully ${type === "bind" ? "bound" : "unbound"} cluster '${cluster}' from '${resolvedSource.name}' to '${resolvedTarget.name}'`, ); } catch (error) { failedClusters.push(cluster); @@ -382,21 +383,24 @@ export default class Bind extends Extension { return; } - const responseData: Zigbee2MQTTAPI['bridge/response/device/bind'] | Zigbee2MQTTAPI['bridge/response/device/unbind'] = { - from: sourceKey!, // valid with assert above on `resolvedSource` - from_endpoint: sourceEndpointKey!, // valid with assert above on `resolvedSourceEndpoint` - to: targetKey!, // valid with assert above on `resolvedTarget` + const responseData: Zigbee2MQTTAPI["bridge/response/device/bind"] | Zigbee2MQTTAPI["bridge/response/device/unbind"] = { + // biome-ignore lint/style/noNonNullAssertion: valid with assert above on `resolvedSource` + from: sourceKey!, + // biome-ignore lint/style/noNonNullAssertion: valid with assert above on `resolvedSourceEndpoint` + from_endpoint: sourceEndpointKey!, + // biome-ignore lint/style/noNonNullAssertion: valid with assert above on `resolvedTarget` + to: targetKey!, to_endpoint: targetEndpointKey, clusters: successfulClusters, failed: failedClusters, }; if (successfulClusters.length !== 0) { - if (type === 'bind') { + if (type === "bind") { await this.setupReporting( resolvedSourceEndpoint.binds.filter((b) => successfulClusters.includes(b.cluster.name) && b.target === resolvedBindTarget), ); - } else if (typeof resolvedBindTarget !== 'number' && !skipDisableReporting) { + } else if (typeof resolvedBindTarget !== "number" && !skipDisableReporting) { await this.disableUnnecessaryReportings(resolvedBindTarget); } } @@ -406,7 +410,7 @@ export default class Bind extends Extension { } private async publishResponse( - type: ParsedMQTTMessage['type'], + type: ParsedMQTTMessage["type"], request: KeyValue, data: Zigbee2MQTTAPI[T], error?: string, @@ -420,7 +424,7 @@ export default class Bind extends Extension { } @bind async onGroupMembersChanged(data: eventdata.GroupMembersChanged): Promise { - if (data.action === 'add') { + if (data.action === "add") { const bindsToGroup: zh.Bind[] = []; for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) { @@ -468,13 +472,16 @@ export default class Bind extends Extension { for (const bind of binds) { 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}`; + // biome-ignore lint/style/noNonNullAssertion: TODO: biome migration: ??? + const resolvedDevice = this.zigbee.resolveEntity(endpoint.getDevice())!; + const entity = `${resolvedDevice.name}/${endpoint.ID}`; try { await endpoint.bind(bind.cluster.name, coordinatorEndpoint); const items = []; + // biome-ignore lint/style/noNonNullAssertion: valid from outer `if` for (const c of REPORT_CLUSTERS[bind.cluster.name as ClusterName]!) { if (!c.condition || (await c.condition(endpoint))) { const i = {...c}; @@ -533,6 +540,7 @@ export default class Bind extends Extension { const items = []; + // biome-ignore lint/style/noNonNullAssertion: valid from loop (pushed to array only if in) for (const item of REPORT_CLUSTERS[cluster as ClusterName]!) { if (!item.condition || (await item.condition(endpoint))) { const i = {...item}; @@ -572,7 +580,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.isZHEndpoint(bind.target) && bind.target.getDevice().type !== 'Coordinator') { + if (utils.isZHEndpoint(bind.target) && bind.target.getDevice().type !== "Coordinator") { toPoll.add(bind.target); } } @@ -592,8 +600,8 @@ 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 ( + // biome-ignore lint/style/noNonNullAssertion: manufacturerID/manufacturerName can be undefined and won't match `includes`, but TS enforces same-type (!poll.manufacturerIDs.includes(device.manufacturerID!) && !poll.manufacturerNames.includes(device.manufacturerName!)) || !endpoint.supportsInputCluster(poll.read.cluster) ) { @@ -614,9 +622,9 @@ export default class Bind extends Extension { try { await endpoint.read(poll.read.cluster, readAttrs); } catch (error) { - logger.error( - `Failed to poll ${readAttrs} from ${this.zigbee.resolveEntity(device)!.name} (${(error as Error).message})`, - ); + // biome-ignore lint/style/noNonNullAssertion: TODO: biome migration: ??? + const resolvedDevice = this.zigbee.resolveEntity(device)!; + logger.error(`Failed to poll ${readAttrs} from ${resolvedDevice.name} (${(error as Error).message})`); } }, 1000); } diff --git a/lib/extension/bridge.ts b/lib/extension/bridge.ts index 52e458360..6686a170d 100644 --- a/lib/extension/bridge.ts +++ b/lib/extension/bridge.ts @@ -1,25 +1,25 @@ -import type winston from 'winston'; +import type winston from "winston"; -import type Group from '../model/group'; -import type {Zigbee2MQTTAPI, Zigbee2MQTTDevice, Zigbee2MQTTResponse, Zigbee2MQTTResponseEndpoints} from '../types/api'; +import type Group from "../model/group"; +import type {Zigbee2MQTTAPI, Zigbee2MQTTDevice, Zigbee2MQTTResponse, Zigbee2MQTTResponseEndpoints} from "../types/api"; -import fs from 'node:fs'; +import fs from "node: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 Transport from 'winston-transport'; +import bind from "bind-decorator"; +import stringify from "json-stable-stringify-without-jsonify"; +import JSZip from "jszip"; +import objectAssignDeep from "object-assign-deep"; +import Transport from "winston-transport"; -import {Zcl} from 'zigbee-herdsman'; -import * as zhc from 'zigbee-herdsman-converters'; +import {Zcl} from "zigbee-herdsman"; +import * as zhc from "zigbee-herdsman-converters"; -import Device from '../model/device'; -import data from '../util/data'; -import logger from '../util/logger'; -import * as settings from '../util/settings'; -import utils from '../util/utils'; -import Extension from './extension'; +import Device from "../model/device"; +import data from "../util/data"; +import logger from "../util/logger"; +import * as settings from "../util/settings"; +import utils from "../util/utils"; +import Extension from "./extension"; const REQUEST_REGEX = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/(.*)`); @@ -33,23 +33,23 @@ export default class Bridge extends Extension { private lastBridgeLoggingPayload?: string; private logTransport!: winston.transport; private requestLookup: {[key: string]: (message: KeyValue | string) => Promise>} = { - 'device/options': this.deviceOptions, - 'device/configure_reporting': this.deviceConfigureReporting, - 'device/remove': this.deviceRemove, - 'device/interview': this.deviceInterview, - 'device/generate_external_definition': this.deviceGenerateExternalDefinition, - 'device/rename': this.deviceRename, - 'group/add': this.groupAdd, - 'group/options': this.groupOptions, - 'group/remove': this.groupRemove, - 'group/rename': this.groupRename, + "device/options": this.deviceOptions, + "device/configure_reporting": this.deviceConfigureReporting, + "device/remove": this.deviceRemove, + "device/interview": this.deviceInterview, + "device/generate_external_definition": this.deviceGenerateExternalDefinition, + "device/rename": this.deviceRename, + "group/add": this.groupAdd, + "group/options": this.groupOptions, + "group/remove": this.groupRemove, + "group/rename": this.groupRename, permit_join: this.permitJoin, restart: this.restart, backup: this.backup, - 'touchlink/factory_reset': this.touchlinkFactoryReset, - 'touchlink/identify': this.touchlinkIdentify, - 'install_code/add': this.installCodeAdd, - 'touchlink/scan': this.touchlinkScan, + "touchlink/factory_reset": this.touchlinkFactoryReset, + "touchlink/identify": this.touchlinkIdentify, + "install_code/add": this.installCodeAdd, + "touchlink/scan": this.touchlinkScan, health_check: this.healthCheck, coordinator_check: this.coordinatorCheck, options: this.bridgeOptions, @@ -64,7 +64,7 @@ export default class Bridge extends Extension { if (payload !== this.lastBridgeLoggingPayload) { this.lastBridgeLoggingPayload = payload; - void this.mqtt.publish('bridge/logging', payload, {}, baseTopic, true); + void this.mqtt.publish("bridge/logging", payload, {}, baseTopic, true); } }; @@ -80,7 +80,7 @@ export default class Bridge extends Extension { } else { class EventTransport extends Transport { override log(info: {message: string; level: string; namespace: string}, next: () => void): void { - if (info.level !== 'debug') { + if (info.level !== "debug") { bridgeLogging(info.message, info.level, info.namespace); } next(); @@ -93,8 +93,8 @@ export default class Bridge extends Extension { logger.addTransport(this.logTransport); this.zigbee2mqttVersion = await utils.getZigbee2MQTTVersion(); - this.zigbeeHerdsmanVersion = await utils.getDependencyVersion('zigbee-herdsman'); - this.zigbeeHerdsmanConvertersVersion = await utils.getDependencyVersion('zigbee-herdsman-converters'); + this.zigbeeHerdsmanVersion = await utils.getDependencyVersion("zigbee-herdsman"); + this.zigbeeHerdsmanConvertersVersion = await utils.getDependencyVersion("zigbee-herdsman-converters"); this.coordinatorVersion = await this.zigbee.getCoordinatorVersion(); this.eventBus.onEntityRenamed(this, async () => { @@ -123,20 +123,20 @@ export default class Bridge extends Extension { this.lastJoinedDeviceIeeeAddr = data.device.ieeeAddr; await this.publishDevices(); - const payload: Zigbee2MQTTAPI['bridge/event'] = { - type: 'device_joined', + const payload: Zigbee2MQTTAPI["bridge/event"] = { + type: "device_joined", data: {friendly_name: data.device.name, ieee_address: data.device.ieeeAddr}, }; - await this.mqtt.publish('bridge/event', stringify(payload), {retain: false, qos: 0}); + await this.mqtt.publish("bridge/event", stringify(payload), {retain: false, qos: 0}); }); this.eventBus.onDeviceLeave(this, async (data) => { await this.publishDevices(); await this.publishDefinitions(); - const payload: Zigbee2MQTTAPI['bridge/event'] = {type: 'device_leave', data: {ieee_address: data.ieeeAddr, friendly_name: data.name}}; + const payload: Zigbee2MQTTAPI["bridge/event"] = {type: "device_leave", data: {ieee_address: data.ieeeAddr, friendly_name: data.name}}; - await this.mqtt.publish('bridge/event', stringify(payload), {retain: false, qos: 0}); + await this.mqtt.publish("bridge/event", stringify(payload), {retain: false, qos: 0}); }); this.eventBus.onDeviceNetworkAddressChanged(this, async () => { await this.publishDevices(); @@ -144,11 +144,11 @@ export default class Bridge extends Extension { this.eventBus.onDeviceInterview(this, async (data) => { await this.publishDevices(); - let payload: Zigbee2MQTTAPI['bridge/event']; + let payload: Zigbee2MQTTAPI["bridge/event"]; - if (data.status === 'successful') { + if (data.status === "successful") { payload = { - type: 'device_interview', + type: "device_interview", data: { friendly_name: data.device.name, status: data.status, @@ -159,22 +159,22 @@ export default class Bridge extends Extension { }; } else { payload = { - type: 'device_interview', + type: "device_interview", data: {friendly_name: data.device.name, status: data.status, ieee_address: data.device.ieeeAddr}, }; } - await this.mqtt.publish('bridge/event', stringify(payload), {retain: false, qos: 0}); + await this.mqtt.publish("bridge/event", stringify(payload), {retain: false, qos: 0}); }); this.eventBus.onDeviceAnnounce(this, async (data) => { await this.publishDevices(); - const payload: Zigbee2MQTTAPI['bridge/event'] = { - type: 'device_announce', + const payload: Zigbee2MQTTAPI["bridge/event"] = { + type: "device_announce", data: {friendly_name: data.device.name, ieee_address: data.device.ieeeAddr}, }; - await this.mqtt.publish('bridge/event', stringify(payload), {retain: false, qos: 0}); + await this.mqtt.publish("bridge/event", stringify(payload), {retain: false, qos: 0}); }); await this.publishInfo(); @@ -207,6 +207,7 @@ export default class Bridge extends Extension { await this.mqtt.publish(`bridge/response/${match[1]}`, stringify(response)); } catch (error) { logger.error(`Request '${data.topic}' failed with error: '${(error as Error).message}'`); + // biome-ignore lint/style/noNonNullAssertion: always using Error 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)); @@ -218,17 +219,17 @@ export default class Bridge extends Extension { * Requests */ - @bind async deviceOptions(message: KeyValue | string): Promise> { - return await this.changeEntityOptions('device', message); + @bind async deviceOptions(message: KeyValue | string): Promise> { + return await this.changeEntityOptions("device", message); } - @bind async groupOptions(message: KeyValue | string): Promise> { - return await this.changeEntityOptions('group', message); + @bind async groupOptions(message: KeyValue | string): Promise> { + return await this.changeEntityOptions("group", message); } - @bind async bridgeOptions(message: KeyValue | string): Promise> { - if (typeof message !== 'object' || typeof message.options !== 'object') { - throw new Error('Invalid payload'); + @bind async bridgeOptions(message: KeyValue | string): Promise> { + if (typeof message !== "object" || typeof message.options !== "object") { + throw new Error("Invalid payload"); } const newSettings = message.options as Partial; @@ -236,7 +237,7 @@ export default class Bridge extends Extension { // Apply some settings on-the-fly. if (newSettings.homeassistant) { - await this.enableDisableExtension(settings.get().homeassistant.enabled, 'HomeAssistant'); + await this.enableDisableExtension(settings.get().homeassistant.enabled, "HomeAssistant"); } if (newSettings.advanced?.log_level != null) { @@ -251,24 +252,25 @@ export default class Bridge extends Extension { logger.setDebugNamespaceIgnore(settings.get().advanced.log_debug_namespace_ignore); } - logger.info('Successfully changed options'); + logger.info("Successfully changed options"); await this.publishInfo(); return utils.getResponse(message, {restart_required: this.restartRequired}); } - @bind async deviceRemove(message: string | KeyValue): Promise> { - return await this.removeEntity('device', message); + @bind async deviceRemove(message: string | KeyValue): Promise> { + return await this.removeEntity("device", message); } - @bind async groupRemove(message: string | KeyValue): Promise> { - return await this.removeEntity('group', message); + @bind async groupRemove(message: string | KeyValue): Promise> { + return await this.removeEntity("group", message); } - @bind async healthCheck(message: string | KeyValue): Promise> { + // biome-ignore lint/suspicious/useAwait: API + @bind async healthCheck(message: string | KeyValue): Promise> { return utils.getResponse(message, {healthy: true}); } - @bind async coordinatorCheck(message: string | KeyValue): Promise> { + @bind async coordinatorCheck(message: string | KeyValue): Promise> { const result = await this.zigbee.coordinatorCheck(); const missingRouters = result.missingRouters.map((d) => { return {ieee_address: d.ieeeAddr, friendly_name: d.name}; @@ -276,69 +278,70 @@ export default class Bridge extends Extension { return utils.getResponse(message, {missing_routers: missingRouters}); } - @bind async groupAdd(message: string | KeyValue): Promise> { - if (typeof message === 'object' && message.friendly_name === undefined) { - throw new Error('Invalid payload'); + @bind async groupAdd(message: string | KeyValue): Promise> { + 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.id !== undefined ? message.id : null; + const friendlyName = typeof message === "object" ? message.friendly_name : message; + 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}); } - @bind async deviceRename(message: string | KeyValue): Promise> { - return await this.renameEntity('device', message); + @bind async deviceRename(message: string | KeyValue): Promise> { + return await this.renameEntity("device", message); } - @bind async groupRename(message: string | KeyValue): Promise> { - return await this.renameEntity('group', message); + @bind async groupRename(message: string | KeyValue): Promise> { + return await this.renameEntity("group", message); } - @bind async restart(message: string | KeyValue): Promise> { + // biome-ignore lint/suspicious/useAwait: API + @bind async restart(message: string | KeyValue): Promise> { // Wait 500 ms before restarting so response can be send. setTimeout(this.restartCallback, 500); - logger.info('Restarting Zigbee2MQTT'); + logger.info("Restarting Zigbee2MQTT"); return utils.getResponse(message, {}); } - @bind async backup(message: string | KeyValue): Promise> { + @bind async backup(message: string | KeyValue): Promise> { await this.zigbee.backup(); const dataPath = data.getPath(); const files = utils .getAllFiles(dataPath) .map((f) => [f, f.substring(dataPath.length + 1)]) - .filter((f) => !f[1].startsWith('log')); + .filter((f) => !f[1].startsWith("log")); const zip = new JSZip(); for (const f of files) { zip.file(f[1], fs.readFileSync(f[0])); } - const base64Zip = await zip.generateAsync({type: 'base64'}); + const base64Zip = await zip.generateAsync({type: "base64"}); return utils.getResponse(message, {zip: base64Zip}); } - @bind async installCodeAdd(message: KeyValue | string): Promise> { - if (typeof message === 'object' && message.value === undefined) { - throw new Error('Invalid payload'); + @bind async installCodeAdd(message: KeyValue | string): Promise> { + if (typeof message === "object" && message.value === undefined) { + throw new Error("Invalid payload"); } - const value = typeof message === 'object' ? message.value : message; + const value = typeof message === "object" ? message.value : message; await this.zigbee.addInstallCode(value); - logger.info('Successfully added new install code'); + logger.info("Successfully added new install code"); return utils.getResponse(message, {value}); } - @bind async permitJoin(message: KeyValue | string): Promise> { + @bind async permitJoin(message: KeyValue | string): Promise> { let time: number | undefined; let device: Device | undefined; - if (typeof message === 'object') { + if (typeof message === "object") { if (message.time === undefined) { - throw new Error('Invalid payload'); + throw new Error("Invalid payload"); } time = Number.parseInt(message.time, 10); @@ -367,9 +370,9 @@ export default class Bridge extends Extension { return utils.getResponse(message, response); } - @bind async touchlinkIdentify(message: KeyValue | string): Promise> { - if (typeof message !== 'object' || message.ieee_address === undefined || message.channel === undefined) { - throw new Error('Invalid payload'); + @bind async touchlinkIdentify(message: KeyValue | string): Promise> { + 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}`); @@ -377,11 +380,11 @@ export default class Bridge extends Extension { return utils.getResponse(message, {ieee_address: message.ieee_address, channel: message.channel}); } - @bind async touchlinkFactoryReset(message: KeyValue | string): Promise> { + @bind async touchlinkFactoryReset(message: KeyValue | string): Promise> { let result = false; - let payload: Zigbee2MQTTAPI['bridge/response/touchlink/factory_reset'] = {}; + let payload: Zigbee2MQTTAPI["bridge/response/touchlink/factory_reset"] = {}; - if (typeof message === 'object' && message.ieee_address !== undefined && message.channel !== undefined) { + 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); @@ -390,26 +393,26 @@ export default class Bridge extends Extension { channel: message.channel, }; } else { - logger.info('Start Touchlink factory reset of first found device'); + logger.info("Start Touchlink factory reset of first found device"); result = await this.zigbee.touchlinkFactoryResetFirst(); } if (result) { - logger.info('Successfully factory reset device through Touchlink'); + logger.info("Successfully factory reset device through Touchlink"); return utils.getResponse(message, payload); } - logger.error('Failed to factory reset device through Touchlink'); - throw new Error('Failed to factory reset device through Touchlink'); + logger.error("Failed to factory reset device through Touchlink"); + throw new Error("Failed to factory reset device through Touchlink"); } - @bind async touchlinkScan(message: KeyValue | string): Promise> { - logger.info('Start Touchlink scan'); + @bind async touchlinkScan(message: KeyValue | string): Promise> { + logger.info("Start Touchlink scan"); const result = await this.zigbee.touchlinkScan(); const found = result.map((r) => { return {ieee_address: r.ieeeAddr, channel: r.channel}; }); - logger.info('Finished Touchlink scan'); + logger.info("Finished Touchlink scan"); return utils.getResponse(message, {found}); } @@ -417,12 +420,12 @@ export default class Bridge extends Extension { * Utils */ - async changeEntityOptions( + async changeEntityOptions( entityType: T, message: KeyValue | string, - ): Promise> { - if (typeof message !== 'object' || message.id === undefined || message.options === undefined) { - throw new Error('Invalid payload'); + ): Promise> { + if (typeof message !== "object" || message.id === undefined || message.options === undefined) { + throw new Error("Invalid payload"); } const cleanup = (o: KeyValue): KeyValue => { @@ -458,9 +461,9 @@ export default class Bridge extends Extension { return utils.getResponse(message, {from: oldOptions, to: newOptions, id: ID, restart_required: this.restartRequired}); } - @bind async deviceConfigureReporting(message: string | KeyValue): Promise> { + @bind async deviceConfigureReporting(message: string | KeyValue): Promise> { if ( - typeof message !== 'object' || + typeof message !== "object" || message.id === undefined || message.endpoint === undefined || message.cluster === undefined || @@ -469,10 +472,10 @@ export default class Bridge extends Extension { message.reportable_change === undefined || message.attribute === undefined ) { - throw new Error('Invalid payload'); + throw new Error("Invalid payload"); } - const device = this.getEntity('device', message.id); + const device = this.getEntity("device", message.id); const endpoint = device.endpoint(message.endpoint); if (!endpoint) { @@ -510,12 +513,12 @@ export default class Bridge extends Extension { }); } - @bind async deviceInterview(message: string | KeyValue): Promise> { - if (typeof message !== 'object' || message.id === undefined) { - throw new Error('Invalid payload'); + @bind async deviceInterview(message: string | KeyValue): Promise> { + if (typeof message !== "object" || message.id === undefined) { + throw new Error("Invalid payload"); } - const device = this.getEntity('device', message.id); + const device = this.getEntity("device", message.id); logger.info(`Interviewing '${device.name}'`); try { @@ -535,29 +538,29 @@ export default class Bridge extends Extension { @bind async deviceGenerateExternalDefinition( message: string | KeyValue, - ): Promise> { - if (typeof message !== 'object' || message.id === undefined) { - throw new Error('Invalid payload'); + ): Promise> { + if (typeof message !== "object" || message.id === undefined) { + throw new Error("Invalid payload"); } - const device = this.getEntity('device', message.id); + const device = this.getEntity("device", message.id); const source = await zhc.generateExternalDefinitionSource(device.zh); return utils.getResponse(message, {id: message.id, source}); } - async renameEntity( + async renameEntity( entityType: T, message: string | KeyValue, - ): Promise> { - const deviceAndHasLast = entityType === 'device' && typeof message === 'object' && message.last === true; + ): Promise> { + const deviceAndHasLast = entityType === "device" && typeof message === "object" && message.last === true; - if (typeof message !== 'object' || (message.from === undefined && !deviceAndHasLast) || message.to === undefined) { - throw new Error('Invalid payload'); + if (typeof message !== "object" || (message.from === undefined && !deviceAndHasLast) || message.to === undefined) { + throw new Error("Invalid payload"); } if (deviceAndHasLast && !this.lastJoinedDeviceIeeeAddr) { - throw new Error('No device has joined since start'); + throw new Error("No device has joined since start"); } const from = deviceAndHasLast ? this.lastJoinedDeviceIeeeAddr : message.from; @@ -569,7 +572,7 @@ export default class Bridge extends Extension { settings.changeFriendlyName(from, to); // Clear retained messages - await this.mqtt.publish(oldFriendlyName, '', {retain: true}); + await this.mqtt.publish(oldFriendlyName, "", {retain: true}); this.eventBus.emitEntityRenamed({entity: entity, homeAssisantRename, from: oldFriendlyName, to}); @@ -586,23 +589,23 @@ export default class Bridge extends Extension { return utils.getResponse(message, {from: oldFriendlyName, to, homeassistant_rename: homeAssisantRename}); } - async removeEntity( + async removeEntity( entityType: T, message: string | KeyValue, - ): Promise> { - const ID = typeof message === 'object' ? message.id : message.trim(); + ): Promise> { + const ID = typeof message === "object" ? message.id : message.trim(); const entity = this.getEntity(entityType, ID); // note: entity.name is dynamically retrieved, will change once device is removed (friendly => ieee) const friendlyName = entity.name; let block = false; let force = false; - let blockForceLog = ''; + let blockForceLog = ""; - if (entityType === 'device' && typeof message === 'object') { + if (entityType === "device" && typeof message === "object") { block = !!message.block; force = !!message.force; blockForceLog = ` (block: ${block}, force: ${force})`; - } else if (entityType === 'group' && typeof message === 'object') { + } else if (entityType === "group" && typeof message === "object") { force = !!message.force; blockForceLog = ` (force: ${force})`; } @@ -621,7 +624,7 @@ export default class Bridge extends Extension { await entity.zh.removeFromNetwork(); } - this.eventBus.emitEntityRemoved({id: entity.ID, name: friendlyName, type: 'device'}); + this.eventBus.emitEntityRemoved({id: entity.ID, name: friendlyName, type: "device"}); settings.removeDevice(entity.ID as string); } else { if (force) { @@ -630,7 +633,7 @@ export default class Bridge extends Extension { await entity.zh.removeFromNetwork(); } - this.eventBus.emitEntityRemoved({id: entity.ID, name: friendlyName, type: 'group'}); + this.eventBus.emitEntityRemoved({id: entity.ID, name: friendlyName, type: "group"}); settings.removeGroup(entity.ID); } @@ -638,7 +641,7 @@ export default class Bridge extends Extension { this.state.remove(entity.ID); // Clear any retained messages - await this.mqtt.publish(friendlyName, '', {retain: true}); + await this.mqtt.publish(friendlyName, "", {retain: true}); logger.info(`Successfully removed ${entityType} '${friendlyName}'${blockForceLog}`); @@ -648,14 +651,14 @@ export default class Bridge extends Extension { // Refresh Cluster definition await this.publishDefinitions(); - const responseData: Zigbee2MQTTAPI['bridge/response/device/remove'] = {id: ID, block, force}; + const responseData: Zigbee2MQTTAPI["bridge/response/device/remove"] = {id: ID, block, force}; return utils.getResponse(message, responseData); } await this.publishGroups(); - const responseData: Zigbee2MQTTAPI['bridge/response/group/remove'] = {id: ID, force}; + const responseData: Zigbee2MQTTAPI["bridge/response/group/remove"] = {id: ID, force}; return utils.getResponse( message, @@ -667,13 +670,13 @@ export default class Bridge extends Extension { } } - getEntity(type: 'group', ID: string): Group; - getEntity(type: 'device', ID: string): Device; - getEntity(type: 'group' | 'device', ID: string): Device | Group; - getEntity(type: 'group' | 'device', ID: string): Device | Group { - const entity = this.zigbee.resolveEntity(ID); + getEntity(type: "group", id: string): Group; + getEntity(type: "device", id: string): Device; + getEntity(type: "group" | "device", id: string): Device | Group; + getEntity(type: "group" | "device", id: string): Device | Group { + const entity = this.zigbee.resolveEntity(id); if (!entity || entity.constructor.name.toLowerCase() !== type) { - throw new Error(`${utils.capitalize(type)} '${ID}' does not exist`); + throw new Error(`${utils.capitalize(type)} '${id}' does not exist`); } return entity; } @@ -686,7 +689,7 @@ export default class Bridge extends Extension { delete config.frontend.auth_token; const networkParams = await this.zigbee.getNetworkParameters(); - const payload: Zigbee2MQTTAPI['bridge/info'] = { + const payload: Zigbee2MQTTAPI["bridge/info"] = { version: this.zigbee2mqttVersion.version, commit: this.zigbee2mqttVersion.commitHash, zigbee_herdsman_converters: this.zigbeeHerdsmanConvertersVersion, @@ -708,14 +711,14 @@ export default class Bridge extends Extension { config_schema: settings.schemaJson, }; - await this.mqtt.publish('bridge/info', stringify(payload), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true); + await this.mqtt.publish("bridge/info", stringify(payload), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true); } async publishDevices(): Promise { - const devices: Zigbee2MQTTAPI['bridge/devices'] = []; + const devices: Zigbee2MQTTAPI["bridge/devices"] = []; for (const device of this.zigbee.devicesIterator()) { - const endpoints: (typeof devices)[number]['endpoints'] = {}; + const endpoints: (typeof devices)[number]["endpoints"] = {}; for (const endpoint of device.zh.endpoints) { const data: (typeof endpoints)[keyof typeof endpoints] = { @@ -730,8 +733,8 @@ export default class Bridge extends Extension { for (const bind of endpoint.binds) { const target = utils.isZHEndpoint(bind.target) - ? {type: 'endpoint', ieee_address: bind.target.deviceIeeeAddress, endpoint: bind.target.ID} - : {type: 'group', id: bind.target.groupID}; + ? {type: "endpoint", ieee_address: bind.target.deviceIeeeAddress, endpoint: bind.target.ID} + : {type: "group", id: bind.target.groupID}; data.bindings.push({cluster: bind.cluster.name, target}); } @@ -768,11 +771,11 @@ export default class Bridge extends Extension { }); } - await this.mqtt.publish('bridge/devices', stringify(devices), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true); + await this.mqtt.publish("bridge/devices", stringify(devices), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true); } async publishGroups(): Promise { - const groups: Zigbee2MQTTAPI['bridge/groups'] = []; + const groups: Zigbee2MQTTAPI["bridge/groups"] = []; for (const group of this.zigbee.groupsIterator()) { const members = []; @@ -783,18 +786,18 @@ export default class Bridge extends Extension { groups.push({ id: group.ID, - friendly_name: group.ID === 901 ? 'default_bind_group' : group.name, + friendly_name: group.ID === 901 ? "default_bind_group" : group.name, description: group.options.description, scenes: utils.getScenes(group.zh), members, }); } - await this.mqtt.publish('bridge/groups', stringify(groups), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true); + await this.mqtt.publish("bridge/groups", stringify(groups), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true); } async publishDefinitions(): Promise { - const data: Zigbee2MQTTAPI['bridge/definition'] = { + const data: Zigbee2MQTTAPI["bridge/definition"] = { clusters: Zcl.Clusters, custom_clusters: {}, }; @@ -803,10 +806,10 @@ export default class Bridge extends Extension { data.custom_clusters[device.ieeeAddr] = device.customClusters; } - await this.mqtt.publish('bridge/definitions', stringify(data), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true); + await this.mqtt.publish("bridge/definitions", stringify(data), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true); } - getDefinitionPayload(device: Device): Zigbee2MQTTDevice['definition'] | undefined { + getDefinitionPayload(device: Device): Zigbee2MQTTDevice["definition"] | undefined { if (!device.definition) { return undefined; } @@ -818,11 +821,11 @@ export default class Bridge extends Extension { if (icon) { /* v8 ignore next */ - icon = icon.replace('${zigbeeModel}', utils.sanitizeImageParameter(device.zh.modelID ?? '')); - icon = icon.replace('${model}', utils.sanitizeImageParameter(device.definition.model)); + icon = icon.replace("${zigbeeModel}", utils.sanitizeImageParameter(device.zh.modelID ?? "")); + icon = icon.replace("${model}", utils.sanitizeImageParameter(device.definition.model)); } - const payload: Zigbee2MQTTDevice['definition'] = { + const payload: Zigbee2MQTTDevice["definition"] = { model: device.definition.model, vendor: device.definition.vendor, description: device.definition.description, diff --git a/lib/extension/configure.ts b/lib/extension/configure.ts index dcf1c2137..68eb92b47 100644 --- a/lib/extension/configure.ts +++ b/lib/extension/configure.ts @@ -1,15 +1,15 @@ -import type {Zigbee2MQTTAPI} from '../types/api'; +import type {Zigbee2MQTTAPI} from "../types/api"; -import bind from 'bind-decorator'; -import stringify from 'json-stable-stringify-without-jsonify'; +import bind from "bind-decorator"; +import stringify from "json-stable-stringify-without-jsonify"; -import * as zhc from 'zigbee-herdsman-converters'; +import * as zhc from "zigbee-herdsman-converters"; -import Device from '../model/device'; -import logger from '../util/logger'; -import * as settings from '../util/settings'; -import utils from '../util/utils'; -import Extension from './extension'; +import Device from "../model/device"; +import logger from "../util/logger"; +import * as settings from "../util/settings"; +import utils from "../util/utils"; +import Extension from "./extension"; /** * This extension calls the zigbee-herdsman-converters definition configure() method @@ -26,17 +26,17 @@ export default class Configure extends Extension { data.device.zh.save(); } - await this.configure(data.device, 'reporting_disabled'); + await this.configure(data.device, "reporting_disabled"); } @bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise { if (data.topic === this.topic) { - const message = utils.parseJSON(data.message, data.message) as Zigbee2MQTTAPI['bridge/request/device/configure']; - const ID = typeof message === 'object' ? message.id : message; + const message = utils.parseJSON(data.message, data.message) as Zigbee2MQTTAPI["bridge/request/device/configure"]; + const ID = typeof message === "object" ? message.id : message; let error: string | undefined; if (ID === undefined) { - error = 'Invalid payload'; + error = "Invalid payload"; } else { const device = this.zigbee.resolveEntity(ID); @@ -46,16 +46,16 @@ export default class Configure extends Extension { error = `Device '${device.name}' cannot be configured`; } else { try { - await this.configure(device, 'mqtt_message', true, true); + await this.configure(device, "mqtt_message", true, true); } catch (e) { error = `Failed to configure (${(e as Error).message})`; } } } - const response = utils.getResponse<'bridge/response/device/configure'>(message, {id: ID}, error); + const response = utils.getResponse<"bridge/response/device/configure">(message, {id: ID}, error); - await this.mqtt.publish('bridge/response/device/configure', stringify(response)); + await this.mqtt.publish("bridge/response/device/configure", stringify(response)); } } @@ -63,10 +63,10 @@ export default class Configure extends Extension { setImmediate(async () => { // Only configure routers on startup, end devices are likely sleeping and // will reconfigure once they send a message - for (const device of this.zigbee.devicesIterator((d) => d.type === 'Router')) { + for (const device of this.zigbee.devicesIterator((d) => d.type === "Router")) { // Sleep 10 seconds between configuring on startup to not DDoS the coordinator when many devices have to be configured. await utils.sleep(10); - await this.configure(device, 'started'); + await this.configure(device, "started"); } }); @@ -76,17 +76,17 @@ export default class Configure extends Extension { data.device.zh.save(); } - await this.configure(data.device, 'zigbee_event'); + await this.configure(data.device, "zigbee_event"); }); - this.eventBus.onDeviceInterview(this, (data) => this.configure(data.device, 'zigbee_event')); - this.eventBus.onLastSeenChanged(this, (data) => this.configure(data.device, 'zigbee_event')); + this.eventBus.onDeviceInterview(this, (data) => this.configure(data.device, "zigbee_event")); + this.eventBus.onLastSeenChanged(this, (data) => this.configure(data.device, "zigbee_event")); this.eventBus.onMQTTMessage(this, this.onMQTTMessage); this.eventBus.onReconfigure(this, this.onReconfigure); } private async configure( device: Device, - event: 'started' | 'zigbee_event' | 'reporting_disabled' | 'mqtt_message', + event: "started" | "zigbee_event" | "reporting_disabled" | "mqtt_message", force = false, throwError = false, ): Promise { @@ -104,7 +104,7 @@ export default class Configure extends Extension { } // Only configure end devices when it is active, otherwise it will likely fails as they are sleeping. - if (device.zh.type === 'EndDevice' && event !== 'zigbee_event') { + if (device.zh.type === "EndDevice" && event !== "zigbee_event") { return; } } diff --git a/lib/extension/extension.ts b/lib/extension/extension.ts index 53712987b..67a02c976 100644 --- a/lib/extension/extension.ts +++ b/lib/extension/extension.ts @@ -1,6 +1,6 @@ abstract class Extension { protected zigbee: Zigbee; - protected mqtt: MQTT; + protected mqtt: Mqtt; protected state: State; protected publishEntityState: PublishEntityState; protected eventBus: EventBus; @@ -12,7 +12,7 @@ abstract class Extension { * Besides initializing variables, the constructor should do nothing! * * @param {Zigbee} zigbee Zigbee controller - * @param {MQTT} mqtt MQTT controller + * @param {Mqtt} mqtt MQTT controller * @param {State} state State controller * @param {Function} publishEntityState Method to publish device state to MQTT. * @param {EventBus} eventBus The event bus @@ -22,7 +22,7 @@ abstract class Extension { */ constructor( zigbee: Zigbee, - mqtt: MQTT, + mqtt: Mqtt, state: State, publishEntityState: PublishEntityState, eventBus: EventBus, @@ -48,12 +48,13 @@ abstract class Extension { /** * Is called once the extension has to stop */ + + // biome-ignore lint/suspicious/useAwait: API async stop(): Promise { this.eventBus.removeListeners(this); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public adjustMessageBeforePublish(entity: Group | Device, message: KeyValue): void {} + public adjustMessageBeforePublish(_entity: Group | Device, _message: KeyValue): void {} } export default Extension; diff --git a/lib/extension/externalConverters.ts b/lib/extension/externalConverters.ts index 4659a3a82..a76bd5906 100644 --- a/lib/extension/externalConverters.ts +++ b/lib/extension/externalConverters.ts @@ -1,16 +1,16 @@ -import type {ExternalDefinitionWithExtend} from 'zigbee-herdsman-converters'; +import type {ExternalDefinitionWithExtend} from "zigbee-herdsman-converters"; -import {addExternalDefinition, removeExternalDefinitions} from 'zigbee-herdsman-converters'; +import {addExternalDefinition, removeExternalDefinitions} from "zigbee-herdsman-converters"; -import logger from '../util/logger'; -import ExternalJSExtension from './externalJS'; +import logger from "../util/logger"; +import ExternalJSExtension from "./externalJS"; type TModule = ExternalDefinitionWithExtend | ExternalDefinitionWithExtend[]; export default class ExternalConverters extends ExternalJSExtension { constructor( zigbee: Zigbee, - mqtt: MQTT, + mqtt: Mqtt, state: State, publishEntityState: PublishEntityState, eventBus: EventBus, @@ -27,13 +27,12 @@ export default class ExternalConverters extends ExternalJSExtension { enableDisableExtension, restartCallback, addExtension, - 'converter', - 'external_converters', + "converter", + "external_converters", ); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected async removeJS(name: string, mod: TModule): Promise { + protected async removeJS(name: string, _mod: TModule): Promise { removeExternalDefinitions(name); await this.zigbee.resolveDevicesDefinitions(true); @@ -59,7 +58,7 @@ export default class ExternalConverters extends ExternalJSExtension { `Failed to load external converter '${newName ?? name}'. Check the code for syntax error and make sure it is up to date with the current Zigbee2MQTT version.`, ); logger.warning( - 'External converters are not meant for long term usage, but for local testing after which a pull request should be created to add out-of-the-box support for the device', + "External converters are not meant for long term usage, but for local testing after which a pull request should be created to add out-of-the-box support for the device", ); throw error; diff --git a/lib/extension/externalExtensions.ts b/lib/extension/externalExtensions.ts index 90c120cbb..3e004be45 100644 --- a/lib/extension/externalExtensions.ts +++ b/lib/extension/externalExtensions.ts @@ -1,15 +1,15 @@ -import type Extension from './extension'; +import type Extension from "./extension"; -import logger from '../util/logger'; -import * as settings from '../util/settings'; -import ExternalJSExtension from './externalJS'; +import logger from "../util/logger"; +import * as settings from "../util/settings"; +import ExternalJSExtension from "./externalJS"; type TModule = new (...args: ConstructorParameters) => Extension; export default class ExternalExtensions extends ExternalJSExtension { constructor( zigbee: Zigbee, - mqtt: MQTT, + mqtt: Mqtt, state: State, publishEntityState: PublishEntityState, eventBus: EventBus, @@ -26,12 +26,12 @@ export default class ExternalExtensions extends ExternalJSExtension { enableDisableExtension, restartCallback, addExtension, - 'extension', - 'external_extensions', + "extension", + "external_extensions", ); } - protected async removeJS(name: string, mod: TModule): Promise { + protected async removeJS(_name: string, mod: TModule): Promise { await this.enableDisableExtension(false, mod.name); } diff --git a/lib/extension/externalJS.ts b/lib/extension/externalJS.ts index 7c6001520..e290a5446 100644 --- a/lib/extension/externalJS.ts +++ b/lib/extension/externalJS.ts @@ -1,18 +1,18 @@ -import type {Zigbee2MQTTAPI, Zigbee2MQTTResponse} from '../types/api'; +import type {Zigbee2MQTTAPI, Zigbee2MQTTResponse} from "../types/api"; -import fs from 'node:fs'; -import path from 'node:path'; +import fs from "node:fs"; +import path from "node:path"; -import bind from 'bind-decorator'; -import stringify from 'json-stable-stringify-without-jsonify'; +import bind from "bind-decorator"; +import stringify from "json-stable-stringify-without-jsonify"; -import data from '../util/data'; -import logger from '../util/logger'; -import * as settings from '../util/settings'; -import utils from '../util/utils'; -import Extension from './extension'; +import data from "../util/data"; +import logger from "../util/logger"; +import * as settings from "../util/settings"; +import utils from "../util/utils"; +import Extension from "./extension"; -const SUPPORTED_OPERATIONS = ['save', 'remove']; +const SUPPORTED_OPERATIONS = ["save", "remove"]; export default abstract class ExternalJSExtension extends Extension { protected folderName: string; @@ -23,7 +23,7 @@ export default abstract class ExternalJSExtension extends Extension { constructor( zigbee: Zigbee, - mqtt: MQTT, + mqtt: Mqtt, state: State, publishEntityState: PublishEntityState, eventBus: EventBus, @@ -42,7 +42,7 @@ export default abstract class ExternalJSExtension extends Extension { // 1-up from this file this.srcBasePath = path.join( __dirname, - '..', + "..", // prevent race in vitest with files being manipulated from same location process.env.VITEST_WORKER_ID ? /* v8 ignore next */ `${folderName}_${Math.floor(Math.random() * 10000)}` : folderName, ); @@ -72,7 +72,7 @@ export default abstract class ExternalJSExtension extends Extension { } protected getFileCode(name: string): string { - return fs.readFileSync(this.getFilePath(name), 'utf8'); + return fs.readFileSync(this.getFilePath(name), "utf8"); } protected *getFiles(inSource = false): Generator<{name: string; code: string}> { @@ -83,7 +83,7 @@ export default abstract class ExternalJSExtension extends Extension { } for (const fileName of fs.readdirSync(basePath)) { - if (fileName.endsWith('.js') || fileName.endsWith('.cjs') || fileName.endsWith('.mjs')) { + if (fileName.endsWith(".js") || fileName.endsWith(".cjs") || fileName.endsWith(".mjs")) { yield {name: fileName, code: this.getFileCode(fileName)}; } } @@ -98,13 +98,13 @@ export default abstract class ExternalJSExtension extends Extension { try { let response: Awaited>; - if (match[1].toLowerCase() === 'save') { + if (match[1].toLowerCase() === "save") { response = await this.save( - message as Zigbee2MQTTAPI['bridge/request/converter/save'] | Zigbee2MQTTAPI['bridge/request/extension/save'], + message as Zigbee2MQTTAPI["bridge/request/converter/save"] | Zigbee2MQTTAPI["bridge/request/extension/save"], ); } else { response = await this.remove( - message as Zigbee2MQTTAPI['bridge/request/converter/remove'] | Zigbee2MQTTAPI['bridge/request/extension/remove'], + message as Zigbee2MQTTAPI["bridge/request/converter/remove"] | Zigbee2MQTTAPI["bridge/request/extension/remove"], ); } @@ -124,10 +124,10 @@ export default abstract class ExternalJSExtension extends Extension { protected abstract loadJS(name: string, mod: M, newName?: string): Promise; @bind private async remove( - message: Zigbee2MQTTAPI['bridge/request/converter/remove'] | Zigbee2MQTTAPI['bridge/request/extension/remove'], - ): Promise> { + message: Zigbee2MQTTAPI["bridge/request/converter/remove"] | Zigbee2MQTTAPI["bridge/request/extension/remove"], + ): Promise> { if (!message.name) { - return utils.getResponse(message, {}, 'Invalid payload'); + return utils.getResponse(message, {}, "Invalid payload"); } const {name} = message; @@ -150,10 +150,10 @@ export default abstract class ExternalJSExtension extends Extension { } @bind private async save( - message: Zigbee2MQTTAPI['bridge/request/converter/save'] | Zigbee2MQTTAPI['bridge/request/extension/save'], - ): Promise> { + message: Zigbee2MQTTAPI["bridge/request/converter/save"] | Zigbee2MQTTAPI["bridge/request/extension/save"], + ): Promise> { if (!message.name || !message.code) { - return utils.getResponse(message, {}, 'Invalid payload'); + return utils.getResponse(message, {}, "Invalid payload"); } const {name, code} = message; @@ -180,14 +180,14 @@ export default abstract class ExternalJSExtension extends Extension { const newSrcFilePath = this.getFilePath(newName, false /* already created above if needed */, true); try { - fs.writeFileSync(newSrcFilePath, code, 'utf8'); + fs.writeFileSync(newSrcFilePath, code, "utf8"); const mod = await import(this.getImportPath(newSrcFilePath)); await this.loadJS(name, mod.default, newName); logger.info(`${newName} loaded. Contents written to '${newSrcFilePath}'.`); // keep original in data folder synced - fs.writeFileSync(this.getFilePath(newName, true, false), code, 'utf8'); + fs.writeFileSync(this.getFilePath(newName, true, false), code, "utf8"); await this.publishExternalJS(); return utils.getResponse(message, {}); @@ -218,6 +218,7 @@ export default abstract class ExternalJSExtension extends Extension { logger.error( `Invalid external ${this.mqttTopic} '${extension.name}' was ignored and renamed to prevent interference with Zigbee2MQTT.`, ); + // biome-ignore lint/style/noNonNullAssertion: always Error logger.debug((error as Error).stack!); } } @@ -238,6 +239,6 @@ export default abstract class ExternalJSExtension extends Extension { private getImportPath(filePath: string): string { // prevent issues on Windows - return path.relative(__dirname, filePath).replaceAll('\\', '/'); + return path.relative(__dirname, filePath).replaceAll("\\", "/"); } } diff --git a/lib/extension/frontend.ts b/lib/extension/frontend.ts index e231629fd..35a1ee4ca 100644 --- a/lib/extension/frontend.ts +++ b/lib/extension/frontend.ts @@ -1,28 +1,28 @@ -import type {IncomingMessage, Server, ServerResponse} from 'node:http'; -import type {Socket} from 'node:net'; +import type {IncomingMessage, Server, ServerResponse} from "node:http"; +import type {Socket} from "node:net"; -import type {RequestHandler} from 'express-static-gzip'; +import type {RequestHandler} from "express-static-gzip"; -import assert from 'node:assert'; -import {existsSync, readFileSync} from 'node:fs'; -import {createServer} from 'node:http'; -import {createServer as createSecureServer} from 'node:https'; -import {posix} from 'node:path'; -import {parse} from 'node:url'; +import assert from "node:assert"; +import {existsSync, readFileSync} from "node:fs"; +import {createServer} from "node:http"; +import {createServer as createSecureServer} from "node:https"; +import {posix} from "node:path"; +import {parse} from "node:url"; -import bind from 'bind-decorator'; -import expressStaticGzip from 'express-static-gzip'; -import finalhandler from 'finalhandler'; -import stringify from 'json-stable-stringify-without-jsonify'; -import WebSocket from 'ws'; +import bind from "bind-decorator"; +import expressStaticGzip from "express-static-gzip"; +import finalhandler from "finalhandler"; +import stringify from "json-stable-stringify-without-jsonify"; +import WebSocket from "ws"; -import frontend from 'zigbee2mqtt-frontend'; +import frontend from "zigbee2mqtt-frontend"; -import data from '../util/data'; -import logger from '../util/logger'; -import * as settings from '../util/settings'; -import utils from '../util/utils'; -import Extension from './extension'; +import data from "../util/data"; +import logger from "../util/logger"; +import * as settings from "../util/settings"; +import utils from "../util/utils"; +import Extension from "./extension"; /** * This extension servers the frontend @@ -42,7 +42,7 @@ export class Frontend extends Extension { constructor( zigbee: Zigbee, - mqtt: MQTT, + mqtt: Mqtt, state: State, publishEntityState: PublishEntityState, eventBus: EventBus, @@ -80,39 +80,37 @@ export class Frontend extends Extension { // TODO: https://github.com/Koenkk/zigbee2mqtt/issues/24654 - enable compressed index serving when express-static-gzip is fixed. index: false, serveStatic: { - index: 'index.html', + index: "index.html", /* v8 ignore start */ setHeaders: (res: ServerResponse, path: string): void => { - if (path.endsWith('index.html')) { - res.setHeader('Cache-Control', 'no-store'); + if (path.endsWith("index.html")) { + res.setHeader("Cache-Control", "no-store"); } }, /* v8 ignore stop */ }, }; this.fileServer = expressStaticGzip(frontend.getPath(), options); - this.deviceIconsFileServer = expressStaticGzip(data.joinPath('device_icons'), options); - this.wss = new WebSocket.Server({noServer: true, path: posix.join(this.baseUrl, 'api')}); + this.deviceIconsFileServer = expressStaticGzip(data.joinPath("device_icons"), options); + this.wss = new WebSocket.Server({noServer: true, path: posix.join(this.baseUrl, "api")}); - this.wss.on('connection', this.onWebSocketConnection); + this.wss.on("connection", this.onWebSocketConnection); if (this.isHttpsConfigured()) { - const serverOptions = { - key: readFileSync(this.sslKey!), // valid from `isHttpsConfigured` - cert: readFileSync(this.sslCert!), // valid from `isHttpsConfigured` - }; + // biome-ignore lint/style/noNonNullAssertion: valid from `isHttpsConfigured` + const serverOptions = {key: readFileSync(this.sslKey!), cert: readFileSync(this.sslCert!)}; this.server = createSecureServer(serverOptions, this.onRequest); } else { this.server = createServer(this.onRequest); } - this.server.on('upgrade', this.onUpgrade); + this.server.on("upgrade", this.onUpgrade); this.eventBus.onMQTTMessagePublished(this, this.onMQTTPublishMessage); if (!this.host) { this.server.listen(this.port); logger.info(`Started frontend on port ${this.port}`); - } else if (this.host.startsWith('/')) { + } else if (this.host.startsWith("/")) { this.server.listen(this.host); logger.info(`Started frontend on socket ${this.host}`); } else { @@ -126,7 +124,7 @@ export class Frontend extends Extension { if (this.wss) { for (const client of this.wss.clients) { - client.send(stringify({topic: 'bridge/state', payload: {state: 'offline'}})); + client.send(stringify({topic: "bridge/state", payload: {state: "offline"}})); client.terminate(); } @@ -138,10 +136,11 @@ export class Frontend extends Extension { @bind private onRequest(request: IncomingMessage, response: ServerResponse): void { const fin = finalhandler(request, response); + // biome-ignore lint/style/noNonNullAssertion: `Only valid for request obtained from Server` const newUrl = posix.relative(this.baseUrl, request.url!); // The request url is not within the frontend base url, so the relative path starts with '..' - if (newUrl.startsWith('.')) { + if (newUrl.startsWith(".")) { fin(); return; @@ -153,9 +152,9 @@ export class Frontend extends Extension { request.url = `/${newUrl}`; request.path = request.url; - if (newUrl.startsWith('device_icons/')) { - request.path = request.path.replace('device_icons/', ''); - request.url = request.url.replace('/device_icons', ''); + if (newUrl.startsWith("device_icons/")) { + request.path = request.path.replace("device_icons/", ""); + request.url = request.url.replace("/device_icons", ""); this.deviceIconsFileServer(request, response, fin); } else { this.fileServer(request, response, fin); @@ -163,6 +162,7 @@ export class Frontend extends Extension { } private authenticate(request: IncomingMessage, cb: (authenticate: boolean) => void): void { + // biome-ignore lint/style/noNonNullAssertion: `Only valid for request obtained from Server` const {query} = parse(request.url!, true); cb(!this.authToken || this.authToken === query.token); } @@ -171,17 +171,17 @@ export class Frontend extends Extension { 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'); + ws.close(4401, "Unauthorized"); } }); }); } @bind private onWebSocketConnection(ws: WebSocket): void { - ws.on('error', (msg) => logger.error(`WebSocket error: ${msg.message}`)); - ws.on('message', (data: Buffer, isBinary: boolean) => { + ws.on("error", (msg) => logger.error(`WebSocket error: ${msg.message}`)); + ws.on("message", (data: Buffer, isBinary: boolean) => { if (!isBinary && data) { const message = data.toString(); const {topic, payload} = JSON.parse(message); @@ -205,7 +205,7 @@ export class Frontend extends Extension { const payload = this.state.get(device); const lastSeen = settings.get().advanced.last_seen; - if (lastSeen !== 'disable') { + if (lastSeen !== "disable") { payload.last_seen = utils.formatDate(device.zh.lastSeen ?? /* v8 ignore next */ 0, lastSeen); } diff --git a/lib/extension/groups.ts b/lib/extension/groups.ts index 767a96b54..bed32bdc5 100644 --- a/lib/extension/groups.ts +++ b/lib/extension/groups.ts @@ -1,37 +1,37 @@ -import type * as zhc from 'zigbee-herdsman-converters'; +import type * as zhc from "zigbee-herdsman-converters"; -import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from '../types/api'; +import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from "../types/api"; -import assert from 'node:assert'; +import assert from "node:assert"; -import bind from 'bind-decorator'; -import equals from 'fast-deep-equal/es6'; -import stringify from 'json-stable-stringify-without-jsonify'; +import bind from "bind-decorator"; +import equals from "fast-deep-equal/es6"; +import stringify from "json-stable-stringify-without-jsonify"; -import Device from '../model/device'; -import Group from '../model/group'; -import logger from '../util/logger'; -import * as settings from '../util/settings'; -import utils, {isLightExpose} from '../util/utils'; -import Extension from './extension'; +import Device from "../model/device"; +import Group from "../model/group"; +import logger from "../util/logger"; +import * as settings from "../util/settings"; +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)$`); const STATE_PROPERTIES: Readonly boolean>> = { state: () => true, - 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')), + 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) => isLightExpose(e) && - (e.features.some((f) => f.name === `color_${value}`) || (value === 'color_temp' && e.features.some((f) => f.name === 'color_temp'))), + (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'; + type: "remove" | "add" | "remove_all"; resolvedGroup?: Group; resolvedDevice?: Device; resolvedEndpoint?: zh.Endpoint; @@ -44,15 +44,16 @@ interface ParsedMQTTMessage { export default class Groups extends Extension { private lastOptimisticState: {[s: string]: KeyValue} = {}; + // biome-ignore lint/suspicious/useAwait: API override async start(): Promise { this.eventBus.onStateChange(this, this.onStateChange); this.eventBus.onMQTTMessage(this, this.onMQTTMessage); } @bind async onStateChange(data: eventdata.StateChange): Promise { - const reason = 'groupOptimistic'; + const reason = "groupOptimistic"; - if (data.reason === reason || data.reason === 'publishCached') { + if (data.reason === reason || data.reason === "publishCached") { return; } @@ -153,15 +154,16 @@ export default class Groups extends Extension { private shouldPublishPayloadForGroup(group: Group, payload: KeyValue): boolean { return ( - group.options.off_state === 'last_member_state' || + group.options.off_state === "last_member_state" || !payload || - (payload.state !== 'OFF' && payload.state !== 'CLOSE') || + (payload.state !== "OFF" && payload.state !== "CLOSE") || this.areAllMembersOffOrClosed(group) ); } private areAllMembersOffOrClosed(group: Group): boolean { for (const member of group.zh.members) { + // biome-ignore lint/style/noNonNullAssertion: TODO: biome migration: valid from loop? const device = this.zigbee.resolveEntity(member.getDevice())!; if (this.state.exists(device)) { @@ -171,11 +173,11 @@ export default class Groups extends Extension { endpointNames && endpointNames.length >= member.ID && device.definition?.meta?.multiEndpoint && - (!device.definition.meta.multiEndpointSkip || !device.definition.meta.multiEndpointSkip.includes('state')) + (!device.definition.meta.multiEndpointSkip || !device.definition.meta.multiEndpointSkip.includes("state")) ? `state_${endpointNames[member.ID - 1]}` - : 'state'; + : "state"; - if (state[stateKey] === 'ON' || state[stateKey] === 'OPEN') { + if (state[stateKey] === "ON" || state[stateKey] === "OPEN") { return false; } } @@ -190,24 +192,24 @@ export default class Groups extends Extension { const topicRegexMatch = data.topic.match(TOPIC_REGEX); if (topicRegexMatch) { - const type = topicRegexMatch[1] as 'remove' | 'add' | 'remove_all'; + const type = topicRegexMatch[1] as "remove" | "add" | "remove_all"; let resolvedGroup: Group | undefined; let groupKey: string | undefined; let skipDisableReporting = false; - const message = JSON.parse(data.message) as Zigbee2MQTTAPI['bridge/request/group/members/add']; + const message = JSON.parse(data.message) as Zigbee2MQTTAPI["bridge/request/group/members/add"]; - if (typeof message !== 'object' || message.device == null) { - return [message, {type, skipDisableReporting}, 'Invalid payload']; + if (typeof message !== "object" || message.device == null) { + return [message, {type, skipDisableReporting}, "Invalid payload"]; } const deviceKey = message.device; skipDisableReporting = message.skip_disable_reporting != null ? message.skip_disable_reporting : false; - if (type !== 'remove_all') { + if (type !== "remove_all") { groupKey = message.group; if (message.group == null) { - return [message, {type, skipDisableReporting}, 'Invalid payload']; + return [message, {type, skipDisableReporting}, "Invalid payload"]; } const group = this.zigbee.resolveEntity(message.group); @@ -225,7 +227,7 @@ export default class Groups extends Extension { return [message, {type, skipDisableReporting}, `Device '${message.device}' does not exist`]; } - const endpointKey = message.endpoint ?? 'default'; + const endpointKey = message.endpoint ?? "default"; const resolvedEndpoint = resolvedDevice.endpoint(message.endpoint); if (!resolvedEndpoint) { @@ -266,30 +268,26 @@ export default class Groups extends Extension { const {resolvedGroup, resolvedDevice, resolvedEndpoint, type, groupKey, deviceKey, endpointKey, skipDisableReporting} = parsed; const changedGroups: Group[] = []; - assert(resolvedDevice, '`resolvedDevice` is missing'); - assert(resolvedEndpoint, '`resolvedEndpoint` is missing'); + assert(resolvedDevice, "`resolvedDevice` is missing"); + assert(resolvedEndpoint, "`resolvedEndpoint` is missing"); try { - if (type === 'add') { - assert(resolvedGroup, '`resolvedGroup` is missing'); + if (type === "add") { + assert(resolvedGroup, "`resolvedGroup` is missing"); logger.info(`Adding '${resolvedDevice.name}' to '${resolvedGroup.name}'`); await resolvedEndpoint.addToGroup(resolvedGroup.zh); changedGroups.push(resolvedGroup); - await this.publishResponse<'bridge/response/group/members/add'>(parsed.type, raw, { - device: deviceKey!, // valid from resolved asserts - endpoint: endpointKey!, // valid from resolved asserts - group: groupKey!, // valid from resolved asserts - }); - } else if (type === 'remove') { - assert(resolvedGroup, '`resolvedGroup` is missing'); + // biome-ignore lint/style/noNonNullAssertion: valid from resolved asserts + const respPayload = {device: deviceKey!, endpoint: endpointKey!, group: groupKey!}; + await this.publishResponse<"bridge/response/group/members/add">(parsed.type, raw, respPayload); + } else if (type === "remove") { + assert(resolvedGroup, "`resolvedGroup` is missing"); logger.info(`Removing '${resolvedDevice.name}' from '${resolvedGroup.name}'`); await resolvedEndpoint.removeFromGroup(resolvedGroup.zh); changedGroups.push(resolvedGroup); - await this.publishResponse<'bridge/response/group/members/remove'>(parsed.type, raw, { - device: deviceKey!, // valid from resolved asserts - endpoint: endpointKey!, // valid from resolved asserts - group: groupKey!, // valid from resolved asserts - }); + // biome-ignore lint/style/noNonNullAssertion: valid from resolved asserts + const respPayload = {device: deviceKey!, endpoint: endpointKey!, group: groupKey!}; + await this.publishResponse<"bridge/response/group/members/remove">(parsed.type, raw, respPayload); } else { // remove_all logger.info(`Removing '${resolvedDevice.name}' from all groups`); @@ -299,14 +297,14 @@ export default class Groups extends Extension { } await resolvedEndpoint.removeFromAllGroups(); - await this.publishResponse<'bridge/response/group/members/remove_all'>(parsed.type, raw, { - device: deviceKey!, // valid from resolved asserts - endpoint: endpointKey!, // valid from resolved asserts - }); + // biome-ignore lint/style/noNonNullAssertion: valid from resolved asserts + const respPayload = {device: deviceKey!, endpoint: endpointKey!}; + await this.publishResponse<"bridge/response/group/members/remove_all">(parsed.type, raw, respPayload); } } catch (e) { const errorMsg = `Failed to ${type} from group (${(e as Error).message})`; await this.publishResponse(parsed.type, raw, {}, errorMsg); + // biome-ignore lint/style/noNonNullAssertion: always Error logger.debug((e as Error).stack!); return; } @@ -317,7 +315,7 @@ export default class Groups extends Extension { } private async publishResponse( - type: ParsedMQTTMessage['type'], + type: ParsedMQTTMessage["type"], request: KeyValue, data: Zigbee2MQTTAPI[T], error?: string, diff --git a/lib/extension/homeassistant.ts b/lib/extension/homeassistant.ts index d060093fa..0b42c2c2b 100644 --- a/lib/extension/homeassistant.ts +++ b/lib/extension/homeassistant.ts @@ -1,14 +1,14 @@ -import type * as zhc from 'zigbee-herdsman-converters'; +import type * as zhc from "zigbee-herdsman-converters"; -import assert from 'node:assert'; +import assert from "node:assert"; -import bind from 'bind-decorator'; -import stringify from 'json-stable-stringify-without-jsonify'; +import bind from "bind-decorator"; +import stringify from "json-stable-stringify-without-jsonify"; -import logger from '../util/logger'; -import * as settings from '../util/settings'; -import utils, {assertBinaryExpose, assertEnumExpose, assertNumericExpose, isBinaryExpose, isEnumExpose, isNumericExpose} from '../util/utils'; -import Extension from './extension'; +import logger from "../util/logger"; +import * as settings from "../util/settings"; +import utils, {assertBinaryExpose, assertEnumExpose, assertNumericExpose, isBinaryExpose, isEnumExpose, isNumericExpose} from "../util/utils"; +import Extension from "./extension"; interface MockProperty { property: string; @@ -37,257 +37,257 @@ interface ActionData { } const ACTION_PATTERNS: string[] = [ - '^(?