Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/adapter/ezsp/adapter/ezspAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/adapter/z-stack/adapter/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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},
];
14 changes: 14 additions & 0 deletions src/controller/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1105,6 +1105,20 @@ export class Controller extends events.EventEmitter<ControllerEventMap> {
// 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:<device_type>:<model_id>" 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<number, unknown>)[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);
Expand Down
13 changes: 12 additions & 1 deletion src/controller/model/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -886,6 +886,15 @@ export class Device extends Entity<ControllerEventMap> {
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.
Expand Down Expand Up @@ -1179,7 +1188,9 @@ export class Device extends Entity<ControllerEventMap> {
}

// 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);
}

/**
Expand Down
40 changes: 40 additions & 0 deletions src/controller/model/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<Cl extends number | string, Custom extends TCustomCluster | undefined = undefined>(
clusterKey: Cl,
transactionSequenceNumber: number,
Expand Down
2 changes: 2 additions & 0 deletions src/zspec/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading