mirror of
https://github.com/home-assistant/supervisor.git
synced 2026-08-13 16:32:50 +01:00
* Skip network re-activation on startup when settings are unchanged Since #3528 Supervisor re-applies its network defaults on every startup: it rewrites the NetworkManager connection profile of each enabled interface and re-activates the connection so the settings take effect. The re-activation runs unconditionally, so every Supervisor start causes a full connection cycle (routes torn down, DHCP re-negotiated, Wi-Fi reassociation) even when the profile did not change, which is the common case. Make NetworkSetting.update() report whether the profile actually changed by comparing NetworkManager's normalized view of the settings before and after the update call. Comparing Supervisor's generated payload against the current profile would not work here since NetworkManager omits properties at their default value from GetSettings. On the startup path, skip re-activation when the profile is unchanged and the connection is currently activated. Out-of-date profiles (e.g. after a change of Supervisor's defaults) are still rewritten and re-activated, and user-initiated updates via the API re-activate unconditionally as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Reapply changed network settings in place on startup When the startup profile update does change the NetworkManager connection profile (e.g. after a change of Supervisor's defaults), the settings were applied through a full re-activation cycle, briefly disrupting connectivity. Use NetworkManager's Device.Reapply() instead, which applies the updated profile to the active connection without disconnecting. Not all settings can be reapplied (e.g. wireless security changes), in which case NetworkManager raises an error and Supervisor falls back to the full re-activation as before. User-initiated updates via the API are unaffected and still re-activate the connection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Log network connection activation and reapply at info level Activating or reapplying a connection affects host connectivity, but was only visible in debug logs. Log at info level when Supervisor activates a connection, reapplies changed settings in place, or creates a new connection, so the Supervisor log shows when and why the host network configuration was touched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Reapply changed settings in place for user-initiated updates too Extend the in-place reapply from the startup path to user-initiated network settings updates via the API. Changing e.g. IP or DNS configuration no longer drops connectivity, which also matters when the update is made remotely over the interface being reconfigured. Payloads containing a Wi-Fi PSK always re-activate the connection: secrets are excluded from GetSettings and ignored by NetworkManager's Reapply (the diff runs with NM_SETTING_COMPARE_FLAG_IGNORE_SECRETS), so an updated PSK would otherwise be written to the profile but never applied or validated. Unchanged settings also still re-activate on the user path, both to keep resubmitting settings working as a way to force a reconnect and to cover secret-only changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Clarify settings variable naming in NetworkSetting.update() The dict fetched from GetSettings was named new_settings and mutated by the merges into the payload sent to Update, so the name was only accurate for half of the function. Keep the fetched state pristine as current_settings, merge into a copy named new_settings, and compare current_settings against the normalized result. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Extract in-place settings application into a helper method Review feedback on #7043 noted the conditional block in apply_changes() had grown confusing with the added nesting. Move the decision whether updated settings are effective without a full re-activation cycle into a dedicated _apply_settings_in_place() method using early returns, so apply_changes() reads linearly: update settings, then activate unless they were applied in place. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
446 lines
16 KiB
Python
446 lines
16 KiB
Python
"""Test network manager."""
|
|
|
|
import asyncio
|
|
from ipaddress import IPv4Address, IPv6Address
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
from dbus_fast import DBusError, Variant
|
|
import pytest
|
|
|
|
from supervisor.const import CoreState
|
|
from supervisor.coresys import CoreSys
|
|
from supervisor.dbus.const import InterfaceMethod
|
|
from supervisor.dbus.network.setting import (
|
|
CONF_ATTR_802_WIRELESS_SECURITY,
|
|
CONF_ATTR_802_WIRELESS_SECURITY_PSK,
|
|
)
|
|
from supervisor.dbus.network.setting.generate import get_connection_from_interface
|
|
from supervisor.exceptions import HostNotSupportedError
|
|
from supervisor.homeassistant.const import WSEvent, WSType
|
|
from supervisor.host.const import WifiMode
|
|
|
|
from tests.dbus_service_mocks.base import DBusServiceMock
|
|
from tests.dbus_service_mocks.network_active_connection import (
|
|
ActiveConnection as ActiveConnectionService,
|
|
)
|
|
from tests.dbus_service_mocks.network_connection_settings import (
|
|
SETTINGS_1_FIXTURE,
|
|
ConnectionSettings as ConnectionSettingsService,
|
|
)
|
|
from tests.dbus_service_mocks.network_device import Device as DeviceService
|
|
from tests.dbus_service_mocks.network_device_wireless import (
|
|
DeviceWireless as DeviceWirelessService,
|
|
)
|
|
from tests.dbus_service_mocks.network_manager import (
|
|
NetworkManager as NetworkManagerService,
|
|
)
|
|
|
|
|
|
@pytest.fixture(name="wireless_service")
|
|
async def fixture_wireless_service(
|
|
network_manager_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]],
|
|
) -> DeviceWirelessService:
|
|
"""Return mock device wireless service."""
|
|
return network_manager_services["network_device_wireless"]
|
|
|
|
|
|
@pytest.fixture(name="device_eth0_service")
|
|
async def fixture_device_eth0_service(
|
|
network_manager_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]],
|
|
) -> DeviceService:
|
|
"""Return mock device eth0 service."""
|
|
return network_manager_services["network_device"][
|
|
"/org/freedesktop/NetworkManager/Devices/1"
|
|
]
|
|
|
|
|
|
async def test_load(
|
|
coresys: CoreSys,
|
|
network_manager_service: NetworkManagerService,
|
|
connection_settings_service: ConnectionSettingsService,
|
|
device_eth0_service: DeviceService,
|
|
):
|
|
"""Test network manager load."""
|
|
network_manager_service.ActivateConnection.calls.clear()
|
|
network_manager_service.CheckConnectivity.calls.clear()
|
|
device_eth0_service.Reapply.calls.clear()
|
|
|
|
await coresys.host.network.load()
|
|
|
|
assert coresys.host.network.connectivity is True
|
|
|
|
assert len(coresys.host.network.dns_servers) == 1
|
|
assert str(coresys.host.network.dns_servers[0]) == "192.168.30.1"
|
|
|
|
assert len(coresys.host.network.interfaces) == 3
|
|
name_dict = {intr.name: intr for intr in coresys.host.network.interfaces}
|
|
assert "eth0" in name_dict
|
|
assert name_dict["eth0"].mac == "AA:BB:CC:DD:EE:FF"
|
|
assert name_dict["eth0"].enabled is True
|
|
assert name_dict["eth0"].ipv4.gateway == IPv4Address("192.168.2.1")
|
|
assert name_dict["eth0"].ipv4.ready is True
|
|
assert name_dict["eth0"].ipv4setting.method == InterfaceMethod.AUTO
|
|
assert name_dict["eth0"].ipv4setting.address == []
|
|
assert name_dict["eth0"].ipv4setting.gateway is None
|
|
assert name_dict["eth0"].ipv4setting.nameservers == [IPv4Address("192.168.2.1")]
|
|
assert name_dict["eth0"].ipv6.gateway == IPv6Address("fe80::da58:d7ff:fe00:9c69")
|
|
assert name_dict["eth0"].ipv6.ready is True
|
|
assert name_dict["eth0"].ipv6setting.method == InterfaceMethod.AUTO
|
|
assert name_dict["eth0"].ipv6setting.address == []
|
|
assert name_dict["eth0"].ipv6setting.gateway is None
|
|
assert name_dict["eth0"].ipv6setting.nameservers == [
|
|
IPv6Address("2001:4860:4860::8888")
|
|
]
|
|
assert "wlan0" in name_dict
|
|
assert name_dict["wlan0"].enabled is False
|
|
|
|
assert connection_settings_service.settings["ipv4"]["method"].value == "auto"
|
|
assert connection_settings_service.settings["ipv4"]["address-data"] == Variant(
|
|
"aa{sv}", []
|
|
)
|
|
assert "gateway" not in connection_settings_service.settings["ipv4"]
|
|
assert connection_settings_service.settings["ipv4"]["dns"] == Variant(
|
|
"au", [16951488]
|
|
)
|
|
assert connection_settings_service.settings["ipv6"]["method"].value == "auto"
|
|
assert connection_settings_service.settings["ipv6"]["address-data"] == Variant(
|
|
"aa{sv}", []
|
|
)
|
|
assert "gateway" not in connection_settings_service.settings["ipv6"]
|
|
assert connection_settings_service.settings["ipv6"]["dns"] == Variant(
|
|
"aay", [bytearray(b" \x01H`H`\x00\x00\x00\x00\x00\x00\x00\x00\x88\x88")]
|
|
)
|
|
assert "eth0.10" in name_dict
|
|
assert name_dict["eth0.10"].enabled is True
|
|
|
|
# The profiles changed (Supervisor defaults applied) with the connections
|
|
# active, so the settings are reapplied in place without re-activation
|
|
assert len(device_eth0_service.Reapply.calls) == 2
|
|
assert (
|
|
"/org/freedesktop/NetworkManager/Devices/1",
|
|
{},
|
|
0,
|
|
0,
|
|
) in device_eth0_service.Reapply.calls
|
|
assert (
|
|
"/org/freedesktop/NetworkManager/Devices/38",
|
|
{},
|
|
0,
|
|
0,
|
|
) in device_eth0_service.Reapply.calls
|
|
assert network_manager_service.ActivateConnection.calls == []
|
|
assert network_manager_service.CheckConnectivity.calls == []
|
|
|
|
|
|
async def test_load_unchanged_settings_skips_activation(
|
|
coresys: CoreSys,
|
|
network_manager_service: NetworkManagerService,
|
|
device_eth0_service: DeviceService,
|
|
):
|
|
"""Test load does not touch connections when settings are unchanged."""
|
|
# First load updates the profiles to Supervisor defaults and applies them
|
|
await coresys.host.network.load()
|
|
|
|
network_manager_service.ActivateConnection.calls.clear()
|
|
device_eth0_service.Reapply.calls.clear()
|
|
|
|
# Profiles now match what Supervisor generates, nothing to apply
|
|
await coresys.host.network.load()
|
|
assert network_manager_service.ActivateConnection.calls == []
|
|
assert device_eth0_service.Reapply.calls == []
|
|
|
|
|
|
async def test_load_outdated_settings_reapplies(
|
|
coresys: CoreSys,
|
|
network_manager_service: NetworkManagerService,
|
|
connection_settings_service: ConnectionSettingsService,
|
|
device_eth0_service: DeviceService,
|
|
):
|
|
"""Test load applies an out of date profile in place."""
|
|
await coresys.host.network.load()
|
|
network_manager_service.ActivateConnection.calls.clear()
|
|
device_eth0_service.Reapply.calls.clear()
|
|
|
|
# Simulate a profile predating a change of Supervisor defaults
|
|
connection_settings_service.settings["connection"]["id"] = Variant(
|
|
"s", "Wired connection 1"
|
|
)
|
|
await coresys.dbus.network.get("eth0").settings.reload()
|
|
|
|
await coresys.host.network.load()
|
|
assert device_eth0_service.Reapply.calls == [
|
|
("/org/freedesktop/NetworkManager/Devices/1", {}, 0, 0)
|
|
]
|
|
assert network_manager_service.ActivateConnection.calls == []
|
|
|
|
|
|
async def test_load_reapply_fails_activates(
|
|
coresys: CoreSys,
|
|
network_manager_service: NetworkManagerService,
|
|
connection_settings_service: ConnectionSettingsService,
|
|
device_eth0_service: DeviceService,
|
|
):
|
|
"""Test load re-activates when changed settings can't be reapplied in place."""
|
|
await coresys.host.network.load()
|
|
network_manager_service.ActivateConnection.calls.clear()
|
|
|
|
# Simulate a profile predating a change of Supervisor defaults
|
|
connection_settings_service.settings["connection"]["id"] = Variant(
|
|
"s", "Wired connection 1"
|
|
)
|
|
await coresys.dbus.network.get("eth0").settings.reload()
|
|
device_eth0_service.reapply_error = DBusError(
|
|
"org.freedesktop.NetworkManager.Device.IncompatibleConnection",
|
|
"Can't reapply any changes to '802-3-ethernet' setting",
|
|
)
|
|
|
|
await coresys.host.network.load()
|
|
assert (
|
|
"/org/freedesktop/NetworkManager/Settings/1",
|
|
"/org/freedesktop/NetworkManager/Devices/1",
|
|
"/",
|
|
) in network_manager_service.ActivateConnection.calls
|
|
|
|
|
|
async def test_apply_changes_reapplies_in_place(
|
|
coresys: CoreSys,
|
|
network_manager_service: NetworkManagerService,
|
|
device_eth0_service: DeviceService,
|
|
):
|
|
"""Test user-initiated apply of changed settings reapplies in place."""
|
|
await coresys.host.network.load()
|
|
network_manager_service.ActivateConnection.calls.clear()
|
|
device_eth0_service.Reapply.calls.clear()
|
|
|
|
interface = coresys.host.network.get("eth0")
|
|
interface.ipv4setting.nameservers = [IPv4Address("1.1.1.1")]
|
|
await coresys.host.network.apply_changes(interface)
|
|
|
|
assert device_eth0_service.Reapply.calls == [
|
|
("/org/freedesktop/NetworkManager/Devices/1", {}, 0, 0)
|
|
]
|
|
assert network_manager_service.ActivateConnection.calls == []
|
|
|
|
|
|
async def test_apply_changes_unchanged_reactivates(
|
|
coresys: CoreSys,
|
|
network_manager_service: NetworkManagerService,
|
|
device_eth0_service: DeviceService,
|
|
):
|
|
"""Test user-initiated apply of unchanged settings re-activates."""
|
|
await coresys.host.network.load()
|
|
network_manager_service.ActivateConnection.calls.clear()
|
|
device_eth0_service.Reapply.calls.clear()
|
|
|
|
await coresys.host.network.apply_changes(coresys.host.network.get("eth0"))
|
|
|
|
assert device_eth0_service.Reapply.calls == []
|
|
assert (
|
|
"/org/freedesktop/NetworkManager/Settings/1",
|
|
"/org/freedesktop/NetworkManager/Devices/1",
|
|
"/",
|
|
) in network_manager_service.ActivateConnection.calls
|
|
|
|
|
|
async def test_apply_changes_with_psk_reactivates(
|
|
coresys: CoreSys,
|
|
network_manager_service: NetworkManagerService,
|
|
device_eth0_service: DeviceService,
|
|
):
|
|
"""Test apply with a Wi-Fi PSK in the payload re-activates."""
|
|
await coresys.host.network.load()
|
|
network_manager_service.ActivateConnection.calls.clear()
|
|
device_eth0_service.Reapply.calls.clear()
|
|
|
|
interface = coresys.host.network.get("eth0")
|
|
interface.ipv4setting.nameservers = [IPv4Address("1.1.1.1")]
|
|
|
|
def add_psk(*args, **kwargs):
|
|
conn = get_connection_from_interface(*args, **kwargs)
|
|
conn[CONF_ATTR_802_WIRELESS_SECURITY] = {
|
|
CONF_ATTR_802_WIRELESS_SECURITY_PSK: Variant("s", "supersecret")
|
|
}
|
|
return conn
|
|
|
|
with patch("supervisor.host.network.get_connection_from_interface", new=add_psk):
|
|
await coresys.host.network.apply_changes(interface)
|
|
|
|
assert device_eth0_service.Reapply.calls == []
|
|
assert (
|
|
"/org/freedesktop/NetworkManager/Settings/1",
|
|
"/org/freedesktop/NetworkManager/Devices/1",
|
|
"/",
|
|
) in network_manager_service.ActivateConnection.calls
|
|
|
|
|
|
async def test_load_with_disabled_methods(
|
|
coresys: CoreSys,
|
|
network_manager_service: NetworkManagerService,
|
|
connection_settings_service: ConnectionSettingsService,
|
|
):
|
|
"""Test load does not disable methods of interfaces."""
|
|
network_manager_service.ActivateConnection.calls.clear()
|
|
|
|
disabled = {"method": Variant("s", "disabled")}
|
|
connection_settings_service.settings = SETTINGS_1_FIXTURE | {
|
|
"ipv4": disabled,
|
|
"ipv6": disabled,
|
|
}
|
|
await coresys.dbus.network.get("eth0").settings.reload()
|
|
|
|
await coresys.host.network.load()
|
|
assert (
|
|
"/org/freedesktop/NetworkManager/Settings/1",
|
|
"/org/freedesktop/NetworkManager/Devices/1",
|
|
"/",
|
|
) not in network_manager_service.ActivateConnection.calls
|
|
|
|
|
|
async def test_load_with_network_connection_issues(
|
|
coresys: CoreSys,
|
|
network_manager_service: NetworkManagerService,
|
|
active_connection_service: ActiveConnectionService,
|
|
):
|
|
"""Test load does not update interfaces with network connection issues."""
|
|
network_manager_service.ActivateConnection.calls.clear()
|
|
|
|
active_connection_service.emit_properties_changed(
|
|
{"StateFlags": 0x10, "Ip4Config": "/"}
|
|
)
|
|
await active_connection_service.ping()
|
|
|
|
await coresys.host.network.load()
|
|
await coresys.host.network.update()
|
|
|
|
assert (
|
|
"/org/freedesktop/NetworkManager/Settings/1",
|
|
"/org/freedesktop/NetworkManager/Devices/1",
|
|
"/",
|
|
) not in network_manager_service.ActivateConnection.calls
|
|
assert len(coresys.host.network.interfaces) == 3
|
|
name_dict = {intr.name: intr for intr in coresys.host.network.interfaces}
|
|
assert "eth0" in name_dict
|
|
assert name_dict["eth0"].enabled is True
|
|
assert name_dict["eth0"].ipv4setting.method == InterfaceMethod.AUTO
|
|
assert name_dict["eth0"].ipv6setting.method == InterfaceMethod.AUTO
|
|
|
|
|
|
async def test_scan_wifi(coresys: CoreSys):
|
|
"""Test scanning wifi."""
|
|
with pytest.raises(HostNotSupportedError):
|
|
await coresys.host.network.scan_wifi(coresys.host.network.get("eth0"))
|
|
|
|
with patch("supervisor.host.network.asyncio.sleep"):
|
|
aps = await coresys.host.network.scan_wifi(coresys.host.network.get("wlan0"))
|
|
|
|
assert len(aps) == 2
|
|
assert aps[0].mac == "E4:57:40:A9:D7:DE"
|
|
assert aps[0].mode == WifiMode.INFRASTRUCTURE
|
|
assert aps[1].mac == "18:4B:0D:23:A1:9C"
|
|
assert aps[1].mode == WifiMode.INFRASTRUCTURE
|
|
|
|
|
|
async def test_scan_wifi_with_failures(
|
|
coresys: CoreSys, wireless_service: DeviceWirelessService, caplog
|
|
):
|
|
"""Test scanning wifi with accesspoint processing failures."""
|
|
wireless_service.all_access_points = [
|
|
"/org/freedesktop/NetworkManager/AccessPoint/43099",
|
|
"/org/freedesktop/NetworkManager/AccessPoint/43100",
|
|
"/org/freedesktop/NetworkManager/AccessPoint/99999",
|
|
]
|
|
|
|
with patch("supervisor.host.network.asyncio.sleep"):
|
|
aps = await coresys.host.network.scan_wifi(coresys.host.network.get("wlan0"))
|
|
|
|
assert len(aps) == 2
|
|
assert "Can't process an AP" in caplog.text
|
|
|
|
|
|
async def test_host_connectivity_changed(
|
|
coresys: CoreSys,
|
|
network_manager_service: NetworkManagerService,
|
|
ha_ws_client: AsyncMock,
|
|
):
|
|
"""Test host connectivity changed."""
|
|
await coresys.host.load()
|
|
assert coresys.host.network.connectivity is True
|
|
|
|
network_manager_service.emit_properties_changed({"Connectivity": 1})
|
|
await network_manager_service.ping()
|
|
assert coresys.host.network.connectivity is False
|
|
await asyncio.sleep(0)
|
|
assert {
|
|
"type": WSType.SUPERVISOR_EVENT,
|
|
"data": {
|
|
"event": WSEvent.SUPERVISOR_UPDATE,
|
|
"update_key": "network",
|
|
"data": {"host_internet": False},
|
|
},
|
|
} in [call.args[0] for call in ha_ws_client.async_send_command.call_args_list]
|
|
|
|
ha_ws_client.async_send_command.reset_mock()
|
|
network_manager_service.emit_properties_changed({}, ["Connectivity"])
|
|
await network_manager_service.ping()
|
|
await network_manager_service.ping()
|
|
assert coresys.host.network.connectivity is True
|
|
await asyncio.sleep(0)
|
|
assert {
|
|
"type": WSType.SUPERVISOR_EVENT,
|
|
"data": {
|
|
"event": WSEvent.SUPERVISOR_UPDATE,
|
|
"update_key": "network",
|
|
"data": {"host_internet": True},
|
|
},
|
|
} in [call.args[0] for call in ha_ws_client.async_send_command.call_args_list]
|
|
|
|
|
|
async def test_host_connectivity_disabled(
|
|
coresys: CoreSys,
|
|
network_manager_service: NetworkManagerService,
|
|
ha_ws_client: AsyncMock,
|
|
):
|
|
"""Test host connectivity check disabled."""
|
|
await coresys.host.network.load()
|
|
|
|
await coresys.core.set_state(CoreState.RUNNING)
|
|
await asyncio.sleep(0)
|
|
ha_ws_client.async_send_command.reset_mock()
|
|
|
|
assert "connectivity_check" not in coresys.resolution.unsupported
|
|
assert coresys.host.network.connectivity is True
|
|
|
|
network_manager_service.emit_properties_changed({"ConnectivityCheckEnabled": False})
|
|
await network_manager_service.ping()
|
|
assert coresys.host.network.connectivity is None
|
|
await asyncio.sleep(0)
|
|
ha_ws_client.async_send_command.assert_any_call(
|
|
{
|
|
"type": WSType.SUPERVISOR_EVENT,
|
|
"data": {
|
|
"event": WSEvent.SUPERVISOR_UPDATE,
|
|
"update_key": "network",
|
|
"data": {"host_internet": None},
|
|
},
|
|
}
|
|
)
|
|
assert "connectivity_check" in coresys.resolution.unsupported
|
|
|
|
ha_ws_client.async_send_command.reset_mock()
|
|
network_manager_service.emit_properties_changed({"ConnectivityCheckEnabled": True})
|
|
await network_manager_service.ping()
|
|
await network_manager_service.ping()
|
|
assert coresys.host.network.connectivity is True
|
|
await asyncio.sleep(0)
|
|
ha_ws_client.async_send_command.assert_any_call(
|
|
{
|
|
"type": WSType.SUPERVISOR_EVENT,
|
|
"data": {
|
|
"event": WSEvent.SUPERVISOR_UPDATE,
|
|
"update_key": "network",
|
|
"data": {"host_internet": True},
|
|
},
|
|
}
|
|
)
|
|
assert "connectivity_check" not in coresys.resolution.unsupported
|