Compare commits

..
Author SHA1 Message Date
SAPHID 2e9140f49f Add local OBS headset view source with output audio 2026-10-24 21:18:00 +00:00
13 changed files with 15 additions and 281 deletions

No files matched your search

-20
View File
@@ -2,26 +2,6 @@
## Unreleased
- 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. OBS playback and audio
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,7 +17,6 @@ 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",
+2 -60
View File
@@ -304,63 +304,6 @@ 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}$/;
@@ -371,7 +314,6 @@ 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;
@@ -699,6 +641,6 @@ if (!app.requestSingleInstanceLock()) {
});
app.on("activate", () => { if (!win) createWindow(); });
app.on("window-all-closed", () => app.quit());
app.on("before-quit", () => { quitting = true; stopObs(); stopServer(); });
process.on("exit", () => { stopObs(); stopServer(); });
app.on("before-quit", () => { quitting = true; stopServer(); });
process.on("exit", stopServer);
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.21",
"version": "0.4.20",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.21",
"version": "0.4.20",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+1 -2
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.21",
"version": "0.4.20",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
@@ -69,7 +69,6 @@
"to": "scripts",
"filter": [
"*.sh",
"obs-headset-source.py",
"frame_power_sample.py",
"frame_power_report.py"
]
-9
View File
@@ -21,15 +21,6 @@ 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,11 +9,6 @@ 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
@@ -1,54 +0,0 @@
# 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).
@@ -1,19 +0,0 @@
# 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.
+8 -16
View File
@@ -5,27 +5,19 @@ 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:
1. Connect to the Frame with `ssh frame` and start SteamVR. Install `python3`
on the computer; the Frame already has `ffmpeg`. OBS runs on the computer.
2. From this repository, 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.
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. Leave the helper running while the source is in use;
Ctrl+C stops it.
`--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
-4
View File
@@ -94,13 +94,9 @@ def main(argv=None):
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__':
-56
View File
@@ -1,15 +1,7 @@
"""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
@@ -28,13 +20,6 @@ class Receiver:
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()
@@ -55,47 +40,6 @@ class ObsSourceTests(unittest.TestCase):
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()
+2 -33
View File
@@ -1240,11 +1240,7 @@
</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>
<p class="sub"><a href="https://github.com/saphid/frame-control/blob/main/docs/obs-headset-source.md" target="_blank" rel="noopener noreferrer">Use the headset view and sound in OBS →</a></p>
</section>
@@ -4393,7 +4389,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>${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>' : ""}`;
$("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>`;
}
function renderStore() {
const apps=sourceState.apps, q=$("sourceQ").value.trim(), browsing=!q && !sourceState.category && !sourceState.installable && !sourceState.source;
@@ -5494,33 +5490,6 @@ $("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;