diff --git a/homeassistant/components/lyngdorf/config_flow.py b/homeassistant/components/lyngdorf/config_flow.py index f06633f16cdd..94366cc67b62 100644 --- a/homeassistant/components/lyngdorf/config_flow.py +++ b/homeassistant/components/lyngdorf/config_flow.py @@ -4,6 +4,7 @@ import logging from typing import Any, override from urllib.parse import urlparse +from lyngdorf.const import LyngdorfModel from lyngdorf.device import ( async_find_receiver_model, async_get_device_serial, @@ -54,31 +55,26 @@ class LyngdorfFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: self._host = user_input[CONF_HOST] - try: - model = await async_find_receiver_model(self._host) - except TimeoutError: + model, serial = await self._async_probe(self._host) + except TimeoutConnect: errors["base"] = "timeout_connect" - except OSError: + except CannotConnect: errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 - errors["base"] = "unknown" - - if not errors and not model: + except UnsupportedModel: errors["base"] = "unsupported_model" - - if not errors and model: + except CannotDetermineId: + errors["base"] = "cannot_determine_id" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: self._device_model = model.model_name self._name = model.model_name - - serial = await async_get_device_serial(self._host) - if not serial: - errors["base"] = "cannot_determine_id" - else: - self._device_serial_number = serial.lower() - await self.async_set_unique_id(self._device_serial_number) - self._abort_if_unique_id_configured() - return await self._create_entry() + self._device_serial_number = serial + await self.async_set_unique_id(serial) + self._abort_if_unique_id_configured() + return await self._create_entry() return self.async_show_form( step_id="user", @@ -90,6 +86,76 @@ class LyngdorfFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def _async_probe(self, host: str) -> tuple[LyngdorfModel, str]: + """Return the model and serial of the device at a host.""" + try: + model = await async_find_receiver_model(host) + except TimeoutError as err: + raise TimeoutConnect from err + except OSError as err: + raise CannotConnect from err + if not model: + raise UnsupportedModel + + try: + serial = await async_get_device_serial(host) + except TimeoutError as err: + raise TimeoutConnect from err + except OSError as err: + raise CannotConnect from err + if not serial: + raise CannotDetermineId + + return model, serial.lower() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of an existing entry. + + SSDP rediscovery only recovers a changed address while the device is + still announcing somewhere Home Assistant can hear it, which a move to + a static address or another subnet can end. + """ + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + host = user_input[CONF_HOST] + try: + model, serial = await self._async_probe(host) + except TimeoutConnect: + errors["base"] = "timeout_connect" + except CannotConnect: + errors["base"] = "cannot_connect" + except UnsupportedModel: + errors["base"] = "unsupported_model" + except CannotDetermineId: + errors["base"] = "cannot_determine_id" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(serial) + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates={ + CONF_HOST: host, + CONF_MODEL: model.model_name, + CONF_SERIAL_NUMBER: serial, + }, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + vol.Schema({vol.Required(CONF_HOST): cv.string}), + reconfigure_entry.data, + ), + errors=errors, + ) + @override async def async_step_ssdp( self, discovery_info: SsdpServiceInfo @@ -181,3 +247,19 @@ class LyngdorfFlowHandler(ConfigFlow, domain=DOMAIN): raise AbortFlow("cannot_determine_id") await self.async_set_unique_id(self._device_serial_number) self._abort_if_unique_id_configured(updates={CONF_HOST: self._host}) + + +class CannotConnect(Exception): + """Error to indicate we cannot connect.""" + + +class TimeoutConnect(Exception): + """Error to indicate the device did not answer in time.""" + + +class UnsupportedModel(Exception): + """Error to indicate the device is not a model we support.""" + + +class CannotDetermineId(Exception): + """Error to indicate the device did not report a serial.""" diff --git a/homeassistant/components/lyngdorf/quality_scale.yaml b/homeassistant/components/lyngdorf/quality_scale.yaml index 2974ff508323..c20a592b33a6 100644 --- a/homeassistant/components/lyngdorf/quality_scale.yaml +++ b/homeassistant/components/lyngdorf/quality_scale.yaml @@ -74,7 +74,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: No repair issues needed. diff --git a/homeassistant/components/lyngdorf/strings.json b/homeassistant/components/lyngdorf/strings.json index f28652c7fe23..4b3baf44bc6a 100644 --- a/homeassistant/components/lyngdorf/strings.json +++ b/homeassistant/components/lyngdorf/strings.json @@ -5,6 +5,8 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_determine_id": "[%key:component::lyngdorf::config::error::cannot_determine_id%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "The device at this address is a different Lyngdorf device from the one this entry was set up with.", "unsupported_model": "This Lyngdorf model is not supported" }, "error": { @@ -19,6 +21,16 @@ "confirm": { "description": "Do you want to set up **{name}**?" }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::lyngdorf::config::step::user::data_description::host%]" + }, + "description": "Update the address Home Assistant uses to reach this device. It must be the same device; a different one will be rejected.", + "title": "[%key:component::lyngdorf::config::step::user::title%]" + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]" diff --git a/tests/components/lyngdorf/test_config_flow.py b/tests/components/lyngdorf/test_config_flow.py index ac001aa7d6e6..7dabfb3a8ea4 100644 --- a/tests/components/lyngdorf/test_config_flow.py +++ b/tests/components/lyngdorf/test_config_flow.py @@ -368,3 +368,162 @@ async def test_ssdp_discovery_connectivity_check_aborts( assert result["type"] is FlowResultType.ABORT assert result["reason"] == expected_reason + + +@pytest.mark.usefixtures("mock_find_receiver_model", "mock_get_device_serial") +async def test_reconfigure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguring an entry updates the host.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.50"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_HOST] == "192.168.1.50" + + +@pytest.mark.usefixtures("mock_find_receiver_model") +async def test_reconfigure_different_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_get_device_serial: AsyncMock, +) -> None: + """Test an entry cannot be pointed at a different device.""" + mock_config_entry.add_to_hass(hass) + mock_get_device_serial.return_value = "aabbccddeeff" + original_host = mock_config_entry.data[CONF_HOST] + + result = await mock_config_entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.50"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + assert mock_config_entry.data[CONF_HOST] == original_host + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (TimeoutError, "timeout_connect"), + (OSError, "cannot_connect"), + (Exception, "unknown"), + ], +) +@pytest.mark.usefixtures("mock_get_device_serial") +async def test_reconfigure_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_find_receiver_model: AsyncMock, + side_effect: type[Exception], + error: str, +) -> None: + """Test reconfigure surfaces connection errors and recovers.""" + mock_config_entry.add_to_hass(hass) + mock_find_receiver_model.side_effect = side_effect + + result = await mock_config_entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.50"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_find_receiver_model.side_effect = None + mock_find_receiver_model.return_value = LyngdorfModel.MP_60 + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.50"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (TimeoutError, "timeout_connect"), + (OSError, "cannot_connect"), + ], +) +@pytest.mark.usefixtures("mock_find_receiver_model") +async def test_user_flow_serial_errors( + hass: HomeAssistant, + mock_get_device_serial: AsyncMock, + side_effect: type[Exception], + error: str, +) -> None: + """Test a failure to read the serial is surfaced on the form.""" + mock_get_device_serial.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.168.1.50"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_get_device_serial.side_effect = None + mock_get_device_serial.return_value = "0050c27c76b2" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.168.1.50"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + ("model", "serial", "error"), + [ + pytest.param(None, "0050c27c76b2", "unsupported_model", id="unsupported"), + pytest.param(LyngdorfModel.MP_60, None, "cannot_determine_id", id="no_serial"), + ], +) +async def test_reconfigure_device_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_find_receiver_model: AsyncMock, + mock_get_device_serial: AsyncMock, + model: LyngdorfModel | None, + serial: str | None, + error: str, +) -> None: + """Test reconfigure surfaces a device it cannot identify.""" + mock_config_entry.add_to_hass(hass) + mock_find_receiver_model.return_value = model + mock_get_device_serial.return_value = serial + + result = await mock_config_entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.168.1.50"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_find_receiver_model.return_value = LyngdorfModel.MP_60 + mock_get_device_serial.return_value = "0050c27c76b2" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.168.1.50"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful"