mirror of
https://github.com/home-assistant/core.git
synced 2025-11-10 03:19:34 +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,
|
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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user