mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 00:00:21 +02:00
127 lines
5.2 KiB
Python
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)
|