feat: add OpenVR performance HUD and optional VR utilities

This commit is contained in:
saphid committed 2026-09-28 22:41:45 +10:00
1 parent dcf9689f64
commit 3fb541dce7
9 files changed
+535 -5

No files matched your search

+22
View File
@@ -85,3 +85,25 @@ class Device(harness.FrameTestCase):
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()
class VRUtilities(harness.FrameTestCase):
def test_missing_vr_runtime_is_unavailable_not_zero_fps(self):
data = ok('GET', '/api/status')
self.assertIsNone(data['performance']['compositorFps'])
self.assertIsNone(data['performance']['appFps'])
self.assertEqual(data['temp'], 41.5)
self.assertEqual(data['battery']['percent'], 76)
def test_optional_paid_utilities_are_not_installed(self):
# The fake library contains games but none of these paid software titles.
for appid in (1009850, 1173510, 1068820, 908520):
code, body, _ = api('POST', '/api/steam', {'action': 'install', 'appid': appid})
self.assertEqual(code, 502, body)
self.assertIn('not owned', body['error'])
self.assertEqual(launches('install'), [])
def test_unverified_controls_are_not_exposed(self):
for action in ('recenter', 'adjust', 'restore'):
code, body, _ = api('POST', '/api/vr', {'action': action, 'origin': 'seated', 'y': .1})
self.assertEqual(code, 400, body)
@@ -27,7 +27,7 @@ function newShortcutId(steam) {
function build(steam) { function build(steam) {
const findShortcut = id => steam.shortcuts.find(s => s.appid === Number(id)); const findShortcut = id => steam.shortcuts.find(s => s.appid === Number(id));
const gameOverview = a => ({ const gameOverview = a => ({
appid: a.appid, display_name: a.display_name, sort_as: a.display_name, app_type: 1, appid: a.appid, display_name: a.display_name, sort_as: a.display_name, app_type: a.app_type ?? 1,
steam_hw_compat_category_packed: a.packed || 0, vr_supported: !!a.vr, vr_only: !!a.vr_only, steam_hw_compat_category_packed: a.packed || 0, vr_supported: !!a.vr, vr_only: !!a.vr_only,
size_on_disk: String(a.installed ? a.size : 0), minutes_playtime_forever: a.minutes || 0, size_on_disk: String(a.installed ? a.size : 0), minutes_playtime_forever: a.minutes || 0,
rt_last_time_played: a.last_played || 0, rt_last_time_played: a.last_played || 0,
+152
View File
@@ -0,0 +1,152 @@
"""Own VR telemetry, optional catalogue and entitlement guards; no headset needed."""
import ctypes
import json
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
import frame_status as status
import frame_steam as steam
import frame_utilities as utilities
import frame_vr as vr
import server
class Telemetry(unittest.TestCase):
def test_background_client_does_not_request_steamvr_start(self):
with mock.patch.object(status.C, 'CDLL') as load:
with status.OpenVR():
pass
self.assertEqual(load.return_value.VR_InitInternal2.call_args.args[1], 3)
load.return_value.VR_ShutdownInternal.assert_called_once()
def test_linux_arm64_timing_layout(self):
self.assertEqual(ctypes.sizeof(status.Timing), 192)
self.assertEqual(status.Timing.pose.offset, 96)
def test_cpu_excludes_guest_and_counts_iowait_idle(self):
with mock.patch.object(status, 'read', return_value='cpu 20 0 10 50 20 0 0 0 7 1\n'):
self.assertEqual(status.cpu_ticks(), (100, 70))
self.assertEqual(status.cpu_percent((100, 70), (200, 110)), 60)
self.assertIsNone(status.cpu_percent((100, 70), (100, 70)))
self.assertIsNone(status.cpu_percent(None, (100, 70)))
def test_compositor_and_app_fps_are_distinct(self):
a, b = status.Timing(), status.Timing()
a.index, a.time = 100, 10
b.index, b.time, b.interval = 118, 10.2, 22.2222
b.gpu, b.compositorCpu = 4, 1
result = status.timing_values(a, b)
self.assertEqual(result['compositorFps'], 90)
self.assertEqual(result['appFps'], 45)
self.assertEqual(result['frameMs'], 11.11)
b.interval = 0
self.assertIsNone(status.timing_values(a, b)['appFps'])
b.index = a.index
self.assertEqual(status.timing_values(a, b), {})
b.index = 5 # compositor restart / wrap
self.assertEqual(status.timing_values(a, b), {})
def test_missing_openvr_keeps_sensor_values(self):
with mock.patch.object(status, 'OpenVR', side_effect=OSError('missing')), \
mock.patch.object(status, 'num', return_value=629), \
mock.patch.object(status, 'cpu_ticks', side_effect=[(100, 50), (200, 100)]), \
mock.patch.object(status.time, 'sleep'):
data = status.performance()
self.assertEqual(data['gpuMHz'], 629)
self.assertEqual(data['cpuPercent'], 50)
self.assertIsNone(data['compositorFps'])
self.assertIsNone(data['appFps'])
def test_import_has_no_device_side_effect(self):
out = subprocess.run([sys.executable, '-c', 'import frame_status'],
cwd=Path(__file__).resolve().parents[1] / 'ui', capture_output=True, text=True)
self.assertEqual(out.returncode, 0, out.stderr)
self.assertEqual(out.stdout, '')
class UtilityGuards(unittest.TestCase):
def test_all_paid_utilities_refused_before_steam_url(self):
with mock.patch.object(steam, 'Page') as page, mock.patch.object(steam, 'steam_url') as url:
page.return_value.eval.return_value = [{'id': i, 'owned': False} for i in steam.UTILITY_IDS]
for appid in set(steam.UTILITY_IDS) - set(steam.FREE_UTILITIES):
with self.assertRaisesRegex(steam.Fail, 'not owned'):
steam.install(appid)
url.assert_not_called()
def test_unknown_ownership_fails_closed(self):
with mock.patch.object(steam, 'Page') as page, mock.patch.object(steam, 'steam_url') as url:
page.return_value.eval.side_effect = steam.Fail('library unavailable')
with self.assertRaises(steam.Fail):
steam.install(908520)
url.assert_not_called()
def test_owned_software_uses_existing_install_flow(self):
with mock.patch.object(steam, 'Page') as page, mock.patch.object(steam, 'steam_url') as url:
page.return_value.eval.side_effect = [[{'id': 908520, 'owned': True}], {'name': 'fpsVR', 'installed': True}]
self.assertEqual(steam.install(908520)['state'], 'installed')
url.assert_not_called()
def test_catalogue_separates_public_claims_ownership_and_reports(self):
data = utilities.catalogue([{'id': 1173510, 'owned': True}], [
{'package': 'com.android.app', 'rating': 'works', 'date': '2026-09-28'},
{'package': 'steam:908520', 'rating': 'broken', 'date': '2026-09-28', 'notes': 'test report'}])
rows = {r['id']: r for r in data['utilities']}
self.assertEqual(rows[1173510]['status'], 'untested')
self.assertTrue(rows[1173510]['canInstall'])
self.assertFalse(rows[908520]['canInstall'])
self.assertEqual(rows[908520]['status'], 'broken')
self.assertTrue(rows[1494460]['canInstall'])
self.assertFalse(rows[1009850]['free'])
@unittest.skipUnless(shutil.which('node'), 'Node needed to evaluate Steam JavaScript')
def test_ownership_js_includes_software_and_rejects_empty_library(self):
script = 'let appStore = {allApps: [{appid:908520,app_type:2,local_per_client_data:{installed:true}}]};\n'
script += 'console.log(JSON.stringify(' + steam.UTILITIES_JS + '));'
out = subprocess.run(['node', '-e', script], capture_output=True, text=True, check=True)
rows = {r['id']: r for r in json.loads(out.stdout)}
self.assertTrue(rows[908520]['owned'])
self.assertTrue(rows[908520]['installed'])
self.assertFalse(rows[1173510]['owned'])
out = subprocess.run(['node', '-e', 'let appStore={allApps:[]};' + steam.UTILITIES_JS], capture_output=True)
self.assertNotEqual(out.returncode, 0)
class HUD(unittest.TestCase):
def test_invalid_action_never_reaches_frame(self):
with mock.patch.object(server, 'ssh') as ssh:
for action in ('hud-start; reboot', 'adjust', 'recenter', 'restore'):
with self.assertRaises(server.Failure) as err:
server.vr({'action': action})
self.assertEqual(err.exception.status, 400)
ssh.assert_not_called()
def test_stop_wont_kill_reused_pid(self):
with tempfile.TemporaryDirectory() as tmp, mock.patch.object(vr, 'ROOT', Path(tmp)), \
mock.patch.object(vr, 'process_identity', return_value='different'), mock.patch.object(vr.os, 'kill') as kill:
(Path(tmp) / 'hud.json').write_text(json.dumps({'pid': 1234, 'start': 'old'}))
vr.panel('hud-stop')
kill.assert_not_called()
self.assertFalse((Path(tmp) / 'hud.json').exists())
def test_duplicate_start_does_not_spawn(self):
with tempfile.TemporaryDirectory() as tmp, mock.patch.object(vr, 'ROOT', Path(tmp)), \
mock.patch.object(vr, 'process_identity', return_value='same'), mock.patch.object(vr.subprocess, 'Popen') as popen:
(Path(tmp) / 'hud.json').write_text(json.dumps({'pid': 1234, 'start': 'same'}))
self.assertIn('already open', vr.panel('hud-start')['message'])
popen.assert_not_called()
def test_start_failure_cleans_up_process(self):
with tempfile.TemporaryDirectory() as tmp, mock.patch.object(vr, 'ROOT', Path(tmp)), \
mock.patch.object(vr.subprocess, 'Popen') as popen, \
mock.patch.object(vr.subprocess, 'check_output', side_effect=OSError('no display')):
popen.return_value.poll.return_value = None
with self.assertRaises(OSError):
vr.panel('hud-start')
popen.return_value.terminate.assert_called_once()
popen.return_value.wait.assert_called_once()
+104 -3
View File
@@ -1,8 +1,11 @@
"""Runs ON the Steam Frame (piped over SSH as `python3 -`); prints one JSON object. """Runs ON the Steam Frame (piped over SSH as `python3 -`); prints one JSON object.
Read-only. Every probe is best effort: a missing tool or file gives null, not an Read-only. Every probe is best effort: a missing tool or file gives null, not an
error. Paths verified on SteamOS 0.3.0 (vr), build 20260922. error. Sensor paths verified on SteamOS 0.3.0; OpenVR timing on 0.4.1
(build 20260925.6191901). See docs/vr-utilities.md.
""" """
import ctypes as C
import math
import glob import glob
import json import json
import os import os
@@ -156,10 +159,104 @@ def flatpaks():
return out return out
# OpenVR C ABI, ValveSoftware/openvr headers/openvr_capi.h (IVRCompositor_029).
# Exact interface version: never guess an index against a different table.
class Pose(C.Structure):
_fields_ = [('matrix', C.c_float * 12), ('velocity', C.c_float * 3),
('angular', C.c_float * 3), ('result', C.c_int),
('valid', C.c_bool), ('connected', C.c_bool)]
class Timing(C.Structure):
_fields_ = [(n, C.c_uint32) for n in ('size', 'index', 'presents', 'mis', 'dropped', 'flags')] + [
('time', C.c_double)] + [(n, C.c_float) for n in (
'gpuPre', 'gpuPost', 'gpu', 'compositorGpu', 'compositorCpu', 'idleCpu', 'interval',
'presentCpu', 'waitCpu', 'submit', 'posesCalled', 'posesReady', 'frameReady',
'updateStart', 'updateEnd', 'renderStart')] + [
('pose', Pose), ('ready', C.c_uint32), ('first', C.c_uint32), ('transfer', C.c_float)]
class OpenVR:
def __enter__(self):
self.lib = C.CDLL('/opt/steamvr/bin/linuxarm64/libopenvr_api.so')
self.lib.VR_InitInternal2.argtypes = [C.POINTER(C.c_int), C.c_int, C.c_char_p]
self.lib.VR_GetGenericInterface.argtypes = [C.c_char_p, C.POINTER(C.c_int)]
self.lib.VR_GetGenericInterface.restype = C.c_void_p
err = C.c_int()
self.lib.VR_InitInternal2(C.byref(err), 3, None) # background: never start or keep SteamVR running
if err.value:
raise RuntimeError(f'SteamVR unavailable (init {err.value})')
return self
def __exit__(self, *args):
self.lib.VR_ShutdownInternal()
def function(self, interface, index, result, *args):
err = C.c_int()
ptr = self.lib.VR_GetGenericInterface(('FnTable:' + interface).encode(), C.byref(err))
if err.value or not ptr:
raise RuntimeError(f'{interface} unavailable ({err.value})')
return C.CFUNCTYPE(result, *args)(C.cast(ptr, C.POINTER(C.c_void_p))[index])
def cpu_ticks():
line = (read('/proc/stat') or '').splitlines()
if not line or not line[0].startswith('cpu '):
return None
try:
# guest/guest_nice are already included in user/nice.
ticks = [int(x) for x in line[0].split()[1:9]]
return sum(ticks), ticks[3] + ticks[4]
except (ValueError, IndexError):
return None
def cpu_percent(before, after):
if before is None or after is None or after[0] <= before[0]:
return None
return round(max(0, min(100, 100 * (1 - (after[1] - before[1]) / (after[0] - before[0])))), 1)
def positive(value):
return round(value, 2) if math.isfinite(value) and value > 0 else None
def timing_values(before, after):
dt, frames = after.time - before.time, after.index - before.index
if dt <= 0 or frames <= 0 or frames / dt > 1000:
return {} # standby or old data; never present stale timings as live
return {'compositorFps': positive(frames / dt),
'frameMs': positive(1000 * dt / frames),
'appFps': positive(1000 / after.interval) if after.interval > 0 else None,
'gpuMs': positive(after.gpu), 'compositorCpuMs': positive(after.compositorCpu)}
def performance():
out = {'compositorFps': None, 'frameMs': None, 'appFps': None,
'gpuMs': None, 'compositorCpuMs': None, 'cpuPercent': None,
'gpuMHz': num('/sys/class/devfreq/3d00000.gpu/cur_freq', 1e-6)}
before = cpu_ticks()
try:
with OpenVR() as vr:
get = vr.function('IVRCompositor_029', 10, C.c_bool, C.POINTER(Timing), C.c_uint32)
a, b = Timing(), Timing()
a.size = b.size = C.sizeof(Timing)
first = get(C.byref(a), 0)
time.sleep(0.2)
if first and get(C.byref(b), 0):
out.update(timing_values(a, b))
except (OSError, RuntimeError):
time.sleep(0.2)
out['cpuPercent'] = cpu_percent(before, cpu_ticks())
return out
def status():
uptime = read("/proc/uptime") uptime = read("/proc/uptime")
procs = process_names() procs = process_names()
print(json.dumps({ return {
"time": time.time(), "time": time.time(),
"performance": performance(),
"hostname": socket.gethostname(), "hostname": socket.gethostname(),
"os": os_release(), "os": os_release(),
"uptime": float(uptime.split()[0]) if uptime else None, "uptime": float(uptime.split()[0]) if uptime else None,
@@ -179,4 +276,8 @@ print(json.dumps({
}, },
"games": games(), "games": games(),
"flatpaks": flatpaks(), "flatpaks": flatpaks(),
})) }
if __name__ == "__main__":
print(json.dumps(status()))
+21
View File
@@ -141,6 +141,21 @@ OWNED_JS = r"""
})() })()
""" """
# Software has app_type 2, so utility ownership must not use OWNED_JS's games filter.
# Steam prices checked 2026-09-28: OVR Advanced Settings is paid on Steam too.
UTILITY_IDS = (1009850, 1173510, 1068820, 908520, 1494460)
FREE_UTILITIES = (1494460,)
UTILITIES_JS = """(() => {
const apps = appStore.allApps;
if (!apps || !apps.length) throw new Error("Steam library is not loaded; ownership is unknown");
return [1009850,1173510,1068820,908520,1494460].map(id => {
const a = apps.find(a => a.appid === id);
return {id, owned: !!a, installed: !!a?.local_per_client_data?.installed,
frame: a ? (a.steam_hw_compat_category_packed >> 8) & 3 : 0};
});
})()"""
WIZARD_JS = """SteamClient.Installs.GetInstallManagerInfo().then(i => ({ WIZARD_JS = """SteamClient.Installs.GetInstallManagerInfo().then(i => ({
state: i?.eInstallState ?? 0, app: i?.currentAppID ?? 0, need: i?.nDiskSpaceRequired || 0, state: i?.eInstallState ?? 0, app: i?.currentAppID ?? 0, need: i?.nDiskSpaceRequired || 0,
free: i?.nDiskSpaceAvailable || 0, error: i?.eAppError, detail: i?.errorDetail }))""" free: i?.nDiskSpaceAvailable || 0, error: i?.eAppError, detail: i?.errorDetail }))"""
@@ -158,6 +173,10 @@ def owned():
def install(appid): def install(appid):
page = Page() page = Page()
if appid in UTILITY_IDS and appid not in FREE_UTILITIES:
rows = page.eval(UTILITIES_JS)
if not any(r['id'] == appid and r['owned'] for r in rows):
raise Fail("This paid utility is not owned by the Frame account. No install or store action was taken.")
app = page.eval(f"(a => a && {{name: a.display_name, installed: !!a.local_per_client_data?.installed}})" app = page.eval(f"(a => a && {{name: a.display_name, installed: !!a.local_per_client_data?.installed}})"
f"(appStore.GetAppOverviewByAppID({appid}))") f"(appStore.GetAppOverviewByAppID({appid}))")
if app and app["installed"]: if app and app["installed"]:
@@ -216,6 +235,8 @@ def main():
cmd = sys.argv[1] if len(sys.argv) > 1 else "" cmd = sys.argv[1] if len(sys.argv) > 1 else ""
if cmd == "owned": if cmd == "owned":
out = owned() out = owned()
elif cmd == "utilities":
out = {"utilities": Page().eval(UTILITIES_JS)}
elif cmd in ("install", "store") and len(sys.argv) == 3 and sys.argv[2].isdigit(): elif cmd in ("install", "store") and len(sys.argv) == 3 and sys.argv[2].isdigit():
out = (install if cmd == "install" else store)(int(sys.argv[2])) out = (install if cmd == "install" else store)(int(sys.argv[2]))
else: else:
+29
View File
@@ -0,0 +1,29 @@
"""Optional software, separate from Frame Control's own controls and HUD.
Public reports are attributed leads, never local verification. Compatibility
reports reuse the existing database with steam:<appid> package keys.
"""
REPORT = 'https://www.roadtovr.com/valve-steam-frame-review/'
UTILITIES = (
(1009850, 'OVR Advanced Settings', False, 'No verified Frame result. Steam edition is paid; the developer also publishes free source/releases.', 'https://github.com/OpenVR-Advanced-Settings/OpenVR-AdvancedSettings'),
(1173510, 'XSOverlay', False, 'Supplied research reports Proton support, but the linked review did not corroborate it on recheck. Untested.', REPORT),
(1068820, 'OVR Toolkit', False, 'Supplied research reports Proton support, but the linked review did not corroborate it on recheck. Untested.', REPORT),
(908520, 'fpsVR', False, 'No Frame-specific result established in the supplied public research. Untested here.', 'https://store.steampowered.com/app/908520/'),
(1494460, 'Desktop+', True, 'Free, developer-published software. No verified Frame result.', 'https://github.com/elvissteinjr/DesktopPlus'),
)
def catalogue(ownership, reports):
owned = {r['id']: r for r in ownership}
out = []
for appid, name, free, note, source in UTILITIES:
local = owned.get(appid, {})
matches = [r for r in reports if r.get('package') == f'steam:{appid}' and r.get('rating') in ('works', 'issues', 'broken')]
latest = max(matches, key=lambda r: r.get('date') or '') if matches else None
out.append({'id': appid, 'name': name, 'free': free, 'owned': local.get('owned'),
'installed': local.get('installed', False), 'frame': local.get('frame', 0),
'status': latest['rating'] if latest else 'untested',
'report': {k: latest.get(k) for k in ('notes', 'date', 'steamos', 'source')} if latest else None,
'note': note, 'source': source,
'canInstall': bool(free or local.get('owned'))})
return {'utilities': out}
+126
View File
@@ -0,0 +1,126 @@
"""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)
+53
View File
@@ -471,6 +471,25 @@
</section> </section>
</div> </div>
<section class="panel" id="vrTools">
<div class="shelf-head"><h2>VR comfort and performance</h2><span class="spacer"></span><span class="sub" id="vrUpdated">Waiting for the Frame</span></div>
<div class="stats" id="vrMetrics"></div>
<div class="hint">Compositor FPS measures SteamVR output, not game FPS. Application FPS is unavailable when no app supplies timing. GPU time is render time, not GPU utilisation. Temperature is the hottest readable sensor.</div>
<div class="chips">
<button class="small" id="hudStart">Open HUD in headset</button>
<button class="small" id="hudStop">Close HUD</button>
</div>
<div class="hint">The HUD is ours and uses the Frame’s built-in tools. Place its panel with SteamVR’s Float in World or controller docking controls. It updates every two seconds until closed.</div>
<h3>Playspace and seated comfort</h3>
<p class="sub" id="comfortNote">Tracking-origin controls are not available yet: reversible changes still need an idle-headset test. Use SteamVR’s recenter and room setup for now. A seated origin only affects apps that use seated tracking; it cannot force a game into seated mode.</p>
<div class="hint">For motion comfort, choose snap-turn, teleport movement and a movement vignette in each game’s settings. SteamVR has no verified universal snap-turn or locomotion-vignette switch.</div>
<details style="margin-top:16px"><summary>Optional VR software</summary>
<p class="sub">None of Frame Control’s features needs these apps. Compatibility reports and account ownership are separate. Paid apps can only be installed here when already owned.</p>
<button class="small" id="vrUtilitiesRefresh">Check ownership and reports</button>
<div class="list" id="vrUtilities"><div class="sub">Load this optional list to check the Frame account.</div></div>
</details>
</section>
<section id="shots"> <section id="shots">
<div class="shelf-head"><h2>Screenshots</h2><span class="count" id="shotCount"></span><span class="spacer"></span> <div class="shelf-head"><h2>Screenshots</h2><span class="count" id="shotCount"></span><span class="spacer"></span>
<button class="small" id="shotsRefresh">Refresh</button> <button class="small" id="shotsRefresh">Refresh</button>
@@ -887,6 +906,7 @@ function refresh() {
setConn(true, state.hostname); setConn(true, state.hostname);
setOnline(true); setOnline(true);
} catch (e) { } catch (e) {
renderPerformance(null);
if (!e.offline) { setConn(false, "Error"); log("Status failed: " + e.message, "e"); } if (!e.offline) { setConn(false, "Error"); log("Status failed: " + e.message, "e"); }
if (!state) ["games", "flatpaks"].forEach(id => failed($(id), e)); if (!state) ["games", "flatpaks"].forEach(id => failed($(id), e));
} finally { } finally {
@@ -897,6 +917,38 @@ function refresh() {
return refreshing; return refreshing;
} }
// ---- our VR HUD and optional third-party catalogue ----
function renderPerformance(s) {
const p = s?.performance || {};
const values = [["Compositor FPS", p.compositorFps, ""], ["Compositor period", p.frameMs, " ms"],
["Application FPS", p.appFps, ""], ["Render GPU time", p.gpuMs, " ms"],
["Compositor CPU", p.compositorCpuMs, " ms"], ["System CPU", p.cpuPercent, "%"],
["GPU clock", p.gpuMHz, " MHz"], ["Hottest sensor", s?.temp, "°C"], ["Battery", s?.battery?.percent, "%"]];
$("vrMetrics").innerHTML = values.map(([label, v, unit]) => `<div class="stat"><div class="k">${label}</div><div class="v">${Number.isFinite(v) ? v.toFixed(1) + unit : "Unavailable"}</div></div>`).join("");
$("vrUpdated").textContent = s ? `Sampled ${new Date(s.time * 1000).toLocaleTimeString()} · refreshes with status` : "No live sample";
}
$("hudStart").onclick = () => act("Open HUD", () => api("/api/vr", {action: "hud-start"}), $("hudStart"));
$("hudStop").onclick = () => act("Close HUD", () => api("/api/vr", {action: "hud-stop"}), $("hudStop"));
async function loadUtilities() {
$("vrUtilitiesRefresh").disabled = true;
try {
const data = await api("/api/vr/utilities");
$("vrUtilities").innerHTML = data.utilities.map(u => `<div class="item">
<div class="grow"><strong>${esc(u.name)}</strong> · ${esc(u.status)} · ${u.free ? "Free" : "Paid"} · ${u.owned == null ? "Ownership unknown" : u.owned ? "Owned" : "Not in library"}
<div class="sub">${esc(u.note)} <a href="${esc(u.source)}" target="_blank" rel="noopener">Source</a></div>
${u.report ? `<div class="sub">Report ${esc(u.report.date || "")}: ${esc(u.report.notes || "")} · SteamOS ${esc(u.report.steamos || "unspecified")}</div>` : ""}</div>
<button class="small" data-utility="${u.id}" ${u.installed || !u.canInstall ? "disabled" : ""}>${u.installed ? "Installed" : "Install (optional)"}</button></div>`).join("");
} catch(e) { failed($("vrUtilities"), e); }
finally { $("vrUtilitiesRefresh").disabled = false; }
}
$("vrUtilitiesRefresh").onclick = loadUtilities;
$("vrUtilities").onclick = async e => {
const b = e.target.closest("[data-utility]");
if (!b) return;
await act("Install optional utility", () => api("/api/steam", {action:"install", appid:b.dataset.utility}), b);
await loadUtilities();
};
// ---- battery ---- // ---- battery ----
function battery(b, power) { function battery(b, power) {
const pct = b?.percent; const pct = b?.percent;
@@ -941,6 +993,7 @@ function battery(b, power) {
} }
function render(s) { function render(s) {
renderPerformance(s);
battery(s.battery, s.power); battery(s.battery, s.power);
$("devName").textContent = `${s.hostname} · ${s.ip ?? "no IP"}`; $("devName").textContent = `${s.hostname} · ${s.ip ?? "no IP"}`;
$("updated").textContent = new Date(s.time * 1000).toLocaleTimeString(); $("updated").textContent = new Date(s.time * 1000).toLocaleTimeString();
+27 -1
View File
@@ -43,6 +43,9 @@ import frame_host # noqa: E402
import frame_store # noqa: E402 import frame_store # noqa: E402
import frame_titles # noqa: E402 import frame_titles # noqa: E402
import frame_webinstall # noqa: E402 import frame_webinstall # noqa: E402
import frame_vr # noqa: E402
import frame_utilities # noqa: E402
import frame_compat_db # noqa: E402
frame_host.trust_bundled_cas() frame_host.trust_bundled_cas()
@@ -434,6 +437,27 @@ def steam(body):
return steam_frame(action, appid) return steam_frame(action, appid)
def vr(body):
try:
action = frame_vr.validate(body)
except ValueError as e:
raise Failure(str(e), 400)
sources = {name: (HERE / (name + '.py')).read_text() for name in ('frame_status', 'frame_vr')}
script = "import sys, types, json, os\n"
for name, source in sources.items():
script += f"m = types.ModuleType({name!r}); sys.modules[{name!r}] = m\nexec({source!r}, m.__dict__)\n"
# Only the optional HUD needs files: its terminal child survives this SSH call.
if action == 'hud-start':
script += "m.ROOT.mkdir(parents=True, exist_ok=True, mode=0o700)\n"
for name, source in sources.items():
script += f"p = m.ROOT / {name + '.py'!r}; t = p.with_suffix('.' + str(os.getpid()) + '.tmp')\nt.write_text({source!r}); os.replace(t, p)\n"
script += f"try:\n print(json.dumps(m.dispatch({body!r})))\nexcept Exception as e:\n print(json.dumps({{'error': str(e)}}))\n"
result = json.loads(ssh('python3 -', stdin=script, timeout=20))
if result.get('error'):
raise Failure(result['error'])
return result
def steam_search(query): def steam_search(query):
q = parse_qs(query) q = parse_qs(query)
cc = (q.get("cc") or [""])[0].upper() cc = (q.get("cc") or [""])[0].upper()
@@ -1227,7 +1251,7 @@ def _sweep_one(prefix, d):
pass pass
POST = {"/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard, POST = {"/api/vr": vr, "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots, "/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start, "/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
"/api/webinstall/cancel": webinstall_cancel} "/api/webinstall/cancel": webinstall_cancel}
@@ -1354,6 +1378,8 @@ class Handler(BaseHTTPRequestHandler):
"shared": frame_catalog.compat_db.shared()}) "shared": frame_catalog.compat_db.shared()})
elif path == "/api/android/catalog": elif path == "/api/android/catalog":
self.send_json({"apps": frame_catalog.catalog()}) self.send_json({"apps": frame_catalog.catalog()})
elif path == "/api/vr/utilities":
self.send_json(frame_utilities.catalogue(steam_frame("utilities")["utilities"], frame_compat_db.load()))
elif path == "/api/status": elif path == "/api/status":
self.send_json(status({})) self.send_json(status({}))
elif path == "/api/steam/owned": elif path == "/api/steam/owned":