diff --git a/.coverage b/.coverage index 3b3afea..d272a91 100644 Binary files a/.coverage and b/.coverage differ diff --git a/custom_components/blanco_unit/__init__.py b/custom_components/blanco_unit/__init__.py index 36c2e26..73cd008 100644 --- a/custom_components/blanco_unit/__init__.py +++ b/custom_components/blanco_unit/__init__.py @@ -20,7 +20,6 @@ from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryNotReady, - HomeAssistantError, IntegrationError, ) @@ -265,32 +264,20 @@ async def async_setup_entry( ) config_entry.runtime_data = coordinator - try: - await coordinator.async_config_entry_first_refresh() - except ConfigEntryAuthFailed as err: - # do not reload if setup failed - _LOGGER.debug("async_setup_entry ConfigEntryAuthFailed %s", str(err)) - unsub_update_listener() - raise err from err - except HomeAssistantError as err: - _LOGGER.debug("async_setup_entry HomeAssistantError %s", str(err)) - # do not reload if setup failed - unsub_update_listener() - raise ConfigEntryNotReady( - translation_key=err.translation_key, - translation_placeholders=err.translation_placeholders, - ) from err - except Exception as err: - _LOGGER.debug("async_setup_entry Exception %s", str(err)) - # do not reload if setup failed - unsub_update_listener() - raise ConfigEntryNotReady( - translation_key="error_unknown", - translation_placeholders={"error": repr(err)}, - ) from err - + # 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) + config_entry.async_create_background_task( + hass, + coordinator.async_refresh(), + f"{DOMAIN} initial refresh {config_entry.entry_id}", + ) + return True diff --git a/tests/test_init.py b/tests/test_init.py index 01c662b..d230fab 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -33,7 +33,6 @@ from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryNotReady, - HomeAssistantError, IntegrationError, ) @@ -71,13 +70,19 @@ async def test_async_setup_entry_success(hass: HomeAssistant) -> None: mock_device.address = "AA:BB:CC:DD:EE:FF" mock_coordinator = MagicMock() - mock_coordinator.async_config_entry_first_refresh = AsyncMock() + mock_coordinator.async_refresh = AsyncMock() 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()) + def _run_bg_task(_hass, coro, _name): + coro.close() + return MagicMock() + + mock_entry.async_create_background_task = MagicMock(side_effect=_run_bg_task) + with ( patch( "custom_components.blanco_unit.bluetooth.async_ble_device_from_address", @@ -93,7 +98,9 @@ async def test_async_setup_entry_success(hass: HomeAssistant) -> None: assert result is True assert mock_entry.runtime_data == mock_coordinator - mock_coordinator.async_config_entry_first_refresh.assert_called_once() + # The first refresh runs in the background so it cannot block startup. + mock_coordinator.async_refresh.assert_called_once() + mock_entry.async_create_background_task.assert_called_once() mock_forward.assert_called_once() # Verify platforms are registered @@ -208,111 +215,6 @@ def capture_callback(hass, callback, *args, **kwargs): mock_reload.assert_called_once_with("test_entry_id") -async def test_async_setup_entry_auth_failed(hass: HomeAssistant) -> None: - """Test config entry setup with authentication failure.""" - mock_device = MagicMock() - mock_device.address = "AA:BB:CC:DD:EE:FF" - - mock_coordinator = MagicMock() - mock_coordinator.async_config_entry_first_refresh = AsyncMock( - side_effect=ConfigEntryAuthFailed("Auth failed") - ) - - mock_entry = MagicMock(spec=ConfigEntry) - mock_entry.entry_id = "test_entry_id" - mock_entry.data = {CONF_MAC: "AA:BB:CC:DD:EE:FF"} - - unsub_listener = MagicMock() - mock_entry.add_update_listener = MagicMock(return_value=unsub_listener) - - 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, - ), - ): - with pytest.raises(ConfigEntryAuthFailed): - await async_setup_entry(hass, mock_entry) - - # Verify unsub listener was called - unsub_listener.assert_called_once() - - -async def test_async_setup_entry_home_assistant_error(hass: HomeAssistant) -> None: - """Test config entry setup with HomeAssistantError.""" - mock_device = MagicMock() - mock_device.address = "AA:BB:CC:DD:EE:FF" - - mock_coordinator = MagicMock() - error = HomeAssistantError() - error.translation_key = "test_error" - error.translation_placeholders = {"key": "value"} - mock_coordinator.async_config_entry_first_refresh = AsyncMock(side_effect=error) - - mock_entry = MagicMock(spec=ConfigEntry) - mock_entry.entry_id = "test_entry_id" - mock_entry.data = {CONF_MAC: "AA:BB:CC:DD:EE:FF"} - - unsub_listener = MagicMock() - mock_entry.add_update_listener = MagicMock(return_value=unsub_listener) - - 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, - ), - ): - with pytest.raises(ConfigEntryNotReady) as exc_info: - await async_setup_entry(hass, mock_entry) - - assert exc_info.value.translation_key == "test_error" - # Verify unsub listener was called - unsub_listener.assert_called_once() - - -async def test_async_setup_entry_generic_exception(hass: HomeAssistant) -> None: - """Test config entry setup with generic exception.""" - mock_device = MagicMock() - mock_device.address = "AA:BB:CC:DD:EE:FF" - - mock_coordinator = MagicMock() - mock_coordinator.async_config_entry_first_refresh = AsyncMock( - side_effect=ValueError("Test error") - ) - - mock_entry = MagicMock(spec=ConfigEntry) - mock_entry.entry_id = "test_entry_id" - mock_entry.data = {CONF_MAC: "AA:BB:CC:DD:EE:FF"} - - unsub_listener = MagicMock() - mock_entry.add_update_listener = MagicMock(return_value=unsub_listener) - - 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, - ), - ): - with pytest.raises(ConfigEntryNotReady) as exc_info: - await async_setup_entry(hass, mock_entry) - - assert exc_info.value.translation_key == "error_unknown" - # Verify unsub listener was called - unsub_listener.assert_called_once() - - async def test_async_reload_entry(hass: HomeAssistant) -> None: """Test config entry reload.""" mock_entry = MagicMock(spec=ConfigEntry)