diff --git a/config.yaml b/config.yaml index 8aef493e4..5dcf95eef 100644 --- a/config.yaml +++ b/config.yaml @@ -10,7 +10,7 @@ config: # Theme to use (located in res/themes) # Use the name of the folder as value # Choose a theme made for your screen size (see DISPLAY_SIZE inside theme.yaml) - THEME: 3.5inchTheme2 + THEME: Steam OS # Hardware sensors reading # Choose the appropriate method for reading your hardware sensors: diff --git a/library/sensors/gpu_fdinfo.py b/library/sensors/gpu_fdinfo.py new file mode 100644 index 000000000..d88abd17f --- /dev/null +++ b/library/sensors/gpu_fdinfo.py @@ -0,0 +1,161 @@ +"""Read AMD GPU activity from DRM client fdinfo counters.""" + +import os +import threading +import time + + +DRM_DEVICE_PREFIX = "/dev/dri/" +REDISCOVER_SECONDS = 15.0 +YOUNG_PROCESS_SECONDS = 120.0 +FULL_RESCAN_SECONDS = 300.0 + + +def _engine_counter(text): + client_id = None + is_amdgpu = False + total_ns = 0 + + for line in text.splitlines(): + if line.startswith("drm-driver:"): + is_amdgpu = line.partition(":")[2].strip() == "amdgpu" + elif line.startswith("drm-client-id:"): + client_id = line.partition(":")[2].strip() or None + elif line.startswith("drm-engine-") and not line.startswith("drm-engine-capacity-"): + fields = line.partition(":")[2].split() + if len(fields) >= 2 and fields[1] == "ns" and fields[0].isdigit(): + total_ns += int(fields[0]) + + return (client_id, total_ns) if is_amdgpu else None + + +class DrmFdinfoSampler: + """Sample cumulative AMDGPU engine time without repeatedly scanning /proc.""" + + def __init__(self, proc="/proc", clock=time.monotonic, clock_ns=time.monotonic_ns): + self._proc = str(proc) + self._clock = clock + self._clock_ns = clock_ns + self._lock = threading.Lock() + self._busy_lock = threading.Lock() + self._fdinfo_paths = [] + self._discovered_at = None + self._full_scan_at = None + self._by_pid = {} + self._previous = None + self._previous_at_ns = None + + def _read(self, path): + try: + with open(path, encoding="utf-8", errors="replace") as handle: + return handle.read() + except OSError: + return None + + def _uptime(self): + text = self._read(os.path.join(self._proc, "uptime")) + try: + return float(text.split()[0]) if text else None + except (IndexError, ValueError): + return None + + def _started(self, pid): + text = self._read(os.path.join(self._proc, pid, "stat")) + if not text: + return None + fields = text.rpartition(")")[2].split() + try: + return int(fields[19]) / os.sysconf("SC_CLK_TCK") + except (IndexError, ValueError, OSError): + return None + + def _scan_pid(self, pid): + paths = [] + fd_dir = os.path.join(self._proc, pid, "fd") + try: + fds = os.listdir(fd_dir) + except OSError: + return paths + for fd in fds: + try: + target = os.readlink(os.path.join(fd_dir, fd)) + except OSError: + continue + if target.startswith(DRM_DEVICE_PREFIX): + paths.append(os.path.join(self._proc, pid, "fdinfo", fd)) + return paths + + def _discover(self, now): + try: + pids = [name for name in os.listdir(self._proc) if name.isdigit()] + except OSError: + return [] + + if self._full_scan_at is None or now - self._full_scan_at >= FULL_RESCAN_SECONDS: + self._full_scan_at = now + self._by_pid = {} + + uptime = self._uptime() + by_pid = {} + for pid in pids: + known = self._by_pid.get(pid) + if known is None: + by_pid[pid] = (self._started(pid), self._scan_pid(pid)) + continue + started, paths = known + young = ( + started is not None + and uptime is not None + and uptime - started < YOUNG_PROCESS_SECONDS + ) + by_pid[pid] = (started, self._scan_pid(pid)) if not paths and young else known + self._by_pid = by_pid + return [path for _started, paths in by_pid.values() for path in paths] + + def sample(self): + """Return cumulative engine nanoseconds indexed by DRM client id.""" + with self._lock: + now = self._clock() + if self._discovered_at is None or now - self._discovered_at >= REDISCOVER_SECONDS: + self._fdinfo_paths = self._discover(now) + self._discovered_at = now + + counters = {} + alive = [] + for path in self._fdinfo_paths: + text = self._read(path) + if text is None: + continue + alive.append(path) + parsed = _engine_counter(text) + if parsed is None: + continue + client_id, total_ns = parsed + key = client_id or path + counters[key] = max(counters.get(key, 0), total_ns) + self._fdinfo_paths = alive + return counters + + def busy_percent(self): + """Return aggregate GPU busy percentage, or None until a baseline exists.""" + with self._busy_lock: + current = self.sample() + now_ns = self._clock_ns() + previous = self._previous + previous_at_ns = self._previous_at_ns + self._previous = current + self._previous_at_ns = now_ns + if previous is None or previous_at_ns is None: + return None + + elapsed_ns = now_ns - previous_at_ns + if elapsed_ns <= 0: + return None + shared_clients = previous.keys() & current.keys() + if not shared_clients: + return None + delta_ns = sum( + max(0, current[client_id] - previous[client_id]) + for client_id in shared_clients + ) + return max(0.0, min(100.0, delta_ns / elapsed_ns * 100.0)) \ No newline at end of file diff --git a/library/sensors/sensors_python.py b/library/sensors/sensors_python.py index fc76af1bd..2aa1ad281 100644 --- a/library/sensors/sensors_python.py +++ b/library/sensors/sensors_python.py @@ -21,7 +21,9 @@ # This file will use Python libraries (psutil, GPUtil, etc.) to get hardware sensors # For all platforms (Linux, Windows, macOS) but not all HW is supported +import glob import math +import os import platform import sys from collections import namedtuple @@ -34,6 +36,7 @@ import psutil import library.sensors.sensors as sensors +from library.sensors.gpu_fdinfo import DrmFdinfoSampler from library.log import logger # AMD GPU on Linux @@ -312,59 +315,120 @@ def is_available() -> bool: class GpuAmd(sensors.Gpu): - @staticmethod - def stats() -> Tuple[ - float, float, float, float, float]: # load (%) / used mem (%) / used mem (Mb) / total mem (Mb) / temp (°C) - if pyamdgpuinfo: - # Unlike other sensors, AMD GPU with pyamdgpuinfo pulls in all the stats at once - pyamdgpuinfo.detect_gpus() - amd_gpu = pyamdgpuinfo.get_gpu(0) + _fdinfo_sampler = DrmFdinfoSampler() + @staticmethod + def _amdgpu_sysfs_devices(): + devices = [] + for device in glob.glob("/sys/class/drm/card*/device"): try: - memory_used_bytes = amd_gpu.query_vram_usage() - memory_used = memory_used_bytes / 1024 / 1024 - except: - memory_used_bytes = math.nan - memory_used = math.nan + with open(os.path.join(device, "vendor"), encoding="ascii") as handle: + vendor = handle.read().strip().lower() + except OSError: + continue + driver = os.path.basename(os.path.realpath(os.path.join(device, "driver"))) + if vendor == "0x1002" and driver == "amdgpu": + devices.append(device) + return devices + @staticmethod + def _sysfs_gpu_busy_percent(): + for device in GpuAmd._amdgpu_sysfs_devices(): try: - memory_total_bytes = amd_gpu.memory_info["vram_size"] - memory_total = memory_total_bytes / 1024 / 1024 - except: - memory_total_bytes = math.nan - memory_total = math.nan + with open(os.path.join(device, "gpu_busy_percent"), encoding="ascii") as handle: + load = float(handle.read().strip()) + except (OSError, ValueError): + continue + if math.isfinite(load) and 0 <= load <= 100: + return load + return math.nan - try: - memory_percentage = (memory_used_bytes / memory_total_bytes) * 100 - except: - memory_percentage = math.nan + @staticmethod + def _gpu_load(amd_gpu=None): + load = GpuAmd._sysfs_gpu_busy_percent() + if math.isfinite(load): + return load + if amd_gpu is None and pyamdgpuinfo: try: - load = amd_gpu.query_load() * 100 - except: - load = math.nan - + if pyamdgpuinfo.detect_gpus() > 0: + amd_gpu = pyamdgpuinfo.get_gpu(0) + except Exception: + pass + if amd_gpu is not None: try: - temperature = amd_gpu.query_temperature() - except: - temperature = math.nan + load = float(amd_gpu.query_load()) * 100 + if math.isfinite(load) and 0 <= load <= 100: + return load + except Exception: + pass - return load, memory_percentage, memory_used, memory_total, temperature - elif pyadl: - amd_gpu = pyadl.ADLManager.getInstance().getDevices()[0] + load = GpuAmd._fdinfo_sampler.busy_percent() + if load is not None and math.isfinite(load): + return load + if pyadl: try: - load = amd_gpu.getCurrentUsage() - except: - load = math.nan + load = float(pyadl.ADLManager.getInstance().getDevices()[0].getCurrentUsage()) + if math.isfinite(load) and 0 <= load <= 100: + return load + except Exception: + pass + return math.nan + + @staticmethod + def stats() -> Tuple[ + float, float, float, float, float]: # load (%) / used mem (%) / used mem (Mb) / total mem (Mb) / temp (°C) + memory_used_bytes = math.nan + memory_total_bytes = math.nan + memory_used = math.nan + memory_total = math.nan + memory_percentage = math.nan + temperature = math.nan + amd_gpu = None + if pyamdgpuinfo: try: - temperature = amd_gpu.getCurrentTemperature() - except: - temperature = math.nan + if pyamdgpuinfo.detect_gpus() > 0: + amd_gpu = pyamdgpuinfo.get_gpu(0) + else: + amd_gpu = None + except Exception: + amd_gpu = None + + if amd_gpu: + try: + memory_used_bytes = amd_gpu.query_vram_usage() + memory_used = memory_used_bytes / 1024 / 1024 + except Exception: + pass + try: + memory_total_bytes = amd_gpu.memory_info["vram_size"] + memory_total = memory_total_bytes / 1024 / 1024 + except Exception: + pass + try: + memory_percentage = (memory_used_bytes / memory_total_bytes) * 100 + except Exception: + pass + try: + temperature = amd_gpu.query_temperature() + except Exception: + pass + + load = GpuAmd._gpu_load(amd_gpu) + + if pyadl: + try: + amd_gpu = pyadl.ADLManager.getInstance().getDevices()[0] + if not math.isfinite(load): + load = amd_gpu.getCurrentUsage() + if not math.isfinite(temperature): + temperature = amd_gpu.getCurrentTemperature() + except Exception: + pass - # GPU memory data not supported by pyadl - return load, math.nan, math.nan, math.nan, temperature + return load, memory_percentage, memory_used, memory_total, temperature @staticmethod def fps() -> int: @@ -406,15 +470,24 @@ def frequency() -> float: @staticmethod def is_available() -> bool: - try: - if pyamdgpuinfo and pyamdgpuinfo.detect_gpus() > 0: - return True - elif pyadl and len(pyadl.ADLManager.getInstance().getDevices()) > 0: - return True - else: - return False - except: - return False + available = False + if pyamdgpuinfo: + try: + if pyamdgpuinfo.detect_gpus() > 0: + available = True + except Exception: + pass + if not available and pyadl: + try: + if len(pyadl.ADLManager.getInstance().getDevices()) > 0: + available = True + except Exception: + pass + if not available: + available = bool(GpuAmd._amdgpu_sysfs_devices()) + if available and not math.isfinite(GpuAmd._sysfs_gpu_busy_percent()): + GpuAmd._fdinfo_sampler.busy_percent() + return available class Memory(sensors.Memory): diff --git a/res/themes/Steam OS.zip b/res/themes/Steam OS.zip new file mode 100644 index 000000000..7bf1b1fd6 Binary files /dev/null and b/res/themes/Steam OS.zip differ diff --git a/res/themes/Steam OS/background.png b/res/themes/Steam OS/background.png new file mode 100644 index 000000000..77b061b48 Binary files /dev/null and b/res/themes/Steam OS/background.png differ diff --git a/res/themes/Steam OS/background.png~ b/res/themes/Steam OS/background.png~ new file mode 100644 index 000000000..4379667e4 Binary files /dev/null and b/res/themes/Steam OS/background.png~ differ diff --git a/res/themes/Steam OS/preview.png b/res/themes/Steam OS/preview.png new file mode 100644 index 000000000..0da362d97 Binary files /dev/null and b/res/themes/Steam OS/preview.png differ diff --git a/res/themes/Steam OS/theme.yaml b/res/themes/Steam OS/theme.yaml new file mode 100644 index 000000000..13dc194a1 --- /dev/null +++ b/res/themes/Steam OS/theme.yaml @@ -0,0 +1,198 @@ +--- +author: "Honorati" + +display: + DISPLAY_SIZE: 3.5" + DISPLAY_ORIENTATION: poltrait + DISPLAY_RGB_LED: 255, 255, 255 + +static_images: + BACKGROUND: + PATH: background.png + X: 0 + Y: 0 + WIDTH: 320 + HEIGHT: 480 + +STATS: + + CPU: + PERCENTAGE: + INTERVAL: 2 + TEXT: + SHOW: True + SHOW_UNIT: True + X: 130 + Y: 233 + FONT: generale-mono/GeneraleMonoA.ttf + FONT_SIZE: 25 + FONT_COLOR: 255, 255, 255 + BACKGROUND_IMAGE: background.png + ALIGN: right + ANCHOR: rt + + TEMPERATURE: + INTERVAL: 2 + TEXT: + SHOW: True + SHOW_UNIT: True + X: 173 + Y: 233 + FONT: generale-mono/GeneraleMonoA.ttf + FONT_SIZE: 25 + FONT_COLOR: 255, 193, 7 + BACKGROUND_IMAGE: background.png + ALIGN: left + ANCHOR: lt + + GPU: + INTERVAL: 2 + PERCENTAGE: + TEXT: + SHOW: True + SHOW_UNIT: True + X: 130 + Y: 308 + FONT: generale-mono/GeneraleMonoA.ttf + FONT_SIZE: 25 + FONT_COLOR: 255, 255, 255 + BACKGROUND_IMAGE: background.png + ALIGN: right + ANCHOR: rt + + TEMPERATURE: + INTERVAL: 2 + TEXT: + SHOW: True + SHOW_UNIT: True + X: 173 + Y: 308 + FONT: generale-mono/GeneraleMonoA.ttf + FONT_SIZE: 25 + FONT_COLOR: 255, 193, 7 + BACKGROUND_IMAGE: background.png + ALIGN: left + ANCHOR: lt + + MEMORY: + INTERVAL: 5 + VIRTUAL: + PERCENT_TEXT: + SHOW: True + SHOW_UNIT: True + X: 60 + Y: 365 + FONT: generale-mono/GeneraleMonoA.ttf + FONT_SIZE: 20 + FONT_COLOR: 255, 255, 255 + BACKGROUND_IMAGE: background.png + ALIGN: left + ANCHOR: lt + + USED: + SHOW: True + SHOW_UNIT: True + X: 10 + Y: 395 + FONT: generale-mono/GeneraleMonoA.ttf + FONT_SIZE: 15 + FONT_COLOR: 255, 255, 255 + BACKGROUND_IMAGE: background.png + ALIGN: left + ANCHOR: lt + + FREE: + SHOW: True + SHOW_UNIT: True + X: 10 + Y: 415 + FONT: generale-mono/GeneraleMonoA.ttf + FONT_SIZE: 15 + FONT_COLOR: 255, 255, 255 + BACKGROUND_IMAGE: background.png + ALIGN: left + ANCHOR: lt + + DISK: + INTERVAL: 60 + USED: + TEXT: + SHOW: True + SHOW_UNIT: True + X: 310 + Y: 395 + FONT: generale-mono/GeneraleMonoA.ttf + FONT_SIZE: 15 + FONT_COLOR: 255, 255, 255 + BACKGROUND_IMAGE: background.png + ALIGN: right + ANCHOR: rt + PERCENT_TEXT: + SHOW: True + SHOW_UNIT: True + X: 247 + Y: 365 + FONT: generale-mono/GeneraleMonoA.ttf + FONT_SIZE: 20 + FONT_COLOR: 255, 255, 255 + BACKGROUND_IMAGE: background.png + ALIGN: right + ANCHOR: rt + + TOTAL: + TEXT: + SHOW: True + SHOW_UNIT: True + X: 310 + Y: 415 + FONT: generale-mono/GeneraleMonoA.ttf + FONT_SIZE: 15 + FONT_COLOR: 255, 255, 255 + BACKGROUND_IMAGE: background.png + ALIGN: right + ANCHOR: rt + + + NET: + INTERVAL: 1 + ETH: + DOWNLOAD: + TEXT: + SHOW: True + X: 162 + Y: 428 + FONT: generale-mono/GeneraleMonoA.ttf + FONT_SIZE: 14 + FONT_COLOR: 255, 255, 255 + BACKGROUND_IMAGE: background.png + ALIGN: center + ANCHOR: mt + + UPLOAD: + TEXT: + SHOW: True + X: 162 + Y: 446 + FONT: generale-mono/GeneraleMonoA.ttf + FONT_SIZE: 14 + FONT_COLOR: 255, 255, 255 + BACKGROUND_IMAGE: background.png + ALIGN: center + ANCHOR: mt + + + DATE: + INTERVAL: 1 + HOUR: + TEXT: + FORMAT: short + SHOW: True + X: 310 + Y: 10 + FONT: generale-mono/GeneraleMonoA.ttf + FONT_SIZE: 20 + FONT_COLOR: 255, 255, 255 + BACKGROUND_IMAGE: background.png + ALIGN: right + ANCHOR: rt + diff --git a/tests/library/test_gpu_fdinfo.py b/tests/library/test_gpu_fdinfo.py new file mode 100644 index 000000000..c216888c0 --- /dev/null +++ b/tests/library/test_gpu_fdinfo.py @@ -0,0 +1,121 @@ +# SPDX-License-Identifier: GPL-3.0-or-later + +import math + +from library.sensors import gpu_fdinfo, sensors_python + + +def test_engine_counter_sums_amdgpu_engines_and_ignores_capacity(): + parsed = gpu_fdinfo._engine_counter( + "drm-driver:\tamdgpu\n" + "drm-client-id:\t7\n" + "drm-engine-gfx:\t200 ns\n" + "drm-engine-compute:\t300 ns\n" + "drm-engine-capacity-gfx:\t2\n" + ) + + assert parsed == ("7", 500) + assert gpu_fdinfo._engine_counter("drm-driver:\ti915\ndrm-engine-render:\t1 ns") is None + + +def test_busy_percent_uses_deltas_and_deduplicates_client_fds(tmp_path): + proc = tmp_path / "proc" + fd_dir = proc / "123" / "fd" + fdinfo_dir = proc / "123" / "fdinfo" + fd_dir.mkdir(parents=True) + fdinfo_dir.mkdir(parents=True) + (proc / "uptime").write_text("5000.0 0.0\n", encoding="ascii") + (proc / "123" / "stat").write_text( + "123 (game) S " + " ".join(["0"] * 19 + ["100"]), + encoding="ascii", + ) + (fd_dir / "3").symlink_to("/dev/dri/renderD128") + (fd_dir / "4").symlink_to("/dev/dri/renderD128") + first_fdinfo = fdinfo_dir / "3" + duplicate_fdinfo = fdinfo_dir / "4" + initial = "drm-driver:\tamdgpu\ndrm-client-id:\t7\ndrm-engine-gfx:\t0 ns\n" + first_fdinfo.write_text(initial, encoding="ascii") + duplicate_fdinfo.write_text(initial, encoding="ascii") + + monotonic_ns = iter((1_000_000_000, 2_000_000_000)) + sampler = gpu_fdinfo.DrmFdinfoSampler( + proc=proc, + clock=lambda: 1000.0, + clock_ns=lambda: next(monotonic_ns), + ) + + assert sampler.busy_percent() is None + + updated = "drm-driver:\tamdgpu\ndrm-client-id:\t7\ndrm-engine-gfx:\t500000000 ns\n" + first_fdinfo.write_text(updated, encoding="ascii") + duplicate_fdinfo.write_text(updated, encoding="ascii") + + assert sampler.busy_percent() == 50.0 + + +def test_busy_percent_is_unavailable_without_shared_clients(): + ticks = iter((1_000_000_000, 2_000_000_000)) + samples = iter(({}, {"new-client": 500_000_000})) + sampler = gpu_fdinfo.DrmFdinfoSampler(clock_ns=lambda: next(ticks)) + sampler.sample = lambda: next(samples) + + assert sampler.busy_percent() is None + assert sampler.busy_percent() is None + + +def test_amd_gpu_load_prefers_valid_sysfs_value(monkeypatch): + monkeypatch.setattr( + sensors_python.GpuAmd, + "_sysfs_gpu_busy_percent", + staticmethod(lambda: 73.0), + ) + monkeypatch.setattr( + sensors_python.GpuAmd._fdinfo_sampler, + "busy_percent", + lambda: (_ for _ in ()).throw(AssertionError("fdinfo should not be read")), + ) + + assert sensors_python.GpuAmd._gpu_load() == 73.0 + + +def test_amd_stats_returns_fdinfo_load_without_optional_bindings(monkeypatch): + monkeypatch.setattr(sensors_python, "pyamdgpuinfo", None) + monkeypatch.setattr(sensors_python, "pyadl", None) + monkeypatch.setattr( + sensors_python.GpuAmd, + "_gpu_load", + staticmethod(lambda amd_gpu=None: 42.0), + ) + + stats = sensors_python.GpuAmd.stats() + assert stats[0] == 42.0 + assert all(math.isnan(value) for value in stats[1:]) + + +def test_amd_detection_warms_fdinfo_baseline_when_sysfs_load_is_unavailable(monkeypatch): + class BrokenAmdgpuinfo: + @staticmethod + def detect_gpus(): + raise RuntimeError("unsupported ASIC") + + sampler_calls = [] + monkeypatch.setattr(sensors_python, "pyamdgpuinfo", BrokenAmdgpuinfo) + monkeypatch.setattr(sensors_python, "pyadl", None) + monkeypatch.setattr( + sensors_python.GpuAmd, + "_amdgpu_sysfs_devices", + staticmethod(lambda: ["/sys/class/drm/card0/device"]), + ) + monkeypatch.setattr( + sensors_python.GpuAmd, + "_sysfs_gpu_busy_percent", + staticmethod(lambda: math.nan), + ) + monkeypatch.setattr( + sensors_python.GpuAmd._fdinfo_sampler, + "sample", + lambda: sampler_calls.append(True), + ) + + assert sensors_python.GpuAmd.is_available() + assert sampler_calls == [True] \ No newline at end of file