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.
This commit is contained in:
sankalp committed 2026-08-23 16:22:30 +05:45
1 parent b7e8786e6b
commit 282d791d01
2 files changed
+92 -57

No files matched your search

+37 -10
View File
@@ -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
+55 -47
View File
@@ -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: