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:
@@ -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"
|
||||
|
||||
@@ -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"),
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user