diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 9fcad5aa4f1c0f..787014c0cc8742 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -16,6 +16,7 @@ InvalidEncryptionKeyAPIError, RequiresEncryptionAPIError, ResolveAPIError, + wifi_mac_to_bluetooth_mac, ) import aiohttp import voluptuous as vol @@ -37,6 +38,7 @@ from homeassistant.data_entry_flow import AbortFlow, FlowResultType from homeassistant.helpers import discovery_flow from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.importlib import async_import_module from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo from homeassistant.helpers.service_info.hassio import HassioServiceInfo @@ -317,6 +319,24 @@ async def async_step_zeroconf( # Check if already configured await self.async_set_unique_id(mac_address) + + # Convert WiFi MAC to Bluetooth MAC and notify Improv BLE if waiting + # ESPHome devices use WiFi MAC + 1 for Bluetooth MAC + # Late import to avoid circular dependency + # NOTE: Do not change to hass.config.components check - improv_ble is + # config_flow only and may not be in the components registry + if improv_ble := await async_import_module( + self.hass, "homeassistant.components.improv_ble" + ): + ble_mac = wifi_mac_to_bluetooth_mac(mac_address) + improv_ble.async_register_next_flow(self.hass, ble_mac, self.flow_id) + _LOGGER.debug( + "Notified Improv BLE of flow %s for BLE MAC %s (derived from WiFi MAC %s)", + self.flow_id, + ble_mac, + mac_address, + ) + await self._async_validate_mac_abort_configured( mac_address, self._host, self._port ) diff --git a/homeassistant/components/improv_ble/__init__.py b/homeassistant/components/improv_ble/__init__.py index ff40b65a8d010d..15f0ef4082f5af 100644 --- a/homeassistant/components/improv_ble/__init__.py +++ b/homeassistant/components/improv_ble/__init__.py @@ -2,10 +2,64 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +import logging +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import format_mac -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up improv_ble from a config entry.""" - raise NotImplementedError +from .const import PROVISIONING_FUTURES + +_LOGGER = logging.getLogger(__name__) + +__all__ = ["async_register_next_flow"] + + +@callback +def async_get_provisioning_futures(hass: HomeAssistant) -> dict: + """Get the provisioning futures registry, creating it if needed. + + This is a helper function for internal use and testing. + It ensures the registry exists without requiring async_setup to run first. + """ + return hass.data.setdefault(PROVISIONING_FUTURES, {}) + + +def async_register_next_flow(hass: HomeAssistant, ble_mac: str, flow_id: str) -> None: + """Register a next flow for a provisioned device. + + Called by other integrations (e.g., ESPHome) when they discover a device + that was provisioned via Improv BLE. If Improv BLE is waiting for this + device, the Future will be resolved with the flow_id. + + Args: + hass: Home Assistant instance + ble_mac: Bluetooth MAC address of the provisioned device + flow_id: Config flow ID to chain to + + """ + registry = async_get_provisioning_futures(hass) + normalized_mac = format_mac(ble_mac) + + future = registry.get(normalized_mac) + if not future: + _LOGGER.debug( + "No provisioning future found for %s (flow_id %s)", + normalized_mac, + flow_id, + ) + return + + if future.done(): + _LOGGER.debug( + "Future for %s already done, ignoring flow_id %s", + normalized_mac, + flow_id, + ) + return + + _LOGGER.debug( + "Resolving provisioning future for %s with flow_id %s", + normalized_mac, + flow_id, + ) + future.set_result(flow_id) diff --git a/homeassistant/components/improv_ble/config_flow.py b/homeassistant/components/improv_ble/config_flow.py index 66e95f6d028b4c..17168ee332fc6b 100644 --- a/homeassistant/components/improv_ble/config_flow.py +++ b/homeassistant/components/improv_ble/config_flow.py @@ -3,7 +3,8 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Coroutine +from collections.abc import AsyncIterator, Callable, Coroutine +from contextlib import asynccontextmanager from dataclasses import dataclass import logging from typing import Any @@ -21,12 +22,19 @@ import voluptuous as vol from homeassistant.components import bluetooth -from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + FlowType, +) from homeassistant.const import CONF_ADDRESS from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers.device_registry import format_mac -from .const import DOMAIN +from . import async_get_provisioning_futures +from .const import DOMAIN, PROVISIONING_TIMEOUT _LOGGER = logging.getLogger(__name__) @@ -285,6 +293,19 @@ async def async_step_identify( return self.async_show_form(step_id="identify") return await self.async_step_start_improv() + @asynccontextmanager + async def _async_provision_context( + self, ble_mac: str + ) -> AsyncIterator[asyncio.Future[str | None]]: + """Context manager to register and cleanup provisioning future.""" + future = self.hass.loop.create_future() + provisioning_futures = async_get_provisioning_futures(self.hass) + provisioning_futures[ble_mac] = future + try: + yield future + finally: + provisioning_futures.pop(ble_mac, None) + async def async_step_provision( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -319,53 +340,86 @@ async def _do_provision() -> None: # mypy is not aware that we can't get here without having these set already assert self._credentials is not None assert self._device is not None + assert self._discovery_info is not None + + # Register future before provisioning starts so other integrations + # can register their flow IDs as soon as they discover the device + ble_mac = format_mac(self._discovery_info.address) errors = {} - try: - redirect_url = await self._try_call( - self._device.provision( - self._credentials.ssid, self._credentials.password, None + async with self._async_provision_context(ble_mac) as future: + try: + redirect_url = await self._try_call( + self._device.provision( + self._credentials.ssid, self._credentials.password, None + ) ) - ) - except AbortFlow as err: - self._provision_result = self.async_abort(reason=err.reason) - return - except improv_ble_errors.ProvisioningFailed as err: - if err.error == Error.NOT_AUTHORIZED: - _LOGGER.debug("Need authorization when calling provision") - self._provision_result = await self.async_step_authorize() + except AbortFlow as err: + self._provision_result = self.async_abort(reason=err.reason) return - if err.error == Error.UNABLE_TO_CONNECT: - self._credentials = None - errors["base"] = "unable_to_connect" + except improv_ble_errors.ProvisioningFailed as err: + if err.error == Error.NOT_AUTHORIZED: + _LOGGER.debug("Need authorization when calling provision") + self._provision_result = await self.async_step_authorize() + return + if err.error == Error.UNABLE_TO_CONNECT: + self._credentials = None + errors["base"] = "unable_to_connect" + # Only for UNABLE_TO_CONNECT do we continue to show the form with an error + else: + self._provision_result = self.async_abort(reason="unknown") + return else: - self._provision_result = self.async_abort(reason="unknown") - return - else: - _LOGGER.debug("Provision successful, redirect URL: %s", redirect_url) - # Clear match history so device can be rediscovered if factory reset. - # This ensures that if the device is factory reset in the future, - # it will trigger a new discovery flow. - assert self._discovery_info is not None - bluetooth.async_clear_address_from_match_history( - self.hass, self._discovery_info.address - ) - # Abort all flows in progress with same unique ID - for flow in self._async_in_progress(include_uninitialized=True): - flow_unique_id = flow["context"].get("unique_id") - if ( - flow["flow_id"] != self.flow_id - and self.unique_id == flow_unique_id - ): - self.hass.config_entries.flow.async_abort(flow["flow_id"]) - if redirect_url: + _LOGGER.debug( + "Provision successful, redirect URL: %s", redirect_url + ) + # Clear match history so device can be rediscovered if factory reset. + # This ensures that if the device is factory reset in the future, + # it will trigger a new discovery flow. + bluetooth.async_clear_address_from_match_history( + self.hass, self._discovery_info.address + ) + # Abort all flows in progress with same unique ID + for flow in self._async_in_progress(include_uninitialized=True): + flow_unique_id = flow["context"].get("unique_id") + if ( + flow["flow_id"] != self.flow_id + and self.unique_id == flow_unique_id + ): + self.hass.config_entries.flow.async_abort(flow["flow_id"]) + + # Wait for another integration to discover and register flow chaining + next_flow_id: str | None = None + + try: + next_flow_id = await asyncio.wait_for( + future, timeout=PROVISIONING_TIMEOUT + ) + except TimeoutError: + _LOGGER.debug( + "Timeout waiting for next flow, proceeding with URL redirect" + ) + + if next_flow_id: + _LOGGER.debug("Received next flow ID: %s", next_flow_id) + self._provision_result = self.async_abort( + reason="provision_successful", + next_flow=(FlowType.CONFIG_FLOW, next_flow_id), + ) + return + + if redirect_url: + self._provision_result = self.async_abort( + reason="provision_successful_url", + description_placeholders={"url": redirect_url}, + ) + return self._provision_result = self.async_abort( - reason="provision_successful_url", - description_placeholders={"url": redirect_url}, + reason="provision_successful" ) return - self._provision_result = self.async_abort(reason="provision_successful") - return + + # If we reach here, we had UNABLE_TO_CONNECT error self._provision_result = self.async_show_form( step_id="provision", data_schema=STEP_PROVISION_SCHEMA, errors=errors ) diff --git a/homeassistant/components/improv_ble/const.py b/homeassistant/components/improv_ble/const.py index 0641773a0557c8..a55826d73a2c5e 100644 --- a/homeassistant/components/improv_ble/const.py +++ b/homeassistant/components/improv_ble/const.py @@ -1,3 +1,15 @@ """Constants for the Improv BLE integration.""" +from __future__ import annotations + +import asyncio + +from homeassistant.util.hass_dict import HassKey + DOMAIN = "improv_ble" + +PROVISIONING_FUTURES: HassKey[dict[str, asyncio.Future[str | None]]] = HassKey(DOMAIN) + +# Timeout in seconds to wait for another integration to register a next flow +# after successful provisioning (e.g., ESPHome discovering the device) +PROVISIONING_TIMEOUT = 10.0 diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 50bdeec8572caf..463bddb52ea79d 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==7.21.1", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.22.0", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 5eb2d0f2bb8cd1..62813871e9d441 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -3169,6 +3169,37 @@ async def async_step_zeroconf( """Handle a flow initialized by Zeroconf discovery.""" return await self._async_step_discovery_without_unique_id() + def _async_set_next_flow_if_valid( + self, + result: ConfigFlowResult, + next_flow: tuple[FlowType, str] | None, + ) -> None: + """Validate and set next_flow in result if provided.""" + if next_flow is None: + return + flow_type, flow_id = next_flow + if flow_type != FlowType.CONFIG_FLOW: + raise HomeAssistantError("Invalid next_flow type") + # Raises UnknownFlow if the flow does not exist. + self.hass.config_entries.flow.async_get(flow_id) + result["next_flow"] = next_flow + + @callback + def async_abort( + self, + *, + reason: str, + description_placeholders: Mapping[str, str] | None = None, + next_flow: tuple[FlowType, str] | None = None, + ) -> ConfigFlowResult: + """Abort the config flow.""" + result = super().async_abort( + reason=reason, + description_placeholders=description_placeholders, + ) + self._async_set_next_flow_if_valid(result, next_flow) + return result + @callback def async_create_entry( # type: ignore[override] self, @@ -3198,13 +3229,7 @@ def async_create_entry( # type: ignore[override] ) result["minor_version"] = self.MINOR_VERSION - if next_flow is not None: - flow_type, flow_id = next_flow - if flow_type != FlowType.CONFIG_FLOW: - raise HomeAssistantError("Invalid next_flow type") - # Raises UnknownFlow if the flow does not exist. - self.hass.config_entries.flow.async_get(flow_id) - result["next_flow"] = next_flow + self._async_set_next_flow_if_valid(result, next_flow) result["options"] = options or {} result["subentries"] = subentries or () result["version"] = self.VERSION diff --git a/requirements_all.txt b/requirements_all.txt index 1458322946b6db..9c99e91f9863ac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3057,7 +3057,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.21.1 +uiprotect==7.22.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index de15be9da8092b..0dda1fdd1f6d61 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2534,7 +2534,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.21.1 +uiprotect==7.22.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 0dbab47b6f5697..b7f451945f4cf0 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -13,6 +13,7 @@ InvalidEncryptionKeyAPIError, RequiresEncryptionAPIError, ResolveAPIError, + wifi_mac_to_bluetooth_mac, ) import aiohttp import pytest @@ -2817,3 +2818,79 @@ async def track_async_init(*args, **kwargs): # Verify next_flow was NOT set since Z-Wave flow aborted assert "next_flow" not in result + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") +async def test_zeroconf_notifies_improv_ble( + hass: HomeAssistant, + mock_client: APIClient, +) -> None: + """Test that zeroconf discovery notifies improv_ble integration.""" + service_info = ZeroconfServiceInfo( + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], + hostname="test8266.local.", + name="mock_name", + port=6053, + properties={ + "mac": "aabbccddeeff", + }, + type="mock_type", + ) + + # Patch improv_ble to ensure it's available and track calls + with patch( + "homeassistant.components.improv_ble.async_register_next_flow" + ) as mock_register: + flow = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=service_info, + ) + + assert flow["type"] is FlowResultType.FORM + assert flow["step_id"] == "discovery_confirm" + + # Verify improv_ble.async_register_next_flow was called with correct parameters + assert len(mock_register.mock_calls) == 1 + call_args = mock_register.mock_calls[0].args + assert call_args[0] is hass # HomeAssistant instance + # WiFi MAC aabbccddeeff + 1 = Bluetooth MAC aabbccddee00 + # (wifi_mac_to_bluetooth_mac from aioesphomeapi) + expected_ble_mac = wifi_mac_to_bluetooth_mac("aa:bb:cc:dd:ee:ff") + assert call_args[1] == expected_ble_mac # BLE MAC address + assert call_args[2] == flow["flow_id"] # Flow ID + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") +async def test_zeroconf_when_improv_ble_not_available( + hass: HomeAssistant, + mock_client: APIClient, +) -> None: + """Test that zeroconf discovery works when improv_ble is not available.""" + service_info = ZeroconfServiceInfo( + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], + hostname="test8266.local.", + name="mock_name", + port=6053, + properties={ + "mac": "aabbccddeeff", + }, + type="mock_type", + ) + + # Mock async_import_module to return None (simulating improv_ble not available) + with patch( + "homeassistant.components.esphome.config_flow.async_import_module", + return_value=None, + ): + flow = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=service_info, + ) + + # Flow should still work even without improv_ble + assert flow["type"] is FlowResultType.FORM + assert flow["step_id"] == "discovery_confirm" diff --git a/tests/components/improv_ble/test_config_flow.py b/tests/components/improv_ble/test_config_flow.py index 140b473cd7d48c..dca47035784a72 100644 --- a/tests/components/improv_ble/test_config_flow.py +++ b/tests/components/improv_ble/test_config_flow.py @@ -1,19 +1,27 @@ """Test the Improv via BLE config flow.""" +import asyncio from collections.abc import Callable from unittest.mock import patch from bleak.exc import BleakError -from improv_ble_client import Error, State, errors as improv_ble_errors +from improv_ble_client import ( + SERVICE_DATA_UUID, + SERVICE_UUID, + Error, + State, + errors as improv_ble_errors, +) import pytest from homeassistant import config_entries +from homeassistant.components import improv_ble from homeassistant.components.bluetooth import ( BluetoothChange, BluetoothServiceInfoBleak, ) from homeassistant.components.improv_ble.const import DOMAIN -from homeassistant.config_entries import SOURCE_IGNORE +from homeassistant.config_entries import SOURCE_IGNORE, FlowType from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType @@ -34,6 +42,43 @@ IMPROV_BLE = "homeassistant.components.improv_ble" +# Discovery info for target flow devices (used for flow chaining tests) +IMPROV_BLE_DISCOVERY_INFO_TARGET1 = BluetoothServiceInfoBleak( + name="target_device", + address="AA:BB:CC:DD:EE:F1", + rssi=-60, + manufacturer_data={}, + service_uuids=[SERVICE_UUID], + service_data={SERVICE_DATA_UUID: b"\x01\x00\x00\x00\x00\x00"}, + source="local", + device=generate_ble_device(address="AA:BB:CC:DD:EE:F1", name="target_device"), + advertisement=generate_advertisement_data( + service_uuids=[SERVICE_UUID], + service_data={SERVICE_DATA_UUID: b"\x01\x00\x00\x00\x00\x00"}, + ), + time=0, + connectable=True, + tx_power=-127, +) + +IMPROV_BLE_DISCOVERY_INFO_TARGET2 = BluetoothServiceInfoBleak( + name="esphome_device", + address="AA:BB:CC:DD:EE:F2", + rssi=-60, + manufacturer_data={}, + service_uuids=[SERVICE_UUID], + service_data={SERVICE_DATA_UUID: b"\x01\x00\x00\x00\x00\x00"}, + source="local", + device=generate_ble_device(address="AA:BB:CC:DD:EE:F2", name="esphome_device"), + advertisement=generate_advertisement_data( + service_uuids=[SERVICE_UUID], + service_data={SERVICE_DATA_UUID: b"\x01\x00\x00\x00\x00\x00"}, + ), + time=0, + connectable=True, + tx_power=-127, +) + @pytest.mark.parametrize( ("url", "abort_reason", "placeholders"), @@ -269,6 +314,7 @@ async def test_bluetooth_rediscovery_after_successful_provision( f"{IMPROV_BLE}.config_flow.ImprovBLEClient.provision", return_value=None, ), + patch(f"{IMPROV_BLE}.config_flow.PROVISIONING_TIMEOUT", 0.0000001), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], {"ssid": "TestNetwork", "password": "secret"} @@ -394,6 +440,7 @@ async def _test_common_success( url: str | None = None, abort_reason: str = "provision_successful", placeholders: dict[str, str] | None = None, + patch_timeout_for_tests=None, ) -> None: """Test bluetooth and user flow success paths.""" @@ -406,6 +453,7 @@ async def _test_common_success( f"{IMPROV_BLE}.config_flow.ImprovBLEClient.provision", return_value=url, ) as mock_provision, + patch(f"{IMPROV_BLE}.config_flow.PROVISIONING_TIMEOUT", 0.0000001), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], {"ssid": "MyWIFI", "password": "secret"} @@ -480,6 +528,7 @@ async def subscribe_state_updates( f"{IMPROV_BLE}.config_flow.ImprovBLEClient.provision", return_value="http://blabla.local", ) as mock_provision, + patch(f"{IMPROV_BLE}.config_flow.PROVISIONING_TIMEOUT", 0.0000001), ): result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.SHOW_PROGRESS @@ -722,6 +771,7 @@ async def _test_provision_error(hass: HomeAssistant, exc) -> str: f"{IMPROV_BLE}.config_flow.ImprovBLEClient.provision", side_effect=exc, ), + patch(f"{IMPROV_BLE}.config_flow.PROVISIONING_TIMEOUT", 0.0000001), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], {"ssid": "MyWIFI", "password": "secret"} @@ -812,6 +862,275 @@ async def test_provision_fails_invalid_data( ) +async def test_flow_chaining_with_next_flow(hass: HomeAssistant) -> None: + """Test flow chaining when another integration registers a next flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + # Confirm bluetooth setup + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + # Start provisioning + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADDRESS: IMPROV_BLE_DISCOVERY_INFO.address}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "provision" + + with ( + patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.need_authorization", + return_value=False, + ), + patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.provision", + return_value=None, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"ssid": "TestNetwork", "password": "secret"} + ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "provisioning" + assert result["step_id"] == "do_provision" + + # Yield to allow the background task to create the future + await asyncio.sleep(0) # task is created with eager_start=False + + # Create a dummy target flow using a different device address + target_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO_TARGET1, + ) + next_config_flow_id = target_result["flow_id"] + + # Simulate another integration discovering the device and registering a flow + # This happens while provision is waiting on the future + improv_ble.async_register_next_flow( + hass, IMPROV_BLE_DISCOVERY_INFO.address, next_config_flow_id + ) + + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "provision_successful" + assert result["next_flow"] == (FlowType.CONFIG_FLOW, next_config_flow_id) + + +async def test_flow_chaining_timeout(hass: HomeAssistant) -> None: + """Test flow chaining timeout when no integration discovers the device.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + # Confirm bluetooth setup + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + # Start provisioning + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADDRESS: IMPROV_BLE_DISCOVERY_INFO.address}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "provision" + + # Complete provisioning successfully but no integration discovers the device + with ( + patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.need_authorization", + return_value=False, + ), + patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.provision", + return_value=None, + ), + patch("asyncio.wait_for", side_effect=TimeoutError), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"ssid": "TestNetwork", "password": "secret"} + ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "provisioning" + assert result["step_id"] == "do_provision" + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "provision_successful" + assert "next_flow" not in result + + +async def test_flow_chaining_with_redirect_url(hass: HomeAssistant) -> None: + """Test flow chaining takes precedence over redirect URL.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + # Confirm bluetooth setup + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + # Start provisioning + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADDRESS: IMPROV_BLE_DISCOVERY_INFO.address}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "provision" + + with ( + patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.need_authorization", + return_value=False, + ), + patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.provision", + return_value="http://blabla.local", + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"ssid": "TestNetwork", "password": "secret"} + ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "provisioning" + assert result["step_id"] == "do_provision" + + # Yield to allow the background task to create the future + await asyncio.sleep(0) # task is created with eager_start=False + + # Create a dummy target flow using a different device address + target_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO_TARGET2, + ) + esphome_flow_id = target_result["flow_id"] + + # Simulate ESPHome discovering the device and notifying Improv BLE + # This happens while provision is still running + improv_ble.async_register_next_flow( + hass, IMPROV_BLE_DISCOVERY_INFO.address, esphome_flow_id + ) + + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + # Should use next_flow instead of redirect URL + assert result["reason"] == "provision_successful" + assert result["next_flow"] == (FlowType.CONFIG_FLOW, esphome_flow_id) + + +async def test_flow_chaining_future_already_done( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test async_register_next_flow when future is already done.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + # Confirm bluetooth setup + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + # Start provisioning + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADDRESS: IMPROV_BLE_DISCOVERY_INFO.address}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "provision" + + with ( + patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.need_authorization", + return_value=False, + ), + patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.provision", + return_value=None, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"ssid": "TestNetwork", "password": "secret"} + ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "provisioning" + assert result["step_id"] == "do_provision" + + # Yield to allow the background task to create the future + await asyncio.sleep(0) # task is created with eager_start=False + + # Create a target flow for the first call + target_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO_TARGET1, + ) + first_flow_id = target_result["flow_id"] + + # First call resolves the future + improv_ble.async_register_next_flow( + hass, IMPROV_BLE_DISCOVERY_INFO.address, first_flow_id + ) + + # Second call immediately after - future is now done but still in registry + # This call should be ignored with a debug log + caplog.clear() + improv_ble.async_register_next_flow( + hass, IMPROV_BLE_DISCOVERY_INFO.address, "second_flow_id" + ) + + # Verify the debug log message was emitted + assert "Future for aa:bb:cc:dd:ee:f0 already done" in caplog.text + assert "ignoring flow_id second_flow_id" in caplog.text + + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "provision_successful" + assert result["next_flow"] == (FlowType.CONFIG_FLOW, first_flow_id) + + async def test_bluetooth_name_update(hass: HomeAssistant) -> None: """Test that discovery notification title updates when device name changes.""" with patch( diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 12ce4a205814a4..b07e9a3f7e549d 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -9582,3 +9582,166 @@ async def async_step_user(self, user_input=None): # Verify frontend was notified again assert len(events) == 2 + + +async def test_config_flow_abort_with_next_flow(hass: HomeAssistant) -> None: + """Test that ConfigFlow.async_abort() can include next_flow parameter.""" + + class TargetFlow(config_entries.ConfigFlow): + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + return self.async_show_form(step_id="user") + + class TestFlow(config_entries.ConfigFlow): + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + # Create target flow first + target_result = await hass.config_entries.flow.async_init( + "test2", context={"source": config_entries.SOURCE_USER} + ) + # Abort with next_flow + return self.async_abort( + reason="provision_successful", + next_flow=( + config_entries.FlowType.CONFIG_FLOW, + target_result["flow_id"], + ), + ) + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + mock_integration(hass, MockModule("test2")) + mock_platform(hass, "test2.config_flow", None) + + with ( + mock_config_flow("test", TestFlow), + mock_config_flow("test2", TargetFlow), + ): + result = await hass.config_entries.flow.async_init( + "test", context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "provision_successful" + assert "next_flow" in result + assert result["next_flow"][0] == config_entries.FlowType.CONFIG_FLOW + # Verify the target flow exists + hass.config_entries.flow.async_get(result["next_flow"][1]) + + +async def test_config_flow_abort_with_invalid_next_flow_type( + hass: HomeAssistant, +) -> None: + """Test that ConfigFlow.async_abort() raises error for invalid flow type.""" + + class TestFlow(config_entries.ConfigFlow): + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + # Try to abort with invalid flow type + return self.async_abort( + reason="test", + next_flow=("invalid_type", "some_flow_id"), # type: ignore[arg-type] + ) + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + + with ( + mock_config_flow("test", TestFlow), + pytest.raises(HomeAssistantError, match="Invalid next_flow type"), + ): + await hass.config_entries.flow.async_init( + "test", context={"source": config_entries.SOURCE_USER} + ) + + +async def test_config_flow_abort_with_nonexistent_next_flow( + hass: HomeAssistant, +) -> None: + """Test that ConfigFlow.async_abort() raises error for nonexistent flow.""" + + class TestFlow(config_entries.ConfigFlow): + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + # Try to abort with nonexistent flow + return self.async_abort( + reason="test", + next_flow=(config_entries.FlowType.CONFIG_FLOW, "nonexistent_flow_id"), + ) + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + + with ( + mock_config_flow("test", TestFlow), + pytest.raises(data_entry_flow.UnknownFlow), + ): + await hass.config_entries.flow.async_init( + "test", context={"source": config_entries.SOURCE_USER} + ) + + +async def test_config_flow_create_entry_with_next_flow(hass: HomeAssistant) -> None: + """Test that ConfigFlow.async_create_entry() can include next_flow parameter.""" + + class TargetFlow(config_entries.ConfigFlow): + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + return self.async_show_form(step_id="user") + + class TestFlow(config_entries.ConfigFlow): + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + # Create target flow first + target_result = await hass.config_entries.flow.async_init( + "test2", context={"source": config_entries.SOURCE_USER} + ) + # Create entry with next_flow + return self.async_create_entry( + title="Test Entry", + data={}, + next_flow=( + config_entries.FlowType.CONFIG_FLOW, + target_result["flow_id"], + ), + ) + + mock_integration( + hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) + ) + mock_platform(hass, "test.config_flow", None) + mock_integration(hass, MockModule("test2")) + mock_platform(hass, "test2.config_flow", None) + + with ( + mock_config_flow("test", TestFlow), + mock_config_flow("test2", TargetFlow), + ): + result = await hass.config_entries.flow.async_init( + "test", context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == "Test Entry" + assert "next_flow" in result + assert result["next_flow"][0] == config_entries.FlowType.CONFIG_FLOW + # Verify the target flow exists + hass.config_entries.flow.async_get(result["next_flow"][1])