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/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..c575a66f6a 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,9 @@ 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); } /** 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;