├── .npmrc ├── .release-please-manifest.json ├── images ├── logo.png ├── logo_large.png ├── architecture.png ├── logo_bee_only.png ├── architecture-new.png └── gladys-assistant-logo.jpg ├── .github ├── FUNDING.yml ├── workflows │ ├── dependency-review.yaml │ ├── merge-master-to-dev.yml │ ├── fail-pr-to-master.yaml │ ├── stale.yml │ ├── issue_bot.yml │ ├── ghcr-cleanup.yml │ ├── update-dependency.yml │ └── release-please.yml ├── dependabot.yml ├── prompts │ ├── review-and-refactor.prompt.md │ ├── create-specification.prompt.md │ ├── update-specification.prompt.md │ └── create-agentsmd.prompt.md └── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yaml │ ├── external_converter.yaml │ ├── wrong_device.yaml │ ├── new_device_support.yaml │ └── problem_report.yaml ├── tsconfig.types.json ├── test ├── mocks │ ├── debounce.ts │ ├── sleep.ts │ ├── jszip.ts │ ├── types.d.ts │ ├── utils.ts │ ├── mqtt.ts │ ├── logger.ts │ └── data.ts ├── tsconfig.json ├── assets │ ├── external_converters │ │ ├── mjs │ │ │ ├── mock-external-converter.mjs │ │ │ └── mock-external-converter-multiple.mjs │ │ └── cjs │ │ │ ├── mock-external-converter.js │ │ │ └── mock-external-converter-multiple.js │ ├── external_extensions │ │ ├── mjs │ │ │ ├── example2Extension.mjs │ │ │ └── exampleExtension.mjs │ │ └── cjs │ │ │ ├── exampleExtension.js │ │ │ └── example2Extension.js │ └── certs │ │ ├── dummy.crt │ │ └── dummy.key ├── benchOptions.ts ├── vitest.config.mts ├── data.test.ts ├── utils.test.ts ├── sd-notify.test.ts └── extensions │ └── onEvent.test.ts ├── lib ├── types │ ├── json-stable-stringify-without-jsonify.d.ts │ ├── zigbee2mqtt-frontend.d.ts │ ├── unix-dgram.d.ts │ ├── dom.shim.d.ts │ └── types.d.ts ├── util │ ├── data.ts │ ├── yaml.ts │ └── sd-notify.ts ├── model │ ├── group.ts │ └── device.ts ├── extension │ ├── extension.ts │ ├── externalConverters.ts │ ├── externalExtensions.ts │ ├── health.ts │ ├── onEvent.ts │ ├── configure.ts │ └── receive.ts └── state.ts ├── cli.js ├── docker ├── docker-entrypoint.sh └── Dockerfile ├── .dockerignore ├── .npmignore ├── release-please-config.json ├── tsconfig.json ├── scripts ├── testExternalConverter.js ├── zStackEraseAllNvMem.js ├── zigbee2socat_installer.sh ├── generateChangelog.mjs └── issueBot.mjs ├── CONTRIBUTING.md ├── .gitignore ├── data └── configuration.example.yaml ├── update.sh ├── biome.json ├── package.json ├── CODE_OF_CONDUCT.md ├── index.js └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" 2 | 3 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "2.7.1" 3 | } 4 | -------------------------------------------------------------------------------- /images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Koenkk/zigbee2mqtt/HEAD/images/logo.png -------------------------------------------------------------------------------- /images/logo_large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Koenkk/zigbee2mqtt/HEAD/images/logo_large.png -------------------------------------------------------------------------------- /images/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Koenkk/zigbee2mqtt/HEAD/images/architecture.png -------------------------------------------------------------------------------- /images/logo_bee_only.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Koenkk/zigbee2mqtt/HEAD/images/logo_bee_only.png -------------------------------------------------------------------------------- /images/architecture-new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Koenkk/zigbee2mqtt/HEAD/images/architecture-new.png -------------------------------------------------------------------------------- /images/gladys-assistant-logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Koenkk/zigbee2mqtt/HEAD/images/gladys-assistant-logo.jpg -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [koenkk] 2 | custom: 3 | - https://www.paypal.me/koenkk 4 | - https://www.buymeacoffee.com/koenkk 5 | -------------------------------------------------------------------------------- /tsconfig.types.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "compilerOptions": { 4 | "emitDeclarationOnly": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/mocks/debounce.ts: -------------------------------------------------------------------------------- 1 | export const mockDebounce = vi.fn((fn) => fn); 2 | 3 | vi.mock("debounce", () => ({ 4 | default: mockDebounce, 5 | })); 6 | -------------------------------------------------------------------------------- /lib/types/json-stable-stringify-without-jsonify.d.ts: -------------------------------------------------------------------------------- 1 | declare module "json-stable-stringify-without-jsonify" { 2 | export default function (obj: unknown): string; 3 | } 4 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const path = require("node:path"); 3 | process.env.ZIGBEE2MQTT_DATA = process.env.ZIGBEE2MQTT_DATA || path.join(process.env.HOME, ".z2m"); 4 | require("./index"); 5 | -------------------------------------------------------------------------------- /docker/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | if [ ! -z "$ZIGBEE2MQTT_DATA" ]; then 5 | DATA="$ZIGBEE2MQTT_DATA" 6 | else 7 | DATA="/app/data" 8 | fi 9 | 10 | echo "Using '$DATA' as data directory" 11 | 12 | exec "$@" 13 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig", 3 | "include": ["./**/*", "vitest.config.mts"], 4 | "compilerOptions": { 5 | "rootDir": "..", 6 | "noEmit": true 7 | }, 8 | "references": [{"path": ".."}] 9 | } 10 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .eslintignore 2 | .eslintrc.js 3 | .git 4 | .github 5 | .gitignore 6 | .idea 7 | .npmrc 8 | .travis* 9 | .travis.yml 10 | README.md 11 | docker/Dockerfile* 12 | images 13 | node_modules 14 | scripts 15 | test 16 | coverage 17 | update.sh 18 | -------------------------------------------------------------------------------- /test/mocks/sleep.ts: -------------------------------------------------------------------------------- 1 | import {vi} from "vitest"; 2 | import utils from "../../lib/util/utils"; 3 | 4 | const spy = vi.spyOn(utils, "sleep"); 5 | 6 | export function mock(): void { 7 | spy.mockImplementation(vi.fn()); 8 | } 9 | 10 | export function restore(): void { 11 | spy.mockRestore(); 12 | } 13 | -------------------------------------------------------------------------------- /lib/types/zigbee2mqtt-frontend.d.ts: -------------------------------------------------------------------------------- 1 | declare module "zigbee2mqtt-frontend" { 2 | const frontend: { 3 | getPath: () => string; 4 | }; 5 | 6 | export default frontend; 7 | } 8 | 9 | declare module "http" { 10 | interface IncomingMessage { 11 | originalUrl?: string; 12 | path?: string; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /test/mocks/jszip.ts: -------------------------------------------------------------------------------- 1 | export const mockJSZipFile = vi.fn(); 2 | export const mockJSZipGenerateAsync = vi.fn().mockReturnValue("THISISBASE64"); 3 | 4 | vi.mock("jszip", () => ({ 5 | default: vi.fn().mockImplementation(() => { 6 | return { 7 | file: mockJSZipFile, 8 | generateAsync: mockJSZipGenerateAsync, 9 | }; 10 | }), 11 | })); 12 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | #tests 2 | test 3 | coverage 4 | 5 | #build tools 6 | .travis.yml 7 | .jenkins.yml 8 | .codeclimate.yml 9 | .github 10 | babel.config.js 11 | tsconfig.tsbuildinfo 12 | 13 | #linters 14 | .jscsrc 15 | .jshintrc 16 | .eslintrc* 17 | .dockerignore 18 | .eslintignore 19 | .gitignore 20 | 21 | #editor settings 22 | .vscode 23 | 24 | #src 25 | lib 26 | docs 27 | docker 28 | images 29 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yaml: -------------------------------------------------------------------------------- 1 | name: Dependency review 2 | 3 | on: [pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | dependency-review: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: 'Checkout repository' 13 | uses: actions/checkout@v6 14 | - name: 'Dependency review' 15 | uses: actions/dependency-review-action@v4 16 | -------------------------------------------------------------------------------- /test/assets/external_converters/mjs/mock-external-converter.mjs: -------------------------------------------------------------------------------- 1 | import {posix} from "node:path"; 2 | 3 | import {identify} from "zigbee-herdsman-converters/lib/modernExtend"; 4 | 5 | export default { 6 | mock: true, 7 | zigbeeModel: ["external_converter_device"], 8 | vendor: "external", 9 | model: "external_converter_device", 10 | description: posix.join("external", "converter"), 11 | extend: [identify()], 12 | }; 13 | -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": { 3 | ".": { 4 | "release-type": "node", 5 | "include-component-in-tag": false, 6 | "include-v-in-tag": false, 7 | "draft": true 8 | } 9 | }, 10 | "pull-request-title-pattern": "chore${scope}: release ${version}", 11 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json" 12 | } 13 | -------------------------------------------------------------------------------- /test/assets/external_converters/cjs/mock-external-converter.js: -------------------------------------------------------------------------------- 1 | const {posix} = require("node:path"); 2 | 3 | const mockDevice = { 4 | mock: true, 5 | zigbeeModel: ["external_converter_device"], 6 | vendor: "external", 7 | model: "external_converter_device", 8 | description: posix.join("external", "converter"), 9 | fromZigbee: [], 10 | toZigbee: [], 11 | exposes: [], 12 | }; 13 | 14 | module.exports = mockDevice; 15 | -------------------------------------------------------------------------------- /test/assets/external_extensions/mjs/example2Extension.mjs: -------------------------------------------------------------------------------- 1 | export default class Example2 { 2 | constructor(_zigbee, mqtt, _state, _publishEntityState, _eventBus) { 3 | this.mqtt = mqtt; 4 | this.mqtt.publish("example2/extension", "call2 from constructor"); 5 | } 6 | 7 | start() { 8 | this.mqtt.publish("example2/extension", "call2 from start"); 9 | } 10 | 11 | stop() { 12 | this.mqtt.publish("example/extension", "call2 from stop"); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /test/assets/external_extensions/cjs/exampleExtension.js: -------------------------------------------------------------------------------- 1 | class Example { 2 | constructor(_zigbee, mqtt, _state, _publishEntityState, _eventBus) { 3 | this.mqtt = mqtt; 4 | this.mqtt.publish("example/extension", "call from constructor"); 5 | } 6 | 7 | start() { 8 | this.mqtt.publish("example/extension", "call from start"); 9 | } 10 | 11 | stop() { 12 | this.mqtt.publish("example/extension", "call from stop"); 13 | } 14 | } 15 | 16 | module.exports = Example; 17 | -------------------------------------------------------------------------------- /test/assets/external_extensions/cjs/example2Extension.js: -------------------------------------------------------------------------------- 1 | class Example2 { 2 | constructor(_zigbee, mqtt, _state, _publishEntityState, _eventBus) { 3 | this.mqtt = mqtt; 4 | this.mqtt.publish("example2/extension", "call2 from constructor"); 5 | } 6 | 7 | start() { 8 | this.mqtt.publish("example2/extension", "call2 from start"); 9 | } 10 | 11 | stop() { 12 | this.mqtt.publish("example/extension", "call2 from stop"); 13 | } 14 | } 15 | 16 | module.exports = Example2; 17 | -------------------------------------------------------------------------------- /test/benchOptions.ts: -------------------------------------------------------------------------------- 1 | import {hrtime} from "node:process"; 2 | import type {bench} from "vitest"; 3 | 4 | export const BENCH_OPTIONS: NonNullable[2]> = { 5 | throws: true, 6 | warmupTime: 1000, 7 | now: () => Number(hrtime.bigint()) / 1e6, 8 | setup: (_task, mode) => { 9 | // Run the garbage collector before warmup at each cycle 10 | if (mode === "warmup" && typeof globalThis.gc === "function") { 11 | globalThis.gc(); 12 | } 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /test/mocks/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module "json-stable-stringify-without-jsonify" { 2 | export default function (obj: unknown): string; 3 | } 4 | 5 | declare module "tmp" { 6 | export function dirSync(): { 7 | name: string; 8 | removeCallback: (err: Error | undefined, name: string, fd: number, cleanupFn: () => void) => void; 9 | }; 10 | export function fileSync(): { 11 | name: string; 12 | fd: number; 13 | removeCallback: (err: Error | undefined, name: string, fd: number, cleanupFn: () => void) => void; 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /lib/types/unix-dgram.d.ts: -------------------------------------------------------------------------------- 1 | declare module "unix-dgram" { 2 | import {EventEmitter} from "node:events"; 3 | 4 | export class UnixDgramSocket extends EventEmitter { 5 | send(buf: Buffer, callback?: (err?: Error) => void): void; 6 | send(buf: Buffer, offset: number, length: number, path: string, callback?: (err?: Error) => void): void; 7 | bind(path: string): void; 8 | connect(remotePath: string): void; 9 | close(): void; 10 | } 11 | 12 | export function createSocket(type: "unix_dgram", listener?: (msg: Buffer) => void): UnixDgramSocket; 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/merge-master-to-dev.yml: -------------------------------------------------------------------------------- 1 | name: Merge master to dev 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | merge-master-to-dev: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v6 13 | - uses: devmasx/merge-branch@master 14 | with: 15 | type: now 16 | head_to_merge: master 17 | target_branch: dev 18 | message: 'chore: merge master to dev' 19 | github_token: ${{ secrets.GH_TOKEN }} 20 | -------------------------------------------------------------------------------- /lib/util/data.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | 3 | function setPath(): string { 4 | return process.env.ZIGBEE2MQTT_DATA ? process.env.ZIGBEE2MQTT_DATA : path.normalize(path.join(__dirname, "..", "..", "data")); 5 | } 6 | 7 | let dataPath = setPath(); 8 | 9 | function joinPath(file: string): string { 10 | return path.resolve(dataPath, file); 11 | } 12 | 13 | function getPath(): string { 14 | return dataPath; 15 | } 16 | 17 | function _testReload(): void { 18 | dataPath = setPath(); 19 | } 20 | 21 | // biome-ignore lint/style/useNamingConvention: test 22 | export default {joinPath, getPath, _testReload}; 23 | -------------------------------------------------------------------------------- /.github/workflows/fail-pr-to-master.yaml: -------------------------------------------------------------------------------- 1 | name: Fail PR to master 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | fail-pr-to-master: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Fail PR to master 13 | run: | 14 | if [[ "${{ github.event.pull_request.title }}" == "chore(dev): release"* ]]; then 15 | echo "PR title starts with 'chore(dev): release', allowing PR" 16 | else 17 | echo "Pull requests to the master branch are not allowed, target dev branch" 18 | exit 1 19 | fi 20 | -------------------------------------------------------------------------------- /test/assets/external_extensions/mjs/exampleExtension.mjs: -------------------------------------------------------------------------------- 1 | export default class Example { 2 | constructor(_zigbee, mqtt, _state, _publishEntityState, _eventBus) { 3 | this.mqtt = mqtt; 4 | this.mqtt.publish("example/extension", "call from constructor"); 5 | this.counter = 0; 6 | } 7 | 8 | start() { 9 | this.mqtt.publish("example/extension", "call from start"); 10 | this.mqtt.publish("example/extension/counter", `start ${this.counter++}`); 11 | } 12 | 13 | stop() { 14 | this.mqtt.publish("example/extension", "call from stop"); 15 | this.mqtt.publish("example/extension/counter", `stop ${--this.counter}`); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /test/assets/external_converters/mjs/mock-external-converter-multiple.mjs: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | mock: 1, 4 | model: "external_converters_device_1", 5 | zigbeeModel: ["external_converter_device_1"], 6 | vendor: "external_1", 7 | description: "external_1", 8 | fromZigbee: [], 9 | toZigbee: [], 10 | exposes: [], 11 | }, 12 | { 13 | mock: 2, 14 | model: "external_converters_device_2", 15 | zigbeeModel: ["external_converter_device_2"], 16 | vendor: "external_2", 17 | description: "external_2", 18 | fromZigbee: [], 19 | toZigbee: [], 20 | exposes: [], 21 | }, 22 | ]; 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "module": "NodeNext", 5 | "esModuleInterop": true, 6 | "target": "esnext", 7 | "lib": ["esnext"], 8 | "strict": true, 9 | "noImplicitAny": true, 10 | "noImplicitThis": true, 11 | "declaration": true, 12 | "declarationMap": true, 13 | "outDir": "dist", 14 | "baseUrl": ".", 15 | "rootDir": "lib", 16 | "inlineSourceMap": true, 17 | "resolveJsonModule": true, 18 | "experimentalDecorators": true, 19 | "composite": true 20 | }, 21 | "include": ["./lib/**/*", "./lib/util/settings.schema.json"] 22 | } 23 | -------------------------------------------------------------------------------- /test/assets/external_converters/cjs/mock-external-converter-multiple.js: -------------------------------------------------------------------------------- 1 | const mockDevices = [ 2 | { 3 | mock: 1, 4 | model: "external_converters_device_1", 5 | zigbeeModel: ["external_converter_device_1"], 6 | vendor: "external_1", 7 | description: "external_1", 8 | fromZigbee: [], 9 | toZigbee: [], 10 | exposes: [], 11 | }, 12 | { 13 | mock: 2, 14 | model: "external_converters_device_2", 15 | zigbeeModel: ["external_converter_device_2"], 16 | vendor: "external_2", 17 | description: "external_2", 18 | fromZigbee: [], 19 | toZigbee: [], 20 | exposes: [], 21 | }, 22 | ]; 23 | 24 | module.exports = mockDevices; 25 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | versioning-strategy: increase 5 | directory: / 6 | schedule: 7 | interval: weekly 8 | target-branch: dev 9 | commit-message: 10 | prefix: fix(ignore) 11 | groups: 12 | minor-patch: 13 | applies-to: version-updates 14 | update-types: 15 | - minor 16 | - patch 17 | - package-ecosystem: docker 18 | directory: /docker 19 | schedule: 20 | interval: weekly 21 | target-branch: dev 22 | - package-ecosystem: github-actions 23 | directory: / 24 | schedule: 25 | interval: weekly 26 | target-branch: dev 27 | -------------------------------------------------------------------------------- /test/vitest.config.mts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | plugins: [], 5 | test: { 6 | onConsoleLog(_log: string, _type: "stdout" | "stderr"): boolean | undefined { 7 | return false; 8 | }, 9 | coverage: { 10 | enabled: false, 11 | provider: "v8", 12 | include: ["lib/**"], 13 | extension: [".ts"], 14 | // exclude: [], 15 | clean: true, 16 | cleanOnRerun: true, 17 | reportsDirectory: "coverage", 18 | reporter: ["text", "html"], 19 | reportOnFailure: false, 20 | thresholds: { 21 | 100: true, 22 | }, 23 | }, 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /test/mocks/utils.ts: -------------------------------------------------------------------------------- 1 | import {vi} from "vitest"; 2 | import type {DefinitionWithExtend} from "zigbee-herdsman-converters"; 3 | 4 | export type EventHandler = (...args: unknown[]) => unknown; 5 | 6 | export async function flushPromises(): Promise { 7 | const nodeTimers = await vi.importActual("node:timers"); 8 | 9 | return await new Promise(nodeTimers.setImmediate); 10 | } 11 | 12 | // https://github.com/jestjs/jest/issues/6028#issuecomment-567669082 13 | export function defuseRejection(promise: Promise): Promise { 14 | promise.catch(() => {}); 15 | 16 | return promise; 17 | } 18 | 19 | export async function getZhcBaseDefinitions(): Promise { 20 | return (await import("zigbee-herdsman-converters/devices/index")).default; 21 | } 22 | -------------------------------------------------------------------------------- /scripts/testExternalConverter.js: -------------------------------------------------------------------------------- 1 | const assert = require("node:assert"); 2 | const vm = require("node:vm"); 3 | const fs = require("node:fs"); 4 | const path = require("node:path"); 5 | const filename = process.argv[2]; 6 | const moduleCode = fs.readFileSync(filename); 7 | const moduleFakePath = path.join(__dirname, "externally-loaded.js"); 8 | const sandbox = { 9 | require: require, 10 | module: {}, 11 | console, 12 | setTimeout, 13 | clearTimeout, 14 | setInterval, 15 | clearInterval, 16 | setImmediate, 17 | clearImmediate, 18 | }; 19 | vm.runInNewContext(moduleCode, sandbox, moduleFakePath); 20 | const converter = sandbox.module.exports; 21 | assert(!converter.toZigbee || !converter.toZigbee.includes(undefined)); 22 | assert(!converter.fromZigbee || !converter.fromZigbee.includes(undefined)); 23 | -------------------------------------------------------------------------------- /.github/prompts/review-and-refactor.prompt.md: -------------------------------------------------------------------------------- 1 | --- 2 | mode: 'agent' 3 | description: 'Review and refactor code in your project according to defined instructions' 4 | --- 5 | 6 | ## Role 7 | 8 | You're a senior expert software engineer with extensive experience in maintaining projects over a long time and ensuring clean code and best practices. 9 | 10 | ## Task 11 | 12 | 1. Take a deep breath, and review all coding guidelines instructions in `.github/instructions/*.md` and `.github/copilot-instructions.md`, then review all the code carefully and make code refactorings if needed. 13 | 2. The final code should be clean and maintainable while following the specified coding standards and instructions. 14 | 3. Do not split up the code, keep the existing files intact. 15 | 4. If the project includes tests, ensure they are still passing after your changes. 16 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Stale 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | workflow_dispatch: 7 | 8 | jobs: 9 | stale: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/stale@v10 13 | with: 14 | repo-token: ${{ secrets.GITHUB_TOKEN }} 15 | stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days' 16 | stale-pr-message: 'This pull request is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days' 17 | days-before-stale: 60 18 | days-before-close: 7 19 | exempt-issue-labels: dont-stale,feature-request 20 | operations-per-run: 500 21 | -------------------------------------------------------------------------------- /lib/types/dom.shim.d.ts: -------------------------------------------------------------------------------- 1 | // minimal required because of sub-deps in mqtt >= 5.14.0 to avoid requiring `dom` type 2 | declare global { 3 | // map to node, doesn't really matter, just needs to be there, and the right "type vs value" to avoid lib check problems 4 | /** @deprecated DOM SHIM, DO NOT USE */ 5 | type MessagePort = import("node:worker_threads").MessagePort; 6 | /** @deprecated DOM SHIM, DO NOT USE */ 7 | type Worker = import("node:worker_threads").Worker; 8 | /** @deprecated DOM SHIM, DO NOT USE */ 9 | type Transferable = import("node:worker_threads").Transferable; 10 | /** @deprecated DOM SHIM, DO NOT USE */ 11 | const addEventListener: import("node:events").EventEmitter["addListener"]; 12 | /** @deprecated DOM SHIM, DO NOT USE */ 13 | const removeEventListener: import("node:events").EventEmitter["removeListener"]; 14 | /** @deprecated DOM SHIM, DO NOT USE */ 15 | const postMessage: import("node:worker_threads").MessagePort["postMessage"]; 16 | } 17 | 18 | export {}; 19 | -------------------------------------------------------------------------------- /test/data.test.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import tmp from "tmp"; 3 | import {describe, expect, it} from "vitest"; 4 | import data from "../lib/util/data"; 5 | 6 | describe("Data", () => { 7 | describe("Get path", () => { 8 | it("Should return correct path", () => { 9 | const expected = path.normalize(path.join(__dirname, "..", "data")); 10 | const actual = data.getPath(); 11 | expect(actual).toBe(expected); 12 | }); 13 | 14 | it("Should return correct path when ZIGBEE2MQTT_DATA set", () => { 15 | const expected = tmp.dirSync().name; 16 | process.env.ZIGBEE2MQTT_DATA = expected; 17 | data._testReload(); 18 | const actual = data.getPath(); 19 | expect(actual).toBe(expected); 20 | expect(data.joinPath("test")).toStrictEqual(path.join(expected, "test")); 21 | expect(data.joinPath("/test")).toStrictEqual(path.resolve(expected, "/test")); 22 | delete process.env.ZIGBEE2MQTT_DATA; 23 | data._testReload(); 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /.github/workflows/issue_bot.yml: -------------------------------------------------------------------------------- 1 | name: Issue Bot 2 | 3 | on: 4 | issues: 5 | types: [opened, edited] 6 | 7 | permissions: 8 | issues: write 9 | contents: read 10 | 11 | jobs: 12 | comment: 13 | runs-on: ubuntu-latest 14 | if: | 15 | contains(github.event.issue.labels.*.name, 'new device support') || 16 | contains(github.event.issue.labels.*.name, 'external converter') || 17 | startsWith(github.event.issue.title, '[New device support]') || 18 | startsWith(github.event.issue.title, '[External Converter]') 19 | steps: 20 | - uses: actions/checkout@v6 21 | with: 22 | sparse-checkout: | 23 | scripts 24 | path: z2m 25 | 26 | - uses: actions/checkout@v6 27 | with: 28 | repository: Koenkk/zigbee-herdsman-converters 29 | ref: master 30 | fetch-depth: 1 31 | path: zhc 32 | 33 | - name: Comment on new device support/external converter issue 34 | uses: actions/github-script@v8 35 | with: 36 | script: | 37 | const {newDeviceSupport} = await import("${{ github.workspace }}/z2m/scripts/issueBot.mjs") 38 | await newDeviceSupport(github, core, context, "${{ github.workspace }}/zhc") 39 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Zigbee2MQTT 2 | 3 | Everybody is invited and welcome to contribute to Zigbee2MQTT. Zigbee2MQTT is written in JavaScript and is based upon [zigbee-herdsman](https://github.com/koenkk/zigbee-herdsman) and [zigbee-herdsman-converters](https://github.com/koenkk/zigbee-herdsman-converters). Zigbee-herdsman-converters contains all device definitions, zigbee-herdsman is responsible for handling all communication with the adapter. 4 | 5 | - Pull requests are always created against the [**dev**](https://github.com/Koenkk/zigbee2mqtt/tree/dev) branch. 6 | - Easiest way to start developing Zigbee2MQTT is by setting up a development environment (aka bare-metal installation). You can follow this [guide](https://www.zigbee2mqtt.io/guide/installation/01_linux.html) to do this. 7 | - You can run the tests locally by executing `pnpm test`. Zigbee2MQTT enforces 100% code coverage, in case you add new code check if your code is covered by running `pnpm run test:coverage`. The coverage report can be found under `coverage/lcov-report/index.html`. Linting is also enforced and can be run with `pnpm run eslint`. 8 | - When you want to add support for a new device no changes to Zigbee2MQTT have to be made, only to zigbee-herdsman-converters. You can find a guide for it [here](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html). 9 | -------------------------------------------------------------------------------- /test/mocks/mqtt.ts: -------------------------------------------------------------------------------- 1 | import type {IClientPublishOptions} from "mqtt"; 2 | 3 | import type {EventHandler} from "./utils"; 4 | 5 | export const events: Record = {}; 6 | 7 | export const mockMQTTPublishAsync = vi.fn(async (_topic: string, _message: string, _opts?: IClientPublishOptions): Promise => {}); 8 | export const mockMQTTEndAsync = vi.fn(async (): Promise => {}); 9 | export const mockMQTTSubscribeAsync = vi.fn(async (_topicObject: string): Promise => {}); 10 | export const mockMQTTUnsubscribeAsync = vi.fn(async (_topic: string): Promise => {}); 11 | 12 | export const mockMQTTConnectAsync = vi.fn(() => ({ 13 | reconnecting: false, 14 | disconnecting: false, 15 | disconnected: false, 16 | publishAsync: mockMQTTPublishAsync, 17 | endAsync: mockMQTTEndAsync, 18 | subscribeAsync: mockMQTTSubscribeAsync, 19 | unsubscribeAsync: mockMQTTUnsubscribeAsync, 20 | on: vi.fn(async (type, handler) => { 21 | if (type === "connect") { 22 | await handler(); 23 | } 24 | 25 | events[type] = handler; 26 | }), 27 | stream: {setMaxListeners: vi.fn()}, 28 | options: { 29 | protocolVersion: 5, 30 | protocol: "mqtt", 31 | host: "localhost", 32 | port: 1883, 33 | }, 34 | queue: [], 35 | })); 36 | 37 | vi.mock("mqtt", () => ({ 38 | connectAsync: mockMQTTConnectAsync, 39 | })); 40 | -------------------------------------------------------------------------------- /.github/workflows/ghcr-cleanup.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | 4 | name: GHCR cleanup 5 | 6 | permissions: {} 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Delete untagged images 13 | uses: actions/github-script@v8 14 | with: 15 | github-token: ${{ secrets.GH_TOKEN }} 16 | script: | 17 | const response = await github.request("GET /${{ env.OWNER }}/packages/container/${{ env.PACKAGE_NAME }}/versions", 18 | { per_page: ${{ env.PER_PAGE }} 19 | }); 20 | for(version of response.data) { 21 | if (version.metadata.container.tags.length == 0) { 22 | try { 23 | console.log("delete " + version.id) 24 | const deleteResponse = await github.request("DELETE /${{ env.OWNER }}/packages/container/${{ env.PACKAGE_NAME }}/versions/" + version.id, { }); 25 | console.log("status " + deleteResponse.status) 26 | } catch (e) { 27 | console.log("failed") 28 | } 29 | } 30 | } 31 | env: 32 | OWNER: user 33 | PACKAGE_NAME: zigbee2mqtt 34 | PER_PAGE: 2000 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Compiled source 15 | dist/* 16 | 17 | # Directory for instrumented libs generated by jscoverage/JSCover 18 | lib-cov 19 | 20 | # Coverage directory used by tools like istanbul 21 | coverage 22 | 23 | # nyc test coverage 24 | .nyc_output 25 | 26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 27 | .grunt 28 | 29 | # Bower dependency directory (https://bower.io/) 30 | bower_components 31 | 32 | # node-waf configuration 33 | .lock-wscript 34 | 35 | # Compiled binary addons (http://nodejs.org/api/addons.html) 36 | build/Release 37 | 38 | # Dependency directories 39 | node_modules/ 40 | jspm_packages/ 41 | 42 | # Typescript v1 declaration files 43 | typings/ 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional pnpm cache directory 49 | .pnpm-store 50 | 51 | # Optional eslint cache 52 | .eslintcache 53 | 54 | # Build cache 55 | tsconfig.tsbuildinfo 56 | tsconfig.types.tsbuildinfo 57 | 58 | # Optional REPL history 59 | .node_repl_history 60 | 61 | # Output of 'npm pack' 62 | *.tgz 63 | 64 | # Yarn Integrity file 65 | .yarn-integrity 66 | 67 | # dotenv environment variables file 68 | .env 69 | 70 | # MacOS indexing file 71 | .DS_Store 72 | 73 | # IDE specific folders 74 | .idea 75 | .*.swp 76 | 77 | # Ignore config 78 | data/* 79 | !data/configuration.example.yaml 80 | data-bench 81 | 82 | # commit-user-lookup.json 83 | scripts/commit-user-lookup.json 84 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 'IMPORTANT: Check development branch changelog first!!' 4 | url: https://gist.github.com/Koenkk/bfd4c3d1725a2cccacc11d6ba51008ba 5 | about: Before submitting an issue, check that it has not already been solved in the development branch. Click here to see the release notes of the development branch. 6 | - name: Switch to the development branch 7 | url: https://www.zigbee2mqtt.io/advanced/more/switch-to-dev-branch.html 8 | about: If the development branch solves an issue you are facing, click here to see the instructions to switch to the development branch. 9 | - name: Questions/discussions 10 | url: https://github.com/Koenkk/zigbee2mqtt/discussions/new 11 | about: Ask questions, discuss devices, show things you made... 12 | - name: Original frontend issues 13 | url: https://github.com/nurikk/zigbee2mqtt-frontend/issues 14 | about: Issues with the frontend package zigbee2mqtt-frontend 15 | - name: WindFront frontend issues 16 | url: https://github.com/Nerivec/zigbee2mqtt-windfront/issues 17 | about: Issues with the frontend package zigbee2mqtt-windfront 18 | - name: Home Assistant addon issues 19 | url: https://github.com/zigbee2mqtt/hassio-zigbee2mqtt/issues 20 | about: Issues with the Home Assistant addon 21 | - name: FAQ 22 | url: https://www.zigbee2mqtt.io/guide/faq 23 | about: Frequently asked questions 24 | - name: Support Chat 25 | url: https://discord.gg/NyseBeK 26 | about: Chat for feedback, questions and troubleshooting 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yaml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea for this project 3 | title: '[Feature request]: ' 4 | labels: [feature request] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | **IMPORTANT:** Before submitting: 10 | - Is your feature request related to the frontend? Then click [here](https://github.com/nurikk/zigbee2mqtt-frontend/issues/new?assignees=&labels=&template=feature_request.md&title=) 11 | - type: textarea 12 | id: textarea1 13 | attributes: 14 | label: Is your feature request related to a problem? Please describe 15 | placeholder: A clear and concise description of what the problem is. Eg. I'm always frustrated when [...] 16 | validations: 17 | required: true 18 | - type: textarea 19 | id: textarea2 20 | attributes: 21 | label: Describe the solution you'd like 22 | placeholder: A clear and concise description of what you want to happen. 23 | validations: 24 | required: true 25 | - type: textarea 26 | id: textarea3 27 | attributes: 28 | label: Describe alternatives you've considered 29 | placeholder: A clear and concise description of any alternative solutions or features you've considered. 30 | validations: 31 | required: true 32 | - type: textarea 33 | id: textarea4 34 | attributes: 35 | label: Additional context 36 | placeholder: Add any other context or screenshots about the feature request here. 37 | validations: 38 | required: true 39 | -------------------------------------------------------------------------------- /data/configuration.example.yaml: -------------------------------------------------------------------------------- 1 | # Indicates the configuration version (used by configuration migrations) 2 | version: 4 3 | 4 | # Home Assistant integration (MQTT discovery) 5 | homeassistant: 6 | enabled: false 7 | 8 | # Enable the frontend, runs on port 8080 by default 9 | frontend: 10 | enabled: true 11 | # port: 8080 12 | 13 | # MQTT settings 14 | mqtt: 15 | # MQTT base topic for zigbee2mqtt MQTT messages 16 | base_topic: zigbee2mqtt 17 | # MQTT server URL 18 | server: 'mqtt://localhost' 19 | # MQTT server authentication, uncomment if required: 20 | # user: my_user 21 | # password: my_password 22 | 23 | # Serial settings, only required when Zigbee2MQTT fails to start with: 24 | # USB adapter discovery error (No valid USB adapter found). 25 | # Specify valid 'adapter' and 'port' in your configuration. 26 | # serial: 27 | # # Location of the adapter 28 | # # USB adapters - use format "port: /dev/serial/by-id/XXX" 29 | # # Ethernet adapters - use format "port: tcp://192.168.1.12:6638" 30 | # port: /dev/serial/by-id/usb-Texas_Instruments_TI_CC2531_USB_CDC___0X00124B0018ED3DDF-if00 31 | # # Adapter type, allowed values: `zstack`, `ember`, `deconz`, `zigate` or `zboss` 32 | # adapter: zstack 33 | 34 | # Periodically check whether devices are online/offline 35 | # availability: 36 | # enabled: false 37 | 38 | # Advanced settings 39 | advanced: 40 | # channel: 11 41 | # Let Zigbee2MQTT generate a network key on first start 42 | network_key: GENERATE 43 | # Let Zigbee2MQTT generate a pan_id on first start 44 | pan_id: GENERATE 45 | # Let Zigbee2MQTT generate a ext_pan_id on first start 46 | ext_pan_id: GENERATE 47 | -------------------------------------------------------------------------------- /lib/util/yaml.ts: -------------------------------------------------------------------------------- 1 | import assert from "node:assert"; 2 | import fs from "node:fs"; 3 | 4 | import equals from "fast-deep-equal/es6"; 5 | import yaml, {YAMLException} from "js-yaml"; 6 | 7 | export class YAMLFileException extends YAMLException { 8 | file: string; 9 | 10 | constructor(error: YAMLException, file: string) { 11 | super(error.reason, error.mark); 12 | 13 | this.name = "YAMLFileException"; 14 | this.cause = error.cause; 15 | this.message = error.message; 16 | this.stack = error.stack; 17 | this.file = file; 18 | } 19 | } 20 | 21 | function read(file: string): KeyValue { 22 | try { 23 | const result = yaml.load(fs.readFileSync(file, "utf8")); 24 | assert(result instanceof Object, `The content of ${file} is expected to be an object`); 25 | return result as KeyValue; 26 | } catch (error) { 27 | if (error instanceof YAMLException) { 28 | throw new YAMLFileException(error, file); 29 | } 30 | 31 | throw error; 32 | } 33 | } 34 | 35 | function readIfExists(file: string, fallback: KeyValue = {}): KeyValue { 36 | return fs.existsSync(file) ? read(file) : fallback; 37 | } 38 | 39 | function writeIfChanged(file: string, content: KeyValue): void { 40 | const before = readIfExists(file); 41 | 42 | if (!equals(before, content)) { 43 | fs.writeFileSync(file, yaml.dump(content)); 44 | } 45 | } 46 | 47 | function updateIfChanged(file: string, key: string, value: KeyValue): void { 48 | const content = read(file); 49 | if (content[key] !== value) { 50 | content[key] = value; 51 | writeIfChanged(file, content); 52 | } 53 | } 54 | 55 | export default {read, readIfExists, updateIfChanged, writeIfChanged}; 56 | -------------------------------------------------------------------------------- /.github/workflows/update-dependency.yml: -------------------------------------------------------------------------------- 1 | on: 2 | repository_dispatch: 3 | types: update_dep 4 | 5 | name: Update dependency 6 | 7 | permissions: {} 8 | jobs: 9 | update-dependency: 10 | permissions: 11 | contents: write 12 | pull-requests: write 13 | 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v6 17 | with: 18 | ref: dev 19 | token: ${{ secrets.GH_TOKEN }} 20 | - uses: pnpm/action-setup@v4 21 | - uses: actions/setup-node@v6 22 | with: 23 | node-version: 24 24 | cache: pnpm 25 | - run: | 26 | pnpm install ${{ github.event.client_payload.package }}@${{ github.event.client_payload.version }} --save-exact 27 | pnpm install --no-frozen-lockfile 28 | - uses: peter-evans/create-pull-request@v7 29 | id: cpr 30 | with: 31 | commit-message: 'fix(ignore): update ${{ github.event.client_payload.package }} to ${{ github.event.client_payload.version }}' 32 | branch: 'deps/${{ github.event.client_payload.package }}' 33 | title: 'fix(ignore): update ${{ github.event.client_payload.package }} to ${{ github.event.client_payload.version }}' 34 | token: ${{ secrets.GH_TOKEN }} 35 | - run: sleep 5 # Otherwise pull request may not exist yet causing automerge to fail 36 | - run: gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}" 37 | if: steps.cpr.outputs.pull-request-operation == 'created' 38 | env: 39 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 40 | -------------------------------------------------------------------------------- /update.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | cd "$(dirname "$0")" 3 | 4 | if [ "$1" != "force" ]; then 5 | echo "Checking for updates..." 6 | git fetch -q 7 | NEW_COMMITS="$(git rev-list HEAD...@{upstream} --count)" 8 | if [ "$NEW_COMMITS" -gt 0 ]; then 9 | echo "Update available!" 10 | else 11 | echo "No update available. Use '$0 force' to skip the check." 12 | exit 0 13 | fi 14 | fi 15 | 16 | NEED_RESTART=0 17 | 18 | OSNAME="$(uname -s)" 19 | if [ "$OSNAME" == "FreeBSD" ]; then 20 | echo "Checking Zigbee2MQTT status..." 21 | if service zigbee2mqtt status >/dev/null; then 22 | echo "Stopping Zigbee2MQTT..." 23 | service zigbee2mqtt stop 24 | NEED_RESTART=1 25 | fi 26 | else 27 | if which systemctl 2> /dev/null > /dev/null; then 28 | echo "Checking Zigbee2MQTT status..." 29 | if systemctl is-active --quiet zigbee2mqtt; then 30 | echo "Stopping Zigbee2MQTT..." 31 | sudo systemctl stop zigbee2mqtt 32 | NEED_RESTART=1 33 | fi 34 | else 35 | echo "Skipped stopping Zigbee2MQTT, no systemctl found" 36 | fi 37 | fi 38 | 39 | echo "Resetting local changes to package.json and pnpm-lock.yaml..." 40 | git checkout --quiet -- package.json pnpm-lock.yaml || true 41 | 42 | if ! command -v pnpm >/dev/null 2>&1; then 43 | echo "pnpm not found, preparing with Corepack..." 44 | corepack prepare pnpm@latest --activate 45 | fi 46 | 47 | echo "Updating..." 48 | git pull --no-rebase 49 | 50 | echo "Installing dependencies..." 51 | pnpm i --frozen-lockfile 52 | 53 | echo "Building..." 54 | pnpm run build 55 | 56 | if [ $NEED_RESTART -eq 1 ]; then 57 | echo "Starting Zigbee2MQTT..." 58 | if [ "$OSNAME" == "FreeBSD" ]; then 59 | service zigbee2mqtt start 60 | else 61 | sudo systemctl start zigbee2mqtt 62 | fi 63 | fi 64 | 65 | echo "Done!" 66 | -------------------------------------------------------------------------------- /test/assets/certs/dummy.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFazCCA1OgAwIBAgIUU70Q1tbGP071yeQ8EcHd/0rxtnwwDQYJKoZIhvcNAQEL 3 | BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM 4 | GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMjEyMDMwMDA1MTBaFw0yMzAx 5 | MDIwMDA1MTBaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw 6 | HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggIiMA0GCSqGSIb3DQEB 7 | AQUAA4ICDwAwggIKAoICAQDR6bYQPgkfWlGQq19U2nMsAo0RZR7wSUuFS0MFpbQS 8 | 2Rr5x/4FwwKhTMOlfr6H2Xo/5pQvGOlhFE69j1HxQRooWDAswnscMv/9pb+BUqoE 9 | 7J29SEndIpmCaI0c3ejZsu4Nq8DxGod/RsQhJdbDU+hkw8qARJ0JjnTb+aN1KH8g 10 | J3KeSMTOL3guBVZQujO0Unbjv47yBIFwi0IihR18aoRaxinb94Aqf92HcjZN++WU 11 | gq7/2Qlm3tF5uJwuNtG/DmzReSblRcUgzPri7qIhb9z51gFNdNX6cTH3yNoF/KtT 12 | XwEArvvsAyMCYuC6w8KI7WNmPI6yxSksgHdcNPlUVZYquLD+z2kYAdIByiyHmIpm 13 | OH3jVN7lRJ6xITOGmyNktfRuSUi2GzECAOWIGpDD/5/gK1pacbOXpJWqVrTjUIAN 14 | BrQf496CbabVgwaQEvq1QJJbuMbpma21/IlS6NpZS7vm/q1ojNsbEB6PYkSsBTzP 15 | iZiiaomjhV2w5yIp5Zn8xyMFVKLb+SEDQBtZZ6CErmbgqp4B3U2b4k5LippLmZZK 16 | pSk7BOuMpsZK8FIGS4KlANFfGVs79y6N+Jxue2IO9pRcpcpbqOXty4662BsPKInw 17 | dKxKTr6TcTe0gEq1fpf1npYwcZ9t36LMJ7L51TbaaJ5sEsXe5QNOOOUgSwPqWmrE 18 | lwIDAQABo1MwUTAdBgNVHQ4EFgQUYD/x9cCuGJgLUPkYLIVa8uvseBkwHwYDVR0j 19 | BBgwFoAUYD/x9cCuGJgLUPkYLIVa8uvseBkwDwYDVR0TAQH/BAUwAwEB/zANBgkq 20 | hkiG9w0BAQsFAAOCAgEAI+gp+8qqO4anj8NHgjJvXhrETLPJIeLuBp/cjF9HA2IL 21 | oAd5rXWPnQ6qgh3q/HazILgIHwI0wSrhVtRlprgGIxNa59cD3RcZCtqFSWIqhAqZ 22 | JnAye3bnGU/KahazQX3oP+y564b/QZvwpJDKC1EoMv1wdpXND2NVn4HwaBc7Tu/d 23 | SX/Tr6k+e32K8mwE+00/yf2rLrmWmNjWtP0f7zRmH3j1+ovktk7UoVhjKXfhqMUE 24 | pWdhPZIXQJ65JNMGKdeFI2ULQY9IbXHeWQUgBCyhTciMvm2XZzopk7rxrh9NZopS 25 | pAlCrWBkOZrs6hfZrus9FOzwSkeIEY7NnhNk05kslERJxjkoFytp7mDd4mTKCVW0 26 | dKc7G1hZWYfO3oe0Zjxi0ragyReNKtyL1BqMbDKJmxHl91iPbdueWix1xxhNFakH 27 | l3eOzfa/DEixvzxy3hzCcvagNb5RsXFVMQXQTgnIW/5GcG5VVB2/o7KidPL3XtqR 28 | Hc/0ebQvloGXgqJbyTXG9EInkwB35wXDQuHgwt1PhwzP1yfp2BCvDsQWrR3IxnBT 29 | DO1o2RpsurgCgUpd0vcW17DePLNfprrhhU2uTPn2bNcWdT/rZmEImkNaA29Rku+3 30 | SZQJZWIx4s2FMdVdAv6ZauROStTY/WayudfcFfP+hYvJ5/ccQ9Gkb7vrGUssFRw= 31 | -----END CERTIFICATE----- 32 | -------------------------------------------------------------------------------- /lib/model/group.ts: -------------------------------------------------------------------------------- 1 | import type * as zhc from "zigbee-herdsman-converters"; 2 | import * as settings from "../util/settings"; 3 | import {DEFAULT_BIND_GROUP_ID} from "../util/utils"; 4 | 5 | export default class Group { 6 | public zh: zh.Group; 7 | private resolveDevice: (ieeeAddr: string) => Device | undefined; 8 | 9 | // biome-ignore lint/style/useNamingConvention: API 10 | get ID(): number { 11 | return this.zh.groupID; 12 | } 13 | get options(): GroupOptions { 14 | // biome-ignore lint/style/noNonNullAssertion: Group always exists in settings 15 | return {...settings.getGroup(this.ID)!}; 16 | } 17 | get name(): string { 18 | return this.options?.friendly_name || this.ID.toString(); 19 | } 20 | 21 | constructor(group: zh.Group, resolveDevice: (ieeeAddr: string) => Device | undefined) { 22 | this.zh = group; 23 | this.resolveDevice = resolveDevice; 24 | } 25 | 26 | ensureInSettings(): void { 27 | if (this.ID !== DEFAULT_BIND_GROUP_ID && !settings.getGroup(this.ID)) { 28 | settings.addGroup(this.name, this.ID.toString()); 29 | } 30 | } 31 | 32 | hasMember(device: Device): boolean { 33 | return !!device.zh.endpoints.find((e) => this.zh.members.includes(e)); 34 | } 35 | 36 | *membersDevices(): Generator { 37 | for (const member of this.zh.members) { 38 | const resolvedDevice = this.resolveDevice(member.deviceIeeeAddress); 39 | 40 | if (resolvedDevice) { 41 | yield resolvedDevice; 42 | } 43 | } 44 | } 45 | 46 | membersDefinitions(): zhc.Definition[] { 47 | const definitions: zhc.Definition[] = []; 48 | 49 | for (const member of this.membersDevices()) { 50 | if (member.definition) { 51 | definitions.push(member.definition); 52 | } 53 | } 54 | 55 | return definitions; 56 | } 57 | 58 | isDevice(): this is Device { 59 | return false; 60 | } 61 | isGroup(): this is Group { 62 | return true; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /lib/extension/extension.ts: -------------------------------------------------------------------------------- 1 | abstract class Extension { 2 | protected zigbee: Zigbee; 3 | protected mqtt: Mqtt; 4 | protected state: State; 5 | protected publishEntityState: PublishEntityState; 6 | protected eventBus: EventBus; 7 | protected enableDisableExtension: (enable: boolean, name: string) => Promise; 8 | protected restartCallback: () => Promise; 9 | protected addExtension: (extension: Extension) => Promise; 10 | 11 | /** 12 | * Besides initializing variables, the constructor should do nothing! 13 | * 14 | * @param {Zigbee} zigbee Zigbee controller 15 | * @param {Mqtt} mqtt MQTT controller 16 | * @param {State} state State controller 17 | * @param {Function} publishEntityState Method to publish device state to MQTT. 18 | * @param {EventBus} eventBus The event bus 19 | * @param {enableDisableExtension} enableDisableExtension Enable/disable extension method 20 | * @param {restartCallback} restartCallback Restart Zigbee2MQTT 21 | * @param {addExtension} addExtension Add an extension 22 | */ 23 | constructor( 24 | zigbee: Zigbee, 25 | mqtt: Mqtt, 26 | state: State, 27 | publishEntityState: PublishEntityState, 28 | eventBus: EventBus, 29 | enableDisableExtension: (enable: boolean, name: string) => Promise, 30 | restartCallback: () => Promise, 31 | addExtension: (extension: Extension) => Promise, 32 | ) { 33 | this.zigbee = zigbee; 34 | this.mqtt = mqtt; 35 | this.state = state; 36 | this.publishEntityState = publishEntityState; 37 | this.eventBus = eventBus; 38 | this.enableDisableExtension = enableDisableExtension; 39 | this.restartCallback = restartCallback; 40 | this.addExtension = addExtension; 41 | } 42 | 43 | /** 44 | * Is called once the extension has to start 45 | */ 46 | async start(): Promise {} 47 | 48 | /** 49 | * Is called once the extension has to stop 50 | */ 51 | 52 | // biome-ignore lint/suspicious/useAwait: API 53 | async stop(): Promise { 54 | this.eventBus.removeListeners(this); 55 | } 56 | 57 | public adjustMessageBeforePublish(_entity: Group | Device, _message: KeyValue): void {} 58 | } 59 | 60 | export default Extension; 61 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG TARGETPLATFORM 2 | 3 | FROM alpine:3.22 AS base 4 | 5 | ENV NODE_ENV=production 6 | WORKDIR /app 7 | RUN apk add --no-cache tzdata eudev tini nodejs 8 | 9 | # Dependencies and build 10 | FROM base AS deps 11 | 12 | COPY package.json pnpm-lock.yaml ./ 13 | 14 | # Make and such are needed to compile serialport for riscv64 15 | # Install pnpm, configure a tmp-based store, then use it under a cache mount so each arch gets its own store 16 | # NOTE: We do not install devDependencies here (the --prod argument in pnpm install) as we use an already 17 | # built artifact (./dist directory) from previous GitHub runner stages. Omitting devDependencies here 18 | # saves storage as this node_modules/ is being copied straight to the release stage later. 19 | RUN --mount=type=cache,id=pnpm-store,target=/tmp/pnpm-store \ 20 | apk add --no-cache make gcc g++ python3 linux-headers npm jq && \ 21 | npm install -g $(jq -r '.packageManager' package.json) && \ 22 | pnpm config set store-dir /tmp/pnpm-store && \ 23 | pnpm install --frozen-lockfile --prod --no-optional && \ 24 | # force serialport to rebuild on current platform instead of using prebuilds 25 | rm -rf $(find ./node_modules/.pnpm/ -wholename "*/@serialport/bindings-cpp/prebuilds" -type d) && \ 26 | pnpm rebuild @serialport/bindings-cpp 27 | 28 | # Release 29 | FROM base AS release 30 | 31 | ARG DATE 32 | ARG VERSION 33 | LABEL org.opencontainers.image.authors="Koen Kanters" 34 | LABEL org.opencontainers.image.title="Zigbee2MQTT" 35 | LABEL org.opencontainers.image.description="Zigbee to MQTT bridge using Zigbee-herdsman" 36 | LABEL org.opencontainers.image.url="https://github.com/Koenkk/zigbee2mqtt" 37 | LABEL org.opencontainers.image.documentation="https://www.zigbee2mqtt.io/" 38 | LABEL org.opencontainers.image.source="https://github.com/Koenkk/zigbee2mqtt" 39 | LABEL org.opencontainers.image.licenses="GPL-3.0" 40 | LABEL org.opencontainers.image.created=${DATE} 41 | LABEL org.opencontainers.image.version=${VERSION} 42 | 43 | COPY --from=deps /app/node_modules ./node_modules 44 | COPY dist ./dist 45 | COPY package.json LICENSE index.js data/configuration.example.yaml ./ 46 | 47 | COPY docker/docker-entrypoint.sh /usr/local/bin/ 48 | RUN chmod +x /usr/local/bin/docker-entrypoint.sh 49 | 50 | RUN mkdir /app/data 51 | 52 | ARG COMMIT 53 | RUN echo "$COMMIT" > dist/.hash 54 | 55 | # Expose the default frontend port (see lib/util/settings.ts defaults.frontend.port) 56 | EXPOSE 8080 57 | 58 | ENTRYPOINT ["docker-entrypoint.sh"] 59 | CMD [ "/sbin/tini", "--", "node", "index.js"] 60 | -------------------------------------------------------------------------------- /test/mocks/logger.ts: -------------------------------------------------------------------------------- 1 | import type Transport from "winston-transport"; 2 | 3 | import type {LogLevel} from "../../lib/util/settings"; 4 | 5 | let level: LogLevel = "info"; 6 | let debugNamespaceIgnore = ""; 7 | let namespacedLevels: Record = {}; 8 | let transports: Transport[] = []; 9 | let transportsEnabled = false; 10 | const getMessage = (messageOrLambda: string | (() => string)): string => (messageOrLambda instanceof Function ? messageOrLambda() : messageOrLambda); 11 | 12 | export const mockLogger = { 13 | log: vi.fn().mockImplementation((level, message, namespace = "z2m") => { 14 | if (transportsEnabled) { 15 | for (const transport of transports) { 16 | transport.log!({level, message, namespace}, () => {}); 17 | } 18 | } 19 | }), 20 | init: vi.fn(), 21 | info: vi.fn().mockImplementation((messageOrLambda, namespace = "z2m") => mockLogger.log("info", getMessage(messageOrLambda), namespace)), 22 | warning: vi.fn().mockImplementation((messageOrLambda, namespace = "z2m") => mockLogger.log("warning", getMessage(messageOrLambda), namespace)), 23 | error: vi.fn().mockImplementation((messageOrLambda, namespace = "z2m") => mockLogger.log("error", getMessage(messageOrLambda), namespace)), 24 | debug: vi.fn().mockImplementation((messageOrLambda, namespace = "z2m") => mockLogger.log("debug", getMessage(messageOrLambda), namespace)), 25 | cleanup: vi.fn(), 26 | logOutput: vi.fn(), 27 | add: (transport: Transport): void => { 28 | transports.push(transport); 29 | }, 30 | addTransport: (transport: Transport): void => { 31 | transports.push(transport); 32 | }, 33 | removeTransport: (transport: Transport): void => { 34 | transports = transports.filter((t) => t !== transport); 35 | }, 36 | setLevel: (newLevel: LogLevel): void => { 37 | level = newLevel; 38 | }, 39 | getLevel: (): LogLevel => level, 40 | setNamespacedLevels: (nsLevels: Record): void => { 41 | namespacedLevels = nsLevels; 42 | }, 43 | getNamespacedLevels: (): Record => namespacedLevels, 44 | setDebugNamespaceIgnore: (newIgnore: string): void => { 45 | debugNamespaceIgnore = newIgnore; 46 | }, 47 | getDebugNamespaceIgnore: (): string => debugNamespaceIgnore, 48 | setTransportsEnabled: (value: boolean): void => { 49 | transportsEnabled = value; 50 | }, 51 | end: vi.fn(), 52 | }; 53 | 54 | vi.mock("../../lib/util/logger", () => ({ 55 | default: mockLogger, 56 | })); 57 | -------------------------------------------------------------------------------- /lib/extension/externalConverters.ts: -------------------------------------------------------------------------------- 1 | import type {ExternalDefinitionWithExtend} from "zigbee-herdsman-converters"; 2 | 3 | import {addExternalDefinition, removeExternalDefinitions} from "zigbee-herdsman-converters"; 4 | 5 | import logger from "../util/logger"; 6 | import ExternalJSExtension from "./externalJS"; 7 | 8 | type TModule = ExternalDefinitionWithExtend | ExternalDefinitionWithExtend[]; 9 | 10 | export default class ExternalConverters extends ExternalJSExtension { 11 | constructor( 12 | zigbee: Zigbee, 13 | mqtt: Mqtt, 14 | state: State, 15 | publishEntityState: PublishEntityState, 16 | eventBus: EventBus, 17 | enableDisableExtension: (enable: boolean, name: string) => Promise, 18 | restartCallback: () => Promise, 19 | addExtension: (extension: Extension) => Promise, 20 | ) { 21 | super( 22 | zigbee, 23 | mqtt, 24 | state, 25 | publishEntityState, 26 | eventBus, 27 | enableDisableExtension, 28 | restartCallback, 29 | addExtension, 30 | "converter", 31 | "external_converters", 32 | ); 33 | } 34 | 35 | protected async removeJS(name: string, _mod: TModule): Promise { 36 | removeExternalDefinitions(name); 37 | 38 | await this.zigbee.resolveDevicesDefinitions(true); 39 | } 40 | 41 | protected async loadJS(name: string, mod: TModule, newName?: string): Promise { 42 | try { 43 | removeExternalDefinitions(name); 44 | 45 | const definitions = Array.isArray(mod) ? mod : [mod]; 46 | 47 | for (const definition of definitions) { 48 | definition.externalConverterName = newName ?? name; 49 | 50 | addExternalDefinition(definition); 51 | logger.info(`Loaded external converter '${newName ?? name}'.`); 52 | } 53 | 54 | await this.zigbee.resolveDevicesDefinitions(true); 55 | } catch (error) { 56 | logger.error( 57 | /* v8 ignore next */ 58 | `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.`, 59 | ); 60 | logger.warning( 61 | "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", 62 | ); 63 | 64 | throw error; 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /lib/extension/externalExtensions.ts: -------------------------------------------------------------------------------- 1 | import logger from "../util/logger"; 2 | import * as settings from "../util/settings"; 3 | import type Extension from "./extension"; 4 | import ExternalJSExtension from "./externalJS"; 5 | 6 | type TModule = new (...args: ConstructorParameters) => Extension; 7 | 8 | export default class ExternalExtensions extends ExternalJSExtension { 9 | constructor( 10 | zigbee: Zigbee, 11 | mqtt: Mqtt, 12 | state: State, 13 | publishEntityState: PublishEntityState, 14 | eventBus: EventBus, 15 | enableDisableExtension: (enable: boolean, name: string) => Promise, 16 | restartCallback: () => Promise, 17 | addExtension: (extension: Extension) => Promise, 18 | ) { 19 | super( 20 | zigbee, 21 | mqtt, 22 | state, 23 | publishEntityState, 24 | eventBus, 25 | enableDisableExtension, 26 | restartCallback, 27 | addExtension, 28 | "extension", 29 | "external_extensions", 30 | ); 31 | } 32 | 33 | protected async removeJS(_name: string, mod: TModule): Promise { 34 | await this.enableDisableExtension(false, mod.name); 35 | } 36 | 37 | protected async loadJS(name: string, mod: TModule, newName?: string): Promise { 38 | try { 39 | // stop if already started 40 | await this.enableDisableExtension(false, mod.name); 41 | await this.addExtension( 42 | new mod( 43 | this.zigbee, 44 | this.mqtt, 45 | this.state, 46 | this.publishEntityState, 47 | this.eventBus, 48 | this.enableDisableExtension, 49 | this.restartCallback, 50 | this.addExtension, 51 | // @ts-expect-error additional params that don't fit the internal `Extension` type 52 | settings, 53 | logger, 54 | ), 55 | ); 56 | 57 | /* v8 ignore start */ 58 | logger.info(`Loaded external extension '${newName ?? name}'.`); 59 | } catch (error) { 60 | logger.error( 61 | /* v8 ignore next */ 62 | `Failed to load external extension '${newName ?? name}'. Check the code for syntax error and make sure it is up to date with the current Zigbee2MQTT version.`, 63 | ); 64 | 65 | throw error; 66 | } 67 | /* v8 ignore stop */ 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /lib/util/sd-notify.ts: -------------------------------------------------------------------------------- 1 | import {platform} from "node:os"; 2 | import type {UnixDgramSocket} from "unix-dgram"; 3 | 4 | import logger from "./logger"; 5 | 6 | /** 7 | * Handle sd_notify protocol, @see https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html 8 | * No-op if running on unsupported platforms or without Type=notify 9 | * Soft-fails if improperly setup (this is not necessary for Zigbee2MQTT to function properly) 10 | */ 11 | export async function initSdNotify(): Promise<{notifyStopping: () => void; stop: () => void} | undefined> { 12 | if (!process.env.NOTIFY_SOCKET) { 13 | return; 14 | } 15 | 16 | let socket: UnixDgramSocket | undefined; 17 | 18 | try { 19 | const {createSocket} = await import("unix-dgram"); 20 | socket = createSocket("unix_dgram"); 21 | } catch (error) { 22 | if (platform() !== "win32" || process.env.WSL_DISTRO_NAME) { 23 | // not on plain Windows 24 | logger.error(`Could not init sd_notify: ${(error as Error).message}`); 25 | // biome-ignore lint/style/noNonNullAssertion: always Error 26 | logger.debug((error as Error).stack!); 27 | } else { 28 | // this should not happen 29 | logger.warning(`NOTIFY_SOCKET env is set: ${(error as Error).message}`); 30 | } 31 | 32 | return; 33 | } 34 | 35 | const sendToSystemd = (msg: string): void => { 36 | const buffer = Buffer.from(msg); 37 | 38 | // biome-ignore lint/style/noNonNullAssertion: valid from start of function 39 | socket.send(buffer, 0, buffer.byteLength, process.env.NOTIFY_SOCKET!, (err) => { 40 | if (err) { 41 | logger.warning(`Failed to send "${msg}" to systemd: ${err.message}`); 42 | } 43 | }); 44 | }; 45 | const notifyStopping = (): void => sendToSystemd("STOPPING=1"); 46 | 47 | sendToSystemd("READY=1"); 48 | 49 | const wdUSec = process.env.WATCHDOG_USEC !== undefined ? Math.max(0, Number.parseInt(process.env.WATCHDOG_USEC, 10)) : -1; 50 | 51 | if (wdUSec > 0) { 52 | // Convert us to ms, send twice as frequently as the timeout 53 | const watchdogInterval = setInterval(() => sendToSystemd("WATCHDOG=1"), wdUSec / 1000 / 2); 54 | 55 | return { 56 | notifyStopping, 57 | stop: (): void => clearInterval(watchdogInterval), 58 | }; 59 | } 60 | 61 | if (wdUSec !== -1) { 62 | logger.warning(`WATCHDOG_USEC invalid: "${process.env.WATCHDOG_USEC}", parsed to "${wdUSec}"`); 63 | } 64 | 65 | return { 66 | notifyStopping, 67 | stop: (): void => {}, 68 | }; 69 | } 70 | -------------------------------------------------------------------------------- /lib/extension/health.ts: -------------------------------------------------------------------------------- 1 | import * as os from "node:os"; 2 | import * as process from "node:process"; 3 | import type {Zigbee2MQTTAPI} from "../types/api"; 4 | import * as settings from "../util/settings"; 5 | import utils from "../util/utils"; 6 | import Extension from "./extension"; 7 | 8 | /** Round with 2 decimals */ 9 | const round2 = (n: number): number => Math.round(n * 100.0) / 100.0; 10 | /** Round with 4 decimals */ 11 | const round4 = (n: number): number => Math.round(n * 10000.0) / 10000.0; 12 | 13 | export default class Health extends Extension { 14 | #checkTimer: NodeJS.Timeout | undefined; 15 | 16 | override async start(): Promise { 17 | await super.start(); 18 | 19 | this.#checkTimer = setInterval(this.#checkHealth.bind(this), utils.minutes(settings.get().health.interval)); 20 | } 21 | 22 | override async stop(): Promise { 23 | clearInterval(this.#checkTimer); 24 | await super.stop(); 25 | } 26 | 27 | clearStats(): void { 28 | this.eventBus.stats.devices.clear(); 29 | this.eventBus.stats.mqtt.published = 0; 30 | this.eventBus.stats.mqtt.received = 0; 31 | } 32 | 33 | async #checkHealth(): Promise { 34 | const sysMemTotalKb = os.totalmem() / 1024; 35 | const sysMemFreeKb = os.freemem() / 1024; 36 | const sysMemUsedKb = sysMemTotalKb - sysMemFreeKb; 37 | const procMemUsedKb = process.memoryUsage().rss / 1024; 38 | const healthcheck: Zigbee2MQTTAPI["bridge/health"] = { 39 | response_time: Date.now(), 40 | os: { 41 | load_average: os.loadavg(), // will be [0,0,0] on Windows (not supported) 42 | memory_used_mb: round2(sysMemUsedKb / 1024), 43 | memory_percent: round4((sysMemUsedKb / sysMemTotalKb) * 100.0), 44 | }, 45 | process: { 46 | uptime_sec: Math.floor(process.uptime()), 47 | memory_used_mb: round2(procMemUsedKb / 1024), 48 | memory_percent: round4((procMemUsedKb / sysMemTotalKb) * 100.0), 49 | }, 50 | mqtt: {...this.mqtt.stats, ...this.eventBus.stats.mqtt}, 51 | devices: {}, 52 | }; 53 | 54 | for (const [ieeeAddr, device] of this.eventBus.stats.devices) { 55 | let messages = 0; 56 | let mps = 0; 57 | 58 | if (device.lastSeenChanges) { 59 | const timeDiff = Date.now() - device.lastSeenChanges.first; 60 | messages = device.lastSeenChanges.messages; 61 | mps = timeDiff > 0 ? round4(messages / (timeDiff / 1000.0)) : 0; 62 | } 63 | 64 | healthcheck.devices[ieeeAddr] = { 65 | messages, 66 | messages_per_sec: mps, 67 | leave_count: device.leaveCounts, 68 | network_address_changes: device.networkAddressChanges, 69 | }; 70 | } 71 | 72 | if (settings.get().health.reset_on_check) { 73 | this.clearStats(); 74 | } 75 | 76 | await this.mqtt.publish("bridge/health", JSON.stringify(healthcheck), {clientOptions: {retain: true, qos: 1}}); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/external_converter.yaml: -------------------------------------------------------------------------------- 1 | name: External converter 2 | description: Submit an external converter 3 | title: '[External Converter]: ' 4 | labels: [external converter] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | **IMPORTANT:** In case the external converter fully works, please consider submitting a [pull request](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html#_4-create-a-pull-request) instead. 10 | - type: input 11 | id: link 12 | attributes: 13 | label: Link 14 | description: Link of this device (product page) 15 | placeholder: https://www.linktomydevice.org 16 | validations: 17 | required: true 18 | - type: input 19 | id: database 20 | attributes: 21 | label: Database entry 22 | description: Entry of this device in `data/database.db` after pairing it 23 | placeholder: '{"id":53,"type":"Router","ieeeAddr":"0x10458d00024284f69","nwkAddr":10148,"manufId":4151,"manufName":"LUMI","powerSource":"DC Source","modelId":"lumi.relay.c2acn01","epList":[1,2],"endpoints":{"1":{"profId":260,"epId":1,"devId":257,"inClusterList":[0,3,4,5,1,2,10,6,16,2820,12],"outClusterList":[25,10],"clusters":{"genBasic":{"attributes":{"modelId":"lumi.relay.c2acn01","appVersion":1,"manufacturerName":"LUMI","powerSource":4,"zclVersion":0,"stackVersion":2,"hwVersion":18,"dateCode":"8-6-2020"}},"genAnalogInput":{"attributes":{"presentValue":129.04425048828125}},"genOnOff":{"attributes":{"61440":117440715,"onOff":1}}},"binds":[],"configuredReportings":[],"meta":{}},"2":{"profId":260,"epId":2,"devId":257,"inClusterList":[6,16,4,5],"outClusterList":[],"clusters":{"genOnOff":{"attributes":{"61440":237478966,"onOff":0}}},"binds":[],"configuredReportings":[],"meta":{}}},"appVersion":1,"stackVersion":2,"hwVersion":18,"dateCode":"8-6-2020","zclVersion":0,"interviewCompleted":true,"meta":{},"lastSeen":1640285631405}' 24 | validations: 25 | required: true 26 | - type: input 27 | id: z2m_version 28 | attributes: 29 | label: Zigbee2MQTT version 30 | description: Can be found in the frontend -> settings -> about -> Zigbee2MQTT version. Are you running Zigbee2MQTT 1.18.1? Then read [this](https://github.com/Koenkk/zigbee2mqtt/releases/tag/1.19.0). 31 | placeholder: '1.22.1' 32 | validations: 33 | required: true 34 | - type: textarea 35 | id: external_definition 36 | attributes: 37 | label: External converter 38 | description: Code of the external converter, to retrieve in the Z2M frontend go to Settings -> Dev Console -> External Converters 39 | render: JavaScript 40 | validations: 41 | required: true 42 | - type: textarea 43 | attributes: 44 | label: What does/doesn't work with the external definition? 45 | description: Indicate what works and what doesn't work with the external definition. 46 | validations: 47 | required: true 48 | - type: textarea 49 | id: notes 50 | attributes: 51 | label: Notes 52 | description: Any additional information or context that might be helpful 53 | placeholder: Add any relevant notes or context here 54 | validations: 55 | required: false -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - dev 5 | 6 | permissions: 7 | contents: write 8 | pull-requests: write 9 | 10 | name: Release Please 11 | 12 | jobs: 13 | release-please: 14 | runs-on: ubuntu-latest 15 | outputs: 16 | release_created: ${{ steps.release.outputs.release_created }} 17 | version: '${{steps.release.outputs.major}}.${{steps.release.outputs.minor}}.${{steps.release.outputs.patch}}' 18 | steps: 19 | - uses: pnpm/action-setup@v4 20 | with: 21 | version: 9 22 | 23 | - uses: actions/setup-node@v6 24 | with: 25 | node-version: 24 26 | 27 | - uses: googleapis/release-please-action@v4 28 | id: release 29 | with: 30 | target-branch: dev 31 | token: ${{secrets.GH_TOKEN}} 32 | 33 | # Checkout repos 34 | - uses: actions/checkout@v6 35 | with: 36 | repository: koenkk/zigbee2mqtt 37 | path: ./z2m 38 | - uses: actions/checkout@v6 39 | with: 40 | repository: koenkk/zigbee2mqtt 41 | path: ./z2m-master 42 | ref: master 43 | 44 | - name: Restore cache commit-user-lookup.json 45 | uses: actions/cache/restore@v4 46 | with: 47 | path: z2m/scripts/commit-user-lookup.json 48 | key: commit-user-lookup-dummy 49 | restore-keys: | 50 | commit-user-lookup- 51 | - name: Generate changelog 52 | run: | 53 | MASTER_Z2M_VERSION=$(cat z2m-master/package.json | jq -r '.version') 54 | MASTER_ZHC_VERSION=$(cat z2m-master/package.json | jq -r '.dependencies."zigbee-herdsman-converters"') 55 | MASTER_ZH_VERSION=$(cat z2m-master/package.json | jq -r '.dependencies."zigbee-herdsman"') 56 | MASTER_FRONTEND_VERSION=$(cat z2m-master/package.json | jq -r '.dependencies."zigbee2mqtt-frontend"') 57 | MASTER_WINDFRONT_VERSION=$(cat z2m-master/package.json | jq -r '.dependencies."zigbee2mqtt-windfront"') 58 | wget -q -O - https://raw.githubusercontent.com/Koenkk/zigbee2mqtt/release-please--branches--dev--components--zigbee2mqtt/CHANGELOG.md > z2m/CHANGELOG.md 59 | cd z2m 60 | pnpm i --frozen-lockfile 61 | node scripts/generateChangelog.mjs $MASTER_Z2M_VERSION $MASTER_ZHC_VERSION $MASTER_ZH_VERSION $MASTER_FRONTEND_VERSION $MASTER_WINDFRONT_VERSION >> ../changelog.md 62 | env: 63 | GH_TOKEN: ${{secrets.GH_TOKEN}} 64 | - name: Update changelog gist 65 | run: | 66 | gh gist edit bfd4c3d1725a2cccacc11d6ba51008ba -a changelog.md 67 | env: 68 | GH_TOKEN: ${{secrets.GH_TOKEN}} 69 | - name: Save cache commit-user-lookup.json 70 | uses: actions/cache/save@v4 71 | if: always() 72 | with: 73 | path: z2m/scripts/commit-user-lookup.json 74 | key: commit-user-lookup-${{ hashFiles('z2m/scripts/commit-user-lookup.json') }} 75 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json", 3 | "vcs": { 4 | "enabled": true, 5 | "clientKind": "git", 6 | "useIgnoreFile": true 7 | }, 8 | "formatter": { 9 | "indentStyle": "space", 10 | "indentWidth": 4, 11 | "lineWidth": 150, 12 | "bracketSpacing": false 13 | }, 14 | "files": { 15 | "includes": ["**", "!package.json"] 16 | }, 17 | "linter": { 18 | "includes": ["**"], 19 | "rules": { 20 | "correctness": { 21 | "noUnusedImports": "error" 22 | }, 23 | "style": { 24 | "noParameterAssign": "off", 25 | "useThrowNewError": "error", 26 | "useThrowOnlyError": "error", 27 | "useNamingConvention": { 28 | "level": "error", 29 | "options": { 30 | "strictCase": false, 31 | "conventions": [ 32 | { 33 | "selector": { 34 | "kind": "objectLiteralProperty" 35 | }, 36 | "formats": ["snake_case", "camelCase", "CONSTANT_CASE", "PascalCase"] 37 | }, 38 | { 39 | "selector": { 40 | "kind": "const" 41 | }, 42 | "formats": ["snake_case", "camelCase", "CONSTANT_CASE", "PascalCase"] 43 | }, 44 | { 45 | "selector": { 46 | "kind": "typeProperty" 47 | }, 48 | "formats": ["snake_case", "camelCase", "CONSTANT_CASE", "PascalCase"] 49 | }, 50 | { 51 | "selector": { 52 | "kind": "enumMember" 53 | }, 54 | "formats": ["CONSTANT_CASE", "PascalCase"] 55 | } 56 | ] 57 | } 58 | }, 59 | "useAsConstAssertion": "error", 60 | "useDefaultParameterLast": "error", 61 | "useEnumInitializers": "error", 62 | "useSelfClosingElements": "error", 63 | "useSingleVarDeclarator": "error", 64 | "noUnusedTemplateLiteral": "error", 65 | "useNumberNamespace": "error", 66 | "noInferrableTypes": "error", 67 | "noUselessElse": "error" 68 | }, 69 | "performance": { 70 | "noDelete": "off" 71 | }, 72 | "suspicious": { 73 | "noConstEnum": "off", 74 | "useAwait": "error" 75 | } 76 | } 77 | }, 78 | "overrides": [ 79 | { 80 | "includes": ["test/**"], 81 | "linter": { 82 | "rules": { 83 | "style": { 84 | "noNonNullAssertion": "off", 85 | "useNamingConvention": "off" 86 | }, 87 | "suspicious": { 88 | "noImplicitAnyLet": "off" 89 | } 90 | } 91 | } 92 | } 93 | ] 94 | } 95 | -------------------------------------------------------------------------------- /test/assets/certs/dummy.key: -------------------------------------------------------------------------------- 1 | -----BEGIN ENCRYPTED PRIVATE KEY----- 2 | MIIJnDBOBgkqhkiG9w0BBQ0wQTApBgkqhkiG9w0BBQwwHAQIrNH7wPLdpbUCAggA 3 | MAwGCCqGSIb3DQIJBQAwFAYIKoZIhvcNAwcECN97bYsRcF6xBIIJSLVWpqTzdlNO 4 | 5J7kOrNcdbbg+SSEYbtT+G9gtwtkI8KO+xzYQZ/fdqmXgNqf9AQmikwDWQkoS4gC 5 | qq6S+iKYn7Y8ZNZ/xu7gWW6L6uNZ3LTe/vkpenU6kmZrDysFTW5b7iH2VmMc/2h/ 6 | J3o3unxeMLMVSaoDG3MG08qj53yTmetTkZZZc4fgf6vK9/4fTJhdXu6n+1COBLBI 7 | KbqbTRKnai3CNiYzz3UHyp9CGx1nX06beTJW8bYJUld9cJz+ozLNSV78oCkcUfwG 8 | yR7p/6+86cWor5SwYG1wEm6w2bXrIJILTUdT92gXC8fhc/B6Id8hcuLIHcvFQ/CP 9 | 31y4Qwea3vY6b1TeRbqS4/fYOYajsiZi9ps0Meg55qMJj/UuNW2iLlR7PVsDXmPO 10 | 33X6cpEVyIlNCJzRREotzSoGmcXjQ5Dgd1LkxZPb163NGkRoJT4GcKCtQxYIyIzy 11 | qyU0YF7I5LPKtNt1IPURHHerW42jM2jFlISxUixgl5Pj3WrmXeEmXq7htItn6X/q 12 | tVEVUf6srs3aos2Ohx7Ji9PVDNuMGGGKxleCM6fU9ax/gYuk5r6PMYGPsD26xuZ0 13 | A9WbFFFI6N1V+M3YsJJdxevZRV1NB3RymYGwFoZFDOKLb5DYJmPP3VVN/DsMNPkw 14 | qaue7rinxBdLi8J6Itusiscg5pCyYQaujXtkK/bi6c1HCld4RvWCDgeU52SWRdJ9 15 | 2Np2629AeBvBfHP1/DsaUsyQPJAgDJk0HXoJyqstoALkRZ6ICoi1833LW/JGIWOv 16 | xnl3JZ2q12aqb2OWxiBUKspnt2y5C2KpQJdmuEL68lAKLg+fzLi/jhJG1bwzWo79 17 | I5G+QeV9+kI3vAIYHu331a+Yevs2p84sIumDGFSCSHAaWMmsrizJ4eKl5NnwZHS+ 18 | xUvcW+ISRKNRm4DWAL/nlwBJWMhCz+nsTKYvE1JO/IH2P5xxtD6bTjJ3YkVAHkBZ 19 | wfApCBxdiQurEiabj06C4OCcNRkqJ/GwLrdxiwTCAt2hd7Dn+ttLo3Fi6iPRae8c 20 | ZtWsCWaFObZ1QBeu7lKm+ev/Ab2SHtp6QMfrArNsrdIxSH1+Sm4DgfHmTmte2Tse 21 | KFHy5Rwokst+Y18zjp3auJ3+NTTr9D1x2JCdQ6wkh66Pyd2FBLqRrMI1fHq9t9P+ 22 | r4DQm9I6FbgALm9yJ2QxV4ytwFGsowAye3vF+hfO5OjAScrG5DrxASRXlnp59zpd 23 | +vMLk6rzcfiHzaJzYc/SOU9ubKN93aJmlL0Zgf1gg54oGnmPbEJeqQzeKVBtisro 24 | FrNUPEFx5QOaqjVipPOpZfpTb0oaUpBdp4RpeI7W3Gb/dsmsRj7ItxvM6QxFdd5e 25 | XfDZEYaUJRwSG3lHg9URWwSmfRWV81BhEuwvji4rNIEQ32VMsi2WWWPLOYYKGIw/ 26 | yE56xWBw7K78XwVx/b5yoNHBNjVNyjpNZ7jXkx6zdOjbqEpXJP+WfgVkYwlhlvAq 27 | cCDbrF7jVWFqTV9tZNxlbaFlS8f17vld8DmmJ29Lo+WecsOo3M9n2Z6i79u7Xg2O 28 | ip0V2Textq8rrKOwC1TLCRoQLypENO/cwdaF5amVkoXYDlY0rQtxf65Xe9bnJLrM 29 | 1G+tG8NZQu/bvW0mgrm0XHt2i6yfjn6sXEOY5VEC2+gRUriQ+GCETgzMe6ukb26/ 30 | VT38Va10SIu+xXwf/GXTwLskOO0AyWLBopTpL5q6faM4o/USX+Xl98sQVNQurHMS 31 | xSQtpL6YPhQrmW7q5Q57OVWy70kEU8RpOJx6I7uyoUDz2g3bjmpIPpQX55vhofXK 32 | 3Xn76Ce9TyiVol71mPtBT1tYzi3ytZh/EYm2Wq3lEhbMT7zjw3T1Ypb4+q12VdCy 33 | 0Y9swKsQdhRCT6FwcCFSpCc2TzCCJCR6mChTe+z6rNm5BS2M6BaYcrX8PP82rZ8k 34 | L0l3ymSLqSJ3J6Q+fdKhRq8EbCq6ngi31Q/l7fPHkW5VAJyowPRayD7UM09n6ou+ 35 | sg/kfQB8nh46uVxRRDpyUsrfhtIEQec2xrU17NmCr2VlAeOjWBn8lE/J1Gfn8WyV 36 | 05mY1aDGcRk/x02+74UujyuozYMfIVDXHpxp8YteTnBfiKVrRq+Aa9zVX9bkyagp 37 | SJUTpF7lqMmiuvyZnG2xfLDfILR/zTRIRrm+ZIJLHeVQJtibOJ1llxE6kRvKx6zp 38 | La47YCHKH+rLDTlNgrE5K/JtQgOKc+7bGQ6SCszf8OZWi5B2+Z+Ht36c3xQk9IOW 39 | cjNpvjZxpiw6L1MhlQPE/wxz2JkOlKjJN0MmLscIyu15X+W/BvHwB8d11CRKUUiE 40 | pV7OUoW2Ol1IgFHrEY33IhxwtyKtAKfQCiORarnwk/GvcD3TqmnzUeQdscdww4BI 41 | wsO3c3aGr+wvU3P/8oFAoffD1KRihtoZ6CB347JcnaeEPlOS13a28JsJCOtjMxy9 42 | wBH6UgzTwo04lDAStST+aIT4TaU4dlWmsAEQTpE8sa4DjpFtzK/ll+Wg2sA/SYaA 43 | uAugsAOnTb1pkgk43GoYfc2fZCu1g9b/L3mZrE62W8HDfm67PWT65XMJziy/hcCy 44 | DbXMAWfohLdePKDG7J0QQI8xcTN9TT5q3ZCglI9Xf7PE/qrHROdRgF8c8zyvB9dG 45 | AVx96Z7akBoveqlFS44MnxIOGI0AzV8MrVbN51W6RHNyg/6UTL50/8zHmRWZj+iO 46 | tfqiW+CtSHacYPy+Tcuft30PWZZkQDGphbRxaBRB00Fb+Bo57epDw4JS+4wRru33 47 | 2RHaVzQmnXwR6kP85x4uAR53UdgMgfK4cyylnFqGDlLFrHZfauFHg3kMIwqgVIVA 48 | uMRrWC9XXU11j6NnP9J6Y64uSrjsh6eZOrtJIwA16U2r3PMMbn1hIdADrKu1MsPt 49 | bkNsnPQUKJyX8DbRX45kje5ssUerbkxhvewvHQJlTyDxtgk/pGHOyKXoT+5vQEj9 50 | pwHOrXi+QWidzi2vfRoNQJ0wXJr39dDTn0hVdsgFo2PsTh98W9cocVQfJnCL4Djs 51 | Zlsp/BJOwlb2e3Q28E6pNyT+N63ZYEO9zLV/WmCB/eV2uMVoKibX3KszYkn0jAs0 52 | RBytgnB8YW3n9RyorShchCKJzdjzR7nw79/lSLgDFE6W6Xh3VaGM07hz0vYWfaui 53 | yvytkM9LPan2/GJM4zLFeA== 54 | -----END ENCRYPTED PRIVATE KEY----- 55 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/wrong_device.yaml: -------------------------------------------------------------------------------- 1 | name: Wrong device picture/vendor/model/description 2 | description: Use if device is detected as supported and is fully functional but has a wrong picture, vendor, model or description 3 | title: '[Wrong device]: ' 4 | labels: [wrong device] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Submit this issue only if your device is detected as **supported** and works **fully**, but displays an incorrect picture, vendor, model, or description. 10 | 11 | **IMPORTANT**: Before opening this issue, please review the [guide](https://www.zigbee2mqtt.io/advanced/support-new-devices/02_support_new_tuya_devices.html#fixing-tuya-device-detection) for instructions on fixing the device detection yourself. 12 | This usually only takes 5 minutes. 13 | - type: input 14 | id: link 15 | attributes: 16 | label: Link 17 | description: Link of this device (product page) 18 | placeholder: https://www.linktomydevice.org 19 | validations: 20 | required: true 21 | - type: input 22 | id: model 23 | attributes: 24 | label: Model 25 | description: Expected model, model that is printed on the device, for Tuya device this is NOT something like TS0601 or _TZE200_cf1sl3tj 26 | placeholder: RTCGQ01LM 27 | validations: 28 | required: true 29 | - type: input 30 | id: description 31 | attributes: 32 | label: Description 33 | description: Expected description 34 | placeholder: Motion sensor 35 | validations: 36 | required: true 37 | - type: input 38 | id: vendor 39 | attributes: 40 | label: Vendor 41 | description: Expected vendor 42 | placeholder: Xiaomi 43 | validations: 44 | required: true 45 | - type: input 46 | id: picture 47 | attributes: 48 | label: Picture (link) 49 | description: Expected picture 50 | placeholder: https://www.linktomydevice.org/RTCGQ01LM.jpg 51 | validations: 52 | required: true 53 | - type: input 54 | id: database 55 | attributes: 56 | label: Database entry 57 | description: Entry of this device in `data/database.db` after pairing it 58 | placeholder: '{"id":53,"type":"Router","ieeeAddr":"0x10458d00024284f69","nwkAddr":10148,"manufId":4151,"manufName":"LUMI","powerSource":"DC Source","modelId":"lumi.relay.c2acn01","epList":[1,2],"endpoints":{"1":{"profId":260,"epId":1,"devId":257,"inClusterList":[0,3,4,5,1,2,10,6,16,2820,12],"outClusterList":[25,10],"clusters":{"genBasic":{"attributes":{"modelId":"lumi.relay.c2acn01","appVersion":1,"manufacturerName":"LUMI","powerSource":4,"zclVersion":0,"stackVersion":2,"hwVersion":18,"dateCode":"8-6-2020"}},"genAnalogInput":{"attributes":{"presentValue":129.04425048828125}},"genOnOff":{"attributes":{"61440":117440715,"onOff":1}}},"binds":[],"configuredReportings":[],"meta":{}},"2":{"profId":260,"epId":2,"devId":257,"inClusterList":[6,16,4,5],"outClusterList":[],"clusters":{"genOnOff":{"attributes":{"61440":237478966,"onOff":0}}},"binds":[],"configuredReportings":[],"meta":{}}},"appVersion":1,"stackVersion":2,"hwVersion":18,"dateCode":"8-6-2020","zclVersion":0,"interviewCompleted":true,"meta":{},"lastSeen":1640285631405}' 59 | validations: 60 | required: true 61 | - type: textarea 62 | id: notes 63 | attributes: 64 | label: Notes 65 | placeholder: Some additional notes... 66 | validations: 67 | required: false 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zigbee2mqtt", 3 | "version": "2.7.1", 4 | "description": "Zigbee to MQTT bridge using Zigbee-herdsman", 5 | "main": "index.js", 6 | "types": "dist/types/api.d.ts", 7 | "packageManager": "pnpm@10.18.3", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/Koenkk/zigbee2mqtt.git" 11 | }, 12 | "engines": { 13 | "node": "^20.15.0 || ^22.2.0 || ^24" 14 | }, 15 | "keywords": [ 16 | "xiaomi", 17 | "tradfri", 18 | "hue", 19 | "bridge", 20 | "zigbee", 21 | "mqtt", 22 | "cc2531" 23 | ], 24 | "scripts": { 25 | "build": "tsc && node index.js writehash", 26 | "build:types": "pnpm run clean && tsc --project tsconfig.types.json && cp lib/util/settings.schema.json dist/util", 27 | "build:watch": "tsc --watch", 28 | "check": "biome check --error-on-warnings", 29 | "check:w": "biome check --write", 30 | "start": "node index.js", 31 | "test": "vitest run --config ./test/vitest.config.mts", 32 | "test:coverage": "vitest run --config ./test/vitest.config.mts --coverage", 33 | "test:watch": "vitest watch --config ./test/vitest.config.mts", 34 | "bench": "vitest bench --run --config ./test/vitest.config.mts", 35 | "prepack": "pnpm run clean && pnpm run build", 36 | "clean": "rimraf coverage dist tsconfig.tsbuildinfo" 37 | }, 38 | "author": "Koen Kanters", 39 | "license": "GPL-3.0", 40 | "bugs": { 41 | "url": "https://github.com/Koenkk/zigbee2mqtt/issues" 42 | }, 43 | "homepage": "https://koenkk.github.io/zigbee2mqtt", 44 | "dependencies": { 45 | "ajv": "^8.17.1", 46 | "bind-decorator": "^1.0.11", 47 | "debounce": "^3.0.0", 48 | "express-static-gzip": "^3.0.0", 49 | "fast-deep-equal": "^3.1.3", 50 | "finalhandler": "^2.1.1", 51 | "humanize-duration": "^3.33.1", 52 | "js-yaml": "^4.1.0", 53 | "json-stable-stringify-without-jsonify": "^1.0.1", 54 | "jszip": "^3.10.1", 55 | "mqtt": "^5.14.1", 56 | "object-assign-deep": "^0.4.0", 57 | "rimraf": "^6.1.2", 58 | "semver": "^7.7.3", 59 | "source-map-support": "^0.5.21", 60 | "throttleit": "^2.1.0", 61 | "winston": "^3.18.3", 62 | "winston-syslog": "^2.7.1", 63 | "winston-transport": "^4.9.0", 64 | "ws": "^8.18.1", 65 | "zigbee-herdsman": "7.0.4", 66 | "zigbee-herdsman-converters": "25.83.1", 67 | "zigbee2mqtt-frontend": "0.9.21", 68 | "zigbee2mqtt-windfront": "2.4.2" 69 | }, 70 | "devDependencies": { 71 | "@biomejs/biome": "^2.3.8", 72 | "@types/finalhandler": "^1.2.3", 73 | "@types/humanize-duration": "^3.27.4", 74 | "@types/js-yaml": "^4.0.9", 75 | "@types/node": "^24.10.1", 76 | "@types/object-assign-deep": "^0.4.3", 77 | "@types/readable-stream": "4.0.22", 78 | "@types/serve-static": "^2.2.0", 79 | "@types/ws": "8.18.1", 80 | "@vitest/coverage-v8": "^3.1.1", 81 | "tmp": "^0.2.5", 82 | "typescript": "^5.9.3", 83 | "vitest": "^3.1.1" 84 | }, 85 | "pnpm": { 86 | "overrides": { 87 | "zigbee-herdsman": "$zigbee-herdsman" 88 | }, 89 | "onlyBuiltDependencies": [ 90 | "@biomejs/biome", 91 | "@serialport/bindings-cpp", 92 | "esbuild", 93 | "unix-dgram" 94 | ] 95 | }, 96 | "bin": { 97 | "zigbee2mqtt": "cli.js" 98 | }, 99 | "optionalDependencies": { 100 | "unix-dgram": "^2.0.7" 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/new_device_support.yaml: -------------------------------------------------------------------------------- 1 | name: New device support request 2 | description: Request support for a new device 3 | title: '[New device support]: ' 4 | labels: [new device support] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | **IMPORTANT:** Before submitting: 10 | - Make sure this device is not already supported in the dev branch by checking the [dev branch changelog](https://gist.github.com/Koenkk/bfd4c3d1725a2cccacc11d6ba51008ba#new-supported-devices) 11 | - Make sure there is no existing issue or PR for this device already, search for your device here: https://github.com/Koenkk/zigbee2mqtt/issues 12 | - Follow this [guide](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html) 13 | - If you are using the Home Assistant addon and are still on 1.18.1, check the first point of [this](https://github.com/Koenkk/zigbee2mqtt/releases/tag/1.19.0) 14 | - type: input 15 | id: link 16 | attributes: 17 | label: Link 18 | description: Link of this device (product page) 19 | placeholder: https://www.linktomydevice.org 20 | validations: 21 | required: true 22 | - type: input 23 | id: database 24 | attributes: 25 | label: Database entry 26 | description: Entry of this device in `data/database.db` after pairing it 27 | placeholder: '{"id":53,"type":"Router","ieeeAddr":"0x10458d00024284f69","nwkAddr":10148,"manufId":4151,"manufName":"LUMI","powerSource":"DC Source","modelId":"lumi.relay.c2acn01","epList":[1,2],"endpoints":{"1":{"profId":260,"epId":1,"devId":257,"inClusterList":[0,3,4,5,1,2,10,6,16,2820,12],"outClusterList":[25,10],"clusters":{"genBasic":{"attributes":{"modelId":"lumi.relay.c2acn01","appVersion":1,"manufacturerName":"LUMI","powerSource":4,"zclVersion":0,"stackVersion":2,"hwVersion":18,"dateCode":"8-6-2020"}},"genAnalogInput":{"attributes":{"presentValue":129.04425048828125}},"genOnOff":{"attributes":{"61440":117440715,"onOff":1}}},"binds":[],"configuredReportings":[],"meta":{}},"2":{"profId":260,"epId":2,"devId":257,"inClusterList":[6,16,4,5],"outClusterList":[],"clusters":{"genOnOff":{"attributes":{"61440":237478966,"onOff":0}}},"binds":[],"configuredReportings":[],"meta":{}}},"appVersion":1,"stackVersion":2,"hwVersion":18,"dateCode":"8-6-2020","zclVersion":0,"interviewCompleted":true,"meta":{},"lastSeen":1640285631405}' 28 | validations: 29 | required: true 30 | - type: input 31 | id: z2m_version 32 | attributes: 33 | label: Zigbee2MQTT version 34 | description: Can be found in the frontend -> settings -> about -> Zigbee2MQTT version. Are you running Zigbee2MQTT 1.18.1? Then read [this](https://github.com/Koenkk/zigbee2mqtt/releases/tag/1.19.0). 35 | placeholder: '1.22.1' 36 | validations: 37 | required: true 38 | - type: textarea 39 | id: external_definition 40 | attributes: 41 | label: External definition 42 | description: See [Creating the external definition](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html#_2-creating-the-external-definition) 43 | render: JavaScript 44 | validations: 45 | required: true 46 | - type: textarea 47 | attributes: 48 | label: What does/doesn't work with the external definition? 49 | description: Indicate what works and what doesn't work with the external definition. 50 | validations: 51 | required: true 52 | - type: textarea 53 | id: notes 54 | attributes: 55 | label: Notes 56 | description: Any additional information or context that might be helpful 57 | placeholder: Add any relevant notes or context here 58 | validations: 59 | required: false -------------------------------------------------------------------------------- /scripts/zStackEraseAllNvMem.js: -------------------------------------------------------------------------------- 1 | const {ZnpCommandStatus, NvSystemIds} = require("zigbee-herdsman/dist/adapter/z-stack/constants/common"); 2 | const {ZnpVersion} = require("zigbee-herdsman/dist/adapter/z-stack/adapter/tstype"); 3 | const {Subsystem} = require("zigbee-herdsman/dist/adapter/z-stack/unpi/constants"); 4 | const {Znp} = require("zigbee-herdsman/dist/adapter/z-stack/znp"); 5 | 6 | class ZStackNvMemEraser { 7 | constructor(device) { 8 | this.znp = new Znp(device, 115200, false); 9 | } 10 | 11 | async start() { 12 | await this.znp.open(); 13 | const attempts = 3; 14 | for (let i = 0; i < attempts; i++) { 15 | try { 16 | await this.znp.request(Subsystem.SYS, "ping", {capabilities: 1}); 17 | break; 18 | } catch (e) { 19 | if (attempts - 1 === i) { 20 | throw new Error(`Failed to connect to the adapter (${e})`); 21 | } 22 | } 23 | } 24 | // Old firmware did not support version, assume it's Z-Stack 1.2 for now. 25 | try { 26 | this.version = (await this.znp.request(Subsystem.SYS, "version", {})).payload; 27 | } catch { 28 | console.log("Failed to get zStack version, assuming 1.2"); 29 | this.version = {transportrev: 2, product: 0, majorrel: 2, minorrel: 0, maintrel: 0, revision: ""}; 30 | } 31 | 32 | console.log(`Detected znp version '${ZnpVersion[this.version.product]}' (${JSON.stringify(this.version)})`); 33 | 34 | await this.clearAllNvMemItems(); 35 | 36 | process.exit(0); 37 | } 38 | 39 | async clearAllNvMemItems() { 40 | let maxNvMemId; 41 | switch (this.version.product) { 42 | case ZnpVersion.ZStack12: 43 | maxNvMemId = 0x0302; 44 | break; 45 | case ZnpVersion.ZStack30x: 46 | maxNvMemId = 0x033f; 47 | break; 48 | case ZnpVersion.ZStack3x0: 49 | maxNvMemId = 0x032f; 50 | break; 51 | } 52 | 53 | let deletedCount = 0; 54 | console.log(`Clearing all NVMEM items, from 0 to ${maxNvMemId}`); 55 | for (let id = 0; id <= maxNvMemId; id++) { 56 | let len; 57 | const needOsal = !(this.version.product === ZnpVersion.zStack3x0 && id <= 7); 58 | if (needOsal) { 59 | const lengthRes = await this.znp.request(Subsystem.SYS, "osalNvLength", {id: id}); 60 | len = lengthRes.payload.length; 61 | } else { 62 | const lengthRes = await this.znp.request(Subsystem.SYS, "nvLength", {sysid: NvSystemIds.ZSTACK, itemid: id, subid: 0}); 63 | len = lengthRes.payload.len; 64 | } 65 | if (len !== 0) { 66 | console.log(`NVMEM item #${id} - deleting, size: ${len}`); 67 | if (needOsal) { 68 | await this.znp.request(Subsystem.SYS, "osalNvDelete", {id: id, len: len}, undefined, undefined, [ 69 | ZnpCommandStatus.SUCCESS, 70 | ZnpCommandStatus.NV_ITEM_INITIALIZED, 71 | ]); 72 | } else { 73 | await this.znp.request(Subsystem.SYS, "nvDelete", {sysid: NvSystemIds.ZSTACK, itemid: id, subid: 0}, undefined, undefined, [ 74 | ZnpCommandStatus.SUCCESS, 75 | ZnpCommandStatus.NV_ITEM_INITIALIZED, 76 | ]); 77 | } 78 | deletedCount++; 79 | } 80 | } 81 | console.log(`Clearing all NVMEM items finished, deleted ${deletedCount} items`); 82 | } 83 | } 84 | const processArgs = process.argv.slice(2); 85 | if (processArgs.length !== 1) { 86 | console.log("ZStack NVMEM eraser."); 87 | console.log("Usage:"); 88 | console.log(" node zStackEraseAllNvMem.js "); 89 | process.exit(1); 90 | } 91 | 92 | const eraser = new ZStackNvMemEraser(processArgs[0]); 93 | 94 | eraser.start(); 95 | -------------------------------------------------------------------------------- /lib/extension/onEvent.ts: -------------------------------------------------------------------------------- 1 | import {onEvent, type OnEvent as ZhcOnEvent} from "zigbee-herdsman-converters"; 2 | 3 | import utils from "../util/utils"; 4 | import Extension from "./extension"; 5 | 6 | /** 7 | * This extension calls the zigbee-herdsman-converters onEvent. 8 | */ 9 | export default class OnEvent extends Extension { 10 | readonly #startCalled: Set = new Set(); 11 | 12 | #getOnEventBaseData(device: Device): ZhcOnEvent.BaseData { 13 | const deviceExposesChanged = (): void => this.eventBus.emitExposesAndDevicesChanged(device); 14 | const state = this.state.get(device); 15 | const options = device.options as KeyValue; 16 | return {deviceExposesChanged, device: device.zh, state, options}; 17 | } 18 | 19 | // biome-ignore lint/suspicious/useAwait: API 20 | override async start(): Promise { 21 | for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) { 22 | // don't await, in case of repeated failures this would hold startup 23 | this.callOnEvent(device, {type: "start", data: this.#getOnEventBaseData(device)}).catch(utils.noop); 24 | } 25 | 26 | this.eventBus.onDeviceJoined(this, async (data) => { 27 | await this.callOnEvent(data.device, {type: "deviceJoined", data: this.#getOnEventBaseData(data.device)}); 28 | }); 29 | this.eventBus.onDeviceLeave(this, async (data) => { 30 | if (data.device) { 31 | await this.callOnEvent(data.device, {type: "stop", data: {ieeeAddr: data.device.ieeeAddr}}); 32 | } 33 | }); 34 | this.eventBus.onEntityRemoved(this, async (data) => { 35 | if (data.entity.isDevice()) { 36 | await this.callOnEvent(data.entity, {type: "stop", data: {ieeeAddr: data.entity.ieeeAddr}}); 37 | } 38 | }); 39 | this.eventBus.onDeviceInterview(this, async (data) => { 40 | await this.callOnEvent(data.device, {type: "deviceInterview", data: {...this.#getOnEventBaseData(data.device), status: data.status}}); 41 | }); 42 | this.eventBus.onDeviceAnnounce(this, async (data) => { 43 | await this.callOnEvent(data.device, {type: "deviceAnnounce", data: this.#getOnEventBaseData(data.device)}); 44 | }); 45 | this.eventBus.onDeviceNetworkAddressChanged(this, async (data) => { 46 | await this.callOnEvent(data.device, {type: "deviceNetworkAddressChanged", data: this.#getOnEventBaseData(data.device)}); 47 | }); 48 | this.eventBus.onEntityOptionsChanged(this, async (data) => { 49 | if (data.entity.isDevice()) { 50 | await this.callOnEvent(data.entity, { 51 | type: "deviceOptionsChanged", 52 | data: {...this.#getOnEventBaseData(data.entity), from: data.from, to: data.to}, 53 | }); 54 | this.eventBus.emitDevicesChanged(); 55 | } 56 | }); 57 | } 58 | 59 | override async stop(): Promise { 60 | await super.stop(); 61 | 62 | for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) { 63 | await this.callOnEvent(device, {type: "stop", data: {ieeeAddr: device.ieeeAddr}}); 64 | } 65 | } 66 | 67 | private async callOnEvent(device: Device, event: ZhcOnEvent.Event): Promise { 68 | if (device.options.disabled) { 69 | return; 70 | } 71 | 72 | if (event.type === "start") { 73 | this.#startCalled.add(device.ieeeAddr); 74 | } else if (event.type !== "stop" && !this.#startCalled.has(device.ieeeAddr) && device.definition?.onEvent) { 75 | this.#startCalled.add(device.ieeeAddr); 76 | await device.definition.onEvent({type: "start", data: this.#getOnEventBaseData(device)}); 77 | } 78 | 79 | await onEvent(event); 80 | await device.definition?.onEvent?.(event); 81 | 82 | if (event.type === "stop") { 83 | this.#startCalled.delete(device.ieeeAddr); 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /lib/state.ts: -------------------------------------------------------------------------------- 1 | import {existsSync, readFileSync, writeFileSync} from "node:fs"; 2 | 3 | import objectAssignDeep from "object-assign-deep"; 4 | 5 | import data from "./util/data"; 6 | import logger from "./util/logger"; 7 | import * as settings from "./util/settings"; 8 | import utils from "./util/utils"; 9 | 10 | const SAVE_INTERVAL = 1000 * 60 * 5; // 5 minutes 11 | const CACHE_IGNORE_PROPERTIES = [ 12 | "action", 13 | "action_.*", 14 | "button", 15 | "button_left", 16 | "button_right", 17 | "forgotten", 18 | "keyerror", 19 | "step_size", 20 | "transition_time", 21 | "group_list", 22 | "group_capacity", 23 | "no_occupancy_since", 24 | "step_mode", 25 | "transition_time", 26 | "duration", 27 | "elapsed", 28 | "from_side", 29 | "to_side", 30 | "illuminance_lux", // removed in z2m 2.0.0 31 | ]; 32 | 33 | class State { 34 | private readonly state = new Map(); 35 | private readonly file = data.joinPath("state.json"); 36 | private timer?: NodeJS.Timeout; 37 | 38 | constructor( 39 | private readonly eventBus: EventBus, 40 | private readonly zigbee: Zigbee, 41 | ) { 42 | this.eventBus = eventBus; 43 | this.zigbee = zigbee; 44 | } 45 | 46 | start(): void { 47 | this.load(); 48 | 49 | // Save the state on every interval 50 | this.timer = setInterval(() => this.save(), SAVE_INTERVAL); 51 | } 52 | 53 | stop(): void { 54 | // Remove any invalid states (ie when the device has left the network) when the system is stopped 55 | for (const [key] of this.state) { 56 | if (typeof key === "string" && key.startsWith("0x") && !this.zigbee.resolveEntity(key)) { 57 | // string key = ieeeAddr 58 | this.state.delete(key); 59 | } 60 | } 61 | 62 | clearTimeout(this.timer); 63 | this.save(); 64 | } 65 | 66 | clear(): void { 67 | this.state.clear(); 68 | } 69 | 70 | private load(): void { 71 | this.state.clear(); 72 | 73 | if (existsSync(this.file)) { 74 | try { 75 | const stateObj = JSON.parse(readFileSync(this.file, "utf8")) as KeyValue; 76 | 77 | for (const key in stateObj) { 78 | this.state.set(key.startsWith("0x") ? key : Number.parseInt(key, 10), stateObj[key]); 79 | } 80 | 81 | logger.debug(`Loaded state from file ${this.file}`); 82 | } catch (error) { 83 | logger.debug(`Failed to load state from file ${this.file} (corrupt file?) (${(error as Error).message})`); 84 | } 85 | } else { 86 | logger.debug(`Can't load state from file ${this.file} (doesn't exist)`); 87 | } 88 | } 89 | 90 | private save(): void { 91 | if (settings.get().advanced.cache_state_persistent) { 92 | logger.debug(`Saving state to file ${this.file}`); 93 | 94 | const json = JSON.stringify(Object.fromEntries(this.state), null, 4); 95 | 96 | try { 97 | writeFileSync(this.file, json, "utf8"); 98 | } catch (error) { 99 | logger.error(`Failed to write state to '${this.file}' (${error})`); 100 | } 101 | } else { 102 | logger.debug("Not saving state"); 103 | } 104 | } 105 | 106 | exists(entity: Device | Group): boolean { 107 | return this.state.has(entity.ID); 108 | } 109 | 110 | get(entity: Group | Device): KeyValue { 111 | return this.state.get(entity.ID) || {}; 112 | } 113 | 114 | set(entity: Group | Device, update: KeyValue, reason?: string): KeyValue { 115 | const fromState = this.state.get(entity.ID) || {}; 116 | const toState = objectAssignDeep({}, fromState, update); 117 | const newCache = {...toState}; 118 | const entityDontCacheProperties = entity.options.filtered_cache || []; 119 | 120 | utils.filterProperties(CACHE_IGNORE_PROPERTIES.concat(entityDontCacheProperties), newCache); 121 | 122 | this.state.set(entity.ID, newCache); 123 | this.eventBus.emitStateChange({entity, from: fromState, to: toState, reason, update}); 124 | return toState; 125 | } 126 | 127 | remove(id: string | number): boolean { 128 | return this.state.delete(id); 129 | } 130 | } 131 | 132 | export default State; 133 | -------------------------------------------------------------------------------- /lib/types/types.d.ts: -------------------------------------------------------------------------------- 1 | import type {AdapterTypes as ZHAdapterTypes, Events as ZHEvents, Models as ZHModels} from "zigbee-herdsman"; 2 | import type {Cluster as ZHCluster, FrameControl as ZHFrameControl} from "zigbee-herdsman/dist/zspec/zcl/definition/tstype"; 3 | 4 | import type TypeEventBus from "../eventBus"; 5 | import type TypeExtension from "../extension/extension"; 6 | import type TypeDevice from "../model/device"; 7 | import type TypeGroup from "../model/group"; 8 | import type TypeMqtt from "../mqtt"; 9 | import type {MqttPublishOptions} from "../mqtt"; 10 | import type TypeState from "../state"; 11 | import type TypeZigbee from "../zigbee"; 12 | import type {Zigbee2MQTTDeviceOptions, Zigbee2MQTTGroupOptions, Zigbee2MQTTSettings} from "./api"; 13 | 14 | declare global { 15 | // Define some class types as global 16 | type EventBus = TypeEventBus; 17 | type Mqtt = TypeMqtt; 18 | type Zigbee = TypeZigbee; 19 | type Group = TypeGroup; 20 | type Device = TypeDevice; 21 | type State = TypeState; 22 | type Extension = TypeExtension; 23 | 24 | // Types 25 | type StateChangeReason = "publishDebounce" | "groupOptimistic" | "lastSeenChanged" | "publishCached" | "publishThrottle"; 26 | type PublishEntityState = (entity: Device | Group, payload: KeyValue, stateChangeReason?: StateChangeReason) => Promise; 27 | type RecursivePartial = {[P in keyof T]?: RecursivePartial}; 28 | type MakePartialExcept = Partial> & Pick; 29 | interface KeyValue { 30 | // biome-ignore lint/suspicious/noExplicitAny: API 31 | [s: string]: any; 32 | } 33 | 34 | // zigbee-herdsman 35 | namespace zh { 36 | type Endpoint = ZHModels.Endpoint; 37 | type Device = ZHModels.Device; 38 | type Group = ZHModels.Group; 39 | type CoordinatorVersion = ZHAdapterTypes.CoordinatorVersion; 40 | type NetworkParameters = ZHAdapterTypes.NetworkParameters; 41 | interface Bind { 42 | cluster: ZHCluster; 43 | target: zh.Endpoint | zh.Group; 44 | } 45 | } 46 | 47 | namespace eventdata { 48 | type EntityRenamed = {entity: Device | Group; homeAssisantRename: boolean; from: string; to: string}; 49 | type EntityRemoved = {entity: Device | Group; name: string}; 50 | type MQTTMessage = {topic: string; message: string}; 51 | type MQTTMessagePublished = {topic: string; payload: string; options: MqttPublishOptions}; 52 | type StateChange = { 53 | entity: Device | Group; 54 | from: KeyValue; 55 | to: KeyValue; 56 | reason?: string; 57 | update: KeyValue; 58 | }; 59 | type PermitJoinChanged = ZHEvents.PermitJoinChangedPayload; 60 | type LastSeenChanged = { 61 | device: Device; 62 | reason: "deviceAnnounce" | "networkAddress" | "deviceJoined" | "messageEmitted" | "messageNonEmitted"; 63 | }; 64 | type DeviceNetworkAddressChanged = {device: Device}; 65 | type DeviceAnnounce = {device: Device}; 66 | type DeviceInterview = {device: Device; status: "started" | "successful" | "failed"}; 67 | type DeviceJoined = {device: Device}; 68 | type EntityOptionsChanged = {entity: Device | Group; from: KeyValue; to: KeyValue}; 69 | type ExposesChanged = {device: Device}; 70 | type Reconfigure = {device: Device}; 71 | type DeviceLeave = {ieeeAddr: string; name: string; device?: Device}; 72 | type GroupMembersChanged = {group: Group; action: "remove" | "add" | "remove_all"; endpoint: zh.Endpoint; skipDisableReporting: boolean}; 73 | type PublishEntityState = {entity: Group | Device; message: KeyValue; stateChangeReason?: StateChangeReason; payload: KeyValue}; 74 | type DeviceMessage = { 75 | type: ZHEvents.MessagePayloadType; 76 | device: Device; 77 | endpoint: zh.Endpoint; 78 | linkquality: number; 79 | groupID: number; // XXX: should this be `?` 80 | cluster: string | number; 81 | data: KeyValue | Array; 82 | meta: {zclTransactionSequenceNumber?: number; manufacturerCode?: number; frameControl?: ZHFrameControl; rawData: Buffer}; 83 | }; 84 | type ScenesChanged = {entity: Device | Group}; 85 | } 86 | 87 | // Settings 88 | type Settings = Zigbee2MQTTSettings; 89 | 90 | type DeviceOptions = Zigbee2MQTTDeviceOptions; 91 | 92 | interface DeviceOptionsWithId extends DeviceOptions { 93 | ID: string; 94 | } 95 | 96 | type GroupOptions = Zigbee2MQTTGroupOptions; 97 | } 98 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/problem_report.yaml: -------------------------------------------------------------------------------- 1 | name: Problem report 2 | description: Create a report to help us improve 3 | labels: [problem] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | **IMPORTANT:** Before submitting: 9 | - Zigbee2MQTT fails to start? Read [this](https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start_crashes-runtime.html) 10 | - Raspberry Pi users? Make sure to check [this](https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start_crashes-runtime.html#raspberry-pi-users-use-a-good-power-supply) 11 | - You read the [FAQ](https://www.zigbee2mqtt.io/guide/faq/) 12 | - Are you using an EZSP adapter (e.g. Dongle-E/SkyConnect)? Try the [new driver](https://github.com/Koenkk/zigbee2mqtt/discussions/21462) 13 | - Make sure the bug also occurs in the [dev branch](https://www.zigbee2mqtt.io/advanced/more/switch-to-dev-branch.html) 14 | - Make sure you are using the [latest firmware](https://www.zigbee2mqtt.io/guide/adapters/#recommended) on your adapter 15 | - The issue has not been [reported already](https://github.com/Koenkk/zigbee2mqtt/issues) 16 | - Is your issue related to the frontend? Then click [here](https://github.com/nurikk/zigbee2mqtt-frontend/issues/new?assignees=&labels=bug%2Ctriage&template=bug_report.yaml&title=%5BBug%5D%3A+) 17 | - If you are using the Home Assistant addon and are still on 1.18.1, check the first point of [this](https://github.com/Koenkk/zigbee2mqtt/releases/tag/1.19.0) 18 | - type: textarea 19 | id: what_happend 20 | attributes: 21 | label: What happened? 22 | validations: 23 | required: true 24 | - type: textarea 25 | id: expect_to_happen 26 | attributes: 27 | label: What did you expect to happen? 28 | placeholder: I expected that ... 29 | validations: 30 | required: false 31 | - type: textarea 32 | id: reproduce 33 | attributes: 34 | label: How to reproduce it (minimal and precise) 35 | placeholder: First do this, then this.. 36 | validations: 37 | required: false 38 | - type: input 39 | id: z2m_version 40 | attributes: 41 | label: Zigbee2MQTT version 42 | description: Can be found in the frontend -> settings -> about -> Zigbee2MQTT version. Are you running Zigbee2MQTT 1.18.1? Then read [this](https://github.com/Koenkk/zigbee2mqtt/releases/tag/1.19.0). 43 | placeholder: '1.22.1' 44 | validations: 45 | required: true 46 | - type: input 47 | id: adapter_fwversion 48 | attributes: 49 | label: Adapter firmware version 50 | description: Can be found in the frontend -> settings -> about -> coordinator revision 51 | placeholder: '20211210' 52 | validations: 53 | required: true 54 | - type: input 55 | id: adapter 56 | attributes: 57 | label: Adapter 58 | description: The adapter you are using. In case of EZSP, try the [new `ember` driver](https://github.com/Koenkk/zigbee2mqtt/discussions/21462) first. 59 | placeholder: Electrolama zig-a-zig-ah! (zzh!), Slaesh's CC2652RB stick, ... 60 | validations: 61 | required: true 62 | - type: textarea 63 | id: setup 64 | attributes: 65 | label: Setup 66 | description: |- 67 | How do you run Z2M (plain, add-on...) and on what machine (Pi, x86-64, containerized...)? 68 | When running on Linux also include output of: `uname -a && cat /etc/issue.net` 69 | placeholder: Add-on on Home Assistant OS on Intel NUC, Plain on Docker container, ... 70 | validations: 71 | required: true 72 | - type: input 73 | id: device 74 | attributes: 75 | label: Device `database.db` entry 76 | description: If the problem is related to a specific device, paste the `database.db` entry here 77 | placeholder: '{"id":117,"type":"EndDevice","ieeeAddr":"0x00158d0001d8e1d2","nwkAddr":25887 ...' 78 | - type: textarea 79 | id: log 80 | attributes: 81 | label: Debug log 82 | description: After enabling [debug logging](https://www.zigbee2mqtt.io/guide/configuration/logging.html#debugging) the log can be found under `data/log`. Attach the file below 83 | placeholder: Click here and drag the file into it or click on "Attach files by.." below 84 | validations: 85 | required: false 86 | - type: textarea 87 | id: notes 88 | attributes: 89 | label: Notes 90 | description: Any additional information or context that might be helpful 91 | placeholder: Add any relevant notes or context here 92 | validations: 93 | required: false 94 | -------------------------------------------------------------------------------- /lib/model/device.ts: -------------------------------------------------------------------------------- 1 | import assert from "node:assert"; 2 | import {InterviewState} from "zigbee-herdsman/dist/controller/model/device"; 3 | import type {CustomClusters} from "zigbee-herdsman/dist/zspec/zcl/definition/tstype"; 4 | import * as zhc from "zigbee-herdsman-converters"; 5 | import {access, Numeric} from "zigbee-herdsman-converters"; 6 | 7 | import * as settings from "../util/settings"; 8 | 9 | const LINKQUALITY = new Numeric("linkquality", access.STATE) 10 | .withUnit("lqi") 11 | .withDescription("Link quality (signal strength)") 12 | .withValueMin(0) 13 | .withValueMax(255) 14 | .withCategory("diagnostic"); 15 | 16 | export default class Device { 17 | public zh: zh.Device; 18 | public definition?: zhc.Definition; 19 | private _definitionModelID?: string; 20 | 21 | get ieeeAddr(): string { 22 | return this.zh.ieeeAddr; 23 | } 24 | // biome-ignore lint/style/useNamingConvention: API 25 | get ID(): string { 26 | return this.zh.ieeeAddr; 27 | } 28 | get options(): DeviceOptionsWithId { 29 | const deviceOptions = settings.getDevice(this.ieeeAddr) ?? {friendly_name: this.ieeeAddr, ID: this.ieeeAddr}; 30 | return {...settings.get().device_options, ...deviceOptions}; 31 | } 32 | get name(): string { 33 | return this.zh.type === "Coordinator" ? "Coordinator" : this.options?.friendly_name; 34 | } 35 | get isSupported(): boolean { 36 | return this.zh.type === "Coordinator" || Boolean(this.definition && !this.definition.generated); 37 | } 38 | get customClusters(): CustomClusters { 39 | return this.zh.customClusters; 40 | } 41 | get otaExtraMetas(): zhc.Ota.ExtraMetas { 42 | return typeof this.definition?.ota === "object" ? this.definition.ota : {}; 43 | } 44 | get interviewed(): boolean { 45 | return this.zh.interviewState === InterviewState.Successful || this.zh.interviewState === InterviewState.Failed; 46 | } 47 | 48 | constructor(device: zh.Device) { 49 | this.zh = device; 50 | } 51 | 52 | exposes(): zhc.Expose[] { 53 | const exposes: zhc.Expose[] = []; 54 | assert(this.definition, "Cannot retreive exposes before definition is resolved"); 55 | if (typeof this.definition.exposes === "function") { 56 | const options: KeyValue = this.options; 57 | exposes.push(...this.definition.exposes(this.zh, options)); 58 | } else { 59 | exposes.push(...this.definition.exposes); 60 | } 61 | exposes.push(LINKQUALITY); 62 | return exposes; 63 | } 64 | 65 | async resolveDefinition(ignoreCache = false): Promise { 66 | if (this.interviewed && (!this.definition || this._definitionModelID !== this.zh.modelID || ignoreCache)) { 67 | this.definition = await zhc.findByDevice(this.zh, true); 68 | this._definitionModelID = this.zh.modelID; 69 | } 70 | } 71 | 72 | ensureInSettings(): void { 73 | if (this.zh.type !== "Coordinator" && !settings.getDevice(this.zh.ieeeAddr)) { 74 | settings.addDevice(this.zh.ieeeAddr); 75 | } 76 | } 77 | 78 | endpoint(key?: string | number): zh.Endpoint | undefined { 79 | if (!key) { 80 | key = "default"; 81 | } else if (!Number.isNaN(Number(key))) { 82 | return this.zh.getEndpoint(Number(key)); 83 | } 84 | 85 | if (this.definition?.endpoint) { 86 | const ID = this.definition.endpoint(this.zh)[key]; 87 | 88 | if (ID) { 89 | return this.zh.getEndpoint(ID); 90 | } 91 | } 92 | 93 | return key === "default" ? this.zh.endpoints[0] : undefined; 94 | } 95 | 96 | endpointName(endpoint: zh.Endpoint): string | undefined { 97 | let epName: string | undefined; 98 | 99 | if (this.definition?.endpoint) { 100 | const mapping = this.definition.endpoint(this.zh); 101 | 102 | for (const name in mapping) { 103 | if (mapping[name] === endpoint.ID) { 104 | epName = name; 105 | break; 106 | } 107 | } 108 | } 109 | 110 | /* v8 ignore next */ 111 | return epName === "default" ? undefined : epName; 112 | } 113 | 114 | getEndpointNames(): string[] { 115 | const names: string[] = []; 116 | 117 | if (this.definition?.endpoint) { 118 | for (const name in this.definition.endpoint(this.zh)) { 119 | if (name !== "default") { 120 | names.push(name); 121 | } 122 | } 123 | } 124 | 125 | return names; 126 | } 127 | 128 | isDevice(): this is Device { 129 | return true; 130 | } 131 | 132 | isGroup(): this is Group { 133 | return false; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /scripts/zigbee2socat_installer.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ###################################### 4 | ## INITIALIZE ## 5 | ###################################### 6 | 7 | set -o errexit -o pipefail -o noclobber -o nounset 8 | 9 | ! getopt --test > /dev/null 10 | if [[ ${PIPESTATUS[0]} -ne 4 ]]; then 11 | echo "Unfortunately getopt failed in this environment." 12 | exit 2 13 | fi 14 | 15 | 16 | PROGRAM_NAME="zigbee2socat_installer" 17 | VERSION="1.0" 18 | 19 | 20 | ###################################### 21 | ## PARSE ARGUMENTS ## 22 | ###################################### 23 | 24 | OPTS="a:p:uvmhV" 25 | LONG="addr:,port:,uninstall,verbose,man,help,version" 26 | 27 | # Concern: possible to escape getopt and execute commands as root? 28 | ! PARSED=$(getopt -n $PROGRAM_NAME \ 29 | -o $OPTS \ 30 | -l $LONG \ 31 | -- "$@") 32 | if [[ ${PIPESTATUS[0]} -ne 0 ]]; then 33 | printf "Error parsing arguments. Try %s --help\n" "$PROGRAM_NAME" 34 | exit 3 35 | fi 36 | eval set -- "$PARSED" # Use remaining arguments that weren't parsed 37 | 38 | 39 | function handle_opts { 40 | if [[ $# = 1 ]]; then 41 | usage 42 | fi 43 | while true; do 44 | case $1 in 45 | -a|--addr) 46 | ip=$2; shift 2; continue ;; 47 | 48 | -p|--port) 49 | port=$2; shift 2; continue ;; 50 | 51 | -u|--uninstall) 52 | uninstall ;; 53 | 54 | -v|--verbose) 55 | verbose=1; shift; continue ;; 56 | 57 | -m|--man) 58 | makemanual ;; 59 | 60 | -h|--help) 61 | usage ;; 62 | 63 | -V|--version) 64 | version ;; 65 | 66 | --) # No more arguments to parse 67 | shift; break ;; 68 | 69 | *) 70 | printf "Programming error! Option: %s\n" "$1" 71 | exit 4 ;; 72 | esac;done 73 | } 74 | 75 | 76 | ###################################### 77 | ## START OF MAIN ## 78 | ###################################### 79 | 80 | verbose=0 81 | ip="" 82 | port="" 83 | 84 | function main { 85 | handle_opts "$@" 86 | 87 | if [[ "$ip" = "" ]]; then 88 | echo "IP-address not specified" 89 | exit 5 90 | fi 91 | 92 | # Is the port valid? 93 | if [[ $port -lt 1 || $port -gt 65535 ]]; then 94 | printf "Port %s, is outside range: [1-65535]\n" "$port" 95 | exit 6 96 | fi 97 | 98 | zigbee-socatvusb-install-package 99 | } 100 | 101 | 102 | ###################################### 103 | ## FUNCTIONS BELOW ## 104 | ###################################### 105 | 106 | function zigbee-socatvusb-install-package { 107 | echo "Installing socat:" 108 | sudo apt-get install socat 109 | echo 110 | 111 | echo "Make dir for zigbee vusb" 112 | sudo mkdir -p /opt/zigbee2mqtt/vusb/ || die "Couldn't mkdir /opt/zigbee2mqtt/vusb/" 113 | sudo chown -R pi:pi /opt/zigbee2mqtt/vusb/ || die "Couldn't chown /opt/zigbee2mqtt/vusb/" 114 | 115 | echo "Creating service file zigbee-socatvusb.service" 116 | service_path="/etc/systemd/system/zigbee-socatvusb.service" 117 | 118 | [[ -f $service_path ]] && sudo rm $service_path 119 | echo "[Unit] 120 | Description=socat-vusb 121 | After=network-online.target 122 | 123 | [Service] 124 | User=pi 125 | ExecStart=/usr/bin/socat -d -d pty,raw,echo=0,link=/opt/zigbee2mqtt/vusb/zigbee_cc2530 tcp:$ip:$port,reuseaddr 126 | Restart=always 127 | RestartSec=10 128 | 129 | [Install] 130 | WantedBy=multi-user.target" > $service_path || die "Couldn't create service /etc/systemd/system/zigbee-socatvusb.service" 131 | 132 | sudo systemctl --system daemon-reload 133 | 134 | echo "Installation is now complete" 135 | echo 136 | echo "Service can be started after configuration by running: sudo systemctl start zigbee-socatvusb" 137 | } 138 | 139 | function uninstall { 140 | service_path="systemctl status socat-vusb.service" 141 | [[ -f $service_path ]] && sudo rm $service_path 142 | sudo systemctl --system daemon-reload 143 | echo "Uninstalled successfully" 144 | exit 0 145 | } 146 | 147 | function makemanual { 148 | [[ -f "$PROGRAM_NAME.man" ]] && sudo rm $PROGRAM_NAME.man 149 | help2man -N ./$PROGRAM_NAME.sh > $PROGRAM_NAME.man ; man ./$PROGRAM_NAME.man 150 | exit 0 151 | } 152 | 153 | function usage { 154 | echo -e "\ 155 | \rSetup for development version of Zigbee2Socat 156 | 157 | -a, --addr IP Listen on this IP-address 158 | -p, --port PORT Listen on this port 159 | -u, --uninstall Uninstall zigbee-socatvusb.service 160 | -v, --verbose Print more information 161 | -m, --man Make and display manual 162 | -h, --help Display this help message 163 | 164 | \rOriginal concept by JFLN\ 165 | " 166 | exit 0 167 | } 168 | 169 | function version { 170 | echo "$PROGRAM_NAME $VERSION" 171 | exit 0 172 | } 173 | 174 | function die { 175 | printf "%s\n" "$1" 176 | exit 1 177 | } 178 | 179 | 180 | main "$@" # Call main-function last 181 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement. 63 | All complaints will be reviewed and investigated promptly and fairly. 64 | 65 | All community leaders are obligated to respect the privacy and security of the 66 | reporter of any incident. 67 | 68 | ## Enforcement Guidelines 69 | 70 | Community leaders will follow these Community Impact Guidelines in determining 71 | the consequences for any action they deem in violation of this Code of Conduct: 72 | 73 | ### 1. Correction 74 | 75 | **Community Impact**: Use of inappropriate language or other behavior deemed 76 | unprofessional or unwelcome in the community. 77 | 78 | **Consequence**: A private, written warning from community leaders, providing 79 | clarity around the nature of the violation and an explanation of why the 80 | behavior was inappropriate. A public apology may be requested. 81 | 82 | ### 2. Warning 83 | 84 | **Community Impact**: A violation through a single incident or series 85 | of actions. 86 | 87 | **Consequence**: A warning with consequences for continued behavior. No 88 | interaction with the people involved, including unsolicited interaction with 89 | those enforcing the Code of Conduct, for a specified period of time. This 90 | includes avoiding interactions in community spaces as well as external channels 91 | like social media. Violating these terms may lead to a temporary or 92 | permanent ban. 93 | 94 | ### 3. Temporary Ban 95 | 96 | **Community Impact**: A serious violation of community standards, including 97 | sustained inappropriate behavior. 98 | 99 | **Consequence**: A temporary ban from any sort of interaction or public 100 | communication with the community for a specified period of time. No public or 101 | private interaction with the people involved, including unsolicited interaction 102 | with those enforcing the Code of Conduct, is allowed during this period. 103 | Violating these terms may lead to a permanent ban. 104 | 105 | ### 4. Permanent Ban 106 | 107 | **Community Impact**: Demonstrating a pattern of violation of community 108 | standards, including sustained inappropriate behavior, harassment of an 109 | individual, or aggression toward or disparagement of classes of individuals. 110 | 111 | **Consequence**: A permanent ban from any sort of public interaction within 112 | the community. 113 | 114 | ## Attribution 115 | 116 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 117 | version 2.0, available at 118 | [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. 119 | 120 | Community Impact Guidelines were inspired by 121 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 122 | 123 | For answers to common questions about this code of conduct, see the FAQ at 124 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available 125 | at [https://www.contributor-covenant.org/translations][translations]. 126 | 127 | [homepage]: https://www.contributor-covenant.org 128 | [v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html 129 | [Mozilla CoC]: https://github.com/mozilla/diversity 130 | [FAQ]: https://www.contributor-covenant.org/faq 131 | [translations]: https://www.contributor-covenant.org/translations 132 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const semver = require("semver"); 2 | const engines = require("./package.json").engines; 3 | const fs = require("node:fs"); 4 | const os = require("node:os"); 5 | const path = require("node:path"); 6 | const {exec} = require("node:child_process"); 7 | require("source-map-support").install(); 8 | 9 | let controller; 10 | let stopping = false; 11 | const watchdog = process.env.Z2M_WATCHDOG != null; 12 | let watchdogCount = 0; 13 | let unsolicitedStop = false; 14 | // csv in minutes, default: 1min, 5min, 15min, 30min, 60min 15 | let watchdogDelays = [2000, 60000, 300000, 900000, 1800000, 3600000]; 16 | 17 | if (watchdog && process.env.Z2M_WATCHDOG !== "default") { 18 | if (/^\d+(.\d+)?(,\d+(.\d+)?)*$/.test(process.env.Z2M_WATCHDOG)) { 19 | watchdogDelays = process.env.Z2M_WATCHDOG.split(",").map((v) => Number.parseFloat(v) * 60000); 20 | } else { 21 | console.log(`Invalid watchdog delays (must use number-only CSV format representing minutes, example: 'Z2M_WATCHDOG=1,5,15,30,60'.`); 22 | process.exit(1); 23 | } 24 | } 25 | 26 | const hashFile = path.join(__dirname, "dist", ".hash"); 27 | 28 | async function triggerWatchdog(code) { 29 | const delay = watchdogDelays[watchdogCount]; 30 | watchdogCount += 1; 31 | 32 | if (delay) { 33 | // garbage collector 34 | controller = undefined; 35 | 36 | console.log(`WATCHDOG: Waiting ${delay / 60000}min before next start try.`); 37 | await new Promise((resolve) => setTimeout(resolve, delay)); 38 | await start(); 39 | } else { 40 | process.exit(code); 41 | } 42 | } 43 | 44 | async function restart() { 45 | await stop(true); 46 | await start(); 47 | } 48 | 49 | async function exit(code, restart = false) { 50 | if (!restart) { 51 | if (watchdog && unsolicitedStop) { 52 | await triggerWatchdog(code); 53 | } else { 54 | process.exit(code); 55 | } 56 | } 57 | } 58 | 59 | async function currentHash() { 60 | return await new Promise((resolve) => { 61 | exec("git rev-parse --short=8 HEAD", (error, stdout) => { 62 | const commitHash = stdout.trim(); 63 | 64 | if (error || commitHash === "") { 65 | resolve("unknown"); 66 | } else { 67 | resolve(commitHash); 68 | } 69 | }); 70 | }); 71 | } 72 | 73 | async function writeHash() { 74 | const hash = await currentHash(); 75 | 76 | fs.writeFileSync(hashFile, hash); 77 | } 78 | 79 | async function build(reason) { 80 | process.stdout.write(`Building Zigbee2MQTT... (${reason})`); 81 | 82 | return await new Promise((resolve, reject) => { 83 | const env = {...process.env}; 84 | const mb600 = 629145600; 85 | 86 | if (mb600 > os.totalmem() && !env.NODE_OPTIONS) { 87 | // Prevent OOM on tsc compile for system with low memory 88 | // https://github.com/Koenkk/zigbee2mqtt/issues/12034 89 | env.NODE_OPTIONS = "--max_old_space_size=256"; 90 | } 91 | 92 | // clean build, prevent failures due to tsc incremental building 93 | exec("pnpm run prepack", {env, cwd: __dirname}, (err) => { 94 | if (err) { 95 | process.stdout.write(", failed\n"); 96 | 97 | if (err.code === 134) { 98 | process.stderr.write("\n\nBuild failed; ran out-of-memory, free some memory (RAM) and start again\n\n"); 99 | } 100 | 101 | reject(err); 102 | } else { 103 | process.stdout.write(", finished\n"); 104 | resolve(); 105 | } 106 | }); 107 | }); 108 | } 109 | 110 | async function checkDist() { 111 | if (!fs.existsSync(hashFile)) { 112 | await build("initial build"); 113 | } 114 | 115 | const distHash = fs.readFileSync(hashFile, "utf8"); 116 | const hash = await currentHash(); 117 | 118 | if (hash !== "unknown" && distHash !== hash) { 119 | await build("hash changed"); 120 | } 121 | } 122 | 123 | async function start() { 124 | console.log(`Starting Zigbee2MQTT ${watchdog ? `with watchdog (${watchdogDelays})` : "without watchdog"}.`); 125 | await checkDist(); 126 | 127 | // gc 128 | { 129 | const version = engines.node; 130 | 131 | if (!semver.satisfies(process.version, version)) { 132 | console.log(`\t\tZigbee2MQTT requires node version ${version}, you are running ${process.version}!\n`); 133 | } 134 | 135 | const {onboard} = require("./dist/util/onboarding"); 136 | 137 | const success = await onboard(); 138 | 139 | if (!success) { 140 | unsolicitedStop = false; 141 | 142 | return await exit(1); 143 | } 144 | } 145 | 146 | const {Controller} = require("./dist/controller"); 147 | controller = new Controller(restart, exit); 148 | 149 | await controller.start(); 150 | 151 | // consider next controller.stop() call as unsolicited, only after successful first start 152 | unsolicitedStop = true; 153 | watchdogCount = 0; // reset 154 | } 155 | 156 | async function stop(restart) { 157 | // `handleQuit` or `restart` never unsolicited 158 | unsolicitedStop = false; 159 | 160 | await controller.stop(restart); 161 | } 162 | 163 | async function handleQuit() { 164 | if (!stopping) { 165 | if (controller) { 166 | stopping = true; 167 | 168 | await stop(false); 169 | } else { 170 | process.exit(0); 171 | } 172 | } 173 | } 174 | 175 | if (require.main === module || require.main.filename.endsWith(`${path.sep}cli.js`)) { 176 | if (process.argv.length === 3 && process.argv[2] === "writehash") { 177 | writeHash(); 178 | } else { 179 | process.on("SIGINT", handleQuit); 180 | process.on("SIGTERM", handleQuit); 181 | start(); 182 | } 183 | } else { 184 | process.on("SIGINT", handleQuit); 185 | process.on("SIGTERM", handleQuit); 186 | 187 | module.exports = {start}; 188 | } 189 | -------------------------------------------------------------------------------- /lib/extension/configure.ts: -------------------------------------------------------------------------------- 1 | import bind from "bind-decorator"; 2 | import stringify from "json-stable-stringify-without-jsonify"; 3 | import * as zhc from "zigbee-herdsman-converters"; 4 | import Device from "../model/device"; 5 | import type {Zigbee2MQTTAPI} from "../types/api"; 6 | import logger from "../util/logger"; 7 | import * as settings from "../util/settings"; 8 | import utils from "../util/utils"; 9 | import Extension from "./extension"; 10 | 11 | /** 12 | * This extension calls the zigbee-herdsman-converters definition configure() method 13 | */ 14 | export default class Configure extends Extension { 15 | private configuring = new Set(); 16 | private attempts: {[s: string]: number} = {}; 17 | private topic = `${settings.get().mqtt.base_topic}/bridge/request/device/configure`; 18 | 19 | @bind private async onReconfigure(data: eventdata.Reconfigure): Promise { 20 | // Disabling reporting unbinds some cluster which could be bound by configure, re-setup. 21 | if (data.device.zh.meta?.configured !== undefined) { 22 | delete data.device.zh.meta.configured; 23 | data.device.zh.save(); 24 | } 25 | 26 | await this.configure(data.device, "reporting_disabled"); 27 | } 28 | 29 | @bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise { 30 | if (data.topic === this.topic) { 31 | const message = utils.parseJSON(data.message, data.message) as Zigbee2MQTTAPI["bridge/request/device/configure"]; 32 | const ID = typeof message === "object" ? message.id : message; 33 | let error: string | undefined; 34 | 35 | if (ID === undefined) { 36 | error = "Invalid payload"; 37 | } else { 38 | const device = this.zigbee.resolveEntity(ID); 39 | 40 | if (!device || !(device instanceof Device)) { 41 | error = `Device '${ID}' does not exist`; 42 | } else if (!device.definition || !device.definition.configure) { 43 | error = `Device '${device.name}' cannot be configured`; 44 | } else { 45 | try { 46 | await this.configure(device, "mqtt_message", true, true); 47 | } catch (e) { 48 | error = `Failed to configure (${(e as Error).message})`; 49 | } 50 | } 51 | } 52 | 53 | const response = utils.getResponse<"bridge/response/device/configure">(message, {id: ID}, error); 54 | 55 | await this.mqtt.publish("bridge/response/device/configure", stringify(response)); 56 | } 57 | } 58 | 59 | override start(): Promise { 60 | setImmediate(async () => { 61 | // Only configure routers on startup, end devices are likely sleeping and 62 | // will reconfigure once they send a message 63 | for (const device of this.zigbee.devicesIterator((d) => d.type === "Router")) { 64 | // Sleep 10 seconds between configuring on startup to not DDoS the coordinator when many devices have to be configured. 65 | await utils.sleep(10); 66 | await this.configure(device, "started"); 67 | } 68 | }); 69 | 70 | this.eventBus.onDeviceJoined(this, async (data) => { 71 | if (data.device.zh.meta.configured !== undefined) { 72 | delete data.device.zh.meta.configured; 73 | data.device.zh.save(); 74 | } 75 | 76 | await this.configure(data.device, "zigbee_event"); 77 | }); 78 | this.eventBus.onDeviceInterview(this, (data) => this.configure(data.device, "zigbee_event")); 79 | this.eventBus.onLastSeenChanged(this, (data) => this.configure(data.device, "zigbee_event")); 80 | this.eventBus.onMQTTMessage(this, this.onMQTTMessage); 81 | this.eventBus.onReconfigure(this, this.onReconfigure); 82 | 83 | return Promise.resolve(); 84 | } 85 | 86 | private async configure( 87 | device: Device, 88 | event: "started" | "zigbee_event" | "reporting_disabled" | "mqtt_message", 89 | force = false, 90 | throwError = false, 91 | ): Promise { 92 | if (!device.definition?.configure) { 93 | return; 94 | } 95 | 96 | if (!force) { 97 | if (device.options.disabled || !device.interviewed) { 98 | return; 99 | } 100 | 101 | if (device.zh.meta?.configured !== undefined) { 102 | return; 103 | } 104 | 105 | // Only configure end devices when it is active, otherwise it will likely fails as they are sleeping. 106 | if (device.zh.type === "EndDevice" && event !== "zigbee_event") { 107 | return; 108 | } 109 | } 110 | 111 | if (this.configuring.has(device.ieeeAddr) || (this.attempts[device.ieeeAddr] >= 3 && !force)) { 112 | return; 113 | } 114 | 115 | this.configuring.add(device.ieeeAddr); 116 | 117 | if (this.attempts[device.ieeeAddr] === undefined) { 118 | this.attempts[device.ieeeAddr] = 0; 119 | } 120 | 121 | logger.info(`Configuring '${device.name}'`); 122 | try { 123 | await device.definition.configure(device.zh, this.zigbee.firstCoordinatorEndpoint(), device.definition); 124 | logger.info(`Successfully configured '${device.name}'`); 125 | device.zh.meta.configured = zhc.getConfigureKey(device.definition); 126 | device.zh.save(); 127 | this.eventBus.emitDevicesChanged(); 128 | } catch (error) { 129 | this.attempts[device.ieeeAddr]++; 130 | const attempt = this.attempts[device.ieeeAddr]; 131 | const msg = `Failed to configure '${device.name}', attempt ${attempt} (${(error as Error).stack})`; 132 | logger.error(msg); 133 | 134 | if (throwError) { 135 | throw error; 136 | } 137 | } finally { 138 | this.configuring.delete(device.ieeeAddr); 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /.github/prompts/create-specification.prompt.md: -------------------------------------------------------------------------------- 1 | --- 2 | mode: 'agent' 3 | description: 'Create a new specification file for the solution, optimized for Generative AI consumption.' 4 | tools: ['changes', 'codebase', 'editFiles', 'extensions', 'fetch', 'githubRepo', 'openSimpleBrowser', 'problems', 'runTasks', 'search', 'searchResults', 'terminalLastCommand', 'terminalSelection', 'testFailure', 'usages', 'vscodeAPI'] 5 | --- 6 | # Create Specification 7 | 8 | Your goal is to create a new specification file for `${input:SpecPurpose}`. 9 | 10 | The specification file must define the requirements, constraints, and interfaces for the solution components in a manner that is clear, unambiguous, and structured for effective use by Generative AIs. Follow established documentation standards and ensure the content is machine-readable and self-contained. 11 | 12 | ## Best Practices for AI-Ready Specifications 13 | 14 | - Use precise, explicit, and unambiguous language. 15 | - Clearly distinguish between requirements, constraints, and recommendations. 16 | - Use structured formatting (headings, lists, tables) for easy parsing. 17 | - Avoid idioms, metaphors, or context-dependent references. 18 | - Define all acronyms and domain-specific terms. 19 | - Include examples and edge cases where applicable. 20 | - Ensure the document is self-contained and does not rely on external context. 21 | 22 | The specification should be saved in the [/spec/](/spec/) directory and named according to the following convention: `spec-[a-z0-9-]+.md`, where the name should be descriptive of the specification's content and starting with the highlevel purpose, which is one of [schema, tool, data, infrastructure, process, architecture, or design]. 23 | 24 | The specification file must be formatted in well formed Markdown. 25 | 26 | Specification files must follow the template below, ensuring that all sections are filled out appropriately. The front matter for the markdown should be structured correctly as per the example following: 27 | 28 | ```md 29 | --- 30 | title: [Concise Title Describing the Specification's Focus] 31 | version: [Optional: e.g., 1.0, Date] 32 | date_created: [YYYY-MM-DD] 33 | last_updated: [Optional: YYYY-MM-DD] 34 | owner: [Optional: Team/Individual responsible for this spec] 35 | tags: [Optional: List of relevant tags or categories, e.g., `infrastructure`, `process`, `design`, `app` etc] 36 | --- 37 | 38 | # Introduction 39 | 40 | [A short concise introduction to the specification and the goal it is intended to achieve.] 41 | 42 | ## 1. Purpose & Scope 43 | 44 | [Provide a clear, concise description of the specification's purpose and the scope of its application. State the intended audience and any assumptions.] 45 | 46 | ## 2. Definitions 47 | 48 | [List and define all acronyms, abbreviations, and domain-specific terms used in this specification.] 49 | 50 | ## 3. Requirements, Constraints & Guidelines 51 | 52 | [Explicitly list all requirements, constraints, rules, and guidelines. Use bullet points or tables for clarity.] 53 | 54 | - **REQ-001**: Requirement 1 55 | - **SEC-001**: Security Requirement 1 56 | - **[3 LETTERS]-001**: Other Requirement 1 57 | - **CON-001**: Constraint 1 58 | - **GUD-001**: Guideline 1 59 | - **PAT-001**: Pattern to follow 1 60 | 61 | ## 4. Interfaces & Data Contracts 62 | 63 | [Describe the interfaces, APIs, data contracts, or integration points. Use tables or code blocks for schemas and examples.] 64 | 65 | ## 5. Acceptance Criteria 66 | 67 | [Define clear, testable acceptance criteria for each requirement using Given-When-Then format where appropriate.] 68 | 69 | - **AC-001**: Given [context], When [action], Then [expected outcome] 70 | - **AC-002**: The system shall [specific behavior] when [condition] 71 | - **AC-003**: [Additional acceptance criteria as needed] 72 | 73 | ## 6. Test Automation Strategy 74 | 75 | [Define the testing approach, frameworks, and automation requirements.] 76 | 77 | - **Test Levels**: Unit, Integration, End-to-End 78 | - **Frameworks**: MSTest, FluentAssertions, Moq (for .NET applications) 79 | - **Test Data Management**: [approach for test data creation and cleanup] 80 | - **CI/CD Integration**: [automated testing in GitHub Actions pipelines] 81 | - **Coverage Requirements**: [minimum code coverage thresholds] 82 | - **Performance Testing**: [approach for load and performance testing] 83 | 84 | ## 7. Rationale & Context 85 | 86 | [Explain the reasoning behind the requirements, constraints, and guidelines. Provide context for design decisions.] 87 | 88 | ## 8. Dependencies & External Integrations 89 | 90 | [Define the external systems, services, and architectural dependencies required for this specification. Focus on **what** is needed rather than **how** it's implemented. Avoid specific package or library versions unless they represent architectural constraints.] 91 | 92 | ### External Systems 93 | - **EXT-001**: [External system name] - [Purpose and integration type] 94 | 95 | ### Third-Party Services 96 | - **SVC-001**: [Service name] - [Required capabilities and SLA requirements] 97 | 98 | ### Infrastructure Dependencies 99 | - **INF-001**: [Infrastructure component] - [Requirements and constraints] 100 | 101 | ### Data Dependencies 102 | - **DAT-001**: [External data source] - [Format, frequency, and access requirements] 103 | 104 | ### Technology Platform Dependencies 105 | - **PLT-001**: [Platform/runtime requirement] - [Version constraints and rationale] 106 | 107 | ### Compliance Dependencies 108 | - **COM-001**: [Regulatory or compliance requirement] - [Impact on implementation] 109 | 110 | **Note**: This section should focus on architectural and business dependencies, not specific package implementations. For example, specify "OAuth 2.0 authentication library" rather than "Microsoft.AspNetCore.Authentication.JwtBearer v6.0.1". 111 | 112 | ## 9. Examples & Edge Cases 113 | 114 | ```code 115 | // Code snippet or data example demonstrating the correct application of the guidelines, including edge cases 116 | ``` 117 | 118 | ## 10. Validation Criteria 119 | 120 | [List the criteria or tests that must be satisfied for compliance with this specification.] 121 | 122 | ## 11. Related Specifications / Further Reading 123 | 124 | [Link to related spec 1] 125 | [Link to relevant external documentation] 126 | 127 | ``` 128 | -------------------------------------------------------------------------------- /test/utils.test.ts: -------------------------------------------------------------------------------- 1 | import {exec} from "node:child_process"; 2 | import fs from "node:fs"; 3 | import path from "node:path"; 4 | import {describe, expect, it, vi} from "vitest"; 5 | import utils, {assertString} from "../lib/util/utils"; 6 | 7 | // keep the implementations, just spy 8 | vi.mock("node:child_process", {spy: true}); 9 | 10 | describe("Utils", () => { 11 | it("Object is empty", () => { 12 | expect(utils.objectIsEmpty({})).toBeTruthy(); 13 | expect(utils.objectIsEmpty({a: 1})).toBeFalsy(); 14 | }); 15 | 16 | it("Object has properties", () => { 17 | expect(utils.objectHasProperties({a: 1, b: 2, c: 3}, ["a", "b"])).toBeTruthy(); 18 | expect(utils.objectHasProperties({a: 1, b: 2, c: 3}, ["a", "b", "d"])).toBeFalsy(); 19 | }); 20 | 21 | it("get Z2M version", async () => { 22 | const readFileSyncSpy = vi.spyOn(fs, "readFileSync"); 23 | const version = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8")).version; 24 | 25 | expect(await utils.getZigbee2MQTTVersion()).toStrictEqual({commitHash: expect.stringMatching(/^(?!unknown)[a-z0-9]{8}$/), version}); 26 | expect(exec).toHaveBeenCalledTimes(1); 27 | 28 | // @ts-expect-error mock spy 29 | exec.mockImplementationOnce((_cmd, cb) => { 30 | cb(null, "abcd1234"); 31 | }); 32 | expect(await utils.getZigbee2MQTTVersion()).toStrictEqual({commitHash: "abcd1234", version}); 33 | 34 | // @ts-expect-error mock spy 35 | exec.mockImplementationOnce((_cmd, cb) => { 36 | cb(null, ""); 37 | }); 38 | // hash file may or may not be present during testing, don't failing matching if not 39 | expect(await utils.getZigbee2MQTTVersion()).toStrictEqual({commitHash: expect.stringMatching(/^(unknown|([a-z0-9]{8}))$/), version}); 40 | 41 | readFileSyncSpy.mockImplementationOnce(() => { 42 | throw new Error("no hash file"); 43 | }); 44 | // @ts-expect-error mock spy 45 | exec.mockImplementationOnce((_cmd, cb) => { 46 | cb(null, ""); 47 | }); 48 | expect(await utils.getZigbee2MQTTVersion()).toStrictEqual({commitHash: "unknown", version}); 49 | 50 | readFileSyncSpy.mockImplementationOnce(() => { 51 | throw new Error("no hash file"); 52 | }); 53 | // @ts-expect-error mock spy 54 | exec.mockImplementationOnce((_cmd, cb) => { 55 | cb(new Error("invalid"), ""); 56 | }); 57 | expect(await utils.getZigbee2MQTTVersion()).toStrictEqual({commitHash: "unknown", version}); 58 | expect(exec).toHaveBeenCalledTimes(5); 59 | }); 60 | 61 | it("Check dependency version", async () => { 62 | const versionHerdsman = JSON.parse( 63 | fs.readFileSync(path.join(__dirname, "..", "node_modules", "zigbee-herdsman", "package.json"), "utf8"), 64 | ).version; 65 | const versionHerdsmanConverters = JSON.parse( 66 | fs.readFileSync(path.join(__dirname, "..", "node_modules", "zigbee-herdsman-converters", "package.json"), "utf8"), 67 | ).version; 68 | expect(await utils.getDependencyVersion("zigbee-herdsman")).toStrictEqual({version: versionHerdsman}); 69 | expect(await utils.getDependencyVersion("zigbee-herdsman-converters")).toStrictEqual({version: versionHerdsmanConverters}); 70 | }); 71 | 72 | it("To local iso string", () => { 73 | const date = new Date("August 19, 1975 23:15:30 UTC+00:00").getTime(); 74 | const getTzOffsetSpy = vi.spyOn(Date.prototype, "getTimezoneOffset"); 75 | getTzOffsetSpy.mockReturnValueOnce(60); 76 | expect(utils.formatDate(date, "ISO_8601_local").toString().endsWith("-01:00")).toBeTruthy(); 77 | getTzOffsetSpy.mockReturnValueOnce(-60); 78 | expect(utils.formatDate(date, "ISO_8601_local").toString().endsWith("+01:00")).toBeTruthy(); 79 | }); 80 | 81 | it("Assert string", () => { 82 | assertString("test", "property"); 83 | expect(() => assertString(1, "property")).toThrow("property is not a string, got number (1)"); 84 | }); 85 | 86 | it("Removes null properties from object", () => { 87 | const obj1 = { 88 | ab: 0, 89 | cd: false, 90 | ef: null, 91 | gh: "", 92 | homeassistant: { 93 | xyz: "mock", 94 | abcd: null, 95 | }, 96 | nested: { 97 | homeassistant: { 98 | abcd: true, 99 | xyz: null, 100 | }, 101 | abc: {}, 102 | def: null, 103 | }, 104 | }; 105 | 106 | utils.removeNullPropertiesFromObject(obj1); 107 | expect(obj1).toStrictEqual({ 108 | ab: 0, 109 | cd: false, 110 | gh: "", 111 | homeassistant: { 112 | xyz: "mock", 113 | }, 114 | nested: { 115 | homeassistant: { 116 | abcd: true, 117 | }, 118 | abc: {}, 119 | }, 120 | }); 121 | 122 | const obj2 = { 123 | ab: 0, 124 | cd: false, 125 | ef: null, 126 | gh: "", 127 | homeassistant: { 128 | xyz: "mock", 129 | abcd: null, 130 | }, 131 | nested: { 132 | homeassistant: { 133 | abcd: true, 134 | xyz: null, 135 | }, 136 | abc: {}, 137 | def: null, 138 | }, 139 | }; 140 | utils.removeNullPropertiesFromObject(obj2, ["homeassistant"]); 141 | expect(obj2).toStrictEqual({ 142 | ab: 0, 143 | cd: false, 144 | gh: "", 145 | homeassistant: { 146 | xyz: "mock", 147 | abcd: null, 148 | }, 149 | nested: { 150 | homeassistant: { 151 | abcd: true, 152 | xyz: null, 153 | }, 154 | abc: {}, 155 | }, 156 | }); 157 | }); 158 | }); 159 | -------------------------------------------------------------------------------- /.github/prompts/update-specification.prompt.md: -------------------------------------------------------------------------------- 1 | --- 2 | mode: 'agent' 3 | description: 'Update an existing specification file for the solution, optimized for Generative AI consumption based on new requirements or updates to any existing code.' 4 | tools: ['changes', 'codebase', 'editFiles', 'extensions', 'fetch', 'githubRepo', 'openSimpleBrowser', 'problems', 'runTasks', 'search', 'searchResults', 'terminalLastCommand', 'terminalSelection', 'testFailure', 'usages', 'vscodeAPI'] 5 | --- 6 | # Update Specification 7 | 8 | Your goal is to update the existing specification file `${file}` based on new requirements or updates to any existing code. 9 | 10 | The specification file must define the requirements, constraints, and interfaces for the solution components in a manner that is clear, unambiguous, and structured for effective use by Generative AIs. Follow established documentation standards and ensure the content is machine-readable and self-contained. 11 | 12 | ## Best Practices for AI-Ready Specifications 13 | 14 | - Use precise, explicit, and unambiguous language. 15 | - Clearly distinguish between requirements, constraints, and recommendations. 16 | - Use structured formatting (headings, lists, tables) for easy parsing. 17 | - Avoid idioms, metaphors, or context-dependent references. 18 | - Define all acronyms and domain-specific terms. 19 | - Include examples and edge cases where applicable. 20 | - Ensure the document is self-contained and does not rely on external context. 21 | 22 | The specification should be saved in the [/spec/](/spec/) directory and named according to the following convention: `[a-z0-9-]+.md`, where the name should be descriptive of the specification's content and starting with the highlevel purpose, which is one of [schema, tool, data, infrastructure, process, architecture, or design]. 23 | 24 | The specification file must be formatted in well formed Markdown. 25 | 26 | Specification files must follow the template below, ensuring that all sections are filled out appropriately. The front matter for the markdown should be structured correctly as per the example following: 27 | 28 | ```md 29 | --- 30 | title: [Concise Title Describing the Specification's Focus] 31 | version: [Optional: e.g., 1.0, Date] 32 | date_created: [YYYY-MM-DD] 33 | last_updated: [Optional: YYYY-MM-DD] 34 | owner: [Optional: Team/Individual responsible for this spec] 35 | tags: [Optional: List of relevant tags or categories, e.g., `infrastructure`, `process`, `design`, `app` etc] 36 | --- 37 | 38 | # Introduction 39 | 40 | [A short concise introduction to the specification and the goal it is intended to achieve.] 41 | 42 | ## 1. Purpose & Scope 43 | 44 | [Provide a clear, concise description of the specification's purpose and the scope of its application. State the intended audience and any assumptions.] 45 | 46 | ## 2. Definitions 47 | 48 | [List and define all acronyms, abbreviations, and domain-specific terms used in this specification.] 49 | 50 | ## 3. Requirements, Constraints & Guidelines 51 | 52 | [Explicitly list all requirements, constraints, rules, and guidelines. Use bullet points or tables for clarity.] 53 | 54 | - **REQ-001**: Requirement 1 55 | - **SEC-001**: Security Requirement 1 56 | - **[3 LETTERS]-001**: Other Requirement 1 57 | - **CON-001**: Constraint 1 58 | - **GUD-001**: Guideline 1 59 | - **PAT-001**: Pattern to follow 1 60 | 61 | ## 4. Interfaces & Data Contracts 62 | 63 | [Describe the interfaces, APIs, data contracts, or integration points. Use tables or code blocks for schemas and examples.] 64 | 65 | ## 5. Acceptance Criteria 66 | 67 | [Define clear, testable acceptance criteria for each requirement using Given-When-Then format where appropriate.] 68 | 69 | - **AC-001**: Given [context], When [action], Then [expected outcome] 70 | - **AC-002**: The system shall [specific behavior] when [condition] 71 | - **AC-003**: [Additional acceptance criteria as needed] 72 | 73 | ## 6. Test Automation Strategy 74 | 75 | [Define the testing approach, frameworks, and automation requirements.] 76 | 77 | - **Test Levels**: Unit, Integration, End-to-End 78 | - **Frameworks**: MSTest, FluentAssertions, Moq (for .NET applications) 79 | - **Test Data Management**: [approach for test data creation and cleanup] 80 | - **CI/CD Integration**: [automated testing in GitHub Actions pipelines] 81 | - **Coverage Requirements**: [minimum code coverage thresholds] 82 | - **Performance Testing**: [approach for load and performance testing] 83 | 84 | ## 7. Rationale & Context 85 | 86 | [Explain the reasoning behind the requirements, constraints, and guidelines. Provide context for design decisions.] 87 | 88 | ## 8. Dependencies & External Integrations 89 | 90 | [Define the external systems, services, and architectural dependencies required for this specification. Focus on **what** is needed rather than **how** it's implemented. Avoid specific package or library versions unless they represent architectural constraints.] 91 | 92 | ### External Systems 93 | - **EXT-001**: [External system name] - [Purpose and integration type] 94 | 95 | ### Third-Party Services 96 | - **SVC-001**: [Service name] - [Required capabilities and SLA requirements] 97 | 98 | ### Infrastructure Dependencies 99 | - **INF-001**: [Infrastructure component] - [Requirements and constraints] 100 | 101 | ### Data Dependencies 102 | - **DAT-001**: [External data source] - [Format, frequency, and access requirements] 103 | 104 | ### Technology Platform Dependencies 105 | - **PLT-001**: [Platform/runtime requirement] - [Version constraints and rationale] 106 | 107 | ### Compliance Dependencies 108 | - **COM-001**: [Regulatory or compliance requirement] - [Impact on implementation] 109 | 110 | **Note**: This section should focus on architectural and business dependencies, not specific package implementations. For example, specify "OAuth 2.0 authentication library" rather than "Microsoft.AspNetCore.Authentication.JwtBearer v6.0.1". 111 | 112 | ## 9. Examples & Edge Cases 113 | 114 | ```code 115 | // Code snippet or data example demonstrating the correct application of the guidelines, including edge cases 116 | ``` 117 | 118 | ## 10. Validation Criteria 119 | 120 | [List the criteria or tests that must be satisfied for compliance with this specification.] 121 | 122 | ## 11. Related Specifications / Further Reading 123 | 124 | [Link to related spec 1] 125 | [Link to relevant external documentation] 126 | 127 | ``` 128 | -------------------------------------------------------------------------------- /test/sd-notify.test.ts: -------------------------------------------------------------------------------- 1 | // biome-ignore assist/source/organizeImports: import mocks first 2 | import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from "vitest"; 3 | import {mockLogger} from "./mocks/logger"; 4 | 5 | import {initSdNotify} from "../lib/util/sd-notify"; 6 | 7 | const mockPlatform = vi.fn(() => "linux"); 8 | 9 | vi.mock("node:os", () => ({ 10 | platform: vi.fn(() => mockPlatform()), 11 | })); 12 | 13 | const mockUnixDgramSocket = { 14 | send: vi.fn(), 15 | }; 16 | const mockCreateSocket = vi.fn(() => { 17 | if (mockPlatform() !== "win32") { 18 | return mockUnixDgramSocket; 19 | } 20 | 21 | throw new Error("Unix datagrams not available on this platform"); 22 | }); 23 | 24 | vi.mock("unix-dgram", () => ({ 25 | createSocket: mockCreateSocket, 26 | })); 27 | 28 | const mocksClear = [ 29 | mockLogger.log, 30 | mockLogger.debug, 31 | mockLogger.info, 32 | mockLogger.warning, 33 | mockLogger.error, 34 | mockUnixDgramSocket.send, 35 | mockCreateSocket, 36 | mockPlatform, 37 | ]; 38 | 39 | describe("sd-notify", () => { 40 | const expectSocketNthSend = (nth: number, message: string): void => { 41 | expect(mockUnixDgramSocket.send).toHaveBeenNthCalledWith(nth, Buffer.from(message), 0, expect.any(Number), "mocked", expect.any(Function)); 42 | }; 43 | 44 | beforeAll(() => { 45 | vi.useFakeTimers(); 46 | }); 47 | 48 | afterAll(() => { 49 | vi.useRealTimers(); 50 | }); 51 | 52 | beforeEach(() => { 53 | for (const mock of mocksClear) mock.mockClear(); 54 | delete process.env.NOTIFY_SOCKET; 55 | delete process.env.WATCHDOG_USEC; 56 | delete process.env.WSL_DISTRO_NAME; 57 | }); 58 | 59 | it("No socket", async () => { 60 | const res = await initSdNotify(); 61 | 62 | expect(mockCreateSocket).toHaveBeenCalledTimes(0); 63 | expect(res).toBeUndefined(); 64 | expect(mockUnixDgramSocket.send).toHaveBeenCalledTimes(0); 65 | }); 66 | 67 | it("Error on unsupported platform", async () => { 68 | // also called by `mockCreateSocket` 69 | mockPlatform.mockImplementationOnce(() => "win32").mockImplementationOnce(() => "win32"); 70 | 71 | process.env.NOTIFY_SOCKET = "mocked"; 72 | const res = await initSdNotify(); 73 | 74 | expect(mockCreateSocket).toHaveBeenCalledTimes(1); 75 | expect(res).toBeUndefined(); 76 | expect(mockUnixDgramSocket.send).toHaveBeenCalledTimes(0); 77 | expect(mockLogger.warning).toHaveBeenCalledWith("NOTIFY_SOCKET env is set: Unix datagrams not available on this platform"); 78 | }); 79 | 80 | it("Error on supported platform", async () => { 81 | // NOTE: `import('unix-dgram')` can also fail in similar way when bindings are missing (not compiled) 82 | mockCreateSocket.mockImplementationOnce(() => { 83 | throw new Error("Error create socket"); 84 | }); 85 | 86 | process.env.NOTIFY_SOCKET = "mocked"; 87 | const res = await initSdNotify(); 88 | 89 | expect(mockCreateSocket).toHaveBeenCalledTimes(1); 90 | expect(res).toBeUndefined(); 91 | expect(mockUnixDgramSocket.send).toHaveBeenCalledTimes(0); 92 | expect(mockLogger.error).toHaveBeenCalledWith("Could not init sd_notify: Error create socket"); 93 | }); 94 | 95 | it("Socket only", async () => { 96 | process.env.NOTIFY_SOCKET = "mocked"; 97 | const res = await initSdNotify(); 98 | 99 | expect(res).toStrictEqual({notifyStopping: expect.any(Function), stop: expect.any(Function)}); 100 | expect(mockUnixDgramSocket.send).toHaveBeenCalledTimes(1); 101 | expectSocketNthSend(1, "READY=1"); 102 | 103 | await vi.advanceTimersByTimeAsync(7500); 104 | 105 | res!.notifyStopping(); 106 | expect(mockUnixDgramSocket.send).toHaveBeenCalledTimes(2); 107 | expectSocketNthSend(2, "STOPPING=1"); 108 | 109 | res!.stop(); 110 | expect(mockUnixDgramSocket.send).toHaveBeenCalledTimes(2); 111 | }); 112 | 113 | it("Invalid watchdog timeout - socket only", async () => { 114 | process.env.NOTIFY_SOCKET = "mocked"; 115 | process.env.WATCHDOG_USEC = "mocked"; 116 | const res = await initSdNotify(); 117 | 118 | expect(res).toStrictEqual({notifyStopping: expect.any(Function), stop: expect.any(Function)}); 119 | expect(mockUnixDgramSocket.send).toHaveBeenCalledTimes(1); 120 | expectSocketNthSend(1, "READY=1"); 121 | 122 | await vi.advanceTimersByTimeAsync(7500); 123 | 124 | res!.notifyStopping(); 125 | expect(mockUnixDgramSocket.send).toHaveBeenCalledTimes(2); 126 | expectSocketNthSend(2, "STOPPING=1"); 127 | 128 | res!.stop(); 129 | expect(mockUnixDgramSocket.send).toHaveBeenCalledTimes(2); 130 | }); 131 | 132 | it("Socket and watchdog", async () => { 133 | process.env.NOTIFY_SOCKET = "mocked"; 134 | process.env.WATCHDOG_USEC = "10000000"; 135 | const res = await initSdNotify(); 136 | 137 | expect(res).toStrictEqual({notifyStopping: expect.any(Function), stop: expect.any(Function)}); 138 | expect(mockUnixDgramSocket.send).toHaveBeenCalledTimes(1); 139 | expectSocketNthSend(1, "READY=1"); 140 | 141 | await vi.advanceTimersByTimeAsync(7500); 142 | expect(mockUnixDgramSocket.send).toHaveBeenCalledTimes(2); 143 | expectSocketNthSend(2, "WATCHDOG=1"); 144 | 145 | res!.notifyStopping(); 146 | expect(mockUnixDgramSocket.send).toHaveBeenCalledTimes(3); 147 | expectSocketNthSend(3, "STOPPING=1"); 148 | 149 | await vi.advanceTimersByTimeAsync(6000); 150 | 151 | expect(mockUnixDgramSocket.send).toHaveBeenCalledTimes(4); 152 | expectSocketNthSend(4, "WATCHDOG=1"); 153 | 154 | res!.stop(); 155 | expect(mockUnixDgramSocket.send).toHaveBeenCalledTimes(4); 156 | 157 | await vi.advanceTimersByTimeAsync(10000); 158 | expect(mockUnixDgramSocket.send).toHaveBeenCalledTimes(4); 159 | }); 160 | 161 | it("Fails to send", async () => { 162 | mockUnixDgramSocket.send.mockImplementationOnce( 163 | (_buf: Buffer, _offset: number, _length: number, _path: string, callback?: (err?: Error) => void) => { 164 | callback!(new Error("Failure")); 165 | }, 166 | ); 167 | 168 | process.env.NOTIFY_SOCKET = "mocked"; 169 | const res = await initSdNotify(); 170 | 171 | expect(res).toStrictEqual({notifyStopping: expect.any(Function), stop: expect.any(Function)}); 172 | expect(mockUnixDgramSocket.send).toHaveBeenCalledTimes(1); 173 | expectSocketNthSend(1, "READY=1"); 174 | 175 | expect(mockLogger.warning).toHaveBeenCalledWith(`Failed to send "READY=1" to systemd: Failure`); 176 | }); 177 | }); 178 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 |
6 |
7 | 30 |

Zigbee2MQTT 🌉 🐝

31 |

32 | Allows you to use your Zigbee devices without the vendor's bridge or gateway. 33 |

34 |

35 | It bridges events and allows you to control your Zigbee devices via MQTT. In this way you can integrate your Zigbee devices with whatever smart home infrastructure you are using. 36 |

37 |
38 | 39 | ## [Getting started](https://www.zigbee2mqtt.io/guide/getting-started) 40 | 41 | The [documentation](https://www.zigbee2mqtt.io/) provides you all the information needed to get up and running! Make sure you don't skip sections if this is your first visit, as there might be important details in there for you. 42 | 43 | If you aren't familiar with **Zigbee** terminology make sure you [read this](https://www.zigbee2mqtt.io/advanced/zigbee/01_zigbee_network.html) to help you out. 44 | 45 | ## [Integrations](https://www.zigbee2mqtt.io/guide/usage/integrations.html) 46 | 47 | Zigbee2MQTT integrates well with (almost) every home automation solution because it uses MQTT. However the following integrations are worth mentioning: 48 | 49 | ### [Home Assistant](https://www.home-assistant.io/) 50 | 51 | 52 | 53 |
54 | 55 | [Home Assistant OS](https://www.home-assistant.io/installation/) using [the official addon](https://github.com/zigbee2mqtt/hassio-zigbee2mqtt) ([other installations](https://www.zigbee2mqtt.io/guide/usage/integrations/home_assistant.html)) 56 | 57 |
58 | 59 | ### [Homey](https://homey.app/) 60 | 61 | 62 | 63 |
64 | 65 | Integration implemented in the [Homey App](https://homey.app/nl-nl/app/com.gruijter.zigbee2mqtt/) ([documentation & support](https://community.homey.app/t/83214)) 66 | 67 |
68 | 69 | ### [Domoticz](https://www.domoticz.com/) 70 | 71 | 72 | 73 |
74 | 75 | Integration implemented in Domoticz ([documentation](https://www.domoticz.com/wiki/Zigbee2MQTT)). 76 | 77 |
78 | 79 | ### [Gladys Assistant](https://gladysassistant.com/) 80 | 81 | 82 | 83 |
84 | 85 | Integration implemented natively in Gladys Assistant ([documentation](https://gladysassistant.com/docs/integrations/zigbee2mqtt/)). 86 | 87 |
88 | 89 | ### [IoBroker](https://www.iobroker.net/) 90 | 91 | 92 | 93 |
94 | 95 | Integration implemented in IoBroker ([documentation](https://github.com/o0shojo0o/ioBroker.zigbee2mqtt)). 96 | 97 |
98 | 99 | ## Architecture 100 | 101 | ![Architecture](images/architecture-new.png) 102 | 103 | ### Internal Architecture 104 | 105 | Zigbee2MQTT is made up of three modules, each developed in its own Github project. Starting from the hardware (adapter) and moving up; [zigbee-herdsman](https://github.com/koenkk/zigbee-herdsman) connects to your adapter to handle Zigbee communication and makes an API available to the higher levels of the stack. For e.g. Texas Instruments hardware, zigbee-herdsman uses the [TI zStack monitoring and test API](https://github.com/Koenkk/zigbee-herdsman/wiki/References#texas-instruments-zstack) to communicate with the adapter. The module [zigbee-herdsman-converters](https://github.com/koenkk/zigbee-herdsman-converters) handles the mapping from individual device models to the Zigbee clusters they support. [Zigbee clusters](https://github.com/Koenkk/zigbee-herdsman/wiki/References#csa-zigbee-alliance-spec) are the layers of the Zigbee protocol on top of the base protocol that define things like how lights, sensors and switches talk to each other over the Zigbee network. Finally, the Zigbee2MQTT module drives zigbee-herdsman and maps the zigbee messages to MQTT messages. Zigbee2MQTT also keeps track of the state of the system. It uses a `database.db` file to store this state; a text file with a JSON database of connected devices and their capabilities. Zigbee2MQTT provides several web-based interfaces ([zigbee2mqtt-frontend](https://github.com/nurikk/zigbee2mqtt-frontend), [zigbee2mqtt-windfront](https://github.com/Nerivec/zigbee2mqtt-windfront)) that allows monitoring and configuration. 106 | 107 | ### Developing 108 | 109 | Zigbee2MQTT uses TypeScript. 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. 110 | 111 | Before running any of the commands, you'll first need to run `pnpm install --include=dev`. 112 | Before submitting changes run `pnpm run check:w` then `pnpm run test:coverage`. 113 | 114 | ## Supported devices 115 | 116 | See [Supported devices](https://www.zigbee2mqtt.io/supported-devices) to check whether your device is supported. There is quite an extensive list, including devices from vendors like [Xiaomi](https://www.zigbee2mqtt.io/supported-devices/#v=Xiaomi), [Ikea](https://www.zigbee2mqtt.io/supported-devices/#v=IKEA), [Philips](https://www.zigbee2mqtt.io/supported-devices/#v=Philips), [OSRAM](https://www.zigbee2mqtt.io/supported-devices/#v=OSRAM) and more. 117 | 118 | If it's not listed in [Supported devices](https://www.zigbee2mqtt.io/supported-devices), support can be added (fairly) easily, see [How to support new devices](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html). 119 | 120 | ## Support & help 121 | 122 | If you need assistance you can check [opened issues](https://github.com/Koenkk/zigbee2mqtt/issues). Feel free to help with Pull Requests when you were able to fix things or add new devices or just share the love on social media. 123 | -------------------------------------------------------------------------------- /test/extensions/onEvent.test.ts: -------------------------------------------------------------------------------- 1 | // biome-ignore assist/source/organizeImports: import mocks first 2 | import {afterAll, beforeAll, beforeEach, describe, expect, it, vi, assert} from "vitest"; 3 | import * as data from "../mocks/data"; 4 | import {mockLogger} from "../mocks/logger"; 5 | import {mockMQTTPublishAsync} from "../mocks/mqtt"; 6 | import {flushPromises} from "../mocks/utils"; 7 | import type {Device as ZhDevice} from "../mocks/zigbeeHerdsman"; 8 | import {devices, events as mockZHEvents, returnDevices} from "../mocks/zigbeeHerdsman"; 9 | 10 | import type {MockInstance} from "vitest"; 11 | import * as zhc from "zigbee-herdsman-converters"; 12 | import type {OnEvent as ZhcOnEvent} from "zigbee-herdsman-converters/lib/types"; 13 | import {Controller} from "../../lib/controller"; 14 | import OnEvent from "../../lib/extension/onEvent"; 15 | import type Device from "../../lib/model/device"; 16 | import * as settings from "../../lib/util/settings"; 17 | 18 | const mocksClear = [mockMQTTPublishAsync, mockLogger.warning, mockLogger.debug]; 19 | 20 | returnDevices.push(devices.bulb.ieeeAddr, devices.LIVOLO.ieeeAddr, devices.coordinator.ieeeAddr); 21 | 22 | describe("Extension: OnEvent", () => { 23 | let controller: Controller; 24 | let onEventSpy: MockInstance; 25 | let deviceOnEventSpy: MockInstance; 26 | 27 | const getZ2MDevice = (zhDevice: string | number | ZhDevice): Device => { 28 | return controller.zigbee.resolveEntity(zhDevice)! as Device; 29 | }; 30 | 31 | beforeAll(async () => { 32 | vi.useFakeTimers(); 33 | data.writeDefaultConfiguration(); 34 | settings.reRead(); 35 | 36 | controller = new Controller(vi.fn(), vi.fn()); 37 | await controller.start(); 38 | await flushPromises(); 39 | 40 | onEventSpy = vi.spyOn(zhc, "onEvent"); 41 | deviceOnEventSpy = vi.spyOn(getZ2MDevice(devices.LIVOLO).definition!, "onEvent"); 42 | }); 43 | 44 | beforeEach(async () => { 45 | for (const mock of mocksClear) { 46 | mock.mockClear(); 47 | } 48 | 49 | await controller.removeExtension(controller.getExtension("OnEvent")!); 50 | onEventSpy.mockClear(); 51 | deviceOnEventSpy.mockClear(); 52 | await controller.addExtension(new OnEvent(...controller.extensionArgs)); 53 | }); 54 | 55 | afterAll(async () => { 56 | await controller.stop(); 57 | await flushPromises(); 58 | vi.useRealTimers(); 59 | }); 60 | 61 | it("starts & stops", async () => { 62 | expect(onEventSpy).toHaveBeenCalledTimes(2); 63 | expect(deviceOnEventSpy).toHaveBeenCalledTimes(1); 64 | expect(deviceOnEventSpy).toHaveBeenNthCalledWith(1, { 65 | type: "start", 66 | data: { 67 | device: devices.LIVOLO, 68 | options: settings.getDevice(devices.LIVOLO.ieeeAddr), 69 | state: {}, 70 | deviceExposesChanged: expect.any(Function), 71 | }, 72 | }); 73 | 74 | await controller.stop(); 75 | 76 | expect(onEventSpy).toHaveBeenCalledTimes(4); 77 | expect(deviceOnEventSpy).toHaveBeenCalledTimes(2); 78 | expect(deviceOnEventSpy).toHaveBeenNthCalledWith(2, { 79 | type: "stop", 80 | data: { 81 | ieeeAddr: devices.LIVOLO.ieeeAddr, 82 | }, 83 | }); 84 | }); 85 | 86 | it("calls on device events", async () => { 87 | await mockZHEvents.deviceAnnounce({device: devices.LIVOLO}); 88 | await flushPromises(); 89 | 90 | // Should always call with 'start' event first 91 | expect(deviceOnEventSpy).toHaveBeenCalledTimes(2); 92 | expect(deviceOnEventSpy).toHaveBeenNthCalledWith(1, { 93 | type: "start", 94 | data: { 95 | device: devices.LIVOLO, 96 | options: settings.getDevice(devices.LIVOLO.ieeeAddr), 97 | state: {}, 98 | deviceExposesChanged: expect.any(Function), 99 | }, 100 | }); 101 | 102 | // Device announce 103 | expect(deviceOnEventSpy).toHaveBeenCalledTimes(2); 104 | expect(deviceOnEventSpy).toHaveBeenNthCalledWith(2, { 105 | type: "deviceAnnounce", 106 | data: { 107 | device: devices.LIVOLO, 108 | options: settings.getDevice(devices.LIVOLO.ieeeAddr), 109 | state: {}, 110 | deviceExposesChanged: expect.any(Function), 111 | }, 112 | }); 113 | 114 | // Check deviceExposesChanged() 115 | const emitExposesAndDevicesChangedSpy = vi.spyOn( 116 | // @ts-expect-error protected 117 | controller.getExtension("OnEvent")!.eventBus, 118 | "emitExposesAndDevicesChanged", 119 | ); 120 | assert(deviceOnEventSpy.mock.calls[0][0]!.type === "start"); 121 | deviceOnEventSpy.mock.calls[0][0]!.data.deviceExposesChanged(); 122 | expect(emitExposesAndDevicesChangedSpy).toHaveBeenCalledTimes(1); 123 | expect(emitExposesAndDevicesChangedSpy).toHaveBeenCalledWith(getZ2MDevice(devices.LIVOLO)); 124 | 125 | // Call `stop` when device leaves 126 | await mockZHEvents.deviceLeave({ieeeAddr: devices.LIVOLO.ieeeAddr}); 127 | await flushPromises(); 128 | expect(deviceOnEventSpy).toHaveBeenCalledTimes(3); 129 | expect(deviceOnEventSpy).toHaveBeenNthCalledWith(3, { 130 | type: "stop", 131 | data: { 132 | ieeeAddr: devices.LIVOLO.ieeeAddr, 133 | }, 134 | }); 135 | 136 | // Call `stop` when device is removed 137 | // @ts-expect-error private 138 | controller.eventBus.emitEntityRemoved({entity: getZ2MDevice(devices.LIVOLO)}); 139 | await flushPromises(); 140 | expect(deviceOnEventSpy).toHaveBeenCalledTimes(4); 141 | expect(deviceOnEventSpy).toHaveBeenNthCalledWith(4, { 142 | type: "stop", 143 | data: { 144 | ieeeAddr: devices.LIVOLO.ieeeAddr, 145 | }, 146 | }); 147 | 148 | // Device interview, should call with 'start' first as 'stop' was called 149 | await mockZHEvents.deviceInterview({device: devices.LIVOLO, status: "started"}); 150 | await flushPromises(); 151 | expect(deviceOnEventSpy).toHaveBeenCalledTimes(6); 152 | expect(deviceOnEventSpy).toHaveBeenNthCalledWith(5, { 153 | type: "start", 154 | data: { 155 | device: devices.LIVOLO, 156 | options: settings.getDevice(devices.LIVOLO.ieeeAddr), 157 | state: {}, 158 | deviceExposesChanged: expect.any(Function), 159 | }, 160 | }); 161 | expect(deviceOnEventSpy).toHaveBeenNthCalledWith(6, { 162 | type: "deviceInterview", 163 | data: { 164 | device: devices.LIVOLO, 165 | options: settings.getDevice(devices.LIVOLO.ieeeAddr), 166 | state: {}, 167 | deviceExposesChanged: expect.any(Function), 168 | status: "started", 169 | }, 170 | }); 171 | }); 172 | 173 | it("does not block startup on failure", async () => { 174 | await controller.removeExtension(controller.getExtension("OnEvent")!); 175 | deviceOnEventSpy.mockImplementationOnce(async () => { 176 | await new Promise((resolve) => setTimeout(resolve, 10000)); 177 | throw new Error("Failed"); 178 | }); 179 | await controller.addExtension(new OnEvent(...controller.extensionArgs)); 180 | }); 181 | }); 182 | -------------------------------------------------------------------------------- /.github/prompts/create-agentsmd.prompt.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: "Prompt for generating an AGENTS.md file for a repository" 3 | mode: "agent" 4 | --- 5 | 6 | # Create high‑quality AGENTS.md file 7 | 8 | You are a code agent. Your task is to create a complete, accurate AGENTS.md at the root of this repository that follows the public guidance at https://agents.md/. 9 | 10 | AGENTS.md is an open format designed to provide coding agents with the context and instructions they need to work effectively on a project. 11 | 12 | ## What is AGENTS.md? 13 | 14 | AGENTS.md is a Markdown file that serves as a "README for agents" - a dedicated, predictable place to provide context and instructions to help AI coding agents work on your project. It complements README.md by containing detailed technical context that coding agents need but might clutter a human-focused README. 15 | 16 | ## Key Principles 17 | 18 | - **Agent-focused**: Contains detailed technical instructions for automated tools 19 | - **Complements README.md**: Doesn't replace human documentation but adds agent-specific context 20 | - **Standardized location**: Placed at repository root (or subproject roots for monorepos) 21 | - **Open format**: Uses standard Markdown with flexible structure 22 | - **Ecosystem compatibility**: Works across 20+ different AI coding tools and agents 23 | 24 | ## File Structure and Content Guidelines 25 | 26 | ### 1. Required Setup 27 | 28 | - Create the file as `AGENTS.md` in the repository root 29 | - Use standard Markdown formatting 30 | - No required fields - flexible structure based on project needs 31 | 32 | ### 2. Essential Sections to Include 33 | 34 | #### Project Overview 35 | 36 | - Brief description of what the project does 37 | - Architecture overview if complex 38 | - Key technologies and frameworks used 39 | 40 | #### Setup Commands 41 | 42 | - Installation instructions 43 | - Environment setup steps 44 | - Dependency management commands 45 | - Database setup if applicable 46 | 47 | #### Development Workflow 48 | 49 | - How to start development server 50 | - Build commands 51 | - Watch/hot-reload setup 52 | - Package manager specifics (npm, pnpm, yarn, etc.) 53 | 54 | #### Testing Instructions 55 | 56 | - How to run tests (unit, integration, e2e) 57 | - Test file locations and naming conventions 58 | - Coverage requirements 59 | - Specific test patterns or frameworks used 60 | - How to run subset of tests or focus on specific areas 61 | 62 | #### Code Style Guidelines 63 | 64 | - Language-specific conventions 65 | - Linting and formatting rules 66 | - File organization patterns 67 | - Naming conventions 68 | - Import/export patterns 69 | 70 | #### Build and Deployment 71 | 72 | - Build commands and outputs 73 | - Environment configurations 74 | - Deployment steps and requirements 75 | - CI/CD pipeline information 76 | 77 | ### 3. Optional but Recommended Sections 78 | 79 | #### Security Considerations 80 | 81 | - Security testing requirements 82 | - Secrets management 83 | - Authentication patterns 84 | - Permission models 85 | 86 | #### Monorepo Instructions (if applicable) 87 | 88 | - How to work with multiple packages 89 | - Cross-package dependencies 90 | - Selective building/testing 91 | - Package-specific commands 92 | 93 | #### Pull Request Guidelines 94 | 95 | - Title format requirements 96 | - Required checks before submission 97 | - Review process 98 | - Commit message conventions 99 | 100 | #### Debugging and Troubleshooting 101 | 102 | - Common issues and solutions 103 | - Logging patterns 104 | - Debug configuration 105 | - Performance considerations 106 | 107 | ## Example Template 108 | 109 | Use this as a starting template and customize based on the specific project: 110 | 111 | ```markdown 112 | # AGENTS.md 113 | 114 | ## Project Overview 115 | 116 | [Brief description of the project, its purpose, and key technologies] 117 | 118 | ## Setup Commands 119 | 120 | - Install dependencies: `[package manager] install` 121 | - Start development server: `[command]` 122 | - Build for production: `[command]` 123 | 124 | ## Development Workflow 125 | 126 | - [Development server startup instructions] 127 | - [Hot reload/watch mode information] 128 | - [Environment variable setup] 129 | 130 | ## Testing Instructions 131 | 132 | - Run all tests: `[command]` 133 | - Run unit tests: `[command]` 134 | - Run integration tests: `[command]` 135 | - Test coverage: `[command]` 136 | - [Specific testing patterns or requirements] 137 | 138 | ## Code Style 139 | 140 | - [Language and framework conventions] 141 | - [Linting rules and commands] 142 | - [Formatting requirements] 143 | - [File organization patterns] 144 | 145 | ## Build and Deployment 146 | 147 | - [Build process details] 148 | - [Output directories] 149 | - [Environment-specific builds] 150 | - [Deployment commands] 151 | 152 | ## Pull Request Guidelines 153 | 154 | - Title format: [component] Brief description 155 | - Required checks: `[lint command]`, `[test command]` 156 | - [Review requirements] 157 | 158 | ## Additional Notes 159 | 160 | - [Any project-specific context] 161 | - [Common gotchas or troubleshooting tips] 162 | - [Performance considerations] 163 | ``` 164 | 165 | ## Working Example from agents.md 166 | 167 | Here's a real example from the agents.md website: 168 | 169 | ```markdown 170 | # Sample AGENTS.md file 171 | 172 | ## Dev environment tips 173 | 174 | - Use `pnpm dlx turbo run where ` to jump to a package instead of scanning with `ls`. 175 | - Run `pnpm install --filter ` to add the package to your workspace so Vite, ESLint, and TypeScript can see it. 176 | - Use `pnpm create vite@latest -- --template react-ts` to spin up a new React + Vite package with TypeScript checks ready. 177 | - Check the name field inside each package's package.json to confirm the right name—skip the top-level one. 178 | 179 | ## Testing instructions 180 | 181 | - Find the CI plan in the .github/workflows folder. 182 | - Run `pnpm turbo run test --filter ` to run every check defined for that package. 183 | - From the package root you can just call `pnpm test`. The commit should pass all tests before you merge. 184 | - To focus on one step, add the Vitest pattern: `pnpm vitest run -t ""`. 185 | - Fix any test or type errors until the whole suite is green. 186 | - After moving files or changing imports, run `pnpm lint --filter ` to be sure ESLint and TypeScript rules still pass. 187 | - Add or update tests for the code you change, even if nobody asked. 188 | 189 | ## PR instructions 190 | 191 | - Title format: [] 192 | - Always run `pnpm lint` and `pnpm test` before committing. 193 | ``` 194 | 195 | ## Implementation Steps 196 | 197 | 1. **Analyze the project structure** to understand: 198 | 199 | - Programming languages and frameworks used 200 | - Package managers and build tools 201 | - Testing frameworks 202 | - Project architecture (monorepo, single package, etc.) 203 | 204 | 2. **Identify key workflows** by examining: 205 | 206 | - package.json scripts 207 | - Makefile or other build files 208 | - CI/CD configuration files 209 | - Documentation files 210 | 211 | 3. **Create comprehensive sections** covering: 212 | 213 | - All essential setup and development commands 214 | - Testing strategies and commands 215 | - Code style and conventions 216 | - Build and deployment processes 217 | 218 | 4. **Include specific, actionable commands** that agents can execute directly 219 | 220 | 5. **Test the instructions** by ensuring all commands work as documented 221 | 222 | 6. **Keep it focused** on what agents need to know, not general project information 223 | 224 | ## Best Practices 225 | 226 | - **Be specific**: Include exact commands, not vague descriptions 227 | - **Use code blocks**: Wrap commands in backticks for clarity 228 | - **Include context**: Explain why certain steps are needed 229 | - **Stay current**: Update as the project evolves 230 | - **Test commands**: Ensure all listed commands actually work 231 | - **Consider nested files**: For monorepos, create AGENTS.md files in subprojects as needed 232 | 233 | ## Monorepo Considerations 234 | 235 | For large monorepos: 236 | 237 | - Place a main AGENTS.md at the repository root 238 | - Create additional AGENTS.md files in subproject directories 239 | - The closest AGENTS.md file takes precedence for any given location 240 | - Include navigation tips between packages/projects 241 | 242 | ## Final Notes 243 | 244 | - AGENTS.md works with 20+ AI coding tools including Cursor, Aider, Gemini CLI, and many others 245 | - The format is intentionally flexible - adapt it to your project's needs 246 | - Focus on actionable instructions that help agents understand and work with your codebase 247 | - This is living documentation - update it as your project evolves 248 | 249 | When creating the AGENTS.md file, prioritize clarity, completeness, and actionability. The goal is to give any coding agent enough context to effectively contribute to the project without requiring additional human guidance. 250 | -------------------------------------------------------------------------------- /scripts/generateChangelog.mjs: -------------------------------------------------------------------------------- 1 | import {execSync} from "node:child_process"; 2 | import fs from "node:fs"; 3 | import path from "node:path"; 4 | import process from "node:process"; 5 | import zhc from "zigbee-herdsman-converters"; 6 | import definitionIndex from "zigbee-herdsman-converters/devices/index"; 7 | 8 | const z2mTillVersion = process.argv[2]; 9 | const zhcTillVersion = process.argv[3]; 10 | const zhTillVersion = process.argv[4]; 11 | const frontendTillVersion = process.argv[5]; 12 | const windfrontTillVersion = process.argv[6]; 13 | 14 | const changelogs = [ 15 | { 16 | tillVersion: z2mTillVersion, 17 | project: "koenkk/zigbee2mqtt", 18 | contents: fs.readFileSync(path.join(import.meta.dirname, "..", "CHANGELOG.md"), "utf-8").split("\n"), 19 | }, 20 | { 21 | tillVersion: zhcTillVersion, 22 | project: "koenkk/zigbee-herdsman-converters", 23 | contents: fs 24 | .readFileSync(path.join(import.meta.dirname, "..", "node_modules", "zigbee-herdsman-converters", "CHANGELOG.md"), "utf-8") 25 | .split("\n"), 26 | }, 27 | { 28 | tillVersion: zhTillVersion, 29 | project: "koenkk/zigbee-herdsman", 30 | contents: fs.readFileSync(path.join(import.meta.dirname, "..", "node_modules", "zigbee-herdsman", "CHANGELOG.md"), "utf-8").split("\n"), 31 | }, 32 | { 33 | tillVersion: frontendTillVersion, 34 | project: "nurikk/zigbee2mqtt-frontend", 35 | isFrontend: true, 36 | contents: fs.readFileSync(path.join(import.meta.dirname, "..", "node_modules", "zigbee2mqtt-frontend", "CHANGELOG.md"), "utf-8").split("\n"), 37 | }, 38 | { 39 | tillVersion: windfrontTillVersion, 40 | project: "Nerivec/zigbee2mqtt-windfront", 41 | }, 42 | ]; 43 | 44 | const releaseRe = /## \[(.+)\]/; 45 | const windfrontChangeRe = /^\* (feat|fix): (.+?)(?: by @([^\s]+) in (https:\/\/github\.com\/Nerivec\/zigbee2mqtt-windfront\/pull\/(\d+)))?$/gm; 46 | const changes = {features: [], fixes: [], detect: [], add: [], error: [], frontend: [], windfront: []}; 47 | let context = null; 48 | const changeRe = [ 49 | /^\* (\*\*(.+):\*\*)?(.+)\((\[#\d+\]\(.+\))\) \(\[.+\]\(https:.+\/(.+)\)\)$/, 50 | /^\* (\*\*(.+):\*\*)?(.+)(https:\/\/github\.com.+) \(\[.+\]\(https:.+\/(.+)\)\)$/, 51 | /^\* (\*\*(.+):\*\*)?(.+)() \(\[.+\]\(https:.+\/(.+)\)\)$/, 52 | ]; 53 | const frontendChangeRe = /^\* (\*\*.+:\*\* )()?(.+) \(\[.+\]\(https:.+\/()(.+)\)\)(.+)?$/; 54 | 55 | let commitUserLookup = {}; 56 | const commitUserFile = path.join(import.meta.dirname, "commit-user-lookup.json"); 57 | if (fs.existsSync(commitUserFile)) { 58 | commitUserLookup = JSON.parse(fs.readFileSync(commitUserFile, "utf8")); 59 | } 60 | 61 | const definitions = definitionIndex.default.map((d) => zhc.prepareDefinition(d)); 62 | const whiteLabels = definitions 63 | .filter((d) => d.whiteLabel) 64 | .flatMap((d) => 65 | d.whiteLabel.map((wl) => { 66 | return {model: wl.model, vendor: wl.vendor ?? d.vendor, description: wl.description ?? d.description}; 67 | }), 68 | ); 69 | const capitalizeFirstChar = (str) => str.charAt(0).toUpperCase() + str.slice(1); 70 | 71 | for (const changelog of changelogs) { 72 | if (changelog.project === "Nerivec/zigbee2mqtt-windfront") { 73 | const releaseRsp = await fetch("https://api.github.com/repos/Nerivec/zigbee2mqtt-windfront/releases"); 74 | const releases = await releaseRsp.json(); 75 | for (const release of releases) { 76 | if (release.name === `v${windfrontTillVersion}`) { 77 | break; 78 | } 79 | 80 | let match = windfrontChangeRe.exec(release.body); 81 | while (match !== null) { 82 | const [, type, message, user, prLink, prId] = match; 83 | const entry = `- ${prId && prLink ? `[#${prId}](${prLink}) ` : ""}${type}: ${message} ${user ? `(@${user.split("[")[0]})` : ""}`; 84 | changes.windfront.push(entry); 85 | match = windfrontChangeRe.exec(release.body); 86 | } 87 | } 88 | } else { 89 | for (const line of changelog.contents) { 90 | const releaseMatch = line.match(releaseRe); 91 | const changeMatch = changelog.isFrontend ? line.match(frontendChangeRe) : changeRe.map((re) => line.match(re)).find((e) => e); 92 | if (releaseMatch) { 93 | if (releaseMatch[1] === changelog.tillVersion) { 94 | break; 95 | } 96 | } else if (line === "### Features") { 97 | context = "features"; 98 | } else if (line === "### Bug Fixes") { 99 | context = "fixes"; 100 | } else if (line.startsWith("* **ignore:**")) { 101 | // continue; 102 | } else if (changeMatch) { 103 | let localContext = changelog.isFrontend ? "frontend" : changeMatch[2] ? changeMatch[2] : context; 104 | if (!changes[localContext]) localContext = "error"; 105 | 106 | const commit = changeMatch[5]; 107 | const commitUserKey = `${changelog.project}-${commit} `; 108 | let user = 109 | commitUserKey in commitUserLookup 110 | ? commitUserLookup[commitUserKey] 111 | : execSync(`curl -s https://api.github.com/repos/${changelog.project}/commits/${commit} | jq -r '.author.login'`) 112 | .toString() 113 | .trim(); 114 | if (user !== "null") commitUserLookup[commitUserKey] = user; 115 | const messages = []; 116 | let message = changeMatch[3].trim(); 117 | if (message.endsWith(".")) message = message.substring(0, message.length - 1); 118 | 119 | if (changelog.isFrontend) { 120 | changes[localContext].push( 121 | `- [${commit.slice(0, 7)}](https://github.com/${changelog.project}/commit/${commit}) ${message} (@${user})`, 122 | ); 123 | messages.push(capitalizeFirstChar(message)); 124 | } else { 125 | const otherUser = message.match(/\[@(.+)\]\(https:\/\/github.com\/.+\)/) || message.match(/@(.+)/); 126 | if (otherUser) { 127 | user = otherUser[1]; 128 | message = message.replace(otherUser[0], ""); 129 | } 130 | 131 | if (localContext === "add") { 132 | for (const model of message.split(",")) { 133 | const definition = definitions.find((d) => d.model === model.trim()); 134 | const whiteLabel = whiteLabels.find((d) => d.model === model.trim()); 135 | const match = definition || whiteLabel; 136 | if (match) { 137 | messages.push(`\`${match.model}\` ${match.vendor} ${match.description}`); 138 | } else { 139 | changes.error.push(`${line} (model '${model}' does not exist)`); 140 | } 141 | } 142 | } else { 143 | messages.push(capitalizeFirstChar(message)); 144 | } 145 | 146 | let issue = changeMatch[4].trim(); 147 | if (issue && !issue.startsWith("[#")) issue = `[#${issue.split("/").pop()}](${issue})`; 148 | if (!issue) { 149 | issue = "_NO_ISSUE_"; 150 | localContext = "error"; 151 | } 152 | 153 | for (const message of messages) { 154 | changes[localContext].push(`- ${issue} ${message} (@${user})`); 155 | } 156 | } 157 | } else if (line === "# Changelog" || line === "### ⚠ BREAKING CHANGES" || !line) { 158 | // continue; 159 | } else { 160 | changes.error.push(`- Unmatched line: ${line}`); 161 | } 162 | } 163 | } 164 | } 165 | 166 | let result = ""; 167 | const names = [ 168 | ["features", "Improvements"], 169 | ["fixes", "Fixes"], 170 | ["windfront", "Windfront"], 171 | ["frontend", "Frontend"], 172 | ["add", "New supported devices"], 173 | ["detect", "Fixed device detections"], 174 | ["error", "Changelog generator error"], 175 | ]; 176 | for (const name of names) { 177 | result += `# ${name[1]}\n`; 178 | if (name[0] === "add") { 179 | result += `This release adds support for ${changes.add.length} devices: \n`; 180 | } 181 | 182 | for (const change of changes[name[0]]) { 183 | result += `${change}\n`; 184 | } 185 | 186 | result += "\n"; 187 | } 188 | 189 | fs.writeFileSync(commitUserFile, JSON.stringify(commitUserLookup), "utf-8"); 190 | 191 | console.log(result.trim()); 192 | -------------------------------------------------------------------------------- /lib/extension/receive.ts: -------------------------------------------------------------------------------- 1 | import assert from "node:assert"; 2 | 3 | import bind from "bind-decorator"; 4 | import debounce from "debounce"; 5 | import stringify from "json-stable-stringify-without-jsonify"; 6 | import throttle from "throttleit"; 7 | 8 | import * as zhc from "zigbee-herdsman-converters"; 9 | 10 | import logger from "../util/logger"; 11 | import * as settings from "../util/settings"; 12 | import utils from "../util/utils"; 13 | import Extension from "./extension"; 14 | 15 | type DebounceFunction = (() => void) & {clear(): void} & {flush(): void}; 16 | 17 | export default class Receive extends Extension { 18 | // TODO: move all to `Map` 19 | private elapsed: {[s: string]: number} = {}; 20 | private debouncers: {[s: string]: {payload: KeyValue; publish: DebounceFunction}} = {}; 21 | private throttlers: {[s: string]: {publish: PublishEntityState}} = {}; 22 | 23 | // biome-ignore lint/suspicious/useAwait: API 24 | override async start(): Promise<void> { 25 | this.eventBus.onPublishEntityState(this, this.onPublishEntityState); 26 | this.eventBus.onDeviceMessage(this, this.onDeviceMessage); 27 | } 28 | 29 | @bind onPublishEntityState(data: eventdata.PublishEntityState): void { 30 | /** 31 | * Prevent that outdated properties are being published. 32 | * In case that e.g. the state is currently held back by a debounce and a new state is published 33 | * remove it from the to be send debounced message. 34 | */ 35 | if ( 36 | data.entity.isDevice() && 37 | this.debouncers[data.entity.ieeeAddr] && 38 | data.stateChangeReason !== "publishDebounce" && 39 | data.stateChangeReason !== "lastSeenChanged" 40 | ) { 41 | for (const key of Object.keys(data.payload)) { 42 | delete this.debouncers[data.entity.ieeeAddr].payload[key]; 43 | } 44 | } 45 | } 46 | 47 | publishDebounce(device: Device, payload: KeyValue, time: number, debounceIgnore: string[] | undefined): void { 48 | if (!this.debouncers[device.ieeeAddr]) { 49 | this.debouncers[device.ieeeAddr] = { 50 | payload: {}, 51 | publish: debounce(async () => { 52 | await this.publishEntityState(device, this.debouncers[device.ieeeAddr].payload, "publishDebounce"); 53 | this.debouncers[device.ieeeAddr].payload = {}; 54 | }, time * 1000), 55 | }; 56 | } 57 | 58 | if (this.isPayloadConflicted(payload, this.debouncers[device.ieeeAddr].payload, debounceIgnore)) { 59 | // publish previous payload immediately 60 | this.debouncers[device.ieeeAddr].publish.flush(); 61 | } 62 | 63 | // extend debounced payload with current 64 | this.debouncers[device.ieeeAddr].payload = {...this.debouncers[device.ieeeAddr].payload, ...payload}; 65 | 66 | // Update state cache right away. This makes sure that during debouncing cached state is always up to date. 67 | // ( Update right away as "lastSeenChanged" event might occur while debouncer is still active. 68 | // And if that happens it would cause old message to be published from cache. 69 | // By updating cache we make sure that state cache is always up-to-date. 70 | this.state.set(device, this.debouncers[device.ieeeAddr].payload); 71 | 72 | this.debouncers[device.ieeeAddr].publish(); 73 | } 74 | 75 | async publishThrottle(device: Device, payload: KeyValue, time: number): Promise<void> { 76 | if (!this.throttlers[device.ieeeAddr]) { 77 | this.throttlers[device.ieeeAddr] = { 78 | publish: throttle(this.publishEntityState, time * 1000), 79 | }; 80 | } 81 | 82 | // Update state cache right away. This makes sure that during throttling cached state is always up to date. 83 | // By updating cache we make sure that state cache is always up-to-date. 84 | this.state.set(device, payload); 85 | 86 | await this.throttlers[device.ieeeAddr].publish(device, payload, "publishThrottle"); 87 | } 88 | 89 | // if debounce_ignore are specified (Array of strings) 90 | // then all newPayload values with key present in debounce_ignore 91 | // should equal or be undefined in oldPayload 92 | // otherwise payload is conflicted 93 | isPayloadConflicted(newPayload: KeyValue, oldPayload: KeyValue, debounceIgnore: string[] | undefined): boolean { 94 | let result = false; 95 | 96 | for (const key in oldPayload) { 97 | if (debounceIgnore?.includes(key) && typeof newPayload[key] !== "undefined" && newPayload[key] !== oldPayload[key]) { 98 | result = true; 99 | } 100 | } 101 | 102 | return result; 103 | } 104 | 105 | @bind async onDeviceMessage(data: eventdata.DeviceMessage): Promise<void> { 106 | /* v8 ignore next */ 107 | if (!data.device) return; 108 | 109 | if (!data.device.definition || !data.device.interviewed) { 110 | logger.debug("Skipping message, still interviewing"); 111 | await utils.publishLastSeen({device: data.device, reason: "messageEmitted"}, settings.get(), true, this.publishEntityState); 112 | return; 113 | } 114 | 115 | const converters = data.device.definition.fromZigbee.filter((c) => { 116 | const type = Array.isArray(c.type) ? c.type.includes(data.type) : c.type === data.type; 117 | return c.cluster === data.cluster && type; 118 | }); 119 | 120 | // Check if there is an available converter, genOta messages are not interesting. 121 | const ignoreClusters: (string | number)[] = ["genOta", "genTime", "genBasic", "genPollCtrl"]; 122 | if (converters.length === 0 && !ignoreClusters.includes(data.cluster)) { 123 | logger.debug( 124 | `No converter available for '${data.device.definition.model}' with ` + 125 | `cluster '${data.cluster}' and type '${data.type}' and data '${stringify(data.data)}'`, 126 | ); 127 | await utils.publishLastSeen({device: data.device, reason: "messageEmitted"}, settings.get(), true, this.publishEntityState); 128 | return; 129 | } 130 | 131 | // Convert this Zigbee message to a MQTT message. 132 | // Get payload for the message. 133 | // - If a payload is returned publish it to the MQTT broker 134 | // - If NO payload is returned do nothing. This is for non-standard behaviour 135 | // for e.g. click switches where we need to count number of clicks and detect long presses. 136 | const publish = async (payload: KeyValue): Promise<void> => { 137 | assert(data.device.definition); 138 | const options: KeyValue = data.device.options; 139 | zhc.postProcessConvertedFromZigbeeMessage(data.device.definition, payload, options, data.device.zh); 140 | 141 | if (settings.get().advanced.elapsed) { 142 | const now = Date.now(); 143 | if (this.elapsed[data.device.ieeeAddr]) { 144 | payload.elapsed = now - this.elapsed[data.device.ieeeAddr]; 145 | } 146 | 147 | this.elapsed[data.device.ieeeAddr] = now; 148 | } 149 | 150 | // Check if we have to debounce or throttle 151 | if (data.device.options.debounce) { 152 | this.publishDebounce(data.device, payload, data.device.options.debounce, data.device.options.debounce_ignore); 153 | } else if (data.device.options.throttle) { 154 | await this.publishThrottle(data.device, payload, data.device.options.throttle); 155 | } else { 156 | await this.publishEntityState(data.device, payload); 157 | } 158 | }; 159 | 160 | const meta = { 161 | device: data.device.zh, 162 | logger, 163 | state: this.state.get(data.device), 164 | deviceExposesChanged: (): void => this.eventBus.emitExposesAndDevicesChanged(data.device), 165 | }; 166 | let payload: KeyValue = {}; 167 | for (const converter of converters) { 168 | try { 169 | const convertData = {...data, device: data.device.zh}; 170 | const options: KeyValue = data.device.options; 171 | const converted = await converter.convert(data.device.definition, convertData, publish, options, meta); 172 | if (converted) { 173 | payload = {...payload, ...converted}; 174 | } 175 | /* v8 ignore start */ 176 | } catch (error) { 177 | logger.error(`Exception while calling fromZigbee converter: ${(error as Error).message}}`); 178 | // biome-ignore lint/style/noNonNullAssertion: always Error 179 | logger.debug((error as Error).stack!); 180 | } 181 | /* v8 ignore stop */ 182 | } 183 | 184 | if (!utils.objectIsEmpty(payload)) { 185 | await publish(payload); 186 | } else { 187 | await utils.publishLastSeen({device: data.device, reason: "messageEmitted"}, settings.get(), true, this.publishEntityState); 188 | } 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /scripts/issueBot.mjs: -------------------------------------------------------------------------------- 1 | import {execSync} from "node:child_process"; 2 | 3 | async function checkDuplicateIssue(github, context, name) { 4 | // Search for existing issues with the same `name` 5 | const searchQuery = `repo:${context.repo.owner}/${context.repo.repo} is:issue -is:pr "${name}" label:"new device support","external converter"`; 6 | 7 | try { 8 | const searchResults = await github.rest.search.issuesAndPullRequests({q: searchQuery, per_page: 100}); 9 | 10 | // Filter out the current issue and return the first duplicate found 11 | const existingIssues = searchResults.data.items.filter((item) => item.number !== context.payload.issue.number).map((i) => `#${i.number}`); 12 | if (existingIssues.length > 0) { 13 | await github.rest.issues.createComment({ 14 | owner: context.repo.owner, 15 | repo: context.repo.repo, 16 | issue_number: context.payload.issue.number, 17 | body: `👋 Hi there! This issue appears to be a duplicate of ${existingIssues.join(", ")} 18 | 19 | This issue will be closed. Please follow the existing issue for updates.`, 20 | }); 21 | 22 | await github.rest.issues.update({ 23 | owner: context.repo.owner, 24 | repo: context.repo.repo, 25 | issue_number: context.payload.issue.number, 26 | state: "closed", 27 | }); 28 | 29 | return true; 30 | } 31 | } catch (error) { 32 | console.error(`Error searching for duplicate issues with ${name}:`, error); 33 | } 34 | 35 | return false; 36 | } 37 | 38 | export async function newDeviceSupport(github, _core, context, zhcDir) { 39 | const issue = context.payload.issue; 40 | // Hide previous bot comments 41 | const comments = await github.rest.issues.listComments({ 42 | owner: context.repo.owner, 43 | repo: context.repo.repo, 44 | issue_number: issue.number, 45 | }); 46 | 47 | for (const comment of comments.data) { 48 | if (comment.user.type === "Bot" && comment.user.login === "github-actions[bot]") { 49 | await github.graphql(`mutation { 50 | minimizeComment(input: {subjectId: "${comment.node_id}", classifier: OUTDATED}) { 51 | clientMutationId 52 | } 53 | }`); 54 | } 55 | } 56 | 57 | const titleAndBody = `${issue.title}\n\n${issue.body ?? ""}`; 58 | 59 | // Check if Tuya manufacturer name is already supported. 60 | const tuyaManufacturerNameRe = /['"](_T\w+_(\w+))['"]/g; 61 | const tuyaManufacturerNames = Array.from(titleAndBody.matchAll(tuyaManufacturerNameRe), (m) => [m[1], m[2]]); 62 | console.log("Found tuyaManufacturerNames", tuyaManufacturerNames); 63 | if (tuyaManufacturerNames.length > 0) { 64 | for (const [fullName, partialName] of tuyaManufacturerNames) { 65 | if (await checkDuplicateIssue(github, context, fullName)) return; 66 | const fullMatch = (() => { 67 | try { 68 | return execSync(`grep -r --include="*.ts" "${fullName}" "${zhcDir}"`, {encoding: "utf8"}); 69 | } catch { 70 | return undefined; 71 | } 72 | })(); 73 | 74 | console.log(`Checking full match for '${fullName}', result: '${fullMatch}'`); 75 | if (fullMatch) { 76 | await github.rest.issues.createComment({ 77 | owner: context.repo.owner, 78 | repo: context.repo.repo, 79 | issue_number: issue.number, 80 | body: `👋 Hi there! The Tuya device with manufacturer name \`${fullName}\` is already supported in the latest dev branch. 81 | See this [guide](https://www.zigbee2mqtt.io/advanced/more/switch-to-dev-branch.html) on how to update, after updating you can remove your external converter. 82 | 83 | In case you created the external converter with the goal to extend or fix an issue with the out-of-the-box support, please submit a pull request. 84 | For instructions on how to create a pull request see the [docs](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html#_4-create-a-pull-request). 85 | If you need help with the process, feel free to ask here and we'll be happy to assist.`, 86 | }); 87 | await github.rest.issues.update({ 88 | owner: context.repo.owner, 89 | repo: context.repo.repo, 90 | issue_number: issue.number, 91 | state: "closed", 92 | }); 93 | 94 | return; 95 | } 96 | 97 | const partialMatch = (() => { 98 | try { 99 | return execSync(`grep -r --include="*.ts" "${partialName}" "${zhcDir}"`, {encoding: "utf8"}); 100 | } catch { 101 | return undefined; 102 | } 103 | })(); 104 | 105 | console.log(`Checking partial match for '${partialName}', result: '${partialMatch}'`); 106 | if (partialMatch) { 107 | const candidates = Array.from(partialMatch.matchAll(tuyaManufacturerNameRe), (m) => m[1]); 108 | 109 | await github.rest.issues.createComment({ 110 | owner: context.repo.owner, 111 | repo: context.repo.repo, 112 | issue_number: issue.number, 113 | body: `👋 Hi there! A similar Tuya device of which the manufacturer name also ends with \`_${partialName}\` is already supported. 114 | This means the device can probably be easily be supported by re-using the existing converter. 115 | 116 | I found the following matches: ${candidates.map((c) => `\`${c}\``).join(", ")} 117 | Try to stop Z2M, change all occurrences of \`${fullName}\` in the \`data/database.db\` to one of the matches above and start Z2M. 118 | 119 | Let us know if it works so we can support this device out-of-the-box!`, 120 | }); 121 | 122 | return; 123 | } 124 | } 125 | } else { 126 | // Check if zigbee model is already supported. 127 | const zigbeeModelRe = /zigbeeModel: \[['"](.+)['"]\]/g; 128 | const zigbeeModels = Array.from(titleAndBody.matchAll(zigbeeModelRe), (m) => m[1]); 129 | 130 | if (zigbeeModels.length > 0) { 131 | for (const zigbeeModel of zigbeeModels) { 132 | if (await checkDuplicateIssue(github, context, fullName)) return; 133 | const fullMatch = (() => { 134 | try { 135 | return execSync(`grep -r --include="*.ts" '"${zigbeeModel}"' "${zhcDir}"`, {encoding: "utf8"}); 136 | } catch { 137 | return undefined; 138 | } 139 | })(); 140 | 141 | if (fullMatch) { 142 | await github.rest.issues.createComment({ 143 | owner: context.repo.owner, 144 | repo: context.repo.repo, 145 | issue_number: issue.number, 146 | body: `👋 Hi there! The device with zigbee model \`${zigbeeModel}\` is already supported in the latest dev branch. 147 | See this [guide](https://www.zigbee2mqtt.io/advanced/more/switch-to-dev-branch.html) on how to update, after updating you can remove your external converter. 148 | 149 | In case you created the external converter with the goal to extend or fix an issue with the out-of-the-box support, please submit a pull request. 150 | For instructions on how to create a pull request see the [docs](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html#_4-create-a-pull-request). 151 | 152 | If you need help with the process, feel free to ask here and we'll be happy to assist.`, 153 | }); 154 | await github.rest.issues.update({ 155 | owner: context.repo.owner, 156 | repo: context.repo.repo, 157 | issue_number: issue.number, 158 | state: "closed", 159 | }); 160 | 161 | return; 162 | } 163 | } 164 | } 165 | } 166 | 167 | // Create a request to pull request comment 168 | await github.rest.issues.createComment({ 169 | owner: context.repo.owner, 170 | repo: context.repo.repo, 171 | issue_number: issue.number, 172 | body: `🙏 Thank you for creating this issue and sharing your external converter! 173 | 174 | In case all features work, please submit a pull request on this repository so the device can be supported out-of-the-box with the next release. 175 | For instructions on how to create a pull request see the [docs](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html#_4-create-a-pull-request). 176 | 177 | If **NOT** all features work, please follow the [How To Support new devices](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html). 178 | ${ 179 | tuyaManufacturerNames.length > 0 180 | ? "Since this is a Tuya also consider [How To Support new Tuya devices](https://www.zigbee2mqtt.io/advanced/support-new-devices/02_support_new_tuya_devices.html)." 181 | : "" 182 | } 183 | 184 | If you need help with the process, feel free to ask here and we'll be happy to assist.`, 185 | }); 186 | } 187 | -------------------------------------------------------------------------------- /test/mocks/data.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import path from "node:path"; 3 | 4 | import stringify from "json-stable-stringify-without-jsonify"; 5 | import tmp from "tmp"; 6 | 7 | import yaml from "../../lib/util/yaml"; 8 | 9 | export const mockDir: string = tmp.dirSync().name; 10 | const configFile = path.join(mockDir, "configuration.yaml"); 11 | const stateFile = path.join(mockDir, "state.json"); 12 | const databaseFile = path.join(mockDir, "database.db"); 13 | 14 | export const DEFAULT_CONFIGURATION = { 15 | homeassistant: {enabled: false}, 16 | frontend: {enabled: false}, 17 | availability: {enabled: false}, 18 | mqtt: { 19 | base_topic: "zigbee2mqtt", 20 | server: "mqtt://localhost", 21 | }, 22 | serial: { 23 | port: "/dev/dummy", 24 | }, 25 | devices: { 26 | "0x18fc2600000d7ae2": { 27 | friendly_name: "bosch_radiator", 28 | }, 29 | "0x18fc2600000d7ae3": { 30 | friendly_name: "bosch_rm230z", 31 | }, 32 | "0x000b57fffec6a5b2": { 33 | retain: true, 34 | friendly_name: "bulb", 35 | description: "this is my bulb", 36 | }, 37 | "0x0017880104e45517": { 38 | retain: true, 39 | friendly_name: "remote", 40 | }, 41 | "0x0017880104e45520": { 42 | retain: false, 43 | friendly_name: "button", 44 | }, 45 | "0x0017880104e45521": { 46 | retain: false, 47 | friendly_name: "button_double_key", 48 | }, 49 | "0x0017880104e45522": { 50 | qos: 1, 51 | retain: false, 52 | friendly_name: "weather_sensor", 53 | }, 54 | "0x0017880104e45523": { 55 | retain: false, 56 | friendly_name: "occupancy_sensor", 57 | }, 58 | "0x0017880104e45524": { 59 | retain: false, 60 | friendly_name: "power_plug", 61 | }, 62 | "0x0017880104e45530": { 63 | retain: false, 64 | friendly_name: "button_double_key_interviewing", 65 | }, 66 | "0x0017880104e45540": { 67 | friendly_name: "ikea_onoff", 68 | }, 69 | "0x000b57fffec6a5b7": { 70 | retain: false, 71 | friendly_name: "bulb_2", 72 | }, 73 | "0x000b57fffec6a5b3": { 74 | retain: false, 75 | friendly_name: "bulb_color", 76 | }, 77 | "0x000b57fffec6a5b4": { 78 | retain: false, 79 | friendly_name: "bulb_color_2", 80 | }, 81 | "0x0017880104e45541": { 82 | retain: false, 83 | friendly_name: "wall_switch", 84 | }, 85 | "0x0017880104e45542": { 86 | retain: false, 87 | friendly_name: "wall_switch_double", 88 | }, 89 | "0x0017880104e45543": { 90 | retain: false, 91 | friendly_name: "led_controller_1", 92 | }, 93 | "0x0017880104e45544": { 94 | retain: false, 95 | friendly_name: "led_controller_2", 96 | }, 97 | "0x0017880104e45545": { 98 | retain: false, 99 | friendly_name: "dimmer_wall_switch", 100 | }, 101 | "0x0017880104e45547": { 102 | retain: false, 103 | friendly_name: "curtain", 104 | }, 105 | "0x0017880104e45548": { 106 | retain: false, 107 | friendly_name: "fan", 108 | }, 109 | "0x0017880104e45549": { 110 | retain: false, 111 | friendly_name: "siren", 112 | }, 113 | "0x0017880104e45529": { 114 | retain: false, 115 | friendly_name: "unsupported2", 116 | }, 117 | "0x0017880104e45550": { 118 | retain: false, 119 | friendly_name: "thermostat", 120 | }, 121 | "0x0017880104e45551": { 122 | retain: false, 123 | friendly_name: "smart vent", 124 | }, 125 | "0x0017880104e45552": { 126 | retain: false, 127 | friendly_name: "j1", 128 | }, 129 | "0x0017880104e45553": { 130 | retain: false, 131 | friendly_name: "bulb_enddevice", 132 | }, 133 | "0x0017880104e45559": { 134 | retain: false, 135 | friendly_name: "cc2530_router", 136 | }, 137 | "0x0017880104e45560": { 138 | retain: false, 139 | friendly_name: "livolo", 140 | }, 141 | "0x90fd9ffffe4b64ae": { 142 | retain: false, 143 | friendly_name: "tradfri_remote", 144 | }, 145 | "0x90fd9ffffe4b64af": { 146 | friendly_name: "roller_shutter", 147 | }, 148 | "0x90fd9ffffe4b64ax": { 149 | friendly_name: "ZNLDP12LM", 150 | }, 151 | "0x90fd9ffffe4b64aa": { 152 | friendly_name: "SP600_OLD", 153 | }, 154 | "0x90fd9ffffe4b64ab": { 155 | friendly_name: "SP600_NEW", 156 | }, 157 | "0x90fd9ffffe4b64ac": { 158 | friendly_name: "MKS-CM-W5", 159 | }, 160 | "0x0017880104e45526": { 161 | friendly_name: "GL-S-007ZS", 162 | }, 163 | "0x0017880104e43559": { 164 | friendly_name: "U202DST600ZB", 165 | }, 166 | "0xf4ce368a38be56a1": { 167 | retain: false, 168 | friendly_name: "zigfred_plus", 169 | front_surface_enabled: "true", 170 | dimmer_1_enabled: "true", 171 | dimmer_1_dimming_enabled: "true", 172 | dimmer_2_enabled: "true", 173 | dimmer_2_dimming_enabled: "true", 174 | dimmer_3_enabled: "true", 175 | dimmer_3_dimming_enabled: "true", 176 | dimmer_4_enabled: "true", 177 | dimmer_4_dimming_enabled: "true", 178 | cover_1_enabled: "true", 179 | cover_1_tilt_enabled: "true", 180 | cover_2_enabled: "true", 181 | cover_2_tilt_enabled: "true", 182 | }, 183 | "0x0017880104e44559": { 184 | friendly_name: "3157100_thermostat", 185 | }, 186 | "0x0017880104a44559": { 187 | friendly_name: "J1_cover", 188 | }, 189 | "0x0017882104a44559": { 190 | friendly_name: "TS0601_thermostat", 191 | }, 192 | "0x0017882104a44560": { 193 | friendly_name: "TS0601_switch", 194 | }, 195 | "0x0017882104a44562": { 196 | friendly_name: "TS0601_cover_switch", 197 | }, 198 | "0x0017882194e45543": { 199 | friendly_name: "QS-Zigbee-D02-TRIAC-2C-LN", 200 | }, 201 | "0x0017880104e45724": { 202 | friendly_name: "GLEDOPTO_2ID", 203 | }, 204 | "0x0017880104e45561": { 205 | friendly_name: "temperature_sensor", 206 | }, 207 | "0x0017880104e45562": { 208 | friendly_name: "heating_actuator", 209 | }, 210 | "0x000b57cdfec6a5b3": { 211 | friendly_name: "hue_twilight", 212 | }, 213 | "0x00124b00cfcf3298": { 214 | friendly_name: "fanbee", 215 | retain: true, 216 | }, 217 | }, 218 | groups: { 219 | 1: { 220 | friendly_name: "group_1", 221 | retain: false, 222 | }, 223 | 2: { 224 | friendly_name: "group_2", 225 | retain: false, 226 | }, 227 | 15071: { 228 | friendly_name: "group_tradfri_remote", 229 | retain: false, 230 | }, 231 | 11: { 232 | friendly_name: "group_with_tradfri", 233 | retain: false, 234 | }, 235 | 12: { 236 | friendly_name: "thermostat_group", 237 | retain: false, 238 | }, 239 | 14: { 240 | friendly_name: "switch_group", 241 | retain: false, 242 | }, 243 | 21: { 244 | friendly_name: "gledopto_group", 245 | }, 246 | 9: { 247 | friendly_name: "ha_discovery_group", 248 | }, 249 | 19: { 250 | friendly_name: "hue_twilight_group", 251 | }, 252 | }, 253 | }; 254 | 255 | export function writeDefaultConfiguration(config: unknown = undefined): void { 256 | config = config || DEFAULT_CONFIGURATION; 257 | 258 | yaml.writeIfChanged(configFile, config); 259 | } 260 | 261 | export function read(): ReturnType<typeof yaml.read> { 262 | return yaml.read(configFile); 263 | } 264 | 265 | export function removeConfiguration(): void { 266 | fs.rmSync(configFile, {force: true}); 267 | } 268 | 269 | export function writeEmptyState(): void { 270 | fs.writeFileSync(stateFile, stringify({})); 271 | } 272 | 273 | export function removeState(): void { 274 | fs.rmSync(stateFile, {force: true}); 275 | } 276 | 277 | export function stateExists(): boolean { 278 | return fs.existsSync(stateFile); 279 | } 280 | 281 | const defaultState = { 282 | "0x000b57fffec6a5b2": { 283 | state: "ON", 284 | brightness: 50, 285 | color_temp: 370, 286 | linkquality: 99, 287 | }, 288 | "0x0017880104e45517": { 289 | brightness: 255, 290 | }, 291 | 1: { 292 | state: "ON", 293 | }, 294 | }; 295 | 296 | export function getDefaultState(): typeof defaultState { 297 | return defaultState; 298 | } 299 | 300 | export function writeDefaultState(): void { 301 | fs.writeFileSync(path.join(mockDir, "state.json"), stringify(defaultState)); 302 | } 303 | 304 | export function writeEmptyDatabase(): void { 305 | fs.writeFileSync(databaseFile, ""); 306 | } 307 | 308 | export function removeDatabase(): void { 309 | fs.rmSync(databaseFile, {force: true}); 310 | } 311 | 312 | vi.mock("../../lib/util/data", async () => { 313 | const nodePath = await vi.importActual<typeof import("node:path")>("node:path"); 314 | 315 | return { 316 | default: { 317 | joinPath: (file: string): string => nodePath.join(mockDir, file), 318 | getPath: (): string => mockDir, 319 | }, 320 | }; 321 | }); 322 | 323 | writeDefaultConfiguration(); 324 | writeDefaultState(); 325 | --------------------------------------------------------------------------------