PICO-based WebXR VR teleoperation

WebXR controllers feed EVA's generic teleop client. EVA retargets controller deltas into end-effector targets, checks them with FK/IK and safety limits, and only then publishes a robot action.

Positioning and support scope

This page documents the PICO-based WebXR input path added in 0.2.0. The VR node is an external input adapter, not a robot driver. It serves a browser page, reads WebXR controller poses and buttons, validates and normalizes them, and publishes normalized frames to EVA over ZeroMQ. It does not connect to a robot SDK and it does not drive motors directly. EVA's transport remains the component that executes a validated action on the robot.

PICO is the primary and recommended device path. PICO 4 and PICO 4 Ultra controller profiles work with the example node. Quest is compatible with the same node: Oculus Touch profiles use the same normalization and retargeting contract. Device compatibility does not imply universal robot support. Only the three collection presets below expose a WebXR VR client in this repository. The other listed robots continue to use physical Leader/Follower or ROS transport input.

RobotOfficial VR collection presetController bindingBoundary
AgiBot G2configs/02_collection/agibot_g2_vr.pyleft_arm=left, right_arm=rightWebXR primary collection path; a matching RL preset also exists.
ARX X5configs/02_collection/arx_x5_vr.pyleft_arm=left, right_arm=rightWebXR teleoperation; no physical Leader adapter is wired into its hardware node.
Dual Frankaconfigs/02_collection/dual_franka_vr.pyleft_arm=left, right_arm=rightWebXR primary collection path; a matching RL preset also exists.
Do not infer support. UR5e, AgileX Piper, ARX R5, R1 Lite, and the other hardware entries have no WebXR preset here. Changing one of their configs to vr_webxr does not make a VR path available.

Control data path: controller input to validated robot action

The browser sends a browser frame (browser-frame version is 1) to the node's authenticated /ws route. The node validates the token, pose dimensions, quaternion, and XR reference space. It wraps the result in protocol eva.teleop.vr with version=3, assigns a session identifier, and carries forward the browser's monotonically increasing sequence, then publishes it on ZMQ PUB endpoint tcp://127.0.0.1:8765. EVA's VR client subscribes to that endpoint. Event acknowledgements travel back through ZMQ ACK endpoint tcp://127.0.0.1:8766.

For every control tick, EVA reads the robot's current joint feedback and computes its current canonical EEF state. Each arm is represented by eight values: x y z qw qx qy qz gripper. VrTeleopClient produces canonical EEF targets plus an active-arm mask. The application uses the current qpos as the IK seed, solves for robot joints, runs FK to measure the residual, applies the joint and gripper limits, and calls transport publish_action. The node owns input and operator events; the client owns retargeting; the EVA application owns gates, IK/FK checks, and publishing.

text
PICO/Quest WebXR
    │  WebSocket /ws (token)
    ▼
vr_webxr/node.py (protocol v3, events, heartbeat)
    │  ZMQ PUB 8765 / PULL ACK 8766
    ▼
EVA VrTeleopClient (relative retargeting, per-arm authorization)
    │  canonical EEF → IK → FK residual and qpos limits
    ▼
EVA transport.publish_action → real robot
Coordinate meaning. A controller pose is absolute only inside the XR local, local-floor, or bounded-floor reference space. It is not an absolute robot coordinate. The client accumulates controller-to-controller relative deltas and adds them to a latched robot EEF.

Prepare the PICO path

The procedure below uses the recommended topology: EVA and the node run on a remote host, while an Ubuntu host is connected to the PICO over USB. Run relative commands from the eva-client-new repository root. The headset reaches a loopback URL through SSH and ADB forwarding. Do not expose an unencrypted remote-IP HTTP page to the headset.

Remote security. For direct remote-IP access, configure the node with --tls-cert and --tls-key so the browser uses HTTPS/WSS, or put a TLS-terminating reverse proxy in front. The source treats loopback as the development exception; PICO use is recommended through loopback + SSH/ADB.

Start PICO end to end

Start the WebXR node first, then start EVA with one of the supported collection configs. When --token is omitted, the node generates a random token and prints it in its log. Copy that exact token for the headset. The token is part of the WebSocket authorization contract and cannot be omitted from the PICO launcher.

Start the WebXR node

