Files

127 lines
5.2 KiB
Python

"""Frame Control's optional performance HUD, using Frame platform tools only.
Controls remain blocked pending idle-headset verification; no playspace writes
are exposed. See docs/vr-utilities.md for the probe evidence and follow-up.
"""
import json
import os
from pathlib import Path
import re
import signal
import subprocess
import sys
import time
from frame_status import battery, max_temp, performance
ROOT = Path.home() / '.local/share/frame-control/vr'
APPID = '2000250025'
def validate(body):
action = body.get('action')
if action not in ('hud-start', 'hud-stop'):
raise ValueError('VR action must be hud-start or hud-stop; playspace controls are not yet verified')
return action
def save(path, data):
tmp = path.with_suffix('.tmp')
tmp.write_text(json.dumps(data))
os.replace(tmp, path)
def process_identity(pid):
try:
stat = Path(f'/proc/{pid}/stat').read_text().rsplit(')', 1)[1].split()
args = Path(f'/proc/{pid}/cmdline').read_bytes().split(b'\0')
if b'frame-control-hud' in args and b'xterm' in Path(f'/proc/{pid}/comm').read_bytes():
return stat[19] # field 22, starttime; protects against PID reuse
except OSError:
pass
return None
def panel(action):
path = ROOT / 'hud.json'
saved = json.loads(path.read_text()) if path.exists() else {}
pid = saved.get('pid')
running = bool(pid and saved.get('start') and process_identity(pid) == saved['start'])
if action == 'hud-stop':
if running:
os.kill(pid, signal.SIGTERM) # xterm closes the PTY; child exits on HUP
path.unlink(missing_ok=True)
return {'message': 'HUD closed.'}
if running:
return {'message': 'HUD is already open. Find Frame Control HUD in the SteamVR dashboard.'}
env = {**os.environ, 'DISPLAY': ':0'}
p = subprocess.Popen(['xterm', '-name', 'frame-control-hud', '-title', 'Frame Control HUD',
'-fa', 'Monospace', '-fs', '20', '-geometry', '56x16',
'-bg', '#171d25', '-fg', '#d6d7d8', '-e',
sys.executable, str(ROOT / 'frame_vr.py'), 'hud'],
env=env, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True)
try:
deadline = time.monotonic() + 8
while time.monotonic() < deadline and p.poll() is None:
tree = subprocess.check_output(['xwininfo', '-display', ':0', '-root', '-tree'],
text=True, timeout=2)
for w in re.findall(r'(0x[0-9a-f]+).*"frame-control-hud"', tree):
owner = subprocess.check_output(['xprop', '-display', ':0', '-id', w, '_NET_WM_PID'],
text=True, timeout=2)
if owner.strip().endswith('= ' + str(p.pid)):
subprocess.run(['xprop', '-display', ':0', '-id', w, '-f', 'STEAM_GAME', '32c',
'-set', 'STEAM_GAME', APPID], check=True, timeout=2)
identity = process_identity(p.pid)
if not identity:
raise RuntimeError('HUD process exited before its panel was ready')
save(path, {'pid': p.pid, 'start': identity})
return {'message': 'HUD opened. In SteamVR, select Frame Control HUD and Float in World or dock it to a controller.'}
time.sleep(.1)
raise RuntimeError('HUD did not create a window; is gamescope running?')
except BaseException:
if p.poll() is None:
p.terminate()
p.wait(timeout=3)
raise
def hud():
def fmt(v, unit=''):
return 'unavailable' if v is None else f'{v:.1f}{unit}'
while True:
p, b = performance(), battery() or {}
lines = ['FRAME CONTROL HUD', '',
'Compositor FPS ' + fmt(p['compositorFps']),
'Compositor period ' + fmt(p['frameMs'], ' ms'),
'Application FPS ' + fmt(p['appFps']),
'Render GPU time ' + fmt(p['gpuMs'], ' ms'),
'Compositor CPU ' + fmt(p['compositorCpuMs'], ' ms'),
'System CPU ' + fmt(p['cpuPercent'], '%'),
'GPU clock ' + fmt(p['gpuMHz'], ' MHz'),
'Hottest sensor ' + fmt(max_temp(), ' C'),
'Battery ' + fmt(b.get('percent'), '%'), '',
time.strftime('Updated %H:%M:%S'), 'Close this window to stop.']
print('\033[2J\033[H' + '\n'.join(lines), flush=True)
time.sleep(2)
def dispatch(body):
import fcntl # Frame only; validation is also imported by Windows hosts
action = validate(body)
ROOT.mkdir(parents=True, exist_ok=True, mode=0o700)
with (ROOT / 'control.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
return panel(action)
if __name__ == '__main__':
if sys.argv[1:] == ['hud']:
hud()
else:
try:
print(json.dumps(dispatch(json.load(sys.stdin))))
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as e:
print(json.dumps({'error': str(e)}))
sys.exit(1)