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.
572 lines
21 KiB
Python
572 lines
21 KiB
Python
from fabric.utils import logger
|
|
from fabric.widgets.box import Box
|
|
from fabric.widgets.button import Button
|
|
from fabric.widgets.label import Label
|
|
|
|
from services.battery import Battery
|
|
from services.bluetooth import BluetoothClient
|
|
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
|
|
from utils.gtk_utils import setup_cursor_hover, svg_file
|
|
from utils.roam import modus_service
|
|
from window.controlcenter.bluetooth import BluetoothConnections
|
|
from window.controlcenter.wifi import WifiConnections
|
|
|
|
|
|
def create_control_window(name_prefix, parent=None, pointing_to=None):
|
|
return AppletWindow(
|
|
parent=parent,
|
|
pointing_to=pointing_to,
|
|
layer="overlay",
|
|
title="modus-controlcenter",
|
|
anchor="top right",
|
|
margin="2px 10px 0px 0px",
|
|
exclusivity="auto",
|
|
name=f"{name_prefix}-window",
|
|
visible=False,
|
|
)
|
|
|
|
|
|
def setup_control_center(
|
|
show_window, name_prefix, widget_class, parent, pointing_to=None, **widget_kwargs
|
|
):
|
|
if not show_window:
|
|
return None, None
|
|
|
|
window = create_control_window(name_prefix, parent=parent, pointing_to=pointing_to)
|
|
widget = widget_class(parent, **widget_kwargs)
|
|
window.children = [widget]
|
|
|
|
return window, widget
|
|
|
|
|
|
def handle_indicator_click(window):
|
|
if window:
|
|
window.toggle()
|
|
|
|
|
|
def hide_control_center(window):
|
|
if window:
|
|
window.hide()
|
|
|
|
|
|
class BluetoothIndicator(Box):
|
|
def __init__(self, show_window=True, **kwargs):
|
|
super().__init__(name="bluetooth-indicator", orientation="h", **kwargs)
|
|
self.show_window = show_window
|
|
|
|
self.bluetooth = BluetoothClient()
|
|
self.bt_icon = svg_file("applets/bluetooth-clear.svg", size=22)
|
|
|
|
self.bt_button = Button(
|
|
name="bt-button", child=self.bt_icon, on_clicked=self.on_bluetooth_clicked
|
|
)
|
|
|
|
self.add(self.bt_button)
|
|
|
|
# Set pointer cursor on hover
|
|
setup_cursor_hover(self.bt_button, "pointer")
|
|
|
|
# Setup control center using shared function
|
|
(
|
|
self.bluetooth_window,
|
|
self.bluetooth_widget,
|
|
) = setup_control_center(
|
|
self.show_window,
|
|
"bluetooth",
|
|
BluetoothConnections,
|
|
self,
|
|
pointing_to=self.bt_button,
|
|
show_back_button=False,
|
|
)
|
|
|
|
modus_service.connect("bluetooth-changed", self.on_bluetooth_changed)
|
|
self.bluetooth.connect("changed", self.on_bluetooth_direct_changed)
|
|
self.bluetooth.connect("device-added", self.on_device_added)
|
|
self.bluetooth.connect("device-removed", self.on_device_removed)
|
|
# Fix #5 — update icon/state whenever any device connects or disconnects
|
|
self.bluetooth.connect(
|
|
"notify::connected-devices", self.on_bluetooth_direct_changed
|
|
)
|
|
|
|
self.update_modus_service_bluetooth_state()
|
|
self.update_state()
|
|
|
|
def update_state(self):
|
|
if not self.bluetooth.enabled:
|
|
self.bt_icon.dynamic_file("applets/bluetooth-off-clear.svg")
|
|
tooltip = "Bluetooth disabled"
|
|
else:
|
|
connected_devices = self.bluetooth.connected_devices
|
|
if connected_devices:
|
|
self.bt_icon.dynamic_file("applets/bluetooth-clear.svg")
|
|
if len(connected_devices) >= 1:
|
|
self.bt_icon.dynamic_file("applets/bluetooth-paired.svg")
|
|
device = connected_devices[0]
|
|
tooltip = f"Connected to {device.alias}"
|
|
if device.battery_percentage > 0:
|
|
tooltip += f" ({device.battery_percentage:.0f}%)"
|
|
else:
|
|
tooltip = f"Connected to {len(connected_devices)} devices"
|
|
else:
|
|
self.bt_icon.dynamic_file("applets/bluetooth-clear.svg")
|
|
tooltip = "No devices connected"
|
|
|
|
self.bt_button.set_tooltip_text(tooltip)
|
|
|
|
def on_bluetooth_changed(self, service, _new_bluetooth_state):
|
|
self.update_state()
|
|
|
|
def on_bluetooth_direct_changed(self, *args):
|
|
self.update_modus_service_bluetooth_state()
|
|
self.update_state()
|
|
|
|
def on_device_added(self, _, address):
|
|
self.update_modus_service_bluetooth_state()
|
|
self.update_state()
|
|
|
|
def on_device_removed(self, _, address):
|
|
self.update_modus_service_bluetooth_state()
|
|
self.update_state()
|
|
|
|
def update_modus_service_bluetooth_state(self):
|
|
if not self.bluetooth.enabled:
|
|
bluetooth_state = "disabled"
|
|
else:
|
|
connected_devices = self.bluetooth.connected_devices
|
|
if connected_devices:
|
|
if len(connected_devices) == 1:
|
|
device = connected_devices[0]
|
|
bluetooth_state = f"connected:{device.alias}"
|
|
if (
|
|
hasattr(device, "battery_percentage")
|
|
and device.battery_percentage > 0
|
|
):
|
|
bluetooth_state += f":{device.battery_percentage:.0f}%"
|
|
else:
|
|
bluetooth_state = f"connected:{len(connected_devices)}_devices"
|
|
else:
|
|
bluetooth_state = "enabled"
|
|
|
|
modus_service.bluetooth = bluetooth_state
|
|
|
|
def on_bluetooth_clicked(self, *args):
|
|
handle_indicator_click(self.bluetooth_window)
|
|
|
|
def close_bluetooth(self, *args):
|
|
hide_control_center(self.bluetooth_window)
|
|
|
|
def hide_controlcenter(self, *args):
|
|
hide_control_center(self.bluetooth_window)
|
|
|
|
|
|
class NetworkIndicator(Box):
|
|
def __init__(self, show_window=True, **kwargs):
|
|
super().__init__(name="network-indicator", orientation="h", **kwargs)
|
|
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)
|
|
|
|
self.network_button = Button(
|
|
name="network-button",
|
|
child=self.network_icon,
|
|
on_clicked=self.on_wifi_clicked,
|
|
)
|
|
|
|
self.add(self.network_button)
|
|
|
|
# Set pointer cursor on hover
|
|
setup_cursor_hover(self.network_button, "pointer")
|
|
|
|
# Setup control center using shared function
|
|
(
|
|
self.wifi_window,
|
|
self.wifi_widget,
|
|
) = setup_control_center(
|
|
self.show_window,
|
|
"wifi",
|
|
WifiConnections,
|
|
self,
|
|
pointing_to=self.network_button,
|
|
show_back_button=False,
|
|
)
|
|
if self.wifi_window:
|
|
self.wifi_window._pointing_widget = self.network_button
|
|
|
|
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()
|
|
|
|
def on_wlan_changed(self, service, _new_wlan_state):
|
|
self.update_state()
|
|
|
|
def on_wifi_device_added(self, *args):
|
|
"""Called when WiFi device is added"""
|
|
if self.network_service.wifi_device:
|
|
self.network_service.wifi_device.connect(
|
|
"changed", self.on_network_direct_changed
|
|
)
|
|
self.update_modus_service_wlan_state()
|
|
self.update_state()
|
|
|
|
def on_ethernet_device_added(self, *args):
|
|
# 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()
|
|
|
|
def on_network_direct_changed(self, *args):
|
|
self.update_modus_service_wlan_state()
|
|
self.update_state()
|
|
|
|
def on_network_changed(self, *args):
|
|
self.update_modus_service_wlan_state()
|
|
self.update_state()
|
|
|
|
def update_modus_service_wlan_state(self):
|
|
wlan_state = "disconnected"
|
|
|
|
if self.network_service.airplane_mode:
|
|
wlan_state = "airplane"
|
|
else:
|
|
wifi = self.network_service.wifi_device
|
|
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}%"
|
|
elif ethernet and ethernet.internet == "activated":
|
|
wlan_state = "ethernet:connected"
|
|
if hasattr(ethernet, "speed") and ethernet.speed:
|
|
wlan_state += f":{ethernet.speed}"
|
|
elif ethernet and ethernet.internet == "activating":
|
|
wlan_state = "ethernet:connecting"
|
|
elif wifi:
|
|
wlan_state = "enabled" if wifi.enabled else "disabled"
|
|
elif ethernet:
|
|
wlan_state = "ethernet:disconnected"
|
|
|
|
modus_service.wlan = wlan_state
|
|
|
|
def update_state(self):
|
|
tooltip = "No network connection"
|
|
icon_file = "wifi-off-clear.svg"
|
|
|
|
if self.network_service.airplane_mode:
|
|
# Airplane mode takes priority
|
|
tooltip = "Airplane mode"
|
|
else:
|
|
wifi = self.network_service.wifi_device
|
|
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}"
|
|
if wifi.strength >= 0:
|
|
tooltip += f" ({wifi.strength}%)"
|
|
self.network_button.set_tooltip_text(tooltip)
|
|
return # Early return to avoid setting icon again
|
|
|
|
# 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"
|
|
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"
|
|
|
|
self.network_icon.dynamic_file(f"applets/{icon_file}")
|
|
self.network_button.set_tooltip_text(tooltip)
|
|
|
|
def on_wifi_clicked(self, *args):
|
|
handle_indicator_click(self.wifi_window)
|
|
|
|
def close_wifi(self, *args):
|
|
hide_control_center(self.wifi_window)
|
|
|
|
def on_wifi_direct_changed(self, *args):
|
|
hide_control_center(self.wifi_window)
|
|
|
|
def on_network_clicked(self, *args):
|
|
handle_indicator_click(self.wifi_window)
|
|
|
|
def hide_controlcenter(self, *args):
|
|
hide_control_center(self.wifi_window)
|
|
|
|
|
|
class BatteryIndicator(Box):
|
|
def __init__(self, show_window=True, **kwargs):
|
|
super().__init__(name="battery-indicator", orientation="h", **kwargs)
|
|
self.show_window = show_window
|
|
|
|
self.battery_service = Battery.get_initial()
|
|
|
|
self.battery_icon = svg_file("battery/battery-100.svg", size=23)
|
|
|
|
self.battery_button = Button(
|
|
name="battery-button",
|
|
child=self.battery_icon,
|
|
on_clicked=self.on_battery_clicked,
|
|
)
|
|
|
|
self.battery_label = Label(name="battery-label", label="--- %")
|
|
|
|
self.add(self.battery_label)
|
|
self.add(self.battery_button)
|
|
|
|
# Set pointer cursor on hover
|
|
setup_cursor_hover(self.battery_button, "pointer")
|
|
|
|
(
|
|
self.battery_window,
|
|
self.battery_widget,
|
|
) = setup_control_center(
|
|
self.show_window,
|
|
"battery",
|
|
BatteryControl,
|
|
self,
|
|
pointing_to=self.battery_button,
|
|
show_back_button=False,
|
|
)
|
|
if self.battery_window:
|
|
self.battery_window._pointing_widget = self.battery_button
|
|
|
|
modus_service.connect("battery-changed", self.on_battery_changed)
|
|
self.battery_service.connect("changed", self.on_battery_direct_changed)
|
|
|
|
self.update_modus_service_battery_state()
|
|
self.update_state()
|
|
|
|
def on_battery_changed(self, service, _new_battery_state):
|
|
self.update_state()
|
|
|
|
def on_battery_direct_changed(self, *args):
|
|
self.update_modus_service_battery_state()
|
|
self.update_state()
|
|
hide_control_center(self.battery_window)
|
|
|
|
def _get_percentage(self):
|
|
return int(self.battery_service.percent)
|
|
|
|
def _get_state(self):
|
|
if self.battery_service.charging:
|
|
return "CHARGING"
|
|
if self.battery_service.discharging:
|
|
return "DISCHARGING"
|
|
if self.battery_service.charged:
|
|
return "FULLY_CHARGED"
|
|
return "UNKNOWN"
|
|
|
|
def _is_present(self):
|
|
return self.battery_service.available
|
|
|
|
def _get_time_to_empty(self):
|
|
return self.battery_service.time_remaining
|
|
|
|
def _get_time_to_full(self):
|
|
return self.battery_service.time_to_full
|
|
|
|
def _get_battery_icon_file(self, percentage: int, is_charging: bool) -> str:
|
|
clamped = max(0, min(100, percentage))
|
|
step = (clamped // 10) * 10
|
|
filename = f"battery-{step:03d}{'-charging' if is_charging else ''}.svg"
|
|
return f"battery/{filename}"
|
|
|
|
def update_modus_service_battery_state(self):
|
|
if not self._is_present():
|
|
battery_state = "not_present"
|
|
else:
|
|
percentage = self._get_percentage()
|
|
state_str = self._get_state().lower()
|
|
|
|
battery_state = f"{state_str}:{percentage}%"
|
|
|
|
if state_str == "discharging":
|
|
time_to_empty = format_duration(self._get_time_to_empty())
|
|
if time_to_empty != "N/A":
|
|
battery_state += f":{time_to_empty}"
|
|
elif state_str == "charging":
|
|
time_to_full = format_duration(self._get_time_to_full())
|
|
if time_to_full != "N/A":
|
|
battery_state += f":{time_to_full}"
|
|
|
|
modus_service.battery = battery_state
|
|
|
|
def get_battery_tooltip(self, percentage, state):
|
|
tooltip = f"Battery: {percentage}%"
|
|
|
|
if state == "CHARGING":
|
|
tooltip += " (Charging)"
|
|
time_to_full = format_duration(self._get_time_to_full())
|
|
if time_to_full != "N/A":
|
|
tooltip += f" - {time_to_full} until full"
|
|
elif state == "DISCHARGING":
|
|
time_to_empty = format_duration(self._get_time_to_empty())
|
|
if time_to_empty != "N/A":
|
|
tooltip += f" - {time_to_empty} remaining"
|
|
elif state == "FULLY_CHARGED":
|
|
tooltip += " (Fully charged)"
|
|
|
|
return tooltip
|
|
|
|
def update_state(self):
|
|
if not self._is_present():
|
|
logger.warning("No battery detected, hiding indicator")
|
|
self.hide()
|
|
return
|
|
else:
|
|
self.show()
|
|
|
|
percentage = self._get_percentage()
|
|
state = self._get_state()
|
|
is_charging = state in ["CHARGING", "FULLY_CHARGED"]
|
|
|
|
icon_file = self._get_battery_icon_file(percentage, is_charging)
|
|
tooltip = self.get_battery_tooltip(percentage, state)
|
|
percentage_text = f"{percentage}%"
|
|
|
|
self.battery_icon.dynamic_file(icon_file)
|
|
self.battery_button.set_tooltip_text(tooltip)
|
|
self.battery_label.set_label(percentage_text)
|
|
|
|
def on_battery_clicked(self, *args):
|
|
handle_indicator_click(self.battery_window)
|
|
|
|
def close_battery(self, *args):
|
|
hide_control_center(self.battery_window)
|
|
|
|
def hide_controlcenter(self, *args):
|
|
hide_control_center(self.battery_window)
|
|
|
|
def destroy(self):
|
|
"""Clean up resources when the indicator is destroyed."""
|
|
# Disconnect signals
|
|
try:
|
|
modus_service.disconnect_by_func(self.on_battery_changed)
|
|
if hasattr(self, "battery_service") and self.battery_service:
|
|
self.battery_service.disconnect_by_func(self.on_battery_direct_changed)
|
|
except Exception as e:
|
|
logger.error(f"Error disconnecting battery indicator signals: {e}")
|
|
|
|
# Destroy window and widget
|
|
try:
|
|
if hasattr(self, "battery_widget") and self.battery_widget:
|
|
self.battery_widget.destroy()
|
|
if hasattr(self, "battery_window") and self.battery_window:
|
|
self.battery_window.destroy()
|
|
except Exception as e:
|
|
logger.error(f"An error occurred: {e}")
|
|
|
|
super().destroy()
|
|
|
|
|
|
# Add destroy methods to other classes in this file as well
|
|
def add_destroy_to_indicators():
|
|
# BluetoothIndicator
|
|
|
|
def bt_destroy(self):
|
|
try:
|
|
modus_service.disconnect_by_func(self.on_bluetooth_changed)
|
|
if hasattr(self, "bluetooth") and self.bluetooth:
|
|
self.bluetooth.disconnect_by_func(self.on_bluetooth_direct_changed)
|
|
self.bluetooth.disconnect_by_func(self.on_device_added)
|
|
self.bluetooth.disconnect_by_func(self.on_device_removed)
|
|
try:
|
|
self.bluetooth.close()
|
|
except Exception as e:
|
|
logger.error(f"Error closing bluetooth indicator client: {e}")
|
|
except Exception as e:
|
|
logger.error(f"Error disconnecting bluetooth indicator signals: {e}")
|
|
try:
|
|
if hasattr(self, "bluetooth_widget") and self.bluetooth_widget:
|
|
self.bluetooth_widget.destroy()
|
|
if hasattr(self, "bluetooth_window") and self.bluetooth_window:
|
|
self.bluetooth_window.destroy()
|
|
except Exception as e:
|
|
logger.error(f"An error occurred: {e}")
|
|
Box.destroy(self)
|
|
|
|
BluetoothIndicator.destroy = bt_destroy
|
|
|
|
# NetworkIndicator
|
|
def net_destroy(self):
|
|
try:
|
|
modus_service.disconnect_by_func(self.on_wlan_changed)
|
|
if hasattr(self, "network_service") and self.network_service:
|
|
self.network_service.disconnect_by_func(self.on_wifi_device_added)
|
|
self.network_service.disconnect_by_func(self.on_ethernet_device_added)
|
|
self.network_service.disconnect_by_func(self.on_network_changed)
|
|
|
|
# Also disconnect from the devices themselves if they exist
|
|
if (
|
|
hasattr(self.network_service, "wifi_device")
|
|
and self.network_service.wifi_device
|
|
):
|
|
try:
|
|
self.network_service.wifi_device.disconnect_by_func(
|
|
self.on_network_direct_changed
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"An error occurred: {e}")
|
|
for eth_path in self._connected_eth_paths:
|
|
eth = getattr(self.network_service, "ethernet_devices", {}).get(
|
|
eth_path
|
|
)
|
|
if eth is None:
|
|
continue
|
|
try:
|
|
eth.disconnect_by_func(self.on_network_direct_changed)
|
|
except Exception as e:
|
|
logger.error(f"An error occurred: {e}")
|
|
except Exception as e:
|
|
logger.error(f"Error disconnecting network indicator signals: {e}")
|
|
try:
|
|
if hasattr(self, "wifi_widget") and self.wifi_widget:
|
|
self.wifi_widget.destroy()
|
|
if hasattr(self, "wifi_window") and self.wifi_window:
|
|
self.wifi_window.destroy()
|
|
except Exception as e:
|
|
logger.error(f"An error occurred: {e}")
|
|
Box.destroy(self)
|
|
|
|
NetworkIndicator.destroy = net_destroy
|
|
|
|
|
|
# Apply the destroy methods
|
|
add_destroy_to_indicators()
|