From e79c76cd3518ba18b282565b997f9a38f0f8181f Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 19 Oct 2025 12:33:23 -0700 Subject: [PATCH] Add reconfigure flow in SolarEdge (#154189) --- .../components/solaredge/config_flow.py | 153 ++++++++++++------ .../components/solaredge/strings.json | 34 +++- .../components/solaredge/test_config_flow.py | 95 +++++++++++ 3 files changed, 231 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/solaredge/config_flow.py b/homeassistant/components/solaredge/config_flow.py index 69bd5d6cd3b..60ab4864041 100644 --- a/homeassistant/components/solaredge/config_flow.py +++ b/homeassistant/components/solaredge/config_flow.py @@ -3,14 +3,18 @@ from __future__ import annotations import socket -from typing import Any +from typing import TYPE_CHECKING, Any from aiohttp import ClientError, ClientResponseError import aiosolaredge from solaredge_web import SolarEdgeWeb import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.data_entry_flow import section @@ -91,17 +95,28 @@ class SolarEdgeConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Step when user initializes a integration.""" + """Step when user initializes an integration or reconfigures it.""" self._errors = {} + entry = None + if self.source == SOURCE_RECONFIGURE: + entry = self._get_reconfigure_entry() + if user_input is not None: name = slugify(user_input.get(CONF_NAME, DEFAULT_NAME)) - site_id = user_input[CONF_SITE_ID] + if self.source == SOURCE_RECONFIGURE: + if TYPE_CHECKING: + assert entry + site_id = entry.data[CONF_SITE_ID] + else: + site_id = user_input[CONF_SITE_ID] api_auth = user_input.get(CONF_SECTION_API_AUTH, {}) web_auth = user_input.get(CONF_SECTION_WEB_AUTH, {}) api_key = api_auth.get(CONF_API_KEY) username = web_auth.get(CONF_USERNAME) - if self._site_in_configuration_exists(site_id): + if self.source != SOURCE_RECONFIGURE and self._site_in_configuration_exists( + site_id + ): self._errors[CONF_SITE_ID] = "already_configured" elif not api_key and not username: self._errors["base"] = "auth_missing" @@ -120,54 +135,92 @@ class SolarEdgeConfigFlow(ConfigFlow, domain=DOMAIN): data = {CONF_SITE_ID: site_id} data.update(api_auth) data.update(web_auth) + + if self.source == SOURCE_RECONFIGURE: + if TYPE_CHECKING: + assert entry + return self.async_update_reload_and_abort(entry, data=data) + return self.async_create_entry(title=name, data=data) + elif self.source == SOURCE_RECONFIGURE: + if TYPE_CHECKING: + assert entry + user_input = { + CONF_SECTION_API_AUTH: {CONF_API_KEY: entry.data.get(CONF_API_KEY, "")}, + CONF_SECTION_WEB_AUTH: { + CONF_USERNAME: entry.data.get(CONF_USERNAME, ""), + CONF_PASSWORD: entry.data.get(CONF_PASSWORD, ""), + }, + } else: user_input = {} - return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - { - vol.Required( - CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME) - ): str, - vol.Required( - CONF_SITE_ID, default=user_input.get(CONF_SITE_ID, "") - ): str, - vol.Optional(CONF_SECTION_API_AUTH): section( - vol.Schema( - { - vol.Optional( - CONF_API_KEY, - default=user_input.get( - CONF_SECTION_API_AUTH, {} - ).get(CONF_API_KEY, ""), - ): str, - } - ), - options={"collapsed": False}, + + data_schema_dict: dict[vol.Marker, Any] = {} + if self.source != SOURCE_RECONFIGURE: + data_schema_dict[ + vol.Required(CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME)) + ] = str + data_schema_dict[ + vol.Required(CONF_SITE_ID, default=user_input.get(CONF_SITE_ID, "")) + ] = str + + data_schema_dict.update( + { + vol.Optional(CONF_SECTION_API_AUTH): section( + vol.Schema( + { + vol.Optional( + CONF_API_KEY, + default=user_input.get(CONF_SECTION_API_AUTH, {}).get( + CONF_API_KEY, "" + ), + ): str, + } ), - vol.Optional(CONF_SECTION_WEB_AUTH): section( - vol.Schema( - { - vol.Inclusive( - CONF_USERNAME, - "web_account", - default=user_input.get( - CONF_SECTION_WEB_AUTH, {} - ).get(CONF_USERNAME, ""), - ): str, - vol.Inclusive( - CONF_PASSWORD, - "web_account", - default=user_input.get( - CONF_SECTION_WEB_AUTH, {} - ).get(CONF_PASSWORD, ""), - ): str, - } - ), - options={"collapsed": False}, + options={"collapsed": False}, + ), + vol.Optional(CONF_SECTION_WEB_AUTH): section( + vol.Schema( + { + vol.Inclusive( + CONF_USERNAME, + "web_account", + default=user_input.get(CONF_SECTION_WEB_AUTH, {}).get( + CONF_USERNAME, "" + ), + ): str, + vol.Inclusive( + CONF_PASSWORD, + "web_account", + default=user_input.get(CONF_SECTION_WEB_AUTH, {}).get( + CONF_PASSWORD, "" + ), + ): str, + } ), - } - ), - errors=self._errors, + options={"collapsed": False}, + ), + } ) + data_schema = vol.Schema(data_schema_dict) + + step_id = "user" + description_placeholders = {} + if self.source == SOURCE_RECONFIGURE: + if TYPE_CHECKING: + assert entry + step_id = "reconfigure" + description_placeholders["site_id"] = entry.data[CONF_SITE_ID] + + return self.async_show_form( + step_id=step_id, + data_schema=data_schema, + errors=self._errors, + description_placeholders=description_placeholders, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration flow initiated by the user.""" + return await self.async_step_user(user_input) diff --git a/homeassistant/components/solaredge/strings.json b/homeassistant/components/solaredge/strings.json index c480f34feed..e0a037ab173 100644 --- a/homeassistant/components/solaredge/strings.json +++ b/homeassistant/components/solaredge/strings.json @@ -33,6 +33,37 @@ } } } + }, + "reconfigure": { + "title": "Reconfigure SolarEdge", + "description": "Update your API key or web account credentials for site {site_id}.", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "api_key": "[%key:component::solaredge::config::step::user::data_description::api_key%]", + "username": "[%key:component::solaredge::config::step::user::data_description::username%]", + "password": "[%key:component::solaredge::config::step::user::data_description::password%]" + }, + "sections": { + "api_auth": { + "name": "[%key:component::solaredge::config::step::user::sections::api_auth::name%]", + "description": "[%key:component::solaredge::config::step::user::sections::api_auth::description%]", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + }, + "web_auth": { + "name": "[%key:component::solaredge::config::step::user::sections::web_auth::name%]", + "description": "[%key:component::solaredge::config::step::user::sections::web_auth::description%]", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + } } }, "error": { @@ -45,7 +76,8 @@ "auth_missing": "You must provide either an API key or a username and password." }, "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%]" } }, "entity": { diff --git a/tests/components/solaredge/test_config_flow.py b/tests/components/solaredge/test_config_flow.py index cb4ec76c674..4ae6422d87e 100644 --- a/tests/components/solaredge/test_config_flow.py +++ b/tests/components/solaredge/test_config_flow.py @@ -307,3 +307,98 @@ async def test_web_login_errors( CONF_PASSWORD: PASSWORD, } assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reconfigure_flow_api_key( + recorder_mock: Recorder, + hass: HomeAssistant, + solaredge_api: Mock, + mock_setup_entry: AsyncMock, +) -> None: + """Test reconfigure flow with API key.""" + entry = MockConfigEntry( + domain=DOMAIN, + title=NAME, + data={CONF_SITE_ID: SITE_ID, CONF_API_KEY: "old_api_key"}, + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SECTION_API_AUTH: {CONF_API_KEY: API_KEY}, + }, + ) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "reconfigure_successful" + + await hass.async_block_till_done() + + assert entry.title == NAME + assert entry.data[CONF_SITE_ID] == SITE_ID + assert entry.data[CONF_API_KEY] == API_KEY + assert mock_setup_entry.call_count == 1 + + +async def test_reconfigure_flow_web_login_and_errors( + recorder_mock: Recorder, + hass: HomeAssistant, + solaredge_web_api: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test reconfigure flow with web login and error handling.""" + entry = MockConfigEntry( + domain=DOMAIN, + title=NAME, + data={CONF_SITE_ID: SITE_ID, CONF_API_KEY: "old_api_key"}, + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "reconfigure" + + # Test error + solaredge_web_api.async_get_equipment.side_effect = ClientResponseError( + None, None, status=401 + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SECTION_WEB_AUTH: { + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + }, + ) + assert result.get("type") is FlowResultType.FORM + assert result.get("errors") == {"base": "invalid_auth"} + + # Test recovery + solaredge_web_api.async_get_equipment.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SECTION_WEB_AUTH: { + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + }, + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "reconfigure_successful" + + await hass.async_block_till_done() + + assert entry.title == NAME + assert entry.data == { + CONF_SITE_ID: SITE_ID, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + } + assert mock_setup_entry.call_count == 1