mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 02:00:19 +02:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f280ba59a | ||
|
|
83fa5c6c5b | ||
|
|
08c306c3a6 | ||
|
|
6a63a5a597 | ||
|
|
3fb541dce7 |
No files matched your search
@@ -99,7 +99,9 @@ Frame Control worker into your headset user account.
|
||||
|
||||
For the other features, nothing is installed on the Frame: the app uses what SteamOS
|
||||
already ships (sideloading a game copies Valve's own devkit scripts to
|
||||
`~/devkit-utils`, as Valve's Devkit Client does). [How each feature works](docs/frame-control.md).
|
||||
`~/devkit-utils`, as Valve's Devkit Client does). The optional
|
||||
[performance HUD](docs/vr-utilities.md) copies our own Python helpers into
|
||||
`~/.local/share/frame-control/vr/`. [How each feature works](docs/frame-control.md).
|
||||
|
||||
## Install
|
||||
|
||||
@@ -213,7 +215,7 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
| [Android apps (Lepton)](docs/apks.md) | Sideloading, the rated F-Droid catalogue, per-app instances |
|
||||
| [Sideloading Linux and Windows games](docs/sideloading.md) | A .zip, folder or .exe as a Steam Devkit Game, runtime detection |
|
||||
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
|
||||
| [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
|
||||
| [VR comfort and HUD](docs/vr-utilities.md) · [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
|
||||
| [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input |
|
||||
| [VR mods and custom songs](docs/mods.md) | Per-game feasibility, real-Frame results and blockers; no installer yet |
|
||||
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"date": "2026-09-28",
|
||||
"os": {
|
||||
"version": "0.4.1",
|
||||
"build": "20260925.6191901",
|
||||
"variant": "vr"
|
||||
},
|
||||
"performance": {
|
||||
"compositorFps": 72.0,
|
||||
"frameMs": 13.89,
|
||||
"appFps": null,
|
||||
"gpuMs": 3.07,
|
||||
"compositorCpuMs": 0.61,
|
||||
"cpuPercent": 40.6,
|
||||
"gpuMHz": 903.0
|
||||
},
|
||||
"batteryPercent": 16,
|
||||
"maxTempC": 73.5,
|
||||
"ownership": [
|
||||
{
|
||||
"id": 1009850,
|
||||
"owned": false,
|
||||
"installed": false,
|
||||
"frame": 0
|
||||
},
|
||||
{
|
||||
"id": 1173510,
|
||||
"owned": false,
|
||||
"installed": false,
|
||||
"frame": 0
|
||||
},
|
||||
{
|
||||
"id": 1068820,
|
||||
"owned": false,
|
||||
"installed": false,
|
||||
"frame": 0
|
||||
},
|
||||
{
|
||||
"id": 908520,
|
||||
"owned": false,
|
||||
"installed": false,
|
||||
"frame": 0
|
||||
},
|
||||
{
|
||||
"id": 1494460,
|
||||
"owned": false,
|
||||
"installed": false,
|
||||
"frame": 0
|
||||
}
|
||||
],
|
||||
"hudLifecycle": "open, duplicate-open, close passed before control-test pause; overlay probe removal verified",
|
||||
"comfort": "Initial 1cm seated probe restored exactly. Later commits/readback disagreed while Frame in use; Alex paused control tests. No controls shipped."
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 269 KiB |
@@ -0,0 +1,58 @@
|
||||
# Independent review attempts — 2026-09-28
|
||||
|
||||
Target: the implementation and evidence in
|
||||
[3fb541d](https://github.com/saphid/frame-control/commit/3fb541d) and
|
||||
[6a63a5a](https://github.com/saphid/frame-control/commit/6a63a5a), supplied as a
|
||||
frozen diff before those commits were made. No executable code changed after
|
||||
the final review snapshot.
|
||||
|
||||
Requested model: **SWE-2 Max**, explicitly selected with `--model swe-2-max`.
|
||||
No completed verdict or model self-identification was returned. This is not a
|
||||
passed review, and there is no "no actionable findings" claim.
|
||||
|
||||
## First attempt
|
||||
|
||||
Launcher:
|
||||
|
||||
```sh
|
||||
devin -p --model swe-2-max --permission-mode auto --respect-workspace-trust false --prompt-file /tmp/frame-vr-review-prompt.txt
|
||||
```
|
||||
|
||||
The prompt required read-only review, no delegation, no edits and no Frame
|
||||
access. The reviewer inspected surrounding code, then stopped while checking
|
||||
the public OpenVR header. The tool runner reported:
|
||||
|
||||
> warning: rejected a tool call that requires confirmation. Running in non-interactive mode.
|
||||
|
||||
The real launcher exit status was **0**, but no verdict was returned. A zero
|
||||
process status here is not evidence that the review completed.
|
||||
|
||||
## Tool-free retry
|
||||
|
||||
Launcher:
|
||||
|
||||
```sh
|
||||
devin -p --model swe-2-max --permission-mode auto --respect-workspace-trust false --prompt-file /tmp/frame-vr-review-final-prompt.txt
|
||||
```
|
||||
|
||||
The self-contained prompt supplied the complete frozen changes, surrounding
|
||||
code, standards and locally fetched authoritative OpenVR header excerpts. It
|
||||
explicitly prohibited tools, edits, delegation and device access. This avoided
|
||||
the first attempt's permission boundary without escalating permissions.
|
||||
|
||||
No output or verdict arrived within the fifteen-minute review window. The
|
||||
process was sent SIGTERM at 918 seconds; the shell recorded real exit status
|
||||
**143**. Findings are unavailable. Review must be completed before considering
|
||||
this partial draft ready; playspace feasibility is also still paused.
|
||||
|
||||
## Other validation
|
||||
|
||||
- 166 Python unit tests passed locally.
|
||||
- 8 website tests and UI JavaScript syntax passed locally.
|
||||
- Desktop and phone-width attached-preview checks passed using live telemetry;
|
||||
paid optional install buttons were disabled, and unavailable metrics cleared.
|
||||
- [Fake-Frame CI](https://github.com/saphid/frame-control/actions/runs/36423548487/job/108931878908)
|
||||
passed, as did Windows/Linux server tests and the main checks job. Docker was
|
||||
unavailable locally. macOS/iOS jobs were still queued at this handoff.
|
||||
- Real-device evidence and the paused-control limitation are in
|
||||
[VR utilities](../../vr-utilities.md).
|
||||
+7
-2
@@ -121,8 +121,13 @@ a limit on the number of floating panels.
|
||||
script needed.
|
||||
- **Inside the desktop panel**: KWin tiling (Meta+arrow keys with a Bluetooth
|
||||
keyboard) or virtual desktops arrange windows within the 1280×800 rectangle.
|
||||
- **Windows-only overlay tools** (Desktop+, OVR Toolkit, OVRdrop) do this for a
|
||||
PC's desktop in SteamVR. They don't run on the Frame's standalone Linux.
|
||||
- **Optional overlay tools:** Desktop+, OVR Toolkit and similar software are
|
||||
separate from Frame Control. Public reports describe some Proton support;
|
||||
Windows-only does not by itself prove a Frame app cannot run. Local status
|
||||
and sources are in [VR utilities](vr-utilities.md).
|
||||
- **Our performance HUD:** Home → VR comfort and performance → Open HUD in
|
||||
headset creates its own gamescope panel using built-in tools. It needs no
|
||||
third-party overlay app. [Metrics and verification](vr-utilities.md).
|
||||
|
||||
## Frame Control's panel switcher
|
||||
|
||||
|
||||
@@ -110,3 +110,11 @@ returns nothing without `cc`, so Frame Control takes the country from
|
||||
- Installing when there's more than one library folder, such as a microSD card.
|
||||
- Uninstalling. `steam://uninstall/<appid>` should open a confirmation in the
|
||||
headset.
|
||||
|
||||
## Optional VR software
|
||||
|
||||
The [VR utilities list](vr-utilities.md#optional-software) is separate from our
|
||||
controls and HUD. It checks software ownership as well as games; paid utilities
|
||||
are installable only when already in the loaded Frame account library. No
|
||||
purchase flow is added. Public reports, local results and ownership are shown
|
||||
separately, and untested tools remain untested.
|
||||
@@ -0,0 +1,139 @@
|
||||
# VR comfort and performance
|
||||
|
||||
Frame Control owns its HUD and telemetry. They use SteamVR/OpenVR, gamescope,
|
||||
Python and xterm already on the Frame, plus the Frame's sensors. No feature
|
||||
requires fpsVR, OVR Advanced Settings, XSOverlay or another third-party app.
|
||||
The software list is a separate, optional convenience.
|
||||
|
||||
This is **part of [#25](https://github.com/saphid/frame-control/issues/25)**,
|
||||
not completion of the issue. Playspace controls remain blocked on the device
|
||||
checks below. The PR stays draft.
|
||||
|
||||
## Our performance HUD
|
||||
|
||||
On **Home → VR comfort and performance**, the app shows a timestamped sample
|
||||
with each status refresh (30 seconds, or Refresh). **Open HUD in headset**
|
||||
starts our text HUD as a gamescope panel, refreshed every two seconds. In the
|
||||
SteamVR dashboard, select **Frame Control HUD**, then Float in World or dock
|
||||
it to a controller. **Close HUD**, or closing its terminal, ends it. Opening
|
||||
it twice reuses the existing process.
|
||||
|
||||
| Value | Meaning and source |
|
||||
|---|---|
|
||||
| Compositor FPS / period | Differences between two `IVRCompositor_029::GetFrameTiming` frame indices and monotonic compositor timestamps, sampled 200 ms apart. Output cadence, not game FPS or a long-term average. |
|
||||
| Application FPS | Reciprocal of OpenVR's client frame interval. Unavailable if there is no positive interval; not inferred from refresh rate. |
|
||||
| Render GPU time | OpenVR total render GPU milliseconds, not GPU utilisation. |
|
||||
| Compositor CPU | OpenVR compositor render CPU milliseconds, not game CPU time. |
|
||||
| System CPU | `/proc/stat` busy-time delta across the sample, with guest time counted once and iowait treated as idle. |
|
||||
| GPU clock | `3d00000.gpu/cur_freq`, converted from Hz to MHz; frequency is not load. |
|
||||
| Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. |
|
||||
|
||||
OpenVR uses background application mode, which does not start SteamVR or keep
|
||||
it running. This mode also returned live timing in a read-only device probe.
|
||||
|
||||
Missing sensors, a stopped or incompatible SteamVR runtime, and non-advancing
|
||||
frame indices display **Unavailable**, never invented zero FPS. Failed status
|
||||
refreshes clear the HUD card rather than keeping a stale live-looking sample.
|
||||
The HUD itself adds CPU/GPU work; it is a diagnostic, not a zero-overhead benchmark.
|
||||
|
||||
**Verified 2026-09-28**, SteamOS 0.4.1, build `20260925.6191901`, SteamVR
|
||||
2.18.1: the exact OpenVR interface and 192-byte timing layout returned advancing
|
||||
frame indices and live GPU/CPU timing. Sensor reads, creation of overlay
|
||||
`valve.steam.desktopgame.2000250025`, duplicate-open handling, and closing the
|
||||
HUD passed. The temporary probe overlay disappeared after its process closed.
|
||||
[Sanitized device sample](evidence/vr-utilities/device.json).
|
||||
|
||||
**Unverified:** visual placement while wearing the headset, controller docking,
|
||||
and overhead during gameplay. The companion card was checked in the attached
|
||||
preview using live Frame data. Creating an overlay does not establish that it
|
||||
was visible to the wearer.
|
||||
|
||||
The optional HUD copies only `frame_status.py` and `frame_vr.py` into
|
||||
`~/.local/share/frame-control/vr/`. It tags only the window whose X11 PID matches
|
||||
its own xterm, avoiding other threads' windows. Stop checks both PID and Linux
|
||||
process start time before sending SIGTERM. It does not stop Steam or SteamVR,
|
||||
edit their settings, install a service, or need sudo.
|
||||
|
||||
## Playspace, seated height and recenter: paused
|
||||
|
||||
**Verified 2026-09-28:** SteamVR exposes `IVRChaperoneSetup_006` and
|
||||
`IVRChaperone_004`. An initial 1 cm seated zero-pose translation committed,
|
||||
read back and restored numerically. A later trial, while the shared Frame was
|
||||
in use, changed universe IDs after commits and returned transforms that did
|
||||
not match the requested write or restore. Journal entries reported
|
||||
`CommitWorkingCopy`, `VREvent_ChaperoneUniverseHasChanged` and
|
||||
`VREvent_ChaperoneRoomSetupCommitted`. The collision-bound arrays and play-area
|
||||
size matched in the saved before/after records, but the origin matrices did not.
|
||||
|
||||
Alex confirmed the headset was in use and asked to pause control tests. No
|
||||
further playspace writes were made. The exploratory control implementation was
|
||||
removed from the shipping API; `recenter`, `adjust` and `restore` are rejected.
|
||||
This is an **unresolved feasibility check**, not evidence that OpenVR controls
|
||||
cannot work. Concurrent use and the Frame driver's coordinate-system handling
|
||||
still need to be separated.
|
||||
|
||||
The probe's original and last-read poses remain in the Frame's
|
||||
`~/.local/share/frame-control/vr/comfort.json` for investigation. This draft
|
||||
neither reads nor applies that baseline. Do not blindly replay it into a room
|
||||
that may have changed. No recenter test was reached in the later trial.
|
||||
|
||||
Before adding controls, on an idle Frame:
|
||||
|
||||
1. Establish current room and tracking state, and inspect the retained probe
|
||||
evidence before considering any restoration.
|
||||
2. Prove seated and standing height/move operations in the Frame driver's
|
||||
current coordinates, including delayed readback, coordinate rebasing and
|
||||
recovery. Show that the physical safety boundary stays correct.
|
||||
3. Verify recenter independently, and test a full apply/restore cycle plus a
|
||||
concurrent room-change refusal. Add a fake OpenVR test for those contracts.
|
||||
4. Check the apparent result in a seated and a standing app before exposing UI.
|
||||
|
||||
**Documented:** seated and standing are tracking origins selected by an app;
|
||||
changing a seated origin cannot force every game to support seated play.
|
||||
|
||||
**Inferred from the installed SteamVR defaults:** there is no generic snap-turn
|
||||
or locomotion-vignette setting. `dashboard.verticalOffsetCm_2` and
|
||||
`steamvr.panelMaskVignette` affect panels, not the player's height or game
|
||||
locomotion. The app gives game-setting hints for snap-turn, teleport movement
|
||||
and movement vignette instead of writing these unrelated settings.
|
||||
|
||||
## Optional software
|
||||
|
||||
**Verified 2026-09-28 (same build):** a read-only query of Steam's loaded
|
||||
`appStore.allApps` found none of these five apps on the Frame account. The query
|
||||
includes software, which the existing games-only library filter excludes.
|
||||
Steam's public app-details API listed only Desktop+ as free. No software was
|
||||
purchased, installed or launched during these ownership checks.
|
||||
|
||||
| Utility | Local Frame status | Public evidence / optional source |
|
||||
|---|---|---|
|
||||
| OVR Advanced Settings (1009850) | **Untested**, not owned | Steam edition is paid. Developer's [free source and releases](https://github.com/OpenVR-Advanced-Settings/OpenVR-AdvancedSettings). No verified Frame result in this work. |
|
||||
| XSOverlay (1173510) | **Untested**, not owned | Supplied research attributes Proton support with tweaks to [Road to VR](https://www.roadtovr.com/valve-steam-frame-review/). This is a public report, not our verification. |
|
||||
| OVR Toolkit (1068820) | **Untested**, not owned | Same [public Frame report](https://www.roadtovr.com/valve-steam-frame-review/); no local verification. |
|
||||
| fpsVR (908520) | **Untested**, not owned | [Steam listing](https://store.steampowered.com/app/908520/). No Frame-specific result established in the supplied research. General PC VR reviews do not verify Frame support. |
|
||||
| Desktop+ (1494460) | **Untested**, free | [Developer source](https://github.com/elvissteinjr/DesktopPlus). No verified Frame result in this work. |
|
||||
|
||||
**Documented (supplied research):** the XSOverlay/OVR Toolkit claims are kept as
|
||||
attributed leads. **Verified source check 2026-09-28:** fetching the linked
|
||||
review returned HTTP 200 and the expected review title, but neither utility name
|
||||
appeared in the fetched HTML or extracted text. The claims could not be
|
||||
corroborated from that page; this is not proof of incompatibility. They do not make those apps dependencies or mark them
|
||||
locally verified. No paid utility is auto-acquired. A server-side check blocks
|
||||
installation through the Steam endpoint if the paid utility is absent from the
|
||||
loaded Frame library; an empty/unavailable library fails closed. Desktop+ uses
|
||||
the existing free Steam install flow, which may need a license confirmation in
|
||||
the headset. None is advertised as known-good without local evidence.
|
||||
|
||||
Compatibility reports reuse the existing `compat-db` storage and validation
|
||||
with `package: "steam:<appid>"`, rather than colliding with Android package IDs.
|
||||
The optional list shows the latest `works`, `issues` or `broken` report with its
|
||||
date, build and notes, separately from public sources and ownership. With no
|
||||
report the status stays **untested**. No shared database schema change or
|
||||
production deployment is needed; no reports were published by this work.
|
||||
|
||||
## Validation and review
|
||||
|
||||
166 unit tests and 8 website tests passed locally. The new fake-Frame cases
|
||||
passed in GitHub CI (Docker was unavailable locally). Desktop and phone-width
|
||||
preview checks passed. The two independent review attempts did not produce a
|
||||
verdict; [commands, real exit statuses and limitations](evidence/vr-utilities/review.md).
|
||||
@@ -83,5 +83,27 @@ class Device(harness.FrameTestCase):
|
||||
self.assertEqual(state()['steam']['pages'][0]['title'], 'Hades on Steam')
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -27,7 +27,7 @@ function newShortcutId(steam) {
|
||||
function build(steam) {
|
||||
const findShortcut = id => steam.shortcuts.find(s => s.appid === Number(id));
|
||||
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,
|
||||
size_on_disk: String(a.installed ? a.size : 0), minutes_playtime_forever: a.minutes || 0,
|
||||
rt_last_time_played: a.last_played || 0,
|
||||
|
||||
@@ -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
-4
@@ -1,9 +1,12 @@
|
||||
"""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
|
||||
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 glob
|
||||
import math
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -183,11 +186,104 @@ def activity_level():
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
# 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, AttributeError): # AttributeError: a SteamVR build without these exports
|
||||
time.sleep(0.2)
|
||||
out['cpuPercent'] = cpu_percent(before, cpu_ticks())
|
||||
return out
|
||||
|
||||
|
||||
def status():
|
||||
uptime = read("/proc/uptime")
|
||||
procs = process_names()
|
||||
print(json.dumps({
|
||||
return {
|
||||
"time": time.time(),
|
||||
"performance": performance(),
|
||||
"hostname": socket.gethostname(),
|
||||
"os": os_release(),
|
||||
"uptime": float(uptime.split()[0]) if uptime else None,
|
||||
@@ -207,7 +303,11 @@ def main():
|
||||
},
|
||||
"games": games(),
|
||||
"flatpaks": flatpaks(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
print(json.dumps(status()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -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 => ({
|
||||
state: i?.eInstallState ?? 0, app: i?.currentAppID ?? 0, need: i?.nDiskSpaceRequired || 0,
|
||||
free: i?.nDiskSpaceAvailable || 0, error: i?.eAppError, detail: i?.errorDetail }))"""
|
||||
@@ -158,6 +173,10 @@ def owned():
|
||||
|
||||
def install(appid):
|
||||
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}})"
|
||||
f"(appStore.GetAppOverviewByAppID({appid}))")
|
||||
if app and app["installed"]:
|
||||
@@ -216,6 +235,8 @@ def main():
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
if cmd == "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():
|
||||
out = (install if cmd == "install" else store)(int(sys.argv[2]))
|
||||
else:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -576,6 +576,25 @@
|
||||
</section>
|
||||
</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 class="panel" id="comfort" style="margin:20px 0;padding:16px">
|
||||
<div class="shelf-head"><h2>Family and comfort</h2><span class="spacer"></span>
|
||||
<button class="action small" id="castBtn">Cast headset view</button>
|
||||
@@ -1165,6 +1184,7 @@ function refresh() {
|
||||
setConn(true, state.hostname);
|
||||
setOnline(true);
|
||||
} catch (e) {
|
||||
renderPerformance(null);
|
||||
if (!e.offline) { setConn(false, "Error"); log("Status failed: " + e.message, "e"); }
|
||||
if (!state) ["games", "flatpaks"].forEach(id => failed($(id), e));
|
||||
} finally {
|
||||
@@ -1175,6 +1195,38 @@ function refresh() {
|
||||
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();
|
||||
};
|
||||
|
||||
// The Frame owns the clock. Poll independently of status and the selected tab.
|
||||
let comfortBusy = false, comfortSeen = new Set(), comfortSession = null, comfortHydrated = false;
|
||||
async function localNotification(message, request = false) {
|
||||
@@ -1314,6 +1366,7 @@ function battery(b, power) {
|
||||
}
|
||||
|
||||
function render(s) {
|
||||
renderPerformance(s);
|
||||
battery(s.battery, s.power);
|
||||
$("devName").textContent = `${s.hostname} · ${s.ip ?? "no IP"}`;
|
||||
$("updated").textContent = new Date(s.time * 1000).toLocaleTimeString();
|
||||
|
||||
+27
-1
@@ -54,6 +54,9 @@ import frame_store # noqa: E402
|
||||
import frame_telemetry # noqa: E402
|
||||
import frame_titles # 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()
|
||||
|
||||
@@ -505,6 +508,27 @@ def steam(body):
|
||||
return res
|
||||
|
||||
|
||||
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):
|
||||
q = parse_qs(query)
|
||||
cc = (q.get("cc") or [""])[0].upper()
|
||||
@@ -1926,7 +1950,7 @@ def agent_approval(body):
|
||||
return frame_agent.approvals.decide(body.get("confirmation"), body.get("accept"))
|
||||
|
||||
|
||||
POST = {"/api/comfort": comfort, "/api/media": media, "/api/agent/call": agent_call, "/api/agent/approval": agent_approval,
|
||||
POST = {"/api/vr": vr, "/api/comfort": comfort, "/api/media": media, "/api/agent/call": agent_call, "/api/agent/approval": agent_approval,
|
||||
"/api/assistant/chat": assistant_chat, "/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/input": remote_input, "/api/touch": remote_touch,
|
||||
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
|
||||
@@ -2077,6 +2101,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"shared": frame_catalog.compat_db.shared()})
|
||||
elif path == "/api/android/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/macview":
|
||||
self.send_json(macview_state(parse_qs(url.query)))
|
||||
elif path == "/api/telemetry":
|
||||
|
||||
Reference in new issue
Block a user