Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions homeassistant/components/esphome/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
InvalidEncryptionKeyAPIError,
RequiresEncryptionAPIError,
ResolveAPIError,
wifi_mac_to_bluetooth_mac,
)
import aiohttp
import voluptuous as vol
Expand All @@ -37,6 +38,7 @@
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
Expand Down Expand Up @@ -317,6 +319,24 @@ async def async_step_zeroconf(

# 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
)
Expand Down
64 changes: 59 additions & 5 deletions homeassistant/components/improv_ble/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up improv_ble from a config entry."""
raise NotImplementedError
from .const import PROVISIONING_FUTURES

_LOGGER = logging.getLogger(__name__)

__all__ = ["async_register_next_flow"]


@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)
138 changes: 96 additions & 42 deletions homeassistant/components/improv_ble/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,12 +22,19 @@
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__)

Expand Down Expand Up @@ -285,6 +293,19 @@ async def async_step_identify(
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:
Expand Down Expand Up @@ -319,53 +340,86 @@ async def _do_provision() -> None:
# 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
)
Expand Down
12 changes: 12 additions & 0 deletions homeassistant/components/improv_ble/const.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion homeassistant/components/unifiprotect/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["uiprotect", "unifi_discovery"],
"requirements": ["uiprotect==7.21.1", "unifi-discovery==1.2.0"],
"requirements": ["uiprotect==7.22.0", "unifi-discovery==1.2.0"],
"ssdp": [
{
"manufacturer": "Ubiquiti Networks",
Expand Down
39 changes: 32 additions & 7 deletions homeassistant/config_entries.py
Original file line number Diff line number Diff line change
Expand Up @@ -3169,6 +3169,37 @@ async def async_step_zeroconf(
"""Handle a flow initialized by Zeroconf discovery."""
return await self._async_step_discovery_without_unique_id()

def _async_set_next_flow_if_valid(
self,
result: ConfigFlowResult,
next_flow: tuple[FlowType, str] | None,
) -> None:
"""Validate and set next_flow in result if provided."""
if next_flow is None:
return
flow_type, flow_id = next_flow
if flow_type != FlowType.CONFIG_FLOW:
raise HomeAssistantError("Invalid next_flow type")
# Raises UnknownFlow if the flow does not exist.
self.hass.config_entries.flow.async_get(flow_id)
result["next_flow"] = next_flow

@callback
def async_abort(
self,
*,
reason: str,
description_placeholders: Mapping[str, str] | None = None,
next_flow: tuple[FlowType, str] | None = None,
) -> ConfigFlowResult:
"""Abort the config flow."""
result = super().async_abort(
reason=reason,
description_placeholders=description_placeholders,
)
self._async_set_next_flow_if_valid(result, next_flow)
return result

@callback
def async_create_entry( # type: ignore[override]
self,
Expand Down Expand Up @@ -3198,13 +3229,7 @@ def async_create_entry( # type: ignore[override]
)

result["minor_version"] = self.MINOR_VERSION
if next_flow is not None:
flow_type, flow_id = next_flow
if flow_type != FlowType.CONFIG_FLOW:
raise HomeAssistantError("Invalid next_flow type")
# Raises UnknownFlow if the flow does not exist.
self.hass.config_entries.flow.async_get(flow_id)
result["next_flow"] = next_flow
self._async_set_next_flow_if_valid(result, next_flow)
result["options"] = options or {}
result["subentries"] = subentries or ()
result["version"] = self.VERSION
Expand Down
2 changes: 1 addition & 1 deletion requirements_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion requirements_test_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading