feat: alerts (M8), notifications config page, and app icon (0.4.0)
release / release (push) Successful in 14s
release / release (push) Successful in 14s
- feat(alerts): desktop notifications (notify-send) for overheat (GPU/CPU past a configurable threshold), GPU-lost, and a new-version-available alert (once per version). Edge-triggered with cooldown so it doesn't spam (core/alerts.py) - feat(gui): Notifications page to configure alerts (enable, GPU/CPU thresholds, Send test); changes apply live and persist via config.save_config/update_config - feat(gui): ship a RigDoctor icon; the GUI self-registers the icon + .desktop on launch and sets the Wayland app-id, so the dock shows it after an update + relaunch (no installer re-run); installer/uninstaller updated to manage the icon - config: alerts_enabled, gpu_temp_alert, cpu_temp_alert; flat-TOML writer - tests for the alert monitor and config round-trip Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,18 @@ All notable changes to RigDoctor are recorded here. Format follows
|
||||
(`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git
|
||||
release tag (so the auto-updater, D18, can compare versions).
|
||||
|
||||
## [0.4.0] - 2026-05-21
|
||||
### Added
|
||||
- **Alerts (M8)**: desktop notifications (via `notify-send`) for **overheat** (GPU/CPU past a
|
||||
threshold), **GPU-lost** (nvidia-smi timeout), and a **new version available** (fired once
|
||||
per version). Edge-triggered with a cooldown so it doesn't spam. Degrades gracefully if
|
||||
`notify-send` isn't installed.
|
||||
- **Notifications page**: configure alerts (enable/disable, GPU/CPU temperature thresholds)
|
||||
with a "Send test" button; changes apply live and persist to `config.toml`.
|
||||
- **App icon**: ships a RigDoctor icon and shows it in the dock/launcher. The GUI
|
||||
**self-registers** the icon + `.desktop` on launch (and sets the Wayland app-id), so a
|
||||
self-update + relaunch picks it up — no need to re-run the installer.
|
||||
|
||||
## [0.3.2] - 2026-05-21
|
||||
### Changed
|
||||
- Replaced the per-page "Run with admin" buttons with a **single password prompt at launch**
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
|
||||
| M3 | Crash-capture logger | Essential | none (opt: smartmontools) | all (NVIDIA first) | P0 | 🟨 |
|
||||
| M4 | Health report (log scan) | Essential | none (opt: smartmontools) | all (NVIDIA first) | P0 | 🟨 |
|
||||
| M2 | Live monitor (TUI) | Monitoring | none (stdlib curses) | all | P1 | ⬜ |
|
||||
| M8 | Alerting | Monitoring | libnotify (opt) | all | P2 | ⬜ |
|
||||
| M8 | Alerting | Monitoring | libnotify (opt) | all | P2 | 🟨 |
|
||||
| M5 | System inventory | Diagnostics | none (opt: lm-sensors, dmidecode) | all | P1 | 🟨 |
|
||||
| M6 | Gaming env checks | Diagnostics | none | all | P2 | ⬜ |
|
||||
| M10 | Desktop GUI | Desktop UI | **python3-pyside6** | all | P2 | 🟨 |
|
||||
|
||||
+16
-2
@@ -16,7 +16,8 @@ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
uninstall() {
|
||||
echo "Removing RigDoctor user-local install…"
|
||||
rm -rf "$VENV"
|
||||
rm -f "$BIN_DIR/rigdoctor" "$BIN_DIR/rigdoctor-gui" "$DESKTOP_FILE"
|
||||
rm -f "$BIN_DIR/rigdoctor" "$BIN_DIR/rigdoctor-gui" "$DESKTOP_FILE" \
|
||||
"$DATA_HOME/icons/hicolor/scalable/apps/rigdoctor.svg"
|
||||
echo "Done. (Config and logs under ~/.config/rigdoctor and ~/.local/share/rigdoctor were kept.)"
|
||||
}
|
||||
|
||||
@@ -81,6 +82,17 @@ mkdir -p "$BIN_DIR"
|
||||
ln -sf "$VENV/bin/rigdoctor" "$BIN_DIR/rigdoctor"
|
||||
ln -sf "$VENV/bin/rigdoctor-gui" "$BIN_DIR/rigdoctor-gui"
|
||||
|
||||
# Install the app icon (for the dock/launcher); fall back to a stock icon.
|
||||
ICON_NAME=utilities-system-monitor
|
||||
ICON_SRC=$("$VENV/bin/python" -c "import os, rigdoctor.gui as g; print(os.path.join(os.path.dirname(g.__file__), 'assets', 'rigdoctor.svg'))" 2>/dev/null || true)
|
||||
if [ -n "$ICON_SRC" ] && [ -f "$ICON_SRC" ]; then
|
||||
ICON_DST="$DATA_HOME/icons/hicolor/scalable/apps/rigdoctor.svg"
|
||||
mkdir -p "$(dirname "$ICON_DST")"
|
||||
cp "$ICON_SRC" "$ICON_DST"
|
||||
ICON_NAME=rigdoctor
|
||||
command -v gtk-update-icon-cache >/dev/null 2>&1 && gtk-update-icon-cache -qtf "$DATA_HOME/icons/hicolor" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
mkdir -p "$DESKTOP_DIR"
|
||||
cat > "$DESKTOP_FILE" <<EOF
|
||||
[Desktop Entry]
|
||||
@@ -88,10 +100,12 @@ Type=Application
|
||||
Name=RigDoctor
|
||||
Comment=Hardware monitoring & crash diagnostics for Linux gamers
|
||||
Exec=$VENV/bin/rigdoctor-gui
|
||||
Icon=utilities-system-monitor
|
||||
Icon=$ICON_NAME
|
||||
Terminal=false
|
||||
Categories=System;Monitor;Utility;
|
||||
StartupWMClass=rigdoctor
|
||||
EOF
|
||||
command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$DESKTOP_DIR" 2>/dev/null || true
|
||||
|
||||
echo
|
||||
echo "RigDoctor $("$VENV/bin/rigdoctor" --version 2>/dev/null | awk '{print $2}') installed."
|
||||
|
||||
+4
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "rigdoctor"
|
||||
version = "0.3.2"
|
||||
version = "0.4.0"
|
||||
description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -21,3 +21,6 @@ rigdoctor-gui = "rigdoctor.gui.app:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
rigdoctor = ["gui/assets/*.svg"]
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
|
||||
|
||||
__version__ = "0.3.2"
|
||||
__version__ = "0.4.0"
|
||||
|
||||
@@ -139,6 +139,9 @@ DEFAULTS: dict = {
|
||||
"log_backups": 10, # keep this many rotated segments (bounds disk use)
|
||||
"update_check_minutes": 30, # re-check for updates this often while running (0 = off)
|
||||
"elevate_on_launch": True, # GUI asks for the password once at launch (SMART/dmidecode)
|
||||
"alerts_enabled": True, # desktop notifications on overheat / GPU-lost / new version
|
||||
"gpu_temp_alert": 90.0, # °C — alert when GPU reaches this
|
||||
"cpu_temp_alert": 95.0, # °C — alert when CPU reaches this
|
||||
}
|
||||
|
||||
|
||||
@@ -154,3 +157,27 @@ def load_config() -> dict:
|
||||
except Exception:
|
||||
pass
|
||||
return cfg
|
||||
|
||||
|
||||
def _toml_value(value) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, (int, float)):
|
||||
return repr(value)
|
||||
return '"' + str(value).replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
|
||||
|
||||
def save_config(values: dict) -> None:
|
||||
"""Write a flat config.toml (stdlib has no TOML writer)."""
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
lines = ["# RigDoctor config — edit in the app (Notifications) or here."]
|
||||
lines += [f"{key} = {_toml_value(value)}" for key, value in values.items()]
|
||||
CONFIG_FILE.write_text("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
def update_config(**changes) -> dict:
|
||||
"""Merge changes into the current effective config and persist them."""
|
||||
cfg = load_config()
|
||||
cfg.update(changes)
|
||||
save_config(cfg)
|
||||
return cfg
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Desktop alerts (M8): notify on overheat / GPU-lost / new version via notify-send.
|
||||
|
||||
Edge-triggered: an alert fires when a condition becomes true (not every sample), and
|
||||
can fire again only after it has cleared and a cooldown has passed — so a hot GPU or a
|
||||
1-Hz sample loop doesn't spam notifications. Degrades to a no-op if notify-send is absent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from .sample import Sample
|
||||
|
||||
APP_NAME = "RigDoctor"
|
||||
_ICON = "utilities-system-monitor"
|
||||
|
||||
|
||||
def available() -> bool:
|
||||
return shutil.which("notify-send") is not None
|
||||
|
||||
|
||||
def notify(title: str, message: str, urgency: str = "normal") -> bool:
|
||||
"""Send a desktop notification (best-effort). urgency: low|normal|critical."""
|
||||
if not available():
|
||||
return False
|
||||
try:
|
||||
subprocess.run(
|
||||
["notify-send", "-a", APP_NAME, "-u", urgency, "-i", _ICON, title, message],
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
return True
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
class AlertMonitor:
|
||||
"""Evaluate samples and raise edge-triggered desktop alerts."""
|
||||
|
||||
def __init__(self, gpu_temp: float = 90.0, cpu_temp: float = 95.0, cooldown: float = 300.0):
|
||||
self.gpu_temp = gpu_temp
|
||||
self.cpu_temp = cpu_temp
|
||||
self.cooldown = cooldown
|
||||
self.enabled = True
|
||||
self._active: dict[str, bool] = {}
|
||||
self._last: dict[str, float] = {}
|
||||
|
||||
def _fire(self, key: str, title: str, message: str, urgency: str = "critical") -> None:
|
||||
if self._active.get(key):
|
||||
return # already alerting; wait until it clears
|
||||
now = time.time()
|
||||
if now - self._last.get(key, 0.0) < self.cooldown:
|
||||
return
|
||||
self._active[key] = True
|
||||
self._last[key] = now
|
||||
notify(title, message, urgency)
|
||||
|
||||
def _clear(self, key: str) -> None:
|
||||
self._active[key] = False
|
||||
|
||||
def check(self, sample: Sample) -> None:
|
||||
if not self.enabled:
|
||||
return
|
||||
gpu_t = next(
|
||||
(r.value for r in sample.readings
|
||||
if r.source == "gpu" and r.metric == "temp" and r.label == "" and r.value is not None),
|
||||
None,
|
||||
)
|
||||
if gpu_t is not None:
|
||||
if gpu_t >= self.gpu_temp:
|
||||
self._fire("gpu_temp", "GPU overheating", f"GPU at {gpu_t:.0f} °C")
|
||||
else:
|
||||
self._clear("gpu_temp")
|
||||
|
||||
cpu_temps = [r.value for r in sample.readings
|
||||
if r.source == "cpu" and r.metric == "temp" and r.value is not None]
|
||||
if cpu_temps:
|
||||
cpu_t = max(cpu_temps)
|
||||
if cpu_t >= self.cpu_temp:
|
||||
self._fire("cpu_temp", "CPU overheating", f"CPU at {cpu_t:.0f} °C")
|
||||
else:
|
||||
self._clear("cpu_temp")
|
||||
|
||||
lost = any(r.source == "gpu" and r.metric == "status" and r.label == "query-timeout"
|
||||
for r in sample.readings)
|
||||
if lost:
|
||||
self._fire("gpu_lost", "GPU not responding", "nvidia-smi query timed out — the GPU may have dropped")
|
||||
else:
|
||||
self._clear("gpu_lost")
|
||||
@@ -23,6 +23,7 @@ def targets(purge: bool = False) -> list[Path]:
|
||||
home / ".local" / "bin" / "rigdoctor",
|
||||
home / ".local" / "bin" / "rigdoctor-gui",
|
||||
share / "applications" / "rigdoctor.desktop",
|
||||
share / "icons" / "hicolor" / "scalable" / "apps" / "rigdoctor.svg",
|
||||
]
|
||||
if purge:
|
||||
items += [config.CONFIG_DIR, config.STATE_DIR, config.DATA_DIR]
|
||||
|
||||
@@ -3,18 +3,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtGui import QIcon
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from ..config import load_config
|
||||
from . import desktop
|
||||
from .main_window import MainWindow
|
||||
from .theme import STYLESHEET
|
||||
|
||||
ICON = Path(__file__).parent / "assets" / "rigdoctor.svg"
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
desktop.ensure() # self-register icon + .desktop so updates show it without re-installing
|
||||
app = QApplication(argv if argv is not None else sys.argv)
|
||||
app.setApplicationName("RigDoctor")
|
||||
app.setApplicationDisplayName("RigDoctor")
|
||||
# Match the installed rigdoctor.desktop so the dock/launcher shows our icon (Wayland app-id).
|
||||
app.setDesktopFileName("rigdoctor")
|
||||
if ICON.exists():
|
||||
app.setWindowIcon(QIcon(str(ICON)))
|
||||
app.setStyle("Fusion")
|
||||
app.setStyleSheet(STYLESHEET)
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
|
||||
<rect x="8" y="8" width="240" height="240" rx="52" fill="#15181e"/>
|
||||
<circle cx="128" cy="128" r="84" fill="none" stroke="#2a2f39" stroke-width="14"/>
|
||||
<path d="M128 44 a84 84 0 1 1 -59.4 24.6" fill="none" stroke="#38bdf8"
|
||||
stroke-width="14" stroke-linecap="round"/>
|
||||
<path d="M60 132 H100 L116 96 L140 168 L156 132 H196" fill="none" stroke="#e6e8eb"
|
||||
stroke-width="14" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 533 B |
@@ -0,0 +1,51 @@
|
||||
"""Best-effort desktop integration: install our icon + .desktop so the dock shows it.
|
||||
|
||||
Runs at GUI launch (idempotent), so a self-update + relaunch refreshes the icon without
|
||||
re-running install.sh. No-op for non-installed (dev) runs where the launcher is absent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .. import config
|
||||
|
||||
_ICON_SRC = Path(__file__).parent / "assets" / "rigdoctor.svg"
|
||||
|
||||
_DESKTOP = """[Desktop Entry]
|
||||
Type=Application
|
||||
Name=RigDoctor
|
||||
Comment=Hardware monitoring & crash diagnostics for Linux gamers
|
||||
Exec={exec}
|
||||
Icon=rigdoctor
|
||||
Terminal=false
|
||||
Categories=System;Monitor;Utility;
|
||||
StartupWMClass=rigdoctor
|
||||
"""
|
||||
|
||||
|
||||
def ensure() -> None:
|
||||
share = config.DATA_DIR.parent # ~/.local/share
|
||||
|
||||
try:
|
||||
if _ICON_SRC.exists():
|
||||
icon_dst = share / "icons" / "hicolor" / "scalable" / "apps" / "rigdoctor.svg"
|
||||
icon_dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not icon_dst.exists() or icon_dst.read_bytes() != _ICON_SRC.read_bytes():
|
||||
shutil.copyfile(_ICON_SRC, icon_dst)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
gui_exec = Path(sys.executable).with_name("rigdoctor-gui")
|
||||
if not gui_exec.exists(): # dev / not a normal install — don't fabricate a .desktop
|
||||
return
|
||||
try:
|
||||
desktop = share / "applications" / "rigdoctor.desktop"
|
||||
content = _DESKTOP.format(exec=gui_exec)
|
||||
desktop.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not desktop.exists() or desktop.read_text() != content:
|
||||
desktop.write_text(content)
|
||||
except OSError:
|
||||
pass
|
||||
@@ -26,16 +26,17 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from .. import __version__
|
||||
from ..config import load_config
|
||||
from ..core import elevation, updates
|
||||
from ..core import alerts, elevation, updates
|
||||
from .dashboard import Dashboard
|
||||
from .health_page import HealthPage
|
||||
from .inventory_page import InventoryPage
|
||||
from .notifications_page import NotificationsPage
|
||||
from .recorder_page import RecorderPage
|
||||
from .setup_page import SetupPage
|
||||
from .theme import ACCENT, GOOD, MUTED
|
||||
from .worker import SamplerWorker
|
||||
|
||||
_NAV_ITEMS = ["Dashboard", "Logs", "Health", "Setup", "Inventory"]
|
||||
_NAV_ITEMS = ["Dashboard", "Logs", "Health", "Setup", "Inventory", "Notifications"]
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
@@ -67,11 +68,14 @@ class MainWindow(QMainWindow):
|
||||
self.health_page = HealthPage()
|
||||
self.setup_page = SetupPage()
|
||||
self.inventory_page = InventoryPage()
|
||||
self.notifications_page = NotificationsPage()
|
||||
self.notifications_page.changed.connect(self._apply_alert_settings)
|
||||
self._stack.addWidget(self.dashboard) # 0 Dashboard
|
||||
self._stack.addWidget(self.recorder_page) # 1 Logs
|
||||
self._stack.addWidget(self.health_page) # 2 Health
|
||||
self._stack.addWidget(self.setup_page) # 3 Setup
|
||||
self._stack.addWidget(self.inventory_page) # 4 Inventory
|
||||
self._stack.addWidget(self.notifications_page) # 5 Notifications
|
||||
content_layout.addWidget(self._stack)
|
||||
|
||||
layout.addWidget(self._build_sidebar())
|
||||
@@ -79,6 +83,15 @@ class MainWindow(QMainWindow):
|
||||
|
||||
self._worker = SamplerWorker(interval=interval)
|
||||
self._worker.sampled.connect(self.dashboard.update_sample)
|
||||
# Desktop alerts (M8): overheat / GPU-lost from the sample stream, new-version below.
|
||||
# Configurable on the Notifications page; gated by AlertMonitor.enabled.
|
||||
self._notified_update_tag = None
|
||||
self._alert_monitor = alerts.AlertMonitor(
|
||||
gpu_temp=float(cfg.get("gpu_temp_alert", 90.0)),
|
||||
cpu_temp=float(cfg.get("cpu_temp_alert", 95.0)),
|
||||
)
|
||||
self._alert_monitor.enabled = bool(cfg.get("alerts_enabled", True))
|
||||
self._worker.sampled.connect(self._alert_monitor.check)
|
||||
self._worker.start()
|
||||
|
||||
# Ask for the password once at launch and collect root-only data (SMART +
|
||||
@@ -216,6 +229,12 @@ class MainWindow(QMainWindow):
|
||||
self.health_page._run()
|
||||
self.inventory_page._run()
|
||||
|
||||
def _apply_alert_settings(self) -> None:
|
||||
cfg = load_config()
|
||||
self._alert_monitor.enabled = bool(cfg.get("alerts_enabled", True))
|
||||
self._alert_monitor.gpu_temp = float(cfg.get("gpu_temp_alert", 90.0))
|
||||
self._alert_monitor.cpu_temp = float(cfg.get("cpu_temp_alert", 95.0))
|
||||
|
||||
def _manual_check(self) -> None:
|
||||
if self._applied:
|
||||
return
|
||||
@@ -279,6 +298,9 @@ class MainWindow(QMainWindow):
|
||||
self._update_label.setText(f'<span style="color:{GOOD};">{tag} available</span>')
|
||||
self._update_btn.setText(f"Update to {tag}")
|
||||
self._update_btn.setVisible(True)
|
||||
if self._alert_monitor.enabled and tag != self._notified_update_tag:
|
||||
self._notified_update_tag = tag # once per version, not every poll
|
||||
alerts.notify("Update available", f"RigDoctor {tag} is available — open RigDoctor to update.")
|
||||
else: # UP_TO_DATE
|
||||
self._update_label.setText("up-to-date")
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Notifications page (M8 config): user-configurable alert settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QDoubleSpinBox,
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..config import load_config, update_config
|
||||
from ..core import alerts
|
||||
|
||||
|
||||
class NotificationsPage(QWidget):
|
||||
changed = Signal() # settings saved — main window re-applies them live
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.setObjectName("Page")
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(20, 18, 20, 18)
|
||||
root.setSpacing(16)
|
||||
|
||||
title = QLabel("Notifications")
|
||||
title.setObjectName("PageTitle")
|
||||
root.addWidget(title)
|
||||
|
||||
card = QFrame()
|
||||
card.setObjectName("Card")
|
||||
v = QVBoxLayout(card)
|
||||
v.setContentsMargins(16, 14, 16, 14)
|
||||
v.setSpacing(10)
|
||||
head = QLabel("Alerts")
|
||||
head.setStyleSheet("font-weight: 700; background: transparent;")
|
||||
v.addWidget(head)
|
||||
|
||||
self._enabled = QCheckBox("Enable desktop notifications")
|
||||
v.addWidget(self._enabled)
|
||||
|
||||
grid = QGridLayout()
|
||||
grid.setHorizontalSpacing(12)
|
||||
grid.setColumnStretch(2, 1)
|
||||
self._gpu = self._spin()
|
||||
self._cpu = self._spin()
|
||||
grid.addWidget(QLabel("GPU temperature alert"), 0, 0)
|
||||
grid.addWidget(self._gpu, 0, 1)
|
||||
grid.addWidget(QLabel("CPU temperature alert"), 1, 0)
|
||||
grid.addWidget(self._cpu, 1, 1)
|
||||
v.addLayout(grid)
|
||||
|
||||
note = QLabel("GPU-lost and new-version alerts are included whenever notifications are enabled.")
|
||||
note.setObjectName("Muted")
|
||||
note.setWordWrap(True)
|
||||
v.addWidget(note)
|
||||
|
||||
buttons = QHBoxLayout()
|
||||
save = QPushButton("Save")
|
||||
save.setObjectName("PrimaryButton")
|
||||
save.clicked.connect(self._save)
|
||||
test = QPushButton("Send test")
|
||||
test.clicked.connect(self._test)
|
||||
buttons.addWidget(save)
|
||||
buttons.addWidget(test)
|
||||
buttons.addStretch(1)
|
||||
v.addLayout(buttons)
|
||||
self._status = QLabel("")
|
||||
self._status.setObjectName("Muted")
|
||||
v.addWidget(self._status)
|
||||
|
||||
root.addWidget(card)
|
||||
root.addStretch(1)
|
||||
self._load()
|
||||
|
||||
@staticmethod
|
||||
def _spin() -> QDoubleSpinBox:
|
||||
spin = QDoubleSpinBox()
|
||||
spin.setRange(40, 110)
|
||||
spin.setDecimals(0)
|
||||
spin.setSingleStep(1)
|
||||
spin.setSuffix(" °C")
|
||||
return spin
|
||||
|
||||
def _load(self) -> None:
|
||||
cfg = load_config()
|
||||
self._enabled.setChecked(bool(cfg.get("alerts_enabled", True)))
|
||||
self._gpu.setValue(float(cfg.get("gpu_temp_alert", 90.0)))
|
||||
self._cpu.setValue(float(cfg.get("cpu_temp_alert", 95.0)))
|
||||
|
||||
def _save(self) -> None:
|
||||
update_config(
|
||||
alerts_enabled=self._enabled.isChecked(),
|
||||
gpu_temp_alert=self._gpu.value(),
|
||||
cpu_temp_alert=self._cpu.value(),
|
||||
)
|
||||
self.changed.emit()
|
||||
self._status.setText("Saved.")
|
||||
|
||||
def _test(self) -> None:
|
||||
ok = alerts.notify("RigDoctor", "Test notification — alerts are working.")
|
||||
self._status.setText("Test notification sent." if ok else "notify-send not found — install libnotify-bin (Setup).")
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Tests for the M8 alert monitor (edge-triggered; notify mocked)."""
|
||||
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from rigdoctor.core import alerts
|
||||
from rigdoctor.core.sample import Reading, Sample
|
||||
|
||||
|
||||
def _gpu(temp):
|
||||
return Sample(readings=[Reading("gpu", "temp", temp, "°C")])
|
||||
|
||||
|
||||
class AlertTests(unittest.TestCase):
|
||||
@mock.patch.object(alerts, "notify")
|
||||
def test_edge_triggered_no_repeat(self, m):
|
||||
mon = alerts.AlertMonitor(gpu_temp=90.0, cooldown=0.0)
|
||||
mon.check(_gpu(95)) # fires
|
||||
mon.check(_gpu(96)) # still hot — no repeat while active
|
||||
self.assertEqual(m.call_count, 1)
|
||||
mon.check(_gpu(50)) # clears
|
||||
mon.check(_gpu(95)) # hot again — fires
|
||||
self.assertEqual(m.call_count, 2)
|
||||
|
||||
@mock.patch.object(alerts, "notify")
|
||||
def test_no_alert_below_threshold(self, m):
|
||||
alerts.AlertMonitor(gpu_temp=90.0).check(_gpu(70))
|
||||
m.assert_not_called()
|
||||
|
||||
@mock.patch.object(alerts, "notify")
|
||||
def test_gpu_lost(self, m):
|
||||
mon = alerts.AlertMonitor()
|
||||
mon.check(Sample(readings=[Reading("gpu", "status", None, "", "query-timeout")]))
|
||||
m.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Tests for config save/load (flat TOML writer)."""
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from rigdoctor import config
|
||||
|
||||
|
||||
class ConfigTests(unittest.TestCase):
|
||||
def test_save_load_round_trip(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
cf = Path(d) / "config.toml"
|
||||
with mock.patch.object(config, "CONFIG_FILE", cf), mock.patch.object(config, "CONFIG_DIR", Path(d)):
|
||||
config.save_config({"alerts_enabled": False, "gpu_temp_alert": 88.0, "update_check_minutes": 5})
|
||||
loaded = config.load_config()
|
||||
self.assertIs(loaded["alerts_enabled"], False)
|
||||
self.assertEqual(loaded["gpu_temp_alert"], 88.0)
|
||||
self.assertEqual(loaded["update_check_minutes"], 5)
|
||||
|
||||
def test_update_config_merges_and_keeps_defaults(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
cf = Path(d) / "config.toml"
|
||||
with mock.patch.object(config, "CONFIG_FILE", cf), mock.patch.object(config, "CONFIG_DIR", Path(d)):
|
||||
config.update_config(cpu_temp_alert=70.0)
|
||||
self.assertEqual(config.load_config()["cpu_temp_alert"], 70.0)
|
||||
self.assertEqual(config.load_config()["gpu_temp_alert"], 90.0) # default preserved
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user