diff --git a/packages/zwave-js/src/lib/commandclass/API.ts b/packages/zwave-js/src/lib/commandclass/API.ts index b8a58d64edfa..ad593b062bc3 100644 --- a/packages/zwave-js/src/lib/commandclass/API.ts +++ b/packages/zwave-js/src/lib/commandclass/API.ts @@ -315,6 +315,7 @@ export interface CCAPIs { Notification: import("./NotificationCC").NotificationCCAPI; Protection: import("./ProtectionCC").ProtectionCCAPI; "Scene Activation": import("./SceneActivationCC").SceneActivationCCAPI; + "Scene Controller Configuration": import("./SceneControllerConfigurationCC").SceneControllerConfigurationCCAPI; Security: import("./SecurityCC").SecurityCCAPI; "Sound Switch": import("./SoundSwitchCC").SoundSwitchCCAPI; Supervision: import("./SupervisionCC").SupervisionCCAPI; diff --git a/packages/zwave-js/src/lib/commandclass/SceneControllerConfigurationCC.test.ts b/packages/zwave-js/src/lib/commandclass/SceneControllerConfigurationCC.test.ts new file mode 100644 index 000000000000..033127ecd8b1 --- /dev/null +++ b/packages/zwave-js/src/lib/commandclass/SceneControllerConfigurationCC.test.ts @@ -0,0 +1,132 @@ +import { CommandClasses, Duration } from "@zwave-js/core"; +import type { Driver } from "../driver/Driver"; +import { ZWaveNode } from "../node/Node"; +import { createEmptyMockDriver } from "../test/mocks"; +import { getGroupCountValueId } from "./AssociationCC"; +import { + SceneControllerConfigurationCC, + SceneControllerConfigurationCCGet, + SceneControllerConfigurationCCReport, + SceneControllerConfigurationCCSet, + SceneControllerConfigurationCommand, +} from "./SceneControllerConfigurationCC"; + +const fakeGroupCount = 5; +const groupCountValueId = getGroupCountValueId(); + +function buildCCBuffer(payload: Buffer): Buffer { + return Buffer.concat([ + Buffer.from([ + CommandClasses["Scene Controller Configuration"], // CC + ]), + payload, + ]); +} + +describe("lib/commandclass/SceneControllerConfigurationCC => ", () => { + let fakeDriver: Driver; + let node2: ZWaveNode; + + beforeAll(() => { + fakeDriver = (createEmptyMockDriver() as unknown) as Driver; + + node2 = new ZWaveNode(2, fakeDriver as any); + (fakeDriver.controller.nodes as any).set(2, node2); + node2.addCC(CommandClasses["Scene Controller Configuration"], { + isSupported: true, + version: 1, + }); + node2.addCC(CommandClasses.Association, { + isSupported: true, + version: 3, + }); + node2.valueDB.setValue(groupCountValueId, fakeGroupCount); + }); + + afterAll(() => { + node2.destroy(); + }); + + it("the Get command should serialize correctly", () => { + const cc = new SceneControllerConfigurationCCGet(fakeDriver, { + nodeId: 2, + groupId: 1, + }); + const expected = buildCCBuffer( + Buffer.from([ + SceneControllerConfigurationCommand.Get, // CC Command + 0b0000_0001, + ]), + ); + expect(cc.serialize()).toEqual(expected); + }); + + it("the Get command should throw if GroupId > groupCount", () => { + expect(() => { + new SceneControllerConfigurationCCGet(fakeDriver, { + nodeId: 2, + groupId: fakeGroupCount + 1, + }); + }).toThrow(); + }); + + it("the Set command should serialize correctly", () => { + const cc = new SceneControllerConfigurationCCSet(fakeDriver, { + nodeId: 2, + groupId: 3, + sceneId: 240, + dimmingDuration: Duration.parseSet(0x05)!, + }); + const expected = buildCCBuffer( + Buffer.from([ + SceneControllerConfigurationCommand.Set, // CC Command + 3, // groupId + 240, // sceneId + 0x05, // dimming duration + ]), + ); + expect(cc.serialize()).toEqual(expected); + }); + + it("the Set command should throw if GroupId > groupCount", () => { + expect( + () => + new SceneControllerConfigurationCCSet(fakeDriver, { + nodeId: 2, + groupId: fakeGroupCount + 1, + sceneId: 240, + dimmingDuration: Duration.parseSet(0x05)!, + }), + ).toThrow(); + }); + + it("the Report command (v1) should be deserialized correctly", () => { + const ccData = buildCCBuffer( + Buffer.from([ + SceneControllerConfigurationCommand.Report, // CC Command + 3, // groupId + 240, // sceneId + 0x05, // dimming duration + ]), + ); + const cc = new SceneControllerConfigurationCCReport(fakeDriver, { + nodeId: 2, + data: ccData, + }); + + expect(cc.groupId).toBe(3); + expect(cc.sceneId).toBe(240); + expect(cc.dimmingDuration).toStrictEqual(Duration.parseReport(0x05)!); + }); + + it("deserializing an unsupported command should return an unspecified version of SceneControllerConfigurationCC", () => { + const serializedCC = buildCCBuffer( + Buffer.from([255]), // not a valid command + ); + const cc: any = new SceneControllerConfigurationCC(fakeDriver, { + nodeId: 1, + data: serializedCC, + }); + expect(cc.constructor).toBe(SceneControllerConfigurationCC); + }); +}); diff --git a/packages/zwave-js/src/lib/commandclass/SceneControllerConfigurationCC.ts b/packages/zwave-js/src/lib/commandclass/SceneControllerConfigurationCC.ts new file mode 100644 index 000000000000..bb5ddecfb2a2 --- /dev/null +++ b/packages/zwave-js/src/lib/commandclass/SceneControllerConfigurationCC.ts @@ -0,0 +1,509 @@ +import { + CommandClasses, + Duration, + Maybe, + MessageOrCCLogEntry, + validatePayload, + ValueID, + ValueMetadata, + ZWaveError, + ZWaveErrorCodes, +} from "@zwave-js/core"; +import { pick } from "@zwave-js/shared"; +import type { Driver } from "../driver/Driver"; +import { MessagePriority } from "../message/Constants"; +import { + PhysicalCCAPI, + PollValueImplementation, + POLL_VALUE, + SetValueImplementation, + SET_VALUE, + throwMissingPropertyKey, + throwUnsupportedProperty, + throwUnsupportedPropertyKey, + throwWrongValueType, +} from "./API"; +import type { AssociationCC } from "./AssociationCC"; +import { + API, + CCCommand, + CCCommandOptions, + CommandClass, + commandClass, + CommandClassDeserializationOptions, + expectedCCResponse, + gotDeserializationOptions, + implementedVersion, +} from "./CommandClass"; + +// All the supported commands +export enum SceneControllerConfigurationCommand { + Set = 0x01, + Get = 0x02, + Report = 0x03, +} + +export function getSceneIdValueID( + endpoint: number | undefined, + groupId: number, +): ValueID { + return { + commandClass: CommandClasses["Scene Controller Configuration"], + endpoint, + property: "sceneId", + propertyKey: groupId, + }; +} + +export function getDimmingDurationValueID( + endpoint: number | undefined, + groupId: number, +): ValueID { + return { + commandClass: CommandClasses["Scene Controller Configuration"], + endpoint, + property: "dimmingDuration", + propertyKey: groupId, + }; +} + +function persistSceneConfig( + this: SceneControllerConfigurationCC, + groupId: number, + sceneId: number, + dimmingDuration: Duration, +) { + const sceneIdValueId = getSceneIdValueID(this.endpointIndex, groupId); + const dimmingDurationValueId = getDimmingDurationValueID( + this.endpointIndex, + groupId, + ); + const valueDB = this.getValueDB(); + + if (!valueDB.hasMetadata(sceneIdValueId)) { + valueDB.setMetadata(sceneIdValueId, { + ...ValueMetadata.UInt8, + label: `Associated Scene ID (${groupId})`, + }); + } + if (!valueDB.hasMetadata(dimmingDurationValueId)) { + valueDB.setMetadata(dimmingDurationValueId, { + ...ValueMetadata.Duration, + label: `Dimming duration (${groupId})`, + }); + } + + valueDB.setValue(sceneIdValueId, sceneId); + valueDB.setValue(dimmingDurationValueId, dimmingDuration); + + return true; +} + +@API(CommandClasses["Scene Controller Configuration"]) +export class SceneControllerConfigurationCCAPI extends PhysicalCCAPI { + public supportsCommand( + cmd: SceneControllerConfigurationCommand, + ): Maybe { + switch (cmd) { + case SceneControllerConfigurationCommand.Get: + case SceneControllerConfigurationCommand.Set: + case SceneControllerConfigurationCommand.Report: + return true; // This is mandatory + } + return super.supportsCommand(cmd); + } + + protected [SET_VALUE]: SetValueImplementation = async ( + { property, propertyKey }, + value, + ): Promise => { + if (propertyKey == undefined) { + throwMissingPropertyKey(this.ccId, property); + } else if (typeof propertyKey !== "number") { + throwUnsupportedPropertyKey(this.ccId, property, propertyKey); + } + if (property === "sceneId") { + if (typeof value !== "number") { + throwWrongValueType( + this.ccId, + property, + "number", + typeof value, + ); + } + + if (value === 0) { + // Disable Group ID / Scene ID + await this.disable(propertyKey); + } else { + // We need to set the dimming duration along with the scene ID + const node = this.endpoint.getNodeUnsafe()!; + // If duration is missing, we set a default of instant + const dimmingDuration = + node.getValue( + getDimmingDurationValueID( + this.endpoint.index, + propertyKey, + ), + ) ?? new Duration(0, "seconds"); + await this.set(propertyKey, value, dimmingDuration); + } + } else { + // setting dimmingDuration value alone not supported, + // because I'm not sure how to handle a Duration value + throwUnsupportedProperty(this.ccId, property); + } + + // Verify the current value after a delay + this.schedulePoll({ property, propertyKey }); + }; + + protected [POLL_VALUE]: PollValueImplementation = async ({ + property, + propertyKey, + }): Promise => { + switch (property) { + case "sceneId": + case "dimmingDuration": { + if (propertyKey == undefined) { + throwMissingPropertyKey(this.ccId, property); + } else if (typeof propertyKey !== "number") { + throwUnsupportedPropertyKey( + this.ccId, + property, + propertyKey, + ); + } + return (await this.get(propertyKey))?.[property]; + } + default: + throwUnsupportedProperty(this.ccId, property); + } + }; + + public async disable(groupId: number): Promise { + this.assertSupportsCommand( + SceneControllerConfigurationCommand, + SceneControllerConfigurationCommand.Set, + ); + + return this.set(groupId, 0, new Duration(0, "seconds")); + } + + public async set( + groupId: number, + sceneId: number, + dimmingDuration: Duration, + ): Promise { + this.assertSupportsCommand( + SceneControllerConfigurationCommand, + SceneControllerConfigurationCommand.Set, + ); + + const cc = new SceneControllerConfigurationCCSet(this.driver, { + nodeId: this.endpoint.nodeId, + endpoint: this.endpoint.index, + groupId, + sceneId, + dimmingDuration, + }); + + await this.driver.sendCommand(cc, this.commandOptions); + } + + public async getLastActivated(): Promise< + | { + groupId: number; + sceneId: number; + dimmingDuration: Duration; + } + | undefined + > { + this.assertSupportsCommand( + SceneControllerConfigurationCommand, + SceneControllerConfigurationCommand.Get, + ); + return this.get(0); + } + + public async get( + groupId: number, + ): Promise< + | { + groupId: number; + sceneId: number; + dimmingDuration: Duration; + } + | undefined + > { + this.assertSupportsCommand( + SceneControllerConfigurationCommand, + SceneControllerConfigurationCommand.Get, + ); + + const cc = new SceneControllerConfigurationCCGet(this.driver, { + nodeId: this.endpoint.nodeId, + endpoint: this.endpoint.index, + groupId, + }); + const response = await this.driver.sendCommand( + cc, + this.commandOptions, + ); + + // Return value includes "groupId", because if get(0) is called + // the returned report will include the actual groupId of the + // last activated groupId / sceneId + if (response) { + return pick(response, ["groupId", "sceneId", "dimmingDuration"]); + } + } +} + +@commandClass(CommandClasses["Scene Controller Configuration"]) +@implementedVersion(1) +export class SceneControllerConfigurationCC extends CommandClass { + declare ccCommand: SceneControllerConfigurationCommand; + + public determineRequiredCCInterviews(): readonly CommandClasses[] { + // AssociationCC is required and MUST be interviewed + // before SceneControllerConfigurationCC to supply groupCount + return [ + ...super.determineRequiredCCInterviews(), + CommandClasses.Association, + ]; + } + + public async interview(complete: boolean = true): Promise { + const node = this.getNode()!; + const endpoint = this.getEndpoint()!; + const api = endpoint.commandClasses[ + "Scene Controller Configuration" + ].withOptions({ + priority: MessagePriority.NodeQuery, + }); + + this.driver.controllerLog.logNode(node.id, { + message: `${this.constructor.name}: doing a ${ + complete ? "complete" : "partial" + } interview...`, + direction: "none", + }); + + const groupCount = this.getGroupCountCached(); + if (groupCount === 0) { + this.driver.controllerLog.logNode(node.id, { + endpoint: this.endpointIndex, + message: `skipping Scene Controller Configuration interview because Association group count is unknown`, + direction: "none", + level: "warn", + }); + return; + } + + // Always query scene configuration for each association group + for (let groupId = 1; groupId <= groupCount; groupId++) { + this.driver.controllerLog.logNode(node.id, { + endpoint: this.endpointIndex, + message: `querying scene configuration for association group #${groupId}...`, + direction: "outbound", + }); + const group = await api.get(groupId); + if (group != undefined) { + const logMessage = `received scene configuration for association group #${groupId}: +scene ID: ${group.sceneId} +dimming duration: ${group.dimmingDuration.toString()}`; + this.driver.controllerLog.logNode(node.id, { + endpoint: this.endpointIndex, + message: logMessage, + direction: "inbound", + }); + } + } + + // Remember that the interview is complete + this.interviewComplete = true; + } + + /** + * Returns the number of association groups reported by the node. + * This only works AFTER the node has been interviewed by this CC + * or the AssociationCC. + */ + protected getGroupCountCached(): number { + return ( + this.getEndpoint() + ?.createCCInstanceUnsafe( + CommandClasses.Association, + ) + ?.getGroupCountCached() ?? 0 + ); + } +} + +interface SceneControllerConfigurationCCSetOptions extends CCCommandOptions { + groupId: number; + sceneId: number; + dimmingDuration: Duration; +} + +@CCCommand(SceneControllerConfigurationCommand.Set) +export class SceneControllerConfigurationCCSet extends SceneControllerConfigurationCC { + public constructor( + driver: Driver, + options: + | CommandClassDeserializationOptions + | SceneControllerConfigurationCCSetOptions, + ) { + super(driver, options); + if (gotDeserializationOptions(options)) { + // TODO: Deserialize payload + throw new ZWaveError( + `${this.constructor.name}: deserialization not implemented`, + ZWaveErrorCodes.Deserialization_NotImplemented, + ); + } else { + const groupCount = this.getGroupCountCached(); + this.groupId = options.groupId; + this.sceneId = options.sceneId; + this.dimmingDuration = options.dimmingDuration; + + // The client SHOULD NOT specify group 1 (the life-line group). + // We don't block it here, because the specs don't forbid it, + // and it may be needed for some devices. + if (this.groupId < 1 || this.groupId > groupCount) { + throw new ZWaveError( + `${this.constructor.name}: The group ID must be between 1 and the number of supported groups ${groupCount}.`, + ZWaveErrorCodes.Argument_Invalid, + ); + } + } + } + + public groupId: number; + + public sceneId: number; + + public dimmingDuration: Duration; + + public serialize(): Buffer { + this.payload = Buffer.from([ + this.groupId, + this.sceneId, + this.dimmingDuration.serializeSet(), + ]); + return super.serialize(); + } + + public toLogEntry(): MessageOrCCLogEntry { + return { + ...super.toLogEntry(), + message: { + "group id": this.groupId, + "scene id": this.sceneId, + "dimming duration": this.dimmingDuration.toString(), + }, + }; + } +} + +@CCCommand(SceneControllerConfigurationCommand.Report) +export class SceneControllerConfigurationCCReport extends SceneControllerConfigurationCC { + public constructor( + driver: Driver, + options: CommandClassDeserializationOptions, + ) { + super(driver, options); + validatePayload(this.payload.length >= 3); + this.groupId = this.payload[0]; + this.sceneId = this.payload[1]; + this.dimmingDuration = + Duration.parseReport(this.payload[2]) ?? new Duration(0, "unknown"); + + this.persistValues(); + } + + public readonly groupId: number; + public readonly sceneId: number; + public readonly dimmingDuration: Duration; + + public persistValues(): boolean { + persistSceneConfig.call( + this, + this.groupId, + this.sceneId, + this.dimmingDuration, + ); + return true; + } + + public toLogEntry(): MessageOrCCLogEntry { + return { + ...super.toLogEntry(), + message: { + "group id": this.groupId, + "scene id": this.sceneId, + "dimming duration": this.dimmingDuration.toString(), + }, + }; + } +} + +function testResponseForSceneControllerConfigurationGet( + sent: SceneControllerConfigurationCCGet, + received: SceneControllerConfigurationCCReport, +) { + // We expect a Scene Controller Configuration Report that matches + // the requested groupId, unless groupId 0 was requested + return sent.groupId === 0 || received.groupId === sent.groupId; +} + +interface SceneControllerConfigurationCCGetOptions extends CCCommandOptions { + groupId: number; +} + +@CCCommand(SceneControllerConfigurationCommand.Get) +@expectedCCResponse( + SceneControllerConfigurationCCReport, + testResponseForSceneControllerConfigurationGet, +) +export class SceneControllerConfigurationCCGet extends SceneControllerConfigurationCC { + public constructor( + driver: Driver, + options: + | CommandClassDeserializationOptions + | SceneControllerConfigurationCCGetOptions, + ) { + super(driver, options); + if (gotDeserializationOptions(options)) { + // TODO: Deserialize payload + throw new ZWaveError( + `${this.constructor.name}: deserialization not implemented`, + ZWaveErrorCodes.Deserialization_NotImplemented, + ); + } else { + const groupCount = this.getGroupCountCached(); + if (options.groupId < 0 || options.groupId > groupCount) { + throw new ZWaveError( + `${this.constructor.name}: The group ID must be between 0 and the number of supported groups ${groupCount}.`, + ZWaveErrorCodes.Argument_Invalid, + ); + } + this.groupId = options.groupId; + } + } + + public groupId: number; + + public serialize(): Buffer { + this.payload = Buffer.from([this.groupId]); + return super.serialize(); + } + + public toLogEntry(): MessageOrCCLogEntry { + return { + ...super.toLogEntry(), + message: { "group id": this.groupId }, + }; + } +}