Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 31178bace8 | |||
|
04e8d72bce
|
|||
| fb468e83c2 | |||
|
b006fa6b8d
|
|||
| b20e8dfc3a | |||
|
81c7757546
|
@@ -5,6 +5,26 @@ All notable changes to RigDoctor are recorded here. Format follows
|
|||||||
(`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git
|
(`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git
|
||||||
release tag (so the auto-updater, D18, can compare versions).
|
release tag (so the auto-updater, D18, can compare versions).
|
||||||
|
|
||||||
|
## [0.40.0] - 2026-05-22
|
||||||
|
### Added
|
||||||
|
- **RAM speed / XMP-EXPO check.** Inventory now shows each module's configured speed and, when it's
|
||||||
|
below the rated speed, the rating (e.g. `4800 MT/s (rated 5600)`); **System Health** flags it
|
||||||
|
("RAM at 4800 MT/s (rated 5600 MT/s)") with the fix — enable XMP/EXPO in BIOS. With the profile
|
||||||
|
off, dmidecode only reports the JEDEC base, so the rated speed is read from both dmidecode and
|
||||||
|
the part number (matched against known DDR5 speed grades, so no false positives). Needs dmidecode
|
||||||
|
(root / launch elevation). Completes the "underperforming hardware" trio with PCIe gen + refresh.
|
||||||
|
|
||||||
|
## [0.39.0] - 2026-05-22
|
||||||
|
### Added
|
||||||
|
- **Displays in the Inventory.** A new `core/displays.py` lists each connected monitor with its
|
||||||
|
resolution and current/max refresh — e.g. `DP-1 · Samsung LC34G55T → 3440x1440 @ 165 Hz`. Reads
|
||||||
|
GNOME's Mutter `DisplayConfig` over D-Bus (works on X11 *and* Wayland), falling back to `xrandr`
|
||||||
|
on other X11 desktops.
|
||||||
|
- **System Health flags monitors below their max refresh.** If a monitor supports a higher refresh
|
||||||
|
at its current resolution (e.g. a 165 Hz panel set to 60 Hz — an easily-missed gaming setting),
|
||||||
|
Health reports it with the fix (raise it in Display settings). Max is computed at the *current*
|
||||||
|
resolution, so it never suggests dropping resolution.
|
||||||
|
|
||||||
## [0.38.0] - 2026-05-22
|
## [0.38.0] - 2026-05-22
|
||||||
### Added
|
### Added
|
||||||
- **PCIe link in the Inventory.** Each NVMe drive now shows its negotiated PCIe link next to the
|
- **PCIe link in the Inventory.** Each NVMe drive now shows its negotiated PCIe link next to the
|
||||||
|
|||||||
@@ -51,30 +51,20 @@ apt pulls the GUI dependencies (PySide6, pyte) automatically:
|
|||||||
sudo apt install ./rigdoctor_*_all.deb # CLI only: add --no-install-recommends
|
sudo apt install ./rigdoctor_*_all.deb # CLI only: add --no-install-recommends
|
||||||
```
|
```
|
||||||
|
|
||||||
**Or add the apt repository** for `apt install` + automatic updates. The registry is public and
|
**Or add the apt repository** for `apt install` + automatic updates (the registry is public and
|
||||||
GPG-signed — no token needed; just add the signing key and a deb822 source:
|
GPG-signed — no token needed):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# signing key → dearmored into the keyring
|
sudo curl https://git.jesseyvanofferen.com/api/packages/jessey/debian/repository.key -o /etc/apt/keyrings/gitea-jessey.asc
|
||||||
sudo install -d -m 0755 /etc/apt/keyrings
|
echo "deb [arch=all signed-by=/etc/apt/keyrings/gitea-jessey.asc] https://git.jesseyvanofferen.com/api/packages/jessey/debian stable main" | sudo tee /etc/apt/sources.list.d/gitea.list
|
||||||
curl -fsSL https://git.jesseyvanofferen.com/api/packages/jessey/debian/repository.key \
|
sudo apt update
|
||||||
| sudo gpg --dearmor -o /etc/apt/keyrings/gitea-jessey.gpg
|
sudo apt install rigdoctor
|
||||||
|
|
||||||
# the source (modern deb822 format, GPG-verified, all-arch)
|
|
||||||
sudo tee /etc/apt/sources.list.d/rigdoctor.sources >/dev/null <<'EOF'
|
|
||||||
Types: deb
|
|
||||||
URIs: https://git.jesseyvanofferen.com/api/packages/jessey/debian
|
|
||||||
Suites: stable
|
|
||||||
Components: main
|
|
||||||
Architectures: all
|
|
||||||
Signed-By: /etc/apt/keyrings/gitea-jessey.gpg
|
|
||||||
EOF
|
|
||||||
|
|
||||||
sudo apt update && sudo apt install rigdoctor
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then `sudo apt upgrade` keeps it current.
|
Then `sudo apt upgrade` keeps it current.
|
||||||
|
|
||||||
|
Then `sudo apt upgrade` keeps it current.
|
||||||
|
|
||||||
### Any distro — self-extracting `.run` (no root)
|
### Any distro — self-extracting `.run` (no root)
|
||||||
|
|
||||||
Download **`rigdoctor-<version>-installer.run`** from the releases page and run it. It installs
|
Download **`rigdoctor-<version>-installer.run`** from the releases page and run it. It installs
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "rigdoctor"
|
name = "rigdoctor"
|
||||||
version = "0.38.0"
|
version = "0.40.0"
|
||||||
description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
|
description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
|
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
|
||||||
|
|
||||||
__version__ = "0.38.0"
|
__version__ = "0.40.0"
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""Connected displays (M5): resolution + current/max refresh per monitor.
|
||||||
|
|
||||||
|
GNOME exposes the authoritative data over D-Bus (Mutter `DisplayConfig.GetCurrentState`),
|
||||||
|
which works on both X11 and Wayland — read via `busctl --json`. Plain X11 desktops fall back
|
||||||
|
to `xrandr`. Other Wayland compositors (sway/KDE) aren't covered yet and degrade to empty.
|
||||||
|
Stdlib only; every probe fails soft. Max refresh is computed at the *current* resolution, so
|
||||||
|
"can go faster" never suggests dropping resolution.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
# A few common PNP monitor-vendor IDs → friendly names (best-effort; unknown codes pass through).
|
||||||
|
_PNP = {
|
||||||
|
"SAM": "Samsung", "DEL": "Dell", "GSM": "LG", "LGD": "LG", "AUS": "ASUS", "ACR": "Acer",
|
||||||
|
"BNQ": "BenQ", "MSI": "MSI", "AOC": "AOC", "VSC": "ViewSonic", "HWP": "HP", "HPN": "HP",
|
||||||
|
"PHL": "Philips", "GBT": "Gigabyte", "APP": "Apple", "DGC": "Dell",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Monitor:
|
||||||
|
connector: str # e.g. "DP-1"
|
||||||
|
name: str # e.g. "Samsung LC34G55T" ("" if unknown, e.g. xrandr)
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
refresh: float # current Hz
|
||||||
|
max_refresh: float # max Hz available at the current resolution
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_go_faster(self) -> bool:
|
||||||
|
"""True if a meaningfully higher refresh is available at the current resolution."""
|
||||||
|
return self.max_refresh - self.refresh > 1.0
|
||||||
|
|
||||||
|
def label(self) -> str:
|
||||||
|
return f"{self.connector} · {self.name}".rstrip(" ·") if self.name else self.connector
|
||||||
|
|
||||||
|
|
||||||
|
def _run(cmd: list[str], timeout: float = 8.0) -> str:
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||||||
|
if proc.returncode == 0:
|
||||||
|
return proc.stdout
|
||||||
|
except (subprocess.SubprocessError, OSError):
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_mutter(out: str) -> list[Monitor]:
|
||||||
|
"""Parse `busctl --json` output of Mutter DisplayConfig.GetCurrentState.
|
||||||
|
|
||||||
|
data = [serial, monitors, logical_monitors, props]; each monitor is
|
||||||
|
[[connector, vendor, product, serial], [modes], props]; each mode is
|
||||||
|
[id, width, height, refresh, scale, [scales], {props}] where props may hold is-current.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = json.loads(out)["data"]
|
||||||
|
raw_monitors = data[1]
|
||||||
|
except (json.JSONDecodeError, KeyError, IndexError, TypeError):
|
||||||
|
return []
|
||||||
|
monitors: list[Monitor] = []
|
||||||
|
for mon in raw_monitors:
|
||||||
|
try:
|
||||||
|
connector, vendor, product = mon[0][0], mon[0][1], mon[0][2]
|
||||||
|
modes = mon[1]
|
||||||
|
except (IndexError, TypeError):
|
||||||
|
continue
|
||||||
|
current = None
|
||||||
|
for m in modes:
|
||||||
|
props = m[6] if len(m) > 6 and isinstance(m[6], dict) else {}
|
||||||
|
if (props.get("is-current") or {}).get("data"):
|
||||||
|
current = m
|
||||||
|
break
|
||||||
|
if current is None:
|
||||||
|
continue
|
||||||
|
w, h, r = int(current[1]), int(current[2]), float(current[3])
|
||||||
|
max_r = max((float(m[3]) for m in modes if int(m[1]) == w and int(m[2]) == h), default=r)
|
||||||
|
name = f"{_PNP.get(vendor, vendor)} {product}".strip()
|
||||||
|
monitors.append(Monitor(connector, name, w, h, r, max_r))
|
||||||
|
return monitors
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_xrandr(out: str) -> list[Monitor]:
|
||||||
|
"""Parse `xrandr --query`: an output line with the active WxH+x+y, then indented mode lines
|
||||||
|
whose rates carry `*` for the current one."""
|
||||||
|
monitors: list[Monitor] = []
|
||||||
|
out_re = re.compile(r"^(\S+) connected.*?(\d+)x(\d+)\+\d+\+\d+")
|
||||||
|
mode_re = re.compile(r"^\s+(\d+)x(\d+)\s+(.+)$")
|
||||||
|
name = ""
|
||||||
|
cw = ch = 0
|
||||||
|
cur_r = max_r = 0.0
|
||||||
|
|
||||||
|
def flush() -> None:
|
||||||
|
if name and cw and cur_r:
|
||||||
|
monitors.append(Monitor(name, "", cw, ch, cur_r, max_r or cur_r))
|
||||||
|
|
||||||
|
for line in out.splitlines():
|
||||||
|
mo = out_re.match(line)
|
||||||
|
if mo:
|
||||||
|
flush()
|
||||||
|
name, cw, ch = mo.group(1), int(mo.group(2)), int(mo.group(3))
|
||||||
|
cur_r = max_r = 0.0
|
||||||
|
continue
|
||||||
|
mm = mode_re.match(line)
|
||||||
|
if mm and name and int(mm.group(1)) == cw and int(mm.group(2)) == ch:
|
||||||
|
for tok in mm.group(3).split():
|
||||||
|
try:
|
||||||
|
rate = float(tok.rstrip("*+"))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
max_r = max(max_r, rate)
|
||||||
|
if "*" in tok:
|
||||||
|
cur_r = rate
|
||||||
|
flush()
|
||||||
|
return monitors
|
||||||
|
|
||||||
|
|
||||||
|
def _mutter() -> list[Monitor]:
|
||||||
|
exe = shutil.which("busctl")
|
||||||
|
if not exe:
|
||||||
|
return []
|
||||||
|
out = _run([exe, "--user", "--json=short", "call", "org.gnome.Mutter.DisplayConfig",
|
||||||
|
"/org/gnome/Mutter/DisplayConfig", "org.gnome.Mutter.DisplayConfig",
|
||||||
|
"GetCurrentState"])
|
||||||
|
return _parse_mutter(out) if out.strip() else []
|
||||||
|
|
||||||
|
|
||||||
|
def _xrandr() -> list[Monitor]:
|
||||||
|
if not shutil.which("xrandr"):
|
||||||
|
return []
|
||||||
|
return _parse_xrandr(_run(["xrandr", "--query"]))
|
||||||
|
|
||||||
|
|
||||||
|
def collect() -> list[Monitor]:
|
||||||
|
"""Connected monitors, via the first backend that returns any (Mutter, then xrandr)."""
|
||||||
|
for backend in (_mutter, _xrandr):
|
||||||
|
try:
|
||||||
|
monitors = backend()
|
||||||
|
except Exception:
|
||||||
|
monitors = []
|
||||||
|
if monitors:
|
||||||
|
return monitors
|
||||||
|
return []
|
||||||
@@ -283,6 +283,46 @@ def check_pcie_links() -> list[Finding]:
|
|||||||
return findings
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def check_displays() -> list[Finding]:
|
||||||
|
"""Flag monitors running below their max refresh rate at the current resolution — e.g. a
|
||||||
|
165 Hz panel set to 60 Hz, a common and easily-missed gaming setting (read-only suggestion)."""
|
||||||
|
from . import displays
|
||||||
|
|
||||||
|
findings: list[Finding] = []
|
||||||
|
for m in displays.collect():
|
||||||
|
if m.can_go_faster:
|
||||||
|
findings.append(Finding(
|
||||||
|
INFO, "Display",
|
||||||
|
f"{m.connector} at {round(m.refresh)} Hz (supports {round(m.max_refresh)} Hz)",
|
||||||
|
f"{m.name or m.connector} is running at {round(m.refresh)} Hz at "
|
||||||
|
f"{m.width}x{m.height}, but supports {round(m.max_refresh)} Hz at that resolution.",
|
||||||
|
"Raise the refresh rate in your desktop's Display settings (GNOME: Settings → Displays)."))
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def check_memory_speed() -> list[Finding]:
|
||||||
|
"""Flag RAM running below its rated speed — i.e. the XMP (Intel) / EXPO (AMD) profile isn't
|
||||||
|
enabled, leaving memory bandwidth on the table. Needs dmidecode (root); silent without it."""
|
||||||
|
from . import elevation, inventory
|
||||||
|
|
||||||
|
priv = elevation.privileged()
|
||||||
|
dmi = priv["dmidecode"] if (priv and priv.get("dmidecode")) else inventory._dmidecode()
|
||||||
|
worst: tuple[int, int] | None = None # (configured, rated) with the biggest gap
|
||||||
|
for m in dmi.get("memory", []):
|
||||||
|
configured, rated = inventory.module_speed(m)
|
||||||
|
if configured and rated and configured < rated:
|
||||||
|
if worst is None or (rated - configured) > (worst[1] - worst[0]):
|
||||||
|
worst = (configured, rated)
|
||||||
|
if worst is None:
|
||||||
|
return []
|
||||||
|
configured, rated = worst
|
||||||
|
return [Finding(
|
||||||
|
INFO, "Memory", f"RAM at {configured} MT/s (rated {rated} MT/s)",
|
||||||
|
f"Memory is running at {configured} MT/s but the modules are rated {rated} MT/s — the "
|
||||||
|
"XMP/EXPO profile isn't enabled, so you're leaving memory bandwidth on the table.",
|
||||||
|
"Enable XMP (Intel) or EXPO (AMD) in your BIOS/UEFI to run at the rated speed.")]
|
||||||
|
|
||||||
|
|
||||||
def run_health_checks(include_journal: bool = True) -> list[Finding]:
|
def run_health_checks(include_journal: bool = True) -> list[Finding]:
|
||||||
"""Run all checks and return findings sorted by severity (worst first).
|
"""Run all checks and return findings sorted by severity (worst first).
|
||||||
|
|
||||||
@@ -306,5 +346,7 @@ def run_health_checks(include_journal: bool = True) -> list[Finding]:
|
|||||||
findings += check_smart()
|
findings += check_smart()
|
||||||
findings += check_live_temps()
|
findings += check_live_temps()
|
||||||
findings += check_pcie_links()
|
findings += check_pcie_links()
|
||||||
|
findings += check_displays()
|
||||||
|
findings += check_memory_speed() # uses elevation data if present, else dmidecode (root)
|
||||||
findings.sort(key=lambda f: _ORDER.get(f.severity, 9))
|
findings.sort(key=lambda f: _ORDER.get(f.severity, 9))
|
||||||
return findings
|
return findings
|
||||||
|
|||||||
@@ -86,6 +86,35 @@ def _firmware(dmi: dict) -> Section:
|
|||||||
return Section("Firmware", items)
|
return Section("Firmware", items)
|
||||||
|
|
||||||
|
|
||||||
|
# Common DDR5 XMP/EXPO speed grades (MT/s) — used to read a kit's rated speed from its part
|
||||||
|
# number, since with XMP/EXPO off dmidecode only reports the JEDEC base (e.g. 4800).
|
||||||
|
_DDR_SPEEDS = {4800, 5200, 5600, 6000, 6200, 6400, 6600, 6800, 7000, 7200, 7600, 8000, 8200, 8400}
|
||||||
|
|
||||||
|
|
||||||
|
def _mts(value: str) -> int | None:
|
||||||
|
"""Parse a dmidecode speed like '4800 MT/s' (or 'MHz') to its integer MT/s."""
|
||||||
|
m = re.match(r"\s*(\d+)", value or "")
|
||||||
|
return int(m.group(1)) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def _rated_from_part(part: str) -> int | None:
|
||||||
|
"""The highest known DDR speed-grade appearing as a 4-digit token in a part number."""
|
||||||
|
grades = [int(n) for n in re.findall(r"(?<!\d)(\d{4})(?!\d)", part or "") if int(n) in _DDR_SPEEDS]
|
||||||
|
return max(grades) if grades else None
|
||||||
|
|
||||||
|
|
||||||
|
def module_speed(m: dict) -> tuple[int | None, int | None]:
|
||||||
|
"""(configured, rated) MT/s for a dmidecode Memory Device.
|
||||||
|
|
||||||
|
Configured = what it's actually running at; rated = the highest of dmidecode's reported max
|
||||||
|
and the part-number speed-grade (so an unapplied XMP/EXPO profile is still detected).
|
||||||
|
"""
|
||||||
|
configured = _mts(m.get("Configured Memory Speed") or m.get("Configured Clock Speed") or m.get("Speed", ""))
|
||||||
|
candidates = [s for s in (_mts(m.get("Speed", "")), _rated_from_part(m.get("Part Number", ""))) if s]
|
||||||
|
rated = max(candidates) if candidates else None
|
||||||
|
return configured, rated
|
||||||
|
|
||||||
|
|
||||||
def _memory(dmi: dict) -> Section:
|
def _memory(dmi: dict) -> Section:
|
||||||
items: list[tuple[str, str]] = []
|
items: list[tuple[str, str]] = []
|
||||||
try:
|
try:
|
||||||
@@ -99,8 +128,12 @@ def _memory(dmi: dict) -> Section:
|
|||||||
if modules:
|
if modules:
|
||||||
items.append(("Modules", str(len(modules))))
|
items.append(("Modules", str(len(modules))))
|
||||||
for i, m in enumerate(modules):
|
for i, m in enumerate(modules):
|
||||||
desc = " · ".join(p for p in (m.get("Size"), m.get("Type"), m.get("Speed"), m.get("Part Number")) if p)
|
configured, rated = module_speed(m)
|
||||||
items.append((f"Slot {i}", desc))
|
speed = f"{configured} MT/s" if configured else m.get("Speed", "")
|
||||||
|
if rated and configured and rated > configured: # XMP/EXPO not applied
|
||||||
|
speed += f" (rated {rated})"
|
||||||
|
parts = (m.get("Size"), m.get("Type"), speed, m.get("Part Number"))
|
||||||
|
items.append((f"Slot {i}", " · ".join(p for p in parts if p)))
|
||||||
elif shutil.which("dmidecode"):
|
elif shutil.which("dmidecode"):
|
||||||
items.append(("Modules", "run with admin for module details"))
|
items.append(("Modules", "run with admin for module details"))
|
||||||
return Section("Memory", items)
|
return Section("Memory", items)
|
||||||
@@ -201,10 +234,18 @@ def _storage() -> Section:
|
|||||||
|
|
||||||
|
|
||||||
def _display() -> Section:
|
def _display() -> Section:
|
||||||
return Section("Display", [
|
from . import displays
|
||||||
|
|
||||||
|
items = [
|
||||||
("Session", os.environ.get("XDG_SESSION_TYPE", "unknown")),
|
("Session", os.environ.get("XDG_SESSION_TYPE", "unknown")),
|
||||||
("Desktop", os.environ.get("XDG_CURRENT_DESKTOP") or os.environ.get("DESKTOP_SESSION", "unknown")),
|
("Desktop", os.environ.get("XDG_CURRENT_DESKTOP") or os.environ.get("DESKTOP_SESSION", "unknown")),
|
||||||
])
|
]
|
||||||
|
for m in displays.collect():
|
||||||
|
val = f"{m.width}x{m.height} @ {round(m.refresh)} Hz"
|
||||||
|
if m.can_go_faster:
|
||||||
|
val += f" (supports {round(m.max_refresh)} Hz)"
|
||||||
|
items.append((m.label(), val))
|
||||||
|
return Section("Display", items)
|
||||||
|
|
||||||
|
|
||||||
def _dmidecode() -> dict:
|
def _dmidecode() -> dict:
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Tests for display detection (Mutter D-Bus JSON + xrandr parsers)."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from rigdoctor.core import displays
|
||||||
|
|
||||||
|
# Minimal Mutter GetCurrentState (busctl --json) shape: current mode is 60 Hz, panel max 165 Hz.
|
||||||
|
_MUTTER_60 = (
|
||||||
|
'{"type":"x","data":[1,[[["DP-1","SAM","LC34G55T","S"],['
|
||||||
|
'["3440x1440@60",3440,1440,60.0,1.0,[1.0],{"is-current":{"type":"b","data":true}}],'
|
||||||
|
'["3440x1440@165",3440,1440,165.0,1.0,[1.0],{"is-preferred":{"type":"b","data":true}}]'
|
||||||
|
'],{}]],[],{}]}'
|
||||||
|
)
|
||||||
|
_MUTTER_MAX = (
|
||||||
|
'{"type":"x","data":[1,[[["DP-1","SAM","LC34G55T","S"],['
|
||||||
|
'["3440x1440@165",3440,1440,165.0,1.0,[1.0],{"is-current":{"type":"b","data":true}}],'
|
||||||
|
'["3440x1440@60",3440,1440,60.0,1.0,[1.0],{}]'
|
||||||
|
'],{}]],[],{}]}'
|
||||||
|
)
|
||||||
|
|
||||||
|
_XRANDR_60 = """Screen 0: minimum 8 x 8, current 3440 x 1440, maximum 16384 x 16384
|
||||||
|
DP-1 connected primary 3440x1440+0+0 (normal left inverted right x axis y axis) 800mm x 335mm
|
||||||
|
3440x1440 60.00*+ 165.00 100.00
|
||||||
|
2560x1440 165.00 60.00
|
||||||
|
HDMI-1 disconnected (normal left inverted right x axis y axis)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class MutterParseTests(unittest.TestCase):
|
||||||
|
def test_parses_and_flags_higher_refresh(self):
|
||||||
|
mons = displays._parse_mutter(_MUTTER_60)
|
||||||
|
self.assertEqual(len(mons), 1)
|
||||||
|
m = mons[0]
|
||||||
|
self.assertEqual(m.connector, "DP-1")
|
||||||
|
self.assertEqual(m.name, "Samsung LC34G55T") # PNP code SAM mapped
|
||||||
|
self.assertEqual((m.width, m.height), (3440, 1440))
|
||||||
|
self.assertEqual(round(m.refresh), 60)
|
||||||
|
self.assertEqual(round(m.max_refresh), 165)
|
||||||
|
self.assertTrue(m.can_go_faster)
|
||||||
|
|
||||||
|
def test_at_max_is_not_flagged(self):
|
||||||
|
m = displays._parse_mutter(_MUTTER_MAX)[0]
|
||||||
|
self.assertEqual(round(m.refresh), 165)
|
||||||
|
self.assertFalse(m.can_go_faster)
|
||||||
|
|
||||||
|
def test_garbage_returns_empty(self):
|
||||||
|
self.assertEqual(displays._parse_mutter("not json"), [])
|
||||||
|
self.assertEqual(displays._parse_mutter("{}"), [])
|
||||||
|
|
||||||
|
|
||||||
|
class XrandrParseTests(unittest.TestCase):
|
||||||
|
def test_current_and_max_refresh(self):
|
||||||
|
mons = displays._parse_xrandr(_XRANDR_60)
|
||||||
|
self.assertEqual(len(mons), 1) # disconnected output ignored
|
||||||
|
m = mons[0]
|
||||||
|
self.assertEqual(m.connector, "DP-1")
|
||||||
|
self.assertEqual((m.width, m.height), (3440, 1440))
|
||||||
|
self.assertEqual(round(m.refresh), 60)
|
||||||
|
self.assertEqual(round(m.max_refresh), 165)
|
||||||
|
self.assertTrue(m.can_go_faster)
|
||||||
|
|
||||||
|
def test_empty_returns_empty(self):
|
||||||
|
self.assertEqual(displays._parse_xrandr(""), [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+39
-1
@@ -4,11 +4,13 @@ import unittest
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from rigdoctor.core import health
|
from rigdoctor.core import displays, health
|
||||||
from rigdoctor.core.health import (
|
from rigdoctor.core.health import (
|
||||||
CRITICAL,
|
CRITICAL,
|
||||||
INFO,
|
INFO,
|
||||||
WARNING,
|
WARNING,
|
||||||
|
check_displays,
|
||||||
|
check_memory_speed,
|
||||||
check_pcie_links,
|
check_pcie_links,
|
||||||
run_health_checks,
|
run_health_checks,
|
||||||
scan_journal_text,
|
scan_journal_text,
|
||||||
@@ -81,5 +83,41 @@ class PcieLinkCheckTests(unittest.TestCase):
|
|||||||
self.assertEqual(check_pcie_links(), [])
|
self.assertEqual(check_pcie_links(), [])
|
||||||
|
|
||||||
|
|
||||||
|
class DisplayCheckTests(unittest.TestCase):
|
||||||
|
def test_lower_than_max_refresh_is_flagged(self):
|
||||||
|
mon = displays.Monitor("DP-1", "Samsung LC34G55T", 3440, 1440, 60.0, 165.0)
|
||||||
|
with mock.patch("rigdoctor.core.displays.collect", return_value=[mon]):
|
||||||
|
findings = check_displays()
|
||||||
|
self.assertEqual(len(findings), 1)
|
||||||
|
self.assertEqual(findings[0].severity, INFO)
|
||||||
|
self.assertIn("165", findings[0].title)
|
||||||
|
|
||||||
|
def test_at_max_refresh_no_finding(self):
|
||||||
|
mon = displays.Monitor("DP-1", "Samsung LC34G55T", 3440, 1440, 165.0, 165.0)
|
||||||
|
with mock.patch("rigdoctor.core.displays.collect", return_value=[mon]):
|
||||||
|
self.assertEqual(check_displays(), [])
|
||||||
|
|
||||||
|
|
||||||
|
class MemorySpeedCheckTests(unittest.TestCase):
|
||||||
|
def _dmi(self, configured, part):
|
||||||
|
return {"memory": [{"Configured Memory Speed": configured, "Speed": configured,
|
||||||
|
"Part Number": part}]}
|
||||||
|
|
||||||
|
def test_flags_unapplied_expo(self):
|
||||||
|
dmi = self._dmi("4800 MT/s", "CMK32GX5M2B5600Z36")
|
||||||
|
with mock.patch("rigdoctor.core.elevation.privileged", return_value=None), \
|
||||||
|
mock.patch("rigdoctor.core.inventory._dmidecode", return_value=dmi):
|
||||||
|
findings = check_memory_speed()
|
||||||
|
self.assertEqual(len(findings), 1)
|
||||||
|
self.assertEqual(findings[0].severity, INFO)
|
||||||
|
self.assertIn("5600", findings[0].title)
|
||||||
|
|
||||||
|
def test_no_flag_at_rated(self):
|
||||||
|
dmi = self._dmi("5600 MT/s", "CMK32GX5M2B5600Z36")
|
||||||
|
with mock.patch("rigdoctor.core.elevation.privileged", return_value=None), \
|
||||||
|
mock.patch("rigdoctor.core.inventory._dmidecode", return_value=dmi):
|
||||||
|
self.assertEqual(check_memory_speed(), [])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -54,5 +54,23 @@ class PcieLinkTests(unittest.TestCase):
|
|||||||
self.assertEqual(inventory._nvme_link("sda"), "")
|
self.assertEqual(inventory._nvme_link("sda"), "")
|
||||||
|
|
||||||
|
|
||||||
|
class MemorySpeedTests(unittest.TestCase):
|
||||||
|
def test_rated_speed_from_part_number(self):
|
||||||
|
self.assertEqual(inventory._rated_from_part("CMK32GX5M2B5600Z36"), 5600)
|
||||||
|
self.assertEqual(inventory._rated_from_part("F5-6000J3038F16G"), 6000)
|
||||||
|
self.assertIsNone(inventory._rated_from_part("NoSpeedHere"))
|
||||||
|
|
||||||
|
def test_detects_unapplied_expo(self):
|
||||||
|
# XMP/EXPO off: dmidecode only sees JEDEC 4800; the 5600 is in the part number.
|
||||||
|
m = {"Configured Memory Speed": "4800 MT/s", "Speed": "4800 MT/s",
|
||||||
|
"Part Number": "CMK32GX5M2B5600Z36"}
|
||||||
|
self.assertEqual(inventory.module_speed(m), (4800, 5600))
|
||||||
|
|
||||||
|
def test_at_rated_speed(self):
|
||||||
|
m = {"Configured Memory Speed": "5600 MT/s", "Speed": "5600 MT/s",
|
||||||
|
"Part Number": "CMK32GX5M2B5600Z36"}
|
||||||
|
self.assertEqual(inventory.module_speed(m), (5600, 5600))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user