Files
core/homeassistant/components/homekit/aidmanager.py
T
2026-07-10 08:31:22 -10:00

243 lines
8.9 KiB
Python

"""Manage allocation of accessory ID's.
HomeKit needs to allocate unique numbers to each accessory. These need to
be stable between reboots and upgrades.
Using a hash function to generate them means collisions. It also means you
can't change the hash without causing breakages for HA users.
This module generates and stores them in a HA storage.
"""
from collections.abc import Generator
import random
from fnv_hash_fast import fnv1a_32
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.storage import Store
from .util import get_aid_storage_filename_for_entry_id
AID_MANAGER_STORAGE_VERSION = 1
AID_MANAGER_SAVE_DELAY = 2
ALLOCATIONS_KEY = "allocations"
UNIQUE_IDS_KEY = "unique_ids"
ACCESSORY_TYPES_KEY = "accessory_types"
INVALID_AIDS = (0, 1)
AID_MIN = 2
AID_MAX = 18446744073709551615
def get_system_unique_id(entity: er.RegistryEntry, entity_unique_id: str) -> str:
"""Determine the system wide unique_id for an entity."""
return f"{entity.platform}.{entity.domain}.{entity_unique_id}"
def _generate_aids(unique_id: str | None, entity_id: str) -> Generator[int]:
"""Generate accessory aid."""
if unique_id:
# Use fnv1a_32 of the unique id as
# fnv1a_32 has less collisions than
# adler32
yield fnv1a_32(unique_id.encode("utf-8"))
# If there is no unique id we use
# fnv1a_32 as it is unlikely to collide
yield fnv1a_32(entity_id.encode("utf-8"))
# If called again resort to random allocations.
# Given the size of the range its unlikely we'll encounter duplicates
# But try a few times regardless
for _ in range(5):
yield random.randrange(AID_MIN, AID_MAX)
class AccessoryAidStorage:
"""Holds a map of entity ID to HomeKit ID.
Will generate new ID's, ensure they are unique and store them to make sure they
persist over reboots.
"""
def __init__(self, hass: HomeAssistant, entry_id: str) -> None:
"""Create a new entity map store."""
self.hass = hass
self.allocations: dict[str, int] = {}
self.allocated_aids: set[int] = set()
self.accessory_types: dict[str, str] = {}
self._entry_id = entry_id
self.store: Store | None = None
self._entity_registry = er.async_get(hass)
async def async_initialize(self) -> None:
"""Load the latest AID data."""
aidstore = get_aid_storage_filename_for_entry_id(self._entry_id)
self.store = Store(self.hass, AID_MANAGER_STORAGE_VERSION, aidstore)
if not (raw_storage := await self.store.async_load()):
# There is no data about aid allocations yet
return
assert isinstance(raw_storage, dict)
self.allocations = raw_storage.get(ALLOCATIONS_KEY, {})
self.allocated_aids = set(self.allocations.values())
self.accessory_types = raw_storage.get(ACCESSORY_TYPES_KEY, {})
def _stable_storage_keys(self, entity_id: str) -> tuple[str, ...]:
"""Return the keys the entity's stable identity can resolve to.
The preferred key comes first, matching the aid allocation
preference for the system unique id over the entity id.
"""
if not (entry := self._entity_registry.async_get(entity_id)):
return (entity_id,)
keys = [get_system_unique_id(entry, entry.unique_id)]
if previous_unique_id := entry.previous_unique_id:
keys.append(get_system_unique_id(entry, previous_unique_id))
keys.append(entity_id)
return tuple(keys)
@callback
def async_set_accessory_type(
self, entity_id: str, accessory_type: str | None
) -> None:
"""Persist the accessory type an entity resolved to, None clears it.
The choice is stored by the same stable identity as the aid
allocation, so it survives entity id renames and unique id changes.
"""
if accessory_type == self.get_accessory_type(entity_id):
return
types = self.accessory_types
keys = self._stable_storage_keys(entity_id)
for key in keys:
types.pop(key, None)
if accessory_type is not None:
types[keys[0]] = accessory_type
self.async_schedule_save()
@callback
def get_accessory_type(self, entity_id: str) -> str | None:
"""Return the stored accessory type for the entity, if any.
A type found under an outdated identity moves to the current one
and schedules a save, since only the latest previous unique id
stays resolvable; the read is loop bound because of that.
"""
types = self.accessory_types
keys = self._stable_storage_keys(entity_id)
for key in keys:
if (accessory_type := types.get(key)) is not None:
if key != keys[0]:
del types[key]
types[keys[0]] = accessory_type
self.async_schedule_save()
return accessory_type
return None
def get_or_allocate_aid_for_entity_id(self, entity_id: str) -> int:
"""Generate a stable aid for an entity id."""
if not (entry := self._entity_registry.async_get(entity_id)):
return self.get_or_allocate_aid(None, entity_id)
sys_unique_id = get_system_unique_id(entry, entry.unique_id)
self._migrate_unique_id_aid_assignment_if_needed(sys_unique_id, entry)
return self.get_or_allocate_aid(sys_unique_id, entity_id)
def entity_is_allocated(self, entity_id: str) -> bool:
"""Return True when the entity already has an allocated aid.
Checks every key get_or_allocate_aid_for_entity_id could resolve to,
without allocating, so callers can tell a previously bridged entity
from a new one.
"""
return self.get_allocated_aid_for_entity_id(entity_id) is not None
def _migrate_unique_id_aid_assignment_if_needed(
self, sys_unique_id: str, entry: er.RegistryEntry
) -> None:
"""Migrate the unique id aid assignment if its changed."""
if sys_unique_id in self.allocations or not (
previous_unique_id := entry.previous_unique_id
):
return
old_sys_unique_id = get_system_unique_id(entry, previous_unique_id)
if aid := self.allocations.pop(old_sys_unique_id, None):
self.allocations[sys_unique_id] = aid
self.async_schedule_save()
def get_or_allocate_aid(self, unique_id: str | None, entity_id: str) -> int:
"""Allocate (and return) a new aid for an accessory."""
if unique_id and unique_id in self.allocations:
return self.allocations[unique_id]
if entity_id in self.allocations:
return self.allocations[entity_id]
for aid in _generate_aids(unique_id, entity_id):
if aid in INVALID_AIDS:
continue
if aid not in self.allocated_aids:
# Prefer the unique_id over the entitiy_id
storage_key = unique_id or entity_id
self.allocations[storage_key] = aid
self.allocated_aids.add(aid)
self.async_schedule_save()
return aid
raise ValueError(
f"Unable to generate unique aid allocation for {entity_id} [{unique_id}]"
)
def get_allocated_aid_for_entity_id(self, entity_id: str) -> int | None:
"""Return the entity's allocated aid without allocating one."""
allocations = self.allocations
return next(
(
allocations[key]
for key in self._stable_storage_keys(entity_id)
if key in allocations
),
None,
)
@callback
def async_delete_aid_for_entity_id(self, entity_id: str) -> None:
"""Remove the aid allocation for an entity."""
for key in self._stable_storage_keys(entity_id):
self.delete_aid(key)
def delete_aid(self, storage_key: str) -> None:
"""Delete an aid allocation."""
if storage_key not in self.allocations:
return
aid = self.allocations.pop(storage_key)
self.allocated_aids.discard(aid)
self.async_schedule_save()
@callback
def async_schedule_save(self) -> None:
"""Schedule saving the entity map cache."""
assert self.store is not None
self.store.async_delay_save(self._data_to_save, AID_MANAGER_SAVE_DELAY)
async def async_save(self) -> None:
"""Save the entity map cache."""
assert self.store is not None
return await self.store.async_save(self._data_to_save())
@callback
def _data_to_save(self) -> dict[str, dict[str, int] | dict[str, str]]:
"""Return data of entity map to store in a file."""
data: dict[str, dict[str, int] | dict[str, str]] = {
ALLOCATIONS_KEY: self.allocations
}
if self.accessory_types:
data[ACCESSORY_TYPES_KEY] = self.accessory_types
return data