diff --git a/homeassistant/components/neopool/config_flow.py b/homeassistant/components/neopool/config_flow.py index b6fde0365d2cd9..debe7f4c16ee56 100644 --- a/homeassistant/components/neopool/config_flow.py +++ b/homeassistant/components/neopool/config_flow.py @@ -20,6 +20,8 @@ from homeassistant.core import callback from .const import ( + CONF_MODBUS_FRAMER, + CONF_UNIT_ID, CONF_USE_AUX1, CONF_USE_AUX2, CONF_USE_AUX3, @@ -40,8 +42,8 @@ async def _async_probe(user_input: dict[str, Any]) -> tuple[str | None, str | No serial = await async_probe_serial( user_input[CONF_HOST], port=user_input[CONF_PORT], - unit_id=user_input["unit_id"], - framer=user_input["modbus_framer"], + unit_id=user_input[CONF_UNIT_ID], + framer=user_input[CONF_MODBUS_FRAMER], ) except NeoPoolConnectionError, NeoPoolTimeoutError: return None, "cannot_connect" @@ -73,9 +75,9 @@ async def async_step_user( { vol.Required(CONF_HOST): str, vol.Optional(CONF_PORT, default=DEFAULT_PORT): vol.Coerce(int), - vol.Optional("unit_id", default=DEFAULT_UNIT_ID): vol.Coerce(int), + vol.Optional(CONF_UNIT_ID, default=DEFAULT_UNIT_ID): vol.Coerce(int), vol.Optional( - "modbus_framer", + CONF_MODBUS_FRAMER, default=DEFAULT_MODBUS_FRAMER, ): vol.In(("tcp", "rtu")), } @@ -100,6 +102,49 @@ async def async_step_user( errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of an existing entry.""" + entry = self._get_reconfigure_entry() + current = entry.data + + data_schema = vol.Schema( + { + vol.Required(CONF_HOST, default=current[CONF_HOST]): str, + vol.Optional( + CONF_PORT, default=current.get(CONF_PORT, DEFAULT_PORT) + ): vol.Coerce(int), + vol.Optional( + CONF_UNIT_ID, + default=current.get(CONF_UNIT_ID, DEFAULT_UNIT_ID), + ): vol.Coerce(int), + vol.Optional( + CONF_MODBUS_FRAMER, + default=current.get(CONF_MODBUS_FRAMER, DEFAULT_MODBUS_FRAMER), + ): vol.In(("tcp", "rtu")), + } + ) + + errors: dict[str, str] = {} + if user_input is not None: + merged = {**current, **user_input} + serial, error_key = await _async_probe(merged) + if error_key: + errors[CONF_HOST] = error_key + else: + await self.async_set_unique_id(serial) + self._abort_if_unique_id_mismatch(reason="serial_mismatch") + return self.async_update_reload_and_abort(entry, data=merged) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + data_schema, user_input or current + ), + errors=errors, + ) + class NeoPoolOptionsFlowHandler(OptionsFlowWithReload): """Handle options flow for NeoPool integration.""" diff --git a/homeassistant/components/neopool/const.py b/homeassistant/components/neopool/const.py index 06ff396c25ed56..33ab63a75ae95a 100644 --- a/homeassistant/components/neopool/const.py +++ b/homeassistant/components/neopool/const.py @@ -17,7 +17,9 @@ DEFAULT_PORT = 502 DEFAULT_UNIT_ID = 1 -# Options-flow keys. +CONF_UNIT_ID = "unit_id" +CONF_MODBUS_FRAMER = "modbus_framer" + CONF_USE_LIGHT = "use_light" CONF_USE_COVER_SENSOR = "use_cover_sensor" CONF_USE_AUX1 = "use_aux1" diff --git a/homeassistant/components/neopool/quality_scale.yaml b/homeassistant/components/neopool/quality_scale.yaml index e4bcbfcd433dcb..ef1369394a49f9 100644 --- a/homeassistant/components/neopool/quality_scale.yaml +++ b/homeassistant/components/neopool/quality_scale.yaml @@ -80,7 +80,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: done stale-devices: status: exempt diff --git a/homeassistant/components/neopool/strings.json b/homeassistant/components/neopool/strings.json index ea8a9192b5e23e..5980ee3e2410d2 100644 --- a/homeassistant/components/neopool/strings.json +++ b/homeassistant/components/neopool/strings.json @@ -1,13 +1,31 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "serial_mismatch": "The device at this address has a different serial number than the one originally configured." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_read_modbus": "Connected, but cannot read from the Modbus device. Check unit ID and framer settings." }, "step": { + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "modbus_framer": "[%key:component::neopool::config::step::user::data::modbus_framer%]", + "port": "[%key:common::config_flow::data::port%]", + "unit_id": "[%key:component::neopool::config::step::user::data::unit_id%]" + }, + "data_description": { + "host": "[%key:component::neopool::config::step::user::data_description::host%]", + "modbus_framer": "[%key:component::neopool::config::step::user::data_description::modbus_framer%]", + "port": "[%key:component::neopool::config::step::user::data_description::port%]", + "unit_id": "[%key:component::neopool::config::step::user::data_description::unit_id%]" + }, + "description": "Update the connection settings for your NeoPool controller.", + "title": "Reconfigure NeoPool Connection" + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", diff --git a/tests/components/neopool/test_config_flow.py b/tests/components/neopool/test_config_flow.py index e47240eaae6bf8..9f6923eb490974 100644 --- a/tests/components/neopool/test_config_flow.py +++ b/tests/components/neopool/test_config_flow.py @@ -111,6 +111,86 @@ async def test_user_flow_already_configured( assert result["reason"] == "already_configured" +@pytest.mark.usefixtures("mock_neopool_client") +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """A reconfigure flow updates the connection settings and reloads the entry.""" + 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"], + {**USER_INPUT, CONF_HOST: "192.0.2.50", CONF_PORT: 1502}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_HOST] == "192.0.2.50" + assert mock_config_entry.data[CONF_PORT] == 1502 + + +@pytest.mark.parametrize( + ("exc_cls", "error_key"), + [ + (NeoPoolConnectionError, "cannot_connect"), + (NeoPoolTimeoutError, "cannot_connect"), + (NeoPoolModbusError, "cannot_read_modbus"), + ], +) +async def test_reconfigure_flow_probe_errors_recover( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_socket_connection: AsyncMock, + exc_cls: type[Exception], + error_key: str, +) -> None: + """Probe errors surface as form errors, and the flow recovers on retry.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + + mock_socket_connection.side_effect = exc_cls("boom") + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {**USER_INPUT, CONF_HOST: "192.0.2.50"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_HOST: error_key} + + mock_socket_connection.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {**USER_INPUT, CONF_HOST: "192.0.2.50"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_flow_serial_mismatch( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_socket_connection: AsyncMock, +) -> None: + """A reconfigure targeting a different physical controller is rejected.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + + mock_socket_connection.return_value = "9999999999" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {**USER_INPUT, CONF_HOST: "192.0.2.50"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "serial_mismatch" + + @pytest.mark.usefixtures("mock_neopool_client") async def test_options_flow_show_form( hass: HomeAssistant,