From 2266c1218f20d96d6c9d5cb9ae870eec9e740d4b Mon Sep 17 00:00:00 2001 From: Ted Tolboom Date: Fri, 16 Oct 2026 20:56:58 +0200 Subject: [PATCH] feat(colorControl): Add the OptionsMask / OptionsOverride command fields The Move to Hue, Move to Saturation, Move to Hue and Saturation, Move to Color and Move to Color Temperature commands end with the OptionsMask and OptionsOverride fields (ZCL r8 5.2.2.3.3), which temporarily override the Options attribute, e.g. executeIfOff. They were not defined, so a driver could not send them without building a raw frame. The fields are optional (encodeMissingFieldsBehavior 'skip'): existing calls without them encode exactly as before. Co-Authored-By: Claude Opus 5.5 --- index.d.ts | 30 ++++++++++-- lib/clusters/colorControl.js | 24 ++++++++- test/colorControl.js | 95 ++++++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 6 deletions(-) diff --git a/index.d.ts b/index.d.ts index 0947555..e67f0bc 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1863,32 +1863,42 @@ declare module "zigbee-clusters" { startUpColorTemperatureMireds: { id: 0x4010, type: ZCLDataType }, }; type ColorControlClusterCommands = { - moveToHue: { id: 0x00, direction: "DIRECTION_SERVER_TO_CLIENT", args: { + moveToHue: { id: 0x00, direction: "DIRECTION_SERVER_TO_CLIENT", encodeMissingFieldsBehavior: "skip", args: { hue: ZCLDataType, direction: ZCLDataType<"shortestDistance" | "longestDistance" | "up" | "down">, transitionTime: ZCLDataType, + optionsMask: ZCLDataType>, + optionsOverride: ZCLDataType>, }, }, - moveToSaturation: { id: 0x03, direction: "DIRECTION_SERVER_TO_CLIENT", args: { + moveToSaturation: { id: 0x03, direction: "DIRECTION_SERVER_TO_CLIENT", encodeMissingFieldsBehavior: "skip", args: { saturation: ZCLDataType, transitionTime: ZCLDataType, + optionsMask: ZCLDataType>, + optionsOverride: ZCLDataType>, }, }, - moveToHueAndSaturation: { id: 0x06, direction: "DIRECTION_SERVER_TO_CLIENT", args: { + moveToHueAndSaturation: { id: 0x06, direction: "DIRECTION_SERVER_TO_CLIENT", encodeMissingFieldsBehavior: "skip", args: { hue: ZCLDataType, saturation: ZCLDataType, transitionTime: ZCLDataType, + optionsMask: ZCLDataType>, + optionsOverride: ZCLDataType>, }, }, - moveToColor: { id: 0x07, direction: "DIRECTION_SERVER_TO_CLIENT", args: { + moveToColor: { id: 0x07, direction: "DIRECTION_SERVER_TO_CLIENT", encodeMissingFieldsBehavior: "skip", args: { colorX: ZCLDataType, colorY: ZCLDataType, transitionTime: ZCLDataType, + optionsMask: ZCLDataType>, + optionsOverride: ZCLDataType>, }, }, - moveToColorTemperature: { id: 0x0a, direction: "DIRECTION_SERVER_TO_CLIENT", args: { + moveToColorTemperature: { id: 0x0a, direction: "DIRECTION_SERVER_TO_CLIENT", encodeMissingFieldsBehavior: "skip", args: { colorTemperature: ZCLDataType, transitionTime: ZCLDataType, + optionsMask: ZCLDataType>, + optionsOverride: ZCLDataType>, }, }, colorLoopSet: { id: 0x44, direction: "DIRECTION_SERVER_TO_CLIENT", args: { @@ -1907,6 +1917,8 @@ declare module "zigbee-clusters" { hue?: number, direction?: "shortestDistance" | "longestDistance" | "up" | "down", transitionTime?: number, + optionsMask?: Bitmap<"executeIfOff">, + optionsOverride?: Bitmap<"executeIfOff">, }, opts?: { waitForResponse?: boolean, @@ -1919,6 +1931,8 @@ declare module "zigbee-clusters" { manufacturerId?: number, saturation?: number, transitionTime?: number, + optionsMask?: Bitmap<"executeIfOff">, + optionsOverride?: Bitmap<"executeIfOff">, }, opts?: { waitForResponse?: boolean, @@ -1932,6 +1946,8 @@ declare module "zigbee-clusters" { hue?: number, saturation?: number, transitionTime?: number, + optionsMask?: Bitmap<"executeIfOff">, + optionsOverride?: Bitmap<"executeIfOff">, }, opts?: { waitForResponse?: boolean, @@ -1945,6 +1961,8 @@ declare module "zigbee-clusters" { colorX?: number, colorY?: number, transitionTime?: number, + optionsMask?: Bitmap<"executeIfOff">, + optionsOverride?: Bitmap<"executeIfOff">, }, opts?: { waitForResponse?: boolean, @@ -1957,6 +1975,8 @@ declare module "zigbee-clusters" { manufacturerId?: number, colorTemperature?: number, transitionTime?: number, + optionsMask?: Bitmap<"executeIfOff">, + optionsOverride?: Bitmap<"executeIfOff">, }, opts?: { waitForResponse?: boolean, diff --git a/lib/clusters/colorControl.js b/lib/clusters/colorControl.js index ef69918..559d86b 100644 --- a/lib/clusters/colorControl.js +++ b/lib/clusters/colorControl.js @@ -3,6 +3,18 @@ const Cluster = require('../Cluster'); const { ZCLDataTypes } = require('../zclTypes'); +// Bits of the Options attribute and of the OptionsMask / OptionsOverride command fields +const OPTIONS_BITMAP = ZCLDataTypes.map8('executeIfOff'); + +// The OptionsMask & OptionsOverride fields SHALL both be present. Default values are provided to +// interpret missing fields from legacy devices. A temporary Options bitmap SHALL be created from +// the Options attribute, using the OptionsMask & OptionsOverride fields. (ZCL r8 5.2.2.3.3) +// Optional here for backwards compatibility: only encoded when both are provided. +const OPTIONS_ARGS = { + optionsMask: OPTIONS_BITMAP, + optionsOverride: OPTIONS_BITMAP, +}; + const ATTRIBUTES = { // Mandatory if colorCapabilities = hueAndSaturation // Reportable, read-only @@ -39,7 +51,7 @@ const ATTRIBUTES = { // Determines the default behavior of some cluster commands, meant to be changed only during // commissioning. When executeIfOff is not set, a command is not executed if the On/Off cluster // exists on the same endpoint and its onOff attribute is false. - options: { id: 15, type: ZCLDataTypes.map8('executeIfOff') }, + options: { id: 15, type: OPTIONS_BITMAP }, // Mandatory if colorCapabilities = enhancedHue // Read-only, scene extension @@ -134,6 +146,7 @@ const ATTRIBUTES = { const COMMANDS = { moveToHue: { id: 0, + encodeMissingFieldsBehavior: 'skip', args: { hue: ZCLDataTypes.uint8, // Direction of travel round the hue circle. shortestDistance is normal usage, @@ -147,40 +160,49 @@ const COMMANDS = { }), // In 1/10ths of a second transitionTime: ZCLDataTypes.uint16, + ...OPTIONS_ARGS, }, }, moveToSaturation: { id: 3, + encodeMissingFieldsBehavior: 'skip', args: { saturation: ZCLDataTypes.uint8, // In 1/10ths of a second transitionTime: ZCLDataTypes.uint16, + ...OPTIONS_ARGS, }, }, moveToHueAndSaturation: { id: 6, + encodeMissingFieldsBehavior: 'skip', args: { hue: ZCLDataTypes.uint8, saturation: ZCLDataTypes.uint8, // In 1/10ths of a second transitionTime: ZCLDataTypes.uint16, + ...OPTIONS_ARGS, }, }, moveToColor: { id: 7, + encodeMissingFieldsBehavior: 'skip', args: { colorX: ZCLDataTypes.uint16, colorY: ZCLDataTypes.uint16, // In 1/10ths of a second transitionTime: ZCLDataTypes.uint16, + ...OPTIONS_ARGS, }, }, moveToColorTemperature: { id: 10, + encodeMissingFieldsBehavior: 'skip', args: { colorTemperature: ZCLDataTypes.uint16, // In 1/10ths of a second transitionTime: ZCLDataTypes.uint16, + ...OPTIONS_ARGS, }, }, diff --git a/test/colorControl.js b/test/colorControl.js index 1c192fb..2e1058f 100644 --- a/test/colorControl.js +++ b/test/colorControl.js @@ -36,6 +36,30 @@ async function sendColorLoopSet(args) { return Endpoint.parseFrame(sendFrameSpy.getCall(0).args[2]); } +// Sends a color command on a fresh node and returns the parsed outgoing frame (see above) +async function sendColorCommand(commandName, args) { + const node = createMockNode({ + loopback: true, + endpoints: [{ + endpointId: 1, + inputClusters: [ColorControlCluster.ID], + }], + }); + + node.endpoints[1].bind('colorControl', new (class extends BoundCluster { + + async [commandName]() { + // Accept the command so no error response is generated + } + + })()); + + const sendFrameSpy = sandbox.spy(node, 'sendFrame'); + await node.endpoints[1].clusters.colorControl[commandName](args); + + return Endpoint.parseFrame(sendFrameSpy.getCall(0).args[2]); +} + describe('Color Control', function() { afterEach(function() { sandbox.restore(); @@ -200,4 +224,75 @@ describe('Color Control', function() { assert.strictEqual(frame.data[0], expectedByte, `wrong bit for ${flag}`); } }); + // ZCL r8 Figure 5-12: Color Temperature Mireds (uint16), Transition Time (uint16), OptionsMask + // (map8), OptionsOverride (map8); 5.2.2.3.3: missing fields are interpreted for legacy devices + it('should encode moveToColorTemperature without options as before', async function() { + const frame = await sendColorCommand('moveToColorTemperature', { + colorTemperature: 0x011E, // 286, uint16 little endian, so 1e 01 + transitionTime: 15, // 0f 00 + }); + + assert.strictEqual(frame.cmdId, 0x0A); + assert.deepStrictEqual(frame.data, Buffer.from([0x1E, 0x01, 0x0F, 0x00])); + }); + + it('should encode moveToColorTemperature with options as specified by the ZCL', async function() { + const frame = await sendColorCommand('moveToColorTemperature', { + colorTemperature: 0x011E, + transitionTime: 15, + optionsMask: ['executeIfOff'], // Bit 0, so 0x01 + optionsOverride: ['executeIfOff'], + }); + + assert.strictEqual(frame.cmdId, 0x0A); + assert.deepStrictEqual(frame.data, Buffer.from([0x1E, 0x01, 0x0F, 0x00, 0x01, 0x01])); + }); + + // ZCL r8 Figure 5-9: Color X, Color Y, Transition Time (uint16), OptionsMask, OptionsOverride + it('should encode moveToColor with options as specified by the ZCL', async function() { + const frame = await sendColorCommand('moveToColor', { + colorX: 0x5000, + colorY: 0x3000, + transitionTime: 20, + optionsMask: ['executeIfOff'], + optionsOverride: [], // Mask set, override cleared: do not execute when off + }); + + assert.strictEqual(frame.cmdId, 0x07); + assert.deepStrictEqual( + frame.data, + Buffer.from([0x00, 0x50, 0x00, 0x30, 0x14, 0x00, 0x01, 0x00]), + ); + }); + + it('should receive moveToColorTemperature with options', async function() { + const node = createMockNode({ + loopback: true, + endpoints: [{ + endpointId: 1, + inputClusters: [ColorControlCluster.ID], + }], + }); + + let receivedData = null; + node.endpoints[1].bind('colorControl', new (class extends BoundCluster { + + async moveToColorTemperature(data) { + receivedData = data; + } + + })()); + + await node.endpoints[1].clusters.colorControl.moveToColorTemperature({ + colorTemperature: 370, + transitionTime: 15, + optionsMask: ['executeIfOff'], + optionsOverride: ['executeIfOff'], + }); + + assert.strictEqual(receivedData.colorTemperature, 370); + assert.strictEqual(receivedData.transitionTime, 15); + assert.deepStrictEqual(receivedData.optionsMask.getBits(), ['executeIfOff']); + assert.deepStrictEqual(receivedData.optionsOverride.getBits(), ['executeIfOff']); + }); });