UNPKG

zigbee-herdsman

Version:

An open source ZigBee gateway solution with node.js.

1,041 lines (905 loc) 41.4 kB
import assert from "node:assert"; import type {Events as AdapterEvents} from "../../adapter"; import {logger} from "../../utils/logger"; import * as ZSpec from "../../zspec"; import {BroadcastAddress} from "../../zspec/enums"; import type {Eui64} from "../../zspec/tstypes"; import * as Zcl from "../../zspec/zcl"; import type * as ZclTypes from "../../zspec/zcl/definition/tstype"; import * as Zdo from "../../zspec/zdo"; import Request from "../helpers/request"; import RequestQueue from "../helpers/requestQueue"; import * as ZclFrameConverter from "../helpers/zclFrameConverter"; import zclTransactionSequenceNumber from "../helpers/zclTransactionSequenceNumber"; import type {KeyValue, SendPolicy} from "../tstype"; import Device from "./device"; import Entity from "./entity"; import Group from "./group"; const NS = "zh:controller:endpoint"; export interface ConfigureReportingItem { attribute: string | number | {ID: number; type: number}; minimumReportInterval: number; maximumReportInterval: number; reportableChange: number; } interface Options { manufacturerCode?: number; disableDefaultResponse?: boolean; disableResponse?: boolean; timeout?: number; direction?: Zcl.Direction; srcEndpoint?: number; reservedBits?: number; transactionSequenceNumber?: number; disableRecovery?: boolean; writeUndiv?: boolean; sendPolicy?: SendPolicy; } interface OptionsWithDefaults extends Options { disableDefaultResponse: boolean; disableResponse: boolean; timeout: number; direction: Zcl.Direction; reservedBits: number; disableRecovery: boolean; writeUndiv: boolean; } interface Clusters { [cluster: string]: { attributes: {[attribute: string]: number | string}; }; } interface BindInternal { cluster: number; type: "endpoint" | "group"; deviceIeeeAddress?: string; endpointID?: number; groupID?: number; } interface Bind { cluster: ZclTypes.Cluster; target: Endpoint | Group; } interface ConfiguredReportingInternal { cluster: number; attrId: number; minRepIntval: number; maxRepIntval: number; repChange: number; manufacturerCode?: number | undefined; } interface ConfiguredReporting { cluster: ZclTypes.Cluster; attribute: ZclTypes.Attribute; minimumReportInterval: number; maximumReportInterval: number; reportableChange: number; } export class Endpoint extends Entity { public deviceID?: number; public inputClusters: number[]; public outputClusters: number[]; public profileID?: number; // biome-ignore lint/style/useNamingConvention: cross-repo impact public readonly ID: number; public readonly clusters: Clusters; public deviceIeeeAddress: string; public deviceNetworkAddress: number; private _binds: BindInternal[]; private _configuredReportings: ConfiguredReportingInternal[]; public meta: KeyValue; private pendingRequests: RequestQueue; // Getters/setters get binds(): Bind[] { const binds: Bind[] = []; for (const bind of this._binds) { // XXX: properties assumed valid when associated to `type` const target: Group | Endpoint | undefined = // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` bind.type === "endpoint" ? Device.byIeeeAddr(bind.deviceIeeeAddress!)?.getEndpoint(bind.endpointID!) : Group.byGroupID(bind.groupID!); if (target) { binds.push({target, cluster: this.getCluster(bind.cluster)}); } } return binds; } get configuredReportings(): ConfiguredReporting[] { const device = this.getDevice(); return this._configuredReportings.map((entry, index) => { const cluster = Zcl.Utils.getCluster(entry.cluster, entry.manufacturerCode, device.customClusters); const attribute: ZclTypes.Attribute = cluster.hasAttribute(entry.attrId) ? cluster.getAttribute(entry.attrId) : {ID: entry.attrId, name: `attr${index}`, type: Zcl.DataType.UNKNOWN, manufacturerCode: undefined}; return { cluster, attribute, minimumReportInterval: entry.minRepIntval, maximumReportInterval: entry.maxRepIntval, reportableChange: entry.repChange, }; }); } private constructor( id: number, profileID: number | undefined, deviceID: number | undefined, inputClusters: number[], outputClusters: number[], deviceNetworkAddress: number, deviceIeeeAddress: string, clusters: Clusters, binds: BindInternal[], configuredReportings: ConfiguredReportingInternal[], meta: KeyValue, ) { super(); this.ID = id; this.profileID = profileID; this.deviceID = deviceID; this.inputClusters = inputClusters; this.outputClusters = outputClusters; this.deviceNetworkAddress = deviceNetworkAddress; this.deviceIeeeAddress = deviceIeeeAddress; this.clusters = clusters; this._binds = binds; this._configuredReportings = configuredReportings; this.meta = meta; this.pendingRequests = new RequestQueue(this); } /** * Get device of this endpoint */ public getDevice(): Device { const device = Device.byIeeeAddr(this.deviceIeeeAddress); if (!device) { logger.error(`Tried to get unknown/deleted device ${this.deviceIeeeAddress} from endpoint ${this.ID}.`, NS); // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` logger.debug(new Error().stack!, NS); } // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` return device!; } /** * @param {number|string} clusterKey * @returns {boolean} */ public supportsInputCluster(clusterKey: number | string): boolean { const cluster = this.getCluster(clusterKey); return this.inputClusters.includes(cluster.ID); } /** * @param {number|string} clusterKey * @returns {boolean} */ public supportsOutputCluster(clusterKey: number | string): boolean { const cluster = this.getCluster(clusterKey); return this.outputClusters.includes(cluster.ID); } /** * @returns {ZclTypes.Cluster[]} */ public getInputClusters(): ZclTypes.Cluster[] { return this.clusterNumbersToClusters(this.inputClusters); } /** * @returns {ZclTypes.Cluster[]} */ public getOutputClusters(): ZclTypes.Cluster[] { return this.clusterNumbersToClusters(this.outputClusters); } private clusterNumbersToClusters(clusterNumbers: number[]): ZclTypes.Cluster[] { return clusterNumbers.map((c) => this.getCluster(c)); } /* * CRUD */ public static fromDatabaseRecord(record: KeyValue, deviceNetworkAddress: number, deviceIeeeAddress: string): Endpoint { // Migrate attrs to attributes for (const entryKey in record.clusters) { const entry = record.clusters[entryKey]; if (entry.attrs != null) { entry.attributes = entry.attrs; delete entry.attrs; } } return new Endpoint( record.epId, record.profId, record.devId, record.inClusterList, record.outClusterList, deviceNetworkAddress, deviceIeeeAddress, record.clusters, record.binds || [], record.configuredReportings || [], record.meta || {}, ); } public toDatabaseRecord(): KeyValue { return { profId: this.profileID, epId: this.ID, devId: this.deviceID, inClusterList: this.inputClusters, outClusterList: this.outputClusters, clusters: this.clusters, binds: this._binds, configuredReportings: this._configuredReportings, meta: this.meta, }; } public static create( id: number, profileID: number | undefined, deviceID: number | undefined, inputClusters: number[], outputClusters: number[], deviceNetworkAddress: number, deviceIeeeAddress: string, ): Endpoint { return new Endpoint(id, profileID, deviceID, inputClusters, outputClusters, deviceNetworkAddress, deviceIeeeAddress, {}, [], [], {}); } public saveClusterAttributeKeyValue(clusterKey: number | string, list: KeyValue): void { const cluster = this.getCluster(clusterKey); if (!this.clusters[cluster.name]) this.clusters[cluster.name] = {attributes: {}}; for (const [attribute, value] of Object.entries(list)) { this.clusters[cluster.name].attributes[attribute] = value; } } public getClusterAttributeValue(clusterKey: number | string, attributeKey: number | string): number | string | undefined { const cluster = this.getCluster(clusterKey); const attribute = cluster.getAttribute(attributeKey); if (this.clusters[cluster.name] && this.clusters[cluster.name].attributes) { return this.clusters[cluster.name].attributes[attribute.name]; } return undefined; } public hasPendingRequests(): boolean { return this.pendingRequests.size > 0; } public async sendPendingRequests(fastPolling: boolean): Promise<void> { return await this.pendingRequests.send(fastPolling); } private async sendRequest(frame: Zcl.Frame, options: OptionsWithDefaults): Promise<AdapterEvents.ZclPayload>; private async sendRequest<Type>(frame: Zcl.Frame, options: OptionsWithDefaults, func: (frame: Zcl.Frame) => Promise<Type>): Promise<Type>; private async sendRequest<Type>( frame: Zcl.Frame, options: OptionsWithDefaults, func: (d: Zcl.Frame) => Promise<Type> = (d: Zcl.Frame): Promise<Type> => { // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` return Entity.adapter!.sendZclFrameToEndpoint( this.deviceIeeeAddress, this.deviceNetworkAddress, this.ID, d, options.timeout, options.disableResponse, options.disableRecovery, options.srcEndpoint, ) as Promise<Type>; }, ): Promise<Type> { const logPrefix = `Request Queue (${this.deviceIeeeAddress}/${this.ID}): `; const device = this.getDevice(); const request = new Request(func, frame, device.pendingRequestTimeout, options.sendPolicy); if (request.sendPolicy !== "bulk") { // Check if such a request is already in the queue and remove the old one(s) if necessary this.pendingRequests.filter(request); } // send without queueing if sendPolicy is 'immediate' or if the device has no timeout set if (request.sendPolicy === "immediate" || !device.pendingRequestTimeout) { if (device.pendingRequestTimeout > 0) { logger.debug(`${logPrefix}send ${frame.command.name} request immediately (sendPolicy=${options.sendPolicy})`, NS); } return await request.send(); } // If this is a bulk message, we queue directly. if (request.sendPolicy === "bulk") { logger.debug(`${logPrefix}queue request (${this.pendingRequests.size})`, NS); return await this.pendingRequests.queue(request); } try { logger.debug(`${logPrefix}send request`, NS); return await request.send(); } catch (error) { // If we got a failed transaction, the device is likely sleeping. // Queue for transmission later. logger.debug(`${logPrefix}queue request (transaction failed) (${error})`, NS); return await this.pendingRequests.queue(request); } } /* * Zigbee functions */ private checkStatus(payload: [{status: Zcl.Status}] | {cmdId: number; statusCode: number}): void { const codes = Array.isArray(payload) ? payload.map((i) => i.status) : [payload.statusCode]; const invalid = codes.find((c) => c !== Zcl.Status.SUCCESS); if (invalid) throw new Zcl.StatusError(invalid); } public async report(clusterKey: number | string, attributes: KeyValue, options?: Options): Promise<void> { const cluster = this.getCluster(clusterKey); const payload: {attrId: number; dataType: number; attrData: number | string | boolean}[] = []; for (const [nameOrID, value] of Object.entries(attributes)) { if (cluster.hasAttribute(nameOrID)) { const attribute = cluster.getAttribute(nameOrID); payload.push({attrId: attribute.ID, attrData: value, dataType: attribute.type}); } else if (!Number.isNaN(Number(nameOrID))) { payload.push({attrId: Number(nameOrID), attrData: value.value, dataType: value.type}); } else { throw new Error(`Unknown attribute '${nameOrID}', specify either an existing attribute or a number`); } } await this.zclCommand(clusterKey, "report", payload, options, attributes); } public async write(clusterKey: number | string, attributes: KeyValue, options?: Options): Promise<void> { const cluster = this.getCluster(clusterKey); const optionsWithDefaults = this.getOptionsWithDefaults(options, true, Zcl.Direction.CLIENT_TO_SERVER, cluster.manufacturerCode); optionsWithDefaults.manufacturerCode = this.ensureManufacturerCodeIsUniqueAndGet( cluster, Object.keys(attributes), optionsWithDefaults.manufacturerCode, "write", ); const payload: {attrId: number; dataType: number; attrData: number | string | boolean}[] = []; for (const [nameOrID, value] of Object.entries(attributes)) { if (cluster.hasAttribute(nameOrID)) { const attribute = cluster.getAttribute(nameOrID); payload.push({attrId: attribute.ID, attrData: value, dataType: attribute.type}); } else if (!Number.isNaN(Number(nameOrID))) { payload.push({attrId: Number(nameOrID), attrData: value.value, dataType: value.type}); } else { throw new Error(`Unknown attribute '${nameOrID}', specify either an existing attribute or a number`); } } await this.zclCommand(clusterKey, optionsWithDefaults.writeUndiv ? "writeUndiv" : "write", payload, optionsWithDefaults, attributes, true); } public async writeResponse( clusterKey: number | string, transactionSequenceNumber: number, attributes: KeyValue, options?: Options, ): Promise<void> { assert(options?.transactionSequenceNumber === undefined, "Use parameter"); const cluster = this.getCluster(clusterKey); const payload: {status: number; attrId: number}[] = []; for (const [nameOrID, value] of Object.entries(attributes)) { if (value.status !== undefined) { if (cluster.hasAttribute(nameOrID)) { const attribute = cluster.getAttribute(nameOrID); payload.push({attrId: attribute.ID, status: value.status}); } else if (!Number.isNaN(Number(nameOrID))) { payload.push({attrId: Number(nameOrID), status: value.status}); } else { throw new Error(`Unknown attribute '${nameOrID}', specify either an existing attribute or a number`); } } else { throw new Error(`Missing attribute 'status'`); } } await this.zclCommand( clusterKey, "writeRsp", payload, {direction: Zcl.Direction.SERVER_TO_CLIENT, ...options, transactionSequenceNumber}, attributes, ); } public async read(clusterKey: number | string, attributes: (string | number)[], options?: Options): Promise<KeyValue> { const device = this.getDevice(); const cluster = this.getCluster(clusterKey, device); const optionsWithDefaults = this.getOptionsWithDefaults(options, true, Zcl.Direction.CLIENT_TO_SERVER, cluster.manufacturerCode); optionsWithDefaults.manufacturerCode = this.ensureManufacturerCodeIsUniqueAndGet( cluster, attributes, optionsWithDefaults.manufacturerCode, "read", ); const payload: {attrId: number}[] = []; for (const attribute of attributes) { payload.push({attrId: typeof attribute === "number" ? attribute : cluster.getAttribute(attribute).ID}); } const resultFrame = await this.zclCommand(clusterKey, "read", payload, optionsWithDefaults, attributes, true); if (resultFrame) { return ZclFrameConverter.attributeKeyValue(resultFrame, device.manufacturerID, device.customClusters); } return {}; } public async readResponse( clusterKey: number | string, transactionSequenceNumber: number, attributes: KeyValue, options?: Options, ): Promise<void> { assert(options?.transactionSequenceNumber === undefined, "Use parameter"); const cluster = this.getCluster(clusterKey); const payload: {attrId: number; status: number; dataType: number; attrData: number | string}[] = []; for (const [nameOrID, value] of Object.entries(attributes)) { if (cluster.hasAttribute(nameOrID)) { const attribute = cluster.getAttribute(nameOrID); payload.push({attrId: attribute.ID, attrData: value, dataType: attribute.type, status: 0}); } else if (!Number.isNaN(Number(nameOrID))) { payload.push({attrId: Number(nameOrID), attrData: value.value, dataType: value.type, status: 0}); } else { throw new Error(`Unknown attribute '${nameOrID}', specify either an existing attribute or a number`); } } await this.zclCommand( clusterKey, "readRsp", payload, {direction: Zcl.Direction.SERVER_TO_CLIENT, ...options, transactionSequenceNumber}, attributes, ); } public async updateSimpleDescriptor(): Promise<void> { const clusterId = Zdo.ClusterId.SIMPLE_DESCRIPTOR_REQUEST; // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` const zdoPayload = Zdo.Buffalo.buildRequest(Entity.adapter!.hasZdoMessageOverhead, clusterId, this.deviceNetworkAddress, this.ID); // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` const response = await Entity.adapter!.sendZdo(this.deviceIeeeAddress, this.deviceNetworkAddress, clusterId, zdoPayload, false); if (!Zdo.Buffalo.checkStatus<Zdo.ClusterId.SIMPLE_DESCRIPTOR_RESPONSE>(response)) { throw new Zdo.StatusError(response[0]); } const simpleDescriptor = response[1]; this.profileID = simpleDescriptor.profileId; this.deviceID = simpleDescriptor.deviceId; this.inputClusters = simpleDescriptor.inClusterList; this.outputClusters = simpleDescriptor.outClusterList; } public hasBind(clusterId: number, target: Endpoint | Group): boolean { return this.getBindIndex(clusterId, target) !== -1; } public getBindIndex(clusterId: number, target: Endpoint | Group): number { return this.binds.findIndex((b) => b.cluster.ID === clusterId && b.target === target); } public addBinding(clusterKey: number | string, target: Endpoint | Group | number): void { const cluster = this.getCluster(clusterKey); if (typeof target === "number") { target = Group.byGroupID(target) || Group.create(target); } this.addBindingInternal(cluster, target); } private addBindingInternal(cluster: ZclTypes.Cluster, target: Endpoint | Group): void { if (!this.hasBind(cluster.ID, target)) { if (target instanceof Group) { this._binds.push({cluster: cluster.ID, groupID: target.groupID, type: "group"}); } else { this._binds.push({ cluster: cluster.ID, type: "endpoint", deviceIeeeAddress: target.deviceIeeeAddress, endpointID: target.ID, }); } this.save(); } } public async bind(clusterKey: number | string, target: Endpoint | Group | number): Promise<void> { const cluster = this.getCluster(clusterKey); if (typeof target === "number") { target = Group.byGroupID(target) || Group.create(target); } const destinationAddress = target instanceof Endpoint ? target.deviceIeeeAddress : target.groupID; const log = `Bind ${this.deviceIeeeAddress}/${this.ID} ${cluster.name} from '${target instanceof Endpoint ? `${destinationAddress}/${target.ID}` : destinationAddress}'`; logger.debug(log, NS); try { const zdoClusterId = Zdo.ClusterId.BIND_REQUEST; const zdoPayload = Zdo.Buffalo.buildRequest( // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` Entity.adapter!.hasZdoMessageOverhead, zdoClusterId, this.deviceIeeeAddress as Eui64, this.ID, cluster.ID, target instanceof Endpoint ? Zdo.UNICAST_BINDING : Zdo.MULTICAST_BINDING, target instanceof Endpoint ? (target.deviceIeeeAddress as Eui64) : ZSpec.BLANK_EUI64, target instanceof Group ? target.groupID : 0, target instanceof Endpoint ? target.ID : 0xff, ); // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` const response = await Entity.adapter!.sendZdo(this.deviceIeeeAddress, this.deviceNetworkAddress, zdoClusterId, zdoPayload, false); if (!Zdo.Buffalo.checkStatus<Zdo.ClusterId.BIND_RESPONSE>(response)) { throw new Zdo.StatusError(response[0]); } this.addBindingInternal(cluster, target); } catch (error) { const err = error as Error; err.message = `${log} failed (${err.message})`; // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` logger.debug(err.stack!, NS); throw error; } } public save(): void { this.getDevice().save(); } public async unbind(clusterKey: number | string, target: Endpoint | Group | number): Promise<void> { const cluster = this.getCluster(clusterKey); const action = `Unbind ${this.deviceIeeeAddress}/${this.ID} ${cluster.name}`; if (typeof target === "number") { const groupTarget = Group.byGroupID(target); if (!groupTarget) { throw new Error(`${action} invalid target '${target}' (no group with this ID exists).`); } target = groupTarget; } const destinationAddress = target instanceof Endpoint ? target.deviceIeeeAddress : target.groupID; const log = `${action} from '${target instanceof Endpoint ? `${destinationAddress}/${target.ID}` : destinationAddress}'`; const index = this.getBindIndex(cluster.ID, target); if (index === -1) { logger.debug(`${log} no bind present, skipping.`, NS); return; } logger.debug(log, NS); try { const zdoClusterId = Zdo.ClusterId.UNBIND_REQUEST; const zdoPayload = Zdo.Buffalo.buildRequest( // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` Entity.adapter!.hasZdoMessageOverhead, zdoClusterId, this.deviceIeeeAddress as Eui64, this.ID, cluster.ID, target instanceof Endpoint ? Zdo.UNICAST_BINDING : Zdo.MULTICAST_BINDING, target instanceof Endpoint ? (target.deviceIeeeAddress as Eui64) : ZSpec.BLANK_EUI64, target instanceof Group ? target.groupID : 0, target instanceof Endpoint ? target.ID : 0xff, ); // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` const response = await Entity.adapter!.sendZdo(this.deviceIeeeAddress, this.deviceNetworkAddress, zdoClusterId, zdoPayload, false); if (!Zdo.Buffalo.checkStatus<Zdo.ClusterId.UNBIND_RESPONSE>(response)) { if (response[0] === Zdo.Status.NO_ENTRY) { logger.debug(`${log} no entry on device, removing entry from database.`, NS); } else { throw new Zdo.StatusError(response[0]); } } this._binds.splice(index, 1); this.save(); } catch (error) { const err = error as Error; err.message = `${log} failed (${err.message})`; // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` logger.debug(err.stack!, NS); throw error; } } public async defaultResponse( commandID: number, status: number, clusterID: number, transactionSequenceNumber: number, options?: Options, ): Promise<void> { assert(options?.transactionSequenceNumber === undefined, "Use parameter"); const payload = {cmdId: commandID, statusCode: status}; await this.zclCommand(clusterID, "defaultRsp", payload, {direction: Zcl.Direction.SERVER_TO_CLIENT, ...options, transactionSequenceNumber}); } public async configureReporting(clusterKey: number | string, items: ConfigureReportingItem[], options?: Options): Promise<void> { const cluster = this.getCluster(clusterKey); const optionsWithDefaults = this.getOptionsWithDefaults(options, true, Zcl.Direction.CLIENT_TO_SERVER, cluster.manufacturerCode); optionsWithDefaults.manufacturerCode = this.ensureManufacturerCodeIsUniqueAndGet( cluster, items, optionsWithDefaults.manufacturerCode, "configureReporting", ); const payload = items.map((item): KeyValue => { let dataType: number | undefined; let attrId: number | undefined; if (typeof item.attribute === "object") { dataType = item.attribute.type; attrId = item.attribute.ID; } else if (cluster.hasAttribute(item.attribute)) { const attribute = cluster.getAttribute(item.attribute); dataType = attribute.type; attrId = attribute.ID; } return { direction: Zcl.Direction.CLIENT_TO_SERVER, attrId, // TODO: biome migration - can be undefined? dataType, // TODO: biome migration - can be undefined? minRepIntval: item.minimumReportInterval, maxRepIntval: item.maximumReportInterval, repChange: item.reportableChange, }; }); await this.zclCommand(clusterKey, "configReport", payload, optionsWithDefaults, items, true); for (const e of payload) { this._configuredReportings = this._configuredReportings.filter( (c) => !( c.attrId === e.attrId && c.cluster === cluster.ID && (!("manufacturerCode" in c) || c.manufacturerCode === optionsWithDefaults.manufacturerCode) ), ); } for (const entry of payload) { if (entry.maxRepIntval !== 0xffff) { this._configuredReportings.push({ cluster: cluster.ID, attrId: entry.attrId, minRepIntval: entry.minRepIntval, maxRepIntval: entry.maxRepIntval, repChange: entry.repChange, manufacturerCode: optionsWithDefaults.manufacturerCode, }); } } this.save(); } public async writeStructured(clusterKey: number | string, payload: KeyValue, options?: Options): Promise<void> { await this.zclCommand(clusterKey, "writeStructured", payload, options); // TODO: support `writeStructuredResponse` } public async command( clusterKey: number | string, commandKey: number | string, payload: KeyValue, options?: Options, ): Promise<undefined | KeyValue> { const frame = await this.zclCommand(clusterKey, commandKey, payload, options, undefined, false, Zcl.FrameType.SPECIFIC); if (frame) { return frame.payload; } } public async commandResponse( clusterKey: number | string, commandKey: number | string, payload: KeyValue, options?: Options, transactionSequenceNumber?: number, ): Promise<void> { assert(options?.transactionSequenceNumber === undefined, "Use parameter"); const device = this.getDevice(); const cluster = this.getCluster(clusterKey, device); const command = cluster.getCommandResponse(commandKey); transactionSequenceNumber = transactionSequenceNumber || zclTransactionSequenceNumber.next(); const optionsWithDefaults = this.getOptionsWithDefaults(options, true, Zcl.Direction.SERVER_TO_CLIENT, cluster.manufacturerCode); const frame = Zcl.Frame.create( Zcl.FrameType.SPECIFIC, optionsWithDefaults.direction, optionsWithDefaults.disableDefaultResponse, optionsWithDefaults.manufacturerCode, transactionSequenceNumber, command.name, cluster.name, payload, device.customClusters, optionsWithDefaults.reservedBits, ); const createLogMessage = (): string => `CommandResponse ${this.deviceIeeeAddress}/${this.ID} ` + `${cluster.name}.${command.name}(${JSON.stringify(payload)}, ${JSON.stringify(optionsWithDefaults)})`; logger.debug(createLogMessage, NS); try { await this.sendRequest(frame, optionsWithDefaults, async (f) => { // Broadcast Green Power responses if (this.ID === 242) { // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` await Entity.adapter!.sendZclFrameToAll(242, f, 242, BroadcastAddress.RX_ON_WHEN_IDLE); } else { // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` await Entity.adapter!.sendZclFrameToEndpoint( this.deviceIeeeAddress, this.deviceNetworkAddress, this.ID, f, optionsWithDefaults.timeout, optionsWithDefaults.disableResponse, optionsWithDefaults.disableRecovery, optionsWithDefaults.srcEndpoint, ); } }); } catch (error) { const err = error as Error; err.message = `${createLogMessage()} failed (${err.message})`; // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` logger.debug(err.stack!, NS); throw error; } } public waitForCommand( clusterKey: number | string, commandKey: number | string, transactionSequenceNumber: number | undefined, timeout: number, ): {promise: Promise<{header: Zcl.Header; payload: KeyValue}>; cancel: () => void} { const device = this.getDevice(); const cluster = this.getCluster(clusterKey, device); const command = cluster.getCommand(commandKey); // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` const waiter = Entity.adapter!.waitFor( this.deviceNetworkAddress, this.ID, Zcl.FrameType.SPECIFIC, Zcl.Direction.CLIENT_TO_SERVER, transactionSequenceNumber, cluster.ID, command.ID, timeout, ); const promise = new Promise<{header: Zcl.Header; payload: KeyValue}>((resolve, reject) => { waiter.promise.then( (payload) => { const frame = Zcl.Frame.fromBuffer(payload.clusterID, payload.header, payload.data, device.customClusters); resolve({header: frame.header, payload: frame.payload}); }, (error) => reject(error), ); }); return {promise, cancel: waiter.cancel}; } private getOptionsWithDefaults( options: Options | undefined, disableDefaultResponse: boolean, direction: Zcl.Direction, manufacturerCode: number | undefined, ): OptionsWithDefaults { return { timeout: 10000, disableResponse: false, disableRecovery: false, disableDefaultResponse, direction, srcEndpoint: undefined, reservedBits: 0, manufacturerCode, transactionSequenceNumber: undefined, writeUndiv: false, ...(options || {}), }; } private ensureManufacturerCodeIsUniqueAndGet( cluster: ZclTypes.Cluster, attributes: (string | number)[] | ConfigureReportingItem[], fallbackManufacturerCode: number | undefined, // XXX: problematic undefined for a "fallback"? caller: string, ): number | undefined { const manufacturerCodes = new Set( attributes.map((nameOrID): number | undefined => { let attributeID: number | string; if (typeof nameOrID === "object") { // ConfigureReportingItem if (typeof nameOrID.attribute !== "object") { attributeID = nameOrID.attribute; } else { return fallbackManufacturerCode; } } else { // string || number attributeID = nameOrID; } // we fall back to caller|cluster provided manufacturerCode if (cluster.hasAttribute(attributeID)) { const attribute = cluster.getAttribute(attributeID); return attribute.manufacturerCode === undefined ? fallbackManufacturerCode : attribute.manufacturerCode; } // unknown attribute, we should not fail on this here return fallbackManufacturerCode; }), ); if (manufacturerCodes.size === 1) { return manufacturerCodes.values().next().value; } throw new Error(`Cannot have attributes with different manufacturerCode in single '${caller}' call`); } public async addToGroup(group: Group): Promise<void> { await this.command("genGroups", "add", {groupid: group.groupID, groupname: ""}); group.addMember(this); } private getCluster(clusterKey: number | string, device: Device | undefined = undefined): ZclTypes.Cluster { if (!device) { device = this.getDevice(); } return Zcl.Utils.getCluster(clusterKey, device.manufacturerID, device.customClusters); } /** * Remove endpoint from a group, accepts both a Group and number as parameter. * The number parameter type should only be used when removing from a group which is not known * to zigbee-herdsman. */ public async removeFromGroup(group: Group | number): Promise<void> { await this.command("genGroups", "remove", {groupid: group instanceof Group ? group.groupID : group}); if (group instanceof Group) { group.removeMember(this); } } public async removeFromAllGroups(): Promise<void> { await this.command("genGroups", "removeAll", {}, {disableDefaultResponse: true}); this.removeFromAllGroupsDatabase(); } public removeFromAllGroupsDatabase(): void { for (const group of Group.allIterator()) { if (group.hasMember(this)) { group.removeMember(this); } } } public async zclCommand( clusterKey: number | string, commandKey: number | string, payload: KeyValue, options?: Options, logPayload?: KeyValue, checkStatus = false, frameType: Zcl.FrameType = Zcl.FrameType.GLOBAL, ): Promise<undefined | Zcl.Frame> { const device = this.getDevice(); const cluster = this.getCluster(clusterKey, device); const command = frameType === Zcl.FrameType.GLOBAL ? Zcl.Utils.getGlobalCommand(commandKey) : cluster.getCommand(commandKey); const hasResponse = frameType === Zcl.FrameType.GLOBAL ? true : command.response !== undefined; const optionsWithDefaults = this.getOptionsWithDefaults(options, hasResponse, Zcl.Direction.CLIENT_TO_SERVER, cluster.manufacturerCode); const frame = Zcl.Frame.create( frameType, optionsWithDefaults.direction, optionsWithDefaults.disableDefaultResponse, optionsWithDefaults.manufacturerCode, optionsWithDefaults.transactionSequenceNumber ?? zclTransactionSequenceNumber.next(), command.name, cluster.name, payload, device.customClusters, optionsWithDefaults.reservedBits, ); const createLogMessage = (): string => `ZCL command ${this.deviceIeeeAddress}/${this.ID} ` + `${cluster.name}.${command.name}(${JSON.stringify(logPayload ? logPayload : payload)}, ${JSON.stringify(optionsWithDefaults)})`; logger.debug(createLogMessage, NS); try { const result = await this.sendRequest(frame, optionsWithDefaults); if (result) { const resultFrame = Zcl.Frame.fromBuffer(result.clusterID, result.header, result.data, device.customClusters); if (result && checkStatus && !optionsWithDefaults.disableResponse) { this.checkStatus(resultFrame.payload); } return resultFrame; } } catch (error) { const err = error as Error; err.message = `${createLogMessage()} failed (${err.message})`; // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` logger.debug(err.stack!, NS); throw error; } } public async zclCommandBroadcast( endpoint: number, destination: BroadcastAddress, clusterKey: number | string, commandKey: number | string, payload: unknown, options?: Options, ): Promise<void> { const device = this.getDevice(); const cluster = this.getCluster(clusterKey, device); const command = cluster.getCommand(commandKey); const optionsWithDefaults = this.getOptionsWithDefaults(options, true, Zcl.Direction.CLIENT_TO_SERVER, cluster.manufacturerCode); const sourceEndpoint = optionsWithDefaults.srcEndpoint ?? this.ID; const frame = Zcl.Frame.create( Zcl.FrameType.SPECIFIC, optionsWithDefaults.direction, true, optionsWithDefaults.manufacturerCode, optionsWithDefaults.transactionSequenceNumber ?? zclTransactionSequenceNumber.next(), command.name, cluster.name, payload, device.customClusters, optionsWithDefaults.reservedBits, ); logger.debug( () => `ZCL command broadcast ${this.deviceIeeeAddress}/${sourceEndpoint} to ${destination}/${endpoint} ` + `${cluster.name}.${command.name}(${JSON.stringify({payload, optionsWithDefaults})})`, NS, ); // if endpoint===0xFF ("broadcast endpoint"), deliver to all endpoints supporting cluster, should be avoided whenever possible // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` await Entity.adapter!.sendZclFrameToAll(endpoint, frame, sourceEndpoint, destination); } } export default Endpoint;