From 282d791d01f2f37eaceef77e5004c303c979766c Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Sun, 23 Aug 2026 16:22:30 +0545 Subject: [PATCH] fix(network): detect usb and bluetooth tethering in panel Wired devices were tracked as a single slot, so USB tethering on a second port never got an Ethernet service object and the indicator kept showing wifi-off while online. Track all wired devices like wifi devices, prefer the one with an active connection, treat BT PAN (type 5) as wired, and refresh on device removal. --- src/services/network.py | 47 +++++++--- src/window/panel/components/indicators.py | 102 ++++++++++++---------- 2 files changed, 92 insertions(+), 57 deletions(-) diff --git a/src/services/network.py b/src/services/network.py index 7490dca..8eb881c 100644 --- a/src/services/network.py +++ b/src/services/network.py @@ -27,6 +27,8 @@ NM_CONN_SETTINGS_IFACE = "org.freedesktop.NetworkManager.Settings.Connection" # DeviceType constants DEVICE_TYPE_ETHERNET = 1 DEVICE_TYPE_WIFI = 2 +# Bluetooth PAN (e.g. bluetooth tethering) shows up as its own device type +DEVICE_TYPE_BLUETOOTH = 5 # DeviceState constants DEVICE_STATE_UNMANAGED = 10 @@ -759,10 +761,17 @@ class Ethernet(Service): @Signal def changed(self) -> None: ... - def __init__(self, nm: NmProxy, device_path: str, **kwargs) -> None: + def __init__( + self, + nm: NmProxy, + device_path: str, + device_type: int = DEVICE_TYPE_ETHERNET, + **kwargs, + ) -> None: super().__init__(**kwargs) self._nm = nm self._device_path = device_path + self.device_type = device_type self._handler_id: int | None = None dev = _make_proxy(nm._bus, device_path, NM_DEVICE_IFACE) self._handler_id = dev.connect( @@ -835,6 +844,7 @@ class NetworkClient(Service): self.wifi_device: Wifi | None = None self.wifi_devices: dict[str, Wifi] = {} self.ethernet_device: Ethernet | None = None + self.ethernet_devices: dict[str, Ethernet] = {} self._airplane_mode: bool = False self._nm_proxy_handler: int | None = None super().__init__(**kwargs) @@ -872,8 +882,7 @@ class NetworkClient(Service): new_devices = set(self._nm.devices) # Track by old wifi device paths old_paths = {w._device_path for w in self.wifi_devices.values()} - if self.ethernet_device: - old_paths.add(self.ethernet_device._device_path) + old_paths |= set(self.ethernet_devices.keys()) new_paths = set(new_devices) added = new_paths - old_paths @@ -901,9 +910,12 @@ class NetworkClient(Service): self.emit("device-added", iface) self.emit("device-ready") - elif dtype == DEVICE_TYPE_ETHERNET: - if self.ethernet_device is None: - self.ethernet_device = Ethernet(self._nm, dev_path) + elif dtype in (DEVICE_TYPE_ETHERNET, DEVICE_TYPE_BLUETOOTH): + if dev_path not in self.ethernet_devices: + ethernet = Ethernet(self._nm, dev_path, device_type=dtype) + self.ethernet_devices[dev_path] = ethernet + if self.ethernet_device is None: + self.ethernet_device = ethernet self.emit("device-ready") def _handle_device_removed(self, dev_path: str): @@ -922,16 +934,31 @@ class NetworkClient(Service): logger.info(f"[Network] Wifi device removed: {iface}") self.emit("device-removed", iface) - elif dtype == DEVICE_TYPE_ETHERNET: - if self.ethernet_device is not None: - self.ethernet_device.close() - self.ethernet_device = None + elif dtype in (DEVICE_TYPE_ETHERNET, DEVICE_TYPE_BLUETOOTH): + ethernet = self.ethernet_devices.pop(dev_path, None) + if ethernet is not None: + ethernet.close() + if self.ethernet_device is ethernet: + self.ethernet_device = next( + iter(self.ethernet_devices.values()), None + ) self.emit("device-removed", iface) # Drop cached proxies now that signal handlers are disconnected and the # removed device is no longer referenced. _invalidate_proxy(dev_path) + def get_ethernet_device(self) -> Ethernet | None: + """Return the wired device with an active connection if any. + + USB tethering appears as an extra wired device, so prefer one that is + activated/activating over a merely present (often unavailable) port. + """ + for ethernet in self.ethernet_devices.values(): + if ethernet.internet in ("activated", "activating"): + return ethernet + return next(iter(self.ethernet_devices.values()), None) + def _get_primary_device(self) -> Literal["wifi", "wired"] | None: if not self._nm: return None diff --git a/src/window/panel/components/indicators.py b/src/window/panel/components/indicators.py index 12e9925..b1f1f72 100644 --- a/src/window/panel/components/indicators.py +++ b/src/window/panel/components/indicators.py @@ -5,7 +5,7 @@ from fabric.widgets.label import Label from services.battery import Battery from services.bluetooth import BluetoothClient -from services.network import NetworkClient +from services.network import DEVICE_TYPE_BLUETOOTH, NetworkClient from shared.window.applet_window import AppletWindow from shared.window.battery_widget import BatteryControl from utils.functions import format_duration, get_wifi_icon_for_strength @@ -168,6 +168,7 @@ class NetworkIndicator(Box): self.show_window = show_window self.network_service = NetworkClient() + self._connected_eth_paths: set[str] = set() self.network_icon = svg_file("applets/wifi-clear.svg", size=22) @@ -200,6 +201,7 @@ class NetworkIndicator(Box): modus_service.connect("wlan-changed", self.on_wlan_changed) self.network_service.connect("device-ready", self.on_wifi_device_added) self.network_service.connect("device-ready", self.on_ethernet_device_added) + self.network_service.connect("device-removed", self.on_network_changed) self.update_modus_service_wlan_state() self.update_state() @@ -216,10 +218,12 @@ class NetworkIndicator(Box): self.update_state() def on_ethernet_device_added(self, *args): - if self.network_service.ethernet_device: - self.network_service.ethernet_device.connect( - "changed", self.on_network_direct_changed - ) + # Track every wired device (e.g. USB tethering adds one) so any of + # them can drive the indicator state + for path, eth in self.network_service.ethernet_devices.items(): + if path not in self._connected_eth_paths: + eth.connect("changed", self.on_network_direct_changed) + self._connected_eth_paths.add(path) self.update_modus_service_wlan_state() self.update_state() @@ -236,27 +240,25 @@ class NetworkIndicator(Box): if self.network_service.airplane_mode: wlan_state = "airplane" - elif self.network_service.wifi_device: + else: wifi = self.network_service.wifi_device - if not wifi.enabled: - wlan_state = "disabled" - elif wifi.ssid and wifi.ssid != "Disconnected": + ethernet = self.network_service.get_ethernet_device() + + # Ethernet (including USB tethering) must be considered even when + # a disconnected wifi device is present + if wifi and wifi.enabled and wifi.ssid and wifi.ssid != "Disconnected": wlan_state = f"connected:{wifi.ssid}" if wifi.strength >= 0: wlan_state += f":{wifi.strength}%" - else: - wlan_state = "enabled" - - # Check Ethernet if WiFi is not connected - elif self.network_service.ethernet_device: - ethernet = self.network_service.ethernet_device - if ethernet.internet == "activated": + elif ethernet and ethernet.internet == "activated": wlan_state = "ethernet:connected" if hasattr(ethernet, "speed") and ethernet.speed: wlan_state += f":{ethernet.speed}" - elif ethernet.internet == "activating": + elif ethernet and ethernet.internet == "activating": wlan_state = "ethernet:connecting" - else: + elif wifi: + wlan_state = "enabled" if wifi.enabled else "disabled" + elif ethernet: wlan_state = "ethernet:disconnected" modus_service.wlan = wlan_state @@ -265,17 +267,18 @@ class NetworkIndicator(Box): tooltip = "No network connection" icon_file = "wifi-off-clear.svg" - # Airplane mode takes priority if self.network_service.airplane_mode: - icon_file = "wifi-off-clear.svg" + # Airplane mode takes priority tooltip = "Airplane mode" - # Check WiFi first (prioritize WiFi over Ethernet) - elif self.network_service.wifi_device: + else: wifi = self.network_service.wifi_device - if not wifi.enabled: - icon_file = "wifi-off-clear.svg" - tooltip = "WiFi disabled" - elif wifi.ssid and wifi.ssid != "Disconnected": + ethernet = self.network_service.get_ethernet_device() + wifi_connected = ( + wifi and wifi.enabled and wifi.ssid and wifi.ssid != "Disconnected" + ) + + if wifi_connected: + # WiFi takes priority when actually connected wifi_icon_path = get_wifi_icon_for_strength(wifi.strength) self.network_icon.set_from_file(wifi_icon_path) tooltip = f"Connected to {wifi.ssid}" @@ -283,22 +286,27 @@ class NetworkIndicator(Box): tooltip += f" ({wifi.strength}%)" self.network_button.set_tooltip_text(tooltip) return # Early return to avoid setting icon again - else: - icon_file = "wifi-off-clear.svg" - tooltip = "WiFi disconnected" - # Check Ethernet if WiFi is not connected - elif self.network_service.ethernet_device: - ethernet = self.network_service.ethernet_device - if ethernet.internet == "activated": + # Check Ethernet regardless of WiFi presence. USB tethering shows + # up as a wired device, so it would be masked by the elif before. + if ethernet and ethernet.internet in ("activated", "activating"): icon_file = "network-wired.svg" - tooltip = "Ethernet connected" - if hasattr(ethernet, "speed") and ethernet.speed: - tooltip += f" ({ethernet.speed})" - elif ethernet.internet == "activating": - icon_file = "network-wired.svg" - tooltip = "Ethernet connecting..." - else: + wired_label = ( + "Bluetooth tethering" + if ethernet.device_type == DEVICE_TYPE_BLUETOOTH + else "Ethernet" + ) + if ethernet.internet == "activated": + tooltip = f"{wired_label} connected" + if hasattr(ethernet, "speed") and ethernet.speed: + tooltip += f" ({ethernet.speed})" + else: + tooltip = f"{wired_label} connecting..." + elif wifi and not wifi.enabled: + tooltip = "WiFi disabled" + elif wifi: + tooltip = "WiFi disconnected" + elif ethernet: icon_file = "network-wired-offline.svg" tooltip = "Ethernet disconnected" @@ -535,14 +543,14 @@ def add_destroy_to_indicators(): ) except Exception as e: logger.error(f"An error occurred: {e}") - if ( - hasattr(self.network_service, "ethernet_device") - and self.network_service.ethernet_device - ): + for eth_path in self._connected_eth_paths: + eth = getattr(self.network_service, "ethernet_devices", {}).get( + eth_path + ) + if eth is None: + continue try: - self.network_service.ethernet_device.disconnect_by_func( - self.on_network_direct_changed - ) + eth.disconnect_by_func(self.on_network_direct_changed) except Exception as e: logger.error(f"An error occurred: {e}") except Exception as e: