From 3bc2ec8c7932c762e3986d6d2d9af6fb3356ecd8 Mon Sep 17 00:00:00 2001 From: Koen Kanters Date: Thu, 27 Dec 2018 18:43:34 +0100 Subject: [PATCH] Simplify groups api. --- docs/information/groups.md | 26 +++++--- .../mqtt_topics_and_message_structure.md | 3 + lib/extension/devicePublish.js | 49 +++++--------- lib/extension/groups.js | 17 +++-- lib/util/utils.js | 23 +++++++ test/devicePublish.test.js | 65 ++++--------------- 6 files changed, 82 insertions(+), 101 deletions(-) diff --git a/docs/information/groups.md b/docs/information/groups.md index 0b8ffee40..eaaccd912 100644 --- a/docs/information/groups.md +++ b/docs/information/groups.md @@ -1,5 +1,7 @@ # Groups -Zigbee2mqtt has support for Zigbee groups. By using Zigbee groups you can control multiple devices simultaneously. +Zigbee2mqtt has support for Zigbee groups. By using Zigbee groups you can control multiple devices simultaneously with one command. + +**NOTE:** to use groups, at least firmware version `20181224` is required! ## Configuration Add the following to your `configuration.yaml`. @@ -12,16 +14,24 @@ groups: friendly_name: group_1 ``` -## Adding a device to a group -Send an MQTT message to `zigbee2mqtt/bridge/groups/[GROUP_FRIENDLY_NAME]/add` with payload `DEVICE_FRIENDLY_NAME` +## Commands +The group of a node can be configured using the following commands: -## Remove a device from a group -Send an MQTT message to `zigbee2mqtt/bridge/groups/[GROUP_FRIENDLY_NAME]/remove` with payload `DEVICE_FRIENDLY_NAME` +- `zigbee2mqtt/bridge/groups/[GROUP_FRIENDLY_NAME]/add` with payload `DEVICE_FRIENDLY_NAME` will add a device to a group. +- `zigbee2mqtt/bridge/groups/[GROUP_FRIENDLY_NAME]/remove` with payload `DEVICE_FRIENDLY_NAME` will remove a device from a group. +- `zigbee2mqtt/bridge/groups/[GROUP_FRIENDLY_NAME]/remove_all` with payload `DEVICE_FRIENDLY_NAME` will remove a device from **all** groups. ## Controlling -To control a group the following topic should be used. The payload is the same as is used for controlling devices. +Controlling a group is similar to controlling a single device. For example to turn on all devices that are part of group send a MQTT message to `zigbee2mqtt/[GROUP_FRIENDLY_NAME]/set` with payload: -``` -zigbee2mqtt/group/[GROUP_FRIENDLY_NAME]/set +```json +{ + "state": "ON", +} ``` +## How do groups work? +By using the above `add` command above, a device will be added to a group. The device itself is responsible for storing to which groups it belongs. Others, e.g. the coordinator, do not have knowledge to which device a groups belongs. + +When using the `set` command, e.g. to turn on all devices in a group, a broadcast request is send to **all* devices in the network. The device itself then determines if it belongs to that group and if it should execute the command. + diff --git a/docs/information/mqtt_topics_and_message_structure.md b/docs/information/mqtt_topics_and_message_structure.md index 18153a48b..4811166b4 100644 --- a/docs/information/mqtt_topics_and_message_structure.md +++ b/docs/information/mqtt_topics_and_message_structure.md @@ -32,6 +32,9 @@ Format should be: `{"old": "OLD_FRIENDLY_NAME", "new": "NEW_FRIENDLY_NAME"}`. ## zigbee2mqtt/bridge/networkmap Allows you to retrieve a map of your zigbee network. Possible payloads are `raw`, `graphviz`. Zigbee2mqtt will send the networkmap to `zigbee2mqtt/bridge/networkmap/[graphviz OR raw]`. +## zigbee2mqtt/bridge/groups/[friendly_name]/(add|remove|remove_all) +See [Groups](../groups.md) + ## zigbee2mqtt/[DEVICE_ID] Where `[DEVICE_ID]` is E.G. `0x00158d0001b79111`. Message published to this topic are **always** in a JSON format. Each device produces a different JSON message, **some** examples: diff --git a/lib/extension/devicePublish.js b/lib/extension/devicePublish.js index d4e80eb03..69909b0e4 100644 --- a/lib/extension/devicePublish.js +++ b/lib/extension/devicePublish.js @@ -56,10 +56,10 @@ class DevicePublish { topic = topic.replace(`${settings.get().mqtt.base_topic}/`, ''); // Parse type from topic - const cmdType = topic.substr(topic.lastIndexOf('/') + 1, topic.length); + const type = topic.substr(topic.lastIndexOf('/') + 1, topic.length); // Remove type from topic - topic = topic.replace(`/${cmdType}`, ''); + topic = topic.replace(`/${type}`, ''); // Check if we have to deal with a postfix. let postfix = ''; @@ -70,15 +70,9 @@ class DevicePublish { topic = topic.replace(`/${postfix}`, ''); } - let entityType = 'device'; - if (topic.startsWith('group/')) { - topic = topic.replace('group/', ''); - entityType = 'group'; - } - const ID = topic; - return {cmdType: cmdType, ID: ID, postfix: postfix, entityType: entityType}; + return {type: type, ID: ID, postfix: postfix}; } onMQTTMessage(topic, message) { @@ -88,31 +82,18 @@ class DevicePublish { return false; } - // Map friendlyName (ID) to entityID if possible. - let entityID = null; - if (topic.entityType === 'group') { - const groupID = settings.getGroupIDByFriendlyName(topic.ID); - if (groupID) { - entityID = Number(groupID); - } else if (utils.isNumeric(topic.ID)) { - entityID = Number(topic.ID); - } else { - logger.error(`Cannot find group '${topic.ID}'`); - return; - } - } else if (topic.entityType === 'device') { - entityID = settings.getIeeeAddrByFriendlyName(topic.ID) || topic.ID; - } + // Resolve the entity + const entity = utils.resolveEntity(topic.ID); // Get entity details let endpoint = null; let converters = null; let device = null; - if (topic.entityType === 'device') { - device = this.zigbee.getDevice(entityID); + if (entity.type === 'device') { + device = this.zigbee.getDevice(entity.ID); if (!device) { - logger.error(`Failed to find device with ieeAddr: '${entityID}'`); + logger.error(`Failed to find device with ieeAddr: '${entity.ID}'`); return; } @@ -131,7 +112,7 @@ class DevicePublish { } converters = model.toZigbee; - } else if (topic.entityType === 'group') { + } else if (entity.type === 'group') { converters = groupConverters; } @@ -159,7 +140,7 @@ class DevicePublish { } // Converter didn't return a result, skip - const converted = converter.convert(key, json[key], json, topic.cmdType); + const converted = converter.convert(key, json[key], json, topic.type); if (!converted) { return; } @@ -167,8 +148,8 @@ class DevicePublish { // Add job to queue this.queue.push((queueCallback) => { this.zigbee.publish( - entityID, - topic.entityType, + entity.ID, + entity.type, converted.cid, converted.cmd, converted.cmdType, @@ -177,7 +158,7 @@ class DevicePublish { endpoint, (error, rsp) => { // Devices do not report when they go off, this ensures state (on/off) is always in sync. - if (topic.entityType === 'device' && topic.cmdType === 'set' && + if (entity.type === 'device' && topic.type === 'set' && !error && (key.startsWith('state') || key === 'brightness')) { const msg = {}; const _key = topic.postfix ? `state_${topic.postfix}` : 'state'; @@ -192,14 +173,14 @@ class DevicePublish { // When there is a transition in the message the state of the device gets out of sync. // Therefore; at the end of the transition, read the new state from the device. - if (topic.cmdType === 'set' && converted.zclData.transtime && topic.entityType === 'device') { + if (topic.type === 'set' && converted.zclData.transtime && entity.type === 'device') { const time = converted.zclData.transtime * 100; const getConverted = converter.convert(key, json[key], json, 'get'); setTimeout(() => { // Add job to queue this.queue.push((queueCallback) => { this.zigbee.publish( - entityID, topic.entityType, getConverted.cid, getConverted.cmd, getConverted.cmdType, + entity.ID, entity.type, getConverted.cid, getConverted.cmd, getConverted.cmdType, getConverted.zclData, getConverted.cfg, endpoint, () => queueCallback() ); }); diff --git a/lib/extension/groups.js b/lib/extension/groups.js index ac28468b8..ac172f343 100644 --- a/lib/extension/groups.js +++ b/lib/extension/groups.js @@ -1,7 +1,7 @@ const settings = require('../util/settings'); const logger = require('../util/logger'); -const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/groups/.+/(remove|add)$`); +const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/.+/(remove|add|remove_all)$`); class Groups { constructor(zigbee, mqtt, state, publishDeviceState) { @@ -12,8 +12,9 @@ class Groups { } onMQTTConnected() { - this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/groups/+/remove`); - this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/groups/+/add`); + this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/group/+/remove`); + this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/group/+/add`); + this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/group/+/remove_all`); } parseTopic(topic) { @@ -22,7 +23,7 @@ class Groups { } // Remove base from topic - topic = topic.replace(`${settings.get().mqtt.base_topic}/bridge/groups/`, ''); + topic = topic.replace(`${settings.get().mqtt.base_topic}/bridge/group/`, ''); // Parse type from topic const type = topic.substr(topic.lastIndexOf('/') + 1, topic.length); @@ -57,10 +58,16 @@ class Groups { // Send command to the device. let payload = null; + let cmd = null; if (topic.type === 'add') { payload = {groupid: groupID, groupname: ''}; + cmd = 'add'; } else if (topic.type === 'remove') { payload = {groupid: groupID}; + cmd = 'remove'; + } else if (topic.type === 'remove_all') { + payload = {}; + cmd = 'removeAll'; } const callback = (error, rsp) => { @@ -72,7 +79,7 @@ class Groups { }; this.zigbee.publish( - ieeeAddr, 'device', 'genGroups', topic.type, 'functional', + ieeeAddr, 'device', 'genGroups', cmd, 'functional', payload, null, null, callback, ); diff --git a/lib/util/utils.js b/lib/util/utils.js index 7e3aad0bb..9168d588a 100644 --- a/lib/util/utils.js +++ b/lib/util/utils.js @@ -1,9 +1,32 @@ +const settings = require('./settings'); + // Xiaomi uses 4151 and 4447 (lumi.plug) as manufacturer ID. const xiaomiManufacturerID = [4151, 4447]; +// An entity can be either a group or a device. +function resolveEntity(ID) { + let type = null; + + if (settings.getIeeeAddrByFriendlyName(ID)) { + // Check if the ID is a friendly_name of a device. + ID = settings.getIeeeAddrByFriendlyName(ID); + type = 'device'; + } else if (settings.getGroupIDByFriendlyName(ID)) { + // Check if the ID is a friendly_name of a group. + ID = Number(settings.getGroupIDByFriendlyName(ID)); + type = 'group'; + } else { + // By default it is a device with ID as ID. + type = 'device'; + } + + return {ID: ID, type: type}; +} + module.exports = { millisecondsToSeconds: (milliseconds) => milliseconds / 1000, secondsToMilliseconds: (seconds) => seconds * 1000, isXiaomiDevice: (device) => xiaomiManufacturerID.includes(device.manufId), isNumeric: (string) => /^\d+$/.test(string), + resolveEntity: (ID) => resolveEntity(ID), }; diff --git a/test/devicePublish.test.js b/test/devicePublish.test.js index 95428aa5e..76ef181d3 100644 --- a/test/devicePublish.test.js +++ b/test/devicePublish.test.js @@ -245,20 +245,6 @@ describe('DevicePublish', () => { chai.assert.deepEqual(zigbee.publish.getCall(0).args[6], cfg.default); chai.assert.deepEqual(zigbee.publish.getCall(0).args[7], null); }); - - it('Should publish messages to groups by ID', () => { - zigbee.publish.resetHistory(); - devicePublish.onMQTTMessage('zigbee2mqtt/group/2/set', JSON.stringify({state: 'ON'})); - chai.assert.isTrue(zigbee.publish.calledOnce); - chai.assert.strictEqual(zigbee.publish.getCall(0).args[0], 2); - chai.assert.strictEqual(zigbee.publish.getCall(0).args[1], 'group'); - chai.assert.strictEqual(zigbee.publish.getCall(0).args[2], 'genOnOff'); - chai.assert.strictEqual(zigbee.publish.getCall(0).args[3], 'on'); - chai.assert.strictEqual(zigbee.publish.getCall(0).args[4], 'functional'); - chai.assert.deepEqual(zigbee.publish.getCall(0).args[5], {}); - chai.assert.deepEqual(zigbee.publish.getCall(0).args[6], cfg.default); - chai.assert.deepEqual(zigbee.publish.getCall(0).args[7], null); - }); }); describe('Parse topic', () => { @@ -295,8 +281,7 @@ describe('DevicePublish', () => { it('Should parse set topic', () => { const topic = 'zigbee2mqtt/my_device_id/set'; const parsed = devicePublish.parseTopic(topic); - chai.assert.strictEqual(parsed.cmdType, 'set'); - chai.assert.strictEqual(parsed.entityType, 'device'); + chai.assert.strictEqual(parsed.type, 'set'); chai.assert.strictEqual(parsed.ID, 'my_device_id'); chai.assert.strictEqual(parsed.postfix, ''); }); @@ -304,8 +289,7 @@ describe('DevicePublish', () => { it('Should parse get topic', () => { const topic = 'zigbee2mqtt/my_device_id2/get'; const parsed = devicePublish.parseTopic(topic); - chai.assert.strictEqual(parsed.cmdType, 'get'); - chai.assert.strictEqual(parsed.entityType, 'device'); + chai.assert.strictEqual(parsed.type, 'get'); chai.assert.strictEqual(parsed.ID, 'my_device_id2'); chai.assert.strictEqual(parsed.postfix, ''); }); @@ -321,8 +305,7 @@ describe('DevicePublish', () => { const topic = 'zigbee2mqtt/at/my/home/my_device_id2/get'; const parsed = devicePublish.parseTopic(topic); - chai.assert.strictEqual(parsed.cmdType, 'get'); - chai.assert.strictEqual(parsed.entityType, 'device'); + chai.assert.strictEqual(parsed.type, 'get'); chai.assert.strictEqual(parsed.ID, 'my_device_id2'); chai.assert.strictEqual(parsed.postfix, ''); }); @@ -330,8 +313,7 @@ describe('DevicePublish', () => { it('Should parse topic with when deviceID has multiple slashes', () => { const topic = 'zigbee2mqtt/floor0/basement/my_device_id2/set'; const parsed = devicePublish.parseTopic(topic); - chai.assert.strictEqual(parsed.cmdType, 'set'); - chai.assert.strictEqual(parsed.entityType, 'device'); + chai.assert.strictEqual(parsed.type, 'set'); chai.assert.strictEqual(parsed.ID, 'floor0/basement/my_device_id2'); chai.assert.strictEqual(parsed.postfix, ''); }); @@ -347,8 +329,7 @@ describe('DevicePublish', () => { const topic = 'zigbee2mqtt/at/my/basement/floor0/basement/my_device_id2/set'; const parsed = devicePublish.parseTopic(topic); - chai.assert.strictEqual(parsed.cmdType, 'set'); - chai.assert.strictEqual(parsed.entityType, 'device'); + chai.assert.strictEqual(parsed.type, 'set'); chai.assert.strictEqual(parsed.ID, 'floor0/basement/my_device_id2'); chai.assert.strictEqual(parsed.postfix, ''); }); @@ -356,8 +337,7 @@ describe('DevicePublish', () => { it('Should parse set with ieeAddr topic', () => { const topic = 'zigbee2mqtt/0x12345689/set'; const parsed = devicePublish.parseTopic(topic); - chai.assert.strictEqual(parsed.cmdType, 'set'); - chai.assert.strictEqual(parsed.entityType, 'device'); + chai.assert.strictEqual(parsed.type, 'set'); chai.assert.strictEqual(parsed.ID, '0x12345689'); chai.assert.strictEqual(parsed.postfix, ''); }); @@ -365,8 +345,7 @@ describe('DevicePublish', () => { it('Should parse set with postfix topic', () => { const topic = 'zigbee2mqtt/0x12345689/left/set'; const parsed = devicePublish.parseTopic(topic); - chai.assert.strictEqual(parsed.cmdType, 'set'); - chai.assert.strictEqual(parsed.entityType, 'device'); + chai.assert.strictEqual(parsed.type, 'set'); chai.assert.strictEqual(parsed.ID, '0x12345689'); chai.assert.strictEqual(parsed.postfix, 'left'); }); @@ -374,8 +353,7 @@ describe('DevicePublish', () => { it('Should parse set with postfix topic', () => { const topic = 'zigbee2mqtt/0x12345689/right/set'; const parsed = devicePublish.parseTopic(topic); - chai.assert.strictEqual(parsed.cmdType, 'set'); - chai.assert.strictEqual(parsed.entityType, 'device'); + chai.assert.strictEqual(parsed.type, 'set'); chai.assert.strictEqual(parsed.ID, '0x12345689'); chai.assert.strictEqual(parsed.postfix, 'right'); }); @@ -383,8 +361,7 @@ describe('DevicePublish', () => { it('Should parse set with postfix topic', () => { const topic = 'zigbee2mqtt/0x12345689/bottom_left/set'; const parsed = devicePublish.parseTopic(topic); - chai.assert.strictEqual(parsed.cmdType, 'set'); - chai.assert.strictEqual(parsed.entityType, 'device'); + chai.assert.strictEqual(parsed.type, 'set'); chai.assert.strictEqual(parsed.ID, '0x12345689'); chai.assert.strictEqual(parsed.postfix, 'bottom_left'); }); @@ -392,8 +369,7 @@ describe('DevicePublish', () => { it('Shouldnt parse set with invalid postfix topic', () => { const topic = 'zigbee2mqtt/0x12345689/invalid/set'; const parsed = devicePublish.parseTopic(topic); - chai.assert.strictEqual(parsed.cmdType, 'set'); - chai.assert.strictEqual(parsed.entityType, 'device'); + chai.assert.strictEqual(parsed.type, 'set'); chai.assert.strictEqual(parsed.ID, '0x12345689/invalid'); chai.assert.strictEqual(parsed.postfix, ''); }); @@ -409,28 +385,9 @@ describe('DevicePublish', () => { const topic = 'zigbee2mqtt/at/my/home/my/device/in/basement/sensor/bottom_left/get'; const parsed = devicePublish.parseTopic(topic); - chai.assert.strictEqual(parsed.cmdType, 'get'); - chai.assert.strictEqual(parsed.entityType, 'device'); + chai.assert.strictEqual(parsed.type, 'get'); chai.assert.strictEqual(parsed.ID, 'my/device/in/basement/sensor'); chai.assert.strictEqual(parsed.postfix, 'bottom_left'); }); - - it('Should parse group topics', () => { - const topic = 'zigbee2mqtt/group/group_1/set'; - const parsed = devicePublish.parseTopic(topic); - chai.assert.strictEqual(parsed.cmdType, 'set'); - chai.assert.strictEqual(parsed.entityType, 'group'); - chai.assert.strictEqual(parsed.ID, 'group_1'); - chai.assert.strictEqual(parsed.postfix, ''); - }); - - it('Should parse group topics with mutiple slashes', () => { - const topic = 'zigbee2mqtt/group/master/child/set'; - const parsed = devicePublish.parseTopic(topic); - chai.assert.strictEqual(parsed.cmdType, 'set'); - chai.assert.strictEqual(parsed.entityType, 'group'); - chai.assert.strictEqual(parsed.ID, 'master/child'); - chai.assert.strictEqual(parsed.postfix, ''); - }); }); });