diff --git a/src/vorta/network_status/abc.py b/src/vorta/network_status/abc.py index 7f74fc16b..2ce69f197 100644 --- a/src/vorta/network_status/abc.py +++ b/src/vorta/network_status/abc.py @@ -2,8 +2,10 @@ from datetime import datetime from typing import List, NamedTuple, Optional +from PyQt6.QtCore import QObject, pyqtSignal -class NetworkStatusMonitor: + +class NetworkStatusMonitor(QObject): @classmethod def get_network_status_monitor(cls) -> 'NetworkStatusMonitor': if sys.platform == 'darwin': @@ -22,10 +24,22 @@ def get_network_status_monitor(cls) -> 'NetworkStatusMonitor': except (UnsupportedException, DBusException): return NullNetworkStatusMonitor() + network_status_changed = pyqtSignal(bool, name="networkStatusChanged") + + def __init__(self, parent=None): + super().__init__(parent) + def is_network_status_available(self): """Is the network status really available, and not just a dummy implementation?""" return type(self) is not NetworkStatusMonitor + def is_network_active(self) -> bool: + """Is there an active network connection. + + True signals that the network is up. The internet may still not be reachable though. + """ + raise NotImplementedError() + def is_network_metered(self) -> bool: """Is the currently connected network a metered connection?""" raise NotImplementedError() @@ -47,6 +61,12 @@ class SystemWifiInfo(NamedTuple): class NullNetworkStatusMonitor(NetworkStatusMonitor): """Dummy implementation, in case we don't have one for current platform.""" + def __init__(self): + super().__init__() + + def is_network_active(self): + return True + def is_network_status_available(self): return False diff --git a/src/vorta/network_status/darwin.py b/src/vorta/network_status/darwin.py index ec1fd4244..9a68b8c2b 100644 --- a/src/vorta/network_status/darwin.py +++ b/src/vorta/network_status/darwin.py @@ -9,6 +9,9 @@ class DarwinNetworkStatus(NetworkStatusMonitor): + def __init__(self): + super().__init__() + def is_network_metered(self) -> bool: interface: CWInterface = self._get_wifi_interface() @@ -25,6 +28,10 @@ def is_network_metered(self) -> bool: return is_ios_hotspot or any(is_network_metered_with_android(d) for d in get_network_devices()) + def is_network_active(self): + # Not yet implemented + return True + def get_current_wifi(self) -> Optional[str]: """ Get current SSID or None if Wi-Fi is off. diff --git a/src/vorta/network_status/network_manager.py b/src/vorta/network_status/network_manager.py index 3d06ddb4c..ee51aa4a6 100644 --- a/src/vorta/network_status/network_manager.py +++ b/src/vorta/network_status/network_manager.py @@ -4,7 +4,7 @@ from typing import Any, List, Mapping, NamedTuple, Optional from PyQt6 import QtDBus -from PyQt6.QtCore import QObject, QVersionNumber +from PyQt6.QtCore import QObject, QVersionNumber, pyqtSignal, pyqtSlot from vorta.network_status.abc import NetworkStatusMonitor, SystemWifiInfo @@ -13,7 +13,9 @@ class NetworkManagerMonitor(NetworkStatusMonitor): def __init__(self, nm_adapter: 'NetworkManagerDBusAdapter' = None): + super().__init__() self._nm = nm_adapter or NetworkManagerDBusAdapter.get_system_nm_adapter() + self._nm.network_status_changed.connect(self.network_status_changed) def is_network_metered(self) -> bool: try: @@ -25,6 +27,13 @@ def is_network_metered(self) -> bool: logger.exception("Failed to check if network is metered, assuming it isn't") return False + def is_network_active(self): + try: + return self._nm.get_connectivity_state() is not NMConnectivityState.NONE + except DBusException: + logger.exception("Failed to check connectivity state. Assuming connected") + return True + def get_current_wifi(self) -> Optional[str]: # Only check the primary connection. VPN over WiFi will still show the WiFi as Primary Connection. # We don't check all active connections, as NM won't disable WiFi when connecting a cable. @@ -98,10 +107,17 @@ class NetworkManagerDBusAdapter(QObject): BUS_NAME = 'org.freedesktop.NetworkManager' NM_PATH = '/org/freedesktop/NetworkManager' + INTERFACE_NAME = 'org.freedesktop.NetworkManager' + SIGNAL_NAME = 'StateChanged' + + network_status_changed = pyqtSignal(bool, name="networkStatusChanged") def __init__(self, parent, bus): super().__init__(parent) self._bus = bus + self._bus.connect( + self.BUS_NAME, self.NM_PATH, self.INTERFACE_NAME, self.SIGNAL_NAME, 'u', self.networkStateChanged + ) self._nm = self._get_iface(self.NM_PATH, 'org.freedesktop.NetworkManager') @classmethod @@ -114,6 +130,12 @@ def get_system_nm_adapter(cls) -> 'NetworkManagerDBusAdapter': raise UnsupportedException("Can't connect to NetworkManager") return nm_adapter + @pyqtSlot("unsigned int") + def networkStateChanged(self, state): + logger.debug(f'network state changed: {state}') + # https://www.networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMState + self.network_status_changed.emit(state >= 60) + def isValid(self): if not self._nm.isValid(): return False @@ -126,6 +148,9 @@ def isValid(self): return False return True + def get_connectivity_state(self) -> 'NMConnectivityState': + return NMConnectivityState(read_dbus_property(self._nm, 'Connectivity')) + def get_primary_connection_path(self) -> Optional[str]: return read_dbus_property(self._nm, 'PrimaryConnection') @@ -186,3 +211,13 @@ class NMDeviceType(Enum): # Only the types we care about UNKNOWN = 0 WIFI = 2 + + +class NMConnectivityState(Enum): + """https://www.networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMConnectivityState""" + + UNKNOWN = 0 + NONE = 1 + PORTAL = 2 + LIMITED = 3 + FULL = 4 diff --git a/src/vorta/scheduler.py b/src/vorta/scheduler.py index 9ff19681c..8c47798c7 100644 --- a/src/vorta/scheduler.py +++ b/src/vorta/scheduler.py @@ -3,7 +3,7 @@ import threading from datetime import datetime as dt from datetime import timedelta -from typing import Dict, NamedTuple, Optional, Tuple, Union +from typing import Dict, List, NamedTuple, Optional, Tuple, Union from packaging import version from PyQt6 import QtCore, QtDBus @@ -19,7 +19,7 @@ from vorta.i18n import translate from vorta.notifications import VortaNotifications from vorta.store.models import BackupProfileModel, EventLogModel -from vorta.utils import borg_compat +from vorta.utils import borg_compat, get_network_status_monitor logger = logging.getLogger(__name__) @@ -62,6 +62,11 @@ def __init__(self): # connect signals self.app.backup_finished_event.connect(lambda res: self.set_timer_for_profile(res['params']['profile_id'])) + # Connect to network manager to to monitor that network status + self.net_status = get_network_status_monitor() + self.net_status.network_status_changed.connect(self.networkStatusChanged) + self._net_up = self.net_status.is_network_active() + # connect to `systemd-logind` to receive sleep/resume events # The signal `PrepareForSleep` will be emitted before and after hibernation. service = "org.freedesktop.login1" @@ -79,6 +84,17 @@ def __init__(self): def loginSuspendNotify(self, suspend: bool): if not suspend: logger.debug("Got login suspend/resume notification") + # Defensively refetch in case the network status didn't arrive + self._net_up = self.net_status.is_network_active() + self.reload_all_timers() + + @QtCore.pyqtSlot(bool) + def networkStatusChanged(self, up: bool): + reload = self._net_up != up + self._net_up = up + logger.debug(f"network status up={up}") + if reload: + logger.info("updating shcedule due to network status change") self.reload_all_timers() def tr(self, *args, **kwargs): @@ -293,7 +309,7 @@ def set_timer_for_profile(self, profile_id: int): # handle missing of a scheduled time if next_time <= dt.now(): - if profile.schedule_make_up_missed: + if profile.schedule_make_up_missed and self._net_up: self.lock.release() try: logger.debug( @@ -306,6 +322,8 @@ def set_timer_for_profile(self, profile_id: int): self.lock.acquire() # with-statement will try to release return # create_backup will lead to a call to this method + else: + logger.debug('Skipping catchup %s (%s), the network is not available', profile.name, profile.id) # calculate next time from now if profile.schedule_mode == 'interval': @@ -361,7 +379,15 @@ def set_timer_for_profile(self, profile_id: int): def reload_all_timers(self): logger.debug('Refreshing all scheduler timers') for profile in BackupProfileModel.select(): - self.set_timer_for_profile(profile.id) + # Only set a timer for the profile if the network is actually up + if profile.repo is None: + logger.debug("nothing scheduled for %s because of unset repo", profile.id) + elif not profile.repo.is_remote_repo() or self._net_up: + logger.debug("scheduling %s", profile.id) + self.set_timer_for_profile(profile.id) + else: + logger.debug("Network is down, not scheduling %s", profile.id) + self.remove_job(profile.id) def next_job(self): now = dt.now()