From 325e819b365cbac2a54335f6ddefe8811dff10cd Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Mon, 24 Nov 2025 20:39:09 +0100 Subject: [PATCH 01/14] feat: add scene handling --- src/controller/controller.ts | 8 +- src/controller/helpers/zclFrameConverter.ts | 112 +++- src/controller/model/endpoint.ts | 681 +++++++++++++++++++- src/controller/model/group.ts | 163 ++++- src/zspec/zcl/buffaloZcl.ts | 29 +- 5 files changed, 974 insertions(+), 19 deletions(-) diff --git a/src/controller/controller.ts b/src/controller/controller.ts index 99af27d7ce..52d9709039 100644 --- a/src/controller/controller.ts +++ b/src/controller/controller.ts @@ -1099,11 +1099,9 @@ export class Controller extends events.EventEmitter { endpoint.saveClusterAttributeKeyValue(frame.cluster.ID, data); } - } else { - if (frame.header.isSpecific) { - type = `command${command.name.charAt(0).toUpperCase()}${command.name.slice(1)}`; - data = frame.payload; - } + } else if (frame.header.isSpecific) { + type = `command${command.name.charAt(0).toUpperCase()}${command.name.slice(1)}`; + data = frame.payload; } } else { type = "raw"; diff --git a/src/controller/helpers/zclFrameConverter.ts b/src/controller/helpers/zclFrameConverter.ts index fb7b50b14d..30e06db28e 100644 --- a/src/controller/helpers/zclFrameConverter.ts +++ b/src/controller/helpers/zclFrameConverter.ts @@ -1,7 +1,8 @@ import {logger} from "../../utils/logger"; import * as Zcl from "../../zspec/zcl"; import type {TFoundation} from "../../zspec/zcl/definition/clusters-types"; -import type {Cluster, CustomClusters} from "../../zspec/zcl/definition/tstype"; +import type {Cluster, CustomClusters, ExtensionFieldSet} from "../../zspec/zcl/definition/tstype"; +import type {Scene} from "../model/endpoint"; import type {ClusterOrRawWriteAttributes, TCustomCluster} from "../tstype"; const NS = "zh:controller:zcl"; @@ -61,4 +62,111 @@ function attributeList(frame: Zcl.Frame, deviceManufacturerID: number | undefine return payload; } -export {attributeKeyValue, attributeList}; +// to/from: first in list is always expected, after can be omitted but has to remain sequentially valid, hence stop on first undefined +// we expect that if a cluster is present, at least one value should be too (though use fallback just in case) +// XXX: use non-value instead of `0` for fallback? (edge-case) +function sceneFromExtensionFieldSets(extFieldSets: ExtensionFieldSet[]): Scene["state"] { + const sceneState: Scene["state"] = {}; + + for (const set of extFieldSets) { + const clusterId = set.clstId; + + switch (clusterId) { + case Zcl.Clusters.genOnOff.ID: { + sceneState.genOnOff = {onOff: (set.extField[0] as number | undefined) ?? 0}; + + break; + } + + case Zcl.Clusters.genLevelCtrl.ID: { + sceneState.genLevelCtrl = {currentLevel: (set.extField[0] as number | undefined) ?? 0}; + + break; + } + + case Zcl.Clusters.closuresWindowCovering.ID: { + const state: Scene["state"]["closuresWindowCovering"] = {currentPositionLiftPercentage: (set.extField[0] as number | undefined) ?? 0}; + const currentPositionTiltPercentage = set.extField[1] as number | undefined; + + if (currentPositionTiltPercentage !== undefined) { + state.currentPositionTiltPercentage = currentPositionTiltPercentage; + } + + sceneState.closuresWindowCovering = state; + + break; + } + + case Zcl.Clusters.barrierControl.ID: { + sceneState.barrierControl = {barrierPosition: (set.extField[0] as number | undefined) ?? 0}; + + break; + } + + case Zcl.Clusters.hvacThermostat.ID: { + const state: Scene["state"]["hvacThermostat"] = {occupiedCoolingSetpoint: (set.extField[0] as number | undefined) ?? 0}; + const occupiedHeatingSetpoint = set.extField[1] as number | undefined; + + if (occupiedHeatingSetpoint !== undefined) { + state.occupiedHeatingSetpoint = occupiedHeatingSetpoint; + const systemMode = set.extField[2] as number | undefined; + + if (systemMode !== undefined) { + state.systemMode = systemMode; + } + } + + sceneState.hvacThermostat = state; + + break; + } + + case Zcl.Clusters.lightingColorCtrl.ID: { + const state: Scene["state"]["lightingColorCtrl"] = {currentX: (set.extField[0] as number | undefined) ?? 0}; + const currentY = set.extField[1] as number | undefined; + + if (currentY !== undefined) { + state.currentY = currentY; + const enhancedCurrentHue = set.extField[2] as number | undefined; + + if (enhancedCurrentHue !== undefined) { + state.enhancedCurrentHue = enhancedCurrentHue; + const currentSaturation = set.extField[3] as number | undefined; + + if (currentSaturation !== undefined) { + state.currentSaturation = currentSaturation; + const colorLoopActive = set.extField[4] as number | undefined; + + if (colorLoopActive !== undefined) { + state.colorLoopActive = colorLoopActive; + const colorLoopDirection = set.extField[5] as number | undefined; + + if (colorLoopDirection !== undefined) { + state.colorLoopDirection = colorLoopDirection; + const colorLoopTime = set.extField[6] as number | undefined; + + if (colorLoopTime !== undefined) { + state.colorLoopTime = colorLoopTime; + const colorTemperature = set.extField[7] as number | undefined; + + if (colorTemperature !== undefined) { + state.colorTemperature = colorTemperature; + } + } + } + } + } + } + } + + sceneState.lightingColorCtrl = state; + + break; + } + } + } + + return sceneState; +} + +export {attributeKeyValue, attributeList, sceneFromExtensionFieldSets}; diff --git a/src/controller/model/endpoint.ts b/src/controller/model/endpoint.ts index 9217e55923..ce3f7f5ec3 100644 --- a/src/controller/model/endpoint.ts +++ b/src/controller/model/endpoint.ts @@ -5,7 +5,7 @@ import * as ZSpec from "../../zspec"; import {BroadcastAddress} from "../../zspec/enums"; import type {Eui64} from "../../zspec/tstypes"; import * as Zcl from "../../zspec/zcl"; -import type {TFoundation} from "../../zspec/zcl/definition/clusters-types"; +import type {TClusterCommandResponsePayload, TFoundation} from "../../zspec/zcl/definition/clusters-types"; import type * as ZclTypes from "../../zspec/zcl/definition/tstype"; import * as Zdo from "../../zspec/zdo"; import Request from "../helpers/request"; @@ -73,6 +73,42 @@ interface Clusters { }; } +export interface Scene { + name: string; + /** + * All entries are possibly undefined to conform with spec. + * - cluster may not be present (if present, assumed to have at least first entry) + * - attribute list (when sent/received) may be shorter than Spec's current full set. + */ + state: { + genOnOff?: {onOff: number}; + genLevelCtrl?: {currentLevel: number}; + closuresWindowCovering?: { + currentPositionLiftPercentage: number; + currentPositionTiltPercentage?: number; + }; + barrierControl?: {barrierPosition: number}; + hvacThermostat?: { + occupiedCoolingSetpoint: number; + occupiedHeatingSetpoint?: number; + systemMode?: number; + }; + lightingColorCtrl?: { + currentX: number; + currentY?: number; + enhancedCurrentHue?: number; + currentSaturation?: number; + colorLoopActive?: number; + colorLoopDirection?: number; + colorLoopTime?: number; + colorTemperature?: number; + }; + }; + /** if true, `transitionTime` is in tenths of a second rather than in seconds */ + enhanced: boolean; + transitionTime: number; +} + export interface BindInternal { cluster: number; type: "endpoint" | "group"; @@ -111,6 +147,7 @@ export class Endpoint extends ZigbeeEntity { // biome-ignore lint/style/useNamingConvention: cross-repo impact public readonly ID: number; public readonly clusters: Clusters; + public readonly scenes: Map; public deviceIeeeAddress: string; public deviceNetworkAddress: number; private _binds: BindInternal[]; @@ -167,6 +204,7 @@ export class Endpoint extends ZigbeeEntity { deviceNetworkAddress: number, deviceIeeeAddress: string, clusters: Clusters, + scenes: Map, binds: BindInternal[], configuredReportings: ConfiguredReportingInternal[], meta: KeyValue, @@ -180,6 +218,7 @@ export class Endpoint extends ZigbeeEntity { this.deviceNetworkAddress = deviceNetworkAddress; this.deviceIeeeAddress = deviceIeeeAddress; this.clusters = clusters; + this.scenes = scenes; this._binds = binds; this._configuredReportings = configuredReportings; this.meta = meta; @@ -243,6 +282,7 @@ export class Endpoint extends ZigbeeEntity { */ public static fromDatabaseRecord(record: KeyValue, deviceNetworkAddress: number, deviceIeeeAddress: string): Endpoint { + // @deprecated Z2M 3.0 // Migrate attrs to attributes for (const entryKey in record.clusters) { const entry = record.clusters[entryKey]; @@ -269,6 +309,60 @@ export class Endpoint extends ZigbeeEntity { } /* v8 ignore stop */ + // @deprecated Z2M 3.0 + // Migrate scenes from meta (ZHC) to plain + if (record.meta.scenes) { + record.scenes = {}; + + for (const key in record.meta.scenes) { + const oldScene = record.meta.scenes[key]; + record.scenes[key] = { + name: oldScene.name, + // ZHC only saved "state", "brightness", "color", "color_temp", "color_mode" + state: {}, + // XXX: no way of knowing from ZHC dataset + enhanced: false, + transitionTime: 0xffff, + } satisfies Scene; + const newSceneState = record.scenes[key].state as Scene["state"]; + + if (oldScene.state.state !== undefined) { + newSceneState.genOnOff = {onOff: oldScene.state.state}; + } + + if (oldScene.state.brightness !== undefined) { + newSceneState.genLevelCtrl = {currentLevel: oldScene.state.brightness}; + } + + if (oldScene.state.color_mode === "xy") { + newSceneState.lightingColorCtrl = { + currentX: oldScene.state.color.x ?? 0, + currentY: oldScene.state.color.y ?? 0, + }; + } else if (oldScene.state.color_mode === "hs") { + newSceneState.lightingColorCtrl = { + currentX: 0, + currentY: 0, + enhancedCurrentHue: oldScene.state.color.hue ?? 0, + currentSaturation: oldScene.state.color.saturation ?? 0, + }; + } else if (oldScene.state.color_mode === "color_temp") { + newSceneState.lightingColorCtrl = { + currentX: 0, + currentY: 0, + enhancedCurrentHue: 0, + currentSaturation: 0, + colorLoopActive: 0, + colorLoopDirection: 0, + colorLoopTime: 0, + colorTemperature: oldScene.state.color_temp ?? 0, + }; + } + } + + delete record.meta.scenes; + } + return new Endpoint( record.epId, record.profId, @@ -278,6 +372,7 @@ export class Endpoint extends ZigbeeEntity { deviceNetworkAddress, deviceIeeeAddress, record.clusters, + record.scenes ? new Map(Object.entries(record.scenes)) : new Map(), record.binds || [], record.configuredReportings || [], record.meta || {}, @@ -293,6 +388,7 @@ export class Endpoint extends ZigbeeEntity { outClusterList: this.outputClusters, clusters: this.clusters, binds: this._binds, + scenes: this.scenes ? Object.fromEntries(this.scenes) : {}, configuredReportings: this._configuredReportings, meta: this.meta, }; @@ -307,7 +403,20 @@ export class Endpoint extends ZigbeeEntity { deviceNetworkAddress: number, deviceIeeeAddress: string, ): Endpoint { - return new Endpoint(id, profileID, deviceID, inputClusters, outputClusters, deviceNetworkAddress, deviceIeeeAddress, {}, [], [], {}); + return new Endpoint( + id, + profileID, + deviceID, + inputClusters, + outputClusters, + deviceNetworkAddress, + deviceIeeeAddress, + {}, + new Map(), + [], + [], + {}, + ); } public saveClusterAttributeKeyValue(clusterKey: number | string, list: KeyValue): void { @@ -1113,11 +1222,573 @@ export class Endpoint extends ZigbeeEntity { } public removeFromAllGroupsDatabase(): void { - for (const group of Group.allIterator()) { - if (group.hasMember(this)) { - group.removeMember(this); + for (const group of Group.allIterator((g) => g.hasMember(this))) { + group.removeMember(this); + } + } + + /** + * Overwrite scenes cache with scene data received from Zigbee. + */ + public syncSceneFromZigbee(payload: TClusterCommandResponsePayload<"genScenes", "viewRsp" | "enhancedViewRsp">, enhanced: boolean): Scene { + if (payload.scenename === undefined || payload.extensionfieldsets === undefined || payload.transtime === undefined) { + throw new Error("Missing data"); + } + + const sceneKey = `${payload.sceneid}_${payload.groupid}`; + const existing = this.scenes.get(sceneKey); + const state = ZclFrameConverter.sceneFromExtensionFieldSets(payload.extensionfieldsets); + + if (existing) { + if (!existing.name && payload.scenename && payload.scenename !== "\u0000") { + // keep existing name if present and incoming not present + existing.name = payload.scenename; + } + + existing.state = state; + existing.enhanced = enhanced; + existing.transitionTime = payload.transtime; + + logger.debug(() => `Synchonized scene from Zigbee scene=${payload.sceneid}, group=${payload.groupid}`, NS); + + return existing; + } + + const scene: Scene = { + name: payload.scenename, + state, + enhanced, + transitionTime: payload.transtime, + }; + + this.scenes.set(sceneKey, scene); + + logger.debug(() => `Added scene from Zigbee scene=${payload.sceneid}, group=${payload.groupid}`, NS); + + return scene; + } + + /** + * Overwrite scenes cache with clusters state cache. + */ + public syncSceneFromState(groupId: number, sceneId: number, sceneName: string, enhanced: boolean, transTime: number): void { + const sceneKey = `${sceneId}_${groupId}`; + let existing = this.scenes.get(sceneKey); + + if (!existing) { + existing = { + name: sceneName, + state: {}, + enhanced, + transitionTime: transTime, + }; + + this.scenes.set(sceneKey, existing); + } else { + if (!existing.name && sceneName) { + // keep existing name if present and incoming not present + existing.name = sceneName; + } + + existing.enhanced = enhanced; + existing.transitionTime = transTime; + } + + if (this.clusters.genOnOff?.attributes !== undefined) { + existing.state.genOnOff = {onOff: (this.clusters.genOnOff.attributes.onOff as number | undefined) ?? 0}; + } + + if (this.clusters.genLevelCtrl?.attributes !== undefined) { + existing.state.genLevelCtrl = {currentLevel: (this.clusters.genLevelCtrl.attributes.currentLevel as number | undefined) ?? 0}; + } + + if (this.clusters.closuresWindowCovering?.attributes !== undefined) { + existing.state.closuresWindowCovering = { + currentPositionLiftPercentage: + (this.clusters.closuresWindowCovering.attributes.currentPositionLiftPercentage as number | undefined) ?? 0, + }; + + const currentPositionTiltPercentage = this.clusters.closuresWindowCovering.attributes.currentPositionTiltPercentage as number | undefined; + + if (currentPositionTiltPercentage !== undefined) { + existing.state.closuresWindowCovering.currentPositionTiltPercentage = currentPositionTiltPercentage as number; } } + + if (this.clusters.barrierControl?.attributes !== undefined) { + existing.state.barrierControl = { + barrierPosition: (this.clusters.barrierControl?.attributes.barrierPosition as number | undefined) ?? 0, + }; + } + + if (this.clusters.hvacThermostat?.attributes !== undefined) { + existing.state.hvacThermostat = { + occupiedCoolingSetpoint: (this.clusters.hvacThermostat.attributes.occupiedCoolingSetpoint as number | undefined) ?? 0, + }; + const occupiedHeatingSetpoint = this.clusters.hvacThermostat.attributes.occupiedHeatingSetpoint as number | undefined; + + if (occupiedHeatingSetpoint !== undefined) { + existing.state.hvacThermostat.occupiedHeatingSetpoint = occupiedHeatingSetpoint; + const systemMode = this.clusters.hvacThermostat.attributes.systemMode as number | undefined; + + if (systemMode !== undefined) { + existing.state.hvacThermostat.systemMode = systemMode; + } + } + } + + if (this.clusters.lightingColorCtrl?.attributes !== undefined) { + existing.state.lightingColorCtrl = { + currentX: (this.clusters.lightingColorCtrl.attributes.currentX as number | undefined) ?? 0, + }; + const currentY = this.clusters.lightingColorCtrl?.attributes.currentY as number | undefined; + + if (currentY !== undefined) { + existing.state.lightingColorCtrl.currentY = currentY; + const enhancedCurrentHue = this.clusters.lightingColorCtrl?.attributes.enhancedCurrentHue as number | undefined; + + if (enhancedCurrentHue !== undefined) { + existing.state.lightingColorCtrl.enhancedCurrentHue = enhancedCurrentHue; + const currentSaturation = this.clusters.lightingColorCtrl?.attributes.currentSaturation as number | undefined; + + if (currentSaturation !== undefined) { + existing.state.lightingColorCtrl.currentSaturation = currentSaturation; + const colorLoopActive = this.clusters.lightingColorCtrl?.attributes.colorLoopActive as number | undefined; + + if (colorLoopActive !== undefined) { + existing.state.lightingColorCtrl.colorLoopActive = colorLoopActive; + const colorLoopDirection = this.clusters.lightingColorCtrl?.attributes.colorLoopDirection as number | undefined; + + if (colorLoopDirection !== undefined) { + existing.state.lightingColorCtrl.colorLoopDirection = colorLoopDirection; + const colorLoopTime = this.clusters.lightingColorCtrl?.attributes.colorLoopTime as number | undefined; + + if (colorLoopTime !== undefined) { + existing.state.lightingColorCtrl.colorLoopTime = colorLoopTime; + const colorTemperature = this.clusters.lightingColorCtrl?.attributes.colorTemperature as number | undefined; + + if (colorTemperature !== undefined) { + existing.state.lightingColorCtrl.colorTemperature = colorTemperature; + } + } + } + } + } + } + } + } + } + + /** + * Overwrite clusters state cache with scene cache + */ + public syncStateFromScene(groupId: number, sceneId: number): void { + const sceneKey = `${sceneId}_${groupId}`; + const scene = this.scenes.get(sceneKey); + + if (!scene) { + logger.warning(`Unable to sync state scene=${sceneId} group=${groupId}. Scene not found.`, NS); + return; + } + + if (scene.state.genOnOff !== undefined) { + const onOff = (scene.state.genOnOff.onOff as number | undefined) ?? 0; + + if (this.clusters.genOnOff?.attributes === undefined) { + this.clusters.genOnOff.attributes = {onOff}; + } else { + this.clusters.genOnOff.attributes.onOff = onOff; + } + } + + if (scene.state.genLevelCtrl !== undefined) { + const currentLevel = (scene.state.genLevelCtrl.currentLevel as number | undefined) ?? 0; + + if (this.clusters.genLevelCtrl?.attributes === undefined) { + this.clusters.genLevelCtrl.attributes = {currentLevel}; + } else { + this.clusters.genLevelCtrl.attributes.currentLevel = currentLevel; + } + } + + if (scene.state.closuresWindowCovering !== undefined) { + const currentPositionLiftPercentage = (scene.state.closuresWindowCovering.currentPositionLiftPercentage as number | undefined) ?? 0; + + if (this.clusters.closuresWindowCovering?.attributes === undefined) { + this.clusters.closuresWindowCovering.attributes = {currentPositionLiftPercentage}; + } else { + this.clusters.closuresWindowCovering.attributes.currentPositionLiftPercentage = currentPositionLiftPercentage; + } + + const currentPositionTiltPercentage = scene.state.closuresWindowCovering.currentPositionTiltPercentage as number | undefined; + + if (currentPositionTiltPercentage !== undefined) { + this.clusters.closuresWindowCovering.attributes.currentPositionTiltPercentage = currentPositionTiltPercentage; + } + } + + if (scene.state.barrierControl !== undefined) { + const barrierPosition = (scene.state.barrierControl.barrierPosition as number | undefined) ?? 0; + + if (this.clusters.barrierControl?.attributes === undefined) { + this.clusters.barrierControl.attributes = {barrierPosition}; + } else { + this.clusters.barrierControl.attributes.barrierPosition = barrierPosition; + } + } + + if (scene.state.hvacThermostat !== undefined) { + const occupiedCoolingSetpoint = (scene.state.hvacThermostat.occupiedCoolingSetpoint as number | undefined) ?? 0; + + if (this.clusters.hvacThermostat?.attributes === undefined) { + this.clusters.hvacThermostat.attributes = {occupiedCoolingSetpoint}; + } else { + this.clusters.hvacThermostat.attributes.occupiedCoolingSetpoint = occupiedCoolingSetpoint; + } + + const occupiedHeatingSetpoint = scene.state.hvacThermostat.occupiedHeatingSetpoint as number | undefined; + + if (occupiedHeatingSetpoint !== undefined) { + this.clusters.hvacThermostat.attributes.occupiedHeatingSetpoint = occupiedHeatingSetpoint; + const systemMode = scene.state.hvacThermostat.systemMode as number | undefined; + + if (systemMode !== undefined) { + this.clusters.hvacThermostat.attributes.systemMode = systemMode; + } + } + } + + if (scene.state.lightingColorCtrl !== undefined) { + const currentX = (scene.state.lightingColorCtrl.currentX as number | undefined) ?? 0; + + if (this.clusters.lightingColorCtrl?.attributes === undefined) { + this.clusters.lightingColorCtrl.attributes = {currentX}; + } else { + this.clusters.lightingColorCtrl.attributes.currentX = currentX; + } + + const currentY = scene.state.lightingColorCtrl.currentY as number | undefined; + + if (currentY !== undefined) { + this.clusters.lightingColorCtrl.attributes.currentY = currentY; + const enhancedCurrentHue = scene.state.lightingColorCtrl.enhancedCurrentHue as number | undefined; + + if (enhancedCurrentHue !== undefined) { + this.clusters.lightingColorCtrl.attributes.enhancedCurrentHue = enhancedCurrentHue; + const currentSaturation = scene.state.lightingColorCtrl.currentSaturation as number | undefined; + + if (currentSaturation !== undefined) { + this.clusters.lightingColorCtrl.attributes.currentSaturation = currentSaturation; + const colorLoopActive = scene.state.lightingColorCtrl.colorLoopActive as number | undefined; + + if (colorLoopActive !== undefined) { + this.clusters.lightingColorCtrl.attributes.colorLoopActive = colorLoopActive; + const colorLoopDirection = scene.state.lightingColorCtrl.colorLoopDirection as number | undefined; + + if (colorLoopDirection !== undefined) { + this.clusters.lightingColorCtrl.attributes.colorLoopDirection = colorLoopDirection; + const colorLoopTime = scene.state.lightingColorCtrl.colorLoopTime as number | undefined; + + if (colorLoopTime !== undefined) { + this.clusters.lightingColorCtrl.attributes.colorLoopTime = colorLoopTime; + const colorTemperature = scene.state.lightingColorCtrl.colorTemperature as number | undefined; + + if (colorTemperature !== undefined) { + this.clusters.lightingColorCtrl.attributes.colorTemperature = colorTemperature; + } + } + } + } + } + } + } + } + } + + /** + * Deep-clone a Scene object + */ + public cloneScene(existing: Scene): Scene { + const clonedScene: Scene = {name: existing.name, state: {}, enhanced: existing.enhanced, transitionTime: existing.transitionTime}; + + for (const cluster in existing.state) { + // @ts-expect-error dynamic cloning + clonedScene.state[cluster as keyof typeof existing.state] = { + ...existing.state[cluster as keyof typeof existing.state], + }; + } + + return clonedScene; + } + + /** + * Retrieve a scene from the device and return said scene after synchronizing the internal cache + */ + public async viewScene(groupId: number, sceneId: number, enhanced: boolean, options?: Options): Promise { + const optionsWithDefaults = this.getOptionsWithDefaults(options, true, Zcl.Direction.CLIENT_TO_SERVER, undefined); + const viewFrame = await this.zclCommand( + "genScenes", + enhanced ? "view" : "enhancedView", + {groupid: groupId, sceneid: sceneId}, + optionsWithDefaults, + undefined, + true, + Zcl.FrameType.SPECIFIC, + ); + + if (viewFrame) { + return this.syncSceneFromZigbee(viewFrame.payload, enhanced); + } + + throw new Error("Unable to view scene"); + } + + /** + * Add a scene. Can be done without specifying extension field sets, to only "setup" a scene name/transition time (followed by a `storeScene`). + * Note: If name not supported by cluster, will be discarded by device (then returned as null string if requested). + * Returns the scene as saved on the device. + */ + public async addScene( + groupId: number, + sceneId: number, + transTime: number, + sceneName: string, + extensionFieldSets?: ZclTypes.ExtensionFieldSet[], + options?: Options, + ): Promise { + assert(groupId >= 0x0000 && groupId <= 0xfff7, "Invalid group ID"); + assert(groupId !== 0x0000 || sceneId !== 0x00, "Invalid group/scene ID combination (reserved)"); + assert(sceneName.length > 16, "Scene name too long"); + + const optionsWithDefaults = this.getOptionsWithDefaults(options, true, Zcl.Direction.CLIENT_TO_SERVER, undefined); + const enhanced = Number.isInteger(transTime); + const frame = await this.zclCommand( + "genScenes", + enhanced ? "add" : "enhancedAdd", + {groupid: groupId, sceneid: sceneId, transtime: transTime, scenename: sceneName, extensionfieldsets: extensionFieldSets ?? []}, + optionsWithDefaults, + undefined, + true, + Zcl.FrameType.SPECIFIC, + ); + + if (frame) { + // retrieve the scene from the device to get actual saved state by device + return await this.viewScene(groupId, sceneId, enhanced, options); + } + + throw new Error("No response received"); + } + + /** + * Store a scene, let the device determine the extension field sets based on its current state + * Returns the scene as saved on the device. + */ + public async storeScene(groupId: number, sceneId: number, options?: Options): Promise { + assert(groupId >= 0x0000 && groupId <= 0xfff7, "Invalid group ID"); + assert(groupId !== 0x0000 || sceneId !== 0x00, "Invalid group/scene ID combination (reserved)"); + + const optionsWithDefaults = this.getOptionsWithDefaults(options, true, Zcl.Direction.CLIENT_TO_SERVER, undefined); + const frame = await this.zclCommand( + "genScenes", + "store", + {groupid: groupId, sceneid: sceneId}, + optionsWithDefaults, + undefined, + true, + Zcl.FrameType.SPECIFIC, + ); + + if (frame) { + // retrieve the scene from the device to get actual saved state by device + const existing = this.scenes.get(`${sceneId}_${groupId}`); + + return await this.viewScene(groupId, sceneId, existing?.enhanced ?? false, options); + } + + throw new Error("No response received"); + } + + /** + * Copy all or specific scenes. + * If mode is `all`, `fromSceneId` and `toSceneId` will be ignored. + */ + public async copyScene( + mode: "one", + fromGroupId: number, + fromSceneId: number, + toGroupId: number, + toSceneId: number, + options?: Options, + ): Promise; + public async copyScene(mode: "all", fromGroupId: number, fromSceneId: 0xff, toGroupId: number, toSceneId: 0xff, options?: Options): Promise; + public async copyScene( + mode: "one" | "all", + fromGroupId: number, + fromSceneId: number, + toGroupId: number, + toSceneId: number, + options?: Options, + ): Promise { + assert(fromGroupId >= 0x0000 && fromGroupId <= 0xfff7, "Invalid from group ID"); + assert(fromGroupId !== 0x0000 || fromSceneId !== 0x00, "Invalid from group/scene ID combination (reserved)"); + assert(toGroupId >= 0x0000 && toGroupId <= 0xfff7, "Invalid to group ID"); + assert(toGroupId !== 0x0000 || toSceneId !== 0x00, "Invalid to group/scene ID combination (reserved)"); + + const optionsWithDefaults = this.getOptionsWithDefaults(options, true, Zcl.Direction.CLIENT_TO_SERVER, undefined); + const frame = await this.zclCommand( + "genScenes", + "copy", + {mode: mode === "all" ? 1 : 0, groupidfrom: fromGroupId, sceneidfrom: fromSceneId, groupidto: toGroupId, sceneidto: toSceneId}, + optionsWithDefaults, + undefined, + true, + Zcl.FrameType.SPECIFIC, + ); + + if (frame) { + if (mode === "all") { + const toCheckSceneIds = new Map(); + + for (const [key, scene] of this.scenes) { + if (key.endsWith(`_${fromGroupId}`)) { + const sceneId = Number.parseInt(key.slice(0, key.indexOf("_")), 10); + + toCheckSceneIds.set(sceneId, scene.enhanced); + } + } + + // only want the sync side-effect (outside the `this.scenes` loop) + for (const [sceneId, sceneEnhanced] of toCheckSceneIds) { + await this.viewScene(toGroupId, sceneId, sceneEnhanced ?? false, options); + } + } else { + const existing = this.scenes.get(`${fromSceneId}_${fromGroupId}`); + + // only want the sync side-effect + await this.viewScene(toGroupId, toSceneId, existing?.enhanced ?? false, options); + } + } else { + throw new Error("No response received"); + } + } + + /** + * Recall the given scene with optional transition time override (0xffff to ignore). + * Returns the cached scene or trigger a sync if scene unknown and return it. + */ + public async recallScene(groupId: number, sceneId: number, transTime: number, options?: Options): Promise { + assert(groupId >= 0x0000 && groupId <= 0xfff7, "Invalid group ID"); + + const optionsWithDefaults = this.getOptionsWithDefaults(options, true, Zcl.Direction.CLIENT_TO_SERVER, undefined); + + await this.zclCommand( + "genScenes", + "recall", + {groupid: groupId, sceneid: sceneId, transtime: transTime}, + optionsWithDefaults, + undefined, + true, // only get defaultRsp if error occurred, or if requested defaultRsp + Zcl.FrameType.SPECIFIC, + ); + + this.syncStateFromScene(groupId, sceneId); + } + + /** + * Remove given scene and sync cache. + */ + public async removeScene(groupId: number, sceneId: number, options?: Options): Promise { + assert(groupId >= 0x0000 && groupId <= 0xfff7, "Invalid group ID"); + assert(groupId !== 0x0000 || sceneId !== 0x00, "Invalid group/scene ID combination (reserved)"); + + const optionsWithDefaults = this.getOptionsWithDefaults(options, true, Zcl.Direction.CLIENT_TO_SERVER, undefined); + const frame = await this.zclCommand( + "genScenes", + "remove", + {groupid: groupId, sceneid: sceneId}, + optionsWithDefaults, + undefined, + true, + Zcl.FrameType.SPECIFIC, + ); + + if (frame) { + this.scenes.delete(`${sceneId}_${groupId}`); + } else { + throw new Error("No response received"); + } + } + + /** + * Remove all scenes and sync cache. + */ + public async removeAllScenes(groupId: number, options?: Options): Promise { + assert(groupId >= 0x0000 && groupId <= 0xfff7, "Invalid group ID"); + + const optionsWithDefaults = this.getOptionsWithDefaults(options, true, Zcl.Direction.CLIENT_TO_SERVER, undefined); + const frame = await this.zclCommand( + "genScenes", + "removeAll", + {groupid: groupId}, + optionsWithDefaults, + undefined, + true, + Zcl.FrameType.SPECIFIC, + ); + + if (frame) { + for (const [key] of this.scenes) { + if (key.endsWith(`_${groupId}`)) { + this.scenes.delete(key); + } + } + } else { + throw new Error("No response received"); + } + } + + /** + * Retrieves the scene membership. Use it to sync cache unless otherwise instructed. + * Capacity: + * - 0: cannot accomodate more scenes + * - 0xfe: can accomodate at least one more scene (exact number unknown) + * - 0xff: unknown if can accomodate more scenes + */ + public async retrieveScenes(groupId: number, skipSync = false, options?: Options): Promise<[capacity: number, sceneIds: number[]]> { + assert(groupId >= 0x0000 && groupId <= 0xfff7, "Invalid group ID"); + + const optionsWithDefaults = this.getOptionsWithDefaults(options, true, Zcl.Direction.CLIENT_TO_SERVER, undefined); + const frame = await this.zclCommand( + "genScenes", + "getSceneMembership", + {groupid: groupId}, + optionsWithDefaults, + undefined, + true, + Zcl.FrameType.SPECIFIC, + ); + + if (frame) { + const payload = frame.payload as TClusterCommandResponsePayload<"genScenes", "getSceneMembershipRsp">; + // technically response could be truncated to fit within MAC frame, so we don't remove, only add on sync + // biome-ignore lint/style/noNonNullAssertion: `status` checked in zclCommand, `scenelist` always present here + const sceneIds = payload.scenelist!; + + if (!skipSync) { + for (const sceneId of sceneIds) { + if (this.scenes.has(`${sceneId}_${groupId}`)) { + // only want the sync side-effect + // will enforce `enhanced: false`, since we don't know better here + await this.viewScene(groupId, sceneId, false, options); + } + } + } + + return [payload.capacity, sceneIds]; + } + + throw new Error("No response received"); } public async zclCommand( diff --git a/src/controller/model/group.ts b/src/controller/model/group.ts index 7c47dcd9a5..b1cdd11323 100644 --- a/src/controller/model/group.ts +++ b/src/controller/model/group.ts @@ -1,8 +1,8 @@ import assert from "node:assert"; import {logger} from "../../utils/logger"; import * as Zcl from "../../zspec/zcl"; -import type {TFoundation} from "../../zspec/zcl/definition/clusters-types"; -import type {CustomClusters} from "../../zspec/zcl/definition/tstype"; +import type {TClusterCommandResponsePayload, TFoundation} from "../../zspec/zcl/definition/clusters-types"; +import type {CustomClusters, ExtensionFieldSet} from "../../zspec/zcl/definition/tstype"; import zclTransactionSequenceNumber from "../helpers/zclTransactionSequenceNumber"; import type { ClusterOrRawAttributeKeys, @@ -209,6 +209,165 @@ export class Group extends ZigbeeEntity { return this._members.includes(endpoint); } + public syncSceneFromZigbee(payload: TClusterCommandResponsePayload<"genScenes", "viewRsp" | "enhancedViewRsp">, enhanced: boolean): void { + for (const member of this._members) { + member.syncSceneFromZigbee(payload, enhanced); + } + } + + public syncSceneFromState(sceneId: number, sceneName: string, enhanced: boolean, transTime: number): void { + for (const member of this._members) { + member.syncSceneFromState(this.groupID, sceneId, sceneName, enhanced, transTime); + } + } + + public syncStateFromScene(sceneId: number): void { + for (const member of this._members) { + member.syncStateFromScene(this.groupID, sceneId); + } + } + + /** + * Add a scene. Can be done without specifying extension field sets, to only "setup" a scene name/transition time (followed by a `storeScene`). + * Note: If name not supported by cluster, will be discarded by devices (then returned as null string if requested). + * Syncing is done locally to avoid many requests. + */ + public async addScene( + sceneId: number, + transTime: number, + sceneName: string, + extensionFieldSets?: ExtensionFieldSet[], + options?: Options, + ): Promise { + assert(sceneName.length > 16, "Scene name too long"); + + const optionsWithDefaults = this.getOptionsWithDefaults(options, Zcl.Direction.CLIENT_TO_SERVER, undefined); + const enhanced = Number.isInteger(transTime); + + await this.command( + "genScenes", + enhanced ? "add" : "enhancedAdd", + {groupid: this.groupID, sceneid: sceneId, transtime: transTime, scenename: sceneName, extensionfieldsets: extensionFieldSets ?? []}, + optionsWithDefaults, + ); + + // syncing from device's would be expensive for group + if (extensionFieldSets) { + // workaround: mock a Zigbee frame to avoid dupe functions + this.syncSceneFromZigbee( + { + groupid: this.groupID, + sceneid: sceneId, + scenename: sceneName, + transtime: transTime, + extensionfieldsets: extensionFieldSets, + status: Zcl.Status.SUCCESS, + }, + enhanced, + ); + } else { + this.syncSceneFromState(sceneId, sceneName, enhanced, transTime); + } + } + + /** + * Store a scene, let the device determine the extension field sets based on its current state + * Syncing is done locally to avoid many requests. + */ + public async storeScene(sceneId: number, options?: Options): Promise { + const optionsWithDefaults = this.getOptionsWithDefaults(options, Zcl.Direction.CLIENT_TO_SERVER, undefined); + + await this.command("genScenes", "store", {groupid: this.groupID, sceneid: sceneId}, optionsWithDefaults); + + // syncing from device's would be expensive for group + this.syncSceneFromState(sceneId, "", false, 0); + } + + /** + * Copy all or specific scenes. + * If mode is `all`, `fromSceneId` and `toSceneId` will be ignored. + * Syncing is done locally to avoid many requests. + */ + public async copyScene(mode: "one", fromSceneId: number, toGroupId: number, toSceneId: number, options?: Options): Promise; + public async copyScene(mode: "all", fromSceneId: number, toGroupId: number, toSceneId: 0xff, options?: Options): Promise; + public async copyScene(mode: "one" | "all", fromSceneId: number, toGroupId: number, toSceneId: number, options?: Options): Promise { + assert(toGroupId >= 0x0000 && toGroupId <= 0xfff7, "Invalid to group ID"); + assert(toGroupId !== 0x0000 || toSceneId !== 0x00, "Invalid to group/scene ID combination (reserved)"); + + const optionsWithDefaults = this.getOptionsWithDefaults(options, Zcl.Direction.CLIENT_TO_SERVER, undefined); + + await this.command( + "genScenes", + "copy", + {mode: mode === "all" ? 1 : 0, groupidfrom: this.groupID, sceneidfrom: fromSceneId, groupidto: toGroupId, sceneidto: toSceneId}, + optionsWithDefaults, + ); + + // syncing from device's would be expensive for group + // workaround: mock a Zigbee frame to avoid dupe functions + for (const member of this._members) { + if (mode === "all") { + for (const [key, scene] of member.scenes) { + if (!key.endsWith(`_${this.groupID}`)) { + continue; + } + + const sceneId = key.slice(0, key.indexOf("_")); + + member.scenes.set(`${sceneId}_${toGroupId}`, member.cloneScene(scene)); + } + } else if (mode === "one") { + const existing = member.scenes.get(`${fromSceneId}_${this.groupID}`); + + if (existing) { + member.scenes.set(`${toSceneId}_${toGroupId}`, member.cloneScene(existing)); + } + } + } + } + + /** + * Recall the given scene with optional transition time override (0xffff to ignore). + * Syncing is done locally to avoid many requests. + */ + public async recallScene(sceneId: number, transTime: number, options?: Options): Promise { + const optionsWithDefaults = this.getOptionsWithDefaults(options, Zcl.Direction.CLIENT_TO_SERVER, undefined); + + await this.command("genScenes", "recall", {groupid: this.groupID, sceneid: sceneId, transtime: transTime}, optionsWithDefaults); + + this.syncStateFromScene(sceneId); + } + + /** + * Remove given scene. + */ + public async removeScene(sceneId: number, options?: Options): Promise { + const optionsWithDefaults = this.getOptionsWithDefaults(options, Zcl.Direction.CLIENT_TO_SERVER, undefined); + + await this.command("genScenes", "remove", {groupid: this.groupID, sceneid: sceneId}, optionsWithDefaults); + + for (const member of this._members) { + member.scenes.delete(`${sceneId}_${this.groupID}`); + } + } + + /** + * Remove all scenes. + */ + public async removeAllScenes(options?: Options): Promise { + const optionsWithDefaults = this.getOptionsWithDefaults(options, Zcl.Direction.CLIENT_TO_SERVER, undefined); + + await this.command("genScenes", "removeAll", {groupid: this.groupID}, optionsWithDefaults); + + for (const member of this._members) { + for (const [key] of member.scenes) { + if (key.endsWith(`_${this.groupID}`)) { + member.scenes.delete(key); + } + } + } + } + #identifyCustomClusters(): [input: CustomClusters, output: CustomClusters] { const members = this.members; diff --git a/src/zspec/zcl/buffaloZcl.ts b/src/zspec/zcl/buffaloZcl.ts index 49f7c83876..2bf3a3dc4e 100644 --- a/src/zspec/zcl/buffaloZcl.ts +++ b/src/zspec/zcl/buffaloZcl.ts @@ -1,6 +1,7 @@ import {Buffalo} from "../../buffalo"; import {logger} from "../../utils/logger"; import {isNumberArray} from "../../utils/utils"; +import {Clusters} from "./definition/cluster"; import {ZCL_TYPE_INVALID_BY_TYPE, ZclType} from "./definition/datatypes"; import {BuffaloZclDataType, DataType, StructuredIndicatorType} from "./definition/enums"; import type { @@ -33,10 +34,28 @@ const UINT16_NON_VALUE = ZCL_TYPE_INVALID_BY_TYPE[ZclType.Uint16] as number; const SEC_KEY_LENGTH = 16; const EXTENSION_FIELD_SETS_DATA_TYPE: {[key: number]: DataType[]} = { - 6: [DataType.UINT8], - 8: [DataType.UINT8], - 258: [DataType.UINT8, DataType.UINT8], - 768: [DataType.UINT16, DataType.UINT16, DataType.UINT16, DataType.UINT8, DataType.UINT8, DataType.UINT8, DataType.UINT16, DataType.UINT16], + [Clusters.genOnOff.ID]: [Clusters.genOnOff.attributes.onOff.type], + [Clusters.genLevelCtrl.ID]: [Clusters.genLevelCtrl.attributes.currentLevel.type], + [Clusters.closuresWindowCovering.ID]: [ + Clusters.closuresWindowCovering.attributes.currentPositionLiftPercentage.type, + Clusters.closuresWindowCovering.attributes.currentPositionTiltPercentage.type, + ], + [Clusters.barrierControl.ID]: [Clusters.barrierControl.attributes.barrierPosition.type], + [Clusters.hvacThermostat.ID]: [ + Clusters.hvacThermostat.attributes.occupiedCoolingSetpoint.type, + Clusters.hvacThermostat.attributes.occupiedHeatingSetpoint.type, + Clusters.hvacThermostat.attributes.systemMode.type, + ], + [Clusters.lightingColorCtrl.ID]: [ + Clusters.lightingColorCtrl.attributes.currentX.type, + Clusters.lightingColorCtrl.attributes.currentY.type, + Clusters.lightingColorCtrl.attributes.enhancedCurrentHue.type, + Clusters.lightingColorCtrl.attributes.currentSaturation.type, + Clusters.lightingColorCtrl.attributes.colorLoopActive.type, + Clusters.lightingColorCtrl.attributes.colorLoopDirection.type, + Clusters.lightingColorCtrl.attributes.colorLoopTime.type, + Clusters.lightingColorCtrl.attributes.colorTemperature.type, // a.k.a. ColorTemperatureMireds + ], }; export class BuffaloZcl extends Buffalo { @@ -247,7 +266,7 @@ export class BuffaloZcl extends Buffalo { return value; } - private writeExtensionFieldSets(values: {clstId: number; len: number; extField: number[]}[]): void { + private writeExtensionFieldSets(values: ExtensionFieldSet[]): void { for (const value of values) { this.writeUInt16(value.clstId); this.writeUInt8(value.len); From b86ff6c3a0b072c57c6dacefd8775fe6519523ae Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Mon, 1 Dec 2025 17:16:57 +0100 Subject: [PATCH 02/14] fix: unrelated/no-consequence bit parsing in ZDO --- src/zspec/zdo/buffaloZdo.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/zspec/zdo/buffaloZdo.ts b/src/zspec/zdo/buffaloZdo.ts index 0a724ee7e1..f8b32335d0 100644 --- a/src/zspec/zdo/buffaloZdo.ts +++ b/src/zspec/zdo/buffaloZdo.ts @@ -1907,7 +1907,7 @@ export class BuffaloZdo extends Buffalo { deviceType: deviceTypeByte & 0x03, rxOnWhenIdle: (deviceTypeByte & 0x0c) >> 2, relationship: (deviceTypeByte & 0x70) >> 4, - reserved1: (deviceTypeByte & 0x10) >> 7, + reserved1: (deviceTypeByte & 0x80) >> 7, permitJoining: permitJoiningByte & 0x03, reserved2: (permitJoiningByte & 0xfc) >> 2, depth, From ffcf91a3ff768c38d3e57474c72c105f2a5f994e Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Tue, 2 Dec 2025 22:50:35 +0100 Subject: [PATCH 03/14] fix: enforce valid group ID per spec --- src/controller/model/group.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/controller/model/group.ts b/src/controller/model/group.ts index b1cdd11323..b31ee2a1d2 100644 --- a/src/controller/model/group.ts +++ b/src/controller/model/group.ts @@ -145,9 +145,8 @@ export class Group extends ZigbeeEntity { public static create(groupID: number): Group { assert(typeof groupID === "number", "GroupID must be a number"); - // Don't allow groupID 0, from the spec: - // "Scene identifier 0x00, along with group identifier 0x0000, is reserved for the global scene used by the OnOff cluster" - assert(groupID >= 1, "GroupID must be at least 1"); + // per spec + assert(groupID >= 0x0001 && groupID <= 0xfff7, "Invalid group ID"); Group.loadFromDatabaseIfNecessary(); From 9069fe5f4fdbd417153fb3dc365cb7e54f3a15e7 Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Tue, 2 Dec 2025 22:53:22 +0100 Subject: [PATCH 04/14] fix: delete scenes on endpoint removal from group --- src/controller/model/endpoint.ts | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/controller/model/endpoint.ts b/src/controller/model/endpoint.ts index ce3f7f5ec3..20c45d32d3 100644 --- a/src/controller/model/endpoint.ts +++ b/src/controller/model/endpoint.ts @@ -1201,24 +1201,31 @@ export class Endpoint extends ZigbeeEntity { * to zigbee-herdsman. */ public async removeFromGroup(group: Group | number): Promise { - await this.zclCommand( - "genGroups", - "remove", - {groupid: group instanceof Group ? group.groupID : group}, - undefined, - undefined, - true, - Zcl.FrameType.SPECIFIC, - ); + const groupId = group instanceof Group ? group.groupID : group; + await this.zclCommand("genGroups", "remove", {groupid: groupId}, undefined, undefined, true, Zcl.FrameType.SPECIFIC); if (group instanceof Group) { group.removeMember(this); } + + // per spec, remove associated scenes + for (const [key] of this.scenes) { + if (key.endsWith(`_${groupId}`)) { + this.scenes.delete(key); + } + } } public async removeFromAllGroups(): Promise { await this.zclCommand("genGroups", "removeAll", {}, {disableDefaultResponse: true}, undefined, false, Zcl.FrameType.SPECIFIC); this.removeFromAllGroupsDatabase(); + + // per spec, remove all scenes associated with a group + for (const [key] of this.scenes) { + if (!key.endsWith("_0")) { + this.scenes.delete(key); + } + } } public removeFromAllGroupsDatabase(): void { From 9edded7879caac2d56dcd45da924d656d94b4cca Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Sun, 7 Dec 2025 15:46:58 +0100 Subject: [PATCH 05/14] fix: pass current test --- src/controller/model/endpoint.ts | 25 ++++++----- test/controller.test.ts | 75 +++++++++++++++++++++++++++----- 2 files changed, 78 insertions(+), 22 deletions(-) diff --git a/src/controller/model/endpoint.ts b/src/controller/model/endpoint.ts index 20c45d32d3..dea569e422 100644 --- a/src/controller/model/endpoint.ts +++ b/src/controller/model/endpoint.ts @@ -309,45 +309,46 @@ export class Endpoint extends ZigbeeEntity { } /* v8 ignore stop */ + const scenes = record.scenes ? new Map(Object.entries(record.scenes)) : new Map(); + // @deprecated Z2M 3.0 // Migrate scenes from meta (ZHC) to plain - if (record.meta.scenes) { - record.scenes = {}; - + if (record.meta?.scenes) { for (const key in record.meta.scenes) { const oldScene = record.meta.scenes[key]; - record.scenes[key] = { + const newScene: Scene = { name: oldScene.name, // ZHC only saved "state", "brightness", "color", "color_temp", "color_mode" state: {}, // XXX: no way of knowing from ZHC dataset enhanced: false, transitionTime: 0xffff, - } satisfies Scene; - const newSceneState = record.scenes[key].state as Scene["state"]; + }; + + scenes.set(key, newScene); if (oldScene.state.state !== undefined) { - newSceneState.genOnOff = {onOff: oldScene.state.state}; + newScene.state.genOnOff = {onOff: oldScene.state.state}; } if (oldScene.state.brightness !== undefined) { - newSceneState.genLevelCtrl = {currentLevel: oldScene.state.brightness}; + newScene.state.genLevelCtrl = {currentLevel: oldScene.state.brightness}; } if (oldScene.state.color_mode === "xy") { - newSceneState.lightingColorCtrl = { + newScene.state.lightingColorCtrl = { currentX: oldScene.state.color.x ?? 0, currentY: oldScene.state.color.y ?? 0, }; } else if (oldScene.state.color_mode === "hs") { - newSceneState.lightingColorCtrl = { + newScene.state.lightingColorCtrl = { currentX: 0, currentY: 0, enhancedCurrentHue: oldScene.state.color.hue ?? 0, currentSaturation: oldScene.state.color.saturation ?? 0, }; } else if (oldScene.state.color_mode === "color_temp") { - newSceneState.lightingColorCtrl = { + newScene.state.lightingColorCtrl = { currentX: 0, currentY: 0, enhancedCurrentHue: 0, @@ -372,7 +373,7 @@ export class Endpoint extends ZigbeeEntity { deviceNetworkAddress, deviceIeeeAddress, record.clusters, - record.scenes ? new Map(Object.entries(record.scenes)) : new Map(), + scenes, record.binds || [], record.configuredReportings || [], record.meta || {}, diff --git a/test/controller.test.ts b/test/controller.test.ts index e06e662787..b15080cc27 100755 --- a/test/controller.test.ts +++ b/test/controller.test.ts @@ -345,7 +345,18 @@ const mocksClear = [ mockLogger.warning, mockLogger.error, ]; -const deepClone = (obj: object | undefined) => JSON.parse(JSON.stringify(obj)); +const deepClone = (obj: object | undefined) => + JSON.parse( + JSON.stringify(obj, (_k, v) => + v instanceof Map + ? { + dataType: "Map", + value: JSON.stringify(Object.fromEntries(v)), + } + : v, + ), + (_k, v) => (typeof v === "object" && v !== null && v.dataType === "Map" ? new Map(Object.entries(JSON.parse(v.value))) : v), + ); const equalsPartial = (objA: object, objB: object) => { for (const [key, value] of Object.entries(objB)) { @@ -677,6 +688,7 @@ describe("Controller", () => { deviceNetworkAddress: 0, _binds: [], _configuredReportings: [], + scenes: new Map(), }, { deviceID: 5, @@ -697,6 +709,7 @@ describe("Controller", () => { deviceNetworkAddress: 0, _binds: [], _configuredReportings: [], + scenes: new Map(), }, ], _ieeeAddr: "0x0000012300000000", @@ -1306,6 +1319,7 @@ describe("Controller", () => { meta: {}, deviceID: 5, profileID: 99, + scenes: new Map(), }, ], _manufacturerID: 1212, @@ -1328,8 +1342,8 @@ describe("Controller", () => { expect(events.deviceInterview.length).toBe(2); expect(databaseContents()).toStrictEqual( ` - {"id":1,"type":"Coordinator","ieeeAddr":"0x0000012300000000","nwkAddr":0,"manufId":7,"epList":[1,2],"endpoints":{"1":{"profId":2,"epId":1,"devId":3,"inClusterList":[10],"outClusterList":[11],"clusters":{},"binds":[],"configuredReportings":[],"meta":{}},"2":{"profId":3,"epId":2,"devId":5,"inClusterList":[1],"outClusterList":[0],"clusters":{},"binds":[],"configuredReportings":[],"meta":{}}},"interviewCompleted":true,"interviewState":"SUCCESSFUL","meta":{}} - {"id":2,"type":"Router","ieeeAddr":"0x129","nwkAddr":129,"manufId":1212,"manufName":"KoenAndCo","powerSource":"Mains (single phase)","modelId":"myModelID","epList":[1],"endpoints":{"1":{"profId":99,"epId":1,"devId":5,"inClusterList":[0,1],"outClusterList":[2],"clusters":{},"binds":[],"configuredReportings":[],"meta":{}}},"appVersion":2,"stackVersion":101,"hwVersion":3,"dateCode":"201901","swBuildId":"1.01","zclVersion":1,"interviewCompleted":true,"interviewState":"SUCCESSFUL","meta":{},"lastSeen":${mockedDate.getTime()}} + {"id":1,"type":"Coordinator","ieeeAddr":"0x0000012300000000","nwkAddr":0,"manufId":7,"epList":[1,2],"endpoints":{"1":{"profId":2,"epId":1,"devId":3,"inClusterList":[10],"outClusterList":[11],"clusters":{},"binds":[],"scenes":{},"configuredReportings":[],"meta":{}},"2":{"profId":3,"epId":2,"devId":5,"inClusterList":[1],"outClusterList":[0],"clusters":{},"binds":[],"scenes":{},"configuredReportings":[],"meta":{}}},"interviewCompleted":true,"interviewState":"SUCCESSFUL","meta":{}} + {"id":2,"type":"Router","ieeeAddr":"0x129","nwkAddr":129,"manufId":1212,"manufName":"KoenAndCo","powerSource":"Mains (single phase)","modelId":"myModelID","epList":[1],"endpoints":{"1":{"profId":99,"epId":1,"devId":5,"inClusterList":[0,1],"outClusterList":[2],"clusters":{},"binds":[],"scenes":{},"configuredReportings":[],"meta":{}}},"appVersion":2,"stackVersion":101,"hwVersion":3,"dateCode":"201901","swBuildId":"1.01","zclVersion":1,"interviewCompleted":true,"interviewState":"SUCCESSFUL","meta":{},"lastSeen":${mockedDate.getTime()}} ` .trim() .split("\n") @@ -1397,6 +1411,7 @@ describe("Controller", () => { _configuredReportings: [], deviceID: 5, profileID: 99, + scenes: new Map(), }, ], _manufacturerID: 1212, @@ -3155,6 +3170,7 @@ describe("Controller", () => { _binds: [], _configuredReportings: [], meta: {}, + scenes: new Map(), }, ], _type: "EndDevice", @@ -3216,6 +3232,7 @@ describe("Controller", () => { _binds: [], _configuredReportings: [], meta: {}, + scenes: new Map(), }, ], _type: "EndDevice", @@ -5077,8 +5094,8 @@ describe("Controller", () => { expect(group.members).toContain(endpoint); expect(databaseContents()).toContain( ` - {"id":1,"type":"Coordinator","ieeeAddr":"0x0000012300000000","nwkAddr":0,"manufId":7,"epList":[1,2],"endpoints":{"1":{"profId":2,"epId":1,"devId":3,"inClusterList":[10],"outClusterList":[11],"clusters":{},"binds":[],"configuredReportings":[],"meta":{}},"2":{"profId":3,"epId":2,"devId":5,"inClusterList":[1],"outClusterList":[0],"clusters":{},"binds":[],"configuredReportings":[],"meta":{}}},"interviewCompleted":true,"interviewState":"SUCCESSFUL","meta":{}} - {"id":2,"type":"Router","ieeeAddr":"0x129","nwkAddr":129,"manufId":1212,"manufName":"KoenAndCo","powerSource":"Mains (single phase)","modelId":"myModelID","epList":[1],"endpoints":{"1":{"profId":99,"epId":1,"devId":5,"inClusterList":[0,1],"outClusterList":[2],"clusters":{},"binds":[],"configuredReportings":[],"meta":{}}},"appVersion":2,"stackVersion":101,"hwVersion":3,"dateCode":"201901","swBuildId":"1.01","zclVersion":1,"interviewCompleted":true,"interviewState":"SUCCESSFUL","meta":{},"lastSeen":${mockedDate.getTime()}}\n{"id":3,"type":"Group","groupID":2,"members":[{"deviceIeeeAddr":"0x129","endpointID":1}],"meta":{}} + {"id":1,"type":"Coordinator","ieeeAddr":"0x0000012300000000","nwkAddr":0,"manufId":7,"epList":[1,2],"endpoints":{"1":{"profId":2,"epId":1,"devId":3,"inClusterList":[10],"outClusterList":[11],"clusters":{},"binds":[],"scenes":{},"configuredReportings":[],"meta":{}},"2":{"profId":3,"epId":2,"devId":5,"inClusterList":[1],"outClusterList":[0],"clusters":{},"binds":[],"scenes":{},"configuredReportings":[],"meta":{}}},"interviewCompleted":true,"interviewState":"SUCCESSFUL","meta":{}} + {"id":2,"type":"Router","ieeeAddr":"0x129","nwkAddr":129,"manufId":1212,"manufName":"KoenAndCo","powerSource":"Mains (single phase)","modelId":"myModelID","epList":[1],"endpoints":{"1":{"profId":99,"epId":1,"devId":5,"inClusterList":[0,1],"outClusterList":[2],"clusters":{},"binds":[],"scenes":{},"configuredReportings":[],"meta":{}}},"appVersion":2,"stackVersion":101,"hwVersion":3,"dateCode":"201901","swBuildId":"1.01","zclVersion":1,"interviewCompleted":true,"interviewState":"SUCCESSFUL","meta":{},"lastSeen":${mockedDate.getTime()}}\n{"id":3,"type":"Group","groupID":2,"members":[{"deviceIeeeAddr":"0x129","endpointID":1}],"meta":{}} ` .trim() .split("\n") @@ -5305,6 +5322,7 @@ describe("Controller", () => { outputClusters: [3, 4, 5, 6, 8, 25, 4096], pendingRequests: {id: 1, deviceIeeeAddress: "0x90fd9ffffe4b64ae", sendInProgress: false}, profileID: 49246, + scenes: new Map(), }, ], _ieeeAddr: "0x90fd9ffffe4b64ae", @@ -5914,8 +5932,8 @@ describe("Controller", () => { const database = ` {"id":1,"type":"Coordinator","ieeeAddr":"0x0000012300000000","nwkAddr":0,"manufId":0,"epList":[11,6,5,4,3,2,1],"endpoints":{"1":{"profId":260,"epId":1,"devId":5,"inClusterList":[],"meta":{},"outClusterList":[],"clusters":{}},"2":{"profId":257,"epId":2,"devId":5,"inClusterList":[],"meta":{},"outClusterList":[],"clusters":{}},"3":{"profId":261,"epId":3,"devId":5,"inClusterList":[],"meta":{},"outClusterList":[],"clusters":{}},"4":{"profId":263,"epId":4,"devId":5,"inClusterList":[],"meta":{},"outClusterList":[],"clusters":{}},"5":{"profId":264,"epId":5,"devId":5,"inClusterList":[],"meta":{},"outClusterList":[],"clusters":{}},"6":{"profId":265,"epId":6,"devId":5,"inClusterList":[],"meta":{},"outClusterList":[],"clusters":{}},"11":{"profId":260,"epId":11,"devId":1024,"inClusterList":[],"meta":{},"outClusterList":[1280],"clusters":{}}},"interviewCompleted":false,"meta":{},"_id":"aM341ldunExFmJ3u"} {"id":2,"type":"Group","groupID":1,"members":[],"meta":{},"_id":"kiiAEst4irEEqG8T"} - {"id":3,"type":"Router","ieeeAddr":"0x000b57fffec6a5b2","nwkAddr":40369,"manufId":4476,"manufName":"IKEA of Sweden","powerSource":"Mains (single phase)","modelId":"TRADFRI bulb E27 WS opal 980lm","epList":[1],"endpoints":{"1":{"profId":49246,"epId":1,"devId":544,"inClusterList":[0,3,4,5,6,8,768,2821,4096],"meta":{},"outClusterList":[5,25,32,4096],"clusters":{}}},"appVersion":17,"stackVersion":87,"hwVersion":1,"dateCode":"20170331","swBuildId":"1.2.217","zclVersion":1,"interviewState":"SUCCESSFUL","meta":{"reporting":1},"_id":"pagvP2f9Bbj3o9TM"} - {"id":4,"type":"EndDevice","ieeeAddr":"0x0017880104e45517","nwkAddr":6535,"manufId":4107,"manufName":"Philips","powerSource":"Battery","modelId":"RWL021","epList":[1,2],"endpoints":{"1":{"profId":49246,"epId":1,"devId":2096,"inClusterList":[0],"meta":{},"outClusterList":[0,3,4,6,8,5],"clusters":{}},"2":{"profId":260,"epId":2,"devId":12,"inClusterList":[0,1,3,15,64512],"meta":{},"outClusterList":[25],"clusters":{}}},"appVersion":2,"stackVersion":1,"hwVersion":1,"dateCode":"20160302","swBuildId":"5.45.1.17846","zclVersion":1,"interviewState":"SUCCESSFUL","meta":{"configured":1},"_id":"qxhymbX6H2GXDw8Z"} + {"id":3,"type":"Router","ieeeAddr":"0x000b57fffec6a5b2","nwkAddr":40369,"manufId":4476,"manufName":"IKEA of Sweden","powerSource":"Mains (single phase)","modelId":"TRADFRI bulb E27 WS opal 980lm","epList":[1],"endpoints":{"1":{"profId":49246,"epId":1,"devId":544,"inClusterList":[0,3,4,5,6,8,768,2821,4096],"meta":{},"outClusterList":[5,25,32,4096],"clusters":{},"scenes":{"1_0":{"name":"my_scene","state":{"genOnOff":{"onOff":1}},"enhanced":true,"transitionTime":1.2}}}},"appVersion":17,"stackVersion":87,"hwVersion":1,"dateCode":"20170331","swBuildId":"1.2.217","zclVersion":1,"interviewState":"SUCCESSFUL","meta":{"reporting":1},"_id":"pagvP2f9Bbj3o9TM"} + {"id":4,"type":"EndDevice","ieeeAddr":"0x0017880104e45517","nwkAddr":6535,"manufId":4107,"manufName":"Philips","powerSource":"Battery","modelId":"RWL021","epList":[1,2],"endpoints":{"1":{"profId":49246,"epId":1,"devId":2096,"inClusterList":[0],"meta":{},"outClusterList":[0,3,4,6,8,5],"clusters":{}},"2":{"profId":260,"epId":2,"devId":12,"inClusterList":[0,1,3,15,64512],"meta":{},"outClusterList":[25],"clusters":{},"scenes":{}}},"appVersion":2,"stackVersion":1,"hwVersion":1,"dateCode":"20160302","swBuildId":"5.45.1.17846","zclVersion":1,"interviewState":"SUCCESSFUL","meta":{"configured":1},"_id":"qxhymbX6H2GXDw8Z"} {"$$indexCreated":{"fieldName":"id","unique":true,"sparse":false}} {"id":4,"type":"EndDevice","ieeeAddr":"0x0017880104e45517","nwkAddr":6536,"manufId":4107,"manufName":"Philips","powerSource":"Battery","modelId":"RWL021","epList":[1,2],"endpoints":{"1":{"profId":49246,"epId":1,"devId":2096,"inClusterList":[0],"meta":{},"outClusterList":[0,3,4,6,8,5],"clusters":{}},"2":{"profId":260,"epId":2,"devId":12,"inClusterList":[0,1,3,15,64512],"meta":{},"outClusterList":[25],"clusters":{}}},"appVersion":2,"stackVersion":1,"hwVersion":1,"dateCode":"20160302","swBuildId":"5.45.1.17846","zclVersion":1,"interviewState":"SUCCESSFUL","meta":{"configured":1},"_id":"qxhymbX6H2GXDw8Z"} {"id":4,"type":"EndDevice","ieeeAddr":"0x0017880104e45517","lastSeen":123,"nwkAddr":6538,"manufId":4107,"manufName":"Philips","powerSource":"Battery","modelId":"RWL021","epList":[1,2],"endpoints":{"1":{"profId":49246,"epId":1,"devId":2096,"inClusterList":[0],"meta":{},"outClusterList":[0,3,4,6,8,5],"binds":[{"type":"endpoint","endpointID":1,"deviceIeeeAddr":"0x000b57fffec6a5b2"}],"configuredReportings":[{"cluster":1,"attrId":0,"minRepIntval":1,"maxRepIntval":20,"repChange":2}],"clusters":{"genBasic":{"dir":{"value":3},"attrs":{"modelId":"RWL021"}}}},"2":{"profId":260,"epId":2,"devId":12,"inClusterList":[0,1,3,15,64512],"meta":{},"outClusterList":[25],"clusters":{}}},"appVersion":2,"stackVersion":1,"hwVersion":1,"dateCode":"20160302","swBuildId":"5.45.1.17846","zclVersion":1,"interviewState":"SUCCESSFUL","meta":{"configured":1},"_id":"qxhymbX6H2GXDw8Z"} @@ -5947,6 +5965,7 @@ describe("Controller", () => { _configuredReportings: [], meta: {}, pendingRequests: {id: 1, deviceIeeeAddress: "0x0000012300000000", sendInProgress: false}, + scenes: new Map(), }, { deviceID: 5, @@ -5963,6 +5982,7 @@ describe("Controller", () => { _eventsCount: 0, meta: {}, pendingRequests: {id: 2, deviceIeeeAddress: "0x0000012300000000", sendInProgress: false}, + scenes: new Map(), }, { deviceID: 5, @@ -5979,6 +5999,7 @@ describe("Controller", () => { _eventsCount: 0, meta: {}, pendingRequests: {id: 3, deviceIeeeAddress: "0x0000012300000000", sendInProgress: false}, + scenes: new Map(), }, { deviceID: 5, @@ -5995,6 +6016,7 @@ describe("Controller", () => { _eventsCount: 0, meta: {}, pendingRequests: {id: 4, deviceIeeeAddress: "0x0000012300000000", sendInProgress: false}, + scenes: new Map(), }, { deviceID: 5, @@ -6011,6 +6033,7 @@ describe("Controller", () => { _eventsCount: 0, meta: {}, pendingRequests: {id: 5, deviceIeeeAddress: "0x0000012300000000", sendInProgress: false}, + scenes: new Map(), }, { deviceID: 5, @@ -6027,6 +6050,7 @@ describe("Controller", () => { _eventsCount: 0, meta: {}, pendingRequests: {id: 6, deviceIeeeAddress: "0x0000012300000000", sendInProgress: false}, + scenes: new Map(), }, { deviceID: 1024, @@ -6043,6 +6067,7 @@ describe("Controller", () => { _eventsCount: 0, meta: {}, pendingRequests: {id: 11, deviceIeeeAddress: "0x0000012300000000", sendInProgress: false}, + scenes: new Map(), }, ], _ieeeAddr: "0x0000012300000000", @@ -6076,6 +6101,17 @@ describe("Controller", () => { outputClusters: [5, 25, 32, 4096], pendingRequests: {id: 1, deviceIeeeAddress: "0x000b57fffec6a5b2", sendInProgress: false}, profileID: 49246, + scenes: new Map([ + [ + "1_0", + { + name: "my_scene", + state: {genOnOff: {onOff: 1}}, + enhanced: true, + transitionTime: 1.2, + }, + ], + ]), }, ], _ieeeAddr: "0x000b57fffec6a5b2", @@ -6118,6 +6154,7 @@ describe("Controller", () => { _configuredReportings: [{cluster: 1, attrId: 0, minRepIntval: 1, maxRepIntval: 20, repChange: 2}], meta: {}, pendingRequests: {id: 1, deviceIeeeAddress: "0x0017880104e45517", sendInProgress: false}, + scenes: new Map(), }, { deviceID: 12, @@ -6134,6 +6171,7 @@ describe("Controller", () => { _eventsCount: 0, meta: {}, pendingRequests: {id: 2, deviceIeeeAddress: "0x0017880104e45517", sendInProgress: false}, + scenes: new Map(), }, ], _ieeeAddr: "0x0017880104e45517", @@ -6179,6 +6217,7 @@ describe("Controller", () => { _configuredReportings: [], meta: {}, pendingRequests: {id: 1, deviceIeeeAddress: "0x0017880104e45518", sendInProgress: false}, + scenes: new Map(), }, { deviceID: 12, @@ -6195,6 +6234,7 @@ describe("Controller", () => { _eventsCount: 0, meta: {}, pendingRequests: {id: 2, deviceIeeeAddress: "0x0017880104e45518", sendInProgress: false}, + scenes: new Map(), }, ], _ieeeAddr: "0x0017880104e45518", @@ -6243,6 +6283,17 @@ describe("Controller", () => { outputClusters: [5, 25, 32, 4096], pendingRequests: {id: 1, deviceIeeeAddress: "0x000b57fffec6a5b2", sendInProgress: false}, profileID: 49246, + scenes: new Map([ + [ + "1_0", + { + name: "my_scene", + state: {genOnOff: {onOff: 1}}, + enhanced: true, + transitionTime: 1.2, + }, + ], + ]), }, ], meta: {}, @@ -6271,9 +6322,9 @@ describe("Controller", () => { // @ts-expect-error: private property controller.databaseSave(); expect(databaseContents()).toStrictEqual( - '{"id":3,"type":"Router","ieeeAddr":"0x000b57fffec6a5b2","nwkAddr":40369,"manufId":4476,"manufName":"IKEA of Sweden","powerSource":"Mains (single phase)","modelId":"TRADFRI bulb E27 WS opal 980lm","epList":[1],"endpoints":{"1":{"profId":49246,"epId":1,"devId":544,"inClusterList":[0,3,4,5,6,8,768,2821,4096],"outClusterList":[5,25,32,4096],"clusters":{},"binds":[],"configuredReportings":[],"meta":{}}},"appVersion":17,"stackVersion":87,"hwVersion":1,"dateCode":"20170331","swBuildId":"1.2.217","zclVersion":1,"interviewCompleted":false,"interviewState":"FAILED","meta":{"reporting":1}}\n' + - '{"id":4,"type":"Router","ieeeAddr":"0x000b57fffec6a5b3","nwkAddr":40369,"manufId":4476,"manufName":"IKEA of Sweden","powerSource":"Mains (single phase)","modelId":"TRADFRI bulb E27 WS opal 980lm","epList":[1],"endpoints":{"1":{"profId":49246,"epId":1,"devId":544,"inClusterList":[0,3,4,5,6,8,768,2821,4096],"outClusterList":[5,25,32,4096],"clusters":{},"binds":[],"configuredReportings":[],"meta":{}}},"appVersion":17,"stackVersion":87,"hwVersion":1,"dateCode":"20170331","swBuildId":"1.2.217","zclVersion":1,"interviewCompleted":false,"interviewState":"PENDING","meta":{"reporting":1}}\n' + - '{"id":5,"type":"Coordinator","ieeeAddr":"0x0000012300000000","nwkAddr":0,"manufId":7,"epList":[1,2],"endpoints":{"1":{"profId":2,"epId":1,"devId":3,"inClusterList":[10],"outClusterList":[11],"clusters":{},"binds":[],"configuredReportings":[],"meta":{}},"2":{"profId":3,"epId":2,"devId":5,"inClusterList":[1],"outClusterList":[0],"clusters":{},"binds":[],"configuredReportings":[],"meta":{}}},"interviewCompleted":true,"interviewState":"SUCCESSFUL","meta":{}}', + '{"id":3,"type":"Router","ieeeAddr":"0x000b57fffec6a5b2","nwkAddr":40369,"manufId":4476,"manufName":"IKEA of Sweden","powerSource":"Mains (single phase)","modelId":"TRADFRI bulb E27 WS opal 980lm","epList":[1],"endpoints":{"1":{"profId":49246,"epId":1,"devId":544,"inClusterList":[0,3,4,5,6,8,768,2821,4096],"outClusterList":[5,25,32,4096],"clusters":{},"binds":[],"scenes":{},"configuredReportings":[],"meta":{}}},"appVersion":17,"stackVersion":87,"hwVersion":1,"dateCode":"20170331","swBuildId":"1.2.217","zclVersion":1,"interviewCompleted":false,"interviewState":"FAILED","meta":{"reporting":1}}\n' + + '{"id":4,"type":"Router","ieeeAddr":"0x000b57fffec6a5b3","nwkAddr":40369,"manufId":4476,"manufName":"IKEA of Sweden","powerSource":"Mains (single phase)","modelId":"TRADFRI bulb E27 WS opal 980lm","epList":[1],"endpoints":{"1":{"profId":49246,"epId":1,"devId":544,"inClusterList":[0,3,4,5,6,8,768,2821,4096],"outClusterList":[5,25,32,4096],"clusters":{},"binds":[],"scenes":{},"configuredReportings":[],"meta":{}}},"appVersion":17,"stackVersion":87,"hwVersion":1,"dateCode":"20170331","swBuildId":"1.2.217","zclVersion":1,"interviewCompleted":false,"interviewState":"PENDING","meta":{"reporting":1}}\n' + + '{"id":5,"type":"Coordinator","ieeeAddr":"0x0000012300000000","nwkAddr":0,"manufId":7,"epList":[1,2],"endpoints":{"1":{"profId":2,"epId":1,"devId":3,"inClusterList":[10],"outClusterList":[11],"clusters":{},"binds":[],"scenes":{},"configuredReportings":[],"meta":{}},"2":{"profId":3,"epId":2,"devId":5,"inClusterList":[1],"outClusterList":[0],"clusters":{},"binds":[],"scenes":{},"configuredReportings":[],"meta":{}}},"interviewCompleted":true,"interviewState":"SUCCESSFUL","meta":{}}', ); }); @@ -6913,6 +6964,7 @@ describe("Controller", () => { _events: {}, _eventsCount: 0, meta: {}, + scenes: new Map(), }, ], _events: {}, @@ -7623,6 +7675,7 @@ describe("Controller", () => { meta: {}, outputClusters: [], pendingRequests: {id: ZSpec.GP_ENDPOINT, deviceIeeeAddress: "0x00000000017171f8", sendInProgress: false}, + scenes: new Map(), }, ], _ieeeAddr: "0x00000000017171f8", @@ -7752,6 +7805,7 @@ describe("Controller", () => { meta: {}, outputClusters: [], pendingRequests: {id: ZSpec.GP_ENDPOINT, deviceIeeeAddress: "0x00000000017171f8", sendInProgress: false}, + scenes: new Map(), }, ], _ieeeAddr: "0x00000000017171f8", @@ -7799,6 +7853,7 @@ describe("Controller", () => { meta: {}, outputClusters: [], pendingRequests: {id: ZSpec.GP_ENDPOINT, deviceIeeeAddress: "0x00000000017171f8", sendInProgress: false}, + scenes: new Map(), }, ], _ieeeAddr: "0x00000000017171f8", From 3cee7ebeeac898829807d0cc50ce138fee3efa4a Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Thu, 11 Dec 2025 21:25:00 +0100 Subject: [PATCH 06/14] fix: tweak APIs for naming support on ZH side --- src/controller/model/endpoint.ts | 46 +++++++++++++++++++++++--------- src/controller/model/group.ts | 20 +++++++++----- 2 files changed, 46 insertions(+), 20 deletions(-) diff --git a/src/controller/model/endpoint.ts b/src/controller/model/endpoint.ts index dea569e422..93c4e2873f 100644 --- a/src/controller/model/endpoint.ts +++ b/src/controller/model/endpoint.ts @@ -1238,7 +1238,11 @@ export class Endpoint extends ZigbeeEntity { /** * Overwrite scenes cache with scene data received from Zigbee. */ - public syncSceneFromZigbee(payload: TClusterCommandResponsePayload<"genScenes", "viewRsp" | "enhancedViewRsp">, enhanced: boolean): Scene { + public syncSceneFromZigbee( + payload: TClusterCommandResponsePayload<"genScenes", "viewRsp" | "enhancedViewRsp">, + overrideSceneName: string | undefined, + enhanced: boolean, + ): Scene { if (payload.scenename === undefined || payload.extensionfieldsets === undefined || payload.transtime === undefined) { throw new Error("Missing data"); } @@ -1248,7 +1252,9 @@ export class Endpoint extends ZigbeeEntity { const state = ZclFrameConverter.sceneFromExtensionFieldSets(payload.extensionfieldsets); if (existing) { - if (!existing.name && payload.scenename && payload.scenename !== "\u0000") { + if (overrideSceneName) { + existing.name = overrideSceneName; + } else if (!existing.name && payload.scenename && payload.scenename !== "\u0000") { // keep existing name if present and incoming not present existing.name = payload.scenename; } @@ -1532,7 +1538,13 @@ export class Endpoint extends ZigbeeEntity { /** * Retrieve a scene from the device and return said scene after synchronizing the internal cache */ - public async viewScene(groupId: number, sceneId: number, enhanced: boolean, options?: Options): Promise { + public async viewScene( + groupId: number, + sceneId: number, + overrideSceneName: string | undefined, + enhanced: boolean, + options?: Options, + ): Promise { const optionsWithDefaults = this.getOptionsWithDefaults(options, true, Zcl.Direction.CLIENT_TO_SERVER, undefined); const viewFrame = await this.zclCommand( "genScenes", @@ -1545,14 +1557,16 @@ export class Endpoint extends ZigbeeEntity { ); if (viewFrame) { - return this.syncSceneFromZigbee(viewFrame.payload, enhanced); + return this.syncSceneFromZigbee(viewFrame.payload, overrideSceneName, enhanced); } throw new Error("Unable to view scene"); } /** - * Add a scene. Can be done without specifying extension field sets, to only "setup" a scene name/transition time (followed by a `storeScene`). + * Add a scene. + * Can be done without specifying extension field sets (empty array), to only "setup" a scene name/transition time (followed by a `storeScene`). + * If extension field sets is undefined, derive from current state. * Note: If name not supported by cluster, will be discarded by device (then returned as null string if requested). * Returns the scene as saved on the device. */ @@ -1561,19 +1575,25 @@ export class Endpoint extends ZigbeeEntity { sceneId: number, transTime: number, sceneName: string, - extensionFieldSets?: ZclTypes.ExtensionFieldSet[], + extensionFieldSets: ZclTypes.ExtensionFieldSet[] | undefined, options?: Options, ): Promise { assert(groupId >= 0x0000 && groupId <= 0xfff7, "Invalid group ID"); assert(groupId !== 0x0000 || sceneId !== 0x00, "Invalid group/scene ID combination (reserved)"); assert(sceneName.length > 16, "Scene name too long"); + if (extensionFieldSets === undefined) { + extensionFieldSets = []; + + // TODO: derive from current state + } + const optionsWithDefaults = this.getOptionsWithDefaults(options, true, Zcl.Direction.CLIENT_TO_SERVER, undefined); const enhanced = Number.isInteger(transTime); const frame = await this.zclCommand( "genScenes", enhanced ? "add" : "enhancedAdd", - {groupid: groupId, sceneid: sceneId, transtime: transTime, scenename: sceneName, extensionfieldsets: extensionFieldSets ?? []}, + {groupid: groupId, sceneid: sceneId, transtime: transTime, scenename: sceneName, extensionfieldsets: extensionFieldSets}, optionsWithDefaults, undefined, true, @@ -1582,7 +1602,7 @@ export class Endpoint extends ZigbeeEntity { if (frame) { // retrieve the scene from the device to get actual saved state by device - return await this.viewScene(groupId, sceneId, enhanced, options); + return await this.viewScene(groupId, sceneId, sceneName, enhanced, options); } throw new Error("No response received"); @@ -1592,7 +1612,7 @@ export class Endpoint extends ZigbeeEntity { * Store a scene, let the device determine the extension field sets based on its current state * Returns the scene as saved on the device. */ - public async storeScene(groupId: number, sceneId: number, options?: Options): Promise { + public async storeScene(groupId: number, sceneId: number, sceneName: string, options?: Options): Promise { assert(groupId >= 0x0000 && groupId <= 0xfff7, "Invalid group ID"); assert(groupId !== 0x0000 || sceneId !== 0x00, "Invalid group/scene ID combination (reserved)"); @@ -1611,7 +1631,7 @@ export class Endpoint extends ZigbeeEntity { // retrieve the scene from the device to get actual saved state by device const existing = this.scenes.get(`${sceneId}_${groupId}`); - return await this.viewScene(groupId, sceneId, existing?.enhanced ?? false, options); + return await this.viewScene(groupId, sceneId, sceneName, existing?.enhanced ?? false, options); } throw new Error("No response received"); @@ -1668,13 +1688,13 @@ export class Endpoint extends ZigbeeEntity { // only want the sync side-effect (outside the `this.scenes` loop) for (const [sceneId, sceneEnhanced] of toCheckSceneIds) { - await this.viewScene(toGroupId, sceneId, sceneEnhanced ?? false, options); + await this.viewScene(toGroupId, sceneId, undefined, sceneEnhanced ?? false, options); } } else { const existing = this.scenes.get(`${fromSceneId}_${fromGroupId}`); // only want the sync side-effect - await this.viewScene(toGroupId, toSceneId, existing?.enhanced ?? false, options); + await this.viewScene(toGroupId, toSceneId, undefined, existing?.enhanced ?? false, options); } } else { throw new Error("No response received"); @@ -1788,7 +1808,7 @@ export class Endpoint extends ZigbeeEntity { if (this.scenes.has(`${sceneId}_${groupId}`)) { // only want the sync side-effect // will enforce `enhanced: false`, since we don't know better here - await this.viewScene(groupId, sceneId, false, options); + await this.viewScene(groupId, sceneId, undefined, false, options); } } } diff --git a/src/controller/model/group.ts b/src/controller/model/group.ts index b31ee2a1d2..18a020650f 100644 --- a/src/controller/model/group.ts +++ b/src/controller/model/group.ts @@ -208,9 +208,13 @@ export class Group extends ZigbeeEntity { return this._members.includes(endpoint); } - public syncSceneFromZigbee(payload: TClusterCommandResponsePayload<"genScenes", "viewRsp" | "enhancedViewRsp">, enhanced: boolean): void { + public syncSceneFromZigbee( + payload: TClusterCommandResponsePayload<"genScenes", "viewRsp" | "enhancedViewRsp">, + overrideSceneName: string, + enhanced: boolean, + ): void { for (const member of this._members) { - member.syncSceneFromZigbee(payload, enhanced); + member.syncSceneFromZigbee(payload, overrideSceneName, enhanced); } } @@ -227,7 +231,8 @@ export class Group extends ZigbeeEntity { } /** - * Add a scene. Can be done without specifying extension field sets, to only "setup" a scene name/transition time (followed by a `storeScene`). + * Add a scene. + * Can be done without specifying extension field sets (empty array), to only "setup" a scene name/transition time (followed by a `storeScene`). * Note: If name not supported by cluster, will be discarded by devices (then returned as null string if requested). * Syncing is done locally to avoid many requests. */ @@ -235,7 +240,7 @@ export class Group extends ZigbeeEntity { sceneId: number, transTime: number, sceneName: string, - extensionFieldSets?: ExtensionFieldSet[], + extensionFieldSets: ExtensionFieldSet[], options?: Options, ): Promise { assert(sceneName.length > 16, "Scene name too long"); @@ -246,7 +251,7 @@ export class Group extends ZigbeeEntity { await this.command( "genScenes", enhanced ? "add" : "enhancedAdd", - {groupid: this.groupID, sceneid: sceneId, transtime: transTime, scenename: sceneName, extensionfieldsets: extensionFieldSets ?? []}, + {groupid: this.groupID, sceneid: sceneId, transtime: transTime, scenename: sceneName, extensionfieldsets: extensionFieldSets}, optionsWithDefaults, ); @@ -262,6 +267,7 @@ export class Group extends ZigbeeEntity { extensionfieldsets: extensionFieldSets, status: Zcl.Status.SUCCESS, }, + sceneName, enhanced, ); } else { @@ -273,13 +279,13 @@ export class Group extends ZigbeeEntity { * Store a scene, let the device determine the extension field sets based on its current state * Syncing is done locally to avoid many requests. */ - public async storeScene(sceneId: number, options?: Options): Promise { + public async storeScene(sceneId: number, sceneName: string, options?: Options): Promise { const optionsWithDefaults = this.getOptionsWithDefaults(options, Zcl.Direction.CLIENT_TO_SERVER, undefined); await this.command("genScenes", "store", {groupid: this.groupID, sceneid: sceneId}, optionsWithDefaults); // syncing from device's would be expensive for group - this.syncSceneFromState(sceneId, "", false, 0); + this.syncSceneFromState(sceneId, sceneName, false, 0); } /** From f8281ed8b81baa852458761f2eee6e4940c6fe3e Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Sat, 20 Dec 2025 19:00:22 +0100 Subject: [PATCH 07/14] fix: add ext field set building for addScene --- src/controller/model/endpoint.ts | 133 ++++++++++++++++++++++++++++--- 1 file changed, 124 insertions(+), 9 deletions(-) diff --git a/src/controller/model/endpoint.ts b/src/controller/model/endpoint.ts index 93c4e2873f..a9a20b2035 100644 --- a/src/controller/model/endpoint.ts +++ b/src/controller/model/endpoint.ts @@ -1331,7 +1331,7 @@ export class Endpoint extends ZigbeeEntity { if (this.clusters.barrierControl?.attributes !== undefined) { existing.state.barrierControl = { - barrierPosition: (this.clusters.barrierControl?.attributes.barrierPosition as number | undefined) ?? 0, + barrierPosition: (this.clusters.barrierControl.attributes.barrierPosition as number | undefined) ?? 0, }; } @@ -1355,31 +1355,31 @@ export class Endpoint extends ZigbeeEntity { existing.state.lightingColorCtrl = { currentX: (this.clusters.lightingColorCtrl.attributes.currentX as number | undefined) ?? 0, }; - const currentY = this.clusters.lightingColorCtrl?.attributes.currentY as number | undefined; + const currentY = this.clusters.lightingColorCtrl.attributes.currentY as number | undefined; if (currentY !== undefined) { existing.state.lightingColorCtrl.currentY = currentY; - const enhancedCurrentHue = this.clusters.lightingColorCtrl?.attributes.enhancedCurrentHue as number | undefined; + const enhancedCurrentHue = this.clusters.lightingColorCtrl.attributes.enhancedCurrentHue as number | undefined; if (enhancedCurrentHue !== undefined) { existing.state.lightingColorCtrl.enhancedCurrentHue = enhancedCurrentHue; - const currentSaturation = this.clusters.lightingColorCtrl?.attributes.currentSaturation as number | undefined; + const currentSaturation = this.clusters.lightingColorCtrl.attributes.currentSaturation as number | undefined; if (currentSaturation !== undefined) { existing.state.lightingColorCtrl.currentSaturation = currentSaturation; - const colorLoopActive = this.clusters.lightingColorCtrl?.attributes.colorLoopActive as number | undefined; + const colorLoopActive = this.clusters.lightingColorCtrl.attributes.colorLoopActive as number | undefined; if (colorLoopActive !== undefined) { existing.state.lightingColorCtrl.colorLoopActive = colorLoopActive; - const colorLoopDirection = this.clusters.lightingColorCtrl?.attributes.colorLoopDirection as number | undefined; + const colorLoopDirection = this.clusters.lightingColorCtrl.attributes.colorLoopDirection as number | undefined; if (colorLoopDirection !== undefined) { existing.state.lightingColorCtrl.colorLoopDirection = colorLoopDirection; - const colorLoopTime = this.clusters.lightingColorCtrl?.attributes.colorLoopTime as number | undefined; + const colorLoopTime = this.clusters.lightingColorCtrl.attributes.colorLoopTime as number | undefined; if (colorLoopTime !== undefined) { existing.state.lightingColorCtrl.colorLoopTime = colorLoopTime; - const colorTemperature = this.clusters.lightingColorCtrl?.attributes.colorTemperature as number | undefined; + const colorTemperature = this.clusters.lightingColorCtrl.attributes.colorTemperature as number | undefined; if (colorTemperature !== undefined) { existing.state.lightingColorCtrl.colorTemperature = colorTemperature; @@ -1585,7 +1585,122 @@ export class Endpoint extends ZigbeeEntity { if (extensionFieldSets === undefined) { extensionFieldSets = []; - // TODO: derive from current state + if (this.clusters.genOnOff?.attributes !== undefined) { + extensionFieldSets.push({ + clstId: Zcl.Clusters.genOnOff.ID, + len: 1, + extField: [(this.clusters.genOnOff.attributes.onOff as number | undefined) ?? 0], + }); + } + + if (this.clusters.genLevelCtrl?.attributes !== undefined) { + extensionFieldSets.push({ + clstId: Zcl.Clusters.genLevelCtrl.ID, + len: 1, + extField: [(this.clusters.genLevelCtrl.attributes.currentLevel as number | undefined) ?? 0], + }); + } + + if (this.clusters.closuresWindowCovering?.attributes !== undefined) { + const fieldSet: ZclTypes.ExtensionFieldSet = { + clstId: Zcl.Clusters.closuresWindowCovering.ID, + len: 1, + extField: [(this.clusters.closuresWindowCovering.attributes.currentPositionLiftPercentage as number | undefined) ?? 0], + }; + + const currentPositionTiltPercentage = this.clusters.closuresWindowCovering.attributes.currentPositionTiltPercentage as + | number + | undefined; + + if (currentPositionTiltPercentage !== undefined) { + fieldSet.extField.push(currentPositionTiltPercentage); + fieldSet.len += 1; + } + + extensionFieldSets.push(fieldSet); + } + + if (this.clusters.barrierControl?.attributes !== undefined) { + extensionFieldSets.push({ + clstId: Zcl.Clusters.barrierControl.ID, + len: 1, + extField: [(this.clusters.barrierControl.attributes.barrierPosition as number | undefined) ?? 0], + }); + } + + if (this.clusters.hvacThermostat?.attributes !== undefined) { + const fieldSet: ZclTypes.ExtensionFieldSet = { + clstId: Zcl.Clusters.hvacThermostat.ID, + len: 2, + extField: [(this.clusters.hvacThermostat.attributes.occupiedCoolingSetpoint as number | undefined) ?? 0], + }; + const occupiedHeatingSetpoint = this.clusters.hvacThermostat.attributes.occupiedHeatingSetpoint as number | undefined; + + if (occupiedHeatingSetpoint !== undefined) { + fieldSet.extField.push(occupiedHeatingSetpoint); + fieldSet.len += 2; + const systemMode = this.clusters.hvacThermostat.attributes.systemMode as number | undefined; + + if (systemMode !== undefined) { + fieldSet.extField.push(systemMode); + fieldSet.len += 1; + } + } + + extensionFieldSets.push(fieldSet); + } + + if (this.clusters.lightingColorCtrl?.attributes !== undefined) { + const fieldSet: ZclTypes.ExtensionFieldSet = { + clstId: Zcl.Clusters.lightingColorCtrl.ID, + len: 2, + extField: [(this.clusters.lightingColorCtrl.attributes.currentX as number | undefined) ?? 0], + }; + const currentY = this.clusters.lightingColorCtrl.attributes.currentY as number | undefined; + + if (currentY !== undefined) { + fieldSet.extField.push(currentY); + fieldSet.len += 2; + const enhancedCurrentHue = this.clusters.lightingColorCtrl.attributes.enhancedCurrentHue as number | undefined; + + if (enhancedCurrentHue !== undefined) { + fieldSet.extField.push(enhancedCurrentHue); + fieldSet.len += 2; + const currentSaturation = this.clusters.lightingColorCtrl.attributes.currentSaturation as number | undefined; + + if (currentSaturation !== undefined) { + fieldSet.extField.push(currentSaturation); + fieldSet.len += 1; + const colorLoopActive = this.clusters.lightingColorCtrl.attributes.colorLoopActive as number | undefined; + + if (colorLoopActive !== undefined) { + fieldSet.extField.push(colorLoopActive); + fieldSet.len += 1; + const colorLoopDirection = this.clusters.lightingColorCtrl.attributes.colorLoopDirection as number | undefined; + + if (colorLoopDirection !== undefined) { + fieldSet.extField.push(colorLoopDirection); + fieldSet.len += 1; + const colorLoopTime = this.clusters.lightingColorCtrl.attributes.colorLoopTime as number | undefined; + + if (colorLoopTime !== undefined) { + fieldSet.extField.push(colorLoopTime); + fieldSet.len += 2; + const colorTemperature = this.clusters.lightingColorCtrl.attributes.colorTemperature as number | undefined; + + if (colorTemperature !== undefined) { + fieldSet.extField.push(colorTemperature); + fieldSet.len += 2; + } + } + } + } + } + } + } + + extensionFieldSets.push(fieldSet); + } } const optionsWithDefaults = this.getOptionsWithDefaults(options, true, Zcl.Direction.CLIENT_TO_SERVER, undefined); From 38e0a46c19b7274ccf24d5909fc016ce4a8833a8 Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Tue, 30 Dec 2025 18:10:47 +0100 Subject: [PATCH 08/14] fix: validate transTime --- src/controller/model/endpoint.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/controller/model/endpoint.ts b/src/controller/model/endpoint.ts index a9a20b2035..399f7efecc 100644 --- a/src/controller/model/endpoint.ts +++ b/src/controller/model/endpoint.ts @@ -1580,6 +1580,7 @@ export class Endpoint extends ZigbeeEntity { ): Promise { assert(groupId >= 0x0000 && groupId <= 0xfff7, "Invalid group ID"); assert(groupId !== 0x0000 || sceneId !== 0x00, "Invalid group/scene ID combination (reserved)"); + assert(Number.isFinite(transTime), "Transition time must be integer or float"); assert(sceneName.length > 16, "Scene name too long"); if (extensionFieldSets === undefined) { @@ -1822,6 +1823,7 @@ export class Endpoint extends ZigbeeEntity { */ public async recallScene(groupId: number, sceneId: number, transTime: number, options?: Options): Promise { assert(groupId >= 0x0000 && groupId <= 0xfff7, "Invalid group ID"); + assert(Number.isFinite(transTime), "Transition time must be integer or float"); const optionsWithDefaults = this.getOptionsWithDefaults(options, true, Zcl.Direction.CLIENT_TO_SERVER, undefined); From 542504c682cbc40d6678254e0a2a63e428a90567 Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Tue, 30 Dec 2025 19:27:55 +0100 Subject: [PATCH 09/14] fix: cleanup --- src/controller/helpers/scenes.ts | 247 +++++++++++++++ src/controller/helpers/zclFrameConverter.ts | 112 +------ src/controller/model/device.ts | 24 +- src/controller/model/endpoint.ts | 335 +++++++------------- src/controller/model/group.ts | 5 +- src/controller/tstype.ts | 18 ++ src/zspec/zcl/buffaloZcl.ts | 2 +- src/zspec/zcl/definition/tstype.ts | 3 +- 8 files changed, 406 insertions(+), 340 deletions(-) create mode 100644 src/controller/helpers/scenes.ts diff --git a/src/controller/helpers/scenes.ts b/src/controller/helpers/scenes.ts new file mode 100644 index 0000000000..5ca916f0fe --- /dev/null +++ b/src/controller/helpers/scenes.ts @@ -0,0 +1,247 @@ +import {Zcl} from "src"; +import type {ExtensionFieldSet} from "../../zspec/zcl/definition/tstype"; +import type {Clusters, Scene} from "../model/endpoint"; +import type {Immutable, ImmutableArray} from "../tstype"; + +/** + * Deep-clone a Scene object + */ +export function cloneScene(existing: Scene): Scene { + const clonedScene: Scene = {name: existing.name, state: {}, enhanced: existing.enhanced, transitionTime: existing.transitionTime}; + + for (const cluster in existing.state) { + // @ts-expect-error dynamic cloning + clonedScene.state[cluster as keyof typeof existing.state] = { + ...existing.state[cluster as keyof typeof existing.state], + }; + } + + return clonedScene; +} + +export function makeSceneState(extFieldSets: ImmutableArray): Scene["state"] { + const sceneState: Scene["state"] = {}; + + // first in list is always expected, after can be omitted but has to remain sequentially valid, hence stop on first undefined + // we expect that if a cluster is present, at least one value should be too (though use fallback just in case) + for (const set of extFieldSets) { + const clusterId = set.clstId; + + switch (clusterId) { + case Zcl.Clusters.genOnOff.ID: { + sceneState.genOnOff = {onOff: set.extField[0] ?? 0}; + + break; + } + + case Zcl.Clusters.genLevelCtrl.ID: { + sceneState.genLevelCtrl = {currentLevel: set.extField[0] ?? 0}; + + break; + } + + case Zcl.Clusters.closuresWindowCovering.ID: { + const state: Scene["state"]["closuresWindowCovering"] = {currentPositionLiftPercentage: set.extField[0] ?? 0}; + const currentPositionTiltPercentage = set.extField[1]; + + if (currentPositionTiltPercentage !== undefined) { + state.currentPositionTiltPercentage = currentPositionTiltPercentage; + } + + sceneState.closuresWindowCovering = state; + + break; + } + + case Zcl.Clusters.barrierControl.ID: { + sceneState.barrierControl = {barrierPosition: set.extField[0] ?? 0}; + + break; + } + + case Zcl.Clusters.hvacThermostat.ID: { + const state: Scene["state"]["hvacThermostat"] = {occupiedCoolingSetpoint: set.extField[0] ?? 0}; + const occupiedHeatingSetpoint = set.extField[1]; + + if (occupiedHeatingSetpoint !== undefined) { + state.occupiedHeatingSetpoint = occupiedHeatingSetpoint; + const systemMode = set.extField[2]; + + if (systemMode !== undefined) { + state.systemMode = systemMode; + } + } + + sceneState.hvacThermostat = state; + + break; + } + + case Zcl.Clusters.lightingColorCtrl.ID: { + const state: Scene["state"]["lightingColorCtrl"] = {currentX: set.extField[0] ?? 0}; + const currentY = set.extField[1]; + + if (currentY !== undefined) { + state.currentY = currentY; + const enhancedCurrentHue = set.extField[2]; + + if (enhancedCurrentHue !== undefined) { + state.enhancedCurrentHue = enhancedCurrentHue; + const currentSaturation = set.extField[3]; + + if (currentSaturation !== undefined) { + state.currentSaturation = currentSaturation; + const colorLoopActive = set.extField[4]; + + if (colorLoopActive !== undefined) { + state.colorLoopActive = colorLoopActive; + const colorLoopDirection = set.extField[5]; + + if (colorLoopDirection !== undefined) { + state.colorLoopDirection = colorLoopDirection; + const colorLoopTime = set.extField[6]; + + if (colorLoopTime !== undefined) { + state.colorLoopTime = colorLoopTime; + const colorTemperature = set.extField[7]; + + if (colorTemperature !== undefined) { + state.colorTemperature = colorTemperature; + } + } + } + } + } + } + } + + sceneState.lightingColorCtrl = state; + + break; + } + } + } + + return sceneState; +} + +export function makeExtensionFieldSets(clusters: Immutable): ExtensionFieldSet[] { + const extensionFieldSets: ExtensionFieldSet[] = []; + + if (clusters.genOnOff?.attributes !== undefined) { + extensionFieldSets.push({ + clstId: Zcl.Clusters.genOnOff.ID, + len: 1, + extField: [clusters.genOnOff.attributes.onOff ?? 0], + }); + } + + if (clusters.genLevelCtrl?.attributes !== undefined) { + extensionFieldSets.push({ + clstId: Zcl.Clusters.genLevelCtrl.ID, + len: 1, + extField: [clusters.genLevelCtrl.attributes.currentLevel ?? 0], + }); + } + + if (clusters.closuresWindowCovering?.attributes !== undefined) { + const fieldSet: ExtensionFieldSet = { + clstId: Zcl.Clusters.closuresWindowCovering.ID, + len: 1, + extField: [clusters.closuresWindowCovering.attributes.currentPositionLiftPercentage ?? 0], + }; + + const currentPositionTiltPercentage = clusters.closuresWindowCovering.attributes.currentPositionTiltPercentage; + + if (currentPositionTiltPercentage !== undefined) { + fieldSet.extField.push(currentPositionTiltPercentage); + fieldSet.len += 1; + } + + extensionFieldSets.push(fieldSet); + } + + if (clusters.barrierControl?.attributes !== undefined) { + extensionFieldSets.push({ + clstId: Zcl.Clusters.barrierControl.ID, + len: 1, + extField: [clusters.barrierControl.attributes.barrierPosition ?? 0], + }); + } + + if (clusters.hvacThermostat?.attributes !== undefined) { + const fieldSet: ExtensionFieldSet = { + clstId: Zcl.Clusters.hvacThermostat.ID, + len: 2, + extField: [clusters.hvacThermostat.attributes.occupiedCoolingSetpoint ?? 0], + }; + const occupiedHeatingSetpoint = clusters.hvacThermostat.attributes.occupiedHeatingSetpoint; + + if (occupiedHeatingSetpoint !== undefined) { + fieldSet.extField.push(occupiedHeatingSetpoint); + fieldSet.len += 2; + const systemMode = clusters.hvacThermostat.attributes.systemMode; + + if (systemMode !== undefined) { + fieldSet.extField.push(systemMode); + fieldSet.len += 1; + } + } + + extensionFieldSets.push(fieldSet); + } + + if (clusters.lightingColorCtrl?.attributes !== undefined) { + const fieldSet: ExtensionFieldSet = { + clstId: Zcl.Clusters.lightingColorCtrl.ID, + len: 2, + extField: [clusters.lightingColorCtrl.attributes.currentX ?? 0], + }; + const currentY = clusters.lightingColorCtrl.attributes.currentY; + + if (currentY !== undefined) { + fieldSet.extField.push(currentY); + fieldSet.len += 2; + const enhancedCurrentHue = clusters.lightingColorCtrl.attributes.enhancedCurrentHue; + + if (enhancedCurrentHue !== undefined) { + fieldSet.extField.push(enhancedCurrentHue); + fieldSet.len += 2; + const currentSaturation = clusters.lightingColorCtrl.attributes.currentSaturation; + + if (currentSaturation !== undefined) { + fieldSet.extField.push(currentSaturation); + fieldSet.len += 1; + const colorLoopActive = clusters.lightingColorCtrl.attributes.colorLoopActive; + + if (colorLoopActive !== undefined) { + fieldSet.extField.push(colorLoopActive); + fieldSet.len += 1; + const colorLoopDirection = clusters.lightingColorCtrl.attributes.colorLoopDirection; + + if (colorLoopDirection !== undefined) { + fieldSet.extField.push(colorLoopDirection); + fieldSet.len += 1; + const colorLoopTime = clusters.lightingColorCtrl.attributes.colorLoopTime; + + if (colorLoopTime !== undefined) { + fieldSet.extField.push(colorLoopTime); + fieldSet.len += 2; + const colorTemperature = clusters.lightingColorCtrl.attributes.colorTemperature; + + if (colorTemperature !== undefined) { + fieldSet.extField.push(colorTemperature); + fieldSet.len += 2; + } + } + } + } + } + } + } + + extensionFieldSets.push(fieldSet); + } + + return extensionFieldSets; +} diff --git a/src/controller/helpers/zclFrameConverter.ts b/src/controller/helpers/zclFrameConverter.ts index 30e06db28e..fb7b50b14d 100644 --- a/src/controller/helpers/zclFrameConverter.ts +++ b/src/controller/helpers/zclFrameConverter.ts @@ -1,8 +1,7 @@ import {logger} from "../../utils/logger"; import * as Zcl from "../../zspec/zcl"; import type {TFoundation} from "../../zspec/zcl/definition/clusters-types"; -import type {Cluster, CustomClusters, ExtensionFieldSet} from "../../zspec/zcl/definition/tstype"; -import type {Scene} from "../model/endpoint"; +import type {Cluster, CustomClusters} from "../../zspec/zcl/definition/tstype"; import type {ClusterOrRawWriteAttributes, TCustomCluster} from "../tstype"; const NS = "zh:controller:zcl"; @@ -62,111 +61,4 @@ function attributeList(frame: Zcl.Frame, deviceManufacturerID: number | undefine return payload; } -// to/from: first in list is always expected, after can be omitted but has to remain sequentially valid, hence stop on first undefined -// we expect that if a cluster is present, at least one value should be too (though use fallback just in case) -// XXX: use non-value instead of `0` for fallback? (edge-case) -function sceneFromExtensionFieldSets(extFieldSets: ExtensionFieldSet[]): Scene["state"] { - const sceneState: Scene["state"] = {}; - - for (const set of extFieldSets) { - const clusterId = set.clstId; - - switch (clusterId) { - case Zcl.Clusters.genOnOff.ID: { - sceneState.genOnOff = {onOff: (set.extField[0] as number | undefined) ?? 0}; - - break; - } - - case Zcl.Clusters.genLevelCtrl.ID: { - sceneState.genLevelCtrl = {currentLevel: (set.extField[0] as number | undefined) ?? 0}; - - break; - } - - case Zcl.Clusters.closuresWindowCovering.ID: { - const state: Scene["state"]["closuresWindowCovering"] = {currentPositionLiftPercentage: (set.extField[0] as number | undefined) ?? 0}; - const currentPositionTiltPercentage = set.extField[1] as number | undefined; - - if (currentPositionTiltPercentage !== undefined) { - state.currentPositionTiltPercentage = currentPositionTiltPercentage; - } - - sceneState.closuresWindowCovering = state; - - break; - } - - case Zcl.Clusters.barrierControl.ID: { - sceneState.barrierControl = {barrierPosition: (set.extField[0] as number | undefined) ?? 0}; - - break; - } - - case Zcl.Clusters.hvacThermostat.ID: { - const state: Scene["state"]["hvacThermostat"] = {occupiedCoolingSetpoint: (set.extField[0] as number | undefined) ?? 0}; - const occupiedHeatingSetpoint = set.extField[1] as number | undefined; - - if (occupiedHeatingSetpoint !== undefined) { - state.occupiedHeatingSetpoint = occupiedHeatingSetpoint; - const systemMode = set.extField[2] as number | undefined; - - if (systemMode !== undefined) { - state.systemMode = systemMode; - } - } - - sceneState.hvacThermostat = state; - - break; - } - - case Zcl.Clusters.lightingColorCtrl.ID: { - const state: Scene["state"]["lightingColorCtrl"] = {currentX: (set.extField[0] as number | undefined) ?? 0}; - const currentY = set.extField[1] as number | undefined; - - if (currentY !== undefined) { - state.currentY = currentY; - const enhancedCurrentHue = set.extField[2] as number | undefined; - - if (enhancedCurrentHue !== undefined) { - state.enhancedCurrentHue = enhancedCurrentHue; - const currentSaturation = set.extField[3] as number | undefined; - - if (currentSaturation !== undefined) { - state.currentSaturation = currentSaturation; - const colorLoopActive = set.extField[4] as number | undefined; - - if (colorLoopActive !== undefined) { - state.colorLoopActive = colorLoopActive; - const colorLoopDirection = set.extField[5] as number | undefined; - - if (colorLoopDirection !== undefined) { - state.colorLoopDirection = colorLoopDirection; - const colorLoopTime = set.extField[6] as number | undefined; - - if (colorLoopTime !== undefined) { - state.colorLoopTime = colorLoopTime; - const colorTemperature = set.extField[7] as number | undefined; - - if (colorTemperature !== undefined) { - state.colorTemperature = colorTemperature; - } - } - } - } - } - } - } - - sceneState.lightingColorCtrl = state; - - break; - } - } - } - - return sceneState; -} - -export {attributeKeyValue, attributeList, sceneFromExtensionFieldSets}; +export {attributeKeyValue, attributeList}; diff --git a/src/controller/model/device.ts b/src/controller/model/device.ts index e66c77a186..690f95f03c 100755 --- a/src/controller/model/device.ts +++ b/src/controller/model/device.ts @@ -26,7 +26,7 @@ import type { OtaUpdateAvailableResult, ZigbeeOtaImageMeta, } from "../tstype"; -import Endpoint, {type BindInternal} from "./endpoint"; +import Endpoint, {type BindInternal, type Clusters} from "./endpoint"; import Entity from "./entity"; const NS = "zh:controller:device"; @@ -353,25 +353,21 @@ export class Device extends Entity { if (frame.header.isGlobal) { // Response to read requests if (frame.command.name === "read" && !this._customReadResponse?.(frame, endpoint)) { - const attributes: {[s: string]: KeyValue} = { - ...endpoint.clusters, - }; - - const isTimeReadRequest = dataPayload.clusterID === Zcl.Clusters.genTime.ID; - if (isTimeReadRequest) { - attributes.genTime = { - attributes: timeService.getTimeClusterAttributes(), - }; - } + const cluster: Clusters[string] = + dataPayload.clusterID === Zcl.Clusters.genTime.ID + ? { + attributes: timeService.getTimeClusterAttributes(), + } + : endpoint.clusters[frame.cluster.name]; - if (frame.cluster.name in attributes) { + if (cluster) { const response: KeyValue = {}; for (const entry of frame.payload) { const name = frame.cluster.getAttribute(entry.attrId)?.name; - if (name && name in attributes[frame.cluster.name].attributes) { - response[name] = attributes[frame.cluster.name].attributes[name]; + if (name && name in cluster.attributes) { + response[name] = cluster.attributes[name]; } } diff --git a/src/controller/model/endpoint.ts b/src/controller/model/endpoint.ts index 399f7efecc..32df58030d 100644 --- a/src/controller/model/endpoint.ts +++ b/src/controller/model/endpoint.ts @@ -10,6 +10,7 @@ import type * as ZclTypes from "../../zspec/zcl/definition/tstype"; import * as Zdo from "../../zspec/zdo"; import Request from "../helpers/request"; import RequestQueue from "../helpers/requestQueue"; +import {makeExtensionFieldSets, makeSceneState} from "../helpers/scenes"; import * as ZclFrameConverter from "../helpers/zclFrameConverter"; import zclTransactionSequenceNumber from "../helpers/zclTransactionSequenceNumber"; import type { @@ -67,10 +68,38 @@ interface OptionsWithDefaults extends Options { writeUndiv: boolean; } -interface Clusters { - [cluster: string]: { - attributes: {[attribute: string]: number | string}; - }; +interface AttributesRecord { + [attribute: string]: number | string | undefined; +} + +interface WithGenericAttributes { + attributes: AttributesRecord & Partial; +} + +// Provides overrides for Scene-related attributes for ease of use and better validation +export interface Clusters extends Record | undefined> { + genOnOff?: WithGenericAttributes<{onOff: number | undefined}>; + genLevelCtrl?: WithGenericAttributes<{currentLevel: number | undefined}>; + closuresWindowCovering?: WithGenericAttributes<{ + currentPositionLiftPercentage: number | undefined; + currentPositionTiltPercentage: number | undefined; + }>; + barrierControl?: WithGenericAttributes<{barrierPosition: number | undefined}>; + hvacThermostat?: WithGenericAttributes<{ + occupiedCoolingSetpoint: number | undefined; + occupiedHeatingSetpoint: number | undefined; + systemMode: number | undefined; + }>; + lightingColorCtrl?: WithGenericAttributes<{ + currentX: number | undefined; + currentY: number | undefined; + enhancedCurrentHue: number | undefined; + currentSaturation: number | undefined; + colorLoopActive: number | undefined; + colorLoopDirection: number | undefined; + colorLoopTime: number | undefined; + colorTemperature: number | undefined; + }>; } export interface Scene { @@ -422,25 +451,29 @@ export class Endpoint extends ZigbeeEntity { public saveClusterAttributeKeyValue(clusterKey: number | string, list: KeyValue): void { const cluster = this.getCluster(clusterKey); + let clusterEntry = this.clusters[cluster.name]; - if (!this.clusters[cluster.name]) { - this.clusters[cluster.name] = {attributes: {}}; + if (!clusterEntry) { + clusterEntry = {attributes: {}}; + this.clusters[cluster.name] = clusterEntry; } for (const attribute in list) { - this.clusters[cluster.name].attributes[attribute] = list[attribute]; + clusterEntry.attributes[attribute] = list[attribute]; } } public getClusterAttributeValue(clusterKey: number | string, attributeKey: number | string): number | string | undefined { const cluster = this.getCluster(clusterKey); - if (this.clusters[cluster.name] && this.clusters[cluster.name].attributes) { + const clusterEntry = this.clusters[cluster.name]; + + if (clusterEntry) { // XXX: used to throw (behavior changed in #1455) const attribute = cluster.getAttribute(attributeKey); if (attribute) { - return this.clusters[cluster.name].attributes[attribute.name]; + return clusterEntry.attributes[attribute.name]; } } @@ -1249,7 +1282,7 @@ export class Endpoint extends ZigbeeEntity { const sceneKey = `${payload.sceneid}_${payload.groupid}`; const existing = this.scenes.get(sceneKey); - const state = ZclFrameConverter.sceneFromExtensionFieldSets(payload.extensionfieldsets); + const state = makeSceneState(payload.extensionfieldsets); if (existing) { if (overrideSceneName) { @@ -1309,41 +1342,40 @@ export class Endpoint extends ZigbeeEntity { } if (this.clusters.genOnOff?.attributes !== undefined) { - existing.state.genOnOff = {onOff: (this.clusters.genOnOff.attributes.onOff as number | undefined) ?? 0}; + existing.state.genOnOff = {onOff: this.clusters.genOnOff.attributes.onOff ?? 0}; } if (this.clusters.genLevelCtrl?.attributes !== undefined) { - existing.state.genLevelCtrl = {currentLevel: (this.clusters.genLevelCtrl.attributes.currentLevel as number | undefined) ?? 0}; + existing.state.genLevelCtrl = {currentLevel: this.clusters.genLevelCtrl.attributes.currentLevel ?? 0}; } if (this.clusters.closuresWindowCovering?.attributes !== undefined) { existing.state.closuresWindowCovering = { - currentPositionLiftPercentage: - (this.clusters.closuresWindowCovering.attributes.currentPositionLiftPercentage as number | undefined) ?? 0, + currentPositionLiftPercentage: this.clusters.closuresWindowCovering.attributes.currentPositionLiftPercentage ?? 0, }; - const currentPositionTiltPercentage = this.clusters.closuresWindowCovering.attributes.currentPositionTiltPercentage as number | undefined; + const currentPositionTiltPercentage = this.clusters.closuresWindowCovering.attributes.currentPositionTiltPercentage; if (currentPositionTiltPercentage !== undefined) { - existing.state.closuresWindowCovering.currentPositionTiltPercentage = currentPositionTiltPercentage as number; + existing.state.closuresWindowCovering.currentPositionTiltPercentage = currentPositionTiltPercentage; } } if (this.clusters.barrierControl?.attributes !== undefined) { existing.state.barrierControl = { - barrierPosition: (this.clusters.barrierControl.attributes.barrierPosition as number | undefined) ?? 0, + barrierPosition: this.clusters.barrierControl.attributes.barrierPosition ?? 0, }; } if (this.clusters.hvacThermostat?.attributes !== undefined) { existing.state.hvacThermostat = { - occupiedCoolingSetpoint: (this.clusters.hvacThermostat.attributes.occupiedCoolingSetpoint as number | undefined) ?? 0, + occupiedCoolingSetpoint: this.clusters.hvacThermostat.attributes.occupiedCoolingSetpoint ?? 0, }; - const occupiedHeatingSetpoint = this.clusters.hvacThermostat.attributes.occupiedHeatingSetpoint as number | undefined; + const occupiedHeatingSetpoint = this.clusters.hvacThermostat.attributes.occupiedHeatingSetpoint; if (occupiedHeatingSetpoint !== undefined) { existing.state.hvacThermostat.occupiedHeatingSetpoint = occupiedHeatingSetpoint; - const systemMode = this.clusters.hvacThermostat.attributes.systemMode as number | undefined; + const systemMode = this.clusters.hvacThermostat.attributes.systemMode; if (systemMode !== undefined) { existing.state.hvacThermostat.systemMode = systemMode; @@ -1353,33 +1385,33 @@ export class Endpoint extends ZigbeeEntity { if (this.clusters.lightingColorCtrl?.attributes !== undefined) { existing.state.lightingColorCtrl = { - currentX: (this.clusters.lightingColorCtrl.attributes.currentX as number | undefined) ?? 0, + currentX: this.clusters.lightingColorCtrl.attributes.currentX ?? 0, }; - const currentY = this.clusters.lightingColorCtrl.attributes.currentY as number | undefined; + const currentY = this.clusters.lightingColorCtrl.attributes.currentY; if (currentY !== undefined) { existing.state.lightingColorCtrl.currentY = currentY; - const enhancedCurrentHue = this.clusters.lightingColorCtrl.attributes.enhancedCurrentHue as number | undefined; + const enhancedCurrentHue = this.clusters.lightingColorCtrl.attributes.enhancedCurrentHue; if (enhancedCurrentHue !== undefined) { existing.state.lightingColorCtrl.enhancedCurrentHue = enhancedCurrentHue; - const currentSaturation = this.clusters.lightingColorCtrl.attributes.currentSaturation as number | undefined; + const currentSaturation = this.clusters.lightingColorCtrl.attributes.currentSaturation; if (currentSaturation !== undefined) { existing.state.lightingColorCtrl.currentSaturation = currentSaturation; - const colorLoopActive = this.clusters.lightingColorCtrl.attributes.colorLoopActive as number | undefined; + const colorLoopActive = this.clusters.lightingColorCtrl.attributes.colorLoopActive; if (colorLoopActive !== undefined) { existing.state.lightingColorCtrl.colorLoopActive = colorLoopActive; - const colorLoopDirection = this.clusters.lightingColorCtrl.attributes.colorLoopDirection as number | undefined; + const colorLoopDirection = this.clusters.lightingColorCtrl.attributes.colorLoopDirection; if (colorLoopDirection !== undefined) { existing.state.lightingColorCtrl.colorLoopDirection = colorLoopDirection; - const colorLoopTime = this.clusters.lightingColorCtrl.attributes.colorLoopTime as number | undefined; + const colorLoopTime = this.clusters.lightingColorCtrl.attributes.colorLoopTime; if (colorLoopTime !== undefined) { existing.state.lightingColorCtrl.colorLoopTime = colorLoopTime; - const colorTemperature = this.clusters.lightingColorCtrl.attributes.colorTemperature as number | undefined; + const colorTemperature = this.clusters.lightingColorCtrl.attributes.colorTemperature; if (colorTemperature !== undefined) { existing.state.lightingColorCtrl.colorTemperature = colorTemperature; @@ -1406,109 +1438,121 @@ export class Endpoint extends ZigbeeEntity { } if (scene.state.genOnOff !== undefined) { - const onOff = (scene.state.genOnOff.onOff as number | undefined) ?? 0; + const onOff = scene.state.genOnOff.onOff ?? 0; + let genOnOff = this.clusters.genOnOff; - if (this.clusters.genOnOff?.attributes === undefined) { - this.clusters.genOnOff.attributes = {onOff}; - } else { - this.clusters.genOnOff.attributes.onOff = onOff; + if (!genOnOff) { + genOnOff = {attributes: {}}; + this.clusters.genOnOff = genOnOff; } + + genOnOff.attributes.onOff = onOff; } if (scene.state.genLevelCtrl !== undefined) { - const currentLevel = (scene.state.genLevelCtrl.currentLevel as number | undefined) ?? 0; + const currentLevel = scene.state.genLevelCtrl.currentLevel ?? 0; + let genLevelCtrl = this.clusters.genLevelCtrl; - if (this.clusters.genLevelCtrl?.attributes === undefined) { - this.clusters.genLevelCtrl.attributes = {currentLevel}; - } else { - this.clusters.genLevelCtrl.attributes.currentLevel = currentLevel; + if (!genLevelCtrl) { + genLevelCtrl = {attributes: {}}; + this.clusters.genLevelCtrl = genLevelCtrl; } + + genLevelCtrl.attributes.currentLevel = currentLevel; } if (scene.state.closuresWindowCovering !== undefined) { - const currentPositionLiftPercentage = (scene.state.closuresWindowCovering.currentPositionLiftPercentage as number | undefined) ?? 0; + const currentPositionLiftPercentage = scene.state.closuresWindowCovering.currentPositionLiftPercentage ?? 0; + let closuresWindowCovering = this.clusters.closuresWindowCovering; - if (this.clusters.closuresWindowCovering?.attributes === undefined) { - this.clusters.closuresWindowCovering.attributes = {currentPositionLiftPercentage}; - } else { - this.clusters.closuresWindowCovering.attributes.currentPositionLiftPercentage = currentPositionLiftPercentage; + if (!closuresWindowCovering) { + closuresWindowCovering = {attributes: {}}; + this.clusters.closuresWindowCovering = closuresWindowCovering; } - const currentPositionTiltPercentage = scene.state.closuresWindowCovering.currentPositionTiltPercentage as number | undefined; + closuresWindowCovering.attributes.currentPositionLiftPercentage = currentPositionLiftPercentage; + + const currentPositionTiltPercentage = scene.state.closuresWindowCovering.currentPositionTiltPercentage; if (currentPositionTiltPercentage !== undefined) { - this.clusters.closuresWindowCovering.attributes.currentPositionTiltPercentage = currentPositionTiltPercentage; + closuresWindowCovering.attributes.currentPositionTiltPercentage = currentPositionTiltPercentage; } } if (scene.state.barrierControl !== undefined) { - const barrierPosition = (scene.state.barrierControl.barrierPosition as number | undefined) ?? 0; + const barrierPosition = scene.state.barrierControl.barrierPosition ?? 0; + let barrierControl = this.clusters.barrierControl; - if (this.clusters.barrierControl?.attributes === undefined) { - this.clusters.barrierControl.attributes = {barrierPosition}; - } else { - this.clusters.barrierControl.attributes.barrierPosition = barrierPosition; + if (!barrierControl) { + barrierControl = {attributes: {}}; + this.clusters.barrierControl = barrierControl; } + + barrierControl.attributes.barrierPosition = barrierPosition; } if (scene.state.hvacThermostat !== undefined) { - const occupiedCoolingSetpoint = (scene.state.hvacThermostat.occupiedCoolingSetpoint as number | undefined) ?? 0; + const occupiedCoolingSetpoint = scene.state.hvacThermostat.occupiedCoolingSetpoint ?? 0; + let hvacThermostat = this.clusters.hvacThermostat; - if (this.clusters.hvacThermostat?.attributes === undefined) { - this.clusters.hvacThermostat.attributes = {occupiedCoolingSetpoint}; - } else { - this.clusters.hvacThermostat.attributes.occupiedCoolingSetpoint = occupiedCoolingSetpoint; + if (!hvacThermostat) { + hvacThermostat = {attributes: {}}; + this.clusters.hvacThermostat = hvacThermostat; } - const occupiedHeatingSetpoint = scene.state.hvacThermostat.occupiedHeatingSetpoint as number | undefined; + hvacThermostat.attributes.occupiedCoolingSetpoint = occupiedCoolingSetpoint; + + const occupiedHeatingSetpoint = scene.state.hvacThermostat.occupiedHeatingSetpoint; if (occupiedHeatingSetpoint !== undefined) { - this.clusters.hvacThermostat.attributes.occupiedHeatingSetpoint = occupiedHeatingSetpoint; - const systemMode = scene.state.hvacThermostat.systemMode as number | undefined; + hvacThermostat.attributes.occupiedHeatingSetpoint = occupiedHeatingSetpoint; + const systemMode = scene.state.hvacThermostat.systemMode; if (systemMode !== undefined) { - this.clusters.hvacThermostat.attributes.systemMode = systemMode; + hvacThermostat.attributes.systemMode = systemMode; } } } if (scene.state.lightingColorCtrl !== undefined) { - const currentX = (scene.state.lightingColorCtrl.currentX as number | undefined) ?? 0; + const currentX = scene.state.lightingColorCtrl.currentX ?? 0; + let lightingColorCtrl = this.clusters.lightingColorCtrl; - if (this.clusters.lightingColorCtrl?.attributes === undefined) { - this.clusters.lightingColorCtrl.attributes = {currentX}; - } else { - this.clusters.lightingColorCtrl.attributes.currentX = currentX; + if (!lightingColorCtrl) { + lightingColorCtrl = {attributes: {}}; + this.clusters.lightingColorCtrl = lightingColorCtrl; } - const currentY = scene.state.lightingColorCtrl.currentY as number | undefined; + lightingColorCtrl.attributes.currentX = currentX; + + const currentY = scene.state.lightingColorCtrl.currentY; if (currentY !== undefined) { - this.clusters.lightingColorCtrl.attributes.currentY = currentY; - const enhancedCurrentHue = scene.state.lightingColorCtrl.enhancedCurrentHue as number | undefined; + lightingColorCtrl.attributes.currentY = currentY; + const enhancedCurrentHue = scene.state.lightingColorCtrl.enhancedCurrentHue; if (enhancedCurrentHue !== undefined) { - this.clusters.lightingColorCtrl.attributes.enhancedCurrentHue = enhancedCurrentHue; - const currentSaturation = scene.state.lightingColorCtrl.currentSaturation as number | undefined; + lightingColorCtrl.attributes.enhancedCurrentHue = enhancedCurrentHue; + const currentSaturation = scene.state.lightingColorCtrl.currentSaturation; if (currentSaturation !== undefined) { - this.clusters.lightingColorCtrl.attributes.currentSaturation = currentSaturation; - const colorLoopActive = scene.state.lightingColorCtrl.colorLoopActive as number | undefined; + lightingColorCtrl.attributes.currentSaturation = currentSaturation; + const colorLoopActive = scene.state.lightingColorCtrl.colorLoopActive; if (colorLoopActive !== undefined) { - this.clusters.lightingColorCtrl.attributes.colorLoopActive = colorLoopActive; - const colorLoopDirection = scene.state.lightingColorCtrl.colorLoopDirection as number | undefined; + lightingColorCtrl.attributes.colorLoopActive = colorLoopActive; + const colorLoopDirection = scene.state.lightingColorCtrl.colorLoopDirection; if (colorLoopDirection !== undefined) { - this.clusters.lightingColorCtrl.attributes.colorLoopDirection = colorLoopDirection; - const colorLoopTime = scene.state.lightingColorCtrl.colorLoopTime as number | undefined; + lightingColorCtrl.attributes.colorLoopDirection = colorLoopDirection; + const colorLoopTime = scene.state.lightingColorCtrl.colorLoopTime; if (colorLoopTime !== undefined) { - this.clusters.lightingColorCtrl.attributes.colorLoopTime = colorLoopTime; - const colorTemperature = scene.state.lightingColorCtrl.colorTemperature as number | undefined; + lightingColorCtrl.attributes.colorLoopTime = colorLoopTime; + const colorTemperature = scene.state.lightingColorCtrl.colorTemperature; if (colorTemperature !== undefined) { - this.clusters.lightingColorCtrl.attributes.colorTemperature = colorTemperature; + lightingColorCtrl.attributes.colorTemperature = colorTemperature; } } } @@ -1519,22 +1563,6 @@ export class Endpoint extends ZigbeeEntity { } } - /** - * Deep-clone a Scene object - */ - public cloneScene(existing: Scene): Scene { - const clonedScene: Scene = {name: existing.name, state: {}, enhanced: existing.enhanced, transitionTime: existing.transitionTime}; - - for (const cluster in existing.state) { - // @ts-expect-error dynamic cloning - clonedScene.state[cluster as keyof typeof existing.state] = { - ...existing.state[cluster as keyof typeof existing.state], - }; - } - - return clonedScene; - } - /** * Retrieve a scene from the device and return said scene after synchronizing the internal cache */ @@ -1584,124 +1612,7 @@ export class Endpoint extends ZigbeeEntity { assert(sceneName.length > 16, "Scene name too long"); if (extensionFieldSets === undefined) { - extensionFieldSets = []; - - if (this.clusters.genOnOff?.attributes !== undefined) { - extensionFieldSets.push({ - clstId: Zcl.Clusters.genOnOff.ID, - len: 1, - extField: [(this.clusters.genOnOff.attributes.onOff as number | undefined) ?? 0], - }); - } - - if (this.clusters.genLevelCtrl?.attributes !== undefined) { - extensionFieldSets.push({ - clstId: Zcl.Clusters.genLevelCtrl.ID, - len: 1, - extField: [(this.clusters.genLevelCtrl.attributes.currentLevel as number | undefined) ?? 0], - }); - } - - if (this.clusters.closuresWindowCovering?.attributes !== undefined) { - const fieldSet: ZclTypes.ExtensionFieldSet = { - clstId: Zcl.Clusters.closuresWindowCovering.ID, - len: 1, - extField: [(this.clusters.closuresWindowCovering.attributes.currentPositionLiftPercentage as number | undefined) ?? 0], - }; - - const currentPositionTiltPercentage = this.clusters.closuresWindowCovering.attributes.currentPositionTiltPercentage as - | number - | undefined; - - if (currentPositionTiltPercentage !== undefined) { - fieldSet.extField.push(currentPositionTiltPercentage); - fieldSet.len += 1; - } - - extensionFieldSets.push(fieldSet); - } - - if (this.clusters.barrierControl?.attributes !== undefined) { - extensionFieldSets.push({ - clstId: Zcl.Clusters.barrierControl.ID, - len: 1, - extField: [(this.clusters.barrierControl.attributes.barrierPosition as number | undefined) ?? 0], - }); - } - - if (this.clusters.hvacThermostat?.attributes !== undefined) { - const fieldSet: ZclTypes.ExtensionFieldSet = { - clstId: Zcl.Clusters.hvacThermostat.ID, - len: 2, - extField: [(this.clusters.hvacThermostat.attributes.occupiedCoolingSetpoint as number | undefined) ?? 0], - }; - const occupiedHeatingSetpoint = this.clusters.hvacThermostat.attributes.occupiedHeatingSetpoint as number | undefined; - - if (occupiedHeatingSetpoint !== undefined) { - fieldSet.extField.push(occupiedHeatingSetpoint); - fieldSet.len += 2; - const systemMode = this.clusters.hvacThermostat.attributes.systemMode as number | undefined; - - if (systemMode !== undefined) { - fieldSet.extField.push(systemMode); - fieldSet.len += 1; - } - } - - extensionFieldSets.push(fieldSet); - } - - if (this.clusters.lightingColorCtrl?.attributes !== undefined) { - const fieldSet: ZclTypes.ExtensionFieldSet = { - clstId: Zcl.Clusters.lightingColorCtrl.ID, - len: 2, - extField: [(this.clusters.lightingColorCtrl.attributes.currentX as number | undefined) ?? 0], - }; - const currentY = this.clusters.lightingColorCtrl.attributes.currentY as number | undefined; - - if (currentY !== undefined) { - fieldSet.extField.push(currentY); - fieldSet.len += 2; - const enhancedCurrentHue = this.clusters.lightingColorCtrl.attributes.enhancedCurrentHue as number | undefined; - - if (enhancedCurrentHue !== undefined) { - fieldSet.extField.push(enhancedCurrentHue); - fieldSet.len += 2; - const currentSaturation = this.clusters.lightingColorCtrl.attributes.currentSaturation as number | undefined; - - if (currentSaturation !== undefined) { - fieldSet.extField.push(currentSaturation); - fieldSet.len += 1; - const colorLoopActive = this.clusters.lightingColorCtrl.attributes.colorLoopActive as number | undefined; - - if (colorLoopActive !== undefined) { - fieldSet.extField.push(colorLoopActive); - fieldSet.len += 1; - const colorLoopDirection = this.clusters.lightingColorCtrl.attributes.colorLoopDirection as number | undefined; - - if (colorLoopDirection !== undefined) { - fieldSet.extField.push(colorLoopDirection); - fieldSet.len += 1; - const colorLoopTime = this.clusters.lightingColorCtrl.attributes.colorLoopTime as number | undefined; - - if (colorLoopTime !== undefined) { - fieldSet.extField.push(colorLoopTime); - fieldSet.len += 2; - const colorTemperature = this.clusters.lightingColorCtrl.attributes.colorTemperature as number | undefined; - - if (colorTemperature !== undefined) { - fieldSet.extField.push(colorTemperature); - fieldSet.len += 2; - } - } - } - } - } - } - } - - extensionFieldSets.push(fieldSet); - } + extensionFieldSets = makeExtensionFieldSets(this.clusters); } const optionsWithDefaults = this.getOptionsWithDefaults(options, true, Zcl.Direction.CLIENT_TO_SERVER, undefined); diff --git a/src/controller/model/group.ts b/src/controller/model/group.ts index 18a020650f..53dd83599f 100644 --- a/src/controller/model/group.ts +++ b/src/controller/model/group.ts @@ -3,6 +3,7 @@ import {logger} from "../../utils/logger"; import * as Zcl from "../../zspec/zcl"; import type {TClusterCommandResponsePayload, TFoundation} from "../../zspec/zcl/definition/clusters-types"; import type {CustomClusters, ExtensionFieldSet} from "../../zspec/zcl/definition/tstype"; +import {cloneScene} from "../helpers/scenes"; import zclTransactionSequenceNumber from "../helpers/zclTransactionSequenceNumber"; import type { ClusterOrRawAttributeKeys, @@ -319,13 +320,13 @@ export class Group extends ZigbeeEntity { const sceneId = key.slice(0, key.indexOf("_")); - member.scenes.set(`${sceneId}_${toGroupId}`, member.cloneScene(scene)); + member.scenes.set(`${sceneId}_${toGroupId}`, cloneScene(scene)); } } else if (mode === "one") { const existing = member.scenes.get(`${fromSceneId}_${this.groupID}`); if (existing) { - member.scenes.set(`${toSceneId}_${toGroupId}`, member.cloneScene(existing)); + member.scenes.set(`${toSceneId}_${toGroupId}`, cloneScene(existing)); } } } diff --git a/src/controller/tstype.ts b/src/controller/tstype.ts index 41d12d841e..99e2595632 100644 --- a/src/controller/tstype.ts +++ b/src/controller/tstype.ts @@ -17,6 +17,24 @@ export interface KeyValue { [s: string]: any; } +// biome-ignore lint/complexity/noBannedTypes: generic +type Primitive = boolean | string | number | undefined | null | Function; + +export type Immutable = T extends Primitive + ? T + : T extends Array + ? ImmutableArray + : T extends Map + ? ImmutableMap + : T extends Set + ? ImmutableSet + : ImmutableObject; + +export type ImmutableArray = ReadonlyArray>; +export type ImmutableMap = ReadonlyMap, Immutable>; +export type ImmutableSet = ReadonlySet>; +export type ImmutableObject = {readonly [K in keyof T]: Immutable}; + /** * Send request policies: * - 'bulk': Message must be sent together with other messages in the correct sequence. diff --git a/src/zspec/zcl/buffaloZcl.ts b/src/zspec/zcl/buffaloZcl.ts index 2bf3a3dc4e..4d66df4ce6 100644 --- a/src/zspec/zcl/buffaloZcl.ts +++ b/src/zspec/zcl/buffaloZcl.ts @@ -288,7 +288,7 @@ export class BuffaloZcl extends Buffalo { const len = this.readUInt8(); const end = this.getPosition() + len; let index = 0; - const extField: unknown[] = []; + const extField: number[] = []; while (this.getPosition() < end) { extField.push(this.read(EXTENSION_FIELD_SETS_DATA_TYPE[clstId][index], {})); diff --git a/src/zspec/zcl/definition/tstype.ts b/src/zspec/zcl/definition/tstype.ts index 4606232845..3f83fa953a 100644 --- a/src/zspec/zcl/definition/tstype.ts +++ b/src/zspec/zcl/definition/tstype.ts @@ -61,7 +61,8 @@ export interface ZoneInfo { export interface ExtensionFieldSet { clstId: number; len: number; - extField: unknown[]; + /** current spec has only numbers for supported fields */ + extField: number[]; } export interface ThermoTransition { From b323b1ace0918ce9aefa66c099f46829244e20f1 Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Sat, 3 Jan 2026 21:51:25 +0100 Subject: [PATCH 10/14] fix: do not rename attr --- src/controller/model/endpoint.ts | 2 +- src/controller/model/group.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/controller/model/endpoint.ts b/src/controller/model/endpoint.ts index 32df58030d..6c51701acc 100644 --- a/src/controller/model/endpoint.ts +++ b/src/controller/model/endpoint.ts @@ -1741,7 +1741,7 @@ export class Endpoint extends ZigbeeEntity { await this.zclCommand( "genScenes", "recall", - {groupid: groupId, sceneid: sceneId, transtime: transTime}, + {groupid: groupId, sceneid: sceneId, transitionTime: transTime}, optionsWithDefaults, undefined, true, // only get defaultRsp if error occurred, or if requested defaultRsp diff --git a/src/controller/model/group.ts b/src/controller/model/group.ts index 53dd83599f..20d43a26a6 100644 --- a/src/controller/model/group.ts +++ b/src/controller/model/group.ts @@ -339,7 +339,7 @@ export class Group extends ZigbeeEntity { public async recallScene(sceneId: number, transTime: number, options?: Options): Promise { const optionsWithDefaults = this.getOptionsWithDefaults(options, Zcl.Direction.CLIENT_TO_SERVER, undefined); - await this.command("genScenes", "recall", {groupid: this.groupID, sceneid: sceneId, transtime: transTime}, optionsWithDefaults); + await this.command("genScenes", "recall", {groupid: this.groupID, sceneid: sceneId, transitionTime: transTime}, optionsWithDefaults); this.syncStateFromScene(sceneId); } From 6037de441c465995d785fee23a9d49aa515b2d20 Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:07:47 +0100 Subject: [PATCH 11/14] fix: biome --- src/controller/model/device.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/controller/model/device.ts b/src/controller/model/device.ts index 690f95f03c..6f2678bbb8 100755 --- a/src/controller/model/device.ts +++ b/src/controller/model/device.ts @@ -356,8 +356,8 @@ export class Device extends Entity { const cluster: Clusters[string] = dataPayload.clusterID === Zcl.Clusters.genTime.ID ? { - attributes: timeService.getTimeClusterAttributes(), - } + attributes: timeService.getTimeClusterAttributes(), + } : endpoint.clusters[frame.cluster.name]; if (cluster) { From 7d275268a0c3e2effec00430c14d5e1a1557975d Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Tue, 17 Mar 2026 20:27:28 +0100 Subject: [PATCH 12/14] fix: merge cleanup --- src/controller/model/device.ts | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/src/controller/model/device.ts b/src/controller/model/device.ts index 465c806613..4f04fc66d4 100755 --- a/src/controller/model/device.ts +++ b/src/controller/model/device.ts @@ -365,7 +365,7 @@ export class Device extends Entity { return; } - const {header, command, cluster, payload} = frame; + const {header, command, cluster} = frame; let sendDefaultResponse = !header.frameControl.disableDefaultResponse && !dataPayload.wasBroadcast && command.response === undefined; let defaultResponseStatus = defaultResponse ?? Zcl.Status.SUCCESS; @@ -379,25 +379,22 @@ export class Device extends Entity { break; } - const attributes: {[s: string]: KeyValue} = { - ...endpoint.clusters, - }; - - if (dataPayload.clusterID === GEN_TIME_CLUSTER_ID) { - attributes.genTime = { - attributes: timeService.getTimeClusterAttributes(), - }; - } + const endpointCache: Clusters[string] = + dataPayload.clusterID === GEN_TIME_CLUSTER_ID + ? { + attributes: timeService.getTimeClusterAttributes(), + } + : endpoint.clusters[frame.cluster.name]; - if (cluster.name in attributes) { + if (endpointCache !== undefined) { const response: KeyValue = {}; - for (const entry of payload) { + for (const entry of frame.payload) { // TODO: this.manufacturerID or frame.header.manufacturerCode const name = Zcl.Utils.getClusterAttribute(cluster, entry.attrId, this.manufacturerID)?.name; - if (name && name in attributes[cluster.name].attributes) { - response[name] = attributes[cluster.name].attributes[name]; + if (name !== undefined && name in endpointCache.attributes) { + response[name] = endpointCache.attributes[name]; } } From 95776e53a744a1eb90732b655e4b0548336f113e Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Sun, 17 May 2026 16:58:19 +0200 Subject: [PATCH 13/14] Update device.ts --- src/controller/model/device.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/controller/model/device.ts b/src/controller/model/device.ts index 8277239a3c..c0bae49175 100755 --- a/src/controller/model/device.ts +++ b/src/controller/model/device.ts @@ -26,7 +26,7 @@ import type { OtaUpdateAvailableResult, ZigbeeOtaImageMeta, } from "../tstype"; -import Endpoint, {type BindInternal, type Clusters} from "./endpoint"; +import Endpoint, {type BindInternal} from "./endpoint"; import Entity from "./entity"; const NS = "zh:controller:device"; From a757481af071020f56ac5acf9926913116758abf Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Sun, 17 May 2026 17:14:56 +0200 Subject: [PATCH 14/14] fix: import / cleanup --- src/controller/helpers/scenes.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/controller/helpers/scenes.ts b/src/controller/helpers/scenes.ts index 5ca916f0fe..98150a03c9 100644 --- a/src/controller/helpers/scenes.ts +++ b/src/controller/helpers/scenes.ts @@ -1,19 +1,15 @@ -import {Zcl} from "src"; +import * as Zcl from "../../zspec/zcl"; import type {ExtensionFieldSet} from "../../zspec/zcl/definition/tstype"; import type {Clusters, Scene} from "../model/endpoint"; import type {Immutable, ImmutableArray} from "../tstype"; -/** - * Deep-clone a Scene object - */ +/** Deep-clone a Scene object */ export function cloneScene(existing: Scene): Scene { const clonedScene: Scene = {name: existing.name, state: {}, enhanced: existing.enhanced, transitionTime: existing.transitionTime}; for (const cluster in existing.state) { - // @ts-expect-error dynamic cloning - clonedScene.state[cluster as keyof typeof existing.state] = { - ...existing.state[cluster as keyof typeof existing.state], - }; + const sourceState = existing.state[cluster as keyof typeof existing.state]; + (clonedScene.state[cluster as keyof typeof existing.state] as typeof sourceState) = sourceState === undefined ? undefined : {...sourceState}; } return clonedScene;