From ff8f16fedb22ed402eb2b6ba5d5b1bddb487b98a Mon Sep 17 00:00:00 2001 From: Koen Kanters Date: Sun, 12 Jul 2020 23:50:15 +0200 Subject: [PATCH] Group MQTT api. https://github.com/Koenkk/zigbee2mqtt/issues/3281 --- lib/extension/groups.js | 202 ++++++++++++++++++---------- test/bind.test.js | 28 ++++ test/group.test.js | 260 ++++++++++++++++++++++++++++++++++-- test/stub/zigbeeHerdsman.js | 5 +- 4 files changed, 412 insertions(+), 83 deletions(-) diff --git a/lib/extension/groups.js b/lib/extension/groups.js index b5621e7a..59d260f4 100644 --- a/lib/extension/groups.js +++ b/lib/extension/groups.js @@ -4,6 +4,8 @@ const Extension = require('./extension'); const utils = require('../util/utils'); const postfixes = utils.getEndpointNames(); +const topicRegex = + new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/group/members/(remove|add|remove_all)$`); const legacyTopicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/(.+)/(remove|add|remove_all)$`); const legacyTopicRegexRemoveAll = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/remove_all$`); @@ -26,6 +28,13 @@ class Groups extends Extension { this.mqtt.subscribe(`${topic}remove_all`); } } + + /* istanbul ignore else */ + if (settings.get().experimental.new_api) { + this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/request/group/members/add`); + this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/request/group/members/remove`); + this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/request/group/members/remove_all`); + } } async onZigbeeStarted() { @@ -143,20 +152,30 @@ class Groups extends Extension { let resolvedEntityGroup = null; let resolvedEntityDevice = null; let hasEndpointName = null; + let error = null; + let groupKey = null; + let deviceKey = null; + let triggeredViaLegacyApi = false; /* istanbul ignore else */ - if (this.legacyApi) { - const topicMatch = topic.match(legacyTopicRegex); - if (topicMatch) { - resolvedEntityGroup = this.zigbee.resolveEntity(topicMatch[1]); - type = topicMatch[2]; + const topicRegexMatch = topic.match(topicRegex); + const legacyTopicRegexRemoveAllMatch = topic.match(legacyTopicRegexRemoveAll); + const legacyTopicRegexMatch = topic.match(legacyTopicRegex); + + if (this.legacyApi && (legacyTopicRegexMatch || legacyTopicRegexRemoveAllMatch)) { + triggeredViaLegacyApi = true; + if (legacyTopicRegexMatch) { + resolvedEntityGroup = this.zigbee.resolveEntity(legacyTopicRegexMatch[1]); + type = legacyTopicRegexMatch[2]; if (!resolvedEntityGroup || resolvedEntityGroup.type !== 'group') { - logger.error(`Group '${topicMatch[1]}' does not exist`); + logger.error(`Group '${legacyTopicRegexMatch[1]}' does not exist`); /* istanbul ignore else */ if (settings.get().advanced.legacy_api) { - const payload = {friendly_name: message, group: topicMatch[1], error: 'group doesn\'t exists'}; + const payload = { + friendly_name: message, group: legacyTopicRegexMatch[1], error: 'group doesn\'t exists', + }; this.mqtt.publish( 'bridge/log', JSON.stringify({type: `device_group_${type}_failed`, message: payload}), @@ -165,10 +184,8 @@ class Groups extends Extension { return {}; } - } else if (topic.match(legacyTopicRegexRemoveAll)) { - type = 'remove_all'; } else { - return {}; + type = 'remove_all'; } resolvedEntityDevice = this.zigbee.resolveEntity(message); @@ -177,7 +194,9 @@ class Groups extends Extension { /* istanbul ignore else */ if (settings.get().advanced.legacy_api) { - const payload = {friendly_name: message, group: topicMatch[1], error: 'entity doesn\'t exists'}; + const payload = { + friendly_name: message, group: legacyTopicRegexMatch[1], error: 'entity doesn\'t exists', + }; this.mqtt.publish( 'bridge/log', JSON.stringify({type: `device_group_${type}_failed`, message: payload}), @@ -188,78 +207,123 @@ class Groups extends Extension { } hasEndpointName = postfixes.find((p) => message.endsWith(`/${p}`)); + } else if (topicRegexMatch) { + type = topicRegexMatch[1]; + message = JSON.parse(message); + deviceKey = message.device; + + if (type !== 'remove_all') { + groupKey = message.group; + resolvedEntityGroup = this.zigbee.resolveEntity(message.group); + if (!resolvedEntityGroup || resolvedEntityGroup.type !== 'group') { + error = `Group '${message.group}' does not exist`; + } + } + + resolvedEntityDevice = this.zigbee.resolveEntity(message.device); + if (!error && (!resolvedEntityDevice || !resolvedEntityDevice.type === 'device')) { + error = `Device '${message.device}' does not exist`; + } + + hasEndpointName = postfixes.find((p) => message.device.endsWith(`/${p}`)); } - return {resolvedEntityGroup, resolvedEntityDevice, type, hasEndpointName}; + return { + resolvedEntityGroup, resolvedEntityDevice, type, hasEndpointName, error, groupKey, deviceKey, + triggeredViaLegacyApi, + }; } async onMQTTMessage(topic, message) { - const {resolvedEntityGroup, resolvedEntityDevice, type, hasEndpointName} = - this.parseMQTTMessage(topic, message); + let { + resolvedEntityGroup, resolvedEntityDevice, type, hasEndpointName, error, triggeredViaLegacyApi, + groupKey, deviceKey, + } = this.parseMQTTMessage(topic, message); if (!type) return; - const keys = [ - `${resolvedEntityDevice.device.ieeeAddr}/${resolvedEntityDevice.endpoint.ID}`, - `${resolvedEntityDevice.name}/${resolvedEntityDevice.endpoint.ID}`, - ]; - - const definition = resolvedEntityDevice.definition; - const endpoints = definition && definition.endpoint ? definition.endpoint(resolvedEntityDevice.device) : null; - const endpointName = endpoints ? - Object.entries(endpoints).find((e) => e[1] === resolvedEntityDevice.endpoint.ID)[0] : null; - - if (endpointName) { - keys.push(`${resolvedEntityDevice.device.ieeeAddr}/${endpointName}`); - keys.push(`${resolvedEntityDevice.name}/${endpointName}`); + const responseData = {device: deviceKey}; + if (groupKey) { + responseData.group = groupKey; } - if (!hasEndpointName) { - keys.push(resolvedEntityDevice.name); - keys.push(resolvedEntityDevice.device.ieeeAddr); - } + if (!error) { + try { + const keys = [ + `${resolvedEntityDevice.device.ieeeAddr}/${resolvedEntityDevice.endpoint.ID}`, + `${resolvedEntityDevice.name}/${resolvedEntityDevice.endpoint.ID}`, + ]; - if (type === 'add') { - logger.info(`Adding '${resolvedEntityDevice.name}' to '${resolvedEntityGroup.name}'`); - await resolvedEntityDevice.endpoint.addToGroup(resolvedEntityGroup.group); - settings.addDeviceToGroup(resolvedEntityGroup.settings.ID, keys); + const definition = resolvedEntityDevice.definition; + const endpoints = definition && definition.endpoint ? + definition.endpoint(resolvedEntityDevice.device) : null; + const endpointName = endpoints ? + Object.entries(endpoints).find((e) => e[1] === resolvedEntityDevice.endpoint.ID)[0] : null; - /* istanbul ignore else */ - if (settings.get().advanced.legacy_api) { - const payload = {friendly_name: resolvedEntityDevice.name, group: resolvedEntityGroup.name}; - this.mqtt.publish( - 'bridge/log', - JSON.stringify({type: `device_group_add`, message: payload}), - ); - } - } else if (type === 'remove') { - logger.info(`Removing '${resolvedEntityDevice.name}' from '${resolvedEntityGroup.name}'`); - await resolvedEntityDevice.endpoint.removeFromGroup(resolvedEntityGroup.group); - settings.removeDeviceFromGroup(resolvedEntityGroup.settings.ID, keys); - - /* istanbul ignore else */ - if (settings.get().advanced.legacy_api) { - const payload = {friendly_name: resolvedEntityDevice.name, group: resolvedEntityGroup.name}; - this.mqtt.publish( - 'bridge/log', - JSON.stringify({type: `device_group_remove`, message: payload}), - ); - } - } else { // remove_all - logger.info(`Removing '${resolvedEntityDevice.name}' from all groups`); - await resolvedEntityDevice.endpoint.removeFromAllGroups(); - for (const settingsGroup of settings.getGroups()) { - settings.removeDeviceFromGroup(settingsGroup.ID, keys); - - /* istanbul ignore else */ - if (settings.get().advanced.legacy_api) { - const payload = {friendly_name: resolvedEntityDevice.name}; - this.mqtt.publish( - 'bridge/log', - JSON.stringify({type: `device_group_remove_all`, message: payload}), - ); + if (endpointName) { + keys.push(`${resolvedEntityDevice.device.ieeeAddr}/${endpointName}`); + keys.push(`${resolvedEntityDevice.name}/${endpointName}`); } + + if (!hasEndpointName) { + keys.push(resolvedEntityDevice.name); + keys.push(resolvedEntityDevice.device.ieeeAddr); + } + + if (type === 'add') { + logger.info(`Adding '${resolvedEntityDevice.name}' to '${resolvedEntityGroup.name}'`); + await resolvedEntityDevice.endpoint.addToGroup(resolvedEntityGroup.group); + settings.addDeviceToGroup(resolvedEntityGroup.settings.ID, keys); + + /* istanbul ignore else */ + if (settings.get().advanced.legacy_api) { + const payload = {friendly_name: resolvedEntityDevice.name, group: resolvedEntityGroup.name}; + this.mqtt.publish( + 'bridge/log', + JSON.stringify({type: `device_group_add`, message: payload}), + ); + } + } else if (type === 'remove') { + logger.info(`Removing '${resolvedEntityDevice.name}' from '${resolvedEntityGroup.name}'`); + await resolvedEntityDevice.endpoint.removeFromGroup(resolvedEntityGroup.group); + settings.removeDeviceFromGroup(resolvedEntityGroup.settings.ID, keys); + + /* istanbul ignore else */ + if (settings.get().advanced.legacy_api) { + const payload = {friendly_name: resolvedEntityDevice.name, group: resolvedEntityGroup.name}; + this.mqtt.publish( + 'bridge/log', + JSON.stringify({type: `device_group_remove`, message: payload}), + ); + } + } else { // remove_all + logger.info(`Removing '${resolvedEntityDevice.name}' from all groups`); + await resolvedEntityDevice.endpoint.removeFromAllGroups(); + for (const settingsGroup of settings.getGroups()) { + settings.removeDeviceFromGroup(settingsGroup.ID, keys); + + /* istanbul ignore else */ + if (settings.get().advanced.legacy_api) { + const payload = {friendly_name: resolvedEntityDevice.name}; + this.mqtt.publish( + 'bridge/log', + JSON.stringify({type: `device_group_remove_all`, message: payload}), + ); + } + } + } + } catch (e) { + error = `Failed to ${type} from group (${e.message})`; } } + + if (!triggeredViaLegacyApi) { + const response = utils.getResponse(message, responseData, error); + await this.mqtt.publish(`bridge/response/group/members/${type}`, JSON.stringify(response)); + } + + if (error) { + logger.error(error); + } } } diff --git a/test/bind.test.js b/test/bind.test.js index 8eaec812..74637152 100644 --- a/test/bind.test.js +++ b/test/bind.test.js @@ -210,6 +210,34 @@ describe('Bind', () => { ); }); + it('Error bind fails when source not existing', async () => { + const device = zigbeeHerdsman.devices.remote; + const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1); + const endpoint = device.getEndpoint(1); + mockClear(device); + MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', JSON.stringify({from: 'remote_not_existing', to: 'bulb_color'})); + await flushPromises(); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/device/bind', + JSON.stringify({"data":{"from":"remote_not_existing","to":"bulb_color"},"status":"error","error":"Source device 'remote_not_existing' does not exist"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Error bind fails when target not existing', async () => { + const device = zigbeeHerdsman.devices.remote; + const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1); + const endpoint = device.getEndpoint(1); + mockClear(device); + MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', JSON.stringify({from: 'remote', to: 'bulb_color_not_existing'})); + await flushPromises(); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/device/bind', + JSON.stringify({"data":{"from":"remote","to":"bulb_color_not_existing"},"status":"error","error":"Target device or group 'bulb_color_not_existing' does not exist"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + it('Legacy api: Should bind', async () => { const device = zigbeeHerdsman.devices.remote; const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1); diff --git a/test/group.test.js b/test/group.test.js index 147b28a4..b1e29937 100644 --- a/test/group.test.js +++ b/test/group.test.js @@ -81,7 +81,7 @@ describe('Groups', () => { expect(logger.error).toHaveBeenCalledWith("Cannot find 'not_existing_bla' of group 'group_1'"); }); - it('Add to group via MQTT', async () => { + it('Legacy api: Add to group via MQTT', async () => { const device = zigbeeHerdsman.devices.bulb_color; const endpoint = device.getEndpoint(1); const group = zigbeeHerdsman.groups.group_1; @@ -96,7 +96,7 @@ describe('Groups', () => { expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bridge/log", '{"type":"device_group_add","message":{"friendly_name":"bulb_color","group":"group_1"}}', {"retain": false, qos: 0}, expect.any(Function)); }); - it('Add to group with slashes via MQTT', async () => { + it('Legacy api: Add to group with slashes via MQTT', async () => { const device = zigbeeHerdsman.devices.bulb_color; const endpoint = device.getEndpoint(1); const group = zigbeeHerdsman.groups["group/with/slashes"]; @@ -117,7 +117,7 @@ describe('Groups', () => { expect(MQTT.subscribe).toHaveBeenCalledWith('zigbee2mqtt/bridge/group/+/+/+/+/+/add'); }); - it('Add to group via MQTT with postfix', async () => { + it('Legacy api: Add to group via MQTT with postfix', async () => { const device = zigbeeHerdsman.devices.QBKG03LM; const endpoint = device.getEndpoint(3); const group = zigbeeHerdsman.groups.group_1; @@ -130,7 +130,7 @@ describe('Groups', () => { expect(settings.getGroup('group_1').devices).toStrictEqual([`${device.ieeeAddr}/${endpoint.ID}`]); }); - it('Add to group via MQTT with postfix shouldnt add it twice', async () => { + it('Legacy api: Add to group via MQTT with postfix shouldnt add it twice', async () => { const device = zigbeeHerdsman.devices.QBKG03LM; const endpoint = device.getEndpoint(3); const group = zigbeeHerdsman.groups.group_1; @@ -145,7 +145,7 @@ describe('Groups', () => { expect(settings.getGroup('group_1').devices).toStrictEqual([`${device.ieeeAddr}/${endpoint.ID}`]); }); - it('Remove from group via MQTT', async () => { + it('Legacy api: Remove from group via MQTT', async () => { const device = zigbeeHerdsman.devices.bulb_color; const endpoint = device.getEndpoint(1); const group = zigbeeHerdsman.groups.group_1; @@ -160,7 +160,7 @@ describe('Groups', () => { expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bridge/log", '{"type":"device_group_remove","message":{"friendly_name":"bulb_color","group":"group_1"}}', {"retain": false, qos: 0}, expect.any(Function)); }); - it('Remove from group via MQTT when in zigbee but not in settings', async () => { + it('Legacy api: Remove from group via MQTT when in zigbee but not in settings', async () => { const device = zigbeeHerdsman.devices.bulb_color; const endpoint = device.getEndpoint(1); const group = zigbeeHerdsman.groups.group_1; @@ -174,7 +174,7 @@ describe('Groups', () => { expect(settings.getGroup('group_1').devices).toStrictEqual(['dummy']); }); - it('Remove from group via MQTT with postfix variant 1', async () => { + it('Legacy api: Remove from group via MQTT with postfix variant 1', async () => { const device = zigbeeHerdsman.devices.bulb_color; const endpoint = device.getEndpoint(1); const group = zigbeeHerdsman.groups.group_1; @@ -188,7 +188,7 @@ describe('Groups', () => { expect(settings.getGroup('group_1').devices).toStrictEqual([]); }); - it('Remove from group via MQTT with postfix variant 2', async () => { + it('Legacy api: Remove from group via MQTT with postfix variant 2', async () => { const device = zigbeeHerdsman.devices.bulb_color; const endpoint = device.getEndpoint(1); const group = zigbeeHerdsman.groups.group_1; @@ -202,7 +202,7 @@ describe('Groups', () => { expect(settings.getGroup('group_1').devices).toStrictEqual([]); }); - it('Remove from group via MQTT with postfix variant 3', async () => { + it('Legacy api: Remove from group via MQTT with postfix variant 3', async () => { const device = zigbeeHerdsman.devices.bulb_color; const endpoint = device.getEndpoint(1); const group = zigbeeHerdsman.groups.group_1; @@ -216,7 +216,7 @@ describe('Groups', () => { expect(settings.getGroup('group_1').devices).toStrictEqual([]); }); - it('Remove from group all', async () => { + it('Legacy api: Remove from group all', async () => { const device = zigbeeHerdsman.devices.bulb_color; const endpoint = device.getEndpoint(1); const group = zigbeeHerdsman.groups.group_1; @@ -245,7 +245,7 @@ describe('Groups', () => { expect(settings.getGroup('group_1').devices).toStrictEqual([]); }); - it('Log when adding to non-existing group', async () => { + it('Legacy api: Log when adding to non-existing group', async () => { await controller.start(); await flushPromises(); logger.error.mockClear(); @@ -254,7 +254,7 @@ describe('Groups', () => { expect(logger.error).toHaveBeenCalledWith("Group 'group_1_not_existing' does not exist"); }); - it('Log when adding to non-existing device', async () => { + it('Legacy api: Log when adding to non-existing device', async () => { await controller.start(); await flushPromises(); logger.error.mockClear(); @@ -459,4 +459,240 @@ describe('Groups', () => { expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb_color", '{"state":"OFF","brightness":0}', {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/group_1", '{"state":"OFF","brightness":0}', {"retain": false, qos: 0}, expect.any(Function)); }); + + it('Add to group via MQTT', async () => { + const device = zigbeeHerdsman.devices.bulb_color; + const endpoint = device.getEndpoint(1); + const group = zigbeeHerdsman.groups.group_1; + settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: []}}); + expect(group.members.length).toBe(0); + await controller.start(); + await flushPromises(); + MQTT.events.message('zigbee2mqtt/bridge/request/group/members/add', JSON.stringify({group: 'group_1', device: 'bulb_color'})); + await flushPromises(); + expect(group.members).toStrictEqual([endpoint]); + expect(settings.getGroup('group_1').devices).toStrictEqual([`${device.ieeeAddr}/1`]); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/group/members/add', + JSON.stringify({"data":{"device":"bulb_color","group":"group_1"},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Add to group via MQTT fails', async () => { + const device = zigbeeHerdsman.devices.bulb_color; + const endpoint = device.getEndpoint(1); + const group = zigbeeHerdsman.groups.group_1; + settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: []}}); + expect(group.members.length).toBe(0); + await controller.start(); + endpoint.addToGroup.mockImplementationOnce(() => {throw new Error('timeout')}); + await flushPromises(); + MQTT.events.message('zigbee2mqtt/bridge/request/group/members/add', JSON.stringify({group: 'group_1', device: 'bulb_color'})); + await flushPromises(); + expect(group.members).toStrictEqual([]); + expect(settings.getGroup('group_1').devices).toStrictEqual([]); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/group/members/add', + JSON.stringify({"data":{"device":"bulb_color","group":"group_1"},"status":"error","error":"Failed to add from group (timeout)"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Add to group with slashes via MQTT', async () => { + const device = zigbeeHerdsman.devices.bulb_color; + const endpoint = device.getEndpoint(1); + const group = zigbeeHerdsman.groups["group/with/slashes"]; + settings.set(['groups'], {'99': {friendly_name: 'group/with/slashes', retain: false, devices: []}}); + expect(group.members.length).toBe(0); + await controller.start(); + await flushPromises(); + MQTT.events.message('zigbee2mqtt/bridge/request/group/members/add', JSON.stringify({group: 'group/with/slashes', device: 'bulb_color'})); + await flushPromises(); + expect(group.members).toStrictEqual([endpoint]); + expect(settings.getGroup('group/with/slashes').devices).toStrictEqual([`${device.ieeeAddr}/1`]); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/group/members/add', + JSON.stringify({"data":{"device":"bulb_color","group":"group/with/slashes"},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Add to group via MQTT with postfix', async () => { + const device = zigbeeHerdsman.devices.QBKG03LM; + const endpoint = device.getEndpoint(3); + const group = zigbeeHerdsman.groups.group_1; + expect(group.members.length).toBe(0); + await controller.start(); + await flushPromises(); + MQTT.events.message('zigbee2mqtt/bridge/request/group/members/add', JSON.stringify({group: 'group_1', device: 'wall_switch_double/right'})); + await flushPromises(); + expect(group.members).toStrictEqual([endpoint]); + expect(settings.getGroup('group_1').devices).toStrictEqual([`${device.ieeeAddr}/${endpoint.ID}`]); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/group/members/add', + JSON.stringify({"data":{"device":"wall_switch_double/right","group":"group_1"},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Add to group via MQTT with postfix shouldnt add it twice', async () => { + const device = zigbeeHerdsman.devices.QBKG03LM; + const endpoint = device.getEndpoint(3); + const group = zigbeeHerdsman.groups.group_1; + expect(group.members.length).toBe(0); + await controller.start(); + await flushPromises(); + MQTT.events.message('zigbee2mqtt/bridge/request/group/members/add', JSON.stringify({group: 'group_1', device: 'wall_switch_double/right'})); + await flushPromises(); + MQTT.events.message('zigbee2mqtt/bridge/request/group/members/add', JSON.stringify({group: 'group_1', device: '0x0017880104e45542/3'})); + await flushPromises(); + expect(group.members).toStrictEqual([endpoint]); + expect(settings.getGroup('group_1').devices).toStrictEqual([`${device.ieeeAddr}/${endpoint.ID}`]); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/group/members/add', + JSON.stringify({"data":{"device":"wall_switch_double/right","group":"group_1"},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Remove from group via MQTT', async () => { + const device = zigbeeHerdsman.devices.bulb_color; + const endpoint = device.getEndpoint(1); + const group = zigbeeHerdsman.groups.group_1; + group.members.push(endpoint); + settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [device.ieeeAddr]}}); + await controller.start(); + await flushPromises(); + MQTT.events.message('zigbee2mqtt/bridge/request/group/members/remove', JSON.stringify({group: 'group_1', device: 'bulb_color'})); + await flushPromises(); + expect(group.members).toStrictEqual([]); + expect(settings.getGroup('group_1').devices).toStrictEqual([]); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/group/members/remove', + JSON.stringify({"data":{"device":"bulb_color","group":"group_1"},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Remove from group via MQTT when in zigbee but not in settings', async () => { + const device = zigbeeHerdsman.devices.bulb_color; + const endpoint = device.getEndpoint(1); + const group = zigbeeHerdsman.groups.group_1; + group.members.push(endpoint); + settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: ['dummy']}}); + await controller.start(); + await flushPromises(); + MQTT.events.message('zigbee2mqtt/bridge/request/group/members/remove', JSON.stringify({group: 'group_1', device: 'bulb_color'})); + await flushPromises(); + expect(group.members).toStrictEqual([]); + expect(settings.getGroup('group_1').devices).toStrictEqual(['dummy']); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/group/members/remove', + JSON.stringify({"data":{"device":"bulb_color","group":"group_1"},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Remove from group via MQTT with postfix variant 1', async () => { + const device = zigbeeHerdsman.devices.bulb_color; + const endpoint = device.getEndpoint(1); + const group = zigbeeHerdsman.groups.group_1; + group.members.push(endpoint); + settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [`wall_switch_double/right`]}}); + await controller.start(); + await flushPromises(); + MQTT.events.message('zigbee2mqtt/bridge/request/group/members/remove', JSON.stringify({group: 'group_1', device: '0x0017880104e45542/3'})); + await flushPromises(); + expect(group.members).toStrictEqual([]); + expect(settings.getGroup('group_1').devices).toStrictEqual([]); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/group/members/remove', + JSON.stringify({"data":{"device":"0x0017880104e45542/3","group":"group_1"},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Remove from group via MQTT with postfix variant 2', async () => { + const device = zigbeeHerdsman.devices.bulb_color; + const endpoint = device.getEndpoint(1); + const group = zigbeeHerdsman.groups.group_1; + group.members.push(endpoint); + settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [`0x0017880104e45542/right`]}}); + await controller.start(); + await flushPromises(); + MQTT.events.message('zigbee2mqtt/bridge/request/group/members/remove', JSON.stringify({group: 'group_1', device: 'wall_switch_double/3'})); + await flushPromises(); + expect(group.members).toStrictEqual([]); + expect(settings.getGroup('group_1').devices).toStrictEqual([]); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/group/members/remove', + JSON.stringify({"data":{"device":"wall_switch_double/3","group":"group_1"},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Remove from group via MQTT with postfix variant 3', async () => { + const device = zigbeeHerdsman.devices.bulb_color; + const endpoint = device.getEndpoint(1); + const group = zigbeeHerdsman.groups.group_1; + group.members.push(endpoint); + settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [`wall_switch_double/3`]}}); + await controller.start(); + await flushPromises(); + MQTT.events.message('zigbee2mqtt/bridge/request/group/members/remove', JSON.stringify({group: 'group_1', device: '0x0017880104e45542/right'})); + await flushPromises(); + expect(group.members).toStrictEqual([]); + expect(settings.getGroup('group_1').devices).toStrictEqual([]); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/group/members/remove', + JSON.stringify({"data":{"device":"0x0017880104e45542/right","group":"group_1"},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Remove from group all', async () => { + const device = zigbeeHerdsman.devices.bulb_color; + const endpoint = device.getEndpoint(1); + const group = zigbeeHerdsman.groups.group_1; + group.members.push(endpoint); + settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [`wall_switch_double/3`]}}); + await controller.start(); + await flushPromises(); + MQTT.events.message('zigbee2mqtt/bridge/request/group/members/remove_all', JSON.stringify({device: '0x0017880104e45542/right'})); + await flushPromises(); + expect(group.members).toStrictEqual([]); + expect(settings.getGroup('group_1').devices).toStrictEqual([]); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/group/members/remove_all', + JSON.stringify({"data":{"device":"0x0017880104e45542/right"},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Error when adding to non-existing group', async () => { + await controller.start(); + await flushPromises(); + logger.error.mockClear(); + MQTT.events.message('zigbee2mqtt/bridge/request/group/members/remove', JSON.stringify({group: 'group_1_not_existing', device: 'bulb_color'})); + await flushPromises(); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/group/members/remove', + JSON.stringify({"data":{"device":"bulb_color","group":"group_1_not_existing"},"status":"error","error":"Group 'group_1_not_existing' does not exist"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Error when adding to non-existing device', async () => { + await controller.start(); + await flushPromises(); + logger.error.mockClear(); + MQTT.events.message('zigbee2mqtt/bridge/request/group/members/add', JSON.stringify({group: 'group_1', device: 'bulb_color_not_existing'})); + await flushPromises(); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/group/members/add', + JSON.stringify({"data":{"device":"bulb_color_not_existing","group":"group_1"},"status":"error","error":"Device 'bulb_color_not_existing' does not exist"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); }); \ No newline at end of file diff --git a/test/stub/zigbeeHerdsman.js b/test/stub/zigbeeHerdsman.js index dbdb89c0..d80ed5b6 100644 --- a/test/stub/zigbeeHerdsman.js +++ b/test/stub/zigbeeHerdsman.js @@ -48,9 +48,10 @@ class Endpoint { return this.outputClusters.includes(clusters[cluster]); } - this.addToGroup = (group) => { + this.addToGroup = jest.fn(); + this.addToGroup.mockImplementation((group) => { if (!group.members.includes(this)) group.members.push(this); - } + }) this.getDevice = () => { return Object.values(devices).find(d => d.ieeeAddr === deviceIeeeAddress);