Teleoperation Devices¶
Input-device backends for teleoperation and for driving a policy by hand: keyboard, gamepad, SpaceMouse and GUI
sliders for SE(2)/SE(3) commands, the GELLO leader arm, the Meta Quest and PICO headsets, and the Wuji Glove. Each
device imports its driver lazily, so its optional extra is needed only when you use it. The
teleoperation guide covers the eden gello, eden metaquest, eden pico,
eden wuji and eden teleop pipelines.
Base class¶
SE(2) and SE(3) devices¶
make_se2_device(name) builds a keyboard or gamepad SE(2) device by name; OperatorVelocityCommand
(a velocity command term) reads one.
Building an SE(2) input device by name, and mapping its deflection to a velocity.
Both :class:~eden.utils.devices.keyboard.TeleopSE2Keyboard and
:class:~eden.utils.devices.gamepad.SE2Gamepad emit a normalized (vx, vy, wz)
deflection in [-1, 1]; the consumer owns the conversion to m/s and rad/s, the
contract :class:~eden.utils.devices.keyboard.SE3Keyboard already documents. This
module holds the two halves of that contract shared by every consumer: naming a
device, and scaling its deflection into a velocity range.
A held key is full deflection, from the first press -- the same thing a stick
pushed to its stop means. That is why the keyboard here is TeleopSE2Keyboard and
not its SE2Keyboard base, whose tanh smoothing turns one press into 0.76
and only approaches full deflection as OS key-repeat accumulates, making the
commanded speed depend on the operator's key-repeat rate.
The keyboard bindings are arrows and , / . -- deliberately not letters or
digits. pynput listens to the keyboard globally rather than to the focused
window, so it also sees the keys the operator types at the terminal to drive the
deployment state machine (:mod:eden.extensions.deployment.utils.operator_input
reads s START, x STOP, i INIT, k KILL, n and 1-9 from
stdin). On WASD bindings, typing START would also command the robot backwards.
======================= ================= ================= Command Key (+ve axis) Key (-ve axis) ======================= ================= ================= Forward / backward vx Up Down Strafe vy Left Right Turn wz , . ======================= ================= =================
Attributes¶
Functions:¶
teleop_key_bindings
¶
teleop_key_bindings() -> dict
SE(2) key bindings that do not collide with the deploy operator keys.
See the module docstring for the table and for why letters and digits are
unusable. Requires pynput (the all extra), since the arrow keys are
pynput key objects rather than characters.
Source code in eden/utils/devices/se2.py
make_se2_device
¶
Build a normalized SE(2) input device by name.
| PARAMETER | DESCRIPTION |
|---|---|
device
|
One of :data:
TYPE:
|
options
|
Device options.
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
DeviceBase
|
The device, already listening. |
Source code in eden/utils/devices/se2.py
scale_se2_deflection
¶
Map a normalized [-1, 1] deflection into [low, high] per axis.
Full deflection maps to the larger side of each axis and the result is clamped
into the range, so an asymmetric range (e.g. (-2.0, 3.0)) reaches both ends and
saturates early on the short side rather than overshooting it. Clamping is what
bounds an operator to the velocities a policy was trained on, so it is applied to
the device reading too -- a miscalibrated stick cannot deflect past 1.0 and out of
the range that way.
Source code in eden/utils/devices/se2.py
Keyboard teleoperation device (SE2/SE3).
Attributes¶
Classes¶
KeyBinding
dataclass
¶
Keyboard
¶
Keyboard(key_bindings: dict[str, KeyBinding] = None)
Bases: DeviceBase
Keyboard device.
| PARAMETER | DESCRIPTION |
|---|---|
key_bindings
|
The key bindings for the keyboard.
TYPE:
|
Source code in eden/utils/devices/keyboard.py
Attributes¶
key_bindings
instance-attribute
¶
key_bindings = key_bindings or {'w': KeyBinding(0, +1), 's': KeyBinding(0, -1), 'a': KeyBinding(1, +1), 'd': KeyBinding(1, -1), 'q': KeyBinding(2, +1), 'e': KeyBinding(2, -1), Key.space: KeyBinding(3, 1, is_toggle=True)}
listener
instance-attribute
¶
Methods:¶
reset
¶
stop
¶
Stop listening and zero the readings. Idempotent.
Mirrors :meth:~eden.utils.devices.gamepad.Gamepad.stop so a caller can
release either device without knowing which it holds.
Source code in eden/utils/devices/keyboard.py
get_raw_data
¶
SE2Keyboard
¶
SE2Keyboard(key_bindings: dict[str, KeyBinding] = None, vel_max: float = 1.0, ang_max: float = 1.0)
Bases: Keyboard
A keyboard controller for sending SE(2) commands as velocity commands.
| PARAMETER | DESCRIPTION |
|---|---|
key_bindings
|
The key bindings for the keyboard.
TYPE:
|
vel_max
|
The maximum value of the velocity commands.
TYPE:
|
ang_max
|
The maximum value of the angular velocity commands.
TYPE:
|
Command
|
|
Move
|
|
Move
|
|
Rotate
|
|
Source code in eden/utils/devices/keyboard.py
TeleopSE2Keyboard
¶
TeleopSE2Keyboard(key_bindings: dict[str, KeyBinding] = None, vel_max: float = 1.0, ang_max: float = 1.0)
Bases: SE2Keyboard
:class:SE2Keyboard whose deflection saturates instead of ramping.
SE2Keyboard smooths its readings through tanh. Because :meth:Keyboard._on_press
accumulates (+=) and the OS delivers key-repeat while a key is held, that makes the
reading a ramp: one press is tanh(1) = 0.76, and full deflection is only approached as
repeats pile up — so the commanded velocity depends on the machine's key-repeat rate and
on how long the operator held the key. Fine for nudging a camera, wrong for a velocity
command a robot acts on, where the same gesture has to mean the same speed everywhere.
Here a held key is full deflection from the first press, exactly like a gamepad stick
pushed to its stop, and releasing still returns to zero (reset_on_release). The
ceiling that deflection maps to is the trained range, which
:func:~eden.utils.devices.se2.scale_se2_deflection owns.
Source code in eden/utils/devices/keyboard.py
SE3Keyboard
¶
SE3Keyboard(key_bindings: dict[str, KeyBinding] = None, pos_max: float = 1.0, rot_max: float = 1.0)
Bases: Keyboard
A keyboard controller for sending SE(3) commands as delta poses and binary command (open/close).
| PARAMETER | DESCRIPTION |
|---|---|
key_bindings
|
The key bindings for the keyboard.
TYPE:
|
pos_max
|
Gain on the three position axes. Defaults to
TYPE:
|
rot_max
|
Gain on the three rotation axes, on the same normalized contract as
TYPE:
|
Key bindings
============================== ================= ================= Description Key (+ve axis) Key (-ve axis) ============================== ================= ================= Toggle gripper (open/close) Space Move along x-axis 8 2 Move along y-axis 4 6 Move along z-axis 7 9 Rotate along x-axis Up Down Rotate along y-axis Right Left Rotate along z-axis 1 3 ============================== ================= =================
Source code in eden/utils/devices/keyboard.py
Attributes¶
limit
instance-attribute
¶
Methods:¶
default_key_bindings
classmethod
¶
Source code in eden/utils/devices/keyboard.py
wasd_key_bindings
classmethod
¶
Source code in eden/utils/devices/keyboard.py
get_command
¶
Gamepad teleoperation device (SE2/SE3).
Attributes¶
Classes¶
GamepadBinding
dataclass
¶
GamepadBinding(index: int, scale: float = 1.0, offset: float = 0.0, deadzone: float = 0.0, is_toggle: bool = False)
Gamepad
¶
Gamepad(bindings: dict[str, GamepadBinding] = None)
Bases: DeviceBase
Gamepad device.
| PARAMETER | DESCRIPTION |
|---|---|
bindings
|
The bindings for the gamepad.
TYPE:
|
Source code in eden/utils/devices/gamepad.py
SE2Gamepad
¶
SE2Gamepad(bindings: dict[str, GamepadBinding] = None, vel_max: float = 1.0, ang_max: float = 1.0, scale: float = SCALE_PS4, offset: float = OFFSET_PS4, deadzone: float = DEADZONE_PS4)
Bases: Gamepad
A gamepad controller for sending SE(2) commands as velocity commands.
Default bindings (inputs event codes; axis scaling defaults to a PS4 pad's 0-255 range, pass
scale=SCALE_XBOX, offset=OFFSET_XBOX for an Xbox pad):
- Left Stick Y: Linear Velocity X
- Left Stick X: Linear Velocity Y
- Right Stick X: Angular Velocity Z
Source code in eden/utils/devices/gamepad.py
Attributes¶
Methods:¶
default_bindings
classmethod
¶
Source code in eden/utils/devices/gamepad.py
get_command
¶
Source code in eden/utils/devices/gamepad.py
SE3Gamepad
¶
SE3Gamepad(bindings: dict[str, GamepadBinding] = None, pos_max: float = 1.0, rot_max: float = 1.0, scale: float = SCALE_PS4, offset: float = OFFSET_PS4, deadzone: float = DEADZONE_PS4)
Bases: Gamepad
A gamepad controller for sending SE(3) commands as delta poses and binary command (open/close).
Default bindings (inputs event codes; axis scaling defaults to a PS4 pad's 0-255 range, pass
scale=SCALE_XBOX, offset=OFFSET_XBOX for an Xbox pad):
- Left Stick Y: X axis
- Left Stick X: Y axis
- Right Stick Y: Z axis
- Right Stick X: Yaw (Rot Z)
- D-Pad Up/Down: Pitch (Rot Y)
- D-Pad Left/Right: Roll (Rot X)
- A Button: Toggle Gripper
pos_max and rot_max default to 1.0, i.e. this device emits a normalized delta
pose and the task-space controller downstream owns the conversion to metres and radians via
its position_scale / orientation_scale. Lower them only to clamp the sticks below full
deflection, and expect the physical step to be the product of the two.
Source code in eden/utils/devices/gamepad.py
Attributes¶
Methods:¶
default_bindings
classmethod
¶
Source code in eden/utils/devices/gamepad.py
get_command
¶
Source code in eden/utils/devices/gamepad.py
SpaceMouse teleoperation device (SE2/SE3).
Attributes¶
Classes¶
Spacemouse
¶
Spacemouse(pos_max: float = 1.0, rot_max: float = 1.0, deadzone: float | tuple = 0.3, polling_rate: int = 200, is_toggle_btn0: bool = False, is_toggle_btn1: bool = False)
Bases: DeviceBase
Spacemouse device.
| PARAMETER | DESCRIPTION |
|---|---|
pos_max
|
Gain on the three position axes. Defaults to
TYPE:
|
rot_max
|
Gain on the three rotation axes, on the same normalized contract as
TYPE:
|
deadzone
|
The deadzone of the spacemouse reading. Default is 0.3. |
polling_rate
|
The polling rate of the spacemouse. Default is 200.
TYPE:
|
is_toggle_btn0
|
Whether the first button is a toggle button. Default is False.
TYPE:
|
is_toggle_btn1
|
Whether the second button is a toggle button. Default is False.
TYPE:
|
Source code in eden/utils/devices/spacemouse.py
Attributes¶
tx_zup_spnav
instance-attribute
¶
Methods:¶
get_raw_data
¶
Return the raw normalized state without coordinate transformation.
Source code in eden/utils/devices/spacemouse.py
get_command
¶
Return the transformed command in Eden coordinate system (Z-up).
Source code in eden/utils/devices/spacemouse.py
is_button_pressed
¶
SE2Spacemouse
¶
Bases: Spacemouse
A spacemouse controller for sending SE(2) commands as velocity commands.
Unlike the delta-pose devices, this one feeds a velocity command directly rather than a task-space controller, so it keeps physical units, capped by default at 0.1 m/s and 0.1 rad/s.
| PARAMETER | DESCRIPTION |
|---|---|
pos_max
|
The maximum linear velocity, in m/s.
TYPE:
|
rot_max
|
The maximum angular velocity, in rad/s.
TYPE:
|
**kwargs
|
Forwarded to :class:
DEFAULT:
|
Source code in eden/utils/devices/spacemouse.py
SE3Spacemouse
¶
SE3Spacemouse(pos_max: float = 1.0, rot_max: float = 1.0, deadzone: float | tuple = 0.3, polling_rate: int = 200, is_toggle_btn0: bool = False, is_toggle_btn1: bool = False)
Bases: Spacemouse
A spacemouse controller for sending SE(3) commands as delta poses and binary command (open/close).
Source code in eden/utils/devices/spacemouse.py
Methods:¶
get_command
¶
Return the transformed command in Eden coordinate system (Z-up).
Source code in eden/utils/devices/spacemouse.py
ImGui slider overlay for manual DOF teleoperation.
Classes¶
SliderGUI
¶
SliderGUI(viewer: Viewer, dofs_name: list[str], dofs_pos_limits: Tensor, reset_callback: Callable[[], None] | None = None, initial_position: Tensor | None = None, plugin: ImGuiOverlayPlugin | None = None)
Bases: DeviceBase
ImGui-based slider device for teleoperation.
Attaches an :class:~genesis.ext.pyrender.overlay.ImGuiOverlayPlugin to the Genesis
viewer and registers a custom side panel with one slider per named DoF plus a Reset
button. The current slider values are polled via :meth:get_command, shape (1, n_dofs).
| PARAMETER | DESCRIPTION |
|---|---|
viewer
|
The viewer to attach the overlay to, typically
TYPE:
|
dofs_name
|
Labels for each slider (used as the ImGui label text). |
dofs_pos_limits
|
Tensor of shape
TYPE:
|
reset_callback
|
Optional callback invoked on the viewer thread when the Reset button is pressed.
TYPE:
|
initial_position
|
Initial slider values (also used as the Reset target). If
TYPE:
|
plugin
|
Existing overlay plugin to register the panel on. If
TYPE:
|
Source code in eden/utils/devices/gui_slider.py
Attributes¶
Methods:¶
get_raw_data
¶
get_command
¶
set_command
¶
Overwrite the slider readings programmatically (clipped into the slider limits).
For syncing the panel to an externally-known pose — e.g. a real-robot teleop keeping the sliders glued to the measured joint positions until control is handed over, so the first commanded target equals the current pose instead of jumping to wherever the sliders were left.
| PARAMETER | DESCRIPTION |
|---|---|
values
|
New slider values, shape
TYPE:
|
Source code in eden/utils/devices/gui_slider.py
GELLO leader arm¶
GELLO leader-arm runtime and configuration.
Attributes¶
Classes¶
GelloLeaderConfig
dataclass
¶
GelloLeaderConfig(joint_ids: Sequence[int], joint_offsets_rad: Sequence[float], joint_signs: Sequence[int], gripper_id: int | None = None, gripper_open_deg: float | None = None, gripper_close_deg: float | None = None, baudrate: int = 57600)
Calibration config for a single-arm Gello leader.
| ATTRIBUTE | DESCRIPTION |
|---|---|
joint_ids |
Dynamixel IDs for the arm DOFs, in controller order. |
joint_offsets_rad |
Offset (rad) subtracted from each raw joint reading. Typically multiples of pi/2. |
joint_signs |
+1 or -1 per arm DOF to match the follower's positive rotation direction. |
gripper_id |
(Optional) Dynamixel ID for the gripper DOF.
TYPE:
|
gripper_open_deg |
(Optional) Gripper position in degrees when fully open.
TYPE:
|
gripper_close_deg |
(Optional) Gripper position in degrees when fully closed.
TYPE:
|
baudrate |
Serial baudrate for Dynamixel communication.
TYPE:
|
Attributes¶
Methods:¶
to_dict
¶
Source code in eden/utils/devices/gello/leader.py
save
¶
Save config to path. Format is inferred from the extension (.json, .yaml, .yml).
Source code in eden/utils/devices/gello/leader.py
from_dict
classmethod
¶
from_dict(data: dict[str, Any]) -> GelloLeaderConfig
load
classmethod
¶
load(path: str | Path) -> GelloLeaderConfig
Load config from path. Format is inferred from the extension (.json, .yaml, .yml).
Source code in eden/utils/devices/gello/leader.py
GelloLeader
¶
GelloLeader(port: str, config: GelloLeaderConfig, start_dofs: ndarray | Sequence[float] | None = None)
Gello leader agent: reads calibrated joint positions from Dynamixel hardware.
The arm DOFs are returned in radians after applying
(raw - offset) * sign. When a gripper is configured, the gripper DOF is
normalized to [0, 1] with 0 == open and 1 == closed.
Source code in eden/utils/devices/gello/leader.py
Attributes¶
Methods:¶
get_command
¶
Return calibrated arm DOFs (rad) concatenated with a normalized gripper value (if any).
Source code in eden/utils/devices/gello/leader.py
Functions:¶
run_gello_leader
¶
Source code in eden/utils/devices/gello/leader.py
GELLO leader-arm joint-offset calibration.
Attributes¶
Classes¶
Functions:¶
get_offset
¶
get_offset(current_dofs: Sequence[float] | ndarray, start_dofs: Sequence[float] | ndarray, signs: Sequence[float] | ndarray, max_pi_multiple: int = 8) -> ndarray
Search per-joint offsets (multiples of pi/2) that align current DOFs with known start DOFs.
For each joint i, picks the offset that minimizes
abs(signs[i] * (current_dofs[i] - offset) - start_dofs[i])
over offset in [-max_pi_multiple * pi, +max_pi_multiple * pi] stepped by pi/2.
Source code in eden/utils/devices/gello/calibration.py
format_offsets_as_pi_half_terms
¶
Return each offset formatted as k*np.pi/2.
Source code in eden/utils/devices/gello/calibration.py
estimate_gripper_range_deg
¶
estimate_gripper_range_deg(current_gripper_rad: float, open_delta_deg: float = -0.2, close_delta_deg: float = -42.0) -> tuple[float, float]
Estimate gripper open/close positions (degrees) from the current resting position.
The gripper is assumed to be held near its open position during calibration.
Source code in eden/utils/devices/gello/calibration.py
calibrate_gello_leader
¶
Source code in eden/utils/devices/gello/calibration.py
76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 | |
Dynamixel servo driver for the GELLO leader arm.
Attributes¶
Classes¶
DynamixelDriver
¶
Minimal Dynamixel position driver for Gello leader arms.
Reads joint positions (rad) and, when torque is enabled, commands goal positions.
Source code in eden/utils/devices/gello/dynamixel_driver.py
Attributes¶
Methods:¶
set_torque_mode
¶
set_torque_mode(enable: bool)
Source code in eden/utils/devices/gello/dynamixel_driver.py
get_positions
¶
Source code in eden/utils/devices/gello/dynamixel_driver.py
set_positions
¶
Source code in eden/utils/devices/gello/dynamixel_driver.py
ZMQ server bridging the GELLO leader arm to the sim follower.
Attributes¶
Classes¶
GelloZMQServer
¶
GelloZMQServer(env: EnvBase, arm_entity_name: str, arm_dofs_name: list[str], ee_link_name: str, *, gripper_entity_name: str | None = None, gripper_dofs_name: list[str] | None = None, host: str = 'localhost', port: int = 5001, verbose: bool = False)
Bases: DeviceBase
ZMQ RPC server exposing a sim arm (and optional two-DOF gripper) as a GELLO follower.
A :class:~eden.utils.devices.gello.zmq_client.GelloClient (e.g. eden gello leader) sends
command_joint_state requests, which :meth:get_command returns; the observation methods read
the follower's state from env.
| PARAMETER | DESCRIPTION |
|---|---|
env
|
The environment to use.
TYPE:
|
arm_entity_name
|
The name of the arm entity.
TYPE:
|
arm_dofs_name
|
The names of the arm DOFs. |
ee_link_name
|
The name of the end-effector link.
TYPE:
|
gripper_entity_name
|
(Optional) The name of the gripper entity.
TYPE:
|
gripper_dofs_name
|
(Optional) The names of the gripper DOFs. |
host
|
The host to use.
TYPE:
|
port
|
The port to use.
TYPE:
|
verbose
|
Whether to log verbose output.
TYPE:
|
Source code in eden/utils/devices/gello/zmq_server.py
68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 | |
Attributes¶
Methods:¶
start
¶
Source code in eden/utils/devices/gello/zmq_server.py
stop
¶
Source code in eden/utils/devices/gello/zmq_server.py
get_raw_data
¶
Functions:¶
ZMQ client for the GELLO teleoperation server.
Attributes¶
Classes¶
GelloClient
¶
Minimal ZMQ REQ client for :class:GelloZMQServer.
Exposes the four RPC methods served by the Gello follower server:
num_dofs, get_joint_positions, get_observations, and command_joint_state.
Source code in eden/utils/devices/gello/zmq_client.py
Meta Quest¶
A WebXR bridge process (televuer) streams controller poses to Eden over ZMQ.
XR bridge process for Meta Quest teleoperation.
Owns the televuer XR session: it spins up a Vuer WebXR server that the Meta Quest connects to over Wi-Fi, captures head / wrist poses plus either the hand-joint poses or the motion-controller state, and displays the stereo frames that Eden renders.
WebXR exposes hand tracking and motion controllers as mutually exclusive
input sources, so the bridge runs in one of two modes (--tracking):
* hand : head/wrist poses + 25 hand-joint positions + pinch/squeeze
* controller : head/wrist poses + per-hand trigger / grip / A,B buttons /
thumbstick (button + 2D axis)
Data flow (see protocol.py): * publishes head/wrist + hand-or-controller state -> Eden (PUB on POSE_PORT) * receives stereo RGB frames <- Eden (PULL on FRAME_PORT) -> headset
Each published dict carries a "mode" key ("hand" or "controller") so
the Eden side knows which fields to expect.
All published poses are converted from the OpenXR basis (x right, y up, z back) into the Genesis/robot basis (x forward, y left, z up) so Eden can consume them directly in its world frame.
Requires the metaquest extra and televuer, which is not on PyPI
(uv sync --inexact --extra metaquest --group metaquest-git in a checkout).
Run: eden metaquest bridge # hand tracking (default) eden metaquest bridge --tracking controller # motion controllers # or, equivalently: uv run python -m eden.utils.devices.metaquest.xr_bridge
Functions:¶
to_robot_se3
¶
to_robot_points
¶
build_pose_msg
¶
Snapshot the current hand-tracking XR state into a JSON dict (robot frame).
Source code in eden/utils/devices/metaquest/xr_bridge.py
build_controller_msg
¶
Snapshot the current motion-controller XR state into a JSON dict (robot frame).
left_wrist / right_wrist are the controller grip poses (already in the
robot frame). Button / trigger / grip booleans and analog values plus the
thumbstick click + 2D axis come straight from the WebXR gamepad mapping:
*_trigger_value/*_squeeze_value: 0.0 (released) -> 1.0 (fully pressed)*_thumbstick_value: (x, y) in [-1, 1], +x right, +y back (see televuer)
Source code in eden/utils/devices/metaquest/xr_bridge.py
main
¶
Run the XR bridge loop (parses argv or sys.argv when None).
Source code in eden/utils/devices/metaquest/xr_bridge.py
125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 | |
Wire protocol for the Eden <-> XR (televuer) ZMQ bridge.
Two localhost channels, both single-part so ZMQ_CONFLATE (keep-latest) works:
- frames : Eden PUSH -> bridge PULL (FRAME_PORT) stereo RGB uint8 (H, W, 3)
- poses : bridge PUB -> Eden SUB (POSE_PORT) head/wrist/hand JSON state
This module is imported by both processes (the XR bridge and Eden), so it must depend only on numpy + pyzmq -- never on televuer or eden.
Pose matrices published here are already expressed in the Genesis/robot world frame: z up, x forward, y left, metres, origin at the XR tracking origin.
Attributes¶
Functions:¶
send_frame
¶
Send one stereo RGB frame. rgb is (H, W, 3) uint8 (left|right halves).
Source code in eden/utils/devices/metaquest/protocol.py
recv_frame
¶
Receive one frame, or return None if flags=zmq.NOBLOCK and none waiting.
Source code in eden/utils/devices/metaquest/protocol.py
recv_pose
¶
Receive the latest pose dict, or None if flags=zmq.NOBLOCK and none waiting.
frame_push
¶
frame_push(ctx, host='127.0.0.1', port=FRAME_PORT)
Eden side: PUSH that drops frames rather than blocking when the bridge lags.
Source code in eden/utils/devices/metaquest/protocol.py
Clutch-relative pose mapping: XR controller pose -> end-effector target.
Ported from Dream-Machines-Robotics/vr-teleop-kit (Apache-2.0), with the
upstream mujoco quaternion calls swapped for Genesis' numpy-backed wxyz helpers
(genesis.utils.geom) — no quaternion math is reimplemented here. The
algorithm and its hardware-tuned defaults are unchanged; see
tests/utils/devices/metaquest/test_pose_mapping.py for the ported sanity checks (T1-T11).
ClutchPoseMapper holds the "engage" state — the controller pose and the EE
pose captured at the moment the operator pressed the clutch (grip) button. While
the clutch is held, the EE target is the engaged EE pose composed with an
effective controller delta, rotated from the XR world frame into the arm's base
frame. On release the mapper disengages and target() returns None until
the next engage, so the operator can reposition their hand without moving the
robot (clutch-relative mapping, like lifting a mouse off the desk).
Reach limit (the interesting part). When the caller passes the arm's current
EE pose into target(), the position/orientation channels become INCREMENTAL:
per-tick controller deltas accumulate into an effective delta, and anything past
pos_reach_limit / rot_reach_limit of the current EE pose is absorbed
(a "slipping clutch"). This kills two failure modes the upstream authors hit on
hardware:
- an absolute orientation map pushed far past a joint stop builds up the ~180° error where the shortest-way direction flips sign -> cap-speed shaking;
- 350° CW and 10° CCW are the same orientation, so an absolute map eventually "snaps" the wrist around the other way. The incremental one never can.
The absorbed overshoot does not come back when the operator reverses (mouse at
the edge of the screen: reversing bites immediately). Trade-off: with
scale_rotation != 1 the per-increment gain is a rate gain, so curved hand
paths are path-dependent on SO(3); re-clutching realigns. 0/None on
either reach limit restores the legacy absolute-delta-since-engage mapping.
Frames: all quaternions are wxyz. R maps XR-world vectors into the arm base
(v_armbase = R @ v_xr); for an Eden bridge that already publishes poses in
the Genesis/robot world frame, R is identity.
Classes¶
ClutchPoseMapper
dataclass
¶
ClutchPoseMapper(R: ndarray = (lambda: eye(3))(), scale: float = 1.0, scale_rotation: float = 1.0, rotation_pivot: ndarray | None = None, rot_reach_limit: float | None = 0.6, pos_reach_limit: float | None = 0.25)
One-handed clutch-relative controller -> EE mapping.
Attributes¶
Methods:¶
set_R
¶
Replace the XR->arm-base rotation. Typically called per engage with a yaw-corrected R so the operator can turn their body between engagements and still have 'controller forward' mean 'robot forward'.
Source code in eden/utils/devices/metaquest/pose_mapping.py
engage
¶
engage(controller_pos_xr: ndarray, controller_quat_xr: ndarray, ee_pos_armbase: ndarray, ee_quat_armbase: ndarray, pivot_armbase: ndarray | None = None) -> None
Capture the engage frame. Call on the rising clutch edge. If
pivot_armbase is supplied, rotation deltas during this engagement
pivot around that point instead of around the EE.
Source code in eden/utils/devices/metaquest/pose_mapping.py
disengage
¶
target
¶
target(controller_pos_xr: ndarray, controller_quat_xr: ndarray, ee_pos_armbase: ndarray | None = None, ee_quat_armbase: ndarray | None = None) -> tuple[ndarray, ndarray] | None
Compute the current EE target in arm-base frame, or None if disengaged.
ee_pos_armbase / ee_quat_armbase are the arm's CURRENT EE pose;
passing them enables the pos/rot reach limits (see the class docstring).
Without them the legacy absolute mapping applies unchanged.
Source code in eden/utils/devices/metaquest/pose_mapping.py
154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 | |
PICO¶
The XRoboToolkit tracking client, the bridge process, and the Remote Vision video link that streams Eden's stereo frames to the headset.
PICO tracking client: XRoboToolkit PC Service state -> Genesis/robot frame.
The PICO headset does not talk to Eden directly. It streams its tracking state to
the XRoboToolkit PC Service running on this host, and the service exposes it
through the xrobotoolkit_sdk extension module. :class:PicoXrClient wraps
that module and hands Eden ready-to-use state:
- poses as 4x4 matrices in the Genesis/robot world frame (z up, x forward, y left, metres, origin at the headset's tracking origin),
- controller buttons / triggers / grips / thumbsticks by name,
- the PICO-only extras: paired motion trackers and, in Full-body mode, the 24 SMPL-ordered body joints.
The service reports poses in the OpenXR basis (x right, y up, -z forward), so
every pose is conjugated into the robot basis exactly as Eden's Meta Quest
bridge does — :data:R_ROBOT_XR here is the same matrix as televuer's
R_ROBOT_OPENXR and as XRoboToolkit's own R_HEADSET_TO_WORLD.
:func:xr_pose_to_se3 and friends are module-level and SDK-free so the frame
conversion is testable without a headset; only :class:PicoXrClient needs the
extension module, which it imports on construction.
Install: build xrobotoolkit_sdk from
https://github.com/XR-Robotics/XRoboToolkit-PC-Service-Pybind (it needs the PC
Service's libPXREARobotSDK.so; setup_ubuntu.sh fetches and builds both).
The service's shared library is preloaded from its install directory when the
dynamic linker can't find it, so no LD_LIBRARY_PATH export is needed.
Attributes¶
R_ROBOT_XR
module-attribute
¶
BODY_JOINT_NAMES
module-attribute
¶
BODY_JOINT_NAMES = ('pelvis', 'left_hip', 'right_hip', 'spine1', 'left_knee', 'right_knee', 'spine2', 'left_ankle', 'right_ankle', 'spine3', 'left_foot', 'right_foot', 'neck', 'left_collar', 'right_collar', 'head', 'left_shoulder', 'right_shoulder', 'left_elbow', 'right_elbow', 'left_wrist', 'right_wrist', 'left_hand', 'right_hand')
Classes¶
PicoXrClient
¶
Live PICO tracking state, converted into Eden's world frame.
Constructing the client initialises the SDK (which connects to the PC Service
on this host); call :meth:close when done. Every getter is a cheap read of
the state the service pushes in the background, so calling them once per sim
step is fine.
Source code in eden/utils/devices/pico/xr_client.py
Attributes¶
tracking
property
¶
tracking: bool
True once the headset reports a pose (i.e. the app is connected and sending).
Methods:¶
pose
¶
pose(name: str) -> NDArray[float64]
Robot-frame 4x4 pose of head / left_controller / right_controller.
value
¶
button
¶
axis
¶
Thumbstick (x, y) in [-1, 1] for "left" or "right".
hand_joints
¶
hand_joints(side: str) -> NDArray[float64] | None
Robot-frame (26, 3) hand-joint positions, or None when hand tracking is off.
This is the OpenXR 26-joint layout (palm included), not the 25-joint
WebXR one the Meta Quest bridge publishes — index it with the OpenXR
XrHandJointEXT order.
Source code in eden/utils/devices/pico/xr_client.py
trackers
¶
PICO motion trackers (up to 5) in the robot frame, keyed by role or serial.
Each value is {"serial", "role", "pos" (3), "quat" (4), "vel" (6),
"acc" (6)}; role is None for a tracker with no tracker_roles
entry, and such a tracker is keyed by its serial instead. vel/acc are
(vx, vy, vz, wx, wy, wz) in the robot frame.
Source code in eden/utils/devices/pico/xr_client.py
body_joints
¶
Full-body joints as {name: {pos, quat, vel, acc}}, or None when unavailable.
Requires Full-body mode in the PICO app with calibrated motion trackers;
keys are :data:BODY_JOINT_NAMES (left_wrist, left_ankle,
left_foot, pelvis, ...). vel/acc are (linear, angular)
6-vectors in the robot frame. A joint the service has not solved yet reports
a degenerate quaternion and is published as identity at the origin.
poses reuses an already-read (24, 7) service-frame array so one
snapshot needs a single SDK pose read; velocities/accelerations are
always read live.
Source code in eden/utils/devices/pico/xr_client.py
body_raw
¶
Untouched service-frame body poses: (timestamp_ns, (24, 7) xyz+xyzw lists).
Deliberately no frame conversion, no untracked-joint substitution, no
wxyz flip — for consumers that apply their own frame math to exactly
what the SDK reports (the SONIC teleop pipelines). The timestamp is
get_time_stamp_ns (not the body-specific stamp) read immediately
before the poses, matching the in-process reader's dedupe order
(eden.extensions.teleop.sonic.inputs.PicoReader).
Source code in eden/utils/devices/pico/xr_client.py
state
¶
Snapshot every input as a JSON-safe dict, in the Genesis/robot frame.
The controller keys deliberately match what eden metaquest bridge
--tracking controller publishes (head, {side}_wrist,
{side}_trigger[_value], {side}_squeeze[_value],
{side}_a_button/_b_button, {side}_thumbstick[_value]), so an
Eden-side teleop loop runs against either headset unchanged. PICO's extras
are additive: {side}_menu_button, trackers (see :meth:trackers),
body_ready and body (see :meth:body_joints), plus the lossless
body_raw / body_raw_timestamp_ns passthrough (see :meth:body_raw).
derived_body=False skips the per-joint body dict and trackers
(a consumer that only reads body_raw — the SONIC operator — saves
most of the per-snapshot SDK work; body_ready then reflects the raw
read).
Source code in eden/utils/devices/pico/xr_client.py
Functions:¶
load_sdk
¶
Import xrobotoolkit_sdk, raising a build hint instead of a bare ImportError.
The pybind module links against the PC Service's libPXREARobotSDK.so,
which lives outside the default loader path. When the import fails on that
library, it is preloaded from the service's install directory and the
import retried — no LD_LIBRARY_PATH export needed.
Source code in eden/utils/devices/pico/xr_client.py
xr_pose_to_se3
¶
Convert one service pose (x, y, z, qx, qy, qz, qw) to a robot-frame 4x4.
Returns :data:UNTRACKED (all zeros) when the quaternion is degenerate, which
is what the service reports for a device it has not seen yet. Eden-side
consumers test np.abs(mat).sum() < 1e-6 for "tracking dropped".
Source code in eden/utils/devices/pico/xr_client.py
xr_pose_to_pos_quat
¶
xr_pose_to_pos_quat(pose: ArrayLike) -> tuple[NDArray[float64], NDArray[float64]] | None
Robot-frame (pos (3,), wxyz quat (4,)) for one service pose, or None if untracked.
Source code in eden/utils/devices/pico/xr_client.py
xr_points_to_robot
¶
Rotate (N, 3) XR-frame points into the Genesis/robot frame.
Source code in eden/utils/devices/pico/xr_client.py
xr_vel6_to_robot
¶
Rotate a 6-vector (vx, vy, vz, wx, wy, wz) velocity/accel into the robot frame.
Both the linear and angular halves are free vectors, so each rotates by
:data:R_ROBOT_XR (a proper rotation, so angular pseudovectors transform the
same way). Used for the motion-tracker and body-joint velocities/accelerations.
Source code in eden/utils/devices/pico/xr_client.py
PICO teleoperation bridge: XRoboToolkit PC Service <-> Eden.
Owns everything device-side of a PICO teleop session so the Eden process only
speaks ZMQ: it reads the headset's tracking state from the PC Service (via
xrobotoolkit_sdk), publishes it in the Genesis/robot world frame, and streams
Eden's rendered stereo frames back to the headset as H.264 over the app's
Remote Vision link.
Data flow (see :mod:eden.utils.devices.pico.protocol for the headset link and
:mod:eden.utils.devices.metaquest.protocol for the ZMQ channels)::
PICO ──Wi-Fi──▶ XRoboToolkit PC Service ──▶ eden pico bridge
│ poses (PUB :5557) ──▶ Eden
headset decoder ◀──H.264 / TCP─────────────┤ frames (PULL :5556) ◀──┘
The published state dict uses the same schema as
eden metaquest bridge --tracking controller, so an Eden-side teleop loop runs
against either headset unchanged; PICO's extras (menu buttons, motion trackers,
full-body joints) are additive keys. See
:meth:eden.utils.devices.pico.xr_client.PicoXrClient.state.
Requires the pico extra (uv sync --inexact --extra pico) plus a locally built
xrobotoolkit_sdk — see :mod:eden.utils.devices.pico.xr_client.
Run: eden pico bridge # or, equivalently: uv run python -m eden.utils.devices.pico.bridge
Attributes¶
Classes¶
Functions:¶
main
¶
Run the PICO bridge loop (parses argv or sys.argv when None).
Source code in eden/utils/devices/pico/bridge.py
49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 | |
Stream Eden's rendered stereo frames to a PICO headset over the Remote Vision link.
:class:PicoVideoLink owns both sockets of the link documented in
:mod:eden.utils.devices.pico.protocol:
- it listens on
:13579for theOPEN_CAMERA/CLOSE_CAMERAcommands the XRoboToolkit app sends when the operator taps Listen / stops, - on
OPEN_CAMERAit connects out to the decoder address the request names and pushes H.264 (or H.265) frames until the app closes the stream.
Two daemon threads do the work — one accepting control connections, one encoding — so the caller only ever does::
link = PicoVideoLink()
link.start()
while ...:
link.push(rgb) # (H, W, 3) uint8 RGB, left|right side-by-side
link.stop()
push() is non-blocking and latest-frame-wins: it drops into a single slot
that the encoder thread drains, so a slow encoder never stalls the sim loop and
the headset always shows the newest frame rather than a growing backlog.
Frames are encoded at the size the headset asked for; a differently sized frame
is rescaled (with a one-time warning) because the app's decoder is configured
from the OPEN_CAMERA request, not from the stream.
The encoder/sender half is ported from SIMPLE's src/simple/teleop/pico/
(MIT, https://github.com/physical-superintelligence-lab/SIMPLE).
Classes¶
PicoVideoLink
¶
PicoVideoLink(address: str = f'0.0.0.0:{CONTROL_PORT}', logger=None)
Control server + video sender for one PICO headset.
Source code in eden/utils/devices/pico/video.py
Attributes¶
streaming
property
¶
streaming: bool
True while a headset is connected and frames are being encoded.
frames_sent
property
¶
frames_sent: int
Frames encoded and written to the headset since the stream opened.
Methods:¶
start
¶
Start accepting control connections (returns immediately).
Source code in eden/utils/devices/pico/video.py
stop
¶
Tear down the stream, the control connection, and both threads.
Source code in eden/utils/devices/pico/video.py
push
¶
Offer one rendered frame ((H, W, 3) uint8 RGB) to the headset.
Non-blocking and latest-wins; a no-op while no headset is streaming.
Source code in eden/utils/devices/pico/video.py
Wire protocol of the Eden <-> PICO (XRoboToolkit) Remote Vision video link.
The XRoboToolkit PICO app drives both directions; Eden is the server on the control channel and the client on the video channel:
-
control (headset -> Eden), TCP
:13579::[4B BE uint32 body_len][body]where
bodyis a little-endianNetworkDataProtocolrecord::[4B LE int32 cmd_len][command][4B LE int32 data_len][data]The app sends
OPEN_CAMERA(data= aCameraRequestDatapayload, see below) when the operator taps Listen, andCLOSE_CAMERA(no data) when they stop. Eden acknowledges each command with the same framing, echoing the command name and an empty data field. -
video (Eden -> headset), TCP to the
ip/porttheOPEN_CAMERArequest names — that is where the app'sMediaDecoderlistens::[4B BE uint32 frame_size][H.264 / H.265 NAL data]
CameraRequestData (the OPEN_CAMERA payload) is::
[0xCA 0xFE] magic
[0x01] version
[7 x 4B LE int32] width, height, fps, bitrate, enable_mv_hevc,
render_mode, port
[1B len + bytes] camera (the app's source profile, e.g. "ZED")
[1B len + bytes] ip (the headset's own address)
This module is pure bytes <-> objects (stdlib only), so the framing is
unit-testable without a headset; the sockets and the encoder live in
:mod:eden.utils.devices.pico.video.
Ported from SIMPLE's src/simple/teleop/pico/protocol.py (MIT,
https://github.com/physical-superintelligence-lab/SIMPLE), which is where the
byte layout above was reverse-engineered from the PICO app.
Attributes¶
Classes¶
CameraRequest
dataclass
¶
CameraRequest(width: int, height: int, fps: int, bitrate: int, hevc: bool, render_mode: int, port: int, camera: str, ip: str)
Functions:¶
encode_message
¶
Frame one control message (length header included) ready to sendall.
Source code in eden/utils/devices/pico/protocol.py
decode_message
¶
Split a control-message body into (command, data).
body is the payload after the 4-byte length header (see
:func:unpack_length). Raises ValueError on a malformed record — the
caller should log it and keep reading rather than tear the connection down,
since the next message may be fine.
Source code in eden/utils/devices/pico/protocol.py
unpack_length
¶
Decode a 4-byte big-endian length header (control body or video frame).
Source code in eden/utils/devices/pico/protocol.py
decode_camera_request
¶
decode_camera_request(data: bytes) -> CameraRequest
Decode an OPEN_CAMERA payload. Raises ValueError if malformed.
Source code in eden/utils/devices/pico/protocol.py
encode_camera_request
¶
encode_camera_request(request: CameraRequest) -> bytes
Encode a :class:CameraRequest back into its wire payload.
Eden never sends this — the PICO app does. It exists so tests (and a fake headset client) can exercise the decoder against the documented layout.
Source code in eden/utils/devices/pico/protocol.py
pack_video_frame
¶
Wuji Glove¶
Wuji Glove teleoperation device: live hand pose + tactile, in Eden's hand frame.
The glove is a plain SDK device — it streams over USB/Ethernet to wuji_sdk running
in this process, so unlike the Meta Quest / PICO headsets there is no bridge process to
stand up. :class:WujiGlove connects, subscribes to the streams you ask for, and keeps
the newest frame of each so every getter is a cheap read that a sim loop can call once
per step.
What it exposes
- hand pose — the 21 MediaPipe landmarks (:func:
~eden.utils.devices.wuji.protocol.skeleton_to_arrays), the IK solver's 21 joint angles, and the 5 fingertip poses. - tactile — the 24x31 palm-and-finger pressure sheet, its zone-grouped view, the calibrated binary/residual contact streams, and the 3D tactile point cloud.
Poses are returned in Eden's hand frame (x forward along the fingers, y left, z up
out of the back of the hand) by default, so an axis means the same physical direction on
either glove — the vendor's +y flips between dorsal and ventral by side. See
:mod:eden.utils.devices.wuji.protocol for the basis change and the data layouts. Pass
frame="wuji" to any getter for the vendor's raw wrist frame instead.
Install the SDK with the wuji extra (uv sync --inexact --extra wuji); it ships manylinux
wheels only, so this is Linux x86_64 / aarch64.
Example
.. code-block:: python
from eden.utils.devices.wuji import WujiGlove
glove = WujiGlove(side="right", streams=("hand_skeleton", "tactile"))
keypoints = glove.get_command() # (21, 3) torch tensor, Eden hand frame
pressure = glove.tactile() # (24, 31), masked taxels are nan
glove.close()
Attributes¶
STREAMS
module-attribute
¶
STREAMS = ('hand_skeleton', 'hand_joint_angles', 'tip_poses', 'emf_poses', 'tactile', 'tactile_zones', 'tactile_binary', 'tactile_residual', 'tactile_point_cloud')
DEFAULT_STREAMS
module-attribute
¶
STREAM_RATE_SOURCE
module-attribute
¶
STREAM_RATE_SOURCE = {'emf_poses': 'emf_poses', 'hand_skeleton': 'emf_poses', 'hand_joint_angles': 'emf_poses', 'tip_poses': 'emf_poses', 'tactile': 'tactile', 'tactile_zones': 'tactile_zones', 'tactile_binary': 'tactile', 'tactile_residual': 'tactile', 'tactile_point_cloud': 'tactile'}
TACTILE_SHEETS
module-attribute
¶
STATE_KEYS
module-attribute
¶
STATE_KEYS = {'hand_skeleton': ('keypoints', 'keypoint_quat', 'keypoint_confidence'), 'tip_poses': ('fingertip_pos', 'fingertip_quat', 'fingertip_confidence'), 'emf_poses': ('emf_pos', 'emf_quat', 'emf_confidence'), 'hand_joint_angles': ('joint_angles', 'joint_angle_confidence'), 'tactile_point_cloud': ('tactile_points', 'tactile_pressure')}
Classes¶
WujiGlove
¶
WujiGlove(*, sn: str | None = None, side: str | None = None, device_name: str | None = None, streams: Sequence[str] = DEFAULT_STREAMS, rate_hz: int | None = None, hand_model_path: str | None = None, exclusive: bool = False)
Bases: DeviceBase
A connected Wuji Glove, streaming hand pose and tactile into Eden.
Constructing the device scans for gloves, connects to one, and opens a background
subscription per requested stream; call :meth:close when done (or use it as a
context manager). Every getter returns None until that stream's first frame has
arrived, so a teleop loop can hold its last pose instead of blocking.
| PARAMETER | DESCRIPTION |
|---|---|
sn
|
Serial number of the glove to connect to.
TYPE:
|
side
|
TYPE:
|
device_name
|
Handle this connection is registered under inside the SDK. It selects no
particular glove, but it does identify the connection to
TYPE:
|
streams
|
Which of :data:
TYPE:
|
rate_hz
|
Lower the device push rate of the rate-adjustable sources
(:data:
TYPE:
|
hand_model_path
|
Custom hand URDF for the glove's online IK, applied before subscribing. Needs a named (non-default) SDK user; without one the SDK's built-in model is used.
TYPE:
|
exclusive
|
Take the glove for this process alone. By default the SDK bridges the device so Wuji Studio / a recorder can read it at the same time.
TYPE:
|
Notes
tactile_binary and tactile_residual are produced by a contact model trained
per SDK user and glove serial. Subscribing to them before running the vendor's
tactile calibration yields an all-zero sheet, not an error, so this device logs a
warning when you ask for one.
Source code in eden/utils/devices/wuji/glove.py
Attributes¶
tracking
property
¶
tracking: bool
True once a hand-pose frame has arrived (the glove is worn and solving).
emf_poses is left out on purpose: the raw coil poses stream whether or not the
IK has solved a hand, so they answer "is the glove powered", not "is it tracking".
Methods:¶
keypoints
¶
keypoints(frame: str = 'hand') -> Tensor | None
(21, 3) MediaPipe landmark positions in metres, or None before the first frame.
Landmarks are in :data:~eden.utils.devices.wuji.protocol.HAND_LANDMARKS order,
wrist-relative, which is exactly what DexRetargeter and the vendor's
RetargetSession consume.
Source code in eden/utils/devices/wuji/glove.py
keypoint_poses
¶
(pos (21, 3), quat (21, 4) wxyz, confidence (21,)), or None before the first frame.
Source code in eden/utils/devices/wuji/glove.py
joint_angle_state
¶
joint_angle_state() -> tuple[Tensor, Tensor] | None
(angles (21,) in radians, confidence (5,)), or None before the first frame.
Both halves come out of one decode; :meth:joint_angles is the view onto the
angles alone, which is what a retargeter reads.
Source code in eden/utils/devices/wuji/glove.py
joint_angles
¶
The IK solver's (21,) joint angles in radians, or None before the first frame.
Packed finger-major in :data:~eden.utils.devices.wuji.protocol.FINGERS order:
5 thumb DOFs (CMC x3, MCP, IP) then 4 per finger (MCP x2, PIP, DIP). This is the
human hand's DOFs, not a robot's — retarget from :meth:keypoints to drive a
robot hand.
Source code in eden/utils/devices/wuji/glove.py
fingertips
¶
(pos (5, 3), quat (5, 4) wxyz, confidence (5,)) fingertip poses, or None.
Thumb first (:data:~eden.utils.devices.wuji.protocol.FINGERS order), from the
glove's IK->FK chain.
Source code in eden/utils/devices/wuji/glove.py
emf_poses
¶
(pos (5, 3), quat (5, 4) wxyz, confidence (5,)) raw EMF coil poses, or None.
The unfiltered sensor product behind the skeleton. Confidence below 0.9 is the vendor's threshold for "unreliable".
Source code in eden/utils/devices/wuji/glove.py
tactile
¶
(24, 31) calibrated pressure in [0, 1], or None before the first frame.
Row-major over the palm-and-finger sheet. The cells the sheet does not populate
are nan (see :func:~eden.utils.devices.wuji.protocol.tactile_grid), so reduce
with torch.nanmean / nan_to_num rather than plain mean.
Source code in eden/utils/devices/wuji/glove.py
tactile_binary
¶
(24, 31) contact mask (1 = contact, nan = masked taxel), or None.
Needs a tactile calibration for this SDK user + glove serial; without one the sheet reads all zero.
Source code in eden/utils/devices/wuji/glove.py
tactile_residual
¶
(24, 31) signed baseline-relative contact signal, or None.
Positive = pressed harder than the calibrated baseline, ~0 = no contact. The
continuous signal behind :meth:tactile_binary; you own the contact threshold.
Masked taxels are nan. Needs the same calibration.
Source code in eden/utils/devices/wuji/glove.py
tactile_taxels
¶
(index, value) for just the taxels stream actually measured, or None.
The node-wise view: one entry per live taxel, dead ones dropped rather than
nan-masked. index is (N, 2) (row, col) into the 24 x 31 sheet,
or (N,) row-major ids when flat=True; value is that taxel's reading.
stream picks which sheet to read — tactile, tactile_binary or
tactile_residual — and must be one this device subscribed to.
See :func:~eden.utils.devices.wuji.protocol.tactile_taxels for why N is not
the point cloud's point count.
Source code in eden/utils/devices/wuji/glove.py
tactile_zones
¶
Pressure grouped by hand region — {"palm", "thumb", ..., "pinky"} — or None.
Each value is a 1-D tensor of that zone's taxels (lengths in
:data:~eden.utils.devices.wuji.protocol.TACTILE_ZONE_SIZES), padding as nan.
Cheaper to reason about than the full sheet when you only need "is the thumb
touching something".
Source code in eden/utils/devices/wuji/glove.py
tactile_points
¶
(points (N, 3), pressure (N,)) for the 3D tactile point cloud, or None.
The 526 real taxels placed on the live hand skeleton, so contact can be drawn in the scene or fed to a contact-conditioned policy.
Source code in eden/utils/devices/wuji/glove.py
get_raw_data
¶
(21, 3) landmarks in the glove's own wrist frame, untransformed.
Zeros until the first hand_skeleton frame arrives, so the shape is stable for
a caller that stacks readings.
Source code in eden/utils/devices/wuji/glove.py
get_command
¶
(21, 3) landmarks in Eden's hand frame — the teleoperation command.
Feed straight into a retargeter (DexRetargeter, wuji_sdk.RetargetSession)
to drive a robot hand. Zeros before the first frame, matching :meth:get_raw_data.
Source code in eden/utils/devices/wuji/glove.py
state
¶
JSON-safe snapshot of every subscribed stream, for logging or a ZMQ bridge.
Only the streams this device subscribed to appear. Tactile sheets are published
as the raw flat 744-value payload with the vendor's
:data:~eden.utils.devices.wuji.protocol.TACTILE_INVALID sentinel intact rather
than the nan-masked grid, because nan is not valid JSON.
Source code in eden/utils/devices/wuji/glove.py
reset
¶
close
¶
Close every subscription and disconnect the glove. Idempotent.
Source code in eden/utils/devices/wuji/glove.py
Functions:¶
load_sdk
¶
Import wuji_sdk, raising an install hint instead of a bare ImportError.
Source code in eden/utils/devices/wuji/glove.py
scan_gloves
¶
List every Wuji Glove on the bus as {"sn", "address", "transport"} dicts.
A connection-free probe: use it to find a serial to pass to :class:WujiGlove. The
glove's side is not discoverable here — it takes a connection to read.
Source code in eden/utils/devices/wuji/glove.py
Wuji Glove data layout: stream shapes, landmark tables, and the basis change into Eden.
Everything here is pure numpy and duck-typed over the SDK message objects, so the
whole decode path is testable without a glove (or without wuji_sdk installed).
:mod:eden.utils.devices.wuji.glove owns the connection and the subscriptions.
Frames
Wuji publishes hand data in the wrist frame (l_wrist / r_wrist), which the
vendor defines anatomically (docs, Coordinate Frames)::
x = radial (ulnar -> radial, i.e. toward the thumb)
z = proximal (distal -> proximal, i.e. along the bone toward the elbow)
y = right-hand rule, so it MIRRORS between hands:
left glove +y = dorsal (back of the hand)
right glove +y = ventral (palm)
So +y means the opposite physical direction on the two gloves. Eden pins every axis
to anatomy instead, in the Genesis basis — x forward along the fingers, y left, z up —
so :data:R_HAND_WUJI maps::
x_eden = -z_wuji (distal, out along the fingers)
z_eden = +y_wuji (left) / -y_wuji (right) (dorsal, out of the back of the hand)
y_eden = z_eden x x_eden = -x_wuji (left) / +x_wuji (right)
Both per-side matrices are proper rotations (det = +1), and the per-side sign flip is exactly the vendor's own note that "left-hand glove data is mirrored from the right hand, with the Y direction inverted". Note what this does not buy: y is radial on a right hand and ulnar on a left one, because a left hand is a mirrored right hand and no right-handed frame can make the two agree. x (distal) and z (dorsal) do agree, which is what a retargeter and a pose-plotting recipe actually read.
Tactile
The tactile matrix is a fixed 24 x 31 row-major grid of 744 taxels, only some of
which are real; the rest are permanently pinned to :data:TACTILE_INVALID (-1.0).
Two counts describe "real" and they disagree by eight — read :data:N_ACTIVE_TAXELS and
:data:N_UNMASKED_TAXELS before hard-coding either, here or in prose. That sentinel is a
number, so grid.mean() over a raw frame is silently wrong —
:func:tactile_grid therefore turns masked taxels into nan, which fails loudly
instead. Use :func:tactile_valid for the boolean mask, or :func:tactile_taxels for
the node-wise view: one (index, value) entry per taxel that actually measured
something, with the dead ones dropped rather than masked.
Attributes¶
HAND_LANDMARKS
module-attribute
¶
HAND_LANDMARKS = ('wrist', 'thumb_cmc', 'thumb_mcp', 'thumb_ip', 'thumb_tip', 'index_finger_mcp', 'index_finger_pip', 'index_finger_dip', 'index_finger_tip', 'middle_finger_mcp', 'middle_finger_pip', 'middle_finger_dip', 'middle_finger_tip', 'ring_finger_mcp', 'ring_finger_pip', 'ring_finger_dip', 'ring_finger_tip', 'pinky_mcp', 'pinky_pip', 'pinky_dip', 'pinky_tip')
FINGERTIP_LANDMARKS
module-attribute
¶
FINGERTIP_LANDMARKS = tuple(i for i, name in enumerate(HAND_LANDMARKS) if name.endswith('_tip'))
HAND_CONNECTIONS
module-attribute
¶
HAND_CONNECTIONS = ((0, 1), (0, 5), (0, 17), (5, 9), (9, 13), (13, 17), (1, 2), (2, 3), (3, 4), (5, 6), (6, 7), (7, 8), (9, 10), (10, 11), (11, 12), (13, 14), (14, 15), (15, 16), (17, 18), (18, 19), (19, 20))
TACTILE_ZONE_SIZES
module-attribute
¶
R_HAND_WUJI
module-attribute
¶
R_HAND_WUJI = {'left': np.array([[0.0, 0.0, -1.0], [-1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]), 'right': np.array([[0.0, 0.0, -1.0], [1.0, 0.0, 0.0], [0.0, -1.0, 0.0]])}
Q_HAND_WUJI
module-attribute
¶
Q_HAND_WUJI = {side: R_to_quat(R) for side, R in R_HAND_WUJI.items()}
HAND_TO_MANO
module-attribute
¶
Functions:¶
side_from_sn
¶
Guess a glove's side from its serial number, or None if the letter is unfamiliar.
Lets a caller connect to the likely glove first instead of probing every one, without
ever trusting the guess: whoever uses it must still confirm against hand_side().
Source code in eden/utils/devices/wuji/protocol.py
check_side
¶
check_frame
¶
wuji_points_to_hand
¶
wuji_points_to_hand(points: ArrayLike, side: str) -> NDArray[float64]
Rotate (N, 3) wrist-frame points into the Eden hand frame for side.
Source code in eden/utils/devices/wuji/protocol.py
wuji_pose_to_hand
¶
wuji_pose_to_hand(position: ArrayLike, orientation_xyzw: ArrayLike, side: str) -> tuple[NDArray[float64], NDArray[float64]]
Convert one wrist-frame pose into the Eden hand frame.
| PARAMETER | DESCRIPTION |
|---|---|
position
|
TYPE:
|
orientation_xyzw
|
The pose's
TYPE:
|
side
|
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
tuple
|
|
Source code in eden/utils/devices/wuji/protocol.py
keypoints_to_mano
¶
Hand-frame (21, 3) landmarks -> wrist-origin MANO keypoints for a retargeter.
Feed the result to DexRetargeter.extract_ref_value exactly as you would a
MediaPipe detection's joint_pos. Pass keypoints in Eden's hand frame (the default
:meth:~eden.utils.devices.wuji.glove.WujiGlove.keypoints output), not the vendor's
wrist frame.
Source code in eden/utils/devices/wuji/protocol.py
skeleton_to_arrays
¶
skeleton_to_arrays(skeleton: Any, side: str, frame: str = 'hand') -> tuple[NDArray[float64], NDArray[float64], NDArray[float64]]
Decode a HandSkeleton frame into (pos (21, 3), quat (21, 4), confidence (21,)).
Landmarks come back in :data:HAND_LANDMARKS (MediaPipe) order, positions in metres.
frame="hand" rotates into Eden's hand basis; frame="wuji" leaves the vendor's
wrist basis alone.
Source code in eden/utils/devices/wuji/protocol.py
fingertips_to_arrays
¶
fingertips_to_arrays(tips: Any, side: str, frame: str = 'hand') -> tuple[NDArray[float64], NDArray[float64], NDArray[float64]]
Decode a FingertipPoses frame into (pos (5, 3), quat (5, 4), confidence (5,)).
Fingers are in :data:FINGERS order (thumb first).
Source code in eden/utils/devices/wuji/protocol.py
emf_poses_to_arrays
¶
emf_poses_to_arrays(emf: Any, side: str, frame: str = 'hand') -> tuple[NDArray[float64], NDArray[float64], NDArray[float64]]
Decode an EmfPoseArray frame into (pos (5, 3), quat (5, 4), confidence (5,)).
These are the raw receiver-coil poses in :data:FINGERS order. They are reported in
the emf_tx (back-of-hand transmitter) frame, which is a pure translation off the
wrist frame, so the rotation applied here is the wrist one — the coil offset is not
removed. Confidence below 0.9 means the vendor considers the estimate unreliable.
Source code in eden/utils/devices/wuji/protocol.py
pack_joint_angles
¶
Pack a HandJointAngles frame's 5x5 padded array into the 21 real DOFs.
Returns (angles (21,) in radians, confidence (5,)). The thumb contributes all
five of its slots, every other finger the first four (:data:FINGER_NUM_DOFS); the
padding slot is dropped rather than passed on as a fake zero-angle joint.
Source code in eden/utils/devices/wuji/protocol.py
tactile_valid
¶
(24, 31) boolean mask of the taxels this frame actually measured.
tactile_grid
¶
Reshape a flat tactile payload into the (24, 31) row-major grid, masked to nan.
Works for every sheet-shaped stream — tactile (pressure in [0, 1]),
tactile_binary (1 = contact), and tactile_residual (signed, baseline-relative).
The cells the sheet does not populate arrive as :data:TACTILE_INVALID and become
nan here, so a stray .mean() reports nan instead of a plausible-looking
number biased toward -1.
Source code in eden/utils/devices/wuji/protocol.py
tactile_taxels
¶
tactile_taxels(data: Sequence[float], flat: bool = False) -> tuple[NDArray[int64], NDArray[float64]]
One entry per taxel this frame actually measured: (index, value).
The node-wise view of a sheet-shaped stream. :func:tactile_grid keeps the full
24 x 31 layout with the dead taxels as nan; this drops them, so value is
every real reading and nothing else — iterate it, reduce it, or feed it somewhere
without first writing the nan filter yourself.
| PARAMETER | DESCRIPTION |
|---|---|
data
|
The stream's flat 744-value payload ( |
flat
|
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
tuple
|
|
Source code in eden/utils/devices/wuji/protocol.py
tactile_zones_to_dict
¶
Decode a TactileZones frame into {zone: (n,) pressures}, masked to nan.
Zones are :data:TACTILE_ZONE_SIZES (palm plus one per finger); the length of each
array is fixed by the hardware, and a length mismatch raises rather than silently
reindexing a caller's per-zone slice.
Source code in eden/utils/devices/wuji/protocol.py
decode_point_cloud
¶
Decode a PointCloud frame into (points (N, 3), pressure (N,)).
The payload is a packed byte buffer, so the field descriptors are validated against
:data:POINT_CLOUD_FIELDS / :data:POINT_CLOUD_STRIDE first: a firmware change to
the layout must raise here rather than reinterpret the bytes into plausible garbage.
Points are in the glove's wrist frame — pass them through :func:wuji_points_to_hand
for the Eden hand frame.
Source code in eden/utils/devices/wuji/protocol.py
eden wuji — find a Wuji Glove and watch its hand-pose / tactile streams live.
The first thing to run after plugging a glove in: it proves the SDK sees the device, that the glove is solving a hand pose, and that the tactile sheet responds to a touch, without building a Genesis scene.
Run::
eden wuji scan # list the gloves on the bus
eden wuji monitor # live status, Ctrl-C to stop
eden wuji monitor --side right --seconds 30
eden wuji monitor --streams hand_skeleton tactile_zones --rate-hz 30
# or, equivalently:
uv run python -m eden.utils.devices.wuji.monitor monitor