From c50203b2cd28efef19ccad33a8419dd8aa5b2ef3 Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:07:11 +0100 Subject: [PATCH 01/10] fix: improve performance with clusters handling --- src/controller/helpers/zclFrameConverter.ts | 14 +-- src/controller/model/device.ts | 19 ++-- src/zspec/zcl/definition/tstype.ts | 4 +- src/zspec/zcl/utils.ts | 108 ++++++++++---------- test/zspec/zcl/utils.test.ts | 15 --- 5 files changed, 73 insertions(+), 87 deletions(-) diff --git a/src/controller/helpers/zclFrameConverter.ts b/src/controller/helpers/zclFrameConverter.ts index fb7b50b14d..e8bfa8cfed 100644 --- a/src/controller/helpers/zclFrameConverter.ts +++ b/src/controller/helpers/zclFrameConverter.ts @@ -6,17 +6,13 @@ 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. +// 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. +// 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; + return frame.header?.manufacturerCode === undefined && deviceManufacturerID === Zcl.ManufacturerCode.LEGRAND_GROUP + ? Zcl.Utils.getCluster(frame.cluster.name, deviceManufacturerID, customClusters) + : frame.cluster; } function attributeKeyValue( diff --git a/src/controller/model/device.ts b/src/controller/model/device.ts index e66c77a186..75bd8b9871 100755 --- a/src/controller/model/device.ts +++ b/src/controller/model/device.ts @@ -1325,23 +1325,30 @@ export class Device extends Entity { public addCustomCluster(name: string, cluster: ClusterDefinition): 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: ClusterDefinition = { ID: cluster.ID, - manufacturerCode: cluster.manufacturerCode, attributes: {...existingCluster.attributes, ...cluster.attributes}, commands: {...existingCluster.commands, ...cluster.commands}, commandsResponse: {...existingCluster.commandsResponse, ...cluster.commandsResponse}, }; + + if (cluster.manufacturerCode !== undefined) { + extendedCluster.manufacturerCode = cluster.manufacturerCode; + } + + this._customClusters[name] = extendedCluster; + } else { + this._customClusters[name] = cluster; } - this._customClusters[name] = cluster; } #waitForOtaCommand( diff --git a/src/zspec/zcl/definition/tstype.ts b/src/zspec/zcl/definition/tstype.ts index b5980ec644..82e53202d1 100644 --- a/src/zspec/zcl/definition/tstype.ts +++ b/src/zspec/zcl/definition/tstype.ts @@ -257,9 +257,7 @@ export interface Command { export interface AttributeDefinition extends Omit {} -export interface CommandDefinition extends Omit { - parameters: readonly Parameter[]; -} +export interface CommandDefinition extends Omit {} export interface Cluster { ID: number; diff --git a/src/zspec/zcl/utils.ts b/src/zspec/zcl/utils.ts index 47888faaf3..46d3d68bf8 100644 --- a/src/zspec/zcl/utils.ts +++ b/src/zspec/zcl/utils.ts @@ -70,7 +70,7 @@ const FOUNDATION_DISCOVER_RSP_IDS = [ /** Runtime fast lookup */ const ZCL_CLUSTERS_ID_TO_NAMES = (() => { - const map = new Map(); + const map = new Map(); for (const clusterName in Clusters) { const cluster = Clusters[clusterName as ClusterName]; @@ -78,9 +78,9 @@ const ZCL_CLUSTERS_ID_TO_NAMES = (() => { const mapEntry = map.get(cluster.ID); if (mapEntry) { - mapEntry.push(clusterName); + mapEntry.push(clusterName as ClusterName); } else { - map.set(cluster.ID, [clusterName]); + map.set(cluster.ID, [clusterName as ClusterName]); } } @@ -111,60 +111,65 @@ function hasCustomClusters(customClusters: CustomClusters): boolean { * - 'manuSpecificPhilips', 'manuSpecificAssaDoorLock' * - 'elkoSwitchConfigurationClusterServer', 'manuSpecificSchneiderLightSwitchConfiguration' */ -function findClusterNameByID( - id: number, - manufacturerCode: number | undefined, - clusters: typeof Clusters | CustomClusters, - zcl: boolean, -): [name: string | undefined, partialMatch: boolean] { +function findZclClusterNameById(id: number, manufacturerCode: number | undefined): [name: string | undefined, partialMatch: boolean] { let name: string | undefined; // if manufacturer code is given, consider partial match if didn't match against manufacturer code let partialMatch = Boolean(manufacturerCode); - if (zcl) { - const zclNames = ZCL_CLUSTERS_ID_TO_NAMES.get(id); + const zclNames = ZCL_CLUSTERS_ID_TO_NAMES.get(id); - if (zclNames) { - for (const zclName of zclNames) { - const cluster = clusters[zclName as ClusterName]; + if (zclNames) { + for (const zclName of zclNames) { + const cluster = Clusters[zclName as ClusterName]; - // priority on first match when matching only ID - if (name === undefined) { - name = zclName; - } + // priority on first match when matching only ID + if (name === undefined) { + name = zclName; + } - if (manufacturerCode && cluster.manufacturerCode === manufacturerCode) { - name = zclName; - partialMatch = false; - break; - } + if (manufacturerCode && cluster.manufacturerCode === manufacturerCode) { + name = zclName; + partialMatch = false; + break; + } - if (!cluster.manufacturerCode) { - name = zclName; - break; - } + if (!cluster.manufacturerCode) { + name = zclName; + break; } } - } else { - for (const clusterName in clusters) { - const cluster = clusters[clusterName as ClusterName]; + } - if (cluster.ID === id) { - // priority on first match when matching only ID - if (name === undefined) { - name = clusterName; - } + return [name, partialMatch]; +} - if (manufacturerCode && cluster.manufacturerCode === manufacturerCode) { - name = clusterName; - partialMatch = false; - break; - } +function findCustomClusterNameByID( + id: number, + manufacturerCode: number | undefined, + customClusters: CustomClusters, +): [name: string | undefined, partialMatch: boolean] { + let name: string | undefined; + // if manufacturer code is given, consider partial match if didn't match against manufacturer code + let partialMatch = Boolean(manufacturerCode); - if (!cluster.manufacturerCode) { - name = clusterName; - break; - } + for (const clusterName in customClusters) { + const cluster = customClusters[clusterName as ClusterName]; + + if (cluster.ID === id) { + // priority on first match when matching only ID + if (name === undefined) { + name = clusterName; + } + + if (manufacturerCode && cluster.manufacturerCode === manufacturerCode) { + name = clusterName; + partialMatch = false; + break; + } + + if (!cluster.manufacturerCode) { + name = clusterName; + break; } } } @@ -183,13 +188,14 @@ function getClusterDefinition( let partialMatch: boolean; // custom clusters have priority over Zcl clusters, except in case of better match (see below) - [name, partialMatch] = findClusterNameByID(key, manufacturerCode, customClusters, false); + [name, partialMatch] = findCustomClusterNameByID(key, manufacturerCode, customClusters); if (!name) { - [name, partialMatch] = findClusterNameByID(key, manufacturerCode, Clusters, true); + [name, partialMatch] = findZclClusterNameById(key, manufacturerCode); } else if (partialMatch) { + // TODO: remove block once custom clusters fully migrated to ZHC let zclName: string | undefined; - [zclName, partialMatch] = findClusterNameByID(key, manufacturerCode, Clusters, true); + [zclName, partialMatch] = findZclClusterNameById(key, manufacturerCode); // Zcl clusters contain a better match, use that one if (zclName !== undefined && !partialMatch) { @@ -200,13 +206,7 @@ function getClusterDefinition( name = key; } - let cluster = - name !== undefined && hasCustomClusters(customClusters) - ? { - ...Clusters[name as ClusterName], - ...customClusters[name], // should override Zcl clusters - } - : Clusters[name as ClusterName]; + let cluster = name !== undefined && hasCustomClusters(customClusters) ? customClusters[name] : Clusters[name as ClusterName]; if (!cluster || cluster.ID === undefined) { if (typeof key === "number") { diff --git a/test/zspec/zcl/utils.test.ts b/test/zspec/zcl/utils.test.ts index 399ceabe21..d42cb10db7 100644 --- a/test/zspec/zcl/utils.test.ts +++ b/test/zspec/zcl/utils.test.ts @@ -13,12 +13,6 @@ const CUSTOM_CLUSTERS: CustomClusters = { commandsResponse: {}, attributes: {myCustomAttr: {ID: 65533, type: Zcl.DataType.UINT8}}, }, - manuSpecificProfalux1NoManuf: { - ID: Zcl.Clusters.manuSpecificProfalux1.ID, - commands: {}, - commandsResponse: {}, - attributes: {myCustomAttr: {ID: 65533, type: Zcl.DataType.UINT8}}, - }, }; describe("ZCL Utils", () => { @@ -94,15 +88,6 @@ describe("ZCL Utils", () => { {key: CUSTOM_CLUSTERS.genBasic.ID, manufacturerCode: undefined, customClusters: CUSTOM_CLUSTERS}, {cluster: CUSTOM_CLUSTERS.genBasic, name: "genBasic"}, ], - [ - "by ID ignoring same custom ID if Zcl is better match with manufacturer code", - { - key: CUSTOM_CLUSTERS.manuSpecificProfalux1NoManuf.ID, - manufacturerCode: Zcl.ManufacturerCode.PROFALUX, - customClusters: CUSTOM_CLUSTERS, - }, - {cluster: Zcl.Clusters.manuSpecificProfalux1, name: "manuSpecificProfalux1"}, - ], ])("Gets cluster %s", (_name, payload, expected) => { const cluster = Zcl.Utils.getCluster(payload.key, payload.manufacturerCode, payload.customClusters); From 3ba343f60771eecd8a3958eac2abef8a55af55aa Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Wed, 25 Feb 2026 23:12:36 +0100 Subject: [PATCH 02/10] fix: remove runtime cluster gen --- src/controller/helpers/zclFrameConverter.ts | 28 +-- src/controller/model/device.ts | 10 +- src/controller/model/endpoint.ts | 26 +-- src/controller/model/group.ts | 8 +- src/zspec/zcl/buffaloZcl.ts | 3 +- src/zspec/zcl/definition/cluster.ts | 38 +++- src/zspec/zcl/definition/tstype.ts | 17 +- src/zspec/zcl/utils.ts | 212 ++++++++------------ src/zspec/zcl/zclFrame.ts | 12 +- test/controller.test.ts | 126 +++++++++--- test/zcl.test.ts | 50 ++--- test/zspec/zcl/utils.test.ts | 95 +++++---- 12 files changed, 332 insertions(+), 293 deletions(-) diff --git a/src/controller/helpers/zclFrameConverter.ts b/src/controller/helpers/zclFrameConverter.ts index e8bfa8cfed..26c0d56381 100644 --- a/src/controller/helpers/zclFrameConverter.ts +++ b/src/controller/helpers/zclFrameConverter.ts @@ -1,19 +1,17 @@ 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 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 { - return frame.header?.manufacturerCode === undefined && deviceManufacturerID === Zcl.ManufacturerCode.LEGRAND_GROUP - ? Zcl.Utils.getCluster(frame.cluster.name, deviceManufacturerID, customClusters) - : frame.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( frame: Zcl.Frame, @@ -21,11 +19,13 @@ function attributeKeyValue { const payload: Record = {}; - 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 { @@ -45,11 +45,13 @@ function attributeKeyValue { const payload: Array = []; - 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); } diff --git a/src/controller/model/device.ts b/src/controller/model/device.ts index 75bd8b9871..d920687a24 100755 --- a/src/controller/model/device.ts +++ b/src/controller/model/device.ts @@ -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"; @@ -368,7 +368,8 @@ export class Device extends Entity { 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]; @@ -1323,7 +1324,7 @@ export class Device extends Entity { await endpoint.read("genBasic", ["zclVersion"], {disableRecovery, sendPolicy: "immediate"}); } - public addCustomCluster(name: string, cluster: ClusterDefinition): void { + public addCustomCluster(name: string, cluster: Cluster): void { assert( cluster.ID !== Zcl.Clusters.touchlink.ID && cluster.ID !== Zcl.Clusters.greenPower.ID, "Overriding of greenPower or touchlink cluster is not supported", @@ -1334,7 +1335,8 @@ export class Device extends Entity { 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})`); - const extendedCluster: ClusterDefinition = { + const extendedCluster: Cluster = { + name: cluster.name, ID: cluster.ID, attributes: {...existingCluster.attributes, ...cluster.attributes}, commands: {...existingCluster.commands, ...cluster.commands}, diff --git a/src/controller/model/endpoint.ts b/src/controller/model/endpoint.ts index 9217e55923..ddc48ed131 100644 --- a/src/controller/model/endpoint.ts +++ b/src/controller/model/endpoint.ts @@ -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, @@ -327,7 +327,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]; @@ -472,7 +472,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}); @@ -504,7 +504,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` @@ -539,7 +539,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}); @@ -585,7 +585,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); @@ -616,7 +616,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}); @@ -833,7 +833,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; @@ -903,7 +903,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}); @@ -958,7 +958,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); @@ -1051,7 +1051,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 @@ -1136,7 +1136,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); @@ -1187,7 +1187,7 @@ export class Endpoint extends ZigbeeEntity { ): Promise { 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; diff --git a/src/controller/model/group.ts b/src/controller/model/group.ts index 7c47dcd9a5..3752d24aa8 100644 --- a/src/controller/model/group.ts +++ b/src/controller/model/group.ts @@ -268,7 +268,7 @@ export class Group 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) { const attrData = Zcl.Utils.processAttributeWrite(attribute, attributes[nameOrID]); @@ -328,7 +328,7 @@ export class Group 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); @@ -379,8 +379,8 @@ export class Group extends ZigbeeEntity { const optionsWithDefaults = this.getOptionsWithDefaults(options, Zcl.Direction.CLIENT_TO_SERVER, cluster.manufacturerCode); const command = optionsWithDefaults.direction === Zcl.Direction.CLIENT_TO_SERVER - ? cluster.getCommand(commandKey) - : cluster.getCommandResponse(commandKey); + ? Zcl.Utils.getClusterCommand(cluster, commandKey) + : Zcl.Utils.getClusterCommandResponse(cluster, commandKey); const createLogMessage = (): string => `Command ${this.groupID} ${cluster.name}.${command.name}(${JSON.stringify(payload)})`; logger.debug(createLogMessage, NS); diff --git a/src/zspec/zcl/buffaloZcl.ts b/src/zspec/zcl/buffaloZcl.ts index 49f7c83876..caa48f7545 100644 --- a/src/zspec/zcl/buffaloZcl.ts +++ b/src/zspec/zcl/buffaloZcl.ts @@ -24,6 +24,7 @@ import type { ZoneInfo, } from "./definition/tstype"; import * as Utils from "./utils"; +import {getClusterAttribute} from "./utils"; const NS = "zh:zcl:buffalo"; @@ -496,7 +497,7 @@ export class BuffaloZcl extends Buffalo { const attributeID = this.readUInt16(); const type = this.readUInt8(); /* v8 ignore next */ - let attribute: string | undefined | number = cluster.getAttribute(attributeID)?.name; + let attribute: string | undefined | number = getClusterAttribute(cluster, attributeID, frame.manufacturerCode)?.name; // number type is only used when going into this if if (!attribute) { diff --git a/src/zspec/zcl/definition/cluster.ts b/src/zspec/zcl/definition/cluster.ts index d70f15e552..0c1e93305f 100644 --- a/src/zspec/zcl/definition/cluster.ts +++ b/src/zspec/zcl/definition/cluster.ts @@ -1,9 +1,19 @@ import {BuffaloZclDataType, DataType, ParameterCondition} from "./enums"; import {ManufacturerCode} from "./manufacturerCode"; import {Status} from "./status"; -import type {ClusterDefinition, ClusterName} from "./tstype"; +import type {Attribute, Cluster, ClusterName, Command} from "./tstype"; -export const Clusters: Readonly>> = { +interface AttributeDefinition extends Omit {} + +interface CommandDefinition extends Omit {} + +interface ClusterDefinition extends Omit { + attributes: Readonly>>; + commands: Readonly>>; + commandsResponse: Readonly>>; +} + +const RawClusters: Readonly>> = { genBasic: { ID: 0x0000, attributes: { @@ -7780,3 +7790,27 @@ export const Clusters: Readonly> commandsResponse: {}, }, }; + +// TODO: tmp, should just add `name` prop directly +for (const clusterKey in RawClusters) { + const cluster = RawClusters[clusterKey as ClusterName]; + // @ts-expect-error tmp + cluster.name = clusterKey; + + for (const attributeKey in cluster.attributes) { + // @ts-expect-error tmp + cluster.attributes[attributeKey].name = attributeKey; + } + + for (const commandKey in cluster.commands) { + // @ts-expect-error tmp + cluster.commands[commandKey].name = commandKey; + } + + for (const commandResponseKey in cluster.commandsResponse) { + // @ts-expect-error tmp + cluster.commandsResponse[commandResponseKey].name = commandResponseKey; + } +} + +export const Clusters = RawClusters as Readonly>>; diff --git a/src/zspec/zcl/definition/tstype.ts b/src/zspec/zcl/definition/tstype.ts index 82e53202d1..8b56328c03 100644 --- a/src/zspec/zcl/definition/tstype.ts +++ b/src/zspec/zcl/definition/tstype.ts @@ -255,10 +255,6 @@ export interface Command { required?: true; } -export interface AttributeDefinition extends Omit {} - -export interface CommandDefinition extends Omit {} - export interface Cluster { ID: number; name: string; @@ -270,21 +266,10 @@ export interface Cluster { commandsResponse: { [s: string]: Command; }; - getAttribute: (key: number | string) => Attribute | undefined; - getCommand: (key: number | string) => Command; - getCommandResponse: (key: number | string) => Command; -} - -export interface ClusterDefinition { - ID: number; - manufacturerCode?: number; - attributes: Readonly>>; - commands: Readonly>>; - commandsResponse: Readonly>>; } export interface CustomClusters { - [k: string]: ClusterDefinition; + [k: string]: Cluster; } export type ClusterName = diff --git a/src/zspec/zcl/utils.ts b/src/zspec/zcl/utils.ts index 46d3d68bf8..41b2bbcb72 100644 --- a/src/zspec/zcl/utils.ts +++ b/src/zspec/zcl/utils.ts @@ -2,7 +2,7 @@ import {Clusters} from "./definition/cluster"; import {ZCL_TYPE_INVALID_BY_TYPE} from "./definition/datatypes"; import {DataType, DataTypeClass} from "./definition/enums"; import {Foundation, type FoundationCommandName, type FoundationDefinition} from "./definition/foundation"; -import type {Attribute, Cluster, ClusterDefinition, ClusterName, Command, CustomClusters, Parameter} from "./definition/tstype"; +import type {Attribute, Cluster, ClusterName, Command, CustomClusters, Parameter} from "./definition/tstype"; const DATA_TYPE_CLASS_DISCRETE = [ DataType.DATA8, @@ -99,226 +99,182 @@ export function getDataTypeClass(dataType: DataType): DataTypeClass { throw new Error(`Don't know value type for '${DataType[dataType]}'`); } -function hasCustomClusters(customClusters: CustomClusters): boolean { - // XXX: was there a good reason to not set the parameter `customClusters` optional? it would allow simple undefined check - // below is twice faster than checking `Object.keys(customClusters).length` - for (const _k in customClusters) return true; - return false; -} - /** * This can be greatly optimized when `clusters==ZCL` once these have been moved out of ZH (can just use fast lookup ): * - 'manuSpecificPhilips', 'manuSpecificAssaDoorLock' * - 'elkoSwitchConfigurationClusterServer', 'manuSpecificSchneiderLightSwitchConfiguration' */ -function findZclClusterNameById(id: number, manufacturerCode: number | undefined): [name: string | undefined, partialMatch: boolean] { - let name: string | undefined; +function findZclClusterById(id: number, manufacturerCode: number | undefined): [cluster: Readonly | undefined, partialMatch: boolean] { + let cluster: Readonly | undefined; // if manufacturer code is given, consider partial match if didn't match against manufacturer code - let partialMatch = Boolean(manufacturerCode); + let partialMatch = !!manufacturerCode; const zclNames = ZCL_CLUSTERS_ID_TO_NAMES.get(id); if (zclNames) { for (const zclName of zclNames) { - const cluster = Clusters[zclName as ClusterName]; + const foundCluster = Clusters[zclName as ClusterName]; // priority on first match when matching only ID - if (name === undefined) { - name = zclName; + if (cluster === undefined) { + cluster = foundCluster; } - if (manufacturerCode && cluster.manufacturerCode === manufacturerCode) { - name = zclName; + if (manufacturerCode && foundCluster.manufacturerCode === manufacturerCode) { + cluster = foundCluster; partialMatch = false; break; } - if (!cluster.manufacturerCode) { - name = zclName; + if (!foundCluster.manufacturerCode) { + cluster = foundCluster; break; } } } - return [name, partialMatch]; + return [cluster, partialMatch]; } -function findCustomClusterNameByID( +function findCustomClusterByID( id: number, manufacturerCode: number | undefined, customClusters: CustomClusters, -): [name: string | undefined, partialMatch: boolean] { - let name: string | undefined; +): [cluster: Readonly | undefined, partialMatch: boolean] { + let cluster: Readonly | undefined; // if manufacturer code is given, consider partial match if didn't match against manufacturer code - let partialMatch = Boolean(manufacturerCode); + let partialMatch = !!manufacturerCode; for (const clusterName in customClusters) { - const cluster = customClusters[clusterName as ClusterName]; + const foundCluster = customClusters[clusterName as ClusterName]; - if (cluster.ID === id) { + if (foundCluster.ID === id) { // priority on first match when matching only ID - if (name === undefined) { - name = clusterName; + if (cluster === undefined) { + cluster = foundCluster; } - if (manufacturerCode && cluster.manufacturerCode === manufacturerCode) { - name = clusterName; + if (manufacturerCode && foundCluster.manufacturerCode === manufacturerCode) { + cluster = foundCluster; partialMatch = false; break; } - if (!cluster.manufacturerCode) { - name = clusterName; + if (!foundCluster.manufacturerCode) { + cluster = foundCluster; break; } } } - return [name, partialMatch]; + return [cluster, partialMatch]; } -function getClusterDefinition( - key: string | number, - manufacturerCode: number | undefined, - customClusters: CustomClusters, -): {name: string; cluster: ClusterDefinition} { - let name: string | undefined; +export function getCluster(key: string | number, manufacturerCode: number | undefined = undefined, customClusters: CustomClusters = {}): Cluster { + let cluster: Readonly | undefined; if (typeof key === "number") { let partialMatch: boolean; // custom clusters have priority over Zcl clusters, except in case of better match (see below) - [name, partialMatch] = findCustomClusterNameByID(key, manufacturerCode, customClusters); + [cluster, partialMatch] = findCustomClusterByID(key, manufacturerCode, customClusters); - if (!name) { - [name, partialMatch] = findZclClusterNameById(key, manufacturerCode); + if (!cluster) { + [cluster, partialMatch] = findZclClusterById(key, manufacturerCode); } else if (partialMatch) { // TODO: remove block once custom clusters fully migrated to ZHC - let zclName: string | undefined; - [zclName, partialMatch] = findZclClusterNameById(key, manufacturerCode); + let zclCluster: Readonly | undefined; + [zclCluster, partialMatch] = findZclClusterById(key, manufacturerCode); // Zcl clusters contain a better match, use that one - if (zclName !== undefined && !partialMatch) { - name = zclName; + if (zclCluster !== undefined && !partialMatch) { + cluster = zclCluster; } } } else { - name = key; + cluster = key in customClusters ? customClusters[key] : Clusters[key as ClusterName]; } - let cluster = name !== undefined && hasCustomClusters(customClusters) ? customClusters[name] : Clusters[name as ClusterName]; - + // TODO: cluster.ID can't be undefined? if (!cluster || cluster.ID === undefined) { if (typeof key === "number") { - name = key.toString(); - cluster = {attributes: {}, commands: {}, commandsResponse: {}, manufacturerCode: undefined, ID: key}; + cluster = {name: `${key}`, ID: key, attributes: {}, commands: {}, commandsResponse: {}}; } else { - name = undefined; + throw new Error(`Cluster '${key}' does not exist`); } } - if (!name) { - throw new Error(`Cluster with name '${key}' does not exist`); - } - - return {name, cluster}; -} - -function cloneClusterEntriesWithName>(entries: Record): Record { - const clone: Record = {}; - - for (const key in entries) { - clone[key] = {...entries[key], name: key}; - } - - return clone; + return cluster; } -function createCluster(name: string, cluster: ClusterDefinition, manufacturerCode?: number): Cluster { - const attributes: Record = cloneClusterEntriesWithName(cluster.attributes); - const commands: Record = cloneClusterEntriesWithName(cluster.commands); - const commandsResponse: Record = cloneClusterEntriesWithName(cluster.commandsResponse); +export function getClusterAttribute(cluster: Cluster, key: number | string, manufacturerCode: number | undefined): Attribute | undefined { + const attributes = cluster.attributes; - const getAttribute = (key: number | string): Attribute | undefined => { - if (typeof key === "number") { - let partialMatchAttr: Attribute | undefined; + if (typeof key === "number") { + let partialMatchAttr: Attribute | undefined; - for (const attrKey in attributes) { - const attr = attributes[attrKey]; + for (const attrKey in attributes) { + const attr = attributes[attrKey]; - if (attr.ID === key) { - if (manufacturerCode && attr.manufacturerCode === manufacturerCode) { - return attr; - } + if (attr.ID === key) { + if (manufacturerCode !== undefined && attr.manufacturerCode === manufacturerCode) { + return attr; + } - if (attr.manufacturerCode === undefined) { - partialMatchAttr = attr; - } + if (attr.manufacturerCode === undefined) { + partialMatchAttr = attr; } } - - return partialMatchAttr; } - return attributes[key]; - }; + return partialMatchAttr; + } - const getCommand = (key: number | string): Command => { - if (typeof key === "number") { - for (const cmdKey in commands) { - const cmd = commands[cmdKey]; + return attributes[key]; +} - if (cmd.ID === key) { - return cmd; - } - } - } else { - const cmd = commands[key]; +export function getClusterCommand(cluster: Cluster, key: number | string): Command { + const commands = cluster.commands; + + if (typeof key === "number") { + for (const cmdKey in commands) { + const cmd = commands[cmdKey]; - if (cmd) { + if (cmd.ID === key) { return cmd; } } + } else { + const cmd = commands[key]; - throw new Error(`Cluster '${name}' has no command '${key}'`); - }; + if (cmd) { + return cmd; + } + } - const getCommandResponse = (key: number | string): Command => { - if (typeof key === "number") { - for (const cmdKey in commandsResponse) { - const cmd = commandsResponse[cmdKey]; + throw new Error(`Cluster '${cluster.name}' has no command '${key}'`); +} - if (cmd.ID === key) { - return cmd; - } - } - } else { - const cmd = commandsResponse[key]; +export function getClusterCommandResponse(cluster: Cluster, key: number | string): Command { + const commandResponses = cluster.commandsResponse; - if (cmd) { + if (typeof key === "number") { + for (const cmdKey in commandResponses) { + const cmd = commandResponses[cmdKey]; + + if (cmd.ID === key) { return cmd; } } + } else { + const cmd = commandResponses[key]; - throw new Error(`Cluster '${name}' has no command response '${key}'`); - }; - - return { - ID: cluster.ID, - attributes, - manufacturerCode: cluster.manufacturerCode, - name, - commands, - commandsResponse, - getAttribute, - getCommand, - getCommandResponse, - }; -} + if (cmd) { + return cmd; + } + } -export function getCluster(key: string | number, manufacturerCode: number | undefined = undefined, customClusters: CustomClusters = {}): Cluster { - const {name, cluster} = getClusterDefinition(key, manufacturerCode, customClusters); - return createCluster(name, cluster, manufacturerCode); + throw new Error(`Cluster '${cluster.name}' has no command response '${key}'`); } function getGlobalCommandNameById(id: number): FoundationCommandName { diff --git a/src/zspec/zcl/zclFrame.ts b/src/zspec/zcl/zclFrame.ts index 210f34efa5..a2546a4bf2 100644 --- a/src/zspec/zcl/zclFrame.ts +++ b/src/zspec/zcl/zclFrame.ts @@ -58,8 +58,8 @@ export class ZclFrame { : frameType === FrameType.GLOBAL ? Utils.getGlobalCommand(commandKey) : direction === Direction.CLIENT_TO_SERVER - ? cluster.getCommand(commandKey) - : cluster.getCommandResponse(commandKey); + ? Utils.getClusterCommand(cluster, commandKey) + : Utils.getClusterCommandResponse(cluster, commandKey); const header = new ZclHeader( {reservedBits, frameType, direction, disableDefaultResponse, manufacturerSpecific: manufacturerCode != null}, @@ -162,8 +162,8 @@ export class ZclFrame { const command: Command = header.isGlobal ? Utils.getGlobalCommand(header.commandIdentifier) : header.frameControl.direction === Direction.CLIENT_TO_SERVER - ? cluster.getCommand(header.commandIdentifier) - : cluster.getCommandResponse(header.commandIdentifier); + ? Utils.getClusterCommand(cluster, header.commandIdentifier) + : Utils.getClusterCommandResponse(cluster, header.commandIdentifier); const payload = ZclFrame.parsePayload(header, cluster, buffalo); return new ZclFrame(header, payload, cluster, command); @@ -184,8 +184,8 @@ export class ZclFrame { private static parsePayloadCluster(header: ZclHeader, cluster: Cluster, buffalo: BuffaloZcl): ZclPayload { const command = header.frameControl.direction === Direction.CLIENT_TO_SERVER - ? cluster.getCommand(header.commandIdentifier) - : cluster.getCommandResponse(header.commandIdentifier); + ? Utils.getClusterCommand(cluster, header.commandIdentifier) + : Utils.getClusterCommandResponse(cluster, header.commandIdentifier); const payload: ZclPayload = {}; for (const parameter of command.parameters) { diff --git a/test/controller.test.ts b/test/controller.test.ts index e06e662787..89c21ef605 100755 --- a/test/controller.test.ts +++ b/test/controller.test.ts @@ -210,6 +210,7 @@ let enroll170 = true; let configureReportStatus = 0; let configureReportDefaultRsp = false; let lastSentZclFrameToEndpoint: Buffer | undefined; +let readManufacturerCode: number | undefined; const restoreMocksendZclFrameToEndpoint = () => { mocksendZclFrameToEndpoint.mockImplementation((_ieeeAddr, networkAddress, endpoint, frame: Zcl.Frame) => { @@ -224,7 +225,7 @@ const restoreMocksendZclFrameToEndpoint = () => { const cluster = frame.cluster; for (const item of frame.payload) { if (item.attrId !== 65314) { - const attribute = cluster.getAttribute(item.attrId); + const attribute = Zcl.Utils.getClusterAttribute(cluster, item.attrId, readManufacturerCode); if (attribute) { if (frame.isCluster("ssIasZone") && item.attrId === 0) { @@ -480,6 +481,7 @@ describe("Controller", () => { beforeEach(() => { vi.setSystemTime(mockedDate); + readManufacturerCode = undefined; sendZdoResponseStatus = Zdo.Status.SUCCESS; for (const m of mocksRestore) m.mockRestore(); for (const m of mocksClear) m.mockClear(); @@ -3330,12 +3332,13 @@ describe("Controller", () => { await mockAdapterEvents.deviceJoined({networkAddress: 129, ieeeAddr: "0x129"}); const device = controller.getDeviceByIeeeAddr("0x129")!; device.addCustomCluster("genBasic", { + name: "genBasic", ID: 0, commands: {}, commandsResponse: {}, attributes: { - customAttr: {ID: 256, type: Zcl.DataType.UINT8}, - aDifferentZclVersion: {ID: 0, type: Zcl.DataType.UINT8}, + customAttr: {name: "customAttr", ID: 256, type: Zcl.DataType.UINT8}, + aDifferentZclVersion: {name: "aDifferentZclVersion", ID: 0, type: Zcl.DataType.UINT8}, }, }); const buffer = Buffer.from([24, 169, 10, 0, 1, 24, 3, 0, 0, 24, 1, 2, 0, 24, 1]); @@ -3348,11 +3351,12 @@ describe("Controller", () => { // Should allow to extend an already extended cluster again. device.addCustomCluster("genBasic", { + name: "genBasic", ID: 0, commands: {}, commandsResponse: {}, attributes: { - customAttrSecondOverride: {ID: 256, type: Zcl.DataType.UINT8}, + customAttrSecondOverride: {name: "customAttrSecondOverride", ID: 256, type: Zcl.DataType.UINT8}, }, }); await mockAdapterEvents.zclPayload(payload); @@ -3366,10 +3370,11 @@ describe("Controller", () => { await mockAdapterEvents.deviceJoined({networkAddress: 129, ieeeAddr: "0x129"}); const device = controller.getDeviceByIeeeAddr("0x129")!; device.addCustomCluster("myCustomCluster", { + name: "myCustomCluster", ID: 9123, commands: {}, commandsResponse: {}, - attributes: {superAttribute: {ID: 0, type: Zcl.DataType.UINT8}}, + attributes: {superAttribute: {name: "superAttribute", ID: 0, type: Zcl.DataType.UINT8}}, }); const buffer = Buffer.from([24, 169, 10, 0, 1, 24, 3, 0, 0, 24, 1]); const header = Zcl.Header.fromBuffer(buffer); @@ -3393,10 +3398,11 @@ describe("Controller", () => { await mockAdapterEvents.deviceJoined({networkAddress: 129, ieeeAddr: "0x129"}); const device = controller.getDeviceByIeeeAddr("0x129")!; device.addCustomCluster("myCustomCluster", { + name: "myCustomCluster", ID: Zcl.Clusters.genBasic.ID, commands: {}, commandsResponse: {}, - attributes: {customAttr: {ID: 256, type: Zcl.DataType.UINT8}}, + attributes: {customAttr: {name: "customAttr", ID: 256, type: Zcl.DataType.UINT8}}, }); const buffer = Buffer.from([24, 169, 10, 0, 1, 24, 3, 0, 0, 24, 1]); const header = Zcl.Header.fromBuffer(buffer); @@ -3462,17 +3468,28 @@ describe("Controller", () => { } device.addCustomCluster("hvacThermostat", { + name: "hvacThermostat", ID: 0x0201, attributes: { - localTemperatureCalibration: {ID: 0x0010, type: Zcl.DataType.INT8, write: true, min: -50, max: 50, default: 0}, + localTemperatureCalibration: { + name: "localTemperatureCalibration", + ID: 0x0010, + type: Zcl.DataType.INT8, + write: true, + min: -50, + max: 50, + default: 0, + }, }, commands: {}, commandsResponse: {}, }); device.addCustomCluster("hvacThermostat", { + name: "hvacThermostat", ID: Zcl.Clusters.hvacThermostat.ID, attributes: { operatingMode: { + name: "operatingMode", ID: 0x4007, type: Zcl.DataType.ENUM8, manufacturerCode: Zcl.ManufacturerCode.ROBERT_BOSCH_GMBH, @@ -3480,6 +3497,7 @@ describe("Controller", () => { max: 0xff, }, heatingDemand: { + name: "heatingDemand", ID: 0x4020, type: Zcl.DataType.ENUM8, manufacturerCode: Zcl.ManufacturerCode.ROBERT_BOSCH_GMBH, @@ -3487,6 +3505,7 @@ describe("Controller", () => { max: 0xff, }, valveAdaptStatus: { + name: "valveAdaptStatus", ID: 0x4022, type: Zcl.DataType.ENUM8, manufacturerCode: Zcl.ManufacturerCode.ROBERT_BOSCH_GMBH, @@ -3494,6 +3513,7 @@ describe("Controller", () => { max: 0xff, }, unknownAttribute0: { + name: "unknownAttribute0", ID: 0x4025, type: Zcl.DataType.ENUM8, manufacturerCode: Zcl.ManufacturerCode.ROBERT_BOSCH_GMBH, @@ -3501,6 +3521,7 @@ describe("Controller", () => { max: 0xff, }, remoteTemperature: { + name: "remoteTemperature", ID: 0x4040, type: Zcl.DataType.INT16, manufacturerCode: Zcl.ManufacturerCode.ROBERT_BOSCH_GMBH, @@ -3508,6 +3529,7 @@ describe("Controller", () => { min: -32768, }, unknownAttribute1: { + name: "unknownAttribute1", ID: 0x4041, type: Zcl.DataType.ENUM8, manufacturerCode: Zcl.ManufacturerCode.ROBERT_BOSCH_GMBH, @@ -3515,6 +3537,7 @@ describe("Controller", () => { max: 0xff, }, windowOpenMode: { + name: "windowOpenMode", ID: 0x4042, type: Zcl.DataType.ENUM8, manufacturerCode: Zcl.ManufacturerCode.ROBERT_BOSCH_GMBH, @@ -3522,6 +3545,7 @@ describe("Controller", () => { max: 0xff, }, boostHeating: { + name: "boostHeating", ID: 0x4043, type: Zcl.DataType.ENUM8, manufacturerCode: Zcl.ManufacturerCode.ROBERT_BOSCH_GMBH, @@ -3529,14 +3553,23 @@ describe("Controller", () => { max: 0xff, }, cableSensorTemperature: { + name: "cableSensorTemperature", ID: 0x4052, type: Zcl.DataType.INT16, manufacturerCode: Zcl.ManufacturerCode.ROBERT_BOSCH_GMBH, write: true, min: -32768, }, - valveType: {ID: 0x4060, type: Zcl.DataType.ENUM8, manufacturerCode: Zcl.ManufacturerCode.ROBERT_BOSCH_GMBH, write: true, max: 0xff}, + valveType: { + name: "valveType", + ID: 0x4060, + type: Zcl.DataType.ENUM8, + manufacturerCode: Zcl.ManufacturerCode.ROBERT_BOSCH_GMBH, + write: true, + max: 0xff, + }, unknownAttribute2: { + name: "unknownAttribute2", ID: 0x4061, type: Zcl.DataType.ENUM8, manufacturerCode: Zcl.ManufacturerCode.ROBERT_BOSCH_GMBH, @@ -3544,15 +3577,30 @@ describe("Controller", () => { max: 0xff, }, cableSensorMode: { + name: "cableSensorMode", ID: 0x4062, type: Zcl.DataType.ENUM8, manufacturerCode: Zcl.ManufacturerCode.ROBERT_BOSCH_GMBH, write: true, max: 0xff, }, - heaterType: {ID: 0x4063, type: Zcl.DataType.ENUM8, manufacturerCode: Zcl.ManufacturerCode.ROBERT_BOSCH_GMBH, write: true, max: 0xff}, - errorState: {ID: 0x5000, type: Zcl.DataType.BITMAP8, manufacturerCode: Zcl.ManufacturerCode.ROBERT_BOSCH_GMBH, write: true}, + heaterType: { + name: "heaterType", + ID: 0x4063, + type: Zcl.DataType.ENUM8, + manufacturerCode: Zcl.ManufacturerCode.ROBERT_BOSCH_GMBH, + write: true, + max: 0xff, + }, + errorState: { + name: "errorState", + ID: 0x5000, + type: Zcl.DataType.BITMAP8, + manufacturerCode: Zcl.ManufacturerCode.ROBERT_BOSCH_GMBH, + write: true, + }, automaticValveAdapt: { + name: "automaticValveAdapt", ID: 0x5010, type: Zcl.DataType.ENUM8, manufacturerCode: Zcl.ManufacturerCode.ROBERT_BOSCH_GMBH, @@ -3561,7 +3609,7 @@ describe("Controller", () => { }, }, commands: { - calibrateValve: {ID: 0x41, parameters: []}, + calibrateValve: {name: "calibrateValve", ID: 0x41, parameters: []}, }, commandsResponse: {}, }); @@ -3590,11 +3638,13 @@ describe("Controller", () => { ), ).rejects.toThrow("localTemperatureCalibration requires max of 50"); + readManufacturerCode = Zcl.ManufacturerCode.ROBERT_BOSCH_GMBH; await expect( endpoint.read<"hvacThermostat", BoschThermostatCluster>("hvacThermostat", ["boostHeating", "operatingMode"], { manufacturerCode: Zcl.ManufacturerCode.ROBERT_BOSCH_GMBH, }), ).resolves.toStrictEqual({16391: 0, 16451: 0}); + readManufacturerCode = undefined; }); it("Send zcl command to all no options", async () => { @@ -3625,8 +3675,11 @@ describe("Controller", () => { await mockAdapterEvents.deviceJoined({networkAddress: 129, ieeeAddr: "0x129"}); const device = controller.getDeviceByIeeeAddr("0x129")!; device.addCustomCluster("ssIasZone", { + name: "ssIasZone", ID: Zcl.Clusters.ssIasZone.ID, - commands: {boschSmokeAlarmSiren: {ID: 0x80, parameters: [{name: "data", type: Zcl.DataType.UINT16, max: 0xffff}]}}, + commands: { + boschSmokeAlarmSiren: {name: "boschSmokeAlarmSiren", ID: 0x80, parameters: [{name: "data", type: Zcl.DataType.UINT16, max: 0xffff}]}, + }, commandsResponse: {}, attributes: {}, }); @@ -4565,7 +4618,7 @@ describe("Controller", () => { expect(deepClone(endpoint.configuredReportings)).toStrictEqual([ { cluster: deepClone(genPowerCfg), - attribute: genPowerCfg.getAttribute("mainsFrequency"), + attribute: Zcl.Utils.getClusterAttribute(genPowerCfg, "mainsFrequency", undefined), minimumReportInterval: 1, maximumReportInterval: 10, reportableChange: 1, @@ -4578,7 +4631,7 @@ describe("Controller", () => { expect(deepClone(endpoint.configuredReportings)).toStrictEqual([ { cluster: deepClone(genPowerCfg), - attribute: genPowerCfg.getAttribute("mainsFrequency"), + attribute: Zcl.Utils.getClusterAttribute(genPowerCfg, "mainsFrequency", undefined), minimumReportInterval: 3, maximumReportInterval: 100, reportableChange: 2, @@ -4591,14 +4644,14 @@ describe("Controller", () => { expect(deepClone(endpoint.configuredReportings)).toStrictEqual([ { cluster: deepClone(genPowerCfg), - attribute: genPowerCfg.getAttribute("mainsFrequency"), + attribute: Zcl.Utils.getClusterAttribute(genPowerCfg, "mainsFrequency", undefined), minimumReportInterval: 3, maximumReportInterval: 100, reportableChange: 2, }, { cluster: deepClone(msOccupancySensing), - attribute: msOccupancySensing.getAttribute("occupancy"), + attribute: Zcl.Utils.getClusterAttribute(msOccupancySensing, "occupancy", undefined), minimumReportInterval: 3, maximumReportInterval: 100, reportableChange: 2, @@ -4611,7 +4664,7 @@ describe("Controller", () => { expect(deepClone(endpoint.configuredReportings)).toStrictEqual([ { cluster: deepClone(genPowerCfg), - attribute: genPowerCfg.getAttribute("mainsFrequency"), + attribute: Zcl.Utils.getClusterAttribute(genPowerCfg, "mainsFrequency", undefined), minimumReportInterval: 3, maximumReportInterval: 100, reportableChange: 2, @@ -7315,10 +7368,11 @@ describe("Controller", () => { await mockAdapterEvents.deviceJoined({networkAddress: 129, ieeeAddr: "0x129"}); const device = controller.getDeviceByIeeeAddr("0x129")!; device.addCustomCluster("myCustomCluster", { + name: "myCustomCluster", ID: 9123, commands: {}, commandsResponse: {}, - attributes: {superAttribute: {ID: 0, type: Zcl.DataType.UINT8}}, + attributes: {superAttribute: {name: "superAttribute", ID: 0, type: Zcl.DataType.UINT8}}, }); const buffer = Buffer.from([24, 169, 99, 0, 1, 24, 3, 0, 0, 24, 1]); const header = Zcl.Header.fromBuffer(buffer); @@ -9338,10 +9392,11 @@ describe("Controller", () => { const device = controller.getDeviceByIeeeAddr("0x177")!; device.addCustomCluster("manuHerdsman", { + name: "manuHerdsman", ID: 64513, commands: {}, commandsResponse: {}, - attributes: {customAttr: {ID: 0, type: Zcl.DataType.UINT8, write: true}}, + attributes: {customAttr: {name: "customAttr", ID: 0, type: Zcl.DataType.UINT8, write: true}}, }); const group = controller.createGroup(34); @@ -9393,20 +9448,21 @@ describe("Controller", () => { const device2 = controller.getDeviceByIeeeAddr("0x178")!; device2.addCustomCluster("manuHerdsman", { + name: "manuHerdsman", ID: 64513, commands: {}, commandsResponse: {}, - attributes: {customAttr: {ID: 0, type: Zcl.DataType.UINT8}}, + attributes: {customAttr: {name: "customAttr", ID: 0, type: Zcl.DataType.UINT8}}, }); group.addMember(device2.getEndpoint(2)!); await expect(async () => { await group.write("manuHerdsman", {customAttr: 14}, {}); - }).rejects.toThrow(new Error(`Cluster with name 'manuHerdsman' does not exist`)); + }).rejects.toThrow(new Error(`Cluster 'manuHerdsman' does not exist`)); await expect(async () => { await group.read("manuHerdsman", ["customAttr"], {}); - }).rejects.toThrow(new Error(`Cluster with name 'manuHerdsman' does not exist`)); + }).rejects.toThrow(new Error(`Cluster 'manuHerdsman' does not exist`)); await group.write<"manuHerdsman", CustomManuHerdsman>("manuHerdsman", {customAttr: 14}, {direction: Zcl.Direction.SERVER_TO_CLIENT}); await group.read<"manuHerdsman", CustomManuHerdsman>("manuHerdsman", ["customAttr"], {direction: Zcl.Direction.SERVER_TO_CLIENT}); @@ -9432,20 +9488,21 @@ describe("Controller", () => { const device3 = controller.getDeviceByIeeeAddr("0x176")!; device3.addCustomCluster("manuHerdsman", { + name: "manuHerdsman", ID: 64513, commands: {}, commandsResponse: {}, - attributes: {customAttr: {ID: 0, type: Zcl.DataType.UINT8}}, + attributes: {customAttr: {name: "customAttr", ID: 0, type: Zcl.DataType.UINT8}}, }); group.addMember(device3.getEndpoint(1)!); await expect(async () => { await group.write("manuHerdsman", {customAttr: 56}, {}); - }).rejects.toThrow(new Error(`Cluster with name 'manuHerdsman' does not exist`)); + }).rejects.toThrow(new Error(`Cluster 'manuHerdsman' does not exist`)); await expect(async () => { await group.read("manuHerdsman", ["customAttr"], {}); - }).rejects.toThrow(new Error(`Cluster with name 'manuHerdsman' does not exist`)); + }).rejects.toThrow(new Error(`Cluster 'manuHerdsman' does not exist`)); }); it("does not read/write to group with non-common custom clusters", async () => { @@ -9455,10 +9512,11 @@ describe("Controller", () => { const device = controller.getDeviceByIeeeAddr("0x177")!; device.addCustomCluster("manuHerdsman", { + name: "manuHerdsman", ID: 64513, commands: {}, commandsResponse: {}, - attributes: {customAttr: {ID: 0, type: Zcl.DataType.UINT8}}, + attributes: {customAttr: {name: "customAttr", ID: 0, type: Zcl.DataType.UINT8}}, }); const group = controller.createGroup(34); @@ -9472,11 +9530,11 @@ describe("Controller", () => { await expect(async () => { await group.write("manuHerdsman", {customAttr: 34}, {}); - }).rejects.toThrow(new Error(`Cluster with name 'manuHerdsman' does not exist`)); + }).rejects.toThrow(new Error(`Cluster 'manuHerdsman' does not exist`)); await expect(async () => { await group.read("manuHerdsman", ["customAttr"], {}); - }).rejects.toThrow(new Error(`Cluster with name 'manuHerdsman' does not exist`)); + }).rejects.toThrow(new Error(`Cluster 'manuHerdsman' does not exist`)); }); it("sends & receives command to group with custom cluster when common to all members", async () => { @@ -9486,12 +9544,14 @@ describe("Controller", () => { const device = controller.getDeviceByIeeeAddr("0x179")!; device.addCustomCluster("manuSpecificInovelli", { + name: "manuSpecificInovelli", ID: 64561, manufacturerCode: Zcl.ManufacturerCode.V_MARK_ENTERPRISES_INC, // omitted for brevity (unused here) attributes: {}, commands: { ledEffect: { + name: "ledEffect", ID: 1, parameters: [ {name: "effect", type: Zcl.DataType.UINT8}, @@ -9501,6 +9561,7 @@ describe("Controller", () => { ], }, individualLedEffect: { + name: "individualLedEffect", ID: 3, parameters: [ {name: "led", type: Zcl.DataType.UINT8}, @@ -9512,10 +9573,7 @@ describe("Controller", () => { }, }, commandsResponse: { - bogus: { - ID: 1, - parameters: [{name: "xyz", type: Zcl.DataType.UINT8}], - }, + bogus: {name: "bogus", ID: 1, parameters: [{name: "xyz", type: Zcl.DataType.UINT8}]}, }, }); @@ -9608,7 +9666,7 @@ describe("Controller", () => { level: 200, duration: 15, }); - }).rejects.toThrow(new Error(`Cluster with name 'manuSpecificInovelli' does not exist`)); + }).rejects.toThrow(new Error(`Cluster 'manuSpecificInovelli' does not exist`)); }); it("Updates a device genBasic properties", async () => { @@ -9854,10 +9912,12 @@ describe("Controller", () => { const myCustomClusters: CustomClusters = { zhSpe: { + name: "zhSpe", ID: 0xffc1, attributes: {}, commands: { readMe: { + name: "readMe", ID: 0, parameters: [{name: "size", type: Zcl.DataType.UINT8}], }, diff --git a/test/zcl.test.ts b/test/zcl.test.ts index 42643c48f4..cd630a476b 100644 --- a/test/zcl.test.ts +++ b/test/zcl.test.ts @@ -16,39 +16,27 @@ describe("Zcl", () => { it("Get cluster by ID", () => { const cluster1 = Zcl.Utils.getCluster(0, undefined, {}); - // @ts-expect-error testing - delete cluster1.getAttribute; - // @ts-expect-error testing - delete cluster1.getCommand; - // @ts-expect-error testing - delete cluster1.getCommandResponse; const cluster2 = Zcl.Utils.getCluster("genBasic", undefined, {}); - // @ts-expect-error testing - delete cluster2.getAttribute; - // @ts-expect-error testing - delete cluster2.getCommand; - // @ts-expect-error testing - delete cluster2.getCommandResponse; expect(cluster1).toStrictEqual(cluster2); }); it("Get cluster attribute by ID", () => { const cluster = Zcl.Utils.getCluster(0, undefined, {}); - const attribute = cluster.getAttribute(1); + const attribute = Zcl.Utils.getClusterAttribute(cluster, 1, undefined); expect(attribute).toStrictEqual({ID: 1, type: DataType.UINT8, name: "appVersion", default: 0, max: 255}); }); it("Cluster has attribute", () => { const cluster = Zcl.Utils.getCluster(0, undefined, {}); - expect(cluster.getAttribute("zclVersion")).not.toBeUndefined(); - expect(cluster.getAttribute("NOTEXISTING")).toBeUndefined(); - expect(cluster.getAttribute(0)).not.toBeUndefined(); - expect(cluster.getAttribute(910293)).toBeUndefined(); + expect(Zcl.Utils.getClusterAttribute(cluster, "zclVersion", undefined)).not.toBeUndefined(); + expect(Zcl.Utils.getClusterAttribute(cluster, "NOTEXISTING", undefined)).toBeUndefined(); + expect(Zcl.Utils.getClusterAttribute(cluster, 0, undefined)).not.toBeUndefined(); + expect(Zcl.Utils.getClusterAttribute(cluster, 910293, undefined)).toBeUndefined(); }); it("Get specific command by name", () => { const cluster = Zcl.Utils.getCluster("genIdentify", undefined, {}); - const command = cluster.getCommand("ezmodeInvoke"); + const command = Zcl.Utils.getClusterCommand(cluster, "ezmodeInvoke"); expect(command.ID).toBe(2); expect(command.name).toBe("ezmodeInvoke"); }); @@ -68,7 +56,7 @@ describe("Zcl", () => { it("Get cluster by name non-existing", () => { expect(() => { Zcl.Utils.getCluster("notExisting", undefined, {}); - }).toThrow("Cluster with name 'notExisting' does not exist"); + }).toThrow("Cluster 'notExisting' does not exist"); }); it("Get cluster by id non-existing", () => { @@ -83,13 +71,13 @@ describe("Zcl", () => { it("Get specific command by ID", () => { const cluster = Zcl.Utils.getCluster("genIdentify", undefined, {}); - const command = cluster.getCommand(2); - expect(command).toStrictEqual(cluster.getCommand("ezmodeInvoke")); + const command = Zcl.Utils.getClusterCommand(cluster, 2); + expect(command).toStrictEqual(Zcl.Utils.getClusterCommand(cluster, "ezmodeInvoke")); }); it("Get specific command by name server to client", () => { const cluster = Zcl.Utils.getCluster("genIdentify", undefined, {}); - const command = cluster.getCommandResponse(0); + const command = Zcl.Utils.getClusterCommandResponse(cluster, 0); expect(command.ID).toBe(0); expect(command.name).toBe("identifyQueryRsp"); }); @@ -97,7 +85,7 @@ describe("Zcl", () => { it("Get specific command by name non existing", () => { expect(() => { const cluster = Zcl.Utils.getCluster("genIdentify", undefined, {}); - cluster.getCommandResponse("nonexisting"); + Zcl.Utils.getClusterCommandResponse(cluster, "nonexisting"); }).toThrow("Cluster 'genIdentify' has no command response 'nonexisting'"); }); @@ -1935,37 +1923,37 @@ describe("Zcl", () => { it("Zcl utils get cluster attributes manufacturerCode", () => { const cluster = Zcl.Utils.getCluster("closuresWindowCovering", 0x1021, {}); - const attribute = cluster.getAttribute(0xf004); + const attribute = Zcl.Utils.getClusterAttribute(cluster, 0xf004, 0x1021); expect(attribute).toStrictEqual(expect.objectContaining({ID: 0xf004, manufacturerCode: 0x1021, name: "stepPositionTilt", type: 48})); }); it("Zcl utils get cluster attributes manufacturerCode wrong", () => { const cluster = Zcl.Utils.getCluster("closuresWindowCovering", 123, {}); - expect(cluster.getAttribute(0x1000)).toBeUndefined(); + expect(Zcl.Utils.getClusterAttribute(cluster, 0x1000, 123)).toBeUndefined(); }); it("Zcl utils get command", () => { const cluster = Zcl.Utils.getCluster("genOnOff", undefined, {}); - const command = cluster.getCommand(0); + const command = Zcl.Utils.getClusterCommand(cluster, 0); expect(command.name).toStrictEqual("off"); - expect(cluster.getCommand("off")).toStrictEqual(command); + expect(Zcl.Utils.getClusterCommand(cluster, "off")).toStrictEqual(command); }); it("Zcl utils get attribute", () => { const cluster = Zcl.Utils.getCluster("genOnOff", undefined, {}); - const attribute = cluster.getAttribute(16385); + const attribute = Zcl.Utils.getClusterAttribute(cluster, 16385, undefined); expect(attribute?.name).toStrictEqual("onTime"); - expect(cluster.getAttribute("onTime")).toStrictEqual(attribute); + expect(Zcl.Utils.getClusterAttribute(cluster, "onTime", undefined)).toStrictEqual(attribute); }); it("Zcl utils get attribute non-existing", () => { const cluster = Zcl.Utils.getCluster("genOnOff", undefined, {}); - expect(cluster.getAttribute("notExisting")).toBeUndefined(); + expect(Zcl.Utils.getClusterAttribute(cluster, "notExisting", undefined)).toBeUndefined(); }); it("Zcl utils get command non-existing", () => { const cluster = Zcl.Utils.getCluster("genOnOff", undefined, {}); - expect(() => cluster.getCommand("notExisting")).toThrow("Cluster 'genOnOff' has no command 'notExisting'"); + expect(() => Zcl.Utils.getClusterCommand(cluster, "notExisting")).toThrow("Cluster 'genOnOff' has no command 'notExisting'"); }); it("Zcl green power readGpd commissioning", () => { diff --git a/test/zspec/zcl/utils.test.ts b/test/zspec/zcl/utils.test.ts index d42cb10db7..c181fdfb68 100644 --- a/test/zspec/zcl/utils.test.ts +++ b/test/zspec/zcl/utils.test.ts @@ -4,14 +4,27 @@ import {ZCL_TYPE_INVALID_BY_TYPE} from "../../../src/zspec/zcl/definition/dataty import type {Attribute, Command, CustomClusters, Parameter} from "../../../src/zspec/zcl/definition/tstype"; const CUSTOM_CLUSTERS: CustomClusters = { - genBasic: {ID: Zcl.Clusters.genBasic.ID, commands: {}, commandsResponse: {}, attributes: {myCustomAttr: {ID: 65533, type: Zcl.DataType.UINT8}}}, - myCustomCluster: {ID: 65534, commands: {}, commandsResponse: {}, attributes: {myCustomAttr: {ID: 65533, type: Zcl.DataType.UINT8}}}, + genBasic: { + name: "genBasic", + ID: Zcl.Clusters.genBasic.ID, + commands: {}, + commandsResponse: {}, + attributes: {myCustomAttr: {name: "myCustomAttr", ID: 65533, type: Zcl.DataType.UINT8}}, + }, + myCustomCluster: { + name: "myCustomCluster", + ID: 65534, + commands: {}, + commandsResponse: {}, + attributes: {myCustomAttr: {name: "myCustomAttr", ID: 65533, type: Zcl.DataType.UINT8}}, + }, myCustomClusterManuf: { + name: "myCustomClusterManuf", ID: 65533, manufacturerCode: 65534, commands: {}, commandsResponse: {}, - attributes: {myCustomAttr: {ID: 65533, type: Zcl.DataType.UINT8}}, + attributes: {myCustomAttr: {name: "myCustomAttr", ID: 65533, type: Zcl.DataType.UINT8}}, }, }; @@ -100,9 +113,6 @@ describe("ZCL Utils", () => { } expect(cluster.manufacturerCode).toStrictEqual(expected.cluster.manufacturerCode); - expect(cluster.getAttribute).toBeInstanceOf(Function); - expect(cluster.getCommand).toBeInstanceOf(Function); - expect(cluster.getCommandResponse).toBeInstanceOf(Function); }); it("Creates empty cluster when getting by invalid ID", () => { @@ -113,9 +123,6 @@ describe("ZCL Utils", () => { expect(cluster.attributes).toStrictEqual({}); expect(cluster.commands).toStrictEqual({}); expect(cluster.commandsResponse).toStrictEqual({}); - expect(cluster.getAttribute).toBeInstanceOf(Function); - expect(cluster.getCommand).toBeInstanceOf(Function); - expect(cluster.getCommandResponse).toBeInstanceOf(Function); }); it("Throws when getting invalid cluster name", () => { @@ -157,20 +164,20 @@ describe("ZCL Utils", () => { ], ])("Gets and checks cluster attribute %s", (_name, payload, expected) => { const cluster = Zcl.Utils.getCluster(expected.cluster.ID, payload.manufacturerCode, payload.customClusters); - const attribute = cluster.getAttribute(payload.key); + const attribute = Zcl.Utils.getClusterAttribute(cluster, payload.key, payload.manufacturerCode); expect(attribute).not.toBeUndefined(); expect(attribute).toStrictEqual(cluster.attributes[expected.name]); }); it("Returns undefined when getting invalid attribute", () => { const cluster = Zcl.Utils.getCluster(Zcl.Clusters.genAlarms.ID, undefined, {}); - expect(cluster.getAttribute("abcd")).toBeUndefined(); - expect(cluster.getAttribute(99999)).toBeUndefined(); + expect(Zcl.Utils.getClusterAttribute(cluster, "abcd", undefined)).toBeUndefined(); + expect(Zcl.Utils.getClusterAttribute(cluster, 99999, undefined)).toBeUndefined(); }); it("Returns undefined when getting attribute with invalid manufacturer code", () => { const cluster = Zcl.Utils.getCluster(Zcl.Clusters.haDiagnostic.ID, 123, {}); - expect(cluster.getAttribute(Zcl.Clusters.haDiagnostic.attributes.danfossSystemStatusCode.ID)).toBeUndefined(); + expect(Zcl.Utils.getClusterAttribute(cluster, Zcl.Clusters.haDiagnostic.attributes.danfossSystemStatusCode.ID, 123)).toBeUndefined(); }); it.each([ @@ -178,17 +185,17 @@ describe("ZCL Utils", () => { ["by name", {key: "resetAll"}, {cluster: Zcl.Clusters.genAlarms, name: "resetAll"}], ])("Gets cluster command %s", (_name, payload, expected) => { const cluster = Zcl.Utils.getCluster(expected.cluster.ID, undefined, {}); - const command = cluster.getCommand(payload.key); + const command = Zcl.Utils.getClusterCommand(cluster, payload.key); expect(command).toStrictEqual(cluster.commands[expected.name]); }); it("Throws when getting invalid command", () => { const cluster = Zcl.Utils.getCluster(Zcl.Clusters.genAlarms.ID, undefined, {}); expect(() => { - cluster.getCommand("abcd"); + Zcl.Utils.getClusterCommand(cluster, "abcd"); }).toThrow(); expect(() => { - cluster.getCommand(99999); + Zcl.Utils.getClusterCommand(cluster, 99999); }).toThrow(); }); @@ -201,17 +208,17 @@ describe("ZCL Utils", () => { ["by name", {key: "getEventLog"}, {cluster: Zcl.Clusters.genAlarms, name: "getEventLog"}], ])("Gets cluster command response %s", (_name, payload, expected) => { const cluster = Zcl.Utils.getCluster(expected.cluster.ID, undefined, {}); - const commandResponse = cluster.getCommandResponse(payload.key); + const commandResponse = Zcl.Utils.getClusterCommandResponse(cluster, payload.key); expect(commandResponse).toStrictEqual(cluster.commandsResponse[expected.name]); }); it("Throws when getting invalid command response", () => { const cluster = Zcl.Utils.getCluster(Zcl.Clusters.genAlarms.ID, undefined, {}); expect(() => { - cluster.getCommandResponse("abcd"); + Zcl.Utils.getClusterCommandResponse(cluster, "abcd"); }).toThrow(); expect(() => { - cluster.getCommandResponse(99999); + Zcl.Utils.getClusterCommandResponse(cluster, 99999); }).toThrow(); }); @@ -312,24 +319,26 @@ describe("ZCL Utils", () => { it("level control for lighting attributes currentLevel and options", () => { const cluster = Zcl.Utils.getCluster("genLevelCtrl"); - let result = Zcl.Utils.processAttributeWrite(cluster.getAttribute("options")!, 0x00); + let result = Zcl.Utils.processAttributeWrite(Zcl.Utils.getClusterAttribute(cluster, "options", undefined)!, 0x00); expect(result).toStrictEqual(0x00); - result = Zcl.Utils.processAttributeWrite(cluster.getAttribute("options")!, 0xff); + result = Zcl.Utils.processAttributeWrite(Zcl.Utils.getClusterAttribute(cluster, "options", undefined)!, 0xff); expect(result).toStrictEqual(0xff); - expect(() => Zcl.Utils.processAttributeWrite(cluster.getAttribute("currentLevel")!, 0x01)).toThrow(/not writable/i); + expect(() => Zcl.Utils.processAttributeWrite(Zcl.Utils.getClusterAttribute(cluster, "currentLevel", undefined)!, 0x01)).toThrow( + /not writable/i, + ); }); it("rssi location attributes coordinate1 and pathLossExponent", () => { const cluster = Zcl.Utils.getCluster("genRssiLocation"); - let result = Zcl.Utils.processAttributeWrite(cluster.getAttribute("coordinate1")!, -0x8000); + let result = Zcl.Utils.processAttributeWrite(Zcl.Utils.getClusterAttribute(cluster, "coordinate1", undefined)!, -0x8000); expect(result).toStrictEqual(-0x8000); - result = Zcl.Utils.processAttributeWrite(cluster.getAttribute("coordinate1")!, 0x7fff); + result = Zcl.Utils.processAttributeWrite(Zcl.Utils.getClusterAttribute(cluster, "coordinate1", undefined)!, 0x7fff); expect(result).toStrictEqual(0x7fff); - result = Zcl.Utils.processAttributeWrite(cluster.getAttribute("coordinate1")!, 0x0012); + result = Zcl.Utils.processAttributeWrite(Zcl.Utils.getClusterAttribute(cluster, "coordinate1", undefined)!, 0x0012); expect(result).toStrictEqual(0x0012); - result = Zcl.Utils.processAttributeWrite(cluster.getAttribute("pathLossExponent")!, 0xff); + result = Zcl.Utils.processAttributeWrite(Zcl.Utils.getClusterAttribute(cluster, "pathLossExponent", undefined)!, 0xff); expect(result).toStrictEqual(0xff); - result = Zcl.Utils.processAttributeWrite(cluster.getAttribute("pathLossExponent")!, Number.NaN); + result = Zcl.Utils.processAttributeWrite(Zcl.Utils.getClusterAttribute(cluster, "pathLossExponent", undefined)!, Number.NaN); expect(result).toStrictEqual(0xffff); }); }); @@ -390,39 +399,41 @@ describe("ZCL Utils", () => { it("basic attributes zclVersion and powerSource", () => { const cluster = Zcl.Utils.getCluster("genBasic"); // max: 0xff - let result = Zcl.Utils.processAttributePostRead(cluster.getAttribute("zclVersion")!, 0xff); + let result = Zcl.Utils.processAttributePostRead(Zcl.Utils.getClusterAttribute(cluster, "zclVersion", undefined)!, 0xff); expect(result).toStrictEqual(0xff); // default: 0xff - result = Zcl.Utils.processAttributePostRead(cluster.getAttribute("powerSource")!, 0xff); + result = Zcl.Utils.processAttributePostRead(Zcl.Utils.getClusterAttribute(cluster, "powerSource", undefined)!, 0xff); expect(result).toStrictEqual(0xff); - result = Zcl.Utils.processAttributePostRead(cluster.getAttribute("zclVersion")!, 0x02); + result = Zcl.Utils.processAttributePostRead(Zcl.Utils.getClusterAttribute(cluster, "zclVersion", undefined)!, 0x02); expect(result).toStrictEqual(0x02); - result = Zcl.Utils.processAttributePostRead(cluster.getAttribute("powerSource")!, 0x03); + result = Zcl.Utils.processAttributePostRead(Zcl.Utils.getClusterAttribute(cluster, "powerSource", undefined)!, 0x03); expect(result).toStrictEqual(0x03); }); it("device temperature config attribute currentTemperature", () => { const cluster = Zcl.Utils.getCluster("genDeviceTempCfg"); - let result = Zcl.Utils.processAttributePostRead(cluster.getAttribute("currentTemperature")!, -32768); + let result = Zcl.Utils.processAttributePostRead(Zcl.Utils.getClusterAttribute(cluster, "currentTemperature", undefined)!, -32768); expect(Number.isNaN(result)).toStrictEqual(true); - result = Zcl.Utils.processAttributePostRead(cluster.getAttribute("currentTemperature")!, 200); + result = Zcl.Utils.processAttributePostRead(Zcl.Utils.getClusterAttribute(cluster, "currentTemperature", undefined)!, 200); expect(result).toStrictEqual(200); - result = Zcl.Utils.processAttributePostRead(cluster.getAttribute("currentTemperature")!, -200); + result = Zcl.Utils.processAttributePostRead(Zcl.Utils.getClusterAttribute(cluster, "currentTemperature", undefined)!, -200); expect(result).toStrictEqual(-200); - expect(() => Zcl.Utils.processAttributePostRead(cluster.getAttribute("currentTemperature")!, 201)).toThrow(/requires max/i); + expect(() => Zcl.Utils.processAttributePostRead(Zcl.Utils.getClusterAttribute(cluster, "currentTemperature", undefined)!, 201)).toThrow( + /requires max/i, + ); }); it("level control for lighting attributes currentLevel and options", () => { const cluster = Zcl.Utils.getCluster("genLevelCtrl"); - let result = Zcl.Utils.processAttributePostRead(cluster.getAttribute("currentLevel")!, 0xff); + let result = Zcl.Utils.processAttributePostRead(Zcl.Utils.getClusterAttribute(cluster, "currentLevel", undefined)!, 0xff); expect(Number.isNaN(result)).toStrictEqual(false); // technically should be true for genLevelCtrlForLighting but handling left to ZHC - result = Zcl.Utils.processAttributePostRead(cluster.getAttribute("currentLevel")!, 0xfe); + result = Zcl.Utils.processAttributePostRead(Zcl.Utils.getClusterAttribute(cluster, "currentLevel", undefined)!, 0xfe); expect(result).toStrictEqual(0xfe); - result = Zcl.Utils.processAttributePostRead(cluster.getAttribute("currentLevel")!, 200); + result = Zcl.Utils.processAttributePostRead(Zcl.Utils.getClusterAttribute(cluster, "currentLevel", undefined)!, 200); expect(result).toStrictEqual(200); - result = Zcl.Utils.processAttributePostRead(cluster.getAttribute("options")!, 0x00); + result = Zcl.Utils.processAttributePostRead(Zcl.Utils.getClusterAttribute(cluster, "options", undefined)!, 0x00); expect(result).toStrictEqual(0x00); - result = Zcl.Utils.processAttributePostRead(cluster.getAttribute("options")!, 0xff); + result = Zcl.Utils.processAttributePostRead(Zcl.Utils.getClusterAttribute(cluster, "options", undefined)!, 0xff); expect(result).toStrictEqual(0xff); }); }); @@ -438,7 +449,7 @@ describe("ZCL Utils", () => { it("rssi location cmd setAbsolute parameters coordinate1 and pathLossExponent", () => { const cluster = Zcl.Utils.getCluster("genRssiLocation"); - const cmd = cluster.getCommand("setAbsolute")!; + const cmd = Zcl.Utils.getClusterCommand(cluster, "setAbsolute")!; const paramCoordinate1 = cmd.parameters.find((p) => p.name === "coordinate1")!; const paramPathLossExponent = cmd.parameters.find((p) => p.name === "pathLossExponent")!; let result = Zcl.Utils.processParameterWrite(paramCoordinate1, -0x8000); @@ -502,7 +513,7 @@ describe("ZCL Utils", () => { it("rssi location cmd rsp locationDataNotification parameters coordinate1 and pathLossExponent", () => { const cluster = Zcl.Utils.getCluster("genRssiLocation"); - const cmd = cluster.getCommandResponse("locationDataNotification")!; + const cmd = Zcl.Utils.getClusterCommandResponse(cluster, "locationDataNotification")!; const paramCoordinate1 = cmd.parameters.find((p) => p.name === "coordinate1")!; const paramPathLossExponent = cmd.parameters.find((p) => p.name === "pathLossExponent")!; let result = Zcl.Utils.processParameterRead(paramCoordinate1, -0x8000); From 645513a68a4aa2a3c41684a02911238c158d040f Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Wed, 25 Feb 2026 23:54:34 +0100 Subject: [PATCH 03/10] fix: more robust readonly typing --- src/controller/model/device.ts | 5 +---- src/zspec/zcl/definition/cluster.ts | 2 +- src/zspec/zcl/definition/tstype.ts | 34 +++++++++++++--------------- src/zspec/zcl/utils.ts | 35 +++++++++++++++-------------- 4 files changed, 36 insertions(+), 40 deletions(-) diff --git a/src/controller/model/device.ts b/src/controller/model/device.ts index d920687a24..e88f264c75 100755 --- a/src/controller/model/device.ts +++ b/src/controller/model/device.ts @@ -1338,15 +1338,12 @@ export class Device extends Entity { 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}, }; - if (cluster.manufacturerCode !== undefined) { - extendedCluster.manufacturerCode = cluster.manufacturerCode; - } - this._customClusters[name] = extendedCluster; } else { this._customClusters[name] = cluster; diff --git a/src/zspec/zcl/definition/cluster.ts b/src/zspec/zcl/definition/cluster.ts index 0c1e93305f..348b5eb5b6 100644 --- a/src/zspec/zcl/definition/cluster.ts +++ b/src/zspec/zcl/definition/cluster.ts @@ -7813,4 +7813,4 @@ for (const clusterKey in RawClusters) { } } -export const Clusters = RawClusters as Readonly>>; +export const Clusters = RawClusters as Readonly>; diff --git a/src/zspec/zcl/definition/tstype.ts b/src/zspec/zcl/definition/tstype.ts index 8b56328c03..765204229e 100644 --- a/src/zspec/zcl/definition/tstype.ts +++ b/src/zspec/zcl/definition/tstype.ts @@ -147,7 +147,7 @@ export interface FrameControl { /** * @see https://github.com/project-chip/zap/blob/master/zcl-builtin/dotdot/README.md#restrictions */ -interface Restrictions { +type Restrictions = Readonly<{ /** specifies an exact length, generally used for a string. */ length?: number; /** specifies the minimum length that a type must take, generally used for a string or a list/array */ @@ -177,7 +177,7 @@ interface Restrictions { * `value` is kept as string for easier handling (will be checked on spot if used anyway) though most often is a hex number string (without 0x) */ special?: [name: string, value: string][]; -} +}>; /** * @see https://github.com/project-chip/zap/blob/master/zcl-builtin/dotdot/README.md#attributes @@ -188,7 +188,7 @@ interface Restrictions { * - requiredIf: Allows for an expression to be implemented which indicates the conditions in which an attribute is mandatory. * Defaults to false */ -export interface Attribute extends Restrictions { +export type Attribute = Readonly<{ ID: number; name: string; type: DataType; @@ -215,12 +215,13 @@ export interface Attribute extends Restrictions { // defaultRef?: string; /** If attribute is client side */ client?: true; -} +}> & + Restrictions; -export interface Parameter extends Restrictions { +export type Parameter = Readonly<{ name: string; type: DataType | BuffaloZclDataType; - conditions?: ( + conditions?: readonly ( | {type: ParameterCondition.MINIMUM_REMAINING_BUFFER_BYTES; value: number} | {type: ParameterCondition.BITMASK_SET; param: string; mask: number /* not set */; reversed?: boolean} | {type: ParameterCondition.BITFIELD_ENUM; param: string; offset: number; size: number; value: number} @@ -239,34 +240,31 @@ export interface Parameter extends Restrictions { * that field may be referenced using this attribute. */ // arrayLengthField?: string; -} +}> & + Restrictions; /** * @see https://github.com/project-chip/zap/blob/master/zcl-builtin/dotdot/README.md#commands * Extra metadata: * - requiredIf: Allows for an expression to be implemented which indicates the conditions in which a command is mandatory. Defaults to false */ -export interface Command { +export type Command = Readonly<{ ID: number; name: string; parameters: readonly Parameter[]; response?: number; /** If the command is mandatory. Defaults to false */ required?: true; -} +}>; -export interface Cluster { +export type Cluster = Readonly<{ ID: number; name: string; manufacturerCode?: number; - attributes: {[s: string]: Attribute}; - commands: { - [s: string]: Command; - }; - commandsResponse: { - [s: string]: Command; - }; -} + attributes: Readonly>; + commands: Readonly>; + commandsResponse: Readonly>; +}>; export interface CustomClusters { [k: string]: Cluster; diff --git a/src/zspec/zcl/utils.ts b/src/zspec/zcl/utils.ts index 41b2bbcb72..bae5539050 100644 --- a/src/zspec/zcl/utils.ts +++ b/src/zspec/zcl/utils.ts @@ -104,8 +104,8 @@ export function getDataTypeClass(dataType: DataType): DataTypeClass { * - 'manuSpecificPhilips', 'manuSpecificAssaDoorLock' * - 'elkoSwitchConfigurationClusterServer', 'manuSpecificSchneiderLightSwitchConfiguration' */ -function findZclClusterById(id: number, manufacturerCode: number | undefined): [cluster: Readonly | undefined, partialMatch: boolean] { - let cluster: Readonly | undefined; +function findZclClusterById(id: number, manufacturerCode: number | undefined): [cluster: Cluster | undefined, partialMatch: boolean] { + let cluster: Cluster | undefined; // if manufacturer code is given, consider partial match if didn't match against manufacturer code let partialMatch = !!manufacturerCode; @@ -140,8 +140,8 @@ function findCustomClusterByID( id: number, manufacturerCode: number | undefined, customClusters: CustomClusters, -): [cluster: Readonly | undefined, partialMatch: boolean] { - let cluster: Readonly | undefined; +): [cluster: Cluster | undefined, partialMatch: boolean] { + let cluster: Cluster | undefined; // if manufacturer code is given, consider partial match if didn't match against manufacturer code let partialMatch = !!manufacturerCode; @@ -171,7 +171,7 @@ function findCustomClusterByID( } export function getCluster(key: string | number, manufacturerCode: number | undefined = undefined, customClusters: CustomClusters = {}): Cluster { - let cluster: Readonly | undefined; + let cluster: Cluster | undefined; if (typeof key === "number") { let partialMatch: boolean; @@ -183,7 +183,7 @@ export function getCluster(key: string | number, manufacturerCode: number | unde [cluster, partialMatch] = findZclClusterById(key, manufacturerCode); } else if (partialMatch) { // TODO: remove block once custom clusters fully migrated to ZHC - let zclCluster: Readonly | undefined; + let zclCluster: Cluster | undefined; [zclCluster, partialMatch] = findZclClusterById(key, manufacturerCode); // Zcl clusters contain a better match, use that one @@ -295,17 +295,18 @@ export function getGlobalCommand(key: number | string): Command { throw new Error(`Global command with key '${key}' does not exist`); } - const result: Command = { - ID: command.ID, - name, - parameters: command.parameters, - }; - - if (command.response !== undefined) { - result.response = command.response; - } - - return result; + return command.response !== undefined + ? { + ID: command.ID, + name, + parameters: command.parameters, + response: command.response, + } + : { + ID: command.ID, + name, + parameters: command.parameters, + }; } export function isClusterName(name: string): name is ClusterName { From d80f20bb2970a1c6ea0dc0730221f28a6d3b2f93 Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Thu, 26 Feb 2026 00:21:15 +0100 Subject: [PATCH 04/10] fix: cleanup --- src/zspec/zcl/utils.ts | 134 +++++++----------- test/controller.test.ts | 299 +++++++++++++++++++++++++++------------- 2 files changed, 251 insertions(+), 182 deletions(-) diff --git a/src/zspec/zcl/utils.ts b/src/zspec/zcl/utils.ts index bae5539050..3bdc0ff5da 100644 --- a/src/zspec/zcl/utils.ts +++ b/src/zspec/zcl/utils.ts @@ -99,107 +99,69 @@ export function getDataTypeClass(dataType: DataType): DataTypeClass { throw new Error(`Don't know value type for '${DataType[dataType]}'`); } -/** - * This can be greatly optimized when `clusters==ZCL` once these have been moved out of ZH (can just use fast lookup ): - * - 'manuSpecificPhilips', 'manuSpecificAssaDoorLock' - * - 'elkoSwitchConfigurationClusterServer', 'manuSpecificSchneiderLightSwitchConfiguration' - */ -function findZclClusterById(id: number, manufacturerCode: number | undefined): [cluster: Cluster | undefined, partialMatch: boolean] { +export function getCluster(key: string | number, manufacturerCode: number | undefined = undefined, customClusters: CustomClusters = {}): Cluster { let cluster: Cluster | undefined; - // if manufacturer code is given, consider partial match if didn't match against manufacturer code - let partialMatch = !!manufacturerCode; - - const zclNames = ZCL_CLUSTERS_ID_TO_NAMES.get(id); - if (zclNames) { - for (const zclName of zclNames) { - const foundCluster = Clusters[zclName as ClusterName]; + if (typeof key === "number") { + // custom clusters have priority over Zcl clusters, except in case of better match (see below) + for (const clusterName in customClusters) { + const foundCluster = customClusters[clusterName as ClusterName]; - // priority on first match when matching only ID - if (cluster === undefined) { - cluster = foundCluster; - } + if (foundCluster.ID === key) { + // priority on first match when matching only ID + if (cluster === undefined) { + cluster = foundCluster; + } - if (manufacturerCode && foundCluster.manufacturerCode === manufacturerCode) { - cluster = foundCluster; - partialMatch = false; - break; - } + if (manufacturerCode && foundCluster.manufacturerCode === manufacturerCode) { + cluster = foundCluster; + break; + } - if (!foundCluster.manufacturerCode) { - cluster = foundCluster; - break; + if (!foundCluster.manufacturerCode) { + cluster = foundCluster; + break; + } } } - } - - return [cluster, partialMatch]; -} - -function findCustomClusterByID( - id: number, - manufacturerCode: number | undefined, - customClusters: CustomClusters, -): [cluster: Cluster | undefined, partialMatch: boolean] { - let cluster: Cluster | undefined; - // if manufacturer code is given, consider partial match if didn't match against manufacturer code - let partialMatch = !!manufacturerCode; - - for (const clusterName in customClusters) { - const foundCluster = customClusters[clusterName as ClusterName]; - - if (foundCluster.ID === id) { - // priority on first match when matching only ID - if (cluster === undefined) { - cluster = foundCluster; - } - - if (manufacturerCode && foundCluster.manufacturerCode === manufacturerCode) { - cluster = foundCluster; - partialMatch = false; - break; - } - if (!foundCluster.manufacturerCode) { - cluster = foundCluster; - break; + if (!cluster) { + // This can be greatly optimized when `clusters==ZCL` once these have been moved out of ZH (can just use fast lookup ): + // - 'manuSpecificPhilips', 'manuSpecificAssaDoorLock' + // - 'elkoSwitchConfigurationClusterServer', 'manuSpecificSchneiderLightSwitchConfiguration' + const zclNames = ZCL_CLUSTERS_ID_TO_NAMES.get(key); + + if (zclNames) { + for (const zclName of zclNames) { + const foundCluster = Clusters[zclName]; + + // priority on first match when matching only ID + if (cluster === undefined) { + cluster = foundCluster; + } + + if (manufacturerCode && foundCluster.manufacturerCode === manufacturerCode) { + cluster = foundCluster; + break; + } + + if (!foundCluster.manufacturerCode) { + cluster = foundCluster; + break; + } + } } } - } - - return [cluster, partialMatch]; -} - -export function getCluster(key: string | number, manufacturerCode: number | undefined = undefined, customClusters: CustomClusters = {}): Cluster { - let cluster: Cluster | undefined; - - if (typeof key === "number") { - let partialMatch: boolean; - - // custom clusters have priority over Zcl clusters, except in case of better match (see below) - [cluster, partialMatch] = findCustomClusterByID(key, manufacturerCode, customClusters); - if (!cluster) { - [cluster, partialMatch] = findZclClusterById(key, manufacturerCode); - } else if (partialMatch) { - // TODO: remove block once custom clusters fully migrated to ZHC - let zclCluster: Cluster | undefined; - [zclCluster, partialMatch] = findZclClusterById(key, manufacturerCode); - - // Zcl clusters contain a better match, use that one - if (zclCluster !== undefined && !partialMatch) { - cluster = zclCluster; - } + // TODO: cluster.ID can't be undefined? + if (!cluster || cluster.ID === undefined) { + cluster = {name: `${key}`, ID: key, attributes: {}, commands: {}, commandsResponse: {}}; } } else { cluster = key in customClusters ? customClusters[key] : Clusters[key as ClusterName]; - } - // TODO: cluster.ID can't be undefined? - if (!cluster || cluster.ID === undefined) { - if (typeof key === "number") { - cluster = {name: `${key}`, ID: key, attributes: {}, commands: {}, commandsResponse: {}}; - } else { + // TODO: cluster.ID can't be undefined? + if (!cluster || cluster.ID === undefined) { throw new Error(`Cluster '${key}' does not exist`); } } diff --git a/test/controller.test.ts b/test/controller.test.ts index 89c21ef605..4301d74b44 100755 --- a/test/controller.test.ts +++ b/test/controller.test.ts @@ -8525,111 +8525,218 @@ describe("Controller", () => { expect(result.missingRouters[0].ieeeAddr).toBe("0x129"); }); - // ZCLFrame with manufacturer specific flag and manufacturer code defined, to generic device - // ZCLFrameConverter should not modify specific frames! - it("Should resolve manufacturer specific cluster attribute names on specific ZCL frames: generic target device", async () => { - const buffer = Buffer.from([28, 33, 16, 13, 1, 2, 240, 0, 48, 4]); - await controller.start(); - await mockAdapterEvents.deviceJoined({networkAddress: 129, ieeeAddr: "0x129"}); + describe("ZCL frame converter attributeKeyValue", () => { + // ZCLFrame with manufacturer specific flag and manufacturer code defined, to generic device + // ZCLFrameConverter should not modify specific frames! + it("Should resolve manufacturer specific cluster attribute names on specific ZCL frames: generic target device", async () => { + const buffer = Buffer.from([28, 33, 16, 13, 1, 2, 240, 0, 48, 4]); + await controller.start(); + await mockAdapterEvents.deviceJoined({networkAddress: 129, ieeeAddr: "0x129"}); - const frame = Zcl.Frame.fromBuffer( - Zcl.Utils.getCluster("closuresWindowCovering", undefined, {}).ID, - Zcl.Header.fromBuffer(buffer), - buffer, - {}, - ); - await mockAdapterEvents.zclPayload({ - wasBroadcast: false, - address: "0x129", - clusterID: frame.cluster.ID, - data: frame.toBuffer(), - header: frame.header, - endpoint: 1, - linkquality: 50, - groupID: 0, + const frame = Zcl.Frame.fromBuffer( + Zcl.Utils.getCluster("closuresWindowCovering", undefined, {}).ID, + Zcl.Header.fromBuffer(buffer), + buffer, + {}, + ); + await mockAdapterEvents.zclPayload({ + wasBroadcast: false, + address: "0x129", + clusterID: frame.cluster.ID, + data: frame.toBuffer(), + header: frame.header, + endpoint: 1, + linkquality: 50, + groupID: 0, + }); + expect(events.message.length).toBe(1); + expect(events.message[0].data).toMatchObject({calibrationMode: 4}); + expect(events.message[0].data).not.toMatchObject({tuyaMotorReversal: 4}); }); - expect(events.message.length).toBe(1); - expect(events.message[0].data).toMatchObject({calibrationMode: 4}); - expect(events.message[0].data).not.toMatchObject({tuyaMotorReversal: 4}); - }); - // ZCLFrame with manufacturer specific flag and manufacturer code defined, to specific device - // ZCLFrameConverter should not modify specific frames! - it("Should resolve manufacturer specific cluster attribute names on specific ZCL frames: specific target device", async () => { - const buffer = Buffer.from([28, 33, 16, 13, 1, 2, 240, 0, 48, 4]); - await controller.start(); - await mockAdapterEvents.deviceJoined({networkAddress: 177, ieeeAddr: "0x177"}); - const frame = Zcl.Frame.fromBuffer( - Zcl.Utils.getCluster("closuresWindowCovering", undefined, {}).ID, - Zcl.Header.fromBuffer(buffer), - buffer, - {}, - ); - await mockAdapterEvents.zclPayload({ - wasBroadcast: false, - address: "0x177", - clusterID: frame.cluster.ID, - data: frame.toBuffer(), - header: frame.header, - endpoint: 1, - linkquality: 50, - groupID: 0, + // ZCLFrame with manufacturer specific flag and manufacturer code defined, to specific device + // ZCLFrameConverter should not modify specific frames! + it("Should resolve manufacturer specific cluster attribute names on specific ZCL frames: specific target device", async () => { + const buffer = Buffer.from([28, 33, 16, 13, 1, 2, 240, 0, 48, 4]); + await controller.start(); + await mockAdapterEvents.deviceJoined({networkAddress: 177, ieeeAddr: "0x177"}); + const frame = Zcl.Frame.fromBuffer( + Zcl.Utils.getCluster("closuresWindowCovering", undefined, {}).ID, + Zcl.Header.fromBuffer(buffer), + buffer, + {}, + ); + await mockAdapterEvents.zclPayload({ + wasBroadcast: false, + address: "0x177", + clusterID: frame.cluster.ID, + data: frame.toBuffer(), + header: frame.header, + endpoint: 1, + linkquality: 50, + groupID: 0, + }); + expect(events.message.length).toBe(1); + expect(events.message[0].data).toMatchObject({calibrationMode: 4}); + expect(events.message[0].data).not.toMatchObject({tuyaMotorReversal: 4}); }); - expect(events.message.length).toBe(1); - expect(events.message[0].data).toMatchObject({calibrationMode: 4}); - expect(events.message[0].data).not.toMatchObject({tuyaMotorReversal: 4}); - }); - // ZCLFrame without manufacturer specific flag or manufacturer code set, to generic device - it("Should resolve generic cluster attribute names on generic ZCL frames: generic target device", async () => { - const buffer = Buffer.from([24, 242, 10, 2, 240, 48, 4]); - await controller.start(); - await mockAdapterEvents.deviceJoined({networkAddress: 129, ieeeAddr: "0x129"}); - const frame = Zcl.Frame.fromBuffer( - Zcl.Utils.getCluster("closuresWindowCovering", undefined, {}).ID, - Zcl.Header.fromBuffer(buffer), - buffer, - {}, - ); - await mockAdapterEvents.zclPayload({ - wasBroadcast: false, - address: "0x129", - clusterID: frame.cluster.ID, - data: frame.toBuffer(), - header: frame.header, - endpoint: 1, - linkquality: 50, - groupID: 0, + // ZCLFrame without manufacturer specific flag or manufacturer code set, to generic device + it("Should resolve generic cluster attribute names on generic ZCL frames: generic target device", async () => { + const buffer = Buffer.from([24, 242, 10, 2, 240, 48, 4]); + await controller.start(); + await mockAdapterEvents.deviceJoined({networkAddress: 129, ieeeAddr: "0x129"}); + const frame = Zcl.Frame.fromBuffer( + Zcl.Utils.getCluster("closuresWindowCovering", undefined, {}).ID, + Zcl.Header.fromBuffer(buffer), + buffer, + {}, + ); + await mockAdapterEvents.zclPayload({ + wasBroadcast: false, + address: "0x129", + clusterID: frame.cluster.ID, + data: frame.toBuffer(), + header: frame.header, + endpoint: 1, + linkquality: 50, + groupID: 0, + }); + expect(events.message.length).toBe(1); + expect(events.message[0].data).toMatchObject({tuyaMotorReversal: 4}); + expect(events.message[0].data).not.toMatchObject({calibrationMode: 4}); + }); + + // ZCLFrame without manufacturer specific flag set or manufacturer code set, to specific device (Legrand only) + it("Should resolve manufacturer specific cluster attribute names on generic ZCL frames: Legrand target device", async () => { + const buffer = Buffer.from([24, 242, 10, 2, 240, 48, 4]); + await controller.start(); + await mockAdapterEvents.deviceJoined({networkAddress: 177, ieeeAddr: "0x177"}); + const frame = Zcl.Frame.fromBuffer( + Zcl.Utils.getCluster("closuresWindowCovering", undefined, {}).ID, + Zcl.Header.fromBuffer(buffer), + buffer, + {}, + ); + await mockAdapterEvents.zclPayload({ + wasBroadcast: false, + address: "0x177", + clusterID: frame.cluster.ID, + data: frame.toBuffer(), + header: frame.header, + endpoint: 1, + linkquality: 50, + groupID: 0, + }); + expect(events.message.length).toBe(1); + expect(events.message[0].data).toMatchObject({calibrationMode: 4}); + expect(events.message[0].data).not.toMatchObject({tuyaMotorReversal: 4}); }); - expect(events.message.length).toBe(1); - expect(events.message[0].data).toMatchObject({tuyaMotorReversal: 4}); - expect(events.message[0].data).not.toMatchObject({calibrationMode: 4}); }); - // ZCLFrame without manufacturer specific flag set or manufacturer code set, to specific device (Legrand only) - it("Should resolve manufacturer specific cluster attribute names on generic ZCL frames: Legrand target device", async () => { - const buffer = Buffer.from([24, 242, 10, 2, 240, 48, 4]); - await controller.start(); - await mockAdapterEvents.deviceJoined({networkAddress: 177, ieeeAddr: "0x177"}); - const frame = Zcl.Frame.fromBuffer( - Zcl.Utils.getCluster("closuresWindowCovering", undefined, {}).ID, - Zcl.Header.fromBuffer(buffer), - buffer, - {}, - ); - await mockAdapterEvents.zclPayload({ - wasBroadcast: false, - address: "0x177", - clusterID: frame.cluster.ID, - data: frame.toBuffer(), - header: frame.header, - endpoint: 1, - linkquality: 50, - groupID: 0, + describe("ZCL frame converter attributeList", () => { + // ZCLFrame with manufacturer specific flag and manufacturer code defined, to generic device + // ZCLFrameConverter should not modify specific frames! + it("Should resolve manufacturer specific cluster attribute names on specific ZCL frames: generic target device", async () => { + const buffer = Buffer.from([28, 33, 16, 13, 0, 2, 240]); + await controller.start(); + await mockAdapterEvents.deviceJoined({networkAddress: 129, ieeeAddr: "0x129"}); + + const frame = Zcl.Frame.fromBuffer( + Zcl.Utils.getCluster("closuresWindowCovering", undefined, {}).ID, + Zcl.Header.fromBuffer(buffer), + buffer, + {}, + ); + await mockAdapterEvents.zclPayload({ + wasBroadcast: false, + address: "0x129", + clusterID: frame.cluster.ID, + data: frame.toBuffer(), + header: frame.header, + endpoint: 1, + linkquality: 50, + groupID: 0, + }); + expect(events.message.length).toBe(1); + expect(events.message[0].data).toStrictEqual(["calibrationMode"]); + }); + + // ZCLFrame with manufacturer specific flag and manufacturer code defined, to specific device + // ZCLFrameConverter should not modify specific frames! + it("Should resolve manufacturer specific cluster attribute names on specific ZCL frames: specific target device", async () => { + const buffer = Buffer.from([28, 33, 16, 13, 0, 2, 240]); + await controller.start(); + await mockAdapterEvents.deviceJoined({networkAddress: 177, ieeeAddr: "0x177"}); + const frame = Zcl.Frame.fromBuffer( + Zcl.Utils.getCluster("closuresWindowCovering", undefined, {}).ID, + Zcl.Header.fromBuffer(buffer), + buffer, + {}, + ); + await mockAdapterEvents.zclPayload({ + wasBroadcast: false, + address: "0x177", + clusterID: frame.cluster.ID, + data: frame.toBuffer(), + header: frame.header, + endpoint: 1, + linkquality: 50, + groupID: 0, + }); + expect(events.message.length).toBe(1); + expect(events.message[0].data).toStrictEqual(["calibrationMode"]); + }); + + // ZCLFrame without manufacturer specific flag or manufacturer code set, to generic device + it("Should resolve generic cluster attribute names on generic ZCL frames: generic target device", async () => { + const buffer = Buffer.from([24, 242, 0, 2, 240]); + await controller.start(); + await mockAdapterEvents.deviceJoined({networkAddress: 129, ieeeAddr: "0x129"}); + const frame = Zcl.Frame.fromBuffer( + Zcl.Utils.getCluster("closuresWindowCovering", undefined, {}).ID, + Zcl.Header.fromBuffer(buffer), + buffer, + {}, + ); + await mockAdapterEvents.zclPayload({ + wasBroadcast: false, + address: "0x129", + clusterID: frame.cluster.ID, + data: frame.toBuffer(), + header: frame.header, + endpoint: 1, + linkquality: 50, + groupID: 0, + }); + expect(events.message.length).toBe(1); + expect(events.message[0].data).toStrictEqual(["tuyaMotorReversal"]); + }); + + // ZCLFrame without manufacturer specific flag set or manufacturer code set, to specific device (Legrand only) + it("Should resolve manufacturer specific cluster attribute names on generic ZCL frames: Legrand target device", async () => { + const buffer = Buffer.from([24, 242, 0, 2, 240]); + await controller.start(); + await mockAdapterEvents.deviceJoined({networkAddress: 177, ieeeAddr: "0x177"}); + const frame = Zcl.Frame.fromBuffer( + Zcl.Utils.getCluster("closuresWindowCovering", undefined, {}).ID, + Zcl.Header.fromBuffer(buffer), + buffer, + {}, + ); + await mockAdapterEvents.zclPayload({ + wasBroadcast: false, + address: "0x177", + clusterID: frame.cluster.ID, + data: frame.toBuffer(), + header: frame.header, + endpoint: 1, + linkquality: 50, + groupID: 0, + }); + expect(events.message.length).toBe(1); + expect(events.message[0].data).toStrictEqual(["calibrationMode"]); }); - expect(events.message.length).toBe(1); - expect(events.message[0].data).toMatchObject({calibrationMode: 4}); - expect(events.message[0].data).not.toMatchObject({tuyaMotorReversal: 4}); }); it("zclCommand", async () => { From 0fb692b63c860800f8cbebe1f9fa494002593bb1 Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Thu, 26 Feb 2026 19:27:23 +0100 Subject: [PATCH 05/10] fix: cleanup --- src/zspec/zcl/utils.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/zspec/zcl/utils.ts b/src/zspec/zcl/utils.ts index 3bdc0ff5da..521a8341c3 100644 --- a/src/zspec/zcl/utils.ts +++ b/src/zspec/zcl/utils.ts @@ -128,7 +128,6 @@ export function getCluster(key: string | number, manufacturerCode: number | unde if (!cluster) { // This can be greatly optimized when `clusters==ZCL` once these have been moved out of ZH (can just use fast lookup ): // - 'manuSpecificPhilips', 'manuSpecificAssaDoorLock' - // - 'elkoSwitchConfigurationClusterServer', 'manuSpecificSchneiderLightSwitchConfiguration' const zclNames = ZCL_CLUSTERS_ID_TO_NAMES.get(key); if (zclNames) { From 7fd662271c9ff1aad6ba3d6cc4dbdf9e36465383 Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Thu, 26 Feb 2026 20:24:25 +0100 Subject: [PATCH 06/10] fix: use faster arg type --- src/controller/controller.ts | 2 +- src/controller/greenPower.ts | 12 +++++++----- src/controller/model/device.ts | 15 ++++++++++----- src/controller/model/endpoint.ts | 8 ++------ src/controller/touchlink.ts | 6 +++--- 5 files changed, 23 insertions(+), 20 deletions(-) diff --git a/src/controller/controller.ts b/src/controller/controller.ts index 99af27d7ce..ffa79f8625 100644 --- a/src/controller/controller.ts +++ b/src/controller/controller.ts @@ -297,7 +297,7 @@ export class Controller extends events.EventEmitter { zcl.manufacturerCode, 0, zcl.commandKey, - clusterKey ?? Zcl.Clusters.touchlink.ID, + clusterKey ?? "touchlink", zcl.payload, customClusters, ); diff --git a/src/controller/greenPower.ts b/src/controller/greenPower.ts index 7c20526622..17b2d916d6 100644 --- a/src/controller/greenPower.ts +++ b/src/controller/greenPower.ts @@ -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 { private adapter: Adapter; @@ -217,7 +219,7 @@ export class GreenPower extends EventEmitter { undefined, zclTransactionSequenceNumber.next(), "pairing", - Zcl.Clusters.greenPower.ID, + "greenPower", payload, {}, ); @@ -246,7 +248,7 @@ export class GreenPower extends EventEmitter { 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 ( @@ -378,7 +380,7 @@ export class GreenPower extends EventEmitter { undefined, zclTransactionSequenceNumber.next(), "response", - Zcl.Clusters.greenPower.ID, + "greenPower", payloadResponse, {}, ); @@ -520,7 +522,7 @@ export class GreenPower extends EventEmitter { undefined, zclTransactionSequenceNumber.next(), "response", - Zcl.Clusters.greenPower.ID, + "greenPower", payload, {}, ); @@ -592,7 +594,7 @@ export class GreenPower extends EventEmitter { undefined, zclTransactionSequenceNumber.next(), "commisioningMode", - Zcl.Clusters.greenPower.ID, + "greenPower", payload, {}, ); diff --git a/src/controller/model/device.ts b/src/controller/model/device.ts index e88f264c75..630ccae4f0 100755 --- a/src/controller/model/device.ts +++ b/src/controller/model/device.ts @@ -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 { @@ -357,7 +362,7 @@ export class Device extends Entity { ...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(), @@ -519,7 +524,7 @@ export class Device extends Entity { // 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; } @@ -1320,7 +1325,7 @@ export class Device extends Entity { // 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"}); } @@ -1362,7 +1367,7 @@ export class Device extends Entity { Zcl.FrameType.SPECIFIC, Zcl.Direction.CLIENT_TO_SERVER, transactionSequenceNumber, - Zcl.Clusters.genOta.ID, + GEN_OTA_CLUSTER_ID, commandId, timeout, ); @@ -1705,7 +1710,7 @@ export class Device extends Entity { 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) { diff --git a/src/controller/model/endpoint.ts b/src/controller/model/endpoint.ts index ddc48ed131..c33d65fd02 100644 --- a/src/controller/model/endpoint.ts +++ b/src/controller/model/endpoint.ts @@ -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)); } /* diff --git a/src/controller/touchlink.ts b/src/controller/touchlink.ts index 5baf38637a..91cc817597 100644 --- a/src/controller/touchlink.ts +++ b/src/controller/touchlink.ts @@ -15,7 +15,7 @@ const createScanRequestFrame = (transaction: number): Zcl.Frame => undefined, 0, "scanRequest", - Zcl.Clusters.touchlink.ID, + "touchlink", {transactionID: transaction, zigbeeInformation: 4, touchlinkInformation: 18}, {}, ); @@ -28,7 +28,7 @@ const createIdentifyRequestFrame = (transaction: number): Zcl.Frame => undefined, 0, "identifyRequest", - Zcl.Clusters.touchlink.ID, + "touchlink", {transactionID: transaction, duration: 65535}, {}, ); @@ -41,7 +41,7 @@ const createResetFactoryNewRequestFrame = (transaction: number): Zcl.Frame => undefined, 0, "resetToFactoryNew", - Zcl.Clusters.touchlink.ID, + "touchlink", {transactionID: transaction}, {}, ); From 90f8043e6b3c3f44746196029b9d40326d0a37ee Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Fri, 27 Feb 2026 23:33:50 +0100 Subject: [PATCH 07/10] fix: cleanup ZCL logic due to removed multi-ID custom conflict --- src/zspec/zcl/utils.ts | 48 ++++++++++++++++-------------------------- 1 file changed, 18 insertions(+), 30 deletions(-) diff --git a/src/zspec/zcl/utils.ts b/src/zspec/zcl/utils.ts index 521a8341c3..7993d103a9 100644 --- a/src/zspec/zcl/utils.ts +++ b/src/zspec/zcl/utils.ts @@ -70,18 +70,12 @@ const FOUNDATION_DISCOVER_RSP_IDS = [ /** Runtime fast lookup */ const ZCL_CLUSTERS_ID_TO_NAMES = (() => { - const map = new Map(); + const map = new Map(); for (const clusterName in Clusters) { const cluster = Clusters[clusterName as ClusterName]; - const mapEntry = map.get(cluster.ID); - - if (mapEntry) { - mapEntry.push(clusterName as ClusterName); - } else { - map.set(cluster.ID, [clusterName as ClusterName]); - } + map.set(cluster.ID, clusterName as ClusterName); } return map; @@ -126,28 +120,22 @@ export function getCluster(key: string | number, manufacturerCode: number | unde } if (!cluster) { - // This can be greatly optimized when `clusters==ZCL` once these have been moved out of ZH (can just use fast lookup ): - // - 'manuSpecificPhilips', 'manuSpecificAssaDoorLock' - const zclNames = ZCL_CLUSTERS_ID_TO_NAMES.get(key); - - if (zclNames) { - for (const zclName of zclNames) { - const foundCluster = Clusters[zclName]; - - // priority on first match when matching only ID - if (cluster === undefined) { - cluster = foundCluster; - } - - if (manufacturerCode && foundCluster.manufacturerCode === manufacturerCode) { - cluster = foundCluster; - break; - } - - if (!foundCluster.manufacturerCode) { - cluster = foundCluster; - break; - } + const zclName = ZCL_CLUSTERS_ID_TO_NAMES.get(key); + + if (zclName) { + const foundCluster = Clusters[zclName]; + + // TODO: can remove all below once all manuf-specific moved to ZHC + + // priority on first match when matching only ID + if (cluster === undefined) { + cluster = foundCluster; + } + + if (manufacturerCode && foundCluster.manufacturerCode === manufacturerCode) { + cluster = foundCluster; + } else if (foundCluster.manufacturerCode === undefined) { + cluster = foundCluster; } } } From 797000fc5870c631f2e912dd8b025a72e91ee2dd Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Sat, 28 Feb 2026 20:52:21 +0100 Subject: [PATCH 08/10] fix: missing tests custom cluster names --- test/controller.test.ts | 3 ++- test/zcl.test.ts | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/test/controller.test.ts b/test/controller.test.ts index 030679fc53..92d160c57c 100755 --- a/test/controller.test.ts +++ b/test/controller.test.ts @@ -5256,10 +5256,11 @@ describe("Controller", () => { const endpoint = device.getEndpoint(1)!; mocksendZclFrameToEndpoint.mockClear(); device.addCustomCluster("manuSpecificAssaDoorLock", { + name: "manuSpecificAssaDoorLock", ID: 0xfc00, attributes: {}, commands: { - getBatteryLevel: {ID: 0x12, parameters: []}, + getBatteryLevel: {name: "getBatteryLevel", ID: 0x12, parameters: []}, }, commandsResponse: {}, }); diff --git a/test/zcl.test.ts b/test/zcl.test.ts index 1284f10a3d..eee640262c 100644 --- a/test/zcl.test.ts +++ b/test/zcl.test.ts @@ -5,6 +5,7 @@ import {BuffaloZclDataType, DataType, Direction, FrameType, StructuredIndicatorT const MANU_SPE_CUSTOM_CLUSTERS = { manuSpecificAssaDoorLock: { + name: "manuSpecificAssaDoorLock", ID: 0xfc00, attributes: {}, commands: {}, @@ -1933,13 +1934,13 @@ describe("Zcl", () => { }); it("Zcl utils get cluster with manufacturerCode", () => { - const cluster = Zcl.Utils.getCluster(0xfc00, 0x100b, MANU_SPE_CUSTOM_CLUSTERS); + const cluster = Zcl.Utils.getCluster(0xfc00, Zcl.ManufacturerCode.SIGNIFY_NETHERLANDS_B_V, MANU_SPE_CUSTOM_CLUSTERS); expect(cluster.ID).toBe(0xfc00); expect(cluster.name).toBe("manuSpecificPhilips"); }); it("Zcl utils get cluster manufacturerCode", () => { - const cluster = Zcl.Utils.getCluster(0xfc00, 0x10f2, MANU_SPE_CUSTOM_CLUSTERS); + const cluster = Zcl.Utils.getCluster(0xfc00, Zcl.ManufacturerCode.UBISYS_TECHNOLOGIES_GMBH, MANU_SPE_CUSTOM_CLUSTERS); expect(cluster.ID).toBe(0xfc00); expect(cluster.name).toBe("manuSpecificAssaDoorLock"); }); From 7def82324589ee930bcd469f9adf60664f3719d9 Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Sat, 28 Feb 2026 21:49:48 +0100 Subject: [PATCH 09/10] fix: full coverage without `zcl.test.ts` --- src/zspec/zcl/zclFrame.ts | 4 +- test/zspec/zcl/buffalo.test.ts | 73 ++++++++++++++++++++++++++++++++++ test/zspec/zcl/frame.test.ts | 28 +++++++++++++ 3 files changed, 103 insertions(+), 2 deletions(-) diff --git a/src/zspec/zcl/zclFrame.ts b/src/zspec/zcl/zclFrame.ts index a2546a4bf2..82336c951a 100644 --- a/src/zspec/zcl/zclFrame.ts +++ b/src/zspec/zcl/zclFrame.ts @@ -11,7 +11,7 @@ import {ZclHeader} from "./zclHeader"; // biome-ignore lint/suspicious/noExplicitAny: API type ZclPayload = any; -const ListTypes: number[] = [ +const LIST_TYPES: readonly (DataType | BuffaloZclDataType)[] = [ BuffaloZclDataType.LIST_UINT8, BuffaloZclDataType.LIST_UINT16, BuffaloZclDataType.LIST_UINT24, @@ -195,7 +195,7 @@ export class ZclFrame { continue; } - if (ListTypes.includes(parameter.type)) { + if (LIST_TYPES.includes(parameter.type)) { const lengthParameter = command.parameters[command.parameters.indexOf(parameter) - 1]; const length = payload[lengthParameter.name]; diff --git a/test/zspec/zcl/buffalo.test.ts b/test/zspec/zcl/buffalo.test.ts index 958b281ffd..88fba31eb8 100644 --- a/test/zspec/zcl/buffalo.test.ts +++ b/test/zspec/zcl/buffalo.test.ts @@ -134,11 +134,34 @@ describe("ZCL Buffalo", () => { {value: 4294967290, types: [Zcl.DataType.DATA32, Zcl.DataType.BITMAP32, Zcl.DataType.UINT32, Zcl.DataType.UTC, Zcl.DataType.BAC_OID]}, {position: 4, write: "writeUInt32", read: "readUInt32"}, ], + [ + "uint40-like", + {value: 1099511627770, types: [Zcl.DataType.DATA40, Zcl.DataType.BITMAP40, Zcl.DataType.UINT40]}, + {position: 5, write: "writeUInt40", read: "readUInt40"}, + ], + [ + "uint48-like", + {value: 281474976710650, types: [Zcl.DataType.DATA48, Zcl.DataType.BITMAP48, Zcl.DataType.UINT48]}, + {position: 6, write: "writeUInt48", read: "readUInt48"}, + ], + [ + "uint56-like", + {value: 72057594037927935n, types: [Zcl.DataType.DATA56, Zcl.DataType.BITMAP56, Zcl.DataType.UINT56]}, + {position: 7, write: "writeUInt56", read: "readUInt56"}, + ], + [ + "uint64-like", + {value: 18446744073709551610n, types: [Zcl.DataType.DATA64, Zcl.DataType.BITMAP64, Zcl.DataType.UINT64]}, + {position: 8, write: "writeUInt64", read: "readUInt64"}, + ], ["int8-like", {value: -120, types: [Zcl.DataType.INT8]}, {position: 1, write: "writeInt8", read: "readInt8"}], ["int16-like", {value: -32760, types: [Zcl.DataType.INT16]}, {position: 2, write: "writeInt16", read: "readInt16"}], ["int24-like", {value: -8388600, types: [Zcl.DataType.INT24]}, {position: 3, write: "writeInt24", read: "readInt24"}], ["int32-like", {value: -2147483640, types: [Zcl.DataType.INT32]}, {position: 4, write: "writeInt32", read: "readInt32"}], + ["int40-like", {value: -549755813887, types: [Zcl.DataType.INT40]}, {position: 5, write: "writeInt40", read: "readInt40"}], ["int48-like", {value: -140737488355320, types: [Zcl.DataType.INT48]}, {position: 6, write: "writeInt48", read: "readInt48"}], + ["int56-like", {value: -36028797018963960n, types: [Zcl.DataType.INT56]}, {position: 7, write: "writeInt56", read: "readInt56"}], + ["int64-like", {value: -9223372036854775800n, types: [Zcl.DataType.INT64]}, {position: 8, write: "writeInt64", read: "readInt64"}], ["float-like", {value: 1.539989614439558e-36, types: [Zcl.DataType.SINGLE_PREC]}, {position: 4, write: "writeFloatLE", read: "readFloatLE"}], [ "double-like", @@ -1298,6 +1321,56 @@ describe("ZCL Buffalo", () => { expect(buffalo.getPosition()).toStrictEqual(expectedWritten.length); }); + it("Reads Mi Struct", () => { + const buffer = Buffer.from([ + 34, 1, 33, 213, 12, 3, 40, 33, 4, 33, 168, 19, 5, 33, 43, 0, 6, 36, 0, 0, 5, 0, 0, 8, 33, 4, 2, 10, 33, 0, 0, 100, 16, 0, + ]); + const buffalo = new BuffaloZcl(buffer); + + expect(buffalo.read(Zcl.BuffaloZclDataType.MI_STRUCT, {})).toStrictEqual({ + "1": 3285, + "3": 33, + "4": 5032, + "5": 43, + "6": 327680, + "8": 516, + "10": 0, + "100": 0, + }); + expect(buffalo.getPosition()).toStrictEqual(buffer.byteLength); + }); + + it("Reads Mi Struct with padding", () => { + const buffer = Buffer.from([ + 68, 3, 40, 29, 5, 33, 190, 45, 8, 33, 47, 18, 9, 33, 2, 21, 100, 16, 1, 101, 16, 0, 110, 32, 255, 111, 32, 255, 148, 32, 4, 149, 57, 184, + 30, 21, 62, 150, 57, 211, 249, 17, 69, 151, 57, 0, 48, 104, 59, 152, 57, 0, 0, 0, 0, 155, 33, 1, 0, 156, 32, 1, 10, 33, 56, 38, 12, 40, 0, + 0, + ]); + + const buffalo = new BuffaloZcl(buffer); + + expect(buffalo.read(Zcl.BuffaloZclDataType.MI_STRUCT, {})).toStrictEqual({ + "3": 29, + "5": 11710, + "8": 4655, + "9": 5378, + "10": 9784, + "12": 0, + "100": 1, + "101": 0, + "110": 255, + "111": 255, + "148": 4, + "149": 0.14562499523162842, + "150": 2335.614013671875, + "151": 0.0035429000854492188, + "152": 0, + "155": 1, + "156": 1, + }); + expect(buffalo.getPosition()).toStrictEqual(buffer.byteLength); + }); + it("Writes & Reads big endian uint24", () => { const value = 16777200; const expectedWritten = [0xff, 0xff, 0xf0]; diff --git a/test/zspec/zcl/frame.test.ts b/test/zspec/zcl/frame.test.ts index 4c3969418e..9fafa33d0b 100644 --- a/test/zspec/zcl/frame.test.ts +++ b/test/zspec/zcl/frame.test.ts @@ -278,6 +278,33 @@ const MANUF_SPE_FRAME = Zcl.Frame.create( const MANUF_SPE_FRAME_BUFFER = Buffer.concat([MANUF_SPE_HEADER_BUFFER, Buffer.from(uint16To8Array(256))]); const MANUF_SPE_FRAME_STRING = `{"header":{"frameControl":{"reservedBits":0,"frameType":0,"direction":0,"disableDefaultResponse":false,"manufacturerSpecific":true},"manufacturerCode":4344,"transactionSequenceNumber":234,"commandIdentifier":0},"payload":[{"attrId":256}],"command":{"ID":0,"name":"read","parameters":[{"name":"attrId","type":9}],"response":1}}`; +const LIST_HEADER = new Zcl.Header( + { + frameType: Zcl.FrameType.SPECIFIC, + manufacturerSpecific: false, + direction: Zcl.Direction.CLIENT_TO_SERVER, + disableDefaultResponse: false, + reservedBits: 0, + }, + undefined, + 43, + Zcl.Clusters.genGroups.commands.getMembership.ID, +); +const LIST_BUFFER = Buffer.from([1, 43, Zcl.Clusters.genGroups.commands.getMembership.ID, 2, 10, 0, 20, 0]); +const LIST_FRAME = Zcl.Frame.create( + LIST_HEADER.frameControl.frameType, + LIST_HEADER.frameControl.direction, + LIST_HEADER.frameControl.disableDefaultResponse, + LIST_HEADER.manufacturerCode, + LIST_HEADER.transactionSequenceNumber, + LIST_HEADER.commandIdentifier, + Zcl.Clusters.genGroups.ID, + {groupcount: 2, grouplist: [10, 20]} /*payload*/, + {} /*custom clusters*/, + LIST_HEADER.frameControl.reservedBits, +); +const LIST_STRING = `{"header":{"frameControl":{"reservedBits":0,"frameType":1,"direction":0,"disableDefaultResponse":false,"manufacturerSpecific":false},"transactionSequenceNumber":43,"commandIdentifier":2},"payload":{"groupcount":2,"grouplist":[10,20]},"command":{"ID":2,"response":2,"parameters":[{"name":"groupcount","type":32},{"name":"grouplist","type":1002}],"required":true,"name":"getMembership"}}`; + describe("ZCL Frame", () => { describe("Validates Parameter Condition", () => { it("MINIMUM_REMAINING_BUFFER_BYTES", () => { @@ -552,6 +579,7 @@ describe("ZCL Frame", () => { {string: SPECIFIC_CONDITION_FRAME_STRING, header: SPECIFIC_CONDITION_HEADER, written: SPECIFIC_CONDITION_FRAME_BUFFER}, ], ["manufacturer-specific", MANUF_SPE_FRAME, {string: MANUF_SPE_FRAME_STRING, header: MANUF_SPE_HEADER, written: MANUF_SPE_FRAME_BUFFER}], + ["list", LIST_FRAME, {string: LIST_STRING, header: LIST_HEADER, written: LIST_BUFFER}], ])("Writes & Reads frame %s", (_name, frame, expected) => { expect(frame).toBeDefined(); expect(frame.toString()).toStrictEqual(expected.string); From 60491fc40636f98523ba0779019713457a9ee91c Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Sat, 28 Feb 2026 21:50:13 +0100 Subject: [PATCH 10/10] fix: multi-ID conflict no longer possible --- test/zcl.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/zcl.test.ts b/test/zcl.test.ts index eee640262c..216ba24951 100644 --- a/test/zcl.test.ts +++ b/test/zcl.test.ts @@ -1934,7 +1934,7 @@ describe("Zcl", () => { }); it("Zcl utils get cluster with manufacturerCode", () => { - const cluster = Zcl.Utils.getCluster(0xfc00, Zcl.ManufacturerCode.SIGNIFY_NETHERLANDS_B_V, MANU_SPE_CUSTOM_CLUSTERS); + const cluster = Zcl.Utils.getCluster(0xfc00, Zcl.ManufacturerCode.SIGNIFY_NETHERLANDS_B_V, {}); expect(cluster.ID).toBe(0xfc00); expect(cluster.name).toBe("manuSpecificPhilips"); });