Skip to main content
Secure WebSocket server (WSS) that receives VRFrame JSON messages from the VR app. On first use it generates a self-signed TLS certificate in ~/.almond/vr/certs/. You can use this module on its own to read raw VR data without full teleoperation, for example in a custom control loop or for data collection.
Or use a callback instead of polling:

VRFrame fields

Controller input

In box mode, while a grip leads the pair, a single click of either thumbstick toggles the grasp (box_grasp). With the pair frozen, the thumbsticks drive Jelly as usual. Pressing both thumbsticks together, within about 0.35 s, toggles box mode. The headset sends that toggle as a set message, described under Live session settings. The stick, click and l_tracked / r_tracked fields have neutral defaults, so a client that leaves them out still works.

Timing and pose source

Mantis episode control

Set by the server

The headset never sets these. The teleop core adds them to the frame it forwards to the IK worker.

Pose transports and USB/Wi-Fi fallback

The headset can stream poses over up to three transports at once: Every copy carries the same pose_source_id and seq, and the server handles each frame once, from whichever link delivers it first. If the cable drops, the Wi-Fi copy takes over with no reconnect. A late copy never rewinds the controls. The server forgets a producer’s sequence when its last connection closes and on disable(), so a reloaded client starts fresh. Only one producer controls the robot at a time. Other clients stay connected and still receive video, HUD state and the robot model, but the server ignores their frames and HUD controls.
  • Managed Quest Mantis sets VRServerConfig.pose_source_kind="webxr".
  • Managed Lighthouse or Ultimate Mantis sets it to "tracker", and gives the bridge and server a one-run expected_pose_source_id.
  • Without a pose source policy, the first producer takes control. A tracker bridge can take over from a Quest. Once a tracker has control, the server doesn’t fall back to a connected Quest if the bridge stops.
A Lighthouse or Ultimate world isn’t registered to the Quest’s floor, so the headset hides the 3D robot overlay by default in those sessions. The WebRTC data channel (almond_axol.vr.control_channel) uses the same ICE and TURN path as the camera video, so an operator outside your tailnet can drive through a relay. Set the TURN server with AXOL_TURN_URL, AXOL_TURN_USERNAME and AXOL_TURN_PASSWORD. On a LAN or plain tailnet, leave them unset. If the data channel fails or drops, the headset requests it again after a delay.

Server to headset feedback

The server pushes JSON messages to the headset over the same socket. Send any of these with VRServer.broadcast_text(). AxolVRTeleop.send_feedback_state(state) sends the state message for any VRState. VRServer.set_mode() sets the mode message. axol teleop sends teleop, and axol collect-data sends data_collection. AxolVRTeleop.send_feedback_episode(episode) stores the episode number with VRServer.set_episode() and broadcasts it. Plain teleop never sets an episode, so the HUD hides it. The server sends the mode, pose source kind and episode to each client when it connects, so a headset that joins mid-session is up to date. The joints message has this shape:
  • q holds measured joint positions in radians when the robot reports them, and commanded targets otherwise. l_grip and r_grip run from 0 to 1.
  • pair is the gripper pair geometry from IKWorker.pair_status(), or null.
    • aligned is true when the grippers already form a box-mode pair. The Box button then reads (aligned), since switching to box mode needs no blend.
    • width is the grip width in metres, between the contact faces.
    • tilt is the pair’s inward tilt in degrees, and grasp is "flush" or "straight".
