diff --git a/lib/extension/bind.ts b/lib/extension/bind.ts index c917f5270c..6e324f13d8 100755 --- a/lib/extension/bind.ts +++ b/lib/extension/bind.ts @@ -195,15 +195,23 @@ const POLL_ON_MESSAGE: Readonly = [ 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; + + 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; - } else { - return undefined; - } + const resolvedSource = this.zigbee.resolveEntity(message.from) as Device; - return {type, sourceKey, targetKey, clusters, skipDisableReporting}; - } + if (!resolvedSource || !(resolvedSource instanceof Device)) { + return [message, {type, skipDisableReporting}, `Source device '${message.from}' does not exist`]; + } - @bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise { - const parsed = this.parseMQTTMessage(data); + const resolvedTarget = message.to === DEFAULT_BIND_GROUP.name ? DEFAULT_BIND_GROUP : this.zigbee.resolveEntity(message.to); - if (!parsed || !parsed.type) { - return; - } + if (!resolvedTarget) { + return [message, {type, skipDisableReporting}, `Target device or group '${message.to}' does not exist`]; + } - const {type, sourceKey, targetKey, clusters, skipDisableReporting} = parsed; - const message = utils.parseJSON(data.message, data.message); - - 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}; - - 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 = []; + const resolvedSourceEndpoint = resolvedSource.endpoint(sourceEndpointKey); - const bindSource = parsedSource.endpoint; - const bindTarget = target instanceof Device ? parsedTarget.endpoint : target instanceof Group ? target.zh : Number(target.ID); + if (!resolvedSourceEndpoint) { + return [ + message, + {type, skipDisableReporting}, + `Source device '${resolvedSource.name}' does not have endpoint '${sourceEndpointKey}'`, + ]; + } - assert(bindSource != undefined && bindTarget != undefined); + // 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}'`, + ]; + } - // Find which clusters are supported by both the source and target. - // Groups are assumed to support all clusters. - const clusterCandidates = clusters ?? ALL_CLUSTER_CANDIDATES; + return [ + message, + { + type, + sourceKey, + sourceEndpointKey, + targetKey, + targetEndpointKey, + clusters, + skipDisableReporting, + resolvedSource, + resolvedTarget, + resolvedSourceEndpoint, + resolvedBindTarget, + }, + undefined, + ]; + } else { + return [undefined, undefined, undefined]; + } + } - for (const cluster of clusterCandidates) { - let matchingClusters = false; + @bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise { + const [raw, parsed, error] = this.parseMQTTMessage(data); - const anyClusterValid = utils.isZHGroup(bindTarget) || typeof bindTarget === 'number' || (target as Device).zh.type === 'Coordinator'; + if (!raw || !parsed) { + return; + } - if (!anyClusterValid && utils.isZHEndpoint(bindTarget)) { - matchingClusters = - (bindTarget.supportsInputCluster(cluster) && bindSource.supportsOutputCluster(cluster)) || - (bindSource.supportsInputCluster(cluster) && bindTarget.supportsOutputCluster(cluster)); - } + if (error) { + await this.publishResponse(parsed.type, raw, {}, error); + return; + } - const sourceValid = bindSource.supportsInputCluster(cluster) || bindSource.supportsOutputCluster(cluster); + const { + type, + sourceKey, + sourceEndpointKey, + targetKey, + targetEndpointKey, + clusters, + skipDisableReporting, + resolvedSource, + resolvedTarget, + resolvedSourceEndpoint, + resolvedBindTarget, + } = parsed; + + assert(resolvedSource, '`resolvedSource` is missing'); + assert(resolvedTarget, '`resolvedTarget` is missing'); + assert(resolvedSourceEndpoint, '`resolvedSourceEndpoint` is missing'); + assert(resolvedBindTarget != undefined, '`resolvedBindTarget` is missing'); + + 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; + + for (const cluster of clusterCandidates) { + let matchingClusters = false; + + const anyClusterValid = + utils.isZHGroup(resolvedBindTarget) || + typeof resolvedBindTarget === 'number' || + (resolvedTarget instanceof Device && resolvedTarget.zh.type === 'Coordinator'); + + if (!anyClusterValid && utils.isZHEndpoint(resolvedBindTarget)) { + matchingClusters = + (resolvedBindTarget.supportsInputCluster(cluster) && resolvedSourceEndpoint.supportsOutputCluster(cluster)) || + (resolvedSourceEndpoint.supportsInputCluster(cluster) && resolvedBindTarget.supportsOutputCluster(cluster)); + } - if (sourceValid && (anyClusterValid || matchingClusters)) { - logger.debug(`${type}ing cluster '${cluster}' from '${source.name}' to '${target.name}'`); - attemptedClusters.push(cluster); + const sourceValid = resolvedSourceEndpoint.supportsInputCluster(cluster) || resolvedSourceEndpoint.supportsOutputCluster(cluster); - try { - if (type === 'bind') { - await bindSource.bind(cluster, bindTarget); - } else { - await bindSource.unbind(cluster, bindTarget); - } + if (sourceValid && (anyClusterValid || matchingClusters)) { + logger.debug(`${type}ing cluster '${cluster}' from '${resolvedSource.name}' to '${resolvedTarget.name}'`); + attemptedClusters.push(cluster); - 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}`; + 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})`); + } } + } - responseData[`clusters`] = successfulClusters; - responseData[`failed`] = failedClusters; + 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; + } - 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); - } + 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); } } - const response = utils.getResponse(message, responseData, error); + await this.publishResponse(parsed.type, raw, responseData); + this.eventBus.emitDevicesChanged(); + } - await this.mqtt.publish(`bridge/response/device/${type}`, stringify(response)); + private async publishResponse(type: ParsedMQTTMessage['type'], request: KeyValue, data: KeyValue, error?: string): Promise { + 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(); } } diff --git a/lib/extension/bridge.ts b/lib/extension/bridge.ts index 69ff55963f..b652c5d4d3 100644 --- a/lib/extension/bridge.ts +++ b/lib/extension/bridge.ts @@ -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) { diff --git a/lib/extension/groups.ts b/lib/extension/groups.ts index eb5b0e6ed7..939160cdab 100644 --- a/lib/extension/groups.ts +++ b/lib/extension/groups.ts @@ -30,13 +30,20 @@ const STATE_PROPERTIES: Readonly { - 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 { - const parsed = await this.parseMQTTMessage(data); + const [raw, parsed, error] = this.parseMQTTMessage(data); + + if (!raw || !parsed) { + return; + } - if (!parsed || !parsed.type) { + if (error) { + await this.publishResponse(parsed.type, raw, {}, error); return; } - const {resolvedEntityGroup, resolvedEntityDevice, type, groupKey, deviceKey, skipDisableReporting, resolvedEntityEndpoint} = parsed; - let error = parsed.error; + const {resolvedGroup, resolvedDevice, resolvedEndpoint, type, groupKey, deviceKey, endpointKey, skipDisableReporting} = parsed; 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); - } + 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`); - await resolvedEntityEndpoint.removeFromAllGroups(); + for (const group of this.zigbee.groupsIterator((g) => g.members.includes(resolvedEndpoint))) { + changedGroups.push(group); } - } catch (e) { - error = `Failed to ${type} from group (${(e as Error).message})`; - logger.debug((e as Error).stack!); + + 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 message = utils.parseJSON(data.message, data.message); - const responseData: KeyValue = {device: deviceKey}; + 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 { + 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}); - } } } } diff --git a/test/extensions/bind.test.ts b/test/extensions/bind.test.ts index b29bb33096..e8ec68c6b1 100644 --- a/test/extensions/bind.test.ts +++ b/test/extensions/bind.test.ts @@ -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), ); diff --git a/test/extensions/bridge.test.ts b/test/extensions/bridge.test.ts index b33ded8a7d..86aa39371c 100644 --- a/test/extensions/bridge.test.ts +++ b/test/extensions/bridge.test.ts @@ -3555,15 +3555,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, @@ -3584,7 +3586,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, @@ -3608,8 +3658,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, @@ -3634,6 +3685,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, @@ -3659,7 +3711,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, diff --git a/test/extensions/groups.test.ts b/test/extensions/groups.test.ts index 9c1f638983..2dffc71b6f 100644 --- a/test/extensions/groups.test.ts +++ b/test/extensions/groups.test.ts @@ -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: {}, 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: {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: 'Invalid payload'}), {retain: false, qos: 0}, expect.any(Function), ); diff --git a/test/extensions/homeassistant.test.ts b/test/extensions/homeassistant.test.ts index ecebcf3ab1..369421f23e 100644 --- a/test/extensions/homeassistant.test.ts +++ b/test/extensions/homeassistant.test.ts @@ -2174,7 +2174,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();