From 0f1d2a77cbdb9ab3a31fc1999262fd01dd02551e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Oct 2025 12:00:55 -1000 Subject: [PATCH] Add flow chaining from Improv BLE to integration config flows (#154415) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/esphome/config_flow.py | 20 ++ .../components/improv_ble/__init__.py | 64 +++- .../components/improv_ble/config_flow.py | 138 +++++--- homeassistant/components/improv_ble/const.py | 12 + tests/components/esphome/test_config_flow.py | 77 +++++ .../components/improv_ble/test_config_flow.py | 323 +++++++++++++++++- 6 files changed, 585 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 9fcad5aa4f1..787014c0cc8 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -16,6 +16,7 @@ from aioesphomeapi import ( InvalidEncryptionKeyAPIError, RequiresEncryptionAPIError, ResolveAPIError, + wifi_mac_to_bluetooth_mac, ) import aiohttp import voluptuous as vol @@ -37,6 +38,7 @@ from homeassistant.core import callback 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 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): # 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 ff40b65a8d0..15f0ef4082f 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 + +from .const import PROVISIONING_FUTURES + +_LOGGER = logging.getLogger(__name__) + +__all__ = ["async_register_next_flow"] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up improv_ble from a config entry.""" - raise NotImplementedError +@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 66e95f6d028..17168ee332f 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 @@ from improv_ble_client import ( 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 @@ class ImprovBLEConfigFlow(ConfigFlow, domain=DOMAIN): 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 @@ class ImprovBLEConfigFlow(ConfigFlow, domain=DOMAIN): # 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 0641773a055..a55826d73a2 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/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 0dbab47b6f5..b7f451945f4 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -13,6 +13,7 @@ from aioesphomeapi import ( InvalidEncryptionKeyAPIError, RequiresEncryptionAPIError, ResolveAPIError, + wifi_mac_to_bluetooth_mac, ) import aiohttp import pytest @@ -2817,3 +2818,79 @@ async def test_user_flow_zwave_discovery_aborts( # 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 140b473cd7d..dca47035784 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 @@ from tests.components.bluetooth import ( 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 _test_common_success_w_authorize( 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(