Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 16 additions & 9 deletions custom_components/blanco_unit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import asyncio
import logging

from bleak.backends.device import BLEDevice
Expand Down Expand Up @@ -39,6 +40,8 @@

_LOGGER = logging.getLogger(__name__)

FIRST_REFRESH_RETRY_SECONDS = 60

PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 76 additions & 6 deletions tests/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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",
Expand All @@ -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()


Expand Down
Loading