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 {TFoundation} from "../../zspec/zcl/definition/clusters-types"; import type {FoundationDefinition} from "../../zspec/zcl/definition/foundation"; 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 { ClusterOrRawAttributeKeys, ClusterOrRawAttributes, ClusterOrRawPayload, ClusterOrRawWriteAttributes, FoundationOrRawPayload, KeyValue, PartialClusterOrRawWriteAttributes, SendPolicy, TCustomCluster, } from "../tstype"; import Device from "./device"; import Entity from "./entity"; import Group from "./group"; import {ZigbeeEntity} from "./zigbeeEntity"; const NS = "zh:controller:endpoint"; export interface ConfigureReportingItem { attribute: ClusterOrRawAttributeKeys[number] | {ID: number; type: number}; minimumReportInterval: number; maximumReportInterval: number; reportableChange?: number; } export interface ReadReportConfigItem { direction?: Zcl.Direction; attribute: ClusterOrRawAttributeKeys[number] | {ID: number}; } interface Options { manufacturerCode?: number; disableDefaultResponse?: boolean; disableResponse?: boolean; timeout?: number; direction?: Zcl.Direction; srcEndpoint?: number; profileId?: 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; } export interface Clusters { [cluster: string]: { attributes: {[attribute: string]: number | string}; }; } export 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 ZigbeeEntity { private databaseID: number; 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(this.databaseID, bind.deviceIeeeAddress!)?.getEndpoint(bind.endpointID!) : Group.byGroupID(bind.groupID!, this.databaseID); 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 = Zcl.Utils.getClusterAttribute(cluster, entry.attrId, entry.manufacturerCode) ?? { 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( databaseID: number, 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.databaseID = databaseID; 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.databaseID, 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.inputClusters.map((c) => this.getCluster(c)); } /** * @returns {ZclTypes.Cluster[]} */ public getOutputClusters(): ZclTypes.Cluster[] { return this.outputClusters.map((c) => this.getCluster(c)); } /* * CRUD */ public static fromDatabaseRecord(record: KeyValue, deviceNetworkAddress: number, deviceIeeeAddress: string, databaseID: number): 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; } } // Migrate cluster renames from https://github.com/Koenkk/zigbee-herdsman/pull/1503 @deprecated 3.0 /* v8 ignore start */ if (record.clusters.piRetailTunnel) { record.clusters.retailTunnel = record.clusters.piRetailTunnel; delete record.clusters.piRetailTunnel; } if (record.clusters.tunneling) { record.clusters.seTunneling = record.clusters.tunneling; delete record.clusters.tunneling; } if (record.clusters.haMeterIdentification) { record.clusters.seMeterIdentification = record.clusters.haMeterIdentification; delete record.clusters.haMeterIdentification; } /* v8 ignore stop */ return new Endpoint( databaseID, 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( databaseID: number, id: number, profileID: number | undefined, deviceID: number | undefined, inputClusters: number[], outputClusters: number[], deviceNetworkAddress: number, deviceIeeeAddress: string, ): Endpoint { return new Endpoint(databaseID, 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 in list) { this.clusters[cluster.name].attributes[attribute] = list[attribute]; } } public getClusterAttributeValue(clusterKey: number | string, attributeKey: number | string): number | string | undefined { const cluster = this.getCluster(clusterKey); if (this.clusters[cluster.name] && this.clusters[cluster.name].attributes) { // XXX: used to throw (behavior changed in #1455) const attribute = Zcl.Utils.getClusterAttribute(cluster, attributeKey, undefined); if (attribute) { return this.clusters[cluster.name].attributes[attribute.name]; } } return undefined; } public saveClusterAttributeReportConfig( clusterId: number, manufacturerCode: Zcl.ManufacturerCode | undefined, reportConfigs: TFoundation["readReportConfigRsp"], ): void { for (const entry of reportConfigs) { if (entry.direction === Zcl.Direction.SERVER_TO_CLIENT) { continue; } const existingConfigIdx = this._configuredReportings.findIndex( (r) => r.cluster === clusterId && r.attrId === entry.attrId && (manufacturerCode === undefined || manufacturerCode === r.manufacturerCode), ); if (entry.status === Zcl.Status.SUCCESS) { if (existingConfigIdx > -1) { this._configuredReportings[existingConfigIdx].minRepIntval = entry.minRepIntval as number; this._configuredReportings[existingConfigIdx].maxRepIntval = entry.maxRepIntval as number; this._configuredReportings[existingConfigIdx].repChange = entry.repChange as number; } else { this._configuredReportings.push({ cluster: clusterId, attrId: entry.attrId, minRepIntval: entry.minRepIntval as number, maxRepIntval: entry.maxRepIntval as number, repChange: entry.repChange as number, manufacturerCode, }); } } else { // UNSUPPORTED_ATTRIBUTE, UNREPORTABLE_ATTRIBUTE, NOT_FOUND if (existingConfigIdx > -1) { this._configuredReportings.splice(existingConfigIdx, 1); } } } this.save(); } public saveBindings(binds: BindInternal[]): void { this._binds = binds; this.save(); } public clearBindings(): void { this._binds.length = 0; this.save(); } public hasPendingRequests(): boolean { return this.pendingRequests.size > 0; } public async sendPendingRequests(fastPolling: boolean): Promise { return await this.pendingRequests.send(fastPolling); } private async sendRequest(frame: Zcl.Frame, options: OptionsWithDefaults): Promise; private async sendRequest(frame: Zcl.Frame, options: OptionsWithDefaults, func: () => Promise): Promise; private async sendRequest( frame: Zcl.Frame, options: OptionsWithDefaults, func: () => Promise = (): Promise => { return Entity.getAdapterByID(this.databaseID)?.sendZclFrameToEndpoint( this.deviceIeeeAddress, this.deviceNetworkAddress, this.ID, frame, options.timeout, options.disableResponse, options.disableRecovery, options.srcEndpoint, options.profileId, ) as Promise; }, ): Promise { 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: Cl, attributes: PartialClusterOrRawWriteAttributes, options?: Options, ): Promise { const cluster = this.getCluster(clusterKey, undefined, options?.manufacturerCode); const payload: TFoundation["report"] = []; // TODO: handle `attr.report !== true` for (const nameOrID in attributes) { const attribute = Zcl.Utils.getClusterAttribute(cluster, nameOrID, options?.manufacturerCode); if (attribute) { payload.push({attrId: attribute.ID, attrData: attributes[nameOrID], dataType: attribute.type}); } else if (!Number.isNaN(Number(nameOrID))) { const value = attributes[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(cluster, "report", payload, options, attributes); } public async write( clusterKey: Cl, attributes: PartialClusterOrRawWriteAttributes, options?: Options, ): Promise { const cluster = this.getCluster(clusterKey, undefined, options?.manufacturerCode); 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: TFoundation["write"] = []; for (const nameOrID in attributes) { const attribute = Zcl.Utils.getClusterAttribute(cluster, nameOrID, options?.manufacturerCode); if (attribute) { // TODO: handle `attr.writeOptional !== true` const attrData = Zcl.Utils.processAttributeWrite(attribute, attributes[nameOrID]); payload.push({attrId: attribute.ID, attrData, dataType: attribute.type}); } else if (!Number.isNaN(Number(nameOrID))) { const value = attributes[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(cluster, optionsWithDefaults.writeUndiv ? "writeUndiv" : "write", payload, optionsWithDefaults, attributes, true); } public async writeResponse( clusterKey: Cl, transactionSequenceNumber: number, attributes: Partial[number], TFoundation["writeRsp"][number]>> & Record, options?: Options, ): Promise { assert(options?.transactionSequenceNumber === undefined, "Use parameter"); const cluster = this.getCluster(clusterKey, undefined, options?.manufacturerCode); const payload: TFoundation["writeRsp"] = []; for (const nameOrID in attributes) { // biome-ignore lint/style/noNonNullAssertion: from loop const value = attributes[nameOrID]!; if (value.status !== undefined) { const attribute = Zcl.Utils.getClusterAttribute(cluster, nameOrID, options?.manufacturerCode); if (attribute) { 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( cluster, "writeRsp", payload, {direction: Zcl.Direction.SERVER_TO_CLIENT, ...options, transactionSequenceNumber}, attributes, ); } // XXX: ideally, the return type should limit to the contents of the `attributes` param public async read( clusterKey: Cl, attributes: ClusterOrRawAttributeKeys, options?: Options, ): Promise> { const device = this.getDevice(); const cluster = this.getCluster(clusterKey, device, options?.manufacturerCode); const optionsWithDefaults = this.getOptionsWithDefaults(options, true, Zcl.Direction.CLIENT_TO_SERVER, cluster.manufacturerCode); optionsWithDefaults.manufacturerCode = this.ensureManufacturerCodeIsUniqueAndGet( cluster, attributes, optionsWithDefaults.manufacturerCode, "read", ); const payload: TFoundation["read"] = []; // TODO: handle `attr.required !== true` => should not throw for (const attribute of attributes) { if (typeof attribute === "number") { payload.push({attrId: attribute}); } else { const attr = Zcl.Utils.getClusterAttribute(cluster, attribute, options?.manufacturerCode); if (attr) { Zcl.Utils.processAttributePreRead(attr); payload.push({attrId: attr.ID}); } else { logger.warning(`Ignoring unknown attribute ${attribute} in cluster ${cluster.name}`, NS); } } } // TODO: could be sending empty array payload const resultFrame = await this.zclCommand(cluster, "read", payload, optionsWithDefaults, attributes, true); return resultFrame ? ZclFrameConverter.attributeKeyValue(resultFrame, device.manufacturerID, device.customClusters) : ({} as ClusterOrRawWriteAttributes); } /** * Sends a Foundation response to a read request. * Any attribute with an `undefined` value will result in a sent record with status `UNSUPPORTED_ATTRIBUTE` */ public async readResponse( clusterKey: Cl, transactionSequenceNumber: number, attributes: PartialClusterOrRawWriteAttributes, options?: Options, ): Promise { assert(options?.transactionSequenceNumber === undefined, "Use parameter"); const cluster = this.getCluster(clusterKey, undefined, options?.manufacturerCode); const payload: TFoundation["readRsp"] = []; for (const nameOrID in attributes) { const attribute = Zcl.Utils.getClusterAttribute(cluster, nameOrID, options?.manufacturerCode); if (attribute) { const attrData = attributes[nameOrID]; if (attrData === undefined) { payload.push({attrId: attribute.ID, status: Zcl.Status.UNSUPPORTED_ATTRIBUTE}); } else { payload.push({attrId: attribute.ID, attrData, dataType: attribute.type, status: Zcl.Status.SUCCESS}); } } else if (!Number.isNaN(Number(nameOrID))) { const value = attributes[nameOrID]; const attrData = value.value; if (attrData === undefined) { payload.push({attrId: Number(nameOrID), status: Zcl.Status.UNSUPPORTED_ATTRIBUTE}); } else { payload.push({attrId: Number(nameOrID), attrData, dataType: value.type, status: Zcl.Status.SUCCESS}); } } else { throw new Error(`Unknown attribute '${nameOrID}', specify either an existing attribute or a number`); } } await this.zclCommand( cluster, "readRsp", payload, {direction: Zcl.Direction.SERVER_TO_CLIENT, disableDefaultResponse: true, ...options, transactionSequenceNumber}, attributes, ); } public async updateSimpleDescriptor(): Promise { const clusterId = Zdo.ClusterId.SIMPLE_DESCRIPTOR_REQUEST; const adapter = Entity.getAdapterByID(this.databaseID); if (!adapter) { throw new Error(`No adapter found for database ID ${this.databaseID}`); } const zdoPayload = Zdo.Buffalo.buildRequest(adapter.hasZdoMessageOverhead ?? false, clusterId, this.deviceNetworkAddress, this.ID); const response = await adapter.sendZdo(this.deviceIeeeAddress, this.deviceNetworkAddress, clusterId, zdoPayload, false); if (!Zdo.Buffalo.checkStatus(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, this.databaseID) || Group.create(target, this.databaseID); } 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 { const cluster = this.getCluster(clusterKey); if (typeof target === "number") { target = Group.byGroupID(target, this.databaseID) || Group.create(target, this.databaseID); } 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( Entity.getAdapterByID(this.databaseID)?.hasZdoMessageOverhead ?? false, 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, ); const adapter = Entity.getAdapterByID(this.databaseID); if (!adapter) { throw new Error(`No adapter found for database ID ${this.databaseID}`); } const response = await adapter.sendZdo(this.deviceIeeeAddress, this.deviceNetworkAddress, zdoClusterId, zdoPayload, false); if (!Zdo.Buffalo.checkStatus(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, force = false): Promise { // When force is true the unbind is done even when the bind is not in the bind list, additionally when the target is a number // it will not check if the group exists. const cluster = this.getCluster(clusterKey); const action = `Unbind ${this.deviceIeeeAddress}/${this.ID} ${cluster.name}`; if (typeof target === "number") { const groupTarget = Group.byGroupID(target, this.databaseID); if (groupTarget) { target = groupTarget; } else if (!force) { throw new Error(`${action} invalid target '${target}' (no group with this ID exists).`); } } const destinationAddress = target instanceof Endpoint ? target.deviceIeeeAddress : target instanceof Group ? target.groupID : target; const log = `${action} from '${target instanceof Endpoint ? `${destinationAddress}/${target.ID}` : destinationAddress}'`; const index = target instanceof Endpoint || target instanceof Group ? this.getBindIndex(cluster.ID, target) : -1; if (index === -1 && !force) { logger.debug(`${log} no bind present, skipping.`, NS); return; } logger.debug(log, NS); try { const zdoClusterId = Zdo.ClusterId.UNBIND_REQUEST; const adapter = Entity.getAdapterByID(this.databaseID); if (!adapter) { throw new Error(`No adapter found for database ID ${this.databaseID}`); } const zdoPayload = Zdo.Buffalo.buildRequest( 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 : typeof target === "number" ? target : 0, target instanceof Endpoint ? target.ID : 0xff, ); const response = await adapter.sendZdo(this.deviceIeeeAddress, this.deviceNetworkAddress, zdoClusterId, zdoPayload, false); if (!Zdo.Buffalo.checkStatus(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]); } } if (index !== -1) { 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 { 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: Cl, items: ConfigureReportingItem[], options?: Options, ): Promise { const cluster = this.getCluster(clusterKey, undefined, options?.manufacturerCode); 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): TFoundation["configReport"][number] => { let dataType: number; let attrId: number; if (typeof item.attribute === "object") { dataType = item.attribute.type; attrId = item.attribute.ID; } else { const attribute = Zcl.Utils.getClusterAttribute(cluster, item.attribute, optionsWithDefaults.manufacturerCode); if (attribute) { dataType = attribute.type; attrId = attribute.ID; } else { throw new Error(`Invalid attribute '${item.attribute}' for cluster '${clusterKey}'`); } } return { direction: Zcl.Direction.CLIENT_TO_SERVER, attrId, dataType, minRepIntval: item.minimumReportInterval, maxRepIntval: item.maximumReportInterval, repChange: item.reportableChange, }; }); await this.zclCommand(cluster, "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 as number, maxRepIntval: entry.maxRepIntval as number, // expects items[].attribute to always point to a number DataType repChange: entry.repChange as number, manufacturerCode: optionsWithDefaults.manufacturerCode, }); } } this.save(); } public async readReportingConfig( clusterKey: Cl, items: ReadReportConfigItem[], options?: Options, ): Promise { const cluster = this.getCluster(clusterKey, undefined, options?.manufacturerCode); const optionsWithDefaults = this.getOptionsWithDefaults(options, true, Zcl.Direction.CLIENT_TO_SERVER, cluster.manufacturerCode); optionsWithDefaults.manufacturerCode = this.ensureManufacturerCodeIsUniqueAndGet( cluster, items, optionsWithDefaults.manufacturerCode, "readReportingConfig", ); const payload: TFoundation["readReportConfig"] = []; for (const item of items) { if (typeof item.attribute === "object") { payload.push({direction: item.direction ?? Zcl.Direction.CLIENT_TO_SERVER, attrId: item.attribute.ID}); } else { const attribute = Zcl.Utils.getClusterAttribute(cluster, item.attribute, optionsWithDefaults.manufacturerCode); if (attribute) { payload.push({direction: item.direction ?? Zcl.Direction.CLIENT_TO_SERVER, attrId: attribute.ID}); } else { logger.warning(`Ignoring unknown attribute ${item.attribute} in cluster ${cluster.name}`, NS); } } } // TODO: could be sending empty array payload // don't check status otherwise whole command fails (we want to cherry-pick here) const response = await this.zclCommand(cluster, "readReportConfig", payload, optionsWithDefaults, items, false); if (response) { this.saveClusterAttributeReportConfig(response.cluster.ID, optionsWithDefaults.manufacturerCode, response.payload); return response.payload; } throw new Error("No response received"); } public async writeStructured( clusterKey: Cl, payload: TFoundation["writeStructured"], options?: Options, ): Promise { await this.zclCommand(clusterKey, "writeStructured", payload, options); // TODO: support `writeStructuredResponse` } public async command( clusterKey: Cl, commandKey: Co, payload: ClusterOrRawPayload, options?: Options, ): Promise { const frame = await this.zclCommand(clusterKey, commandKey, payload, options, undefined, false, Zcl.FrameType.SPECIFIC); if (frame) { return frame.payload; } } public async commandResponse( clusterKey: Cl, commandKey: Co, payload: ClusterOrRawPayload, options?: Options, transactionSequenceNumber?: number, ): Promise { assert(options?.transactionSequenceNumber === undefined, "Use parameter"); const device = this.getDevice(); const cluster = this.getCluster(clusterKey, device, options?.manufacturerCode); const command = Zcl.Utils.getClusterCommandResponse(cluster, 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, cluster, 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 { // Broadcast Green Power responses if (this.ID === 242) { await this.sendRequest(frame, optionsWithDefaults, async () => { await Entity.getAdapterByID(this.databaseID)?.sendZclFrameToAll(242, frame, 242, BroadcastAddress.RX_ON_WHEN_IDLE); }); } else { await this.sendRequest(frame, optionsWithDefaults); } } 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; } } 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[] | ReadReportConfigItem[], fallbackManufacturerCode: number | undefined, // XXX: problematic undefined for a "fallback"? caller: string, ): number | undefined { let firstManufacturerCode: number | undefined; let codeSet = false; for (const nameOrID of attributes) { let attributeID: number | string; if (typeof nameOrID === "object") { // ConfigureReportingItem if (typeof nameOrID.attribute !== "object") { attributeID = nameOrID.attribute; } else { if (!codeSet) { firstManufacturerCode = fallbackManufacturerCode; codeSet = true; } else if (firstManufacturerCode !== fallbackManufacturerCode) { throw new Error(`Cannot have attributes with different manufacturerCode in single '${caller}' call`); } continue; } } else { // string || number attributeID = nameOrID; } // we fall back to caller|cluster provided manufacturerCode const attribute = Zcl.Utils.getClusterAttribute(cluster, attributeID, undefined); const manufacturerCode = attribute ? attribute.manufacturerCode === undefined ? fallbackManufacturerCode : attribute.manufacturerCode : fallbackManufacturerCode; if (!codeSet) { firstManufacturerCode = manufacturerCode; codeSet = true; } else if (firstManufacturerCode !== manufacturerCode) { throw new Error(`Cannot have attributes with different manufacturerCode in single '${caller}' call`); } } return firstManufacturerCode; } public async addToGroup(group: Group): Promise { await this.zclCommand("genGroups", "add", {groupid: group.groupID, groupname: ""}, undefined, undefined, true, Zcl.FrameType.SPECIFIC); group.addMember(this); } private getCluster( clusterKey: number | string, device: Device | undefined = undefined, manufacturerCode: number | undefined = undefined, ): ZclTypes.Cluster { if (!device) { device = this.getDevice(); } return Zcl.Utils.getCluster(clusterKey, manufacturerCode ?? 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 { await this.zclCommand( "genGroups", "remove", {groupid: group instanceof Group ? group.groupID : group}, undefined, undefined, true, Zcl.FrameType.SPECIFIC, ); if (group instanceof Group) { group.removeMember(this); } } public async removeFromAllGroups(): Promise { await this.zclCommand("genGroups", "removeAll", {}, {disableDefaultResponse: true}, undefined, false, Zcl.FrameType.SPECIFIC); this.removeFromAllGroupsDatabase(); } public removeFromAllGroupsDatabase(): void { for (const group of Group.allByDatabaseID(this.databaseID)) { if (group.hasMember(this)) { group.removeMember(this); } } } public async zclCommand( clusterKey: Cl | ZclTypes.Cluster, commandKey: Co | ZclTypes.Command | FoundationDefinition, payload: ClusterOrRawPayload | FoundationOrRawPayload, options?: Options, logPayload?: KeyValue, checkStatus = false, frameType: Zcl.FrameType = Zcl.FrameType.GLOBAL, ): Promise { const device = this.getDevice(); const cluster = typeof clusterKey === "object" ? clusterKey : this.getCluster(clusterKey, device, options?.manufacturerCode); const command = typeof commandKey === "object" ? commandKey : frameType === Zcl.FrameType.GLOBAL ? Zcl.Utils.getGlobalCommand(commandKey) : Zcl.Utils.getClusterCommand(cluster, 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, cluster, 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 (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: Cl, commandKey: Co, payload: ClusterOrRawPayload | FoundationOrRawPayload, options?: Options, ): Promise { const device = this.getDevice(); const cluster = this.getCluster(clusterKey, device, options?.manufacturerCode); const command = Zcl.Utils.getClusterCommand(cluster, 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, cluster, 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 await Entity.getAdapterByID(this.databaseID)?.sendZclFrameToAll(endpoint, frame, sourceEndpoint, destination); } } export default Endpoint;