Home Assistant Unofficial Reference 2024.12.1
type_covers.py
Go to the documentation of this file.
1 """Class to hold all cover accessories."""
2 
3 import logging
4 from typing import Any
5 
6 from pyhap.const import (
7  CATEGORY_DOOR,
8  CATEGORY_GARAGE_DOOR_OPENER,
9  CATEGORY_WINDOW,
10  CATEGORY_WINDOW_COVERING,
11 )
12 from pyhap.service import Service
13 from pyhap.util import callback as pyhap_callback
14 
16  ATTR_CURRENT_POSITION,
17  ATTR_CURRENT_TILT_POSITION,
18  ATTR_POSITION,
19  ATTR_TILT_POSITION,
20  DOMAIN as COVER_DOMAIN,
21  CoverEntityFeature,
22  CoverState,
23 )
24 from homeassistant.const import (
25  ATTR_ENTITY_ID,
26  ATTR_SUPPORTED_FEATURES,
27  SERVICE_CLOSE_COVER,
28  SERVICE_OPEN_COVER,
29  SERVICE_SET_COVER_POSITION,
30  SERVICE_SET_COVER_TILT_POSITION,
31  SERVICE_STOP_COVER,
32  STATE_ON,
33 )
34 from homeassistant.core import (
35  Event,
36  EventStateChangedData,
37  HassJobType,
38  State,
39  callback,
40 )
41 from homeassistant.helpers.event import async_track_state_change_event
42 
43 from .accessories import TYPES, HomeAccessory
44 from .const import (
45  ATTR_OBSTRUCTION_DETECTED,
46  CHAR_CURRENT_DOOR_STATE,
47  CHAR_CURRENT_POSITION,
48  CHAR_CURRENT_TILT_ANGLE,
49  CHAR_HOLD_POSITION,
50  CHAR_OBSTRUCTION_DETECTED,
51  CHAR_POSITION_STATE,
52  CHAR_TARGET_DOOR_STATE,
53  CHAR_TARGET_POSITION,
54  CHAR_TARGET_TILT_ANGLE,
55  CONF_LINKED_OBSTRUCTION_SENSOR,
56  HK_DOOR_CLOSED,
57  HK_DOOR_CLOSING,
58  HK_DOOR_OPEN,
59  HK_DOOR_OPENING,
60  HK_POSITION_GOING_TO_MAX,
61  HK_POSITION_GOING_TO_MIN,
62  HK_POSITION_STOPPED,
63  PROP_MAX_VALUE,
64  PROP_MIN_VALUE,
65  SERV_DOOR,
66  SERV_GARAGE_DOOR_OPENER,
67  SERV_WINDOW,
68  SERV_WINDOW_COVERING,
69 )
70 
71 DOOR_CURRENT_HASS_TO_HK = {
72  CoverState.OPEN: HK_DOOR_OPEN,
73  CoverState.CLOSED: HK_DOOR_CLOSED,
74  CoverState.OPENING: HK_DOOR_OPENING,
75  CoverState.CLOSING: HK_DOOR_CLOSING,
76 }
77 
78 # HomeKit only has two states for
79 # Target Door State:
80 # 0: Open
81 # 1: Closed
82 # Opening is mapped to 0 since the target is Open
83 # Closing is mapped to 1 since the target is Closed
84 DOOR_TARGET_HASS_TO_HK = {
85  CoverState.OPEN: HK_DOOR_OPEN,
86  CoverState.CLOSED: HK_DOOR_CLOSED,
87  CoverState.OPENING: HK_DOOR_OPEN,
88  CoverState.CLOSING: HK_DOOR_CLOSED,
89 }
90 
91 MOVING_STATES = {CoverState.OPENING, CoverState.CLOSING}
92 
93 _LOGGER = logging.getLogger(__name__)
94 
95 
96 @TYPES.register("GarageDoorOpener")
98  """Generate a Garage Door Opener accessory for a cover entity.
99 
100  The cover entity must be in the 'garage' device class
101  and support no more than open, close, and stop.
102  """
103 
104  def __init__(self, *args: Any) -> None:
105  """Initialize a GarageDoorOpener accessory object."""
106  super().__init__(*args, category=CATEGORY_GARAGE_DOOR_OPENER)
107  state = self.hasshass.states.get(self.entity_identity_id)
108  assert state
109 
110  serv_garage_door = self.add_preload_service(SERV_GARAGE_DOOR_OPENER)
111  self.char_current_statechar_current_state = serv_garage_door.configure_char(
112  CHAR_CURRENT_DOOR_STATE, value=0
113  )
114  self.char_target_statechar_target_state = serv_garage_door.configure_char(
115  CHAR_TARGET_DOOR_STATE, value=0, setter_callback=self.set_stateset_state
116  )
117  self.char_obstruction_detectedchar_obstruction_detected = serv_garage_door.configure_char(
118  CHAR_OBSTRUCTION_DETECTED, value=False
119  )
120 
121  self.linked_obstruction_sensorlinked_obstruction_sensor = self.configconfig.get(CONF_LINKED_OBSTRUCTION_SENSOR)
122  if self.linked_obstruction_sensorlinked_obstruction_sensor:
123  self._async_update_obstruction_state_async_update_obstruction_state(
124  self.hasshass.states.get(self.linked_obstruction_sensorlinked_obstruction_sensor)
125  )
126 
127  self.async_update_stateasync_update_stateasync_update_state(state)
128 
129  @callback
130  @pyhap_callback # type: ignore[misc]
131  def run(self) -> None:
132  """Handle accessory driver started event.
133 
134  Run inside the Home Assistant event loop.
135  """
136  if self.linked_obstruction_sensorlinked_obstruction_sensor:
137  self._subscriptions.append(
139  self.hasshass,
140  [self.linked_obstruction_sensorlinked_obstruction_sensor],
141  self._async_update_obstruction_event_async_update_obstruction_event,
142  job_type=HassJobType.Callback,
143  )
144  )
145 
146  super().run()
147 
148  @callback
150  self, event: Event[EventStateChangedData]
151  ) -> None:
152  """Handle state change event listener callback."""
153  self._async_update_obstruction_state_async_update_obstruction_state(event.data["new_state"])
154 
155  @callback
156  def _async_update_obstruction_state(self, new_state: State | None) -> None:
157  """Handle linked obstruction sensor state change to update HomeKit value."""
158  if not new_state:
159  return
160 
161  detected = new_state.state == STATE_ON
162  if self.char_obstruction_detectedchar_obstruction_detected.value == detected:
163  return
164 
165  self.char_obstruction_detectedchar_obstruction_detected.set_value(detected)
166  _LOGGER.debug(
167  "%s: Set linked obstruction %s sensor to %d",
168  self.entity_identity_id,
169  self.linked_obstruction_sensorlinked_obstruction_sensor,
170  detected,
171  )
172 
173  def set_state(self, value: int) -> None:
174  """Change garage state if call came from HomeKit."""
175  _LOGGER.debug("%s: Set state to %d", self.entity_identity_id, value)
176 
177  params = {ATTR_ENTITY_ID: self.entity_identity_id}
178  if value == HK_DOOR_OPEN:
179  if self.char_current_statechar_current_state.value != value:
180  self.char_current_statechar_current_state.set_value(HK_DOOR_OPENING)
181  self.async_call_serviceasync_call_service(COVER_DOMAIN, SERVICE_OPEN_COVER, params)
182  elif value == HK_DOOR_CLOSED:
183  if self.char_current_statechar_current_state.value != value:
184  self.char_current_statechar_current_state.set_value(HK_DOOR_CLOSING)
185  self.async_call_serviceasync_call_service(COVER_DOMAIN, SERVICE_CLOSE_COVER, params)
186 
187  @callback
188  def async_update_state(self, new_state: State) -> None:
189  """Update cover state after state changed."""
190  hass_state: CoverState = new_state.state # type: ignore[assignment]
191  target_door_state = DOOR_TARGET_HASS_TO_HK.get(hass_state)
192  current_door_state = DOOR_CURRENT_HASS_TO_HK.get(hass_state)
193 
194  if ATTR_OBSTRUCTION_DETECTED in new_state.attributes:
195  obstruction_detected = (
196  new_state.attributes[ATTR_OBSTRUCTION_DETECTED] is True
197  )
198  self.char_obstruction_detectedchar_obstruction_detected.set_value(obstruction_detected)
199 
200  if target_door_state is not None:
201  self.char_target_statechar_target_state.set_value(target_door_state)
202  if current_door_state is not None:
203  self.char_current_statechar_current_state.set_value(current_door_state)
204 
205 
207  """Generate a base Window accessory for a cover entity.
208 
209  This class is used for WindowCoveringBasic and
210  WindowCovering
211  """
212 
213  def __init__(self, *args: Any, category: int, service: Service) -> None:
214  """Initialize a OpeningDeviceBase accessory object."""
215  super().__init__(*args, category=category)
216  state = self.hasshass.states.get(self.entity_identity_id)
217  assert state
218  self.features: int = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
219  self._supports_stop_supports_stop = self.features & CoverEntityFeature.STOP
220  self.charschars = []
221  if self._supports_stop_supports_stop:
222  self.charschars.append(CHAR_HOLD_POSITION)
223  self._supports_tilt_supports_tilt = self.features & CoverEntityFeature.SET_TILT_POSITION
224 
225  if self._supports_tilt_supports_tilt:
226  self.charschars.extend([CHAR_TARGET_TILT_ANGLE, CHAR_CURRENT_TILT_ANGLE])
227 
228  self.serv_coverserv_cover = self.add_preload_service(service, self.charschars)
229 
230  if self._supports_stop_supports_stop:
231  self.char_hold_positionchar_hold_position = self.serv_coverserv_cover.configure_char(
232  CHAR_HOLD_POSITION, setter_callback=self.set_stopset_stop
233  )
234 
235  if self._supports_tilt_supports_tilt:
236  self.char_target_tiltchar_target_tilt = self.serv_coverserv_cover.configure_char(
237  CHAR_TARGET_TILT_ANGLE, setter_callback=self.set_tiltset_tilt
238  )
239  self.char_current_tiltchar_current_tilt = self.serv_coverserv_cover.configure_char(
240  CHAR_CURRENT_TILT_ANGLE, value=0
241  )
242 
243  def set_stop(self, value: int) -> None:
244  """Stop the cover motion from HomeKit."""
245  if value != 1:
246  return
247  self.async_call_serviceasync_call_service(
248  COVER_DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: self.entity_identity_id}
249  )
250 
251  def set_tilt(self, value: float) -> None:
252  """Set tilt to value if call came from HomeKit."""
253  _LOGGER.debug("%s: Set tilt to %d", self.entity_identity_id, value)
254 
255  # HomeKit sends values between -90 and 90.
256  # We'll have to normalize to [0,100]
257  value = round((value + 90) / 180.0 * 100.0)
258 
259  params = {ATTR_ENTITY_ID: self.entity_identity_id, ATTR_TILT_POSITION: value}
260 
261  self.async_call_serviceasync_call_service(
262  COVER_DOMAIN, SERVICE_SET_COVER_TILT_POSITION, params, value
263  )
264 
265  @callback
266  def async_update_state(self, new_state: State) -> None:
267  """Update cover position and tilt after state changed."""
268  # update tilt
269  if not self._supports_tilt_supports_tilt:
270  return
271  current_tilt = new_state.attributes.get(ATTR_CURRENT_TILT_POSITION)
272  if not isinstance(current_tilt, (float, int)):
273  return
274  # HomeKit sends values between -90 and 90.
275  # We'll have to normalize to [0,100]
276  current_tilt = (current_tilt / 100.0 * 180.0) - 90.0
277  current_tilt = int(current_tilt)
278  self.char_current_tiltchar_current_tilt.set_value(current_tilt)
279  self.char_target_tiltchar_target_tilt.set_value(current_tilt)
280 
281 
283  """Generate a Window/WindowOpening accessory for a cover entity.
284 
285  The cover entity must support: set_cover_position.
286  """
287 
288  def __init__(self, *args: Any, category: int, service: Service) -> None:
289  """Initialize a WindowCovering accessory object."""
290  super().__init__(*args, category=category, service=service)
291  state = self.hasshass.states.get(self.entity_identity_id)
292  assert state
293  self.char_current_positionchar_current_position = self.serv_coverserv_cover.configure_char(
294  CHAR_CURRENT_POSITION, value=0
295  )
296  target_args: dict[str, Any] = {"value": 0}
297  if self.features & CoverEntityFeature.SET_POSITION:
298  target_args["setter_callback"] = self.move_covermove_cover
299  else:
300  # If its tilt only we lock the position state to 0 (closed)
301  # since CHAR_CURRENT_POSITION/CHAR_TARGET_POSITION are required
302  # by homekit, but really don't exist.
303  _LOGGER.debug(
304  (
305  "%s does not support setting position, current position will be"
306  " locked to closed"
307  ),
308  self.entity_identity_id,
309  )
310  target_args["properties"] = {PROP_MIN_VALUE: 0, PROP_MAX_VALUE: 0}
311 
312  self.char_target_positionchar_target_position = self.serv_coverserv_cover.configure_char(
313  CHAR_TARGET_POSITION, **target_args
314  )
315  self.char_position_statechar_position_state = self.serv_coverserv_cover.configure_char(
316  CHAR_POSITION_STATE, value=HK_POSITION_STOPPED
317  )
318  self.async_update_stateasync_update_stateasync_update_stateasync_update_state(state)
319 
320  def move_cover(self, value: int) -> None:
321  """Move cover to value if call came from HomeKit."""
322  _LOGGER.debug("%s: Set position to %d", self.entity_identity_id, value)
323  params = {ATTR_ENTITY_ID: self.entity_identity_id, ATTR_POSITION: value}
324  self.async_call_serviceasync_call_service(COVER_DOMAIN, SERVICE_SET_COVER_POSITION, params, value)
325 
326  @callback
327  def async_update_state(self, new_state: State) -> None:
328  """Update cover position and tilt after state changed."""
329  current_position = new_state.attributes.get(ATTR_CURRENT_POSITION)
330  if isinstance(current_position, (float, int)):
331  current_position = int(current_position)
332  self.char_current_positionchar_current_position.set_value(current_position)
333  # Writing target_position on a moving cover
334  # will break the moving state in HK.
335  if new_state.state not in MOVING_STATES:
336  self.char_target_positionchar_target_position.set_value(current_position)
337 
338  position_state = _hass_state_to_position_start(new_state.state)
339  self.char_position_statechar_position_state.set_value(position_state)
340 
341  super().async_update_state(new_state)
342 
343 
344 @TYPES.register("Door")
346  """Generate a Door accessory for a cover entity.
347 
348  The entity must support: set_cover_position.
349  """
350 
351  def __init__(self, *args: Any) -> None:
352  """Initialize a Door accessory object."""
353  super().__init__(*args, category=CATEGORY_DOOR, service=SERV_DOOR)
354 
355 
356 @TYPES.register("Window")
358  """Generate a Window accessory for a cover entity with WINDOW device class.
359 
360  The entity must support: set_cover_position.
361  """
362 
363  def __init__(self, *args: Any) -> None:
364  """Initialize a Window accessory object."""
365  super().__init__(*args, category=CATEGORY_WINDOW, service=SERV_WINDOW)
366 
367 
368 @TYPES.register("WindowCovering")
370  """Generate a WindowCovering accessory for a cover entity.
371 
372  The entity must support: set_cover_position.
373  """
374 
375  def __init__(self, *args: Any) -> None:
376  """Initialize a WindowCovering accessory object."""
377  super().__init__(
378  *args, category=CATEGORY_WINDOW_COVERING, service=SERV_WINDOW_COVERING
379  )
380 
381 
382 @TYPES.register("WindowCoveringBasic")
384  """Generate a Window accessory for a cover entity.
385 
386  The cover entity must support: open_cover, close_cover,
387  stop_cover (optional).
388  """
389 
390  def __init__(self, *args: Any) -> None:
391  """Initialize a WindowCoveringBasic accessory object."""
392  super().__init__(
393  *args, category=CATEGORY_WINDOW_COVERING, service=SERV_WINDOW_COVERING
394  )
395  state = self.hasshass.states.get(self.entity_identity_id)
396  assert state
397  self.char_current_positionchar_current_position = self.serv_coverserv_cover.configure_char(
398  CHAR_CURRENT_POSITION, value=0
399  )
400  self.char_target_positionchar_target_position = self.serv_coverserv_cover.configure_char(
401  CHAR_TARGET_POSITION, value=0, setter_callback=self.move_covermove_cover
402  )
403  self.char_position_statechar_position_state = self.serv_coverserv_cover.configure_char(
404  CHAR_POSITION_STATE, value=HK_POSITION_STOPPED
405  )
406  self.async_update_stateasync_update_stateasync_update_stateasync_update_state(state)
407 
408  def move_cover(self, value: int) -> None:
409  """Move cover to value if call came from HomeKit."""
410  _LOGGER.debug("%s: Set position to %d", self.entity_identity_id, value)
411 
412  if (
413  self._supports_stop_supports_stop
414  and value > 70
415  or not self._supports_stop_supports_stop
416  and value >= 50
417  ):
418  service, position = (SERVICE_OPEN_COVER, 100)
419  elif value < 30 or not self._supports_stop_supports_stop:
420  service, position = (SERVICE_CLOSE_COVER, 0)
421  else:
422  service, position = (SERVICE_STOP_COVER, 50)
423 
424  params = {ATTR_ENTITY_ID: self.entity_identity_id}
425  self.async_call_serviceasync_call_service(COVER_DOMAIN, service, params)
426 
427  # Snap the current/target position to the expected final position.
428  self.char_current_positionchar_current_position.set_value(position)
429  self.char_target_positionchar_target_position.set_value(position)
430 
431  @callback
432  def async_update_state(self, new_state: State) -> None:
433  """Update cover position after state changed."""
434  position_mapping = {CoverState.OPEN: 100, CoverState.CLOSED: 0}
435  _state: CoverState = new_state.state # type: ignore[assignment]
436  hk_position = position_mapping.get(_state)
437  if hk_position is not None:
438  is_moving = _state in MOVING_STATES
439 
440  if self.char_current_positionchar_current_position.value != hk_position:
441  self.char_current_positionchar_current_position.set_value(hk_position)
442  if self.char_target_positionchar_target_position.value != hk_position and not is_moving:
443  self.char_target_positionchar_target_position.set_value(hk_position)
444  position_state = _hass_state_to_position_start(new_state.state)
445  if self.char_position_statechar_position_state.value != position_state:
446  self.char_position_statechar_position_state.set_value(position_state)
447 
448  super().async_update_state(new_state)
449 
450 
451 def _hass_state_to_position_start(state: str) -> int:
452  """Convert hass state to homekit position state."""
453  if state == CoverState.OPENING:
454  return HK_POSITION_GOING_TO_MAX
455  if state == CoverState.CLOSING:
456  return HK_POSITION_GOING_TO_MIN
457  return HK_POSITION_STOPPED
None async_call_service(self, str domain, str service, dict[str, Any]|None service_data, Any|None value=None)
Definition: accessories.py:609
None _async_update_obstruction_state(self, State|None new_state)
Definition: type_covers.py:156
None _async_update_obstruction_event(self, Event[EventStateChangedData] event)
Definition: type_covers.py:151
None __init__(self, *Any args, int category, Service service)
Definition: type_covers.py:213
None __init__(self, *Any args, int category, Service service)
Definition: type_covers.py:288
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
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