From 3d772b5db33ba653f763420f067b90d1cfc16cd8 Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Sun, 23 Jun 2024 10:38:00 +0200 Subject: [PATCH] fix: Bind code optimisation/formatting (#23110) * Bind code optimization/formatting * Feedback * Fix coverage --- lib/extension/availability.ts | 4 +- lib/extension/bind.ts | 286 ++++++++++++++++++++-------------- lib/extension/groups.ts | 39 +++-- test/bind.test.js | 109 ++++++++++++- 4 files changed, 292 insertions(+), 146 deletions(-) diff --git a/lib/extension/availability.ts b/lib/extension/availability.ts index b8e1c005b..cbb2566bc 100644 --- a/lib/extension/availability.ts +++ b/lib/extension/availability.ts @@ -6,7 +6,7 @@ import debounce from 'debounce'; import bind from 'bind-decorator'; import * as zhc from 'zigbee-herdsman-converters'; -const retrieveOnReconnect = [ +const RETRIEVE_ON_RECONNECT: readonly {keys: string[], condition?: (state: KeyValue) => boolean}[] = [ {keys: ['state']}, {keys: ['brightness'], condition: (state: KeyValue): boolean => state.state === 'ON'}, {keys: ['color', 'color_temp'], condition: (state: KeyValue): boolean => state.state === 'ON'}, @@ -217,7 +217,7 @@ export default class Availability extends Extension { logger.debug(`Retrieving state of '${device.name}' after reconnect`); // Color and color temperature converters do both, only needs to be called once. - for (const item of retrieveOnReconnect) { + for (const item of RETRIEVE_ON_RECONNECT) { if (item.condition && this.state.get(device) && !item.condition(this.state.get(device))) { continue; } diff --git a/lib/extension/bind.ts b/lib/extension/bind.ts index 4905862fd..08e6e123c 100755 --- a/lib/extension/bind.ts +++ b/lib/extension/bind.ts @@ -8,20 +8,19 @@ import {Zcl} from 'zigbee-herdsman'; import bind from 'bind-decorator'; import Device from '../model/device'; import Group from '../model/group'; +import {ClusterName} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype'; -const legacyApi = settings.get().advanced.legacy_api; -const legacyTopicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/(bind|unbind)/.+$`); -const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/(bind|unbind)`); -const allClusterCandidates = ['genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl', 'closuresWindowCovering', - 'hvacThermostat', 'msIlluminanceMeasurement', 'msTemperatureMeasurement', 'msRelativeHumidity', - 'msSoilMoisture', 'msCO2']; +const LEGACY_API = settings.get().advanced.legacy_api; +const LEGACY_TOPIC_REGEX = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/(bind|unbind)/.+$`); +const TOPIC_REGEX = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/(bind|unbind)`); +const ALL_CLUSTER_CANDIDATES: readonly ClusterName[] = [ + 'genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl', 'closuresWindowCovering', 'hvacThermostat', 'msIlluminanceMeasurement', + 'msTemperatureMeasurement', 'msRelativeHumidity', 'msSoilMoisture', 'msCO2', +]; // See zigbee-herdsman-converters -const defaultBindGroup = {type: 'group_number', ID: 901, name: 'default_bind_group'}; - -const defaultReportConfiguration = { - minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1, -}; +const DEFAULT_BIND_GROUP = {type: 'group_number', ID: 901, name: 'default_bind_group'}; +const DEFAULT_REPORT_CONFIG = {minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1}; const getColorCapabilities = async (endpoint: zh.Endpoint): Promise<{colorTemperature: boolean, colorXY: boolean}> => { if (endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') == null) { @@ -29,50 +28,54 @@ const getColorCapabilities = async (endpoint: zh.Endpoint): Promise<{colorTemper } const value = endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') as number; + return { - colorTemperature: (value & 1<<4) > 0, - colorXY: (value & 1<<3) > 0, + colorTemperature: (value & 1 << 4) > 0, + colorXY: (value & 1 << 3) > 0, }; }; -const reportClusters: {[s: string]: - {attribute: string, minimumReportInterval: number, maximumReportInterval: number, reportableChange: number - condition?: (endpoint: zh.Endpoint) => Promise}[]} = -{ +const REPORT_CLUSTERS: Readonly Promise; +}>[]>>> = { 'genOnOff': [ - {attribute: 'onOff', ...defaultReportConfiguration, minimumReportInterval: 0, reportableChange: 0}, + {attribute: 'onOff', ...DEFAULT_REPORT_CONFIG, minimumReportInterval: 0, reportableChange: 0}, ], 'genLevelCtrl': [ - {attribute: 'currentLevel', ...defaultReportConfiguration}, + {attribute: 'currentLevel', ...DEFAULT_REPORT_CONFIG}, ], 'lightingColorCtrl': [ { - attribute: 'colorTemperature', ...defaultReportConfiguration, + attribute: 'colorTemperature', ...DEFAULT_REPORT_CONFIG, condition: async (endpoint): Promise => (await getColorCapabilities(endpoint)).colorTemperature, }, { - attribute: 'currentX', ...defaultReportConfiguration, + attribute: 'currentX', ...DEFAULT_REPORT_CONFIG, condition: async (endpoint): Promise => (await getColorCapabilities(endpoint)).colorXY, }, { - attribute: 'currentY', ...defaultReportConfiguration, + attribute: 'currentY', ...DEFAULT_REPORT_CONFIG, condition: async (endpoint): Promise => (await getColorCapabilities(endpoint)).colorXY, }, ], 'closuresWindowCovering': [ - {attribute: 'currentPositionLiftPercentage', ...defaultReportConfiguration}, - {attribute: 'currentPositionTiltPercentage', ...defaultReportConfiguration}, + {attribute: 'currentPositionLiftPercentage', ...DEFAULT_REPORT_CONFIG}, + {attribute: 'currentPositionTiltPercentage', ...DEFAULT_REPORT_CONFIG}, ], }; type PollOnMessage = { - cluster: {[s: string]: {type: string, data: KeyValue}[]} - read: {cluster: string, attributes: string[], attributesForEndpoint?: (endpoint: zh.Endpoint) => Promise} - manufacturerIDs: number[], - manufacturerNames: string [], + cluster: Readonly>>; + read: Readonly<{cluster: string, attributes: string[], attributesForEndpoint?: (endpoint: zh.Endpoint) => Promise}>; + manufacturerIDs: readonly number[]; + manufacturerNames: readonly string[]; }[]; -const pollOnMessage: PollOnMessage = [ +const POLL_ON_MESSAGE: Readonly = [ { // On messages that have the cluster and type of below cluster: { @@ -159,8 +162,17 @@ const pollOnMessage: PollOnMessage = [ attributesForEndpoint: async (endpoint): Promise => { const supportedAttrs = await getColorCapabilities(endpoint); const readAttrs: string[] = []; - supportedAttrs.colorXY && readAttrs.push('currentX', 'currentY'); - supportedAttrs.colorTemperature && readAttrs.push('colorTemperature'); + + /* istanbul ignore else */ + if (supportedAttrs.colorXY) { + readAttrs.push('currentX', 'currentY'); + } + + /* istanbul ignore else */ + if (supportedAttrs.colorTemperature) { + readAttrs.push('colorTemperature'); + } + return readAttrs; }, }, @@ -193,18 +205,18 @@ export default class Bind extends Extension { } private parseMQTTMessage(data: eventdata.MQTTMessage): ParsedMQTTMessage { - let type: 'bind' | 'unbind' = null; - let sourceKey = null; - let targetKey = null; - let clusters = null; - let skipDisableReporting = false; + let type: ParsedMQTTMessage['type'] = null; + let sourceKey: ParsedMQTTMessage['sourceKey'] = null; + let targetKey: ParsedMQTTMessage['targetKey'] = null; + let clusters: ParsedMQTTMessage['clusters'] = null; + let skipDisableReporting: ParsedMQTTMessage['skipDisableReporting'] = false; - if (legacyApi && data.topic.match(legacyTopicRegex)) { + if (LEGACY_API && data.topic.match(LEGACY_TOPIC_REGEX)) { const topic = data.topic.replace(`${settings.get().mqtt.base_topic}/bridge/`, ''); - type = topic.split('/')[0] as 'bind' | 'unbind'; + type = topic.split('/')[0] as ParsedMQTTMessage['type']; sourceKey = topic.replace(`${type}/`, ''); targetKey = data.message; - } else if (data.topic.match(topicRegex)) { + } else if (data.topic.match(TOPIC_REGEX)) { type = data.topic.endsWith('unbind') ? 'unbind' : 'bind'; const message = JSON.parse(data.message); sourceKey = message.from; @@ -218,14 +230,18 @@ export default class Bind extends Extension { @bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise { const {type, sourceKey, targetKey, clusters, skipDisableReporting} = this.parseMQTTMessage(data); - if (!type) return null; + + if (!type) { + return null; + } + const message = utils.parseJSON(data.message, data.message); let error = null; const parsedSource = this.zigbee.resolveEntityAndEndpoint(sourceKey); const parsedTarget = this.zigbee.resolveEntityAndEndpoint(targetKey); const source = parsedSource.entity; - const target = targetKey === 'default_bind_group' ? defaultBindGroup : parsedTarget.entity; + const target = targetKey === DEFAULT_BIND_GROUP.name ? DEFAULT_BIND_GROUP : parsedTarget.entity; const responseData: KeyValue = {from: sourceKey, to: targetKey}; if (!source || !(source instanceof Device)) { @@ -242,31 +258,25 @@ export default class Bind extends Extension { const attemptedClusters = []; const bindSource: zh.Endpoint = parsedSource.endpoint; - let bindTarget: number | zh.Group | zh.Endpoint = null; - if (target instanceof Device) bindTarget = parsedTarget.endpoint; - else if (target instanceof Group) bindTarget = target.zh; - else bindTarget = Number(target.ID); - + const bindTarget: number | zh.Group | zh.Endpoint = (target instanceof Device) ? parsedTarget.endpoint : + ((target instanceof Group) ? target.zh : Number(target.ID)); // Find which clusters are supported by both the source and target. // Groups are assumed to support all clusters. - const clusterCandidates = clusters ?? allClusterCandidates; + const clusterCandidates = clusters ?? ALL_CLUSTER_CANDIDATES; + for (const cluster of clusterCandidates) { let matchingClusters = false; - const anyClusterValid = utils.isZHGroup(bindTarget) || typeof bindTarget === 'number' || - (target as Device).zh.type === 'Coordinator'; + const anyClusterValid = utils.isZHGroup(bindTarget) || typeof bindTarget === 'number' || (target as Device).zh.type === 'Coordinator'; if (!anyClusterValid && utils.isEndpoint(bindTarget)) { - matchingClusters = ((bindTarget.supportsInputCluster(cluster) && - bindSource.supportsOutputCluster(cluster)) || - (bindSource.supportsInputCluster(cluster) && - bindTarget.supportsOutputCluster(cluster)) ); + matchingClusters = ((bindTarget.supportsInputCluster(cluster) && bindSource.supportsOutputCluster(cluster)) || + (bindSource.supportsInputCluster(cluster) && bindTarget.supportsOutputCluster(cluster))); } - const sourceValid = bindSource.supportsInputCluster(cluster) || - bindSource.supportsOutputCluster(cluster); + const sourceValid = bindSource.supportsInputCluster(cluster) || bindSource.supportsOutputCluster(cluster); - if ( sourceValid && (anyClusterValid || matchingClusters)) { + if (sourceValid && (anyClusterValid || matchingClusters)) { logger.debug(`${type}ing cluster '${cluster}' from '${source.name}' to '${target.name}'`); attemptedClusters.push(cluster); @@ -279,31 +289,25 @@ export default class Bind extends Extension { successfulClusters.push(cluster); logger.info( - `Successfully ${type === 'bind' ? 'bound' : 'unbound'} cluster '${cluster}' from ` + - `'${source.name}' to '${target.name}'`, + `Successfully ${type === 'bind' ? 'bound' : 'unbound'} cluster '${cluster}' from '${source.name}' to '${target.name}'`, ); /* istanbul ignore else */ if (settings.get().advanced.legacy_api) { await this.mqtt.publish( 'bridge/log', - stringify({type: `device_${type}`, - message: {from: source.name, to: target.name, cluster}}), + stringify({type: `device_${type}`, message: {from: source.name, to: target.name, cluster}}), ); } } catch (error) { failedClusters.push(cluster); - logger.error( - `Failed to ${type} cluster '${cluster}' from '${source.name}' to ` + - `'${target.name}' (${error})`, - ); + logger.error(`Failed to ${type} cluster '${cluster}' from '${source.name}' to '${target.name}' (${error})`); /* istanbul ignore else */ if (settings.get().advanced.legacy_api) { await this.mqtt.publish( 'bridge/log', - stringify({type: `device_${type}_failed`, - message: {from: source.name, to: target.name, cluster}}), + stringify({type: `device_${type}_failed`, message: {from: source.name, to: target.name, cluster}}), ); } } @@ -316,10 +320,7 @@ export default class Bind extends Extension { /* istanbul ignore else */ if (settings.get().advanced.legacy_api) { - await this.mqtt.publish( - 'bridge/log', - stringify({type: `device_${type}_failed`, message: {from: source.name, to: target.name}}), - ); + await this.mqtt.publish('bridge/log', stringify({type: `device_${type}_failed`, message: {from: source.name, to: target.name}})); } } else if (failedClusters.length === attemptedClusters.length) { error = `Failed to ${type}`; @@ -330,17 +331,18 @@ export default class Bind extends Extension { if (successfulClusters.length !== 0) { if (type === 'bind') { - await this.setupReporting(bindSource.binds.filter((b) => - successfulClusters.includes(b.cluster.name) && b.target === bindTarget)); + await this.setupReporting(bindSource.binds.filter((b) => successfulClusters.includes(b.cluster.name) && b.target === bindTarget)); } else if ((typeof bindTarget !== 'number') && !skipDisableReporting) { await this.disableUnnecessaryReportings(bindTarget); } } } - const triggeredViaLegacyApi = data.topic.match(legacyTopicRegex); + const triggeredViaLegacyApi = data.topic.match(LEGACY_TOPIC_REGEX); + if (!triggeredViaLegacyApi) { const response = utils.getResponse(message, responseData, error); + await this.mqtt.publish(`bridge/response/device/${type}`, stringify(response)); } @@ -353,9 +355,18 @@ export default class Bind extends Extension { @bind async onGroupMembersChanged(data: eventdata.GroupMembersChanged): Promise { if (data.action === 'add') { - const bindsToGroup = this.zigbee.devices(false).map((c) => c.zh.endpoints) - .reduce((a, v) => a.concat(v)).map((e) => e.binds) - .reduce((a, v) => a.concat(v)).filter((b) => b.target === data.group.zh); + const bindsToGroup: zh.Bind[] = []; + + for (const device of this.zigbee.devices(false)) { + for (const endpoint of device.zh.endpoints) { + for (const bind of endpoint.binds) { + if (bind.target === data.group.zh) { + bindsToGroup.push(bind); + } + } + } + } + await this.setupReporting(bindsToGroup); } else { // action === remove/remove_all if (!data.skipDisableReporting) { @@ -366,35 +377,53 @@ export default class Bind extends Extension { getSetupReportingEndpoints(bind: zh.Bind, coordinatorEp: zh.Endpoint): zh.Endpoint[] { const endpoints = utils.isEndpoint(bind.target) ? [bind.target] : bind.target.members; + return endpoints.filter((e) => { - const supportsInputCluster = e.supportsInputCluster(bind.cluster.name); - const hasConfiguredReporting = !!e.configuredReportings.find((c) => c.cluster.name === bind.cluster.name); - const hasBind = !!e.binds.find((b) => b.cluster.name === bind.cluster.name && b.target === coordinatorEp); - return supportsInputCluster && !(hasBind && hasConfiguredReporting); + if (!e.supportsInputCluster(bind.cluster.name)) { + return false; + } + + const hasConfiguredReporting = e.configuredReportings.some((c) => c.cluster.name === bind.cluster.name); + + if (!hasConfiguredReporting) { + return true; + } + + const hasBind = e.binds.some((b) => b.cluster.name === bind.cluster.name && b.target === coordinatorEp); + + return !hasBind; }); } async setupReporting(binds: zh.Bind[]): Promise { const coordinatorEndpoint = this.zigbee.firstCoordinatorEndpoint(); - for (const bind of binds.filter((b) => b.cluster.name in reportClusters)) { - for (const endpoint of this.getSetupReportingEndpoints(bind, coordinatorEndpoint)) { - const entity = `${this.zigbee.resolveEntity(endpoint.getDevice()).name}/${endpoint.ID}`; - try { - await endpoint.bind(bind.cluster.name, coordinatorEndpoint); - const items = []; - for (const c of reportClusters[bind.cluster.name]) { - /* istanbul ignore else */ - if (!c.condition || await c.condition(endpoint)) { - const i = {...c}; - delete i.condition; - items.push(i); - } - } - await endpoint.configureReporting(bind.cluster.name, items); - logger.info(`Successfully setup reporting for '${entity}' cluster '${bind.cluster.name}'`); - } catch (error) { - logger.warning(`Failed to setup reporting for '${entity}' cluster '${bind.cluster.name}'`); + for (const bind of binds) { + /* istanbul ignore else */ + if (bind.cluster.name in REPORT_CLUSTERS) { + for (const endpoint of this.getSetupReportingEndpoints(bind, coordinatorEndpoint)) { + const entity = `${this.zigbee.resolveEntity(endpoint.getDevice()).name}/${endpoint.ID}`; + + try { + await endpoint.bind(bind.cluster.name, coordinatorEndpoint); + + const items = []; + + for (const c of REPORT_CLUSTERS[bind.cluster.name as ClusterName]) { + /* istanbul ignore else */ + if (!c.condition || await c.condition(endpoint)) { + const i = {...c}; + delete i.condition; + + items.push(i); + } + } + + await endpoint.configureReporting(bind.cluster.name, items); + logger.info(`Successfully setup reporting for '${entity}' cluster '${bind.cluster.name}'`); + } catch (error) { + logger.warning(`Failed to setup reporting for '${entity}' cluster '${bind.cluster.name}'`); + } } } } @@ -405,30 +434,47 @@ export default class Bind extends Extension { async disableUnnecessaryReportings(target: zh.Group | zh.Endpoint): Promise { const coordinator = this.zigbee.firstCoordinatorEndpoint(); const endpoints = utils.isEndpoint(target) ? [target] : target.members; + const allBinds: zh.Bind[] = []; + + for (const device of this.zigbee.devices(false)) { + for (const endpoint of device.zh.endpoints) { + for (const bind of endpoint.binds) { + allBinds.push(bind); + } + } + } + for (const endpoint of endpoints) { const device = this.zigbee.resolveEntity(endpoint.getDevice()) as Device; const entity = `${device.name}/${endpoint.ID}`; - const boundClusters = endpoint.binds.filter((b) => b.target === coordinator) - .map((b) => b.cluster.name); - const requiredClusters = this.zigbee.devices(false).map((c) => c.zh.endpoints) - .reduce((a, v) => a.concat(v)) - .map((e) => e.binds).reduce((a, v) => a.concat(v)).filter((bind) => { - if (utils.isEndpoint(bind.target)) { - return bind.target === endpoint; - } else { - return bind.target.members.includes(endpoint); - } - }).map((b) => b.cluster.name).filter((v, i, a) => a.indexOf(v) === i); + const requiredClusters: string[] = []; + const boundClusters: string[] = []; - for (const cluster of boundClusters.filter((c) => !requiredClusters.includes(c) && c in reportClusters)) { + for (const bind of allBinds) { + if (utils.isEndpoint(bind.target) ? bind.target === endpoint : bind.target.members.includes(endpoint)) { + requiredClusters.push(bind.cluster.name); + } + } + + for (const b of endpoint.binds) { + /* istanbul ignore else */ + if (b.target === coordinator && !requiredClusters.includes(b.cluster.name) && (b.cluster.name in REPORT_CLUSTERS)) { + boundClusters.push(b.cluster.name); + } + } + + for (const cluster of boundClusters) { try { await endpoint.unbind(cluster, coordinator); + const items = []; - for (const item of reportClusters[cluster]) { + + for (const item of REPORT_CLUSTERS[cluster as ClusterName]) { /* istanbul ignore else */ if (!item.condition || await item.condition(endpoint)) { const i = {...item}; delete i.condition; + items.push({...i, maximumReportInterval: 0xFFFF}); } } @@ -453,11 +499,13 @@ export default class Bind extends Extension { * When dimming the bulb via the dimmer switch the state is therefore not reported. * When we receive a message from a Hue dimmer we read the brightness from the bulb (if bound). */ - const polls = pollOnMessage.filter((p) => - p.cluster[data.cluster]?.find((c) => c.type === data.type && utils.equalsPartial(data.data, c.data))); + const polls = POLL_ON_MESSAGE.filter( + (p) => p.cluster[data.cluster as ClusterName]?.some((c) => c.type === data.type && utils.equalsPartial(data.data, c.data)), + ); if (polls.length) { const toPoll: Set = new Set(); + // Add bound devices for (const endpoint of data.device.zh.endpoints) { for (const bind of endpoint.binds) { @@ -469,8 +517,11 @@ export default class Bind extends Extension { // If message is published to a group, add members of the group const group = data.groupID && data.groupID !== 0 && this.zigbee.groupByID(data.groupID); + if (group) { - group.zh.members.forEach((m) => toPoll.add(m)); + for (const member of group.zh.members) { + toPoll.add(member); + } } for (const endpoint of toPoll) { @@ -482,19 +533,20 @@ export default class Bind extends Extension { } let readAttrs = poll.read.attributes; + if (poll.read.attributesForEndpoint) { const attrsForEndpoint = await poll.read.attributesForEndpoint(endpoint); readAttrs = [...poll.read.attributes, ...attrsForEndpoint]; } - const key = `${endpoint.getDevice().ieeeAddr}_${endpoint.ID}_${pollOnMessage.indexOf(poll)}`; + const key = `${endpoint.getDevice().ieeeAddr}_${endpoint.ID}_${POLL_ON_MESSAGE.indexOf(poll)}`; + if (!this.pollDebouncers[key]) { this.pollDebouncers[key] = debounce(async () => { try { await endpoint.read(poll.read.cluster, readAttrs); } catch (error) { - logger.error(`Failed to poll ${readAttrs} from ` + - `${this.zigbee.resolveEntity(endpoint.getDevice()).name}`); + logger.error(`Failed to poll ${readAttrs} from ${this.zigbee.resolveEntity(endpoint.getDevice()).name}`); } }, 1000); } diff --git a/lib/extension/groups.ts b/lib/extension/groups.ts index fd566dbc2..15a8f0b0a 100644 --- a/lib/extension/groups.ts +++ b/lib/extension/groups.ts @@ -9,12 +9,11 @@ import Device from '../model/device'; import Group from '../model/group'; import * as zhc from 'zigbee-herdsman-converters'; -const topicRegex = - new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/group/members/(remove|add|remove_all)$`); -const legacyTopicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/(.+)/(remove|add|remove_all)$`); -const legacyTopicRegexRemoveAll = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/remove_all$`); +const TOPIC_REGEX = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/group/members/(remove|add|remove_all)$`); +const LEGACY_TOPIC_REGEX = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/(.+)/(remove|add|remove_all)$`); +const LEGACY_TOPIC_REGEX_REMOVE_ALL = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/remove_all$`); -const stateProperties: {[s: string]: (value: string, exposes: zhc.Expose[]) => boolean} = { +const STATE_PROPERTIES: Readonly boolean>> = { 'state': () => true, 'brightness': (value, exposes) => exposes.some((e) => e.type === 'light' && e.features.some((f) => f.name === 'brightness')), 'color_temp': (value, exposes) => exposes.some((e) => e.type === 'light' && e.features.some((f) => f.name === 'color_temp')), @@ -128,7 +127,7 @@ export default class Groups extends Extension { endpointName = endpointNameMatch; } - if (prop in stateProperties) { + if (prop in STATE_PROPERTIES) { payload[prop] = value; } } @@ -165,7 +164,7 @@ export default class Groups extends Extension { const memberPayload: KeyValue = {}; for (const key of payloadKeys) { - if (stateProperties[key](payload[key], exposes)) { + if (STATE_PROPERTIES[key](payload[key], exposes)) { memberPayload[key] = payload[key]; } } @@ -218,27 +217,27 @@ export default class Groups extends Extension { } private async parseMQTTMessage(data: eventdata.MQTTMessage): Promise { - let type: 'remove' | 'add' | 'remove_all' = null; - let resolvedEntityGroup: Group = null; - let resolvedEntityDevice: Device = null; - let resolvedEntityEndpoint: zh.Endpoint = null; - let error: string = null; - let groupKey: string = null; - let deviceKey: string = null; - let triggeredViaLegacyApi = false; - let skipDisableReporting = false; + let type: ParsedMQTTMessage['type'] = null; + let resolvedEntityGroup: ParsedMQTTMessage['resolvedEntityGroup'] = null; + let resolvedEntityDevice: ParsedMQTTMessage['resolvedEntityDevice'] = null; + let resolvedEntityEndpoint: ParsedMQTTMessage['resolvedEntityEndpoint'] = null; + let error: ParsedMQTTMessage['error'] = null; + let groupKey: ParsedMQTTMessage['groupKey'] = null; + let deviceKey: ParsedMQTTMessage['deviceKey'] = null; + let triggeredViaLegacyApi: ParsedMQTTMessage['triggeredViaLegacyApi'] = false; + let skipDisableReporting: ParsedMQTTMessage['skipDisableReporting'] = false; /* istanbul ignore else */ - const topicRegexMatch = data.topic.match(topicRegex); - const legacyTopicRegexRemoveAllMatch = data.topic.match(legacyTopicRegexRemoveAll); - const legacyTopicRegexMatch = data.topic.match(legacyTopicRegex); + const topicRegexMatch = data.topic.match(TOPIC_REGEX); + const legacyTopicRegexRemoveAllMatch = data.topic.match(LEGACY_TOPIC_REGEX_REMOVE_ALL); + const legacyTopicRegexMatch = data.topic.match(LEGACY_TOPIC_REGEX); if (this.legacyApi && (legacyTopicRegexMatch || legacyTopicRegexRemoveAllMatch)) { triggeredViaLegacyApi = true; if (legacyTopicRegexMatch) { resolvedEntityGroup = this.zigbee.resolveEntity(legacyTopicRegexMatch[1]) as Group; - type = legacyTopicRegexMatch[2] as 'remove' | 'remove_all' | 'add'; + type = legacyTopicRegexMatch[2] as ParsedMQTTMessage['type']; if (!resolvedEntityGroup || !(resolvedEntityGroup instanceof Group)) { logger.error(`Group '${legacyTopicRegexMatch[1]}' does not exist`); diff --git a/test/bind.test.js b/test/bind.test.js index 28046fa7f..896fe5328 100644 --- a/test/bind.test.js +++ b/test/bind.test.js @@ -83,19 +83,114 @@ describe('Bind', () => { {retain: false, qos: 0}, expect.any(Function) ); - expect(MQTT.publish).toHaveBeenCalledWith( - 'zigbee2mqtt/bridge/devices', - expect.any(String), - { retain: true, qos: 0 }, - expect.any(Function) - ); + expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), { retain: true, qos: 0 }, expect.any(Function)); // Teardown target.binds = originalTargetBinds; device.getEndpoint(1).outputClusters = originalDeviceOutputClusters; }); - it('Should bind only specifief clusters', async () => { + it('Filters out unsupported clusters for reporting setup', async () => { + const device = zigbeeHerdsman.devices.remote; + const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1); + const endpoint = device.getEndpoint(1); + + // Setup + const originalDeviceInputClusters = device.getEndpoint(1).inputClusters; + device.getEndpoint(1).inputClusters = [...device.getEndpoint(1).inputClusters, 8]; + const originalDeviceOutputClusters = device.getEndpoint(1).outputClusters; + device.getEndpoint(1).outputClusters = [...device.getEndpoint(1).outputClusters, 768]; + const originalTargetInputClusters = target.inputClusters; + target.inputClusters = [...originalTargetInputClusters]; + target.inputClusters.splice(originalTargetInputClusters.indexOf(8), 1);// remove genLevelCtrl + const originalTargetOutputClusters = target.outputClusters; + target.outputClusters = [...target.outputClusters, 8]; + const originalTargetBinds = target.binds; + target.binds = [{cluster: {name: 'genLevelCtrl'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)}]; + target.getClusterAttributeValue.mockImplementationOnce((cluster, value) => undefined); + mockClear(device); + target.configureReporting.mockImplementationOnce(() => {throw new Error("timeout")}); + + MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({transaction: "1234", from: 'remote', to: 'bulb_color'})); + await flushPromises(); + + expect(target.read).toHaveBeenCalledWith('lightingColorCtrl', [ 'colorCapabilities' ]); + expect(endpoint.bind).toHaveBeenCalledTimes(4); + expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target); + expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target); + expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target); + expect(endpoint.bind).toHaveBeenCalledWith("lightingColorCtrl", target); + expect(target.configureReporting).toHaveBeenCalledTimes(2); + expect(target.configureReporting).toHaveBeenCalledWith("genOnOff",[{"attribute": "onOff", "maximumReportInterval": 3600, "minimumReportInterval": 0, "reportableChange": 0}]); + // expect(target.configureReporting).toHaveBeenCalledWith("genLevelCtrl",[{"attribute": "currentLevel", "maximumReportInterval": 3600, "minimumReportInterval": 5, "reportableChange": 1}]); + expect(target.configureReporting).toHaveBeenCalledWith("lightingColorCtrl",[{"attribute":"colorTemperature","minimumReportInterval":5,"maximumReportInterval":3600,"reportableChange":1},{"attribute":"currentX","minimumReportInterval":5,"maximumReportInterval":3600,"reportableChange":1},{"attribute":"currentY","minimumReportInterval":5,"maximumReportInterval":3600,"reportableChange":1}]); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/device/bind', + stringify({"transaction": "1234","data":{"from":"remote","to":"bulb_color","clusters":["genScenes","genOnOff","genLevelCtrl","lightingColorCtrl"],"failed":[]},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + + expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), { retain: true, qos: 0 }, expect.any(Function)); + + // Teardown + target.binds = originalTargetBinds; + target.inputClusters = originalTargetInputClusters; + target.outputClusters = originalTargetOutputClusters; + device.getEndpoint(1).inputClusters = originalDeviceInputClusters; + device.getEndpoint(1).outputClusters = originalDeviceOutputClusters; + }); + + it('Filters out reporting setup based on bind status', async () => { + const device = zigbeeHerdsman.devices.remote; + const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1); + const endpoint = device.getEndpoint(1); + + // Setup + const originalDeviceOutputClusters = device.getEndpoint(1).outputClusters; + device.getEndpoint(1).outputClusters = [...device.getEndpoint(1).outputClusters, 768]; + const originalTargetBinds = target.binds; + target.binds = [{cluster: {name: 'genLevelCtrl'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)}]; + target.getClusterAttributeValue.mockImplementationOnce((cluster, value) => undefined); + mockClear(device); + target.configureReporting.mockImplementationOnce(() => {throw new Error("timeout")}); + const originalTargetCR = target.configuredReportings; + target.configuredReportings = [ + { + cluster: {name: 'genLevelCtrl'}, + attribute: {name: 'currentLevel', ID: 0}, + minimumReportInterval: 0, + maximumReportInterval: 3600, + reportableChange: 0, + } + ]; + + MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({transaction: "1234", from: 'remote', to: 'bulb_color'})); + await flushPromises(); + expect(target.read).toHaveBeenCalledWith('lightingColorCtrl', [ 'colorCapabilities' ]); + expect(endpoint.bind).toHaveBeenCalledTimes(4); + expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target); + expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target); + expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target); + expect(endpoint.bind).toHaveBeenCalledWith("lightingColorCtrl", target); + expect(target.configureReporting).toHaveBeenCalledTimes(2); + expect(target.configureReporting).toHaveBeenCalledWith("genOnOff",[{"attribute": "onOff", "maximumReportInterval": 3600, "minimumReportInterval": 0, "reportableChange": 0}]); + // expect(target.configureReporting).toHaveBeenCalledWith("genLevelCtrl",[{"attribute": "currentLevel", "maximumReportInterval": 3600, "minimumReportInterval": 5, "reportableChange": 1}]); + expect(target.configureReporting).toHaveBeenCalledWith("lightingColorCtrl",[{"attribute":"colorTemperature","minimumReportInterval":5,"maximumReportInterval":3600,"reportableChange":1},{"attribute":"currentX","minimumReportInterval":5,"maximumReportInterval":3600,"reportableChange":1},{"attribute":"currentY","minimumReportInterval":5,"maximumReportInterval":3600,"reportableChange":1}]); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/device/bind', + stringify({"transaction": "1234","data":{"from":"remote","to":"bulb_color","clusters":["genScenes","genOnOff","genLevelCtrl", "lightingColorCtrl"],"failed":[]},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + + expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), { retain: true, qos: 0 }, expect.any(Function)); + + // Teardown + target.configuredReportings = originalTargetCR; + target.binds = originalTargetBinds; + device.getEndpoint(1).outputClusters = originalDeviceOutputClusters; + }); + + it('Should bind only specified clusters', async () => { const device = zigbeeHerdsman.devices.remote; const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1); const endpoint = device.getEndpoint(1);