Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Group support #15 #745

Merged
merged 5 commits into from
Dec 21, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/information/groups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Groups
Zigbee2mqtt has support for Zigbee groups. By using Zigbee groups you can control multiple devices simultaneously.

## Configuration
Add the following to your `configuration.yaml`.

```yaml
groups:
# ID, each group should have a different numerical ID
'1':
# Name which will be used to control the group
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`

## Remove a device from a group
Send an MQTT message to `zigbee2mqtt/bridge/groups/[GROUP_FRIENDLY_NAME]/remove` with payload `DEVICE_FRIENDLY_NAME`

## Controlling
To control a group the following topic should be used. The payload is the same as is used for controlling devices.

```
zigbee2mqtt/group/[GROUP_FRIENDLY_NAME]/set
```

2 changes: 2 additions & 0 deletions lib/controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const ExtensionDeviceConfigure = require('./extension/deviceConfigure');
const ExtensionDeviceReceive = require('./extension/deviceReceive');
const ExtensionMarkOnlineXiaomi = require('./extension/markOnlineXiaomi');
const ExtensionBridgeConfig = require('./extension/bridgeConfig');
const ExtensionGroups = require('./extension/groups');

class Controller {
constructor() {
Expand All @@ -38,6 +39,7 @@ class Controller {
new ExtensionRouterPollXiaomi(this.zigbee, this.mqtt, this.state, this.publishDeviceState),
new ExtensionMarkOnlineXiaomi(this.zigbee, this.mqtt, this.state, this.publishDeviceState),
new ExtensionBridgeConfig(this.zigbee, this.mqtt, this.state, this.publishDeviceState),
new ExtensionGroups(this.zigbee, this.mqtt, this.state, this.publishDeviceState),
];

if (settings.get().homeassistant) {
Expand Down
101 changes: 70 additions & 31 deletions lib/extension/devicePublish.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,20 @@ const settings = require('../util/settings');
const zigbeeShepherdConverters = require('zigbee-shepherd-converters');
const Queue = require('queue');
const logger = require('../util/logger');
const utils = require('../util/utils');

const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/.+/(set|get)$`);
const postfixes = ['left', 'right', 'center', 'bottom_left', 'bottom_right', 'top_left', 'top_right'];
const maxDepth = 20;

const groupConverters = [
zigbeeShepherdConverters.toZigbeeConverters.on_off,
zigbeeShepherdConverters.toZigbeeConverters.light_brightness,
zigbeeShepherdConverters.toZigbeeConverters.light_colortemp,
zigbeeShepherdConverters.toZigbeeConverters.light_color,
zigbeeShepherdConverters.toZigbeeConverters.ignore_transition,
];

class DevicePublish {
constructor(zigbee, mqtt, state, publishDeviceState) {
this.zigbee = zigbee;
Expand Down Expand Up @@ -47,10 +56,10 @@ class DevicePublish {
topic = topic.replace(`${settings.get().mqtt.base_topic}/`, '');

// Parse type from topic
const type = topic.substr(topic.lastIndexOf('/') + 1, topic.length);
const cmdType = topic.substr(topic.lastIndexOf('/') + 1, topic.length);

// Remove type from topic
topic = topic.replace(`/${type}`, '');
topic = topic.replace(`/${cmdType}`, '');

// Check if we have to deal with a postfix.
let postfix = '';
Expand All @@ -61,9 +70,15 @@ class DevicePublish {
topic = topic.replace(`/${postfix}`, '');
}

const deviceID = topic;
let entityType = 'device';
if (topic.startsWith('group/')) {
topic = topic.replace('group/', '');
entityType = 'group';
}

const ID = topic;

return {type: type, deviceID: deviceID, postfix: postfix};
return {cmdType: cmdType, ID: ID, postfix: postfix, entityType: entityType};
}

onMQTTMessage(topic, message) {
Expand All @@ -73,22 +88,51 @@ class DevicePublish {
return false;
}

// Map friendlyName to ieeeAddr if possible.
const ieeeAddr = settings.getIeeeAddrByFriendlyName(topic.deviceID) || topic.deviceID;

// Get device
const device = this.zigbee.getDevice(ieeeAddr);
if (!device) {
logger.error(`Failed to find device with ieeAddr: '${ieeeAddr}'`);
return;
// 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;
}

// Map device to a model
const model = zigbeeShepherdConverters.findByZigbeeModel(device.modelId);
if (!model) {
logger.warn(`Device with modelID '${device.modelId}' is not supported.`);
logger.warn(`Please see: https://koenkk.github.io/zigbee2mqtt/how_tos/how_to_support_new_devices.html`);
return;
// Get entity details
let endpoint = null;
let converters = null;
let device = null;

if (topic.entityType === 'device') {
device = this.zigbee.getDevice(entityID);
if (!device) {
logger.error(`Failed to find device with ieeAddr: '${entityID}'`);
return;
}

// Map device to a model
const model = zigbeeShepherdConverters.findByZigbeeModel(device.modelId);
if (!model) {
logger.warn(`Device with modelID '${device.modelId}' is not supported.`);
logger.warn(`Please see: https://koenkk.github.io/zigbee2mqtt/how_tos/how_to_support_new_devices.html`);
return;
}

// Determine endpoint to publish to.
if (model.hasOwnProperty('ep')) {
const eps = model.ep(device);
endpoint = eps.hasOwnProperty(topic.postfix) ? eps[topic.postfix] : null;
}

converters = model.toZigbee;
} else if (topic.entityType === 'group') {
converters = groupConverters;
}

// Convert the MQTT message to a Zigbee message.
Expand All @@ -100,13 +144,6 @@ class DevicePublish {
json = {state: message.toString()};
}

// Determine endpoint to publish to.
let endpoint = null;
if (model.hasOwnProperty('ep')) {
const eps = model.ep(device);
endpoint = eps.hasOwnProperty(topic.postfix) ? eps[topic.postfix] : null;
}

// When brightness is present skip state; brightness also handles state.
if (json.hasOwnProperty('brightness') && json.hasOwnProperty('state')) {
logger.debug(`Skipping 'state' because of 'brightness'`);
Expand All @@ -115,22 +152,23 @@ class DevicePublish {

// For each key in the JSON message find the matching converter.
Object.keys(json).forEach((key) => {
const converter = model.toZigbee.find((c) => c.key.includes(key));
const converter = converters.find((c) => c.key.includes(key));
if (!converter) {
logger.error(`No converter available for '${key}' (${json[key]})`);
return;
}

// Converter didn't return a result, skip
const converted = converter.convert(key, json[key], json, topic.type);
const converted = converter.convert(key, json[key], json, topic.cmdType);
if (!converted) {
return;
}

// Add job to queue
this.queue.push((queueCallback) => {
this.zigbee.publish(
ieeeAddr,
entityID,
topic.entityType,
converted.cid,
converted.cmd,
converted.cmdType,
Expand All @@ -139,7 +177,8 @@ class DevicePublish {
endpoint,
(error, rsp) => {
// Devices do not report when they go off, this ensures state (on/off) is always in sync.
if (topic.type === 'set' && !error && (key.startsWith('state') || key === 'brightness')) {
if (topic.entityType === 'device' && topic.cmdType === 'set' &&
!error && (key.startsWith('state') || key === 'brightness')) {
const msg = {};
const _key = topic.postfix ? `state_${topic.postfix}` : 'state';
msg[_key] = key === 'brightness' ? 'ON' : json['state'];
Expand All @@ -153,14 +192,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.type === 'set' && converted.zclData.transtime) {
if (topic.cmdType === 'set' && converted.zclData.transtime && topic.entityType === '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(
ieeeAddr, getConverted.cid, getConverted.cmd, getConverted.cmdType,
entityID, topic.entityType, getConverted.cid, getConverted.cmd, getConverted.cmdType,
getConverted.zclData, getConverted.cfg, endpoint, () => queueCallback()
);
});
Expand Down
87 changes: 87 additions & 0 deletions lib/extension/groups.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
const settings = require('../util/settings');
const logger = require('../util/logger');

const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/groups/.+/(remove|add)$`);

class Groups {
constructor(zigbee, mqtt, state, publishDeviceState) {
this.zigbee = zigbee;
this.mqtt = mqtt;
this.state = state;
this.publishDeviceState = publishDeviceState;
}

stop() {
this.queue.stop();
}

onMQTTConnected() {
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/groups/+/remove`);
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/groups/+/add`);
}

parseTopic(topic) {
if (!topic.match(topicRegex)) {
return null;
}

// Remove base from topic
topic = topic.replace(`${settings.get().mqtt.base_topic}/bridge/groups/`, '');

// Parse type from topic
const type = topic.substr(topic.lastIndexOf('/') + 1, topic.length);

// Remove type from topic
topic = topic.replace(`/${type}`, '');

return {friendly_name: topic, type: type};
}

onMQTTMessage(topic, message) {
topic = this.parseTopic(topic);

if (!topic) {
return false;
}

// Find ID of this group.
const groupID = settings.getGroupIDByFriendlyName(topic.friendly_name);
if (!groupID) {
logger.error(`Group with friendly_name '${topic.friendly_name}' doesn't exist`);
return;
}

// Map message to ieeeAddr and check if device exist.
message = message.toString();
const ieeeAddr = settings.getIeeeAddrByFriendlyName(message) || message;
if (!this.zigbee.getDevice(ieeeAddr)) {
logger.error(`Failed to find device '${message}'`);
return;
}

// Send command to the device.
let payload = null;
if (topic.type === 'add') {
payload = {groupid: groupID, groupname: topic.friendly_name};
} else if (topic.type === 'remove') {
payload = {groupid: groupID};
}

const callback = (error, rsp) => {
if (error) {
logger.error(`Failed to ${topic.type} ${ieeeAddr} from ${topic.friendly_name}`);
} else {
logger.error(`Succesfully ${topic.type} ${ieeeAddr} to ${topic.friendly_name}`);
}
};

this.zigbee.publish(
ieeeAddr, 'device', 'genGroups', topic.type, 'functional',
payload, null, null, callback,
);

return true;
}
}

module.exports = Groups;
12 changes: 12 additions & 0 deletions lib/util/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const defaults = {
mqtt: {
include_device_information: false,
},
groups: {},
device_options: {},
advanced: {
log_directory: path.join(data.getPath(), 'log', '%TIMESTAMP%'),
Expand Down Expand Up @@ -73,6 +74,16 @@ function getIeeeAddrByFriendlyName(friendlyName) {
);
}

function getGroupIDByFriendlyName(friendlyName) {
if (!settings.groups) {
return null;
}

return Object.keys(settings.groups).find((ID) =>
settings.groups[ID].friendly_name === friendlyName
);
}

function changeFriendlyName(old, new_) {
const ieeeAddr = getIeeeAddrByFriendlyName(old);

Expand All @@ -94,5 +105,6 @@ module.exports = {
removeDevice: (ieeeAddr) => removeDevice(ieeeAddr),

getIeeeAddrByFriendlyName: (friendlyName) => getIeeeAddrByFriendlyName(friendlyName),
getGroupIDByFriendlyName: (friendlyName) => getGroupIDByFriendlyName(friendlyName),
changeFriendlyName: (old, new_) => changeFriendlyName(old, new_),
};
1 change: 1 addition & 0 deletions lib/util/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ module.exports = {
millisecondsToSeconds: (milliseconds) => milliseconds / 1000,
secondsToMilliseconds: (seconds) => seconds * 1000,
isXiaomiDevice: (device) => xiaomiManufacturerID.includes(device.manufId),
isNumeric: (string) => /^\d+$/.test(string),
};
Loading