From the EVA Client repository, run the command in the VR example README with the page and ZMQ ports used by the official presets:

bash
cd "$CLIENT_ROOT"
source .venv/bin/activate

python examples/input_sources/vr_webxr/node.py \
  --host 127.0.0.1 \
  --port 43876 \
  --endpoint tcp://127.0.0.1:8765 \
  --ack-endpoint tcp://127.0.0.1:8766

The repository wrapper can locate the repository and activate its environment for you. It passes VR_TOKEN through to the node:

bash
VR_TOKEN="<OPTIONAL_TOKEN>" ./examples/input_sources/vr_webxr/run_node.sh

An empty token still lets the node generate one, but you must then use the token printed by that node instance. Keep the node terminal open so its WebXR node ready, PICO command, and receive diagnostics remain visible.

Forward page port 43876 over SSH

On the Ubuntu host connected to PICO, keep this SSH tunnel running. Here <remote-host> is the host where the node is listening. The tunnel maps Ubuntu loopback port 43876 to the remote host's loopback port with the same number.

bash
ssh -N \
  -p 22 \
  -o ExitOnForwardFailure=yes \
  -o ServerAliveInterval=30 \
  -o ServerAliveCountMax=3 \
  -L 43876:127.0.0.1:43876 \
  <remote-user>@<remote-host>

Check ADB and open the PICO page

Query the device before launching. If exactly one authorized device is present, the script detects its serial. With multiple devices, pass the PICO serial explicitly.

bash
adb devices -l

export VR_TOKEN="<TOKEN_FROM_NODE_LOG>"
./examples/input_sources/vr_webxr/open_pico.sh

open_pico.sh requires VR_TOKEN, checks that the selected ADB state is exactly device, runs adb reverse tcp:43876 tcp:43876, and opens a URL containing the token, mode=ar, and a reload timestamp. Choose a device either way shown below; a command-line serial takes precedence over PICO_SERIAL:

bash
./examples/input_sources/vr_webxr/open_pico.sh "<PICO_SERIAL>"

export PICO_SERIAL="<PICO_SERIAL>"
export VR_TOKEN="<TOKEN_FROM_NODE_LOG>"
./examples/input_sources/vr_webxr/open_pico.sh

After the page opens, click ENTER MR to enter the WebXR session. To force a browser refresh, the README uses this URL and Android command:

bash
VR_URL="http://127.0.0.1:43876/?token=<TOKEN_FROM_NODE_LOG>&mode=ar&reload=$(date +%s)"
adb -s "$PICO_SERIAL" shell "am start -S -a android.intent.action.VIEW -d '$VR_URL'"
First live check. The node should print WebXR node ready and a single-line VR RX seq=... diagnostic with increasing sequence numbers. EVA should report a connected VR source, fresh input age, and the expected authorized arms.

Launch one of the three official collection presets

In another terminal, still at the EVA Client repository root, choose exactly one command. Each preset sets collection.teleop.control_source to "client" and constructs a vr_webxr client. The commands are the complete official WebXR collection entrypoints:

TargetCommandCollection details
AgiBot G2eva --config configs/02_collection/agibot_g2_vr.pyDual-arm WebXR; collection schema comes from its inherited config.
ARX X5eva --config configs/02_collection/arx_x5_vr.pyDual arm, three camera schema and task list; output root is work_dirs/collection/arx_x5_vr.
Dual Frankaeva --config configs/02_collection/dual_franka_vr.pyDual-arm WebXR; collection schema comes from its inherited config.

In COLLECT, confirm robot feedback and the VR client connection. Short-release the right B to open the global ARM gate, long-press each hand's grip to authorize the corresponding arm, and short-release the right A to start recording. During a take, leave the relevant authorization on (it is a toggle, not a continuous grip hold). Short-release right A again to stop and save; hold right A for the long-press cancel action.

Check before real motion. Verify left/right identity, the coordinate rotation, and gripper direction in a safe posture first. ARM, per-arm authorization, workspace, and IK thresholds all have to permit a command before EVA publishes it.

Controller mapping and three separate meanings

The node uses a fixed six-slot WebXR gamepad layout: trigger=0, grip=1, primary=4, and secondary=5. Buttons 2 and 3 are not mapped to operator events. The profile string is preserved for diagnostics but does not replace this implemented layout. The A/B/X/Y labels below follow the source comments and tests; the numeric slot is the wire-level fact.

