zigbee-herdsman-zigate
Version:
An open source ZigBee gateway solution with node.js.
633 lines (553 loc) • 25.3 kB
text/typescript
import Entity from './entity';
import {KeyValue} from '../tstype';
import * as Zcl from '../../zcl';
import ZclTransactionSequenceNumber from '../helpers/zclTransactionSequenceNumber';
import * as ZclFrameConverter from '../helpers/zclFrameConverter';
import Group from './group';
import Device from './device';
import Debug from "debug";
import assert from 'assert';
const debug = {
info: Debug('zigbee-herdsman:controller:endpoint'),
error: Debug('zigbee-herdsman:controller:endpoint'),
};
interface ConfigureReportingItem {
attribute: string | number | {ID: number; type: number};
minimumReportInterval: number;
maximumReportInterval: number;
reportableChange: number;
}
interface Options {
manufacturerCode?: number;
disableDefaultResponse?: boolean;
disableResponse?: boolean;
timeout?: number;
direction?: Zcl.Direction;
srcEndpoint?: number;
reservedBits?: number;
transactionSequenceNumber?: number;
disableRecovery?: boolean;
}
interface Clusters {
[cluster: string]: {
attributes: {[attribute: string]: number | string};
};
}
interface BindInternal {
cluster: number;
type: 'endpoint' | 'group';
deviceIeeeAddress?: string;
endpointID?: number;
groupID?: number;
}
interface Bind {
cluster: Zcl.TsType.Cluster;
target: Endpoint | Group;
}
class Endpoint extends Entity {
public deviceID?: number;
public inputClusters: number[];
public outputClusters: number[];
public profileID?: number;
public readonly ID: number;
public readonly clusters: Clusters;
private readonly deviceIeeeAddress: string;
public deviceNetworkAddress: number;
private _binds: BindInternal[];
private meta: KeyValue;
// Getters/setters
get binds(): Bind[] {
return this._binds.map((entry) => {
let target: Group | Endpoint = null;
if (entry.type === 'endpoint') {
const device = Device.byIeeeAddr(entry.deviceIeeeAddress);
if (device) {
target = device.getEndpoint(entry.endpointID);
}
} else {
target = Group.byGroupID(entry.groupID);
}
if (target) {
return {target, cluster: Zcl.Utils.getCluster(entry.cluster)};
} else {
return undefined;
}
}).filter(b => b !== undefined);
}
private constructor(
ID: number, profileID: number, deviceID: number, inputClusters: number[], outputClusters: number[],
deviceNetworkAddress: number, deviceIeeeAddress: string, clusters: Clusters, binds: BindInternal[],
meta: KeyValue,
) {
super();
this.ID = ID;
this.profileID = profileID;
this.deviceID = deviceID;
this.inputClusters = inputClusters;
this.outputClusters = outputClusters;
this.deviceNetworkAddress = deviceNetworkAddress;
this.deviceIeeeAddress = deviceIeeeAddress;
this.clusters = clusters;
this._binds = binds;
this.meta = meta;
}
/**
* Get device of this endpoint
*/
public getDevice(): Device {
return Device.byIeeeAddr(this.deviceIeeeAddress);
}
/**
* @param {number|string} clusterKey
* @returns {boolean}
*/
public supportsInputCluster(clusterKey: number | string, ): boolean {
const cluster = Zcl.Utils.getCluster(clusterKey);
return this.inputClusters.includes(cluster.ID);
}
/**
* @param {number|string} clusterKey
* @returns {boolean}
*/
public supportsOutputCluster(clusterKey: number | string, ): boolean {
const cluster = Zcl.Utils.getCluster(clusterKey);
return this.outputClusters.includes(cluster.ID);
}
/**
* @returns {Zcl.TsType.Cluster[]}
*/
public getInputClusters(): Zcl.TsType.Cluster[] {
return this.clusterNumbersToClusters(this.inputClusters);
}
/**
* @returns {Zcl.TsType.Cluster[]}
*/
public getOutputClusters(): Zcl.TsType.Cluster[] {
return this.clusterNumbersToClusters(this.outputClusters);
}
private clusterNumbersToClusters(clusterNumbers: number[]): Zcl.TsType.Cluster[] {
return clusterNumbers.map((c) => {
try {
return Zcl.Utils.getCluster(c, this.getDevice().manufacturerID);
} catch {
return null;
}
}).filter((c) => c !== null);
}
/*
* CRUD
*/
public static fromDatabaseRecord(
record: KeyValue, deviceNetworkAddress: number, deviceIeeeAddress: string
): Endpoint {
// Migrate attrs to attributes
for (const entry of Object.values(record.clusters).filter((e) => e.hasOwnProperty('attrs'))) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
entry.attributes = entry.attrs;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete entry.attrs;
}
const meta = record.meta ? record.meta : {};
return new Endpoint(
record.epId, record.profId, record.devId, record.inClusterList, record.outClusterList,
deviceNetworkAddress, deviceIeeeAddress, record.clusters, record.binds || [], meta
);
}
public toDatabaseRecord(): KeyValue {
return {
profId: this.profileID, epId: this.ID, devId: this.deviceID,
inClusterList: this.inputClusters, outClusterList: this.outputClusters, clusters: this.clusters,
binds: this._binds, meta: this.meta,
};
}
public static create(
ID: number, profileID: number, deviceID: number, inputClusters: number[], outputClusters: number[],
deviceNetworkAddress: number, deviceIeeeAddress: string,
): Endpoint {
return new Endpoint(
ID, profileID, deviceID, inputClusters, outputClusters, deviceNetworkAddress,
deviceIeeeAddress, {}, [], {},
);
}
public saveClusterAttributeKeyValue(clusterKey: number | string, list: KeyValue): void {
const cluster = Zcl.Utils.getCluster(clusterKey);
if (!this.clusters[cluster.name]) this.clusters[cluster.name] = {attributes: {}};
for (const [attribute, value] of Object.entries(list)) {
this.clusters[cluster.name].attributes[attribute] = value;
}
}
public getClusterAttributeValue(clusterKey: number | string, attributeKey: number | string): number | string {
const cluster = Zcl.Utils.getCluster(clusterKey);
const attribute = cluster.getAttribute(attributeKey);
if (this.clusters[cluster.name] && this.clusters[cluster.name].attributes) {
return this.clusters[cluster.name].attributes[attribute.name];
}
return null;
}
/*
* Zigbee functions
*/
private checkStatus(payload: [{status: Zcl.Status}]): void {
for (const item of payload) {
if (item.status !== Zcl.Status.SUCCESS) {
throw new Zcl.ZclStatusError(item.status);
}
}
}
public async write(
clusterKey: number | string, attributes: KeyValue, options?: Options
): Promise<void> {
options = this.getOptionsWithDefaults(options, true, Zcl.Direction.CLIENT_TO_SERVER);
const cluster = Zcl.Utils.getCluster(clusterKey);
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 (!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 log = `Write ${this.deviceIeeeAddress}/${this.ID} ` +
`${cluster.name}(${JSON.stringify(attributes)}, ${JSON.stringify(options)})`;
debug.info(log);
try {
const frame = Zcl.ZclFrame.create(
Zcl.FrameType.GLOBAL, options.direction, options.disableDefaultResponse,
options.manufacturerCode, options.transactionSequenceNumber ?? ZclTransactionSequenceNumber.next(),
'write', cluster.ID, payload, options.reservedBits
);
const result = await Entity.adapter.sendZclFrameToEndpoint(
this.deviceIeeeAddress, this.deviceNetworkAddress, this.ID, frame, options.timeout,
options.disableResponse, options.disableRecovery, options.srcEndpoint,
);
if (!options.disableResponse) {
this.checkStatus(result.frame.Payload);
}
} catch (error) {
error.message = `${log} failed (${error.message})`;
debug.error(error.message);
throw error;
}
}
public async read(
clusterKey: number | string, attributes: string[] | number [], options?: Options
): Promise<KeyValue> {
options = this.getOptionsWithDefaults(options, true, Zcl.Direction.CLIENT_TO_SERVER);
const cluster = Zcl.Utils.getCluster(clusterKey);
const payload: {attrId: number}[] = [];
for (const attribute of attributes) {
payload.push({attrId: typeof attribute === 'number' ? attribute : cluster.getAttribute(attribute).ID});
}
const frame = Zcl.ZclFrame.create(
Zcl.FrameType.GLOBAL, options.direction, options.disableDefaultResponse,
options.manufacturerCode, options.transactionSequenceNumber ?? ZclTransactionSequenceNumber.next(), 'read',
cluster.ID, payload, options.reservedBits
);
const log = `Read ${this.deviceIeeeAddress}/${this.ID} ` +
`${cluster.name}(${JSON.stringify(attributes)}, ${JSON.stringify(options)})`;
debug.info(log);
try {
const result = await Entity.adapter.sendZclFrameToEndpoint(
this.deviceIeeeAddress, this.deviceNetworkAddress, this.ID, frame, options.timeout,
options.disableResponse, options.disableRecovery, options.srcEndpoint,
);
if (!options.disableResponse) {
this.checkStatus(result.frame.Payload);
return ZclFrameConverter.attributeKeyValue(result.frame);
} else {
return null;
}
} catch (error) {
error.message = `${log} failed (${error.message})`;
debug.error(error.message);
throw error;
}
}
public async readResponse(
clusterKey: number | string, transactionSequenceNumber: number, attributes: KeyValue, options?: Options
): Promise<void> {
assert(!options || !options.hasOwnProperty('transactionSequenceNumber'), 'Use parameter');
options = this.getOptionsWithDefaults(options, true, Zcl.Direction.SERVER_TO_CLIENT);
const cluster = Zcl.Utils.getCluster(clusterKey);
const payload: {attrId: number; status: number; dataType: number; attrData: number | string}[] = [];
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, status: 0});
} else if (!isNaN(Number(nameOrID))){
payload.push({attrId: Number(nameOrID), attrData: value.value, dataType: value.type, status: 0});
} else {
throw new Error(`Unknown attribute '${nameOrID}', specify either an existing attribute or a number`);
}
}
const frame = Zcl.ZclFrame.create(
Zcl.FrameType.GLOBAL, options.direction, options.disableDefaultResponse,
options.manufacturerCode, transactionSequenceNumber, 'readRsp', cluster.ID, payload, options.reservedBits
);
const log = `ReadResponse ${this.deviceIeeeAddress}/${this.ID} ` +
`${cluster.name}(${JSON.stringify(attributes)}, ${JSON.stringify(options)})`;
debug.info(log);
try {
await Entity.adapter.sendZclFrameToEndpoint(
this.deviceIeeeAddress, this.deviceNetworkAddress, this.ID, frame, options.timeout,
options.disableResponse, options.disableRecovery, options.srcEndpoint
);
} catch (error) {
error.message = `${log} failed (${error.message})`;
debug.error(error.message);
throw error;
}
}
public async bind(clusterKey: number | string, target: Endpoint | Group | number): Promise<void> {
const cluster = Zcl.Utils.getCluster(clusterKey);
const type = target instanceof Endpoint ? 'endpoint' : 'group';
const destinationAddress =
target instanceof Endpoint ? target.deviceIeeeAddress : (target instanceof Group ? target.groupID : target);
const log = `Bind ${this.deviceIeeeAddress}/${this.ID} ${cluster.name} from ` +
`'${target instanceof Endpoint ? `${destinationAddress}/${target.ID}` : destinationAddress}'`;
debug.info(log);
try {
await Entity.adapter.bind(
this.deviceNetworkAddress, this.deviceIeeeAddress, this.ID, cluster.ID, destinationAddress, type,
target instanceof Endpoint ? target.ID : null,
);
// NOTE: In case the bind is done by group number, it won't be saved to the database
if (!this.binds.find((b) => b.cluster.ID === cluster.ID && b.target === target)) {
if (target instanceof Group) {
this._binds.push({cluster: cluster.ID, groupID: target.groupID, type: 'group'});
} else if (target instanceof Endpoint) {
this._binds.push({
cluster: cluster.ID, type: 'endpoint', deviceIeeeAddress: target.deviceIeeeAddress,
endpointID: target.ID
});
}
this.save();
}
} catch (error) {
error.message = `${log} failed (${error.message})`;
debug.error(error.message);
throw error;
}
}
public save(): void {
this.getDevice().save();
}
public async unbind(clusterKey: number | string, target: Endpoint | Group | number): Promise<void> {
const cluster = Zcl.Utils.getCluster(clusterKey);
const type = target instanceof Endpoint ? 'endpoint' : 'group';
const destinationAddress =
target instanceof Endpoint ? target.deviceIeeeAddress : (target instanceof Group ? target.groupID : target);
const log = `Unbind ${this.deviceIeeeAddress}/${this.ID} ${cluster.name} from ` +
`'${target instanceof Endpoint ? `${destinationAddress}/${target.ID}` : destinationAddress}'`;
debug.info(log);
try {
await Entity.adapter.unbind(
this.deviceNetworkAddress, this.deviceIeeeAddress, this.ID, cluster.ID, destinationAddress, type,
target instanceof Endpoint ? target.ID : null,
);
const index = this.binds.findIndex((b) => b.cluster.ID === cluster.ID && b.target === target);
if (index !== -1) {
this._binds.splice(index, 1);
this.save();
}
} catch (error) {
error.message = `${log} failed (${error.message})`;
debug.error(error.message);
throw error;
}
}
public async defaultResponse(
commandID: number, status: number, clusterID: number, transactionSequenceNumber: number, options?: Options
): Promise<void> {
assert(!options || !options.hasOwnProperty('transactionSequenceNumber'), 'Use parameter');
options = this.getOptionsWithDefaults(options, true, Zcl.Direction.SERVER_TO_CLIENT);
const payload = {cmdId: commandID, statusCode: status};
const frame = Zcl.ZclFrame.create(
Zcl.FrameType.GLOBAL, options.direction, options.disableDefaultResponse,
options.manufacturerCode, transactionSequenceNumber, 'defaultRsp', clusterID, payload, options.reservedBits
);
const log = `DefaultResponse ${this.deviceIeeeAddress}/${this.ID} ` +
`${clusterID}(${commandID}, ${JSON.stringify(options)})`;
debug.info(log);
try {
await Entity.adapter.sendZclFrameToEndpoint(
this.deviceIeeeAddress, this.deviceNetworkAddress, this.ID, frame, options.timeout,
options.disableResponse, options.disableRecovery, options.srcEndpoint
);
} catch (error) {
error.message = `${log} failed (${error.message})`;
debug.error(error.message);
throw error;
}
}
public async configureReporting(
clusterKey: number | string, items: ConfigureReportingItem[], options?: Options
): Promise<void> {
options = this.getOptionsWithDefaults(options, true, Zcl.Direction.CLIENT_TO_SERVER);
const cluster = Zcl.Utils.getCluster(clusterKey);
const payload = items.map((item): KeyValue => {
let dataType, attrId;
if (typeof item.attribute === 'object') {
dataType = item.attribute.type;
attrId = item.attribute.ID;
} else {
/* istanbul ignore else */
if (cluster.hasAttribute(item.attribute)) {
const attribute = cluster.getAttribute(item.attribute);
dataType = attribute.type;
attrId = attribute.ID;
}
}
return {
direction: Zcl.Direction.CLIENT_TO_SERVER,
attrId, dataType,
minRepIntval: item.minimumReportInterval,
maxRepIntval: item.maximumReportInterval,
repChange: item.reportableChange,
};
});
const frame = Zcl.ZclFrame.create(
Zcl.FrameType.GLOBAL, options.direction, options.disableDefaultResponse,
options.manufacturerCode, options.transactionSequenceNumber ?? ZclTransactionSequenceNumber.next(),
'configReport', cluster.ID, payload, options.reservedBits
);
const log = `ConfigureReporting ${this.deviceIeeeAddress}/${this.ID} ` +
`${cluster.name}(${JSON.stringify(items)}, ${JSON.stringify(options)})`;
debug.info(log);
try {
const result = await Entity.adapter.sendZclFrameToEndpoint(
this.deviceIeeeAddress, this.deviceNetworkAddress, this.ID, frame, options.timeout,
options.disableResponse, options.disableRecovery, options.srcEndpoint
);
if (!options.disableResponse) {
this.checkStatus(result.frame.Payload);
}
} catch (error) {
error.message = `${log} failed (${error.message})`;
debug.error(error.message);
throw error;
}
}
public async command(
clusterKey: number | string, commandKey: number | string, payload: KeyValue, options?: Options,
): Promise<void | KeyValue> {
const cluster = Zcl.Utils.getCluster(clusterKey);
const command = cluster.getCommand(commandKey);
const hasResponse = command.hasOwnProperty('response');
options = this.getOptionsWithDefaults(options, hasResponse, Zcl.Direction.CLIENT_TO_SERVER);
const frame = Zcl.ZclFrame.create(
Zcl.FrameType.SPECIFIC, options.direction, options.disableDefaultResponse,
options.manufacturerCode, options.transactionSequenceNumber ?? ZclTransactionSequenceNumber.next(),
command.ID, cluster.ID, payload, options.reservedBits
);
const log = `Command ${this.deviceIeeeAddress}/${this.ID} ` +
`${cluster.name}.${command.name}(${JSON.stringify(payload)}, ${JSON.stringify(options)})`;
debug.info(log);
try {
const result = await Entity.adapter.sendZclFrameToEndpoint(
this.deviceIeeeAddress, this.deviceNetworkAddress, this.ID, frame, options.timeout,
options.disableResponse, options.disableRecovery, options.srcEndpoint
);
if (result) {
return result.frame.Payload;
}
} catch (error) {
error.message = `${log} failed (${error.message})`;
debug.error(error.message);
throw error;
}
}
public async commandResponse(
clusterKey: number | string, commandKey: number | string, payload: KeyValue, options?: Options,
transactionSequenceNumber?: number
): Promise<void | KeyValue> {
assert(!options || !options.hasOwnProperty('transactionSequenceNumber'), 'Use parameter');
const cluster = Zcl.Utils.getCluster(clusterKey);
const command = cluster.getCommandResponse(commandKey);
transactionSequenceNumber = transactionSequenceNumber || ZclTransactionSequenceNumber.next();
options = this.getOptionsWithDefaults(options, true, Zcl.Direction.SERVER_TO_CLIENT);
const frame = Zcl.ZclFrame.create(
Zcl.FrameType.SPECIFIC, options.direction, options.disableDefaultResponse,
options.manufacturerCode, transactionSequenceNumber, command.ID, cluster.ID, payload, options.reservedBits
);
const log = `CommandResponse ${this.deviceIeeeAddress}/${this.ID} ` +
`${cluster.name}.${command.name}(${JSON.stringify(payload)}, ${JSON.stringify(options)})`;
debug.info(log);
try {
await Entity.adapter.sendZclFrameToEndpoint(
this.deviceIeeeAddress, this.deviceNetworkAddress, this.ID, frame, options.timeout,
options.disableResponse, options.disableRecovery, options.srcEndpoint
);
} catch (error) {
error.message = `${log} failed (${error.message})`;
debug.error(error.message);
throw error;
}
}
public waitForCommand(
clusterKey: number | string, commandKey: number | string, transactionSequenceNumber: number, timeout: number,
): {promise: Promise<{header: KeyValue; payload: KeyValue}>; cancel: () => void} {
const cluster = Zcl.Utils.getCluster(clusterKey);
const command = cluster.getCommand(commandKey);
const waiter = Entity.adapter.waitFor(
this.deviceNetworkAddress, this.ID, Zcl.FrameType.SPECIFIC, Zcl.Direction.CLIENT_TO_SERVER,
transactionSequenceNumber, cluster.ID, command.ID, timeout
);
const promise = new Promise<{header: KeyValue; payload: KeyValue}>((resolve, reject) => {
waiter.promise.then(
(payload) => resolve({header: payload.frame.Header, payload: payload.frame.Payload}),
(error) => reject(error),
);
});
return {promise, cancel: waiter.cancel};
}
private getOptionsWithDefaults(
options: Options, disableDefaultResponse: boolean, direction: Zcl.Direction
): Options {
const providedOptions = options || {};
return {
timeout: 10000,
disableResponse: false,
disableRecovery: false,
disableDefaultResponse,
direction,
srcEndpoint: null,
reservedBits: 0,
manufacturerCode: null,
transactionSequenceNumber: null,
...providedOptions
};
}
public async addToGroup(group: Group): Promise<void> {
await this.command('genGroups', 'add', {groupid: group.groupID, groupname: ''});
group.addMember(this);
}
/**
* Remove endpoint from a group, accepts both a Group and number as parameter.
* The number parameter type should only be used when removing from a group which is not known
* to zigbee-herdsman.
*/
public async removeFromGroup(group: Group | number): Promise<void> {
await this.command('genGroups', 'remove', {groupid: group instanceof Group ? group.groupID : group});
if (group instanceof Group) {
group.removeMember(this);
}
}
public async removeFromAllGroups(): Promise<void> {
await this.command('genGroups', 'removeAll', {}, {disableDefaultResponse: true});
this.removeFromAllGroupsDatabase();
}
public removeFromAllGroupsDatabase(): void {
for (const group of Group.all()) {
if (group.hasMember(this)) {
group.removeMember(this);
}
}
}
}
export default Endpoint;