From a09e963e4a67266d1151fe96fa3bbf5e8fa9df1c 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 | 86 +++++++++++++++++++++++++++++++++--- 3 files changed, 128 insertions(+), 12 deletions(-) diff --git a/index.d.ts b/index.d.ts index c7b0a2b..f5a4f61 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1868,32 +1868,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: { @@ -1912,6 +1922,8 @@ declare module "zigbee-clusters" { hue?: number, direction?: "shortestDistance" | "longestDistance" | "up" | "down", transitionTime?: number, + optionsMask?: Bitmap<"executeIfOff">, + optionsOverride?: Bitmap<"executeIfOff">, }, opts?: { waitForResponse?: boolean, @@ -1924,6 +1936,8 @@ declare module "zigbee-clusters" { manufacturerId?: number, saturation?: number, transitionTime?: number, + optionsMask?: Bitmap<"executeIfOff">, + optionsOverride?: Bitmap<"executeIfOff">, }, opts?: { waitForResponse?: boolean, @@ -1937,6 +1951,8 @@ declare module "zigbee-clusters" { hue?: number, saturation?: number, transitionTime?: number, + optionsMask?: Bitmap<"executeIfOff">, + optionsOverride?: Bitmap<"executeIfOff">, }, opts?: { waitForResponse?: boolean, @@ -1950,6 +1966,8 @@ declare module "zigbee-clusters" { colorX?: number, colorY?: number, transitionTime?: number, + optionsMask?: Bitmap<"executeIfOff">, + optionsOverride?: Bitmap<"executeIfOff">, }, opts?: { waitForResponse?: boolean, @@ -1962,6 +1980,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 90a3a10..d10afa8 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 @@ -143,6 +155,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, @@ -156,40 +169,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 f987278..fd47507 100644 --- a/test/colorControl.js +++ b/test/colorControl.js @@ -7,9 +7,9 @@ const BoundCluster = require('../lib/BoundCluster'); const ColorControlCluster = require('../lib/clusters/colorControl'); const { createVirtualDevice } = require('./util/virtualDevice'); -// Sends colorLoopSet on a fresh node and returns the parsed outgoing frame, so tests can assert +// Sends a color command on a fresh node and returns the parsed outgoing frame, so tests can assert // the payload bytes against the ZCL spec instead of round-tripping through our own definition -async function sendColorLoopSet(args) { +async function sendColorCommand(commandName, args) { const { zclNode, virtualDevice, frames } = createVirtualDevice({ endpoints: [ { @@ -22,13 +22,13 @@ async function sendColorLoopSet(args) { virtualDevice.endpoints[1].bind( 'colorControl', new (class extends BoundCluster { - async colorLoopSet() { + async [commandName]() { // Accept the command so no error response is generated } })(), ); - await zclNode.endpoints[1].clusters.colorControl.colorLoopSet(args); + await zclNode.endpoints[1].clusters.colorControl[commandName](args); assert.strictEqual(frames[0].from, 'zclNode'); return frames[0].frame; @@ -171,7 +171,7 @@ describe('Color Control', function () { // ZCL r8 Figure 5-17 (payload layout), Figure 5-18 (updateFlags bits), Table 5.25 (action // values), Table 5.26 (direction values), Table 5.13 (command id). it('should encode colorLoopSet as specified by the ZCL', async function () { - const frame = await sendColorLoopSet({ + const frame = await sendColorCommand('colorLoopSet', { updateFlags: ['updateAction', 'updateStartHue'], // Bits 0 and 3, so 0x09 action: 'activateFromEnhancedCurrentHue', // 0x02 direction: 'decrementHue', // 0x00 @@ -193,7 +193,7 @@ describe('Color Control', function () { ]; for (const [flag, expectedByte] of flagBits) { - const frame = await sendColorLoopSet({ + const frame = await sendColorCommand('colorLoopSet', { updateFlags: [flag], action: 'deactivate', direction: 'decrementHue', @@ -204,4 +204,78 @@ 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 { zclNode, virtualDevice } = createVirtualDevice({ + endpoints: [ + { + endpointId: 1, + inputClusters: [ColorControlCluster.ID], + }, + ], + }); + + let receivedData = null; + virtualDevice.endpoints[1].bind( + 'colorControl', + new (class extends BoundCluster { + async moveToColorTemperature(data) { + receivedData = data; + } + })(), + ); + + await zclNode.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']); + }); });