mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-17 17:01:30 +02:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
199e67e979 | ||
|
|
78bbf3a3de |
No files matched your search
@@ -34,7 +34,12 @@ Read `docs/PLAYBOOK.md` (symptom → fix) before debugging a game, and `docs/FRA
|
||||
`install/live_player.py` (MSE; starts muted as browsers require, "Sound on" button; 0.3 s cushion, catches up at
|
||||
1.1x, seeks only when >2 s behind: seeking to the very edge starved it) opened
|
||||
in the user's default browser: Flet can't show video outside `flet build` bundles; the stream outlives the tab and
|
||||
stops on disconnect/window close); settings; welcome; activity panel). Files, Screenshots and the Library share right-click menus
|
||||
stops on disconnect/window close); monitor (`views/monitor.py` + `frame/monitor.py` + agent v60 `_monitor`: one
|
||||
JSON sample per tick over an SSH exec channel while the tab is shown (stopped in go/disconnect/on_close, the
|
||||
agent ends at EOF); sources and costs in docs/FRAME_RUNTIME.md "Monitoring sources"; game card (fps from
|
||||
FrameBridge pacing; End game = Steam's Exit game, then cmd_stop), tiles with `C.Sparkline` (Flet canvas, no charts
|
||||
extension), details, a pooled process table (Game / Steam & SteamVR / All; right-click: end / force kill / end
|
||||
game; MON_CRITICAL needs force, MON_NEVER is refused)); settings; welcome; activity panel). Files, Screenshots and the Library share right-click menus
|
||||
(one `ft.ContextMenu` per view, filled on right-click; on one of several selected items they act on the whole
|
||||
selection, `C.menu_targets`) and click-and-drag multi-select (`C.DragSelect`: pan start/end on the area + item
|
||||
hover events, which Flutter also sends with the button held; Flet can't report item positions, so no rubber band).
|
||||
|
||||
+731
-1
@@ -36,7 +36,7 @@ import time
|
||||
import zlib
|
||||
from types import SimpleNamespace
|
||||
|
||||
AGENT_VERSION = 59
|
||||
AGENT_VERSION = 60
|
||||
HOME = os.path.expanduser("~")
|
||||
STEAM = os.path.join(HOME, ".local/share/Steam")
|
||||
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
|
||||
@@ -3778,12 +3778,742 @@ def not_started(text, waited, grace=30):
|
||||
return i >= 0 and "Boot complete!" not in text[i:] and waited >= grace
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ monitor
|
||||
# The GUI's Monitor tab: `_monitor` streams one JSON sample per tick (CPU/GPU/memory/temperatures/power/battery, the
|
||||
# running FramePort games with fps, processes) and takes control lines (interval, process filter, end a process or a
|
||||
# game) until stdin closes. Sources (dev Frame, SteamOS 0.4.3, kernel 6.18, 2026-10-07): GPU busy = summed DRM fdinfo
|
||||
# `drm-engine-gpu` ns of every render-node fd (msm), GPU clock from devfreq 3d00000.gpu, 48 thermal zones grouped by
|
||||
# type, max34417 power monitors (µW; vph = the whole system), the max1720x battery gauge, FrameBridge's `pacing:` lines
|
||||
# for fps. Cheap by design: a naive sample (fd scan of ~520 processes) cost 43 ms CPU, so kernel threads are skipped
|
||||
# after their first sighting and render fds are cached per process.
|
||||
PROC = "/proc"
|
||||
SYS = "/sys"
|
||||
MON_INTERVALS = (1, 2, 5)
|
||||
MON_FILTERS = ("game", "steam", "all")
|
||||
MON_PROC_LIMIT = 150
|
||||
MON_CONTEXT = 3 # "game" filter: the busiest other processes, shown for context
|
||||
# processes never signalled: the session, SSH and system plumbing (killing them logs the user out or drops FramePort)
|
||||
MON_NEVER = ("systemd", "sshd", "sshd-session", "sshd-auth", "dbus-daemon", "dbus-broker", "dbus-broker-lau",
|
||||
"(sd-pam)", "login", "agetty")
|
||||
# processes that end the headset session, Steam or the desktop when killed: the GUI asks again ("force")
|
||||
MON_CRITICAL = ("steam", "steamwebhelper", "reaper", "vrserver", "vrcompositor", "vrmonitor", "vrwebhelper",
|
||||
"vrdashboard", "vrstartup", "XRServiceLoopTh", "XRService", "gamescope", "gamescope-wl", "Xwayland",
|
||||
"plasmashell", "kwin_wayland", "kwin_x11", "pipewire", "pipewire-pulse", "wireplumber", "V4L2Cam",
|
||||
"steamos-session", "gamescope-sessi")
|
||||
# processes whose command line can name a game (only these are read: cmdlines of ~500 processes cost too much)
|
||||
MON_ROOT_COMMS = ("conmon", "launch.sh", "reaper", "bash", "sh")
|
||||
MON_ROOT_PREFIXES = ("python", "proton", "pv-", "wine", "steam-runtime")
|
||||
MON_GROUPS = ( # (group, comm prefixes), first match wins; games are found by their process tree
|
||||
("steamvr", ("vr", "XRService", "V4L2Cam", "eyetracking", "proxmicmute", "systemlayer")),
|
||||
("steam", ("steam", "reaper", "fossilize")),
|
||||
("desktop", ("gamescope", "Xwayland", "plasmashell", "kwin", "xdg-desktop", "pipewire", "wireplumber",
|
||||
"kded", "ksmserver")),
|
||||
)
|
||||
TEMP_GROUPS = ( # (group, zone type prefixes): the hottest zone of each group is shown
|
||||
("CPU", ("cpu",)), ("GPU", ("gpuss",)), ("Memory", ("ddr",)), ("Battery", ("max1720x",)), ("NPU", ("nsp",)),
|
||||
("Power ICs", ("pm8",)), ("Modem", ("modem",)), ("Camera", ("camera", "video")), ("Storage", ("ufs",)),
|
||||
)
|
||||
RAIL_GROUPS = {"vph": "system", "gfx": "gpu", "apc0": "cpu", "apc1": "cpu", "apc2": "cpu", "nsp1": "npu",
|
||||
"nsp2": "npu"}
|
||||
PACING_RE = re.compile(rb"FrameBridge: pacing: ([0-9.]+) fps(?:, displayTime vs predicted: avg ([0-9.]+) ms)?")
|
||||
CLK_TCK = os.sysconf("SC_CLK_TCK") if hasattr(os, "sysconf") else 100
|
||||
|
||||
|
||||
def _rd(path):
|
||||
try:
|
||||
with open(path) as f:
|
||||
return f.read()
|
||||
except (OSError, ValueError):
|
||||
return ""
|
||||
|
||||
|
||||
def _num(text, default=None):
|
||||
try:
|
||||
return int(text.strip())
|
||||
except (ValueError, AttributeError):
|
||||
return default
|
||||
|
||||
|
||||
def read_cpu_times():
|
||||
"""[total, core0, core1, …] as (busy, all) jiffies from /proc/stat."""
|
||||
out = []
|
||||
for line in _rd(f"{PROC}/stat").splitlines():
|
||||
if not line.startswith("cpu"):
|
||||
break
|
||||
f = [int(x) for x in line.split()[1:9]]
|
||||
idle = f[3] + f[4] # idle + iowait
|
||||
out.append((sum(f) - idle, sum(f)))
|
||||
return out
|
||||
|
||||
|
||||
def cpu_percent(prev, cur):
|
||||
"""Busy % per entry of read_cpu_times() between two readings (0 when nothing elapsed)."""
|
||||
res = []
|
||||
for (b0, a0), (b1, a1) in zip(prev, cur, strict=False):
|
||||
res.append(round(100.0 * (b1 - b0) / (a1 - a0), 1) if a1 > a0 else 0.0)
|
||||
return res
|
||||
|
||||
|
||||
def read_clusters():
|
||||
"""[{"cpus": [0, 1], "max_mhz": 2265, "policy": path}] per cpufreq policy."""
|
||||
out = []
|
||||
policies = glob.glob(f"{SYS}/devices/system/cpu/cpufreq/policy*")
|
||||
for p in sorted(policies, key=lambda s: _num(s.rsplit("policy", 1)[1], 0)):
|
||||
cpus = [int(x) for x in _rd(f"{p}/related_cpus").split() if x.isdigit()]
|
||||
out.append({"cpus": cpus, "max_mhz": (_num(_rd(f"{p}/cpuinfo_max_freq"), 0)) // 1000, "policy": p})
|
||||
return out
|
||||
|
||||
|
||||
def find_gpu_devfreq():
|
||||
for d in sorted(glob.glob(f"{SYS}/class/devfreq/*")):
|
||||
if "gpu" in os.path.basename(d) or "gpu" in _rd(f"{d}/name"):
|
||||
return d
|
||||
return None
|
||||
|
||||
|
||||
def read_meminfo():
|
||||
m = {}
|
||||
for line in _rd(f"{PROC}/meminfo").splitlines():
|
||||
k, _, v = line.partition(":")
|
||||
if k in ("MemTotal", "MemAvailable", "SwapTotal", "SwapFree"):
|
||||
m[k] = _num(v.split()[0], 0) * 1024
|
||||
return {"total": m.get("MemTotal", 0), "avail": m.get("MemAvailable", 0), "swap_total": m.get("SwapTotal", 0),
|
||||
"swap_free": m.get("SwapFree", 0)}
|
||||
|
||||
|
||||
def read_psi():
|
||||
""""some" avg10 of /proc/pressure/{cpu,memory,io}: % of time something waited on it in the last 10 s."""
|
||||
out = {}
|
||||
for k in ("cpu", "memory", "io"):
|
||||
m = re.search(r"^some avg10=([0-9.]+)", _rd(f"{PROC}/pressure/{k}"), re.M)
|
||||
if m:
|
||||
out[k] = float(m.group(1))
|
||||
return out
|
||||
|
||||
|
||||
def temp_zones():
|
||||
"""[(group, zone name, temp path)] for every thermal zone in a known group."""
|
||||
out = []
|
||||
for z in sorted(glob.glob(f"{SYS}/class/thermal/thermal_zone*"), key=lambda s: _num(s.rsplit("zone", 1)[1], 0)):
|
||||
name = _rd(f"{z}/type").strip()
|
||||
group = next((g for g, prefixes in TEMP_GROUPS if name.startswith(prefixes)), None)
|
||||
if group:
|
||||
out.append((group, name.replace("-thermal", ""), f"{z}/temp"))
|
||||
return out
|
||||
|
||||
|
||||
def read_temps(zones):
|
||||
"""({group: hottest °C}, {group: {zone: °C}})"""
|
||||
groups, detail = {}, {}
|
||||
for group, name, path in zones:
|
||||
v = _num(_rd(path))
|
||||
if v is None or v <= -40000:
|
||||
continue
|
||||
c = round(v / 1000.0, 1)
|
||||
detail.setdefault(group, {})[name] = c
|
||||
groups[group] = max(groups.get(group, c), c)
|
||||
return groups, detail
|
||||
|
||||
|
||||
def power_rails():
|
||||
"""[(label, power*_input path)] of the max34417 power monitors (µW) that RAIL_GROUPS names."""
|
||||
out = []
|
||||
for h in sorted(glob.glob(f"{SYS}/class/hwmon/hwmon*")):
|
||||
for lab in sorted(glob.glob(f"{h}/power*_label")):
|
||||
label = _rd(lab).strip()
|
||||
if label in RAIL_GROUPS: # only the rails the Monitor shows: each read is an I2C transfer
|
||||
out.append((label, lab[:-len("_label")] + "_input"))
|
||||
return out
|
||||
|
||||
|
||||
def read_power(rails):
|
||||
"""{"system", "cpu", "gpu", "npu": W, "rails": {label: W}}"""
|
||||
out = {"rails": {}}
|
||||
for label, path in rails:
|
||||
uw = _num(_rd(path))
|
||||
if uw is None:
|
||||
continue
|
||||
w = uw / 1e6
|
||||
out["rails"][label] = round(w, 3)
|
||||
group = RAIL_GROUPS.get(label)
|
||||
if group:
|
||||
out[group] = round(out.get(group, 0.0) + w, 3)
|
||||
return out
|
||||
|
||||
|
||||
def find_fan():
|
||||
for f in sorted(glob.glob(f"{SYS}/class/hwmon/hwmon*/fan1_input")):
|
||||
return f
|
||||
return None
|
||||
|
||||
|
||||
class BatteryReader:
|
||||
"""battery_state() fields + watts (negative = draining), seconds to empty/full, health, cycles, °C. The gauge sits
|
||||
on I2C (~0.7 ms of kernel time per file on the Frame), so the paths are found once and the slow-changing fields
|
||||
are read every 10 s."""
|
||||
FAST = (("percent", "capacity"), ("current", "current_now"), ("voltage", "voltage_now"))
|
||||
SLOW = (("empty_s", "time_to_empty_now"), ("full_s", "time_to_full_now"), ("cycles", "cycle_count"),
|
||||
("full_uah", "charge_full"), ("design_uah", "charge_full_design"), ("temp", "temp"))
|
||||
|
||||
def __init__(self):
|
||||
self.dir, self.chargers, self.slow, self.slow_at = None, [], {}, -1e9
|
||||
try:
|
||||
names = sorted(os.listdir(POWER_SUPPLY))
|
||||
except OSError:
|
||||
names = []
|
||||
for name in names:
|
||||
d = os.path.join(POWER_SUPPLY, name)
|
||||
kind = _rd(f"{d}/type").strip()
|
||||
if kind == "Battery" and self.dir is None and _rd(f"{d}/capacity").strip().isdigit():
|
||||
self.dir = d
|
||||
elif kind in CHARGER_TYPES:
|
||||
self.chargers.append(f"{d}/online")
|
||||
|
||||
def read(self, now):
|
||||
if not self.dir:
|
||||
return None
|
||||
v = {k: _num(_rd(f"{self.dir}/{f}")) for k, f in self.FAST}
|
||||
status = _rd(f"{self.dir}/status").strip()
|
||||
plugged = any(_rd(c).strip() == "1" for c in self.chargers)
|
||||
b = {"percent": v["percent"], "status": status, "plugged": plugged or status in ("Charging", "Full"),
|
||||
"draining": status == "Discharging"}
|
||||
if v["current"] is not None and v["voltage"] is not None:
|
||||
b["watts"] = round(v["current"] * v["voltage"] / 1e12, 2)
|
||||
if now - self.slow_at >= 10:
|
||||
self.slow = {k: _num(_rd(f"{self.dir}/{f}")) for k, f in self.SLOW}
|
||||
if self.slow.get("temp") is not None:
|
||||
self.slow["temp"] = self.slow["temp"] / 10.0
|
||||
self.slow["health"] = _rd(f"{self.dir}/health").strip() or None
|
||||
self.slow_at = now
|
||||
b.update({k: x for k, x in self.slow.items() if x is not None})
|
||||
return b
|
||||
|
||||
|
||||
def read_net():
|
||||
"""{iface: (rx bytes, tx bytes)} for the Frame's links."""
|
||||
out = {}
|
||||
for line in _rd(f"{PROC}/net/dev").splitlines()[2:]:
|
||||
name, _, rest = line.partition(":")
|
||||
name = name.strip()
|
||||
if name.startswith(("wlan", "usb", "eth", "enp")):
|
||||
f = rest.split()
|
||||
out[name] = (int(f[0]), int(f[8]))
|
||||
return out
|
||||
|
||||
|
||||
def proc_stat(pid):
|
||||
"""(comm, ppid, utime+stime jiffies, start jiffies, rss pages) from /proc/<pid>/stat, or None."""
|
||||
s = _rd(f"{PROC}/{pid}/stat")
|
||||
r = s.rfind(")")
|
||||
if r < 0:
|
||||
return None
|
||||
comm = s[s.find("(") + 1:r]
|
||||
f = s[r + 2:].split()
|
||||
try:
|
||||
return comm, int(f[1]), int(f[11]) + int(f[12]), int(f[19]), int(f[21])
|
||||
except (IndexError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def drm_fds(pid):
|
||||
"""File descriptors of a process that are GPU render nodes (/dev/dri/renderD*; Lepton games see /dev/kgsl-3d0)."""
|
||||
out = []
|
||||
try:
|
||||
fds = os.listdir(f"{PROC}/{pid}/fd")
|
||||
except OSError:
|
||||
return out
|
||||
for fd in fds:
|
||||
try:
|
||||
t = os.readlink(f"{PROC}/{pid}/fd/{fd}")
|
||||
except OSError:
|
||||
continue
|
||||
if t.startswith(("/dev/dri/render", "/dev/kgsl")):
|
||||
out.append(fd)
|
||||
return out
|
||||
|
||||
|
||||
def drm_engine_ns(pid, fds):
|
||||
"""Summed `drm-engine-gpu` ns over the process's render fds (several fds = several DRM clients); None if gone."""
|
||||
total, seen = 0, False
|
||||
for fd in fds:
|
||||
text = _rd(f"{PROC}/{pid}/fdinfo/{fd}")
|
||||
i = text.find("drm-engine-gpu:")
|
||||
if i < 0:
|
||||
continue
|
||||
seen = True
|
||||
total += _num(text[i + 15:].split(None, 1)[0], 0)
|
||||
return total if seen else None
|
||||
|
||||
|
||||
def cgroup_dir(pid):
|
||||
"""The cgroup v2 directory of a process that has memory/cpu stats (walks up from a leaf without them)."""
|
||||
for line in _rd(f"{PROC}/{pid}/cgroup").splitlines():
|
||||
if line.startswith("0::"):
|
||||
d = f"{SYS}/fs/cgroup" + line[3:].strip()
|
||||
while len(d) > len(f"{SYS}/fs/cgroup") and not os.path.exists(f"{d}/memory.current"):
|
||||
d = os.path.dirname(d)
|
||||
return d if os.path.exists(f"{d}/memory.current") else None
|
||||
return None
|
||||
|
||||
|
||||
def cgroup_usage(d):
|
||||
"""(cpu usage µs, memory bytes) of a cgroup directory."""
|
||||
m = re.search(r"^usage_usec (\d+)", _rd(f"{d}/cpu.stat"), re.M)
|
||||
return (int(m.group(1)) if m else None), _num(_rd(f"{d}/memory.current"))
|
||||
|
||||
|
||||
def monitor_group(comm):
|
||||
for group, prefixes in MON_GROUPS:
|
||||
if comm.startswith(prefixes):
|
||||
return group
|
||||
return "other"
|
||||
|
||||
|
||||
class Monitor:
|
||||
"""Sampling state: previous counters for deltas, caches (kernel threads, render fds, zones, rails, games)."""
|
||||
|
||||
def __init__(self, clock=time.monotonic, uid=None):
|
||||
self.clock = clock
|
||||
self.uid = os.getuid() if uid is None else uid
|
||||
self.ncpu = max(1, len(read_cpu_times()) - 1)
|
||||
self.clusters = read_clusters()
|
||||
self.gpu = find_gpu_devfreq()
|
||||
self.zones = temp_zones()
|
||||
self.rails = power_rails()
|
||||
self.fan = find_fan()
|
||||
self.battery = BatteryReader()
|
||||
self.page = os.sysconf("SC_PAGE_SIZE") if hasattr(os, "sysconf") else 4096
|
||||
self.btime = next((int(x.split()[1]) for x in _rd(f"{PROC}/stat").splitlines() if x.startswith("btime ")), 0)
|
||||
self.filter = "game"
|
||||
self.kthreads = set()
|
||||
self.drm = {} # pid -> (fds, time of the fd scan)
|
||||
self.gpu_ns = {} # pid -> last drm-engine ns
|
||||
self.gpu_pct = {} # pid -> GPU % in the last tick
|
||||
self.proc_prev = {} # pid -> (cpu jiffies, time)
|
||||
self.cpu_prev = None
|
||||
self.net_prev = None
|
||||
self.cg_prev = {} # cgroup dir -> (usage µs, time)
|
||||
self.logs = {} # launch.log path -> [offset, fps, avg ms, time of the line]
|
||||
self.deps = []
|
||||
self.deps_at = -1e9
|
||||
self.tick = 0
|
||||
self.last = None # time of the previous sample
|
||||
self.disk_at = -1e9
|
||||
self.zone_detail_at = -1e9
|
||||
self.procs = [] # last scan (all, unfiltered), for kill checks
|
||||
self.games = []
|
||||
self.roots = {}
|
||||
self.root_cache = {} # (pid, start, comm) -> (package, kind) | None
|
||||
|
||||
def static(self):
|
||||
gpu_max = _num(_rd(f"{self.gpu}/max_freq"), 0) // 1000000 if self.gpu else None
|
||||
return {"agent": AGENT_VERSION, "cores": self.ncpu, "gpu_max_mhz": gpu_max,
|
||||
"clusters": [{"cpus": c["cpus"], "max_mhz": c["max_mhz"]} for c in self.clusters],
|
||||
"mem_total": read_meminfo()["total"], "rails": [r[0] for r in self.rails],
|
||||
"temp_groups": sorted({z[0] for z in self.zones}, key=[g for g, _ in TEMP_GROUPS].index),
|
||||
"fan": bool(self.fan), "intervals": list(MON_INTERVALS), "filters": list(MON_FILTERS)}
|
||||
|
||||
# -------------------------------------------------------------------- games
|
||||
def deployments(self, now):
|
||||
if now - self.deps_at > 30:
|
||||
self.deps = cmd_list_installed({})["games"]
|
||||
self.deps_at = now
|
||||
return self.deps
|
||||
|
||||
def fps(self, base, now):
|
||||
"""Latest FrameBridge pacing of a game's launch.log (read incrementally): (fps, avg ms) or (None, None)."""
|
||||
path = os.path.join(base, "launch.log")
|
||||
st = self.logs.setdefault(path, [None, None, None, 0.0])
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
except OSError:
|
||||
return None, None
|
||||
if st[0] is None or size < st[0]: # first look or a new launch truncated it: only the last 64 KiB
|
||||
st[0] = max(0, size - 65536)
|
||||
if size > st[0]:
|
||||
with open(path, "rb") as f:
|
||||
f.seek(st[0])
|
||||
data = f.read(min(size - st[0], 1 << 20))
|
||||
st[0] += len(data)
|
||||
for m in PACING_RE.finditer(data):
|
||||
st[1] = float(m.group(1))
|
||||
st[2] = float(m.group(2)) if m.group(2) else None
|
||||
st[3] = now
|
||||
if st[1] is None or now - st[3] > 15: # pacing comes every ~5 s; older = loading or gone
|
||||
return None, None
|
||||
return st[1], st[2]
|
||||
|
||||
# ---------------------------------------------------------------- processes
|
||||
def scan(self, now, wall):
|
||||
"""Every process worth showing: uid 1000 + everything inside a game container. Groups by process tree."""
|
||||
raw = {}
|
||||
try:
|
||||
names = os.listdir(PROC)
|
||||
except OSError:
|
||||
names = []
|
||||
for name in names:
|
||||
if not name.isdigit():
|
||||
continue
|
||||
pid = int(name)
|
||||
if pid in self.kthreads:
|
||||
continue
|
||||
st = proc_stat(pid)
|
||||
if st is None:
|
||||
continue
|
||||
if st[1] == 2 or pid == 2: # kernel threads: never shown, skipped from now on
|
||||
self.kthreads.add(pid)
|
||||
continue
|
||||
try:
|
||||
uid = os.stat(f"{PROC}/{pid}").st_uid
|
||||
except OSError:
|
||||
continue
|
||||
raw[pid] = (st, uid)
|
||||
self.kthreads &= {int(n) for n in names if n.isdigit()}
|
||||
deps = self.deployments(now)
|
||||
by_appid = {str(d.get("appid")): d for d in deps}
|
||||
roots = {} # pid -> (package, "container" | "launcher"): a game container's conmon, its launch.sh/reaper,
|
||||
# Proton processes started in its folder (their whole process trees are the game)
|
||||
bases = [(d["base"].rstrip("/") + "/", d["package"]) for d in deps if d.get("kind") in ("pcvr", "linux")]
|
||||
anchors = [(os.path.join(ANCHORS, d["package"], "launch.sh"), d["package"]) for d in deps]
|
||||
for pid, ((comm, _ppid, _cpu, start, _rss), uid) in raw.items():
|
||||
if uid != self.uid or not (comm in MON_ROOT_COMMS or comm.startswith(MON_ROOT_PREFIXES)):
|
||||
continue
|
||||
key = (pid, start, comm)
|
||||
if key not in self.root_cache:
|
||||
cmd = _rd(f"{PROC}/{pid}/cmdline").replace("\0", " ")
|
||||
root = None
|
||||
if comm == "conmon":
|
||||
m = re.search(r"lepton-steamlaunch-(\d+)", cmd)
|
||||
if m and m.group(1) in by_appid:
|
||||
root = (by_appid[m.group(1)]["package"], "container")
|
||||
else:
|
||||
pkg = next((k for path, k in anchors if path in cmd), None) or \
|
||||
next((k for base, k in bases if base in cmd), None)
|
||||
root = (pkg, "launcher") if pkg else None
|
||||
self.root_cache[key] = root
|
||||
if self.root_cache[key]:
|
||||
roots[pid] = self.root_cache[key]
|
||||
children = {}
|
||||
for pid, ((_comm, ppid, *_), _uid) in raw.items():
|
||||
children.setdefault(ppid, []).append(pid)
|
||||
game_of = {}
|
||||
for root, (pkg, _kind) in roots.items():
|
||||
stack = [root]
|
||||
while stack:
|
||||
p = stack.pop()
|
||||
if p in game_of:
|
||||
continue
|
||||
game_of[p] = pkg
|
||||
stack.extend(children.get(p, ()))
|
||||
out = []
|
||||
for pid, ((comm, ppid, cpu, start, rss), uid) in raw.items():
|
||||
pkg = game_of.get(pid)
|
||||
if uid != self.uid and not pkg:
|
||||
continue
|
||||
prev = self.proc_prev.get(pid)
|
||||
cpu_pct = 0.0
|
||||
if prev and now > prev[1]:
|
||||
cpu_pct = 100.0 * (cpu - prev[0]) / CLK_TCK / (now - prev[1]) / self.ncpu
|
||||
self.proc_prev[pid] = (cpu, now)
|
||||
out.append({"pid": pid, "ppid": ppid, "name": comm, "group": f"game:{pkg}" if pkg else monitor_group(comm),
|
||||
"game": pkg, "cpu": round(max(cpu_pct, 0.0), 1), "rss": rss * self.page,
|
||||
"age": max(0, int(wall - (self.btime + start / CLK_TCK))) if self.btime else None,
|
||||
"uid": uid, "critical": comm in MON_CRITICAL, "locked": comm in MON_NEVER})
|
||||
# render fds: new processes now; a game process without any every 4 s for its first 2 min (Android games
|
||||
# open the GPU a while after starting), others every 60 s (listing ~300 fds of a steamwebhelper costs)
|
||||
fds = self.drm.get(pid)
|
||||
young_game = pkg and (p_age := out[-1]["age"]) is not None and p_age < 120
|
||||
if fds is None or now - fds[1] > (4 if young_game and not fds[0] else 60):
|
||||
self.drm[pid] = (drm_fds(pid), now)
|
||||
alive = set(raw)
|
||||
for d in (self.proc_prev, self.drm, self.gpu_ns, self.gpu_pct):
|
||||
for pid in [p for p in d if p not in alive]:
|
||||
del d[pid]
|
||||
for key in [k for k in self.root_cache if k[0] not in alive]:
|
||||
del self.root_cache[key]
|
||||
self.procs = out
|
||||
self.roots = roots
|
||||
return out
|
||||
|
||||
def gpu_sample(self, now, dt):
|
||||
"""GPU % per process (from the cached render fds) and the summed busy %."""
|
||||
total = 0.0
|
||||
for pid, (fds, _t) in list(self.drm.items()):
|
||||
if not fds:
|
||||
continue
|
||||
ns = drm_engine_ns(pid, fds)
|
||||
if ns is None:
|
||||
continue
|
||||
prev = self.gpu_ns.get(pid)
|
||||
self.gpu_ns[pid] = ns
|
||||
if prev is not None and dt > 0 and ns >= prev:
|
||||
pct = min(100.0, (ns - prev) / 1e7 / dt)
|
||||
self.gpu_pct[pid] = pct
|
||||
total += pct
|
||||
return min(100.0, total)
|
||||
|
||||
def games_sample(self, now):
|
||||
"""Running FramePort games: title, kind, start time, CPU/memory of the container's cgroup, GPU %, fps."""
|
||||
deps = {d["package"]: d for d in self.deployments(now)}
|
||||
out = []
|
||||
for pkg in sorted({v[0] for v in self.roots.values()}):
|
||||
d = deps.get(pkg) or {}
|
||||
pids = [p["pid"] for p in self.procs if p["game"] == pkg]
|
||||
cpu = sum(p["cpu"] for p in self.procs if p["game"] == pkg)
|
||||
rss = sum(p["rss"] for p in self.procs if p["game"] == pkg)
|
||||
mem = None
|
||||
conmon = [pid for pid, (k, kind) in self.roots.items() if k == pkg and kind == "container"]
|
||||
kids = [p["pid"] for p in self.procs if p["ppid"] in conmon]
|
||||
cg = cgroup_dir(kids[0]) if kids else None
|
||||
if cg and cg != cgroup_dir(conmon[0]): # the container's own cgroup (conmon's is the user's)
|
||||
usage, mem = cgroup_usage(cg)
|
||||
prev = self.cg_prev.get(cg)
|
||||
self.cg_prev[cg] = (usage, now)
|
||||
if usage is not None and prev and prev[0] is not None and now > prev[1]:
|
||||
cpu = 100.0 * (usage - prev[0]) / 1e6 / (now - prev[1]) / self.ncpu
|
||||
ages = [p["age"] for p in self.procs if p["game"] == pkg and p["age"] is not None]
|
||||
fps, ms = self.fps(d["base"], now) if d.get("base") else (None, None)
|
||||
out.append({"package": pkg, "title": d.get("title") or pkg, "kind": d.get("kind", "quest"),
|
||||
"appid": d.get("appid"), "elapsed": max(ages) if ages else None,
|
||||
"cpu": round(max(cpu, 0.0), 1), "mem": mem if mem is not None else rss,
|
||||
"gpu": round(sum(self.gpu_pct.get(p, 0.0) for p in pids), 1),
|
||||
"fps": fps, "frame_ms": ms, "processes": len(pids)})
|
||||
self.games = out
|
||||
return out
|
||||
|
||||
def filtered(self):
|
||||
procs = self.procs
|
||||
for p in procs:
|
||||
p["gpu"] = round(self.gpu_pct.get(p["pid"], 0.0), 1)
|
||||
if self.filter == "game":
|
||||
shown = [p for p in procs if p["game"]]
|
||||
others = sorted((p for p in procs if not p["game"]), key=lambda p: (-p["cpu"], -p["rss"]))
|
||||
shown += [dict(p, context=True) for p in others[:MON_CONTEXT]]
|
||||
elif self.filter == "steam":
|
||||
shown = [p for p in procs if p["group"] in ("steam", "steamvr", "desktop")]
|
||||
else:
|
||||
shown = list(procs)
|
||||
shown.sort(key=lambda p: (-p["cpu"], -p["gpu"], -p["rss"]))
|
||||
return shown[:MON_PROC_LIMIT]
|
||||
|
||||
# ------------------------------------------------------------------- sample
|
||||
def sample(self, wall=None):
|
||||
t0 = time.process_time()
|
||||
now = self.clock()
|
||||
wall = time.time() if wall is None else wall
|
||||
dt = now - self.last if self.last is not None else 0.0
|
||||
self.last = now
|
||||
out = {"t": round(wall, 3), "dt": round(dt, 3)}
|
||||
cur = read_cpu_times()
|
||||
if self.cpu_prev:
|
||||
pct = cpu_percent(self.cpu_prev, cur)
|
||||
out["cpu"] = {"total": pct[0] if pct else 0.0, "cores": pct[1:]}
|
||||
else:
|
||||
out["cpu"] = {"total": 0.0, "cores": [0.0] * self.ncpu}
|
||||
self.cpu_prev = cur
|
||||
out["cpu"]["mhz"] = [_num(_rd(f"{c['policy']}/scaling_cur_freq"), 0) // 1000 for c in self.clusters]
|
||||
out["mem"] = read_meminfo()
|
||||
out["psi"] = read_psi()
|
||||
temps, detail = read_temps(self.zones)
|
||||
out["temps"] = temps
|
||||
if now - self.zone_detail_at >= 5:
|
||||
out["zones"] = detail
|
||||
self.zone_detail_at = now
|
||||
if self.fan:
|
||||
out["fan"] = _num(_rd(self.fan))
|
||||
out["power"] = read_power(self.rails)
|
||||
out["battery"] = self.battery.read(now)
|
||||
net = read_net()
|
||||
if self.net_prev and dt > 0:
|
||||
prev = self.net_prev
|
||||
out["net"] = {k: [max(0, int((v[0] - prev[k][0]) / dt)), max(0, int((v[1] - prev[k][1]) / dt))]
|
||||
for k, v in net.items() if k in prev}
|
||||
self.net_prev = net
|
||||
if now - self.disk_at >= 30:
|
||||
try:
|
||||
st = os.statvfs(HOME)
|
||||
out["disk"] = {"free": st.f_bavail * st.f_frsize, "total": st.f_blocks * st.f_frsize}
|
||||
except OSError:
|
||||
pass
|
||||
self.disk_at = now
|
||||
scan = self.tick % 2 == 0 or not self.procs
|
||||
if scan:
|
||||
self.scan(now, wall)
|
||||
busy = self.gpu_sample(now, dt)
|
||||
out["gpu"] = {"busy": round(busy, 1) if dt > 0 else None,
|
||||
"mhz": _num(_rd(f"{self.gpu}/cur_freq"), 0) // 1000000 if self.gpu else None}
|
||||
out["games"] = self.games_sample(now)
|
||||
if scan:
|
||||
out["procs"] = self.filtered()
|
||||
out["filter"] = self.filter
|
||||
self.tick += 1
|
||||
out["self_ms"] = round((time.process_time() - t0) * 1000, 2)
|
||||
return out
|
||||
|
||||
# ------------------------------------------------------------------ actions
|
||||
def ancestors(self):
|
||||
pids, p = set(), os.getpid()
|
||||
while p > 1:
|
||||
pids.add(p)
|
||||
st = proc_stat(p)
|
||||
if not st:
|
||||
break
|
||||
p = st[1]
|
||||
return pids
|
||||
|
||||
def kill(self, pid, sig="TERM", force=False, wait=3.0):
|
||||
"""Signal one process the Monitor shows. TERM waits up to `wait` s and reports whether it ended."""
|
||||
import signal
|
||||
|
||||
pid = int(pid)
|
||||
st = proc_stat(pid)
|
||||
if st is None:
|
||||
return {"ended": True, "pid": pid, "note": "already gone"}
|
||||
comm = st[0]
|
||||
if pid <= 2 or pid in self.ancestors() or comm in MON_NEVER:
|
||||
raise AgentError(f"{comm} ({pid}) is part of the system or of FramePort's connection; it can't be ended")
|
||||
try:
|
||||
uid = os.stat(f"{PROC}/{pid}").st_uid
|
||||
except OSError:
|
||||
return {"ended": True, "pid": pid, "note": "already gone"}
|
||||
in_game = any(p["pid"] == pid and p["game"] for p in self.procs)
|
||||
if uid != self.uid and not in_game:
|
||||
raise AgentError(f"{comm} ({pid}) belongs to another user")
|
||||
if comm in MON_CRITICAL and not force:
|
||||
raise AgentError(f"CRITICAL: {comm}")
|
||||
signum = signal.SIGKILL if sig == "KILL" else signal.SIGTERM
|
||||
try:
|
||||
os.kill(pid, signum)
|
||||
except ProcessLookupError:
|
||||
return {"ended": True, "pid": pid}
|
||||
except PermissionError as exc:
|
||||
raise AgentError(f"not allowed to end {comm} ({pid}): {exc}") from exc
|
||||
end = time.monotonic() + wait
|
||||
while time.monotonic() < end:
|
||||
st = proc_stat(pid)
|
||||
if st is None or _rd(f"{PROC}/{pid}/status").find("State:\tZ") >= 0:
|
||||
return {"ended": True, "pid": pid, "name": comm}
|
||||
time.sleep(0.1)
|
||||
return {"ended": False, "pid": pid, "name": comm}
|
||||
|
||||
|
||||
def monitor_end_game(pkg, wait=6.0):
|
||||
"""Steam's Exit game for a FramePort game (so Steam's session ends cleanly), then cmd_stop if it still runs."""
|
||||
pkg = check_pkg(pkg)
|
||||
dep = deployment(pkg)
|
||||
if not dep:
|
||||
raise AgentError(f"{pkg} is not installed")
|
||||
|
||||
def running():
|
||||
if dep.get("kind") in ("pcvr", "linux"):
|
||||
return bool(pcvr_pids(dep["base"]))
|
||||
return container_running(dep["appid"])
|
||||
via = None
|
||||
devkit = devkit_gameid(pkg)
|
||||
appid = (devkit_appid(devkit) if devkit else None) or dep.get("appid")
|
||||
try:
|
||||
steam_js(f"SteamClient.Apps.TerminateApp('{steam_gameid(appid)}', false)")
|
||||
via = "steam"
|
||||
end = time.monotonic() + wait
|
||||
while time.monotonic() < end and running():
|
||||
time.sleep(0.5)
|
||||
except Exception: # noqa: BLE001 (Steam's UI unreachable: stop it directly)
|
||||
pass
|
||||
if running():
|
||||
cmd_stop({"package": pkg})
|
||||
via = "stop"
|
||||
end = time.monotonic() + wait
|
||||
while time.monotonic() < end and running():
|
||||
time.sleep(0.5)
|
||||
return {"ended": not running(), "via": via, "package": pkg}
|
||||
|
||||
|
||||
def monitor_session(stdin, stdout, monitor=None, sleep=time.sleep, max_ticks=None):
|
||||
"""Long-lived: prints {"ready": 1, "static": {...}}, then one sample per tick. Control lines on stdin:
|
||||
{"interval": 1|2|5}, {"procs": "game"|"steam"|"all"}, {"pause": bool}, {"id": n, "kill": pid, "sig": "TERM"|
|
||||
"KILL", "force": bool}, {"id": n, "end_game": package} (each with an id gets {"reply": n, "ok": …}). Ends at EOF
|
||||
or when the SSH session (parent) is gone."""
|
||||
import threading
|
||||
|
||||
mon = monitor or Monitor()
|
||||
lock = threading.Lock()
|
||||
state = {"interval": 1.0, "stop": False, "pause": False}
|
||||
wake = threading.Event()
|
||||
|
||||
def emit(obj):
|
||||
with lock:
|
||||
stdout.write(json.dumps(obj, separators=(",", ":")) + "\n")
|
||||
stdout.flush()
|
||||
|
||||
def act(msg):
|
||||
try:
|
||||
if "kill" in msg:
|
||||
res = mon.kill(msg["kill"], msg.get("sig", "TERM"), bool(msg.get("force")))
|
||||
else:
|
||||
res = monitor_end_game(msg["end_game"])
|
||||
emit({"reply": msg.get("id"), "ok": True, "result": res})
|
||||
except AgentError as exc:
|
||||
emit({"reply": msg.get("id"), "ok": False, "error": str(exc)})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
emit({"reply": msg.get("id"), "ok": False, "error": f"{type(exc).__name__}: {exc}"})
|
||||
wake.set() # a fresh sample shows the result
|
||||
|
||||
def reader():
|
||||
try:
|
||||
for line in stdin:
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if msg.get("interval") in MON_INTERVALS:
|
||||
state["interval"] = float(msg["interval"])
|
||||
if msg.get("procs") in MON_FILTERS:
|
||||
mon.filter = msg["procs"]
|
||||
mon.tick = 0 # rescan now
|
||||
if "pause" in msg:
|
||||
state["pause"] = bool(msg["pause"])
|
||||
if "kill" in msg or "end_game" in msg:
|
||||
threading.Thread(target=act, args=(msg,), daemon=True).start()
|
||||
continue
|
||||
wake.set()
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
state["stop"] = True
|
||||
wake.set()
|
||||
|
||||
emit({"ready": 1, "static": mon.static()})
|
||||
threading.Thread(target=reader, daemon=True).start()
|
||||
ticks = 0
|
||||
parent = os.getppid()
|
||||
try:
|
||||
while not state["stop"]:
|
||||
if not state["pause"]:
|
||||
emit(mon.sample())
|
||||
ticks += 1
|
||||
if max_ticks is not None and ticks >= max_ticks:
|
||||
break
|
||||
wake.clear()
|
||||
if sleep is time.sleep:
|
||||
wake.wait(state["interval"])
|
||||
else:
|
||||
sleep(state["interval"])
|
||||
if os.getppid() != parent: # SSH session gone without closing stdin
|
||||
break
|
||||
except (OSError, ValueError, BrokenPipeError):
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
COMMANDS = {n[4:]: f for n, f in globals().items() if n.startswith("cmd_")}
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) >= 2 and sys.argv[1] == "_keyboard":
|
||||
return keyboard_session(sys.stdin, sys.stdout)
|
||||
if len(sys.argv) >= 2 and sys.argv[1] == "_monitor":
|
||||
return monitor_session(sys.stdin, sys.stdout)
|
||||
if len(sys.argv) >= 3 and sys.argv[1] == "_shortcuts_worker":
|
||||
shortcuts_worker(sys.argv[2])
|
||||
return 0
|
||||
|
||||
@@ -143,3 +143,24 @@
|
||||
one everywhere (FramePort's "Type on Frame", agent `_keyboard`).
|
||||
- Unity text fields close without a system keyboard; see `frame.unity_text_input` in PLAYBOOK.md.
|
||||
|
||||
|
||||
## Monitoring sources (probed 2026-10-07, SteamOS 0.4.3, kernel 6.18; used by the Monitor tab, agent `_monitor`)
|
||||
|
||||
All readable by the steamos user without root; the agent reads them directly (no programs started per sample).
|
||||
|
||||
| Metric | Source | Notes |
|
||||
|---|---|---|
|
||||
| CPU | `/proc/stat` deltas; `cpufreq/policy{0,2,5,7}` | 8 cores in 4 clusters: 0-1 / 2-4 / 5-6 / 7 (max 2.27 / 3.15 / 2.96 / 3.05 GHz) |
|
||||
| GPU busy | Sum of `drm-engine-gpu` (ns) deltas in `/proc/<pid>/fdinfo/<fd>` over every render-node fd (msm DRM) | Gives GPU % per process too. A process can hold several render fds: add them all. Lepton games see the node as `/dev/kgsl-3d0` (bind mount of `/dev/dri/renderD128`). `drm-total-memory` reads 0 |
|
||||
| GPU clock | `/sys/class/devfreq/3d00000.gpu/{cur,max}_freq` | 231–903 MHz in 12 steps |
|
||||
| Memory / pressure | `/proc/meminfo`; `/proc/pressure/{cpu,memory,io}` (`some avg10`) | 16 GB RAM, 8 GB swap |
|
||||
| Temperatures | `/sys/class/thermal/thermal_zone*/{type,temp}` | 48 zones: `cpu*`/`cpuss*`, `gpuss-*`, `ddr`, `nsph*` (NPU), `pm8550*`/`pm8010*` (power ICs), `modem*`, `camera*`, `video`, `max1720x_bat*` |
|
||||
| Fan | hwmon `slg4ax46073v` `fan1_input` | ~8200 rpm idle |
|
||||
| Power | hwmon `max34417_*` `power{1-4}_{label,input}` (µW) | `vph` = whole system (~3.6 W idle), `gfx` = GPU, `apc0/1/2` = CPU clusters, `nsp1/2` = NPU; each read is an I2C transfer (~0.7 ms wall), so only these are read |
|
||||
| Battery | `power_supply/max1720x_bat_7-36` | `current_now` (µA, negative = draining) × `voltage_now` (µV) = watts; `time_to_empty_now`/`time_to_full_now` (s), `cycle_count`, `health`, `temp` (0.1 °C) |
|
||||
| Game fps | `<base>/launch.log` lines `FrameBridge: pacing: N fps …` (every ~5 s) | Quest games only; SteamVR writes PC VR frame stats only as an end-of-session summary in `vrcompositor.txt` |
|
||||
| Game container | conmon `-n lepton-steamlaunch-<appid>`; its child's `/proc/<pid>/cgroup` → `cpu.stat`, `memory.current` | Android processes in the container run under subuid-mapped uids (still the user's: the user owns the user namespace) |
|
||||
|
||||
Cost: a naive sample (fds of ~520 processes scanned) took 43 ms CPU. With kernel threads skipped after their first
|
||||
sighting, command lines checked once per process and render fds cached (rescanned every 60 s, every 4 s for young game
|
||||
processes without one), a steady process scan is ~2 ms (every 2 s) and the sensors ~4 ms per tick: ~0.6 % of one core.
|
||||
@@ -113,6 +113,19 @@ reality), showing only what matters for that game. Changes are kept with the gam
|
||||
**Make Unity text fields work** for them. The first time, it downloads Cpp2IL (a tool that finds the right spot in the
|
||||
game's code, ~17 MB). Games added before this version: open the game's menu → **Analyze again**, then reinstall.
|
||||
|
||||
## Watching the Frame (Monitor)
|
||||
|
||||
- **Monitor** (its own tab in the sidebar) shows live what the Frame is doing while the tab is open: the running game
|
||||
with its frame rate (Quest games), CPU, graphics chip, memory, the hottest temperature with the fan speed, power
|
||||
draw and battery time left, each with a 2-minute chart. **Show details** adds every CPU core, all temperature
|
||||
sensors, where the power goes and the network.
|
||||
- **Processes**: **Game** (default) lists the running game's processes, **Steam & SteamVR** and **All** show more.
|
||||
Right-click a process (or use **⋯**) to end it, force-kill it or end its whole game; **End game** on the game card
|
||||
closes the game the way Steam's Exit game does. A lock marks programs whose end would close Steam, SteamVR or the
|
||||
desktop: FramePort asks again before ending those.
|
||||
- The numbers come every second (or every 2/5 s) and cost the Frame well under 1 % of one CPU core; nothing keeps
|
||||
running on the Frame after you leave the tab.
|
||||
|
||||
## Updating
|
||||
|
||||
**Game configs** (the tested recipes in the catalog) update by themselves: FramePort checks GitHub every 6 hours for
|
||||
|
||||
@@ -112,6 +112,10 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
- A user's problem report: `frameport diag inspect <FramePort-diag-*.zip>` re-triages its launch log with the current
|
||||
signatures; the zip has the recipe, analysis, ELF/PE imports, Frame logs and versions (docs/DIAGNOSTICS.md).
|
||||
- Read `<base>/launch.log` (logcat mirror). Filter the game's pid: `Start proc <pid>:<package>`.
|
||||
- **Monitor tab** (GUI, while a game runs): fps from FrameBridge's pacing lines next to GPU busy %, the hottest
|
||||
sensor, memory pressure and per-process CPU/GPU/memory. A memory runaway (Lucky's Tale, Vader Immortal) shows as
|
||||
rising memory + "waiting" before the OOM kill; a GPU-bound game as GPU near 100 % with fps under target; heat as the
|
||||
temperature tile turning amber/red. Sources and costs: FRAME_RUNTIME.md "Monitoring sources".
|
||||
- The adapter logs as `FrameBridge` (settings, xrCreateInstance result, swapchain retries, pacing fps).
|
||||
- For GLES/GLAD engines, wrap `eglGetProcAddress` to see shader compile errors (build the shim with `-DGLSHIM_TRACE`
|
||||
for per-FBO draw counts and draw-call errors; the bridge has `-DOVP_GL_DIAG` for eye-image readback).
|
||||
|
||||
+93
-1
@@ -182,6 +182,83 @@ class FakeKeyboardSession:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class FakeMonitorSession:
|
||||
"""--fake-frame: the Monitor tab's stream as synthetic samples, one per second: a Quest game near 72 fps, warming
|
||||
temperatures, ~40 processes (nothing reaches a Frame)."""
|
||||
|
||||
PROCS = [("com.example.game", "game:com.example.game", 38.0, 22.0, 1_900_000_000),
|
||||
("RenderThread", "game:com.example.game", 12.0, 0.0, 0), ("surfaceflinger", "game:com.example.game",
|
||||
3.1, 4.0, 90_000_000),
|
||||
("vrcompositor", "steamvr", 6.2, 18.5, 80_000_000), ("vrserver", "steamvr", 4.3, 0.0, 86_000_000),
|
||||
("XRServiceLoopTh", "steamvr", 5.1, 0.0, 410_000_000), ("steam", "steam", 3.5, 0.2, 290_000_000),
|
||||
("steamwebhelper", "steam", 2.4, 0.4, 560_000_000), ("gamescope-wl", "desktop", 1.1, 1.2, 50_000_000),
|
||||
("Xwayland", "desktop", 0.2, 0.1, 68_000_000)] + \
|
||||
[("steamwebhelper", "steam", 0.1, 0.0, 60_000_000 + i * 1_000_000) for i in range(30)]
|
||||
|
||||
def __init__(self, frame, on_sample, on_end=None):
|
||||
import math
|
||||
|
||||
self.math, self.closed, self.on_sample = math, False, on_sample
|
||||
self.static = {"agent": 60, "cores": 8, "gpu_max_mhz": 903, "mem_total": 16 * 1024 ** 3, "fan": True,
|
||||
"clusters": [{"cpus": [0, 1], "max_mhz": 2265}, {"cpus": [2, 3, 4], "max_mhz": 3148},
|
||||
{"cpus": [5, 6], "max_mhz": 2956}, {"cpus": [7], "max_mhz": 3052}],
|
||||
"temp_groups": ["CPU", "GPU", "Memory", "Battery", "NPU", "Power ICs", "Modem", "Camera"]}
|
||||
self.t = 0
|
||||
threading.Thread(target=self._run, daemon=True).start()
|
||||
|
||||
def _sample(self) -> dict:
|
||||
m, t = self.math, self.t
|
||||
wave = lambda a, p, o=0: a * (1 + m.sin(t / p + o)) / 2 # noqa: E731
|
||||
game = library.games()[0] if library.games() else {"package": "com.example.game", "title": "Example Game"}
|
||||
procs = [{"pid": 4100 + i, "ppid": 1, "name": n, "group": g, "game": game["package"] if g.startswith("game")
|
||||
else None, "cpu": round(c * (0.8 + wave(0.4, 3, i)), 1), "gpu": gp, "rss": r, "age": 1800 + i,
|
||||
"uid": 1000, "critical": n in ("vrcompositor", "vrserver", "steam", "gamescope-wl", "Xwayland",
|
||||
"XRServiceLoopTh", "steamwebhelper"), "locked": False}
|
||||
for i, (n, g, c, gp, r) in enumerate(self.PROCS)]
|
||||
return {
|
||||
"t": time.time(), "dt": 1.0, "self_ms": 4.2,
|
||||
"cpu": {"total": round(38 + wave(20, 4), 1), "cores": [round(20 + wave(70, 3 + i, i), 1) for i in range(8)],
|
||||
"mhz": [1785, 2803, 2611, 3052]},
|
||||
"gpu": {"busy": round(55 + wave(30, 5), 1), "mhz": 680},
|
||||
"mem": {"total": 16 * 1024 ** 3, "avail": int((7.2 - wave(0.6, 6)) * 1024 ** 3),
|
||||
"swap_total": 8 * 1024 ** 3, "swap_free": 8 * 1024 ** 3},
|
||||
"psi": {"cpu": 2.0, "memory": 0.0, "io": 0.3},
|
||||
"temps": {"CPU": round(61 + t * 0.15, 1), "GPU": round(58 + t * 0.12, 1), "Memory": 49.5, "Battery": 33.2,
|
||||
"NPU": 44.0, "Power ICs": 46.0, "Modem": 41.0, "Camera": 43.5},
|
||||
"zones": {"CPU": {"cpu7-top": round(61 + t * 0.15, 1), "cpu0": 55.0}, "GPU": {"gpuss-0": 58.0}},
|
||||
"fan": 9400 + int(wave(600, 7)),
|
||||
"power": {"system": round(9.5 + wave(2.5, 4), 2), "cpu": 2.6, "gpu": round(3.1 + wave(1.2, 5), 2),
|
||||
"npu": 0.1},
|
||||
"battery": {"percent": 78, "status": "Discharging", "plugged": False, "draining": True, "watts": -10.4,
|
||||
"empty_s": 5400},
|
||||
"net": {"wlan0": [int(wave(250_000, 3)), 18_000], "wlanap": [0, 0], "usb0": [0, 0]},
|
||||
"games": [{"package": game["package"], "title": game.get("title") or game["package"], "kind": "quest",
|
||||
"appid": 1, "elapsed": 1800 + t, "cpu": 41.0, "gpu": 26.5, "mem": 2_300_000_000,
|
||||
"processes": 3, "fps": round(71.5 + wave(0.8, 2) - (14 if t % 23 == 7 else 0), 1),
|
||||
"frame_ms": 0.1}],
|
||||
"procs": procs, "filter": "game"}
|
||||
|
||||
def _run(self) -> None:
|
||||
for self.t in range(100000):
|
||||
if self.closed:
|
||||
return
|
||||
self.on_sample(self._sample())
|
||||
time.sleep(1)
|
||||
|
||||
def set_interval(self, s): pass # noqa: E704
|
||||
def set_filter(self, w): pass # noqa: E704
|
||||
def pause(self, p): pass # noqa: E704
|
||||
|
||||
def kill(self, pid, sig="TERM", force=False):
|
||||
return {"ended": True, "pid": pid}
|
||||
|
||||
def end_game(self, package):
|
||||
return {"ended": True, "via": "steam"}
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def open_type_tab(app: FramePortApp) -> None:
|
||||
"""The Type on Frame tab, with one key 'pressed' so the screenshot shows it working."""
|
||||
app.type_on_frame()
|
||||
@@ -189,6 +266,14 @@ def open_type_tab(app: FramePortApp) -> None:
|
||||
app.keyboard_view.press("Enter")
|
||||
|
||||
|
||||
def open_monitor_details(app: FramePortApp) -> None:
|
||||
"""The Monitor with its details row open."""
|
||||
app.go("monitor")
|
||||
time.sleep(2)
|
||||
if not app.monitor_view.details.visible:
|
||||
app.monitor_view.toggle_details()
|
||||
|
||||
|
||||
LINUX_APPIMAGE = "linux.venera" # --linux's packages
|
||||
LINUX_FOLDER = "linux.matineevr"
|
||||
|
||||
@@ -281,6 +366,7 @@ def main() -> int:
|
||||
ap.add_argument("--docs", action="store_true", help="only the screens used in the docs (Library, --game, Frame)")
|
||||
ap.add_argument("--gestures", action="store_true", help="with --fake-frame: real mouse drags (drag-select) and "
|
||||
"right-clicks (menus) in Files, Screenshots and the Library; prints what got selected")
|
||||
ap.add_argument("--only", default=None, help="comma-separated step names to keep (e.g. monitor,monitor-details)")
|
||||
ap.add_argument("--linux", action="store_true", help="add two pretend arm64 Linux apps and render their pages")
|
||||
args = ap.parse_args()
|
||||
if args.linux:
|
||||
@@ -297,6 +383,7 @@ def main() -> int:
|
||||
steps = [("library", lambda a: a.navigate(0)), ("frame", lambda a: a.navigate(1)),
|
||||
("files", lambda a: a.go("files")), ("screenshots", lambda a: a.go("screenshots")),
|
||||
("live", lambda a: a.go("live")), ("keyboard", lambda a: a.go("keyboard")),
|
||||
("monitor", lambda a: a.go("monitor")), ("monitor-details", open_monitor_details),
|
||||
("tools", lambda a: a.go("settings"))]
|
||||
if game:
|
||||
steps.insert(1, ("game", lambda a: a.open_game(game)))
|
||||
@@ -319,6 +406,7 @@ def main() -> int:
|
||||
steps.append(("screenshots", lambda a: a.go("screenshots")))
|
||||
steps.append(("screenshot-viewer", lambda a: a.screenshots_view.viewer(0)))
|
||||
steps.append(("type-on-frame", lambda a: (a.page.pop_dialog(), open_type_tab(a))))
|
||||
steps.append(("monitor", lambda a: a.go("monitor")))
|
||||
steps.append(("power-confirm", lambda a: (a.page.pop_dialog(), a.frame_power("restart"))))
|
||||
mouse: dict[str, callable] = {} # step name -> mouse actions (Playwright page) after its screenshot
|
||||
if args.gestures:
|
||||
@@ -434,12 +522,16 @@ def main() -> int:
|
||||
steps.append(("game-connected", lambda a: a.open_game(game)))
|
||||
if not args.no_test:
|
||||
steps.append(("launch-test-job", job))
|
||||
if args.only: # in the order given (e.g. frame-connected,monitor)
|
||||
by_name = dict(steps)
|
||||
steps = [(n, by_name[n]) for n in args.only.split(",") if n in by_name]
|
||||
ready, done = threading.Event(), []
|
||||
|
||||
if args.fake_frame: # never reach a real Frame (start-up auto-connect, discovery, the 30 s poll)
|
||||
from frameport.frame import keyboard
|
||||
from frameport.frame import keyboard, monitor
|
||||
|
||||
keyboard.KeyboardSession = FakeKeyboardSession
|
||||
monitor.MonitorSession = FakeMonitorSession
|
||||
FramePortApp.connect = lambda self, *a, **k: None
|
||||
FramePortApp.refresh_frame = lambda self, *a, **k: None
|
||||
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Monitor: a live view of the Frame (CPU/GPU/memory/temperatures/power/battery, the running game, processes) streamed
|
||||
by the agent's `_monitor` session as one JSON line per tick over one SSH channel, plus actions on processes and games
|
||||
(answered on the same channel). The session runs only while the GUI's Monitor tab is open; the agent ends it at EOF.
|
||||
|
||||
The helpers below (History, formatting, levels, sort_filter) are pure so the view stays thin and they are testable."""
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import json
|
||||
import posixpath
|
||||
import threading
|
||||
from collections import deque
|
||||
from collections.abc import Callable
|
||||
|
||||
from ..core import applog
|
||||
|
||||
MIN_AGENT = 60 # first agent with `_monitor`
|
||||
HISTORY = 120 # points per sparkline (2 min at 1 s)
|
||||
REPLY_TIMEOUT = 15.0
|
||||
|
||||
|
||||
class MonitorUnavailable(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class ProcessCritical(RuntimeError):
|
||||
"""Ending the process would stop Steam, SteamVR or the desktop: ask the user, then retry with force=True."""
|
||||
|
||||
|
||||
class MonitorSession:
|
||||
"""One `_monitor` stream. `on_sample(dict)` is called from the reader thread for every sample; `on_end(reason)`
|
||||
once when the stream stops by itself (connection lost, agent ended)."""
|
||||
|
||||
def __init__(self, frame, on_sample: Callable[[dict], None], on_end: Callable[[str], None] | None = None,
|
||||
timeout: float = 20):
|
||||
self.frame = frame
|
||||
self.on_sample = on_sample
|
||||
self.on_end = on_end
|
||||
self.closed = False
|
||||
self._lock = threading.Lock()
|
||||
self._ids = itertools.count(1)
|
||||
self._waiting: dict[int, list] = {} # id -> [Event, reply]
|
||||
frame.ensure_agent()
|
||||
remote = posixpath.join(frame.home, ".local/share/frameport/agent/frameport_agent.py")
|
||||
self._stdin, self._stdout, _err = frame.client.exec_command(f"python3 {remote} _monitor", timeout=timeout)
|
||||
first = json.loads(self._stdout.readline() or "{}")
|
||||
if not first.get("ready"):
|
||||
self.close()
|
||||
raise MonitorUnavailable(first.get("error") or "the Frame didn't start the monitor")
|
||||
self.static: dict = first.get("static") or {}
|
||||
self._stdout.channel.settimeout(None)
|
||||
self._thread = threading.Thread(target=self._read, name="frame-monitor", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
# ----------------------------------------------------------------- stream
|
||||
def _read(self) -> None:
|
||||
reason = "ended"
|
||||
try:
|
||||
for line in self._stdout:
|
||||
if self.closed:
|
||||
return
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if "reply" in msg:
|
||||
slot = self._waiting.get(msg.get("reply"))
|
||||
if slot:
|
||||
slot[1] = msg
|
||||
slot[0].set()
|
||||
continue
|
||||
try:
|
||||
self.on_sample(msg)
|
||||
except Exception: # noqa: BLE001 - a view bug must not end the stream
|
||||
applog.log.exception("monitor: sample handler failed")
|
||||
except Exception as exc: # noqa: BLE001 - connection lost
|
||||
reason = str(exc) or type(exc).__name__
|
||||
finally:
|
||||
for slot in list(self._waiting.values()):
|
||||
slot[0].set()
|
||||
if not self.closed:
|
||||
self.closed = True
|
||||
if self.on_end:
|
||||
self.on_end(reason)
|
||||
|
||||
def send(self, msg: dict) -> None:
|
||||
with self._lock:
|
||||
if self.closed:
|
||||
return
|
||||
self._stdin.write(json.dumps(msg) + "\n")
|
||||
self._stdin.flush()
|
||||
|
||||
def set_interval(self, seconds: int) -> None:
|
||||
self.send({"interval": seconds})
|
||||
|
||||
def set_filter(self, which: str) -> None:
|
||||
self.send({"procs": which})
|
||||
|
||||
def pause(self, paused: bool) -> None:
|
||||
self.send({"pause": bool(paused)})
|
||||
|
||||
def _request(self, msg: dict, timeout: float = REPLY_TIMEOUT) -> dict:
|
||||
rid = next(self._ids)
|
||||
slot = [threading.Event(), None]
|
||||
self._waiting[rid] = slot
|
||||
try:
|
||||
self.send({**msg, "id": rid})
|
||||
if not slot[0].wait(timeout) or slot[1] is None:
|
||||
raise MonitorUnavailable("the Frame didn't answer" if not self.closed else "the monitor stopped")
|
||||
finally:
|
||||
self._waiting.pop(rid, None)
|
||||
reply = slot[1]
|
||||
if not reply.get("ok"):
|
||||
err = reply.get("error") or "failed"
|
||||
if err.startswith("CRITICAL:"):
|
||||
raise ProcessCritical(err.split(":", 1)[1].strip())
|
||||
raise RuntimeError(err)
|
||||
return reply.get("result") or {}
|
||||
|
||||
def kill(self, pid: int, sig: str = "TERM", force: bool = False) -> dict:
|
||||
"""End one process: {"ended": bool, "pid", "name"}. TERM waits up to 3 s on the Frame."""
|
||||
return self._request({"kill": int(pid), "sig": sig, "force": force})
|
||||
|
||||
def end_game(self, package: str) -> dict:
|
||||
"""Steam's Exit game, then a direct stop: {"ended": bool, "via": "steam"|"stop"}."""
|
||||
return self._request({"end_game": package}, timeout=30)
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
self.closed = True
|
||||
try:
|
||||
self._stdin.channel.shutdown_write() # EOF: the agent's monitor ends
|
||||
self._stdin.close()
|
||||
except Exception: # noqa: BLE001 - the connection may be gone already
|
||||
pass
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------------- pure helpers
|
||||
class History:
|
||||
"""Rolling series for the sparklines: name -> deque of the last HISTORY values (None = a gap)."""
|
||||
|
||||
def __init__(self, size: int = HISTORY):
|
||||
self.size = size
|
||||
self.series: dict[str, deque] = {}
|
||||
|
||||
def add(self, name: str, value: float | None) -> None:
|
||||
self.series.setdefault(name, deque(maxlen=self.size)).append(value)
|
||||
|
||||
def get(self, name: str) -> list:
|
||||
return list(self.series.get(name, ()))
|
||||
|
||||
def clear(self) -> None:
|
||||
self.series.clear()
|
||||
|
||||
|
||||
def series_of(sample: dict) -> dict[str, float | None]:
|
||||
"""The values one sample adds to the History."""
|
||||
mem = sample.get("mem") or {}
|
||||
total = mem.get("total") or 0
|
||||
temps = sample.get("temps") or {}
|
||||
bat = sample.get("battery") or {}
|
||||
game = (sample.get("games") or [None])[0] or {}
|
||||
return {
|
||||
"cpu": (sample.get("cpu") or {}).get("total"),
|
||||
"gpu": (sample.get("gpu") or {}).get("busy"),
|
||||
"mem": 100.0 * (total - mem.get("avail", 0)) / total if total else None,
|
||||
"temp": max(temps.values()) if temps else None,
|
||||
"power": (sample.get("power") or {}).get("system"),
|
||||
"battery": bat.get("percent"),
|
||||
"fps": game.get("fps"),
|
||||
}
|
||||
|
||||
|
||||
def fmt_bytes(n: float | None) -> str:
|
||||
if n is None:
|
||||
return "–"
|
||||
for unit in ("B", "KB", "MB", "GB"):
|
||||
if abs(n) < 1024 or unit == "GB":
|
||||
return f"{n:.0f} {unit}" if unit in ("B", "KB") else f"{n:.1f} {unit}"
|
||||
n /= 1024
|
||||
return f"{n:.1f} GB"
|
||||
|
||||
|
||||
def fmt_rate(n: float | None) -> str:
|
||||
return "–" if n is None else fmt_bytes(n) + "/s"
|
||||
|
||||
|
||||
def fmt_watts(w: float | None) -> str:
|
||||
if w is None:
|
||||
return "–"
|
||||
return f"{w:.2f} W" if abs(w) < 10 else f"{w:.1f} W"
|
||||
|
||||
|
||||
def fmt_duration(s: float | None) -> str:
|
||||
"""'1 h 05 min', '12 min', '45 s'."""
|
||||
if s is None:
|
||||
return "–"
|
||||
s = int(s)
|
||||
if s >= 3600:
|
||||
return f"{s // 3600} h {s % 3600 // 60:02d} min"
|
||||
if s >= 60:
|
||||
return f"{s // 60} min"
|
||||
return f"{s} s"
|
||||
|
||||
|
||||
def fmt_pct(v: float | None) -> str:
|
||||
return "–" if v is None else f"{v:.0f} %"
|
||||
|
||||
|
||||
# (warn, error) thresholds; "low" metrics warn when the value falls below them
|
||||
LEVELS = {
|
||||
"cpu": (85, 97), "gpu": (90, 99), "mem": (85, 93), "psi_memory": (10, 30),
|
||||
"temp": (80, 90), "temp_Battery": (45, 50), "fps_ratio": (0.9, 0.75),
|
||||
}
|
||||
LOW = {"fps_ratio"}
|
||||
|
||||
|
||||
def level(metric: str, value: float | None) -> str:
|
||||
"""ok | warn | error for a metric's value (unknown metric or no value: ok)."""
|
||||
if value is None or metric not in LEVELS:
|
||||
return "ok"
|
||||
warn, err = LEVELS[metric]
|
||||
if metric in LOW:
|
||||
return "error" if value < err else "warn" if value < warn else "ok"
|
||||
return "error" if value >= err else "warn" if value >= warn else "ok"
|
||||
|
||||
|
||||
def temp_level(group: str, value: float | None) -> str:
|
||||
return level(f"temp_{group}" if f"temp_{group}" in LEVELS else "temp", value)
|
||||
|
||||
|
||||
SORT_KEYS = ("cpu", "gpu", "rss", "name")
|
||||
|
||||
|
||||
def sort_filter(procs: list[dict], query: str = "", key: str = "cpu", descending: bool = True) -> list[dict]:
|
||||
"""Processes matching the search (name, PID or game package) in the chosen order."""
|
||||
q = (query or "").strip().lower()
|
||||
if q:
|
||||
procs = [p for p in procs if q in p.get("name", "").lower() or q == str(p.get("pid"))
|
||||
or q in (p.get("game") or "").lower()]
|
||||
if key == "name":
|
||||
return sorted(procs, key=lambda p: (p.get("name", "").lower(), p.get("pid", 0)), reverse=descending)
|
||||
return sorted(procs, key=lambda p: (p.get(key) or 0, p.get("rss") or 0), reverse=descending)
|
||||
|
||||
|
||||
def group_label(group: str) -> str:
|
||||
"""'game:<pkg>' -> 'Game'; steam/steamvr/desktop/other as shown in the table."""
|
||||
if group.startswith("game:"):
|
||||
return "Game"
|
||||
return {"steam": "Steam", "steamvr": "SteamVR", "desktop": "Desktop"}.get(group, "Other")
|
||||
|
||||
|
||||
def power_split(power: dict) -> list[tuple[str, float]]:
|
||||
"""[(label, W)] for the stacked power bar: CPU, GPU, NPU, and the rest of the system rail."""
|
||||
system = power.get("system") or 0.0
|
||||
parts = [(k.upper(), power.get(k) or 0.0) for k in ("cpu", "gpu", "npu")]
|
||||
rest = max(0.0, system - sum(v for _k, v in parts))
|
||||
return [*parts, ("Other", rest)]
|
||||
|
||||
|
||||
def battery_line(bat: dict | None) -> str:
|
||||
"""'2 h 10 min left · −4.0 W' / 'Charging · full in 35 min' / 'Full'."""
|
||||
if not bat:
|
||||
return "No battery"
|
||||
status = bat.get("status") or ""
|
||||
watts = bat.get("watts")
|
||||
if status == "Full":
|
||||
return "Full"
|
||||
if status == "Charging" or (bat.get("plugged") and not bat.get("draining")):
|
||||
full = bat.get("full_s")
|
||||
return "Charging" + (f" · full in {fmt_duration(full)}" if full and full < 360000 else "")
|
||||
left = bat.get("empty_s")
|
||||
parts = [f"{fmt_duration(left)} left"] if left and left < 360000 else []
|
||||
if watts is not None:
|
||||
parts.append(fmt_watts(abs(watts)))
|
||||
return " · ".join(parts) or status
|
||||
|
||||
|
||||
REFRESH_RATES = (72, 80, 90, 96, 108, 120, 144) # what the Frame's display offers (vrserver.txt)
|
||||
|
||||
|
||||
def fps_target(values: list) -> int | None:
|
||||
"""The refresh rate a game is presumably aiming for: the lowest Frame rate at or above its best recent fps
|
||||
(with 3 % slack), for the dashed guide on the fps sparkline."""
|
||||
best = max((v for v in values if v is not None), default=None)
|
||||
if best is None:
|
||||
return None
|
||||
return next((r for r in REFRESH_RATES if best <= r * 1.03), REFRESH_RATES[-1])
|
||||
@@ -150,6 +150,7 @@
|
||||
"Back to library": "",
|
||||
"Back up the signing keys ({len}) to {shown_dir} first": "",
|
||||
"Banner": "",
|
||||
"Battery": "",
|
||||
"Before a swapchain image is handed to the Frame, wait until the game's GPU work on it has finished, for games that hand over images early (an eye flickers or jitters). Costs some frame time. 2 = only after the first 60 s (compare both in one session).": "",
|
||||
"Before each Play on this PC, FramePort reads SteamVR's record of the game's last session (frames dropped; frame times from fpsVR if installed). If it dropped frames, it lowers the game's refresh rate to one whose frame budget fits (e.g. 96 → 90 Hz) and turns motion smoothing on, through SteamVR's per-application settings; each later session can step down again. Set a refresh rate or smoothing mode here to choose yourself. Ignored on the Frame.": "",
|
||||
"Best guess": "",
|
||||
@@ -157,6 +158,7 @@
|
||||
"Browse and manage files on the Frame (Files tab): upload from this PC, download, rename, delete. Videos, Downloads and Documents are shared by every Quest game (/sdcard/Movies, /sdcard/Download, /sdcard/Documents); each installed game also has its own storage (its /sdcard). Apps find files by browsing folders. Video players that list only their own folder (e.g. 4XVR's Internal Storage = 4XPlayer): upload into that folder in the game's storage.": "",
|
||||
"Build page": "",
|
||||
"Cable connected.": "",
|
||||
"Camera": "",
|
||||
"Can't reach your Frame. Make sure it's on, awake and on the same network, then try again.": "",
|
||||
"Can't run": "",
|
||||
"Cancel": "",
|
||||
@@ -215,6 +217,7 @@
|
||||
"Connecting the keyboard…": "",
|
||||
"Connecting to {value}…": "",
|
||||
"Connecting…": "",
|
||||
"Connection lost, reconnecting…": "",
|
||||
"Content files from the game's data": "",
|
||||
"Continue": "",
|
||||
"Controller input diagnostics (log)": "",
|
||||
@@ -226,9 +229,11 @@
|
||||
"Copy failed; select the text instead": "",
|
||||
"Copy folder path": "",
|
||||
"Copy log": "",
|
||||
"Copy name": "",
|
||||
"Copy OVRPlugin if VrApi is present": "",
|
||||
"Copy OVRPort libraries": "",
|
||||
"Copy path": "",
|
||||
"Copy PID {pid}": "",
|
||||
"Couldn't check this PC": "",
|
||||
"Couldn't connect: {exc}": "",
|
||||
"Couldn't list the screenshots: {exc}": "",
|
||||
@@ -242,6 +247,9 @@
|
||||
"Couldn't use {choice}: {exc}": "",
|
||||
"Counts the headset as worn, so games that only move the player while it's worn get head tracking and controls (e.g. BONELAB).": "",
|
||||
"Cover": "",
|
||||
"CPU": "",
|
||||
"CPU cores": "",
|
||||
"CPU {cpu} · GPU {gpu} · memory {mem} · {n} processes": "",
|
||||
"Create": "",
|
||||
"Customize": "",
|
||||
"Cylinder layers (curved menus and movie screens, e.g. 4XVR), which the Frame's runtime lacks, are shown as a few flat strips along the curve. 0 = drop them. 360° (equirect) layers can't be shown on the Frame.": "",
|
||||
@@ -266,6 +274,7 @@
|
||||
"Deletes the rollback copies kept from each game's previous install and leftover uploads. The games and saves stay.": "",
|
||||
"Delete…": "",
|
||||
"Deselect": "",
|
||||
"Desktop": "",
|
||||
"Details": "",
|
||||
"Dev builds let you test fixes before they're released. They're less tested than releases and may have bugs. You get the next release as a normal update (you have {version}).": "",
|
||||
"Developer Mode (on the Frame: Settings → System → Developer) lets FramePort find the Frame on your network and connect to it over SSH. The first-time setup command turns it on for you.": "",
|
||||
@@ -315,6 +324,12 @@
|
||||
"Drops Quest-only shared library requirements.": "",
|
||||
"Each patch is described in plain words. Turn this on to also see its id, exactly what it changes in the game (useful when reporting a problem) and its parameters.": "",
|
||||
"Enabled by you.": "",
|
||||
"End game": "",
|
||||
"End process": "",
|
||||
"End the whole game": "",
|
||||
"End {name}?": "",
|
||||
"End {title}?": "",
|
||||
"Ending this stops Steam, SteamVR or the desktop": "",
|
||||
"Enter the address": "",
|
||||
"Enter the Frame's address (e.g. steamos@frame.local or its IP)": "",
|
||||
"Environment variables exported by the launcher (e.g. VK_INSTANCE_LAYERS=\"\" to test without Valve's layers).": "",
|
||||
@@ -323,6 +338,7 @@
|
||||
"Every game start on the Frame used to leak one kernel key, and after about 200 launches every game fails to start. FramePort switches that leak off, but keys already used only come back after a restart of the Frame.": "",
|
||||
"Every N seconds, saves the left-eye image the game submits (a quarter of its size) as fb_snap_0-7.ppm in the game's files folder on the Frame. Launch tests run without anyone wearing the headset, so the headset view stays black; this shows what the game itself draws. OpenGL ES games only.": "",
|
||||
"Every uploaded file is present with the right size.": "",
|
||||
"Every {n} s": "",
|
||||
"everything in ~ (not seen by games)": "",
|
||||
"Everything is up to date": "",
|
||||
"Executable": "",
|
||||
@@ -339,6 +355,7 @@
|
||||
"Failed": "",
|
||||
"Failed: {error}": "",
|
||||
"Fake XR_FB_scene/spatial entities: a guardian-sized room with floor, ceiling and four walls, for mixed-reality games that build their level from the room (e.g. Demeter).": "",
|
||||
"fan": "",
|
||||
"Files": "",
|
||||
"Files and environment on the Frame": "",
|
||||
"Files and environment variables placed next to the game on the Frame.": "",
|
||||
@@ -384,7 +401,12 @@
|
||||
"For Unreal games whose models flicker or show through walls and windows on the Frame (e.g. Into The Radius 2). Needs a rebuild.": "",
|
||||
"For Vulkan games whose models flicker or vanish while you look at them (e.g. Into The Radius 2). Needs a rebuild.": "",
|
||||
"Force enable passthrough": "",
|
||||
"Force kill": "",
|
||||
"Force kill {name}?": "",
|
||||
"Found": "",
|
||||
"fps isn't reported for PC VR and Linux games": "",
|
||||
"fps · target {hz} Hz": "",
|
||||
"fps: waiting for the game's first frames": "",
|
||||
"Frame agent": "",
|
||||
"Frame battery: charging": "",
|
||||
"Frame battery: not charging": "",
|
||||
@@ -416,6 +438,7 @@
|
||||
"FramePort's tools, library, artwork and the signing keys of your rebuilt games. Back it up: an update must be signed with the same key, or the game (and its saves) has to be reinstalled.": "",
|
||||
"FramePort's translator between the game and the Steam Frame: fills in what the Frame lacks (controllers, room, passthrough). Needed for every converted game.": "",
|
||||
"Frames in Developer Mode show up here.": "",
|
||||
"frames per second": "",
|
||||
"Free up space": "",
|
||||
"Free up space on the Frame": "",
|
||||
"Free up space?": "",
|
||||
@@ -431,6 +454,7 @@
|
||||
"Game configs from GitHub: last checked {when}": "",
|
||||
"Game launch capacity": "",
|
||||
"Game not found": "",
|
||||
"Game page": "",
|
||||
"Game settings": "",
|
||||
"Game settings · {title}": "",
|
||||
"Game settings. ": "",
|
||||
@@ -438,6 +462,7 @@
|
||||
"Game settings…": "",
|
||||
"Game storage": "",
|
||||
"Game's choice": "",
|
||||
"Game: the running FramePort game's processes (its Android container or Proton) plus the busiest others. Steam & SteamVR: Steam, SteamVR and the desktop. All: every program of your user on the Frame. A lock marks programs whose end would close Steam, SteamVR or the desktop.": "",
|
||||
"Games see this folder as {value}": "",
|
||||
"Games that ask the headset for its controller models (Meta's runtime controller models, XR_FB_render_model) get the Steam Frame controllers instead of Quest Touch controllers. The models come from the Frame's own SteamVR and are converted on the Frame at install time. Games that ship their own controller meshes aren't affected. Turning it on needs a rebuild (it adds a small library in front of OVRPort's loader).": "",
|
||||
"Generate OVRPort config": "",
|
||||
@@ -455,8 +480,11 @@
|
||||
"GL wrapper: OpenGL ES depth formats (Minecraft)": "",
|
||||
"Go to my library": "",
|
||||
"Go to System and turn on \"Enable Developer Mode\". A Developer page appears in Settings; nothing else changes, your games and data stay as they are.": "",
|
||||
"GPU": "",
|
||||
"Graphics": "",
|
||||
"Group": "",
|
||||
"Hide Android's navigation bar": "",
|
||||
"Hide details": "",
|
||||
"Hide log": "",
|
||||
"Hide patches": "",
|
||||
"Hide Quest foveation": "",
|
||||
@@ -469,7 +497,10 @@
|
||||
"Higher keeps more detail but uses more memory.": "",
|
||||
"Higher looks sharper but needs more power; lower runs smoother. 1.0× is the game's own.": "",
|
||||
"Home folder": "",
|
||||
"hottest: {part}": "",
|
||||
"How a PC VR game starts: Proton runs the Windows program on the Frame; for Oculus games, Revive translates the Oculus API to OpenXR.": "",
|
||||
"How much of the time the Frame's graphics chip was busy, added up from every program that draws (the game, SteamVR's compositor, Steam). Near 100 % the game can't keep its frame rate.": "",
|
||||
"How often the Frame sends new numbers": "",
|
||||
"How often the picture updates. Higher is smoother if the game can keep up.": "",
|
||||
"How well the game runs on the Steam Frame. \"Works\" and \"Works with issues\" come from recipes tested on a real Frame. \"Untested\" means FramePort suggested a recipe from the game's engine and VR API that nobody has confirmed yet. \"Can't run\" means there's a known blocker (for example a 32-bit-only game).": "",
|
||||
"I played it in the headset with this recipe": "",
|
||||
@@ -510,6 +541,7 @@
|
||||
"Installs the app as it is, with an entry in the Frame's Steam library": "",
|
||||
"Installs the game exactly as it is: no patches (your copy is already patched)": "",
|
||||
"into {value}": "",
|
||||
"It belongs to a running game, which may stop with it.": "",
|
||||
"It can't be reached (asleep, turned off or out of Wi-Fi). Wake it or turn it on: FramePort continues by itself, and uploads pick up where they stopped.": "",
|
||||
"It doesn't run: report…": "",
|
||||
"It has a known blocker on the Steam Frame.": "",
|
||||
@@ -537,6 +569,7 @@
|
||||
"Language packs from the game's files": "",
|
||||
"Last build": "",
|
||||
"Last launch test: {get} · furthest: {value}": "",
|
||||
"late": "",
|
||||
"Later": "",
|
||||
"Launch log": "",
|
||||
"Launch log · {title}": "",
|
||||
@@ -571,6 +604,7 @@
|
||||
"Lets you type into this app's text fields on the Frame (they'd close at once).": "",
|
||||
"Library": "",
|
||||
"Linux": "",
|
||||
"Linux app": "",
|
||||
"Linux app (arm64)": "",
|
||||
"Linux VR app · OpenXR": "",
|
||||
"Live": "",
|
||||
@@ -602,6 +636,8 @@
|
||||
"Marks the game as a VR app so headsets show it as one.": "",
|
||||
"Maximum": "",
|
||||
"Maximum size in pixels of each face of the cube the 360° image is converted to (256–2730). Higher is sharper but uses more GPU memory.": "",
|
||||
"Memory": "",
|
||||
"Memory in use by everything on the Frame. \"Waiting\" is the share of time programs had to wait for memory in the last 10 s: when it climbs, the Frame is running out and may freeze or close a game.": "",
|
||||
"Menus and screens": "",
|
||||
"Meta Quest": "",
|
||||
"Meta XR Audio: skip Quest telemetry class": "",
|
||||
@@ -613,6 +649,9 @@
|
||||
"Mirrored": "",
|
||||
"Mixed reality": "",
|
||||
"Mixed-reality games that build their level from your room get a simple room with walls.": "",
|
||||
"Modem": "",
|
||||
"Monitor": "",
|
||||
"Monitor uses {pct} % of the Frame's CPU": "",
|
||||
"More actions": "",
|
||||
"Moves where the pointer starts, forward or back.": "",
|
||||
"Moves where the pointing ray starts forward (+) or back (−).": "",
|
||||
@@ -623,6 +662,7 @@
|
||||
"Needed by another patch": "",
|
||||
"Needs the Oculus Platform (Meta Horizon app) for its license check, which the Frame doesn't have — it crashes at startup there. Play it on this PC.": "",
|
||||
"Needs Windows (or WSL on Windows)": "",
|
||||
"Network": "",
|
||||
"Neutralises Meta XR Audio's Quest-only calls (Unity/Wwise).": "",
|
||||
"never": "",
|
||||
"New config applied: use Update on Frame to install it": "",
|
||||
@@ -637,12 +677,14 @@
|
||||
"No artwork could be downloaded from that {source} result; the current artwork stays. Try another one.": "",
|
||||
"No dev build is published right now": "",
|
||||
"No folders to rescan yet: add games with Scan a folder": "",
|
||||
"No FramePort game is running. Start one in the headset or with Play, or switch the filter to see everything on the Frame.": "",
|
||||
"No Frames found. New Frame? Use first-time setup (step 1). Otherwise make sure Developer Mode is on (Settings → System → Developer) and the Frame is on the same network.": "",
|
||||
"No games found in that folder": "",
|
||||
"No limit": "",
|
||||
"No matching files": "",
|
||||
"No new games in {name}": "",
|
||||
"No notes.": "",
|
||||
"No process matches.": "",
|
||||
"No release notes.": "",
|
||||
"No screenshots yet": "",
|
||||
"No Unreal crash reporter": "",
|
||||
@@ -652,6 +694,7 @@
|
||||
"None": "",
|
||||
"None found": "",
|
||||
"Normal": "",
|
||||
"Not connected: {error}": "",
|
||||
"Not enough space on the Frame": "",
|
||||
"Not enough space on the Frame ({detail}). Remove games you don't play or use Free up space on the Steam Frame page.": "",
|
||||
"Not from a FramePort game": "",
|
||||
@@ -673,6 +716,7 @@
|
||||
"Now {scale:.0%}.": "",
|
||||
"Now {scale:.0%}. Changes apply the next time FramePort starts.": "",
|
||||
"Now {scale:.0%}; {new:.0%} after restarting FramePort.": "",
|
||||
"NPU": "",
|
||||
"Oculus game — needs Revive, which doesn't run on the Frame. Play it on this PC (SteamVR).": "",
|
||||
"Oculus OS telemetry class stubs": "",
|
||||
"Oculus XR Plugin games render both eyes in one pass (multiview) on Android. If one eye shows only effects or grey (e.g. I Am Cat), rendering each eye in its own pass can fix it, at some GPU cost. Rewrites OculusSettings.GetStereoRenderingMode and its inlined read in OculusLoader.Initialize -> MultiPass in libil2cpp.so (found with Cpp2IL). Try it when one eye is wrong.": "",
|
||||
@@ -703,6 +747,7 @@
|
||||
"OR": "",
|
||||
"Or paste text to type it in one go": "",
|
||||
"Or turn on Developer Mode on the Frame (Settings → System → Developer): it then shows up under step 2. Open Settings → Developer → Pair new host on the Frame, click Connect there and approve FramePort. That way needs no connection into this computer.": "",
|
||||
"Other": "",
|
||||
"Override the guardian depth (0 = use guardian, min 1.5 m).": "",
|
||||
"Override the guardian width (0 = use guardian, min 1.5 m).": "",
|
||||
"OVRPort converts Quest games from Meta's own VR APIs to standard OpenXR. These are its optional patches.": "",
|
||||
@@ -724,6 +769,8 @@
|
||||
"Patches every game of this kind gets (on by default). Customize lists them all.": "",
|
||||
"Patches for what the Frame's runtime does differently from a Quest (graphics formats, missing OpenXR extensions, Lepton's launcher requirements).": "",
|
||||
"Patches that can't matter for this game (wrong engine or API) are hidden. Show them to force one on anyway.": "",
|
||||
"Pause": "",
|
||||
"Paused": "",
|
||||
"Paused: Frame battery low, plug it in": "",
|
||||
"Paused: waiting for your Frame": "",
|
||||
"PC VR": "",
|
||||
@@ -732,6 +779,7 @@
|
||||
"PC VR games (Proton)": "",
|
||||
"PC VR games can run on this PC only with Windows (or WSL on Windows)": "",
|
||||
"PC VR games on this PC run through SteamVR, so a headset connected to this PC works with them.": "",
|
||||
"PC VR via Proton": "",
|
||||
"PC VR · Oculus": "",
|
||||
"PC VR · Proton": "",
|
||||
"PC VR —": "",
|
||||
@@ -743,6 +791,7 @@
|
||||
"Picture": "",
|
||||
"pictures for the Steam library": "",
|
||||
"Pictures you took in the headset (Steam screenshots on the Frame)": "",
|
||||
"PID": "",
|
||||
"Platform compat (ovrMessageType_ToString)": "",
|
||||
"Play on Frame": "",
|
||||
"Play on this PC": "",
|
||||
@@ -753,11 +802,14 @@
|
||||
"Pointer start": "",
|
||||
"Pointer turn": "",
|
||||
"Points Oculus games at Revive on the Frame.": "",
|
||||
"Power": "",
|
||||
"Power ICs": "",
|
||||
"Prepare {title}": "",
|
||||
"Pretend room layout": "",
|
||||
"Prevents a start-up crash in some Unreal games.": "",
|
||||
"Previous": "",
|
||||
"Problems and feedback": "",
|
||||
"Processes": "",
|
||||
"Program": "",
|
||||
"Proton couldn't run a test program (see the log)": "",
|
||||
"Proton debug log (Frame)": "",
|
||||
@@ -772,6 +824,7 @@
|
||||
"Puts settings files next to the game on the Frame (e.g. to turn off an unsupported effect).": "",
|
||||
"Quality": "",
|
||||
"Quest": "",
|
||||
"Quest game": "",
|
||||
"Quest games (Lepton)": "",
|
||||
"Quest ✓": "",
|
||||
"Quest ✓: Lepton is installed, so Quest games can run. PC VR ✓: Proton is installed, so PC VR games can run on the Frame.": "",
|
||||
@@ -863,6 +916,9 @@
|
||||
"Room width": "",
|
||||
"Rotate quads 180°": "",
|
||||
"Routes OVRPlugin through FramePort's extension shim, which turns Meta's amplitude-envelope vibrations into plain ones: OVRPort's loader reads their nanosecond duration as seconds and allocates gigabytes, freezing the Frame when the game vibrates a controller (e.g. Lucky's Tale's save slots, GitHub #9). Needs a rebuild.": "",
|
||||
"Running": "",
|
||||
"running for {t}": "",
|
||||
"Running now": "",
|
||||
"Runs directly — no Revive needed": "",
|
||||
"Runs natively on SteamOS, started from the Frame's Steam library": "",
|
||||
"Runs Oculus PC games on OpenXR": "",
|
||||
@@ -890,6 +946,7 @@
|
||||
"Search": "",
|
||||
"Search again": "",
|
||||
"Search games and tags": "",
|
||||
"Search name, PID or game": "",
|
||||
"Search the stores instead": "",
|
||||
"Searched a moment ago.": "",
|
||||
"Searching…": "",
|
||||
@@ -915,6 +972,7 @@
|
||||
"Show advanced settings": "",
|
||||
"Show all patches ({len} don't apply to this game)": "",
|
||||
"Show curved menus and screens": "",
|
||||
"Show details": "",
|
||||
"Show hidden files": "",
|
||||
"Show less": "",
|
||||
"Show log": "",
|
||||
@@ -927,6 +985,7 @@
|
||||
"Show technical details (patch ids, exact effects, parameters)": "",
|
||||
"Show the app's Android window (for typing)": "",
|
||||
"Show video panels": "",
|
||||
"Showing the top {shown} of {n}: search to find others.": "",
|
||||
"Showing {len} of {n}": "",
|
||||
"Shows 3D 360° pictures flat if they look doubled.": "",
|
||||
"Shows the Frame's cameras where the game expects passthrough.": "",
|
||||
@@ -973,6 +1032,7 @@
|
||||
"Steadier head tracking": "",
|
||||
"Steady controller input after focus": "",
|
||||
"Steam": "",
|
||||
"Steam & SteamVR": "",
|
||||
"Steam for Windows not found": "",
|
||||
"Steam Frame": "",
|
||||
"Steam Frame patches": "",
|
||||
@@ -992,6 +1052,7 @@
|
||||
"Stops some Unreal games from closing themselves right after starting.": "",
|
||||
"Stops strict debugging checks that make some games quit.": "",
|
||||
"Stops Unreal's crash reporter, which can block the game from starting.": "",
|
||||
"Storage": "",
|
||||
"Store details for {n} game": [
|
||||
"",
|
||||
""
|
||||
@@ -1004,6 +1065,7 @@
|
||||
"Suggested for this game": "",
|
||||
"Swap eyes": "",
|
||||
"Swap left/right views (diagnostic).": "",
|
||||
"swap {n}": "",
|
||||
"Swapchain format fallback": "",
|
||||
"Swaps in an OpenXR OVRPlugin for games that use VrApi through OVRPlugin (older Unity/Unreal).": "",
|
||||
"Switch Frame…": "",
|
||||
@@ -1013,6 +1075,8 @@
|
||||
"tall, 600 × 900": "",
|
||||
"Team Beef ports: treat the Frame as a Meta headset": "",
|
||||
"Team Beef's ports (e.g. Lambda1VR, RTCWQuest) load an OpenXR library named after the headset maker and pick their VR setup by maker; for \"valve\" they stop at start (libopenxr_loader_valve.so not found) or take the Pico path. Adds that library and lets them take the Meta path.": "",
|
||||
"Temperature": "",
|
||||
"Temperatures": "",
|
||||
"Test": "",
|
||||
"Test Proton on the Frame": "",
|
||||
"Text and layout size": "",
|
||||
@@ -1043,15 +1107,19 @@
|
||||
"The Frame's Steam didn't accept {title}'s library entry, so FramePort added it as \"Devkit Game: …\" instead. Starting it now — put the headset on.": "",
|
||||
"The Frame's Steam library still shows the old artwork.": "",
|
||||
"the game didn't pass its checks (see the list above)": "",
|
||||
"The game is closed the way Steam's Exit game does it; if it doesn't stop, FramePort stops it. Progress that the game hasn't saved is lost.": "",
|
||||
"the installed game (APK and its data)": "",
|
||||
"The launch settings changed since it was installed: update the Steam shortcut": "",
|
||||
"The live view can start once FramePort is connected to the Frame.": "",
|
||||
"the log of the last start": "",
|
||||
"The monitor isn't connected.": "",
|
||||
"The monitor shows your Frame's games, load, temperatures and battery live once FramePort is connected to it.": "",
|
||||
"The PC VR counterpart of OVRPort's 'Patch Oculus detection for Unreal'. Unreal's Oculus plugin only starts when the Oculus service announces a headset (the Windows event 'OculusHMDConnected'); without it the game runs as a flat window. On the Frame the launcher runs the game through FramePort's small helper (fp_oculushmd.exe) that provides that event while the game runs, instead of relying only on Revive's hook of the check. Ignored on this PC (the Oculus app or Revive handle it there).": "",
|
||||
"The picture opens in your default web browser, where it plays smoothly and can go full screen. It shows the headset's view with its sound (click Sound on in the player) whatever is running: Steam's menus, SteamVR or a game; while the headset sleeps the picture is black and updates about once a second. Streaming costs the Frame a little performance; stop it when you're done.": "",
|
||||
"the previous version, kept for one rollback": "",
|
||||
"The repack's own Revive · runs the game directly": "",
|
||||
"The small helper program FramePort runs on the Frame (installs, launch tests, Steam entries). It comes with this app and is replaced on the Frame automatically whenever it differs.": "",
|
||||
"The time the Frame spends collecting these numbers": "",
|
||||
"The upload to the Frame was damaged on the way. Try again (a cable or the Frame's hotspot is more reliable than a busy Wi-Fi).": "",
|
||||
"The VR runtime games talk to. On the Frame that's SteamVR.": "",
|
||||
"These games can't run on the Steam Frame": "",
|
||||
@@ -1205,12 +1273,16 @@
|
||||
"Waiting for the Frame": "",
|
||||
"Waiting for your Frame": "",
|
||||
"Waiting for your Frame… (code {code})": "",
|
||||
"waiting {pct} %": "",
|
||||
"Watch what the headset shows, in a window on this PC": "",
|
||||
"Welcome to FramePort": "",
|
||||
"What FramePort will do": "",
|
||||
"What happens? (optional: you can also write it on GitHub)": "",
|
||||
"What the whole Frame draws right now (its main power rail), with the share of the CPU and the graphics chip. Higher power means more heat and a shorter battery.": "",
|
||||
"What your Frame is doing right now": "",
|
||||
"What's new": "",
|
||||
"Where it's installed": "",
|
||||
"Where the power goes": "",
|
||||
"Where this game's recipe came from: catalog (tested on a Frame), heuristics (suggested by FramePort) or user (changed by you).": "",
|
||||
"Which program starts {get}?": "",
|
||||
"Which Proton build runs the game on the Frame: the stable Proton (the default: smoother in our tests) or Proton Experimental (gets Valve's ARM64 fixes first; try it when a game doesn't start or runs badly). Also takes any compat tool name from the Frame's ARM64 list.": "",
|
||||
@@ -1274,6 +1346,11 @@
|
||||
"{len} selected": "",
|
||||
"{message} The Frame's Steam library still shows the old art.": "",
|
||||
"{n_pc} on this PC": "",
|
||||
"{name} (PID {pid}) is asked to close.": "",
|
||||
"{name} (PID {pid}) stops at once without saving anything.": "",
|
||||
"{name} ended.": "",
|
||||
"{name} is part of Steam, SteamVR or the desktop. Ending it can close the headset view or restart Steam, and anything running in the headset may stop.": "",
|
||||
"{name} is still running.": "",
|
||||
"{name} isn't a Linux app (an AppImage, a Linux program or a .zip/.tar archive)": "",
|
||||
"{n} file": [
|
||||
"",
|
||||
@@ -1316,6 +1393,7 @@
|
||||
"",
|
||||
""
|
||||
],
|
||||
"{n} processes": "",
|
||||
"{n} screenshot": [
|
||||
"",
|
||||
""
|
||||
@@ -1333,9 +1411,11 @@
|
||||
"{rate}/s": "",
|
||||
"{remaining} more after this": "",
|
||||
"{title} couldn't be added to the Frame's Steam library: {why}": "",
|
||||
"{title} ended.": "",
|
||||
"{title} failed: {error}": "",
|
||||
"{title} is already running on the Frame — put the headset on": "",
|
||||
"{title} is already starting — put the headset on": "",
|
||||
"{title} is still running.": "",
|
||||
"{title} isn't in the Frame's Steam library yet: adding it (Steam restarts once)": "",
|
||||
"{title} will be in the Frame's Steam library when the game that's running is closed": "",
|
||||
"{title} will be in the Frame's Steam library when you're back in Gaming Mode: start it from there": "",
|
||||
@@ -1344,6 +1424,7 @@
|
||||
"{title}: done": "",
|
||||
"{title}: launch test {verdict} (furthest: {value})": "",
|
||||
"{title}: Steam artwork updated on the Frame": "",
|
||||
"{used} of {total}": "",
|
||||
"{user}@{host} · {get} {get2} (build {get3})": "",
|
||||
"{value:.0f} MiB": "",
|
||||
"{value:.1f} GiB": "",
|
||||
|
||||
+21
-1
@@ -34,6 +34,7 @@ NAV = [("library", tr("Library"), ft.Icons.GRID_VIEW_ROUNDED),
|
||||
("screenshots", tr("Screenshots"), ft.Icons.PHOTO_LIBRARY_OUTLINED),
|
||||
("live", tr("Live view"), ft.Icons.CAST_ROUNDED),
|
||||
("keyboard", tr("Type on Frame"), ft.Icons.KEYBOARD_ROUNDED),
|
||||
("monitor", tr("Monitor"), ft.Icons.MONITOR_HEART_OUTLINED),
|
||||
("settings", tr("Settings"), ft.Icons.TUNE_ROUNDED)]
|
||||
POLL_SECONDS = 30
|
||||
|
||||
@@ -74,6 +75,7 @@ class FramePortApp:
|
||||
self.screenshots_view = None # likewise (views/screenshots.ScreenshotsView): keeps the game filter
|
||||
self.live_view = None # likewise (views/live.LiveView): owns the running stream, which outlives the tab
|
||||
self.keyboard_view = None # likewise (views/keyboard.KeyboardView): its keyboard exists only while it's shown
|
||||
self.monitor_view = None # likewise (views/monitor.MonitorView): its stream runs only while it's shown
|
||||
self._typing_on_frame = False
|
||||
self.exe_queue: list[str] = [] # games whose executable the user should confirm (after a scan)
|
||||
self._failures: list[Job] = [] # failed installs/tests, shown together when the queue is done
|
||||
@@ -82,7 +84,7 @@ class FramePortApp:
|
||||
self._handled_jobs: set[int] = set() # finished jobs this session has reacted to (pop-ups, refreshes)
|
||||
page.on_close = lambda e: (self.jobs.unsubscribe(self._on_job), # session gone: stop drawing into it
|
||||
self.stop_live(), # and stop a live view (it would keep the Frame encoding)
|
||||
self.stop_keyboard())
|
||||
self.stop_keyboard(), self.stop_monitor())
|
||||
from .updater import Updater
|
||||
|
||||
self.updater = Updater(self) # new FramePort releases (sidebar card, Library bar, one-click update)
|
||||
@@ -319,6 +321,8 @@ class FramePortApp:
|
||||
self.game_scroll = 0.0
|
||||
if self.route[0] == "keyboard" and route != "keyboard":
|
||||
self.stop_keyboard() # leaving the tab removes the virtual keyboard from the Frame
|
||||
if self.route[0] == "monitor" and route != "monitor":
|
||||
self.stop_monitor() # leaving the tab ends the Frame's monitor stream
|
||||
self.route = (route, *args)
|
||||
self.render()
|
||||
|
||||
@@ -363,6 +367,12 @@ class FramePortApp:
|
||||
if self.keyboard_view is None:
|
||||
self.keyboard_view = KeyboardView(self)
|
||||
view = self.keyboard_view.mount()
|
||||
elif kind == "monitor":
|
||||
from .views.monitor import MonitorView
|
||||
|
||||
if self.monitor_view is None:
|
||||
self.monitor_view = MonitorView(self)
|
||||
view = self.monitor_view.mount()
|
||||
elif kind == "settings":
|
||||
view = SettingsView(self).build()
|
||||
else:
|
||||
@@ -400,6 +410,11 @@ class FramePortApp:
|
||||
if self.frame_state != "connected":
|
||||
self.stop_keyboard()
|
||||
self.render()
|
||||
elif self.route[0] == "monitor" and (self.monitor_view is None or self.monitor_view.root is None
|
||||
or self.monitor_view.stopped or self.frame_state != "connected"):
|
||||
if self.frame_state != "connected":
|
||||
self.stop_monitor()
|
||||
self.render()
|
||||
else:
|
||||
self._refresh_sidebar()
|
||||
|
||||
@@ -411,6 +426,10 @@ class FramePortApp:
|
||||
if self.keyboard_view is not None:
|
||||
self.keyboard_view.stop()
|
||||
|
||||
def stop_monitor(self) -> None:
|
||||
if self.monitor_view is not None:
|
||||
self.monitor_view.stop()
|
||||
|
||||
def open_game(self, package: str, advanced: bool = False, show_all: bool = False) -> None:
|
||||
self.go("game", package, advanced, show_all)
|
||||
|
||||
@@ -1869,6 +1888,7 @@ class FramePortApp:
|
||||
def disconnect(self):
|
||||
self.stop_live()
|
||||
self.stop_keyboard()
|
||||
self.stop_monitor()
|
||||
if self.target:
|
||||
try:
|
||||
self.target.close()
|
||||
|
||||
@@ -454,3 +454,109 @@ def is_media_player(g: dict) -> bool:
|
||||
patches = ((g.get("recipe") or {}).get("patches") or {})
|
||||
exts = ((g.get("analysis") or {}).get("extra") or {}).get("xr_layer_exts") or []
|
||||
return "adapter.equirect_emul" in patches or any(e.startswith("XR_KHR_composition_layer_equirect") for e in exts)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ charts
|
||||
def spark_points(values: list, width: float, height: float, lo: float = 0.0, hi: float | None = None,
|
||||
slots: int | None = None, pad: float = 2.0) -> list[list[tuple[float, float]]]:
|
||||
"""Runs of (x, y) points for a sparkline: the newest value at the right edge, one slot per value (`slots` fixes
|
||||
the spacing so a young series grows from the right), None = a gap. y grows downwards (canvas coordinates)."""
|
||||
n = slots or len(values)
|
||||
if n < 2 or width <= 0 or height <= 0:
|
||||
return []
|
||||
real = [v for v in values if v is not None]
|
||||
if hi is None:
|
||||
hi = max(real, default=lo + 1.0)
|
||||
span = (hi - lo) or 1.0
|
||||
step = width / (n - 1)
|
||||
x0 = width - step * (len(values) - 1)
|
||||
runs, run = [], []
|
||||
for i, v in enumerate(values):
|
||||
if v is None:
|
||||
if run:
|
||||
runs.append(run)
|
||||
run = []
|
||||
continue
|
||||
frac = min(1.0, max(0.0, (v - lo) / span))
|
||||
run.append((x0 + i * step, pad + (height - 2 * pad) * (1.0 - frac)))
|
||||
if run:
|
||||
runs.append(run)
|
||||
return runs
|
||||
|
||||
|
||||
class Sparkline:
|
||||
"""A small line chart with a soft area fill (Flet canvas, built into Flet: no charts extension). Created once;
|
||||
set() replaces only the paths' elements, so a tick sends a small patch. Fills its width (on_resize)."""
|
||||
|
||||
def __init__(self, color: str = T.ACCENT, height: int = 36, slots: int = 120, lo: float = 0.0,
|
||||
hi: float | None = None, min_slots: int = 30):
|
||||
import flet.canvas as cv
|
||||
|
||||
self.cv, self.slots, self.min_slots, self.lo, self.hi = cv, slots, min_slots, lo, hi
|
||||
self.width, self.height = 0.0, T.px(height)
|
||||
self.values: list = []
|
||||
self.target: float | None = None
|
||||
self.area = cv.Path([], paint=ft.Paint(color=T.soft(color, 0.16), style=ft.PaintingStyle.FILL))
|
||||
self.line = cv.Path([], paint=ft.Paint(color=color, stroke_width=T.px(1.6), style=ft.PaintingStyle.STROKE,
|
||||
stroke_join=ft.StrokeJoin.ROUND, stroke_cap=ft.StrokeCap.ROUND))
|
||||
self.guide = cv.Path([], paint=ft.Paint(color=T.soft(T.TEXT_3, 0.6), stroke_width=1,
|
||||
style=ft.PaintingStyle.STROKE, stroke_dash_pattern=[3, 3]))
|
||||
self.control = cv.Canvas([self.area, self.guide, self.line], height=self.height, expand=True,
|
||||
on_resize=self._resized)
|
||||
|
||||
def _resized(self, e) -> None:
|
||||
self.width, self.height = e.width, e.height or self.height
|
||||
self._draw()
|
||||
update(self.control)
|
||||
|
||||
def set_color(self, color: str) -> None:
|
||||
self.line.paint.color = color
|
||||
self.area.paint.color = T.soft(color, 0.16)
|
||||
|
||||
def set(self, values: list, hi: float | None = None, target: float | None = None) -> None:
|
||||
"""New values (oldest first); hi overrides the top of the scale; target draws a dashed guide (e.g. 72 fps)."""
|
||||
self.values, self.target = list(values)[-self.slots:], target
|
||||
if hi is not None:
|
||||
self.hi = hi
|
||||
self._draw()
|
||||
|
||||
def _draw(self) -> None:
|
||||
cv, w, h = self.cv, self.width, self.height
|
||||
hi = self.hi
|
||||
if hi is None:
|
||||
hi = max([v for v in self.values if v is not None] + [self.target or 0, self.lo + 1.0]) * 1.1
|
||||
# the time window starts at min_slots points and widens to `slots` as data comes in (a new chart isn't a
|
||||
# sliver at the right edge for its first minute)
|
||||
runs = spark_points(self.values, w, h, self.lo, hi, max(self.min_slots, min(self.slots, len(self.values))))
|
||||
line, area = [], []
|
||||
for run in runs:
|
||||
line.append(cv.Path.MoveTo(*run[0]))
|
||||
line += [cv.Path.LineTo(x, y) for x, y in run[1:]]
|
||||
area += [cv.Path.MoveTo(run[0][0], h), *[cv.Path.LineTo(x, y) for x, y in run],
|
||||
cv.Path.LineTo(run[-1][0], h), cv.Path.Close()]
|
||||
self.line.elements, self.area.elements = line, area
|
||||
guide = []
|
||||
if self.target is not None and w > 0:
|
||||
y = spark_points([self.target, self.target], w, h, self.lo, hi)[0][0][1]
|
||||
guide = [cv.Path.MoveTo(0, y), cv.Path.LineTo(w, y)]
|
||||
self.guide.elements = guide
|
||||
|
||||
|
||||
class MeterBar:
|
||||
"""A thin horizontal bar of coloured segments (per-core load, power split); widths are fractions of the bar."""
|
||||
|
||||
def __init__(self, colors: list[str], height: int = 6):
|
||||
self.segments = [ft.Container(bgcolor=c, expand=0, height=T.px(height)) for c in colors]
|
||||
self.rest = ft.Container(expand=1000, height=T.px(height))
|
||||
self.control = ft.Container(ft.Row([*self.segments, self.rest], spacing=0), bgcolor=T.SURFACE_3,
|
||||
border_radius=T.px(height), height=T.px(height),
|
||||
clip_behavior=ft.ClipBehavior.HARD_EDGE)
|
||||
|
||||
def set(self, fractions: list[float]) -> None:
|
||||
"""Fractions (0..1) per segment; the rest of the bar stays empty. Expand weights are integers (per mille)."""
|
||||
total = 0
|
||||
for seg, f in zip(self.segments, fractions, strict=False):
|
||||
seg.expand = max(0, int(round(min(1.0, max(0.0, f)) * 1000)))
|
||||
seg.visible = seg.expand > 0
|
||||
total += seg.expand
|
||||
self.rest.expand = max(1, 1000 - total)
|
||||
@@ -139,4 +139,14 @@ HELP: dict[str, str] = _Translated({
|
||||
"diag_bundle": "A zip with FramePort's logs, the game's recipe and analysis, launch-test logs and the Frame's "
|
||||
"runtime details — enough to debug without the game files. IP addresses, host and user names, "
|
||||
"home folders and Steam ids are replaced by placeholders. Attach it to a GitHub issue.",
|
||||
"monitor_gpu": "How much of the time the Frame's graphics chip was busy, added up from every program that draws "
|
||||
"(the game, SteamVR's compositor, Steam). Near 100 % the game can't keep its frame rate.",
|
||||
"monitor_pressure": "Memory in use by everything on the Frame. \"Waiting\" is the share of time programs had to "
|
||||
"wait for memory in the last 10 s: when it climbs, the Frame is running out and may freeze or "
|
||||
"close a game.",
|
||||
"monitor_power": "What the whole Frame draws right now (its main power rail), with the share of the CPU and the "
|
||||
"graphics chip. Higher power means more heat and a shorter battery.",
|
||||
"monitor_filter": "Game: the running FramePort game's processes (its Android container or Proton) plus the busiest "
|
||||
"others. Steam & SteamVR: Steam, SteamVR and the desktop. All: every program of your user on "
|
||||
"the Frame. A lock marks programs whose end would close Steam, SteamVR or the desktop.",
|
||||
})
|
||||
@@ -0,0 +1,657 @@
|
||||
"""Monitor: a live view of the Frame — the running game (fps, CPU/GPU/memory), CPU, GPU, memory, temperatures, power
|
||||
and battery with 2-minute sparklines, and its processes (end or kill one, end a game). Streamed by the agent's
|
||||
`_monitor` session (frame/monitor.py) only while the tab is shown: app.go / app.disconnect / closing the window call
|
||||
stop(), and the agent ends the stream at EOF, so nothing keeps running on the Frame.
|
||||
|
||||
Built once and updated in place (CLAUDE.md performance rules): a tick changes values, colours and sparkline paths;
|
||||
the process table is a fixed pool of rows re-bound to the newest data, so clicks and menus keep working."""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import flet as ft
|
||||
|
||||
from ...errors import explain
|
||||
from ...frame import monitor as M
|
||||
from ...i18n import tr
|
||||
from .. import components as C
|
||||
from .. import theme as T
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import FramePortApp
|
||||
|
||||
ROWS = 50 # process rows shown at most (the agent sends up to 150; the search narrows them)
|
||||
RETRY_SECONDS = 5 # reconnect after the stream was lost, while the tab is open
|
||||
LEVEL_COLOR = {"ok": T.TEXT, "warn": T.WARN, "error": T.ERROR}
|
||||
GROUP_COLOR = {"Game": T.ACCENT, "Steam": T.INFO, "SteamVR": T.PC, "Desktop": T.TEXT_2, "Other": T.TEXT_3}
|
||||
CLUSTER_COLORS = (T.INFO, T.ACCENT, T.PC, T.OK)
|
||||
POWER_COLORS = (T.INFO, T.ACCENT, T.PC, T.TEXT_3) # CPU, GPU, NPU, other
|
||||
|
||||
|
||||
def label(name: str) -> str:
|
||||
"""The shown (translated) name of a process group or sensor group from the stream (literal tr() calls so
|
||||
scripts/i18n_extract.py finds them)."""
|
||||
return {"Game": tr("Game"), "Steam": tr("Steam"), "SteamVR": tr("SteamVR"), "Desktop": tr("Desktop"),
|
||||
"Other": tr("Other"), "CPU": tr("CPU"), "GPU": tr("GPU"), "Memory": tr("Memory"),
|
||||
"Battery": tr("Battery"), "NPU": tr("NPU"), "Power ICs": tr("Power ICs"), "Modem": tr("Modem"),
|
||||
"Camera": tr("Camera"), "Storage": tr("Storage")}.get(name, name)
|
||||
|
||||
|
||||
class Tile:
|
||||
"""One metric: label, big value, a sub-line and a 2-minute sparkline."""
|
||||
|
||||
def __init__(self, label: str, icon: str, color: str, hi: float | None = None, help_key: str | None = None):
|
||||
self.color = color
|
||||
self.value = ft.Text("–", size=T.px(26), weight=ft.FontWeight.W_600, color=T.TEXT)
|
||||
self.sub = C.meta("", max_lines=1, overflow=ft.TextOverflow.ELLIPSIS)
|
||||
self.spark = C.Sparkline(color, height=34, hi=hi)
|
||||
head = ft.Row([ft.Icon(icon, size=T.px(16), color=color), C.meta(label)], spacing=T.S2)
|
||||
if help_key:
|
||||
head.controls.append(C.help_icon(help_key, 14))
|
||||
self.control = C.card(ft.Column([head, self.value, self.sub, self.spark.control], spacing=T.px(4)),
|
||||
padding=T.S3, col={"xs": 12, "sm": 6, "md": 4, "xl": 2})
|
||||
|
||||
def set(self, value: str, sub: str, series: list, lvl: str = "ok", target: float | None = None) -> None:
|
||||
self.value.value, self.sub.value = value, sub
|
||||
self.value.color = LEVEL_COLOR[lvl]
|
||||
self.spark.set_color(self.color if lvl == "ok" else LEVEL_COLOR[lvl])
|
||||
self.spark.set(series, target=target)
|
||||
|
||||
|
||||
class ProcRow:
|
||||
"""A pooled process row: created once, bound to whichever process is shown at its position."""
|
||||
|
||||
def __init__(self, view: MonitorView):
|
||||
self.view, self.proc = view, None
|
||||
self.lock = ft.Icon(ft.Icons.LOCK_OUTLINE_ROUNDED, size=T.px(14), color=T.WARN, visible=False,
|
||||
tooltip=tr("Ending this stops Steam, SteamVR or the desktop"))
|
||||
self.name = ft.Text("", size=T.T_BODY, color=T.TEXT, max_lines=1, overflow=ft.TextOverflow.ELLIPSIS)
|
||||
self.pid = C.meta("")
|
||||
self.group = ft.Text("", size=T.T_SMALL, weight=ft.FontWeight.W_600)
|
||||
self.group_box = ft.Container(self.group, border_radius=T.px(20),
|
||||
padding=ft.Padding(T.px(8), T.px(2), T.px(8), T.px(2)))
|
||||
self.cpu, self.gpu, self.mem, self.age = (ft.Text("", size=T.T_BODY, color=T.TEXT_2,
|
||||
text_align=ft.TextAlign.RIGHT) for _ in range(4))
|
||||
dots = ft.GestureDetector(ft.Icon(ft.Icons.MORE_HORIZ_ROUNDED, size=T.px(18), color=T.TEXT_2),
|
||||
on_tap_down=lambda e: self.view.open_menu(self.proc, e.global_position),
|
||||
mouse_cursor=ft.MouseCursor.CLICK)
|
||||
cells = [ft.Container(ft.Row([self.lock, self.name], spacing=T.px(6), tight=True), expand=True),
|
||||
ft.Container(self.pid, width=T.px(70)), ft.Container(self.group_box, width=T.px(96)),
|
||||
ft.Container(self.cpu, width=T.px(64)), ft.Container(self.gpu, width=T.px(64)),
|
||||
ft.Container(self.mem, width=T.px(84)), ft.Container(self.age, width=T.px(76)),
|
||||
ft.Container(dots, width=T.px(32), alignment=ft.Alignment.CENTER)]
|
||||
self.box = ft.Container(ft.Row(cells, spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
padding=ft.Padding(T.S3, T.px(6), T.S2, T.px(6)), border_radius=T.RADIUS_SM,
|
||||
on_hover=self._hover)
|
||||
self.control = ft.GestureDetector(self.box, visible=False,
|
||||
on_secondary_tap_down=lambda e: self.view.open_menu(self.proc,
|
||||
e.global_position))
|
||||
|
||||
def _hover(self, e) -> None:
|
||||
self.box.bgcolor = T.SURFACE_2 if e.data in (True, "true") else None
|
||||
C.update(self.box)
|
||||
|
||||
def bind(self, p: dict | None) -> None:
|
||||
self.proc = p
|
||||
self.control.visible = p is not None
|
||||
if p is None:
|
||||
return
|
||||
group_name = M.group_label(p.get("group", ""))
|
||||
color = GROUP_COLOR[group_name]
|
||||
self.name.value = p.get("name", "")
|
||||
self.name.color = T.TEXT_3 if p.get("context") else T.TEXT
|
||||
self.name.tooltip = p.get("game") or None
|
||||
self.pid.value = str(p.get("pid"))
|
||||
self.group.value, self.group.color = label(group_name), color
|
||||
self.group_box.bgcolor = T.soft(color, 0.12)
|
||||
self.lock.visible = bool(p.get("critical") or p.get("locked"))
|
||||
self.lock.color = T.TEXT_3 if p.get("locked") else T.WARN
|
||||
self.cpu.value = f"{p.get('cpu', 0):.1f} %"
|
||||
self.cpu.color = LEVEL_COLOR[M.level("cpu", p.get("cpu"))] if p.get("cpu", 0) >= 1 else T.TEXT_2
|
||||
self.gpu.value = f"{p['gpu']:.0f} %" if p.get("gpu") else "–"
|
||||
self.mem.value = M.fmt_bytes(p.get("rss"))
|
||||
self.age.value = M.fmt_duration(p.get("age"))
|
||||
|
||||
|
||||
class MonitorView:
|
||||
def __init__(self, app: FramePortApp):
|
||||
self.app = app
|
||||
self.session = None # frame.monitor.MonitorSession while the tab is open
|
||||
self._connecting = False
|
||||
self._gen = 0 # bumped by stop(): a connect that finishes after the tab was left closes itself
|
||||
self.stopped = True
|
||||
self.root = None
|
||||
self.history = M.History()
|
||||
self.static: dict = {}
|
||||
self.procs: list[dict] = []
|
||||
self.sort_key, self.descending = "cpu", True
|
||||
self.paused = False
|
||||
self.games_meta: dict[str, dict] = {} # package -> {"art": url | None, "in_library": bool}
|
||||
self.game_pkg: str | None = None
|
||||
self.last_games: list[dict] = []
|
||||
self.last_sample: dict | None = None
|
||||
self._build()
|
||||
|
||||
# ---------------------------------------------------------------- building
|
||||
def _build(self) -> None:
|
||||
self.live_dot = C.dot(T.TEXT_3, T.px(8))
|
||||
self.live_text = C.meta(tr("Connecting…"))
|
||||
self.live = ft.Container(ft.Row([self.live_dot, self.live_text], spacing=T.S2, tight=True),
|
||||
bgcolor=T.SURFACE_3, border_radius=T.px(20),
|
||||
padding=ft.Padding(T.px(10), T.px(5), T.px(12), T.px(5)))
|
||||
self.overhead = C.meta("")
|
||||
self.interval = ft.Dropdown(value="1", dense=True, width=T.px(150), border_color=T.BORDER,
|
||||
options=[ft.DropdownOption(str(s), tr("Every {n} s").format(n=s))
|
||||
for s in (1, 2, 5)], text_size=T.T_BODY,
|
||||
on_select=self._set_interval, tooltip=tr("How often the Frame sends new numbers"))
|
||||
self.pause_btn = C.icon_btn(ft.Icons.PAUSE_ROUNDED, tr("Pause"), lambda e: self.toggle_pause())
|
||||
self.reconnect = C.secondary(tr("Reconnect"), ft.Icons.REFRESH_ROUNDED, lambda e: self.start())
|
||||
self.reconnect.visible = False
|
||||
|
||||
# the running game
|
||||
self.game_art = ft.Container(width=T.px(96), height=T.px(96), border_radius=T.RADIUS_SM,
|
||||
bgcolor=T.SURFACE_2)
|
||||
self.game_title = ft.Text("", size=T.px(20), weight=ft.FontWeight.W_600, color=T.TEXT, max_lines=1,
|
||||
overflow=ft.TextOverflow.ELLIPSIS)
|
||||
self.game_sub = C.meta("")
|
||||
self.game_fps = ft.Text("–", size=T.px(40), weight=ft.FontWeight.W_700, color=T.TEXT)
|
||||
self.game_fps_sub = C.meta(tr("frames per second"))
|
||||
self.game_spark = C.Sparkline(T.OK, height=48)
|
||||
self.game_stats = C.body("", T.TEXT_2)
|
||||
self.end_btn = C.secondary(tr("End game"), ft.Icons.STOP_CIRCLE_OUTLINED, lambda e: self.ask_end_game())
|
||||
self.page_btn = C.ghost(tr("Game page"), ft.Icons.OPEN_IN_NEW_ROUNDED,
|
||||
lambda e: self.game_pkg and self.app.open_game(self.game_pkg))
|
||||
self.game_card = C.card(ft.Row([
|
||||
self.game_art,
|
||||
ft.Column([C.pill(tr("Running now"), T.OK, ft.Icons.PLAY_ARROW_ROUNDED), self.game_title, self.game_sub,
|
||||
self.game_stats, ft.Row([self.end_btn, self.page_btn], spacing=T.S2)],
|
||||
spacing=T.px(6), expand=3),
|
||||
ft.Column([ft.Row([self.game_fps, self.game_fps_sub], spacing=T.S2,
|
||||
vertical_alignment=ft.CrossAxisAlignment.END),
|
||||
self.game_spark.control], spacing=T.px(4), expand=2,
|
||||
horizontal_alignment=ft.CrossAxisAlignment.STRETCH),
|
||||
], spacing=T.S4, vertical_alignment=ft.CrossAxisAlignment.CENTER), padding=T.S4, visible=False,
|
||||
bgcolor=T.soft(T.ACCENT, 0.06))
|
||||
self.game_card.border = ft.Border.all(1, T.soft(T.ACCENT, 0.35))
|
||||
|
||||
# metric tiles
|
||||
self.t_cpu = Tile(tr("CPU"), ft.Icons.MEMORY_ROUNDED, T.INFO, hi=100)
|
||||
self.t_gpu = Tile(tr("GPU"), ft.Icons.VIEW_IN_AR_ROUNDED, T.ACCENT, hi=100, help_key="monitor_gpu")
|
||||
self.t_mem = Tile(tr("Memory"), ft.Icons.STORAGE_ROUNDED, T.PC, hi=100, help_key="monitor_pressure")
|
||||
self.t_temp = Tile(tr("Temperature"), ft.Icons.THERMOSTAT_ROUNDED, T.WARN)
|
||||
self.t_power = Tile(tr("Power"), ft.Icons.BOLT_ROUNDED, T.OK, help_key="monitor_power")
|
||||
self.t_bat = Tile(tr("Battery"), ft.Icons.BATTERY_STD_ROUNDED, T.OK, hi=100)
|
||||
self.tiles = ft.ResponsiveRow([t.control for t in (self.t_cpu, self.t_gpu, self.t_mem, self.t_temp,
|
||||
self.t_power, self.t_bat)],
|
||||
spacing=T.S3, run_spacing=T.S3)
|
||||
|
||||
# details (filled once the stream says how many cores / which sensors the Frame has)
|
||||
self.cores = ft.Column(spacing=T.px(6))
|
||||
self.temps = ft.Row(spacing=T.S2, run_spacing=T.S2, wrap=True)
|
||||
self.power_bar = C.MeterBar(list(POWER_COLORS), height=10)
|
||||
self.power_legend = ft.Row(spacing=T.S4, run_spacing=T.S2, wrap=True)
|
||||
self.net = ft.Column(spacing=T.px(4))
|
||||
self.core_bars: list[tuple[C.MeterBar, ft.Text]] = []
|
||||
self.temp_chips: dict[str, ft.Text] = {}
|
||||
self.legend_texts: list[ft.Text] = []
|
||||
self.net_texts: dict[str, ft.Text] = {}
|
||||
self.details = ft.ResponsiveRow([
|
||||
C.card(ft.Column([C.meta(tr("CPU cores")), self.cores], spacing=T.S2), padding=T.S3,
|
||||
col={"xs": 12, "md": 4}),
|
||||
C.card(ft.Column([C.meta(tr("Temperatures")), self.temps], spacing=T.S2), padding=T.S3,
|
||||
col={"xs": 12, "md": 4}),
|
||||
C.card(ft.Column([C.meta(tr("Where the power goes")), self.power_bar.control, self.power_legend,
|
||||
ft.Container(height=T.px(4)), C.meta(tr("Network")), self.net], spacing=T.S2),
|
||||
padding=T.S3, col={"xs": 12, "md": 4}),
|
||||
], spacing=T.S3, run_spacing=T.S3, visible=False)
|
||||
self.details_btn = C.ghost(tr("Show details"), ft.Icons.EXPAND_MORE_ROUNDED, lambda e: self.toggle_details())
|
||||
|
||||
# processes
|
||||
self.filter = ft.SegmentedButton(
|
||||
segments=[ft.Segment("game", label=tr("Game")), ft.Segment("steam", label=tr("Steam & SteamVR")),
|
||||
ft.Segment("all", label=tr("All"))],
|
||||
selected=["game"], show_selected_icon=False, on_change=self._set_filter)
|
||||
self.search = ft.TextField(hint_text=tr("Search name, PID or game"), dense=True, width=T.px(240),
|
||||
border_color=T.BORDER, prefix_icon=ft.Icons.SEARCH_ROUNDED,
|
||||
on_change=lambda e: self._bind_rows(update=True))
|
||||
self.sort_btns: dict[str, ft.TextButton] = {}
|
||||
left, right = ft.Alignment.CENTER_LEFT, ft.Alignment.CENTER_RIGHT
|
||||
header_cells = [ft.Container(self._sort_btn("name", tr("Name")), expand=True, alignment=left),
|
||||
ft.Container(C.meta(tr("PID")), width=T.px(70), alignment=left),
|
||||
ft.Container(C.meta(tr("Group")), width=T.px(96), alignment=left),
|
||||
ft.Container(self._sort_btn("cpu", tr("CPU")), width=T.px(64), alignment=right),
|
||||
ft.Container(self._sort_btn("gpu", tr("GPU")), width=T.px(64), alignment=right),
|
||||
ft.Container(self._sort_btn("rss", tr("Memory")), width=T.px(84), alignment=right),
|
||||
ft.Container(C.meta(tr("Running")), width=T.px(76), alignment=right),
|
||||
ft.Container(width=T.px(32))]
|
||||
self.header = ft.Container(ft.Row(header_cells, spacing=T.S2), padding=ft.Padding(T.S3, 0, T.S2, T.px(4)),
|
||||
border=ft.Border(bottom=ft.BorderSide(1, T.BORDER)))
|
||||
self.rows = [ProcRow(self) for _ in range(ROWS)]
|
||||
self.proc_note = C.meta("")
|
||||
self.no_game = C.callout(tr("No FramePort game is running. Start one in the headset or with Play, or switch "
|
||||
"the filter to see everything on the Frame."), icon=ft.Icons.SPORTS_ESPORTS_ROUNDED)
|
||||
self.no_game.visible = False
|
||||
self.menu = ft.ContextMenu(content=C.card(ft.Column(
|
||||
[self.header, ft.Column([r.control for r in self.rows], spacing=0), self.proc_note],
|
||||
spacing=T.px(4)), padding=T.S2), secondary_trigger=None, tertiary_trigger=None)
|
||||
self._update_sort_labels()
|
||||
|
||||
def _sort_btn(self, key: str, label: str) -> ft.TextButton:
|
||||
b = ft.TextButton(label, on_click=lambda e, k=key: self.set_sort(k),
|
||||
style=ft.ButtonStyle(padding=0, color=T.TEXT_3,
|
||||
text_style=ft.TextStyle(size=T.T_META, weight=ft.FontWeight.W_600)))
|
||||
self.sort_btns[key] = b
|
||||
return b
|
||||
|
||||
def mount(self) -> ft.Control:
|
||||
app = self.app
|
||||
heading, sub = tr("Monitor"), tr("What your Frame is doing right now")
|
||||
if not (app.target and app.frame_state == "connected"):
|
||||
return ft.Column([
|
||||
app.top_bar(heading, sub),
|
||||
C.empty_state(ft.Icons.MONITOR_HEART_OUTLINED, tr("Connect your Frame first"),
|
||||
tr("The monitor shows your Frame's games, load, temperatures and battery live once "
|
||||
"FramePort is connected to it."),
|
||||
C.primary(tr("Connect"), ft.Icons.LINK_ROUNDED, lambda e: app.go("frame")))], expand=True)
|
||||
if self.root is None:
|
||||
self.root = ft.Column([
|
||||
app.top_bar(heading, sub, [self.overhead, self.live, self.reconnect, self.interval, self.pause_btn]),
|
||||
self.game_card,
|
||||
self.tiles,
|
||||
ft.Row([self.details_btn], alignment=ft.MainAxisAlignment.START),
|
||||
self.details,
|
||||
C.section(tr("Processes"), ft.Row([self.filter, self.search], spacing=T.S3, wrap=True,
|
||||
run_spacing=T.S2, alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
|
||||
self.no_game, self.menu, help="monitor_filter"),
|
||||
ft.Container(height=T.S5),
|
||||
], spacing=T.S4, scroll=ft.ScrollMode.AUTO, expand=True)
|
||||
self.start()
|
||||
return self.root
|
||||
|
||||
# ---------------------------------------------------------------- session
|
||||
def start(self) -> None:
|
||||
self.stopped = False
|
||||
if self.session is not None or self._connecting:
|
||||
return
|
||||
self._connecting = True
|
||||
self._set_live(tr("Connecting…"), T.TEXT_3, update=self.root is not None)
|
||||
gen, target = self._gen, self.app.target
|
||||
self.app.run_bg(self._connect, gen, target)
|
||||
|
||||
def _connect(self, gen: int, target) -> None:
|
||||
try:
|
||||
session = M.MonitorSession(target.frame, lambda s, g=gen: self._on_sample(g, s),
|
||||
lambda why, g=gen: self._on_end(g, why))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._connecting = False
|
||||
if gen == self._gen:
|
||||
self._set_live(tr("Not connected: {error}").format(error=explain(exc)), T.ERROR)
|
||||
return
|
||||
self._connecting = False
|
||||
if gen != self._gen: # the tab was left while connecting
|
||||
session.close()
|
||||
return
|
||||
self.session = session
|
||||
if session.static != self.static:
|
||||
self.static = session.static
|
||||
self._build_static()
|
||||
if self.interval.value != "1":
|
||||
session.set_interval(int(self.interval.value))
|
||||
if self.filter.selected != ["game"]:
|
||||
session.set_filter(self.filter.selected[0])
|
||||
if self.paused:
|
||||
session.pause(True)
|
||||
self._set_live(tr("Live"), T.OK)
|
||||
|
||||
def _on_end(self, gen: int, why: str) -> None:
|
||||
if gen != self._gen:
|
||||
return
|
||||
self.session = None
|
||||
self._set_live(tr("Connection lost, reconnecting…"), T.WARN)
|
||||
timer = threading.Timer(RETRY_SECONDS, lambda: gen == self._gen and not self.stopped and self.start())
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""End the stream (leaving the tab, disconnecting, closing the app)."""
|
||||
self._gen += 1
|
||||
self.stopped = True
|
||||
session, self.session = self.session, None
|
||||
if session is not None:
|
||||
session.close()
|
||||
|
||||
def _set_live(self, text: str, color: str, update: bool = True) -> None:
|
||||
self.live_text.value, self.live_dot.bgcolor = text, color
|
||||
self.reconnect.visible = color == T.ERROR
|
||||
if update:
|
||||
C.update(self.live, self.reconnect)
|
||||
|
||||
def _set_interval(self, e) -> None:
|
||||
if self.session:
|
||||
self.session.set_interval(int(self.interval.value))
|
||||
|
||||
def toggle_pause(self) -> None:
|
||||
self.paused = not self.paused
|
||||
if self.session:
|
||||
self.session.pause(self.paused)
|
||||
self.pause_btn.icon = ft.Icons.PLAY_ARROW_ROUNDED if self.paused else ft.Icons.PAUSE_ROUNDED
|
||||
self.pause_btn.tooltip = tr("Resume") if self.paused else tr("Pause")
|
||||
self._set_live(tr("Paused") if self.paused else tr("Live"), T.TEXT_3 if self.paused else T.OK, update=False)
|
||||
C.update(self.pause_btn, self.live)
|
||||
|
||||
def toggle_details(self) -> None:
|
||||
self.details.visible = not self.details.visible
|
||||
if self.details.visible and self.last_sample: # fill it now, not at the next tick
|
||||
self._apply_details(self.last_sample)
|
||||
self.details_btn.content = tr("Hide details") if self.details.visible else tr("Show details")
|
||||
self.details_btn.icon = ft.Icons.EXPAND_LESS_ROUNDED if self.details.visible else ft.Icons.EXPAND_MORE_ROUNDED
|
||||
C.update(self.details, self.details_btn)
|
||||
|
||||
def _set_filter(self, e) -> None:
|
||||
which = (self.filter.selected or ["game"])[0]
|
||||
if self.session:
|
||||
self.session.set_filter(which)
|
||||
|
||||
def set_sort(self, key: str) -> None:
|
||||
if self.sort_key == key:
|
||||
self.descending = not self.descending
|
||||
else:
|
||||
self.sort_key, self.descending = key, key != "name"
|
||||
self._update_sort_labels()
|
||||
self._bind_rows(update=True)
|
||||
C.update(*self.sort_btns.values())
|
||||
|
||||
def _update_sort_labels(self) -> None:
|
||||
names = {"name": tr("Name"), "cpu": tr("CPU"), "gpu": tr("GPU"), "rss": tr("Memory")}
|
||||
for key, b in self.sort_btns.items():
|
||||
arrow = (" ↓" if self.descending else " ↑") if key == self.sort_key else ""
|
||||
b.content = names[key] + arrow
|
||||
b.style.color = T.TEXT if key == self.sort_key else T.TEXT_3
|
||||
|
||||
# ---------------------------------------------------------------- static layout from the Frame
|
||||
def _build_static(self) -> None:
|
||||
st = self.static
|
||||
colors = {}
|
||||
for i, cl in enumerate(st.get("clusters") or []):
|
||||
for cpu in cl.get("cpus") or []:
|
||||
colors[cpu] = CLUSTER_COLORS[i % len(CLUSTER_COLORS)]
|
||||
self.core_bars = []
|
||||
rows = []
|
||||
for cpu in range(st.get("cores") or 0):
|
||||
bar, val = C.MeterBar([colors.get(cpu, T.INFO)], height=6), C.meta("", width=T.px(42),
|
||||
text_align=ft.TextAlign.RIGHT)
|
||||
self.core_bars.append((bar, val))
|
||||
rows.append(ft.Row([C.meta(f"{cpu}", width=T.px(16)), ft.Container(bar.control, expand=True), val],
|
||||
spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.CENTER))
|
||||
self.cores.controls = rows
|
||||
self.temp_chips = {}
|
||||
chips = []
|
||||
for group in st.get("temp_groups") or []:
|
||||
value = ft.Text("–", size=T.T_BODY, weight=ft.FontWeight.W_600, color=T.TEXT)
|
||||
self.temp_chips[group] = value
|
||||
chips.append(ft.Container(ft.Column([C.meta(label(group)), value], spacing=0, tight=True),
|
||||
bgcolor=T.SURFACE_2, border_radius=T.RADIUS_SM,
|
||||
padding=ft.Padding(T.px(10), T.px(6), T.px(10), T.px(6))))
|
||||
self.temps.controls = chips
|
||||
self.legend_texts = []
|
||||
legend = []
|
||||
for color, name in zip(POWER_COLORS, ("CPU", "GPU", "NPU", tr("Other")), strict=True):
|
||||
t = C.meta(f"{name} –")
|
||||
self.legend_texts.append(t)
|
||||
legend.append(ft.Row([C.dot(color, T.px(8)), t], spacing=T.px(6), tight=True))
|
||||
self.power_legend.controls = legend
|
||||
self.net.controls, self.net_texts = [], {}
|
||||
C.update(self.cores, self.temps, self.power_legend, self.net)
|
||||
|
||||
# ---------------------------------------------------------------- samples
|
||||
def _on_sample(self, gen: int, s: dict) -> None:
|
||||
if gen != self._gen or self.root is None:
|
||||
return
|
||||
self.last_sample = s
|
||||
for name, value in M.series_of(s).items():
|
||||
self.history.add(name, value)
|
||||
changed = [self._apply_tiles(s), self._apply_game(s), self._apply_details(s)]
|
||||
if "procs" in s:
|
||||
self.procs = s.get("procs") or []
|
||||
changed.append(self._bind_rows(update=False))
|
||||
cpu_share = (s.get("self_ms") or 0) / 10 / max(1.0, s.get("dt") or 1.0) / max(1, self.static.get("cores", 1))
|
||||
self.overhead.value = tr("Monitor uses {pct} % of the Frame's CPU").format(pct=f"{cpu_share:.2f}")
|
||||
self.overhead.tooltip = tr("The time the Frame spends collecting these numbers")
|
||||
C.update(self.overhead, *[c for group in changed for c in group])
|
||||
|
||||
def _apply_tiles(self, s: dict) -> list:
|
||||
h, st = self.history, self.static
|
||||
cpu = s.get("cpu") or {}
|
||||
mhz = cpu.get("mhz") or []
|
||||
clocks = " · ".join(f"{m / 1000:.1f}" for m in mhz) + (" GHz" if mhz else "")
|
||||
self.t_cpu.set(M.fmt_pct(cpu.get("total")), clocks, h.get("cpu"), M.level("cpu", cpu.get("total")))
|
||||
gpu = s.get("gpu") or {}
|
||||
gmax = st.get("gpu_max_mhz")
|
||||
sub = f"{gpu.get('mhz')} / {gmax} MHz" if gpu.get("mhz") is not None and gmax else ""
|
||||
self.t_gpu.set(M.fmt_pct(gpu.get("busy")), sub, h.get("gpu"), M.level("gpu", gpu.get("busy")))
|
||||
mem = s.get("mem") or {}
|
||||
total, used = mem.get("total") or 0, (mem.get("total") or 0) - (mem.get("avail") or 0)
|
||||
psi = (s.get("psi") or {}).get("memory")
|
||||
swap = (mem.get("swap_total") or 0) - (mem.get("swap_free") or 0)
|
||||
sub = tr("{used} of {total}").format(used=M.fmt_bytes(used), total=M.fmt_bytes(total))
|
||||
if swap > 64 * 1024 ** 2:
|
||||
sub += " · " + tr("swap {n}").format(n=M.fmt_bytes(swap))
|
||||
if psi:
|
||||
sub += " · " + tr("waiting {pct} %").format(pct=f"{psi:.0f}")
|
||||
lvl = max((M.level("mem", 100.0 * used / total if total else None), M.level("psi_memory", psi)),
|
||||
key=("ok", "warn", "error").index)
|
||||
self.t_mem.set(M.fmt_pct(100.0 * used / total if total else None), sub, h.get("mem"), lvl)
|
||||
temps = s.get("temps") or {}
|
||||
hot = max(temps.items(), key=lambda kv: kv[1]) if temps else None
|
||||
fan = s.get("fan")
|
||||
sub = (tr("hottest: {part}").format(part=label(hot[0])) if hot else "") + \
|
||||
(f" · {tr('fan')} {fan} rpm" if fan else "")
|
||||
lvl = max((M.temp_level(g, v) for g, v in temps.items()), key=("ok", "warn", "error").index, default="ok")
|
||||
self.t_temp.set(f"{hot[1]:.0f} °C" if hot else "–", sub, h.get("temp"), lvl)
|
||||
pw = s.get("power") or {}
|
||||
self.t_power.set(M.fmt_watts(pw.get("system")),
|
||||
" · ".join(f"{k.upper()} {M.fmt_watts(pw.get(k))}" for k in ("cpu", "gpu") if k in pw),
|
||||
h.get("power"))
|
||||
bat = s.get("battery")
|
||||
lvl = "error" if bat and bat.get("percent", 100) <= 10 and bat.get("draining") else \
|
||||
"warn" if bat and bat.get("percent", 100) <= 20 and bat.get("draining") else "ok"
|
||||
self.t_bat.set(M.fmt_pct(bat.get("percent")) if bat else "–", M.battery_line(bat), h.get("battery"), lvl)
|
||||
self.t_bat.color = T.OK if bat and bat.get("plugged") else T.INFO
|
||||
return [t.control for t in (self.t_cpu, self.t_gpu, self.t_mem, self.t_temp, self.t_power, self.t_bat)]
|
||||
|
||||
def _game_meta(self, pkg: str) -> dict:
|
||||
meta = self.games_meta.get(pkg)
|
||||
if meta is None: # once per game, on the stream's thread (library + thumbnail lookups are file I/O)
|
||||
from ...artwork import thumbs
|
||||
from ...core import library
|
||||
from .library import CARD_ART
|
||||
|
||||
entry = library.game(pkg)
|
||||
meta = {"in_library": entry is not None,
|
||||
"art": thumbs.url(pkg, CARD_ART, wait=False) if entry else None}
|
||||
self.games_meta[pkg] = meta
|
||||
return meta
|
||||
|
||||
def _apply_game(self, s: dict) -> list:
|
||||
games = s.get("games") or []
|
||||
self.last_games = games
|
||||
game = games[0] if games else None
|
||||
was = self.game_card.visible
|
||||
self.game_card.visible = game is not None
|
||||
self.no_game.visible = game is None and (self.filter.selected or ["game"])[0] == "game"
|
||||
if game is None:
|
||||
if self.game_pkg is not None:
|
||||
self.game_pkg = None
|
||||
self.history.series.pop("fps", None)
|
||||
return [self.game_card, self.no_game] if was else [self.no_game]
|
||||
pkg = game["package"]
|
||||
if pkg != self.game_pkg:
|
||||
self.game_pkg = pkg
|
||||
meta = self._game_meta(pkg)
|
||||
art = C.art_fill(meta["art"], radius=T.RADIUS_SM, width=T.px(96), height=T.px(96))
|
||||
self.game_art.image, self.game_art.gradient = art.image, art.gradient
|
||||
self.game_art.content, self.game_art.alignment = art.content, art.alignment
|
||||
self.page_btn.visible = meta["in_library"]
|
||||
kind = {"pcvr": tr("PC VR via Proton"), "linux": tr("Linux app")}.get(game.get("kind"), tr("Quest game"))
|
||||
self.game_title.value = game.get("title") or pkg
|
||||
self.game_sub.value = " · ".join(x for x in (kind, tr("running for {t}").format(
|
||||
t=M.fmt_duration(game.get("elapsed"))) if game.get("elapsed") is not None else "") if x)
|
||||
self.game_stats.value = tr("CPU {cpu} · GPU {gpu} · memory {mem} · {n} processes").format(
|
||||
cpu=M.fmt_pct(game.get("cpu")), gpu=M.fmt_pct(game.get("gpu")), mem=M.fmt_bytes(game.get("mem")),
|
||||
n=game.get("processes", 0))
|
||||
fps = game.get("fps")
|
||||
series = self.history.get("fps")
|
||||
target = M.fps_target(series)
|
||||
ratio = fps / target if fps and target else None
|
||||
lvl = M.level("fps_ratio", ratio)
|
||||
self.game_fps.value = f"{fps:.0f}" if fps is not None else "–"
|
||||
self.game_fps.color = LEVEL_COLOR[lvl] if fps is not None else T.TEXT_3
|
||||
if game.get("kind") in ("pcvr", "linux"):
|
||||
self.game_fps_sub.value = tr("fps isn't reported for PC VR and Linux games")
|
||||
elif fps is None:
|
||||
self.game_fps_sub.value = tr("fps: waiting for the game's first frames")
|
||||
else:
|
||||
ms = game.get("frame_ms")
|
||||
self.game_fps_sub.value = tr("fps · target {hz} Hz").format(hz=target) + (
|
||||
f" · {ms:.1f} ms " + tr("late") if ms and ms >= 0.5 else "")
|
||||
self.game_spark.set_color(T.OK if lvl == "ok" else LEVEL_COLOR[lvl])
|
||||
self.game_spark.set(series, target=target, hi=(target or 72) * 1.15)
|
||||
return [self.game_card, self.no_game]
|
||||
|
||||
def _apply_details(self, s: dict) -> list:
|
||||
if not self.details.visible:
|
||||
return []
|
||||
out = []
|
||||
cores = (s.get("cpu") or {}).get("cores") or []
|
||||
for (bar, val), pct in zip(self.core_bars, cores, strict=False):
|
||||
bar.set([pct / 100.0])
|
||||
val.value = f"{pct:.0f} %"
|
||||
out += [bar.control, val]
|
||||
temps = s.get("temps") or {}
|
||||
zones = s.get("zones")
|
||||
for group, text in self.temp_chips.items():
|
||||
v = temps.get(group)
|
||||
text.value = f"{v:.1f} °C" if v is not None else "–"
|
||||
text.color = LEVEL_COLOR[M.temp_level(group, v)]
|
||||
if zones and group in zones:
|
||||
text.tooltip = "\n".join(f"{z}: {c:.1f} °C" for z, c in sorted(zones[group].items()))
|
||||
out.append(text)
|
||||
split = M.power_split(s.get("power") or {})
|
||||
total = sum(w for _n, w in split) or 1.0
|
||||
self.power_bar.set([w / total for _n, w in split])
|
||||
for t, (name, w) in zip(self.legend_texts, split, strict=False):
|
||||
t.value = f"{label(name)} {M.fmt_watts(w)}"
|
||||
out += [self.power_bar.control, *self.legend_texts]
|
||||
net = s.get("net") or {}
|
||||
new = [k for k in net if k not in self.net_texts]
|
||||
for k in new:
|
||||
self.net_texts[k] = C.meta("")
|
||||
self.net.controls.append(self.net_texts[k])
|
||||
for k, (rx, tx) in net.items():
|
||||
self.net_texts[k].value = f"{k} ↓ {M.fmt_rate(rx)} ↑ {M.fmt_rate(tx)}"
|
||||
out += [self.net] if new else list(self.net_texts.values())
|
||||
return out
|
||||
|
||||
def _bind_rows(self, update: bool = True) -> list:
|
||||
procs = M.sort_filter(self.procs, self.search.value or "", self.sort_key, self.descending)
|
||||
for row, p in zip(self.rows, procs + [None] * ROWS, strict=False):
|
||||
row.bind(p)
|
||||
shown, total = min(len(procs), ROWS), len(self.procs)
|
||||
if len(procs) > ROWS:
|
||||
self.proc_note.value = tr("Showing the top {shown} of {n}: search to find others.").format(
|
||||
shown=shown, n=len(procs))
|
||||
elif self.search.value and not procs:
|
||||
self.proc_note.value = tr("No process matches.")
|
||||
else:
|
||||
self.proc_note.value = tr("{n} processes").format(n=total) if total else ""
|
||||
controls = [r.control for r in self.rows] + [self.proc_note]
|
||||
if update:
|
||||
C.update(*controls)
|
||||
return controls
|
||||
|
||||
# ---------------------------------------------------------------- actions
|
||||
def open_menu(self, p: dict | None, position=None) -> None:
|
||||
if not p:
|
||||
return
|
||||
name, pid = p.get("name", "?"), p.get("pid")
|
||||
actions: list = []
|
||||
if not p.get("locked"):
|
||||
actions += [(tr("End process"), ft.Icons.CLOSE_ROUNDED, lambda e: self.ask_kill(p, "TERM")),
|
||||
(tr("Force kill"), ft.Icons.DANGEROUS_OUTLINED, lambda e: self.ask_kill(p, "KILL"))]
|
||||
if p.get("game"):
|
||||
actions += [None, (tr("End the whole game"), ft.Icons.STOP_CIRCLE_OUTLINED,
|
||||
lambda e: self.ask_end_game(p["game"]))]
|
||||
actions += [None, (tr("Copy PID {pid}").format(pid=pid), ft.Icons.CONTENT_COPY_ROUNDED,
|
||||
lambda e: self.app.copy(str(pid))),
|
||||
(tr("Copy name"), ft.Icons.CONTENT_COPY_ROUNDED, lambda e: self.app.copy(name))]
|
||||
self.menu.items = C.menu_items(actions)
|
||||
C.update(self.menu)
|
||||
self.app.page.run_task(self.menu.open, global_position=position)
|
||||
|
||||
def ask_kill(self, p: dict, sig: str) -> None:
|
||||
name, pid = p.get("name", "?"), p.get("pid")
|
||||
if p.get("critical"):
|
||||
heading = tr("End {name}?").format(name=name)
|
||||
text = tr("{name} is part of Steam, SteamVR or the desktop. Ending it can close the headset view or "
|
||||
"restart Steam, and anything running in the headset may stop.").format(name=name)
|
||||
elif sig == "KILL":
|
||||
heading = tr("Force kill {name}?").format(name=name)
|
||||
text = tr("{name} (PID {pid}) stops at once without saving anything.").format(name=name, pid=pid)
|
||||
else:
|
||||
heading = tr("End {name}?").format(name=name)
|
||||
text = tr("{name} (PID {pid}) is asked to close.").format(name=name, pid=pid)
|
||||
if p.get("game"):
|
||||
text += " " + tr("It belongs to a running game, which may stop with it.")
|
||||
label = tr("Force kill") if sig == "KILL" else tr("End process")
|
||||
C.confirm(self.app.page, heading, text, label, lambda: self.app.run_bg(self._kill, p, sig),
|
||||
danger=sig == "KILL" or bool(p.get("critical")))
|
||||
|
||||
def _kill(self, p: dict, sig: str) -> None:
|
||||
session = self.session
|
||||
if session is None:
|
||||
self.app.toast(tr("The monitor isn't connected."), error=True)
|
||||
return
|
||||
name = p.get("name", "?")
|
||||
try:
|
||||
res = session.kill(p["pid"], sig, force=bool(p.get("critical")))
|
||||
except M.ProcessCritical: # the table was older than the Frame's view of it
|
||||
res = session.kill(p["pid"], sig, force=True)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.app.toast(explain(exc), error=True)
|
||||
return
|
||||
if res.get("ended"):
|
||||
self.app.toast(tr("{name} ended.").format(name=name))
|
||||
else:
|
||||
self.app.toast(tr("{name} is still running.").format(name=name), action=tr("Force kill"),
|
||||
on_action=lambda e: self.app.run_bg(self._kill, p, "KILL"))
|
||||
|
||||
def ask_end_game(self, pkg: str | None = None) -> None:
|
||||
pkg = pkg or self.game_pkg
|
||||
if not pkg:
|
||||
return
|
||||
title = next((g.get("title") for g in self.last_games if g.get("package") == pkg), None) or pkg
|
||||
C.confirm(self.app.page, tr("End {title}?").format(title=title),
|
||||
tr("The game is closed the way Steam's Exit game does it; if it doesn't stop, FramePort stops it. "
|
||||
"Progress that the game hasn't saved is lost."),
|
||||
tr("End game"), lambda: self.app.run_bg(self._end_game, pkg, title), danger=True)
|
||||
|
||||
def _end_game(self, pkg: str, title: str) -> None:
|
||||
session = self.session
|
||||
if session is None:
|
||||
self.app.toast(tr("The monitor isn't connected."), error=True)
|
||||
return
|
||||
self.end_btn.disabled = True
|
||||
C.update(self.end_btn)
|
||||
try:
|
||||
res = session.end_game(pkg)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.app.toast(explain(exc), error=True)
|
||||
return
|
||||
finally:
|
||||
self.end_btn.disabled = False
|
||||
C.update(self.end_btn)
|
||||
if res.get("ended"):
|
||||
self.app.toast(tr("{title} ended.").format(title=title))
|
||||
else:
|
||||
self.app.toast(tr("{title} is still running.").format(title=title), error=True)
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Monitor client: the `_monitor` line protocol (samples, replies, end of stream) and the pure helpers."""
|
||||
import io
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.frame import monitor as M
|
||||
|
||||
|
||||
class _Chan:
|
||||
def __init__(self):
|
||||
self.shut = False
|
||||
|
||||
def settimeout(self, t):
|
||||
pass
|
||||
|
||||
def shutdown_write(self):
|
||||
self.shut = True
|
||||
|
||||
|
||||
class _In(io.StringIO):
|
||||
def __init__(self, on_line=None):
|
||||
super().__init__()
|
||||
self.channel = _Chan()
|
||||
self.on_line = on_line
|
||||
|
||||
def write(self, s):
|
||||
n = super().write(s)
|
||||
if self.on_line:
|
||||
for line in s.splitlines():
|
||||
self.on_line(json.loads(line))
|
||||
return n
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _Out:
|
||||
"""stdout of the SSH channel: lines are fed by the test; None ends it, an exception breaks it."""
|
||||
|
||||
def __init__(self):
|
||||
self.q = queue.Queue()
|
||||
self.channel = _Chan()
|
||||
|
||||
def feed(self, obj):
|
||||
self.q.put(obj if obj is None or isinstance(obj, Exception) else json.dumps(obj) + "\n")
|
||||
|
||||
def readline(self):
|
||||
item = self.q.get(timeout=5)
|
||||
return item or ""
|
||||
|
||||
def __iter__(self):
|
||||
while True:
|
||||
item = self.q.get(timeout=5)
|
||||
if item is None:
|
||||
return
|
||||
if isinstance(item, Exception):
|
||||
raise item
|
||||
yield item
|
||||
|
||||
|
||||
def _session(ready=None, on_line=None):
|
||||
out = _Out()
|
||||
out.feed(ready if ready is not None else {"ready": 1, "static": {"agent": 60, "cores": 8}})
|
||||
stdin = _In(on_line)
|
||||
client = SimpleNamespace(exec_command=lambda cmd, timeout=None: (stdin, out, None))
|
||||
frame = SimpleNamespace(client=client, home="/home/steamos", ensure_agent=lambda: None)
|
||||
samples, ended = [], []
|
||||
return frame, stdin, out, samples, ended
|
||||
|
||||
|
||||
def test_stream_samples_controls_and_close():
|
||||
frame, stdin, out, samples, ended = _session()
|
||||
s = M.MonitorSession(frame, samples.append, ended.append)
|
||||
assert s.static == {"agent": 60, "cores": 8}
|
||||
out.feed({"t": 1, "cpu": {"total": 5}})
|
||||
out.feed("not json") # ignored
|
||||
out.feed({"t": 2})
|
||||
deadline = time.time() + 5
|
||||
while len(samples) < 2 and time.time() < deadline:
|
||||
time.sleep(0.01)
|
||||
assert [x["t"] for x in samples] == [1, 2]
|
||||
s.set_interval(2)
|
||||
s.set_filter("all")
|
||||
s.pause(True)
|
||||
s.close()
|
||||
out.feed(None)
|
||||
assert [json.loads(x) for x in stdin.getvalue().splitlines()] == [{"interval": 2}, {"procs": "all"},
|
||||
{"pause": True}]
|
||||
assert stdin.channel.shut and s.closed
|
||||
s._thread.join(5)
|
||||
assert ended == [] # closed by us: no "ended" callback
|
||||
s.set_interval(5) # after close: ignored
|
||||
assert len(stdin.getvalue().splitlines()) == 3
|
||||
|
||||
|
||||
def test_replies_kill_end_game_and_critical():
|
||||
holder = {}
|
||||
|
||||
def answer(msg): # the "agent": answers each request on stdout
|
||||
out = holder["out"]
|
||||
if msg.get("kill") == 101 and not msg.get("force"):
|
||||
out.feed({"reply": msg["id"], "ok": False, "error": "CRITICAL: vrcompositor"})
|
||||
elif msg.get("kill") == 1:
|
||||
out.feed({"reply": msg["id"], "ok": False, "error": "systemd (1) is part of the system"})
|
||||
elif "kill" in msg:
|
||||
out.feed({"reply": msg["id"], "ok": True, "result": {"ended": True, "pid": msg["kill"],
|
||||
"sig": msg["sig"], "force": msg["force"]}})
|
||||
elif "end_game" in msg:
|
||||
out.feed({"reply": msg["id"], "ok": True, "result": {"ended": True, "via": "steam"}})
|
||||
frame, stdin, out, samples, ended = _session(on_line=answer)
|
||||
holder["out"] = out
|
||||
s = M.MonitorSession(frame, samples.append, ended.append)
|
||||
assert s.kill(42) == {"ended": True, "pid": 42, "sig": "TERM", "force": False}
|
||||
assert s.kill(101, "KILL", force=True)["force"] is True
|
||||
with pytest.raises(M.ProcessCritical, match="vrcompositor"):
|
||||
s.kill(101)
|
||||
with pytest.raises(RuntimeError, match="part of the system"):
|
||||
s.kill(1)
|
||||
assert s.end_game("com.x.y") == {"ended": True, "via": "steam"}
|
||||
s.close()
|
||||
out.feed(None)
|
||||
|
||||
|
||||
def test_stream_lost_calls_on_end_and_unblocks_requests():
|
||||
frame, stdin, out, samples, ended = _session()
|
||||
s = M.MonitorSession(frame, samples.append, ended.append)
|
||||
result = {}
|
||||
|
||||
def ask():
|
||||
try:
|
||||
s.kill(5)
|
||||
except M.MonitorUnavailable as exc:
|
||||
result["err"] = str(exc)
|
||||
t = threading.Thread(target=ask)
|
||||
t.start()
|
||||
time.sleep(0.1)
|
||||
out.feed(OSError("Socket is closed"))
|
||||
t.join(5)
|
||||
s._thread.join(5)
|
||||
assert ended == ["Socket is closed"] and s.closed
|
||||
assert "stopped" in result["err"]
|
||||
|
||||
|
||||
def test_refused_by_an_old_agent():
|
||||
frame, *_ = _session(ready={"ok": False, "error": "usage: frameport_agent.py <...>"})
|
||||
with pytest.raises(M.MonitorUnavailable, match="usage"):
|
||||
M.MonitorSession(frame, lambda s: None)
|
||||
|
||||
|
||||
def test_history_and_series():
|
||||
h = M.History(size=3)
|
||||
for v in (1, 2, None, 4):
|
||||
h.add("cpu", v)
|
||||
assert h.get("cpu") == [2, None, 4] and h.get("nope") == []
|
||||
s = {"cpu": {"total": 12.5}, "gpu": {"busy": None}, "mem": {"total": 1000, "avail": 250},
|
||||
"temps": {"CPU": 50.0, "GPU": 61.5}, "power": {"system": 6.2}, "battery": {"percent": 80},
|
||||
"games": [{"fps": 71.9}]}
|
||||
assert M.series_of(s) == {"cpu": 12.5, "gpu": None, "mem": 75.0, "temp": 61.5, "power": 6.2, "battery": 80,
|
||||
"fps": 71.9}
|
||||
assert M.series_of({})["fps"] is None and M.series_of({})["mem"] is None
|
||||
|
||||
|
||||
def test_formatting():
|
||||
assert M.fmt_bytes(512) == "512 B" and M.fmt_bytes(2048) == "2 KB"
|
||||
assert M.fmt_bytes(5 * 1024 ** 2) == "5.0 MB" and M.fmt_bytes(16 * 1024 ** 3) == "16.0 GB"
|
||||
assert M.fmt_bytes(None) == "–" and M.fmt_rate(2048) == "2 KB/s"
|
||||
assert M.fmt_watts(3.558) == "3.56 W" and M.fmt_watts(12.34) == "12.3 W"
|
||||
assert M.fmt_duration(45) == "45 s" and M.fmt_duration(720) == "12 min" and M.fmt_duration(3900) == "1 h 05 min"
|
||||
assert M.fmt_pct(33.4) == "33 %" and M.fmt_pct(None) == "–"
|
||||
|
||||
|
||||
def test_levels():
|
||||
assert M.level("cpu", 50) == "ok" and M.level("cpu", 90) == "warn" and M.level("cpu", 99) == "error"
|
||||
assert M.level("temp", 79.9) == "ok" and M.level("temp", 85) == "warn" and M.level("temp", 90) == "error"
|
||||
assert M.temp_level("Battery", 46) == "warn" and M.temp_level("GPU", 46) == "ok"
|
||||
assert M.level("fps_ratio", 1.0) == "ok" and M.level("fps_ratio", 0.8) == "warn"
|
||||
assert M.level("fps_ratio", 0.5) == "error"
|
||||
assert M.level("unknown", 1e9) == "ok" and M.level("cpu", None) == "ok"
|
||||
|
||||
|
||||
def test_sort_filter_and_labels():
|
||||
procs = [{"pid": 1, "name": "steam", "cpu": 5, "rss": 10, "game": None, "gpu": 0},
|
||||
{"pid": 22, "name": "com.game", "cpu": 40, "rss": 5, "game": "com.ex.game", "gpu": 30},
|
||||
{"pid": 3, "name": "Xwayland", "cpu": 5, "rss": 50, "game": None, "gpu": 2}]
|
||||
assert [p["pid"] for p in M.sort_filter(procs)] == [22, 3, 1]
|
||||
assert [p["pid"] for p in M.sort_filter(procs, key="name", descending=False)] == [22, 1, 3]
|
||||
assert [p["pid"] for p in M.sort_filter(procs, "ex.game")] == [22]
|
||||
assert [p["pid"] for p in M.sort_filter(procs, "3")] == [3] # a PID matches exactly
|
||||
assert M.group_label("game:com.x") == "Game" and M.group_label("steamvr") == "SteamVR"
|
||||
assert M.group_label("other") == "Other"
|
||||
|
||||
|
||||
def test_power_split_and_battery_line():
|
||||
split = M.power_split({"system": 6.0, "cpu": 1.5, "gpu": 2.0, "npu": 0.1})
|
||||
assert split == [("CPU", 1.5), ("GPU", 2.0), ("NPU", 0.1), ("Other", pytest.approx(2.4))]
|
||||
assert M.battery_line(None) == "No battery"
|
||||
assert M.battery_line({"status": "Discharging", "draining": True, "empty_s": 7800, "watts": -4.0}) == \
|
||||
"2 h 10 min left · 4.00 W"
|
||||
assert M.battery_line({"status": "Charging", "plugged": True, "full_s": 2100}) == "Charging · full in 35 min"
|
||||
assert M.battery_line({"status": "Full"}) == "Full"
|
||||
|
||||
|
||||
def test_fps_target():
|
||||
assert M.fps_target([]) is None and M.fps_target([None]) is None
|
||||
assert M.fps_target([50.0, 71.9, 72.4]) == 72 and M.fps_target([88.0, 90.1]) == 90
|
||||
assert M.fps_target([200]) == 144
|
||||
@@ -0,0 +1,327 @@
|
||||
"""The agent's Monitor stream (`_monitor`): readers against a fake /proc + /sys tree, process grouping, kill safety."""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from test_agent import AGENT, load_agent
|
||||
|
||||
GAME = "com.example.game"
|
||||
APPID = 2541636856
|
||||
|
||||
|
||||
def w(path, text):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text)
|
||||
|
||||
|
||||
def proc(root, pid, comm, ppid, cpu=0, start=100, rss=1000, cmdline=None, fds=None, cgroup=None):
|
||||
d = root / "proc" / str(pid)
|
||||
stat = f"{pid} ({comm}) S {ppid} 1 1 0 -1 0 0 0 0 0 {cpu} 0 0 0 20 0 1 0 {start} 100000 {rss} 0"
|
||||
w(d / "stat", stat)
|
||||
w(d / "cmdline", "\0".join(cmdline or [comm]) + "\0")
|
||||
w(d / "status", f"Name:\t{comm}\nState:\tS (sleeping)\n")
|
||||
if cgroup:
|
||||
w(d / "cgroup", f"0::{cgroup}\n")
|
||||
(d / "fd").mkdir(parents=True, exist_ok=True)
|
||||
for fd, (target, ns) in (fds or {}).items():
|
||||
os.symlink(target, d / "fd" / str(fd))
|
||||
w(d / "fdinfo" / str(fd), f"pos:\t0\ndrm-driver:\tmsm\ndrm-engine-gpu:\t{ns} ns\ndrm-cycles-gpu:\t0\n")
|
||||
|
||||
|
||||
def set_cpu(root, pid, cpu):
|
||||
p = root / "proc" / str(pid) / "stat"
|
||||
f = p.read_text().split(" ")
|
||||
f[13] = str(cpu) # utime (field 14)
|
||||
p.write_text(" ".join(f))
|
||||
|
||||
|
||||
def set_gpu(root, pid, fd, ns):
|
||||
w(root / "proc" / str(pid) / "fdinfo" / str(fd), f"drm-driver:\tmsm\ndrm-engine-gpu:\t{ns} ns\n")
|
||||
|
||||
|
||||
def frame_tree(root, home):
|
||||
"""A small Frame: 2 clusters, GPU devfreq, thermal zones, power rails, fan, battery, one running Quest game."""
|
||||
p = root / "proc"
|
||||
w(p / "stat", "cpu 100 0 100 800 0 0 0 0 0 0\ncpu0 50 0 50 400 0 0 0 0 0 0\n"
|
||||
"cpu1 50 0 50 400 0 0 0 0 0 0\nintr 1\nbtime 1000\n")
|
||||
w(p / "meminfo", "MemTotal: 16000000 kB\nMemFree: 1 kB\nMemAvailable: 8000000 kB\n"
|
||||
"SwapTotal: 8000000 kB\nSwapFree: 7000000 kB\n")
|
||||
for k in ("cpu", "memory", "io"):
|
||||
w(p / "pressure" / k, "some avg10=2.50 avg60=1.00 avg300=0.00 total=1\n"
|
||||
"full avg10=0.00 avg60=0 avg300=0 total=0\n")
|
||||
w(p / "net" / "dev", "Inter-| Receive\n face |bytes\n lo: 5 0 0 0 0 0 0 0 5 0 0 0 0 0 0 0\n"
|
||||
" wlan0: 1000 0 0 0 0 0 0 0 2000 0 0 0 0 0 0 0\n")
|
||||
s = root / "sys"
|
||||
for i, (cpus, mx, cur) in enumerate((("0", 2265600, 1248000), ("1", 3148800, 1708800))):
|
||||
d = s / "devices/system/cpu/cpufreq" / f"policy{i}"
|
||||
w(d / "related_cpus", cpus + "\n")
|
||||
w(d / "cpuinfo_max_freq", f"{mx}\n")
|
||||
w(d / "scaling_cur_freq", f"{cur}\n")
|
||||
g = s / "class/devfreq/3d00000.gpu"
|
||||
w(g / "name", "3d00000.gpu\n")
|
||||
w(g / "cur_freq", "231000000\n")
|
||||
w(g / "max_freq", "903000000\n")
|
||||
w(s / "class/devfreq/1d84000.ufshc/name", "1d84000.ufshc\n")
|
||||
for i, (kind, mc) in enumerate((("cpu0-thermal", 41000), ("cpu7-top-thermal", 52500), ("gpuss-0-thermal", 47000),
|
||||
("max1720x_bat_7-36", 23200), ("aoss0-thermal", 30000))):
|
||||
w(s / f"class/thermal/thermal_zone{i}/type", kind + "\n")
|
||||
w(s / f"class/thermal/thermal_zone{i}/temp", f"{mc}\n")
|
||||
h = s / "class/hwmon/hwmon51"
|
||||
for i, (label, uw) in enumerate((("apc0", 500000), ("apc1", 250000), ("gfx", 1500000), ("vph", 6000000),
|
||||
("unused", 8), ("bob", 160000)), 1):
|
||||
w(h / f"power{i}_label", label + "\n")
|
||||
w(h / f"power{i}_input", f"{uw}\n")
|
||||
w(s / "class/hwmon/hwmon50/fan1_input", "8200\n")
|
||||
b = root / "power_supply/max1720x_bat_7-36"
|
||||
for k, v in {"type": "Battery", "capacity": "80", "status": "Discharging", "current_now": "-500000",
|
||||
"voltage_now": "8000000", "time_to_empty_now": "7200", "cycle_count": "7", "temp": "233",
|
||||
"health": "Good", "charge_full": "2698000", "charge_full_design": "2730000"}.items():
|
||||
w(b / k, v + "\n")
|
||||
w(root / "power_supply/usb/type", "USB\n")
|
||||
w(root / "power_supply/usb/online", "0\n")
|
||||
cg = s / "fs/cgroup/user.slice/libpod-abc.scope"
|
||||
w(cg / "memory.current", "2000000000\n")
|
||||
w(cg / "cpu.stat", "usage_usec 1000000\n")
|
||||
w(s / "fs/cgroup/user.slice/app.slice/memory.current", "1\n")
|
||||
# a Quest game: launch.sh (Steam's reaper) + the podman container (conmon → Android init → the game)
|
||||
base = home / "Applications/quest-frame" / GAME
|
||||
w(base / "deployment.json", json.dumps({"package": GAME, "appid": APPID, "title": "Example Game",
|
||||
"kind": "quest"}))
|
||||
w(base / "launch.log", "x\n10-07 02:00:14.554 1183 1212 I FrameBridge: pacing: 71.9 fps, displayTime vs "
|
||||
"predicted: avg 0.10 ms, max 0.00 ms\n")
|
||||
proc(root, 1, "systemd", 0)
|
||||
proc(root, 2, "kthreadd", 0)
|
||||
proc(root, 3, "kworker/0:0", 2)
|
||||
proc(root, 100, "steam", 1, cpu=10, fds={5: ("/dev/dri/renderD128", 0)})
|
||||
proc(root, 101, "vrcompositor", 1, fds={7: ("/dev/dri/renderD128", 1000), 9: ("/dev/dri/renderD128", 0)})
|
||||
launcher = str(home / "Applications/quest-frame" / GAME / "launch.sh")
|
||||
proc(root, 200, "launch.sh", 100, cmdline=["/bin/bash", launcher])
|
||||
proc(root, 300, "conmon", 1, cmdline=["/usr/bin/conmon", "-n", f"lepton-steamlaunch-{APPID}"],
|
||||
cgroup="/user.slice/app.slice")
|
||||
proc(root, 301, "init", 300, cgroup="/user.slice/libpod-abc.scope/container")
|
||||
proc(root, 302, "com.example.gam", 301, cpu=50, fds={3: ("/dev/kgsl-3d0", 5000)},
|
||||
cgroup="/user.slice/libpod-abc.scope/container")
|
||||
proc(root, 400, "bash", 1, cmdline=["bash", "-c", "sleep 9"])
|
||||
return base
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fa(monkeypatch, tmp_path):
|
||||
"""The agent with PROC/SYS/POWER_SUPPLY pointing at a fake Frame."""
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
a = load_agent(monkeypatch, home)
|
||||
root = tmp_path / "frame"
|
||||
frame_tree(root, home)
|
||||
monkeypatch.setattr(a, "PROC", str(root / "proc"))
|
||||
monkeypatch.setattr(a, "SYS", str(root / "sys"))
|
||||
monkeypatch.setattr(a, "POWER_SUPPLY", str(root / "power_supply"))
|
||||
a.ROOT = root
|
||||
return a
|
||||
|
||||
|
||||
def monitor(fa):
|
||||
clock = {"t": 100.0}
|
||||
m = fa.Monitor(clock=lambda: clock["t"])
|
||||
return m, clock
|
||||
|
||||
|
||||
def test_static_and_readers(fa):
|
||||
m, _ = monitor(fa)
|
||||
st = m.static()
|
||||
assert st["agent"] == fa.AGENT_VERSION and st["cores"] == 2 and st["gpu_max_mhz"] == 903
|
||||
assert st["clusters"] == [{"cpus": [0], "max_mhz": 2265}, {"cpus": [1], "max_mhz": 3148}]
|
||||
assert st["rails"] == ["apc0", "apc1", "gfx", "vph"] # "unused"/"bob" aren't read (each read is an I2C transfer)
|
||||
assert st["temp_groups"] == ["CPU", "GPU", "Battery"] and st["fan"] is True
|
||||
s = m.sample(wall=2000.0)
|
||||
assert s["temps"] == {"CPU": 52.5, "GPU": 47.0, "Battery": 23.2}
|
||||
assert s["zones"]["CPU"] == {"cpu0": 41.0, "cpu7-top": 52.5}
|
||||
assert s["power"]["system"] == 6.0 and s["power"]["cpu"] == 0.75 and s["power"]["gpu"] == 1.5
|
||||
assert s["fan"] == 8200 and s["cpu"]["mhz"] == [1248, 1708]
|
||||
assert s["mem"] == {"total": 16000000 * 1024, "avail": 8000000 * 1024, "swap_total": 8000000 * 1024,
|
||||
"swap_free": 7000000 * 1024}
|
||||
assert s["psi"] == {"cpu": 2.5, "memory": 2.5, "io": 2.5}
|
||||
b = s["battery"]
|
||||
assert b["percent"] == 80 and b["watts"] == -4.0 and b["draining"] and not b["plugged"]
|
||||
assert b["empty_s"] == 7200 and b["cycles"] == 7 and b["temp"] == 23.3 and b["health"] == "Good"
|
||||
assert s["gpu"] == {"busy": None, "mhz": 231} # no delta yet
|
||||
assert "disk" in s and s["self_ms"] >= 0
|
||||
|
||||
|
||||
def test_cpu_gpu_net_deltas(fa):
|
||||
m, clock = monitor(fa)
|
||||
m.sample(wall=2000.0)
|
||||
p = fa.ROOT / "proc"
|
||||
w(p / "stat", "cpu 200 0 100 900 0 0 0 0 0 0\ncpu0 100 0 50 450 0 0 0 0 0 0\n"
|
||||
"cpu1 100 0 50 450 0 0 0 0 0 0\nbtime 1000\n")
|
||||
w(p / "net" / "dev", "a\nb\n wlan0: 3000 0 0 0 0 0 0 0 2500 0 0 0 0 0 0 0\n")
|
||||
set_gpu(fa.ROOT, 302, 3, 5000 + 250_000_000) # 25 % of 1 s
|
||||
set_gpu(fa.ROOT, 101, 7, 1000 + 100_000_000) # 10 %
|
||||
clock["t"] += 1.0
|
||||
s = m.sample(wall=2001.0)
|
||||
assert s["cpu"]["total"] == 50.0 and s["cpu"]["cores"] == [50.0, 50.0]
|
||||
assert s["net"] == {"wlan0": [2000, 500]}
|
||||
assert s["gpu"]["busy"] == 35.0
|
||||
|
||||
|
||||
def test_processes_games_and_filters(fa):
|
||||
m, clock = monitor(fa)
|
||||
s = m.sample(wall=2000.0)
|
||||
names = {p["pid"]: p for p in m.procs}
|
||||
assert 3 not in names and 2 not in names # kernel threads
|
||||
assert {names[pid]["game"] for pid in (200, 300, 301, 302)} == {GAME}
|
||||
assert names[100]["group"] == "steam" and names[101]["group"] == "steamvr" and names[400]["group"] == "other"
|
||||
assert names[101]["critical"] and names[1]["locked"]
|
||||
# default filter: the game's processes + the busiest others as context
|
||||
shown = s["procs"]
|
||||
assert s["filter"] == "game"
|
||||
assert {p["pid"] for p in shown if p["game"]} == {200, 300, 301, 302}
|
||||
assert all(p.get("context") for p in shown if not p["game"]) and len([p for p in shown if not p["game"]]) == 3
|
||||
m.filter = "steam"
|
||||
assert {p["pid"] for p in m.filtered()} == {100, 101}
|
||||
m.filter = "all"
|
||||
assert len(m.filtered()) == len(m.procs)
|
||||
# game card: fps from the pacing line, memory from the container's cgroup (not conmon's), process count
|
||||
g = s["games"][0]
|
||||
assert g["package"] == GAME and g["title"] == "Example Game" and g["appid"] == APPID
|
||||
assert g["fps"] == 71.9 and g["frame_ms"] == 0.1 and g["mem"] == 2000000000 and g["processes"] == 4
|
||||
# next scan: CPU % per process + the container cgroup's CPU %
|
||||
set_cpu(fa.ROOT, 302, 50 + 100) # 100 jiffies in 1 s = 1 core of 2 → 50 %
|
||||
clock["t"] += 1.0
|
||||
m.sample(wall=2001.0) # odd tick: no process scan (GPU and container CPU % are still per tick)
|
||||
set_gpu(fa.ROOT, 302, 3, 5000 + 250_000_000)
|
||||
w(fa.ROOT / "sys/fs/cgroup/user.slice/libpod-abc.scope/cpu.stat", "usage_usec 1500000\n") # 0.5 s of 2 cores
|
||||
clock["t"] += 1.0
|
||||
s = m.sample(wall=2002.0)
|
||||
game = next(p for p in s["procs"] if p["pid"] == 302)
|
||||
assert game["cpu"] == 25.0 and game["gpu"] == 25.0
|
||||
assert s["games"][0]["cpu"] == 25.0 and s["games"][0]["gpu"] == 25.0
|
||||
|
||||
|
||||
def test_pacing_tail_is_incremental_and_goes_stale(fa):
|
||||
m, clock = monitor(fa)
|
||||
base = str(fa.ROOT.parent / "home/Applications/quest-frame" / GAME)
|
||||
assert m.fps(base, 100.0) == (71.9, 0.1)
|
||||
with open(os.path.join(base, "launch.log"), "a") as f:
|
||||
f.write("I FrameBridge: pacing: 45.0 fps, displayTime vs predicted: avg 2.00 ms, max 0.00 ms\n")
|
||||
assert m.fps(base, 101.0) == (45.0, 2.0)
|
||||
assert m.fps(base, 120.0) == (None, None) # no line for 15 s: loading or ended
|
||||
open(os.path.join(base, "launch.log"), "w").write("I FrameBridge: pacing: 72.0 fps\n") # a new launch
|
||||
assert m.fps(base, 121.0) == (72.0, None)
|
||||
|
||||
|
||||
def test_kill_safety(fa):
|
||||
m, _ = monitor(fa)
|
||||
m.sample(wall=2000.0)
|
||||
with pytest.raises(fa.AgentError, match="system"):
|
||||
m.kill(1)
|
||||
with pytest.raises(fa.AgentError, match="CRITICAL: vrcompositor"):
|
||||
m.kill(101)
|
||||
assert m.kill(999) == {"ended": True, "pid": 999, "note": "already gone"}
|
||||
|
||||
|
||||
def test_kill_other_user_only_inside_a_game(fa, monkeypatch):
|
||||
m = fa.Monitor(uid=12345) # nothing in the fake tree belongs to this user
|
||||
m.sample(wall=2000.0)
|
||||
with pytest.raises(fa.AgentError, match="another user"):
|
||||
m.kill(400)
|
||||
|
||||
|
||||
def test_kill_real_process(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
m = a.Monitor()
|
||||
with pytest.raises(a.AgentError):
|
||||
m.kill(os.getpid()) # its own process tree (the SSH session) is never ended
|
||||
child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"])
|
||||
try:
|
||||
res = m.kill(child.pid, "TERM")
|
||||
assert res["ended"] is True and res["pid"] == child.pid
|
||||
finally:
|
||||
child.kill()
|
||||
child.wait()
|
||||
|
||||
|
||||
def test_end_game_steam_then_stop(fa, monkeypatch):
|
||||
calls, state = [], {"running": True}
|
||||
monkeypatch.setattr(fa, "devkit_gameid", lambda pkg: None)
|
||||
monkeypatch.setattr(fa, "container_running", lambda appid: state["running"])
|
||||
monkeypatch.setattr(fa, "steam_js", lambda expr, timeout=5: calls.append(expr))
|
||||
|
||||
def stop(args):
|
||||
calls.append(("stop", args["package"]))
|
||||
state["running"] = False
|
||||
return {"stopped": True}
|
||||
monkeypatch.setattr(fa, "cmd_stop", stop)
|
||||
res = fa.monitor_end_game(GAME, wait=0.2)
|
||||
assert calls[0] == f"SteamClient.Apps.TerminateApp('{fa.steam_gameid(APPID)}', false)"
|
||||
assert calls[1] == ("stop", GAME) and res == {"ended": True, "via": "stop", "package": GAME}
|
||||
# Steam's Exit game was enough: no podman kill
|
||||
calls.clear()
|
||||
monkeypatch.setattr(fa, "steam_js", lambda expr, timeout=5: state.update(running=False))
|
||||
state["running"] = True
|
||||
assert fa.monitor_end_game(GAME, wait=0.2)["via"] == "steam"
|
||||
with pytest.raises(fa.AgentError, match="bad package"):
|
||||
fa.monitor_end_game("x y")
|
||||
|
||||
|
||||
class FakeMon:
|
||||
def __init__(self):
|
||||
self.filter, self.n = "game", 0
|
||||
|
||||
def static(self):
|
||||
return {"agent": 60}
|
||||
|
||||
def sample(self):
|
||||
self.n += 1
|
||||
return {"n": self.n, "filter": self.filter}
|
||||
|
||||
def kill(self, pid, sig="TERM", force=False):
|
||||
if pid == 1:
|
||||
raise RuntimeError("nope")
|
||||
return {"ended": True, "pid": pid, "sig": sig, "force": force}
|
||||
|
||||
|
||||
def test_session_protocol(fa):
|
||||
r, wfd = os.pipe()
|
||||
stdin, writer = os.fdopen(r), os.fdopen(wfd, "w")
|
||||
out = io.StringIO()
|
||||
mon = FakeMon()
|
||||
t = threading.Thread(target=fa.monitor_session, args=(stdin, out, mon), daemon=True)
|
||||
t.start()
|
||||
|
||||
def lines():
|
||||
return [json.loads(x) for x in out.getvalue().splitlines()]
|
||||
writer.write(json.dumps({"procs": "all"}) + "\n" + json.dumps({"interval": 5}) + "\n"
|
||||
+ json.dumps({"id": 7, "kill": 42, "sig": "KILL", "force": True}) + "\n"
|
||||
+ json.dumps({"id": 8, "kill": 1}) + "\n" + "not json\n")
|
||||
writer.flush()
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline and len([x for x in lines() if "reply" in x]) < 2:
|
||||
time.sleep(0.02)
|
||||
writer.close() # EOF ends the session
|
||||
t.join(5)
|
||||
assert not t.is_alive()
|
||||
got = lines()
|
||||
assert got[0] == {"ready": 1, "static": {"agent": 60}}
|
||||
assert mon.filter == "all"
|
||||
replies = {x["reply"]: x for x in got if "reply" in x}
|
||||
assert replies[7] == {"reply": 7, "ok": True, "result": {"ended": True, "pid": 42, "sig": "KILL", "force": True}}
|
||||
assert replies[8]["ok"] is False and "nope" in replies[8]["error"]
|
||||
assert any(x.get("filter") == "all" for x in got if "n" in x) # a fresh sample after the filter change
|
||||
|
||||
|
||||
def test_monitor_cli_ends_at_eof(tmp_path):
|
||||
"""`_monitor` on the real machine: ready line, a sample, and exit once stdin closes."""
|
||||
p = subprocess.Popen([sys.executable, str(AGENT), "_monitor"], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
text=True, env={"HOME": str(tmp_path), "PATH": "/usr/bin:/bin"})
|
||||
ready = json.loads(p.stdout.readline())
|
||||
sample = json.loads(p.stdout.readline())
|
||||
p.stdin.close()
|
||||
assert p.wait(timeout=10) == 0
|
||||
assert ready["ready"] == 1 and ready["static"]["cores"] >= 1
|
||||
assert "cpu" in sample and "mem" in sample and "procs" in sample
|
||||
Reference in new issue
Block a user