From b006fa6b8d1167c9fb4af74b45a7f66ebe7fad4e Mon Sep 17 00:00:00 2001 From: Jessey van Offeren Date: Fri, 22 May 2026 16:55:33 +0200 Subject: [PATCH] =?UTF-8?q?feat(displays):=20monitors=20w/=20resolution+re?= =?UTF-8?q?fresh=20in=20Inventory;=20flag=20sub-max=20refresh=20in=20Healt?= =?UTF-8?q?h=20=E2=80=94=200.39.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 11 +++ pyproject.toml | 2 +- src/rigdoctor/__init__.py | 2 +- src/rigdoctor/core/displays.py | 148 ++++++++++++++++++++++++++++++++ src/rigdoctor/core/health.py | 18 ++++ src/rigdoctor/core/inventory.py | 12 ++- tests/test_displays.py | 67 +++++++++++++++ tests/test_health.py | 18 +++- 8 files changed, 273 insertions(+), 5 deletions(-) create mode 100644 src/rigdoctor/core/displays.py create mode 100644 tests/test_displays.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a264089..32e6308 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index f05eb1f..3a54b29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/rigdoctor/__init__.py b/src/rigdoctor/__init__.py index 13c9117..798a259 100644 --- a/src/rigdoctor/__init__.py +++ b/src/rigdoctor/__init__.py @@ -1,3 +1,3 @@ """RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers.""" -__version__ = "0.38.0" +__version__ = "0.39.0" diff --git a/src/rigdoctor/core/displays.py b/src/rigdoctor/core/displays.py new file mode 100644 index 0000000..4081ebf --- /dev/null +++ b/src/rigdoctor/core/displays.py @@ -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 [] diff --git a/src/rigdoctor/core/health.py b/src/rigdoctor/core/health.py index c54a47b..e271f13 100644 --- a/src/rigdoctor/core/health.py +++ b/src/rigdoctor/core/health.py @@ -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 diff --git a/src/rigdoctor/core/inventory.py b/src/rigdoctor/core/inventory.py index 330fca4..2393f72 100644 --- a/src/rigdoctor/core/inventory.py +++ b/src/rigdoctor/core/inventory.py @@ -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: diff --git a/tests/test_displays.py b/tests/test_displays.py new file mode 100644 index 0000000..b46581b --- /dev/null +++ b/tests/test_displays.py @@ -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() diff --git a/tests/test_health.py b/tests/test_health.py index de6bf6e..6485017 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -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()