Compare commits

..
Author SHA1 Message Date
SAPHID 8b706cf8dd Record Round 3 headset mirror and charge-limit checks 2026-10-24 21:21:19 +00:00
7 changed files with 24 additions and 194 deletions

No files matched your search

+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.
@@ -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.
-38
View File
@@ -1,38 +0,0 @@
# 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 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
```
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
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-24:** against a real Frame running SteamOS 0.5.6,
build 20261022.6153909, the receiver saved a 4.021-second MPEG-TS sample on
the Linux PC; `ffprobe` found H.264 video and AAC audio. The temporary sample
was removed. The headset was unworn, so visible content and audible sound were
not checked. OBS itself was not installed on that PC; its Media Source setup,
monitoring and sync are still unverified.
-3
View File
@@ -1,8 +1,5 @@
# 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.
-107
View File
@@ -1,107 +0,0 @@
#!/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
def send(self, data):
self.sock.sendto(data, self.target)
if __name__ == '__main__':
try:
sys.exit(main())
except (OSError, RuntimeError) as error:
print(error, file=sys.stderr)
sys.exit(1)
-45
View File
@@ -1,45 +0,0 @@
"""The OBS relay must preserve transport packets and keep its output local."""
import importlib.util
import io
from pathlib import Path
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_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))
if __name__ == '__main__':
unittest.main()
-1
View File
@@ -1240,7 +1240,6 @@
</div>
<div class="hint" id="recStatus" role="status" hidden></div>
<div class="hint" id="viewHint"></div>
<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>