Robot setup for deployment¶
This page is the one-time setup and the robot-specific behaviour of each hardware backend
eden deploy drives: the SDK to install, how the robot is reached, its remote, and what its wire
does differently. Read Deployment first for the loop, the operator state machine
and the safety mechanisms every backend shares. None of this applies to eden deploy --mujoco,
which needs no SDK.
| Robot | Backend | SDK (Python import) | Operator inputs |
|---|---|---|---|
| Unitree G1 | UnitreeG1Deployment |
unitree_sdk2py + CycloneDDS |
G1 remote, keyboard |
| Unitree Go2 | UnitreeGo2Deployment |
unitree_sdk2py + CycloneDDS |
keyboard |
| Unitree Go1 | UnitreeGo1Deployment |
robot_interface (legacy unitree_legged_sdk) |
keyboard |
| LimX Oli | LimxOliDeployment |
limxsdk |
LimX remote, keyboard |
| Franka Panda / FR3 | FrankaPandaDeployment |
panda_py |
keyboard |
| RoboTera XHand | RoboTeraXHandDeployment |
xhand_controller |
keyboard |
| Wuji Hand 1 / Hand 2 | WujiHandDeployment |
wujihandpy / wuji_sdk |
keyboard |
Every SDK is imported at connect() (or guarded at import), so a missing one never breaks
import eden; the backend raises with an install hint when it connects.
Before any robot¶
ONNX runtime¶
On a G1 29-DOF, eden deploy preloads the Holosoma locomotion policy as the boot-active safety slot
(--base_policy auto), and Holosoma, like sonic and omnixtreme, is an ONNX policy. Install one
of the two mutually exclusive runtime extras:
Without it, the policy fails at startup, before anything connects, with onnxruntime is not
installed and both install commands.
The two wheels share the onnxruntime/ package directory. To switch, reinstall the one you want
on top of the other: pip install --force-reinstall --no-deps "onnxruntime-gpu==<its version>", or
uv sync --inexact --extra onnx-gpu --reinstall-package onnxruntime-gpu in a checkout (the error
Eden prints gives the exact command). Never uninstall either wheel: that deletes files the other
needs, and Eden then fails fast on the half-removed install with a message saying so.
The CPU wheel is fast enough for the deployed policies, because Eden bounds every session's thread pool (see Control-loop timing).
SDKs live outside the lockfile¶
The robot SDKs below (except Wuji Hand 2's, which is the wuji extra) are installed into the venv
by hand and are not declared in pyproject.toml. A plain uv sync removes packages the lockfile
does not list, these included: reinstall them after one, or sync with --inexact.
State-check recipes¶
Before running a policy on a new robot, run its state-check recipe under
examples/recipes/deployment/: each reads the real robot through its backend, mirrors it in sim and
prints a side-by-side comparison of every quantity the observation terms use, flagging mismatches.
| Recipe | Robot |
|---|---|
real_g1_states.py |
Unitree G1 |
real_oli_states.py, real_oli_teleoperation_slider.py (joint sliders in the viewer) |
LimX Oli |
real_franka_states.py, real_franka_teleoperation_keyboard.py |
Franka Panda |
real_xhand.py (same sinusoid to sim and real) |
XHand |
real_wuji_hand2.py (open and close, reports tracking) |
Wuji Hand 2 |
The G1 and Franka recipes are run with LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libstdc++.so.6, as
their headers show. The headers do not say why. If eden deploy on the same machine fails to import the robot's
SDK with a GLIBCXX_... not found error, preload the same library.
Unitree G1¶
UnitreeG1Deployment drives G1Dofs23, G1Dofs29 and G1Dofs29NoHands over the Unitree SDK2
DDS low-level interface (unitree_hg): LowState_ on rt/lowstate, a CRC-stamped LowCmd_ on
rt/lowcmd, joints mapped to motor slots by name.
Install the SDK and CycloneDDS¶
The backend imports unitree_sdk2py, which needs the CycloneDDS native library (libddsc.so.0).
When it is missing, connect() raises:
RuntimeError: Unitree SDK (unitree_sdk2py) failed to import. Install it and ensure it
(and its native deps, e.g. libddsc.so.0 from CycloneDDS) are on the loader path.
Underlying error: ModuleNotFoundError: No module named 'unitree_sdk2py'
-
Build CycloneDDS once and point
CYCLONEDDS_HOMEat the install prefix; thecycloneddsPython wheel the SDK pins (cyclonedds==0.10.2) builds against it.git clone https://github.com/eclipse-cyclonedds/cyclonedds -b releases/0.10.x cmake -S cyclonedds -B cyclonedds/build -DCMAKE_INSTALL_PREFIX=$HOME/cyclonedds/install -DBUILD_IDLC=ON cmake --build cyclonedds/build --target install # persist it (the loader and the cyclonedds build both read this) echo 'export CYCLONEDDS_HOME=$HOME/cyclonedds/install' >> ~/.bashrc export CYCLONEDDS_HOME=$HOME/cyclonedds/install$CYCLONEDDS_HOME/libmust containlibddsc.so.0. -
Install the SDK into the Eden venv, editable so it stays linked to the checkout:
-
Verify both import:
If the SDK was installed without its native CRC library (unitree_sdk2py/utils/lib/crc_*.so),
Eden falls back to the SDK's pure-Python CRC and logs a warning; it is slower but well within a
50 Hz loop's budget.
Network interface¶
The DDS channel binds to the first wired interface (en* / eth*) that is up, skipping loopback
and virtual ones (docker*, br-*, veth*, tailscale*, tun*, tap*). Override it with
GS_UNITREE_NETWORK_INTERFACE (also read: UNITREE_NETWORK_INTERFACE, CYCLONEDDS_NETWORK_INTERFACE),
or the backend option network_interface. With no usable interface, connect() raises naming the
variable. The same applies to the Go2.
Connecting¶
connect() first releases any high-level motion mode the robot is running (through the SDK's
MotionSwitcherClient), then waits up to connect_timeout_s (eden deploy passes 10 s) for the
first LowState_. Nothing goes on the wire before the first command.
Remote: A START, B STOP, Y INIT, L1 + R1 KILL, Select next policy slot (L1 + Select is ignored so it does not collide with other chords). The keyboard keys work alongside it.
Publish rate on the G1¶
The G1 has parallel-linkage joints, the ankles always and, on the 3-DOF-waist 29-DOF build, waist
roll and pitch too, driven in PR (pitch/roll) space via LowCmd_.mode_pr. The per-packet PD
mechanism described in Republishing the wire command
applies, so the backend re-sends the held LowCmd_ between control ticks at publish_rate,
default 500 Hz. 500 Hz is the rate both vendor references feed the robot at, independent of any
policy rate: Unitree's own g1_low_level_example publishes at control_dt_ = 0.002, and NVIDIA's
SONIC G1 deploy runs a dedicated 500 Hz command-writer thread beside its 50 Hz control thread.
Inferred on the G1, measured on the Oli
The per-packet damping fault was measured by a torque fit on Oli hardware captures. Nothing
equivalent has been measured on a G1: the mechanism and the 500 Hz default come from the two
vendor references. --publish_rate 0 restores one packet per tick, which is the A/B to run
before concluding anything about a particular robot. The hardware example scripts under
examples/policies/ take the same flag, spelled --publish-rate. --debug_record writes the
commanded and measured history (including stamp), and the re-send loop logs the packet rate
it achieved when it stops.
On shutdown the backend holds the last pose it read at kp = 0 for shutdown_damping_steps ticks,
with the re-send loop still running, before it drops the channel.
Unitree Go2¶
UnitreeGo2Deployment drives Go2 over the same SDK2 DDS stack as the G1 (unitree_go), so the
G1 install and network steps apply
unchanged. It has no remote reader: use the keyboard.
- Publish rate. The backend re-sends its held
LowCmd_atpublish_rate, default 500 Hz, for parity with the vendor, not to fix a fault: the Go2's twelve joints are all serial (itsLowCmd_has nomode_pr), so the parallel-linkage mechanism does not apply. 500 Hz is what Unitree's own Go2 low-level example feeds the robot (go2_stand_example.py:dt = 0.002, written from a 2 msRecurrentThread).--publish_rate 0sends one packet per tick. The re-send loop's health reporting applies unchanged. - Go2W inherits this binding but is refused at construction: its wheel motors have no confirmed SDK motor index or velocity-mode mapping, and guessing them on real hardware is unsafe.
Unitree Go1¶
The Go1 predates the DDS stack. UnitreeGo1Deployment uses the legacy unitree_legged_sdk,
exposed as the robot_interface pybind module (synchronous UDP, not CycloneDDS), so the CycloneDDS
setup above does not apply.
Setup¶
-
Build and install the
robot_interfacemodule from theunitree_legged_sdkPython wrapper (python -m build --wheel, then install the wheel into the venv), or pointEDEN_GO1_SDK_PATHat the directory holdingrobot_interface.*.so:export EDEN_GO1_SDK_PATH=/path/to/unitree_go1_sdk uv run python -c "import robot_interface; print('OK')"When missing,
connect()raisesRuntimeError: Unitree Go1 SDK (the legacy 'robot_interface' module) failed to import, with both options. - No ONNX runtime is needed: Holosoma is a G1-only policy, and--base_policy autoloads no base policy on a Go1, so your--checkpointis slot 1. - Put the robot in low-level mode on the remote: L2 + A → L2 + B → L1 + L2 + start.
Running¶
connect()primes the bidirectional UDP link: the robot only streams state to a client it has heard from, so the backend sends a limp command (kp = kd = tau = 0) while it waits for the first state. A timeout means the robot is not powered, not in low-level mode, or not reachable atrobot_ip:robot_port(default192.168.123.10:8007) from local UDP portlocal_port(default 8080).--power_protect_factor Nsets the SDK'sSafety.PowerProtectfactor,1..10= 10%..100% of rated motor power, default 10. Lower factors clamp the torque a gait needs (the backend's source records factor 7 freezing themove_forwardGo1 mid-stance), and the SDK examples' value of1starves locomotion entirely and tripsError: Power Protection. Lower it (for example to5) only for cautious bench bring-up with the robot supported, then raise it back toward10.- Stand-up. INIT is a joint-space ramp that is blind to where the feet are (see
Gains and the INIT ramp). If the robot starts from an
awkward stance, some legs fight the ground to reach their target angles; that shows up as a large
persistent tracking error on those joints and drives their motors into thermal derating, so
the leg goes weak and the robot freezes or sags. Lift the robot and set it down squarely, feet
roughly under the hips, before INIT; wait for
INIT reached the target poseand confirm full height before START.
Monitoring¶
--debug_recordadditionally records, per step, each motor's temperature and mode and the battery (x_motor_temp,x_motor_mode,x_battery_v,x_battery_soc).- The backend warns, once per joint until it recovers, when a motor reports Overheat mode or reaches
motor_temp_warn_c(default 80 °C). A derating motor is the usual cause of a frozen or weak leg: let the motors cool between runs. - If a leg is stuck with its measured
taufar below the commanded PD demand (kp·(cmd − q) − kd·qd, reconstructable from the record), the motor is limiting itself (temperature, battery), not the controller or the policy.
Go1 safety
connect() touches the real robot. Keep the e-stop in reach and the robot supported for first
runs. The default --power_protect_factor 10 (100%) leaves no PowerProtect margin; dial it
back while commissioning. Do not disable load-bearing leg joints on an unsupported robot.
LimX Oli¶
LimxOliDeployment drives Oli (HU_D04) through LimX's low-level SDK, limxsdk.
Install¶
The SDK is not on PyPI; install the platform wheel shipped in the
limxsdk-lowlevel repo:
git clone https://github.com/limxdynamics/limxsdk-lowlevel
uv pip install limxsdk-lowlevel/python3/amd64/limxsdk-*.whl # aarch64/ on the onboard PC
uv run python -c "import limxsdk.robot, limxsdk.datatypes; print('OK')"
Address¶
The backend connects to the robot_ip option, else the ROBOT_IP environment variable, else
10.192.1.2 (the robot's address on its own network). ROBOT_IP=127.0.0.1 targets the
LimX MuJoCo simulator, which speaks the same
protocol on localhost.
Hardware preparation¶
Follow LimX's procedure; both steps are required. Hang the robot, power it on, click the right joystick button on the remote to activate the motors, then press R1 + DOWN to enter developer mode (it persists across reboots; exit with R1 + LEFT).
The failure modes are distinctive. Outside developer mode the onboard motion controller keeps
commanding the motors, so SDK commands fight it: the motors buzz and shake. In developer mode
with the motors not activated, SDK commands are silently ignored: the robot stays limp. From the
moment a deploy boots (STOP, holding), the robot should feel stiff; limp means the activation
step is missing. The backend prints this at reset() too.
Remote¶
The remote is PS4-style (□ square, △ triangle, ✕ cross, ○ circle); LimX's docs and SDK comments name the same buttons with Xbox-style letters (✕ = A, ○ = B, □ = X, △ = Y). The chords follow LimX's own deploy tooling where it has a counterpart:
| Chord | Command |
|---|---|
| L1 + △ | INIT |
| R1 + □ | START |
| L1 + ✕ | STOP |
| L1 + □ | KILL (soft-damp and exit) |
| L1 + ○ | Next policy slot |
Wire behaviour¶
- Joint order by name. Motor order is mapped by name from the robot's live
getMotorNames()at connect, never by position (LimX's docs and controller files disagree on the head-joint order). If a robot reports names that do not match the URDF joint names even after_joint-suffix normalization, map them with themotor_name_mapoption (Eden DOF name → SDK motor name). - PR space. Commands are sent in PR (serial-joint) space with the SDK's
parallel_solve_requiredflag set, so the SDK handles the parallel ankle and waist linkages, matching Eden's serialHU_D04_01.urdfand LimX's own RL deployment stack. - Publish rate is load-bearing on this robot. In PR mode the robot evaluates the PD law of its
six parallel-linkage joints (ankle pitch/roll, waist roll/pitch) once per received packet, on
that packet's state. Measured on hardware (recorded in
eden/extensions/deployment/utils/republisher.py): with one packet per tick their torque fitskp·(q_des − q) − kd·dq[previous packet]at R² 0.92–0.99 while the serial joints fit the same-instant velocity, and the waist-roll linkage rang at 25 Hz once a foot strike kicked it; at 1 kHz the audible buzz is gone and the >10 Hz torque RMS drops from 23.7 to 2.1 N·m. The backend re-sends the held command atpublish_rate, default 1000 Hz (LimX's own rate).--publish_rate 0sends one packet per tick, for A/B only. - Torque clamp. Every commanded target is clamped to
q ± soft_torque_limit · budget / kparound the measured pose, so the position term of the implied PD torque never exceeds the per-joint budget (soft_torque_limitdefaults to 0.95, like LimX's walk controller; 0 disables it). The budget is the robot'sdefault_dofs_force_limitswith theclamp_torque_limitsoption on top, whose default is LimX's deploy-timeuser_torque_limitwhere that differs from Eden's training limits: 80 N·m on the six parallel ankle and waist joints (each driven by two 42 N·m motors), while training, and the action scale derived from it, keeps 42. The clamp runs after the target low-pass, on every command path (INIT, hold, policy). Unlike LimX's clamp, the window is not shifted bykd·dq/kp: theirs is re-evaluated every 1 ms with fresh state, Eden's once per 20 ms tick on the previous tick's state, and while the waist rang the SDK's reported velocity for the parallel joints was 8–11× the derivative of the reported position, so that term pinned the command to the window edge on 11–28% of walking ticks and amplified the oscillation. - Diagnostics. Error-level robot diagnostics (motor faults, overheat, …) are logged as
[deploy] robot diagnostic '<name>': level=… code=…whenever they change. Set theverbose_diagnosticsoption (--verbose-diagnosticson the slider recipe) to also log warning and OK transitions.
--menagerie under --mujoco loads LimX's own MJCF for the Oli, with the linkages as equality
constraints; see Which model is loaded.
Franka Panda and FR3¶
FrankaPandaDeployment drives FrankaEmikaPanda, FrankaResearch3 and FrankaEmikaPandaNoHand
through panda-python (panda_py): the 7-DOF arm on panda-py's real-time JointPosition
controller at 1 kHz inside libfranka, the Franka Hand through libfranka's blocking Gripper API
from a worker thread, so a step never blocks on the gripper.
Install and prepare¶
-
panda-pythonwheels ship inside per-libfranka zips on panda-py's GitHub releases, with no direct wheel URL; install the one whose libfranka matches your controller's server version (server v4 → libfranka 0.8.0). The comment above thefrankaextra inpyproject.tomlhas the download-and-install commands for that case. Without it,connect()raisespanda-python package is not installed. -
Activate FCI in Desk and release the joint brakes.
- A PREEMPT_RT kernel is strongly recommended for stable 1 kHz control; without it, expect
occasional
communication_constraints_violationreflexes. robot_ipdefaults to192.168.1.1.
Behaviour¶
- Gains. The arm runs panda-py's own stiffness (
arm_stiffness/arm_damping, panda-py's defaults when unset), not the command's, so--deploy_kp_kd_scaleand--disable_jointsdo not reach it, and each policy slot runs at the same gains. - Smoothing.
arm_filter_coeff(default 0.05; panda-py's own is 1.0) low-passes the setpoint inside the 1 kHz loop: at policy rates the controller sees abrupt setpoint changes, which make the arm buzz under PD. Lower it if it still oscillates, raise it toward 1.0 for responsiveness. - Velocity clamp. Each step's commanded joint delta is scaled down, direction preserved, so the
implied joint velocity stays within
velocity_safety_factor(default 0.5) of the datasheet limits; without it, a differential-IK step near a high-Jacobian configuration can trip libfranka'sjoint_velocity_violationreflex. - Boot. The arm boots in STOP holding its measured pose (the first command starts the
controller at that setpoint); INIT ramps it to
init_targetoverinit_steps, with the velocity clamp. - The gripper is the policy's alone. The hold, INIT and the START tick leave it where the policy
last commanded it, so STOP or INIT never opens a grasp. With
use_grasp_on_close(default on), a target width belowclose_width_threshold(0.04 m) closes with force-controlledgrasp(gripper_force, default 20 N), which holds objects during arm motion; wider targets usemove.target_lpfsmooths the arm only.skip_gripper_homingskips homing when an object is already held. - Position terms only. Torque-mode action terms (
OperationalSpaceController) are refused at construction; useDifferentialIKControlleror another position-mode term. - The arm has no IMU:
read_statereports an identity base orientation.
RoboTera XHand¶
RoboTeraXHandDeployment drives XHand1_R / XHand1_L through the vendor's xhand_controller
package. It is not on PyPI and no Eden extra installs it: get it from RoboTera with the hand's SDK.
- Bus.
protocolis"RS485"(default;serial_port/dev/ttyUSB0,baud_rate3000000) or"EtherCAT"(the first EtherCAT port found).hand_id0 selects the first enumerated hand; a configured ID that is not present raises, listing the ones that are. CRC failures are retriedcrc_retriestimes (default 3) before raising. - The hand runs its own PD on firmware gains, programmed once at connect from
dofs_kp/dofs_ki/dofs_kd(defaults 50 / 0 / 25) andtor_max(300), all in firmware units with no known mapping to N·m/rad;connectlogs what it programs. Only positions are sent per command. So--deploy_kp_kd_scaleother than 1.0 and--disable_jointsare refused at construction: lower the firmware gains instead.finger_modeis 0 powerless, 3 position (default), 5 powerful. - Joint velocity is a finite difference of consecutive positions over wall-clock time (the SDK reports none), zero on the first read: noisier and a period later than the simulator's.
- The hand has no IMU:
read_statereports an identity orientation.
Wuji Hand 1 and Hand 2¶
WujiHandDeployment (eden/extensions/deployment/wuji_hand.py) is bound to WujiHand1* and
WujiHand2Beta2*, left and right.
Install¶
- Hand 1 needs
wujihandpy(pip install wujihandpy==1.5.1, the version upstream pins). - Hand 2 needs
wuji_sdk, thewujiextra:pip install "eden-world[wuji]", oruv sync --inexact --extra wujiin a checkout. It is Linux-only (manylinux wheels for x86-64 and aarch64); elsewhere the extra installs nothing.
Both are imported at connect, so neither is needed to construct the backend or to run it against a
simulator.
Wuji SDK telemetry
Importing wuji_sdk starts a background worker that sends anonymous usage events to a
third-party analytics host. Set WUJI_TELEMETRY_DISABLE=1 in the environment to turn it off;
Eden does not set it for you.
Behaviour¶
- Side and joint order come from the joint names. Both SDKs speak 20 angles, finger-major and
thumb first, the same for either hand. The backend maps
{right,left}_finger{1-5}_joint{1-4}(Hand 1 and thewuji-mjlabtask models) and{r,l}_thumb_cmc_flex…{r,l}_pinky_dip(Hand2Beta2) onto that order, so a robot may declare its DOFs in any order. A robot outside those classes must name its generation:WujiHandDeployment.configure(hand="hand2"). -
Gains are programmed once, at connect. Hand 2 takes MIT
mit_kp/mit_kd(one value, or a pattern-keyed dict covering all 20) and a current limiteffort_limit_a(default 1.0 A). Hand 1 runs its own position loop, so it refusesdeploy_kp_kd_scaleand gains; soften it witheffort_limit_a(default 0.5 A). Both refuse--disable_joints.Hand 2's default MIT gains are the simulation's
Left unset,
mit_kp/mit_kddefault to the robot'sdefault_dofs_kp/default_dofs_kdtimesdeploy_kp_kd_scale, which forWujiHand2Beta2are 50 / 5, simulation gains. The hand's firmware ships with kp 3.0, kd 0.05, which is whatexamples/recipes/deployment/real_wuji_hand2.pyprograms. Set them explicitly on real hardware, and compare them with what your policy trained at: the deploy warns per slot when the two differ. -
Streaming.
stream_mode="sync"(default) interpolatesservo_hz(default 200) sub-targets across each control period and blocks until the period's deadline, chained from the previous one, so a tick is one control period and the inference time is absorbed into it (the base rate limiter is not stacked on top)."thread"ramps from a background thread and the rate limiter paces the loop. A tick with nothing to send holds the last target for the period, so a"sync"loop never spins faster than the control rate. - Safety. Lost joint feedback always stops the run (raising
DeployStop): on Hand 2 a joint feed older thanstale_after_s(default 0.5 s), on Hand 1 a position read that gets no answer within it. A latched hardware fault stops it unlessstop_on_fault=False. Both are checked before every control tick (hold, INIT and policy alike) and before every waypoint ofease_to.clip_targets_to_joint_limitsclamps every target to the built robot's range,ease_toincluded. - Loop order. The backend reads the hand before the commands publish
(
sync_before_commands), and it advances the env's episode clock once per tick, only while the policy drives, restarting it at every START (advances_episode_clock), so a warm-up gated on it runs from the handover however long the hand held and INITed before. - No IMU. The base orientation is the robot's
default_root_quat, and joint velocity is a finite difference of consecutive reads (vel_lowpass_hzsmooths it). - Extending it. A subclass overrides
sync_scene()to write anything else it observes into the scene after each read (a camera-tracked object) andcheck_safety()to add its own stop conditions.use_transport(transport)swaps the hardware for a simulator, for sim-to-sim through the same code path: anyHandTransportbuilt with the deployment'sdep.stream.
See also¶
- Deployment: the deploy loop, state machine, safety, timing and MuJoCo sim-to-sim
- Installation: extras and the
uv/pipinstall commands - Environment variables: every variable Eden reads
- Teleoperation: driving these robots from a human operator