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']); + }); });