From 8dc7ad8dcc054dd3e27c84f3e46e1610a033d634 Mon Sep 17 00:00:00 2001 From: Koen Kanters Date: Wed, 16 Jun 2021 22:48:48 +0200 Subject: [PATCH] Home Assistant group discovery #2410 (#7672) * Initial * done --- lib/extension/homeassistant.js | 853 +++++++++++++++++-------------- test/bridge.test.js | 10 +- test/group.test.js | 3 +- test/homeassistant.test.js | 97 +++- test/legacy/bridgeLegacy.test.js | 2 +- test/publish.test.js | 24 +- test/stub/data.js | 6 +- test/stub/zigbeeHerdsman.js | 4 +- 8 files changed, 589 insertions(+), 410 deletions(-) diff --git a/lib/extension/homeassistant.js b/lib/extension/homeassistant.js index 57a849974..d9aacb4ff 100644 --- a/lib/extension/homeassistant.js +++ b/lib/extension/homeassistant.js @@ -18,8 +18,17 @@ const sensorClick = { 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 */ @@ -50,10 +59,394 @@ class HomeAssistant extends Extension { 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 && typeof firstExpose.value_on !== 'boolean') { + discoveryEntry = { + type: 'switch', + object_id: endpoint ? + `switch_${firstExpose.name}_${endpoint}` : + `switch_${firstExpose.name}`, + discovery_payload: { + value_template: `{{ value_json.${firstExpose.property} }}`, + payload_on: firstExpose.value_on, + payload_off: firstExpose.value_off, + 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] = []; @@ -81,380 +474,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'}, - }; - - /** - * 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 (expose.access & ACCESS_SET && typeof expose.value_on !== 'boolean') { - discoveryEntry = { - type: 'switch', - object_id: expose.endpoint ? - `switch_${expose.name}_${expose.endpoint}` : - `switch_${expose.name}`, - discovery_payload: { - value_template: `{{ value_json.${expose.property} }}`, - payload_on: expose.value_on, - payload_off: expose.value_off, - command_topic: true, - command_topic_prefix: expose.endpoint ? expose.endpoint : undefined, - command_topic_postfix: expose.property, - ...(lookup[expose.name] || {}), - }, - }; - } else { - 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', 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: 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; @@ -466,11 +493,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 @@ -537,7 +568,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); } } @@ -559,17 +590,43 @@ 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', @@ -596,7 +653,7 @@ class HomeAssistant extends Extension { } } - if (resolvedEntity.settings.hasOwnProperty('legacy') && !resolvedEntity.settings.legacy) { + if (isDevice && resolvedEntity.settings.hasOwnProperty('legacy') && !resolvedEntity.settings.legacy) { configs = configs.filter((c) => c !== sensorClick); } @@ -624,9 +681,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; } @@ -805,11 +866,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) { @@ -833,19 +894,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]); } } @@ -884,13 +945,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) { @@ -926,8 +992,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) { @@ -956,7 +1023,7 @@ class HomeAssistant extends Extension { }, }; - const topic = this.getDiscoveryTopic(config, device); + const topic = this.getDiscoveryTopic(config, entity); const payload = { ...config.discovery_payload, subtype: value, diff --git a/test/bridge.test.js b/test/bridge.test.js index 18d9b1587..5c80ae76c 100644 --- a/test/bridge.test.js +++ b/test/bridge.test.js @@ -43,7 +43,7 @@ describe('Bridge', () => { const directory = settings.get().advanced.log_directory; expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bridge/info', - stringify({ "restart_required": false, "commit": version.commitHash, "config": { "advanced": { "adapter_concurrent": null, "adapter_delay": null, "availability_blacklist": [], "availability_blocklist": [], "availability_passlist": [], "availability_timeout": 0, "availability_whitelist": [], "cache_state": true, "cache_state_persistent": true, "cache_state_send_on_startup": true, "channel": 11, "elapsed": false, "ext_pan_id": [221, 221, 221, 221, 221, 221, 221, 221], "homeassistant_discovery_topic": "homeassistant", "homeassistant_legacy_entity_attributes": true, "homeassistant_legacy_triggers": true, "homeassistant_status_topic": "hass/status", "last_seen": "disable", "legacy_api": false, "log_directory": directory, "log_file": "log.txt", "log_level": "info", "log_output": ["console", "file"], "log_rotation": true, "log_symlink_current": false, "log_syslog": {}, "pan_id": 6754, "report": false, "soft_reset_timeout": 0, "timestamp_format": "YYYY-MM-DD HH:mm:ss" }, "ban": [], "blocklist": [], "device_options": {}, "devices": { "0x000b57fffec6a5b2": { "friendly_name": "bulb", "retain": true }, "0x000b57fffec6a5b3": { "friendly_name": "bulb_color", "retain": false }, "0x000b57fffec6a5b4": { "friendly_name": "bulb_color_2", "retain": false }, "0x000b57fffec6a5b7": { "friendly_name": "bulb_2", "retain": false }, "0x0017880104a44559": { "friendly_name": "J1_cover" }, "0x0017880104e43559": { "friendly_name": "U202DST600ZB" }, "0x0017880104e44559": { "friendly_name": "3157100_thermostat" }, "0x0017880104e45517": { "friendly_name": "remote", "retain": true }, "0x0017880104e45518": { "friendly_name": "0x0017880104e45518" }, "0x0017880104e45520": { "friendly_name": "button", "retain": false }, "0x0017880104e45521": { "friendly_name": "button_double_key", "retain": false }, "0x0017880104e45522": { "friendly_name": "weather_sensor", "qos": 1, "retain": false }, "0x0017880104e45523": { "friendly_name": "occupancy_sensor", "retain": false }, "0x0017880104e45524": { "friendly_name": "power_plug", "retain": false }, "0x0017880104e45526": { "friendly_name": "GL-S-007ZS" }, "0x0017880104e45529": { "friendly_name": "unsupported2", "retain": false }, "0x0017880104e45530": { "friendly_name": "button_double_key_interviewing", "retain": false }, "0x0017880104e45540": { "friendly_name": "ikea_onoff" }, "0x0017880104e45541": { "friendly_name": "wall_switch", "retain": false }, "0x0017880104e45542": { "friendly_name": "wall_switch_double", "retain": false }, "0x0017880104e45543": { "friendly_name": "led_controller_1", "retain": false }, "0x0017880104e45544": { "friendly_name": "led_controller_2", "retain": false }, "0x0017880104e45545": { "friendly_name": "dimmer_wall_switch", "retain": false }, "0x0017880104e45547": { "friendly_name": "curtain", "retain": false }, "0x0017880104e45548": { "friendly_name": "fan", "retain": false }, "0x0017880104e45549": { "friendly_name": "siren", "retain": false }, "0x0017880104e45550": { "friendly_name": "thermostat", "retain": false }, "0x0017880104e45551": { "friendly_name": "smart vent", "retain": false }, "0x0017880104e45552": { "friendly_name": "j1", "retain": false }, "0x0017880104e45553": { "friendly_name": "bulb_enddevice", "retain": false }, "0x0017880104e45559": { "friendly_name": "cc2530_router", "retain": false }, "0x0017880104e45560": { "friendly_name": "livolo", "retain": false }, "0x0017880104e45724":{"friendly_name":"GLEDOPTO_2ID"}, "0x0017882104a44559": { "friendly_name": "TS0601_thermostat" }, "0x0017882194e45543": { "friendly_name": "QS-Zigbee-D02-TRIAC-2C-LN" }, "0x90fd9ffffe4b64aa": { "friendly_name": "SP600_OLD" }, "0x90fd9ffffe4b64ab": { "friendly_name": "SP600_NEW" }, "0x90fd9ffffe4b64ac": { "friendly_name": "MKS-CM-W5" }, "0x90fd9ffffe4b64ae": { "friendly_name": "tradfri_remote", "retain": false }, "0x90fd9ffffe4b64af": { "friendly_name": "roller_shutter" }, "0x90fd9ffffe4b64ax": { "friendly_name": "ZNLDP12LM" }, "0x0017880104e45561": { "friendly_name": "temperature_sensor" }, "0x0017880104e45562": { "friendly_name": "heating_actuator" } }, "experimental": { "output": "json" }, "external_converters": [], "groups": { "1": { "friendly_name": "group_1", "retain": false }, "11": { "devices": ["bulb_2"], "friendly_name": "group_with_tradfri", "retain": false }, "14": { "devices": ["power_plug"], "friendly_name": "switch_group", "retain": false }, "12": { "devices": ["TS0601_thermostat"], "friendly_name": "thermostat_group", "retain": false }, "15071": { "devices": ["bulb_color_2", "bulb_2"], "friendly_name": "group_tradfri_remote", "retain": false }, "2": { "friendly_name": "group_2", "retain": false }, "21":{"devices":["GLEDOPTO_2ID/cct"],"friendly_name":"gledopto_group"} }, "homeassistant": false, "map_options": { "graphviz": { "colors": { "fill": { "coordinator": "#e04e5d", "enddevice": "#fff8ce", "router": "#4ea3e0" }, "font": { "coordinator": "#ffffff", "enddevice": "#000000", "router": "#ffffff" }, "line": { "active": "#009900", "inactive": "#994444" } } } }, "mqtt": { "base_topic": "zigbee2mqtt", "include_device_information": false, "server": "mqtt://localhost", "force_disable_retain": false, }, "ota":{"disable_automatic_update_check":false,"update_check_interval":1440}, "passlist": [], "permit_join": true, "serial": { "disable_led": false, "port": "/dev/dummy" }, "whitelist": [] }, "coordinator": { "meta": { "revision": 20190425, "version": 1 }, "type": "z-Stack" }, "log_level": "info", "network": { "channel": 15, "extended_pan_id": [0, 11, 22], "pan_id": 5674 }, "permit_join": false, "version": version.version, config_schema: settings.schema }), + stringify({ "restart_required": false, "commit": version.commitHash, "config": { "advanced": { "adapter_concurrent": null, "adapter_delay": null, "availability_blacklist": [], "availability_blocklist": [], "availability_passlist": [], "availability_timeout": 0, "availability_whitelist": [], "cache_state": true, "cache_state_persistent": true, "cache_state_send_on_startup": true, "channel": 11, "elapsed": false, "ext_pan_id": [221, 221, 221, 221, 221, 221, 221, 221], "homeassistant_discovery_topic": "homeassistant", "homeassistant_legacy_entity_attributes": true, "homeassistant_legacy_triggers": true, "homeassistant_status_topic": "hass/status", "last_seen": "disable", "legacy_api": false, "log_directory": directory, "log_file": "log.txt", "log_level": "info", "log_output": ["console", "file"], "log_rotation": true, "log_symlink_current": false, "log_syslog": {}, "pan_id": 6754, "report": false, "soft_reset_timeout": 0, "timestamp_format": "YYYY-MM-DD HH:mm:ss" }, "ban": [], "blocklist": [], "device_options": {}, "devices": { "0x000b57fffec6a5b2": { "friendly_name": "bulb", "retain": true }, "0x000b57fffec6a5b3": { "friendly_name": "bulb_color", "retain": false }, "0x000b57fffec6a5b4": { "friendly_name": "bulb_color_2", "retain": false }, "0x000b57fffec6a5b7": { "friendly_name": "bulb_2", "retain": false }, "0x0017880104a44559": { "friendly_name": "J1_cover" }, "0x0017880104e43559": { "friendly_name": "U202DST600ZB" }, "0x0017880104e44559": { "friendly_name": "3157100_thermostat" }, "0x0017880104e45517": { "friendly_name": "remote", "retain": true }, "0x0017880104e45518": { "friendly_name": "0x0017880104e45518" }, "0x0017880104e45520": { "friendly_name": "button", "retain": false }, "0x0017880104e45521": { "friendly_name": "button_double_key", "retain": false }, "0x0017880104e45522": { "friendly_name": "weather_sensor", "qos": 1, "retain": false }, "0x0017880104e45523": { "friendly_name": "occupancy_sensor", "retain": false }, "0x0017880104e45524": { "friendly_name": "power_plug", "retain": false }, "0x0017880104e45526": { "friendly_name": "GL-S-007ZS" }, "0x0017880104e45529": { "friendly_name": "unsupported2", "retain": false }, "0x0017880104e45530": { "friendly_name": "button_double_key_interviewing", "retain": false }, "0x0017880104e45540": { "friendly_name": "ikea_onoff" }, "0x0017880104e45541": { "friendly_name": "wall_switch", "retain": false }, "0x0017880104e45542": { "friendly_name": "wall_switch_double", "retain": false }, "0x0017880104e45543": { "friendly_name": "led_controller_1", "retain": false }, "0x0017880104e45544": { "friendly_name": "led_controller_2", "retain": false }, "0x0017880104e45545": { "friendly_name": "dimmer_wall_switch", "retain": false }, "0x0017880104e45547": { "friendly_name": "curtain", "retain": false }, "0x0017880104e45548": { "friendly_name": "fan", "retain": false }, "0x0017880104e45549": { "friendly_name": "siren", "retain": false }, "0x0017880104e45550": { "friendly_name": "thermostat", "retain": false }, "0x0017880104e45551": { "friendly_name": "smart vent", "retain": false }, "0x0017880104e45552": { "friendly_name": "j1", "retain": false }, "0x0017880104e45553": { "friendly_name": "bulb_enddevice", "retain": false }, "0x0017880104e45559": { "friendly_name": "cc2530_router", "retain": false }, "0x0017880104e45560": { "friendly_name": "livolo", "retain": false }, "0x0017880104e45724":{"friendly_name":"GLEDOPTO_2ID"}, "0x0017882104a44559": { "friendly_name": "TS0601_thermostat" }, "0x0017882194e45543": { "friendly_name": "QS-Zigbee-D02-TRIAC-2C-LN" }, "0x90fd9ffffe4b64aa": { "friendly_name": "SP600_OLD" }, "0x90fd9ffffe4b64ab": { "friendly_name": "SP600_NEW" }, "0x90fd9ffffe4b64ac": { "friendly_name": "MKS-CM-W5" }, "0x90fd9ffffe4b64ae": { "friendly_name": "tradfri_remote", "retain": false }, "0x90fd9ffffe4b64af": { "friendly_name": "roller_shutter" }, "0x90fd9ffffe4b64ax": { "friendly_name": "ZNLDP12LM" }, "0x0017880104e45561": { "friendly_name": "temperature_sensor" }, "0x0017880104e45562": { "friendly_name": "heating_actuator" } }, "experimental": { "output": "json" }, "external_converters": [], "groups": { "1": { "friendly_name": "group_1", "retain": false }, "9": { "devices": ['bulb_color_2', 'bulb_2', 'wall_switch_double/right'], "friendly_name": "ha_discovery_group"}, "11": { "devices": ["bulb_2"], "friendly_name": "group_with_tradfri", "retain": false }, "14": { "devices": ["power_plug"], "friendly_name": "switch_group", "retain": false }, "12": { "devices": ["TS0601_thermostat"], "friendly_name": "thermostat_group", "retain": false }, "15071": { "devices": ["bulb_color_2", "bulb_2"], "friendly_name": "group_tradfri_remote", "retain": false }, "2": { "friendly_name": "group_2", "retain": false }, "21":{"devices":["GLEDOPTO_2ID/cct"],"friendly_name":"gledopto_group"} }, "homeassistant": false, "map_options": { "graphviz": { "colors": { "fill": { "coordinator": "#e04e5d", "enddevice": "#fff8ce", "router": "#4ea3e0" }, "font": { "coordinator": "#ffffff", "enddevice": "#000000", "router": "#ffffff" }, "line": { "active": "#009900", "inactive": "#994444" } } } }, "mqtt": { "base_topic": "zigbee2mqtt", "include_device_information": false, "server": "mqtt://localhost", "force_disable_retain": false, }, "ota":{"disable_automatic_update_check":false,"update_check_interval":1440}, "passlist": [], "permit_join": true, "serial": { "disable_led": false, "port": "/dev/dummy" }, "whitelist": [] }, "coordinator": { "meta": { "revision": 20190425, "version": 1 }, "type": "z-Stack" }, "log_level": "info", "network": { "channel": 15, "extended_pan_id": [0, 11, 22], "pan_id": 5674 }, "permit_join": false, "version": version.version, config_schema: settings.schema }), { retain: true, qos: 0 }, expect.any(Function) ); @@ -88,7 +88,7 @@ describe('Bridge', () => { logger.setTransportsEnabled(true); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bridge/groups', - stringify([{"friendly_name":"group_1","id":1,"members":[]},{"friendly_name":"group_tradfri_remote","id":15071,"members":[]},{"friendly_name":99,"id":99,"members":[]},{"friendly_name":"group_with_tradfri","id":11,"members":[]},{"friendly_name":"thermostat_group","id":12,"members":[]},{"friendly_name":"switch_group","id":14,"members":[{"endpoint":1,"ieee_address":"0x0017880104e45524"}]},{"friendly_name":"gledopto_group","id":21,"members":[]},{"friendly_name":"default_bind_group","id":901,"members":[]},{"friendly_name":"group_2","id":2,"members":[]}]), + stringify([{"friendly_name":"group_1","id":1,"members":[]},{"friendly_name":"group_tradfri_remote","id":15071,"members":[]},{"friendly_name":99,"id":99,"members":[]},{"friendly_name":"group_with_tradfri","id":11,"members":[]},{"friendly_name":"thermostat_group","id":12,"members":[]},{"friendly_name":"switch_group","id":14,"members":[{"endpoint":1,"ieee_address":"0x0017880104e45524"}]},{"friendly_name":"gledopto_group","id":21,"members":[]},{"friendly_name":"default_bind_group","id":901,"members":[]},{"friendly_name":"ha_discovery_group","id":9,"members":[]},{"friendly_name":"group_2","id":2,"members":[]}]), { retain: true, qos: 0 }, expect.any(Function) ); @@ -659,13 +659,13 @@ describe('Bridge', () => { it('Should allow to add group with ID', async () => { MQTT.publish.mockClear(); - MQTT.events.message('zigbee2mqtt/bridge/request/group/add', stringify({friendly_name: "group_193", id: 9})); + MQTT.events.message('zigbee2mqtt/bridge/request/group/add', stringify({friendly_name: "group_193", id: 92})); await flushPromises(); - expect(settings.getGroup('group_193')).toStrictEqual({"ID": 9, "devices": [], "friendly_name": "group_193", "friendlyName": "group_193"}); + expect(settings.getGroup('group_193')).toStrictEqual({"ID": 92, "devices": [], "friendly_name": "group_193", "friendlyName": "group_193"}); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bridge/response/group/add', - stringify({"data":{"friendly_name":"group_193","id": 9},"status":"ok"}), + stringify({"data":{"friendly_name":"group_193","id": 92},"status":"ok"}), {retain: false, qos: 0}, expect.any(Function) ); }); diff --git a/test/group.test.js b/test/group.test.js index e17ec553f..f36e0b892 100644 --- a/test/group.test.js +++ b/test/group.test.js @@ -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 () => { diff --git a/test/homeassistant.test.js b/test/homeassistant.test.js index b66e926ce..76303eaca 100644 --- a/test/homeassistant.test.js +++ b/test/homeassistant.test.js @@ -44,13 +44,68 @@ 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', @@ -1614,4 +1669,44 @@ describe('HomeAssistant extension', () => { 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), + ); + }); }); diff --git a/test/legacy/bridgeLegacy.test.js b/test/legacy/bridgeLegacy.test.js index b87ec2750..1098cf51d 100644 --- a/test/legacy/bridgeLegacy.test.js +++ b/test/legacy/bridgeLegacy.test.js @@ -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 () => { diff --git a/test/publish.test.js b/test/publish.test.js index 9e51ea07c..1fb4d268c 100644 --- a/test/publish.test.js +++ b/test/publish.test.js @@ -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 () => { @@ -403,10 +403,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", {}, {}); }); @@ -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 () => { diff --git a/test/stub/data.js b/test/stub/data.js index 2da7890bd..da2537e0a 100644 --- a/test/stub/data.js +++ b/test/stub/data.js @@ -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: [], }; diff --git a/test/stub/zigbeeHerdsman.js b/test/stub/zigbeeHerdsman.js index 10f1354d5..6d2c6e5a9 100644 --- a/test/stub/zigbeeHerdsman.js +++ b/test/stub/zigbeeHerdsman.js @@ -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,