From 90f0afded87ca18f128ff2e5b33ebc4fe7b04747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= Date: Mon, 13 Jul 2026 17:31:04 +0200 Subject: [PATCH] Add reconfigure flow to nobo_hub (#169493) --- .../components/nobo_hub/config_flow.py | 69 +++++++- .../components/nobo_hub/quality_scale.yaml | 2 +- .../components/nobo_hub/strings.json | 14 +- tests/components/nobo_hub/test_config_flow.py | 160 ++++++++++++++++++ 4 files changed, 239 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/nobo_hub/config_flow.py b/homeassistant/components/nobo_hub/config_flow.py index 58a6c90e2696..8df9940493d3 100644 --- a/homeassistant/components/nobo_hub/config_flow.py +++ b/homeassistant/components/nobo_hub/config_flow.py @@ -1,12 +1,13 @@ """Config flow for Nobø Ecohub integration.""" -import socket +import ipaddress from typing import TYPE_CHECKING, Any, override from pynobo import nobo import voluptuous as vol from homeassistant.config_entries import ( + ConfigEntryState, ConfigFlow, ConfigFlowResult, OptionsFlowWithReload, @@ -199,6 +200,68 @@ class NoboHubConfigFlow(ConfigFlow, domain=DOMAIN): }, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of an existing hub. + + Only the IP address is editable. When the entry is not loaded, + the new IP is probed here before updating. When the entry is + loaded, probing is skipped to avoid competing with the active + connection for the hub's limited concurrent-connection slots; + the reload's ``async_setup_entry`` re-validates the updated IP. + """ + reconfigure_entry = self._get_reconfigure_entry() + errors: dict[str, str] = {} + + if user_input is not None: + new_ip = user_input[CONF_IP_ADDRESS] + is_loaded = reconfigure_entry.state is ConfigEntryState.LOADED + try: + ipaddress.ip_address(new_ip) + except ValueError: + errors[CONF_IP_ADDRESS] = "invalid_ip" + else: + try: + # Probe the new IP only when the integration is not currently + # loaded — if it were, the running connection would compete + # with the probe for the hub's limited concurrent-connection + # slots. + if not is_loaded: + await self._test_connection( + reconfigure_entry.data[CONF_SERIAL], new_ip + ) + except NoboHubConnectError as error: + # The serial is fixed in reconfigure, so blame the IP rather + # than the (uneditable) serial number. + errors[CONF_IP_ADDRESS] = ( + "cannot_connect_ip" + if error.msg == "cannot_connect" + else error.msg + ) + else: + if new_ip == reconfigure_entry.data[CONF_IP_ADDRESS] and is_loaded: + # No-op: IP unchanged and the running integration already + # proves it works. Skip the reload to avoid a needless + # reconnect. + return self.async_abort(reason="reconfigure_successful") + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates={CONF_IP_ADDRESS: new_ip}, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + vol.Schema({vol.Required(CONF_IP_ADDRESS): str}), + user_input or reconfigure_entry.data, + ), + errors=errors, + description_placeholders={ + CONF_SERIAL: reconfigure_entry.data[CONF_SERIAL], + }, + ) + async def async_step_manual( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -245,8 +308,8 @@ class NoboHubConfigFlow(ConfigFlow, domain=DOMAIN): if len(serial) != SERIAL_LENGTH or not serial.isdigit(): raise NoboHubConnectError("invalid_serial") try: - socket.inet_aton(ip_address) - except OSError as err: + ipaddress.ip_address(ip_address) + except ValueError as err: raise NoboHubConnectError("invalid_ip") from err hub = nobo(serial=serial, ip=ip_address, discover=False, synchronous=False) # pynobo distinguishes the two failure modes: TCP-level errors diff --git a/homeassistant/components/nobo_hub/quality_scale.yaml b/homeassistant/components/nobo_hub/quality_scale.yaml index 8855f9f95240..1812cab9b10f 100644 --- a/homeassistant/components/nobo_hub/quality_scale.yaml +++ b/homeassistant/components/nobo_hub/quality_scale.yaml @@ -65,7 +65,7 @@ rules: entity-translations: todo exception-translations: todo icon-translations: todo - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: Integration has no repair scenarios. diff --git a/homeassistant/components/nobo_hub/strings.json b/homeassistant/components/nobo_hub/strings.json index 07cd3d15c269..4725b8cb05d8 100644 --- a/homeassistant/components/nobo_hub/strings.json +++ b/homeassistant/components/nobo_hub/strings.json @@ -3,7 +3,8 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "cannot_discover": "Could not detect a Nobø Ecohub at the discovered IP address." + "cannot_discover": "Could not detect a Nobø Ecohub at the discovered IP address.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "Failed to connect - check serial number", @@ -24,6 +25,15 @@ }, "description": "Configure a Nobø Ecohub not discovered on your local network. If your hub is on another network, you can still connect to it by entering the complete serial number (12 digits) and its IP address." }, + "reconfigure": { + "data": { + "ip_address": "[%key:common::config_flow::data::ip%]" + }, + "data_description": { + "ip_address": "[%key:component::nobo_hub::config::step::manual::data_description::ip_address%]" + }, + "description": "Update the IP address for Nobø Ecohub with serial number {serial}." + }, "selected": { "data": { "serial_suffix": "Serial number suffix (3 digits)" @@ -62,7 +72,7 @@ }, "exceptions": { "cannot_connect": { - "message": "Unable to connect to Nobø Ecohub with serial {serial} at {ip}; will retry. If the hub is on a different network from Home Assistant and has changed IP address, remove and re-add the integration." + "message": "Unable to connect to Nobø Ecohub with serial {serial} at {ip}; will retry. If the hub is on a different network from Home Assistant and has changed IP address, reconfigure the integration with the new IP address." }, "set_global_override_failed": { "message": "Failed to set global override." diff --git a/tests/components/nobo_hub/test_config_flow.py b/tests/components/nobo_hub/test_config_flow.py index d8155df92244..a62e84bb0a92 100644 --- a/tests/components/nobo_hub/test_config_flow.py +++ b/tests/components/nobo_hub/test_config_flow.py @@ -1,5 +1,6 @@ """Test the Nobø Ecohub config flow.""" +import errno from unittest.mock import AsyncMock, PropertyMock, patch import pytest @@ -15,6 +16,8 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from .conftest import SERIAL, STORED_IP + from tests.common import MockConfigEntry DHCP_DISCOVERY = DhcpServiceInfo( @@ -773,6 +776,163 @@ async def test_dhcp_discovery_no_broadcast(hass: HomeAssistant) -> None: assert result["reason"] == "cannot_discover" +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reconfigure_flow_changes_ip( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """A new IP is probed before save when the entry is not loaded.""" + new_ip = "192.168.1.200" + 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" + assert result["description_placeholders"] == {CONF_SERIAL: SERIAL} + + with ( + patch( + "homeassistant.components.nobo_hub.config_flow.nobo.async_connect_hub", + return_value=True, + ) as mock_connect, + patch( + "homeassistant.components.nobo_hub.config_flow.nobo.hub_info", + new_callable=PropertyMock, + create=True, + return_value={"name": "My Nobø Ecohub"}, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_IP_ADDRESS: new_ip}, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + assert mock_config_entry.data == {CONF_SERIAL: SERIAL, CONF_IP_ADDRESS: new_ip} + mock_connect.assert_awaited_once_with(new_ip, SERIAL) + + +@pytest.mark.parametrize( + ("submitted_ip", "connect_outcome", "expected_error", "expected_connect_count"), + [ + ( + "192.168.1.200", + {"side_effect": ConnectionRefusedError(errno.ECONNREFUSED, "")}, + "cannot_connect_ip", + 1, + ), + ("not-an-ip", {"return_value": True}, "invalid_ip", 0), + ], + ids=["unreachable_ip", "invalid_format"], +) +@pytest.mark.usefixtures("mock_setup_entry", "mock_unload_entry") +async def test_reconfigure_flow_rejects_bad_ip( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + submitted_ip: str, + connect_outcome: dict[str, object], + expected_error: str, + expected_connect_count: int, +) -> None: + """A bad IP is rejected inline; resubmitting a good IP completes the reconfigure.""" + recovery_ip = "192.168.1.201" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + with patch( + "homeassistant.components.nobo_hub.config_flow.nobo.async_connect_hub", + **connect_outcome, + ) as mock_connect: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_IP_ADDRESS: submitted_ip}, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {CONF_IP_ADDRESS: expected_error} + assert mock_config_entry.data[CONF_IP_ADDRESS] == STORED_IP + assert mock_connect.await_count == expected_connect_count + + with ( + patch( + "homeassistant.components.nobo_hub.config_flow.nobo.async_connect_hub", + return_value=True, + ), + patch( + "homeassistant.components.nobo_hub.config_flow.nobo.hub_info", + new_callable=PropertyMock, + create=True, + return_value={"name": "My Nobø Ecohub"}, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_IP_ADDRESS: recovery_ip}, + ) + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + assert mock_config_entry.data == {CONF_SERIAL: SERIAL, CONF_IP_ADDRESS: recovery_ip} + + +async def test_reconfigure_flow_unchanged_ip_skips_reload( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, +) -> None: + """Submitting the same IP while connected aborts without triggering a reload.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + mock_setup_entry.reset_mock() + + result = await mock_config_entry.start_reconfigure_flow(hass) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_IP_ADDRESS: STORED_IP}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + mock_unload_entry.assert_not_awaited() + mock_setup_entry.assert_not_awaited() + + +async def test_reconfigure_flow_changed_ip_triggers_reload_and_skips_probe( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, +) -> None: + """Submitting a different IP while connected reloads the entry without probing.""" + new_ip = "192.168.1.200" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + mock_setup_entry.reset_mock() + mock_unload_entry.reset_mock() + + result = await mock_config_entry.start_reconfigure_flow(hass) + with patch( + "homeassistant.components.nobo_hub.config_flow.nobo.async_connect_hub" + ) as mock_connect: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_IP_ADDRESS: new_ip}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_IP_ADDRESS] == new_ip + mock_connect.assert_not_awaited() + mock_unload_entry.assert_awaited_once() + mock_setup_entry.assert_awaited_once() + + async def test_options_flow( hass: HomeAssistant, mock_setup_entry: AsyncMock,