Add flow chaining from Improv BLE to integration config flows (#154415)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
J. Nick Koston
2025-10-14 12:00:55 -10:00
committed by GitHub
parent 385fc5b3d0
commit 0f1d2a77cb
6 changed files with 585 additions and 49 deletions

View File

@@ -16,6 +16,7 @@ from aioesphomeapi import (
InvalidEncryptionKeyAPIError, InvalidEncryptionKeyAPIError,
RequiresEncryptionAPIError, RequiresEncryptionAPIError,
ResolveAPIError, ResolveAPIError,
wifi_mac_to_bluetooth_mac,
) )
import aiohttp import aiohttp
import voluptuous as vol import voluptuous as vol
@@ -37,6 +38,7 @@ from homeassistant.core import callback
from homeassistant.data_entry_flow import AbortFlow, FlowResultType from homeassistant.data_entry_flow import AbortFlow, FlowResultType
from homeassistant.helpers import discovery_flow from homeassistant.helpers import discovery_flow
from homeassistant.helpers.device_registry import format_mac 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.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo
from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.service_info.hassio import HassioServiceInfo
@@ -317,6 +319,24 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
# Check if already configured # Check if already configured
await self.async_set_unique_id(mac_address) 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( await self._async_validate_mac_abort_configured(
mac_address, self._host, self._port mac_address, self._host, self._port
) )

View File

