mirror of
https://github.com/home-assistant/core.git
synced 2025-11-09 02:49:40 +00:00
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:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user