zigbee-herdsman
Version:
An open source ZigBee gateway solution with node.js.
1,041 lines (905 loc) • 41.4 kB
text/typescript
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;