From 97844d7ee09e69fc66a3d4170fa431dc4e0d3f6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= Date: Sat, 27 Jun 2026 14:18:53 +0200 Subject: [PATCH] Filter already-configured hubs from nobo_hub config flow (#169593) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) --- .../components/nobo_hub/config_flow.py | 19 +++- homeassistant/components/nobo_hub/const.py | 5 + tests/components/nobo_hub/test_config_flow.py | 103 +++++++++++++++++- 3 files changed, 124 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nobo_hub/config_flow.py b/homeassistant/components/nobo_hub/config_flow.py index c5b0e3c062f..58a6c90e269 100644 --- a/homeassistant/components/nobo_hub/config_flow.py +++ b/homeassistant/components/nobo_hub/config_flow.py @@ -25,6 +25,8 @@ from .const import ( DOMAIN, OVERRIDE_TYPE_CONSTANT, OVERRIDE_TYPE_NOW, + SERIAL_LENGTH, + SERIAL_PREFIX_LENGTH, ) DATA_NOBO_HUB_IMPL = "nobo_hub_flow_implementation" @@ -49,7 +51,20 @@ class NoboHubConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle the initial step.""" if self._discovered_hubs is None: - self._discovered_hubs = dict(await nobo.async_discover_hubs()) + # Wait 5s — real-world gaps up to ~4s have been observed. + discovered = dict(await nobo.async_discover_hubs(autodiscover_wait=5.0)) + # Hide hubs that already have a config entry. Include matching on IP + # as serial prefix is not unique. + configured = { + (entry.data[CONF_IP_ADDRESS], entry.unique_id[:SERIAL_PREFIX_LENGTH]) + for entry in self._async_current_entries(include_ignore=False) + if entry.unique_id + } + self._discovered_hubs = { + ip: prefix + for ip, prefix in discovered.items() + if (ip, prefix) not in configured + } if not self._discovered_hubs: # No hubs auto discovered @@ -227,7 +242,7 @@ class NoboHubConfigFlow(ConfigFlow, domain=DOMAIN): ) async def _test_connection(self, serial: str, ip_address: str) -> str: - if not len(serial) == 12 or not serial.isdigit(): + if len(serial) != SERIAL_LENGTH or not serial.isdigit(): raise NoboHubConnectError("invalid_serial") try: socket.inet_aton(ip_address) diff --git a/homeassistant/components/nobo_hub/const.py b/homeassistant/components/nobo_hub/const.py index 4e1cd852f17..f69dce354bf 100644 --- a/homeassistant/components/nobo_hub/const.py +++ b/homeassistant/components/nobo_hub/const.py @@ -9,6 +9,11 @@ CONF_OVERRIDE_TYPE = "override_type" OVERRIDE_TYPE_CONSTANT = "constant" OVERRIDE_TYPE_NOW = "now" +# Hub serial: 9-digit batch prefix + 3-digit per-hub suffix. Discovery +# broadcasts only the prefix; the user supplies the suffix. +SERIAL_PREFIX_LENGTH = 9 +SERIAL_LENGTH = SERIAL_PREFIX_LENGTH + 3 + NOBO_MANUFACTURER = "Glen Dimplex Nordic AS" ATTR_HARDWARE_VERSION: Final = "hardware_version" ATTR_SOFTWARE_VERSION: Final = "software_version" diff --git a/tests/components/nobo_hub/test_config_flow.py b/tests/components/nobo_hub/test_config_flow.py index 5d75770dd21..d8155df9224 100644 --- a/tests/components/nobo_hub/test_config_flow.py +++ b/tests/components/nobo_hub/test_config_flow.py @@ -32,12 +32,13 @@ async def test_configure_with_discover( with patch( "homeassistant.components.nobo_hub.config_flow.nobo.async_discover_hubs", return_value=[("1.1.1.1", "123456789")], - ): + ) as mock_discover: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" + mock_discover.assert_awaited_once_with(autodiscover_wait=5.0) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -76,6 +77,106 @@ async def test_configure_with_discover( mock_setup_entry.assert_awaited_once() +@pytest.mark.parametrize( + ("discovered", "expected_devices", "selected_device"), + [ + # Same IP+prefix hidden; sibling with same prefix at a different IP shown. + ( + [("1.1.1.1", "111111111"), ("2.2.2.2", "111111111")], + {"2.2.2.2", "manual"}, + "2.2.2.2", + ), + # Same IP, different prefix → different hub (e.g. replacement), shown. + ([("1.1.1.1", "222222222")], {"1.1.1.1", "manual"}, "1.1.1.1"), + ], + ids=["sibling_different_ip", "replaced_hub"], +) +async def test_configure_filters_configured_hubs( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + discovered: list[tuple[str, str]], + expected_devices: set[str], + selected_device: str, +) -> None: + """Configured (IP, prefix) pairs are hidden; the user can pick a remaining one.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="111111111012", + data={CONF_SERIAL: "111111111012", CONF_IP_ADDRESS: "1.1.1.1"}, + ).add_to_hass(hass) + + with patch("pynobo.nobo.async_discover_hubs", return_value=discovered): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert set(result["data_schema"].schema["device"].container) == expected_devices + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"device": selected_device}, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "selected" + + with ( + patch("pynobo.nobo.async_connect_hub", return_value=True), + patch( + "pynobo.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"], + {"serial_suffix": "999"}, + ) + + assert result3["type"] is FlowResultType.CREATE_ENTRY + + +async def test_configure_skips_user_step_when_all_configured( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Flow falls through to manual when every discovered hub matches a configured pair.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="111111111012", + data={CONF_SERIAL: "111111111012", CONF_IP_ADDRESS: "1.1.1.1"}, + ).add_to_hass(hass) + + with patch( + "pynobo.nobo.async_discover_hubs", + return_value=[("1.1.1.1", "111111111")], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + + with ( + patch("pynobo.nobo.async_connect_hub", return_value=True), + patch( + "pynobo.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"], + {"serial": "999999999999", "ip_address": "9.9.9.9"}, + ) + + assert result2["type"] is FlowResultType.CREATE_ENTRY + + async def test_configure_manual( hass: HomeAssistant, mock_setup_entry: AsyncMock,