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 zclTransactionSequenceNumber from "../helpers/zclTransactionSequenceNumber"; import type { ClusterOrRawAttributeKeys, ClusterOrRawPayload, DatabaseEntry, KeyValue, PartialClusterOrRawWriteAttributes, TCustomCluster, } from "../tstype"; import Device from "./device"; import type Endpoint from "./endpoint"; import Entity from "./entity"; import {ZigbeeEntity} from "./zigbeeEntity"; const NS = "zh:controller:group"; interface Options { manufacturerCode?: number; direction?: Zcl.Direction; srcEndpoint?: number; reservedBits?: number; transactionSequenceNumber?: number; } interface OptionsWithDefaults extends Options { direction: Zcl.Direction; reservedBits: number; } export class Group extends ZigbeeEntity { private databaseID: number; private ID: number; public readonly groupID: number; private readonly _members: Endpoint[]; #customClusters: [input: CustomClusters, output: CustomClusters]; // Can be used by applications to store data. public readonly meta: KeyValue; // This lookup contains all groups that are queried from the database, this is to ensure that always // the same instance is returned. private static readonly groups: Map> = new Map(); /** Member endpoints with valid devices (not unknown/deleted) */ get members(): Endpoint[] { return this._members.filter((e) => e.getDevice() !== undefined); } /** List of server / client custom clusters common to all devices in the group */ get customClusters(): [input: CustomClusters, output: CustomClusters] { return this.#customClusters; } private constructor(databaseID: number, ID: number, groupID: number, members: Endpoint[], meta: KeyValue) { super(); this.databaseID = databaseID; this.ID = ID; this.groupID = groupID; this._members = members; this.meta = meta; this.#customClusters = this.#identifyCustomClusters(); } /* * CRUD */ /** * Reset runtime lookups. */ public static resetCache(): void { Group.groups.clear(); } private static fromDatabaseEntry(entry: DatabaseEntry, databaseID: number): Group { // db is expected to never contain duplicate, so no need for explicit check const members: Endpoint[] = []; for (const member of entry.members) { const device = Device.byIeeeAddr(databaseID, member.deviceIeeeAddr); if (device) { const endpoint = device.getEndpoint(member.endpointID); if (endpoint) { members.push(endpoint); } } } return new Group(databaseID, entry.id, entry.groupID, members, entry.meta); } private toDatabaseRecord(): DatabaseEntry { const members: DatabaseEntry["members"] = []; for (const member of this._members) { const device = member.getDevice(); if (device) { members.push({deviceIeeeAddr: device.ieeeAddr, endpointID: member.ID}); } } return {id: this.ID, type: "Group", groupID: this.groupID, members, meta: this.meta}; } private static loadFromDatabaseIfNecessary(): void { Entity.databases.forEach(database => { if (!Group.groups.get(database.id)) { Group.groups.set(database.id, new Map()); } const entries = database.getEntriesIterator(['Group']); for (const entry of entries) { const group = Group.fromDatabaseEntry(entry, database.id); Group.groups.get(database.id)?.set(group.groupID, group); } }); } public static byGroupID(groupID: number, databaseID: number): Group | undefined { Group.loadFromDatabaseIfNecessary(); return Group.groups.get(databaseID)?.get(groupID); } // public static all(): Group[] { // Group.loadFromDatabaseIfNecessary(); // return Array.from(Group.groups.values()); // } public static allByDatabaseID(databaseID: number): Group[] { Group.loadFromDatabaseIfNecessary(); return Array.from(Group.groups.get(databaseID)?.values() ?? []); } public static *allIterator(databaseID: number, predicate?: (value: Group) => boolean): Generator { Group.loadFromDatabaseIfNecessary(); for (const group of Group.groups.get(databaseID)?.values() ?? []) { if (!predicate || predicate(group)) { yield group; } } } public static create(groupID: number, databaseID: 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"); Group.loadFromDatabaseIfNecessary(); if (Group.groups.get(databaseID)?.has(groupID)) { throw new Error(`Group with groupID '${groupID}' already exists`); } const database = Entity.getDatabaseByID(databaseID); if (!database) { throw new Error(`Database with ID '${databaseID}' not found`); } const ID = database.newID(); const group = new Group(databaseID, ID, groupID, [], {}); database.insert(group.toDatabaseRecord()); Group.groups.get(databaseID)?.set(group.groupID, group); return group; } public async removeFromNetwork(): Promise { for (const endpoint of this._members) { await endpoint.removeFromGroup(this); } this.removeFromDatabase(); } public removeFromDatabase(): void { Group.loadFromDatabaseIfNecessary(); const database = Entity.getDatabaseByID(this.databaseID); if (database?.has(this.ID)) { database.remove(this.ID); } Group.groups.get(this.databaseID)?.delete(this.groupID); } public save(writeDatabase = true): void { const database = Entity.getDatabaseByID(this.databaseID); if (database) { database.update(this.toDatabaseRecord(), writeDatabase); } } public addMember(endpoint: Endpoint): void { if (!this._members.includes(endpoint)) { this._members.push(endpoint); this.save(); this.#customClusters = this.#identifyCustomClusters(); } } public removeMember(endpoint: Endpoint): void { const i = this._members.indexOf(endpoint); if (i > -1) { this._members.splice(i, 1); this.save(); this.#customClusters = this.#identifyCustomClusters(); } } public hasMember(endpoint: Endpoint): boolean { return this._members.includes(endpoint); } #identifyCustomClusters(): [input: CustomClusters, output: CustomClusters] { const members = this.members; if (members.length > 0) { const customClusters = members[0].getDevice().customClusters; const inputClusters: CustomClusters = {}; const outputClusters: CustomClusters = {}; for (const clusterName in customClusters) { const customCluster = customClusters[clusterName]; let hasInput = true; let hasOutput = true; for (const member of members) { if (clusterName in member.getDevice().customClusters) { hasInput = member.inputClusters.includes(customCluster.ID); hasOutput = member.outputClusters.includes(customCluster.ID); if (!hasInput && !hasOutput) { break; } } else { hasInput = false; hasOutput = false; break; } } if (hasInput) { inputClusters[clusterName] = customCluster; } if (hasOutput) { outputClusters[clusterName] = customCluster; } } return [inputClusters, outputClusters]; } return [{}, {}]; } /* * Zigbee functions */ public async write( clusterKey: Cl, attributes: PartialClusterOrRawWriteAttributes, options?: Options, ): Promise { const customClusters = this.#customClusters[options?.direction === Zcl.Direction.SERVER_TO_CLIENT ? 1 : 0 /* default to CLIENT_TO_SERVER */]; const cluster = Zcl.Utils.getCluster(clusterKey, options?.manufacturerCode, customClusters); const optionsWithDefaults = this.getOptionsWithDefaults(options, Zcl.Direction.CLIENT_TO_SERVER, cluster.manufacturerCode); const payload: TFoundation["write"] = []; for (const nameOrID in attributes) { const attribute = Zcl.Utils.getClusterAttribute(cluster, nameOrID, options?.manufacturerCode); if (attribute) { const attrData = Zcl.Utils.processAttributeWrite(attribute, attributes[nameOrID]); payload.push({attrId: attribute.ID, attrData, dataType: attribute.type}); } else if (!Number.isNaN(Number(nameOrID))) { const value = attributes[nameOrID]; payload.push({attrId: Number(nameOrID), attrData: value.value, dataType: value.type}); } else { throw new Error(`Unknown attribute '${nameOrID}', specify either an existing attribute or a number`); } } const createLogMessage = (): string => `Write ${this.groupID} ${cluster.name}(${JSON.stringify(attributes)}, ${JSON.stringify(optionsWithDefaults)})`; logger.debug(createLogMessage, NS); try { const frame = Zcl.Frame.create( Zcl.FrameType.GLOBAL, optionsWithDefaults.direction, true, optionsWithDefaults.manufacturerCode, optionsWithDefaults.transactionSequenceNumber ?? zclTransactionSequenceNumber.next(), "write", cluster, payload, customClusters, optionsWithDefaults.reservedBits, ); await Entity.getAdapterByID(this.databaseID)?.sendZclFrameToGroup(this.groupID, frame, optionsWithDefaults.srcEndpoint); } catch (error) { const err = error as Error; err.message = `${createLogMessage()} failed (${err.message})`; // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` logger.debug(err.stack!, NS); throw error; } } public async read( clusterKey: Cl, attributes: ClusterOrRawAttributeKeys, options?: Options, ): Promise { const customClusters = this.#customClusters[options?.direction === Zcl.Direction.SERVER_TO_CLIENT ? 1 : 0 /* default to CLIENT_TO_SERVER */]; const cluster = Zcl.Utils.getCluster(clusterKey, options?.manufacturerCode, customClusters); const optionsWithDefaults = this.getOptionsWithDefaults(options, Zcl.Direction.CLIENT_TO_SERVER, cluster.manufacturerCode); const payload: TFoundation["read"] = []; // TODO: handle `attr.required !== true` => should not throw for (const attribute of attributes) { if (typeof attribute === "number") { payload.push({attrId: attribute}); } else { const attr = Zcl.Utils.getClusterAttribute(cluster, attribute, options?.manufacturerCode); if (attr) { Zcl.Utils.processAttributePreRead(attr); payload.push({attrId: attr.ID}); } else { logger.warning(`Ignoring unknown attribute ${attribute} in cluster ${cluster.name}`, NS); } } } const frame = Zcl.Frame.create( Zcl.FrameType.GLOBAL, optionsWithDefaults.direction, true, optionsWithDefaults.manufacturerCode, optionsWithDefaults.transactionSequenceNumber ?? zclTransactionSequenceNumber.next(), "read", cluster, payload, customClusters, optionsWithDefaults.reservedBits, ); const createLogMessage = (): string => `Read ${this.groupID} ${cluster.name}(${JSON.stringify(attributes)}, ${JSON.stringify(optionsWithDefaults)})`; logger.debug(createLogMessage, NS); try { await Entity.getAdapterByID(this.databaseID)?.sendZclFrameToGroup(this.groupID, frame, optionsWithDefaults.srcEndpoint); } catch (error) { const err = error as Error; err.message = `${createLogMessage()} failed (${err.message})`; // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` logger.debug(err.stack!, NS); throw error; } } public async command( clusterKey: Cl, commandKey: Co, payload: ClusterOrRawPayload, options?: Options, ): Promise { const customClusters = this.#customClusters[options?.direction === Zcl.Direction.SERVER_TO_CLIENT ? 1 : 0 /* default to CLIENT_TO_SERVER */]; const cluster = Zcl.Utils.getCluster(clusterKey, options?.manufacturerCode, customClusters); const optionsWithDefaults = this.getOptionsWithDefaults(options, Zcl.Direction.CLIENT_TO_SERVER, cluster.manufacturerCode); const command = optionsWithDefaults.direction === Zcl.Direction.CLIENT_TO_SERVER ? Zcl.Utils.getClusterCommand(cluster, commandKey) : Zcl.Utils.getClusterCommandResponse(cluster, commandKey); const createLogMessage = (): string => `Command ${this.groupID} ${cluster.name}.${command.name}(${JSON.stringify(payload)})`; logger.debug(createLogMessage, NS); try { const frame = Zcl.Frame.create( Zcl.FrameType.SPECIFIC, optionsWithDefaults.direction, true, optionsWithDefaults.manufacturerCode, optionsWithDefaults.transactionSequenceNumber ?? zclTransactionSequenceNumber.next(), command, cluster, payload, customClusters, optionsWithDefaults.reservedBits, ); await Entity.getAdapterByID(this.databaseID)?.sendZclFrameToGroup(this.groupID, frame, optionsWithDefaults.srcEndpoint); } catch (error) { const err = error as Error; err.message = `${createLogMessage()} failed (${err.message})`; // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` logger.debug(err.stack!, NS); throw error; } } private getOptionsWithDefaults( options: Options | undefined, direction: Zcl.Direction, manufacturerCode: number | undefined, ): OptionsWithDefaults { return { direction, srcEndpoint: undefined, reservedBits: 0, manufacturerCode, transactionSequenceNumber: undefined, ...(options || {}), }; } } export default Group;