Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
node_modules
build
.envrc
50 changes: 47 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ attr3: { ... },
| `id` | Yes | Always 4-digit hex format (0x0000); add decimal comment if > 9 |
| `args` | If has params | Object with typed fields |
| `response` | If expects response | Has own `id` and `args` |
| `encodeMissingFieldsBehavior` | Controls the behavior of missing fields when encoding the struct | Set to `skip` if some fields are optional, omit if all fields are mandatory |
| `direction` | For server→client | `Cluster.DIRECTION_SERVER_TO_CLIENT` |

#### Command Sections
Expand Down Expand Up @@ -333,11 +334,54 @@ lockDoor: {
},
```

**Server→client commands** (events/notifications) should be evaluated per case:
- Implement if commonly needed (e.g., `operationEventNotification` for door locks)
- Skip obscure or rarely-used notifications unless specifically requested
**Server→client commands** should only be added as standalone commands when they can be sent **unsolicited** (not just as a response to a request). Examples:
- Unsolicited notifications (e.g., `operationEventNotification` for door locks) — add as standalone
- Responses that the server can also send unsolicited (e.g., `upgradeEndResponse` for synchronized upgrades) — add as standalone
- Responses that are **only** sent in reply to a request (e.g., `queryNextImageResponse`) — do **NOT** add as standalone; they are already covered by the inline `response:` on the request command
- These require `direction: Cluster.DIRECTION_SERVER_TO_CLIENT`

#### Optional fields
Some commands contain optional fields. If this is the case add `encodeMissingFieldsBehavior: 'skip'` to the command definition.

```javascript
lockDoor: {
id: 0x0000,
encodeMissingFieldsBehavior: 'skip',
args: { pinCode: ZCLDataTypes.octstr },
response: {
id: 0x0000,
args: { status: ZCLDataTypes.uint8 },
},
}
```

#### Command variants
Some commands might change their structure based on a status field. If this is the case, add `encodeMissingFieldsBehavior: 'skip'`. And add a comment above each field
when the field should be present.

**IMPORTANT**: This applies to `response:` structs too, not just the top-level command. If a response has variant fields (e.g., different fields depending on a status value), add `encodeMissingFieldsBehavior: 'skip'` to the response and include all variant fields with comments indicating when each group is present.

```javascript
imageBlockRequest: {
id: 0x0003,
args: { ... },
response: {
id: 0x0005,
encodeMissingFieldsBehavior: 'skip',
args: {
status: ZCLDataTypes.enum8Status,
// When status is SUCCESS
manufacturerCode: ZCLDataTypes.uint16,
fileOffset: ZCLDataTypes.uint32,
imageData: ZCLDataTypes.buffer,
// When status is WAIT_FOR_DATA
currentTime: ZCLDataTypes.uint32,
requestTime: ZCLDataTypes.uint32,
},
},
}
```

---

### ZCLDataTypes Reference
Expand Down
48 changes: 44 additions & 4 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ type ZCLNodeConstructorInput = {
) => Promise<void>;
};

type ZCLEnum8Status = 'SUCCESS' | 'FAILURE' | 'NOT_AUTHORIZED' | 'RESERVED_FIELD_NOT_ZERO' | 'MALFORMED_COMMAND' | 'UNSUP_CLUSTER_COMMAND' | 'UNSUP_GENERAL_COMMAND' | 'UNSUP_MANUF_CLUSTER_COMMAND' | 'UNSUP_MANUF_GENERAL_COMMAND' | 'INVALID_FIELD' | 'UNSUPPORTED_ATTRIBUTE' | 'INVALID_VALUE' | 'READ_ONLY' | 'INSUFFICIENT_SPACE' | 'DUPLICATE_EXISTS' | 'NOT_FOUND' | 'UNREPORTABLE_ATTRIBUTE' | 'INVALID_DATA_TYPE' | 'INVALID_SELECTOR' | 'WRITE_ONLY' | 'INCONSISTENT_STARTUP_STATE' | 'DEFINED_OUT_OF_BAND' | 'INCONSISTENT' | 'ACTION_DENIED' | 'TIMEOUT' | 'ABORT' | 'INVALID_IMAGE' | 'WAIT_FOR_DATA' | 'NO_IMAGE_AVAILABLE' | 'REQUIRE_MORE_IMAGE' | 'NOTIFICATION_PENDING' | 'HARDWARE_FAILURE' | 'SOFTWARE_FAILURE' | 'CALIBRATION_ERROR' | 'UNSUPPORTED_CLUSTER';

export interface ZCLNodeCluster extends EventEmitter {
discoverCommandsGenerated(params?: {
startValue?: number;
Expand Down Expand Up @@ -449,10 +451,10 @@ export interface GroupsCluster extends ZCLNodeCluster {
writeAttributes(attributes: Partial<GroupsClusterAttributes>, opts?: { timeout?: number }): Promise<unknown>;
on<K extends keyof GroupsClusterAttributes & string>(eventName: `attr.${K}`, listener: (value: GroupsClusterAttributes[K]) => void): this;
once<K extends keyof GroupsClusterAttributes & string>(eventName: `attr.${K}`, listener: (value: GroupsClusterAttributes[K]) => void): this;
addGroup(args: { groupId: number; groupName: string }, opts?: ClusterCommandOptions): Promise<{ status: 'SUCCESS' | 'FAILURE' | 'NOT_AUTHORIZED' | 'RESERVED_FIELD_NOT_ZERO' | 'MALFORMED_COMMAND' | 'UNSUP_CLUSTER_COMMAND' | 'UNSUP_GENERAL_COMMAND' | 'UNSUP_MANUF_CLUSTER_COMMAND' | 'UNSUP_MANUF_GENERAL_COMMAND' | 'INVALID_FIELD' | 'UNSUPPORTED_ATTRIBUTE' | 'INVALID_VALUE' | 'READ_ONLY' | 'INSUFFICIENT_SPACE' | 'DUPLICATE_EXISTS' | 'NOT_FOUND' | 'UNREPORTABLE_ATTRIBUTE' | 'INVALID_DATA_TYPE' | 'INVALID_SELECTOR' | 'WRITE_ONLY' | 'INCONSISTENT_STARTUP_STATE' | 'DEFINED_OUT_OF_BAND' | 'INCONSISTENT' | 'ACTION_DENIED' | 'TIMEOUT' | 'ABORT' | 'INVALID_IMAGE' | 'WAIT_FOR_DATA' | 'NO_IMAGE_AVAILABLE' | 'REQUIRE_MORE_IMAGE' | 'NOTIFICATION_PENDING' | 'HARDWARE_FAILURE' | 'SOFTWARE_FAILURE' | 'CALIBRATION_ERROR' | 'UNSUPPORTED_CLUSTER'; groupId: number }>;
viewGroup(args: { groupId: number }, opts?: ClusterCommandOptions): Promise<{ status: 'SUCCESS' | 'FAILURE' | 'NOT_AUTHORIZED' | 'RESERVED_FIELD_NOT_ZERO' | 'MALFORMED_COMMAND' | 'UNSUP_CLUSTER_COMMAND' | 'UNSUP_GENERAL_COMMAND' | 'UNSUP_MANUF_CLUSTER_COMMAND' | 'UNSUP_MANUF_GENERAL_COMMAND' | 'INVALID_FIELD' | 'UNSUPPORTED_ATTRIBUTE' | 'INVALID_VALUE' | 'READ_ONLY' | 'INSUFFICIENT_SPACE' | 'DUPLICATE_EXISTS' | 'NOT_FOUND' | 'UNREPORTABLE_ATTRIBUTE' | 'INVALID_DATA_TYPE' | 'INVALID_SELECTOR' | 'WRITE_ONLY' | 'INCONSISTENT_STARTUP_STATE' | 'DEFINED_OUT_OF_BAND' | 'INCONSISTENT' | 'ACTION_DENIED' | 'TIMEOUT' | 'ABORT' | 'INVALID_IMAGE' | 'WAIT_FOR_DATA' | 'NO_IMAGE_AVAILABLE' | 'REQUIRE_MORE_IMAGE' | 'NOTIFICATION_PENDING' | 'HARDWARE_FAILURE' | 'SOFTWARE_FAILURE' | 'CALIBRATION_ERROR' | 'UNSUPPORTED_CLUSTER'; groupId: number; groupNames: string }>;
addGroup(args: { groupId: number; groupName: string }, opts?: ClusterCommandOptions): Promise<{ status: ZCLEnum8Status; groupId: number }>;
viewGroup(args: { groupId: number }, opts?: ClusterCommandOptions): Promise<{ status: ZCLEnum8Status; groupId: number; groupNames: string }>;
getGroupMembership(args: { groupIds: number[] }, opts?: ClusterCommandOptions): Promise<{ capacity: number; groups: number[] }>;
removeGroup(args: { groupId: number }, opts?: ClusterCommandOptions): Promise<{ status: 'SUCCESS' | 'FAILURE' | 'NOT_AUTHORIZED' | 'RESERVED_FIELD_NOT_ZERO' | 'MALFORMED_COMMAND' | 'UNSUP_CLUSTER_COMMAND' | 'UNSUP_GENERAL_COMMAND' | 'UNSUP_MANUF_CLUSTER_COMMAND' | 'UNSUP_MANUF_GENERAL_COMMAND' | 'INVALID_FIELD' | 'UNSUPPORTED_ATTRIBUTE' | 'INVALID_VALUE' | 'READ_ONLY' | 'INSUFFICIENT_SPACE' | 'DUPLICATE_EXISTS' | 'NOT_FOUND' | 'UNREPORTABLE_ATTRIBUTE' | 'INVALID_DATA_TYPE' | 'INVALID_SELECTOR' | 'WRITE_ONLY' | 'INCONSISTENT_STARTUP_STATE' | 'DEFINED_OUT_OF_BAND' | 'INCONSISTENT' | 'ACTION_DENIED' | 'TIMEOUT' | 'ABORT' | 'INVALID_IMAGE' | 'WAIT_FOR_DATA' | 'NO_IMAGE_AVAILABLE' | 'REQUIRE_MORE_IMAGE' | 'NOTIFICATION_PENDING' | 'HARDWARE_FAILURE' | 'SOFTWARE_FAILURE' | 'CALIBRATION_ERROR' | 'UNSUPPORTED_CLUSTER'; groupId: number }>;
removeGroup(args: { groupId: number }, opts?: ClusterCommandOptions): Promise<{ status: ZCLEnum8Status; groupId: number }>;
removeAllGroups(opts?: ClusterCommandOptions): Promise<void>;
addGroupIfIdentify(args: { groupId: number; groupName: string }, opts?: ClusterCommandOptions): Promise<void>;
}
Expand Down Expand Up @@ -848,7 +850,36 @@ export interface OnOffCluster extends ZCLNodeCluster {
export interface OnOffSwitchCluster extends ZCLNodeCluster {
}

export interface OTAClusterAttributes {
upgradeServerID?: string;
fileOffset?: number;
currentFileVersion?: number;
currentZigBeeStackVersion?: number;
downloadedFileVersion?: number;
downloadedZigBeeStackVersion?: number;
imageUpgradeStatus?: 'normal' | 'downloadInProgress' | 'downloadComplete' | 'waitingToUpgrade' | 'countDown' | 'waitForMore' | 'waitingToUpgradeViaExternalEvent';
manufacturerID?: number;
imageTypeID?: number;
minimumBlockPeriod?: number;
imageStamp?: number;
upgradeActivationPolicy?: 'otaServerActivationAllowed' | 'outOfBandActivationOnly';
upgradeTimeoutPolicy?: 'applyUpgradeAfterTimeout' | 'doNotApplyUpgradeAfterTimeout';
}

export interface OTACluster extends ZCLNodeCluster {
readAttributes<K extends 'upgradeServerID' | 'fileOffset' | 'currentFileVersion' | 'currentZigBeeStackVersion' | 'downloadedFileVersion' | 'downloadedZigBeeStackVersion' | 'imageUpgradeStatus' | 'manufacturerID' | 'imageTypeID' | 'minimumBlockPeriod' | 'imageStamp' | 'upgradeActivationPolicy' | 'upgradeTimeoutPolicy'>(attributeNames: K[], opts?: { timeout?: number }): Promise<Pick<OTAClusterAttributes, K>>;
readAttributes(attributeNames: Array<keyof OTAClusterAttributes | number>, opts?: { timeout?: number }): Promise<Partial<OTAClusterAttributes> & Record<number, unknown>>;
writeAttributes(attributes: Partial<OTAClusterAttributes>, opts?: { timeout?: number }): Promise<unknown>;
on<K extends keyof OTAClusterAttributes & string>(eventName: `attr.${K}`, listener: (value: OTAClusterAttributes[K]) => void): this;
once<K extends keyof OTAClusterAttributes & string>(eventName: `attr.${K}`, listener: (value: OTAClusterAttributes[K]) => void): this;
imageNotify(args?: { payloadType?: 'queryJitter' | 'queryJitterAndManufacturerCode' | 'queryJitterAndManufacturerCodeAndImageType' | 'queryJitterAndManufacturerCodeAndImageTypeAndNewFileVersion'; queryJitter?: number; manufacturerCode?: number; imageType?: number; newFileVersion?: number }, opts?: ClusterCommandOptions): Promise<void>;
queryNextImageRequest(args?: { fieldControl?: Partial<{ hardwareVersionPresent: boolean }>; manufacturerCode?: number; imageType?: number; fileVersion?: number; hardwareVersion?: number }, opts?: ClusterCommandOptions): Promise<{ status?: ZCLEnum8Status; manufacturerCode?: number; imageType?: number; fileVersion?: number; imageSize?: number }>;
imageBlockRequest(args?: { fieldControl?: Partial<{ requestNodeAddressPresent: boolean; minimumBlockPeriodPresent: boolean }>; manufacturerCode?: number; imageType?: number; fileVersion?: number; fileOffset?: number; maximumDataSize?: number; requestNodeAddress?: string; minimumBlockPeriod?: number }, opts?: ClusterCommandOptions): Promise<{ status?: ZCLEnum8Status; manufacturerCode?: number; imageType?: number; fileVersion?: number; fileOffset?: number; dataSize?: number; imageData?: Buffer; currentTime?: number; requestTime?: number; minimumBlockPeriod?: number }>;
imagePageRequest(args?: { fieldControl?: Partial<{ requestNodeAddressPresent: boolean }>; manufacturerCode?: number; imageType?: number; fileVersion?: number; fileOffset?: number; maximumDataSize?: number; pageSize?: number; responseSpacing?: number; requestNodeAddress?: string }, opts?: ClusterCommandOptions): Promise<{ status?: ZCLEnum8Status; manufacturerCode?: number; imageType?: number; fileVersion?: number; fileOffset?: number; dataSize?: number; imageData?: Buffer; currentTime?: number; requestTime?: number; minimumBlockPeriod?: number }>;
imageBlockResponse(args?: { status?: ZCLEnum8Status; manufacturerCode?: number; imageType?: number; fileVersion?: number; fileOffset?: number; dataSize?: number; imageData?: Buffer; currentTime?: number; requestTime?: number; minimumBlockPeriod?: number }, opts?: ClusterCommandOptions): Promise<void>;
upgradeEndRequest(args: { status: ZCLEnum8Status; manufacturerCode: number; imageType: number; fileVersion: number }, opts?: ClusterCommandOptions): Promise<{ manufacturerCode: number; imageType: number; fileVersion: number; currentTime: number; upgradeTime: number }>;
upgradeEndResponse(args: { manufacturerCode: number; imageType: number; fileVersion: number; currentTime: number; upgradeTime: number }, opts?: ClusterCommandOptions): Promise<void>;
queryDeviceSpecificFileRequest(args: { requestNodeAddress: string; manufacturerCode: number; imageType: number; fileVersion: number; zigBeeStackVersion: number }, opts?: ClusterCommandOptions): Promise<{ status?: ZCLEnum8Status; manufacturerCode?: number; imageType?: number; fileVersion?: number; imageSize?: number }>;
}

export interface PollControlClusterAttributes {
Expand Down Expand Up @@ -1165,7 +1196,7 @@ export interface ClusterAttributesByName {
occupancySensing: OccupancySensingClusterAttributes;
onOff: OnOffClusterAttributes;
onOffSwitch: Record<string, unknown>;
ota: Record<string, unknown>;
ota: OTAClusterAttributes;
pollControl: PollControlClusterAttributes;
powerConfiguration: PowerConfigurationClusterAttributes;
powerProfile: Record<string, unknown>;
Expand Down Expand Up @@ -1197,6 +1228,7 @@ export type ZCLNodeEndpoint = {
clusters: ClusterRegistry & {
[clusterName: string]: ZCLNodeCluster | undefined;
};
bind(clusterName: string, impl: BoundCluster): void;
};

export interface ZCLNode {
Expand Down Expand Up @@ -1486,6 +1518,14 @@ export const CLUSTER: {
COMMANDS: unknown;
};
};
export class BoundCluster {

}

export class ZCLError extends Error {
zclStatus: ZCLEnum8Status;
constructor(zclStatus?: ZCLEnum8Status);
}
export const AlarmsCluster: {
new (...args: any[]): AlarmsCluster;
ID: 9;
Expand Down
2 changes: 2 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const Clusters = require('./lib/clusters');
const BoundCluster = require('./lib/BoundCluster');
const zclTypes = require('./lib/zclTypes');
const zclFrames = require('./lib/zclFrames');
const { ZCLError } = require('./lib/util');

/**
* Enables or disables debug logging.
Expand Down Expand Up @@ -55,6 +56,7 @@ module.exports = {
ZCLDataTypes,
ZCLDataType,
ZCLStruct,
ZCLError,
...Clusters,
debug,
ZIGBEE_PROFILE_ID,
Expand Down
2 changes: 1 addition & 1 deletion lib/BoundCluster.js
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ class BoundCluster {
const result = await this[command.name](args, meta, frame, rawFrame);
if (command.response && command.response.args) {
// eslint-disable-next-line new-cap
return [command.response.id, new command.response.args(result)];
return [command.response.id, new command.response.args(result), command.response];
}
// eslint-disable-next-line consistent-return
return;
Expand Down
14 changes: 10 additions & 4 deletions lib/Cluster.js
Original file line number Diff line number Diff line change
Expand Up @@ -789,7 +789,7 @@ class Cluster extends EventEmitter {
const response = await handler.call(this, args, meta, frame, rawFrame);
if (command.response && command.response.args) {
// eslint-disable-next-line new-cap
return [command.response.id, new command.response.args(response)];
return [command.response.id, new command.response.args(response), command.response];
}
// eslint-disable-next-line consistent-return
return;
Expand Down Expand Up @@ -1027,7 +1027,9 @@ class Cluster extends EventEmitter {
clusterClass.commandsById = Object.entries(clusterClass.commands).reduce((r, [name, _cmd]) => {
const cmd = { ..._cmd, name };
if (cmd.args) {
cmd.args = ZCLStruct(`${clusterClass.NAME}.${name}`, cmd.args);
cmd.args = ZCLStruct(`${clusterClass.NAME}.${name}`, cmd.args, {
encodeMissingFieldsBehavior: cmd.encodeMissingFieldsBehavior,
});
if (_cmd === GLOBAL_COMMANDS.defaultResponse) {
clusterClass.defaultResponseArgsType = cmd.args;
}
Expand All @@ -1045,7 +1047,9 @@ class Cluster extends EventEmitter {
res.id = cmd.id;
}
if (res.args) {
res.args = ZCLStruct(`${clusterClass.NAME}.${res.name}`, res.args);
res.args = ZCLStruct(`${clusterClass.NAME}.${res.name}`, res.args, {
encodeMissingFieldsBehavior: res.encodeMissingFieldsBehavior,
});
}
if (cmd.global) res.global = true;
if (cmd.manufacturerSpecific) res.manufacturerSpecific = true;
Expand Down Expand Up @@ -1096,7 +1100,9 @@ class Cluster extends EventEmitter {
}

if (cmd.args) {
const CommandArgs = ZCLStruct(`${this.name}.${cmdName}`, cmd.args);
const CommandArgs = ZCLStruct(`${this.name}.${cmdName}`, cmd.args, {
encodeMissingFieldsBehavior: cmd.encodeMissingFieldsBehavior,
});
payload.data = new CommandArgs(args);
}

Expand Down
29 changes: 25 additions & 4 deletions lib/Endpoint.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ const BoundCluster = require('./BoundCluster');
const { ZCLStandardHeader, ZCLMfgSpecificHeader } = require('./zclFrames');

let { debug } = require('./util');
const { getLogId } = require('./util');
const { getLogId, ZCLError } = require('./util');
const { ZCLDataTypes } = require('./zclTypes');

debug = debug.extend('endpoint');

Expand Down Expand Up @@ -120,7 +121,10 @@ class Endpoint extends EventEmitter {

// If cluster specific error, respond with a default response error frame
if (clusterSpecificError) {
const defaultResponseErrorFrame = this.makeDefaultResponseFrame(frame, false);
const status = clusterSpecificError instanceof ZCLError
? clusterSpecificError.zclStatus : undefined;

const defaultResponseErrorFrame = this.makeDefaultResponseFrame(frame, false, status);
this.sendFrame(clusterId, defaultResponseErrorFrame.toBuffer()).catch(err => {
debug(`${this.getLogId(clusterId)}, error while sending default error response`, err, { response: defaultResponseErrorFrame });
});
Expand All @@ -135,7 +139,10 @@ class Endpoint extends EventEmitter {
// If a cluster specific response was generated, set the response data
// and cmdId in the response frame.
if (clusterSpecificResponse) {
const [cmdId, data] = clusterSpecificResponse;
const [cmdId, data, cmd] = clusterSpecificResponse;
if (!cmd.global) {
responseFrame.frameControl.clusterSpecific = true;
}
responseFrame.data = data.toBuffer();
responseFrame.cmdId = cmdId;
}
Expand Down Expand Up @@ -185,9 +192,10 @@ class Endpoint extends EventEmitter {
* Returns a default response frame with an error status code.
* @param {*} receivedFrame
* @param {boolean} success
* @param {string} [status] - Optional ZCL status code to use in the response
* @returns {ZCLStandardHeader|ZCLMfgSpecificHeader}
*/
makeDefaultResponseFrame(receivedFrame, success) {
makeDefaultResponseFrame(receivedFrame, success, status) {
let responseFrame;
if (receivedFrame instanceof ZCLStandardHeader) {
responseFrame = new ZCLStandardHeader();
Expand All @@ -205,6 +213,19 @@ class Endpoint extends EventEmitter {
responseFrame.trxSequenceNumber = receivedFrame.trxSequenceNumber;
responseFrame.cmdId = 0x0B;
responseFrame.data = Buffer.from([receivedFrame.cmdId, success ? 0 : 1]);

// If not successful, set the status code in the response frame data
// Note that if the status code is invalid, enum8Status.toBuffer will throw an error,
// and the default error status will be FAILURE. This also allows overriding the status code
// with SUCCESS, which is intentional. The OTA Cluster requires this in some cases.
if (!success && typeof status === 'string') {
try {
ZCLDataTypes.enum8Status.toBuffer(responseFrame.data, status, 1);
} catch (err) {
// Ignore invalid status codes and keep the default FAILURE status.
}
}

return responseFrame;
}

Expand Down
Loading
Loading