From e3593c3076c7aac2536619cd656e12bb2ea7ec38 Mon Sep 17 00:00:00 2001 From: Josh Gustafson Date: Sat, 23 May 2026 02:24:28 -0600 Subject: [PATCH] Arcam reconfig flow (#171767) --- .../components/arcam_fmj/config_flow.py | 81 ++++++++--- .../components/arcam_fmj/strings.json | 11 +- .../components/arcam_fmj/test_config_flow.py | 136 ++++++++++++++++++ 3 files changed, 204 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/arcam_fmj/config_flow.py b/homeassistant/components/arcam_fmj/config_flow.py index a0ebb3fb9a56..73331bfa9a3e 100644 --- a/homeassistant/components/arcam_fmj/config_flow.py +++ b/homeassistant/components/arcam_fmj/config_flow.py @@ -16,6 +16,13 @@ from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_UDN, SsdpServiceIn from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN +STEP_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + } +) + class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN): """Handle config flow.""" @@ -31,13 +38,22 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(uuid) self._abort_if_unique_id_configured({CONF_HOST: host, CONF_PORT: port}) - async def _async_try_connect(self, host: str, port: int) -> None: - """Verify the device is reachable.""" + async def _async_try_connect(self, host: str, port: int) -> dict[str, str]: + """Verify the device is reachable; return errors keyed by reason.""" client = Client(host, port) try: await client.start() + except socket.gaierror: + return {"base": "invalid_host"} + except TimeoutError: + return {"base": "timeout_connect"} + except ConnectionRefusedError: + return {"base": "connection_refused"} + except ConnectionFailed, OSError: + return {"base": "cannot_connect"} finally: await client.stop() + return {} async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -53,19 +69,10 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN): user_input[CONF_HOST], user_input[CONF_PORT], uuid ) - try: - await self._async_try_connect( - user_input[CONF_HOST], user_input[CONF_PORT] - ) - except socket.gaierror: - errors["base"] = "invalid_host" - except TimeoutError: - errors["base"] = "timeout_connect" - except ConnectionRefusedError: - errors["base"] = "connection_refused" - except ConnectionFailed, OSError: - errors["base"] = "cannot_connect" - else: + errors = await self._async_try_connect( + user_input[CONF_HOST], user_input[CONF_PORT] + ) + if not errors: return self.async_create_entry( title=f"{DEFAULT_NAME} ({user_input[CONF_HOST]})", data={ @@ -74,16 +81,46 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN): }, ) - fields = { - vol.Required(CONF_HOST): str, - vol.Required(CONF_PORT, default=DEFAULT_PORT): int, - } - schema = vol.Schema(fields) + schema = STEP_DATA_SCHEMA if user_input is not None: schema = self.add_suggested_values_to_schema(schema, user_input) return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of an existing entry.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + if user_input is not None: + uuid = await get_uniqueid_from_host( + async_get_clientsession(self.hass), user_input[CONF_HOST] + ) + if uuid: + await self.async_set_unique_id(uuid) + self._abort_if_unique_id_mismatch() + + errors = await self._async_try_connect( + user_input[CONF_HOST], user_input[CONF_PORT] + ) + if not errors: + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates={ + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + }, + ) + + schema = self.add_suggested_values_to_schema( + STEP_DATA_SCHEMA, user_input or reconfigure_entry.data + ) + + return self.async_show_form( + step_id="reconfigure", data_schema=schema, errors=errors + ) + async def async_step_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -113,9 +150,7 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN): await self._async_set_unique_id_and_update(host, port, uuid) - try: - await self._async_try_connect(host, port) - except ConnectionFailed, OSError: + if await self._async_try_connect(host, port): return self.async_abort(reason="cannot_connect") self.host = host diff --git a/homeassistant/components/arcam_fmj/strings.json b/homeassistant/components/arcam_fmj/strings.json index fa0f85f7057f..b9f0d33055ce 100644 --- a/homeassistant/components/arcam_fmj/strings.json +++ b/homeassistant/components/arcam_fmj/strings.json @@ -3,7 +3,9 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "Please ensure you reconfigure against the same device." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -16,6 +18,13 @@ "confirm": { "description": "Do you want to add Arcam FMJ on `{host}` to Home Assistant?" }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "description": "[%key:component::arcam_fmj::config::step::user::description%]" + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", diff --git a/tests/components/arcam_fmj/test_config_flow.py b/tests/components/arcam_fmj/test_config_flow.py index c757559b89a5..7c0f9bf25324 100644 --- a/tests/components/arcam_fmj/test_config_flow.py +++ b/tests/components/arcam_fmj/test_config_flow.py @@ -287,3 +287,139 @@ async def test_user_unable_to_connect( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == f"Arcam FMJ ({MOCK_HOST})" assert result["data"] == MOCK_CONFIG_ENTRY + + +async def test_reconfigure( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test reconfiguring an existing entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "old_host", CONF_PORT: MOCK_PORT}, + title=MOCK_NAME, + unique_id=MOCK_UUID, + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + aioclient_mock.get(MOCK_UPNP_LOCATION, text=MOCK_UPNP_DEVICE) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + assert entry.data == MOCK_CONFIG_ENTRY + assert entry.unique_id == MOCK_UUID + + +async def test_reconfigure_no_ssdp( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test reconfiguring when the new host does not respond to ssdp.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "old_host", CONF_PORT: MOCK_PORT}, + title=MOCK_NAME, + unique_id=MOCK_UUID, + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + aioclient_mock.get(MOCK_UPNP_LOCATION, status=404) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + assert entry.data == MOCK_CONFIG_ENTRY + assert entry.unique_id == MOCK_UUID + + +async def test_reconfigure_unique_id_mismatch( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test reconfiguring against a different device aborts.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "old_host", CONF_PORT: MOCK_PORT}, + title=MOCK_NAME, + unique_id="other_uuid", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + aioclient_mock.get(MOCK_UPNP_LOCATION, text=MOCK_UPNP_DEVICE) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + + assert entry.data == {CONF_HOST: "old_host", CONF_PORT: MOCK_PORT} + + +@pytest.mark.parametrize( + ("connect_exception", "expected_error"), + [ + pytest.param(ConnectionFailed, "cannot_connect", id="connection_failed"), + pytest.param( + ConnectionRefusedError, "connection_refused", id="connection_refused" + ), + pytest.param(OSError, "cannot_connect", id="os_error"), + pytest.param(socket.gaierror, "invalid_host", id="invalid_host"), + pytest.param(TimeoutError, "timeout_connect", id="timeout_connect"), + ], +) +async def test_reconfigure_unable_to_connect( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + dummy_client: MagicMock, + connect_exception: type[Exception], + expected_error: str, +) -> None: + """Test reconfiguring when the device cannot be reached.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "old_host", CONF_PORT: MOCK_PORT}, + title=MOCK_NAME, + unique_id=MOCK_UUID, + ) + entry.add_to_hass(hass) + + dummy_client.start.side_effect = AsyncMock(side_effect=connect_exception) + aioclient_mock.get(MOCK_UPNP_LOCATION, status=404) + + result = await entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + user_input = {CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": expected_error} + + dummy_client.start.side_effect = AsyncMock(return_value=None) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + assert entry.data == MOCK_CONFIG_ENTRY