Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 73 additions & 23 deletions src/core/sensors.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,47 @@
import platform
import shutil
import subprocess
import threading
import time
from typing import Any

import psutil

logger = logging.getLogger(__name__)

# A Windows GPU-counter probe slower than this serves cached values between
# rare retries instead of stalling every sample.
GPU_PROBE_SLOW_SECONDS = 0.2
GPU_PROBE_BACKOFF_SECONDS = 30.0


class SensorReader:
"""Collect metrics and calculate network throughput between reads."""

def __init__(self) -> None:
self._network = psutil.net_io_counters()
self._network_time = time.monotonic()
self._nvidia_smi = shutil.which("nvidia-smi") # PATH scan once; presence is fixed per run.
self._wmi_cimv2 = None # COM objects are created lazily and reused per thread.
self._wmi_thermal = None
self._gpu_values = {"usage": None, "temperature": None}
self._gpu_next_probe = 0.0

@staticmethod
def _temperatures() -> dict[str, float | None]:
def _cimv2(self):
if self._wmi_cimv2 is None:
import wmi # type: ignore[import-not-found]

self._wmi_cimv2 = wmi.WMI(namespace=r"root\cimv2")
return self._wmi_cimv2

def _thermal(self):
if self._wmi_thermal is None:
import wmi # type: ignore[import-not-found]

self._wmi_thermal = wmi.WMI(namespace=r"root\wmi")
return self._wmi_thermal

def _temperatures(self) -> dict[str, float | None]:
cpu: float | None = None
gpu: float | None = None
try:
Expand All @@ -39,19 +63,16 @@ def _temperatures() -> dict[str, float | None]:

if platform.system() == "Windows" and cpu is None:
try:
import wmi # type: ignore[import-not-found]

readings = wmi.WMI(namespace=r"root\wmi").MSAcpi_ThermalZoneTemperature()
readings = self._thermal().MSAcpi_ThermalZoneTemperature()
if readings:
cpu = round((float(readings[0].CurrentTemperature) / 10) - 273.15, 1)
except Exception as error: # noqa: BLE001 - WMI exposes provider-specific COM errors.
logger.debug("Windows temperature sensor unavailable: %s", error)
return {"cpu": cpu, "gpu": gpu}

@staticmethod
def _gpu() -> dict[str, float | None]:
def _gpu(self) -> dict[str, float | None]:
"""Read NVIDIA CLI metrics, then vendor-neutral Windows counters."""
if shutil.which("nvidia-smi"):
if self._nvidia_smi:
try:
result = subprocess.run(
["nvidia-smi", "--query-gpu=utilization.gpu,temperature.gpu", "--format=csv,noheader,nounits"],
Expand All @@ -61,20 +82,38 @@ def _gpu() -> dict[str, float | None]:
timeout=2,
)
usage, temperature = result.stdout.splitlines()[0].split(",", maxsplit=1)
return {"usage": float(usage.strip()), "temperature": float(temperature.strip())}
self._gpu_values = {"usage": float(usage.strip()), "temperature": float(temperature.strip())}
return dict(self._gpu_values)
except (OSError, subprocess.SubprocessError, ValueError, IndexError):
pass
if platform.system() == "Windows":
try:
import wmi # type: ignore[import-not-found]

engines = wmi.WMI(namespace=r"root\cimv2").Win32_PerfFormattedData_GPUPerformanceCounters_GPUEngine()
usage = sum(
float(engine.UtilizationPercentage or 0) for engine in engines if "engtype_3D" in engine.Name
)
return {"usage": min(100.0, usage), "temperature": None}
except Exception as error: # noqa: BLE001 - WMI exposes provider-specific COM errors.
logger.debug("Windows GPU counters unavailable: %s", error)
now = time.monotonic()
if now >= self._gpu_next_probe:
started = time.perf_counter()
try:
engines = self._cimv2().query(
"SELECT Name, UtilizationPercentage"
" FROM Win32_PerfFormattedData_GPUPerformanceCounters_GPUEngine WHERE Name LIKE '%engtype_3D%'"
)
usage = sum(float(engine.UtilizationPercentage or 0) for engine in engines)
# An empty counter set means nothing usable was exposed.
self._gpu_values = (
{"usage": min(100.0, usage), "temperature": None}
if engines
else {"usage": None, "temperature": None}
)
except Exception as error: # noqa: BLE001 - WMI exposes provider-specific COM errors.
logger.debug("Windows GPU counters unavailable: %s", error)
self._gpu_values = {"usage": None, "temperature": None}
elapsed = time.perf_counter() - started
if elapsed > GPU_PROBE_SLOW_SECONDS:
self._gpu_next_probe = time.monotonic() + GPU_PROBE_BACKOFF_SECONDS
logger.debug(
"Slow GPU probe (%.0f ms); caching values for %.0f s",
elapsed * 1000,
GPU_PROBE_BACKOFF_SECONDS,
)
return dict(self._gpu_values)
return {"usage": None, "temperature": None}

