From f932260b940ec67dc610469afa19a38ee2ceaa96 Mon Sep 17 00:00:00 2001 From: Penny Wood Date: Fri, 18 Sep 2026 00:05:52 +0800 Subject: [PATCH] Add iZone manual host entry to the user config flow (#178805) Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com> --- homeassistant/components/izone/config_flow.py | 157 +++++- homeassistant/components/izone/discovery.py | 16 + homeassistant/components/izone/strings.json | 22 +- tests/components/izone/conftest.py | 42 +- tests/components/izone/test_config_flow.py | 474 ++++++++++++++++-- tests/components/izone/test_discovery.py | 16 + 6 files changed, 672 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/izone/config_flow.py b/homeassistant/components/izone/config_flow.py index 88e43a62f58a..03daad4d382b 100644 --- a/homeassistant/components/izone/config_flow.py +++ b/homeassistant/components/izone/config_flow.py @@ -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) diff --git a/homeassistant/components/izone/discovery.py b/homeassistant/components/izone/discovery.py index 05fc54c180a2..806638ac6b5b 100644 --- a/homeassistant/components/izone/discovery.py +++ b/homeassistant/components/izone/discovery.py @@ -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. diff --git a/homeassistant/components/izone/strings.json b/homeassistant/components/izone/strings.json index 1bb081da18cd..90bfed3fa355 100644 --- a/homeassistant/components/izone/strings.json +++ b/homeassistant/components/izone/strings.json @@ -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" + } } } }, diff --git a/tests/components/izone/conftest.py b/tests/components/izone/conftest.py index 4f14d8af860e..9e117693a2e6 100644 --- a/tests/components/izone/conftest.py +++ b/tests/components/izone/conftest.py @@ -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: diff --git a/tests/components/izone/test_config_flow.py b/tests/components/izone/test_config_flow.py index 2907de27ca8e..15b066139117 100644 --- a/tests/components/izone/test_config_flow.py +++ b/tests/components/izone/test_config_flow.py @@ -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"} diff --git a/tests/components/izone/test_discovery.py b/tests/components/izone/test_discovery.py index 03227b999c17..42c5454eaf9d 100644 --- a/tests/components/izone/test_discovery.py +++ b/tests/components/izone/test_discovery.py @@ -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")