From 0808e30e37541d594ac0fc408797cd14efbcda36 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 24 Jun 2026 17:11:54 +0200 Subject: [PATCH] Add repair when IPv6 is disabled for Matter (#174653) Co-authored-by: Claude Opus 4.8 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/matter/__init__.py | 59 +++++- homeassistant/components/matter/strings.json | 4 + tests/components/matter/test_init.py | 193 ++++++++++++++++++- 3 files changed, 254 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/matter/__init__.py b/homeassistant/components/matter/__init__.py index 20d62a14fba..de54159ad3b 100644 --- a/homeassistant/components/matter/__init__.py +++ b/homeassistant/components/matter/__init__.py @@ -4,6 +4,7 @@ import asyncio from functools import cache from typing import TYPE_CHECKING +from aiohasupervisor.models import InterfaceMethod from matter_server.client import MatterClient from matter_server.client.exceptions import ( CannotConnect, @@ -15,13 +16,20 @@ from matter_server.client.exceptions import ( from matter_server.common.errors import MatterError, NodeNotExists from yarl import URL -from homeassistant.components.hassio import AddonError, AddonManager, AddonState +from homeassistant.components.hassio import ( + AddonError, + AddonManager, + AddonState, + SupervisorError, + get_supervisor_client, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.issue_registry import ( IssueSeverity, async_create_issue, @@ -123,6 +131,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: MatterConfigEntry) -> bo async_delete_issue(hass, DOMAIN, "server_version_version_too_old") async_delete_issue(hass, DOMAIN, "server_version_version_too_new") + await _async_check_ipv6_enabled(hass) + ble_proxy: MatterBleProxy | None = None async def on_hass_stop(event: Event) -> None: @@ -252,6 +262,53 @@ def _derive_ble_proxy_url(matter_ws_url: str) -> str | None: return str(parsed.with_path(new_path)) +async def _async_check_ipv6_enabled(hass: HomeAssistant) -> None: + """Raise a repair issue when IPv6 is disabled in Supervisor network settings. + + Matter relies on IPv6 to communicate with devices. On Supervised/HAOS + installations the host network IPv6 method can be disabled per interface, + which silently breaks Matter, so we surface a repair pointing the user at + the network settings. + """ + if not is_hassio(hass): + return + + client = get_supervisor_client(hass) + try: + network_info = await client.network.info() + except SupervisorError as err: + LOGGER.debug("Failed to fetch Supervisor network info: %s", err) + return + + connected_interfaces = [ + interface + for interface in network_info.interfaces + if interface.enabled and interface.connected + ] + # Without a connected interface we can't tell whether IPv6 is disabled or + # the network is simply not up yet, so avoid raising a false repair. + if not connected_interfaces: + return + + if any( + interface.ipv6 is not None + and interface.ipv6.method is not InterfaceMethod.DISABLED + for interface in connected_interfaces + ): + async_delete_issue(hass, DOMAIN, "ipv6_disabled") + return + + async_create_issue( + hass, + DOMAIN, + "ipv6_disabled", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="ipv6_disabled", + learn_more_url="homeassistant://config/network", + ) + + async def _client_listen( hass: HomeAssistant, entry: MatterConfigEntry, diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 6433f5f7404..22e223db756 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -721,6 +721,10 @@ } }, "issues": { + "ipv6_disabled": { + "description": "Matter relies on IPv6 to communicate with some devices, but IPv6 is disabled on all of your connected network interfaces. Locally connected Wi-Fi and Ethernet devices may still work, but features such as using an external Thread border router need IPv6 enabled. Select \"Learn more\" to open the network settings.", + "title": "IPv6 is disabled but required by Matter" + }, "server_version_version_too_new": { "description": "The version of the Matter Server you are currently running is too new for this version of Home Assistant. Please update Home Assistant or downgrade the Matter Server to an older version to fix this issue.", "title": "Older version of Matter Server needed" diff --git a/tests/components/matter/test_init.py b/tests/components/matter/test_init.py index 5aaf2eeb041..3d28d8d8d85 100644 --- a/tests/components/matter/test_init.py +++ b/tests/components/matter/test_init.py @@ -5,7 +5,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, call, patch from aiohasupervisor import SupervisorError -from aiohasupervisor.models import PartialBackupOptions +from aiohasupervisor.models import InterfaceMethod, PartialBackupOptions from matter_server.client.exceptions import ( CannotConnect, NotConnected, @@ -550,6 +550,197 @@ async def test_issue_registry_invalid_version( assert not issue_registry.async_get_issue(DOMAIN, issue_raised) +def _mock_network_interface( + *, enabled: bool, connected: bool, ipv6_method: InterfaceMethod | None +) -> MagicMock: + """Build a mock Supervisor network interface.""" + interface = MagicMock() + interface.enabled = enabled + interface.connected = connected + interface.ipv6 = None if ipv6_method is None else MagicMock(method=ipv6_method) + return interface + + +@pytest.mark.parametrize( + ("interfaces", "issue_expected"), + [ + pytest.param( + [ + _mock_network_interface( + enabled=True, connected=True, ipv6_method=InterfaceMethod.DISABLED + ) + ], + True, + id="ipv6_disabled", + ), + pytest.param( + [_mock_network_interface(enabled=True, connected=True, ipv6_method=None)], + True, + id="no_ipv6_config", + ), + pytest.param( + [ + _mock_network_interface( + enabled=True, connected=True, ipv6_method=InterfaceMethod.AUTO + ) + ], + False, + id="ipv6_auto", + ), + pytest.param( + [ + _mock_network_interface( + enabled=True, connected=True, ipv6_method=InterfaceMethod.STATIC + ) + ], + False, + id="ipv6_static", + ), + pytest.param( + [ + _mock_network_interface( + enabled=True, connected=True, ipv6_method=InterfaceMethod.DISABLED + ), + _mock_network_interface( + enabled=True, connected=True, ipv6_method=InterfaceMethod.AUTO + ), + ], + False, + id="ipv6_enabled_on_secondary_interface", + ), + pytest.param( + [ + _mock_network_interface( + enabled=True, connected=False, ipv6_method=InterfaceMethod.DISABLED + ) + ], + False, + id="no_connected_interface", + ), + ], +) +async def test_ipv6_disabled_repair( + hass: HomeAssistant, + matter_client: MagicMock, + issue_registry: ir.IssueRegistry, + interfaces: list[MagicMock], + issue_expected: bool, +) -> None: + """Test repair issue when IPv6 is disabled in Supervisor network settings.""" + supervisor_client = MagicMock() + supervisor_client.network.info = AsyncMock( + return_value=MagicMock(interfaces=interfaces) + ) + + entry = MockConfigEntry(domain=DOMAIN, data={"url": "ws://localhost:5580/ws"}) + entry.add_to_hass(hass) + + with ( + patch("homeassistant.components.matter.is_hassio", return_value=True), + patch( + "homeassistant.components.matter.get_supervisor_client", + return_value=supervisor_client, + ), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + issue = issue_registry.async_get_issue(DOMAIN, "ipv6_disabled") + assert (issue is not None) is issue_expected + + +async def test_ipv6_repair_not_raised_without_supervisor( + hass: HomeAssistant, + matter_client: MagicMock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test the IPv6 repair is skipped when not running on Supervisor.""" + with patch( + "homeassistant.components.matter.get_supervisor_client" + ) as get_supervisor_client: + entry = MockConfigEntry(domain=DOMAIN, data={"url": "ws://localhost:5580/ws"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + get_supervisor_client.assert_not_called() + assert not issue_registry.async_get_issue(DOMAIN, "ipv6_disabled") + + +async def test_ipv6_repair_supervisor_error( + hass: HomeAssistant, + matter_client: MagicMock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test the IPv6 repair handles Supervisor errors gracefully.""" + supervisor_client = MagicMock() + supervisor_client.network.info = AsyncMock(side_effect=SupervisorError("boom")) + + entry = MockConfigEntry(domain=DOMAIN, data={"url": "ws://localhost:5580/ws"}) + entry.add_to_hass(hass) + + with ( + patch("homeassistant.components.matter.is_hassio", return_value=True), + patch( + "homeassistant.components.matter.get_supervisor_client", + return_value=supervisor_client, + ), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert not issue_registry.async_get_issue(DOMAIN, "ipv6_disabled") + + +async def test_ipv6_repair_resolves_on_reload( + hass: HomeAssistant, + matter_client: MagicMock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test the IPv6 repair is removed once IPv6 is enabled again.""" + supervisor_client = MagicMock() + supervisor_client.network.info = AsyncMock( + return_value=MagicMock( + interfaces=[ + _mock_network_interface( + enabled=True, connected=True, ipv6_method=InterfaceMethod.DISABLED + ) + ] + ) + ) + + entry = MockConfigEntry(domain=DOMAIN, data={"url": "ws://localhost:5580/ws"}) + entry.add_to_hass(hass) + + with ( + patch("homeassistant.components.matter.is_hassio", return_value=True), + patch( + "homeassistant.components.matter.get_supervisor_client", + return_value=supervisor_client, + ), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert issue_registry.async_get_issue(DOMAIN, "ipv6_disabled") + + supervisor_client.network.info.return_value = MagicMock( + interfaces=[ + _mock_network_interface( + enabled=True, connected=True, ipv6_method=InterfaceMethod.AUTO + ) + ] + ) + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert not issue_registry.async_get_issue(DOMAIN, "ipv6_disabled") + + @pytest.mark.parametrize( ("stop_addon_side_effect", "entry_state"), [