@staticmethod
Expand Down Expand Up @@ -102,15 +141,26 @@ def _network_rates(self) -> dict[str, float]:
self._network_time = now
return rates

def get_all(self) -> dict[str, Any]:
gpu = self._gpu()
temperatures = self._temperatures()
@staticmethod
def _cancelled(stop: threading.Event | None) -> bool:
return stop is not None and stop.is_set()

def get_all(self, stop: threading.Event | None = None) -> dict[str, Any]:
# Cooperative cancellation: once stopped, skip the remaining heavy
# probes so shutdown never waits on an in-flight slow provider.
gpu = {"usage": None, "temperature": None}
temperatures = {"cpu": None, "gpu": None}
disks: dict[str, float] = {}
if not self._cancelled(stop):
gpu = self._gpu()
temperatures = self._temperatures()
disks = self._disks()
if gpu["temperature"] is not None:
temperatures["gpu"] = gpu["temperature"]
return {
"cpu": psutil.cpu_percent(),
"ram": psutil.virtual_memory().percent,
"disks": self._disks(),
"disks": disks,
"temps": temperatures,
"gpu": gpu,
"network": self._network_rates(),
Expand Down
47 changes: 37 additions & 10 deletions src/ui/hud.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,50 @@

from __future__ import annotations

from PyQt6.QtCore import Qt, QTimer
import threading

from PyQt6.QtCore import Qt, QThread, pyqtSignal
from PyQt6.QtGui import QLinearGradient, QPainter, QPen
from PyQt6.QtWidgets import QApplication, QMenu, QWidget

from src.core.sensors import SensorReader
from src.core.settings_storage import load_settings, save_settings
from src.core.theme import color, load_theme
from src.ui.layout import create_widgets, load_layout, save_layout


class SensorWorker(QThread):
"""Samples metrics off the GUI thread so slow probes never block painting."""

ready = pyqtSignal(dict)

def __init__(self, interval_ms: int) -> None:
super().__init__()
self.interval_ms = max(int(interval_ms), 50)
self._stop = threading.Event()

def run(self) -> None:
from src.core.sensors import SensorReader # imported in the worker thread; owns its COM objects

reader = SensorReader()
while True:
data = reader.get_all(stop=self._stop)
if self._stop.is_set():
break
self.ready.emit(data)
if self._stop.wait(self.interval_ms / 1000):
break

def stop(self) -> None:
self._stop.set()


class GlassHUD(QWidget):
def __init__(self) -> None:
super().__init__()
self.settings = load_settings()
self.theme = load_theme(self.settings["theme"])
self.layout_data = load_layout(self.settings["layout"])
self.widgets = create_widgets(self.layout_data)
self.sensor_reader = SensorReader()
self.drag_pos = None
self.settings_window = None
flags = Qt.WindowType.FramelessWindowHint | Qt.WindowType.Tool
Expand All @@ -35,13 +61,11 @@ def __init__(self) -> None:
position = self.settings["window"]
if position["x"] is not None and position["y"] is not None:
self.move(position["x"], position["y"])
self.timer = QTimer(self)
self.timer.timeout.connect(self.update_stats)
self.timer.start(self.settings["refresh_interval_ms"])
self.update_stats()
self.sensor_worker = SensorWorker(self.settings["refresh_interval_ms"])
self.sensor_worker.ready.connect(self.apply_stats) # queued across threads
self.sensor_worker.start(QThread.Priority.LowPriority)

def update_stats(self) -> None:
data = self.sensor_reader.get_all()
def apply_stats(self, data: dict) -> None:
for widget in self.widgets:
widget.update(data)
self.update()
Expand All @@ -50,7 +74,7 @@ def apply_settings(self, settings: dict) -> None:
self.settings = save_settings(settings)
self.theme = load_theme(self.settings["theme"])
self.setWindowOpacity(self.settings["opacity"])
self.timer.setInterval(self.settings["refresh_interval_ms"])
self.sensor_worker.interval_ms = self.settings["refresh_interval_ms"]
for widget in self.widgets:
widget.set_theme(self.theme)
self.update()
Expand Down Expand Up @@ -113,6 +137,9 @@ def mouseReleaseEvent(self, event) -> None:
save_settings(self.settings)

def shutdown(self) -> None:
# Stop sampling first so exit never races an in-flight probe.
self.sensor_worker.stop()
self.sensor_worker.wait(5000)
# Single exit path so the layout is saved whether the user exits from
# the HUD context menu or the tray menu.
save_layout(self.widgets, self.width(), self.height(), self.settings["layout"])
Expand Down
81 changes: 81 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import time
from unittest.mock import Mock, patch

from src.core.sensors import SensorReader
Expand Down Expand Up @@ -82,5 +83,85 @@ def test_sensor_schema_is_stable_without_optional_hardware():
assert set(result) == {"cpu", "ram", "disks", "temps", "gpu", "network"}


class _FakeGpuConnection:
def __init__(self, engines):
self.engines = engines
self.queries = []

def query(self, wql):
self.queries.append(wql)
return self.engines


class _FakeEngine:
Name = "pid_1_eng_0_engtype_3D"
UtilizationPercentage = 40


def test_slow_windows_gpu_probe_backs_off(monkeypatch):
# Regression: probing the GPU counter set every tick pegged a CPU core on
# machines where the provider is slow; slow probes must now be throttled.
import src.core.sensors as sensors_module

reader = SensorReader()
connection = _FakeGpuConnection([_FakeEngine()])

class SlowConnection:
def query(self, wql):
time.sleep(0.25) # above the 0.2 s backoff threshold
return connection.query(wql)

monkeypatch.setattr(sensors_module.platform, "system", lambda: "Windows")
monkeypatch.setattr(SensorReader, "_cimv2", lambda self: SlowConnection())
first = reader._gpu()
second = reader._gpu() # inside the backoff window -> served from cache
assert first == {"usage": 40.0, "temperature": None}
assert second == first
assert len(connection.queries) == 1


def test_windows_gpu_query_filters_and_handles_empty_counters(monkeypatch):
import src.core.sensors as sensors_module

reader = SensorReader()
connection = _FakeGpuConnection([])
monkeypatch.setattr(sensors_module.platform, "system", lambda: "Windows")
monkeypatch.setattr(SensorReader, "_cimv2", lambda self: connection)
assert reader._gpu() == {"usage": None, "temperature": None}
assert "engtype_3D" in connection.queries[0] # filtering happens server-side


def test_nvidia_smi_path_is_resolved_once(monkeypatch):
# Regression: the PATH was rescanned on every sample.
import src.core.sensors as sensors_module

calls = []
monkeypatch.setattr(sensors_module.shutil, "which", lambda name: calls.append(name) or "/usr/bin/nvidia-smi")
reader = SensorReader()
monkeypatch.setattr(sensors_module.platform, "system", lambda: "Linux") # skip Windows fallback
reader._gpu()
reader._gpu()
assert len(calls) == 1


def test_get_all_skips_heavy_probes_after_stop():
import threading

reader = SensorReader()
stop = threading.Event()
stop.set()
with (
patch.object(reader, "_gpu") as gpu_mock,
patch.object(reader, "_temperatures") as temps_mock,
patch.object(reader, "_disks") as disks_mock,
):
result = reader.get_all(stop=stop)
gpu_mock.assert_not_called()
temps_mock.assert_not_called()
disks_mock.assert_not_called()
assert set(result) == {"cpu", "ram", "disks", "temps", "gpu", "network"} # schema stays stable
assert result["gpu"] == {"usage": None, "temperature": None}


def test_bundled_default_theme_loads():
assert load_theme()["colors"]["text"]
1 change: 1 addition & 0 deletions tests/test_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ def test_hud_shutdown_saves_layout(monkeypatch):
hud, saved = _make_hud(monkeypatch)
hud.shutdown()
assert len(saved) == 1 # Regression: every exit path must persist the layout.
assert not hud.sensor_worker.isRunning() # Sampling must stop before quit.
app.processEvents()


Expand Down
Loading