From 08561210469203141d1c329f4d847e047cacae51 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 28 Jun 2023 14:21:15 +0200 Subject: [PATCH] Add action topic to MQTT humidifier (#95212) * Add action topic to MQTT humidifier * Add tests --- homeassistant/components/mqtt/climate.py | 4 +- homeassistant/components/mqtt/const.py | 2 + homeassistant/components/mqtt/humidifier.py | 109 ++++++++++++-------- tests/components/mqtt/test_humidifier.py | 64 ++++++++++++ 4 files changed, 135 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 095b1d958a9..40ec754aa44 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -52,6 +52,8 @@ from homeassistant.util.unit_conversion import TemperatureConverter from . import subscription from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA from .const import ( + CONF_ACTION_TEMPLATE, + CONF_ACTION_TOPIC, CONF_CURRENT_HUMIDITY_TEMPLATE, CONF_CURRENT_HUMIDITY_TOPIC, CONF_CURRENT_TEMP_TEMPLATE, @@ -90,8 +92,6 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "MQTT HVAC" -CONF_ACTION_TEMPLATE = "action_template" -CONF_ACTION_TOPIC = "action_topic" CONF_AUX_COMMAND_TOPIC = "aux_command_topic" CONF_AUX_STATE_TEMPLATE = "aux_state_template" CONF_AUX_STATE_TOPIC = "aux_state_topic" diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index bf01bb13483..d09a2bb8cb6 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -29,6 +29,8 @@ CONF_WS_HEADERS = "ws_headers" CONF_WILL_MESSAGE = "will_message" CONF_PAYLOAD_RESET = "payload_reset" +CONF_ACTION_TEMPLATE = "action_template" +CONF_ACTION_TOPIC = "action_topic" CONF_CURRENT_HUMIDITY_TEMPLATE = "current_humidity_template" CONF_CURRENT_HUMIDITY_TOPIC = "current_humidity_topic" CONF_CURRENT_TEMP_TEMPLATE = "current_temperature_template" diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index 624bf0c698b..392a112bcdb 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -10,11 +10,13 @@ import voluptuous as vol from homeassistant.components import humidifier from homeassistant.components.humidifier import ( + ATTR_ACTION, ATTR_CURRENT_HUMIDITY, ATTR_HUMIDITY, ATTR_MODE, DEFAULT_MAX_HUMIDITY, DEFAULT_MIN_HUMIDITY, + HumidifierAction, HumidifierDeviceClass, HumidifierEntity, HumidifierEntityFeature, @@ -36,6 +38,8 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import subscription from .config import MQTT_RW_SCHEMA from .const import ( + CONF_ACTION_TEMPLATE, + CONF_ACTION_TOPIC, CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, CONF_CURRENT_HUMIDITY_TEMPLATE, @@ -114,6 +118,8 @@ def valid_humidity_range_configuration(config: ConfigType) -> ConfigType: _PLATFORM_SCHEMA_BASE = MQTT_RW_SCHEMA.extend( { + vol.Optional(CONF_ACTION_TEMPLATE): cv.template, + vol.Optional(CONF_ACTION_TOPIC): valid_subscribe_topic, # CONF_AVAIALABLE_MODES_LIST and CONF_MODE_COMMAND_TOPIC must be used together vol.Inclusive( CONF_AVAILABLE_MODES_LIST, "available_modes", default=[] @@ -163,6 +169,17 @@ DISCOVERY_SCHEMA = vol.All( valid_mode_configuration, ) +TOPICS = ( + CONF_ACTION_TOPIC, + CONF_STATE_TOPIC, + CONF_COMMAND_TOPIC, + CONF_CURRENT_HUMIDITY_TOPIC, + CONF_TARGET_HUMIDITY_STATE_TOPIC, + CONF_TARGET_HUMIDITY_COMMAND_TOPIC, + CONF_MODE_STATE_TOPIC, + CONF_MODE_COMMAND_TOPIC, +) + async def async_setup_entry( hass: HomeAssistant, @@ -224,18 +241,7 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): self._attr_min_humidity = config[CONF_TARGET_HUMIDITY_MIN] self._attr_max_humidity = config[CONF_TARGET_HUMIDITY_MAX] - self._topic = { - key: config.get(key) - for key in ( - CONF_STATE_TOPIC, - CONF_COMMAND_TOPIC, - CONF_CURRENT_HUMIDITY_TOPIC, - CONF_TARGET_HUMIDITY_STATE_TOPIC, - CONF_TARGET_HUMIDITY_COMMAND_TOPIC, - CONF_MODE_STATE_TOPIC, - CONF_MODE_COMMAND_TOPIC, - ) - } + self._topic = {key: config.get(key) for key in TOPICS} self._payload = { "STATE_ON": config[CONF_PAYLOAD_ON], "STATE_OFF": config[CONF_PAYLOAD_OFF], @@ -248,6 +254,8 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): self._attr_available_modes = [] if self._attr_available_modes: self._attr_supported_features = HumidifierEntityFeature.MODES + if CONF_MODE_STATE_TOPIC in config: + self._attr_mode = None optimistic: bool = config[CONF_OPTIMISTIC] self._optimistic = optimistic or self._topic[CONF_STATE_TOPIC] is None @@ -269,6 +277,7 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): self._value_templates = {} value_templates: dict[str, Template | None] = { + ATTR_ACTION: config.get(CONF_ACTION_TEMPLATE), ATTR_CURRENT_HUMIDITY: config.get(CONF_CURRENT_HUMIDITY_TEMPLATE), CONF_STATE: config.get(CONF_STATE_VALUE_TEMPLATE), ATTR_HUMIDITY: config.get(CONF_TARGET_HUMIDITY_STATE_TEMPLATE), @@ -280,6 +289,22 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): entity=self, ).async_render_with_possible_json_value + def add_subscription( + self, + topics: dict[str, dict[str, Any]], + topic: str, + msg_callback: Callable[[ReceiveMessage], None], + ) -> None: + """Add a subscription.""" + qos: int = self._config[CONF_QOS] + if topic in self._topic and self._topic[topic] is not None: + topics[topic] = { + "topic": self._topic[topic], + "msg_callback": msg_callback, + "qos": qos, + "encoding": self._config[CONF_ENCODING] or None, + } + def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" topics: dict[str, Any] = {} @@ -300,13 +325,29 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): self._attr_is_on = None get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - if self._topic[CONF_STATE_TOPIC] is not None: - topics[CONF_STATE_TOPIC] = { - "topic": self._topic[CONF_STATE_TOPIC], - "msg_callback": state_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } + self.add_subscription(topics, CONF_STATE_TOPIC, state_received) + + @callback + @log_messages(self.hass, self.entity_id) + def action_received(msg: ReceiveMessage) -> None: + """Handle new received MQTT message.""" + action_payload = self._value_templates[ATTR_ACTION](msg.payload) + if not action_payload or action_payload == PAYLOAD_NONE: + _LOGGER.debug("Ignoring empty action from '%s'", msg.topic) + return + try: + self._attr_action = HumidifierAction(str(action_payload)) + except ValueError: + _LOGGER.error( + "'%s' received on topic %s. '%s' is not a valid action", + msg.payload, + msg.topic, + action_payload, + ) + return + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + + self.add_subscription(topics, CONF_ACTION_TOPIC, action_received) @callback @log_messages(self.hass, self.entity_id) @@ -343,13 +384,9 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): self._attr_current_humidity = current_humidity get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - if self._topic[CONF_CURRENT_HUMIDITY_TOPIC] is not None: - topics[CONF_CURRENT_HUMIDITY_TOPIC] = { - "topic": self._topic[CONF_CURRENT_HUMIDITY_TOPIC], - "msg_callback": current_humidity_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } + self.add_subscription( + topics, CONF_CURRENT_HUMIDITY_TOPIC, current_humidity_received + ) @callback @log_messages(self.hass, self.entity_id) @@ -389,14 +426,9 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): self._attr_target_humidity = target_humidity get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - if self._topic[CONF_TARGET_HUMIDITY_STATE_TOPIC] is not None: - topics[CONF_TARGET_HUMIDITY_STATE_TOPIC] = { - "topic": self._topic[CONF_TARGET_HUMIDITY_STATE_TOPIC], - "msg_callback": target_humidity_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } - self._attr_target_humidity = None + self.add_subscription( + topics, CONF_TARGET_HUMIDITY_STATE_TOPIC, target_humidity_received + ) @callback @log_messages(self.hass, self.entity_id) @@ -422,14 +454,7 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): self._attr_mode = mode get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - if self._topic[CONF_MODE_STATE_TOPIC] is not None: - topics[CONF_MODE_STATE_TOPIC] = { - "topic": self._topic[CONF_MODE_STATE_TOPIC], - "msg_callback": mode_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } - self._attr_mode = None + self.add_subscription(topics, CONF_MODE_STATE_TOPIC, mode_received) self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, self._sub_state, topics diff --git a/tests/components/mqtt/test_humidifier.py b/tests/components/mqtt/test_humidifier.py index fecf9c33fc0..1c386b28703 100644 --- a/tests/components/mqtt/test_humidifier.py +++ b/tests/components/mqtt/test_humidifier.py @@ -14,6 +14,7 @@ from homeassistant.components.humidifier import ( DOMAIN, SERVICE_SET_HUMIDITY, SERVICE_SET_MODE, + HumidifierAction, ) from homeassistant.components.mqtt.const import CONF_CURRENT_HUMIDITY_TOPIC from homeassistant.components.mqtt.humidifier import ( @@ -151,6 +152,7 @@ async def test_fail_setup_if_no_command_topic( mqtt.DOMAIN: { humidifier.DOMAIN: { "name": "test", + "action_topic": "action-topic", "state_topic": "state-topic", "command_topic": "command-topic", "current_humidity_topic": "current-humidity-topic", @@ -186,14 +188,17 @@ async def test_controlling_state_via_topic( state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN assert not state.attributes.get(ATTR_ASSUMED_STATE) + assert not state.attributes.get(humidifier.ATTR_ACTION) async_fire_mqtt_message(hass, "state-topic", "StAtE_On") state = hass.states.get("humidifier.test") assert state.state == STATE_ON + assert not state.attributes.get(humidifier.ATTR_ACTION) async_fire_mqtt_message(hass, "state-topic", "StAtE_OfF") state = hass.states.get("humidifier.test") assert state.state == STATE_OFF + assert not state.attributes.get(humidifier.ATTR_ACTION) async_fire_mqtt_message(hass, "humidity-state-topic", "0") state = hass.states.get("humidifier.test") @@ -270,6 +275,34 @@ async def test_controlling_state_via_topic( async_fire_mqtt_message(hass, "state-topic", "None") state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN + assert not state.attributes.get(humidifier.ATTR_ACTION) + + # Turn un the humidifier + async_fire_mqtt_message(hass, "state-topic", "StAtE_On") + state = hass.states.get("humidifier.test") + assert state.state == STATE_ON + assert not state.attributes.get(humidifier.ATTR_ACTION) + + async_fire_mqtt_message(hass, "action-topic", HumidifierAction.DRYING.value) + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_ACTION) == HumidifierAction.DRYING + + async_fire_mqtt_message(hass, "action-topic", HumidifierAction.HUMIDIFYING.value) + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_ACTION) == HumidifierAction.HUMIDIFYING + + async_fire_mqtt_message(hass, "action-topic", HumidifierAction.HUMIDIFYING.value) + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_ACTION) == HumidifierAction.HUMIDIFYING + + async_fire_mqtt_message(hass, "action-topic", "invalid_action") + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_ACTION) == HumidifierAction.HUMIDIFYING + + async_fire_mqtt_message(hass, "state-topic", "StAtE_OfF") + state = hass.states.get("humidifier.test") + assert state.state == STATE_OFF + assert state.attributes.get(humidifier.ATTR_ACTION) == HumidifierAction.OFF @pytest.mark.parametrize( @@ -279,6 +312,7 @@ async def test_controlling_state_via_topic( mqtt.DOMAIN: { humidifier.DOMAIN: { "name": "test", + "action_topic": "action-topic", "state_topic": "state-topic", "command_topic": "command-topic", "current_humidity_topic": "current-humidity-topic", @@ -292,6 +326,7 @@ async def test_controlling_state_via_topic( "baby", ], "current_humidity_template": "{{ value_json.val }}", + "action_template": "{{ value_json.val }}", "state_value_template": "{{ value_json.val }}", "target_humidity_state_template": "{{ value_json.val }}", "mode_state_template": "{{ value_json.val }}", @@ -381,6 +416,35 @@ async def test_controlling_state_via_topic_and_json_message( state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN + # Make sure the humidifier is ON + async_fire_mqtt_message(hass, "state-topic", '{"val":"ON"}') + state = hass.states.get("humidifier.test") + assert state.state == STATE_ON + + async_fire_mqtt_message(hass, "action-topic", '{"val": "drying"}') + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_ACTION) == HumidifierAction.DRYING + + async_fire_mqtt_message(hass, "action-topic", '{"val": "humidifying"}') + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_ACTION) == HumidifierAction.HUMIDIFYING + + async_fire_mqtt_message(hass, "action-topic", '{"val": null}') + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_ACTION) == HumidifierAction.HUMIDIFYING + + async_fire_mqtt_message(hass, "action-topic", '{"otherval": "idle"}') + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_ACTION) == HumidifierAction.HUMIDIFYING + + async_fire_mqtt_message(hass, "action-topic", '{"val": "idle"}') + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_ACTION) == HumidifierAction.IDLE + + async_fire_mqtt_message(hass, "action-topic", '{"val": "off"}') + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_ACTION) == HumidifierAction.OFF + @pytest.mark.parametrize( "hass_config",