Skip to content
2 changes: 1 addition & 1 deletion src/controller/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ export class Controller extends events.EventEmitter<ControllerEventMap> {
zcl.manufacturerCode,
0,
zcl.commandKey,
clusterKey ?? Zcl.Clusters.touchlink.ID,
clusterKey ?? "touchlink",
zcl.payload,
customClusters,
);
Expand Down
12 changes: 7 additions & 5 deletions src/controller/greenPower.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ interface GreenPowerEventMap {
deviceLeave: [sourceID: number];
}

const COMMISSIONING_NOTIFICATION_COMMAND_ID = Zcl.Clusters.greenPower.commands.commissioningNotification.ID;

export class GreenPower extends EventEmitter<GreenPowerEventMap> {
private adapter: Adapter;

Expand Down Expand Up @@ -217,7 +219,7 @@ export class GreenPower extends EventEmitter<GreenPowerEventMap> {
undefined,
zclTransactionSequenceNumber.next(),
"pairing",
Zcl.Clusters.greenPower.ID,
"greenPower",
payload,
{},
);
Expand Down Expand Up @@ -246,7 +248,7 @@ export class GreenPower extends EventEmitter<GreenPowerEventMap> {
try {
// notification: A.3.3.4.1
// commissioningNotification: A.3.3.4.3
const isCommissioningNotification = frame.header.commandIdentifier === Zcl.Clusters.greenPower.commands.commissioningNotification.ID;
const isCommissioningNotification = frame.header.commandIdentifier === COMMISSIONING_NOTIFICATION_COMMAND_ID;
const securityLevel = isCommissioningNotification ? (frame.payload.options >> 4) & 0x3 : (frame.payload.options >> 6) & 0x3;

if (
Expand Down Expand Up @@ -378,7 +380,7 @@ export class GreenPower extends EventEmitter<GreenPowerEventMap> {
undefined,
zclTransactionSequenceNumber.next(),
"response",
Zcl.Clusters.greenPower.ID,
"greenPower",
payloadResponse,
{},
);
Expand Down Expand Up @@ -520,7 +522,7 @@ export class GreenPower extends EventEmitter<GreenPowerEventMap> {
undefined,
zclTransactionSequenceNumber.next(),
"response",
Zcl.Clusters.greenPower.ID,
"greenPower",
payload,
{},
);
Expand Down Expand Up @@ -592,7 +594,7 @@ export class GreenPower extends EventEmitter<GreenPowerEventMap> {
undefined,
zclTransactionSequenceNumber.next(),
"commisioningMode",
Zcl.Clusters.greenPower.ID,
"greenPower",
payload,
{},
);
Expand Down
32 changes: 15 additions & 17 deletions src/controller/helpers/zclFrameConverter.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,31 @@
import {logger} from "../../utils/logger";
import * as Zcl from "../../zspec/zcl";
import type {TFoundation} from "../../zspec/zcl/definition/clusters-types";
import type {Cluster, CustomClusters} from "../../zspec/zcl/definition/tstype";
import type {CustomClusters} from "../../zspec/zcl/definition/tstype";
import type {ClusterOrRawWriteAttributes, TCustomCluster} from "../tstype";

const NS = "zh:controller:zcl";

// Legrand devices (e.g. 4129) fail to set the manufacturerSpecific flag and
// manufacturerCode in the frame header, despite using specific attributes.
// This leads to incorrect reported attribute names.
// Remap the attributes using the target device's manufacturer ID
// if the header is lacking the information.
function getCluster(frame: Zcl.Frame, deviceManufacturerID: number | undefined, customClusters: CustomClusters): Cluster {
let cluster = frame.cluster;
if (!frame?.header?.manufacturerCode && frame?.cluster && deviceManufacturerID === Zcl.ManufacturerCode.LEGRAND_GROUP) {
cluster = Zcl.Utils.getCluster(frame.cluster.ID, deviceManufacturerID, customClusters);
}
return cluster;
}
const LEGRAND_GROUP_MANUF_CODE = Zcl.ManufacturerCode.LEGRAND_GROUP;

// NOTE: `legrandWorkaround`:
// Legrand devices fail to set the manufacturerSpecific flag and manufacturerCode in the frame header, despite using specific attributes.
// This leads to incorrect reported attribute names.
// Remap the attributes using the target device's manufacturer ID if the header is lacking the information.

function attributeKeyValue<Cl extends number | string, Custom extends TCustomCluster | undefined = undefined>(
frame: Zcl.Frame,
deviceManufacturerID: number | undefined,
customClusters: CustomClusters,
): ClusterOrRawWriteAttributes<Cl, Custom> {
const payload: Record<string | number, unknown> = {};
const cluster = getCluster(frame, deviceManufacturerID, customClusters);
const legrandWorkaround = frame.header?.manufacturerCode === undefined && deviceManufacturerID === LEGRAND_GROUP_MANUF_CODE;
const cluster = legrandWorkaround ? Zcl.Utils.getCluster(frame.cluster.name, deviceManufacturerID, customClusters) : frame.cluster;
const manufacturerCode = legrandWorkaround ? deviceManufacturerID : frame.header.manufacturerCode;

// TODO: remove this type once Zcl.Frame is typed
for (const item of frame.payload as TFoundation["report" | "write" | "readRsp"]) {
const attribute = cluster.getAttribute(item.attrId);
const attribute = Zcl.Utils.getClusterAttribute(cluster, item.attrId, manufacturerCode);

if (attribute) {
try {
Expand All @@ -49,11 +45,13 @@ function attributeKeyValue<Cl extends number | string, Custom extends TCustomClu

function attributeList(frame: Zcl.Frame, deviceManufacturerID: number | undefined, customClusters: CustomClusters): Array<string | number> {
const payload: Array<string | number> = [];
const cluster = getCluster(frame, deviceManufacturerID, customClusters);
const legrandWorkaround = frame.header?.manufacturerCode === undefined && deviceManufacturerID === LEGRAND_GROUP_MANUF_CODE;
const cluster = legrandWorkaround ? Zcl.Utils.getCluster(frame.cluster.name, deviceManufacturerID, customClusters) : frame.cluster;
const manufacturerCode = legrandWorkaround ? deviceManufacturerID : frame.header.manufacturerCode;

// TODO: remove this type once Zcl.Frame is typed
for (const item of frame.payload as TFoundation["read"]) {
const attribute = cluster.getAttribute(item.attrId);
const attribute = Zcl.Utils.getClusterAttribute(cluster, item.attrId, manufacturerCode);

payload.push(attribute?.name ?? item.attrId);
}
Expand Down
37 changes: 24 additions & 13 deletions src/controller/model/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {BroadcastAddress} from "../../zspec/enums";
import type {Eui64} from "../../zspec/tstypes";
import * as Zcl from "../../zspec/zcl";
import type {TClusterCommandPayload, TClusterPayload, TPartialClusterAttributes} from "../../zspec/zcl/definition/clusters-types";
import type {ClusterDefinition, CustomClusters} from "../../zspec/zcl/definition/tstype";
import type {Cluster, CustomClusters} from "../../zspec/zcl/definition/tstype";
import type {TZclFrame} from "../../zspec/zcl/zclFrame";
import * as Zdo from "../../zspec/zdo";
import type {BindingTableEntry, LQITableEntry, RoutingTableEntry} from "../../zspec/zdo/definition/tstypes";
Expand Down Expand Up @@ -43,6 +43,11 @@ const INTERVIEW_GENBASIC_ATTRIBUTES = [
"swBuildId",
] as const;

const GEN_BASIC_CLUSTER_ID = Zcl.Clusters.genBasic.ID;
const GEN_TIME_CLUSTER_ID = Zcl.Clusters.genTime.ID;
const GEN_POLL_CTRL_CLUSTER_ID = Zcl.Clusters.genPollCtrl.ID;
const GEN_OTA_CLUSTER_ID = Zcl.Clusters.genOta.ID;

type CustomReadResponse = (frame: Zcl.Frame, endpoint: Endpoint) => boolean;

export enum InterviewState {
Expand Down Expand Up @@ -357,7 +362,7 @@ export class Device extends Entity<ControllerEventMap> {
...endpoint.clusters,
};

const isTimeReadRequest = dataPayload.clusterID === Zcl.Clusters.genTime.ID;
const isTimeReadRequest = dataPayload.clusterID === GEN_TIME_CLUSTER_ID;
if (isTimeReadRequest) {
attributes.genTime = {
attributes: timeService.getTimeClusterAttributes(),
Expand All @@ -368,7 +373,8 @@ export class Device extends Entity<ControllerEventMap> {
const response: KeyValue = {};

for (const entry of frame.payload) {
const name = frame.cluster.getAttribute(entry.attrId)?.name;
// TODO: this.manufacturerID or frame.header.manufacturerCode
const name = Zcl.Utils.getClusterAttribute(frame.cluster, entry.attrId, this.manufacturerID)?.name;

if (name && name in attributes[frame.cluster.name].attributes) {
response[name] = attributes[frame.cluster.name].attributes[name];
Expand Down Expand Up @@ -518,7 +524,7 @@ export class Device extends Entity<ControllerEventMap> {

// default: no timeout (messages expire immediately after first send attempt)
let pendingRequestTimeout = 0;
if (endpoints.filter((e): boolean => e.inputClusters.includes(Zcl.Clusters.genPollCtrl.ID)).length > 0) {
if (endpoints.filter((e): boolean => e.inputClusters.includes(GEN_POLL_CTRL_CLUSTER_ID)).length > 0) {
// default for devices that support genPollCtrl cluster (RX off when idle): 1 day
pendingRequestTimeout = 86400000;
}
Expand Down Expand Up @@ -1319,29 +1325,34 @@ export class Device extends Entity<ControllerEventMap> {
// Zigbee does not have an official pinging mechanism. Use a read request
// of a mandatory basic cluster attribute to keep it as lightweight as
// possible.
const endpoint = this.endpoints.find((ep) => ep.inputClusters.includes(0)) ?? this.endpoints[0];
const endpoint = this.endpoints.find((ep) => ep.inputClusters.includes(GEN_BASIC_CLUSTER_ID)) ?? this.endpoints[0];
await endpoint.read("genBasic", ["zclVersion"], {disableRecovery, sendPolicy: "immediate"});
}

public addCustomCluster(name: string, cluster: ClusterDefinition): void {
public addCustomCluster(name: string, cluster: Cluster): void {
assert(
![Zcl.Clusters.touchlink.ID, Zcl.Clusters.greenPower.ID].includes(cluster.ID),
cluster.ID !== Zcl.Clusters.touchlink.ID && cluster.ID !== Zcl.Clusters.greenPower.ID,
"Overriding of greenPower or touchlink cluster is not supported",
);
if (Zcl.Utils.isClusterName(name)) {
const existingCluster = this._customClusters[name] ?? Zcl.Clusters[name];

if (Zcl.Utils.isClusterName(name)) {
// Extend existing cluster
const existingCluster = this._customClusters[name] ?? Zcl.Clusters[name];
assert(existingCluster.ID === cluster.ID, `Custom cluster ID (${cluster.ID}) should match existing cluster ID (${existingCluster.ID})`);
cluster = {

const extendedCluster: Cluster = {
name: cluster.name,
ID: cluster.ID,
manufacturerCode: cluster.manufacturerCode,
attributes: {...existingCluster.attributes, ...cluster.attributes},
commands: {...existingCluster.commands, ...cluster.commands},
commandsResponse: {...existingCluster.commandsResponse, ...cluster.commandsResponse},
};

this._customClusters[name] = extendedCluster;
} else {
this._customClusters[name] = cluster;
}
this._customClusters[name] = cluster;
}

#waitForOtaCommand<Co extends string>(
Expand All @@ -1356,7 +1367,7 @@ export class Device extends Entity<ControllerEventMap> {
Zcl.FrameType.SPECIFIC,
Zcl.Direction.CLIENT_TO_SERVER,
transactionSequenceNumber,
Zcl.Clusters.genOta.ID,
GEN_OTA_CLUSTER_ID,
commandId,
timeout,
);
Expand Down Expand Up @@ -1699,7 +1710,7 @@ export class Device extends Entity<ControllerEventMap> {
await endpoint.defaultResponse(
Zcl.Clusters.genOta.commands.upgradeEndRequest.ID,
Zcl.Status.SUCCESS,
Zcl.Clusters.genOta.ID,
GEN_OTA_CLUSTER_ID,
endResult.header.transactionSequenceNumber,
);
} catch (error) {
Expand Down
34 changes: 15 additions & 19 deletions src/controller/model/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export class Endpoint extends ZigbeeEntity {

return this._configuredReportings.map((entry, index) => {
const cluster = Zcl.Utils.getCluster(entry.cluster, entry.manufacturerCode, device.customClusters);
const attribute: ZclTypes.Attribute = cluster.getAttribute(entry.attrId) ?? {
const attribute: ZclTypes.Attribute = Zcl.Utils.getClusterAttribute(cluster, entry.attrId, entry.manufacturerCode) ?? {
ID: entry.attrId,
name: `attr${index}`,
type: Zcl.DataType.UNKNOWN,
Expand Down Expand Up @@ -224,18 +224,14 @@ export class Endpoint extends ZigbeeEntity {
* @returns {ZclTypes.Cluster[]}
*/
public getInputClusters(): ZclTypes.Cluster[] {
return this.clusterNumbersToClusters(this.inputClusters);
return this.inputClusters.map((c) => this.getCluster(c));
}

/**
* @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));
return this.outputClusters.map((c) => this.getCluster(c));
}

/*
Expand Down Expand Up @@ -327,7 +323,7 @@ export class Endpoint extends ZigbeeEntity {

if (this.clusters[cluster.name] && this.clusters[cluster.name].attributes) {
// XXX: used to throw (behavior changed in #1455)
const attribute = cluster.getAttribute(attributeKey);
const attribute = Zcl.Utils.getClusterAttribute(cluster, attributeKey, undefined);

if (attribute) {
return this.clusters[cluster.name].attributes[attribute.name];
Expand Down Expand Up @@ -472,7 +468,7 @@ export class Endpoint extends ZigbeeEntity {
// TODO: handle `attr.report !== true`

for (const nameOrID in attributes) {
const attribute = cluster.getAttribute(nameOrID);
const attribute = Zcl.Utils.getClusterAttribute(cluster, nameOrID, options?.manufacturerCode);

if (attribute) {
payload.push({attrId: attribute.ID, attrData: attributes[nameOrID], dataType: attribute.type});
Expand Down Expand Up @@ -504,7 +500,7 @@ export class Endpoint extends ZigbeeEntity {
const payload: TFoundation["write"] = [];

for (const nameOrID in attributes) {
const attribute = cluster.getAttribute(nameOrID);
const attribute = Zcl.Utils.getClusterAttribute(cluster, nameOrID, options?.manufacturerCode);

if (attribute) {
// TODO: handle `attr.writeOptional !== true`
Expand Down Expand Up @@ -539,7 +535,7 @@ export class Endpoint extends ZigbeeEntity {
const value = attributes[nameOrID]!;

if (value.status !== undefined) {
const attribute = cluster.getAttribute(nameOrID);
const attribute = Zcl.Utils.getClusterAttribute(cluster, nameOrID, options?.manufacturerCode);

if (attribute) {
payload.push({attrId: attribute.ID, status: value.status});
Expand Down Expand Up @@ -585,7 +581,7 @@ export class Endpoint extends ZigbeeEntity {
if (typeof attribute === "number") {
payload.push({attrId: attribute});
} else {
const attr = cluster.getAttribute(attribute);
const attr = Zcl.Utils.getClusterAttribute(cluster, attribute, options?.manufacturerCode);

if (attr) {
Zcl.Utils.processAttributePreRead(attr);
Expand Down Expand Up @@ -616,7 +612,7 @@ export class Endpoint extends ZigbeeEntity {
const payload: TFoundation["readRsp"] = [];

for (const nameOrID in attributes) {
const attribute = cluster.getAttribute(nameOrID);
const attribute = Zcl.Utils.getClusterAttribute(cluster, nameOrID, options?.manufacturerCode);

if (attribute) {
payload.push({attrId: attribute.ID, attrData: attributes[nameOrID], dataType: attribute.type, status: 0});
Expand Down Expand Up @@ -833,7 +829,7 @@ export class Endpoint extends ZigbeeEntity {
dataType = item.attribute.type;
attrId = item.attribute.ID;
} else {
const attribute = cluster.getAttribute(item.attribute);
const attribute = Zcl.Utils.getClusterAttribute(cluster, item.attribute, optionsWithDefaults.manufacturerCode);

if (attribute) {
dataType = attribute.type;
Expand Down Expand Up @@ -903,7 +899,7 @@ export class Endpoint extends ZigbeeEntity {
if (typeof item.attribute === "object") {
payload.push({direction: item.direction ?? Zcl.Direction.CLIENT_TO_SERVER, attrId: item.attribute.ID});
} else {
const attribute = cluster.getAttribute(item.attribute);
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});
Expand Down Expand Up @@ -958,7 +954,7 @@ export class Endpoint extends ZigbeeEntity {

const device = this.getDevice();
const cluster = this.getCluster(clusterKey, device, options?.manufacturerCode);
const command = cluster.getCommandResponse(commandKey);
const command = Zcl.Utils.getClusterCommandResponse(cluster, commandKey);
transactionSequenceNumber = transactionSequenceNumber ?? zclTransactionSequenceNumber.next();
const optionsWithDefaults = this.getOptionsWithDefaults(options, true, Zcl.Direction.SERVER_TO_CLIENT, cluster.manufacturerCode);

Expand Down Expand Up @@ -1051,7 +1047,7 @@ export class Endpoint extends ZigbeeEntity {
}

// we fall back to caller|cluster provided manufacturerCode
const attribute = cluster.getAttribute(attributeID);
const attribute = Zcl.Utils.getClusterAttribute(cluster, attributeID, undefined);
const manufacturerCode = attribute
? attribute.manufacturerCode === undefined
? fallbackManufacturerCode
Expand Down Expand Up @@ -1136,7 +1132,7 @@ export class Endpoint extends ZigbeeEntity {
? commandKey
: frameType === Zcl.FrameType.GLOBAL
? Zcl.Utils.getGlobalCommand(commandKey)
: cluster.getCommand(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);

Expand Down Expand Up @@ -1187,7 +1183,7 @@ export class Endpoint extends ZigbeeEntity {
): Promise<void> {
const device = this.getDevice();
const cluster = this.getCluster(clusterKey, device, options?.manufacturerCode);
const command = cluster.getCommand(commandKey);
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;

Expand Down
Loading