UNPKG

zigbee-herdsman

Version:

An open source ZigBee gateway solution with node.js.

343 lines (286 loc) 13 kB
import assert from "node:assert"; import events from "node:events"; import {Socket} from "node:net"; import {Queue, Waitress, wait} from "../../../utils"; import {logger} from "../../../utils/logger"; import {ClusterId as ZdoClusterId} from "../../../zspec/zdo"; import {SerialPort} from "../../serialPort"; import SocketPortUtils from "../../socketPortUtils"; import * as Constants from "../constants"; import {Frame as UnpiFrame, Parser as UnpiParser, Writer as UnpiWriter} from "../unpi"; import {Subsystem, Type} from "../unpi/constants"; import Definition from "./definition"; import type {ZpiObjectPayload} from "./tstype"; import {isMtCmdSreqZdo} from "./utils"; import {ZpiObject} from "./zpiObject"; const { COMMON: {ZnpCommandStatus}, Utils: {statusDescription}, } = Constants; const timeouts = { SREQ: 6000, reset: 30000, default: 10000, }; const NS = "zh:zstack:znp"; interface WaitressMatcher { type: Type; subsystem: Subsystem; command: string; target?: number | string; transid?: number; state?: number; } export class Znp extends events.EventEmitter { private path: string; private baudRate: number; private rtscts: boolean; private serialPort?: SerialPort; private socketPort?: Socket; private unpiWriter: UnpiWriter; private unpiParser: UnpiParser; private initialized: boolean; private queue: Queue; private waitress: Waitress<ZpiObject, WaitressMatcher>; public constructor(path: string, baudRate: number, rtscts: boolean) { super(); this.path = path; this.baudRate = typeof baudRate === "number" ? baudRate : 115200; this.rtscts = typeof rtscts === "boolean" ? rtscts : false; this.initialized = false; this.queue = new Queue(); this.waitress = new Waitress<ZpiObject, WaitressMatcher>(this.waitressValidator, this.waitressTimeoutFormatter); this.unpiWriter = new UnpiWriter(); this.unpiParser = new UnpiParser(); } private onUnpiParsed(frame: UnpiFrame): void { try { const object = ZpiObject.fromUnpiFrame(frame); logger.debug(() => `<-- ${object.toString(object.subsystem !== Subsystem.ZDO)}`, NS); this.waitress.resolve(object); this.emit("received", object); } catch (error) { logger.error(`Error while parsing to ZpiObject '${error}'`, NS); } } public isInitialized(): boolean { return this.initialized; } private onPortError(error: Error): void { logger.error(`Port error: ${error}`, NS); } private onPortClose(): void { logger.info("Port closed", NS); this.initialized = false; this.emit("close"); } public async open(): Promise<void> { return SocketPortUtils.isTcpPath(this.path) ? await this.openSocketPort() : await this.openSerialPort(); } private async openSerialPort(): Promise<void> { const options = {path: this.path, baudRate: this.baudRate, rtscts: this.rtscts, autoOpen: false}; logger.info(`Opening SerialPort with ${JSON.stringify(options)}`, NS); this.serialPort = new SerialPort(options); this.unpiWriter.pipe(this.serialPort); this.serialPort.pipe(this.unpiParser); this.unpiParser.on("parsed", this.onUnpiParsed.bind(this)); try { await this.serialPort.asyncOpen(); logger.info("Serialport opened", NS); this.serialPort.once("close", this.onPortClose.bind(this)); this.serialPort.once("error", this.onPortError.bind(this)); this.initialized = true; await this.skipBootloader(); } catch (error) { this.initialized = false; if (this.serialPort.isOpen) { this.serialPort.close(); } throw error; } } private async openSocketPort(): Promise<void> { const info = SocketPortUtils.parseTcpPath(this.path); logger.info(`Opening TCP socket with ${info.host}:${info.port}`, NS); this.socketPort = new Socket(); this.socketPort.setNoDelay(true); this.socketPort.setKeepAlive(true, 15000); this.unpiWriter.pipe(this.socketPort); this.socketPort.pipe(this.unpiParser); this.unpiParser.on("parsed", this.onUnpiParsed.bind(this)); return await new Promise((resolve, reject): void => { // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` this.socketPort!.on("connect", () => { logger.info("Socket connected", NS); }); const self = this; // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` this.socketPort!.on("ready", async () => { logger.info("Socket ready", NS); await self.skipBootloader(); self.initialized = true; resolve(); }); // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` this.socketPort!.once("close", this.onPortClose.bind(this)); // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` this.socketPort!.on("error", (error) => { logger.error(`Socket error ${error}`, NS); reject(new Error("Error while opening socket")); self.initialized = false; }); // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` this.socketPort!.connect(info.port, info.host); }); } private async skipBootloader(): Promise<void> { try { await this.request(Subsystem.SYS, "ping", {capabilities: 1}, undefined, 250); } catch { // Skip bootloader on CC2530/CC2531 // Send magic byte: https://github.com/Koenkk/zigbee2mqtt/issues/1343 to bootloader // and give ZNP 1 second to start. try { logger.info("Writing CC2530/CC2531 skip bootloader payload", NS); this.unpiWriter.writeBuffer(Buffer.from([0xef])); await wait(1000); await this.request(Subsystem.SYS, "ping", {capabilities: 1}, undefined, 250 /* v8 ignore next */); } catch { // Skip bootloader on some CC2652 devices (e.g. zzh-p) logger.info("Skip bootloader for CC2652/CC1352", NS); if (this.serialPort) { await this.serialPort.asyncSet({dtr: false, rts: false}); await wait(150); await this.serialPort.asyncSet({dtr: false, rts: true}); await wait(150); await this.serialPort.asyncSet({dtr: false, rts: false}); await wait(150); } } } } public async close(): Promise<void> { logger.info("closing", NS); this.queue.clear(); if (this.initialized) { this.initialized = false; if (this.serialPort) { try { await this.serialPort.asyncFlushAndClose(); } catch (error) { this.emit("close"); throw error; } } else { // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` this.socketPort!.destroy(); } } this.emit("close"); } public async requestWithReply( subsystem: Subsystem, command: string, payload: ZpiObjectPayload, waiterID?: number, timeout?: number, expectedStatuses: Constants.COMMON.ZnpCommandStatus[] = [ZnpCommandStatus.SUCCESS], ): Promise<ZpiObject> { const reply = await this.request(subsystem, command, payload, waiterID, timeout, expectedStatuses); if (reply === undefined) { throw new Error(`Command ${command} has no reply`); } return reply; } public request( subsystem: Subsystem, command: string, payload: ZpiObjectPayload, waiterID?: number, timeout?: number, expectedStatuses: Constants.COMMON.ZnpCommandStatus[] = [ZnpCommandStatus.SUCCESS], ): Promise<ZpiObject | undefined> { if (!this.initialized) { throw new Error("Cannot request when znp has not been initialized yet"); } const object = ZpiObject.createRequest(subsystem, command, payload); return this.queue.execute<ZpiObject | undefined>(async () => { logger.debug(() => `--> ${object}`, NS); if (object.type === Type.SREQ) { const t = object.command.name === "bdbStartCommissioning" || object.command.name === "startupFromApp" ? 40000 : timeouts.SREQ; const waiter = this.waitress.waitFor({type: Type.SRSP, subsystem: object.subsystem, command: object.command.name}, timeout || t); this.unpiWriter.writeFrame(object.unpiFrame); const result = await waiter.start().promise; if (result?.payload.status !== undefined && !expectedStatuses.includes(result.payload.status)) { if (typeof waiterID === "number") { this.waitress.remove(waiterID); } throw new Error( `--> '${object}' failed with status '${statusDescription( result.payload.status, )}' (expected '${expectedStatuses.map(statusDescription)}')`, ); } return result; } if (object.type === Type.AREQ && object.isResetCommand()) { const waiter = this.waitress.waitFor({type: Type.AREQ, subsystem: Subsystem.SYS, command: "resetInd"}, timeout || timeouts.reset); this.queue.clear(); this.unpiWriter.writeFrame(object.unpiFrame); return await waiter.start().promise; } if (object.type === Type.AREQ) { this.unpiWriter.writeFrame(object.unpiFrame); /* v8 ignore start */ } else { throw new Error(`Unknown type '${object.type}'`); } /* v8 ignore stop */ }); } public requestZdo(clusterId: ZdoClusterId, payload: Buffer, waiterID?: number): Promise<void> { return this.queue.execute(async () => { const cmd = Definition[Subsystem.ZDO].find((c) => isMtCmdSreqZdo(c) && c.zdoClusterId === clusterId); assert(cmd, `Command for ZDO cluster ID '${clusterId}' not supported.`); const unpiFrame = new UnpiFrame(Type.SREQ, Subsystem.ZDO, cmd.ID, payload); const waiter = this.waitress.waitFor({type: Type.SRSP, subsystem: Subsystem.ZDO, command: cmd.name}, timeouts.SREQ); this.unpiWriter.writeFrame(unpiFrame); const result = await waiter.start().promise; if (result?.payload.status !== undefined && result.payload.status !== ZnpCommandStatus.SUCCESS) { if (waiterID !== undefined) { this.waitress.remove(waiterID); } throw new Error( `--> 'SREQ: ZDO - ${ZdoClusterId[clusterId]} - ${payload.toString("hex")}' failed with status '${statusDescription(result.payload.status)}'`, ); } }); } private waitressTimeoutFormatter(matcher: WaitressMatcher, timeout: number): string { return `${Type[matcher.type]} - ${Subsystem[matcher.subsystem]} - ${matcher.command} after ${timeout}ms`; } public waitFor( type: Type, subsystem: Subsystem, command: string, target: number | string | undefined, transid: number | undefined, state: number | undefined, timeout: number = timeouts.default, ): {start: () => {promise: Promise<ZpiObject>; ID: number}; ID: number} { return this.waitress.waitFor({type, subsystem, command, target, transid, state}, timeout); } private waitressValidator(zpiObject: ZpiObject, matcher: WaitressMatcher): boolean { return ( matcher.type === zpiObject.type && matcher.subsystem === zpiObject.subsystem && matcher.command === zpiObject.command.name && (matcher.target === undefined || (typeof matcher.target === "number" ? matcher.target === zpiObject.payload.srcaddr : matcher.target === zpiObject.payload.zdo?.[1]?.eui64)) && (matcher.transid === undefined || matcher.transid === zpiObject.payload.transid) && (matcher.state === undefined || matcher.state === zpiObject.payload.state) ); } }