Compare commits

...
55 Commits
Author SHA1 Message Date
Koen Kanters 41b67fdd07 1.20.0 2021-07-01 18:16:02 +02:00
Koen Kanters 112a731812 Undev 2021-07-01 18:15:53 +02:00
github-actions[bot]andKoenkk be36316355 Update zigbee-herdsman-converters to 14.0.185 (#7916)
Co-authored-by: Koenkk <Koenkk@users.noreply.github.com>
2021-07-01 17:52:41 +02:00
github-actions[bot]andKoenkk 2afd9ab68b Update zigbee-herdsman-converters to 14.0.184 (#7909)
Co-authored-by: Koenkk <Koenkk@users.noreply.github.com>
2021-06-30 17:42:57 +02:00
github-actions[bot]andKoenkk 4cfa22501c Update zigbee-herdsman-converters to 14.0.183 (#7896)
Co-authored-by: Koenkk <Koenkk@users.noreply.github.com>
2021-06-29 08:59:36 +02:00
github-actions[bot]andKoenkk 519a6e91f4 Update zigbee-herdsman-converters to 14.0.182 (#7885)
Co-authored-by: Koenkk <Koenkk@users.noreply.github.com>
2021-06-26 17:50:27 +02:00
Koen Kanters 1587dc7b84 Add additional debug logging for OTA updates. https://github.com/Koenkk/zigbee2mqtt/issues/3997 2021-06-26 17:21:48 +02:00
github-actions[bot]andnurikk 523b315ab1 Update zigbee2mqtt-frontend to 0.4.29 (#7884)
Co-authored-by: nurikk <nurikk@users.noreply.github.com>
2021-06-26 22:43:10 +08:00
github-actions[bot]andnurikk 1c6730ea5f Update zigbee2mqtt-frontend to 0.4.28 (#7877)
Co-authored-by: nurikk <nurikk@users.noreply.github.com>
2021-06-26 00:21:54 +08:00
github-actions[bot]andnurikk a4eb3a7a0b Update zigbee2mqtt-frontend to 0.4.27 (#7876)
Co-authored-by: nurikk <nurikk@users.noreply.github.com>
2021-06-25 20:31:31 +08:00
github-actions[bot]andnurikk 6898f9c54a Update zigbee2mqtt-frontend to 0.4.25 (#7872)
Co-authored-by: nurikk <nurikk@users.noreply.github.com>
2021-06-25 08:36:17 +08:00
github-actions[bot]andnurikk dadda2de96 Update zigbee2mqtt-frontend to 0.4.24 (#7866)
Co-authored-by: nurikk <nurikk@users.noreply.github.com>
2021-06-25 00:30:11 +08:00
Koen Kanters c27c300324 Combine published state of /set command into one message. https://github.com/Koenkk/zigbee2mqtt/issues/7854 2021-06-23 16:33:34 +02:00
06a30f7ae9 Update zigbee-herdsman-converters to 14.0.181 (#7857)
Co-authored-by: Koenkk <Koenkk@users.noreply.github.com>
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2021-06-22 23:05:47 +02:00
github-actions[bot]andKoenkk 74c2b1c252 Update zigbee-herdsman to 0.13.117 (#7856)
Co-authored-by: Koenkk <Koenkk@users.noreply.github.com>
2021-06-22 23:03:30 +02:00
Koen Kanters 7b7f511dc7 Fix non supported color_mode and color properties being published for group members. https://github.com/Koenkk/zigbee2mqtt/issues/7220 https://github.com/Koenkk/zigbee2mqtt/issues/7736 2021-06-22 22:45:12 +02:00
Koen Kanters 0c45812c69 Fix frontend not working when MQTT broker is offline. https://github.com/nurikk/z2m-frontend/issues/589 2021-06-22 17:03:06 +02:00
github-actions[bot]andnurikk ac855963cb Update zigbee2mqtt-frontend to 0.4.22 (#7845)
Co-authored-by: nurikk <nurikk@users.noreply.github.com>
2021-06-21 23:56:30 +08:00
github-actions[bot]andKoenkk 02e4abbff2 Update zigbee-herdsman-converters to 14.0.180 (#7840)
Co-authored-by: Koenkk <Koenkk@users.noreply.github.com>
2021-06-20 20:54:43 +02:00
github-actions[bot]andnurikk 76a0cd507f Update zigbee2mqtt-frontend to 0.4.21 (#7830)
Co-authored-by: nurikk <nurikk@users.noreply.github.com>
2021-06-20 01:17:32 +08:00
Koen Kanters 91c16812df Home Assistant: enable expose binary as switch for boolean values. https://github.com/Koenkk/zigbee2mqtt/issues/7797 2021-06-18 14:45:56 +02:00
github-actions[bot]andKoenkk 922f5024ff Update zigbee-herdsman-converters to 14.0.179 (#7817)
Co-authored-by: Koenkk <Koenkk@users.noreply.github.com>
2021-06-18 13:20:31 +02:00
Koen Kanters 8dc7ad8dcc Home Assistant group discovery #2410 (#7672)
* Initial

* done
2021-06-16 22:48:48 +02:00
github-actions[bot]andKoenkk c8fdfc77f2 Update zigbee-herdsman-converters to 14.0.178 (#7808)
Co-authored-by: Koenkk <Koenkk@users.noreply.github.com>
2021-06-16 21:44:24 +02:00
Koen Kanters ce311c93b1 Home Assistant: disable expose binary as switch for boolean values. https://github.com/Koenkk/zigbee2mqtt/issues/7797 2021-06-16 17:20:11 +02:00
github-actions[bot]andKoenkk 04c4c77d1e Update zigbee-herdsman-converters to 14.0.177 (#7801)
Co-authored-by: Koenkk <Koenkk@users.noreply.github.com>
2021-06-15 18:18:44 +02:00
github-actions[bot]andKoenkk 1c8fd479d0 Update zigbee-herdsman to 0.13.116 (#7799)
Co-authored-by: Koenkk <Koenkk@users.noreply.github.com>
2021-06-15 18:12:19 +02:00
github-actions[bot]andKoenkk 497ca1800b Update zigbee-herdsman-converters to 14.0.176 (#7789)
Co-authored-by: Koenkk <Koenkk@users.noreply.github.com>
2021-06-14 19:15:38 +02:00
Koen Kanters 7f995e964d Fix "Cannot read property 'group' of null" exception on "request/group/members/remove_all". https://github.com/Koenkk/zigbee2mqtt/issues/7785 2021-06-14 19:12:24 +02:00
github-actions[bot]andKoenkk 3b3dae51b5 Update zigbee-herdsman-converters to 14.0.175 (#7767)
Co-authored-by: Koenkk <Koenkk@users.noreply.github.com>
2021-06-12 19:51:51 +02:00
github-actions[bot]andKoenkk c314e8d267 Update zigbee-herdsman to 0.13.115 (#7766)
Co-authored-by: Koenkk <Koenkk@users.noreply.github.com>
2021-06-12 19:37:56 +02:00
Robert AlexaandKoen Kanters 8dab33dee9 Home Assistant: expose binary with SET access as switch (#7760)
* Add support for binary attributes with SET access - discover as switch in HA

* Code linting

* Update npm-shrinkwrap.json

* Update homeassistant.js

Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2021-06-12 19:05:13 +02:00
github-actions[bot]andKoenkk 217ce2212a Update zigbee-herdsman-converters to 14.0.174 (#7758)
Co-authored-by: Koenkk <Koenkk@users.noreply.github.com>
2021-06-11 19:21:04 +02:00
github-actions[bot]andKoenkk 4ccee02fe3 Update zigbee-herdsman-converters to 14.0.173 (#7748)
Co-authored-by: Koenkk <Koenkk@users.noreply.github.com>
2021-06-10 19:38:57 +02:00
github-actions[bot]andKoenkk ee7bdf4ae2 Update zigbee-herdsman-converters to 14.0.172 (#7735)
Co-authored-by: Koenkk <Koenkk@users.noreply.github.com>
2021-06-08 16:23:08 +02:00
Koen Kanters fa8593c898 Log and publish friendly_name on deviceLeave. https://github.com/Koenkk/zigbee2mqtt/issues/7725 2021-06-08 15:58:10 +02:00
Franck Nijhof 000884b080 Deprecate Home Assistant entity attributes (#7683)
* Deprecate Home Assistant entity attributes

* Adjust test to match latest dev

* Set default only for new installations

* Fix tests
2021-06-08 15:33:14 +02:00
github-actions[bot]andKoenkk 9eb1fe61e7 Update zigbee-herdsman-converters to 14.0.171 (#7724)
Co-authored-by: Koenkk <Koenkk@users.noreply.github.com>
2021-06-07 15:54:32 +02:00
github-actions[bot]andKoenkk 741b936680 Update zigbee-herdsman to 0.13.114 (#7723)
Co-authored-by: Koenkk <Koenkk@users.noreply.github.com>
2021-06-07 15:49:32 +02:00
github-actions[bot]andKoenkk 61b57aceae Update zigbee-herdsman to 0.13.113 (#7719)
Co-authored-by: Koenkk <Koenkk@users.noreply.github.com>
2021-06-07 10:09:34 +02:00
Koen Kanters 7b13b2d213 Improve 0401a13d5d 2021-06-07 09:54:33 +02:00
Koen Kanters 0401a13d5d Disable legacy options on new network start. 2021-06-07 09:46:10 +02:00
github-actions[bot]andnurikk 95d39a3ff6 Update zigbee2mqtt-frontend to 0.4.19 (#7711)
Co-authored-by: nurikk <nurikk@users.noreply.github.com>
2021-06-06 17:26:29 +08:00
github-actions[bot]andKoenkk 34e286c261 Update zigbee-herdsman to 0.13.112 (#7705)
Co-authored-by: Koenkk <Koenkk@users.noreply.github.com>
2021-06-05 19:35:46 +02:00
github-actions[bot]andKoenkk 0b19d01e6d Update zigbee-herdsman-converters to 14.0.170 (#7702)
Co-authored-by: Koenkk <Koenkk@users.noreply.github.com>
2021-06-04 22:16:48 +02:00
Franck Nijhof 5e7387fa9c Disable less common Home Assistant entities by default (#7684)
* Disable less common Home Assistant entities by default

* Disable illuminance by default

* Please eslint
2021-06-04 21:33:45 +02:00
Franck Nijhof 51fd598e32 Add Home Assistant sensor state_class support (#7677) 2021-06-04 11:41:42 +02:00
Koen Kanters 269dac2bf8 Promote to dev 2021-06-04 11:33:25 +02:00
Koen Kanters 9781c4352f Merge branch 'master' into dev 2021-06-04 11:32:57 +02:00
Koen Kanters 9bd46932c3 1.19.1 2021-06-04 11:29:42 +02:00
Koen Kanters ac5ebae639 Fix Docker container not starting on Raspberry Pi. https://github.com/Koenkk/zigbee2mqtt/issues/7662 2021-06-04 11:29:28 +02:00
github-actions[bot]andnurikk a608c3984b Update zigbee2mqtt-frontend to 0.4.18 (#7689)
Co-authored-by: nurikk <nurikk@users.noreply.github.com>
2021-06-03 22:54:47 +08:00
github-actions[bot]andKoenkk c5081559cf Update zigbee-herdsman-converters to 14.0.169 (#7670)
Co-authored-by: Koenkk <Koenkk@users.noreply.github.com>
2021-06-02 20:36:13 +02:00
Koen Kanters 0ea5f14ab7 Fix Docker container not starting on Raspberry Pi. https://github.com/Koenkk/zigbee2mqtt/issues/7662 2021-06-02 19:42:20 +02:00
Koen Kanters 9f02eb3359 Promote to dev 2021-06-01 17:55:58 +02:00
23 changed files with 1214 additions and 839 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:14-alpine3.13 as base
FROM node:14-alpine3.12 as base
WORKDIR /app
RUN apk add --no-cache tzdata eudev tini
+13 -2
View File
@@ -100,8 +100,9 @@ class Controller {
logger.info(`Starting Zigbee2MQTT version ${info.version} (commit #${info.commitHash})`);
// Start zigbee
let startResult;
try {
await this.zigbee.start();
startResult = await this.zigbee.start();
this.callExtensionMethod('onZigbeeStarted', []);
this.zigbee.on('event', this.onZigbeeEvent.bind(this));
this.zigbee.on('adapterDisconnected', this.onZigbeeAdapterDisconnected);
@@ -114,6 +115,14 @@ class Controller {
this.exitCallback(1);
}
// Disable some legacy options on new network creation
if (startResult === 'reset') {
settings.set(['advanced', 'homeassistant_legacy_entity_attributes'], false);
settings.set(['advanced', 'legacy_api'], false);
settings.set(['device_options', 'legacy'], false);
this.enableDisableExtension(false, 'BridgeLegacy');
}
// Log zigbee clients on startup
const devices = this.zigbee.getClients();
logger.info(`Currently ${devices.length} devices are joined:`);
@@ -257,7 +266,9 @@ class Controller {
} else {
/* istanbul ignore else */
if (type === 'deviceLeave') {
logger.warn(`Device '${name || data.ieeeAddr}' left the network`);
const settingsDevice = settings.getDevice(data.ieeeAddr);
logger.warn(`Device '${settingsDevice ? settingsDevice.friendlyName : data.ieeeAddr}' ` +
`left the network`);
this.state.remove(data.ieeeAddr);
}
}
+2 -3
View File
@@ -277,10 +277,9 @@ class Bind extends Extension {
}
async groupMembersChanged(data) {
const bindsToGroup = this.zigbee.getClients().map((c) => c.endpoints).flat().map((e) => e.binds)
.flat().filter((b) => b.target === data.group.group);
if (data.action === 'add') {
const bindsToGroup = this.zigbee.getClients().map((c) => c.endpoints).flat().map((e) => e.binds)
.flat().filter((b) => b.target === data.group.group);
await this.setupReporting(bindsToGroup);
} else { // action === remove/remove_all
if (!data.skipDisableReporting) {
+6 -1
View File
@@ -122,7 +122,12 @@ class Bridge extends Extension {
payload = {
friendly_name: resolvedEntity.settings.friendlyName, ieee_address: ieeeAddress,
};
} else payload = {ieee_address: ieeeAddress}; // deviceLeave
} else {
// deviceLeave
const settingsDevice = settings.getDevice(ieeeAddress);
payload = {ieee_address: ieeeAddress,
friendly_name: settingsDevice ? settingsDevice.friendlyName : null};
}
await this.mqtt.publish(
'bridge/event',
+3 -3
View File
@@ -19,8 +19,8 @@ class Frontend extends Extension {
this.onRequest = this.onRequest.bind(this);
this.onUpgrade = this.onUpgrade.bind(this);
this.mqttBaseTopic = settings.get().mqtt.base_topic;
this.onMQTTPublishedMessage = this.onMQTTPublishedMessage.bind(this);
this.mqtt.on('publishedMessage', this.onMQTTPublishedMessage);
this.onMQTTPublishMessage = this.onMQTTPublishMessage.bind(this);
this.mqtt.on('publishMessage', this.onMQTTPublishMessage);
this.onWebSocketConnection = this.onWebSocketConnection.bind(this);
this.server = http.createServer(this.onRequest);
this.server.on('upgrade', this.onUpgrade);
@@ -106,7 +106,7 @@ class Frontend extends Extension {
}
}
onMQTTPublishedMessage(data) {
onMQTTPublishMessage(data) {
let {topic, payload, options} = data;
if (topic.startsWith(`${this.mqttBaseTopic}/`)) {
// Send topic without base_topic
+25 -4
View File
@@ -11,6 +11,21 @@ const topicRegex =
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$`);
const stateProperties = {
'state': (value, definition) => true,
'brightness': (value, definition) =>
definition.exposes.find((e) => e.type === 'light' && e.features.find((f) => f.name === 'brightness')),
'color_temp': (value, definition) =>
definition.exposes.find((e) => e.type === 'light' && e.features.find((f) => f.name === 'color_temp')),
'color': (value, definition) =>
definition.exposes.find((e) => e.type === 'light' &&
e.features.find((f) => f.name === 'color_xy' || f.name === 'color_hs')),
'color_mode': (value, definition) =>
definition.exposes.find((e) => e.type === 'light' && (
(e.features.find((f) => f.name === `color_${value}`)) ||
(value === 'color_temp' && e.features.find((f) => f.name === 'color_temp')) )),
};
class Groups extends Extension {
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
super(zigbee, mqtt, state, publishEntityState, eventBus);
@@ -82,7 +97,6 @@ class Groups extends Extension {
return;
}
const properties = ['state', 'brightness', 'color_temp', 'color', 'color_mode'];
const payload = {};
let endpointName;
@@ -93,7 +107,7 @@ class Groups extends Extension {
endpointName = endpointNameMatch;
}
if (properties.includes(prop)) {
if (prop in stateProperties) {
payload[prop] = value;
}
}
@@ -121,8 +135,15 @@ class Groups extends Extension {
const groupIDsToPublish = new Set();
for (const member of resolvedEntity.group.members) {
const memberPayload = {...payload};
const endpointName = this.zigbee.resolveEntity(member).endpointName;
const resolvedEntity = this.zigbee.resolveEntity(member);
const memberPayload = {};
Object.keys(payload).forEach((key) => {
if (stateProperties[key](payload[key], resolvedEntity.definition)) {
memberPayload[key] = payload[key];
}
});
const endpointName = resolvedEntity.endpointName;
if (endpointName) {
Object.keys(memberPayload).forEach((key) => {
memberPayload[`${key}_${endpointName}`] = memberPayload[key];
+470 -364
View File
@@ -16,9 +16,19 @@ const sensorClick = {
},
};
const ACCESS_STATE = 1;
const ACCESS_STATE = 0b001;
const ACCESS_SET = 0b010;
const groupSupportedTypes = ['light', 'switch', 'lock', 'cover'];
const defaultStatusTopic = 'homeassistant/status';
const featurePropertyWithoutEndpoint = (feature) => {
if (feature.endpoint) {
return feature.property.slice(0, -1 + -1 * feature.endpoint.length);
} else {
return feature.property;
}
};
/**
* This extensions handles integration with HomeAssistant
*/
@@ -42,16 +52,403 @@ class HomeAssistant extends Extension {
this.discoveryTopic = settings.get().advanced.homeassistant_discovery_topic;
this.statusTopic = settings.get().advanced.homeassistant_status_topic;
this.entityAttributes = settings.get().advanced.homeassistant_legacy_entity_attributes;
this.eventBus.on('deviceRemoved', (data) => this.onDeviceRemoved(data.resolvedEntity), this.constructor.name);
this.eventBus.on('publishEntityState', (data) => this.onPublishEntityState(data), this.constructor.name);
this.eventBus.on('deviceRenamed', (data) =>
this.onDeviceRenamed(data.device, data.homeAssisantRename), this.constructor.name,
);
this.eventBus.on(`groupMembersChanged`, (d) => this.groupMembersChanged(d), this.constructor.name);
this.populateMapping();
}
exposeToConfig(exposes, entityType, definition) {
// For groups an array of exposes (of the same type) is passed, this is to determine e.g. what features
// to use for a bulb (e.g. color_xy/color_temp)
assert(entityType === 'group' || exposes.length === 1, 'Multiple exposes for device not allowed');
const firstExpose = exposes[0];
assert(entityType === 'device' || groupSupportedTypes.includes(firstExpose.type),
`Unsupported expose type ${firstExpose.type} for group`);
let discoveryEntry = null;
const endpoint = entityType === 'device' ? exposes[0].endpoint : undefined;
const getProperty = (feature) => entityType === 'group' ?
featurePropertyWithoutEndpoint(feature) : feature.property;
/* istanbul ignore else */
if (firstExpose.type === 'light') {
const hasColorXY = exposes.find((expose) => expose.features.find((e) => e.name === 'color_xy'));
const hasColorHS = exposes.find((expose) => expose.features.find((e) => e.name === 'color_hs'));
const hasBrightness = exposes.find((expose) => expose.features.find((e) => e.name === 'brightness'));
const hasColorTemp = exposes.find((expose) => expose.features.find((e) => e.name === 'color_temp'));
discoveryEntry = {
type: 'light',
object_id: endpoint ? `light_${endpoint}` : 'light',
discovery_payload: {
brightness: !!hasBrightness,
schema: 'json',
command_topic: true,
brightness_scale: 254,
command_topic_prefix: endpoint,
state_topic_postfix: endpoint,
},
};
const colorModes = [
hasColorXY ? 'xy' : null,
!hasColorXY && hasColorHS ? 'hs' : null,
hasColorTemp ? 'color_temp' : null,
].filter((c) => c);
if (colorModes.length) {
discoveryEntry.discovery_payload.color_mode = true;
discoveryEntry.discovery_payload.supported_color_modes = colorModes;
}
if (entityType === 'device' && hasColorTemp) {
const colorTemp = firstExpose.features.find((e) => e.name === 'color_temp');
discoveryEntry.discovery_payload.max_mireds = colorTemp.value_max;
discoveryEntry.discovery_payload.min_mireds = colorTemp.value_min;
}
const effect = definition && definition.exposes.find((e) => e.type === 'enum' && e.name === 'effect');
if (effect) {
discoveryEntry.discovery_payload.effect = true;
discoveryEntry.discovery_payload.effect_list = effect.values;
}
} else if (firstExpose.type === 'switch') {
const state = firstExpose.features.find((f) => f.name === 'state');
const property = getProperty(state);
discoveryEntry = {
type: 'switch',
object_id: endpoint ? `switch_${endpoint}` : 'switch',
discovery_payload: {
payload_off: state.value_off,
payload_on: state.value_on,
value_template: `{{ value_json.${property} }}`,
command_topic: true,
command_topic_prefix: endpoint,
},
};
const different = ['valve_detection', 'window_detection', 'auto_lock', 'away_mode'];
if (different.includes(property)) {
discoveryEntry.discovery_payload.command_topic_postfix = property;
discoveryEntry.discovery_payload.state_off = state.value_off;
discoveryEntry.discovery_payload.state_on = state.value_on;
discoveryEntry.object_id = property;
if (property === 'window_detection') {
discoveryEntry.discovery_payload.icon = 'mdi:window-open-variant';
}
}
} else if (firstExpose.type === 'climate') {
const setpointProperties = ['occupied_heating_setpoint', 'current_heating_setpoint'];
const setpoint = firstExpose.features.find((f) => setpointProperties.includes(f.name));
assert(setpoint, 'No setpoint found');
const temperature = firstExpose.features.find((f) => f.name === 'local_temperature');
assert(temperature, 'No temperature found');
discoveryEntry = {
type: 'climate',
object_id: endpoint ? `climate_${endpoint}` : 'climate',
discovery_payload: {
// Static
state_topic: false,
temperature_unit: 'C',
// Setpoint
temp_step: setpoint.value_step,
min_temp: setpoint.value_min.toString(),
max_temp: setpoint.value_max.toString(),
// Temperature
current_temperature_topic: true,
current_temperature_template: `{{ value_json.${temperature.property} }}`,
},
};
const mode = firstExpose.features.find((f) => f.name === 'system_mode');
if (mode) {
if (mode.values.includes('sleep')) {
// 'sleep' is not supported by homeassistent, but is valid according to ZCL
// TRV that support sleep (e.g. Viessmann) will have it removed from here,
// this allows other expose consumers to still use it, e.g. the frontend.
mode.values.splice(mode.values.indexOf('sleep'), 1);
}
discoveryEntry.discovery_payload.mode_state_topic = true;
discoveryEntry.discovery_payload.mode_state_template = `{{ value_json.${mode.property} }}`;
discoveryEntry.discovery_payload.modes = mode.values;
discoveryEntry.discovery_payload.mode_command_topic = true;
}
const state = firstExpose.features.find((f) => f.name === 'running_state');
if (state) {
discoveryEntry.discovery_payload.action_topic = true;
discoveryEntry.discovery_payload.action_template = `{% set values = ` +
`{'idle':'off','heat':'heating','cool':'cooling','fan only':'fan'}` +
` %}{{ values[value_json.${state.property}] }}`;
}
const coolingSetpoint = firstExpose.features.find((f) => f.name === 'occupied_cooling_setpoint');
if (coolingSetpoint) {
discoveryEntry.discovery_payload.temperature_low_command_topic = setpoint.name;
discoveryEntry.discovery_payload.temperature_low_state_template =
`{{ value_json.${setpoint.property} }}`;
discoveryEntry.discovery_payload.temperature_low_state_topic = true;
discoveryEntry.discovery_payload.temperature_high_command_topic = coolingSetpoint.name;
discoveryEntry.discovery_payload.temperature_high_state_template =
`{{ value_json.${coolingSetpoint.property} }}`;
discoveryEntry.discovery_payload.temperature_high_state_topic = true;
} else {
discoveryEntry.discovery_payload.temperature_command_topic = setpoint.name;
discoveryEntry.discovery_payload.temperature_state_template =
`{{ value_json.${setpoint.property} }}`;
discoveryEntry.discovery_payload.temperature_state_topic = true;
}
const fanMode = firstExpose.features.find((f) => f.name === 'fan_mode');
if (fanMode) {
discoveryEntry.discovery_payload.fan_modes = fanMode.values;
discoveryEntry.discovery_payload.fan_mode_command_topic = true;
discoveryEntry.discovery_payload.fan_mode_state_template =
`{{ value_json.${fanMode.property} }}`;
discoveryEntry.discovery_payload.fan_mode_state_topic = true;
}
const preset = firstExpose.features.find((f) => f.name === 'preset');
if (preset) {
discoveryEntry.discovery_payload.hold_modes = preset.values;
discoveryEntry.discovery_payload.hold_command_topic = true;
discoveryEntry.discovery_payload.hold_state_template =
`{{ value_json.${preset.property} }}`;
discoveryEntry.discovery_payload.hold_state_topic = true;
}
const awayMode = firstExpose.features.find((f) => f.name === 'away_mode');
if (awayMode) {
discoveryEntry.discovery_payload.away_mode_command_topic = true;
discoveryEntry.discovery_payload.away_mode_state_topic = true;
discoveryEntry.discovery_payload.away_mode_state_template =
`{{ value_json.${awayMode.property} }}`;
}
if (firstExpose.endpoint) {
discoveryEntry.discovery_payload.state_topic_postfix = firstExpose.endpoint;
}
} else if (firstExpose.type === 'lock') {
assert(!endpoint, `Endpoint not supported for lock type`);
const state = firstExpose.features.find((f) => f.name === 'state');
assert(state, 'No state found');
discoveryEntry = {
type: 'lock',
object_id: 'lock',
discovery_payload: {
command_topic: true,
value_template: `{{ value_json.${state.property} }}`,
},
};
if (state.property === 'keypad_lockout') {
// deprecated: keypad_lockout is messy, but changing is breaking
discoveryEntry.discovery_payload.payload_lock = state.value_on;
discoveryEntry.discovery_payload.payload_unlock = state.value_off;
discoveryEntry.discovery_payload.state_topic = true;
discoveryEntry.object_id = 'keypad_lock';
} else if (state.property === 'child_lock') {
// deprecated: child_lock is messy, but changing is breaking
discoveryEntry.discovery_payload.payload_lock = state.value_on;
discoveryEntry.discovery_payload.payload_unlock = state.value_off;
discoveryEntry.discovery_payload.state_locked = 'LOCK';
discoveryEntry.discovery_payload.state_unlocked = 'UNLOCK';
discoveryEntry.discovery_payload.state_topic = true;
discoveryEntry.object_id = 'child_lock';
} else {
discoveryEntry.discovery_payload.state_locked = state.value_on;
discoveryEntry.discovery_payload.state_unlocked = state.value_off;
}
if (state.property !== 'state') {
discoveryEntry.discovery_payload.command_topic_postfix = state.property;
}
} else if (firstExpose.type === 'cover') {
assert(!endpoint, `Endpoint not supported for cover type`);
const hasPosition = exposes.find((expose) => expose.features.find((e) => e.name === 'position'));
const hasTilt = exposes.find((expose) => expose.features.find((e) => e.name === 'tilt'));
discoveryEntry = {
type: 'cover',
object_id: 'cover',
discovery_payload: {
command_topic: true,
state_topic: !hasPosition,
},
};
if (!hasPosition && !hasTilt) {
discoveryEntry.discovery_payload.optimistic = true;
}
if (hasPosition) {
discoveryEntry.discovery_payload = {...discoveryEntry.discovery_payload,
position_template: '{{ value_json.position }}',
set_position_template: '{ "position": {{ position }} }',
set_position_topic: true,
position_topic: true,
};
}
if (hasTilt) {
discoveryEntry.discovery_payload = {...discoveryEntry.discovery_payload,
tilt_command_topic: true,
tilt_status_topic: true,
tilt_status_template: '{{ value_json.tilt }}',
};
}
} else if (firstExpose.type === 'fan') {
assert(!endpoint, `Endpoint not supported for fan type`);
discoveryEntry = {
type: 'fan',
object_id: 'fan',
discovery_payload: {
state_topic: true,
state_value_template: '{{ value_json.fan_state }}',
command_topic: true,
command_topic_postfix: 'fan_state',
},
};
const speed = firstExpose.features.find((e) => e.name === 'mode');
if (speed) {
discoveryEntry.discovery_payload.speed_state_topic = true;
discoveryEntry.discovery_payload.speed_command_topic = true;
discoveryEntry.discovery_payload.speed_value_template = '{{ value_json.fan_mode }}';
discoveryEntry.discovery_payload.speeds = speed.values;
}
} else if (firstExpose.type === 'binary') {
const lookup = {
occupancy: {device_class: 'motion'},
battery_low: {device_class: 'battery'},
water_leak: {device_class: 'moisture'},
vibration: {device_class: 'vibration'},
contact: {device_class: 'door'},
smoke: {device_class: 'smoke'},
gas: {device_class: 'gas'},
carbon_monoxide: {device_class: 'safety'},
presence: {device_class: 'presence'},
};
/**
* If Z2M binary attribute has SET access then expose it as `switch` in HA
* There is also a check on the values for typeof boolean to prevent invalid values and commands
* silently failing - commands work fine but some devices won't reject unexpected values.
* https://github.com/Koenkk/zigbee2mqtt/issues/7740
* Dont expose boolean values for now: https://github.com/Koenkk/zigbee2mqtt/issues/7797
*/
if (firstExpose.access & ACCESS_SET) {
discoveryEntry = {
type: 'switch',
object_id: endpoint ?
`switch_${firstExpose.name}_${endpoint}` :
`switch_${firstExpose.name}`,
discovery_payload: {
value_template: typeof firstExpose.value_on === 'boolean' ?
`{% if value_json.${firstExpose.property} %} true {% else %} false {% endif %}` :
`{{ value_json.${firstExpose.property} }}`,
payload_on: firstExpose.value_on.toString(),
payload_off: firstExpose.value_off.toString(),
command_topic: true,
command_topic_prefix: endpoint,
command_topic_postfix: firstExpose.property,
...(lookup[firstExpose.name] || {}),
},
};
} else {
discoveryEntry = {
type: 'binary_sensor',
object_id: endpoint ? `${firstExpose.name}_${endpoint}` : `${firstExpose.name}`,
discovery_payload: {
value_template: `{{ value_json.${firstExpose.property} }}`,
payload_on: firstExpose.value_on,
payload_off: firstExpose.value_off,
...(lookup[firstExpose.name] || {}),
},
};
}
} else if (firstExpose.type === 'numeric') {
const lookup = {
battery: {device_class: 'battery', state_class: 'measurement'},
temperature: {device_class: 'temperature', state_class: 'measurement'},
humidity: {device_class: 'humidity', state_class: 'measurement'},
illuminance_lux: {device_class: 'illuminance', state_class: 'measurement'},
illuminance: {
device_class: 'illuminance', enabled_by_default: false, state_class: 'measurement',
},
soil_moisture: {icon: 'mdi:water-percent', state_class: 'measurement'},
position: {icon: 'mdi:valve', state_class: 'measurement'},
pressure: {device_class: 'pressure', state_class: 'measurement'},
power: {device_class: 'power', state_class: 'measurement'},
linkquality: {enabled_by_default: false, icon: 'mdi:signal', state_class: 'measurement'},
current: {device_class: 'current', state_class: 'measurement'},
voltage: {device_class: 'voltage', enabled_by_default: false, state_class: 'measurement'},
current_phase_b: {device_class: 'current', state_class: 'measurement'},
voltage_phase_b: {
device_class: 'voltage', enabled_by_default: false, state_class: 'measurement',
},
current_phase_c: {device_class: 'current', state_class: 'measurement'},
voltage_phase_c: {
device_class: 'voltage', enabled_by_default: false, state_class: 'measurement',
},
energy: {device_class: 'energy'},
smoke_density: {icon: 'mdi:google-circles-communities', state_class: 'measurement'},
gas_density: {icon: 'mdi:google-circles-communities', state_class: 'measurement'},
pm25: {icon: 'mdi:air-filter', state_class: 'measurement'},
pm10: {icon: 'mdi:air-filter', state_class: 'measurement'},
voc: {icon: 'mdi:air-filter', state_class: 'measurement'},
aqi: {icon: 'mdi:air-filter', state_class: 'measurement'},
hcho: {icon: 'mdi:air-filter', state_class: 'measurement'},
requested_brightness_level: {enabled_by_default: false, icon: 'mdi:brightness-5'},
requested_brightness_percent: {enabled_by_default: false, icon: 'mdi:brightness-5'},
eco2: {icon: 'mdi:molecule-co2', state_class: 'measurement'},
co2: {icon: 'mdi:molecule-co2', state_class: 'measurement'},
local_temperature: {device_class: 'temperature', state_class: 'measurement'},
x_axis: {icon: 'mdi:axis-x-arrow'},
y_axis: {icon: 'mdi:axis-y-arrow'},
z_axis: {icon: 'mdi:axis-z-arrow'},
};
discoveryEntry = {
type: 'sensor',
object_id: endpoint ? `${firstExpose.name}_${endpoint}` : `${firstExpose.name}`,
discovery_payload: {
value_template: `{{ value_json.${firstExpose.property} }}`,
...(firstExpose.unit && {unit_of_measurement: firstExpose.unit}),
...lookup[firstExpose.name],
},
};
} else if (firstExpose.type === 'enum' || firstExpose.type === 'text' || firstExpose.type === 'composite') {
if (firstExpose.access & ACCESS_STATE) {
const lookup = {
action: {icon: 'mdi:gesture-double-tap'},
};
discoveryEntry = {
type: 'sensor',
object_id: firstExpose.property,
discovery_payload: {
value_template: `{{ value_json.${firstExpose.property} }}`,
...lookup[firstExpose.name],
},
};
}
} else {
throw new Error(`Unsupported exposes type: '${firstExpose.type}'`);
}
return discoveryEntry;
}
populateMapping() {
for (const def of zigbeeHerdsmanConverters.definitions) {
this.mapping[def.model] = [];
@@ -79,349 +476,14 @@ class HomeAssistant extends Extension {
}
for (const expose of def.exposes) {
let discoveryEntry = null;
/* istanbul ignore else */
if (expose.type === 'light') {
const colorXY = expose.features.find((e) => e.name === 'color_xy');
const colorHS = expose.features.find((e) => e.name === 'color_hs');
const brightness = expose.features.find((e) => e.name === 'brightness');
const colorTemp = expose.features.find((e) => e.name === 'color_temp');
discoveryEntry = {
type: 'light',
object_id: expose.endpoint ? `light_${expose.endpoint}` : 'light',
discovery_payload: {
brightness: !!brightness,
schema: 'json',
command_topic: true,
brightness_scale: 254,
command_topic_prefix: expose.endpoint ? expose.endpoint : undefined,
state_topic_postfix: expose.endpoint ? expose.endpoint : undefined,
},
};
const colorModes = [
colorXY ? 'xy' : null,
!colorXY && colorHS ? 'hs' : null,
colorTemp ? 'color_temp' : null,
].filter((c) => c);
if (colorModes.length) {
discoveryEntry.discovery_payload.color_mode = true;
discoveryEntry.discovery_payload.supported_color_modes = colorModes;
}
if (colorTemp) {
discoveryEntry.discovery_payload.max_mireds = colorTemp.value_max;
discoveryEntry.discovery_payload.min_mireds = colorTemp.value_min;
}
const effect = def.exposes.find((e) => e.type === 'enum' && e.name === 'effect');
if (effect) {
discoveryEntry.discovery_payload.effect = true;
discoveryEntry.discovery_payload.effect_list = effect.values;
}
} else if (expose.type === 'switch') {
const state = expose.features.find((f) => f.name === 'state');
discoveryEntry = {
type: 'switch',
object_id: expose.endpoint ? `switch_${expose.endpoint}` : 'switch',
discovery_payload: {
payload_off: state.value_off,
payload_on: state.value_on,
value_template: `{{ value_json.${state.property} }}`,
command_topic: true,
command_topic_prefix: expose.endpoint ? expose.endpoint : undefined,
},
};
const different = ['valve_detection', 'window_detection', 'auto_lock', 'away_mode'];
if (different.includes(state.property)) {
discoveryEntry.discovery_payload.command_topic_postfix = state.property;
discoveryEntry.discovery_payload.state_off = state.value_off;
discoveryEntry.discovery_payload.state_on = state.value_on;
discoveryEntry.discovery_payload.state_topic = true;
discoveryEntry.object_id = state.property;
if (state.property === 'window_detection') {
discoveryEntry.discovery_payload.icon = 'mdi:window-open-variant';
}
}
} else if (expose.type === 'climate') {
const setpointProperties = ['occupied_heating_setpoint', 'current_heating_setpoint'];
const setpoint = expose.features.find((f) => setpointProperties.includes(f.name));
assert(setpoint, 'No setpoint found');
const temperature = expose.features.find((f) => f.name === 'local_temperature');
assert(temperature, 'No temperature found');
discoveryEntry = {
type: 'climate',
object_id: expose.endpoint ? `climate_${expose.endpoint}` : 'climate',
discovery_payload: {
// Static
state_topic: false,
temperature_unit: 'C',
// Setpoint
temp_step: setpoint.value_step,
min_temp: setpoint.value_min.toString(),
max_temp: setpoint.value_max.toString(),
// Temperature
current_temperature_topic: true,
current_temperature_template: `{{ value_json.${temperature.property} }}`,
},
};
const mode = expose.features.find((f) => f.name === 'system_mode');
if (mode) {
if (mode.values.includes('sleep')) {
// 'sleep' is not supported by homeassistent, but is valid according to ZCL
// TRV that support sleep (e.g. Viessmann) will have it removed from here,
// this allows other expose consumers to still use it, e.g. the frontend.
mode.values.splice(mode.values.indexOf('sleep'), 1);
}
discoveryEntry.discovery_payload.mode_state_topic = true;
discoveryEntry.discovery_payload.mode_state_template = `{{ value_json.${mode.property} }}`;
discoveryEntry.discovery_payload.modes = mode.values;
discoveryEntry.discovery_payload.mode_command_topic = true;
}
const state = expose.features.find((f) => f.name === 'running_state');
if (state) {
discoveryEntry.discovery_payload.action_topic = true;
discoveryEntry.discovery_payload.action_template = `{% set values = ` +
`{'idle':'off','heat':'heating','cool':'cooling','fan only':'fan'}` +
` %}{{ values[value_json.${state.property}] }}`;
}
const coolingSetpoint = expose.features.find((f) => f.name === 'occupied_cooling_setpoint');
if (coolingSetpoint) {
discoveryEntry.discovery_payload.temperature_low_command_topic = setpoint.name;
discoveryEntry.discovery_payload.temperature_low_state_template =
`{{ value_json.${setpoint.property} }}`;
discoveryEntry.discovery_payload.temperature_low_state_topic = true;
discoveryEntry.discovery_payload.temperature_high_command_topic = coolingSetpoint.name;
discoveryEntry.discovery_payload.temperature_high_state_template =
`{{ value_json.${coolingSetpoint.property} }}`;
discoveryEntry.discovery_payload.temperature_high_state_topic = true;
} else {
discoveryEntry.discovery_payload.temperature_command_topic = setpoint.name;
discoveryEntry.discovery_payload.temperature_state_template =
`{{ value_json.${setpoint.property} }}`;
discoveryEntry.discovery_payload.temperature_state_topic = true;
}
const fanMode = expose.features.find((f) => f.name === 'fan_mode');
if (fanMode) {
discoveryEntry.discovery_payload.fan_modes = fanMode.values;
discoveryEntry.discovery_payload.fan_mode_command_topic = true;
discoveryEntry.discovery_payload.fan_mode_state_template =
`{{ value_json.${fanMode.property} }}`;
discoveryEntry.discovery_payload.fan_mode_state_topic = true;
}
const preset = expose.features.find((f) => f.name === 'preset');
if (preset) {
discoveryEntry.discovery_payload.hold_modes = preset.values;
discoveryEntry.discovery_payload.hold_command_topic = true;
discoveryEntry.discovery_payload.hold_state_template =
`{{ value_json.${preset.property} }}`;
discoveryEntry.discovery_payload.hold_state_topic = true;
}
const awayMode = expose.features.find((f) => f.name === 'away_mode');
if (awayMode) {
discoveryEntry.discovery_payload.away_mode_command_topic = true;
discoveryEntry.discovery_payload.away_mode_state_topic = true;
discoveryEntry.discovery_payload.away_mode_state_template =
`{{ value_json.${awayMode.property} }}`;
}
if (expose.endpoint) {
discoveryEntry.discovery_payload.state_topic_postfix = expose.endpoint;
}
} else if (expose.type === 'lock') {
assert(!expose.endpoint, `Endpoint not supported for lock type`);
const state = expose.features.find((f) => f.name === 'state');
assert(state, 'No state found');
discoveryEntry = {
type: 'lock',
object_id: 'lock',
discovery_payload: {
command_topic: true,
value_template: `{{ value_json.${state.property} }}`,
},
};
if (state.property === 'keypad_lockout') {
// deprecated: keypad_lockout is messy, but changing is breaking
discoveryEntry.discovery_payload.payload_lock = state.value_on;
discoveryEntry.discovery_payload.payload_unlock = state.value_off;
discoveryEntry.discovery_payload.state_topic = true;
discoveryEntry.object_id = 'keypad_lock';
} else if (state.property === 'child_lock') {
// deprecated: child_lock is messy, but changing is breaking
discoveryEntry.discovery_payload.payload_lock = state.value_on;
discoveryEntry.discovery_payload.payload_unlock = state.value_off;
discoveryEntry.discovery_payload.state_locked = 'LOCK';
discoveryEntry.discovery_payload.state_unlocked = 'UNLOCK';
discoveryEntry.discovery_payload.state_topic = true;
discoveryEntry.object_id = 'child_lock';
} else {
discoveryEntry.discovery_payload.state_locked = state.value_on;
discoveryEntry.discovery_payload.state_unlocked = state.value_off;
}
if (state.property !== 'state') {
discoveryEntry.discovery_payload.command_topic_postfix = state.property;
}
} else if (expose.type === 'cover') {
assert(!expose.endpoint, `Endpoint not supported for cover type`);
const hasPosition = expose.features.find((e) => e.name === 'position');
const hasTilt = expose.features.find((e) => e.name === 'tilt');
discoveryEntry = {
type: 'cover',
object_id: 'cover',
discovery_payload: {
command_topic: true,
state_topic: !hasPosition,
},
};
if (!hasPosition && !hasTilt) {
discoveryEntry.discovery_payload.optimistic = true;
}
if (hasPosition) {
discoveryEntry.discovery_payload = {...discoveryEntry.discovery_payload,
position_template: '{{ value_json.position }}',
set_position_template: '{ "position": {{ position }} }',
set_position_topic: true,
position_topic: true,
};
}
if (hasTilt) {
discoveryEntry.discovery_payload = {...discoveryEntry.discovery_payload,
tilt_command_topic: true,
tilt_status_topic: true,
tilt_status_template: '{{ value_json.tilt }}',
};
}
} else if (expose.type === 'fan') {
assert(!expose.endpoint, `Endpoint not supported for fan type`);
discoveryEntry = {
type: 'fan',
object_id: 'fan',
discovery_payload: {
state_topic: true,
state_value_template: '{{ value_json.fan_state }}',
command_topic: true,
command_topic_postfix: 'fan_state',
},
};
const speed = expose.features.find((e) => e.name === 'mode');
if (speed) {
discoveryEntry.discovery_payload.speed_state_topic = true;
discoveryEntry.discovery_payload.speed_command_topic = true;
discoveryEntry.discovery_payload.speed_value_template = '{{ value_json.fan_mode }}';
discoveryEntry.discovery_payload.speeds = speed.values;
}
} else if (expose.type === 'binary') {
const lookup = {
occupancy: {device_class: 'motion'},
battery_low: {device_class: 'battery'},
water_leak: {device_class: 'moisture'},
vibration: {device_class: 'vibration'},
contact: {device_class: 'door'},
smoke: {device_class: 'smoke'},
gas: {device_class: 'gas'},
carbon_monoxide: {device_class: 'safety'},
presence: {device_class: 'presence'},
};
discoveryEntry = {
type: 'binary_sensor',
object_id: expose.endpoint ? `${expose.name}_${expose.endpoint}` : `${expose.name}`,
discovery_payload: {
value_template: `{{ value_json.${expose.property} }}`,
payload_on: expose.value_on,
payload_off: expose.value_off,
...(lookup[expose.name] || {}),
},
};
} else if (expose.type === 'numeric') {
const lookup = {
battery: {device_class: 'battery'},
temperature: {device_class: 'temperature'},
humidity: {device_class: 'humidity'},
illuminance_lux: {device_class: 'illuminance'},
illuminance: {device_class: 'illuminance'},
soil_moisture: {icon: 'mdi:water-percent'},
position: {icon: 'mdi:valve'},
pressure: {device_class: 'pressure'},
power: {device_class: 'power'},
linkquality: {icon: 'mdi:signal'},
current: {device_class: 'current'},
voltage: {device_class: 'voltage'},
current_phase_b: {device_class: 'current'},
voltage_phase_b: {device_class: 'voltage'},
current_phase_c: {device_class: 'current'},
voltage_phase_c: {device_class: 'voltage'},
energy: {device_class: 'energy'},
smoke_density: {icon: 'mdi:google-circles-communities'},
gas_density: {icon: 'mdi:google-circles-communities'},
pm25: {icon: 'mdi:air-filter'},
pm10: {icon: 'mdi:air-filter'},
voc: {icon: 'mdi:air-filter'},
aqi: {icon: 'mdi:air-filter'},
hcho: {icon: 'mdi:air-filter'},
requested_brightness_level: {icon: 'mdi:brightness-5'},
requested_brightness_percent: {icon: 'mdi:brightness-5'},
eco2: {icon: 'mdi:molecule-co2'},
co2: {icon: 'mdi:molecule-co2'},
local_temperature: {device_class: 'temperature'},
x_axis: {icon: 'mdi:axis-x-arrow'},
y_axis: {icon: 'mdi:axis-y-arrow'},
z_axis: {icon: 'mdi:axis-z-arrow'},
};
discoveryEntry = {
type: 'sensor',
object_id: expose.endpoint ? `${expose.name}_${expose.endpoint}` : `${expose.name}`,
discovery_payload: {
value_template: `{{ value_json.${expose.property} }}`,
...(expose.unit && {unit_of_measurement: expose.unit}),
...lookup[expose.name],
},
};
} else if (expose.type === 'enum' || expose.type === 'text' || expose.type === 'composite') {
if (expose.access & ACCESS_STATE) {
const lookup = {
action: {icon: 'mdi:gesture-double-tap'},
};
discoveryEntry = {
type: 'sensor',
object_id: expose.property,
discovery_payload: {
value_template: `{{ value_json.${expose.property} }}`,
...lookup[expose.name],
},
};
}
} else {
throw new Error(`Unsupported exposes type: '${expose.type}'`);
}
const discoveryEntry = this.exposeToConfig([expose], 'device', def);
if (discoveryEntry) {
this.mapping[def.model].push(discoveryEntry);
}
}
}
// Deprecated in favour of exposes
for (const definition of utils.getExternalConvertersDefinitions(settings)) {
if (definition.hasOwnProperty('homeassistant')) {
this.mapping[definition.model] = definition.homeassistant;
@@ -433,11 +495,15 @@ class HomeAssistant extends Extension {
logger.debug(`Clearing Home Assistant discovery topic for '${resolvedEntity.name}'`);
delete this.discovered[resolvedEntity.device.ieeeAddr];
for (const config of this.getConfigs(resolvedEntity)) {
const topic = this.getDiscoveryTopic(config, resolvedEntity.device);
const topic = this.getDiscoveryTopic(config, resolvedEntity);
this.mqtt.publish(topic, null, {retain: true, qos: 0}, this.discoveryTopic, false, false);
}
}
groupMembersChanged(data) {
this.discover(data.group, true);
}
async onPublishEntityState(data) {
/**
* In case we deal with a lightEndpoint configuration Zigbee2MQTT publishes
@@ -504,7 +570,7 @@ class HomeAssistant extends Extension {
// https://github.com/Koenkk/zigbee2mqtt/issues/4096#issuecomment-674044916
if (homeAssisantRename) {
for (const config of this.getConfigs(resolvedEntity)) {
const topic = this.getDiscoveryTopic(config, device);
const topic = this.getDiscoveryTopic(config, resolvedEntity);
this.mqtt.publish(topic, null, {retain: true, qos: 0}, this.discoveryTopic, false, false);
}
}
@@ -526,23 +592,50 @@ class HomeAssistant extends Extension {
this.mqtt.subscribe(`${this.discoveryTopic}/#`);
// MQTT discovery of all paired devices on startup.
for (const device of this.zigbee.getClients()) {
const resolvedEntity = this.zigbee.resolveEntity(device);
for (const entity of [...this.zigbee.getClients(), ...this.zigbee.getGroups()]) {
const resolvedEntity = this.zigbee.resolveEntity(entity);
this.discover(resolvedEntity, true);
}
}
getConfigs(resolvedEntity) {
if (!resolvedEntity || !resolvedEntity.definition || !this.mapping[resolvedEntity.definition.model]) return [];
if (!resolvedEntity || (resolvedEntity.type === 'device' && !resolvedEntity.definition) ||
(resolvedEntity.type === 'device' && !this.mapping[resolvedEntity.definition.model])) return [];
let configs = this.mapping[resolvedEntity.definition.model].slice();
if (resolvedEntity.definition.hasOwnProperty('ota')) {
const isDevice = resolvedEntity.type === 'device';
let configs;
if (isDevice) {
configs = this.mapping[resolvedEntity.definition.model].slice();
} else { // group
const exposesByType = {};
resolvedEntity.group.members.map((m) => zigbeeHerdsmanConverters.findByDevice(m.getDevice()))
.filter((m) => m != null).forEach((definition) => {
for (const expose of definition.exposes.filter((e) => groupSupportedTypes.includes(e.type))) {
let key = expose.type;
if (['switch', 'lock', 'cover'].includes(expose.type) && expose.endpoint) {
// A device can have multiple of these types which have to discovered seperately.
// e.g. switch with property state and valve_detection.
const state = expose.features.find((f) => f.name === 'state');
key += featurePropertyWithoutEndpoint(state.property);
}
if (!exposesByType[key]) exposesByType[key] = [];
exposesByType[key].push(expose);
}
});
configs = Object.values(exposesByType).map((exposes) => this.exposeToConfig(exposes, 'group'));
}
if (isDevice && resolvedEntity.definition.hasOwnProperty('ota')) {
const updateStateSensor = {
type: 'sensor',
object_id: 'update_state',
discovery_payload: {
icon: 'mdi:update',
value_template: `{{ value_json['update']['state'] }}`,
enabled_by_default: false,
},
};
@@ -555,13 +648,14 @@ class HomeAssistant extends Extension {
payload_on: true,
payload_off: false,
value_template: '{{ value_json.update_available}}',
enabled_by_default: false,
},
};
configs.push(updateAvailableSensor);
}
}
if (resolvedEntity.settings.hasOwnProperty('legacy') && !resolvedEntity.settings.legacy) {
if (isDevice && resolvedEntity.settings.hasOwnProperty('legacy') && !resolvedEntity.settings.legacy) {
configs = configs.filter((c) => c !== sensorClick);
}
@@ -589,9 +683,13 @@ class HomeAssistant extends Extension {
discover(resolvedEntity, force=false) {
// Check if already discoverd and check if there are configs.
const {device, definition} = resolvedEntity;
const discover = force || !this.discovered[device.ieeeAddr];
if (!discover || !device || !definition || !this.mapping[definition.model] || device.interviewing ||
const {device, definition, group} = resolvedEntity;
const discoverKey = resolvedEntity.type === 'device' ? device.ieeeAddr : group.groupID;
const discover = force || !this.discovered[discoverKey];
if (group) {
if (!discover || group.members.length === 0) return;
} else if (!discover || !device || !definition || !this.mapping[definition.model] || device.interviewing ||
(resolvedEntity.settings.hasOwnProperty('homeassistant') && !resolvedEntity.settings.homeassistant)) {
return;
}
@@ -622,7 +720,9 @@ class HomeAssistant extends Extension {
payload.tilt_status_topic = stateTopic;
}
payload.json_attributes_topic = stateTopic;
if (this.entityAttributes) {
payload.json_attributes_topic = stateTopic;
}
// Set (unique) name, separate by space if friendlyName contains space.
const nameSeparator = friendlyName.includes('_') ? '_' : ' ';
@@ -768,11 +868,11 @@ class HomeAssistant extends Extension {
}
}
const topic = this.getDiscoveryTopic(config, device);
const topic = this.getDiscoveryTopic(config, resolvedEntity);
this.mqtt.publish(topic, stringify(payload), {retain: true, qos: 0}, this.discoveryTopic, false, false);
});
this.discovered[device.ieeeAddr] = true;
this.discovered[discoverKey] = true;
}
onMQTTMessage(topic, message) {
@@ -796,19 +896,19 @@ class HomeAssistant extends Extension {
return;
}
const ieeeAddr = discoveryMatch[2];
const resolvedEntity = this.zigbee.resolveEntity(ieeeAddr);
let clear = !resolvedEntity || !resolvedEntity.definition;
const ID = discoveryMatch[2];
const resolvedEntity = this.zigbee.resolveEntity(ID);
let clear = !resolvedEntity || resolvedEntity.type === 'device' && !resolvedEntity.definition;
// Only save when topic matches otherwise config is not updated when renamed by editing configuration.yaml
if (resolvedEntity) {
const key = `${discoveryMatch[3].substring(0, discoveryMatch[3].indexOf('_'))}`;
const triggerTopic = `${settings.get().mqtt.base_topic}/${resolvedEntity.name}/${key}`;
if (isDeviceAutomation && message.topic === triggerTopic) {
if (!this.discoveredTriggers[ieeeAddr]) {
this.discoveredTriggers[ieeeAddr] = new Set();
if (!this.discoveredTriggers[ID]) {
this.discoveredTriggers[ID] = new Set();
}
this.discoveredTriggers[ieeeAddr].add(discoveryMatch[3]);
this.discoveredTriggers[ID].add(discoveryMatch[3]);
}
}
@@ -847,13 +947,18 @@ class HomeAssistant extends Extension {
}
getDevicePayload(resolvedEntity) {
return {
const payload = {
identifiers: [`zigbee2mqtt_${resolvedEntity.settings.ID}`],
name: resolvedEntity.settings.friendlyName,
sw_version: `Zigbee2MQTT ${zigbee2mqttVersion}`,
model: `${resolvedEntity.definition.description} (${resolvedEntity.definition.model})`,
manufacturer: resolvedEntity.definition.vendor,
};
if (resolvedEntity.type === 'device') {
payload.model = `${resolvedEntity.definition.description} (${resolvedEntity.definition.model})`;
payload.manufacturer = resolvedEntity.definition.vendor;
}
return payload;
}
adjustMessagePayloadBeforePublish(resolvedEntity, messagePayload) {
@@ -889,8 +994,9 @@ class HomeAssistant extends Extension {
}
}
getDiscoveryTopic(config, device) {
return `${config.type}/${device.ieeeAddr}/${config.object_id}/config`;
getDiscoveryTopic(config, resolvedEntity) {
const key = resolvedEntity.type === 'device' ? resolvedEntity.device.ieeeAddr : resolvedEntity.group.groupID;
return `${config.type}/${key}/${config.object_id}/config`;
}
async publishDeviceTriggerDiscover(entity, key, value, force=false) {
@@ -919,7 +1025,7 @@ class HomeAssistant extends Extension {
},
};
const topic = this.getDiscoveryTopic(config, device);
const topic = this.getDiscoveryTopic(config, entity);
const payload = {
...config.discovery_payload,
subtype: value,
+1
View File
@@ -235,6 +235,7 @@ class OTAUpdate extends Extension {
this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message, meta}));
}
} catch (e) {
logger.debug(`Update of '${resolvedEntity.name}' failed (${e})`);
error = `Update of '${resolvedEntity.name}' failed (${e.message})`;
this.removeProgressAndRemainingFromState(resolvedEntity.device);
+12 -2
View File
@@ -160,6 +160,12 @@ class EntityPublish extends Extension {
// For each attribute call the corresponding converter
const usedConverters = {};
const toPublish = {};
const addToToPublish = (ID, payload) => {
if (!(ID in toPublish)) toPublish[ID] = {};
toPublish[ID] = {...toPublish[ID], ...payload};
};
for (let [key, value] of entries) {
let endpointName = topic.endpointName;
let actualTarget = target;
@@ -242,12 +248,12 @@ class EntityPublish extends Extension {
options.filtered_optimistic.forEach((a) => delete msg[a]);
}
this.publishEntityState(resolvedEntity.settings.ID, msg);
addToToPublish(resolvedEntity.settings.ID, msg);
}
if (result && result.membersState && optimistic) {
for (const [ieeeAddr, state] of Object.entries(result.membersState)) {
this.publishEntityState(ieeeAddr, state);
addToToPublish(ieeeAddr, state);
}
}
@@ -291,6 +297,10 @@ class EntityPublish extends Extension {
usedConverters[endpointOrGroupID].push(converter);
}
for (const [ID, payload] of Object.entries(toPublish)) {
this.publishEntityState(ID, payload);
}
return true;
}
}
+2 -1
View File
@@ -116,6 +116,8 @@ class MQTT extends events.EventEmitter {
this.publishedTopics.add(topic);
}
this.emit('publishMessage', {topic, payload, options});
if (!this.isConnected()) {
if (!skipLog) {
logger.error(`Not connected to MQTT server!`);
@@ -135,7 +137,6 @@ class MQTT extends events.EventEmitter {
return new Promise((resolve) => {
this.client.publish(topic, payload, actualOptions, () => {
this.emit('publishedMessage', {topic, payload, options});
resolve();
});
});
+9
View File
@@ -125,6 +125,15 @@ const defaults = {
*/
homeassistant_status_topic: 'hass/status',
/**
* Home Assistant legacy entity attributes, when enabled:
* Zigbee2MQTT will send additional states as attributes with each entity.
* For example, A temperature & humidity sensor will have 2 entities for
* the temperature and humidity, with this setting enabled both entities
* will also have an temperature and humidity attribute.
*/
homeassistant_legacy_entity_attributes: true,
/**
* Home Assistant legacy triggers, when enabled:
* - Zigbee2mqt will send an empty 'action' or 'click' after one has been send
+6
View File
@@ -423,6 +423,12 @@
"requiresRestart": true,
"examples": ["homeassistant"]
},
"homeassistant_legacy_entity_attributes": {
"type": "boolean",
"title": "Home Assistant legacy entity attributes",
"description": "Home Assistant legacy entity attributes, when enabled Zigbee2MQTT will add state attributes to each entity, additional to the separate entities and devices it already creates",
"default": true
},
"homeassistant_status_topic": {
"type": "string",
"title": "Home Assistant status topic",
+5 -2
View File
@@ -60,10 +60,11 @@ class Zigbee extends events.EventEmitter {
herdsmanSettings.network.panID = newPanID;
}
let startResult;
try {
herdsmanSettings.acceptJoiningDeviceHandler = this.acceptJoiningDeviceHandler;
this.herdsman = new ZigbeeHerdsman.Controller(herdsmanSettings, logger);
await this.herdsman.start();
startResult = await this.herdsman.start();
} catch (error) {
logger.error(`Error while starting zigbee-herdsman`);
throw error;
@@ -79,7 +80,7 @@ class Zigbee extends events.EventEmitter {
this.herdsman.on('message', (data) => this.emit('event', 'message', data));
this.herdsman.on('permitJoinChanged', (data) => this.emit('permitJoinChanged', data));
logger.info('zigbee-herdsman started');
logger.info(`zigbee-herdsman started (${startResult})`);
logger.info(`Coordinator firmware version: '${stringify(await this.getCoordinatorVersion())}'`);
logger.debug(`Zigbee network parameters: ${stringify(await this.herdsman.getNetworkParameters())}`);
@@ -109,6 +110,8 @@ class Zigbee extends events.EventEmitter {
await this.herdsman.setTransmitPower(transmitPower);
logger.info(`Set transmit power to '${transmitPower}'`);
}
return startResult;
}
async getCoordinatorVersion() {
+404 -400
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "zigbee2mqtt",
"version": "1.19.0",
"version": "1.20.0",
"description": "Zigbee to MQTT bridge using Zigbee-herdsman",
"main": "index.js",
"repository": {
@@ -52,9 +52,9 @@
"winston": "^3.3.3",
"winston-syslog": "^2.4.4",
"ws": "^7.3.1",
"zigbee-herdsman": "0.13.111",
"zigbee-herdsman-converters": "14.0.168",
"zigbee2mqtt-frontend": "0.4.16"
"zigbee-herdsman": "0.13.117",
"zigbee-herdsman-converters": "14.0.185",
"zigbee2mqtt-frontend": "0.4.29"
},
"devDependencies": {
"eslint": "*",
+6 -6
View File
File diff suppressed because one or more lines are too long
+9
View File
@@ -623,4 +623,13 @@ describe('Controller', () => {
expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/fo', 'bar', { retain: false, qos: 0 }, expect.any(Function));
});
it('Should disable legacy options on new network start', async () => {
expect(settings.get().advanced.homeassistant_legacy_entity_attributes).toBeTruthy();
expect(settings.get().advanced.legacy_api).toBeTruthy();
zigbeeHerdsman.start.mockReturnValueOnce('reset');
await controller.start();
expect(settings.get().advanced.homeassistant_legacy_entity_attributes).toBeFalsy();
expect(settings.get().advanced.legacy_api).toBeFalsy();
});
});
+29 -1
View File
@@ -317,11 +317,12 @@ describe('Groups', () => {
await zigbeeHerdsman.events.message({data: {onOff: 1}, cluster: 'genOnOff', device: device1, endpoint: device1.getEndpoint(1), type: 'attributeReport', linkquality: 10});
await zigbeeHerdsman.events.message({data: {onOff: 1}, cluster: 'genOnOff', device: device2, endpoint: device2.getEndpoint(1), type: 'attributeReport', linkquality: 10});
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(4);
expect(MQTT.publish).toHaveBeenCalledTimes(5);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/group_tradfri_remote", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb_2", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb_color_2", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/group_with_tradfri", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/ha_discovery_group", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function));
});
it('Should publish state change of all members when a group changes its state', async () => {
@@ -873,4 +874,31 @@ describe('Groups', () => {
{retain: false, qos: 0}, expect.any(Function)
);
});
it('onlythis Should only include relevant properties when publishing member states', async () => {
const bulbColor = zigbeeHerdsman.devices.bulb_color;
const bulbColorTemp = zigbeeHerdsman.devices.bulb;
const group = zigbeeHerdsman.groups.group_1;
group.members.push(bulbColor.getEndpoint(1));
group.members.push(bulbColorTemp.getEndpoint(1));
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [bulbColor.ieeeAddr, bulbColorTemp.ieeeAddr]}});
await controller.start();
await flushPromises();
MQTT.publish.mockClear();
await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({color_temp: 50}));
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(3);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb_color", stringify({"color_mode":"color_temp","color_temp":50}), {"retain": false, qos: 0}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/group_1", stringify({"color_mode":"color_temp","color_temp":50}), {"retain": false, qos: 0}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({"color_mode":"color_temp","color_temp":50}), {"retain": true, qos: 0}, expect.any(Function));
MQTT.publish.mockClear();
await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({color: {x: 0.5, y: 0.3}}));
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(3);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb_color", stringify({"color":{"x":0.5,"y":0.3},"color_mode":"xy","color_temp":548}), {"retain": false, qos: 0}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/group_1", stringify({"color":{"x":0.5,"y":0.3},"color_mode":"xy","color_temp":548}), {"retain": false, qos: 0}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({"color_mode":"color_temp","color_temp":548}), {"retain": true, qos: 0}, expect.any(Function));
});
});
+148 -2
View File
@@ -44,16 +44,72 @@ describe('HomeAssistant extension', () => {
expect(duplicated).toHaveLength(0);
});
it('Should discover devices', async () => {
it('Should discover devices and groups', async () => {
controller = new Controller(false);
await controller.start();
let payload;
await flushPromises();
payload = {
"availability":[{"topic":"zigbee2mqtt/bridge/state"}],
"brightness":true,
"brightness_scale":254,
"color_mode":true,
"command_topic":"zigbee2mqtt/ha_discovery_group/set",
"device":{
"identifiers":[
"zigbee2mqtt_9"
],
"name":"ha_discovery_group",
"sw_version":this.version,
},
"json_attributes_topic":"zigbee2mqtt/ha_discovery_group",
"name":"ha_discovery_group",
"schema":"json",
"state_topic":"zigbee2mqtt/ha_discovery_group",
"supported_color_modes":[
"xy",
"color_temp"
],
"unique_id":"9_light_zigbee2mqtt"
};
expect(MQTT.publish).toHaveBeenCalledWith(
'homeassistant/light/9/light/config',
stringify(payload),
{ retain: true, qos: 0 },
expect.any(Function),
);
payload = {
"availability":[{"topic":"zigbee2mqtt/bridge/state"}],
"command_topic":"zigbee2mqtt/ha_discovery_group/set",
"device":{
"identifiers":["zigbee2mqtt_9"],
"name":"ha_discovery_group",
"sw_version":this.version,
},
"json_attributes_topic":"zigbee2mqtt/ha_discovery_group",
"name":"ha_discovery_group",
"payload_off":"OFF",
"payload_on":"ON",
"state_topic":"zigbee2mqtt/ha_discovery_group",
"unique_id":"9_switch_zigbee2mqtt",
"value_template":"{{ value_json.state }}"
};
expect(MQTT.publish).toHaveBeenCalledWith(
'homeassistant/switch/9/switch/config',
stringify(payload),
{ retain: true, qos: 0 },
expect.any(Function),
);
payload = {
'unit_of_measurement': '°C',
'device_class': 'temperature',
'state_class': 'measurement',
'value_template': '{{ value_json.temperature }}',
'state_topic': 'zigbee2mqtt/weather_sensor',
'json_attributes_topic': 'zigbee2mqtt/weather_sensor',
@@ -79,6 +135,7 @@ describe('HomeAssistant extension', () => {
payload = {
'unit_of_measurement': '%',
'device_class': 'humidity',
'state_class': 'measurement',
'value_template': '{{ value_json.humidity }}',
'state_topic': 'zigbee2mqtt/weather_sensor',
'json_attributes_topic': 'zigbee2mqtt/weather_sensor',
@@ -104,6 +161,7 @@ describe('HomeAssistant extension', () => {
payload = {
'unit_of_measurement': 'hPa',
'device_class': 'pressure',
'state_class': 'measurement',
'value_template': '{{ value_json.pressure }}',
'state_topic': 'zigbee2mqtt/weather_sensor',
'json_attributes_topic': 'zigbee2mqtt/weather_sensor',
@@ -129,6 +187,7 @@ describe('HomeAssistant extension', () => {
payload = {
'unit_of_measurement': '%',
'device_class': 'battery',
'state_class': 'measurement',
'value_template': '{{ value_json.battery }}',
'state_topic': 'zigbee2mqtt/weather_sensor',
'json_attributes_topic': 'zigbee2mqtt/weather_sensor',
@@ -153,7 +212,9 @@ describe('HomeAssistant extension', () => {
payload = {
'icon': 'mdi:signal',
'enabled_by_default': false,
'unit_of_measurement': 'lqi',
'state_class': 'measurement',
'value_template': '{{ value_json.linkquality }}',
'state_topic': 'zigbee2mqtt/weather_sensor',
'json_attributes_topic': 'zigbee2mqtt/weather_sensor',
@@ -304,6 +365,7 @@ describe('HomeAssistant extension', () => {
payload = {
'unit_of_measurement': '°C',
'device_class': 'temperature',
'state_class': 'measurement',
'value_template': "{{ value_json.temperature }}",
'state_topic': 'zigbee2mqtt/weather_sensor',
'json_attributes_topic': 'zigbee2mqtt/weather_sensor',
@@ -329,6 +391,7 @@ describe('HomeAssistant extension', () => {
payload = {
'unit_of_measurement': '%',
'device_class': 'humidity',
'state_class': 'measurement',
'value_template': '{{ value_json.humidity }}',
'state_topic': 'zigbee2mqtt/weather_sensor',
'json_attributes_topic': 'zigbee2mqtt/weather_sensor',
@@ -354,6 +417,7 @@ describe('HomeAssistant extension', () => {
payload = {
'unit_of_measurement': 'hPa',
'device_class': 'pressure',
'state_class': 'measurement',
'value_template': '{{ value_json.pressure }}',
'state_topic': 'zigbee2mqtt/weather_sensor',
'json_attributes_topic': 'zigbee2mqtt/weather_sensor',
@@ -410,6 +474,7 @@ describe('HomeAssistant extension', () => {
payload = {
'unit_of_measurement': '°C',
'device_class': 'temperature',
'state_class': 'measurement',
'value_template': '{{ value_json.temperature }}',
'state_topic': 'zigbee2mqtt/weather_sensor',
'json_attributes_topic': 'zigbee2mqtt/weather_sensor',
@@ -437,6 +502,7 @@ describe('HomeAssistant extension', () => {
payload = {
'unit_of_measurement': '%',
'device_class': 'humidity',
'state_class': 'measurement',
'value_template': '{{ value_json.humidity }}',
'state_topic': 'zigbee2mqtt/weather_sensor',
'json_attributes_topic': 'zigbee2mqtt/weather_sensor',
@@ -698,6 +764,7 @@ describe('HomeAssistant extension', () => {
payload = {
'unit_of_measurement': '°C',
'device_class': 'temperature',
'state_class': 'measurement',
'value_template': '{{ value_json.temperature }}',
'state_topic': 'zigbee2mqtt/weather_sensor',
'json_attributes_topic': 'zigbee2mqtt/weather_sensor',
@@ -840,6 +907,7 @@ describe('HomeAssistant extension', () => {
const payloadHA = {
'unit_of_measurement': '°C',
'device_class': 'temperature',
'state_class': 'measurement',
'value_template': '{{ value_json.temperature }}',
'state_topic': 'zigbee2mqtt/weather_sensor',
'json_attributes_topic': 'zigbee2mqtt/weather_sensor',
@@ -966,6 +1034,7 @@ describe('HomeAssistant extension', () => {
payload = {
'unit_of_measurement': '°C',
'device_class': 'temperature',
'state_class': 'measurement',
'value_template': '{{ value_json.temperature }}',
'state_topic': 'zigbee2mqtt/weather_sensor',
'json_attributes_topic': 'zigbee2mqtt/weather_sensor',
@@ -1052,6 +1121,7 @@ describe('HomeAssistant extension', () => {
const payload = {
'unit_of_measurement': '°C',
'device_class': 'temperature',
'state_class': 'measurement',
'value_template': '{{ value_json.temperature }}',
'state_topic': 'zigbee2mqtt/weather_sensor_renamed',
'json_attributes_topic': 'zigbee2mqtt/weather_sensor_renamed',
@@ -1122,6 +1192,7 @@ describe('HomeAssistant extension', () => {
const payload = {
'unit_of_measurement': '°C',
'device_class': 'temperature',
'state_class': 'measurement',
'value_template': '{{ value_json.temperature }}',
'state_topic': 'zigbee2mqtt/weather_sensor_renamed',
'json_attributes_topic': 'zigbee2mqtt/weather_sensor_renamed',
@@ -1153,6 +1224,7 @@ describe('HomeAssistant extension', () => {
"payload_on":true,
"payload_off":false,
"value_template":"{{ value_json.update_available}}",
"enabled_by_default": false,
"state_topic":"zigbee2mqtt/bulb",
"json_attributes_topic":"zigbee2mqtt/bulb",
"name":"bulb update available",
@@ -1330,7 +1402,7 @@ describe('HomeAssistant extension', () => {
expect(MQTT.publish).toHaveBeenCalledWith('homeassistant/device_automation/0x0017880104e45520/action_double/config', expect.any(String), expect.any(Object), expect.any(Function));
});
it('Should not discover device_automtation when disabled', async () => {
it('Should not discover device_automation when disabled', async () => {
settings.set(['device_options'], {
homeassistant: {device_automation: null},
})
@@ -1563,4 +1635,78 @@ describe('HomeAssistant extension', () => {
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith('homeassistant/device_automation/0x000b57fffec6a5b2/action_button_3_single/config', null, {qos: 0, retain: true}, expect.any(Function));
});
it('Should not have Home Assistant legacy entity attributes when disabled', async () => {
settings.set(['advanced', 'homeassistant_legacy_entity_attributes'], false);
controller = new Controller(false);
await controller.start();
let payload;
await flushPromises();
payload = {
'unit_of_measurement': '°C',
'device_class': 'temperature',
'state_class': 'measurement',
'value_template': '{{ value_json.temperature }}',
'state_topic': 'zigbee2mqtt/weather_sensor',
'name': 'weather_sensor_temperature',
'unique_id': '0x0017880104e45522_temperature_zigbee2mqtt',
'device': {
'identifiers': ['zigbee2mqtt_0x0017880104e45522'],
'name': 'weather_sensor',
'sw_version': this.version,
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
'manufacturer': 'Xiaomi',
},
'availability': [{topic: 'zigbee2mqtt/bridge/state'}],
};
expect(MQTT.publish).toHaveBeenCalledWith(
'homeassistant/sensor/0x0017880104e45522/temperature/config',
stringify(payload),
{ retain: true, qos: 0 },
expect.any(Function),
);
});
it('Should rediscover group when device is added to it', async () => {
controller = new Controller(false);
await controller.start();
await flushPromises();
MQTT.publish.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/request/group/members/add', stringify({group: 'ha_discovery_group', device: 'wall_switch_double/left'}));
await flushPromises();
const payload = {
"availability":[{"topic":"zigbee2mqtt/bridge/state"}],
"brightness":true,
"brightness_scale":254,
"color_mode":true,
"command_topic":"zigbee2mqtt/ha_discovery_group/set",
"device":{
"identifiers":[
"zigbee2mqtt_9"
],
"name":"ha_discovery_group",
"sw_version":this.version,
},
"json_attributes_topic":"zigbee2mqtt/ha_discovery_group",
"name":"ha_discovery_group",
"schema":"json",
"state_topic":"zigbee2mqtt/ha_discovery_group",
"supported_color_modes":[
"xy",
"color_temp"
],
"unique_id":"9_light_zigbee2mqtt"
};
expect(MQTT.publish).toHaveBeenCalledWith(
'homeassistant/light/9/light/config',
stringify(payload),
{ retain: true, qos: 0 },
expect.any(Function),
);
});
});
+1 -1
View File
@@ -188,7 +188,7 @@ describe('Bridge legacy', () => {
await flushPromises();
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log');
const payload = JSON.parse(MQTT.publish.mock.calls[0][1]);
expect(payload).toStrictEqual({"message":[{"ID":1,"devices":[],"friendly_name":"group_1","retain":false},{"ID":2,"devices":[],"friendly_name":"group_2","retain":false},{"ID":11,"devices":["bulb_2"],"friendly_name":"group_with_tradfri","retain":false},{"ID":12,"devices":["TS0601_thermostat"],"friendly_name":"thermostat_group","retain":false},{"ID":14,"devices":["power_plug"],"friendly_name":"switch_group","retain":false},{"ID":21,"devices":["GLEDOPTO_2ID/cct"],"friendly_name":"gledopto_group"},{"ID":15071,"devices":["bulb_color_2","bulb_2"],"friendly_name":"group_tradfri_remote","retain":false}],"type":"groups"});
expect(payload).toStrictEqual({"message":[{"ID":1,"devices":[],"friendly_name":"group_1","retain":false},{"ID":2,"devices":[],"friendly_name":"group_2","retain":false},{"ID":9,"devices":["bulb_color_2","bulb_2","wall_switch_double/right"],"friendly_name":"ha_discovery_group"},{"ID":11,"devices":["bulb_2"],"friendly_name":"group_with_tradfri","retain":false},{"ID":12,"devices":["TS0601_thermostat"],"friendly_name":"thermostat_group","retain":false},{"ID":14,"devices":["power_plug"],"friendly_name":"switch_group","retain":false},{"ID":21,"devices":["GLEDOPTO_2ID/cct"],"friendly_name":"gledopto_group"},{"ID":15071,"devices":["bulb_color_2","bulb_2"],"friendly_name":"group_tradfri_remote","retain":false}],"type":"groups"});
});
it('Should allow rename devices', async () => {
+50 -40
View File
@@ -176,10 +176,10 @@ describe('Publish', () => {
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(1);
expect(endpoint.command).toHaveBeenCalledWith("genOnOff", "off", {}, {});
expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/wall_switch_double');
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({state_right: 'OFF'});
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 0, "retain": false});
expect(MQTT.publish).toHaveBeenCalledTimes(2);
expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/wall_switch_double');
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({state_right: 'OFF'});
expect(MQTT.publish.mock.calls[1][2]).toStrictEqual({"qos": 0, "retain": false});
});
it('Should publish messages to zigbee devices to non default-ep with state_[EP]', async () => {
@@ -231,13 +231,12 @@ describe('Publish', () => {
expect(endpoint.command).toHaveBeenCalledTimes(2);
expect(endpoint.command).toHaveBeenCalledWith("genOnOff", "on", {}, {});
expect(endpoint.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColor", {colorx: 6553500, colory: 3276750, transtime: 0}, {});
expect(MQTT.publish).toHaveBeenCalledTimes(2);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bulb_color');
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({state: 'ON'});
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 0, "retain": false});
expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bulb_color');
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({color_mode: 'xy', color: {x: 100, y: 50}, state: 'ON'});
expect(MQTT.publish.mock.calls[1][2]).toStrictEqual({"qos": 0, "retain": false});
expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb_color',
stringify({color_mode: 'xy', color: {x: 100, y: 50}, state: 'ON'}),
{retain: false, qos: 0}, expect.any(Function)
);
});
it('Should publish messages to zigbee devices with color xy and brightness', async () => {
@@ -248,13 +247,12 @@ describe('Publish', () => {
expect(endpoint.command).toHaveBeenCalledTimes(2);
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 20, transtime: 0}, {});
expect(endpoint.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColor", {colorx: 6553500, colory: 3276750, transtime: 0}, {});
expect(MQTT.publish).toHaveBeenCalledTimes(2);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bulb_color');
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({state: 'ON', brightness: 20});
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 0, "retain": false});
expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bulb_color');
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({color_mode: 'xy', color: {x: 100, y: 50}, state: 'ON', brightness: 20});
expect(MQTT.publish.mock.calls[1][2]).toStrictEqual({"qos": 0, "retain": false});
expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb_color',
stringify({color_mode: 'xy', color: {x: 100, y: 50}, state: 'ON', brightness: 20}),
{retain: false, qos: 0}, expect.any(Function)
);
});
it('Should publish messages to zigbee devices with color xy, brightness and state on', async () => {
@@ -265,13 +263,13 @@ describe('Publish', () => {
expect(endpoint.command).toHaveBeenCalledTimes(2);
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 20, transtime: 0}, {});
expect(endpoint.command).toHaveBeenCalledWith("lightingColorCtrl", "moveToColor", {colorx: 6553500, colory: 3276750, transtime: 0}, {});
expect(MQTT.publish).toHaveBeenCalledTimes(2);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bulb_color');
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({state: 'ON', brightness: 20});
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 0, "retain": false});
expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bulb_color');
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({color: {x: 100, y: 50}, state: 'ON', brightness: 20, color_mode: 'xy'});
expect(MQTT.publish.mock.calls[1][2]).toStrictEqual({"qos": 0, "retain": false});
expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb_color',
stringify({color: {x: 100, y: 50}, state: 'ON', brightness: 20, color_mode: 'xy'}),
{ retain: false, qos: 0 },
expect.any(Function)
);
});
it('Should publish messages to zigbee devices with color xy, brightness and state off', async () => {
@@ -282,13 +280,13 @@ describe('Publish', () => {
expect(endpoint.command).toHaveBeenCalledTimes(2);
expect(endpoint.command).toHaveBeenNthCalledWith(1, "lightingColorCtrl", "moveToColor", {colorx: 6553500, colory: 3276750, transtime: 0}, {});
expect(endpoint.command).toHaveBeenNthCalledWith(2, "genOnOff", "off", {}, {});
expect(MQTT.publish).toHaveBeenCalledTimes(2);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bulb_color');
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({color_mode: 'xy', color: {x: 100, y: 50}});
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 0, "retain": false});
expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bulb_color');
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({color_mode: 'xy', color: {x: 100, y: 50}, state: 'OFF'});
expect(MQTT.publish.mock.calls[1][2]).toStrictEqual({"qos": 0, "retain": false});
expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb_color',
stringify({color_mode: 'xy', color: {x: 100, y: 50}, state: 'OFF'}),
{ retain: false, qos: 0 },
expect.any(Function)
);
});
it('Should publish messages to zigbee devices with color rgb object', async () => {
@@ -403,10 +401,10 @@ describe('Publish', () => {
it('Should create and publish to group which is in configuration.yaml but not in zigbee-herdsman', async () => {
delete zigbeeHerdsman.groups.group_2;
expect(Object.values(zigbeeHerdsman.groups).length).toBe(8);
expect(Object.values(zigbeeHerdsman.groups).length).toBe(9);
await MQTT.events.message('zigbee2mqtt/group_2/set', stringify({state: 'ON'}));
await flushPromises();
expect(Object.values(zigbeeHerdsman.groups).length).toBe(9);
expect(Object.values(zigbeeHerdsman.groups).length).toBe(10);
expect(zigbeeHerdsman.groups.group_2.command).toHaveBeenCalledTimes(1);
expect(zigbeeHerdsman.groups.group_2.command).toHaveBeenCalledWith("genOnOff", "on", {}, {});
});
@@ -1108,11 +1106,13 @@ describe('Publish', () => {
expect(endpoint.command).toHaveBeenCalledTimes(2);
expect(endpoint.command.mock.calls[0]).toEqual(["genOnOff", "on", {}, {}]);
expect(endpoint.command.mock.calls[1]).toEqual(["lightingColorCtrl", "moveToColorTemp", {colortemp: 100, transtime: 0}, {}]);
expect(MQTT.publish).toHaveBeenCalledTimes(2);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bulb_color');
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({state: 'ON'});
expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bulb_color');
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({state: 'ON', color_temp: 100, color_mode: 'color_temp'});
expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb_color',
stringify({state: 'ON', color_temp: 100, color_mode: 'color_temp'}),
{ retain: false, qos: 0 },
expect.any(Function)
);
});
it('Home Assistant: should not set state when color is also set', async () => {
@@ -1400,7 +1400,7 @@ describe('Publish', () => {
await flushPromises();
expect(group.command).toHaveBeenCalledTimes(1);
expect(group.command).toHaveBeenCalledWith('genScenes', 'recall', { groupid: 15071, sceneid: 1 }, {});
expect(MQTT.publish).toHaveBeenCalledTimes(5);
expect(MQTT.publish).toHaveBeenCalledTimes(7);
expect(MQTT.publish).toHaveBeenNthCalledWith(1,
'zigbee2mqtt/group_tradfri_remote',
stringify({"brightness":50,"color_temp":290,"state":"ON","color_mode": "color_temp"}),
@@ -1422,10 +1422,20 @@ describe('Publish', () => {
{retain: false, qos: 0}, expect.any(Function)
);
expect(MQTT.publish).toHaveBeenNthCalledWith(5,
'zigbee2mqtt/ha_discovery_group',
stringify({"brightness":50,"color_mode":"color_temp","color_temp":290,"state":"ON"}),
{retain: false, qos: 0}, expect.any(Function)
);
expect(MQTT.publish).toHaveBeenNthCalledWith(6,
'zigbee2mqtt/group_with_tradfri',
stringify({"brightness":100,"color_mode":"color_temp","color_temp":290,"state":"ON"}),
{retain: false, qos: 0}, expect.any(Function)
);
expect(MQTT.publish).toHaveBeenNthCalledWith(7,
'zigbee2mqtt/ha_discovery_group',
stringify({"brightness":100,"color_mode":"color_temp","color_temp":290,"state":"ON"}),
{retain: false, qos: 0}, expect.any(Function)
);
});
it('Should sync colors', async () => {
+5 -1
View File
@@ -207,7 +207,11 @@ function writeDefaultConfiguration() {
'21': {
friendly_name: 'gledopto_group',
devices: ['GLEDOPTO_2ID/cct'],
}
},
'9': {
friendly_name: 'ha_discovery_group',
devices: ['bulb_color_2', 'bulb_2', 'wall_switch_double/right']
},
},
external_converters: [],
};
+3 -1
View File
@@ -132,6 +132,7 @@ const bulb_2 = new Device('Router', '0x000b57fffec6a5b7', 40369, 4476, [new End
const TS0601_thermostat = new Device('EndDevice', '0x0017882104a44559', 6544,4151, [new Endpoint(1, [], [], '0x0017882104a44559')], true, "Mains (single phase)", 'kud7u2l');
const ZNCZ02LM = new Device('Router', '0x0017880104e45524', 6540,4151, [new Endpoint(1, [0], [], '0x0017880104e45524')], true, "Mains (single phase)", "lumi.plug");
const GLEDOPTO_2ID = new Device('Router', '0x0017880104e45724', 6540,4151, [new Endpoint(11, [0,3,4,5,6,8,768], [], '0x0017880104e45724', [], {}, [], 49246, 528), new Endpoint(12, [0, 3, 4, 5, 6, 8, 768], [], '0x0017880104e45724', [], {}, [], 260, 258), new Endpoint(13, [4096], [4096], '0x0017880104e45724', [], {}, [], 49246, 57694), new Endpoint(15, [0, 3, 4, 5, 6, 8, 768], [], '0x0017880104e45724', [], {}, [], 49246, 256)], true, "Mains (single phase)", 'GL-C-007', false, 'GLEDOPTO');
const QBKG03LM = new Device('Router', '0x0017880104e45542', 6540,4151, [new Endpoint(1, [0], [], '0x0017880104e45542'), new Endpoint(2, [0, 6], [], '0x0017880104e45542'), new Endpoint(3, [0, 6], [], '0x0017880104e45542')], true, "Mains (single phase)", 'lumi.ctrl_neutral2');
const groups = {
'group_1': new Group(1, []),
@@ -142,6 +143,7 @@ const groups = {
'group_with_switch': new Group(14, [ZNCZ02LM.endpoints[0]]),
'gledopto_group': new Group(21, [GLEDOPTO_2ID.endpoints[3]]),
'default_bind_group': new Group(901, []),
'ha_discovery_group': new Group(9, [bulb_color_2.endpoints[0], bulb_2.endpoints[0], QBKG03LM.endpoints[1]]),
}
const devices = {
@@ -162,7 +164,7 @@ const devices = {
'ZNCZ02LM': ZNCZ02LM,
'E1743': new Device('Router', '0x0017880104e45540', 6540,4476, [new Endpoint(1, [0], [])], true, "Mains (single phase)", 'TRADFRI on/off switch'),
'QBKG04LM': new Device('Router', '0x0017880104e45541', 6549,4151, [new Endpoint(1, [0], [25]), new Endpoint(2, [0, 6], [])], true, "Mains (single phase)", 'lumi.ctrl_neutral1'),
'QBKG03LM':new Device('Router', '0x0017880104e45542', 6540,4151, [new Endpoint(1, [0], [], '0x0017880104e45542'), new Endpoint(2, [0, 6], [], '0x0017880104e45542'), new Endpoint(3, [0, 6], [], '0x0017880104e45542')], true, "Mains (single phase)", 'lumi.ctrl_neutral2'),
'QBKG03LM':QBKG03LM,
'GLEDOPTO1112': new Device('Router', '0x0017880104e45543', 6540, 4151, [new Endpoint(11, [0], [], '0x0017880104e45543'), new Endpoint(13, [0], [], '0x0017880104e45543')], true, "Mains (single phase)", 'GL-C-008'),
'GLEDOPTO111213': new Device('Router', '0x0017880104e45544', 6540,4151, [new Endpoint(11, [0], []), new Endpoint(13, [0], []), new Endpoint(12, [0], [])], true, "Mains (single phase)", 'GL-C-008'),
'GLEDOPTO_2ID': GLEDOPTO_2ID,