Compare commits

..
Author SHA1 Message Date
SAPHID 612a766057 test(layout): provide page helpers in isolated control center fixtures 2026-10-17 23:58:37 +00:00
SAPHID fa9ee01cc1 chore: resolve conflicts with main 2026-10-17 22:10:07 +00:00
saphidandClaude Opus 5.5 1863c92101 Show the focus ring on the title form's and display picker's selects too
The fix review found two older rules (#titleForm select:focus, .disp select:focus)
that outranked the new select:focus-visible outline and still removed it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:32:28 +10:00
saphidandClaude Opus 5.5 c0ae75d56b Keep the headset menu reachable and on screen, and keep focus visible
From the independent review of this PR:

- Without a battery reading the chip was hidden, and with it the only way to
  the headset's details. It now stays, labelled Headset details.
- On a 375 px phone the menu lined up with the chip and ran 82 px off the
  left edge. It now stays 12 px inside the window.
- A long headset name pushed the menu wider; it's now cut short with an
  ellipsis (the full name is in the tooltip).
- Reduced motion turned off smooth scrolling for the page but not for the
  new scroll area.
- Selects lost their focus ring; keyboard focus now shows a blue outline.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 20:56:32 +10:00
saphidandClaude Opus 5.5 12aaec7888 Keep tool headings on one line and label the battery temperature
Mac in the headset's picture quality moves into the panel with a label, and the
panel switcher's Open in headset button sits under its list, so neither heading
wraps in a narrow column. Headings line up across panels whether or not they
hold a button. An odd last figure in a stats grid spans the row instead of
leaving a hole. The battery menu says which temperature is the battery's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:31:02 +10:00
saphidandClaude Opus 5.5 32fbbfe475 Fit the window at every size and show the battery once
The page now scrolls between the header and the Activity bar on a computer,
so the bar never covers content, and nothing overflows sideways from the
760 x 560 minimum up. The header gives way as the window narrows: the
wordmark goes, the tabs tighten, and below 960 px the logo, Refresh (R still
works) and the pill's second line go. Phones keep scrolling the window.

The battery shows once, in the header chip. Clicking it opens a menu with
the headset's storage, memory, temperature, Wi-Fi, uptime, SteamOS build and
services; the big battery card is gone from Home, and the VR performance
table no longer repeats battery and temperature.

Heading rows wrap their buttons onto a new line instead of squeezing the
heading, and every drop-down is dark.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:19:19 +10:00
23 changed files with 132 additions and 3727 deletions

No files matched your search

+1 -2
View File
@@ -219,11 +219,10 @@ Frame's software fits together, all checked against a real headset and labelled
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
| [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) | UEVR for Unreal games from Frame Control (install, play in VR, remove), the per-game table, Beat Saber blockers |
| [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 |
| [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
| [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset |
| [Eye tracking and heart rate](docs/tracking.md) | Our OpenXR → OSC bridge, BlueZ heart-rate panel and optional local session log; SlimeVR feasibility notes |
| [AI agents and assistant](docs/agents.md) | Key-free MCP tools, human approvals, and an opt-in assistant panel |
| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, the headset smoke test and a Windows test VM |
| [Open questions](docs/open-questions.md) | What's still unchecked |
-122
View File
@@ -1,122 +0,0 @@
# UEVR from Frame Control, 2026-09-29
**Verified observations**, with limits below. Same device and software as
[2026-09-28](mods-2026-09-28.md): aarch64, SteamOS `0.4.1`, BUILD_ID
`20260925.6191901`, Proton `11.0-2c` ARM64, SteamVR `2.18.1`. Times are the
Frame's AEST clock. Headset tests ran under the shared
`/tmp/frame-test.lock`, taken 11:13–11:28, 11:33–11:49, 12:02–12:40,
12:44–12:52 and 13:01–13:03. Battery 45 % →
93 %, on charge throughout.
**Not observed:** the VR image, head tracking and controller input. SteamVR
reported `activity_level` 3 (user interaction timeout; the headset was not
being worn) with `average_target_fps` 1. Every `frame_vrshot.py` stereo
capture was a uniform dark frame. Frame submits prove the app is presenting
to SteamVR, not that the image is correct.
## Manual injection (11:13–11:28)
Game launched with `steam steam://rungameid/1067310`. `UEVRInjector.exe` was
started with the game process's Wine variables, and its WPF window drew
through DXVK's D3D9. It listed `Drop-Win64-Shipping (pid: 320) (SkyArk
(64-bit, PCD3D_SM5))`, a Wine-side process id.
- `--attach=Drop-Win64-Shipping` didn't inject. The button still read
**Inject**, and the saved `OpenVRRadio=True` in `AppData/Local/praydog/…/user.config`
was not restored on the next start.
- XTest clicks landed on the game: `XQueryPointer` reported child
`0x2000001` (the game) at the injector's coordinates, and
`XLowerWindow`/`XRaiseWindow`/`XSetInputFocus` didn't change that.
- `XSendEvent` button events to the injector window worked. After OpenVR and
Inject, `/proc/<game>/maps` contained
`drive_c/frame26/uevr/UEVRBackend.dll` and `openvr_api.dll`, next to
Proton's `vrclient_x64.dll`. UEVR's log:
```text
[11:17:32.948] Hooking D3D11
[11:17:33.020] Hooked DirectX 11
[11:17:34.263] Framework initialized
[11:17:34.475] [VR] Requested runtime: openvr_api.dll
[11:17:39.791] UGameViewportClient::Draw called for the first time.
[11:17:39.792] is stereo enabled called!
```
It also logged repeated `Failed to initialize Framework on DirectX 12` (probing
before it chose D3D11) and failed Unreal scans, for example `Failed to locate
r.EnableStereoEmulation cvar` and `Failed to find FSceneView constructor`.
SteamVR loaded UEVR's action manifest and `bindings_oculus_touch.json` for
`steam.app.1067310`. `vrcmd --stats` then showed `"key" :
"steam.app.1067310"` with `frame_submits` 4439, then 5506 a few seconds later.
A second session's injector exited within about 20 seconds. The third,
started fresh, injected again (`frame_submits` 1412). After the game was
stopped, a plain launch had no UEVR modules and no SteamVR scene app. All test
files were removed.
## Through Frame Control (11:33–11:49)
Local `ui/server.py` against the `frame` alias. First the API, then the
page's **VR mod** dialog, driven with a headless Chromium:
| Step | Result |
|---|---|
| `status` for 2177750 (HL2 VR) | `eligible: false`; `install` refused ("doesn't look like an Unreal Engine game") |
| `install` for 1067310 | 3 s. Three downloads, hashes matched, receipt written |
| `start`, game running | First run refused: `the UEVR window is 960 px wide, not 625`. It was measured before WPF laid it out; `start` now waits for the width to settle. Rerun: injected, `frame_submits` 27 (36 s) |
| `uninstall` while running | Refused: "the game is running; quit it first" |
| `start`, game not running | Launched the game, injected, `frame_submits` 45 (55 s) |
| UI: Remove, Install | Worked; the prefix listing matched its pre-install state except an empty `UnrealVRMod`, which uninstall now also removes |
| UI: Play in VR | Failed once: the injector died with `Process terminated. … Resource name: Arg_AccessViolationException`, after `Fontconfig error: No writable cache directories` (it had no `HOME`). Fixed by keeping the normal environment under the game's (the relaunch-on-failure added next is gone with the GUI injector; see below) |
| UI: Play in VR ×4 | 4 of 4 injected. The three cold starts took 54, 58 and 58 s; frame submits 56, 71, 80 |
| UI: Remove | `drive_c`, `AppData/Roaming` and `AppData/Local` listings matched the pre-install state; download cache and receipt gone |
## The GUI injector wasn't reliable (12:02–12:40)
After review fixes, cold starts through the API with UEVR's own injector:
- Batches of 4, 6 and 8 runs: 3/4, 4/6 and 6/8 injected. A retry that
relaunched the injector didn't rescue a bad session. In both failures of
the last batch, the first injector ignored the clicks and the next two
never showed a window within 60 s.
- Captures of the ignoring injector (`xwd`) showed its first-drawn frame,
with OpenVR still unselected. The injector's log had no error.
Lock released at 12:40 before redesigning.
## Frame Control's own injector (12:44–12:52)
`start` now runs `frame_inject.py` under python.org's embeddable Python
3.14.7 (SHA-256 `d297e5ff…1f15`, matching python.org's release-file API), in
the game's Wine session. The old install was removed and the new one
installed without the headset lock, since neither launches anything. The
prefix listing after removal matched the pre-install state; the new install
is 47 MB.
| Step | Result |
|---|---|
| API `start`, cold | Injected in 35 s, `frame_submits` 39. UEVR log: `Hooked DirectX 11`, `Framework initialized`, `Requested runtime: openvr_api.dll`; `config.txt` has `Frontend_RequestedRuntime=openvr_api.dll` |
| API `start` ×8, cold | **8/8** injected, 33–34 s each; `frame_submits` 33–42 at the check |
| UI: dialog while running | "UEVR is running in the game now." Remove disabled (screenshot in `docs/img/uevr-dialog.png`) |
| UI: Remove → Install → Play in VR → Remove | Each worked; Play in VR injected (`frame_submits` 106 a few seconds later) |
| After the last Remove | `drive_c`, `AppData/Roaming` and `AppData/Local` listings matched the pre-install state; download cache and receipts gone; no game or `python.exe` processes. The empty `/tmp/frame-control-mods.lock` was deleted by hand (tmpfs) |
Battery 98–100 % on charge. Lock released at 12:52.
## After the second review (13:01–13:03)
`frame_inject.py` now declares every Win32 signature, checks each result,
frees its remote buffers and closes its handles; uninstall accepts only our
folder and UEVR's own settings folders. Rechecked: install; three cold starts
injected in 35, 34 and 33 s (`frame_submits` 42, 38, 47), UEVR logging
`Framework initialized` and `Requested runtime: openvr_api.dll`; uninstall
left `drive_c`, `AppData/Roaming` and `AppData/Local` as before, with no cache,
receipts or processes. Frame Control's injector total: 12 of 12 cold starts
through the API, plus the UI run.
## Left on the Frame
- Gravitas (free, 7.4 GB, installed 2026-09-28 for this work) is still
installed; nothing else from this work is left.
- No `UEVRInjector.exe` or game processes were left running. Steam and
SteamVR were running at the end.
- The test lock was released at 13:03.
-10
View File
@@ -20,15 +20,6 @@ SteamVR (vrserver, vrcompositor, vrdashboard) ← renders the room + pane
Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 3056000
```
## Tracking additions (verified 2026-09-28)
On SteamOS 0.4.1, build `20260925.6191901`, SteamVR exposes combined gaze
through `XR_EXT_eye_gaze_interaction` in a headless OpenXR 1.0 session. Our
reader obtained valid tracked samples and sent OSC to a configured loopback
receiver. BlueZ LE discovery works; GTK4/GI can render our heart-rate panel.
No BLE strap or SlimeVR trackers were attached. See [tracking](tracking.md)
for the evidence, privacy defaults and untested integration boundaries.
## Facts worth knowing
| Fact | Where it matters |
@@ -61,7 +52,6 @@ for the evidence, privacy defaults and untested integration boundaries.
| The Steam client runs with `-cef-enable-debugging`, so its UI answers Chrome DevTools on loopback `127.0.0.1:8080`. The `SharedJSContext` page has `appStore` (owned apps), `downloadsStore` and `SteamClient.*`. `steam steam://install/<appid>` over SSH installs an owned game; when the options dialog shows (state 7), `SteamClient.Installs.ContinueInstall()` accepts it. **Verified 2026-09-25** with Balatro and Broforce. The Frame rating is `steam_hw_compat_category_packed >> 8 & 3`. | [steam-games.md](steam-games.md), `ui/frame_steam.py` |
| Chromium Flatpak 154 has **no immersive WebXR**: `navigator.xr` exists, but `isSessionSupported("immersive-vr")` returns `false`. Web VR180 players (DL8/DeoVR embeds) still play video inline as a flat, pannable view, and their VR button opens a tab on immersiveweb.dev. Forcing it doesn't help. `--enable-features=OpenXR,WebXR --force-webxr-runtime=openxr`, with `/opt/steamvr` and `XR_RUNTIME_JSON` exposed to the Flatpak, still returns `false`. The aarch64 Linux binary has no OpenXR code at all (no `XR_RUNTIME_JSON`, `xrGetInstanceProcAddr` or loader strings), even though `chrome://flags` lists `#webxr-runtime` → OpenXR. **Why (verified against source 2026-09-25):** M154 is the first release that compiles OpenXR on Linux (`enable_openxr` includes `is_linux`, `checkout_openxr` is true in Flathub's tarball, and Flathub's GN args don't turn it off). But `content/services/isolated_xr_device/xr_runtime_provider.cc` only creates an OpenXR device under `ENABLE_OPENXR && IS_WIN`, on 154, 155 and `main`. Nothing on Linux calls the OpenXR code, so the linker drops it. The missing pieces are two unmerged Gerrit CLs (bug 506004811): [8132979](https://chromium-review.googlesource.com/c/chromium/src/+/8132979) wires the provider on Linux (with `kOpenXR` still off by default, so it needs `--enable-features=OpenXR`), and [8441736](https://chromium-review.googlesource.com/c/chromium/src/+/8441736) runs the XR service in a sandbox that allows SteamVR's sockets. The Frame does have an aarch64 runtime: `~/.config/openxr/1/active_runtime.json` → SteamVR `bin/linuxarm64/vrclient.so`. To watch in 3D, use a native player, or a Chromium built with those two CLs ([webxr-chromium.md](webxr-chromium.md)). That build (156.0.8071.0, arm64) reports `immersive-vr` as supported and starts a session that SteamVR takes as its scene app. With the headset on, the WebXR samples scene and three.js's stereo 360 video demo showed in 3D (verified 2026-09-26, seccomp sandbox off). Started with `--remote-debugging-port=9222`, Chromium answers DevTools on loopback. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web video, [panels.md](panels.md) |
| **DeoVR (Steam app 837380, Windows/Unity) runs immersively** under Proton ARM64 + FEX: Unity's OpenVR XR plugin finds `OpenVR Headset(Steam Frame)` and the `frame_controller`, the GPU shows as Turnip Adreno 750, and AVPro Video decodes through `MF-MediaEngine-Hardware`. It played 7680×3840 and 8192×4096 H.265 VR180 SBS streams in dome/fisheye mode (`FirstFrameReady`). Unity's own `VideoPlayer` (used for grid thumbnails) fails with `0xc00d36bb`, so thumbnail previews stay blank. The first launch takes about 45 s (`ComputeShaders: InitAsync`). Log: `compatdata/837380/pfx/drive_c/users/steamuser/AppData/LocalLow/Deo VR/Deo VR/Player.log`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | [vr-video.md](vr-video.md) |
| **A second Windows program can join a running Proton game.** Copy `WINEPREFIX`, `WINEDLLPATH`, `WINEDLLOVERRIDES`, `PATH` (Proton's `files/bin-arm64` comes first) and `DISPLAY` from `/proc/<game pid>/environ`, keeping your own `HOME` and the rest, and run `wine prog.exe` from an ordinary SSH shell. It shares the game's wineserver (outside the pressure-vessel container). Don't copy `WINESERVERSOCKET`, which is an inherited fd. A game's windows live on gamescope's X display for that app (`:1` for Gravitas), where the full-screen game keeps the pointer and focus: XTest clicks and restacking don't reach other windows, but `XSendEvent` button events sent to a window do. `xwd` captures WPF/DXVK windows but shows Vulkan game windows as black. **Verified 2026-09-29**, BUILD_ID 20260925.6191901, Proton 11.0-2c ARM64. | [mods.md](mods.md) |
| **Wolvic (VR browser APK) runs in Lepton against SteamVR's OpenXR**, with limits. The stock Lynx build aborts (`Runtime doesn't support selected swapChain color format`: it wants `GL_RGBA8`), and the stock Quest build fails with `XR_ERROR_API_VERSION_UNSUPPORTED`. Patching `DeviceDelegateOpenXR::GetSwapChainCreateInfo` in the Lynx build's `libnative-lib.so` to `GL_SRGB8_ALPHA8` (0x8C43) and re-signing fixes start-up. The Gecko engine then segfaults in `libxul`. The Chromium-engine build (Lynx v1.3-chromium) browses fine as an immersive app. Its page reports `isSessionSupported("immersive-vr") == true`, and `requestSession` succeeds, running about 36 rAF/s, but the headset shows **black** for WebXR content, or Wolvic's loading spinner that never clears, until the session is ended. Video decodes on the software `OMX.google.h264.decoder`. Tapping the URL bar's selection menu crashes it (no clipboard service). Open URLs with `am start -a VIEW -n com.igalia.wolvic/.VRBrowserActivity -d <url>` over the instance's ADB. DevTools is at `localabstract:content_shell_devtools_remote`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web VR video, [apks.md](apks.md) |
| Tailscale runs without root as a userspace `tailscaled` user service (static arm64 build in `~/.local/share/tailscale`, lingering on). In userspace mode, inbound tailnet connections reach the Frame's **loopback**, so every port, including DevTools on 8080, is reachable from the tailnet. **Verified 2026-09-25.** | [tailscale.md](tailscale.md), `scripts/tailscale-on-frame.sh` |
| **T3 Code desktop runs natively.** The stock release `T3-Code-0.0.42-arm64.AppImage` in `~/Applications/T3CodeDesktop/` starts with no extra setup: glibc 2.39, `libfuse.so.2`, GTK 3, NSS and libsecret are on the image. `panel-on-frame.sh --name t3code-desktop -- '~/Applications/T3CodeDesktop/T3-Code.AppImage'` gives it its own panel (`valve.steam.desktopgame.2000281357`, `--ozone-platform=x11`). Its bundled server listens on `127.0.0.1:3773` and shows up in onboarding as the `frame` computer, with `passwordStore: gnome-libsecret`. The image has no agent CLI and no `node`. Agents run through the LAN CLIProxyAPI (`llm-proxy.lan:8317`, which resolves on the Frame). Claude Code 2.1.283 comes from `claude.ai/install.sh`, and Codex 0.157.1 from the `codex-aarch64-unknown-linux-musl` release tarball, both into `~/.local/bin`. `with-cliproxy` and a mode-600 `~/.config/cliproxyapi/secrets.env` are copied from the Mac. The wrappers `claude-cliproxy` and `codex-cliproxy` (a `-c model_provider=cliproxy`, `wire_api="responses"`, `env_key="CLIPROXY_API_KEY"`) are set as `providers.claudeAgent.binaryPath` and `providers.codex.binaryPath` in `~/.t3/userdata/settings.json`, and T3 picked that up without a restart. Through the wrappers, `claude auth status` reports `loggedIn: true` (`oauth_token`), and both CLIs answered a prompt with `kimi-k3`. `gamescopectl screenshot` captured another layer (the Lepton T3 app) rather than this panel. `DISPLAY=:0 xwd -id <win>` piped to `ffmpeg` captures the window itself (1920×1080). **Verified 2026-09-26**, BUILD_ID 20260922.6101926. | Running T3 Code as a host on the Frame |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

+15 -85
View File
@@ -1,14 +1,11 @@
# Flat-to-VR mods and Beat Saber songs
**Status: UEVR works from Frame Control for Unreal Engine games; Beat Saber
songs are blocked.** Installed games have a **VR mod** button. It installs
UEVR, starts the game with UEVR injected, and removes UEVR again. That whole
loop was run on a real Frame on 2026-09-29 with Gravitas, from the app's own
UI ([evidence](evidence/mods-2026-09-29.md)). The headset was unworn, so
the VR image, head tracking and controls haven't been seen yet. Beat Saber
custom songs need an owned Beat Saber on the Frame, which there isn't
([below](#beat-saber-songs-first)).
[Issue #26](https://github.com/saphid/frame-control/issues/26) stays open for those.
**Status: feasibility work, not an installer.** Frame Control does not yet
manage mods or Beat Saber songs. [Issue #26](https://github.com/saphid/frame-control/issues/26)
stays open: neither UEVR injection nor Beat Saber custom-song playback has
been verified on this Frame. Alex has an owned copy on a Quest 2; that copy
has not been inspected. There is no Mods button until the underlying
install, playback and removal have been checked.
The mod manager must be Frame Control's own implementation. Mods and songs
are permitted third-party content; BSManager, ModsBeforeFriday, MO2 and other
@@ -18,87 +15,27 @@ unofficial mod mirrors are part of this work.
## Per-game support
Checked 2026-09-28 and 2026-09-29 on SteamOS **0.4.1**, BUILD_ID
**20260925.6191901**, aarch64, with **Proton 11.0-2c ARM64** and SteamVR **2.18.1**. “Verified” describes only
Checked 2026-09-28 on SteamOS **0.4.1**, BUILD_ID **20260925.6191901**, aarch64,
with **Proton 11.0-2c ARM64** and SteamVR **2.18.1**. “Verified” describes only
the observation stated, not a promise that the game is playable. “Documented”
means an upstream source describes it; “inferred” means it still needs a test.
| Game / build | Mod or content | Evidence and support status | Next check |
|---|---|---|---|
| Half-Life 2: VR Mod – Episode One, Steam 2177750, build 25413453 | Official Steam community mod, shared base depot 658920 build 25413418 | **Verified: startup only.** Already installed; launched through Proton ARM64. The stereo headset capture showed its first-time setup, and SteamVR loaded `bindings_frame.json`. Gameplay, controller interaction, fresh installation and removal are unverified. | Complete first-time setup and play a level before offering a tested install shortcut. |
| Gravitas, Steam 1067310, Windows, Unreal Engine 4 | UEVR 1.05, from Frame Control | **Verified 2026-09-29: installs, injects, SteamVR receives frames, removes cleanly.** Twelve cold **Play in VR** runs (33–35 s each), and the full cycle from the app's UI, all loaded `UEVRBackend.dll` into the game, with UEVR logging `Hooked DirectX 11` and `Requested runtime: openvr_api.dll`. SteamVR's compositor counted frame submits for `steam.app.1067310`. Remove restored the prefix to its pre-install file listing. **Not seen:** the stereo image, head tracking and controls (headset unworn). | Wear the headset: check the image, tracking, UEVR's in-headset menu and controller input. |
| Gravitas, Steam 1067310, Windows | UEVR 1.05 | **Verified: prerequisites and windows only.** Free Steam install completed. The game produced a `SkyArk (64-bit, PCD3D_SM5)` window. UEVR needed .NET; with official .NET 6.0.36 libraries it produced a `UEVR` window. A combined run exited 1 with X11 errors before injection was verified. **Inferred: compatibility remains unknown**, not proven broken. | Retry during a stable headset session; verify injection, stereo scene output, controls and removal. |
| Beat Saber, Steam 620980, Windows / Proton | Basic custom songs; later SongCore and version-matched mods | **Verified: absent from the 868-game library returned by this Frame.** Store metadata lists Windows, not Linux. **Documented:** the PC game reads basic maps from `Beat Saber_Data/CustomLevels` without a mod manager. Playback on Frame is unverified. | An already-owned, legitimately installed copy is required. Do not buy it as part of this task. |
| Beat Saber, claimed native ARM64 build | Custom songs / native mods | **Inferred: unverified.** The research mentions this build but supplies no verified official distributable or tested layout. CPU architecture alone does not identify Android versus Linux, the game version or the mod ABI. | Establish official provenance, ownership, binary type and version before touching files. Do not apply Quest patches to an unidentified build. |
| Beat Saber, Alex's Quest 2 copy | Custom songs / Android mods | **Documented: owner-reported copy on Quest 2.** Not reachable on 2026-09-29 (no device on `adb` from the Mac). No APK, version or installed mods inspected; no Frame playback verified. A Quest copy is a Meta store purchase; copying it to another headset would need its entitlement check to pass there, which this work won't work around. This does not establish ownership of the Steam build. | When the Quest is available, inspect the owned copy's version and supported transfer path, then test Lepton/OpenXR compatibility without bypassing entitlement checks. |
| Beat Saber, Alex's Quest 2 copy | Custom songs / Android mods | **Documented: owner-reported copy on Quest 2**, currently charging. No APK, version or installed mods inspected; no Frame playback verified. This does not establish ownership of the Steam build. | When the Quest is available, inspect the owned copy's version and supported transfer path, then test Lepton/OpenXR compatibility without bypassing entitlement checks. |
| Hogwarts Legacy, Steam 990080 | R.E.A.L. | **Verified: listed in this Frame's owned library, not installed.** Official release access and redistribution permission were not established; the referenced author Patreon page returned HTTP 403. No archive downloaded or game tested. | Obtain a current free release from the author and confirm its terms before any test. A news report saying “free” is not a redistribution grant. |
| Horizon Zero Dawn, Steam 1151640; Horizon Forbidden West, Steam 2420110 | R.E.A.L. | **Verified: both listed as owned, neither installed.** Same source/permission blocker as above; runtime support is unverified. | Check each game's supported version against an accessible official release. |
| Half-Life 2 VR / other OpenVR games | OpenComposite, per-game replacement | **Documented:** forwards OpenVR calls to OpenXR. **Inferred: Frame compatibility unknown.** Not installed or tested. HL2 VR reached setup with the shipped OpenVR path already. | Test a specific game and replacement DLL only if needed; preserve its original DLL. Never switch the shared headset's runtime globally. |
| Doom / Quake / Half-Life Team Beef ports | Author's VR ports plus separately owned or free game data | **Inferred: untested.** Android ARM64 support does not establish OpenXR extension or controller compatibility on Lepton. | Choose an official release and legally usable data set, then test that exact port. |
| Skyrim VR, Steam 611670 | SKSEVR / HIGGS / PLANCK stack | **Verified: Skyrim VR is absent from this library.** Owning flat Skyrim or Special Edition is not the VR game's entitlement. Runtime and mod support are unverified. | An already-owned VR copy and version-matched official mod releases are required. |
The test records ([2026-09-28](evidence/mods-2026-09-28.md),
[2026-09-29](evidence/mods-2026-09-29.md)) distinguish process startup,
visible output, injection and failures.
## UEVR from Frame Control
![The VR mod dialog while UEVR runs in Gravitas](img/uevr-dialog.png)
**Get games → an installed game → VR mod.** The dialog says whether the game
can take UEVR, then offers **Install UEVR**, **▶ Play in VR** and **Remove
UEVR**. The server route is `POST /api/mods {"action": "status" | "install" |
"start" | "uninstall", "appid": …}`, and the work happens on the Frame in
[`ui/frame_mods.py`](../ui/frame_mods.py). It uses only the standard library,
like `frame_steam.py`.
- **Which games.** Unreal Engine games, recognised by their
`…/Binaries/Win64/*-Win64-Shipping.exe` (inferred as the general rule; checked
with Gravitas, and HL2 VR is correctly refused). The game needs a Proton
prefix, so it must have been played once.
- **Install.** Downloads praydog's [UEVR 1.05 release](https://github.com/praydog/UEVR/releases/tag/1.05)
and python.org's [Windows x64 embeddable Python 3.14.7](https://www.python.org/downloads/release/python-3147/).
Each is checked against a pinned SHA-256; Python's comes from python.org's
release-file API. Archives are unpacked into a staging folder, rejecting
entries that escape it, symlinks and oversized contents, then moved into
`<prefix>/drive_c/frame-control/` (about 47 MB). Nothing goes into the
game's own folder. A receipt in `~/.local/share/frame-control/mods/` lists
what to remove, including UEVR's own settings folders only if they didn't
exist before. Install and remove refuse while the game is running, and only
one mod action runs at a time.
- **Play in VR.** Launches the game through Steam if it isn't running and
waits for its window. It then runs a short script under that Python, with
the game's Proton, prefix and Wine settings read from the game process, so
both share one Wine session. The script repeats what UEVR's own injector does
when you press Inject with OpenVR chosen: load `UEVRPluginNullifier.dll` and
call its `nullify`, load `openvr_api.dll`, set `Frontend_RequestedRuntime` in
UEVR's per-game `config.txt`, then load `UEVRBackend.dll`. Each load is a
`LoadLibraryW` remote thread, as in the frontend's `Injector.cs`. `start`
then checks that `UEVRBackend.dll` is mapped into the game and reads
SteamVR's frame count for the app (`vrcmd --stats`).
- **Remove.** Deletes what the receipt lists, then the download cache once
no game uses it. Verified: the prefix's file listing matched its
pre-install state afterwards.
Why not UEVR's own injector (verified 2026-09-29):
- It's a .NET 6 WPF app, so it needs Microsoft's .NET runtimes (about 70 MB
of downloads) in the prefix.
- Under gamescope, the full-screen game keeps the pointer and focus. XTest
clicks, raising or refocusing the injector window don't change that, so in
the headset you can't click it. Sending X `ButtonPress`/`ButtonRelease`
events straight to its window does work.
- `--attach=<process>` didn't inject under Wine, and it didn't restore its
saved OpenVR choice.
- Driven that way, it injected in 6 of 8 cold starts. In a bad session every
new injector ignored input or never showed its window, even after
relaunching. Its process list reads each process's main window title, a
cross-process window message; a window that isn't answering would block its
UI thread (inferred from the frontend's source, not proven). Without a
`HOME` it also died in .NET at startup.
- Frame Control's own script injected in 12 of 12 cold starts through the API, in 33–35 s, and again from the UI.
- UEVR's DirectX 12 probing logs errors before it settles on DirectX 11 for
Gravitas, and some of its Unreal engine scans fail on this older UE4 game.
Neither stopped the injection.
The [test record](evidence/mods-2026-09-28.md) distinguishes process startup,
visible output and failures. It also records a SteamVR restart during the
shared session, which prevents attributing the failed UEVR attempt to FEX.
## Beat Saber: songs first
@@ -125,10 +62,7 @@ or dependency for Frame Control.
## Requirements for our manager
For UEVR, items 1–5 are implemented in `frame_mods.py` and covered by
fake-library tests (`tests/test_mods.py`), apart from checking ownership
through Steam; item 6 is met for Gravitas. Beat Saber songs would need the
same, and none of it is built for them:
These are **planned**, not implemented or verified:
1. Resolve the selected Steam game's real library, installed build, executable
architecture and Proton prefix. Confirm ownership through Steam; a directory
@@ -157,11 +91,7 @@ same, and none of it is built for them:
- [UEVR 1.05 official release](https://github.com/praydog/UEVR/releases/tag/1.05)
and [author's usage instructions](https://github.com/praydog/UEVR#getting-started).
- [Microsoft .NET 6 release metadata](https://builds.dotnet.microsoft.com/dotnet/release-metadata/6.0/releases.json),
including the SHA-512 hashes used for the injector tests.
- [UEVR frontend source](https://github.com/praydog/UEVR-Frontend): `UEVR/MainWindow.xaml.cs`
(`Inject_Clicked`) and `UEVR/Injector.cs`, the steps `frame_inject.py` follows.
- [python.org release-file API](https://www.python.org/api/v2/downloads/release_file/) for the
embeddable Python's `sha256_sum`.
including the SHA-512 hashes used for the test runtimes.
- [OpenComposite's OpenXR branch](https://gitlab.com/znixian/OpenOVR/-/tree/openxr),
including per-game installation and the need to preserve original DLLs.
- [R.E.A.L. author post referenced by the research](https://www.patreon.com/realvr/posts/but-wheres-link-165840151)
+3 -3
View File
@@ -4,9 +4,9 @@ Frame Control's **Get games** section lists the games you own with each one's
Steam Frame rating, installs them on the Frame, and searches the Steam store.
This page covers how it works underneath, so you can do the same from a shell.
Installed Unreal Engine games also have a **VR mod** button that installs,
starts and removes UEVR; see [VR mods](mods.md) for how it works and the
per-game table. Mod support is separate from Steam's Frame rating.
For flat-to-VR mods and Beat Saber custom songs, see the
[per-game feasibility table](mods.md). Mod support is separate from Steam's
Frame rating; there is no mod installer yet.
## How it works
-7
View File
@@ -187,13 +187,6 @@ refuses ids with a hyphen (`missing/invalid arguments`), which the fake had
accepted. The fake now refuses them the same way, and Frame Control makes ids
Steam accepts.
## Tracking protocols and fake BlueZ
`tests/test_tracking.py` exercises our gaze conversion, OSC sender, HRS parser
and BlueZ lifecycle with an in-memory fake object tree. It runs in the normal
unit suite without Bluetooth, GTK or OpenXR. Real Frame results and the absent
strap/tracker boundaries are recorded in [tracking](tracking.md).
## Windows test VM
The unit tests run on Windows in CI, but the app itself doesn't. Anything that
-329
View File
@@ -1,329 +0,0 @@
# Eye tracking and heart rate
Frame Control's own tools run on the Frame, using OpenXR and BlueZ. No
VRCFaceTracking, LunaHR, Pulsoid or other tracking app is required. This is a
command-line first version; it does not add a desktop app tab.
## What was checked
**Verified 2026-09-28**, on a real aarch64 Frame running SteamOS 0.4.1,
BUILD_ID `20260925.6191901`:
| Check | Result |
|---|---|
| OpenXR gaze | SteamVR advertises `XR_EXT_eye_gaze_interaction`, `XR_MND_headless` and `XR_KHR_convert_timespec_time`. `supportsEyeGazeInteraction=1`. A headless session reached FOCUSED and produced 269 valid, tracked orientations in the first ten-second probe. |
| Our gaze → OSC bridge | A separate ten-second run produced 280 valid samples and 280 correctly padded 44-byte `/tracking/eye/CenterPitchYaw` messages at an explicitly configured loopback receiver. Only counters and packet-layout checks were retained. |
| Bluetooth stack | BlueZ active, adapter powered, central/peripheral roles available. LE discovery started and stopped successfully. No pairing or adapter power settings changed. |
| Our heart-rate panel | GTK4/GI runs on the stock image. A **synthetic 72 BPM** notification displayed in our X11 window, tagged `STEAM_GAME=2000000027`. Window capture checked; no real heart-rate measurement was taken. |
| SlimeVR, separate feasibility check | Native aarch64 server v21.1.0 ran with an isolated Temurin 21 JRE, created its driver sockets and accepted a local TCP connection on port 21110. Driver v6.0.0 loaded with all shared libraries resolved; `HmdDriverFactory("IServerTrackedDeviceProvider_004")` returned a non-null provider and error 0. |
![Our heart-rate panel on the Frame, showing synthetic 72 BPM](img/heart-rate-panel.png)
The image is a capture of our own Frame window using a fake notification,
not a real sensor reading.
**Untested:** a real BLE strap's notifications, physical fit/contact behaviour,
end-to-end heart-rate display/OSC/log with a strap, avatar response in VRChat,
gaze accuracy/calibration, coexistence with every immersive app, in-headset
panel placement, SlimeVR tracker/calibration data and the SlimeVR driver running
inside SteamVR. No trackers or strap are attached. The driver was loaded in a
separate process; it was **not registered or activated in SteamVR**. Steam and
SteamVR were not stopped or restarted.
**Verified blocker resolved:** importing `tkinter` fails because `libtk8.6.so`
is absent. The panel uses the installed GTK4/GI bindings instead. The Frame's
OpenXR headers advertise a newer API version than the runtime accepts; our
reader requests OpenXR 1.0 explicitly.
## Install our tools
From this checkout on your computer, while the Frame is awake:
```sh
python3 scripts/tracking-on-frame.py install
```
This copies our Python code and compiles our small C OpenXR reader into
`~/.local/share/frame-control/tracking/` on the Frame. It uses the Frame's
existing compiler, OpenXR headers/loader, Python, dbus-python, GI and GTK4.
Nothing is downloaded, and no sudo, driver registration, system setting,
service or autostart is added. `FRAME_ALIAS` can select another SSH alias.
The desktop app/server keeps its existing stdlib-only dependency set.
## Eye tracking → OSC
Start with a ten-second capability/data-availability check:
```sh
python3 scripts/tracking-on-frame.py gaze --seconds 10
```
This prints support, session-state numbers and sample counters. It opens no
OSC socket and prints no gaze coordinates. Exit 0 means at least one valid
sample, 3 means no valid sample was observed, and 1 means an API/runtime error.
If there are no valid samples, wake/wear the headset and check its tracking
setup; a successful capability check alone does not prove usable gaze.
To send to VRChat running **on the Frame**, explicitly enable OSC in VRChat
and choose its local UDP endpoint:
```sh
python3 scripts/tracking-on-frame.py gaze --seconds 3600 --osc 127.0.0.1 9000
```
For a receiver on another computer, replace `127.0.0.1` with that computer's
IP address and choose its listening port. Addresses are IP literals (IPv4 or
IPv6); there is no discovery or default destination. Loopback here always
means **the Frame**, not the computer running the SSH command. OSC uses
unencrypted UDP: configure only a receiver you intend to receive this data.
**Documented:** [VRChat's eye OSC interface](https://docs.vrchat.com/docs/osc-eye-tracking)
accepts `/tracking/eye/CenterPitchYaw` with two floats in degrees, positive down
and right. We locate OpenXR's combined gaze pose relative to VIEW (the head),
rotate its -Z forward vector and convert that direction to these angles.
Only active, orientation-valid **and tracked** samples are sent, at up to
30 Hz. No eyelid/blink, individual-eye or face values are invented. We do not
send neutral gaze on tracking loss; VRChat's documented timeout restores its
automatic eye behaviour after input stops.
**Privacy:** gaze is personal data. It stays in process memory and a private
pipe between our reader and bridge. There is no gaze log option, telemetry,
OSC receiver or raw gaze on stdout/stderr. Only an explicit `--osc IP PORT`
opens an output socket. Runtime diagnostics and validity counters are not
measurements. Stop with Ctrl-C or let `--seconds` expire (maximum 24 hours).
A lost headless session ends the run; it does not silently reconnect.
## BLE heart rate → our panel, OSC and optional log
First discover/pair your strap in SteamOS's Bluetooth settings. Select that
strap's Bluetooth address explicitly; our tool does not scan for or connect
to arbitrary nearby devices.
```sh
python3 scripts/tracking-on-frame.py heart \
--device AA:BB:CC:DD:EE:FF --panel --seconds 3600
```
This uses BlueZ's standard Heart Rate Service (`180d`) and Heart Rate
Measurement (`2a37`) notifications. It finds the characteristic only beneath
the selected device's HRS service. The reader handles 8- and 16-bit BPM,
contact flags and optional energy/RR fields; energy and RR intervals are
validated for length but discarded. Zero BPM, reported loss of skin contact,
malformed packets and readings older than five seconds are not shown as a
current measurement. A disconnect stops the run; reconnect and start again.
This is a social/fitness readout, not a medical monitor.
The panel is our GTK4 window on gamescope's X display. Use SteamVR's panel
controls to float/dock it (see [panels](panels.md)). **Stop**, closing the panel,
Ctrl-C, SSH hangup or the duration limit ends our subscription. A connection
that was already open when we started is preserved; a connection we opened
is disconnected on exit. No Bluetooth power or pairing state is changed.
Add either output explicitly:
```sh
python3 scripts/tracking-on-frame.py heart \
--device AA:BB:CC:DD:EE:FF --panel --seconds 3600 \
--osc 127.0.0.1 9000 --address /avatar/parameters/HeartRate \
--log /home/steamos/heart-session.csv
```
The OSC value is integer BPM. `HeartRate` is a chosen avatar parameter, **not a
built-in VRChat heart-rate feature**; your avatar/receiver must define the
matching parameter. `--address` can select another literal OSC path. The local
panel works without OSC, a log or an avatar integration.
The optional CSV contains only `unix_seconds,bpm`. It is created privately
(mode 0600), refuses existing files/symlinks, and lives **on the Frame** at the
path you specify. Nothing is logged by default, and heart-rate values are not
printed to the terminal. Delete your session file when you no longer need it.
### Checking heart rate against a reference
`scripts/heart-check.py` runs on your computer. `listen` shows our OSC
readings live as they arrive, so you can watch them next to another device:
```sh
python3 scripts/heart-check.py listen --port 9000 --out ours.csv
```
Point the Frame at it with `--osc <your computer's IP> 9000`. `compare` lines
up two recordings by time and reports the mean difference, bias, the share
within ±5 BPM and the delay between them. It passes when the mean difference
is at most 5 BPM, at least 80% of reference readings are matched and nothing
was shown while the sensor reported lost skin contact:
```sh
python3 scripts/heart-check.py compare ours.csv reference.csv
python3 scripts/heart-check.py compare ours.csv ~/Downloads/export.zip
```
The reference can be a CSV (`time,bpm[,flags]`, time in unix seconds or ISO
8601) or an Apple Health export (`export.zip` or `export.xml`). Only heart-rate
records within the recording's time range are read. Everything stays on your
computer.
`scripts/heart-test-strap.swift` turns a Mac into a synthetic strap. It
advertises the standard Heart Rate Service and sends a fixed, known sequence
(8-bit and 16-bit values and a skin-contact loss), printing each sent value, so
`compare` can check that the Frame shows exactly what was sent. It needs
Bluetooth permission for the process that runs it. **Untested on 2026-09-29:**
it compiled, but on this Mac, launched from an agent session, macOS never
delivered a Bluetooth state and no permission prompt appeared, so it never
advertised.
## Pulse from the eye cameras (experimental)
The Frame has no heart-rate sensor. **Verified 2026-09-29** (SteamOS 0.4.1,
build `20260925.6191901`): its sensors are an ambient light/proximity sensor
(`vcnl4000`), a hall sensor (`als31300`), two passthrough cameras
(`arcimx616`), two tracking cameras (`og01a1b`) and two IR eye cameras
(`og0ve10`). There is no optical heart-rate (PPG) sensor.
The experiment asks whether the eye cameras can see a pulse anyway. With each
heartbeat, the blood volume in the skin around the eye changes slightly and
its IR reflectance changes with it. This is camera-based photoplethysmography;
near-IR works, though the signal is weaker than in green light.
```sh
python3 scripts/tracking-on-frame.py pulse --seconds 60 --show
```
How it works:
- **Capture (verified).** SteamVR ships `eyetracking --calib N`, which saves
both eye cameras for N seconds as 400×400 8-bit IR PNGs with a monotonic
timestamp per frame, at about 90 fps per eye. SteamVR's live eye tracker,
part of `steamvr.service`, gets its frames from the DSP and stops its
cameras when the headset is off. Unworn captures ran alongside it: its PID
and log were unchanged and our OpenXR gaze session still started
afterwards. **Untested:** whether the capture and the live tracker coexist
while the headset is worn and tracking.
- **Privacy.** Each image is reduced to a 16×16 grid of patch averages as
soon as it is complete, then deleted. Three worker processes do this beside
the capture. If more than 900 images (about five seconds) ever wait, we stop
reading them and delete them undecoded until the capture ends, and report
an error. The capture directory is removed on exit, even after errors. No image is kept or leaves the Frame. The estimate
is printed only with `--show`, and sent or saved only with `--osc` or
`--log`, as for the strap.
- **Estimate.** Patch traces are averaged down to 15 Hz and turned into
relative change. A 2-second moving median removes drift and blinks.
Patches with frequent spikes (the eyeball and eyelid) are dropped, as are
dark or saturated ones. The 20% of patches with the clearest rhythm between
42 and 180 BPM are combined in the frequency domain. Output is an overall
estimate plus one estimate per second over 15-second windows. A result
counts as **clear** only when the top patches agree and the combined signal
stands out from the noise. Otherwise the command exits 3 and sends no OSC.
`--log` still records the per-second estimates, so a comparison shows how
far off an unclear result was.
The thresholds are provisional until checked on real wearers.
**Verified on the Frame, unworn, 2026-09-29:** captures of 3,600-5,400 eye
frames never had more than 8 images on disk, finished a few seconds after the
capture ended and left no capture directory. **The estimator alone gave a
false "clear" pulse.** With nobody wearing the headset, five runs reported a
steady, self-consistent rhythm (90, 90, 93, 94 and 96 BPM; patch agreement
100%, signal/noise 0.63-0.70), and earlier runs reported 127-129 BPM at lower
signal/noise. It is a periodic camera or illumination artifact, and its
frequency drifts between runs. A wearer-less scene cannot contain a pulse, so
the signal/noise gate cannot tell this artifact from one. Because of that,
`pulse` reads the Frame's proximity sensor (`vcnl4000`) before and after the
capture. It reads about 3 unworn (**verified**). If either reading is below 20
the result is never called clear, nothing is sent over OSC, and the command
exits 3. **Inferred, unmeasured:** that a worn reading is well above 20; the
cut-off is provisional until someone wears the headset. If the sensor can't be
read, the guard is skipped. Confirming a real pulse also needs a reference
(below).
**Do not interrupt the capture. Verified on the Frame, 2026-09-29:** sending
SIGTERM to `eyetracking --calib` left the DSP service's eye camera (OV6211)
stuck "streaming": its log had no "Stopping streaming" line, and every later
request failed with "Failed to start streaming". Head tracking kept working.
Clearing it needs the DSP service restarted or the Frame rebooted, so `pulse`
never signals the tool. After Ctrl-C or a failure it keeps deleting images
until the tool ends by itself (at most the `--seconds` plus a few seconds),
and only kills a tool that overruns by 30 s, with a warning that the eye
cameras may need a reboot. So an interrupted run can take a while to return.
**Verified on synthetic data** (unit tests): a 0.3% brightness pulse in a
third of the patches, with noise, drift, blinks and eye movement, is
recovered within 1.5 BPM at 58, 72 and 115 BPM; noise and blinks alone are
not reported as a pulse. **Not yet verified:** whether a real wearer's eye
images contain a usable pulse, and how accurate it is. That needs someone
wearing the headset and a reference, as below.
### Comparing with an Apple Watch
1. On the watch, start a workout (for example **Other**) so it measures heart
rate every few seconds rather than occasionally.
2. Put the Frame on, sit still and look ahead. Run:
```sh
python3 scripts/tracking-on-frame.py pulse --seconds 120 --show \
--log /home/steamos/pulse.csv
```
The per-second estimates print at the end. Compare them with what the
watch showed.
3. End the workout. On the iPhone, open Health → your picture → **Export All
Health Data**, and AirDrop `export.zip` to the Mac.
4. On the Mac:
```sh
scp frame:pulse.csv . && ssh frame rm pulse.csv
python3 scripts/heart-check.py compare pulse.csv ~/Downloads/export.zip
```
This first version analyses after the capture ends, because the method must
prove itself before a live panel is worth building. The Apple Watch is a
reference, not ground truth: in workouts it is typically within a few BPM of
a chest strap when you are still.
## SlimeVR: feasibility only
SlimeVR is an independent application stack. Neither of our features installs,
launches or depends on it. Users who want it can follow
[SlimeVR's setup documentation](https://docs.slimevr.dev/server/index.html).
The consented upstream releases tested were
[server v21.1.0](https://github.com/SlimeVR/SlimeVR-Server/releases/tag/v21.1.0)
and [driver v6.0.0](https://github.com/SlimeVR/SlimeVR-OpenVR-Driver/releases/tag/v6.0.0),
under SlimeVR's MIT/Apache-2.0 licensing.
**Verified layout, read-only:** the Frame's registered runtime is `/opt/steamvr`;
its native driver is `drivers/cv/bin/linuxarm64/driver_cv.so`, with a
`drivers/cv/driver.vrdrivermanifest`. Frame controller manifests/resources are
under `drivers/frame_controller/`. Configuration is under
`~/.config/openvr/config/`, not the Steam client's config directory. The
SlimeVR release also uses `slimevr/bin/linuxarm64/driver_slimevr.so` plus its
manifest. Nothing in those installed SteamVR directories was changed.
**Inferred:** the matching ABI/layout and standalone factory success make
SteamVR integration plausible. They do not prove successful driver `Init`,
server/driver IPC, tracking, or calibration. That needs a separate integration
check with hardware and an agreed SteamVR restart. No Java executable was on
PATH for this check, so an isolated JRE was used. SlimeVR's server opens LAN
listeners; our temporary server was stopped and the temporary downloads,
configuration and logs were removed. It is not left installed or running.
## Tests and remaining checks
```sh
python3 -m unittest discover -s tests
```
`tests/test_tracking.py` covers HRS packet parsing, contact/staleness, OSC
padding/types and a real loopback socket, quaternion signs, opt-in networking,
private/exclusive logging and a fake BlueZ object tree. The fake checks service
ownership, notification routing, delayed GATT discovery and connection cleanup.
It does not pretend to be a physical strap or a real OpenXR runtime.
Before calling hardware support complete, attach a strap and check BPM against
its own display/reference, loss of contact, disconnect/reconnect, Stop, OSC and
CSV together. Check avatar eyes while looking up/down/left/right in a supported
VRChat session. No third-party tracking app is needed for either test.
Independent review attempt: `devin -p --model swe-2-max` with the frozen diff,
contribution standards and read-only instructions returned no output for ten
minutes. It was terminated with exit 143. No completed review or actual model
identity was returned; hardware checks and independent review remain follow-up
work before making the draft ready.
+1 -1
View File
@@ -26,7 +26,7 @@ it twice reuses the existing process.
| 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`. |
| Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. In the headset HUD only: the app shows them once, in the battery menu at the top. |
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.
-135
View File
@@ -1,135 +0,0 @@
/* Frame Control's OpenXR gaze source. No values on stdout/stderr or disk.
* The Python bridge supplies a private pipe with --fd; standalone probes only
* report counters. Uses a headless session, never submits frames or takes focus. */
#define XR_USE_TIMESPEC
#include <time.h>
#include <openxr/openxr.h>
#include <openxr/openxr_platform.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
static volatile sig_atomic_t stopped;
static void stop(int sig) { (void)sig; stopped = 1; }
#define CHECK(call) do { result = (call); if (XR_FAILED(result)) { \
fprintf(stderr, "%s failed (%d)\n", #call, result); goto cleanup; } } while (0)
int main(int argc, char **argv) {
int seconds = 10, fd = -1, running = 0, rc = 1;
unsigned active = 0, valid = 0, samples = 0;
for (int i = 1; i < argc; i++) {
if (!strcmp(argv[i], "--seconds") && i+1 < argc) seconds = atoi(argv[++i]);
else if (!strcmp(argv[i], "--fd") && i+1 < argc) fd = atoi(argv[++i]);
else { fprintf(stderr, "usage: gaze [--seconds 1..86400] [--fd private-pipe]\n"); return 2; }
}
if (seconds < 1 || seconds > 86400 || (fd != -1 && fd < 3)) return 2;
FILE *out = fd == -1 ? NULL : fdopen(fd, "w");
if (fd != -1 && !out) return 2;
signal(SIGINT, stop); signal(SIGTERM, stop); signal(SIGHUP, stop); signal(SIGPIPE, SIG_IGN);
XrResult result;
XrInstance instance = XR_NULL_HANDLE;
XrSession session = XR_NULL_HANDLE;
XrActionSet set = XR_NULL_HANDLE;
XrSpace gaze = XR_NULL_HANDLE, view = XR_NULL_HANDLE;
const char *extensions[] = {"XR_EXT_eye_gaze_interaction", "XR_MND_headless", "XR_KHR_convert_timespec_time"};
XrInstanceCreateInfo create = {.type = XR_TYPE_INSTANCE_CREATE_INFO};
strcpy(create.applicationInfo.applicationName, "Frame Control gaze");
create.applicationInfo.apiVersion = XR_MAKE_VERSION(1, 0, 0);
create.enabledExtensionCount = 3; create.enabledExtensionNames = extensions;
CHECK(xrCreateInstance(&create, &instance));
XrSystemGetInfo get = {.type = XR_TYPE_SYSTEM_GET_INFO, .formFactor = XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY};
XrSystemId system;
CHECK(xrGetSystem(instance, &get, &system));
XrSystemEyeGazeInteractionPropertiesEXT eye = {.type = XR_TYPE_SYSTEM_EYE_GAZE_INTERACTION_PROPERTIES_EXT};
XrSystemProperties props = {.type = XR_TYPE_SYSTEM_PROPERTIES, .next = &eye};
CHECK(xrGetSystemProperties(instance, system, &props));
printf("supportsEyeGazeInteraction=%u\n", eye.supportsEyeGazeInteraction);
if (!eye.supportsEyeGazeInteraction) goto cleanup;
XrSessionCreateInfo sc = {.type = XR_TYPE_SESSION_CREATE_INFO, .systemId = system};
CHECK(xrCreateSession(instance, &sc, &session));
XrActionSetCreateInfo asc = {.type = XR_TYPE_ACTION_SET_CREATE_INFO};
strcpy(asc.actionSetName, "gaze"); strcpy(asc.localizedActionSetName, "Gaze");
CHECK(xrCreateActionSet(instance, &asc, &set));
XrActionCreateInfo ac = {.type = XR_TYPE_ACTION_CREATE_INFO, .actionType = XR_ACTION_TYPE_POSE_INPUT};
strcpy(ac.actionName, "gaze_pose"); strcpy(ac.localizedActionName, "Gaze pose");
XrAction action;
CHECK(xrCreateAction(set, &ac, &action));
XrPath profile, input;
CHECK(xrStringToPath(instance, "/interaction_profiles/ext/eye_gaze_interaction", &profile));
CHECK(xrStringToPath(instance, "/user/eyes_ext/input/gaze_ext/pose", &input));
XrActionSuggestedBinding binding = {action, input};
XrInteractionProfileSuggestedBinding suggested = {.type = XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING,
.interactionProfile = profile, .countSuggestedBindings = 1, .suggestedBindings = &binding};
CHECK(xrSuggestInteractionProfileBindings(instance, &suggested));
XrSessionActionSetsAttachInfo attach = {.type = XR_TYPE_SESSION_ACTION_SETS_ATTACH_INFO, .countActionSets = 1, .actionSets = &set};
CHECK(xrAttachSessionActionSets(session, &attach));
XrActionSpaceCreateInfo space = {.type = XR_TYPE_ACTION_SPACE_CREATE_INFO, .action = action, .poseInActionSpace.orientation.w = 1};
CHECK(xrCreateActionSpace(session, &space, &gaze));
XrReferenceSpaceCreateInfo ref = {.type = XR_TYPE_REFERENCE_SPACE_CREATE_INFO, .referenceSpaceType = XR_REFERENCE_SPACE_TYPE_VIEW,
.poseInReferenceSpace.orientation.w = 1};
CHECK(xrCreateReferenceSpace(session, &ref, &view));
PFN_xrConvertTimespecTimeToTimeKHR convert;
CHECK(xrGetInstanceProcAddr(instance, "xrConvertTimespecTimeToTimeKHR", (PFN_xrVoidFunction *)&convert));
struct timespec start, now;
clock_gettime(CLOCK_MONOTONIC, &start);
while (!stopped) {
clock_gettime(CLOCK_MONOTONIC, &now);
if (now.tv_sec - start.tv_sec >= seconds) break;
XrEventDataBuffer event = {.type = XR_TYPE_EVENT_DATA_BUFFER};
while ((result = xrPollEvent(instance, &event)) == XR_SUCCESS) {
if (event.type == XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED) {
XrSessionState state = ((XrEventDataSessionStateChanged *)&event)->state;
printf("sessionState=%d\n", state); fflush(stdout);
if (state == XR_SESSION_STATE_READY && !running) {
XrSessionBeginInfo begin = {.type = XR_TYPE_SESSION_BEGIN_INFO, .primaryViewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO};
CHECK(xrBeginSession(session, &begin)); running = 1;
} else if (state == XR_SESSION_STATE_STOPPING) {
CHECK(xrEndSession(session)); running = 0; stopped = 1;
} else if (state == XR_SESSION_STATE_EXITING || state == XR_SESSION_STATE_LOSS_PENDING) stopped = 1;
} else if (event.type == XR_TYPE_EVENT_DATA_INSTANCE_LOSS_PENDING) stopped = 1;
event.type = XR_TYPE_EVENT_DATA_BUFFER;
}
if (XR_FAILED(result)) goto cleanup;
if (running && !stopped) {
XrActiveActionSet activeSet = {set, XR_NULL_PATH};
XrActionsSyncInfo sync = {.type = XR_TYPE_ACTIONS_SYNC_INFO, .countActiveActionSets = 1, .activeActionSets = &activeSet};
CHECK(xrSyncActions(session, &sync));
if (result != XR_SUCCESS) {
struct timespec delay = {.tv_nsec = 33333333};
nanosleep(&delay, NULL);
continue; /* No stale gaze when the runtime denies focus. */
}
XrActionStateGetInfo ag = {.type = XR_TYPE_ACTION_STATE_GET_INFO, .action = action};
XrActionStatePose pose = {.type = XR_TYPE_ACTION_STATE_POSE};
CHECK(xrGetActionStatePose(session, &ag, &pose));
samples++;
if (pose.isActive) {
active++;
XrTime time; CHECK(convert(instance, &now, &time));
XrSpaceLocation location = {.type = XR_TYPE_SPACE_LOCATION};
CHECK(xrLocateSpace(gaze, view, time, &location));
XrSpaceLocationFlags needed = XR_SPACE_LOCATION_ORIENTATION_VALID_BIT | XR_SPACE_LOCATION_ORIENTATION_TRACKED_BIT;
if ((location.locationFlags & needed) == needed) {
valid++;
if (out) {
XrQuaternionf q = location.pose.orientation;
if (fprintf(out, "%g %g %g %g\n", q.x, q.y, q.z, q.w) < 0 || fflush(out)) goto cleanup;
}
}
}
}
struct timespec delay = {.tv_nsec = 33333333}; nanosleep(&delay, NULL);
}
printf("samples=%u active=%u valid=%u\n", samples, active, valid);
rc = valid ? 0 : 3; /* Distinguish a working session from observed gaze. */
cleanup:
if (view) xrDestroySpace(view);
if (gaze) xrDestroySpace(gaze);
if (session) xrDestroySession(session);
if (set) xrDestroyActionSet(set);
if (instance) xrDestroyInstance(instance);
if (out) fclose(out);
return rc;
}
-481
View File
@@ -1,481 +0,0 @@
"""Experimental pulse estimate from the Frame's IR eye-tracking cameras.
Skin brightens and darkens very slightly with each heartbeat as blood volume
changes (photoplethysmography). The eye cameras film the skin around each eye
under steady IR light at about 90 frames per second, so that rhythm may be
visible in the average brightness of small patches of skin.
Capture uses SteamVR's own `eyetracking --calib` mode, which writes PNG pairs
to /tmp. Each image is reduced to a grid of patch averages the moment it is
complete, then deleted; the capture directory is removed on exit. No image
leaves the Frame or outlives the run. This is an experiment, not a medical
measurement.
"""
from array import array
import bisect
import cmath
import json
import math
import os
from pathlib import Path
import re
import shutil
import subprocess
import time
ET_DIR = Path("/opt/steamvr/tools/eyetracking")
ET_BIN = ET_DIR / "bin/linuxarm64/eyetracking"
ET_WEIGHTS = ET_DIR / "resources/et_dsp_20250610_03136.weights"
GRID = 16 # 16 × 16 patches of 25 × 25 pixels on the 400 × 400 image
RATE = 15.0 # analysis sample rate, Hz; the band of interest ends at 3 Hz
LOW, HIGH = 42.0, 180.0 # BPM search band
# The Frame's proximity sensor (vcnl4000) reads about 3 with nobody wearing it.
# A worn reading has not been measured yet, so the cut-off is provisional.
IIO = Path("/sys/bus/iio/devices")
WORN_MIN = 20.0
WINDOW = 15.0 # seconds per windowed estimate
# ---------------------------------------------------------------- capture ---
def grid_means(pixels, width, height, stride, channels, grid=GRID):
"""Average of channel 0 in each of grid × grid equal patches."""
bw, bh = width // grid, height // grid
sums = [0] * (grid * grid)
for y in range(bh * grid):
start = y * stride
row = pixels[start:start + width * channels:channels]
base = (y // bh) * grid
for bx in range(grid):
sums[base + bx] += sum(row[bx * bw:(bx + 1) * bw])
area = bw * bh
return [s / area for s in sums]
def load_grid(path):
import gi
gi.require_version("GdkPixbuf", "2.0")
from gi.repository import GdkPixbuf
image = GdkPixbuf.Pixbuf.new_from_file(str(path))
return grid_means(image.read_pixel_bytes().get_data(), image.get_width(), image.get_height(),
image.get_rowstride(), image.get_n_channels())
def reduce_file(loader, path):
"""Reduce one image to its patch grid and delete it, whatever happens."""
try:
return loader(path)
finally:
os.unlink(path)
class Capture:
"""Run SteamVR's eye-camera capture and reduce frames as they arrive."""
NAME = re.compile(r"^(left|right)_(\d+)\.png$")
# Only SteamVR's own capture directories are read and removed.
PREFIX = "/tmp/etcalib_"
# Printed by the capture tool; its output is only flushed when it exits.
WRITING = re.compile(r"Writing capture to: (\S+)")
# About five seconds of frames (~65 MB in RAM-backed /tmp). If reduction
# falls further behind than this, the capture stops rather than letting
# eye images pile up.
MAX_BACKLOG = 900
def __init__(self, seconds, runner=subprocess.Popen, loader=load_grid, workers=3):
self.seconds, self.runner, self.loader, self.workers = seconds, runner, loader, workers
self.grids = {"left": {}, "right": {}}
self.directory = None
self.pool = None
self.submitted = set()
self.futures = {}
self.new = set() # every capture directory that appeared during our run
self.before = None # capture directories that existed before the run
self.log = None
def candidates(self):
parent, stem = os.path.split(self.PREFIX)
return {Path(entry.path) for entry in os.scandir(parent)
if entry.name.startswith(stem) and entry.is_dir(follow_symlinks=False)}
def run(self):
# The capture tool's output goes to a private temporary file, read for
# failure messages. It is block-buffered, so the capture directory is
# found by watching for a new one rather than waiting for its name.
import tempfile
self.before = before = self.candidates()
process = None
with tempfile.TemporaryFile("w+") as log:
self.log = log
try:
if self.workers:
# SteamVR pins its eye tracker to cores 0-1; decoding runs beside it.
import concurrent.futures
import multiprocessing
self.pool = concurrent.futures.ProcessPoolExecutor(
self.workers, mp_context=multiprocessing.get_context("fork"))
process = self.runner([str(ET_BIN), "-b", "CDSP", "-w", str(ET_WEIGHTS), "--calib", str(self.seconds)],
cwd=str(ET_BIN.parent), stdout=log, stderr=subprocess.STDOUT)
deadline = time.monotonic() + self.seconds + 30
while True:
# Checked on every poll: a second capture directory means
# we can't tell which images are ours, so stop.
self.new |= self.candidates() - before
if len(self.new) > 1:
raise RuntimeError("another eye-camera capture is running")
if not self.directory and self.new:
self.directory = next(iter(self.new))
finished = process.poll() is not None
self.reduce(final=finished)
if finished:
break
if time.monotonic() > deadline:
raise RuntimeError("eye-camera capture did not finish")
time.sleep(0.02)
log.seek(0)
text = log.read()
if process.returncode or not self.directory:
reason = "cameras unavailable" if "Failed to" in text else f"exit {process.returncode}"
raise RuntimeError(f"eye-camera capture failed ({reason})")
named = self.written(log)
if named and named != self.directory:
raise RuntimeError("the capture wrote somewhere else; not using those images")
return self.frames()
finally:
# Each step runs even if an earlier one failed: eye images must
# be removed whatever else went wrong.
# Ctrl-C and SIGTERM (raised as KeyboardInterrupt) are held
# until the images are gone, then re-raised.
interrupted, failed = None, None
for step in (lambda: self.finish(process),
lambda: self.pool and self.pool.shutdown(wait=True, cancel_futures=True),
self.adopt_reported):
try:
step()
except BaseException as error:
if isinstance(error, Exception):
failed = failed or error
else:
interrupted = interrupted or error
self.log = None
self.remove()
if interrupted:
raise interrupted
if failed:
raise RuntimeError(f"the eye-camera capture did not stop cleanly: {failed}")
def finish(self, process):
"""Let the capture tool end by itself, deleting its images meanwhile.
Never signal it: stopping the tool early leaves the headset's eye
camera stuck streaming until the DSP service restarts (verified on the
Frame with SIGTERM). Its run is bounded by `seconds`, so waiting is
short. Only if it overruns by a wide margin is it killed."""
if not process:
return
interrupted = None
give_up = time.monotonic() + self.seconds + 30
while True:
try:
if process.poll() is not None:
break
self.discard()
if time.monotonic() > give_up:
process.kill()
process.wait()
raise RuntimeError("the eye-camera capture had to be killed; "
"the eye cameras may need a reboot to stream again")
time.sleep(0.05)
except KeyboardInterrupt as error: # keep cleaning up until it ends
interrupted = interrupted or error
if interrupted:
raise interrupted
def discard(self):
"""Delete the eye images written so far, without reading them."""
if self.before is not None:
self.new |= self.candidates() - self.before
for directory in self.new | ({self.directory} if self.directory else set()):
if str(directory).startswith(self.PREFIX) and directory.is_dir() and not directory.is_symlink():
for entry in os.scandir(directory):
if self.NAME.match(entry.name):
try:
os.unlink(entry.path)
except OSError:
pass
def adopt_reported(self):
"""The directory the tool reported is ours by its own account."""
named = self.written(self.log)
if named and str(named).startswith(self.PREFIX):
self.new.add(named)
def written(self, log):
"""The directory the capture tool reported, once its output is flushed."""
log.seek(0)
match = self.WRITING.search(log.read())
return Path(match.group(1)) if match else None
def reduce(self, final=False):
"""Reduce and delete every complete image; an image is complete once a
later one of the same eye exists, or the capture has ended."""
if not self.directory or not self.directory.is_dir():
return
pending = {"left": [], "right": []}
for entry in os.scandir(self.directory):
match = self.NAME.match(entry.name)
if match:
pending[match.group(1)].append((int(match.group(2)), entry.path))
if sum(len(files) for files in pending.values()) > self.MAX_BACKLOG:
raise RuntimeError("eye-image processing fell behind; capture abandoned")
for eye, files in pending.items():
files.sort()
ready = files if final else files[:-1]
for index, path in ready:
if path in self.submitted:
continue
self.submitted.add(path)
if self.pool:
self.futures[(eye, index)] = self.pool.submit(reduce_file, self.loader, path)
else:
self.grids[eye][index] = array("f", reduce_file(self.loader, path))
for key, future in list(self.futures.items()):
if final or future.done():
self.grids[key[0]][key[1]] = array("f", future.result())
del self.futures[key]
def frames(self):
"""[(monotonic seconds, eye, grid)] joined with the capture metadata."""
meta = json.loads((self.directory / "meta.json").read_text())
frames = []
for pair in meta["frames"]:
for eye in ("left", "right"):
info = pair.get(eye) or {}
match = self.NAME.match(Path(info.get("fname", "")).name)
grid = self.grids[eye].get(int(match.group(2))) if match else None
if info.get("valid") and grid is not None:
frames.append((float(info["tsMono"]), eye, grid))
return frames
def remove(self):
"""Remove every capture directory that appeared during the run. Eye
images must not outlive it, so a failure to delete is an error."""
left = []
if self.before is not None:
try:
self.new |= self.candidates() - self.before # even if interrupted before the first poll
except OSError:
pass
for directory in self.new | ({self.directory} if self.directory else set()):
if str(directory).startswith(self.PREFIX) and directory.is_dir() and not directory.is_symlink():
try:
shutil.rmtree(directory)
except OSError:
left.append(str(directory))
if left:
raise RuntimeError("could not delete eye images in " + ", ".join(sorted(left)))
# --------------------------------------------------------------- analysis ---
def fft(values):
"""In-place iterative radix-2 FFT of a list whose length is a power of 2."""
n = len(values)
a = list(values)
j = 0
for i in range(1, n):
bit = n >> 1
while j & bit:
j ^= bit
bit >>= 1
j |= bit
if i < j:
a[i], a[j] = a[j], a[i]
size = 2
while size <= n:
step = cmath.exp(-2j * math.pi / size)
half = size // 2
for start in range(0, n, size):
w = 1
for k in range(start, start + half):
t = w * a[k + half]
a[k + half] = a[k] - t
a[k] += t
w *= step
size *= 2
return a
def resample(times, values, rate=RATE):
"""Average samples into 1/rate bins from the first sample; empty bins are
filled from the previous bin."""
if not times:
return []
start = times[0]
count = int((times[-1] - start) * rate) + 1
sums, counts = [0.0] * count, [0] * count
for t, v in zip(times, values):
i = min(int((t - start) * rate), count - 1)
sums[i] += v
counts[i] += 1
out, last = [], None
for s, c in zip(sums, counts):
last = s / c if c else last
out.append(last)
first = next(v for v in out if v is not None)
return [first if v is None else v for v in out]
def clean(signal, rate=RATE):
"""Relative change with slow drift removed; None if the patch is mostly
blinks or eye movement. Spikes (blinks, saccades) become gaps at the
local level rather than clipped steps, which would add false rhythm."""
mean = sum(signal) / len(signal)
x = [v / mean - 1 for v in signal]
half = int(rate) # 2-second centred moving median removes drift and blinks
trend = []
for i in range(len(x)):
chunk = sorted(x[max(0, i - half):i + half + 1])
trend.append(chunk[len(chunk) // 2])
residual = [v - m for v, m in zip(x, trend)]
mad = sorted(abs(v) for v in residual)[len(residual) // 2] or 1e-9
limit = 4 * 1.4826 * mad
spikes = sum(1 for v in residual if abs(v) > limit)
if spikes > 0.05 * len(residual):
return None
return [v if abs(v) <= limit else 0.0 for v in residual]
def spectrum(signal, rate=RATE):
"""[(bpm, power)] within the search band, Hann-windowed and zero-padded."""
n = len(signal)
size = 1
while size < max(n * 4, 256):
size *= 2
window = [0.5 - 0.5 * math.cos(2 * math.pi * i / (n - 1)) for i in range(n)] if n > 1 else [1.0]
padded = [s * w for s, w in zip(signal, window)] + [0.0] * (size - n)
result = fft(padded)
out = []
for k in range(size // 2):
bpm = k * rate / size * 60
if LOW <= bpm <= HIGH:
out.append((bpm, abs(result[k]) ** 2))
return out
def peak(spec):
"""Peak BPM with parabolic interpolation between spectral bins."""
i = max(range(len(spec)), key=lambda k: spec[k][1])
if 0 < i < len(spec) - 1:
a, b, c = spec[i - 1][1], spec[i][1], spec[i + 1][1]
denominator = a - 2 * b + c
offset = 0.5 * (a - c) / denominator if denominator else 0.0
return spec[i][0] + offset * (spec[1][0] - spec[0][0])
return spec[i][0]
def snr(spec, bpm, width=4.0):
"""Power near the pulse and its first harmonic against the rest of the band."""
near = sum(p for f, p in spec if abs(f - bpm) <= width or abs(f - 2 * bpm) <= width)
rest = sum(p for f, p in spec) - near
if near <= 0:
return 0.0
return near / rest if rest > 0 else float("inf")
def normalised(spec):
total = sum(p for _, p in spec) or 1.0
return [p / total for _, p in spec]
def usable(values):
"""Patches that are neither dark nor saturated for the whole recording."""
mean = sum(values) / len(values)
return 8 <= mean <= 245
def estimate(times, patches, rate=RATE, share=0.2, window=WINDOW):
"""Estimate pulse from patch brightness traces.
times: monotonic seconds per frame. patches: {name: [brightness per frame]}.
Selects the share of usable patches with the clearest periodic signal,
combines their spectra and reports the overall and windowed estimates.
"""
cleaned = {}
for name, values in patches.items():
if usable(values):
signal = clean(resample(times, values, rate), rate)
if signal is not None and any(signal): # flat patches carry no rhythm
cleaned[name] = signal
if not cleaned or len(next(iter(cleaned.values()))) < rate * 8:
raise ValueError("need at least 8 seconds of usable eye-camera frames")
quality = []
for name, signal in cleaned.items():
spec = spectrum(signal, rate)
own = peak(spec)
quality.append((snr(spec, own), name, own, spec))
quality.sort(reverse=True)
chosen = quality[:max(4, int(len(quality) * share))]
combined = [sum(values) for values in zip(*(normalised(spec) for _, _, _, spec in chosen))]
bins = [f for f, _ in chosen[0][3]]
bpm = peak(list(zip(bins, combined)))
top = chosen[:8]
agree = sum(1 for _, _, own, _ in top if abs(own - bpm) <= 5) / len(top)
series = []
samples = int(window * rate)
length = len(next(iter(cleaned.values())))
for end in range(samples, length + 1, int(rate)):
specs = [spectrum(cleaned[name][end - samples:end], rate) for _, name, _, _ in chosen]
total = [sum(values) for values in zip(*(normalised(s) for s in specs))]
window_bins = [f for f, _ in specs[0]]
series.append((times[0] + end / rate, peak(list(zip(window_bins, total)))))
return {
"bpm": bpm,
"agreement": agree,
"patches": len(chosen),
"usable": len(cleaned),
"snr": snr(list(zip(bins, combined)), bpm),
"series": series,
}
def analyse(frames):
"""Estimate from Capture.frames(); both eyes' patches are analysed together
on a shared time base, each sample taken from that eye's nearest frame."""
times = sorted({t for t, _, _ in frames})
patches = {}
for eye in ("left", "right"):
eye_frames = sorted((t, grid) for t, e, grid in frames if e == eye)
if not eye_frames:
continue
eye_times = [t for t, _ in eye_frames]
nearest = []
for t in times:
i = bisect.bisect_left(eye_times, t)
if i == len(eye_times) or (i > 0 and t - eye_times[i - 1] <= eye_times[i] - t):
i -= 1
nearest.append(i)
for k in range(len(eye_frames[0][1])):
patches[f"{eye}{k}"] = [eye_frames[i][1][k] for i in nearest]
return estimate(times, patches)
def proximity(root=None):
"""The headset's proximity reading, or None if it can't be read."""
try:
for device in sorted(Path(root or IIO).glob("iio:device*")):
if (device / "name").read_text().strip() == "vcnl4000":
return float((device / "in_proximity_raw").read_text())
except (OSError, ValueError):
pass
return None
def worn(reading):
"""False only when the sensor says the headset is not on a face."""
return reading is None or reading >= WORN_MIN
def reliable(result):
"""Whether the estimate is clear enough to show as a reading. Thresholds
are provisional until checked against a reference on a real wearer."""
return result["agreement"] >= 0.75 and result["snr"] >= 0.5
-438
View File
@@ -1,438 +0,0 @@
#!/usr/bin/env python3
"""Frame-local tracking tools. No network destination or data log by default."""
import argparse
import math
import os
from pathlib import Path
import signal
import socket
import struct
import subprocess
import time
class TrackingError(RuntimeError):
"""A safe, actionable status message containing no sensor data."""
HRS = "0000180d-0000-1000-8000-00805f9b34fb"
MEASUREMENT = "00002a37-0000-1000-8000-00805f9b34fb"
DEVICE = "org.bluez.Device1"
SERVICE = "org.bluez.GattService1"
CHARACTERISTIC = "org.bluez.GattCharacteristic1"
def heart_rate(data):
"""Validate the Bluetooth HRS measurement, returning BPM/contact only.
Energy and RR intervals are checked for length but never retained.
None means contact is supported and the strap reports no skin contact.
"""
data = bytes(data)
if len(data) < 2 or data[0] & 0xe0:
raise ValueError("invalid HRS measurement")
flags = data[0]
size = 2 if flags & 1 else 1
end = 1 + size + (2 if flags & 8 else 0)
if len(data) < end:
raise ValueError("truncated HRS measurement")
extra = len(data) - end
if (flags & 16 and (extra < 2 or extra % 2)) or (not flags & 16 and extra):
raise ValueError("invalid HRS optional fields")
if flags & 4 and not flags & 2:
return None
bpm = int.from_bytes(data[1:1 + size], "little")
return bpm if bpm else None
def gaze_angles(quaternion):
"""OpenXR head-relative -Z forward → VRChat degrees, down/right positive."""
if len(quaternion) != 4 or not all(math.isfinite(v) for v in quaternion):
raise ValueError("invalid gaze orientation")
norm = math.sqrt(sum(v * v for v in quaternion))
if not 0.9 < norm < 1.1:
raise ValueError("invalid gaze orientation")
x, y, z, w = (v / norm for v in quaternion)
# Rotate OpenXR's forward vector (0, 0, -1) into VIEW space.
dx, dy, dz = -2 * (x*z + w*y), 2 * (w*x - y*z), 2 * (x*x + y*y) - 1
return math.degrees(math.atan2(-dy, math.hypot(dx, dz))), math.degrees(math.atan2(dx, -dz))
def osc_message(address, values):
if not address.startswith("/") or any(c.isspace() or c in '\0#*,?[]{}' for c in address):
raise ValueError("OSC address must be a literal path")
def string(value):
encoded = value.encode("utf-8") + b"\0"
return encoded + b"\0" * (-len(encoded) % 4)
tags, payload = ",", b""
for value in values:
if type(value) is int:
tags += "i"
payload += struct.pack(">i", value)
else:
if not math.isfinite(value):
raise ValueError("OSC value must be finite")
tags += "f"
payload += struct.pack(">f", value)
return string(address) + string(tags) + payload
class Osc:
def __init__(self, endpoint=None):
self.sock = None
self.target = None
if endpoint:
import ipaddress
address = ipaddress.ip_address(endpoint[0])
port = int(endpoint[1])
if address.is_unspecified or address.is_multicast or not 1 <= port <= 65535:
raise ValueError("OSC needs a unicast IP address and port 1..65535")
self.sock = socket.socket(socket.AF_INET6 if address.version == 6 else socket.AF_INET, socket.SOCK_DGRAM)
self.target = (str(address), port)
def send(self, address, values):
if self.sock:
self.sock.sendto(osc_message(address, values), self.target)
def close(self):
if self.sock:
self.sock.close()
class HeartSession:
def __init__(self, osc, address, log=None, clock=time.monotonic):
self.osc, self.address, self.clock = osc, address, clock
self.bpm, self.updated = None, None
self.log = None
if log:
# Exclusive creation refuses existing files and symlinks; mode is
# private even with a permissive process umask.
fd = os.open(log, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
self.log = os.fdopen(fd, "w")
self.log.write("unix_seconds,bpm\n")
def notification(self, data):
self.bpm = heart_rate(data)
self.updated = self.clock()
if self.bpm is not None:
self.osc.send(self.address, [self.bpm])
if self.log:
self.log.write(f"{time.time():.3f},{self.bpm}\n")
self.log.flush()
def current(self):
if self.updated is None or self.clock() - self.updated > 5:
return None
return self.bpm
def close(self):
if self.log:
self.log.close()
class BluezHeart:
"""One explicitly selected, already discovered strap; no ambient scan."""
def __init__(self, bus, interface, address, on_value):
self.bus, self.interface, self.on_value = bus, interface, on_value
self.device = self.characteristic = None
self.connected_here = False
self.notifying = False
self.match = None
objects = self.objects()
matches = [path for path, interfaces in objects.items()
if str(interfaces.get(DEVICE, {}).get("Address", "")).upper() == address.upper()]
if len(matches) != 1:
raise TrackingError("Strap not found uniquely in BlueZ; pair/discover it in SteamOS Bluetooth settings first")
self.device = matches[0]
self.match = bus.add_signal_receiver(self.changed, signal_name="PropertiesChanged",
dbus_interface="org.freedesktop.DBus.Properties",
bus_name="org.bluez", path_keyword="path")
try:
if not objects[self.device][DEVICE].get("Connected"):
self.call(self.device, DEVICE).Connect(timeout=20)
self.connected_here = True
except Exception:
self.close()
raise
def objects(self):
return self.call("/", "org.freedesktop.DBus.ObjectManager").GetManagedObjects()
def call(self, path, kind):
return self.interface(self.bus.get_object("org.bluez", path), kind)
def subscribe(self):
objects = self.objects()
if not objects.get(self.device, {}).get(DEVICE, {}).get("ServicesResolved"):
return False
services = {p for p, obj in objects.items() if str(obj.get(SERVICE, {}).get("UUID", "")).lower() == HRS
and obj[SERVICE].get("Device") == self.device}
for path, obj in objects.items():
props = obj.get(CHARACTERISTIC, {})
if props.get("Service") in services and str(props.get("UUID", "")).lower() == MEASUREMENT:
if "notify" not in props.get("Flags", []):
raise TrackingError("Heart-rate characteristic does not support notifications")
self.characteristic = path
self.call(path, CHARACTERISTIC).StartNotify()
self.notifying = True
return True
raise TrackingError("Selected device has no standard Heart Rate Service measurement")
def changed(self, kind, changes, invalidated, path=None):
if kind == CHARACTERISTIC and path == self.characteristic and "Value" in changes:
self.on_value(changes["Value"])
elif kind == DEVICE and path == self.device and "Connected" in changes and not changes["Connected"]:
self.on_value(None)
def close(self):
try:
if self.notifying:
self.call(self.characteristic, CHARACTERISTIC).StopNotify()
finally:
if self.match:
self.match.remove()
if self.connected_here:
self.call(self.device, DEVICE).Disconnect()
def run_gaze(args, osc):
binary = Path(__file__).with_name("gaze")
command = [str(binary), "--seconds", str(args.seconds)]
if not args.osc:
return subprocess.call(command)
read_fd, write_fd = os.pipe()
process = None
try:
process = subprocess.Popen(command + ["--fd", str(write_fd)], pass_fds=(write_fd,))
os.close(write_fd)
write_fd = None
with os.fdopen(read_fd) as source:
read_fd = None
for line in source:
try:
angles = gaze_angles([float(v) for v in line.split()])
except ValueError:
continue
osc.send("/tracking/eye/CenterPitchYaw", angles)
return process.wait()
finally:
if read_fd is not None:
os.close(read_fd)
if write_fd is not None:
os.close(write_fd)
if process and process.poll() is None:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
class HeartPanel:
"""Our GTK panel, using the Frame's existing GTK4/GI platform libraries."""
def __init__(self):
os.environ["GDK_BACKEND"] = "x11"
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("GdkX11", "4.0")
from gi.repository import Gtk, Gdk, GdkX11, GLib
Gtk.init()
self.running = True
self.window = Gtk.Window(title="Frame Control · Heart rate")
self.window.set_default_size(480, 320)
self.window.connect("close-request", self.stop)
Gtk.Settings.get_default().set_property("gtk-application-prefer-dark-theme", True)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
box.set_valign(Gtk.Align.CENTER)
box.set_halign(Gtk.Align.CENTER)
box.set_size_request(440, -1)
for side in ("top", "bottom", "start", "end"):
getattr(box, "set_margin_" + side)(24)
self.window.set_child(box)
title = Gtk.Label(label="Heart rate")
title.add_css_class("title-2")
box.append(title)
self.reading = Gtk.Label(label="—")
self.reading.add_css_class("reading")
box.append(self.reading)
self.status = Gtk.Label(label="Waiting for strap")
box.append(self.status)
button = Gtk.Button(label="Stop")
button.connect("clicked", self.stop)
box.append(button)
css = Gtk.CssProvider()
css.load_from_data(b".reading { font-size: 144px; font-weight: 700; }")
Gtk.StyleContext.add_provider_for_display(Gdk.Display.get_default(), css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
self.window.present()
context = GLib.MainContext.default()
while context.pending():
context.iteration(False)
try:
xid = GdkX11.X11Surface.get_xid(self.window.get_surface())
subprocess.run(["xprop", "-id", str(xid), "-f", "STEAM_GAME", "32c", "-set", "STEAM_GAME", "2000000027"],
check=True, stdout=subprocess.DEVNULL)
except Exception:
self.window.destroy()
raise
def stop(self, *args):
self.running = False
return True
def update(self, bpm):
self.reading.set_label(str(bpm) if bpm is not None else "—")
self.status.set_label("beats per minute" if bpm is not None else "Waiting for strap")
def close(self):
self.window.destroy()
def run_heart(args, osc):
import dbus
from dbus.mainloop.glib import DBusGMainLoop
from gi.repository import GLib
DBusGMainLoop(set_as_default=True)
session = HeartSession(osc, args.address, args.log)
reader, root = None, None
failure = []
def value(data):
if data is None:
session.bpm = None
failure.append("Strap disconnected; reconnect and start again")
return
try:
session.notification(data)
except ValueError:
session.bpm = None
except OSError:
failure.append("OSC or session log write failed")
try:
reader = BluezHeart(dbus.SystemBus(), dbus.Interface, args.device, value)
context = GLib.MainContext.default()
deadline = time.monotonic() + 20
while not reader.subscribe():
if time.monotonic() > deadline:
raise TrackingError("Timed out waiting for the strap's GATT services")
while context.pending():
context.iteration(False)
time.sleep(0.1)
end = time.monotonic() + args.seconds
print("Heart-rate notifications started; readings stay local unless OSC or a log was selected.")
if args.panel:
root = HeartPanel()
running = lambda: root.running if root else True
while running() and time.monotonic() < end and not failure:
while context.pending():
context.iteration(False)
if root:
root.update(session.current())
time.sleep(0.05)
if failure:
raise TrackingError(failure[0])
return 0
finally:
if root:
root.close()
try:
if reader:
reader.close()
finally:
session.close()
def run_pulse(args, osc):
"""Experimental: estimate pulse from the IR eye cameras (see pulse.py)."""
import pulse
if not pulse.ET_BIN.exists():
raise TrackingError("SteamVR's eye-tracking tool is not installed on this Frame")
print(f"Capturing {args.seconds} s from the eye cameras. Wear the headset and keep still.", flush=True)
readings = [pulse.proximity()]
try:
frames = pulse.Capture(args.seconds).run()
readings.append(pulse.proximity())
except RuntimeError as error: # capture status only; no image data
raise TrackingError(str(error))
print(f"Captured {len(frames)} eye frames; images already deleted. Analysing...", flush=True)
try:
result = pulse.analyse(frames)
except ValueError as error:
raise TrackingError(str(error))
on_face = all(pulse.worn(reading) for reading in readings)
if not on_face:
# Unworn, the cameras still show a steady periodic artifact that looks
# like a pulse (verified on the Frame), so it is never called clear.
print("The proximity sensor says the headset is not being worn; no pulse can be read.")
clear = on_face and pulse.reliable(result)
offset = time.time() - time.monotonic() # the capture's timestamps are CLOCK_MONOTONIC
if args.log:
fd = os.open(args.log, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w") as log:
log.write("unix_seconds,bpm\n")
for when, bpm in result["series"]:
log.write(f"{when + offset:.3f},{bpm:.1f}\n")
if clear:
osc.send(args.address, [round(result["bpm"])])
if args.show:
print(f"Estimate: {result['bpm']:.1f} BPM ({'clear' if clear else 'NOT clear'}; "
f"patch agreement {result['agreement']:.0%}, signal/noise {result['snr']:.2f}, "
f"{result['patches']} of {result['usable']} usable patches)")
print("Per-second estimates (15 s windows): "
+ " ".join(str(round(bpm)) for _, bpm in result["series"]))
else:
print("A clear pulse was found." if clear else "No clear pulse was found.")
return 0 if clear else 3
def main():
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
gaze = commands.add_parser("gaze", help="headless OpenXR; prints counters only without --osc")
heart = commands.add_parser("heart", help="standard BLE HRS from an explicitly selected strap")
pulse = commands.add_parser("pulse", help="experimental pulse estimate from the IR eye cameras")
for command in (gaze, heart, pulse):
command.add_argument("--osc", nargs=2, metavar=("IP", "PORT"), help="explicit UDP destination; no default")
for command in (gaze, heart):
command.add_argument("--seconds", type=int, default=10, help="bounded run, 1..86400 seconds (default: 10)")
pulse.add_argument("--seconds", type=int, default=60, help="capture length, 20..300 seconds (default: 60)")
heart.add_argument("--device", required=True, help="strap Bluetooth address already discovered by BlueZ")
heart.add_argument("--panel", action="store_true", help="show our panel on gamescope DISPLAY=:0")
for command in (heart, pulse):
command.add_argument("--address", default="/avatar/parameters/HeartRate", help="integer BPM OSC parameter")
command.add_argument("--log", help="new private CSV file; disabled by default")
pulse.add_argument("--show", action="store_true", help="print the estimate and per-second series")
args = parser.parse_args()
if not 1 <= args.seconds <= 86400:
parser.error("--seconds must be 1..86400")
if args.command == "pulse" and not 20 <= args.seconds <= 300:
parser.error("pulse --seconds must be 20..300")
def interrupted(signum, frame):
raise KeyboardInterrupt
signal.signal(signal.SIGTERM, interrupted)
if hasattr(signal, "SIGHUP"):
signal.signal(signal.SIGHUP, interrupted)
osc = None
try:
if args.command in ("heart", "pulse"):
osc_message(args.address, [0])
if getattr(args, "panel", False):
os.environ["DISPLAY"] = ":0"
osc = Osc(args.osc)
run = {"gaze": run_gaze, "heart": run_heart, "pulse": run_pulse}[args.command]
return run(args, osc)
except TrackingError as error:
print(str(error))
return 1
except KeyboardInterrupt:
return 130
except Exception as error:
# Never dump notifications, gaze, BLE addresses or exception payloads.
print(f"Tracking stopped ({type(error).__name__}). Check the device, runtime and selected output.")
return 1
finally:
if osc:
osc.close()
if __name__ == "__main__":
raise SystemExit(main())
-274
View File
@@ -1,274 +0,0 @@
#!/usr/bin/env python3
"""Check Frame Control's heart-rate readings against a reference, on your computer.
python3 scripts/heart-check.py listen --port 9000 --out ours.csv
python3 scripts/heart-check.py compare ours.csv reference.csv
python3 scripts/heart-check.py compare ours.csv ~/Downloads/export.zip
`listen` receives our integer-BPM OSC messages (send them here with
`tracking-on-frame.py heart --osc <this computer's IP> 9000`) and shows each
reading live, so you can watch it next to a reference such as your Apple
Watch. `--out` also records `unix_seconds,bpm` to a new private file.
`compare` lines up two recordings by time and reports how far apart they are.
The reference can be:
- a CSV whose first column is a time (unix seconds or ISO 8601) and whose
second is BPM, such as our own log, `listen --out`, or the test strap's
output (a third `flags` column marks skin-contact loss as "no reading");
- an Apple Health export (`export.zip` or `export.xml`, from Health → your
profile → Export All Health Data). Only heart-rate records within the
recording's time range are read.
Everything stays on this computer. Nothing is uploaded.
"""
import argparse
import bisect
import csv
from datetime import datetime
import io
import os
from pathlib import Path
import re
import socket
import struct
import sys
import time
import zipfile
from xml.etree import ElementTree
ADDRESS = "/avatar/parameters/HeartRate"
def parse_osc(packet):
"""Return (address, values) for a single OSC message with i/f arguments."""
def string(offset):
end = packet.index(b"\0", offset)
return packet[offset:end].decode("utf-8"), (end + 4) & ~3
address, offset = string(0)
tags, offset = string(offset)
if not tags.startswith(","):
raise ValueError("not an OSC message")
values = []
for tag in tags[1:]:
if tag not in "if" or offset + 4 > len(packet):
raise ValueError("unsupported OSC argument")
values.append(struct.unpack(">i" if tag == "i" else ">f", packet[offset:offset + 4])[0])
offset += 4
return address, values
def listen(port, address, out, seconds, clock=time.time, stream=sys.stdout):
log = None
if out:
log = os.fdopen(os.open(out, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w")
log.write("unix_seconds,bpm\n")
received = 0
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.bind(("0.0.0.0", port))
sock.settimeout(0.5)
print(f"Listening for {address} on UDP port {port}. Ctrl-C stops.", file=stream, flush=True)
end = clock() + seconds if seconds else None
try:
while end is None or clock() < end:
try:
packet = sock.recv(1024)
except socket.timeout:
continue
try:
path, values = parse_osc(packet)
except (ValueError, UnicodeDecodeError):
continue
if path != address or len(values) != 1:
continue
now = clock()
bpm = int(values[0])
received += 1
print(f"{time.strftime('%H:%M:%S', time.localtime(now))} {bpm:3d} bpm", file=stream, flush=True)
if log:
log.write(f"{now:.3f},{bpm}\n")
log.flush()
except KeyboardInterrupt:
pass
finally:
if log:
log.close()
return received
def parse_time(text):
text = text.strip()
if re.fullmatch(r"\d+(\.\d+)?", text):
return float(text)
# Apple Health uses "2026-09-29 09:12:03 +1000".
text = re.sub(r" ([+-]\d{2}):?(\d{2})$", r"\1\2", text).replace("Z", "+0000")
for pattern in ("%Y-%m-%d %H:%M:%S%z", "%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S.%f%z"):
try:
return datetime.strptime(text, pattern).timestamp()
except ValueError:
pass
raise ValueError(f"unrecognised time {text!r}")
def read_csv(path):
"""[(time, bpm or None)], sorted. A header row is skipped."""
samples = []
with open(path, newline="") as source:
for row in csv.reader(source):
if len(row) < 2:
continue
try:
when, bpm = parse_time(row[0]), int(float(row[1]))
except (ValueError, OverflowError):
continue # header or unparseable line
try:
flags = int(float(row[2])) if len(row) > 2 else None
except (ValueError, OverflowError):
flags = None # an unrecognised flag leaves the reading as it is
if flags is not None and flags & 4 and not flags & 2:
bpm = None # contact supported and not detected: no reading
samples.append((when, bpm))
return sorted(samples, key=lambda s: s[0])
def read_health(path, start, end):
"""Heart-rate records from an Apple Health export between start and end."""
samples = []
def scan(source):
for _, element in ElementTree.iterparse(source):
if element.tag == "Record" and element.get("type") == "HKQuantityTypeIdentifierHeartRate":
try:
when = parse_time(element.get("startDate") or "")
value = round(float(element.get("value") or ""))
except (ValueError, OverflowError):
continue
if start <= when <= end:
samples.append((when, value))
element.clear()
if zipfile.is_zipfile(path):
with zipfile.ZipFile(path) as archive:
names = [n for n in archive.namelist() if n.endswith("/export.xml") or n == "export.xml"]
if not names:
raise ValueError("no export.xml in that archive; use Health's Export All Health Data")
with archive.open(names[0]) as source:
scan(source)
else:
with open(path, "rb") as source:
scan(source)
return sorted(samples)
def read_any(path, start=None, end=None):
path = str(path)
if path.endswith((".zip", ".xml")):
return read_health(path, start, end)
return read_csv(path)
def value_at(samples, when, hold, times=None):
"""Our reading at a moment: the latest sample no older than `hold` seconds.
`times` is the samples' time column, if the caller has already built it."""
times = times if times is not None else [s[0] for s in samples]
i = bisect.bisect_right(times, when) - 1
if i < 0 or when - times[i] > hold:
return None
return samples[i][1]
def compare(ours, reference, max_lag=10.0, hold=5.0):
"""Compare our readings with the reference at each reference moment.
`lag` is the delay added to reference times before looking up ours (our
readings arrive after the sensor's). The best lag within ±max_lag is used.
"""
real = [(t, b) for t, b in reference if b is not None]
if not real or not any(b is not None for _, b in ours):
raise ValueError("both recordings need at least one reading")
best = None
ours_times = [t for t, _ in ours]
steps = int(max_lag * 4)
for step in range(-steps, steps + 1):
lag = step / 4
pairs = [(value_at(ours, t + lag, hold, ours_times), b) for t, b in real]
pairs = [(o, r) for o, r in pairs if o is not None]
if not pairs:
continue
error = sum(abs(o - r) for o, r in pairs) / len(pairs)
key = (-len(pairs), error, abs(lag))
if best is None or key < best[0]:
best = (key, lag, pairs)
if best is None:
raise ValueError("the recordings do not overlap in time")
_, lag, pairs = best
differences = [o - r for o, r in pairs]
# While the reference says "no reading" (lost skin contact), we must not
# produce new readings. Count ours that arrive during such a stretch.
gaps = [t for t, b in reference if b is None]
reference_times = [t for t, _ in reference]
shown_in_gaps = 0
for t, bpm in ours:
i = bisect.bisect_right(reference_times, t - lag) - 1
if bpm is not None and i >= 0 and reference[i][1] is None:
shown_in_gaps += 1
return {
"reference_readings": len(real),
"matched": len(pairs),
"lag_seconds": lag,
"mean_abs_error": sum(abs(d) for d in differences) / len(differences),
"bias": sum(differences) / len(differences),
"max_abs_error": max(abs(d) for d in differences),
"within_5": sum(1 for d in differences if abs(d) <= 5) / len(differences),
"exact": sum(1 for d in differences if d == 0) / len(differences),
"no_contact_moments": len(gaps),
"shown_during_no_contact": shown_in_gaps,
}
def report(result, tolerance, stream=sys.stdout):
print(f"Reference readings: {result['reference_readings']}, matched: {result['matched']}", file=stream)
print(f"Best alignment: ours {result['lag_seconds']:+.2f} s after the reference", file=stream)
print(f"Mean absolute difference: {result['mean_abs_error']:.2f} BPM "
f"(bias {result['bias']:+.2f}, worst {result['max_abs_error']})", file=stream)
print(f"Within ±5 BPM: {result['within_5']:.0%}; identical: {result['exact']:.0%}", file=stream)
if result["no_contact_moments"]:
print(f"No-contact moments: {result['no_contact_moments']}, where we showed a stale "
f"reading: {result['shown_during_no_contact']}", file=stream)
coverage = result["matched"] / result["reference_readings"]
passed = (result["mean_abs_error"] <= tolerance and coverage >= 0.8
and not result["shown_during_no_contact"])
print(("PASS" if passed else "FAIL") + f" (mean difference ≤ {tolerance} BPM, ≥80% of reference "
f"readings matched, nothing shown without contact)", file=stream)
return passed
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
commands = parser.add_subparsers(dest="command", required=True)
heard = commands.add_parser("listen", help="show and optionally record our OSC readings live")
heard.add_argument("--port", type=int, default=9000)
heard.add_argument("--address", default=ADDRESS)
heard.add_argument("--out", help="new private CSV file")
heard.add_argument("--seconds", type=float, default=0, help="stop after this long (default: until Ctrl-C)")
check = commands.add_parser("compare", help="compare our recording with a reference")
check.add_argument("ours", type=Path)
check.add_argument("reference", type=Path)
check.add_argument("--tolerance", type=float, default=5.0, help="allowed mean difference in BPM (default 5)")
check.add_argument("--max-lag", type=float, default=10.0, help="largest time offset to search, seconds")
args = parser.parse_args(argv)
if args.command == "listen":
count = listen(args.port, args.address, args.out, args.seconds)
print(f"Received {count} readings.")
return 0 if count else 3
try:
ours = read_csv(args.ours)
if not ours:
raise ValueError("our recording has no readings")
reference = read_any(args.reference, ours[0][0] - 60, ours[-1][0] + 60)
result = compare(ours, reference, args.max_lag)
except (ValueError, OSError, csv.Error, ElementTree.ParseError, zipfile.BadZipFile) as error:
print(f"Could not compare: {error}")
return 1
return 0 if report(result, args.tolerance) else 1
if __name__ == "__main__":
raise SystemExit(main())
-100
View File
@@ -1,100 +0,0 @@
// A synthetic Bluetooth heart-rate strap for testing, run on the Mac.
//
// swiftc -O scripts/heart-test-strap.swift -o /tmp/heart-test-strap
// /tmp/heart-test-strap [seconds] # default 60
//
// Advertises the standard Heart Rate Service (180d) as "FC Test Strap" and,
// while a central is subscribed, sends one Heart Rate Measurement (2a37) per
// second from a fixed, known sequence. Each sent value is printed to stdout as
// `unix_seconds,bpm,flags` so scripts/heart-check.py can compare what the
// Frame received with what was sent. Every value is synthetic; this is not a
// sensor. macOS asks for Bluetooth permission the first time it runs.
import CoreBluetooth
import Foundation
let hrs = CBUUID(string: "180D")
let measurement = CBUUID(string: "2A37")
/// The known sequence: an 8-bit ramp, 16-bit encodings, a skin-contact loss
/// (which must show as no reading) and a recovery.
func packet(_ second: Int) -> (bpm: Int, flags: UInt8) {
switch second % 60 {
case 0..<30: return (60 + second % 60 * 4, 0x06) // 60…176, contact detected
case 30..<40: return (180 - (second % 60 - 30) * 3, 0x07) // 16-bit value
case 40..<45: return (150, 0x04) // contact lost
default: return (72 + (second % 60 - 45), 0x06)
}
}
final class Strap: NSObject, CBPeripheralManagerDelegate {
let seconds: Int
var manager: CBPeripheralManager!
var characteristic: CBMutableCharacteristic!
var subscribed = false
var sent = 0
init(seconds: Int) {
self.seconds = seconds
super.init()
manager = CBPeripheralManager(delegate: self, queue: nil)
}
func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
guard peripheral.state == .poweredOn else {
FileHandle.standardError.write("Bluetooth state \(peripheral.state.rawValue)\n".data(using: .utf8)!)
if peripheral.state == .unauthorized || peripheral.state == .unsupported || peripheral.state == .poweredOff {
FileHandle.standardError.write("Bluetooth unavailable (state \(peripheral.state.rawValue))\n".data(using: .utf8)!)
exit(1)
}
return
}
characteristic = CBMutableCharacteristic(type: measurement, properties: [.notify], value: nil, permissions: [])
let service = CBMutableService(type: hrs, primary: true)
service.characteristics = [characteristic]
peripheral.add(service)
}
func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {
if let error { fail("add service: \(error.localizedDescription)") }
peripheral.startAdvertising([CBAdvertisementDataLocalNameKey: "FC Test Strap",
CBAdvertisementDataServiceUUIDsKey: [hrs]])
}
func peripheralManagerDidStartAdvertising(_ peripheral: CBPeripheralManager, error: Error?) {
if let error { fail("advertise: \(error.localizedDescription)") }
FileHandle.standardError.write("Advertising FC Test Strap\n".data(using: .utf8)!)
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in self.tick() }
}
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) {
subscribed = true
FileHandle.standardError.write("Central subscribed\n".data(using: .utf8)!)
}
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) {
subscribed = false
FileHandle.standardError.write("Central unsubscribed\n".data(using: .utf8)!)
}
func tick() {
guard subscribed else { return }
if sent >= seconds { exit(0) }
let (bpm, flags) = packet(sent)
var bytes: [UInt8] = [flags, UInt8(bpm & 0xff)]
if flags & 1 != 0 { bytes.append(UInt8(bpm >> 8)) }
if manager.updateValue(Data(bytes), for: characteristic, onSubscribedCentrals: nil) {
print(String(format: "%.3f,%d,%d", Date().timeIntervalSince1970, bpm, flags))
fflush(stdout)
sent += 1
}
}
func fail(_ message: String) -> Never {
FileHandle.standardError.write("\(message)\n".data(using: .utf8)!)
exit(1)
}
}
let seconds = CommandLine.arguments.count > 1 ? Int(CommandLine.arguments[1]) ?? 60 : 60
let strap = Strap(seconds: seconds)
RunLoop.main.run()
-60
View File
@@ -1,60 +0,0 @@
#!/usr/bin/env python3
"""Install or run our local-only tracking tools on the Frame.
python3 scripts/tracking-on-frame.py install
python3 scripts/tracking-on-frame.py gaze --seconds 10
python3 scripts/tracking-on-frame.py gaze --seconds 3600 --osc 127.0.0.1 9000
python3 scripts/tracking-on-frame.py heart --device AA:BB:CC:DD:EE:FF --panel
python3 scripts/tracking-on-frame.py pulse --seconds 60 --show # experimental
FRAME_ALIAS overrides the SSH alias (default: frame). Runs in the foreground;
Ctrl-C stops the reader. No service, autostart, sudo or SteamVR settings changes.
"""
import os
from pathlib import Path
import shlex
import subprocess
import sys
import tarfile
REMOTE = '"$HOME/.local/share/frame-control/tracking"'
INSTALL = '''set -eu
base="$HOME/.local/share/frame-control/tracking"
mkdir -p "$base"
stage=$(mktemp -d "$base/.install.XXXXXX")
trap 'rm -rf "$stage"' EXIT
tar -xf - -C "$stage"
cc -O2 -Wall -Wextra -Werror "$stage/gaze.c" \\
-L/opt/steamvr/bin/linuxarm64 -Wl,-rpath,/opt/steamvr/bin/linuxarm64 \\
-lopenxr_loader -o "$stage/gaze"
chmod 700 "$stage/gaze" "$stage/tracking.py"
chmod 600 "$stage/pulse.py"
mv "$stage/gaze" "$stage/tracking.py" "$stage/pulse.py" "$base/"
echo 'Installed Frame Control tracking tools (no service started).'
'''
def main():
if len(sys.argv) < 2 or sys.argv[1] not in ("install", "gaze", "heart", "pulse"):
print(__doc__)
return 2
host = os.environ.get("FRAME_ALIAS", "frame")
if not host or host.startswith("-"):
raise ValueError("invalid SSH alias")
ssh = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", host]
if sys.argv[1] == "install":
import tempfile
source = Path(__file__).resolve().parents[1] / "frame" / "tracking"
with tempfile.TemporaryFile() as archive:
with tarfile.open(fileobj=archive, mode="w") as tar:
for name in ("gaze.c", "tracking.py", "pulse.py"):
tar.add(source / name, arcname=name)
archive.seek(0)
return subprocess.call(ssh + ["bash -c " + shlex.quote(INSTALL)], stdin=archive)
# Allocate a tty so SSH forwards Ctrl-C and hangup to the foreground process.
command = 'exec python3 ' + REMOTE + '/tracking.py ' + shlex.join(sys.argv[1:])
return subprocess.call(ssh[:-1] + ["-tt", host, command])
if __name__ == "__main__":
raise SystemExit(main())
+2 -2
View File
@@ -64,7 +64,7 @@ function sharedPage({productionApi=false} = {}) {
const request=deferred();request.path=path;request.opts=opts;requests.push(request);return request.promise;
};
Object.assign(context,{renderPill(){},renderConnDlg(){},renderBanner(){},viaText(){},netLabel(){},
resetShots(){},loadDevices(){},renderDevices(){},renderTests(){},reloadAll(){},checkContactPrompt(){}});
resetShots(){},closeDevPop(){},loadDevices(){},renderDevices(){},renderTests(){},reloadAll(){},checkContactPrompt(){}});
vm.runInContext(`const UI_KEY = 'test-only';
const link = {s:null,device:'frame-a',phase:'connected',rev:0,net:null};
const disp = {}, gm = {seq:0}, cat = {}, wi = {};
@@ -450,7 +450,7 @@ test('every linked workflow selects its real page and section, preserving shortc
if(!ids.has(id))return null;
if(!sections.has(id))sections.set(id,{hidden:false,scrollIntoView(){scrolled=id;}});
return sections.get(id);
},live:false,toggleLive(){},loadMacView(){},loadPanelSwitcher(){}};
},live:false,toggleLive(){},scrollToY(){},loadMacView(){},loadPanelSwitcher(){}};
const androidStart=html.indexOf('function showAndroidTab(');
const androidEnd=html.indexOf("document.querySelectorAll('[data-store-tab]').forEach(button => button.onclick",androidStart);
vm.createContext(context);vm.runInContext(html.slice(androidStart,androidEnd)+html.slice(start,end)+'\nPAGES.push("devices", "control");',context);
-337
View File
@@ -1,337 +0,0 @@
"""VR mod (UEVR) checks that need no headset or network: a fake Steam library on disk.
Run: python3 -m unittest discover -s tests
"""
import hashlib
import io
import itertools
import json
import stat
import subprocess
import sys
import tempfile
import unittest
import zipfile
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_mods # noqa: E402
import test_server # noqa: E402 (not `from … import`, or unittest runs ServerGuards twice)
APPID = 1067310
def zip_bytes(files, links=()):
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as z:
for name, data in files.items():
z.writestr(name, data)
for name in links:
info = zipfile.ZipInfo(name)
info.external_attr = (stat.S_IFLNK | 0o777) << 16
z.writestr(info, "/etc/passwd")
return buf.getvalue()
@unittest.skipIf(sys.platform == "win32", "the helper runs on the Frame (Linux): /proc, flock and POSIX prefix paths")
class FakeLibrary(unittest.TestCase):
"""A Steam library with Gravitas's real layout, and pinned downloads served from memory."""
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
home = Path(tmp.name)
self.steam = home / ".local/share/Steam"
apps = self.steam / "steamapps"
(apps / "common/Gravitas/SkyArk/Binaries/Win64").mkdir(parents=True)
(apps / "common/Gravitas/SkyArk/Binaries/Win64/Drop-Win64-Shipping.exe").write_bytes(b"MZ" * 100)
(apps / "common/Gravitas/Drop.exe").write_bytes(b"MZ")
self.prefix = apps / f"compatdata/{APPID}/pfx"
for sub in ("Roaming", "Local"):
(self.prefix / "drive_c/users/steamuser/AppData" / sub).mkdir(parents=True)
(apps / f"appmanifest_{APPID}.acf").write_text(
f'"AppState"\n{{\n\t"appid"\t\t"{APPID}"\n\t"name"\t\t"Gravitas"\n\t"installdir"\t\t"Gravitas"\n}}\n')
for name, value in (("STEAM", self.steam), ("CACHE", home / ".cache/frame-control/mods"),
("RECEIPTS", home / ".local/share/frame-control/mods")):
p = mock.patch.object(frame_mods, name, value)
p.start()
self.addCleanup(p.stop)
self.running = None
p = mock.patch.object(frame_mods, "game_pid", side_effect=lambda exe: self.running)
p.start()
self.addCleanup(p.stop)
self.downloads = {frame_mods.UEVR["url"]: zip_bytes({"UEVRInjector.exe": b"MZ", "UEVRBackend.dll": b"MZ",
"openvr_api.dll": b"MZ"})}
self.downloads[frame_mods.PYTHON["url"]] = zip_bytes({"python.exe": b"MZ", "python314.zip": b"PK"})
self.pin(self.downloads)
def pin(self, downloads):
"""Serve these bytes and pin their real hashes in place of the published ones."""
for name in ("UEVR", "PYTHON"):
pinned = getattr(frame_mods, name)
p = mock.patch.object(frame_mods, name,
dict(pinned, sha256=hashlib.sha256(downloads[pinned["url"]]).hexdigest()))
p.start()
self.addCleanup(p.stop)
p = mock.patch.object(frame_mods.urllib.request, "urlopen",
side_effect=lambda url, timeout: io.BytesIO(downloads[url]))
self.urlopen = p.start()
self.addCleanup(p.stop)
def managed(self):
return self.prefix / "drive_c/frame-control"
class Status(FakeLibrary):
def test_unreal_game_is_eligible_and_not_installed(self):
st = frame_mods.status(APPID)
self.assertEqual((st["eligible"], st["exe"], st["installed"], st["running"], st["prefix"]),
(True, "Drop-Win64-Shipping.exe", False, False, True))
def test_game_without_unreal_binary_is_refused(self):
(self.steam / "steamapps/common/Gravitas/SkyArk/Binaries/Win64/Drop-Win64-Shipping.exe").unlink()
self.assertFalse(frame_mods.status(APPID)["eligible"])
with self.assertRaisesRegex(frame_mods.Fail, "Unreal Engine"):
frame_mods.install(APPID)
self.urlopen.assert_not_called()
def test_uninstalled_game(self):
with self.assertRaisesRegex(frame_mods.Fail, "isn't installed"):
frame_mods.status(620)
class InstallUninstall(FakeLibrary):
def test_install_then_uninstall_leaves_prefix_as_found(self):
before = sorted(p.relative_to(self.prefix) for p in self.prefix.rglob("*"))
res = frame_mods.install(APPID)
self.assertTrue(res["installed"])
self.assertTrue((self.managed() / "uevr-1.05/UEVRBackend.dll").is_file())
self.assertTrue((self.managed() / "python-3.14.7/python.exe").is_file())
self.assertIn("UEVRBackend.dll", (self.managed() / "python-3.14.7/frame_inject.py").read_text())
self.assertFalse(list(self.managed().glob(".staging-*")))
# What UEVR writes on its first injection.
roaming = self.prefix / "drive_c/users/steamuser/AppData/Roaming/UnrealVRMod/Drop-Win64-Shipping"
roaming.mkdir(parents=True)
(roaming / "log.txt").write_text("x")
(self.prefix / "drive_c/users/steamuser/AppData/Local/praydog/x").mkdir(parents=True)
frame_mods.uninstall(APPID)
after = sorted(p.relative_to(self.prefix) for p in self.prefix.rglob("*"))
self.assertEqual(after, before)
self.assertFalse(frame_mods.CACHE.exists(), "the downloads go once no game uses them")
self.assertIsNone(frame_mods.read_receipt(APPID))
def test_existing_uevr_settings_survive_uninstall(self):
profile = self.prefix / "drive_c/users/steamuser/AppData/Roaming/UnrealVRMod/Drop-Win64-Shipping"
profile.mkdir(parents=True)
(profile / "config.txt").write_text("mine")
frame_mods.install(APPID)
frame_mods.uninstall(APPID)
self.assertEqual((profile / "config.txt").read_text(), "mine")
def test_second_install_is_a_no_op(self):
frame_mods.install(APPID)
calls = self.urlopen.call_count
self.assertIn("already installed", frame_mods.install(APPID)["message"])
self.assertEqual(self.urlopen.call_count, calls)
def test_refuses_while_game_runs(self):
self.running = 4242
with self.assertRaisesRegex(frame_mods.Fail, "running"):
frame_mods.install(APPID)
self.running = None
frame_mods.install(APPID)
self.running = 4242
with self.assertRaisesRegex(frame_mods.Fail, "running"):
frame_mods.uninstall(APPID)
self.assertTrue((self.managed() / "uevr-1.05").is_dir())
def test_needs_a_prefix(self):
import shutil
shutil.rmtree(self.prefix.parent)
with self.assertRaisesRegex(frame_mods.Fail, "play it once"):
frame_mods.install(APPID)
def test_receipt_cannot_point_outside_the_prefix(self):
frame_mods.install(APPID)
r = frame_mods.read_receipt(APPID)
victim = self.steam / "steamapps/common/Gravitas"
r["remove"].append(str(self.prefix / "../../../common/Gravitas"))
frame_mods.receipt_path(APPID).write_text(json.dumps(r))
with self.assertRaisesRegex(frame_mods.Fail, "didn't create"):
frame_mods.uninstall(APPID)
self.assertTrue(victim.is_dir())
self.assertTrue((self.managed() / "uevr-1.05").is_dir(), "nothing removed when the receipt is bad")
def test_receipt_cannot_delete_other_things_in_the_prefix(self):
frame_mods.install(APPID)
r = frame_mods.read_receipt(APPID)
users = self.prefix / "drive_c/users"
r["remove"].append(str(users))
frame_mods.receipt_path(APPID).write_text(json.dumps(r))
with self.assertRaisesRegex(frame_mods.Fail, "didn't create"):
frame_mods.uninstall(APPID)
self.assertTrue(users.is_dir())
def test_failed_removal_keeps_the_receipt(self):
frame_mods.install(APPID)
with mock.patch.object(frame_mods.shutil, "rmtree"): # e.g. a permission error, swallowed
with self.assertRaisesRegex(frame_mods.Fail, "couldn't remove"):
frame_mods.uninstall(APPID)
self.assertIsNotNone(frame_mods.read_receipt(APPID), "Remove can be tried again")
frame_mods.uninstall(APPID)
self.assertFalse(self.managed().exists())
def test_receipt_cannot_name_another_prefix(self):
frame_mods.install(APPID)
r = frame_mods.read_receipt(APPID)
r["prefix"] = "/"
frame_mods.receipt_path(APPID).write_text(json.dumps(r))
with self.assertRaisesRegex(frame_mods.Fail, "isn't app"):
frame_mods.uninstall(APPID)
self.assertTrue((self.managed() / "uevr-1.05").is_dir())
class Downloads(FakeLibrary):
def test_hash_mismatch_deletes_and_installs_nothing(self):
self.downloads[frame_mods.UEVR["url"]] = zip_bytes({"UEVRBackend.dll": b"tampered"})
with self.assertRaisesRegex(frame_mods.Fail, "doesn't match"):
frame_mods.install(APPID)
self.assertFalse((frame_mods.CACHE / "UEVR.zip").exists())
self.assertFalse(self.managed().exists())
self.assertIsNone(frame_mods.read_receipt(APPID))
def test_archive_escaping_its_folder_is_refused(self):
for bad in (zip_bytes({"UEVRBackend.dll": b"MZ", "../../evil.dll": b"MZ"}),
zip_bytes({"UEVRBackend.dll": b"MZ"}, links=["openvr_api.dll"])):
downloads = dict(self.downloads, **{frame_mods.UEVR["url"]: bad})
self.pin(downloads)
with self.subTest(), self.assertRaises(frame_mods.Fail):
frame_mods.install(APPID)
self.assertFalse((self.prefix / "drive_c/evil.dll").exists())
self.assertFalse(list(self.managed().glob("*")) if self.managed().exists() else [])
import shutil
shutil.rmtree(frame_mods.CACHE, ignore_errors=True)
def test_oversized_archive_is_refused(self):
with mock.patch.object(frame_mods, "MAX_UNPACKED", 3):
with self.assertRaisesRegex(frame_mods.Fail, "more than expected"):
frame_mods.install(APPID)
def test_oversized_download_is_stopped(self):
with mock.patch.object(frame_mods, "MAX_DOWNLOAD", 10):
with self.assertRaisesRegex(frame_mods.Fail, "bigger than expected"):
frame_mods.install(APPID)
self.assertFalse(list(frame_mods.CACHE.glob("*")))
class Actions(FakeLibrary):
def test_one_change_at_a_time(self):
import fcntl
lock = Path(tempfile.mkdtemp()) / "mods.lock"
with mock.patch.object(frame_mods, "LOCK", lock), open(lock, "w") as held:
fcntl.flock(held, fcntl.LOCK_EX)
with self.assertRaisesRegex(frame_mods.Fail, "another mod action"):
frame_mods.main(["install", str(APPID)])
self.assertFalse(frame_mods.status(APPID)["installed"])
self.assertEqual(frame_mods.main(["status", str(APPID)])["appid"], APPID, "status never waits")
def test_zero_frames_is_an_answer(self):
stats = json.dumps([{"key": f"steam.app.{APPID}", "frame_submits": 0}, {"operation": "status"}])
with mock.patch.object(frame_mods.subprocess, "run", return_value=mock.Mock(stdout=stats)):
self.assertEqual(frame_mods.wait("frames", 5, lambda: frame_mods.frame_submits(APPID)), 0)
with mock.patch.object(frame_mods.subprocess, "run", return_value=mock.Mock(stdout='{"odd": 1}')):
self.assertIsNone(frame_mods.frame_submits(APPID))
class Start(FakeLibrary):
def environ(self, text):
"""The game's /proc/<pid>/environ; every other file reads normally."""
real = frame_mods.Path.read_text
return mock.patch.object(frame_mods.Path, "read_text", autospec=True, side_effect=lambda path, *a, **k:
text if str(path).startswith("/proc/") else real(path, *a, **k))
def test_needs_install_first(self):
with self.assertRaisesRegex(frame_mods.Fail, "isn't installed"):
frame_mods.start(APPID)
def run_start(self, reply, loaded=(False, True, True, True)):
frame_mods.install(APPID)
self.running = 4242
env = "WINEPREFIX=/x\0DISPLAY=:1\0WINESERVERSOCKET=17\0PATH=/p\0"
with self.environ(env), \
mock.patch.object(frame_mods, "loaded", side_effect=list(loaded) + [True] * 5), \
mock.patch.object(frame_mods, "x11_windows", return_value=[(1, "SkyArk", 1280)]), \
mock.patch.object(frame_mods.subprocess, "run", return_value=mock.Mock(stdout=reply, stderr="")) as run, \
mock.patch.object(frame_mods, "frame_submits", return_value=7), \
mock.patch.object(frame_mods.time, "sleep"):
return frame_mods.start(APPID), run
def test_injects_with_the_games_wine_settings(self):
res, run = self.run_start('noise\n{"pid": 320}\n')
self.assertEqual((res["frame_submits"], res["message"]), (7, "UEVR injected into Gravitas; SteamVR is receiving its frames"))
cmd, env = run.call_args.args[0], run.call_args.kwargs["env"]
self.assertEqual(cmd[:2], ["wine", "C:\\frame-control\\python-3.14.7\\python.exe"])
self.assertEqual(cmd[3:], ["Drop-Win64-Shipping.exe", "C:\\frame-control\\uevr-1.05",
"C:\\users\\steamuser\\AppData\\Roaming\\UnrealVRMod\\Drop-Win64-Shipping"])
self.assertEqual(env["WINEPREFIX"], "/x")
self.assertNotIn("WINESERVERSOCKET", env, "an inherited fd number means nothing to a new process")
self.assertIn("HOME", env, "fontconfig needs a HOME for its cache")
def test_reports_the_injection_error(self):
with self.assertRaisesRegex(frame_mods.Fail, "couldn't open"):
self.run_start('{"error": "couldn\'t open Drop-Win64-Shipping.exe (5)"}\n')
def test_success_is_checked_in_the_game(self):
with self.assertRaisesRegex(frame_mods.Fail, "has no UEVRBackend.dll"):
with mock.patch.object(frame_mods.time, "time", side_effect=itertools.count(0, 5)):
self.run_start('{"pid": 320}\n', loaded=[False] * 40)
def test_inject_script_is_valid_python(self):
compile(frame_mods.INJECT_PY, "frame_inject.py", "exec")
class Helper(unittest.TestCase):
@unittest.skipIf(sys.platform == "win32", "uninstall takes the flock lock, which only exists on the Frame (Linux)")
def test_unexpected_errors_are_still_json(self):
# A receipt from another version, without "exe": a KeyError, not a traceback.
script = (ROOT / "ui" / "frame_mods.py").read_text().replace(
"def read_receipt(appid):", "def read_receipt(appid):\n return {'prefix': '/x'}\n\n\ndef _unused(appid):")
out = subprocess.run([sys.executable, "-", "uninstall", "1"], input=script,
capture_output=True, text=True, timeout=30, env={"HOME": tempfile.mkdtemp()})
self.assertEqual(out.returncode, 1, out.stderr)
self.assertIn("KeyError", json.loads(out.stdout)["error"])
def test_bad_usage_prints_json_error(self):
for args in ([], ["install"], ["install", "12x"], ["remove", "620"]):
out = subprocess.run([sys.executable, str(ROOT / "ui" / "frame_mods.py"), *args],
capture_output=True, text=True, timeout=30)
self.assertEqual(out.returncode, 1, args)
self.assertIn("error", json.loads(out.stdout), args)
class ModRoutes(test_server.ServerGuards):
"""Reuses ServerGuards' server (unresolvable SSH alias); validation runs before any SSH."""
def test_mod_input_validation(self):
for body in ({"action": "install", "appid": "1; reboot"}, {"action": "install", "appid": ""},
{"action": "delete", "appid": "1067310"}, {"appid": "1067310"}):
status, payload = self.post("/api/mods", body)
self.assertEqual(status, 400, f"{body} -> {payload}")
def test_needs_custom_header(self):
self.assertEqual(self.request("POST", "/api/mods", {"action": "status", "appid": "1"})[0], 403)
def test_runs_the_mods_helper_with_its_timeout(self):
import server
with mock.patch.object(server, "steam_frame", return_value={"ok": 1}) as run:
self.assertEqual(server.mods({"action": "install", "appid": "1067310"}), {"ok": 1})
run.assert_called_once_with("install", "1067310", timeout=600, script="frame_mods.py")
for name in [n for n in dir(test_server.ServerGuards) if n.startswith("test_")]:
setattr(ModRoutes, name, None)
-472
View File
@@ -1,472 +0,0 @@
"""Eye-camera pulse estimate and the heart-rate comparison tool; no headset needed."""
import importlib.util
import io
import json
import math
import os
from pathlib import Path
import random
import socket
import struct
import sys
import tempfile
import unittest
import zipfile
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
def load(name, path):
spec = importlib.util.spec_from_file_location(name, ROOT / path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
pulse = load("pulse", "frame/tracking/pulse.py")
check = load("heart_check", "scripts/heart-check.py")
def synthetic(bpm, pulsing, seconds=40, fps=30, patches=48, noise=0.004, seed=7):
"""Patch brightness with a faint pulse in `pulsing` patches, plus sensor
noise, slow drift, blinks in some patches and eye movement in others."""
rng = random.Random(seed)
times = [i / fps for i in range(seconds * fps)]
values = {}
for k in range(patches):
base, amplitude, trace = 40 + k, 0.003 if k < pulsing else 0.0, []
for t in times:
v = base * (1 + amplitude * math.sin(2 * math.pi * bpm / 60 * t)
+ noise * rng.gauss(0, 1) + 0.02 * math.sin(0.1 * t + k))
if k % 8 == 7 and int(t * 10) % 47 == 0:
v *= 0.5 # blink
if k % 8 == 6 and int(t * 10) % 23 == 0:
v *= 1.3 # frequent eye movement
trace.append(v)
values[k] = trace
return times, values
class Estimate(unittest.TestCase):
def test_finds_pulse(self):
for bpm in (58, 72, 115):
with self.subTest(bpm=bpm):
result = pulse.estimate(*synthetic(bpm, 16))
self.assertAlmostEqual(result["bpm"], bpm, delta=1.5)
self.assertTrue(pulse.reliable(result))
self.assertTrue(all(abs(b - bpm) <= 2 for _, b in result["series"]))
def test_noise_and_blinks_are_not_a_pulse(self):
result = pulse.estimate(*synthetic(72, 0))
self.assertFalse(pulse.reliable(result))
def test_frequent_spike_patches_are_dropped(self):
times, values = synthetic(72, 16)
result = pulse.estimate(times, values)
self.assertLess(result["usable"], len(values))
def test_needs_enough_frames(self):
with self.assertRaises(ValueError):
pulse.estimate(*synthetic(72, 16, seconds=5))
times, values = synthetic(72, 16)
with self.assertRaises(ValueError):
pulse.estimate(times, {k: [0.0] * len(times) for k in values}) # all dark
def test_series_times(self):
times, values = synthetic(72, 16, seconds=20)
series = pulse.estimate(times, values)["series"]
self.assertAlmostEqual(series[0][0], pulse.WINDOW, delta=0.1)
self.assertEqual(len(series), 20 - int(pulse.WINDOW) + 1)
def test_flat_patches_do_not_rank_first(self):
times, values = synthetic(72, 16)
values["flat"] = [100.0] * len(times)
result = pulse.estimate(times, values)
self.assertAlmostEqual(result["bpm"], 72, delta=1.5)
self.assertEqual(pulse.snr([(60.0, 0.0), (61.0, 0.0)], 60), 0.0)
def test_analyse_uses_nearest_frame(self):
times, values = synthetic(72, 4, patches=4, seconds=10)
# Right-eye frames 1 ms before each left frame: nearest is that frame.
frames = [(t, "left", [values[k][i] for k in range(4)]) for i, t in enumerate(times)]
frames += [(t - 0.001, "right", [float(i)] * 4) for i, t in enumerate(times)]
seen = {}
original = pulse.estimate
with patch.object(pulse, "estimate", lambda t, p: seen.update(p) or original(t, p)):
pulse.analyse(frames)
self.assertEqual(seen["right0"][:5], [0.0, 0.0, 1.0, 1.0, 2.0])
def test_fft_matches_dft(self):
signal = [math.sin(i) + (i % 3) for i in range(16)]
fast = pulse.fft(signal)
for k in range(16):
slow = sum(signal[n] * complex(math.cos(2 * math.pi * k * n / 16), -math.sin(2 * math.pi * k * n / 16))
for n in range(16))
self.assertAlmostEqual(abs(fast[k] - slow), 0, places=9)
def test_grid_means(self):
# 4 × 4 image in RGB with padding: left half 10, right half 30 (channel 0).
width, height, channels, stride = 4, 4, 3, 16
pixels = bytearray(stride * height)
for y in range(height):
for x in range(width):
pixels[y * stride + x * channels] = 10 if x < 2 else 30
pixels[y * stride + x * channels + 1] = 255 # other channels ignored
self.assertEqual(pulse.grid_means(bytes(pixels), width, height, stride, channels, grid=2),
[10, 30, 10, 30])
def test_analyse_combines_both_eyes(self):
times, values = synthetic(80, 16, patches=16)
frames = []
for i, t in enumerate(times):
frames.append((t, "left", [values[k][i] for k in range(16)]))
frames.append((t + 0.001, "right", [values[k][i] for k in range(16)]))
result = pulse.analyse(frames)
self.assertAlmostEqual(result["bpm"], 80, delta=1.5)
self.assertEqual(result["usable"] % 2, 0)
class FakeCaptureTool:
"""Stands in for `eyetracking --calib`: writes PNG names over a few polls."""
def __init__(self, directory, frames=6, fail=False):
self.directory, self.frames, self.fail = Path(directory), frames, fail
self.polls = 0
self.returncode = None
self.stdout = None
self.announce = None # a different directory to report, if set
def __call__(self, command, cwd, stdout, stderr):
self.command = command
self.stdout = stdout
if self.fail:
stdout.write("Failed to initialize cameras\n")
stdout.flush()
self.returncode = 1
return self
# Real output is block-buffered until exit, so nothing is printed here.
stdout.flush()
self.directory.mkdir()
return self
def poll(self):
if self.returncode is not None:
return self.returncode
if self.polls < self.frames:
for eye in ("left", "right"):
(self.directory / f"{eye}_{self.polls}.png").write_bytes(b"png")
self.polls += 1
return None
meta = {"frames": [{eye: {"fname": str(self.directory / f"{eye}_{i}.png"), "frameNum": i + 10,
"tsMono": 100 + i / 90, "valid": i != 2} for eye in ("left", "right")}
for i in range(self.frames)]}
(self.directory / "meta.json").write_text(json.dumps(meta))
# The real tool's buffered output only appears once it exits.
self.stdout.write(f"Writing capture to: {self.announce or self.directory}\nCaptured images\n")
self.stdout.flush()
self.returncode = 0
return 0
def terminate(self):
raise AssertionError("the capture tool must never be signalled")
kill = terminate
def wait(self, timeout=None):
return self.returncode
class WornCheck(unittest.TestCase):
def sensor(self, name, raw):
root = Path(self.enterContext(tempfile.TemporaryDirectory()))
device = root / "iio:device2"
device.mkdir()
(device / "name").write_text(name + "\n")
(device / "in_proximity_raw").write_text(raw + "\n")
return root
def test_reads_the_proximity_sensor(self):
self.assertEqual(pulse.proximity(self.sensor("vcnl4000", "3.250000000")), 3.25)
def test_unreadable_sensor_does_not_block(self):
self.assertIsNone(pulse.proximity(self.sensor("other", "9")))
self.assertIsNone(pulse.proximity(self.sensor("vcnl4000", "junk")))
self.assertIsNone(pulse.proximity(Path("/nonexistent")))
self.assertTrue(pulse.worn(None))
def test_low_reading_means_unworn(self):
self.assertFalse(pulse.worn(3.1))
self.assertTrue(pulse.worn(pulse.WORN_MIN))
class CaptureLifecycle(unittest.TestCase):
def setUp(self):
self.root = Path(tempfile.mkdtemp())
self.directory = self.root / "etcalib_test"
(self.root / "etcalib_older").mkdir() # an earlier capture is not ours
self.loaded = []
def tearDown(self):
import shutil
shutil.rmtree(self.root, ignore_errors=True)
def loader(self, path):
self.loaded.append(Path(path).name)
self.assertTrue(Path(path).exists())
return [float(len(self.loaded))]
def capture(self, tool):
class TestCapture(pulse.Capture):
# A temporary directory stands in for /tmp/etcalib_*.
PREFIX = str(self.root / "etcalib_")
capture = TestCapture(20, runner=tool, loader=self.loader, workers=0)
with patch.object(pulse.time, "sleep", lambda s: None):
return capture, capture.run()
def test_reduces_deletes_and_joins_metadata(self):
tool = FakeCaptureTool(self.directory)
capture, frames = self.capture(tool)
self.assertEqual(sorted(self.loaded), sorted(f"{e}_{i}.png" for e in ("left", "right") for i in range(6)))
self.assertFalse(self.directory.exists()) # images and metadata removed
self.assertTrue((self.root / "etcalib_older").exists())
self.assertEqual(len(frames), 10) # frame 2 invalid in both eyes
self.assertEqual(tool.command[-2:], ["--calib", "20"])
self.assertTrue(all(isinstance(t, float) and eye in ("left", "right") for t, eye, _ in frames))
def test_camera_failure(self):
tool = FakeCaptureTool(self.directory, fail=True)
with self.assertRaises(RuntimeError) as raised:
self.capture(tool)
self.assertIn("cameras unavailable", str(raised.exception))
def test_backlog_abandons_capture_and_removes_images(self):
class Stalled(FakeCaptureTool):
def poll(self):
if self.polls > 8:
self.returncode = 0 # the bounded run ends by itself
return 0
for i in range(20):
for eye in ("left", "right"):
(self.directory / f"{eye}_{self.polls * 20 + i}.png").write_bytes(b"png")
self.polls += 1
return None
tool = Stalled(self.directory)
class TestCapture(pulse.Capture):
PREFIX = str(self.root / "etcalib_")
MAX_BACKLOG = 50
capture = TestCapture(20, runner=tool, loader=self.loader, workers=0)
capture.reduce = lambda final=False, original=capture.reduce: (
original(final) if tool.polls > 3 else None) # reduction stalls
with patch.object(pulse.time, "sleep", lambda s: None), self.assertRaises(RuntimeError) as raised:
capture.run()
self.assertIn("fell behind", str(raised.exception))
self.assertEqual(tool.returncode, 0) # left to end by itself, never signalled
self.assertFalse(self.directory.exists()) # no image left behind
def test_second_capture_directory_stops_and_removes_both(self):
foreign = self.root / "etcalib_foreign"
class Racing(FakeCaptureTool):
def poll(self):
if self.polls == 2:
foreign.mkdir()
(foreign / "left_0.png").write_bytes(b"png")
return super().poll()
tool = Racing(self.directory)
with self.assertRaises(RuntimeError) as raised:
self.capture(tool)
self.assertIn("another eye-camera capture", str(raised.exception))
self.assertFalse(self.directory.exists())
self.assertFalse(foreign.exists()) # no eye image outlives the run
self.assertTrue((self.root / "etcalib_older").exists())
def test_images_elsewhere_are_not_used_but_are_removed(self):
tool = FakeCaptureTool(self.directory)
tool.announce = self.root / "etcalib_reported"
tool.announce.mkdir()
(tool.announce / "left_0.png").write_bytes(b"png")
with self.assertRaises(RuntimeError) as raised:
self.capture(tool)
self.assertIn("somewhere else", str(raised.exception))
self.assertFalse(tool.announce.exists())
self.assertFalse(self.directory.exists())
def test_interrupt_during_cleanup_still_removes_images(self):
class Stubborn(FakeCaptureTool):
interrupts = 0
def poll(self):
if self.polls == 2 and self.interrupts < 2:
self.interrupts += 1
raise KeyboardInterrupt # Ctrl-C mid-capture, and again while waiting
return super().poll()
tool = Stubborn(self.directory)
with self.assertRaises(KeyboardInterrupt):
self.capture(tool)
self.assertEqual(tool.returncode, 0) # still let finish, never signalled
self.assertFalse(self.directory.exists())
def test_overrunning_tool_is_killed_as_a_last_resort(self):
class Hung(FakeCaptureTool):
killed = False
def poll(self):
(self.directory / "left_0.png").write_bytes(b"png")
return None if not self.killed else -9
def kill(self):
self.killed = True
tool = Hung(self.directory)
clock = iter(range(0, 10000, 20))
with patch.object(pulse.time, "monotonic", lambda: next(clock)), \
self.assertRaises(RuntimeError) as raised:
self.capture(tool)
self.assertIn("may need a reboot", str(raised.exception))
self.assertTrue(tool.killed)
self.assertFalse(self.directory.exists())
def test_cleanup_tries_every_directory(self):
capture = pulse.Capture(20)
capture.PREFIX = str(self.root / "etcalib_")
first, second = self.root / "etcalib_a", self.root / "etcalib_b"
for directory in (first, second):
directory.mkdir()
(directory / "left_0.png").write_bytes(b"png")
capture.new = {first, second}
real = pulse.shutil.rmtree
def flaky(path):
if Path(path) == first:
raise OSError("busy")
real(path)
with patch.object(pulse.shutil, "rmtree", flaky), self.assertRaises(RuntimeError) as raised:
capture.remove()
self.assertIn("etcalib_a", str(raised.exception))
self.assertFalse(second.exists())
def test_removes_a_directory_seen_only_at_the_end(self):
capture = pulse.Capture(20)
capture.PREFIX = str(self.root / "etcalib_")
capture.before = capture.candidates()
late = self.root / "etcalib_late"
late.mkdir()
(late / "left_0.png").write_bytes(b"png")
capture.remove()
self.assertFalse(late.exists())
self.assertTrue((self.root / "etcalib_older").exists())
def test_never_adopts_directories_without_a_baseline(self):
capture = pulse.Capture(20)
capture.PREFIX = str(self.root / "etcalib_")
capture.remove()
self.assertTrue((self.root / "etcalib_older").exists())
def test_only_removes_capture_directories(self):
capture = pulse.Capture(20)
capture.directory = self.root
capture.remove()
self.assertTrue(self.root.exists())
class HeartCheck(unittest.TestCase):
def write(self, name, text):
path = Path(self.enterContext(tempfile.TemporaryDirectory())) / name
path.write_text(text)
return path
def test_osc_round_trip(self):
tracking = load("tracking", "frame/tracking/tracking.py")
self.assertEqual(check.parse_osc(tracking.osc_message("/avatar/parameters/HeartRate", [72])),
("/avatar/parameters/HeartRate", [72]))
address, values = check.parse_osc(tracking.osc_message("/x", [1.5, -2.0]))
self.assertEqual((address, values), ("/x", [1.5, -2.0]))
with self.assertRaises(ValueError):
check.parse_osc(b"/x\0\0,s\0\0abc\0")
def test_listen_records_readings(self):
tracking = load("tracking", "frame/tracking/tracking.py")
with tempfile.TemporaryDirectory() as directory:
out = Path(directory) / "ours.csv"
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
probe.bind(("127.0.0.1", 0))
port = probe.getsockname()[1]
import threading
def send():
import time
time.sleep(0.3)
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sender:
sender.sendto(tracking.osc_message(check.ADDRESS, [72]), ("127.0.0.1", port))
sender.sendto(tracking.osc_message("/other", [1]), ("127.0.0.1", port))
threading.Thread(target=send).start()
shown = io.StringIO()
count = check.listen(port, check.ADDRESS, str(out), 1.5, stream=shown)
self.assertEqual(count, 1)
self.assertIn("72 bpm", shown.getvalue())
self.assertEqual(out.read_text().splitlines()[1].split(",")[1], "72")
self.assertEqual(out.stat().st_mode & 0o777, 0o600)
def test_compare_finds_lag_and_contact_loss(self):
reference = [(1000.0 + i, 60 + i, 6) for i in range(40)] + [(1040.0 + i, 150, 4) for i in range(5)]
ours = [(1002.0 + i, 60 + i) for i in range(40)] # 2 s late, nothing during contact loss
ref = self.write("ref.csv", "unix_seconds,bpm,flags\n" + "".join(f"{t},{b},{f}\n" for t, b, f in reference))
mine = self.write("ours.csv", "unix_seconds,bpm\n" + "".join(f"{t},{b}\n" for t, b in ours))
result = check.compare(check.read_csv(mine), check.read_any(ref))
self.assertEqual(result["lag_seconds"], 2.0)
self.assertEqual(result["mean_abs_error"], 0)
self.assertEqual(result["shown_during_no_contact"], 0)
self.assertTrue(check.report(result, 5, stream=io.StringIO()))
# A stale reading sent during contact loss fails the check.
stale = check.read_csv(mine) + [(1043.0, 99)]
result = check.compare(stale, check.read_any(ref))
self.assertEqual(result["shown_during_no_contact"], 1)
self.assertFalse(check.report(result, 5, stream=io.StringIO()))
def test_compare_against_apple_health_export(self):
records = "".join(
f'<Record type="HKQuantityTypeIdentifierHeartRate" unit="count/min" '
f'startDate="2026-09-29 12:00:{s:02d} +1000" endDate="2026-09-29 12:00:{s:02d} +1000" value="{70 + s % 3}"/>'
for s in range(0, 60, 5))
other = '<Record type="HKQuantityTypeIdentifierStepCount" startDate="2026-09-29 12:00:00 +1000" value="9"/>'
xml = f'<?xml version="1.0"?><HealthData>{other}{records}</HealthData>'
with tempfile.TemporaryDirectory() as directory:
archive = Path(directory) / "export.zip"
with zipfile.ZipFile(archive, "w") as z:
z.writestr("apple_health_export/export.xml", xml)
start = check.parse_time("2026-09-29 12:00:00 +1000")
samples = check.read_any(archive, start - 60, start + 120)
self.assertEqual(len(samples), 12)
self.assertEqual(samples[0], (start, 70))
ours = [(start + i + 1.0, 71) for i in range(60)]
result = check.compare(ours, samples)
self.assertLessEqual(result["mean_abs_error"], 1)
def test_unusual_flags_and_missing_export(self):
path = self.write("ref.csv", "time,bpm,flags\n1000,70,6.0\n1001,71,yes\n1002,72,4\n")
self.assertEqual(check.read_csv(path), [(1000.0, 70), (1001.0, 71), (1002.0, None)])
with tempfile.TemporaryDirectory() as directory:
archive = Path(directory) / "other.zip"
with zipfile.ZipFile(archive, "w") as z:
z.writestr("notes.txt", "x")
with self.assertRaises(ValueError):
check.read_any(archive, 0, 1)
def test_compare_reports_bad_input_without_traceback(self):
good = self.write("ref.csv", "1000,70\n")
shown = io.StringIO()
with patch("sys.stdout", shown):
self.assertEqual(check.main(["compare", str(good.parent / "missing.csv"), str(good)]), 1)
self.assertEqual(check.main(["compare", str(self.write("ours.csv", "1000,inf\n1001,70\n")),
str(self.write("bad.xml", "<not closed"))]), 1)
self.assertEqual(shown.getvalue().count("Could not compare"), 2)
def test_health_records_with_non_finite_values_are_skipped(self):
record = '<Record type="HKQuantityTypeIdentifierHeartRate" startDate="2026-09-29 12:00:00 +1000" value="%s"/>'
path = self.write("export.xml", "<HealthData>" + record % "inf" + record % "72" + "</HealthData>")
start = check.parse_time("2026-09-29 12:00:00 +1000")
self.assertEqual(check.read_health(path, start - 1, start + 1), [(start, 72)])
def test_time_formats(self):
self.assertEqual(check.parse_time("1700000000.5"), 1700000000.5)
self.assertEqual(check.parse_time("2023-11-14T22:13:20Z"), 1700000000)
self.assertEqual(check.parse_time("2023-11-15 08:13:20 +1000"), 1700000000)
with self.assertRaises(ValueError):
check.parse_time("yesterday")
if __name__ == "__main__":
unittest.main()
-188
View File
@@ -1,188 +0,0 @@
"""Tracking protocols and fake-Frame BlueZ lifecycle; no headset or strap needed."""
import importlib.util
import math
import os
from pathlib import Path
import socket
import stat
import struct
import tempfile
import unittest
from unittest.mock import Mock, patch
SPEC = importlib.util.spec_from_file_location("tracking", Path(__file__).resolve().parents[1] / "frame/tracking/tracking.py")
t = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(t)
class Protocols(unittest.TestCase):
def test_hrs_formats(self):
self.assertEqual(t.heart_rate(b"\x00\x48"), 72)
self.assertEqual(t.heart_rate(b"\x01\x2c\x01"), 300)
self.assertEqual(t.heart_rate(b"\x1e\x48\x01\x00\x00\x04\x00\x04"), 72)
self.assertEqual(t.heart_rate(b"\x02\x48"), 72) # contact not supported
self.assertIsNone(t.heart_rate(b"\x04\x48")) # no contact
self.assertIsNone(t.heart_rate(b"\x00\x00"))
def test_hrs_malformed(self):
for packet in (b"", b"\x00", b"\x01\x48", b"\x08\x48\x00", b"\x10\x48",
b"\x10\x48\x00", b"\x00\x48\x01", b"\xe0\x48"):
with self.subTest(packet=packet), self.assertRaises(ValueError):
t.heart_rate(packet)
def test_gaze_coordinates(self):
self.assertEqual(t.gaze_angles([0, 0, 0, 1]), (0, 0))
angle = math.radians(15)
pitch, yaw = t.gaze_angles([math.sin(angle), 0, 0, math.cos(angle)])
self.assertAlmostEqual(pitch, -30) # OpenXR +X rotation looks up
self.assertAlmostEqual(yaw, 0)
pitch, yaw = t.gaze_angles([0, -math.sin(angle), 0, math.cos(angle)])
self.assertAlmostEqual(pitch, 0)
self.assertAlmostEqual(yaw, 30) # right
def test_invalid_gaze(self):
for pose in ([0, 0, 0, 0], [math.nan, 0, 0, 1], [0, 0, 0], [0, 0, 0, math.inf]):
with self.assertRaises(ValueError):
t.gaze_angles(pose)
def test_osc_wire(self):
self.assertEqual(t.osc_message('/x', [72]), b'/x\0\0,i\0\0' + struct.pack('>i', 72))
self.assertEqual(t.osc_message('/x', [1.0, -2.0]), b'/x\0\0,ff\0' + struct.pack('>ff', 1, -2))
for address in ('x', '/x\0y', '/x y', '/x*'):
with self.assertRaises(ValueError):
t.osc_message(address, [1])
def test_default_never_opens_socket(self):
with patch.object(t.socket, 'socket') as create:
osc = t.Osc()
osc.send('/x', [72])
osc.close()
create.assert_not_called()
def test_only_configured_endpoint(self):
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as receiver:
receiver.bind(('127.0.0.1', 0))
receiver.settimeout(1)
osc = t.Osc(receiver.getsockname())
try:
osc.send('/tracking/eye/CenterPitchYaw', [0.0, 30.0])
self.assertEqual(receiver.recv(1024), t.osc_message('/tracking/eye/CenterPitchYaw', [0.0, 30.0]))
finally:
osc.close()
def test_endpoint_validation(self):
for endpoint in [('example.org', 9000), ('0.0.0.0', 9000), ('224.0.0.1', 9000), ('127.0.0.1', 0), ('::1', 65536)]:
with self.assertRaises(ValueError):
t.Osc(endpoint)
def test_heart_staleness_contact_and_log(self):
now = [0]
osc = Mock()
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'session.csv'
session = t.HeartSession(osc, '/hr', path, lambda: now[0])
self.assertIsNone(session.current())
session.notification(b'\x00\x48')
self.assertEqual(session.current(), 72)
osc.send.assert_called_once_with('/hr', [72])
now[0] = 6
self.assertIsNone(session.current())
session.notification(b'\x04\x48')
self.assertIsNone(session.current())
self.assertEqual(osc.send.call_count, 1)
session.close()
self.assertEqual(path.read_text().splitlines()[0], 'unix_seconds,bpm')
self.assertEqual(len(path.read_text().splitlines()), 2)
if os.name != 'nt':
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600)
with self.assertRaises(FileExistsError):
t.HeartSession(osc, '/hr', path)
def test_no_log_by_default(self):
with patch.object(t.os, 'open') as create:
session = t.HeartSession(Mock(), '/hr')
session.notification(b'\x00\x48')
session.close()
create.assert_not_called()
class FakeBluez(unittest.TestCase):
def setUp(self):
self.device = '/org/bluez/hci0/dev_TEST'
self.service = self.device + '/service1'
self.char = self.service + '/char1'
self.objects = {
self.device: {t.DEVICE: {'Address': 'AA:BB:CC:DD:EE:FF', 'Connected': False, 'ServicesResolved': True}},
self.service: {t.SERVICE: {'UUID': t.HRS, 'Device': self.device}},
self.char: {t.CHARACTERISTIC: {'UUID': t.MEASUREMENT, 'Service': self.service, 'Flags': ['notify']}},
}
self.api = Mock()
self.api.GetManagedObjects.side_effect = lambda: self.objects
self.bus = Mock()
self.interface = Mock(return_value=self.api)
self.values = Mock()
def reader(self):
return t.BluezHeart(self.bus, self.interface, 'AA:BB:CC:DD:EE:FF', self.values)
def test_subscribe_receive_and_cleanup(self):
reader = self.reader()
self.api.Connect.assert_called_once()
self.assertTrue(reader.subscribe())
self.api.StartNotify.assert_called_once()
reader.changed(t.CHARACTERISTIC, {'Value': [0, 72]}, [], self.char)
self.values.assert_called_once_with([0, 72])
reader.changed(t.CHARACTERISTIC, {'Value': [0, 73]}, [], '/other/strap')
self.assertEqual(self.values.call_count, 1)
reader.close()
self.api.StopNotify.assert_called_once()
self.api.Disconnect.assert_called_once()
self.bus.add_signal_receiver.return_value.remove.assert_called_once()
def test_preserve_existing_connection(self):
self.objects[self.device][t.DEVICE]['Connected'] = True
reader = self.reader()
reader.subscribe()
reader.close()
self.api.Connect.assert_not_called()
self.api.Disconnect.assert_not_called()
def test_only_selected_device_service(self):
self.objects[self.service][t.SERVICE]['Device'] = '/other/device'
reader = self.reader()
try:
with self.assertRaises(RuntimeError):
reader.subscribe()
finally:
reader.close()
self.api.StartNotify.assert_not_called()
def test_wait_for_services(self):
self.objects[self.device][t.DEVICE]['ServicesResolved'] = False
reader = self.reader()
self.assertFalse(reader.subscribe())
reader.close()
self.api.StartNotify.assert_not_called()
self.api.StopNotify.assert_not_called()
def test_disconnect_notification(self):
reader = self.reader()
reader.changed(t.DEVICE, {'Connected': 0}, [], self.device) # dbus.Boolean behaves as int
self.values.assert_called_once_with(None)
reader.close()
def test_failed_notify_cleans_connection(self):
reader = self.reader()
self.api.StartNotify.side_effect = RuntimeError('failure')
with self.assertRaises(RuntimeError):
reader.subscribe()
reader.close()
self.api.StopNotify.assert_not_called()
self.api.Disconnect.assert_called_once()
def test_unknown_device_does_not_connect_or_scan(self):
self.objects.clear()
with self.assertRaises(RuntimeError):
self.reader()
self.api.Connect.assert_not_called()
self.api.StartDiscovery.assert_not_called()
-532
View File
@@ -1,532 +0,0 @@
#!/usr/bin/env python3
"""Runs ON the Frame (piped over SSH): UEVR, a flat-to-VR mod for Unreal games.
Usage: python3 - status APPID # can UEVR run here, is it installed, is the game running
python3 - install APPID # download the pinned official release into the game's prefix
python3 - start APPID # launch the game if needed, inject UEVR, confirm SteamVR frames
python3 - uninstall APPID # remove exactly what install (and UEVR's first run) created
Prints one JSON object. Errors are {"error": "..."} with exit status 1.
Verified 2026-09-29 on SteamOS 0.4.1 (build 20260925.6191901), Proton 11.0
ARM64 and SteamVR 2.18.1 with Gravitas (1067310): after injection the game has
UEVRBackend.dll loaded and SteamVR counts frame submits for it. Stereo image,
head tracking and controls were not seen: the headset was unworn (docs/mods.md).
UEVR's own injector (a .NET WPF app) is not used. Under Wine and FEX it hung
or ignored input in about one session in four, and gamescope keeps real input
away from it. Instead the official Windows embeddable Python runs INJECT_PY in
the game's Wine session, doing what the injector does on Inject.
Nothing here is bundled. UEVR comes from praydog's GitHub release and Python
from python.org, each checked against a pinned hash. Files live in
<prefix>/drive_c/frame-control, never in the game's own folder, and a receipt
records what to remove.
"""
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import time
import urllib.request
import zipfile
from pathlib import Path
HOME = Path.home()
STEAM = HOME / ".local/share/Steam"
CACHE = HOME / ".cache/frame-control/mods"
RECEIPTS = HOME / ".local/share/frame-control/mods"
LOCK = Path("/tmp/frame-control-mods.lock") # tmpfs: gone at reboot, never left in ~
VRCMD = "/opt/steamvr/bin/linuxarm64/vrcmd"
MANAGED = "frame-control" # drive_c/<this> inside the game's prefix
UEVR = {"version": "1.05", "dir": "uevr-1.05",
"url": "https://github.com/praydog/UEVR/releases/download/1.05/UEVR.zip",
"sha256": "af4f2f91306802d7ee4e8497d483a547ac8e9a3067dbafb81324100524215d3c"}
# Windows x64 embeddable Python, to run INJECT_PY. Hash from python.org's
# release-file API (sha256_sum).
PYTHON = {"version": "3.14.7", "dir": "python-3.14.7",
"url": "https://www.python.org/ftp/python/3.14.7/python-3.14.7-embed-amd64.zip",
"sha256": "d297e5ff019966817ad8502465176139f2d3d840fa4ed84b13bed399a6ab1f15"}
MAX_UNPACKED = 200 << 20 # the two archives unpack to about 50 MB
MAX_DOWNLOAD = 50 << 20 # each archive is 7–13 MB
# The game's environment a second program needs to join its Wine session. Not
# WINESERVERSOCKET and friends: those are inherited file descriptors.
GAME_ENV = re.compile(r"(WINEPREFIX|WINEDLLPATH|WINEDLLOVERRIDES|WINEFSYNC|WINEDEBUG|PATH|DISPLAY|"
r"XDG_RUNTIME_DIR|PROTON_VR_RUNTIME|FEX_APP_CONFIG|FEX_APP_CONFIG_LOCATION|"
r"SteamAppId|SteamGameId|STEAM_COMPAT_DATA_PATH|WINE_LARGE_ADDRESS_AWARE)=")
# Runs under Windows Python in the game's Wine session: the steps of UEVR
# 1.05's frontend (UEVR/MainWindow.xaml.cs Inject_Clicked, UEVR/Injector.cs)
# with "Nullify VR plugins" on and the OpenVR runtime. Prints one JSON line.
INJECT_PY = r"""
import ctypes, json, os, sys
from ctypes import wintypes as W
exe, uevr, profile = sys.argv[1:4]
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
H, P, SZ = W.HANDLE, ctypes.c_void_p, ctypes.c_size_t
for name, res, args in (
("OpenProcess", H, [W.DWORD, W.BOOL, W.DWORD]),
("CloseHandle", W.BOOL, [H]),
("VirtualAllocEx", P, [H, P, SZ, W.DWORD, W.DWORD]),
("VirtualFreeEx", W.BOOL, [H, P, SZ, W.DWORD]),
("WriteProcessMemory", W.BOOL, [H, P, P, SZ, P]),
("CreateRemoteThread", H, [H, P, SZ, P, P, W.DWORD, P]),
("WaitForSingleObject", W.DWORD, [H, W.DWORD]),
("GetModuleHandleW", W.HMODULE, [W.LPCWSTR]),
("LoadLibraryW", W.HMODULE, [W.LPCWSTR]),
("FreeLibrary", W.BOOL, [W.HMODULE]),
("GetProcAddress", P, [W.HMODULE, ctypes.c_char_p]),
("CreateToolhelp32Snapshot", H, [W.DWORD, W.DWORD]),
("Process32FirstW", W.BOOL, [H, P]), ("Process32NextW", W.BOOL, [H, P]),
("Module32FirstW", W.BOOL, [H, P]), ("Module32NextW", W.BOOL, [H, P])):
fn = getattr(k32, name)
fn.restype, fn.argtypes = res, args
INVALID = ctypes.c_void_p(-1).value
class Entry(ctypes.Structure): # PROCESSENTRY32W
_fields_ = [("size", W.DWORD), ("usage", W.DWORD), ("pid", W.DWORD), ("heap", ctypes.c_void_p),
("module", W.DWORD), ("threads", W.DWORD), ("parent", W.DWORD), ("prio", ctypes.c_long),
("flags", W.DWORD), ("name", W.WCHAR * 260)]
class Module(ctypes.Structure): # MODULEENTRY32W
_fields_ = [("size", W.DWORD), ("mid", W.DWORD), ("pid", W.DWORD), ("glbl", W.DWORD), ("proc", W.DWORD),
("base", ctypes.c_void_p), ("bytes", W.DWORD), ("handle", W.HMODULE),
("name", W.WCHAR * 256), ("path", W.WCHAR * 260)]
def fail(msg):
print(json.dumps({"error": f"{msg} (Windows error {ctypes.get_last_error()})"})); sys.exit(1)
def snapshot(flags, pid, entry, first, next_, match):
snap = k32.CreateToolhelp32Snapshot(flags, pid)
if not snap or snap == INVALID:
fail("couldn't list processes")
try:
entry.size = ctypes.sizeof(entry)
ok = first(snap, ctypes.byref(entry))
while ok:
if match(entry):
return entry
ok = next_(snap, ctypes.byref(entry))
finally:
k32.CloseHandle(snap)
def remote_call(proc, fn, arg, wait_ms):
t = k32.CreateRemoteThread(proc, None, 0, fn, arg, 0, None)
if not t:
fail("CreateRemoteThread failed")
try:
return k32.WaitForSingleObject(t, wait_ms) == 0 # WAIT_OBJECT_0
finally:
k32.CloseHandle(t)
def inject(proc, pid, name):
path = os.path.join(uevr, name)
data = ctypes.create_unicode_buffer(path)
mem = k32.VirtualAllocEx(proc, None, ctypes.sizeof(data), 0x3000, 0x04) # commit|reserve, read/write
if not mem:
fail(f"couldn't allocate in {exe}")
try:
if not k32.WriteProcessMemory(proc, mem, data, ctypes.sizeof(data), None):
fail(f"couldn't write into {exe}")
load = k32.GetProcAddress(k32.GetModuleHandleW("kernel32.dll"), b"LoadLibraryW")
if not remote_call(proc, load, mem, 30000):
fail(f"loading {name} into {exe} didn't finish in 30 s")
finally:
k32.VirtualFreeEx(proc, mem, 0, 0x8000) # MEM_RELEASE
m = snapshot(0x18, pid, Module(), k32.Module32FirstW, k32.Module32NextW, # TH32CS_SNAPMODULE | SNAPMODULE32
lambda m: m.path.lower() == path.lower())
if not m:
fail(f"{name} didn't load into {exe}")
return path, m.base
e = snapshot(2, 0, Entry(), k32.Process32FirstW, k32.Process32NextW, # TH32CS_SNAPPROCESS
lambda e: e.name.lower() == exe.lower())
if not e:
fail(f"{exe} isn't running")
pid = e.pid
proc = k32.OpenProcess(0x1F0FFF, False, pid) # PROCESS_ALL_ACCESS
if not proc:
fail(f"couldn't open {exe}")
path, base = inject(proc, pid, "UEVRPluginNullifier.dll")
local = k32.LoadLibraryW(path)
fn = local and k32.GetProcAddress(local, b"nullify")
if not fn:
fail("couldn't find UEVRPluginNullifier.dll's nullify")
offset = fn - local # same DLL, same layout in both processes
k32.FreeLibrary(local)
# The frontend waits 2 s for nullify and carries on either way; so do we, but say so.
nullified = remote_call(proc, base + offset, None, 2000)
inject(proc, pid, "openvr_api.dll")
# The frontend saves the runtime choice in the per-game config before UEVRBackend reads it.
os.makedirs(profile, exist_ok=True)
cfg = os.path.join(profile, "config.txt")
lines = open(cfg).read().splitlines() if os.path.exists(cfg) else []
lines = [l for l in lines if not l.startswith("Frontend_RequestedRuntime=")] + ["Frontend_RequestedRuntime=openvr_api.dll"]
open(cfg, "w").write("\n".join(lines) + "\n")
inject(proc, pid, "UEVRBackend.dll")
k32.CloseHandle(proc)
print(json.dumps({"pid": pid, "nullified": nullified}))
"""
class Fail(Exception):
pass
def libraries():
"""Steam library folders: the default one plus any in libraryfolders.vdf."""
libs = [STEAM]
try:
text = (STEAM / "steamapps/libraryfolders.vdf").read_text(errors="replace")
libs += [Path(p.replace("\\\\", "\\")) for p in re.findall(r'"path"\s+"([^"]+)"', text)]
except OSError:
pass
seen, out = set(), []
for lib in libs:
key = os.path.realpath(lib)
if key not in seen:
seen.add(key)
out.append(lib)
return out
def game(appid):
"""Where the game is installed and its Proton prefix, from its app manifest."""
for lib in libraries():
manifest = lib / f"steamapps/appmanifest_{appid}.acf"
try:
text = manifest.read_text(errors="replace")
except OSError:
continue
m = re.search(r'"installdir"\s+"([^"]+)"', text)
if not m:
continue
name = re.search(r'"name"\s+"([^"]*)"', text)
return {"name": name.group(1) if name else str(appid),
"dir": lib / "steamapps/common" / m.group(1),
"prefix": lib / f"steamapps/compatdata/{appid}/pfx"}
raise Fail(f"app {appid} isn't installed on this Frame")
def shipping_exe(game_dir):
"""The Unreal Engine game binary (…/Binaries/Win64/*-Win64-Shipping.exe), or None."""
found = [p for p in game_dir.glob("*/Binaries/Win64/*.exe") if p.name.lower().endswith("-win64-shipping.exe")]
found += [p for p in game_dir.glob("Binaries/Win64/*.exe") if p.name.lower().endswith("-win64-shipping.exe")]
return max(found, key=lambda p: p.stat().st_size) if found else None
def game_pid(exe_name):
"""The running Wine process for this .exe (its cmdline is the Windows path)."""
want = "\\" + exe_name.lower()
for d in Path("/proc").iterdir():
if not d.name.isdigit():
continue
try:
cmd = (d / "cmdline").read_bytes().split(b"\0")[0].decode(errors="replace").lower()
except OSError:
continue
if cmd.endswith(want):
return int(d.name)
return None
def loaded(pid, name):
try:
return any(line.rstrip().endswith("/" + name) for line in open(f"/proc/{pid}/maps"))
except OSError:
return False
def receipt_path(appid):
return RECEIPTS / f"{appid}-uevr.json"
def read_receipt(appid):
try:
return json.loads(receipt_path(appid).read_text())
except (OSError, ValueError):
return None
def managed_dir(g):
return g["prefix"] / "drive_c" / MANAGED
def user_dirs(g, exe):
"""What UEVR itself writes into the prefix when it first runs."""
user = g["prefix"] / "drive_c/users/steamuser/AppData"
return {"profile": user / "Roaming/UnrealVRMod" / exe.stem, # per-game settings and log.txt
"injector": user / "Local/praydog"} # the injector's own settings
def status(appid):
g = game(appid)
exe = shipping_exe(g["dir"])
r = read_receipt(appid)
pid = game_pid(exe.name) if exe else None
return {"appid": appid, "name": g["name"], "mod": "UEVR", "version": UEVR["version"],
"eligible": bool(exe), "exe": exe.name if exe else None,
"prefix": g["prefix"].is_dir(), "installed": bool(r),
"running": bool(pid), "injected": bool(pid and loaded(pid, "UEVRBackend.dll"))}
def fetch(url, digest, algo):
"""Download into the cache once, and check its hash every time it's used."""
CACHE.mkdir(parents=True, exist_ok=True)
path = CACHE / url.rsplit("/", 1)[1]
if not path.exists():
part = path.with_suffix(path.suffix + ".part")
try:
with urllib.request.urlopen(url, timeout=60) as r, open(part, "wb") as f:
for block in iter(lambda: r.read(1 << 20), b""):
if f.tell() + len(block) > MAX_DOWNLOAD:
raise Fail(f"{url} is bigger than expected; stopped the download")
f.write(block)
except (OSError, Fail) as e:
part.unlink(missing_ok=True)
raise Fail(f"download failed: {url}: {e}")
part.rename(path)
h = hashlib.new(algo)
with open(path, "rb") as f:
for block in iter(lambda: f.read(1 << 20), b""):
h.update(block)
if h.hexdigest() != digest:
path.unlink()
raise Fail(f"{path.name} doesn't match its published {algo}; deleted it, try again")
return path
def unpack(archive, dest, budget):
"""Extract a ZIP into dest, refusing anything that would land outside it."""
root = os.path.realpath(dest)
with zipfile.ZipFile(archive) as z:
for info in z.infolist():
target = os.path.realpath(os.path.join(dest, info.filename))
if target != root and not target.startswith(root + os.sep):
raise Fail(f"{archive.name} has an entry outside its folder: {info.filename}")
if (info.external_attr >> 16) & 0o170000 == 0o120000:
raise Fail(f"{archive.name} contains a symlink: {info.filename}")
budget -= info.file_size
if budget < 0:
raise Fail(f"{archive.name} unpacks to more than expected")
z.extractall(dest)
return budget
def install(appid):
g = game(appid)
exe = shipping_exe(g["dir"])
if not exe:
raise Fail(f"{g['name']} doesn't look like an Unreal Engine game (no *-Win64-Shipping.exe), so UEVR can't hook it")
if not g["prefix"].is_dir():
raise Fail(f"{g['name']} has no Proton prefix yet; play it once, then add the mod")
if game_pid(exe.name):
raise Fail(f"{g['name']} is running; quit it first")
if read_receipt(appid):
return {"message": f"UEVR {UEVR['version']} is already installed for {g['name']}", **status(appid)}
archives = [(fetch(UEVR["url"], UEVR["sha256"], "sha256"), UEVR["dir"])]
archives.append((fetch(PYTHON["url"], PYTHON["sha256"], "sha256"), PYTHON["dir"]))
base = managed_dir(g)
dirs = user_dirs(g, exe)
fresh = [str(d) for d in (dirs["profile"].parent, base) if not d.exists()] # remove on uninstall if empty
stage = base / f".staging-{os.getpid()}"
shutil.rmtree(stage, ignore_errors=True)
try:
budget = MAX_UNPACKED
for archive, sub in archives:
budget = unpack(archive, stage / sub, budget)
for need in (stage / UEVR["dir"] / "UEVRBackend.dll", stage / PYTHON["dir"] / "python.exe"):
if not need.is_file():
raise Fail(f"the download has no {need.name}")
if game_pid(exe.name): # it may have started during the download
raise Fail(f"{g['name']} started; quit it first")
(stage / PYTHON["dir"] / "frame_inject.py").write_text(INJECT_PY)
for sub in (UEVR["dir"], PYTHON["dir"]):
shutil.rmtree(base / sub, ignore_errors=True)
(stage / sub).rename(base / sub)
finally:
shutil.rmtree(stage, ignore_errors=True)
if str(base) in fresh:
try:
base.rmdir() # only succeeds if the install failed and left it empty
except OSError:
pass
receipt = {"appid": appid, "mod": "UEVR", "version": UEVR["version"], "exe": exe.name,
"installed": int(time.time()), "prefix": str(g["prefix"]),
"remove": [str(base / UEVR["dir"]), str(base / PYTHON["dir"])],
# Parents that didn't exist yet (UEVR makes UnrealVRMod), deepest first.
"remove_if_empty": fresh,
# UEVR creates these on first injection. Remove them on uninstall
# only if they weren't there before, so earlier settings survive.
"remove_if_created": {k: str(v) for k, v in dirs.items() if not v.exists()},
"sources": [UEVR["url"], PYTHON["url"]]}
RECEIPTS.mkdir(parents=True, exist_ok=True)
tmp = receipt_path(appid).with_suffix(".tmp")
tmp.write_text(json.dumps(receipt, indent=1))
tmp.rename(receipt_path(appid))
return {"message": f"Installed UEVR {UEVR['version']} for {g['name']}", **status(appid)}
def x11_windows(display):
"""(id, name, width) for each named top-level window, via xwininfo."""
try:
out = subprocess.run(["xwininfo", "-root", "-tree"], env={**os.environ, "DISPLAY": display},
capture_output=True, text=True, timeout=10).stdout
except (OSError, subprocess.TimeoutExpired):
return []
return [(int(m[1], 16), m[2], int(m[3])) for m in re.finditer(r'(0x[0-9a-f]+) "([^"]*)":.*?\)\s+(\d+)x\d+', out)]
def frame_submits(appid):
"""SteamVR's count of frames the app has submitted, or None if it isn't a scene app."""
try:
stats = json.loads(subprocess.run([VRCMD, "--stats"], capture_output=True, text=True, timeout=15).stdout)
except (OSError, ValueError, subprocess.TimeoutExpired):
return None
if not isinstance(stats, list):
return None
return next((s.get("frame_submits") for s in stats if isinstance(s, dict) and s.get("key") == f"steam.app.{appid}"), None)
def wait(what, seconds, check, step=2):
end = time.time() + seconds
while time.time() < end:
v = check()
if v is not None and v is not False: # 0 frames is still an answer
return v
time.sleep(step)
raise Fail(f"timed out waiting for {what}")
def start(appid):
g = game(appid)
r = read_receipt(appid)
if not r:
raise Fail(f"UEVR isn't installed for {g['name']}")
exe = r["exe"]
pid = game_pid(exe)
if pid and loaded(pid, "UEVRBackend.dll"):
return {"message": f"UEVR is already running in {g['name']}", "frame_submits": frame_submits(appid), **status(appid)}
if not pid:
subprocess.Popen(["steam", f"steam://rungameid/{appid}"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
env={**os.environ, "DISPLAY": os.environ.get("DISPLAY", ":0")}, start_new_session=True)
pid = wait(f"{g['name']} to start", 120, lambda: game_pid(exe))
try:
environ = Path(f"/proc/{pid}/environ").read_text(errors="replace")
except OSError:
raise Fail(f"{exe} exited") from None
game_env = dict(line.split("=", 1) for line in environ.split("\0") if GAME_ENV.match(line))
display = game_env.get("DISPLAY")
if not display or "WINEPREFIX" not in game_env:
raise Fail(f"{exe} doesn't look like a Proton game process")
# UEVR hooks the game's D3D11/12 device, so wait for its window. Proton's
# own helper windows ("Steam", "Default IME", …) are small.
wait("the game window", 90, lambda: any(w[2] >= 640 for w in x11_windows(display)))
time.sleep(10)
# Our own environment (HOME and the rest) under the game's Wine settings.
env = {**os.environ, **game_env}
base = f"C:\\{MANAGED}"
profile = f"C:\\users\\steamuser\\AppData\\Roaming\\UnrealVRMod\\{Path(exe).stem}"
try:
out = subprocess.run(["wine", f"{base}\\{PYTHON['dir']}\\python.exe", f"{base}\\{PYTHON['dir']}\\frame_inject.py",
exe, f"{base}\\{UEVR['dir']}", profile], env=env, capture_output=True, text=True,
timeout=120, stdin=subprocess.DEVNULL,
cwd=str(Path(r["prefix"]) / "drive_c" / MANAGED))
except subprocess.TimeoutExpired:
raise Fail("the injection step didn't finish in 2 minutes") from None
reply = None
for line in reversed(out.stdout.splitlines()):
try:
reply = json.loads(line)
break
except ValueError:
continue
if not isinstance(reply, dict):
reply = None
if not reply or "error" in reply:
raise Fail("UEVR injection failed: " + (reply or {}).get("error", (out.stderr.strip().splitlines() or ["no output"])[-1]))
try:
wait("UEVRBackend.dll to load in the game", 20, lambda: loaded(pid, "UEVRBackend.dll"))
except Fail:
raise Fail(f"the injection reported success but {exe} has no UEVRBackend.dll") from None
submits = None
try:
submits = wait("SteamVR frames", 30, lambda: frame_submits(appid))
except Fail:
pass
return {"message": f"UEVR injected into {g['name']}" + ("; SteamVR is receiving its frames" if submits is not None
else "; SteamVR hasn't reported frames from it yet"),
"frame_submits": submits, **status(appid)}
def uninstall(appid):
r = read_receipt(appid)
if not r:
raise Fail(f"UEVR isn't installed for app {appid}")
if game_pid(r["exe"]):
raise Fail("the game is running; quit it first")
prefix = os.path.realpath(r["prefix"])
if not prefix.endswith(f"/steamapps/compatdata/{appid}/pfx"):
raise Fail(f"receipt names a prefix that isn't app {appid}'s: {r['prefix']}")
# Only ever our folder and UEVR's own settings folders for this game, even
# if the receipt says otherwise.
managed = os.path.join(prefix, "drive_c", MANAGED)
uevr_dirs = {os.path.realpath(str(d)) for d in user_dirs({"prefix": Path(prefix)}, Path(r["exe"])).values()}
targets = list(r.get("remove", [])) + list(r.get("remove_if_created", {}).values())
for t in targets:
real = os.path.realpath(t)
if not (real.startswith(managed + os.sep) or real in uevr_dirs):
raise Fail(f"receipt lists a path Frame Control didn't create: {t}")
for t in targets:
shutil.rmtree(t, ignore_errors=True)
left = [t for t in targets if os.path.lexists(t)]
if left: # keep the receipt, so Remove can be tried again
raise Fail("couldn't remove " + ", ".join(left))
parents = {os.path.realpath(os.path.join(prefix, "drive_c/users/steamuser/AppData/Roaming/UnrealVRMod")), managed}
for t in r.get("remove_if_empty", []):
if os.path.realpath(t) in parents:
try:
os.rmdir(t)
except OSError:
pass # something else lives there now
receipt_path(appid).unlink()
if not any(RECEIPTS.glob("*-uevr.json")): # no other game uses the downloads
shutil.rmtree(CACHE, ignore_errors=True)
try:
RECEIPTS.rmdir()
except OSError:
pass
return {"message": "Removed UEVR", "appid": appid, "removed": targets}
def main(argv):
if len(argv) != 2 or argv[0] not in ("status", "install", "start", "uninstall") or not argv[1].isdigit():
raise Fail("usage: status|install|start|uninstall APPID")
action = {"status": status, "install": install, "start": start, "uninstall": uninstall}[argv[0]]
if action is status:
return status(int(argv[1]))
import fcntl # only on the Frame; the server and its tests also import this module on Windows
# One change at a time, or an uninstall could delete what an install is moving in.
LOCK.parent.mkdir(parents=True, exist_ok=True)
with open(LOCK, "w") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
raise Fail("another mod action is still running on the Frame; try again when it finishes") from None
return action(int(argv[1]))
if __name__ == "__main__":
try:
print(json.dumps(main(sys.argv[1:])))
except Fail as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
except Exception as e: # still one JSON object, for the server to show
print(json.dumps({"error": f"{type(e).__name__}: {e}"}))
sys.exit(1)
+105 -129
View File
@@ -27,8 +27,10 @@
}
* { box-sizing: border-box; }
[hidden] { display: none !important; } /* class display rules would otherwise win */
html { scroll-behavior: smooth; scroll-padding-top: calc(76px + env(safe-area-inset-top)); }
body { margin: 0; color: var(--text); min-height: 100vh; padding-bottom: calc(46px + env(safe-area-inset-bottom));
/* On a computer the header and the Activity bar are the window's chrome: the page scrolls in #scroll
between them, so neither covers anything. Phones scroll the window (see the phone rules below). */
body { margin: 0; color: var(--text); height: 100vh; display: flex; flex-direction: column; overflow: hidden;
padding-bottom: calc(42px + env(safe-area-inset-bottom));
font: 14px/1.45 "Motiva Sans", -apple-system, "Segoe UI", Arial, sans-serif;
background: radial-gradient(1400px 700px at 15% -15%, #2a475e 0%, transparent 60%),
linear-gradient(180deg, var(--bg) 0%, #16202d 40%, var(--bg-deep) 100%) fixed; }
@@ -38,13 +40,14 @@
::-webkit-scrollbar-track { background: transparent; }
/* ---- top bar (Steam's STORE / LIBRARY / COMMUNITY strip) ---- */
header { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: 30px;
#scroll { flex: 1; min-height: 0; overflow: auto; scroll-behavior: smooth; scroll-padding-top: 12px; }
header { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: 30px; flex: none; min-width: 0;
height: calc(64px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) calc(28px + env(safe-area-inset-right)) 0 calc(28px + env(safe-area-inset-left)); background: rgba(23, 29, 37, .94); backdrop-filter: blur(12px);
box-shadow: 0 1px 0 rgba(255,255,255,.04), 0 6px 18px rgba(0,0,0,.35); }
.brand { display: flex; align-items: center; gap: 11px; color: var(--bright); text-decoration: none; }
.brand { display: flex; align-items: center; gap: 11px; color: var(--bright); text-decoration: none; flex: none; }
.brand svg { width: 30px; height: 30px; }
.brand b { font-weight: 700; font-size: 15px; letter-spacing: 2.5px; }
nav { display: flex; gap: 4px; height: 100%; }
nav { display: flex; gap: 4px; height: 100%; flex: none; }
nav a { display: flex; align-items: center; padding: 0 13px; height: 100%; color: var(--text); text-decoration: none;
font-weight: 500; font-size: 15px; letter-spacing: 1.2px; text-transform: uppercase;
border-bottom: 3px solid transparent; transition: color .15s; }
@@ -62,11 +65,18 @@
.dot.off { background: var(--bad); }
main { max-width: 1480px; margin: 0 auto; padding: 22px calc(28px + env(safe-area-inset-right)) 30px calc(28px + env(safe-area-inset-left)); display: flex; flex-direction: column; gap: 26px; }
.grid-top { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(330px, 1fr); gap: 22px; }
.grid-top { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(330px, 1fr); gap: 22px; align-items: start; }
.grid-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 22px; }
@media (max-width: 1366px) { header { gap: 20px; } nav a { padding: 0 11px; } }
@media (max-width: 1150px) { .grid-top, .grid-3 { grid-template-columns: 1fr; } header { gap: 16px; padding: env(safe-area-inset-top) calc(18px + env(safe-area-inset-right)) 0 calc(18px + env(safe-area-inset-left)); } nav a { padding: 0 10px; } }
@media (max-width: 820px) { .brand b { display: none; } }
/* The header gives way as the window narrows: the wordmark goes, then the tabs tighten, then the
connection pill drops its second line. It fits down to the app's 760 px minimum, with room for
the macOS window buttons the app adds on the left. */
.hdr-end > button:not(.pill), .hdr-end > .chip { flex: none; }
.hdr-end > button:not(.pill):not(.chip) { width: 36px; padding: 0; justify-content: center; }
@media (max-width: 1250px) { .brand b { display: none; } }
@media (max-width: 1150px) { .grid-top, .grid-3 { grid-template-columns: 1fr; } header { gap: 14px; padding: env(safe-area-inset-top) calc(18px + env(safe-area-inset-right)) 0 calc(18px + env(safe-area-inset-left)); } nav a { padding: 0 10px; } }
@media (max-width: 960px) { header { gap: 10px; } nav { gap: 0; } nav a { padding: 0 8px; font-size: 13px; letter-spacing: .4px; }
.brand, #refreshAll { display: none; } .pill .pt span { display: none; } }
nav svg { display: none; }
html { -webkit-text-size-adjust: 100%; }
body { -webkit-tap-highlight-color: transparent; }
@@ -92,7 +102,7 @@
.wait::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--dim); flex: none; }
/* ---- connection pill, its details dialog, and the Devices tab (frame_link.py) ---- */
.pill { height: 40px; padding: 0 12px; gap: 9px; max-width: 420px; min-width: 150px; flex: 0 1 auto; background: var(--btn); }
.pill { height: 40px; padding: 0 12px; gap: 9px; max-width: 320px; min-width: 0; flex: 0 1 auto; background: var(--btn); }
.pill .dot { flex: none; }
.pill .dot.wait { background: var(--warn); box-shadow: 0 0 6px rgba(217,162,58,.7); animation: pulse 1s ease-in-out infinite; }
@keyframes pulse { 50% { opacity: .35; } }
@@ -154,8 +164,10 @@
/* ---- panels and section headers ---- */
.panel { background: linear-gradient(180deg, rgba(42,47,56,.72) 0%, rgba(30,35,41,.9) 100%);
border-radius: 4px; padding: 18px; box-shadow: 0 8px 24px rgba(0,0,0,.25); min-width: 0; }
.shelf-head { display: flex; align-items: baseline; gap: 12px; margin: 0 0 14px; }
.shelf-head h2 { margin: 0; font-size: 13px; font-weight: 700; letter-spacing: 2px; text-transform: uppercase; color: var(--text); }
/* Buttons in a heading row wrap onto a line of their own rather than squeeze the heading. */
.shelf-head { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px 12px; margin: 0 0 14px; }
.panel > .shelf-head:first-child h2 { line-height: 28px; } /* as tall as a button, so headings line up across panels */
.shelf-head h2 { margin: 0; font-size: 13px; font-weight: 700; letter-spacing: 2px; text-transform: uppercase; color: var(--text); white-space: nowrap; }
.shelf-head .count { color: var(--muted); font-size: 13px; }
.shelf-head .spacer { flex: 1; }
.sub { color: var(--muted); font-size: 12.5px; }
@@ -184,6 +196,11 @@
border-radius: 3px; padding: 9px 11px; font: inherit; }
textarea { resize: vertical; min-height: 76px; }
input:focus, textarea:focus { outline: none; border-color: var(--blue); background: rgba(0,0,0,.4); }
/* Every drop-down dark, like the fields; the panels' own rules below still size them. */
select { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
padding: 7px 8px; font: inherit; font-size: 13px; max-width: 100%; min-width: 0; }
select:focus-visible { outline: 2px solid var(--blue); outline-offset: 1px; }
option { background: #1e2329; color: var(--text); }
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
/* ---- headset view ---- */
@@ -228,15 +245,21 @@
@keyframes spin { to { transform: rotate(360deg); } }
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
/* ---- device ---- */
.battery { display: flex; gap: 16px; align-items: center; padding: 16px; border-radius: 3px; background: rgba(0,0,0,.22); }
.battery svg { width: 74px; height: 38px; flex: none; }
.battery .pct { font-size: 34px; font-weight: 300; color: var(--bright); line-height: 1; font-variant-numeric: tabular-nums; }
.battery .state { font-weight: 700; font-size: 13px; letter-spacing: 1.2px; text-transform: uppercase; margin-top: 6px; }
.battery .state.charging { color: var(--green-hi); } .battery .state.full { color: var(--green); }
.battery .state.low { color: var(--bad); } .battery .state.draining { color: var(--muted); }
.battery .detail { color: var(--muted); font-size: 12.5px; margin-top: 2px; }
/* ---- the headset's details: a menu under the battery chip ---- */
.pop { position: fixed; inset: auto; margin: 0; width: min(390px, calc(100vw - 24px)); max-height: calc(100vh - 90px); overflow: auto;
padding: 16px; border: 1px solid rgba(255,255,255,.1); border-radius: 6px; background: #1e2329; color: var(--text);
box-shadow: 0 20px 60px rgba(0,0,0,.6); -webkit-app-region: no-drag; }
.pop .shelf-head { margin-bottom: 10px; }
.pop .shelf-head h2 { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.pop .stats { margin-top: 12px; }
.pop .chips { margin-top: 12px; }
.batt .state { font-weight: 700; font-size: 12.5px; letter-spacing: 1.2px; text-transform: uppercase; }
.batt .state.charging { color: var(--green-hi); } .batt .state.full { color: var(--green); }
.batt .state.low { color: var(--bad); } .batt .state.draining { color: var(--text); }
.batt .detail { color: var(--muted); font-size: 12.5px; margin-top: 2px; }
button.chip { height: 32px; padding: 0 12px; font-size: 13px; }
.stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1px; margin-top: 14px; border-radius: 3px; overflow: hidden; }
.stats > .stat:last-child:nth-child(odd) { grid-column: 1 / -1; }
.stat { background: rgba(0,0,0,.18); padding: 10px 12px; min-width: 0; }
.stat .k { color: var(--muted); font-size: 11px; letter-spacing: 1px; text-transform: uppercase; }
.stat .v { color: var(--bright); font-size: 17px; margin-top: 1px; font-variant-numeric: tabular-nums;
@@ -346,9 +369,9 @@
.and-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 22px; align-items: start; }
.and-col { display: grid; gap: 22px; align-content: start; }
.rep-item .s { white-space: normal; }
#bugDlg, #repDlg, #titleDlg, #wiDlg, #pwDlg, #apkAltDlg, #aboutDlg, #modDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
#bugDlg, #repDlg, #titleDlg, #wiDlg, #pwDlg, #apkAltDlg, #aboutDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
padding: 22px; width: min(560px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
#bugDlg::backdrop, #repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop, #aboutDlg::backdrop, #modDlg::backdrop { background: rgba(0,0,0,.55); }
#bugDlg::backdrop, #repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop, #aboutDlg::backdrop { background: rgba(0,0,0,.55); }
#bugDlg { width: min(640px, calc(100vw - 40px)); }
#bugForm label.field { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
#bugForm label.field input, #bugForm label.field textarea, #bugForm select { margin-top: 5px; }
@@ -357,7 +380,7 @@
#bugForm .popt { margin: 14px 0 0; }
#bugForm .sentlog { max-height: 200px; }
#bugWarn { color: var(--muted); font-size: 12.5px; line-height: 1.45; margin: 12px 0 0; }
#bugDlg h2, #repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2, #apkAltDlg h2, #aboutDlg h2, #modDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
#bugDlg h2, #repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2, #apkAltDlg h2, #aboutDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
.about-text { max-height: 55vh; overflow: auto; }
.about-text pre { white-space: pre-wrap; font-size: 12px; color: var(--muted); }
.about-text summary { cursor: pointer; margin: 8px 0; }
@@ -365,7 +388,7 @@
#repForm label input[type=text], #repForm textarea, #titleForm label input, #titleForm label select { margin-top: 5px; }
#titleForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
border-radius: 3px; padding: 8px 10px; font: inherit; }
#titleForm select:focus { outline: none; border-color: var(--blue); }
#titleForm select:focus { border-color: var(--blue); }
#titleForm .note { font-size: 12.5px; color: var(--muted); margin-top: 10px; }
#titleForm .note.warn { color: #d9a23a; }
#titleList { margin-top: 8px; }
@@ -413,7 +436,6 @@
.game-card .bar > i { display: block; height: 100%; background: var(--action); }
.f-0 { color: var(--muted); }
/* ---- Android display ---- */
#mvQuality { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px; padding: 4px 6px; font: inherit; font-size: 12px; }
.mv-windows { max-height: 420px; overflow-y: auto; }
.mv-perm { background: rgba(255,190,60,.08); border: 1px solid rgba(255,190,60,.25); border-radius: 4px; padding: 10px 12px; margin-bottom: 12px; font-size: 13px; }
.mv-perm .row { margin-top: 8px; }
@@ -422,7 +444,7 @@
.mv-opt .sub { display: block; margin-left: 22px; font-size: 12px; }
.disp select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
padding: 8px 10px; font: inherit; font-size: 13px; }
.disp select:focus { outline: none; border-color: var(--blue); }
.disp select:focus { border-color: var(--blue); }
.disp .stats { grid-template-columns: repeat(3, 1fr); margin: 12px 0 4px; }
.disp select:disabled { opacity: .6; }
.disp .stat .v { font-size: 15px; }
@@ -449,11 +471,14 @@
/* ---- phones, upright or on their side: tabs move to a bottom bar, as in iOS apps. Every edge keeps clear of
the safe area (notch or Dynamic Island, rounded corners, home indicator); env() is zero on desktops. ---- */
@media (max-width: 640px), (max-height: 500px) and (hover: none) {
html { scroll-padding-top: calc(64px + env(safe-area-inset-top)); }
html { scroll-behavior: smooth; scroll-padding-top: calc(64px + env(safe-area-inset-top)); }
body { height: auto; display: block; overflow: visible; }
#scroll { overflow: visible; }
/* No blur here: it would make the header the containing block of the fixed tab bar. */
header { height: calc(52px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) calc(14px + env(safe-area-inset-right)) 0 calc(14px + env(safe-area-inset-left)); gap: 10px;
backdrop-filter: none; background: rgb(23, 29, 37); }
.brand svg { width: 26px; height: 26px; }
.brand { display: flex; } #refreshAll { display: inline-flex; } .pill .pt span { display: block; } /* the tabs are at the bottom here */
header .chip { height: 30px; padding: 0 10px; }
/* Let connection text yield before battery and header actions. */
.hdr-end { flex: 1; gap: 6px; }
@@ -650,7 +675,7 @@
.detail-layout { display:flex; flex-direction:column; gap:24px; } .detail-buy { order:-1; width:100%; }
.sources-dialog { padding:24px; } .sources-dialog .store-close { margin:-12px -12px -20px 0; }
}
@media(prefers-reduced-motion:reduce) { html { scroll-behavior:auto; } .store-card, .source-toggle::after { transition:none; } .store-skeleton { animation:none; } }
@media(prefers-reduced-motion:reduce) { html, #scroll { scroll-behavior:auto; } .store-card, .source-toggle::after { transition:none; } .store-skeleton { animation:none; } }
</style>
<link rel="stylesheet" href="/control-center.css">
@@ -677,7 +702,7 @@
<div class="hdr-end">
<button class="pill" id="conn" role="status" aria-live="polite" title="Connection details"><span class="dot wait"></span><span class="pt"><b>Connecting…</b><span></span></span></button>
<select class="devsel desk-only" id="devSel" aria-label="Headset" title="Switch headset" hidden></select>
<span class="chip" id="battChip" title="Battery">—</span>
<button class="chip" id="battChip" title="Battery and headset details" popovertarget="devPop" aria-haspopup="dialog" hidden>—</button>
<button id="reportBtn" data-report title="Report a problem, with diagnostics" aria-label="Report a problem">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path d="M12 3l10 18H2z"/><path d="M12 10v5M12 18v.5"/></svg>
</button>
@@ -686,7 +711,21 @@
</button>
</div>
</header>
<div class="pop" id="devPop" popover role="dialog" aria-labelledby="devPopTitle">
<div class="shelf-head"><h2 id="devPopTitle">Headset</h2><span class="count" id="devName"></span><span class="spacer"></span><span class="sub" id="updated"></span></div>
<div class="batt"><div class="state draining" id="battState">&nbsp;</div><div class="detail" id="battDetail">&nbsp;</div></div>
<div class="stats">
<div class="stat"><div class="k">Storage free</div><div class="v" id="sDisk">—</div><div class="meter"><i id="mDisk"></i></div></div>
<div class="stat"><div class="k">Memory used</div><div class="v" id="sMem">—</div><div class="meter"><i id="mMem"></i></div></div>
<div class="stat"><div class="k">Temperature</div><div class="v" id="sTemp">—</div><div class="d" id="dTemp">&nbsp;</div></div>
<div class="stat"><div class="k">Wi-Fi</div><div class="v" id="sWifi">—</div><div class="d" id="dWifi">&nbsp;</div></div>
<div class="stat"><div class="k">Uptime</div><div class="v" id="sUp">—</div><div class="d" id="dUp">&nbsp;</div></div>
<div class="stat"><div class="k">SteamOS</div><div class="v" id="sOs">—</div><div class="d" id="dOs">&nbsp;</div></div>
</div>
<div class="chips" id="services" aria-label="Running on the headset"></div>
</div>
<div id="scroll">
<main>
<div class="notice" id="updateBar" hidden>
<div class="grow" id="updateText"></div>
@@ -827,29 +866,7 @@
</section>
<section class="panel" id="device">
<div class="shelf-head"><h2>Device</h2><span class="count" id="devName"></span><span class="spacer"></span><span class="sub" id="updated"></span></div>
<div class="battery">
<svg viewBox="0 0 74 38" aria-hidden="true">
<rect x="1.5" y="1.5" width="64" height="35" rx="5" fill="none" stroke="#8f98a0" stroke-width="3"/>
<rect x="67" y="12" width="5.5" height="14" rx="2" fill="#8f98a0"/>
<rect id="battFill" x="6" y="6" width="0" height="26" rx="2.5" fill="#59bf40"/>
<path id="battBolt" d="M37 5 L27 21 H34 L31 33 L42 16 H35 Z" fill="#fff" stroke="#1e2329" stroke-width="1.6" stroke-linejoin="round" visibility="hidden"/>
</svg>
<div style="min-width:0">
<div class="pct" id="battPct">—</div>
<div class="state draining" id="battState">&nbsp;</div>
<div class="detail" id="battDetail">&nbsp;</div>
</div>
</div>
<div class="stats">
<div class="stat"><div class="k">Storage free</div><div class="v" id="sDisk">—</div><div class="meter"><i id="mDisk"></i></div></div>
<div class="stat"><div class="k">Memory used</div><div class="v" id="sMem">—</div><div class="meter"><i id="mMem"></i></div></div>
<div class="stat"><div class="k">Temperature</div><div class="v" id="sTemp">—</div><div class="d" id="dTemp">&nbsp;</div></div>
<div class="stat"><div class="k">Wi-Fi</div><div class="v" id="sWifi">—</div><div class="d" id="dWifi">&nbsp;</div></div>
<div class="stat"><div class="k">Uptime</div><div class="v" id="sUp">—</div><div class="d" id="dUp">&nbsp;</div></div>
<div class="stat"><div class="k">SteamOS</div><div class="v" id="sOs">—</div><div class="d" id="dOs">&nbsp;</div></div>
</div>
<div class="chips" id="services"></div>
<div class="shelf-head"><h2>Volume</h2></div>
<div class="vol">
<button class="small" id="muteBtn" aria-label="Mute">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4 9h4l5-4v14l-5-4H4z"/><path id="volWaves" d="M16 8.5a5 5 0 0 1 0 7M18.5 6a8.5 8.5 0 0 1 0 12" fill="none" stroke="currentColor" stroke-width="2"/></svg>
@@ -863,7 +880,7 @@
<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="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. Battery and temperature are in the battery menu at the top.</div>
<div class="chips">
<button class="small" id="hudStart">Open HUD in headset</button>
<button class="small" id="hudStop">Close HUD</button>
@@ -1222,22 +1239,23 @@
<section class="panel" id="panels">
<div class="shelf-head"><h2>Panel switcher</h2><span class="spacer"></span>
<button class="small" id="panelsRefresh">Refresh</button>
<button class="small action" id="panelsOpen">Open in headset</button></div>
<button class="small" id="panelsRefresh">Refresh</button></div>
<div class="list" id="panelList"><div class="sub">Refresh to see open panels.</div></div>
<div class="row" style="margin-top:12px"><button class="small action" id="panelsOpen">Open in headset</button></div>
<div class="hint">Choose a panel to show it in SteamVR. The headset switcher stays available from Steam's dashboard.
Saved spatial layouts are not available yet.</div>
</section>
<section class="panel" id="macview" hidden>
<div class="shelf-head"><h2>Mac in the headset</h2><span class="count" id="mvCount"></span><span class="spacer"></span>
<select id="mvQuality" title="Picture quality and bandwidth">
<option value="sharp">Sharp</option><option value="balanced" selected>Balanced</option>
<option value="light">Light</option><option value="compatible">Compatible (JPEG)</option>
</select>
<button class="small" id="mvRefresh">Refresh</button></div>
<label class="mv-opt"><input type="checkbox" id="mvSeparate" checked> Give each window its own display
<span class="sub">its menus and sheets come along, nothing covers it, and it goes back when you stop</span></label>
<div class="row" style="margin:0 0 12px"><label for="mvQuality">Picture quality</label>
<select id="mvQuality" title="Sharper pictures use more of your network">
<option value="sharp">Sharp</option><option value="balanced" selected>Balanced</option>
<option value="light">Light</option><option value="compatible">Compatible (JPEG)</option>
</select></div>
<div class="mv-perm" id="mvPerm" hidden></div>
<div class="list" id="mvDisplays"></div>
<div class="shelf-head" style="margin-top:16px"><h2>Windows</h2><span class="count" id="mvWinCount"></span></div>
@@ -1301,6 +1319,7 @@
</div>
</div>
</main>
</div>
<div class="dropzone" id="dropzone" hidden><div><b>Drop to send to the Frame</b>
<span class="sub">Files go to <code>~/Downloads</code>; <code>.apk</code> files install as Android apps;
a game's <code>.zip</code>, folder or <code>.exe</code> becomes a Steam library title.</span></div></div>
@@ -1334,21 +1353,6 @@
<div class="actions"><button id="apkAltClose">Close</button></div>
</dialog>
<dialog id="modDlg" aria-labelledby="modTitle">
<h2 id="modTitle">Play in VR with UEVR</h2>
<p id="modState" class="sub"></p>
<p class="sub">UEVR by praydog turns many Unreal Engine games into VR games. Frame Control downloads
the official UEVR 1.05 release and python.org's Windows Python, which it uses to load UEVR into the game
(about 20 MB), checks their hashes, and keeps them in this game's Proton prefix, not the game's folder.
Remove takes them out again.</p>
<p class="sub">Tested on the Frame with one game so far. Whether a game looks and plays right in VR varies;
UEVR's own menu in the headset has the per-game settings.</p>
<div class="row rep-actions"><span class="sub" id="modMsg"></span><span class="spacer"></span>
<button type="button" class="small" id="modClose">Close</button>
<button type="button" class="small" id="modRemove" hidden>Remove UEVR</button>
<button type="button" class="action small" id="modInstall" hidden>Install UEVR</button>
<button type="button" class="play small" id="modStart" hidden>▶ PLAY IN VR</button></div>
</dialog>
<dialog id="aboutDlg" aria-labelledby="aboutTitle">
<h2 id="aboutTitle">About and licences</h2>
<p>Frame Control is MIT-licensed. It ships other people's software, each under its own licence: the notices and
@@ -1521,6 +1525,9 @@ function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&amp;",
function gb(n) { return n >= 1e12 ? (n/1e12).toFixed(2) + " TB" : n >= 1e9 ? (n/1e9).toFixed(1) + " GB" : (n/1e6).toFixed(0) + " MB"; }
function dur(s) { const d = Math.floor(s/86400), h = Math.floor(s%86400/3600), m = Math.floor(s%3600/60);
return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : `${m}m`; }
// What scrolls: #scroll on a computer, where the header and Activity bar stay put; the window on phones.
function scroller() { return getComputedStyle($("scroll")).overflowY === "visible" ? document.scrollingElement : $("scroll"); }
function scrollToY(y) { scroller().scrollTo({ top: y, behavior: "instant" }); }
function meter(el, pct, warnAt, badAt) {
el.style.width = Math.max(0, Math.min(100, pct)) + "%";
el.className = pct >= badAt ? "bad" : pct >= warnAt ? "warn" : "";
@@ -1649,7 +1656,7 @@ function setOnline(ok, why) {
if (!ok) {
$("offMsg").textContent = why || "Can't reach the Frame";
setConn(false, "Offline");
$("battChip").hidden = true;
$("battChip").hidden = true; closeDevPop();
if (was !== false) { log(why || "Can't reach the Frame", "e"); schedule(); }
if (window.connBanner) window.connBanner();
} else if (was === false) {
@@ -1707,7 +1714,7 @@ function renderPerformance(s) {
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, "%"]];
["GPU clock", p.gpuMHz, " MHz"]];
$("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";
}
@@ -1843,10 +1850,6 @@ function battery(b, power) {
const draining = !full && !charging && !idle && (!power || b?.status === "Discharging" || b?.watts < 0);
const low = !charging && !full && pct != null && pct <= 15;
const color = full || charging ? "#59bf40" : low ? "#d94126" : pct != null && pct <= 30 ? "#d9a23a" : "#dcdedf";
$("battFill").setAttribute("width", pct == null ? 0 : (55 * Math.min(100, Math.max(0, pct)) / 100).toFixed(1));
$("battFill").setAttribute("fill", color);
$("battBolt").setAttribute("visibility", charging ? "visible" : "hidden");
$("battPct").textContent = pct == null ? "—" : `${pct}%`;
let label, cls, detail = [];
if (!b) { label = "Battery unknown"; cls = "draining"; }
@@ -1862,7 +1865,7 @@ function battery(b, power) {
if (b?.timeToEmpty > 0 && b.timeToEmpty < 86400) detail.push(`About ${dur(b.timeToEmpty)} left`);
}
if (power) detail.push(`${/PD/.test(power.type || "") ? "USB-C PD" : power.type || "Charger"}${power.watts ? ` ${Math.round(power.watts)} W` : ""}`);
if (b?.tempC != null) detail.push(`${b.tempC.toFixed(0)}°C`);
if (b?.tempC != null) detail.push(`battery ${b.tempC.toFixed(0)}°C`);
$("battState").textContent = label;
$("battState").className = "state " + cls;
$("battDetail").textContent = detail.join(" · ") || " ";
@@ -1871,14 +1874,26 @@ function battery(b, power) {
const chipPct = pct == null ? 0 : Math.min(100, Math.max(0, pct));
$("battChip").innerHTML = pct == null ? "—" :
`<svg width="22" height="12" viewBox="0 0 22 12" aria-hidden="true"><rect x=".75" y=".75" width="18" height="10.5" rx="2" fill="none" stroke="#8f98a0" stroke-width="1.5"/><rect x="19.8" y="3.5" width="1.8" height="5" rx=".6" fill="#8f98a0"/><rect x="2.5" y="2.5" width="${(14.5 * chipPct / 100).toFixed(1)}" height="7" rx="1" fill="${color}"/></svg>${bolt}<span>${pct}%</span>`;
$("battChip").title = label;
$("battChip").hidden = pct == null;
$("battChip").title = `${label}. Click for the headset's details.`;
$("battChip").setAttribute("aria-label", pct == null ? "Headset details" : `Battery ${pct}%, ${label}. Headset details`);
$("battChip").hidden = false; // even without a battery reading: it's the way to the headset's details
}
// hidePopover throws where the menu isn't open (and doesn't exist in older browsers).
function closeDevPop() { try { $("devPop").hidePopover(); } catch {} }
// The battery chip's menu opens under the chip, right-aligned with it.
$("devPop").addEventListener("beforetoggle", e => {
if (e.newState !== "open") return;
const r = $("battChip").getBoundingClientRect();
$("devPop").style.top = `${Math.round(r.bottom + 8)}px`;
const width = Math.min(390, innerWidth - 24); // as .pop's CSS width
$("devPop").style.right = `${Math.max(12, Math.min(Math.round(innerWidth - r.right), innerWidth - width - 12))}px`;
});
function render(s) {
renderPerformance(s);
window.frameControlCenter?.status(s, stateReceivedAt, stateRead);
battery(s.battery, s.power);
$("devPopTitle").textContent = $("devPopTitle").title = window.connName || "Headset";
$("devName").textContent = `${s.hostname} · ${s.ip ?? "no IP"}`;
$("updated").textContent = new Date(s.time * 1000).toLocaleTimeString();
const h = s.disk.home;
@@ -2967,7 +2982,7 @@ function showApkAlternatives(reason, result, dev = window.connDevice) {
}, dev));
if (res) $("apkAltDlg").close(); else { b.disabled = false; b.textContent = "Install"; }
await loadAndroid();
if (cat.apps) { const y = window.scrollY; filterCatalog(); window.scrollTo(0, y); }
if (cat.apps) { const y = scroller().scrollTop; filterCatalog(); scrollToY(y); }
if (res && res.app) await offerTest(res.app);
};
if (!$("apkAltDlg").open) $("apkAltDlg").showModal();
@@ -3201,7 +3216,7 @@ function renderChips() {
}
function filterGames(keepScroll) {
const y = window.scrollY;
const y = scroller().scrollTop;
if (gm.src === "owned") {
if (!gm.owned) return;
const terms = gm.q.toLowerCase().split(/\s+/).filter(Boolean);
@@ -3217,7 +3232,7 @@ function filterGames(keepScroll) {
$("gmGrid").innerHTML = ""; gm.shown = 0; moreGames(n);
if (!gm.results.length) $("gmGrid").innerHTML = `<div class="sub">${gm.src === "store" && !gm.storeQ
? "Type a name and press Enter to search the Steam store." : "Nothing matches. Try turning on more ratings above."}</div>`;
if (keepScroll) window.scrollTo(0, y);
if (keepScroll) scrollToY(y);
}
function price(p) {
@@ -3234,8 +3249,7 @@ function gameButtons(id, name, free) {
const open = `<button class="small" data-gm="store" data-id="${id}" data-name="${esc(name)}" title="Open the store page in the headset">Store on Frame</button>`;
if (d && d.appid === id) return `<span class="tag">${d.paused ? "Paused" : "Downloading"} ${d.percent ?? 0}%</span>`;
if (g && g.installed) return `<span class="tag">Installed</span>` +
`<button class="play small" data-launch="${id}" data-name="${esc(name)}">▶ PLAY</button>` +
`<button class="small" data-mod="${id}" data-name="${esc(name)}" title="Flat-to-VR mod for Unreal Engine games">VR mod</button>`;
`<button class="play small" data-launch="${id}" data-name="${esc(name)}">▶ PLAY</button>`;
if (g) return `<button class="action small" data-gm="install" data-id="${id}" data-name="${esc(name)}">Install on Frame</button>`;
return `<a href="${storeUrl}" target="_blank" rel="noopener" title="Opens in your browser">${free ? "Get" : "Buy"} on Steam ↗</a>${open}`;
}
@@ -3307,45 +3321,6 @@ document.body.addEventListener("click", async e => {
});
loadOwned();
// ---- VR mod (UEVR) for one installed game; frame_mods.py does the work ----
const mod = { appid: null, name: "" };
function showMod(st, note) {
const busy = note === null;
$("modTitle").textContent = `${mod.name} in VR`;
$("modState").textContent = !st ? "Checking the game on the Frame…"
: !st.eligible ? "This doesn't look like an Unreal Engine game, so UEVR can't run it."
: !st.prefix ? "Play the game once first, so Steam sets up its Proton prefix."
: st.injected ? "UEVR is running in the game now. Put on the headset."
: st.installed ? `UEVR ${st.version} is installed for this game.${st.running ? " The game is running; Play in VR adds UEVR to it." : ""}`
: `UEVR ${st.version} isn't installed for this game yet.`;
$("modMsg").textContent = busy ? "Working… this can take a few minutes" : note || "";
const ok = st && st.eligible && st.prefix;
$("modInstall").hidden = !ok || st.installed; $("modStart").hidden = !ok || !st.installed || st.injected;
$("modRemove").hidden = !st || !st.installed;
for (const b of ["modInstall", "modStart", "modRemove"]) $(b).disabled = busy || (b === "modRemove" && st && st.running);
}
async function modAction(action, label) {
const appid = mod.appid;
showMod(mod.st, null);
const res = await act(`${label} for ${mod.name}`, () => api("/api/mods", { action, appid }));
if (appid !== mod.appid) return;
try { mod.st = await api("/api/mods", { action: "status", appid }); } catch {}
showMod(mod.st, res ? res.message : "Didn't work; the log has the reason");
}
document.body.addEventListener("click", async e => {
const b = e.target.closest("[data-mod]"); if (!b) return;
mod.appid = b.dataset.mod; mod.name = b.dataset.name; mod.st = null;
showMod(null, "");
$("modDlg").showModal();
const appid = mod.appid;
try { const st = await api("/api/mods", { action: "status", appid }); if (appid === mod.appid) { mod.st = st; showMod(st, ""); } }
catch (err) { if (appid === mod.appid) $("modState").textContent = err.message; }
});
$("modInstall").onclick = () => modAction("install", "Install UEVR");
$("modStart").onclick = () => modAction("start", "Start UEVR");
$("modRemove").onclick = () => modAction("uninstall", "Remove UEVR");
$("modClose").onclick = () => $("modDlg").close();
// ---- Discover apps ----
const sourceState = { vr: null, installable: false, source: "", apps: [], sources: [], request: 0, detail: null, detailRequest: 0, jobs: new Map() };
const storeName = s => String(s || "").replace(/ \(demo\)$/i, "");
@@ -3890,7 +3865,7 @@ document.body.addEventListener("click", async e => {
if (action === "probe") { loadCatalog(); loadReports(); }
}
loadAndroid();
if (cat.apps) { const y = window.scrollY; filterCatalog(); window.scrollTo(0, y); }
if (cat.apps) { const y = scroller().scrollTop; filterCatalog(); scrollToY(y); }
});
loadCatalog();
@@ -4420,7 +4395,7 @@ function showPage() {
if (id === "display" || id === "androidLibrary") showAndroidTab("library");
else if (id === "appStore") showAndroidTab("browse");
const section = !PAGES.includes(id) && id && $(id);
if (section) section.scrollIntoView(); else window.scrollTo(0, 0);
if (section) section.scrollIntoView(); else scrollToY(0);
if (page === "tools") { loadMacView(); loadPanelSwitcher(); }
}
window.addEventListener("hashchange", showPage);
@@ -5113,6 +5088,7 @@ function onConnection(s) {
renderPill(); renderConnDlg();
const devId = s.device && s.device.id;
window.connDevice = devId || null;
window.connName = s.device ? s.device.name : null;
if (link.device !== null && devId !== link.device) {
log(`Now using ${s.device.name}`, "ok");
state = null;
@@ -5145,7 +5121,7 @@ function onConnection(s) {
if ($("wiDlg").open && !wi.job && !wi.starting) $("wiDlg").close();
$("dispSel").innerHTML = "<option>Loading…</option>"; $("dispSel").disabled = true; $("dispCtl").hidden = true;
link.reload = true; // everything on the page was the other headset's
$("battChip").hidden = true;
$("battChip").hidden = true; closeDevPop();
}
if (s.phase !== link.phase || devId !== link.device) {
if (s.phase === "connected") {
+5 -20
View File
@@ -709,14 +709,13 @@ def launch(body):
return {"message": f"Launching {appid}"}
def steam_frame(*args, timeout=40, script="frame_steam.py"):
"""Run frame_steam.py (it drives the Steam client), or another on-Frame
helper with the same JSON contract, on the Frame and return its JSON."""
def steam_frame(*args, timeout=40):
"""Run frame_steam.py on the Frame (it drives the Steam client) and return its JSON."""
try:
out = ssh("python3 - " + " ".join(map(shlex.quote, args)),
stdin=(HERE / script).read_text(), timeout=timeout)
stdin=(HERE / "frame_steam.py").read_text(), timeout=timeout)
except Failure as e:
# The helper prints {"error": ...} on stdout when it fails, but ssh()
# frame_steam.py prints {"error": ...} on stdout when it fails, but ssh()
# reports stderr instead if there was any, so look in both.
for line in [*reversed(getattr(e, "stdout", "").splitlines()), *reversed(str(e).splitlines())]:
try:
@@ -767,20 +766,6 @@ def vr(body):
return result
# Downloads are about 20 MB; starting waits for the game, the injection and SteamVR.
MOD_TIMEOUT = {"status": 40, "install": 600, "start": 900, "uninstall": 60}
def mods(body):
"""UEVR for one installed Unreal game: status, install, start (inject) or uninstall."""
appid, action = str(body.get("appid", "")), body.get("action")
if not APPID.match(appid):
raise Failure("bad appid", 400)
if action not in MOD_TIMEOUT:
raise Failure("action must be " + ", ".join(MOD_TIMEOUT), 400)
return steam_frame(action, appid, timeout=MOD_TIMEOUT[action], script="frame_mods.py")
def steam_search(query):
q = parse_qs(query)
cc = (q.get("cc") or [""])[0].upper()
@@ -2504,7 +2489,7 @@ POST = {
"/api/agent/approval": agent_approval, "/api/assistant/chat": assistant_chat,
"/api/input": remote_input, "/api/touch": remote_touch,
"/api/settings/artwork": frame_steamgriddb.save_settings,
"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/quest": quest, "/api/meta": meta, "/api/queue": queue_action, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/mods": mods, "/api/volume": set_volume, "/api/clipboard": clipboard,
"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/quest": quest, "/api/meta": meta, "/api/queue": queue_action, "/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/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
"/api/webinstall/cancel": webinstall_cancel,