diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 67da7a1068f..bc911b991ca 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -227,6 +227,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not await conn.async_setup(): del hass.data[KNOWN_DEVICES][conn.unique_id] + if (connection := getattr(conn.pairing, "connection")) and hasattr( + connection, "host" + ): + raise ConfigEntryNotReady( + f"Cannot connect to {connection.host}:{connection.port}" + ) raise ConfigEntryNotReady return True diff --git a/homeassistant/components/homekit_controller/camera.py b/homeassistant/components/homekit_controller/camera.py index 0ffa0a22f4d..0f0dd4f9050 100644 --- a/homeassistant/components/homekit_controller/camera.py +++ b/homeassistant/components/homekit_controller/camera.py @@ -25,7 +25,7 @@ class HomeKitCamera(AccessoryEntity, Camera): self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a jpeg with the current camera snapshot.""" - return await self._accessory.pairing.image( + return await self._accessory.pairing.image( # type: ignore[attr-defined] self._aid, width or 640, height or 480, diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index 493dd05a8b2..ac840eb0689 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -17,6 +17,7 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import device_registry as dr +from .connection import HKDevice from .const import DOMAIN, KNOWN_DEVICES from .utils import async_get_controller @@ -240,17 +241,19 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self.hass.config_entries.async_update_entry( existing_entry, data={**existing_entry.data, **updated_ip_port} ) - conn = self.hass.data[KNOWN_DEVICES][hkid] + conn: HKDevice = self.hass.data[KNOWN_DEVICES][hkid] # When we rediscover the device, let aiohomekit know # that the device is available and we should not wait # to retry connecting any longer. reconnect_soon # will do nothing if the device is already connected await conn.pairing.reconnect_soon() - if conn.config_num != config_num: + if config_num and conn.config_num != config_num: _LOGGER.debug( "HomeKit info %s: c# incremented, refreshing entities", hkid ) - self.hass.async_create_task(conn.async_refresh_entity_map(config_num)) + self.hass.async_create_task( + conn.async_refresh_entity_map_and_entities(config_num) + ) return self.async_abort(reason="already_configured") _LOGGER.debug("Discovered device %s (%s - %s)", name, model, hkid) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 7a85e234807..35f4c6bdb31 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -7,6 +7,7 @@ import datetime import logging from typing import Any +from aiohomekit import Controller from aiohomekit.exceptions import ( AccessoryDisconnectedError, AccessoryNotFoundError, @@ -35,6 +36,7 @@ from .const import ( IDENTIFIER_SERIAL_NUMBER, ) from .device_trigger import async_fire_triggers, async_setup_triggers_for_entry +from .storage import EntityMapStorage DEFAULT_SCAN_INTERVAL = datetime.timedelta(seconds=60) RETRY_INTERVAL = 60 # seconds @@ -70,11 +72,13 @@ class HKDevice: # don't want to mutate a dict owned by a config entry. self.pairing_data = pairing_data.copy() - self.pairing = hass.data[CONTROLLER].load_pairing( + connection: Controller = hass.data[CONTROLLER] + + self.pairing = connection.load_pairing( self.pairing_data["AccessoryPairingID"], self.pairing_data ) - self.accessories = None + self.accessories: list[Any] | None = None self.config_num = 0 self.entity_map = Accessories() @@ -167,26 +171,23 @@ class HKDevice: async def async_setup(self) -> bool: """Prepare to use a paired HomeKit device in Home Assistant.""" - cache = self.hass.data[ENTITY_MAP].get_map(self.unique_id) - if not cache: - if await self.async_refresh_entity_map(self.config_num): - self._polling_interval_remover = async_track_time_interval( - self.hass, self.async_update, DEFAULT_SCAN_INTERVAL - ) - return True + entity_storage: EntityMapStorage = self.hass.data[ENTITY_MAP] + if cache := entity_storage.get_map(self.unique_id): + self.accessories = cache["accessories"] + self.config_num = cache["config_num"] + self.entity_map = Accessories.from_list(self.accessories) + elif not await self.async_refresh_entity_map(self.config_num): return False - self.accessories = cache["accessories"] - self.config_num = cache["config_num"] - - self.entity_map = Accessories.from_list(self.accessories) - + await self.async_process_entity_map() + if not self.pairing.is_connected: + return False + # If everything is up to date, we can create the entities + # since we know the data is not stale. + self.add_entities() self._polling_interval_remover = async_track_time_interval( self.hass, self.async_update, DEFAULT_SCAN_INTERVAL ) - - self.hass.async_create_task(self.async_process_entity_map()) - return True def device_info_for_accessory(self, accessory: Accessory) -> DeviceInfo: @@ -361,7 +362,7 @@ class HKDevice: # is especially important for BLE, as the Pairing instance relies on the entity map # to map aid/iid to GATT characteristics. So push it to there as well. - self.pairing.pairing_data["accessories"] = self.accessories + self.pairing.pairing_data["accessories"] = self.accessories # type: ignore[attr-defined] self.async_detect_workarounds() @@ -375,8 +376,6 @@ class HKDevice: # Load any triggers for this config entry await async_setup_triggers_for_entry(self.hass, self.config_entry) - self.add_entities() - if self.watchable_characteristics: await self.pairing.subscribe(self.watchable_characteristics) if not self.pairing.is_connected: @@ -395,6 +394,12 @@ class HKDevice: self.config_entry, self.platforms ) + async def async_refresh_entity_map_and_entities(self, config_num: int) -> None: + """Refresh the entity map and entities for this pairing.""" + await self.async_refresh_entity_map(config_num) + await self.async_process_entity_map() + self.add_entities() + async def async_refresh_entity_map(self, config_num: int) -> bool: """Handle setup of a HomeKit accessory.""" try: @@ -404,15 +409,17 @@ class HKDevice: # later when Bonjour spots c# is still not up to date. return False + assert self.accessories is not None + self.entity_map = Accessories.from_list(self.accessories) - self.hass.data[ENTITY_MAP].async_create_or_update_map( + entity_storage: EntityMapStorage = self.hass.data[ENTITY_MAP] + + entity_storage.async_create_or_update_map( self.unique_id, config_num, self.accessories ) self.config_num = config_num - self.hass.async_create_task(self.async_process_entity_map()) - return True def add_accessory_factory(self, add_entities_cb) -> None: diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index 4cad585d135..e773b2ffc66 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -186,6 +186,13 @@ async def setup_platform(hass): async def setup_test_accessories(hass, accessories): """Load a fake homekit device based on captured JSON profile.""" fake_controller = await setup_platform(hass) + return await setup_test_accessories_with_controller( + hass, accessories, fake_controller + ) + + +async def setup_test_accessories_with_controller(hass, accessories, fake_controller): + """Load a fake homekit device based on captured JSON profile.""" pairing_id = "00:00:00:00:00:00" diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py index 7b62c1e9d6d..9535b7d0cd5 100644 --- a/tests/components/homekit_controller/test_config_flow.py +++ b/tests/components/homekit_controller/test_config_flow.py @@ -515,7 +515,7 @@ async def test_discovery_already_configured_update_csharp(hass, controller): assert entry.data["AccessoryIP"] == discovery_info.host assert entry.data["AccessoryPort"] == discovery_info.port - assert connection_mock.async_refresh_entity_map.await_count == 1 + assert connection_mock.async_refresh_entity_map_and_entities.await_count == 1 @pytest.mark.parametrize("exception,expected", PAIRING_START_ABORT_ERRORS) diff --git a/tests/components/homekit_controller/test_init.py b/tests/components/homekit_controller/test_init.py index 84a6c8b86bf..5b4d5c74ff6 100644 --- a/tests/components/homekit_controller/test_init.py +++ b/tests/components/homekit_controller/test_init.py @@ -1,19 +1,26 @@ """Tests for homekit_controller init.""" +from datetime import timedelta from unittest.mock import patch +from aiohomekit import exceptions +from aiohomekit.model import Accessory from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes +from aiohomekit.testing import FakeController, FakeDiscovery, FakePairing -from homeassistant.components.homekit_controller.const import ENTITY_MAP +from homeassistant.components.homekit_controller.const import DOMAIN, ENTITY_MAP +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow -from .common import Helper, remove_device +from .common import Helper, remove_device, setup_test_accessories_with_controller +from tests.common import async_fire_time_changed from tests.components.homekit_controller.common import setup_test_component ALIVE_DEVICE_NAME = "testdevice" @@ -89,3 +96,76 @@ async def test_device_remove_devices(hass, hass_ws_client): await remove_device(await hass_ws_client(hass), dead_device_entry.id, entry_id) is True ) + + +async def test_offline_device_raises(hass): + """Test an offline device raises ConfigEntryNotReady.""" + + is_connected = False + + class OfflineFakePairing(FakePairing): + """Fake pairing that always returns False for is_connected.""" + + @property + def is_connected(self): + nonlocal is_connected + return is_connected + + class OfflineFakeDiscovery(FakeDiscovery): + """Fake discovery that returns an offline pairing.""" + + async def start_pairing(self, alias: str): + if self.description.id in self.controller.pairings: + raise exceptions.AlreadyPairedError( + f"{self.description.id} already paired" + ) + + async def finish_pairing(pairing_code): + if pairing_code != self.pairing_code: + raise exceptions.AuthenticationError("M4") + pairing_data = {} + pairing_data["AccessoryIP"] = self.info["address"] + pairing_data["AccessoryPort"] = self.info["port"] + pairing_data["Connection"] = "IP" + + obj = self.controller.pairings[alias] = OfflineFakePairing( + self.controller, pairing_data, self.accessories + ) + return obj + + return finish_pairing + + class OfflineFakeController(FakeController): + """Fake controller that always returns a discovery with a pairing that always returns False for is_connected.""" + + def add_device(self, accessories): + device_id = "00:00:00:00:00:00" + discovery = self.discoveries[device_id] = OfflineFakeDiscovery( + self, + device_id, + accessories=accessories, + ) + return discovery + + with patch( + "homeassistant.components.homekit_controller.utils.Controller" + ) as controller: + fake_controller = controller.return_value = OfflineFakeController() + await async_setup_component(hass, DOMAIN, {}) + + accessory = Accessory.create_with_info( + "TestDevice", "example.com", "Test", "0001", "0.1" + ) + create_alive_service(accessory) + + config_entry, _ = await setup_test_accessories_with_controller( + hass, [accessory], fake_controller + ) + + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + is_connected = True + + async_fire_time_changed(hass, utcnow() + timedelta(seconds=10)) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.LOADED