Files
SDG-DRIFT/Ax-Shell/modules/metrics.py
T
2026-06-03 21:32:45 +02:00

718 lines
26 KiB
Python

import json
import logging
import subprocess
import time
import psutil
from fabric.core.fabricator import Fabricator
from fabric.utils.helpers import invoke_repeater
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.circularprogressbar import CircularProgressBar
from fabric.widgets.eventbox import EventBox
from fabric.widgets.label import Label
from fabric.widgets.overlay import Overlay
from fabric.widgets.revealer import Revealer
from fabric.widgets.scale import Scale
from gi.repository import GLib
import config.data as data
from modules.upower.upower import UPowerManager
import modules.icons as icons
from services.network import NetworkClient
logger = logging.getLogger(__name__)
class MetricsProvider:
"""
Class responsible for obtaining centralized CPU, memory, disk usage, and battery metrics.
It updates periodically so that all widgets querying it display the same values.
"""
def __init__(self):
self.gpu = []
self.cpu = 0.0
self.mem = 0.0
self.disk = []
self.upower = UPowerManager()
self.display_device = self.upower.get_display_device()
self.bat_percent = 0.0
self.bat_charging = None
self.bat_time = 0
self._gpu_update_running = False
self._gpu_update_counter = 0
GLib.timeout_add_seconds(2, self._update)
def _update(self):
self.cpu = psutil.cpu_percent(interval=0)
self.mem = psutil.virtual_memory().percent
self.disk = [psutil.disk_usage(path).percent for path in data.BAR_METRICS_DISKS]
self._gpu_update_counter += 1
if self._gpu_update_counter >= 5: # Update GPU every 10 seconds (5 * 2s)
self._gpu_update_counter = 0
if not self._gpu_update_running:
self._start_gpu_update_async()
battery = self.upower.get_full_device_information(self.display_device)
if battery is None:
self.bat_percent = 0.0
self.bat_charging = None
self.bat_time = 0
else:
self.bat_percent = battery['Percentage']
self.bat_charging = battery['State'] == 1
self.bat_time = battery['TimeToFull'] if self.bat_charging else battery['TimeToEmpty']
return True
def _start_gpu_update_async(self):
"""Starts a new GLib thread to run nvtop in the background."""
self._gpu_update_running = True
GLib.Thread.new("nvtop-thread", lambda _: self._run_nvtop_in_thread(), None)
def _run_nvtop_in_thread(self):
"""Runs nvtop via subprocess in a separate GLib thread."""
output = None
error_message = None
try:
result = subprocess.check_output(["nvtop", "-s"], text=True, timeout=10)
output = result
except FileNotFoundError:
error_message = "nvtop command not found."
logger.warning(error_message)
except subprocess.CalledProcessError as e:
error_message = f"nvtop failed with exit code {e.returncode}: {e.stderr.strip()}"
logger.error(error_message)
except subprocess.TimeoutExpired:
error_message = "nvtop command timed out."
logger.error(error_message)
except Exception as e:
error_message = f"Unexpected error running nvtop: {e}"
logger.error(error_message)
GLib.idle_add(self._process_gpu_output, output, error_message)
self._gpu_update_running = False
def _process_gpu_output(self, output, error_message):
"""Process nvtop JSON output on the main loop."""
try:
if error_message:
logger.error(f"GPU update failed: {error_message}")
self.gpu = []
elif output:
info = json.loads(output)
try:
self.gpu = [
(
int(v["gpu_util"].strip("%"))
if v["gpu_util"] is not None
else 0
)
for v in info
]
except (KeyError, ValueError, TypeError) as e:
logger.error(f"Failed parsing nvtop JSON: {e}")
self.gpu = []
else:
logger.warning("nvtop returned no output.")
self.gpu = []
except json.JSONDecodeError as e:
logger.error(f"JSON decode error: {e}")
self.gpu = []
except Exception as e:
logger.error(f"Error processing nvtop output: {e}")
self.gpu = []
return False
def get_metrics(self):
return (self.cpu, self.mem, self.disk, self.gpu)
def get_battery(self):
return (self.bat_percent, self.bat_charging, self.bat_time)
def get_gpu_info(self):
try:
result = subprocess.check_output(["nvtop", "-s"], text=True, timeout=5)
return json.loads(result)
except FileNotFoundError:
logger.warning("nvtop not found; GPU info unavailable.")
return []
except subprocess.CalledProcessError as e:
logger.error(f"nvtop init sync failed: {e}")
return []
except subprocess.TimeoutExpired:
logger.error("nvtop init call timed out.")
return []
except json.JSONDecodeError as e:
logger.error(f"Init JSON parse error: {e}")
return []
except Exception as e:
logger.error(f"Unexpected error during GPU init: {e}")
return []
shared_provider = MetricsProvider()
class SingularMetric:
def __init__(self, id, name, icon):
self.usage = Scale(
name=f"{id}-usage",
value=0.25,
orientation='v',
inverted=True,
v_align='fill',
v_expand=True,
)
self.label = Label(
name=f"{id}-label",
markup=icon,
)
self.box = Box(
name=f"{id}-box",
orientation='v',
spacing=8,
children=[
self.usage,
self.label,
]
)
self.box.set_tooltip_markup(f"{icon} {name}")
class Metrics(Box):
def __init__(self, **kwargs):
super().__init__(
name="metrics",
spacing=8,
h_align="center",
v_align="fill",
visible=True,
all_visible=True,
)
visible = getattr(data, "METRICS_VISIBLE", {'cpu': True, 'ram': True, 'disk': True, 'gpu': True})
disks = [SingularMetric("disk", f"DISK ({path})" if len(data.BAR_METRICS_DISKS) != 1 else "DISK", icons.disk)
for path in data.BAR_METRICS_DISKS] if visible.get('disk', True) else []
gpu_info = shared_provider.get_gpu_info()
gpus = [SingularMetric(f"gpu", f"GPU ({v['device_name']})" if len(gpu_info) != 1 else "GPU", icons.gpu)
for v in gpu_info] if visible.get('gpu', True) else []
self.cpu = SingularMetric("cpu", "CPU", icons.cpu) if visible.get('cpu', True) else None
self.ram = SingularMetric("ram", "RAM", icons.memory) if visible.get('ram', True) else None
self.disk = disks
self.gpu = gpus
self.scales = []
if self.disk: self.scales.extend([v.box for v in self.disk])
if self.ram: self.scales.append(self.ram.box)
if self.cpu: self.scales.append(self.cpu.box)
if self.gpu: self.scales.extend([v.box for v in self.gpu])
if self.cpu: self.cpu.usage.set_sensitive(False)
if self.ram: self.ram.usage.set_sensitive(False)
for disk in self.disk:
disk.usage.set_sensitive(False)
for gpu in self.gpu:
gpu.usage.set_sensitive(False)
for x in self.scales:
self.add(x)
GLib.timeout_add_seconds(2, self.update_status)
def update_status(self):
cpu, mem, disks, gpus = shared_provider.get_metrics()
if self.cpu:
self.cpu.usage.value = cpu / 100.0
if self.ram:
self.ram.usage.value = mem / 100.0
for i, disk in enumerate(self.disk):
if i < len(disks):
disk.usage.value = disks[i] / 100.0
for i, gpu in enumerate(self.gpu):
if i < len(gpus):
gpu.usage.value = gpus[i] / 100.0
return True
class SingularMetricSmall:
def __init__(self, id, name, icon):
self.name_markup = name
self.icon_markup = icon
self.icon = Label(name="metrics-icon", markup=icon)
self.circle = CircularProgressBar(
name="metrics-circle",
value=0,
size=28,
line_width=2,
start_angle=150,
end_angle=390,
style_classes=id,
child=self.icon,
)
self.level = Label(name="metrics-level", style_classes=id, label="0%")
self.revealer = Revealer(
name=f"metrics-{id}-revealer",
transition_duration=250,
transition_type="slide-left",
child=self.level,
child_revealed=False,
)
self.box = Box(
name=f"metrics-{id}-box",
orientation="h",
spacing=0,
children=[self.circle, self.revealer],
)
def markup(self):
return f"{self.icon_markup} {self.name_markup}" if not data.VERTICAL else f"{self.icon_markup} {self.name_markup}: {self.level.get_label()}"
class MetricsSmall(Button):
def __init__(self, **kwargs):
super().__init__(name="metrics-small", **kwargs)
main_box = Box(
spacing=0,
orientation="h" if not data.VERTICAL else "v",
visible=True,
all_visible=True,
)
visible = getattr(data, "METRICS_SMALL_VISIBLE", {'cpu': True, 'ram': True, 'disk': True, 'gpu': True})
disks = [SingularMetricSmall("disk", f"DISK ({path})" if len(data.BAR_METRICS_DISKS) != 1 else "DISK", icons.disk)
for path in data.BAR_METRICS_DISKS] if visible.get('disk', True) else []
gpu_info = shared_provider.get_gpu_info()
gpus = [SingularMetricSmall(f"gpu", f"GPU ({v['device_name']})" if len(gpu_info) != 1 else "GPU", icons.gpu)
for v in gpu_info] if visible.get('gpu', True) else []
self.cpu = SingularMetricSmall("cpu", "CPU", icons.cpu) if visible.get('cpu', True) else None
self.ram = SingularMetricSmall("ram", "RAM", icons.memory) if visible.get('ram', True) else None
self.disk = disks
self.gpu = gpus
for disk in self.disk:
main_box.add(disk.box)
main_box.add(Box(name="metrics-sep"))
if self.ram:
main_box.add(self.ram.box)
main_box.add(Box(name="metrics-sep"))
if self.cpu:
main_box.add(self.cpu.box)
for gpu in self.gpu:
main_box.add(Box(name="metrics-sep"))
main_box.add(gpu.box)
self.add(main_box)
self.connect("enter-notify-event", self.on_mouse_enter)
self.connect("leave-notify-event", self.on_mouse_leave)
GLib.timeout_add_seconds(2, self.update_metrics)
self.hide_timer = None
self.hover_counter = 0
def _format_percentage(self, value: int) -> str:
"""Formato natural del porcentaje sin forzar ancho fijo."""
return f"{value}%"
def on_mouse_enter(self, widget, event):
if not data.VERTICAL:
self.hover_counter += 1
if self.hide_timer is not None:
GLib.source_remove(self.hide_timer)
self.hide_timer = None
if self.cpu: self.cpu.revealer.set_reveal_child(True)
if self.ram: self.ram.revealer.set_reveal_child(True)
for disk in self.disk:
disk.revealer.set_reveal_child(True)
for gpu in self.gpu:
gpu.revealer.set_reveal_child(True)
return False
def on_mouse_leave(self, widget, event):
if not data.VERTICAL:
if self.hover_counter > 0:
self.hover_counter -= 1
if self.hover_counter == 0:
if self.hide_timer is not None:
GLib.source_remove(self.hide_timer)
self.hide_timer = GLib.timeout_add(500, self.hide_revealer)
return False
def hide_revealer(self):
if not data.VERTICAL:
if self.cpu: self.cpu.revealer.set_reveal_child(False)
if self.ram: self.ram.revealer.set_reveal_child(False)
for disk in self.disk:
disk.revealer.set_reveal_child(False)
for gpu in self.gpu:
gpu.revealer.set_reveal_child(False)
self.hide_timer = None
return False
def update_metrics(self):
cpu, mem, disks, gpus = shared_provider.get_metrics()
if self.cpu:
self.cpu.circle.set_value(cpu / 100.0)
self.cpu.level.set_label(self._format_percentage(int(cpu)))
if self.ram:
self.ram.circle.set_value(mem / 100.0)
self.ram.level.set_label(self._format_percentage(int(mem)))
for i, disk in enumerate(self.disk):
if i < len(disks):
disk.circle.set_value(disks[i] / 100.0)
disk.level.set_label(self._format_percentage(int(disks[i])))
for i, gpu in enumerate(self.gpu):
if i < len(gpus):
gpu.circle.set_value(gpus[i] / 100.0)
gpu.level.set_label(self._format_percentage(int(gpus[i])))
tooltip_metrics = []
if self.disk: tooltip_metrics.extend(self.disk)
if self.ram: tooltip_metrics.append(self.ram)
if self.cpu: tooltip_metrics.append(self.cpu)
if self.gpu: tooltip_metrics.extend(self.gpu)
self.set_tooltip_markup((" - " if not data.VERTICAL else "\n").join([v.markup() for v in tooltip_metrics]))
return True
class Battery(Button):
def __init__(self, **kwargs):
super().__init__(name="metrics-small", **kwargs)
main_box = Box(
spacing=0,
orientation="h",
visible=True,
all_visible=True,
)
self.bat_icon = Label(name="metrics-icon", markup=icons.battery)
self.bat_circle = CircularProgressBar(
name="metrics-circle",
value=0,
size=28,
line_width=2,
start_angle=150,
end_angle=390,
style_classes="bat",
child=self.bat_icon,
)
self.bat_level = Label(name="metrics-level", style_classes="bat", label="100%")
self.bat_revealer = Revealer(
name="metrics-bat-revealer",
transition_duration=250,
transition_type="slide-left",
child=self.bat_level,
child_revealed=False,
)
self.bat_box = Box(
name="metrics-bat-box",
orientation="h",
spacing=0,
children=[self.bat_circle, self.bat_revealer],
)
main_box.add(self.bat_box)
self.add(main_box)
self.connect("enter-notify-event", self.on_mouse_enter)
self.connect("leave-notify-event", self.on_mouse_leave)
self.batt_fabricator = Fabricator(
poll_from=lambda v: shared_provider.get_battery(),
on_changed=lambda f, v: self.update_battery,
interval=1000,
stream=False,
default_value=0
)
self.batt_fabricator.changed.connect(self.update_battery)
GLib.idle_add(self.update_battery, None, shared_provider.get_battery())
self.hide_timer = None
self.hover_counter = 0
def _format_percentage(self, value: int) -> str:
"""Formato natural del porcentaje sin forzar ancho fijo."""
return f"{value}%"
def on_mouse_enter(self, widget, event):
if not data.VERTICAL:
self.hover_counter += 1
if self.hide_timer is not None:
GLib.source_remove(self.hide_timer)
self.hide_timer = None
self.bat_revealer.set_reveal_child(True)
return False
def on_mouse_leave(self, widget, event):
if not data.VERTICAL:
if self.hover_counter > 0:
self.hover_counter -= 1
if self.hover_counter == 0:
if self.hide_timer is not None:
GLib.source_remove(self.hide_timer)
self.hide_timer = GLib.timeout_add(500, self.hide_revealer)
return False
def hide_revealer(self):
if not data.VERTICAL:
self.bat_revealer.set_reveal_child(False)
self.hide_timer = None
return False
def update_battery(self, sender, battery_data):
value, charging, time = battery_data
if value == 0:
self.set_visible(False)
else:
self.set_visible(True)
self.bat_circle.set_value(value / 100)
percentage = int(value)
self.bat_level.set_label(self._format_percentage(percentage))
if percentage <= 15:
self.bat_icon.add_style_class("alert")
self.bat_circle.add_style_class("alert")
else:
self.bat_icon.remove_style_class("alert")
self.bat_circle.remove_style_class("alert")
if time < 60:
time_status = f"{int(time)}sec"
elif time < 60 * 60:
time_status = f"{int(time / 60)}min"
else:
time_status = f"{int(time / 60 / 60)}h"
if percentage == 100 and charging == False:
self.bat_icon.set_markup(icons.battery)
charging_status = f"{icons.bat_full} Fully Charged - {time_status} left"
elif percentage == 100 and charging == True:
self.bat_icon.set_markup(icons.battery)
charging_status = f"{icons.bat_full} Fully Charged"
elif charging == True:
self.bat_icon.set_markup(icons.charging)
charging_status = f"{icons.bat_charging} Charging - {time_status} left"
elif percentage <= 15 and charging == False:
self.bat_icon.set_markup(icons.alert)
charging_status = f"{icons.bat_low} Low Battery - {time_status} left"
elif charging == False:
self.bat_icon.set_markup(icons.discharging)
charging_status = f"{icons.bat_discharging} Discharging - {time_status} left"
else:
self.bat_icon.set_markup(icons.battery)
charging_status = "Battery"
self.set_tooltip_markup(f"{charging_status}" if not data.VERTICAL else f"{charging_status}: {percentage}%")
class NetworkApplet(Button):
def __init__(self, **kwargs):
super().__init__(name="button-bar", **kwargs)
self.download_label = Label(name="download-label", markup="Download: 0 B/s")
self.network_client = NetworkClient()
self.upload_label = Label(name="upload-label", markup="Upload: 0 B/s")
self.wifi_label = Label(name="network-icon-label", markup="WiFi: Unknown")
self.is_mouse_over = False
self.downloading = False
self.uploading = False
self.download_icon = Label(name="download-icon-label", markup=icons.download, v_align="center", h_align="center", h_expand=True, v_expand=True)
self.upload_icon = Label(name="upload-icon-label", markup=icons.upload, v_align="center", h_align="center", h_expand=True, v_expand=True)
self.download_box = Box(
children=[self.download_icon, self.download_label],
)
self.upload_box = Box(
children=[self.upload_label, self.upload_icon],
)
self.download_revealer = Revealer(child=self.download_box, transition_type = "slide-right" if not data.VERTICAL else "slide-down", child_revealed=False)
self.upload_revealer = Revealer(child=self.upload_box, transition_type="slide-left" if not data.VERTICAL else "slide-up",child_revealed=False)
self.children = Box(
orientation="h" if not data.VERTICAL else "v",
children=[self.upload_revealer, self.wifi_label, self.download_revealer],
)
if data.VERTICAL:
self.download_label.set_visible(False)
self.upload_label.set_visible(False)
self.upload_icon.set_margin_top(4)
self.download_icon.set_margin_bottom(4)
self.last_counters = psutil.net_io_counters()
self.last_time = time.time()
invoke_repeater(1000, self.update_network)
self.connect("enter-notify-event", self.on_mouse_enter)
self.connect("leave-notify-event", self.on_mouse_leave)
def update_network(self):
current_time = time.time()
elapsed = current_time - self.last_time
current_counters = psutil.net_io_counters()
download_speed = (current_counters.bytes_recv - self.last_counters.bytes_recv) / elapsed
upload_speed = (current_counters.bytes_sent - self.last_counters.bytes_sent) / elapsed
download_str = self.format_speed(download_speed)
upload_str = self.format_speed(upload_speed)
self.download_label.set_markup(download_str)
self.upload_label.set_markup(upload_str)
self.downloading = (download_speed >= 10e6)
self.uploading = (upload_speed >= 2e6)
if not self.is_mouse_over:
if self.downloading:
self.download_urgent()
elif self.uploading:
self.upload_urgent()
else:
self.remove_urgent()
show_download = self.downloading or (self.is_mouse_over and not data.VERTICAL)
show_upload = self.uploading or (self.is_mouse_over and not data.VERTICAL)
self.download_revealer.set_reveal_child(show_download)
self.upload_revealer.set_reveal_child(show_upload)
primary_device = None
if self.network_client:
primary_device = self.network_client.primary_device
tooltip_base = ""
tooltip_vertical = ""
if primary_device == "wired" and self.network_client.ethernet_device:
ethernet_state = self.network_client.ethernet_device.internet
if ethernet_state == "activated":
self.wifi_label.set_markup(icons.world)
elif ethernet_state == "activating":
self.wifi_label.set_markup(icons.world)
else:
self.wifi_label.set_markup(icons.world_off)
tooltip_base = "Ethernet Connection"
tooltip_vertical = f"SSID: Ethernet\nUpload: {upload_str}\nDownload: {download_str}"
elif self.network_client and self.network_client.wifi_device:
if self.network_client.wifi_device.ssid != "Disconnected":
strength = self.network_client.wifi_device.strength
if strength >= 75:
self.wifi_label.set_markup(icons.wifi_3)
elif strength >= 50:
self.wifi_label.set_markup(icons.wifi_2)
elif strength >= 25:
self.wifi_label.set_markup(icons.wifi_1)
else:
self.wifi_label.set_markup(icons.wifi_0)
tooltip_base = self.network_client.wifi_device.ssid
tooltip_vertical = f"SSID: {self.network_client.wifi_device.ssid}\nUpload: {upload_str}\nDownload: {download_str}"
else:
self.wifi_label.set_markup(icons.world_off)
tooltip_base = "Disconnected"
tooltip_vertical = f"SSID: Disconnected\nUpload: {upload_str}\nDownload: {download_str}"
else:
self.wifi_label.set_markup(icons.world_off)
tooltip_base = "Disconnected"
tooltip_vertical = f"SSID: Disconnected\nUpload: {upload_str}\nDownload: {download_str}"
if data.VERTICAL:
self.set_tooltip_text(tooltip_vertical)
else:
self.set_tooltip_text(tooltip_base)
self.last_counters = current_counters
self.last_time = current_time
return True
def format_speed(self, speed):
if speed < 1024:
return f"{speed:.0f} B/s"
elif speed < 1024 * 1024:
return f"{speed / 1024:.1f} KB/s"
else:
return f"{speed / (1024 * 1024):.1f} MB/s"
def on_mouse_enter(self, *_):
self.is_mouse_over = True
if not data.VERTICAL:
self.download_revealer.set_reveal_child(True)
self.upload_revealer.set_reveal_child(True)
return
def on_mouse_leave(self, *_):
self.is_mouse_over = False
if not data.VERTICAL:
self.download_revealer.set_reveal_child(self.downloading)
self.upload_revealer.set_reveal_child(self.uploading)
if self.downloading:
self.download_urgent()
elif self.uploading:
self.upload_urgent()
else:
self.remove_urgent()
return
def upload_urgent(self):
self.add_style_class("upload")
self.wifi_label.add_style_class("urgent")
self.upload_label.add_style_class("urgent")
self.upload_icon.add_style_class("urgent")
self.download_icon.add_style_class("urgent")
self.download_label.add_style_class("urgent")
self.upload_revealer.set_reveal_child(True)
self.download_revealer.set_reveal_child(self.downloading)
return
def download_urgent(self):
self.add_style_class("download")
self.wifi_label.add_style_class("urgent")
self.download_label.add_style_class("urgent")
self.download_icon.add_style_class("urgent")
self.upload_icon.add_style_class("urgent")
self.upload_label.add_style_class("urgent")
self.download_revealer.set_reveal_child(True)
self.upload_revealer.set_reveal_child(self.uploading)
return
def remove_urgent(self):
self.remove_style_class("download")
self.remove_style_class("upload")
self.wifi_label.remove_style_class("urgent")
self.download_label.remove_style_class("urgent")
self.upload_label.remove_style_class("urgent")
self.download_icon.remove_style_class("urgent")
self.upload_icon.remove_style_class("urgent")
return