From c9ec166f4b79cd59f501b358eacf344d1f590943 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Mon, 8 Apr 2019 15:28:42 +0200 Subject: [PATCH] Add MQTT climate two-point target temperature support (#22860) * Add MQTT climate two-point target temperature support * Sort * Fix test --- homeassistant/components/mqtt/climate.py | 104 ++++++++++++++++++++++- tests/components/mqtt/test_climate.py | 69 ++++++++++++++- 2 files changed, 170 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 0f4229c8688..17d32984bb5 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -10,7 +10,10 @@ from homeassistant.components.climate.const import ( ATTR_OPERATION_MODE, DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, STATE_AUTO, STATE_COOL, STATE_DRY, STATE_FAN_ONLY, STATE_HEAT, SUPPORT_AUX_HEAT, SUPPORT_AWAY_MODE, SUPPORT_FAN_MODE, SUPPORT_HOLD_MODE, - SUPPORT_OPERATION_MODE, SUPPORT_SWING_MODE, SUPPORT_TARGET_TEMPERATURE) + SUPPORT_OPERATION_MODE, SUPPORT_SWING_MODE, SUPPORT_TARGET_TEMPERATURE, + ATTR_TARGET_TEMP_LOW, + ATTR_TARGET_TEMP_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW, + SUPPORT_TARGET_TEMPERATURE_HIGH) from homeassistant.components.fan import SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM from homeassistant.const import ( ATTR_TEMPERATURE, CONF_DEVICE, CONF_NAME, CONF_VALUE_TEMPLATE, STATE_OFF, @@ -41,6 +44,10 @@ CONF_MODE_STATE_TEMPLATE = 'mode_state_template' CONF_TEMPERATURE_COMMAND_TOPIC = 'temperature_command_topic' CONF_TEMPERATURE_STATE_TOPIC = 'temperature_state_topic' CONF_TEMPERATURE_STATE_TEMPLATE = 'temperature_state_template' +CONF_TEMPERATURE_LOW_COMMAND_TOPIC = 'temperature_low_command_topic' +CONF_TEMPERATURE_LOW_STATE_TOPIC = 'temperature_low_state_topic' +CONF_TEMPERATURE_HIGH_COMMAND_TOPIC = 'temperature_high_command_topic' +CONF_TEMPERATURE_HIGH_STATE_TOPIC = 'temperature_high_state_topic' CONF_FAN_MODE_COMMAND_TOPIC = 'fan_mode_command_topic' CONF_FAN_MODE_STATE_TOPIC = 'fan_mode_state_topic' CONF_FAN_MODE_STATE_TEMPLATE = 'fan_mode_state_template' @@ -130,6 +137,12 @@ PLATFORM_SCHEMA = SCHEMA_BASE.extend({ vol.Optional(CONF_TEMP_MAX, default=DEFAULT_MAX_TEMP): vol.Coerce(float), vol.Optional(CONF_TEMP_STEP, default=1.0): vol.Coerce(float), vol.Optional(CONF_TEMPERATURE_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_TEMPERATURE_HIGH_COMMAND_TOPIC): + mqtt.valid_publish_topic, + vol.Optional(CONF_TEMPERATURE_HIGH_STATE_TOPIC): + mqtt.valid_subscribe_topic, + vol.Optional(CONF_TEMPERATURE_LOW_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_TEMPERATURE_LOW_STATE_TOPIC): mqtt.valid_subscribe_topic, vol.Optional(CONF_TEMPERATURE_STATE_TEMPLATE): cv.template, vol.Optional(CONF_TEMPERATURE_STATE_TOPIC): mqtt.valid_subscribe_topic, vol.Optional(CONF_UNIQUE_ID): cv.string, @@ -186,6 +199,8 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._topic = None self._value_templates = None self._target_temperature = None + self._target_temperature_low = None + self._target_temperature_high = None self._current_fan_mode = None self._current_operation = None self._current_swing_mode = None @@ -230,6 +245,8 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, CONF_POWER_COMMAND_TOPIC, CONF_MODE_COMMAND_TOPIC, CONF_TEMPERATURE_COMMAND_TOPIC, + CONF_TEMPERATURE_LOW_COMMAND_TOPIC, + CONF_TEMPERATURE_HIGH_COMMAND_TOPIC, CONF_FAN_MODE_COMMAND_TOPIC, CONF_SWING_MODE_COMMAND_TOPIC, CONF_AWAY_MODE_COMMAND_TOPIC, @@ -238,6 +255,8 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, CONF_POWER_STATE_TOPIC, CONF_MODE_STATE_TOPIC, CONF_TEMPERATURE_STATE_TOPIC, + CONF_TEMPERATURE_LOW_STATE_TOPIC, + CONF_TEMPERATURE_HIGH_STATE_TOPIC, CONF_FAN_MODE_STATE_TOPIC, CONF_SWING_MODE_STATE_TOPIC, CONF_AWAY_MODE_STATE_TOPIC, @@ -250,8 +269,16 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, # set to None in non-optimistic mode self._target_temperature = self._current_fan_mode = \ self._current_operation = self._current_swing_mode = None + self._target_temperature_low = None + self._target_temperature_high = None + if self._topic[CONF_TEMPERATURE_STATE_TOPIC] is None: self._target_temperature = config[CONF_TEMP_INITIAL] + if self._topic[CONF_TEMPERATURE_LOW_STATE_TOPIC] is None: + self._target_temperature_low = config[CONF_TEMP_INITIAL] + if self._topic[CONF_TEMPERATURE_HIGH_STATE_TOPIC] is None: + self._target_temperature_high = config[CONF_TEMP_INITIAL] + if self._topic[CONF_FAN_MODE_STATE_TOPIC] is None: self._current_fan_mode = SPEED_LOW if self._topic[CONF_SWING_MODE_STATE_TOPIC] is None: @@ -339,6 +366,38 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, 'msg_callback': handle_temperature_received, 'qos': qos} + @callback + def handle_temperature_low_received(msg): + """Handle target temperature low coming via MQTT.""" + try: + self._target_temperature_low = float(msg.payload) + self.async_write_ha_state() + except ValueError: + _LOGGER.error("Could not parse low temperature from %s", + msg.payload) + + if self._topic[CONF_TEMPERATURE_LOW_STATE_TOPIC] is not None: + topics[CONF_TEMPERATURE_LOW_STATE_TOPIC] = { + 'topic': self._topic[CONF_TEMPERATURE_LOW_STATE_TOPIC], + 'msg_callback': handle_temperature_low_received, + 'qos': qos} + + @callback + def handle_temperature_high_received(msg): + """Handle target temperature high coming via MQTT.""" + try: + self._target_temperature_high = float(msg.payload) + self.async_write_ha_state() + except ValueError: + _LOGGER.error("Could not parse high temperature from %s", + msg.payload) + + if self._topic[CONF_TEMPERATURE_HIGH_STATE_TOPIC] is not None: + topics[CONF_TEMPERATURE_HIGH_STATE_TOPIC] = { + 'topic': self._topic[CONF_TEMPERATURE_HIGH_STATE_TOPIC], + 'msg_callback': handle_temperature_high_received, + 'qos': qos} + @callback def handle_fan_mode_received(msg): """Handle receiving fan mode via MQTT.""" @@ -498,6 +557,16 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, """Return the temperature we try to reach.""" return self._target_temperature + @property + def target_temperature_low(self): + """Return the low target temperature we try to reach.""" + return self._target_temperature_low + + @property + def target_temperature_high(self): + """Return the high target temperature we try to reach.""" + return self._target_temperature_high + @property def current_operation(self): """Return current operation ie. heat, cool, idle.""" @@ -556,6 +625,31 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, kwargs.get(ATTR_TEMPERATURE), self._config[CONF_QOS], self._config[CONF_RETAIN]) + if kwargs.get(ATTR_TARGET_TEMP_LOW) is not None: + if self._topic[CONF_TEMPERATURE_LOW_STATE_TOPIC] is None: + # optimistic mode + self._target_temperature_low = kwargs[ATTR_TARGET_TEMP_LOW] + + if (self._config[CONF_SEND_IF_OFF] or + self._current_operation != STATE_OFF): + mqtt.async_publish( + self.hass, self._topic[CONF_TEMPERATURE_LOW_COMMAND_TOPIC], + kwargs.get(ATTR_TARGET_TEMP_LOW), self._config[CONF_QOS], + self._config[CONF_RETAIN]) + + if kwargs.get(ATTR_TARGET_TEMP_HIGH) is not None: + if self._topic[CONF_TEMPERATURE_HIGH_STATE_TOPIC] is None: + # optimistic mode + self._target_temperature_high = kwargs[ATTR_TARGET_TEMP_HIGH] + + if (self._config[CONF_SEND_IF_OFF] or + self._current_operation != STATE_OFF): + mqtt.async_publish( + self.hass, + self._topic[CONF_TEMPERATURE_HIGH_COMMAND_TOPIC], + kwargs.get(ATTR_TARGET_TEMP_HIGH), self._config[CONF_QOS], + self._config[CONF_RETAIN]) + # Always optimistic? self.async_write_ha_state() @@ -691,6 +785,14 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, (self._topic[CONF_TEMPERATURE_COMMAND_TOPIC] is not None): support |= SUPPORT_TARGET_TEMPERATURE + if (self._topic[CONF_TEMPERATURE_LOW_STATE_TOPIC] is not None) or \ + (self._topic[CONF_TEMPERATURE_LOW_COMMAND_TOPIC] is not None): + support |= SUPPORT_TARGET_TEMPERATURE_LOW + + if (self._topic[CONF_TEMPERATURE_HIGH_STATE_TOPIC] is not None) or \ + (self._topic[CONF_TEMPERATURE_HIGH_COMMAND_TOPIC] is not None): + support |= SUPPORT_TARGET_TEMPERATURE_HIGH + if (self._topic[CONF_MODE_COMMAND_TOPIC] is not None) or \ (self._topic[CONF_MODE_STATE_TOPIC] is not None): support |= SUPPORT_OPERATION_MODE diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 7bdfe8f452f..a8e1ae6111e 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -15,7 +15,8 @@ from homeassistant.components.climate.const import ( SUPPORT_AUX_HEAT, SUPPORT_AWAY_MODE, SUPPORT_FAN_MODE, SUPPORT_HOLD_MODE, SUPPORT_OPERATION_MODE, SUPPORT_SWING_MODE, SUPPORT_TARGET_TEMPERATURE, STATE_AUTO, - STATE_COOL, STATE_HEAT, STATE_DRY, STATE_FAN_ONLY) + STATE_COOL, STATE_HEAT, STATE_DRY, STATE_FAN_ONLY, + SUPPORT_TARGET_TEMPERATURE_LOW, SUPPORT_TARGET_TEMPERATURE_HIGH) from homeassistant.components.mqtt.discovery import async_start from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE from homeassistant.setup import setup_component @@ -35,6 +36,8 @@ DEFAULT_CONFIG = { 'name': 'test', 'mode_command_topic': 'mode-topic', 'temperature_command_topic': 'temperature-topic', + 'temperature_low_command_topic': 'temperature-low-topic', + 'temperature_high_command_topic': 'temperature-high-topic', 'fan_mode_command_topic': 'fan-mode-topic', 'swing_mode_command_topic': 'swing-mode-topic', 'away_mode_command_topic': 'away-mode-topic', @@ -75,7 +78,9 @@ class TestMQTTClimate(unittest.TestCase): state = self.hass.states.get(ENTITY_CLIMATE) support = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE | SUPPORT_SWING_MODE | SUPPORT_FAN_MODE | SUPPORT_AWAY_MODE | - SUPPORT_HOLD_MODE | SUPPORT_AUX_HEAT) + SUPPORT_HOLD_MODE | SUPPORT_AUX_HEAT | + SUPPORT_TARGET_TEMPERATURE_LOW | + SUPPORT_TARGET_TEMPERATURE_HIGH) assert state.attributes.get("supported_features") == support @@ -341,6 +346,66 @@ class TestMQTTClimate(unittest.TestCase): state = self.hass.states.get(ENTITY_CLIMATE) assert 1701 == state.attributes.get('temperature') + def test_set_target_temperature_low_high(self): + """Test setting the low/high target temperature.""" + assert setup_component(self.hass, CLIMATE_DOMAIN, DEFAULT_CONFIG) + + common.set_temperature(self.hass, target_temp_low=20, + target_temp_high=23, + entity_id=ENTITY_CLIMATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + print(state.attributes) + assert 20 == state.attributes.get('target_temp_low') + assert 23 == state.attributes.get('target_temp_high') + self.mock_publish.async_publish.assert_any_call( + 'temperature-low-topic', 20, 0, False) + self.mock_publish.async_publish.assert_any_call( + 'temperature-high-topic', 23, 0, False) + + def test_set_target_temperature_low_highpessimistic(self): + """Test setting the low/high target temperature.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config['climate']['temperature_low_state_topic'] = \ + 'temperature-low-state' + config['climate']['temperature_high_state_topic'] = \ + 'temperature-high-state' + assert setup_component(self.hass, CLIMATE_DOMAIN, config) + + state = self.hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get('target_temp_low') is None + assert state.attributes.get('target_temp_high') is None + self.hass.block_till_done() + common.set_temperature(self.hass, target_temp_low=20, + target_temp_high=23, + entity_id=ENTITY_CLIMATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get('target_temp_low') is None + assert state.attributes.get('target_temp_high') is None + + fire_mqtt_message(self.hass, 'temperature-low-state', '1701') + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + assert 1701 == state.attributes.get('target_temp_low') + assert state.attributes.get('target_temp_high') is None + + fire_mqtt_message(self.hass, 'temperature-high-state', '1703') + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + assert 1701 == state.attributes.get('target_temp_low') + assert 1703 == state.attributes.get('target_temp_high') + + fire_mqtt_message(self.hass, 'temperature-low-state', 'not a number') + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + assert 1701 == state.attributes.get('target_temp_low') + + fire_mqtt_message(self.hass, 'temperature-high-state', 'not a number') + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + assert 1703 == state.attributes.get('target_temp_high') + def test_receive_mqtt_temperature(self): """Test getting the current temperature via MQTT.""" config = copy.deepcopy(DEFAULT_CONFIG)