zigbee-clusters
Version:
Zigbee Cluster Library for Node.js
1,177 lines (1,051 loc) • 39 kB
JavaScript
'use strict';
const EventEmitter = require('events');
let { debug } = require('./util');
const { getLogId } = require('./util');
debug = debug.extend('cluster');
const {
ZCLStandardHeader, ZCLMfgSpecificHeader, ZCLAttributeDataRecord, ZCLConfigureReportingRecords,
} = require('./zclFrames');
const { ZCLStruct, ZCLDataTypes } = require('./zclTypes');
const GLOBAL_ATTRIBUTES = {
clusterRevision: { id: 0xfffd, type: ZCLDataTypes.uint16 },
attributeReportingStatus: {
id: 0xfffe,
type: ZCLDataTypes.enum8({
PENDING: 0,
COMPLETE: 1,
}),
},
};
const GLOBAL_COMMANDS = {
readAttributes: {
id: 0x00,
args: {
attributes: ZCLDataTypes.Array0(ZCLDataTypes.uint16),
},
response: {
id: 0x01,
args: {
attributes: ZCLDataTypes.buffer,
},
},
global: true,
},
writeAttributes: {
id: 0x02,
args: {
attributes: ZCLDataTypes.buffer,
},
response: {
id: 0x04,
args: {
attributes: ZCLDataTypes.Array0(ZCLStruct('AttributeResponse', {
status: ZCLDataTypes.enum8Status,
id: ZCLDataTypes.uint16,
})),
},
},
global: true,
},
writeAttributesAtomic: {
id: 0x03,
args: {
attributes: ZCLDataTypes.buffer,
},
response: {
id: 0x04,
args: {
attributes: ZCLDataTypes.Array0(ZCLStruct('AttributeResponse', {
status: ZCLDataTypes.enum8Status,
id: ZCLDataTypes.uint16,
})),
},
},
global: true,
},
writeAttributesNoResponse: {
id: 0x05,
args: {
attributes: ZCLDataTypes.buffer,
},
global: true,
},
configureReporting: {
id: 0x06,
args: {
reports: ZCLConfigureReportingRecords(),
},
response: {
id: 0x07,
args: {
reports: ZCLDataTypes.Array0(ZCLStruct('ConfigureReportingResponse', {
status: ZCLDataTypes.enum8Status,
direction: ZCLDataTypes.enum8({
reported: 0,
received: 1,
}),
attributeId: ZCLDataTypes.uint16,
})),
},
},
global: true,
},
readReportingConfiguration: {
id: 0x08,
args: {
attributes: ZCLDataTypes.Array0(ZCLStruct('ReadReportingConfiguration', {
direction: ZCLDataTypes.enum8({
reported: 0,
received: 1,
}),
attributeId: ZCLDataTypes.uint16,
})),
},
response: {
id: 0x09,
args: {
reports: ZCLConfigureReportingRecords({ withStatus: true }),
},
},
global: true,
},
reportAttributes: {
id: 0x0A,
args: {
attributes: ZCLDataTypes.buffer,
},
global: true,
},
defaultResponse: {
id: 0x0B,
args: {
cmdId: ZCLDataTypes.uint8,
status: ZCLDataTypes.enum8Status,
},
global: true,
},
discoverAttributes: {
id: 0x0C,
args: {
startValue: ZCLDataTypes.uint16,
maxResults: ZCLDataTypes.uint8,
},
response: {
id: 0x0D,
args: {
lastResponse: ZCLDataTypes.bool,
attributes: ZCLDataTypes.Array0(ZCLStruct('DiscoveredAttribute', {
id: ZCLDataTypes.uint16,
dataTypeId: ZCLDataTypes.uint8,
})),
},
},
global: true,
},
readAttributesStructured: {
id: 0x0E,
args: {
attributes: ZCLDataTypes.Array0(ZCLStruct('AttributeSelector', {
attributeId: ZCLDataTypes.uint16,
indexPath: ZCLDataTypes.Array8(ZCLDataTypes.uint16),
})),
},
response: {
id: 0x01,
args: {
attributes: ZCLDataTypes.buffer,
},
},
global: true,
},
writeAttributesStructured: {
id: 0x0F,
args: {
attributes: ZCLDataTypes.Array0(ZCLStruct('AttributeSelector', {
attributeId: ZCLDataTypes.uint16,
indexPath: ZCLDataTypes.Array8(ZCLDataTypes.uint16),
dataTypeId: ZCLDataTypes.uint8,
value: ZCLDataTypes.buffer,
})),
},
response: {
id: 0x10,
args: {
attributes: ZCLDataTypes.buffer,
},
},
global: true,
},
discoverCommandsReceived: {
id: 0x11,
args: {
startValue: ZCLDataTypes.uint8,
maxResults: ZCLDataTypes.uint8,
},
response: {
id: 0x12,
args: {
lastResponse: ZCLDataTypes.bool,
commandIds: ZCLDataTypes.Array0(ZCLDataTypes.uint8),
},
},
global: true,
},
discoverCommandsGenerated: {
id: 0x13,
args: {
startValue: ZCLDataTypes.uint8,
maxResults: ZCLDataTypes.uint8,
},
response: {
id: 0x14,
args: {
lastResponse: ZCLDataTypes.bool,
commandIds: ZCLDataTypes.Array0(ZCLDataTypes.uint8),
},
},
global: true,
},
discoverAttributesExtended: {
id: 0x15,
args: {
startValue: ZCLDataTypes.uint16,
maxResults: ZCLDataTypes.uint8,
},
response: {
id: 0x16,
args: {
lastResponse: ZCLDataTypes.bool,
attributes: ZCLDataTypes.Array0(ZCLStruct('DiscoveredAttributeExtended', {
id: ZCLDataTypes.uint16,
dataTypeId: ZCLDataTypes.uint8,
acl: ZCLDataTypes.map8('readable', 'writable', 'reportable'),
})),
},
},
global: true,
},
};
/**
* The base cluster class every other cluster implementation must extend from.
*/
class Cluster extends EventEmitter {
/**
* Create a new cluster instance.
* @param {Endpoint} endpoint - A node {@link Endpoint} instance
*/
constructor(endpoint) {
super();
this._endpoint = endpoint;
this._nextTrxSeqNr = 0;
this.name = this.constructor.NAME;
this._trxHandlers = {};
}
/**
* Stub for ID property.
* @constructor
* @abstract
* @private
*/
static get ID() {
throw new Error('cluster_id_unspecified');
}
/**
* Stub for NAME property.
* @constructor
* @abstract
* @private
*/
static get NAME() {
return new Error('cluster_name_unspecified');
}
/**
* Stub for ATTRIBUTES property.
* @constructor
* @abstract
* @private
*/
static get ATTRIBUTES() {
return {};
}
/**
* Stub for COMMANDS property.
* @constructor
* @abstract
* @private
*/
static get COMMANDS() {
return this.prototype === Cluster.prototype ? GLOBAL_COMMANDS : {};
}
/**
* Constants that refer to the direction of a frame. The direction of a frame
* is read from the frameControl property in the ZCL header. Usually a client cluster
* sends commands to a server cluster (the cluster holding the attribute values), but
* this can also be the other way around.
*/
static DIRECTION_SERVER_TO_CLIENT = 'DIRECTION_SERVER_TO_CLIENT';
static DIRECTION_CLIENT_TO_SERVER = 'DIRECTION_CLIENT_TO_SERVER';
/**
* Returns log id string for this cluster.
* @returns {string}
*/
get logId() {
let endpointId;
if (this._endpoint && typeof this._endpoint._endpointId === 'number') {
endpointId = this._endpoint._endpointId;
}
return getLogId(endpointId, this.constructor.NAME, this.constructor.ID);
}
/**
* Command which requests the remote cluster to report its generated commands. Generated
* commands are commands which may be sent by the remote cluster.
*
* TODO: handle the case where `lastResponse===false`. It might be possible that there are
* more commands to be reported than can be transmitted in one report (in practice very
* unlikely though). If `lastResponse===false` invoke `discoverCommandsGenerated` again
* starting from the index where the previous invocation stopped (`maxResults`).
*
* TODO: The manufacturer-specific sub-field SHALL be set to 0 to discover standard commands
* in a ZigBee cluster or 1 to discover manufacturer-specific commands in either a standard or
* a manufacturer-specific cluster. A manufacturer ID in this field of 0xffff (wildcard) will
* discover any manufacture- specific
* commands.
*
* @param {object} [opts=]
* @param {number} [opts.startValue=0]
* @param {number} [opts.maxResults=250]
* @returns {Promise<number[]>}
*/
async discoverCommandsGenerated({ startValue = 0, maxResults = 250 } = {}) {
const { commandIds } = await super.discoverCommandsGenerated({
startValue,
maxResults,
});
const res = commandIds.map(cId => ((this.constructor.commandsById[cId] || [])
.filter(c => !c.global)
.sort((a, b) => (a.isResponse ? 1 : 0) - (b.isResponse ? 1 : 0)) // TODO
.pop() || {})
.name || cId);
debug(this.logId, 'discoverCommandsGenerated', res);
return res;
}
/**
* Command which requests the remote cluster to report its received commands. Received
* commands are commands which may be received by the remote cluster.
*
* TODO: handle the case where `lastResponse===false`. It might be possible that there are
* more commands to be reported than can be transmitted in one report (in practice very
* unlikely though). If `lastResponse===false` invoke `discoverCommandsGenerated` again
* starting from the index where the previous invocation stopped (`maxResults`).
*
* TODO: The manufacturer-specific sub-field SHALL be set to 0 to discover standard commands
* in a ZigBee cluster or 1 to discover manufacturer-specific commands in either a standard or
* a manufacturer-specific cluster. A manufacturer ID in this field of 0xffff (wildcard) will
* discover any manufacture- specific commands.
*
* @param {object} [opts=]
* @param {number} [opts.startValue=0]
* @param {number} [opts.maxResults=255]
* @returns {Promise<number[]>}
*/
async discoverCommandsReceived({ startValue = 0, maxResults = 255 } = {}) {
const { commandIds } = await super.discoverCommandsReceived({
startValue,
maxResults,
});
const res = commandIds.map(cId => ((this.constructor.commandsById[cId] || [])
.filter(c => !c.global)
.sort((a, b) => (a.isResponse ? 0 : 1) - (b.isResponse ? 0 : 1)) // TODO
.pop() || {})
.name || cId);
debug(this.logId, 'discoverCommandsReceived', res);
return res;
}
/**
* Command which reads a given set of attributes from the remote cluster.
* Note: do not mix regular and manufacturer specific attributes.
* @param {string[]} attributeNames
* @param {{timeout: number}} [opts=]
* @returns {Promise<Object.<string, unknown>>} - Object with values (e.g. `{ onOff: true }`)
*/
async readAttributes(attributeNames, opts) {
if (attributeNames instanceof Array === false) {
throw new Error('Expected attribute names array, as of zigbee-clusters@2.0.0 call readAttributes([\'myAttr\'])');
}
if (!attributeNames.length) {
attributeNames = Object.keys(this.constructor.attributes);
}
const mismatch = attributeNames.find(n => !this.constructor.attributes[n]);
if (mismatch) {
throw new TypeError(`${mismatch} is not a valid attribute of ${this.name}`);
}
const idToName = {};
const attrIds = new Set(attributeNames.map(a => {
idToName[this.constructor.attributes[a].id] = a;
return this.constructor.attributes[a].id;
}));
const resultObj = {};
while (attrIds.size) {
// Check if command should get manufacturerSpecific flag
const manufacturerId = this._checkForManufacturerSpecificAttributes(Array.from(attrIds));
debug(this.logId, 'read attributes', [...attrIds], manufacturerId ? `manufacturer specific id ${manufacturerId}` : '');
const { attributes } = await super.readAttributes({
attributes: [...attrIds],
manufacturerId,
}, opts);
debug(this.logId, 'read attributes result', { attributes });
const result = this.constructor.attributeArrayStatusDataType.fromBuffer(attributes, 0);
if (!result.length) break;
result.forEach(a => {
attrIds.delete(a.id);
if (a.status === 'SUCCESS') {
resultObj[idToName[a.id]] = a.value;
}
});
}
return resultObj;
}
/**
* Command which writes a given set of attribute key-value pairs to the remote cluster.
* Note: do not mix regular and manufacturer specific attributes.
* @param {object} attributes - Object with attribute names as keys and their values (e.g. `{
* onOff: true, fakeAttributeName: 10 }`.
* @returns {Promise<*|{attributes: *}>}
*/
async writeAttributes(attributes = {}) {
const arr = Object.keys(attributes).map(n => {
const attr = this.constructor.attributes[n];
if (!attr) {
throw new TypeError(`${n} is not a valid attribute of ${this.name}`);
}
return {
id: attr.id,
value: attributes[n],
};
});
// Check if command should get manufacturerSpecific flag
const manufacturerId = this._checkForManufacturerSpecificAttributes(
Object.keys(attributes).map(n => this.constructor.attributes[n].id),
);
let data = Buffer.alloc(1024);
data = data.slice(0, this.constructor.attributeArrayDataType.toBuffer(data, arr, 0));
debug(this.logId, 'write attributes', attributes, manufacturerId ? `manufacturer specific id ${manufacturerId}` : '');
return super.writeAttributes({ attributes: data, manufacturerId });
}
/**
* Command which configures attribute reporting for the given `attributes` on the remote cluster.
* Note: do not mix regular and manufacturer specific attributes.
* @param {object} attributes - Attribute reporting configuration (e.g. `{ onOff: {
* minInterval: 0, maxInterval: 300, minChange: 1 } }`)
* @returns {Promise<void>}
*/
async configureReporting(attributes = {}) {
const req = [];
// eslint-disable-next-line guard-for-in,no-restricted-syntax
for (const attributeName in attributes) {
const attr = this.constructor.attributes[attributeName];
if (!attr) throw new TypeError(`${attributeName} Does not exist (${this.constructor.name})`);
const config = {
direction: 'reported',
attributeId: attr.id,
attributeDataType: attr.type.id,
minInterval: 0,
maxInterval: 0xffff,
minChange: 1,
...attributes[attributeName],
};
// Set minChange to zero when this condition is true (see ZCL spec 2.5.7.1.7.)
if (config.maxInterval === 0x0000 && config.minInterval === 0xffff) {
config.minChange = 0;
}
// Strip the `minChange` attribute for non-analog attributes (see ZCL spec 2.5.7.1.7.)
if (!attr.type.isAnalog) {
delete config.minChange;
}
req.push(config);
}
if (req.length) {
// Check if command should get manufacturerSpecific flag
const manufacturerId = this._checkForManufacturerSpecificAttributes(
Object.keys(attributes).map(n => this.constructor.attributes[n].id),
);
debug(this.logId, 'configure reporting', req, manufacturerId ? `manufacturer specific id ${manufacturerId}` : '');
const { reports } = await super.configureReporting({ reports: req, manufacturerId });
debug(this.logId, 'configured reporting', reports);
for (const result of reports) {
if (result.status !== 'SUCCESS') {
throw new Error(result.status);
}
}
}
}
/**
* @typedef {object} ReadReportingConfiguration
* @property {ZCLDataTypes.enum8Status} status
* @property {'reported'|'received'} direction
* @property {number} attributeId
* @property {ZCLDataType.id} [attributeDataType]
* @property {number} [minInterval]
* @property {number} [maxInterval]
* @property {number} [minChange]
* @property {number} [timeoutPeriod]
*/
/**
* Command which retrieves the reporting configurations for the given `attributes` from the
* remote cluster. Currently this only takes the 'reported' into account, this represents the
* reports the remote cluster would sent out, instead of receive (which is likely the most
* interesting).
* Note: do not mix regular and manufacturer specific attributes.
* @param {Array} attributes - Array with number/strings (either attribute id, or attribute name).
* @returns {Promise<ReadReportingConfiguration[]>} - Returns array with
* ReadReportingConfiguration objects per attribute.
*/
async readReportingConfiguration(attributes = []) {
const req = [];
// Loop all the provided attributes
for (const attribute of attributes) {
let attrId;
if (typeof attribute === 'number') {
attrId = attribute;
} else if (typeof attribute === 'string' && this.constructor.attributes[attribute]) {
attrId = this.constructor.attributes[attribute].id;
}
// Check for valid attribute id
if (typeof attrId !== 'number') {
throw new Error(`Could not find attrId ${attrId} on cluster ${this.constructor.NAME}`);
}
// Push configuration
req.push({
direction: 'reported', // We are only interested in the reported direction, this
// property acts a filter and will only retrieve the reporting configuration for
// attributes which are being reported by the remote cluster (as opposed to being
// 'received' by the remote cluster)
attributeId: attrId,
});
}
// Check if command should get manufacturerSpecific flag
const manufacturerId = this._checkForManufacturerSpecificAttributes(
attributes
.map(attributeNameOrId => {
if (typeof attributeNameOrId === 'number') return attributeNameOrId;
return this.constructor.attributes[attributeNameOrId].id;
}),
);
debug(this.logId, 'read reporting configuration', req, manufacturerId ? `manufacturer specific id ${manufacturerId}` : '');
// If a request has been constructed execute it
if (req.length) {
// Perform request, it returns a buffer that needs to be parsed
const { reports } = await super.readReportingConfiguration({
attributes: req,
manufacturerId,
});
debug(this.logId, 'read reporting configuration result', reports);
// Return the parsed reports
return reports;
}
// Return empty array
return [];
}
/**
* Command which discovers the implemented attributes on the remote cluster.
*
* TODO: handle the case where `lastResponse===false`. It might be possible that there are
* more commands to be reported than can be transmitted in one report (in practice very
* unlikely though). If `lastResponse===false` invoke `discoverCommandsGenerated` again
* starting from the index where the previous invocation stopped (`maxResults`).
*
* TODO: The manufacturer specific sub-field SHALL be set to 0 to discover standard attributes
* in a ZigBee cluster or 1 to discover manufacturer specific attributes in either a standard
* or a manufacturer specific cluster.
*
* @returns {Promise<Array>} - Array with string or number values (depending on if the
* attribute
* is implemented in zigbee-clusters or not).
*/
async discoverAttributes() {
const { attributes } = await super.discoverAttributes({
startValue: 0,
maxResults: 255,
});
const result = [];
for (const attr of attributes) {
// Push the name if attribute is implemented in zigbee-clusters otherwise push attribute id
result.push(this.constructor.attributesById[attr.id]
? this.constructor.attributesById[attr.id].name
: attr.id);
}
debug(this.logId, 'discover attributes', result);
return result;
}
/**
* Command which discovers the implemented attributes on the remote cluster, the difference with
* `discoverAttributes` is that this command also reports the access control field of the
* attribute (whether it is readable/writable/reportable).
*
* TODO: handle the case where `lastResponse===false`. It might be possible that there are
* more commands to be reported than can be transmitted in one report (in practice very
* unlikely though). If `lastResponse===false` invoke `discoverCommandsGenerated` again
* starting from the index where the previous invocation stopped (`maxResults`).
*
* TODO: The manufacturer-specific sub-field SHALL be set to 0 to discover standard attributes
* in a ZigBee cluster or 1 to discover manufacturer-specific attributes in either a standard
* or a manufacturer- specific cluster. A manufacturer ID in this field of 0xffff (wildcard)
* will discover any manufacture-specific attributes.
*
* @returns {Promise<Array>} - Returns an array with objects with attribute names as keys and
* following object as values: `{name: string, id: number, acl: { readable: boolean, writable:
* boolean, reportable: boolean } }`. Note that `name` is optional based on whether the
* attribute is implemented in zigbee-clusters.
*/
async discoverAttributesExtended() {
const { attributes } = await super.discoverAttributesExtended({
startValue: 0,
maxResults: 250,
});
const result = [];
for (const attr of attributes) {
const attribute = this.constructor.attributesById[attr.id];
const discoveredAttribute = {
acl: attr.acl,
id: attr.id,
};
// If the attribute is implemented in zigbee-clusters add name
if (attribute) {
discoveredAttribute.name = attribute.name;
}
result.push(discoveredAttribute);
}
debug(this.logId, 'discover attributes extended', result);
return result;
}
/**
* Handles an incoming frame on this specific cluster instance. It will be matched against the
* known commands, only known commands are handled. If the command is known AND it is not a
* direct response on a previously send frame AND a handler for this command is registered on
* this cluster instance, this will be called.
* @param frame
* @param meta
* @param rawFrame
* @returns {Promise}
* @private
*
* @example
* // This handler will be called when the `toggle` command is received.
* cluster.onToggle = payload => console.log('received toggle command', payload);
*/
async handleFrame(frame, meta, rawFrame) {
const commands = this.constructor.commandsById[frame.cmdId] || [];
// Filter commands of this cluster based on cluster specific vs global,
// and manufacturer specific vs not manufacturer specific.
let filteredCommands = commands.filter(cmd => frame.frameControl.clusterSpecific === !cmd.global
&& (cmd.global || frame.frameControl.manufacturerSpecific === !!cmd.manufacturerId)
&& (cmd.global || !frame.frameControl.manufacturerSpecific
|| frame.manufacturerId === cmd.manufacturerId));
// Try to filter based on frame direction, note: this is optional as a cluster command
// does not always have a 'direction' property. The filter ensure that multiple commands
// can share the same command id. If so, it is required to add the direction property
// to both command definitions (see iasZone.js as an example). Cluster.js only receives
// frames with directionToClient=true. BoundCluster.js receives frames with
// directionToClient=false.
filteredCommands = filteredCommands.filter(command => {
// Only filter commands that have a direction string property
if (typeof command.direction === 'string') {
// Filter out commands marked as DIRECTION_CLIENT_TO_SERVER when
// frameControl.directionToClient = true
if (frame.frameControl.directionToClient
&& command.direction === Cluster.DIRECTION_CLIENT_TO_SERVER) {
return false;
}
}
return true;
});
// Sort and pop the selected command from the array
const command = filteredCommands
.sort((a, b) => (a.isResponse ? 1 : 0) - (b.isResponse ? 1 : 0))
.pop();
if (command) {
const handlerName = `on${command.name.charAt(0).toUpperCase()}${command.name.slice(1)}`;
// Parse the command arguments
const args = command.args
? command.args.fromBuffer(frame.data, 0)
: undefined;
debug(this.logId, 'received frame', command.name, args);
// Invoke the right handler
const handler = this._trxHandlers[frame.trxSequenceNumber] || this[handlerName];
delete this._trxHandlers[frame.trxSequenceNumber];
if (handler) {
const response = await handler.call(this, args, meta, frame, rawFrame);
if (command.response && command.response.args) {
// eslint-disable-next-line new-cap
return [command.response.id, new command.response.args(response)];
}
// eslint-disable-next-line consistent-return
return;
}
}
debug(this.logId, 'unknown command received:', frame, meta);
throw new Error('unknown_command_received');
}
/**
* Handles sending a frame to the remote cluster.
* @param {object} data
* @returns {Promise<*>}
* @private
*/
async sendFrame(data) {
data = {
frameControl: ['clusterSpecific'],
data: Buffer.alloc(0),
...data,
};
if (!data.frameControl.includes('manufacturerSpecific')) {
data = new ZCLStandardHeader(data);
} else {
data = new ZCLMfgSpecificHeader(data);
}
debug(this.logId, 'send frame', data);
return this._endpoint.sendFrame(this.constructor.ID, data.toBuffer());
}
/**
* START MESSAGE HANDLERS:
*/
/**
* Message handler which is called from `handleFrame` when the incoming frame is a attribute
* report. This handler will emit the received attribute properties.
* @param {object} attributes - The received report object.
* @returns {Promise<void>}
* @private
*/
async onReportAttributes({ attributes } = {}) {
attributes = this.constructor.attributeArrayDataType.fromBuffer(attributes, 0);
attributes.forEach(attr => this.emit(`attr.${attr.name}`, attr.value));
}
/**
* Message handler which is called from `handleFrame` when the incoming frame is a discover
* commands generated response. Generated commands are commands which may be sent by this cluster.
* @param {number} [startValue=0]
* @param {number} [maxResults=250]
* @returns {Promise<{commandIds: number[], lastResponse: boolean}>}
* @private
*/
async onDiscoverCommandsGenerated({ startValue = 0, maxResults = 250 } = {}) {
const cmds = [].concat(...Object.values(this.constructor.commandsById))
.filter(c => !c.global && !c.isResponse && this[c.name])
.map(c => c.id)
.sort()
.filter(cId => cId >= startValue);
const result = cmds.slice(0, maxResults);
debug(this.logId, 'onDiscoverCommandsGenerated', {
lastResponse: result.length === cmds.length,
commandIds: result,
});
return {
lastResponse: result.length === cmds.length,
commandIds: result,
};
}
/**
* Message handler which is called from `handleFrame` when the incoming frame is a discover
* commands received response. Received commands are commands which may be received by
* this cluster.
* @param {number} [startValue=0]
* @param {number} [maxResults=250]
* @returns {Promise<{commandIds: number[], lastResponse: boolean}>}
* @private
*/
async onDiscoverCommandsReceived({ startValue = 0, maxResults = 250 } = {}) {
const cmds = [].concat(...Object.values(this.constructor.commandsById))
.filter(c => !c.global && c.response
&& (this[c.name] || this[`on${c.name.charAt(0).toUpperCase()}${c.name.slice(1)}`]))
.map(c => c.response.id)
.sort()
.filter(cId => cId >= startValue);
const result = cmds.slice(0, maxResults);
debug(this.logId, 'onDiscoverCommandsReceived', {
lastResponse: result.length === cmds.length,
commandIds: result,
});
return {
lastResponse: result.length === cmds.length,
commandIds: result,
};
}
// TODO: implement if needed
// async writeAttributesAtomic(attributes = {}) {
// const arr = Object.keys(attributes).map(n => {
// const attr = this.constructor.attributes[n];
// if (!attr) {
// throw new TypeError(`${n} is not a valid attribute of ${this.name}`);
// }
// return {
// id: attr.id,
// data: attributes[n],
// };
// });
//
// let data = Buffer.alloc(1024);
// data = data.slice(0, this.constructor.attributeArrayDataType.toBuffer(data, arr, 0));
//
// return super.writeAttributesAtomic({ attributes: data });
// }
// TODO: implement if needed
// async writeAttributesNoResponse(attributes = {}) {
// const arr = Object.keys(attributes).map(n => {
// const attr = this.constructor.attributes[n];
// if (!attr) {
// throw new TypeError(`${n} is not a valid attribute of ${this.name}`);
// }
// return {
// id: attr.id,
// data: attributes[n],
// };
// });
//
// let data = Buffer.alloc(1024);
// data = data.slice(0, this.constructor.attributeArrayDataType.toBuffer(data, arr, 0));
//
// return super.writeAttributesNoResponse({ attributes: data });
// }
/**
* Add a cluster class. This should be called whenever a custom Cluster implementation has
* been created before it will be available on the node.
* @param {Cluster} clusterClass - The class, not an instance.
*
* @example
*
* const { Cluster } = require('zigbee-clusters');
*
* const MyCluster extends Cluster {
* // Implement custom cluster logic here
* get NAME() {
* return 'myClusterName';
* }
* }
*
* Cluster.addCluster(MyCluster);
*
* // Now it will be available
* zclNode.endpoints[1].clusters['myClusterName'].doSomething();
*/
static addCluster(clusterClass) {
this._addPrototypeMethods(clusterClass);
this.clusters[clusterClass.ID] = clusterClass;
this.clusters[clusterClass.NAME] = clusterClass;
}
/**
* Remove cluster by ID or NAME.
* @param {string|number} clusterIdOrName
*/
static removeCluster(clusterIdOrName) {
if (this.clusters[clusterIdOrName]) {
// eslint-disable-next-line no-shadow
const Cluster = this.clusters[clusterIdOrName];
delete this.clusters[Cluster.NAME];
delete this.clusters[Cluster.ID];
}
}
/**
* Get a cluster instance by ID or NAME.
* @param {string|number} clusterIdOrName
* @returns {Cluster}
*/
static getCluster(clusterIdOrName) {
return this.clusters[clusterIdOrName];
}
/**
* Generates next transaction sequence number.
* @returns {number} - Transaction sequence number.
* @private
*/
nextSeqNr() {
this._nextTrxSeqNr = (this._nextTrxSeqNr + 1) % 256;
return this._nextTrxSeqNr;
}
async _awaitPacket(trxSequenceNumber, timeout = 25000) {
if (this._trxHandlers[trxSequenceNumber]) {
throw new Error(`already waiting for this trx: ${trxSequenceNumber}`);
}
return new Promise((resolve, reject) => {
const t = setTimeout(() => {
delete this._trxHandlers[trxSequenceNumber];
reject(new Error('Timeout: Expected Response'));
}, timeout);
this._trxHandlers[trxSequenceNumber] = async frame => {
delete this._trxHandlers[trxSequenceNumber];
resolve(frame);
clearTimeout(t);
};
});
}
// / START STATIC METHODS
// Adds command proxy stubs to a proto object which is one level higher.
// this way you can 'override' the commands and still use `super.` to access the default
// implementation
static _addPrototypeMethods(clusterClass) {
const firstProto = Object.getPrototypeOf(clusterClass.prototype);
const proto = Object.create(firstProto);
Object.setPrototypeOf(clusterClass.prototype, proto);
const commands = clusterClass.COMMANDS;
clusterClass.attributes = {
...GLOBAL_ATTRIBUTES,
...clusterClass.ATTRIBUTES,
};
clusterClass.commands = {
...GLOBAL_COMMANDS,
...clusterClass.COMMANDS,
};
clusterClass.attributesById = Object.entries(clusterClass.attributes).reduce((r, [name, a]) => {
r[a.id] = { ...a, name };
return r;
}, {});
clusterClass.attributeArrayStatusDataType = ZCLDataTypes.Array0(
ZCLAttributeDataRecord(true, clusterClass.attributesById),
);
clusterClass.attributeArrayDataType = ZCLDataTypes.Array0(
ZCLAttributeDataRecord(false, clusterClass.attributesById),
);
// Ids are not unique
clusterClass.commandsById = Object.entries(clusterClass.commands).reduce((r, [name, _cmd]) => {
const cmd = { ..._cmd, name };
if (cmd.args) {
cmd.args = ZCLStruct(`${clusterClass.NAME}.${name}`, cmd.args);
if (_cmd === GLOBAL_COMMANDS.defaultResponse) {
clusterClass.defaultResponseArgsType = cmd.args;
}
}
if (r[cmd.id]) {
r[cmd.id].push(cmd);
} else {
r[cmd.id] = [cmd];
}
if (cmd.response) {
const res = { ...cmd.response, name: `${name}.response`, isResponse: true };
cmd.response = res;
if (typeof res.id !== 'number') {
res.id = cmd.id;
}
if (res.args) {
res.args = ZCLStruct(`${clusterClass.NAME}.${res.name}`, res.args);
}
if (cmd.global) res.global = true;
if (cmd.manufacturerSpecific) res.manufacturerSpecific = true;
if (r[res.id]) {
r[res.id].push(res);
} else {
r[res.id] = [res];
}
}
return r;
}, {});
// eslint-disable-next-line guard-for-in,no-restricted-syntax
for (const cmdName in commands) {
Object.defineProperty(proto, cmdName, {
value: {
async [cmdName](args, opts = {}) {
const cmd = commands[cmdName];
const payload = {
cmdId: cmd.id,
trxSequenceNumber: this.nextSeqNr(),
};
if (cmd.global) {
payload.frameControl = [];
// Some global commands can also be manufacturerSpecific (e.g. read/write manuf
// specific attributes), in that case the manuf id needs to be parsed from the
// args as it is a dynamic property which can not be defined on the command.
if (args.manufacturerId !== undefined) {
if (typeof args.manufacturerId === 'number') {
payload.frameControl.push('manufacturerSpecific');
payload.manufacturerId = args.manufacturerId;
}
// Always delete it as it is not part of the command args
delete args.manufacturerId;
}
}
if (cmd.manufacturerId) {
payload.frameControl = ['clusterSpecific', 'manufacturerSpecific'];
payload.manufacturerId = cmd.manufacturerId;
}
if (cmd.frameControl) {
payload.frameControl = cmd.frameControl;
}
if (cmd.args) {
const CommandArgs = ZCLStruct(`${this.name}.${cmdName}`, cmd.args);
payload.data = new CommandArgs(args);
}
if (payload.frameControl && payload.frameControl.includes('disableDefaultResponse')) {
return this.sendFrame(payload);
}
// This is a workaround for nodes that send a default response, even if they are sending
// another response message (this is not allowed by zigbee cluster spec §2.5.12.2).
// The option sets the 'Disable Default Response' flag in the ZCL header,
// causing the node to only send a default response if an error occurred.
// A normal response from the node is still required to resolve this cluster command.
if (opts.disableDefaultResponse) {
if (!Array.isArray(payload.frameControl)) {
// Need to add the 'clusterSpecific' flag here,
// because it is not added by 'sendFrame' if the frameControl flags are present.
payload.frameControl = ['clusterSpecific', 'disableDefaultResponse'];
} else if (!payload.frameControl.includes('disableDefaultResponse')) {
payload.frameControl.push('disableDefaultResponse');
}
}
if (opts.waitForResponse === false) {
return this.sendFrame(payload);
}
// Check if a valid timeout override is provided
let responseTimeout;
if (typeof opts.timeout === 'number') {
responseTimeout = opts.timeout;
}
const [response] = await Promise.all([
this._awaitPacket(payload.trxSequenceNumber, responseTimeout),
this.sendFrame(payload),
]);
if (response instanceof this.constructor.defaultResponseArgsType) {
if (response.status !== 'SUCCESS') {
throw new Error(response.status);
}
// eslint-disable-next-line consistent-return
return;
}
return response;
},
}[cmdName],
});
}
}
/**
* Given an array of attribute ids it checks if all of the attributes are manufacturer
* specific attributes. If that is the case it will return the manufacturerId so that the
* command can set the manufacturerSpecific flag.
* @param {number[]} attributeIds
* @returns {null|number}
* @private
*/
_checkForManufacturerSpecificAttributes(attributeIds) {
// Convert to set
const attrIdsSet = new Set(attributeIds);
// Filter attributeIds for manufacturer specific attributes
const manufacturerIds = [];
for (const attribute of Object.values(this.constructor.attributes)) {
if (attrIdsSet.has(attribute.id) && typeof attribute.manufacturerId === 'number') {
manufacturerIds.push(attribute.manufacturerId);
}
}
// Do not allow different manufacturer ids in one command
if (new Set(manufacturerIds).size > 1) {
throw new Error('Error: detected multiple manufacturer ids, can only read from one at a time');
}
// Show warning if a manufacturer specific attribute was found amongst non-manufacturer
// specific attributes
if (manufacturerIds.length > 0 && attrIdsSet.size !== manufacturerIds.length) {
debug(this.logId, 'WARNING expected only manufacturer specific attributes got:', manufacturerIds);
}
// Return the manufacturerId that was found in the attributes
if (attrIdsSet.size === manufacturerIds.length) return manufacturerIds[0];
return null;
}
}
Cluster.clusters = {};
Cluster._addPrototypeMethods(Cluster);
module.exports = Cluster;