Home Assistant Unofficial Reference 2024.12.1
type_fans.py
Go to the documentation of this file.
1 """Class to hold all fan accessories."""
2 
3 import logging
4 from typing import Any
5 
6 from pyhap.const import CATEGORY_FAN
7 
9  ATTR_DIRECTION,
10  ATTR_OSCILLATING,
11  ATTR_PERCENTAGE,
12  ATTR_PERCENTAGE_STEP,
13  ATTR_PRESET_MODE,
14  ATTR_PRESET_MODES,
15  DIRECTION_FORWARD,
16  DIRECTION_REVERSE,
17  DOMAIN as FAN_DOMAIN,
18  SERVICE_OSCILLATE,
19  SERVICE_SET_DIRECTION,
20  SERVICE_SET_PERCENTAGE,
21  SERVICE_SET_PRESET_MODE,
22  FanEntityFeature,
23 )
24 from homeassistant.const import (
25  ATTR_ENTITY_ID,
26  ATTR_SUPPORTED_FEATURES,
27  SERVICE_TURN_OFF,
28  SERVICE_TURN_ON,
29  STATE_OFF,
30  STATE_ON,
31 )
32 from homeassistant.core import State, callback
33 
34 from .accessories import TYPES, HomeAccessory
35 from .const import (
36  CHAR_ACTIVE,
37  CHAR_NAME,
38  CHAR_ON,
39  CHAR_ROTATION_DIRECTION,
40  CHAR_ROTATION_SPEED,
41  CHAR_SWING_MODE,
42  CHAR_TARGET_FAN_STATE,
43  PROP_MIN_STEP,
44  SERV_FANV2,
45  SERV_SWITCH,
46 )
47 from .util import cleanup_name_for_homekit
48 
49 _LOGGER = logging.getLogger(__name__)
50 
51 
52 @TYPES.register("Fan")
54  """Generate a Fan accessory for a fan entity.
55 
56  Currently supports: state, speed, oscillate, direction.
57  """
58 
59  def __init__(self, *args: Any) -> None:
60  """Initialize a new Fan accessory object."""
61  super().__init__(*args, category=CATEGORY_FAN)
62  self.chars: list[str] = []
63  state = self.hasshass.states.get(self.entity_identity_id)
64  assert state
65  self._reload_on_change_attrs_reload_on_change_attrs.extend(
66  (
67  ATTR_PERCENTAGE_STEP,
68  ATTR_PRESET_MODES,
69  )
70  )
71 
72  features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
73  percentage_step = state.attributes.get(ATTR_PERCENTAGE_STEP, 1)
74  self.preset_modes: list[str] | None = state.attributes.get(ATTR_PRESET_MODES)
75 
76  if features & FanEntityFeature.DIRECTION:
77  self.chars.append(CHAR_ROTATION_DIRECTION)
78  if features & FanEntityFeature.OSCILLATE:
79  self.chars.append(CHAR_SWING_MODE)
80  if features & FanEntityFeature.SET_SPEED:
81  self.chars.append(CHAR_ROTATION_SPEED)
82  if self.preset_modes and len(self.preset_modes) == 1:
83  self.chars.append(CHAR_TARGET_FAN_STATE)
84 
85  serv_fan = self.add_preload_service(SERV_FANV2, self.chars)
86  self.set_primary_service(serv_fan)
87  self.char_activechar_active = serv_fan.configure_char(CHAR_ACTIVE, value=0)
88 
89  self.char_directionchar_direction = None
90  self.char_speedchar_speed = None
91  self.char_swingchar_swing = None
92  self.char_target_fan_statechar_target_fan_state = None
93  self.preset_mode_charspreset_mode_chars = {}
94 
95  if CHAR_ROTATION_DIRECTION in self.chars:
96  self.char_directionchar_direction = serv_fan.configure_char(
97  CHAR_ROTATION_DIRECTION, value=0
98  )
99 
100  if CHAR_ROTATION_SPEED in self.chars:
101  # Initial value is set to 100 because 0 is a special value (off). 100 is
102  # an arbitrary non-zero value. It is updated immediately by async_update_state
103  # to set to the correct initial value.
104  self.char_speedchar_speed = serv_fan.configure_char(
105  CHAR_ROTATION_SPEED,
106  value=100,
107  properties={PROP_MIN_STEP: percentage_step},
108  )
109 
110  if self.preset_modes and len(self.preset_modes) == 1:
111  self.char_target_fan_statechar_target_fan_state = serv_fan.configure_char(
112  CHAR_TARGET_FAN_STATE,
113  value=0,
114  )
115  elif self.preset_modes:
116  for preset_mode in self.preset_modes:
117  preset_serv = self.add_preload_service(
118  SERV_SWITCH, CHAR_NAME, unique_id=preset_mode
119  )
120  serv_fan.add_linked_service(preset_serv)
121  preset_serv.configure_char(
122  CHAR_NAME,
123  value=cleanup_name_for_homekit(
124  f"{self.display_name} {preset_mode}"
125  ),
126  )
127 
128  def setter_callback(value: int, preset_mode: str = preset_mode) -> None:
129  return self.set_preset_modeset_preset_mode(value, preset_mode)
130 
131  self.preset_mode_charspreset_mode_chars[preset_mode] = preset_serv.configure_char(
132  CHAR_ON,
133  value=False,
134  setter_callback=setter_callback,
135  )
136 
137  if CHAR_SWING_MODE in self.chars:
138  self.char_swingchar_swing = serv_fan.configure_char(CHAR_SWING_MODE, value=0)
139  self.async_update_stateasync_update_stateasync_update_state(state)
140  serv_fan.setter_callback = self._set_chars_set_chars
141 
142  def _set_chars(self, char_values: dict[str, Any]) -> None:
143  _LOGGER.debug("Fan _set_chars: %s", char_values)
144  if CHAR_ACTIVE in char_values:
145  if char_values[CHAR_ACTIVE]:
146  # If the device supports set speed we
147  # do not want to turn on as it will take
148  # the fan to 100% than to the desired speed.
149  #
150  # Setting the speed will take care of turning
151  # on the fan if FanEntityFeature.SET_SPEED is set.
152  if not self.char_speedchar_speed or CHAR_ROTATION_SPEED not in char_values:
153  self.set_stateset_state(1)
154  else:
155  # Its off, nothing more to do as setting the
156  # other chars will likely turn it back on which
157  # is what we want to avoid
158  self.set_stateset_state(0)
159  return
160 
161  if CHAR_SWING_MODE in char_values:
162  self.set_oscillatingset_oscillating(char_values[CHAR_SWING_MODE])
163  if CHAR_ROTATION_DIRECTION in char_values:
164  self.set_directionset_direction(char_values[CHAR_ROTATION_DIRECTION])
165 
166  # We always do this LAST to ensure they
167  # get the speed they asked for
168  if CHAR_ROTATION_SPEED in char_values:
169  self.set_percentageset_percentage(char_values[CHAR_ROTATION_SPEED])
170  if CHAR_TARGET_FAN_STATE in char_values:
171  self.set_single_preset_modeset_single_preset_mode(char_values[CHAR_TARGET_FAN_STATE])
172 
173  def set_single_preset_mode(self, value: int) -> None:
174  """Set auto call came from HomeKit."""
175  params: dict[str, Any] = {ATTR_ENTITY_ID: self.entity_identity_id}
176  if value:
177  assert self.preset_modes
178  _LOGGER.debug(
179  "%s: Set auto to 1 (%s)", self.entity_identity_id, self.preset_modes[0]
180  )
181  params[ATTR_PRESET_MODE] = self.preset_modes[0]
182  self.async_call_serviceasync_call_service(FAN_DOMAIN, SERVICE_SET_PRESET_MODE, params)
183  elif current_state := self.hasshass.states.get(self.entity_identity_id):
184  percentage: float = current_state.attributes.get(ATTR_PERCENTAGE) or 50.0
185  params[ATTR_PERCENTAGE] = percentage
186  _LOGGER.debug("%s: Set auto to 0", self.entity_identity_id)
187  self.async_call_serviceasync_call_service(FAN_DOMAIN, SERVICE_TURN_ON, params)
188 
189  def set_preset_mode(self, value: int, preset_mode: str) -> None:
190  """Set preset_mode if call came from HomeKit."""
191  _LOGGER.debug(
192  "%s: Set preset_mode %s to %d", self.entity_identity_id, preset_mode, value
193  )
194  params = {ATTR_ENTITY_ID: self.entity_identity_id}
195  if value:
196  params[ATTR_PRESET_MODE] = preset_mode
197  self.async_call_serviceasync_call_service(FAN_DOMAIN, SERVICE_SET_PRESET_MODE, params)
198  else:
199  self.async_call_serviceasync_call_service(FAN_DOMAIN, SERVICE_TURN_ON, params)
200 
201  def set_state(self, value: int) -> None:
202  """Set state if call came from HomeKit."""
203  _LOGGER.debug("%s: Set state to %d", self.entity_identity_id, value)
204  service = SERVICE_TURN_ON if value == 1 else SERVICE_TURN_OFF
205  params = {ATTR_ENTITY_ID: self.entity_identity_id}
206  self.async_call_serviceasync_call_service(FAN_DOMAIN, service, params)
207 
208  def set_direction(self, value: int) -> None:
209  """Set state if call came from HomeKit."""
210  _LOGGER.debug("%s: Set direction to %d", self.entity_identity_id, value)
211  direction = DIRECTION_REVERSE if value == 1 else DIRECTION_FORWARD
212  params = {ATTR_ENTITY_ID: self.entity_identity_id, ATTR_DIRECTION: direction}
213  self.async_call_serviceasync_call_service(FAN_DOMAIN, SERVICE_SET_DIRECTION, params, direction)
214 
215  def set_oscillating(self, value: int) -> None:
216  """Set state if call came from HomeKit."""
217  _LOGGER.debug("%s: Set oscillating to %d", self.entity_identity_id, value)
218  oscillating = value == 1
219  params = {ATTR_ENTITY_ID: self.entity_identity_id, ATTR_OSCILLATING: oscillating}
220  self.async_call_serviceasync_call_service(FAN_DOMAIN, SERVICE_OSCILLATE, params, oscillating)
221 
222  def set_percentage(self, value: float) -> None:
223  """Set state if call came from HomeKit."""
224  _LOGGER.debug("%s: Set speed to %d", self.entity_identity_id, value)
225  params = {ATTR_ENTITY_ID: self.entity_identity_id, ATTR_PERCENTAGE: value}
226  self.async_call_serviceasync_call_service(FAN_DOMAIN, SERVICE_SET_PERCENTAGE, params, value)
227 
228  @callback
229  def async_update_state(self, new_state: State) -> None:
230  """Update fan after state change."""
231  # Handle State
232  state = new_state.state
233  attributes = new_state.attributes
234  if state in (STATE_ON, STATE_OFF):
235  self._state_state = 1 if state == STATE_ON else 0
236  self.char_activechar_active.set_value(self._state_state)
237 
238  # Handle Direction
239  if self.char_directionchar_direction is not None:
240  direction = new_state.attributes.get(ATTR_DIRECTION)
241  if direction in (DIRECTION_FORWARD, DIRECTION_REVERSE):
242  hk_direction = 1 if direction == DIRECTION_REVERSE else 0
243  self.char_directionchar_direction.set_value(hk_direction)
244 
245  # Handle Speed
246  if self.char_speedchar_speed is not None and state != STATE_OFF:
247  # We do not change the homekit speed when turning off
248  # as it will clear the restore state
249  percentage = attributes.get(ATTR_PERCENTAGE)
250  # If the homeassistant component reports its speed as the first entry
251  # in its speed list but is not off, the hk_speed_value is 0. But 0
252  # is a special value in homekit. When you turn on a homekit accessory
253  # it will try to restore the last rotation speed state which will be
254  # the last value saved by char_speed.set_value. But if it is set to
255  # 0, HomeKit will update the rotation speed to 100 as it thinks 0 is
256  # off.
257  #
258  # Therefore, if the hk_speed_value is 0 and the device is still on,
259  # the rotation speed is mapped to 1 otherwise the update is ignored
260  # in order to avoid this incorrect behavior.
261  if percentage == 0 and state == STATE_ON:
262  percentage = max(1, self.char_speedchar_speed.properties[PROP_MIN_STEP])
263  if percentage is not None:
264  self.char_speedchar_speed.set_value(percentage)
265 
266  # Handle Oscillating
267  if self.char_swingchar_swing is not None:
268  oscillating = attributes.get(ATTR_OSCILLATING)
269  if isinstance(oscillating, bool):
270  hk_oscillating = 1 if oscillating else 0
271  self.char_swingchar_swing.set_value(hk_oscillating)
272 
273  current_preset_mode = attributes.get(ATTR_PRESET_MODE)
274  if self.char_target_fan_statechar_target_fan_state is not None:
275  # Handle single preset mode
276  self.char_target_fan_statechar_target_fan_state.set_value(int(current_preset_mode is not None))
277  return
278 
279  # Handle multiple preset modes
280  for preset_mode, char in self.preset_mode_charspreset_mode_chars.items():
281  hk_value = 1 if preset_mode == current_preset_mode else 0
282  char.set_value(hk_value)
None async_call_service(self, str domain, str service, dict[str, Any]|None service_data, Any|None value=None)
Definition: accessories.py:609
None _set_chars(self, dict[str, Any] char_values)
Definition: type_fans.py:142
None async_update_state(self, State new_state)
Definition: type_fans.py:229
None set_preset_mode(self, int value, str preset_mode)
Definition: type_fans.py:189