zigbee-herdsman
Version:
An open source ZigBee gateway solution with node.js.
334 lines (273 loc) • 12.1 kB
text/typescript
import assert from "node:assert";
import {logger} from "../../utils/logger";
import * as Zcl from "../../zspec/zcl";
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[];
// 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);
}
private constructor(databaseID: number, groupID: number, members: Endpoint[], meta: KeyValue) {
super();
this.databaseID = databaseID;
this.groupID = groupID;
this._members = members;
this.meta = meta;
}
/*
* 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) {
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
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`);
}
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
const databaseID = Entity.database!.newID();
const group = new Group(databaseID, groupID, [], {});
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
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();
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
if (Entity.database!.has(this.databaseID)) {
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
Entity.database!.remove(this.databaseID);
}
Group.groups.delete(this.groupID);
}
public save(writeDatabase = true): void {
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
Entity.database!.update(this.toDatabaseRecord(), writeDatabase);
}
public addMember(endpoint: Endpoint): void {
if (!this._members.includes(endpoint)) {
this._members.push(endpoint);
this.save();
}
}
public removeMember(endpoint: Endpoint): void {
const i = this._members.indexOf(endpoint);
if (i > -1) {
this._members.splice(i, 1);
this.save();
}
}
public hasMember(endpoint: Endpoint): boolean {
return this._members.includes(endpoint);
}
/*
* Zigbee functions
*/
public async write(clusterKey: number | string, attributes: KeyValue, options?: Options): Promise<void> {
const optionsWithDefaults = this.getOptionsWithDefaults(options, Zcl.Direction.CLIENT_TO_SERVER);
const cluster = Zcl.Utils.getCluster(clusterKey, undefined, {});
const payload: {attrId: number; dataType: number; attrData: number | string | boolean}[] = [];
for (const [nameOrID, value] of Object.entries(attributes)) {
if (cluster.hasAttribute(nameOrID)) {
const attribute = cluster.getAttribute(nameOrID);
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.ID,
payload,
{},
optionsWithDefaults.reservedBits,
);
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
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 optionsWithDefaults = this.getOptionsWithDefaults(options, Zcl.Direction.CLIENT_TO_SERVER);
const cluster = Zcl.Utils.getCluster(clusterKey, undefined, {});
const payload: {attrId: number}[] = [];
for (const attribute of attributes) {
payload.push({attrId: typeof attribute === "number" ? attribute : cluster.getAttribute(attribute).ID});
}
const frame = Zcl.Frame.create(
Zcl.FrameType.GLOBAL,
optionsWithDefaults.direction,
true,
optionsWithDefaults.manufacturerCode,
optionsWithDefaults.transactionSequenceNumber ?? zclTransactionSequenceNumber.next(),
"read",
cluster.ID,
payload,
{},
optionsWithDefaults.reservedBits,
);
const createLogMessage = (): string =>
`Read ${this.groupID} ${cluster.name}(${JSON.stringify(attributes)}, ${JSON.stringify(optionsWithDefaults)})`;
logger.debug(createLogMessage, NS);
try {
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
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 optionsWithDefaults = this.getOptionsWithDefaults(options, Zcl.Direction.CLIENT_TO_SERVER);
const cluster = Zcl.Utils.getCluster(clusterKey, undefined, {});
const command = cluster.getCommand(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.ID,
cluster.ID,
payload,
{},
optionsWithDefaults.reservedBits,
);
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
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): OptionsWithDefaults {
return {
direction,
srcEndpoint: undefined,
reservedBits: 0,
manufacturerCode: undefined,
transactionSequenceNumber: undefined,
...(options || {}),
};
}
}
export default Group;