From 8041177e286cded4a11633436853c7016e613b6f Mon Sep 17 00:00:00 2001 From: Christian Lackas Date: Fri, 4 Sep 2026 12:47:23 +0200 Subject: [PATCH 1/2] Set up platforms only after the first refresh has data The platform setups read coordinator.data.device_type to decide which entities exist. Since #21 the first refresh runs in a background task, so the data is still None when the platforms are forwarded and binary_sensor, select and sensor abort with AttributeError. Forward the platforms from that background task once the first refresh has produced data, retrying while the device is unreachable. --- custom_components/blanco_unit/__init__.py | 25 ++++--- tests/test_init.py | 82 +++++++++++++++++++++-- 2 files changed, 92 insertions(+), 15 deletions(-) diff --git a/custom_components/blanco_unit/__init__.py b/custom_components/blanco_unit/__init__.py index 73cd008..20d4228 100644 --- a/custom_components/blanco_unit/__init__.py +++ b/custom_components/blanco_unit/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import logging from bleak.backends.device import BLEDevice @@ -39,6 +40,8 @@ _LOGGER = logging.getLogger(__name__) +FIRST_REFRESH_RETRY_SECONDS = 60 + PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.BUTTON, @@ -264,18 +267,22 @@ async def async_setup_entry( ) config_entry.runtime_data = coordinator - # Set up the entities right away and load the first data set in the - # background. Connecting to the Blanco unit over BLE can take a long time - # or fail transiently, and blocking setup on that first refresh stalls - # Home Assistant startup. Entities report as "unavailable" until the first - # refresh succeeds; the coordinator retries on its update interval and - # whenever the device is rediscovered over Bluetooth. - await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + # Connecting to the Blanco unit over BLE can take a long time or fail + # transiently, so the first refresh must not block Home Assistant startup. + # The platforms read the device type to decide which entities exist, so + # they are forwarded from a background task once data has arrived. + async def _setup_platforms_when_ready() -> None: + while True: + await coordinator.async_refresh() + if coordinator.data is not None: + break + await asyncio.sleep(FIRST_REFRESH_RETRY_SECONDS) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) config_entry.async_create_background_task( hass, - coordinator.async_refresh(), - f"{DOMAIN} initial refresh {config_entry.entry_id}", + _setup_platforms_when_ready(), + f"{DOMAIN} initial setup {config_entry.entry_id}", ) return True diff --git a/tests/test_init.py b/tests/test_init.py index d230fab..f0df88c 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -5,6 +5,7 @@ import pytest from custom_components.blanco_unit import ( + FIRST_REFRESH_RETRY_SECONDS, _find_device_by_scanning, _is_random_mac, _register_retry_callback, @@ -77,11 +78,13 @@ async def test_async_setup_entry_success(hass: HomeAssistant) -> None: mock_entry.data = {CONF_MAC: "AA:BB:CC:DD:EE:FF"} mock_entry.add_update_listener = MagicMock(return_value=MagicMock()) - def _run_bg_task(_hass, coro, _name): - coro.close() + bg_tasks: list = [] + + def _capture_bg_task(_hass, coro, _name): + bg_tasks.append(coro) return MagicMock() - mock_entry.async_create_background_task = MagicMock(side_effect=_run_bg_task) + mock_entry.async_create_background_task = MagicMock(side_effect=_capture_bg_task) with ( patch( @@ -98,9 +101,15 @@ def _run_bg_task(_hass, coro, _name): assert result is True assert mock_entry.runtime_data == mock_coordinator - # The first refresh runs in the background so it cannot block startup. - mock_coordinator.async_refresh.assert_called_once() + # The first refresh runs in the background so it cannot block startup, + # and the platforms are only set up once it has produced data. mock_entry.async_create_background_task.assert_called_once() + mock_coordinator.async_refresh.assert_not_called() + mock_forward.assert_not_called() + + await bg_tasks[0] + + mock_coordinator.async_refresh.assert_called_once() mock_forward.assert_called_once() # Verify platforms are registered @@ -113,6 +122,56 @@ def _run_bg_task(_hass, coro, _name): assert Platform.SENSOR in call_args[1] +async def test_async_setup_entry_retries_until_data(hass: HomeAssistant) -> None: + """Test platforms are only set up once the first refresh returns data.""" + mock_device = MagicMock() + mock_device.address = "AA:BB:CC:DD:EE:FF" + + mock_coordinator = MagicMock() + mock_coordinator.data = None + + async def _refresh() -> None: + if mock_coordinator.async_refresh.await_count >= 2: + mock_coordinator.data = MagicMock() + + mock_coordinator.async_refresh = AsyncMock(side_effect=_refresh) + + mock_entry = MagicMock(spec=ConfigEntry) + mock_entry.entry_id = "test_entry_id" + mock_entry.data = {CONF_MAC: "AA:BB:CC:DD:EE:FF"} + mock_entry.add_update_listener = MagicMock(return_value=MagicMock()) + + bg_tasks: list = [] + + def _capture_bg_task(_hass, coro, _name): + bg_tasks.append(coro) + return MagicMock() + + mock_entry.async_create_background_task = MagicMock(side_effect=_capture_bg_task) + + with ( + patch( + "custom_components.blanco_unit.bluetooth.async_ble_device_from_address", + return_value=mock_device, + ), + patch( + "custom_components.blanco_unit.BlancoUnitCoordinator", + return_value=mock_coordinator, + ), + patch( + "custom_components.blanco_unit.asyncio.sleep", AsyncMock() + ) as mock_sleep, + patch.object(hass.config_entries, "async_forward_entry_setups") as mock_forward, + ): + assert await async_setup_entry(hass, mock_entry) is True + + await bg_tasks[0] + + assert mock_coordinator.async_refresh.await_count == 2 + mock_sleep.assert_awaited_once_with(FIRST_REFRESH_RETRY_SECONDS) + mock_forward.assert_called_once() + + async def test_async_setup_entry_device_not_found_first_time( hass: HomeAssistant, ) -> None: @@ -721,12 +780,20 @@ async def test_async_setup_entry_random_mac_success(hass: HomeAssistant) -> None mock_device.address = "11:22:33:44:55:66" mock_coordinator = MagicMock() - mock_coordinator.async_config_entry_first_refresh = AsyncMock() + mock_coordinator.async_refresh = AsyncMock() mock_entry = _make_random_mac_entry() mock_entry.title = "Test Blanco" mock_entry.add_update_listener = MagicMock(return_value=MagicMock()) + bg_tasks: list = [] + + def _capture_bg_task(_hass, coro, _name): + bg_tasks.append(coro) + return MagicMock() + + mock_entry.async_create_background_task = MagicMock(side_effect=_capture_bg_task) + with ( patch( "custom_components.blanco_unit._find_device_by_scanning", @@ -742,6 +809,9 @@ async def test_async_setup_entry_random_mac_success(hass: HomeAssistant) -> None assert result is True assert mock_entry.runtime_data == mock_coordinator + + await bg_tasks[0] + mock_forward.assert_called_once() From 5bb33b86ad02c3d9c727d19b39436e2514de4aa9 Mon Sep 17 00:00:00 2001 From: Christian Lackas Date: Fri, 4 Sep 2026 12:57:36 +0200 Subject: [PATCH 2/2] Wait for background tasks when setting up the integration in tests --- tests/conftest.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 832ff0f..eeb6e60 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -34,7 +34,9 @@ async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + # The platforms are set up from a background task once the first refresh + # has data, so waiting for the foreground tasks alone is not enough. + await hass.async_block_till_done(wait_background_tasks=True) # Create fixtures for common test data