Compare commits

...
Author SHA1 Message Date
Alex SouthwellandSAPHID bcdd69f6d8 Bump Frame Control to 0.4.22 (#176)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 22:45:11 +00:00
Alex SouthwellandSAPHID f5becdd2c0 Document real OBS Media Source verification (#175)
* Document real OBS Media Source video and audio verification

* Record packaged OBS Start and Stop headset check

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 22:33:11 +00:00
Alex SouthwellandSAPHID e185cb3b29 Bundle OBS source with desktop Start and Stop control (#174)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 22:05:20 +00:00
Alex SouthwellandSAPHID 2320dc4d0e Bump Frame Control to 0.4.21 (#173)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 21:40:55 +00:00
Alex SouthwellandSAPHID affe3eda65 Record Round 3 headset mirror and charge-limit checks (#172)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 21:30:51 +00:00
Alex SouthwellandSAPHID 9e9b25e9f4 Add local OBS headset view source with output audio (#170)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 21:29:23 +00:00
Alex SouthwellandSAPHID f7153b3ab1 Guide emulator setup and clarify ROM storage limits (#171)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 21:28:49 +00:00
15 changed files with 521 additions and 6 deletions

No files matched your search

+26
View File
@@ -2,6 +2,32 @@
## Unreleased
- Verify the packaged OBS Start and Stop control with a real Frame, and record
37.600 seconds of headset mirror video and non-silent output audio in OBS.
Listening, sync and active passthrough remain unverified.
[Test record](docs/evidence/obs-media-source-real-2026-10-25.md).
- Bundle the OBS source helper in Linux and macOS installers and add a
**Capture & Live** Start/Stop control. The app selects the active Frame and
closes its SSH stream on a headset switch or app exit. A real Frame stream
reached OBS with video and non-silent audio; listening and sync remain
unverified. [Setup and limits](docs/obs-headset-source.md).
- Add a source-checkout helper that sends the Frame's SteamVR mirror and
default-output audio to a local OBS Media Source. A real Frame supplied a
4.021-second H.264/AAC stream to a Linux receiver; OBS playback and sync
remain unverified. [Setup and limits](docs/obs-headset-source.md).
- Link emulator setup guidance from Store → Emulators. Existing Steam library
shortcuts and artwork cover Android apps and sideloaded titles; Android ROM
transfer still needs a verified per-instance workflow.
[Guide](docs/emulators.md).
- Record the current Frame OS mirror and charge-limit checks. Active
passthrough in the mirror is still unknown; this build has no supported
user-writable 80% charge-limit control.
[Headset evidence](docs/evidence/round3-headset-2026-10-25.md).
- Add Windows Tools → Wireless Adapter doctor: an explicit read-only PC snapshot
of distinct Wi-Fi Direct PnP instances, presence/link state, PC Wi-Fi and
reported channels/bands, with possible 2.4 GHz overlap guidance. Unknowns stay
+1
View File
@@ -17,6 +17,7 @@ const REQUIRED = [
"ui/frame_status.py",
"scripts/frame_power_sample.py",
"scripts/frame_power_report.py",
"scripts/obs-headset-source.py",
"ui/frame_android.py",
"frame/android/lepton-app.sh",
"frame/openxr-compat/XrApiLayer_FRAME_compat.json",
+60 -2
View File
@@ -304,6 +304,63 @@ ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null);
ipcMain.handle("update:check", (e) => fromUi(e) ? checkForUpdate({ manual: true }).then(publicUpdate) : null);
ipcMain.handle("update:install", (e) => { if (fromUi(e)) installUpdate(); });
// The OBS source is local to this computer. The Python relay owns its SSH/ffmpeg
// process and cleans it up on SIGTERM. Keep one relay per app, and stop it when
// the selected headset changes or the app quits.
const OBS_SCRIPT = path.join(SCRIPTS, "obs-headset-source.py");
let obsChild = null;
let obsState = { state: "idle", alias: "", error: "" };
function publicObs() { return { ...obsState, supported: !IS_WIN }; }
function setObs(state, alias = "", error = "") {
obsState = { state, alias, error };
if (win && !win.isDestroyed()) win.webContents.send("obs:state", publicObs());
return publicObs();
}
function stopObs() {
if (!obsChild) return publicObs();
setObs("stopping", obsState.alias);
obsChild.kill("SIGTERM");
return publicObs();
}
ipcMain.handle("obs:status", (e) => fromUi(e) ? publicObs() : null);
ipcMain.handle("obs:start", async (e) => {
if (!fromUi(e)) return null;
if (IS_WIN) throw new Error("The OBS source currently supports Linux and macOS computers.");
if (obsChild) return publicObs();
const alias = activeAlias();
if (!ALIAS_RE.test(alias)) throw new Error("Select a valid headset first.");
if (!fs.existsSync(OBS_SCRIPT)) throw new Error("The OBS helper is missing; reinstall Frame Control.");
const env = { ...process.env, PATH: await loginPath() };
const executable = python || await findPython(env);
if (!executable) throw new Error(PYTHON_HELP);
// A concurrent click or a headset switch may have happened while finding Python.
if (obsChild) return publicObs();
if (alias !== activeAlias()) throw new Error("The selected headset changed; try again.");
const child = spawn(executable, [...PY_FLAGS, OBS_SCRIPT, "--frame", alias],
{ env, stdio: ["ignore", "ignore", "pipe"], windowsHide: true });
obsChild = child;
setObs("starting", alias);
let output = "";
child.stderr.on("data", (chunk) => {
output = (output + chunk.toString("utf8")).slice(-2048);
if (obsChild === child && output.includes("Frame Control OBS stream is ready")) setObs("running", alias);
});
child.once("error", (err) => {
if (obsChild !== child) return;
obsChild = null;
setObs("failed", alias, `Could not start OBS source: ${err.message}`);
});
child.once("exit", (code, signal) => {
if (obsChild !== child) return;
const stopping = obsState.state === "stopping" || quitting;
obsChild = null;
setObs(stopping ? "idle" : "failed", alias,
stopping ? "" : `OBS source stopped (${signal || code}). ${output.trim().split("\n").pop() || "Check SteamVR and SSH."}`);
});
return publicObs();
});
ipcMain.handle("obs:stop", (e) => fromUi(e) ? stopObs() : null);
// The page reports the headsets it knows (the server's Devices tab), so the Frame
// menu can switch between them. Only plain names and ids go into the menu.
const ALIAS_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
@@ -314,6 +371,7 @@ ipcMain.on("devices:changed", (e, list) => {
.map(d => ({ id: d.id.slice(0, 80), name: String(d.name || d.alias).slice(0, 60), alias: d.alias, active: !!d.active }));
if (JSON.stringify(next) === JSON.stringify(devices)) return;
devices = next;
if (obsChild && obsState.alias !== activeAlias()) stopObs();
buildMenu();
});
const activeAlias = () => (devices.find(d => d.active) || {}).alias || FRAME;
@@ -641,6 +699,6 @@ if (!app.requestSingleInstanceLock()) {
});
app.on("activate", () => { if (!win) createWindow(); });
app.on("window-all-closed", () => app.quit());
app.on("before-quit", () => { quitting = true; stopServer(); });
process.on("exit", stopServer);
app.on("before-quit", () => { quitting = true; stopObs(); stopServer(); });
process.on("exit", () => { stopObs(); stopServer(); });
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.20",
"version": "0.4.22",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.20",
"version": "0.4.22",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.20",
"version": "0.4.22",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
@@ -69,6 +69,7 @@
"to": "scripts",
"filter": [
"*.sh",
"obs-headset-source.py",
"frame_power_sample.py",
"frame_power_report.py"
]
+9
View File
@@ -21,6 +21,15 @@ contextBridge.exposeInMainWorld("frameApp", {
writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes),
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
restartServer: () => ipcRenderer.invoke("server:restart"), // the "couldn't start" page's Try Again
obs: {
status: () => ipcRenderer.invoke("obs:status"),
start: () => ipcRenderer.invoke("obs:start"),
stop: () => ipcRenderer.invoke("obs:stop"),
onState: (cb) => {
ipcRenderer.removeAllListeners("obs:state");
ipcRenderer.on("obs:state", (_e, state) => cb(state));
},
},
// The Frame menu's headset switcher: the page tells it the headsets, and hears picks.
devicesChanged: (list) => ipcRenderer.send("devices:changed", list),
onUseDevice: (cb) => {
+5
View File
@@ -9,6 +9,11 @@ only control the Frame has is a vendor charger attribute that Valve's own
charging daemon also manages. Research as of 2026-10-23, confirmed with the
read-only checks below on a Frame running SteamOS 0.5.6 (build 20261015.6141207):
Rechecked read-only on build `20261022.6153909` on 2026-10-25: the
`BatteryChargeLimit1` interface still returned `UnknownInterface`, no
`charge_control_*` sysfs attribute existed, and the vendor charging switches
were root-owned mode `644`. See [Round 3 headset evidence](evidence/round3-headset-2026-10-25.md).
- `steamosctl get-max-charge-level` fails with `UnknownInterface: ...BatteryChargeLimit1`.
- `/usr/share/steamos-manager/devices/` has `[battery_charge_limit]` only for
the Steam Deck, ROG Ally, ROG Xbox Ally and MSI Claw, not the Frame.
+54
View File
@@ -0,0 +1,54 @@
# Emulators and your own games
Frame Control's **Store → Emulators** lists Android emulators from its open
app sources. Pick an app, read its compatibility result, and install it. An
installed Android app gets its own Steam library shortcut and artwork; **Add
artwork** in its library menu can fill missing images later. The artwork
workflow also covers native Linux and Windows titles added through **Send to
Frame**. An optional SteamGridDB key under **Settings → Library artwork**
can supply exact-title community images; source images and generated art are
fallbacks. See [VR APKs → In the Steam library](vr-apks.md#in-the-steam-library)
for the limits and tested behavior.
## Set up an emulator
1. In **Store → Emulators**, choose an emulator that supports the console and
file format you want. Check its compatibility result before installing;
an app listed in the store is not automatically verified on the Frame.
2. Launch the new shortcut from Steam. Open the emulator's own settings to
choose a gamepad layout, core or system, and game folder. These controls
differ by emulator; Frame Control does not change them.
3. Use game files you own and any firmware that emulator's instructions
require. Frame Control does not provide ROMs or firmware.
**Android apps run in separate Lepton instances.** Copying a ROM to the
Frame's Linux `~/Downloads` through **Send to Frame** does not automatically
put it in an Android app's `/sdcard/Download`. A one-click ROM transfer is
deferred until an emulator and its file picker can be tested end to end on a
real Frame. An app's own import or sync feature may be enough. Advanced users
can use [Lepton's per-instance ADB port](apks.md)
to copy into that instance, after confirming the correct container and port.
Do not assume that port 5555 is the emulator: it is Lepton Development, while
installed apps receive their own ports.
For a native Linux emulator that can read the Frame's home directory, use
**Send to Frame → Copy to ~/Downloads instead** for your files, then open them
from the emulator. Whether a particular emulator has a Frame-compatible ARM64
build, correct controller mapping and readable storage must be checked for
that app. **Linux apps** in Frame Control lists Flatpak options; a Windows or
native game archive can instead be added through
[Send to Frame](sideloading.md#using-it), which registers a Steam title and
artwork. Each ROM is usually opened *inside* its emulator; Frame Control does
not create one Steam shortcut per ROM.
## If something does not appear
- Check that the emulator is running and is the same app instance to which
you sent the file. Android instances do not share `/sdcard` storage.
- Check the emulator's supported extension and whether it expects an archive
or an extracted file. Some systems need firmware before games appear.
- For an empty Steam tile, use **Add artwork** in the installed app or
sideloaded title menu. This changes artwork, not emulator data or ROMs.
The Frame's microSD/USB path into Android apps remains unverified without an
inserted medium and an app readback test; see [issue #169](https://github.com/saphid/frame-control/issues/169).
@@ -0,0 +1,48 @@
# OBS Media Source on a real Frame, 2026-10-25 AEDT
The Linux x86_64 AppImage from PR #174's exact head `69fff152` had SHA-256
`4902ac80b77fbf98ec918db29df9cf9260b8abd6763a5b5055572335869f038a`.
Its extracted resources passed `app/build/check-resources.js`, including the
OBS helper, and the app launched under Xvfb with an isolated profile and a
dummy SSH alias. Its bundled server served the page with HTTP 200.
For the device test, the PC was lxso2. The real Frame was reached as `ssh frame`
under an atomically acquired `/tmp/frame-test.lock`. Battery read 100% Full
before and after. SteamVR 2.18.2 reported `activity_level: 3` (standby). The
mirror service and `/dev/video99` were active. The test cleaned its temporary
remote WAV and released only its own lock.
OBS 30.0.2 ran in a disposable Ubuntu 24.04 container on lxso2, with Xvfb and
host loopback networking. A Media Source opened `udp://127.0.0.1:15099` with
input format `mpegts`. A synthetic pattern and tone first verified the OBS
scene: its saved 43.733-second MKV had H.264 video and AAC audio with a
`-17.9 dB` audio peak. The real Frame test then ran the helper from the
extracted AppImage resources **before** OBS opened the source, so OBS joined
an ongoing stream. OBS's saved 37.600-second MKV had H.264 video and AAC audio.
The standby mirror appeared in decoded frames by five seconds. A 440 Hz tone
played briefly to the Frame's default PulseAudio output appeared in the OBS
audio recording at seconds 20–21; decoded audio peaked at `-34.4 dB`. A
separate direct recording of `@DEFAULT_MONITOR@` during the same type of tone
peaked at `-34.5 dB`. These measurements verify non-silent headset output
audio passed through the helper and OBS into the saved recording.
The first OBS attempt opened its Media Source well before the Frame sent
packets. Its output had a silent AAC track until the mirror appeared late.
Starting the helper before OBS fixed this in the second run. The setup guide
now recommends starting the source in Frame Control before adding or activating
the OBS Media Source.
The exact-head AppImage was then launched on an isolated Xvfb display and
profile with the real `frame` alias. In **Tools → Capture & Live**, clicking
**Start OBS source** changed the UI to **Stop OBS source** and showed
`udp://127.0.0.1:15099` after the first stream packet. The packaged Python
helper was running with `--frame frame`. Clicking **Stop OBS source** returned
the UI to **Start OBS source**; the helper and its remote `/dev/video99` ffmpeg
process were gone. The app exited through **File → Quit**. Battery again read
100% Full, and the owned lock was released.
**Limits:** Nobody listened to OBS audio monitoring or the Frame speakers.
Audio/video sync was not measured. The headset was unworn and in standby, so
this test does not answer whether active passthrough pixels appear in the
mirror. The OBS MKVs were temporary local test artifacts and are not included
in the repository.
@@ -0,0 +1,19 @@
# Round 3 headset checks, 25 October 2026
These checks used the real Frame after the device hold ended. SteamOS was
0.5.6, build `20261022.6153909`. Each device session acquired
`/tmp/frame-test.lock` atomically and released only its own lock. The battery
read 100% and Full in the first check and after the OBS session; Steam and
SteamVR were left running.
| Question | Observation | Conclusion |
|---|---|---|
| Does active passthrough appear in SteamVR's `/dev/video99` mirror? | The mirror service was active. One 1920×1080 RGB frame read from `/dev/video99` had a single pixel value, `(24, 28, 53)`, throughout. The headset was unworn; no active passthrough session or wearer-visible room view was established. Frame Control has no verified remote passthrough toggle. The temporary frame was removed. | **Still unknown.** A uniform standby frame cannot answer whether SteamVR composites active passthrough into its mirror. No headset setting was changed. |
| Can Frame Control prompt when a headset text box opens? | No desktop `plasmashell` process was running during this check. There is no verified cross-app focus event from SteamVR, Lepton and the nested desktop; KDE Connect's `keyboardstate` packet describes keyboard support, not a focused text field. | **Not built.** A prompt triggered by an unverified signal would be misleading. The existing **Type on the headset** control remains available. |
| Does reverse clipboard exist? | Frame Control already has **Copy the headset's clipboard here**. Its earlier real Frame test used a temporary nested desktop and confirmed that text copied through Klipper reached a Linux PC clipboard. No desktop panel was open during this session, so it was not rerun here. | **Shipped and previously verified**, text only. Windows/macOS backends and arbitrary headset apps remain unverified on a real Frame. |
| Is an 80% charge limit available and writable? | `steamosctl get-max-charge-level` returned `UnknownInterface: ...BatteryChargeLimit1`. No `charge_control_*` attribute was found under `/sys/class/power_supply`. Only `pm8550b-charger/charging_pause` and `charging_enable` were present, each mode `644`, owner `root:root`. | **No supported user-writable control.** No battery attribute was written. |
The separate OBS transport check on the same build sent the mirror and the
default-output monitor to a Linux PC. A 4.021-second temporary capture had
H.264 video and AAC audio according to `ffprobe`. It was removed; OBS itself
and audible playback were not tested. See the OBS source PR and guide.
+47
View File
@@ -0,0 +1,47 @@
# Headset view in OBS
Frame Control can send SteamVR's headset mirror and the Frame's output audio to
OBS on a Linux or macOS computer. The helper uses the same `/dev/video99` video
source and default-output monitor as **Capture & Live → Record**. It sends a
local MPEG-TS stream; no port is opened to the LAN or internet.
1. Connect to the Frame and start SteamVR. OBS runs on the computer; the Frame
already has `ffmpeg`.
2. In the **Linux or macOS Frame Control app**, open **Capture & Live** and click
**Start OBS source**. Keep Frame Control open; **Stop OBS source**, switching
headsets, or quitting the app closes its SSH stream. It uses the selected
headset and the app's bundled helper.
3. In OBS, add a **Media Source**, clear **Local File**, set **Input** to
`udp://127.0.0.1:15099`, and set **Input Format** to `mpegts`. The source
carries video and AAC audio. Use OBS's audio mixer to choose monitoring and
recording levels.
From a source checkout, you can instead install `python3` on the computer and
run:
```sh
python3 scripts/obs-headset-source.py
```
Ctrl+C stops the checkout helper. The app's Start/Stop control currently supports
Linux and macOS; Windows users can run the checkout helper with Python 3 and
OpenSSH from a terminal, but this path has not been verified on a Windows PC.
`--height 1080 --fps 60` increases quality and Frame CPU use. The default is
720p30. `--port 15100` selects another loopback port; use the same port in
OBS. If the Frame's PulseAudio monitor cannot be read, the helper exits with
an error. Use `--no-audio` only when a silent view is useful. To inspect the
stream without OBS, run `ffprobe -v error -show_streams
udp://127.0.0.1:15099` in a second terminal while the helper is running.
This is the composited, undistorted one-eye **SteamVR mirror**, not a raw
passthrough camera feed. The mirror may be blank when the headset is idle or
unworn. Whether active passthrough pixels appear in it is still unverified.
The helper does not send a microphone. It is designed for one local OBS
receiver. **Verified 2026-10-25:** the helper bundled in the Linux AppImage
sent a real Frame stream to OBS 30's Media Source. OBS recorded 37.6 seconds
with H.264 video and AAC audio; a brief 440 Hz tone played to the Frame's
default output was present in the saved audio. The headset was in standby, so
the video showed the standby mirror. Nobody listened to OBS's monitoring output,
and audio/video sync and active passthrough remain unverified. See the
[test evidence](evidence/obs-media-source-real-2026-10-25.md).
+3
View File
@@ -1,5 +1,8 @@
# Recording and Steam clips
For a live OBS Media Source with headset output audio, see
[Headset view in OBS](obs-headset-source.md).
Frame Control can record SteamVR's mirrored headset view, with its sound, and save it as
an `.mp4` on your computer. It can also export the clips and background
recordings from Steam's own game recording, at any length, as one `.mp4` each.
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Send the Frame's SteamVR mirror and output audio to a local OBS Media Source.
OBS opens udp://127.0.0.1:15099 with input format mpegts. Nothing is bound to
the network: this process sends MPEG-TS packets to the computer's loopback.
"""
import argparse
import shlex
import signal
import socket
import subprocess
import sys
PACKET = 188
DATAGRAM = PACKET * 7
def remote_command(height=720, fps=30, audio=True):
if height not in (720, 1080) or fps not in (30, 60):
raise ValueError('height must be 720 or 1080; fps must be 30 or 60')
rate = 3 if height == 720 else 6
clock = '-thread_queue_size 1024 -use_wallclock_as_timestamps 1'
video = f'{clock} -f v4l2 -video_size 1920x1080 -i /dev/video99'
sound = f' {clock} -f pulse -i @DEFAULT_MONITOR@' if audio else ''
maps = '-map 0:v:0 -map 1:a:0' if audio else '-map 0:v:0'
encode = (f'-vf fps={fps},scale=-2:{height},format=yuv420p '
f'-c:v libx264 -preset ultrafast -tune zerolatency -g {fps * 2} -bf 0 '
f'-b:v {rate}M -maxrate {rate}M -bufsize {rate}M '
'-x264-params repeat-headers=1')
if audio:
encode += ' -af aresample=async=1 -c:a aac -b:a 160k'
return ("export XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/run/user/$(id -u)}; "
"test -e /dev/video99 || { echo 'SteamVR mirror /dev/video99 is missing' >&2; exit 3; }; "
f'ffmpeg -hide_banner -loglevel warning -nostdin {video}{sound} '
f'{maps} {encode} -f mpegts -flush_packets 1 -')
def relay(source, destination):
"""Preserve 188-byte MPEG-TS packet boundaries in loopback datagrams."""
pending = bytearray()
total = 0
while data := source.read(DATAGRAM):
pending.extend(data)
size = min(len(pending) // PACKET * PACKET, DATAGRAM)
if size:
destination.send(bytes(pending[:size]))
del pending[:size]
total += size
if pending:
raise RuntimeError('Frame stream ended with an incomplete MPEG-TS packet')
return total
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--frame', default='frame', help='SSH host or alias (default: frame)')
parser.add_argument('--port', type=int, default=15099, help='local OBS UDP port (default: 15099)')
parser.add_argument('--height', type=int, choices=(720, 1080), default=720)
parser.add_argument('--fps', type=int, choices=(30, 60), default=30)
parser.add_argument('--no-audio', action='store_true', help='video only when the Frame audio monitor is unavailable')
args = parser.parse_args(argv)
if not 1024 <= args.port <= 65535:
parser.error('port must be between 1024 and 65535')
if not args.frame or args.frame.startswith('-') or any(c.isspace() for c in args.frame):
parser.error('frame must be one SSH host or alias')
command = remote_command(args.height, args.fps, not args.no_audio)
ssh = ['ssh', '-T', '-o', 'BatchMode=yes', args.frame, 'bash', '-lc', shlex.quote(command)]
target = ('127.0.0.1', args.port)
print(f'Sending Frame view to udp://127.0.0.1:{args.port} for OBS; press Ctrl+C to stop.', file=sys.stderr)
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as udp:
proc = subprocess.Popen(ssh, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE)
try:
amount = relay(proc.stdout, _Sender(udp, target))
code = proc.wait(timeout=10)
if code or not amount:
raise RuntimeError(f'Frame stream stopped (SSH exit {code}; {amount} bytes). '
'Check SteamVR and the audio monitor; try --no-audio if needed.')
return 0
except KeyboardInterrupt:
return 0
finally:
proc.stdout.close()
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
class _Sender:
def __init__(self, sock, target):
self.sock, self.target = sock, target
self.ready = False
def send(self, data):
self.sock.sendto(data, self.target)
if not self.ready:
self.ready = True
print('Frame Control OBS stream is ready', file=sys.stderr, flush=True)
if __name__ == '__main__':
try:
sys.exit(main())
except (OSError, RuntimeError) as error:
print(error, file=sys.stderr)
sys.exit(1)
+101
View File
@@ -0,0 +1,101 @@
"""The OBS relay must preserve transport packets and keep its output local."""
import importlib.util
import io
import json
import os
from pathlib import Path
import signal
import socket
import subprocess
import sys
import tempfile
import time
import unittest
SCRIPT = Path(__file__).resolve().parents[1] / 'scripts' / 'obs-headset-source.py'
spec = importlib.util.spec_from_file_location('obs_headset_source', SCRIPT)
obs = importlib.util.module_from_spec(spec)
spec.loader.exec_module(obs)
class Receiver:
def __init__(self):
self.chunks = []
def send(self, data):
self.chunks.append(data)
class ObsSourceTests(unittest.TestCase):
def test_desktop_packages_the_helper_as_a_required_resource(self):
root = SCRIPT.parents[1]
build = json.loads((root / 'app' / 'package.json').read_text())['build']
scripts = next(item for item in build['extraResources'] if item['to'] == 'scripts')
self.assertIn(SCRIPT.name, scripts['filter'])
self.assertIn('scripts/' + SCRIPT.name, (root / 'app' / 'build' / 'check-resources.js').read_text())
def test_relay_keeps_whole_transport_packets(self):
stream = bytes(range(188)) * 101
receiver = Receiver()
self.assertEqual(obs.relay(io.BytesIO(stream), receiver), len(stream))
self.assertEqual(b''.join(receiver.chunks), stream)
self.assertTrue(all(len(chunk) % 188 == 0 and len(chunk) <= 1316
for chunk in receiver.chunks))
def test_incomplete_packet_fails(self):
with self.assertRaisesRegex(RuntimeError, 'incomplete'):
obs.relay(io.BytesIO(b'x' * 189), Receiver())
def test_remote_command_uses_recording_sources(self):
with_audio = obs.remote_command()
self.assertIn('/dev/video99', with_audio)
self.assertIn('@DEFAULT_MONITOR@', with_audio)
self.assertIn('-map 1:a:0', with_audio)
self.assertIn('-f mpegts', with_audio)
self.assertNotIn('@DEFAULT_MONITOR@', obs.remote_command(audio=False))
@unittest.skipIf(sys.platform == 'win32', 'the packaged Start/Stop control is Linux/macOS only')
def test_relay_stops_its_ssh_child_on_app_signal(self):
with tempfile.TemporaryDirectory() as folder, socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as receiver:
directory = Path(folder)
stopped = directory / 'stopped'
fake = directory / 'ssh'
fake.write_text("#!/usr/bin/env python3\n"
"import os, signal, sys, time\n"
"def stop(*_):\n"
" open(os.environ['FAKE_SSH_STOPPED'], 'w').close()\n"
" sys.exit(0)\n"
"signal.signal(signal.SIGTERM, stop)\n"
"sys.stdout.buffer.write(b'G' * 1316)\n"
"sys.stdout.buffer.flush()\n"
"while True: time.sleep(1)\n")
fake.chmod(0o755)
receiver.bind(('127.0.0.1', 0))
receiver.settimeout(8)
env = {**os.environ, 'PATH': str(directory) + os.pathsep + os.environ.get('PATH', ''),
'FAKE_SSH_STOPPED': str(stopped)}
process = subprocess.Popen([sys.executable, str(SCRIPT), '--frame', 'fake-frame',
'--port', str(receiver.getsockname()[1])],
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, env=env)
try:
packet, _ = receiver.recvfrom(1500)
self.assertEqual(packet, b'G' * 1316)
process.send_signal(signal.SIGTERM)
_, errors = process.communicate(timeout=8)
self.assertEqual(process.returncode, 0, errors.decode())
self.assertIn(b'Frame Control OBS stream is ready', errors)
for _ in range(30):
if stopped.exists():
break
time.sleep(0.1)
self.assertTrue(stopped.exists(), 'the SSH child survived relay shutdown')
finally:
if process.poll() is None:
process.kill()
process.communicate(timeout=5)
if __name__ == '__main__':
unittest.main()
+33 -1
View File
@@ -1240,6 +1240,11 @@
</div>
<div class="hint" id="recStatus" role="status" hidden></div>
<div class="hint" id="viewHint"></div>
<div id="obsControl" class="hint" hidden>
<button class="small" id="obsBtn">Start OBS source</button>
<span id="obsStatus" role="status"></span>
</div>
<p class="sub"><a href="https://github.com/saphid/frame-control/blob/main/docs/obs-headset-source.md" target="_blank" rel="noopener noreferrer">Set up the headset view and sound in OBS →</a></p>
</section>
@@ -4388,7 +4393,7 @@ function renderCats() {
$("storeCats").querySelectorAll("[data-cat]").forEach(b => b.setAttribute("aria-pressed", String(b.dataset.cat === sourceState.category)));
const [, label, eyebrow, blurb] = storeCat(sourceState.category);
$("storeHeading").hidden = !sourceState.category;
$("storeHeading").innerHTML = `<div class="store-eyebrow">${esc(sourceState.category ? eyebrow : "Android apps for your Frame")}</div><h1>${esc(sourceState.category ? label : "Apps & VR")}</h1><p>${esc(blurb)}</p>`;
$("storeHeading").innerHTML = `<div class="store-eyebrow">${esc(sourceState.category ? eyebrow : "Android apps for your Frame")}</div><h1>${esc(sourceState.category ? label : "Apps & VR")}</h1><p>${esc(blurb)}</p>${sourceState.category === "emulators" ? '<p><a href="https://github.com/saphid/frame-control/blob/main/docs/emulators.md" target="_blank" rel="noopener noreferrer">Set up an emulator and your own games →</a></p>' : ""}`;
}
function renderStore() {
const apps=sourceState.apps, q=$("sourceQ").value.trim(), browsing=!q && !sourceState.category && !sourceState.installable && !sourceState.source;
@@ -5489,6 +5494,33 @@ $("recBtn").onclick = async e => {
if (res) showRec(res); else pollRec();
};
// The desktop app owns the local OBS relay, so it can close SSH when the app
// exits. The browser and mobile views keep the command-line guide instead.
if (window.frameApp?.obs) {
const obs = window.frameApp.obs;
const wrap = $("obsControl"), button = $("obsBtn"), status = $("obsStatus");
let state = { state: "idle" };
function showObs(next) {
if (!next) return;
state = next;
wrap.hidden = !next.supported;
button.textContent = next.state === "running" ? "Stop OBS source" : "Start OBS source";
button.disabled = next.state === "starting" || next.state === "stopping";
status.textContent = next.state === "starting" ? "Connecting to the Frame and waiting for video…"
: next.state === "running" ? `Sending ${next.alias} view and sound to udp://127.0.0.1:15099`
: next.state === "stopping" ? "Stopping the OBS source…"
: next.state === "failed" ? next.error : "";
}
obs.onState(showObs);
obs.status().then(showObs).catch(() => {});
button.onclick = async e => {
const stopping = state.state === "running";
const result = await act(stopping ? "Stop OBS source" : "Start OBS source",
() => stopping ? obs.stop() : obs.start(), e.currentTarget);
if (result) showObs(result);
};
}
const clipsState = { list: [], gen: 0, exporting: new Set() };
async function loadClips() {
if (HOST.mobile) return;