fix!: improve performance with ZCL clusters handling#1663
fix!: improve performance with ZCL clusters handling#1663
Conversation
a731125 to
19a6db8
Compare
19a6db8 to
d80f20b
Compare
There was a problem hiding this comment.
Pull request overview
This PR significantly improves performance by eliminating runtime cluster object generation and cloning, achieving 2-6x effective performance improvements on frequently-called operations. The core optimization removes the expensive clone-like behavior where cluster objects were dynamically created with getter functions for each access. Instead, clusters are now accessed directly with Readonly types enforcing immutability, and attribute/command lookups are performed via new utility functions.
Changes:
- Breaking API change: Replaced
cluster.getAttribute(),cluster.getCommand(), andcluster.getCommandResponse()methods with static utility functionsZcl.Utils.getClusterAttribute(),Zcl.Utils.getClusterCommand(), andZcl.Utils.getClusterCommandResponse() - Type system improvements: All
Cluster,Attribute,Command, andParametertypes are nowReadonly, preventing accidental mutations - Simplified cluster lookup: Removed fallback-to-ZCL logic for partial custom cluster matches, streamlining the cluster resolution algorithm
- Performance optimization: Eliminated object spreading and runtime cluster cloning, reducing GC pressure from many short-lived objects
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/zspec/zcl/definition/tstype.ts | Converted Cluster, Attribute, Command, and Parameter to Readonly types; removed ClusterDefinition interface |
| src/zspec/zcl/definition/cluster.ts | Added initialization loop to populate name properties at module load time; made ClusterDefinition internal-only |
| src/zspec/zcl/utils.ts | Refactored getCluster to avoid cloning; extracted attribute/command getters as standalone utility functions |
| src/zspec/zcl/zclFrame.ts | Updated to use new Utils.getClusterCommand and Utils.getClusterCommandResponse functions |
| src/zspec/zcl/buffaloZcl.ts | Updated to use Utils.getClusterAttribute for attribute lookups |
| src/controller/model/device.ts | Added cluster ID constants; updated custom cluster handling; changed to use new Utils functions |
| src/controller/model/endpoint.ts | Removed redundant clusterNumbersToClusters method; updated all attribute/command access to use Utils functions |
| src/controller/model/group.ts | Updated all cluster attribute/command access to use new Utils API |
| src/controller/helpers/zclFrameConverter.ts | Refactored Legrand workaround to use Utils functions; clarified manufacturer code handling |
| src/controller/touchlink.ts | Changed to use cluster name strings instead of cluster IDs for frame creation |
| src/controller/greenPower.ts | Changed to use cluster name strings; added command ID constant |
| src/controller/controller.ts | Updated touchlink frame creation to use cluster name |
| test/zspec/zcl/utils.test.ts | Added name properties to all custom cluster test data; updated tests to use new Utils API; removed test for fallback-to-ZCL behavior |
| test/zcl.test.ts | Updated all tests to use Utils.getClusterAttribute/Command/CommandResponse functions |
| test/controller.test.ts | Added name properties to all custom cluster definitions; added readManufacturerCode test variable for manufacturer-specific attribute tests |
| // TODO: cluster.ID can't be undefined? | ||
| if (!cluster || cluster.ID === undefined) { | ||
| cluster = {name: `${key}`, ID: key, attributes: {}, commands: {}, commandsResponse: {}}; | ||
| } | ||
| } else { | ||
| name = key; | ||
| } | ||
| cluster = key in customClusters ? customClusters[key] : Clusters[key as ClusterName]; | ||
|
|
||
| let cluster = | ||
| name !== undefined && hasCustomClusters(customClusters) | ||
| ? { | ||
| ...Clusters[name as ClusterName], | ||
| ...customClusters[name], // should override Zcl clusters | ||
| } | ||
| : Clusters[name as ClusterName]; | ||
|
|
||
| if (!cluster || cluster.ID === undefined) { | ||
| if (typeof key === "number") { | ||
| name = key.toString(); | ||
| cluster = {attributes: {}, commands: {}, commandsResponse: {}, manufacturerCode: undefined, ID: key}; | ||
| } else { | ||
| name = undefined; | ||
| // TODO: cluster.ID can't be undefined? | ||
| if (!cluster || cluster.ID === undefined) { |
There was a problem hiding this comment.
There are two TODO comments about whether cluster.ID can be undefined. Given that the Cluster type now requires ID: number, these checks and TODOs are no longer necessary. Consider removing both the conditional checks and the TODO comments since the type system now guarantees ID is always defined.
| 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; | ||
| } | ||
| } |
There was a problem hiding this comment.
The initialization loop mutates objects declared as Readonly, which violates TypeScript's type system contract. While this works at runtime, it's dangerous because:
- It bypasses type safety using
@ts-expect-errorsuppressions - Objects marked
Readonlymay be optimized by JavaScript engines with the assumption they won't be mutated - It violates the principle of least surprise for maintainers
Consider either:
- Removing
ReadonlyfromRawClustersdeclaration - Or constructing new objects with the
nameproperties included from the start (as the PR description mentions this is temporary)
Koenkk
left a comment
There was a problem hiding this comment.
LGTM!
It will add a bit of duplication (property name + name property), but I think it's worth it to avoid object-spread on module load
Yeah I agree, that's worth the perf gain
Been wanting to investigate this... It's proving worthwhile, 2-6x "effective" perf, on stuff that's called so very often! 😮
It also has the benefit of relieving the pressure on the GC (many, many short-lived objects were previously created).
The "core" of this is the removal of the runtime cluster generation (clone-like behavior), which was the expensive part.
Added
Readonlyon all related types to ensure nothing can mutate theClustersobject, since we no longer have clones (don't think anything was mutating the clones before either, but this should catch it if any).Replaced in-object functions with plain utils to get attrs & cmds. [breaking change]
Also cleaned up the logic for finding custom clusters by ID:
by ID ignoring same custom ID if Zcl is better match with manufacturer code@Koenkk if you can think of anything that would block this approach that we might need to investigate in more details... 😬
I haven't done the migration to add
nameto Clusters entries yet (used init loop, with some type-ignore for now). It will add a bit of duplication (property name + name property), but I think it's worth it to avoid object-spread on module load and avoid any double memory scenario (i.e. to not re-create the whole object withnameprops)... Can just write a quick script to do the update automatically once reviewed. Could keep the init loop, but it's not great, since have to type-assert and type-ignore, plus it's a big object to loop through at module load...At quick glance, the ZHC migration should be minimal:
nameprop to custom cluster entries (the bulk of it)ClusterDefinition=>ClustertypingBench with CI runner:
https://github.com/Koenkk/zigbee-herdsman/actions/runs/22422209079/attempts/1#summary-64922300358
And a simple
getClustercall bench: