Add iZone manual host entry to the user config flow (#178805)

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
This commit is contained in:
Penny Wood
2026-09-17 18:05:52 +02:00
committed by GitHub
co-authored by Simon Lamon
parent 437f44e199
commit f932260b94
6 changed files with 672 additions and 55 deletions
+138 -19
View File
@@ -14,7 +14,6 @@ from homeassistant import config_entries
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, FlowType
from homeassistant.const import CONF_HOST
from homeassistant.core import callback
from homeassistant.helpers import discovery_flow
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
@@ -34,6 +33,8 @@ SELECTED_CONTROLLER_UID = "selected_controller_uid"
# Wait after IASD for ASPort replies (matches pizone discover_all wait).
USER_SCAN_WAIT_SECONDS = SCAN_TIMEOUT
STEP_MANUAL_HOST_SCHEMA = probatio.Schema({probatio.Required(CONF_HOST): str})
@dataclass(frozen=True, slots=True)
class _ShelfCandidate:
@@ -57,7 +58,8 @@ class IZoneConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 2
_discovered_controller_ip: str | None = None
_discovered_controller_host: str | None = None
_discovered_controller_uid: str | None = None
_user_discovery_task: asyncio.Task[None] | None = None
_user_discovery_failed: bool = False
@@ -103,8 +105,11 @@ class IZoneConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user(
self, _user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""User-started flow: search the LAN, then offer discovered controllers."""
return await self.async_step_discover()
"""User-started flow: search the LAN or enter a controller host."""
return self.async_show_menu(
step_id="user",
menu_options=["discover", "manual_host"],
)
async def _async_run_user_discovery(self) -> None:
"""Scan and wait for the progress step (no unique_id work here)."""
@@ -147,14 +152,16 @@ class IZoneConfigFlow(ConfigFlow, domain=DOMAIN):
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."""
"""After Search scan: nudge to Enter host, hand off, 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")
return self._async_show_manual_host_form(
errors={"base": "no_devices_found"}
)
if len(candidates) == 1:
return self.async_abort(
reason="continue_setup",
@@ -208,6 +215,21 @@ class IZoneConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="already_configured")
return self.async_abort(reason="no_devices_found")
async def async_step_manual_host(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Enter a controller IP or hostname, then hand off or confirm."""
if user_input is None:
return self._async_show_manual_host_form()
host = user_input[CONF_HOST].strip()
if not host:
return self._async_show_manual_host_form(
errors={CONF_HOST: "required"},
suggested_values=user_input,
)
return await self._async_manual_host_submit(host)
@override
async def async_step_homekit(
self, discovery_info: ZeroconfServiceInfo
@@ -244,7 +266,7 @@ class IZoneConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.debug("Unable to start iZone discovery service", exc_info=True)
return self.async_abort(reason="discovery_failed")
self._discovered_controller_ip = endpoint.host
self._discovered_controller_host = endpoint.host
# Re-check after awaiting discovery to catch mid-flight configuration.
self._abort_if_unique_id_configured()
@@ -269,15 +291,15 @@ class IZoneConfigFlow(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(uid)
self._abort_if_unique_id_configured()
# Persist through confirm into entry data as CONF_HOST.
self._discovered_controller_ip = host
self._discovered_controller_host = host
return await self.async_step_confirm()
async def async_step_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm adding a controller found via HomeKit or discovery."""
controller_uid = self.unique_id
host = self._discovered_controller_ip
"""Confirm adding a controller found via HomeKit, discovery, or Ignore replace."""
controller_uid = self.unique_id or self._discovered_controller_uid
host = self._discovered_controller_host
assert isinstance(controller_uid, str)
assert controller_uid
assert host is not None
@@ -327,14 +349,114 @@ class IZoneConfigFlow(ConfigFlow, domain=DOMAIN):
return sorted(candidates, key=lambda candidate: (candidate.uid, candidate.host))
@callback
def _async_schedule_integration_discovery_flow(
def _async_shelf_candidate_for_host(self, host: str) -> _ShelfCandidate | None:
"""Return the shelf candidate whose host matches *host*, if any."""
for candidate in self._async_user_candidates():
if candidate.host == host:
return candidate
return None
@callback
def _async_shelf_candidate_for_uid(self, uid: str) -> _ShelfCandidate | None:
"""Return the shelf candidate whose UID matches *uid*, if any."""
for candidate in self._async_user_candidates():
if candidate.uid == uid:
return candidate
return None
@callback
def _async_handoff_to_shelf(self, candidate: _ShelfCandidate) -> ConfigFlowResult:
"""Abort the user flow into the shelf confirm for *candidate*."""
return self.async_abort(
reason="continue_setup",
next_flow=(FlowType.CONFIG_FLOW, candidate.flow_id),
)
@callback
def _async_show_manual_host_form(
self,
*,
errors: dict[str, str] | None = None,
suggested_values: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Show the Enter host form."""
return self.async_show_form(
step_id="manual_host",
data_schema=self.add_suggested_values_to_schema(
STEP_MANUAL_HOST_SCHEMA, suggested_values
),
errors=errors,
)
async def _async_manual_host_submit(self, host: str) -> ConfigFlowResult:
"""Handoff a shelf hit, else probe and shelve or Ignore-replace."""
# Placeholder host is good enough to skip a probe; after probe, match UID.
if (candidate := self._async_shelf_candidate_for_host(host)) is not None:
return self._async_handoff_to_shelf(candidate)
try:
endpoint = await izone_discovery.async_discover_by_host(self.hass, host)
except OSError:
_LOGGER.debug("Unable to start iZone discovery service", exc_info=True)
return self.async_abort(reason="discovery_failed")
except pizone.UnpairedBridgeError:
return self._async_show_manual_host_form(
errors={"base": "unpaired_bridge"},
suggested_values={CONF_HOST: host},
)
except pizone.ControllerAlreadyClaimedError:
return self._async_show_manual_host_form(
errors={"base": "already_configured"},
suggested_values={CONF_HOST: host},
)
if endpoint is None:
return self._async_show_manual_host_form(
errors={"base": "cannot_connect"},
suggested_values={CONF_HOST: host},
)
if endpoint.uid in izone_discovery.yaml_excluded_uids(self.hass):
return self._async_show_manual_host_form(
errors={"base": "no_devices_found"},
suggested_values={CONF_HOST: host},
)
existing = self.hass.config_entries.async_entry_for_domain_unique_id(
DOMAIN, endpoint.uid
)
if existing is not None:
if existing.source == config_entries.SOURCE_IGNORE:
self._discovered_controller_uid = endpoint.uid
self._discovered_controller_host = endpoint.host
return await self.async_step_confirm()
return self._async_show_manual_host_form(
errors={"base": "already_configured"},
suggested_values={CONF_HOST: host},
)
if (candidate := self._async_shelf_candidate_for_uid(endpoint.uid)) is not None:
if candidate.host != endpoint.host:
# Shelf still shows an older discovery address; replace that card.
self.hass.config_entries.flow.async_abort(candidate.flow_id)
else:
return self._async_handoff_to_shelf(candidate)
await self._async_shelve_integration_discovery_flow(endpoint.uid, endpoint.host)
if (candidate := self._async_shelf_candidate_for_uid(endpoint.uid)) is not None:
return self._async_handoff_to_shelf(candidate)
return self._async_show_manual_host_form(
errors={"base": "no_devices_found"},
suggested_values={CONF_HOST: host},
)
async def _async_shelve_integration_discovery_flow(
self,
uid: str,
host: str,
) -> None:
"""Queue integration discovery (import fan-out or HomeKit sibling)."""
discovery_flow.async_create_flow(
self.hass,
"""Await a shelf confirm flow for manual host."""
await self.hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": config_entries.SOURCE_INTEGRATION_DISCOVERY,
@@ -378,7 +500,4 @@ class IZoneConfigFlow(ConfigFlow, domain=DOMAIN):
continue
if candidate.uid in current_ids or candidate.uid in in_progress_ids:
continue
self._async_schedule_integration_discovery_flow(
candidate.uid,
candidate.host,
)
izone_discovery.async_note_integration_discovery(self.hass, candidate)
@@ -234,6 +234,22 @@ async def async_discover_endpoint(
return await service.discover_by_uid(uid)
async def async_discover_by_host(
hass: HomeAssistant, host: str
) -> pizone.ControllerEndpoint | None:
"""HTTP-probe a controller at *host* for config-flow validation.
Starts shared discovery if needed.
Raises:
OSError: Discovery UDP socket could not be bound.
UnpairedBridgeError: Probed UID is the unpaired placeholder.
ControllerAlreadyClaimedError: Host or UID is already claimed on the service.
"""
service = await async_ensure_discovery(hass)
return await service.discover_by_host(host)
async def async_maybe_stop_discovery(hass: HomeAssistant) -> None:
"""Stop discovery when nothing actionable remains.
+21 -1
View File
@@ -6,7 +6,15 @@
"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%]"
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
"unpaired_bridge": "This iZone bridge is not paired with an air conditioner."
},
"error": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
"required": "Please enter a host.",
"unpaired_bridge": "This iZone bridge is not paired with an air conditioner."
},
"flow_title": "{name}",
"progress": {
@@ -16,11 +24,23 @@
"confirm": {
"description": "Do you want to set up iZone?\n\nController UID: {controller_uid}\nController IP: {host}"
},
"manual_host": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"description": "Enter the IP address or hostname of your iZone controller."
},
"select_controller": {
"data": {
"selected_controller_uid": "Controller"
},
"description": "Multiple unconfigured iZone controllers were found:\n{controllers}\n\nChoose the controller you want to set up now. Any controller you do not select will remain available as a discovered device you can set up later under **Settings** > **Devices & services**."
},
"user": {
"menu_options": {
"discover": "Search for devices",
"manual_host": "Enter host"
}
}
}
},
+39 -3
View File
@@ -212,9 +212,15 @@ def patch_discovered_controllers(
async def _discover_one(hass: HomeAssistant, uid: str) -> ControllerEndpoint | None:
return endpoints.get(uid)
async def _discover_by_host(
hass: HomeAssistant, host: str
) -> ControllerEndpoint | None:
return next((ep for ep in endpoints.values() if ep.host == host), None)
mock_discover_all = AsyncMock(side_effect=_discover_all)
mock_scan = AsyncMock(side_effect=_scan)
mock_discover_one = AsyncMock(side_effect=_discover_one)
mock_discover_by_host = AsyncMock(side_effect=_discover_by_host)
with (
patch(
"homeassistant.components.izone.discovery.async_discover_all_endpoints",
@@ -228,20 +234,50 @@ def patch_discovered_controllers(
"homeassistant.components.izone.discovery.async_discover_endpoint",
new=mock_discover_one,
),
patch(
"homeassistant.components.izone.discovery.async_discover_by_host",
new=mock_discover_by_host,
),
):
yield mock_discover_all, mock_discover_one, mock_scan
async def async_start_user_discover(
hass: HomeAssistant, result: FlowResult
) -> FlowResult:
"""Select Search from the user menu and return the progress step."""
if result["type"] is FlowResultType.MENU:
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": "discover"}
)
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["progress_action"] == "discover"
return result
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"
"""Advance a user Search flow past the menu and SHOW_PROGRESS discover."""
result = await async_start_user_discover(hass, result)
await hass.async_block_till_done(wait_background_tasks=True)
return await hass.config_entries.flow.async_configure(result["flow_id"])
async def async_choose_manual_host(
hass: HomeAssistant, result: FlowResult
) -> FlowResult:
"""Select Enter host from the user menu."""
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": "manual_host"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual_host"
return result
async def async_follow_user_handoff(
hass: HomeAssistant, result: FlowResult
) -> FlowResult:
+442 -32
View File
@@ -5,6 +5,7 @@ from collections.abc import Generator
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
import pizone
import pytest
from homeassistant import config_entries
@@ -16,9 +17,11 @@ from homeassistant.data_entry_flow import FlowResultType
from homeassistant.setup import async_setup_component
from .conftest import (
async_choose_manual_host,
async_finish_user_discover,
async_follow_user_handoff,
async_load_yaml_exclude,
async_start_user_discover,
create_mock_controller,
endpoint_from_controller,
patch_discovered_controllers,
@@ -234,10 +237,10 @@ async def test_select_controller_rerender_hands_off_when_one_left(
assert result["next_flow"] is not None
async def test_select_controller_rerender_aborts_when_shelf_empty(
async def test_select_controller_rerender_nudges_manual_host_when_shelf_empty(
hass: HomeAssistant,
) -> None:
"""Re-show after every shelf flow is gone aborts no_devices_found."""
"""Re-show after every shelf flow is gone opens Enter host with an error."""
first_controller = create_mock_controller("000000001", "192.0.2.1")
second_controller = create_mock_controller("000000002", "192.0.2.2")
@@ -257,8 +260,9 @@ async def test_select_controller_rerender_aborts_when_shelf_empty(
result = await hass.config_entries.flow.async_configure(user_flow_id)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_devices_found"
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual_host"
assert result["errors"] == {"base": "no_devices_found"}
@pytest.mark.usefixtures("mock_entry_setup")
@@ -378,10 +382,10 @@ async def test_select_controller_aborts_already_configured_when_uid_left_shelf(
assert result["reason"] == "already_configured"
async def test_broadcast_aborts_when_all_discovered_are_configured(
async def test_broadcast_nudges_manual_host_when_all_discovered_are_configured(
hass: HomeAssistant,
) -> None:
"""Search aborts when every noted controller is already configured."""
"""Search opens Enter host when every noted controller is already configured."""
configured_controller = create_mock_controller("000000001", "192.0.2.1")
MockConfigEntry(
domain=DOMAIN,
@@ -396,14 +400,15 @@ async def test_broadcast_aborts_when_all_discovered_are_configured(
)
result = await async_finish_user_discover(hass, result)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_devices_found"
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual_host"
assert result["errors"] == {"base": "no_devices_found"}
async def test_user_flow_aborts_when_all_discovered_are_ignored(
async def test_user_flow_nudges_manual_host_when_all_discovered_are_ignored(
hass: HomeAssistant,
) -> None:
"""Search aborts when every noted controller is ignored (no shelf flow)."""
"""Search opens Enter host when every noted controller is ignored (no shelf)."""
ignored_controller = create_mock_controller("000000001", "192.0.2.1")
MockConfigEntry(
domain=DOMAIN,
@@ -418,8 +423,9 @@ async def test_user_flow_aborts_when_all_discovered_are_ignored(
)
result = await async_finish_user_discover(hass, result)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_devices_found"
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual_host"
assert result["errors"] == {"base": "no_devices_found"}
async def test_import_aborts_when_another_izone_flow_in_progress(
@@ -431,7 +437,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.SHOW_PROGRESS
assert user_flow["type"] is FlowResultType.MENU
result = await hass.config_entries.flow.async_init(
DOMAIN,
@@ -509,6 +515,7 @@ async def test_user_discover_reshows_progress_while_scan_running(
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await async_start_user_discover(hass, result)
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["progress_action"] == "discover"
@@ -522,8 +529,9 @@ async def test_user_discover_reshows_progress_while_scan_running(
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"
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual_host"
assert result["errors"] == {"base": "no_devices_found"}
async def test_user_search_skips_peer_user_flow_when_building_candidates(
@@ -538,8 +546,8 @@ async def test_user_search_skips_peer_user_flow_when_building_candidates(
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
assert first["type"] is FlowResultType.MENU
assert second["type"] is FlowResultType.MENU
first = await async_finish_user_discover(hass, first)
@@ -743,7 +751,7 @@ async def test_user_search_allowed_while_homekit_flow_in_progress(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["type"] is FlowResultType.MENU
result = await async_finish_user_discover(hass, result)
assert result["type"] is FlowResultType.FORM
@@ -900,16 +908,17 @@ async def test_homekit_aborts_when_discovery_bind_fails(hass: HomeAssistant) ->
assert result["reason"] == "discovery_failed"
async def test_user_flow_aborts_when_no_controllers_found(hass: HomeAssistant) -> None:
"""User flow aborts when broadcast discovery returns no controllers."""
async def test_user_search_empty_nudges_manual_host(hass: HomeAssistant) -> None:
"""Empty Search shows Enter host with no_devices_found instead of aborting."""
with patch_discovered_controllers([]):
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"
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual_host"
assert result["errors"] == {"base": "no_devices_found"}
async def test_homekit_without_model_aborts(
@@ -1208,7 +1217,7 @@ async def test_confirm_asserts_when_controller_data_is_missing(
# Corrupt flow-local state that the public path always sets before confirm.
flow = hass.config_entries.flow._progress[result["flow_id"]]
flow._discovered_controller_ip = None
flow._discovered_controller_host = None
with pytest.raises(AssertionError):
await flow.async_step_confirm()
@@ -1234,25 +1243,26 @@ async def test_confirm_asserts_when_unique_id_is_not_string(
def test_async_fan_out_skips_uids_already_in_progress() -> None:
"""Fan-out skips scheduling flows for UIDs already in progress."""
"""Fan-out skips noting discovery for UIDs already in progress."""
candidate = endpoint_from_controller(
create_mock_controller("000000002", "192.0.2.2")
)
# Drive the helper with a stub flow: happy-path fan-out tests only cover the
# "schedule missing UIDs" branch, not the already-in-progress skip.
# "note missing UIDs" branch, not the already-in-progress skip.
fake_flow = SimpleNamespace(
hass=object(),
_async_current_ids=Mock(return_value=set()),
_async_in_progress=Mock(return_value=[{"context": {"unique_id": "000000002"}}]),
_async_schedule_integration_discovery_flow=Mock(),
)
config_flow.IZoneConfigFlow._async_fan_out_discovered_endpoints(
fake_flow,
[candidate],
selected_uid="000000001",
)
with patch.object(izone_discovery, "async_note_integration_discovery") as mock_note:
config_flow.IZoneConfigFlow._async_fan_out_discovered_endpoints(
fake_flow,
[candidate],
selected_uid="000000001",
)
fake_flow._async_schedule_integration_discovery_flow.assert_not_called()
mock_note.assert_not_called()
async def test_homekit_aborts_for_yaml_excluded_uid_without_discovery(
@@ -1339,3 +1349,403 @@ async def test_async_migrate_entry_clears_legacy_data(
assert entry.version == 2
assert entry.data == {}
@pytest.mark.usefixtures("mock_entry_setup")
async def test_user_menu_always_offers_search_and_host(
hass: HomeAssistant,
) -> None:
"""User start is always a menu, including when an entry is already loaded."""
MockConfigEntry(
domain=DOMAIN,
unique_id="000000001",
data={CONF_HOST: "192.0.2.1"},
version=2,
).add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
assert result["menu_options"] == ["discover", "manual_host"]
@pytest.mark.usefixtures("mock_entry_setup")
async def test_user_manual_host_success_shelves_and_handoff(
hass: HomeAssistant,
) -> None:
"""Probe of an unknown host schedules a shelf flow and hands off."""
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}
)
result = await async_choose_manual_host(hass, result)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: "192.0.2.55"}
)
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()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "iZone 000000001"
assert result["data"] == {CONF_HOST: "192.0.2.55"}
assert result["result"].unique_id == "000000001"
@pytest.mark.usefixtures("mock_entry_setup")
async def test_user_manual_host_matching_shelf_skips_probe(
hass: HomeAssistant,
) -> None:
"""Typing a host already on the Discovered shelf hands off without probing."""
controller = create_mock_controller("000000001", "192.0.2.55")
with (
patch_discovered_controllers(controller),
patch(
"homeassistant.components.izone.discovery.async_discover_by_host",
new=AsyncMock(),
) as mock_probe,
):
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)
shelf_flow_id = result["flow_id"]
menu = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
host_form = await async_choose_manual_host(hass, menu)
result = await hass.config_entries.flow.async_configure(
host_form["flow_id"], {CONF_HOST: "192.0.2.55"}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "continue_setup"
assert result["next_flow"] == (config_entries.FlowType.CONFIG_FLOW, shelf_flow_id)
mock_probe.assert_not_called()
@pytest.mark.usefixtures("mock_entry_setup")
async def test_user_manual_host_handoff_by_uid_when_shelf_host_stale(
hass: HomeAssistant,
) -> None:
"""Stale shelf host is replaced; handoff confirm/create use the probed host."""
stale = create_mock_controller("000000001", "10.0.0.1")
current = create_mock_controller("000000001", "192.0.2.55")
with patch_discovered_controllers(stale):
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)
stale_shelf_flow_id = result["flow_id"]
with patch_discovered_controllers(current):
menu = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
host_form = await async_choose_manual_host(hass, menu)
result = await hass.config_entries.flow.async_configure(
host_form["flow_id"], {CONF_HOST: "192.0.2.55"}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "continue_setup"
next_flow = result["next_flow"]
assert next_flow is not None
_flow_type, shelf_flow_id = next_flow
assert shelf_flow_id != stale_shelf_flow_id
result = await async_follow_user_handoff(hass, result)
assert result["description_placeholders"]["host"] == "192.0.2.55"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {CONF_HOST: "192.0.2.55"}
assert stale_shelf_flow_id not in hass.config_entries.flow._progress
@pytest.mark.usefixtures("mock_entry_setup")
async def test_user_manual_host_yaml_excluded_stays_on_form(
hass: HomeAssistant,
) -> None:
"""Probed UID listed in YAML exclude redisplays Enter host."""
await async_load_yaml_exclude(hass, "000000001")
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}
)
result = await async_choose_manual_host(hass, result)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: "192.0.2.55"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual_host"
assert result["errors"] == {"base": "no_devices_found"}
@pytest.mark.usefixtures("mock_entry_setup")
async def test_user_manual_host_yaml_excluded_ignored_uid_stays_on_form(
hass: HomeAssistant,
) -> None:
"""YAML exclude wins over Ignore replacement, matching other discovery paths."""
await async_load_yaml_exclude(hass, "000000001")
MockConfigEntry(
domain=DOMAIN,
unique_id="000000001",
source=config_entries.SOURCE_IGNORE,
data={},
).add_to_hass(hass)
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}
)
result = await async_choose_manual_host(hass, result)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: "192.0.2.55"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual_host"
assert result["errors"] == {"base": "no_devices_found"}
@pytest.mark.usefixtures("mock_entry_setup")
async def test_user_manual_host_ignored_uid_confirms_without_unique_id(
hass: HomeAssistant,
) -> None:
"""Typed host of an ignored UID confirms in-flow and replaces Ignore on create."""
ignored = MockConfigEntry(
domain=DOMAIN,
unique_id="000000001",
source=config_entries.SOURCE_IGNORE,
data={},
)
ignored.add_to_hass(hass)
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}
)
result = await async_choose_manual_host(hass, result)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: "192.0.2.55"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm"
user_progress = hass.config_entries.flow.async_get(result["flow_id"])
assert user_progress["context"].get("unique_id") is None
retry = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
retry = await async_choose_manual_host(hass, retry)
retry = await hass.config_entries.flow.async_configure(
retry["flow_id"], {CONF_HOST: "192.0.2.55"}
)
assert retry["type"] is FlowResultType.FORM
assert retry["step_id"] == "confirm"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["result"].unique_id == "000000001"
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
assert entries[0].source != config_entries.SOURCE_IGNORE
async def test_user_manual_host_empty_rejected_by_schema(hass: HomeAssistant) -> None:
"""Whitespace-only host is a required-field error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await async_choose_manual_host(hass, result)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: " "}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual_host"
assert result["errors"] == {CONF_HOST: "required"}
async def test_user_manual_host_unreachable(hass: HomeAssistant) -> None:
"""Unreachable host redisplays the form with cannot_connect."""
with patch(
"homeassistant.components.izone.discovery.async_discover_by_host",
new=AsyncMock(return_value=None),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await async_choose_manual_host(hass, result)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: "192.0.2.99"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual_host"
assert result["errors"] == {"base": "cannot_connect"}
async def test_user_manual_host_already_configured_stays_on_form(
hass: HomeAssistant,
) -> None:
"""Loaded entry for the probed UID redisplays Enter host with an error."""
MockConfigEntry(
domain=DOMAIN,
unique_id="000000001",
data={CONF_HOST: "10.0.0.90"},
version=2,
).add_to_hass(hass)
controller = create_mock_controller("000000001", "10.0.0.90")
with patch_discovered_controllers(controller):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await async_choose_manual_host(hass, result)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: "10.0.0.90"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual_host"
assert result["errors"] == {"base": "already_configured"}
async def test_user_manual_host_unpaired_stays_on_form(hass: HomeAssistant) -> None:
"""Unpaired placeholder UID redisplays Enter host with an error."""
with patch(
"homeassistant.components.izone.discovery.async_discover_by_host",
new=AsyncMock(side_effect=pizone.UnpairedBridgeError("unpaired")),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await async_choose_manual_host(hass, result)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: "192.0.2.111"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual_host"
assert result["errors"] == {"base": "unpaired_bridge"}
async def test_user_manual_host_claimed_stays_on_form(hass: HomeAssistant) -> None:
"""Claimed controller on the discovery service redisplays Enter host."""
with patch(
"homeassistant.components.izone.discovery.async_discover_by_host",
new=AsyncMock(side_effect=pizone.ControllerAlreadyClaimedError("claimed")),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await async_choose_manual_host(hass, result)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: "192.0.2.1"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual_host"
assert result["errors"] == {"base": "already_configured"}
async def test_user_manual_host_discovery_bind_fails(hass: HomeAssistant) -> None:
"""UDP bind failure during Enter host probe aborts discovery_failed."""
with patch(
"homeassistant.components.izone.discovery.async_discover_by_host",
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_choose_manual_host(hass, result)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: "192.0.2.55"}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "discovery_failed"
@pytest.mark.usefixtures("mock_entry_setup")
async def test_user_manual_host_handoff_by_uid_when_typed_host_differs(
hass: HomeAssistant,
) -> None:
"""Typed alias that probes to the shelf host hands off without replacing the card."""
controller = create_mock_controller("000000001", "192.0.2.55")
endpoint = endpoint_from_controller(controller)
with (
patch_discovered_controllers(controller),
patch(
"homeassistant.components.izone.discovery.async_discover_by_host",
new=AsyncMock(return_value=endpoint),
),
):
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)
shelf_flow_id = result["flow_id"]
menu = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
host_form = await async_choose_manual_host(hass, menu)
result = await hass.config_entries.flow.async_configure(
host_form["flow_id"], {CONF_HOST: "izone.example"}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "continue_setup"
assert result["next_flow"] == (config_entries.FlowType.CONFIG_FLOW, shelf_flow_id)
assert shelf_flow_id in hass.config_entries.flow._progress
async def test_user_manual_host_shelve_miss_stays_on_form(
hass: HomeAssistant,
) -> None:
"""If shelving does not produce a shelf card, stay on Enter host."""
endpoint = endpoint_from_controller(
create_mock_controller("000000001", "192.0.2.55")
)
with (
patch(
"homeassistant.components.izone.discovery.async_discover_by_host",
new=AsyncMock(return_value=endpoint),
),
patch(
"homeassistant.components.izone.config_flow.IZoneConfigFlow."
"_async_shelve_integration_discovery_flow",
new=AsyncMock(),
),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await async_choose_manual_host(hass, result)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: "192.0.2.55"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual_host"
assert result["errors"] == {"base": "no_devices_found"}
+16
View File
@@ -28,6 +28,7 @@ def _mock_pizone_service() -> Mock:
service.close = AsyncMock()
service.discover_all = AsyncMock(return_value=[])
service.discover_by_uid = AsyncMock(return_value=None)
service.discover_by_host = AsyncMock(return_value=None)
return service
@@ -548,3 +549,18 @@ async def test_discover_endpoint_by_uid(
assert result == endpoint
mock_service.discover_by_uid.assert_awaited_once_with("000000001")
async def test_discover_by_host(
hass: HomeAssistant,
mock_pizone_create_discovery: tuple[AsyncMock, Mock],
) -> None:
"""Manual host lookup returns a single endpoint from discover_by_host."""
_, mock_service = mock_pizone_create_discovery
endpoint = create_mock_endpoint("000000001", "192.0.2.1")
mock_service.discover_by_host = AsyncMock(return_value=endpoint)
result = await izone_discovery.async_discover_by_host(hass, "192.0.2.1")
assert result == endpoint
mock_service.discover_by_host.assert_awaited_once_with("192.0.2.1")