Compare commits

..

2 Commits

Author SHA1 Message Date
jessey fb468e83c2 Merge pull request 'feat(displays): monitors w/ resolution+refresh in Inventory; flag sub-max refresh in Health — 0.39.0' (#43) from feat/displays into main
release / test (push) Successful in 12s
release / release (push) Successful in 15s
Reviewed-on: #43
2026-05-22 14:56:15 +00:00
jessey b006fa6b8d feat(displays): monitors w/ resolution+refresh in Inventory; flag sub-max refresh in Health — 0.39.0
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 27s
New core/displays.py reads connected monitors via GNOME Mutter DisplayConfig over
D-Bus (busctl --json; works on X11 + Wayland), falling back to xrandr on other X11
desktops. Inventory's Display section now lists each monitor's resolution + current
refresh (e.g. 'DP-1 · Samsung LC34G55T: 3440x1440 @ 165 Hz'). System Health
(check_displays) flags a monitor running below its max refresh AT THE CURRENT
resolution (e.g. 165 Hz panel set to 60 Hz) — never suggests lowering resolution.
+tests (Mutter JSON + xrandr parsers, health check).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:55:33 +02:00
8 changed files with 273 additions and 5 deletions
+11
View File
@@ -5,6 +5,17 @@ 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.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
### Added
- **PCIe link in the Inventory.** Each NVMe drive now shows its negotiated PCIe link next to the
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "rigdoctor"
version = "0.38.0"
version = "0.39.0"
description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
readme = "README.md"
requires-python = ">=3.11"
+1 -1
View File
@@ -1,3 +1,3 @@
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
__version__ = "0.38.0"
__version__ = "0.39.0"
+148
View File
@@ -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 []
+18
View File
@@ -283,6 +283,23 @@ def check_pcie_links() -> list[Finding]:
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 run_health_checks(include_journal: bool = True) -> list[Finding]:
"""Run all checks and return findings sorted by severity (worst first).
@@ -306,5 +323,6 @@ def run_health_checks(include_journal: bool = True) -> list[Finding]:
findings += check_smart()
findings += check_live_temps()
findings += check_pcie_links()
findings += check_displays()
findings.sort(key=lambda f: _ORDER.get(f.severity, 9))
return findings
+10 -2
View File
@@ -201,10 +201,18 @@ def _storage() -> Section:
def _display() -> Section:
return Section("Display", [
from . import displays
items = [
("Session", os.environ.get("XDG_SESSION_TYPE", "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:
+67
View File
@@ -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()
+17 -1
View File
@@ -4,11 +4,12 @@ import unittest
from pathlib import Path
from unittest import mock
from rigdoctor.core import health
from rigdoctor.core import displays, health
from rigdoctor.core.health import (
CRITICAL,
INFO,
WARNING,
check_displays,
check_pcie_links,
run_health_checks,
scan_journal_text,
@@ -81,5 +82,20 @@ class PcieLinkCheckTests(unittest.TestCase):
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(), [])
if __name__ == "__main__":
unittest.main()