diff --git a/homeassistant/components/matter/config_flow.py b/homeassistant/components/matter/config_flow.py index 7dc06807a98..b079dcd9b54 100644 --- a/homeassistant/components/matter/config_flow.py +++ b/homeassistant/components/matter/config_flow.py @@ -17,6 +17,8 @@ from homeassistant.components.hassio import ( HassioServiceInfo, is_hassio, ) +from homeassistant.components.onboarding import async_is_onboarded +from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant @@ -64,6 +66,7 @@ class MatterConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Set up flow instance.""" + self._running_in_background = False self.ws_address: str | None = None # If we install the add-on we should uninstall it on entry remove. self.integration_created_addon = False @@ -78,7 +81,7 @@ class MatterConfigFlow(ConfigFlow, domain=DOMAIN): if not self.install_task: self.install_task = self.hass.async_create_task(self._async_install_addon()) - if not self.install_task.done(): + if not self._running_in_background and not self.install_task.done(): return self.async_show_progress( step_id="install_addon", progress_action="install_addon", @@ -89,12 +92,16 @@ class MatterConfigFlow(ConfigFlow, domain=DOMAIN): await self.install_task except AddonError as err: LOGGER.error(err) + if self._running_in_background: + return await self.async_step_install_failed() return self.async_show_progress_done(next_step_id="install_failed") finally: self.install_task = None self.integration_created_addon = True + if self._running_in_background: + return await self.async_step_start_addon() return self.async_show_progress_done(next_step_id="start_addon") async def async_step_install_failed( @@ -125,7 +132,7 @@ class MatterConfigFlow(ConfigFlow, domain=DOMAIN): """Start Matter Server add-on.""" if not self.start_task: self.start_task = self.hass.async_create_task(self._async_start_addon()) - if not self.start_task.done(): + if not self._running_in_background and not self.start_task.done(): return self.async_show_progress( step_id="start_addon", progress_action="start_addon", @@ -136,10 +143,14 @@ class MatterConfigFlow(ConfigFlow, domain=DOMAIN): await self.start_task except (FailedConnect, AddonError, AbortFlow) as err: LOGGER.error(err) + if self._running_in_background: + return await self.async_step_start_failed() return self.async_show_progress_done(next_step_id="start_failed") finally: self.start_task = None + if self._running_in_background: + return await self.async_step_finish_addon_setup() return self.async_show_progress_done(next_step_id="finish_addon_setup") async def async_step_start_failed( @@ -223,6 +234,18 @@ class MatterConfigFlow(ConfigFlow, domain=DOMAIN): step_id="manual", data_schema=get_manual_schema(user_input), errors=errors ) + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + if not async_is_onboarded(self.hass) and is_hassio(self.hass): + await self._async_handle_discovery_without_unique_id() + self._running_in_background = True + return await self.async_step_on_supervisor( + user_input={CONF_USE_ADDON: True} + ) + return await self._async_step_discovery_without_unique_id() + async def async_step_hassio( self, discovery_info: HassioServiceInfo ) -> ConfigFlowResult: diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 0e27eb36f85..b3acc0d547c 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", "requirements": ["python-matter-server==5.7.0"], - "zeroconf": ["_matter._tcp.local."] + "zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."] } diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 3441026994b..7b1bbff9de0 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -608,6 +608,11 @@ ZEROCONF = { "domain": "matter", }, ], + "_matterc._udp.local.": [ + { + "domain": "matter", + }, + ], "_mediaremotetv._tcp.local.": [ { "domain": "apple_tv", diff --git a/tests/components/matter/test_config_flow.py b/tests/components/matter/test_config_flow.py index 33e94a743f7..39ae40172c1 100644 --- a/tests/components/matter/test_config_flow.py +++ b/tests/components/matter/test_config_flow.py @@ -24,6 +24,37 @@ ADDON_DISCOVERY_INFO = { "host": "host1", "port": 5581, } +ZEROCONF_INFO_TCP = ZeroconfServiceInfo( + ip_address=ip_address("fd11:be53:8d46:0:729e:5a4f:539d:1ee6"), + ip_addresses=[ip_address("fd11:be53:8d46:0:729e:5a4f:539d:1ee6")], + port=5540, + hostname="CDEFGHIJ12345678.local.", + type="_matter._tcp.local.", + name="ABCDEFGH123456789-0000000012345678._matter._tcp.local.", + properties={"SII": "3300", "SAI": "1100", "T": "0"}, +) + +ZEROCONF_INFO_UDP = ZeroconfServiceInfo( + ip_address=ip_address("fd11:be53:8d46:0:729e:5a4f:539d:1ee6"), + ip_addresses=[ip_address("fd11:be53:8d46:0:729e:5a4f:539d:1ee6")], + port=5540, + hostname="CDEFGHIJ12345678.local.", + type="_matterc._udp.local.", + name="ABCDEFGH123456789._matterc._udp.local.", + properties={ + "VP": "4874+77", + "DT": "21", + "DN": "Eve Door", + "SII": "3300", + "SAI": "1100", + "T": "0", + "D": "183", + "CM": "2", + "RI": "0400530980B950D59BF473CFE42BD7DDBF2D", + "PH": "36", + "PI": None, + }, +) @pytest.fixture(name="setup_entry", autouse=True) @@ -35,6 +66,15 @@ def setup_entry_fixture() -> Generator[AsyncMock, None, None]: yield mock_setup_entry +@pytest.fixture(name="unload_entry", autouse=True) +def unload_entry_fixture() -> Generator[AsyncMock, None, None]: + """Mock entry unload.""" + with patch( + "homeassistant.components.matter.async_unload_entry", return_value=True + ) as mock_unload_entry: + yield mock_unload_entry + + @pytest.fixture(name="client_connect", autouse=True) def client_connect_fixture() -> Generator[AsyncMock, None, None]: """Mock server version.""" @@ -80,6 +120,16 @@ def addon_setup_time_fixture() -> Generator[int, None, None]: yield addon_setup_time +@pytest.fixture(name="not_onboarded") +def mock_onboarded_fixture() -> Generator[MagicMock, None, None]: + """Mock that Home Assistant is not yet onboarded.""" + with patch( + "homeassistant.components.matter.config_flow.async_is_onboarded", + return_value=False, + ) as mock_onboarded: + yield mock_onboarded + + async def test_manual_create_entry( hass: HomeAssistant, client_connect: AsyncMock, @@ -179,24 +229,18 @@ async def test_manual_already_configured( assert setup_entry.call_count == 1 +@pytest.mark.parametrize("zeroconf_info", [ZEROCONF_INFO_TCP, ZEROCONF_INFO_UDP]) async def test_zeroconf_discovery( hass: HomeAssistant, client_connect: AsyncMock, setup_entry: AsyncMock, + zeroconf_info: ZeroconfServiceInfo, ) -> None: """Test flow started from Zeroconf discovery.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=ZeroconfServiceInfo( - ip_address=ip_address("fd11:be53:8d46:0:729e:5a4f:539d:1ee6"), - ip_addresses=[ip_address("fd11:be53:8d46:0:729e:5a4f:539d:1ee6")], - port=5540, - hostname="CDEFGHIJ12345678.local.", - type="_matter._tcp.local.", - name="ABCDEFGH123456789-0000000012345678._matter._tcp.local.", - properties={"SII": "3300", "SAI": "1100", "T": "0"}, - ), + data=zeroconf_info, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "manual" @@ -221,6 +265,185 @@ async def test_zeroconf_discovery( assert setup_entry.call_count == 1 +@pytest.mark.parametrize("zeroconf_info", [ZEROCONF_INFO_TCP, ZEROCONF_INFO_UDP]) +async def test_zeroconf_discovery_not_onboarded_not_supervisor( + hass: HomeAssistant, + client_connect: AsyncMock, + setup_entry: AsyncMock, + not_onboarded: MagicMock, + zeroconf_info: ZeroconfServiceInfo, +) -> None: + """Test flow started from Zeroconf discovery when not onboarded.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf_info, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "url": "ws://localhost:5580/ws", + }, + ) + await hass.async_block_till_done() + + assert client_connect.call_count == 1 + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Matter" + assert result["data"] == { + "url": "ws://localhost:5580/ws", + "integration_created_addon": False, + "use_addon": False, + } + assert setup_entry.call_count == 1 + + +@pytest.mark.parametrize("zeroconf_info", [ZEROCONF_INFO_TCP, ZEROCONF_INFO_UDP]) +@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) +async def test_zeroconf_not_onboarded_already_discovered( + hass: HomeAssistant, + supervisor: MagicMock, + addon_info: AsyncMock, + addon_running: AsyncMock, + client_connect: AsyncMock, + setup_entry: AsyncMock, + not_onboarded: MagicMock, + zeroconf_info: ZeroconfServiceInfo, +) -> None: + """Test flow Zeroconf discovery when not onboarded and already discovered.""" + result_flow_1 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf_info, + ) + result_flow_2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf_info, + ) + await hass.async_block_till_done() + assert result_flow_2["type"] is FlowResultType.ABORT + assert result_flow_2["reason"] == "already_configured" + assert addon_info.call_count == 1 + assert client_connect.call_count == 1 + assert result_flow_1["type"] is FlowResultType.CREATE_ENTRY + assert result_flow_1["title"] == "Matter" + assert result_flow_1["data"] == { + "url": "ws://host1:5581/ws", + "use_addon": True, + "integration_created_addon": False, + } + assert setup_entry.call_count == 1 + + +@pytest.mark.parametrize("zeroconf_info", [ZEROCONF_INFO_TCP, ZEROCONF_INFO_UDP]) +@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) +async def test_zeroconf_not_onboarded_running( + hass: HomeAssistant, + supervisor: MagicMock, + addon_info: AsyncMock, + addon_running: AsyncMock, + client_connect: AsyncMock, + setup_entry: AsyncMock, + not_onboarded: MagicMock, + zeroconf_info: ZeroconfServiceInfo, +) -> None: + """Test flow Zeroconf discovery when not onboarded and add-on running.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf_info, + ) + await hass.async_block_till_done() + + assert addon_info.call_count == 1 + assert client_connect.call_count == 1 + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Matter" + assert result["data"] == { + "url": "ws://host1:5581/ws", + "use_addon": True, + "integration_created_addon": False, + } + assert setup_entry.call_count == 1 + + +@pytest.mark.parametrize("zeroconf_info", [ZEROCONF_INFO_TCP, ZEROCONF_INFO_UDP]) +@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) +async def test_zeroconf_not_onboarded_installed( + hass: HomeAssistant, + supervisor: MagicMock, + addon_info: AsyncMock, + addon_installed: AsyncMock, + start_addon: AsyncMock, + client_connect: AsyncMock, + setup_entry: AsyncMock, + not_onboarded: MagicMock, + zeroconf_info: ZeroconfServiceInfo, +) -> None: + """Test flow Zeroconf discovery when not onboarded and add-on installed.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf_info, + ) + await hass.async_block_till_done() + + assert addon_info.call_count == 1 + assert start_addon.call_args == call(hass, "core_matter_server") + assert client_connect.call_count == 1 + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Matter" + assert result["data"] == { + "url": "ws://host1:5581/ws", + "use_addon": True, + "integration_created_addon": False, + } + assert setup_entry.call_count == 1 + + +@pytest.mark.parametrize("zeroconf_info", [ZEROCONF_INFO_TCP, ZEROCONF_INFO_UDP]) +@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) +async def test_zeroconf_not_onboarded_not_installed( + hass: HomeAssistant, + supervisor: MagicMock, + addon_info: AsyncMock, + addon_store_info: AsyncMock, + addon_not_installed: AsyncMock, + install_addon: AsyncMock, + start_addon: AsyncMock, + client_connect: AsyncMock, + setup_entry: AsyncMock, + not_onboarded: MagicMock, + zeroconf_info: ZeroconfServiceInfo, +) -> None: + """Test flow Zeroconf discovery when not onboarded and add-on not installed.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf_info, + ) + await hass.async_block_till_done() + + assert addon_info.call_count == 0 + assert addon_store_info.call_count == 2 + assert install_addon.call_args == call(hass, "core_matter_server") + assert start_addon.call_args == call(hass, "core_matter_server") + assert client_connect.call_count == 1 + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Matter" + assert result["data"] == { + "url": "ws://host1:5581/ws", + "use_addon": True, + "integration_created_addon": True, + } + assert setup_entry.call_count == 1 + + @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) async def test_supervisor_discovery( hass: HomeAssistant, @@ -702,6 +925,90 @@ async def test_addon_running_failures( assert result["reason"] == abort_reason +@pytest.mark.parametrize("zeroconf_info", [ZEROCONF_INFO_TCP, ZEROCONF_INFO_UDP]) +@pytest.mark.parametrize( + ( + "discovery_info", + "discovery_info_error", + "client_connect_error", + "addon_info_error", + "abort_reason", + "discovery_info_called", + "client_connect_called", + ), + [ + ( + {"config": ADDON_DISCOVERY_INFO}, + HassioAPIError(), + None, + None, + "addon_get_discovery_info_failed", + True, + False, + ), + ( + {"config": ADDON_DISCOVERY_INFO}, + None, + CannotConnect(Exception("Boom")), + None, + "cannot_connect", + True, + True, + ), + ( + None, + None, + None, + None, + "addon_get_discovery_info_failed", + True, + False, + ), + ( + {"config": ADDON_DISCOVERY_INFO}, + None, + None, + HassioAPIError(), + "addon_info_failed", + False, + False, + ), + ], +) +async def test_addon_running_failures_zeroconf( + hass: HomeAssistant, + supervisor: MagicMock, + addon_running: AsyncMock, + addon_info: AsyncMock, + get_addon_discovery_info: AsyncMock, + client_connect: AsyncMock, + discovery_info_error: Exception | None, + client_connect_error: Exception | None, + addon_info_error: Exception | None, + abort_reason: str, + discovery_info_called: bool, + client_connect_called: bool, + not_onboarded: MagicMock, + zeroconf_info: ZeroconfServiceInfo, +) -> None: + """Test all failures when add-on is running and not onboarded.""" + get_addon_discovery_info.side_effect = discovery_info_error + client_connect.side_effect = client_connect_error + addon_info.side_effect = addon_info_error + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf_info, + ) + await hass.async_block_till_done() + + assert addon_info.call_count == 1 + assert get_addon_discovery_info.called is discovery_info_called + assert client_connect.called is client_connect_called + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == abort_reason + + @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) async def test_addon_running_already_configured( hass: HomeAssistant, @@ -854,6 +1161,71 @@ async def test_addon_installed_failures( assert result["reason"] == "addon_start_failed" +@pytest.mark.parametrize("zeroconf_info", [ZEROCONF_INFO_TCP, ZEROCONF_INFO_UDP]) +@pytest.mark.parametrize( + ( + "discovery_info", + "start_addon_error", + "client_connect_error", + "discovery_info_called", + "client_connect_called", + ), + [ + ( + {"config": ADDON_DISCOVERY_INFO}, + HassioAPIError(), + None, + False, + False, + ), + ( + {"config": ADDON_DISCOVERY_INFO}, + None, + CannotConnect(Exception("Boom")), + True, + True, + ), + ( + None, + None, + None, + True, + False, + ), + ], +) +async def test_addon_installed_failures_zeroconf( + hass: HomeAssistant, + supervisor: MagicMock, + addon_installed: AsyncMock, + addon_info: AsyncMock, + start_addon: AsyncMock, + get_addon_discovery_info: AsyncMock, + client_connect: AsyncMock, + start_addon_error: Exception | None, + client_connect_error: Exception | None, + discovery_info_called: bool, + client_connect_called: bool, + not_onboarded: MagicMock, + zeroconf_info: ZeroconfServiceInfo, +) -> None: + """Test add-on start failure when add-on is installed and not onboarded.""" + start_addon.side_effect = start_addon_error + client_connect.side_effect = client_connect_error + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf_info + ) + await hass.async_block_till_done() + + assert addon_info.call_count == 1 + assert start_addon.call_args == call(hass, "core_matter_server") + assert get_addon_discovery_info.called is discovery_info_called + assert client_connect.called is client_connect_called + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "addon_start_failed" + + @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) async def test_addon_installed_already_configured( hass: HomeAssistant, @@ -985,6 +1357,30 @@ async def test_addon_not_installed_failures( assert result["reason"] == "addon_install_failed" +@pytest.mark.parametrize("zeroconf_info", [ZEROCONF_INFO_TCP, ZEROCONF_INFO_UDP]) +async def test_addon_not_installed_failures_zeroconf( + hass: HomeAssistant, + supervisor: MagicMock, + addon_not_installed: AsyncMock, + addon_info: AsyncMock, + install_addon: AsyncMock, + not_onboarded: MagicMock, + zeroconf_info: ZeroconfServiceInfo, +) -> None: + """Test add-on install failure.""" + install_addon.side_effect = HassioAPIError() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf_info + ) + await hass.async_block_till_done() + + assert install_addon.call_args == call(hass, "core_matter_server") + assert addon_info.call_count == 0 + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "addon_install_failed" + + @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) async def test_addon_not_installed_already_configured( hass: HomeAssistant,