Step-by-step guide for deploying the video-to-robot teleoperation pipeline on a physical Unitree G1 humanoid.
Prerequisite: Complete the Reproduction Guide first. You should have a working sim pipeline (Stages 1-3) before attempting hardware deployment.
Check the Glossary for definitions of technical terms used in this guide.
The GR00T-WBC control loop supports both simulation and real hardware through a single --interface flag. When you switch from --interface sim (the default) to --interface real, the following things change:
| Aspect | Simulation (sim) |
Hardware (real) |
|---|---|---|
| Communication | Loopback DDS (local) | CycloneDDS over Ethernet UDP |
| Physics | MuJoCo simulates gravity, contacts, motors | Real motors, real gravity, real consequences |
| Odometry | Full state from sim | Disabled (position fixed at [0,0,0]) |
| Waist pitch KD | Default value | Reduced by factor of 10 (sim2real gap compensation) |
| Hand calibration | Skipped | Runs automatically on startup |
| Locomotion controller | Not present | Must be explicitly released via remote controller |
| Joint velocity violations | Warning only | Hard exit (sys.exit(1)) |
| Elastic band | Virtual spring holds robot | Not applicable — use physical harness instead |
The motion publisher (publish_upper_body_from_results.py) is identical in both modes. It publishes joint targets over DDS regardless of whether the control loop is talking to a simulator or real hardware.
gr00t_wbc-deploy-root:latest).pkl file from Stage 2 of the pipelineThe workstation communicates with the G1 over a direct Ethernet connection. The robot has two RJ45 ports on its neck — use either one.
Plug an Ethernet cable from the robot’s neck RJ45 port to your workstation’s Ethernet port.
Find your Ethernet interface name:
ip link show
Look for the interface that is plugged in (state UP or DOWN but physically connected). Common names are eth0, enp3s0, eno1, etc. Wireless interfaces (wlan0, wlp*) are not what you want.
Configure it with the required static IP:
# Replace <IFACE> with your Ethernet interface name (e.g., enp3s0)
sudo ip link set <IFACE> up
sudo ip addr flush dev <IFACE>
sudo ip addr add 192.168.123.222/24 dev <IFACE>
The robot must be powered on for this step (see Section 2). Once it is booted:
ping -c 3 192.168.123.164
You should see replies. If not:
ssh unitree@192.168.123.164
# Password: 123
This confirms bidirectional network access. Exit the SSH session before proceeding.
Use the hanging method — suspend the robot from its shoulder buckles so its feet are off the ground. This prevents falls during initialization and gives you a safe window to verify everything before the robot bears its own weight.
Attach the robot’s shoulder buckles to the suspension frame or overhead harness. The robot’s feet should hang freely, not touching the ground.
Using the Unitree remote controller:
Why release the locomotion controller? The G1 ships with a built-in locomotion controller that claims exclusive access to the motors. GR00T-WBC communicates directly with the motors via SDK2, so the built-in controller must be released first. The
--interface realcode does this automatically viaMotionSwitcherClient, but entering debug mode via the remote controller ensures a clean handoff.
From the workstation:
ping -c 3 192.168.123.164
If this works, the robot is booted and the network is ready.
The following example uses video PXL_20260114_214954286 throughout. Replace with your video name as needed.
Copy retarget .pkl files to the Docker-accessible location. The deploy Docker container mounts the directory it is launched from (~/Projects/iolani-g1-workshop/GR00T-WholeBodyControl), so results must be placed there — not in the standalone ~/Projects/GR00T-WholeBodyControl directory used for simulation.
cd ~/Projects/iolani-g1-workshop
# Copy results directly to the deploy Docker path
mkdir -p GR00T-WholeBodyControl/resources/poses
cp results/PXL_20260114_214954286/retarget_unitree_g1.pkl \
GR00T-WholeBodyControl/resources/poses/PXL_20260114_214954286.pkl
Simulation vs. hardware paths: The reproduction guide (simulation) uses the standalone
~/Projects/GR00T-WholeBodyControlviajust groot-copy. For hardware deployment, copy directly to~/Projects/iolani-g1-workshop/GR00T-WholeBodyControlinstead — the deploy container mounts this directory.
Before starting Docker, identify your Ethernet interface:
ip link show
Look for the interface connected to the robot (e.g., enp39s0). You will need this name for the --interface flag.
Enable X11 forwarding for Docker, then start the deploy container with the specific interface name and flags:
xhost +local:docker
cd ~/Projects/iolani-g1-workshop/GR00T-WholeBodyControl && ./docker/run_docker.sh --deploy --root --interface enp39s0 --no-with_hands --no-data_collection --no-enable_upper_body_operation --no-enable_webcam_recording --no-view_camera
Note: Pass the actual Ethernet interface name (e.g.,
enp39s0) instead ofreal. Using--interface realmay fail withdoes not match an available interfaceif CycloneDDS cannot auto-detect the correct interface inside Docker.
The deploy script will:
Y to confirm.The control loop will:
MotionSwitcherClient (may show send request error — this is expected if the robot is already in debug mode via the remote controller).--no-with_hands to avoid hangs).Note: The robot’s arms will move to a default pose as soon as the control loop starts — this is the startup ramp and is expected. The
]key activates the balance policy (legs), not the arm control.
Open a new terminal on the host and attach to the same Docker container:
sudo docker exec -it gr00t_wbc-deploy-root /bin/bash
Inside the container, run the publisher (single line):
python gr00t_wbc/control/main/teleop/publish_upper_body_from_results.py --results resources/poses/PXL_20260114_214954286.pkl --loop --teleop-frequency 30 --hand-mode zero --speed 0.25 --initial-pose-seconds 10.0 --upper-body-only --smooth 3.0 --two-pass
You should see:
[info] applied Gaussian smoothing (sigma=3.0 frames)
[info] clamped N out-of-range joint values to 95% of URDF limits
==================================================
[PASS 1] Playing trajectory once — sim is recording collisions...
[PASS 1] Press ] in Terminal 1 to activate, wait 5s, press 9
==================================================
After pass 1 completes, the publisher automatically reads the collision log, fixes those frames, and starts pass 2 (looping). You do NOT need to restart or re-activate — the control loop stays running.
Start with
--speed 0.25or lower. On real hardware, motions that look fine in sim can generate dangerous torques. Quarter speed is a safe starting point. You can increase gradually once you confirm the robot is stable.
Switch to Terminal 1 (the tmux control pane — use Ctrl+b then arrow keys to navigate between tmux panes). Press keyboard keys in the terminal, not any viewer window.
] to activate the balance policy. You should see Use policy action: True printed.Once the balance policy is active and the robot is holding a stable pose in the harness:
--speed further.To replay a different video, stop the publisher in Terminal 2 (Ctrl+C) and start it again with a different .pkl:
python gr00t_wbc/control/main/teleop/publish_upper_body_from_results.py --results resources/poses/PXL_20260114_215356412.pkl --loop --teleop-frequency 30 --hand-mode zero --speed 0.25 --initial-pose-seconds 10.0 --upper-body-only --smooth 3.0 --two-pass
The control loop in Terminal 1 does not need to be restarted.
There are three ways to stop the robot immediately. The safety operator should know all three:
| Method | How | Effect | When to Use |
|---|---|---|---|
| Remote: L2 + B | Press on Unitree remote controller | Damping mode — all joints gently resist motion, robot slows to a stop | Preferred first response. Robot stays upright if possible. |
| Remote: L2 + Y | Press on Unitree remote controller | Zero torque — all motors instantly turn off | Robot is in danger of damaging itself or surroundings. Robot will collapse. |
Keyboard: backtick (`) |
Press in Terminal 1 | Kills the tmux session running the control loop | Software-level stop from the workstation. |
Order of preference:
Never run the hardware pipeline alone. Always have a safety observer present with the remote controller.
The GR00T-WBC control loop includes a JointSafetyMonitor that continuously checks joint velocities:
| Joint Group | Velocity Limit |
|---|---|
| Arm joints | 6.0 rad/s |
| Hand joints | 50.0 rad/s |
If any joint exceeds its velocity limit, the control loop calls sys.exit(1) immediately. This is intentional — on real hardware, velocity violations are not warnings, they are hard stops. If this happens:
The balance policy (ONNX checkpoint shipped with GR00T-WBC) was trained in simulation and has known limitations:
w/s/a/d keys) may work in sim but are riskier on real hardware.--speed 0.25 is critical.| Gap | Impact | Mitigation |
|---|---|---|
| Waist pitch KD | Automatically reduced by 10x in real mode |
Built into the code, no action needed |
| No odometry | Robot does not know its absolute position | Acceptable for upper-body teleoperation |
| Motor friction and backlash | Joints may not reach commanded positions precisely | Start with slow motions to verify tracking |
| Floor surface | Sim assumes flat, high-friction ground | Use flat, non-slip flooring. Avoid carpet or polished surfaces. |
| Cable drag | Ethernet cable exerts force on the robot | Route cable overhead or use enough slack to avoid pulling |
| Parameter | Recommended Value | Why |
|---|---|---|
--speed (publisher) |
0.25 (start here) | Quarter speed keeps torques within the balance policy’s stability envelope |
upper_body_joint_speed (configs.py) |
5.0 rad/s (default) | Must stay below ARM_VELOCITY_LIMIT (6.0 rad/s) to avoid hard shutdown. Tunable per-video via --upper-body-joint-speed CLI arg on the control loop |
--smooth (publisher) |
3.0 (recommended) | Gaussian smoothing of pose data. Removes jitter from noisy PromptHMR tracking. Higher = smoother but less faithful. 0 = disabled |
--two-pass (publisher) |
Recommended for hardware | Two-pass collision removal. Pass 1 plays the trajectory once while the sim logs all self-collisions. Pass 2 fixes those frames by interpolating through them smoothly, then loops. Catches collisions caused by the balance policy’s leg motion that static checks miss |
--collision-free (publisher) |
Alternative to --two-pass |
Static MuJoCo collision check on all frames before playback. Faster but less accurate — doesn’t account for balance policy leg motion. Use --two-pass when possible |
Gradually increase speed only after confirming stability at the current speed. Suggested progression: 0.25 -> 0.35 -> 0.5. Do not exceed 0.5 on first deployment.
| Check | Threshold | Behavior |
|---|---|---|
| Arm joint velocity | 6.0 rad/s (NVIDIA) | Hard exit on real hardware, warning in sim |
| Hand joint velocity | 50.0 rad/s (NVIDIA) | Hard exit on real hardware, warning in sim |
| Arm joint position | 95% of URDF range | Hard exit on real hardware (5% margin before mechanical stop) |
| Shoulder roll position | ±0.75 rad (self-collision-safe) | Publisher clamps via SELF_COLLISION_SAFE_LIMITS — prevents arm-torso contact |
| Self-collision | MuJoCo mesh collision geoms | Warning in sim (logged to /tmp/gr00t_collision_log.jsonl). Use --two-pass to auto-fix |
| Problem | Solution |
|---|---|
ping 192.168.123.164 fails |
Check cable, verify interface IP is 192.168.123.222/24, wait for robot to boot (~1 min) |
| DDS topics not discovered | Ensure Docker is running with --deploy flag (host networking). Check that no firewall blocks UDP on the 192.168.123.0/24 subnet. |
| SSH works but control loop cannot connect | The control loop auto-detects the interface with a 192.168.123.* IP. If you have multiple interfaces in that subnet, disconnect the extras. |
| Problem | Solution |
|---|---|
| Robot does not power on | Verify battery is charged (LED indicators on the robot). Try the power sequence again: short press, then 2+ second long press. |
| L2+R2 does not release locomotion controller | Make sure you pressed L2+B (damping) first. The remote controller must be paired with the robot (it pairs automatically on boot). |
| Robot jerks on startup | This can happen if debug mode was not entered cleanly. Power cycle and repeat Section 2. |
| Problem | Solution |
|---|---|
--interface real cannot find Ethernet interface |
Use the explicit interface name instead (e.g., --interface enp39s0). Run ip link show on the host to find it. |
real: does not match an available interface |
Same as above — pass the actual interface name, not real. |
Joint velocity violation (sys.exit(1)) |
Reduce --speed in the publisher. Default upper_body_joint_speed is now 5.0 rad/s (below 6.0 limit). Add --smooth 3.0 to filter noisy pose data. For per-video tuning, pass --upper-body-joint-speed 4.0 to the control loop. |
| Shoulder roll position safety violation on startup | The robot’s arms naturally rest at ~0 rad shoulder roll after entering debug mode, but the URDF lower bound is 0.19 rad. Combined with the 5% critical margin, the safety monitor triggers immediately. Fix: Widen the shoulder roll bounds in gr00t_wbc/control/robot_model/supplemental_info/g1/g1_supplemental_info.py: change left_shoulder_roll_joint lower bound from 0.19 to -0.5 and right_shoulder_roll_joint upper bound from -0.19 to 0.5. This allows the natural rest position while still catching arms crossing the body. |
| Hand calibration hangs | Use --no-with_hands to skip hand calibration. The publisher uses --hand-mode zero so hands are not needed. Alternatively, wait up to 30 seconds or power cycle the robot’s hands. |
[ClientStub] send request error / 3102 None |
The MotionSwitcherClient cannot reach the robot’s service from Docker. Enter debug mode via the physical remote (L2+B then L2+R2) before starting the control loop. |
| Control loop starts but robot does not move | 1. Verify the robot is in debug/develop mode (L2+B then L2+R2 on the remote). 2. Press ] in Terminal 1 to activate the policy. 3. Verify the publisher is running in Terminal 2. |
Docker image gr00t_wbc-deploy-root not found |
Tag the cached image: sudo docker tag gr00t_wbc-deploy-cache-root:latest gr00t_wbc-deploy-root:latest. Or pull from remote: ./docker/run_docker.sh --install --root. |
| Problem | Solution |
|---|---|
| Robot falls when weight transferred from harness | Raise the robot back into the harness. Reduce --speed. Verify the balance policy is active (Use policy action: True in Terminal 1). |
| Motions look different from sim | Expected due to sim2real gaps. Real motors have friction, backlash, and compliance that the sim does not model. Reduce speed for closer tracking. |
| Battery low warning | Land the robot back in the harness, press L2+B (damping), and shut down gracefully. Do not continue operating on low battery — motor torques may become inconsistent. |
Print this section and keep it near the robot during deployment.
EMERGENCY STOP
Remote: L2 + B (damping — try first)
Remote: L2 + Y (zero torque — robot will fall)
Keyboard: ` (backtick — kills control loop)
STARTUP SEQUENCE
1. Suspend robot in harness
2. Power on (short press + long press)
3. Wait ~1 minute
4. L2 + B (damping mode)
5. L2 + R2 (debug mode)
6. Start control loop: --interface enp39s0
7. Start publisher: --speed 0.25
8. Press ] to activate policy
9. Wait 10 seconds for initial pose
10. Slowly lower harness
NETWORK
Robot: 192.168.123.164
Workstation: 192.168.123.222/24
SSH: unitree@192.168.123.164 (pw: 123)
VELOCITY LIMITS
Arm joints: 6.0 rad/s (hard kill on real hardware)
Hand joints: 50.0 rad/s (hard kill on real hardware)
Position: 95% of URDF range (hard kill on real hardware)
Shoulder roll: ±0.75 rad (self-collision-safe clamp)
upper_body_joint_speed: 5.0 rad/s (InterpolationPolicy cap)
Recommended: --speed 0.25 --smooth 3.0 --two-pass