Add a reconfigure flow to the Sofar integration (#180856)

This commit is contained in:
darkrain-nl
2026-08-30 16:56:03 -04:00
committed by GitHub
parent d948431121
commit c01aac0ec8
4 changed files with 181 additions and 11 deletions
+56 -8
View File
@@ -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,
)
@@ -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
+15 -1
View File
@@ -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%]",
+109 -1
View File
@@ -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