mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-08-24 11:39:53 +00:00
fix: Bind code optimisation/formatting (#23110)
* Bind code optimization/formatting * Feedback * Fix coverage
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
+169
-117
@@ -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<boolean>}[]} =
|
||||
{
|
||||
const REPORT_CLUSTERS: Readonly<Partial<Record<ClusterName, Readonly<{
|
||||
attribute: string;
|
||||
minimumReportInterval: number;
|
||||
maximumReportInterval: number;
|
||||
reportableChange: number;
|
||||
condition?: (endpoint: zh.Endpoint) => Promise<boolean>;
|
||||
}>[]>>> = {
|
||||
'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<boolean> => (await getColorCapabilities(endpoint)).colorTemperature,
|
||||
},
|
||||
{
|
||||
attribute: 'currentX', ...defaultReportConfiguration,
|
||||
attribute: 'currentX', ...DEFAULT_REPORT_CONFIG,
|
||||
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
|
||||
},
|
||||
{
|
||||
attribute: 'currentY', ...defaultReportConfiguration,
|
||||
attribute: 'currentY', ...DEFAULT_REPORT_CONFIG,
|
||||
condition: async (endpoint): Promise<boolean> => (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<string[]>}
|
||||
manufacturerIDs: number[],
|
||||
manufacturerNames: string [],
|
||||
cluster: Readonly<Partial<Record<ClusterName, {type: string, data: KeyValue}[]>>>;
|
||||
read: Readonly<{cluster: string, attributes: string[], attributesForEndpoint?: (endpoint: zh.Endpoint) => Promise<string[]>}>;
|
||||
manufacturerIDs: readonly number[];
|
||||
manufacturerNames: readonly string[];
|
||||
}[];
|
||||
|
||||
const pollOnMessage: PollOnMessage = [
|
||||
const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
|
||||
{
|
||||
// On messages that have the cluster and type of below
|
||||
cluster: {
|
||||
@@ -159,8 +162,17 @@ const pollOnMessage: PollOnMessage = [
|
||||
attributesForEndpoint: async (endpoint): Promise<string[]> => {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<zh.Endpoint> = 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);
|
||||
}
|
||||
|
||||
+19
-20
@@ -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<Record<string, (value: string, exposes: zhc.Expose[]) => 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<ParsedMQTTMessage> {
|
||||
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`);
|
||||
|
||||
+102
-7
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user