VRFrame JSON messages from the VR app. A self-signed TLS certificate is auto-generated in ~/.almond/vr/certs/ on first use. This module can be used standalone to read raw VR data without full teleoperation — useful for custom control loops or data collection.
VRFrame fields
Pose transports & USB/Wi-Fi fallback
The headset can stream pose over more than one transport at once — the network WebSocket, a USBadb reverse tunnel (Quest over USB), and a dedicated WebRTC data channel (remote teleop). Every copy carries the same pose_source_id and seq; the server keeps a monotonic high-water mark for that logical producer and processes each frame exactly once, from whichever link delivers it first. A cable drop therefore falls through to the next Wi-Fi copy with no reconnect, while a delayed standby copy can never rewind controls or enter the jitter buffer. The producer state resets after its last carrying client disconnects and on disable(), so a reloaded source with a restarted counter is accepted.
Only one logical producer owns poses at a time. Managed Quest Mantis sets VRServerConfig.pose_source_kind="webxr"; managed Lighthouse/Ultimate sets it to "tracker" and gives both the owned bridge and server a fresh expected_pose_source_id for that operation. A connected source of another kind or ID stays available for signaling, camera video, server-driven HUD, and URDF state but its VRFrames and client-originated HUD controls are view-only and ignored, so a forgotten standalone bridge or connected Quest cannot claim or alter a managed run. Lighthouse/Ultimate worlds are not automatically registered to that viewer’s Quest local-floor world, so their URDF state carries viewer_world_aligned=false and the client hides the spatial overlay by default. On an unrestricted compatibility server the first producer claims control, except that a tracker bridge may supersede WebXR; once a tracker has claimed it, the server does not silently fail over to a still-connected Quest if the bridge dies.
The WebRTC pose data channel (almond_axol.vr.control_channel) shares the same ICE/TURN path as the camera video, so an operator off your tailnet can drive over a relay. ICE servers are read from the environment (AXOL_TURN_URL / AXOL_TURN_USERNAME / AXOL_TURN_PASSWORD; see almond_axol.vr.ice) — on a LAN or plain tailnet they’re unset and the direct path is used. If the data channel fails to negotiate (or the peer connection drops), the headset re-requests it after a backoff rather than permanently falling back to the WebSocket.
Server → headset feedback
The server can push a state override to all connected headsets at any time usingVRServer.broadcast_text(). The headset interprets messages of the form {"type": "state", "value": "saving"} as a state override that blocks recording controls. {"type": "state", "value": "error"} shows an error indicator in the headset UI. {"type": "state", "value": "data_collection"} re-enables controls after saving. The AxolVRTeleop.send_feedback_state(state) helper wraps this for all VRState values.
On connect the server also announces its operating mode — {"type": "mode", "value": "teleop" | "data_collection"} — set via VRServer.set_mode(). The web UI uses it to lock the HUD to a single mode: axol teleop sends teleop (recording controls hidden), while axol collect-data sends data_collection (recording enabled, but no switching back to plain teleop). A separate replayable {"type": "pose_source_kind", "value": "webxr" | "tracker" | null} announcement makes a Quest’s A/X recording controls view-only in tracker-owned Lighthouse/Ultimate sessions; the server independently rejects HUD control messages from any socket that is not carrying the active WebXR pose source. These messages are pushed once per client in the WebSocket accept handler, so a headset that joins mid-session still gets them.
During data collection the server likewise pushes the current episode number — {"type": "episode", "value": N} (1-based) — which the headset renders as an Episode N HUD readout. axol collect-data announces it at the start of each episode via AxolVRTeleop.send_feedback_episode(episode), which both stores the value on the server (VRServer.set_episode(), re-sent on connect so a mid-session headset shows the right number) and broadcasts a live update to connected clients. Plain teleop never sets it, so the readout stays hidden.
Teleop additionally broadcasts {"type": "tracking", "value": true|false} whenever the engage toggle changes, so the headset knows when the robot is being controlled — it uses this to only allow repositioning and resizing the camera screens (a trigger gesture) while tracking is disengaged.
Teleop also streams the arm joint state at ~20 Hz — {"type": "joints", "value": {"q": {"<urdf joint>": rad, ...}, "l_grip": 0..1, "r_grip": 0..1, "engaged": bool, "pair": {"aligned": bool, "width": m, "tilt": deg, "grasp": str} | null}} (measured positions when the robot reports them, else the commanded targets) — for the headset HUD. pair is the gripper-pair geometry the IK worker reports a few times a second (IKWorker.pair_status()): aligned is true when the two grippers already form the box-mode pair — level, the fitted tool’s contact face toward each other across a box-mode-sized gap — the HUD’s Box button shows (aligned) then, since switching to box mode from that pose costs no alignment blend; width is the grip width (the separation the tool’s contact faces would have with the mounts where they are), tilt the pair’s inward tilt trim in degrees and grasp ("flush" / "straight") the current grasp, both shown on the same button while the mode is on. The URDF and its meshes are served from the same origin under /urdf/ (axol.urdf, meshes/*.stl).
Live session settings
The knobs an operator can turn mid-session — box mode, re-engage behaviour, hold-to-engage, grip force, reach scale, arm speed — are exposed as live settings (almond_axol.teleop.live.LiveSettings) on the same socket, from any client (headset HUD, control panel, SDK):
- client → server
{"type": "set", "key": "<key>", "value": <value>}— handled by the callback installed withVRServer.set_on_setting(). Values are validated (type, range, choices); a rejected request is logged and simply not echoed. A boolean key also accepts"value": "toggle", flipped against the server’s current value — what the headset’s both-sticks gesture sends, so a gesture repeated before the echo lands can’t re-send a stale state. - client → server
{"type": "session-config-request"}— re-sends that client the connect-time session config (mode, everyset_announceentry such assettings,pose_source_kind,pose_mode,tracking,episode,hud). The web client sends it once its message listeners are installed, since those come up a render after the socket opens and the connect-time copy can land before anyone is listening (a headset that missedsettingswould hide every live control, box mode included). - server → clients
{"type": "settings", "value": {"schema": [...], "values": {"<key>": ...}}}— sent on connect (set_announce("settings", ...)), onsession-config-request, and after everyset, including no-ops, so all clients converge on the server’s state.schemais a list of{key, label, type: "boolean"|"select"|"number", help, options, min, max, step, unit}entries; both UIs render their controls from it, so a new knob is one entry inLIVE_SETTINGS.
The
box_* keys exist only when the robot has the parcel gripper (VRTeleopConfig.gripper == "parcel", adopted from AxolConfig.gripper): with the parallel gripper they are left out of both schema and values and a set of one is rejected, so clients show box mode only when box_mode is in values.
Core-side settings are queued (VRTeleopCore.set_live()) and applied on the IK thread before the next frame — or while idle, so the panel can change them with no headset connected; IK-worker fields are mirrored to the worker subprocess as ("set", key, value) messages.
The server also relays HUD state between clients. The active WebXR pose client publishes its transient HUD — an armed save/discard confirmation popup and the record-start countdown — as {"type": "hud", "value": {...}}; the server stores the latest value, forwards it to every other connected client, re-sends it to one that joins mid-dialog, and clears it with a null broadcast when the publisher disconnects. Signaling-only viewers and Quests attached to tracker-owned runs cannot publish this state. The web control panel uses the relay to mirror the in-headset popups so a session can be followed from the dashboard.
Camera video to the headset
The server can stream camera frames to the headset over WebRTC. Register per-camera sources withset_video_sources(). A source is either a pre-encoded camera (the GPU-resident gst_zed pipeline, exposing subscribe() / alive — its H.264 access units go straight to RTP with no Python encode) or a raw frame source. A connected ZedCamera (or stereo eye) is registered directly — the relay adapts it — and you can equally pass any object exposing fixed width / height / fps plus wait_next(after_ts, timeout_ms). The manager picks the right WebRTC track per source automatically:
/ws channel is reused for SDP signaling (no extra ports): the headset sends webrtc-request, the server replies with a webrtc-offer carrying one video track per source (labelled by camera name), and the headset answers with webrtc-answer. A stereo overhead streams both eyes packed side-by-side in one track, overhead_sbs — a single decoder session on the headset, since two full-res sessions cap the Quest’s render rate — which the VR app renders per-lens through a WebXR media layer; the SDK-fallback path (no gst) instead registers the two per-eye sources overhead_left / overhead_right. A stereo wrist is registered as a single source under its slot name (one feed, like a mono camera). Dataset recording is unaffected — it still stores the two per-eye observation keys.
Pass None or {} to turn video off. axol teleop --cameras and collect-data wire this up automatically.
Pipeline
Teleop and data collection share one GPU-resident camera path (almond_axol.video.gst_zed). The zed-gstreamer source elements (zedxonesrc mono / zedsrc stereo) grab frames straight into NVMM (GPU) memory, and a single in-process GStreamer pipeline tees that one zero-copy buffer to two consumers:
- a headset branch — fixed at 30 fps,
nvv4l2h264enc(NVENC) →appsink— so the whole grab→encode chain stays on the GPU and Python only sees H.264 access units, which aiortc forwards as pre-encoded packets; - a dataset branch — independently rate-limited to the requested recording fps, downscaled by the VIC, and NVENC-encoded.
gdppaypreserves every access unit’s sensor-exposure PTS through nativeshmsink/shmsrc, so the recorder can pair it with the nearest timestamped robot state and mux it without a raw-frame copy or re-encode; - a raw policy/fallback branch —
nvvidconvconverts to RGBA for an in-processappsinkor Python shared-memory fallback. Its exposure PTS is likewise mapped totime.perf_counter.
almond_axol.video.video_proc.VideoRelayProcess, registered via set_video_manager()). Teleop builds only the headset branch. Data collection adds the independently paced dataset branch and a second recorder subprocess that owns the LeRobotDataset. The control loop publishes joint/action snapshots into a bounded shared-memory history; the recorder selects the entry nearest the median camera exposure. Missing timestamps, stale/repeated frames, excessive camera/state skew, or broken predictive-video continuity discard the episode rather than silently saving a mismatched row. time.perf_counter has a process-shared monotonic origin, so relay PTS and control snapshots are directly comparable. If native GDP/shared-memory support is absent, collection uses the strict raw-frame fallback; if the timestamp-patched ZED plugin binary cannot be verified, it falls back to the ZED SDK.
Requires the GPU-resident camera stack the host installer sets up once: the GStreamer + PyGObject support from axol gst.install plus the patched zed-gstreamer source plugins from axol gst.build-zed (the NVENC elements themselves ship with the Jetson L4T BSP). The NVENC/VIC engine clocks must also be pinned to their maximum for low-latency encode (axol provision does it — see jetson.setup — and the installer’s systemd unit re-applies it at boot); the default Jetson devfreq governor scales them for throughput, which otherwise roughly triples per-frame encode latency.
