diff --git a/.gitignore b/.gitignore index dd87e2d..ff6157e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules build +.envrc \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 7a0e5a4..a28a2e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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 diff --git a/index.d.ts b/index.d.ts index fb17920..6474e31 100644 --- a/index.d.ts +++ b/index.d.ts @@ -31,6 +31,8 @@ type ZCLNodeConstructorInput = { ) => Promise; }; +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; @@ -449,10 +451,10 @@ export interface GroupsCluster extends ZCLNodeCluster { writeAttributes(attributes: Partial, opts?: { timeout?: number }): Promise; on(eventName: `attr.${K}`, listener: (value: GroupsClusterAttributes[K]) => void): this; once(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; addGroupIfIdentify(args: { groupId: number; groupName: string }, opts?: ClusterCommandOptions): Promise; } @@ -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(attributeNames: K[], opts?: { timeout?: number }): Promise>; + readAttributes(attributeNames: Array, opts?: { timeout?: number }): Promise & Record>; + writeAttributes(attributes: Partial, opts?: { timeout?: number }): Promise; + on(eventName: `attr.${K}`, listener: (value: OTAClusterAttributes[K]) => void): this; + once(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; + 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; + 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; + 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 { @@ -1165,7 +1196,7 @@ export interface ClusterAttributesByName { occupancySensing: OccupancySensingClusterAttributes; onOff: OnOffClusterAttributes; onOffSwitch: Record; - ota: Record; + ota: OTAClusterAttributes; pollControl: PollControlClusterAttributes; powerConfiguration: PowerConfigurationClusterAttributes; powerProfile: Record; @@ -1197,6 +1228,7 @@ export type ZCLNodeEndpoint = { clusters: ClusterRegistry & { [clusterName: string]: ZCLNodeCluster | undefined; }; + bind(clusterName: string, impl: BoundCluster): void; }; export interface ZCLNode { @@ -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; diff --git a/index.js b/index.js index 63eb227..735cb96 100644 --- a/index.js +++ b/index.js @@ -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. @@ -55,6 +56,7 @@ module.exports = { ZCLDataTypes, ZCLDataType, ZCLStruct, + ZCLError, ...Clusters, debug, ZIGBEE_PROFILE_ID, diff --git a/lib/BoundCluster.js b/lib/BoundCluster.js index a318d99..fe1c90c 100644 --- a/lib/BoundCluster.js +++ b/lib/BoundCluster.js @@ -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; diff --git a/lib/Cluster.js b/lib/Cluster.js index 945655f..e66f7df 100644 --- a/lib/Cluster.js +++ b/lib/Cluster.js @@ -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; @@ -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; } @@ -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; @@ -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); } diff --git a/lib/Endpoint.js b/lib/Endpoint.js index 5cde4e0..a4a9ffa 100644 --- a/lib/Endpoint.js +++ b/lib/Endpoint.js @@ -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'); @@ -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 }); }); @@ -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; } @@ -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(); @@ -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; } diff --git a/lib/clusters/ota.js b/lib/clusters/ota.js index a172a6a..b7d31f5 100644 --- a/lib/clusters/ota.js +++ b/lib/clusters/ota.js @@ -1,15 +1,286 @@ 'use strict'; const Cluster = require('../Cluster'); +const { ZCLDataTypes } = require('../zclTypes'); -const ATTRIBUTES = {}; +// ============================================================================ +// Server Attributes +// ============================================================================ +const ATTRIBUTES = { + // OTA Upgrade Cluster Attributes (0x0000 - 0x000C) -const COMMANDS = {}; + // The IEEE address of the upgrade server resulted from the discovery of the + // upgrade server's identity. If the value is set to a non-zero value and + // corresponds to an IEEE address of a device that is no longer accessible, a + // device MAY choose to discover a new Upgrade Server depending on its own + // security policies. + upgradeServerID: { id: 0x0000, type: ZCLDataTypes.EUI64 }, // Mandatory + + // The parameter indicates the current location in the OTA upgrade image. It is + // essentially the (start of the) address of the image data that is being + // transferred from the OTA server to the client. + fileOffset: { id: 0x0001, type: ZCLDataTypes.uint32 }, // Optional + + // The file version of the running firmware image on the device. + currentFileVersion: { id: 0x0002, type: ZCLDataTypes.uint32 }, // Optional + + // The ZigBee stack version of the running image on the device. + currentZigBeeStackVersion: { id: 0x0003, type: ZCLDataTypes.uint16 }, // Optional + + // The file version of the downloaded image on additional memory space on the + // device. + downloadedFileVersion: { id: 0x0004, type: ZCLDataTypes.uint32 }, // Optional + + // The ZigBee stack version of the downloaded image on additional memory space + // on the device. + downloadedZigBeeStackVersion: { id: 0x0005, type: ZCLDataTypes.uint16 }, // Optional + + // The upgrade status of the client device. The status indicates where the client + // device is at in terms of the download and upgrade process. + imageUpgradeStatus: { // Mandatory + id: 0x0006, + type: ZCLDataTypes.enum8({ + normal: 0x00, + downloadInProgress: 0x01, + downloadComplete: 0x02, + waitingToUpgrade: 0x03, + countDown: 0x04, + waitForMore: 0x05, + waitingToUpgradeViaExternalEvent: 0x06, + }), + }, + + // The ZigBee assigned value for the manufacturer of the device. + manufacturerID: { id: 0x0007, type: ZCLDataTypes.uint16 }, // Optional + + // The image type identifier of the file that the client is currently downloading, + // or a file that has been completely downloaded but not upgraded to yet. + imageTypeID: { id: 0x0008, type: ZCLDataTypes.uint16 }, // Optional + + // This attribute acts as a rate limiting feature for the server to slow down the + // client download and prevent saturating the network with block requests. The + // value is in milliseconds. + minimumBlockPeriod: { id: 0x0009, type: ZCLDataTypes.uint16 }, // Optional + + // A 32 bit value used as a second verification to identify the image. The value + // must be consistent during the lifetime of the same image and unique for each + // different build of the image. + imageStamp: { id: 0x000A, type: ZCLDataTypes.uint32 }, // 10, Optional + + // Indicates what behavior the client device supports for activating a fully + // downloaded but not installed upgrade image. + upgradeActivationPolicy: { // Optional + id: 0x000B, // 11 + type: ZCLDataTypes.enum8({ + otaServerActivationAllowed: 0x00, + outOfBandActivationOnly: 0x01, + }), + }, + + // Dictates the behavior of the device in situations where an explicit activation + // command cannot be retrieved. + upgradeTimeoutPolicy: { // Optional + id: 0x000C, // 12 + type: ZCLDataTypes.enum8({ + applyUpgradeAfterTimeout: 0x00, + doNotApplyUpgradeAfterTimeout: 0x01, + }), + }, +}; + +// Response to the Image Block Request or Image Page Request. The payload varies +// based on the status field. +const IMAGE_BLOCK_RESPONSE = { + id: 0x0005, + encodeMissingFieldsBehavior: 'skip', + args: { + status: ZCLDataTypes.enum8Status, + // When status is SUCCESS + manufacturerCode: ZCLDataTypes.uint16, + imageType: ZCLDataTypes.uint16, + fileVersion: ZCLDataTypes.uint32, + fileOffset: ZCLDataTypes.uint32, + dataSize: ZCLDataTypes.uint8, + imageData: ZCLDataTypes.buffer, + // When status is WAIT_FOR_DATA + currentTime: ZCLDataTypes.uint32, + requestTime: ZCLDataTypes.uint32, + minimumBlockPeriod: ZCLDataTypes.uint16, + }, +}; + +const UPGRADE_END_RESPONSE = { + id: 0x0007, + args: { + manufacturerCode: ZCLDataTypes.uint16, + imageType: ZCLDataTypes.uint16, + fileVersion: ZCLDataTypes.uint32, + currentTime: ZCLDataTypes.uint32, + upgradeTime: ZCLDataTypes.uint32, + }, +}; + +// ============================================================================ +// Commands +// ============================================================================ +const COMMANDS = { + // --- Server to Client Commands --- + + // The purpose of sending Image Notify command is so the server has a way to + // notify client devices of when the OTA upgrade images are available for them. + imageNotify: { // Optional + id: 0x0000, + direction: Cluster.DIRECTION_SERVER_TO_CLIENT, + encodeMissingFieldsBehavior: 'skip', + frameControl: ['directionToClient', 'clusterSpecific', 'disableDefaultResponse'], + args: { + payloadType: ZCLDataTypes.enum8({ + queryJitter: 0x00, + queryJitterAndManufacturerCode: 0x01, + queryJitterAndManufacturerCodeAndImageType: 0x02, + queryJitterAndManufacturerCodeAndImageTypeAndNewFileVersion: 0x03, + }), + queryJitter: ZCLDataTypes.uint8, + // Present when payloadType >= 0x01 + manufacturerCode: ZCLDataTypes.uint16, + // Present when payloadType >= 0x02 + imageType: ZCLDataTypes.uint16, + // Present when payloadType >= 0x03 + newFileVersion: ZCLDataTypes.uint32, + }, + }, + + // --- Client to Server Commands --- + + // Client queries the server if new OTA upgrade image is available. + queryNextImageRequest: { // Mandatory + id: 0x0001, + encodeMissingFieldsBehavior: 'skip', + args: { + fieldControl: ZCLDataTypes.map8('hardwareVersionPresent'), + manufacturerCode: ZCLDataTypes.uint16, + imageType: ZCLDataTypes.uint16, + fileVersion: ZCLDataTypes.uint32, + // Present when fieldControl bit 0 is set + hardwareVersion: ZCLDataTypes.uint16, + }, + response: { + id: 0x0002, + encodeMissingFieldsBehavior: 'skip', + args: { + status: ZCLDataTypes.enum8Status, + // Remaining fields only present when status is SUCCESS + manufacturerCode: ZCLDataTypes.uint16, + imageType: ZCLDataTypes.uint16, + fileVersion: ZCLDataTypes.uint32, + imageSize: ZCLDataTypes.uint32, + }, + }, + }, + + // Client requests a block of data from the OTA upgrade image. + imageBlockRequest: { // Mandatory + id: 0x0003, + encodeMissingFieldsBehavior: 'skip', + args: { + fieldControl: ZCLDataTypes.map8( + 'requestNodeAddressPresent', + 'minimumBlockPeriodPresent', + ), + manufacturerCode: ZCLDataTypes.uint16, + imageType: ZCLDataTypes.uint16, + fileVersion: ZCLDataTypes.uint32, + fileOffset: ZCLDataTypes.uint32, + maximumDataSize: ZCLDataTypes.uint8, + // Present when fieldControl bit 0 is set + requestNodeAddress: ZCLDataTypes.EUI64, + // Present when fieldControl bit 1 is set + minimumBlockPeriod: ZCLDataTypes.uint16, + }, + response: IMAGE_BLOCK_RESPONSE, + }, + + // Client requests pages of data from the OTA upgrade image. Using Image Page + // Request reduces the number of requests sent from the client to the upgrade + // server, compared to using Image Block Request command. + imagePageRequest: { // Optional + id: 0x0004, + encodeMissingFieldsBehavior: 'skip', + args: { + fieldControl: ZCLDataTypes.map8('requestNodeAddressPresent'), + manufacturerCode: ZCLDataTypes.uint16, + imageType: ZCLDataTypes.uint16, + fileVersion: ZCLDataTypes.uint32, + fileOffset: ZCLDataTypes.uint32, + maximumDataSize: ZCLDataTypes.uint8, + pageSize: ZCLDataTypes.uint16, + responseSpacing: ZCLDataTypes.uint16, + // Present when fieldControl bit 0 is set + requestNodeAddress: ZCLDataTypes.EUI64, + }, + response: IMAGE_BLOCK_RESPONSE, + }, + + imageBlockResponse: { // Mandatory + ...IMAGE_BLOCK_RESPONSE, + direction: Cluster.DIRECTION_SERVER_TO_CLIENT, + frameControl: ['directionToClient', 'clusterSpecific', 'disableDefaultResponse'], + }, + + // Sent by the client when it has completed downloading an image. The status + // value SHALL be SUCCESS, INVALID_IMAGE, REQUIRE_MORE_IMAGE, or ABORT. + upgradeEndRequest: { // Mandatory + id: 0x0006, + args: { + status: ZCLDataTypes.enum8Status, + manufacturerCode: ZCLDataTypes.uint16, + imageType: ZCLDataTypes.uint16, + fileVersion: ZCLDataTypes.uint32, + }, + response: UPGRADE_END_RESPONSE, + }, + + // --- Server to Client Commands --- + + // Response to the Upgrade End Request, indicating when the client should upgrade + // to the new image. + upgradeEndResponse: { // Mandatory + ...UPGRADE_END_RESPONSE, + direction: Cluster.DIRECTION_SERVER_TO_CLIENT, + frameControl: ['directionToClient', 'clusterSpecific', 'disableDefaultResponse'], + }, + + // Client requests a device specific file such as security credential, + // configuration or log. + queryDeviceSpecificFileRequest: { // Optional + id: 0x0008, + args: { + requestNodeAddress: ZCLDataTypes.EUI64, + manufacturerCode: ZCLDataTypes.uint16, + imageType: ZCLDataTypes.uint16, + fileVersion: ZCLDataTypes.uint32, + zigBeeStackVersion: ZCLDataTypes.uint16, + }, + response: { + id: 0x0009, + encodeMissingFieldsBehavior: 'skip', + args: { + status: ZCLDataTypes.enum8Status, + // Remaining fields only present when status is SUCCESS + manufacturerCode: ZCLDataTypes.uint16, + imageType: ZCLDataTypes.uint16, + fileVersion: ZCLDataTypes.uint32, + imageSize: ZCLDataTypes.uint32, + }, + }, + }, + +}; class OTACluster extends Cluster { static get ID() { - return 25; // 0x19 + return 0x0019; // 25 } static get NAME() { diff --git a/lib/util/index.js b/lib/util/index.js index 3da7649..5343125 100644 --- a/lib/util/index.js +++ b/lib/util/index.js @@ -21,8 +21,18 @@ function getLogId(endpointId, clusterName, clusterId) { return `ep: ${endpointId}, cl: ${clusterName} (${clusterId})`; } +class ZCLError extends Error { + + constructor(zclStatus = 'FAILURE') { + super(`ZCL Error: ${zclStatus}`); + this.zclStatus = zclStatus; + } + +} + module.exports = { debug, getLogId, getPropertyDescriptor, + ZCLError, }; diff --git a/package-lock.json b/package-lock.json index db83516..f3cd940 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "2.9.1", "license": "ISC", "dependencies": { - "@athombv/data-types": "^1.1.3", + "@athombv/data-types": "^1.2.0", "debug": "^4.1.1" }, "devDependencies": { @@ -32,9 +32,9 @@ } }, "node_modules/@athombv/data-types": { - "version": "1.1.3", - "resolved": "https://npm.pkg.github.com/download/@athombv/data-types/1.1.3/037d9c66a3af4411e2410e424b08e2aa1c454155", - "integrity": "sha512-W95IbuE05ZUn7gNaSmpJorWvaA6YoaAIGrxhQ4wgX7esCeOwLnrEeVtHY7tnMzjEvz1RqK8Quqwo5ZDHXAMiJQ==", + "version": "1.2.0", + "resolved": "https://npm.pkg.github.com/download/@athombv/data-types/1.2.0/93c49d441f594617f9e8396f503e0773f14f79a9", + "integrity": "sha512-ms7FMIcndguAP24M3EYUERBbI9j28HNPhLsx6p3sqg4n1Z2AXTUOz2RYjeipVwk7E3xqPRhYdg2UpXLGESL1fw==", "license": "GPL-3.0", "engines": { "node": ">=12.0.0" @@ -5983,9 +5983,9 @@ }, "dependencies": { "@athombv/data-types": { - "version": "1.1.3", - "resolved": "https://npm.pkg.github.com/download/@athombv/data-types/1.1.3/037d9c66a3af4411e2410e424b08e2aa1c454155", - "integrity": "sha512-W95IbuE05ZUn7gNaSmpJorWvaA6YoaAIGrxhQ4wgX7esCeOwLnrEeVtHY7tnMzjEvz1RqK8Quqwo5ZDHXAMiJQ==" + "version": "1.2.0", + "resolved": "https://npm.pkg.github.com/download/@athombv/data-types/1.2.0/93c49d441f594617f9e8396f503e0773f14f79a9", + "integrity": "sha512-ms7FMIcndguAP24M3EYUERBbI9j28HNPhLsx6p3sqg4n1Z2AXTUOz2RYjeipVwk7E3xqPRhYdg2UpXLGESL1fw==" }, "@athombv/jsdoc-template": { "version": "1.6.1", diff --git a/package.json b/package.json index f3479ad..a119920 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "watch": "^1.0.2" }, "dependencies": { - "@athombv/data-types": "^1.1.3", + "@athombv/data-types": "^1.2.0", "debug": "^4.1.1" } } diff --git a/scripts/generate-types.js b/scripts/generate-types.js index 0ff97fd..83712c4 100644 --- a/scripts/generate-types.js +++ b/scripts/generate-types.js @@ -9,6 +9,7 @@ const fs = require('fs'); const path = require('path'); +const { ZCLDataTypes } = require('../lib/zclTypes'); const OUTPUT_FILE = path.join(__dirname, '../index.d.ts'); @@ -17,7 +18,7 @@ const OUTPUT_FILE = path.join(__dirname, '../index.d.ts'); * @param {object} dataType - ZCLDataType object with shortName and args * @returns {string} TypeScript type string */ -function zclTypeToTS(dataType) { +function zclTypeToTS(dataType, useEnum8StatusType = true) { if (!dataType || !dataType.shortName) return 'unknown'; const { shortName, args } = dataType; @@ -49,6 +50,10 @@ function zclTypeToTS(dataType) { return 'Buffer'; } + if (dataType === ZCLDataTypes.enum8Status && useEnum8StatusType) { + return 'ZCLEnum8Status'; + } + // Enum types - extract keys from args[0] if (/^enum(4|8|16|32)$/.test(shortName)) { if (args && args[0] && typeof args[0] === 'object') { @@ -111,6 +116,8 @@ function parseCluster(ClusterClass, exportName) { for (const [name, def] of Object.entries(cmds)) { const cmdArgs = []; const responseArgs = []; + const cmdArgsOptional = def && def.encodeMissingFieldsBehavior === 'skip'; + const responseArgsOptional = def && def.response && def.response.encodeMissingFieldsBehavior === 'skip'; if (def && def.args) { for (const [argName, argType] of Object.entries(def.args)) { cmdArgs.push({ @@ -128,7 +135,9 @@ function parseCluster(ClusterClass, exportName) { }); } } - commands.push({ name, args: cmdArgs, responseArgs }); + commands.push({ + name, args: cmdArgs, responseArgs, cmdArgsOptional, responseArgsOptional, + }); } return { @@ -188,13 +197,14 @@ function generateClusterInterface(cluster) { // Determine return type based on response args let returnType = 'void'; if (cmd.responseArgs && cmd.responseArgs.length > 0) { - returnType = `{ ${cmd.responseArgs.map(a => `${a.name}: ${a.tsType}`).join('; ')} }`; + const sep = cmd.responseArgsOptional ? '?: ' : ': '; + returnType = `{ ${cmd.responseArgs.map(a => `${a.name}${sep}${a.tsType}`).join('; ')} }`; } if (cmd.args.length > 0) { - // Buffer arguments are optional - ZCL allows empty octet strings - const allArgsOptional = cmd.args.every(a => a.tsType === 'Buffer'); - const argsType = `{ ${cmd.args.map(a => `${a.name}${a.tsType === 'Buffer' ? '?' : ''}: ${a.tsType}`).join('; ')} }`; + // Args are optional when encodeMissingFieldsBehavior is 'skip', or when all args are Buffers + const allArgsOptional = cmd.cmdArgsOptional || cmd.args.every(a => a.tsType === 'Buffer'); + const argsType = `{ ${cmd.args.map(a => `${a.name}${(cmd.cmdArgsOptional || a.tsType === 'Buffer') ? '?' : ''}: ${a.tsType}`).join('; ')} }`; // If all args are optional, make the entire args object optional lines.push(` ${cmd.name}(args${allArgsOptional ? '?' : ''}: ${argsType}, opts?: ClusterCommandOptions): Promise<${returnType}>;`); } else { @@ -270,6 +280,8 @@ type ZCLNodeConstructorInput = { meta?: unknown ) => Promise; }; + +type ZCLEnum8Status = ${zclTypeToTS(ZCLDataTypes.enum8Status, false)}; `); // Base ZCLNodeCluster interface @@ -372,6 +384,7 @@ type ZCLNodeConstructorInput = { clusters: ClusterRegistry & { [clusterName: string]: ZCLNodeCluster | undefined; }; + bind(clusterName: string, impl: BoundCluster): void; }; export interface ZCLNode { @@ -402,6 +415,15 @@ export const CLUSTER: { } lines.push('};'); + lines.push(`export class BoundCluster { + +} + +export class ZCLError extends Error { + zclStatus: ZCLEnum8Status; + constructor(zclStatus?: ZCLEnum8Status); +}`); + // Export all cluster classes for (const cluster of clusters) { const interfaceName = toInterfaceName(cluster); diff --git a/test/ota.js b/test/ota.js new file mode 100644 index 0000000..0ed5d77 --- /dev/null +++ b/test/ota.js @@ -0,0 +1,200 @@ +// eslint-disable-next-line max-classes-per-file,lines-around-directive +'use strict'; + +const assert = require('assert'); +const BoundCluster = require('../lib/BoundCluster'); +const Node = require('../lib/Node'); +const OTACluster = require('../lib/clusters/ota'); + +// ZCL Standard Header: frameControl(1), trxSequenceNumber(1), cmdId(1), data(rest) +const ZCL_HEADER_SIZE = 3; + +// OTA cluster command IDs +const CMD_QUERY_NEXT_IMAGE_REQUEST = OTACluster.COMMANDS.queryNextImageRequest.id; +const CMD_QUERY_NEXT_IMAGE_RESPONSE = OTACluster.COMMANDS.queryNextImageRequest.response.id; + +/** + * Creates a mock node that captures raw ZCL frame buffers from every sendFrame call, + * with loopback so frames are handled by the same node. + */ +function createCapturingNode() { + const capturedFrames = []; + let node; + + const mockNode = { + sendFrame: (endpointId, clusterId, buffer) => { + capturedFrames.push(buffer); + return node.handleFrame(endpointId, clusterId, buffer, {}); + }, + endpointDescriptors: [{ + endpointId: 1, + inputClusters: [OTACluster.ID], + outputClusters: [], + }], + }; + + node = new Node(mockNode); + return { node, capturedFrames }; +} + +describe('OTA', function() { + // --- queryNextImageRequest: encodeMissingFieldsBehavior on the request --- + + it('should NOT encode hardwareVersion when omitted from queryNextImageRequest', async function() { + const { node, capturedFrames } = createCapturingNode(); + + let receivedData = null; + node.endpoints[1].bind('ota', new (class extends BoundCluster { + + async queryNextImageRequest(data) { + receivedData = data; + return { status: 'NO_IMAGE_AVAILABLE' }; + } + + })()); + + await node.endpoints[1].clusters.ota.queryNextImageRequest({ + fieldControl: 0, + manufacturerCode: 0x1234, + imageType: 0x0001, + fileVersion: 0x00000010, + // hardwareVersion intentionally omitted + }); + + const requestFrame = capturedFrames.find(buf => buf[2] === CMD_QUERY_NEXT_IMAGE_REQUEST); + assert.ok(requestFrame, 'request frame not captured'); + + // fieldControl(1) + manufacturerCode(2) + imageType(2) + fileVersion(4) = 9 bytes + // hardwareVersion (2 bytes) must NOT be present + const payload = requestFrame.slice(ZCL_HEADER_SIZE); + assert.strictEqual( + payload.length, + 9, + `payload must be 9 bytes (no hardwareVersion), got ${payload.length}`, + ); + + // Absent fields decode as 0 (decoder reads from exhausted buffer) + assert.strictEqual(receivedData.manufacturerCode, 0x1234); + assert.strictEqual(receivedData.imageType, 0x0001); + assert.strictEqual(receivedData.fileVersion, 0x00000010); + assert.strictEqual(receivedData.hardwareVersion, 0); + }); + + it('should encode hardwareVersion when provided in queryNextImageRequest', async function() { + const { node, capturedFrames } = createCapturingNode(); + + node.endpoints[1].bind('ota', new (class extends BoundCluster { + + async queryNextImageRequest() { + return { status: 'NO_IMAGE_AVAILABLE' }; + } + + })()); + + await node.endpoints[1].clusters.ota.queryNextImageRequest({ + fieldControl: ['hardwareVersionPresent'], + manufacturerCode: 0x1234, + imageType: 0x0001, + fileVersion: 0x00000010, + hardwareVersion: 0x0002, + }); + + const requestFrame = capturedFrames.find(buf => buf[2] === CMD_QUERY_NEXT_IMAGE_REQUEST); + assert.ok(requestFrame, 'request frame not captured'); + + // fieldControl(1) + manufacturerCode(2) + imageType(2) + fileVersion(4) + // + hardwareVersion(2) = 11 bytes + const payload = requestFrame.slice(ZCL_HEADER_SIZE); + assert.strictEqual( + payload.length, + 11, + `payload must be 11 bytes (with hardwareVersion), got ${payload.length}`, + ); + + // hardwareVersion is at bytes 9-10 (LE uint16) + assert.strictEqual(payload.readUInt16LE(9), 0x0002); + }); + + // --- queryNextImageResponse: encodeMissingFieldsBehavior on the response --- + + it('should encode all fields in queryNextImageResponse when status is SUCCESS', async function() { + const { node, capturedFrames } = createCapturingNode(); + + node.endpoints[1].bind('ota', new (class extends BoundCluster { + + async queryNextImageRequest() { + return { + status: 'SUCCESS', + manufacturerCode: 0x1234, + imageType: 0x0001, + fileVersion: 0x00000020, + imageSize: 0x00010000, + }; + } + + })()); + + const response = await node.endpoints[1].clusters.ota.queryNextImageRequest({ + fieldControl: 0, + manufacturerCode: 0x1234, + imageType: 0x0001, + fileVersion: 0x00000010, + }); + + const responseFrame = capturedFrames.find(buf => buf[2] === CMD_QUERY_NEXT_IMAGE_RESPONSE); + assert.ok(responseFrame, 'response frame not captured'); + + // status(1) + manufacturerCode(2) + imageType(2) + fileVersion(4) + imageSize(4) = 13 bytes + const payload = responseFrame.slice(ZCL_HEADER_SIZE); + assert.strictEqual( + payload.length, + 13, + `payload must be 13 bytes (all fields), got ${payload.length}`, + ); + + assert.strictEqual(response.status, 'SUCCESS'); + assert.strictEqual(response.manufacturerCode, 0x1234); + assert.strictEqual(response.imageType, 0x0001); + assert.strictEqual(response.fileVersion, 0x00000020); + assert.strictEqual(response.imageSize, 0x00010000); + }); + + it('should encode only the status byte in queryNextImageResponse when status is NO_IMAGE_AVAILABLE', + async function() { + const { node, capturedFrames } = createCapturingNode(); + + node.endpoints[1].bind('ota', new (class extends BoundCluster { + + async queryNextImageRequest() { + return { status: 'NO_IMAGE_AVAILABLE' }; + } + + })()); + + const response = await node.endpoints[1].clusters.ota.queryNextImageRequest({ + fieldControl: 0, + manufacturerCode: 0x1234, + imageType: 0x0001, + fileVersion: 0x00000010, + }); + + const responseFrame = capturedFrames.find(buf => buf[2] === CMD_QUERY_NEXT_IMAGE_RESPONSE); + assert.ok(responseFrame, 'response frame not captured'); + + // Only status(1) — all other fields must be absent + const payload = responseFrame.slice(ZCL_HEADER_SIZE); + assert.strictEqual( + payload.length, + 1, + `payload must be 1 byte (status only), got ${payload.length}`, + ); + assert.strictEqual(payload[0], 0x98, 'status byte must be NO_IMAGE_AVAILABLE (0x98)'); + + assert.strictEqual(response.status, 'NO_IMAGE_AVAILABLE'); + // Absent fields decode as 0 (decoder reads from exhausted buffer) + assert.strictEqual(response.manufacturerCode, 0); + assert.strictEqual(response.imageType, 0); + assert.strictEqual(response.fileVersion, 0); + assert.strictEqual(response.imageSize, 0); + }); +}); diff --git a/test/testZCLError.js b/test/testZCLError.js new file mode 100644 index 0000000..c4a7b15 --- /dev/null +++ b/test/testZCLError.js @@ -0,0 +1,212 @@ +/* eslint-disable max-classes-per-file */ + +'use strict'; + +const assert = require('assert'); +const BoundCluster = require('../lib/BoundCluster'); +const Node = require('../lib/Node'); +const OnOffCluster = require('../lib/clusters/onOff'); +const { ZCLStandardHeader } = require('../lib/zclFrames'); +const { ZCLError } = require('../lib/util'); +const { createMockNode } = require('./util'); + +/** + * Creates a connected node pair where frames sent from the device back to the controller + * are captured before being forwarded. + */ +function createSpyNodePair() { + const capturedFrames = []; + let controllerNode; + let deviceNode; + + const mockController = { + sendFrame: (endpointId, clusterId, data) => deviceNode.handleFrame(endpointId, clusterId, data), + endpointDescriptors: [{ endpointId: 1, inputClusters: [OnOffCluster.ID], outputClusters: [] }], + }; + + const mockDevice = { + sendFrame: (endpointId, clusterId, data) => { + capturedFrames.push({ endpointId, clusterId, data }); + return controllerNode.handleFrame(endpointId, clusterId, data); + }, + endpointDescriptors: [{ endpointId: 1, inputClusters: [OnOffCluster.ID], outputClusters: [] }], + }; + + controllerNode = new Node(mockController); + deviceNode = new Node(mockDevice); + + return { controllerNode, deviceNode, capturedFrames }; +} + +describe('ZCLError handling', function() { + it('should return ZCLError status code in Default Response when BoundCluster throws ZCLError', async function() { + const node = createMockNode({ + loopback: true, + endpoints: [{ + endpointId: 1, + inputClusters: [OnOffCluster.ID], + }], + }); + + node.endpoints[1].bind('onOff', new (class extends BoundCluster { + + async setOn() { + throw new ZCLError('NOT_AUTHORIZED'); + } + + })()); + + await assert.rejects( + () => node.endpoints[1].clusters.onOff.setOn(), + err => { + assert.strictEqual(err.message, 'NOT_AUTHORIZED'); + return true; + }, + ); + }); + + it('should return FAILURE status in Default Response when BoundCluster throws a generic Error', async function() { + const node = createMockNode({ + loopback: true, + endpoints: [{ + endpointId: 1, + inputClusters: [OnOffCluster.ID], + }], + }); + + node.endpoints[1].bind('onOff', new (class extends BoundCluster { + + async setOn() { + throw new Error('something went wrong'); + } + + })()); + + await assert.rejects( + () => node.endpoints[1].clusters.onOff.setOn(), + err => { + assert.strictEqual(err.message, 'FAILURE'); + return true; + }, + ); + }); + + it('should encode ZCLError status code in Default Response frame bytes', async function() { + // NOT_AUTHORIZED = 0x7e per ZCL spec + const NOT_AUTHORIZED_STATUS_BYTE = 0x7e; + const SET_ON_CMD_ID = OnOffCluster.COMMANDS.setOn.id; + const DEFAULT_RESPONSE_CMD_ID = 0x0B; + + const { controllerNode, deviceNode, capturedFrames } = createSpyNodePair(); + + deviceNode.endpoints[1].bind('onOff', new (class extends BoundCluster { + + async setOn() { + throw new ZCLError('NOT_AUTHORIZED'); + } + + })()); + + await assert.rejects(() => controllerNode.endpoints[1].clusters.onOff.setOn()); + + // capturedFrames only contains frames sent from the device back to the controller. + // Find the Default Response frame among the captured device → controller frames. + const defaultResponseFrame = capturedFrames + .map(f => ZCLStandardHeader.fromBuffer(f.data)) + .find(frame => frame.cmdId === DEFAULT_RESPONSE_CMD_ID); + + assert.ok(defaultResponseFrame, 'Default Response frame was sent'); + assert.strictEqual(defaultResponseFrame.data[0], SET_ON_CMD_ID, + 'Default Response should reference the received setOn command ID'); + assert.strictEqual(defaultResponseFrame.data[1], NOT_AUTHORIZED_STATUS_BYTE, + 'Default Response should encode NOT_AUTHORIZED (0x7e) as the status byte'); + }); + + it('should encode FAILURE (0x01) in Default Response frame bytes when a generic Error is thrown', async function() { + const FAILURE_STATUS_BYTE = 0x01; + const SET_ON_CMD_ID = OnOffCluster.COMMANDS.setOn.id; + const DEFAULT_RESPONSE_CMD_ID = 0x0B; + + const { controllerNode, deviceNode, capturedFrames } = createSpyNodePair(); + + deviceNode.endpoints[1].bind('onOff', new (class extends BoundCluster { + + async setOn() { + throw new Error('something went wrong'); + } + + })()); + + await assert.rejects(() => controllerNode.endpoints[1].clusters.onOff.setOn()); + + const defaultResponseFrame = capturedFrames + .map(f => ZCLStandardHeader.fromBuffer(f.data)) + .find(frame => frame.cmdId === DEFAULT_RESPONSE_CMD_ID); + + assert.ok(defaultResponseFrame, 'Default Response frame was sent'); + assert.strictEqual(defaultResponseFrame.data[0], SET_ON_CMD_ID, + 'Default Response should reference the received setOn command ID'); + assert.strictEqual(defaultResponseFrame.data[1], FAILURE_STATUS_BYTE, + 'Default Response should encode FAILURE (0x01) as the status byte'); + }); + + it('should send SUCCESS status byte when ZCLError("SUCCESS") is thrown', async function() { + const SUCCESS_STATUS_BYTE = 0x00; + const SET_ON_CMD_ID = OnOffCluster.COMMANDS.setOn.id; + const DEFAULT_RESPONSE_CMD_ID = 0x0B; + + const { controllerNode, deviceNode, capturedFrames } = createSpyNodePair(); + + deviceNode.endpoints[1].bind('onOff', new (class extends BoundCluster { + + async setOn() { + throw new ZCLError('SUCCESS'); + } + + })()); + + // The controller should not reject — a SUCCESS default response resolves the command + await controllerNode.endpoints[1].clusters.onOff.setOn(); + + const defaultResponseFrame = capturedFrames + .map(f => ZCLStandardHeader.fromBuffer(f.data)) + .find(frame => frame.cmdId === DEFAULT_RESPONSE_CMD_ID); + + assert.ok(defaultResponseFrame, 'Default Response frame was sent'); + assert.strictEqual(defaultResponseFrame.data[0], SET_ON_CMD_ID, + 'Default Response should reference the received setOn command ID'); + assert.strictEqual(defaultResponseFrame.data[1], SUCCESS_STATUS_BYTE, + 'Default Response should encode SUCCESS (0x00) as the status byte'); + }); + + it('should propagate different ZCLError status codes', async function() { + const statuses = ['NOT_AUTHORIZED', 'INVALID_VALUE', 'INSUFFICIENT_SPACE']; + + for (const status of statuses) { + const node = createMockNode({ + loopback: true, + endpoints: [{ + endpointId: 1, + inputClusters: [OnOffCluster.ID], + }], + }); + + node.endpoints[1].bind('onOff', new (class extends BoundCluster { + + async setOn() { + throw new ZCLError(status); + } + + })()); + + // eslint-disable-next-line no-await-in-loop + await assert.rejects( + () => node.endpoints[1].clusters.onOff.setOn(), + err => { + assert.strictEqual(err.message, status); + return true; + }, + ); + } + }); +});