diff --git a/index.d.ts b/index.d.ts index 986d77a..68f89be 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1835,6 +1835,14 @@ declare module "zigbee-clusters" { currentY: { id: 0x04, type: ZCLDataType }, colorTemperatureMireds: { id: 0x07, type: ZCLDataType }, colorMode: { id: 0x08, type: ZCLDataType<"currentHueAndCurrentSaturation" | "currentXAndCurrentY" | "colorTemperatureMireds"> }, + options: { id: 0x0f, type: ZCLDataType> }, + enhancedCurrentHue: { id: 0x4000, type: ZCLDataType }, + enhancedColorMode: { id: 0x4001, type: ZCLDataType<"currentHueAndCurrentSaturation" | "currentXAndCurrentY" | "colorTemperatureMireds" | "enhancedCurrentHueAndCurrentSaturation"> }, + colorLoopActive: { id: 0x4002, type: ZCLDataType }, + colorLoopDirection: { id: 0x4003, type: ZCLDataType }, + colorLoopTime: { id: 0x4004, type: ZCLDataType }, + colorLoopStartEnhancedHue: { id: 0x4005, type: ZCLDataType }, + colorLoopStoredEnhancedHue: { id: 0x4006, type: ZCLDataType }, colorCapabilities: { id: 0x400a, type: ZCLDataType> }, colorTempPhysicalMinMireds: { id: 0x400b, type: ZCLDataType }, colorTempPhysicalMaxMireds: { id: 0x400c, type: ZCLDataType }, @@ -1869,6 +1877,14 @@ declare module "zigbee-clusters" { transitionTime: ZCLDataType, }, }, + colorLoopSet: { id: 0x44, direction: "DIRECTION_SERVER_TO_CLIENT", args: { + updateFlags: ZCLDataType>, + action: ZCLDataType<"deactivate" | "activateFromColorLoopStartEnhancedHue" | "activateFromEnhancedCurrentHue">, + direction: ZCLDataType<"decrementHue" | "incrementHue">, + time: ZCLDataType, + startHue: ZCLDataType, + }, + }, }; class ColorControlCluster extends Cluster { moveToHue( @@ -1934,6 +1950,21 @@ declare module "zigbee-clusters" { disableDefaultResponse?: boolean, }, ): Promise; + colorLoopSet( + args?: { + manufacturerId?: number, + updateFlags?: Bitmap<"updateAction" | "updateDirection" | "updateTime" | "updateStartHue">, + action?: "deactivate" | "activateFromColorLoopStartEnhancedHue" | "activateFromEnhancedCurrentHue", + direction?: "decrementHue" | "incrementHue", + time?: number, + startHue?: number, + }, + opts?: { + waitForResponse?: boolean, + timeout?: number, + disableDefaultResponse?: boolean, + }, + ): Promise; } type BallastConfigurationClusterAttributes = { physicalMinLevel: { id: 0x00, type: ZCLDataType }, diff --git a/lib/clusters/colorControl.js b/lib/clusters/colorControl.js index 33041d5..ef69918 100644 --- a/lib/clusters/colorControl.js +++ b/lib/clusters/colorControl.js @@ -9,19 +9,19 @@ const ATTRIBUTES = { currentHue: { id: 0, type: ZCLDataTypes.uint8 }, // Mandatory if colorCapabilities = hueAndSaturation - // Reportable, read-only + // Reportable, read-only, scene extension currentSaturation: { id: 1, type: ZCLDataTypes.uint8 }, - // Mandatory - // Reportable, read-only + // Mandatory if colorCapabilities = xy + // Reportable, read-only, scene extension currentX: { id: 3, type: ZCLDataTypes.uint16 }, - // Mandatory - // Reportable, read-only + // Mandatory if colorCapabilities = xy + // Reportable, read-only, scene extension currentY: { id: 4, type: ZCLDataTypes.uint16 }, // Mandatory if colorCapabilities = colorTemperature - // Reportable, read-only + // Reportable, read-only, scene extension colorTemperatureMireds: { id: 7, type: ZCLDataTypes.uint16 }, // Mandatory, read-only @@ -34,9 +34,69 @@ const ATTRIBUTES = { }), }, + // Mandatory + // Read/write + // 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') }, + + // Mandatory if colorCapabilities = enhancedHue + // Read-only, scene extension + // Represents non-equidistant steps along the CIE 1931 color triangle, providing 16-bits + // precision. The upper 8 bits are used as an index in the implementation specific XY lookup + // table, the lower 8 bits interpolate between these steps in a linear way. For compatibility + // with standard ZCL, currentHue contains a hue value in the range 0 to 254, calculated from + // this attribute. + enhancedCurrentHue: { id: 16384, type: ZCLDataTypes.uint16 }, + + // Mandatory, read-only + // Specifies which attributes are currently determining the color of the device. Note that for + // compatibility with standard ZCL, colorMode reports currentHueAndCurrentSaturation while the + // device is actually driven by enhancedCurrentHue, so read this attribute instead of colorMode + // to tell those two apart. + enhancedColorMode: { + id: 16385, + type: ZCLDataTypes.enum8({ + currentHueAndCurrentSaturation: 0, + currentXAndCurrentY: 1, + colorTemperatureMireds: 2, + enhancedCurrentHueAndCurrentSaturation: 3, + }), + }, + + // Mandatory if colorCapabilities = colorLoop + // Read-only, scene extension + // Current active status of the color loop: 0x00 = inactive, 0x01 = active, + // all other values (0x02 - 0xff) are reserved + colorLoopActive: { id: 16386, type: ZCLDataTypes.uint8 }, + + // Mandatory if colorCapabilities = colorLoop + // Read-only, scene extension + // Current direction of the color loop: 0x00 = enhancedCurrentHue is decremented, + // 0x01 = enhancedCurrentHue is incremented, all other values (0x02 - 0xff) are reserved + colorLoopDirection: { id: 16387, type: ZCLDataTypes.uint8 }, + + // Mandatory if colorCapabilities = colorLoop + // Read-only, scene extension + // Number of seconds it takes to perform a full color loop, i.e. to cycle all values of the + // enhancedCurrentHue attribute (between 0x0000 and 0xffff). Default 0x0019 (25 seconds). + colorLoopTime: { id: 16388, type: ZCLDataTypes.uint16 }, + + // Mandatory if colorCapabilities = colorLoop + // Read-only + // Value of enhancedCurrentHue from which the color loop starts. Default 0x2300. + colorLoopStartEnhancedHue: { id: 16389, type: ZCLDataTypes.uint16 }, + + // Mandatory if colorCapabilities = colorLoop + // Read-only + // Value of enhancedCurrentHue before the color loop was started. Once the color loop is + // complete, enhancedCurrentHue is restored to this value. + colorLoopStoredEnhancedHue: { id: 16390, type: ZCLDataTypes.uint16 }, + // Mandatory, read-only // Bit 0: hueAndSaturation mandatory commands: moveToHue, moveHue, stepHue, moveToSaturation, - // moveSaturation, stepSaturation moveToHueAndSaturation, stopMoveStep, related attributes: + // moveSaturation, stepSaturation, moveToHueAndSaturation, stopMoveStep, related attributes: // currentHue, currentSaturation // Bit 1: enhancedHue (hueAndSaturation must also be supported) mandatory commands: @@ -63,8 +123,11 @@ const ATTRIBUTES = { // Read-only colorTempPhysicalMaxMireds: { id: 16396, type: ZCLDataTypes.uint16 }, + // Mandatory if colorTemperatureMireds is supported + // Read/write, scene extension // Startup color temperature, reflected in colorTemperatureMireds on power up - // (0xffff = restore previous value) + // (0xffff = restore previous value). On power up colorMode and enhancedColorMode are set to + // colorTemperatureMireds. startUpColorTemperatureMireds: { id: 16400, type: ZCLDataTypes.uint16 }, }; @@ -73,19 +136,24 @@ const COMMANDS = { id: 0, args: { hue: ZCLDataTypes.uint8, - direction: ZCLDataTypes.enum8({ // TODO: ? + // Direction of travel round the hue circle. shortestDistance is normal usage, + // longestDistance can be used for rainbow effects. For those two values only, the up + // direction is taken when both paths are equally long. up and down are explicit. + direction: ZCLDataTypes.enum8({ shortestDistance: 0, longestDistance: 1, up: 2, down: 3, }), + // In 1/10ths of a second transitionTime: ZCLDataTypes.uint16, }, }, moveToSaturation: { id: 3, - args: { // TODO + args: { saturation: ZCLDataTypes.uint8, + // In 1/10ths of a second transitionTime: ZCLDataTypes.uint16, }, }, @@ -94,6 +162,7 @@ const COMMANDS = { args: { hue: ZCLDataTypes.uint8, saturation: ZCLDataTypes.uint8, + // In 1/10ths of a second transitionTime: ZCLDataTypes.uint16, }, }, @@ -102,6 +171,7 @@ const COMMANDS = { args: { colorX: ZCLDataTypes.uint16, colorY: ZCLDataTypes.uint16, + // In 1/10ths of a second transitionTime: ZCLDataTypes.uint16, }, }, @@ -109,9 +179,46 @@ const COMMANDS = { id: 10, args: { colorTemperature: ZCLDataTypes.uint16, + // In 1/10ths of a second transitionTime: ZCLDataTypes.uint16, }, }, + + // Mandatory if colorCapabilities = colorLoop + // Activates a color loop such that the color lamp cycles through its range of hues + colorLoopSet: { + id: 68, // 0x44 + args: { + // Specifies which color loop attributes to update before the color loop is started. The + // action, direction, time and startHue fields below are ignored by the device unless their + // flag is set here. + updateFlags: ZCLDataTypes.map8( + 'updateAction', 'updateDirection', 'updateTime', 'updateStartHue', + ), + + // Action to take for the color loop, only applied when updateFlags.updateAction is set + action: ZCLDataTypes.enum8({ + deactivate: 0, + activateFromColorLoopStartEnhancedHue: 1, + activateFromEnhancedCurrentHue: 2, + }), + + // Direction for the color loop, written to colorLoopDirection when + // updateFlags.updateDirection is set + direction: ZCLDataTypes.enum8({ + decrementHue: 0, + incrementHue: 1, + }), + + // Number of seconds over which to perform a full color loop, written to colorLoopTime when + // updateFlags.updateTime is set + time: ZCLDataTypes.uint16, + + // Starting hue to use for the color loop, written to colorLoopStartEnhancedHue when + // updateFlags.updateStartHue is set + startHue: ZCLDataTypes.uint16, + }, + }, }; class ColorControlCluster extends Cluster { diff --git a/test/colorControl.js b/test/colorControl.js index 0f170ec..1c192fb 100644 --- a/test/colorControl.js +++ b/test/colorControl.js @@ -2,11 +2,45 @@ 'use strict'; const assert = require('assert'); +const sinon = require('sinon'); + const BoundCluster = require('../lib/BoundCluster'); const ColorControlCluster = require('../lib/clusters/colorControl'); +const Endpoint = require('../lib/Endpoint'); const { createMockNode } = require('./util'); +const sandbox = sinon.createSandbox(); + +// Sends colorLoopSet 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) { + const node = createMockNode({ + loopback: true, + endpoints: [{ + endpointId: 1, + inputClusters: [ColorControlCluster.ID], + }], + }); + + node.endpoints[1].bind('colorControl', new (class extends BoundCluster { + + async colorLoopSet() { + // Accept the command so no error response is generated + } + + })()); + + const sendFrameSpy = sandbox.spy(node, 'sendFrame'); + await node.endpoints[1].clusters.colorControl.colorLoopSet(args); + + return Endpoint.parseFrame(sendFrameSpy.getCall(0).args[2]); +} + describe('Color Control', function() { + afterEach(function() { + sandbox.restore(); + }); + it('should receive moveToColor', async function() { const node = createMockNode({ loopback: true, @@ -91,4 +125,79 @@ describe('Color Control', function() { assert.strictEqual(receivedData.saturation, 200); assert.strictEqual(receivedData.transitionTime, 10); }); + + it('should receive colorLoopSet', 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 colorLoopSet(data) { + receivedData = data; + } + + })()); + + await node.endpoints[1].clusters.colorControl.colorLoopSet({ + updateFlags: ['updateAction', 'updateDirection', 'updateTime', 'updateStartHue'], + action: 'activateFromColorLoopStartEnhancedHue', + direction: 'incrementHue', + time: 30, + startHue: 0x2300, + }); + + assert.deepStrictEqual(receivedData.updateFlags.getBits(), [ + 'updateAction', 'updateDirection', 'updateTime', 'updateStartHue', + ]); + assert.strictEqual(receivedData.action, 'activateFromColorLoopStartEnhancedHue'); + assert.strictEqual(receivedData.direction, 'incrementHue'); + assert.strictEqual(receivedData.time, 30); + assert.strictEqual(receivedData.startHue, 0x2300); + }); + + // The loopback test above encodes and decodes with the same definition, so it passes even when + // that definition is wrong on the wire. These bytes come from the spec, not from our types: + // 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({ + updateFlags: ['updateAction', 'updateStartHue'], // Bits 0 and 3, so 0x09 + action: 'activateFromEnhancedCurrentHue', // 0x02 + direction: 'decrementHue', // 0x00 + time: 0x1234, // uint16 little endian, so 34 12 + startHue: 0x2300, // uint16 little endian, so 00 23 + }); + + assert.strictEqual(frame.cmdId, 0x44); + assert.deepStrictEqual(frame.data, Buffer.from([0x09, 0x02, 0x00, 0x34, 0x12, 0x00, 0x23])); + }); + + // Setting every flag at once cannot tell the bit positions apart, so pin each one separately + it('should encode each colorLoopSet update flag on its own bit', async function() { + const flagBits = [ + ['updateAction', 0x01], + ['updateDirection', 0x02], + ['updateTime', 0x04], + ['updateStartHue', 0x08], + ]; + + for (const [flag, expectedByte] of flagBits) { + // eslint-disable-next-line no-await-in-loop + const frame = await sendColorLoopSet({ + updateFlags: [flag], + action: 'deactivate', + direction: 'decrementHue', + time: 0, + startHue: 0, + }); + + assert.strictEqual(frame.data[0], expectedByte, `wrong bit for ${flag}`); + } + }); });