aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-11-09 18:59:58 +0100
committerDanilo M. <danix@danix.xyz>2026-11-09 18:59:58 +0100
commit84ac47a8ab196ee20ad778219bfa13df48f87241 (patch)
tree313118623d690a33674a93213bc4c62f6a6ad157
parenta9d709f6dd893b6fab6750b67f0c4f367458f40c (diff)
downloaddeck-it-84ac47a8ab196ee20ad778219bfa13df48f87241.tar.gz
deck-it-84ac47a8ab196ee20ad778219bfa13df48f87241.zip
feat: Intel xe GPU stats (temp, fan, VRAM)
-rw-r--r--config.toml4
-rw-r--r--deckit/modules/stats.py95
-rw-r--r--docs/superpowers/specs/2026-11-09-deck-it-design.md2
-rw-r--r--static/modules/stats.js10
-rw-r--r--tests/test_stats.py23
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()