diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 8d3e2ff1547a..7dd004c2af83 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -166,6 +166,8 @@ class RuntimeEntryData: ) loaded_platforms: set[Platform] = field(default_factory=set) platform_load_lock: asyncio.Lock = field(default_factory=asyncio.Lock) + # Set once the first connection has finished scanner setup or teardown. + first_connect_done: asyncio.Event = field(default_factory=asyncio.Event) _storage_contents: StoreData | None = None _pending_storage: Callable[[], StoreData] | None = None assist_pipeline_update_callbacks: list[CALLBACK_TYPE] = field(default_factory=list) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 2a6d38471722..3eeaed77be5e 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -1,11 +1,12 @@ """Manager for esphome devices.""" +import asyncio import base64 from functools import partial import logging import secrets import struct -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import TYPE_CHECKING, Any, Final, NamedTuple from aioesphomeapi import ( APIClient, @@ -106,6 +107,9 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) +# Max time to wait at startup for a BLE proxy to register its scanner. +STARTUP_SCANNER_WAIT: Final = 3.0 + LOG_LEVEL_TO_LOGGER = { LogLevel.LOG_LEVEL_NONE: logging.DEBUG, LogLevel.LOG_LEVEL_ERROR: logging.ERROR, @@ -677,6 +681,8 @@ class ESPHomeManager: hass, device_info.bluetooth_mac_address or device_info.mac_address ) + entry_data.first_connect_done.set() + if device_info.voice_assistant_feature_flags_compat(api_version) and ( Platform.ASSIST_SATELLITE not in entry_data.loaded_platforms ): @@ -988,6 +994,21 @@ class ESPHomeManager: await reconnect_logic.start() + # Wait for a cached BLE proxy to register its scanner before finishing setup. + if ( + device_info := entry_data.device_info + ) is not None and device_info.bluetooth_proxy_feature_flags_compat( + entry_data.api_version + ): + try: + async with asyncio.timeout(STARTUP_SCANNER_WAIT): + await entry_data.first_connect_done.wait() + except TimeoutError: + _LOGGER.debug( + "%s: Timed out waiting for Bluetooth scanner to register", + self.host, + ) + @callback def _async_setup_device_registry( diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 89a42e9aadf9..96ffcb52d3b7 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -9,7 +9,9 @@ from unittest.mock import AsyncMock, Mock, call, patch from aioesphomeapi import ( APIClient, APIConnectionError, + APIVersion, AreaInfo, + BluetoothProxyFeature, DeviceInfo, EncryptionPlaintextAPIError, ExecuteServiceResponse, @@ -3274,3 +3276,124 @@ async def test_service_registration_response_types( hass.services.supports_response(DOMAIN, "test_status_service") == SupportsResponse.NONE ) + + +def _create_cached_bluetooth_proxy_entry( + hass: HomeAssistant, + hass_storage: dict[str, Any], + bluetooth_proxy_feature_flags: BluetoothProxyFeature, +) -> tuple[MockConfigEntry, DeviceInfo]: + """Create an entry with cached device info so setup knows the proxy state.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="11:22:33:44:55:aa", + data={ + CONF_HOST: "test.local", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_BLUETOOTH_MAC_ADDRESS: "AA:BB:CC:DD:EE:FC", + }, + ) + entry.add_to_hass(hass) + device_info = DeviceInfo( + name="test", + mac_address="11:22:33:44:55:AA", + bluetooth_mac_address="AA:BB:CC:DD:EE:FC", + bluetooth_proxy_feature_flags=bluetooth_proxy_feature_flags, + ) + storage_key = f"{DOMAIN}.{entry.entry_id}" + hass_storage[storage_key] = { + "version": 1, + "minor_version": 1, + "key": storage_key, + "data": { + "device_info": device_info.to_dict(), + "api_version": APIVersion(1, 9).to_dict(), + }, + } + return entry, device_info + + +@pytest.mark.usefixtures("mock_zeroconf") +async def test_bluetooth_proxy_waits_for_scanner_at_startup( + hass: HomeAssistant, + mock_client: APIClient, + hass_storage: dict[str, Any], +) -> None: + """Test setup waits for a cached bluetooth proxy to register its scanner.""" + entry, device_info = _create_cached_bluetooth_proxy_entry( + hass, hass_storage, BluetoothProxyFeature.PASSIVE_SCAN + ) + connect_event = asyncio.Event() + reached_connect = asyncio.Event() + + async def _block_until_released() -> tuple[DeviceInfo, list[Any], list[Any]]: + reached_connect.set() + await connect_event.wait() + return (device_info, [], []) + + mock_client.device_info_and_list_entities = _block_until_released + + setup_task = hass.async_create_task(hass.config_entries.async_setup(entry.entry_id)) + async with asyncio.timeout(2): + await reached_connect.wait() + + # Setup must still be waiting for the scanner to be registered. + assert not setup_task.done() + + connect_event.set() + async with asyncio.timeout(2): + assert await setup_task is True + assert entry.runtime_data.first_connect_done.is_set() + + +@pytest.mark.usefixtures("mock_zeroconf") +async def test_bluetooth_proxy_startup_wait_times_out( + hass: HomeAssistant, + mock_client: APIClient, + hass_storage: dict[str, Any], +) -> None: + """Test setup finishes if a cached bluetooth proxy never connects.""" + entry, device_info = _create_cached_bluetooth_proxy_entry( + hass, hass_storage, BluetoothProxyFeature.PASSIVE_SCAN + ) + connect_event = asyncio.Event() + + async def _never_returns() -> tuple[DeviceInfo, list[Any], list[Any]]: + await connect_event.wait() + return (device_info, [], []) + + mock_client.device_info_and_list_entities = _never_returns + + with patch("homeassistant.components.esphome.manager.STARTUP_SCANNER_WAIT", 0.05): + async with asyncio.timeout(2): + assert await hass.config_entries.async_setup(entry.entry_id) is True + + connect_event.set() + await hass.async_block_till_done() + + +@pytest.mark.usefixtures("mock_zeroconf") +async def test_non_bluetooth_device_does_not_wait_at_startup( + hass: HomeAssistant, + mock_client: APIClient, + hass_storage: dict[str, Any], +) -> None: + """Test setup does not wait for a device that is not a bluetooth proxy.""" + entry, device_info = _create_cached_bluetooth_proxy_entry( + hass, hass_storage, BluetoothProxyFeature(0) + ) + connect_event = asyncio.Event() + + async def _never_returns() -> tuple[DeviceInfo, list[Any], list[Any]]: + await connect_event.wait() + return (device_info, [], []) + + mock_client.device_info_and_list_entities = _never_returns + + # The connection is blocked, but without proxy flags setup must not wait. + async with asyncio.timeout(2): + assert await hass.config_entries.async_setup(entry.entry_id) is True + + connect_event.set() + await hass.async_block_till_done()