import json import logging import subprocess import psutil from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.circularprogressbar import CircularProgressBar from fabric.widgets.label import Label from fabric.widgets.revealer import Revealer from gi.repository import GLib import config.data as data import utils.icons as icons 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.swap = 0.0 self.disk = [] self.bat_percent = 0.0 self.bat_charging = None self.bat_time = 0 self._gpu_update_running = False GLib.timeout_add_seconds(1, self._update) def _update(self): self.cpu = psutil.cpu_percent(interval=0) self.mem = psutil.virtual_memory().percent self.swap = psutil.swap_memory().percent self.disk = [psutil.disk_usage(path).percent for path in data.METRICS_DISKS] if not self._gpu_update_running: self._start_gpu_update_async() 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: try: info = json.loads(output) if not info or not isinstance(info, list): logger.warning("Invalid nvtop data format") self.gpu = [] return False gpu_utils = [] for gpu in info: if not isinstance(gpu, dict): continue util = gpu.get("gpu_util", "0%") if not isinstance(util, str): continue try: # Remove % and convert to int, default to 0 if fails util_value = int(util.rstrip("%") or 0) gpu_utils.append(util_value) except (ValueError, TypeError): continue self.gpu = gpu_utils if gpu_utils else [] except (KeyError, ValueError, TypeError, AttributeError) 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.swap, 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) if not result.strip(): return [] info = json.loads(result) if not info or not isinstance(info, list): return [] valid_gpus = [] for gpu in info: if not isinstance(gpu, dict): continue gpu_data = { 'device_name': gpu.get('device_name', 'GPU'), 'gpu_util': gpu.get('gpu_util', '0%'), 'mem_util': gpu.get('mem_util', '0%'), 'temperature': gpu.get('temperature', '0 C'), 'power': gpu.get('power', '0 W') } valid_gpus.append(gpu_data) return valid_gpus 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.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}: {self.level.get_label()}" class Metrics(Button): def __init__(self, **kwargs): super().__init__(name="metrics", **kwargs) main_box = Box( spacing=0, orientation="h" if not data.VERTICAL else "v", visible=True, all_visible=True, ) visible = getattr(data, "METRICS_VISIBLE", {'cpu': True, 'ram': True, 'swap': True, 'disk': True, 'gpu': True}) disks = [SingularMetric("disk", f"DISK ({path})" if len(data.METRICS_DISKS) != 1 else "DISK", icons.disk) for path in data.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.swap = SingularMetric("swap", "SWAP", icons.swap) if visible.get('swap', 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.swap: main_box.add(self.swap.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("clicked", self.on_click) self.labels_visible = False GLib.timeout_add_seconds(1, self.update_metrics) def _format_percentage(self, value: int) -> str: """Formato natural del porcentaje sin forzar ancho fijo.""" return f"{value}%" def on_click(self, widget): if not data.VERTICAL: self.labels_visible = not self.labels_visible if self.cpu: self.cpu.revealer.set_reveal_child(self.labels_visible) if self.ram: self.ram.revealer.set_reveal_child(self.labels_visible) if self.swap: self.swap.revealer.set_reveal_child(self.labels_visible) for disk in self.disk: disk.revealer.set_reveal_child(self.labels_visible) for gpu in self.gpu: gpu.revealer.set_reveal_child(self.labels_visible) def update_metrics(self): cpu, mem, swap, 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))) if self.swap: self.swap.circle.set_value(swap / 100.0) self.swap.level.set_label(self._format_percentage(int(swap))) 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.swap: tooltip_metrics.append(self.swap) 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