From 08fae5d4197de891b998a5853fca98097d886a62 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Sat, 22 Jun 2024 21:12:32 +0200 Subject: [PATCH] Add reconfigure flow to Fronius (#116132) --- .../components/fronius/config_flow.py | 91 ++++--- homeassistant/components/fronius/strings.json | 9 +- tests/components/fronius/test_config_flow.py | 233 +++++++++++++++++- tests/components/fronius/test_diagnostics.py | 2 +- 4 files changed, 303 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/fronius/config_flow.py b/homeassistant/components/fronius/config_flow.py index cd0078230a3..b16f43d58e8 100644 --- a/homeassistant/components/fronius/config_flow.py +++ b/homeassistant/components/fronius/config_flow.py @@ -10,7 +10,7 @@ from pyfronius import Fronius, FroniusError import voluptuous as vol from homeassistant.components.dhcp import DhcpServiceInfo -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -22,12 +22,6 @@ _LOGGER: Final = logging.getLogger(__name__) DHCP_REQUEST_DELAY: Final = 60 -STEP_USER_DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_HOST): str, - } -) - def create_title(info: FroniusConfigEntryData) -> str: """Return the title of the config flow.""" @@ -40,10 +34,7 @@ def create_title(info: FroniusConfigEntryData) -> str: async def validate_host( hass: HomeAssistant, host: str ) -> tuple[str, FroniusConfigEntryData]: - """Validate the user input allows us to connect. - - Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. - """ + """Validate the user input allows us to connect.""" fronius = Fronius(async_get_clientsession(hass), host) try: @@ -81,33 +72,32 @@ class FroniusConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize flow.""" self.info: FroniusConfigEntryData + self._entry: ConfigEntry | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - if user_input is None: - return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA - ) - errors = {} - try: - unique_id, info = await validate_host(self.hass, user_input[CONF_HOST]) - except CannotConnect: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - await self.async_set_unique_id(unique_id, raise_on_progress=False) - self._abort_if_unique_id_configured(updates=dict(info)) + if user_input is not None: + try: + unique_id, info = await validate_host(self.hass, user_input[CONF_HOST]) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(unique_id, raise_on_progress=False) + self._abort_if_unique_id_configured(updates=dict(info)) - return self.async_create_entry(title=create_title(info), data=info) + return self.async_create_entry(title=create_title(info), data=info) return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + errors=errors, ) async def async_step_dhcp( @@ -150,6 +140,51 @@ class FroniusConfigFlow(ConfigFlow, domain=DOMAIN): }, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Add reconfigure step to allow to reconfigure a config entry.""" + errors = {} + + if user_input is not None: + try: + unique_id, info = await validate_host(self.hass, user_input[CONF_HOST]) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + # Config didn't change or is already configured in another entry + self._async_abort_entries_match(dict(info)) + + existing_entry = await self.async_set_unique_id( + unique_id, raise_on_progress=False + ) + assert self._entry is not None + if existing_entry and existing_entry.entry_id != self._entry.entry_id: + # Uid of device is already configured in another entry (but with different host) + self._abort_if_unique_id_configured() + + return self.async_update_reload_and_abort( + self._entry, + data=info, + reason="reconfigure_successful", + ) + + if self._entry is None: + self._entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + assert self._entry is not None + host = self._entry.data[CONF_HOST] + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), + description_placeholders={"device": self._entry.title}, + errors=errors, + ) + class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/fronius/strings.json b/homeassistant/components/fronius/strings.json index af93694284a..ccfb88852a8 100644 --- a/homeassistant/components/fronius/strings.json +++ b/homeassistant/components/fronius/strings.json @@ -11,6 +11,12 @@ }, "confirm_discovery": { "description": "Do you want to add {device} to Home Assistant?" + }, + "reconfigure": { + "description": "Update your configuration information for {device}.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + } } }, "error": { @@ -19,7 +25,8 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "invalid_host": "[%key:common::config_flow::error::invalid_host%]" + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "entity": { diff --git a/tests/components/fronius/test_config_flow.py b/tests/components/fronius/test_config_flow.py index bf5ef360752..41593a0ad2e 100644 --- a/tests/components/fronius/test_config_flow.py +++ b/tests/components/fronius/test_config_flow.py @@ -50,7 +50,7 @@ async def test_form_with_logger(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert result["errors"] is None + assert not result["errors"] with ( patch( @@ -85,7 +85,7 @@ async def test_form_with_inverter(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert result["errors"] is None + assert not result["errors"] with ( patch( @@ -338,3 +338,232 @@ async def test_dhcp_invalid( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "invalid_host" + + +async def test_reconfigure(hass: HomeAssistant) -> None: + """Test reconfiguring an entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="123.4567890", + data={ + CONF_HOST: "10.1.2.3", + "is_logger": True, + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + with ( + patch( + "pyfronius.Fronius.current_logger_info", + side_effect=FroniusError, + ), + patch( + "pyfronius.Fronius.inverter_info", + return_value=INVERTER_INFO_RETURN_VALUE, + ), + patch( + "homeassistant.components.fronius.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + "host": "10.9.1.1", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == { + "host": "10.9.1.1", + "is_logger": False, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reconfigure_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="123.4567890", + data={ + CONF_HOST: "10.1.2.3", + "is_logger": True, + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + + with ( + patch( + "pyfronius.Fronius.current_logger_info", + side_effect=FroniusError, + ), + patch( + "pyfronius.Fronius.inverter_info", + side_effect=FroniusError, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_reconfigure_unexpected(hass: HomeAssistant) -> None: + """Test we handle unexpected error.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="123.4567890", + data={ + CONF_HOST: "10.1.2.3", + "is_logger": True, + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + + with patch( + "pyfronius.Fronius.current_logger_info", + side_effect=KeyError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "unknown"} + + +async def test_reconfigure_already_configured(hass: HomeAssistant) -> None: + """Test reconfiguring an entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="123.4567890", + data={ + CONF_HOST: "10.1.2.3", + "is_logger": True, + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + with ( + patch( + "pyfronius.Fronius.current_logger_info", + return_value=LOGGER_INFO_RETURN_VALUE, + ), + patch( + "pyfronius.Fronius.inverter_info", + return_value=INVERTER_INFO_RETURN_VALUE, + ), + patch( + "homeassistant.components.fronius.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + "host": "10.1.2.3", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_reconfigure_already_existing(hass: HomeAssistant) -> None: + """Test reconfiguring entry to already existing device.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="123.4567890", + data={ + CONF_HOST: "10.1.2.3", + "is_logger": True, + }, + ) + entry.add_to_hass(hass) + + entry_2_uid = "222.2222222" + entry_2 = MockConfigEntry( + domain=DOMAIN, + unique_id=entry_2_uid, + data={ + CONF_HOST: "10.2.2.2", + "is_logger": True, + }, + ) + entry_2.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + with patch( + "pyfronius.Fronius.current_logger_info", + return_value={"unique_identifier": {"value": entry_2_uid}}, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "10.1.1.1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" diff --git a/tests/components/fronius/test_diagnostics.py b/tests/components/fronius/test_diagnostics.py index 7d8a49dcb7d..7b1f384e405 100644 --- a/tests/components/fronius/test_diagnostics.py +++ b/tests/components/fronius/test_diagnostics.py @@ -1,4 +1,4 @@ -"""Tests for the diagnostics data provided by the KNX integration.""" +"""Tests for the diagnostics data provided by the Fronius integration.""" from syrupy import SnapshotAssertion