1
0
mirror of https://github.com/home-assistant/core.git synced 2026-07-02 20:26:16 +01:00

Add repair when IPv6 is disabled for Matter (#174653)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Stefan Agner
2026-06-24 17:11:54 +02:00
committed by GitHub
parent f0ed257f47
commit 0808e30e37
3 changed files with 254 additions and 2 deletions
+58 -1
View File
@@ -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,
@@ -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"
+192 -1
View File
@@ -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"),
[