mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2024-11-17 19:08:28 -07:00
1b32a46649
* Initial * Updates * Some tests * tests * Updates * Move poll to bind * Move report to legacy * Updates * Remove toPoll key. https://github.com/Koenkk/zigbee2mqtt/pull/5529#pullrequestreview-570285310 * Updates * Updates * Update bridge.test.js * Fix tests
331 lines
14 KiB
JavaScript
331 lines
14 KiB
JavaScript
const settings = require('../util/settings');
|
|
const logger = require('../util/logger');
|
|
const Extension = require('./extension');
|
|
const utils = require('../util/utils');
|
|
const postfixes = utils.getEndpointNames();
|
|
const stringify = require('json-stable-stringify-without-jsonify');
|
|
const equals = require('fast-deep-equal/es6');
|
|
|
|
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$`);
|
|
|
|
class Groups extends Extension {
|
|
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
|
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
|
this.onStateChange = this.onStateChange.bind(this);
|
|
this.legacyApi = settings.get().advanced.legacy_api;
|
|
this.lastOptimisticState = {};
|
|
}
|
|
|
|
async onZigbeeStarted() {
|
|
this.eventBus.on('stateChange', this.onStateChange);
|
|
await this.syncGroupsWithSettings();
|
|
}
|
|
|
|
async syncGroupsWithSettings() {
|
|
const settingsGroups = settings.getGroups();
|
|
const zigbeeGroups = this.zigbee.getGroups();
|
|
|
|
const addRemoveFromGroup = async (action, deviceName, groupName, endpoint, group) => {
|
|
try {
|
|
logger.info(`${action === 'add' ? 'Adding' : 'Removing'} '${deviceName}' to group '${groupName}'`);
|
|
if (action === 'remove') {
|
|
await endpoint.removeFromGroup(group);
|
|
} else {
|
|
await endpoint.addToGroup(group);
|
|
}
|
|
} catch (error) {
|
|
logger.error(`Failed to ${action} '${deviceName}' from '${groupName}'`);
|
|
}
|
|
};
|
|
|
|
for (const settingGroup of settingsGroups) {
|
|
const groupID = settingGroup.ID;
|
|
const zigbeeGroup = zigbeeGroups.find((g) => g.groupID === groupID) || this.zigbee.createGroup(groupID);
|
|
const settingsEntity = settingGroup.devices.map((d) => {
|
|
const entity = this.zigbee.resolveEntity(d);
|
|
if (!entity) logger.error(`Cannot find '${d}' of group '${settingGroup.friendlyName}'`);
|
|
return entity;
|
|
}).filter((e) => e != null);
|
|
|
|
// In settings but not in zigbee
|
|
for (const entity of settingsEntity) {
|
|
if (!zigbeeGroup.hasMember(entity.endpoint)) {
|
|
addRemoveFromGroup('add', entity.name, settingGroup.friendlyName, entity.endpoint, zigbeeGroup);
|
|
}
|
|
}
|
|
|
|
// In zigbee but not in settings
|
|
for (const endpoint of zigbeeGroup.members) {
|
|
if (!settingsEntity.find((e) => e.endpoint === endpoint)) {
|
|
const deviceName = settings.getDevice(endpoint.getDevice().ieeeAddr).friendlyName;
|
|
addRemoveFromGroup('remove', deviceName, settingGroup.friendlyName, endpoint, zigbeeGroup);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const zigbeeGroup of zigbeeGroups) {
|
|
if (!settingsGroups.find((g) => g.ID === zigbeeGroup.groupID)) {
|
|
for (const endpoint of zigbeeGroup.members) {
|
|
const deviceName = settings.getDevice(endpoint.getDevice().ieeeAddr).friendlyName;
|
|
addRemoveFromGroup('remove', deviceName, zigbeeGroup.groupID, endpoint, zigbeeGroup);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async onStateChange(data) {
|
|
const reason = 'group_optimistic';
|
|
if (data.reason === reason) {
|
|
return;
|
|
}
|
|
|
|
const properties = ['state', 'brightness', 'color_temp', 'color'];
|
|
const payload = {};
|
|
|
|
properties.forEach((prop) => {
|
|
if (data.to.hasOwnProperty(prop)) {
|
|
payload[prop] = data.to[prop];
|
|
}
|
|
});
|
|
|
|
if (Object.keys(payload).length) {
|
|
const resolvedEntity = this.zigbee.resolveEntity(data.ID);
|
|
const zigbeeGroups = this.zigbee.getGroups().filter((zigbeeGroup) => {
|
|
const settingsGroup = settings.getGroup(zigbeeGroup.groupID);
|
|
return settingsGroup && settingsGroup.optimistic;
|
|
});
|
|
|
|
if (resolvedEntity.type === 'device') {
|
|
for (const zigbeeGroup of zigbeeGroups) {
|
|
if (zigbeeGroup.hasMember(resolvedEntity.endpoint) &&
|
|
!equals(this.lastOptimisticState[zigbeeGroup.groupID], payload)) {
|
|
if (!payload || payload.state !== 'OFF' || this.areAllMembersOff(zigbeeGroup)) {
|
|
this.lastOptimisticState[zigbeeGroup.groupID] = payload;
|
|
await this.publishEntityState(zigbeeGroup.groupID, payload, reason);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Invalidate the last optimistic group state when group state is changed directly.
|
|
delete this.lastOptimisticState[resolvedEntity.group.groupID];
|
|
|
|
const groupIDsToPublish = new Set();
|
|
for (const member of resolvedEntity.group.members) {
|
|
await this.publishEntityState(member.getDevice().ieeeAddr, payload, reason);
|
|
for (const zigbeeGroup of zigbeeGroups) {
|
|
if (zigbeeGroup.hasMember(member)) {
|
|
if (!payload || payload.state !== 'OFF' || this.areAllMembersOff(zigbeeGroup)) {
|
|
groupIDsToPublish.add(zigbeeGroup.groupID);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
groupIDsToPublish.delete(resolvedEntity.group.groupID);
|
|
for (const groupID of groupIDsToPublish) {
|
|
await this.publishEntityState(groupID, payload, reason);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
areAllMembersOff(zigbeeGroup) {
|
|
for (const member of zigbeeGroup.members) {
|
|
const device = member.getDevice();
|
|
if (this.state.exists(device.ieeeAddr)) {
|
|
const state = this.state.get(device.ieeeAddr);
|
|
if (state && state.state === 'ON') {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
parseMQTTMessage(topic, message) {
|
|
let type = null;
|
|
let resolvedEntityGroup = null;
|
|
let resolvedEntityDevice = null;
|
|
let hasEndpointName = null;
|
|
let error = null;
|
|
let groupKey = null;
|
|
let deviceKey = null;
|
|
let triggeredViaLegacyApi = false;
|
|
|
|
/* istanbul ignore else */
|
|
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 '${legacyTopicRegexMatch[1]}' does not exist`);
|
|
|
|
/* istanbul ignore else */
|
|
if (settings.get().advanced.legacy_api) {
|
|
const payload = {
|
|
friendly_name: message, group: legacyTopicRegexMatch[1], error: 'group doesn\'t exists',
|
|
};
|
|
this.mqtt.publish(
|
|
'bridge/log',
|
|
stringify({type: `device_group_${type}_failed`, message: payload}),
|
|
);
|
|
}
|
|
|
|
return {};
|
|
}
|
|
} else {
|
|
type = 'remove_all';
|
|
}
|
|
|
|
resolvedEntityDevice = this.zigbee.resolveEntity(message);
|
|
if (!resolvedEntityDevice || !resolvedEntityDevice.type === 'device') {
|
|
logger.error(`Device '${message}' does not exist`);
|
|
|
|
/* istanbul ignore else */
|
|
if (settings.get().advanced.legacy_api) {
|
|
const payload = {
|
|
friendly_name: message, group: legacyTopicRegexMatch[1], error: 'entity doesn\'t exists',
|
|
};
|
|
this.mqtt.publish(
|
|
'bridge/log',
|
|
stringify({type: `device_group_${type}_failed`, message: payload}),
|
|
);
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
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, error, groupKey, deviceKey,
|
|
triggeredViaLegacyApi,
|
|
};
|
|
}
|
|
|
|
async onMQTTMessage(topic, message) {
|
|
let {
|
|
resolvedEntityGroup, resolvedEntityDevice, type, hasEndpointName, error, triggeredViaLegacyApi,
|
|
groupKey, deviceKey,
|
|
} = this.parseMQTTMessage(topic, message);
|
|
if (!type) return;
|
|
|
|
const responseData = {device: deviceKey};
|
|
if (groupKey) {
|
|
responseData.group = groupKey;
|
|
}
|
|
|
|
if (!error) {
|
|
try {
|
|
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}`);
|
|
}
|
|
|
|
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',
|
|
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',
|
|
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',
|
|
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}`, stringify(response));
|
|
}
|
|
|
|
if (error) {
|
|
logger.error(error);
|
|
} else {
|
|
this.eventBus.emit('groupMembersChanged',
|
|
{group: resolvedEntityGroup, action: type, endpoint: resolvedEntityDevice.endpoint});
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = Groups;
|