@@ -2,10 +2,64 @@
from __future__ import annotations from __future__ import annotations
from homeassistant.config_entries import ConfigEntry import logging
from homeassistant.core import HomeAssistant
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: @callback
"""Set up improv_ble from a config entry.""" def async_get_provisioning_futures(hass: HomeAssistant) -> dict:
raise NotImplementedError """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)

View File

@@ -3,7 +3,8 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import Callable, Coroutine from collections.abc import AsyncIterator, Callable, Coroutine
from contextlib import asynccontextmanager
from dataclasses import dataclass from dataclasses import dataclass
import logging import logging
from typing import Any from typing import Any
@@ -21,12 +22,19 @@ from improv_ble_client import (
import voluptuous as vol import voluptuous as vol
from homeassistant.components import bluetooth 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.const import CONF_ADDRESS
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.data_entry_flow import AbortFlow 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__) _LOGGER = logging.getLogger(__name__)
@@ -285,6 +293,19 @@ class ImprovBLEConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(step_id="identify") return self.async_show_form(step_id="identify")
return await self.async_step_start_improv() 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( async def async_step_provision(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> 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 # mypy is not aware that we can't get here without having these set already
assert self._credentials is not None assert self._credentials is not None
assert self._device 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 = {} errors = {}
try: async with self._async_provision_context(ble_mac) as future:
redirect_url = await self._try_call( try:
self._device.provision( redirect_url = await self._try_call(
self._credentials.ssid, self._credentials.password, None self._device.provision(
self._credentials.ssid, self._credentials.password, None
)
) )
) except AbortFlow as err:
except AbortFlow as err: self._provision_result = self.async_abort(reason=err.reason)
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()
return return
if err.error == Error.UNABLE_TO_CONNECT: except improv_ble_errors.ProvisioningFailed as err:
self._credentials = None if err.error == Error.NOT_AUTHORIZED:
errors["base"] = "unable_to_connect" _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: else:
self._provision_result = self.async_abort(reason="unknown") _LOGGER.debug(
return "Provision successful, redirect URL: %s", redirect_url
else: )
_LOGGER.debug("Provision successful, redirect URL: %s", redirect_url) # Clear match history so device can be rediscovered if factory reset.
# Clear match history so device can be rediscovered if factory reset. # This ensures that if the device is factory reset in the future,
# This ensures that if the device is factory reset in the future, # it will trigger a new discovery flow.
# it will trigger a new discovery flow. bluetooth.async_clear_address_from_match_history(
assert self._discovery_info is not None self.hass, self._discovery_info.address
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):
# Abort all flows in progress with same unique ID flow_unique_id = flow["context"].get("unique_id")
for flow in self._async_in_progress(include_uninitialized=True): if (
flow_unique_id = flow["context"].get("unique_id") flow["flow_id"] != self.flow_id
if ( and self.unique_id == flow_unique_id
flow["flow_id"] != self.flow_id ):
and self.unique_id == flow_unique_id self.hass.config_entries.flow.async_abort(flow["flow_id"])
):
self.hass.config_entries.flow.async_abort(flow["flow_id"]) # Wait for another integration to discover and register flow chaining
if redirect_url: 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( self._provision_result = self.async_abort(
reason="provision_successful_url", reason="provision_successful"
description_placeholders={"url": redirect_url},
) )
return 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( self._provision_result = self.async_show_form(
step_id="provision", data_schema=STEP_PROVISION_SCHEMA, errors=errors step_id="provision", data_schema=STEP_PROVISION_SCHEMA, errors=errors
) )

View File

@@ -1,3 +1,15 @@
"""Constants for the Improv BLE integration.""" """Constants for the Improv BLE integration."""
from __future__ import annotations
import asyncio
from homeassistant.util.hass_dict import HassKey
DOMAIN = "improv_ble" 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

View File

@@ -13,6 +13,7 @@ from aioesphomeapi import (
InvalidEncryptionKeyAPIError, InvalidEncryptionKeyAPIError,
RequiresEncryptionAPIError, RequiresEncryptionAPIError,
ResolveAPIError, ResolveAPIError,
wifi_mac_to_bluetooth_mac,
) )
import aiohttp import aiohttp
import pytest 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 # Verify next_flow was NOT set since Z-Wave flow aborted
assert "next_flow" not in result 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"

View File

@@ -1,19 +1,27 @@
"""Test the Improv via BLE config flow.""" """Test the Improv via BLE config flow."""
import asyncio
from collections.abc import Callable from collections.abc import Callable
from unittest.mock import patch from unittest.mock import patch
from bleak.exc import BleakError 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 import pytest
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components import improv_ble
from homeassistant.components.bluetooth import ( from homeassistant.components.bluetooth import (
BluetoothChange, BluetoothChange,
BluetoothServiceInfoBleak, BluetoothServiceInfoBleak,
) )
from homeassistant.components.improv_ble.const import DOMAIN 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.const import CONF_ADDRESS
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult, FlowResultType from homeassistant.data_entry_flow import FlowResult, FlowResultType
@@ -34,6 +42,43 @@ from tests.components.bluetooth import (
IMPROV_BLE = "homeassistant.components.improv_ble" 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( @pytest.mark.parametrize(
("url", "abort_reason", "placeholders"), ("url", "abort_reason", "placeholders"),
@@ -269,6 +314,7 @@ async def test_bluetooth_rediscovery_after_successful_provision(
f"{IMPROV_BLE}.config_flow.ImprovBLEClient.provision", f"{IMPROV_BLE}.config_flow.ImprovBLEClient.provision",
return_value=None, return_value=None,
), ),
patch(f"{IMPROV_BLE}.config_flow.PROVISIONING_TIMEOUT", 0.0000001),
): ):
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"ssid": "TestNetwork", "password": "secret"} result["flow_id"], {"ssid": "TestNetwork", "password": "secret"}
@@ -394,6 +440,7 @@ async def _test_common_success(
url: str | None = None, url: str | None = None,
abort_reason: str = "provision_successful", abort_reason: str = "provision_successful",
placeholders: dict[str, str] | None = None, placeholders: dict[str, str] | None = None,
patch_timeout_for_tests=None,
) -> None: ) -> None:
"""Test bluetooth and user flow success paths.""" """Test bluetooth and user flow success paths."""
@@ -406,6 +453,7 @@ async def _test_common_success(
f"{IMPROV_BLE}.config_flow.ImprovBLEClient.provision", f"{IMPROV_BLE}.config_flow.ImprovBLEClient.provision",
return_value=url, return_value=url,
) as mock_provision, ) as mock_provision,
patch(f"{IMPROV_BLE}.config_flow.PROVISIONING_TIMEOUT", 0.0000001),
): ):
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"ssid": "MyWIFI", "password": "secret"} 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", f"{IMPROV_BLE}.config_flow.ImprovBLEClient.provision",
return_value="http://blabla.local", return_value="http://blabla.local",
) as mock_provision, ) as mock_provision,
patch(f"{IMPROV_BLE}.config_flow.PROVISIONING_TIMEOUT", 0.0000001),
): ):
result = await hass.config_entries.flow.async_configure(result["flow_id"]) result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.SHOW_PROGRESS 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", f"{IMPROV_BLE}.config_flow.ImprovBLEClient.provision",
side_effect=exc, side_effect=exc,
), ),
patch(f"{IMPROV_BLE}.config_flow.PROVISIONING_TIMEOUT", 0.0000001),
): ):
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"ssid": "MyWIFI", "password": "secret"} 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: async def test_bluetooth_name_update(hass: HomeAssistant) -> None:
"""Test that discovery notification title updates when device name changes.""" """Test that discovery notification title updates when device name changes."""
with patch( with patch(