Improve iZone user Search with progress and handoff to Discovered flows (#178721)

This commit is contained in:
Penny Wood
2026-08-12 12:03:32 +02:00
committed by GitHub
parent f663401780
commit 40323a6688
6 changed files with 512 additions and 192 deletions
+120 -95
View File
@@ -1,16 +1,19 @@
"""Config flow for izone."""
import asyncio
from collections.abc import Iterable
from dataclasses import dataclass
import logging
from typing import Any, Self, override
import pizone
from pizone.discovery import SCAN_TIMEOUT
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, FlowType
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import callback
from homeassistant.helpers import discovery_flow
from homeassistant.helpers.selector import (
SelectOptionDict,
@@ -28,6 +31,18 @@ _LOGGER = logging.getLogger(__name__)
SELECTED_CONTROLLER_UID = "selected_controller_uid"
# Wait after IASD for ASPort replies (matches pizone discover_all wait).
USER_SCAN_WAIT_SECONDS = SCAN_TIMEOUT
@dataclass(frozen=True, slots=True)
class _ShelfCandidate:
"""An in-progress discovery/HomeKit flow on the Discovered shelf."""
uid: str
host: str
flow_id: str
def _flow_uid_for_matching(flow: ConfigFlow) -> str | None:
"""Return a stable controller UID for deduplicating in-progress flows."""
@@ -42,8 +57,9 @@ class IZoneConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 2
_user_discovered_endpoints: list[pizone.ControllerEndpoint] | None = None
_discovered_controller_ip: str | None = None
_user_discovery_task: asyncio.Task[None] | None = None
_user_discovery_failed: bool = False
@override
def is_matching(self, other_flow: Self) -> bool:
@@ -87,89 +103,85 @@ class IZoneConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user(
self, _user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""User-started flow: offer configuration choices for discovered controllers.
Discovery is started if not yet running, then a fresh discovery cycle is triggered
and this step waits briefly for replies.
While this interactive flow is active, runtime integration discovery remains
blocked by ``_async_blocks_runtime_integration_discovery`` to avoid UI races.
"""
if self._async_in_progress(include_uninitialized=True):
return self.async_abort(reason="already_in_progress")
"""User-started flow: search the LAN, then offer discovered controllers."""
return await self.async_step_discover()
async def _async_run_user_discovery(self) -> None:
"""Scan and wait for the progress step (no unique_id work here)."""
self._user_discovery_failed = False
try:
endpoints = await izone_discovery.async_discover_all_endpoints(self.hass)
await izone_discovery.async_scan(self.hass)
except OSError:
_LOGGER.debug("Unable to start iZone discovery service", exc_info=True)
return self.async_abort(reason="discovery_failed")
if not endpoints:
_LOGGER.debug("No controllers found")
return self.async_abort(reason="no_devices_found")
self._user_discovery_failed = True
return
# Sleep is the ASPort reply window; the event loop processes datagrams
# and eager discovery-flow inits during it (no post-sleep drain).
await asyncio.sleep(USER_SCAN_WAIT_SECONDS)
self._user_discovered_endpoints = self._async_get_unconfigured_endpoints(
endpoints
)
if not self._user_discovered_endpoints:
return self.async_abort(reason="already_configured")
if len(self._user_discovered_endpoints) > 1:
return await self.async_step_select_controller()
sole = self._user_discovered_endpoints[0]
await self.async_set_unique_id(sole.uid)
self._discovered_controller_ip = sole.host
return await self.async_step_confirm()
async def async_step_select_controller(
self, user_input: dict[str, Any] | None = None
async def async_step_discover(
self, _user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Choose one unconfigured controller after broadcast discovery."""
if not self._user_discovered_endpoints:
"""Broadcast discovery with a progress UI, then continue to discovery_done."""
if not self._user_discovery_task:
self._user_discovery_task = self.hass.async_create_task(
self._async_run_user_discovery()
)
# Always leave progress first; the task may already be done (eager).
return self.async_show_progress(
step_id="discover",
progress_action="discover",
progress_task=self._user_discovery_task,
)
if not self._user_discovery_task.done():
return self.async_show_progress(
step_id="discover",
progress_action="discover",
progress_task=self._user_discovery_task,
)
self._user_discovery_task = None
return self.async_show_progress_done(next_step_id="discovery_done")
async def async_step_discovery_done(
self, _user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""After Search scan: abort, hand off the sole shelf flow, or choose."""
if self._user_discovery_failed:
return self.async_abort(reason="discovery_failed")
candidates = self._async_user_candidates()
if not candidates:
_LOGGER.debug("No controllers found on the Discovered shelf")
return self.async_abort(reason="no_devices_found")
if len(candidates) == 1:
return self.async_abort(
reason="continue_setup",
next_flow=(FlowType.CONFIG_FLOW, candidates[0].flow_id),
)
by_uid = {
endpoint.uid: endpoint for endpoint in self._user_discovered_endpoints
}
selection_schema = vol.Schema(
{
vol.Required(
SELECTED_CONTROLLER_UID,
default=self._user_discovered_endpoints[0].uid,
default=candidates[0].uid,
): SelectSelector(
SelectSelectorConfig(
options=[
SelectOptionDict(
value=endpoint.uid,
label=f"{endpoint.uid} ({endpoint.host})",
value=candidate.uid,
label=f"{candidate.uid} ({candidate.host})",
)
for endpoint in self._user_discovered_endpoints
for candidate in candidates
],
mode=SelectSelectorMode.DROPDOWN,
)
)
}
)
if user_input is not None:
selected_uid = user_input[SELECTED_CONTROLLER_UID]
if (primary := by_uid.get(selected_uid)) is None:
return self.async_abort(reason="no_devices_found")
for endpoint in self._user_discovered_endpoints:
if endpoint.uid == primary.uid:
continue
# Using integration_discovery lets HA's deduplication guard prevent stacking
# flows for UIDs already in progress or already configured.
self._async_schedule_integration_discovery_flow(
endpoint.uid,
endpoint.host,
)
return await self._async_create_controller_entry(primary)
controllers_lines = "\n".join(
f"- {endpoint.uid} ({endpoint.host})"
for endpoint in self._user_discovered_endpoints
f"- {candidate.uid} ({candidate.host})" for candidate in candidates
)
return self.async_show_form(
step_id="select_controller",
@@ -177,6 +189,25 @@ class IZoneConfigFlow(ConfigFlow, domain=DOMAIN):
description_placeholders={"controllers": controllers_lines},
)
async def async_step_select_controller(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Hand off to the shelf confirm for the selected controller UID."""
if user_input is None:
return await self.async_step_discovery_done()
selected_uid = user_input[SELECTED_CONTROLLER_UID]
by_uid = {
candidate.uid: candidate for candidate in self._async_user_candidates()
}
if (selected := by_uid.get(selected_uid)) is not None:
return self.async_abort(
reason="continue_setup",
next_flow=(FlowType.CONFIG_FLOW, selected.flow_id),
)
if selected_uid in self._async_current_ids(include_ignore=True):
return self.async_abort(reason="already_configured")
return self.async_abort(reason="no_devices_found")
@override
async def async_step_homekit(
self, discovery_info: ZeroconfServiceInfo
@@ -254,6 +285,7 @@ class IZoneConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is None:
self.context["title_placeholders"] = {
"name": self._entry_title(controller_uid),
"host": str(host),
}
return self.async_show_form(
step_id="confirm",
@@ -269,13 +301,38 @@ class IZoneConfigFlow(ConfigFlow, domain=DOMAIN):
# -- Private helpers
@callback
def _async_user_candidates(self) -> list[_ShelfCandidate]:
"""Return Discovered-shelf flows (integration discovery and HomeKit)."""
candidates: list[_ShelfCandidate] = []
for flow in self.hass.config_entries.flow.async_progress_by_handler(
DOMAIN, include_uninitialized=True
):
if flow["flow_id"] == self.flow_id:
continue
context = flow["context"]
if context.get("source") not in (
config_entries.SOURCE_INTEGRATION_DISCOVERY,
config_entries.SOURCE_HOMEKIT,
):
continue
uid = context.get("unique_id")
placeholders = context.get("title_placeholders")
host = placeholders.get("host") if placeholders is not None else None
if not isinstance(uid, str) or not isinstance(host, str):
continue
candidates.append(
_ShelfCandidate(uid=uid, host=host, flow_id=flow["flow_id"])
)
return sorted(candidates, key=lambda candidate: (candidate.uid, candidate.host))
@callback
def _async_schedule_integration_discovery_flow(
self,
uid: str,
host: str,
) -> None:
"""Queue integration discovery (import fan-out or manual discovery pick)."""
"""Queue integration discovery (import fan-out or HomeKit sibling)."""
discovery_flow.async_create_flow(
self.hass,
DOMAIN,
@@ -291,38 +348,6 @@ class IZoneConfigFlow(ConfigFlow, domain=DOMAIN):
"""Standard config entry title for a controller UID."""
return f"iZone {device_uid}"
@staticmethod
def _filter_yaml_exclude(
hass: HomeAssistant, endpoints: dict[str, pizone.ControllerEndpoint]
) -> dict[str, pizone.ControllerEndpoint]:
"""Remove UIDs listed in deprecated YAML ``exclude``."""
excluded = izone_discovery.yaml_excluded_uids(hass)
if not excluded:
return endpoints
return {
uid: endpoint
for uid, endpoint in endpoints.items()
if endpoint.uid not in excluded
}
@callback
def _async_get_unconfigured_endpoints(
self, endpoints: dict[str, pizone.ControllerEndpoint]
) -> list[pizone.ControllerEndpoint]:
"""Return sorted unconfigured endpoints for the interactive user flow."""
endpoints = self._filter_yaml_exclude(self.hass, endpoints)
# include_ignore=True ensures controllers whose entries have been explicitly
# ignored by the user (SOURCE_IGNORE) are not re-offered as configurable.
configured_uids = self._async_current_ids(include_ignore=True)
return sorted(
(
endpoint
for endpoint in endpoints.values()
if endpoint.uid not in configured_uids
),
key=lambda endpoint: (endpoint.uid, endpoint.host),
)
async def _async_create_controller_entry(
self,
endpoint: pizone.ControllerEndpoint,
+13 -14
View File
@@ -75,8 +75,6 @@ def async_note_integration_discovery(
"""Start a config flow when discovery reports an unclaimed endpoint."""
if endpoint.uid in yaml_excluded_uids(hass):
return
if _async_blocks_runtime_integration_discovery(hass):
return
discovery_flow.async_create_flow(
hass,
DOMAIN,
@@ -88,18 +86,6 @@ def async_note_integration_discovery(
)
@callback
def _async_blocks_runtime_integration_discovery(hass: HomeAssistant) -> bool:
"""Return True when an interactive setup flow should own the UI."""
for flw in hass.config_entries.flow.async_progress_by_handler(
DOMAIN, include_uninitialized=True
):
src = flw["context"].get("source")
if src == config_entries.SOURCE_USER:
return True
return False
@callback
def async_schedule_idle_stop(hass: HomeAssistant) -> None:
"""Schedule a delayed shutdown check for the shared discovery service."""
@@ -221,6 +207,19 @@ async def async_discover_all_endpoints(
return {endpoint.uid: endpoint for endpoint in await service.discover_all()}
async def async_scan(hass: HomeAssistant) -> None:
"""Broadcast IASD so ASPort replies fill the Discovered shelf.
Does not wait for replies or run ``discover_all``'s post-wait probe fan-out.
Callers that need replies to land should sleep after this (e.g. config flow).
Raises:
OSError: Discovery UDP socket could not be bound.
"""
service = await async_ensure_discovery(hass)
await service.scan()
async def async_discover_endpoint(
hass: HomeAssistant, uid: str
) -> pizone.ControllerEndpoint | None:
@@ -3,11 +3,15 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"continue_setup": "Continue setting up the discovered iZone controller.",
"discovery_failed": "Failed to start iZone discovery. Make sure your network is properly configured.",
"discovery_started": "iZone discovery has started. Your controllers will appear as discovered devices under Settings \u003e Devices \u0026 services.",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
},
"flow_title": "{name}",
"progress": {
"discover": "Searching for iZone controllers on your network"
},
"step": {
"confirm": {
"description": "Do you want to set up iZone?\n\nController UID: {controller_uid}\nController IP: {host}"
+52 -7
View File
@@ -7,9 +7,11 @@ from unittest.mock import AsyncMock, Mock, patch
from pizone import Controller, ControllerEndpoint, DiscoveryService, Zone
import pytest
from homeassistant.components.izone import discovery as izone_discovery
from homeassistant.components.izone.const import DOMAIN
from homeassistant.const import CONF_EXCLUDE, CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult, FlowResultType
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
@@ -182,8 +184,11 @@ def create_mock_zone(
@contextmanager
def patch_discovered_controllers(
controllers: Mock | dict[str, Mock] | Iterable[Mock],
) -> Generator[tuple[AsyncMock, AsyncMock]]:
"""Patch discovery helpers using mock controllers' uid/ip."""
) -> Generator[tuple[AsyncMock, AsyncMock, AsyncMock]]:
"""Patch discovery helpers using mock controllers' uid/ip.
User Search scan notes each controller onto the Discovered shelf.
"""
if isinstance(controllers, dict):
ctrl_list = list(controllers.values())
elif isinstance(controllers, Mock):
@@ -200,22 +205,56 @@ def patch_discovered_controllers(
) -> dict[str, ControllerEndpoint]:
return dict(endpoints)
async def _scan(hass: HomeAssistant) -> None:
for endpoint in endpoints.values():
izone_discovery.async_note_integration_discovery(hass, endpoint)
async def _discover_one(hass: HomeAssistant, uid: str) -> ControllerEndpoint | None:
return endpoints.get(uid)
mock_discover_all = AsyncMock(side_effect=_discover_all)
mock_scan = AsyncMock(side_effect=_scan)
mock_discover_one = AsyncMock(side_effect=_discover_one)
with (
patch(
"homeassistant.components.izone.discovery.async_discover_all_endpoints",
new=mock_discover_all,
),
patch(
"homeassistant.components.izone.discovery.async_scan",
new=mock_scan,
),
patch(
"homeassistant.components.izone.discovery.async_discover_endpoint",
new=mock_discover_one,
),
):
yield mock_discover_all, mock_discover_one
yield mock_discover_all, mock_discover_one, mock_scan
async def async_finish_user_discover(
hass: HomeAssistant, result: FlowResult
) -> FlowResult:
"""Advance a user Search flow past SHOW_PROGRESS discover."""
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["progress_action"] == "discover"
await hass.async_block_till_done(wait_background_tasks=True)
return await hass.config_entries.flow.async_configure(result["flow_id"])
async def async_follow_user_handoff(
hass: HomeAssistant, result: FlowResult
) -> FlowResult:
"""Follow Search handoff into the shelf confirm form."""
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "continue_setup"
next_flow = result["next_flow"]
assert next_flow is not None
_flow_type, flow_id = next_flow
result = await hass.config_entries.flow.async_configure(flow_id)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm"
return result
async def async_load_yaml_exclude(hass: HomeAssistant, *uids: str) -> None:
@@ -238,10 +277,16 @@ async def async_load_yaml_exclude(hass: HomeAssistant, *uids: str) -> None:
@pytest.fixture
def mock_entry_setup() -> Generator[None]:
"""Skip full entry setup for config-flow create-entry tests."""
with patch(
"homeassistant.components.izone.async_setup_entry",
return_value=True,
"""Skip full entry setup/unload for config-flow create-entry tests."""
with (
patch(
"homeassistant.components.izone.async_setup_entry",
return_value=True,
),
patch(
"homeassistant.components.izone.async_unload_entry",
return_value=True,
),
):
yield
+307 -76
View File
@@ -1,5 +1,6 @@
"""Tests for iZone config flow."""
import asyncio
from collections.abc import Generator
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
@@ -15,6 +16,8 @@ from homeassistant.data_entry_flow import FlowResultType
from homeassistant.setup import async_setup_component
from .conftest import (
async_finish_user_discover,
async_follow_user_handoff,
async_load_yaml_exclude,
create_mock_controller,
endpoint_from_controller,
@@ -31,10 +34,16 @@ def _make_homekit_info(md: str, host: str | None = None) -> SimpleNamespace:
@pytest.fixture(autouse=True)
def mock_izone_timeouts() -> Generator[None]:
"""Mock iZone idle-stop delay to speed up tests."""
with patch(
"homeassistant.components.izone.discovery.DISCOVERY_IDLE_SECONDS",
0.04,
"""Mock iZone discovery waits so tests do not sleep for real scan timeouts."""
with (
patch(
"homeassistant.components.izone.discovery.DISCOVERY_IDLE_SECONDS",
0.04,
),
patch(
"homeassistant.components.izone.config_flow.USER_SCAN_WAIT_SECONDS",
0,
),
):
yield
@@ -43,14 +52,14 @@ def mock_izone_timeouts() -> Generator[None]:
async def test_user_discovery_success(
hass: HomeAssistant,
) -> None:
"""Test user flow confirms and creates an entry for a discovered controller."""
"""Test user Search hands off to the shelf confirm and creates an entry."""
controller = create_mock_controller("000000001", "192.0.2.55")
with patch_discovered_controllers(controller):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm"
result = await async_finish_user_discover(hass, result)
result = await async_follow_user_handoff(hass, result)
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
await hass.async_block_till_done()
@@ -61,19 +70,22 @@ async def test_user_discovery_success(
@pytest.mark.usefixtures("mock_entry_setup")
async def test_user_discovery_default_selects_first_and_queues_other(
async def test_user_discovery_default_selects_first_and_leaves_other(
hass: HomeAssistant,
) -> None:
"""Default dropdown selection configures first UID and queues the other for confirm."""
"""Default dropdown selection hands off to first UID; other stays on shelf."""
first = create_mock_controller("000000001", "192.0.2.1")
second = create_mock_controller("000000002", "192.0.2.2")
with patch_discovered_controllers([first, second]):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await async_finish_user_discover(hass, result)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "select_controller"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
result = await async_follow_user_handoff(hass, result)
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
await hass.async_block_till_done(wait_background_tasks=True)
assert result["type"] is FlowResultType.CREATE_ENTRY
@@ -96,7 +108,7 @@ async def test_user_discovery_default_selects_first_and_queues_other(
async def test_broadcast_skips_already_configured_controller(
hass: HomeAssistant,
) -> None:
"""Test broadcast discovery skips configured controllers and sets up an unconfigured one."""
"""Search shelf omits configured controllers and hands off the unconfigured one."""
configured_controller = create_mock_controller("000000001", "192.0.2.1")
unconfigured_controller = create_mock_controller("000000002", "192.0.2.2")
MockConfigEntry(
@@ -110,8 +122,8 @@ async def test_broadcast_skips_already_configured_controller(
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm"
result = await async_finish_user_discover(hass, result)
result = await async_follow_user_handoff(hass, result)
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
await hass.async_block_till_done()
@@ -125,7 +137,7 @@ async def test_broadcast_skips_already_configured_controller(
async def test_user_discovery_skips_yaml_excluded_controllers(
hass: HomeAssistant,
) -> None:
"""User flow should not offer controllers excluded by deprecated YAML config."""
"""User Search should not offer controllers excluded by deprecated YAML config."""
excluded_controller = create_mock_controller("000000001", "192.0.2.1")
allowed_controller = create_mock_controller("000000002", "192.0.2.2")
await async_load_yaml_exclude(hass, excluded_controller.device_uid)
@@ -134,8 +146,8 @@ async def test_user_discovery_skips_yaml_excluded_controllers(
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm"
result = await async_finish_user_discover(hass, result)
result = await async_follow_user_handoff(hass, result)
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
await hass.async_block_till_done()
@@ -149,7 +161,7 @@ async def test_user_discovery_skips_yaml_excluded_controllers(
async def test_broadcast_multiple_unconfigured_shows_choice(
hass: HomeAssistant,
) -> None:
"""Test broadcast discovery shows a controller choice when multiple unconfigured controllers are found."""
"""Search shows a controller choice when multiple shelf flows are present."""
first_controller = create_mock_controller("000000002", "192.0.2.1")
second_controller = create_mock_controller("000000001", "192.0.2.2")
@@ -157,6 +169,7 @@ async def test_broadcast_multiple_unconfigured_shows_choice(
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await async_finish_user_discover(hass, result)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "select_controller"
@@ -164,9 +177,15 @@ async def test_broadcast_multiple_unconfigured_shows_choice(
assert len(schema_keys) == 1
assert str(schema_keys[0].schema) == config_flow.SELECTED_CONTROLLER_UID
# Choose one and queue the other as integration discovery (confirm step).
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
# GET re-show (user_input is None) must not submit the default.
rerender = await hass.config_entries.flow.async_configure(result["flow_id"])
assert rerender["type"] is FlowResultType.FORM
assert rerender["step_id"] == "select_controller"
# Default is lowest UID; hand off and leave the other on the shelf.
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
result = await async_follow_user_handoff(hass, result)
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
await hass.async_block_till_done(wait_background_tasks=True)
assert result["type"] is FlowResultType.CREATE_ENTRY
@@ -188,10 +207,10 @@ async def test_broadcast_multiple_unconfigured_shows_choice(
assert progress[0]["context"]["unique_id"] == "000000002"
async def test_select_controller_aborts_when_choices_missing(
async def test_select_controller_rerender_hands_off_when_one_left(
hass: HomeAssistant,
) -> None:
"""Controller selection aborts if discovered choices were lost on the flow."""
"""Re-show after the shelf shrinks to one candidate hands off that flow."""
first_controller = create_mock_controller("000000001", "192.0.2.1")
second_controller = create_mock_controller("000000002", "192.0.2.2")
@@ -199,22 +218,26 @@ async def test_select_controller_aborts_when_choices_missing(
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await async_finish_user_discover(hass, result)
# Public configure cannot clear flow-local discovery state; poke the in-progress
# instance so the empty-choices abort path is exercised.
flow = hass.config_entries.flow._progress[result["flow_id"]]
flow._user_discovered_endpoints = None
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "select_controller"
for progress in hass.config_entries.flow.async_progress_by_handler(DOMAIN):
if progress["context"].get("unique_id") == "000000002":
hass.config_entries.flow.async_abort(progress["flow_id"])
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_devices_found"
assert result["reason"] == "continue_setup"
assert result["next_flow"] is not None
async def test_select_controller_aborts_when_uid_not_in_choices(
async def test_select_controller_rerender_aborts_when_shelf_empty(
hass: HomeAssistant,
) -> None:
"""Controller selection aborts if the submitted UID is not in the choices."""
"""Re-show after every shelf flow is gone aborts no_devices_found."""
first_controller = create_mock_controller("000000001", "192.0.2.1")
second_controller = create_mock_controller("000000002", "192.0.2.2")
@@ -222,23 +245,27 @@ async def test_select_controller_aborts_when_uid_not_in_choices(
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await async_finish_user_discover(hass, result)
# Schema validation rejects unknown UIDs; call the step directly with a UID that
# is not in the discovered set to cover the step's own abort.
flow = hass.config_entries.flow._progress[result["flow_id"]]
result = await flow.async_step_select_controller(
{config_flow.SELECTED_CONTROLLER_UID: "000000099"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "select_controller"
user_flow_id = result["flow_id"]
for progress in hass.config_entries.flow.async_progress_by_handler(DOMAIN):
if progress["flow_id"] != user_flow_id:
hass.config_entries.flow.async_abort(progress["flow_id"])
result = await hass.config_entries.flow.async_configure(user_flow_id)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_devices_found"
@pytest.mark.usefixtures("mock_entry_setup")
async def test_select_controller_creates_selected_uid_and_queues_others(
async def test_select_controller_hands_off_selected_flow_and_leaves_others(
hass: HomeAssistant,
) -> None:
"""A selected controller is configured and non-selected controllers are queued."""
"""A selected shelf flow hands off; non-selected shelf flows remain."""
first_controller = create_mock_controller("000000002", "192.0.2.1")
second_controller = create_mock_controller("000000001", "192.0.2.2")
@@ -246,11 +273,14 @@ async def test_select_controller_creates_selected_uid_and_queues_others(
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await async_finish_user_discover(hass, result)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{config_flow.SELECTED_CONTROLLER_UID: "000000002"},
)
result = await async_follow_user_handoff(hass, result)
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
await hass.async_block_till_done(wait_background_tasks=True)
assert result["type"] is FlowResultType.CREATE_ENTRY
@@ -269,10 +299,89 @@ async def test_select_controller_creates_selected_uid_and_queues_others(
assert skipped_flows[0]["context"]["unique_id"] == "000000001"
async def test_select_controller_aborts_when_uid_not_on_shelf(
hass: HomeAssistant,
) -> None:
"""Abort no_devices_found when the submitted UID is unknown to the shelf."""
first_controller = create_mock_controller("000000001", "192.0.2.1")
second_controller = create_mock_controller("000000002", "192.0.2.2")
with patch_discovered_controllers([first_controller, second_controller]):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await async_finish_user_discover(hass, result)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "select_controller"
flow = hass.config_entries.flow._progress[result["flow_id"]]
result = await flow.async_step_select_controller(
{config_flow.SELECTED_CONTROLLER_UID: "000000099"}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_devices_found"
@pytest.mark.parametrize(
("entry_source", "entry_data"),
[
pytest.param(
config_entries.SOURCE_USER,
{CONF_HOST: "192.0.2.1"},
id="configured",
),
pytest.param(
config_entries.SOURCE_IGNORE,
{},
id="ignored",
),
],
)
async def test_select_controller_aborts_already_configured_when_uid_left_shelf(
hass: HomeAssistant,
entry_source: str,
entry_data: dict[str, str],
) -> None:
"""Abort already_configured when the chosen UID was claimed off the shelf."""
first_controller = create_mock_controller("000000001", "192.0.2.1")
second_controller = create_mock_controller("000000002", "192.0.2.2")
with patch_discovered_controllers([first_controller, second_controller]):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await async_finish_user_discover(hass, result)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "select_controller"
claimed_uid = "000000001"
for progress in hass.config_entries.flow.async_progress_by_handler(DOMAIN):
if progress["context"].get("unique_id") == claimed_uid:
hass.config_entries.flow.async_abort(progress["flow_id"])
MockConfigEntry(
domain=DOMAIN,
unique_id=claimed_uid,
source=entry_source,
data=entry_data,
version=2,
).add_to_hass(hass)
flow = hass.config_entries.flow._progress[result["flow_id"]]
result = await flow.async_step_select_controller(
{config_flow.SELECTED_CONTROLLER_UID: claimed_uid}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_broadcast_aborts_when_all_discovered_are_configured(
hass: HomeAssistant,
) -> None:
"""Test broadcast discovery aborts when every discovered controller is configured."""
"""Search aborts when every noted controller is already configured."""
configured_controller = create_mock_controller("000000001", "192.0.2.1")
MockConfigEntry(
domain=DOMAIN,
@@ -285,20 +394,16 @@ async def test_broadcast_aborts_when_all_discovered_are_configured(
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await async_finish_user_discover(hass, result)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert result["reason"] == "no_devices_found"
async def test_user_flow_aborts_when_all_discovered_are_ignored(
hass: HomeAssistant,
) -> None:
"""User flow aborts when every discovered controller has been explicitly ignored.
_async_get_unconfigured_controllers uses include_ignore=True so controllers
whose entries carry SOURCE_IGNORE are not re-offered as configurable, respecting
the user's earlier choice to dismiss them.
"""
"""Search aborts when every noted controller is ignored (no shelf flow)."""
ignored_controller = create_mock_controller("000000001", "192.0.2.1")
MockConfigEntry(
domain=DOMAIN,
@@ -311,9 +416,10 @@ async def test_user_flow_aborts_when_all_discovered_are_ignored(
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await async_finish_user_discover(hass, result)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert result["reason"] == "no_devices_found"
async def test_import_aborts_when_another_izone_flow_in_progress(
@@ -325,8 +431,7 @@ async def test_import_aborts_when_another_izone_flow_in_progress(
user_flow = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert user_flow["type"] is FlowResultType.FORM
assert user_flow["step_id"] == "confirm"
assert user_flow["type"] is FlowResultType.SHOW_PROGRESS
result = await hass.config_entries.flow.async_init(
DOMAIN,
@@ -376,17 +481,113 @@ async def test_import_aborts_when_discovery_bind_fails(hass: HomeAssistant) -> N
async def test_user_flow_aborts_when_discovery_bind_fails(hass: HomeAssistant) -> None:
"""User flow aborts when discovery cannot bind the UDP socket."""
with patch(
"homeassistant.components.izone.discovery.async_discover_all_endpoints",
"homeassistant.components.izone.discovery.async_scan",
new=AsyncMock(side_effect=OSError("bind failed")),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await async_finish_user_discover(hass, result)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "discovery_failed"
async def test_user_discover_reshows_progress_while_scan_running(
hass: HomeAssistant,
) -> None:
"""Re-entering discover while the scan task is pending keeps SHOW_PROGRESS."""
release = asyncio.Event()
async def _blocked_scan(_hass: HomeAssistant) -> None:
await release.wait()
with patch(
"homeassistant.components.izone.discovery.async_scan",
new=AsyncMock(side_effect=_blocked_scan),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["progress_action"] == "discover"
still_progress = await hass.config_entries.flow.async_configure(
result["flow_id"]
)
assert still_progress["type"] is FlowResultType.SHOW_PROGRESS
assert still_progress["progress_action"] == "discover"
release.set()
await hass.async_block_till_done(wait_background_tasks=True)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_devices_found"
async def test_user_search_skips_peer_user_flow_when_building_candidates(
hass: HomeAssistant,
) -> None:
"""Concurrent SOURCE_USER flows are not treated as shelf candidates."""
controller = create_mock_controller("000000001", "192.0.2.1")
with patch_discovered_controllers(controller):
first = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
second = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert first["type"] is FlowResultType.SHOW_PROGRESS
assert second["type"] is FlowResultType.SHOW_PROGRESS
first = await async_finish_user_discover(hass, first)
assert first["type"] is FlowResultType.ABORT
assert first["reason"] == "continue_setup"
assert first["next_flow"] is not None
assert second["flow_id"] in {
progress["flow_id"]
for progress in hass.config_entries.flow.async_progress_by_handler(DOMAIN)
}
async def test_user_candidates_skips_discovery_without_host_placeholder(
hass: HomeAssistant,
) -> None:
"""Discovery flows missing a string host placeholder are not offered."""
first = create_mock_controller("000000001", "192.0.2.1")
second = create_mock_controller("000000002", "192.0.2.2")
with patch_discovered_controllers([first, second]):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await async_finish_user_discover(hass, result)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "select_controller"
flow = hass.config_entries.flow._progress[result["flow_id"]]
incomplete = {
"flow_id": "incomplete-discovery",
"handler": DOMAIN,
"context": {
"source": config_entries.SOURCE_INTEGRATION_DISCOVERY,
"unique_id": "000000099",
},
"step_id": "confirm",
}
progress = list(hass.config_entries.flow.async_progress_by_handler(DOMAIN))
with patch.object(
hass.config_entries.flow,
"async_progress_by_handler",
return_value=[incomplete, *progress],
):
candidates = flow._async_user_candidates()
assert {candidate.uid for candidate in candidates} == {"000000001", "000000002"}
@pytest.mark.usefixtures("mock_entry_setup")
async def test_homekit_confirm_uses_discovered_host(
hass: HomeAssistant,
@@ -408,7 +609,10 @@ async def test_homekit_confirm_uses_discovered_host(
for flow in hass.config_entries.flow.async_progress()
if flow["flow_id"] == result["flow_id"]
)
assert flow["context"]["title_placeholders"] == {"name": "iZone 000000001"}
assert flow["context"]["title_placeholders"] == {
"name": "iZone 000000001",
"host": "192.0.2.3",
}
assert result["description_placeholders"] == {
"controller_uid": "000000001",
"host": "192.0.2.3",
@@ -495,17 +699,19 @@ async def test_homekit_flow_sets_device_uid_once(
@pytest.mark.usefixtures("mock_entry_setup")
async def test_homekit_aborts_while_user_confirm_is_open(
async def test_homekit_aborts_while_user_select_is_open(
hass: HomeAssistant,
) -> None:
"""HomeKit onboarding for same UID is blocked while a user flow is already active."""
controller = create_mock_controller("000000001", "192.0.2.3")
with patch_discovered_controllers(controller):
"""HomeKit onboarding for same UID is blocked while user Search select is open."""
first = create_mock_controller("000000001", "192.0.2.3")
second = create_mock_controller("000000002", "192.0.2.4")
with patch_discovered_controllers([first, second]):
user_flow = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
user_flow = await async_finish_user_discover(hass, user_flow)
assert user_flow["type"] is FlowResultType.FORM
assert user_flow["step_id"] == "confirm"
assert user_flow["step_id"] == "select_controller"
result = await hass.config_entries.flow.async_init(
DOMAIN,
@@ -517,12 +723,13 @@ async def test_homekit_aborts_while_user_confirm_is_open(
assert result["reason"] == "already_in_progress"
async def test_user_broadcast_aborts_when_homekit_flow_in_progress(
async def test_user_search_allowed_while_homekit_flow_in_progress(
hass: HomeAssistant,
) -> None:
"""Test user broadcast discovery aborts when a HomeKit flow is already active."""
controller = create_mock_controller("000000001", "192.0.2.3")
with patch_discovered_controllers(controller):
"""User Search may start while a HomeKit confirm flow is already open."""
homekit_controller = create_mock_controller("000000001", "192.0.2.3")
other_controller = create_mock_controller("000000002", "192.0.2.4")
with patch_discovered_controllers([homekit_controller, other_controller]):
homekit_flow = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_HOMEKIT},
@@ -532,14 +739,15 @@ async def test_user_broadcast_aborts_when_homekit_flow_in_progress(
assert homekit_flow["type"] is FlowResultType.FORM
assert homekit_flow["step_id"] == "confirm"
user_flow = await hass.config_entries.flow.async_init(
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
result = user_flow
assert result["type"] is FlowResultType.SHOW_PROGRESS
result = await async_finish_user_discover(hass, result)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_in_progress"
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "select_controller"
async def test_homekit_aborts_when_uid_already_configured(
@@ -698,6 +906,7 @@ async def test_user_flow_aborts_when_no_controllers_found(hass: HomeAssistant) -
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await async_finish_user_discover(hass, result)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_devices_found"
@@ -877,10 +1086,10 @@ async def test_runtime_integration_discovery_skips_for_ignored_unique_id(
assert not hass.config_entries.flow.async_progress_by_handler(DOMAIN)
async def test_runtime_integration_discovery_skips_during_user_select_controller_step(
async def test_runtime_integration_discovery_allows_during_user_select_controller_step(
hass: HomeAssistant,
) -> None:
"""Do not stack auto discovery while the user is choosing discovered controllers."""
"""Runtime discovery may add shelf flows while the user is choosing controllers."""
MockConfigEntry(
domain=DOMAIN,
unique_id="000000001",
@@ -893,33 +1102,48 @@ async def test_runtime_integration_discovery_skips_during_user_select_controller
user_flow = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
user_flow = await async_finish_user_discover(hass, user_flow)
assert user_flow["type"] is FlowResultType.FORM
assert user_flow["step_id"] == "select_controller"
new_ctrl = create_mock_controller("000000002", "192.0.2.2")
discovery_flows = [
flow
for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN)
if flow["context"]["source"] == config_entries.SOURCE_INTEGRATION_DISCOVERY
]
assert len(discovery_flows) == 2
with patch(
"homeassistant.helpers.discovery_flow.async_create_flow"
) as mock_create_flow:
izone_discovery.async_note_integration_discovery(
hass, endpoint_from_controller(new_ctrl)
)
await hass.async_block_till_done(wait_background_tasks=True)
# Re-noting an existing shelf UID must not stack another flow.
izone_discovery.async_note_integration_discovery(
hass, endpoint_from_controller(first)
)
await hass.async_block_till_done(wait_background_tasks=True)
mock_create_flow.assert_not_called()
discovery_flows = [
flow
for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN)
if flow["context"]["source"] == config_entries.SOURCE_INTEGRATION_DISCOVERY
]
assert len(discovery_flows) == 2
assert {flow["context"]["unique_id"] for flow in discovery_flows} == {
first.device_uid,
second.device_uid,
}
@pytest.mark.usefixtures("mock_entry_setup")
async def test_runtime_integration_discovery_skips_during_user_confirm(
async def test_runtime_integration_discovery_allows_during_user_confirm(
hass: HomeAssistant,
) -> None:
"""Runtime discovery stays suppressed while an interactive user flow is active."""
"""Runtime discovery may add shelf flows while a shelf confirm step is open."""
first = create_mock_controller("000000001", "192.0.2.1")
second = create_mock_controller("000000002", "192.0.2.2")
with patch_discovered_controllers(first):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await async_finish_user_discover(hass, result)
result = await async_follow_user_handoff(hass, result)
assert result["step_id"] == "confirm"
izone_discovery.async_note_integration_discovery(
@@ -928,8 +1152,11 @@ async def test_runtime_integration_discovery_skips_during_user_confirm(
await hass.async_block_till_done(wait_background_tasks=True)
progress = hass.config_entries.flow.async_progress_by_handler(DOMAIN)
assert len(progress) == 1
assert progress[0]["context"]["source"] == config_entries.SOURCE_USER
assert len(progress) == 2
sources = {flow["context"]["source"] for flow in progress}
assert sources == {
config_entries.SOURCE_INTEGRATION_DISCOVERY,
}
async def test_async_setup_starts_import_flow(hass: HomeAssistant) -> None:
@@ -976,6 +1203,8 @@ async def test_confirm_asserts_when_controller_data_is_missing(
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await async_finish_user_discover(hass, result)
result = await async_follow_user_handoff(hass, result)
# Corrupt flow-local state that the public path always sets before confirm.
flow = hass.config_entries.flow._progress[result["flow_id"]]
@@ -994,6 +1223,8 @@ async def test_confirm_asserts_when_unique_id_is_not_string(
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await async_finish_user_discover(hass, result)
result = await async_follow_user_handoff(hass, result)
flow = hass.config_entries.flow._progress[result["flow_id"]]
flow.context["unique_id"] = None
+16
View File
@@ -519,6 +519,22 @@ async def test_discover_all_endpoints(
mock_service.discover_all.assert_awaited_once()
async def test_scan(
hass: HomeAssistant,
mock_pizone_create_discovery: tuple[AsyncMock, Mock],
) -> None:
"""User Search broadcasts on an already-running service, not discover_all."""
_, mock_service = mock_pizone_create_discovery
await izone_discovery.async_ensure_discovery(hass)
mock_service.scan.reset_mock()
await izone_discovery.async_scan(hass)
mock_service.scan.assert_awaited_once()
mock_service.discover_all.assert_not_called()
async def test_discover_endpoint_by_uid(
hass: HomeAssistant,
mock_pizone_create_discovery: tuple[AsyncMock, Mock],