From 3b33e0d832b238b40360383099391e2093ea05cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Oct 2022 09:58:09 -1000 Subject: [PATCH] Add support for restoring HomeKit IIDs (#79913) --- homeassistant/components/homekit/__init__.py | 64 +++++++++-- .../components/homekit/accessories.py | 38 ++++++- .../components/homekit/diagnostics.py | 2 +- .../components/homekit/iidmanager.py | 96 +++++++++++++++++ homeassistant/components/homekit/type_fans.py | 4 +- .../components/homekit/type_media_players.py | 16 ++- .../components/homekit/type_remotes.py | 2 +- .../components/homekit/type_switches.py | 2 +- .../components/homekit/type_triggers.py | 6 +- homeassistant/components/homekit/util.py | 29 +++-- tests/components/homekit/conftest.py | 63 +++++++++-- tests/components/homekit/test_accessories.py | 38 +++++-- tests/components/homekit/test_config_flow.py | 33 +++++- tests/components/homekit/test_homekit.py | 89 ++++++++++----- tests/components/homekit/test_iidmanager.py | 101 ++++++++++++++++++ tests/components/homekit/test_type_cameras.py | 16 --- tests/components/homekit/test_type_covers.py | 4 +- tests/components/homekit/test_type_fans.py | 2 +- .../homekit/test_type_media_players.py | 2 +- .../homekit/test_type_security_systems.py | 5 +- tests/components/homekit/test_type_sensors.py | 6 +- .../components/homekit/test_type_switches.py | 8 +- .../homekit/test_type_thermostats.py | 4 +- 23 files changed, 527 insertions(+), 103 deletions(-) create mode 100644 homeassistant/components/homekit/iidmanager.py create mode 100644 tests/components/homekit/test_iidmanager.py diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 36c7bac9d0a..b809f6db205 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -74,7 +74,13 @@ from . import ( # noqa: F401 type_switches, type_thermostats, ) -from .accessories import HomeAccessory, HomeBridge, HomeDriver, get_accessory +from .accessories import ( + HomeAccessory, + HomeBridge, + HomeDriver, + HomeIIDManager, + get_accessory, +) from .aidmanager import AccessoryAidStorage from .const import ( ATTR_INTEGRATION, @@ -107,6 +113,7 @@ from .const import ( SERVICE_HOMEKIT_UNPAIR, SHUTDOWN_TIMEOUT, ) +from .iidmanager import AccessoryIIDStorage from .type_triggers import DeviceTriggerAccessory from .util import ( accessory_friendly_name, @@ -489,8 +496,6 @@ def _async_register_events_and_services(hass: HomeAssistant) -> None: class HomeKit: """Class to handle all actions between HomeKit and Home Assistant.""" - driver: HomeDriver - def __init__( self, hass: HomeAssistant, @@ -520,12 +525,14 @@ class HomeKit: self._homekit_mode = homekit_mode self._devices = devices or [] self.aid_storage: AccessoryAidStorage | None = None + self.iid_storage: AccessoryIIDStorage | None = None self.status = STATUS_READY - + self.driver: HomeDriver | None = None self.bridge: HomeBridge | None = None def setup(self, async_zeroconf_instance: AsyncZeroconf, uuid: str) -> None: """Set up bridge and accessory driver.""" + assert self.iid_storage is not None persist_file = get_persist_fullpath_for_entry_id(self.hass, self._entry_id) self.driver = HomeDriver( @@ -541,6 +548,7 @@ class HomeKit: async_zeroconf_instance=async_zeroconf_instance, zeroconf_server=f"{uuid}-hap.local.", loader=get_loader(), + iid_manager=HomeIIDManager(self.iid_storage), ) # If we do not load the mac address will be wrong @@ -555,14 +563,27 @@ class HomeKit: return await self.async_reset_accessories_in_bridge_mode(entity_ids) + async def _async_shutdown_accessory(self, accessory: HomeAccessory) -> None: + """Shutdown an accessory.""" + assert self.driver is not None + await accessory.stop() + # Deallocate the IIDs for the accessory + iid_manager = self.driver.iid_manager + for service in accessory.services: + iid_manager.remove_iid(iid_manager.remove_obj(service)) + for char in service.characteristics: + iid_manager.remove_iid(iid_manager.remove_obj(char)) + async def async_reset_accessories_in_accessory_mode( self, entity_ids: Iterable[str] ) -> None: """Reset accessories in accessory mode.""" + assert self.driver is not None + acc = cast(HomeAccessory, self.driver.accessory) + await self._async_shutdown_accessory(acc) if acc.entity_id not in entity_ids: return - await acc.stop() if not (state := self.hass.states.get(acc.entity_id)): _LOGGER.warning( "The underlying entity %s disappeared during reset", acc.entity_id @@ -579,6 +600,8 @@ class HomeKit: """Reset accessories in bridge mode.""" assert self.aid_storage is not None assert self.bridge is not None + assert self.driver is not None + new = [] acc: HomeAccessory | None for entity_id in entity_ids: @@ -590,9 +613,10 @@ class HomeKit: self._name, entity_id, ) - if (acc := await self.async_remove_bridge_accessory(aid)) and ( - state := self.hass.states.get(acc.entity_id) - ): + acc = await self.async_remove_bridge_accessory(aid) + if acc: + await self._async_shutdown_accessory(acc) + if acc and (state := self.hass.states.get(acc.entity_id)): new.append(state) else: _LOGGER.warning( @@ -612,10 +636,13 @@ class HomeKit: async def async_config_changed(self) -> None: """Call config changed which writes out the new config to disk.""" + assert self.driver is not None await self.hass.async_add_executor_job(self.driver.config_changed) def add_bridge_accessory(self, state: State) -> HomeAccessory | None: """Try adding accessory to bridge if configured beforehand.""" + assert self.driver is not None + if self._would_exceed_max_devices(state.entity_id): return None @@ -694,7 +721,6 @@ class HomeKit: """Try adding accessory to bridge if configured beforehand.""" assert self.bridge is not None if acc := self.bridge.accessories.pop(aid, None): - await acc.stop() return cast(HomeAccessory, acc) return None @@ -741,9 +767,14 @@ class HomeKit: self.status = STATUS_WAIT async_zc_instance = await zeroconf.async_get_async_instance(self.hass) uuid = await instance_id.async_get(self.hass) - await self.hass.async_add_executor_job(self.setup, async_zc_instance, uuid) self.aid_storage = AccessoryAidStorage(self.hass, self._entry_id) + self.iid_storage = AccessoryIIDStorage(self.hass, self._entry_id) + # Avoid gather here since it will be I/O bound anyways await self.aid_storage.async_initialize() + await self.iid_storage.async_initialize() + await self.hass.async_add_executor_job(self.setup, async_zc_instance, uuid) + assert self.driver is not None + if not await self._async_create_accessories(): return self._async_register_bridge() @@ -760,6 +791,8 @@ class HomeKit: @callback def _async_show_setup_message(self) -> None: """Show the pairing setup message.""" + assert self.driver is not None + async_show_setup_message( self.hass, self._entry_id, @@ -771,6 +804,8 @@ class HomeKit: @callback def async_unpair(self) -> None: """Remove all pairings for an accessory so it can be repaired.""" + assert self.driver is not None + state = self.driver.state for client_uuid in list(state.paired_clients): # We need to check again since removing a single client @@ -842,6 +877,8 @@ class HomeKit: self, entity_states: list[State] ) -> HomeAccessory | None: """Create a single HomeKit accessory (accessory mode).""" + assert self.driver is not None + if not entity_states: _LOGGER.error( "HomeKit %s cannot startup: entity not available: %s", @@ -864,6 +901,8 @@ class HomeKit: self, entity_states: Iterable[State] ) -> HomeAccessory: """Create a HomeKit bridge with accessories. (bridge mode).""" + assert self.driver is not None + self.bridge = HomeBridge(self.hass, self.driver, self._name) for state in entity_states: self.add_bridge_accessory(state) @@ -892,6 +931,8 @@ class HomeKit: async def _async_create_accessories(self) -> bool: """Create the accessories.""" + assert self.driver is not None + entity_states = await self.async_configure_accessories() if self._homekit_mode == HOMEKIT_MODE_ACCESSORY: acc = self._async_create_single_accessory(entity_states) @@ -910,7 +951,8 @@ class HomeKit: return self.status = STATUS_STOPPED _LOGGER.debug("Driver stop for %s", self._name) - await self.driver.async_stop() + if self.driver: + await self.driver.async_stop() @callback def _async_configure_linked_sensors( diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 9d428573b5b..61c2e3cd5dd 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -7,7 +7,10 @@ from uuid import UUID from pyhap.accessory import Accessory, Bridge from pyhap.accessory_driver import AccessoryDriver +from pyhap.characteristic import Characteristic from pyhap.const import CATEGORY_OTHER +from pyhap.iid_manager import IIDManager +from pyhap.service import Service from pyhap.util import callback as pyhap_callback from homeassistant.components.cover import CoverDeviceClass, CoverEntityFeature @@ -83,6 +86,7 @@ from .const import ( TYPE_SWITCH, TYPE_VALVE, ) +from .iidmanager import AccessoryIIDStorage from .util import ( accessory_friendly_name, async_dismiss_setup_message, @@ -266,6 +270,7 @@ class HomeAccessory(Accessory): # type: ignore[misc] driver=driver, display_name=cleanup_name_for_homekit(name), aid=aid, + iid_manager=driver.iid_manager, *args, **kwargs, ) @@ -316,8 +321,8 @@ class HomeAccessory(Accessory): # type: ignore[misc] serv_info.configure_char( CHAR_HARDWARE_REVISION, value=hw_version[:MAX_VERSION_LENGTH] ) - self.iid_manager.assign(char) char.broker = self + self.iid_manager.assign(char) self.category = category self.entity_id = entity_id @@ -565,7 +570,7 @@ class HomeBridge(Bridge): # type: ignore[misc] def __init__(self, hass: HomeAssistant, driver: HomeDriver, name: str) -> None: """Initialize a Bridge object.""" - super().__init__(driver, name) + super().__init__(driver, name, iid_manager=driver.iid_manager) self.set_info_service( firmware_revision=format_version(__version__), manufacturer=MANUFACTURER, @@ -598,6 +603,7 @@ class HomeDriver(AccessoryDriver): # type: ignore[misc] entry_id: str, bridge_name: str, entry_title: str, + iid_manager: HomeIIDManager, **kwargs: Any, ) -> None: """Initialize a AccessoryDriver object.""" @@ -606,6 +612,7 @@ class HomeDriver(AccessoryDriver): # type: ignore[misc] self._entry_id = entry_id self._bridge_name = bridge_name self._entry_title = entry_title + self.iid_manager = iid_manager @pyhap_callback # type: ignore[misc] def pair( @@ -632,3 +639,30 @@ class HomeDriver(AccessoryDriver): # type: ignore[misc] self.state.pincode, self.accessory.xhm_uri(), ) + + +class HomeIIDManager(IIDManager): # type: ignore[misc] + """IID Manager that remembers IIDs between restarts.""" + + def __init__(self, iid_storage: AccessoryIIDStorage) -> None: + """Initialize a IIDManager object.""" + super().__init__() + self._iid_storage = iid_storage + + def get_iid_for_obj(self, obj: Characteristic | Service) -> int: + """Get IID for object.""" + aid = obj.broker.aid + if isinstance(obj, Characteristic): + service = obj.service + iid = self._iid_storage.get_or_allocate_iid( + aid, service.type_id, service.unique_id, obj.type_id, obj.unique_id + ) + else: + iid = self._iid_storage.get_or_allocate_iid( + aid, obj.type_id, obj.unique_id, None, None + ) + if iid in self.objs: + raise RuntimeError( + f"Cannot assign IID {iid} to {obj} as it is already in use by: {self.objs[iid]}" + ) + return iid diff --git a/homeassistant/components/homekit/diagnostics.py b/homeassistant/components/homekit/diagnostics.py index f717f02ea02..aadac9b4acc 100644 --- a/homeassistant/components/homekit/diagnostics.py +++ b/homeassistant/components/homekit/diagnostics.py @@ -27,7 +27,7 @@ async def async_get_config_entry_diagnostics( "options": dict(entry.options), }, } - if not hasattr(homekit, "driver"): + if not homekit.driver: # not started yet or startup failed return data driver: AccessoryDriver = homekit.driver data.update(driver.get_accessories()) diff --git a/homeassistant/components/homekit/iidmanager.py b/homeassistant/components/homekit/iidmanager.py new file mode 100644 index 00000000000..1b5cc7d6722 --- /dev/null +++ b/homeassistant/components/homekit/iidmanager.py @@ -0,0 +1,96 @@ +""" +Manage allocation of instance ID's. + +HomeKit needs to allocate unique numbers to each accessory. These need to +be stable between reboots and upgrades. + +This module generates and stores them in a HA storage. +""" +from __future__ import annotations + +from uuid import UUID + +from pyhap.util import uuid_to_hap_type + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.storage import Store + +from .util import get_iid_storage_filename_for_entry_id + +IID_MANAGER_STORAGE_VERSION = 1 +IID_MANAGER_SAVE_DELAY = 2 + +ALLOCATIONS_KEY = "allocations" + +IID_MIN = 1 +IID_MAX = 18446744073709551615 + + +class AccessoryIIDStorage: + """ + Provide stable allocation of IIDs for the lifetime of an accessory. + + 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 iid store.""" + self.hass = hass + self.allocations: dict[str, int] = {} + self.allocated_iids: list[int] = [] + self.entry_id = entry_id + self.store: Store | None = None + + async def async_initialize(self) -> None: + """Load the latest IID data.""" + iid_store = get_iid_storage_filename_for_entry_id(self.entry_id) + self.store = Store(self.hass, IID_MANAGER_STORAGE_VERSION, iid_store) + + if not (raw_storage := await self.store.async_load()): + # There is no data about iid allocations yet + return + + assert isinstance(raw_storage, dict) + self.allocations = raw_storage.get(ALLOCATIONS_KEY, {}) + self.allocated_iids = sorted(self.allocations.values()) + + def get_or_allocate_iid( + self, + aid: int, + service_uuid: UUID, + service_unique_id: str | None, + char_uuid: UUID | None, + char_unique_id: str | None, + ) -> int: + """Generate a stable iid.""" + service_hap_type: str = uuid_to_hap_type(service_uuid) + char_hap_type: str | None = uuid_to_hap_type(char_uuid) if char_uuid else None + # Allocation key must be a string since we are saving it to JSON + allocation_key = ( + f'{aid}_{service_hap_type}_{service_unique_id or ""}_' + f'{char_hap_type or ""}_{char_unique_id or ""}' + ) + if allocation_key in self.allocations: + return self.allocations[allocation_key] + next_iid = self.allocated_iids[-1] + 1 if self.allocated_iids else 1 + self.allocations[allocation_key] = next_iid + self.allocated_iids.append(next_iid) + self._async_schedule_save() + return next_iid + + @callback + def _async_schedule_save(self) -> None: + """Schedule saving the iid allocations.""" + assert self.store is not None + self.store.async_delay_save(self._data_to_save, IID_MANAGER_SAVE_DELAY) + + async def async_save(self) -> None: + """Save the iid allocations.""" + 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]]: + """Return data of entity map to store in a file.""" + return {ALLOCATIONS_KEY: self.allocations} diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py index ecc73f5e731..e3116c99e26 100644 --- a/homeassistant/components/homekit/type_fans.py +++ b/homeassistant/components/homekit/type_fans.py @@ -105,7 +105,9 @@ class Fan(HomeAccessory): ) elif self.preset_modes: for preset_mode in self.preset_modes: - preset_serv = self.add_preload_service(SERV_SWITCH, CHAR_NAME) + preset_serv = self.add_preload_service( + SERV_SWITCH, CHAR_NAME, unique_id=preset_mode + ) serv_fan.add_linked_service(preset_serv) preset_serv.configure_char( CHAR_NAME, diff --git a/homeassistant/components/homekit/type_media_players.py b/homeassistant/components/homekit/type_media_players.py index b26016b8adc..55519fdf6f7 100644 --- a/homeassistant/components/homekit/type_media_players.py +++ b/homeassistant/components/homekit/type_media_players.py @@ -96,7 +96,9 @@ class MediaPlayer(HomeAccessory): if FEATURE_ON_OFF in feature_list: name = self.generate_service_name(FEATURE_ON_OFF) - serv_on_off = self.add_preload_service(SERV_SWITCH, CHAR_NAME) + serv_on_off = self.add_preload_service( + SERV_SWITCH, CHAR_NAME, unique_id=FEATURE_ON_OFF + ) serv_on_off.configure_char(CHAR_NAME, value=name) self.chars[FEATURE_ON_OFF] = serv_on_off.configure_char( CHAR_ON, value=False, setter_callback=self.set_on_off @@ -104,7 +106,9 @@ class MediaPlayer(HomeAccessory): if FEATURE_PLAY_PAUSE in feature_list: name = self.generate_service_name(FEATURE_PLAY_PAUSE) - serv_play_pause = self.add_preload_service(SERV_SWITCH, CHAR_NAME) + serv_play_pause = self.add_preload_service( + SERV_SWITCH, CHAR_NAME, unique_id=FEATURE_PLAY_PAUSE + ) serv_play_pause.configure_char(CHAR_NAME, value=name) self.chars[FEATURE_PLAY_PAUSE] = serv_play_pause.configure_char( CHAR_ON, value=False, setter_callback=self.set_play_pause @@ -112,7 +116,9 @@ class MediaPlayer(HomeAccessory): if FEATURE_PLAY_STOP in feature_list: name = self.generate_service_name(FEATURE_PLAY_STOP) - serv_play_stop = self.add_preload_service(SERV_SWITCH, CHAR_NAME) + serv_play_stop = self.add_preload_service( + SERV_SWITCH, CHAR_NAME, unique_id=FEATURE_PLAY_STOP + ) serv_play_stop.configure_char(CHAR_NAME, value=name) self.chars[FEATURE_PLAY_STOP] = serv_play_stop.configure_char( CHAR_ON, value=False, setter_callback=self.set_play_stop @@ -120,7 +126,9 @@ class MediaPlayer(HomeAccessory): if FEATURE_TOGGLE_MUTE in feature_list: name = self.generate_service_name(FEATURE_TOGGLE_MUTE) - serv_toggle_mute = self.add_preload_service(SERV_SWITCH, CHAR_NAME) + serv_toggle_mute = self.add_preload_service( + SERV_SWITCH, CHAR_NAME, unique_id=FEATURE_TOGGLE_MUTE + ) serv_toggle_mute.configure_char(CHAR_NAME, value=name) self.chars[FEATURE_TOGGLE_MUTE] = serv_toggle_mute.configure_char( CHAR_ON, value=False, setter_callback=self.set_toggle_mute diff --git a/homeassistant/components/homekit/type_remotes.py b/homeassistant/components/homekit/type_remotes.py index aa064cfc012..fb808eff8b0 100644 --- a/homeassistant/components/homekit/type_remotes.py +++ b/homeassistant/components/homekit/type_remotes.py @@ -131,7 +131,7 @@ class RemoteInputSelectAccessory(HomeAccessory): ) for index, source in enumerate(self.sources): serv_input = self.add_preload_service( - SERV_INPUT_SOURCE, [CHAR_IDENTIFIER, CHAR_NAME] + SERV_INPUT_SOURCE, [CHAR_IDENTIFIER, CHAR_NAME], unique_id=source ) serv_tv.add_linked_service(serv_input) serv_input.configure_char(CHAR_CONFIGURED_NAME, value=source) diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index 1598df015c5..f79185c64b1 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -259,7 +259,7 @@ class SelectSwitch(HomeAccessory): options = state.attributes[ATTR_OPTIONS] for option in options: serv_option = self.add_preload_service( - SERV_OUTLET, [CHAR_NAME, CHAR_IN_USE] + SERV_OUTLET, [CHAR_NAME, CHAR_IN_USE], unique_id=option ) serv_option.configure_char( CHAR_NAME, value=cleanup_name_for_homekit(option) diff --git a/homeassistant/components/homekit/type_triggers.py b/homeassistant/components/homekit/type_triggers.py index 776fe6f3110..b9b2ad6ce8f 100644 --- a/homeassistant/components/homekit/type_triggers.py +++ b/homeassistant/components/homekit/type_triggers.py @@ -42,12 +42,14 @@ class DeviceTriggerAccessory(HomeAccessory): for idx, trigger in enumerate(device_triggers): type_ = trigger["type"] subtype = trigger.get("subtype") + unique_id = f'{type_}-{subtype or ""}' trigger_name = ( f"{type_.title()} {subtype.title()}" if subtype else type_.title() ) serv_stateless_switch = self.add_preload_service( SERV_STATELESS_PROGRAMMABLE_SWITCH, [CHAR_NAME, CHAR_SERVICE_LABEL_INDEX], + unique_id=unique_id, ) self.triggers.append( serv_stateless_switch.configure_char( @@ -60,7 +62,9 @@ class DeviceTriggerAccessory(HomeAccessory): serv_stateless_switch.configure_char( CHAR_SERVICE_LABEL_INDEX, value=idx + 1 ) - serv_service_label = self.add_preload_service(SERV_SERVICE_LABEL) + serv_service_label = self.add_preload_service( + SERV_SERVICE_LABEL, unique_id=unique_id + ) serv_service_label.configure_char(CHAR_SERVICE_LABEL_NAMESPACE, value=1) serv_stateless_switch.add_linked_service(serv_service_label) diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 445b73cccbe..ee02ea1a576 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -431,10 +431,15 @@ def get_persist_filename_for_entry_id(entry_id: str) -> str: def get_aid_storage_filename_for_entry_id(entry_id: str) -> str: - """Determine the ilename of homekit aid storage file.""" + """Determine the filename of homekit aid storage file.""" return f"{DOMAIN}.{entry_id}.aids" +def get_iid_storage_filename_for_entry_id(entry_id: str) -> str: + """Determine the filename of homekit iid storage file.""" + return f"{DOMAIN}.{entry_id}.iids" + + def get_persist_fullpath_for_entry_id(hass: HomeAssistant, entry_id: str) -> str: """Determine the path to the homekit state file.""" return hass.config.path(STORAGE_DIR, get_persist_filename_for_entry_id(entry_id)) @@ -447,6 +452,13 @@ def get_aid_storage_fullpath_for_entry_id(hass: HomeAssistant, entry_id: str) -> ) +def get_iid_storage_fullpath_for_entry_id(hass: HomeAssistant, entry_id: str) -> str: + """Determine the path to the homekit iid storage file.""" + return hass.config.path( + STORAGE_DIR, get_iid_storage_filename_for_entry_id(entry_id) + ) + + def _format_version_part(version_part: str) -> str: return str(max(0, min(MAX_VERSION_PART, coerce_int(version_part)))) @@ -466,14 +478,15 @@ def _is_zero_but_true(value: Any) -> bool: return convert_to_float(value) == 0 -def remove_state_files_for_entry_id(hass: HomeAssistant, entry_id: str) -> bool: +def remove_state_files_for_entry_id(hass: HomeAssistant, entry_id: str) -> None: """Remove the state files from disk.""" - persist_file_path = get_persist_fullpath_for_entry_id(hass, entry_id) - aid_storage_path = get_aid_storage_fullpath_for_entry_id(hass, entry_id) - os.unlink(persist_file_path) - if os.path.exists(aid_storage_path): - os.unlink(aid_storage_path) - return True + for path in ( + get_persist_fullpath_for_entry_id(hass, entry_id), + get_aid_storage_fullpath_for_entry_id(hass, entry_id), + get_iid_storage_fullpath_for_entry_id(hass, entry_id), + ): + if os.path.exists(path): + os.unlink(path) def _get_test_socket() -> socket.socket: diff --git a/tests/components/homekit/conftest.py b/tests/components/homekit/conftest.py index 5e2acbcd9db..7b79e0f9b6b 100644 --- a/tests/components/homekit/conftest.py +++ b/tests/components/homekit/conftest.py @@ -3,17 +3,50 @@ from contextlib import suppress import os from unittest.mock import patch -from pyhap.accessory_driver import AccessoryDriver import pytest from homeassistant.components.device_tracker.legacy import YAML_DEVICES -from homeassistant.components.homekit.const import EVENT_HOMEKIT_CHANGED +from homeassistant.components.homekit.accessories import HomeDriver, HomeIIDManager +from homeassistant.components.homekit.const import BRIDGE_NAME, EVENT_HOMEKIT_CHANGED +from homeassistant.components.homekit.iidmanager import AccessoryIIDStorage from tests.common import async_capture_events, mock_device_registry, mock_registry @pytest.fixture -def hk_driver(loop): +def iid_storage(hass): + """Mock the iid storage.""" + with patch.object(AccessoryIIDStorage, "_async_schedule_save"): + yield AccessoryIIDStorage(hass, "") + + +@pytest.fixture() +def run_driver(hass, loop, iid_storage): + """Return a custom AccessoryDriver instance for HomeKit accessory init. + + This mock does not mock async_stop, so the driver will not be stopped + """ + with patch("pyhap.accessory_driver.AsyncZeroconf"), patch( + "pyhap.accessory_driver.AccessoryEncoder" + ), patch("pyhap.accessory_driver.HAPServer"), patch( + "pyhap.accessory_driver.AccessoryDriver.publish" + ), patch( + "pyhap.accessory_driver.AccessoryDriver.persist" + ): + yield HomeDriver( + hass, + pincode=b"123-45-678", + entry_id="", + entry_title="mock entry", + bridge_name=BRIDGE_NAME, + iid_manager=HomeIIDManager(iid_storage), + address="127.0.0.1", + loop=loop, + ) + + +@pytest.fixture +def hk_driver(hass, loop, iid_storage): """Return a custom AccessoryDriver instance for HomeKit accessory init.""" with patch("pyhap.accessory_driver.AsyncZeroconf"), patch( "pyhap.accessory_driver.AccessoryEncoder" @@ -24,11 +57,20 @@ def hk_driver(loop): ), patch( "pyhap.accessory_driver.AccessoryDriver.persist" ): - yield AccessoryDriver(pincode=b"123-45-678", address="127.0.0.1", loop=loop) + yield HomeDriver( + hass, + pincode=b"123-45-678", + entry_id="", + entry_title="mock entry", + bridge_name=BRIDGE_NAME, + iid_manager=HomeIIDManager(iid_storage), + address="127.0.0.1", + loop=loop, + ) @pytest.fixture -def mock_hap(loop, mock_zeroconf): +def mock_hap(hass, loop, iid_storage, mock_zeroconf): """Return a custom AccessoryDriver instance for HomeKit accessory init.""" with patch("pyhap.accessory_driver.AsyncZeroconf"), patch( "pyhap.accessory_driver.AccessoryEncoder" @@ -43,7 +85,16 @@ def mock_hap(loop, mock_zeroconf): ), patch( "pyhap.accessory_driver.AccessoryDriver.persist" ): - yield AccessoryDriver(pincode=b"123-45-678", address="127.0.0.1", loop=loop) + yield HomeDriver( + hass, + pincode=b"123-45-678", + entry_id="", + entry_title="mock entry", + bridge_name=BRIDGE_NAME, + iid_manager=HomeIIDManager(iid_storage), + address="127.0.0.1", + loop=loop, + ) @pytest.fixture diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index 6d7de6eb696..2a0f3f2f718 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -10,6 +10,7 @@ from homeassistant.components.homekit.accessories import ( HomeAccessory, HomeBridge, HomeDriver, + HomeIIDManager, ) from homeassistant.components.homekit.const import ( ATTR_DISPLAY_NAME, @@ -107,7 +108,7 @@ async def test_home_accessory(hass, hk_driver): hk_driver, "Home Accessory that exceeds the maximum maximum maximum maximum maximum maximum length", entity_id2, - 3, + 4, { ATTR_MODEL: "Awesome Model that exceeds the maximum maximum maximum maximum maximum maximum length", ATTR_MANUFACTURER: "Lux Brands that exceeds the maximum maximum maximum maximum maximum maximum length", @@ -140,7 +141,7 @@ async def test_home_accessory(hass, hk_driver): hk_driver, "Home Accessory that exceeds the maximum maximum maximum maximum maximum maximum length", entity_id2, - 3, + 5, { ATTR_MODEL: "Awesome Model that exceeds the maximum maximum maximum maximum maximum maximum length", ATTR_MANUFACTURER: "Lux Brands that exceeds the maximum maximum maximum maximum maximum maximum length", @@ -191,7 +192,7 @@ async def test_home_accessory(hass, hk_driver): entity_id = "test_model.demo" hass.states.async_set(entity_id, None) await hass.async_block_till_done() - acc = HomeAccessory(hass, hk_driver, "test_name", entity_id, 2, None) + acc = HomeAccessory(hass, hk_driver, "test_name", entity_id, 6, None) serv = acc.services[0] # SERV_ACCESSORY_INFO assert serv.get_characteristic(CHAR_MODEL).value == "Test Model" @@ -317,7 +318,7 @@ async def test_battery_service(hass, hk_driver, caplog): with patch( "homeassistant.components.homekit.accessories.HomeAccessory.async_update_state" ): - acc = HomeAccessory(hass, hk_driver, "Battery Service", entity_id, 2, None) + acc = HomeAccessory(hass, hk_driver, "Battery Service", entity_id, 3, None) assert acc._char_battery.value == 0 assert acc._char_low_battery.value == 0 assert acc._char_charging.value == 2 @@ -405,7 +406,7 @@ async def test_linked_battery_sensor(hass, hk_driver, caplog): hk_driver, "Battery Service", entity_id, - 2, + 3, {CONF_LINKED_BATTERY_SENSOR: linked_battery, CONF_LOW_BATTERY_THRESHOLD: 50}, ) with patch( @@ -700,16 +701,17 @@ def test_home_bridge(hk_driver): assert serv.get_characteristic(CHAR_MODEL).value == BRIDGE_MODEL assert serv.get_characteristic(CHAR_SERIAL_NUMBER).value == BRIDGE_SERIAL_NUMBER + +def test_home_bridge_setup_message(hk_driver): + """Test HomeBridge setup message.""" bridge = HomeBridge("hass", hk_driver, "test_name") assert bridge.display_name == "test_name" assert len(bridge.services) == 2 - serv = bridge.services[0] # SERV_ACCESSORY_INFO - # setup_message bridge.setup_message() -def test_home_driver(): +def test_home_driver(iid_storage): """Test HomeDriver class.""" ip_address = "127.0.0.1" port = 51826 @@ -722,6 +724,7 @@ def test_home_driver(): "entry_id", "name", "title", + iid_manager=HomeIIDManager(iid_storage), address=ip_address, port=port, persist_file=path, @@ -749,3 +752,22 @@ def test_home_driver(): mock_unpair.assert_called_with("client_uuid") mock_show_msg.assert_called_with("hass", "entry_id", "title (any)", pin, "X-HM://0") + + +async def test_iid_collision_raises(hass, hk_driver): + """Test iid collision raises. + + If we try to allocate the same IID to the an accessory twice, we should + raise an exception. + """ + + entity_id = "light.accessory" + entity_id2 = "light.accessory2" + + hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set(entity_id2, STATE_OFF) + + HomeAccessory(hass, hk_driver, "Home Accessory", entity_id, 2, {}) + + with pytest.raises(RuntimeError): + HomeAccessory(hass, hk_driver, "Home Accessory", entity_id2, 2, {}) diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index 1b2a0b4e211..144a97853c5 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -387,8 +387,9 @@ async def test_options_flow_exclude_mode_basic(hass, mock_get_source_ip): } +@patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True) async def test_options_flow_devices( - mock_hap, + port_mock, hass, demo_cleanup, device_reg, @@ -473,9 +474,13 @@ async def test_options_flow_devices( }, } + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) + +@patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True) async def test_options_flow_devices_preserved_when_advanced_off( - mock_hap, hass, mock_get_source_ip, mock_async_zeroconf + port_mock, hass, mock_get_source_ip, mock_async_zeroconf ): """Test devices are preserved if they were added in advanced mode but it was turned off.""" config_entry = MockConfigEntry( @@ -542,6 +547,8 @@ async def test_options_flow_devices_preserved_when_advanced_off( "include_entities": [], }, } + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) async def test_options_flow_include_mode_with_non_existant_entity( @@ -600,6 +607,8 @@ async def test_options_flow_include_mode_with_non_existant_entity( "include_entities": ["climate.new", "climate.front_gate"], }, } + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) async def test_options_flow_exclude_mode_with_non_existant_entity( @@ -659,6 +668,8 @@ async def test_options_flow_exclude_mode_with_non_existant_entity( "include_entities": [], }, } + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) async def test_options_flow_include_mode_basic(hass, mock_get_source_ip): @@ -704,6 +715,7 @@ async def test_options_flow_include_mode_basic(hass, mock_get_source_ip): "include_entities": ["climate.new"], }, } + await hass.config_entries.async_unload(config_entry.entry_id) async def test_options_flow_exclude_mode_with_cameras(hass, mock_get_source_ip): @@ -809,6 +821,8 @@ async def test_options_flow_exclude_mode_with_cameras(hass, mock_get_source_ip): }, "entity_config": {"camera.native_h264": {"video_codec": "copy"}}, } + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) async def test_options_flow_include_mode_with_cameras(hass, mock_get_source_ip): @@ -941,6 +955,8 @@ async def test_options_flow_include_mode_with_cameras(hass, mock_get_source_ip): }, "mode": "bridge", } + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) async def test_options_flow_with_camera_audio(hass, mock_get_source_ip): @@ -1073,6 +1089,8 @@ async def test_options_flow_with_camera_audio(hass, mock_get_source_ip): }, "mode": "bridge", } + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) async def test_options_flow_blocked_when_from_yaml(hass, mock_get_source_ip): @@ -1112,6 +1130,7 @@ async def test_options_flow_blocked_when_from_yaml(hass, mock_get_source_ip): user_input={}, ) assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + await hass.config_entries.async_unload(config_entry.entry_id) @patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True) @@ -1211,6 +1230,8 @@ async def test_options_flow_include_mode_basic_accessory( "include_entities": ["media_player.tv"], }, } + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) async def test_converting_bridge_to_accessory_mode(hass, hk_driver, mock_get_source_ip): @@ -1317,6 +1338,8 @@ async def test_converting_bridge_to_accessory_mode(hass, hk_driver, mock_get_sou }, } assert len(mock_setup_entry.mock_calls) == 1 + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) def _get_schema_default(schema, key_name): @@ -1423,6 +1446,8 @@ async def test_options_flow_exclude_mode_skips_category_entities( "include_entities": [], }, } + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) @patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True) @@ -1501,6 +1526,8 @@ async def test_options_flow_exclude_mode_skips_hidden_entities( "include_entities": [], }, } + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) @patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True) @@ -1583,3 +1610,5 @@ async def test_options_flow_include_mode_allows_hidden_entities( ], }, } + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index dbb63ba690a..21dc94a4b54 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio -import os from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch from pyhap.accessory import Accessory @@ -60,7 +59,6 @@ from homeassistant.helpers.entityfilter import ( convert_filter, ) from homeassistant.setup import async_setup_component -from homeassistant.util import json as json_util from .util import PATH_HOMEKIT, async_init_entry, async_init_integration @@ -122,6 +120,7 @@ def _mock_homekit(hass, entry, homekit_mode, entity_filter=None, devices=None): def _mock_homekit_bridge(hass, entry): homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) homekit.driver = MagicMock() + homekit.iid_storage = MagicMock() return homekit @@ -177,6 +176,49 @@ async def test_setup_min(hass, mock_async_zeroconf): assert mock_homekit().async_start.called is True +@patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True) +async def test_removing_entry(port_mock, hass, mock_async_zeroconf): + """Test removing a config entry.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_NAME: BRIDGE_NAME, CONF_PORT: DEFAULT_PORT}, + options={}, + ) + entry.add_to_hass(hass) + + with patch(f"{PATH_HOMEKIT}.HomeKit") as mock_homekit, patch( + "homeassistant.components.network.async_get_source_ip", return_value="1.2.3.4" + ): + mock_homekit.return_value = homekit = Mock() + type(homekit).async_start = AsyncMock() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + mock_homekit.assert_any_call( + hass, + BRIDGE_NAME, + DEFAULT_PORT, + "1.2.3.4", + ANY, + ANY, + {}, + HOMEKIT_MODE_BRIDGE, + None, + entry.entry_id, + entry.title, + devices=[], + ) + + # Test auto start enabled + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + assert mock_homekit().async_start.called is True + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + async def test_homekit_setup(hass, hk_driver, mock_async_zeroconf): """Test setup of bridge and driver.""" entry = MockConfigEntry( @@ -203,6 +245,7 @@ async def test_homekit_setup(hass, hk_driver, mock_async_zeroconf): zeroconf_mock = MagicMock() uuid = await instance_id.async_get(hass) with patch(f"{PATH_HOMEKIT}.HomeDriver", return_value=hk_driver) as mock_driver: + homekit.iid_storage = MagicMock() await hass.async_add_executor_job(homekit.setup, zeroconf_mock, uuid) path = get_persist_fullpath_for_entry_id(hass, entry.entry_id) @@ -219,6 +262,7 @@ async def test_homekit_setup(hass, hk_driver, mock_async_zeroconf): async_zeroconf_instance=zeroconf_mock, zeroconf_server=f"{uuid}-hap.local.", loader=ANY, + iid_manager=ANY, ) assert homekit.driver.safe_mode is False @@ -247,6 +291,7 @@ async def test_homekit_setup_ip_address(hass, hk_driver, mock_async_zeroconf): path = get_persist_fullpath_for_entry_id(hass, entry.entry_id) uuid = await instance_id.async_get(hass) with patch(f"{PATH_HOMEKIT}.HomeDriver", return_value=hk_driver) as mock_driver: + homekit.iid_storage = MagicMock() await hass.async_add_executor_job(homekit.setup, mock_async_zeroconf, uuid) mock_driver.assert_called_with( hass, @@ -261,6 +306,7 @@ async def test_homekit_setup_ip_address(hass, hk_driver, mock_async_zeroconf): async_zeroconf_instance=mock_async_zeroconf, zeroconf_server=f"{uuid}-hap.local.", loader=ANY, + iid_manager=ANY, ) @@ -289,6 +335,7 @@ async def test_homekit_setup_advertise_ip(hass, hk_driver, mock_async_zeroconf): path = get_persist_fullpath_for_entry_id(hass, entry.entry_id) uuid = await instance_id.async_get(hass) with patch(f"{PATH_HOMEKIT}.HomeDriver", return_value=hk_driver) as mock_driver: + homekit.iid_storage = MagicMock() await hass.async_add_executor_job(homekit.setup, async_zeroconf_instance, uuid) mock_driver.assert_called_with( hass, @@ -303,10 +350,11 @@ async def test_homekit_setup_advertise_ip(hass, hk_driver, mock_async_zeroconf): async_zeroconf_instance=async_zeroconf_instance, zeroconf_server=f"{uuid}-hap.local.", loader=ANY, + iid_manager=ANY, ) -async def test_homekit_add_accessory(hass, mock_async_zeroconf): +async def test_homekit_add_accessory(hass, mock_async_zeroconf, mock_hap): """Add accessory if config exists and get_acc returns an accessory.""" entry = MockConfigEntry( @@ -340,10 +388,12 @@ async def test_homekit_add_accessory(hass, mock_async_zeroconf): mock_get_acc.assert_called_with(hass, ANY, ANY, 1467253281, {}) assert homekit.bridge.add_accessory.called + await homekit.async_stop() + @pytest.mark.parametrize("acc_category", [CATEGORY_TELEVISION, CATEGORY_CAMERA]) async def test_homekit_warn_add_accessory_bridge( - hass, acc_category, mock_async_zeroconf, caplog + hass, acc_category, mock_async_zeroconf, mock_hap, caplog ): """Test we warn when adding cameras or tvs to a bridge.""" @@ -367,6 +417,7 @@ async def test_homekit_warn_add_accessory_bridge( homekit.add_bridge_accessory(state) mock_get_acc.assert_called_with(hass, ANY, ANY, 1508819236, {}) assert not homekit.bridge.add_accessory.called + await homekit.async_stop() assert "accessory mode" in caplog.text @@ -385,7 +436,6 @@ async def test_homekit_remove_accessory(hass, mock_async_zeroconf): acc = await homekit.async_remove_bridge_accessory(6) assert acc is acc_mock - assert acc_mock.stop.called assert len(homekit.bridge.accessories) == 0 @@ -722,7 +772,7 @@ async def test_homekit_stop(hass): assert homekit.driver.async_stop.called is True -async def test_homekit_reset_accessories(hass, mock_async_zeroconf): +async def test_homekit_reset_accessories(hass, mock_async_zeroconf, mock_hap): """Test resetting HomeKit accessories.""" entry = MockConfigEntry( @@ -736,20 +786,15 @@ async def test_homekit_reset_accessories(hass, mock_async_zeroconf): "pyhap.accessory.Bridge.add_accessory" ) as mock_add_accessory, patch( "pyhap.accessory_driver.AccessoryDriver.config_changed" - ) as hk_driver_config_changed, patch( + ), patch( "pyhap.accessory_driver.AccessoryDriver.async_start" ), patch( f"{PATH_HOMEKIT}.accessories.HomeAccessory.run" - ) as mock_run, patch.object( + ), patch.object( homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0 ): await async_init_entry(hass, entry) - acc_mock = MagicMock() - acc_mock.entity_id = entity_id - acc_mock.stop = AsyncMock() - aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) - homekit.bridge.accessories = {aid: acc_mock} homekit.status = STATUS_RUNNING homekit.driver.aio_stop_event = MagicMock() @@ -761,10 +806,9 @@ async def test_homekit_reset_accessories(hass, mock_async_zeroconf): ) await hass.async_block_till_done() - assert hk_driver_config_changed.call_count == 2 assert mock_add_accessory.called - assert mock_run.called homekit.status = STATUS_READY + await homekit.async_stop() async def test_homekit_unpair(hass, device_reg, mock_async_zeroconf): @@ -1028,7 +1072,7 @@ async def test_homekit_reset_accessories_not_bridged(hass, mock_async_zeroconf): homekit.status = STATUS_STOPPED -async def test_homekit_reset_single_accessory(hass, mock_async_zeroconf): +async def test_homekit_reset_single_accessory(hass, mock_hap, mock_async_zeroconf): """Test resetting HomeKit single accessory.""" entry = MockConfigEntry( @@ -1046,13 +1090,7 @@ async def test_homekit_reset_single_accessory(hass, mock_async_zeroconf): f"{PATH_HOMEKIT}.accessories.HomeAccessory.run" ) as mock_run: await async_init_entry(hass, entry) - homekit.status = STATUS_RUNNING - acc_mock = MagicMock() - acc_mock.entity_id = entity_id - acc_mock.stop = AsyncMock() - - homekit.driver.accessory = acc_mock homekit.driver.aio_stop_event = MagicMock() await hass.services.async_call( @@ -1065,6 +1103,7 @@ async def test_homekit_reset_single_accessory(hass, mock_async_zeroconf): assert mock_run.called assert hk_driver_config_changed.call_count == 1 homekit.status = STATUS_READY + await homekit.async_stop() async def test_homekit_reset_single_accessory_unsupported(hass, mock_async_zeroconf): @@ -1494,12 +1533,6 @@ async def test_homekit_uses_system_zeroconf(hass, hk_driver, mock_async_zeroconf await hass.async_block_till_done() -def _write_data(path: str, data: dict) -> None: - """Write the data.""" - os.makedirs(os.path.dirname(path), exist_ok=True) - json_util.save_json(path, data) - - async def test_homekit_ignored_missing_devices( hass, hk_driver, device_reg, entity_reg, mock_async_zeroconf ): diff --git a/tests/components/homekit/test_iidmanager.py b/tests/components/homekit/test_iidmanager.py new file mode 100644 index 00000000000..a791c30a341 --- /dev/null +++ b/tests/components/homekit/test_iidmanager.py @@ -0,0 +1,101 @@ +"""Tests for the HomeKit IID manager.""" + + +from uuid import UUID + +from homeassistant.components.homekit.const import DOMAIN +from homeassistant.components.homekit.iidmanager import ( + AccessoryIIDStorage, + get_iid_storage_filename_for_entry_id, +) +from homeassistant.util.uuid import random_uuid_hex + +from tests.common import MockConfigEntry + + +async def test_iid_generation_and_restore(hass, iid_storage, hass_storage): + """Test generating iids and restoring them from storage.""" + entry = MockConfigEntry(domain=DOMAIN) + + iid_storage = AccessoryIIDStorage(hass, entry.entry_id) + await iid_storage.async_initialize() + + random_service_uuid = UUID(random_uuid_hex()) + random_characteristic_uuid = UUID(random_uuid_hex()) + + iid1 = iid_storage.get_or_allocate_iid( + 1, random_service_uuid, None, random_characteristic_uuid, None + ) + iid2 = iid_storage.get_or_allocate_iid( + 1, random_service_uuid, None, random_characteristic_uuid, None + ) + assert iid1 == iid2 + + service_only_iid1 = iid_storage.get_or_allocate_iid( + 1, random_service_uuid, None, None, None + ) + service_only_iid2 = iid_storage.get_or_allocate_iid( + 1, random_service_uuid, None, None, None + ) + assert service_only_iid1 == service_only_iid2 + assert service_only_iid1 != iid1 + + service_only_iid_with_unique_id1 = iid_storage.get_or_allocate_iid( + 1, random_service_uuid, "any", None, None + ) + service_only_iid_with_unique_id2 = iid_storage.get_or_allocate_iid( + 1, random_service_uuid, "any", None, None + ) + assert service_only_iid_with_unique_id1 == service_only_iid_with_unique_id2 + assert service_only_iid_with_unique_id1 != service_only_iid1 + + unique_char_iid1 = iid_storage.get_or_allocate_iid( + 1, random_service_uuid, None, random_characteristic_uuid, "any" + ) + unique_char_iid2 = iid_storage.get_or_allocate_iid( + 1, random_service_uuid, None, random_characteristic_uuid, "any" + ) + assert unique_char_iid1 == unique_char_iid2 + assert unique_char_iid1 != iid1 + + unique_service_unique_char_iid1 = iid_storage.get_or_allocate_iid( + 1, random_service_uuid, "any", random_characteristic_uuid, "any" + ) + unique_service_unique_char_iid2 = iid_storage.get_or_allocate_iid( + 1, random_service_uuid, "any", random_characteristic_uuid, "any" + ) + assert unique_service_unique_char_iid1 == unique_service_unique_char_iid2 + assert unique_service_unique_char_iid1 != iid1 + + unique_service_unique_char_new_aid_iid1 = iid_storage.get_or_allocate_iid( + 2, random_service_uuid, "any", random_characteristic_uuid, "any" + ) + unique_service_unique_char_new_aid_iid2 = iid_storage.get_or_allocate_iid( + 2, random_service_uuid, "any", random_characteristic_uuid, "any" + ) + assert ( + unique_service_unique_char_new_aid_iid1 + == unique_service_unique_char_new_aid_iid2 + ) + assert unique_service_unique_char_new_aid_iid1 != iid1 + assert unique_service_unique_char_new_aid_iid1 != unique_service_unique_char_iid1 + + await iid_storage.async_save() + + iid_storage2 = AccessoryIIDStorage(hass, entry.entry_id) + await iid_storage2.async_initialize() + iid3 = iid_storage2.get_or_allocate_iid( + 1, random_service_uuid, None, random_characteristic_uuid, None + ) + assert iid3 == iid1 + + +async def test_iid_storage_filename(hass, iid_storage, hass_storage): + """Test iid storage uses the expected filename.""" + entry = MockConfigEntry(domain=DOMAIN) + + iid_storage = AccessoryIIDStorage(hass, entry.entry_id) + await iid_storage.async_initialize() + assert iid_storage.store.path.endswith( + get_iid_storage_filename_for_entry_id(entry.entry_id) + ) diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index f6855ca3cbb..80a4f3c4e88 100644 --- a/tests/components/homekit/test_type_cameras.py +++ b/tests/components/homekit/test_type_cameras.py @@ -4,7 +4,6 @@ import asyncio from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch from uuid import UUID -from pyhap.accessory_driver import AccessoryDriver import pytest from homeassistant.components import camera, ffmpeg @@ -78,21 +77,6 @@ async def _async_stop_stream(hass, acc, session_info): await hass.async_block_till_done() -@pytest.fixture() -def run_driver(hass): - """Return a custom AccessoryDriver instance for HomeKit accessory init.""" - with patch("pyhap.accessory_driver.AsyncZeroconf"), patch( - "pyhap.accessory_driver.AccessoryEncoder" - ), patch("pyhap.accessory_driver.HAPServer"), patch( - "pyhap.accessory_driver.AccessoryDriver.publish" - ), patch( - "pyhap.accessory_driver.AccessoryDriver.persist" - ): - yield AccessoryDriver( - pincode=b"123-45-678", address="127.0.0.1", loop=hass.loop - ) - - def _mock_reader(): """Mock ffmpeg reader.""" diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index bc512a4b162..5d261886248 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -573,7 +573,7 @@ async def test_windowcovering_basic_restore(hass, hk_driver, events): assert acc.char_target_position is not None assert acc.char_position_state is not None - acc = WindowCoveringBasic(hass, hk_driver, "Cover", "cover.all_info_set", 2, None) + acc = WindowCoveringBasic(hass, hk_driver, "Cover", "cover.all_info_set", 3, None) assert acc.category == 14 assert acc.char_current_position is not None assert acc.char_target_position is not None @@ -611,7 +611,7 @@ async def test_windowcovering_restore(hass, hk_driver, events): assert acc.char_target_position is not None assert acc.char_position_state is not None - acc = WindowCovering(hass, hk_driver, "Cover", "cover.all_info_set", 2, None) + acc = WindowCovering(hass, hk_driver, "Cover", "cover.all_info_set", 3, None) assert acc.category == 14 assert acc.char_current_position is not None assert acc.char_target_position is not None diff --git a/tests/components/homekit/test_type_fans.py b/tests/components/homekit/test_type_fans.py index fbcf6a00421..9b5f8286d8b 100644 --- a/tests/components/homekit/test_type_fans.py +++ b/tests/components/homekit/test_type_fans.py @@ -561,7 +561,7 @@ async def test_fan_restore(hass, hk_driver, events): assert acc.char_speed is None assert acc.char_swing is None - acc = Fan(hass, hk_driver, "Fan", "fan.all_info_set", 2, None) + acc = Fan(hass, hk_driver, "Fan", "fan.all_info_set", 3, None) assert acc.category == 3 assert acc.char_active is not None assert acc.char_direction is not None diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py index dbdc2b0ba55..30b9bc77f5d 100644 --- a/tests/components/homekit/test_type_media_players.py +++ b/tests/components/homekit/test_type_media_players.py @@ -442,7 +442,7 @@ async def test_tv_restore(hass, hk_driver, events): assert not hasattr(acc, "char_input_source") acc = TelevisionMediaPlayer( - hass, hk_driver, "MediaPlayer", "media_player.all_info_set", 2, None + hass, hk_driver, "MediaPlayer", "media_player.all_info_set", 3, None ) assert acc.category == 31 assert acc.chars_tv == [CHAR_REMOTE_KEY] diff --git a/tests/components/homekit/test_type_security_systems.py b/tests/components/homekit/test_type_security_systems.py index 64f1d82d123..920bf6d8a31 100644 --- a/tests/components/homekit/test_type_security_systems.py +++ b/tests/components/homekit/test_type_security_systems.py @@ -282,13 +282,16 @@ async def test_supported_states(hass, hk_driver, events): }, ] + aid = 1 + for test_config in test_configs: attrs = {"supported_features": test_config.get("features")} hass.states.async_set(entity_id, None, attributes=attrs) await hass.async_block_till_done() - acc = SecuritySystem(hass, hk_driver, "SecuritySystem", entity_id, 2, config) + aid += 1 + acc = SecuritySystem(hass, hk_driver, "SecuritySystem", entity_id, aid, config) await acc.run() await hass.async_block_till_done() diff --git a/tests/components/homekit/test_type_sensors.py b/tests/components/homekit/test_type_sensors.py index b916d447d12..4997a35910d 100644 --- a/tests/components/homekit/test_type_sensors.py +++ b/tests/components/homekit/test_type_sensors.py @@ -423,12 +423,14 @@ async def test_motion_uses_bool(hass, hk_driver): async def test_binary_device_classes(hass, hk_driver): """Test if services and characteristics are assigned correctly.""" entity_id = "binary_sensor.demo" + aid = 1 for device_class, (service, char, _) in BINARY_SENSOR_SERVICE_MAP.items(): hass.states.async_set(entity_id, STATE_OFF, {ATTR_DEVICE_CLASS: device_class}) await hass.async_block_till_done() - acc = BinarySensor(hass, hk_driver, "Binary Sensor", entity_id, 2, None) + aid += 1 + acc = BinarySensor(hass, hk_driver, "Binary Sensor", entity_id, aid, None) assert acc.get_service(service).display_name == service assert acc.char_detected.display_name == char @@ -460,7 +462,7 @@ async def test_sensor_restore(hass, hk_driver, events): acc = get_accessory(hass, hk_driver, hass.states.get("sensor.temperature"), 2, {}) assert acc.category == 10 - acc = get_accessory(hass, hk_driver, hass.states.get("sensor.humidity"), 2, {}) + acc = get_accessory(hass, hk_driver, hass.states.get("sensor.humidity"), 3, {}) assert acc.category == 10 diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index cc80201ae33..0d6f8f0d586 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -150,23 +150,23 @@ async def test_valve_set_state(hass, hk_driver, events): assert acc.category == 29 # Faucet assert acc.char_valve_type.value == 3 # Water faucet - acc = Valve(hass, hk_driver, "Valve", entity_id, 2, {CONF_TYPE: TYPE_SHOWER}) + acc = Valve(hass, hk_driver, "Valve", entity_id, 3, {CONF_TYPE: TYPE_SHOWER}) await acc.run() await hass.async_block_till_done() assert acc.category == 30 # Shower assert acc.char_valve_type.value == 2 # Shower head - acc = Valve(hass, hk_driver, "Valve", entity_id, 2, {CONF_TYPE: TYPE_SPRINKLER}) + acc = Valve(hass, hk_driver, "Valve", entity_id, 4, {CONF_TYPE: TYPE_SPRINKLER}) await acc.run() await hass.async_block_till_done() assert acc.category == 28 # Sprinkler assert acc.char_valve_type.value == 1 # Irrigation - acc = Valve(hass, hk_driver, "Valve", entity_id, 2, {CONF_TYPE: TYPE_VALVE}) + acc = Valve(hass, hk_driver, "Valve", entity_id, 5, {CONF_TYPE: TYPE_VALVE}) await acc.run() await hass.async_block_till_done() - assert acc.aid == 2 + assert acc.aid == 5 assert acc.category == 29 # Faucet assert acc.char_active.value == 0 diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index a964568cc60..33b45e54081 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -1045,7 +1045,7 @@ async def test_thermostat_restore(hass, hk_driver, events): "off", } - acc = Thermostat(hass, hk_driver, "Climate", "climate.all_info_set", 2, None) + acc = Thermostat(hass, hk_driver, "Climate", "climate.all_info_set", 3, None) assert acc.category == 9 assert acc.get_temperature_range() == (60.0, 70.0) assert set(acc.char_target_heat_cool.properties["ValidValues"].keys()) == { @@ -1859,7 +1859,7 @@ async def test_water_heater_restore(hass, hk_driver, events): } acc = WaterHeater( - hass, hk_driver, "WaterHeater", "water_heater.all_info_set", 2, None + hass, hk_driver, "WaterHeater", "water_heater.all_info_set", 3, None ) assert acc.category == 9 assert acc.get_temperature_range() == (60.0, 70.0)