The server also serves the robot model under /urdf/ (axol.urdf and meshes/*.stl).

Live session settings

Some settings can change during a session from any client: the headset HUD, the control panel or the SDK. They are defined in almond_axol.teleop.live.LiveSettings and use three messages on the same socket.
  • Client to server: {"type": "set", "key": "<key>", "value": <value>}. The callback set with VRServer.set_on_setting() handles it. The value is checked for type, range and choices, and a rejected request is logged and not echoed. A boolean key also accepts "value": "toggle", which flips the server’s current value.
  • Client to server: {"type": "session-config-request"}. The server re-sends that client the session config it sends on connect: mode, settings, pose_source_kind, pose_mode, tracking, episode and hud. Send it once your message listeners are ready, so you don’t miss the copy sent on connect.
  • Server to clients: {"type": "settings", "value": {"schema": [...], "values": {"<key>": ...}}}. Sent on connect, on session-config-request, and after every set, so all clients show the server’s state. Each schema entry has key, label, type ("boolean", "select" or "number"), help, options, min, max, step and unit. Both UIs build their controls from it.
See VRTeleopConfig fields for what each box-mode setting does. The box_* keys exist only when the robot has the parcel gripper (VRTeleopConfig.gripper == "parcel"). Otherwise they are left out of schema and values, and a set of one is rejected. Clients show box mode only when box_mode is in values. VRTeleopCore.set_live() queues each change and applies it on the IK thread before the next frame, or while the session is idle. Settings that the IK worker reads are passed on to it as ("set", key, value) messages. The server also relays HUD state between clients. The active Quest publishes its save or discard confirmation and the recording countdown as {"type": "hud", "value": {...}}. The server forwards the latest value to every other client, re-sends it to a client that joins mid-dialog, and clears it when the publisher disconnects. Viewers and Quests in tracker-owned sessions can’t publish HUD state. The control panel uses this relay to show the headset’s popups.

Camera video to the headset

The server can stream camera frames to the headset over WebRTC. Register a source per camera with set_video_sources():
A source can be:
  • a pre-encoded camera from the GPU pipeline (gst_zed), with subscribe() and alive. Its H.264 frames go straight to the headset with no encoding in Python.
  • a connected ZedCamera or stereo eye, which the relay adapts.
  • any object with fixed width, height and fps, and wait_next(after_ts, timeout_ms).
Pass None or {} to turn video off. axol teleop --cameras and collect-data set this up for you. Signaling uses the existing /ws channel, so no extra ports are needed. The headset sends webrtc-request, the server replies with a webrtc-offer that has one video track per camera, and the headset answers with webrtc-answer.
  • A stereo overhead camera streams both eyes side by side in one track, overhead_sbs, which the VR app shows per eye. Without GStreamer, the SDK fallback sends two tracks, overhead_left and overhead_right.
  • A stereo wrist camera streams one feed under its slot name, like a mono camera.
  • Dataset recording still stores both eyes of a stereo overhead camera as two observation keys.

Pipeline

Teleop and data collection share one camera path that stays on the GPU (almond_axol.video.gst_zed). The zed-gstreamer sources (zedxonesrc for mono, zedsrc for stereo) capture into GPU memory, and one GStreamer pipeline splits each frame into up to three branches:
  • Headset: 30 fps, encoded by NVENC (nvv4l2h264enc). aiortc forwards the H.264 packets to the headset without re-encoding.
  • Dataset: paced to the recording fps, downscaled and NVENC-encoded. Each frame keeps its exposure timestamp, so the recorder can pair it with the nearest robot state without copying or re-encoding the frame.
  • Raw: converted to RGBA for policies and for the fallback path. Its exposure timestamps are also on the time.perf_counter clock.
Camera capture, encoding and WebRTC run in a separate process (almond_axol.video.video_proc.VideoRelayProcess), registered with set_video_manager(). Teleop builds only the headset branch. Data collection adds the dataset branch and a recorder process that owns the LeRobotDataset. During collection, the recorder pairs each frame set with the robot state sampled nearest the median camera exposure. It discards the episode instead of saving a mismatched row if any of these happen:
  • a timestamp is missing,
  • a frame is stale or repeated,
  • the cameras and robot state are too far apart in time,
  • the predictive video stream breaks.
If shared-memory frame transport isn’t available, collection falls back to raw frames. If the patched ZED plugin can’t be verified, it falls back to the ZED SDK. This path needs the GPU camera stack that the host installer sets up:
  • GStreamer and PyGObject from axol gst.install.
  • The patched zed-gstreamer plugins from axol gst.build-zed. The NVENC elements come with the Jetson L4T BSP.
  • NVENC and VIC clocks pinned to their maximum. axol provision does this (see jetson.setup), and the installer’s systemd unit reapplies it at boot. Without it, encode latency per frame roughly triples.

VRServerConfig fields

Before opening the VR app, accept the self-signed certificate by navigating to https://<hostname>.local:8000 in the VR browser and proceeding past the security warning.