mirror of
https://github.com/home-assistant/core.git
synced 2026-08-14 09:13:06 +01:00
Co-authored-by: J. Nick Koston <nick@home-assistant.io> Co-authored-by: J. Nick Koston <nick@koston.org>
243 lines
8.9 KiB
Python
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
|