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,
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
)

View File

@@ -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)

View File

@@ -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
)

View File

@@ -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

View File

@@ -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"

View File

@@ -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(