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:
Nerivec
2024-12-01 20:09:28 +01:00
committed by Koen Kanters
parent 3f76586a50
commit 9537ef05d5
7 changed files with 535 additions and 274 deletions
+174 -95
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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),
);
+58 -5
View File
@@ -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,
+68 -34
View File
@@ -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),
);
+1 -1
View File
@@ -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();