InputEdge or durationWire or application meaning
Both triggers (button 0)Normalized to [0,1] on every frameGripper target input. It is neither ARM nor grip authorization. A non-authorized arm can still carry a separate gripper target, although ARM OFF prevents an application publish.
Both grips (button 1)Default 1000 ms long press; long-press again to switch offThe node debounces and sends grip_engaged per hand. The raw grip button is not sent to EVA. A toggle requests haptic intensity 0.6 for 80 ms; actual vibration depends on WebXR Gamepad haptics support.
Right A / primary (button 4)Short release emits; holding at least 1000 ms emits one long eventShort release: record_toggle. Long press: record_cancel, and the later release is suppressed.
Right B / secondary (button 5)Short release emitsarm_toggle, the global collection ARM gate.
Left X / primary (button 4)Release emitshome. Shared application state accepts it only when the collection controls permit HOME, including ARM OFF.
Left Y / secondary (button 5)Release emitsintervention_toggle for a configured RL REAL/HIL workspace.

Keep the following three controls conceptually separate. Global ARM is EVA collection's total gate. Right B is routed through the shared web:collect_arm:on/off commands; with ARM OFF, neither hand may make collection publish motion. Per-arm grip authorization is a hand-specific motion permission. Only a controller with grip_engaged=true makes that arm's spatial EEF target active; an unauthorized arm's joints are held at the previous safe value. Trigger gripper control is a gripper data channel. Its button-0 value is mapped to a gripper target and is not a synonym for either gate.

ARM OFF or a teleop reset clears both hands' permissions and accumulated pose references. Turning ARM back on does not restore old permissions. A new WebXR session also starts without the old controller state. This distinction is intentional: a gate transition must not silently re-arm motion, while a trigger may still describe a gripper target once a permitted application tick exists.

Relative retargeting, pose, and gripper behavior

Each WebXR pose contains a three-element position and an orientation_xyzw quaternion. Both node and client reject non-finite values, wrong dimensions, and a zero quaternion, and normalize valid quaternions. Accepted reference spaces are only local, local-floor, and bounded-floor. These are XR reference coordinates, not robot-world coordinates.

On the first update for an engaged arm, the retargeter latches the measured robot EEF as its home EEF and latches the controller's current position and rotation as the reference. That first output is home, so grabbing at an arbitrary headset pose does not jump the robot. Each later frame subtracts the previous controller position, composes the relative rotation, and accumulates both deltas. The position accumulation is transformed by base_from_xr_rotation, scaled by position_scale, and added to home. The orientation uses the accumulated relative rotation around the home orientation and is emitted in canonical qw qx qy qz order.

Releasing an arm's grip freezes its accumulated EEF target and clears that arm's current controller reference. Re-engaging at a new controller pose establishes a new reference and continues from the previous accumulated target, without a release/re-grab jump. A full reset clears home, reference position, reference rotation, accumulated position, accumulated rotation, and the filter. If tracking is lost while an arm is authorized, that tick is rejected; recovery continues from the existing anchor rather than re-anchoring to a bad pose.

eef_filter_alpha is the low-pass coefficient in (0,1]; 1.0 disables smoothing. Smaller values smooth position and quaternion jitter, while the gripper value stays responsive. An optional workspace supplies finite three-dimensional min and max bounds. An out-of-bounds target is rejected by the client and is not sent downstream.

Gripper modeSource calculationInterpretation
binaryAt or above threshold, use close_value; otherwise use open_valueThreshold open/close.
analog / linearopen_value + trigger * (close_value - open_value)All three official VR presets use linear. A digital PICO trigger press is normalized to 1.0 even when its GamepadButton value is zero.
toggleA trigger rising edge across threshold switches the stored targetUsed only when selected in a config; state belongs to the retargeter.

Configuration fields and a shortened official example

VR fields live under collection.teleop. During validation, EVA compares client arm group names with the robot's runtime arm groups. The arms mapping must contain exactly those names, and every group must bind to a unique left or right controller. The parser rejects equal endpoints, non-positive timeouts, invalid rotation matrices, invalid workspace bounds, and gripper thresholds outside [0,1].

