#!/usr/bin/env python3 """ Enhanced GPU Desktop Monitor Provides multiple display modes for GPU monitoring: - Overlay: Floating transparent window - System Tray: Minimal tray icon with tooltips - Dashboard: Full-featured desktop application """ import sys import os import time import json import logging import threading from typing import Dict, List, Optional, Tuple from dataclasses import dataclass from pathlib import Path from PyQt5.QtWidgets import ( QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QSystemTrayIcon, QMenu, QAction, QMainWindow, QTabWidget, QGridLayout, QGroupBox, QProgressBar, QMessageBox, QComboBox, QCheckBox, QSpinBox, QDoubleSpinBox, QFrame, QScrollArea ) from PyQt5.QtCore import QTimer, Qt, QPoint, QThread, pyqtSignal, QObject from PyQt5.QtGui import QIcon, QFont, QColor, QPainter, QPen, QPixmap import matplotlib.pyplot as plt from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas import numpy as np from gpu_monitoring import GPUManager, GPUStatus from gpu_fan_controller import FanController, FanMode, ProfileType logger = logging.getLogger(__name__) class GPUDataThread(QThread): """Background thread for collecting GPU data.""" data_updated = pyqtSignal(dict) error_occurred = pyqtSignal(str) def __init__(self, gpu_manager: GPUManager, update_interval: float = 1.0): super().__init__() self.gpu_manager = gpu_manager self.update_interval = update_interval self.running = False def run(self): """Main thread loop.""" self.running = True while self.running: try: status = self.gpu_manager.get_status() self.data_updated.emit(status) time.sleep(self.update_interval) except Exception as e: self.error_occurred.emit(str(e)) time.sleep(1) # Wait before retrying def stop(self): """Stop the thread.""" self.running = False self.wait() class FanControlThread(QThread): """Background thread for fan control.""" status_updated = pyqtSignal(object) def __init__(self, fan_controller: FanController, update_interval: float = 2.0): super().__init__() self.fan_controller = fan_controller self.update_interval = update_interval self.running = False def run(self): """Main thread loop.""" self.running = True while self.running: try: status = self.fan_controller.get_status() if status: self.status_updated.emit(status) time.sleep(self.update_interval) except Exception as e: logger.error(f"Fan control thread error: {e}") time.sleep(1) def stop(self): """Stop the thread.""" self.running = False self.wait() class GPUOverlayWindow(QWidget): """Floating overlay window for GPU monitoring.""" def __init__(self, config: Dict): super().__init__() self.config = config self.gpu_manager = GPUManager() self.fan_controller = FanController() self.init_ui() self.init_data_collection() # Window properties self.setWindowFlags(Qt.WindowStaysOnTopHint | Qt.FramelessWindowHint | Qt.Tool) self.setAttribute(Qt.WA_TranslucentBackground) self.setFixedSize(200, 160) # Drag functionality self.old_pos = None # Start monitoring self.gpu_manager.initialize() self.fan_controller.initialize() def init_ui(self): """Initialize the overlay UI.""" # Main layout layout = QVBoxLayout() layout.setContentsMargins(10, 10, 10, 10) layout.setSpacing(5) # Title self.lbl_title = QLabel("GPU Monitor") self.lbl_title.setStyleSheet(""" QLabel { color: #3498db; font-size: 14px; font-weight: bold; border-bottom: 1px solid #3498db; padding-bottom: 5px; } """) layout.addWidget(self.lbl_title) # GPU Info self.lbl_gpu = QLabel("GPU: --") self.lbl_gpu.setStyleSheet("color: #ecf0f1; font-size: 12px;") layout.addWidget(self.lbl_gpu) # Temperature temp_layout = QHBoxLayout() self.lbl_temp_label = QLabel("Temp:") self.lbl_temp_label.setStyleSheet("color: #ecf0f1; font-size: 12px;") self.lbl_temp_value = QLabel("--°C") self.lbl_temp_value.setStyleSheet("color: #e74c3c; font-size: 12px; font-weight: bold;") temp_layout.addWidget(self.lbl_temp_label) temp_layout.addStretch() temp_layout.addWidget(self.lbl_temp_value) layout.addLayout(temp_layout) # Load load_layout = QHBoxLayout() self.lbl_load_label = QLabel("Load:") self.lbl_load_label.setStyleSheet("color: #ecf0f1; font-size: 12px;") self.lbl_load_value = QLabel("--%") self.lbl_load_value.setStyleSheet("color: #f1c40f; font-size: 12px; font-weight: bold;") load_layout.addWidget(self.lbl_load_label) load_layout.addStretch() load_layout.addWidget(self.lbl_load_value) layout.addLayout(load_layout) # Fan fan_layout = QHBoxLayout() self.lbl_fan_label = QLabel("Fan:") self.lbl_fan_label.setStyleSheet("color: #ecf0f1; font-size: 12px;") self.lbl_fan_value = QLabel("-- RPM") self.lbl_fan_value.setStyleSheet("color: #2ecc71; font-size: 12px; font-weight: bold;") fan_layout.addWidget(self.lbl_fan_label) fan_layout.addStretch() fan_layout.addWidget(self.lbl_fan_value) layout.addLayout(fan_layout) # Power power_layout = QHBoxLayout() self.lbl_power_label = QLabel("Power:") self.lbl_power_label.setStyleSheet("color: #ecf0f1; font-size: 12px;") self.lbl_power_value = QLabel("-- W") self.lbl_power_value.setStyleSheet("color: #9b59b6; font-size: 12px; font-weight: bold;") power_layout.addWidget(self.lbl_power_label) power_layout.addStretch() power_layout.addWidget(self.lbl_power_value) layout.addLayout(power_layout) # Memory mem_layout = QHBoxLayout() self.lbl_mem_label = QLabel("VRAM:") self.lbl_mem_label.setStyleSheet("color: #ecf0f1; font-size: 12px;") self.lbl_mem_value = QLabel("-- MB") self.lbl_mem_value.setStyleSheet("color: #1abc9c; font-size: 12px; font-weight: bold;") mem_layout.addWidget(self.lbl_mem_label) mem_layout.addStretch() mem_layout.addWidget(self.lbl_mem_value) layout.addLayout(mem_layout) # Close button self.btn_close = QPushButton("×") self.btn_close.setFixedSize(20, 20) self.btn_close.setStyleSheet(""" QPushButton { background-color: transparent; color: #e74c3c; font-size: 18px; font-weight: bold; border: none; margin: 0; padding: 0; } QPushButton:hover { color: #ff0000; } """) self.btn_close.clicked.connect(self.close) # Add close button to layout close_layout = QHBoxLayout() close_layout.addStretch() close_layout.addWidget(self.btn_close) layout.addLayout(close_layout) self.setLayout(layout) # Apply styling self.setStyleSheet(""" QWidget { background-color: rgba(0, 0, 0, 200); border-radius: 8px; border: 1px solid #3498db; } """) def init_data_collection(self): """Initialize data collection threads.""" # GPU data thread self.gpu_thread = GPUDataThread(self.gpu_manager, self.config['update_interval']) self.gpu_thread.data_updated.connect(self.update_gpu_data) self.gpu_thread.error_occurred.connect(self.handle_error) self.gpu_thread.start() # Fan control thread self.fan_thread = FanControlThread(self.fan_controller, 2.0) self.fan_thread.status_updated.connect(self.update_fan_data) self.fan_thread.start() def update_gpu_data(self, status_dict: Dict[str, Optional[GPUStatus]]): """Update GPU data display.""" for gpu_name, gpu_status in status_dict.items(): if gpu_status: # Update GPU name self.lbl_gpu.setText(f"GPU: {gpu_name}") # Update temperature temp_color = self.get_temp_color(gpu_status.temperature) self.lbl_temp_value.setText(f"{gpu_status.temperature:.1f}°C") self.lbl_temp_value.setStyleSheet(f"color: {temp_color}; font-size: 12px; font-weight: bold;") # Update load load_color = self.get_load_color(gpu_status.load) self.lbl_load_value.setText(f"{gpu_status.load:.1f}%") self.lbl_load_value.setStyleSheet(f"color: {load_color}; font-size: 12px; font-weight: bold;") # Update fan fan_rpm = self.calculate_fan_rpm(gpu_status.fan_pwm) self.lbl_fan_value.setText(f"{fan_rpm} RPM ({gpu_status.fan_pwm}%)") # Update power self.lbl_power_value.setText(f"{gpu_status.power_draw:.1f} W") # Update memory self.lbl_mem_value.setText(f"{gpu_status.memory_used}/{gpu_status.memory_total} MB") break # Only show first GPU def update_fan_data(self, fan_status): """Update fan control data.""" # Fan status updates can be handled here if needed pass def handle_error(self, error_msg: str): """Handle data collection errors.""" logger.error(f"Data collection error: {error_msg}") # Could show error notification here def get_temp_color(self, temperature: float) -> str: """Get color based on temperature.""" if temperature < 60: return "#2ecc71" # Green elif temperature < 75: return "#f1c40f" # Yellow else: return "#e74c3c" # Red def get_load_color(self, load: float) -> str: """Get color based on load.""" if load < 50: return "#2ecc71" # Green elif load < 80: return "#f1c40f" # Yellow else: return "#e74c3c" # Red def calculate_fan_rpm(self, pwm: int) -> int: """Calculate approximate RPM from PWM.""" return int((pwm / 255) * 4000) def mousePressEvent(self, event): """Handle mouse press for dragging.""" if event.button() == Qt.LeftButton: self.old_pos = event.globalPos() def mouseMoveEvent(self, event): """Handle mouse move for dragging.""" if self.old_pos is not None: delta = QPoint(event.globalPos() - self.old_pos) self.move(self.x() + delta.x(), self.y() + delta.y()) self.old_pos = event.globalPos() def mouseReleaseEvent(self, event): """Handle mouse release.""" self.old_pos = None def closeEvent(self, event): """Clean up threads on close.""" self.gpu_thread.stop() self.fan_thread.stop() super().closeEvent(event) class SystemTrayMonitor(QSystemTrayIcon): """System tray icon for GPU monitoring.""" def __init__(self, config: Dict): super().__init__() self.config = config self.gpu_manager = GPUManager() self.fan_controller = FanController() self.init_ui() self.init_data_collection() # Start monitoring self.gpu_manager.initialize() self.fan_controller.initialize() def init_ui(self): """Initialize system tray UI.""" # Create icon self.setIcon(QIcon.fromTheme("video-display")) # Create menu menu = QMenu() # Status actions self.action_status = QAction("Status: --", self) self.action_status.setEnabled(False) menu.addAction(self.action_status) menu.addSeparator() # Profile actions self.profile_group = QAction("Profiles", self) menu.addAction(self.profile_group) self.action_silent = QAction("Silent", self) self.action_silent.triggered.connect(lambda: self.set_profile("silent")) menu.addAction(self.action_silent) self.action_balanced = QAction("Balanced", self) self.action_balanced.triggered.connect(lambda: self.set_profile("balanced")) menu.addAction(self.action_balanced) self.action_performance = QAction("Performance", self) self.action_performance.triggered.connect(lambda: self.set_profile("performance")) menu.addAction(self.action_performance) menu.addSeparator() # Open dashboard self.action_dashboard = QAction("Open Dashboard", self) self.action_dashboard.triggered.connect(self.open_dashboard) menu.addAction(self.action_dashboard) menu.addSeparator() # Exit action self.action_exit = QAction("Exit", self) self.action_exit.triggered.connect(QApplication.instance().quit) menu.addAction(self.action_exit) self.setContextMenu(menu) self.activated.connect(self.on_tray_activated) self.show() def init_data_collection(self): """Initialize data collection threads.""" # GPU data thread self.gpu_thread = GPUDataThread(self.gpu_manager, self.config['update_interval']) self.gpu_thread.data_updated.connect(self.update_tray_tooltip) self.gpu_thread.start() def update_tray_tooltip(self, status_dict: Dict[str, Optional[GPUStatus]]): """Update system tray tooltip.""" tooltip = "GPU Monitor\n" for gpu_name, gpu_status in status_dict.items(): if gpu_status: tooltip += f"\n{gpu_name}:" tooltip += f"\n Temp: {gpu_status.temperature:.1f}°C" tooltip += f"\n Load: {gpu_status.load:.1f}%" tooltip += f"\n Fan: {self.calculate_fan_rpm(gpu_status.fan_pwm)} RPM" tooltip += f"\n Power: {gpu_status.power_draw:.1f} W" tooltip += f"\n VRAM: {gpu_status.memory_used}/{gpu_status.memory_total} MB" break self.setToolTip(tooltip) # Update menu status if gpu_status: self.action_status.setText(f"Status: {gpu_status.temperature:.1f}°C, {gpu_status.load:.1f}%") def on_tray_activated(self, reason): """Handle tray icon activation.""" if reason == QSystemTrayIcon.Trigger: # Show/hide dashboard pass def set_profile(self, profile_name: str): """Set fan profile.""" if self.fan_controller.set_profile(profile_name): QMessageBox.information(None, "Profile Changed", f"Switched to {profile_name} profile") def open_dashboard(self): """Open the main dashboard window.""" # This would need to be implemented to show the main window pass def calculate_fan_rpm(self, pwm: int) -> int: """Calculate approximate RPM from PWM.""" return int((pwm / 255) * 4000) def closeEvent(self, event): """Clean up threads on close.""" self.gpu_thread.stop() super().closeEvent(event) class DashboardWindow(QMainWindow): """Full-featured dashboard window.""" def __init__(self, config: Dict): super().__init__() self.config = config self.gpu_manager = GPUManager() self.fan_controller = FanController() self.init_ui() self.init_data_collection() # Start monitoring self.gpu_manager.initialize() self.fan_controller.initialize() self.setWindowTitle("GPU Monitoring Dashboard") self.setGeometry(100, 100, 800, 600) def init_ui(self): """Initialize dashboard UI.""" # Main widget and layout main_widget = QWidget() main_layout = QVBoxLayout() # Tab widget self.tabs = QTabWidget() # Overview tab self.overview_tab = self.create_overview_tab() self.tabs.addTab(self.overview_tab, "Overview") # Charts tab self.charts_tab = self.create_charts_tab() self.tabs.addTab(self.charts_tab, "Charts") # Fan Control tab self.fan_tab = self.create_fan_tab() self.tabs.addTab(self.fan_tab, "Fan Control") # Settings tab self.settings_tab = self.create_settings_tab() self.tabs.addTab(self.settings_tab, "Settings") main_layout.addWidget(self.tabs) main_widget.setLayout(main_layout) self.setCentralWidget(main_widget) def create_overview_tab(self): """Create the overview tab.""" widget = QWidget() layout = QGridLayout() # GPU Information Group gpu_group = QGroupBox("GPU Information") gpu_layout = QGridLayout() self.lbl_gpu_name = QLabel("--") self.lbl_gpu_name.setStyleSheet("font-weight: bold; font-size: 16px;") gpu_layout.addWidget(QLabel("GPU:"), 0, 0) gpu_layout.addWidget(self.lbl_gpu_name, 0, 1) self.lbl_temp = QLabel("-- °C") self.lbl_temp.setStyleSheet("font-size: 14px; color: #e74c3c;") gpu_layout.addWidget(QLabel("Temperature:"), 1, 0) gpu_layout.addWidget(self.lbl_temp, 1, 1) self.lbl_load = QLabel("-- %") self.lbl_load.setStyleSheet("font-size: 14px; color: #f1c40f;") gpu_layout.addWidget(QLabel("Load:"), 2, 0) gpu_layout.addWidget(self.lbl_load, 2, 1) self.lbl_power = QLabel("-- W") self.lbl_power.setStyleSheet("font-size: 14px; color: #9b59b6;") gpu_layout.addWidget(QLabel("Power:"), 3, 0) gpu_layout.addWidget(self.lbl_power, 3, 1) self.lbl_vram = QLabel("-- MB") self.lbl_vram.setStyleSheet("font-size: 14px; color: #1abc9c;") gpu_layout.addWidget(QLabel("VRAM:"), 4, 0) gpu_layout.addWidget(self.lbl_vram, 4, 1) gpu_group.setLayout(gpu_layout) layout.addWidget(gpu_group, 0, 0) # Fan Information Group fan_group = QGroupBox("Fan Control") fan_layout = QGridLayout() self.lbl_fan_mode = QLabel("--") fan_layout.addWidget(QLabel("Mode:"), 0, 0) fan_layout.addWidget(self.lbl_fan_mode, 0, 1) self.lbl_fan_profile = QLabel("--") fan_layout.addWidget(QLabel("Profile:"), 1, 0) fan_layout.addWidget(self.lbl_fan_profile, 1, 1) self.lbl_fan_speed = QLabel("-- RPM") fan_layout.addWidget(QLabel("Speed:"), 2, 0) fan_layout.addWidget(self.lbl_fan_speed, 2, 1) self.lbl_fan_pwm = QLabel("-- %") fan_layout.addWidget(QLabel("PWM:"), 3, 0) fan_layout.addWidget(self.lbl_fan_pwm, 3, 1) fan_group.setLayout(fan_layout) layout.addWidget(fan_group, 0, 1) widget.setLayout(layout) return widget def create_charts_tab(self): """Create the charts tab.""" widget = QWidget() layout = QVBoxLayout() # Create matplotlib figure self.figure, self.axes = plt.subplots(2, 2, figsize=(10, 8)) self.canvas = FigureCanvas(self.figure) layout.addWidget(self.canvas) widget.setLayout(layout) return widget def create_fan_tab(self): """Create the fan control tab.""" widget = QWidget() layout = QVBoxLayout() # Profile selection profile_layout = QHBoxLayout() profile_layout.addWidget(QLabel("Select Profile:")) self.combo_profile = QComboBox() self.combo_profile.addItems(["Silent", "Balanced", "Performance"]) self.combo_profile.currentTextChanged.connect(self.on_profile_changed) profile_layout.addWidget(self.combo_profile) layout.addLayout(profile_layout) # Manual control manual_group = QGroupBox("Manual Control") manual_layout = QHBoxLayout() self.spin_manual_pwm = QSpinBox() self.spin_manual_pwm.setRange(0, 255) self.spin_manual_pwm.setValue(0) manual_layout.addWidget(QLabel("Manual PWM:")) manual_layout.addWidget(self.spin_manual_pwm) self.btn_set_manual = QPushButton("Set Manual") self.btn_set_manual.clicked.connect(self.on_set_manual) manual_layout.addWidget(self.btn_set_manual) manual_group.setLayout(manual_layout) layout.addWidget(manual_group) widget.setLayout(layout) return widget def create_settings_tab(self): """Create the settings tab.""" widget = QWidget() layout = QVBoxLayout() # Update interval interval_layout = QHBoxLayout() interval_layout.addWidget(QLabel("Update Interval (seconds):")) self.spin_interval = QDoubleSpinBox() self.spin_interval.setRange(0.1, 10.0) self.spin_interval.setValue(self.config['update_interval']) self.spin_interval.valueChanged.connect(self.on_interval_changed) interval_layout.addWidget(self.spin_interval) layout.addLayout(interval_layout) # Display options display_group = QGroupBox("Display Options") display_layout = QVBoxLayout() self.chk_show_temp = QCheckBox("Show Temperature") self.chk_show_temp.setChecked(self.config['show_temperature']) display_layout.addWidget(self.chk_show_temp) self.chk_show_load = QCheckBox("Show Load") self.chk_show_load.setChecked(self.config['show_gpu_load']) display_layout.addWidget(self.chk_show_load) self.chk_show_fan = QCheckBox("Show Fan Speed") self.chk_show_fan.setChecked(self.config['show_fan_speed']) display_layout.addWidget(self.chk_show_fan) display_group.setLayout(display_layout) layout.addWidget(display_group) widget.setLayout(layout) return widget def init_data_collection(self): """Initialize data collection threads.""" # GPU data thread self.gpu_thread = GPUDataThread(self.gpu_manager, self.config['update_interval']) self.gpu_thread.data_updated.connect(self.update_dashboard) self.gpu_thread.start() # Fan control thread self.fan_thread = FanControlThread(self.fan_controller, 2.0) self.fan_thread.status_updated.connect(self.update_fan_status) self.fan_thread.start() def update_dashboard(self, status_dict: Dict[str, Optional[GPUStatus]]): """Update dashboard display.""" for gpu_name, gpu_status in status_dict.items(): if gpu_status: # Update GPU info self.lbl_gpu_name.setText(gpu_name) self.lbl_temp.setText(f"{gpu_status.temperature:.1f} °C") self.lbl_load.setText(f"{gpu_status.load:.1f} %") self.lbl_power.setText(f"{gpu_status.power_draw:.1f} W") self.lbl_vram.setText(f"{gpu_status.memory_used}/{gpu_status.memory_total} MB") break def update_fan_status(self, fan_status): """Update fan status display.""" if fan_status: self.lbl_fan_mode.setText(fan_status.mode.value) self.lbl_fan_profile.setText(fan_status.profile) self.lbl_fan_speed.setText(f"{self.calculate_fan_rpm(fan_status.current_pwm)} RPM") self.lbl_fan_pwm.setText(f"{fan_status.current_pwm}%") def on_profile_changed(self, profile_name: str): """Handle profile change.""" self.fan_controller.set_profile(profile_name.lower()) def on_set_manual(self): """Handle manual PWM setting.""" pwm = self.spin_manual_pwm.value() self.fan_controller.set_manual_pwm(pwm) def on_interval_changed(self, value: float): """Handle update interval change.""" self.config['update_interval'] = value self.gpu_thread.update_interval = value def calculate_fan_rpm(self, pwm: int) -> int: """Calculate approximate RPM from PWM.""" return int((pwm / 255) * 4000) def closeEvent(self, event): """Clean up threads on close.""" self.gpu_thread.stop() self.fan_thread.stop() super().closeEvent(event) class GPUApplication: """Main application class.""" def __init__(self): self.config = self.load_config() self.app = QApplication(sys.argv) # Set application style self.app.setStyle("Fusion") # Create appropriate window based on mode if self.config['display_mode'] == 'overlay': self.window = GPUOverlayWindow(self.config) elif self.config['display_mode'] == 'tray': self.window = SystemTrayMonitor(self.config) else: # dashboard self.window = DashboardWindow(self.config) def load_config(self) -> Dict: """Load configuration from file.""" config_path = Path("config/monitoring.json") if config_path.exists(): with open(config_path, 'r') as f: return json.load(f) else: # Return default config return { "update_interval": 1.0, "display_mode": "overlay", "show_gpu_load": True, "show_temperature": True, "show_fan_speed": True, "show_power": True, "show_vram": True } def run(self): """Run the application.""" if hasattr(self.window, 'show'): self.window.show() sys.exit(self.app.exec_()) if __name__ == "__main__": app = GPUApplication() app.run()