From c01aac0ec8c88d8e185996827846ce5f9d10f566 Mon Sep 17 00:00:00 2001 From: darkrain-nl <24763370+darkrain-nl@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:56:03 +0200 Subject: [PATCH] Add a reconfigure flow to the Sofar integration (#180856) --- homeassistant/components/sofar/config_flow.py | 64 ++++++++-- .../components/sofar/quality_scale.yaml | 2 +- homeassistant/components/sofar/strings.json | 16 ++- tests/components/sofar/test_config_flow.py | 110 +++++++++++++++++- 4 files changed, 181 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/sofar/config_flow.py b/homeassistant/components/sofar/config_flow.py index ab3c1c9cb2bd..446de1a9b46f 100644 --- a/homeassistant/components/sofar/config_flow.py +++ b/homeassistant/components/sofar/config_flow.py @@ -10,6 +10,7 @@ import voluptuous as vol from homeassistant.components.modbus import async_get_temporary_unit from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.selector import ( NumberSelector, @@ -41,6 +42,17 @@ STEP_USER_DATA_SCHEMA = vol.Schema( ) +async def _async_probe( + hass: HomeAssistant, host: str, port: int, unit_id: int +) -> SofarInverter: + """Connect to the inverter and read its identity, or raise.""" + params = ModbusTcpParams(host=host, port=port) + async with async_get_temporary_unit(hass, params, unit_id) as unit: + device = SofarInverter(unit) + await device.async_update() + return device + + class SofarConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a Sofar config flow.""" @@ -54,15 +66,13 @@ class SofarConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} description_placeholders: dict[str, str] = {} if user_input is not None: - params = ModbusTcpParams( - host=user_input[CONF_HOST], port=user_input[CONF_PORT] - ) try: - async with async_get_temporary_unit( - self.hass, params, user_input[CONF_UNIT_ID] - ) as unit: - device = SofarInverter(unit) - await device.async_update() + device = await _async_probe( + self.hass, + user_input[CONF_HOST], + user_input[CONF_PORT], + user_input[CONF_UNIT_ID], + ) except (ModbusError, HomeAssistantError) as err: errors["base"] = "cannot_connect" description_placeholders["error"] = str(err) @@ -84,3 +94,41 @@ class SofarConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, description_placeholders=description_placeholders, ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle updating an existing entry's connection details.""" + reconfigure_entry = self._get_reconfigure_entry() + errors: dict[str, str] = {} + description_placeholders: dict[str, str] = {} + if user_input is not None: + try: + device = await _async_probe( + self.hass, + user_input[CONF_HOST], + user_input[CONF_PORT], + user_input[CONF_UNIT_ID], + ) + except (ModbusError, HomeAssistantError) as err: + errors["base"] = "cannot_connect" + description_placeholders["error"] = str(err) + else: + assert device.serial_number is not None + if not device.inverter_type: + errors["base"] = "unrecognized_inverter" + else: + await self.async_set_unique_id(device.serial_number) + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + reconfigure_entry, data_updates=user_input + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input or reconfigure_entry.data + ), + errors=errors, + description_placeholders=description_placeholders, + ) diff --git a/homeassistant/components/sofar/quality_scale.yaml b/homeassistant/components/sofar/quality_scale.yaml index 72a70c57ab37..12e6a96b089c 100644 --- a/homeassistant/components/sofar/quality_scale.yaml +++ b/homeassistant/components/sofar/quality_scale.yaml @@ -70,7 +70,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: todo stale-devices: todo diff --git a/homeassistant/components/sofar/strings.json b/homeassistant/components/sofar/strings.json index cd21b53efd04..06348487f51e 100644 --- a/homeassistant/components/sofar/strings.json +++ b/homeassistant/components/sofar/strings.json @@ -1,13 +1,27 @@ { "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%]", + "unique_id_mismatch": "Please reconfigure the same inverter you originally set up." }, "error": { "cannot_connect": "Failed to connect: {error}", "unrecognized_inverter": "The device answered, but its serial number doesn't match a known Sofar model." }, "step": { + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "unit_id": "Modbus unit ID" + }, + "data_description": { + "host": "[%key:component::sofar::config::step::user::data_description::host%]", + "port": "[%key:component::sofar::config::step::user::data_description::port%]", + "unit_id": "[%key:component::sofar::config::step::user::data_description::unit_id%]" + } + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", diff --git a/tests/components/sofar/test_config_flow.py b/tests/components/sofar/test_config_flow.py index e1be81ee439b..324c8a226e20 100644 --- a/tests/components/sofar/test_config_flow.py +++ b/tests/components/sofar/test_config_flow.py @@ -10,13 +10,14 @@ import pytest from homeassistant import config_entries from homeassistant.components.sofar.const import DEFAULT_NAME, DOMAIN +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.exceptions import HomeAssistantError from . import MOCK_MODEL, MOCK_SERIAL, MOCK_USER_INPUT, seed_pv_inverter -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value # A recognized prefix with no model in sofar-modbus's own table. _UNMODELED_SERIAL = "SA1XXES100XX" @@ -202,3 +203,110 @@ async def test_user_step_already_configured(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +_NEW_USER_INPUT = {**MOCK_USER_INPUT, CONF_HOST: "192.168.1.200"} + + +async def test_reconfigure_updates_the_entry( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test reconfigure updates the entry and reloads it.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id=MOCK_SERIAL, data=MOCK_USER_INPUT) + entry.add_to_hass(hass) + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_conn = MockModbusConnection() + seed_pv_inverter(mock_conn.for_unit(1)) + + with _patch_temporary_unit(mock_conn): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], _NEW_USER_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == _NEW_USER_INPUT + + +async def test_reconfigure_rejects_a_different_serial(hass: HomeAssistant) -> None: + """Test reconfigure aborts if the inverter's serial doesn't match.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id=MOCK_SERIAL, data=MOCK_USER_INPUT) + entry.add_to_hass(hass) + result = await entry.start_reconfigure_flow(hass) + + mock_conn = MockModbusConnection() + seed_pv_inverter(mock_conn.for_unit(1), serial=_UNMODELED_SERIAL) + + with _patch_temporary_unit(mock_conn): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], _NEW_USER_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + assert entry.data == MOCK_USER_INPUT + + +@pytest.mark.parametrize( + ("seed", "expected_error", "expected_placeholders"), + [ + pytest.param( + _seed_unreachable, + "cannot_connect", + {"error": "stuck"}, + id="cannot_connect", + ), + pytest.param( + _seed_unrecognized, + "unrecognized_inverter", + {}, + id="unrecognized_inverter", + ), + ], +) +async def test_reconfigure_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + seed: Callable[[MockModbusUnit], None], + expected_error: str, + expected_placeholders: dict[str, str], +) -> None: + """Test the reconfigure step reports the right error and recovers.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id=MOCK_SERIAL, data=MOCK_USER_INPUT) + entry.add_to_hass(hass) + result = await entry.start_reconfigure_flow(hass) + + mock_conn = MockModbusConnection() + seed(mock_conn.for_unit(1)) + + with _patch_temporary_unit(mock_conn): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], _NEW_USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": expected_error} + assert result["description_placeholders"] == expected_placeholders + assert entry.data == MOCK_USER_INPUT + # The retry starts from what was typed, not from the stored entry. + assert ( + get_schema_suggested_value(result["data_schema"].schema, CONF_HOST) + == _NEW_USER_INPUT[CONF_HOST] + ) + + working_conn = MockModbusConnection() + seed_pv_inverter(working_conn.for_unit(1)) + + with _patch_temporary_unit(working_conn): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], _NEW_USER_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == _NEW_USER_INPUT