From 2ed8ec0bdf17feb76d283807d235e06c2f7b6418 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Sat, 31 Jan 2026 20:21:55 +0100 Subject: [PATCH] Add reconfigure to Proxmox (#161941) Co-authored-by: Norbert Rittel --- .../components/proxmoxve/config_flow.py | 76 ++++++++++-- .../components/proxmoxve/strings.json | 15 ++- .../components/proxmoxve/test_config_flow.py | 115 ++++++++++++++++++ 3 files changed, 192 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/proxmoxve/config_flow.py b/homeassistant/components/proxmoxve/config_flow.py index 6f6c7595ede..83c3372f29a 100644 --- a/homeassistant/components/proxmoxve/config_flow.py +++ b/homeassistant/components/proxmoxve/config_flow.py @@ -111,19 +111,8 @@ class ProxmoxveConfigFlow(ConfigFlow, domain=DOMAIN): proxmox_nodes: list[dict[str, Any]] = [] if user_input is not None: self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) - try: - proxmox_nodes = await self.hass.async_add_executor_job( - _get_nodes_data, user_input - ) - except ProxmoxConnectTimeout: - errors["base"] = "connect_timeout" - except ProxmoxAuthenticationError: - errors["base"] = "invalid_auth" - except ProxmoxSSLError: - errors["base"] = "ssl_error" - except ProxmoxNoNodesFound: - errors["base"] = "no_nodes_found" - else: + proxmox_nodes, errors = await self._validate_input(user_input) + if not errors: return self.async_create_entry( title=user_input[CONF_HOST], data={**user_input, CONF_NODES: proxmox_nodes}, @@ -135,6 +124,67 @@ class ProxmoxveConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + errors: dict[str, str] = {} + reconf_entry = self._get_reconfigure_entry() + suggested_values = { + CONF_HOST: reconf_entry.data[CONF_HOST], + CONF_PORT: reconf_entry.data[CONF_PORT], + CONF_REALM: reconf_entry.data[CONF_REALM], + CONF_USERNAME: reconf_entry.data[CONF_USERNAME], + CONF_PASSWORD: reconf_entry.data[CONF_PASSWORD], + CONF_VERIFY_SSL: reconf_entry.data[CONF_VERIFY_SSL], + } + + if user_input: + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + user_input = {**reconf_entry.data, **user_input} + _, errors = await self._validate_input(user_input) + if not errors: + return self.async_update_reload_and_abort( + reconf_entry, + data_updates={ + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + CONF_REALM: user_input[CONF_REALM], + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], + }, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + data_schema=CONFIG_SCHEMA, + suggested_values=user_input or suggested_values, + ), + errors=errors, + ) + + async def _validate_input( + self, user_input: dict[str, Any] + ) -> tuple[list[dict[str, Any]], dict[str, str]]: + """Validate the user input. Return nodes data and/or errors.""" + errors: dict[str, str] = {} + proxmox_nodes: list[dict[str, Any]] = [] + try: + proxmox_nodes = await self.hass.async_add_executor_job( + _get_nodes_data, user_input + ) + except ProxmoxConnectTimeout: + errors["base"] = "connect_timeout" + except ProxmoxAuthenticationError: + errors["base"] = "invalid_auth" + except ProxmoxSSLError: + errors["base"] = "ssl_error" + except ProxmoxNoNodesFound: + errors["base"] = "no_nodes_found" + return proxmox_nodes, errors + async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: """Handle a flow initiated by configuration file.""" self._async_abort_entries_match({CONF_HOST: import_data[CONF_HOST]}) diff --git a/homeassistant/components/proxmoxve/strings.json b/homeassistant/components/proxmoxve/strings.json index 842784306b2..d289ec6278f 100644 --- a/homeassistant/components/proxmoxve/strings.json +++ b/homeassistant/components/proxmoxve/strings.json @@ -1,7 +1,8 @@ { "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%]" }, "error": { "cannot_connect": "Cannot connect to Proxmox VE server", @@ -11,6 +12,18 @@ "ssl_error": "SSL check failed. Check the SSL settings" }, "step": { + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "port": "[%key:common::config_flow::data::port%]", + "realm": "Realm", + "username": "[%key:common::config_flow::data::username%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "description": "Use the following form to reconfigure your Proxmox VE server connection.", + "title": "Reconfigure Proxmox VE integration" + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", diff --git a/tests/components/proxmoxve/test_config_flow.py b/tests/components/proxmoxve/test_config_flow.py index 2e6ee6d93c9..8087eb8124f 100644 --- a/tests/components/proxmoxve/test_config_flow.py +++ b/tests/components/proxmoxve/test_config_flow.py @@ -228,3 +228,118 @@ async def test_import_flow_exceptions( assert result["reason"] == reason assert len(mock_setup_entry.mock_calls) == 0 assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + + +async def test_full_flow_reconfigure( + hass: HomeAssistant, + mock_proxmox_client: MagicMock, + mock_setup_entry: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the full flow of the config flow.""" + 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=MOCK_USER_STEP, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == MOCK_TEST_CONFIG + + +async def test_full_flow_reconfigure_match_entries( + hass: HomeAssistant, + mock_proxmox_client: MagicMock, + mock_setup_entry: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the full flow of the config flow, this time matching existing entries.""" + mock_config_entry.add_to_hass(hass) + + # Adding a second entry with a different host, since configuring the same host should work + second_entry = MockConfigEntry( + domain=DOMAIN, + title="Second ProxmoxVE", + data={ + **MOCK_TEST_CONFIG, + CONF_HOST: "192.168.1.1", + }, + ) + second_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={ + **MOCK_USER_STEP, + CONF_HOST: "192.168.1.1", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_config_entry.data == MOCK_TEST_CONFIG + assert len(mock_setup_entry.mock_calls) == 0 + + +@pytest.mark.parametrize( + ("exception", "reason"), + [ + ( + AuthenticationError("Invalid credentials"), + "invalid_auth", + ), + ( + SSLError("SSL handshake failed"), + "ssl_error", + ), + ( + ConnectTimeout("Connection timed out"), + "connect_timeout", + ), + ( + ResourceException("404", "status_message", "content"), + "no_nodes_found", + ), + ], +) +async def test_full_flow_reconfigure_exceptions( + hass: HomeAssistant, + mock_proxmox_client: MagicMock, + mock_setup_entry: MagicMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + reason: str, +) -> None: + """Test the full flow of the config flow.""" + 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" + + mock_proxmox_client.nodes.get.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_STEP, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": reason} + + mock_proxmox_client.nodes.get.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_STEP, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == MOCK_TEST_CONFIG