Home Assistant Unofficial Reference 2024.12.1
humidifier.py
Go to the documentation of this file.
1 """Adds support for generic hygrostat units."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections.abc import Callable, Mapping
7 from datetime import datetime, timedelta
8 import logging
9 from typing import TYPE_CHECKING, Any, cast
10 
12  ATTR_HUMIDITY,
13  MODE_AWAY,
14  MODE_NORMAL,
15  PLATFORM_SCHEMA as HUMIDIFIER_PLATFORM_SCHEMA,
16  HumidifierAction,
17  HumidifierDeviceClass,
18  HumidifierEntity,
19  HumidifierEntityFeature,
20 )
21 from homeassistant.config_entries import ConfigEntry
22 from homeassistant.const import (
23  ATTR_ENTITY_ID,
24  ATTR_MODE,
25  CONF_NAME,
26  CONF_UNIQUE_ID,
27  EVENT_HOMEASSISTANT_START,
28  SERVICE_TURN_OFF,
29  SERVICE_TURN_ON,
30  STATE_OFF,
31  STATE_ON,
32  STATE_UNAVAILABLE,
33  STATE_UNKNOWN,
34 )
35 from homeassistant.core import (
36  DOMAIN as HOMEASSISTANT_DOMAIN,
37  Event,
38  EventStateChangedData,
39  EventStateReportedData,
40  HomeAssistant,
41  State,
42  callback,
43 )
44 from homeassistant.helpers import condition, config_validation as cv
45 from homeassistant.helpers.device import async_device_info_to_link_from_entity
46 from homeassistant.helpers.entity_platform import AddEntitiesCallback
47 from homeassistant.helpers.event import (
48  async_track_state_change_event,
49  async_track_state_report_event,
50  async_track_time_interval,
51 )
52 from homeassistant.helpers.restore_state import RestoreEntity
53 from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
54 
55 from . import (
56  CONF_AWAY_FIXED,
57  CONF_AWAY_HUMIDITY,
58  CONF_DEVICE_CLASS,
59  CONF_DRY_TOLERANCE,
60  CONF_HUMIDIFIER,
61  CONF_INITIAL_STATE,
62  CONF_KEEP_ALIVE,
63  CONF_MAX_HUMIDITY,
64  CONF_MIN_DUR,
65  CONF_MIN_HUMIDITY,
66  CONF_SENSOR,
67  CONF_STALE_DURATION,
68  CONF_TARGET_HUMIDITY,
69  CONF_WET_TOLERANCE,
70  HYGROSTAT_SCHEMA,
71 )
72 
73 _LOGGER = logging.getLogger(__name__)
74 
75 ATTR_SAVED_HUMIDITY = "saved_humidity"
76 
77 PLATFORM_SCHEMA = HUMIDIFIER_PLATFORM_SCHEMA.extend(HYGROSTAT_SCHEMA.schema)
78 
79 
81  hass: HomeAssistant,
82  config: ConfigType,
83  async_add_entities: AddEntitiesCallback,
84  discovery_info: DiscoveryInfoType | None = None,
85 ) -> None:
86  """Set up the generic hygrostat platform."""
87  if discovery_info:
88  config = discovery_info
89  await _async_setup_config(
90  hass, config, config.get(CONF_UNIQUE_ID), async_add_entities
91  )
92 
93 
95  hass: HomeAssistant,
96  config_entry: ConfigEntry,
97  async_add_entities: AddEntitiesCallback,
98 ) -> None:
99  """Initialize config entry."""
100 
101  await _async_setup_config(
102  hass,
103  config_entry.options,
104  config_entry.entry_id,
105  async_add_entities,
106  )
107 
108 
109 def _time_period_or_none(value: Any) -> timedelta | None:
110  if value is None:
111  return None
112  return cast(timedelta, cv.time_period(value))
113 
114 
116  hass: HomeAssistant,
117  config: Mapping[str, Any],
118  unique_id: str | None,
119  async_add_entities: AddEntitiesCallback,
120 ) -> None:
121  name: str = config[CONF_NAME]
122  switch_entity_id: str = config[CONF_HUMIDIFIER]
123  sensor_entity_id: str = config[CONF_SENSOR]
124  min_humidity: float | None = config.get(CONF_MIN_HUMIDITY)
125  max_humidity: float | None = config.get(CONF_MAX_HUMIDITY)
126  target_humidity: float | None = config.get(CONF_TARGET_HUMIDITY)
127  device_class: HumidifierDeviceClass | None = config.get(CONF_DEVICE_CLASS)
128  min_cycle_duration: timedelta | None = _time_period_or_none(
129  config.get(CONF_MIN_DUR)
130  )
131  sensor_stale_duration: timedelta | None = _time_period_or_none(
132  config.get(CONF_STALE_DURATION)
133  )
134  dry_tolerance: float = config[CONF_DRY_TOLERANCE]
135  wet_tolerance: float = config[CONF_WET_TOLERANCE]
136  keep_alive: timedelta | None = _time_period_or_none(config.get(CONF_KEEP_ALIVE))
137  initial_state: bool | None = config.get(CONF_INITIAL_STATE)
138  away_humidity: int | None = config.get(CONF_AWAY_HUMIDITY)
139  away_fixed: bool | None = config.get(CONF_AWAY_FIXED)
140 
142  [
144  hass,
145  name,
146  switch_entity_id,
147  sensor_entity_id,
148  min_humidity,
149  max_humidity,
150  target_humidity,
151  device_class,
152  min_cycle_duration,
153  dry_tolerance,
154  wet_tolerance,
155  keep_alive,
156  initial_state,
157  away_humidity,
158  away_fixed,
159  sensor_stale_duration,
160  unique_id,
161  )
162  ]
163  )
164 
165 
167  """Representation of a Generic Hygrostat device."""
168 
169  _attr_should_poll = False
170 
171  def __init__(
172  self,
173  hass: HomeAssistant,
174  name: str,
175  switch_entity_id: str,
176  sensor_entity_id: str,
177  min_humidity: float | None,
178  max_humidity: float | None,
179  target_humidity: float | None,
180  device_class: HumidifierDeviceClass | None,
181  min_cycle_duration: timedelta | None,
182  dry_tolerance: float,
183  wet_tolerance: float,
184  keep_alive: timedelta | None,
185  initial_state: bool | None,
186  away_humidity: int | None,
187  away_fixed: bool | None,
188  sensor_stale_duration: timedelta | None,
189  unique_id: str | None,
190  ) -> None:
191  """Initialize the hygrostat."""
192  self._name_name = name
193  self._switch_entity_id_switch_entity_id = switch_entity_id
194  self._sensor_entity_id_sensor_entity_id = sensor_entity_id
196  hass,
197  switch_entity_id,
198  )
199  self._device_class_device_class = device_class or HumidifierDeviceClass.HUMIDIFIER
200  self._min_cycle_duration_min_cycle_duration = min_cycle_duration
201  self._dry_tolerance_dry_tolerance = dry_tolerance
202  self._wet_tolerance_wet_tolerance = wet_tolerance
203  self._keep_alive_keep_alive = keep_alive
204  self._state_state = initial_state
205  self._saved_target_humidity_saved_target_humidity = away_humidity or target_humidity
206  self._active_active = False
207  self._cur_humidity_cur_humidity: float | None = None
208  self._humidity_lock_humidity_lock = asyncio.Lock()
209  self._min_humidity_min_humidity = min_humidity
210  self._max_humidity_max_humidity = max_humidity
211  self._target_humidity_target_humidity = target_humidity
212  if away_humidity:
213  self._attr_supported_features |= HumidifierEntityFeature.MODES
214  self._away_humidity_away_humidity = away_humidity
215  self._away_fixed_away_fixed = away_fixed
216  self._sensor_stale_duration_sensor_stale_duration = sensor_stale_duration
217  self._remove_stale_tracking_remove_stale_tracking: Callable[[], None] | None = None
218  self._is_away_is_away = False
219  self._attr_action_attr_action = HumidifierAction.IDLE
220  self._attr_unique_id_attr_unique_id = unique_id
221 
222  async def async_added_to_hass(self) -> None:
223  """Run when entity about to be added."""
224  await super().async_added_to_hass()
225 
226  self.async_on_removeasync_on_remove(
228  self.hasshass, self._sensor_entity_id_sensor_entity_id, self._async_sensor_event_async_sensor_event
229  )
230  )
231  self.async_on_removeasync_on_remove(
233  self.hasshass, self._sensor_entity_id_sensor_entity_id, self._async_sensor_event_async_sensor_event
234  )
235  )
236  self.async_on_removeasync_on_remove(
238  self.hasshass, self._switch_entity_id_switch_entity_id, self._async_switch_event_async_switch_event
239  )
240  )
241  if self._keep_alive_keep_alive:
242  self.async_on_removeasync_on_remove(
244  self.hasshass, self._async_operate_async_operate, self._keep_alive_keep_alive
245  )
246  )
247 
248  async def _async_startup(event: Event | None) -> None:
249  """Init on startup."""
250  sensor_state = self.hasshass.states.get(self._sensor_entity_id_sensor_entity_id)
251  if sensor_state is None or sensor_state.state in (
252  STATE_UNKNOWN,
253  STATE_UNAVAILABLE,
254  ):
255  _LOGGER.debug(
256  "The sensor state is %s, initialization is delayed",
257  sensor_state.state if sensor_state is not None else "None",
258  )
259  return
260 
261  await self._async_sensor_update_async_sensor_update(sensor_state)
262 
263  self.hasshass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_startup)
264 
265  if (old_state := await self.async_get_last_stateasync_get_last_state()) is not None:
266  if old_state.attributes.get(ATTR_MODE) == MODE_AWAY:
267  self._is_away_is_away = True
268  self._saved_target_humidity_saved_target_humidity = self._target_humidity_target_humidity
269  self._target_humidity_target_humidity = self._away_humidity_away_humidity or self._target_humidity_target_humidity
270  if old_state.attributes.get(ATTR_HUMIDITY):
271  self._target_humidity_target_humidity = int(old_state.attributes[ATTR_HUMIDITY])
272  if old_state.attributes.get(ATTR_SAVED_HUMIDITY):
273  self._saved_target_humidity_saved_target_humidity = int(
274  old_state.attributes[ATTR_SAVED_HUMIDITY]
275  )
276  if old_state.state:
277  self._state_state = old_state.state == STATE_ON
278  if self._target_humidity_target_humidity is None:
279  if self._device_class_device_class == HumidifierDeviceClass.HUMIDIFIER:
280  self._target_humidity_target_humidity = self.min_humiditymin_humiditymin_humidity
281  else:
282  self._target_humidity_target_humidity = self.max_humiditymax_humiditymax_humidity
283  _LOGGER.warning(
284  "No previously saved humidity, setting to %s", self._target_humidity_target_humidity
285  )
286  if self._state_state is None:
287  self._state_state = False
288 
289  await _async_startup(None) # init the sensor
290 
291  async def async_will_remove_from_hass(self) -> None:
292  """Run when entity will be removed from hass."""
293  if self._remove_stale_tracking_remove_stale_tracking:
294  self._remove_stale_tracking_remove_stale_tracking()
295  return await super().async_will_remove_from_hass()
296 
297  @property
298  def available(self) -> bool:
299  """Return True if entity is available."""
300  return self._active_active
301 
302  @property
303  def extra_state_attributes(self) -> dict[str, Any] | None:
304  """Return the optional state attributes."""
305  if self._saved_target_humidity_saved_target_humidity:
306  return {ATTR_SAVED_HUMIDITY: self._saved_target_humidity_saved_target_humidity}
307  return None
308 
309  @property
310  def name(self) -> str:
311  """Return the name of the hygrostat."""
312  return self._name_name
313 
314  @property
315  def is_on(self) -> bool | None:
316  """Return true if the hygrostat is on."""
317  return self._state_state
318 
319  @property
320  def current_humidity(self) -> float | None:
321  """Return the measured humidity."""
322  return self._cur_humidity_cur_humidity
323 
324  @property
325  def target_humidity(self) -> float | None:
326  """Return the humidity we try to reach."""
327  return self._target_humidity_target_humidity
328 
329  @property
330  def mode(self) -> str | None:
331  """Return the current mode."""
332  if self._away_humidity_away_humidity is None:
333  return None
334  if self._is_away_is_away:
335  return MODE_AWAY
336  return MODE_NORMAL
337 
338  @property
339  def available_modes(self) -> list[str] | None:
340  """Return a list of available modes."""
341  if self._away_humidity_away_humidity:
342  return [MODE_NORMAL, MODE_AWAY]
343  return None
344 
345  @property
346  def device_class(self) -> HumidifierDeviceClass:
347  """Return the device class of the humidifier."""
348  return self._device_class_device_class
349 
350  async def async_turn_on(self, **kwargs: Any) -> None:
351  """Turn hygrostat on."""
352  if not self._active_active:
353  return
354  self._state_state = True
355  await self._async_operate_async_operate(force=True)
356  self.async_write_ha_stateasync_write_ha_state()
357 
358  async def async_turn_off(self, **kwargs: Any) -> None:
359  """Turn hygrostat off."""
360  if not self._active_active:
361  return
362  self._state_state = False
363  if self._is_device_active_is_device_active:
364  await self._async_device_turn_off_async_device_turn_off()
365  self.async_write_ha_stateasync_write_ha_state()
366 
367  async def async_set_humidity(self, humidity: int) -> None:
368  """Set new target humidity."""
369  if humidity is None:
370  return # type: ignore[unreachable]
371 
372  if self._is_away_is_away and self._away_fixed_away_fixed:
373  self._saved_target_humidity_saved_target_humidity = humidity
374  self.async_write_ha_stateasync_write_ha_state()
375  return
376 
377  self._target_humidity_target_humidity = humidity
378  await self._async_operate_async_operate()
379  self.async_write_ha_stateasync_write_ha_state()
380 
381  @property
382  def min_humidity(self) -> float:
383  """Return the minimum humidity."""
384  if self._min_humidity_min_humidity:
385  return self._min_humidity_min_humidity
386 
387  # get default humidity from super class
388  return super().min_humidity
389 
390  @property
391  def max_humidity(self) -> float:
392  """Return the maximum humidity."""
393  if self._max_humidity_max_humidity:
394  return self._max_humidity_max_humidity
395 
396  # Get default humidity from super class
397  return super().max_humidity
398 
400  self, event: Event[EventStateChangedData] | Event[EventStateReportedData]
401  ) -> None:
402  """Handle ambient humidity changes."""
403  new_state = event.data["new_state"]
404  if new_state is None:
405  return
406 
407  await self._async_sensor_update_async_sensor_update(new_state)
408 
409  async def _async_sensor_update(self, new_state: State) -> None:
410  """Update state based on humidity sensor."""
411 
412  if self._sensor_stale_duration_sensor_stale_duration:
413  if self._remove_stale_tracking_remove_stale_tracking:
414  self._remove_stale_tracking_remove_stale_tracking()
415 
416  self._remove_stale_tracking_remove_stale_tracking = async_track_time_interval(
417  self.hasshass,
418  self._async_sensor_not_responding_async_sensor_not_responding,
419  self._sensor_stale_duration_sensor_stale_duration,
420  )
421 
422  await self._async_update_humidity_async_update_humidity(new_state.state)
423  await self._async_operate_async_operate()
424  self.async_write_ha_stateasync_write_ha_state()
425 
426  async def _async_sensor_not_responding(self, now: datetime | None = None) -> None:
427  """Handle sensor stale event."""
428 
429  state = self.hasshass.states.get(self._sensor_entity_id_sensor_entity_id)
430  _LOGGER.debug(
431  "Sensor has not been updated for %s",
432  now - state.last_reported if now and state else "---",
433  )
434  _LOGGER.warning("Sensor is stalled, call the emergency stop")
435  await self._async_update_humidity_async_update_humidity("Stalled")
436 
437  @callback
438  def _async_switch_event(self, event: Event[EventStateChangedData]) -> None:
439  """Handle humidifier switch state changes."""
440  self._async_switch_changed_async_switch_changed(event.data["new_state"])
441 
442  @callback
443  def _async_switch_changed(self, new_state: State | None) -> None:
444  """Handle humidifier switch state changes."""
445  if new_state is None:
446  return
447 
448  if new_state.state == STATE_ON:
449  if self._device_class_device_class == HumidifierDeviceClass.DEHUMIDIFIER:
450  self._attr_action_attr_action = HumidifierAction.DRYING
451  else:
452  self._attr_action_attr_action = HumidifierAction.HUMIDIFYING
453  else:
454  self._attr_action_attr_action = HumidifierAction.IDLE
455 
456  self.async_write_ha_stateasync_write_ha_state()
457 
458  async def _async_update_humidity(self, humidity: str) -> None:
459  """Update hygrostat with latest state from sensor."""
460  try:
461  self._cur_humidity_cur_humidity = float(humidity)
462  except ValueError as ex:
463  if self._active_active:
464  _LOGGER.warning("Unable to update from sensor: %s", ex)
465  self._active_active = False
466  else:
467  _LOGGER.debug("Unable to update from sensor: %s", ex)
468  self._cur_humidity_cur_humidity = None
469  if self._is_device_active_is_device_active:
470  await self._async_device_turn_off_async_device_turn_off()
471 
472  async def _async_operate(
473  self, time: datetime | None = None, force: bool = False
474  ) -> None:
475  """Check if we need to turn humidifying on or off."""
476  async with self._humidity_lock_humidity_lock:
477  if not self._active_active and None not in (
478  self._cur_humidity_cur_humidity,
479  self._target_humidity_target_humidity,
480  ):
481  self._active_active = True
482  force = True
483  _LOGGER.debug(
484  (
485  "Obtained current and target humidity. "
486  "Generic hygrostat active. %s, %s"
487  ),
488  self._cur_humidity_cur_humidity,
489  self._target_humidity_target_humidity,
490  )
491 
492  if not self._active_active or not self._state_state:
493  return
494 
495  if not force and time is None:
496  # If the `force` argument is True, we
497  # ignore `min_cycle_duration`.
498  # If the `time` argument is not none, we were invoked for
499  # keep-alive purposes, and `min_cycle_duration` is irrelevant.
500  if self._min_cycle_duration_min_cycle_duration:
501  if self._is_device_active_is_device_active:
502  current_state = STATE_ON
503  else:
504  current_state = STATE_OFF
505  long_enough = condition.state(
506  self.hasshass,
507  self._switch_entity_id_switch_entity_id,
508  current_state,
509  self._min_cycle_duration_min_cycle_duration,
510  )
511  if not long_enough:
512  return
513 
514  if force:
515  # Ignore the tolerance when switched on manually
516  dry_tolerance: float = 0
517  wet_tolerance: float = 0
518  else:
519  dry_tolerance = self._dry_tolerance_dry_tolerance
520  wet_tolerance = self._wet_tolerance_wet_tolerance
521 
522  if TYPE_CHECKING:
523  assert self._target_humidity_target_humidity is not None
524  assert self._cur_humidity_cur_humidity is not None
525  too_dry = self._target_humidity_target_humidity - self._cur_humidity_cur_humidity >= dry_tolerance
526  too_wet = self._cur_humidity_cur_humidity - self._target_humidity_target_humidity >= wet_tolerance
527  if self._is_device_active_is_device_active:
528  if (
529  self._device_class_device_class == HumidifierDeviceClass.HUMIDIFIER and too_wet
530  ) or (
531  self._device_class_device_class == HumidifierDeviceClass.DEHUMIDIFIER and too_dry
532  ):
533  _LOGGER.debug("Turning off humidifier %s", self._switch_entity_id_switch_entity_id)
534  await self._async_device_turn_off_async_device_turn_off()
535  elif time is not None:
536  # The time argument is passed only in keep-alive case
537  await self._async_device_turn_on_async_device_turn_on()
538  elif (
539  self._device_class_device_class == HumidifierDeviceClass.HUMIDIFIER and too_dry
540  ) or (self._device_class_device_class == HumidifierDeviceClass.DEHUMIDIFIER and too_wet):
541  _LOGGER.debug("Turning on humidifier %s", self._switch_entity_id_switch_entity_id)
542  await self._async_device_turn_on_async_device_turn_on()
543  elif time is not None:
544  # The time argument is passed only in keep-alive case
545  await self._async_device_turn_off_async_device_turn_off()
546 
547  @property
548  def _is_device_active(self) -> bool:
549  """If the toggleable device is currently active."""
550  return self.hasshass.states.is_state(self._switch_entity_id_switch_entity_id, STATE_ON)
551 
552  async def _async_device_turn_on(self) -> None:
553  """Turn humidifier toggleable device on."""
554  data = {ATTR_ENTITY_ID: self._switch_entity_id_switch_entity_id}
555  await self.hasshass.services.async_call(HOMEASSISTANT_DOMAIN, SERVICE_TURN_ON, data)
556 
557  async def _async_device_turn_off(self) -> None:
558  """Turn humidifier toggleable device off."""
559  data = {ATTR_ENTITY_ID: self._switch_entity_id_switch_entity_id}
560  await self.hasshass.services.async_call(
561  HOMEASSISTANT_DOMAIN, SERVICE_TURN_OFF, data
562  )
563 
564  async def async_set_mode(self, mode: str) -> None:
565  """Set new mode.
566 
567  This method must be run in the event loop and returns a coroutine.
568  """
569  if self._away_humidity_away_humidity is None:
570  return
571  if mode == MODE_AWAY and not self._is_away_is_away:
572  self._is_away_is_away = True
573  if not self._saved_target_humidity_saved_target_humidity:
574  self._saved_target_humidity_saved_target_humidity = self._away_humidity_away_humidity
575  self._saved_target_humidity_saved_target_humidity, self._target_humidity_target_humidity = (
576  self._target_humidity_target_humidity,
577  self._saved_target_humidity_saved_target_humidity,
578  )
579  await self._async_operate_async_operate(force=True)
580  elif mode == MODE_NORMAL and self._is_away_is_away:
581  self._is_away_is_away = False
582  self._saved_target_humidity_saved_target_humidity, self._target_humidity_target_humidity = (
583  self._target_humidity_target_humidity,
584  self._saved_target_humidity_saved_target_humidity,
585  )
586  await self._async_operate_async_operate(force=True)
587 
588  self.async_write_ha_stateasync_write_ha_state()
None _async_switch_event(self, Event[EventStateChangedData] event)
Definition: humidifier.py:438
None __init__(self, HomeAssistant hass, str name, str switch_entity_id, str sensor_entity_id, float|None min_humidity, float|None max_humidity, float|None target_humidity, HumidifierDeviceClass|None device_class, timedelta|None min_cycle_duration, float dry_tolerance, float wet_tolerance, timedelta|None keep_alive, bool|None initial_state, int|None away_humidity, bool|None away_fixed, timedelta|None sensor_stale_duration, str|None unique_id)
Definition: humidifier.py:190
None _async_operate(self, datetime|None time=None, bool force=False)
Definition: humidifier.py:474
None _async_sensor_event(self, Event[EventStateChangedData]|Event[EventStateReportedData] event)
Definition: humidifier.py:401
None async_on_remove(self, CALLBACK_TYPE func)
Definition: entity.py:1331
None async_setup_platform(HomeAssistant hass, ConfigType config, AddEntitiesCallback async_add_entities, DiscoveryInfoType|None discovery_info=None)
Definition: humidifier.py:85
None _async_setup_config(HomeAssistant hass, Mapping[str, Any] config, str|None unique_id, AddEntitiesCallback async_add_entities)
Definition: humidifier.py:120
None async_setup_entry(HomeAssistant hass, ConfigEntry config_entry, AddEntitiesCallback async_add_entities)
Definition: humidifier.py:98
dr.DeviceInfo|None async_device_info_to_link_from_entity(HomeAssistant hass, str entity_id_or_uuid)
Definition: device.py:28
CALLBACK_TYPE async_track_state_report_event(HomeAssistant hass, str|Iterable[str] entity_ids, Callable[[Event[EventStateReportedData]], Any] action, HassJobType|None job_type=None)
Definition: event.py:412
CALLBACK_TYPE async_track_state_change_event(HomeAssistant hass, str|Iterable[str] entity_ids, Callable[[Event[EventStateChangedData]], Any] action, HassJobType|None job_type=None)
Definition: event.py:314
CALLBACK_TYPE async_track_time_interval(HomeAssistant hass, Callable[[datetime], Coroutine[Any, Any, None]|None] action, timedelta interval, *str|None name=None, bool|None cancel_on_shutdown=None)
Definition: event.py:1679