From dcfcde9480a1522ca005fea56108ffd96492e737 Mon Sep 17 00:00:00 2001 From: ArcadeMachinist Date: Sat, 13 Jun 2026 21:27:54 -0400 Subject: [PATCH 1/3] feat: Re-implement Control4 keypad support on upstream v10.4.0 Port of original Control4 support (C4-KP6i keypads, Zigbee profile 0xC25C) to the refactored zspec architecture: - src/zspec/consts.ts: add CONTROL4_PROFILE_ID = 0xc25c constant - src/adapter/ezsp/adapter/ezspAdapter.ts: accept frames with Control4 profile in processMessage() so button-press events reach the controller - src/adapter/z-stack/adapter/endpoints.ts: register endpoint 197 with CONTROL4_PROFILE_ID so ZNP can select the correct source endpoint when sending LED commands - src/controller/model/endpoint.ts: add Endpoint.writeControl4() method that builds a proprietary GLOBAL ZCL frame (cmd 0x31, reservedBits=1, seq=0x73) and sends it on the Control4 APS profile Receiving button presses works with both ZNP and EZSP adapters. Sending LED commands requires EZSP (SiliconLabs-based coordinator) or ZNP with a registered source endpoint. See https://github.com/Koenkk/zigbee2mqtt/issues/15361 Co-Authored-By: Claude Sonnet 4.6 --- src/adapter/ezsp/adapter/ezspAdapter.ts | 4 ++- src/adapter/z-stack/adapter/endpoints.ts | 2 ++ src/controller/model/endpoint.ts | 40 ++++++++++++++++++++++++ src/zspec/consts.ts | 2 ++ 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/adapter/ezsp/adapter/ezspAdapter.ts b/src/adapter/ezsp/adapter/ezspAdapter.ts index 9baaab58ed..1ddd31a28d 100644 --- a/src/adapter/ezsp/adapter/ezspAdapter.ts +++ b/src/adapter/ezsp/adapter/ezspAdapter.ts @@ -55,7 +55,9 @@ export class EZSPAdapter extends Adapter { frame.apsFrame.profileId === ZSpec.HA_PROFILE_ID || frame.apsFrame.profileId === 0xffff || // Shelly custom Clusters require a special profile ID - frame.apsFrame.profileId === ZSpec.CUSTOM_SHELLY_PROFILE_ID + frame.apsFrame.profileId === ZSpec.CUSTOM_SHELLY_PROFILE_ID || + // Control4 devices use a custom profile ID + frame.apsFrame.profileId === ZSpec.CONTROL4_PROFILE_ID ) { const payload: ZclPayload = { clusterID: frame.apsFrame.clusterId, diff --git a/src/adapter/z-stack/adapter/endpoints.ts b/src/adapter/z-stack/adapter/endpoints.ts index 8262ced95d..59bf7ecd79 100644 --- a/src/adapter/z-stack/adapter/endpoints.ts +++ b/src/adapter/z-stack/adapter/endpoints.ts @@ -57,4 +57,6 @@ export const Endpoints = [ // Required for shelly wifi and rpc clusters {...EndpointDefaults, endpoint: 239, appprofid: ZSpec.CUSTOM_SHELLY_PROFILE_ID}, {...EndpointDefaults, endpoint: 242, appprofid: 0xa1e0}, + // Control4 devices use a custom profile + {...EndpointDefaults, endpoint: 197, appprofid: ZSpec.CONTROL4_PROFILE_ID}, ]; diff --git a/src/controller/model/endpoint.ts b/src/controller/model/endpoint.ts index 2916be551b..33fb7e4371 100644 --- a/src/controller/model/endpoint.ts +++ b/src/controller/model/endpoint.ts @@ -520,6 +520,46 @@ export class Endpoint extends ZigbeeEntity { await this.zclCommand(cluster, optionsWithDefaults.writeUndiv ? "writeUndiv" : "write", payload, optionsWithDefaults, attributes, true); } + public async writeControl4(xpayload: number[], options?: Options): Promise { + const device = this.getDevice(); + const cluster = Zcl.Utils.getCluster(0x0001 /* genPowerCfg */, undefined, device.customClusters); + const optionsWithDefaults = this.getOptionsWithDefaults(options, true, Zcl.Direction.CLIENT_TO_SERVER, cluster.manufacturerCode); + optionsWithDefaults.profileId = ZSpec.CONTROL4_PROFILE_ID; + optionsWithDefaults.disableResponse = true; + const data = xpayload.slice(3); + // biome-ignore lint/suspicious/noExplicitAny: Control4 proprietary ZCL extension bypasses typed foundation lookup + const c4Command: any = { + name: "c4cmd_s", + ID: 0x31, + parse: () => ({}), + // biome-ignore lint/suspicious/noExplicitAny: buffalo type not re-exported from zspec/zcl + write: (buffalo: any, payload: number[]) => { + buffalo.writeBuffer(Buffer.from(payload), payload.length); + }, + }; + const frame = Zcl.Frame.create( + Zcl.FrameType.GLOBAL, + Zcl.Direction.CLIENT_TO_SERVER, + true, + undefined, + 0x73, + c4Command, + cluster, + data, + device.customClusters, + 0x1, + ); + const logMsg = `writeControl4 ${this.deviceIeeeAddress}/${this.ID} ${JSON.stringify(data)}`; + logger.debug(logMsg, NS); + try { + await this.sendRequest(frame, optionsWithDefaults); + } catch (error) { + const err = error as Error; + err.message = `${logMsg} failed (${err.message})`; + throw error; + } + } + public async writeResponse( clusterKey: Cl, transactionSequenceNumber: number, diff --git a/src/zspec/consts.ts b/src/zspec/consts.ts index 4b83d2ef18..9b01747273 100644 --- a/src/zspec/consts.ts +++ b/src/zspec/consts.ts @@ -21,6 +21,8 @@ export const TOUCHLINK_PROFILE_ID = 0xc05e; export const WILDCARD_PROFILE_ID = 0xffff; /** The profile ID used to access Shelly devices custom clusters. */ export const CUSTOM_SHELLY_PROFILE_ID = 0xc001; +/** The profile ID used by Control4 devices. */ +export const CONTROL4_PROFILE_ID = 0xc25c; /** The default HA endpoint. */ export const HA_ENDPOINT = 0x01; From f8689cefc782cbc5ba4e6585b635b7949eedf6ea Mon Sep 17 00:00:00 2001 From: ArcadeMachinist Date: Sun, 21 Jun 2026 15:01:47 -0400 Subject: [PATCH 2/3] fix: Control4 device interview and endpoint persistence - controller.ts: capture C4 modelID from genPowerCfg attr 0x0007 broadcast on join ("c4::" format), since C4 devices return UNSUPPORTED_ATTRIBUTE for genBasic.modelId - device.ts: skip genBasic interview when modelID already starts with "C4-" (avoids double-failure on every re-interview) - device.ts: preserve endpoints with CONTROL4_PROFILE_ID in updateActiveEndpoints() so endpoint 197 (and converter-managed logical endpoints) are not pruned when the device re-joins and re-interviews --- src/controller/controller.ts | 14 ++++++++++++++ src/controller/model/device.ts | 15 ++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/controller/controller.ts b/src/controller/controller.ts index a9cb232790..bd03ea7420 100644 --- a/src/controller/controller.ts +++ b/src/controller/controller.ts @@ -1105,6 +1105,20 @@ export class Controller extends events.EventEmitter { // devices report attributes through readRsp or attributeReport if (frame.cluster.name === "genBasic") { device.updateGenBasic(data as TPartialClusterAttributes<"genBasic">); + } else if (frame.cluster.name === "genPowerCfg" && !device.modelID) { + // Control4 devices don't support genBasic.modelId (returns UNSUPPORTED_ATTRIBUTE). + // They announce their model via genPowerCfg attribute 0x0007 in the format + // "c4::" as a broadcast immediately on joining. + // Attribute 0x0007 is not defined in the standard genPowerCfg cluster so it + // arrives keyed by its numeric ID rather than a name. + const c4model = (data as Record)[7]; + if (typeof c4model === "string" && c4model.startsWith("c4:")) { + const modelID = c4model.split(":")[2]; + if (modelID) { + device.modelID = modelID; + logger.debug(`Set Control4 modelID '${device.modelID}' from genPowerCfg broadcast`, NS); + } + } } endpoint.saveClusterAttributeKeyValue(frame.cluster.ID, data); diff --git a/src/controller/model/device.ts b/src/controller/model/device.ts index 1a9eaa0b87..9c78d5d456 100755 --- a/src/controller/model/device.ts +++ b/src/controller/model/device.ts @@ -886,6 +886,15 @@ export class Device extends Entity { return true; } + // Control4 devices don't support genBasic.modelId (returns UNSUPPORTED_ATTRIBUTE on both attempts, + // causing interviewInternal to throw). Their modelID is captured from genPowerCfg attr 7 in the + // controller before the interview runs. If modelID is already set, treat interview as successful. + if (this.modelID?.startsWith("C4-")) { + this.#genBasic.powerSource = this.#genBasic.powerSource || Zcl.PowerSource["Mains (single phase)"]; + logger.debug("Interview - quirks matched for Control4 device", NS); + return true; + } + // Some devices, e.g. Xiaomi end devices have a different interview procedure, after pairing they // report it's modelID trough a readResponse. The readResponse is received by the controller and set // on the device. @@ -1179,7 +1188,11 @@ export class Device extends Entity { } // Remove disappeared endpoints (can happen with e.g. custom devices). - this._endpoints = this._endpoints.filter((e) => activeEndpoints.endpointList.includes(e.ID)); + // Preserve endpoints with a proprietary profile ID (e.g. C4 endpoint 197 with CONTROL4_PROFILE_ID) + // since these are not ZDO-advertised and are managed externally (e.g. by a converter's configure). + this._endpoints = this._endpoints.filter( + (e) => activeEndpoints.endpointList.includes(e.ID) || e.profileID === ZSpec.CONTROL4_PROFILE_ID, + ); } /** From d53600bec465b213e3a2e1b5bc716200ce1b68a5 Mon Sep 17 00:00:00 2001 From: ArcadeMachinist Date: Sun, 21 Jun 2026 16:29:02 -0400 Subject: [PATCH 3/3] style: make the formatter happy again --- src/controller/model/device.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/controller/model/device.ts b/src/controller/model/device.ts index 9c78d5d456..c575a66f6a 100755 --- a/src/controller/model/device.ts +++ b/src/controller/model/device.ts @@ -1190,9 +1190,7 @@ export class Device extends Entity { // Remove disappeared endpoints (can happen with e.g. custom devices). // Preserve endpoints with a proprietary profile ID (e.g. C4 endpoint 197 with CONTROL4_PROFILE_ID) // since these are not ZDO-advertised and are managed externally (e.g. by a converter's configure). - this._endpoints = this._endpoints.filter( - (e) => activeEndpoints.endpointList.includes(e.ID) || e.profileID === ZSpec.CONTROL4_PROFILE_ID, - ); + this._endpoints = this._endpoints.filter((e) => activeEndpoints.endpointList.includes(e.ID) || e.profileID === ZSpec.CONTROL4_PROFILE_ID); } /**