From c35a6dc044940f7e41aac188f556a49e0adcf313 Mon Sep 17 00:00:00 2001 From: Paul Laffitte Date: Tue, 24 Mar 2026 21:30:43 +0100 Subject: [PATCH] Add reconfiguration flow to QNAP (#166064) Co-authored-by: Joostlek --- homeassistant/components/qnap/config_flow.py | 82 ++++++++++---- homeassistant/components/qnap/strings.json | 20 ++++ tests/components/qnap/test_config_flow.py | 113 +++++++++++++++++-- 3 files changed, 186 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/qnap/config_flow.py b/homeassistant/components/qnap/config_flow.py index 504883b55e9..c9b84faf8d6 100644 --- a/homeassistant/components/qnap/config_flow.py +++ b/homeassistant/components/qnap/config_flow.py @@ -47,33 +47,43 @@ class QnapConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + async def _async_validate_input( + self, user_input: dict[str, Any] + ) -> tuple[dict[str, str], dict[str, Any] | None]: + """Validate user input by connecting to the QNAP device.""" + errors: dict[str, str] = {} + host = user_input[CONF_HOST] + protocol = "https" if user_input[CONF_SSL] else "http" + api = QNAPStats( + host=f"{protocol}://{host}", + port=user_input[CONF_PORT], + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + verify_ssl=user_input[CONF_VERIFY_SSL], + timeout=DEFAULT_TIMEOUT, + ) + try: + stats = await self.hass.async_add_executor_job(api.get_system_stats) + except ConnectTimeout: + errors["base"] = "cannot_connect" + except TypeError: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected error") + errors["base"] = "unknown" + else: + return errors, stats + return errors, None + async def async_step_user( self, user_input: dict[str, Any] | None = None, ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: - host = user_input[CONF_HOST] - protocol = "https" if user_input[CONF_SSL] else "http" - api = QNAPStats( - host=f"{protocol}://{host}", - port=user_input[CONF_PORT], - username=user_input[CONF_USERNAME], - password=user_input[CONF_PASSWORD], - verify_ssl=user_input[CONF_VERIFY_SSL], - timeout=DEFAULT_TIMEOUT, - ) - try: - stats = await self.hass.async_add_executor_job(api.get_system_stats) - except ConnectTimeout: - errors["base"] = "cannot_connect" - except TypeError: - errors["base"] = "invalid_auth" - except Exception: - _LOGGER.exception("Unexpected error") - errors["base"] = "unknown" - else: + errors, stats = await self._async_validate_input(user_input) + if not errors and stats is not None: unique_id = stats["system"]["serial_number"] await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() @@ -85,3 +95,33 @@ class QnapConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=self.add_suggested_values_to_schema(DATA_SCHEMA, user_input), 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] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + errors, stats = await self._async_validate_input(user_input) + if not errors and stats is not None: + unique_id = stats["system"]["serial_number"] + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates=user_input, + ) + + suggested_values: dict[str, Any] = dict(user_input or reconfigure_entry.data) + suggested_values.pop(CONF_PASSWORD, None) + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + DATA_SCHEMA, + suggested_values, + ), + errors=errors, + ) diff --git a/homeassistant/components/qnap/strings.json b/homeassistant/components/qnap/strings.json index 2a73eb20467..3a3b338b51e 100644 --- a/homeassistant/components/qnap/strings.json +++ b/homeassistant/components/qnap/strings.json @@ -1,11 +1,31 @@ { "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "The device serial number does not match the original device. Please make sure you are connecting to the same QNAP NAS." + }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "port": "[%key:common::config_flow::data::port%]", + "ssl": "[%key:common::config_flow::data::ssl%]", + "username": "[%key:common::config_flow::data::username%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of your QNAP device." + }, + "description": "Update the connection settings for your QNAP NAS.", + "title": "Reconfigure QNAP connection" + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", diff --git a/tests/components/qnap/test_config_flow.py b/tests/components/qnap/test_config_flow.py index 57ac67525e2..ed9be0546dc 100644 --- a/tests/components/qnap/test_config_flow.py +++ b/tests/components/qnap/test_config_flow.py @@ -18,7 +18,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .conftest import TEST_HOST, TEST_PASSWORD, TEST_USERNAME +from .conftest import TEST_HOST, TEST_PASSWORD, TEST_SERIAL, TEST_USERNAME + +from tests.common import MockConfigEntry STANDARD_CONFIG = { CONF_USERNAME: TEST_USERNAME, @@ -26,6 +28,15 @@ STANDARD_CONFIG = { CONF_HOST: TEST_HOST, } +ENTRY_DATA = { + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_SSL: const.DEFAULT_SSL, + CONF_VERIFY_SSL: const.DEFAULT_VERIFY_SSL, + CONF_PORT: const.DEFAULT_PORT, +} + pytestmark = pytest.mark.usefixtures("mock_setup_entry", "qnap_connect") @@ -78,11 +89,97 @@ async def test_config_flow(hass: HomeAssistant, qnap_connect: MagicMock) -> None assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Test NAS name" - assert result["data"] == { - CONF_HOST: "1.2.3.4", - CONF_USERNAME: "admin", - CONF_PASSWORD: "password", - CONF_SSL: const.DEFAULT_SSL, - CONF_VERIFY_SSL: const.DEFAULT_VERIFY_SSL, - CONF_PORT: const.DEFAULT_PORT, + assert result["data"] == ENTRY_DATA + + +async def test_reconfigure_flow(hass: HomeAssistant, qnap_connect: MagicMock) -> None: + """Test reconfigure flow updates the config entry.""" + entry = MockConfigEntry( + domain=const.DOMAIN, + unique_id=TEST_SERIAL, + data=ENTRY_DATA, + ) + 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"], + {**STANDARD_CONFIG, CONF_HOST: "5.6.7.8"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data[CONF_HOST] == "5.6.7.8" + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (ConnectTimeout("Test error"), "cannot_connect"), + (TypeError("Test error"), "invalid_auth"), + (Exception("Test error"), "unknown"), + ], +) +async def test_reconfigure_errors( + hass: HomeAssistant, + qnap_connect: MagicMock, + side_effect: Exception, + error: str, +) -> None: + """Test reconfigure flow shows error on various exceptions.""" + entry = MockConfigEntry( + domain=const.DOMAIN, + unique_id=TEST_SERIAL, + data=ENTRY_DATA, + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + qnap_connect.get_system_stats.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + STANDARD_CONFIG, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": error} + + qnap_connect.get_system_stats.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + STANDARD_CONFIG, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_unique_id_mismatch( + hass: HomeAssistant, qnap_connect: MagicMock +) -> None: + """Test reconfigure aborts when serial number doesn't match.""" + entry = MockConfigEntry( + domain=const.DOMAIN, + unique_id=TEST_SERIAL, + data=ENTRY_DATA, + ) + entry.add_to_hass(hass) + + qnap_connect.get_system_stats.return_value = { + "system": {"serial_number": "DIFFERENT_SERIAL", "name": "Other NAS"} } + + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + STANDARD_CONFIG, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + assert entry.data[CONF_HOST] == TEST_HOST