From 799d527fb5872f438390fa8c5d9df39d972b5ee1 Mon Sep 17 00:00:00 2001 From: Mike K Date: Tue, 3 Jan 2023 23:57:20 +0200 Subject: [PATCH] Add MQTT climate setting for current humidity (#84592) * MQTT Climate: Add support for setting the current humidity via MQTT * MQTT Climate: Add configuration constants related to setting the target humidity * MQTT Climate: Add support for setting the humidity's state topic & template * MQTT Climate: Add support for setting the initial humidity * MQTT Climate: Add support for setting the humidity's command topic & template * MQTT Climate: Add support for setting the min/max humidity * MQTT Climate: Fix style & tests * MQTT Climate: Set the initial humidity to None * MQTT Climate: Rename _set_mqtt_attribute to _set_climate_attribute and handle_temperature_received to handle_climate_attribute_received * MQTT Climate: Copy humidity range validation from MQTT Humidifier * MQTT Climate: Remove CONF_HUMIDITY_INITIAL * MQTT Climate: Only enable support for TARGET_HUMIDITY when the command topic is set * MQTT Climate: Check if setting the target humidity is supported before actually setting it * MQTT Climate: Make sure that CONF_HUMIDITY_COMMAND_TOPIC has been configured when setting CONF_HUMIDITY_STATE_TOPIC * MQTT Climate: Fix broken tests * MQTT Climate: Add test for optimistically setting the target humidity * MQTT Climate: Remove references to "temperature" in handle_climate_attribute_received * MQTT Climate: Add additional humidity-related tests * MQTT Climate: Remove supported feature check in handle_target_humidity_received It's not needed because this is covered by the `valid_humidity_state_configuration` validation. Co-authored-by: Jan Bouwhuis * MQTT Climate: Remove supported feature check in async_set_humidity It is covered by the base Climate entity. Co-authored-by: Jan Bouwhuis Co-authored-by: Jan Bouwhuis --- .../components/mqtt/abbreviations.py | 2 + homeassistant/components/mqtt/climate.py | 120 ++++++++- tests/components/mqtt/test_climate.py | 235 +++++++++++++++++- 3 files changed, 344 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 68a19015414..7a974e44b27 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -43,6 +43,8 @@ ABBREVIATIONS = { "cod_arm_req": "code_arm_required", "cod_dis_req": "code_disarm_required", "cod_trig_req": "code_trigger_required", + "curr_hum_t": "current_humidity_topic", + "curr_hum_tpl": "current_humidity_template", "curr_temp_t": "current_temperature_topic", "curr_temp_tpl": "current_temperature_template", "dev": "device", diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 5b38a34e85e..c8a50b523e8 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -13,7 +13,9 @@ from homeassistant.components.climate import ( ATTR_HVAC_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, + DEFAULT_MAX_HUMIDITY, DEFAULT_MAX_TEMP, + DEFAULT_MIN_HUMIDITY, DEFAULT_MIN_TEMP, FAN_AUTO, FAN_HIGH, @@ -85,6 +87,8 @@ CONF_AWAY_MODE_COMMAND_TOPIC = "away_mode_command_topic" CONF_AWAY_MODE_STATE_TEMPLATE = "away_mode_state_template" CONF_AWAY_MODE_STATE_TOPIC = "away_mode_state_topic" +CONF_CURRENT_HUMIDITY_TEMPLATE = "current_humidity_template" +CONF_CURRENT_HUMIDITY_TOPIC = "current_humidity_topic" CONF_CURRENT_TEMP_TEMPLATE = "current_temperature_template" CONF_CURRENT_TEMP_TOPIC = "current_temperature_topic" CONF_FAN_MODE_COMMAND_TEMPLATE = "fan_mode_command_template" @@ -99,6 +103,12 @@ CONF_HOLD_STATE_TEMPLATE = "hold_state_template" CONF_HOLD_STATE_TOPIC = "hold_state_topic" CONF_HOLD_LIST = "hold_modes" +CONF_HUMIDITY_COMMAND_TEMPLATE = "target_humidity_command_template" +CONF_HUMIDITY_COMMAND_TOPIC = "target_humidity_command_topic" +CONF_HUMIDITY_STATE_TEMPLATE = "target_humidity_state_template" +CONF_HUMIDITY_STATE_TOPIC = "target_humidity_state_topic" +CONF_HUMIDITY_MAX = "max_humidity" +CONF_HUMIDITY_MIN = "min_humidity" CONF_MODE_COMMAND_TEMPLATE = "mode_command_template" CONF_MODE_COMMAND_TOPIC = "mode_command_topic" CONF_MODE_LIST = "modes" @@ -164,8 +174,10 @@ MQTT_CLIMATE_ATTRIBUTES_BLOCKED = frozenset( VALUE_TEMPLATE_KEYS = ( CONF_AUX_STATE_TEMPLATE, + CONF_CURRENT_HUMIDITY_TEMPLATE, CONF_CURRENT_TEMP_TEMPLATE, CONF_FAN_MODE_STATE_TEMPLATE, + CONF_HUMIDITY_STATE_TEMPLATE, CONF_MODE_STATE_TEMPLATE, CONF_POWER_STATE_TEMPLATE, CONF_ACTION_TEMPLATE, @@ -178,6 +190,7 @@ VALUE_TEMPLATE_KEYS = ( COMMAND_TEMPLATE_KEYS = { CONF_FAN_MODE_COMMAND_TEMPLATE, + CONF_HUMIDITY_COMMAND_TEMPLATE, CONF_MODE_COMMAND_TEMPLATE, CONF_PRESET_MODE_COMMAND_TEMPLATE, CONF_SWING_MODE_COMMAND_TEMPLATE, @@ -191,9 +204,12 @@ TOPIC_KEYS = ( CONF_ACTION_TOPIC, CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC, + CONF_CURRENT_HUMIDITY_TOPIC, CONF_CURRENT_TEMP_TOPIC, CONF_FAN_MODE_COMMAND_TOPIC, CONF_FAN_MODE_STATE_TOPIC, + CONF_HUMIDITY_COMMAND_TOPIC, + CONF_HUMIDITY_STATE_TOPIC, CONF_MODE_COMMAND_TOPIC, CONF_MODE_STATE_TOPIC, CONF_POWER_COMMAND_TOPIC, @@ -218,11 +234,36 @@ def valid_preset_mode_configuration(config: ConfigType) -> ConfigType: return config +def valid_humidity_range_configuration(config: ConfigType) -> ConfigType: + """Validate that the target_humidity range configuration is valid, throws if it isn't.""" + if config[CONF_HUMIDITY_MIN] >= config[CONF_HUMIDITY_MAX]: + raise ValueError("target_humidity_max must be > target_humidity_min") + if config[CONF_HUMIDITY_MAX] > 100: + raise ValueError("max_humidity must be <= 100") + + return config + + +def valid_humidity_state_configuration(config: ConfigType) -> ConfigType: + """Validate that if CONF_HUMIDITY_STATE_TOPIC is set then CONF_HUMIDITY_COMMAND_TOPIC is also set.""" + if ( + CONF_HUMIDITY_STATE_TOPIC in config + and CONF_HUMIDITY_COMMAND_TOPIC not in config + ): + raise ValueError( + f"{CONF_HUMIDITY_STATE_TOPIC} cannot be used without {CONF_HUMIDITY_COMMAND_TOPIC}" + ) + + return config + + _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( { vol.Optional(CONF_AUX_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_AUX_STATE_TEMPLATE): cv.template, vol.Optional(CONF_AUX_STATE_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_CURRENT_HUMIDITY_TEMPLATE): cv.template, + vol.Optional(CONF_CURRENT_HUMIDITY_TOPIC): valid_subscribe_topic, vol.Optional(CONF_CURRENT_TEMP_TEMPLATE): cv.template, vol.Optional(CONF_CURRENT_TEMP_TOPIC): valid_subscribe_topic, vol.Optional(CONF_FAN_MODE_COMMAND_TEMPLATE): cv.template, @@ -233,6 +274,16 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( ): cv.ensure_list, vol.Optional(CONF_FAN_MODE_STATE_TEMPLATE): cv.template, vol.Optional(CONF_FAN_MODE_STATE_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_HUMIDITY_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_HUMIDITY_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_HUMIDITY_MIN, default=DEFAULT_MIN_HUMIDITY): vol.Coerce( + float + ), + vol.Optional(CONF_HUMIDITY_MAX, default=DEFAULT_MAX_HUMIDITY): vol.Coerce( + float + ), + vol.Optional(CONF_HUMIDITY_STATE_TEMPLATE): cv.template, + vol.Optional(CONF_HUMIDITY_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_MODE_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_MODE_COMMAND_TOPIC): valid_publish_topic, vol.Optional( @@ -313,6 +364,8 @@ PLATFORM_SCHEMA_MODERN = vol.All( cv.removed(CONF_HOLD_LIST), _PLATFORM_SCHEMA_BASE, valid_preset_mode_configuration, + valid_humidity_range_configuration, + valid_humidity_state_configuration, ) # Configuring MQTT Climate under the climate platform key was deprecated in HA Core 2022.6 @@ -337,6 +390,8 @@ DISCOVERY_SCHEMA = vol.All( cv.removed(CONF_HOLD_STATE_TOPIC), cv.removed(CONF_HOLD_LIST), valid_preset_mode_configuration, + valid_humidity_range_configuration, + valid_humidity_state_configuration, ) @@ -396,6 +451,8 @@ class MqttClimate(MqttEntity, ClimateEntity): self._attr_hvac_modes = config[CONF_MODE_LIST] self._attr_min_temp = config[CONF_TEMP_MIN] self._attr_max_temp = config[CONF_TEMP_MAX] + self._attr_min_humidity = config[CONF_HUMIDITY_MIN] + self._attr_max_humidity = config[CONF_HUMIDITY_MAX] self._attr_precision = config.get(CONF_PRECISION, super().precision) self._attr_fan_modes = config[CONF_FAN_MODE_LIST] self._attr_swing_modes = config[CONF_SWING_MODE_LIST] @@ -485,6 +542,9 @@ class MqttClimate(MqttEntity, ClimateEntity): ): support |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + if self._topic[CONF_HUMIDITY_COMMAND_TOPIC] is not None: + support |= ClimateEntityFeature.TARGET_HUMIDITY + if (self._topic[CONF_FAN_MODE_STATE_TOPIC] is not None) or ( self._topic[CONF_FAN_MODE_COMMAND_TOPIC] is not None ): @@ -554,23 +614,23 @@ class MqttClimate(MqttEntity, ClimateEntity): add_subscription(topics, CONF_ACTION_TOPIC, handle_action_received) @callback - def handle_temperature_received( + def handle_climate_attribute_received( msg: ReceiveMessage, template_name: str, attr: str ) -> None: - """Handle temperature coming via MQTT.""" + """Handle climate attributes coming via MQTT.""" payload = render_template(msg, template_name) try: setattr(self, attr, float(payload)) get_mqtt_data(self.hass).state_write_requests.write_state_request(self) except ValueError: - _LOGGER.error("Could not parse temperature from %s", payload) + _LOGGER.error("Could not parse %s from %s", template_name, payload) @callback @log_messages(self.hass, self.entity_id) def handle_current_temperature_received(msg: ReceiveMessage) -> None: """Handle current temperature coming via MQTT.""" - handle_temperature_received( + handle_climate_attribute_received( msg, CONF_CURRENT_TEMP_TEMPLATE, "_attr_current_temperature" ) @@ -582,7 +642,7 @@ class MqttClimate(MqttEntity, ClimateEntity): @log_messages(self.hass, self.entity_id) def handle_target_temperature_received(msg: ReceiveMessage) -> None: """Handle target temperature coming via MQTT.""" - handle_temperature_received( + handle_climate_attribute_received( msg, CONF_TEMP_STATE_TEMPLATE, "_attr_target_temperature" ) @@ -594,7 +654,7 @@ class MqttClimate(MqttEntity, ClimateEntity): @log_messages(self.hass, self.entity_id) def handle_temperature_low_received(msg: ReceiveMessage) -> None: """Handle target temperature low coming via MQTT.""" - handle_temperature_received( + handle_climate_attribute_received( msg, CONF_TEMP_LOW_STATE_TEMPLATE, "_attr_target_temperature_low" ) @@ -606,7 +666,7 @@ class MqttClimate(MqttEntity, ClimateEntity): @log_messages(self.hass, self.entity_id) def handle_temperature_high_received(msg: ReceiveMessage) -> None: """Handle target temperature high coming via MQTT.""" - handle_temperature_received( + handle_climate_attribute_received( msg, CONF_TEMP_HIGH_STATE_TEMPLATE, "_attr_target_temperature_high" ) @@ -614,6 +674,31 @@ class MqttClimate(MqttEntity, ClimateEntity): topics, CONF_TEMP_HIGH_STATE_TOPIC, handle_temperature_high_received ) + @callback + @log_messages(self.hass, self.entity_id) + def handle_current_humidity_received(msg: ReceiveMessage) -> None: + """Handle current humidity coming via MQTT.""" + handle_climate_attribute_received( + msg, CONF_CURRENT_HUMIDITY_TEMPLATE, "_attr_current_humidity" + ) + + add_subscription( + topics, CONF_CURRENT_HUMIDITY_TOPIC, handle_current_humidity_received + ) + + @callback + @log_messages(self.hass, self.entity_id) + def handle_target_humidity_received(msg: ReceiveMessage) -> None: + """Handle target humidity coming via MQTT.""" + + handle_climate_attribute_received( + msg, CONF_HUMIDITY_STATE_TEMPLATE, "_attr_target_humidity" + ) + + add_subscription( + topics, CONF_HUMIDITY_STATE_TOPIC, handle_target_humidity_received + ) + @callback def handle_mode_received( msg: ReceiveMessage, template_name: str, attr: str, mode_list: str @@ -744,7 +829,7 @@ class MqttClimate(MqttEntity, ClimateEntity): self._config[CONF_ENCODING], ) - async def _set_temperature( + async def _set_climate_attribute( self, temp: float | None, cmnd_topic: str, @@ -770,7 +855,7 @@ class MqttClimate(MqttEntity, ClimateEntity): if (operation_mode := kwargs.get(ATTR_HVAC_MODE)) is not None: await self.async_set_hvac_mode(operation_mode) - changed = await self._set_temperature( + changed = await self._set_climate_attribute( kwargs.get(ATTR_TEMPERATURE), CONF_TEMP_COMMAND_TOPIC, CONF_TEMP_COMMAND_TEMPLATE, @@ -778,7 +863,7 @@ class MqttClimate(MqttEntity, ClimateEntity): "_attr_target_temperature", ) - changed |= await self._set_temperature( + changed |= await self._set_climate_attribute( kwargs.get(ATTR_TARGET_TEMP_LOW), CONF_TEMP_LOW_COMMAND_TOPIC, CONF_TEMP_LOW_COMMAND_TEMPLATE, @@ -786,7 +871,7 @@ class MqttClimate(MqttEntity, ClimateEntity): "_attr_target_temperature_low", ) - changed |= await self._set_temperature( + changed |= await self._set_climate_attribute( kwargs.get(ATTR_TARGET_TEMP_HIGH), CONF_TEMP_HIGH_COMMAND_TOPIC, CONF_TEMP_HIGH_COMMAND_TEMPLATE, @@ -798,6 +883,19 @@ class MqttClimate(MqttEntity, ClimateEntity): return self.async_write_ha_state() + async def async_set_humidity(self, humidity: int) -> None: + """Set new target humidity.""" + + await self._set_climate_attribute( + humidity, + CONF_HUMIDITY_COMMAND_TOPIC, + CONF_HUMIDITY_COMMAND_TEMPLATE, + CONF_HUMIDITY_STATE_TOPIC, + "_attr_target_humidity", + ) + + self.async_write_ha_state() + async def async_set_swing_mode(self, swing_mode: str) -> None: """Set new swing mode.""" payload = self._command_templates[CONF_SWING_MODE_COMMAND_TEMPLATE](swing_mode) diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 227b725bd00..67c6e2b4a82 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -9,13 +9,17 @@ import voluptuous as vol from homeassistant.components import climate, mqtt from homeassistant.components.climate import ( ATTR_AUX_HEAT, + ATTR_CURRENT_HUMIDITY, ATTR_CURRENT_TEMPERATURE, ATTR_FAN_MODE, + ATTR_HUMIDITY, ATTR_HVAC_ACTION, ATTR_SWING_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, + DEFAULT_MAX_HUMIDITY, DEFAULT_MAX_TEMP, + DEFAULT_MIN_HUMIDITY, DEFAULT_MIN_TEMP, PRESET_ECO, ClimateEntityFeature, @@ -67,6 +71,7 @@ DEFAULT_CONFIG = { climate.DOMAIN: { "name": "test", "mode_command_topic": "mode-topic", + "target_humidity_command_topic": "humidity-topic", "temperature_command_topic": "temperature-topic", "temperature_low_command_topic": "temperature-low-topic", "temperature_high_command_topic": "temperature-high-topic", @@ -108,6 +113,8 @@ async def test_setup_params(hass, mqtt_mock_entry_with_yaml_config): assert state.state == "off" assert state.attributes.get("min_temp") == DEFAULT_MIN_TEMP assert state.attributes.get("max_temp") == DEFAULT_MAX_TEMP + assert state.attributes.get("min_humidity") == DEFAULT_MIN_HUMIDITY + assert state.attributes.get("max_humidity") == DEFAULT_MAX_HUMIDITY async def test_preset_none_in_preset_modes(hass, caplog): @@ -156,6 +163,7 @@ async def test_supported_features(hass, mqtt_mock_entry_with_yaml_config): | ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.AUX_HEAT | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TARGET_HUMIDITY ) assert state.attributes.get("supported_features") == support @@ -489,6 +497,21 @@ async def test_set_target_temperature(hass, mqtt_mock_entry_with_yaml_config): mqtt_mock.async_publish.reset_mock() +async def test_set_target_humidity(hass, mqtt_mock_entry_with_yaml_config): + """Test setting the target humidity.""" + assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) + await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() + + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("humidity") is None + await common.async_set_humidity(hass, humidity=82, entity_id=ENTITY_CLIMATE) + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("humidity") == 82 + mqtt_mock.async_publish.assert_called_once_with("humidity-topic", "82", 0, False) + mqtt_mock.async_publish.reset_mock() + + async def test_set_target_temperature_pessimistic( hass, mqtt_mock_entry_with_yaml_config ): @@ -639,6 +662,53 @@ async def test_set_target_temperature_low_high_optimistic( assert state.attributes.get("target_temp_high") == 25 +async def test_set_target_humidity_optimistic(hass, mqtt_mock_entry_with_yaml_config): + """Test setting the target humidity optimistic.""" + config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) + config["climate"]["target_humidity_state_topic"] = "humidity-state" + config["climate"]["optimistic"] = True + assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) + await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() + + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("humidity") is None + await common.async_set_humidity(hass, humidity=52, entity_id=ENTITY_CLIMATE) + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("humidity") == 52 + + async_fire_mqtt_message(hass, "humidity-state", "53") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("humidity") == 53 + + async_fire_mqtt_message(hass, "humidity-state", "not a number") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("humidity") == 53 + + +async def test_set_target_humidity_pessimistic(hass, mqtt_mock_entry_with_yaml_config): + """Test setting the target humidity.""" + config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) + config["climate"]["target_humidity_state_topic"] = "humidity-state" + assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) + await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() + + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("humidity") is None + await common.async_set_humidity(hass, humidity=50, entity_id=ENTITY_CLIMATE) + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("humidity") is None + + async_fire_mqtt_message(hass, "humidity-state", "80") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("humidity") == 80 + + async_fire_mqtt_message(hass, "humidity-state", "not a number") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("humidity") == 80 + + async def test_receive_mqtt_temperature(hass, mqtt_mock_entry_with_yaml_config): """Test getting the current temperature via MQTT.""" config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) @@ -652,6 +722,36 @@ async def test_receive_mqtt_temperature(hass, mqtt_mock_entry_with_yaml_config): assert state.attributes.get("current_temperature") == 47 +async def test_receive_mqtt_humidity(hass, mqtt_mock_entry_with_yaml_config): + """Test getting the current humidity via MQTT.""" + config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) + config["climate"]["current_humidity_topic"] = "current_humidity" + assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) + await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() + + async_fire_mqtt_message(hass, "current_humidity", "35") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("current_humidity") == 35 + + +async def test_handle_target_humidity_received(hass, mqtt_mock_entry_with_yaml_config): + """Test setting the target humidity via MQTT.""" + config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) + config["climate"]["target_humidity_state_topic"] = "humidity-state" + assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) + await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() + + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("humidity") is None + + async_fire_mqtt_message(hass, "humidity-state", "65") + + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("humidity") == 65 + + async def test_handle_action_received(hass, mqtt_mock_entry_with_yaml_config): """Test getting the action received via MQTT.""" config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) @@ -929,7 +1029,8 @@ async def test_get_target_temperature_low_high_with_templates( async_fire_mqtt_message(hass, "temperature-state", '"-INVALID-"') state = hass.states.get(ENTITY_CLIMATE) # make sure, the invalid value gets logged... - assert "Could not parse temperature from" in caplog.text + assert "Could not parse temperature_low_state_template from" in caplog.text + assert "Could not parse temperature_high_state_template from" in caplog.text # ... but the actual value stays unchanged. assert state.attributes.get("target_temp_low") == 1031 assert state.attributes.get("target_temp_high") == 1032 @@ -951,8 +1052,10 @@ async def test_get_with_templates(hass, mqtt_mock_entry_with_yaml_config, caplog config["climate"]["fan_mode_state_topic"] = "fan-state" config["climate"]["swing_mode_state_topic"] = "swing-state" config["climate"]["temperature_state_topic"] = "temperature-state" + config["climate"]["target_humidity_state_topic"] = "humidity-state" config["climate"]["aux_state_topic"] = "aux-state" config["climate"]["current_temperature_topic"] = "current-temperature" + config["climate"]["current_humidity_topic"] = "current-humidity" config["climate"]["preset_mode_state_topic"] = "current-preset-mode" assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) await hass.async_block_till_done() @@ -986,10 +1089,26 @@ async def test_get_with_templates(hass, mqtt_mock_entry_with_yaml_config, caplog async_fire_mqtt_message(hass, "temperature-state", '"-INVALID-"') state = hass.states.get(ENTITY_CLIMATE) # make sure, the invalid value gets logged... - assert "Could not parse temperature from -INVALID-" in caplog.text + assert "Could not parse temperature_state_template from -INVALID-" in caplog.text # ... but the actual value stays unchanged. assert state.attributes.get("temperature") == 1031 + # Humidity - with valid value + assert state.attributes.get("humidity") is None + async_fire_mqtt_message(hass, "humidity-state", '"82"') + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("humidity") == 82 + + # Humidity - with invalid value + async_fire_mqtt_message(hass, "humidity-state", '"-INVALID-"') + state = hass.states.get(ENTITY_CLIMATE) + # make sure, the invalid value gets logged... + assert ( + "Could not parse target_humidity_state_template from -INVALID-" in caplog.text + ) + # ... but the actual value stays unchanged. + assert state.attributes.get("humidity") == 82 + # Preset Mode assert state.attributes.get("preset_mode") == "none" async_fire_mqtt_message(hass, "current-preset-mode", '{"attribute": "eco"}') @@ -1018,6 +1137,11 @@ async def test_get_with_templates(hass, mqtt_mock_entry_with_yaml_config, caplog state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("current_temperature") == 74656 + # Current humidity + async_fire_mqtt_message(hass, "current-humidity", '"35"') + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("current_humidity") == 35 + # Action async_fire_mqtt_message(hass, "action", '"cooling"') state = hass.states.get(ENTITY_CLIMATE) @@ -1040,6 +1164,7 @@ async def test_set_and_templates(hass, mqtt_mock_entry_with_yaml_config, caplog) config["climate"]["temperature_command_template"] = "temp: {{ value }}" config["climate"]["temperature_high_command_template"] = "temp_hi: {{ value }}" config["climate"]["temperature_low_command_template"] = "temp_lo: {{ value }}" + config["climate"]["target_humidity_command_template"] = "humidity: {{ value }}" assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) await hass.async_block_till_done() @@ -1106,6 +1231,15 @@ async def test_set_and_templates(hass, mqtt_mock_entry_with_yaml_config, caplog) assert state.attributes.get("target_temp_low") == 20 assert state.attributes.get("target_temp_high") == 23 + # Humidity + await common.async_set_humidity(hass, humidity=82, entity_id=ENTITY_CLIMATE) + mqtt_mock.async_publish.assert_called_once_with( + "humidity-topic", "humidity: 82", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("humidity") == 82 + async def test_min_temp_custom(hass, mqtt_mock_entry_with_yaml_config): """Test a custom min temp.""" @@ -1139,6 +1273,38 @@ async def test_max_temp_custom(hass, mqtt_mock_entry_with_yaml_config): assert max_temp == 60 +async def test_min_humidity_custom(hass, mqtt_mock_entry_with_yaml_config): + """Test a custom min humidity.""" + config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) + config["climate"]["min_humidity"] = 42 + + assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) + await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() + + state = hass.states.get(ENTITY_CLIMATE) + min_humidity = state.attributes.get("min_humidity") + + assert isinstance(min_humidity, float) + assert state.attributes.get("min_humidity") == 42 + + +async def test_max_humidity_custom(hass, mqtt_mock_entry_with_yaml_config): + """Test a custom max humidity.""" + config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) + config["climate"]["max_humidity"] = 58 + + assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) + await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() + + state = hass.states.get(ENTITY_CLIMATE) + max_humidity = state.attributes.get("max_humidity") + + assert isinstance(max_humidity, float) + assert max_humidity == 58 + + async def test_temp_step_custom(hass, mqtt_mock_entry_with_yaml_config): """Test a custom temp step.""" config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) @@ -1269,6 +1435,7 @@ async def test_unique_id(hass, mqtt_mock_entry_with_yaml_config): ("action_topic", "cooling", ATTR_HVAC_ACTION, "cooling"), ("aux_state_topic", "ON", ATTR_AUX_HEAT, "on"), ("current_temperature_topic", "22.1", ATTR_CURRENT_TEMPERATURE, 22.1), + ("current_humidity_topic", "60.4", ATTR_CURRENT_HUMIDITY, 60.4), ("fan_mode_state_topic", "low", ATTR_FAN_MODE, "low"), ("mode_state_topic", "cool", None, None), ("mode_state_topic", "fan_only", None, None), @@ -1276,6 +1443,7 @@ async def test_unique_id(hass, mqtt_mock_entry_with_yaml_config): ("temperature_low_state_topic", "19.1", ATTR_TARGET_TEMP_LOW, 19.1), ("temperature_high_state_topic", "22.9", ATTR_TARGET_TEMP_HIGH, 22.9), ("temperature_state_topic", "19.9", ATTR_TEMPERATURE, 19.9), + ("target_humidity_state_topic", "82.6", ATTR_HUMIDITY, 82.6), ], ) async def test_encoding_subscribable_topics( @@ -1545,6 +1713,13 @@ async def test_precision_whole(hass, mqtt_mock_entry_with_yaml_config): 29.8, "temperature_high_command_template", ), + ( + climate.SERVICE_SET_HUMIDITY, + "target_humidity_command_topic", + {"humidity": "82"}, + 82, + "target_humidity_command_template", + ), ], ) async def test_publishing_with_custom_encoding( @@ -1578,6 +1753,62 @@ async def test_publishing_with_custom_encoding( ) +@pytest.mark.parametrize( + "config,valid", + [ + ( + { + "name": "test_valid_humidity_min_max", + "min_humidity": 20, + "max_humidity": 80, + }, + True, + ), + ( + { + "name": "test_invalid_humidity_min_max_1", + "min_humidity": 0, + "max_humidity": 101, + }, + False, + ), + ( + { + "name": "test_invalid_humidity_min_max_2", + "max_humidity": 20, + "min_humidity": 40, + }, + False, + ), + ( + { + "name": "test_valid_humidity_state", + "target_humidity_state_topic": "humidity-state", + "target_humidity_command_topic": "humidity-command", + }, + True, + ), + ( + { + "name": "test_invalid_humidity_state", + "target_humidity_state_topic": "humidity-state", + }, + False, + ), + ], +) +async def test_humidity_configuration_validity(hass, config, valid): + """Test the validity of humidity configurations.""" + assert ( + await async_setup_component( + hass, + mqtt.DOMAIN, + {mqtt.DOMAIN: {climate.DOMAIN: config}}, + ) + is valid + ) + + async def test_reloadable(hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path): """Test reloading the MQTT platform.""" domain = climate.DOMAIN