1 """Helpers for managing a pairing with a HomeKit accessory or bridge."""
3 from __future__
import annotations
6 from collections.abc
import Callable, Iterable
7 from datetime
import datetime, timedelta
8 from functools
import partial
10 from operator
import attrgetter
11 from types
import MappingProxyType
12 from typing
import Any
14 from aiohomekit
import Controller
15 from aiohomekit.controller
import TransportType
16 from aiohomekit.exceptions
import (
17 AccessoryDisconnectedError,
18 AccessoryNotFoundError,
21 from aiohomekit.model
import Accessories, Accessory, Transport
22 from aiohomekit.model.characteristics
import Characteristic, CharacteristicsTypes
23 from aiohomekit.model.services
import Service, ServicesTypes
28 from homeassistant.core import CALLBACK_TYPE, CoreState, Event, HomeAssistant, callback
35 from .config_flow
import normalize_hkid
37 CHARACTERISTIC_PLATFORMS,
41 HOMEKIT_ACCESSORY_DISPATCH,
42 IDENTIFIER_ACCESSORY_ID,
43 IDENTIFIER_LEGACY_ACCESSORY_ID,
44 IDENTIFIER_LEGACY_SERIAL_NUMBER,
45 IDENTIFIER_SERIAL_NUMBER,
49 from .device_trigger
import async_fire_triggers, async_setup_triggers_for_entry
50 from .utils
import IidTuple, unique_id_to_iids
53 MAX_POLL_FAILURES_TO_DECLARE_UNAVAILABLE = 3
56 BLE_AVAILABILITY_CHECK_INTERVAL = 1800
58 _LOGGER = logging.getLogger(__name__)
60 type AddAccessoryCb = Callable[[Accessory], bool]
61 type AddServiceCb = Callable[[Service], bool]
62 type AddCharacteristicCb = Callable[[Characteristic], bool]
66 """Return if the serial number appears to be valid."""
70 return float(
"".join(serial.rsplit(
".", 1))) > 1
81 config_entry: ConfigEntry,
82 pairing_data: MappingProxyType[str, Any],
84 """Initialise a generic HomeKit device."""
93 connection: Controller = hass.data[CONTROLLER]
98 self.accessory_factories: list[AddAccessoryCb] = []
101 self.listeners: list[AddServiceCb] = []
104 self.trigger_factories: list[AddServiceCb] = []
108 self._triggers: set[tuple[int, int]] = set()
111 self.char_factories: list[AddCharacteristicCb] = []
118 self.platforms: set[str] = set()
122 self.entities: set[tuple[int, int |
None, int |
None]] = set()
126 self.
devicesdevices: dict[int, str] = {}
130 self.pollable_characteristics: set[tuple[int, int]] = set()
140 self.watchable_characteristics: set[tuple[int, int]] = set()
145 cooldown=DEBOUNCE_COOLDOWN,
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()
161 """Return the accessories from the pairing."""
162 return self.
pairingpairing.accessories_state.accessories
166 """Return the config num from the pairing."""
167 return self.
pairingpairing.accessories_state.config_num
170 self, characteristics: list[tuple[int, int]]
172 """Add (aid, iid) pairs that we need to poll."""
173 self.pollable_characteristics.
update(characteristics)
176 self, characteristics: list[tuple[int, int]]
178 """Remove all pollable characteristics by accessory id."""
179 for aid_iid
in characteristics:
180 self.pollable_characteristics.discard(aid_iid)
183 self, characteristics: list[tuple[int, int]]
185 """Add (aid, iid) pairs that we need to poll."""
186 self.watchable_characteristics.
update(characteristics)
187 self._pending_subscribes.
update(characteristics)
198 """Cancel the subscribe timer."""
205 """Subscribe to characteristics."""
207 if self._pending_subscribes:
208 subscribes = self._pending_subscribes.copy()
209 self._pending_subscribes.clear()
213 name=f
"hkc subscriptions {self.unique_id}",
218 self, characteristics: list[tuple[int, int]]
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)
227 """Mark state of all entities on this connection when it becomes available or unavailable."""
229 "Called async_set_available_state with %s for %s", available, self.
unique_idunique_id
234 for callback_
in self._availability_callbacks:
238 """Populate the BLE accessory state without blocking startup.
240 If the accessory was asleep at startup we need to retry
241 since we continued on to allow startup to proceed.
243 If this fails the state may be inconsistent, but will
244 get corrected as soon as the accessory advertises again.
248 await self.
pairingpairing.async_populate_accessories_state(force_update=
True)
249 except STARTUP_EXCEPTIONS
as ex:
252 "Failed to populate BLE accessory state for %s, accessory may be"
253 " sleeping and will be retried the next time it advertises: %s"
260 """Prepare to use a paired HomeKit device in Home Assistant."""
262 transport = pairing.transport
275 attempts =
None if self.
hasshass.state
is CoreState.running
else 1
277 transport == Transport.BLE
278 and pairing.accessories
279 and pairing.accessories.has_aid(1)
287 entry.async_on_unload(
288 self.
hasshass.bus.async_listen(
289 EVENT_HOMEASSISTANT_STARTED,
294 await self.
pairingpairing.async_populate_accessories_state(
295 force_update=
True, attempts=attempts
299 entry.async_on_unload(pairing.dispatcher_connect(self.
process_new_eventsprocess_new_events))
300 entry.async_on_unload(
303 entry.async_on_unload(
316 if transport == Transport.BLE:
321 entry.async_on_unload(
325 timedelta(seconds=BLE_AVAILABILITY_CHECK_INTERVAL),
326 name=f
"HomeKit Device {self.unique_id} BLE availability "
331 if "sensor" not in self.platforms:
337 """Start polling for updates."""
345 self.
pairingpairing.poll_interval,
346 name=f
"HomeKit Device {self.unique_id} availability check poll",
352 """Schedule an update."""
353 self.
config_entryconfig_entry.async_create_background_task(
356 name=f
"hkc {self.unique_id} alive poll",
361 """Add new entities to Home Assistant."""
366 """Build a DeviceInfo for a given accessory."""
369 IDENTIFIER_ACCESSORY_ID,
370 f
"{self.unique_id}:aid:{accessory.aid}",
375 identifiers.add((IDENTIFIER_SERIAL_NUMBER, accessory.serial_number))
380 IDENTIFIER_ACCESSORY_ID,
381 f
"{self.unique_id}:aid:{accessory.aid}",
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,
392 if accessory.aid != 1:
396 device_info[ATTR_VIA_DEVICE] = (
397 IDENTIFIER_ACCESSORY_ID,
398 f
"{self.unique_id}:aid:1",
405 """Migrate legacy device entries from 3-tuples to 2-tuples."""
407 "Migrating device registry entries for pairing %s", self.
unique_idunique_id
410 device_registry = dr.async_get(self.
hasshass)
412 for accessory
in self.
entity_mapentity_map.accessories:
416 IDENTIFIER_LEGACY_ACCESSORY_ID,
417 f
"{self.unique_id}_{accessory.aid}",
421 if accessory.aid == 1:
423 (DOMAIN, IDENTIFIER_LEGACY_ACCESSORY_ID, self.
unique_idunique_id)
428 (DOMAIN, IDENTIFIER_LEGACY_SERIAL_NUMBER, accessory.serial_number)
431 device = device_registry.async_get_device(identifiers=identifiers)
435 if self.
config_entryconfig_entry.entry_id
not in device.config_entries:
438 "Found candidate device for %s:aid:%s, but owned by a different"
439 " config entry, skipping"
447 "Migrating device identifiers for %s:aid:%s",
451 device_registry.async_update_device(
455 IDENTIFIER_ACCESSORY_ID,
456 f
"{self.unique_id}:aid:{accessory.aid}",
463 self, old_unique_id: str, new_unique_id: str |
None, platform: str
465 """Migrate legacy unique IDs to new format."""
466 assert new_unique_id
is not None
468 "Checking if unique ID %s on %s needs to be migrated",
472 entity_registry = er.async_get(self.
hasshass)
478 entity_id := entity_registry.async_get_entity_id(
479 platform, DOMAIN, old_unique_id
482 _LOGGER.debug(
"Unique ID %s does not need to be migrated", old_unique_id)
484 if new_entity_id := entity_registry.async_get_entity_id(
485 platform, DOMAIN, new_unique_id
489 "Unique ID %s is already in use by %s (system may have been"
497 "Migrating unique ID for entity %s (%s -> %s)",
502 entity_registry.async_update_entity(entity_id, new_unique_id=new_unique_id)
506 """Migrate remove legacy serial numbers from devices.
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.
514 "Removing legacy serial numbers from device registry entries for"
520 device_registry = dr.async_get(self.
hasshass)
521 for accessory
in self.
entity_mapentity_map.accessories:
524 IDENTIFIER_ACCESSORY_ID,
525 f
"{self.unique_id}:aid:{accessory.aid}",
528 legacy_serial_identifier = (
529 IDENTIFIER_SERIAL_NUMBER,
530 accessory.serial_number,
533 device = device_registry.async_get_device(identifiers=identifiers)
534 if not device
or legacy_serial_identifier
not in device.identifiers:
537 device_registry.async_update_device(device.id, new_identifiers=identifiers)
541 """Delete entity registry entities for removed characteristics, services and accessories."""
543 "Removing stale entity registry entries for pairing %s",
547 reg = er.async_get(self.
hasshass)
553 entries = er.async_entries_for_config_entry(reg, self.
config_entryconfig_entry.entry_id)
554 existing_entities = {
555 iids: entry.entity_id
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))
565 for service
in accessory.services:
566 current_unique_id.add((accessory.aid, service.iid,
None))
568 for char
in service.characteristics:
569 if self.
pairingpairing.transport != Transport.BLE:
570 if char.type == CharacteristicsTypes.THREAD_CONTROL_POINT:
573 current_unique_id.add(
582 if stale := existing_entities.keys() - current_unique_id:
585 "Removing stale entity registry entry %s for pairing %s",
586 existing_entities[parts],
589 reg.async_remove(existing_entities[parts])
593 """Config entries from step_bluetooth used incorrect identifier for unique_id."""
595 if unique_id != self.
config_entryconfig_entry.unique_id:
597 "Fixing incorrect unique_id: %s -> %s",
601 self.
hasshass.config_entries.async_update_entry(
607 """Build device registry entries for all accessories paired with the bridge.
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.
613 device_registry = dr.async_get(self.
hasshass)
619 for accessory
in sorted(self.
entity_mapentity_map.accessories, key=attrgetter(
"aid")):
622 device = device_registry.async_get_or_create(
627 devices[accessory.aid] = device.id
633 """Detect any workarounds that are needed for this pairing."""
634 unreliable_serial_numbers =
False
638 for accessory
in self.
entity_mapentity_map.accessories:
642 "Serial number %r is not valid, it cannot be used as a unique"
645 accessory.serial_number,
647 unreliable_serial_numbers =
True
649 elif accessory.serial_number
in devices:
652 "Serial number %r is duplicated within this pairing, it cannot"
653 " be used as a unique identifier"
655 accessory.serial_number,
657 unreliable_serial_numbers =
True
659 elif accessory.serial_number == accessory.hardware_revision:
663 "Serial number %r is actually the hardware revision, it cannot"
664 " be used as a unique identifier"
666 accessory.serial_number,
668 unreliable_serial_numbers =
True
670 devices.add(accessory.serial_number)
675 """Process the entity map and load any platforms or entities that need adding.
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.
701 """Stop interacting with device and prepare for removal from hass."""
704 await self.
hasshass.config_entries.async_unload_platforms(
709 """Handle a config change notification from the pairing."""
715 """Process a change in the pairings accessories state."""
717 for callback_
in self._config_changed_callbacks:
724 self, entity_key: tuple[int, int |
None, int |
None]
726 """Handle an entity being removed.
728 Releases the entity from self.entities so it can be added again.
730 self.entities.discard(entity_key)
733 """Add a callback to run when discovering new entities for accessories."""
734 self.accessory_factories.append(add_entities_cb)
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)
746 """Add a callback to run when discovering new entities for accessories."""
747 self.char_factories.append(add_entities_cb)
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)
761 """Add a callback to run when discovering new entities for services."""
762 self.listeners.append(add_entities_cb)
766 """Add a callback to run when discovering new triggers for services."""
767 self.trigger_factories.append(add_triggers_cb)
771 for accessory
in self.
entity_mapentity_map.accessories:
773 for service
in accessory.services:
775 entity_key = (aid, iid)
777 if entity_key
in self._triggers:
781 for add_trigger_cb
in callbacks:
782 if add_trigger_cb(service):
783 self._triggers.
add(entity_key)
787 """Process the entity map and create HA entities."""
794 for accessory
in self.
entity_mapentity_map.accessories:
796 for service
in accessory.services:
797 entity_key = (aid,
None, service.iid)
799 if entity_key
in self.entities:
803 for listener
in callbacks:
804 if listener(service):
805 self.entities.
add(entity_key)
809 """Load a group of platforms."""
811 if not (to_load := platforms - self.platforms):
813 self.platforms.
update(to_load)
814 await self.
hasshass.config_entries.async_forward_entry_setups(
819 """Load any platforms needed by this HomeKit device."""
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)
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)
840 """Update the available state of the device."""
844 """Request an debounced update from the accessory."""
849 """Poll state of all entities attached to this bridge/accessory."""
850 to_poll = self.pollable_characteristics
851 accessories = self.
entity_mapentity_map.accessories
855 and len(accessories) == 1
857 and not (to_poll - self.watchable_characteristics)
858 and self.
pairingpairing.is_available
859 and await self.
pairingpairing.controller.async_reachable(
874 "Accessory is reachable, limiting poll to firmware version: %s",
877 first_accessory = accessories[0]
878 accessory_info = first_accessory.services.first(
879 service_type=ServicesTypes.ACCESSORY_INFORMATION
881 assert accessory_info
is not None
882 firmware_iid = accessory_info[CharacteristicsTypes.FIRMWARE_REVISION].iid
883 to_poll = {(first_accessory.aid, firmware_iid)}
890 "HomeKit connection not polling any characteristics: %s", self.
unique_idunique_id
898 "HomeKit device update skipped as previous poll still in"
909 "HomeKit device no longer detecting back pressure - not"
917 _LOGGER.debug(
"Starting HomeKit device update: %s", self.
unique_idunique_id)
921 except AccessoryNotFoundError:
926 except (AccessoryDisconnectedError, EncryptionError):
930 if self.
_poll_failures_poll_failures >= MAX_POLL_FAILURES_TO_DECLARE_UNAVAILABLE:
937 _LOGGER.debug(
"Finished HomeKit device update: %s", self.
unique_idunique_id)
940 self, new_values_dict: dict[tuple[int, int], dict[str, Any]]
942 """Process events from accessory into HA state."""
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)
953 for callback_
in to_callback:
958 self, characteristics: set[tuple[int, int]], callback_: CALLBACK_TYPE
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]
968 self, characteristics: set[tuple[int, int]], callback_: CALLBACK_TYPE
970 """Add characteristics to the watch list."""
971 for aid_iid
in characteristics:
972 self._subscriptions.setdefault(aid_iid, set()).
add(callback_)
979 """Remove an availability callback."""
980 self._availability_callbacks.
remove(callback_)
984 """Add characteristics to the watch list."""
985 self._availability_callbacks.
add(callback_)
990 """Remove an availability callback."""
991 self._config_changed_callbacks.
remove(callback_)
995 """Subscribe to config of the accessory being changed aka c# changes."""
996 self._config_changed_callbacks.
add(callback_)
1000 self, *args: Any, **kwargs: Any
1001 ) -> dict[tuple[int, int], dict[str, Any]]:
1002 """Read latest state from homekit accessory."""
1006 self, characteristics: Iterable[tuple[int, int, Any]]
1008 """Control a HomeKit device state from Home Assistant."""
1013 """Is this a thread capable device not connected by CoAP."""
1014 if self.
pairingpairing.controller.transport_type != TransportType.BLE:
1017 if not self.
entity_mapentity_map.aid(1).services.first(
1018 service_type=ServicesTypes.THREAD_TRANSPORT
1025 """Migrate a HomeKit pairing to CoAP (Thread)."""
1026 if self.
pairingpairing.controller.transport_type == TransportType.COAP:
1032 await self.
pairingpairing.thread_provision(dataset)
1036 await self.
hasshass.data[CONTROLLER]
1037 .transports[TransportType.COAP]
1038 .async_find(self.
unique_idunique_id, timeout=30)
1040 self.
hasshass.config_entries.async_update_entry(
1044 "Connection":
"CoAP",
1045 "AccessoryIP": discovery.description.address,
1046 "AccessoryPort": discovery.description.port,
1050 "%s: Found device on local network, migrating integration to Thread",
1054 except AccessoryNotFoundError
as exc:
1056 "%s: Failed to appear on local network as a Thread device, reverting to BLE",
1062 await self.
hasshass.config_entries.async_reload(self.
config_entryconfig_entry.entry_id)
1066 """Return a unique id for this accessory or bridge.
1068 This id is random and will change if a device undergoes a hard reset.
1070 return self.
pairing_datapairing_data[
"AccessoryPairingID"]
None async_migrate_ble_unique_id(self)
None async_update(self, datetime|None now=None)
None _async_subscribe(self, datetime _now)
unreliable_serial_numbers
None process_new_events(self, dict[tuple[int, int], dict[str, Any]] new_values_dict)
None remove_pollable_characteristics(self, list[tuple[int, int]] characteristics)
CALLBACK_TYPE async_subscribe_availability(self, CALLBACK_TYPE callback_)
None async_load_platforms(self)
None async_process_entity_map(self)
None add_char_factory(self, AddCharacteristicCb add_entities_cb)
None async_request_update(self, datetime|None now=None)
None async_entity_key_removed(self, tuple[int, int|None, int|None] entity_key)
None async_reap_stale_entity_registry_entries(self)
None async_detect_workarounds(self)
None _async_start_polling(self)
None _remove_characteristics_callback(self, set[tuple[int, int]] characteristics, CALLBACK_TYPE callback_)
None async_update_new_accessories_state(self)
None add_pollable_characteristics(self, list[tuple[int, int]] characteristics)
None _remove_config_changed_callback(self, CALLBACK_TYPE callback_)
DeviceInfo device_info_for_accessory(self, Accessory accessory)
None async_thread_provision(self)
None remove_watchable_characteristics(self, list[tuple[int, int]] characteristics)
None _async_load_platforms(self, set[str] platforms)
None async_migrate_devices(self)
None add_trigger_factory(self, AddServiceCb add_triggers_cb)
None _add_new_entities(self, list[AddServiceCb] callbacks)
None _remove_availability_callback(self, CALLBACK_TYPE callback_)
None add_accessory_factory(self, AddAccessoryCb add_entities_cb)
None __init__(self, HomeAssistant hass, ConfigEntry config_entry, MappingProxyType[str, Any] pairing_data)
None _async_schedule_update(self, datetime now)
None async_add_new_entities(self)
None async_update_available_state(self, *Any _)
None _add_new_entities_for_accessory(self, list[AddAccessoryCb] handlers)
None async_create_devices(self)
dict[tuple[int, int], dict[str, Any]] get_characteristics(self, *Any args, **Any kwargs)
None _async_cancel_subscription_timer(self)
Accessories entity_map(self)
bool is_unprovisioned_thread_device(self)
CALLBACK_TYPE async_subscribe_config_changed(self, CALLBACK_TYPE callback_)
None async_migrate_unique_id(self, str old_unique_id, str|None new_unique_id, str platform)
None process_config_changed(self, int config_num)
None async_remove_legacy_device_serial_numbers(self)
None _add_new_entities_for_char(self, list[AddCharacteristicCb] handlers)
None _add_new_triggers(self, list[AddServiceCb] callbacks)
CALLBACK_TYPE async_subscribe(self, set[tuple[int, int]] characteristics, CALLBACK_TYPE callback_)
None put_characteristics(self, Iterable[tuple[int, int, Any]] characteristics)
None async_set_available_state(self, bool available)
None _async_populate_ble_accessory_state(self, Event event)
None add_listener(self, AddServiceCb add_entities_cb)
None add_watchable_characteristics(self, list[tuple[int, int]] characteristics)
bool add(self, _T matcher)
bool remove(self, _T matcher)
web.Response get(self, web.Request request, str config_key)
str normalize_hkid(str hkid)
bool valid_serial_number(str serial)
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)
IssData update(pyiss.ISS iss)
Callable[[], None] subscribe(HomeAssistant hass, str topic, MessageCallbackType msg_callback, int qos=DEFAULT_QOS, str encoding="utf-8")
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)
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)