UNPKG

homey-zigbeedriver

Version:

This module can be used to make the development of Zigbee apps for Homey easier.

1,234 lines (1,084 loc) 59.1 kB
'use strict'; // eslint-disable-next-line node/no-unpublished-require const Homey = require('homey'); const { ZCLNode } = require('zigbee-clusters'); const ZigBeeDriver = require('./ZigBeeDriver'); const { __, assertClusterSpecification, assertCapabilityId, assertZCLNode, wrapAsyncWithRetry, recursiveDeepCopy, } = require('./util'); const CAPABILITIES_DEBOUNCE = 500; // ms const CONFIGURED_ATTRIBUTE_REPORTING_STORE_KEY = 'configuredAttributeReporting'; const SW_BUILD_ID = '__swBuildId'; /** * Every {@link ClusterCapabilityConfiguration} will be extended from this defaults object to * ensure properties have expected defaults. * @type {{endpoint: number, set: null, getOpts: {}, get: null, report: null}} * @private */ const CLUSTER_CAPABILITY_CONFIGURATION_DEFAULTS = { get: null, getOpts: {}, set: null, report: null, endpoint: 1, }; /** * Every {@link AttributeReportingConfiguration} will be extended from this defaults object to * ensure properties have expected defaults. * @type {{minInterval: number, minChange: number}} * @private */ const ATTRIBUTE_REPORTING_CONFIGURATION_DEFAULTS = { minInterval: 0, minChange: 1, }; /** * Store value key used for determining if node is just paired or has been initialized before. * @constant * @type {string} * @private */ const FIRST_INIT = 'zb_first_init'; /** * End device announce event key. * @constant * @type {string} * @private */ const END_DEVICE_ANNOUNCE_EVENT = 'endDeviceAnnounce'; /** * @typedef {string} CapabilityId - Homey.Device capability id (e.g. `onoff`) */ /** * @typedef {object} ClusterSpecification - Object containing the cluster name and id. * @property {string} NAME - Cluster name (e.g. 'onOff') * @property {number} ID - Cluster id (e.g. 4) */ /** * @typedef {number} EndpointId - Zigbee {@link Endpoint.ID} (e.g. 1) */ /** * @extends Homey.Device * @example * const { ZigBeeDevice } = require('homey-zigbeedriver'); * * class ZigBeeBulb extends ZigBeeDevice { * onNodeInit({ zclNode }) { * await zclNode.endpoints[1].clusters.onOff.toggle(); * } * } */ class ZigBeeDevice extends Homey.Device { /** * This method can be overridden. It will be called when the {@link ZigBeeDevice} instance is * ready and did initialize a {@link ZCLNode}. * @param {ZCLNode} zclNode * @param {Homey.ZigBeeNode} node * @abstract * * @example * const { ZigBeeDevice } = require('homey-zigbeedriver'); * * class ZigBeeBulb extends ZigBeeDevice { * onNodeInit({ zclNode }) { * await zclNode.endpoints[1].clusters.onOff.toggle(); * } * } */ onNodeInit({ zclNode, node }) { } /** * @deprecated since v1.0.0 - Legacy from homey-meshdriver, use {@link onNodeInit} instead. * This method can be overridden. It will be called when the {@link ZigBeeDevice} instance is * ready and did initialize a {@link Homey.ZigBeeNode}. * @abstract */ onMeshInit() { } /** * This method can be overridden. It will be called when the {@link Homey.ZigBeeNode} * instance received a end device announce indication from the node itself. For sleepy devices * this means that the node is temporarily `online` to handle some requests. For powered * devices this usually means that they have been re-powered. Note: behaviour may differ between * devices. * @abstract */ onEndDeviceAnnounce() { this.log('Received end device announce indication'); } /** * This method can be overridden to use different energy objects per Zigbee device `productId`. * @abstract * @returns {object.<{string}, {object}>} * * @example * class ZigBeeBulb extends ZigBeeDevice { * get energyMap() { * return { * 'TRADFRI bulb E14 W op/ch 400lm': { * approximation: { * usageOff: 0, * usageOn: 10, * }, * }, * 'TRADFRI bulb E27 RGB 1000lm': { * approximation: { * usageOff: 0, * usageOn: 18, * }, * }, * }; * } * } */ get energyMap() { return {}; } /** * Overrides {@link Homey.Device.getEnergy} to enable zigbee devices to expose a {@link energyMap} * object with different energy objects `productId` (as specified in the driver manifest). If * the `energyMap` object is available and has an entry for the `productId` of this device * this entry will be added to the energy object in the drivers' manifest. * @since Homey v3.0.0 * @returns {object} - Energy object */ getEnergy() { const energy = super.getEnergy(); const zigbeeProductId = this.getSetting('zb_product_id'); if (zigbeeProductId && this.energyMap[zigbeeProductId]) { return { ...energy, ...this.energyMap[zigbeeProductId], }; } return energy; } /** * Triggers a flow. * @param {string} id - Flow id * @param {object} tokens * @param {object} state * @returns {Promise<T>} */ async triggerFlow({ id, tokens = {}, state = {} }) { if (typeof id !== 'string') throw new TypeError('expected_flow_id_string'); // Get device trigger card instance const deviceTriggerCard = this.homey.flow.getDeviceTriggerCard(id); if (!deviceTriggerCard) { this.error('Error: failed to get device trigger flow card', { id }); throw new Error('failed_to_get_device_trigger_card'); } this.debug('trigger flow', { id, tokens, state }); // Return trigger promise return deviceTriggerCard .trigger(this, tokens, state) .catch(err => { this.error('Error: flow trigger device failed', { id, tokens, state }, err); throw err; }); } /** * @typedef {function} SetParserFunction * * This method is given a `setValue` and will use that to generate an object with the needed * command attributes as specified in {@link Cluster.COMMANDS}. This object will be provided * to the Cluster command as parameters when executed. * * @param {any} setValue * @returns {Promise<object|null>} - If return value is `null` the command will not be executed. */ /** * @typedef {function} ReportParserFunction * * This method is called when a report is received for the `report` attribute. In this method the * `reportValue` can be parsed and mapped to become a valid Homey.Device capability value. * * @param {any} reportValue * @returns {any|null|Promise} - If return value is `null` the Homey.Device capability value * will not be changed. */ /** * @typedef {object} ClusterCapabilityConfiguration * * @property {string} [get] - Cluster attribute as specified in {@link Cluster.ATTRIBUTES}. This * attribute will be fetched by {@link Cluster.readAttributes} when the capability value needs * to be fetched. * * @property {string} [set] - Cluster command as specified in {@link Cluster.COMMANDS}, this * command will be executed when the capability is set. * @property {SetParserFunction} [setParser] - Method that will be called before `set` is * called, to generate the parameters for the Cluster command execution. * * @property {string} [report] - Cluster attribute as specified in {@link Cluster.ATTRIBUTES}. * When a report is received for this attribute the respective `reportParser` will be called. * @property {ReportParserFunction} [reportParser] * * @property {EndpointId} [endpoint=1] - The {@link ZCLNode}'s endpoint to use for this * configuration. * * @property {object} [getOpts] - Options object specific for `get`. * @property {boolean} [getOpts.getOnStart=false] - Fetches the `get` attribute when the * {@link ZCLNode} is first initialized and the capability value is unknown (i.e. `null`). * Note: this only works for non-sleepy devices. * @property {boolean} [getOpts.getOnOnline=false] - Fetches the `get` attribute when the * {@link ZCLNode} comes online (i.e. Homey received an end device announce indication, * directly after receiving this a sleepy node should be able to respond to any request). * @property {number|string} [getOpts.pollInterval] - Number: interval (in ms) to poll `get`. * String: the Homey.Device's setting key which represents a user configurable poll interval * value. */ /** * Map a Zigbee cluster to a Homey.Device capability. Using the provided cluster configuration * a mapping will be made between the device's capability and the Zigbee cluster. * @param {CapabilityId} capabilityId - Homey.Device capability id (e.g. `onoff`) * @param {ClusterSpecification} cluster - Cluster specification (id and name) * @param {ClusterCapabilityConfiguration} [clusterCapabilityConfiguration] - User provided * ClusterCapabilityMapConfiguration, these will override and extend the system cluster * capability map configuration if available (e.g. ./system/capabilities/onoff). * * @example * const { CLUSTER } = require('zigbee-clusters'); * * this.registerCapability('onoff', CLUSTER.ON_OFF, { * // This is often just a string, but can be a function as well * set: value => (value ? 'setOn' : 'setOff'), * setParser(setValue) { * // In case a "set command" takes an argument you can return it from the setParser * }, * get: 'onOff', * report: 'onOff', * reportParser(report) { * if (report && report.onOff === true) return true; * return false; * }, * reportOpts: { * configureAttributeReporting: { * minInterval: 3600, // Minimally once every hour * maxInterval: 60000, // Maximally once every ~16 hours * minChange: 1, * }, * }, * endpoint: 1, // Default is 1 * getOpts: { * getOnStart: true, * getOnOnline: true, * pollInterval: 30000, // in ms * }, * }); */ registerCapability(capabilityId, cluster, clusterCapabilityConfiguration) { assertClusterSpecification(cluster); assertCapabilityId(capabilityId, this.hasCapability.bind(this)); this.debug(`register capability ${capabilityId} with cluster ${cluster.NAME}`); // Merge system and user clusterCapabilityConfiguration this._mergeSystemAndUserClusterCapabilityConfigurations( capabilityId, cluster, clusterCapabilityConfiguration, ); this.debug(`registered capability ${capabilityId} with cluster ${cluster.NAME}, configuration:`, this._getClusterCapabilityConfiguration(capabilityId, cluster)); // Register get/set/report this._registerCapabilitySet(capabilityId, cluster); // Register reporting before getting this._registerCapabilityReport(capabilityId, cluster); this._registerCapabilityGet(capabilityId, cluster); } /** * @typedef {object} MultipleCapabilitiesConfiguration * @property {CapabilityId} capabilityId * @property {ClusterSpecification} cluster * @property {ClusterCapabilityConfiguration} userOpts */ /** * Register multiple Homey.Device capabilities with a {@link ClusterCapabilityConfiguration}. * When a capability is changed (or multiple in quick succession), the event will be debounced * with the other capabilities in the multipleCapabilitiesConfiguration array. * @param {MultipleCapabilitiesConfiguration[]} multipleCapabilitiesConfiguration - * Configuration options for multiple capability cluster mappings. * @param {function} multipleCapabilitiesListener - Called after debounce of * {@link CAPABILITIES_DEBOUNCE}. As fallback, if this function returns a falsy value or an Error * each changed capability will be processed individually instead of together. * * @example * const { CLUSTER } = require('zigbee-clusters'); * * this.registerMultipleCapabilities([ * { * // This one will extend the system capability and override the `setParser` * capabilityId: 'onoff', * cluster: CLUSTER.ON_OFF, * userOpts: { * setParser(setValue) { * // do something different here * }, * }, * }, * { * // This one will extend the system capability * capabilityId: 'dim', * cluster: CLUSTER.LEVEL_CONTROL, * } * ], event => { * // Debounced event when one or more capabilities have changed * }); */ registerMultipleCapabilities( multipleCapabilitiesConfiguration = [], multipleCapabilitiesListener, ) { this.debug(`register multiple capabilities [${multipleCapabilitiesConfiguration.map(x => x.capabilityId || x.capability).join(', ')}]`); // Loop all provided capability configurations multipleCapabilitiesConfiguration.forEach(capabilityConfiguration => { // TODO: `capability` and `opts` are legacy properties, remove with next major update const capabilityId = capabilityConfiguration.capabilityId || capabilityConfiguration.capability; const { cluster } = capabilityConfiguration; assertClusterSpecification(cluster); assertCapabilityId(capabilityId, this.hasCapability.bind(this)); const userClusterCapabilityConfiguration = capabilityConfiguration.userOpts || capabilityConfiguration.opts || {}; // Override default system opts with user opts this._mergeSystemAndUserClusterCapabilityConfigurations( capabilityId, cluster, userClusterCapabilityConfiguration, ); this.debug(`register multiple capabilities → registered ${capabilityId}, with configuration:`, this._getClusterCapabilityConfiguration(capabilityId, cluster)); // Register capability getter this._registerCapabilityGet(capabilityId, cluster); }); // Register multiple capabilities with a debounce this.registerMultipleCapabilityListener( // TODO: `capability` is legacy property, remove with next major update multipleCapabilitiesConfiguration.map(x => x.capabilityId || x.capability), async (valueObj, optsObj) => { this.debug(`multiple capabilities listener [${multipleCapabilitiesConfiguration.map(x => x.capabilityId || x.capability).join(', ')}]`, valueObj, optsObj); // Call the provided `multipleCapabilitiesListener` method to let the device handle the // multiple capability changes, this often returns a promise, do not await but return it // instead const result = multipleCapabilitiesListener(valueObj, optsObj); // If it did not handle it for some reason, we will process each capability value one by one if (!result || result instanceof Error) { this.debug(`multiple capabilities listener [${multipleCapabilitiesConfiguration.map(x => x.capabilityId || x.capability).join(', ')}] → fallback`); // Loop all changed capabilities const setClusterCapabilityValuePromises = []; for (const capabilityId of Object.keys(valueObj)) { // Find capability object from configuration const capabilityObj = multipleCapabilitiesConfiguration .find(x => x.capabilityId === capabilityId || x.capability === capabilityId); const value = valueObj[capabilityId]; const opts = optsObj[capabilityId]; // Try to handle executing the capability change event setClusterCapabilityValuePromises.push(this.setClusterCapabilityValue( capabilityId, capabilityObj.cluster, value, opts, )); } // Return all set cluster capability value promises return Promise.all(setClusterCapabilityValuePromises); } // Return result (promise) return result; }, CAPABILITIES_DEBOUNCE, ); } /** * Method that searches for the first occurrence of a given cluster in a device's endpoints and * returns the endpoint id. Note: this method only finds clusters that act as `inputCluster` * on the node, `outputClusters` are not discoverable with this method. * @param {ClusterSpecification} cluster * @returns {EndpointId|null} endpointId - Returns `null` if cluster could not be found on any * endpoint. */ getClusterEndpoint(cluster) { assertClusterSpecification(cluster); assertZCLNode(this.zclNode); // Loop all endpoints for first occurrence of cluster // eslint-disable-next-line no-restricted-syntax for (const [endpointId, endpoint] of Object.entries(this.zclNode.endpoints)) { if (endpoint.clusters && endpoint.clusters[cluster.NAME]) { return Number(endpointId); } } this.debug(`Error: could not find cluster ${cluster.NAME} on any of the node's endpoints`); // Not found, probably something wrong, return default return null; } /** * @deprecated since v1.0.0 - Use a {@link BoundCluster} instead (see example below). * @example * class CustomBoundCluster extends BoundCluster { * setOn() { * // This method will be called when the `setOn` command is received * } * } * * zclNode.endpoints[1].clusters.bind('onOff', new CustomBoundCluster()); */ registerReportListener() { throw new Error('You are using a deprecated function, please refactor' + ' registerReportListener to a `BoundCluster` implementation (see example in' + ' documentation)'); } /** * @deprecated since v1.0.0 - Use {@link configureAttributeReporting} instead. */ async registerAttrReportListener() { throw new Error('You are using a deprecated function, please refactor' + ' registerAttrReportListener to configureAttributeReporting'); } /** * @typedef {object} AttributeReportingConfiguration * @property {ClusterSpecification} cluster * @property {string} attributeName - The name of the attribute (e.g. `onOff`) * @property {number} [minInterval=0] - The minimum reporting interval in seconds (e.g. 10), the * default value is 0 which imposes no minimum limit (unless one is imposed by the * specification of the cluster using this reporting mechanism). Range: 0 - 65535. * @property {number} maxInterval - The maximum reporting interval in seconds (e.g. 300), this * value must be larger than 60 and larger than `minInterval`. When this parameter is set to * 65535 the device shall not issue reports for the specified attribute. When this parameter * is set to 0 and the `minInterval` is set to 65535 the device will revert back to its * default reporting configuration. Range: 0 - 65535. * @property {number} [minChange=1] - The minimum value the attribute has to change in order to * trigger a report. For attributes with 'discrete' data type this field is irrelevant. If * `minInterval` is set to 65535, and `maxInterval` to 0, this value will be set to 0. See * section 2.5.7.1.7 of the Zigbee Cluster Library specification version 1.0, revision 6. * @property {EndpointId} [endpointId=1] - The endpoint index (e.g. 1) */ /** * Configure the node to send attribute reports. After successful configuration the device's * store value {@link CONFIGURED_ATTRIBUTE_REPORTING_STORE_KEY} will be updated to reflect the * configured attribute reporting configuration with an additional `lastUpdated` value. * * Note: not all devices support this, and not all attributes are reportable, check the Zigbee * Cluster Library specification for more information. Additionally, many devices require a * binding to be configured before attribute reporting can be configured, include the cluster id * in the `bindings` array in the `zigbee.endpoints` object in the driver's manifest. * @param {AttributeReportingConfiguration[]} attributeReportingConfigurations * @returns {Promise} * * @example * const { CLUSTER } = require('zigbee-clusters'); * * await this.configureAttributeReporting([{ * cluster: CLUSTER.ILLUMINANCE_MEASUREMENT, * attributeName: 'measuredValue', * minInterval: 0, * maxInterval: 300, * minChange: 10, * }]); * * // When setting multiple attribute reporting configurations combine them into one call, * // multiple attribute configurations for a single cluster on a single endpoint will need only * // one remote call to the node. This especially important for sleepy (battery) devices. * await this.configureAttributeReporting([ * { * endpointId: 2, * cluster: CLUSTER.COLOR_CONTROL, * attributeName: 'currentHue', * minInterval: 0, * maxInterval: 300, * minChange: 10, * }, * { * endpointId: 2, * cluster: CLUSTER.COLOR_CONTROL, * attributeName: 'currentSaturation', * minInterval: 0, * maxInterval: 300, * minChange: 10, * }, * ]); * * // In order to handle the attribute reports, bind a listener * zclNode.endpoints[1].clusters[CLUSTER.COLOR_CONTROL.NAME] * .on('attr.currentSaturation', (currentSaturation) => { * // handle reported attribute value * }); */ async configureAttributeReporting(attributeReportingConfigurations) { // Convert input object to an object sorted by endpoint and cluster, this is needed because // we need to group `configureReporting` calls by cluster per endpoint (i.e. for each // cluster only one `configureReporting` call is necessary per endpoint) const sortedConfig = {}; for (const attributeReportingConfiguration of attributeReportingConfigurations) { const endpointId = typeof attributeReportingConfiguration.endpointId === 'number' ? attributeReportingConfiguration.endpointId : 1; assertClusterSpecification(attributeReportingConfiguration.cluster); assertZCLNode(this.zclNode, endpointId, attributeReportingConfiguration.cluster); // This creates an object of the following structure // { // <endpointId>: { // <clusterName>: { // <attributeName>: { // cluster: <clusterName> // attributeName: <attributeName> // minInterval: <minInterval> // maxInterval: <maxInterval> // minChange: <minChange> // } // } // } // } sortedConfig[endpointId] = { ...(sortedConfig[endpointId] || {}), [attributeReportingConfiguration.cluster.NAME]: { ...((sortedConfig[endpointId] || {})[attributeReportingConfiguration.cluster.NAME] || {}), [attributeReportingConfiguration.attributeName]: { ...attributeReportingConfiguration, }, }, }; } // Store all individual promises (per endpoint/cluster combination) const configurationPromises = []; // Loop all individual endpoint configurations for (const [endpointId, endpointConfig] of Object.entries(sortedConfig)) { // Loop all individual cluster configurations for (const [clusterName, clusterConfig] of Object.entries(endpointConfig)) { const configureAttributeReportingOptions = {}; // Loop all individual attribute configurations for (const [attributeName, attributeConfig] of Object.entries(clusterConfig)) { // Expand attribute config with defaults let { minChange } = { ...ATTRIBUTE_REPORTING_CONFIGURATION_DEFAULTS, ...attributeConfig }; const { minInterval, maxInterval } = { ...ATTRIBUTE_REPORTING_CONFIGURATION_DEFAULTS, ...attributeConfig, }; if (minInterval < 0) throw new RangeError('invalid_min_interval_value'); // Max interval must be larger than 60 and larger than minInterval or 0 if (maxInterval !== 0 && (maxInterval < 60 || maxInterval < minInterval)) { throw new Error('invalid_max_interval_value'); } // See: section 2.5.7.1.7 of the Zigbee Cluster Library specification version 1.0, // revision 6. if (maxInterval === 0 && minInterval === 65535) { minChange = 0; } // Store config for later use configureAttributeReportingOptions[attributeName] = { minInterval, maxInterval, minChange, }; this.debug(`configure attribute reporting (endpoint: ${endpointId}, cluster: ${clusterName}, attribute: ${attributeName})`, configureAttributeReportingOptions[attributeName]); } // Make the configure reporting call with the cluster configuration (all attribute // configurations for this cluster) and push it to the promises array so it can be // resolved once all configurations are set configurationPromises.push( wrapAsyncWithRetry( // Wrap with retry (2-time, directly) this.zclNode.endpoints[endpointId] .clusters[clusterName] .configureReporting.bind( this.zclNode.endpoints[endpointId].clusters[clusterName], configureAttributeReportingOptions, ), 2, ) .then(async () => { this.debug(`configured attribute reporting (endpoint: ${endpointId}, cluster: ${clusterName})`, configureAttributeReportingOptions); // Store configuration for later reference const currentConfigurations = this.getStoreValue( CONFIGURED_ATTRIBUTE_REPORTING_STORE_KEY, ) || []; // Add last updated property to attribute reporting configuration object and restore // previously removed attributes let filteredConfigurations = currentConfigurations; for (const [attributeName, obj] of Object.entries( configureAttributeReportingOptions, )) { obj.lastUpdated = Date.now(); obj.clusterName = clusterName; obj.attributeName = attributeName; obj.endpointId = endpointId; // Remove elements from array that match the endpoint, cluster and attribute // name to prevent duplicate configurations (only one config can be active // for a specific endpoint, cluster and attribute, so store the latest). filteredConfigurations = filteredConfigurations.filter( x => { const config = x[attributeName]; if (config != null && config.endpointId === endpointId && config.clusterName === clusterName && config.attributeName === attributeName) { return false; } return true; }, ); } await this.setStoreValue(CONFIGURED_ATTRIBUTE_REPORTING_STORE_KEY, [ ...filteredConfigurations, { ...configureAttributeReportingOptions }, ]); this.debug(`stored attribute reporting configuration (endpoint: ${endpointId}, cluster: ${clusterName})`, this.getStoreValue(CONFIGURED_ATTRIBUTE_REPORTING_STORE_KEY)); }) .catch(err => { this.error(`Error: configuring attribute reporting (endpoint: ${endpointId}, cluster: ${clusterName})`, configureAttributeReportingOptions, err); throw err; }), ); } } return Promise.all(configurationPromises); } /** * Method that handles an incoming attribute report. It parses the result using the * {@link ReportParserFunction}, if this is not available it returns `null`. If the parsing * succeeded the capability value will be updated and the parsed payload will be returned. * @param {CapabilityId} capabilityId * @param {ClusterSpecification} cluster * @param {*} payload * @returns {Promise<null|*>} - Returns `null` if parsing failed or yielded no result. * * @example * const { CLUSTER } = require('zigbee-clusters'); * * zclNode.endpoints[1].clusters.onOff.on('attr.onOff', value => { * return this.parseAttributeReport('onoff', CLUSTER.ON_OFF, { onOff: value }); * }); */ async parseAttributeReport(capabilityId, cluster, payload) { assertClusterSpecification(cluster); assertCapabilityId(capabilityId, this.hasCapability.bind(this)); const { report, reportParser } = this._getClusterCapabilityConfiguration(capabilityId, cluster); if (typeof reportParser !== 'function') return null; if (!(report in payload)) return null; // Expected property is not available in report this.debug(`handle report (cluster: ${cluster.NAME}, capability: ${capabilityId}), raw payload:`, payload); const parsedPayload = await reportParser.call(this, payload[report]); if (parsedPayload instanceof Error) return null; if (parsedPayload === null) return null; this.log(`handle report (cluster: ${cluster.NAME}, capability: ${capabilityId}), parsed payload:`, parsedPayload); // Update capability value in Homey this.setCapabilityValue(capabilityId, parsedPayload).catch(err => this.error('Error: could not set capability value, reason:', err)); return parsedPayload; } /** * This method reads the `get` part of the {@link ClusterCapabilityConfiguration} and based on * that performs a `readAttributes` call on the cluster. It will trigger * {@link parseAttributeReport} once the new value is received which will parse the result and * update the capability value. * @param {CapabilityId} capabilityId * @param {ClusterSpecification} cluster * @returns {Promise<*>} * * @example * const { CLUSTER } = require('zigbee-clusters'); * * const measureLuminance = await this.getClusterCapabilityValue( * 'measure_luminance', * CLUSTER.ILLUMINANCE_MEASUREMENT, * ); */ async getClusterCapabilityValue(capabilityId, cluster) { assertClusterSpecification(cluster); assertCapabilityId(capabilityId, this.hasCapability.bind(this)); const { endpoint, get } = this._getClusterCapabilityConfiguration(capabilityId, cluster); assertZCLNode(this.zclNode, endpoint, cluster); if (typeof endpoint !== 'number') throw new TypeError('expected_endpoint_number'); if (typeof get !== 'string') throw new TypeError('expected_get_string'); this.log(`get → ${capabilityId} → read attribute (cluster: ${cluster.NAME}, attributeId: ${get}, endpoint: ${endpoint})`); // Read attribute from ZCLNode with retry (1-time, directly) const result = await wrapAsyncWithRetry( this.zclNode.endpoints[endpoint].clusters[cluster.NAME].readAttributes .bind(this.zclNode.endpoints[endpoint].clusters[cluster.NAME], [get]), ).catch(err => { this.error(`Error: get → ${capabilityId} → read attribute (cluster: ${cluster.NAME}, attributeId: ${get}, endpoint: ${endpoint})`, err); throw err; }); this.debug(`get → ${capabilityId} → read attribute (cluster: ${cluster.NAME}, attributeId: ${get}, endpoint: ${endpoint}) → raw result:`, result); // Parse the raw result const parsedResult = await this.parseAttributeReport(capabilityId, cluster, result); this.log(`get → ${capabilityId} → read attribute (cluster: ${cluster.NAME}, attributeId: ${get}, endpoint: ${endpoint}) → parsed result`, parsedResult); return parsedResult; } /** * This method retrieves the `set` part of the {@link ClusterCapabilityConfiguration}, parses the * payload by calling the {@link ClusterCapabilityConfiguration.setParser}, and finally * executes the cluster command as configured by {@link ClusterCapabilityConfiguration.set}. * @param {CapabilityId} capabilityId * @param {ClusterSpecification} cluster * @param {*} value - The desired capability value. * @param {Homey.Device.registerCapabilityListener.listener.opts} [opts={}] * @returns {Promise<*|null>} - Returns the set capability value or `null` if the * {@link ClusterCapabilityConfiguration.setParser} returned `null` (i.e. command set should * not be executed). * * @example * const { CLUSTER } = require('zigbee-clusters'); * * await this.setClusterCapabilityValue('dim', CLUSTER.LEVEL_CONTROL, 0.6, { duration: 500 }); */ async setClusterCapabilityValue(capabilityId, cluster, value, opts = {}) { assertClusterSpecification(cluster); assertCapabilityId(capabilityId, this.hasCapability.bind(this)); const { setParser, endpoint } = this._getClusterCapabilityConfiguration(capabilityId, cluster); let { set } = this._getClusterCapabilityConfiguration(capabilityId, cluster); assertZCLNode(this.zclNode, endpoint, cluster); if (typeof setParser !== 'function') throw new TypeError('set_parser_is_not_a_function'); if (typeof endpoint !== 'number') throw new TypeError('expected_endpoint_number'); if (typeof set !== 'function' && typeof set !== 'string') throw new TypeError('expected_set_function_or_string'); this.log(`set ${capabilityId} → ${value} (cluster: ${cluster.NAME}, endpoint: ${endpoint})`); // `set` can be a function, in that case call the function to convert to a string value if (typeof set === 'function') set = set(value, opts); // Call the `setParser` to generate the command properties which will be passed when // executing the cluster command const parsedPayload = await setParser.call(this, value, opts); if (parsedPayload instanceof Error) throw parsedPayload; // In the case that the parser returns `null` do not continue executing the command if (parsedPayload === null) { this.debug(`WARNING: set ${capabilityId} → ${value} (command: ${set}, cluster: ${cluster.NAME}, endpoint: ${endpoint}) returned \`null\`, ignoring command set`); return null; } this.debug(`set ${capabilityId} → ${value} (command: ${set}, cluster: ${cluster.NAME}, endpoint: ${endpoint}), parsed payload:`, parsedPayload); // Execute the cluster command with retry (1-time, directly) return wrapAsyncWithRetry( this.zclNode.endpoints[endpoint].clusters[cluster.NAME][set] .bind(this.zclNode.endpoints[endpoint].clusters[cluster.NAME], parsedPayload), ).catch(err => { this.error(`Error: could not perform ${set} on cluster: ${cluster.NAME}, endpoint: ${endpoint} for capability ${capabilityId}`, err); throw new Error(this.zigbeedriverI18n('error.command_failed')); }); } /** * Schedule execution of an async method for the next end device announce event. * @param {AsyncFunction} method * @returns {Promise<unknown>} * @private */ async scheduleForNextEndDeviceAnnounce(method) { return new Promise((resolve, reject) => { this.node.once(END_DEVICE_ANNOUNCE_EVENT, () => { method().then(resolve).catch(reject); }); }); } /** * Print the current node information, this contains information on the node's endpoints and * clusters (and if it is a sleepy device or not). */ printNode() { this.log('------------------------------------------'); // log the entire Node this.log('Node:', this.getData().token); this.log('- Receive when idle:', this.node.receiveWhenIdle); Object.keys(this.zclNode.endpoints) .forEach(endpoint => { this.log('- Endpoints:', endpoint); this.log('-- Clusters:'); Object.keys(this.zclNode.endpoints[endpoint].clusters) .forEach(key => { this.log('---', key); }); }); this.log('------------------------------------------'); } /** * Debug logging method. Will only log to stdout if enabled via {@link enableDebug}. * @param {*} args */ debug(...args) { if (this._debugEnabled) { this.log.bind(this, '[dbg]').apply(this, args); } } /** * Enable {@link ZigBeeDevice.debug} statements. */ enableDebug() { this._debugEnabled = true; } /** * Disable {@link ZigBeeDevice.debug} statements. */ disableDebug() { this._debugEnabled = false; } /** * Method is called by the Homey Apps SDK when the {@link Homey.Device} instance is * initialized. It will configure this {@link ZigBeeDevice} instance and retrieve a * {@link Homey.ZigBeeNode} instance from {@link Homey.ManagerZigBee}. This ZigBeeNode instance * will then be used to create a {@link ZCLNode} instance. * @private */ async onInit() { super.onInit(); this._debugEnabled = false; this._pollIntervals = {}; this._clusterCapabilityConfigurations = new Map(); this._flowTriggers = {}; // Bind __ with current language this.zigbeedriverI18n = __.bind(this, this.homey.i18n.getLanguage()); const { token } = this.getData(); // Throw error if this device is a Zigbee sub device but its driver does not extend ZigBeeDriver if (this.isSubDevice() && !(this.driver instanceof ZigBeeDriver)) { this.error(`Error: Driver ${this.driver.id} must extend ZigBeeDriver when using Zigbee sub devices`); throw new Error(`Driver ${this.driver.id} must extend ZigBeeDriver when using Zigbee sub devices`); } // Get ZigBeeNode instance from ManagerZigBee this.homey.zigbee.getNode(this) .then(async node => { this.node = node; // Bind end device announce listener this.node.on(END_DEVICE_ANNOUNCE_EVENT, this.onEndDeviceAnnounce.bind(this)); // Check if `getEnergy` method is available (Homey >=v3.0.0) if (typeof this.getEnergy === 'function') { const energyObject = this.getEnergy(); await this.setEnergy(energyObject); } // And a ZCLNode instance is already available on the driver if (this.driver._zclNodes instanceof Map && this.driver._zclNodes.has(token)) { // Re-use ZCLNode instance, this is needed for Zigbee sub devices which share a // single ZCLNode instance this.zclNode = this.driver._zclNodes.get(token); } // If no ZCLNode could be re-used, create a new one if (!this.zclNode) this.zclNode = new ZCLNode(this.node); // If possible, register it with the driver for future re-use if (this.driver._zclNodes instanceof Map && !this.driver._zclNodes.has(token)) { this.driver._zclNodes.set(token, this.zclNode); } this.log('ZigBeeDevice has been initialized', { firstInit: this.isFirstInit(), isSubDevice: this.isSubDevice() }); // Mark device as available await this.setAvailable(); try { // Legacy from homey-meshdriver await this.onMeshInit(); } catch (err) { this.error('Error: \'onMeshInit()\' failed, reason:', err); throw err; } try { // Call overridable method with initialized ZCLNode await this.onNodeInit({ zclNode: this.zclNode, node }); } catch (err) { this.error('Error: \'onNodeInit()\' failed, reason:', err); throw err; } // Mark this node as initialized await this.setStoreValue(FIRST_INIT, false); }) .catch(err => { this.error('Error: could not initialize node', err); }); } /** * Returns true if this device is a Zigbee sub device. * @returns {boolean} */ isSubDevice() { return typeof this.getData().subDeviceId === 'string'; } /** * Returns true if node has just been initialized for the first time (after awaiting * {@link onNodeInit} this value will be updated. * @returns {boolean} */ isFirstInit() { return this.getStoreValue(FIRST_INIT) !== false; } /** * Remove all listeners and intervals from node. This method can be overridden if additional * clean up actions are required, but be sure to call `super.onDeleted()` at some point. * @abstract */ onDeleted() { // Remove listeners on node if (this.node) this.node.removeAllListeners(); // Remove listeners on zclNode if (this.zclNode) { for (const endpoint of Object.values(this.zclNode.endpoints)) { for (const cluster of Object.values(endpoint.clusters)) { cluster.removeAllListeners(); } endpoint.removeAllListeners(); } this.zclNode.removeAllListeners(); } // Clear all pollIntervals if (this._pollIntervals) { // Sometimes it is null/undefined for some reason Object.keys(this._pollIntervals) .forEach(capabilityId => { Object.values(this._pollIntervals[capabilityId]) .forEach(interval => { this.homey.clearInterval(interval); }); }); } this.debug('deleted ZigBeeDevice instance'); } /** * Retrieves the `swBuildId` from the Basic cluster of the specified endpoint. If the * `swBuildId` is already cached in the device store, it will return the cached value. * @param {object} [params={}] Method parameters. * @param {number} [params.endpointId=1] The endpoint id to fetch the swBuildId from (default: 1). * @param {boolean} [params.skipCache=false] If true, it will skip the cache and always fetch the * swBuildId from the device (default: false). * @returns {Promise<string>} The swBuildId of the device. */ async getSwBuildId({ endpointId = 1, skipCache = false } = {}) { if (skipCache === false) { // Check if the zw_sb_build_id is reachable in this Homey version // Getting system settings from apps is no longer available after Homey Pro 12.4.8. const swBuildIdSetting = this.getSetting('zw_sb_build_id'); if (typeof swBuildIdSetting === 'string') { this.debug(`Using swBuildId from zw_sb_build_id setting: ${swBuildIdSetting}`); await this.setStoreValue(SW_BUILD_ID, swBuildIdSetting) .catch(err => this.error('Error: could not store swBuildId in device store', err)); return swBuildIdSetting; } const cachedVersion = this.getStoreValue(SW_BUILD_ID); if (cachedVersion) { this.debug(`Found cached swBuildId: ${cachedVersion}`); return cachedVersion; } } this.debug('No cached swBuildId found, fetching from node...'); const swBuildId = await this._fetchSwBuildId({ endpointId }); this.debug(`Fetched swBuildId: ${swBuildId}`); await this.setStoreValue(SW_BUILD_ID, swBuildId) .catch(err => this.error('Error: could not store swBuildId in device store', err)); return swBuildId; } /** * Fetches the `swBuildId` from the Basic cluster of the specified endpoint. * @param {object} params Method parameters. * @param {number} params.endpointId The endpoint id to fetch the swBuildId from. * @returns {Promise<string>} The swBuildId of the device. * @throws {Error} If the endpointId is not a number. * @throws {Error} If the zclNode is not available or the swBuildId attribute cannot be read. * @throws {Error} If the swBuildId attribute is not found or has an invalid type. * @private */ async _fetchSwBuildId({ endpointId }) { if (typeof endpointId !== 'number') { throw new Error('Invalid endpointId, expected a number'); } if (!this.zclNode) { this.error('Error: zclNode is not available, cannot fetch swBuildId'); throw new Error('zclNode is not available, cannot fetch swBuildId'); } const attributes = await this.zclNode.endpoints[endpointId].clusters.basic.readAttributes(['swBuildId']) .catch(err => { this.error('Error: could not read swBuildId attribute from Basic cluster', err); throw err; }); if (!attributes || typeof attributes.swBuildId !== 'string') { this.error('Error: swBuildId attribute not found or invalid type:', attributes); throw new Error('Could not find swBuildId attribute on the Basic cluster'); } return attributes.swBuildId; } /** * Method that handles registering the `get` part of the * {@link ClusterCapabilityConfiguration}. If * {@link ClusterCapabilityConfiguration.getOpts.getOnStart} is set, the node is a non-sleepy * device and the capability value is currently unknown, execute the cluster command that will * retrieve the capability value from the device. Additionally, if * {@link ClusterCapabilityConfiguration.getOpts.getOnOnline} is set the cluster command will * be executed to retrieve the capability value when the device sends an end device announce * indication. Also, if {@link ClusterCapabilityConfiguration.getOpts.pollInterval} is set * to either a number or a string (setting key) a poll interval will be registered which * executes the cluster command to retrieve the capability value. Finally, if this is the * first init of the device (directly after pairing) it will attempt to read the attribute * value specified by `get`. * @param {CapabilityId} capabilityId * @param {ClusterSpecification} cluster * @private */ _registerCapabilityGet(capabilityId, cluster) { assertClusterSpecification(cluster); assertCapabilityId(capabilityId, this.hasCapability.bind(this)); this.debug(`register capability get, capability ${capabilityId}, cluster: ${cluster.NAME}`); const { get, endpoint, getOpts, report, } = this._getClusterCapabilityConfiguration(capabilityId, cluster); assertZCLNode(this.zclNode, endpoint, cluster); const { getOnStart, getOnOnline, pollInterval } = getOpts; // Make sure that attribute reports are parsed and handled if (typeof report === 'string') { this.zclNode.endpoints[endpoint].clusters[cluster.NAME] .on(`attr.${report}`, value => { return this.parseAttributeReport(capabilityId, cluster, { [report]: value }) .catch(err => { this.error('Error: failed to parse attribute report', { [report]: value }, err); }); }); } // Get initial value on start if null, unless it's an offline battery device and the // getOnOnline flag is also set // Only continue if a `get` attribute is configured if (typeof get === 'string') { // If the `getOnStart` option is set and the node is not a sleepy device and the capability // value is unknown go execute getClusterCapabilityValue. This situation is almost always // only after the first init of a device after pairing. if (getOnStart && this.getCapabilityValue(capabilityId) === null && this.node.receiveWhenIdle !== true) { this.getClusterCapabilityValue(capabilityId, cluster) .catch(err => { this.error(`Error: could not get value for capability (\`getOnStart\`): ${capabilityId} on cluster: ${cluster.NAME}`, err); }); } // When node comes online (i.e. sends an end device announce indication) execute // getClusterCapabilityValue when `getOnOnline` is set to true. if (getOnOnline) { this.node.on('endDeviceAnnounce', () => { this.debug('Received end device announce indication and `getOnOnline` is configured'); this.getClusterCapabilityValue(capabilityId, cluster) .catch(err => { this.error(`Error: could not get value for capability (\`getOnOnline\`): ${capabilityId} on cluster: ${cluster.NAME}`, err); }); }); } // Configure poll intervals if needed if (pollInterval) { // If poll interval is a number treat it as the interval in ms if (typeof pollInterval === 'number') { this._setPollInterval(capabilityId, cluster, pollInterval); // Else if poll interval is a string treat it as a settings key on the device instance, // this setting should return a number value representing the interval in ms } else if (typeof pollInterval === 'string') { this._setPollInterval(capabilityId, cluster, this.getSetting(pollInterval)); } } // If this is the first init of the node (directly after pairing) fetch the values for which // a `get` is specified. if (this.isFirstInit()) { this.getClusterCapabilityValue(capabilityId, cluster).catch(err => { this.error(`Error: could not fetch initial value for ${capabilityId}`, err); // When failed even after retry, re-schedule for next end device announce event this.scheduleForNextEndDeviceAnnounce( this.getClusterCapabilityValue.bind(this, capabilityId, cluster), ).catch(retryErr => { this.error(`[retry on end device announce] Error: could not fetch initial value for ${capabilityId}`, retryErr); }); }); } } } /** * Method that handles registering the `set` part of the * {@link ClusterCapabilityConfiguration}. When a capability value is changed (i.e. the * capability listener is called {@link Homey.Device.registerCapabilityListener}) the * {@link setClusterCapabilityValue} will be called which handles converting the * capability value change to a Zigbee command in order to actually change the device's state. * @param {CapabilityId} capabilityId * @param {ClusterSpecification} cluster * @private */ _registerCapabilitySet(capabilityId, cluster) { assertClusterSpecification(cluster); assertCapabilityId(capabilityId, this.hasCapability.bind(this)); this.debug(`register capability set, capability ${capabilityId}, cluster: ${cluster.NAME}`); // Register the capability and attach a listener to act on a capability change by the user this.registerCapabilityListener(capabilityId, async (value, opts) => { return this.setClusterCapabilityValue(capabilityId, cluster, value, opts) .catch(err => { this.error(`Error: failed to set cluster capability value (capability: ${capabilityId}, cluster: ${cluste