From d83502514a28d4699f0eecc8a89242fd3b2cc491 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Fri, 26 Sep 2025 19:48:51 +0200 Subject: [PATCH] Fix Thread flow abort on multiple flows (#153048) --- .../components/thread/config_flow.py | 14 ++- homeassistant/components/thread/manifest.json | 1 + homeassistant/generated/integrations.json | 3 +- tests/components/thread/test_config_flow.py | 87 +++++++++++++++++-- 4 files changed, 93 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/thread/config_flow.py b/homeassistant/components/thread/config_flow.py index bf202a50c347..42caf5d9e32c 100644 --- a/homeassistant/components/thread/config_flow.py +++ b/homeassistant/components/thread/config_flow.py @@ -5,7 +5,11 @@ from __future__ import annotations from typing import Any from homeassistant.components import onboarding -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + DEFAULT_DISCOVERY_UNIQUE_ID, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -18,14 +22,18 @@ class ThreadConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_import(self, import_data: None) -> ConfigFlowResult: """Set up by import from async_setup.""" - await self._async_handle_discovery_without_unique_id() + await self.async_set_unique_id( + DEFAULT_DISCOVERY_UNIQUE_ID, raise_on_progress=False + ) return self.async_create_entry(title="Thread", data={}) async def async_step_user( self, user_input: dict[str, str] | None = None ) -> ConfigFlowResult: """Set up by import from async_setup.""" - await self._async_handle_discovery_without_unique_id() + await self.async_set_unique_id( + DEFAULT_DISCOVERY_UNIQUE_ID, raise_on_progress=False + ) return self.async_create_entry(title="Thread", data={}) async def async_step_zeroconf( diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json index 868ced022b8b..22d55f57d48d 100644 --- a/homeassistant/components/thread/manifest.json +++ b/homeassistant/components/thread/manifest.json @@ -8,5 +8,6 @@ "integration_type": "service", "iot_class": "local_polling", "requirements": ["python-otbr-api==2.7.0", "pyroute2==0.7.5"], + "single_config_entry": true, "zeroconf": ["_meshcop._udp.local."] } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e260b37afe61..2ce0e314afb5 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6807,7 +6807,8 @@ "name": "Thread", "integration_type": "service", "config_flow": true, - "iot_class": "local_polling" + "iot_class": "local_polling", + "single_config_entry": true }, "tibber": { "name": "Tibber", diff --git a/tests/components/thread/test_config_flow.py b/tests/components/thread/test_config_flow.py index 7feefdafedf9..1f9561ac4c7d 100644 --- a/tests/components/thread/test_config_flow.py +++ b/tests/components/thread/test_config_flow.py @@ -3,6 +3,8 @@ from ipaddress import ip_address from unittest.mock import patch +import pytest + from homeassistant.components import thread from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -56,14 +58,18 @@ async def test_import(hass: HomeAssistant) -> None: assert config_entry.unique_id is None -async def test_import_then_zeroconf(hass: HomeAssistant) -> None: - """Test the import flow.""" +@pytest.mark.parametrize("source", ["import", "user"]) +async def test_single_instance_allowed_zeroconf( + hass: HomeAssistant, + source: str, +) -> None: + """Test zeroconf single instance allowed abort reason.""" with patch( "homeassistant.components.thread.async_setup_entry", return_value=True, ) as mock_setup_entry: result = await hass.config_entries.flow.async_init( - thread.DOMAIN, context={"source": "import"} + thread.DOMAIN, context={"source": source} ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -77,7 +83,7 @@ async def test_import_then_zeroconf(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "single_instance_allowed" assert len(mock_setup_entry.mock_calls) == 0 @@ -152,8 +158,45 @@ async def test_zeroconf_setup_onboarding(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_zeroconf_then_import(hass: HomeAssistant) -> None: - """Test the import flow.""" +@pytest.mark.parametrize( + ("first_source", "second_source"), [("import", "user"), ("user", "import")] +) +async def test_import_and_user( + hass: HomeAssistant, + first_source: str, + second_source: str, +) -> None: + """Test single instance allowed for user and import.""" + with patch( + "homeassistant.components.thread.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + thread.DOMAIN, context={"source": first_source} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(mock_setup_entry.mock_calls) == 1 + + with patch( + "homeassistant.components.thread.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + thread.DOMAIN, context={"source": second_source} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + assert len(mock_setup_entry.mock_calls) == 0 + + +@pytest.mark.parametrize("source", ["import", "user"]) +async def test_zeroconf_then_import_user( + hass: HomeAssistant, + source: str, +) -> None: + """Test single instance allowed abort reason for import/user flow.""" result = await hass.config_entries.flow.async_init( thread.DOMAIN, context={"source": "zeroconf"}, data=TEST_ZEROCONF_RECORD ) @@ -169,9 +212,37 @@ async def test_zeroconf_then_import(hass: HomeAssistant) -> None: return_value=True, ) as mock_setup_entry: result = await hass.config_entries.flow.async_init( - thread.DOMAIN, context={"source": "import"} + thread.DOMAIN, context={"source": source} ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "single_instance_allowed" assert len(mock_setup_entry.mock_calls) == 0 + + +@pytest.mark.parametrize("source", ["import", "user"]) +async def test_zeroconf_in_progress_then_import_user( + hass: HomeAssistant, + source: str, +) -> None: + """Test priority (import/user) flow with zeroconf flow in progress.""" + result = await hass.config_entries.flow.async_init( + thread.DOMAIN, context={"source": "zeroconf"}, data=TEST_ZEROCONF_RECORD + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + + with patch( + "homeassistant.components.thread.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + thread.DOMAIN, context={"source": source} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert mock_setup_entry.call_count == 1 + + flows_in_progress = hass.config_entries.flow.async_progress() + assert len(flows_in_progress) == 0