Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
161 changes: 161 additions & 0 deletions library/sensors/gpu_fdinfo.py
Original file line number Diff line number Diff line change
@@ -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))
169 changes: 121 additions & 48 deletions library/sensors/sensors_python.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down
Binary file added res/themes/Steam OS.zip
Binary file not shown.
Binary file added res/themes/Steam OS/background.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added res/themes/Steam OS/background.png~
Binary file not shown.
Binary file added res/themes/Steam OS/preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading