Home Assistant Unofficial Reference 2024.12.1
fan.py
Go to the documentation of this file.
1 """Support for Z-Wave fans."""
2 
3 from __future__ import annotations
4 
5 import math
6 from typing import Any, cast
7 
8 from zwave_js_server.client import Client as ZwaveClient
9 from zwave_js_server.const import TARGET_VALUE_PROPERTY, CommandClass
10 from zwave_js_server.const.command_class.multilevel_switch import SET_TO_PREVIOUS_VALUE
11 from zwave_js_server.const.command_class.thermostat import (
12  THERMOSTAT_FAN_OFF_PROPERTY,
13  THERMOSTAT_FAN_STATE_PROPERTY,
14 )
15 from zwave_js_server.model.driver import Driver
16 from zwave_js_server.model.value import Value as ZwaveValue
17 
18 from homeassistant.components.fan import (
19  DOMAIN as FAN_DOMAIN,
20  FanEntity,
21  FanEntityFeature,
22 )
23 from homeassistant.config_entries import ConfigEntry
24 from homeassistant.core import HomeAssistant, callback
25 from homeassistant.exceptions import HomeAssistantError
26 from homeassistant.helpers.dispatcher import async_dispatcher_connect
27 from homeassistant.helpers.entity_platform import AddEntitiesCallback
29  percentage_to_ranged_value,
30  ranged_value_to_percentage,
31 )
32 
33 from .const import DATA_CLIENT, DOMAIN
34 from .discovery import ZwaveDiscoveryInfo
35 from .discovery_data_template import FanValueMapping, FanValueMappingDataTemplate
36 from .entity import ZWaveBaseEntity
37 from .helpers import get_value_of_zwave_value
38 
39 PARALLEL_UPDATES = 0
40 
41 DEFAULT_SPEED_RANGE = (1, 99) # off is not included
42 
43 ATTR_FAN_STATE = "fan_state"
44 
45 
47  hass: HomeAssistant,
48  config_entry: ConfigEntry,
49  async_add_entities: AddEntitiesCallback,
50 ) -> None:
51  """Set up Z-Wave Fan from Config Entry."""
52  client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT]
53 
54  @callback
55  def async_add_fan(info: ZwaveDiscoveryInfo) -> None:
56  """Add Z-Wave fan."""
57  driver = client.driver
58  assert driver is not None # Driver is ready before platforms are loaded.
59  entities: list[ZWaveBaseEntity] = []
60  if info.platform_hint == "has_fan_value_mapping":
61  entities.append(ValueMappingZwaveFan(config_entry, driver, info))
62  elif info.platform_hint == "thermostat_fan":
63  entities.append(ZwaveThermostatFan(config_entry, driver, info))
64  else:
65  entities.append(ZwaveFan(config_entry, driver, info))
66 
67  async_add_entities(entities)
68 
69  config_entry.async_on_unload(
71  hass,
72  f"{DOMAIN}_{config_entry.entry_id}_add_{FAN_DOMAIN}",
73  async_add_fan,
74  )
75  )
76 
77 
79  """Representation of a Z-Wave fan."""
80 
81  _attr_supported_features = (
82  FanEntityFeature.SET_SPEED
83  | FanEntityFeature.TURN_OFF
84  | FanEntityFeature.TURN_ON
85  )
86  _enable_turn_on_off_backwards_compatibility = False
87 
88  def __init__(
89  self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo
90  ) -> None:
91  """Initialize the fan."""
92  super().__init__(config_entry, driver, info)
93  target_value = self.get_zwave_valueget_zwave_value(TARGET_VALUE_PROPERTY)
94  assert target_value
95  self._target_value_target_value = target_value
96 
97  self._use_optimistic_state_use_optimistic_state: bool = False
98 
99  async def async_set_percentage(self, percentage: int) -> None:
100  """Set the speed percentage of the fan."""
101  if percentage == 0:
102  zwave_speed = 0
103  else:
104  zwave_speed = math.ceil(
105  percentage_to_ranged_value(DEFAULT_SPEED_RANGE, percentage)
106  )
107 
108  await self._async_set_value_async_set_value(self._target_value_target_value, zwave_speed)
109 
110  async def async_turn_on(
111  self,
112  percentage: int | None = None,
113  preset_mode: str | None = None,
114  **kwargs: Any,
115  ) -> None:
116  """Turn the device on."""
117  if percentage is not None:
118  await self.async_set_percentageasync_set_percentageasync_set_percentage(percentage)
119  elif preset_mode is not None:
120  await self.async_set_preset_modeasync_set_preset_mode(preset_mode)
121  else:
122  if self.infoinfo.primary_value.command_class != CommandClass.SWITCH_MULTILEVEL:
123  raise HomeAssistantError(
124  "`percentage` or `preset_mode` must be provided"
125  )
126  # If this is a Multilevel Switch CC value, we do an optimistic state update
127  # when setting to a previous value to avoid waiting for the value to be
128  # updated from the device which is typically delayed and causes a confusing
129  # UX.
130  await self._async_set_value_async_set_value(self._target_value_target_value, SET_TO_PREVIOUS_VALUE)
131  self._use_optimistic_state_use_optimistic_state = True
132  self.async_write_ha_stateasync_write_ha_state()
133 
134  async def async_turn_off(self, **kwargs: Any) -> None:
135  """Turn the device off."""
136  await self._async_set_value_async_set_value(self._target_value_target_value, 0)
137 
138  @property
139  def is_on(self) -> bool | None:
140  """Return true if device is on (speed above 0)."""
141  if self._use_optimistic_state_use_optimistic_state:
142  self._use_optimistic_state_use_optimistic_state = False
143  return True
144  if self.infoinfo.primary_value.value is None:
145  # guard missing value
146  return None
147  return bool(self.infoinfo.primary_value.value > 0)
148 
149  @property
150  def percentage(self) -> int | None:
151  """Return the current speed percentage."""
152  if self.infoinfo.primary_value.value is None:
153  # guard missing value
154  return None
156  DEFAULT_SPEED_RANGE, self.infoinfo.primary_value.value
157  )
158 
159  @property
160  def percentage_step(self) -> float:
161  """Return the step size for percentage."""
162  return 1
163 
164 
166  """A Zwave fan with a value mapping data (e.g., 1-24 is low)."""
167 
168  def __init__(
169  self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo
170  ) -> None:
171  """Initialize the fan."""
172  super().__init__(config_entry, driver, info)
173  self.data_templatedata_template = cast(
174  FanValueMappingDataTemplate, self.infoinfo.platform_data_template
175  )
176 
177  async def async_set_percentage(self, percentage: int) -> None:
178  """Set the speed percentage of the fan."""
179  zwave_speed = self.percentage_to_zwave_speedpercentage_to_zwave_speed(percentage)
180  await self._async_set_value_async_set_value(self._target_value_target_value, zwave_speed)
181 
182  async def async_set_preset_mode(self, preset_mode: str) -> None:
183  """Set new preset mode."""
184  for zwave_value, mapped_preset_mode in self.fan_value_mappingfan_value_mapping.presets.items():
185  if preset_mode == mapped_preset_mode:
186  await self._async_set_value_async_set_value(self._target_value_target_value, zwave_value)
187  return
188 
189  @property
190  def available(self) -> bool:
191  """Return whether the entity is available."""
192  return super().available and self.has_fan_value_mappinghas_fan_value_mapping
193 
194  @property
195  def percentage(self) -> int | None:
196  """Return the current speed percentage."""
197  if self.infoinfo.primary_value.value is None:
198  # guard missing value
199  return None
200 
201  if self.preset_modepreset_modepreset_mode is not None:
202  return None
203 
204  return self.zwave_speed_to_percentagezwave_speed_to_percentage(self.infoinfo.primary_value.value)
205 
206  @property
207  def percentage_step(self) -> float:
208  """Return the step size for percentage."""
209  # This is the same implementation as the base fan type, but
210  # it needs to be overridden here because the ZwaveFan does
211  # something different for fans with unknown speeds.
212  return 100 / self.speed_countspeed_countspeed_count
213 
214  @property
215  def preset_modes(self) -> list[str]:
216  """Return the available preset modes."""
217  if not self.has_fan_value_mappinghas_fan_value_mapping:
218  return []
219 
220  return list(self.fan_value_mappingfan_value_mapping.presets.values())
221 
222  @property
223  def preset_mode(self) -> str | None:
224  """Return the current preset mode."""
225  if (value := self.infoinfo.primary_value.value) is None:
226  return None
227  return self.fan_value_mappingfan_value_mapping.presets.get(value)
228 
229  @property
230  def has_fan_value_mapping(self) -> bool:
231  """Check if the speed configuration is valid."""
232  return (
233  self.data_templatedata_template.get_fan_value_mapping(self.infoinfo.platform_data)
234  is not None
235  )
236 
237  @property
238  def fan_value_mapping(self) -> FanValueMapping:
239  """Return the speed configuration for this fan."""
240  fan_value_mapping = self.data_templatedata_template.get_fan_value_mapping(
241  self.infoinfo.platform_data
242  )
243 
244  # Entity should be unavailable if this isn't set
245  assert fan_value_mapping is not None
246 
247  return fan_value_mapping
248 
249  @property
250  def speed_count(self) -> int:
251  """Return the number of speeds the fan supports."""
252  return len(self.fan_value_mappingfan_value_mapping.speeds)
253 
254  @property
255  def supported_features(self) -> FanEntityFeature:
256  """Flag supported features."""
257  flags = (
258  FanEntityFeature.SET_SPEED
259  | FanEntityFeature.TURN_OFF
260  | FanEntityFeature.TURN_ON
261  )
262  if self.has_fan_value_mappinghas_fan_value_mapping and self.fan_value_mappingfan_value_mapping.presets:
263  flags |= FanEntityFeature.PRESET_MODE
264 
265  return flags
266 
267  def percentage_to_zwave_speed(self, percentage: int) -> int:
268  """Map a percentage to a ZWave speed."""
269  if percentage == 0:
270  return 0
271 
272  # Since the percentage steps are computed with rounding, we have to
273  # search to find the appropriate speed.
274  for speed_range in self.fan_value_mappingfan_value_mapping.speeds:
275  (_, max_speed) = speed_range
276  step_percentage = self.zwave_speed_to_percentagezwave_speed_to_percentage(max_speed)
277 
278  # zwave_speed_to_percentage will only return None if
279  # `self.fan_value_mapping.speeds` doesn't contain the
280  # specified speed. This can't happen here, because
281  # the input is coming from the same data structure.
282  assert step_percentage
283 
284  if percentage <= step_percentage:
285  break
286 
287  return max_speed
288 
289  def zwave_speed_to_percentage(self, zwave_speed: int) -> int | None:
290  """Convert a Zwave speed to a percentage.
291 
292  This method may return None if the device's value mapping doesn't cover
293  the specified Z-Wave speed.
294  """
295  if zwave_speed == 0:
296  return 0
297 
298  percentage = 0.0
299  for speed_range in self.fan_value_mappingfan_value_mapping.speeds:
300  (min_speed, max_speed) = speed_range
301  percentage += self.percentage_steppercentage_steppercentage_steppercentage_step
302  if min_speed <= zwave_speed <= max_speed:
303  # This choice of rounding function is to provide consistency with how
304  # the UI handles steps e.g., for a 3-speed fan, you get steps at 33,
305  # 67, and 100.
306  return round(percentage)
307 
308  # The specified Z-Wave device value doesn't map to a defined speed.
309  return None
310 
311 
313  """Representation of a Z-Wave thermostat fan."""
314 
315  _fan_mode: ZwaveValue
316  _fan_off: ZwaveValue | None = None
317  _fan_state: ZwaveValue | None = None
318 
319  def __init__(
320  self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo
321  ) -> None:
322  """Initialize the thermostat fan."""
323  super().__init__(config_entry, driver, info)
324 
325  self._fan_mode_fan_mode = self.infoinfo.primary_value
326 
327  self._fan_off_fan_off = self.get_zwave_valueget_zwave_value(
328  THERMOSTAT_FAN_OFF_PROPERTY,
329  CommandClass.THERMOSTAT_FAN_MODE,
330  add_to_watched_value_ids=True,
331  )
332  self._fan_state_fan_state = self.get_zwave_valueget_zwave_value(
333  THERMOSTAT_FAN_STATE_PROPERTY,
334  CommandClass.THERMOSTAT_FAN_STATE,
335  add_to_watched_value_ids=True,
336  )
337 
338  async def async_turn_on(
339  self,
340  percentage: int | None = None,
341  preset_mode: str | None = None,
342  **kwargs: Any,
343  ) -> None:
344  """Turn the device on."""
345  if not self._fan_off_fan_off:
346  raise HomeAssistantError("Unhandled action turn_on")
347  await self._async_set_value_async_set_value(self._fan_off_fan_off, False)
348 
349  async def async_turn_off(self, **kwargs: Any) -> None:
350  """Turn the device off."""
351  if not self._fan_off_fan_off:
352  raise HomeAssistantError("Unhandled action turn_off")
353  await self._async_set_value_async_set_value(self._fan_off_fan_off, True)
354 
355  @property
356  def is_on(self) -> bool | None:
357  """Return true if device is on."""
358  if (value := get_value_of_zwave_value(self._fan_off_fan_off)) is None:
359  return None
360  return not cast(bool, value)
361 
362  @property
363  def preset_mode(self) -> str | None:
364  """Return the current preset mode, e.g., auto, smart, interval, favorite."""
365  value = get_value_of_zwave_value(self._fan_mode_fan_mode)
366  if value is None or str(value) not in self._fan_mode_fan_mode.metadata.states:
367  return None
368  return cast(str, self._fan_mode_fan_mode.metadata.states[str(value)])
369 
370  async def async_set_preset_mode(self, preset_mode: str) -> None:
371  """Set new preset mode."""
372 
373  try:
374  new_state = next(
375  int(state)
376  for state, label in self._fan_mode_fan_mode.metadata.states.items()
377  if label == preset_mode
378  )
379  except StopIteration:
380  raise ValueError(f"Received an invalid fan mode: {preset_mode}") from None
381 
382  await self._async_set_value_async_set_value(self._fan_mode_fan_mode, new_state)
383 
384  @property
385  def preset_modes(self) -> list[str] | None:
386  """Return a list of available preset modes."""
387  if not self._fan_mode_fan_mode.metadata.states:
388  return None
389  return list(self._fan_mode_fan_mode.metadata.states.values())
390 
391  @property
392  def supported_features(self) -> FanEntityFeature:
393  """Flag supported features."""
394  if not self._fan_off_fan_off:
395  return FanEntityFeature.PRESET_MODE
396  return (
397  FanEntityFeature.PRESET_MODE
398  | FanEntityFeature.TURN_ON
399  | FanEntityFeature.TURN_OFF
400  )
401 
402  @property
403  def fan_state(self) -> str | None:
404  """Return the current state, Idle, Running, etc."""
405  value = get_value_of_zwave_value(self._fan_state_fan_state)
406  if (
407  value is None
408  or self._fan_state_fan_state is None
409  or str(value) not in self._fan_state_fan_state.metadata.states
410  ):
411  return None
412  return cast(str, self._fan_state_fan_state.metadata.states[str(value)])
413 
414  @property
415  def extra_state_attributes(self) -> dict[str, str]:
416  """Return the optional state attributes."""
417  attrs = {}
418 
419  if state := self.fan_statefan_state:
420  attrs[ATTR_FAN_STATE] = state
421 
422  return attrs
None async_set_percentage(self, int percentage)
Definition: __init__.py:340
None async_set_preset_mode(self, str preset_mode)
Definition: __init__.py:385
SetValueResult|None _async_set_value(self, ZwaveValue value, Any new_value, dict|None options=None, bool|None wait_for_result=None)
Definition: entity.py:330
ZwaveValue|None get_zwave_value(self, str|int value_property, int|None command_class=None, int|None endpoint=None, int|str|None value_property_key=None, bool add_to_watched_value_ids=True, bool check_all_endpoints=False)
Definition: entity.py:280
None async_set_preset_mode(self, str preset_mode)
Definition: fan.py:182
int|None zwave_speed_to_percentage(self, int zwave_speed)
Definition: fan.py:289
None __init__(self, ConfigEntry config_entry, Driver driver, ZwaveDiscoveryInfo info)
Definition: fan.py:170
int percentage_to_zwave_speed(self, int percentage)
Definition: fan.py:267
None async_turn_off(self, **Any kwargs)
Definition: fan.py:134
None __init__(self, ConfigEntry config_entry, Driver driver, ZwaveDiscoveryInfo info)
Definition: fan.py:90
None async_set_percentage(self, int percentage)
Definition: fan.py:99
None async_turn_on(self, int|None percentage=None, str|None preset_mode=None, **Any kwargs)
Definition: fan.py:115
None async_turn_on(self, int|None percentage=None, str|None preset_mode=None, **Any kwargs)
Definition: fan.py:343
None __init__(self, ConfigEntry config_entry, Driver driver, ZwaveDiscoveryInfo info)
Definition: fan.py:321
None async_set_preset_mode(self, str preset_mode)
Definition: fan.py:370
None async_setup_entry(HomeAssistant hass, ConfigEntry config_entry, AddEntitiesCallback async_add_entities)
Definition: fan.py:50
Any|None get_value_of_zwave_value(ZwaveValue|None value)
Definition: helpers.py:129
Callable[[], None] async_dispatcher_connect(HomeAssistant hass, str signal, Callable[..., Any] target)
Definition: dispatcher.py:103
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