diff options
| -rw-r--r-- | config.toml | 4 | ||||
| -rw-r--r-- | deckit/modules/stats.py | 95 | ||||
| -rw-r--r-- | docs/superpowers/specs/2026-11-09-deck-it-design.md | 2 | ||||
| -rw-r--r-- | static/modules/stats.js | 10 | ||||
| -rw-r--r-- | tests/test_stats.py | 23 |
5 files changed, 94 insertions, 40 deletions
diff --git a/config.toml b/config.toml index 4a818bc..c22b86b 100644 --- a/config.toml +++ b/config.toml @@ -21,11 +21,13 @@ display = 5900 [hardware] # Each one is "auto" or a fixed value, like net = "wlan0", battery = "BAT1", -# cpu_sensor = "coretemp", gpu = "nvidia" | "amd" | "none". +# cpu_sensor = "coretemp". GPU is "auto" (Intel xe) or "none". net = "auto" battery = "auto" cpu_sensor = "auto" gpu = "auto" +# The xe driver reports no VRAM capacity, so give the card's total here. +gpu_mem_total = "12G" [modules] # A module is disabled by setting it false. A new module file under diff --git a/deckit/modules/stats.py b/deckit/modules/stats.py index 800524c..255efc6 100644 --- a/deckit/modules/stats.py +++ b/deckit/modules/stats.py @@ -1,19 +1,17 @@ -"""System stats: CPU, memory, temperature, GPU, network, uptime.""" +"""System stats: CPU, memory, GPU (Intel xe), network, uptime. -import shutil +The Arc driver exposes temperature and fan through hwmon and no VRAM capacity; +VRAM in use comes from the DRM fdinfo scan, as quickshell's perf panel does. +""" + +import re import socket -import subprocess import time from pathlib import Path from ..module import Module - -def _run(cmd, timeout=2): - try: - return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout).stdout.strip() - except (OSError, subprocess.TimeoutExpired): - return "" +UNITS = {"": 1, "K": 1024, "M": 1024 ** 2, "G": 1024 ** 3, "T": 1024 ** 4} def _read(path, default=""): @@ -23,6 +21,39 @@ def _read(path, default=""): return default +def _parse_bytes(value): + if isinstance(value, int): + return value + m = re.fullmatch(r"(\d+(?:\.\d+)?)\s*([KMGT]?)(?:i?B)?", str(value).strip(), re.I) + if not m: + return None + return int(float(m[1]) * UNITS[m[2].upper()]) + + +def _intel_vram_used(root=Path("/proc")): + """Sum drm-resident-vram0 over xe clients, counting each client id once.""" + seen, total = set(), 0 + for fdinfo in root.glob("[0-9]*/fdinfo/*"): + try: + text = fdinfo.read_text() + except OSError: + continue + fields = {} + for line in text.splitlines(): + key, sep, value = line.partition(":") + if sep and key.startswith("drm-"): + fields[key] = value.strip() + client, resident = fields.get("drm-client-id"), fields.get("drm-resident-vram0") + if fields.get("drm-driver") != "xe" or not client or client in seen or not resident: + continue + m = re.match(r"(\d+)\s*([KMGT]?)(?:i?B)?", resident) + if not m: + continue + seen.add(client) + total += int(m[1]) * UNITS[m[2].upper()] + return total + + def _cpu_sensor(hw): names = {_read(h / "name"): h for h in Path("/sys/class/hwmon").glob("hwmon*")} want = hw.get("cpu_sensor", "auto") @@ -43,15 +74,11 @@ def _battery(hw): def _gpu(hw): - want = hw.get("gpu", "auto") - if want in ("auto", "nvidia") and shutil.which("nvidia-smi"): - for dev in Path("/sys/bus/pci/drivers/nvidia").glob("0000:*"): - info = _read(next(Path("/proc/driver/nvidia/gpus").glob(f"{dev.name}*/information"), "/dev/null")) - model = next((l.split(":", 1)[1].strip() for l in info.splitlines() if l.startswith("Model")), "NVIDIA GPU") - return {"kind": "nvidia", "dev": dev, "name": model.removeprefix("NVIDIA ").removeprefix("GeForce ")} - if want in ("auto", "amd"): - for busy in sorted(Path("/sys/class/drm").glob("card*/device/gpu_busy_percent")): - return {"kind": "amd", "dev": busy.parent, "name": "AMD GPU"} + if hw.get("gpu", "auto") == "none": + return None + for name in Path("/sys/class/hwmon").glob("hwmon*/name"): + if _read(name) == "xe": + return {"hwmon": name.parent, "name": "Intel Arc"} return None @@ -70,6 +97,7 @@ def make(cfg: dict) -> Module: cpu_sensor = _cpu_sensor(hw) battery = _battery(hw) gpu = _gpu(hw) + gpu_total = _parse_bytes(hw.get("gpu_mem_total", "")) if gpu else None prev = {"cpu": None, "net": None, "t": None, "gpu_t": 0, "gpu_v": None} def cpu_percent(): @@ -98,26 +126,19 @@ def make(cfg: dict) -> Module: def read_gpu(): if not gpu: return None - # Querying a suspended dGPU wakes it, so only ask when it is already awake. - if _read(gpu["dev"] / "power/runtime_status") == "suspended": - return {"asleep": True, "name": gpu["name"]} - if time.time() - prev["gpu_t"] < 5: + # The fdinfo scan touches every process, so keep it to once every 2 s. + if time.time() - prev["gpu_t"] < 2: return prev["gpu_v"] - v = None - try: - if gpu["kind"] == "nvidia": - out = _run(["nvidia-smi", "--query-gpu=utilization.gpu,temperature.gpu,memory.used,memory.total", - "--format=csv,noheader,nounits"]) - u, t, mu, mt = map(int, out.splitlines()[0].split(", ")) - else: - d = gpu["dev"] - u = int(_read(d / "gpu_busy_percent")) - temp = next(d.glob("hwmon/hwmon*/temp1_input"), None) - t = round(int(_read(temp)) / 1000) if temp else None - mu, mt = (int(_read(d / f"mem_info_vram_{x}", "0")) // 1048576 for x in ("used", "total")) - v = {"asleep": False, "name": gpu["name"], "util": u, "temp": t, "memUsed": mu, "memTotal": mt} - except (ValueError, IndexError): - pass + temp = _read(gpu["hwmon"] / "temp2_input") + fan = _read(gpu["hwmon"] / "fan1_input") + v = { + "name": gpu["name"], + "util": None, + "temp": round(int(temp) / 1000) if temp else None, + "fan": int(fan) if fan else None, + "memUsed": _intel_vram_used(), + "memTotal": gpu_total, + } prev.update(gpu_t=time.time(), gpu_v=v) return v diff --git a/docs/superpowers/specs/2026-11-09-deck-it-design.md b/docs/superpowers/specs/2026-11-09-deck-it-design.md index fad1e4f..3fbc7c8 100644 --- a/docs/superpowers/specs/2026-11-09-deck-it-design.md +++ b/docs/superpowers/specs/2026-11-09-deck-it-design.md @@ -110,7 +110,7 @@ Reuse tabdeck's model unchanged in spirit: | Module | Source | Actions | |---|---|---| -| stats | /proc, sysfs, nvidia-smi | none (read) | +| stats | /proc, sysfs (Intel xe: temp, fan, VRAM) | none (read) | | music | playerctl + wpctl | play/pause, prev/next, volume ±/mute | | windows | hyprctl clients/activewindow/workspaces | workspace switch, focus/close, launch (config commands) | | obs | obs-websocket v5 client | set scene, toggle record/stream, input volume/mute, set/trigger transition, toggle source | diff --git a/static/modules/stats.js b/static/modules/stats.js index b29b4ae..a119c6c 100644 --- a/static/modules/stats.js +++ b/static/modules/stats.js @@ -6,10 +6,18 @@ const uptime = (s) => { const m = Math.floor((s % 3600) / 60); return d ? `${d}d ${h}h` : `${h}h ${m}m`; }; +const gib = (b) => (b / 1073741824).toFixed(1); const gpu = (g) => { if (!g) return "—"; if (g.asleep) return `${g.name} (asleep)`; - return `${g.name} ${g.util}%${g.temp != null ? ` · ${g.temp}°C` : ""}`; + const parts = [g.name]; + if (g.util != null) parts.push(`${g.util}%`); + if (g.temp != null) parts.push(`${g.temp}°C`); + if (g.fan != null) parts.push(`${g.fan} rpm`); + if (g.memUsed != null) { + parts.push(g.memTotal ? `${gib(g.memUsed)} / ${gib(g.memTotal)} GiB` : `${gib(g.memUsed)} GiB`); + } + return parts.join(" · "); }; function row(label, value) { diff --git a/tests/test_stats.py b/tests/test_stats.py index fad6cc2..81acdba 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -21,5 +21,28 @@ class Stats(unittest.TestCase): self.assertIn("stats", names) +class Intel(unittest.TestCase): + def test_parse_bytes(self): + self.assertEqual(stats._parse_bytes("12G"), 12 * 1024 ** 3) + self.assertEqual(stats._parse_bytes("512M"), 512 * 1024 ** 2) + self.assertEqual(stats._parse_bytes(2048), 2048) + self.assertIsNone(stats._parse_bytes("nope")) + + def test_vram_scan_counts_each_client_once(self): + import tempfile + root = Path(tempfile.mkdtemp()) + rows = [ + ("100", "3", "drm-driver:\txe\ndrm-client-id:\t7\ndrm-resident-vram0:\t1024 MiB\n"), + ("100", "4", "drm-driver:\txe\ndrm-client-id:\t7\ndrm-resident-vram0:\t1024 MiB\n"), + ("200", "5", "drm-driver:\txe\ndrm-client-id:\t8\ndrm-resident-vram0:\t512 MiB\n"), + ("300", "6", "drm-driver:\ti915\ndrm-client-id:\t9\ndrm-resident-vram0:\t999 MiB\n"), + ] + for pid, fd, body in rows: + d = root / pid / "fdinfo" + d.mkdir(parents=True, exist_ok=True) + (d / fd).write_text(body) + self.assertEqual(stats._intel_vram_used(root), (1024 + 512) * 1024 ** 2) + + if __name__ == "__main__": unittest.main() |