KeyPurpose and constraints
collection.teleop.control_sourceUse "client" for an input client. "transport" selects the separate hardware-side Leader path.
collection.teleop.client.typeUse "vr_webxr"; the generic factory builds VrTeleopClient.
endpoint / ack_endpointDifferent tcp:// endpoints. Official values are tcp://127.0.0.1:8765 and tcp://127.0.0.1:8766.
input_timeout_s / heartbeat_timeout_sFreshness limit for frames and liveness limit for node heartbeats. Official presets set 0.25 and 2.0 seconds.
position_scale / base_from_xr_rotationScale is positive. The axis transform is a finite, orthonormal, determinant-one 3×3 rotation.
eef_filter_alphaOptional smoothing coefficient in (0,1]; omitted means 1.0.
workspace.min/maxOptional finite three-vectors with each minimum below its maximum.
grippermode is binary, analog, linear, or toggle; threshold is in [0,1]; open and close values are finite.
arms.<group>.controllerBind the named arm group to left or right. Arm-level workspace, rotation, scale, filter, and gripper keys can override shared values.
collection.teleop.safetyApplication thresholds: max_qpos_step, max_position_error_m, and max_orientation_error_rad.

The following shortened block is taken from the official ARX X5 VR preset (its common fields also appear in the Dual Franka preset). It omits robot base configuration, ARX X5 cameras, and tasks. Use the target robot's official file when editing values: AgiBot G2's official gripper endpoints are open_value=0.0 and close_value=-0.785, so its endpoints must not be replaced casually with the 1.0/0.0 values shown here.

python
collection = dict(
    teleop=dict(
        _delete_=True,
        control_source="client",
        safety=dict(
            max_qpos_step=0.08,
            max_position_error_m=0.08,
            max_orientation_error_rad=0.35,
        ),
        client=dict(
            type="vr_webxr",
            endpoint="tcp://127.0.0.1:8765",
            ack_endpoint="tcp://127.0.0.1:8766",
            input_timeout_s=0.25,
            heartbeat_timeout_s=2.0,
            position_scale=1.0,
            eef_filter_alpha=0.1,
            base_from_xr_rotation=[
                [0.0, 0.0, -1.0],
                [-1.0, 0.0, 0.0],
                [0.0, 1.0, 0.0],
            ],
            gripper=dict(
                mode="linear", threshold=0.6,
                open_value=1.0, close_value=0.0,
            ),
            arms=dict(
                left_arm=dict(controller="left"),
                right_arm=dict(controller="right"),
            ),
        ),
    ),
)
Keep safety constraints meaningful. If adding an arm-level workspace or override, preserve its shape and validation rules. Do not put absolute XR positions into robot coordinates, delete a real workspace bound, or enlarge an error threshold merely to suppress a rejection.

ARM, FK/IK, and publish-time safety

When the console switches to COLLECT, EVA can prewarm the client teleop FK/IK path. It uses the robot's initial qpos for one FK→IK→FK validation and publishes no robot action during prewarm. Each live tick reads the latest transport qpos, computes measured EEF, polls the VR client, solves IK with the current qpos as seed, and validates the forward-kinematics residual before publishing.

CheckOfficial behaviorWhen it fails
Global ARM and collection stateCollection must be in COLLECT, collection teleop must be active, and ARM must be ON. The RL workspace has separate restrictions on collection VR buttons.The event is rejected or ignored; no collection motion is published.
Per-arm active maskAn engaged grip makes that arm active. An inactive arm's joints return to the previous safe qpos while its independent gripper target is preserved.That arm stays still; another active arm cannot move it.
WorkspaceThe retargeter checks each target position against its arm's min/max bounds.The client returns rejected and the tick is not published; a later safe frame can recover.
IK/FK residualPosition error is limited to 0.08 m; orientation error is limited to 0.35 rad.The fault is recorded and that tick is not published.
qpos and gripper limitsEach non-gripper joint changes by at most 0.08 per tick. Gripper values are clipped to the min/max of robot gripper_open and gripper_close.Sudden joint changes and non-finite commands are not allowed.

The client exposes an explicit TeleopResult: only COMMAND enters IK and publishing, IDLE means no action is available, and REJECTED means that frame or target is unsafe. A stale, tracking-loss, workspace, or IK tick is dropped without automatically restarting the collection lifecycle. Read the status before proceeding or deactivating teleop.

