From c27e66b6180e98581eb669cb1a0c43a3de9528d6 Mon Sep 17 00:00:00 2001 From: Koen Kanters Date: Wed, 4 Nov 2020 22:33:00 +0100 Subject: [PATCH] Changes for exposes. --- lib/extension/bridge.js | 3 +- lib/extension/homeassistant.js | 319 +++++++-------------------------- test/homeassistant.test.js | 13 -- 3 files changed, 70 insertions(+), 265 deletions(-) diff --git a/lib/extension/bridge.js b/lib/extension/bridge.js index 043361903..81c368446 100644 --- a/lib/extension/bridge.js +++ b/lib/extension/bridge.js @@ -527,8 +527,7 @@ class Bridge extends Extension { model: definition.model, vendor: definition.vendor, description: definition.description, - [definition.hasOwnProperty('exposes') ? 'exposes' : 'supports']: - definition.hasOwnProperty('exposes') ? definition.exposes : definition.supports, + exposes: definition.exposes, }; } else { return null; diff --git a/lib/extension/homeassistant.js b/lib/extension/homeassistant.js index ed83a84ee..ced4ce859 100644 --- a/lib/extension/homeassistant.js +++ b/lib/extension/homeassistant.js @@ -9,26 +9,6 @@ const assert = require('assert'); const cfg = { // Binary sensor - 'binary_sensor_water_leak': { - type: 'binary_sensor', - object_id: 'water_leak', - discovery_payload: { - payload_on: true, - payload_off: false, - value_template: '{{ value_json.water_leak }}', - device_class: 'moisture', - }, - }, - 'binary_sensor_battery_low': { - type: 'binary_sensor', - object_id: 'battery_low', - discovery_payload: { - payload_on: true, - payload_off: false, - value_template: '{{ value_json.battery_low}}', - device_class: 'battery', - }, - }, 'binary_sensor_update_available': { type: 'binary_sensor', object_id: 'update_available', @@ -48,15 +28,6 @@ const cfg = { value_template: `{{ value_json['update']['state'] }}`, }, }, - 'sensor_local_temperature': { - type: 'sensor', - object_id: 'local_temperature', - discovery_payload: { - unit_of_measurement: '°C', - device_class: 'temperature', - value_template: '{{ value_json.local_temperature }}', - }, - }, 'sensor_click': { type: 'sensor', object_id: 'click', @@ -74,15 +45,6 @@ const cfg = { value_template: '{{ value_json.brightness }}', }, }, - 'sensor_battery': { - type: 'sensor', - object_id: 'battery', - discovery_payload: { - unit_of_measurement: '%', - device_class: 'battery', - value_template: '{{ value_json.battery }}', - }, - }, 'sensor_linkquality': { type: 'sensor', object_id: 'linkquality', @@ -93,65 +55,6 @@ const cfg = { }, }, - // Switch - 'switch_window_detection': { - type: 'switch', - object_id: 'window_detection', - discovery_payload: { - state_topic: true, - command_topic: true, - command_topic_postfix: 'window_detection', - payload_off: 'OFF', - payload_on: 'ON', - state_off: 'OFF', - state_on: 'ON', - value_template: '{{ value_json.window_detection }}', - icon: 'mdi:window-open-variant', - }, - }, - 'switch_valve_detection': { - type: 'switch', - object_id: 'valve_detection', - discovery_payload: { - state_topic: true, - command_topic: true, - command_topic_postfix: 'valve_detection', - payload_off: 'OFF', - payload_on: 'ON', - state_off: 'OFF', - state_on: 'ON', - value_template: '{{ value_json.valve_detection }}', - }, - }, - - // Lock - 'lock_keypad_lockout': { - type: 'lock', - object_id: 'keypad_lock', - discovery_payload: { - state_topic: true, - command_topic: true, - command_topic_postfix: 'keypad_lockout', - payload_unlock: '0', - payload_lock: '1', - value_template: '{{ value_json.keypad_lockout }}', - }, - }, - 'lock_child_lock': { - type: 'lock', - object_id: 'child_lock', - discovery_payload: { - state_topic: true, - command_topic: true, - command_topic_postfix: 'child_lock', - payload_lock: 'LOCK', - payload_unlock: 'UNLOCK', - state_locked: 'LOCKED', - state_unlocked: 'UNLOCKED', - value_template: '{{ value_json.child_lock }}', - }, - }, - // Trigger 'trigger_action': { type: 'device_automation', @@ -169,146 +72,6 @@ const cfg = { }, }; -/* istanbul ignore next */ -const climate = (minTemp=7, maxTemp=30, temperatureStateProperty='occupied_heating_setpoint', - tempStep=1, systemModes=['off', 'auto', 'heat'], fanModes=[], holdModes=[], - temperatureLowStateTopic=false, temperatureHighStateTopic=false, endpoint=null ) => { - const jsonProperty = (key) => `value_json.${key}${endpoint ? `_${endpoint}` : ''}`; - const retVal = { - type: 'climate', - object_id: endpoint ? `climate_${endpoint}` : 'climate', - discovery_payload: { - state_topic: false, - temperature_unit: 'C', - min_temp: `${minTemp}`, - max_temp: `${maxTemp}`, - mode_state_topic: true, - mode_state_template: `{{ ${jsonProperty('system_mode')} }}`, - mode_command_topic: true, - current_temperature_topic: true, - current_temperature_template: `{{ ${jsonProperty('local_temperature')} }}`, - temp_step: tempStep, - action_topic: true, - action_template: - '{% set values = {\'idle\':\'off\',\'heat\':\'heating\',\'cool\':\'cooling\',\'fan only\':\'fan\'}'+ - ` %}{{ values[${jsonProperty('running_state')}] }}`, - }, - }; - - if (endpoint) { - retVal.discovery_payload.state_topic_postfix = endpoint; - } - - // system_modes empty <=> use auto (in other case ha ui is showing all modes) - if (systemModes.length > 0) { - retVal.discovery_payload.modes = systemModes; - } else { - retVal.discovery_payload.modes = ['auto']; - } - // hold_modes empty <=> don't use presets - if (holdModes.length > 0) { - // NOTE: Preset 'none' will be added as first item on HA side `mqtt/climate.py preset_modes()` - const indexOfNone = holdModes.indexOf('none'); - if (indexOfNone > -1) holdModes.splice(indexOfNone, 1); - - // HA has special behaviour for the away mode - // https://github.com/Koenkk/zigbee2mqtt/pull/4491#issuecomment-701550476 - const indexOfAway = holdModes.indexOf('away'); - /* istanbul ignore else */ - if (indexOfAway > -1) { - holdModes.splice(indexOfAway, 1); // HA will add "Away" to modes by itself - retVal.discovery_payload.away_mode_command_topic = true; - retVal.discovery_payload.away_mode_state_topic = true; - retVal.discovery_payload.away_mode_state_template = - `{{ ${jsonProperty('away_mode')} }}`; - } - - if (holdModes.length > 0) { // || indexOfAway > -1) { - retVal.discovery_payload.hold_modes = holdModes; - retVal.discovery_payload.hold_command_topic = true; - retVal.discovery_payload.hold_state_template = `{{ ${jsonProperty('preset')} }}`; - retVal.discovery_payload.hold_state_topic = true; - } - } - // fan_modes empty <=> don't use fan modes - if (fanModes.length > 0) { - retVal.discovery_payload.fan_modes = fanModes; - retVal.discovery_payload.fan_mode_command_topic = true; - retVal.discovery_payload.fan_mode_state_template = `{{ ${jsonProperty('fan_mode')} }}`; - retVal.discovery_payload.fan_mode_state_topic = true; - } - // if no high and low temp used then use temperature_state_topic - if (!temperatureHighStateTopic && !temperatureLowStateTopic) { - retVal.discovery_payload.temperature_state_topic = true; - retVal.discovery_payload.temperature_state_template = `{{ ${jsonProperty(temperatureStateProperty)} }}`; - retVal.discovery_payload.temperature_command_topic = temperatureStateProperty; - } - // use low target temperature - if (temperatureLowStateTopic) { - retVal.discovery_payload.temperature_low_state_topic = temperatureLowStateTopic; - retVal.discovery_payload.temperature_low_state_template = `{{ ${jsonProperty('occupied_heating_setpoint')} }}`; - retVal.discovery_payload.temperature_low_command_topic = 'occupied_heating_setpoint'; - } - // use high target temperature - if (temperatureHighStateTopic) { - retVal.discovery_payload.temperature_high_state_topic = temperatureHighStateTopic; - retVal.discovery_payload.temperature_high_state_template = `{{ ${jsonProperty('occupied_cooling_setpoint')} }}`; - retVal.discovery_payload.temperature_high_command_topic = 'occupied_cooling_setpoint'; - } - return retVal; -}; - - -// Map Home Assistant configurations to devices. -const manualMaping = { - 'GS361A-H04': [ - cfg.lock_child_lock, - cfg.switch_window_detection, - cfg.switch_valve_detection, - climate(5, 30, 'current_heating_setpoint', 0.5, ['off', 'auto', 'heat', 'manual']), - cfg.sensor_battery, - ], - '3157100': [climate(10, 30, 'occupied_heating_setpoint', 1, ['off', 'heat', 'cool'], - ['auto', 'on'], [], true, true), cfg.sensor_battery], - 'RC-2000WH': [climate(10, 30, 'occupied_heating_setpoint', 1, ['off', 'auto', 'heat', 'cool'], - ['auto', 'on', 'smart'], [], true, true)], - 'TS0601_thermostat': [ - cfg.lock_child_lock, cfg.switch_window_detection, cfg.switch_valve_detection, cfg.sensor_battery, - climate(5, 30, 'current_heating_setpoint', 0.5, [], [], - ['schedule', 'manual', 'away', 'boost', 'complex', 'comfort', 'eco']), - ], - 'HT-08': [ - cfg.lock_child_lock, - climate(5, 35, 'current_heating_setpoint', 0.5, - ['off', 'heat', 'auto'], [], ['none', 'away']), - ], - 'HT-10': [ - cfg.lock_child_lock, cfg.binary_sensor_battery_low, - climate(5, 35, 'current_heating_setpoint', 0.5, - ['off', 'heat', 'auto'], [], ['none', 'away']), - ], - '07703L': [ - cfg.lock_child_lock, cfg.binary_sensor_battery_low, - climate(5, 35, 'current_heating_setpoint', 0.5, - ['off', 'heat', 'auto'], [], ['none', 'away']), - ], - 'BHT-002-GCLZB': [ - cfg.lock_child_lock, climate(5, 30, 'current_heating_setpoint', 1, ['off', 'heat'], [], ['hold', 'program']), - ], - 'SLR2': [ - climate(7, 30, 'occupied_heating_setpoint', 1, ['off', 'auto', 'heat'], [], [], false, false, 'heat'), - climate(7, 30, 'occupied_heating_setpoint', 1, ['off', 'auto', 'heat'], [], [], false, false, 'cool'), - ], - 'SEA801-Zigbee': [ - cfg.binary_sensor_battery_low, - climate(5, 30, 'current_heating_setpoint', 0.5, ['off', 'heat'], [], ['manual', 'auto']), - ], - 'SEA802-Zigbee': [ - cfg.binary_sensor_battery_low, - climate(5, 30, 'current_heating_setpoint', 0.5, ['off', 'heat'], [], ['manual', 'auto']), - ], -}; - const defaultStatusTopic = 'homeassistant/status'; /** @@ -348,7 +111,6 @@ class HomeAssistant extends Extension { populateMapping() { for (const def of zigbeeHerdsmanConverters.definitions) { if (def.hasOwnProperty('exposes')) { - assert(!manualMaping.hasOwnProperty(def.model), `'${def.model}' has manual mapping and exposes`); this.mapping[def.model] = []; if (['WXKG01LM', 'HS1EB/HS1EB-E', 'ICZB-KPD14S', 'TERNCY-SD01', 'TERNCY-PP01', 'ICZB-KPD18S', @@ -390,19 +152,31 @@ class HomeAssistant extends Extension { 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: 'OFF', payload_on: 'ON', - value_template: `{{ value_json.state${expose.endpoint ? `_${expose.endpoint}` : ''} }}`, + value_template: `{{ value_json.${state.property} }}`, command_topic: true, command_topic_prefix: expose.endpoint ? expose.endpoint : undefined, }, }; + + if (state.property === 'valve_detection' || state.property === 'window_detection') { + discoveryEntry.discovery_payload.command_topic_postfix = state.property; + discoveryEntry.discovery_payload.state_off = 'OFF'; + discoveryEntry.discovery_payload.state_on = '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') { - assert(!expose.endpoint, `Endpoint not supported for climate type`); const setpointProperties = ['occupied_heating_setpoint', 'current_heating_setpoint']; const setpoint = expose.features.find((f) => setpointProperties.includes(f.name)); assert(setpoint, 'No setpoint found'); @@ -424,9 +198,6 @@ class HomeAssistant extends Extension { temp_step: setpoint.value_step, min_temp: setpoint.value_min.toString(), max_temp: setpoint.value_max.toString(), - temperature_command_topic: setpoint.property, - temperature_state_template: `{{ value_json.${setpoint.property} }}`, - temperature_state_topic: true, // Temperature current_temperature_topic: true, current_temperature_template: `{{ value_json.${temperature.property} }}`, @@ -442,6 +213,53 @@ class HomeAssistant extends Extension { ` %}{{ 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'); @@ -461,6 +279,14 @@ class HomeAssistant extends Extension { 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 = 'LOCKED'; + discoveryEntry.discovery_payload.state_unlocked = 'UNLOCKED'; + 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; @@ -613,8 +439,6 @@ class HomeAssistant extends Extension { this.mapping[def.model].push(discoveryEntry); } } - } else if (manualMaping.hasOwnProperty(def.model)) { - this.mapping[def.model] = manualMaping[def.model]; } else { logger.warn(`Supported device '${def.model}' has no Home Assistant mapping`); } @@ -765,11 +589,6 @@ class HomeAssistant extends Extension { if (!resolvedEntity || !resolvedEntity.definition || !this.mapping[resolvedEntity.definition.model]) return []; let configs = this.mapping[resolvedEntity.definition.model].slice(); - if (!resolvedEntity.definition.hasOwnProperty('exposes')) { - // Exposes already has linkquality. - configs.push(cfg.sensor_linkquality); - } - if (resolvedEntity.definition.hasOwnProperty('ota')) { if (this.legacyApi) { configs.push(cfg.binary_sensor_update_available); diff --git a/test/homeassistant.test.js b/test/homeassistant.test.js index 5ba1754fe..43ef84528 100644 --- a/test/homeassistant.test.js +++ b/test/homeassistant.test.js @@ -22,19 +22,6 @@ describe('HomeAssistant extension', () => { settings.set(['homeassistant'], true); }); - it('Should have mapping for all devices supported by zigbee-herdsman-converters', () => { - const missing = []; - const ha = new HomeAssistant(null, null, null, null, {on: () => {}}); - - require('zigbee-herdsman-converters').definitions.forEach((d) => { - if (!d.hasOwnProperty('exposes') && !ha._getMapping()[d.model]) { - missing.push(d.model); - } - }); - - expect(missing).toHaveLength(0); - }); - it('Should not have duplicate type/object_ids in a mapping', () => { const duplicated = []; const ha = new HomeAssistant(null, null, null, null, {on: () => {}});