mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-08-27 21:20:03 +00:00
fix!: Improve bind/bridge/groups parsing and resolving consistency (#24432)
* Improve bind/bridge/groups parsing and resolving consistency * Fix mqtt payload casing. Improve typing. * Cleanup tests * Add test for bind endpoint by ID. * Match behavior for group response 'default' endpoint.
This commit is contained in:
+174
-95
@@ -195,15 +195,23 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
|
||||
|
||||
interface ParsedMQTTMessage {
|
||||
type: 'bind' | 'unbind';
|
||||
sourceKey: string;
|
||||
targetKey: string;
|
||||
sourceKey?: string;
|
||||
sourceEndpointKey?: string | number;
|
||||
targetKey?: string;
|
||||
targetEndpointKey?: string | number;
|
||||
clusters?: string[];
|
||||
skipDisableReporting: boolean;
|
||||
resolvedSource?: Device;
|
||||
resolvedTarget?: Device | Group | typeof DEFAULT_BIND_GROUP;
|
||||
resolvedSourceEndpoint?: zh.Endpoint;
|
||||
resolvedBindTarget?: number | zh.Endpoint | zh.Group;
|
||||
}
|
||||
|
||||
interface DataMessage {
|
||||
from: ParsedMQTTMessage['sourceKey'];
|
||||
from_endpoint?: ParsedMQTTMessage['sourceEndpointKey'];
|
||||
to: ParsedMQTTMessage['targetKey'];
|
||||
to_endpoint: ParsedMQTTMessage['targetEndpointKey'];
|
||||
clusters: ParsedMQTTMessage['clusters'];
|
||||
skip_disable_reporting?: ParsedMQTTMessage['skipDisableReporting'];
|
||||
}
|
||||
@@ -217,128 +225,199 @@ export default class Bind extends Extension {
|
||||
this.eventBus.onGroupMembersChanged(this, this.onGroupMembersChanged);
|
||||
}
|
||||
|
||||
private parseMQTTMessage(data: eventdata.MQTTMessage): ParsedMQTTMessage | undefined {
|
||||
let type: ParsedMQTTMessage['type'] | undefined;
|
||||
let sourceKey: ParsedMQTTMessage['sourceKey'] | undefined;
|
||||
let targetKey: ParsedMQTTMessage['targetKey'] | undefined;
|
||||
let clusters: ParsedMQTTMessage['clusters'] | undefined;
|
||||
let skipDisableReporting: ParsedMQTTMessage['skipDisableReporting'] = false;
|
||||
|
||||
private parseMQTTMessage(
|
||||
data: eventdata.MQTTMessage,
|
||||
): [raw: KeyValue | undefined, parsed: ParsedMQTTMessage | undefined, error: string | undefined] {
|
||||
if (data.topic.match(TOPIC_REGEX)) {
|
||||
type = data.topic.endsWith('unbind') ? 'unbind' : 'bind';
|
||||
const type = data.topic.endsWith('unbind') ? 'unbind' : 'bind';
|
||||
let skipDisableReporting = false;
|
||||
const message: DataMessage = JSON.parse(data.message);
|
||||
sourceKey = message.from;
|
||||
targetKey = message.to;
|
||||
clusters = message.clusters;
|
||||
skipDisableReporting = message.skip_disable_reporting != undefined ? message.skip_disable_reporting : false;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {type, sourceKey, targetKey, clusters, skipDisableReporting};
|
||||
if (typeof message !== 'object' || message.from == undefined || message.to == undefined) {
|
||||
return [message, {type, skipDisableReporting}, `Invalid payload`];
|
||||
}
|
||||
|
||||
const sourceKey = message.from;
|
||||
const sourceEndpointKey = message.from_endpoint ?? 'default';
|
||||
const targetKey = message.to;
|
||||
const targetEndpointKey = message.to_endpoint;
|
||||
const clusters = message.clusters;
|
||||
skipDisableReporting = message.skip_disable_reporting != undefined ? message.skip_disable_reporting : false;
|
||||
const resolvedSource = this.zigbee.resolveEntity(message.from) as Device;
|
||||
|
||||
if (!resolvedSource || !(resolvedSource instanceof Device)) {
|
||||
return [message, {type, skipDisableReporting}, `Source device '${message.from}' does not exist`];
|
||||
}
|
||||
|
||||
const resolvedTarget = message.to === DEFAULT_BIND_GROUP.name ? DEFAULT_BIND_GROUP : this.zigbee.resolveEntity(message.to);
|
||||
|
||||
if (!resolvedTarget) {
|
||||
return [message, {type, skipDisableReporting}, `Target device or group '${message.to}' does not exist`];
|
||||
}
|
||||
|
||||
const resolvedSourceEndpoint = resolvedSource.endpoint(sourceEndpointKey);
|
||||
|
||||
if (!resolvedSourceEndpoint) {
|
||||
return [
|
||||
message,
|
||||
{type, skipDisableReporting},
|
||||
`Source device '${resolvedSource.name}' does not have endpoint '${sourceEndpointKey}'`,
|
||||
];
|
||||
}
|
||||
|
||||
// resolves to 'default' endpoint if targetEndpointKey is invalid (used by frontend for 'Coordinator')
|
||||
const resolvedBindTarget =
|
||||
resolvedTarget instanceof Device
|
||||
? resolvedTarget.endpoint(targetEndpointKey)
|
||||
: resolvedTarget instanceof Group
|
||||
? resolvedTarget.zh
|
||||
: Number(resolvedTarget.ID);
|
||||
|
||||
if (resolvedTarget instanceof Device && !resolvedBindTarget) {
|
||||
return [
|
||||
message,
|
||||
{type, skipDisableReporting},
|
||||
`Target device '${resolvedTarget.name}' does not have endpoint '${targetEndpointKey}'`,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
message,
|
||||
{
|
||||
type,
|
||||
sourceKey,
|
||||
sourceEndpointKey,
|
||||
targetKey,
|
||||
targetEndpointKey,
|
||||
clusters,
|
||||
skipDisableReporting,
|
||||
resolvedSource,
|
||||
resolvedTarget,
|
||||
resolvedSourceEndpoint,
|
||||
resolvedBindTarget,
|
||||
},
|
||||
undefined,
|
||||
];
|
||||
} else {
|
||||
return [undefined, undefined, undefined];
|
||||
}
|
||||
}
|
||||
|
||||
@bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const parsed = this.parseMQTTMessage(data);
|
||||
const [raw, parsed, error] = this.parseMQTTMessage(data);
|
||||
|
||||
if (!parsed || !parsed.type) {
|
||||
if (!raw || !parsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {type, sourceKey, targetKey, clusters, skipDisableReporting} = parsed;
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
if (error) {
|
||||
await this.publishResponse(parsed.type, raw, {}, error);
|
||||
return;
|
||||
}
|
||||
|
||||
let error: string | undefined;
|
||||
const parsedSource = this.zigbee.resolveEntityAndEndpoint(sourceKey);
|
||||
const parsedTarget = this.zigbee.resolveEntityAndEndpoint(targetKey);
|
||||
const source = parsedSource.entity;
|
||||
const target = targetKey === DEFAULT_BIND_GROUP.name ? DEFAULT_BIND_GROUP : parsedTarget.entity;
|
||||
const responseData: KeyValue = {from: sourceKey, to: targetKey};
|
||||
const {
|
||||
type,
|
||||
sourceKey,
|
||||
sourceEndpointKey,
|
||||
targetKey,
|
||||
targetEndpointKey,
|
||||
clusters,
|
||||
skipDisableReporting,
|
||||
resolvedSource,
|
||||
resolvedTarget,
|
||||
resolvedSourceEndpoint,
|
||||
resolvedBindTarget,
|
||||
} = parsed;
|
||||
|
||||
if (!source || !(source instanceof Device)) {
|
||||
error = `Source device '${sourceKey}' does not exist`;
|
||||
} else if (parsedSource.endpointID && !parsedSource.endpoint) {
|
||||
error = `Source device '${parsedSource.ID}' does not have endpoint '${parsedSource.endpointID}'`;
|
||||
} else if (!target) {
|
||||
error = `Target device or group '${targetKey}' does not exist`;
|
||||
} else if (target instanceof Device && parsedTarget.endpointID && !parsedTarget.endpoint) {
|
||||
error = `Target device '${parsedTarget.ID}' does not have endpoint '${parsedTarget.endpointID}'`;
|
||||
} else {
|
||||
const successfulClusters: string[] = [];
|
||||
const failedClusters = [];
|
||||
const attemptedClusters = [];
|
||||
assert(resolvedSource, '`resolvedSource` is missing');
|
||||
assert(resolvedTarget, '`resolvedTarget` is missing');
|
||||
assert(resolvedSourceEndpoint, '`resolvedSourceEndpoint` is missing');
|
||||
assert(resolvedBindTarget != undefined, '`resolvedBindTarget` is missing');
|
||||
|
||||
const bindSource = parsedSource.endpoint;
|
||||
const bindTarget = target instanceof Device ? parsedTarget.endpoint : target instanceof Group ? target.zh : Number(target.ID);
|
||||
const successfulClusters: string[] = [];
|
||||
const failedClusters = [];
|
||||
const attemptedClusters = [];
|
||||
// Find which clusters are supported by both the source and target.
|
||||
// Groups are assumed to support all clusters.
|
||||
const clusterCandidates = clusters ?? ALL_CLUSTER_CANDIDATES;
|
||||
|
||||
assert(bindSource != undefined && bindTarget != undefined);
|
||||
for (const cluster of clusterCandidates) {
|
||||
let matchingClusters = false;
|
||||
|
||||
// Find which clusters are supported by both the source and target.
|
||||
// Groups are assumed to support all clusters.
|
||||
const clusterCandidates = clusters ?? ALL_CLUSTER_CANDIDATES;
|
||||
const anyClusterValid =
|
||||
utils.isZHGroup(resolvedBindTarget) ||
|
||||
typeof resolvedBindTarget === 'number' ||
|
||||
(resolvedTarget instanceof Device && resolvedTarget.zh.type === 'Coordinator');
|
||||
|
||||
for (const cluster of clusterCandidates) {
|
||||
let matchingClusters = false;
|
||||
if (!anyClusterValid && utils.isZHEndpoint(resolvedBindTarget)) {
|
||||
matchingClusters =
|
||||
(resolvedBindTarget.supportsInputCluster(cluster) && resolvedSourceEndpoint.supportsOutputCluster(cluster)) ||
|
||||
(resolvedSourceEndpoint.supportsInputCluster(cluster) && resolvedBindTarget.supportsOutputCluster(cluster));
|
||||
}
|
||||
|
||||
const anyClusterValid = utils.isZHGroup(bindTarget) || typeof bindTarget === 'number' || (target as Device).zh.type === 'Coordinator';
|
||||
const sourceValid = resolvedSourceEndpoint.supportsInputCluster(cluster) || resolvedSourceEndpoint.supportsOutputCluster(cluster);
|
||||
|
||||
if (!anyClusterValid && utils.isZHEndpoint(bindTarget)) {
|
||||
matchingClusters =
|
||||
(bindTarget.supportsInputCluster(cluster) && bindSource.supportsOutputCluster(cluster)) ||
|
||||
(bindSource.supportsInputCluster(cluster) && bindTarget.supportsOutputCluster(cluster));
|
||||
}
|
||||
if (sourceValid && (anyClusterValid || matchingClusters)) {
|
||||
logger.debug(`${type}ing cluster '${cluster}' from '${resolvedSource.name}' to '${resolvedTarget.name}'`);
|
||||
attemptedClusters.push(cluster);
|
||||
|
||||
const sourceValid = bindSource.supportsInputCluster(cluster) || bindSource.supportsOutputCluster(cluster);
|
||||
|
||||
if (sourceValid && (anyClusterValid || matchingClusters)) {
|
||||
logger.debug(`${type}ing cluster '${cluster}' from '${source.name}' to '${target.name}'`);
|
||||
attemptedClusters.push(cluster);
|
||||
|
||||
try {
|
||||
if (type === 'bind') {
|
||||
await bindSource.bind(cluster, bindTarget);
|
||||
} else {
|
||||
await bindSource.unbind(cluster, bindTarget);
|
||||
}
|
||||
|
||||
successfulClusters.push(cluster);
|
||||
logger.info(
|
||||
`Successfully ${type === 'bind' ? 'bound' : 'unbound'} cluster '${cluster}' from '${source.name}' to '${target.name}'`,
|
||||
);
|
||||
} catch (error) {
|
||||
failedClusters.push(cluster);
|
||||
logger.error(`Failed to ${type} cluster '${cluster}' from '${source.name}' to '${target.name}' (${error})`);
|
||||
try {
|
||||
if (type === 'bind') {
|
||||
await resolvedSourceEndpoint.bind(cluster, resolvedBindTarget);
|
||||
} else {
|
||||
await resolvedSourceEndpoint.unbind(cluster, resolvedBindTarget);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (attemptedClusters.length === 0) {
|
||||
logger.error(`Nothing to ${type} from '${source.name}' to '${target.name}'`);
|
||||
error = `Nothing to ${type}`;
|
||||
} else if (failedClusters.length === attemptedClusters.length) {
|
||||
error = `Failed to ${type}`;
|
||||
}
|
||||
|
||||
responseData[`clusters`] = successfulClusters;
|
||||
responseData[`failed`] = failedClusters;
|
||||
|
||||
if (successfulClusters.length !== 0) {
|
||||
if (type === 'bind') {
|
||||
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);
|
||||
successfulClusters.push(cluster);
|
||||
logger.info(
|
||||
`Successfully ${type === 'bind' ? 'bound' : 'unbound'} cluster '${cluster}' from '${resolvedSource.name}' to '${resolvedTarget.name}'`,
|
||||
);
|
||||
} catch (error) {
|
||||
failedClusters.push(cluster);
|
||||
logger.error(`Failed to ${type} cluster '${cluster}' from '${resolvedSource.name}' to '${resolvedTarget.name}' (${error})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = utils.getResponse(message, responseData, error);
|
||||
if (attemptedClusters.length === 0) {
|
||||
logger.error(`Nothing to ${type} from '${resolvedSource.name}' to '${resolvedTarget.name}'`);
|
||||
await this.publishResponse(parsed.type, raw, {}, `Nothing to ${type}`);
|
||||
return;
|
||||
} else if (failedClusters.length === attemptedClusters.length) {
|
||||
await this.publishResponse(parsed.type, raw, {}, `Failed to ${type}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.mqtt.publish(`bridge/response/device/${type}`, stringify(response));
|
||||
const responseData: KeyValue = {
|
||||
from: sourceKey,
|
||||
from_endpoint: sourceEndpointKey,
|
||||
to: targetKey,
|
||||
to_endpoint: targetEndpointKey,
|
||||
clusters: successfulClusters,
|
||||
failed: failedClusters,
|
||||
};
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (successfulClusters.length !== 0) {
|
||||
if (type === 'bind') {
|
||||
await this.setupReporting(
|
||||
resolvedSourceEndpoint.binds.filter((b) => successfulClusters.includes(b.cluster.name) && b.target === resolvedBindTarget),
|
||||
);
|
||||
} else if (typeof resolvedBindTarget !== 'number' && !skipDisableReporting) {
|
||||
await this.disableUnnecessaryReportings(resolvedBindTarget);
|
||||
}
|
||||
}
|
||||
|
||||
await this.publishResponse(parsed.type, raw, responseData);
|
||||
this.eventBus.emitDevicesChanged();
|
||||
}
|
||||
|
||||
private async publishResponse(type: ParsedMQTTMessage['type'], request: KeyValue, data: KeyValue, error?: string): Promise<void> {
|
||||
const response = stringify(utils.getResponse(request, data, error));
|
||||
await this.mqtt.publish(`bridge/response/device/${type}`, response);
|
||||
|
||||
if (error) {
|
||||
logger.error(error);
|
||||
} else {
|
||||
this.eventBus.emitDevicesChanged();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-13
@@ -431,6 +431,7 @@ export default class Bridge extends Extension {
|
||||
if (
|
||||
typeof message !== 'object' ||
|
||||
message.id === undefined ||
|
||||
message.endpoint === undefined ||
|
||||
message.cluster === undefined ||
|
||||
message.maximum_report_interval === undefined ||
|
||||
message.minimum_report_interval === undefined ||
|
||||
@@ -440,14 +441,11 @@ export default class Bridge extends Extension {
|
||||
throw new Error(`Invalid payload`);
|
||||
}
|
||||
|
||||
const device = this.zigbee.resolveEntityAndEndpoint(message.id);
|
||||
if (!device.entity) {
|
||||
throw new Error(`Device '${message.id}' does not exist`);
|
||||
}
|
||||
const device = this.getEntity('device', message.id);
|
||||
const endpoint = device.endpoint(message.endpoint);
|
||||
|
||||
const endpoint = device.endpoint;
|
||||
if (!endpoint) {
|
||||
throw new Error(`Device '${device.ID}' does not have endpoint '${device.endpointID}'`);
|
||||
throw new Error(`Device '${device.ID}' does not have endpoint '${message.endpoint}'`);
|
||||
}
|
||||
|
||||
const coordinatorEndpoint = this.zigbee.firstCoordinatorEndpoint();
|
||||
@@ -472,6 +470,7 @@ export default class Bridge extends Extension {
|
||||
|
||||
return utils.getResponse(message, {
|
||||
id: message.id,
|
||||
endpoint: message.endpoint,
|
||||
cluster: message.cluster,
|
||||
maximum_report_interval: message.maximum_report_interval,
|
||||
minimum_report_interval: message.minimum_report_interval,
|
||||
@@ -485,7 +484,7 @@ export default class Bridge extends Extension {
|
||||
throw new Error(`Invalid payload`);
|
||||
}
|
||||
|
||||
const device = this.getEntity('device', message.id) as Device;
|
||||
const device = this.getEntity('device', message.id);
|
||||
logger.info(`Interviewing '${device.name}'`);
|
||||
|
||||
try {
|
||||
@@ -508,12 +507,7 @@ export default class Bridge extends Extension {
|
||||
throw new Error(`Invalid payload`);
|
||||
}
|
||||
|
||||
const device = this.zigbee.resolveEntityAndEndpoint(message.id).entity as Device;
|
||||
|
||||
if (!device) {
|
||||
throw new Error(`Device '${message.id}' does not exist`);
|
||||
}
|
||||
|
||||
const device = this.getEntity('device', message.id);
|
||||
const source = await zhc.generateExternalDefinitionSource(device.zh);
|
||||
|
||||
return utils.getResponse(message, {id: message.id, source});
|
||||
@@ -634,6 +628,9 @@ export default class Bridge extends Extension {
|
||||
}
|
||||
}
|
||||
|
||||
getEntity(type: 'group', ID: string): Group;
|
||||
getEntity(type: 'device', ID: string): Device;
|
||||
getEntity(type: 'group' | 'device', ID: string): Device | Group;
|
||||
getEntity(type: 'group' | 'device', ID: string): Device | Group {
|
||||
const entity = this.zigbee.resolveEntity(ID);
|
||||
if (!entity || entity.constructor.name.toLowerCase() !== type) {
|
||||
|
||||
+107
-84
@@ -30,13 +30,20 @@ const STATE_PROPERTIES: Readonly<Record<string, (value: string, exposes: zhc.Exp
|
||||
|
||||
interface ParsedMQTTMessage {
|
||||
type: 'remove' | 'add' | 'remove_all';
|
||||
resolvedEntityGroup?: Group;
|
||||
resolvedEntityDevice: Device;
|
||||
error?: string;
|
||||
resolvedGroup?: Group;
|
||||
resolvedDevice?: Device;
|
||||
resolvedEndpoint?: zh.Endpoint;
|
||||
groupKey?: string;
|
||||
deviceKey?: string;
|
||||
endpointKey?: string | number;
|
||||
skipDisableReporting: boolean;
|
||||
resolvedEntityEndpoint?: zh.Endpoint;
|
||||
}
|
||||
|
||||
interface DataMessage {
|
||||
device: ParsedMQTTMessage['deviceKey'];
|
||||
group: ParsedMQTTMessage['groupKey'];
|
||||
endpoint: ParsedMQTTMessage['endpointKey'];
|
||||
skip_disable_reporting?: ParsedMQTTMessage['skipDisableReporting'];
|
||||
}
|
||||
|
||||
export default class Groups extends Extension {
|
||||
@@ -175,120 +182,136 @@ export default class Groups extends Extension {
|
||||
return true;
|
||||
}
|
||||
|
||||
private async parseMQTTMessage(data: eventdata.MQTTMessage): Promise<ParsedMQTTMessage | undefined> {
|
||||
let type: ParsedMQTTMessage['type'] | undefined;
|
||||
let resolvedEntityGroup: ParsedMQTTMessage['resolvedEntityGroup'] | undefined;
|
||||
let resolvedEntityDevice: ParsedMQTTMessage['resolvedEntityDevice'] | undefined;
|
||||
let resolvedEntityEndpoint: ParsedMQTTMessage['resolvedEntityEndpoint'] | undefined;
|
||||
let error: ParsedMQTTMessage['error'] | undefined;
|
||||
let groupKey: ParsedMQTTMessage['groupKey'] | undefined;
|
||||
let deviceKey: ParsedMQTTMessage['deviceKey'] | undefined;
|
||||
let skipDisableReporting: ParsedMQTTMessage['skipDisableReporting'] = false;
|
||||
|
||||
/* istanbul ignore else */
|
||||
private parseMQTTMessage(
|
||||
data: eventdata.MQTTMessage,
|
||||
): [raw: KeyValue | undefined, parsed: ParsedMQTTMessage | undefined, error: string | undefined] {
|
||||
const topicRegexMatch = data.topic.match(TOPIC_REGEX);
|
||||
|
||||
if (topicRegexMatch) {
|
||||
type = topicRegexMatch[1] as 'remove' | 'add' | 'remove_all';
|
||||
const message = JSON.parse(data.message);
|
||||
deviceKey = message.device;
|
||||
skipDisableReporting = 'skip_disable_reporting' in message ? message.skip_disable_reporting : false;
|
||||
const type = topicRegexMatch[1] as 'remove' | 'add' | 'remove_all';
|
||||
let resolvedGroup;
|
||||
let groupKey;
|
||||
let skipDisableReporting = false;
|
||||
const message: DataMessage = JSON.parse(data.message);
|
||||
|
||||
if (typeof message !== 'object' || message.device == undefined) {
|
||||
return [message, {type, skipDisableReporting}, 'Invalid payload'];
|
||||
}
|
||||
|
||||
const deviceKey = message.device;
|
||||
skipDisableReporting = message.skip_disable_reporting != undefined ? message.skip_disable_reporting : false;
|
||||
|
||||
if (type !== 'remove_all') {
|
||||
groupKey = message.group;
|
||||
resolvedEntityGroup = this.zigbee.resolveEntity(message.group) as Group;
|
||||
|
||||
if (!resolvedEntityGroup || !(resolvedEntityGroup instanceof Group)) {
|
||||
error = `Group '${message.group}' does not exist`;
|
||||
if (message.group == undefined) {
|
||||
return [message, {type, skipDisableReporting}, `Invalid payload`];
|
||||
}
|
||||
|
||||
resolvedGroup = this.zigbee.resolveEntity(message.group);
|
||||
|
||||
if (!resolvedGroup || !(resolvedGroup instanceof Group)) {
|
||||
return [message, {type, skipDisableReporting}, `Group '${message.group}' does not exist`];
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = this.zigbee.resolveEntityAndEndpoint(message.device);
|
||||
resolvedEntityDevice = parsed?.entity as Device;
|
||||
const resolvedDevice = this.zigbee.resolveEntity(message.device);
|
||||
|
||||
if (!error && (!resolvedEntityDevice || !(resolvedEntityDevice instanceof Device))) {
|
||||
error = `Device '${message.device}' does not exist`;
|
||||
if (!resolvedDevice || !(resolvedDevice instanceof Device)) {
|
||||
return [message, {type, skipDisableReporting}, `Device '${message.device}' does not exist`];
|
||||
}
|
||||
|
||||
if (!error) {
|
||||
resolvedEntityEndpoint = parsed.endpoint;
|
||||
const endpointKey = message.endpoint ?? 'default';
|
||||
const resolvedEndpoint = resolvedDevice.endpoint(message.endpoint);
|
||||
|
||||
if (parsed.endpointID && !resolvedEntityEndpoint) {
|
||||
error = `Device '${parsed.ID}' does not have endpoint '${parsed.endpointID}'`;
|
||||
}
|
||||
if (!resolvedEndpoint) {
|
||||
return [message, {type, skipDisableReporting}, `Device '${resolvedDevice.name}' does not have endpoint '${endpointKey}'`];
|
||||
}
|
||||
|
||||
return [
|
||||
message,
|
||||
{
|
||||
resolvedGroup,
|
||||
resolvedDevice,
|
||||
resolvedEndpoint,
|
||||
type,
|
||||
groupKey,
|
||||
deviceKey,
|
||||
endpointKey,
|
||||
skipDisableReporting,
|
||||
},
|
||||
undefined,
|
||||
];
|
||||
} else {
|
||||
return undefined;
|
||||
return [undefined, undefined, undefined];
|
||||
}
|
||||
|
||||
return {
|
||||
resolvedEntityGroup,
|
||||
resolvedEntityDevice,
|
||||
type,
|
||||
error,
|
||||
groupKey,
|
||||
deviceKey,
|
||||
skipDisableReporting,
|
||||
resolvedEntityEndpoint,
|
||||
};
|
||||
}
|
||||
|
||||
@bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const parsed = await this.parseMQTTMessage(data);
|
||||
const [raw, parsed, error] = this.parseMQTTMessage(data);
|
||||
|
||||
if (!parsed || !parsed.type) {
|
||||
if (!raw || !parsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {resolvedEntityGroup, resolvedEntityDevice, type, groupKey, deviceKey, skipDisableReporting, resolvedEntityEndpoint} = parsed;
|
||||
let error = parsed.error;
|
||||
const changedGroups: Group[] = [];
|
||||
|
||||
if (!error) {
|
||||
assert(resolvedEntityEndpoint, '`resolvedEntityEndpoint` is missing');
|
||||
try {
|
||||
if (type === 'add') {
|
||||
assert(resolvedEntityGroup, '`resolvedEntityGroup` is missing');
|
||||
logger.info(`Adding '${resolvedEntityDevice.name}' to '${resolvedEntityGroup.name}'`);
|
||||
await resolvedEntityEndpoint.addToGroup(resolvedEntityGroup.zh);
|
||||
changedGroups.push(resolvedEntityGroup);
|
||||
} else if (type === 'remove') {
|
||||
assert(resolvedEntityGroup, '`resolvedEntityGroup` is missing');
|
||||
logger.info(`Removing '${resolvedEntityDevice.name}' from '${resolvedEntityGroup.name}'`);
|
||||
await resolvedEntityEndpoint.removeFromGroup(resolvedEntityGroup.zh);
|
||||
changedGroups.push(resolvedEntityGroup);
|
||||
} else {
|
||||
// remove_all
|
||||
logger.info(`Removing '${resolvedEntityDevice.name}' from all groups`);
|
||||
|
||||
for (const group of this.zigbee.groupsIterator((g) => g.members.includes(resolvedEntityEndpoint))) {
|
||||
changedGroups.push(group);
|
||||
}
|
||||
|
||||
await resolvedEntityEndpoint.removeFromAllGroups();
|
||||
}
|
||||
} catch (e) {
|
||||
error = `Failed to ${type} from group (${(e as Error).message})`;
|
||||
logger.debug((e as Error).stack!);
|
||||
}
|
||||
if (error) {
|
||||
await this.publishResponse(parsed.type, raw, {}, error);
|
||||
return;
|
||||
}
|
||||
|
||||
const message = utils.parseJSON(data.message, data.message);
|
||||
const responseData: KeyValue = {device: deviceKey};
|
||||
const {resolvedGroup, resolvedDevice, resolvedEndpoint, type, groupKey, deviceKey, endpointKey, skipDisableReporting} = parsed;
|
||||
const changedGroups: Group[] = [];
|
||||
|
||||
assert(resolvedDevice, '`resolvedDevice` is missing');
|
||||
assert(resolvedEndpoint, '`resolvedEndpoint` is missing');
|
||||
|
||||
try {
|
||||
if (type === 'add') {
|
||||
assert(resolvedGroup, '`resolvedGroup` is missing');
|
||||
logger.info(`Adding '${resolvedDevice.name}' to '${resolvedGroup.name}'`);
|
||||
await resolvedEndpoint.addToGroup(resolvedGroup.zh);
|
||||
changedGroups.push(resolvedGroup);
|
||||
} else if (type === 'remove') {
|
||||
assert(resolvedGroup, '`resolvedGroup` is missing');
|
||||
logger.info(`Removing '${resolvedDevice.name}' from '${resolvedGroup.name}'`);
|
||||
await resolvedEndpoint.removeFromGroup(resolvedGroup.zh);
|
||||
changedGroups.push(resolvedGroup);
|
||||
} else {
|
||||
// remove_all
|
||||
logger.info(`Removing '${resolvedDevice.name}' from all groups`);
|
||||
|
||||
for (const group of this.zigbee.groupsIterator((g) => g.members.includes(resolvedEndpoint))) {
|
||||
changedGroups.push(group);
|
||||
}
|
||||
|
||||
await resolvedEndpoint.removeFromAllGroups();
|
||||
}
|
||||
} catch (e) {
|
||||
const errorMsg = `Failed to ${type} from group (${(e as Error).message})`;
|
||||
await this.publishResponse(parsed.type, raw, {}, errorMsg);
|
||||
logger.debug((e as Error).stack!);
|
||||
return;
|
||||
}
|
||||
|
||||
const responseData: KeyValue = {device: deviceKey, endpoint: endpointKey};
|
||||
|
||||
if (groupKey) {
|
||||
responseData.group = groupKey;
|
||||
}
|
||||
|
||||
await this.mqtt.publish(`bridge/response/group/members/${type}`, stringify(utils.getResponse(message, responseData, error)));
|
||||
await this.publishResponse(parsed.type, raw, responseData);
|
||||
|
||||
for (const group of changedGroups) {
|
||||
this.eventBus.emitGroupMembersChanged({group, action: type, endpoint: resolvedEndpoint, skipDisableReporting});
|
||||
}
|
||||
}
|
||||
|
||||
private async publishResponse(type: ParsedMQTTMessage['type'], request: KeyValue, data: KeyValue, error?: string): Promise<void> {
|
||||
const response = stringify(utils.getResponse(request, data, error));
|
||||
await this.mqtt.publish(`bridge/response/group/members/${type}`, response);
|
||||
|
||||
if (error) {
|
||||
logger.error(error);
|
||||
} else {
|
||||
assert(resolvedEntityEndpoint, '`resolvedEntityEndpoint` is missing');
|
||||
for (const group of changedGroups) {
|
||||
this.eventBus.emitGroupMembersChanged({group, action: type, endpoint: resolvedEntityEndpoint, skipDisableReporting});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+117
-42
@@ -96,7 +96,13 @@ describe('Extension: Bind', () => {
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({
|
||||
transaction: '1234',
|
||||
data: {from: 'remote', to: 'bulb_color', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl'], failed: []},
|
||||
data: {
|
||||
from: 'remote',
|
||||
from_endpoint: 'default',
|
||||
to: 'bulb_color',
|
||||
clusters: ['genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl'],
|
||||
failed: [],
|
||||
},
|
||||
status: 'ok',
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
@@ -110,6 +116,18 @@ describe('Extension: Bind', () => {
|
||||
device.getEndpoint(1)!.outputClusters = originalDeviceOutputClusters;
|
||||
});
|
||||
|
||||
it('Should throw error on invalid payload', async () => {
|
||||
mockMQTT.publish.mockClear();
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({fromz: 'remote', to: 'bulb_color'}));
|
||||
await flushPromises();
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({data: {}, status: 'error', error: 'Invalid payload'}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Filters out unsupported clusters for reporting setup', async () => {
|
||||
const device = devices.remote;
|
||||
const target = devices.bulb_color.getEndpoint(1)!;
|
||||
@@ -156,7 +174,13 @@ describe('Extension: Bind', () => {
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({
|
||||
transaction: '1234',
|
||||
data: {from: 'remote', to: 'bulb_color', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl'], failed: []},
|
||||
data: {
|
||||
from: 'remote',
|
||||
from_endpoint: 'default',
|
||||
to: 'bulb_color',
|
||||
clusters: ['genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl'],
|
||||
failed: [],
|
||||
},
|
||||
status: 'ok',
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
@@ -221,7 +245,13 @@ describe('Extension: Bind', () => {
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({
|
||||
transaction: '1234',
|
||||
data: {from: 'remote', to: 'bulb_color', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl'], failed: []},
|
||||
data: {
|
||||
from: 'remote',
|
||||
from_endpoint: 'default',
|
||||
to: 'bulb_color',
|
||||
clusters: ['genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl'],
|
||||
failed: [],
|
||||
},
|
||||
status: 'ok',
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
@@ -247,7 +277,7 @@ describe('Extension: Bind', () => {
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({data: {from: 'remote', to: 'bulb_color', clusters: ['genOnOff'], failed: []}, status: 'ok'}),
|
||||
stringify({data: {from: 'remote', from_endpoint: 'default', to: 'bulb_color', clusters: ['genOnOff'], failed: []}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -263,7 +293,7 @@ describe('Extension: Bind', () => {
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(0);
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({data: {from: 'remote', to: 'button', clusters: [], failed: []}, status: 'error', error: 'Nothing to bind'}),
|
||||
stringify({data: {}, status: 'error', error: 'Nothing to bind'}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -313,7 +343,10 @@ describe('Extension: Bind', () => {
|
||||
expect(devices.bulb_color.meta.configured).toBe(332242049);
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/unbind',
|
||||
stringify({data: {from: 'remote', to: 'bulb_color', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []}, status: 'ok'}),
|
||||
stringify({
|
||||
data: {from: 'remote', from_endpoint: 'default', to: 'bulb_color', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []},
|
||||
status: 'ok',
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -337,7 +370,10 @@ describe('Extension: Bind', () => {
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', target);
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/unbind',
|
||||
stringify({data: {from: 'remote', to: 'Coordinator', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []}, status: 'ok'}),
|
||||
stringify({
|
||||
data: {from: 'remote', from_endpoint: 'default', to: 'Coordinator', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []},
|
||||
status: 'ok',
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -366,7 +402,10 @@ describe('Extension: Bind', () => {
|
||||
]);
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({data: {from: 'remote', to: 'group_1', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []}, status: 'ok'}),
|
||||
stringify({
|
||||
data: {from: 'remote', from_endpoint: 'default', to: 'group_1', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []},
|
||||
status: 'ok',
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -400,7 +439,10 @@ describe('Extension: Bind', () => {
|
||||
expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', target);
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/unbind',
|
||||
stringify({data: {from: 'remote', to: 'group_1', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []}, status: 'ok'}),
|
||||
stringify({
|
||||
data: {from: 'remote', from_endpoint: 'default', to: 'group_1', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []},
|
||||
status: 'ok',
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -475,7 +517,10 @@ describe('Extension: Bind', () => {
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({data: {from: 'remote', to: '1', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []}, status: 'ok'}),
|
||||
stringify({
|
||||
data: {from: 'remote', from_endpoint: 'default', to: '1', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []},
|
||||
status: 'ok',
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -492,30 +537,55 @@ describe('Extension: Bind', () => {
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'bulb_color'}));
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(3);
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({data: {}, status: 'error', error: 'Failed to bind'}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should bind from non default endpoint names', async () => {
|
||||
const device = devices.remote;
|
||||
const target = devices.QBKG03LM.getEndpoint(3)!;
|
||||
const endpoint = device.getEndpoint(2)!;
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message(
|
||||
'zigbee2mqtt/bridge/request/device/bind',
|
||||
stringify({from: 'remote', from_endpoint: 'ep2', to: 'wall_switch_double', to_endpoint: 'right'}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({
|
||||
data: {from: 'remote', to: 'bulb_color', clusters: [], failed: ['genScenes', 'genOnOff', 'genLevelCtrl']},
|
||||
status: 'error',
|
||||
error: 'Failed to bind',
|
||||
data: {from: 'remote', from_endpoint: 'ep2', to: 'wall_switch_double', to_endpoint: 'right', clusters: ['genOnOff'], failed: []},
|
||||
status: 'ok',
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should bind from non default endpoints', async () => {
|
||||
it('Should bind from non default endpoint IDs', async () => {
|
||||
const device = devices.remote;
|
||||
const target = devices.QBKG03LM.getEndpoint(3)!;
|
||||
const endpoint = device.getEndpoint(2)!;
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote/ep2', to: 'wall_switch_double/right'}));
|
||||
mockMQTTEvents.message(
|
||||
'zigbee2mqtt/bridge/request/device/bind',
|
||||
stringify({from: 'remote', from_endpoint: 2, to: 'wall_switch_double', to_endpoint: 3}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({data: {from: 'remote/ep2', to: 'wall_switch_double/right', clusters: ['genOnOff'], failed: []}, status: 'ok'}),
|
||||
stringify({
|
||||
data: {from: 'remote', from_endpoint: 2, to: 'wall_switch_double', to_endpoint: 3, clusters: ['genOnOff'], failed: []},
|
||||
status: 'ok',
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -532,7 +602,16 @@ describe('Extension: Bind', () => {
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('msTemperatureMeasurement', target);
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({data: {from: 'temperature_sensor', to: 'heating_actuator', clusters: ['msTemperatureMeasurement'], failed: []}, status: 'ok'}),
|
||||
stringify({
|
||||
data: {
|
||||
from: 'temperature_sensor',
|
||||
from_endpoint: 'default',
|
||||
to: 'heating_actuator',
|
||||
clusters: ['msTemperatureMeasurement'],
|
||||
failed: [],
|
||||
},
|
||||
status: 'ok',
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -543,13 +622,13 @@ describe('Extension: Bind', () => {
|
||||
const target = devices.QBKG04LM.getEndpoint(2)!;
|
||||
const endpoint = device.getEndpoint(2)!;
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote/ep2', to: 'wall_switch'}));
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', from_endpoint: 'ep2', to: 'wall_switch'}));
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({data: {from: 'remote/ep2', to: 'wall_switch', clusters: ['genOnOff'], failed: []}, status: 'ok'}),
|
||||
stringify({data: {from: 'remote', from_endpoint: 'ep2', to: 'wall_switch', clusters: ['genOnOff'], failed: []}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -569,7 +648,13 @@ describe('Extension: Bind', () => {
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/unbind',
|
||||
stringify({
|
||||
data: {from: 'remote', to: 'default_bind_group', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []},
|
||||
data: {
|
||||
from: 'remote',
|
||||
from_endpoint: 'default',
|
||||
to: 'default_bind_group',
|
||||
clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'],
|
||||
failed: [],
|
||||
},
|
||||
status: 'ok',
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
@@ -584,11 +669,7 @@ describe('Extension: Bind', () => {
|
||||
await flushPromises();
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({
|
||||
data: {from: 'remote_not_existing', to: 'bulb_color'},
|
||||
status: 'error',
|
||||
error: "Source device 'remote_not_existing' does not exist",
|
||||
}),
|
||||
stringify({data: {}, status: 'error', error: "Source device 'remote_not_existing' does not exist"}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -597,15 +678,14 @@ describe('Extension: Bind', () => {
|
||||
it("Error bind fails when source device's endpoint does not exist", async () => {
|
||||
const device = devices.remote;
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote/not_existing_endpoint', to: 'bulb_color'}));
|
||||
mockMQTTEvents.message(
|
||||
'zigbee2mqtt/bridge/request/device/bind',
|
||||
stringify({from: 'remote', from_endpoint: 'not_existing_endpoint', to: 'bulb_color'}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({
|
||||
data: {from: 'remote/not_existing_endpoint', to: 'bulb_color'},
|
||||
status: 'error',
|
||||
error: "Source device 'remote' does not have endpoint 'not_existing_endpoint'",
|
||||
}),
|
||||
stringify({data: {}, status: 'error', error: "Source device 'remote' does not have endpoint 'not_existing_endpoint'"}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -618,11 +698,7 @@ describe('Extension: Bind', () => {
|
||||
await flushPromises();
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({
|
||||
data: {from: 'remote', to: 'bulb_color_not_existing'},
|
||||
status: 'error',
|
||||
error: "Target device or group 'bulb_color_not_existing' does not exist",
|
||||
}),
|
||||
stringify({data: {}, status: 'error', error: "Target device or group 'bulb_color_not_existing' does not exist"}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -631,15 +707,14 @@ describe('Extension: Bind', () => {
|
||||
it("Error bind fails when target device's endpoint does not exist", async () => {
|
||||
const device = devices.remote;
|
||||
mockClear(device);
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'bulb_color/not_existing_endpoint'}));
|
||||
mockMQTTEvents.message(
|
||||
'zigbee2mqtt/bridge/request/device/bind',
|
||||
stringify({from: 'remote', to: 'bulb_color', to_endpoint: 'not_existing_endpoint'}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/bind',
|
||||
stringify({
|
||||
data: {from: 'remote', to: 'bulb_color/not_existing_endpoint'},
|
||||
status: 'error',
|
||||
error: "Target device 'bulb_color' does not have endpoint 'not_existing_endpoint'",
|
||||
}),
|
||||
stringify({data: {}, status: 'error', error: "Target device 'bulb_color' does not have endpoint 'not_existing_endpoint'"}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
@@ -3551,15 +3551,17 @@ describe('Extension: Bridge', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('Should allow to configure reporting', async () => {
|
||||
it('Should allow to configure reporting with endpoint as number', async () => {
|
||||
const device = devices.bulb;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
endpoint.bind.mockClear();
|
||||
endpoint.configureReporting.mockClear();
|
||||
mockMQTT.publish.mockClear();
|
||||
mockMQTTEvents.message(
|
||||
'zigbee2mqtt/bridge/request/device/configure_reporting',
|
||||
stringify({
|
||||
id: '0x000b57fffec6a5b2/1',
|
||||
id: '0x000b57fffec6a5b2',
|
||||
endpoint: 1,
|
||||
cluster: 'genLevelCtrl',
|
||||
attribute: 'currentLevel',
|
||||
maximum_report_interval: 10,
|
||||
@@ -3580,7 +3582,55 @@ describe('Extension: Bridge', () => {
|
||||
'zigbee2mqtt/bridge/response/device/configure_reporting',
|
||||
stringify({
|
||||
data: {
|
||||
id: '0x000b57fffec6a5b2/1',
|
||||
id: '0x000b57fffec6a5b2',
|
||||
endpoint: 1,
|
||||
cluster: 'genLevelCtrl',
|
||||
attribute: 'currentLevel',
|
||||
maximum_report_interval: 10,
|
||||
minimum_report_interval: 1,
|
||||
reportable_change: 1,
|
||||
},
|
||||
status: 'ok',
|
||||
}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), {retain: true, qos: 0}, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Should allow to configure reporting with endpoint as string', async () => {
|
||||
const device = devices.bulb;
|
||||
const endpoint = device.getEndpoint(1)!;
|
||||
endpoint.bind.mockClear();
|
||||
endpoint.configureReporting.mockClear();
|
||||
mockMQTT.publish.mockClear();
|
||||
mockMQTTEvents.message(
|
||||
'zigbee2mqtt/bridge/request/device/configure_reporting',
|
||||
stringify({
|
||||
id: '0x000b57fffec6a5b2',
|
||||
endpoint: '1',
|
||||
cluster: 'genLevelCtrl',
|
||||
attribute: 'currentLevel',
|
||||
maximum_report_interval: 10,
|
||||
minimum_report_interval: 1,
|
||||
reportable_change: 1,
|
||||
}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(endpoint.bind).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', devices.coordinator.endpoints[0]);
|
||||
expect(endpoint.configureReporting).toHaveBeenCalledTimes(1);
|
||||
expect(endpoint.configureReporting).toHaveBeenCalledWith(
|
||||
'genLevelCtrl',
|
||||
[{attribute: 'currentLevel', maximumReportInterval: 10, minimumReportInterval: 1, reportableChange: 1}],
|
||||
undefined,
|
||||
);
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/device/configure_reporting',
|
||||
stringify({
|
||||
data: {
|
||||
id: '0x000b57fffec6a5b2',
|
||||
endpoint: '1',
|
||||
cluster: 'genLevelCtrl',
|
||||
attribute: 'currentLevel',
|
||||
maximum_report_interval: 10,
|
||||
@@ -3604,8 +3654,9 @@ describe('Extension: Bridge', () => {
|
||||
'zigbee2mqtt/bridge/request/device/configure_reporting',
|
||||
stringify({
|
||||
id: 'bulb',
|
||||
// endpoint: '1',
|
||||
cluster: 'genLevelCtrl',
|
||||
attribute_lala: 'currentLevel',
|
||||
attribute: 'currentLevel',
|
||||
maximum_report_interval: 10,
|
||||
minimum_report_interval: 1,
|
||||
reportable_change: 1,
|
||||
@@ -3630,6 +3681,7 @@ describe('Extension: Bridge', () => {
|
||||
'zigbee2mqtt/bridge/request/device/configure_reporting',
|
||||
stringify({
|
||||
id: 'non_existing_device',
|
||||
endpoint: '1',
|
||||
cluster: 'genLevelCtrl',
|
||||
attribute: 'currentLevel',
|
||||
maximum_report_interval: 10,
|
||||
@@ -3655,7 +3707,8 @@ describe('Extension: Bridge', () => {
|
||||
mockMQTTEvents.message(
|
||||
'zigbee2mqtt/bridge/request/device/configure_reporting',
|
||||
stringify({
|
||||
id: '0x000b57fffec6a5b2/non_existing_endpoint',
|
||||
id: '0x000b57fffec6a5b2',
|
||||
endpoint: 'non_existing_endpoint',
|
||||
cluster: 'genLevelCtrl',
|
||||
attribute: 'currentLevel',
|
||||
maximum_report_interval: 10,
|
||||
|
||||
@@ -527,7 +527,7 @@ describe('Extension: Groups', () => {
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/members/add',
|
||||
stringify({data: {device: 'bulb_color', group: 'group_1'}, transaction: '123', status: 'ok'}),
|
||||
stringify({data: {device: 'bulb_color', endpoint: 'default', group: 'group_1'}, transaction: '123', status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -549,7 +549,7 @@ describe('Extension: Groups', () => {
|
||||
expect(mockMQTT.publish).not.toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/members/add',
|
||||
stringify({data: {device: 'bulb_color', group: 'group_1'}, status: 'error', error: 'Failed to add from group (timeout)'}),
|
||||
stringify({data: {}, status: 'error', error: 'Failed to add from group (timeout)'}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -568,7 +568,7 @@ describe('Extension: Groups', () => {
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/members/add',
|
||||
stringify({data: {device: 'bulb_color', group: 'group/with/slashes'}, status: 'ok'}),
|
||||
stringify({data: {device: 'bulb_color', endpoint: 'default', group: 'group/with/slashes'}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -580,13 +580,16 @@ describe('Extension: Groups', () => {
|
||||
const group = groups.group_1;
|
||||
expect(group.members.length).toBe(0);
|
||||
mockMQTT.publish.mockClear();
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/group/members/add', stringify({group: 'group_1', device: 'wall_switch_double/right'}));
|
||||
mockMQTTEvents.message(
|
||||
'zigbee2mqtt/bridge/request/group/members/add',
|
||||
stringify({group: 'group_1', device: 'wall_switch_double', endpoint: 'right'}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(group.members).toStrictEqual([endpoint]);
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/members/add',
|
||||
stringify({data: {device: 'wall_switch_double/right', group: 'group_1'}, status: 'ok'}),
|
||||
stringify({data: {device: 'wall_switch_double', endpoint: 'right', group: 'group_1'}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -598,15 +601,21 @@ describe('Extension: Groups', () => {
|
||||
const group = groups.group_1;
|
||||
expect(group.members.length).toBe(0);
|
||||
mockMQTT.publish.mockClear();
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/group/members/add', stringify({group: 'group_1', device: 'wall_switch_double/right'}));
|
||||
mockMQTTEvents.message(
|
||||
'zigbee2mqtt/bridge/request/group/members/add',
|
||||
stringify({group: 'group_1', device: 'wall_switch_double', endpoint: 'right'}),
|
||||
);
|
||||
await flushPromises();
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/group/members/add', stringify({group: 'group_1', device: '0x0017880104e45542/3'}));
|
||||
mockMQTTEvents.message(
|
||||
'zigbee2mqtt/bridge/request/group/members/add',
|
||||
stringify({group: 'group_1', device: '0x0017880104e45542', endpoint: '3'}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(group.members).toStrictEqual([endpoint]);
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/members/add',
|
||||
stringify({data: {device: 'wall_switch_double/right', group: 'group_1'}, status: 'ok'}),
|
||||
stringify({data: {device: 'wall_switch_double', endpoint: 'right', group: 'group_1'}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -624,7 +633,7 @@ describe('Extension: Groups', () => {
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/members/remove',
|
||||
stringify({data: {device: 'bulb_color', group: 'group_1'}, status: 'ok'}),
|
||||
stringify({data: {device: 'bulb_color', endpoint: 'default', group: 'group_1'}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -645,7 +654,7 @@ describe('Extension: Groups', () => {
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/members/remove',
|
||||
stringify({data: {device: 'bulb_color', group: 'group_1'}, status: 'ok'}),
|
||||
stringify({data: {device: 'bulb_color', endpoint: 'default', group: 'group_1'}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -657,13 +666,16 @@ describe('Extension: Groups', () => {
|
||||
const group = groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
mockMQTT.publish.mockClear();
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/group/members/remove', stringify({group: 'group_1', device: '0x0017880104e45542/3'}));
|
||||
mockMQTTEvents.message(
|
||||
'zigbee2mqtt/bridge/request/group/members/remove',
|
||||
stringify({group: 'group_1', device: '0x0017880104e45542', endpoint: '3'}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(group.members).toStrictEqual([]);
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/members/remove',
|
||||
stringify({data: {device: '0x0017880104e45542/3', group: 'group_1'}, status: 'ok'}),
|
||||
stringify({data: {device: '0x0017880104e45542', endpoint: '3', group: 'group_1'}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -675,13 +687,16 @@ describe('Extension: Groups', () => {
|
||||
const group = groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
mockMQTT.publish.mockClear();
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/group/members/remove', stringify({group: 'group_1', device: 'wall_switch_double/3'}));
|
||||
mockMQTTEvents.message(
|
||||
'zigbee2mqtt/bridge/request/group/members/remove',
|
||||
stringify({group: 'group_1', device: 'wall_switch_double', endpoint: '3'}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(group.members).toStrictEqual([]);
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/members/remove',
|
||||
stringify({data: {device: 'wall_switch_double/3', group: 'group_1'}, status: 'ok'}),
|
||||
stringify({data: {device: 'wall_switch_double', endpoint: '3', group: 'group_1'}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -693,13 +708,16 @@ describe('Extension: Groups', () => {
|
||||
const group = groups.group_1;
|
||||
group.members.push(endpoint);
|
||||
mockMQTT.publish.mockClear();
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/group/members/remove', stringify({group: 'group_1', device: '0x0017880104e45542/right'}));
|
||||
mockMQTTEvents.message(
|
||||
'zigbee2mqtt/bridge/request/group/members/remove',
|
||||
stringify({group: 'group_1', device: '0x0017880104e45542', endpoint: 'right'}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(group.members).toStrictEqual([]);
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/members/remove',
|
||||
stringify({data: {device: '0x0017880104e45542/right', group: 'group_1'}, status: 'ok'}),
|
||||
stringify({data: {device: '0x0017880104e45542', endpoint: 'right', group: 'group_1'}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -709,13 +727,13 @@ describe('Extension: Groups', () => {
|
||||
const group = groups.group_1;
|
||||
groups.group_1.members.push(devices.QBKG03LM.endpoints[2]);
|
||||
mockMQTT.publish.mockClear();
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/group/members/remove_all', stringify({device: '0x0017880104e45542/right'}));
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/group/members/remove_all', stringify({device: '0x0017880104e45542', endpoint: 'right'}));
|
||||
await flushPromises();
|
||||
expect(group.members).toStrictEqual([]);
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/members/remove_all',
|
||||
stringify({data: {device: '0x0017880104e45542/right'}, status: 'ok'}),
|
||||
stringify({data: {device: '0x0017880104e45542', endpoint: 'right'}, status: 'ok'}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -729,11 +747,7 @@ describe('Extension: Groups', () => {
|
||||
expect(mockMQTT.publish).not.toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/members/remove',
|
||||
stringify({
|
||||
data: {device: 'bulb_color', group: 'group_1_not_existing'},
|
||||
status: 'error',
|
||||
error: "Group 'group_1_not_existing' does not exist",
|
||||
}),
|
||||
stringify({data: {}, status: 'error', error: "Group 'group_1_not_existing' does not exist"}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -747,11 +761,7 @@ describe('Extension: Groups', () => {
|
||||
expect(mockMQTT.publish).not.toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/members/add',
|
||||
stringify({
|
||||
data: {device: 'bulb_color_not_existing', group: 'group_1'},
|
||||
status: 'error',
|
||||
error: "Device 'bulb_color_not_existing' does not exist",
|
||||
}),
|
||||
stringify({data: {}, status: 'error', error: "Device 'bulb_color_not_existing' does not exist"}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -762,17 +772,41 @@ describe('Extension: Groups', () => {
|
||||
mockMQTT.publish.mockClear();
|
||||
mockMQTTEvents.message(
|
||||
'zigbee2mqtt/bridge/request/group/members/add',
|
||||
stringify({group: 'group_1', device: 'bulb_color/not_existing_endpoint'}),
|
||||
stringify({group: 'group_1', device: 'bulb_color', endpoint: 'not_existing_endpoint'}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(mockMQTT.publish).not.toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/members/add',
|
||||
stringify({
|
||||
data: {device: 'bulb_color/not_existing_endpoint', group: 'group_1'},
|
||||
status: 'error',
|
||||
error: "Device 'bulb_color' does not have endpoint 'not_existing_endpoint'",
|
||||
}),
|
||||
stringify({data: {}, status: 'error', error: "Device 'bulb_color' does not have endpoint 'not_existing_endpoint'"}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Error when invalid payload', async () => {
|
||||
mockLogger.error.mockClear();
|
||||
mockMQTT.publish.mockClear();
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/group/members/add', stringify({group: 'group_1', devicez: 'bulb_color'}));
|
||||
await flushPromises();
|
||||
expect(mockMQTT.publish).not.toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/members/add',
|
||||
stringify({data: {}, status: 'error', error: 'Invalid payload'}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Error when add/remove with invalid payload', async () => {
|
||||
mockLogger.error.mockClear();
|
||||
mockMQTT.publish.mockClear();
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/group/members/add', stringify({groupz: 'group_1', device: 'bulb_color'}));
|
||||
await flushPromises();
|
||||
expect(mockMQTT.publish).not.toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
|
||||
expect(mockMQTT.publish).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/group/members/add',
|
||||
stringify({data: {}, status: 'error', error: 'Invalid payload'}),
|
||||
{retain: false, qos: 0},
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
@@ -2268,7 +2268,7 @@ describe('Extension: HomeAssistant', () => {
|
||||
mockMQTT.publish.mockClear();
|
||||
mockMQTTEvents.message(
|
||||
'zigbee2mqtt/bridge/request/group/members/add',
|
||||
stringify({group: 'ha_discovery_group', device: 'wall_switch_double/left'}),
|
||||
stringify({group: 'ha_discovery_group', device: 'wall_switch_double', endpoint: 'left'}),
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user