From 7d29177a46a050bd3dc0ffbeec341f531c4c9de9 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 18 Sep 2026 16:21:35 +0200 Subject: [PATCH] Add reconfigure flow to Mikrotik (#181763) --- .../components/mikrotik/config_flow.py | 48 ++++++++-- .../components/mikrotik/quality_scale.yaml | 2 +- .../components/mikrotik/strings.json | 20 +++- tests/components/mikrotik/test_config_flow.py | 96 +++++++++++++++++++ 4 files changed, 156 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/mikrotik/config_flow.py b/homeassistant/components/mikrotik/config_flow.py index 64dd8c077e8f..99e129111bfb 100644 --- a/homeassistant/components/mikrotik/config_flow.py +++ b/homeassistant/components/mikrotik/config_flow.py @@ -27,6 +27,16 @@ from .const import ( from .coordinator import MikrotikConfigEntry, get_api from .errors import CannotConnect, LoginError +DATA_SCHEMA = probatio.Schema( + { + probatio.Required(CONF_HOST): str, + probatio.Required(CONF_USERNAME): str, + probatio.Required(CONF_PASSWORD): str, + probatio.Optional(CONF_PORT, default=DEFAULT_API_PORT): int, + probatio.Optional(CONF_VERIFY_SSL, default=False): bool, + } +) + class MikrotikFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a Mikrotik config flow.""" @@ -65,14 +75,36 @@ class MikrotikFlowHandler(ConfigFlow, domain=DOMAIN): ) return self.async_show_form( step_id="user", - data_schema=probatio.Schema( - { - probatio.Required(CONF_HOST): str, - probatio.Required(CONF_USERNAME): str, - probatio.Required(CONF_PASSWORD): str, - probatio.Optional(CONF_PORT, default=DEFAULT_API_PORT): int, - probatio.Optional(CONF_VERIFY_SSL, default=False): bool, - } + data_schema=DATA_SCHEMA, + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + errors = {} + reconfigure_entry = self._get_reconfigure_entry() + if user_input is not None: + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + + try: + await self.hass.async_add_executor_job(get_api, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except LoginError: + errors[CONF_USERNAME] = "invalid_auth" + errors[CONF_PASSWORD] = "invalid_auth" + + if not errors: + 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( + DATA_SCHEMA, reconfigure_entry.data ), errors=errors, ) diff --git a/homeassistant/components/mikrotik/quality_scale.yaml b/homeassistant/components/mikrotik/quality_scale.yaml index adca560d3af1..898c0f804d04 100644 --- a/homeassistant/components/mikrotik/quality_scale.yaml +++ b/homeassistant/components/mikrotik/quality_scale.yaml @@ -66,7 +66,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: no known use cases for repair issues or flows, yet diff --git a/homeassistant/components/mikrotik/strings.json b/homeassistant/components/mikrotik/strings.json index a1a27d4ae6d1..2fdf0a7ed734 100644 --- a/homeassistant/components/mikrotik/strings.json +++ b/homeassistant/components/mikrotik/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": "[%key:common::config_flow::error::cannot_connect%]", @@ -19,6 +20,23 @@ "description": "The password for {username} is invalid.", "title": "[%key:common::config_flow::title::reauth%]" }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "port": "[%key:common::config_flow::data::port%]", + "username": "[%key:common::config_flow::data::username%]", + "verify_ssl": "[%key:common::config_flow::data::ssl%]" + }, + "data_description": { + "host": "[%key:component::mikrotik::config::step::user::data_description::host%]", + "password": "[%key:component::mikrotik::config::step::user::data_description::password%]", + "port": "[%key:component::mikrotik::config::step::user::data_description::port%]", + "username": "[%key:component::mikrotik::config::step::user::data_description::username%]", + "verify_ssl": "[%key:component::mikrotik::config::step::user::data_description::verify_ssl%]" + }, + "title": "[%key:component::mikrotik::config::step::user::title%]" + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", diff --git a/tests/components/mikrotik/test_config_flow.py b/tests/components/mikrotik/test_config_flow.py index 605f6496b6c1..d10e878969fc 100644 --- a/tests/components/mikrotik/test_config_flow.py +++ b/tests/components/mikrotik/test_config_flow.py @@ -1,5 +1,7 @@ """Test Mikrotik setup process.""" +from unittest.mock import patch + from librouteros.exceptions import ConnectionClosed, TrapError import pytest @@ -235,3 +237,97 @@ async def test_reauth_failed_conn_error( assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} + + +RECONFIGURE_INPUT = { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "new-username", + CONF_PASSWORD: "new-password", + CONF_PORT: 8729, + CONF_VERIFY_SSL: True, +} + + +async def test_reconfigure_success( + hass: HomeAssistant, + mock_config_entry: MockConfigEntryFactory, +) -> None: + """Test reconfiguring the integration updates the config entry.""" + entry = mock_config_entry(data=DEMO_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" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=RECONFIGURE_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == RECONFIGURE_INPUT + + +async def test_reconfigure_host_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntryFactory, +) -> None: + """Test reconfigure aborts when the new host belongs to another entry.""" + entry = mock_config_entry(data=DEMO_USER_INPUT) + entry.add_to_hass(hass) + other_entry = mock_config_entry(data={**DEMO_USER_INPUT, CONF_HOST: "1.1.1.1"}) + other_entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=RECONFIGURE_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert entry.data == DEMO_USER_INPUT + + +@pytest.mark.parametrize( + ("side_effect", "expected_errors"), + [ + pytest.param(CONN_ERROR, {"base": "cannot_connect"}, id="cannot_connect"), + pytest.param( + AUTH_ERROR, + {CONF_USERNAME: "invalid_auth", CONF_PASSWORD: "invalid_auth"}, + id="invalid_auth", + ), + ], +) +async def test_reconfigure_error_recovery( + hass: HomeAssistant, + mock_config_entry: MockConfigEntryFactory, + side_effect: Exception, + expected_errors: dict[str, str], +) -> None: + """Test reconfigure shows an error and then recovers on valid input.""" + entry = mock_config_entry(data=DEMO_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" + + with patch("librouteros.connect", side_effect=side_effect): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=RECONFIGURE_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == expected_errors + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=RECONFIGURE_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == RECONFIGURE_INPUT