Home Assistant Unofficial Reference 2024.12.1
connection.py
Go to the documentation of this file.
1 """Helpers for managing a pairing with a HomeKit accessory or bridge."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections.abc import Callable, Iterable
7 from datetime import datetime, timedelta
8 from functools import partial
9 import logging
10 from operator import attrgetter
11 from types import MappingProxyType
12 from typing import Any
13 
14 from aiohomekit import Controller
15 from aiohomekit.controller import TransportType
16 from aiohomekit.exceptions import (
17  AccessoryDisconnectedError,
18  AccessoryNotFoundError,
19  EncryptionError,
20 )
21 from aiohomekit.model import Accessories, Accessory, Transport
22 from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes
23 from aiohomekit.model.services import Service, ServicesTypes
24 
25 from homeassistant.components.thread import async_get_preferred_dataset
26 from homeassistant.config_entries import ConfigEntry
27 from homeassistant.const import ATTR_VIA_DEVICE, EVENT_HOMEASSISTANT_STARTED
28 from homeassistant.core import CALLBACK_TYPE, CoreState, Event, HomeAssistant, callback
29 from homeassistant.exceptions import HomeAssistantError
30 from homeassistant.helpers import device_registry as dr, entity_registry as er
31 from homeassistant.helpers.debounce import Debouncer
32 from homeassistant.helpers.device_registry import DeviceInfo
33 from homeassistant.helpers.event import async_call_later, async_track_time_interval
34 
35 from .config_flow import normalize_hkid
36 from .const import (
37  CHARACTERISTIC_PLATFORMS,
38  CONTROLLER,
39  DEBOUNCE_COOLDOWN,
40  DOMAIN,
41  HOMEKIT_ACCESSORY_DISPATCH,
42  IDENTIFIER_ACCESSORY_ID,
43  IDENTIFIER_LEGACY_ACCESSORY_ID,
44  IDENTIFIER_LEGACY_SERIAL_NUMBER,
45  IDENTIFIER_SERIAL_NUMBER,
46  STARTUP_EXCEPTIONS,
47  SUBSCRIBE_COOLDOWN,
48 )
49 from .device_trigger import async_fire_triggers, async_setup_triggers_for_entry
50 from .utils import IidTuple, unique_id_to_iids
51 
52 RETRY_INTERVAL = 60 # seconds
53 MAX_POLL_FAILURES_TO_DECLARE_UNAVAILABLE = 3
54 
55 
56 BLE_AVAILABILITY_CHECK_INTERVAL = 1800 # seconds
57 
58 _LOGGER = logging.getLogger(__name__)
59 
60 type AddAccessoryCb = Callable[[Accessory], bool]
61 type AddServiceCb = Callable[[Service], bool]
62 type AddCharacteristicCb = Callable[[Characteristic], bool]
63 
64 
65 def valid_serial_number(serial: str) -> bool:
66  """Return if the serial number appears to be valid."""
67  if not serial:
68  return False
69  try:
70  return float("".join(serial.rsplit(".", 1))) > 1
71  except ValueError:
72  return True
73 
74 
75 class HKDevice:
76  """HomeKit device."""
77 
78  def __init__(
79  self,
80  hass: HomeAssistant,
81  config_entry: ConfigEntry,
82  pairing_data: MappingProxyType[str, Any],
83  ) -> None:
84  """Initialise a generic HomeKit device."""
85 
86  self.hasshass = hass
87  self.config_entryconfig_entry = config_entry
88 
89  # We copy pairing_data because homekit_python may mutate it, but we
90  # don't want to mutate a dict owned by a config entry.
91  self.pairing_datapairing_data = pairing_data.copy()
92 
93  connection: Controller = hass.data[CONTROLLER]
94 
95  self.pairingpairing = connection.load_pairing(self.unique_idunique_id, self.pairing_datapairing_data)
96 
97  # A list of callbacks that turn HK accessories into entities
98  self.accessory_factories: list[AddAccessoryCb] = []
99 
100  # A list of callbacks that turn HK service metadata into entities
101  self.listeners: list[AddServiceCb] = []
102 
103  # A list of callbacks that turn HK service metadata into triggers
104  self.trigger_factories: list[AddServiceCb] = []
105 
106  # Track aid/iid pairs so we know if we already handle triggers for a HK
107  # service.
108  self._triggers: set[tuple[int, int]] = set()
109 
110  # A list of callbacks that turn HK characteristics into entities
111  self.char_factories: list[AddCharacteristicCb] = []
112 
113  # The platforms we have forwarded the config entry so far. If a new
114  # accessory is added to a bridge we may have to load additional
115  # platforms. We don't want to load all platforms up front if its just
116  # a lightbulb. And we don't want to forward a config entry twice
117  # (triggers a Config entry already set up error)
118  self.platforms: set[str] = set()
119 
120  # This just tracks aid/iid pairs so we know if a HK service has been
121  # mapped to a HA entity.
122  self.entities: set[tuple[int, int | None, int | None]] = set()
123 
124  # A map of aid -> device_id
125  # Useful when routing events to triggers
126  self.devicesdevices: dict[int, str] = {}
127 
128  self.availableavailable = False
129 
130  self.pollable_characteristics: set[tuple[int, int]] = set()
131 
132  # Never allow concurrent polling of the same accessory or bridge
133  self._polling_lock_polling_lock = asyncio.Lock()
134  self._polling_lock_warned_polling_lock_warned = False
135  self._poll_failures_poll_failures = 0
136 
137  # This is set to True if we can't rely on serial numbers to be unique
138  self.unreliable_serial_numbersunreliable_serial_numbers = False
139 
140  self.watchable_characteristics: set[tuple[int, int]] = set()
141 
142  self._debounced_update_debounced_update = Debouncer(
143  hass,
144  _LOGGER,
145  cooldown=DEBOUNCE_COOLDOWN,
146  immediate=False,
147  function=self.async_updateasync_update,
148  background=True,
149  )
150 
151  self._availability_callbacks: set[CALLBACK_TYPE] = set()
152  self._config_changed_callbacks: set[CALLBACK_TYPE] = set()
153  self._subscriptions: dict[tuple[int, int], set[CALLBACK_TYPE]] = {}
154  self._pending_subscribes: set[tuple[int, int]] = set()
155  self._subscribe_timer_subscribe_timer: CALLBACK_TYPE | None = None
156  self._load_platforms_lock_load_platforms_lock = asyncio.Lock()
157  self._full_update_requested_full_update_requested: bool = False
158 
159  @property
160  def entity_map(self) -> Accessories:
161  """Return the accessories from the pairing."""
162  return self.pairingpairing.accessories_state.accessories
163 
164  @property
165  def config_num(self) -> int:
166  """Return the config num from the pairing."""
167  return self.pairingpairing.accessories_state.config_num
168 
170  self, characteristics: list[tuple[int, int]]
171  ) -> None:
172  """Add (aid, iid) pairs that we need to poll."""
173  self.pollable_characteristics.update(characteristics)
174 
176  self, characteristics: list[tuple[int, int]]
177  ) -> None:
178  """Remove all pollable characteristics by accessory id."""
179  for aid_iid in characteristics:
180  self.pollable_characteristics.discard(aid_iid)
181 
183  self, characteristics: list[tuple[int, int]]
184  ) -> None:
185  """Add (aid, iid) pairs that we need to poll."""
186  self.watchable_characteristics.update(characteristics)
187  self._pending_subscribes.update(characteristics)
188  # Try to subscribe to the characteristics all at once
189  if not self._subscribe_timer_subscribe_timer:
190  self._subscribe_timer_subscribe_timer = async_call_later(
191  self.hasshass,
192  SUBSCRIBE_COOLDOWN,
193  self._async_subscribe_async_subscribe,
194  )
195 
196  @callback
198  """Cancel the subscribe timer."""
199  if self._subscribe_timer_subscribe_timer:
200  self._subscribe_timer_subscribe_timer()
201  self._subscribe_timer_subscribe_timer = None
202 
203  @callback
204  def _async_subscribe(self, _now: datetime) -> None:
205  """Subscribe to characteristics."""
206  self._subscribe_timer_subscribe_timer = None
207  if self._pending_subscribes:
208  subscribes = self._pending_subscribes.copy()
209  self._pending_subscribes.clear()
210  self.config_entryconfig_entry.async_create_task(
211  self.hasshass,
212  self.pairingpairing.subscribe(subscribes),
213  name=f"hkc subscriptions {self.unique_id}",
214  eager_start=True,
215  )
216 
218  self, characteristics: list[tuple[int, int]]
219  ) -> None:
220  """Remove all pollable characteristics by accessory id."""
221  for aid_iid in characteristics:
222  self.watchable_characteristics.discard(aid_iid)
223  self._pending_subscribes.discard(aid_iid)
224 
225  @callback
226  def async_set_available_state(self, available: bool) -> None:
227  """Mark state of all entities on this connection when it becomes available or unavailable."""
228  _LOGGER.debug(
229  "Called async_set_available_state with %s for %s", available, self.unique_idunique_id
230  )
231  if self.availableavailable == available:
232  return
233  self.availableavailable = available
234  for callback_ in self._availability_callbacks:
235  callback_()
236 
237  async def _async_populate_ble_accessory_state(self, event: Event) -> None:
238  """Populate the BLE accessory state without blocking startup.
239 
240  If the accessory was asleep at startup we need to retry
241  since we continued on to allow startup to proceed.
242 
243  If this fails the state may be inconsistent, but will
244  get corrected as soon as the accessory advertises again.
245  """
246  self._async_start_polling_async_start_polling()
247  try:
248  await self.pairingpairing.async_populate_accessories_state(force_update=True)
249  except STARTUP_EXCEPTIONS as ex:
250  _LOGGER.debug(
251  (
252  "Failed to populate BLE accessory state for %s, accessory may be"
253  " sleeping and will be retried the next time it advertises: %s"
254  ),
255  self.config_entryconfig_entry.title,
256  ex,
257  )
258 
259  async def async_setup(self) -> None:
260  """Prepare to use a paired HomeKit device in Home Assistant."""
261  pairing = self.pairingpairing
262  transport = pairing.transport
263  entry = self.config_entryconfig_entry
264 
265  # We need to force an update here to make sure we have
266  # the latest values since the async_update we do in
267  # async_process_entity_map will no values to poll yet
268  # since entities are added via dispatching and then
269  # they add the chars they are concerned about in
270  # async_added_to_hass which is too late.
271  #
272  # Ideally we would know which entities we are about to add
273  # so we only poll those chars but that is not possible
274  # yet.
275  attempts = None if self.hasshass.state is CoreState.running else 1
276  if (
277  transport == Transport.BLE
278  and pairing.accessories
279  and pairing.accessories.has_aid(1)
280  ):
281  # The GSN gets restored and a catch up poll will be
282  # triggered via disconnected events automatically
283  # if we are out of sync. To be sure we are in sync;
284  # If for some reason the BLE connection failed
285  # previously we force an update after startup
286  # is complete.
287  entry.async_on_unload(
288  self.hasshass.bus.async_listen(
289  EVENT_HOMEASSISTANT_STARTED,
290  self._async_populate_ble_accessory_state_async_populate_ble_accessory_state,
291  )
292  )
293  else:
294  await self.pairingpairing.async_populate_accessories_state(
295  force_update=True, attempts=attempts
296  )
297  self._async_start_polling_async_start_polling()
298 
299  entry.async_on_unload(pairing.dispatcher_connect(self.process_new_eventsprocess_new_events))
300  entry.async_on_unload(
301  pairing.dispatcher_connect_config_changed(self.process_config_changedprocess_config_changed)
302  )
303  entry.async_on_unload(
304  pairing.dispatcher_availability_changed(self.async_set_available_stateasync_set_available_state)
305  )
306  entry.async_on_unload(self._async_cancel_subscription_timer_async_cancel_subscription_timer)
307 
308  await self.async_process_entity_mapasync_process_entity_map()
309 
310  # If everything is up to date, we can create the entities
311  # since we know the data is not stale.
312  await self.async_add_new_entitiesasync_add_new_entities()
313 
314  self.async_set_available_stateasync_set_available_state(self.pairingpairing.is_available)
315 
316  if transport == Transport.BLE:
317  # If we are using BLE, we need to periodically check of the
318  # BLE device is available since we won't get callbacks
319  # when it goes away since we HomeKit supports disconnected
320  # notifications and we cannot treat a disconnect as unavailability.
321  entry.async_on_unload(
323  self.hasshass,
324  self.async_update_available_stateasync_update_available_state,
325  timedelta(seconds=BLE_AVAILABILITY_CHECK_INTERVAL),
326  name=f"HomeKit Device {self.unique_id} BLE availability "
327  "check poll",
328  )
329  )
330  # BLE devices always get an RSSI sensor as well
331  if "sensor" not in self.platforms:
332  async with self._load_platforms_lock_load_platforms_lock:
333  await self._async_load_platforms_async_load_platforms({"sensor"})
334 
335  @callback
336  def _async_start_polling(self) -> None:
337  """Start polling for updates."""
338  # We use async_request_update to avoid multiple updates
339  # at the same time which would generate a spurious warning
340  # in the log about concurrent polling.
341  self.config_entryconfig_entry.async_on_unload(
343  self.hasshass,
344  self._async_schedule_update_async_schedule_update,
345  self.pairingpairing.poll_interval,
346  name=f"HomeKit Device {self.unique_id} availability check poll",
347  )
348  )
349 
350  @callback
351  def _async_schedule_update(self, now: datetime) -> None:
352  """Schedule an update."""
353  self.config_entryconfig_entry.async_create_background_task(
354  self.hasshass,
355  self._debounced_update_debounced_update.async_call(),
356  name=f"hkc {self.unique_id} alive poll",
357  eager_start=True,
358  )
359 
360  async def async_add_new_entities(self) -> None:
361  """Add new entities to Home Assistant."""
362  await self.async_load_platformsasync_load_platforms()
363  self.add_entitiesadd_entities()
364 
365  def device_info_for_accessory(self, accessory: Accessory) -> DeviceInfo:
366  """Build a DeviceInfo for a given accessory."""
367  identifiers = {
368  (
369  IDENTIFIER_ACCESSORY_ID,
370  f"{self.unique_id}:aid:{accessory.aid}",
371  )
372  }
373 
374  if not self.unreliable_serial_numbersunreliable_serial_numbers:
375  identifiers.add((IDENTIFIER_SERIAL_NUMBER, accessory.serial_number))
376 
377  device_info = DeviceInfo(
378  identifiers={
379  (
380  IDENTIFIER_ACCESSORY_ID,
381  f"{self.unique_id}:aid:{accessory.aid}",
382  )
383  },
384  name=accessory.name,
385  manufacturer=accessory.manufacturer,
386  model=accessory.model,
387  sw_version=accessory.firmware_revision,
388  hw_version=accessory.hardware_revision,
389  serial_number=accessory.serial_number,
390  )
391 
392  if accessory.aid != 1:
393  # Every pairing has an accessory 1
394  # It *doesn't* have a via_device, as it is the device we are connecting to
395  # Every other accessory should use it as its via device.
396  device_info[ATTR_VIA_DEVICE] = (
397  IDENTIFIER_ACCESSORY_ID,
398  f"{self.unique_id}:aid:1",
399  )
400 
401  return device_info
402 
403  @callback
404  def async_migrate_devices(self) -> None:
405  """Migrate legacy device entries from 3-tuples to 2-tuples."""
406  _LOGGER.debug(
407  "Migrating device registry entries for pairing %s", self.unique_idunique_id
408  )
409 
410  device_registry = dr.async_get(self.hasshass)
411 
412  for accessory in self.entity_mapentity_map.accessories:
413  identifiers = {
414  (
415  DOMAIN,
416  IDENTIFIER_LEGACY_ACCESSORY_ID,
417  f"{self.unique_id}_{accessory.aid}",
418  ),
419  }
420 
421  if accessory.aid == 1:
422  identifiers.add(
423  (DOMAIN, IDENTIFIER_LEGACY_ACCESSORY_ID, self.unique_idunique_id)
424  )
425 
426  if valid_serial_number(accessory.serial_number):
427  identifiers.add(
428  (DOMAIN, IDENTIFIER_LEGACY_SERIAL_NUMBER, accessory.serial_number)
429  )
430 
431  device = device_registry.async_get_device(identifiers=identifiers) # type: ignore[arg-type]
432  if not device:
433  continue
434 
435  if self.config_entryconfig_entry.entry_id not in device.config_entries:
436  _LOGGER.warning(
437  (
438  "Found candidate device for %s:aid:%s, but owned by a different"
439  " config entry, skipping"
440  ),
441  self.unique_idunique_id,
442  accessory.aid,
443  )
444  continue
445 
446  _LOGGER.debug(
447  "Migrating device identifiers for %s:aid:%s",
448  self.unique_idunique_id,
449  accessory.aid,
450  )
451  device_registry.async_update_device(
452  device.id,
453  new_identifiers={
454  (
455  IDENTIFIER_ACCESSORY_ID,
456  f"{self.unique_id}:aid:{accessory.aid}",
457  )
458  },
459  )
460 
461  @callback
463  self, old_unique_id: str, new_unique_id: str | None, platform: str
464  ) -> None:
465  """Migrate legacy unique IDs to new format."""
466  assert new_unique_id is not None
467  _LOGGER.debug(
468  "Checking if unique ID %s on %s needs to be migrated",
469  old_unique_id,
470  platform,
471  )
472  entity_registry = er.async_get(self.hasshass)
473  # async_get_entity_id wants the "homekit_controller" domain
474  # in the platform field and the actual platform in the domain
475  # field for historical reasons since everything used to be
476  # PLATFORM.INTEGRATION instead of INTEGRATION.PLATFORM
477  if (
478  entity_id := entity_registry.async_get_entity_id(
479  platform, DOMAIN, old_unique_id
480  )
481  ) is None:
482  _LOGGER.debug("Unique ID %s does not need to be migrated", old_unique_id)
483  return
484  if new_entity_id := entity_registry.async_get_entity_id(
485  platform, DOMAIN, new_unique_id
486  ):
487  _LOGGER.debug(
488  (
489  "Unique ID %s is already in use by %s (system may have been"
490  " downgraded)"
491  ),
492  new_unique_id,
493  new_entity_id,
494  )
495  return
496  _LOGGER.debug(
497  "Migrating unique ID for entity %s (%s -> %s)",
498  entity_id,
499  old_unique_id,
500  new_unique_id,
501  )
502  entity_registry.async_update_entity(entity_id, new_unique_id=new_unique_id)
503 
504  @callback
506  """Migrate remove legacy serial numbers from devices.
507 
508  We no longer use serial numbers as device identifiers
509  since they are not reliable, and the HomeKit spec
510  does not require them to be stable.
511  """
512  _LOGGER.debug(
513  (
514  "Removing legacy serial numbers from device registry entries for"
515  " pairing %s"
516  ),
517  self.unique_idunique_id,
518  )
519 
520  device_registry = dr.async_get(self.hasshass)
521  for accessory in self.entity_mapentity_map.accessories:
522  identifiers = {
523  (
524  IDENTIFIER_ACCESSORY_ID,
525  f"{self.unique_id}:aid:{accessory.aid}",
526  )
527  }
528  legacy_serial_identifier = (
529  IDENTIFIER_SERIAL_NUMBER,
530  accessory.serial_number,
531  )
532 
533  device = device_registry.async_get_device(identifiers=identifiers)
534  if not device or legacy_serial_identifier not in device.identifiers:
535  continue
536 
537  device_registry.async_update_device(device.id, new_identifiers=identifiers)
538 
539  @callback
541  """Delete entity registry entities for removed characteristics, services and accessories."""
542  _LOGGER.debug(
543  "Removing stale entity registry entries for pairing %s",
544  self.unique_idunique_id,
545  )
546 
547  reg = er.async_get(self.hasshass)
548 
549  # For the current config entry only, visit all registry entity entries
550  # Build a set of (unique_id, aid, sid, iid)
551  # For services, (unique_id, aid, sid, None)
552  # For accessories, (unique_id, aid, None, None)
553  entries = er.async_entries_for_config_entry(reg, self.config_entryconfig_entry.entry_id)
554  existing_entities = {
555  iids: entry.entity_id
556  for entry in entries
557  if (iids := unique_id_to_iids(entry.unique_id))
558  }
559 
560  # Process current entity map and produce a similar set
561  current_unique_id: set[IidTuple] = set()
562  for accessory in self.entity_mapentity_map.accessories:
563  current_unique_id.add((accessory.aid, None, None))
564 
565  for service in accessory.services:
566  current_unique_id.add((accessory.aid, service.iid, None))
567 
568  for char in service.characteristics:
569  if self.pairingpairing.transport != Transport.BLE:
570  if char.type == CharacteristicsTypes.THREAD_CONTROL_POINT:
571  continue
572 
573  current_unique_id.add(
574  (
575  accessory.aid,
576  service.iid,
577  char.iid,
578  )
579  )
580 
581  # Remove the difference
582  if stale := existing_entities.keys() - current_unique_id:
583  for parts in stale:
584  _LOGGER.debug(
585  "Removing stale entity registry entry %s for pairing %s",
586  existing_entities[parts],
587  self.unique_idunique_id,
588  )
589  reg.async_remove(existing_entities[parts])
590 
591  @callback
592  def async_migrate_ble_unique_id(self) -> None:
593  """Config entries from step_bluetooth used incorrect identifier for unique_id."""
594  unique_id = normalize_hkid(self.unique_idunique_id)
595  if unique_id != self.config_entryconfig_entry.unique_id:
596  _LOGGER.debug(
597  "Fixing incorrect unique_id: %s -> %s",
598  self.config_entryconfig_entry.unique_id,
599  unique_id,
600  )
601  self.hasshass.config_entries.async_update_entry(
602  self.config_entryconfig_entry, unique_id=unique_id
603  )
604 
605  @callback
606  def async_create_devices(self) -> None:
607  """Build device registry entries for all accessories paired with the bridge.
608 
609  This is done as well as by the entities for 2 reasons. First, the bridge
610  might not have any entities attached to it. Secondly there are stateless
611  entities like doorbells and remote controls.
612  """
613  device_registry = dr.async_get(self.hasshass)
614 
615  devices = {}
616 
617  # Accessories need to be created in the correct order or setting up
618  # relationships with ATTR_VIA_DEVICE may fail.
619  for accessory in sorted(self.entity_mapentity_map.accessories, key=attrgetter("aid")):
620  device_info = self.device_info_for_accessorydevice_info_for_accessory(accessory)
621 
622  device = device_registry.async_get_or_create(
623  config_entry_id=self.config_entryconfig_entry.entry_id,
624  **device_info,
625  )
626 
627  devices[accessory.aid] = device.id
628 
629  self.devicesdevices = devices
630 
631  @callback
632  def async_detect_workarounds(self) -> None:
633  """Detect any workarounds that are needed for this pairing."""
634  unreliable_serial_numbers = False
635 
636  devices = set()
637 
638  for accessory in self.entity_mapentity_map.accessories:
639  if not valid_serial_number(accessory.serial_number):
640  _LOGGER.debug(
641  (
642  "Serial number %r is not valid, it cannot be used as a unique"
643  " identifier"
644  ),
645  accessory.serial_number,
646  )
647  unreliable_serial_numbers = True
648 
649  elif accessory.serial_number in devices:
650  _LOGGER.debug(
651  (
652  "Serial number %r is duplicated within this pairing, it cannot"
653  " be used as a unique identifier"
654  ),
655  accessory.serial_number,
656  )
657  unreliable_serial_numbers = True
658 
659  elif accessory.serial_number == accessory.hardware_revision:
660  # This is a known bug with some devices (e.g. RYSE SmartShades)
661  _LOGGER.debug(
662  (
663  "Serial number %r is actually the hardware revision, it cannot"
664  " be used as a unique identifier"
665  ),
666  accessory.serial_number,
667  )
668  unreliable_serial_numbers = True
669 
670  devices.add(accessory.serial_number)
671 
672  self.unreliable_serial_numbersunreliable_serial_numbers = unreliable_serial_numbers
673 
674  async def async_process_entity_map(self) -> None:
675  """Process the entity map and load any platforms or entities that need adding.
676 
677  This is idempotent and will be called at startup and when we detect metadata changes
678  via the c# counter on the zeroconf record.
679  """
680  # Ensure the Pairing object has access to the latest version of the entity map. This
681  # is especially important for BLE, as the Pairing instance relies on the entity map
682  # to map aid/iid to GATT characteristics. So push it to there as well.
683  self.async_detect_workaroundsasync_detect_workarounds()
684 
685  # Migrate to new device ids
686  self.async_migrate_devicesasync_migrate_devices()
687 
688  # Remove any of the legacy serial numbers from the device registry
689  self.async_remove_legacy_device_serial_numbersasync_remove_legacy_device_serial_numbers()
690 
691  self.async_migrate_ble_unique_idasync_migrate_ble_unique_id()
692 
693  self.async_reap_stale_entity_registry_entriesasync_reap_stale_entity_registry_entries()
694 
695  self.async_create_devicesasync_create_devices()
696 
697  # Load any triggers for this config entry
698  await async_setup_triggers_for_entry(self.hasshass, self.config_entryconfig_entry)
699 
700  async def async_unload(self) -> None:
701  """Stop interacting with device and prepare for removal from hass."""
702  await self.pairingpairing.shutdown()
703 
704  await self.hasshass.config_entries.async_unload_platforms(
705  self.config_entryconfig_entry, self.platforms
706  )
707 
708  def process_config_changed(self, config_num: int) -> None:
709  """Handle a config change notification from the pairing."""
710  self.config_entryconfig_entry.async_create_task(
711  self.hasshass, self.async_update_new_accessories_stateasync_update_new_accessories_state(), eager_start=True
712  )
713 
714  async def async_update_new_accessories_state(self) -> None:
715  """Process a change in the pairings accessories state."""
716  await self.async_process_entity_mapasync_process_entity_map()
717  for callback_ in self._config_changed_callbacks:
718  callback_()
719  await self.async_updateasync_update()
720  await self.async_add_new_entitiesasync_add_new_entities()
721 
722  @callback
724  self, entity_key: tuple[int, int | None, int | None]
725  ) -> None:
726  """Handle an entity being removed.
727 
728  Releases the entity from self.entities so it can be added again.
729  """
730  self.entities.discard(entity_key)
731 
732  def add_accessory_factory(self, add_entities_cb: AddAccessoryCb) -> None:
733  """Add a callback to run when discovering new entities for accessories."""
734  self.accessory_factories.append(add_entities_cb)
735  self._add_new_entities_for_accessory_add_new_entities_for_accessory([add_entities_cb])
736 
737  def _add_new_entities_for_accessory(self, handlers: list[AddAccessoryCb]) -> None:
738  for accessory in self.entity_mapentity_map.accessories:
739  entity_key = (accessory.aid, None, None)
740  for handler in handlers:
741  if entity_key not in self.entities and handler(accessory):
742  self.entities.add(entity_key)
743  break
744 
745  def add_char_factory(self, add_entities_cb: AddCharacteristicCb) -> None:
746  """Add a callback to run when discovering new entities for accessories."""
747  self.char_factories.append(add_entities_cb)
748  self._add_new_entities_for_char_add_new_entities_for_char([add_entities_cb])
749 
750  def _add_new_entities_for_char(self, handlers: list[AddCharacteristicCb]) -> None:
751  for accessory in self.entity_mapentity_map.accessories:
752  for service in accessory.services:
753  for char in service.characteristics:
754  entity_key = (accessory.aid, service.iid, char.iid)
755  for handler in handlers:
756  if entity_key not in self.entities and handler(char):
757  self.entities.add(entity_key)
758  break
759 
760  def add_listener(self, add_entities_cb: AddServiceCb) -> None:
761  """Add a callback to run when discovering new entities for services."""
762  self.listeners.append(add_entities_cb)
763  self._add_new_entities_add_new_entities([add_entities_cb])
764 
765  def add_trigger_factory(self, add_triggers_cb: AddServiceCb) -> None:
766  """Add a callback to run when discovering new triggers for services."""
767  self.trigger_factories.append(add_triggers_cb)
768  self._add_new_triggers_add_new_triggers([add_triggers_cb])
769 
770  def _add_new_triggers(self, callbacks: list[AddServiceCb]) -> None:
771  for accessory in self.entity_mapentity_map.accessories:
772  aid = accessory.aid
773  for service in accessory.services:
774  iid = service.iid
775  entity_key = (aid, iid)
776 
777  if entity_key in self._triggers:
778  # Don't add the same trigger again
779  continue
780 
781  for add_trigger_cb in callbacks:
782  if add_trigger_cb(service):
783  self._triggers.add(entity_key)
784  break
785 
786  def add_entities(self) -> None:
787  """Process the entity map and create HA entities."""
788  self._add_new_entities_add_new_entities(self.listeners)
789  self._add_new_entities_for_accessory_add_new_entities_for_accessory(self.accessory_factories)
790  self._add_new_entities_for_char_add_new_entities_for_char(self.char_factories)
791  self._add_new_triggers_add_new_triggers(self.trigger_factories)
792 
793  def _add_new_entities(self, callbacks: list[AddServiceCb]) -> None:
794  for accessory in self.entity_mapentity_map.accessories:
795  aid = accessory.aid
796  for service in accessory.services:
797  entity_key = (aid, None, service.iid)
798 
799  if entity_key in self.entities:
800  # Don't add the same entity again
801  continue
802 
803  for listener in callbacks:
804  if listener(service):
805  self.entities.add(entity_key)
806  break
807 
808  async def _async_load_platforms(self, platforms: set[str]) -> None:
809  """Load a group of platforms."""
810  assert self._load_platforms_lock_load_platforms_lock.locked(), "Must be called with lock held"
811  if not (to_load := platforms - self.platforms):
812  return
813  self.platforms.update(to_load)
814  await self.hasshass.config_entries.async_forward_entry_setups(
815  self.config_entryconfig_entry, platforms
816  )
817 
818  async def async_load_platforms(self) -> None:
819  """Load any platforms needed by this HomeKit device."""
820  async with self._load_platforms_lock_load_platforms_lock:
821  to_load: set[str] = set()
822  for accessory in self.entity_mapentity_map.accessories:
823  for service in accessory.services:
824  if service.type in HOMEKIT_ACCESSORY_DISPATCH:
825  platform = HOMEKIT_ACCESSORY_DISPATCH[service.type]
826  if platform not in self.platforms:
827  to_load.add(platform)
828 
829  for char in service.characteristics:
830  if char.type in CHARACTERISTIC_PLATFORMS:
831  platform = CHARACTERISTIC_PLATFORMS[char.type]
832  if platform not in self.platforms:
833  to_load.add(platform)
834 
835  if to_load:
836  await self._async_load_platforms_async_load_platforms(to_load)
837 
838  @callback
839  def async_update_available_state(self, *_: Any) -> None:
840  """Update the available state of the device."""
841  self.async_set_available_stateasync_set_available_state(self.pairingpairing.is_available)
842 
843  async def async_request_update(self, now: datetime | None = None) -> None:
844  """Request an debounced update from the accessory."""
845  self._full_update_requested_full_update_requested = True
846  await self._debounced_update_debounced_update.async_call()
847 
848  async def async_update(self, now: datetime | None = None) -> None:
849  """Poll state of all entities attached to this bridge/accessory."""
850  to_poll = self.pollable_characteristics
851  accessories = self.entity_mapentity_map.accessories
852 
853  if (
854  not self._full_update_requested_full_update_requested
855  and len(accessories) == 1
856  and self.availableavailable
857  and not (to_poll - self.watchable_characteristics)
858  and self.pairingpairing.is_available
859  and await self.pairingpairing.controller.async_reachable(
860  self.unique_idunique_id, timeout=5.0
861  )
862  ):
863  # If its a single accessory and all chars are watchable,
864  # only poll the firmware version to keep the connection alive
865  # https://github.com/home-assistant/core/issues/123412
866  #
867  # Firmware revision is used here since iOS does this to keep camera
868  # connections alive, and the goal is to not regress
869  # https://github.com/home-assistant/core/issues/116143
870  # by polling characteristics that are not normally polled frequently
871  # and may not be tested by the device vendor.
872  #
873  _LOGGER.debug(
874  "Accessory is reachable, limiting poll to firmware version: %s",
875  self.unique_idunique_id,
876  )
877  first_accessory = accessories[0]
878  accessory_info = first_accessory.services.first(
879  service_type=ServicesTypes.ACCESSORY_INFORMATION
880  )
881  assert accessory_info is not None
882  firmware_iid = accessory_info[CharacteristicsTypes.FIRMWARE_REVISION].iid
883  to_poll = {(first_accessory.aid, firmware_iid)}
884 
885  self._full_update_requested_full_update_requested = False
886 
887  if not to_poll:
888  self.async_update_available_stateasync_update_available_state()
889  _LOGGER.debug(
890  "HomeKit connection not polling any characteristics: %s", self.unique_idunique_id
891  )
892  return
893 
894  if self._polling_lock_polling_lock.locked():
895  if not self._polling_lock_warned_polling_lock_warned:
896  _LOGGER.warning(
897  (
898  "HomeKit device update skipped as previous poll still in"
899  " flight: %s"
900  ),
901  self.unique_idunique_id,
902  )
903  self._polling_lock_warned_polling_lock_warned = True
904  return
905 
906  if self._polling_lock_warned_polling_lock_warned:
907  _LOGGER.warning(
908  (
909  "HomeKit device no longer detecting back pressure - not"
910  " skipping poll: %s"
911  ),
912  self.unique_idunique_id,
913  )
914  self._polling_lock_warned_polling_lock_warned = False
915 
916  async with self._polling_lock_polling_lock:
917  _LOGGER.debug("Starting HomeKit device update: %s", self.unique_idunique_id)
918 
919  try:
920  new_values_dict = await self.get_characteristicsget_characteristics(to_poll)
921  except AccessoryNotFoundError:
922  # Not only did the connection fail, but also the accessory is not
923  # visible on the network.
924  self.async_set_available_stateasync_set_available_state(False)
925  return
926  except (AccessoryDisconnectedError, EncryptionError):
927  # Temporary connection failure. Device may still available but our
928  # connection was dropped or we are reconnecting
929  self._poll_failures_poll_failures += 1
930  if self._poll_failures_poll_failures >= MAX_POLL_FAILURES_TO_DECLARE_UNAVAILABLE:
931  self.async_set_available_stateasync_set_available_state(False)
932  return
933 
934  self._poll_failures_poll_failures = 0
935  self.process_new_eventsprocess_new_events(new_values_dict)
936 
937  _LOGGER.debug("Finished HomeKit device update: %s", self.unique_idunique_id)
938 
940  self, new_values_dict: dict[tuple[int, int], dict[str, Any]]
941  ) -> None:
942  """Process events from accessory into HA state."""
943  self.async_set_available_stateasync_set_available_state(True)
944 
945  # Process any stateless events (via device_triggers)
946  async_fire_triggers(self, new_values_dict)
947 
948  to_callback: set[CALLBACK_TYPE] = set()
949  for aid_iid in self.entity_mapentity_map.process_changes(new_values_dict):
950  if callbacks := self._subscriptions.get(aid_iid):
951  to_callback.update(callbacks)
952 
953  for callback_ in to_callback:
954  callback_()
955 
956  @callback
958  self, characteristics: set[tuple[int, int]], callback_: CALLBACK_TYPE
959  ) -> None:
960  """Remove a characteristics callback."""
961  for aid_iid in characteristics:
962  self._subscriptions[aid_iid].remove(callback_)
963  if not self._subscriptions[aid_iid]:
964  del self._subscriptions[aid_iid]
965 
966  @callback
968  self, characteristics: set[tuple[int, int]], callback_: CALLBACK_TYPE
969  ) -> CALLBACK_TYPE:
970  """Add characteristics to the watch list."""
971  for aid_iid in characteristics:
972  self._subscriptions.setdefault(aid_iid, set()).add(callback_)
973  return partial(
974  self._remove_characteristics_callback_remove_characteristics_callback, characteristics, callback_
975  )
976 
977  @callback
978  def _remove_availability_callback(self, callback_: CALLBACK_TYPE) -> None:
979  """Remove an availability callback."""
980  self._availability_callbacks.remove(callback_)
981 
982  @callback
983  def async_subscribe_availability(self, callback_: CALLBACK_TYPE) -> CALLBACK_TYPE:
984  """Add characteristics to the watch list."""
985  self._availability_callbacks.add(callback_)
986  return partial(self._remove_availability_callback_remove_availability_callback, callback_)
987 
988  @callback
989  def _remove_config_changed_callback(self, callback_: CALLBACK_TYPE) -> None:
990  """Remove an availability callback."""
991  self._config_changed_callbacks.remove(callback_)
992 
993  @callback
994  def async_subscribe_config_changed(self, callback_: CALLBACK_TYPE) -> CALLBACK_TYPE:
995  """Subscribe to config of the accessory being changed aka c# changes."""
996  self._config_changed_callbacks.add(callback_)
997  return partial(self._remove_config_changed_callback_remove_config_changed_callback, callback_)
998 
1000  self, *args: Any, **kwargs: Any
1001  ) -> dict[tuple[int, int], dict[str, Any]]:
1002  """Read latest state from homekit accessory."""
1003  return await self.pairingpairing.get_characteristics(*args, **kwargs)
1004 
1006  self, characteristics: Iterable[tuple[int, int, Any]]
1007  ) -> None:
1008  """Control a HomeKit device state from Home Assistant."""
1009  await self.pairingpairing.put_characteristics(characteristics)
1010 
1011  @property
1013  """Is this a thread capable device not connected by CoAP."""
1014  if self.pairingpairing.controller.transport_type != TransportType.BLE:
1015  return False
1016 
1017  if not self.entity_mapentity_map.aid(1).services.first(
1018  service_type=ServicesTypes.THREAD_TRANSPORT
1019  ):
1020  return False
1021 
1022  return True
1023 
1024  async def async_thread_provision(self) -> None:
1025  """Migrate a HomeKit pairing to CoAP (Thread)."""
1026  if self.pairingpairing.controller.transport_type == TransportType.COAP:
1027  raise HomeAssistantError("Already connected to a thread network")
1028 
1029  if not (dataset := await async_get_preferred_dataset(self.hasshass)):
1030  raise HomeAssistantError("No thread network credentials available")
1031 
1032  await self.pairingpairing.thread_provision(dataset)
1033 
1034  try:
1035  discovery = (
1036  await self.hasshass.data[CONTROLLER]
1037  .transports[TransportType.COAP]
1038  .async_find(self.unique_idunique_id, timeout=30)
1039  )
1040  self.hasshass.config_entries.async_update_entry(
1041  self.config_entryconfig_entry,
1042  data={
1043  **self.config_entryconfig_entry.data,
1044  "Connection": "CoAP",
1045  "AccessoryIP": discovery.description.address,
1046  "AccessoryPort": discovery.description.port,
1047  },
1048  )
1049  _LOGGER.debug(
1050  "%s: Found device on local network, migrating integration to Thread",
1051  self.unique_idunique_id,
1052  )
1053 
1054  except AccessoryNotFoundError as exc:
1055  _LOGGER.debug(
1056  "%s: Failed to appear on local network as a Thread device, reverting to BLE",
1057  self.unique_idunique_id,
1058  )
1059  raise HomeAssistantError("Could not migrate device to Thread") from exc
1060 
1061  finally:
1062  await self.hasshass.config_entries.async_reload(self.config_entryconfig_entry.entry_id)
1063 
1064  @property
1065  def unique_id(self) -> str:
1066  """Return a unique id for this accessory or bridge.
1067 
1068  This id is random and will change if a device undergoes a hard reset.
1069  """
1070  return self.pairing_datapairing_data["AccessoryPairingID"]
None process_new_events(self, dict[tuple[int, int], dict[str, Any]] new_values_dict)
Definition: connection.py:941
None remove_pollable_characteristics(self, list[tuple[int, int]] characteristics)
Definition: connection.py:177
CALLBACK_TYPE async_subscribe_availability(self, CALLBACK_TYPE callback_)
Definition: connection.py:983
None add_char_factory(self, AddCharacteristicCb add_entities_cb)
Definition: connection.py:745
None async_entity_key_removed(self, tuple[int, int|None, int|None] entity_key)
Definition: connection.py:725
None _remove_characteristics_callback(self, set[tuple[int, int]] characteristics, CALLBACK_TYPE callback_)
Definition: connection.py:959
None add_pollable_characteristics(self, list[tuple[int, int]] characteristics)
Definition: connection.py:171
None _remove_config_changed_callback(self, CALLBACK_TYPE callback_)
Definition: connection.py:989
DeviceInfo device_info_for_accessory(self, Accessory accessory)
Definition: connection.py:365
None remove_watchable_characteristics(self, list[tuple[int, int]] characteristics)
Definition: connection.py:219
None add_trigger_factory(self, AddServiceCb add_triggers_cb)
Definition: connection.py:765
None _add_new_entities(self, list[AddServiceCb] callbacks)
Definition: connection.py:793
None _remove_availability_callback(self, CALLBACK_TYPE callback_)
Definition: connection.py:978
None add_accessory_factory(self, AddAccessoryCb add_entities_cb)
Definition: connection.py:732
None __init__(self, HomeAssistant hass, ConfigEntry config_entry, MappingProxyType[str, Any] pairing_data)
Definition: connection.py:83
None _add_new_entities_for_accessory(self, list[AddAccessoryCb] handlers)
Definition: connection.py:737
dict[tuple[int, int], dict[str, Any]] get_characteristics(self, *Any args, **Any kwargs)
Definition: connection.py:1001
CALLBACK_TYPE async_subscribe_config_changed(self, CALLBACK_TYPE callback_)
Definition: connection.py:994
None async_migrate_unique_id(self, str old_unique_id, str|None new_unique_id, str platform)
Definition: connection.py:464
None _add_new_entities_for_char(self, list[AddCharacteristicCb] handlers)
Definition: connection.py:750
None _add_new_triggers(self, list[AddServiceCb] callbacks)
Definition: connection.py:770
CALLBACK_TYPE async_subscribe(self, set[tuple[int, int]] characteristics, CALLBACK_TYPE callback_)
Definition: connection.py:969
None put_characteristics(self, Iterable[tuple[int, int, Any]] characteristics)
Definition: connection.py:1007
None add_listener(self, AddServiceCb add_entities_cb)
Definition: connection.py:760
None add_watchable_characteristics(self, list[tuple[int, int]] characteristics)
Definition: connection.py:184
bool add(self, _T matcher)
Definition: match.py:185
bool remove(self, _T matcher)
Definition: match.py:214
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
None async_setup_triggers_for_entry(HomeAssistant hass, ConfigEntry config_entry)
None async_fire_triggers(HKDevice conn, dict[tuple[int, int], dict[str, Any]] events)
IidTuple|None unique_id_to_iids(str unique_id)
Definition: utils.py:18
IssData update(pyiss.ISS iss)
Definition: __init__.py:33
Callable[[], None] subscribe(HomeAssistant hass, str topic, MessageCallbackType msg_callback, int qos=DEFAULT_QOS, str encoding="utf-8")
Definition: client.py:247
str|None async_get_preferred_dataset(HomeAssistant hass)
CALLBACK_TYPE async_call_later(HomeAssistant hass, float|timedelta delay, HassJob[[datetime], Coroutine[Any, Any, None]|None]|Callable[[datetime], Coroutine[Any, Any, None]|None] action)
Definition: event.py:1597
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