mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 10:13:52 -05:00
Wait for ESPHome bluetooth proxy connection at startup (#173164)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user