Home Assistant Unofficial Reference 2024.12.1
humidifier.py
Go to the documentation of this file.
1 """Support for MQTT humidifiers."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable
6 import logging
7 from typing import Any
8 
9 import voluptuous as vol
10 
11 from homeassistant.components import humidifier
13  ATTR_ACTION,
14  ATTR_CURRENT_HUMIDITY,
15  ATTR_HUMIDITY,
16  ATTR_MODE,
17  DEFAULT_MAX_HUMIDITY,
18  DEFAULT_MIN_HUMIDITY,
19  HumidifierAction,
20  HumidifierDeviceClass,
21  HumidifierEntity,
22  HumidifierEntityFeature,
23 )
24 from homeassistant.config_entries import ConfigEntry
25 from homeassistant.const import (
26  CONF_NAME,
27  CONF_OPTIMISTIC,
28  CONF_PAYLOAD_OFF,
29  CONF_PAYLOAD_ON,
30  CONF_STATE,
31 )
32 from homeassistant.core import HomeAssistant, callback
34 from homeassistant.helpers.entity_platform import AddEntitiesCallback
35 from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
36 from homeassistant.helpers.template import Template
37 from homeassistant.helpers.typing import ConfigType, VolSchemaType
38 
39 from . import subscription
40 from .config import MQTT_RW_SCHEMA
41 from .const import (
42  CONF_ACTION_TEMPLATE,
43  CONF_ACTION_TOPIC,
44  CONF_COMMAND_TEMPLATE,
45  CONF_COMMAND_TOPIC,
46  CONF_CURRENT_HUMIDITY_TEMPLATE,
47  CONF_CURRENT_HUMIDITY_TOPIC,
48  CONF_STATE_TOPIC,
49  CONF_STATE_VALUE_TEMPLATE,
50  PAYLOAD_NONE,
51 )
52 from .entity import MqttEntity, async_setup_entity_entry_helper
53 from .models import (
54  MqttCommandTemplate,
55  MqttValueTemplate,
56  PublishPayloadType,
57  ReceiveMessage,
58 )
59 from .schemas import MQTT_ENTITY_COMMON_SCHEMA
60 from .util import valid_publish_topic, valid_subscribe_topic
61 
62 PARALLEL_UPDATES = 0
63 
64 CONF_AVAILABLE_MODES_LIST = "modes"
65 CONF_DEVICE_CLASS = "device_class"
66 CONF_MODE_COMMAND_TEMPLATE = "mode_command_template"
67 CONF_MODE_COMMAND_TOPIC = "mode_command_topic"
68 CONF_MODE_STATE_TOPIC = "mode_state_topic"
69 CONF_MODE_STATE_TEMPLATE = "mode_state_template"
70 CONF_PAYLOAD_RESET_MODE = "payload_reset_mode"
71 CONF_PAYLOAD_RESET_HUMIDITY = "payload_reset_humidity"
72 CONF_TARGET_HUMIDITY_COMMAND_TEMPLATE = "target_humidity_command_template"
73 CONF_TARGET_HUMIDITY_COMMAND_TOPIC = "target_humidity_command_topic"
74 CONF_TARGET_HUMIDITY_MIN = "min_humidity"
75 CONF_TARGET_HUMIDITY_MAX = "max_humidity"
76 CONF_TARGET_HUMIDITY_STATE_TEMPLATE = "target_humidity_state_template"
77 CONF_TARGET_HUMIDITY_STATE_TOPIC = "target_humidity_state_topic"
78 
79 DEFAULT_NAME = "MQTT Humidifier"
80 DEFAULT_PAYLOAD_ON = "ON"
81 DEFAULT_PAYLOAD_OFF = "OFF"
82 DEFAULT_PAYLOAD_RESET = "None"
83 
84 MQTT_HUMIDIFIER_ATTRIBUTES_BLOCKED = frozenset(
85  {
86  humidifier.ATTR_HUMIDITY,
87  humidifier.ATTR_MAX_HUMIDITY,
88  humidifier.ATTR_MIN_HUMIDITY,
89  humidifier.ATTR_MODE,
90  humidifier.ATTR_AVAILABLE_MODES,
91  }
92 )
93 
94 _LOGGER = logging.getLogger(__name__)
95 
96 
97 def valid_mode_configuration(config: ConfigType) -> ConfigType:
98  """Validate that the mode reset payload is not one of the available modes."""
99  if config[CONF_PAYLOAD_RESET_MODE] in config[CONF_AVAILABLE_MODES_LIST]:
100  raise vol.Invalid("modes must not contain payload_reset_mode")
101  return config
102 
103 
104 def valid_humidity_range_configuration(config: ConfigType) -> ConfigType:
105  """Validate humidity range.
106 
107  Ensures that the target_humidity range configuration is valid,
108  throws if it isn't.
109  """
110  if config[CONF_TARGET_HUMIDITY_MIN] >= config[CONF_TARGET_HUMIDITY_MAX]:
111  raise vol.Invalid("target_humidity_max must be > target_humidity_min")
112  if config[CONF_TARGET_HUMIDITY_MAX] > 100:
113  raise vol.Invalid("max_humidity must be <= 100")
114 
115  return config
116 
117 
118 _PLATFORM_SCHEMA_BASE = MQTT_RW_SCHEMA.extend(
119  {
120  vol.Optional(CONF_ACTION_TEMPLATE): cv.template,
121  vol.Optional(CONF_ACTION_TOPIC): valid_subscribe_topic,
122  # CONF_AVAIALABLE_MODES_LIST and CONF_MODE_COMMAND_TOPIC must be used together
123  vol.Inclusive(
124  CONF_AVAILABLE_MODES_LIST, "available_modes", default=[]
125  ): cv.ensure_list,
126  vol.Inclusive(CONF_MODE_COMMAND_TOPIC, "available_modes"): valid_publish_topic,
127  vol.Optional(CONF_COMMAND_TEMPLATE): cv.template,
128  vol.Optional(CONF_CURRENT_HUMIDITY_TEMPLATE): cv.template,
129  vol.Optional(CONF_CURRENT_HUMIDITY_TOPIC): valid_subscribe_topic,
130  vol.Optional(
131  CONF_DEVICE_CLASS, default=HumidifierDeviceClass.HUMIDIFIER
132  ): vol.In(
133  [HumidifierDeviceClass.HUMIDIFIER, HumidifierDeviceClass.DEHUMIDIFIER, None]
134  ),
135  vol.Optional(CONF_MODE_COMMAND_TEMPLATE): cv.template,
136  vol.Optional(CONF_MODE_STATE_TOPIC): valid_subscribe_topic,
137  vol.Optional(CONF_MODE_STATE_TEMPLATE): cv.template,
138  vol.Optional(CONF_NAME): vol.Any(cv.string, None),
139  vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string,
140  vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string,
141  vol.Optional(CONF_STATE_VALUE_TEMPLATE): cv.template,
142  vol.Required(CONF_TARGET_HUMIDITY_COMMAND_TOPIC): valid_publish_topic,
143  vol.Optional(CONF_TARGET_HUMIDITY_COMMAND_TEMPLATE): cv.template,
144  vol.Optional(
145  CONF_TARGET_HUMIDITY_MAX, default=DEFAULT_MAX_HUMIDITY
146  ): cv.positive_float,
147  vol.Optional(
148  CONF_TARGET_HUMIDITY_MIN, default=DEFAULT_MIN_HUMIDITY
149  ): cv.positive_float,
150  vol.Optional(CONF_TARGET_HUMIDITY_STATE_TEMPLATE): cv.template,
151  vol.Optional(CONF_TARGET_HUMIDITY_STATE_TOPIC): valid_subscribe_topic,
152  vol.Optional(
153  CONF_PAYLOAD_RESET_HUMIDITY, default=DEFAULT_PAYLOAD_RESET
154  ): cv.string,
155  vol.Optional(CONF_PAYLOAD_RESET_MODE, default=DEFAULT_PAYLOAD_RESET): cv.string,
156  }
157 ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
158 
159 PLATFORM_SCHEMA_MODERN = vol.All(
160  _PLATFORM_SCHEMA_BASE,
161  valid_humidity_range_configuration,
162  valid_mode_configuration,
163 )
164 
165 DISCOVERY_SCHEMA = vol.All(
166  _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA),
167  valid_humidity_range_configuration,
168  valid_mode_configuration,
169 )
170 
171 TOPICS = (
172  CONF_ACTION_TOPIC,
173  CONF_STATE_TOPIC,
174  CONF_COMMAND_TOPIC,
175  CONF_CURRENT_HUMIDITY_TOPIC,
176  CONF_TARGET_HUMIDITY_STATE_TOPIC,
177  CONF_TARGET_HUMIDITY_COMMAND_TOPIC,
178  CONF_MODE_STATE_TOPIC,
179  CONF_MODE_COMMAND_TOPIC,
180 )
181 
182 
184  hass: HomeAssistant,
185  config_entry: ConfigEntry,
186  async_add_entities: AddEntitiesCallback,
187 ) -> None:
188  """Set up MQTT humidifier through YAML and through MQTT discovery."""
190  hass,
191  config_entry,
192  MqttHumidifier,
193  humidifier.DOMAIN,
194  async_add_entities,
195  DISCOVERY_SCHEMA,
196  PLATFORM_SCHEMA_MODERN,
197  )
198 
199 
201  """A MQTT humidifier component."""
202 
203  _attr_mode: str | None = None
204  _default_name = DEFAULT_NAME
205  _entity_id_format = humidifier.ENTITY_ID_FORMAT
206  _attributes_extra_blocked = MQTT_HUMIDIFIER_ATTRIBUTES_BLOCKED
207 
208  _command_templates: dict[str, Callable[[PublishPayloadType], PublishPayloadType]]
209  _value_templates: dict[str, Callable[[ReceivePayloadType], ReceivePayloadType]]
210  _optimistic: bool
211  _optimistic_target_humidity: bool
212  _optimistic_mode: bool
213  _payload: dict[str, str]
214  _topic: dict[str, Any]
215 
216  @staticmethod
217  def config_schema() -> VolSchemaType:
218  """Return the config schema."""
219  return DISCOVERY_SCHEMA
220 
221  def _setup_from_config(self, config: ConfigType) -> None:
222  """(Re)Setup the entity."""
223  self._attr_device_class_attr_device_class = config.get(CONF_DEVICE_CLASS)
224  self._attr_min_humidity_attr_min_humidity = config[CONF_TARGET_HUMIDITY_MIN]
225  self._attr_max_humidity_attr_max_humidity = config[CONF_TARGET_HUMIDITY_MAX]
226 
227  self._topic_topic = {key: config.get(key) for key in TOPICS}
228  self._payload_payload = {
229  "STATE_ON": config[CONF_PAYLOAD_ON],
230  "STATE_OFF": config[CONF_PAYLOAD_OFF],
231  "HUMIDITY_RESET": config[CONF_PAYLOAD_RESET_HUMIDITY],
232  "MODE_RESET": config[CONF_PAYLOAD_RESET_MODE],
233  }
234  if CONF_MODE_COMMAND_TOPIC in config and CONF_AVAILABLE_MODES_LIST in config:
235  self._attr_available_modes_attr_available_modes = config[CONF_AVAILABLE_MODES_LIST]
236  else:
237  self._attr_available_modes_attr_available_modes = []
238  if self._attr_available_modes_attr_available_modes:
239  self._attr_supported_features_attr_supported_features = HumidifierEntityFeature.MODES
240  if CONF_MODE_STATE_TOPIC in config:
241  self._attr_mode_attr_mode = None
242 
243  optimistic: bool = config[CONF_OPTIMISTIC]
244  self._optimistic_optimistic = optimistic or self._topic_topic[CONF_STATE_TOPIC] is None
245  self._attr_assumed_state_attr_assumed_state = bool(self._optimistic_optimistic)
246  self._optimistic_target_humidity_optimistic_target_humidity = (
247  optimistic or self._topic_topic[CONF_TARGET_HUMIDITY_STATE_TOPIC] is None
248  )
249  self._optimistic_mode_optimistic_mode = optimistic or self._topic_topic[CONF_MODE_STATE_TOPIC] is None
250 
251  command_templates: dict[str, Template | None] = {
252  CONF_STATE: config.get(CONF_COMMAND_TEMPLATE),
253  ATTR_HUMIDITY: config.get(CONF_TARGET_HUMIDITY_COMMAND_TEMPLATE),
254  ATTR_MODE: config.get(CONF_MODE_COMMAND_TEMPLATE),
255  }
256  self._command_templates_command_templates = {
257  key: MqttCommandTemplate(tpl, entity=self).async_render
258  for key, tpl in command_templates.items()
259  }
260 
261  value_templates: dict[str, Template | None] = {
262  ATTR_ACTION: config.get(CONF_ACTION_TEMPLATE),
263  ATTR_CURRENT_HUMIDITY: config.get(CONF_CURRENT_HUMIDITY_TEMPLATE),
264  CONF_STATE: config.get(CONF_STATE_VALUE_TEMPLATE),
265  ATTR_HUMIDITY: config.get(CONF_TARGET_HUMIDITY_STATE_TEMPLATE),
266  ATTR_MODE: config.get(CONF_MODE_STATE_TEMPLATE),
267  }
268  self._value_templates_value_templates = {
269  key: MqttValueTemplate(
270  tpl,
271  entity=self,
272  ).async_render_with_possible_json_value
273  for key, tpl in value_templates.items()
274  }
275 
276  @callback
277  def _state_received(self, msg: ReceiveMessage) -> None:
278  """Handle new received MQTT message."""
279  payload = self._value_templates_value_templates[CONF_STATE](msg.payload)
280  if not payload:
281  _LOGGER.debug("Ignoring empty state from '%s'", msg.topic)
282  return
283  if payload == self._payload_payload["STATE_ON"]:
284  self._attr_is_on_attr_is_on = True
285  elif payload == self._payload_payload["STATE_OFF"]:
286  self._attr_is_on_attr_is_on = False
287  elif payload == PAYLOAD_NONE:
288  self._attr_is_on_attr_is_on = None
289 
290  @callback
291  def _action_received(self, msg: ReceiveMessage) -> None:
292  """Handle new received MQTT message."""
293  action_payload = self._value_templates_value_templates[ATTR_ACTION](msg.payload)
294  if not action_payload or action_payload == PAYLOAD_NONE:
295  _LOGGER.debug("Ignoring empty action from '%s'", msg.topic)
296  return
297  try:
298  self._attr_action_attr_action = HumidifierAction(str(action_payload))
299  except ValueError:
300  _LOGGER.error(
301  "'%s' received on topic %s. '%s' is not a valid action",
302  msg.payload,
303  msg.topic,
304  action_payload,
305  )
306  return
307 
308  @callback
309  def _current_humidity_received(self, msg: ReceiveMessage) -> None:
310  """Handle new received MQTT message for the current humidity."""
311  rendered_current_humidity_payload = self._value_templates_value_templates[
312  ATTR_CURRENT_HUMIDITY
313  ](msg.payload)
314  if rendered_current_humidity_payload == self._payload_payload["HUMIDITY_RESET"]:
315  self._attr_current_humidity_attr_current_humidity = None
316  return
317  if not rendered_current_humidity_payload:
318  _LOGGER.debug("Ignoring empty current humidity from '%s'", msg.topic)
319  return
320  try:
321  current_humidity = round(float(rendered_current_humidity_payload))
322  except ValueError:
323  _LOGGER.warning(
324  "'%s' received on topic %s. '%s' is not a valid humidity",
325  msg.payload,
326  msg.topic,
327  rendered_current_humidity_payload,
328  )
329  return
330  if current_humidity < 0 or current_humidity > 100:
331  _LOGGER.warning(
332  "'%s' received on topic %s. '%s' is not a valid humidity",
333  msg.payload,
334  msg.topic,
335  rendered_current_humidity_payload,
336  )
337  return
338  self._attr_current_humidity_attr_current_humidity = current_humidity
339 
340  @callback
341  def _target_humidity_received(self, msg: ReceiveMessage) -> None:
342  """Handle new received MQTT message for the target humidity."""
343  rendered_target_humidity_payload = self._value_templates_value_templates[ATTR_HUMIDITY](
344  msg.payload
345  )
346  if not rendered_target_humidity_payload:
347  _LOGGER.debug("Ignoring empty target humidity from '%s'", msg.topic)
348  return
349  if rendered_target_humidity_payload == self._payload_payload["HUMIDITY_RESET"]:
350  self._attr_target_humidity_attr_target_humidity = None
351  return
352  try:
353  target_humidity = round(float(rendered_target_humidity_payload))
354  except ValueError:
355  _LOGGER.warning(
356  "'%s' received on topic %s. '%s' is not a valid target humidity",
357  msg.payload,
358  msg.topic,
359  rendered_target_humidity_payload,
360  )
361  return
362  if (
363  target_humidity < self._attr_min_humidity_attr_min_humidity
364  or target_humidity > self._attr_max_humidity_attr_max_humidity
365  ):
366  _LOGGER.warning(
367  "'%s' received on topic %s. '%s' is not a valid target humidity",
368  msg.payload,
369  msg.topic,
370  rendered_target_humidity_payload,
371  )
372  return
373  self._attr_target_humidity_attr_target_humidity = target_humidity
374 
375  @callback
376  def _mode_received(self, msg: ReceiveMessage) -> None:
377  """Handle new received MQTT message for mode."""
378  mode = str(self._value_templates_value_templates[ATTR_MODE](msg.payload))
379  if mode == self._payload_payload["MODE_RESET"]:
380  self._attr_mode_attr_mode = None
381  return
382  if not mode:
383  _LOGGER.debug("Ignoring empty mode from '%s'", msg.topic)
384  return
385  if not self.available_modesavailable_modes or mode not in self.available_modesavailable_modes:
386  _LOGGER.warning(
387  "'%s' received on topic %s. '%s' is not a valid mode",
388  msg.payload,
389  msg.topic,
390  mode,
391  )
392  return
393 
394  self._attr_mode_attr_mode = mode
395 
396  @callback
397  def _prepare_subscribe_topics(self) -> None:
398  """(Re)Subscribe to topics."""
399  self.add_subscriptionadd_subscription(CONF_STATE_TOPIC, self._state_received_state_received, {"_attr_is_on"})
400  self.add_subscriptionadd_subscription(
401  CONF_ACTION_TOPIC, self._action_received_action_received, {"_attr_action"}
402  )
403  self.add_subscriptionadd_subscription(
404  CONF_CURRENT_HUMIDITY_TOPIC,
405  self._current_humidity_received_current_humidity_received,
406  {"_attr_current_humidity"},
407  )
408  self.add_subscriptionadd_subscription(
409  CONF_TARGET_HUMIDITY_STATE_TOPIC,
410  self._target_humidity_received_target_humidity_received,
411  {"_attr_target_humidity"},
412  )
413  self.add_subscriptionadd_subscription(
414  CONF_MODE_STATE_TOPIC, self._mode_received_mode_received, {"_attr_mode"}
415  )
416 
417  async def _subscribe_topics(self) -> None:
418  """(Re)Subscribe to topics."""
419  subscription.async_subscribe_topics_internal(self.hasshasshass, self._sub_state_sub_state)
420 
421  async def async_turn_on(self, **kwargs: Any) -> None:
422  """Turn on the entity.
423 
424  This method is a coroutine.
425  """
426  mqtt_payload = self._command_templates_command_templates[CONF_STATE](self._payload_payload["STATE_ON"])
427  await self.async_publish_with_configasync_publish_with_config(
428  self._config_config[CONF_COMMAND_TOPIC], mqtt_payload
429  )
430  if self._optimistic_optimistic:
431  self._attr_is_on_attr_is_on = True
432  self.async_write_ha_stateasync_write_ha_state()
433 
434  async def async_turn_off(self, **kwargs: Any) -> None:
435  """Turn off the entity.
436 
437  This method is a coroutine.
438  """
439  mqtt_payload = self._command_templates_command_templates[CONF_STATE](self._payload_payload["STATE_OFF"])
440  await self.async_publish_with_configasync_publish_with_config(
441  self._config_config[CONF_COMMAND_TOPIC], mqtt_payload
442  )
443  if self._optimistic_optimistic:
444  self._attr_is_on_attr_is_on = False
445  self.async_write_ha_stateasync_write_ha_state()
446 
447  async def async_set_humidity(self, humidity: float) -> None:
448  """Set the target humidity of the humidifier.
449 
450  This method is a coroutine.
451  """
452  mqtt_payload = self._command_templates_command_templates[ATTR_HUMIDITY](humidity)
453  await self.async_publish_with_configasync_publish_with_config(
454  self._config_config[CONF_TARGET_HUMIDITY_COMMAND_TOPIC], mqtt_payload
455  )
456  if self._optimistic_target_humidity_optimistic_target_humidity:
457  self._attr_target_humidity_attr_target_humidity = humidity
458  self.async_write_ha_stateasync_write_ha_state()
459 
460  async def async_set_mode(self, mode: str) -> None:
461  """Set the mode of the fan.
462 
463  This method is a coroutine.
464  """
465  if not self.available_modesavailable_modes or mode not in self.available_modesavailable_modes:
466  _LOGGER.warning("'%s'is not a valid mode", mode)
467  return
468 
469  mqtt_payload = self._command_templates_command_templates[ATTR_MODE](mode)
470  await self.async_publish_with_configasync_publish_with_config(
471  self._config_config[CONF_MODE_COMMAND_TOPIC], mqtt_payload
472  )
473  if self._optimistic_mode_optimistic_mode:
474  self._attr_mode_attr_mode = mode
475  self.async_write_ha_stateasync_write_ha_state()
None async_publish_with_config(self, str topic, PublishPayloadType payload)
Definition: entity.py:1377
bool add_subscription(self, str state_topic_config_key, Callable[[ReceiveMessage], None] msg_callback, set[str]|None tracked_attributes, bool disable_encoding=False)
Definition: entity.py:1484
None _current_humidity_received(self, ReceiveMessage msg)
Definition: humidifier.py:309
None _target_humidity_received(self, ReceiveMessage msg)
Definition: humidifier.py:341
None async_setup_entity_entry_helper(HomeAssistant hass, ConfigEntry entry, type[MqttEntity]|None entity_class, str domain, AddEntitiesCallback async_add_entities, VolSchemaType discovery_schema, VolSchemaType platform_schema_modern, dict[str, type[MqttEntity]]|None schema_class_mapping=None)
Definition: entity.py:245
ConfigType valid_humidity_range_configuration(ConfigType config)
Definition: humidifier.py:104
ConfigType valid_mode_configuration(ConfigType config)
Definition: humidifier.py:97
None async_setup_entry(HomeAssistant hass, ConfigEntry config_entry, AddEntitiesCallback async_add_entities)
Definition: humidifier.py:187