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