From ffca43027f34fa5f1d7a8ff966b0e457d8373223 Mon Sep 17 00:00:00 2001 From: konsulten Date: Fri, 6 Mar 2026 20:23:17 +0100 Subject: [PATCH] Add reconfigure flow for systemnexa2 (#164361) --- CODEOWNERS | 4 +- .../components/systemnexa2/config_flow.py | 156 ++++++++++-------- .../components/systemnexa2/manifest.json | 4 +- .../components/systemnexa2/quality_scale.yaml | 2 +- .../components/systemnexa2/strings.json | 9 + .../systemnexa2/test_config_flow.py | 117 +++++++++++++ 6 files changed, 218 insertions(+), 74 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index df75f27c2c9..43b24da587b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1654,8 +1654,8 @@ build.json @home-assistant/supervisor /tests/components/system_bridge/ @timmo001 /homeassistant/components/systemmonitor/ @gjohansson-ST /tests/components/systemmonitor/ @gjohansson-ST -/homeassistant/components/systemnexa2/ @konsulten @slangstrom -/tests/components/systemnexa2/ @konsulten @slangstrom +/homeassistant/components/systemnexa2/ @konsulten +/tests/components/systemnexa2/ @konsulten /homeassistant/components/tado/ @erwindouna /tests/components/tado/ @erwindouna /homeassistant/components/tag/ @home-assistant/core diff --git a/homeassistant/components/systemnexa2/config_flow.py b/homeassistant/components/systemnexa2/config_flow.py index c71b5fd5af8..963b523dc43 100644 --- a/homeassistant/components/systemnexa2/config_flow.py +++ b/homeassistant/components/systemnexa2/config_flow.py @@ -9,7 +9,12 @@ import aiohttp from sn2.device import Device import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + SOURCE_USER, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import ( ATTR_MODEL, ATTR_SW_VERSION, @@ -18,7 +23,6 @@ from homeassistant.const import ( CONF_MODEL, CONF_NAME, ) -from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util.network import is_ip_address @@ -34,18 +38,6 @@ _SCHEMA = vol.Schema( ) -def _is_valid_host(ip_or_hostname: str) -> bool: - if not ip_or_hostname: - return False - if is_ip_address(ip_or_hostname): - return True - try: - socket.gethostbyname(ip_or_hostname) - except socket.gaierror: - return False - return True - - @dataclass(kw_only=True) class _DiscoveryInfo: name: str @@ -67,70 +59,77 @@ class SystemNexa2ConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle user-initiated flow.""" + """Handle user-initiated configuration and reconfiguration.""" errors: dict[str, str] = {} - if user_input is None: - return self.async_show_form(step_id="user", data_schema=_SCHEMA) + if user_input is not None: + host = user_input[CONF_HOST] + if not await self._async_is_valid_host(host): + errors["base"] = "invalid_host" + else: + try: + temp_dev = await Device.initiate_device( + host=host, + session=async_get_clientsession(self.hass), + ) + info = await temp_dev.get_info() + except TimeoutError, aiohttp.ClientError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + device_id = info.information.unique_id + device_model = info.information.model + device_version = info.information.sw_version + supported, error = Device.is_device_supported( + model=device_model, + device_version=device_version, + ) + if device_id is None or device_version is None or not supported: + _LOGGER.error("Unsupported model: %s", error) + return self.async_abort( + reason="unsupported_model", + description_placeholders={ + ATTR_MODEL: str(device_model), + ATTR_SW_VERSION: str(device_version), + }, + ) - host_or_ip = user_input[CONF_HOST] + await self.async_set_unique_id(info.information.unique_id) - if not _is_valid_host(host_or_ip): - errors["base"] = "invalid_host" - else: - temp_dev = await Device.initiate_device( - host=host_or_ip, - session=async_get_clientsession(self.hass), - ) + if self.source == SOURCE_USER: + self._abort_if_unique_id_configured() + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch(reason="wrong_device") - try: - info = await temp_dev.get_info() - except TimeoutError, aiohttp.ClientError: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates={CONF_HOST: host}, + ) + self._discovered_device = _DiscoveryInfo( + name=info.information.name, + host=host, + device_id=device_id, + model=device_model, + device_version=device_version, + ) + return await self._async_create_device_entry() - if errors: + if self.source == SOURCE_RECONFIGURE: return self.async_show_form( - step_id="user", data_schema=_SCHEMA, errors=errors + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + _SCHEMA, + user_input or self._get_reconfigure_entry().data, + ), + errors=errors, ) - - device_id = info.information.unique_id - device_model = info.information.model - device_version = info.information.sw_version - if device_id is None or device_model is None or device_version is None: - return self.async_abort( - reason="unsupported_model", - description_placeholders={ - ATTR_MODEL: str(device_model), - ATTR_SW_VERSION: str(device_version), - }, - ) - - self._discovered_device = _DiscoveryInfo( - name=info.information.name, - host=host_or_ip, - device_id=device_id, - model=device_model, - device_version=device_version, + return self.async_show_form( + step_id="user", + data_schema=_SCHEMA, + errors=errors, ) - supported, error = Device.is_device_supported( - model=self._discovered_device.model, - device_version=self._discovered_device.device_version, - ) - if not supported: - _LOGGER.error("Unsupported model: %s", error) - raise AbortFlow( - reason="unsupported_model", - description_placeholders={ - ATTR_MODEL: str(self._discovered_device.model), - ATTR_SW_VERSION: str(self._discovered_device.device_version), - }, - ) - await self._async_set_unique_id() - - return await self._async_create_device_entry() async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo @@ -198,3 +197,22 @@ class SystemNexa2ConfigFlow(ConfigFlow, domain=DOMAIN): CONF_DEVICE_ID: self._discovered_device.device_id, }, ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration.""" + return await self.async_step_user(user_input) + + async def _async_is_valid_host(self, ip_or_hostname: str) -> bool: + + if not ip_or_hostname: + return False + if is_ip_address(ip_or_hostname): + return True + try: + await self.hass.async_add_executor_job(socket.gethostbyname, ip_or_hostname) + + except socket.gaierror: + return False + return True diff --git a/homeassistant/components/systemnexa2/manifest.json b/homeassistant/components/systemnexa2/manifest.json index 3b20ea00ab9..dbbe0c05c57 100644 --- a/homeassistant/components/systemnexa2/manifest.json +++ b/homeassistant/components/systemnexa2/manifest.json @@ -1,12 +1,12 @@ { "domain": "systemnexa2", "name": "System Nexa 2", - "codeowners": ["@konsulten", "@slangstrom"], + "codeowners": ["@konsulten"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/systemnexa2", "integration_type": "device", "iot_class": "local_push", - "quality_scale": "silver", + "quality_scale": "platinum", "requirements": ["python-sn2==0.4.0"], "zeroconf": ["_systemnexa2._tcp.local."] } diff --git a/homeassistant/components/systemnexa2/quality_scale.yaml b/homeassistant/components/systemnexa2/quality_scale.yaml index fbec9bcebe5..cb413534cee 100644 --- a/homeassistant/components/systemnexa2/quality_scale.yaml +++ b/homeassistant/components/systemnexa2/quality_scale.yaml @@ -68,7 +68,7 @@ rules: icon-translations: status: exempt comment: No icons referenced currently - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: At the moment there are no repairable situations. diff --git a/homeassistant/components/systemnexa2/strings.json b/homeassistant/components/systemnexa2/strings.json index ed48e08e1bd..b4e62314a82 100644 --- a/homeassistant/components/systemnexa2/strings.json +++ b/homeassistant/components/systemnexa2/strings.json @@ -20,6 +20,15 @@ "description": "Do you want to add the device `{name}` to Home Assistant?", "title": "Discovered Nexa System 2 device" }, + "reconfigure": { + "data": { + "host": "[%key:component::systemnexa2::config::step::user::data::host%]" + }, + "data_description": { + "host": "[%key:component::systemnexa2::config::step::user::data_description::host%]" + }, + "description": "Update the IP address or hostname if your device has been moved to a different address on the network" + }, "user": { "data": { "host": "IP/hostname" diff --git a/tests/components/systemnexa2/test_config_flow.py b/tests/components/systemnexa2/test_config_flow.py index 91a11892454..f9af48d0afd 100644 --- a/tests/components/systemnexa2/test_config_flow.py +++ b/tests/components/systemnexa2/test_config_flow.py @@ -291,3 +291,120 @@ async def test_zeroconf_discovery_none_values(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "unsupported_model" + + +@pytest.mark.usefixtures("mock_system_nexa_2_device") +async def test_reconfigure_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test reconfiguration flow.""" + + mock_config_entry.add_to_hass(hass) + + assert mock_config_entry.data[CONF_HOST] != "10.0.0.132" + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + # Test successful reconfiguration with new host + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "10.0.0.132"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_HOST] == "10.0.0.132" + assert mock_config_entry.data[CONF_DEVICE_ID] == "aabbccddee02" + assert mock_config_entry.data[CONF_MODEL] == "WPO-01" + assert mock_config_entry.data[CONF_NAME] == "Outdoor Smart Plug" + + +async def test_reconfigure_flow_invalid_host( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguration flow with invalid host.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + # Mock socket.gethostbyname to raise error for invalid hostname + with patch( + "homeassistant.components.systemnexa2.config_flow.socket.gethostbyname", + side_effect=socket.gaierror(-2, "Name or service not known"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "invalid_hostname"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_host"} + + +async def test_reconfigure_flow_cannot_connect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_system_nexa_2_device: MagicMock, +) -> None: + """Test reconfiguration flow with connection error.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + mock_system_nexa_2_device.initiate_device.side_effect = TimeoutError( + "Connection failed" + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "10.0.0.132"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_system_nexa_2_device.initiate_device.side_effect = Exception("Unknown") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "10.0.0.132"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + +async def test_reconfigure_flow_wrong_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_system_nexa_2_device: MagicMock, +) -> None: + """Test reconfiguration flow with different device.""" + + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + different_device_info = InformationData( + name="Different Device", + model="Test Model", + unique_id="different_device_id", + sw_version="Test Model Version", + hw_version="Test HW Version", + wifi_dbm=-50, + wifi_ssid="Test WiFi SSID", + dimmable=False, + ) + mock_system_nexa_2_device.return_value.get_info.return_value = InformationUpdate( + information=different_device_info + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "10.0.0.132"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_device"