Freshness, sessions, sequence, and fail-closed reconnects

Safety depends on message health as well as pose values. The node emits a heartbeat about every 0.25 s. EVA reports the source as connected only when the worker is healthy, the browser is connected, and the most recent node heartbeat is no older than heartbeat_timeout_s=2.0 seconds. A frame's seq must be a non-negative integer greater than the previous frame; an old or duplicate sequence is ignored. Every frame has a non-empty session_id, and an event must belong to the active session and is deduplicated by event id.

Recovery sequence. On disconnect or stale input, release all buttons and both grips, wait for neutral/connected status, then press B again for ARM ON and long-press each grip again. Old session state is never restored automatically.

Troubleshoot in order

Check the layers in this order: device, page, node, ZMQ, EVA, then robot. Treat token, session, sequence, and source-error values in logs as current-session diagnostics; do not commit the token to a public script or repository.

SymptomCheck and action
adb was not foundInstall Android Platform Tools and confirm adb is on PATH. The script stops before opening a page when this check fails.
No authorized ADB device is connectedCheck the USB cable and accept the headset authorization prompt, then rerun adb devices -l. The device state must be device.
Multiple ADB devicesPass the exact serial as ./examples/input_sources/vr_webxr/open_pico.sh "<PICO_SERIAL>" or set PICO_SERIAL.
VR_TOKEN is required or page 401Copy the token from the current node log, run export VR_TOKEN="<TOKEN_FROM_NODE_LOG>", and reopen. A missing, mistyped, or token from a different node instance cannot authorize /ws.
PICO page will not open or refreshConfirm the SSH tunnel is running and uses 43876, then confirm adb reverse tcp:43876 tcp:43876; reopen through the script. Direct remote-IP access needs HTTPS/WSS.
Node cannot start its ZMQ bridgeCheck whether ports 8765 or 8766 are already occupied and ensure endpoint and ack-endpoint differ. Keep one node instance for the selected ports.
EVA says VR input is not connectedCheck for WebXR node ready and increasing VR RX seq. The config's tcp://127.0.0.1:8765/:8766 must exactly match the node.
VR input frame stale or heartbeat disconnectedRe-enter MR, inspect the page and SSH/ADB path, and ensure the node is still running. Recover through neutral instead of trusting an old frame.
VR left/right controller is not trackedCheck headset tracking, controller visibility, and battery. Once valid=true returns, long-press that hand's grip again from a safe pose.
VR target outside workspaceMove the controller back inside the configured bounds and check workspace.min/max against the robot workspace. Do not remove a real safety boundary just to clear the error.
IK failed or residual exceeds a thresholdCheck current robot qpos feedback, EEF/URDF calibration, base_from_xr_rotation, and target orientation. Fix the configuration or start pose; do not bypass the 0.08 m/0.35 rad checks.
Only one arm moves, gripper direction is reversed, or a button does nothingCheck arms.left_arm.controller/right_arm.controller, gripper endpoints, and button-4/5 release semantics. Observe event ACKs and authorized_groups; B's ARM state is not grip authorization.
No action after reconnectThis is the intended protection. Release controls, wait for a new-session neutral frame, press B for ARM ON, and long-press each grip again. Old permissions and accumulated deltas were cleared.

Success criteria and collection output

A safe live-control check has four layers of evidence. The node remains ready and its VR RX seq increases. EVA reports connected=true, input age below 250 ms, and a healthy heartbeat. After ARM ON, every intended arm appears in authorized_groups and engaged_groups, and the published-action count increases. The first engaged frame starts at the measured EEF without a jump, and later targets stay inside the workspace and IK/FK limits.

For a collection take, short-release right A to start, perform the task, and short-release right A to stop; wait until COLLECT returns to idle. A successful episode adds data/chunk-000/episode_NNNNNN.parquet, one videos/chunk-000/<camera>/episode_NNNNNN.mp4 under each configured camera directory, and a line in meta/episodes.jsonl under the task dataset. A right-A long cancel should write nothing for that take.

Scale gradually. To validate the link, first verify status, neutral, authorization, and one no-jump target. Only after that check passes should you record full tasks. A red quality episode is still saved, but review it in COLLECT or REPLAY.

Next steps