From 99bc2016881654fa1f39b679b6c59e37a639a992 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Sat, 17 Sep 2022 03:55:41 -0400 Subject: [PATCH] Listen for dbus property changes (#3872) * Listen for dbus property changes * Avoid remaking dbus proxy objects * proper snake case for pylint * some cleanup and more tests --- supervisor/dbus/agent/__init__.py | 33 +++-- supervisor/dbus/agent/apparmor.py | 19 +-- supervisor/dbus/agent/cgroup.py | 8 +- supervisor/dbus/agent/datadisk.py | 20 +-- supervisor/dbus/agent/system.py | 8 +- supervisor/dbus/hostname.py | 20 ++- supervisor/dbus/interface.py | 43 ++++-- supervisor/dbus/logind.py | 6 +- supervisor/dbus/manager.py | 45 ++++--- supervisor/dbus/network/__init__.py | 88 +++++++----- supervisor/dbus/network/accesspoint.py | 21 ++- supervisor/dbus/network/configuration.py | 11 +- supervisor/dbus/network/connection.py | 125 +++++++++--------- supervisor/dbus/network/dns.py | 47 ++++--- supervisor/dbus/network/interface.py | 69 +++++++--- supervisor/dbus/network/ip_configuration.py | 68 ++++++++++ supervisor/dbus/network/setting/__init__.py | 26 +++- supervisor/dbus/network/settings.py | 6 +- supervisor/dbus/network/wireless.py | 38 ++++-- supervisor/dbus/rauc.py | 32 +++-- supervisor/dbus/resolved.py | 21 ++- supervisor/dbus/systemd.py | 20 +-- supervisor/dbus/timedate.py | 20 ++- supervisor/host/control.py | 1 - supervisor/host/manager.py | 27 ++-- supervisor/host/network.py | 55 +++++++- supervisor/os/manager.py | 1 - supervisor/utils/dbus.py | 116 +++++++++++++--- tests/api/test_dns.py | 2 +- tests/api/test_host.py | 8 +- tests/common.py | 61 +++++++++ tests/conftest.py | 41 +++--- tests/dbus/agent/test_agent.py | 12 ++ tests/dbus/agent/test_apparmor.py | 11 ++ tests/dbus/agent/test_datadisk.py | 13 ++ tests/dbus/network/setting/test_init.py | 18 +++ tests/dbus/network/test_dns.py | 11 ++ tests/dbus/network/test_interface.py | 10 ++ tests/dbus/network/test_ip_configuration.py | 42 ++++++ tests/dbus/network/test_network_manager.py | 25 +++- tests/dbus/network/test_setting.py | 25 +++- tests/dbus/network/test_wireless.py | 24 ++++ tests/dbus/test_hostname.py | 12 ++ tests/dbus/test_interface.py | 92 +++++++++++++ tests/dbus/test_rauc.py | 14 ++ tests/dbus/test_resolved.py | 11 ++ tests/dbus/test_timedate.py | 13 ++ tests/fixtures/apparmor/.empty/.empty | 0 tests/host/test_control.py | 24 ++++ tests/host/test_manager.py | 75 ++++------- tests/host/test_network.py | 96 ++++++++++++++ tests/host/test_supported_features.py | 2 +- .../test_evaluate_network_manager.py | 2 +- .../evaluation/test_evaluate_resolved.py | 13 +- 54 files changed, 1207 insertions(+), 444 deletions(-) create mode 100644 supervisor/dbus/network/ip_configuration.py create mode 100644 tests/dbus/network/test_ip_configuration.py create mode 100644 tests/dbus/test_interface.py create mode 100644 tests/fixtures/apparmor/.empty/.empty create mode 100644 tests/host/test_control.py diff --git a/supervisor/dbus/agent/__init__.py b/supervisor/dbus/agent/__init__.py index 2e1e330ec..bacaf4349 100644 --- a/supervisor/dbus/agent/__init__.py +++ b/supervisor/dbus/agent/__init__.py @@ -7,7 +7,6 @@ from awesomeversion import AwesomeVersion from dbus_next.aio.message_bus import MessageBus from ...exceptions import DBusError, DBusInterfaceError -from ...utils.dbus import DBus from ..const import ( DBUS_ATTR_DIAGNOSTICS, DBUS_ATTR_VERSION, @@ -15,7 +14,7 @@ from ..const import ( DBUS_NAME_HAOS, DBUS_OBJECT_HAOS, ) -from ..interface import DBusInterface, dbus_property +from ..interface import DBusInterfaceProxy, dbus_property from ..utils import dbus_connected from .apparmor import AppArmor from .cgroup import CGroup @@ -25,10 +24,13 @@ from .system import System _LOGGER: logging.Logger = logging.getLogger(__name__) -class OSAgent(DBusInterface): +class OSAgent(DBusInterfaceProxy): """Handle D-Bus interface for OS-Agent.""" - name = DBUS_NAME_HAOS + name: str = DBUS_NAME_HAOS + bus_name: str = DBUS_NAME_HAOS + object_path: str = DBUS_OBJECT_HAOS + properties_interface: str = DBUS_IFACE_HAOS def __init__(self) -> None: """Initialize Properties.""" @@ -79,8 +81,9 @@ class OSAgent(DBusInterface): async def connect(self, bus: MessageBus) -> None: """Connect to system's D-Bus.""" + _LOGGER.info("Load dbus interface %s", self.name) try: - self.dbus = await DBus.connect(bus, DBUS_NAME_HAOS, DBUS_OBJECT_HAOS) + await super().connect(bus) await self.cgroup.connect(bus) await self.apparmor.connect(bus) await self.system.connect(bus) @@ -93,8 +96,20 @@ class OSAgent(DBusInterface): ) @dbus_connected - async def update(self): + async def update(self, changed: dict[str, Any] | None = None) -> None: """Update Properties.""" - self.properties = await self.dbus.get_properties(DBUS_IFACE_HAOS) - await self.apparmor.update() - await self.datadisk.update() + await super().update(changed) + + if not changed and self.apparmor.is_connected: + await self.apparmor.update() + + if not changed and self.datadisk.is_connected: + await self.datadisk.update() + + def disconnect(self) -> None: + """Disconnect from D-Bus.""" + self.cgroup.disconnect() + self.apparmor.disconnect() + self.system.disconnect() + self.datadisk.disconnect() + super().disconnect() diff --git a/supervisor/dbus/agent/apparmor.py b/supervisor/dbus/agent/apparmor.py index 3a24c242f..26810630d 100644 --- a/supervisor/dbus/agent/apparmor.py +++ b/supervisor/dbus/agent/apparmor.py @@ -3,22 +3,24 @@ from pathlib import Path from typing import Any from awesomeversion import AwesomeVersion -from dbus_next.aio.message_bus import MessageBus -from ...utils.dbus import DBus from ..const import ( DBUS_ATTR_PARSER_VERSION, DBUS_IFACE_HAOS_APPARMOR, DBUS_NAME_HAOS, DBUS_OBJECT_HAOS_APPARMOR, ) -from ..interface import DBusInterface, dbus_property +from ..interface import DBusInterfaceProxy, dbus_property from ..utils import dbus_connected -class AppArmor(DBusInterface): +class AppArmor(DBusInterfaceProxy): """AppArmor object for OS Agent.""" + bus_name: str = DBUS_NAME_HAOS + object_path: str = DBUS_OBJECT_HAOS_APPARMOR + properties_interface: str = DBUS_IFACE_HAOS_APPARMOR + def __init__(self) -> None: """Initialize Properties.""" self.properties: dict[str, Any] = {} @@ -29,15 +31,6 @@ class AppArmor(DBusInterface): """Return version of host AppArmor parser.""" return AwesomeVersion(self.properties[DBUS_ATTR_PARSER_VERSION]) - async def connect(self, bus: MessageBus) -> None: - """Get connection information.""" - self.dbus = await DBus.connect(bus, DBUS_NAME_HAOS, DBUS_OBJECT_HAOS_APPARMOR) - - @dbus_connected - async def update(self): - """Update Properties.""" - self.properties = await self.dbus.get_properties(DBUS_IFACE_HAOS_APPARMOR) - @dbus_connected async def load_profile(self, profile: Path, cache: Path) -> None: """Load/Update AppArmor profile.""" diff --git a/supervisor/dbus/agent/cgroup.py b/supervisor/dbus/agent/cgroup.py index e565fc842..1114ab8f8 100644 --- a/supervisor/dbus/agent/cgroup.py +++ b/supervisor/dbus/agent/cgroup.py @@ -1,8 +1,5 @@ """CGroup object for OS-Agent.""" -from dbus_next.aio.message_bus import MessageBus - -from ...utils.dbus import DBus from ..const import DBUS_NAME_HAOS, DBUS_OBJECT_HAOS_CGROUP from ..interface import DBusInterface from ..utils import dbus_connected @@ -11,9 +8,8 @@ from ..utils import dbus_connected class CGroup(DBusInterface): """CGroup object for OS Agent.""" - async def connect(self, bus: MessageBus) -> None: - """Get connection information.""" - self.dbus = await DBus.connect(bus, DBUS_NAME_HAOS, DBUS_OBJECT_HAOS_CGROUP) + bus_name: str = DBUS_NAME_HAOS + object_path: str = DBUS_OBJECT_HAOS_CGROUP @dbus_connected async def add_devices_allowed(self, container_id: str, permission: str) -> None: diff --git a/supervisor/dbus/agent/datadisk.py b/supervisor/dbus/agent/datadisk.py index 802d9f899..cfae729e3 100644 --- a/supervisor/dbus/agent/datadisk.py +++ b/supervisor/dbus/agent/datadisk.py @@ -2,22 +2,23 @@ from pathlib import Path from typing import Any -from dbus_next.aio.message_bus import MessageBus - -from ...utils.dbus import DBus from ..const import ( DBUS_ATTR_CURRENT_DEVICE, DBUS_IFACE_HAOS_DATADISK, DBUS_NAME_HAOS, DBUS_OBJECT_HAOS_DATADISK, ) -from ..interface import DBusInterface, dbus_property +from ..interface import DBusInterfaceProxy, dbus_property from ..utils import dbus_connected -class DataDisk(DBusInterface): +class DataDisk(DBusInterfaceProxy): """DataDisk object for OS Agent.""" + bus_name: str = DBUS_NAME_HAOS + object_path: str = DBUS_OBJECT_HAOS_DATADISK + properties_interface: str = DBUS_IFACE_HAOS_DATADISK + def __init__(self) -> None: """Initialize Properties.""" self.properties: dict[str, Any] = {} @@ -28,15 +29,6 @@ class DataDisk(DBusInterface): """Return current device used for data.""" return Path(self.properties[DBUS_ATTR_CURRENT_DEVICE]) - async def connect(self, bus: MessageBus) -> None: - """Get connection information.""" - self.dbus = await DBus.connect(bus, DBUS_NAME_HAOS, DBUS_OBJECT_HAOS_DATADISK) - - @dbus_connected - async def update(self): - """Update Properties.""" - self.properties = await self.dbus.get_properties(DBUS_IFACE_HAOS_DATADISK) - @dbus_connected async def change_device(self, device: Path) -> None: """Migrate data disk to a new device.""" diff --git a/supervisor/dbus/agent/system.py b/supervisor/dbus/agent/system.py index 68a8a1867..fd15acf58 100644 --- a/supervisor/dbus/agent/system.py +++ b/supervisor/dbus/agent/system.py @@ -1,8 +1,5 @@ """System object for OS-Agent.""" -from dbus_next.aio.message_bus import MessageBus - -from ...utils.dbus import DBus from ..const import DBUS_NAME_HAOS, DBUS_OBJECT_HAOS_SYSTEM from ..interface import DBusInterface from ..utils import dbus_connected @@ -11,9 +8,8 @@ from ..utils import dbus_connected class System(DBusInterface): """System object for OS Agent.""" - async def connect(self, bus: MessageBus) -> None: - """Get connection information.""" - self.dbus = await DBus.connect(bus, DBUS_NAME_HAOS, DBUS_OBJECT_HAOS_SYSTEM) + bus_name: str = DBUS_NAME_HAOS + object_path: str = DBUS_OBJECT_HAOS_SYSTEM @dbus_connected async def schedule_wipe_device(self) -> None: diff --git a/supervisor/dbus/hostname.py b/supervisor/dbus/hostname.py index 6a4107536..9f2fe0633 100644 --- a/supervisor/dbus/hostname.py +++ b/supervisor/dbus/hostname.py @@ -5,7 +5,6 @@ from typing import Any from dbus_next.aio.message_bus import MessageBus from ..exceptions import DBusError, DBusInterfaceError -from ..utils.dbus import DBus from .const import ( DBUS_ATTR_CHASSIS, DBUS_ATTR_DEPLOYMENT, @@ -17,19 +16,22 @@ from .const import ( DBUS_NAME_HOSTNAME, DBUS_OBJECT_HOSTNAME, ) -from .interface import DBusInterface, dbus_property +from .interface import DBusInterfaceProxy, dbus_property from .utils import dbus_connected _LOGGER: logging.Logger = logging.getLogger(__name__) -class Hostname(DBusInterface): +class Hostname(DBusInterfaceProxy): """Handle D-Bus interface for hostname/system. https://www.freedesktop.org/software/systemd/man/org.freedesktop.hostname1.html """ - name = DBUS_NAME_HOSTNAME + name: str = DBUS_NAME_HOSTNAME + bus_name: str = DBUS_NAME_HOSTNAME + object_path: str = DBUS_OBJECT_HOSTNAME + properties_interface: str = DBUS_IFACE_HOSTNAME def __init__(self): """Initialize Properties.""" @@ -37,10 +39,9 @@ class Hostname(DBusInterface): async def connect(self, bus: MessageBus): """Connect to system's D-Bus.""" + _LOGGER.info("Load dbus interface %s", self.name) try: - self.dbus = await DBus.connect( - bus, DBUS_NAME_HOSTNAME, DBUS_OBJECT_HOSTNAME - ) + await super().connect(bus) except DBusError: _LOGGER.warning("Can't connect to systemd-hostname") except DBusInterfaceError: @@ -88,8 +89,3 @@ class Hostname(DBusInterface): async def set_static_hostname(self, hostname: str) -> None: """Change local hostname.""" await self.dbus.call_set_static_hostname(hostname, False) - - @dbus_connected - async def update(self): - """Update Properties.""" - self.properties = await self.dbus.get_properties(DBUS_IFACE_HOSTNAME) diff --git a/supervisor/dbus/interface.py b/supervisor/dbus/interface.py index 15116bdba..e3a35d1c7 100644 --- a/supervisor/dbus/interface.py +++ b/supervisor/dbus/interface.py @@ -1,11 +1,12 @@ """Interface class for D-Bus wrappers.""" -from abc import ABC, abstractmethod +from abc import ABC from functools import wraps from typing import Any from dbus_next.aio.message_bus import MessageBus from ..utils.dbus import DBus +from .utils import dbus_connected def dbus_property(func): @@ -26,28 +27,48 @@ class DBusInterface(ABC): dbus: DBus | None = None name: str | None = None + bus_name: str | None = None + object_path: str | None = None @property - def is_connected(self): + def is_connected(self) -> bool: """Return True, if they is connected to D-Bus.""" return self.dbus is not None - @abstractmethod - async def connect(self, bus: MessageBus): + def __del__(self) -> None: + """Disconnect on delete.""" + self.disconnect() + + async def connect(self, bus: MessageBus) -> None: """Connect to D-Bus.""" + self.dbus = await DBus.connect(bus, self.bus_name, self.object_path) - def disconnect(self): + def disconnect(self) -> None: """Disconnect from D-Bus.""" - self.dbus = None + if self.is_connected: + self.dbus.disconnect() + self.dbus = None -class DBusInterfaceProxy(ABC): +class DBusInterfaceProxy(DBusInterface): """Handle D-Bus interface proxy.""" - dbus: DBus | None = None - object_path: str | None = None + properties_interface: str | None = None properties: dict[str, Any] | None = None + sync_properties: bool = True - @abstractmethod - async def connect(self, bus: MessageBus): + async def connect(self, bus: MessageBus) -> None: """Connect to D-Bus.""" + await super().connect(bus) + await self.update() + + if self.sync_properties: + self.dbus.sync_property_changes(self.properties_interface, self.update) + + @dbus_connected + async def update(self, changed: dict[str, Any] | None = None) -> None: + """Update properties via D-Bus.""" + if changed and self.properties: + self.properties.update(changed) + else: + self.properties = await self.dbus.get_properties(self.properties_interface) diff --git a/supervisor/dbus/logind.py b/supervisor/dbus/logind.py index c3482378e..df70d164a 100644 --- a/supervisor/dbus/logind.py +++ b/supervisor/dbus/logind.py @@ -4,7 +4,6 @@ import logging from dbus_next.aio.message_bus import MessageBus from ..exceptions import DBusError, DBusInterfaceError -from ..utils.dbus import DBus from .const import DBUS_NAME_LOGIND, DBUS_OBJECT_LOGIND from .interface import DBusInterface from .utils import dbus_connected @@ -19,11 +18,14 @@ class Logind(DBusInterface): """ name = DBUS_NAME_LOGIND + bus_name: str = DBUS_NAME_LOGIND + object_path: str = DBUS_OBJECT_LOGIND async def connect(self, bus: MessageBus): """Connect to D-Bus.""" + _LOGGER.info("Load dbus interface %s", self.name) try: - self.dbus = await DBus.connect(bus, DBUS_NAME_LOGIND, DBUS_OBJECT_LOGIND) + await super().connect(bus) except DBusError: _LOGGER.warning("Can't connect to systemd-logind") except DBusInterfaceError: diff --git a/supervisor/dbus/manager.py b/supervisor/dbus/manager.py index 87ff698a1..f14406931 100644 --- a/supervisor/dbus/manager.py +++ b/supervisor/dbus/manager.py @@ -1,4 +1,5 @@ """D-Bus interface objects.""" +import asyncio import logging from dbus_next import BusType @@ -82,6 +83,20 @@ class DBusManager(CoreSysAttributes): """Return the message bus.""" return self._bus + @property + def all(self) -> list[DBusInterface]: + """Return all managed dbus interfaces.""" + return [ + self.agent, + self.systemd, + self.logind, + self.hostname, + self.timedate, + self.network, + self.rauc, + self.resolved, + ] + async def load(self) -> None: """Connect interfaces to D-Bus.""" if not SOCKET_DBUS.exists(): @@ -99,22 +114,17 @@ class DBusManager(CoreSysAttributes): _LOGGER.info("Connected to system D-Bus.") - dbus_loads: list[DBusInterface] = [ - self.agent, - self.systemd, - self.logind, - self.hostname, - self.timedate, - self.network, - self.rauc, - self.resolved, - ] - for dbus in dbus_loads: - _LOGGER.info("Load dbus interface %s", dbus.name) - try: - await dbus.connect(self.bus) - except Exception as err: # pylint: disable=broad-except - _LOGGER.warning("Can't load dbus interface %s: %s", dbus.name, err) + errors = await asyncio.gather( + *[dbus.connect(self.bus) for dbus in self.all], return_exceptions=True + ) + + for err in errors: + if err: + _LOGGER.warning( + "Can't load dbus interface %s: %s", + self.all[errors.index(err)].name, + err, + ) self.sys_host.supported_features.cache_clear() @@ -124,5 +134,8 @@ class DBusManager(CoreSysAttributes): _LOGGER.warning("No D-Bus connection to close.") return + for dbus in self.all: + dbus.disconnect() + self.bus.disconnect() _LOGGER.info("Closed conection to system D-Bus.") diff --git a/supervisor/dbus/network/__init__.py b/supervisor/dbus/network/__init__.py index 045751f82..f710ac713 100644 --- a/supervisor/dbus/network/__init__.py +++ b/supervisor/dbus/network/__init__.py @@ -1,5 +1,4 @@ """Network Manager implementation for DBUS.""" -import asyncio import logging from typing import Any @@ -14,7 +13,6 @@ from ...exceptions import ( DBusInterfaceMethodError, HostNotSupportedError, ) -from ...utils.dbus import DBus from ..const import ( DBUS_ATTR_CONNECTION_ENABLED, DBUS_ATTR_DEVICES, @@ -24,9 +22,10 @@ from ..const import ( DBUS_NAME_NM, DBUS_OBJECT_BASE, DBUS_OBJECT_NM, + ConnectivityState, DeviceType, ) -from ..interface import DBusInterface, dbus_property +from ..interface import DBusInterfaceProxy, dbus_property from ..utils import dbus_connected from .connection import NetworkConnection from .dns import NetworkManagerDNS @@ -39,13 +38,16 @@ _LOGGER: logging.Logger = logging.getLogger(__name__) MINIMAL_VERSION = AwesomeVersion("1.14.6") -class NetworkManager(DBusInterface): +class NetworkManager(DBusInterfaceProxy): """Handle D-Bus interface for Network Manager. https://developer.gnome.org/NetworkManager/stable/gdbus-org.freedesktop.NetworkManager.html """ - name = DBUS_NAME_NM + name: str = DBUS_NAME_NM + bus_name: str = DBUS_NAME_NM + object_path: str = DBUS_OBJECT_NM + properties_interface: str = DBUS_IFACE_NM def __init__(self) -> None: """Initialize Properties.""" @@ -99,22 +101,16 @@ class NetworkManager(DBusInterface): self, settings: Any, device_object: str ) -> tuple[NetworkSetting, NetworkConnection]: """Activate a connction on a device.""" - ( - obj_con_setting, - obj_active_con, - ) = await self.dbus.call_add_and_activate_connection( + (_, obj_active_con,) = await self.dbus.call_add_and_activate_connection( settings, device_object, DBUS_OBJECT_BASE ) - con_setting = NetworkSetting(obj_con_setting) active_con = NetworkConnection(obj_active_con) - await asyncio.gather( - con_setting.connect(self.dbus.bus), active_con.connect(self.dbus.bus) - ) - return con_setting, active_con + await active_con.connect(self.dbus.bus) + return active_con.settings, active_con @dbus_connected - async def check_connectivity(self, *, force: bool = False) -> int: + async def check_connectivity(self, *, force: bool = False) -> ConnectivityState: """Check the connectivity of the host.""" if force: return await self.dbus.call_check_connectivity() @@ -123,8 +119,9 @@ class NetworkManager(DBusInterface): async def connect(self, bus: MessageBus) -> None: """Connect to system's D-Bus.""" + _LOGGER.info("Load dbus interface %s", self.name) try: - self.dbus = await DBus.connect(bus, DBUS_NAME_NM, DBUS_OBJECT_NM) + await super().connect(bus) await self.dns.connect(bus) await self.settings.connect(bus) except DBusError: @@ -159,29 +156,38 @@ class NetworkManager(DBusInterface): ) @dbus_connected - async def update(self): + async def update(self, changed: dict[str, Any] | None = None) -> None: """Update Properties.""" - self.properties = await self.dbus.get_properties(DBUS_IFACE_NM) + await super().update(changed) - await self.dns.update() + if not changed and self.dns.is_connected: + await self.dns.update() - self._interfaces.clear() + if changed and DBUS_ATTR_DEVICES not in changed: + return + + interfaces = {} + curr_devices = {intr.object_path: intr for intr in self.interfaces.values()} for device in self.properties[DBUS_ATTR_DEVICES]: - interface = NetworkInterface(self.dbus, device) + if device in curr_devices and curr_devices[device].is_connected: + interface = curr_devices[device] + await interface.update() + else: + interface = NetworkInterface(self.dbus, device) - # Connect to interface - try: - await interface.connect(self.dbus.bus) - except (DBusFatalError, DBusInterfaceMethodError) as err: - # Docker creates and deletes interfaces quite often, sometimes - # this causes a race condition: A device disappears while we - # try to query it. Ignore those cases. - _LOGGER.warning("Can't process %s: %s", device, err) - continue - except Exception as err: # pylint: disable=broad-except - _LOGGER.exception("Error while processing interface: %s", err) - sentry_sdk.capture_exception(err) - continue + # Connect to interface + try: + await interface.connect(self.dbus.bus) + except (DBusFatalError, DBusInterfaceMethodError) as err: + # Docker creates and deletes interfaces quite often, sometimes + # this causes a race condition: A device disappears while we + # try to query it. Ignore those cases. + _LOGGER.warning("Can't process %s: %s", device, err) + continue + except Exception as err: # pylint: disable=broad-except + _LOGGER.exception("Error while processing interface: %s", err) + sentry_sdk.capture_exception(err) + continue # Skeep interface if ( @@ -202,4 +208,16 @@ class NetworkManager(DBusInterface): ): interface.primary = True - self._interfaces[interface.name] = interface + interfaces[interface.name] = interface + + self._interfaces = interfaces + + def disconnect(self) -> None: + """Disconnect from D-Bus.""" + self.dns.disconnect() + self.settings.disconnect() + + for intr in self.interfaces.values(): + intr.disconnect() + + super().disconnect() diff --git a/supervisor/dbus/network/accesspoint.py b/supervisor/dbus/network/accesspoint.py index 5424645e0..a06251122 100644 --- a/supervisor/dbus/network/accesspoint.py +++ b/supervisor/dbus/network/accesspoint.py @@ -1,8 +1,7 @@ """Connection object for Network Manager.""" -from dbus_next.aio.message_bus import MessageBus +from typing import Any -from ...utils.dbus import DBus from ..const import ( DBUS_ATTR_FREQUENCY, DBUS_ATTR_HWADDRESS, @@ -21,10 +20,15 @@ class NetworkWirelessAP(DBusInterfaceProxy): https://developer.gnome.org/NetworkManager/stable/gdbus-org.freedesktop.NetworkManager.AccessPoint.html """ + bus_name: str = DBUS_NAME_NM + properties_interface: str = DBUS_IFACE_ACCESSPOINT + # Don't sync these. They may disappear and strength changes a lot + sync_properties: bool = False + def __init__(self, object_path: str) -> None: """Initialize NetworkWireless AP object.""" - self.object_path = object_path - self.properties = {} + self.object_path: str = object_path + self.properties: dict[str, Any] = {} @property @dbus_property @@ -47,16 +51,11 @@ class NetworkWirelessAP(DBusInterfaceProxy): @property @dbus_property def mode(self) -> int: - """Return details about mac address.""" + """Return details about mode.""" return self.properties[DBUS_ATTR_MODE] @property @dbus_property def strength(self) -> int: - """Return details about mac address.""" + """Return details about strength.""" return int(self.properties[DBUS_ATTR_STRENGTH]) - - async def connect(self, bus: MessageBus) -> None: - """Get connection information.""" - self.dbus = await DBus.connect(bus, DBUS_NAME_NM, self.object_path) - self.properties = await self.dbus.get_properties(DBUS_IFACE_ACCESSPOINT) diff --git a/supervisor/dbus/network/configuration.py b/supervisor/dbus/network/configuration.py index 65415a06d..3490af56a 100644 --- a/supervisor/dbus/network/configuration.py +++ b/supervisor/dbus/network/configuration.py @@ -1,18 +1,9 @@ """NetworkConnection object4s for Network Manager.""" -from ipaddress import IPv4Address, IPv4Interface, IPv6Address, IPv6Interface +from ipaddress import IPv4Address, IPv6Address import attr -@attr.s(slots=True) -class IpConfiguration: - """NetworkSettingsIPConfig object for Network Manager.""" - - gateway: IPv4Address | IPv6Address | None = attr.ib() - nameservers: list[IPv4Address | IPv6Address] = attr.ib() - address: list[IPv4Interface | IPv6Interface] = attr.ib() - - @attr.s(slots=True) class DNSConfiguration: """DNS configuration Object.""" diff --git a/supervisor/dbus/network/connection.py b/supervisor/dbus/network/connection.py index eb38943a6..0bb8d49e9 100644 --- a/supervisor/dbus/network/connection.py +++ b/supervisor/dbus/network/connection.py @@ -1,26 +1,19 @@ """Connection object for Network Manager.""" -from ipaddress import ip_address, ip_interface -from dbus_next.aio.message_bus import MessageBus +from typing import Any + +from supervisor.dbus.network.setting import NetworkSetting -from ...const import ATTR_ADDRESS, ATTR_PREFIX -from ...utils.dbus import DBus from ..const import ( - DBUS_ATTR_ADDRESS_DATA, DBUS_ATTR_CONNECTION, - DBUS_ATTR_GATEWAY, DBUS_ATTR_ID, DBUS_ATTR_IP4CONFIG, DBUS_ATTR_IP6CONFIG, - DBUS_ATTR_NAMESERVER_DATA, - DBUS_ATTR_NAMESERVERS, DBUS_ATTR_STATE, DBUS_ATTR_STATE_FLAGS, DBUS_ATTR_TYPE, DBUS_ATTR_UUID, DBUS_IFACE_CONNECTION_ACTIVE, - DBUS_IFACE_IP4CONFIG, - DBUS_IFACE_IP6CONFIG, DBUS_NAME_NM, DBUS_OBJECT_BASE, ConnectionStateFlags, @@ -28,7 +21,7 @@ from ..const import ( ) from ..interface import DBusInterfaceProxy, dbus_property from ..utils import dbus_connected -from .configuration import IpConfiguration +from .ip_configuration import IpConfiguration class NetworkConnection(DBusInterfaceProxy): @@ -37,14 +30,18 @@ class NetworkConnection(DBusInterfaceProxy): https://developer.gnome.org/NetworkManager/stable/gdbus-org.freedesktop.NetworkManager.Connection.Active.html """ + bus_name: str = DBUS_NAME_NM + properties_interface: str = DBUS_IFACE_CONNECTION_ACTIVE + def __init__(self, object_path: str) -> None: """Initialize NetworkConnection object.""" - self.object_path = object_path - self.properties = {} + self.object_path: str = object_path + self.properties: dict[str, Any] = {} self._ipv4: IpConfiguration | None = None self._ipv6: IpConfiguration | None = None self._state_flags: set[ConnectionStateFlags] = {ConnectionStateFlags.NONE} + self._settings: NetworkSetting | None = None @property @dbus_property @@ -76,10 +73,9 @@ class NetworkConnection(DBusInterfaceProxy): return self._state_flags @property - @dbus_property - def setting_object(self) -> str: - """Return the connection object path.""" - return self.properties[DBUS_ATTR_CONNECTION] + def settings(self) -> NetworkSetting | None: + """Return settings.""" + return self._settings @property def ipv4(self) -> IpConfiguration | None: @@ -91,15 +87,10 @@ class NetworkConnection(DBusInterfaceProxy): """Return a ip6 configuration object for the connection.""" return self._ipv6 - async def connect(self, bus: MessageBus) -> None: - """Get connection information.""" - self.dbus = await DBus.connect(bus, DBUS_NAME_NM, self.object_path) - await self.update() - @dbus_connected - async def update(self): + async def update(self, changed: dict[str, Any] | None = None) -> None: """Update connection information.""" - self.properties = await self.dbus.get_properties(DBUS_IFACE_CONNECTION_ACTIVE) + await super().update(changed) # State Flags self._state_flags = { @@ -109,43 +100,55 @@ class NetworkConnection(DBusInterfaceProxy): } or {ConnectionStateFlags.NONE} # IPv4 - if self.properties[DBUS_ATTR_IP4CONFIG] != DBUS_OBJECT_BASE: - ip4 = await DBus.connect( - self.dbus.bus, DBUS_NAME_NM, self.properties[DBUS_ATTR_IP4CONFIG] - ) - ip4_data = await ip4.get_properties(DBUS_IFACE_IP4CONFIG) - - self._ipv4 = IpConfiguration( - ip_address(ip4_data[DBUS_ATTR_GATEWAY]) - if ip4_data.get(DBUS_ATTR_GATEWAY) - else None, - [ - ip_address(nameserver[ATTR_ADDRESS]) - for nameserver in ip4_data.get(DBUS_ATTR_NAMESERVER_DATA, []) - ], - [ - ip_interface(f"{address[ATTR_ADDRESS]}/{address[ATTR_PREFIX]}") - for address in ip4_data.get(DBUS_ATTR_ADDRESS_DATA, []) - ], - ) + if not changed or DBUS_ATTR_IP4CONFIG in changed: + if ( + self._ipv4 + and self._ipv4.is_connected + and self._ipv4.object_path == self.properties[DBUS_ATTR_IP4CONFIG] + ): + await self._ipv4.update() + elif self.properties[DBUS_ATTR_IP4CONFIG] != DBUS_OBJECT_BASE: + self._ipv4 = IpConfiguration(self.properties[DBUS_ATTR_IP4CONFIG]) + await self._ipv4.connect(self.dbus.bus) + else: + self._ipv4 = None # IPv6 - if self.properties[DBUS_ATTR_IP6CONFIG] != DBUS_OBJECT_BASE: - ip6 = await DBus.connect( - self.dbus.bus, DBUS_NAME_NM, self.properties[DBUS_ATTR_IP6CONFIG] - ) - ip6_data = await ip6.get_properties(DBUS_IFACE_IP6CONFIG) + if not changed or DBUS_ATTR_IP6CONFIG in changed: + if ( + self._ipv6 + and self._ipv6.is_connected + and self._ipv6.object_path == self.properties[DBUS_ATTR_IP6CONFIG] + ): + await self._ipv6.update() + elif self.properties[DBUS_ATTR_IP6CONFIG] != DBUS_OBJECT_BASE: + self._ipv6 = IpConfiguration( + self.properties[DBUS_ATTR_IP6CONFIG], False + ) + await self._ipv6.connect(self.dbus.bus) + else: + self._ipv6 = None - self._ipv6 = IpConfiguration( - ip_address(ip6_data[DBUS_ATTR_GATEWAY]) - if ip6_data.get(DBUS_ATTR_GATEWAY) - else None, - [ - ip_address(bytes(nameserver)) - for nameserver in ip6_data.get(DBUS_ATTR_NAMESERVERS) - ], - [ - ip_interface(f"{address[ATTR_ADDRESS]}/{address[ATTR_PREFIX]}") - for address in ip6_data.get(DBUS_ATTR_ADDRESS_DATA, []) - ], - ) + # Settings + if not changed or DBUS_ATTR_CONNECTION in changed: + if ( + self._settings + and self._settings.is_connected + and self._settings.object_path == self.properties[DBUS_ATTR_CONNECTION] + ): + await self._settings.reload() + elif self.properties[DBUS_ATTR_CONNECTION] != DBUS_OBJECT_BASE: + self._settings = NetworkSetting(self.properties[DBUS_ATTR_CONNECTION]) + await self._settings.connect(self.dbus.bus) + else: + self._settings = None + + def disconnect(self) -> None: + """Disconnect from D-Bus.""" + if self.ipv4: + self.ipv4.disconnect() + if self.ipv6: + self.ipv6.disconnect() + if self.settings: + self.settings.disconnect() + super().disconnect() diff --git a/supervisor/dbus/network/dns.py b/supervisor/dbus/network/dns.py index c6da4fe09..327587730 100644 --- a/supervisor/dbus/network/dns.py +++ b/supervisor/dbus/network/dns.py @@ -1,6 +1,7 @@ """Network Manager DNS Manager object.""" from ipaddress import ip_address import logging +from typing import Any from dbus_next.aio.message_bus import MessageBus @@ -12,7 +13,6 @@ from ...const import ( ATTR_VPN, ) from ...exceptions import DBusError, DBusInterfaceError -from ...utils.dbus import DBus from ..const import ( DBUS_ATTR_CONFIGURATION, DBUS_ATTR_MODE, @@ -21,24 +21,29 @@ from ..const import ( DBUS_NAME_NM, DBUS_OBJECT_DNS, ) -from ..interface import DBusInterface +from ..interface import DBusInterfaceProxy from ..utils import dbus_connected from .configuration import DNSConfiguration _LOGGER: logging.Logger = logging.getLogger(__name__) -class NetworkManagerDNS(DBusInterface): +class NetworkManagerDNS(DBusInterfaceProxy): """Handle D-Bus interface for NM DnsManager. https://developer.gnome.org/NetworkManager/stable/gdbus-org.freedesktop.NetworkManager.DnsManager.html """ + bus_name: str = DBUS_NAME_NM + object_path: str = DBUS_OBJECT_DNS + properties_interface: str = DBUS_IFACE_DNS + def __init__(self) -> None: """Initialize Properties.""" self._mode: str | None = None self._rc_manager: str | None = None self._configuration: list[DNSConfiguration] = [] + self.properties: dict[str, Any] = {} @property def mode(self) -> str | None: @@ -58,7 +63,7 @@ class NetworkManagerDNS(DBusInterface): async def connect(self, bus: MessageBus) -> None: """Connect to system's D-Bus.""" try: - self.dbus = await DBus.connect(bus, DBUS_NAME_NM, DBUS_OBJECT_DNS) + await super().connect(bus) except DBusError: _LOGGER.warning("Can't connect to DnsManager") except DBusInterfaceError: @@ -67,24 +72,28 @@ class NetworkManagerDNS(DBusInterface): ) @dbus_connected - async def update(self): + async def update(self, changed: dict[str, Any] | None = None) -> None: """Update Properties.""" - data = await self.dbus.get_properties(DBUS_IFACE_DNS) - if not data: + await super().update(changed) + if not self.properties: _LOGGER.warning("Can't get properties for DnsManager") return - self._mode = data.get(DBUS_ATTR_MODE) - self._rc_manager = data.get(DBUS_ATTR_RCMANAGER) + self._mode = self.properties.get(DBUS_ATTR_MODE) + self._rc_manager = self.properties.get(DBUS_ATTR_RCMANAGER) # Parse configuraton - self._configuration = [ - DNSConfiguration( - [ip_address(nameserver) for nameserver in config.get(ATTR_NAMESERVERS)], - config.get(ATTR_DOMAINS), - config.get(ATTR_INTERFACE), - config.get(ATTR_PRIORITY), - config.get(ATTR_VPN), - ) - for config in data.get(DBUS_ATTR_CONFIGURATION, []) - ] + if not changed or DBUS_ATTR_CONFIGURATION in changed: + self._configuration = [ + DNSConfiguration( + [ + ip_address(nameserver) + for nameserver in config.get(ATTR_NAMESERVERS) + ], + config.get(ATTR_DOMAINS), + config.get(ATTR_INTERFACE), + config.get(ATTR_PRIORITY), + config.get(ATTR_VPN), + ) + for config in self.properties.get(DBUS_ATTR_CONFIGURATION, []) + ] diff --git a/supervisor/dbus/network/interface.py b/supervisor/dbus/network/interface.py index 76654ae25..b9943b2fe 100644 --- a/supervisor/dbus/network/interface.py +++ b/supervisor/dbus/network/interface.py @@ -1,5 +1,7 @@ """NetworkInterface object for Network Manager.""" +from typing import Any + from dbus_next.aio.message_bus import MessageBus from ...utils.dbus import DBus @@ -15,6 +17,7 @@ from ..const import ( DeviceType, ) from ..interface import DBusInterfaceProxy, dbus_property +from ..utils import dbus_connected from .connection import NetworkConnection from .setting import NetworkSetting from .wireless import NetworkWireless @@ -26,15 +29,17 @@ class NetworkInterface(DBusInterfaceProxy): https://developer.gnome.org/NetworkManager/stable/gdbus-org.freedesktop.NetworkManager.Device.html """ + bus_name: str = DBUS_NAME_NM + properties_interface: str = DBUS_IFACE_DEVICE + def __init__(self, nm_dbus: DBus, object_path: str) -> None: """Initialize NetworkConnection object.""" - self.object_path = object_path - self.properties = {} + self.object_path: str = object_path + self.properties: dict[str, Any] = {} - self.primary = False + self.primary: bool = False self._connection: NetworkConnection | None = None - self._settings: NetworkSetting | None = None self._wireless: NetworkWireless | None = None self._nm_dbus: DBus = nm_dbus @@ -70,7 +75,7 @@ class NetworkInterface(DBusInterfaceProxy): @property def settings(self) -> NetworkSetting | None: """Return the connection settings used for this interface.""" - return self._settings + return self.connection.settings if self.connection else None @property def wireless(self) -> NetworkWireless | None: @@ -78,27 +83,49 @@ class NetworkInterface(DBusInterfaceProxy): return self._wireless async def connect(self, bus: MessageBus) -> None: - """Get device information.""" - self.dbus = await DBus.connect(bus, DBUS_NAME_NM, self.object_path) - self.properties = await self.dbus.get_properties(DBUS_IFACE_DEVICE) + """Connect to D-Bus.""" + return await super().connect(bus) + + @dbus_connected + async def update(self, changed: dict[str, Any] | None = None) -> None: + """Update properties via D-Bus.""" + await super().update(changed) # Abort if device is not managed if not self.managed: return # If active connection exists - if self.properties[DBUS_ATTR_ACTIVE_CONNECTION] != DBUS_OBJECT_BASE: - self._connection = NetworkConnection( - self.properties[DBUS_ATTR_ACTIVE_CONNECTION] - ) - await self._connection.connect(bus) - - # Attach settings - if self.connection and self.connection.setting_object != DBUS_OBJECT_BASE: - self._settings = NetworkSetting(self.connection.setting_object) - await self._settings.connect(bus) + if not changed or DBUS_ATTR_ACTIVE_CONNECTION in changed: + if ( + self._connection + and self._connection.is_connected + and self._connection.object_path + == self.properties[DBUS_ATTR_ACTIVE_CONNECTION] + ): + await self.connection.update() + elif self.properties[DBUS_ATTR_ACTIVE_CONNECTION] != DBUS_OBJECT_BASE: + self._connection = NetworkConnection( + self.properties[DBUS_ATTR_ACTIVE_CONNECTION] + ) + await self._connection.connect(self.dbus.bus) + else: + self._connection = None # Wireless - if self.type == DeviceType.WIRELESS: - self._wireless = NetworkWireless(self.object_path) - await self._wireless.connect(bus) + if not changed or DBUS_ATTR_DEVICE_TYPE in changed: + if self.type != DeviceType.WIRELESS: + self._wireless = None + elif self.wireless and self.wireless.is_connected: + await self._wireless.update() + else: + self._wireless = NetworkWireless(self.object_path) + await self._wireless.connect(self.dbus.bus) + + def disconnect(self) -> None: + """Disconnect from D-Bus.""" + if self.connection: + self.connection.disconnect() + if self.wireless: + self.wireless.disconnect() + super().disconnect() diff --git a/supervisor/dbus/network/ip_configuration.py b/supervisor/dbus/network/ip_configuration.py new file mode 100644 index 000000000..643c822ba --- /dev/null +++ b/supervisor/dbus/network/ip_configuration.py @@ -0,0 +1,68 @@ +"""IP Configuration object for Network Manager.""" + +from ipaddress import ( + IPv4Address, + IPv4Interface, + IPv6Address, + IPv6Interface, + ip_address, + ip_interface, +) +from typing import Any + +from ...const import ATTR_ADDRESS, ATTR_PREFIX +from ..const import ( + DBUS_ATTR_ADDRESS_DATA, + DBUS_ATTR_GATEWAY, + DBUS_ATTR_NAMESERVER_DATA, + DBUS_ATTR_NAMESERVERS, + DBUS_IFACE_IP4CONFIG, + DBUS_IFACE_IP6CONFIG, + DBUS_NAME_NM, +) +from ..interface import DBusInterfaceProxy, dbus_property + + +class IpConfiguration(DBusInterfaceProxy): + """IP Configuration object for Network Manager.""" + + bus_name: str = DBUS_NAME_NM + + def __init__(self, object_path: str, ip4: bool = True) -> None: + """Initialize properties.""" + self._ip4: bool = ip4 + self.object_path: str = object_path + self.properties_interface: str = ( + DBUS_IFACE_IP4CONFIG if ip4 else DBUS_IFACE_IP6CONFIG + ) + self.properties: dict[str, Any] = {} + + @property + @dbus_property + def gateway(self) -> IPv4Address | IPv6Address: + """Get gateway.""" + return ip_address(self.properties[DBUS_ATTR_GATEWAY]) + + @property + @dbus_property + def nameservers(self) -> list[IPv4Address | IPv6Address]: + """Get nameservers.""" + if self._ip4: + return [ + ip_address(nameserver[ATTR_ADDRESS]) + for nameserver in self.properties[DBUS_ATTR_NAMESERVER_DATA] + ] + + return [ + ip_address(bytes(nameserver)) + for nameserver in self.properties[DBUS_ATTR_NAMESERVERS] + ] + + @property + @dbus_property + def address(self) -> list[IPv4Interface | IPv6Interface]: + """Get address.""" + return [ + ip_interface(f"{address[ATTR_ADDRESS]}/{address[ATTR_PREFIX]}") + for address in self.properties[DBUS_ATTR_ADDRESS_DATA] + ] diff --git a/supervisor/dbus/network/setting/__init__.py b/supervisor/dbus/network/setting/__init__.py index 9aa919314..d49b201bc 100644 --- a/supervisor/dbus/network/setting/__init__.py +++ b/supervisor/dbus/network/setting/__init__.py @@ -5,9 +5,8 @@ from typing import Any from dbus_next.aio.message_bus import MessageBus from ....const import ATTR_METHOD, ATTR_MODE, ATTR_PSK, ATTR_SSID -from ....utils.dbus import DBus from ...const import DBUS_NAME_NM -from ...interface import DBusInterfaceProxy +from ...interface import DBusInterface from ...utils import dbus_connected from ..configuration import ( ConnectionProperties, @@ -67,16 +66,17 @@ def _merge_settings_attribute( base_settings[attribute] = new_settings[attribute] -class NetworkSetting(DBusInterfaceProxy): +class NetworkSetting(DBusInterface): """Network connection setting object for Network Manager. https://developer.gnome.org/NetworkManager/stable/gdbus-org.freedesktop.NetworkManager.Settings.Connection.html """ + bus_name: str = DBUS_NAME_NM + def __init__(self, object_path: str) -> None: """Initialize NetworkConnection object.""" - self.object_path = object_path - self.properties = {} + self.object_path: str = object_path self._connection: ConnectionProperties | None = None self._wireless: WirelessProperties | None = None @@ -162,7 +162,21 @@ class NetworkSetting(DBusInterfaceProxy): async def connect(self, bus: MessageBus) -> None: """Get connection information.""" - self.dbus = await DBus.connect(bus, DBUS_NAME_NM, self.object_path) + await super().connect(bus) + await self.reload() + + # pylint: disable=unnecessary-lambda + # wrapper created by annotation fails the signature test, varargs not supported + self.dbus.Settings.Connection.on_updated(lambda: self.reload()) + + def disconnect(self) -> None: + """Disconnect from D-Bus.""" + self.dbus.Settings.Connection.off_updated(self.reload) + super().disconnect() + + @dbus_connected + async def reload(self): + """Get current settings for connection.""" data = await self.get_settings() # Get configuration settings we care about diff --git a/supervisor/dbus/network/settings.py b/supervisor/dbus/network/settings.py index 300552966..558af3579 100644 --- a/supervisor/dbus/network/settings.py +++ b/supervisor/dbus/network/settings.py @@ -5,7 +5,6 @@ from typing import Any from dbus_next.aio.message_bus import MessageBus from ...exceptions import DBusError, DBusInterfaceError -from ...utils.dbus import DBus from ..const import DBUS_NAME_NM, DBUS_OBJECT_SETTINGS from ..interface import DBusInterface from ..network.setting import NetworkSetting @@ -20,10 +19,13 @@ class NetworkManagerSettings(DBusInterface): https://developer.gnome.org/NetworkManager/stable/gdbus-org.freedesktop.NetworkManager.Settings.html """ + bus_name: str = DBUS_NAME_NM + object_path: str = DBUS_OBJECT_SETTINGS + async def connect(self, bus: MessageBus) -> None: """Connect to system's D-Bus.""" try: - self.dbus = await DBus.connect(bus, DBUS_NAME_NM, DBUS_OBJECT_SETTINGS) + await super().connect(bus) except DBusError: _LOGGER.warning("Can't connect to Network Manager Settings") except DBusInterfaceError: diff --git a/supervisor/dbus/network/wireless.py b/supervisor/dbus/network/wireless.py index ce690c9d6..6a523ccd6 100644 --- a/supervisor/dbus/network/wireless.py +++ b/supervisor/dbus/network/wireless.py @@ -1,10 +1,8 @@ """Wireless object for Network Manager.""" import asyncio import logging +from typing import Any -from dbus_next.aio.message_bus import MessageBus - -from ...utils.dbus import DBus from ..const import ( DBUS_ATTR_ACTIVE_ACCESSPOINT, DBUS_IFACE_DEVICE_WIRELESS, @@ -24,10 +22,13 @@ class NetworkWireless(DBusInterfaceProxy): https://developer.gnome.org/NetworkManager/stable/gdbus-org.freedesktop.NetworkManager.Device.Wireless.html """ + bus_name: str = DBUS_NAME_NM + properties_interface: str = DBUS_IFACE_DEVICE_WIRELESS + def __init__(self, object_path: str) -> None: """Initialize NetworkConnection object.""" - self.object_path = object_path - self.properties = {} + self.object_path: str = object_path + self.properties: dict[str, Any] = {} self._active: NetworkWirelessAP | None = None @@ -55,14 +56,23 @@ class NetworkWireless(DBusInterfaceProxy): return accesspoints - async def connect(self, bus: MessageBus) -> None: - """Get connection information.""" - self.dbus = await DBus.connect(bus, DBUS_NAME_NM, self.object_path) - self.properties = await self.dbus.get_properties(DBUS_IFACE_DEVICE_WIRELESS) + async def update(self, changed: dict[str, Any] | None = None) -> None: + """Update properties via D-Bus.""" + await super().update(changed) # Get details from current active - if self.properties[DBUS_ATTR_ACTIVE_ACCESSPOINT] != DBUS_OBJECT_BASE: - self._active = NetworkWirelessAP( - self.properties[DBUS_ATTR_ACTIVE_ACCESSPOINT] - ) - await self._active.connect(bus) + if not changed or DBUS_ATTR_ACTIVE_ACCESSPOINT in changed: + if ( + self._active + and self._active.is_connected + and self._active.object_path + == self.properties[DBUS_ATTR_ACTIVE_ACCESSPOINT] + ): + await self._active.update() + elif self.properties[DBUS_ATTR_ACTIVE_ACCESSPOINT] != DBUS_OBJECT_BASE: + self._active = NetworkWirelessAP( + self.properties[DBUS_ATTR_ACTIVE_ACCESSPOINT] + ) + await self._active.connect(self.dbus.bus) + else: + self._active = None diff --git a/supervisor/dbus/rauc.py b/supervisor/dbus/rauc.py index afc227781..f38ee3a95 100644 --- a/supervisor/dbus/rauc.py +++ b/supervisor/dbus/rauc.py @@ -5,7 +5,7 @@ from typing import Any from dbus_next.aio.message_bus import MessageBus from ..exceptions import DBusError, DBusInterfaceError -from ..utils.dbus import DBus, DBusSignalWrapper +from ..utils.dbus import DBusSignalWrapper from .const import ( DBUS_ATTR_BOOT_SLOT, DBUS_ATTR_COMPATIBLE, @@ -18,16 +18,19 @@ from .const import ( DBUS_SIGNAL_RAUC_INSTALLER_COMPLETED, RaucState, ) -from .interface import DBusInterface +from .interface import DBusInterfaceProxy from .utils import dbus_connected _LOGGER: logging.Logger = logging.getLogger(__name__) -class Rauc(DBusInterface): +class Rauc(DBusInterfaceProxy): """Handle D-Bus interface for rauc.""" - name = DBUS_NAME_RAUC + name: str = DBUS_NAME_RAUC + bus_name: str = DBUS_NAME_RAUC + object_path: str = DBUS_OBJECT_BASE + properties_interface: str = DBUS_IFACE_RAUC_INSTALLER def __init__(self): """Initialize Properties.""" @@ -37,10 +40,13 @@ class Rauc(DBusInterface): self._variant: str | None = None self._boot_slot: str | None = None + self.properties: dict[str, Any] = {} + async def connect(self, bus: MessageBus): """Connect to D-Bus.""" + _LOGGER.info("Load dbus interface %s", self.name) try: - self.dbus = await DBus.connect(bus, DBUS_NAME_RAUC, DBUS_OBJECT_BASE) + await super().connect(bus) except DBusError: _LOGGER.warning("Can't connect to rauc") except DBusInterfaceError: @@ -92,15 +98,15 @@ class Rauc(DBusInterface): return await self.dbus.Installer.call_mark(state, slot_identifier) @dbus_connected - async def update(self): + async def update(self, changed: dict[str, Any] | None = None) -> None: """Update Properties.""" - data = await self.dbus.get_properties(DBUS_IFACE_RAUC_INSTALLER) - if not data: + await super().update(changed) + if not self.properties: _LOGGER.warning("Can't get properties for rauc") return - self._operation = data.get(DBUS_ATTR_OPERATION) - self._last_error = data.get(DBUS_ATTR_LAST_ERROR) - self._compatible = data.get(DBUS_ATTR_COMPATIBLE) - self._variant = data.get(DBUS_ATTR_VARIANT) - self._boot_slot = data.get(DBUS_ATTR_BOOT_SLOT) + self._operation = self.properties.get(DBUS_ATTR_OPERATION) + self._last_error = self.properties.get(DBUS_ATTR_LAST_ERROR) + self._compatible = self.properties.get(DBUS_ATTR_COMPATIBLE) + self._variant = self.properties.get(DBUS_ATTR_VARIANT) + self._boot_slot = self.properties.get(DBUS_ATTR_BOOT_SLOT) diff --git a/supervisor/dbus/resolved.py b/supervisor/dbus/resolved.py index b3ebc04b1..09f9313aa 100644 --- a/supervisor/dbus/resolved.py +++ b/supervisor/dbus/resolved.py @@ -7,7 +7,6 @@ from typing import Any from dbus_next.aio.message_bus import MessageBus from ..exceptions import DBusError, DBusInterfaceError -from ..utils.dbus import DBus from .const import ( DBUS_ATTR_CACHE_STATISTICS, DBUS_ATTR_CURRENT_DNS_SERVER, @@ -38,19 +37,21 @@ from .const import ( MulticastProtocolEnabled, ResolvConfMode, ) -from .interface import DBusInterface, dbus_property -from .utils import dbus_connected +from .interface import DBusInterfaceProxy, dbus_property _LOGGER: logging.Logger = logging.getLogger(__name__) -class Resolved(DBusInterface): +class Resolved(DBusInterfaceProxy): """Handle D-Bus interface for systemd-resolved. https://www.freedesktop.org/software/systemd/man/org.freedesktop.resolve1.html """ - name = DBUS_NAME_RESOLVED + name: str = DBUS_NAME_RESOLVED + bus_name: str = DBUS_NAME_RESOLVED + object_path: str = DBUS_OBJECT_RESOLVED + properties_interface: str = DBUS_IFACE_RESOLVED_MANAGER def __init__(self): """Initialize Properties.""" @@ -58,10 +59,9 @@ class Resolved(DBusInterface): async def connect(self, bus: MessageBus): """Connect to D-Bus.""" + _LOGGER.info("Load dbus interface %s", self.name) try: - self.dbus = await DBus.connect( - bus, DBUS_NAME_RESOLVED, DBUS_OBJECT_RESOLVED - ) + await super().connect(bus) except DBusError: _LOGGER.warning("Can't connect to systemd-resolved.") except DBusInterfaceError: @@ -188,8 +188,3 @@ class Resolved(DBusInterface): def transaction_statistics(self) -> tuple[int, int] | None: """Return transactions processing and processed since last reset.""" return self.properties[DBUS_ATTR_TRANSACTION_STATISTICS] - - @dbus_connected - async def update(self): - """Update Properties.""" - self.properties = await self.dbus.get_properties(DBUS_IFACE_RESOLVED_MANAGER) diff --git a/supervisor/dbus/systemd.py b/supervisor/dbus/systemd.py index 71baad4d8..5e5416d80 100644 --- a/supervisor/dbus/systemd.py +++ b/supervisor/dbus/systemd.py @@ -5,7 +5,6 @@ from typing import Any from dbus_next.aio.message_bus import MessageBus from ..exceptions import DBusError, DBusInterfaceError -from ..utils.dbus import DBus from .const import ( DBUS_ATTR_FINISH_TIMESTAMP, DBUS_ATTR_FIRMWARE_TIMESTAMP_MONOTONIC, @@ -16,19 +15,24 @@ from .const import ( DBUS_NAME_SYSTEMD, DBUS_OBJECT_SYSTEMD, ) -from .interface import DBusInterface, dbus_property +from .interface import DBusInterfaceProxy, dbus_property from .utils import dbus_connected _LOGGER: logging.Logger = logging.getLogger(__name__) -class Systemd(DBusInterface): +class Systemd(DBusInterfaceProxy): """Systemd function handler. https://www.freedesktop.org/software/systemd/man/org.freedesktop.systemd1.html """ - name = DBUS_NAME_SYSTEMD + name: str = DBUS_NAME_SYSTEMD + bus_name: str = DBUS_NAME_SYSTEMD + object_path: str = DBUS_OBJECT_SYSTEMD + # NFailedUnits is the only property that emits a change signal and we don't use it + sync_properties: bool = False + properties_interface: str = DBUS_IFACE_SYSTEMD_MANAGER def __init__(self) -> None: """Initialize Properties.""" @@ -36,8 +40,9 @@ class Systemd(DBusInterface): async def connect(self, bus: MessageBus): """Connect to D-Bus.""" + _LOGGER.info("Load dbus interface %s", self.name) try: - self.dbus = await DBus.connect(bus, DBUS_NAME_SYSTEMD, DBUS_OBJECT_SYSTEMD) + await super().connect(bus) except DBusError: _LOGGER.warning("Can't connect to systemd") except DBusInterfaceError: @@ -98,8 +103,3 @@ class Systemd(DBusInterface): ) -> list[tuple[str, str, str, str, str, str, str, int, str, str]]: """Return a list of available systemd services.""" return await self.dbus.Manager.call_list_units() - - @dbus_connected - async def update(self): - """Update Properties.""" - self.properties = await self.dbus.get_properties(DBUS_IFACE_SYSTEMD_MANAGER) diff --git a/supervisor/dbus/timedate.py b/supervisor/dbus/timedate.py index abdee1cb9..56c04adc1 100644 --- a/supervisor/dbus/timedate.py +++ b/supervisor/dbus/timedate.py @@ -6,7 +6,6 @@ from typing import Any from dbus_next.aio.message_bus import MessageBus from ..exceptions import DBusError, DBusInterfaceError -from ..utils.dbus import DBus from ..utils.dt import utc_from_timestamp from .const import ( DBUS_ATTR_NTP, @@ -17,19 +16,22 @@ from .const import ( DBUS_NAME_TIMEDATE, DBUS_OBJECT_TIMEDATE, ) -from .interface import DBusInterface, dbus_property +from .interface import DBusInterfaceProxy, dbus_property from .utils import dbus_connected _LOGGER: logging.Logger = logging.getLogger(__name__) -class TimeDate(DBusInterface): +class TimeDate(DBusInterfaceProxy): """Timedate function handler. https://www.freedesktop.org/software/systemd/man/org.freedesktop.timedate1.html """ - name = DBUS_NAME_TIMEDATE + name: str = DBUS_NAME_TIMEDATE + bus_name: str = DBUS_NAME_TIMEDATE + object_path: str = DBUS_OBJECT_TIMEDATE + properties_interface: str = DBUS_IFACE_TIMEDATE def __init__(self) -> None: """Initialize Properties.""" @@ -61,10 +63,9 @@ class TimeDate(DBusInterface): async def connect(self, bus: MessageBus): """Connect to D-Bus.""" + _LOGGER.info("Load dbus interface %s", self.name) try: - self.dbus = await DBus.connect( - bus, DBUS_NAME_TIMEDATE, DBUS_OBJECT_TIMEDATE - ) + await super().connect(bus) except DBusError: _LOGGER.warning("Can't connect to systemd-timedate") except DBusInterfaceError: @@ -81,8 +82,3 @@ class TimeDate(DBusInterface): async def set_ntp(self, use_ntp: bool) -> None: """Turn NTP on or off.""" await self.dbus.call_set_ntp(use_ntp, False) - - @dbus_connected - async def update(self): - """Update Properties.""" - self.properties = await self.dbus.get_properties(DBUS_IFACE_TIMEDATE) diff --git a/supervisor/host/control.py b/supervisor/host/control.py index f1d7fc9ff..9e92916bf 100644 --- a/supervisor/host/control.py +++ b/supervisor/host/control.py @@ -71,7 +71,6 @@ class SystemControl(CoreSysAttributes): _LOGGER.info("Set hostname %s", hostname) await self.sys_dbus.hostname.set_static_hostname(hostname) - await self.sys_dbus.hostname.update() async def set_datetime(self, new_time: datetime) -> None: """Update host clock with new (utc) datetime.""" diff --git a/supervisor/host/manager.py b/supervisor/host/manager.py index fbef39969..2f7422a90 100644 --- a/supervisor/host/manager.py +++ b/supervisor/host/manager.py @@ -98,29 +98,21 @@ class HostManager(CoreSysAttributes): return features - async def reload( - self, - *, - services: bool = True, - network: bool = True, - agent: bool = True, - audio: bool = True, - ): + async def reload(self): """Reload host functions.""" await self.info.update() - if services and self.sys_dbus.systemd.is_connected: + if self.sys_dbus.systemd.is_connected: await self.services.update() - if network and self.sys_dbus.network.is_connected: + if self.sys_dbus.network.is_connected: await self.network.update() - if agent and self.sys_dbus.agent.is_connected: + if self.sys_dbus.agent.is_connected: await self.sys_dbus.agent.update() - if audio: - with suppress(PulseAudioError): - await self.sound.update() + with suppress(PulseAudioError): + await self.sound.update() _LOGGER.info("Host information reload completed") self.supported_features.cache_clear() # pylint: disable=no-member @@ -128,7 +120,12 @@ class HostManager(CoreSysAttributes): async def load(self): """Load host information.""" with suppress(HassioError): - await self.reload(network=False) + if self.sys_dbus.systemd.is_connected: + await self.services.update() + + with suppress(PulseAudioError): + await self.sound.update() + await self.network.load() # Register for events diff --git a/supervisor/host/network.py b/supervisor/host/network.py index 7c143361b..e19261d54 100644 --- a/supervisor/host/network.py +++ b/supervisor/host/network.py @@ -5,12 +5,16 @@ import asyncio from contextlib import suppress from ipaddress import IPv4Address, IPv4Interface, IPv6Address, IPv6Interface import logging +from typing import Any import attr from ..const import ATTR_HOST_INTERNET from ..coresys import CoreSys, CoreSysAttributes from ..dbus.const import ( + DBUS_ATTR_CONNECTION_ENABLED, + DBUS_ATTR_CONNECTIVITY, + DBUS_IFACE_NM, DBUS_SIGNAL_NM_CONNECTION_ACTIVE_CHANGED, ConnectionStateFlags, ConnectionStateType, @@ -32,6 +36,7 @@ from ..exceptions import ( from ..jobs.const import JobCondition from ..jobs.decorator import Job from ..resolution.checks.network_interface_ipv4 import CheckNetworkInterfaceIPV4 +from ..utils.dbus import DBus from .const import AuthMethod, InterfaceMethod, InterfaceType, WifiMode _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -51,10 +56,16 @@ class NetworkManager(CoreSysAttributes): return self._connectivity @connectivity.setter - def connectivity(self, state: bool) -> None: + def connectivity(self, state: bool | None) -> None: """Set host connectivity state.""" if self._connectivity == state: return + + if state is None or self._connectivity is None: + self.sys_create_task( + self.sys_resolution.evaluate.get("connectivity_check")() + ) + self._connectivity = state self.sys_homeassistant.websocket.supervisor_update_event( "network", {ATTR_HOST_INTERNET: state} @@ -107,8 +118,6 @@ class NetworkManager(CoreSysAttributes): @Job(conditions=JobCondition.HOST_NETWORK) async def load(self): """Load network information and reapply defaults over dbus.""" - await self.update() - # Apply current settings on each interface so OS can update any out of date defaults interfaces = [ Interface.from_dbus_interface(interface) @@ -128,6 +137,34 @@ class NetworkManager(CoreSysAttributes): ] ) + self.sys_dbus.network.dbus.properties.on_properties_changed( + self._check_connectivity_changed + ) + + async def _check_connectivity_changed( + self, interface: str, changed: dict[str, Any], invalidated: list[str] + ): + """Check if connectivity property has changed.""" + if interface != DBUS_IFACE_NM: + return + + changed = DBus.remove_dbus_signature(changed) + connectivity_check: bool | None = changed.get(DBUS_ATTR_CONNECTION_ENABLED) + connectivity: bool | None = changed.get(DBUS_ATTR_CONNECTIVITY) + + if ( + connectivity_check is True + or DBUS_ATTR_CONNECTION_ENABLED in invalidated + or DBUS_ATTR_CONNECTIVITY in invalidated + ): + self.sys_create_task(self.check_connectivity()) + + elif connectivity_check is False: + self.connectivity = None + + elif connectivity is not None: + self.connectivity = connectivity == ConnectivityState.CONNECTIVITY_FULL + async def update(self, *, force_connectivity_check: bool = False): """Update properties over dbus.""" _LOGGER.info("Updating local network information") @@ -366,18 +403,22 @@ class Interface: Interface._map_nm_type(inet.type), IpConfig( ipv4_method, - inet.connection.ipv4.address, + inet.connection.ipv4.address if inet.connection.ipv4.address else [], inet.connection.ipv4.gateway, - inet.connection.ipv4.nameservers, + inet.connection.ipv4.nameservers + if inet.connection.ipv4.nameservers + else [], ipv4_ready, ) if inet.connection and inet.connection.ipv4 else IpConfig(ipv4_method, [], None, [], ipv4_ready), IpConfig( ipv6_method, - inet.connection.ipv6.address, + inet.connection.ipv6.address if inet.connection.ipv6.address else [], inet.connection.ipv6.gateway, - inet.connection.ipv6.nameservers, + inet.connection.ipv6.nameservers + if inet.connection.ipv6.nameservers + else [], ipv6_ready, ) if inet.connection and inet.connection.ipv6 diff --git a/supervisor/os/manager.py b/supervisor/os/manager.py index dc3272940..6880f0c98 100644 --- a/supervisor/os/manager.py +++ b/supervisor/os/manager.py @@ -145,7 +145,6 @@ class OSManager(CoreSysAttributes): self._board = cpe.get_target_hardware()[0] self._os_name = cpe.get_product()[0] - await self.sys_dbus.rauc.update() await self.datadisk.load() _LOGGER.info( diff --git a/supervisor/utils/dbus.py b/supervisor/utils/dbus.py index 41836b53e..66178d64e 100644 --- a/supervisor/utils/dbus.py +++ b/supervisor/utils/dbus.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio import logging -from typing import Any, Awaitable, Callable +from typing import Any, Awaitable, Callable, Coroutine from dbus_next import ErrorType, InvalidIntrospectionError, Message, MessageType from dbus_next.aio.message_bus import MessageBus @@ -41,6 +41,7 @@ class DBus: self._proxy_obj: ProxyObject | None = None self._proxies: dict[str, ProxyInterface] = {} self._bus: MessageBus = bus + self._signal_monitors: dict[str, dict[str, list[Callable]]] = {} @staticmethod async def connect(bus: MessageBus, bus_name: str, object_path: str) -> DBus: @@ -58,17 +59,11 @@ class DBus: """Remove signature info.""" if isinstance(data, Variant): return DBus.remove_dbus_signature(data.value) - elif isinstance(data, dict): - for k in data: - data[k] = DBus.remove_dbus_signature(data[k]) - return data - elif isinstance(data, list): - new_list = [] - for item in data: - new_list.append(DBus.remove_dbus_signature(item)) - return new_list - else: - return data + if isinstance(data, dict): + return {k: DBus.remove_dbus_signature(v) for k, v in data.items()} + if isinstance(data, list): + return [DBus.remove_dbus_signature(item) for item in data] + return data @staticmethod def from_dbus_error(err: DBusError) -> HassioNotSupportedError | DBusError: @@ -155,12 +150,55 @@ class DBus: """Get message bus.""" return self._bus + @property + def properties(self) -> ProxyInterface: + """Get properties proxy interface.""" + return DBusCallWrapper(self, DBUS_INTERFACE_PROPERTIES) + async def get_properties(self, interface: str) -> dict[str, Any]: """Read all properties from interface.""" return await DBus.call_dbus( self._proxies[DBUS_INTERFACE_PROPERTIES], "call_get_all", interface ) + def sync_property_changes( + self, + interface: str, + update: Callable[[dict[str, Any]], Coroutine[None]], + ) -> Callable: + """Sync property changes for interface with cache. + + Pass return value to `stop_sync_property_changes` to stop. + """ + + async def sync_property_change( + prop_interface: str, changed: dict[str, Variant], invalidated: list[str] + ): + """Sync property changes to cache.""" + if interface != prop_interface: + return + + if invalidated: + await update() + else: + await update(DBus.remove_dbus_signature(changed)) + + self.properties.on_properties_changed(sync_property_change) + return sync_property_change + + def stop_sync_property_changes(self, sync_property_change: Callable): + """Stop syncing property changes with cache.""" + self.properties.off_properties_changed(sync_property_change) + + def disconnect(self): + """Remove all active signal listeners.""" + for intr, signals in self._signal_monitors.items(): + for name, callbacks in signals.items(): + for callback in callbacks: + getattr(self._proxies[intr], f"off_{name}")(callback) + + self._signal_monitors = {} + def signal(self, signal_member: str) -> DBusSignalWrapper: """Get signal context manager for this object.""" return DBusSignalWrapper(self, signal_member) @@ -179,7 +217,7 @@ class DBusCallWrapper: self.interface: str = interface self._proxy: ProxyInterface | None = self.dbus._proxies.get(self.interface) - def __call__(self) -> None: + def __call__(self, *args, **kwargs) -> None: """Catch this method from being called.""" _LOGGER.error("D-Bus method %s not exists!", self.interface) raise DBusInterfaceMethodError() @@ -189,7 +227,8 @@ class DBusCallWrapper: if not self._proxy: return DBusCallWrapper(self.dbus, f"{self.interface}.{name}") - dbus_type = name.split("_", 1)[0] + dbus_parts = name.split("_", 1) + dbus_type = dbus_parts[0] if not hasattr(self._proxy, name): message = f"{name} does not exist in D-Bus interface {self.interface}!" @@ -202,7 +241,7 @@ class DBusCallWrapper: if dbus_type in ["on", "off"]: raise DBusInterfaceSignalError(message, _LOGGER.error) - # Not much can be done with these currently. *args callbacks aren't supported so can't wrap it + # Can't wrap these since *args callbacks aren't supported. But can track them for automatic disconnect later if dbus_type in ["on", "off"]: _LOGGER.debug( "D-Bus signal monitor - %s.%s on %s", @@ -210,7 +249,52 @@ class DBusCallWrapper: name, self.dbus.object_path, ) - return self._method + dbus_name = dbus_parts[1] + + if dbus_type == "on": + + def _on_signal(callback: Callable): + getattr(self._proxy, name)(callback) + + # pylint: disable=protected-access + if self.interface not in self.dbus._signal_monitors: + self.dbus._signal_monitors[self.interface] = {} + + if dbus_name not in self.dbus._signal_monitors[self.interface]: + self.dbus._signal_monitors[self.interface][dbus_name] = [ + callback + ] + else: + self.dbus._signal_monitors[self.interface][dbus_name].append( + callback + ) + + return _on_signal + + def _off_signal(callback: Callable): + getattr(self._proxy, name)(callback) + + # pylint: disable=protected-access + if ( + self.interface not in self.dbus._signal_monitors + or dbus_name not in self.dbus._signal_monitors[self.interface] + or callback + not in self.dbus._signal_monitors[self.interface][dbus_name] + ): + _LOGGER.warning( + "Signal listener not found for %s.%s", self.interface, dbus_name + ) + else: + self.dbus._signal_monitors[self.interface][dbus_name].remove( + callback + ) + + if not self.dbus._signal_monitors[self.interface][dbus_name]: + del self.dbus._signal_monitors[self.interface][dbus_name] + if not self.dbus._signal_monitors[self.interface]: + del self.dbus._signal_monitors[self.interface] + + return _off_signal if dbus_type in ["call", "get", "set"]: diff --git a/tests/api/test_dns.py b/tests/api/test_dns.py index 7d93e2709..9f1c45325 100644 --- a/tests/api/test_dns.py +++ b/tests/api/test_dns.py @@ -5,7 +5,7 @@ from supervisor.coresys import CoreSys from supervisor.dbus.const import MulticastProtocolEnabled -async def test_llmnr_mdns_info(api_client, coresys: CoreSys): +async def test_llmnr_mdns_info(api_client, coresys: CoreSys, dbus_is_connected): """Test llmnr and mdns in info api.""" coresys.host.sys_dbus.resolved.is_connected = False diff --git a/tests/api/test_host.py b/tests/api/test_host.py index daf809e72..92718d637 100644 --- a/tests/api/test_host.py +++ b/tests/api/test_host.py @@ -32,7 +32,9 @@ async def test_api_host_info(api_client, coresys_disk_info: CoreSys): assert result["data"]["apparmor_version"] == "2.13.2" -async def test_api_host_features(api_client, coresys_disk_info: CoreSys): +async def test_api_host_features( + api_client, coresys_disk_info: CoreSys, dbus_is_connected +): """Test host info features.""" coresys = coresys_disk_info @@ -93,7 +95,9 @@ async def test_api_host_features(api_client, coresys_disk_info: CoreSys): assert "resolved" in result["data"]["features"] -async def test_api_llmnr_mdns_info(api_client, coresys_disk_info: CoreSys): +async def test_api_llmnr_mdns_info( + api_client, coresys_disk_info: CoreSys, dbus_is_connected +): """Test llmnr and mdns details in info.""" coresys = coresys_disk_info diff --git a/tests/common.py b/tests/common.py index 4d7d86684..9ef8f70fd 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1,8 +1,69 @@ """Common test functions.""" +import asyncio import json from pathlib import Path from typing import Any +from dbus_next.introspection import Method, Property, Signal + +from supervisor.dbus.interface import DBusInterface, DBusInterfaceProxy +from supervisor.utils.dbus import DBUS_INTERFACE_PROPERTIES + + +def get_dbus_name(intr_list: list[Method | Property | Signal], snake_case: str) -> str: + """Find name in introspection list, fallback to ignore case match.""" + name = "".join([part.capitalize() for part in snake_case.split("_")]) + names = [item.name for item in intr_list] + if name in names: + return name + + # Acronyms like NTP can't be easily converted back to camel case. Fallback to ignore case match + lower_name = name.lower() + for val in names: + if lower_name == val.lower(): + return val + + raise AttributeError(f"Could not find match for {name} in D-Bus introspection!") + + +def fire_watched_signal(dbus: DBusInterface, signal: str, data: list[Any] | str): + """Test firing a watched signal.""" + if isinstance(data, str) and exists_fixture(data): + data = load_json_fixture(data) + + if not isinstance(data, list): + raise ValueError("Data must be a list!") + + signal_parts = signal.split(".") + interface = ".".join(signal_parts[:-1]) + name = signal_parts[-1] + + # pylint: disable=protected-access + assert interface in dbus.dbus._signal_monitors + + signals = dbus.dbus._proxies[interface].introspection.signals + signal_monitors = { + get_dbus_name(signals, k): v + for k, v in dbus.dbus._signal_monitors[interface].items() + } + assert name in signal_monitors + + for coro in [callback(*data) for callback in signal_monitors[name]]: + asyncio.create_task(coro) + + +def fire_property_change_signal( + dbus: DBusInterfaceProxy, + changed: dict[str, Any] | None = None, + invalidated: list[str] | None = None, +): + """Fire a property change signal for an interface proxy.""" + fire_watched_signal( + dbus, + f"{DBUS_INTERFACE_PROPERTIES}.PropertiesChanged", + [dbus.properties_interface, changed or {}, invalidated or []], + ) + def load_json_fixture(filename: str) -> Any: """Load a json fixture.""" diff --git a/tests/conftest.py b/tests/conftest.py index a08b250c2..e533b73d1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,6 @@ from aiohttp import web from awesomeversion import AwesomeVersion from dbus_next.aio.message_bus import MessageBus from dbus_next.aio.proxy_object import ProxyInterface, ProxyObject -from dbus_next.introspection import Method, Property, Signal import pytest from securetar import SecureTarFile @@ -56,7 +55,7 @@ from supervisor.store.repository import Repository from supervisor.utils.dbus import DBUS_INTERFACE_PROPERTIES, DBus from supervisor.utils.dt import utcnow -from .common import exists_fixture, load_fixture, load_json_fixture +from .common import exists_fixture, get_dbus_name, load_fixture, load_json_fixture from .const import TEST_ADDON_SLUG # pylint: disable=redefined-outer-name, protected-access @@ -103,22 +102,6 @@ def docker() -> DockerAPI: yield docker_obj -def _get_dbus_name(intr_list: list[Method | Property | Signal], snake_case: str) -> str: - """Find name in introspection list, fallback to ignore case match.""" - name = "".join([part.capitalize() for part in snake_case.split("_")]) - names = [item.name for item in intr_list] - if name in names: - return name - - # Acronyms like NTP can't be easily converted back to camel case. Fallback to ignore case match - lower_name = name.lower() - for val in names: - if lower_name == val.lower(): - return val - - raise AttributeError(f"Could not find match for {name} in D-Bus introspection!") - - @pytest.fixture async def dbus_bus() -> MessageBus: """Message bus mock.""" @@ -199,7 +182,7 @@ def dbus(dbus_bus: MessageBus) -> DBus: [dbus_type, dbus_name] = method.split("_", 1) if dbus_type in ["get", "set"]: - dbus_name = _get_dbus_name( + dbus_name = get_dbus_name( proxy_interface.introspection.properties, dbus_name ) dbus_commands.append( @@ -213,7 +196,7 @@ def dbus(dbus_bus: MessageBus) -> DBus: proxy_interface.path, proxy_interface.introspection.name )[dbus_name] - dbus_name = _get_dbus_name(proxy_interface.introspection.methods, dbus_name) + dbus_name = get_dbus_name(proxy_interface.introspection.methods, dbus_name) dbus_commands.append( f"{proxy_interface.path}-{proxy_interface.introspection.name}.{dbus_name}" ) @@ -230,9 +213,8 @@ def dbus(dbus_bus: MessageBus) -> DBus: return load_json_fixture(f"{fixture}.json") with patch("supervisor.utils.dbus.DBus.call_dbus", new=mock_call_dbus), patch( - "supervisor.dbus.interface.DBusInterface.is_connected", - return_value=True, - ), patch("supervisor.utils.dbus.DBus._init_proxy", new=mock_init_proxy), patch( + "supervisor.utils.dbus.DBus._init_proxy", new=mock_init_proxy + ), patch( "supervisor.utils.dbus.DBusSignalWrapper.__aenter__", new=mock_signal___aenter__ ), patch( "supervisor.utils.dbus.DBusSignalWrapper.__aexit__", new=mock_signal___aexit__ @@ -245,6 +227,16 @@ def dbus(dbus_bus: MessageBus) -> DBus: yield dbus_commands +@pytest.fixture +async def dbus_is_connected(): + """Mock DBusInterface.is_connected for tests.""" + with patch( + "supervisor.dbus.interface.DBusInterface.is_connected", + return_value=True, + ) as is_connected: + yield is_connected + + @pytest.fixture async def network_manager(dbus, dbus_bus: MessageBus) -> NetworkManager: """Mock NetworkManager.""" @@ -344,6 +336,9 @@ async def coresys( su_config.ADDONS_GIT = Path( Path(__file__).parent.joinpath("fixtures"), "addons/git" ) + su_config.APPARMOR_DATA = Path( + Path(__file__).parent.joinpath("fixtures"), "apparmor" + ) # WebSocket coresys_obj.homeassistant.api.check_api_state = mock_async_return_true diff --git a/tests/dbus/agent/test_agent.py b/tests/dbus/agent/test_agent.py index f9cfd38c8..4b892aab6 100644 --- a/tests/dbus/agent/test_agent.py +++ b/tests/dbus/agent/test_agent.py @@ -1,7 +1,11 @@ """Test OSAgent dbus interface.""" +import asyncio + from supervisor.coresys import CoreSys +from tests.common import fire_property_change_signal + async def test_dbus_osagent(coresys: CoreSys): """Test coresys dbus connection.""" @@ -13,3 +17,11 @@ async def test_dbus_osagent(coresys: CoreSys): assert coresys.dbus.agent.version == "1.1.0" assert coresys.dbus.agent.diagnostics + + fire_property_change_signal(coresys.dbus.agent, {"Diagnostics": False}) + await asyncio.sleep(0) + assert coresys.dbus.agent.diagnostics is False + + fire_property_change_signal(coresys.dbus.agent, {}, ["Diagnostics"]) + await asyncio.sleep(0) + assert coresys.dbus.agent.diagnostics is True diff --git a/tests/dbus/agent/test_apparmor.py b/tests/dbus/agent/test_apparmor.py index 1d291e431..fb2f52bd1 100644 --- a/tests/dbus/agent/test_apparmor.py +++ b/tests/dbus/agent/test_apparmor.py @@ -1,4 +1,5 @@ """Test AppArmor/Agent dbus interface.""" +import asyncio from pathlib import Path import pytest @@ -6,6 +7,8 @@ import pytest from supervisor.coresys import CoreSys from supervisor.exceptions import DBusNotConnectedError +from tests.common import fire_property_change_signal + async def test_dbus_osagent_apparmor(coresys: CoreSys): """Test coresys dbus connection.""" @@ -16,6 +19,14 @@ async def test_dbus_osagent_apparmor(coresys: CoreSys): assert coresys.dbus.agent.apparmor.version == "2.13.2" + fire_property_change_signal(coresys.dbus.agent.apparmor, {"ParserVersion": "1.0.0"}) + await asyncio.sleep(0) + assert coresys.dbus.agent.apparmor.version == "1.0.0" + + fire_property_change_signal(coresys.dbus.agent, {}, ["ParserVersion"]) + await asyncio.sleep(0) + assert coresys.dbus.agent.apparmor.version == "2.13.2" + async def test_dbus_osagent_apparmor_load(coresys: CoreSys, dbus: list[str]): """Load AppArmor Profile on host.""" diff --git a/tests/dbus/agent/test_datadisk.py b/tests/dbus/agent/test_datadisk.py index c60de7136..564a2fa15 100644 --- a/tests/dbus/agent/test_datadisk.py +++ b/tests/dbus/agent/test_datadisk.py @@ -1,4 +1,5 @@ """Test Datadisk/Agent dbus interface.""" +import asyncio from pathlib import Path import pytest @@ -6,6 +7,8 @@ import pytest from supervisor.coresys import CoreSys from supervisor.exceptions import DBusNotConnectedError +from tests.common import fire_property_change_signal + async def test_dbus_osagent_datadisk(coresys: CoreSys): """Test coresys dbus connection.""" @@ -16,6 +19,16 @@ async def test_dbus_osagent_datadisk(coresys: CoreSys): assert coresys.dbus.agent.datadisk.current_device.as_posix() == "/dev/sda" + fire_property_change_signal( + coresys.dbus.agent.datadisk, {"CurrentDevice": "/dev/sda1"} + ) + await asyncio.sleep(0) + assert coresys.dbus.agent.datadisk.current_device.as_posix() == "/dev/sda1" + + fire_property_change_signal(coresys.dbus.agent.datadisk, {}, ["CurrentDevice"]) + await asyncio.sleep(0) + assert coresys.dbus.agent.datadisk.current_device.as_posix() == "/dev/sda" + async def test_dbus_osagent_datadisk_change_device(coresys: CoreSys, dbus: list[str]): """Change datadisk on device.""" diff --git a/tests/dbus/network/setting/test_init.py b/tests/dbus/network/setting/test_init.py index cafab86b9..434240251 100644 --- a/tests/dbus/network/setting/test_init.py +++ b/tests/dbus/network/setting/test_init.py @@ -1,4 +1,5 @@ """Test Network Manager Connection object.""" +import asyncio from typing import Any from unittest.mock import patch @@ -11,6 +12,7 @@ from supervisor.host.const import InterfaceMethod from supervisor.host.network import Interface from supervisor.utils.dbus import DBus +from tests.common import fire_watched_signal from tests.const import TEST_INTERFACE SETTINGS_WITH_SIGNATURE = { @@ -165,3 +167,19 @@ async def test_ipv6_disabled_is_link_local(coresys: CoreSys): assert conn["ipv4"]["method"] == Variant("s", "disabled") assert conn["ipv6"]["method"] == Variant("s", "link-local") + + +async def test_watching_updated_signal(coresys: CoreSys, dbus: list[str]): + """Test get settings called on update signal.""" + await coresys.dbus.network.interfaces[TEST_INTERFACE].connect(coresys.dbus.bus) + dbus.clear() + + fire_watched_signal( + coresys.dbus.network.interfaces[TEST_INTERFACE].settings, + "org.freedesktop.NetworkManager.Settings.Connection.Updated", + [], + ) + await asyncio.sleep(0) + assert dbus == [ + "/org/freedesktop/NetworkManager/Settings/1-org.freedesktop.NetworkManager.Settings.Connection.GetSettings" + ] diff --git a/tests/dbus/network/test_dns.py b/tests/dbus/network/test_dns.py index 6bad5492c..40bcb8fad 100644 --- a/tests/dbus/network/test_dns.py +++ b/tests/dbus/network/test_dns.py @@ -1,9 +1,12 @@ """Test DNS Manager object.""" +import asyncio from ipaddress import IPv4Address from supervisor.dbus.network import NetworkManager from supervisor.dbus.network.configuration import DNSConfiguration +from tests.common import fire_property_change_signal + async def test_dns(network_manager: NetworkManager): """Test dns manager.""" @@ -14,3 +17,11 @@ async def test_dns(network_manager: NetworkManager): [IPv4Address("192.168.30.1")], ["syshack.ch"], "eth0", 100, False ) ] + + fire_property_change_signal(network_manager.dns, {"Mode": "test"}) + await asyncio.sleep(0) + assert network_manager.dns.mode == "test" + + fire_property_change_signal(network_manager.dns, {}, ["Mode"]) + await asyncio.sleep(0) + assert network_manager.dns.mode == "default" diff --git a/tests/dbus/network/test_interface.py b/tests/dbus/network/test_interface.py index 34ac835b0..7a8654645 100644 --- a/tests/dbus/network/test_interface.py +++ b/tests/dbus/network/test_interface.py @@ -1,4 +1,5 @@ """Test NetwrokInterface.""" +import asyncio from ipaddress import IPv4Address, IPv4Interface, IPv6Address, IPv6Interface import pytest @@ -6,6 +7,7 @@ import pytest from supervisor.dbus.const import DeviceType, InterfaceMethod from supervisor.dbus.network import NetworkManager +from tests.common import fire_property_change_signal from tests.const import TEST_INTERFACE, TEST_INTERFACE_WLAN @@ -40,6 +42,14 @@ async def test_network_interface_ethernet(network_manager: NetworkManager): assert interface.settings.ipv6.method == InterfaceMethod.AUTO assert interface.settings.connection.id == "Wired connection 1" + fire_property_change_signal(interface.connection, {"State": 4}) + await asyncio.sleep(0) + assert interface.connection.state == 4 + + fire_property_change_signal(interface.connection, {}, ["State"]) + await asyncio.sleep(0) + assert interface.connection.state == 2 + @pytest.mark.asyncio async def test_network_interface_wlan(network_manager: NetworkManager): diff --git a/tests/dbus/network/test_ip_configuration.py b/tests/dbus/network/test_ip_configuration.py new file mode 100644 index 000000000..2051ccbe4 --- /dev/null +++ b/tests/dbus/network/test_ip_configuration.py @@ -0,0 +1,42 @@ +"""Test Network Manager IP configuration object.""" + +import asyncio +from ipaddress import IPv4Address, IPv6Address + +from supervisor.dbus.network import NetworkManager + +from tests.common import fire_property_change_signal +from tests.const import TEST_INTERFACE + + +async def test_ipv4_configuration(network_manager: NetworkManager): + """Test ipv4 configuration object.""" + ipv4 = network_manager.interfaces[TEST_INTERFACE].connection.ipv4 + assert ipv4.gateway == IPv4Address("192.168.2.1") + assert ipv4.nameservers == [IPv4Address("192.168.2.2")] + + fire_property_change_signal(ipv4, {"Gateway": "192.168.100.1"}) + await asyncio.sleep(0) + assert ipv4.gateway == IPv4Address("192.168.100.1") + + fire_property_change_signal(ipv4, {}, ["Gateway"]) + await asyncio.sleep(0) + assert ipv4.gateway == IPv4Address("192.168.2.1") + + +async def test_ipv6_configuration(network_manager: NetworkManager): + """Test ipv4 configuration object.""" + ipv6 = network_manager.interfaces[TEST_INTERFACE].connection.ipv6 + assert ipv6.gateway == IPv6Address("fe80::da58:d7ff:fe00:9c69") + assert ipv6.nameservers == [ + IPv6Address("2001:1620:2777:1::10"), + IPv6Address("2001:1620:2777:2::20"), + ] + + fire_property_change_signal(ipv6, {"Gateway": "2001:1620:2777:1::10"}) + await asyncio.sleep(0) + assert ipv6.gateway == IPv6Address("2001:1620:2777:1::10") + + fire_property_change_signal(ipv6, {}, ["Gateway"]) + await asyncio.sleep(0) + assert ipv6.gateway == IPv6Address("fe80::da58:d7ff:fe00:9c69") diff --git a/tests/dbus/network/test_network_manager.py b/tests/dbus/network/test_network_manager.py index 674c807f8..fbe6b8e41 100644 --- a/tests/dbus/network/test_network_manager.py +++ b/tests/dbus/network/test_network_manager.py @@ -1,4 +1,5 @@ """Test NetworkInterface.""" +import asyncio from unittest.mock import AsyncMock import pytest @@ -7,8 +8,10 @@ from supervisor.dbus.const import ConnectionStateType from supervisor.dbus.network import NetworkManager from supervisor.exceptions import HostNotSupportedError +from .setting.test_init import SETTINGS_WITH_SIGNATURE + +from tests.common import fire_property_change_signal from tests.const import TEST_INTERFACE -from tests.dbus.network.setting.test_init import SETTINGS_WITH_SIGNATURE # pylint: disable=protected-access @@ -17,6 +20,15 @@ from tests.dbus.network.setting.test_init import SETTINGS_WITH_SIGNATURE async def test_network_manager(network_manager: NetworkManager): """Test network manager update.""" assert TEST_INTERFACE in network_manager.interfaces + assert network_manager.connectivity_enabled is True + + fire_property_change_signal(network_manager, {"ConnectivityCheckEnabled": False}) + await asyncio.sleep(0) + assert network_manager.connectivity_enabled is False + + fire_property_change_signal(network_manager, {"ConnectivityCheckEnabled": True}) + await asyncio.sleep(0) + assert network_manager.connectivity_enabled is True @pytest.mark.asyncio @@ -54,9 +66,12 @@ async def test_activate_connection(network_manager: NetworkManager, dbus: list[s "/org/freedesktop/NetworkManager/Devices/1", ) assert connection.state == ConnectionStateType.ACTIVATED - assert connection.setting_object == "/org/freedesktop/NetworkManager/Settings/1" + assert ( + connection.settings.object_path == "/org/freedesktop/NetworkManager/Settings/1" + ) assert dbus == [ - "/org/freedesktop/NetworkManager-org.freedesktop.NetworkManager.ActivateConnection" + "/org/freedesktop/NetworkManager-org.freedesktop.NetworkManager.ActivateConnection", + "/org/freedesktop/NetworkManager/Settings/1-org.freedesktop.NetworkManager.Settings.Connection.GetSettings", ] @@ -71,7 +86,9 @@ async def test_add_and_activate_connection( assert settings.connection.uuid == "0c23631e-2118-355c-bbb0-8943229cb0d6" assert settings.ipv4.method == "auto" assert connection.state == ConnectionStateType.ACTIVATED - assert connection.setting_object == "/org/freedesktop/NetworkManager/Settings/1" + assert ( + connection.settings.object_path == "/org/freedesktop/NetworkManager/Settings/1" + ) assert dbus == [ "/org/freedesktop/NetworkManager-org.freedesktop.NetworkManager.AddAndActivateConnection", "/org/freedesktop/NetworkManager/Settings/1-org.freedesktop.NetworkManager.Settings.Connection.GetSettings", diff --git a/tests/dbus/network/test_setting.py b/tests/dbus/network/test_setting.py index 1ba3a9e79..e979ece09 100644 --- a/tests/dbus/network/test_setting.py +++ b/tests/dbus/network/test_setting.py @@ -1,4 +1,6 @@ """Test NetwrokInterface.""" +import asyncio + import pytest from supervisor.dbus.const import DeviceType @@ -6,6 +8,7 @@ from supervisor.dbus.network import NetworkManager from supervisor.dbus.network.setting.generate import get_connection_from_interface from supervisor.host.network import Interface +from tests.common import fire_property_change_signal from tests.const import TEST_INTERFACE, TEST_INTERFACE_WLAN @@ -28,9 +31,29 @@ async def test_get_connection_from_interface(network_manager: NetworkManager): assert "address-data" not in connection_payload["ipv6"] +async def test_network_interface(network_manager: NetworkManager): + """Test network interface.""" + interface = network_manager.interfaces[TEST_INTERFACE] + assert interface.name == TEST_INTERFACE + assert interface.type == DeviceType.ETHERNET + assert interface.managed is True + + fire_property_change_signal( + network_manager.interfaces[TEST_INTERFACE], {"Managed": False} + ) + await asyncio.sleep(0) + assert network_manager.interfaces[TEST_INTERFACE].managed is False + + fire_property_change_signal( + network_manager.interfaces[TEST_INTERFACE], {}, ["Managed"] + ) + await asyncio.sleep(0) + assert network_manager.interfaces[TEST_INTERFACE].managed is True + + @pytest.mark.asyncio async def test_network_interface_wlan(network_manager: NetworkManager): - """Test network interface.""" + """Test wireless network interface.""" interface = network_manager.interfaces[TEST_INTERFACE_WLAN] assert interface.name == TEST_INTERFACE_WLAN assert interface.type == DeviceType.WIRELESS diff --git a/tests/dbus/network/test_wireless.py b/tests/dbus/network/test_wireless.py index c9418673d..e525c8f8f 100644 --- a/tests/dbus/network/test_wireless.py +++ b/tests/dbus/network/test_wireless.py @@ -1,6 +1,30 @@ """Test Network Manager Wireless object.""" +import asyncio + from supervisor.dbus.network import NetworkManager +from tests.common import fire_property_change_signal + + +async def test_wireless(network_manager: NetworkManager): + """Test wireless properties.""" + assert network_manager.interfaces["wlan0"].wireless.active is None + + fire_property_change_signal( + network_manager.interfaces["wlan0"].wireless, + {"ActiveAccessPoint": "/org/freedesktop/NetworkManager/AccessPoint/43099"}, + ) + await asyncio.sleep(0) + assert ( + network_manager.interfaces["wlan0"].wireless.active.mac == "E4:57:40:A9:D7:DE" + ) + + fire_property_change_signal( + network_manager.interfaces["wlan0"].wireless, {}, ["ActiveAccessPoint"] + ) + await asyncio.sleep(0) + assert network_manager.interfaces["wlan0"].wireless.active is None + async def test_request_scan(network_manager: NetworkManager, dbus: list[str]): """Test request scan.""" diff --git a/tests/dbus/test_hostname.py b/tests/dbus/test_hostname.py index b9ed783d5..4b030f48f 100644 --- a/tests/dbus/test_hostname.py +++ b/tests/dbus/test_hostname.py @@ -1,10 +1,14 @@ """Test hostname dbus interface.""" +import asyncio + import pytest from supervisor.coresys import CoreSys from supervisor.exceptions import DBusNotConnectedError +from tests.common import fire_property_change_signal + async def test_dbus_hostname_info(coresys: CoreSys): """Test coresys dbus connection.""" @@ -21,6 +25,14 @@ async def test_dbus_hostname_info(coresys: CoreSys): ) assert coresys.dbus.hostname.operating_system == "Home Assistant OS 6.0.dev20210504" + fire_property_change_signal(coresys.dbus.hostname, {"StaticHostname": "test"}) + await asyncio.sleep(0) + assert coresys.dbus.hostname.hostname == "test" + + fire_property_change_signal(coresys.dbus.hostname, {}, ["StaticHostname"]) + await asyncio.sleep(0) + assert coresys.dbus.hostname.hostname == "homeassistant-n2" + async def test_dbus_sethostname(coresys: CoreSys, dbus: list[str]): """Set hostname on backend.""" diff --git a/tests/dbus/test_interface.py b/tests/dbus/test_interface.py new file mode 100644 index 000000000..4a87de457 --- /dev/null +++ b/tests/dbus/test_interface.py @@ -0,0 +1,92 @@ +"""Test dbus interface.""" + +import asyncio +from dataclasses import dataclass +from unittest.mock import MagicMock + +from dbus_next.aio.message_bus import MessageBus +import pytest + +from supervisor.dbus.interface import DBusInterface, DBusInterfaceProxy + +from tests.common import fire_property_change_signal, fire_watched_signal + + +@dataclass +class DBusInterfaceMock: + """DBus Interface and signalling mocks.""" + + obj: DBusInterface + on_device_added: MagicMock = MagicMock() + off_device_added: MagicMock = MagicMock() + + +@pytest.fixture(name="proxy") +async def fixture_proxy( + request: pytest.FixtureRequest, dbus_bus: MessageBus, dbus +) -> DBusInterfaceMock: + """Get a proxy.""" + proxy = DBusInterfaceProxy() + proxy.bus_name = "org.freedesktop.NetworkManager" + proxy.object_path = "/org/freedesktop/NetworkManager" + proxy.properties_interface = "org.freedesktop.NetworkManager" + proxy.sync_properties = request.param + + await proxy.connect(dbus_bus) + + # pylint: disable=protected-access + nm_proxy = proxy.dbus._proxies["org.freedesktop.NetworkManager"] + + mock = DBusInterfaceMock(proxy) + setattr(nm_proxy, "on_device_added", mock.on_device_added) + setattr(nm_proxy, "off_device_added", mock.off_device_added) + + yield mock + + +@pytest.mark.parametrize("proxy", [True], indirect=True) +async def test_dbus_proxy_connect(dbus_bus: MessageBus, proxy: DBusInterfaceMock): + """Test dbus proxy connect.""" + assert proxy.obj.is_connected + assert proxy.obj.properties["Connectivity"] == 4 + + fire_property_change_signal(proxy.obj, {"Connectivity": 1}) + await asyncio.sleep(0) + assert proxy.obj.properties["Connectivity"] == 1 + + +@pytest.mark.parametrize("proxy", [False], indirect=True) +async def test_dbus_proxy_connect_no_sync( + dbus_bus: MessageBus, proxy: DBusInterfaceMock +): + """Test dbus proxy connect with no properties sync.""" + assert proxy.obj.is_connected + assert proxy.obj.properties["Connectivity"] == 4 + + with pytest.raises(AssertionError): + fire_property_change_signal(proxy.obj, {"Connectivity": 1}) + + +@pytest.mark.parametrize("proxy", [False], indirect=True) +async def test_signal_listener_disconnect( + dbus_bus: MessageBus, proxy: DBusInterfaceMock +): + """Test disconnect/delete unattaches signal listeners.""" + assert proxy.obj.is_connected + device = None + + async def callback(dev: str): + nonlocal device + device = dev + + proxy.obj.dbus.on_device_added(callback) + proxy.on_device_added.assert_called_once_with(callback) + + fire_watched_signal( + proxy.obj, "org.freedesktop.NetworkManager.DeviceAdded", ["/test/obj/1"] + ) + await asyncio.sleep(0) + assert device == "/test/obj/1" + + proxy.obj.disconnect() + proxy.off_device_added.assert_called_once_with(callback) diff --git a/tests/dbus/test_rauc.py b/tests/dbus/test_rauc.py index 625737ed4..6b6e3c27a 100644 --- a/tests/dbus/test_rauc.py +++ b/tests/dbus/test_rauc.py @@ -1,21 +1,35 @@ """Test rauc dbus interface.""" +import asyncio + import pytest from supervisor.coresys import CoreSys from supervisor.dbus.const import RaucState from supervisor.exceptions import DBusNotConnectedError +from tests.common import fire_property_change_signal + async def test_rauc(coresys: CoreSys): """Test rauc properties.""" assert coresys.dbus.rauc.boot_slot is None assert coresys.dbus.rauc.operation is None + assert coresys.dbus.rauc.last_error is None await coresys.dbus.rauc.connect(coresys.dbus.bus) await coresys.dbus.rauc.update() assert coresys.dbus.rauc.boot_slot == "B" assert coresys.dbus.rauc.operation == "idle" + assert coresys.dbus.rauc.last_error == "" + + fire_property_change_signal(coresys.dbus.rauc, {"LastError": "Error!"}) + await asyncio.sleep(0) + assert coresys.dbus.rauc.last_error == "Error!" + + fire_property_change_signal(coresys.dbus.rauc, {}, ["LastError"]) + await asyncio.sleep(0) + assert coresys.dbus.rauc.last_error == "" async def test_install(coresys: CoreSys, dbus: list[str]): diff --git a/tests/dbus/test_resolved.py b/tests/dbus/test_resolved.py index 166f496f5..6461504f5 100644 --- a/tests/dbus/test_resolved.py +++ b/tests/dbus/test_resolved.py @@ -1,5 +1,6 @@ """Test systemd-resolved dbus interface.""" +import asyncio from socket import AF_INET6, inet_aton, inet_pton from unittest.mock import patch @@ -14,6 +15,8 @@ from supervisor.dbus.const import ( ResolvConfMode, ) +from tests.common import fire_property_change_signal + DNS_IP_FIELDS = [ "DNS", "DNSEx", @@ -113,3 +116,11 @@ async def test_dbus_resolved_info(coresys_ip_bytes: CoreSys): ] assert coresys.dbus.resolved.dns_stub_listener == DNSStubListenerEnabled.NO assert coresys.dbus.resolved.resolv_conf_mode == ResolvConfMode.FOREIGN + + fire_property_change_signal(coresys.dbus.resolved, {"LLMNRHostname": "test"}) + await asyncio.sleep(0) + assert coresys.dbus.resolved.llmnr_hostname == "test" + + fire_property_change_signal(coresys.dbus.resolved, {}, ["LLMNRHostname"]) + await asyncio.sleep(0) + assert coresys.dbus.resolved.llmnr_hostname == "homeassistant" diff --git a/tests/dbus/test_timedate.py b/tests/dbus/test_timedate.py index c38037aaf..641062890 100644 --- a/tests/dbus/test_timedate.py +++ b/tests/dbus/test_timedate.py @@ -1,4 +1,5 @@ """Test TimeDate dbus interface.""" +import asyncio from datetime import datetime, timezone import pytest @@ -6,10 +7,13 @@ import pytest from supervisor.coresys import CoreSys from supervisor.exceptions import DBusNotConnectedError +from tests.common import fire_property_change_signal + async def test_dbus_timezone(coresys: CoreSys): """Test coresys dbus connection.""" assert coresys.dbus.timedate.dt_utc is None + assert coresys.dbus.timedate.ntp is None await coresys.dbus.timedate.connect(coresys.dbus.bus) await coresys.dbus.timedate.update() @@ -17,11 +21,20 @@ async def test_dbus_timezone(coresys: CoreSys): assert coresys.dbus.timedate.dt_utc == datetime( 2021, 5, 19, 8, 36, 54, 405718, tzinfo=timezone.utc ) + assert coresys.dbus.timedate.ntp is True assert ( coresys.dbus.timedate.dt_utc.isoformat() == "2021-05-19T08:36:54.405718+00:00" ) + fire_property_change_signal(coresys.dbus.timedate, {"NTP": False}) + await asyncio.sleep(0) + assert coresys.dbus.timedate.ntp is False + + fire_property_change_signal(coresys.dbus.timedate, {}, ["NTP"]) + await asyncio.sleep(0) + assert coresys.dbus.timedate.ntp is True + async def test_dbus_settime(coresys: CoreSys, dbus: list[str]): """Set timestamp on backend.""" diff --git a/tests/fixtures/apparmor/.empty/.empty b/tests/fixtures/apparmor/.empty/.empty new file mode 100644 index 000000000..e69de29bb diff --git a/tests/host/test_control.py b/tests/host/test_control.py new file mode 100644 index 000000000..c9aeb98da --- /dev/null +++ b/tests/host/test_control.py @@ -0,0 +1,24 @@ +"""Test host control.""" + +import asyncio + +from supervisor.coresys import CoreSys + +from tests.common import fire_property_change_signal + + +async def test_set_hostname(coresys: CoreSys, dbus: list[str]): + """Test set hostname.""" + await coresys.dbus.hostname.connect(coresys.dbus.bus) + + assert coresys.dbus.hostname.hostname == "homeassistant-n2" + + dbus.clear() + await coresys.host.control.set_hostname("test") + assert dbus == [ + "/org/freedesktop/hostname1-org.freedesktop.hostname1.SetStaticHostname" + ] + + fire_property_change_signal(coresys.dbus.hostname, {"StaticHostname": "test"}) + await asyncio.sleep(0) + assert coresys.dbus.hostname.hostname == "test" diff --git a/tests/host/test_manager.py b/tests/host/test_manager.py index c670c4b98..b5ec62932 100644 --- a/tests/host/test_manager.py +++ b/tests/host/test_manager.py @@ -1,5 +1,5 @@ """Test host manager.""" -from unittest.mock import AsyncMock, PropertyMock, patch +from unittest.mock import PropertyMock, patch from supervisor.coresys import CoreSys from supervisor.dbus.agent import OSAgent @@ -10,42 +10,6 @@ from supervisor.dbus.systemd import Systemd from supervisor.dbus.timedate import TimeDate -async def test_reload(coresys: CoreSys): - """Test manager reload.""" - with patch.object(coresys.host.info, "update") as info_update, patch.object( - coresys.host.services, "update" - ) as services_update, patch.object( - coresys.host.network, "update" - ) as network_update, patch.object( - coresys.host.sys_dbus.agent, "update", new=AsyncMock() - ) as agent_update, patch.object( - coresys.host.sound, "update" - ) as sound_update: - - await coresys.host.reload() - - info_update.assert_called_once() - services_update.assert_called_once() - network_update.assert_called_once() - agent_update.assert_called_once() - sound_update.assert_called_once() - - info_update.reset_mock() - services_update.reset_mock() - network_update.reset_mock() - agent_update.reset_mock() - sound_update.reset_mock() - - await coresys.host.reload( - services=False, network=False, agent=False, audio=False - ) - info_update.assert_called_once() - services_update.assert_not_called() - network_update.assert_not_called() - agent_update.assert_not_called() - sound_update.assert_not_called() - - async def test_load( coresys: CoreSys, hostname: Hostname, @@ -53,6 +17,7 @@ async def test_load( timedate: TimeDate, os_agent: OSAgent, resolved: Resolved, + dbus: list[str], ): """Test manager load.""" type(coresys.dbus).hostname = PropertyMock(return_value=hostname) @@ -60,17 +25,9 @@ async def test_load( type(coresys.dbus).timedate = PropertyMock(return_value=timedate) type(coresys.dbus).agent = PropertyMock(return_value=os_agent) type(coresys.dbus).resolved = PropertyMock(return_value=resolved) + dbus.clear() - with patch.object(coresys.host.sound, "update") as sound_update, patch.object( - coresys.host.apparmor, "load" - ) as apparmor_load: - # Network is updated on connect for a version check so its not None already - assert coresys.dbus.hostname.hostname is None - assert coresys.dbus.systemd.boot_timestamp is None - assert coresys.dbus.timedate.timezone is None - assert coresys.dbus.agent.diagnostics is None - assert coresys.dbus.resolved.multicast_dns is None - + with patch.object(coresys.host.sound, "update") as sound_update: await coresys.host.load() assert coresys.dbus.hostname.hostname == "homeassistant-n2" @@ -79,6 +36,28 @@ async def test_load( assert coresys.dbus.agent.diagnostics is True assert coresys.dbus.network.connectivity_enabled is True assert coresys.dbus.resolved.multicast_dns == MulticastProtocolEnabled.RESOLVE + assert coresys.dbus.agent.apparmor.version == "2.13.2" sound_update.assert_called_once() - apparmor_load.assert_called_once() + + assert ( + "/org/freedesktop/systemd1-org.freedesktop.systemd1.Manager.ListUnits" in dbus + ) + + +async def test_reload(coresys: CoreSys, dbus: list[str]): + """Test manager reload and ensure it does not unnecessarily recreate dbus objects.""" + await coresys.dbus.load() + await coresys.host.load() + + with patch("supervisor.utils.dbus.DBus.connect") as connect, patch.object( + coresys.host.sound, "update" + ) as sound_update: + await coresys.host.reload() + + connect.assert_not_called() + sound_update.assert_called_once() + + assert ( + "/org/freedesktop/systemd1-org.freedesktop.systemd1.Manager.ListUnits" in dbus + ) diff --git a/tests/host/test_network.py b/tests/host/test_network.py index e937a9c50..7e7ddffec 100644 --- a/tests/host/test_network.py +++ b/tests/host/test_network.py @@ -1,17 +1,22 @@ """Test network manager.""" +import asyncio from ipaddress import IPv4Address, IPv6Address from unittest.mock import Mock, PropertyMock, patch from dbus_next.aio.proxy_object import ProxyInterface import pytest +from supervisor.const import CoreState from supervisor.coresys import CoreSys from supervisor.dbus.const import ConnectionStateFlags, InterfaceMethod from supervisor.exceptions import DBusFatalError, HostNotSupportedError +from supervisor.homeassistant.const import WSEvent, WSType from supervisor.host.const import InterfaceType, WifiMode from supervisor.host.network import Interface, IpConfig from supervisor.utils.dbus import DBus +from tests.common import fire_property_change_signal + async def test_load(coresys: CoreSys, dbus: list[str]): """Test network manager load.""" @@ -155,3 +160,94 @@ async def test_scan_wifi_with_failures(coresys: CoreSys, caplog): assert len(aps) == 2 assert "Can't process an AP" in caplog.text + + +async def test_host_connectivity_changed(coresys: CoreSys): + """Test host connectivity changed.""" + # pylint: disable=protected-access + client = coresys.homeassistant.websocket._client + await coresys.host.network.load() + + assert coresys.host.network.connectivity is True + + fire_property_change_signal(coresys.dbus.network, {"Connectivity": 1}) + await asyncio.sleep(0) + assert coresys.host.network.connectivity is False + await asyncio.sleep(0) + client.async_send_command.assert_called_once_with( + { + "type": WSType.SUPERVISOR_EVENT, + "data": { + "event": WSEvent.SUPERVISOR_UPDATE, + "update_key": "network", + "data": {"host_internet": False}, + }, + } + ) + + client.async_send_command.reset_mock() + fire_property_change_signal(coresys.dbus.network, {}, ["Connectivity"]) + await asyncio.sleep(0) + await asyncio.sleep(0) + assert coresys.host.network.connectivity is True + await asyncio.sleep(0) + client.async_send_command.assert_called_once_with( + { + "type": WSType.SUPERVISOR_EVENT, + "data": { + "event": WSEvent.SUPERVISOR_UPDATE, + "update_key": "network", + "data": {"host_internet": True}, + }, + } + ) + + +async def test_host_connectivity_disabled(coresys: CoreSys): + """Test host connectivity check disabled.""" + # pylint: disable=protected-access + client = coresys.homeassistant.websocket._client + await coresys.host.network.load() + + coresys.core.state = CoreState.RUNNING + await asyncio.sleep(0) + client.async_send_command.reset_mock() + + assert "connectivity_check" not in coresys.resolution.unsupported + assert coresys.host.network.connectivity is True + + fire_property_change_signal( + coresys.dbus.network, {"ConnectivityCheckEnabled": False} + ) + await asyncio.sleep(0) + assert coresys.host.network.connectivity is None + await asyncio.sleep(0) + client.async_send_command.assert_called_once_with( + { + "type": WSType.SUPERVISOR_EVENT, + "data": { + "event": WSEvent.SUPERVISOR_UPDATE, + "update_key": "network", + "data": {"host_internet": None}, + }, + } + ) + assert "connectivity_check" in coresys.resolution.unsupported + + client.async_send_command.reset_mock() + fire_property_change_signal(coresys.dbus.network, {}, ["ConnectivityCheckEnabled"]) + await asyncio.sleep(0) + await asyncio.sleep(0) + assert coresys.host.network.connectivity is True + await asyncio.sleep(0) + client.async_send_command.assert_called_once_with( + { + "type": WSType.SUPERVISOR_EVENT, + "data": { + "event": WSEvent.SUPERVISOR_UPDATE, + "update_key": "network", + "data": {"host_internet": True}, + }, + } + ) + assert "connectivity_check" not in coresys.resolution.unsupported diff --git a/tests/host/test_supported_features.py b/tests/host/test_supported_features.py index 97a47bcfd..5a4de141b 100644 --- a/tests/host/test_supported_features.py +++ b/tests/host/test_supported_features.py @@ -2,7 +2,7 @@ # pylint: disable=protected-access -def test_supported_features(coresys): +def test_supported_features(coresys, dbus_is_connected): """Test host features.""" assert "network" in coresys.host.features diff --git a/tests/resolution/evaluation/test_evaluate_network_manager.py b/tests/resolution/evaluation/test_evaluate_network_manager.py index ba267b9c1..29a645923 100644 --- a/tests/resolution/evaluation/test_evaluate_network_manager.py +++ b/tests/resolution/evaluation/test_evaluate_network_manager.py @@ -7,7 +7,7 @@ from supervisor.coresys import CoreSys from supervisor.resolution.evaluations.network_manager import EvaluateNetworkManager -async def test_evaluation(coresys: CoreSys): +async def test_evaluation(coresys: CoreSys, dbus_is_connected): """Test evaluation.""" network_manager = EvaluateNetworkManager(coresys) coresys.core.state = CoreState.RUNNING diff --git a/tests/resolution/evaluation/test_evaluate_resolved.py b/tests/resolution/evaluation/test_evaluate_resolved.py index 2178eb0c8..175ff6c77 100644 --- a/tests/resolution/evaluation/test_evaluate_resolved.py +++ b/tests/resolution/evaluation/test_evaluate_resolved.py @@ -1,25 +1,24 @@ """Test evaluate systemd-resolved.""" -from unittest.mock import PropertyMock, patch +from unittest.mock import patch from supervisor.const import CoreState from supervisor.coresys import CoreSys from supervisor.resolution.evaluations.resolved import EvaluateResolved -async def test_evaluation(coresys: CoreSys): +async def test_evaluation(coresys: CoreSys, dbus_is_connected): """Test evaluation.""" resolved = EvaluateResolved(coresys) coresys.core.state = CoreState.SETUP assert resolved.reason not in coresys.resolution.unsupported - with patch.object( - type(coresys.dbus.resolved), "is_connected", PropertyMock(return_value=False) - ): - await resolved() - assert resolved.reason in coresys.resolution.unsupported + coresys.dbus.resolved.is_connected = False + await resolved() + assert resolved.reason in coresys.resolution.unsupported + coresys.dbus.resolved.is_connected = True await resolved() assert resolved.reason not in coresys.resolution.unsupported