UNPKG

zigbee-herdsman

Version:

An open source ZigBee gateway solution with node.js.

401 lines (326 loc) 14.5 kB
import assert from "node:assert"; import {logger} from "../../utils/logger"; import * as Zcl from "../../zspec/zcl"; import type {CustomClusters} from "../../zspec/zcl/definition/tstype"; import zclTransactionSequenceNumber from "../helpers/zclTransactionSequenceNumber"; import type {DatabaseEntry, KeyValue} from "../tstype"; import Device from "./device"; import type Endpoint from "./endpoint"; import Entity from "./entity"; 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 Entity { private databaseID: 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<number /* groupID */, Group> = new Map(); private static loadedFromDatabase = false; /** 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, groupID: number, members: Endpoint[], meta: KeyValue) { super(); this.databaseID = databaseID; this.groupID = groupID; this._members = members; this.meta = meta; this.#customClusters = this.#identifyCustomClusters(); } /* * CRUD */ /** * Reset runtime lookups. */ public static resetCache(): void { Group.groups.clear(); Group.loadedFromDatabase = false; } private static fromDatabaseEntry(entry: DatabaseEntry): 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(member.deviceIeeeAddr); if (device) { const endpoint = device.getEndpoint(member.endpointID); if (endpoint) { members.push(endpoint); } } } return new Group(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.databaseID, type: "Group", groupID: this.groupID, members, meta: this.meta}; } private static loadFromDatabaseIfNecessary(): void { if (!Group.loadedFromDatabase) { for (const entry of Entity.database.getEntriesIterator(["Group"])) { const group = Group.fromDatabaseEntry(entry); Group.groups.set(group.groupID, group); } Group.loadedFromDatabase = true; } } public static byGroupID(groupID: number): Group | undefined { Group.loadFromDatabaseIfNecessary(); return Group.groups.get(groupID); } /** * @deprecated use allIterator() */ public static all(): Group[] { Group.loadFromDatabaseIfNecessary(); return Array.from(Group.groups.values()); } public static *allIterator(predicate?: (value: Group) => boolean): Generator<Group> { Group.loadFromDatabaseIfNecessary(); for (const group of Group.groups.values()) { if (!predicate || predicate(group)) { yield group; } } } 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"); Group.loadFromDatabaseIfNecessary(); if (Group.groups.has(groupID)) { throw new Error(`Group with groupID '${groupID}' already exists`); } const databaseID = Entity.database.newID(); const group = new Group(databaseID, groupID, [], {}); Entity.database.insert(group.toDatabaseRecord()); Group.groups.set(group.groupID, group); return group; } public async removeFromNetwork(): Promise<void> { for (const endpoint of this._members) { await endpoint.removeFromGroup(this); } this.removeFromDatabase(); } public removeFromDatabase(): void { Group.loadFromDatabaseIfNecessary(); if (Entity.database.has(this.databaseID)) { Entity.database.remove(this.databaseID); } Group.groups.delete(this.groupID); } public save(writeDatabase = true): void { Entity.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: number | string, attributes: KeyValue, options?: Options): Promise<void> { 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: {attrId: number; dataType: number; attrData: number | string | boolean}[] = []; for (const [nameOrID, value] of Object.entries(attributes)) { const attribute = cluster.getAttribute(nameOrID); if (attribute) { payload.push({attrId: attribute.ID, attrData: value, dataType: attribute.type}); } else if (!Number.isNaN(Number(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.adapter.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: number | string, attributes: (string | number)[], options?: Options): Promise<void> { 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: {attrId: number}[] = []; for (const attribute of attributes) { if (typeof attribute === "number") { payload.push({attrId: attribute}); } else { const attr = cluster.getAttribute(attribute); if (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.adapter.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: number | string, commandKey: number | string, payload: KeyValue, options?: Options): Promise<void> { 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 ? cluster.getCommand(commandKey) : cluster.getCommandResponse(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.adapter.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;