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>
220 lines
7.7 KiB
Python
220 lines
7.7 KiB
Python
"""Test NetwrokInterface."""
|
|
|
|
from ipaddress import IPv4Address, IPv4Interface, IPv6Address, IPv6Interface
|
|
|
|
from dbus_fast.aio.message_bus import MessageBus
|
|
import pytest
|
|
|
|
from supervisor.dbus.const import DeviceType, InterfaceMethod
|
|
from supervisor.dbus.network import NetworkManager
|
|
from supervisor.dbus.network.interface import NetworkInterface
|
|
|
|
from tests.common import mock_dbus_services
|
|
from tests.const import TEST_INTERFACE_ETH_NAME, TEST_INTERFACE_WLAN_NAME
|
|
from tests.dbus_service_mocks.base import DBusServiceMock
|
|
from tests.dbus_service_mocks.network_device import Device as DeviceService
|
|
|
|
|
|
@pytest.fixture(name="device_eth0_service")
|
|
async def fixture_device_eth0_service(
|
|
network_manager_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]],
|
|
) -> DeviceService:
|
|
"""Mock Device eth0 service."""
|
|
return network_manager_services["network_device"][
|
|
"/org/freedesktop/NetworkManager/Devices/1"
|
|
]
|
|
|
|
|
|
@pytest.fixture(name="device_wlan0_service")
|
|
async def fixture_device_wlan0_service(
|
|
network_manager_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]],
|
|
) -> DeviceService:
|
|
"""Mock Device wlan0 service."""
|
|
return network_manager_services["network_device"][
|
|
"/org/freedesktop/NetworkManager/Devices/3"
|
|
]
|
|
|
|
|
|
@pytest.fixture(name="device_unmanaged_service")
|
|
async def fixture_device_unmanaged_service(
|
|
dbus_session_bus: MessageBus,
|
|
) -> DeviceService:
|
|
"""Mock Device unmanaged service."""
|
|
return (
|
|
await mock_dbus_services(
|
|
{"network_device": "/org/freedesktop/NetworkManager/Devices/35"},
|
|
dbus_session_bus,
|
|
)
|
|
)["network_device"]
|
|
|
|
|
|
async def test_network_interface_ethernet(
|
|
device_eth0_service: DeviceService, dbus_session_bus: MessageBus
|
|
):
|
|
"""Test network interface."""
|
|
interface = NetworkInterface("/org/freedesktop/NetworkManager/Devices/1")
|
|
|
|
assert interface.sync_properties is False
|
|
assert interface.interface_name is None
|
|
assert interface.type is None
|
|
|
|
await interface.connect(dbus_session_bus)
|
|
|
|
assert interface.sync_properties is True
|
|
assert interface.interface_name == TEST_INTERFACE_ETH_NAME
|
|
assert interface.type == DeviceType.ETHERNET
|
|
assert interface.managed is True
|
|
assert interface.wireless is None
|
|
assert interface.connection.state == 2
|
|
assert interface.connection.uuid == "0c23631e-2118-355c-bbb0-8943229cb0d6"
|
|
|
|
assert interface.connection.ipv4.address == [IPv4Interface("192.168.2.148/24")]
|
|
assert interface.connection.ipv6.address == [
|
|
IPv6Interface("2a03:169:3df5:0:6be9:2588:b26a:a679/64"),
|
|
IPv6Interface("2a03:169:3df5::2f1/128"),
|
|
]
|
|
|
|
assert interface.connection.ipv4.gateway == IPv4Address("192.168.2.1")
|
|
assert interface.connection.ipv6.gateway == IPv6Address("fe80::da58:d7ff:fe00:9c69")
|
|
|
|
assert interface.connection.ipv4.nameservers == [IPv4Address("192.168.2.2")]
|
|
assert interface.connection.ipv6.nameservers == [
|
|
IPv6Address("2001:1620:2777:1::10"),
|
|
IPv6Address("2001:1620:2777:2::20"),
|
|
]
|
|
|
|
assert interface.settings.ipv4.method == InterfaceMethod.AUTO
|
|
assert interface.settings.ipv6.method == InterfaceMethod.AUTO
|
|
assert interface.settings.connection.id == "Wired connection 1"
|
|
|
|
device_eth0_service.emit_properties_changed({"Managed": False})
|
|
await device_eth0_service.ping()
|
|
assert interface.managed is False
|
|
|
|
device_eth0_service.emit_properties_changed({}, ["Managed"])
|
|
await device_eth0_service.ping()
|
|
await device_eth0_service.ping()
|
|
assert interface.managed is True
|
|
|
|
|
|
async def test_reapply(
|
|
device_eth0_service: DeviceService, dbus_session_bus: MessageBus
|
|
):
|
|
"""Test reapply on network interface."""
|
|
device_eth0_service.Reapply.calls.clear()
|
|
|
|
interface = NetworkInterface("/org/freedesktop/NetworkManager/Devices/1")
|
|
await interface.connect(dbus_session_bus)
|
|
|
|
await interface.reapply()
|
|
assert device_eth0_service.Reapply.calls == [
|
|
("/org/freedesktop/NetworkManager/Devices/1", {}, 0, 0)
|
|
]
|
|
|
|
|
|
async def test_network_interface_wlan(
|
|
device_wlan0_service: DeviceService, dbus_session_bus: MessageBus
|
|
):
|
|
"""Test wlan network interface."""
|
|
interface = NetworkInterface("/org/freedesktop/NetworkManager/Devices/3")
|
|
|
|
assert interface.wireless is None
|
|
|
|
await interface.connect(dbus_session_bus)
|
|
|
|
assert interface.sync_properties is True
|
|
assert interface.interface_name == TEST_INTERFACE_WLAN_NAME
|
|
assert interface.type == DeviceType.WIRELESS
|
|
assert interface.wireless is not None
|
|
assert interface.wireless.bitrate == 0
|
|
|
|
|
|
async def test_old_connection_disconnect(
|
|
network_manager: NetworkManager, device_eth0_service: DeviceService
|
|
):
|
|
"""Test old connection disconnects on connection change."""
|
|
interface = network_manager.get(TEST_INTERFACE_ETH_NAME)
|
|
connection = interface.connection
|
|
assert connection.is_connected is True
|
|
|
|
device_eth0_service.emit_properties_changed({"ActiveConnection": "/"})
|
|
await device_eth0_service.ping()
|
|
|
|
assert interface.connection is None
|
|
assert connection.is_connected is False
|
|
|
|
|
|
async def test_old_wireless_disconnect(
|
|
network_manager: NetworkManager, device_wlan0_service: DeviceService
|
|
):
|
|
"""Test old wireless disconnects on type change."""
|
|
interface = network_manager.get(TEST_INTERFACE_WLAN_NAME)
|
|
wireless = interface.wireless
|
|
assert wireless.is_connected is True
|
|
|
|
device_wlan0_service.emit_properties_changed({"DeviceType": DeviceType.ETHERNET})
|
|
await device_wlan0_service.ping()
|
|
|
|
assert interface.wireless is None
|
|
assert wireless.is_connected is False
|
|
|
|
|
|
async def test_unmanaged_interface(
|
|
device_unmanaged_service: DeviceService, dbus_session_bus: MessageBus
|
|
):
|
|
"""Test unmanaged interfaces don't sync properties."""
|
|
interface = NetworkInterface("/org/freedesktop/NetworkManager/Devices/35")
|
|
await interface.connect(dbus_session_bus)
|
|
|
|
assert interface.managed is False
|
|
assert interface.connection is None
|
|
assert interface.driver == "veth"
|
|
assert interface.sync_properties is False
|
|
|
|
device_unmanaged_service.emit_properties_changed({"Driver": "test"})
|
|
await device_unmanaged_service.ping()
|
|
assert interface.driver == "veth"
|
|
|
|
|
|
async def test_interface_becomes_unmanaged(
|
|
network_manager: NetworkManager,
|
|
device_eth0_service: DeviceService,
|
|
device_wlan0_service: DeviceService,
|
|
):
|
|
"""Test managed objects disconnect when interface becomes unmanaged."""
|
|
eth0 = network_manager.get(TEST_INTERFACE_ETH_NAME)
|
|
connection = eth0.connection
|
|
wlan0 = network_manager.get(TEST_INTERFACE_WLAN_NAME)
|
|
wireless = wlan0.wireless
|
|
|
|
assert connection.is_connected is True
|
|
assert wireless.is_connected is True
|
|
|
|
device_eth0_service.emit_properties_changed({"Managed": False})
|
|
await device_eth0_service.ping()
|
|
device_wlan0_service.emit_properties_changed({"Managed": False})
|
|
await device_wlan0_service.ping()
|
|
|
|
assert wlan0.wireless is None
|
|
assert wireless.is_connected is False
|
|
assert eth0.connection is None
|
|
assert connection.is_connected is False
|
|
|
|
|
|
async def test_unknown_device_type(
|
|
device_eth0_service: DeviceService, dbus_session_bus: MessageBus
|
|
):
|
|
"""Test unknown device types are handled gracefully."""
|
|
interface = NetworkInterface("/org/freedesktop/NetworkManager/Devices/1")
|
|
await interface.connect(dbus_session_bus)
|
|
|
|
# Emit an unknown device type (e.g., 1000 which doesn't exist in the enum)
|
|
device_eth0_service.emit_properties_changed({"DeviceType": 1000})
|
|
await device_eth0_service.ping()
|
|
|
|
# Should preserve the actual value as a pseudo-member instead of crashing
|
|
assert isinstance(interface.type, DeviceType)
|
|
assert interface.type == 1000
|
|
# Wireless should be None since it's not a wireless device
|
|
assert interface.wireless is None
|