Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Provides functionality to interact with fans."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from datetime import timedelta
7 from enum import IntFlag
8 import functools as ft
9 import logging
10 import math
11 from typing import Any, final
12 
13 from propcache import cached_property
14 import voluptuous as vol
15 
16 from homeassistant.config_entries import ConfigEntry
17 from homeassistant.const import (
18  SERVICE_TOGGLE,
19  SERVICE_TURN_OFF,
20  SERVICE_TURN_ON,
21  STATE_ON,
22 )
23 from homeassistant.core import HomeAssistant, callback
24 from homeassistant.exceptions import ServiceValidationError
25 from homeassistant.helpers import config_validation as cv
27  DeprecatedConstantEnum,
28  all_with_deprecated_constants,
29  check_if_deprecated_constant,
30  dir_with_deprecated_constants,
31 )
32 from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription
33 from homeassistant.helpers.entity_component import EntityComponent
34 from homeassistant.helpers.entity_platform import EntityPlatform
35 from homeassistant.helpers.typing import ConfigType
36 from homeassistant.loader import bind_hass
37 from homeassistant.util.hass_dict import HassKey
39  percentage_to_ranged_value,
40  ranged_value_to_percentage,
41 )
42 
43 _LOGGER = logging.getLogger(__name__)
44 
45 DOMAIN = "fan"
46 DATA_COMPONENT: HassKey[EntityComponent[FanEntity]] = HassKey(DOMAIN)
47 ENTITY_ID_FORMAT = DOMAIN + ".{}"
48 PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
49 PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
50 SCAN_INTERVAL = timedelta(seconds=30)
51 
52 
53 class FanEntityFeature(IntFlag):
54  """Supported features of the fan entity."""
55 
56  SET_SPEED = 1
57  OSCILLATE = 2
58  DIRECTION = 4
59  PRESET_MODE = 8
60  TURN_OFF = 16
61  TURN_ON = 32
62 
63 
64 # These SUPPORT_* constants are deprecated as of Home Assistant 2022.5.
65 # Please use the FanEntityFeature enum instead.
66 _DEPRECATED_SUPPORT_SET_SPEED = DeprecatedConstantEnum(
67  FanEntityFeature.SET_SPEED, "2025.1"
68 )
69 _DEPRECATED_SUPPORT_OSCILLATE = DeprecatedConstantEnum(
70  FanEntityFeature.OSCILLATE, "2025.1"
71 )
72 _DEPRECATED_SUPPORT_DIRECTION = DeprecatedConstantEnum(
73  FanEntityFeature.DIRECTION, "2025.1"
74 )
75 _DEPRECATED_SUPPORT_PRESET_MODE = DeprecatedConstantEnum(
76  FanEntityFeature.PRESET_MODE, "2025.1"
77 )
78 
79 SERVICE_INCREASE_SPEED = "increase_speed"
80 SERVICE_DECREASE_SPEED = "decrease_speed"
81 SERVICE_OSCILLATE = "oscillate"
82 SERVICE_SET_DIRECTION = "set_direction"
83 SERVICE_SET_PERCENTAGE = "set_percentage"
84 SERVICE_SET_PRESET_MODE = "set_preset_mode"
85 
86 DIRECTION_FORWARD = "forward"
87 DIRECTION_REVERSE = "reverse"
88 
89 ATTR_PERCENTAGE = "percentage"
90 ATTR_PERCENTAGE_STEP = "percentage_step"
91 ATTR_OSCILLATING = "oscillating"
92 ATTR_DIRECTION = "direction"
93 ATTR_PRESET_MODE = "preset_mode"
94 ATTR_PRESET_MODES = "preset_modes"
95 
96 # mypy: disallow-any-generics
97 
98 
100  """Raised when the preset_mode is not in the preset_modes list."""
101 
102  def __init__(
103  self, *args: object, translation_placeholders: dict[str, str] | None = None
104  ) -> None:
105  """Initialize the exception."""
106  super().__init__(
107  *args,
108  translation_domain=DOMAIN,
109  translation_key="not_valid_preset_mode",
110  translation_placeholders=translation_placeholders,
111  )
112 
113 
114 @bind_hass
115 def is_on(hass: HomeAssistant, entity_id: str) -> bool:
116  """Return if the fans are on based on the statemachine."""
117  entity = hass.states.get(entity_id)
118  assert entity
119  return entity.state == STATE_ON
120 
121 
122 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
123  """Expose fan control via statemachine and services."""
124  component = hass.data[DATA_COMPONENT] = EntityComponent[FanEntity](
125  _LOGGER, DOMAIN, hass, SCAN_INTERVAL
126  )
127 
128  await component.async_setup(config)
129 
130  # After the transition to percentage and preset_modes concludes,
131  # switch this back to async_turn_on and remove async_turn_on_compat
132  component.async_register_entity_service(
133  SERVICE_TURN_ON,
134  {
135  vol.Optional(ATTR_PERCENTAGE): vol.All(
136  vol.Coerce(int), vol.Range(min=0, max=100)
137  ),
138  vol.Optional(ATTR_PRESET_MODE): cv.string,
139  },
140  "async_handle_turn_on_service",
141  [FanEntityFeature.TURN_ON],
142  )
143  component.async_register_entity_service(
144  SERVICE_TURN_OFF, None, "async_turn_off", [FanEntityFeature.TURN_OFF]
145  )
146  component.async_register_entity_service(
147  SERVICE_TOGGLE,
148  None,
149  "async_toggle",
150  [FanEntityFeature.TURN_OFF, FanEntityFeature.TURN_ON],
151  )
152  component.async_register_entity_service(
153  SERVICE_INCREASE_SPEED,
154  {
155  vol.Optional(ATTR_PERCENTAGE_STEP): vol.All(
156  vol.Coerce(int), vol.Range(min=0, max=100)
157  )
158  },
159  "async_increase_speed",
160  [FanEntityFeature.SET_SPEED],
161  )
162  component.async_register_entity_service(
163  SERVICE_DECREASE_SPEED,
164  {
165  vol.Optional(ATTR_PERCENTAGE_STEP): vol.All(
166  vol.Coerce(int), vol.Range(min=0, max=100)
167  )
168  },
169  "async_decrease_speed",
170  [FanEntityFeature.SET_SPEED],
171  )
172  component.async_register_entity_service(
173  SERVICE_OSCILLATE,
174  {vol.Required(ATTR_OSCILLATING): cv.boolean},
175  "async_oscillate",
176  [FanEntityFeature.OSCILLATE],
177  )
178  component.async_register_entity_service(
179  SERVICE_SET_DIRECTION,
180  {vol.Optional(ATTR_DIRECTION): cv.string},
181  "async_set_direction",
182  [FanEntityFeature.DIRECTION],
183  )
184  component.async_register_entity_service(
185  SERVICE_SET_PERCENTAGE,
186  {
187  vol.Required(ATTR_PERCENTAGE): vol.All(
188  vol.Coerce(int), vol.Range(min=0, max=100)
189  )
190  },
191  "async_set_percentage",
192  [FanEntityFeature.SET_SPEED],
193  )
194  component.async_register_entity_service(
195  SERVICE_SET_PRESET_MODE,
196  {vol.Required(ATTR_PRESET_MODE): cv.string},
197  "async_handle_set_preset_mode_service",
198  [FanEntityFeature.SET_SPEED, FanEntityFeature.PRESET_MODE],
199  )
200 
201  return True
202 
203 
204 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
205  """Set up a config entry."""
206  return await hass.data[DATA_COMPONENT].async_setup_entry(entry)
207 
208 
209 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
210  """Unload a config entry."""
211  return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
212 
213 
214 class FanEntityDescription(ToggleEntityDescription, frozen_or_thawed=True):
215  """A class that describes fan entities."""
216 
217 
218 CACHED_PROPERTIES_WITH_ATTR_ = {
219  "percentage",
220  "speed_count",
221  "current_direction",
222  "oscillating",
223  "supported_features",
224  "preset_mode",
225  "preset_modes",
226 }
227 
228 
229 class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
230  """Base class for fan entities."""
231 
232  _entity_component_unrecorded_attributes = frozenset({ATTR_PRESET_MODES})
233 
234  entity_description: FanEntityDescription
235  _attr_current_direction: str | None = None
236  _attr_oscillating: bool | None = None
237  _attr_percentage: int | None = 0
238  _attr_preset_mode: str | None = None
239  _attr_preset_modes: list[str] | None = None
240  _attr_speed_count: int = 100
241  _attr_supported_features: FanEntityFeature = FanEntityFeature(0)
242 
243  __mod_supported_features: FanEntityFeature = FanEntityFeature(0)
244  # Integrations should set `_enable_turn_on_off_backwards_compatibility` to False
245  # once migrated and set the feature flags TURN_ON/TURN_OFF as needed.
246  _enable_turn_on_off_backwards_compatibility: bool = True
247 
248  def __getattribute__(self, name: str, /) -> Any:
249  """Get attribute.
250 
251  Modify return of `supported_features` to
252  include `_mod_supported_features` if attribute is set.
253  """
254  if name != "supported_features":
255  return super().__getattribute__(name)
256 
257  # Convert the supported features to ClimateEntityFeature.
258  # Remove this compatibility shim in 2025.1 or later.
259  _supported_features: FanEntityFeature = super().__getattribute__(
260  "supported_features"
261  )
262  _mod_supported_features: FanEntityFeature = super().__getattribute__(
263  "_FanEntity__mod_supported_features"
264  )
265  if type(_supported_features) is int: # noqa: E721
266  _features = FanEntityFeature(_supported_features)
267  self._report_deprecated_supported_features_values_report_deprecated_supported_features_values(_features)
268  else:
269  _features = _supported_features
270 
271  if not _mod_supported_features:
272  return _features
273 
274  # Add automatically calculated FanEntityFeature.TURN_OFF/TURN_ON to
275  # supported features and return it
276  return _features | _mod_supported_features
277 
278  @callback
280  self,
281  hass: HomeAssistant,
282  platform: EntityPlatform,
283  parallel_updates: asyncio.Semaphore | None,
284  ) -> None:
285  """Start adding an entity to a platform."""
286  super().add_to_platform_start(hass, platform, parallel_updates)
287 
288  def _report_turn_on_off(feature: str, method: str) -> None:
289  """Log warning not implemented turn on/off feature."""
290  report_issue = self._suggest_report_issue_suggest_report_issue()
291  message = (
292  "Entity %s (%s) does not set FanEntityFeature.%s"
293  " but implements the %s method. Please %s"
294  )
295  _LOGGER.warning(
296  message,
297  self.entity_identity_id,
298  type(self),
299  feature,
300  method,
301  report_issue,
302  )
303 
304  # Adds FanEntityFeature.TURN_OFF/TURN_ON depending on service calls implemented
305  # This should be removed in 2025.2.
306  if self._enable_turn_on_off_backwards_compatibility is False:
307  # Return if integration has migrated already
308  return
309 
310  supported_features = self.supported_featuressupported_featuressupported_features
311  if supported_features & (FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF):
312  # The entity supports both turn_on and turn_off, the backwards compatibility
313  # checks are not needed
314  return
315 
316  if not supported_features & FanEntityFeature.TURN_OFF and (
317  type(self).async_turn_off is not ToggleEntity.async_turn_off
318  or type(self).turn_off is not ToggleEntity.turn_off
319  ):
320  # turn_off implicitly supported by implementing turn_off method
321  _report_turn_on_off("TURN_OFF", "turn_off")
322  self.__mod_supported_features |= ( # pylint: disable=unused-private-member
323  FanEntityFeature.TURN_OFF
324  )
325 
326  if not supported_features & FanEntityFeature.TURN_ON and (
327  type(self).async_turn_on is not FanEntity.async_turn_on
328  or type(self).turn_on is not FanEntity.turn_on
329  ):
330  # turn_on implicitly supported by implementing turn_on method
331  _report_turn_on_off("TURN_ON", "turn_on")
332  self.__mod_supported_features |= ( # pylint: disable=unused-private-member
333  FanEntityFeature.TURN_ON
334  )
335 
336  def set_percentage(self, percentage: int) -> None:
337  """Set the speed of the fan, as a percentage."""
338  raise NotImplementedError
339 
340  async def async_set_percentage(self, percentage: int) -> None:
341  """Set the speed of the fan, as a percentage."""
342  if percentage == 0:
343  await self.async_turn_offasync_turn_off()
344  await self.hasshass.async_add_executor_job(self.set_percentageset_percentage, percentage)
345 
346  async def async_increase_speed(self, percentage_step: int | None = None) -> None:
347  """Increase the speed of the fan."""
348  await self._async_adjust_speed_async_adjust_speed(1, percentage_step)
349 
350  async def async_decrease_speed(self, percentage_step: int | None = None) -> None:
351  """Decrease the speed of the fan."""
352  await self._async_adjust_speed_async_adjust_speed(-1, percentage_step)
353 
355  self, modifier: int, percentage_step: int | None
356  ) -> None:
357  """Increase or decrease the speed of the fan."""
358  current_percentage = self.percentagepercentage or 0
359 
360  if percentage_step is not None:
361  new_percentage = current_percentage + (percentage_step * modifier)
362  else:
363  speed_range = (1, self.speed_countspeed_count)
364  speed_index = math.ceil(
365  percentage_to_ranged_value(speed_range, current_percentage)
366  )
367  new_percentage = ranged_value_to_percentage(
368  speed_range, speed_index + modifier
369  )
370 
371  new_percentage = max(0, min(100, new_percentage))
372 
373  await self.async_set_percentageasync_set_percentage(new_percentage)
374 
375  def set_preset_mode(self, preset_mode: str) -> None:
376  """Set new preset mode."""
377  raise NotImplementedError
378 
379  @final
380  async def async_handle_set_preset_mode_service(self, preset_mode: str) -> None:
381  """Validate and set new preset mode."""
382  self._valid_preset_mode_or_raise_valid_preset_mode_or_raise(preset_mode)
383  await self.async_set_preset_modeasync_set_preset_mode(preset_mode)
384 
385  async def async_set_preset_mode(self, preset_mode: str) -> None:
386  """Set new preset mode."""
387  await self.hasshass.async_add_executor_job(self.set_preset_modeset_preset_mode, preset_mode)
388 
389  @final
390  @callback
391  def _valid_preset_mode_or_raise(self, preset_mode: str) -> None:
392  """Raise NotValidPresetModeError on invalid preset_mode."""
393  preset_modes = self.preset_modespreset_modes
394  if not preset_modes or preset_mode not in preset_modes:
395  preset_modes_str: str = ", ".join(preset_modes or [])
397  translation_placeholders={
398  "preset_mode": preset_mode,
399  "preset_modes": preset_modes_str,
400  },
401  )
402 
403  def set_direction(self, direction: str) -> None:
404  """Set the direction of the fan."""
405  raise NotImplementedError
406 
407  async def async_set_direction(self, direction: str) -> None:
408  """Set the direction of the fan."""
409  await self.hasshass.async_add_executor_job(self.set_directionset_direction, direction)
410 
411  def turn_on(
412  self,
413  percentage: int | None = None,
414  preset_mode: str | None = None,
415  **kwargs: Any,
416  ) -> None:
417  """Turn on the fan."""
418  raise NotImplementedError
419 
420  @final
422  self,
423  percentage: int | None = None,
424  preset_mode: str | None = None,
425  **kwargs: Any,
426  ) -> None:
427  """Validate and turn on the fan."""
428  if preset_mode is not None:
429  self._valid_preset_mode_or_raise_valid_preset_mode_or_raise(preset_mode)
430  await self.async_turn_onasync_turn_onasync_turn_on(percentage, preset_mode, **kwargs)
431 
432  async def async_turn_on(
433  self,
434  percentage: int | None = None,
435  preset_mode: str | None = None,
436  **kwargs: Any,
437  ) -> None:
438  """Turn on the fan."""
439  await self.hasshass.async_add_executor_job(
440  ft.partial(
441  self.turn_onturn_onturn_on,
442  percentage=percentage,
443  preset_mode=preset_mode,
444  **kwargs,
445  )
446  )
447 
448  def oscillate(self, oscillating: bool) -> None:
449  """Oscillate the fan."""
450  raise NotImplementedError
451 
452  async def async_oscillate(self, oscillating: bool) -> None:
453  """Oscillate the fan."""
454  await self.hasshass.async_add_executor_job(self.oscillateoscillate, oscillating)
455 
456  @property
457  def is_on(self) -> bool | None:
458  """Return true if the entity is on."""
459  return (
460  self.percentagepercentage is not None and self.percentagepercentage > 0
461  ) or self.preset_modepreset_mode is not None
462 
463  @cached_property
464  def percentage(self) -> int | None:
465  """Return the current speed as a percentage."""
466  return self._attr_percentage
467 
468  @cached_property
469  def speed_count(self) -> int:
470  """Return the number of speeds the fan supports."""
471  return self._attr_speed_count
472 
473  @property
474  def percentage_step(self) -> float:
475  """Return the step size for percentage."""
476  return 100 / self.speed_countspeed_count
477 
478  @cached_property
479  def current_direction(self) -> str | None:
480  """Return the current direction of the fan."""
481  return self._attr_current_direction
482 
483  @cached_property
484  def oscillating(self) -> bool | None:
485  """Return whether or not the fan is currently oscillating."""
486  return self._attr_oscillating
487 
488  @property
489  def capability_attributes(self) -> dict[str, list[str] | None]:
490  """Return capability attributes."""
491  attrs = {}
492  supported_features = self.supported_featuressupported_featuressupported_features
493 
494  if (
495  FanEntityFeature.SET_SPEED in supported_features
496  or FanEntityFeature.PRESET_MODE in supported_features
497  ):
498  attrs[ATTR_PRESET_MODES] = self.preset_modespreset_modes
499 
500  return attrs
501 
502  @final
503  @property
504  def state_attributes(self) -> dict[str, float | str | None]:
505  """Return optional state attributes."""
506  data: dict[str, float | str | None] = {}
507  supported_features = self.supported_featuressupported_featuressupported_features
508 
509  if FanEntityFeature.DIRECTION in supported_features:
510  data[ATTR_DIRECTION] = self.current_directioncurrent_direction
511 
512  if FanEntityFeature.OSCILLATE in supported_features:
513  data[ATTR_OSCILLATING] = self.oscillatingoscillating
514 
515  has_set_speed = FanEntityFeature.SET_SPEED in supported_features
516 
517  if has_set_speed:
518  data[ATTR_PERCENTAGE] = self.percentagepercentage
519  data[ATTR_PERCENTAGE_STEP] = self.percentage_steppercentage_step
520 
521  if has_set_speed or FanEntityFeature.PRESET_MODE in supported_features:
522  data[ATTR_PRESET_MODE] = self.preset_modepreset_mode
523 
524  return data
525 
526  @cached_property
527  def supported_features(self) -> FanEntityFeature:
528  """Flag supported features."""
529  return self._attr_supported_features
530 
531  @cached_property
532  def preset_mode(self) -> str | None:
533  """Return the current preset mode, e.g., auto, smart, interval, favorite.
534 
535  Requires FanEntityFeature.SET_SPEED.
536  """
537  return self._attr_preset_mode
538 
539  @cached_property
540  def preset_modes(self) -> list[str] | None:
541  """Return a list of available preset modes.
542 
543  Requires FanEntityFeature.SET_SPEED.
544  """
545  return self._attr_preset_modes
546 
547 
548 # These can be removed if no deprecated constant are in this module anymore
549 __getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals())
550 __dir__ = ft.partial(
551  dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
552 )
553 __all__ = all_with_deprecated_constants(globals())
None _async_adjust_speed(self, int modifier, int|None percentage_step)
Definition: __init__.py:356
None oscillate(self, bool oscillating)
Definition: __init__.py:448
Any __getattribute__(self, str name)
Definition: __init__.py:248
None set_preset_mode(self, str preset_mode)
Definition: __init__.py:375
None async_decrease_speed(self, int|None percentage_step=None)
Definition: __init__.py:350
None set_percentage(self, int percentage)
Definition: __init__.py:336
None async_handle_set_preset_mode_service(self, str preset_mode)
Definition: __init__.py:380
list[str]|None preset_modes(self)
Definition: __init__.py:540
None async_set_direction(self, str direction)
Definition: __init__.py:407
None set_direction(self, str direction)
Definition: __init__.py:403
None turn_on(self, int|None percentage=None, str|None preset_mode=None, **Any kwargs)
Definition: __init__.py:416
None async_turn_on(self, int|None percentage=None, str|None preset_mode=None, **Any kwargs)
Definition: __init__.py:437
None _valid_preset_mode_or_raise(self, str preset_mode)
Definition: __init__.py:391
None async_set_percentage(self, int percentage)
Definition: __init__.py:340
None add_to_platform_start(self, HomeAssistant hass, EntityPlatform platform, asyncio.Semaphore|None parallel_updates)
Definition: __init__.py:284
None async_set_preset_mode(self, str preset_mode)
Definition: __init__.py:385
None async_oscillate(self, bool oscillating)
Definition: __init__.py:452
dict[str, float|str|None] state_attributes(self)
Definition: __init__.py:504
None async_handle_turn_on_service(self, int|None percentage=None, str|None preset_mode=None, **Any kwargs)
Definition: __init__.py:426
FanEntityFeature supported_features(self)
Definition: __init__.py:527
dict[str, list[str]|None] capability_attributes(self)
Definition: __init__.py:489
None async_increase_speed(self, int|None percentage_step=None)
Definition: __init__.py:346
None __init__(self, *object args, dict[str, str]|None translation_placeholders=None)
Definition: __init__.py:104
None _report_deprecated_supported_features_values(self, IntFlag replacement)
Definition: entity.py:1645
int|None supported_features(self)
Definition: entity.py:861
None async_turn_off(self, **Any kwargs)
Definition: entity.py:1709
None async_turn_on(self, **Any kwargs)
Definition: entity.py:1701
None turn_on(self, **Any kwargs)
Definition: entity.py:1697
bool async_setup(HomeAssistant hass, ConfigType config)
Definition: __init__.py:122
bool async_unload_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:209
bool async_setup_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:204
bool is_on(HomeAssistant hass, str entity_id)
Definition: __init__.py:115
list[str] all_with_deprecated_constants(dict[str, Any] module_globals)
Definition: deprecation.py:356
float percentage_to_ranged_value(tuple[float, float] low_high_range, float percentage)
Definition: percentage.py:81
int ranged_value_to_percentage(tuple[float, float] low_high_range, float value)
Definition: percentage.py:64