From 9bb0f9a6840f8cd85fb769ec69ec7fe7470c057a Mon Sep 17 00:00:00 2001 From: Jessey van Offeren Date: Fri, 22 May 2026 16:45:08 +0200 Subject: [PATCH] =?UTF-8?q?feat(inventory):=20show=20NVMe=20PCIe=20link=20?= =?UTF-8?q?gen/width,=20flag=20downtrains=20=E2=80=94=200.38.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each NVMe drive's Inventory entry now shows its negotiated PCIe link (e.g. '· PCIe Gen4 x4') from sysfs (current/max link speed+width), and flags drives running below their capability ('Gen3 x4 (capable of Gen4 x4)') — so you can confirm a Gen4 SSD is in a Gen4 slot. SATA disks show no PCIe link. Renders in the GUI Inventory, CLI, and the Markdown/JSON export automatically. +tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 8 ++++++ pyproject.toml | 2 +- src/rigdoctor/__init__.py | 2 +- src/rigdoctor/core/inventory.py | 47 ++++++++++++++++++++++++++++++++- tests/test_inventory.py | 28 ++++++++++++++++++++ 5 files changed, 84 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 970ebd9..9d84e0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ 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.38.0] - 2026-05-22 +### Added +- **PCIe link in the Inventory.** Each NVMe drive now shows its negotiated PCIe link next to the + model — e.g. `Samsung SSD 980 PRO 1TB (931.5G) · PCIe Gen4 x4` — read from sysfs + (`current/max_link_speed` + width). If a drive negotiates below its capability (a slower M.2 + slot, lane-sharing, or a downtrain) it's flagged: `PCIe Gen3 x4 (capable of Gen4 x4)`. So you + can confirm a Gen4 SSD is actually in a Gen4 slot. (SATA disks show no PCIe link.) + ## [0.37.1] - 2026-05-22 ### Fixed - **`rigdoctor update` now uses the right method for how RigDoctor was installed.** It detects diff --git a/pyproject.toml b/pyproject.toml index 0e5d594..f05eb1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "rigdoctor" -version = "0.37.1" +version = "0.38.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 3d2cff1..13c9117 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.37.1" +__version__ = "0.38.0" diff --git a/src/rigdoctor/core/inventory.py b/src/rigdoctor/core/inventory.py index 8810b30..f0e43c5 100644 --- a/src/rigdoctor/core/inventory.py +++ b/src/rigdoctor/core/inventory.py @@ -9,6 +9,7 @@ from __future__ import annotations import json import os import platform +import re import shutil import subprocess from dataclasses import dataclass @@ -123,6 +124,46 @@ def _gpu() -> Section: return Section("GPU", [("Device", g) for g in gpus] or [("Device", "unknown")]) +# PCIe link speed (GT/s) → generation. +_PCIE_GEN = {"2.5": 1, "5": 2, "5.0": 2, "8": 3, "8.0": 3, "16": 4, "16.0": 4, "32": 5, "32.0": 5} + + +def _gen(speed: str) -> int | None: + """Map a sysfs link speed like '16.0 GT/s PCIe' to its PCIe generation (4).""" + tok = speed.strip().split()[0] if speed.strip() else "" + return _PCIE_GEN.get(tok) + + +def _link_desc(dev: Path) -> str: + """Describe a PCI device's negotiated PCIe link, noting if it's below its max. + + e.g. 'PCIe Gen4 x4', or 'PCIe Gen3 x4 (capable of Gen4 x4)' when downtrained / in a + slower slot. + """ + def rd(name: str) -> str: + try: + return (dev / name).read_text().strip() + except OSError: + return "" + + cur_g, cur_w = _gen(rd("current_link_speed")), rd("current_link_width") + max_g, max_w = _gen(rd("max_link_speed")), rd("max_link_width") + if not cur_g or not cur_w: + return "" + desc = f"PCIe Gen{cur_g} x{cur_w}" + if max_g and (cur_g < max_g or (max_w and cur_w != max_w)): + desc += f" (capable of Gen{max_g} x{max_w})" + return desc + + +def _nvme_link(block_name: str) -> str: + """PCIe link for an NVMe block device (nvme0n1 → controller nvme0); '' for non-NVMe.""" + m = re.match(r"(nvme\d+)", block_name) + if not m: + return "" + return _link_desc(Path("/sys/class/nvme") / m.group(1) / "device") + + def _storage() -> Section: items: list[tuple[str, str]] = [] # TYPE first so MODEL (which can contain spaces) is the trailing field. @@ -133,7 +174,11 @@ def _storage() -> Section: continue name, size = parts[1], parts[2] model = parts[3] if len(parts) > 3 else "" - items.append((name, f"{model} ({size})".strip())) + desc = f"{model} ({size})".strip() + link = _nvme_link(name) # NVMe PCIe gen/width (e.g. Gen4 x4), flags downtrains + if link: + desc += f" · {link}" + items.append((name, desc)) return Section("Storage", items or [("Disks", "unknown")]) diff --git a/tests/test_inventory.py b/tests/test_inventory.py index 59ed77f..fc78804 100644 --- a/tests/test_inventory.py +++ b/tests/test_inventory.py @@ -1,6 +1,8 @@ """Tests for the M5 system inventory (render + dict round-trip; collect on real system).""" +import tempfile import unittest +from pathlib import Path from rigdoctor.core import inventory from rigdoctor.core.inventory import Section @@ -26,5 +28,31 @@ class InventoryTests(unittest.TestCase): self.assertIn("- **Model:** Test CPU", md) +class PcieLinkTests(unittest.TestCase): + def test_gen_mapping(self): + self.assertEqual(inventory._gen("16.0 GT/s PCIe"), 4) + self.assertEqual(inventory._gen("8.0 GT/s PCIe"), 3) + self.assertIsNone(inventory._gen("")) + + def _fake_dev(self, cur_s, cur_w, max_s, max_w) -> Path: + d = Path(tempfile.mkdtemp()) + (d / "current_link_speed").write_text(cur_s) + (d / "current_link_width").write_text(cur_w) + (d / "max_link_speed").write_text(max_s) + (d / "max_link_width").write_text(max_w) + return d + + def test_link_at_full_speed(self): + dev = self._fake_dev("16.0 GT/s PCIe", "4", "16.0 GT/s PCIe", "4") + self.assertEqual(inventory._link_desc(dev), "PCIe Gen4 x4") + + def test_link_downtrained_flags_capability(self): + dev = self._fake_dev("8.0 GT/s PCIe", "4", "16.0 GT/s PCIe", "4") + self.assertEqual(inventory._link_desc(dev), "PCIe Gen3 x4 (capable of Gen4 x4)") + + def test_non_nvme_has_no_link(self): + self.assertEqual(inventory._nvme_link("sda"), "") + + if __name__ == "__main__": unittest.main()